tangled
alpha
login
or
join now
tranquil.farm
/
tranquil-pds
149
fork
atom
Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
149
fork
atom
overview
issues
19
pulls
2
pipelines
fix: better defaults, add in pg & nginx
lewis.moe
4 weeks ago
ee0f599e
3e4760c2
+507
-147
4 changed files
expand all
collapse all
unified
split
docs
install-debian.md
flake.nix
module.nix
test.nix
+8
docs/install-debian.md
···
175
175
proxy_request_buffering off;
176
176
}
177
177
178
178
+
location = /oauth/client-metadata.json {
179
179
+
root /var/www/tranquil-pds;
180
180
+
default_type application/json;
181
181
+
sub_filter_once off;
182
182
+
sub_filter_types application/json;
183
183
+
sub_filter '__PDS_HOSTNAME__' $host;
184
184
+
}
185
185
+
178
186
location /oauth/ {
179
187
proxy_pass http://127.0.0.1:3000;
180
188
proxy_http_version 1.1;
+1
-1
flake.nix
···
30
30
default = pkgs.callPackage ./shell.nix {};
31
31
});
32
32
33
33
-
nixosModules.default = import ./module.nix;
33
33
+
nixosModules.default = import ./module.nix self;
34
34
35
35
checks.x86_64-linux.integration = import ./test.nix {
36
36
pkgs = nixpkgs.legacyPackages.x86_64-linux;
+285
-118
module.nix
···
1
1
-
{
1
1
+
self: {
2
2
config,
3
3
lib,
4
4
pkgs,
···
8
8
9
9
optionalStr = lib.types.nullOr lib.types.str;
10
10
optionalInt = lib.types.nullOr lib.types.int;
11
11
-
optionalBool = lib.types.nullOr lib.types.bool;
12
11
optionalPath = lib.types.nullOr lib.types.str;
13
13
-
optionalPort = lib.types.nullOr lib.types.port;
14
12
15
13
filterNulls = lib.filterAttrs (_: v: v != null);
16
14
17
15
boolToStr = b:
18
18
-
if b == true
16
16
+
if b
19
17
then "true"
20
20
-
else if b == false
21
21
-
then "false"
22
22
-
else null;
18
18
+
else "false";
19
19
+
20
20
+
backendUrl = "http://127.0.0.1:${toString cfg.settings.server.port}";
21
21
+
22
22
+
useACME = cfg.nginx.enableACME && cfg.nginx.useACMEHost == null;
23
23
+
hasSSL = useACME || cfg.nginx.useACMEHost != null;
23
24
24
25
settingsToEnv = settings: let
25
26
raw = {
···
38
39
AWS_REGION = settings.storage.awsRegion;
39
40
S3_BUCKET = settings.storage.s3Bucket;
40
41
41
41
-
BACKUP_ENABLED = boolToStr settings.backup.enabled;
42
42
+
BACKUP_ENABLED = boolToStr settings.backup.enable;
42
43
BACKUP_STORAGE_BACKEND = settings.backup.backend;
43
44
BACKUP_STORAGE_PATH = settings.backup.path;
44
45
BACKUP_S3_BUCKET = settings.backup.s3Bucket;
···
94
95
PDS_AGE_ASSURANCE_OVERRIDE = boolToStr settings.misc.ageAssuranceOverride;
95
96
ALLOW_HTTP_PROXY = boolToStr settings.misc.allowHttpProxy;
96
97
97
97
-
SSO_GITHUB_ENABLED = boolToStr settings.sso.github.enabled;
98
98
+
SSO_GITHUB_ENABLED = boolToStr settings.sso.github.enable;
98
99
SSO_GITHUB_CLIENT_ID = settings.sso.github.clientId;
99
100
100
100
-
SSO_DISCORD_ENABLED = boolToStr settings.sso.discord.enabled;
101
101
+
SSO_DISCORD_ENABLED = boolToStr settings.sso.discord.enable;
101
102
SSO_DISCORD_CLIENT_ID = settings.sso.discord.clientId;
102
103
103
103
-
SSO_GOOGLE_ENABLED = boolToStr settings.sso.google.enabled;
104
104
+
SSO_GOOGLE_ENABLED = boolToStr settings.sso.google.enable;
104
105
SSO_GOOGLE_CLIENT_ID = settings.sso.google.clientId;
105
106
106
106
-
SSO_GITLAB_ENABLED = boolToStr settings.sso.gitlab.enabled;
107
107
+
SSO_GITLAB_ENABLED = boolToStr settings.sso.gitlab.enable;
107
108
SSO_GITLAB_CLIENT_ID = settings.sso.gitlab.clientId;
108
109
SSO_GITLAB_ISSUER = settings.sso.gitlab.issuer;
109
110
110
110
-
SSO_OIDC_ENABLED = boolToStr settings.sso.oidc.enabled;
111
111
+
SSO_OIDC_ENABLED = boolToStr settings.sso.oidc.enable;
111
112
SSO_OIDC_CLIENT_ID = settings.sso.oidc.clientId;
112
113
SSO_OIDC_ISSUER = settings.sso.oidc.issuer;
113
114
SSO_OIDC_NAME = settings.sso.oidc.name;
114
115
115
115
-
SSO_APPLE_ENABLED = boolToStr settings.sso.apple.enabled;
116
116
+
SSO_APPLE_ENABLED = boolToStr settings.sso.apple.enable;
116
117
SSO_APPLE_CLIENT_ID = settings.sso.apple.clientId;
117
118
SSO_APPLE_TEAM_ID = settings.sso.apple.teamId;
118
119
SSO_APPLE_KEY_ID = settings.sso.apple.keyId;
···
123
124
options.services.tranquil-pds = {
124
125
enable = lib.mkEnableOption "tranquil-pds AT Protocol personal data server";
125
126
126
126
-
package = lib.mkPackageOption pkgs "tranquil-pds" {};
127
127
+
package = lib.mkOption {
128
128
+
type = lib.types.package;
129
129
+
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
130
130
+
defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-pds";
131
131
+
description = "The tranquil-pds package to use";
132
132
+
};
127
133
128
134
user = lib.mkOption {
129
135
type = lib.types.str;
···
155
161
'';
156
162
};
157
163
164
164
+
database.createLocally = lib.mkOption {
165
165
+
type = lib.types.bool;
166
166
+
default = false;
167
167
+
description = ''
168
168
+
Create the postgres database and user on the local host.
169
169
+
'';
170
170
+
};
171
171
+
172
172
+
frontend.package = lib.mkOption {
173
173
+
type = lib.types.nullOr lib.types.package;
174
174
+
default = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-frontend;
175
175
+
defaultText = lib.literalExpression "self.packages.\${pkgs.stdenv.hostPlatform.system}.tranquil-frontend";
176
176
+
description = "Frontend package to serve via nginx (set null to disable frontend)";
177
177
+
};
178
178
+
179
179
+
nginx = {
180
180
+
enable = lib.mkEnableOption "nginx reverse proxy for tranquil-pds";
181
181
+
182
182
+
enableACME = lib.mkOption {
183
183
+
type = lib.types.bool;
184
184
+
default = true;
185
185
+
description = "Enable ACME for the pds domain";
186
186
+
};
187
187
+
188
188
+
useACMEHost = lib.mkOption {
189
189
+
type = lib.types.nullOr lib.types.str;
190
190
+
default = null;
191
191
+
description = ''
192
192
+
Use a pre-configured ACME certificate instead of generating one.
193
193
+
Set this to the cert name from security.acme.certs for wildcard setups.
194
194
+
REMEMBER: Handle subdomains (*.pds.example.com) require a wildcard cert via DNS-01.
195
195
+
'';
196
196
+
};
197
197
+
198
198
+
openFirewall = lib.mkOption {
199
199
+
type = lib.types.bool;
200
200
+
default = true;
201
201
+
description = "Open ports 80 and 443 in the firewall";
202
202
+
};
203
203
+
};
204
204
+
158
205
settings = {
159
206
server = {
160
207
host = lib.mkOption {
···
182
229
};
183
230
184
231
maxConnections = lib.mkOption {
185
185
-
type = optionalInt;
186
186
-
default = null;
232
232
+
type = lib.types.int;
233
233
+
default = 100;
187
234
description = "Maximum database connections";
188
235
};
189
236
190
237
minConnections = lib.mkOption {
191
191
-
type = optionalInt;
192
192
-
default = null;
238
238
+
type = lib.types.int;
239
239
+
default = 10;
193
240
description = "Minimum database connections";
194
241
};
195
242
196
243
acquireTimeoutSecs = lib.mkOption {
197
197
-
type = optionalInt;
198
198
-
default = null;
244
244
+
type = lib.types.int;
245
245
+
default = 10;
199
246
description = "Connection acquire timeout in seconds";
200
247
};
201
248
};
···
208
255
};
209
256
210
257
blobPath = lib.mkOption {
211
211
-
type = optionalPath;
212
212
-
default = null;
258
258
+
type = lib.types.str;
259
259
+
default = "${cfg.dataDir}/blobs";
260
260
+
defaultText = lib.literalExpression ''"''${cfg.dataDir}/blobs"'';
213
261
description = "Path for filesystem blob storage";
214
262
};
215
263
···
233
281
};
234
282
235
283
backup = {
236
236
-
enabled = lib.mkOption {
237
237
-
type = optionalBool;
238
238
-
default = null;
239
239
-
description = "Enable automatic repo backups";
240
240
-
};
284
284
+
enable = lib.mkEnableOption "automatic repo backups";
241
285
242
286
backend = lib.mkOption {
243
287
type = lib.types.enum ["filesystem" "s3"];
···
246
290
};
247
291
248
292
path = lib.mkOption {
249
249
-
type = optionalPath;
250
250
-
default = null;
293
293
+
type = lib.types.str;
294
294
+
default = "${cfg.dataDir}/backups";
295
295
+
defaultText = lib.literalExpression ''"''${cfg.dataDir}/backups"'';
251
296
description = "Path for filesystem backup storage";
252
297
};
253
298
···
258
303
};
259
304
260
305
retentionCount = lib.mkOption {
261
261
-
type = optionalInt;
262
262
-
default = null;
306
306
+
type = lib.types.int;
307
307
+
default = 7;
263
308
description = "Number of backups to retain";
264
309
};
265
310
266
311
intervalSecs = lib.mkOption {
267
267
-
type = optionalInt;
268
268
-
default = null;
312
312
+
type = lib.types.int;
313
313
+
default = 86400;
269
314
description = "Backup interval in seconds";
270
315
};
271
316
};
···
280
325
281
326
security = {
282
327
allowInsecureSecrets = lib.mkOption {
283
283
-
type = optionalBool;
284
284
-
default = null;
328
328
+
type = lib.types.bool;
329
329
+
default = false;
285
330
description = "Allow default/weak secrets (development only, NEVER in production ofc)";
286
331
};
287
332
};
288
333
289
334
plc = {
290
335
directoryUrl = lib.mkOption {
291
291
-
type = optionalStr;
292
292
-
default = null;
336
336
+
type = lib.types.str;
337
337
+
default = "https://plc.directory";
293
338
description = "PLC directory URL";
294
339
};
295
340
296
341
timeoutSecs = lib.mkOption {
297
297
-
type = optionalInt;
298
298
-
default = null;
342
342
+
type = lib.types.int;
343
343
+
default = 10;
299
344
description = "PLC request timeout in seconds";
300
345
};
301
346
302
347
connectTimeoutSecs = lib.mkOption {
303
303
-
type = optionalInt;
304
304
-
default = null;
348
348
+
type = lib.types.int;
349
349
+
default = 5;
305
350
description = "PLC connection timeout in seconds";
306
351
};
307
352
···
314
359
315
360
did = {
316
361
cacheTtlSecs = lib.mkOption {
317
317
-
type = optionalInt;
318
318
-
default = null;
362
362
+
type = lib.types.int;
363
363
+
default = 300;
319
364
description = "DID document cache TTL in seconds";
320
365
};
321
366
};
···
330
375
331
376
firehose = {
332
377
bufferSize = lib.mkOption {
333
333
-
type = optionalInt;
334
334
-
default = null;
378
378
+
type = lib.types.int;
379
379
+
default = 10000;
335
380
description = "Firehose broadcast channel buffer size";
336
381
};
337
382
···
344
389
345
390
notifications = {
346
391
batchSize = lib.mkOption {
347
347
-
type = optionalInt;
348
348
-
default = null;
392
392
+
type = lib.types.int;
393
393
+
default = 100;
349
394
description = "Notification queue batch size";
350
395
};
351
396
352
397
pollIntervalMs = lib.mkOption {
353
353
-
type = optionalInt;
354
354
-
default = null;
398
398
+
type = lib.types.int;
399
399
+
default = 1000;
355
400
description = "Notification queue poll interval in ms";
356
401
};
357
402
···
388
433
389
434
limits = {
390
435
maxBlobSize = lib.mkOption {
391
391
-
type = optionalInt;
392
392
-
default = null;
436
436
+
type = lib.types.int;
437
437
+
default = 10737418240;
393
438
description = "Maximum blob size in bytes";
394
439
};
395
440
};
396
441
397
442
import = {
398
443
accepting = lib.mkOption {
399
399
-
type = optionalBool;
400
400
-
default = null;
444
444
+
type = lib.types.bool;
445
445
+
default = true;
401
446
description = "Accept repository imports";
402
447
};
403
448
404
449
maxSize = lib.mkOption {
405
405
-
type = optionalInt;
406
406
-
default = null;
450
450
+
type = lib.types.int;
451
451
+
default = 1073741824;
407
452
description = "Maximum import size in bytes";
408
453
};
409
454
410
455
maxBlocks = lib.mkOption {
411
411
-
type = optionalInt;
412
412
-
default = null;
456
456
+
type = lib.types.int;
457
457
+
default = 500000;
413
458
description = "Maximum blocks per import";
414
459
};
415
460
416
461
skipVerification = lib.mkOption {
417
417
-
type = optionalBool;
418
418
-
default = null;
462
462
+
type = lib.types.bool;
463
463
+
default = false;
419
464
description = "Skip verification during import (testing only)";
420
465
};
421
466
};
422
467
423
468
registration = {
424
469
inviteCodeRequired = lib.mkOption {
425
425
-
type = optionalBool;
426
426
-
default = null;
470
470
+
type = lib.types.bool;
471
471
+
default = false;
427
472
description = "Require invite codes for registration";
428
473
};
429
474
···
434
479
};
435
480
436
481
enableSelfHostedDidWeb = lib.mkOption {
437
437
-
type = optionalBool;
438
438
-
default = null;
482
482
+
type = lib.types.bool;
483
483
+
default = true;
439
484
description = "Enable self-hosted did:web identities";
440
485
};
441
486
};
···
462
507
463
508
rateLimiting = {
464
509
disable = lib.mkOption {
465
465
-
type = optionalBool;
466
466
-
default = null;
510
510
+
type = lib.types.bool;
511
511
+
default = false;
467
512
description = "Disable rate limiting (testing only, NEVER in production you naughty!)";
468
513
};
469
514
};
470
515
471
516
scheduling = {
472
517
deleteCheckIntervalSecs = lib.mkOption {
473
473
-
type = optionalInt;
474
474
-
default = null;
518
518
+
type = lib.types.int;
519
519
+
default = 3600;
475
520
description = "Scheduled deletion check interval in seconds";
476
521
};
477
522
};
···
492
537
493
538
misc = {
494
539
ageAssuranceOverride = lib.mkOption {
495
495
-
type = optionalBool;
496
496
-
default = null;
540
540
+
type = lib.types.bool;
541
541
+
default = false;
497
542
description = "Override age assurance checks";
498
543
};
499
544
500
545
allowHttpProxy = lib.mkOption {
501
501
-
type = optionalBool;
502
502
-
default = null;
546
546
+
type = lib.types.bool;
547
547
+
default = false;
503
548
description = "Allow HTTP for proxy requests (development only)";
504
549
};
505
550
};
506
551
507
552
sso = {
508
553
github = {
509
509
-
enabled = lib.mkOption {
510
510
-
type = optionalBool;
511
511
-
default = null;
554
554
+
enable = lib.mkOption {
555
555
+
type = lib.types.bool;
556
556
+
default = false;
512
557
description = "Enable GitHub SSO";
513
558
};
514
559
···
520
565
};
521
566
522
567
discord = {
523
523
-
enabled = lib.mkOption {
524
524
-
type = optionalBool;
525
525
-
default = null;
568
568
+
enable = lib.mkOption {
569
569
+
type = lib.types.bool;
570
570
+
default = false;
526
571
description = "Enable Discord SSO";
527
572
};
528
573
···
534
579
};
535
580
536
581
google = {
537
537
-
enabled = lib.mkOption {
538
538
-
type = optionalBool;
539
539
-
default = null;
582
582
+
enable = lib.mkOption {
583
583
+
type = lib.types.bool;
584
584
+
default = false;
540
585
description = "Enable Google SSO";
541
586
};
542
587
···
548
593
};
549
594
550
595
gitlab = {
551
551
-
enabled = lib.mkOption {
552
552
-
type = optionalBool;
553
553
-
default = null;
596
596
+
enable = lib.mkOption {
597
597
+
type = lib.types.bool;
598
598
+
default = false;
554
599
description = "Enable GitLab SSO";
555
600
};
556
601
···
568
613
};
569
614
570
615
oidc = {
571
571
-
enabled = lib.mkOption {
572
572
-
type = optionalBool;
573
573
-
default = null;
616
616
+
enable = lib.mkOption {
617
617
+
type = lib.types.bool;
618
618
+
default = false;
574
619
description = "Enable generic OIDC SSO";
575
620
};
576
621
···
594
639
};
595
640
596
641
apple = {
597
597
-
enabled = lib.mkOption {
598
598
-
type = optionalBool;
599
599
-
default = null;
642
642
+
enable = lib.mkOption {
643
643
+
type = lib.types.bool;
644
644
+
default = false;
600
645
description = "Enable Apple Sign-in";
601
646
};
602
647
···
622
667
};
623
668
};
624
669
625
625
-
config = let
626
626
-
effectiveBlobPath =
627
627
-
if cfg.settings.storage.blobPath != null
628
628
-
then cfg.settings.storage.blobPath
629
629
-
else "${cfg.dataDir}/blobs";
630
630
-
effectiveBackupPath =
631
631
-
if cfg.settings.backup.path != null
632
632
-
then cfg.settings.backup.path
633
633
-
else "${cfg.dataDir}/backups";
634
634
-
envVars =
635
635
-
(settingsToEnv cfg.settings)
636
636
-
// {
637
637
-
BLOB_STORAGE_PATH = effectiveBlobPath;
638
638
-
BACKUP_STORAGE_PATH = effectiveBackupPath;
670
670
+
config = lib.mkIf cfg.enable (lib.mkMerge [
671
671
+
(lib.mkIf (cfg.settings.notifications.mailFromAddress != null) {
672
672
+
services.tranquil-pds.settings.notifications.sendmailPath =
673
673
+
lib.mkDefault "/run/wrappers/bin/sendmail";
674
674
+
})
675
675
+
676
676
+
(lib.mkIf (cfg.settings.notifications.signalSenderNumber != null) {
677
677
+
services.tranquil-pds.settings.notifications.signalCliPath =
678
678
+
lib.mkDefault (lib.getExe pkgs.signal-cli);
679
679
+
})
680
680
+
681
681
+
(lib.mkIf cfg.database.createLocally {
682
682
+
services.postgresql = {
683
683
+
enable = true;
684
684
+
ensureDatabases = [cfg.user];
685
685
+
ensureUsers = [
686
686
+
{
687
687
+
name = cfg.user;
688
688
+
ensureDBOwnership = true;
689
689
+
}
690
690
+
];
691
691
+
};
692
692
+
693
693
+
services.tranquil-pds.settings.database.url =
694
694
+
lib.mkDefault "postgresql:///${cfg.user}?host=/run/postgresql";
695
695
+
696
696
+
systemd.services.tranquil-pds = {
697
697
+
requires = ["postgresql.service"];
698
698
+
after = ["postgresql.service"];
639
699
};
640
640
-
in
641
641
-
lib.mkIf cfg.enable {
642
642
-
assertions = [
643
643
-
{
644
644
-
assertion = config.systemd.enable or true;
645
645
-
message = "services.tranquil-pds requires systemd";
646
646
-
}
647
647
-
];
700
700
+
})
701
701
+
702
702
+
(lib.mkIf cfg.nginx.enable (lib.mkMerge [
703
703
+
{
704
704
+
services.nginx = {
705
705
+
enable = true;
706
706
+
recommendedProxySettings = lib.mkDefault true;
707
707
+
recommendedTlsSettings = lib.mkDefault true;
708
708
+
recommendedGzipSettings = lib.mkDefault true;
709
709
+
recommendedOptimisation = lib.mkDefault true;
710
710
+
711
711
+
virtualHosts.${cfg.settings.server.pdsHostname} = {
712
712
+
serverAliases = ["*.${cfg.settings.server.pdsHostname}"];
713
713
+
forceSSL = hasSSL;
714
714
+
enableACME = useACME;
715
715
+
useACMEHost = cfg.nginx.useACMEHost;
716
716
+
717
717
+
root = lib.mkIf (cfg.frontend.package != null) "${cfg.frontend.package}";
718
718
+
719
719
+
extraConfig = "client_max_body_size ${toString cfg.settings.limits.maxBlobSize};";
720
720
+
721
721
+
locations = let
722
722
+
proxyLocations = {
723
723
+
"/xrpc/" = {
724
724
+
proxyPass = backendUrl;
725
725
+
proxyWebsockets = true;
726
726
+
extraConfig = ''
727
727
+
proxy_read_timeout 86400;
728
728
+
proxy_send_timeout 86400;
729
729
+
proxy_buffering off;
730
730
+
proxy_request_buffering off;
731
731
+
'';
732
732
+
};
733
733
+
734
734
+
"/oauth/" = {
735
735
+
proxyPass = backendUrl;
736
736
+
extraConfig = ''
737
737
+
proxy_read_timeout 300;
738
738
+
proxy_send_timeout 300;
739
739
+
'';
740
740
+
};
741
741
+
742
742
+
"/.well-known/" = {
743
743
+
proxyPass = backendUrl;
744
744
+
};
745
745
+
746
746
+
"/webhook/" = {
747
747
+
proxyPass = backendUrl;
748
748
+
};
648
749
750
750
+
"= /metrics" = {
751
751
+
proxyPass = backendUrl;
752
752
+
};
753
753
+
754
754
+
"= /health" = {
755
755
+
proxyPass = backendUrl;
756
756
+
};
757
757
+
758
758
+
"= /robots.txt" = {
759
759
+
proxyPass = backendUrl;
760
760
+
};
761
761
+
762
762
+
"= /logo" = {
763
763
+
proxyPass = backendUrl;
764
764
+
};
765
765
+
766
766
+
"~ ^/u/[^/]+/did\\.json$" = {
767
767
+
proxyPass = backendUrl;
768
768
+
};
769
769
+
};
770
770
+
771
771
+
frontendLocations = lib.optionalAttrs (cfg.frontend.package != null) {
772
772
+
"= /oauth/client-metadata.json" = {
773
773
+
root = "${cfg.frontend.package}";
774
774
+
extraConfig = ''
775
775
+
default_type application/json;
776
776
+
sub_filter_once off;
777
777
+
sub_filter_types application/json;
778
778
+
sub_filter '__PDS_HOSTNAME__' $host;
779
779
+
'';
780
780
+
};
781
781
+
782
782
+
"/assets/" = {
783
783
+
extraConfig = ''
784
784
+
expires 1y;
785
785
+
add_header Cache-Control "public, immutable";
786
786
+
'';
787
787
+
tryFiles = "$uri =404";
788
788
+
};
789
789
+
790
790
+
"/app/" = {
791
791
+
tryFiles = "$uri $uri/ /index.html";
792
792
+
};
793
793
+
794
794
+
"= /" = {
795
795
+
tryFiles = "/homepage.html /index.html";
796
796
+
};
797
797
+
798
798
+
"/" = {
799
799
+
tryFiles = "$uri $uri/ /index.html";
800
800
+
priority = 9999;
801
801
+
};
802
802
+
};
803
803
+
in
804
804
+
proxyLocations // frontendLocations;
805
805
+
};
806
806
+
};
807
807
+
}
808
808
+
809
809
+
(lib.mkIf cfg.nginx.openFirewall {
810
810
+
networking.firewall.allowedTCPPorts = [80 443];
811
811
+
})
812
812
+
]))
813
813
+
814
814
+
{
649
815
users.users.${cfg.user} = {
650
816
isSystemUser = true;
651
817
inherit (cfg) group;
···
656
822
657
823
systemd.tmpfiles.rules = [
658
824
"d ${cfg.dataDir} 0750 ${cfg.user} ${cfg.group} -"
659
659
-
"d ${effectiveBlobPath} 0750 ${cfg.user} ${cfg.group} -"
660
660
-
"d ${effectiveBackupPath} 0750 ${cfg.user} ${cfg.group} -"
825
825
+
"d ${cfg.settings.storage.blobPath} 0750 ${cfg.user} ${cfg.group} -"
826
826
+
"d ${cfg.settings.backup.path} 0750 ${cfg.user} ${cfg.group} -"
661
827
];
662
828
663
829
systemd.services.tranquil-pds = {
···
666
832
wants = ["network.target"];
667
833
wantedBy = ["multi-user.target"];
668
834
669
669
-
environment = envVars;
835
835
+
environment = settingsToEnv cfg.settings;
670
836
671
837
serviceConfig = {
672
838
Type = "exec";
···
698
864
RemoveIPC = true;
699
865
700
866
ReadWritePaths = [
701
701
-
effectiveBlobPath
702
702
-
effectiveBackupPath
867
867
+
cfg.settings.storage.blobPath
868
868
+
cfg.settings.backup.path
703
869
];
704
870
};
705
871
};
706
706
-
};
872
872
+
}
873
873
+
]);
707
874
}
+213
-28
test.nix
···
13
13
}: {
14
14
imports = [self.nixosModules.default];
15
15
16
16
-
services.postgresql = {
17
17
-
enable = true;
18
18
-
ensureDatabases = ["tranquil"];
19
19
-
ensureUsers = [
20
20
-
{
21
21
-
name = "tranquil";
22
22
-
ensureDBOwnership = true;
23
23
-
}
24
24
-
];
25
25
-
authentication = ''
26
26
-
local all all trust
27
27
-
host all all 127.0.0.1/32 trust
28
28
-
host all all ::1/128 trust
29
29
-
'';
30
30
-
};
31
31
-
32
16
services.tranquil-pds = {
33
17
enable = true;
34
34
-
package = self.packages.${pkgs.stdenv.hostPlatform.system}.tranquil-pds;
18
18
+
database.createLocally = true;
35
19
secretsFile = pkgs.writeText "tranquil-secrets" ''
36
20
JWT_SECRET=test-jwt-secret-must-be-32-chars-long
37
21
DPOP_SECRET=test-dpop-secret-must-be-32-chars-long
38
22
MASTER_KEY=test-master-key-must-be-32-chars-long
39
23
'';
40
24
25
25
+
nginx = {
26
26
+
enable = true;
27
27
+
enableACME = false;
28
28
+
};
29
29
+
41
30
settings = {
42
31
server.pdsHostname = "test.local";
43
32
server.host = "0.0.0.0";
44
33
45
45
-
database.url = "postgres://tranquil@localhost/tranquil";
46
46
-
47
34
storage.blobBackend = "filesystem";
48
48
-
backup.backend = "filesystem";
35
35
+
rateLimiting.disable = true;
36
36
+
security.allowInsecureSecrets = true;
49
37
};
50
38
};
51
51
-
52
52
-
networking.firewall.allowedTCPPorts = [3000];
53
39
};
54
40
55
41
testScript = ''
42
42
+
import json
43
43
+
56
44
server.wait_for_unit("postgresql.service")
57
45
server.wait_for_unit("tranquil-pds.service")
46
46
+
server.wait_for_unit("nginx.service")
58
47
server.wait_for_open_port(3000)
48
48
+
server.wait_for_open_port(80)
49
49
+
50
50
+
def xrpc(method, endpoint, *, headers=None, data=None, raw_body=None, via="nginx"):
51
51
+
host_header = "-H 'Host: test.local'" if via == "nginx" else ""
52
52
+
base = "http://localhost" if via == "nginx" else "http://localhost:3000"
53
53
+
url = f"{base}/xrpc/{endpoint}"
54
54
+
55
55
+
parts = ["curl", "-sf", "-X", method, host_header]
56
56
+
if headers:
57
57
+
parts.extend(f"-H '{k}: {v}'" for k, v in headers.items())
58
58
+
if data is not None:
59
59
+
parts.append("-H 'Content-Type: application/json'")
60
60
+
parts.append(f"-d '{json.dumps(data)}'")
61
61
+
if raw_body:
62
62
+
parts.append(f"--data-binary @{raw_body}")
63
63
+
parts.append(f"'{url}'")
64
64
+
65
65
+
return server.succeed(" ".join(parts))
66
66
+
67
67
+
def xrpc_json(method, endpoint, **kwargs):
68
68
+
return json.loads(xrpc(method, endpoint, **kwargs))
69
69
+
70
70
+
def xrpc_status(endpoint, *, headers=None, via="nginx"):
71
71
+
host_header = "-H 'Host: test.local'" if via == "nginx" else ""
72
72
+
base = "http://localhost" if via == "nginx" else "http://localhost:3000"
73
73
+
url = f"{base}/xrpc/{endpoint}"
74
74
+
75
75
+
parts = ["curl", "-s", "-o", "/dev/null", "-w", "'%{http_code}'", host_header]
76
76
+
if headers:
77
77
+
parts.extend(f"-H '{k}: {v}'" for k, v in headers.items())
78
78
+
parts.append(f"'{url}'")
79
79
+
80
80
+
return server.succeed(" ".join(parts)).strip()
81
81
+
82
82
+
def http_status(path, *, host="test.local", via="nginx"):
83
83
+
base = "http://localhost" if via == "nginx" else "http://localhost:3000"
84
84
+
return server.succeed(
85
85
+
f"curl -s -o /dev/null -w '%{{http_code}}' -H 'Host: {host}' '{base}{path}'"
86
86
+
).strip()
87
87
+
88
88
+
def http_get(path, *, host="test.local"):
89
89
+
return server.succeed(
90
90
+
f"curl -sf -H 'Host: {host}' 'http://localhost{path}'"
91
91
+
)
92
92
+
93
93
+
def http_header(path, header, *, host="test.local"):
94
94
+
return server.succeed(
95
95
+
f"curl -sI -H 'Host: {host}' 'http://localhost{path}'"
96
96
+
f" | grep -i '^{header}:'"
97
97
+
).strip()
98
98
+
99
99
+
# --- testing that stuff is up in general ---
59
100
60
101
with subtest("service is running"):
61
102
status = server.succeed("systemctl is-active tranquil-pds")
62
103
assert "active" in status
63
104
64
64
-
with subtest("blob storage directory exists"):
105
105
+
with subtest("data directories exist"):
65
106
server.succeed("test -d /var/lib/tranquil-pds/blobs")
66
107
server.succeed("test -d /var/lib/tranquil-pds/backups")
67
108
68
68
-
with subtest("healthcheck responds"):
69
69
-
server.succeed("curl -sf http://localhost:3000/xrpc/_health")
109
109
+
with subtest("postgres database created"):
110
110
+
server.succeed("sudo -u tranquil-pds psql -d tranquil-pds -c 'SELECT 1'")
111
111
+
112
112
+
with subtest("healthcheck via backend"):
113
113
+
xrpc("GET", "_health", via="backend")
114
114
+
115
115
+
with subtest("healthcheck via nginx"):
116
116
+
xrpc("GET", "_health")
117
117
+
118
118
+
with subtest("describeServer"):
119
119
+
desc = xrpc_json("GET", "com.atproto.server.describeServer")
120
120
+
assert "availableUserDomains" in desc
121
121
+
assert "did" in desc
122
122
+
assert desc.get("inviteCodeRequired") == False
123
123
+
124
124
+
with subtest("nginx serves frontend"):
125
125
+
result = server.succeed("curl -sf -H 'Host: test.local' http://localhost/")
126
126
+
assert "<html" in result.lower() or "<!" in result
127
127
+
128
128
+
with subtest("well-known proxied"):
129
129
+
code = http_status("/.well-known/atproto-did")
130
130
+
assert code != "502" and code != "504", f"well-known proxy broken: {code}"
131
131
+
132
132
+
with subtest("health endpoint proxied"):
133
133
+
code = http_status("/health")
134
134
+
assert code != "404" and code != "502", f"/health not proxied: {code}"
135
135
+
136
136
+
with subtest("robots.txt proxied"):
137
137
+
code = http_status("/robots.txt")
138
138
+
assert code != "404" and code != "502", f"/robots.txt not proxied: {code}"
139
139
+
140
140
+
with subtest("metrics endpoint proxied"):
141
141
+
code = http_status("/metrics")
142
142
+
assert code != "502", f"/metrics not proxied: {code}"
143
143
+
144
144
+
with subtest("oauth path proxied"):
145
145
+
code = http_status("/oauth/.well-known/openid-configuration")
146
146
+
assert code != "502" and code != "504", f"oauth proxy broken: {code}"
147
147
+
148
148
+
with subtest("subdomain routing works"):
149
149
+
code = http_status("/xrpc/_health", host="alice.test.local")
150
150
+
assert code == "200", f"subdomain routing failed: {code}"
70
151
71
71
-
with subtest("describeServer returns valid response"):
72
72
-
result = server.succeed("curl -sf http://localhost:3000/xrpc/com.atproto.server.describeServer")
73
73
-
assert "availableUserDomains" in result
152
152
+
with subtest("client-metadata.json served with host substitution"):
153
153
+
meta_raw = http_get("/oauth/client-metadata.json")
154
154
+
meta = json.loads(meta_raw)
155
155
+
assert "client_id" in meta, f"no client_id in client-metadata: {meta}"
156
156
+
assert "test.local" in meta_raw, "host substitution did not apply"
157
157
+
158
158
+
with subtest("static assets location exists"):
159
159
+
code = http_status("/assets/nonexistent.js")
160
160
+
assert code == "404", f"expected 404 for missing asset, got {code}"
161
161
+
162
162
+
with subtest("spa fallback works"):
163
163
+
code = http_status("/app/some/deep/route")
164
164
+
assert code == "200", f"SPA fallback broken: {code}"
165
165
+
166
166
+
with subtest("firewall ports open"):
167
167
+
server.succeed("ss -tlnp | grep ':80 '")
168
168
+
server.succeed("ss -tlnp | grep ':3000 '")
169
169
+
170
170
+
# --- test little bit of an account lifecycle ---
171
171
+
172
172
+
with subtest("create account"):
173
173
+
account = xrpc_json("POST", "com.atproto.server.createAccount", data={
174
174
+
"handle": "alice.test.local",
175
175
+
"password": "NixOS-Test-Pass-99!",
176
176
+
"email": "alice@test.local",
177
177
+
"didType": "web",
178
178
+
})
179
179
+
assert "accessJwt" in account, f"no accessJwt: {account}"
180
180
+
assert "did" in account, f"no did: {account}"
181
181
+
access_token = account["accessJwt"]
182
182
+
did = account["did"]
183
183
+
assert did.startswith("did:web:"), f"expected did:web, got {did}"
184
184
+
185
185
+
with subtest("mark account verified"):
186
186
+
server.succeed(
187
187
+
f"sudo -u tranquil-pds psql -d tranquil-pds "
188
188
+
f"-c \"UPDATE users SET email_verified = true WHERE did = '{did}'\""
189
189
+
)
190
190
+
191
191
+
auth = {"Authorization": f"Bearer {access_token}"}
192
192
+
193
193
+
with subtest("get session"):
194
194
+
session = xrpc_json("GET", "com.atproto.server.getSession", headers=auth)
195
195
+
assert session["did"] == did
196
196
+
assert session["handle"] == "alice.test.local"
197
197
+
198
198
+
with subtest("create record"):
199
199
+
created = xrpc_json("POST", "com.atproto.repo.createRecord", headers=auth, data={
200
200
+
"repo": did,
201
201
+
"collection": "app.bsky.feed.post",
202
202
+
"record": {
203
203
+
"$type": "app.bsky.feed.post",
204
204
+
"text": "hello from lewis silly nix integration test",
205
205
+
"createdAt": "2025-01-01T00:00:00.000Z",
206
206
+
},
207
207
+
})
208
208
+
assert "uri" in created, f"no uri: {created}"
209
209
+
assert "cid" in created, f"no cid: {created}"
210
210
+
record_uri = created["uri"]
211
211
+
record_cid = created["cid"]
212
212
+
rkey = record_uri.split("/")[-1]
213
213
+
214
214
+
with subtest("read record back"):
215
215
+
fetched = xrpc_json(
216
216
+
"GET",
217
217
+
f"com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={rkey}",
218
218
+
)
219
219
+
assert fetched["uri"] == record_uri
220
220
+
assert fetched["cid"] == record_cid
221
221
+
assert fetched["value"]["text"] == "hello from lewis silly nix integration test"
222
222
+
223
223
+
with subtest("upload blob"):
224
224
+
server.succeed("dd if=/dev/urandom bs=1024 count=4 of=/tmp/testblob.bin 2>/dev/null")
225
225
+
blob_resp = xrpc_json(
226
226
+
"POST",
227
227
+
"com.atproto.repo.uploadBlob",
228
228
+
headers={**auth, "Content-Type": "application/octet-stream"},
229
229
+
raw_body="/tmp/testblob.bin",
230
230
+
)
231
231
+
assert "blob" in blob_resp, f"no blob: {blob_resp}"
232
232
+
blob_ref = blob_resp["blob"]
233
233
+
assert blob_ref["size"] == 4096
234
234
+
235
235
+
with subtest("export repo as car"):
236
236
+
server.succeed(
237
237
+
f"curl -sf -H 'Host: test.local' "
238
238
+
f"-o /tmp/repo.car "
239
239
+
f"'http://localhost/xrpc/com.atproto.sync.getRepo?did={did}'"
240
240
+
)
241
241
+
size = int(server.succeed("stat -c%s /tmp/repo.car").strip())
242
242
+
assert size > 0, "exported car is empty"
243
243
+
244
244
+
with subtest("delete record"):
245
245
+
xrpc_json("POST", "com.atproto.repo.deleteRecord", headers=auth, data={
246
246
+
"repo": did,
247
247
+
"collection": "app.bsky.feed.post",
248
248
+
"rkey": rkey,
249
249
+
})
250
250
+
251
251
+
with subtest("deleted record gone"):
252
252
+
code = xrpc_status(
253
253
+
f"com.atproto.repo.getRecord?repo={did}&collection=app.bsky.feed.post&rkey={rkey}",
254
254
+
)
255
255
+
assert code != "200", f"expected non-200 for deleted record, got {code}"
256
256
+
257
257
+
with subtest("service still healthy after lifecycle"):
258
258
+
xrpc("GET", "_health")
74
259
'';
75
260
}