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 }