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 server = {
119 listenAddr = mkOption {
120 type = types.str;
121 default = "0.0.0.0:5555";
122 description = "Address to listen on";
123 };
124
125 internalListenAddr = mkOption {
126 type = types.str;
127 default = "127.0.0.1:5444";
128 description = "Internal address for inter-service communication";
129 };
130
131 owner = mkOption {
132 type = types.str;
133 example = "did:plc:qfpnj4og54vl56wngdriaxug";
134 description = "DID of owner (required)";
135 };
136
137 dbPath = mkOption {
138 type = types.path;
139 default = "${cfg.stateDir}/knotserver.db";
140 description = "Path to the database file";
141 };
142
143 hostname = mkOption {
144 type = types.str;
145 example = "my.knot.com";
146 description = "Hostname for the server (required)";
147 };
148
149 plcUrl = mkOption {
150 type = types.str;
151 default = "https://plc.directory";
152 description = "atproto PLC directory";
153 };
154
155 jetstreamEndpoint = mkOption {
156 type = types.str;
157 default = "wss://jetstream1.us-west.bsky.network/subscribe";
158 description = "Jetstream endpoint to subscribe to";
159 };
160
161 logDids = mkOption {
162 type = types.bool;
163 default = true;
164 description = "Enable logging of DIDs";
165 };
166
167 dev = mkOption {
168 type = types.bool;
169 default = false;
170 description = "Enable development mode (disables signature verification)";
171 };
172 };
173
174 environmentFile = mkOption {
175 type = with types; nullOr path;
176 default = null;
177 example = "/etc/appview.env";
178 description = ''
179 Additional environment file as defined in {manpage}`systemd.exec(5)`.
180
181 Sensitive secrets such as {env}`KNOT_COOKIE_SECRET`,
182 {env}`KNOT_OAUTH_CLIENT_SECRET`, and {env}`KNOT_OAUTH_CLIENT_KID`
183 may be passed to the service without making them world readable in the nix store.
184 '';
185 };
186 };
187 };
188
189 config = mkIf cfg.enable {
190 environment.systemPackages = [
191 pkgs.git
192 cfg.package
193 ];
194
195 users.users.${cfg.gitUser} = {
196 isSystemUser = true;
197 useDefaultShell = true;
198 home = cfg.stateDir;
199 createHome = true;
200 group = cfg.gitUser;
201 };
202
203 users.groups.${cfg.gitUser} = {};
204
205 services.openssh = {
206 enable = true;
207 extraConfig = ''
208 Match User ${cfg.gitUser}
209 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
210 AuthorizedKeysCommandUser nobody
211 ChallengeResponseAuthentication no
212 PasswordAuthentication no
213 '';
214 };
215
216 environment.etc."ssh/keyfetch_wrapper" = {
217 mode = "0555";
218 text = ''
219 #!${pkgs.stdenv.shell}
220 ${cfg.package}/bin/knot keys \
221 -config ${cfg.stateDir}/config.yml \
222 -output authorized-keys
223 '';
224 };
225
226 systemd.services.knot = {
227 description = "knot service";
228 after = ["network.target" "sshd.service"];
229 wantedBy = ["multi-user.target"];
230 enableStrictShellChecks = true;
231
232 preStart = let
233 setMotd =
234 if cfg.motdFile != null && cfg.motd != null
235 then throw "motdFile and motd cannot be both set"
236 else ''
237 ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
238 ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
239 '';
240 in ''
241 mkdir -p "${cfg.repo.scanPath}"
242 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
243
244 mkdir -p "${cfg.stateDir}/.config/git"
245 cat > "${cfg.stateDir}/.config/git/config" << EOF
246 [user]
247 name = ${cfg.git.userName}
248 email = ${cfg.git.userEmail}
249 [receive]
250 advertisePushOptions = true
251 [uploadpack]
252 allowFilter = true
253 EOF
254 ${setMotd}
255 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
256 '';
257
258 serviceConfig = {
259 User = cfg.gitUser;
260 PermissionsStartOnly = true;
261 WorkingDirectory = cfg.stateDir;
262 Environment = [
263 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
264 "KNOT_REPO_README=${concatStringsSep "," cfg.repo.readme}"
265 "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
266 "KNOT_GIT_USER_NAME=${cfg.git.userName}"
267 "KNOT_GIT_USER_EMAIL=${cfg.git.userEmail}"
268 "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
269 "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
270 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
271 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
272 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
273 "KNOT_SERVER_PLC_URL=${cfg.server.plcUrl}"
274 "KNOT_SERVER_JETSTREAM_ENDPOINT=${cfg.server.jetstreamEndpoint}"
275 "KNOT_SERVER_OWNER=${cfg.server.owner}"
276 "KNOT_SERVER_LOG_DIDS=${
277 if cfg.server.logDids
278 then "true"
279 else "false"
280 }"
281 "KNOT_SERVER_DEV=${
282 if cfg.server.dev
283 then "true"
284 else "false"
285 }"
286 ];
287 EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
288 ExecStart = "${cfg.package}/bin/knot server -config ${cfg.stateDir}/config.yml";
289 Restart = "always";
290 RestartSec = 5;
291 };
292 };
293
294 networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
295 };
296 }