Architecture¶
Doppel is a Cargo workspace. Dependencies point one way and converge on
doppel-core, which is what lets the configuration model be exercised without
a network, rendering without HTTP, and the proxy logic without an admin API.
The crates¶
| Crate | Owns |
|---|---|
doppel-core |
The configuration model and its types, YAML loading, validation, the ConfigStore trait and its file-backed implementation, the compiled runtime, the error model |
doppel-render |
Variable extraction and Jinja2 rendering for mock responses; knows nothing about HTTP |
doppel-proxy |
The proxy listener, resolution, fault injection, mock matching, upstream forwarding, the request pipeline |
doppel-admin |
The admin HTTP API: token access control, proxy CRUD, template files, reload, status, metrics, the OpenAPI document, and the dashboard the build script embeds |
doppel-store-postgres |
PostgresStore, and the sqlx migrations that own its schema |
doppel-telemetry |
Logging initialization and optional Sentry |
doppel-cli |
The doppel binary: argument parsing, the control channel, and wiring the rest together |
doppel-cli -> { doppel-proxy, doppel-admin, doppel-store-postgres,
doppel-telemetry, doppel-core }
doppel-proxy -> { doppel-render, doppel-core }
doppel-admin -> doppel-core
doppel-store-postgres -> doppel-core
doppel-render -> doppel-core
doppel-telemetry -> doppel-core
doppel-core -> (nothing in this workspace)
Configuration: parse, don't validate¶
A constraint on a single configuration value belongs in a type, not in a
validation rule. crates/doppel-core/src/config/ holds one module per kind of
value -- Name, Token, Port, HttpStatus, HttpMethod, Ratio,
Seconds, TimeoutSeconds, ByteSize, UpstreamUrl, HeaderName,
HeaderValue, Pattern, Selector, TemplateName -- each refusing bad input
while the document is being parsed.
The rule set in crates/doppel-core/src/validate/ is for what a type cannot
decide: anything that needs two fields at once, or the whole document.
Duplicate names, min <= max, "this field is required when that one says so",
"the two listeners must not share a port".
Nineteen of the original thirty-five rules are gone, each into a type. The retired-rules table records where each went, and three tests keep that table honest.
A type that carries something expensive to derive should carry it derived:
Pattern holds the compiled regex, UpstreamUrl the parsed URL, Selector
its segments. That is what removes the second parse rather than merely moving
the first.
The compiled runtime¶
A Config is a document. A Runtime is that document with everything the
request path needs already computed -- patterns compiled, header names folded,
response shapes resolved to one variant.
RuntimeHolder swaps one atomically. A reload builds a whole new Runtime,
validates and compiles it, and only then swaps; a failure anywhere leaves the
running one untouched. Requests in flight finish against the runtime they
started with.
Runtime::compile reads config.proxies. Other sections reached through the
same Arc<Config> -- notably admin -- are therefore live after a reload,
while the listeners those sections describe are not, because they are already
bound. unapplied_sections reports the difference.
Authorization¶
Every admin handler judges a request against the configuration the last reload put into effect, never against whatever the store holds at that moment. The store is what it reads and writes data through.
That distinction is a security property, not a style: someone who can write the
configuration out of band, but holds no token, must not be able to grant
themselves one and use it on the next request. access::policy is the one way
a handler gets the configuration it judges by.
Two stores, one trait¶
ConfigStore is the boundary. FileStore serialises writers with an advisory
lock on a sibling <config>.lock file; PostgresStore uses a conditional
UPDATE inside a transaction. Reads on the PostgreSQL side take one
REPEATABLE READ snapshot, because a configuration spans two tables and a
per-statement snapshot would assemble one from two of them.
One conformance suite runs against both, which is what keeps the trait's
promises true rather than merely intended. It lives in
doppel-core/src/conformance.rs behind the test-support feature and is the
exit criterion for any new store.
The control channel¶
A Unix socket carrying newline-delimited JSON, one request and one response per
connection. It is how config reload and token add reach a running process
without going through the admin listener -- which may be turned off, and which
needs a token the operator may be trying to create.
The socket is bound inside a private directory and published with a hard link, which is atomic and exclusive: a second bind fails loudly rather than silently taking over a live channel.
The dashboard¶
A React application in frontend/, built by vite into frontend/dist, walked at
compile time by crates/doppel-admin/build.rs and embedded with include_bytes!.
The listener serves index.html at /, the hashed assets under /static/, and
robots.txt; the operator-facing half of this is in
The dashboard.
Three decisions are worth knowing before changing any of it.
The assets are embedded, not served from disk. So npm run build alone
changes nothing about a running binary -- the binary has to be rebuilt after it.
The build script watches frontend/dist, so cargo build does re-embed; the trap
is forgetting the cargo build. A binary compiled without frontend/dist
compiles fine and answers 503 at /, which keeps
cargo install --path crates/doppel-cli working on a machine with no Node. CI
sets DOPPEL_REQUIRE_DASHBOARD_ASSETS so that concession cannot silently apply
there, and the release refuses to publish a binary that lacks the page.
The image builds either way. docker build . uses a binary staged in dist/
when there is one and compiles inside its own builder stage when there is not --
which is what lets make image work on a machine that cannot link musl. The
release stages binaries built on native runners and points the builder stage at a
base with no toolchain, so it never pulls a Rust image to run a cp. The compile
branch refuses to run without frontend/dist, because a binary built without it
answers 503 at its own root.
Settings reach the page through the HTML, not an endpoint. The listener
substitutes {title, public, version, authHeader, refreshMs} into a
application/json script element per request. Every one of those is known when
the HTML is written, and a fetch would mean the page cannot draw its own header
until a round trip finishes. The JSON has its less-than characters escaped, which
is what makes a hostile admin.title a non-event and what allows a content
security policy with no unsafe-inline -- the one inline stylesheet the page
carries, the code editor's, is named by hash instead.
The page never decides what the caller may do. GET /api/v1/access reports
that, evaluated by the same function that enforces it, so a control is disabled
because the server would have refused rather than because the page guessed.
Layers¶
frontend/src/
components/ presentational; may not import a service
pages/ one screen each: fetches, holds its own state, renders components
services/ the only code that talks to the API
store/ state that outlives one screen (zustand)
types/ the API's shapes, and the one error type
The rule that a component may not reach a service is in eslint.config.js, not
only in review: the first component to grow a fetch becomes the one nothing can
be tested without.
Tests split by what each tool is good for. jest covers services, stores and the
components that do not route; anything that navigates is covered by Playwright,
against a real browser and a real binary -- react-router ships ESM only and uses
import.meta, which cannot survive jest's transform to CommonJS. That split is
not a workaround for a build problem alone: the three bugs the suite found on its
first run were a stale rights subscription, a field name that disagreed with the
API, and a CommonJS interop failure that appeared only in the built bundle. None
were reachable from a unit test.
frontend/src/__tests__/bundle-size.test.ts holds the payload to a measured
budget, and asserts that the entry chunk does not contain prism -- a size limit
alone would let the highlighter hide in the entry's slack, and then every visitor
pays for it to list proxies.