All checks were successful
Deploy / build-deploy (push) Successful in 2m33s
239 lines
12 KiB
Text
239 lines
12 KiB
Text
# catcrafts.net Caddy site block
|
||
#
|
||
# Two upstreams: Caddy's file_server for build artifacts, and catcrafts-server
|
||
# for everything else. Point `root` at the host directory you bind-mount into
|
||
# the runner as /deploy (the "-v /path/to/webroot:/deploy" in the runner's
|
||
# config.yaml).
|
||
#
|
||
# catcrafts-server speaks PLAINTEXT HTTP/1.1 on localhost — Caddy terminates
|
||
# TLS. That is also why the backend uses Crafter.Network's ListenerHTTP1 rather
|
||
# than its HTTP/3 listener: Caddy cannot reverse_proxy to an h3 upstream.
|
||
# Do not expose port 8081 directly.
|
||
|
||
catcrafts.net {
|
||
root * /srv/catcrafts.net
|
||
encode zstd gzip
|
||
|
||
header {
|
||
Referrer-Policy "strict-origin-when-cross-origin"
|
||
X-Content-Type-Options "nosniff"
|
||
-Server
|
||
|
||
# HSTS. Caddy redirects http->https but does NOT send this header on
|
||
# its own, so without it a first visit over http is still interceptable
|
||
# and every later one is only as safe as the redirect. A shop taking
|
||
# card payments should not be relying on a redirect.
|
||
#
|
||
# includeSubDomains commits EVERY catcrafts.net subdomain to HTTPS —
|
||
# www and forgejo are both on TLS here, so it holds. Drop that token if
|
||
# a subdomain ever has to serve plaintext. `preload` is deliberately
|
||
# NOT set: submission to the browser preload list is months to undo,
|
||
# and it should be a decision, not a side effect of this file.
|
||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||
|
||
# CSP. Defence in depth rather than the primary control — markup is
|
||
# built through Catcrafts.Shared:Html, where escaping is enforced by
|
||
# the type system and forgetting is a compile error. What this adds is
|
||
# the damage limitation that escaping cannot provide:
|
||
#
|
||
# form-action 'self' the checkout form cannot be retargeted at
|
||
# another origin — the directive that matters
|
||
# most on a page that collects an address
|
||
# frame-ancestors no clickjacking the buy button
|
||
# base-uri 'none' an injected <base> cannot re-point every
|
||
# relative script src on the page
|
||
# object-src 'none' no plugin content, ever
|
||
#
|
||
# 'unsafe-inline' in script-src is a known and bounded compromise: the
|
||
# geo price hint (kGeoPriceHintScript) must run before first paint to
|
||
# set a class on <html> without a flash, so it is inline by design.
|
||
# Removing it means hashing that constant here and re-hashing on every
|
||
# edit — silently breaking the hint when someone forgets. To tighten
|
||
# this properly, move the script to a real file and give it a nonce.
|
||
# 'wasm-unsafe-eval' is what the WASM runtime needs to compile the
|
||
# module; it does not enable eval() for JavaScript.
|
||
#
|
||
# img-src and media-src allow https: because a post whose media mirror
|
||
# failed still points at the source instance's URL (see Media::Describe)
|
||
# — locking those to 'self' would blank exactly the pictures a post is
|
||
# talking about.
|
||
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https:; font-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
|
||
}
|
||
|
||
# ── cross-origin isolation, scoped ────────────────────────────────────
|
||
#
|
||
# The WASM runtime needs a cross-origin-isolated context (SharedArrayBuffer
|
||
# / threads), and these three headers are what provide it. They are NOT
|
||
# optional on a page that boots the module — without them it loads and the
|
||
# runtime fails.
|
||
#
|
||
# But they are scoped to the paths that actually load it, rather than applied
|
||
# site-wide. COEP: require-corp blocks every cross-origin subresource that
|
||
# does not opt in, so applying it to pages that have no wasm would constrain
|
||
# them for no benefit — and the shop's payment pages later must not inherit
|
||
# that restriction.
|
||
@isolated path /demos/* /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \
|
||
/catcrafts-head.js /files.json /variants.json /*.wgsl
|
||
header @isolated {
|
||
Cross-Origin-Opener-Policy "same-origin"
|
||
Cross-Origin-Embedder-Policy "require-corp"
|
||
Cross-Origin-Resource-Policy "same-origin"
|
||
}
|
||
# Subresources an isolated document pulls in must carry CORP themselves.
|
||
# The icons need it for the same reason the stylesheet does: "/" boots the
|
||
# wasm, so its icon fetches happen inside a require-corp document.
|
||
#
|
||
# -precomposed is listed here even though the rewrite below folds it onto
|
||
# apple-touch-icon.png: Caddy's directive order runs `header` BEFORE
|
||
# `rewrite`, so this matcher sees the URI as it arrived. (`handle` runs after,
|
||
# which is why @static does NOT need to list it.) Drop the path here and the
|
||
# probe still 200s, just bare.
|
||
header /styles.css Cross-Origin-Resource-Policy "same-origin"
|
||
@icons path /favicon.svg /favicon.ico /apple-touch-icon.png \
|
||
/apple-touch-icon-precomposed.png
|
||
header @icons Cross-Origin-Resource-Policy "same-origin"
|
||
|
||
# ── analytics, two tiers ──────────────────────────────────────────────
|
||
#
|
||
# GoAccess reports rebuilt hourly by catcrafts-analytics.timer from the
|
||
# access logs below. Server-side only: no client-side analytics anywhere
|
||
# on the site, per the privacy policy (request logging only).
|
||
#
|
||
# /analytics/ is public and censored - visitor IPs are anonymized at
|
||
# ingest, there is no host panel, and sensitive paths never enter its DB
|
||
# (see CENSOR_RE in deploy/catcrafts-analytics). /analytics/private/ is
|
||
# the uncensored report. Generate the hash with `caddy hash-password`.
|
||
redir /analytics /analytics/ 308
|
||
redir /analytics/private /analytics/private/ 308
|
||
|
||
# The privacy policy's "view previous versions" promise. A redirect so
|
||
# the policy text stays short and survives repo or path moves - update
|
||
# the target here, never the policy wording.
|
||
redir /legal/privacy/history https://forgejo.catcrafts.net/Catcrafts/catcrafts.net/commits/branch/master/shared/interfaces/Catcrafts.Shared-Content.cppm 302
|
||
handle_path /analytics/private/* {
|
||
basic_auth {
|
||
jorijn <bcrypt-hash-here>
|
||
}
|
||
root * /var/www/analytics-private
|
||
header Cache-Control "private, no-store"
|
||
file_server
|
||
}
|
||
handle_path /analytics/* {
|
||
root * /var/www/analytics
|
||
header Cache-Control "public, max-age=600"
|
||
file_server
|
||
}
|
||
|
||
# ── build artifacts: served from disk ─────────────────────────────────
|
||
#
|
||
# file_server does sendfile, precompressed variants and range requests far
|
||
# better than the backend would. `precompressed` serves the .zst / .gz
|
||
# siblings the CI build produces, so the ~800 KB module is never recompressed
|
||
# per request. Cache-busted by the ?v=<buildId> in index.html.
|
||
#
|
||
# CAVEAT for the icons in here: nothing cache-busts them. Clients ask for
|
||
# /favicon.svg, /favicon.ico and /apple-touch-icon.png by bare path, so an
|
||
# immutable year is exactly how long a redrawn icon takes to reach a repeat
|
||
# visitor. Long-standing for favicon.svg; the rasters inherit it deliberately
|
||
# rather than diverge. Shorten all three together if that ever bites.
|
||
#
|
||
# The rewrite is Safari's legacy Home Screen probe, from before it honoured
|
||
# <link rel="apple-touch-icon">. What it asks for is byte-identical to
|
||
# apple-touch-icon.png, so it is folded onto that rather than committed twice
|
||
# — a second copy would also ride in the wasm bundle's VFS and be fetched at
|
||
# every boot for nothing. @static below matches the rewritten path.
|
||
rewrite /apple-touch-icon-precomposed.png /apple-touch-icon.png
|
||
@static path /catcrafts*.wasm /runtime.js /dom-env.js /dom-webgpu.js \
|
||
/catcrafts-head.js /files.json /variants.json /styles.css \
|
||
/favicon.svg /favicon.ico /apple-touch-icon.png \
|
||
/robots.txt /*.wgsl /*.jpg /posts.json /rates.json
|
||
handle @static {
|
||
header Cache-Control "public, max-age=31536000, immutable"
|
||
file_server {
|
||
precompressed zstd gzip
|
||
}
|
||
}
|
||
|
||
# ── mirrored post media ──────────────────────────────────────────────
|
||
#
|
||
# Deliberately NOT under the web root: that directory is mirrored with
|
||
# `rsync --delete` on every deploy, and this media is not always
|
||
# reproducible — if a source instance deletes a file, our copy is the only
|
||
# one left. Living on the app mount puts it physically outside the delete.
|
||
#
|
||
# Filenames are the content hash, so a changed file gets a new name and the
|
||
# immutable cache lifetime is honest.
|
||
handle_path /media/* {
|
||
root * /srv/catcrafts-app/media
|
||
header Cache-Control "public, max-age=31536000, immutable"
|
||
file_server
|
||
}
|
||
|
||
# ── everything else: server-rendered ─────────────────────────────────
|
||
#
|
||
# Pages, /feed.xml, /sitemap.xml and later /api/*. The backend sets its own
|
||
# Cache-Control and returns real status codes — a 404 for an unknown path and
|
||
# a 301 for the retired /blog URLs, which a client-side router cannot do.
|
||
# Checkout rate limiting is per-peer, and this block is what makes that
|
||
# possible: reverse_proxy APPENDS the real client address to
|
||
# X-Forwarded-For, and the backend reads the rightmost entry (see
|
||
# ClientAddressFromForwarded — the leftmost is whatever the client claimed).
|
||
# That is only sound while nothing but Caddy can reach 8081, which is why
|
||
# the backend binds loopback and why the warning at the top of this file
|
||
# says not to expose the port.
|
||
#
|
||
# Caddy's own rate_limit directive is a third-party module and is NOT in a
|
||
# standard build — adding it here without rebuilding Caddy stops the server
|
||
# from starting. If volume ever justifies limiting at the edge, build Caddy
|
||
# with github.com/mholt/caddy-ratelimit first.
|
||
handle {
|
||
reverse_proxy 127.0.0.1:8081 {
|
||
health_uri /api/healthz
|
||
}
|
||
}
|
||
|
||
# If the backend is down, fall back to the static wasm shell so the site
|
||
# degrades to a client-rendered app rather than a bare Caddy error page.
|
||
# This must be handle_errors, not handle_response: handle_response only
|
||
# fires on responses an upstream actually sent, and a backend that is not
|
||
# running refuses the connection — that is an error, which only
|
||
# handle_errors sees (verified against a dead port). The error status is
|
||
# kept, so a crawler sees the honest 502 while a human gets the working
|
||
# client-rendered app; only SSR and real 404s are lost.
|
||
handle_errors 502 503 504 {
|
||
rewrite * /index.html
|
||
header Cache-Control "no-store"
|
||
file_server
|
||
}
|
||
|
||
# ── access log ────────────────────────────────────────────────────────
|
||
#
|
||
# Feeds the GoAccess analytics (see deploy/catcrafts-analytics), and is the
|
||
# only record of everything those aggregates discard: Sec-Fetch headers,
|
||
# Range behaviour, query strings, per-IP request sequences. Every bot fleet
|
||
# identified so far was caught in the raw log, not the report.
|
||
#
|
||
# roll_keep is a COUNT, so retention is really min(roll_keep × roll_size,
|
||
# roll_keep_for) — a byte budget, not the time window it reads as. 52 rolls
|
||
# is 5.2 GB, which is under a year for anything busier than 14 MB/day, and
|
||
# nothing here has ever been that quiet: 19-24 MB/day on a slow week, and
|
||
# 617 MB/day at the Hacker News peak, where 52 rolls is EIGHT DAYS. 365
|
||
# rolls (~2.6 GB gzipped, at the ~14:1 these compress to) makes
|
||
# roll_keep_for the constraint that actually decides, up to ~100 MB/day.
|
||
#
|
||
# roll_keep_for must be explicit — lumberjack's default silently deletes
|
||
# after 90 days.
|
||
log {
|
||
output file /var/log/caddy/catcrafts.net.log {
|
||
roll_size 100MiB
|
||
roll_keep 365
|
||
roll_keep_for 8760h
|
||
}
|
||
}
|
||
}
|
||
|
||
# www -> apex. DNS A/AAAA for www point at this box; Caddy obtains the
|
||
# certificate automatically. 308 to match the redirs above.
|
||
www.catcrafts.net {
|
||
redir https://catcrafts.net{uri} 308
|
||
}
|