mirror of
https://github.com/NixOS/nixpkgs.git
synced 2025-06-09 19:13:26 +03:00
nixos/agnos: init
This commit is contained in:
parent
cccb203579
commit
7757648f82
5 changed files with 527 additions and 0 deletions
|
@ -361,6 +361,7 @@
|
|||
./programs/zsh/zsh.nix
|
||||
./rename.nix
|
||||
./security/acme
|
||||
./security/agnos.nix
|
||||
./security/apparmor.nix
|
||||
./security/audit.nix
|
||||
./security/auditd.nix
|
||||
|
|
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
209
nixos/tests/agnos.nix
Normal file
209
nixos/tests/agnos.nix
Normal file
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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" ] {
|
||||
|
|
|
@ -28,4 +28,6 @@ rustPlatform.buildRustPackage rec {
|
|||
license = licenses.mit;
|
||||
maintainers = with maintainers; [ justinas ];
|
||||
};
|
||||
|
||||
passthru.tests = nixosTests.agnos;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue