From 10252f9e25884fc7979f0a26b3805aa04de7647b Mon Sep 17 00:00:00 2001 From: Reiase Date: Fri, 24 Jul 2026 17:23:20 +0800 Subject: [PATCH] Add network policy for egress control in persisting-capture - Introduced a new `NetworkPolicy` module to manage egress rules for forward-proxy traffic. - Added `NetworkConfig` struct to define network modes: `public`, `no-network`, and `allowlist`. - Implemented validation for network configurations and integrated checks in the proxy dispatch logic. - Updated `Cargo.toml` and `Cargo.lock` to include the `ipnet` dependency for IP address handling. - Added tests for various network policy scenarios, ensuring correct behavior for allowed and denied hosts. - Enhanced documentation to reflect new network policy features and usage examples. This commit enhances the security and configurability of the proxy by allowing users to define egress rules based on their requirements. --- Cargo.lock | 1 + crates/persisting-capture/Cargo.toml | 5 + crates/persisting-capture/src/config.rs | 31 +- .../persisting-capture/src/proxy/dispatch.rs | 64 +- .../persisting-capture/src/proxy/forward.rs | 173 +++++- crates/persisting-capture/src/proxy/mod.rs | 1 + .../src/proxy/network_policy.rs | 566 ++++++++++++++++++ .../persisting-capture/src/runtime/debug.rs | 7 + .../tests/network_policy_http.rs | 365 +++++++++++ docs/src/guide/capture.md | 18 +- docs/src/guide/capture.zh.md | 35 +- examples/README.md | 2 +- examples/llm-proxy/README.md | 11 + examples/llm-proxy/allowlist.toml | 37 ++ examples/llm-proxy/deepseek.toml | 9 + examples/llm-proxy/multi-provider.toml | 10 + 16 files changed, 1322 insertions(+), 13 deletions(-) create mode 100644 crates/persisting-capture/src/proxy/network_policy.rs create mode 100644 crates/persisting-capture/tests/network_policy_http.rs create mode 100644 examples/llm-proxy/README.md create mode 100644 examples/llm-proxy/allowlist.toml diff --git a/Cargo.lock b/Cargo.lock index 30d0357..03a768b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5764,6 +5764,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "ipnet", "lazy_static", "pulsing-actor", "regex", diff --git a/crates/persisting-capture/Cargo.toml b/crates/persisting-capture/Cargo.toml index e53d62c..e3543e2 100644 --- a/crates/persisting-capture/Cargo.toml +++ b/crates/persisting-capture/Cargo.toml @@ -34,6 +34,7 @@ pulsing-actor = { workspace = true } dashmap = "6" blake3 = "1" hostname = "0.4" +ipnet = "2" [dev-dependencies] tempfile = "3" @@ -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" diff --git a/crates/persisting-capture/src/config.rs b/crates/persisting-capture/src/config.rs index 86ddbde..b9317b1 100644 --- a/crates/persisting-capture/src/config.rs +++ b/crates/persisting-capture/src/config.rs @@ -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, } +/// 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, +} + +/// 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() } @@ -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()) { diff --git a/crates/persisting-capture/src/proxy/dispatch.rs b/crates/persisting-capture/src/proxy/dispatch.rs index bae4fbb..9a5462e 100644 --- a/crates/persisting-capture/src/proxy/dispatch.rs +++ b/crates/persisting-capture/src/proxy/dispatch.rs @@ -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; @@ -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, @@ -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( @@ -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(); @@ -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 } diff --git a/crates/persisting-capture/src/proxy/forward.rs b/crates/persisting-capture/src/proxy/forward.rs index 068cef5..84f7518 100644 --- a/crates/persisting-capture/src/proxy/forward.rs +++ b/crates/persisting-capture/src/proxy/forward.rs @@ -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 { @@ -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 { @@ -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> { + 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 @@ -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() { @@ -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")); } } diff --git a/crates/persisting-capture/src/proxy/mod.rs b/crates/persisting-capture/src/proxy/mod.rs index afe2be9..4756bb4 100644 --- a/crates/persisting-capture/src/proxy/mod.rs +++ b/crates/persisting-capture/src/proxy/mod.rs @@ -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; diff --git a/crates/persisting-capture/src/proxy/network_policy.rs b/crates/persisting-capture/src/proxy/network_policy.rs new file mode 100644 index 0000000..ac69cb1 --- /dev/null +++ b/crates/persisting-capture/src/proxy/network_policy.rs @@ -0,0 +1,566 @@ +//! Harbor-aligned egress policy for forward-proxy traffic. +//! +//! Modes: `public` | `no-network` | `allowlist`. +//! Matching: exact hostname, leading `*.suffix`, IPv4/IPv6 literal, CIDR. +//! Entries are not URLs, ports, or paths. + +use std::net::IpAddr; +use std::str::FromStr; + +use axum::http::StatusCode; +use ipnet::IpNet; + +use crate::config::{NetworkConfig, NetworkMode, ProxyConfig}; + +/// Compiled policy used at request time. +#[derive(Debug, Clone)] +pub struct NetworkPolicy { + pub mode: NetworkMode, + /// Normalized allowlist entries (config + model upstream hosts when allowlist). + pub allowed: Vec, + /// Host part of `listen` (always bypassed with other loopbacks). + pub listen_host: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AllowedEntry { + Exact(String), + WildcardSuffix(String), + Ip(IpAddr), + Cidr(IpNet), +} + +impl NetworkPolicy { + pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result { + let listen_host = host_from_listen(&cfg.listen); + let mut raw = cfg.network.allowed_hosts.clone(); + if cfg.network.mode == NetworkMode::Allowlist { + for host in upstream_hosts_from_models(cfg) { + if !raw.iter().any(|h| normalize_host(h) == host) { + raw.push(host); + } + } + } + let mut allowed = Vec::with_capacity(raw.len()); + for entry in &raw { + allowed.push(parse_allowed_entry(entry)?); + } + Ok(Self { + mode: cfg.network.mode, + allowed, + listen_host, + }) + } + + pub fn mode_str(&self) -> &'static str { + match self.mode { + NetworkMode::Public => "public", + NetworkMode::NoNetwork => "no-network", + NetworkMode::Allowlist => "allowlist", + } + } +} + +pub fn validate_network_config(network: &NetworkConfig) -> anyhow::Result<()> { + for entry in &network.allowed_hosts { + parse_allowed_entry(entry)?; + } + Ok(()) +} + +/// Normalize host for comparison: trim, lowercase, strip trailing dot. +pub fn normalize_host(host: &str) -> String { + host.trim() + .trim_matches(|c| c == '[' || c == ']') + .to_ascii_lowercase() + .trim_end_matches('.') + .to_string() +} + +/// Parse `listen` (`127.0.0.1:19081` or `http://127.0.0.1:19081`) into host. +pub fn host_from_listen(listen: &str) -> String { + let s = listen.trim(); + let without_scheme = s + .strip_prefix("http://") + .or_else(|| s.strip_prefix("https://")) + .unwrap_or(s); + host_from_authority(without_scheme.trim_end_matches('/')) +} + +/// Extract host from `host:port`, `[ipv6]:port`, or bare host. +pub fn host_from_authority(authority: &str) -> String { + let authority = authority.trim(); + if let Some(rest) = authority.strip_prefix('[') { + if let Some(end) = rest.find(']') { + return normalize_host(&rest[..end]); + } + } + if let Some((host, port)) = authority.rsplit_once(':') { + if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) { + return normalize_host(host); + } + } + normalize_host(authority) +} + +pub fn parse_allowed_entry(raw: &str) -> anyhow::Result { + let entry = raw.trim(); + if entry.is_empty() { + anyhow::bail!("allowed_hosts entry must not be empty"); + } + if entry.contains("://") || entry.contains(']') || entry.contains('[') { + anyhow::bail!( + "allowed_hosts entry `{entry}` must be a hostname, `*.suffix`, IP, or CIDR \ + (not a URL or bracketed IPv6)" + ); + } + // Path-like (but allow CIDR `a.b.c.d/nn`). + if entry.contains('/') && IpNet::from_str(entry).is_err() { + anyhow::bail!( + "allowed_hosts entry `{entry}` must be a hostname, `*.suffix`, IP, or CIDR \ + (not a URL path)" + ); + } + // Port in entry is forbidden (Harbor semantics). + if let Some((host_part, maybe_port)) = entry.rsplit_once(':') { + if !host_part.is_empty() + && maybe_port.chars().all(|c| c.is_ascii_digit()) + && !entry.contains('/') + && host_part.parse::().is_err() + && !host_part.contains(':') + { + // hostname:port — reject + anyhow::bail!( + "allowed_hosts entry `{entry}` must not include a port (got hostname:port)" + ); + } + } + + if let Some(suffix) = entry.strip_prefix("*.") { + let suffix = normalize_host(suffix); + if suffix.is_empty() || suffix.contains('*') { + anyhow::bail!("invalid wildcard allowed_hosts entry `{entry}`"); + } + if suffix.parse::().is_ok() { + anyhow::bail!("wildcard allowed_hosts cannot wrap an IP (`{entry}`)"); + } + return Ok(AllowedEntry::WildcardSuffix(suffix)); + } + + if entry.contains('*') { + anyhow::bail!( + "allowed_hosts entry `{entry}`: only leading `*.suffix` wildcards are supported" + ); + } + + if let Ok(cidr) = IpNet::from_str(entry) { + // Prefer CIDR form when a prefix length is present. + if entry.contains('/') { + return Ok(AllowedEntry::Cidr(cidr)); + } + // `IpNet::from_str("1.1.1.1")` succeeds as /32 — treat as literal IP. + return Ok(AllowedEntry::Ip(cidr.addr())); + } + if let Ok(ip) = IpAddr::from_str(entry) { + return Ok(AllowedEntry::Ip(ip)); + } + + let host = normalize_host(entry); + if host.is_empty() || host.contains(':') { + anyhow::bail!("invalid allowed_hosts hostname `{entry}`"); + } + Ok(AllowedEntry::Exact(host)) +} + +pub fn host_matches(host: &str, allowed: &[AllowedEntry]) -> bool { + let host = normalize_host(host); + if host.is_empty() { + return false; + } + let host_ip = IpAddr::from_str(&host).ok(); + for entry in allowed { + match entry { + AllowedEntry::Exact(h) => { + if host == *h { + return true; + } + } + AllowedEntry::WildcardSuffix(suffix) => { + // `*.example.com` matches subdomains only, not apex `example.com`. + if host.ends_with(suffix) + && host.len() > suffix.len() + && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' + { + return true; + } + } + AllowedEntry::Ip(ip) => { + if host_ip == Some(*ip) { + return true; + } + } + AllowedEntry::Cidr(net) => { + if let Some(ip) = host_ip { + if net.contains(&ip) { + return true; + } + } + } + } + } + false +} + +pub fn is_loopback_host(host: &str, listen_host: &str) -> bool { + let h = normalize_host(host); + if h == "localhost" || h == "127.0.0.1" || h == "::1" || h == "0:0:0:0:0:0:0:1" { + return true; + } + if !listen_host.is_empty() && h == normalize_host(listen_host) { + return true; + } + if let Ok(ip) = IpAddr::from_str(&h) { + return ip.is_loopback(); + } + false +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DenyReason { + NoNetwork, + AllowlistEmpty, + NotInAllowlist, +} + +impl DenyReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::NoNetwork => "no-network", + Self::AllowlistEmpty => "allowlist-empty", + Self::NotInAllowlist => "not-in-allowlist", + } + } +} + +/// Check whether forward-proxy egress to `host` is allowed. +pub fn assert_egress_allowed(policy: &NetworkPolicy, host: &str) -> Result<(), DenyReason> { + if is_loopback_host(host, &policy.listen_host) { + return Ok(()); + } + match policy.mode { + NetworkMode::Public => Ok(()), + NetworkMode::NoNetwork => Err(DenyReason::NoNetwork), + NetworkMode::Allowlist => { + if policy.allowed.is_empty() { + return Err(DenyReason::AllowlistEmpty); + } + if host_matches(host, &policy.allowed) { + Ok(()) + } else { + Err(DenyReason::NotInAllowlist) + } + } + } +} + +pub fn forbidden_response(host: &str, reason: &DenyReason) -> (StatusCode, String) { + ( + StatusCode::FORBIDDEN, + format!( + "persisting-proxy: egress to `{host}` denied ({})", + reason.as_str() + ), + ) +} + +fn upstream_hosts_from_models(cfg: &ProxyConfig) -> Vec { + let mut out = Vec::new(); + for route in &cfg.models { + for url in [&route.upstream, &route.upstream_anthropic] + .into_iter() + .flatten() + { + if let Ok(parsed) = url::Url::parse(url) { + if let Some(host) = parsed.host_str() { + let n = normalize_host(host); + if !n.is_empty() && !out.iter().any(|h| h == &n) { + out.push(n); + } + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CaptureLevel, ModelRoute, NetworkConfig, NetworkMode, ProxyConfig}; + + fn cfg(mode: NetworkMode, allowed: &[&str], upstream: Option<&str>) -> ProxyConfig { + 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: upstream.map(str::to_string), + upstream_anthropic: None, + path_prefix: None, + api_key_env: None, + api_key: None, + forward: None, + }], + } + } + + #[test] + fn normalize_and_authority_host() { + assert_eq!(normalize_host(" Example.COM. "), "example.com"); + assert_eq!(host_from_authority("api.openai.com:443"), "api.openai.com"); + assert_eq!(host_from_authority("[::1]:443"), "::1"); + assert_eq!(host_from_listen("http://127.0.0.1:19081"), "127.0.0.1"); + } + + #[test] + fn exact_and_wildcard_match() { + let entries = vec![ + parse_allowed_entry("example.com").unwrap(), + parse_allowed_entry("*.example.com").unwrap(), + ]; + assert!(host_matches("example.com", &entries)); + assert!(!host_matches("www.example.com", &entries[0..1])); + assert!(host_matches("www.example.com", &entries)); + assert!(host_matches("a.b.example.com", &entries)); + assert!(!host_matches("example.org", &entries)); + // wildcard does not include apex alone when only wildcard present + let wild = vec![parse_allowed_entry("*.example.com").unwrap()]; + assert!(!host_matches("example.com", &wild)); + } + + #[test] + fn ip_and_cidr_match() { + let entries = vec![ + parse_allowed_entry("1.1.1.1").unwrap(), + parse_allowed_entry("10.0.0.0/8").unwrap(), + ]; + assert!(host_matches("1.1.1.1", &entries)); + assert!(host_matches("10.1.2.3", &entries)); + assert!(!host_matches("8.8.8.8", &entries)); + } + + #[test] + fn rejects_url_port_path_entries() { + assert!(parse_allowed_entry("https://example.com").is_err()); + assert!(parse_allowed_entry("example.com/path").is_err()); + assert!(parse_allowed_entry("example.com:443").is_err()); + assert!(parse_allowed_entry("[::1]").is_err()); + assert!(parse_allowed_entry("*example.com").is_err()); + } + + #[test] + fn public_allows_all() { + let p = NetworkPolicy::from_config(&cfg(NetworkMode::Public, &[], None)).unwrap(); + assert!(assert_egress_allowed(&p, "evil.example").is_ok()); + } + + #[test] + fn no_network_denies_non_loopback() { + let p = NetworkPolicy::from_config(&cfg(NetworkMode::NoNetwork, &[], None)).unwrap(); + assert_eq!( + assert_egress_allowed(&p, "pypi.org"), + Err(DenyReason::NoNetwork) + ); + assert!(assert_egress_allowed(&p, "127.0.0.1").is_ok()); + assert!(assert_egress_allowed(&p, "localhost").is_ok()); + } + + #[test] + fn allowlist_empty_denies() { + let p = NetworkPolicy::from_config(&cfg(NetworkMode::Allowlist, &[], None)).unwrap(); + // upstream None → empty effective list + assert_eq!( + assert_egress_allowed(&p, "pypi.org"), + Err(DenyReason::AllowlistEmpty) + ); + } + + #[test] + fn allowlist_merges_upstream_host() { + let p = NetworkPolicy::from_config(&cfg( + NetworkMode::Allowlist, + &["pypi.org"], + Some("https://api.openai.com/v1"), + )) + .unwrap(); + assert!(assert_egress_allowed(&p, "pypi.org").is_ok()); + assert!(assert_egress_allowed(&p, "api.openai.com").is_ok()); + assert_eq!( + assert_egress_allowed(&p, "github.com"), + Err(DenyReason::NotInAllowlist) + ); + } + + #[test] + fn toml_network_section_loads() { + let cfg = ProxyConfig::from_toml_str( + r#" +listen = "127.0.0.1:1" + +[network] +mode = "allowlist" +allowed_hosts = ["pypi.org", "*.googleapis.com"] + +[[models]] +name = "*" +upstream = "http://example.com/v1" +"#, + ) + .unwrap(); + assert_eq!(cfg.network.mode, NetworkMode::Allowlist); + assert_eq!(cfg.network.allowed_hosts.len(), 2); + assert!(NetworkPolicy::from_config(&cfg).is_ok()); + } + + #[test] + fn toml_rejects_bad_allowed_host() { + let err = ProxyConfig::from_toml_str( + r#" +listen = "127.0.0.1:1" + +[network] +mode = "allowlist" +allowed_hosts = ["https://bad.example"] + +[[models]] +name = "*" +upstream = "http://example.com/v1" +"#, + ); + assert!(err.is_err()); + } + + #[test] + fn matching_is_case_insensitive() { + let entries = vec![parse_allowed_entry("Example.COM").unwrap()]; + assert!(host_matches("example.com", &entries)); + assert!(host_matches("EXAMPLE.COM", &entries)); + } + + #[test] + fn ipv6_literal_and_cidr() { + let entries = vec![ + parse_allowed_entry("2001:db8::1").unwrap(), + parse_allowed_entry("fe80::/10").unwrap(), + ]; + assert!(host_matches("2001:db8::1", &entries)); + assert!(host_matches("fe80::abcd", &entries)); + assert!(!host_matches("2001:db8::2", &entries)); + } + + #[test] + fn listen_host_is_always_bypassed() { + let mut c = cfg(NetworkMode::NoNetwork, &[], None); + c.listen = "10.0.0.5:19081".into(); + let p = NetworkPolicy::from_config(&c).unwrap(); + assert!(assert_egress_allowed(&p, "10.0.0.5").is_ok()); + assert_eq!( + assert_egress_allowed(&p, "10.0.0.6"), + Err(DenyReason::NoNetwork) + ); + } + + #[test] + fn merges_upstream_anthropic_host() { + let mut c = cfg( + NetworkMode::Allowlist, + &[], + Some("https://api.openai.com/v1"), + ); + c.models[0].upstream_anthropic = Some("https://api.anthropic.com/v1".into()); + let p = NetworkPolicy::from_config(&c).unwrap(); + assert!(assert_egress_allowed(&p, "api.openai.com").is_ok()); + assert!(assert_egress_allowed(&p, "api.anthropic.com").is_ok()); + assert_eq!( + assert_egress_allowed(&p, "evil.example"), + Err(DenyReason::NotInAllowlist) + ); + } + + #[test] + fn allowlist_only_upstream_is_not_empty_deny() { + // empty allowed_hosts but upstream present → effective list non-empty + let p = NetworkPolicy::from_config(&cfg( + NetworkMode::Allowlist, + &[], + Some("https://api.deepseek.com/v1"), + )) + .unwrap(); + assert!(assert_egress_allowed(&p, "api.deepseek.com").is_ok()); + assert_eq!( + assert_egress_allowed(&p, "pypi.org"), + Err(DenyReason::NotInAllowlist) + ); + } + + #[test] + fn forbidden_response_mentions_host_and_reason() { + let (status, body) = forbidden_response("github.com", &DenyReason::NotInAllowlist); + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(body.contains("github.com")); + assert!(body.contains("not-in-allowlist")); + } + + #[test] + fn example_allowlist_toml_loads_multiline_hosts() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/llm-proxy/allowlist.toml"); + let cfg = ProxyConfig::from_toml_file(&path).unwrap(); + assert_eq!(cfg.network.mode, NetworkMode::Allowlist); + assert!(cfg.network.allowed_hosts.len() >= 3); + assert!(cfg.network.allowed_hosts.iter().any(|h| h == "pypi.org")); + let policy = NetworkPolicy::from_config(&cfg).unwrap(); + assert!(assert_egress_allowed(&policy, "pypi.org").is_ok()); + // upstream host merged from deepseek.toml sibling + assert!(assert_egress_allowed(&policy, "api.deepseek.com").is_ok()); + assert_eq!( + assert_egress_allowed(&policy, "evil.example"), + Err(DenyReason::NotInAllowlist) + ); + } + + #[test] + fn toml_multiline_allowed_hosts_one_per_line() { + let cfg = ProxyConfig::from_toml_str( + r#" +listen = "127.0.0.1:1" + +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", + "files.pythonhosted.org", + "*.example.com", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .unwrap(); + assert_eq!(cfg.network.allowed_hosts.len(), 3); + let p = NetworkPolicy::from_config(&cfg).unwrap(); + assert!(assert_egress_allowed(&p, "a.example.com").is_ok()); + assert!(!assert_egress_allowed(&p, "example.com").is_ok()); + } +} diff --git a/crates/persisting-capture/src/runtime/debug.rs b/crates/persisting-capture/src/runtime/debug.rs index e5e98e2..5305c47 100644 --- a/crates/persisting-capture/src/runtime/debug.rs +++ b/crates/persisting-capture/src/runtime/debug.rs @@ -109,6 +109,13 @@ pub fn log_connect(storage: &Path, target: &str, session_id: &str) { ); } +pub fn log_network_denied(storage: &Path, host: &str, mode: &str, reason: &str, session_id: &str) { + emit( + storage, + &format!("network.denied host={host} mode={mode} reason={reason} session={session_id}"), + ); +} + pub fn log_forward( storage: &Path, method: &str, diff --git a/crates/persisting-capture/tests/network_policy_http.rs b/crates/persisting-capture/tests/network_policy_http.rs new file mode 100644 index 0000000..57dea86 --- /dev/null +++ b/crates/persisting-capture/tests/network_policy_http.rs @@ -0,0 +1,365 @@ +//! End-to-end HTTP checks for Harbor-style `[network]` egress policy. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::Router; +use persisting_capture::config::ProxyConfig; +use persisting_capture::proxy::serve_with_shutdown_and_ready; +use persisting_capture::sink::SeqOnlySink; +use tokio::sync::oneshot; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +async fn spawn_mock_http() -> (u16, oneshot::Sender<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let (stop_tx, stop_rx) = oneshot::channel::<()>(); + tokio::spawn(async move { + let app = Router::new().route("/", get(|| async { "ok" })).route( + "/v1/chat/completions", + post(|| async { + axum::Json(serde_json::json!({ + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + })) + }), + ); + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = stop_rx.await; + }) + .await + .ok(); + }); + tokio::task::yield_now().await; + (port, stop_tx) +} + +async fn spawn_proxy(toml: &str) -> (String, tempfile::TempDir, oneshot::Sender<()>) { + let listen_port = free_port(); + let admin_port = free_port(); + let toml = toml + .replace("{{LISTEN}}", &format!("127.0.0.1:{listen_port}")) + .replace("{{ADMIN}}", &format!("127.0.0.1:{admin_port}")); + let cfg = ProxyConfig::from_toml_str(&toml).expect("proxy toml"); + let tmp = tempfile::tempdir().unwrap(); + let (ready_tx, ready_rx) = oneshot::channel(); + let (stop_tx, stop_rx) = oneshot::channel::<()>(); + let storage = tmp.path().to_path_buf(); + let sink: Arc = Arc::new(SeqOnlySink::new()); + tokio::spawn(async move { + let _ = + serve_with_shutdown_and_ready(cfg, storage, sink, false, Some(ready_tx), async move { + let _ = stop_rx.await; + }) + .await; + }); + ready_rx.await.expect("proxy ready"); + (format!("http://127.0.0.1:{listen_port}"), tmp, stop_tx) +} + +async fn raw_connect(proxy: &str, authority: &str) -> (StatusCode, String) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let addr: SocketAddr = proxy + .trim_start_matches("http://") + .parse() + .expect("proxy addr"); + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let req = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n"); + stream.write_all(req.as_bytes()).await.unwrap(); + + let mut buf = vec![0u8; 2048]; + let n = stream.read(&mut buf).await.unwrap(); + let text = String::from_utf8_lossy(&buf[..n]); + let status_line = text.lines().next().unwrap_or(""); + let code = status_line + .split_whitespace() + .nth(1) + .and_then(|c| c.parse::().ok()) + .unwrap_or(0); + let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string(); + (status, body) +} + +#[tokio::test] +async fn e2e_allowlist_denies_connect_to_unlisted_host() { + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let (status, body) = raw_connect(&proxy, "github.com:443").await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!(body.contains("github.com"), "{body}"); + let _ = stop.send(()); +} + +#[tokio::test] +async fn e2e_allowlist_permits_connect_to_listed_host() { + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "allowlist" +allowed_hosts = [ + "example.com", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let (status, _) = raw_connect(&proxy, "example.com:443").await; + assert_eq!(status, StatusCode::OK); + let _ = stop.send(()); +} + +#[tokio::test] +async fn e2e_no_network_denies_connect_but_allows_loopback() { + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "no-network" + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let (denied, _) = raw_connect(&proxy, "example.com:443").await; + assert_eq!(denied, StatusCode::FORBIDDEN); + let (ok, _) = raw_connect(&proxy, "127.0.0.1:9").await; + assert_eq!(ok, StatusCode::OK); + let _ = stop.send(()); +} + +#[tokio::test] +async fn e2e_allowlist_denies_absolute_uri_forward() { + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(&proxy).unwrap()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let resp = client.get("http://github.com/").send().await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = resp.text().await.unwrap(); + assert!(body.contains("github.com"), "{body}"); + let _ = stop.send(()); +} + +#[tokio::test] +async fn e2e_allowlist_allows_absolute_uri_to_local_mock() { + let (mock_port, mock_stop) = spawn_mock_http().await; + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "allowlist" +allowed_hosts = [ + "127.0.0.1", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(&proxy).unwrap()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let resp = client + .get(format!("http://127.0.0.1:{mock_port}/")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.text().await.unwrap(), "ok"); + let _ = stop.send(()); + let _ = mock_stop.send(()); +} + +#[tokio::test] +async fn e2e_relative_llm_gateway_bypasses_host_allowlist() { + let (mock_port, mock_stop) = spawn_mock_http().await; + let toml = format!( + r#" +listen = "{{{{LISTEN}}}}" +admin_listen = "{{{{ADMIN}}}}" +agent_id = "t" + +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:{mock_port}/v1" +"# + ); + let (proxy, _tmp, stop) = spawn_proxy(&toml).await; + + // No HTTP_PROXY: hit listen with a relative path (LLM gateway). + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let resp = client + .post(format!("{proxy}/v1/chat/completions")) + .header("content-type", "application/json") + .body(r#"{"model":"test","messages":[{"role":"user","content":"hi"}]}"#) + .send() + .await + .unwrap(); + // Must not be blocked by network policy (403). Upstream mock returns 200. + assert_ne!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(resp.status(), StatusCode::OK); + let _ = stop.send(()); + let _ = mock_stop.send(()); +} + +#[tokio::test] +async fn e2e_public_mode_allows_absolute_uri_forward() { + let (mock_port, mock_stop) = spawn_mock_http().await; + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "public" + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(&proxy).unwrap()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let resp = client + .get(format!("http://127.0.0.1:{mock_port}/")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let _ = stop.send(()); + let _ = mock_stop.send(()); +} + +#[tokio::test] +async fn e2e_absolute_uri_llm_path_still_checks_host() { + let (proxy, _tmp, stop) = spawn_proxy( + r#" +listen = "{{LISTEN}}" +admin_listen = "{{ADMIN}}" +agent_id = "t" + +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", +] + +[[models]] +name = "*" +upstream = "http://127.0.0.1:9/v1" +"#, + ) + .await; + + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::all(&proxy).unwrap()) + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + // Absolute-URI LLM path to an unlisted host must be denied before capture/upstream. + let resp = client + .post("http://api.openai.com/v1/chat/completions") + .header("content-type", "application/json") + .body(r#"{"model":"gpt","messages":[{"role":"user","content":"x"}]}"#) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = resp.text().await.unwrap(); + assert!(body.contains("api.openai.com"), "{body}"); + let _ = stop.send(()); +} diff --git a/docs/src/guide/capture.md b/docs/src/guide/capture.md index ab3eb36..0df4e0e 100644 --- a/docs/src/guide/capture.md +++ b/docs/src/guide/capture.md @@ -67,11 +67,27 @@ Codex: replace `claude` with `codex`. Custom agent: `... -- python3 your_agent.p | Flag | Meaning | |------|---------| | `-o DIR` | Store root (default `.persisting/capture`) | -| `-c FILE` | Proxy TOML (`listen`, `models`, upstream) | +| `-c FILE` | Proxy TOML (`listen`, `models`, upstream; optional `[network]`) | | `-f md` | Markdown only (default) | | `-f vortex` | Vortex canonical + same live Markdown; reconcile md ↔ Vortex | | `--debug` | Log proxied requests to stderr + `.capture/debug.log` | +Optional Harbor-aligned egress control — see [`examples/llm-proxy/allowlist.toml`](../../examples/llm-proxy/allowlist.toml): + +```toml +[network] +mode = "allowlist" # public | no-network | allowlist +allowed_hosts = [ + "pypi.org", + "files.pythonhosted.org", + "github.com", + "api.github.com", + "registry.npmjs.org", +] +``` + +Applies to forward-proxy traffic (`CONNECT` + absolute-URI HTTP). Default `public` preserves prior behavior. Relative-path LLM gateway on `listen` is not filtered; configured `[[models]]` upstream hosts are auto-merged into the allowlist. See the [Chinese guide](capture.zh.md) for full semantics. + `traj capture` is **in-process** — no `traj proxy stop` needed when the child exits. --- diff --git a/docs/src/guide/capture.zh.md b/docs/src/guide/capture.zh.md index babe324..49a1dec 100644 --- a/docs/src/guide/capture.zh.md +++ b/docs/src/guide/capture.zh.md @@ -87,11 +87,44 @@ persisting traj capture -o ./store -c proxy.toml -f md -- python3 agent.py export DEEPSEEK_API_KEY=sk-... ``` -配置见 [`examples/llm-proxy/deepseek.toml`](../../examples/llm-proxy/deepseek.toml): +配置见 [`examples/llm-proxy/`](../../examples/llm-proxy/): + +| 文件 | 用途 | +|------|------| +| [`deepseek.toml`](../../examples/llm-proxy/deepseek.toml) | DeepSeek 双协议(默认 `public`) | +| [`multi-provider.toml`](../../examples/llm-proxy/multi-provider.toml) | 多厂商按 model 前缀路由 | +| [`allowlist.toml`](../../examples/llm-proxy/allowlist.toml) | Harbor 风格 `allowlist` 出口控制 | - `listen`:Capture 代理监听地址(默认 `127.0.0.1:19081`) - `agent_id`:轨迹目录名(如 `deepseek-proxy`) - `[[models]]`:上游路由(OpenAI / Anthropic 双协议) +- `[network]`(可选):Harbor 风格出口控制,见下方 + +#### 网络出口策略(可选) + +`[network]` 对齐 [Harbor Network Policy](https://www.harborframework.com/docs/tasks/network-policy) 的三种模式,但强制手段不同:Capture 在显式 `HTTP_PROXY` 的 `CONNECT` / 绝对 URI 转发入口拦截,不做 nft/gost 透明劫持。仍依赖子进程遵守代理环境变量。 + +| `mode` | 行为 | +|--------|------| +| `public`(默认) | 转发代理全放行(与旧行为一致) | +| `no-network` | 拒绝非本机出站 | +| `allowlist` | 仅允许 `allowed_hosts`;列表为空则全拒。配置的 `[[models]].upstream` / `upstream_anthropic` host 会自动并入白名单 | + +`allowed_hosts` 条目:精确 hostname、`*.example.com`(不含 apex)、IPv4/IPv6 literal、CIDR;不要写 URL、端口或路径。`example.com` 不含 `www.example.com`;需要两者时同时写 `example.com` 与 `*.example.com`。打到 `listen` 的相对路径 LLM 网关不受名单约束;`localhost` / `127.0.0.1` / `::1` 始终旁路。 + +完整示例:[allowlist.toml](../../examples/llm-proxy/allowlist.toml)。片段: + +```toml +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", + "files.pythonhosted.org", + "github.com", + "api.github.com", + "registry.npmjs.org", +] +``` ### 2.2 用 `traj capture` 包装 Agent diff --git a/examples/README.md b/examples/README.md index ee30999..366954d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -30,6 +30,6 @@ python -m examples.06_tiered_lazy_prefetch_eviction |------|------| | [**capture-walkthrough/**](capture-walkthrough/) | `./run.sh` — Mock LLM + `traj capture` + 校验 | | [**trajectory-tlv/**](trajectory-tlv/) | 静态 `trajectory.tlv.md`(裸对话正文),供 replay 与设计对照 | -| [**llm-proxy/**](llm-proxy/) | 真实 LLM 代理配置(DeepSeek、多厂商) | +| [**llm-proxy/**](llm-proxy/) | 真实 LLM 代理配置(DeepSeek、多厂商、`allowlist` 出口白名单) | `capture-walkthrough/store/` 为运行 demo 时本地生成,已在该目录 `.gitignore` 中忽略。 diff --git a/examples/llm-proxy/README.md b/examples/llm-proxy/README.md new file mode 100644 index 0000000..7e149fd --- /dev/null +++ b/examples/llm-proxy/README.md @@ -0,0 +1,11 @@ +# LLM proxy configs + +TOML for `persisting traj capture` / `traj proxy` (`-c`). + +| File | Notes | +|------|--------| +| [deepseek.toml](deepseek.toml) | DeepSeek OpenAI + Anthropic dual upstream (default `public`) | +| [multi-provider.toml](multi-provider.toml) | Route by model prefix (DeepSeek / Claude / Gemini / GPT) | +| [allowlist.toml](allowlist.toml) | Same DeepSeek routing with Harbor-style `[network] mode = "allowlist"` | + +`allowed_hosts` should list **one host per line**. In `allowlist` mode, hosts from `[[models]].upstream` / `upstream_anthropic` are merged automatically. diff --git a/examples/llm-proxy/allowlist.toml b/examples/llm-proxy/allowlist.toml new file mode 100644 index 0000000..09c54a6 --- /dev/null +++ b/examples/llm-proxy/allowlist.toml @@ -0,0 +1,37 @@ +# Harbor 风格出口白名单 — 转发代理仅允许下列 host +# +# export DEEPSEEK_API_KEY=sk-... +# persisting traj capture -o ./store -c examples/llm-proxy/allowlist.toml -f md -- claude +# +# 说明: +# - mode = "allowlist":未列出的 CONNECT / 绝对 URI 出站返回 403 +# - [[models]] upstream host(此处 api.deepseek.com)会自动并入白名单 +# - 相对路径打到 listen 的 LLM 网关不受名单约束 +# - localhost / 127.0.0.1 / ::1 始终旁路 +# +# 另见:deepseek.toml(默认 public)、multi-provider.toml + +listen = "127.0.0.1:19081" +admin_listen = "127.0.0.1:9876" +agent_id = "deepseek-allowlist" + +[network] +mode = "allowlist" +allowed_hosts = [ + "pypi.org", + "files.pythonhosted.org", + "github.com", + "api.github.com", + "registry.npmjs.org", +] + +[[models]] +name = "deepseek-v4-flash" +provider = "openai" +upstream = "https://api.deepseek.com/v1" +upstream_anthropic = "https://api.deepseek.com/anthropic/v1" +api_key_env = "DEEPSEEK_API_KEY" + +[[models]] +name = "*" +forward = "deepseek-v4-flash" diff --git a/examples/llm-proxy/deepseek.toml b/examples/llm-proxy/deepseek.toml index a1e8356..3fd6d0d 100644 --- a/examples/llm-proxy/deepseek.toml +++ b/examples/llm-proxy/deepseek.toml @@ -10,11 +10,20 @@ # 任意客户端 model 通过 forward 改写为 deepseek-chat 再转发。 # # capture_level = "dialogue" # summary | dialogue (default) | full +# +# 出口控制默认 public(全放行)。白名单示例见 allowlist.toml。 listen = "127.0.0.1:19081" admin_listen = "127.0.0.1:9876" agent_id = "deepseek-proxy" +# [network] +# mode = "public" # public | no-network | allowlist +# allowed_hosts = [ +# "pypi.org", +# "files.pythonhosted.org", +# ] + [[models]] name = "deepseek-v4-flash" provider = "openai" diff --git a/examples/llm-proxy/multi-provider.toml b/examples/llm-proxy/multi-provider.toml index d780a1e..393bebc 100644 --- a/examples/llm-proxy/multi-provider.toml +++ b/examples/llm-proxy/multi-provider.toml @@ -1,4 +1,14 @@ # 多厂商透传路由 — 按客户端 model 前缀选 upstream,body/token 不变 +# +# 出口控制默认 public。收紧示例: +# [network] +# mode = "allowlist" +# allowed_hosts = [ +# "pypi.org", +# "files.pythonhosted.org", +# ] +# (各 [[models]] upstream host 在 allowlist 模式下会自动并入) +# 完整白名单模板见 allowlist.toml。 listen = "127.0.0.1:8080" admin_listen = "127.0.0.1:9876"