mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-07-13 13:40:28 +03:00
Merge remote-tracking branch 'origin/master' into staging-next
This commit is contained in:
commit
b3146d4446
8183 changed files with 9734 additions and 8649 deletions
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
x86_64-linux = "/nix/store/00a7rdfwhm6avqkgj68grddbzyz3h6ql-nix-2.24.13";
|
||||
i686-linux = "/nix/store/s6c620v60hfishzi1lbfpryk65lbvg8g-nix-2.24.13";
|
||||
aarch64-linux = "/nix/store/7yg9is1shh3383iwi6qynz3vh91l1f9d-nix-2.24.13";
|
||||
riscv64-linux = "/nix/store/fagjkrx5r6p52xp8qb5581bmnlgp01sn-nix-riscv64-unknown-linux-gnu-2.24.13";
|
||||
x86_64-darwin = "/nix/store/ifby7rrgkkly5pzjnyac90lzvrak3i9y-nix-2.24.13";
|
||||
aarch64-darwin = "/nix/store/b0rbdp6ba2fprprpgsw1a8pplzg0j324-nix-2.24.13";
|
||||
x86_64-linux = "/nix/store/kvqnqgjw3k0xmv3ypzajz3c5wf1pxnbs-nix-2.24.14";
|
||||
i686-linux = "/nix/store/292xy9z1vjmy0888bzadmj9fmq1ccapv-nix-2.24.14";
|
||||
aarch64-linux = "/nix/store/qsy62z6rk31s8s937nvkcdhn0ds62yax-nix-2.24.14";
|
||||
riscv64-linux = "/nix/store/zvrzwzv534zcmhw4ai1hbc4iz229hk3p-nix-riscv64-unknown-linux-gnu-2.24.14";
|
||||
x86_64-darwin = "/nix/store/jc7x6906wyy6csgf6br1gbwkw56nxm4l-nix-2.24.14";
|
||||
aarch64-darwin = "/nix/store/sfrmn30fijs6qpfi7ckjkv2vdr4z590h-nix-2.24.14";
|
||||
}
|
||||
|
|
|
@ -361,6 +361,7 @@
|
|||
./programs/zsh/zsh.nix
|
||||
./rename.nix
|
||||
./security/acme
|
||||
./security/agnos.nix
|
||||
./security/apparmor.nix
|
||||
./security/audit.nix
|
||||
./security/auditd.nix
|
||||
|
|
|
@ -10,16 +10,24 @@ in
|
|||
{
|
||||
options.programs.amnezia-vpn = {
|
||||
enable = lib.mkEnableOption "The AmneziaVPN client";
|
||||
package = lib.mkPackageOption pkgs "amnezia-vpn" { };
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ pkgs.amnezia-vpn ];
|
||||
services.dbus.packages = [ pkgs.amnezia-vpn ];
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
services.dbus.packages = [ cfg.package ];
|
||||
services.resolved.enable = true;
|
||||
|
||||
systemd = {
|
||||
packages = [ pkgs.amnezia-vpn ];
|
||||
services."AmneziaVPN".wantedBy = [ "multi-user.target" ];
|
||||
packages = [ cfg.package ];
|
||||
services."AmneziaVPN" = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = with pkgs; [
|
||||
procps
|
||||
iproute2
|
||||
sudo
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
314
nixos/modules/security/agnos.nix
Normal file
314
nixos/modules/security/agnos.nix
Normal file
|
@ -0,0 +1,314 @@
|
|||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.security.agnos;
|
||||
format = pkgs.formats.toml { };
|
||||
name = "agnos";
|
||||
stateDir = "/var/lib/${name}";
|
||||
|
||||
accountType =
|
||||
let
|
||||
inherit (lib) types mkOption;
|
||||
in
|
||||
types.submodule {
|
||||
freeformType = format.type;
|
||||
|
||||
options = {
|
||||
email = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Email associated with this account.
|
||||
'';
|
||||
};
|
||||
private_key_path = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Path of the PEM-encoded private key for this account.
|
||||
Currently, only RSA keys are supported.
|
||||
|
||||
If this path does not exist, then the behavior depends on `generateKeys.enable`.
|
||||
When this option is `true`,
|
||||
the key will be automatically generated and saved to this path.
|
||||
When it is `false`, agnos will fail.
|
||||
|
||||
If a relative path is specified,
|
||||
the key will be looked up (or generated and saved to) under `${stateDir}`.
|
||||
'';
|
||||
};
|
||||
certificates = mkOption {
|
||||
type = types.listOf certificateType;
|
||||
description = ''
|
||||
Certificates for agnos to issue or renew.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
certificateType =
|
||||
let
|
||||
inherit (lib) types literalExpression mkOption;
|
||||
in
|
||||
types.submodule {
|
||||
freeformType = format.type;
|
||||
|
||||
options = {
|
||||
domains = mkOption {
|
||||
type = types.listOf types.str;
|
||||
description = ''
|
||||
Domains the certificate represents
|
||||
'';
|
||||
example = literalExpression ''["a.example.com", "b.example.com", "*b.example.com"]'';
|
||||
};
|
||||
fullchain_output_file = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Output path for the full chain including the acquired certificate.
|
||||
If a relative path is specified, the file will be created in `${stateDir}`.
|
||||
'';
|
||||
};
|
||||
key_output_file = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Output path for the certificate private key.
|
||||
If a relative path is specified, the file will be created in `${stateDir}`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options.security.agnos =
|
||||
let
|
||||
inherit (lib) types mkEnableOption mkOption;
|
||||
in
|
||||
{
|
||||
enable = mkEnableOption name;
|
||||
|
||||
settings = mkOption {
|
||||
description = "Settings";
|
||||
type = types.submodule {
|
||||
freeformType = format.type;
|
||||
|
||||
options = {
|
||||
dns_listen_addr = mkOption {
|
||||
type = types.str;
|
||||
default = "0.0.0.0:53";
|
||||
description = ''
|
||||
Address for agnos to listen on.
|
||||
Note that this needs to be reachable by the outside world,
|
||||
and 53 is required in most situations
|
||||
since `NS` records do not allow specifying the port.
|
||||
'';
|
||||
};
|
||||
|
||||
accounts = mkOption {
|
||||
type = types.listOf accountType;
|
||||
description = ''
|
||||
A list of ACME accounts.
|
||||
Each account is associated with an email address
|
||||
and can be used to obtain an arbitrary amount of certificate
|
||||
(subject to provider's rate limits,
|
||||
see e.g. [Let's Encrypt Rate Limits](https://letsencrypt.org/docs/rate-limits/)).
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
generateKeys = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable automatic generation of account keys.
|
||||
|
||||
When this is `true`, a key will be generated for each account where
|
||||
the file referred to by the `private_key` path does not exist yet.
|
||||
|
||||
Currently, only RSA keys can be generated.
|
||||
'';
|
||||
};
|
||||
|
||||
keySize = mkOption {
|
||||
type = types.int;
|
||||
default = 4096;
|
||||
description = ''
|
||||
Key size in bits to use when generating new keys.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
server = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
ACME Directory Resource URI. Defaults to Let's Encrypt's production endpoint,
|
||||
`https://acme-v02.api.letsencrypt.org/directory`, if unset.
|
||||
'';
|
||||
};
|
||||
|
||||
serverCa = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
The root certificate (in PEM format) of the ACME server's HTTPS interface.
|
||||
'';
|
||||
};
|
||||
|
||||
persistent = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
When `true`, use a persistent systemd timer.
|
||||
'';
|
||||
};
|
||||
|
||||
startAt = mkOption {
|
||||
type = types.either types.str (types.listOf types.str);
|
||||
default = "daily";
|
||||
example = "02:00";
|
||||
description = ''
|
||||
How often or when to run agnos.
|
||||
|
||||
The format is described in
|
||||
{manpage}`systemd.time(7)`.
|
||||
'';
|
||||
};
|
||||
|
||||
temporarilyOpenFirewall = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
When `true`, will open the port specified in `settings.dns_listen_addr`
|
||||
before running the agnos service, and close it when agnos finishes running.
|
||||
'';
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
description = ''
|
||||
Group to run Agnos as. The acquired certificates will be owned by this group.
|
||||
'';
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
description = ''
|
||||
User to run Agnos as. The acquired certificates will be owned by this user.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config =
|
||||
let
|
||||
configFile = format.generate "agnos.toml" cfg.settings;
|
||||
port = lib.toInt (lib.last (builtins.split ":" cfg.settings.dns_listen_addr));
|
||||
|
||||
useNftables = config.networking.nftables.enable;
|
||||
|
||||
# nftables implementation for temporarilyOpenFirewall
|
||||
nftablesSetup = pkgs.writeShellScript "agnos-fw-setup" ''
|
||||
${lib.getExe pkgs.nftables} add element inet nixos-fw temp-ports "{ tcp . ${toString port} }"
|
||||
${lib.getExe pkgs.nftables} add element inet nixos-fw temp-ports "{ udp . ${toString port} }"
|
||||
'';
|
||||
nftablesTeardown = pkgs.writeShellScript "agnos-fw-teardown" ''
|
||||
${lib.getExe pkgs.nftables} delete element inet nixos-fw temp-ports "{ tcp . ${toString port} }"
|
||||
${lib.getExe pkgs.nftables} delete element inet nixos-fw temp-ports "{ udp . ${toString port} }"
|
||||
'';
|
||||
|
||||
# iptables implementation for temporarilyOpenFirewall
|
||||
helpers = ''
|
||||
function ip46tables() {
|
||||
${lib.getExe' pkgs.iptables "iptables"} -w "$@"
|
||||
${lib.getExe' pkgs.iptables "ip6tables"} -w "$@"
|
||||
}
|
||||
'';
|
||||
fwFilter = ''--dport ${toString port} -j ACCEPT -m comment --comment "agnos"'';
|
||||
iptablesSetup = pkgs.writeShellScript "agnos-fw-setup" ''
|
||||
${helpers}
|
||||
ip46tables -I INPUT 1 -p tcp ${fwFilter}
|
||||
ip46tables -I INPUT 1 -p udp ${fwFilter}
|
||||
'';
|
||||
iptablesTeardown = pkgs.writeShellScript "agnos-fw-setup" ''
|
||||
${helpers}
|
||||
ip46tables -D INPUT -p tcp ${fwFilter}
|
||||
ip46tables -D INPUT -p udp ${fwFilter}
|
||||
'';
|
||||
in
|
||||
lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = !cfg.temporarilyOpenFirewall || config.networking.firewall.enable;
|
||||
message = "temporarilyOpenFirewall is only useful when firewall is enabled";
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.agnos = {
|
||||
serviceConfig = {
|
||||
ExecStartPre =
|
||||
lib.optional cfg.generateKeys.enable ''
|
||||
${pkgs.agnos}/bin/agnos-generate-accounts-keys \
|
||||
--no-confirm \
|
||||
--key-size ${toString cfg.generateKeys.keySize} \
|
||||
${configFile}
|
||||
''
|
||||
++ lib.optional cfg.temporarilyOpenFirewall (
|
||||
"+" + (if useNftables then nftablesSetup else iptablesSetup)
|
||||
);
|
||||
ExecStopPost = lib.optional cfg.temporarilyOpenFirewall (
|
||||
"+" + (if useNftables then nftablesTeardown else iptablesTeardown)
|
||||
);
|
||||
ExecStart = ''
|
||||
${pkgs.agnos}/bin/agnos \
|
||||
${if cfg.server != null then "--acme-url=${cfg.server}" else "--no-staging"} \
|
||||
${lib.optionalString (cfg.serverCa != null) "--acme-serv-ca=${cfg.serverCa}"} \
|
||||
${configFile}
|
||||
'';
|
||||
Type = "oneshot";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
StateDirectory = name;
|
||||
StateDirectoryMode = "0750";
|
||||
WorkingDirectory = "${stateDir}";
|
||||
|
||||
# Allow binding privileged ports if necessary
|
||||
CapabilityBoundingSet = lib.mkIf (port < 1024) [ "CAP_NET_BIND_SERVICE" ];
|
||||
AmbientCapabilities = lib.mkIf (port < 1024) [ "CAP_NET_BIND_SERVICE" ];
|
||||
};
|
||||
|
||||
after = [
|
||||
"firewall.target"
|
||||
"network-online.target"
|
||||
"nftables.service"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
};
|
||||
|
||||
systemd.timers.agnos = {
|
||||
timerConfig = {
|
||||
OnCalendar = cfg.startAt;
|
||||
Persistent = cfg.persistent;
|
||||
Unit = "agnos.service";
|
||||
};
|
||||
wantedBy = [ "timers.target" ];
|
||||
};
|
||||
|
||||
users.groups = lib.mkIf (cfg.group == name) {
|
||||
${cfg.group} = { };
|
||||
};
|
||||
|
||||
users.users = lib.mkIf (cfg.user == name) {
|
||||
${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
description = "Agnos service user";
|
||||
group = cfg.group;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
|
@ -147,34 +147,34 @@ let
|
|||
else
|
||||
throw "Invalid database driver: ${cfg.database.driver}";
|
||||
|
||||
mattermostPluginDerivations =
|
||||
with pkgs;
|
||||
map (
|
||||
plugin:
|
||||
stdenv.mkDerivation {
|
||||
name = "mattermost-plugin";
|
||||
installPhase = ''
|
||||
mkdir -p $out/share
|
||||
cp ${plugin} $out/share/plugin.tar.gz
|
||||
'';
|
||||
dontUnpack = true;
|
||||
dontPatch = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
preferLocalBuild = true;
|
||||
}
|
||||
) cfg.plugins;
|
||||
mattermostPluginDerivations = map (
|
||||
plugin:
|
||||
pkgs.stdenvNoCC.mkDerivation {
|
||||
name = "${cfg.package.name}-plugin";
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/share
|
||||
ln -sf ${plugin} $out/share/plugin.tar.gz
|
||||
runHook postInstall
|
||||
'';
|
||||
dontUnpack = true;
|
||||
dontPatch = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
preferLocalBuild = true;
|
||||
}
|
||||
) cfg.plugins;
|
||||
|
||||
mattermostPlugins =
|
||||
with pkgs;
|
||||
if mattermostPluginDerivations == [ ] then
|
||||
null
|
||||
else
|
||||
stdenv.mkDerivation {
|
||||
pkgs.stdenvNoCC.mkDerivation {
|
||||
name = "${cfg.package.name}-plugins";
|
||||
nativeBuildInputs = [ autoPatchelfHook ] ++ mattermostPluginDerivations;
|
||||
nativeBuildInputs = [ pkgs.autoPatchelfHook ] ++ mattermostPluginDerivations;
|
||||
buildInputs = [ cfg.package ];
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out
|
||||
plugins=(${
|
||||
escapeShellArgs (map (plugin: "${plugin}/share/plugin.tar.gz") mattermostPluginDerivations)
|
||||
|
@ -187,6 +187,7 @@ let
|
|||
GZIP_OPT=-9 tar -C "$hash" -cvzf "$out/$hash.tar.gz" .
|
||||
rm -rf "$hash"
|
||||
done
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
dontUnpack = true;
|
||||
|
@ -254,8 +255,8 @@ let
|
|||
}
|
||||
);
|
||||
|
||||
mattermostConfJSON = pkgs.writeText "mattermost-config.json" (builtins.toJSON mattermostConf);
|
||||
|
||||
format = pkgs.formats.json { };
|
||||
finalConfig = format.generate "mattermost-config.json" mattermostConf;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
|
@ -454,9 +455,9 @@ in
|
|||
the options specified in services.mattermost will be generated
|
||||
but won't be overwritten on changes or rebuilds.
|
||||
|
||||
If this option is disabled, changes in the system console won't
|
||||
be possible (default). If an config.json is present, it will be
|
||||
overwritten!
|
||||
If this option is disabled, persistent changes in the system
|
||||
console won't be possible (the default). If a config.json is
|
||||
present, it will be overwritten at service start!
|
||||
'';
|
||||
};
|
||||
|
||||
|
@ -480,7 +481,20 @@ in
|
|||
description = ''
|
||||
Plugins to add to the configuration. Overrides any installed if non-null.
|
||||
This is a list of paths to .tar.gz files or derivations evaluating to
|
||||
.tar.gz files.
|
||||
.tar.gz files. You can use `mattermost.buildPlugin` to build plugins;
|
||||
see the NixOS documentation for more details.
|
||||
'';
|
||||
};
|
||||
|
||||
pluginsBundle = mkOption {
|
||||
type = with types; nullOr package;
|
||||
default = mattermostPlugins;
|
||||
defaultText = ''
|
||||
All entries in {config}`services.mattermost.plugins`, repacked
|
||||
'';
|
||||
description = ''
|
||||
Derivation building to a directory of plugin tarballs.
|
||||
This overrides {option}`services.mattermost.plugins` if provided.
|
||||
'';
|
||||
};
|
||||
|
||||
|
@ -508,7 +522,8 @@ in
|
|||
type = with types; attrsOf (either int str);
|
||||
default = { };
|
||||
description = ''
|
||||
Extra environment variables to export to the Mattermost process, in the systemd unit.
|
||||
Extra environment variables to export to the Mattermost process
|
||||
from the systemd unit configuration.
|
||||
'';
|
||||
example = {
|
||||
MM_SERVICESETTINGS_SITEURL = "http://example.com";
|
||||
|
@ -524,11 +539,11 @@ in
|
|||
for mattermost (see [the Mattermost documentation](https://docs.mattermost.com/configure/configuration-settings.html#environment-variables)).
|
||||
|
||||
Settings defined in the environment file will overwrite settings
|
||||
set via nix or via the {option}`services.mattermost.extraConfig`
|
||||
set via Nix or via the {option}`services.mattermost.extraConfig`
|
||||
option.
|
||||
|
||||
Useful for setting config options without their value ending up in the
|
||||
(world-readable) nix store, e.g. for a database password.
|
||||
(world-readable) Nix store, e.g. for a database password.
|
||||
'';
|
||||
};
|
||||
|
||||
|
@ -639,13 +654,13 @@ in
|
|||
if cfg.database.driver == "postgres" then
|
||||
{
|
||||
sslmode = "disable";
|
||||
connect_timeout = 30;
|
||||
connect_timeout = 60;
|
||||
}
|
||||
else if cfg.database.driver == "mysql" then
|
||||
{
|
||||
charset = "utf8mb4,utf8";
|
||||
writeTimeout = "30s";
|
||||
readTimeout = "30s";
|
||||
writeTimeout = "60s";
|
||||
readTimeout = "60s";
|
||||
}
|
||||
else
|
||||
throw "Invalid database driver ${cfg.database.driver}";
|
||||
|
@ -653,13 +668,13 @@ in
|
|||
if config.mattermost.database.driver == "postgres" then
|
||||
{
|
||||
sslmode = "disable";
|
||||
connect_timeout = 30;
|
||||
connect_timeout = 60;
|
||||
}
|
||||
else if config.mattermost.database.driver == "mysql" then
|
||||
{
|
||||
charset = "utf8mb4,utf8";
|
||||
writeTimeout = "30s";
|
||||
readTimeout = "30s";
|
||||
writeTimeout = "60s";
|
||||
readTimeout = "60s";
|
||||
}
|
||||
else
|
||||
throw "Invalid database driver";
|
||||
|
@ -687,7 +702,7 @@ in
|
|||
};
|
||||
|
||||
settings = mkOption {
|
||||
type = types.attrs;
|
||||
inherit (format) type;
|
||||
default = { };
|
||||
description = ''
|
||||
Additional configuration options as Nix attribute set in config.json schema.
|
||||
|
@ -786,7 +801,7 @@ in
|
|||
"d= ${tempDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
|
||||
# Ensure that pluginDir is a directory, as it could be a symlink on prior versions.
|
||||
"r- ${pluginDir} - - - - -"
|
||||
# Don't remove or clean it out since it should be persistent, as this is where plugins are unpacked.
|
||||
"d= ${pluginDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
|
||||
# Ensure that the plugin directories exist.
|
||||
|
@ -801,15 +816,14 @@ in
|
|||
"L+ ${cfg.dataDir}/client - - - - ${cfg.package}/client"
|
||||
]
|
||||
++ (
|
||||
if mattermostPlugins == null then
|
||||
# Create the plugin tarball directory if it's a symlink.
|
||||
if cfg.pluginsBundle == null then
|
||||
# Create the plugin tarball directory to allow plugin uploads.
|
||||
[
|
||||
"r- ${cfg.dataDir}/plugins - - - - -"
|
||||
"d= ${cfg.dataDir}/plugins 0750 ${cfg.user} ${cfg.group} - -"
|
||||
]
|
||||
else
|
||||
# Symlink the plugin tarball directory, removing anything existing.
|
||||
[ "L+ ${cfg.dataDir}/plugins - - - - ${mattermostPlugins}" ]
|
||||
# Symlink the plugin tarball directory, removing anything existing, since it's managed by Nix.
|
||||
[ "L+ ${cfg.dataDir}/plugins - - - - ${cfg.pluginsBundle}" ]
|
||||
);
|
||||
|
||||
systemd.services.mattermost = rec {
|
||||
|
@ -836,7 +850,7 @@ in
|
|||
configDir=${escapeShellArg cfg.configDir}
|
||||
logDir=${escapeShellArg cfg.logDir}
|
||||
package=${escapeShellArg cfg.package}
|
||||
nixConfig=${escapeShellArg mattermostConfJSON}
|
||||
nixConfig=${escapeShellArg finalConfig}
|
||||
''
|
||||
+ optionalString (versionAtLeast config.system.stateVersion "25.05") ''
|
||||
# Migrate configs in the pre-25.05 directory structure.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue