Configuration reference¶
One YAML document. Unknown keys are an error, not a silently ignored setting -- a mistyped field name fails at load rather than doing nothing at runtime.
main.example.yaml in the repository is this reference made concrete, and is
asserted against by the test suite.
Editor support¶
The configuration has a JSON Schema, so an editor can complete field names, show
what each field is for and mark a bad value as you type -- before Doppel is run
at all. Put this line at the top of your main.yaml:
# yaml-language-server: $schema=https://raw.githubusercontent.com/lorem-dev/doppel/main/doppel-config.schema.json
VS Code's YAML extension reads it, as does any other yaml-language-server
client. main.example.yaml already carries it.
That URL follows main. Every release also attaches the schema as an asset, so
a deployment that pins a version can validate against the schema for exactly
that version rather than for whatever is current.
A running Doppel serves its own:
curl -s http://127.0.0.1:8081/api/v1/schema | jq .
No token, because it describes the shape of a configuration rather than the contents of one. This is the copy to check a document against before pushing it, since it comes from the process that is going to read it -- the dashboard uses it for exactly that, both while a field is being typed and in its YAML editor.
The schema is generated from the same Rust types this page documents -- see
doppel config schema -- so it cannot describe a field
that does not exist, and CI fails if the checked-in copy falls behind.
What it catches: an unknown key, percentage: 45 where a fraction was meant,
method: get in lower case, a port of 0, a missing url. What it cannot
catch: anything needing more than one field, such as min <= max. Those are the
validation rules below, and they run when the configuration is
loaded.
Top level¶
| Key | Required | Purpose |
|---|---|---|
server |
yes | Where the proxy listens |
admin |
yes | Admin API settings |
proxies |
no | Empty or absent is legal; requests then get 503 |
logging |
no | Level and format; defaults to info and json |
control |
no | Control socket path; defaults to /tmp/doppel.sock |
templates |
no | Template directory; defaults to ./templates |
sentry |
no | Absent or an empty DSN disables it |
server¶
server:
host: "0.0.0.0"
port: 8080
| Key | Type | Default | Notes |
|---|---|---|---|
host |
IP address | required | Must parse as an IP, not a hostname |
port |
1..65535 | required | Must differ from admin.port |
external_url |
absolute URL, or a template | host:port |
Where clients reach Doppel, for rewriting a redirect or a body. May be a template over the system variables, e.g. http://{{ host }}/. DOPPEL_EXTERNAL_URL overrides it. See Doppel's own address |
Worker threads are not configured here. They size the tokio runtime, and a
database-backed store cannot be opened before that runtime exists -- so the
value has to be known before the configuration is read, which puts it on the
same side of the boundary as the connection settings. Use --workers or
DOPPEL_WORKERS; see the CLI reference. A document still
carrying server.workers is rejected as an unknown field rather than quietly
ignored.
logging¶
logging:
level: info
format: json
level is one of trace, debug, info, warn, error; format is json
or text. RUST_LOG overrides level when set and non-empty.
control¶
control:
socket: /tmp/doppel.sock
The Unix socket doppel config reload talks to. Created with mode 0600 and
removed on shutdown. Its parent directory must exist -- that is checked when
serve starts, not by config validate, which stays independent of the
machine it runs on.
templates¶
templates:
dir: ./templates
Template files live at <dir>/<proxy-name>/<file>. Created at startup if
absent.
sentry¶
sentry:
dsn: "https://key@sentry.example.com/1"
Optional. An absent section or an empty DSN disables it.
DOPPEL_SENTRY_DSN provides the DSN, or replaces the one written here -- a DSN is
a credential, and the environment is where a deployment usually keeps one. An
empty variable counts as unset and leaves this field in force. See
Sentry.
admin¶
The admin listener's address, its tokens, and who may do what.
admin:
enable: true
dashboard: true
title: "Doppel"
host: "0.0.0.0"
port: 8081
public: false
groups: ["*"]
auth:
header: X-Proxy-Authorization
tokens:
- name: user1
group: admin
token: c0a721e2-90ff-40f0-a230-c1ab83d751d8
access:
list: ["admin", "user"]
read: ["admin", "user"]
create: ["admin"]
update: user1
delete: admin
upload: admin
upload:
limit: 1Mi
enable defaults to true. Set it to false to run the proxy with no admin
application at all: the port is never bound, so it cannot collide with
anything, and /api/v1/status, /metrics, /openapi.json and the whole API are
gone with it. The proxy listener and the control socket are untouched, which
makes doppel config reload the only remaining way in.
The validation rules do not consult it. A configuration that is only safe because nothing serves it is a trap set for whoever turns the listener on later, and they will not re-read the rules first -- so rule V34 still refuses a public write action with the listener off.
dashboard defaults to true and serves the browser dashboard from this
listener's root: /, /static/{path} and /robots.txt. false leaves those
three unrouted -- they answer 404 like any other unknown path -- and changes
nothing about the JSON API. title is the heading that dashboard shows and the
browser tab's name, at most 64 characters and free of control characters,
defaulting to Doppel. Both take effect on restart, since the routes are built
once. See The dashboard.
Toggling it takes effect on restart, not on reload; a reload reports admin
among the sections it could not apply.
auth.header defaults to X-Proxy-Authorization and expects Bearer {token}.
Each entry in tokens has a unique name and a unique token. group is
free-form; admin and user are predefined, and any other name must be
carried by at least one token -- referencing a group nobody belongs to locks the
action out entirely, which is far more often a typo than an intent.
access maps each action to public, a single name, or a list of names. An
empty list means public. Names are token names or group names.
public: serve the admin API unauthenticated¶
admin:
public: true
Every action answers as public, for anyone, with no token. false by default.
This overrides rule V34, which otherwise refuses a public write action. V34 is
there so an unauthenticated writable proxy set cannot happen by omission; a
field called public set to true is not an omission.
Anything access, groups or a proxy's overrides still say is ignored, and
startup says so rather than refusing the document -- so a configuration can be
made public temporarily without being gutted first:
admin.public is true: the whole admin API is served unauthenticated, including
the actions that rewrite the proxy set
admin.access, admin.groups are ignored while the admin API is public; every
action answers as `public` regardless
groups: which names access may reference¶
admin:
groups: ["*"] # the default
Bounds the vocabulary access may draw on, here and in a proxy's overrides.
Rule V36.
groups |
access may name |
|---|---|
absent, or ["*"] |
anything. The default |
["admin", "ci"] |
admin, ci, and nothing else -- user is refused |
[] |
nobody, which is the same as public: true. See below |
public and admin are always available whatever the list says. public is the
absence of a subject rather than a name; admin is the fallback every action
already has, and a list that revoked it would leave the four write actions with
no legal value at all.
groups: [] names nobody, so nothing can be granted to anyone -- which leaves
all-public as the only reading of it that describes a configuration that runs. It
is treated as public: true, and startup says which of the two you wrote.
The violation names everything permitted, because the reader has to choose between changing the reference and widening the list:
admin.access.read: `user` is not an allowed group; `admin.access` may name only `admin`, `ci`, `public`
Two things groups does not do. It does not create groups -- a name still has to
be predefined or carried by a token, which is rule V27, reported separately
because widening the list and adding a token are different fixes. And it is not
authorisation: it bounds what a configuration may say, and a caller's rights
still come from access.
POST and PUT /api/v1/proxies refuse a document whose access names something
outside the list, with 400 and CONFIG_INVALID.
Every action defaults to the admin group, reads included. The most common
configuration is the one nobody wrote, so the default has to be the safe one.
Reads are not exempt: a proxy document carries the headers that proxy
injects upstream, and url may itself contain user:password@. Listing
proxies therefore publishes upstream credentials, which is no lesser harm
than rewriting the proxy set. Setting list or read to public is
allowed -- a configuration with no secrets in it may reasonably do so -- but
it has to be a choice, not what happens when the section is left out.
Setting a write action to public is refused outright (rule V34); no
configuration wants an unauthenticated caller rewriting the proxy set.
GET /api/v1/status stays unauthenticated regardless: it reports names, upstreams
and counts, and strips any credentials from the upstream before printing
it.
upload.limit is a byte count. It must be greater than zero.
Binary and decimal units are both accepted and mean different things:
Ki/Mi/Gi (equivalently KiB/MiB/GiB) are powers of 1024, and
kB/MB/GB are powers of 1000. A plain number is bytes. Case is not
significant on input.
A bare K, M or G is refused. It meant the binary unit in earlier
versions, which contradicts SI, and quietly reinterpreting it as decimal would
have shrunk every configured buffer by a few percent with nothing to show for
it. The error names both replacements, so the fix is a one-character edit and
the choice is the operator's.
proxies¶
Names must be unique. The list may be empty or left out -- see No proxies configured.
proxies:
- name: proxy1
type: http
url: "https://external-service.com/api/v1/"
timeout: 60
body_limit: 1Mi
resolve:
type: default
access:
read: ["user1", "user2"]
headers:
Authorization: "Bearer 1234567890"
loss:
percentage: 0.1
status: 503
latency:
percentage: 0.45
min: 0.05
max: 0.2
replace: 1.0
mocks: []
| Key | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Unique; also used as the template subdirectory |
type |
http |
required | The only value. tcp is refused while parsing, with a message saying it is not implemented |
url |
absolute URL | required | http or https, no query or fragment |
timeout |
seconds > 0 | 30 | Bounds the whole upstream exchange |
body_limit |
byte size > 0 | 1 MiB | Only used when a matched mock extracts from the body |
resolve |
see below | {type: default} |
|
access |
overrides | none | Only read, update, delete, upload |
headers |
map | none | Injected upstream, overriding the client's |
loss |
see below | none | |
latency |
see below | none | |
replace |
0.0..1.0 | 1.0 | Probability a matching mock actually answers |
rewrite_redirects |
boolean | true |
Point a redirect back at Doppel when its target is on the upstream's host. See Redirects |
rewrite_urls |
boolean | true |
Replace the upstream's own address with Doppel's in the text bodies it relays. Exact host only. See Urls in a body |
mocks |
list | none | See Mocks and templating |
resolve¶
resolve:
type: header
header: X-Proxy-Name
type is default or header. At most one proxy may be the default; zero is
legal and means every request must resolve by header. A header resolver must
name a valid header.
loss and latency¶
loss:
percentage: 0.1
status: 503
latency:
percentage: 0.45
min: 0.05
max: 0.2
Percentages are fractions in 0.0..1.0; 0.0 never fires and 1.0 always
does. status is a real HTTP status. min and max are seconds, both
non-negative, with min <= max.
body_limit¶
A mock that extracts variables from the request body has to buffer it, which
the proxy otherwise avoids -- bodies stream through. This bounds that buffer.
Exceeding it is 413. See Mocks and templating.
mocks[]¶
mocks:
- name: pricing
request:
method: GET
url: "^/pricing/(?P<id>[0-9]+)/$"
headers:
who: X-User
query:
page: .page
body:
items: .content.items
response:
status: 200
json: '{"id": "{{ id }}", "page": "{{ page }}"}'
headers:
X-Served-By: "mock {{ id }}"
proxy:
replace: 0.5
| Key | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Unique within the proxy |
request |
see below | required | What the mock matches |
response |
see below | required | What it answers |
proxy |
see below | none | Per-mock overrides |
request:
| Key | Type | Default | Notes |
|---|---|---|---|
method |
upper-case method | required | Matched exactly; get is rejected at load |
url |
regex | required | Matched against the path, unanchored. Named groups become variables |
headers |
variable → header name | none | |
query |
variable → selector | none | |
body |
variable → selector | none | Buying the buffer bounded by body_limit |
response -- exactly one of body, json or template:
| Key | Type | Default | Notes |
|---|---|---|---|
status |
100..599 | required | |
body |
template | none | Sent as text/plain |
json |
template | none | Sent as application/json; must render to valid JSON |
template |
file name | none | A file under this proxy's template directory |
headers |
header name → template | none | The value is a template, rendered per request |
proxy accepts replace, loss and latency, with the same types and bounds
as on the proxy. What is inherited and what is not is in
Injecting faults.
Every variable a template names has to be bound, or the render fails with
500 -- see Mocks and templating.
Validation¶
The rule set runs identically at startup, on reload, and under
doppel config validate. Every violation is reported together with the others,
each carrying the configuration path that produced it:
proxies[0].latency.min: min must be <= max
proxies[1].resolve.header: `header` is required when `type: header`
Rules carry stable V<n> numbers so a message can be looked up, and a number
is never reused once retired. Several things a rule would otherwise check are
instead enforced by the types: a host that is not an IP, an unknown log level
or format, a name (below), and a proxy access block naming an action it may
not override all fail while the document is being parsed. Those stop at the
first error, because parsing does; everything the rule set checks is collected
and reported together.
V34 is worth naming because it refuses a configuration earlier versions
accepted: admin.access may not grant create, update, delete or
upload to public. No configuration wants an unauthenticated caller
rewriting the proxy set.
Retired rules¶
Most of the original rule set is gone, not because the checks were dropped but because they moved into the types and now run while the document is being parsed. A message quoted in an old issue can be looked up here.
| Rule | Was | Now |
|---|---|---|
| V2 | server.host is an IP |
IpAddr |
| V3 | server.workers is positive |
the field is --workers |
| V4 | log level and format are known | LogLevel, LogFormat |
| V5 | at least one proxy is configured | nothing -- an empty list is legal, see No proxies configured |
| V7 | type: tcp is refused |
ProxyKind, while the document is parsed |
| V8, V32 | upstream url is absolute http(s), no query | UpstreamUrl |
| V9 | timeout is positive | TimeoutSeconds |
| V12, V13 | probability in 0..=1, status in 100..=599 | Ratio, HttpStatus |
| V14 (part) | latency bounds are non-negative | Seconds |
| V15, V24 | header names and values are well formed | HeaderName, HeaderValue |
| V17 | method is known and upper case | HttpMethod |
| V18 | mock url pattern compiles | Pattern |
| V22 | response status in 100..=599 | HttpStatus |
| V23 | selector is well formed | Selector |
| V28 | proxy access overrides a permitted action |
ProxyAccessConfig |
| V29, V33 | size limits are positive | ByteSize |
| V31 | template file name is safe | TemplateName |
| V35 | proxy name is a usable directory name | Name |
A retired number is never reused.
Fifteen rules remain: V1, V6, V10, V11, V14, V16, V19, V20, V21, V25, V26, V27,
V30, V34 and V36. Each needs more than one field to decide, which is exactly why
none of them could become a type -- V36, the newest, compares access against
admin.groups.
Names¶
A proxy name, a mock name, a token name and a group name follow one rule:
letters, digits, - and _, between 2 and 64 characters. A proxy name is
capped at 32 instead.
. is not allowed. It was until 0.3.0, and the reference configuration taught
names like Billing.API.v2; write Billing-API-v2. Dropping it removed two
further rules with it -- a name becomes a directory component, so .hidden and
.. each had to be refused separately, and neither can now be written at all.
A proxy name is capped shorter because it travels further than any other: a
directory under templates.dir, a proxy label on every metric, a field in
every log line, and the value a client puts in a resolution header on every
request.
The rule is enforced by the type, while the document is being parsed, rather than by a validation rule afterwards. A name becomes a directory component, a metric label, a log field and part of a URL, so the moment it comes into existence is the only place worth checking it -- and there is then no later moment at which an unchecked name exists. There used to be a rule V35 doing this; it is gone, because a type that admits a bad value and a rule that catches it later are two things to keep in step.
Template file names¶
A mock's response.template names one file under
<templates.dir>/<proxy>/: one path component, no separators, no leading dot,
no .., no control characters, at most 200 bytes.
That is the same rule the admin API applies to an uploaded file name, and it is now literally the same code. Rule V31 used to restate it for the configuration side; a configuration and an upload could have drifted apart about what a file name is, and only one of them would have been checked.
Names are refused rather than normalised. Rewriting a path an operator asked for is worse than refusing it: they then believe a file landed somewhere it did not.
Mock patterns and selectors¶
A mock's request.url is a regular expression, matched unanchored against the
request path. Its named capture groups become template variables. It is
compiled when the document is read and kept compiled, so the request path
never compiles it again.
A query or body selector is a leading dot followed by dot-separated field
names: .filter, .content.items. It addresses object keys only; a segment
reaching an array yields the array, and a missing key binds nothing rather
than failing the request.
Rules V18 and V23 did these checks. Both are types now, and both
removed a second copy of the same work: Runtime::compile used to recompile
every pattern, and the render path used to re-parse every selector on every
request -- each with its own failure branch for input a loaded configuration
could not contain.
Headers¶
Every field naming an HTTP header -- admin.auth.header, a proxy's
resolve.header, the keys of proxies[*].headers, the values of a mock's
request.headers, and the keys of a mock's response.headers -- is a header
name: a non-empty RFC 9110 token. Case is kept as written; matching against an
incoming request folds case at that point instead.
The values of proxies[*].headers are header values: visible ASCII, plus
space and tab. A line break is refused with that named, because one would end
the header early and let the rest be read as headers of its own.
Rules V15 and V24 did part of this. Between them they left a gap: a
mock's response header names were checked by neither, so X Id: was a
configuration that loaded, validated, and then produced a header no client
could parse. The types close it, because they are the same types everywhere.
What remains of V11 is the part that needs two fields: resolve.header is
required when resolve.type is header.
Upstream URLs¶
A proxy's url is an absolute http or https URL with no query string and
no fragment. Rules V8 and V32 did those checks; the type does them
now, and keeps the URL parsed rather than as text, so the forwarding path
never parses it again.
The query and fragment are refused rather than ignored because the forwarding path replaces the query wholesale with the incoming request's. One configured here would be discarded on every request, silently, which is worse than being told at startup.
The stored form is normalised: https://example.com is written back as
https://example.com/. That is what makes two spellings of the same upstream
produce the same revision.
A URL may carry user:password@. It is not refused -- some upstreams want it
-- but it is logged once at startup, because that credential is part of the
proxy document GET /api/v1/proxies returns.
Sizes¶
admin.upload.limit and a proxy's body_limit are byte counts, from 1 byte
to 1 GiB. Both bound something Doppel holds in memory, so a number past that
is not a larger limit but the absence of one.
Write a plain integer, or a suffix: Ki, Mi, Gi are binary (1024-based)
and kB, MB, GB are decimal (1000-based). A bare K, M or G is
refused rather than guessed at -- it meant the binary unit in this project's
own past, which contradicts SI, and reinterpreting it silently would resize
every configured buffer without a word. The message names both replacements.
A limit of 0 is refused: it rejects everything, which shows up as a confusing 413 on every request rather than as the configuration mistake it is. Rules V29 and V33 said that once per field and are gone.
Whatever spelling is used, the value is written back as a plain integer, so
1Mi and 1048576 produce the same revision.
Numbers with units¶
| Field | Type | Range |
|---|---|---|
loss.percentage, latency.percentage, replace |
probability | 0.0 to 1.0 |
latency.min, latency.max |
seconds | 0 to 300 |
timeout |
whole seconds | 1 to 3600 |
The probabilities are fractions despite the field name: 50% is 0.5. Writing
50 is refused, and the message says so, because the old rule reported it as
"out of range" and left the reader to work out which range.
The upper bounds are sanity bounds, not protocol ones. A latency past five
minutes outlives every client that would wait for it, and a timeout past an
hour is a value written in milliseconds far more often than it is an intent --
timeout: 30000 is refused with that named.
A timeout of 0 is refused rather than read as "no timeout": leave timeout
out to get the default.
Rules V9, V12, V13 and the sign half of V14 did these checks
and are gone. What is left of V14 is the ordering: min must not exceed
max, which needs both fields and so cannot be a type.
Methods and statuses¶
A mock's request.method is one of the methods Doppel knows, spelled in upper
case. The list is a typo guard, not a protocol restriction: FETCH is refused
because it is far more often a mistake than an intent, and a genuinely
non-standard method needs adding to the list. The value is not upper-cased for
you -- HTTP methods are case-sensitive, so a stored get would never match an
incoming GET, and a document writing it is told to write GET instead.
A status -- response.status, and loss.status -- is a number from 100 to
599. Rules V17 and V22 did these checks and are gone; the types do
them now, at parse time.
Rule V30 still applies: 204 and 304 forbid a body, so a mock declaring one alongside either status is refused. That compares the status against what the response declares, which is two fields.
Ports¶
server.port and admin.port are numbers from 1 to 65535. Port 0 is refused
by the type, with a message saying why: to the operating system it means "any
free port", so a configuration naming it describes a server whose address
nobody can predict -- including whoever wrote the line.
Rule V1 is what remains: the two listeners must not share a port. That compares two fields, so no single value can be checked for it.
A port below 1024 is accepted and logged once at startup, because binding one
usually needs elevated privilege. It is not an error -- running on port 80
behind a capability or a redirect is a real deployment -- but the far more
common cause is a typo, and the failure that produces otherwise is a bare
Permission denied from bind.
Tokens from the environment¶
DOPPEL_ADMIN_TOKENS supplies admin tokens without writing them into the
configuration document. It is a JSON object keyed by token name:
{
"ci": {"token": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "group": "admin"},
"readonly": {"token": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"}
}
group is optional and defaults to user, matching doppel token add, and
for the same reason: a variable set by provisioning tooling rather than read
back by a person should not grant administration because a field was left out.
Every field is held to exactly the rules the document is held to -- the name and group are names, the value is a token -- and a malformed variable fails startup. It is not logged and skipped: an operator who provisioned a token and saw no error believes they have access, and finding out otherwise happens at the worst possible moment. An unset or empty variable is simply no tokens, since deployment tooling routinely renders an empty string for an absent secret.
These tokens are checked before the configured ones. A name given in both resolves to the environment's group, and the configured token of that name stops authenticating entirely -- two live secrets for one identity, one of which nobody remembers issuing, is worse than a replacement. A warning at startup names each configured token that is shadowed.
doppel token add refuses a name the environment claims, because the token it
would generate and store could never authenticate.
They are deliberately not merged into the loaded configuration. The
revision is derived from the configuration's content, so folding the
environment in would make two instances reading one stored document compute
two different revisions, and every compare-and-swap between them would fail
over a difference neither had written. One consequence to know: an access list
naming a token that exists only in the environment fails rule V27, because
validation is pure and cannot see it. Name a group instead -- admin and
user always exist.
Access lists¶
admin.access and a proxy's access name subjects: public, one token or
group name, or a list of them. An empty list means public.
The names are names, checked by the same type that checks a token's own name. An access list can no longer reference something no token could ever be called -- rule V27 would have reported that as an unknown subject, which is true and unhelpful.
public is a keyword in this position and is never parsed as a name, even
though it happens to be spelled like a legal one.
Tokens¶
An admin token is printable ASCII with no spaces, 32 to 255 characters. The character set is the one an HTTP header value admits, because that is where the token is read from -- a token containing a space could be written into a configuration and would then never match anything a client could send.
There is no required form. A version 4 UUID is the recommended shape and what Doppel generates, but a token pasted out of a secret manager does not have to be reformatted to be accepted.
Rule V26 still applies on top of the type: token names must be unique, and so must token values. Uniqueness is a property of the set rather than of one token, which is why it stays a rule.
A token is redacted in log lines and in debug output. It is written out in
full by config pull and stored in full, since those are the places whose
whole purpose is to reproduce the configuration.
For the full list of rules and their identifiers, see the design specifications in the repository.