Files
ipx/web/Caddyfile
rays 8acf57961e Ten wrong passwords from one address hold it off sign-in for a quarter hour (#409)
POST /api/login answered every wrong password 401 for as long as someone
kept trying. Now ipx counts them by the client's address: the tenth in a
window holds that address off until the window, which starts at its first
wrong password, ends, answered 429 with how long is left. Counted by
address and not name, since by name anyone could lock anyone out; a right
password does not reset the count, or a guesser with an account of their
own could clear it between guesses.

Behind Caddy every peer is Caddy, so the address is the last one in
X-Forwarded-For, taken only from trusted_proxies. Caddy now sets that to
{client_ip}: Cf-Connecting-IP for the tunnel's requests, believed only
from TUNNEL_PEER (servers trusted_proxies), and the peer for everyone
else, replacing whatever the client sent. Checked with Caddy against an
echo upstream: a forged XFF and Cf-Connecting-IP from elsewhere reach ipx
as the peer, the tunnel's Cf-Connecting-IP as itself.

The sign-in load test sends each attempt from its own address, so it
still measures a flood from many, whose passwords are all checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:18:38 +00:00

166 lines
5.9 KiB
Caddyfile

# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin,
# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it.
# IPX_UPSTREAM where ipx is, host:port (ipx:8099)
# WEB_BIND where to listen (:8080)
# WEB_TRACING on: a span for each request to OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_SERVICE_NAME), continuing the page's traceparent
# and passing it to ipx, so one trace runs browser, Caddy, ipx (#213). Off: none,
# and no export failing every few seconds where there is nothing to send to.
# METRICS_BIND where to serve Caddy's metrics for Prometheus (:9180); keep it off the tunnel
# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's
# requests come from. Nobody else's are, so nobody else can claim to be signed in
# by Cloudflare. Unset passes none.
{
admin off
auto_https off
# Who a request is from, {client_ip}: Cloudflare's Cf-Connecting-IP for the tunnel's requests,
# taken only from cloudflared, and the peer for everyone else's. ipx counts wrong passwords by it
# (#409), and without this every request through the tunnel is cloudflared's one address.
servers {
trusted_proxies static {$TUNNEL_PEER:255.255.255.255}
client_ip_headers Cf-Connecting-IP
}
# Requests, their times and upstream errors as Prometheus metrics, served below (#371).
metrics
# Everything else Caddy logs, its errors among them, which carry the request too: the same
# filter as the access log's below.
log default {
output stderr
format filter {
wrap json
fields {
request>uri query {
delete token
}
request>headers delete
}
}
}
}
# Caddy's own metrics (#371), on a port of their own: the tunnel reaches only WEB_BIND's.
http://{$METRICS_BIND::9180} {
metrics
}
(tracing-on) {
tracing {
# Named for the request, as ipx names its own (#286): one name for all left every span in
# Tempo looking the same.
span "{http.request.method} {http.request.uri.path}"
}
}
(tracing-off) {
}
http://{$WEB_BIND::8080} {
import tracing-{$WEB_TRACING:off}
root * {$WEB_ROOT:/srv}
# One JSON line a request on stdout, for Loki and the dashboard's Page row (#210). Not the
# shared token a ?token= link carries, nor the request's headers, which hold the session
# cookie and Cloudflare Access's token.
log {
output stdout
format filter {
wrap json
fields {
request>uri query {
delete token
}
request>headers delete
}
}
}
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
# until it had enough to send, nor /media, which is audio and video already compressed.
@compress not path /api/events /media/*
encode @compress zstd gzip
# The copies web/build.mts compressed are served in place of their file, never asked for by
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
# admins (#253).
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
@packed path *.br *.gz
handle @packed {
respond 404
}
# Before anything else, so no page or API call sees them: only ipx sets these.
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
request_header @forged -Cf-Access-Authenticated-User-Email
request_header @forged -Cf-Access-Jwt-Assertion
# Whatever header ipx's [web] trusted_header names, when it is not Cloudflare's: ipx trusts it
# from this address, and anyone could send it through here (#323).
request_header @forged -{$TRUSTED_HEADER:Cf-Access-Authenticated-User-Email}
@api path /api/* /media/*
handle @api {
# Live updates are a stream: flushed as they come, never buffered.
reverse_proxy {$IPX_UPSTREAM:ipx:8099} {
flush_interval -1
# The client alone, not the chain Caddy would otherwise pass on, whose last address is
# cloudflared's for the tunnel: ipx takes the last as who is asking (#409).
header_up X-Forwarded-For {client_ip}
}
}
# Signing in with the shared token in the address (?token=): ipx sets the session cookie and
# sends the browser on to the page without it.
@tokened {
path / /admin
query token=*
}
handle @tokened {
rewrite * /api/signin?{query}
reverse_proxy {$IPX_UPSTREAM:ipx:8099}
}
# The app and the admin page go to whoever ipx says may have them: a browser not signed in is
# sent to /login, anyone but an admin asking for /admin to the app.
@page path / /index.html /admin /admin.html /admin.js
handle @page {
forward_auth {$IPX_UPSTREAM:ipx:8099} {
uri /api/page-check
}
rewrite / /index.html
rewrite /admin /admin.html
header Cache-Control no-cache
file_server {
precompressed br gzip
}
}
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
@login path /login /login.html
handle @login {
forward_auth {$IPX_UPSTREAM:ipx:8099} {
uri /api/login-check
}
rewrite * /login.html
header Cache-Control no-cache
file_server {
precompressed br gzip
}
}
# Scripts and the stylesheet are named by a hash of their contents (web/build.mts), so a browser
# keeps them a year; the icons a day, under names that do not change.
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
header @hashed Cache-Control "public, max-age=31536000, immutable"
# The service worker (#389): the browser looks for a new one at each visit, and must find it.
header /sw.js Cache-Control no-cache
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
header /favicon.ico Content-Type image/png
@icons path *.png *.svg *.ico *.woff2
header @icons Cache-Control "public, max-age=86400"
handle {
file_server {
precompressed br gzip
}
}
}