From 4fccfb90890c3b70570882f8b4d07875929589b4 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 27 Oct 2026 19:05:04 +0700 Subject: [PATCH] feat: mark proxy-originated errors and pin container DNS - handle_errors sets X-Proxy-Error (dial|upstream) and X-Proxy-Error-Detail so clients with several proxies can fail over when the upstream was never reached, instead of treating the proxy's 502 as the upstream's - explicit dns in compose: the container no longer inherits a host resolver that may disappear (systemd-resolved was switched off on the VM and every lookup failed until a restart) - strip X-Proxy-Token from Caddy logs (it was logged in clear text) Co-Authored-By: Claude Opus 5.5 --- Caddyfile | 42 ++++++++++++++++++++++++++++++++++++++++++ VERSION | 2 +- docker-compose.yml | 7 +++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Caddyfile b/Caddyfile index c7f5949..a9509a2 100644 --- a/Caddyfile +++ b/Caddyfile @@ -5,6 +5,18 @@ { email {$ACME_EMAIL} admin off + + # The error log dumps request headers, and Caddy redacts only + # Authorization/Cookie on its own: X-Proxy-Token used to land in + # `docker logs` in clear text on every failed upstream call. + log default { + format filter { + wrap json + fields { + request>headers>X-Proxy-Token delete + } + } + } } # --- Access control (generated by docker-entrypoint.sh from env) --- @@ -44,8 +56,38 @@ } } +# --- Proxy-originated errors --- +# Errors produced by the proxy itself (the upstream answered nothing) are +# marked with X-Proxy-Error so that a client with several proxies can tell +# them apart from an upstream's own 5xx and fail over: +# dial — the upstream was never reached (DNS, connect, no route): the +# request did not leave this host, retrying elsewhere is safe +# even for non-idempotent calls +# upstream — anything else (timeouts, broken responses): the upstream may +# have processed the request +# X-Proxy-Error-Detail carries the reason. Only clients that passed the +# guards ever get here (the guards respond directly, without an error). +# Note: handled errors are logged by Caddy at DEBUG level only — the reason +# is visible to the client via X-Proxy-Error-Detail. +(proxy_errors) { + handle_errors { + @dial `{err.message}.startsWith("dial ")` + handle @dial { + header X-Proxy-Error dial + header X-Proxy-Error-Detail "{err.message}" + respond `{"error":"proxy_dial_failed"}` {err.status_code} + } + handle { + header X-Proxy-Error upstream + header X-Proxy-Error-Detail "{err.message}" + respond `{"error":"proxy_upstream_failed"}` {err.status_code} + } + } +} + (routes) { import /etc/caddy/matchers.caddy + import proxy_errors route { # Health check — no auth, evaluated before the guards diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/docker-compose.yml b/docker-compose.yml index 6810b46..e91158f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,13 @@ services: # Legacy plain-HTTP endpoint (unchanged for existing clients) - "8082:8080" env_file: .env + # Explicit resolvers instead of inheriting the host's: Docker snapshots + # the host resolv.conf at container start, so switching the host off + # systemd-resolved (127.0.0.53) left Caddy with a dead resolver and every + # upstream lookup failing until the container was restarted. + dns: + - 1.1.1.1 + - 8.8.8.8 volumes: # Let's Encrypt certificates and ACME account keys — MUST persist, # otherwise every recreate re-issues certs and burns LE rate limits