|
| 1 | +{ |
| 2 | + config, |
| 3 | + lib, |
| 4 | + pkgs, |
| 5 | + ... |
| 6 | +}: |
| 7 | +let |
| 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 | + }; |
| 82 | +in |
| 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 | +} |
0 commit comments