at 25.11-pre 9.8 kB view raw
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: 7let 8 cfg = config.security.agnos; 9 format = pkgs.formats.toml { }; 10 name = "agnos"; 11 stateDir = "/var/lib/${name}"; 12 13 accountType = 14 let 15 inherit (lib) types mkOption; 16 in 17 types.submodule { 18 freeformType = format.type; 19 20 options = { 21 email = mkOption { 22 type = types.str; 23 description = '' 24 Email associated with this account. 25 ''; 26 }; 27 private_key_path = mkOption { 28 type = types.str; 29 description = '' 30 Path of the PEM-encoded private key for this account. 31 Currently, only RSA keys are supported. 32 33 If this path does not exist, then the behavior depends on `generateKeys.enable`. 34 When this option is `true`, 35 the key will be automatically generated and saved to this path. 36 When it is `false`, agnos will fail. 37 38 If a relative path is specified, 39 the key will be looked up (or generated and saved to) under `${stateDir}`. 40 ''; 41 }; 42 certificates = mkOption { 43 type = types.listOf certificateType; 44 description = '' 45 Certificates for agnos to issue or renew. 46 ''; 47 }; 48 }; 49 }; 50 51 certificateType = 52 let 53 inherit (lib) types literalExpression mkOption; 54 in 55 types.submodule { 56 freeformType = format.type; 57 58 options = { 59 domains = mkOption { 60 type = types.listOf types.str; 61 description = '' 62 Domains the certificate represents 63 ''; 64 example = literalExpression ''["a.example.com", "b.example.com", "*b.example.com"]''; 65 }; 66 fullchain_output_file = mkOption { 67 type = types.str; 68 description = '' 69 Output path for the full chain including the acquired certificate. 70 If a relative path is specified, the file will be created in `${stateDir}`. 71 ''; 72 }; 73 key_output_file = mkOption { 74 type = types.str; 75 description = '' 76 Output path for the certificate private key. 77 If a relative path is specified, the file will be created in `${stateDir}`. 78 ''; 79 }; 80 }; 81 }; 82in 83{ 84 options.security.agnos = 85 let 86 inherit (lib) types mkEnableOption mkOption; 87 in 88 { 89 enable = mkEnableOption name; 90 91 settings = mkOption { 92 description = "Settings"; 93 type = types.submodule { 94 freeformType = format.type; 95 96 options = { 97 dns_listen_addr = mkOption { 98 type = types.str; 99 default = "0.0.0.0:53"; 100 description = '' 101 Address for agnos to listen on. 102 Note that this needs to be reachable by the outside world, 103 and 53 is required in most situations 104 since `NS` records do not allow specifying the port. 105 ''; 106 }; 107 108 accounts = mkOption { 109 type = types.listOf accountType; 110 description = '' 111 A list of ACME accounts. 112 Each account is associated with an email address 113 and can be used to obtain an arbitrary amount of certificate 114 (subject to provider's rate limits, 115 see e.g. [Let's Encrypt Rate Limits](https://letsencrypt.org/docs/rate-limits/)). 116 ''; 117 }; 118 }; 119 }; 120 }; 121 122 generateKeys = { 123 enable = mkOption { 124 type = types.bool; 125 default = false; 126 description = '' 127 Enable automatic generation of account keys. 128 129 When this is `true`, a key will be generated for each account where 130 the file referred to by the `private_key` path does not exist yet. 131 132 Currently, only RSA keys can be generated. 133 ''; 134 }; 135 136 keySize = mkOption { 137 type = types.int; 138 default = 4096; 139 description = '' 140 Key size in bits to use when generating new keys. 141 ''; 142 }; 143 }; 144 145 server = mkOption { 146 type = types.nullOr types.str; 147 default = null; 148 description = '' 149 ACME Directory Resource URI. Defaults to Let's Encrypt's production endpoint, 150 `https://acme-v02.api.letsencrypt.org/directory`, if unset. 151 ''; 152 }; 153 154 serverCa = mkOption { 155 type = types.nullOr types.path; 156 default = null; 157 description = '' 158 The root certificate (in PEM format) of the ACME server's HTTPS interface. 159 ''; 160 }; 161 162 persistent = mkOption { 163 type = types.bool; 164 default = true; 165 description = '' 166 When `true`, use a persistent systemd timer. 167 ''; 168 }; 169 170 startAt = mkOption { 171 type = types.either types.str (types.listOf types.str); 172 default = "daily"; 173 example = "02:00"; 174 description = '' 175 How often or when to run agnos. 176 177 The format is described in 178 {manpage}`systemd.time(7)`. 179 ''; 180 }; 181 182 temporarilyOpenFirewall = mkOption { 183 type = types.bool; 184 default = false; 185 description = '' 186 When `true`, will open the port specified in `settings.dns_listen_addr` 187 before running the agnos service, and close it when agnos finishes running. 188 ''; 189 }; 190 191 group = mkOption { 192 type = types.str; 193 default = name; 194 description = '' 195 Group to run Agnos as. The acquired certificates will be owned by this group. 196 ''; 197 }; 198 199 user = mkOption { 200 type = types.str; 201 default = name; 202 description = '' 203 User to run Agnos as. The acquired certificates will be owned by this user. 204 ''; 205 }; 206 }; 207 208 config = 209 let 210 configFile = format.generate "agnos.toml" cfg.settings; 211 port = lib.toInt (lib.last (builtins.split ":" cfg.settings.dns_listen_addr)); 212 213 useNftables = config.networking.nftables.enable; 214 215 # nftables implementation for temporarilyOpenFirewall 216 nftablesSetup = pkgs.writeShellScript "agnos-fw-setup" '' 217 ${lib.getExe pkgs.nftables} add element inet nixos-fw temp-ports "{ tcp . ${toString port} }" 218 ${lib.getExe pkgs.nftables} add element inet nixos-fw temp-ports "{ udp . ${toString port} }" 219 ''; 220 nftablesTeardown = pkgs.writeShellScript "agnos-fw-teardown" '' 221 ${lib.getExe pkgs.nftables} delete element inet nixos-fw temp-ports "{ tcp . ${toString port} }" 222 ${lib.getExe pkgs.nftables} delete element inet nixos-fw temp-ports "{ udp . ${toString port} }" 223 ''; 224 225 # iptables implementation for temporarilyOpenFirewall 226 helpers = '' 227 function ip46tables() { 228 ${lib.getExe' pkgs.iptables "iptables"} -w "$@" 229 ${lib.getExe' pkgs.iptables "ip6tables"} -w "$@" 230 } 231 ''; 232 fwFilter = ''--dport ${toString port} -j ACCEPT -m comment --comment "agnos"''; 233 iptablesSetup = pkgs.writeShellScript "agnos-fw-setup" '' 234 ${helpers} 235 ip46tables -I INPUT 1 -p tcp ${fwFilter} 236 ip46tables -I INPUT 1 -p udp ${fwFilter} 237 ''; 238 iptablesTeardown = pkgs.writeShellScript "agnos-fw-setup" '' 239 ${helpers} 240 ip46tables -D INPUT -p tcp ${fwFilter} 241 ip46tables -D INPUT -p udp ${fwFilter} 242 ''; 243 in 244 lib.mkIf cfg.enable { 245 assertions = [ 246 { 247 assertion = !cfg.temporarilyOpenFirewall || config.networking.firewall.enable; 248 message = "temporarilyOpenFirewall is only useful when firewall is enabled"; 249 } 250 ]; 251 252 systemd.services.agnos = { 253 serviceConfig = { 254 ExecStartPre = 255 lib.optional cfg.generateKeys.enable '' 256 ${pkgs.agnos}/bin/agnos-generate-accounts-keys \ 257 --no-confirm \ 258 --key-size ${toString cfg.generateKeys.keySize} \ 259 ${configFile} 260 '' 261 ++ lib.optional cfg.temporarilyOpenFirewall ( 262 "+" + (if useNftables then nftablesSetup else iptablesSetup) 263 ); 264 ExecStopPost = lib.optional cfg.temporarilyOpenFirewall ( 265 "+" + (if useNftables then nftablesTeardown else iptablesTeardown) 266 ); 267 ExecStart = '' 268 ${pkgs.agnos}/bin/agnos \ 269 ${if cfg.server != null then "--acme-url=${cfg.server}" else "--no-staging"} \ 270 ${lib.optionalString (cfg.serverCa != null) "--acme-serv-ca=${cfg.serverCa}"} \ 271 ${configFile} 272 ''; 273 Type = "oneshot"; 274 User = cfg.user; 275 Group = cfg.group; 276 StateDirectory = name; 277 StateDirectoryMode = "0750"; 278 WorkingDirectory = "${stateDir}"; 279 280 # Allow binding privileged ports if necessary 281 CapabilityBoundingSet = lib.mkIf (port < 1024) [ "CAP_NET_BIND_SERVICE" ]; 282 AmbientCapabilities = lib.mkIf (port < 1024) [ "CAP_NET_BIND_SERVICE" ]; 283 }; 284 285 after = [ 286 "firewall.target" 287 "network-online.target" 288 "nftables.service" 289 ]; 290 wants = [ "network-online.target" ]; 291 }; 292 293 systemd.timers.agnos = { 294 timerConfig = { 295 OnCalendar = cfg.startAt; 296 Persistent = cfg.persistent; 297 Unit = "agnos.service"; 298 }; 299 wantedBy = [ "timers.target" ]; 300 }; 301 302 users.groups = lib.mkIf (cfg.group == name) { 303 ${cfg.group} = { }; 304 }; 305 306 users.users = lib.mkIf (cfg.user == name) { 307 ${cfg.user} = { 308 isSystemUser = true; 309 description = "Agnos service user"; 310 group = cfg.group; 311 }; 312 }; 313 }; 314}