Repository navigation
Releases: ExyonQ/EQProxy
Release list
v0.6.3
EQProxy v0.6.3 — 2026-10-21
Patch release closing the two defects found while measuring TLS parity: a
configuration that asked for HTTP/2 could pass the gate and then fail every
request, and the container had no writable state directory, so config apply
and config rollback always failed inside it. No wire behaviour of a valid
configuration changes.
Fixed
alpn = ["h2"]/["h2c"]is rejected by the config gate instead of
silently negotiating a protocol the dataplane cannot speak. With
mode = "http"rustls completed the handshake for h2, then every request
failed (curlexit 52) while the proxy counters showed
client[accepted=0]. The error names the field, the cause and the fix
(alpn = ["http/1.1"]), with the file/line context of the other gate
errors.mode = "tcp"is untouched: it relays bytes without parsing them,
so an h2 hint there is a legitimate tunnel choice. A backend TLS entry is
rejected only when an HTTP-mode listener routes to that backend, because
that listener only ever sends HTTP/1.1 over the connection.config applyandconfig rollbackwork in the published image. The
state directory defaulted to./.eqproxy, which is/in the container,
and uid 65532 cannot write there: both commands exited 1 with
Permission denied. The runtime stage now ships
/var/lib/eqproxyowned by 65532 and setsEQPROXY_STATE_DIRto it, so the
commands work out of the box; mount a volume on that path to keep history
across container replacements. The image also exposes 8443, the port the
documented TLS listener uses.- Reports no longer panic when the reader closes the pipe.
println!
aborts onEPIPE, soeqproxy config explain | head(and any long report
piped intogrep -q, as the release script does) died with "failed
printing to stdout: Broken pipe". Reports now go through a writer that
treats a closed reader as success; the exit code stays 0.
Documentation
- The Docker Hub page used the
0.6.1tag in every example, named uid
65534 for a file-readability requirement when the image runs as
65532, and did not mention the state directory or volume. All three are
corrected and the page points at v0.6.3. - The TLS parity report's note that
alpn = ["h2"]"is accepted by the
config gate" is now historical: v0.6.3 rejects it at validation time. - Erratum: the v0.6.1 parity report labelled one column "glibc", but that
column ran the release asset — which is a static musl build — inside Debian.
Both sides were the same binary, so the report measured the image base and
the user, not libc. The erratum is at the top of
docs/evidence/v0.6.1-parity/README.md; its "no measurable cost" result
stands for packaging only. - TLS parity measured with genuinely built glibc binaries on both
architectures (docs/evidence/v0.6.2-tls-parity/). Findings: ECDSA P-256
termination works and is at parity on arm64 (−2.4 %, p 0.28) but the musl
build is 20 % behind on amd64 under load (p 0.001) and 11 % behind with
no TLS at all (p 0.0001), so the gap is libc-general, not handshake cost;
TLS 1.2 sessions resume and TLS 1.3 ones do not (no ticketer is
configured, so no ticket is ever sent); and HTTP/2 does not exist, while
alpn = ["h2"]was accepted by the config gate (v0.6.3 rejects it) and
negotiates a protocol the proxy cannot serve (curl exit 52, zero accepted
connections). - The published Linux binaries are musl only, so the glibc build that carries
the tier-1 performance claims is not the artifact users download. Shipping
glibc variants is an open decision, not a claim.
v0.6.2
Changelog — EQProxy
Language: English. Format follows "Keep a Changelog" (Added / Changed /
Fixed); versions are vMAJOR.MINOR.PATCH git tags.
[Unreleased]
Nothing yet.
[v0.6.2] — 2026-10-20
Patch release with two goals: the image is now scanned and exercised on
both architectures before a release tag exists, and the two RustSec advisories
published after v0.6.1 (webpki-roots 1.0.9, rustls-webpki 0.103.15) are
fixed. No configuration key, wire behaviour or rule semantics changed.
Changed
- Docker Scout now gates both image architectures, before any release
exists. The image job built and scanned onlylinux/amd64;
linux/arm64reached the registries unscanned. It now builds the
multi-arch manifest once, pushes it under a throwaway
<version>-candidatetag, records the index digest, scans both
linux/amd64andlinux/arm64(docker scout cves --only-severity critical,high --exit-code), runs the container E2E journeys
(--version,config validate, live proxied traffic) on both
architectures, and only then promotes that exact digest to<version>/
:latestand mirrors it to Docker Hub — no rebuild, so the scanned bytes
are the published bytes. The GitHub release job nowneedsthe image job,
so a release cannot be created next to an unscanned image, and the two
SCOUT-<version>-linux-<arch>.mdreports ship as release assets. A
post-promotion step re-reads every published tag's digest and fails if it
differs from the scanned one.
Security
webpki-roots1.0.9 → 1.0.10 andrustls-webpki0.103.15 → 0.103.16.
RUSTSEC-2026-0381 (2026-10-19, "Bundled root certificates allow trust in
distrusted emSign intermediates") and RUSTSEC-2026-0382 (2026-10-20,
"Certificate verification may accept distrusted intermediates") were
published after the v0.6.1 audit and turned the nextveyra auditinto
REJECTED. The bump is the advisory's own fix, not a suppression: no test or
check changed,cargo fmt/clippy -D warnings/cargo test --workspace
(327 passed, 0 failed) still pass. The published v0.6.1 binaries and image
were built before the advisories and keep the old roots — the fix ships
with the next tag.
Documentation
- README and the Docker Hub page now state what the artifacts already were:
theeqproxy-linux-amd64/eqproxy-linux-arm64release binaries are
static musl (no libc, no interpreter, runnable fromFROM scratch),
verified against the published v0.6.1 assets in
docs/evidence/release-v0.6.1-scout-both-arch.md.
[v0.6.1] — 2026-10-20
«Rules, routing and traffic», patch release. It closes every scope box that
v0.6.0 had postponed by owner decision: multi-certificate TLS per SNI, the
two Envoy rule actions (hash_policy and request_mirror), the native
filter equivalence table (B-E4) and the public JSON Schema with editor
autocompletion (Q1/CE1, which also completes CE8). The v0.6 roadmap now
reads 32/32 scope boxes and 6/6 version-gate criteria. No behaviour of an
existing deployment changes: every addition is opt-in and every new key is
rejected by the config gate when malformed.
Added
- Multi-certificate TLS per SNI (B-I2/B-H10/B-E9; Envoy
filter_chainsper
SNI, nginx multiplessl_certificate):[[listener.*.tls.certs]]entries
addcert/key/namesto a listener. Thecert/keypair of
[listener.*.tls]stays the default certificate — it answers clients
without SNI and names no entry claims, like nginx's default server — and
each entry answers the names it declares: exact names first, then the
longest wildcard, with*.example.comcovering exactly one extra label
(RFC 6125). Names are case-insensitive and tolerate a trailing dot; the
config gate rejects an emptynameslist, a name claimed by two
certificates, malformed labels, a wildcard that is not the leftmost label
and IP literals (a TLS client never sends an IP as SNI, RFC 6066) — each
error naming the field, the cause and a valid example.config explain
lists the names of each listener (sni=[…]). The hot certificate reload
path rebuilds the resolver, so the SNI set swaps in hot (verified: the
same name answers with the previous certificate, then with the reloaded
one, on the same listener). Verified end to end: unit tests for the name
grammar and the resolver preference order, a dataplane E2E with real
handshakes that records the presented leaf per SNI (exact, wildcard,
two-label miss, unknown name, no SNI, case-insensitivity) and the real
binary driven by
openssl s_client -servername … -showcertsfor both names plus the
default fallbacks, with traffic through the SNI tunnel. - Envoy rule actions
hash_policyandrequest_mirror(B-H3), the last two
pieces of the rule engine.hash_policy = { key = "source" | "path" | "host" | "cookie:NAME" | "header:NAME" }makes asplitpick its target
by weighted rendezvous hashing instead of the rotation: the same key
keeps its target across requests and connections, the weight sets each
target's share of keys, and removing a target only remaps the keys it
owned (no state, no ring to rebuild); a request without the key falls back
to the smooth weighted rotation.request_mirror = { backend, percentage = 1..=100 }sends a copy of the matching request to a
shadow backend on a detached task — the client keeps the primary answer,
a mirror that is down or slow changes nothing (requests_mirrored,
mirror_failures,mirror_skippedin METRICS), the copy carries the
same forwarded head (and body, up to a 256 KiB budget — larger is not
mirrored and counted, never truncated). The config gate names field,
cause and fix for a hash policy without a split, with fewer than two live
targets or with an unknown key, and for a mirror to a missing backend,
with a percentage outside 1..=100 or on a local action.config explain
shows(hash cookie:session …)andmirror 100% -> shadow. Verified end
to end: stickiness, spread and consistency under target removal, a real
copy observed on the shadow socket, a dead shadow backend without client
impact, and the same through the real binary. - Public JSON Schema of the configuration (Q1/CE1):
eqproxy config schema [--output FILE]prints or writes a draft 2020-12 schema generated from
the same Rust types the parser deserializes (so it cannot drift from the
accepted format), published as
eqproxy.schema.json.eqproxy.toml.example
starts with the#:schema ./eqproxy.schema.jsondirective, which gives
Taplo/Even Better TOML users completion and inline validation — including
additionalProperties: false(fromdeny_unknown_fields), so a typo is
flagged beforeconfig validaterejects it. Three gates keep it honest:
the generated schema must match the repository file byte for byte, the
documented example must satisfy it (and the real gate), and a typo plus a
numeric duration must be rejected by it. - Filter equivalence table (B-E4) in docs/equivalences.md:
CORS,ext_authzandrate_limitmapped from Envoy, nginx, HAProxy and
ingress-nginx to EQProxy, with the semantics that differ stated explicitly
(fail-closed auth withoutfailure_mode_allow, a per-instance token
bucket, HTTP-onlyext_authz) and what still needs WASM.
Changed
- Container image runtime:
scratchwith a static musl binary instead of
debian:bookworm-slim. Docker Scout scanned the previous image with 4
critical + 13 high findings, every one of them in packages inherited from
the Debian base (perl, util-linux, openssl, zlib) and none in EQProxy; the
fix removes that surface rather than patching it: the image now carries
0 packages (binary +/etc/ssl/certs/ca-certificates.crtonly, 15.8 MB
locally vs 164 MB), reports0C 0H 0M 0Land passes the non-root and
copyleft policies. The builder stage fails if the binary stops being
statically linked, and it now copieseqproxy.schema.jsonbefore compiling
(eqproxy-configembeds it withinclude_str!, so the image build was
broken by the Q1/CE1 increment and this fixes it). - Release pipeline: the image is scanned with Docker Scout (
critical,high,
--exit-code) before the push, builds use--pullso the recorded
base digest is current, and the pushed manifest carries provenance
(mode=max) plus an SPDX SBOM.
Verification (this release)
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings(0 warnings) andcargo test --workspacegreen: 23 suites,
327 passed, 0 failed, 2 ignored (the two Docker-gated harnesses, last
measured 23/23 differential and 65/65 migration on the v0.6.0 tree; this
patch does not touch those paths).veyra audit . --profile standard: ACCEPTED, plus the Rust Audit static
scan and an EvoForgemcp_submit_evalon a real slice.- Image scanned with Docker Scout before publishing (see the image
section of the release evidence). - Parity of the two runtimes measured on the released artifacts over both
architectures and both transports (S-H1-04, 16 reps per cell): arm64 HTTP
+1.6 % (p 0.51) and arm64 TLS +2.0 % (p 0.68) for the container, amd64 HTTP
+6.2 % (p 0.062) and amd64 TLS +6.0 % (p 0.027), with p50/p95 within a few
percent, RSS at parity (5.3–7.2 MiB both sides) and every response a real
200in all 128 measured reps. The sign flips between campaigns, so the
supported reading is "no meaningful regression", not "musl is faster".
Report:docs/evidence/v0.6.1-parity/
(it also records the correction of the first version, which bind-mounted the
glibc binary and produced virtiofs TLS outliers and a false −40 % RSS).
[v0.6.0] — 2026-10-20
«Rules, routing and traffic» (Phase 4), complete: requests are routed by
typed rules, a request can be shap...
v0.6.1
Changelog — EQProxy
Language: English. Format follows "Keep a Changelog" (Added / Changed /
Fixed); versions are vMAJOR.MINOR.PATCH git tags.
[Unreleased]
Nothing yet.
[v0.6.1] — 2026-10-20
«Rules, routing and traffic», patch release. It closes every scope box that
v0.6.0 had postponed by owner decision: multi-certificate TLS per SNI, the
two Envoy rule actions (hash_policy and request_mirror), the native
filter equivalence table (B-E4) and the public JSON Schema with editor
autocompletion (Q1/CE1, which also completes CE8). The v0.6 roadmap now
reads 32/32 scope boxes and 6/6 version-gate criteria. No behaviour of an
existing deployment changes: every addition is opt-in and every new key is
rejected by the config gate when malformed.
Added
- Multi-certificate TLS per SNI (B-I2/B-H10/B-E9; Envoy
filter_chainsper
SNI, nginx multiplessl_certificate):[[listener.*.tls.certs]]entries
addcert/key/namesto a listener. Thecert/keypair of
[listener.*.tls]stays the default certificate — it answers clients
without SNI and names no entry claims, like nginx's default server — and
each entry answers the names it declares: exact names first, then the
longest wildcard, with*.example.comcovering exactly one extra label
(RFC 6125). Names are case-insensitive and tolerate a trailing dot; the
config gate rejects an emptynameslist, a name claimed by two
certificates, malformed labels, a wildcard that is not the leftmost label
and IP literals (a TLS client never sends an IP as SNI, RFC 6066) — each
error naming the field, the cause and a valid example.config explain
lists the names of each listener (sni=[…]). The hot certificate reload
path rebuilds the resolver, so the SNI set swaps in hot (verified: the
same name answers with the previous certificate, then with the reloaded
one, on the same listener). Verified end to end: unit tests for the name
grammar and the resolver preference order, a dataplane E2E with real
handshakes that records the presented leaf per SNI (exact, wildcard,
two-label miss, unknown name, no SNI, case-insensitivity) and the real
binary driven by
openssl s_client -servername … -showcertsfor both names plus the
default fallbacks, with traffic through the SNI tunnel. - Envoy rule actions
hash_policyandrequest_mirror(B-H3), the last two
pieces of the rule engine.hash_policy = { key = "source" | "path" | "host" | "cookie:NAME" | "header:NAME" }makes asplitpick its target
by weighted rendezvous hashing instead of the rotation: the same key
keeps its target across requests and connections, the weight sets each
target's share of keys, and removing a target only remaps the keys it
owned (no state, no ring to rebuild); a request without the key falls back
to the smooth weighted rotation.request_mirror = { backend, percentage = 1..=100 }sends a copy of the matching request to a
shadow backend on a detached task — the client keeps the primary answer,
a mirror that is down or slow changes nothing (requests_mirrored,
mirror_failures,mirror_skippedin METRICS), the copy carries the
same forwarded head (and body, up to a 256 KiB budget — larger is not
mirrored and counted, never truncated). The config gate names field,
cause and fix for a hash policy without a split, with fewer than two live
targets or with an unknown key, and for a mirror to a missing backend,
with a percentage outside 1..=100 or on a local action.config explain
shows(hash cookie:session …)andmirror 100% -> shadow. Verified end
to end: stickiness, spread and consistency under target removal, a real
copy observed on the shadow socket, a dead shadow backend without client
impact, and the same through the real binary. - Public JSON Schema of the configuration (Q1/CE1):
eqproxy config schema [--output FILE]prints or writes a draft 2020-12 schema generated from
the same Rust types the parser deserializes (so it cannot drift from the
accepted format), published as
eqproxy.schema.json.eqproxy.toml.example
starts with the#:schema ./eqproxy.schema.jsondirective, which gives
Taplo/Even Better TOML users completion and inline validation — including
additionalProperties: false(fromdeny_unknown_fields), so a typo is
flagged beforeconfig validaterejects it. Three gates keep it honest:
the generated schema must match the repository file byte for byte, the
documented example must satisfy it (and the real gate), and a typo plus a
numeric duration must be rejected by it. - Filter equivalence table (B-E4) in docs/equivalences.md:
CORS,ext_authzandrate_limitmapped from Envoy, nginx, HAProxy and
ingress-nginx to EQProxy, with the semantics that differ stated explicitly
(fail-closed auth withoutfailure_mode_allow, a per-instance token
bucket, HTTP-onlyext_authz) and what still needs WASM.
Changed
- Container image runtime:
scratchwith a static musl binary instead of
debian:bookworm-slim. Docker Scout scanned the previous image with 4
critical + 13 high findings, every one of them in packages inherited from
the Debian base (perl, util-linux, openssl, zlib) and none in EQProxy; the
fix removes that surface rather than patching it: the image now carries
0 packages (binary +/etc/ssl/certs/ca-certificates.crtonly, 15.8 MB
locally vs 164 MB), reports0C 0H 0M 0Land passes the non-root and
copyleft policies. The builder stage fails if the binary stops being
statically linked, and it now copieseqproxy.schema.jsonbefore compiling
(eqproxy-configembeds it withinclude_str!, so the image build was
broken by the Q1/CE1 increment and this fixes it). - Release pipeline: the image is scanned with Docker Scout (
critical,high,
--exit-code) before the push, builds use--pullso the recorded
base digest is current, and the pushed manifest carries provenance
(mode=max) plus an SPDX SBOM.
Verification (this release)
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings(0 warnings) andcargo test --workspacegreen: 23 suites,
327 passed, 0 failed, 2 ignored (the two Docker-gated harnesses, last
measured 23/23 differential and 65/65 migration on the v0.6.0 tree; this
patch does not touch those paths).veyra audit . --profile standard: ACCEPTED, plus the Rust Audit static
scan and an EvoForgemcp_submit_evalon a real slice.- Image scanned with Docker Scout before publishing (see the image
section of the release evidence). - Parity of the two runtimes measured on the released artifacts over both
architectures and both transports (S-H1-04, 16 reps per cell): arm64 HTTP
+1.6 % (p 0.51) and arm64 TLS +2.0 % (p 0.68) for the container, amd64 HTTP
+6.2 % (p 0.062) and amd64 TLS +6.0 % (p 0.027), with p50/p95 within a few
percent, RSS at parity (5.3–7.2 MiB both sides) and every response a real
200in all 128 measured reps. The sign flips between campaigns, so the
supported reading is "no meaningful regression", not "musl is faster".
Report:docs/evidence/v0.6.1-parity/
(it also records the correction of the first version, which bind-mounted the
glibc binary and produced virtiofs TLS outliers and a false −40 % RSS).
[v0.6.0] — 2026-10-20
«Rules, routing and traffic» (Phase 4), complete: requests are routed by
typed rules, a request can be shaped on the way up and back, traffic is
balanced, split, limited, pinned and authenticated per rule, DNS changes
in hot, and migrations from the four reference implementations are
demonstrated case by case. The version gate closes with every criterion
measured (below). v0.6.0-alpha published the first slice of this same
phase; everything in the next section is part of this release.
Added
- Typed routing rules per listener (B-H3/B-H4/B-N2/B-N3): ordered
first-match-wins conditions —host/host_prefix/host_regex
(case-insensitive, port-free),path/path_prefix/path_regex
(literal, no normalization),methods,headers,cookies(RFC 6265),
map(hot data, below),ipCIDR blocks — routing to exactly one action;
the listener'sbackendis the default server when nothing matches. - Rule actions (B-N5/B-I6/B-I11):
use_backend,split(weighted
traffic with smooth round-robin shares andweight = 0rollback),
redirect(3xx with a$host/$uri/$request_uritemplate) and
direct_response(local answer, no backend contacted), plus
rewrite.path_prefix_rewrite/host_rewrite. - Request and response header actions (B-N4/B-I4/B-E17):
set_headers,
add_headers,del_headers,response_headers, templated with
$remote_addr/$host/$scheme; theforwarded_headerspolicy is
applied last so a rule can never spoof it, and framing headers are
rejected. - Balancing (B-H5/B-N12/B-E6):
roundrobin,leastconn(weighted
least-connections with SWRR tie-breaking) andslow_startdynamic
weighting that ramps a recovered server from ~0 to its weight. - Source and cookie affinity (B-H8/B-N13/B-I8), bounded by construction
(no stick tables):mode = "source"hashes the client IP,mode = "cookie"inserts a slot token re-issued only when stale. - Canary and weighted traffic (B-I11; ingress
canary-weight,
canary-by-header,canary-by-cookie; Envoyweighted_clusters) and
hot data maps (C10/PP-02):[maps.*]re-read onreload_intervaland
swapped atomically without a config reload and without losing any
other state; a bad update is rejected once, the last good data keeps
serving,maps[reloads= failures=]counts it. - Per-rule rate limiting (B-N8/B-I7/B-E16): token bucket per client IP
with explicit429+rate_limitedcounter, no network in the hot
path. - CORS and HSTS (B-I10): loc...
v0.6.0
Changelog — EQProxy
Language: English. Format follows "Keep a Changelog" (Added / Changed /
Fixed); versions are vMAJOR.MINOR.PATCH git tags.
[v0.6.0] — 2026-10-20
«Rules, routing and traffic» (Phase 4), complete: requests are routed by
typed rules, a request can be shaped on the way up and back, traffic is
balanced, split, limited, pinned and authenticated per rule, DNS changes
in hot, and migrations from the four reference implementations are
demonstrated case by case. The version gate closes with every criterion
measured (below). v0.6.0-alpha published the first slice of this same
phase; everything in the next section is part of this release.
Added
- Typed routing rules per listener (B-H3/B-H4/B-N2/B-N3): ordered
first-match-wins conditions —host/host_prefix/host_regex
(case-insensitive, port-free),path/path_prefix/path_regex
(literal, no normalization),methods,headers,cookies(RFC 6265),
map(hot data, below),ipCIDR blocks — routing to exactly one action;
the listener'sbackendis the default server when nothing matches. - Rule actions (B-N5/B-I6/B-I11):
use_backend,split(weighted
traffic with smooth round-robin shares andweight = 0rollback),
redirect(3xx with a$host/$uri/$request_uritemplate) and
direct_response(local answer, no backend contacted), plus
rewrite.path_prefix_rewrite/host_rewrite. - Request and response header actions (B-N4/B-I4/B-E17):
set_headers,
add_headers,del_headers,response_headers, templated with
$remote_addr/$host/$scheme; theforwarded_headerspolicy is
applied last so a rule can never spoof it, and framing headers are
rejected. - Balancing (B-H5/B-N12/B-E6):
roundrobin,leastconn(weighted
least-connections with SWRR tie-breaking) andslow_startdynamic
weighting that ramps a recovered server from ~0 to its weight. - Source and cookie affinity (B-H8/B-N13/B-I8), bounded by construction
(no stick tables):mode = "source"hashes the client IP,mode = "cookie"inserts a slot token re-issued only when stale. - Canary and weighted traffic (B-I11; ingress
canary-weight,
canary-by-header,canary-by-cookie; Envoyweighted_clusters) and
hot data maps (C10/PP-02):[maps.*]re-read onreload_intervaland
swapped atomically without a config reload and without losing any
other state; a bad update is rejected once, the last good data keeps
serving,maps[reloads= failures=]counts it. - Per-rule rate limiting (B-N8/B-I7/B-E16): token bucket per client IP
with explicit429+rate_limitedcounter, no network in the hot
path. - CORS and HSTS (B-I10): local preflight for
OPTIONS
(204+ allow-methods/headers/max-age, the backend never sees it) and
access-control-*/vary: originon rule responses, with"*"+
credentials rejected at the config gate. - Per-rule authentication (B-N9/B-I9):
type = "basic"against htpasswd
($apr1$pinned toopenssl passwd -apr1, plain allowed, bcrypt/crypt
rejected by name) andauth-urlsubrequests carrying the client
headers andX-Original-Method/X-Original-URL, failing closed
(500for a refusing auth service,502when it is down or times out,
counted inauth_denied/auth_service_errors). - Non-blocking dynamic DNS (B-H13/B-N18/B-E15): listeners bind and serve
immediately whatever DNS does, unresolved slots answer an explicit
503(counted inno_available_server),resolve_intervalre-resolves
and swaps addresses in hot keeping the last-known one on failure, with
dns[ok= failures= updates=]in METRICS. - WebSocket upgrades and
CONNECTtunnels (B-H15/B-I18/B-E11) with
per-direction idle timeouts, and PROXY protocol v2 send + autodetected
receive (B-H9/B-E10). - Timeouts per phase and direction (Q5/B-H11(p)/B-E12):
stream_idle_timeout,max_stream_duration(total exchange cap,
504when it fires),request_headers_timeout, each documented in
config explain(timeout_on_fire). - Migration matrix (v0.6 gate «migración demostrada»):
docs/migration/
holds the same demo deployment written in HAProxy 3.4, operator nginx,
static Envoy 1.34 and ingress-nginx Kubernetes objects (plus the config
the controller generates) and its translation toeqproxy.toml; the
harnesseqproxy-cli/tests/migration.rsruns all four originals in real
containers next to the EQProxy binary and asserts 13/13 cases on
every implementation (65/65 checks, no exclusions), covering pool
choice, canary by header/cookie, method routing, prefix rewrites,
redirects, local answers and the default backend. schema_versioncompatibility policy (Q3, ADR-0002): the binary accepts
1..=SCHEMA_VERSION, rejects unknown fields and versions outside the
range by name, and version validation moves only with a declared
transition.
Changed
- Workspace version and the internal path-dependency pins move to
0.6.0(the release tag must match the workspace version). config validate,doctor,config explainand the config gate now
cover the rules, actions, maps, CORS, rate limit, auth, affinity and
DNS fields, each error naming the field, the cause and a valid example.
Fixed
- HTTP health checks complete at the HTTP message boundary (RFC 9112 §6)
instead of waiting for the origin to close (Content-Length, chunked,
HEAD/1xx/204/304 and close-delimited bodies), so a keep-alive origin
no longer flaps a healthy server todown.
Version gate (all criteria measured)
- Property-test corpus + live fuzzing of the rule evaluator: green
(26,834 executions, 0 crashes). - Differential corpus against HAProxy 3.4.6, nginx, the ingress-nginx data
plane and static Envoy v1.34.2: 23/23 cases, 0 exclusions
(cargo test -p eqproxy-cli --test differential -- --ignored). - Migration demonstrated: 13/13 cases × 5 implementations = 65/65
checks (cargo test -p eqproxy-cli --test migration -- --ignored),
evidence indocs/evidence/v06-migration/. config explainexplains the routing decision per rule; hung-DNS
startup serves (or answers an explicit503); benchmarks S-H1-04/05 on
amd64 and arm64 with raw data indocs/evidence/v06-bench{,-arm64}/.cargo fmt --check,cargo clippy --workspace --all-targets(0
warnings) andcargo test --workspacegreen.
Declared gaps (postponed by owner decision, 2026-10-20)
hash_policy(consistent hashing / keyed sessions) and
request_mirror→ v0.6.x.- Multi-certificate TLS per SNI (B-I2/B-H10/B-E9) → v0.6.x/v0.7.
- Public JSON Schema / editor autocompletion (Q1/CE1) → v0.6.x.
- The B-E4 native-filter equivalence table (CORS, ext_authz, rate_limit
already exist under their own names) → v0.6.x. - Map values are consumed through
equalsmatching only (not as backend
names or header values), and map files must be written atomically.
[v0.6.0-alpha] — 2026-10-19
Prerelease slice of v0.6 «Reglas, enrutado y tráfico»: typed routing rules,
rule actions and the differential harness; superseded by v0.6.0 above,
which carries the complete feature set. Published as a prerelease (the
:latest image tags kept pointing at v0.5.0).
Added
- Typed routing rules per listener (B-H3/B-H4, first slice):
[[listener.*.rules]]evaluates ordered, first-match-wins conditions
per request —host/host_prefix/host_regex(case-insensitive,
compared port-free),path/path_prefix/path_regex(literal,
case-sensitive, no normalization:/v1also matches/v10),
methods(case-sensitive tokens),headers(exact byte-for-byte /
unanchored regex / presence, all ANDed) andip(effective source in
one of the CIDR blocks) — and routes the match touse_backend; the
listener'sbackendremains the fallback when no rule matches. The
semantics are defined once ineqproxy-core::rulesand pinned by
property tests and fuzzing (Q8: defined once, never silently changed). - Actionable rule validation (Q1/CE1): a bad rule is rejected naming its
field, the cause and a concrete valid example
(listener.web.rules[0].host_regex); at most one host and one path
variant per rule, and at least one condition is required (the
catch-all case is the listener's default backend, not a rule). config explaintraces routing per rule (H4): every rule with why it
matched or not (rule[0] "api": no match (host: expected …)), then
route_decisionnaming the chosen backend — never a bare backend
without the path to it.- Rule actions (B-N5/B-I6, second slice; Envoy
prefix_rewrite/
host_rewrite,redirect,direct_response): each rule carries
exactly one action —use_backend(optionally with a
[listener.*.rules.rewrite]subtable:path_prefix_rewritereplaces
the rule's matchedpath_prefixin the forwarded target (literal;
""strips it) andhost_rewritesets the upstreamHost),
redirect(that 3xx withLocationrendered per request from a
template with$host,$uriand$request_uri; statuses
301/302/303/307/308) ordirect_response(that status,content_type
andbody, served locally). The local actions never contact a backend
and drain the request body to keep keep-alive aligned (else close);
they are counted in the newredirects_served/
direct_responses_servedmetrics.config explainreports each
action (redirect 301 -> "/new",direct_response 200 …,
use_backend … (rewrite: …)) and labels local responses
no backend contacted. - Action validation (Q1):
rewriteis accepted only onuse_backend
rules and needs the rule'spath_prefix; redirect templates reject
unknown$…placeholders; every error names its field
(listener.web.rules[0].redirect.status) with a concrete example. - Differential test harness for the v0.6 gate (declared corpus in
docs/differential-corpus.md): the same 16 shared-semantics cases run
against EQProxy (th...
v0.6.0-alpha
Changelog — EQProxy
Language: English. Format follows "Keep a Changelog" (Added / Changed /
Fixed); versions are vMAJOR.MINOR.PATCH git tags.
[v0.6.0-alpha] — 2026-10-19
First slice of v0.6 «Reglas, enrutado y tráfico»: typed routing rules.
Published as a prerelease (v0.6 stays open: differential tests and
the remaining version gates are pending). The :latest image tags keep
pointing at v0.5.0 until a final release.
Added
- Typed routing rules per listener (B-H3/B-H4, first slice):
[[listener.*.rules]]evaluates ordered, first-match-wins conditions
per request —host/host_prefix/host_regex(case-insensitive,
compared port-free),path/path_prefix/path_regex(literal,
case-sensitive, no normalization:/v1also matches/v10),
methods(case-sensitive tokens),headers(exact byte-for-byte /
unanchored regex / presence, all ANDed) andip(effective source in
one of the CIDR blocks) — and routes the match touse_backend; the
listener'sbackendremains the fallback when no rule matches. The
semantics are defined once ineqproxy-core::rulesand pinned by
property tests and fuzzing (Q8: defined once, never silently changed). - Actionable rule validation (Q1/CE1): a bad rule is rejected naming its
field, the cause and a concrete valid example
(listener.web.rules[0].host_regex); at most one host and one path
variant per rule, and at least one condition is required (the
catch-all case is the listener's default backend, not a rule). config explaintraces routing per rule (H4): every rule with why it
matched or not (rule[0] "api": no match (host: expected …)), then
route_decisionnaming the chosen backend — never a bare backend
without the path to it.
Changed
- HTTP listeners route each request through the listener's rule list
before falling back to the default backend (TCP mode is unaffected). - Repository layout (owner decision 2026-10-19):
scripts/(release and
benchmark harnesses) is maintainer-local and no longer published; the
release pipeline is self-contained in.github/workflows/release.yml
(it absorbedscripts/release.sh), so CI builds and verifies every
artifact without the folder.
Verified against the built binary (E2E: multi-backend routing, rule
order, fallback and negative cases) with a property-test corpus and a
live fuzz target for the evaluator (26,834 executions, 0 crashes).
[v0.5.0] — 2026-10-18
«Operabilidad y confianza» (completa Phase 3). The two blocks that sat
under Unreleased (planned as 0.4.6 — hot reload and release
automation) fold into this release.
Added
- HTTP health checks with
expectmatching (B-H6/B-N11/B-I19/B-E5):
type = "http"withhttp_path,http_method,http_host,
expect_status(ranges like"200-299") andexpect_rstring
(regex over the body). One real exchange per probe with a hard
timeout; E2E-verified (a backend answering 500 is marked DOWN and
traffic moves to the healthy server). - Passive failure detection (B-N10): with
passive = trueon the health
check, live-traffic verdicts feed the same fall/rise machine —
connect-phase failures and HTTP 5xx responses count as failures, other
completed exchanges as success. Passive marks only apply when an
active checker exists that can bring the server back (safe restart). - Safe connect-phase retries and bounded backend queues (B-H7/B-E7):
retries(extra dials, max 5) with the documented replay rule
(idempotent methods only unlessretry_non_idempotent = trueopts in;
the failed server is excluded from redials), andmaxqueue+
timeout_queue(never more thanmaxqueuewaiters, never unbounded
waits). A full queue is rejected immediately with 503 and an expired
waiter with 503 — never a silent hang. Counters:queue_full,
queue_timeout,retry_attempts. - Incoming PROXY protocol v1 with an origin allowlist (B-H9/B-E10):
proxy_receive = true+proxy_allow = ["10.0.0.0/8", …]turns the
listener into a strict front door — non-allowlisted origins and
malformed or timed-out headers are closed without a byte forwarded
(proxy_protocol_denied,proxy_protocol_invalid). The claimed
client address becomes the peer for logs, forwarded headers and
outgoing PROXY. - mTLS (A15):
client_caunder[listener.*.tls]requires client
certificates issued by that CA (verified E2E: no cert and foreign
certs are rejected, the right cert round-trips). - Forwarded headers (B-N4, first step):
forwarded_headers = "off" | "append" | "replace"handlesX-Forwarded-For/
X-Real-IPon the forwarded copy of the head —appendkeeps the
chain and grows it,replacedrops spoofed hops. Off by default. - Custom local responses (C9 minimal):
local_response(status,
content_type, body) serves the "no server available" class with the
configured body; counted inlocal_responses_served. - Hot certificates (D7): a reload now applies new TLS material on kept
sockets (verified E2E: after rotation the old root fails and the new
one works on the same socket), and the swap latency is measured and
published (cfg[… last_swap_us=N], PP-04/05 — measured, never
assumed). config explaindocuments what each timeout does when it fires
(Q4/H5/CE5):timeout_on_firemaps every phase to its client-visible
outcome (408/502/504/close), plus the queue, retry and local-response
policy of the backend.- Soak harness (
scripts/soak.py) with published budgets (BENCHMARK_PLAN
§9): the real binary under sustained load with RSS/CPU sampled from
/proc, and an honest verdict (no measurement, no PASS). The 24h gate
itself passed on netcup-bench (2026-10-18): 17.28M requests, 0 errors,
0 bad status, steady RSS 8.86 MiB (budget 64), peak 9.32 MiB (budget
128), clean SIGINT shutdown (exit 0).
Changed
- Keep-alive pool hardening (A16): before pooling, the connection is
probed for EOF — a backend that closed (even without announcing
Connection: close) is dropped instead of handed to the next
exchange, which used to fail with 502 on its first write. - Metrics snapshot rendering groups counters by failure origin
(client/proxy/network/backend/phases; Q4/H5/CE5) with bounded
cardinality (CE4: fixed counter set, no labels).
Added (folded from the planned 0.4.6)
-
Hot config reload (S-CFG-01):
kill -HUPre-reads the config file and
applies it to the running proxy. The candidate goes through the same
gate as startup (parse + validate + resolve); a rejected one logs
config reload rejectedwith the cause and the running config keeps
serving (fail-closed). Accepted swaps match listeners by name: same
name + address keeps the socket (no rejection window for new
connections), missing addresses are bound before anything mutates
(all-or-nothing), removed listeners stop, and established connections
drain with the config they were accepted with. Counters never reset
(PP-03) and the newcfg[reloads=N reload_failures=N]metrics group
makes reloads alertable. -
Release automation (
.github/workflows/release.yml): pushing av*
tag runs the exactscripts/release.shstages on the runners that can
execute them (macos-15 for darwin, ubuntu + containers for linux),
E2E-verifies every artifact (real CLI journeys and live proxied
traffic) before anything is published, then creates the GitHub release
(binaries + SHA256SUMS + SBOM) and builds, verifies and pushes the
multi-arch image to ghcr.release.shgained stages (darwin,linux,
sbom,sums,verify-image) so local runs and CI share one verified
pipeline; the image artifact itself is verified (version, config gate
and live proxied traffic through the container).
Fixed
- Release-pipeline E2E backend no longer starts Python's
http.server
CLI: on Python 3.14HTTPServer.server_bindresolves reverse DNS
(socket.getfqdn()) betweenbind()andlisten(), which hung on the
macOS runners and left the probe bound but never listening. The
verification backend is now a raw socket with zero name resolution,
and the loopback wait in CI reports what actually blocked.
[v0.4.5] — 2026-10-15
Fixed
- HTTP/1.1 interim responses (1xx) no longer desync a keep-alive client or a
pooled backend connection. Before, a100/103head from the backend was
mistaken for the final response: the client saw the interim as its only
answer and the real response stayed queued for the next exchange. Interim
responses are now forwarded (RFC 9112 §6.2) and the loop keeps reading
until the final message; a redundant backend100after the proxy's own
is consumed exactly once.101 Switching Protocolsis refused with 502
(upgrade tunneling is unsupported) and 1xx floods are bounded (8 max). Expect: 100-continuedeadlocked until the inactivity timeout: the client
waited for an interim response that never came while the proxy waited for
the body. The proxy now releases the client with a real100 Continue
(RFC 9112 §10.1.2) once the request head has reached the backend.- Unsupported
Expectvalues are rejected explicitly with 417 instead of
being silently forwarded (RFC 9112 §10.1.2). HTTP/1.0 requests ignore the
100-continueexpectation as the RFC requires. - Malformed backend status lines (
HTTP/1.1 999 …,016, …) are rejected
with 502; status codes outside 100–599 were previously accepted. - Backend bytes sent past the end of a response were silently discarded and
the connection returned to the keep-alive pool, breaking the A16
byte-alignment contract (the next exchange could consume the stale tail).
The connection is now dropped instead of pooled, the bytes are never
forwarded to the client, and the drop increments the new bounded
backend_connections_discardedcounter (metrics snapshot) so operators
can alert on it (audit finding on the interim-response change; also
reachable befor...
v0.5.0
Changelog — EQProxy
Language: English. Format follows "Keep a Changelog" (Added / Changed /
Fixed); versions are vMAJOR.MINOR.PATCH git tags.
[Unreleased]
Nothing yet. Next: v0.6 «Reglas, enrutado y tráfico».
[v0.5.0] — 2026-10-18
«Operabilidad y confianza» (completa Phase 3). The two blocks that sat
under Unreleased (planned as 0.4.6 — hot reload and release
automation) fold into this release.
Added
- HTTP health checks with
expectmatching (B-H6/B-N11/B-I19/B-E5):
type = "http"withhttp_path,http_method,http_host,
expect_status(ranges like"200-299") andexpect_rstring
(regex over the body). One real exchange per probe with a hard
timeout; E2E-verified (a backend answering 500 is marked DOWN and
traffic moves to the healthy server). - Passive failure detection (B-N10): with
passive = trueon the health
check, live-traffic verdicts feed the same fall/rise machine —
connect-phase failures and HTTP 5xx responses count as failures, other
completed exchanges as success. Passive marks only apply when an
active checker exists that can bring the server back (safe restart). - Safe connect-phase retries and bounded backend queues (B-H7/B-E7):
retries(extra dials, max 5) with the documented replay rule
(idempotent methods only unlessretry_non_idempotent = trueopts in;
the failed server is excluded from redials), andmaxqueue+
timeout_queue(never more thanmaxqueuewaiters, never unbounded
waits). A full queue is rejected immediately with 503 and an expired
waiter with 503 — never a silent hang. Counters:queue_full,
queue_timeout,retry_attempts. - Incoming PROXY protocol v1 with an origin allowlist (B-H9/B-E10):
proxy_receive = true+proxy_allow = ["10.0.0.0/8", …]turns the
listener into a strict front door — non-allowlisted origins and
malformed or timed-out headers are closed without a byte forwarded
(proxy_protocol_denied,proxy_protocol_invalid). The claimed
client address becomes the peer for logs, forwarded headers and
outgoing PROXY. - mTLS (A15):
client_caunder[listener.*.tls]requires client
certificates issued by that CA (verified E2E: no cert and foreign
certs are rejected, the right cert round-trips). - Forwarded headers (B-N4, first step):
forwarded_headers = "off" | "append" | "replace"handlesX-Forwarded-For/
X-Real-IPon the forwarded copy of the head —appendkeeps the
chain and grows it,replacedrops spoofed hops. Off by default. - Custom local responses (C9 minimal):
local_response(status,
content_type, body) serves the "no server available" class with the
configured body; counted inlocal_responses_served. - Hot certificates (D7): a reload now applies new TLS material on kept
sockets (verified E2E: after rotation the old root fails and the new
one works on the same socket), and the swap latency is measured and
published (cfg[… last_swap_us=N], PP-04/05 — measured, never
assumed). config explaindocuments what each timeout does when it fires
(Q4/H5/CE5):timeout_on_firemaps every phase to its client-visible
outcome (408/502/504/close), plus the queue, retry and local-response
policy of the backend.- Soak harness (
scripts/soak.py) with published budgets (BENCHMARK_PLAN
§9): the real binary under sustained load with RSS/CPU sampled from
/proc, and an honest verdict (no measurement, no PASS). The 24h gate
itself passed on netcup-bench (2026-10-18): 17.28M requests, 0 errors,
0 bad status, steady RSS 8.86 MiB (budget 64), peak 9.32 MiB (budget
128), clean SIGINT shutdown (exit 0).
Changed
- Keep-alive pool hardening (A16): before pooling, the connection is
probed for EOF — a backend that closed (even without announcing
Connection: close) is dropped instead of handed to the next
exchange, which used to fail with 502 on its first write. - Metrics snapshot rendering groups counters by failure origin
(client/proxy/network/backend/phases; Q4/H5/CE5) with bounded
cardinality (CE4: fixed counter set, no labels).
Added (folded from the planned 0.4.6)
-
Hot config reload (S-CFG-01):
kill -HUPre-reads the config file and
applies it to the running proxy. The candidate goes through the same
gate as startup (parse + validate + resolve); a rejected one logs
config reload rejectedwith the cause and the running config keeps
serving (fail-closed). Accepted swaps match listeners by name: same
name + address keeps the socket (no rejection window for new
connections), missing addresses are bound before anything mutates
(all-or-nothing), removed listeners stop, and established connections
drain with the config they were accepted with. Counters never reset
(PP-03) and the newcfg[reloads=N reload_failures=N]metrics group
makes reloads alertable. -
Release automation (
.github/workflows/release.yml): pushing av*
tag runs the exactscripts/release.shstages on the runners that can
execute them (macos-15 for darwin, ubuntu + containers for linux),
E2E-verifies every artifact (real CLI journeys and live proxied
traffic) before anything is published, then creates the GitHub release
(binaries + SHA256SUMS + SBOM) and builds, verifies and pushes the
multi-arch image to ghcr.release.shgained stages (darwin,linux,
sbom,sums,verify-image) so local runs and CI share one verified
pipeline; the image artifact itself is verified (version, config gate
and live proxied traffic through the container).
Fixed
- Release-pipeline E2E backend no longer starts Python's
http.server
CLI: on Python 3.14HTTPServer.server_bindresolves reverse DNS
(socket.getfqdn()) betweenbind()andlisten(), which hung on the
macOS runners and left the probe bound but never listening. The
verification backend is now a raw socket with zero name resolution,
and the loopback wait in CI reports what actually blocked.
[v0.4.5] — 2026-10-15
Fixed
- HTTP/1.1 interim responses (1xx) no longer desync a keep-alive client or a
pooled backend connection. Before, a100/103head from the backend was
mistaken for the final response: the client saw the interim as its only
answer and the real response stayed queued for the next exchange. Interim
responses are now forwarded (RFC 9112 §6.2) and the loop keeps reading
until the final message; a redundant backend100after the proxy's own
is consumed exactly once.101 Switching Protocolsis refused with 502
(upgrade tunneling is unsupported) and 1xx floods are bounded (8 max). Expect: 100-continuedeadlocked until the inactivity timeout: the client
waited for an interim response that never came while the proxy waited for
the body. The proxy now releases the client with a real100 Continue
(RFC 9112 §10.1.2) once the request head has reached the backend.- Unsupported
Expectvalues are rejected explicitly with 417 instead of
being silently forwarded (RFC 9112 §10.1.2). HTTP/1.0 requests ignore the
100-continueexpectation as the RFC requires. - Malformed backend status lines (
HTTP/1.1 999 …,016, …) are rejected
with 502; status codes outside 100–599 were previously accepted. - Backend bytes sent past the end of a response were silently discarded and
the connection returned to the keep-alive pool, breaking the A16
byte-alignment contract (the next exchange could consume the stale tail).
The connection is now dropped instead of pooled, the bytes are never
forwarded to the client, and the drop increments the new bounded
backend_connections_discardedcounter (metrics snapshot) so operators
can alert on it (audit finding on the interim-response change; also
reachable before it viaresponse_carry). 100 Continueis only generated when the request actually expects a body
(a bodiless request withExpect: 100-continuegets no interim).- Upstream failures are now diagnosable: invalid response heads, refused
protocol switches (101) and interim floods log a WARN with listener,
backend and peer (no secrets); pool drops are counted in the metrics
(backend_connections_discarded).
[v0.4.4] — 2026-10-15
Added
eqproxy doctor [--config <file>]: pre-flight environment diagnostics
(Phase 3). With a config it gates exactly likerun, then reports one
ok:/warn:/err:line per check: a momentary TCP bind per listener
(proves the port is free and permitted), the TLS state per listener
(material already parsed by the gate), and the FD budget from SPEC §7.3
and T07 (2 FDs per connection across allmaxconnplus one per
listener with 10 % margin, against the softNOFILElimit; a short
budget warns, never fails). Verdict line last (doctor: PASS/FAILwith
counts); exit 0 unless the gate fails or any check errors. With no
--config, environment only (FD limit + pointer to--config).
Backend reachability is deliberately not probed: a down backend is not
a config error, and dialing would pollute backends. New dependency:
rlimit0.10 (safeNOFILEreads;unsafestays forbidden).
[v0.4.3] — 2026-10-15
Fixed
config applyconsumed/dev/stdin(or any pipe) on its first read
and then failed gating on the empty second read. It now reads the
candidate once and gates from those bytes (found by the Veyra audit's
apply-stores-first-configurationscenario; no released file-based
workflow was affected).- Two Clippy lints in the new code (
collapsible_ifin history listing,
needless_borrowin the SIGINT-shutdown test helper).
[v0.4.2] — 2026-10-15
Added
config apply/config rollback(offline versioned state):apply
gates the candidate exactly likerun(load, validate, resolve; exit 1
leaves the state untouched), pushes the previouscurrent.tomlto
history/(capped at 32, oldest pruned) and installs the candidate via
atomic rename; re-applying identical ...
v0.4.5
Changelog — EQProxy
Language: English. Format follows "Keep a Changelog" (Added / Changed /
Fixed); versions are vMAJOR.MINOR.PATCH git tags.
[Unreleased]
[v0.4.5] — 2026-10-15
Fixed
- HTTP/1.1 interim responses (1xx) no longer desync a keep-alive client or a
pooled backend connection. Before, a100/103head from the backend was
mistaken for the final response: the client saw the interim as its only
answer and the real response stayed queued for the next exchange. Interim
responses are now forwarded (RFC 9112 §6.2) and the loop keeps reading
until the final message; a redundant backend100after the proxy's own
is consumed exactly once.101 Switching Protocolsis refused with 502
(upgrade tunneling is unsupported) and 1xx floods are bounded (8 max). Expect: 100-continuedeadlocked until the inactivity timeout: the client
waited for an interim response that never came while the proxy waited for
the body. The proxy now releases the client with a real100 Continue
(RFC 9112 §10.1.2) once the request head has reached the backend.- Unsupported
Expectvalues are rejected explicitly with 417 instead of
being silently forwarded (RFC 9112 §10.1.2). HTTP/1.0 requests ignore the
100-continueexpectation as the RFC requires. - Malformed backend status lines (
HTTP/1.1 999 …,016, …) are rejected
with 502; status codes outside 100–599 were previously accepted. - Backend bytes sent past the end of a response were silently discarded and
the connection returned to the keep-alive pool, breaking the A16
byte-alignment contract (the next exchange could consume the stale tail).
The connection is now dropped instead of pooled, the bytes are never
forwarded to the client, and the drop increments the new bounded
backend_connections_discardedcounter (metrics snapshot) so operators
can alert on it (audit finding on the interim-response change; also
reachable before it viaresponse_carry). 100 Continueis only generated when the request actually expects a body
(a bodiless request withExpect: 100-continuegets no interim).- Upstream failures are now diagnosable: invalid response heads, refused
protocol switches (101) and interim floods log a WARN with listener,
backend and peer (no secrets); pool drops are counted in the metrics
(backend_connections_discarded).
[v0.4.4] — 2026-10-15
Added
eqproxy doctor [--config <file>]: pre-flight environment diagnostics
(Phase 3). With a config it gates exactly likerun, then reports one
ok:/warn:/err:line per check: a momentary TCP bind per listener
(proves the port is free and permitted), the TLS state per listener
(material already parsed by the gate), and the FD budget from SPEC §7.3
and T07 (2 FDs per connection across allmaxconnplus one per
listener with 10 % margin, against the softNOFILElimit; a short
budget warns, never fails). Verdict line last (doctor: PASS/FAILwith
counts); exit 0 unless the gate fails or any check errors. With no
--config, environment only (FD limit + pointer to--config).
Backend reachability is deliberately not probed: a down backend is not
a config error, and dialing would pollute backends. New dependency:
rlimit0.10 (safeNOFILEreads;unsafestays forbidden).
[v0.4.3] — 2026-10-15
Fixed
config applyconsumed/dev/stdin(or any pipe) on its first read
and then failed gating on the empty second read. It now reads the
candidate once and gates from those bytes (found by the Veyra audit's
apply-stores-first-configurationscenario; no released file-based
workflow was affected).- Two Clippy lints in the new code (
collapsible_ifin history listing,
needless_borrowin the SIGINT-shutdown test helper).
[v0.4.2] — 2026-10-15
Added
config apply/config rollback(offline versioned state):apply
gates the candidate exactly likerun(load, validate, resolve; exit 1
leaves the state untouched), pushes the previouscurrent.tomlto
history/(capped at 32, oldest pruned) and installs the candidate via
atomic rename; re-applying identical bytes is a no-op report.
rollbackre-gates the newest backup (relative cert paths resolve
against the apply-time directory recorded in its header) and pops it
into current, so walking back takes one rollback per apply. State lives
in./.eqproxy(EQPROXY_STATE_DIRoverrides it). Hot-apply to a
running process stays pending (S-CFG-01).
Changed
- Dependencies:
equivalent1.0.2 → 1.0.3 (transitive, semver-compatible).
[v0.4.1] — 2026-10-15
Added
config plan: dry run ofrun— loads, validates and resolves (real
certificate reads, address parsing, backend assembly), then reports the
full activation inventory (listeners, timeouts, backends, servers)
without binding or dialing. Exit 1 carries the same actionable errors
runwould fail with.
[v0.4.0] — 2026-10-14
First distributable release: containers and binaries, English-only docs.
Added
- Backend keep-alive pool (A16): per-server idle pool with a reuse-safety
contract (clean exchanges only), reuse/dial counters. config explain: offline simulation of one routing decision (listener,
backend, selected server with health state), no side effects (SPEC §8).config diff: offline comparison of two configurations (+/-/~
vocabulary) for the pre-apply safety workflow.- Official multi-arch container image (
docker/Dockerfile, linux/amd64 +
linux/arm64): locked release build,debian:bookworm-slimruntime as
nobody,ca-certificatesforca_fileagainst the system bundle. - Reproducible release pipeline (
scripts/release.sh): four binaries +
SHA256SUMS + SBOM, every artifact E2E-verified before checksums are
written.
Changed
- Documentation is English-only from this release (code, messages,
comments and all published docs). ADRs renamed to English slugs.
Scope honesty
- This release is not declared production ready (Phase 9 gate pending):
no hot config reload, no rollback, HTTP/1.1 + TCP only, no HTTP/2/3.
See ROADMAP.md for the verified scope per phase.
[v0.3.0] — 2026-10-13
- Real HTTP/1.1 proxy with strict framing (smuggling rejected pre-backend),
TLS termination and verified backend TLS, weighted round-robin, TCP health
checks, PROXY v1 send, per-phase timeouts, framing fuzz corpus.
[v0.2.0] — 2026-10-12
- Real minimal TCP proxy with validated TOML config, resource budgets,
graceful shutdown, basic metrics.
[v0.1.0] — 2026-10-12
- Research and architecture: SPEC, threat model, parity matrix, ADRs,
benchmark plan.
v0.4.4
v0.4.4 — doctor pre-flight diagnostics
Phase 3, eighth slice. With this release every planned CLI command is
implemented except config import (Phase 8): validate, explain,
diff, plan, apply, rollback, doctor.
Added
eqproxy doctor [--config <file>]: pre-flight environment diagnostics.
With a config it gates exactly likerun, then reports one
ok:/warn:/err:line per check: a momentary TCP bind per listener
(proves the port is free and permitted), the TLS state per listener,
and the FD budget from SPEC §7.3 / T07 (2 FDs per connection across all
maxconnplus one per listener with 10 % margin, against the soft
NOFILElimit — a short budget warns, never fails). Verdict line last,
exit 0 unless the gate fails or any check errors. With no--config,
environment only. Backend reachability is deliberately not probed.- New dependency:
rlimit0.10 (safeNOFILEreads;unsafestays
forbidden workspace-wide).
Scope honesty
Not declared production ready (Phase 9 gate pending): TCP + HTTP/1.1
only; apply/rollback manage versioned files offline, hot config
reload under traffic stays pending (S-CFG-01). 136 tests green (fmt,
Clippy -D warnings, full workspace suite, per-artifact release E2E with
live proxied traffic, Veyra Proof audit).
Verify
shasum -c SHA256SUMS.txt
./eqproxy-darwin-arm64 --version # eqproxy 0.4.4v0.4.3
v0.4.3 — apply stdin fix (audit follow-up)
Patch over v0.4.2. File-based apply/rollback workflows from v0.4.2
are unaffected; this fixes the pipe edge found by the Veyra Proof audit.
Fixed
config applyconsumed/dev/stdin(or any pipe) on its first read and
then failed gating on the empty second read. It now reads the candidate
once and gates from those bytes, with a dedicated regression test
(apply_reads_piped_stdin_once) alongside the audit's
apply-stores-first-configurationscenario.- Two Clippy lints in the new code (
collapsible_ifin history listing,
needless_borrowin the SIGINT-shutdown test helper).
Scope honesty
Not declared production ready (Phase 9 gate pending): TCP + HTTP/1.1
only; apply/rollback manage versioned files offline, hot config
reload under traffic stays pending (S-CFG-01). 129 tests green (fmt,
Clippy -D warnings, full workspace suite, per-artifact release E2E with
live proxied traffic, Veyra Proof audit).
Verify
shasum -c SHA256SUMS.txt
./eqproxy-darwin-arm64 --version # eqproxy 0.4.3v0.4.2
v0.4.2 — Offline config apply / config rollback
Phase 3, seventh slice. The pre-apply safety workflow is now complete
offline: validate → explain → diff → plan → apply → rollback.
Added
config apply: fail-closed gate (loads, validates and resolves exactly
likerun— exit 1 leaves the state untouched), pushes the previous
current.tomltohistory/(capped at 32, oldest pruned) and installs
the candidate via atomic rename. Re-applying identical bytes is a no-op
report. State lives in./.eqproxy(EQPROXY_STATE_DIRoverrides it).config rollback: re-gates the newest backup (relative cert paths
resolve against the apply-time directory recorded in its header) and
pops it into current — one rollback per apply to walk back; going
forward again is a freshapply.
Changed
- Dependencies:
equivalent1.0.2 → 1.0.3 (transitive, semver-compatible).
Scope honesty
This release is not declared production ready (Phase 9 gate pending):
TCP + HTTP/1.1 only. apply/rollback manage versioned files offline;
hot config reload under traffic stays pending (S-CFG-01). 128 tests green
(fmt, Clippy -D warnings, full workspace suite, per-artifact release E2E
with live proxied traffic, Veyra Proof audit).
Verify
shasum -c SHA256SUMS.txt
./eqproxy-darwin-arm64 --version # eqproxy 0.4.2