Monorepo for Tangled
tangled.org
1{
2 pkgs,
3 config,
4 lib,
5 ...
6}: let
7 cfg = config.services.tangled.appview;
8in
9 with lib; {
10 options = {
11 services.tangled.appview = {
12 enable = mkOption {
13 type = types.bool;
14 default = false;
15 description = "Enable tangled appview";
16 };
17
18 package = mkOption {
19 type = types.package;
20 description = "Package to use for the appview";
21 };
22
23 # core configuration
24 port = mkOption {
25 type = types.port;
26 default = 3000;
27 description = "Port to run the appview on";
28 };
29
30 listenAddr = mkOption {
31 type = types.str;
32 default = "0.0.0.0:${toString cfg.port}";
33 description = "Listen address for the appview service";
34 };
35
36 metricsListenAddr = mkOption {
37 type = types.str;
38 default = "0.0.0.0:9090";
39 description = "Listen address for the Prometheus metrics endpoint";
40 };
41
42 dbPath = mkOption {
43 type = types.str;
44 default = "/var/lib/appview/appview.db";
45 description = "Path to the SQLite database file";
46 };
47
48 appviewHost = mkOption {
49 type = types.str;
50 default = "tangled.org";
51 example = "example.com";
52 description = "Public host URL for the appview instance";
53 };
54
55 appviewName = mkOption {
56 type = types.str;
57 default = "Tangled";
58 description = "Display name for the appview instance";
59 };
60
61 dev = mkOption {
62 type = types.bool;
63 default = false;
64 description = "Enable development mode";
65 };
66
67 disallowedNicknamesFile = mkOption {
68 type = types.nullOr types.path;
69 default = null;
70 description = "Path to file containing disallowed nicknames";
71 };
72
73 # redis configuration
74 redis = {
75 addr = mkOption {
76 type = types.str;
77 default = "localhost:6379";
78 description = "Redis server address";
79 };
80
81 db = mkOption {
82 type = types.int;
83 default = 0;
84 description = "Redis database number";
85 };
86 };
87
88 # jetstream configuration
89 jetstream = {
90 endpoint = mkOption {
91 type = types.str;
92 default = "wss://jetstream1.us-east.bsky.network/subscribe";
93 description = "Jetstream WebSocket endpoint";
94 };
95 };
96
97 # knotstream consumer configuration
98 knotstream = {
99 retryInterval = mkOption {
100 type = types.str;
101 default = "60s";
102 description = "Initial retry interval for knotstream consumer";
103 };
104
105 maxRetryInterval = mkOption {
106 type = types.str;
107 default = "120m";
108 description = "Maximum retry interval for knotstream consumer";
109 };
110
111 connectionTimeout = mkOption {
112 type = types.str;
113 default = "5s";
114 description = "Connection timeout for knotstream consumer";
115 };
116
117 workerCount = mkOption {
118 type = types.int;
119 default = 64;
120 description = "Number of workers for knotstream consumer";
121 };
122
123 queueSize = mkOption {
124 type = types.int;
125 default = 100;
126 description = "Queue size for knotstream consumer";
127 };
128 };
129
130 # spindlestream consumer configuration
131 spindlestream = {
132 retryInterval = mkOption {
133 type = types.str;
134 default = "60s";
135 description = "Initial retry interval for spindlestream consumer";
136 };
137
138 maxRetryInterval = mkOption {
139 type = types.str;
140 default = "120m";
141 description = "Maximum retry interval for spindlestream consumer";
142 };
143
144 connectionTimeout = mkOption {
145 type = types.str;
146 default = "5s";
147 description = "Connection timeout for spindlestream consumer";
148 };
149
150 workerCount = mkOption {
151 type = types.int;
152 default = 64;
153 description = "Number of workers for spindlestream consumer";
154 };
155
156 queueSize = mkOption {
157 type = types.int;
158 default = 100;
159 description = "Queue size for spindlestream consumer";
160 };
161 };
162
163 # resend configuration
164 resend = {
165 sentFrom = mkOption {
166 type = types.str;
167 default = "noreply@notifs.tangled.sh";
168 description = "Email address to send notifications from";
169 };
170 };
171
172 # posthog configuration
173 posthog = {
174 endpoint = mkOption {
175 type = types.str;
176 default = "https://eu.i.posthog.com";
177 description = "PostHog API endpoint";
178 };
179 };
180
181 # camo configuration
182 camo = {
183 host = mkOption {
184 type = types.str;
185 default = "https://camo.tangled.sh";
186 description = "Camo proxy host URL";
187 };
188 };
189
190 # avatar configuration
191 avatar = {
192 host = mkOption {
193 type = types.str;
194 default = "https://avatar.tangled.sh";
195 description = "Avatar service host URL";
196 };
197 };
198
199 plc = {
200 url = mkOption {
201 type = types.str;
202 default = "https://plc.directory";
203 description = "PLC directory URL";
204 };
205 };
206
207 pds = {
208 host = mkOption {
209 type = types.str;
210 default = "https://tngl.sh";
211 description = "PDS host URL";
212 };
213 };
214
215 label = {
216 defaults = mkOption {
217 type = types.listOf types.str;
218 default = [
219 "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/wontfix"
220 "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue"
221 "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate"
222 "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/documentation"
223 "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/assignee"
224 ];
225 description = "Default label definitions";
226 };
227
228 goodFirstIssue = mkOption {
229 type = types.str;
230 default = "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/good-first-issue";
231 description = "Good first issue label definition";
232 };
233 };
234
235 # ssh log server configuration
236 ssh = {
237 enable = mkOption {
238 type = types.bool;
239 default = false;
240 description = "Enable the SSH pipeline log server";
241 };
242
243 listenAddr = mkOption {
244 type = types.str;
245 default = "0.0.0.0:3333";
246 description = "Listen address for the SSH log server";
247 };
248
249 hostKeyPath = mkOption {
250 type = types.nullOr types.str;
251 default = null;
252 example = "/var/lib/appview/ssh_host_key";
253 description = ''
254 Path to the SSH host key file. If null, an ephemeral key is
255 generated on each startup. generate with:
256
257 ssh-keygen -t ed25519 -N "" -f /var/lib/appview/ssh_host_key
258 '';
259 };
260 };
261
262 environmentFile = mkOption {
263 type = with types; nullOr path;
264 default = null;
265 example = "/etc/appview.env";
266 description = ''
267 Additional environment file as defined in {manpage}`systemd.exec(5)`.
268
269 Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET`,
270 {env}`TANGLED_OAUTH_CLIENT_SECRET`, {env}`TANGLED_RESEND_API_KEY`,
271 {env}`TANGLED_CAMO_SHARED_SECRET`, {env}`TANGLED_AVATAR_SHARED_SECRET`,
272 {env}`TANGLED_REDIS_PASS`, {env}`TANGLED_PDS_ADMIN_SECRET`,
273 {env}`TANGLED_CLOUDFLARE_API_TOKEN`, {env}`TANGLED_CLOUDFLARE_ZONE_ID`,
274 {env}`TANGLED_CLOUDFLARE_TURNSTILE_SITE_KEY`,
275 {env}`TANGLED_CLOUDFLARE_TURNSTILE_SECRET_KEY`,
276 {env}`TANGLED_POSTHOG_API_KEY`, {env}`TANGLED_APP_PASSWORD`,
277 and {env}`TANGLED_ALT_APP_PASSWORD` may be passed to the service
278 without making them world readable in the nix store.
279 '';
280 };
281 };
282 };
283
284 config = mkIf cfg.enable {
285 services.redis.servers.appview = {
286 enable = true;
287 port = 6379;
288 };
289
290 systemd.services.appview = {
291 description = "tangled appview service";
292 wantedBy = ["multi-user.target"];
293 after = ["redis-appview.service" "network-online.target"];
294 requires = ["redis-appview.service"];
295 wants = ["network-online.target"];
296
297 path = [pkgs.diffutils];
298
299 serviceConfig = {
300 Type = "simple";
301 ExecStart = "${cfg.package}/bin/appview";
302 Restart = "always";
303 RestartSec = "10s";
304 EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
305
306 # state directory
307 StateDirectory = "appview";
308 WorkingDirectory = "/var/lib/appview";
309
310 # security hardening
311 NoNewPrivileges = true;
312 PrivateTmp = true;
313 ProtectSystem = "strict";
314 ProtectHome = true;
315 ReadWritePaths =
316 ["/var/lib/appview"]
317 ++ optionals (cfg.ssh.enable && cfg.ssh.hostKeyPath != null) [cfg.ssh.hostKeyPath];
318 };
319
320 environment =
321 {
322 TANGLED_DB_PATH = cfg.dbPath;
323 TANGLED_LISTEN_ADDR = cfg.listenAddr;
324 TANGLED_METRICS_LISTEN_ADDR = cfg.metricsListenAddr;
325 TANGLED_APPVIEW_HOST = cfg.appviewHost;
326 TANGLED_APPVIEW_NAME = cfg.appviewName;
327 TANGLED_DEV =
328 if cfg.dev
329 then "true"
330 else "false";
331 }
332 // optionalAttrs (cfg.disallowedNicknamesFile != null) {
333 TANGLED_DISALLOWED_NICKNAMES_FILE = cfg.disallowedNicknamesFile;
334 }
335 // {
336 TANGLED_REDIS_ADDR = cfg.redis.addr;
337 TANGLED_REDIS_DB = toString cfg.redis.db;
338
339 TANGLED_JETSTREAM_ENDPOINT = cfg.jetstream.endpoint;
340
341 TANGLED_KNOTSTREAM_RETRY_INTERVAL = cfg.knotstream.retryInterval;
342 TANGLED_KNOTSTREAM_MAX_RETRY_INTERVAL = cfg.knotstream.maxRetryInterval;
343 TANGLED_KNOTSTREAM_CONNECTION_TIMEOUT = cfg.knotstream.connectionTimeout;
344 TANGLED_KNOTSTREAM_WORKER_COUNT = toString cfg.knotstream.workerCount;
345 TANGLED_KNOTSTREAM_QUEUE_SIZE = toString cfg.knotstream.queueSize;
346
347 TANGLED_SPINDLESTREAM_RETRY_INTERVAL = cfg.spindlestream.retryInterval;
348 TANGLED_SPINDLESTREAM_MAX_RETRY_INTERVAL = cfg.spindlestream.maxRetryInterval;
349 TANGLED_SPINDLESTREAM_CONNECTION_TIMEOUT = cfg.spindlestream.connectionTimeout;
350 TANGLED_SPINDLESTREAM_WORKER_COUNT = toString cfg.spindlestream.workerCount;
351 TANGLED_SPINDLESTREAM_QUEUE_SIZE = toString cfg.spindlestream.queueSize;
352
353 TANGLED_RESEND_SENT_FROM = cfg.resend.sentFrom;
354
355 TANGLED_POSTHOG_ENDPOINT = cfg.posthog.endpoint;
356
357 TANGLED_CAMO_HOST = cfg.camo.host;
358
359 TANGLED_AVATAR_HOST = cfg.avatar.host;
360
361 TANGLED_PLC_URL = cfg.plc.url;
362
363 TANGLED_PDS_HOST = cfg.pds.host;
364
365 TANGLED_LABEL_DEFAULTS = concatStringsSep "," cfg.label.defaults;
366 TANGLED_LABEL_GFI = cfg.label.goodFirstIssue;
367 }
368 // optionalAttrs cfg.ssh.enable {
369 TANGLED_SSH_ENABLED = "true";
370 TANGLED_SSH_LISTEN_ADDR = cfg.ssh.listenAddr;
371 }
372 // optionalAttrs (cfg.ssh.enable && cfg.ssh.hostKeyPath != null) {
373 TANGLED_SSH_HOST_KEY_PATH = cfg.ssh.hostKeyPath;
374 };
375 };
376 };
377 }