diff --git a/CONTEXT.md b/CONTEXT.md index 2e8c38a..0b6db70 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -165,3 +165,16 @@ The persistence behind the Mapping Table. v1 uses a local SQLite file with appli ## Mapping Retention(映射留存) A mapping expires after 90 days of inactivity by default (no write or restore). Each Creator also has a hard cap (100,000 rows); overflow is evicted LRU. The Management UI can change TTL and wipe a Creator prefix. It cannot display plaintext. + + +## Egress Proxy(出站代理) + +Optional HTTP/HTTPS/SOCKS5 URL in config.toml [egress] proxy_url. The model-facing reqwest client uses it for Upstream forwards. Empty means direct. Changing it requires a process restart in v1. + +## Header Profile(请求头配置) + +[headers] profile: region_neutral (default) forces Accept-Language to the configured value on outbound model requests; passthrough leaves client language headers alone (aside from Accept-Encoding: identity). Auth headers are never rewritten. + +## Rule Pack(规则组) + +Built-in rules are grouped into secrets and region packs under [packs]. Packs can be disabled independently. Custom rules in config still override the live set when non-empty. diff --git a/Cargo.lock b/Cargo.lock index 1df92c5..d8d697b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,23 +645,21 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", - "potential_utf", - "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.3.0" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", @@ -670,62 +668,99 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" [[package]] name = "icu_properties" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", "icu_collections", - "icu_locale_core", + "icu_locid_transform", "icu_properties_data", "icu_provider", - "zerotrie", + "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" [[package]] name = "icu_provider" -version = "2.3.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", - "icu_locale_core", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", "writeable", "yoke", "zerofrom", - "zerotrie", "zerovec", ] +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "idna" version = "1.1.0" @@ -739,9 +774,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", @@ -814,9 +849,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "log" @@ -911,15 +946,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1522,9 +1548,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", @@ -1758,6 +1784,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1772,7 +1804,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "veil" -version = "0.3.12" +version = "0.3.14" dependencies = [ "axum", "base64", @@ -1799,7 +1831,7 @@ dependencies = [ [[package]] name = "veil-engine" -version = "0.3.12" +version = "0.3.14" dependencies = [ "fancy-regex", "hex", @@ -1815,7 +1847,7 @@ dependencies = [ [[package]] name = "veil-store" -version = "0.3.12" +version = "0.3.14" dependencies = [ "aes-gcm", "hkdf", @@ -2080,18 +2112,25 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" -version = "0.6.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yoke" -version = "0.8.3" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ + "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2099,9 +2138,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", @@ -2170,22 +2209,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - [[package]] name = "zerovec" -version = "0.11.7" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", @@ -2194,13 +2222,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "3e3c6377872d72510393f688a555d7097b0f741995c7a00f0407f786dd486b2d" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9e11b5b..7580f42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ tempfile = "3" toml = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util"] } axum = "0.8" -reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"] } http-body-util = "0.1" bytes = "1" rust-embed = "8" diff --git a/README.md b/README.md index fa024db..9f7eb8b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Veil -**Hide passwords, tokens, and other secrets when you use large models. Works with the tools already on your machine — one-click install.** +**Lower geo/environment risk signals on Claude-style official subscriptions; also hide passwords and tokens. One-click install for everyday tools.** + +This does not guarantee you will not be banned. Egress IP needs an outbound proxy; bodies and headers such as `Accept-Language` are rewritten via rule packs and header profile. You drop `.env` into the chat so it can fix a bug. It does — and the password is already sitting on someone else's server. @@ -73,7 +75,7 @@ Company relay / LiteLLM: set the relay root on the **Upstream** page; the key in Open the console → **Rules**. -Out of the box it catches private keys, API keys, tokens, database URLs, login passwords, mainland mobile numbers, national IDs, and IPs. Email is off — too many false hits. +Built-in packs: `secrets` (keys, tokens, connection strings, passwords, mainland mobile/IDs, IPs) and `region` (time zones like Asia/Shanghai, locales like zh-CN). Email is off by default. Tune `[packs]`, `[egress]`, and `[headers]` in `config.toml`; restart after proxy/header changes. Flip a switch to pause a rule. Open a row to edit the regex, word list, or priority. Add your own at the bottom: project codenames, internal names become `{{CODENAME_…}}`. diff --git a/README.zh-CN.md b/README.zh-CN.md index 24e0921..e81dedd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,8 @@ # Veil -**使用大模型时,隐藏你的密码、token 等敏感信息。适配市面上各种工具,一键安装即可用。** +**降低 Claude 等官方订阅里容易暴露地区 / 环境的风险信号;顺带隐藏密码、token。适配常见工具,一键安装。** + +不承诺「装了就不封号」。出口 IP 需配置出站代理;正文与 `Accept-Language` 等由规则组和头像配置改写。 你把 `.env` 丢进对话框,是为了让它帮你改代码。它改是改了——密码也已经躺在别人的服务器上。 @@ -75,7 +77,7 @@ Cursor、Python、环境变量等细接法见 [使用说明](docs/使用.md)。 打开管理页 → **规则**。 -出厂会拦:私钥、API Key、Token、数据库连接串、登录密码、手机号、身份证、IP。邮箱默认关掉,误报太多。 +出厂规则组分两包:`secrets`(私钥、API Key、Token、连接串、密码、手机号、身份证、IP)与 `region`(时区如 Asia/Shanghai、locale 如 zh-CN)。邮箱默认关掉。可在 `config.toml` 的 `[packs]` / `[egress]` / `[headers]` 调整;改代理或头配置后需重启。 点开关就能停一条。点开一行能改正则、词表、优先级。底下可以加自己的:项目代号、内部人名,化名就会变成 `{{CODENAME_…}}`。 diff --git a/crates/veil-engine/src/builtin.rs b/crates/veil-engine/src/builtin.rs index 3e5fd9b..5d97b2a 100644 --- a/crates/veil-engine/src/builtin.rs +++ b/crates/veil-engine/src/builtin.rs @@ -1,17 +1,39 @@ use crate::rules::{Allowlist, Rule, RuleSet}; -/// Built-in rule pack with the default allowlist. +/// Which built-in rule packs to include. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PackFlags { + pub secrets: bool, + pub region: bool, +} + +impl Default for PackFlags { + fn default() -> Self { + Self { + secrets: true, + region: true, + } + } +} + +/// Built-in rule pack with the default allowlist (secrets + region). pub fn builtin_ruleset() -> RuleSet { - let mut email = Rule::regex( - "email", - "EMAIL", - r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", - 80, - ); - email.enabled = false; - - RuleSet::new( - vec![ + builtin_ruleset_with_packs(PackFlags::default()) +} + +/// Built-in rules filtered by pack flags. +pub fn builtin_ruleset_with_packs(packs: PackFlags) -> RuleSet { + let mut rules = Vec::new(); + + if packs.secrets { + let mut email = Rule::regex( + "email", + "EMAIL", + r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", + 80, + ); + email.enabled = false; + rules.extend([ Rule::regex( "pem", "PEM", @@ -46,9 +68,31 @@ pub fn builtin_ruleset() -> RuleSet { Rule::regex("phone", "PHONE", r"(? Allowlist { @@ -57,7 +101,7 @@ fn default_allowlist() -> Allowlist { #[cfg(test)] mod tests { - use super::builtin_ruleset; + use super::{builtin_ruleset, builtin_ruleset_with_packs, PackFlags}; fn types(text: &str) -> Vec { builtin_ruleset() @@ -100,6 +144,25 @@ mod tests { assert!(t.contains(&"IDCARD".into())); } + #[test] + fn region_timezone_and_locale() { + let t = types("tz Asia/Shanghai lang zh-CN and zh_CN plus zh-Hans"); + assert!(t.contains(&"TZ".into()), "{t:?}"); + assert!(t.iter().filter(|x| *x == "LOCALE").count() >= 1, "{t:?}"); + } + + #[test] + fn packs_can_disable_region() { + let set = builtin_ruleset_with_packs(PackFlags { + secrets: true, + region: false, + }); + assert!(set + .find_hits("Asia/Shanghai zh-CN") + .iter() + .all(|h| h.type_prefix != "TZ" && h.type_prefix != "LOCALE")); + } + #[test] fn connection_string_beats_inner_ip() { let hits = builtin_ruleset().find_hits("postgres://u:s3cret@10.1.2.3:5432/app"); diff --git a/crates/veil/src/admin/api.rs b/crates/veil/src/admin/api.rs index 5243531..efa80dd 100644 --- a/crates/veil/src/admin/api.rs +++ b/crates/veil/src/admin/api.rs @@ -7,7 +7,6 @@ use axum::response::IntoResponse; use axum::Json; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use veil_engine::builtin::builtin_ruleset; use crate::config::{generate_admin_token, Config, RuleConfig, RuleSource}; use crate::master_key::{create_secret_file, load_or_create}; @@ -67,6 +66,10 @@ pub async fn get_status(State(state): State, headers: HeaderMap) -> .into_iter() .rev() .find_map(|e| e.error_class); + let rule_count = crate::ruleset::ruleset_from_config(&cfg) + .rules() + .len() + .max(cfg.rules.len()); Json(StatusBody { product: "Veil", bind: cfg.bind, @@ -78,7 +81,7 @@ pub async fn get_status(State(state): State, headers: HeaderMap) -> mapping_ttl_days: cfg.mapping_ttl_days, request_body_limit_mib: cfg.request_body_limit_mib, master_key_set, - rule_count: builtin_ruleset().rules().len().max(cfg.rules.len()), + rule_count, last_error_class, version: crate::update::current_version(), }) @@ -106,7 +109,7 @@ pub async fn get_rules(State(state): State, headers: HeaderMap) -> i .unwrap_or_else(|e| e.into_inner()) .clone(); if cfg.rules.is_empty() { - let views: Vec = builtin_ruleset() + let views: Vec = crate::ruleset::ruleset_from_config(&cfg) .rules() .iter() .map(|r| RuleView { @@ -199,6 +202,20 @@ pub struct UpstreamBody { pub openai_responses_upstream: String, #[serde(default)] pub routes: Vec, + #[serde(default)] + pub egress_proxy_url: String, + #[serde(default)] + pub header_profile: String, + #[serde(default)] + pub accept_language: String, + #[serde(default = "bool_true_pack")] + pub pack_secrets: bool, + #[serde(default = "bool_true_pack")] + pub pack_region: bool, +} + +fn bool_true_pack() -> bool { + true } pub async fn get_upstream( @@ -213,11 +230,20 @@ pub async fn get_upstream( .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); + let profile = match cfg.headers.profile { + crate::config::HeaderProfile::RegionNeutral => "region_neutral", + crate::config::HeaderProfile::Passthrough => "passthrough", + }; Json(UpstreamBody { anthropic_upstream: cfg.anthropic_upstream, openai_completions_upstream: cfg.openai_completions_upstream, openai_responses_upstream: cfg.openai_responses_upstream, routes: cfg.routes, + egress_proxy_url: cfg.egress.proxy_url, + header_profile: profile.into(), + accept_language: cfg.headers.accept_language, + pack_secrets: cfg.packs.secrets, + pack_region: cfg.packs.region, }) .into_response() } @@ -234,6 +260,19 @@ pub async fn put_upstream( cfg.anthropic_upstream = body.anthropic_upstream; cfg.openai_completions_upstream = body.openai_completions_upstream; cfg.openai_responses_upstream = body.openai_responses_upstream; + cfg.egress.proxy_url = body.egress_proxy_url.trim().to_string(); + cfg.headers.profile = match body.header_profile.trim().to_ascii_lowercase().as_str() { + "passthrough" => crate::config::HeaderProfile::Passthrough, + _ => crate::config::HeaderProfile::RegionNeutral, + }; + if !body.accept_language.trim().is_empty() { + cfg.headers.accept_language = body.accept_language; + } + cfg.packs.secrets = body.pack_secrets; + cfg.packs.region = body.pack_region; + if let Err(e) = cfg.validate_egress() { + return (StatusCode::BAD_REQUEST, e).into_response(); + } if !body.routes.is_empty() { for incoming in body.routes { if let Some(row) = cfg.routes.iter_mut().find(|r| r.id == incoming.id) { @@ -293,7 +332,11 @@ pub async fn get_clients(State(state): State, headers: HeaderMap) -> if let Err(code) = require_token(&state, &headers, false) { return code.into_response(); } - let cfg = state.config.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let cfg = state + .config + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); let list: Vec = crate::clients::CLIENTS .iter() .map(|spec| { diff --git a/crates/veil/src/config.rs b/crates/veil/src/config.rs index 24024e1..1846395 100644 --- a/crates/veil/src/config.rs +++ b/crates/veil/src/config.rs @@ -68,6 +68,61 @@ impl Default for ClientRoute { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HeaderProfile { + #[default] + RegionNeutral, + Passthrough, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct EgressConfig { + /// HTTP/HTTPS/SOCKS5 proxy for upstream forwards. Empty = direct. + pub proxy_url: String, +} + +impl Default for EgressConfig { + fn default() -> Self { + Self { + proxy_url: String::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HeadersConfig { + pub profile: HeaderProfile, + pub accept_language: String, +} + +impl Default for HeadersConfig { + fn default() -> Self { + Self { + profile: HeaderProfile::RegionNeutral, + accept_language: "en-US,en;q=0.9".into(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PacksConfig { + pub secrets: bool, + pub region: bool, +} + +impl Default for PacksConfig { + fn default() -> Self { + Self { + secrets: true, + region: true, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -97,6 +152,12 @@ pub struct Config { /// Optional prompt injection (off by default). #[serde(default)] pub alias_hint: bool, + #[serde(default)] + pub egress: EgressConfig, + #[serde(default)] + pub headers: HeadersConfig, + #[serde(default)] + pub packs: PacksConfig, } fn default_allowlist() -> Vec { @@ -129,6 +190,9 @@ impl Default for Config { rules: Vec::new(), allowlist: default_allowlist(), alias_hint: false, + egress: EgressConfig::default(), + headers: HeadersConfig::default(), + packs: PacksConfig::default(), } } } @@ -167,6 +231,9 @@ impl Config { apply_env_overrides(&mut cfg); cfg.clamp(); + if let Err(e) = cfg.validate_egress() { + return Err(Error::Setup(e)); + } Ok(cfg) } @@ -215,6 +282,29 @@ impl Config { fn clamp(&mut self) { self.request_body_limit_mib = self.request_body_limit_mib.min(128); + self.egress.proxy_url = self.egress.proxy_url.trim().to_string(); + if self.headers.accept_language.trim().is_empty() { + self.headers.accept_language = HeadersConfig::default().accept_language; + } + } + + /// Validate egress.proxy_url if set. Call after load/save edits. + pub fn validate_egress(&self) -> Result<(), String> { + let url = self.egress.proxy_url.trim(); + if url.is_empty() { + return Ok(()); + } + let lower = url.to_ascii_lowercase(); + if !(lower.starts_with("http://") + || lower.starts_with("https://") + || lower.starts_with("socks5://") + || lower.starts_with("socks5h://")) + { + return Err(format!( + "egress.proxy_url must be http(s) or socks5(h), got {url}" + )); + } + Ok(()) } } @@ -251,6 +341,19 @@ mod tests { use crate::test_env::EnvLock; use sha2::{Digest, Sha256}; + #[test] + fn egress_and_header_defaults() { + let cfg = Config::default(); + assert!(cfg.egress.proxy_url.is_empty()); + assert_eq!(cfg.headers.profile, super::HeaderProfile::RegionNeutral); + assert_eq!(cfg.headers.accept_language, "en-US,en;q=0.9"); + assert!(cfg.packs.secrets && cfg.packs.region); + assert!(cfg.validate_egress().is_ok()); + let mut bad = cfg.clone(); + bad.egress.proxy_url = "ftp://x".into(); + assert!(bad.validate_egress().is_err()); + } + #[test] fn config_toml_roundtrip_defaults() { let cfg = Config::default(); diff --git a/crates/veil/src/lib.rs b/crates/veil/src/lib.rs index 00eb200..3e7a208 100644 --- a/crates/veil/src/lib.rs +++ b/crates/veil/src/lib.rs @@ -9,10 +9,11 @@ pub mod logging; pub mod master_key; pub mod process; pub mod proxy; +pub mod ruleset; pub mod setup; pub mod update; -pub use config::{Config, Mode, RuleConfig}; +pub use config::{Config, HeaderProfile, Mode, RuleConfig}; pub use data_dir::default_data_dir; pub use master_key::load_or_create; diff --git a/crates/veil/src/main.rs b/crates/veil/src/main.rs index 36b471d..48b743e 100644 --- a/crates/veil/src/main.rs +++ b/crates/veil/src/main.rs @@ -156,8 +156,18 @@ async fn serve(data_dir: &std::path::Path, cfg: &Config) -> Result<(), Box, pub alias_hint: bool, + pub header_profile: HeaderProfile, + pub accept_language: String, } impl AppState { @@ -36,21 +39,37 @@ impl AppState { upstream: UpstreamConfig, body_limit_bytes: usize, ) -> Self { - Self { + Self::with_options( + store, + upstream, + body_limit_bytes, + Arc::new(builtin_ruleset()), + "", + HeadersConfig::default(), + ) + .expect("default AppState") + } + + pub fn with_options( + store: Arc, + upstream: UpstreamConfig, + body_limit_bytes: usize, + rules: Arc, + proxy_url: &str, + headers: HeadersConfig, + ) -> Result { + Ok(Self { upstream, body_limit_bytes, store, - rules: Arc::new(builtin_ruleset()), - client: reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .tcp_nodelay(true) - .pool_max_idle_per_host(16) - .build() - .expect("reqwest client"), + rules, + client: build_http_client(proxy_url)?, traffic: TrafficLog::default(), default_family: None, alias_hint: false, - } + header_profile: headers.profile, + accept_language: headers.accept_language, + }) } pub fn with_traffic(mut self, traffic: TrafficLog) -> Self { @@ -59,6 +78,19 @@ impl AppState { } } +fn build_http_client(proxy_url: &str) -> Result { + let mut builder = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .tcp_nodelay(true) + .pool_max_idle_per_host(16); + let proxy_url = proxy_url.trim(); + if !proxy_url.is_empty() { + let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| format!("bad proxy_url: {e}"))?; + builder = builder.proxy(proxy); + } + builder.build().map_err(|e| format!("http client: {e}")) +} + pub async fn proxy_handler( State(state): State, req: axum::http::Request, @@ -148,12 +180,22 @@ async fn proxy_inner( } let mut builder = state.client.request(method, &url); + let force_al = state.header_profile == HeaderProfile::RegionNeutral; + let mut wrote_al = false; for (name, value) in headers.iter() { if skip_request_header(name) { continue; } + if force_al && name == header::ACCEPT_LANGUAGE { + builder = builder.header(header::ACCEPT_LANGUAGE, state.accept_language.as_str()); + wrote_al = true; + continue; + } builder = builder.header(name, value); } + if force_al && !wrote_al { + builder = builder.header(header::ACCEPT_LANGUAGE, state.accept_language.as_str()); + } builder = builder.header(header::ACCEPT_ENCODING, "identity"); if let Ok(host) = host_from_url(&url) { builder = builder.header(header::HOST, host); @@ -450,3 +492,25 @@ fn restore_response( Ok(bytes.clone()) } } + +#[cfg(test)] +mod header_tests { + use super::*; + #[test] + fn build_client_accepts_empty_proxy() { + assert!(build_http_client("").is_ok()); + } + + #[test] + fn build_client_rejects_empty_scheme_proxy() { + // Config-level validate_egress catches bad schemes; reqwest may parse odd hosts. + assert!(crate::config::Config { + egress: crate::config::EgressConfig { + proxy_url: "ftp://127.0.0.1:1".into() + }, + ..Default::default() + } + .validate_egress() + .is_err()); + } +} diff --git a/crates/veil/src/ruleset.rs b/crates/veil/src/ruleset.rs new file mode 100644 index 0000000..995b5e4 --- /dev/null +++ b/crates/veil/src/ruleset.rs @@ -0,0 +1,82 @@ +use veil_engine::builtin::{builtin_ruleset_with_packs, PackFlags}; +use veil_engine::rules::{Allowlist, Rule, RuleSet}; + +use crate::config::{Config, RuleConfig}; + +/// Build the live rule set from pack flags and optional config overrides. +pub fn ruleset_from_config(cfg: &Config) -> RuleSet { + let packs = PackFlags { + secrets: cfg.packs.secrets, + region: cfg.packs.region, + }; + let base = builtin_ruleset_with_packs(packs); + let allowlist = Allowlist::from(cfg.allowlist.clone()); + + if cfg.rules.is_empty() { + // Rebuild with config allowlist while keeping built-in rules. + return RuleSet::new(base.rules().to_vec(), allowlist); + } + + let mut rules = Vec::new(); + for row in &cfg.rules { + if let Some(rule) = rule_from_config(row) { + rules.push(rule); + } + } + // If conversion yielded nothing, fall back to packs. + if rules.is_empty() { + return RuleSet::new(base.rules().to_vec(), allowlist); + } + RuleSet::new(rules, allowlist) +} + +fn rule_from_config(row: &RuleConfig) -> Option { + let mut rule = if row.id == "ip" || row.type_prefix.eq_ignore_ascii_case("IP") { + // Prefer parse-based IP matcher for the built-in ip row. + if row.pattern.is_none() && row.words.as_ref().map(|w| w.is_empty()).unwrap_or(true) { + Rule::ip(&row.id, &row.type_prefix, row.priority) + } else if let Some(words) = &row.words { + if !words.is_empty() { + Rule::dictionary(&row.id, &row.type_prefix, words.clone(), row.priority) + } else if let Some(pat) = &row.pattern { + Rule::regex(&row.id, &row.type_prefix, pat, row.priority) + } else { + Rule::ip(&row.id, &row.type_prefix, row.priority) + } + } else if let Some(pat) = &row.pattern { + Rule::regex(&row.id, &row.type_prefix, pat, row.priority) + } else { + Rule::ip(&row.id, &row.type_prefix, row.priority) + } + } else if let Some(words) = &row.words { + if !words.is_empty() { + Rule::dictionary(&row.id, &row.type_prefix, words.clone(), row.priority) + } else if let Some(pat) = &row.pattern { + Rule::regex(&row.id, &row.type_prefix, pat, row.priority) + } else { + return None; + } + } else if let Some(pat) = &row.pattern { + Rule::regex(&row.id, &row.type_prefix, pat, row.priority) + } else { + return None; + }; + rule.enabled = row.enabled; + let _ = &row.source; + Some(rule) +} + +#[cfg(test)] +mod tests { + use super::ruleset_from_config; + use crate::config::Config; + + #[test] + fn default_config_includes_region() { + let cfg = Config::default(); + let set = ruleset_from_config(&cfg); + let hits = set.find_hits("Asia/Shanghai and zh-CN"); + assert!(hits.iter().any(|h| h.type_prefix == "TZ")); + assert!(hits.iter().any(|h| h.type_prefix == "LOCALE")); + } +} diff --git a/crates/veil/tests/admin_api.rs b/crates/veil/tests/admin_api.rs index f787722..e74a341 100644 --- a/crates/veil/tests/admin_api.rs +++ b/crates/veil/tests/admin_api.rs @@ -147,7 +147,10 @@ async fn clients_lists_all_adapters() { .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let list: Vec = resp.json().await.unwrap(); - let ids: Vec<_> = list.iter().map(|c| c["id"].as_str().unwrap().to_string()).collect(); + let ids: Vec<_> = list + .iter() + .map(|c| c["id"].as_str().unwrap().to_string()) + .collect(); assert!(ids.contains(&"claude".into())); assert!(ids.contains(&"codex".into())); assert!(ids.contains(&"trae".into())); diff --git a/docs/redesign-anti-ban.md b/docs/redesign-anti-ban.md new file mode 100644 index 0000000..2eece92 --- /dev/null +++ b/docs/redesign-anti-ban.md @@ -0,0 +1,59 @@ +# Veil redesign: Claude risk-signal reduction + +Status: draft for implementation. Marketing claim: lower geo/environment signals that correlate with Claude account risk. Do not promise "will not ban." + +## Positioning + +- Primary: local gateway that reduces China/geo fingerprints reaching Anthropic (and similar upstreams). +- Secondary: existing secret redaction (passwords, tokens, IPs). +- Honest scope: body + selected headers + egress IP via outbound proxy. Not absolute anonymity. + +## MVP features + +1. **Outbound proxy** — HTTP/HTTPS/SOCKS5 URL in config; reqwest Client uses it for upstream forwards only (not management UI). +2. **Rule packs** — group built-in rules: `secrets` (current), `region` (new, on by default for anti-ban narrative). UI toggles packs; rules remain individually overridable. +3. **Header rewrite** — configurable outbound header mutations. Default region profile: + - Set or replace `Accept-Language` to a non-CN value (e.g. `en-US,en;q=0.9`) + - Optionally strip/replace other locale headers if present + - Keep auth headers untouched (`x-api-key`, `Authorization`, `api-key`, cookies) +4. **Passthrough** — unchanged: non-matching body text still forwards; fail-closed only when scan cannot prove cleanliness or mapping persist fails. + +## Region pack (v1 matchers) + +- Timezone IDs: `Asia/Shanghai`, `Asia/Chongqing`, `Asia/Urumqi`, `Asia/Harbin`, `PRC`, `CST` in obvious timezone contexts (prefer dictionary over bare `CST`) +- Locale tokens: `zh-CN`, `zh_CN`, `zh-Hans`, `chinese` in Accept-Language-like body strings +- Chinese mainland mobile / ID already in secrets pack (keep; also geo-relevant) +- IP rule already exists (keep in secrets or dual-tag) +- Optional dictionary: common province/city names — ship small, off by default if high false positives + +## Config sketch (`config.toml`) + +```toml +[egress] +# empty = direct +proxy_url = "" # e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 + +[headers] +# profile: "passthrough" | "region_neutral" +profile = "region_neutral" +accept_language = "en-US,en;q=0.9" + +[packs] +secrets = true +region = true +``` + +## Code touch points + +- `crates/veil/src/config.rs` — new sections +- `crates/veil/src/proxy/forward.rs` — Client builder with proxy; apply header profile after skip_request_header +- `crates/veil-engine/src/builtin.rs` (+ rules) — region pack rules +- Admin API + UI Upstream/Rules — proxy field, pack toggles, header profile +- README / README.zh-CN / CONTEXT.md — reposition copy +- Tests: proxy client construction, Accept-Language rewrite, region rule hits + +## Non-goals (MVP) + +- Full TLS MITM / custom CA +- Guaranteed ban immunity +- Rewriting Chinese natural language in chat to English