ipx trusts [web] trusted_header from Caddy's address, and Caddy stripped only the two Cf-Access-* names from requests not from TUNNEL_PEER. With trusted_header set to anything else (X-Remote-User for another proxy), a request through ipx-web carried it to ipx as Caddy's and was believed: anyone could sign in as anyone. TRUSTED_HEADER, defaulting to Cloudflare's name, is stripped the same way. Production uses Cloudflare's header and sets nothing, so it behaves as before. caddy adapt shows the delete list with and without. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
146 lines
4.9 KiB
Caddyfile
146 lines
4.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.
|
|
# 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
|
|
# 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
(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.mjs 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
|
|
}
|
|
}
|
|
|
|
# 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.mjs), 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 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
|
|
}
|
|
}
|
|
}
|