This repository has no description
0

Configure Feed

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

nix: package knot-server and knot-migrate, add a nixos module

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>

author did:plc:3fwecdnvtcscjnrx2p4n7a… committer
Tangled
date (Jul 27, 2026, 7:07 PM +0300) commit e4840a76 parent bebc4274 change-id sswqztsu
+579 -11
+22
flake.nix
··· 173 173 }; 174 174 knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 175 175 knot = self.callPackage ./nix/pkgs/knot.nix {}; 176 + knot-rs = self.callPackage ./nix/pkgs/knot-crate.nix { 177 + src = self.rustSrc; 178 + crate = "knot-server"; 179 + }; 180 + knot-migrate = self.callPackage ./nix/pkgs/knot-crate.nix { 181 + src = self.rustSrc; 182 + crate = "knot-migrate"; 183 + }; 176 184 dolly = self.callPackage ./nix/pkgs/dolly.nix {}; 177 185 tap = self.callPackage ./nix/pkgs/tap.nix {}; 178 186 knotmirror = self.callPackage ./nix/pkgs/knotmirror.nix {}; ··· 191 199 shuttle 192 200 knot-unwrapped 193 201 knot 202 + knot-rs 203 + knot-migrate 194 204 appview 195 205 docs 196 206 dolly ··· 219 229 spindle 220 230 knot 221 231 knot-unwrapped 232 + knot-rs 233 + knot-migrate 222 234 sqlite-lib 223 235 docs 224 236 shuttle ··· 595 607 imports = [./nix/modules/knot.nix]; 596 608 597 609 services.tangled.knot.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot; 610 + }; 611 + nixosModules.knot-rs = { 612 + lib, 613 + pkgs, 614 + ... 615 + }: { 616 + imports = [./nix/modules/knot-rs.nix]; 617 + 618 + services.tangled.knot-rs.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot-rs; 619 + services.tangled.knot-rs.migratePackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.knot-migrate; 598 620 }; 599 621 nixosModules.spindle = { 600 622 lib,
+48 -11
knot2/crates/knot-server/src/main.rs
··· 10 10 11 11 use std::collections::BTreeSet; 12 12 use std::num::{NonZeroU32, NonZeroU64}; 13 - use std::path::PathBuf; 13 + use std::path::{Path, PathBuf}; 14 14 use std::sync::Arc; 15 15 use std::time::Duration; 16 16 ··· 74 74 .init(); 75 75 } 76 76 77 + const VALIDATE_CONFIG_ONLY: &str = "--config-only"; 78 + 79 + enum ValidateScope { 80 + ConfigOnly, 81 + Environment, 82 + } 83 + 84 + impl ValidateScope { 85 + fn verify(self, config: &knot_config::Validated) -> anyhow::Result<()> { 86 + match self { 87 + Self::ConfigOnly => Ok(()), 88 + Self::Environment => config 89 + .verify_environment() 90 + .context("verify runtime environment"), 91 + } 92 + } 93 + } 94 + 95 + fn validate(args: impl Iterator<Item = String>) -> anyhow::Result<()> { 96 + let (flags, paths): (Vec<String>, Vec<String>) = args.partition(|arg| arg.starts_with("--")); 97 + flags 98 + .iter() 99 + .find(|flag| flag.as_str() != VALIDATE_CONFIG_ONLY) 100 + .map_or(Ok(()), |unknown| { 101 + Err(anyhow::anyhow!( 102 + "unrecognized flag {unknown}, expected {VALIDATE_CONFIG_ONLY}" 103 + )) 104 + })?; 105 + let scope = match flags.is_empty() { 106 + true => ValidateScope::Environment, 107 + false => ValidateScope::ConfigOnly, 108 + }; 109 + let path = match paths.as_slice() { 110 + [] => None, 111 + [path] => Some(Path::new(path)), 112 + extra => anyhow::bail!( 113 + "expected at most one configuration path, got {}", 114 + extra.len() 115 + ), 116 + }; 117 + let config = knot_config::load(path).context("load configuration")?; 118 + scope.verify(&config)?; 119 + println!("configuration is valid"); 120 + Ok(()) 121 + } 122 + 77 123 fn subcommand(name: &str) -> Option<anyhow::Result<()>> { 78 124 match name { 79 125 "config-template" => { 80 126 print!("{}", knot_config::template()); 81 127 Some(Ok(())) 82 128 } 83 - "validate" => Some( 84 - knot_config::load(std::env::args().nth(2).map(PathBuf::from).as_deref()) 85 - .context("load configuration") 86 - .and_then(|config| { 87 - config 88 - .verify_environment() 89 - .context("verify runtime environment") 90 - }) 91 - .map(|()| println!("configuration is valid")), 92 - ), 129 + "validate" => Some(validate(std::env::args().skip(2))), 93 130 _ => None, 94 131 } 95 132 }
+481
nix/modules/knot-rs.nix
··· 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; 74 + in { 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 + }
+28
nix/pkgs/knot-crate.nix
··· 1 + { 2 + rustPlatform, 3 + src, 4 + crate, 5 + cmake, 6 + perl, 7 + ... 8 + }: 9 + rustPlatform.buildRustPackage { 10 + pname = crate; 11 + version = "2.0.0"; 12 + 13 + inherit src; 14 + 15 + cargoLock.lockFile = "${src}/Cargo.lock"; 16 + 17 + nativeBuildInputs = [ 18 + cmake 19 + perl 20 + ]; 21 + 22 + dontUseCmakeConfigure = true; 23 + 24 + cargoBuildFlags = ["--bin" crate "--package" crate]; 25 + doCheck = false; 26 + 27 + meta.mainProgram = crate; 28 + }