This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / nix / modules / knot-rs.nix
17 kB 481 lines
1{ 2 config, 3 lib, 4 pkgs, 5 ... 6}: let 7 cfg = config.services.tangled.knot-rs; 8 9 inherit (lib) literalExpression mkEnableOption mkOption types; 10 11 settingsFormat = pkgs.formats.toml {}; 12 13 addrType = types.strMatching "^([[][0-9a-fA-F:]+[]]|[^:]+):[0-9]+$"; 14 absPathType = types.strMatching "^/.+"; 15 16 portOf = addr: lib.toInt (lib.last (lib.splitString ":" addr)); 17 hostOf = addr: lib.concatStringsSep ":" (lib.init (lib.splitString ":" addr)); 18 isLoopback = addr: lib.elem (hostOf addr) ["127.0.0.1" "[::1]"]; 19 20 inherit (cfg.settings) server tls; 21 22 sshPort = portOf server.ssh_listen_addr; 23 listenPort = portOf server.listen_addr; 24 internalPort = portOf server.internal_listen_addr; 25 26 tlsEnabled = tls.acme_enabled || tls.cert_path != null; 27 28 publicTcpPorts = 29 lib.optional (!isLoopback server.ssh_listen_addr) sshPort 30 ++ lib.optional (!isLoopback server.listen_addr) listenPort 31 ++ lib.optional (tls.mtls_enabled && !isLoopback server.internal_listen_addr) internalPort; 32 33 publicUdpPorts = 34 lib.optional (tlsEnabled && tls.http3 && !isLoopback server.listen_addr) listenPort; 35 36 bindsPrivilegedPort = 37 lib.any (port: port < 1024) 38 ([sshPort listenPort] ++ lib.optional tls.mtls_enabled internalPort); 39 40 stateDirs = lib.unique ( 41 [cfg.stateDir cfg.settings.repo.scan_path] 42 ++ lib.optional (cfg.settings.lfs.store_path != null) cfg.settings.lfs.store_path 43 ++ lib.optional tls.acme_enabled tls.acme_cache_dir 44 ); 45 46 keyDirs = lib.subtractLists stateDirs (lib.unique [ 47 (dirOf cfg.settings.secrets.sealed_key_file) 48 (dirOf server.ssh_host_key_file) 49 ]); 50 51 writablePaths = stateDirs ++ keyDirs; 52 53 usesHomePath = 54 lib.any 55 (path: lib.any (prefix: lib.hasPrefix prefix "${path}/") ["/home/" "/root/"]) 56 writablePaths; 57 58 populated = lib.filterAttrsRecursive (_: value: value != null) cfg.settings; 59 60 rendered = 61 settingsFormat.generate "knot.toml" 62 (lib.filterAttrs (_: value: value != {}) populated); 63 64 configFile = 65 if pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform 66 then 67 pkgs.runCommandLocal "knot-config.toml" { 68 nativeBuildInputs = [cfg.package]; 69 } '' 70 knot-server validate --config-only ${rendered} 71 ln -s ${rendered} $out 72 '' 73 else rendered; 74in { 75 _class = "nixos"; 76 77 options.services.tangled.knot-rs = { 78 enable = mkEnableOption "the knot git server"; 79 80 package = mkOption { 81 type = types.package; 82 description = "Package providing the knot-server binary"; 83 }; 84 85 migratePackage = mkOption { 86 type = types.package; 87 description = "Package providing the knot-migrate binary"; 88 }; 89 90 installMigrateTool = mkOption { 91 type = types.bool; 92 default = false; 93 description = '' 94 Whether to instlal {option}`migratePackage` system-wide. 95 Only needed if doing a one-time migration from the Go knot. 96 ''; 97 }; 98 99 user = mkOption { 100 type = types.str; 101 default = "knot"; 102 description = "User the knot runs as and the owner of the repositories"; 103 }; 104 105 group = mkOption { 106 type = types.str; 107 default = cfg.user; 108 description = "Group the knot runs as"; 109 }; 110 111 stateDir = mkOption { 112 type = absPathType; 113 default = "/var/lib/knot"; 114 description = "Directory the knot stores its repositories, sealed key, and ssh host key in"; 115 }; 116 117 openFirewall = mkOption { 118 type = types.bool; 119 default = true; 120 description = '' 121 Whether to open the port of each listen address that isn't loopback, 122 plus the matching UDP port when HTTP3 serves over TLS. 123 ''; 124 }; 125 126 environmentFile = mkOption { 127 type = types.nullOr absPathType; 128 default = null; 129 example = "/etc/secrets/knot.env"; 130 description = '' 131 Environment file as defined in {manpage}`systemd.exec(5)`, 132 which sets the master key and any other secret 133 so they stay out of the nix store. 134 Every `KNOT_*` variable it sets 135 also overrides the matching key in {option}`settings`. 136 ''; 137 }; 138 139 settings = mkOption { 140 type = types.submodule { 141 freeformType = settingsFormat.type; 142 143 options = { 144 server = { 145 hostname = mkOption { 146 type = types.str; 147 example = "knot.oyster.cafe"; 148 description = "Public hostname, which is also the knot's did:web identity"; 149 }; 150 151 admins = mkOption { 152 type = types.nonEmptyListOf types.str; 153 example = ["did:plc:boltless"]; 154 description = '' 155 DIDs with knot-admin authority. 156 The knot reports the first entry as its service owner, 157 so reordering this list changes the owner it advertises. 158 ''; 159 }; 160 161 listen_addr = mkOption { 162 type = addrType; 163 default = "127.0.0.1:5555"; 164 description = '' 165 Address the HTTP surface listens on. 166 The module default suits a reverse proxy in front, 167 while the binary's own default is `[::]:5555`. 168 ''; 169 }; 170 171 internal_listen_addr = mkOption { 172 type = addrType; 173 default = "[::1]:5444"; 174 description = "Address the mTLS admin surface listens on when {option}`settings.tls.mtls_enabled` is set"; 175 }; 176 177 ssh_listen_addr = mkOption { 178 type = addrType; 179 default = "[::]:2222"; 180 description = '' 181 Address the knot's own ssh server listens on. 182 Moving this to port 22 collides with {option}`services.openssh` 183 unless that also moves. 184 ''; 185 }; 186 187 ssh_host_key_file = mkOption { 188 type = absPathType; 189 default = "${cfg.stateDir}/ssh_host_ed25519_key"; 190 defaultText = literalExpression ''"''${stateDir}/ssh_host_ed25519_key"''; 191 description = '' 192 Private ssh host key the knot presents. 193 The knot creates one on first start when the file is absent, 194 so its directory must be writable. 195 Keep this off the nix store. 196 ''; 197 }; 198 }; 199 200 acl.admission = mkOption { 201 type = types.enum ["closed" "open"]; 202 default = "closed"; 203 description = "Whether repository creation needs knot membership"; 204 }; 205 206 repo.scan_path = mkOption { 207 type = absPathType; 208 default = "${cfg.stateDir}/repos"; 209 defaultText = literalExpression ''"''${stateDir}/repos"''; 210 description = "Directory the knot serves repositories from"; 211 }; 212 213 git.object_format = mkOption { 214 type = types.enum ["sha1" "sha256"]; 215 default = "sha256"; 216 description = "Object format for repositories the knot creates"; 217 }; 218 219 secrets = { 220 sealed_key_file = mkOption { 221 type = absPathType; 222 default = "${cfg.stateDir}/knot.sealed"; 223 defaultText = literalExpression ''"''${stateDir}/knot.sealed"''; 224 description = '' 225 Sealed store for the knot signing key. 226 The knot creates one on first start when the file is absent, 227 so its directory must be writable. 228 ''; 229 }; 230 231 master_key_env = mkOption { 232 type = types.strMatching "^[A-Z_][A-Z0-9_]*$"; 233 default = "KNOT_MASTER_KEY"; 234 description = '' 235 Name of the environment variable with the base64 master key that unseals 236 {option}`settings.secrets.sealed_key_file`. 237 Set the value itself in {option}`environmentFile`. 238 Losing it makes every sealed key unreadable. 239 ''; 240 }; 241 }; 242 243 atproto.plc_directory = mkOption { 244 type = types.str; 245 example = "https://plc.directory"; 246 description = "atproto PLC directory. This has no default so that the plcdir is an explicit choice."; 247 }; 248 249 xrpc.trusted_proxy_header = mkOption { 250 type = types.nullOr types.str; 251 default = null; 252 example = "x-forwarded-for"; 253 description = '' 254 Header a trusted reverse proxy appends the client address to. 255 Rate limiting keys every request on the proxy's own address while this is null. 256 Only set it if a trusted proxy overwrites the header, 257 since a client can forge it otherwise. 258 ''; 259 }; 260 261 lfs.store_path = mkOption { 262 type = types.nullOr absPathType; 263 default = null; 264 description = "Directory for Git LFS objects. The knot won't serve LFS while this is null."; 265 }; 266 267 tls = { 268 cert_path = mkOption { 269 type = types.nullOr absPathType; 270 default = null; 271 example = "/etc/knot/tls/cert.pem"; 272 description = '' 273 Certificate chain the knot presents. 274 The knot serves plain HTTP 275 while this and {option}`settings.tls.acme_enabled` are both unset. 276 That suits a reverse proxy in front. 277 ''; 278 }; 279 280 key_path = mkOption { 281 type = types.nullOr absPathType; 282 default = null; 283 example = "/etc/knot/tls/key.pem"; 284 description = '' 285 Private key for {option}`settings.tls.cert_path`. 286 Set both or neither. 287 Keep this off the nix store. 288 ''; 289 }; 290 291 http3 = mkOption { 292 type = types.bool; 293 default = true; 294 description = "Whether to serve HTTP/3 over QUIC on the UDP port matching {option}`settings.server.listen_addr`"; 295 }; 296 297 acme_enabled = mkOption { 298 type = types.bool; 299 default = false; 300 description = "Whether to obtain certificates over ACME instead of reading {option}`settings.tls.cert_path`"; 301 }; 302 303 acme_cache_dir = mkOption { 304 type = absPathType; 305 default = "${cfg.stateDir}/acme"; 306 defaultText = literalExpression ''"''${stateDir}/acme"''; 307 description = "Directory for the ACME account key and issued certificates"; 308 }; 309 310 acme_contact = mkOption { 311 type = types.nullOr types.str; 312 default = null; 313 example = "nel@oyster.cafe"; 314 description = "Contact email the knot registers the ACME account with, required when ACME is enabled"; 315 }; 316 317 acme_staging = mkOption { 318 type = types.bool; 319 default = false; 320 description = '' 321 Whether to use the Let's Encrypt staging directory. 322 Set it while testing so a typo doesn't exhaust the production rate limit. 323 ''; 324 }; 325 326 mtls_enabled = mkOption { 327 type = types.bool; 328 default = false; 329 description = "Whether to serve the mTLS admin surface on {option}`settings.server.internal_listen_addr`"; 330 }; 331 332 mtls_client_ca_path = mkOption { 333 type = types.nullOr absPathType; 334 default = null; 335 example = "/etc/knot/tls/admin-ca.pem"; 336 description = "CA that signs admin client certificates, required when mTLS is enabled"; 337 }; 338 339 mtls_admin_spki_pin = mkOption { 340 type = types.nullOr types.str; 341 default = null; 342 description = "Base64 SHA-256 SPKI pin of the admin client certificate, required when mTLS is enabled"; 343 }; 344 }; 345 }; 346 }; 347 348 description = '' 349 Configuration the module renders to `/etc/knot/config.toml`. 350 The knot reads that file on startup. 351 Keys beyond the ones declared here pass through unchanged, 352 and `knot-server validate --config-only` checks the result at build time 353 when the build platform can run the knot binary. 354 Run `nix run .#knot-rs -- config-template` for the full key list. 355 Put secrets in {option}`environmentFile`. 356 ''; 357 }; 358 }; 359 360 config = lib.mkIf cfg.enable { 361 assertions = [ 362 { 363 assertion = cfg.environmentFile != null; 364 message = "services.tangled.knot-rs.environmentFile must be set, since the knot reads its master key from ${cfg.settings.secrets.master_key_env} in the environment and won't start without it"; 365 } 366 { 367 assertion = !config.services.openssh.enable || !(lib.elem sshPort config.services.openssh.ports); 368 message = "services.tangled.knot-rs.settings.server.ssh_listen_addr takes port ${toString sshPort}, which services.openssh already listens on"; 369 } 370 { 371 assertion = (tls.cert_path == null) == (tls.key_path == null); 372 message = "services.tangled.knot-rs.settings.tls.cert_path and tls.key_path must both be set or both unset"; 373 } 374 { 375 assertion = !(tls.acme_enabled && tls.cert_path != null); 376 message = "services.tangled.knot-rs.settings.tls.acme_enabled can't combine with a static tls.cert_path"; 377 } 378 { 379 assertion = !tls.acme_enabled || tls.acme_contact != null; 380 message = "services.tangled.knot-rs.settings.tls.acme_contact is required when tls.acme_enabled is set"; 381 } 382 { 383 assertion = !tls.acme_enabled || !isLoopback server.listen_addr; 384 message = "services.tangled.knot-rs.settings.tls.acme_enabled needs a certificate authority to reach settings.server.listen_addr, and ${server.listen_addr} is loopback"; 385 } 386 { 387 assertion = !tls.mtls_enabled || tlsEnabled; 388 message = "services.tangled.knot-rs.settings.tls.mtls_enabled requires a certificate from tls.cert_path or ACME"; 389 } 390 { 391 assertion = !tls.mtls_enabled || (tls.mtls_client_ca_path != null && tls.mtls_admin_spki_pin != null); 392 message = "services.tangled.knot-rs.settings.tls.mtls_enabled requires tls.mtls_client_ca_path and tls.mtls_admin_spki_pin"; 393 } 394 ]; 395 396 warnings = 397 lib.optional (tls.acme_enabled && listenPort != 443) 398 "services.tangled.knot-rs validates over TLS-ALPN-01, which a certificate authority reaches on TCP 443, and settings.server.listen_addr uses port ${toString listenPort}. Map 443 to that port."; 399 400 environment.systemPackages = 401 [cfg.package] 402 ++ lib.optional cfg.installMigrateTool cfg.migratePackage; 403 404 environment.etc."knot/config.toml".source = configFile; 405 406 users.users.${cfg.user} = { 407 isSystemUser = true; 408 home = cfg.stateDir; 409 inherit (cfg) group; 410 }; 411 412 users.groups.${cfg.group} = {}; 413 414 systemd.tmpfiles.settings."10-knot-rs" = lib.genAttrs stateDirs (path: { 415 d = { 416 mode = 417 if path == tls.acme_cache_dir 418 then "0700" 419 else "0750"; 420 inherit (cfg) user group; 421 }; 422 }); 423 424 systemd.services.knot-rs = { 425 description = "knot git server"; 426 after = ["network-online.target"]; 427 wants = ["network-online.target"]; 428 wantedBy = ["multi-user.target"]; 429 430 restartTriggers = [configFile]; 431 432 startLimitIntervalSec = 60; 433 startLimitBurst = 5; 434 435 serviceConfig = { 436 User = cfg.user; 437 Group = cfg.group; 438 UMask = "0077"; 439 WorkingDirectory = cfg.stateDir; 440 EnvironmentFile = cfg.environmentFile; 441 ExecStart = "${lib.getExe cfg.package} /etc/knot/config.toml"; 442 Restart = "on-failure"; 443 RestartSec = 5; 444 TimeoutStopSec = 120; 445 LimitNOFILE = 65536; 446 AmbientCapabilities = lib.mkIf bindsPrivilegedPort ["CAP_NET_BIND_SERVICE"]; 447 CapabilityBoundingSet = 448 if bindsPrivilegedPort 449 then ["CAP_NET_BIND_SERVICE"] 450 else []; 451 ReadWritePaths = stateDirs ++ map (dir: "-${dir}") keyDirs; 452 NoNewPrivileges = true; 453 ProtectProc = "invisible"; 454 ProtectSystem = "strict"; 455 ProtectHome = !usesHomePath; 456 PrivateTmp = true; 457 PrivateDevices = true; 458 PrivateUsers = !bindsPrivilegedPort; 459 ProtectHostname = true; 460 ProtectClock = true; 461 ProtectKernelTunables = true; 462 ProtectKernelModules = true; 463 ProtectKernelLogs = true; 464 ProtectControlGroups = true; 465 RestrictAddressFamilies = ["AF_INET" "AF_INET6" "AF_NETLINK" "AF_UNIX"]; 466 RestrictNamespaces = true; 467 LockPersonality = true; 468 MemoryDenyWriteExecute = true; 469 RestrictRealtime = true; 470 RestrictSUIDSGID = true; 471 RemoveIPC = true; 472 PrivateMounts = true; 473 SystemCallFilter = ["@system-service" "~@privileged @resources"]; 474 SystemCallArchitectures = "native"; 475 }; 476 }; 477 478 networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall publicTcpPorts; 479 networking.firewall.allowedUDPPorts = lib.mkIf cfg.openFirewall publicUdpPorts; 480 }; 481}