Monorepo for Tangled tangled.org
2

Configure Feed

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

at master 12 kB View raw
1{ 2 config, 3 pkgs, 4 lib, 5 ... 6}: let 7 cfg = config.services.tangled.knot; 8in 9 with lib; { 10 options = { 11 services.tangled.knot = { 12 enable = mkOption { 13 type = types.bool; 14 default = false; 15 description = "Enable a tangled knot"; 16 }; 17 18 package = mkOption { 19 type = types.package; 20 description = "Package to use for the knot"; 21 }; 22 23 appviewEndpoint = mkOption { 24 type = types.str; 25 default = "https://tangled.org"; 26 description = "Appview endpoint"; 27 }; 28 29 gitUser = mkOption { 30 type = types.str; 31 default = "git"; 32 description = "User that hosts git repos and performs git operations"; 33 }; 34 35 openFirewall = mkOption { 36 type = types.bool; 37 default = true; 38 description = "Open port 22 in the firewall for ssh"; 39 }; 40 41 stateDir = mkOption { 42 type = types.path; 43 default = "/home/${cfg.gitUser}"; 44 description = "Tangled knot data directory"; 45 }; 46 47 repo = { 48 scanPath = mkOption { 49 type = types.path; 50 default = cfg.stateDir; 51 description = "Path where repositories are scanned from"; 52 }; 53 54 readme = mkOption { 55 type = types.listOf types.str; 56 default = [ 57 "README.md" 58 "readme.md" 59 "README" 60 "readme" 61 "README.markdown" 62 "readme.markdown" 63 "README.txt" 64 "readme.txt" 65 "README.rst" 66 "readme.rst" 67 "README.org" 68 "readme.org" 69 "README.asciidoc" 70 "readme.asciidoc" 71 ]; 72 description = "List of README filenames to look for (in priority order)"; 73 }; 74 75 mainBranch = mkOption { 76 type = types.str; 77 default = "main"; 78 description = "Default branch name for repositories"; 79 }; 80 }; 81 82 git = { 83 userName = mkOption { 84 type = types.str; 85 default = "Tangled"; 86 description = "Git user name used as committer"; 87 }; 88 89 userEmail = mkOption { 90 type = types.str; 91 default = "noreply@tangled.org"; 92 description = "Git user email used as committer"; 93 }; 94 }; 95 96 motd = mkOption { 97 type = types.nullOr types.str; 98 default = null; 99 description = '' 100 Message of the day 101 102 The contents are shown as-is; eg. you will want to add a newline if 103 setting a non-empty message since the knot won't do this for you. 104 ''; 105 }; 106 107 motdFile = mkOption { 108 type = types.nullOr types.path; 109 default = null; 110 description = '' 111 File containing message of the day 112 113 The contents are shown as-is; eg. you will want to add a newline if 114 setting a non-empty message since the knot won't do this for you. 115 ''; 116 }; 117 118 knotmirrors = mkOption { 119 type = types.listOf types.str; 120 default = [ 121 "https://mirror.tangled.network" 122 ]; 123 description = "List of knotmirror hosts to request crawl"; 124 }; 125 126 server = { 127 listenAddr = mkOption { 128 type = types.str; 129 default = "0.0.0.0:5555"; 130 description = "Address to listen on"; 131 }; 132 133 internalListenAddr = mkOption { 134 type = types.str; 135 default = "127.0.0.1:5444"; 136 description = "Internal address for inter-service communication"; 137 }; 138 139 owner = mkOption { 140 type = types.str; 141 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 142 description = "DID of owner (required)"; 143 }; 144 145 dbPath = mkOption { 146 type = types.path; 147 default = "${cfg.stateDir}/knotserver.db"; 148 description = "Path to the database file"; 149 }; 150 151 hostname = mkOption { 152 type = types.str; 153 example = "my.knot.com"; 154 description = "Hostname for the server (required)"; 155 }; 156 157 plcUrl = mkOption { 158 type = types.str; 159 default = "https://plc.directory"; 160 description = "atproto PLC directory"; 161 }; 162 163 jetstreamEndpoint = mkOption { 164 type = types.str; 165 default = "wss://jetstream1.us-west.bsky.network/subscribe"; 166 description = "Jetstream endpoint to subscribe to"; 167 }; 168 169 logDids = mkOption { 170 type = types.bool; 171 default = true; 172 description = "Enable logging of DIDs"; 173 }; 174 175 dev = mkOption { 176 type = types.bool; 177 default = false; 178 description = "Enable development mode (disables signature verification)"; 179 }; 180 181 secureMode = mkOption { 182 type = types.bool; 183 default = false; 184 description = "Isolate git subprocesses with Landlock (requires kernel >= 5.13)"; 185 }; 186 187 maxResponseKB = mkOption { 188 type = types.int; 189 default = 5120; 190 description = "Maximum response size in kilobytes"; 191 }; 192 }; 193 194 environmentFile = mkOption { 195 type = with types; nullOr path; 196 default = null; 197 example = "/etc/knot.env"; 198 description = '' 199 Additional environment file as defined in {manpage}`systemd.exec(5)`. 200 201 Sensitive secrets such as {env}`KNOT_SERVER_ADMIN_SECRET` may be 202 passed to the service without making them world readable in the nix 203 store. 204 ''; 205 }; 206 }; 207 }; 208 209 config = mkIf cfg.enable { 210 environment.systemPackages = [ 211 pkgs.git 212 cfg.package 213 ]; 214 215 users.users.${cfg.gitUser} = { 216 isSystemUser = true; 217 useDefaultShell = true; 218 home = cfg.stateDir; 219 createHome = true; 220 group = cfg.gitUser; 221 }; 222 223 users.groups.${cfg.gitUser} = {}; 224 225 services.openssh = { 226 enable = true; 227 extraConfig = '' 228 Match User ${cfg.gitUser} 229 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 230 AuthorizedKeysCommandUser nobody 231 ChallengeResponseAuthentication no 232 PasswordAuthentication no 233 ''; 234 }; 235 236 environment.etc."ssh/keyfetch_wrapper" = { 237 mode = "0555"; 238 text = '' 239 #!${pkgs.stdenv.shell} 240 ${cfg.package}/bin/knot keys \ 241 -output authorized-keys \ 242 -internal-api "http://${cfg.server.internalListenAddr}" \ 243 -git-dir "${cfg.repo.scanPath}" \ 244 -log-path /tmp/knotguard.log \ 245 ${optionalString cfg.server.secureMode "-secure-mode -guard-path /run/wrappers/bin/knot"} 246 ''; 247 }; 248 249 # In secure mode, the guard subcommand (run via sshd's forced command as 250 # the git user) needs CAP_SETUID to drop to the virtual UID before exec'ing 251 # git. security.wrappers installs a setcap'd wrapper around the binary. 252 security.wrappers = mkIf cfg.server.secureMode { 253 knot = { 254 source = "${cfg.package}/bin/knot"; 255 owner = "root"; 256 group = cfg.gitUser; 257 permissions = "u+rx,g+x"; 258 capabilities = "cap_setuid,cap_setgid,cap_chown+eip"; 259 }; 260 }; 261 262 systemd.services.knot = { 263 description = "knot service"; 264 after = ["network-online.target" "sshd.service"]; 265 wants = ["network-online.target"]; 266 wantedBy = ["multi-user.target"]; 267 enableStrictShellChecks = true; 268 269 preStart = let 270 setMotd = 271 if cfg.motdFile != null && cfg.motd != null 272 then throw "motdFile and motd cannot be both set" 273 else '' 274 ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 275 ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 276 ''; 277 in '' 278 mkdir -p "${cfg.repo.scanPath}" 279 mkdir -p "${cfg.stateDir}/.config/git" 280 cat > "${cfg.stateDir}/.config/git/config" << EOF 281 [user] 282 name = ${cfg.git.userName} 283 email = ${cfg.git.userEmail} 284 [receive] 285 advertisePushOptions = true 286 [uploadpack] 287 allowFilter = true 288 allowReachableSHA1InWant = true 289 [safe] 290 directory = * 291 EOF 292 ${setMotd} 293 chown ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 294 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}/.config" 295 ${optionalString cfg.server.secureMode '' 296 # secure mode runs git subprocesses as virtual UIDs that are not in 297 # the git group. they need execute (traverse) permission on the 298 # state dir so they can resolve $HOME/.config/git/config; read 299 # permission is intentionally withheld so they can't list other 300 # repos by name. 301 chmod o+x "${cfg.stateDir}" 302 ''} 303 ${optionalString (cfg.motdFile != null || cfg.motd != null) "chown ${cfg.gitUser}:${cfg.gitUser} ${cfg.stateDir}/motd"} 304 ${optionalString cfg.server.secureMode '' 305 ${cfg.package}/bin/knot migrate-isolation \ 306 --force \ 307 --git-dir "${cfg.repo.scanPath}" \ 308 --db "${cfg.server.dbPath}" \ 309 --internal-api "${cfg.server.internalListenAddr}" 310 chown ${cfg.gitUser}:${cfg.gitUser} "${cfg.server.dbPath}" 311 ''} 312 ''; 313 314 serviceConfig = 315 { 316 User = cfg.gitUser; 317 PermissionsStartOnly = true; 318 WorkingDirectory = cfg.stateDir; 319 Environment = [ 320 "PATH=${lib.makeBinPath [pkgs.bash pkgs.git pkgs.coreutils]}:/run/current-system/sw/bin" 321 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 322 "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}" 323 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 324 "KNOT_GIT_USER_NAME=${cfg.git.userName}" 325 "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}" 326 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 327 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 328 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 329 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 330 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 331 "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 332 "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 333 "KNOT_SERVER_OWNER=${cfg.server.owner}" 334 "KNOT_MIRRORS=${concatStringsSep "," cfg.knotmirrors}" 335 "KNOT_SERVER_LOG_DIDS=${ 336 if cfg.server.logDids 337 then "true" 338 else "false" 339 }" 340 "KNOT_SERVER_DEV=${ 341 if cfg.server.dev 342 then "true" 343 else "false" 344 }" 345 "KNOT_SERVER_MAX_RESPONSE_KB=${toString cfg.server.maxResponseKB}" 346 "KNOT_SERVER_SECURE_MODE=${ 347 if cfg.server.secureMode 348 then "true" 349 else "false" 350 }" 351 ]; 352 EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile; 353 ExecStart = "${cfg.package}/bin/knot server"; 354 Restart = "always"; 355 } 356 // optionalAttrs cfg.server.secureMode { 357 AmbientCapabilities = ["CAP_CHOWN" "CAP_SETUID" "CAP_SETGID"]; 358 CapabilityBoundingSet = ["CAP_CHOWN" "CAP_SETUID" "CAP_SETGID"]; 359 }; 360 }; 361 362 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22]; 363 }; 364 }