• v2.0.0 9ab9f83758

    v2.0.0
    All checks were successful
    Build Image (main) / build-amd64 (push) Successful in 1m10s
    Build Image (main) / build-arm64 (push) Successful in 1m33s
    Build Image (main) / manifest (push) Successful in 1s
    Release Image (tag) / promote (push) Successful in 6s
    Stable

    james.oates released this 2026-10-26 17:40:10 +00:00 | 12 commits to main since this release

    Container image: forge.isohex.uk/isohex/edge-router:v2.0.0 — promoted by digest,
    so it is byte-identical to what main tested.

    The Linux binaries below are extracted from that same image rather than built
    separately, so they match it exactly. They are statically linked against musl: no
    libc, no container runtime and no CA bundle needed on the host. Verify with
    sha256sum -c SHA256SUMS --ignore-missing.

    The image runs as root (uid 0, gid 0) and /app/data is root:root. Unchanged
    from v1.1.0. A fixed non-root uid is planned for a later release and will require
    preparing volume ownership on the host first; nothing to do for this one.

    Read this before upgrading

    v2.0.0 removes three subsystems and changes how certificates are obtained. Every
    removed setting is a hard startup error, not a silent ignore
    — an upgrade with
    stale config fails loudly instead of running in a state you did not configure.

    TPM, OpenBao and at-rest encryption are gone

    Removed: ENCRYPTION_KEY, ENCRYPTION_KEY_FILE, ENCRYPTION_KEY_OLD,
    ENCRYPTION_ENGINE, DATA_DIR, every BAO_* and TPM_* variable, and the
    enrol-tpm subcommand. Delete them from your environment and compose files.

    Certificates and private keys are now stored unencrypted. The database's file
    mode is their protection: ER sets umask(077) before opening the database so the
    journal file SQLite creates is private too, corrects the main file's mode at
    startup, and refuses to start if the database is owned by another user. Put the
    database on a volume only ER can read.

    This is defensible because ER no longer holds a DNS provider credential (see
    below), so a readable database yields the certificates themselves, not the ability
    to issue new ones for your zone.

    Certificates are derived from your routes, not configured

    [[tls]] and [[managed_cert]] are gone. A leftover [[tls]] block is a startup
    error naming its replacement.

    Every host your [[services]], [[static_sites]] and [[admin]] entries mention
    now gets its own certificate, renewed automatically. There is no certificate
    list to maintain: add a route, get a certificate; remove it, the certificate goes.

    Replace your [[tls]] blocks with one [[acme]] block for the account, and point
    ER at a DNS-01 endpoint:

    [[acme]]
    name  = "letsencrypt"
    email = "[email protected]"
    # staging = true
    

    [[acme]] is an array of tables and takes exactly one entry — two is an error
    rather than a silent choice of one. It is an array because using several ACME
    directories is planned (#50), so that arrives as new fields rather than another
    change to this block.

    HTTPREQ_ENDPOINT=https://dns01.internal.example/dns
    

    The endpoint speaks lego's httpreq protocol,
    so any implementation works and ER holds no provider token — cloudflare_api_token
    is gone entirely. Optional basic auth via HTTPREQ_USERNAME/HTTPREQ_PASSWORD,
    each with a _FILE form for a mounted secret.

    Wildcards are now opt-in. Write a route host as *.example.com to get one; by
    default each name gets its own certificate, so a failure for one name cannot affect
    another.

    Two things worth planning for:

    • First rollout consumes rate limit. Let's Encrypt allows 50 new certificates
      per registered domain per week. Renewals are exempt, so steady state is fine, but
      a first run over many names is not — use a wildcard route or stage the rollout.
    • Prove your endpoint under staging first. Set staging = true, confirm
      issuance works, then flip it to false and restart: ER registers a production
      account and reissues everything the staging run produced. Nothing to delete by
      hand.

    Forwarded headers are only believed, and only chained, on a trusted listener

    trust_forwarded_for previously governed what ER believed about an incoming
    request but nothing about what it sent upstream. Three changes:

    • X-Forwarded-For is no longer chained onto the client's own value. On a
      listener marked trust_forwarded_for ER appends its peer to what the upstream
      proxy wrote, as before. On any other listener it sends only its own peer.
      Previously a client could send X-Forwarded-For: 10.0.0.1 and every backend that
      reads the leftmost entry — Rails remote_ip, Express trust proxy, Django, Go
      RealIP, most WAF and rate-limit middleware — would report that address.
    • X-Forwarded-Proto: https from an untrusted client no longer counts. It used
      to suppress the HTTP→HTTPS redirect and then be re-sent to the backend as ER's own
      authoritative header, so a backend using Django's SECURE_PROXY_SSL_HEADER or
      Rails force_ssl treated a cleartext hop as TLS.
    • CF-Connecting-IP and RFC 7239 Forwarded are dropped from client requests
      and never forwarded. CF-Connecting-IP is ER's own most authoritative client-IP
      source, so forwarding the client's copy of it was handing a backend the one header
      ER itself would have refused to believe.

    The WebSocket path now applies the same rules; it previously forwarded every client
    header verbatim and set none of its own.

    Most likely upgrade regression: a redirect loop. If a CDN or tunnel terminates
    TLS and forwards plain HTTP to ER, ER previously accepted that hop's
    X-Forwarded-Proto: https from anyone and skipped its HTTP→HTTPS redirect. It no
    longer does unless the listener is trusted, so a host served that way can start
    answering every request with a 301 to itself. Two fixes, either is fine: set
    trust_forwarded_for = true on the listener that intermediary arrives on (the
    better one — it also restores the real client IP), or set skip_https_redirect = true on the service, which has always been the documented remedy for this topology.

    Also check: if a backend relies on reading a forwarded chain or protocol that
    originates upstream of ER, that listener needs trust_forwarded_for = true — and it
    must be structurally reachable only through the trusted intermediary
    (match_local_ip on its binding). Nothing warns if it is not; see
    #64.

    A wildcard route host now covers exactly one label

    *.example.com matches sub.example.com and no longer matches
    deep.sub.example.com. One label is what a wildcard certificate covers (RFC 6125),
    and routing now matches that — previously ER would route a host it could never obtain
    a certificate for, then serve its self-signed fallback for it.

    What to check: a route relying on two-level wildcard matching stops matching. Add
    a route host for the intermediate wildcard (*.sub.example.com), which gets its own
    certificate, or name the host exactly. [[l4_routes]] SNI and [[static_sites]]
    hosts narrow the same way.

    ALLOWED_ADMIN_HOSTS and ALLOWED_ADMIN_IPS are now startup errors

    Neither was ever read by this version, yet the shipped docker-compose.yml set one
    of them — so an operator reading our own compose file could reasonably conclude admin
    access was restricted to localhost when it was not restricted at all. Rather than
    quietly ignoring them, ER now refuses to start and names the replacement.

    Express admin access as an [[admin]] entry:

    [[admin]]
    host = "router.example.com"
    
    [[admin.access]]
    via       = "lan"
    allow_ips = ["127.0.0.1/32", "::1/128"]
    

    With no [[admin]] entry at all, the admin API and dashboard are not served.
    Every request falls through to the reverse proxy, including /health and /ready.
    So an [[admin]] entry is what turns the API on, and its access rules are what
    restrict it — an entry with no [[admin.access]] rule is deny-all, like every other
    resource.

    A reserved name (.localhost, .test, …) now refuses HTTPS

    Names reserved by RFC 6761/6762 — anything under .localhost, .local, .test,
    .invalid or .example — are skipped rather than attempted, because no publicly
    trusted CA can issue for them
    : they are not in the public DNS, so there is nothing
    for an ACME challenge to validate.

    This is a behaviour change. Previously such a name was served ER's self-signed
    fallback on HTTPS — a certificate warning, but a working connection. It is now
    refused, because ER must never serve one name's certificate for another.

    Watch for ER's own redirect: a route without skip_https_redirect gets a 301 from
    HTTP to HTTPS, so a .localhost route reachable over HTTP today can become
    unreachable. Either give those routes real names under a zone ER can get a
    certificate for, or set skip_https_redirect = true on them.

    They do not count against readiness — a development host whose routes are all
    .localhost still reports ready.

    If you need HTTPS on a name no public CA will issue for, there is no supported way
    to do it in this release; see issue #50 for the options being considered.

    A name with no certificate now fails the handshake

    Previously a name with no usable certificate could be served the parent domain's
    certificate or ER's self-signed fallback. It is now refused with a TLS alert.
    Clients get a connection error rather than a wrong identity. The self-signed
    fallback applies only to names ER was never configured for.

    The TLS REST API is read-only

    GET/POST/DELETE /api/tls and POST /api/tls/<id>/renew are gone; the set is
    derived from routes, so you change it by changing routes. GET /api/certificates
    reports every certificate's state, including the last error. New /ready reports
    per-name certificate state (admin API, authenticated) and /health is a liveness
    probe needing no key.

    [[static_sites]] and [default_site] are going

    Both still work exactly as before. But a static site is really a service whose
    source is a local directory rather than an upstream, and modelling it separately
    is why it has no access rules, no path matching and no request logging. They will
    fold into [[services]] with a dir instead of a target_host, and
    [default_site] becomes [default] — see issue #51.

    Nothing to do now. Said here so the change is a documented plan rather than a
    surprise, and so new config can be written knowing which way this goes.

    Worth knowing meanwhile: a [[static_sites]] entry cannot be restricted to
    a listener, because it has no access_rules field, so it is reachable from
    every binding that terminates HTTP or HTTPS. If a directory should not be
    public, do not serve it as a static site on a host whose bindings include a
    public one.

    Security fixes

    • Path traversal in the static file server. A request path reached
      Path::join unnormalised, so a literal ../ escaped the configured directory and
      read any file the process could open — which, since the image runs as root,
      includes the certificate database. Neither hyper nor axum collapses .., so the
      sequence arrived intact from a raw request; most clients normalise first, which is
      why it went unnoticed. The path is now rebuilt from normal components only and a
      request trying to leave the directory is refused rather than rewritten. Affects
      every release with a [[static_sites]] entry or [default_site].dir, v1.1.0
      included.
    • Forwarded-header trust, the three items under "Read this before upgrading".
    • A request head must arrive within 30 seconds. hyper has no default here, so a
      connection could send a request line and then trickle headers indefinitely, and a
      single host could park unlimited half-open connections on every binding. Bounds the
      head only — a slow body, a long upload and an upgraded WebSocket are unaffected.
    • Request query strings are no longer logged. The per-request span recorded the
      full URI, and both the shipped compose file and the no-RUST_LOG default ran at
      debug, so a credential carried in a URL — a presigned link, a ?token= webhook,
      an OAuth ?code= callback — reached the host log. That undercut the request log,
      which already strips authorization/cookie/x-api-key and stores the path
      without the query. The span now records the path only, so debug is safe to use
      for troubleshooting, and both defaults are info.

    Known limits

    Worth knowing before you deploy; each is ticketed rather than fixed.

    • Any API key is equivalent to the master key. A key's type is recorded but
      never consulted, so a key issued to a self-registering workload can also mint keys,
      revoke the master key, and repoint any service at another backend. Treat every key
      as a full-control credential.
    • The registration endpoints are not covered by the [[admin]] access rules.
      POST/PATCH/DELETE on /api/services and /api/l4 require the admin Host and
      a valid key, but not the via/allow_ips restriction that the read-only endpoints
      enforce. So an [[admin.access]] rule limiting the API to one listener does not
      limit the endpoints that change configuration. Both of these are
      #55.
    • Revoking a key does not end dashboard sessions created with it. X-API-Key
      auth stops immediately; an established er_session cookie survives up to its
      12-hour deadline. Restart ER to clear sessions
      (#60).
    • A static-site directory is trusted. Symlinks inside it are followed
      deliberately, so a directory an untrusted process can write symlinks into is a full
      escape from that directory
      (#65).
    • A [[static_sites]] entry cannot be restricted to a listener — it has no
      access_rules field, so it is reachable from every binding that terminates HTTP or
      HTTPS.
    • /health and /ready are behind the admin Host gate. A monitoring probe has
      to address the admin host; there is no separate health listener. With no [[admin]]
      entry they are not served at all
      (#67).

    Also in this release

    • A wildcard route host covers the names beneath it. Configuring both
      *.example.com and app.example.com previously ordered two certificates — a
      second one for a name the wildcard already certifies — and a failure on that
      redundant order refused the name at the handshake while an unconfigured subdomain
      of the same wildcard was served fine. The covered name is now dropped from the
      certificate set, which also keeps the duplicate off Let's Encrypt's weekly limit.
    • A name is refused, not substituted, while it waits for its first certificate.
      Issuance is serial and one order can take minutes, so names late in a fresh
      deployment's set previously had no certificate state at all and were served the
      self-signed fallback until the reconciler reached them.
    • A wildcard certificate is served for the names it covers. A failed order for
      app.example.com used to refuse the handshake even with a valid
      *.example.com certificate available — the refusal rule is about never serving a
      different identity, and a wildcard over the parent is not a different identity.
    • WebSocket to an https upstream works. It previously returned 501 because the
      tunnel had no TLS layer. It now uses the same trust decision as the proxy path
      and the health probe, with target_host as the SNI and verification name.
    • The image is ~46MB, down from ~179MB, now that nothing needs glibc.
    • mimalloc is the global allocator, because musl's serialises small
      allocations under thread contention and a tower of tokio tasks produces them
      constantly. This is mitigation against a documented property, not a measured
      improvement — no benchmark has been run on either allocator. It covers Rust
      allocations; bundled SQLite and ring keep musl's malloc.
    • Static sites from MinIO/S3 buckets are removed. MinIO is abandoned upstream;
      serve the bucket as a website and point a [[services]] entry at it, which also
      gains health checks, access rules, logging and timeouts.
    • Host matching no longer accepts a prefix. starts_with let
      Host: svc.example.com.attacker.example reach a service declaring
      svc.example.com. Matching is now exact or an explicit *.example.com, which
      works where it never did before. Any config relying on prefix matching loses it.
    • Honest fallbacks — a dead backend is a 503, not a 404; [default_site],
      error_pages and the X-Edge-Router-Fallback header.
    Downloads