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
1 change: 1 addition & 0 deletions Cargo.lock

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

5 changes: 5 additions & 0 deletions crates/persisting-capture/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pulsing-actor = { workspace = true }
dashmap = "6"
blake3 = "1"
hostname = "0.4"
ipnet = "2"

[dev-dependencies]
tempfile = "3"
Expand All @@ -55,3 +56,7 @@ path = "tests/capture_apps_claude.rs"
[[test]]
name = "tlv_golden"
path = "tests/tlv_golden.rs"

[[test]]
name = "network_policy_http"
path = "tests/network_policy_http.rs"
31 changes: 30 additions & 1 deletion crates/persisting-capture/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,37 @@ pub struct ProxyConfig {
/// Log every proxied / captured HTTP request to stderr and `{storage}/.capture/debug.log`.
#[serde(default)]
pub debug: bool,
/// Harbor-aligned egress policy for forward-proxy traffic (`CONNECT` + absolute-URI).
#[serde(default)]
pub network: NetworkConfig,
pub models: Vec<ModelRoute>,
}

/// Egress control for the capture forward proxy (Harbor-style modes).
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct NetworkConfig {
/// `public` (default) | `no-network` | `allowlist`.
#[serde(default)]
pub mode: NetworkMode,
/// Allowlist entries when `mode = "allowlist"` (exact host, `*.suffix`, IP, CIDR).
#[serde(default)]
pub allowed_hosts: Vec<String>,
}

/// Network egress mode (Harbor: `public` / `no-network` / `allowlist`).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NetworkMode {
/// Full forward-proxy access (default; backward compatible).
#[default]
Public,
/// Deny all non-loopback forward-proxy egress.
NoNetwork,
/// Only hosts in `allowed_hosts` (plus configured model upstream hosts); empty list denies all.
Allowlist,
}

fn default_admin_listen() -> String {
"127.0.0.1:9876".to_string()
}
Expand Down Expand Up @@ -136,8 +164,9 @@ impl ProxyConfig {
Ok(toml::to_string_pretty(self)?)
}

/// Validate model entries, `forward` references, and duplicate names.
/// Validate model entries, `forward` references, network policy, and duplicate names.
pub fn validate(&self) -> anyhow::Result<()> {
crate::proxy::network_policy::validate_network_config(&self.network)?;
let mut seen = HashSet::new();
for route in &self.models {
if !seen.insert(route.name.clone()) {
Expand Down
64 changes: 56 additions & 8 deletions crates/persisting-capture/src/proxy/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ use super::forward::{
handle_connect, is_forward_proxy_request, is_llm_capture_path, transparent_forward,
};
use super::llm_capture::llm_capture;
use super::network_policy::{
assert_egress_allowed, forbidden_response, host_from_authority, NetworkPolicy,
};
use super::state::ProxyState;
use crate::debug::{self, is_debug_enabled};
use crate::session_storage::resolve_capture_route;
Expand Down Expand Up @@ -49,6 +52,27 @@ async fn dispatch(
result
}

fn deny_egress(
state: &ProxyState,
policy: &NetworkPolicy,
host: &str,
reason: &super::network_policy::DenyReason,
session_id: &str,
debug_on: bool,
) -> Response {
if debug_on {
debug::log_network_denied(
state.storage.as_path(),
host,
policy.mode_str(),
reason.as_str(),
session_id,
);
}
let (status, msg) = forbidden_response(host, reason);
(status, msg).into_response()
}

async fn dispatch_impl(
state: ProxyState,
req: Request,
Expand All @@ -65,21 +89,44 @@ async fn dispatch_impl(
let session_id = log_route.session_id.clone();
let cfg = effective_config(&state, &log_route);
let debug_on = is_debug_enabled(&cfg, state.storage.as_path());
let policy = NetworkPolicy::from_config(&cfg)?;

if *req.method() == Method::CONNECT {
let authority = req
.uri()
.authority()
.map(|a| a.to_string())
.unwrap_or_else(|| uri.clone());
let host = host_from_authority(&authority);
if let Err(reason) = assert_egress_allowed(&policy, &host) {
return Ok(deny_egress(
&state,
&policy,
&host,
&reason,
&session_id,
debug_on,
));
}
if debug_on {
let target = req
.uri()
.authority()
.map(|a| a.to_string())
.unwrap_or_else(|| uri.clone());
debug::log_connect(state.storage.as_path(), &target, &session_id);
debug::log_connect(state.storage.as_path(), &authority, &session_id);
}
return Ok(handle_connect(req).await);
return Ok(handle_connect(req, &policy).await);
}

let path = req.uri().path().to_string();
if is_forward_proxy_request(req.method(), req.uri()) {
let host = req.uri().host().map(str::to_string).unwrap_or_default();
if let Err(reason) = assert_egress_allowed(&policy, &host) {
return Ok(deny_egress(
&state,
&policy,
&host,
&reason,
&session_id,
debug_on,
));
}
if is_llm_capture_path(&path) {
if debug_on {
debug::log_dispatch(
Expand All @@ -101,7 +148,7 @@ async fn dispatch_impl(
"forward",
);
}
let resp = transparent_forward(&state.client, req).await?;
let resp = transparent_forward(&state.client, req, &policy).await?;
if debug_on {
let status = resp.status();
let headers = resp.headers().clone();
Expand Down Expand Up @@ -140,5 +187,6 @@ async fn dispatch_impl(
"llm_gateway",
);
}
// Relative-path LLM gateway on `listen` — not subject to egress allowlist.
llm_capture(state, req, peer, debug_on).await
}
173 changes: 172 additions & 1 deletion crates/persisting-capture/src/proxy/forward.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,20 @@ use tokio::net::TcpStream;
use crate::protocol::ProtocolKind;

use super::http_headers::skip_transparent_forward_header;
use super::network_policy::{
assert_egress_allowed, forbidden_response, host_from_authority, NetworkPolicy,
};

/// `CONNECT host:443` — tunnel TCP to target (HTTPS and other TLS).
pub async fn handle_connect(req: Request) -> Response {
pub async fn handle_connect(req: Request, policy: &NetworkPolicy) -> Response {
let Some(authority) = req.uri().authority().map(|a| a.to_string()) else {
return StatusCode::BAD_REQUEST.into_response();
};
let host = host_from_authority(&authority);
if let Err(reason) = assert_egress_allowed(policy, &host) {
let (status, msg) = forbidden_response(&host, &reason);
return (status, msg).into_response();
}
let target = connect_target(&authority);
let on_upgrade: OnUpgrade = hyper::upgrade::on(req);
tokio::spawn(async move {
Expand All @@ -39,6 +47,15 @@ pub async fn handle_connect(req: Request) -> Response {
}

fn connect_target(authority: &str) -> String {
if authority.starts_with('[') {
// `[ipv6]:port` or `[ipv6]`
if authority.matches(':').count() >= 2 {
if authority.ends_with(']') {
return format!("{authority}:443");
}
return authority.to_string();
}
}
if authority.contains(':') {
authority.to_string()
} else {
Expand All @@ -58,7 +75,17 @@ pub fn is_llm_capture_path(path: &str) -> bool {
pub async fn transparent_forward(
client: &reqwest::Client,
req: Request,
policy: &NetworkPolicy,
) -> anyhow::Result<Response<Body>> {
let host = req.uri().host().map(str::to_string).unwrap_or_default();
if let Err(reason) = assert_egress_allowed(policy, &host) {
let (status, msg) = forbidden_response(&host, &reason);
return Ok(Response::builder()
.status(status)
.body(Body::from(msg))
.expect("403 body"));
}

let (parts, body) = req.into_parts();
let url = parts.uri.to_string();
let body_bytes = body
Expand Down Expand Up @@ -101,6 +128,34 @@ pub async fn transparent_forward(
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CaptureLevel, ModelRoute, NetworkConfig, NetworkMode, ProxyConfig};
use crate::proxy::network_policy::NetworkPolicy;

fn policy(mode: NetworkMode, allowed: &[&str]) -> NetworkPolicy {
NetworkPolicy::from_config(&ProxyConfig {
listen: "127.0.0.1:19081".into(),
admin_listen: "127.0.0.1:9876".into(),
agent_id: "default".into(),
session_header: "x-persisting-session-id".into(),
capture_level: CaptureLevel::Dialogue,
debug: false,
network: NetworkConfig {
mode,
allowed_hosts: allowed.iter().map(|s| (*s).to_string()).collect(),
},
models: vec![ModelRoute {
name: "*".into(),
provider: None,
upstream: Some("http://127.0.0.1:9/v1".into()),
upstream_anthropic: None,
path_prefix: None,
api_key_env: None,
api_key: None,
forward: None,
}],
})
.unwrap()
}

#[test]
fn forward_proxy_detection() {
Expand All @@ -120,5 +175,121 @@ mod tests {
fn connect_target_defaults_port() {
assert_eq!(connect_target("api.openai.com"), "api.openai.com:443");
assert_eq!(connect_target("127.0.0.1:8080"), "127.0.0.1:8080");
assert_eq!(connect_target("[::1]"), "[::1]:443");
}

#[tokio::test]
async fn connect_denied_before_tunnel() {
let p = policy(NetworkMode::Allowlist, &["pypi.org"]);
let req = Request::builder()
.method(Method::CONNECT)
.uri("github.com:443")
.body(Body::empty())
.unwrap();
let resp = handle_connect(req, &p).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn connect_allowlist_permits_listed_host() {
let p = policy(NetworkMode::Allowlist, &["api.openai.com"]);
// No upgrade header → tunnel task fails quietly, but status must be 200 (allowed).
let req = Request::builder()
.method(Method::CONNECT)
.uri("api.openai.com:443")
.body(Body::empty())
.unwrap();
let resp = handle_connect(req, &p).await;
assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn connect_no_network_denies() {
let p = policy(NetworkMode::NoNetwork, &[]);
let req = Request::builder()
.method(Method::CONNECT)
.uri("example.com:443")
.body(Body::empty())
.unwrap();
let resp = handle_connect(req, &p).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn transparent_forward_denied() {
let p = policy(NetworkMode::Allowlist, &["pypi.org"]);
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let req = Request::builder()
.method(Method::GET)
.uri("http://github.com/")
.body(Body::empty())
.unwrap();
let resp = transparent_forward(&client, req, &p).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn public_connect_allows() {
let p = policy(NetworkMode::Public, &[]);
let req = Request::builder()
.method(Method::CONNECT)
.uri("anywhere.example:443")
.body(Body::empty())
.unwrap();
let resp = handle_connect(req, &p).await;
assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn connect_loopback_allowed_under_no_network() {
let p = policy(NetworkMode::NoNetwork, &[]);
let req = Request::builder()
.method(Method::CONNECT)
.uri("127.0.0.1:9443")
.body(Body::empty())
.unwrap();
let resp = handle_connect(req, &p).await;
assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn transparent_forward_allows_listed_local() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let app = axum::Router::new().route("/", axum::routing::get(|| async { "pong" }));
axum::serve(listener, app).await.ok();
});
// Brief yield so accept loop starts.
tokio::task::yield_now().await;

let p = policy(NetworkMode::Allowlist, &["127.0.0.1"]);
let client = reqwest::Client::builder().no_proxy().build().unwrap();
let req = Request::builder()
.method(Method::GET)
.uri(format!("http://127.0.0.1:{}/", addr.port()))
.body(Body::empty())
.unwrap();
let resp = transparent_forward(&client, req, &p).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn connect_deny_body_explains_reason() {
let p = policy(NetworkMode::Allowlist, &["pypi.org"]);
let req = Request::builder()
.method(Method::CONNECT)
.uri("github.com:443")
.body(Body::empty())
.unwrap();
let resp = handle_connect(req, &p).await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.unwrap()
.to_bytes();
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("github.com"));
assert!(body.contains("not-in-allowlist") || body.contains("denied"));
}
}
1 change: 1 addition & 0 deletions crates/persisting-capture/src/proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod http_headers;
pub mod llm_capture;
pub mod model;
pub mod models_list;
pub mod network_policy;
pub mod reasoning;
pub mod router;
pub mod state;
Expand Down
Loading
Loading