Repository navigation
EQProxy v0.8.0 — 2026-10-27
Added
-
Web panel (v0.8 slice 11, D6/Q1/CE1; ADR-0029).
[admin] panel = true
(off by default; with it off every/panelpath is a404like any other
unknown route) serves a server-rendered client of the administration plane:
a dashboard with the live listeners and, per backend, its policy and its
servers withstate/healthy/active/drained, plus aready/drain/
maintenanceform per slot, a form per[backend.*]/[listener.*]table
and the certificate reload. No JavaScript is shipped, so every action is a
plainPOSTform:POST /panel/loginexchanges the bearer token once for
anHttpOnly,SameSite=Strictcookie scoped toPath=/panel, plus a CSRF
token that every later form carries and the server checks against that
session (a cross-sitePOSTwould need a token it cannot read). Sessions
live in memory only — 30 minutes idle, at most 16, and a logout revokes just
its own session — and a read-role session reads but cannot write. Every
action calls the same function the JSON API calls (apply_server_state, the
configuration writer with the form'srevisionasIf-Match, and the
certificate action), so the panel cannot drift from the API; each attempt
writes itsAUDITline and no secret is echoed. The page is one template
and a stylesheet underdefault-src 'none'; style-src 'self'; form-action 'self', so there is no script to audit. Tests: five E2E over a
real admin socket (login flow with the token never echoed, session required
and a stale cookie cleared, CSRF plusOrigin, a read session cannot write,
and the panel absent unless the configuration turns it on) and a journey
over the real binary that logs in, applies a backend table and drains a
server through the forms
(e2e_panel.rs). -
Per-server
maintenancecloses the idle material (v0.8 slice 10,
D6/H3/B-H12/B-E8; ADR-0028). The third per-server administration state:
maintenanceremoves the slot from selection exactly asdraindoes and,
at the transition, closes what the proxy has parked on it — the keep-alive
pool and the backend HTTP/2 session, which leaves the hub and ends when its
last stream does —, so nothing of ours stays on a machine about to be
patched. Afterwards the slot refuses to park a finished exchange on it, and
selection never picks it. The window opens as an event, not a poll:
INFO server ready for maintenance: backend=… target=… active=0is written
where the last in-flight guard drops, the label-free
eqproxy_backend_servers_maintenancegauge counts the windows, and
eqproxy_backend_drains_completed_totalkeeps its one meaning — a
maintenance window never moves it. The wire route is
PUT …/servers/{target}/statewith{"state":"maintenance"}. Tests: admin
and balancer unit cases (round trip, the slot is never selected, a finished
exchange is not re-parked, gauge and counters), two dataplane E2E cases with
a real HTTP/2 origin (an idle session ends at the transition; an in-flight
stream is cut loose only when it ends), the wire route in the admin E2E, and
a journey over the release binary whose origin counts its own open
sockets —open: 1before the window,open: 0at it, the client
connection untouched and an in-flight exchange only ending when it finishes
(evidence). -
Backup servers,
backup = true(v0.8, B8). A server slot can be
declared a backup: the balancer keeps a second tier and consults it only
when no primary is eligible (down, draining,weight = 0, unresolved or
excluded by a retry), so primaries take traffic back on the next request
after recovery and the backup stays cold otherwise. Inside the tier one
rung serves at a time — the first operational backup, which is the
reference default (all_backups = trueselectsoption allbackups, below)
— and the check
happens inside the one selection function both algorithms (round-robin,
leastconn) share with the affinity and hash paths.GET /api/v1/status
reportsbackupper slot so the read plane says which role a server plays.
Tests: schema parse/validation, seven balancer unit cases (idle backup,
failover and recovery, drained and weight-0 primaries, retry exclusion,
affinity, all-backups, leastconn), a CLI E2E that fails a primary over to
the backup and revives it, and a journey over the release binary with three
real origins and HTTP health checks
(evidence). -
Backup failover ladder,
priority(v0.8, B8; EQProxy extension). The
backup tier is ordered:priority(0..=255, backup only) is the failover
order, higher first, and ties keep configuration order. Only the first
operational rung serves; when it fails the next one takes over, and a
recovered higher rung takes the traffic back on the next request. The
reference has no numeric priority onserverlines, so the field is
documented as an extension, rejected on a non-backup slot instead of being
a silent no-op, bounded and reported inGET /api/v1/statusand
config diff(addr(weight,backup,priority)), and declared with its range
in the public JSON Schema. Tests: config validation (range and
backup-only), four balancer unit cases (ladder order and ties, retry across
rungs, affinity, leastconn), a CLI E2E that walks the ladder down and back
up through the real binary, and a journey against the release binary with
one primary and two real backup origins
(evidence). -
Backup pooling,
all_backups = true(v0.8, B7/B8;option allbackups).
A backend can balance its whole backup tier instead of serving one rung at
a time: when no primary is eligible, every operational backup is a
candidate, so the configured algorithm (roundrobin,leastconn) and the
serverweights spread the load across the pool — and, as the reference
states, there is no priority order between the backups anymore. Pairing it
with apriorityrung, or enabling it in a backend with nobackup = true
slot to balance, is a configuration error instead of a silent no-op (both
named with the field path and a fix), and the public JSON Schema declares
the field.GET /api/v1/statuspublishes the live policy per backend,
config explainnames it andconfig diffreports the transition
(all_backups: false -> true). Tests: config validation, six balancer unit
cases (2:1 pool split, ladder dropped, dead member out with retry
exclusion, all-down, leastconn inside the pool, affinity inside the pool),
status/diff assertions, a CLI E2E that walks primary → pool → survivor →
pool → primary with exact weighted counts, and a journey over the release
binary with three real origins for both algorithms
(evidence). -
Endpoint-by-endpoint authorization matrix (v0.8 gate, SPEC §9). A new
E2E asserts, over real sockets and for all three callers (anonymous, read
token, write token), what every admin route answers:GET /api/v1/status
andGET /metrics(401/200/200),PUT …/servers/{target}/state
(401/403/200), both document writes andPOST /api/v1/certificates/reload
(401/403, and 503config_unavailablewhen the role gate passes but no
applier is attached — never a 200 it cannot back), an unknown route
(401/404/404) and an unserved method (401 without a token, 405 with the
plane'sAllowlist). Every answer is swept for the two bearer tokens
(T17), a 401 must disclose neither the topology nor anything about the
token, and the run ends by proving that no refusal changed the server state or the
revision. The gate's other two clauses are covered by existing tests: the
dataplane keeps serving after the admin task is aborted
(the_dataplane_keeps_serving_when_the_admin_task_dies) and the binary
journeys assert no token in the process log. -
Per-server and per-route metrics, opt-in and budgeted (v0.8 slice 9, CE4;
ADR-0026).[metrics] detailed = true(off by default) makes the
dataplane attribute each completed exchange to the server that served it
and to the rule that matched it:eqproxy_server_requests_totaland
eqproxy_server_errors_total(backend,server) plus
eqproxy_route_requests_total(listener,route— only for a matched
rule; default-backend traffic gets no invented label).max_series
(default 128, validated 1..=4096) is a hard ceiling counted across both
families: a series that does not fit is refused and counted in
eqproxy_metrics_detail_dropped_total, an existing series keeps counting,
and four label-freeeqproxy_metrics_detail_*series report enabled, live
series, budget and refusals in every scrape — so "no traffic", "detail
off" and "budget refused it" are distinguishable, and a flag that promises
detail without measuring it is no longer possible.GET /api/v1/status
carries the same rows undermetrics_detail;detailedmoves onSIGHUP
(enabling starts an empty window, disabling drops it), whilemax_series
is fixed at startup and a reload that changes it warns and keeps the live
budget. Label values are escaped before the exposition format. Detail
followshttp_requests_proxied— h1/h2/h3 through the h1 engine and gRPC
streams — and protocol switches (including WebSocket) stay pending. Tests:
config validation (0and4097rejected, default 128), the core registry
(budget refusal, refusal counting, disable-drops-window, escaping), admin
rendering/status, and a journey against the release binary that enables on
SIGHUP, asserts exact per-server and per-route counts with a 5xx split,
disables and proves the window is gone, then runs a second process with
max_series = 2that tracks two series and counts the observation it
refused. -
Certificate renewal without a restart (v0.8 slice 8, D7; ADR-0025).
POST /api/v1/certificates/reload(write token, noOrigin, no body)
re-reads the configuration and its certificate files through the same
single writer asSIGHUPand answers only after the dataplane committed,
withlistener,listen,fingerprint(sha256:<hex>of the leaf really
served),changedandquicper TLS listener;GET /api/v1/status
reports the sametls_fingerprint. The material moved behind a resolver
every handshake consults, so a renewal is one atomic swap: established
connections keep running, new h1/h2/h3 handshakes get the new certificate,
and a live QUIC endpoint — which cannot be reconfigured — no longer forces
a restart for a chain, key or SNI-name change (ALPN, the mTLS client CA and
the minimum version are still refused withQuicTlsReloadand a message
that names them). A candidate that fails validation (a PEM that does not
parse, a key that does not match) answers400, keeps the previous
certificate serving, and now also logsERROR config reload rejected: …
and movesconfig_reload_failures— a rejectedSIGHUPused to be silent.
The fingerprint uses AWS-LC, already the rustls provider: no new crate.
Tests: TLS unit tests (fingerprint identity, adoption allowed for material
and refused for ALPN/mTLS/version), the h3 rotation E2E (the connection
opened before the renewal keeps serving, the rotated-out root fails, the
new one works), the kept-socket TCP rotation E2E extended with a live
in-flight connection, admin-plane unit tests (RBAC,Origin, body, method
list) and a journey against the release binary that rotates the files on
disk, checks the DER a real TLS client receives before and after, and
proves a corrupt PEM is rejected without touching the live certificate. -
A finished drain is observable (v0.8 slice 7, H3; ADR-0024). The
per-server status gaineddrained(state = "drain"with no exchange left
in flight),PUT …/stateanswers it too, and the moment the last in-flight
exchange ends is recorded where it happens — in the drop of its in-flight
guard — as oneINFO server drained: backend=… target=… active=0line and a
counter, instead of being inferred from polling./metricsgained two
label-free series,eqproxy_backend_servers_draining(slots declared in
drain) andeqproxy_backend_drains_completed_total(finished episodes), so
cardinality cannot grow with the topology (CE4); the per-slot detail stays
in the JSON status, where the budget is the configured config. Tests: two
balancer unit tests (the event fires exactly once, an idle drain reports
finished without an event) and a new E2E journey with the real binary and a
gated origin —active=1/drained=falsewhile the drain is open,
active=0/drained=true/counter1when the held exchange ends, one log
line naming the slot, and503for new traffic; a control run with the
event disabled fails that journey. -
Named users in the admin socket's POSIX ACL (v0.8 slice 6, B-H14;
ADR-0023).unix_socket_acl_uids = [501, 502]grants those uidsrwon
the socket file through its POSIX access ACL, in addition to the owning
group, so a user outside the group canconnectwithout widening it. The
kernel enforces the ACL atconnect, before the process sees a peer; the
peer-uid allowlist and the bearer token still apply afterwards. The option
requiresunix_socket_mode = "0660"plusunix_socket_gid(the mode's
group class is the ACL mask), is Linux-only, deduplicates and caps the list
at 64 entries. There is nolibacl: the entries are written as the
system.posix_acl_accessextended attribute in its documented kernel
layout, then replaced or removed onSIGHUPtogether with the rest of
the socket policy, so an emptied list leaves no stale user. Tests: encoder
unit tests (tag order, mask, dedup, size), validation (Linux-only,0660
pairing, duplicates, cap, TCP socket), an encoder/reload test on Linux and
a container journey with four real uids — owner, ACL-only, group-only and
outsider — plusgetfaclbefore and after the reload. The journey asserts
every observation and runs against the image of each published
architecture (linux/arm64,linux/amd64); a control run that names the
outsider in the ACL fails it, so its green is not vacuous. The release
workflow runs that journey on the-candidateimage of both architectures
and stops before promoting the digest if either fails. A
pull-request job runs the same published file on nativeamd64and
arm64runners (ubuntu-24.04-arm), so no leg depends on syscall
emulation; each leg checks its ownuname -mbefore starting and fails if
the label did not land on the architecture the matrix declares. -
Hot reload of the admin socket policy (v0.8 slice 6, B-H14; ADR-0022).
unix_socket_mode,unix_socket_gidandunix_socket_allow_uidsare no
longer startup-only: aSIGHUPapplies them to the socket the running
process already bound. The file policy is writtenchown-then-chmodand
the accept loop reads the peer-uid list per connection from a shared cell,
so the next peer is judged by the new list while the listener, its queue
and every open connection stay untouched. The policy moves before the
dataplane swap and is put back when the swap is refused, so a rejected
generation leaves neither a new mode nor a new list behind; a policy the
process cannot write (a group it does not belong to) is rejected as a whole
with the failing syscall named and the previous policy still in force. The
endpoint and the tokens remain fixed at startup and a reload that changes
them warns instead of pretending. Tests: in-process E2E (widen to0660+
group, deny the owner through the reloaded list, restore owner-only, refuse
a foreign gid with the live policy unchanged) and CLI E2E with the real
binary (three SIGHUPs: applied, rejected, applied again; the dataplane keeps
serving throughout and the socket file is still removed on shutdown). -
Group-shared admin socket and peer-uid allowlist (v0.8 slice 6, B-H14;
ADR-0022).unix_socket_mode = "0660"plus a numeric
unix_socket_gidlets a group reach the admin plane instead of only the
owner;0600remains the default and refuses a gid, and no other mode is
accepted. The socket ischowned beforechmod(which can clear mode
bits); a failed group change aborts startup and removes the file it just
created.unix_socket_allow_uidsadds a fail-closed uid allowlist checked
against the kernel'sSO_PEERCRED/getpeereidanswer immediately after
acceptand before any HTTP byte: a peer outside the list, or one whose
uid the kernel does not report, is closed with a warning and learns
nothing about the route. The bearer token still applies afterwards; the
three layers (filesystem, uid, token) are independent. Tests: validation
(mode/gid pairing, unsupported mode, duplicate and socket-less uid lists),
in-process E2E with the real credential (allowed uid, 0660 + group owner,
denied uid gets EOF) and CLI E2E with the real binary (allowed run answers
200; denied run closes the connection but keeps proxying traffic).
Named users joined the ACL in the next slice (ADR-0023, below); group names,
abstract sockets and socket activation remain pending for v0.9.1, and the
policy itself (mode, gid, uid allowlist, ACL) now reloads onSIGHUP. -
Admin plane on a Unix domain socket (v0.8 slice 5, B-H14 first step;
ADR-0021).[admin]now takesunix_socketas the alternative to
listen: exactly one endpoint, resolved against the configuration file.
The socket is created with mode0600, so the filesystem decides who may
connect while the bearer token (and the separate write token) still decides
what the caller may do; the routes, limits andAUDITlines are unchanged,
and a Unix peer is audited asunix-socket:uid=<uid>. Startup refuses a
path that is already served (no stealing), replaces a stale socket left by
a crash, and refuses to delete any file that is not a socket. A clean
shutdown unlinks only the inode this process created, so a restart does not
inherit a dead name;allow_remotewithunix_socketis rejected instead
of ignored. Tests: unit (validation of both/neither/empty/allow_remote),
in-process E2E (mode0600, token gate, status/metrics, live-socket
conflict, stale replacement, regular-file preservation, unlink on
shutdown) and CLI E2E with the real binary over the socket plus proxied
traffic and SIGINT. Remaining for v0.9.1 (B-H14): shared-group sockets,
SO_PEERCREDallowlists, abstract sockets and socket activation. -
Structured access log (v0.8 slice 4, B-N14/B-I14/B-E8; ADR-0020). An
optional[access_log]writes one line per HTTP request answered by the
dataplane engine — HTTP/1.1, every HTTP/2 stream and every HTTP/3 request,
which all sharehttp1::serve_http— to stdout or an append-only file
(path, relative to the configuration file), asjson(default) or the
nginxcombinedshape. Entries carry listener, client, protocol, method,
path without the query string, host, status, duration, backend/server when
one served and the response byte count when exactly known; an unparsed head
is logged with-fields and an exchange that ended without a response with
status 0. The sink is a bounded channel (4096) drained by its own task: a
full or failed sink drops lines and advancesaccess_log_droppedinstead of
delaying a request, and shutdown flushes what is queued. Destination and
format are fixed at startup (aSIGHUPthat edits them warns and keeps the
running sink); an unopenable destination fails startup instead of serving
without the configured log. The query string and request headers other than
Referer/User-Agentare never logged. Tests: unit (JSON/combined shape,
UTC calendar, one-line escaping, drop accounting, flush on shutdown), E2E
in-process (HTTP/1.1 parsed and unparsed entries, HTTP/2 protocol field) and
CLI E2E with the real binary under 40 concurrent requests and a SIGINT. -
Read-only administration plane (v0.8 slice 1, D6/B-H12/B-N14/B-I14/B-E8;
ADR-0017). An optional[admin]table opens a listener of its own, with a
bearer token fromtokenortoken_file(at least 16 bytes, constant-time
comparison, never echoed). It servesGET /api/v1/status(version, uptime,
the validated listeners/backends/servers and the live counters as JSON) and
GET /metrics(Prometheus text with a fixed series set — the only label is
the build version, so a scrape cannot grow the index). Every route requires
the token (401 withWWW-Authenticateotherwise), onlyGETis served
(405 +Allow: GET), unknown routes are 404, heads over 8 KiB are 431 and
an exchange has 5 s. The plane is a separate socket and task: the dataplane
keeps serving with it dead (E2E test aborts it mid-run), and a configured
plane that cannot bind fails startup. Validation keeps the plane on loopback
unlessallow_remote = true(this slice has no TLS there), refuses a
concrete port already used by a[listener.*](two:0binds are not a
collision: the kernel picks distinct ports) and requires exactly one secret.
eqproxy config schemaexports the new keys;eqproxy.toml.example
documents them. Writes, RBAC, auditing and the panel UI were still pending at
this slice; ADR-0018, ADR-0019, ADR-0025 and ADR-0029 delivered them later
in v0.8. -
Per-server drain over the admin plane (v0.8 slice 2, B-H12/B-E8/H3;
ADR-0018).PUT /api/v1/backends/{backend}/servers/{target}/statewith
{"state":"ready"|"drain"}takes a server out of selection without
touching its in-flight exchanges, andreadyreturns it. The write needs
its ownwrite_token/write_token_file, distinct from the read token; with
no write secret every write is403 write_disabled, and the read role gets
403 forbidden. The request must be JSON, at most 1 KiB, declare its
length, and carry noOrigin(a browser never belongs on the plane); each
attempt writes oneAUDITJSON line (role, peer, outcome,from/to,
never the secret).GET /api/v1/statusnow reports per server the
configured target, the resolved address, weight,state,healthyand
active, so a drain is observable (H3). The state registry is shared by
every balancer and survivesSIGHUP; with every server drained the proxy
answers the existing explicit503and countsno_available_server. With
maintenanceand the idle-pool closure still pending at this slice (ADR-0028
delivered them for the window); the durable audit sink and a live-updating
topology view remain pending. -
Hot backend and listener configuration through the API (v0.8 slice 3,
B-E1/H2/CE2; ADR-0019).PUT /api/v1/backends/{name}and
PUT /api/v1/listeners/{name}take that TOML table — bare body or the full
section copied from the file — (application/toml, declared length up to
64 KiB,Originrefused, write role), create the name when absent and
replace the table otherwise. The
status answer now carries arevisionand the same value asETag; every
write is conditional: missingIf-Matchis428, a stale one is412
with the current revision. One configuration task serializes API writes
andSIGHUP, validates and resolves the whole document first (missing
certificates or maps, unknown backends, invalid tables are400and never
reach the dataplane), and answers200only after the swap commits; a bind
conflict or a frozen QUIC endpoint is409and leaves the old generation
serving. A listener kept by name and address keeps its socket, so four
concurrent clients proxied across a backend change, a backend creation and
a listener repoint with zero failed requests in the E2E (real binary,
real origins), each generation observed in the responses. Runtime changes
are not written to the file: the nextSIGHUPre-reads the baseline and
replaces them (asserted in the same test).DELETE, persistence, drift and
RBAC beyond the two tokens remain pending.
Changed
-
Roadmap milestone numbering (owner decision, 2026-10-25). After v0.8
the roadmap advances0.8.5,0.9.0,0.9.1… and continues0.9.2,0.9.3
up to0.9.50;1.0.0only when the owner decides. The
extensions-and-converter milestone is now v0.8.5 (was v0.9) and the
parity-certification gate v0.9.1 (was v1.0). The forward references in
this changelog,README.md, ADR-0021/0022/0023,docs/equivalences.mdand the
two v0.8 admin-socket evidence pages were updated to the new numbers; the
roadmap's own files are local-only and are renumbered in the maintainer's
tree. -
The Docker Hub page is published by CI, not edited on the website.
docs/docker-hub-readme.mdis the single source: after every stable release
(and on demand, to repair drift).github/workflows/dockerhub-page.yml
renders{{version}}fromCargo.tomland{{packages}}from the Scout
SBOM of the published image, re-checks the 0 critical / 0 high sentence with
--exit-codeon both architectures, PATCHes the description and reads it
back, so a page that disagrees with the release fails the run instead of
shipping. The page drifted twice by hand: v0.7.0 still carried 0.6.3 strings
and the wrong package count.