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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`Proxy::config_snapshot()` and `Proxy::update_config()` are now synchronous.
- Integration tests: config hot-reload on keep-alive connections, Prometheus
`/metrics` endpoint (counters, histogram buckets, gauge, metadata).
- **`header_up`** in `reverse_proxy` blocks — set or remove headers on upstream
requests (applied after default `Host` / `X-Forwarded-*`).
- Upstream placeholders for `header_up`: `{upstream_host}`, `{request.uri}`,
`{remote_ip}` (via `process_upstream_substitution`).
- Dependencies: `arc-swap`, `metrics`, `metrics-exporter-prometheus` (optional)

### Changed
Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,25 @@ localhost:8080 {

Timeout values support duration suffixes: `30s`, `5m`, `2h`, `1d`, or plain numbers (seconds).

Upstream request headers (`header_up`) can be set inside the `reverse_proxy` block. They are applied **after** the default `Host` and `X-Forwarded-*` headers, so explicit values override defaults:

```caddy
localhost:8080 {
reverse_proxy https://api.example.com:443 {
connect_timeout 10s
read_timeout 30s
header_up Host {upstream_host}
header_up X-Original-Uri {request.uri}
header_up -Accept-Encoding
}
}
```

| Syntax | Action |
|--------|--------|
| `header_up Name value` | set/replace header on the upstream request |
| `header_up -Name` | remove header before forwarding |

#### `tls`

Enable HTTPS on the frontend with TLS termination. Specify paths to the certificate chain and private key (PEM format).
Expand Down Expand Up @@ -440,12 +459,18 @@ localhost:8080 {

### Placeholders

Use placeholders in header values:
Use placeholders in `header` and `header_up` values:

- `{header.Name}` - Value of request header with that name
- `{env.VAR}` - Value of environment variable
- `{uuid}` - Random UUID

`header_up` also supports:

- `{upstream_host}` - hostname:port of the `reverse_proxy` backend URL
- `{request.uri}` - path + query of the incoming client request
- `{remote_ip}` - client IP (`X-Forwarded-For` / `X-Real-IP`, else socket address)

## Features

### Default Features
Expand Down
8 changes: 8 additions & 0 deletions benches/proxy_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ fn bench_directive_operations(c: &mut Criterion) {
to: "http://backend:9001".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
Directive::UriReplace {
find: "/old".to_string(),
Expand Down Expand Up @@ -426,6 +427,7 @@ fn create_simple_config() -> Config {
to: "http://127.0.0.1:9001".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
],
tls: None,
Expand Down Expand Up @@ -462,6 +464,7 @@ fn create_medium_config() -> Config {
to: "http://backend:9001".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
],
tls: None,
Expand All @@ -475,6 +478,7 @@ fn create_medium_config() -> Config {
to: "http://backend:9002".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
}],
tls: None,
},
Expand Down Expand Up @@ -508,6 +512,7 @@ fn create_complex_config() -> Config {
to: "http://user-service:8001".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
],
},
Expand All @@ -522,6 +527,7 @@ fn create_complex_config() -> Config {
to: "http://order-service:8002".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
],
},
Expand All @@ -541,6 +547,7 @@ fn create_complex_config() -> Config {
to: "http://api-service:8000".to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
],
},
Expand Down Expand Up @@ -578,6 +585,7 @@ fn create_multi_site_config(count: usize) -> Config {
to: format!("http://backend:{}", 9000 + i),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
},
],
tls: None,
Expand Down
46 changes: 46 additions & 0 deletions src/auth/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ pub fn process_header_substitution<B>(value: &str, req: &Request<B>) -> anyhow::
Ok(result)
}

/// Process header value substitutions for upstream (`header_up`) operations.
///
/// Supports all the placeholders of [`process_header_substitution`] plus three
/// extra placeholders that only make sense for outbound (upstream) headers:
///
/// - `{upstream_host}` — hostname:port of the `reverse_proxy` backend
/// - `{request.uri}` — the full path + query of the incoming request
/// - `{remote_ip}` — client IP from `remote_addr` (or `X-Forwarded-For` / `X-Real-IP`)
///
/// The order of substitution is: base placeholders first (`{header.*}`, `{env.*}`,
/// `{uuid}`), then the upstream-specific ones. This lets you write e.g.
/// `header_up Host {upstream_host}` while still being able to use `{header.X-Foo}`.
pub fn process_upstream_substitution<B>(
value: &str,
req: &Request<B>,
upstream_host: &str,
request_uri: &str,
remote_ip: &str,
) -> anyhow::Result<String> {
// Base substitutions ({header.*}, {env.*}, {uuid}).
let mut result = process_header_substitution(value, req)?;

result = result.replace("{upstream_host}", upstream_host);
result = result.replace("{request.uri}", request_uri);
result = result.replace("{remote_ip}", remote_ip);

Ok(result)
}

/// Extract remote IP address from request headers
///
/// Looks for the X-Forwarded-For or X-Real-IP headers to determine the
Expand Down Expand Up @@ -188,4 +217,21 @@ mod tests {
let ip = extract_remote_ip(&req);
assert!(ip.is_none());
}

#[test]
fn test_process_upstream_substitution() {
let req = make_request_with_header("X-Trace", "abc");
let result = process_upstream_substitution(
"host={upstream_host} uri={request.uri} ip={remote_ip} trace={header.X-Trace}",
&req,
"api.example.com:443",
"/v1/items?limit=10",
"203.0.113.7",
)
.unwrap();
assert_eq!(
result,
"host=api.example.com:443 uri=/v1/items?limit=10 ip=203.0.113.7 trace=abc"
);
}
}
2 changes: 1 addition & 1 deletion src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ pub mod headers;
pub mod validator;

// Re-export commonly used functions for convenience
pub use headers::process_header_substitution;
pub use headers::{process_header_substitution, process_upstream_substitution};
pub use validator::validate_token;
2 changes: 1 addition & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ mod models;
mod parser;

pub use address::{extract_hostname, resolve_listen_addr, tls_redirect_port};
pub use models::{Config, Directive, SiteConfig, TlsConfig};
pub use models::{Config, Directive, HeaderDirective, SiteConfig, TlsConfig};
11 changes: 11 additions & 0 deletions src/config/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub enum Directive {
to: String,
connect_timeout: Option<u64>,
read_timeout: Option<u64>,
#[cfg_attr(feature = "api", serde(default))]
header_up: Vec<HeaderDirective>,
},
HandlePath {
pattern: String,
Expand Down Expand Up @@ -63,3 +65,12 @@ pub enum Directive {
body: String,
},
}

/// A single header operation within a `header_up` block.
/// `value = None` means the header should be removed.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "api", derive(Serialize, Deserialize))]
pub struct HeaderDirective {
pub name: String,
pub value: Option<String>,
}
74 changes: 72 additions & 2 deletions src/config/parser.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::config::address::{extract_hostname, resolve_listen_addr};
use crate::config::models::HeaderDirective;
use crate::config::{Config, Directive, SiteConfig};
use crate::error::ProxyError;
use std::collections::HashMap;
Expand All @@ -12,6 +13,8 @@ struct PendingBlock {
// Timeout settings for reverse_proxy blocks (in seconds)
connect_timeout: Option<u64>,
read_timeout: Option<u64>,
// header_up operations collected inside a reverse_proxy block
header_up: Vec<HeaderDirective>,
}

/// Parse a human-readable duration string into seconds.
Expand Down Expand Up @@ -107,6 +110,7 @@ impl FromStr for Config {
args,
connect_timeout: None,
read_timeout: None,
header_up: vec![],
});
directive_stack.push(vec![]);
continue;
Expand Down Expand Up @@ -138,6 +142,7 @@ impl FromStr for Config {
to,
connect_timeout: block_info.connect_timeout,
read_timeout: block_info.read_timeout,
header_up: block_info.header_up,
}
}
_ => {
Expand Down Expand Up @@ -188,7 +193,7 @@ impl FromStr for Config {
let directive_name = parts[0];
let args = parts[1..].to_vec();

// Special handling: timeout settings inside a reverse_proxy block
// Special handling: timeout and header_up settings inside a reverse_proxy block
if let Some(block) = block_stack.last_mut() {
if block.directive_type == "reverse_proxy" {
match directive_name {
Expand Down Expand Up @@ -218,9 +223,44 @@ impl FromStr for Config {
})?);
continue;
}
"header_up" => {
let raw_name = args.first().cloned().ok_or_else(|| {
ProxyError::Parse(format!(
"Missing header name for header_up on line {}",
line_num + 1
))
})?;
let directive = if let Some(name) = raw_name.strip_prefix('-') {
if name.is_empty() {
return Err(ProxyError::Parse(format!(
"Missing header name after '-' in header_up on line {}",
line_num + 1
)));
}
HeaderDirective {
name: name.to_string(),
value: None,
}
} else {
let value = args[1..].join(" ");
if value.is_empty() {
return Err(ProxyError::Parse(format!(
"Missing value for header_up {} on line {}",
raw_name,
line_num + 1
)));
}
HeaderDirective {
name: raw_name.to_string(),
value: Some(value),
}
};
block.header_up.push(directive);
continue;
}
_ => {
return Err(ProxyError::Parse(format!(
"Unexpected directive '{}' inside reverse_proxy block on line {}. Only connect_timeout and read_timeout are allowed.",
"Unexpected directive '{}' inside reverse_proxy block on line {}. Allowed: connect_timeout, read_timeout, header_up.",
directive_name, line_num + 1
)));
}
Expand Down Expand Up @@ -265,6 +305,7 @@ impl FromStr for Config {
to: to.to_string(),
connect_timeout: None,
read_timeout: None,
header_up: vec![],
}
}
"uri_replace" => {
Expand Down Expand Up @@ -447,6 +488,7 @@ mod tests {
to,
connect_timeout,
read_timeout,
..
} => {
assert_eq!(to, "http://backend:9001");
assert_eq!(*connect_timeout, None);
Expand All @@ -473,6 +515,7 @@ mod tests {
to,
connect_timeout,
read_timeout,
..
} => {
assert_eq!(to, "http://backend:9001");
assert_eq!(*connect_timeout, Some(10));
Expand Down Expand Up @@ -505,6 +548,33 @@ mod tests {
}
}

#[test]
fn test_parse_reverse_proxy_with_header_up() {
let config = r#"localhost:8080 {
reverse_proxy https://api.example.com:443 {
header_up Host {upstream_host}
header_up X-Original-Uri {request.uri}
header_up -Accept-Encoding
}
}"#;
let result: Config = config.parse().unwrap();
let site = result.sites.get("localhost:8080").unwrap();

match &site.directives[0] {
Directive::ReverseProxy { to, header_up, .. } => {
assert_eq!(to, "https://api.example.com:443");
assert_eq!(header_up.len(), 3);
assert_eq!(header_up[0].name, "Host");
assert_eq!(header_up[0].value.as_deref(), Some("{upstream_host}"));
assert_eq!(header_up[1].name, "X-Original-Uri");
assert_eq!(header_up[1].value.as_deref(), Some("{request.uri}"));
assert_eq!(header_up[2].name, "Accept-Encoding");
assert!(header_up[2].value.is_none());
}
_ => panic!("Expected ReverseProxy directive"),
}
}

#[test]
fn test_parse_reverse_proxy_block_rejects_unknown_directive() {
let config = r#"localhost:8080 {
Expand Down
Loading
Loading