diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 5aa0af4c442c..ca4148d81ba8 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -361,6 +361,7 @@ ./programs/zsh/zsh.nix ./rename.nix ./security/acme + ./security/agnos.nix ./security/apparmor.nix ./security/audit.nix ./security/auditd.nix diff --git a/nixos/modules/security/agnos.nix b/nixos/modules/security/agnos.nix new file mode 100644 index 000000000000..dbd93afdb263 --- /dev/null +++ b/nixos/modules/security/agnos.nix @@ -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; + }; + }; + }; +} diff --git a/nixos/tests/agnos.nix b/nixos/tests/agnos.nix new file mode 100644 index 000000000000..b73f1c021412 --- /dev/null +++ b/nixos/tests/agnos.nix @@ -0,0 +1,209 @@ +{ + system ? builtins.currentSystem, + pkgs ? import ../.. { inherit system; }, + lib ? pkgs.lib, +}: + +let + inherit (import ../lib/testing-python.nix { inherit system pkgs; }) makeTest; + nodeIP = n: n.networking.primaryIPAddress; + dnsZone = + nodes: + pkgs.writeText "agnos.test.zone" '' + $TTL 604800 + @ IN SOA ns1.agnos.test. root.agnos.test. ( + 3 ; Serial + 604800 ; Refresh + 86400 ; Retry + 2419200 ; Expire + 604800 ) ; Negative Cache TTL + ; + ; name servers - NS records + IN NS ns1.agnos.test. + + ; name servers - A records + ns1.agnos.test. IN A ${nodeIP nodes.dnsserver} + + agnos-ns.agnos.test. IN A ${nodeIP nodes.server} + _acme-challenge.a.agnos.test. IN NS agnos-ns.agnos.test. + _acme-challenge.b.agnos.test. IN NS agnos-ns.agnos.test. + _acme-challenge.c.agnos.test. IN NS agnos-ns.agnos.test. + _acme-challenge.d.agnos.test. IN NS agnos-ns.agnos.test. + ''; + + mkTest = + { + name, + extraServerConfig ? { }, + checkFirewallClosed ? true, + }: + makeTest { + inherit name; + meta = { + maintainers = with lib.maintainers; [ justinas ]; + }; + + nodes = { + # The fake ACME server which will respond to client requests + acme = + { nodes, pkgs, ... }: + { + imports = [ ./common/acme/server ]; + environment.systemPackages = [ pkgs.netcat ]; + networking.nameservers = lib.mkForce [ (nodeIP nodes.dnsserver) ]; + }; + + # A fake DNS server which points _acme-challenge subdomains to "server" + dnsserver = + { nodes, ... }: + { + networking.firewall.allowedTCPPorts = [ 53 ]; + networking.firewall.allowedUDPPorts = [ 53 ]; + services.bind = { + cacheNetworks = [ "192.168.1.0/24" ]; + enable = true; + extraOptions = '' + dnssec-validation no; + ''; + zones."agnos.test" = { + file = dnsZone nodes; + master = true; + }; + }; + }; + + # The server using agnos to request certificates + server = + { nodes, ... }: + { + imports = [ extraServerConfig ]; + + networking.extraHosts = '' + ${nodeIP nodes.acme} acme.test + ''; + security.agnos = { + enable = true; + generateKeys.enable = true; + persistent = false; + server = "https://acme.test/dir"; + serverCa = ./common/acme/server/ca.cert.pem; + temporarilyOpenFirewall = true; + + settings.accounts = [ + { + email = "webmaster@agnos.test"; + # account with an existing private key + private_key_path = "${./common/acme/server/acme.test.key.pem}"; + + certificates = [ + { + domains = [ "a.agnos.test" ]; + # Absolute paths + fullchain_output_file = "/tmp/a.agnos.test.crt"; + key_output_file = "/tmp/a.agnos.test.key"; + } + + { + domains = [ + "b.agnos.test" + "*.b.agnos.test" + ]; + # Relative paths + fullchain_output_file = "b.agnos.test.crt"; + key_output_file = "b.agnos.test.key"; + } + ]; + } + + { + email = "webmaster2@agnos.test"; + # account with a missing private key, should get generated + private_key_path = "webmaster2.key"; + + certificates = [ + { + domains = [ "c.agnos.test" ]; + # Absolute paths + fullchain_output_file = "/tmp/c.agnos.test.crt"; + key_output_file = "/tmp/c.agnos.test.key"; + } + + { + domains = [ + "d.agnos.test" + "*.d.agnos.test" + ]; + # Relative paths + fullchain_output_file = "d.agnos.test.crt"; + key_output_file = "d.agnos.test.key"; + } + ]; + } + ]; + }; + }; + }; + + testScript = '' + def check_firewall_closed(caller): + """ + Check that TCP port 53 is closed again. + + Since we do not set `networking.firewall.rejectPackets`, + "timed out" indicates a closed port, + while "connection refused" (after agnos has shut down) indicates an open port. + """ + + out = caller.fail("nc -v -z -w 1 server 53 2>&1") + assert "Connection timed out" in out + + start_all() + acme.wait_for_unit('pebble.service') + server.wait_for_unit('default.target') + + # Test that agnos.timer is scheduled + server.succeed("systemctl status agnos.timer") + server.succeed('systemctl start agnos.service') + + expected_perms = "640 agnos agnos" + outputs = [ + "/tmp/a.agnos.test.crt", + "/tmp/a.agnos.test.key", + "/var/lib/agnos/b.agnos.test.crt", + "/var/lib/agnos/b.agnos.test.key", + "/var/lib/agnos/webmaster2.key", + "/tmp/c.agnos.test.crt", + "/tmp/c.agnos.test.key", + "/var/lib/agnos/d.agnos.test.crt", + "/var/lib/agnos/d.agnos.test.key", + ] + for o in outputs: + out = server.succeed(f"stat -c '%a %U %G' {o}").strip() + assert out == expected_perms, \ + f"Expected mode/owner/group to be '{expected_perms}', but it was '{out}'" + + ${lib.optionalString checkFirewallClosed "check_firewall_closed(acme)"} + ''; + }; +in +{ + iptables = mkTest { + name = "iptables"; + }; + + nftables = mkTest { + name = "nftables"; + extraServerConfig = { + networking.nftables.enable = true; + }; + }; + + no-firewall = mkTest { + name = "no-firewall"; + extraServerConfig = { + networking.firewall.enable = lib.mkForce false; + security.agnos.temporarilyOpenFirewall = lib.mkForce false; + }; + checkFirewallClosed = false; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 9770153b216a..8ec1a101313f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -177,6 +177,7 @@ in agate = runTest ./web-servers/agate.nix; agda = runTest ./agda.nix; age-plugin-tpm-decrypt = runTest ./age-plugin-tpm-decrypt.nix; + agnos = discoverTests (import ./agnos.nix); agorakit = runTest ./web-apps/agorakit.nix; airsonic = runTest ./airsonic.nix; akkoma = runTestOn [ "x86_64-linux" "aarch64-linux" ] { diff --git a/pkgs/by-name/ag/agnos/package.nix b/pkgs/by-name/ag/agnos/package.nix index 9b6bc6258b88..cc171a165971 100644 --- a/pkgs/by-name/ag/agnos/package.nix +++ b/pkgs/by-name/ag/agnos/package.nix @@ -28,4 +28,6 @@ rustPlatform.buildRustPackage rec { license = licenses.mit; maintainers = with maintainers; [ justinas ]; }; + + passthru.tests = nixosTests.agnos; }