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