Skip to main content

max / makenotwork

9.7 KB · 282 lines History Blame Raw
1 # Makenotwork Caddy Configuration
2 # Place in /etc/caddy/Caddyfile on the server
3 #
4 # TLS: Cloudflare Origin CA cert (wildcard *.makenot.work + makenot.work)
5 # All HTTPS traffic routed through Cloudflare proxy (origin IP hidden).
6 # Authenticated Origin Pulls: only Cloudflare can reach the origin.
7 # git.makenot.work redirects browser visits to the web UI.
8 # SSH clone uses ssh.makenot.work (proxy OFF in Cloudflare).
9 #
10 # Custom domains: on-demand TLS via Let's Encrypt (ACME HTTP-01).
11 # The ask endpoint validates that the domain is verified before issuing a cert.
12 # makenot.work subdomains remain protected by Cloudflare mTLS even with ports open.
13
14 {
15 on_demand_tls {
16 ask http://localhost:3000/api/domains/caddy-ask
17 }
18 }
19
20 # Shared TLS config: Origin CA cert + Authenticated Origin Pulls (mTLS)
21 (cloudflare_tls) {
22 tls /etc/caddy/cloudflare-origin.pem /etc/caddy/cloudflare-origin-key.pem {
23 client_auth {
24 mode require_and_verify
25 trusted_ca_cert_file /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem
26 }
27 }
28 }
29
30 makenot.work {
31 import cloudflare_tls
32
33 # Block internal API from external access (CLI uses localhost directly)
34 @internal path /api/internal/*
35 respond @internal 404
36
37 # Reverse proxy to application (includes /docs routes)
38 reverse_proxy localhost:3000
39
40 # Security headers (CSP is set by the app, do not duplicate here)
41 header {
42 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
43 }
44
45 # Branded error pages. 404 and 500 come from the app: they are embedded in
46 # the binary (src/routes/pages/public/error_pages.rs), so the copy and brand
47 # glyphs cannot drift a release behind the site, and no per-deploy file
48 # upload keeps them in sync.
49 #
50 # The catch-all stays a file read. It fires when the app is unreachable,
51 # which is precisely when the app cannot render its own page, so 502.html
52 # must exist on disk. Sando ships error-pages/ inside each release, so the
53 # root goes through the `current` symlink and follows every deploy. It is NOT
54 # /opt/makenotwork/error-pages: that was legacy deploy.sh's REMOTE_DIR, and
55 # pointing at it left the app-down page serving Caddy's bare 404 instead.
56 handle_errors {
57 @404 expression {err.status_code} == 404
58 handle @404 {
59 rewrite * /__errors/404.html
60 reverse_proxy localhost:3000
61 }
62 @500 expression {err.status_code} == 500
63 handle @500 {
64 rewrite * /__errors/500.html
65 reverse_proxy localhost:3000
66 }
67 handle {
68 root * /opt/mnw/current/error-pages
69 rewrite * /502.html
70 file_server
71 }
72 }
73
74 encode gzip zstd
75
76 log {
77 output file /var/log/caddy/makenotwork.log
78 format json
79 }
80 }
81
82 # Creator custom pages (u.makenot.work).
83 # Same app process as the apex; the server's host-dispatch middleware renders
84 # sanitized creator HTML/CSS here, keeps this origin cookieless, and sets its own
85 # strict CSP (do not duplicate CSP here). TLS is the wildcard *.makenot.work
86 # Origin CA cert via cloudflare_tls.
87 #
88 # This explicit block is REQUIRED: without it, u.makenot.work falls into the
89 # on-demand-TLS catch-all (:443) below, whose ask endpoint refuses a cert
90 # because u.makenot.work is not a verified custom domain.
91 #
92 # Requires a Cloudflare DNS record for u.makenot.work (proxy ON), or coverage by
93 # a *.makenot.work wildcard DNS record.
94 u.makenot.work {
95 import cloudflare_tls
96
97 # Internal API is unreachable here anyway (host dispatch only serves custom
98 # pages + /static), but block it explicitly as defense in depth.
99 @internal path /api/internal/*
100 respond @internal 404
101
102 reverse_proxy localhost:3000
103
104 header {
105 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
106 }
107
108 encode gzip zstd
109
110 log {
111 output file /var/log/caddy/userpages.log
112 format json
113 }
114 }
115
116 # Multithreaded forum
117 forums.makenot.work {
118 import cloudflare_tls
119
120 reverse_proxy localhost:3400
121
122 header {
123 X-Frame-Options "SAMEORIGIN"
124 X-Content-Type-Options "nosniff"
125 X-XSS-Protection "1; mode=block"
126 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
127 Permissions-Policy "camera=(), microphone=(), geolocation=()"
128 Referrer-Policy "strict-origin-when-cross-origin"
129 Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self' https://makenot.work"
130 }
131
132 encode gzip zstd
133
134 log {
135 output file /var/log/caddy/forums.log
136 format json
137 }
138 }
139
140 # CDN for promoted public images: reverse-proxies to Hetzner Object Storage.
141 # Cloudflare caches responses at the edge (free egress). Origin only hit on cache miss.
142 # Requires: public-read s3:GetObject policy on the public bucket, Cloudflare DNS A
143 # record for cdn.makenot.work (proxy ON), and CDN_BASE_URL=https://cdn.makenot.work
144 # so the app emits edge URLs rather than direct-origin ones.
145 cdn.makenot.work {
146 import cloudflare_tls
147
148 # Only allow GET (downloads). Block mutations.
149 @not_get not method GET HEAD
150 respond @not_get 405
151
152 # Prepend the bucket name to the URI path and proxy to Hetzner Object Storage.
153 # This bucket MUST be the one named by the app's S3_PUBLIC_BUCKET, and MUST NOT
154 # be S3_BUCKET: there is no signature check here, so pointing it at the main
155 # bucket would serve every paid download unsigned. Only immutably-public image
156 # content (covers, gallery, item/project images) is promoted into the public
157 # bucket; audio, video, and downloads stay private and are always presigned by
158 # the app (see src/routes/storage/downloads.rs).
159 rewrite * /makenotwork-public{uri}
160 reverse_proxy https://fsn1.your-objectstorage.com {
161 header_up Host fsn1.your-objectstorage.com
162 }
163
164 header {
165 X-Content-Type-Options "nosniff"
166 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
167 Access-Control-Allow-Origin "https://makenot.work"
168 Access-Control-Allow-Methods "GET, HEAD"
169 # Cache-Control is set on the S3 objects themselves (immutable).
170 # Cloudflare respects the origin's Cache-Control header.
171 }
172
173 log {
174 output file /var/log/caddy/cdn.log
175 format json
176 }
177 }
178
179 # Alloy hotfix RPM repository: reverse-proxies to Hetzner Object Storage.
180 # Modelled on the cdn block above and deliberately NOT folded into it: that
181 # block rewrites into makenotwork-public by path, and its comment is emphatic
182 # about why that bucket must never be the main one. A separate block for a
183 # separate bucket keeps that argument intact.
184 #
185 # What fetches this is dnf and rpm-ostree, by path, with no auth: repomd.xml,
186 # then the metadata and packages it names. Nothing the server proper offers is
187 # used, which is the point — a published hotfix reaches machines with no deploy
188 # at all. Writes never come through here; they are presigned PUTs straight to
189 # the bucket, minted by /api/v1/admin/rpm/uploads.
190 #
191 # Requires: public-read s3:GetObject policy on makenotwork-rpm, a Cloudflare
192 # DNS A record for rpm.makenot.work (proxy ON), and the server configured with
193 # S3_RPM_BUCKET (or RPM_S3_*) plus RPM_BASE_URL=https://rpm.makenot.work.
194 rpm.makenot.work {
195 import cloudflare_tls
196
197 # Only allow GET (fetches). Block mutations — publishing does not come
198 # through Caddy at all.
199 @not_get not method GET HEAD
200 respond @not_get 405
201
202 rewrite * /makenotwork-rpm{uri}
203 reverse_proxy https://fsn1.your-objectstorage.com {
204 header_up Host fsn1.your-objectstorage.com
205 }
206
207 header {
208 X-Content-Type-Options "nosniff"
209 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
210 # Cache-Control is set on the objects themselves at presign time, and
211 # the split is load-bearing: packages are immutable and cache forever,
212 # repomd.xml must not be cached or a published fix never arrives.
213 # See routes::rpm::cache_control_for.
214 }
215
216 log {
217 output file /var/log/caddy/rpm.log
218 format json
219 }
220 }
221
222 # dl.maxj.phd download host retired 2026-06-09. MNW now serves all downloads
223 # (creator product pages / makenot.work DMGs). The maxjphd_tls mTLS snippet and
224 # the dl.maxj.phd file_server block were removed with it; the /etc/caddy/maxj-phd-origin*
225 # cert/key are now unused on prod and can be deleted there.
226
227 # Redirect www to canonical domain
228 # Note: makenotwork.com and www.makenotwork.com redirects are handled by
229 # Cloudflare Redirect Rules (edge-level, no origin hit needed).
230 # Those domains are not covered by the *.makenot.work Origin CA cert.
231 # Redirect git subdomain browser visits to web UI
232 git.makenot.work {
233 import cloudflare_tls
234 redir https://makenot.work/git permanent
235 }
236
237 www.makenot.work {
238 import cloudflare_tls
239 redir https://makenot.work{uri} permanent
240 }
241
242 # Custom domains: on-demand TLS via Let's Encrypt.
243 # Caddy calls /api/domains/caddy-ask before issuing a cert for any domain.
244 # makenot.work subdomains are unaffected (matched by explicit blocks above
245 # which use Cloudflare Origin CA + mTLS).
246 :443 {
247 tls {
248 on_demand
249 }
250
251 # Custom domains connect directly to the origin (no Cloudflare mTLS in front),
252 # so any client-supplied CF-Connecting-IP / X-Forwarded-For is forgeable. The
253 # app trusts CF-Connecting-IP for rate-limiting, lockouts, and audit logs, so
254 # overwrite it with the real TCP peer and strip XFF before proxying, a client
255 # can no longer mint fake source IPs to evade per-IP throttles or poison logs.
256 reverse_proxy localhost:3000 {
257 # Set (replace) CF-Connecting-IP to the real TCP peer, overwrites any
258 # value the client sent. Strip X-Forwarded-For so no forged value reaches
259 # the app (the app ignores XFF anyway; this is hygiene).
260 header_up CF-Connecting-IP {http.request.remote.host}
261 header_up -X-Forwarded-For
262 }
263
264 header {
265 X-Content-Type-Options "nosniff"
266 Strict-Transport-Security "max-age=31536000; includeSubDomains"
267 Referrer-Policy "strict-origin-when-cross-origin"
268 }
269
270 encode gzip zstd
271
272 log {
273 output file /var/log/caddy/custom-domains.log
274 format json
275 }
276 }
277
278 # HTTP catch-all: redirect to HTTPS (also needed for ACME HTTP-01 challenges)
279 :80 {
280 redir https://{host}{uri} permanent
281 }
282