Skip to content

Releases: ExyonQ/EQProxy

v0.6.3

Choose a tag to compare

@github-actions github-actions released this 21 Oct 17:17

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 (curl exit 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 apply and config rollback work 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/eqproxy owned by 65532 and sets EQPROXY_STATE_DIR to 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 on EPIPE, so eqproxy config explain | head (and any long report
    piped into grep -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.1 tag 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

Choose a tag to compare

@github-actions github-actions released this 20 Oct 22:10

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 only linux/amd64;
    linux/arm64 reached the registries unscanned. It now builds the
    multi-arch manifest once, pushes it under a throwaway
    <version>-candidate tag, records the index digest, scans both
    linux/amd64 and linux/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> /
    :latest and mirrors it to Docker Hub — no rebuild, so the scanned bytes
    are the published bytes. The GitHub release job now needs the image job,
    so a release cannot be created next to an unscanned image, and the two
    SCOUT-<version>-linux-<arch>.md reports 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-roots 1.0.9 → 1.0.10 and rustls-webpki 0.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 next veyra audit into
    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:
    the eqproxy-linux-amd64 / eqproxy-linux-arm64 release binaries are
    static musl (no libc, no interpreter, runnable from FROM 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_chains per
    SNI, nginx multiple ssl_certificate): [[listener.*.tls.certs]] entries
    add cert/key/names to a listener. The cert/key pair 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.com covering exactly one extra label
    (RFC 6125). Names are case-insensitive and tolerate a trailing dot; the
    config gate rejects an empty names list, 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 … -showcerts for both names plus the
    default fallbacks, with traffic through the SNI tunnel.
  • Envoy rule actions hash_policy and request_mirror (B-H3), the last two
    pieces of the rule engine. hash_policy = { key = "source" | "path" | "host" | "cookie:NAME" | "header:NAME" } makes a split pick 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_skipped in 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 …) and mirror 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.json directive, which gives
    Taplo/Even Better TOML users completion and inline validation — including
    additionalProperties: false (from deny_unknown_fields), so a typo is
    flagged before config validate rejects 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_authz and rate_limit mapped from Envoy, nginx, HAProxy and
    ingress-nginx to EQProxy, with the semantics that differ stated explicitly
    (fail-closed auth without failure_mode_allow, a per-instance token
    bucket, HTTP-only ext_authz) and what still needs WASM.

Changed

  • Container image runtime: scratch with 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.crt only, 15.8 MB
    locally vs 164 MB), reports 0C 0H 0M 0L and passes the non-root and
    copyleft policies. The builder stage fails if the binary stops being
    statically linked, and it now copies eqproxy.schema.json before compiling
    (eqproxy-config embeds it with include_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 --pull so 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) and cargo test --workspace green: 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 EvoForge mcp_submit_eval on 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
    200 in 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...

Read more

v0.6.1

Choose a tag to compare

@github-actions github-actions released this 20 Oct 17:48

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_chains per
    SNI, nginx multiple ssl_certificate): [[listener.*.tls.certs]] entries
    add cert/key/names to a listener. The cert/key pair 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.com covering exactly one extra label
    (RFC 6125). Names are case-insensitive and tolerate a trailing dot; the
    config gate rejects an empty names list, 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 … -showcerts for both names plus the
    default fallbacks, with traffic through the SNI tunnel.
  • Envoy rule actions hash_policy and request_mirror (B-H3), the last two
    pieces of the rule engine. hash_policy = { key = "source" | "path" | "host" | "cookie:NAME" | "header:NAME" } makes a split pick 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_skipped in 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 …) and mirror 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.json directive, which gives
    Taplo/Even Better TOML users completion and inline validation — including
    additionalProperties: false (from deny_unknown_fields), so a typo is
    flagged before config validate rejects 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_authz and rate_limit mapped from Envoy, nginx, HAProxy and
    ingress-nginx to EQProxy, with the semantics that differ stated explicitly
    (fail-closed auth without failure_mode_allow, a per-instance token
    bucket, HTTP-only ext_authz) and what still needs WASM.

Changed

  • Container image runtime: scratch with 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.crt only, 15.8 MB
    locally vs 164 MB), reports 0C 0H 0M 0L and passes the non-root and
    copyleft policies. The builder stage fails if the binary stops being
    statically linked, and it now copies eqproxy.schema.json before compiling
    (eqproxy-config embeds it with include_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 --pull so 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) and cargo test --workspace green: 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 EvoForge mcp_submit_eval on 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
    200 in 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), ip CIDR blocks — routing to exactly one action;
    the listener's backend is the default server when nothing matches.
  • Rule actions (B-N5/B-I6/B-I11): use_backend, split (weighted
    traffic with smooth round-robin shares and weight = 0 rollback),
    redirect (3xx with a $host/$uri/$request_uri template) 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; the forwarded_headers policy 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) and slow_start dynamic
    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; Envoy weighted_clusters) and
    hot data maps (C10/PP-02): [maps.*] re-read on reload_interval and
    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 explicit 429 + rate_limited counter, no network in the hot
    path.
  • CORS and HSTS (B-I10): loc...
Read more

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 20 Oct 13:49

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), ip CIDR blocks — routing to exactly one action;
    the listener's backend is the default server when nothing matches.
  • Rule actions (B-N5/B-I6/B-I11): use_backend, split (weighted
    traffic with smooth round-robin shares and weight = 0 rollback),
    redirect (3xx with a $host/$uri/$request_uri template) 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; the forwarded_headers policy 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) and slow_start dynamic
    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; Envoy weighted_clusters) and
    hot data maps (C10/PP-02): [maps.*] re-read on reload_interval and
    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 explicit 429 + rate_limited counter, 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: origin on rule responses, with "*" +
    credentials rejected at the config gate.
  • Per-rule authentication (B-N9/B-I9): type = "basic" against htpasswd
    ($apr1$ pinned to openssl passwd -apr1, plain allowed, bcrypt/crypt
    rejected by name) and auth-url subrequests carrying the client
    headers and X-Original-Method/X-Original-URL, failing closed
    (500 for a refusing auth service, 502 when it is down or times out,
    counted in auth_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 in no_available_server), resolve_interval re-resolves
    and swaps addresses in hot keeping the last-known one on failure, with
    dns[ok= failures= updates=] in METRICS.
  • WebSocket upgrades and CONNECT tunnels (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,
    504 when 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 to eqproxy.toml; the
    harness eqproxy-cli/tests/migration.rs runs 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_version compatibility 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 explain and 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 to down.

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 in docs/evidence/v06-migration/.
  • config explain explains the routing decision per rule; hung-DNS
    startup serves (or answers an explicit 503); benchmarks S-H1-04/05 on
    amd64 and arm64 with raw data in docs/evidence/v06-bench{,-arm64}/.
  • cargo fmt --check, cargo clippy --workspace --all-targets (0
    warnings) and cargo test --workspace green.

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 equals matching 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: /v1 also matches /v10),
    methods (case-sensitive tokens), headers (exact byte-for-byte /
    unanchored regex / presence, all ANDed) and ip (effective source in
    one of the CIDR blocks) — and routes the match to use_backend; the
    listener's backend remains the fallback when no rule matches. The
    semantics are defined once in eqproxy-core::rules and 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 explain traces routing per rule (H4): every rule with why it
    matched or not (rule[0] "api": no match (host: expected …)), then
    route_decision naming 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_rewrite replaces
    the rule's matched path_prefix in the forwarded target (literal;
    "" strips it) and host_rewrite sets the upstream Host),
    redirect (that 3xx with Location rendered per request from a
    template with $host, $uri and $request_uri; statuses
    301/302/303/307/308) or direct_response (that status, content_type
    and body, 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 new redirects_served /
    direct_responses_served metrics. config explain reports each
    action (redirect 301 -> "/new", direct_response 200 …,
    use_backend … (rewrite: …)) and labels local responses
    no backend contacted.
  • Action validation (Q1): rewrite is accepted only on use_backend
    rules and needs the rule's path_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...
Read more

v0.6.0-alpha

v0.6.0-alpha Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 18 Oct 23:53

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: /v1 also matches /v10),
    methods (case-sensitive tokens), headers (exact byte-for-byte /
    unanchored regex / presence, all ANDed) and ip (effective source in
    one of the CIDR blocks) — and routes the match to use_backend; the
    listener's backend remains the fallback when no rule matches. The
    semantics are defined once in eqproxy-core::rules and 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 explain traces routing per rule (H4): every rule with why it
    matched or not (rule[0] "api": no match (host: expected …)), then
    route_decision naming 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 absorbed scripts/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 expect matching (B-H6/B-N11/B-I19/B-E5):
    type = "http" with http_path, http_method, http_host,
    expect_status (ranges like "200-299") and expect_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 = true on 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 unless retry_non_idempotent = true opts in;
    the failed server is excluded from redials), and maxqueue +
    timeout_queue (never more than maxqueue waiters, 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_ca under [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" handles X-Forwarded-For /
    X-Real-IP on the forwarded copy of the head — append keeps the
    chain and grows it, replace drops 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 in local_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 explain documents what each timeout does when it fires
    (Q4/H5/CE5): timeout_on_fire maps 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 -HUP re-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 rejected with 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 new cfg[reloads=N reload_failures=N] metrics group
    makes reloads alertable.

  • Release automation (.github/workflows/release.yml): pushing a v*
    tag runs the exact scripts/release.sh stages 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.sh gained 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.14 HTTPServer.server_bind resolves reverse DNS
    (socket.getfqdn()) between bind() and listen(), 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, a 100/103 head 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 backend 100 after the proxy's own
    is consumed exactly once. 101 Switching Protocols is refused with 502
    (upgrade tunneling is unsupported) and 1xx floods are bounded (8 max).
  • Expect: 100-continue deadlocked 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 real 100 Continue
    (RFC 9112 §10.1.2) once the request head has reached the backend.
  • Unsupported Expect values are rejected explicitly with 417 instead of
    being silently forwarded (RFC 9112 §10.1.2). HTTP/1.0 requests ignore the
    100-continue expectation 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_discarded counter (metrics snapshot) so operators
    can alert on it (audit finding on the interim-response change; also
    reachable befor...
Read more

v0.5.0

Choose a tag to compare

@github-actions github-actions released this 18 Oct 16:22

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 expect matching (B-H6/B-N11/B-I19/B-E5):
    type = "http" with http_path, http_method, http_host,
    expect_status (ranges like "200-299") and expect_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 = true on 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 unless retry_non_idempotent = true opts in;
    the failed server is excluded from redials), and maxqueue +
    timeout_queue (never more than maxqueue waiters, 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_ca under [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" handles X-Forwarded-For /
    X-Real-IP on the forwarded copy of the head — append keeps the
    chain and grows it, replace drops 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 in local_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 explain documents what each timeout does when it fires
    (Q4/H5/CE5): timeout_on_fire maps 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 -HUP re-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 rejected with 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 new cfg[reloads=N reload_failures=N] metrics group
    makes reloads alertable.

  • Release automation (.github/workflows/release.yml): pushing a v*
    tag runs the exact scripts/release.sh stages 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.sh gained 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.14 HTTPServer.server_bind resolves reverse DNS
    (socket.getfqdn()) between bind() and listen(), 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, a 100/103 head 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 backend 100 after the proxy's own
    is consumed exactly once. 101 Switching Protocols is refused with 502
    (upgrade tunneling is unsupported) and 1xx floods are bounded (8 max).
  • Expect: 100-continue deadlocked 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 real 100 Continue
    (RFC 9112 §10.1.2) once the request head has reached the backend.
  • Unsupported Expect values are rejected explicitly with 417 instead of
    being silently forwarded (RFC 9112 §10.1.2). HTTP/1.0 requests ignore the
    100-continue expectation 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_discarded counter (metrics snapshot) so operators
    can alert on it (audit finding on the interim-response change; also
    reachable before it via response_carry).
  • 100 Continue is only generated when the request actually expects a body
    (a bodiless request with Expect: 100-continue gets 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 like run, 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 all maxconn plus one per
    listener with 10 % margin, against the soft NOFILE limit; a short
    budget warns, never fails). Verdict line last (doctor: PASS/FAIL with
    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:
    rlimit 0.10 (safe NOFILE reads; unsafe stays forbidden).

[v0.4.3] — 2026-10-15

Fixed

  • config apply consumed /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-configuration scenario; no released file-based
    workflow was affected).
  • Two Clippy lints in the new code (collapsible_if in history listing,
    needless_borrow in the SIGINT-shutdown test helper).

[v0.4.2] — 2026-10-15

Added

  • config apply / config rollback (offline versioned state): apply
    gates the candidate exactly like run (load, validate, resolve; exit 1
    leaves the state untouched), pushes the previous current.toml to
    history/ (capped at 32, oldest pruned) and installs the candidate via
    atomic rename; re-applying identical ...
Read more

v0.4.5

Choose a tag to compare

@ExyonQ ExyonQ released this 15 Oct 19:49

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, a 100/103 head 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 backend 100 after the proxy's own
    is consumed exactly once. 101 Switching Protocols is refused with 502
    (upgrade tunneling is unsupported) and 1xx floods are bounded (8 max).
  • Expect: 100-continue deadlocked 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 real 100 Continue
    (RFC 9112 §10.1.2) once the request head has reached the backend.
  • Unsupported Expect values are rejected explicitly with 417 instead of
    being silently forwarded (RFC 9112 §10.1.2). HTTP/1.0 requests ignore the
    100-continue expectation 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_discarded counter (metrics snapshot) so operators
    can alert on it (audit finding on the interim-response change; also
    reachable before it via response_carry).
  • 100 Continue is only generated when the request actually expects a body
    (a bodiless request with Expect: 100-continue gets 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 like run, 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 all maxconn plus one per
    listener with 10 % margin, against the soft NOFILE limit; a short
    budget warns, never fails). Verdict line last (doctor: PASS/FAIL with
    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:
    rlimit 0.10 (safe NOFILE reads; unsafe stays forbidden).

[v0.4.3] — 2026-10-15

Fixed

  • config apply consumed /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-configuration scenario; no released file-based
    workflow was affected).
  • Two Clippy lints in the new code (collapsible_if in history listing,
    needless_borrow in the SIGINT-shutdown test helper).

[v0.4.2] — 2026-10-15

Added

  • config apply / config rollback (offline versioned state): apply
    gates the candidate exactly like run (load, validate, resolve; exit 1
    leaves the state untouched), pushes the previous current.toml to
    history/ (capped at 32, oldest pruned) and installs the candidate via
    atomic rename; re-applying identical bytes is a no-op report.
    rollback re-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_DIR overrides it). Hot-apply to a
    running process stays pending (S-CFG-01).

Changed

  • Dependencies: equivalent 1.0.2 → 1.0.3 (transitive, semver-compatible).

[v0.4.1] — 2026-10-15

Added

  • config plan: dry run of run — 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
    run would 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-slim runtime as
    nobody, ca-certificates for ca_file against 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

Choose a tag to compare

@ExyonQ ExyonQ released this 15 Oct 13:49

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 like run, 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
    maxconn plus one per listener with 10 % margin, against the soft
    NOFILE limit — 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: rlimit 0.10 (safe NOFILE reads; unsafe stays
    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.4

v0.4.3

Choose a tag to compare

@ExyonQ ExyonQ released this 15 Oct 13:23

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 apply consumed /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-configuration scenario.
  • Two Clippy lints in the new code (collapsible_if in history listing,
    needless_borrow in 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.3

v0.4.2

Choose a tag to compare

@ExyonQ ExyonQ released this 15 Oct 13:05

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
    like run — exit 1 leaves the state untouched), pushes the previous
    current.toml to history/ (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_DIR overrides 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 fresh apply.

Changed

  • Dependencies: equivalent 1.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