# FeatherReader — in-container Caddy edge.
#
# This is the ONLY listener bound off-loopback (it is the Fly internal_port).
# TLS is terminated by Cloudflare (Full-strict) + Fly's force_https in front, so
# Caddy itself serves plain HTTP on :8080 with automatic HTTPS turned off.
#
# Routing (grounded in the source):
# * OAuth routing is SPLIT into two importable files, one per backend, chosen
# by FEATHERREADER_REPO_BACKEND at container start
# (deploy/caddy-oauth-sidecar.conf and deploy/caddy-oauth-rust.conf). The
# notes below describe the sidecar variant, which is the default.
# * The sidecar's PUBLIC OAuth endpoints live at its ROOT (/login, /callback,
# /client-metadata.json, /jwks.json). In prod SIDECAR_PUBLIC_URL is
# https://feather-reader.com/oauth, so client_id/redirect_uri are under
# /oauth/* — Caddy proxies /oauth/* to the sidecar with the /oauth prefix
# STRIPPED.
# * The Rust app owns everything else (the htmx UI, /health, /static/*, and its
# OWN post-login callback route /oauth/callback).
# * COLLISION: both the sidecar's atproto redirect_uri and the Rust app's
# callback are the literal path /oauth/callback. They are disambiguated by
# query param (grounded in oauth-sidecar/src/server.ts):
# - PDS -> SIDECAR (/callback): code=&state= (success) OR error=&state=
# (user denied / OAuth error). NEITHER carries session_id or
# error_description. This MUST reach the sidecar — only it holds the
# per-request PKCE/state row and can call oauthClient.callback().
# - SIDECAR -> RUST APP: ?session_id=<id> on success (server.ts:196), or
# ?error=OAuthCallbackFailed&error_description=<msg> on the sidecar's own
# failure hand-off (server.ts:200-204). Both are app-bound.
# So we route /oauth/callback to the APP when it carries `session_id` OR
# `error_description` (the sidecar->app hop markers), and let everything else
# under /oauth/callback (crucially the PDS's `?error=&state=` deny path) fall
# through to the sidecar. Handled BEFORE the generic /oauth/* rule.
# * /internal/* is the sidecar's shared-secret server-to-server API and MUST
# NOT be publicly reachable. In this topology the Rust app reaches it over
# loopback (SIDECAR_INTERNAL_URL=http://127.0.0.1:8081), so it never needs to
# be edge-routable. We block BOTH the bare /internal/* AND the prefixed
# /oauth/internal/* (which would otherwise fall through handle_path /oauth/*
# and reach the sidecar's /internal/* after prefix strip). Combined with the
# sidecar binding loopback-only and its own X-Internal-Secret guard, the
# internal API is genuinely unreachable from outside the container.
{
# Cloudflare + Fly terminate TLS; no ACME here.
auto_https off
# Trust the loopback proxy chain only; the real client IP arrives as a header
# that we re-assert below (the Rust app validates it via
# FEATHERREADER_TRUSTED_IP_HEADER=cf-connecting-ip).
servers {
trusted_proxies static private_ranges
}
admin off
# **The redaction below must ALSO cover the error logger, not just the access
# log.** A site-level `log` directive configures only `http.log.access.*`.
# Errors during handling — every 502/503 from an upstream that is down, slow
# or restarting — are emitted by `http.log.error.*`, a DIFFERENT logger that
# inherits nothing from it and dumps the full request, headers and all.
#
# Measured, not assumed: with only the site-level filter in place, a request
# that failed upstream logged `"X-Origin-Auth":["<the secret>"]` verbatim from
# `http.log.error.log0` while `http.log.access.log0` correctly showed
# `REDACTED`. So the leak would have survived the fix, and would have fired on
# exactly the occasions an operator tails the logs — an outage.
# **Named, with `include http.log.error` — NOT a reconfigured `default`.** The
# adapter then adds `http.log.error` to `default`'s `exclude`, so there is no
# duplicate emission and `default` keeps its own writer and encoder.
#
# Reconfiguring `default` directly also redacted correctly, but it silently
# re-pointed EVERY runtime log line: stderr->stdout, JSON->console with raw
# ANSI colour embedded (measured 1 stdout / 15 stderr before, 17 / 0 after).
# Restating `default`'s real shape by hand fixes that only for as long as the
# restatement stays accurate. This form cannot drift out of sync with it.
log origin_errors {
output stderr
include http.log.error
format filter {
wrap json
fields {
request>headers>X-Origin-Auth replace REDACTED
request>headers>Cookie replace REDACTED
request>uri query {
replace code REDACTED
replace state REDACTED
replace session_id REDACTED
}
}
}
}
}
:8080 {
# **`X-Origin-Auth` must never reach the log.** It IS the origin lock — the
# shared secret Cloudflare injects and the matcher below compares against —
# and the console encoder writes every request header verbatim, so until this
# filter existed the secret appeared in full on EVERY access-log line. That
# put it in `fly logs`, in any log drain, and in the scrollback of anyone who
# ever tailed this app. A control whose key is published beside every request
# it guards is not a control.
#
# Caddy redacts the standard credential headers by default, which is exactly
# why this one slipped: `X-Origin-Auth` is a custom name, so nothing knew to
# treat it as a secret. `Cookie` is filtered here too rather than trusted to
# that default. Caddy's built-in redaction already covers `Cookie`,
# `Authorization`, `Proxy-Authorization` and `Set-Cookie`, as a literal
# `REDACTED` replacement rather than a hash — so this is belt-and-braces, not
# a fix. It uses `replace` to match that shape: `delete` drops the field
# entirely and loses the presence signal the next paragraph argues for.
#
# `replace`, not `delete`, for the origin header: it keeps the FIELD while
# losing the value, so a 403 still distinguishes "arrived without the header"
# (bypassed Cloudflare) from "arrived with the wrong one" (stale secret,
# mid-rotation). Deleting it would collapse those into the same log line, and
# they call for opposite responses.
# **The OAuth query string is a credential too.** The header filters above
# leave `uri` untouched, and `/oauth/callback` carries the PDS's `?code=` —
# a single-use authorization code — plus the `state` that binds it to the
# pending row. Until this filter existed both appeared verbatim on every
# callback access-log line, which is the same distribution the paragraph
# above objects to for the origin secret: `fly logs`, any drain, anyone's
# scrollback. A logged code is not directly replayable (it is one-shot, and
# the exchange also demands the PKCE verifier, a DPoP proof and
# `private_key_jwt`), so this is hygiene rather than an open hole — but the
# argument for redacting the header is the argument for redacting these.
#
# `replace`, not `delete`, for the same reason as above: the field survives
# with its value gone, so a failed callback still shows WHICH parameters
# arrived. `iss`, `error` and `error_description` are deliberately NOT
# filtered — they name the issuer and say why a handshake failed, they are
# not secrets, and they are most of the diagnostic value of these lines.
#
# This is a LOG filter and touches nothing else: the `/oauth/callback`
# routing below still matches on `session_id`/`error_description` as before.
log {
output stdout
format filter {
wrap console
fields {
request>headers>X-Origin-Auth replace REDACTED
request>headers>Cookie replace REDACTED
request>uri query {
replace code REDACTED
replace state REDACTED
replace session_id REDACTED
}
}
}
}
# --- Origin lock: require Cloudflare's injected shared secret --------------
# Cloudflare fronts this app (Full-strict) and sets `X-Origin-Auth` on every
# request via a Transform Rule. A request that reaches the Fly origin WITHOUT
# it bypassed Cloudflare (someone hit the origin IP directly) — reject it, so
# the cf-connecting-ip trust (rate limiting) and the edge WAF/cache rules
# can't be sidestepped. The secret comes from the FEATHERREADER_ORIGIN_SECRET
# env (a `fly secret`), never baked into this file/image.
#
# `/health` is EXEMPT: Fly's internal health probe hits it directly (not
# through Cloudflare, so no header) — gating it would flap the machine
# unhealthy.
#
# That exemption BOUNDS what /health may say. It used to return only a static
# "ok featherreader/<ver>" string; it now also reports whether the database is
# reachable, whether the poll loop is ticking, whether fetching is paused, and
# which OAuth backend is live — so an operator can diagnose an instance whose
# feeds have stopped, including during an OAuth outage when the session-gated
# /admin/metrics is exactly as unreachable as the thing it would diagnose.
#
# Every one of those is a machine fact of the same class /stats already
# publishes to anyone: no user counts, no DIDs, no feed URLs, and no precise
# internal numbers. The version string in the same response already pins the
# exact code, so the backend name discloses nothing further. Anything outside
# that class must go behind the origin lock, not here.
#
# Placed FIRST so nothing below routes for a header-less request.
#
# This matcher alone does NOT fail closed, contrary to what this comment said
# for months: with the secret unset it compares against "", which an
# empty-valued header satisfies. The `@empty_origin_value` guard below is what
# actually makes a missing or mistyped secret lock the door.
@no_origin_secret {
not path /health
not header X-Origin-Auth {env.FEATHERREADER_ORIGIN_SECRET}
}
handle @no_origin_secret {
respond 403
}
# **An EMPTY header value is a third case, and it FAILED OPEN.**
#
# `not header X-Origin-Auth {env.FEATHERREADER_ORIGIN_SECRET}` expands to
# `not header X-Origin-Auth ""` when the secret is unset. An empty-valued
# header then MATCHES the empty expected value, `not` inverts it, the matcher
# above does not fire, and the request is proxied. Measured with the secret
# unset: no header -> 403, junk -> 403, but `X-Origin-Auth:` with no value
# -> **200**. The comment below claimed the opposite for months, and
# `oauth-sidecar/src/client-ip.ts` founds the whole `cf-connecting-ip` trust
# model on that claim.
#
# This makes the documented property true: an empty value is refused
# regardless of what the secret is, so a missing or mistyped
# FEATHERREADER_ORIGIN_SECRET locks the door rather than opening it.
@empty_origin_value {
not path /health
header_regexp X-Origin-Auth ^$
}
handle @empty_origin_value {
respond 403
}
# --- Hard block: the sidecar's internal API is never edge-routable --------
# Matches the bare path AND the /oauth-prefixed path (the latter would else
# fall through handle_path /oauth/* -> sidecar /internal/*). This block is
# placed FIRST so nothing below can route to it.
@internal path /internal /internal/* /oauth/internal /oauth/internal/*
handle @internal {
respond 404
}
# --- OAuth routing: selected by FEATHERREADER_REPO_BACKEND ----------------
# container-entrypoint.sh installs ONE of deploy/caddy-oauth-{sidecar,rust}.conf
# as /etc/caddy/oauth-routes.conf before Caddy starts. The two backends cannot
# share /oauth/callback -- the PDS sends identical `?code=&state=&iss=` to both,
# so nothing in the request distinguishes them and one process must own the
# path. See either conf file for the full reasoning.
import /etc/caddy/oauth-routes.conf
# --- Everything else: the Rust featherreader app --------------------------
# The UI, /health, and /static/* all live here.
#
# CLIENT-IP TRUST MODEL (important): the app reads the real visitor IP from
# Cf-Connecting-Ip (FEATHERREADER_TRUSTED_IP_HEADER). Cloudflare sets that
# header to the true visitor and STRIPS any client-supplied copy — so the
# header is authoritative ONLY when every request provably transits CF. We do
# NOT overwrite it here (that would replace the real visitor IP with the CF
# edge IP and defeat the whole point). The required backstop is network-level:
# the Fly app MUST be reachable ONLY via Cloudflare (Fly private networking /
# a CF-IP allowlist), so a direct-to-origin request cannot forge the header.
# See MANUAL STEP: lock origin to Cloudflare. Per src/web.rs this header only
# keys the rate limiter (not auth), so the residual risk if the lockdown lapses
# is rate-limit-bucket spoofing, not an auth bypass — but do the lockdown.
handle {
reverse_proxy 127.0.0.1:8082 {
# The secret has done its job by here; no upstream reads it. Forwarding
# it means a future `RUST_LOG=debug`, or any header-dumping middleware,
# reopens exactly the leak this file closes. Repeated per proxy rather
# than one `request_header`, because that directive is ordered BEFORE
# `handle` and would strip the header before the lock's matcher reads
# it — disabling the lock outright.
header_up -X-Origin-Auth
}
}
}