-
v2.0.0
Stablereleased this
2026-10-26 17:40:10 +00:00 | 11 commits to main since this releaseContainer 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/dataisroot: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, everyBAO_*andTPM_*variable, and the
enrol-tpmsubcommand. 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 setsumask(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/dnsThe 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 viaHTTPREQ_USERNAME/HTTPREQ_PASSWORD,
each with a_FILEform for a mounted secret.Wildcards are now opt-in. Write a route host as
*.example.comto 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
stagingfirst. Setstaging = true, confirm
issuance works, then flip it tofalseand 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_forpreviously governed what ER believed about an incoming
request but nothing about what it sent upstream. Three changes:X-Forwarded-Foris no longer chained onto the client's own value. On a
listener markedtrust_forwarded_forER 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 sendX-Forwarded-For: 10.0.0.1and every backend that
reads the leftmost entry — Railsremote_ip, Expresstrust proxy, Django, Go
RealIP, most WAF and rate-limit middleware — would report that address.X-Forwarded-Proto: httpsfrom 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'sSECURE_PROXY_SSL_HEADERor
Railsforce_ssltreated a cleartext hop as TLS.CF-Connecting-IPand RFC 7239Forwardedare dropped from client requests
and never forwarded.CF-Connecting-IPis 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: httpsfrom 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 = trueon the listener that intermediary arrives on (the
better one — it also restores the real client IP), or setskip_https_redirect = trueon 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 needstrust_forwarded_for = true— and it
must be structurally reachable only through the trusted intermediary
(match_local_ipon its binding). Nothing warns if it is not; see
#64.A wildcard route host now covers exactly one label
*.example.commatchessub.example.comand 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_HOSTSandALLOWED_ADMIN_IPSare now startup errorsNeither was ever read by this version, yet the shipped
docker-compose.ymlset 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/healthand/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 HTTPSNames reserved by RFC 6761/6762 — anything under
.localhost,.local,.test,
.invalidor.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_redirectgets a 301 from
HTTP to HTTPS, so a.localhostroute reachable over HTTP today can become
unreachable. Either give those routes real names under a zone ER can get a
certificate for, or setskip_https_redirect = trueon them.They do not count against readiness — a development host whose routes are all
.localhoststill 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/tlsandPOST /api/tls/<id>/reneware 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/readyreports
per-name certificate state (admin API, authenticated) and/healthis a liveness
probe needing no key.[[static_sites]]and[default_site]are goingBoth 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 adirinstead of atarget_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 noaccess_rulesfield, 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::joinunnormalised, 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_LOGdefault 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 stripsauthorization/cookie/x-api-keyand stores the path
without the query. The span now records the path only, sodebugis safe to use
for troubleshooting, and both defaults areinfo.
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/DELETEon/api/servicesand/api/l4require the admin Host and
a valid key, but not thevia/allow_ipsrestriction 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 establisheder_sessioncookie 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_rulesfield, so it is reachable from every binding that terminates HTTP or
HTTPS. /healthand/readyare 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.comandapp.example.compreviously 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.comused to refuse the handshake even with a valid
*.example.comcertificate 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, withtarget_hostas 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_withlet
Host: svc.example.com.attacker.examplereach 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_pagesand theX-Edge-Router-Fallbackheader.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- First rollout consumes rate limit. Let's Encrypt allows 50 new certificates
-
v1.1.0
Stablereleased this
2026-09-28 16:31:48 +00:00 | 82 commits to main since this releaseContainer image:
forge.isohex.uk/isohex/edge-router:v1.1.0— promoted by digest from2f6e830, 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: no libc, no container runtime, no CA bundle needed on the host.
Verify with
sha256sum -c SHA256SUMS --ignore-missing.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
Source code (ZIP)
-
v1.0.1
StableAll checks were successfulBuild and Publish Docker Image (release) / build-and-push (push) Successful in 10m36sreleased this
2026-04-15 11:47:39 +00:00 | 221 commits to main since this releaseBug Fixes
-
Fix dashboard 403 on API endpoints —
allowed_ipsin[[admin]]config is now opt-in. Previously it defaulted to127.0.0.1,::1, which blocked all browser-based dashboard API calls from external IPs even with a valid API key. Now, omittingallowed_ipsallows all IPs and relies on API key authentication alone. Setallowed_ipsexplicitly to add IP restriction on top. -
Fix admin IP detection behind reverse proxies —
admin_ip_middlewarenow readsCF-Connecting-IPthenX-Real-IPbefore falling back to the raw TCP connection IP. Fixes incorrect IP matching when the router is deployed behind Cloudflare or nginx. -
CI layer caching — Kaniko build cache enabled in the develop workflow, reducing image build times.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v1.0.0
StableAll checks were successfulBuild and Publish Docker Image (release) / build-and-push (push) Successful in 10m53sreleased this
2026-04-13 12:34:24 +00:00 | 223 commits to main since this releaseInitial Release
First stable release of Edge Router — a self-contained reverse proxy written in Rust (Axum) with dynamic service registration via REST API and SQLite-backed persistence.
Highlights
- Dynamic service registration and health checking
- Admin dashboard with API key authentication
- ACME DNS-01 certificate acquisition via Cloudflare
- Per-service HTTP→HTTPS redirect control
- Static site and MinIO bucket serving
- In-place service updates via
PATCH /api/services/:name - Version and git hash embedded at build time
- Multi-stage CI pipeline with develop and tagged release workflows
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads