Skip to content

chore: back-merge main into develop - #7

Merged
lorem-dev merged 7 commits into
developfrom
main
Aug 11, 2026
Merged

chore: back-merge main into develop#7
lorem-dev merged 7 commits into
developfrom
main

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated back-merge of main into develop.

Latest commit on main: 562e13e -- feat: forward the client's authority, and point redirects back at Doppel

Opened by Back-merge main -> develop, run 31376660194.

Merge this (or turn on auto-merge) to keep develop in step with
main. Resolve any conflicts here rather than on develop.

Two gaps that both follow from not relaying `Host`.

The upstream had no way to learn what the client asked for. It now gets
`X-Forwarded-Host` and `X-Forwarded-Proto`, neither overwriting a value
that arrived, so Doppel behind another proxy keeps the authority the client
really used rather than substituting an internal one -- the same treatment
`X-Forwarded-For` already had. The proto is `http`: Doppel terminates no
TLS, and `https` would name a hop that does not exist.

A redirect was relayed with the upstream's own authority in `Location`,
so a client following it left Doppel from that point on -- past every
injected fault and every mock, with nothing logged and nothing failing.
The test keeps passing and stops testing anything. nginx has
`proxy_redirect` for this and Apache `ProxyPassReverse`, both on by
default, which is why this defaults to on too.

`Location` is resolved against the URL actually requested upstream and, if
it lands under the proxy's base, returned as a root-relative path -- so
Doppel never has to guess its own public name. Measured end to end: a
target under the base becomes `/orders/7?page=2` and curl follows it back
into Doppel; one above the base becomes absolute. That second case fixes a
quieter bug, where `Location: /login` under a base of `/v2/` had the client
come back asking for `/v2/login`.

`rewrite_redirects: false` relays byte for byte, for a client under test
for its redirect handling rather than against a degraded backend.

Both pinned by mutation: making the helper a no-op, overwriting an incoming
value, ignoring the setting and never rewriting each fail a different test.
The conformance fixture carries `rewrite_redirects: false` so both stores
round-trip the new column -- dropping it on load fails the revision check.

The migrate test read `schema version 1` from a literal, so the first
migration ever added broke it; it now reads the newest version from the
embedded migrations.
The default had been flipped to `false` and the whole suite stayed green:
every test builds a `CompiledProxy` literal with the field set explicitly,
so nothing went through `compile_proxy` to notice. Two tests now compile a
configuration that says nothing about it and assert `true`, and one that
says `false` and asserts it carries through -- so the first cannot pass by
the field being hardcoded either. Verified by flipping the default back:
both fail.

The documentation pass that found it also turned up three things:

Mocks had no reference table. Every other part of the configuration has
one; `mocks[]` was documented only by example, and `request` and `proxy`
appeared nowhere as fields with types and defaults. Added, and the YAML in
it was run through `config validate` and then served -- `GET
/pricing/42/?page=3` answers `{"id": "42", "page": "3"}` with the header
the table promises.

`overview/concepts.md` still said both faults "apply before Doppel decides
what will answer the request", which 0.2.0 made false: the mock is decided
first and the proxy's loss does not touch a mocked request.

`observability.md` described `latency_injected_ms` as "which faults fired".
It is the wait actually taken, which reads 0 when the upstream already
spent longer than the target while the counter still counts the roll --
the one case where the two disagree, and worth saying so.

`main.example.yaml` gains `rewrite_redirects` next to `replace`, matching
how the example spells out other defaults rather than leaving them implied.
The configuration had no machine-readable description, so every mistake
waited for a `config validate` run. `doppel-config.schema.json` at the
repository root is generated from the same `utoipa::ToSchema` derives the
admin API's OpenAPI document uses -- no second description of the types to
keep in step -- and `doppel config schema` prints it. Editors read it
through a `yaml-language-server` modeline, which `main.example.yaml` now
carries; the release attaches it as a signed asset, so a deployment can
validate against the schema for exactly the version it runs.

Every field gained a doc comment, because the schema is read as a tooltip
and a field without one says nothing. A test enumerates them rather than
sampling. `utoipa` buries the description of an `Option<T>` inside the
`oneOf` branch beside the `$ref`; `hoist_descriptions` lifts it back to the
property, which is where anything looks.

`scripts/config_schema.py` regenerates the file and `--check` fails when it
is stale. Both CI and a Rust drift test run that check, deliberately: one
fails for whoever runs the suite, the other names the fix in a step title.
There is a `regenerate-config-schema` skill, and `pre-release-check` now
runs it.

Verified against `jsonschema`: the schema is valid 2020-12, accepts
`main.example.yaml`, and rejects `percentage: 45`, `method: get`, `port: 0`,
an unknown key, a missing `url` and a removed `server` section.

Three behaviour changes come with it.

An empty or absent `proxies` list is legal. Rule V5 refused it, which meant
a fresh deployment could not start until its proxies were written -- so the
two ways to add one, reload and the admin API, were unreachable exactly
when they were most useful. A request meanwhile is answered `503
NO_PROXIES_CONFIGURED`: the configuration is unfinished, the client did
nothing wrong, and a `404` would send whoever is debugging the client into
the client. Deleting the last proxy over the API is allowed for the same
reason.

`type: tcp` is refused while the document is parsed rather than by rule V7.
`ProxyKind` lost the variant, so the runtime, the store and the schema no
longer carry a case that could never be reached; a hand-written
`Deserialize` keeps the message saying it is unimplemented rather than
merely unaccepted.

Names no longer admit `.`, and the caps are 64 in general and 32 for a
proxy. Dropping the dot removed two more rules with it: a name is a
directory component, so `.hidden` and `..` each needed refusing, and
neither can now be written. A proxy name is capped tighter because it
travels further -- a directory, a metric label, a log field, and a header
value on every request. `Name` is generic over its cap so the two share one
character set, one trait surface and one schema that reports whichever
limit applies.

V5 and V7 retire, leaving fourteen rules.
`access` could name any token or group that existed. `admin.groups` says
which of them a configuration is allowed to reference, here and in a
proxy's overrides, and rule V36 checks it.

`["*"]` is the default and allows any; `["admin", "ci"]` allows exactly
those; `[]` allows none. `public` is never governed by it -- it is the
absence of a subject rather than a name, and a deployment locked down to
`[]` still has to be able to say "anyone may read this".

The default is permissive, which is the opposite of how `access` itself
defaults. That is deliberate: `access` defaults to `admin` because the cost
of being wrong is unauthenticated writes, and this defaults to `*` because
the cost of being wrong is an operator unable to name their own groups.

Running it caught an error in my own documentation. `groups: []` is
stronger than "no custom groups": every action defaults to the `admin`
group, so an empty list makes the defaults themselves violations and all
six actions have to be spelled `public`. The docs said "or left at its
default", which is exactly wrong. Corrected in three places -- the page, the
field's doc comment and the example -- and the page now shows the six
violations that result.

V27 and V36 read the same places, so `access_sites` walks them once and
both rules run per site. They were a loop each, which is how a rule added
later covers the admin block and quietly forgets the per-proxy overrides.

`groups` is an `Option`, skipped when absent, for the same reason
`rewrite_redirects` is: the revision is derived from the canonical YAML, so
materialising a default would have made every configuration stored before
this field existed fail its own revision check on the first load after the
upgrade. Migration 0003 adds a nullable column with no DDL default, and
says so.

Two schema fixes found by checking what it actually rejects rather than
that it exists. `AllowedGroup` had no pattern, so `groups: ["not a name!"]`
validated; it now composes one from the name rules, which are exposed as
`name::CHARACTERS` and `name::MIN` so there is one spelling of the class.
And `Name<64>` and `Name<32>` both reported as `Name`, the second silently
overwriting the first, so the schema claimed a proxy name could be 64
characters -- `ToSchema::name` now gives each cap its own component. Checked
with `jsonschema`: a 33-character proxy name is rejected and a 40-character
token name is not.
`access_of` wrapped `&config.admin.access` and was the only reader of it
left once V27 and V36 moved onto `access_sites`; a function that says less
than the field access it replaces is a name to look up for nothing.

The `Name` tests had a local `type Default = Name<MAX>`, which shadows the
trait of the same name in that module -- a reader meeting `Default::parse`
has to work out which of the two it is. Renamed to `AnyName`.

Also verified the claim the configuration reference makes about the admin
API, rather than reasoning about it: with `groups: ["admin"]`, posting a
proxy whose `access` names `user` answers

    HTTP 400 {"code": "CONFIG_INVALID", "message": "configuration is
    invalid: proxies[1].access.read: `user` is not an allowed group:
    `admin.groups` allows only `admin`"}

and the same proxy naming `admin` answers `201`.
@lorem-dev
lorem-dev merged commit b8146e7 into develop Aug 11, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant