Skip to content

feat: bind Doppel's own variables into every template - #12

Merged
lorem-dev merged 1 commit into
developfrom
feat/system-vars
Aug 14, 2026
Merged

feat: bind Doppel's own variables into every template#12
lorem-dev merged 1 commit into
developfrom
feat/system-vars

Conversation

@lorem-dev

Copy link
Copy Markdown
Owner

Nine variables Doppel binds itself, a templated external_url, and snake_case
throughout the reference configuration.

What a template can see now

A template could see only what its own mock declared, so the things every mock
wants -- which proxy answered, which version, who called -- were extracted by hand
in each one, and two of them could not be extracted at all.

Variable Is
proxy_name The proxy that resolved. Empty when none did
mock_name The mock answering. Empty for a forwarded request
doppel_version The version serving the request
request_id The id echoed in X-Request-ID, minted when the client sent none
method, path The request line, without the query string
host The Host the client asked for
peer_ip The address the connection came from
real_ip Who the request is said to be from

real_ip is the chain a proxy in front writes: X-Real-IP, then the leftmost
X-Forwarded-For entry -- the original client rather than the hop next to us, and
across field lines, because a chain split over several is legal and some proxies
emit it -- then the connection's own address. So it always has a value and no
template needs | default('').

peer_ip beside it is deliberate: one is a claim anybody can make and the other
is a fact, and a mock that decides something needs to know which it is reading.

Reserved, and why the advisory exists

They are bound after the operator's extractions, so a mock that extracts into
proxy_name finds Doppel's value rather than its own -- the alternative is a
template whose meaning depends on which mock rendered it. The extraction still
costs a header read, so startup names the mock and what it shadowed:

mock `m1` of proxy `alpha` extracts `proxy_name` into a name Doppel binds
itself; the system value wins, so the extraction is read and thrown away

The list of names lives in doppel-core: the binding is in doppel-render and
the validation is in core, and core is the crate both can see.

A templated external_url

server:
  external_url: "http://{{ host }}/"
  external_url: "https://{{ proxy_name }}.gw.example.com/"

A value containing {{ is a template rendered per request; anything else is
parsed as a url when the configuration is read, as before. The scheme has to be
literal, because a value that does not start with one cannot become a usable url
however it renders.

{{ host }} is opt-in for a reason, and the documentation says so in a warning:
Host arrives from the client, so a deployment that builds a redirect out of it
is choosing to let a caller decide where its own redirects point. A template that
fails to render, or renders to something that is not a url, means no rewriting for
that request rather than a failed one -- improving a Location is not worth a 500.

snake_case, and one extraction removed

Every variable name in main.example.yaml and the documentation is snake_case,
matching the system ones. An operator's own names are untouched; Jinja accepts
either.

The reference configuration also stopped extracting X-Request-ID into
request_id, which is now exactly the wasted work the advisory warns about.
mock4 keeps the strict-rendering lesson on X-Trace-Id, which is genuinely the
operator's, and renders proxy_name and doppel_version beside it.

Verification

make gate clean, make e2e clean (81 tests). Seventeen unit tests, three on the
templated address, and one end-to-end through the built binary over a real socket
-- the real_ip chain needs a peer to fall back to, and only a connection has one.

Mutation-checked twice: with nothing binding the system variables the end-to-end
test fails with undefined variable proxy_name, and ignoring X-Real-IP fails the
chain test.

A template could see only what its own mock declared, so the things every mock
wants -- which proxy answered, which version answered, who called -- had to be
extracted by hand in each one, and two of them could not be extracted at all.

Nine system variables now, bound into every render: `proxy_name`, `mock_name`,
`doppel_version`, `request_id`, `method`, `path`, `host`, `peer_ip`, `real_ip`.

`real_ip` is the chain a proxy in front writes: `X-Real-IP`, then the *leftmost*
`X-Forwarded-For` entry -- the original client rather than the hop next to us,
across field lines, because a chain split over several is legal and some proxies
emit it -- then the connection's own address, so the variable always has a value
and no template needs `| default('')`. `peer_ip` is that address on its own, and
the pair is deliberate: one is a claim anybody can make and the other is a fact,
and a mock that decides something needs to know which it is reading.

They are reserved. Bound *after* the operator's extractions, so a mock that
extracts into `proxy_name` finds Doppel's value rather than its own -- the
alternative is a template whose meaning depends on which mock rendered it. The
extraction still costs a header read, so `startup_advisories` names the mock and
the names it shadowed. The list of names lives in `doppel-core`, which is the
crate both the binding and the validation can see.

`server.external_url` may now be a template over them, rendered per request:
`http://{{ host }}/` answers each client with the address it asked for, and
`https://{{ proxy_name }}.gw.example.com/` gives each proxy its own name behind a
wildcard. Opt-in for a reason -- `Host` is the caller's claim, so a deployment
that builds a redirect out of it is choosing to, and the documentation says so in
a warning rather than a footnote. A template that fails to render, or renders to
something that is not a url, means no rewriting for that request rather than a
failed one: a cosmetic improvement to a `Location` is not worth a 500.

Every variable name in `main.example.yaml` and the documentation is `snake_case`
now, matching the system ones. Jinja accepts either, and an operator's own names
are untouched -- but two conventions in one context read as two sources.

The reference configuration also stopped extracting `X-Request-ID` into
`request_id`: it is a system variable, always present and minted when the client
sends none, so the extraction was exactly the wasted work the new advisory warns
about. `mock4` keeps the strict-rendering lesson on `X-Trace-Id`, which is
genuinely the operator's, and renders `proxy_name` and `doppel_version` beside it.

Seventeen unit tests, three on the templated address, one end-to-end through the
binary over a real socket -- the `real_ip` chain needs a peer to fall back to, and
only a connection has one. Mutation-checked twice: nothing binding the system
variables fails the end-to-end test with `undefined variable proxy_name`, and
ignoring `X-Real-IP` fails the chain test.
@lorem-dev
lorem-dev merged commit 3720e11 into develop Aug 14, 2026
5 checks passed
@lorem-dev
lorem-dev deleted the feat/system-vars branch August 14, 2026 09:13
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