Unify the config model: one resource, served from an upstream or a directory #51

Open
opened 2026-10-25 15:28:42 +00:00 by james.oates · 0 comments
Owner

Two things that belong together: the config blocks have diverged into several shapes doing similar jobs, and some of their names actively mislead. Both are breaking changes, so they should land in one release rather than be dripped out.

Written up now because the reasoning was worked out while reviewing v2.0.0 and would otherwise be rediscovered from scratch.

The model, stated plainly

What ER actually does, in order:

  1. Bind a socket — ip:port, and what it may do with an accepted connection.
  2. Decide which trust zone the connection arrived in, from its real local address.
  3. Check whether the source is allowed to reach the resource it is asking for.
  4. Present a certificate, from the database or obtained by ACME with DNS-01.
  5. Match the name — SNI before a handshake, or Host plus path after one.
  6. Serve it from an upstream or from a local directory.
  7. Passthrough (tunnel bytes) or relay (terminate and re-originate).

The config does not map cleanly onto that, and that is the whole problem.

A static site is a service with a local source

[[static_sites]] was modelled as a way of responding rather than a service whose source is a directory, so it sits outside the chain that services go through. Verified:

service static site
access_rules yes (proxy.rs:374) no field exists
path matching routing_paths host only
request logging enable_logging no
health check gates routing N/A (no upstream)
timeout, upstream_ca_cert yes N/A (no upstream)

StaticSiteConfig is {host, dir, error_pages} (routes_config.rs:248), and serve_static matches by host and serves, with no access check anywhere on that path (proxy.rs:999-1013).

The consequence: whatever is in a static site's dir is served to anyone who can reach any binding that terminates HTTP or HTTPS, and there is no way to say otherwise. With one binding facing a tunnel and one facing the LAN, an internal-only directory is on both. Three of the differences above are genuinely N/A without an upstream; access rules, paths and logging are not.

[default_site] is a third shape again — a static site with no host, its own enable flag, and legacy env and [config] aliases.

Target

One resource. A [[services]] entry has either a target_host (proxy) or a dir (serve locally), and everything else — access rules, path matching, logging, error pages — applies uniformly. [default] is then "the service used when nothing matched", either naming one or carrying an inline block.

[[services]] keeps its name deliberately: it is also the REST path, the SQLite table, ServiceResponse and the dashboard, and a directory ER serves genuinely is a service it provides — the upstream is an implementation detail of one kind.

Cost

Services are DB-backed and API-registerable; static sites are TOML-only. Unifying means making static sites DB-backed — which gets them API registration and live rule changes for free — plus a migration and a rewrite of the fallback chain's plumbing. CLAUDE.md already warns that access_rules across DB-backed and TOML-only resources is two independent implementations.

Renames

Deferred to this release rather than taken in v2.0.0, because doing them earlier would split one migration into two: [default_site] would be renamed now and reshaped again here.

Now Proposed Why
[[listeners]] [[zones]] The worst of them. Both words mean "a thing that listens", and the binding is what listens. A listener is a trust zone selected by local address — AccessRule.via's own doc comment calls it "that listener's trust zone". via = "lan" with [[zones]] name = "lan" reads correctly. Widest doc surface: every via example.
[[l4_routes]] [[passthrough]] "L4" is jargon, and names a protocol layer when the observable difference is when ER decides. Reuses mode = "passthrough", already on bindings, so the relationship is self-evident. ([[tunnels]] captures connection-scope better as a noun; matching the existing word was judged worth more.)
[config] [server] A block called "config" inside a config file says nothing. These are server-level settings as against per-route ones.
[default_site] [default] Drops "site", which pre-judges it as a directory rather than a service reference.
[[bindings]] keep bind = "0.0.0.0:443" is self-explanatory and matches HAProxy's vocabulary.
[[services]], [[admin]] keep See above.

Each rename needs a hard error on the old name naming the new one — the pattern [[tls]] already uses.

Passthrough stays a separate block

Considered and rejected: mode = "passthrough" on a service. A passthrough route is decided once per TCP connection, before any handshake; a service is decided per HTTP request, after termination. One connection carries many requests that may hit different services, where a passthrough tunnel is the whole connection.

So max_connections is meaningful for one and not the other, and every HTTP-shaped field — paths, rewrite_host, forwarded headers, body limits, redirects, error pages, request logging, trust_forwarded_for — has no referent in passthrough, because ER never sees a request. Matching differs in shape too: match_sni is a single String; a service has routing_hosts and routing_paths, because SNI carries one name and no path.

The real overlap is four fields — name, target_host, target_port, access — and AccessRuleConfig is already shared. Merging would buy one less block in exchange for a block where a dozen fields are invalid in one mode and two in the other, making validation errors the user interface.

[[bindings]]
name = "https"
bind = "0.0.0.0:443"
mode = "tls"

  [[bindings.zones]]
  name = "lan"
  match_local_ip = "10.69.0.0/16"

Removes parent and the "which one listens?" question, and reads better against the dual-stack behaviour where one declared binding becomes a v4/v6 pair sharing zone names. But it touches parent resolution, the synthesised-pair logic and the catch-all default, so it is a change with its own tests rather than a rename.

Already done in v2.0.0

[acme] → [[acme]] with a name (40c87a7). Taken early because it is a table→array shape change, which cannot be aliased cleanly, and because the block is new in v2.0.0 so nothing deployed has it. See #50 for what fills the array.

Two things that belong together: the config blocks have diverged into several shapes doing similar jobs, and some of their names actively mislead. Both are breaking changes, so they should land in one release rather than be dripped out. Written up now because the reasoning was worked out while reviewing v2.0.0 and would otherwise be rediscovered from scratch. ## The model, stated plainly What ER actually does, in order: 1. **Bind** a socket — ip:port, and what it may do with an accepted connection. 2. Decide which **trust zone** the connection arrived in, from its real local address. 3. Check whether the source is **allowed** to reach the resource it is asking for. 4. Present a **certificate**, from the database or obtained by ACME with DNS-01. 5. Match the name — **SNI** before a handshake, or **Host** plus path after one. 6. Serve it from an **upstream** or from a **local directory**. 7. **Passthrough** (tunnel bytes) or **relay** (terminate and re-originate). The config does not map cleanly onto that, and that is the whole problem. ## A static site is a service with a local source `[[static_sites]]` was modelled as *a way of responding* rather than *a service whose source is a directory*, so it sits outside the chain that services go through. Verified: | | service | static site | |---|---|---| | `access_rules` | yes (`proxy.rs:374`) | **no field exists** | | path matching | `routing_paths` | **host only** | | request logging | `enable_logging` | **no** | | health check | gates routing | N/A (no upstream) | | timeout, `upstream_ca_cert` | yes | N/A (no upstream) | `StaticSiteConfig` is `{host, dir, error_pages}` (`routes_config.rs:248`), and `serve_static` matches by host and serves, with no access check anywhere on that path (`proxy.rs:999-1013`). **The consequence:** whatever is in a static site's `dir` is served to anyone who can reach any binding that terminates HTTP or HTTPS, and there is no way to say otherwise. With one binding facing a tunnel and one facing the LAN, an internal-only directory is on both. Three of the differences above are genuinely N/A without an upstream; access rules, paths and logging are not. `[default_site]` is a third shape again — a static site with no host, its own enable flag, and legacy env and `[config]` aliases. ### Target One resource. A `[[services]]` entry has either a `target_host` (proxy) or a `dir` (serve locally), and everything else — access rules, path matching, logging, error pages — applies uniformly. `[default]` is then "the service used when nothing matched", either naming one or carrying an inline block. `[[services]]` keeps its name deliberately: it is also the REST path, the SQLite table, `ServiceResponse` and the dashboard, and a directory ER serves genuinely *is* a service it provides — the upstream is an implementation detail of one kind. ### Cost Services are DB-backed and API-registerable; static sites are TOML-only. Unifying means making static sites DB-backed — which gets them API registration and live rule changes for free — plus a migration and a rewrite of the fallback chain's plumbing. CLAUDE.md already warns that `access_rules` across DB-backed and TOML-only resources is two independent implementations. ## Renames Deferred to this release rather than taken in v2.0.0, because doing them earlier would split one migration into two: `[default_site]` would be renamed now and reshaped again here. | Now | Proposed | Why | |---|---|---| | `[[listeners]]` | `[[zones]]` | The worst of them. Both words mean "a thing that listens", and the *binding* is what listens. A listener is a trust zone selected by local address — `AccessRule.via`'s own doc comment calls it "that listener's trust zone". `via = "lan"` with `[[zones]] name = "lan"` reads correctly. Widest doc surface: every `via` example. | | `[[l4_routes]]` | `[[passthrough]]` | "L4" is jargon, and names a protocol layer when the observable difference is *when ER decides*. Reuses `mode = "passthrough"`, already on bindings, so the relationship is self-evident. (`[[tunnels]]` captures connection-scope better as a noun; matching the existing word was judged worth more.) | | `[config]` | `[server]` | A block called "config" inside a config file says nothing. These are server-level settings as against per-route ones. | | `[default_site]` | `[default]` | Drops "site", which pre-judges it as a directory rather than a service reference. | | `[[bindings]]` | keep | `bind = "0.0.0.0:443"` is self-explanatory and matches HAProxy's vocabulary. | | `[[services]]`, `[[admin]]` | keep | See above. | Each rename needs a hard error on the old name naming the new one — the pattern `[[tls]]` already uses. ## Passthrough stays a separate block Considered and rejected: `mode = "passthrough"` on a service. A passthrough route is decided **once per TCP connection, before any handshake**; a service is decided **per HTTP request, after termination**. One connection carries many requests that may hit different services, where a passthrough tunnel is the whole connection. So `max_connections` is meaningful for one and not the other, and every HTTP-shaped field — paths, `rewrite_host`, forwarded headers, body limits, redirects, error pages, request logging, `trust_forwarded_for` — has no referent in passthrough, because ER never sees a request. Matching differs in shape too: `match_sni` is a single `String`; a service has `routing_hosts` *and* `routing_paths`, because SNI carries one name and no path. The real overlap is four fields — `name`, `target_host`, `target_port`, `access` — and `AccessRuleConfig` is already shared. Merging would buy one less block in exchange for a block where a dozen fields are invalid in one mode and two in the other, making validation errors the user interface. ## Candidate, not recommended: nest zones under bindings ```toml [[bindings]] name = "https" bind = "0.0.0.0:443" mode = "tls" [[bindings.zones]] name = "lan" match_local_ip = "10.69.0.0/16" ``` Removes `parent` and the "which one listens?" question, and reads better against the dual-stack behaviour where one declared binding becomes a v4/v6 pair sharing zone names. But it touches parent resolution, the synthesised-pair logic and the catch-all default, so it is a change with its own tests rather than a rename. ## Already done in v2.0.0 `[acme]` → `[[acme]]` with a `name` (`40c87a7`). Taken early because it is a table→array *shape* change, which cannot be aliased cleanly, and because the block is new in v2.0.0 so nothing deployed has it. See #50 for what fills the array.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
IsoHex/edge-router#51
No description provided.