An empty key switches the pseudonym off: no X-Client-Id is sent, and a
rate limit downstream falls back to one bucket shared by every visitor.
That is the one state nobody chooses on purpose and the easiest to reach
by forgetting a line in a .env — so the entrypoint now fills the key in
with `openssl rand -base64 32` when nothing else did.
Nobody picks this value, nothing outside the container needs to know it,
and no two deployments need the same one, which is what makes generating
it the right default rather than a convenience. A fresh key per container
start costs a reset of the downstream rate-limit buckets — invisible
against a one-minute window — and makes pseudonyms from before and after
unlinkable, which is the property the key exists for rather than a loss.
Passing one explicitly still wins, for whoever wants pseudonyms stable
across restarts or identical on two proxies.
base64 and not hex, deliberately: HAProxy's hmac() decodes the key as
base64, and hex would be accepted and silently decoded into something
else — a usable key, but it would quietly cost the guarantee that a
malformed one stops the container at configuration parsing.
The base image's entrypoint is the haproxy binary with no shell in
between, so the wrapper is the whole chain and execs the same binary with
the same arguments. CMD is restated rather than inherited.
Verified by building the image and checking the config in all three
states: unset (wrapper reports it generated one, config parses), set and
valid (wrapper silent, config parses), set and not base64 (`[ALERT]
invalid args in converter 'hmac' : failed to parse key`, container
refuses to start).
READMEs updated in both languages, and "address" is spelled "IP address"
throughout — it was never anything else.
A password in argv is world-readable through /proc/PID/cmdline while the
command runs; docker warns about it for that reason. The environment of a
process is readable by its owner alone, so $PASS itself was never the problem.
The two pushes are joined with && rather than left as separate statements: the
step's exit code is the last command's, so the intent is now explicit instead
of resting on whatever -e flag the runner's shell happens to carry.
Same structure as the bitmessage and bitcoind repositories: intro, usage with
compose and cli examples, a parameter table, and notes carrying the traps —
why the certificate must exist before start-up, why the runtime API is a unix
socket, why there are two loggers, and why HSTS is one day rather than a year.
The runtime API was an unauthenticated `level admin` channel on TCP 9999.
`expose:` restricts nothing and docker networks have no per-port rules, so it
was reachable by every container sharing a network — nginx included — and
reaching it means installing your own certificate and key. It is now a unix
socket on a volume shared with certbot alone, which also keeps the private key
off the network on every renewal. `expose-fd listeners` is dropped: it hands
the listening sockets themselves to a client of that socket and only serves
seamless reloads, which this image never performs.
HAProxy binds a unix socket by creating `<path>.<pid>.tmp` and renaming it, so
it needs write access to the directory; the image now creates /var/lib/haproxy
owned by uid 1001 and docker carries that onto an empty named volume. Without
it HAProxy refuses to start.
Also in this commit:
- The visitor's address no longer leaves the process. `option forwardfor` is
gone, X-Forwarded-For is deleted unconditionally, and X-Client-Id carries an
HMAC of the address under the optional XFF_HMAC_KEY instead — one-to-one with
the address, so a rate limit keyed on it is as precise, but not reversible.
An empty key sends no header rather than one derived from an empty key.
- Logging, which was absent entirely. To stdout for `docker logs`, errors-only
via the existing dontlog-normal. Both log-format and error-log-format are
hand-written: the built-in formats open with %ci:%cp and would have logged
the addresses the rest of this works to avoid. The pseudonym is computed by a
tcp-request connection rule so a refused handshake has one too.
- TLS pinned: floor TLS 1.2, ECDHE-only in ECDSA and RSA variants, no session
tickets, ALPN offering HTTP/2.
- HTTP redirects to HTTPS, excepting the ACME challenge path, plus HSTS at one
day — short deliberately, since the header cannot be recalled once sent.
- timeout http-request, which `timeout client` cannot stand in for: that one
resets on every byte, so a slow-drip client held a connection indefinitely.
- Backends re-resolve through the declared `resolvers docker`, which nothing
referenced. Names were resolved once at boot, so a container recreated on a
new IP was never noticed. init-addr libc,none lets HAProxy start anyway.
- The commented-out /ms deny rules are deleted. They would have blocked
/ms/info and /ms/api/v2/whoami, both deliberately public.
- compose: build context is the repository root, as the Dockerfile's COPY
expects and as CI already did; restart policy added, since an empty
certificate volume is a fatal start-up error; the volumes section, which was
missing, so the file can actually come up.
Verified on testnet2 against the real binary: config parses clean with the key
set, empty, and rejects a non-base64 key at parse time; a live stack confirms
the pseudonym is stable per address, differs between addresses, survives a
forged header, and that the client address appears nowhere in the log.