Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/relay-discard-caddy-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 12 additions & 12 deletions relay/deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address>" 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use GET for the external health check

When an operator adopts this command as the replacement for the discarded diagnostics, it never receives the health endpoint's successful response: curl --manual documents that HTTP -I uses HEAD, while RelayServer.start() accepts /healthz only when req.method === 'GET' and otherwise returns 426. This can produce a permanent false failure in monitoring; use the GET command already documented in relay/deploy/README.md or add HEAD handling to the endpoint.

Useful? React with 👍 / 👎.

# to end.
log {
output file /var/log/caddy/caddy.log {
roll_size 10mb
roll_keep 3
}
level ERROR
format json
output discard
}
}

Expand Down
66 changes: 66 additions & 0 deletions relay/deploy/caddyfile.test.ts
Original file line number Diff line number Diff line change
@@ -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 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. 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", () => {
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')
})
})
Loading