From cd514020f804c5ee3b3dd68d94b2b808a78f2d89 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:14:46 +0200 Subject: [PATCH 1/2] Discard Caddy's own diagnostics in front of the relay Caddy's global log on the relay host went to a rotating file at ERROR level with no filter. A proxy error there names the request URI, which carries the rendezvous handle, beside the client address. Go's "TLS handshake error from
" puts the address in the message text, where a field filter cannot reach it. Discard that log, as the escrow already does. The site access log stays: its filter already deletes the URI, the headers and both address fields. A new test holds both rules and fails against the old file. The cost is no Caddy log when a certificate stops renewing; an outside check of /healthz covers that. Co-Authored-By: Claude Opus 5.5 --- .changeset/relay-discard-caddy-diagnostics.md | 5 ++ relay/deploy/Caddyfile | 24 +++---- relay/deploy/caddyfile.test.ts | 66 +++++++++++++++++++ 3 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 .changeset/relay-discard-caddy-diagnostics.md create mode 100644 relay/deploy/caddyfile.test.ts diff --git a/.changeset/relay-discard-caddy-diagnostics.md b/.changeset/relay-discard-caddy-diagnostics.md new file mode 100644 index 0000000..57ac47d --- /dev/null +++ b/.changeset/relay-discard-caddy-diagnostics.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Discard Caddy's own diagnostics in front of the relay, as the escrow already does, so a proxy or TLS error no longer writes a client address or a rendezvous handle to disk. diff --git a/relay/deploy/Caddyfile b/relay/deploy/Caddyfile index e0ca8a4..72f4ed1 100644 --- a/relay/deploy/Caddyfile +++ b/relay/deploy/Caddyfile @@ -8,19 +8,19 @@ { # Caddy's own diagnostics, which the site block below does not govern. # - # Left at the default they go to stderr, which Docker keeps in an - # unrotated json-file — and a proxy error names the request URI, which - # carries the rendezvous handle, beside the client address. That is the - # household identifier this design exists to withhold, written to the - # least guarded file on the host and kept forever. ERROR only, to a - # rotating file, and never stdout. + # Left at the default they go to stderr, which Docker keeps. Pointed at a + # file, they still keep a record: a proxy error names the request URI, + # which carries the rendezvous handle, beside the client address, and Go's + # "TLS handshake error from
" puts the address in the message + # text itself, where no field filter reaches it. That is the household + # identifier this design exists to withhold. So, as in the escrow: they + # are discarded, which is a shape rather than a list of fields. + # + # What that costs: no Caddy log to read when a certificate stops renewing. + # `curl -sI https://relay.ftw.energy/healthz` from outside covers it end + # to end. log { - output file /var/log/caddy/caddy.log { - roll_size 10mb - roll_keep 3 - } - level ERROR - format json + output discard } } diff --git a/relay/deploy/caddyfile.test.ts b/relay/deploy/caddyfile.test.ts new file mode 100644 index 0000000..102a55b --- /dev/null +++ b/relay/deploy/caddyfile.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment node + +/* The proxy in front of the relay, held to keeping no address. + * + * Like the escrow's test, this checks configuration, which cannot prove what + * Caddy does. It catches the one line that went wrong: Caddy's own + * diagnostics went to a file with no filter, so one proxy error wrote the + * client address and the URI, which carries the rendezvous handle, beside the + * time. Go's TLS handshake errors put the address in the message text, where + * a field filter cannot reach it, so the diagnostics are discarded. + * + * The site's access log stays, because the operator reads aggregate traffic + * from it. Its filter must keep deleting the fields that name a household or + * an address. + */ + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' + +const caddyfile = readFileSync(new URL('./Caddyfile', import.meta.url), 'utf8') + +/** The body of every `log { … }` block, comments stripped. */ +function logBlocks(text: string): string[] { + const source = text.replace(/^\s*#.*$/gm, '') + const blocks: string[] = [] + const opener = /(^|\s)log\s*\{/g + let match: RegExpExecArray | null + while ((match = opener.exec(source))) { + let depth = 1 + let at = opener.lastIndex + while (at < source.length && depth > 0) { + if (source[at] === '{') depth++ + if (source[at] === '}') depth-- + at++ + } + blocks.push(source.slice(opener.lastIndex, at - 1).trim()) + } + return blocks +} + +describe('the proxy in front of the relay', () => { + const [global, site, ...rest] = logBlocks(caddyfile) + + it('has exactly two log blocks: the global one and the site access log', () => { + // Without the global block the default logger goes back to stderr, + // where Docker keeps it. + expect(global, 'the global log block went missing').toBeDefined() + expect(site, 'the site access log went missing').toBeDefined() + expect(rest, 'a new log block needs its own rule here').toEqual([]) + }) + + it("discards Caddy's own diagnostics", () => { + expect(global.split('\n').map((line) => line.trim()).filter(Boolean)).toEqual(['output discard']) + }) + + it('deletes the handle, the headers and every address from the access log', () => { + for (const field of ['request>uri delete', 'request>headers delete', 'request>remote_ip delete', 'request>client_ip delete']) { + expect(site, `the access log keeps ${field.split(' ')[0]}`).toContain(field) + } + }) + + it('never writes a log to stdout or stderr, where Docker keeps it', () => { + expect(caddyfile).not.toContain('output stdout') + expect(caddyfile).not.toContain('output stderr') + }) +}) From 7cc2f0e49f9e074ca1b5461c321349dbd9f072ce Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 19:15:38 +0200 Subject: [PATCH 2/2] Type the relay Caddyfile test's log blocks Co-Authored-By: Claude Opus 5.5 --- relay/deploy/caddyfile.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/relay/deploy/caddyfile.test.ts b/relay/deploy/caddyfile.test.ts index 102a55b..62bb3a5 100644 --- a/relay/deploy/caddyfile.test.ts +++ b/relay/deploy/caddyfile.test.ts @@ -39,14 +39,14 @@ function logBlocks(text: string): string[] { } describe('the proxy in front of the relay', () => { - const [global, site, ...rest] = logBlocks(caddyfile) + const blocks = logBlocks(caddyfile) + const global = blocks[0] ?? '' + const site = blocks[1] ?? '' it('has exactly two log blocks: the global one and the site access log', () => { // Without the global block the default logger goes back to stderr, - // where Docker keeps it. - expect(global, 'the global log block went missing').toBeDefined() - expect(site, 'the site access log went missing').toBeDefined() - expect(rest, 'a new log block needs its own rule here').toEqual([]) + // where Docker keeps it. A third block needs its own rule here. + expect(blocks.length, 'expected the global log and the site access log, and nothing else').toBe(2) }) it("discards Caddy's own diagnostics", () => {