forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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 mainBranch = mkOption { 55 type = types.str; 56 default = "main"; 57 description = "Default branch name for repositories"; 58 }; 59 }; 60 61 motd = mkOption { 62 type = types.nullOr types.str; 63 default = null; 64 description = '' 65 Message of the day 66 67 The contents are shown as-is; eg. you will want to add a newline if 68 setting a non-empty message since the knot won't do this for you. 69 ''; 70 }; 71 72 motdFile = mkOption { 73 type = types.nullOr types.path; 74 default = null; 75 description = '' 76 File containing message of the day 77 78 The contents are shown as-is; eg. you will want to add a newline if 79 setting a non-empty message since the knot won't do this for you. 80 ''; 81 }; 82 83 server = { 84 listenAddr = mkOption { 85 type = types.str; 86 default = "0.0.0.0:5555"; 87 description = "Address to listen on"; 88 }; 89 90 internalListenAddr = mkOption { 91 type = types.str; 92 default = "127.0.0.1:5444"; 93 description = "Internal address for inter-service communication"; 94 }; 95 96 owner = mkOption { 97 type = types.str; 98 example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 description = "DID of owner (required)"; 100 }; 101 102 dbPath = mkOption { 103 type = types.path; 104 default = "${cfg.stateDir}/knotserver.db"; 105 description = "Path to the database file"; 106 }; 107 108 hostname = mkOption { 109 type = types.str; 110 example = "my.knot.com"; 111 description = "Hostname for the server (required)"; 112 }; 113 114 plcUrl = mkOption { 115 type = types.str; 116 default = "https://plc.directory"; 117 description = "atproto PLC directory"; 118 }; 119 120 jetstreamEndpoint = mkOption { 121 type = types.str; 122 default = "wss://jetstream1.us-west.bsky.network/subscribe"; 123 description = "Jetstream endpoint to subscribe to"; 124 }; 125 126 dev = mkOption { 127 type = types.bool; 128 default = false; 129 description = "Enable development mode (disables signature verification)"; 130 }; 131 }; 132 }; 133 }; 134 135 config = mkIf cfg.enable { 136 environment.systemPackages = [ 137 pkgs.git 138 cfg.package 139 ]; 140 141 users.users.${cfg.gitUser} = { 142 isSystemUser = true; 143 useDefaultShell = true; 144 home = cfg.stateDir; 145 createHome = true; 146 group = cfg.gitUser; 147 }; 148 149 users.groups.${cfg.gitUser} = {}; 150 151 services.openssh = { 152 enable = true; 153 extraConfig = '' 154 Match User ${cfg.gitUser} 155 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 156 AuthorizedKeysCommandUser nobody 157 ''; 158 }; 159 160 environment.etc."ssh/keyfetch_wrapper" = { 161 mode = "0555"; 162 text = '' 163 #!${pkgs.stdenv.shell} 164 ${cfg.package}/bin/knot keys \ 165 -output authorized-keys \ 166 -internal-api "http://${cfg.server.internalListenAddr}" \ 167 -git-dir "${cfg.repo.scanPath}" \ 168 -log-path /tmp/knotguard.log 169 ''; 170 }; 171 172 systemd.services.knot = { 173 description = "knot service"; 174 after = ["network.target" "sshd.service"]; 175 wantedBy = ["multi-user.target"]; 176 enableStrictShellChecks = true; 177 178 preStart = let 179 setMotd = 180 if cfg.motdFile != null && cfg.motd != null 181 then throw "motdFile and motd cannot be both set" 182 else '' 183 ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 184 ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 185 ''; 186 in '' 187 mkdir -p "${cfg.repo.scanPath}" 188 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 189 190 mkdir -p "${cfg.stateDir}/.config/git" 191 cat > "${cfg.stateDir}/.config/git/config" << EOF 192 [user] 193 name = Git User 194 email = git@example.com 195 [receive] 196 advertisePushOptions = true 197 EOF 198 ${setMotd} 199 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 200 ''; 201 202 serviceConfig = { 203 User = cfg.gitUser; 204 PermissionsStartOnly = true; 205 WorkingDirectory = cfg.stateDir; 206 Environment = [ 207 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 208 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 209 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 210 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 211 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 212 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 213 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 214 "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}" 215 "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}" 216 "KNOT_SERVER_OWNER=${cfg.server.owner}" 217 ]; 218 ExecStart = "${cfg.package}/bin/knot server"; 219 Restart = "always"; 220 }; 221 }; 222 223 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22]; 224 }; 225 }