1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
|
{ depot, pkgs, lib, config, ... }:
let
cfg = config.profpatsch.website;
webRoot = "/var/www/web/users/Profpatsch/web";
observations-inbox = depot.users.Profpatsch.observations-inbox;
# Secrets are read by systemd as root and passed in as credentials, so the
# service's own DynamicUser never has filesystem access to them and they
# never enter the nix store or the process table.
secretsDir = "/var/lib/secrets/observations-inbox";
in {
options.profpatsch.website = {
enable = lib.mkEnableOption "profpatsch.de website";
observationsInboxPort = lib.mkOption {
type = lib.types.port;
default = 8778;
description = "Local port for the observations submission inbox";
};
};
config = lib.mkIf cfg.enable {
services.nginx.virtualHosts."profpatsch.de" = {
forceSSL = true;
enableACME = true;
locations."/" = {
alias = "${webRoot}/";
extraConfig = ''
index index.html;
try_files $uri $uri/ $uri.html =404;
add_header Cache-Control "no-cache";
'';
};
locations."/.well-known/openpgpkey/hu".root = pkgs.linkFarm "well-known-pgp-keys" [
{ name = ".well-known/openpgpkey/hu/dizb37aqa5h4skgu7jf1xjr4q71w4paq";
path = ./key.asc;
}
];
locations."/mlp/" = {
alias = "/sync/www/mlp/";
};
};
# --- observations: audio readings of other people's texts ---------------
#
# The site (pages + feed) is part of the website repo and arrives by the
# same `git push legosi-web canon` as everything else. The audio does not:
# it is rsynced to /var/lib/observations/audio/ and served from there.
#
# Keeping the two apart is deliberate — see observations(7). Audio in git
# would be published to sources.profpatsch.de and would grow the repo by
# roughly half a gigabyte a year that git can never reclaim.
services.nginx.virtualHosts."observations.profpatsch.de" = {
forceSSL = true;
enableACME = true;
locations."/" = {
alias = "${webRoot}/observations/";
extraConfig = ''
index index.html;
try_files $uri $uri/ $uri.html =404;
add_header Cache-Control "no-cache";
'';
};
# --- the submission inbox -------------------------------------------
#
# A drop zone for listeners to send recordings of their own. See
# observations(7) for why this exists despite the argument against
# having an inbox, and observations-inbox(1) for the mechanics.
locations."/submit" = {
proxyPass = "http://127.0.0.1:${toString cfg.observationsInboxPort}";
extraConfig = ''
# A little above the 5 MB the service accepts, so that an oversized
# upload is refused with a message rather than a reset connection.
client_max_body_size 6M;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Uploads come from phones on slow connections.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_request_buffering off;
'';
};
# The review pages. The token in the URL *is* the credential, so this
# location must not be logged: nginx would otherwise write every
# capability into access.log, which is rotated, retained and backed up.
locations."/inbox/" = {
proxyPass = "http://127.0.0.1:${toString cfg.observationsInboxPort}";
extraConfig = ''
access_log off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
locations."/audio/" = {
alias = "/var/lib/observations/audio/";
extraConfig = ''
# nginx's mime.types maps .m4a to the legacy audio/x-m4a. Podcast
# clients match on the registered type, so override it here.
types { audio/mp4 m4a; }
# Range requests are what make seeking in the player work, and what
# lets a client resume an interrupted download. Without this a
# 30-minute episode can only be fetched from the beginning.
add_header Accept-Ranges bytes;
# Episodes are immutable once published: the number in the filename
# is permanent (observations(7)), so a URL always names the same
# bytes.
add_header Cache-Control "public, max-age=31536000, immutable";
sendfile on;
sendfile_max_chunk 1m;
'';
};
};
# The audio directory is created here so the first rsync has somewhere to
# land; the files themselves are placed by hand and outlive any rebuild.
systemd.tmpfiles.rules = [
"d /var/lib/observations 0755 root root -"
"d /var/lib/observations/audio 0755 root root -"
# Secrets are placed by hand; this only ensures the directory exists and
# is unreadable to anyone but root.
"d /var/lib/secrets 0700 root root -"
"d /var/lib/secrets/observations-inbox 0700 root root -"
];
systemd.services.observations-inbox = {
description = "observations submission inbox";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = lib.escapeShellArgs [
"${observations-inbox}/bin/observations-inbox"
"-addr=127.0.0.1:${toString cfg.observationsInboxPort}"
"-db=/var/lib/observations-inbox/inbox.db"
"-base-url=https://observations.profpatsch.de"
"-passphrase-file=%d/passphrase"
"-mail-file=%d/mail"
"-gemini-key-file=%d/gemini-key"
];
# The inbox database gets its own state directory, deliberately *not*
# /var/lib/observations. That directory holds the published episode
# audio, which nginx serves; a DynamicUser service claiming it as
# StateDirectory turns it into a symlink into /var/lib/private (mode
# 0700 root), and nginx can no longer traverse it — every published
# episode starts answering 403.
StateDirectory = "observations-inbox";
DynamicUser = true;
Restart = "always";
RestartSec = "5s";
# systemd reads these as root and copies them into the per-service
# credentials tmpfs (mode 0400, wiped on stop). The service reads them
# from %d, so they never enter the environment, the nix store or the
# process table.
# The mail file carries the SMTP host, account and password together.
# Keeping the host and address in there rather than in ExecStart is
# deliberate: everything in ExecStart is visible in the process table
# and, since the unit is generated by Nix, in a world-readable store
# path.
LoadCredential = [
"passphrase:${secretsDir}/passphrase"
"mail:${secretsDir}/mail"
"gemini-key:${secretsDir}/gemini-key"
];
# It accepts uploads from the public internet, so it gets everything
# the sandbox has to offer that it does not actively need.
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
};
};
};
}
|