Monorepo for Tangled
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 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 }