Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ release promotes it to a version heading; the `bump-version` skill does that.

## Development

## 1.1.0 -- 2026-08-14

### Added

- Nine system variables in every mock template: `proxy_name`, `mock_name`,
`doppel_version`, `request_id`, `method`, `path`, `host`, `peer_ip`, `real_ip`.
- `real_ip` reads `X-Real-IP`, then the leftmost `X-Forwarded-For`, then the
connection's own address. `peer_ip` is always the connection's.
- `server.external_url` may be a template over those variables, rendered per
request: `http://{{ host }}/` answers each client with its own address.
- `DOPPEL_SENTRY_DSN` provides the Sentry DSN, or overrides `sentry.dsn`: a DSN is
a credential, and the environment is where a deployment keeps one.

### Changed

- Variable names in `main.example.yaml` and the documentation are `snake_case`.
Your own names are unaffected; Jinja accepts either.
- A mock that extracts into a system variable's name is named at startup: the
system value wins, so the extraction is read and thrown away.

## 1.0.0 -- 2026-08-13

### Added
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "3"

[workspace.package]
version = "1.0.0"
version = "1.1.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
Expand Down
18 changes: 15 additions & 3 deletions DOCKERHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,17 +178,29 @@ docker run --rm -p 58080:8080 -p 58081:8081 \
Without it a rewritten redirect names port 8080, which is not published, and the
client follows it nowhere. Doppel logs the address it settled on at startup.

## Tokens
## Credentials

`DOPPEL_ADMIN_TOKENS` keeps secrets out of the configuration file that gets
mounted in:
Two of them, and neither has to go into the configuration file that gets mounted
in:

```bash
docker run --rm \
-e DOPPEL_ADMIN_TOKENS='{"ci":{"token":"...","group":"admin"}}' \
-e DOPPEL_SENTRY_DSN='https://key@sentry.example.com/1' \
...
```

Both override what the document says. `DOPPEL_SENTRY_DSN` also means a
`sentry` section is not needed at all -- and an empty value counts as unset, so a
variable that failed to interpolate leaves a configured DSN in force rather than
silently turning error reporting off.

Sentry reporting needs a build with the `sentry` cargo feature, and neither the
published image nor the released binaries carry it -- it is off by default so a
default build does not ship a TLS stack and an HTTP client it never uses. A DSN
given to a build without it warns at startup and is not reported; nothing else
changes. Build your own with `--features sentry` if you need it.

## Reloading

`doppel config reload` talks to a control socket inside the container. Two ways
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ full list.
upstream's redirects. Needed behind a port mapping or an ingress; otherwise
`server.host:server.port` is used.

`DOPPEL_ADMIN_TOKENS` and `DOPPEL_SENTRY_DSN` keep the two credentials out of the
configuration file. Both override what the document says.

## A minimal configuration

```yaml
Expand Down
15 changes: 11 additions & 4 deletions crates/doppel-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,16 @@ pub async fn serve(store: Arc<dyn ConfigStore>, config: Config) -> Result<(), Cl
//
// Bound to a name, not `_`: the guard flushes and stops reporting when
// dropped, and `let _ = ...` would drop it here.
let _sentry = doppel_telemetry::sentry::init(config.sentry.as_ref())
.map_err(|err| CliError::Failed(err.to_string()))?;
//
// `DOPPEL_SENTRY_DSN` is read here rather than merged into the document, for
// the reason the admin tokens are not: the revision is a hash of the
// configuration's content, and folding the environment into it would make two
// instances reading one stored document disagree about the revision.
let _sentry = doppel_telemetry::sentry::init(
config.sentry.as_ref(),
doppel_core::config::sentry_dsn_from_env().as_deref(),
)
.map_err(|err| CliError::Failed(err.to_string()))?;

// Checked before anything binds, and a bad value fails startup. A
// malformed token variable that was logged and skipped would leave an
Expand Down Expand Up @@ -262,8 +270,7 @@ pub async fn serve(store: Arc<dyn ConfigStore>, config: Config) -> Result<(), Cl
});

let proxy_task = tokio::spawn(serve_proxy(
ProxyState::new(Arc::clone(&holder))
.with_external_url(external_url.map(doppel_core::config::ExternalUrl::into_url)),
ProxyState::new(Arc::clone(&holder)).with_external_url(external_url),
listener,
async {
let _ = proxy_rx.await;
Expand Down
68 changes: 68 additions & 0 deletions crates/doppel-cli/tests/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,71 @@ fn a_dsn_without_the_feature_is_reported_rather_than_ignored() {
);
assert!(!both.contains("s3cr3tsentrykey"), "{both}");
}

/// The DSN from the environment, through the built binary.
///
/// The unit tests decide precedence; this is the half only a process can answer:
/// that the variable is read at all, that the startup line says which source won,
/// and that the key does not travel any further from the environment than it does
/// from the document.
#[test]
fn a_dsn_from_the_environment_is_read_and_its_source_named() {
let up = upstream();
// No `sentry` section in the document at all -- the case a deployment that
// provisions credentials through the environment actually has.
let server = Server::start_with_env(up.port, config, &[("DOPPEL_SENTRY_DSN", SENTRY_DSN)]);
server.get("/anything");

send_sigterm(server.pid());
let (_status, stdout, stderr) =
wait_after_signal(server.into_child(), "SIGTERM", SIGNAL_WAIT_DEADLINE);

let both = format!("{stdout}{stderr}");
assert!(
!both.contains("s3cr3tsentrykey"),
"the key leaked into the logs: {both}"
);
// Named the way an operator would grep for it, whether or not this build can
// report: with the feature the line says reporting is enabled, without it that
// nothing will be reported, and both carry the source.
assert!(
both.contains("DOPPEL_SENTRY_DSN"),
"startup must say which source the dsn came from: {both}"
);
assert!(
both.contains("sentry.invalid"),
"the host survives redaction, which is what makes the line useful: {both}"
);
}

/// An empty variable leaves the document's DSN alone.
///
/// The direction matters: `DOPPEL_SENTRY_DSN=${SENTRY_DSN}` with nothing behind
/// `SENTRY_DSN` is a compose file that means nothing by it, and silently turning
/// error reporting off is the worse reading of that.
#[test]
fn an_empty_variable_does_not_disable_a_configured_dsn() {
let up = upstream();
let server = Server::start_with_env(
up.port,
|ports, socket, templates| {
config(ports, socket, templates).replace(
"proxies:",
&format!("sentry:\n dsn: \"{SENTRY_DSN}\"\nproxies:"),
)
},
&[("DOPPEL_SENTRY_DSN", "")],
);
server.get("/anything");

send_sigterm(server.pid());
let (_status, stdout, stderr) =
wait_after_signal(server.into_child(), "SIGTERM", SIGNAL_WAIT_DEADLINE);

let both = format!("{stdout}{stderr}");
assert!(
both.contains("sentry.dsn"),
"the document's dsn must still be the one in force: {both}"
);
assert!(!both.contains("s3cr3tsentrykey"), "{both}");
}
41 changes: 33 additions & 8 deletions crates/doppel-cli/tests/mocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ fn mock2_renders_from_the_request_body_and_query() {
assert_eq!(parsed["description"], "first");
assert_eq!(
parsed["items"], 3,
"`resourceItems | length` must count the array"
"`resource_items | length` must count the array"
);
}

Expand All @@ -112,13 +112,13 @@ fn the_literal_42_path_is_mocked_rather_than_proxied() {
#[test]
fn mock4_binds_a_path_capture_into_the_body_and_headers() {
let (_up, server) = server();
// `mock4` extracts `requestId` from `X-Request-ID` and renders it into a
// response header. Rendering is strict, so that request header is not
// optional -- the reference config says as much where the mock is defined.
// `mock4` extracts `trace_id` from `X-Trace-Id` and renders it into a response
// header. Rendering is strict, so that request header is not optional -- the
// reference config says as much where the mock is defined.
let url = format!("http://127.0.0.1:{}/api/v1/resource/7/", server.port());
let response = reqwest::blocking::Client::new()
.get(url)
.header("x-request-id", "abc-123")
.header("x-trace-id", "abc-123")
.send()
.unwrap();
assert_eq!(response.status().as_u16(), 200);
Expand All @@ -127,11 +127,36 @@ fn mock4_binds_a_path_capture_into_the_body_and_headers() {
"7",
"the capture must render into the response header too"
);
assert_eq!(
response.headers().get("x-trace-id").unwrap(),
"abc-123",
"the extracted header must render back out"
);
// A system variable Doppel binds itself, with no extraction anywhere in the
// reference configuration: the id it echoes is the one the client sent.
let echoed = response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned();
let parsed: serde_json::Value = serde_json::from_str(&response.text().unwrap()).unwrap();
assert_eq!(
parsed["id"], "7",
"the resourceId capture must reach the body"
"the resource_id capture must reach the body"
);
// `proxy_name` and `doppel_version`, rendered from nothing the configuration
// declared.
let served_by = parsed["served_by"].as_str().unwrap_or_default();
assert!(
served_by.starts_with("proxy1 "),
"the proxy's own name must render, got {served_by:?}"
);
assert!(
served_by.ends_with(env!("CARGO_PKG_VERSION")),
"the running version must render, got {served_by:?}"
);
assert!(!echoed.is_empty(), "a request id must always be echoed");
}

#[test]
Expand All @@ -146,7 +171,7 @@ fn mock4_without_the_header_it_extracts_fails_loudly() {
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["code"], "TEMPLATE_RENDER_ERROR");
assert!(
parsed["message"].as_str().unwrap().contains("requestId"),
parsed["message"].as_str().unwrap().contains("trace_id"),
"the message must name the variable, got {}",
parsed["message"]
);
Expand All @@ -166,7 +191,7 @@ fn mock6_renders_a_template_file() {
server.write_template(
"proxy1",
"put.json.j2",
r#"{"updated": "{{ resourceId }}", "name": "{{ resourceName }}"}"#,
r#"{"updated": "{{ resource_id }}", "name": "{{ resource_name }}"}"#,
);
let (status, body) = server.request(
"PUT",
Expand Down
Loading