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
16 changes: 16 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ release promotes it to a version heading; the `bump-version` skill does that.

## Development

### 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.

### 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
3 changes: 1 addition & 2 deletions crates/doppel-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,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
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
94 changes: 94 additions & 0 deletions crates/doppel-cli/tests/proxying.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,97 @@ fn a_body_that_names_the_upstream_comes_back_naming_doppel() {
"a different host must survive: {body}"
);
}

/// The system variables, rendered by a mock in a running binary.
///
/// `real_ip` is the one worth driving through a socket rather than a unit test:
/// the chain is `X-Real-IP`, then the leftmost `X-Forwarded-For`, then the peer,
/// and only a real connection has a peer to fall back to.
#[test]
fn a_mock_renders_the_system_variables() {
let up = upstream();
let server = Server::start_with(up.port, |ports, socket, templates| {
format!(
r#"
server:
host: "127.0.0.1"
port: {proxy}
admin:
host: "127.0.0.1"
port: {admin}
tokens: []
access: {{}}
upload:
limit: 1Mi
control:
socket: {socket}
templates:
dir: {templates}
proxies:
- name: p1
type: http
url: "http://127.0.0.1:{upstream}/"
mocks:
- name: who
request:
method: GET
url: ^/who$
response:
status: 200
json: '{{"proxy": "{{{{ proxy_name }}}}", "mock": "{{{{ mock_name }}}}", "version": "{{{{ doppel_version }}}}", "real_ip": "{{{{ real_ip }}}}", "peer_ip": "{{{{ peer_ip }}}}", "method": "{{{{ method }}}}", "path": "{{{{ path }}}}", "host": "{{{{ host }}}}", "request_id": "{{{{ request_id }}}}"}}'
"#,
proxy = ports.server,
admin = ports.admin,
upstream = ports.upstream,
socket = socket.display(),
templates = templates.display(),
)
});

// Nothing in front of this, so `real_ip` falls back to the peer -- and the
// peer is loopback, because that is where the test client is.
let (status, body) = server.get("/who");
assert_eq!(status, 200, "{body}");
let plain: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(plain["proxy"], "p1");
assert_eq!(plain["mock"], "who");
assert_eq!(plain["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(plain["method"], "GET");
assert_eq!(plain["path"], "/who");
assert_eq!(plain["peer_ip"], "127.0.0.1");
assert_eq!(plain["real_ip"], "127.0.0.1", "with no header, the peer");
assert!(
!plain["request_id"].as_str().unwrap().is_empty(),
"a request id is always bound, minted when the client sends none"
);
assert!(
plain["host"].as_str().unwrap().starts_with("127.0.0.1:"),
"host is what the client asked for: {}",
plain["host"]
);

// With a proxy in front, `real_ip` is what that proxy says.
let url = format!("http://127.0.0.1:{}/who", server.port());
let response = reqwest::blocking::Client::new()
.get(&url)
.header("x-real-ip", "203.0.113.7")
.header("x-forwarded-for", "198.51.100.1, 10.0.0.8")
.send()
.unwrap();
let claimed: serde_json::Value = serde_json::from_str(&response.text().unwrap()).unwrap();
assert_eq!(claimed["real_ip"], "203.0.113.7", "X-Real-IP comes first");
assert_eq!(
claimed["peer_ip"], "127.0.0.1",
"and the peer is still the socket's own, which nobody can claim"
);

// Without X-Real-IP, the leftmost X-Forwarded-For entry: the original client
// rather than the hop next to us.
let forwarded = reqwest::blocking::Client::new()
.get(&url)
.header("x-forwarded-for", "198.51.100.1, 10.0.0.8")
.send()
.unwrap();
let chained: serde_json::Value = serde_json::from_str(&forwarded.text().unwrap()).unwrap();
assert_eq!(chained["real_ip"], "198.51.100.1");
}
80 changes: 69 additions & 11 deletions crates/doppel-core/src/config/url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,34 +137,90 @@ impl utoipa::ToSchema for UpstreamUrl {}
/// Held to the same rules as an upstream base, and used the same way: a path is
/// a prefix, so `https://gw.example.com/doppel/` is a Doppel reached under a
/// prefix and rewritten locations keep it.
///
/// It may also be a template over the system variables, 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. That is opt-in for a reason: `host` is a claim by the
/// caller, so a deployment that builds a redirect out of it is choosing to.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ExternalUrl(reqwest::Url);
pub enum ExternalUrl {
/// A url, parsed and checked when the configuration was read.
Fixed(reqwest::Url),
/// A template, kept as text: what it renders to depends on the request, so
/// the rules a fixed url is held to are checked on the result instead. A
/// rendered value that does not parse means no rewriting for that request --
/// not a failed one.
Template(String),
}

/// Whether a configured value is a template rather than a url.
///
/// `{{` is the only marker looked for. A path may legally contain a brace, and
/// neither Jinja's statement (`{%`) nor its comment (`{#`) form makes sense in an
/// address, so the test stays on the one form this feature is for.
#[must_use]
pub fn is_template(value: &str) -> bool {
value.contains("{{")
}

impl ExternalUrl {
/// Check a string and keep it parsed, or say why not.
pub fn parse(value: &str) -> Result<Self, UrlError> {
parse_base(value).map(Self)
if is_template(value) {
return Self::parse_template(value);
}
parse_base(value).map(Self::Fixed)
}

/// A template, checked as far as one can be before it renders.
///
/// The scheme has to be there literally: everything after it may be
/// substituted, but a value that does not begin `http://` or `https://`
/// cannot become a usable url however it renders, and catching that at
/// startup beats finding out on the first redirect.
fn parse_template(value: &str) -> Result<Self, UrlError> {
let scheme = value.split("://").next().unwrap_or_default();
if !matches!(scheme, "http" | "https") {
return Err(UrlError::BadScheme(scheme.to_owned()));
}
if value.contains('?') || value.contains('#') {
return Err(UrlError::HasQueryOrFragment);
}
Ok(Self::Template(value.to_owned()))
}

/// The url, when this is one rather than a template.
#[must_use]
pub fn into_url(self) -> reqwest::Url {
self.0
pub fn as_url(&self) -> Option<&reqwest::Url> {
match self {
Self::Fixed(url) => Some(url),
Self::Template(_) => None,
}
}

/// The template, when this is one.
#[must_use]
pub fn as_url(&self) -> &reqwest::Url {
&self.0
pub fn template(&self) -> Option<&str> {
match self {
Self::Fixed(_) => None,
Self::Template(text) => Some(text),
}
}

/// What the operator wrote: a normalised url, or the template verbatim.
#[must_use]
pub fn as_str(&self) -> &str {
self.0.as_str()
match self {
Self::Fixed(url) => url.as_str(),
Self::Template(text) => text,
}
}
}

impl fmt::Display for ExternalUrl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.0.as_str())
f.write_str(self.as_str())
}
}

Expand All @@ -177,10 +233,12 @@ impl FromStr for ExternalUrl {
}

impl Serialize for ExternalUrl {
/// The parsed form, for the reason `UpstreamUrl` serializes that way: the
/// revision is computed over what this writes.
/// What the operator wrote. A fixed url comes back normalised, for the reason
/// `UpstreamUrl` does -- the revision is computed over this -- and a template
/// comes back verbatim, because there is nothing to normalise and rewriting it
/// would change the revision of a document nobody edited.
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.0.as_str())
s.serialize_str(self.as_str())
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/doppel-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod redact;
pub mod reload;
pub mod runtime;
pub mod store;
pub mod template;
pub mod validate;

pub use config::{Config, ConfigError};
Expand Down
8 changes: 5 additions & 3 deletions crates/doppel-core/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,10 +678,12 @@ proxies:
let rt = compile_reference();
let proxy1 = rt.proxy_by_name("proxy1").unwrap();

// mock4 declares `requestId: X-Request-ID`.
// mock4 declares `trace_id: X-Trace-Id`. Deliberately not a system
// variable's name: the reference configuration stopped extracting one
// when Doppel began binding them itself.
assert_eq!(
mock(proxy1, "mock4").header_vars,
vec![("requestId".to_owned(), "x-request-id".to_owned())]
vec![("trace_id".to_owned(), "x-trace-id".to_owned())]
);
}

Expand All @@ -696,7 +698,7 @@ proxies:
crate::config::Selector::parse(".filter").unwrap()
)));
assert!(mock(proxy1, "mock2").body_vars.contains(&(
"resourceItems".to_owned(),
"resource_items".to_owned(),
crate::config::Selector::parse(".content.items").unwrap()
)));
}
Expand Down
Loading