Unify the config model: one resource, served from an upstream or a directory #51
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
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:access_rulesproxy.rs:374)routing_pathsenable_loggingupstream_ca_certStaticSiteConfigis{host, dir, error_pages}(routes_config.rs:248), andserve_staticmatches 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
diris 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 atarget_host(proxy) or adir(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,ServiceResponseand 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_rulesacross 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.[[listeners]][[zones]]AccessRule.via's own doc comment calls it "that listener's trust zone".via = "lan"with[[zones]] name = "lan"reads correctly. Widest doc surface: everyviaexample.[[l4_routes]][[passthrough]]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][default_site][default][[bindings]]bind = "0.0.0.0:443"is self-explanatory and matches HAProxy's vocabulary.[[services]],[[admin]]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_connectionsis 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_sniis a singleString; a service hasrouting_hostsandrouting_paths, because SNI carries one name and no path.The real overlap is four fields —
name,target_host,target_port,access— andAccessRuleConfigis 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
Removes
parentand 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 aname(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.