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"