fix(ai): bypass proxies for loopback requests, fix IPv6 parsing

This commit is contained in:
Rendegou
2026-10-18 08:33:55 +08:00
committed by GitHub
parent 3ebaf047e0
commit 95eb27dab1
11 changed files with 2510 additions and 1 deletions
+1
View File
@@ -20,4 +20,5 @@ httpdate = "1"
tempfile = "3"
[dev-dependencies]
reqwest = { version = "0.12", default-features = false, features = ["system-proxy"] }
tokio = { version = "1", features = ["test-util", "rt-multi-thread"] }
+28 -1
View File
@@ -1944,6 +1944,7 @@ fn ai_endpoint_is_loopback(config: &AiConfig) -> bool {
let Some(host) = url.host_str() else {
return false;
};
let host = host.strip_prefix('[').and_then(|host| host.strip_suffix(']')).unwrap_or(host);
host.eq_ignore_ascii_case("localhost") || host.parse::<IpAddr>().map(|addr| addr.is_loopback()).unwrap_or(false)
}
@@ -1952,7 +1953,10 @@ pub fn build_ai_http_client(config: &AiConfig, timeout_secs: u64) -> Result<reqw
if config.skip_tls_verify {
builder = builder.danger_accept_invalid_certs(true);
}
if config.proxy_enabled && !config.proxy_url.trim().is_empty() && !ai_endpoint_is_loopback(config) {
if ai_endpoint_is_loopback(config) {
// Skipping an explicit proxy alone still allows environment and system proxies.
builder = builder.no_proxy();
} else if config.proxy_enabled && !config.proxy_url.trim().is_empty() {
let proxy_url = normalize_ai_proxy_url(&config.proxy_url);
let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid AI proxy URL: {e}"))?;
builder = builder.proxy(proxy);
@@ -6386,6 +6390,29 @@ mod tests {
build_ai_http_client(&config, 1).unwrap();
}
#[test]
fn ai_http_client_identifies_loopback_endpoints() {
for (endpoint, expected) in [
("http://localhost:3456/v1", true),
("http://LOCALHOST:3456/v1", true),
("http://127.0.0.1:3456/v1", true),
("http://127.12.34.56:3456/v1", true),
("http://[::1]:3456/v1", true),
("http://192.168.1.2:3456/v1", false),
("http://10.0.0.1:3456/v1", false),
("http://[fd00::1]:3456/v1", false),
("https://api.example.com/v1", false),
("http://localhost.example.com/v1", false),
("invalid endpoint", false),
] {
let config: AiConfig = serde_json::from_value(serde_json::json!({
"provider": "openai-compatible", "endpoint": endpoint
}))
.unwrap();
assert_eq!(super::ai_endpoint_is_loopback(&config), expected, "{endpoint}");
}
}
#[test]
fn ai_http_client_bypasses_proxy_for_loopback_endpoint() {
let config = AiConfig {
@@ -0,0 +1,184 @@
//! Requests run in fresh processes so proxy variables and discovery caches are isolated.
use dbx_ai_provider::ai::{build_ai_http_client, AiConfig};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener};
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
struct Mock {
address: SocketAddr,
hits: Arc<AtomicUsize>,
stopped: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl Mock {
fn start(bind: &str, status: u16) -> std::io::Result<Self> {
let listener = TcpListener::bind(bind)?;
let address = listener.local_addr()?;
listener.set_nonblocking(true)?;
let hits = Arc::new(AtomicUsize::new(0));
let stopped = Arc::new(AtomicBool::new(false));
let worker_hits = hits.clone();
let worker_stopped = stopped.clone();
let worker = thread::spawn(move || {
while !worker_stopped.load(Ordering::SeqCst) {
match listener.accept() {
Ok((mut stream, _)) => {
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
let length = stream.read(&mut buffer).unwrap();
assert!(length > 0 && request.len() < 65536, "invalid mock request");
request.extend_from_slice(&buffer[..length]);
}
worker_hits.fetch_add(1, Ordering::SeqCst);
let body = if status == 200 { "model" } else { "proxy" };
write!(
stream,
"HTTP/1.1 {status} Mock\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("mock accept failed: {error}"),
}
}
});
Ok(Self { address, hits, stopped, worker: Some(worker) })
}
}
impl Drop for Mock {
fn drop(&mut self) {
self.stopped.store(true, Ordering::SeqCst);
if let Some(worker) = self.worker.take() {
let result = worker.join();
if !thread::panicking() {
result.unwrap();
}
}
}
}
fn request(endpoint: &str, mode: &str, environment: &Mock, explicit: &str, expected: &Mock, status: u16) {
let mut command = Command::new(std::env::current_exe().unwrap());
command.args(["--ignored", "--exact", "proxy_request_child", "--nocapture"]);
for key in
["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]
{
command.env_remove(key);
}
command.env("HTTP_PROXY", format!("http://{}", environment.address));
command.env("HTTPS_PROXY", format!("http://{}", environment.address));
command.env("ALL_PROXY", format!("http://{}", environment.address));
command.env("DBX_PROXY_TEST_ENDPOINT", endpoint);
command.env("DBX_PROXY_TEST_MODE", mode);
command.env("DBX_PROXY_TEST_EXPLICIT", explicit);
command.env("DBX_PROXY_TEST_PEER", expected.address.to_string());
command.env("DBX_PROXY_TEST_STATUS", status.to_string());
let mut child = command.spawn().unwrap();
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(result) = child.try_wait().unwrap() {
assert!(result.success(), "child failed: {endpoint}, {mode}");
break;
}
if Instant::now() > deadline {
child.kill().unwrap();
child.wait().unwrap();
panic!("proxy test child timed out: {endpoint}, {mode}");
}
thread::sleep(Duration::from_millis(10));
}
}
fn loopback_requests(mode: &str) {
for host in ["127.0.0.1", "127.0.0.2", "localhost", "[::1]"] {
let bind = if host == "localhost" { "127.0.0.1" } else { host };
let model = match Mock::start(&format!("{bind}:0"), 200) {
Ok(mock) => mock,
Err(error)
if host == "[::1]"
&& matches!(
error.kind(),
std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
) =>
{
eprintln!("IPv6 loopback is unavailable: {error}");
continue;
}
Err(error) => panic!("cannot bind model mock: {error}"),
};
let proxy = Mock::start("127.0.0.1:0", 502).unwrap();
let endpoint = format!("http://{host}:{}/v1/models", model.address.port());
let explicit = match mode {
"valid" => format!("http://{}", proxy.address),
"invalid" => "not a proxy url".to_owned(),
_ => String::new(),
};
request(&endpoint, mode, &proxy, &explicit, &model, 200);
assert_eq!(model.hits.load(Ordering::SeqCst), 1);
assert_eq!(proxy.hits.load(Ordering::SeqCst), 0);
}
}
#[test]
fn loopback_bypasses_automatic_proxy_when_disabled() {
loopback_requests("disabled");
}
#[test]
fn loopback_bypasses_automatic_proxy_with_empty_explicit_proxy() {
loopback_requests("empty");
}
#[test]
fn loopback_bypasses_valid_explicit_proxy() {
loopback_requests("valid");
}
#[test]
fn loopback_ignores_invalid_explicit_proxy() {
loopback_requests("invalid");
}
#[test]
fn public_endpoint_preserves_automatic_and_explicit_proxy_priority() {
let automatic = Mock::start("127.0.0.1:0", 502).unwrap();
let explicit = Mock::start("127.0.0.1:0", 502).unwrap();
let endpoint = "http://dbx-proxy-regression.invalid/v1/models";
request(endpoint, "disabled", &automatic, "", &automatic, 502);
request(endpoint, "valid", &automatic, &format!("http://{}", explicit.address), &explicit, 502);
assert_eq!(automatic.hits.load(Ordering::SeqCst), 1);
assert_eq!(explicit.hits.load(Ordering::SeqCst), 1);
}
#[test]
#[ignore = "invoked only by the parent tests in a fresh proxy environment"]
fn proxy_request_child() {
let endpoint = std::env::var("DBX_PROXY_TEST_ENDPOINT").unwrap();
let mode = std::env::var("DBX_PROXY_TEST_MODE").unwrap();
let config: AiConfig = serde_json::from_value(serde_json::json!({
"provider": "openai-compatible", "endpoint": endpoint,
"proxyEnabled": mode != "disabled", "proxyUrl": std::env::var("DBX_PROXY_TEST_EXPLICIT").unwrap()
}))
.unwrap();
tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
let client = build_ai_http_client(&config, 5).unwrap();
let response = client.get(&endpoint).send().await.unwrap();
let expected_status: u16 = std::env::var("DBX_PROXY_TEST_STATUS").unwrap().parse().unwrap();
assert_eq!(response.status().as_u16(), expected_status);
assert_eq!(response.remote_addr().unwrap().to_string(), std::env::var("DBX_PROXY_TEST_PEER").unwrap());
assert_eq!(response.text().await.unwrap(), if expected_status == 200 { "model" } else { "proxy" });
});
}
+1
View File
@@ -0,0 +1 @@
/__pycache__/
+1548
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "dbx-ai-proxy-12277-proof"
version = "0.1.0"
edition = "2021"
publish = false
[workspace]
[dependencies]
dbx-ai-provider = { path = "../../crates/dbx-ai-provider" }
reqwest = { version = "=0.12.28", default-features = false, features = ["rustls-tls", "system-proxy"] }
hyper-util = "=0.1.20"
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "=0.61.2", features = ["Win32_System_Registry", "Win32_Foundation", "Win32_Security"] }
+222
View File
@@ -0,0 +1,222 @@
# Issue #12277:通过 aliyun 复现本地 AI 请求误走代理
2026-10-16 在 Windows 上直接调用
`dbx_ai_provider::ai::build_ai_http_client()` 完成修复前复现和修复后验证。
客户端构建显式启用 `reqwest/system-proxy`,固定 reqwest 0.12.28、hyper-util 0.1.20。
其他依赖由本目录独立的 Cargo.lock 固定,不修改主工作区依赖。
## 拓扑和生命周期
- `ssh aliyun` 在服务器内存中执行 `remote_mock.py`,不上传文件、不安装软件。
- 模型 mock 和返回 502 的代理 mock 均绑定服务器 `127.0.0.1` 随机端口。
- SSH 转发到 Windows `127.0.0.1` 和 `[::1]`,客户端请求地址仍是 loopback。
- 每个请求启动独立客户端进程,只向该进程传入代理环境变量。
- 每例同时记录 HTTP 状态、TCP peer、服务器命中角色/方法/路径。
- SSH stdin 关闭时 mock 退出;驱动程序在 finally 中关闭隧道和远端会话。
## 运行
在仓库根目录用 PowerShell 7 执行(需要可用的 Windows Rust 编译工具链):
```powershell
cargo build --offline --locked --manifest-path scripts\ai-proxy-12277\Cargo.toml --target-dir target\ai-proxy-12277
python scripts\ai-proxy-12277\run_proof.py
python scripts\ai-proxy-12277\run_proof.py --suite remaining
```
默认 `--expect fixed` 验证修复后的行为;在修复前代码上运行时,使用
`--expect baseline` 验证旧缺陷。两个套件分别是 13 例环境变量场景和 49 例补充场景。
新报告分别写入 `{fixed|baseline}-{environment|remaining}-results.json`,
不会覆盖下面列出的首轮历史报告。
本机 GNU 工具链需要额外的链接环境。本次构建前使用了仓库已有的
`target\sqlserver-7542-tools\rust-env.ps1`,这不是运行客户端所需条件。
## 修复前实际结果(历史证据)
运行时间、随机端口和逐例原始证据见
`target/ai-proxy-12277/proof-results.json`。
| 场景 | 状态 | 服务端实际命中 |
| --- | --- | --- |
| 清空进程代理环境变量,AI 代理关闭 | 200 | 模型 1,代理 0 |
| 设置 HTTP_PROXY 等,AI 代理关闭,IPv4 | 502 | 模型 0,代理 1 |
| 同上,开启 AI 显式代理但 loopback 跳过该配置 | 502 | 模型 0,代理 1 |
| 增加准确 NO_PROXY=127.0.0.1,localhost,::1 | 200 | 模型 1,代理 0 |
| NO_PROXY=127.* | 502 | 模型 0,代理 1 |
| NO_PROXY=127.0.0.0/8 | 200 | 模型 1,代理 0 |
| 设置代理环境变量,目标 localhost | 502 | 模型 0,代理 1 |
| 设置代理环境变量,目标 [::1] | 502 | 模型 0,代理 1 |
| IPv6 增加 NO_PROXY=::1 | 200 | 模型 1,代理 0 |
| 设置代理环境变量,POST stream=true | 502 | 模型 0,代理 1 |
| 同上,增加准确 NO_PROXY | 200 | 模型 1,代理 0,收到 SSE 数据 |
| 对照:reqwest builder.no_proxy(),保留代理环境变量 | 200 | 模型 1,代理 0 |
| 对照:公网域名配置显式代理 | 502 | 模型 0,代理 1 |
驱动程序退出码 0;13 个场景均符合预期。
其中预期 502 表示成功复现缺陷或验证可控代理路由,并不表示修复通过。
## 系统代理与完整 AI 入口补充验证(修复前)
`--suite remaining` 使用 Windows `RegOverridePredefKey` 将**当前测试进程**的
HKCU 映射到独立的临时易失性注册表键。底层 hyper-util 仍真实读取
`Software\Microsoft\Windows\CurrentVersion\Internet Settings` 中的
`ProxyEnable`、`ProxyServer` 和 `ProxyOverride`。其他进程读取原始注册表,
测试前后快照检查本机真实代理配置未改变。
API 的进程隔离与恢复语义见 [Microsoft 文档](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regoverridepredefkey)。
补充矩阵覆盖:
- 清空代理环境变量,仅使用 Windows 系统代理;禁用系统代理作对照。
- ProxyOverride 无绕过规则、`127.*`、准确地址、CIDR 和 localhost。
- IPv4/IPv6,流式/非流式,NO_PROXY 与系统绕过规则组合。
- 使用两个独立代理 mock 区分系统代理、环境代理和显式代理的优先级。
- DBX 完整 `list_models_core`、`test_connection_core`、`complete`、`stream` 入口;
每个入口验证系统代理 IPv4/IPv6 失败和直连成功、环境代理失败和直连成功。
- 成功的流式入口检查解析后的文字为 `ok`,且有 done chunk;非流式检查结果为 `ok`。
原始证据在 `target/ai-proxy-12277/remaining-results.json`,执行日志在
`target/ai-proxy-12277/remaining-run.log`。
补充矩阵实际运行 **49 例(25 例路由组合 + 24 例完整 AI 入口)**,
49 例均符合预期,驱动退出码 0。加上首轮 13 例,共记录 62 个场景。
此处“符合预期”包含成功复现缺陷,并不表示生产代码已修复。
| 补充验证 | 实际观察 |
| --- | --- |
| 环境变量为空,系统代理启用,ProxyOverride=127.* | 502,模型 0 次、系统代理 1 次 |
| 系统绕过规则为准确 IPv4、IPv6 或 IPv4 CIDR | 200,直达模型 |
| localhost 在系统绕过列表中 | 200;移除绕过后 502 |
| 系统代理关闭 | IPv4 和 IPv6 均直连 200 |
| 环境 NO_PROXY 准确匹配,保留系统代理 | IPv4 和 IPv6 均直连 200 |
| IPv4 配置显式代理,系统绕过准确匹配 | 显式代理被跳过,模型 200 |
| IPv6 配置显式代理,即使系统绕过准确匹配 | 仍命中显式代理,502 |
| 公网目标同时配置系统代理和显式代理 | 命中显式代理;没有显式代理时命中系统代理 |
| 同时配置环境代理和系统代理 | 命中环境代理 |
| 四个完整 AI 入口,系统代理无有效绕过或环境代理无 NO_PROXY | 均失败,命中代理,模型未命中 |
| 四个完整 AI 入口,准确系统绕过或准确 NO_PROXY | 均成功,返回模型列表、连接成功、聊天文字和完成事件 |
清理检查:三个远端随机端口均拒绝连接;`HKCU\Software` 下
`DBXIssue12277-*` 临时键剩余 0 个;真实系统代理配置前后快照一致。
## 修复前结论
修复前实现只跳过添加显式代理,没有在本地目标场景关闭自动代理发现。
AI 代理关闭不阻止本次请求采用环境变量代理;TCP peer 和远端命中记录共同证明了路由。
准确 NO_PROXY 和 no_proxy() 对照证明模型及 SSH 转发正常。
第一轮 13 例仅验证环境变量代理;补充矩阵验证 Windows 系统代理注册表读取路径。
本机实际 ProxyEnable 原始值为 0,没有更改真实 Internet Settings 或 Clash 配置。
只在专用命名空间创建临时注册表键,并在各测试进程退出前恢复映射、删除临时键。
另确认 IPv6 回环识别缺口:`reqwest::Url::host_str()` 对 IPv6 返回 `[::1]`,
原先的 `host.parse::<IpAddr>()` 不接受方括号,因此本地 IPv6 地址没有跳过显式代理。
即使系统 ProxyOverride 准确包含 `::1`,显式代理仍收到这类请求;
相同配置的 IPv4 请求会直连。原始结果同时记录 url_host 和实际命中代理角色。
模型列表入口先解码 JSON;遇到代理返回纯文本 502 时,错误可能表现为 JSON 解码失败,
而不是带 502 的错误文字。用服务端命中记录确认路由,避免仅按错误字符串判定。
## 修复内容与验收
- 解析 IPv6 主机时先去除配对方括号,再判断 IP 是否为回环地址。
- 回环目标在实际客户端构造时调用 `no_proxy()`,同时禁用环境、系统和显式代理。
- 非回环目标保留原有代理逻辑;未增加 DNS 查询或私网地址自动绕过。
- 永久回归测试使用独立子进程和本地模型/代理 mock;测试依赖显式启用 system-proxy。
检查响应状态、TCP peer、模型与代理命中次数。旧代码四组回环测试失败、公网对照通过。
修复后验收证据:
- `target/ai-proxy-12277/provider-tests-green.log`:provider 完整测试。
- `target/ai-proxy-12277/fixed-environment-results.json`:13 例环境变量场景。
- `target/ai-proxy-12277/fixed-remaining-results.json`:49 例系统代理及完整 AI 入口场景。
- `target/ai-proxy-12277/regression-red.log`:修复前回归失败记录。
修复后预期:全部回环请求直达模型,普通请求返回 200;四个完整 AI 入口返回正确数据。
公网代理对照仍返回 mock 的 502,并命中预期代理,这表示公网代理路由得到保留。
原有 `environment-over-system-priority` 场景的目标也是回环地址,修复后应直连,
不再用它断言环境代理优先级;公网环境/显式代理优先级由永久回归测试验证。
修复后实际运行结果:266 个单元测试和 5 组真实请求回归测试通过。
保留 4 个原有忽略测试;额外 1 个忽略项是由父测试显式启动的子进程入口。
阿里云两轮验收 **13/13 + 49/49 全部通过**,旧复现结果保持原样。
全部本地请求命中模型而非代理,四个完整 AI 入口均成功,公网代理对照保留原行为。
独立代码审查未发现正确性问题。
`target/ai-proxy-12277/fixed-cleanup-verification.json` 记录清理验收:
真实代理配置前后一致、临时注册表键为 0,远端 mock 和本地隧道端口均已关闭。
第一轮流式场景仅调用真实构造器;补充矩阵调用完整聊天流解析入口。
上述 62 例没有操作真实 Clash/TUN;后续真实 FlClash 验证见下节。
## 保持联网配置原样的真实 FlClash 验证
本机运行 FlClash.exe(PID 11604)和 FlClashCore.exe(PID 4756),
核心实际监听 `127.0.0.1:10433`。测试时真实 Windows `ProxyEnable=0`,
测试终端的 HTTP_PROXY/HTTPS_PROXY 指向该端口,NO_PROXY 原始值包含回环地址。
```powershell
python scripts\ai-proxy-12277\run_proof.py --suite real-clash --real-proxy http://127.0.0.1:10433
```
本次 30 例全部通过:
- 1 例强制经过真实代理的 reqwest 对照:TCP peer 为 `127.0.0.1:10433`,
FlClash 将请求转发到模型,返回 200。此对照未调用 DBX 构造器。
- 12 例 DBX 实际请求:IPv4、localhost、IPv6 分别覆盖继承原始环境、
子进程代理指向真实 FlClash 且移除 NO_PROXY、显式代理、系统代理读取链路。
- 5 例补充组合:环境 NO_PROXY=127.*、系统无绕过规则的 IPv4/IPv6、
两种 IP 的 stream=true 请求。
- 12 例完整 AI 入口:模型列表、连接测试、普通聊天和流式聊天,分别覆盖
环境代理 IPv4、系统代理 IPv4、显式代理 IPv6,均返回正确内容。
修复后的普通请求 TCP peer 均是模型转发端口,而非真实 Clash 端口;
全部 DBX 请求均有且仅有一次模型命中。流式入口解析出 `ok` 和 done 事件。
系统代理场景只在测试进程中映射隔离注册表,启用后指向**真实 FlClash**,
未修改真实 Windows 代理开关。
测试没有退出/重启 FlClash,也没有切换节点、系统代理或 TUN。
前后快照确认 FlClash 进程及创建时间、10433 端口归属和真实代理配置一致。
远端服务、本地模型转发及临时注册表已清理。
证据:
- `target/ai-proxy-12277/fixed-real-clash-results.json`:30 例原始结果。
- `target/ai-proxy-12277/real-clash-state-before.json` 和 `real-clash-state-after.json`:状态快照。
- `target/ai-proxy-12277/real-clash-verification.json`:结果、配置一致性与清理验证。
边界:本机 FlClash 对强制代理对照返回 200,因此本轮未重现原反馈的 502;
验证的是修复后本地请求确实不经过实际运行的 Clash。
上述 30 例没有操作 DBX 桌面 GUI、真实模型、API Key 或数据库连接,也未验证 TUN 开关切换。
## Windows 桌面 GUI 验证(2026-10-17 至 2026-10-18)
使用包含修复的本地 Tauri 桌面构建,连接实际 Rust 后端和 Vite 开发前端,
数据目录与 WebView profile 均隔离到 `target/ai-proxy-12277/`。模型为本机 HTTP/SSE mock,
不使用真实 API Key;临时 SQLite 数据库仅提供 AI 助手要求的连接上下文。
- IPv4:`http://127.0.0.1:13937/v1`,关闭 AI 显式代理。
- IPv6:`http://[::1]:13938/v1`,启用 AI 显式代理并指向未监听的 `http://127.0.0.1:1`。
- 测试应用的 HTTP_PROXY/HTTPS_PROXY/ALL_PROXY 指向真实 FlClash `127.0.0.1:10433`,
移除该子进程的 NO_PROXY;没有修改全局环境或 FlClash 配置。
- 两组均通过 GUI 模型发现、连接测试、配置保存和 Ask 流式聊天;聊天显示
`DBX_GUI_12277_OK: local model responded successfully.`。
- 模型请求日志与应用进程 socket 采样共同确认直连 `127.0.0.1:13937` / `::1:13938`。
应用后台更新检查也会连接代理,不能用整个应用是否出现 10433 连接判断 AI 请求路由。
构建命令为 `cargo build --offline -p dbx --no-default-features --features sqlite-bundled`。
该精简构建未启用 os-keyring,最初保存非空代理配置出现 KEY_PROVIDER_UNAVAILABLE;
为测试进程指定独立 DBX_SECRET_KEY_FILE 后保存成功,重启后 IPv6 配置与聊天验证通过。
另一次启动因缺少构建依赖的 DLL 搜索路径返回 0xC0000135,恢复构建环境 PATH 后正常启动。
这两项是测试构建环境处理,没有追加生产代码修改。
证据位于 `target/ai-proxy-12277/`:
- `gui-evidence/`:IPv4/IPv6 连接成功、模型列表、聊天回复截图及对应 UIA 文本。
- `gui-mock-requests.jsonl` 与 `gui-desktop-network.jsonl`:请求及实际 TCP 连接记录。
- `gui-verification.json`:结果汇总;`gui-cleanup.json`:清理检查。
测试应用、模型 mock、Vite 和监控进程已退出,1420/13937/13938 无残留监听,
真实 FlClash 10433 监听仍保留。跨日运行中不以 PID 恒定作为验收条件。
本轮没有切换系统代理、节点或 TUN,也没有使用真实模型;GUI 覆盖流式聊天,
非流式聊天仍由前述核心入口测试覆盖。
+63
View File
@@ -0,0 +1,63 @@
"""Runs only for the lifetime of its SSH stdin; binds remote loopback only."""
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
hits = []
lock = threading.Lock()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def respond(self):
if self.path == '/_stats' and self.server.role == 'model':
with lock:
body = json.dumps(hits).encode()
status, kind = 200, 'application/json'
else:
length = int(self.headers.get('Content-Length', '0'))
payload = {}
if length:
raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode())
except (ValueError, UnicodeDecodeError):
pass
with lock:
hits.append({'role': self.server.role, 'method': self.command, 'path': self.path})
if self.server.role.startswith('proxy'):
status, kind, body = 502, 'text/plain', b'dbx-12277 mock proxy: request incorrectly routed here'
elif self.command == 'POST' and payload.get('stream'):
status, kind, body = 200, 'text/event-stream', b'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n'
elif self.command == 'POST':
status, kind = 200, 'application/json'
body = b'{"id":"proof","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}'
else:
status, kind = 200, 'application/json'
body = b'{"object":"list","data":[{"id":"local-repro","object":"model"}]}'
self.send_response(status)
self.send_header('Content-Type', kind)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = respond
do_POST = respond
servers = []
for role in ('model', 'proxy', 'proxy-explicit'):
server = HTTPServer(('127.0.0.1', 0), Handler)
server.role = role
threading.Thread(target=server.serve_forever, daemon=True).start()
servers.append(server)
print(json.dumps({s.role: s.server_port for s in servers}), flush=True)
try:
sys.stdin.read()
finally:
for server in servers:
server.shutdown()
server.server_close()
+258
View File
@@ -0,0 +1,258 @@
"""Windows driver: SSH-hosted mocks, loopback forwards, fresh DBX process per case."""
import base64
import argparse
import datetime
import json
import os
from pathlib import Path
import socket
import subprocess
import time
import urllib.request
import urllib.parse
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
BINARY = ROOT / 'target/ai-proxy-12277/debug/dbx-ai-proxy-12277-proof.exe'
PROXY_KEYS = {'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy'}
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def free_port(family=socket.AF_INET):
with socket.socket(family) as sock:
sock.bind(('::1' if family == socket.AF_INET6 else '127.0.0.1', 0))
return sock.getsockname()[1]
def stats(port):
with opener.open('http://127.0.0.1:{}/_stats'.format(port), timeout=5) as response:
return json.load(response)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--suite', choices=['environment', 'remaining', 'real-clash'], default='environment')
parser.add_argument('--expect', choices=['baseline', 'fixed'], default='fixed')
parser.add_argument('--real-proxy', help='Existing local Clash HTTP proxy; never changes Clash configuration')
args = parser.parse_args()
suite = args.suite
import winreg
settings_path = r'Software\Microsoft\Windows\CurrentVersion\Internet Settings'
def registry_snapshot():
values = {}
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, settings_path) as key:
for name in ('ProxyEnable', 'ProxyServer', 'ProxyOverride', 'AutoConfigURL'):
try:
values[name] = winreg.QueryValueEx(key, name)
except FileNotFoundError:
values[name] = None
return values
original_settings = registry_snapshot()
real_peer = None
if suite == 'real-clash':
real_url = urllib.parse.urlsplit(args.real_proxy or '')
if real_url.scheme != 'http' or real_url.hostname != '127.0.0.1' or not real_url.port or real_url.username:
parser.error('--real-proxy must be an existing unauthenticated http://127.0.0.1:PORT listener')
if args.expect != 'fixed':
parser.error('real-clash suite validates fixed behavior only')
real_peer = '127.0.0.1:{}'.format(real_url.port)
encoded = base64.b64encode((HERE / 'remote_mock.py').read_bytes()).decode('ascii')
command = 'python3 -u -c "import base64;exec(base64.b64decode(\'{}\'))"'.format(encoded)
remote = subprocess.Popen(
['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', 'aliyun', command],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8')
tunnel = None
try:
line = remote.stdout.readline()
if not line:
raise RuntimeError('remote mock startup failed: ' + remote.stderr.read())
ports = json.loads(line)
model_port, proxy_port, ipv6_port, explicit_port = free_port(), free_port(), free_port(socket.AF_INET6), free_port()
tunnel = subprocess.Popen([
'ssh', '-N', '-T', '-o', 'BatchMode=yes', '-o', 'ExitOnForwardFailure=yes',
'-o', 'ConnectTimeout=10',
'-L', '127.0.0.1:{}:127.0.0.1:{}'.format(model_port, ports['model']),
'-L', '127.0.0.1:{}:127.0.0.1:{}'.format(proxy_port, ports['proxy']),
'-L', '[::1]:{}:127.0.0.1:{}'.format(ipv6_port, ports['model']),
'-L', '127.0.0.1:{}:127.0.0.1:{}'.format(explicit_port, ports['proxy-explicit']),
'aliyun'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
for _ in range(100):
if tunnel.poll() is not None:
raise RuntimeError('SSH tunnel failed: ' + tunnel.stderr.read().decode())
try:
stats(model_port)
break
except (OSError, ValueError):
time.sleep(0.1)
else:
raise RuntimeError('SSH tunnel did not become ready')
endpoint = 'http://127.0.0.1:{}/v1'.format(model_port)
ipv6_endpoint = 'http://[::1]:{}/v1'.format(ipv6_port)
proxy = 'http://127.0.0.1:{}'.format(proxy_port)
explicit_proxy = 'http://127.0.0.1:{}'.format(explicit_port)
clean_env = {k: v for k, v in os.environ.items() if k.lower() not in PROXY_KEYS and not k.startswith('DBX_PROOF_')}
proxy_env = dict(clean_env, HTTP_PROXY=proxy, HTTPS_PROXY=proxy, ALL_PROXY=proxy)
cases = [
('baseline-no-env-proxy', endpoint, 'default', clean_env, '', 200, 'model'),
('proxy-disabled-ipv4', endpoint, 'default', proxy_env, '', 502, 'proxy'),
('explicit-proxy-skipped-ipv4', endpoint, 'explicit', proxy_env, 'invalid proxy url', 502, 'proxy'),
('exact-no-proxy-ipv4', endpoint, 'default', dict(proxy_env, NO_PROXY='127.0.0.1,localhost,::1'), '', 200, 'model'),
('windows-style-wildcard-env', endpoint, 'default', dict(proxy_env, NO_PROXY='127.*'), '', 502, 'proxy'),
('cidr-no-proxy-ipv4', endpoint, 'default', dict(proxy_env, NO_PROXY='127.0.0.0/8'), '', 200, 'model'),
('proxy-disabled-localhost', endpoint.replace('127.0.0.1', 'localhost'), 'default', proxy_env, '', 502, 'proxy'),
('proxy-disabled-ipv6', ipv6_endpoint, 'default', proxy_env, '', 502, 'proxy'),
('exact-no-proxy-ipv6', ipv6_endpoint, 'default', dict(proxy_env, NO_PROXY='::1'), '', 200, 'model'),
('stream-proxy-disabled', endpoint, 'stream', proxy_env, '', 502, 'proxy'),
('stream-exact-no-proxy', endpoint, 'stream', dict(proxy_env, NO_PROXY='127.0.0.1'), '', 200, 'model'),
('no-proxy-builder-control', endpoint, 'direct-control', proxy_env, '', 200, 'model'),
('public-explicit-proxy-control', 'http://dbx-repro.invalid/v1', 'explicit', clean_env, proxy, 502, 'proxy'),
]
if suite == 'remaining':
system_env = dict(clean_env, DBX_PROOF_SYSTEM_PROXY='127.0.0.1:{}'.format(proxy_port))
wildcard = dict(system_env, DBX_PROOF_PROXY_OVERRIDE='localhost;127.*;10.*;172.16.*;<local>')
exact = dict(system_env, DBX_PROOF_PROXY_OVERRIDE='localhost;127.0.0.1;::1')
localhost_endpoint = endpoint.replace('127.0.0.1', 'localhost')
cases = [
('system-no-bypass-ipv4', endpoint, 'default', system_env, '', 502, 'proxy'),
('system-wildcard-ipv4', endpoint, 'default', wildcard, '', 502, 'proxy'),
('system-exact-ipv4', endpoint, 'default', exact, '', 200, 'model'),
('system-cidr-ipv4', endpoint, 'default', dict(system_env, DBX_PROOF_PROXY_OVERRIDE='127.0.0.0/8'), '', 200, 'model'),
('system-wildcard-localhost', localhost_endpoint, 'default', wildcard, '', 200, 'model'),
('system-no-bypass-localhost', localhost_endpoint, 'default', system_env, '', 502, 'proxy'),
('system-no-bypass-ipv6', ipv6_endpoint, 'default', system_env, '', 502, 'proxy'),
('system-exact-ipv6', ipv6_endpoint, 'default', exact, '', 200, 'model'),
('system-stream-wildcard', endpoint, 'stream', wildcard, '', 502, 'proxy'),
('system-stream-exact', endpoint, 'stream', exact, '', 200, 'model'),
('system-disabled-ipv4', endpoint, 'default', dict(system_env, DBX_PROOF_SYSTEM_ENABLED='0'), '', 200, 'model'),
('system-disabled-ipv6', ipv6_endpoint, 'default', dict(system_env, DBX_PROOF_SYSTEM_ENABLED='0'), '', 200, 'model'),
('system-no-proxy-env-ipv4', endpoint, 'default', dict(wildcard, NO_PROXY='127.0.0.1'), '', 200, 'model'),
('system-no-proxy-env-ipv6', ipv6_endpoint, 'default', dict(system_env, NO_PROXY='::1'), '', 200, 'model'),
('system-no-proxy-builder-control', endpoint, 'direct-control', wildcard, '', 200, 'model'),
('system-explicit-loopback-ipv4', endpoint, 'explicit', system_env, explicit_proxy, 502, 'proxy'),
('system-explicit-loopback-localhost', localhost_endpoint, 'explicit', system_env, explicit_proxy, 502, 'proxy'),
('system-explicit-loopback-ipv6', ipv6_endpoint, 'explicit', system_env, explicit_proxy, 502, 'proxy-explicit'),
('system-exact-explicit-ipv4', endpoint, 'explicit', exact, explicit_proxy, 200, 'model'),
('system-exact-explicit-ipv6', ipv6_endpoint, 'explicit', exact, explicit_proxy, 502, 'proxy-explicit'),
('explicit-only-ipv4', endpoint, 'explicit', dict(system_env, DBX_PROOF_SYSTEM_ENABLED='0'), explicit_proxy, 200, 'model'),
('explicit-only-ipv6', ipv6_endpoint, 'explicit', dict(system_env, DBX_PROOF_SYSTEM_ENABLED='0'), explicit_proxy, 502, 'proxy-explicit'),
('system-public-proxy', 'http://dbx-repro.invalid/v1', 'default', system_env, '', 502, 'proxy'),
('system-public-explicit-priority', 'http://dbx-repro.invalid/v1', 'explicit', system_env, explicit_proxy, 502, 'proxy-explicit'),
('environment-over-system-priority', endpoint, 'default', dict(system_env, HTTP_PROXY=explicit_proxy), '', 502, 'proxy-explicit'),
]
for mode in ('core-models', 'core-test', 'core-complete', 'core-stream'):
for label, url, env, ok, role in [
('system-wildcard', endpoint, wildcard, False, 'proxy'),
('system-exact', endpoint, exact, True, 'model'),
('system-ipv6-no-bypass', ipv6_endpoint, system_env, False, 'proxy'),
('system-ipv6-exact', ipv6_endpoint, exact, True, 'model'),
('environment-proxy', endpoint, proxy_env, False, 'proxy'),
('environment-no-proxy', endpoint, dict(proxy_env, NO_PROXY='127.0.0.1'), True, 'model'),
]:
cases.append((mode + '-' + label, url, mode, env, '', ok, role))
if suite == 'real-clash':
real_env = dict(clean_env, HTTP_PROXY=args.real_proxy, HTTPS_PROXY=args.real_proxy, ALL_PROXY=args.real_proxy)
inherited_env = {k: v for k, v in os.environ.items() if not k.startswith('DBX_PROOF_')}
system_env = dict(clean_env, DBX_PROOF_SYSTEM_PROXY=real_peer,
DBX_PROOF_PROXY_OVERRIDE=(original_settings.get('ProxyOverride') or [''])[0])
cases = [('real-clash-forced-proxy-control', endpoint, 'proxy-control', clean_env, args.real_proxy, None, 'real-clash')]
urls = [('ipv4', endpoint), ('localhost', endpoint.replace('127.0.0.1', 'localhost')), ('ipv6', ipv6_endpoint)]
for label, url in urls:
cases.extend([
('real-clash-inherited-' + label, url, 'default', inherited_env, '', 200, 'model'),
('real-clash-env-no-bypass-' + label, url, 'default', real_env, '', 200, 'model'),
('real-clash-explicit-' + label, url, 'explicit', real_env, args.real_proxy, 200, 'model'),
('real-clash-system-reader-' + label, url, 'default', system_env, '', 200, 'model'),
])
cases.extend([
('real-clash-env-wildcard', endpoint, 'default', dict(real_env, NO_PROXY='127.*'), '', 200, 'model'),
('real-clash-system-no-bypass-ipv4', endpoint, 'default', dict(system_env, DBX_PROOF_PROXY_OVERRIDE=''), '', 200, 'model'),
('real-clash-system-no-bypass-ipv6', ipv6_endpoint, 'default', dict(system_env, DBX_PROOF_PROXY_OVERRIDE=''), '', 200, 'model'),
('real-clash-stream-ipv4', endpoint, 'stream', real_env, '', 200, 'model'),
('real-clash-stream-ipv6', ipv6_endpoint, 'stream', real_env, '', 200, 'model'),
])
for mode in ('core-models', 'core-test', 'core-complete', 'core-stream'):
for label, url, env in [
('env-ipv4', endpoint, real_env),
('system-ipv4', endpoint, system_env),
('explicit-ipv6', ipv6_endpoint, dict(real_env, DBX_PROOF_EXPLICIT='1')),
]:
cases.append(('real-clash-' + mode + '-' + label, url, mode, env, args.real_proxy, True, 'model'))
results = []
for name, url, mode, env, explicit, expected_status, expected_role in cases:
if args.expect == 'fixed' and mode != 'proxy-control' and url in (endpoint, ipv6_endpoint, endpoint.replace('127.0.0.1', 'localhost')):
expected_status = True if mode.startswith('core-') else 200
expected_role = 'model'
before = len(stats(model_port))
proc = subprocess.run([str(BINARY), url, mode, explicit], env=env,
capture_output=True, text=True, encoding='utf-8', timeout=20)
if proc.returncode or proc.stderr:
result = {'process_exit': proc.returncode, 'stderr': proc.stderr, 'stdout': proc.stdout}
else:
result = json.loads(proc.stdout)
hits = stats(model_port)[before:]
result.update(case=name, endpoint=url, hits=hits)
result['registry_fixture'] = {k: v for k, v in env.items() if k.startswith('DBX_PROOF_')}
result['proxy_environment'] = {k: v for k, v in env.items() if k.lower() in PROXY_KEYS}
result['expected_status_or_success'] = expected_status
result['expected_role'] = expected_role
transport_ok = len(hits) == 1 and hits[0]['role'] == expected_role
if mode == 'proxy-control':
# Prove the real listener was used. Its policy may forward or reject loopback.
result['observed_expected'] = result.get('peer') == real_peer and isinstance(result.get('status'), int)
result['observed_expected'] &= (len(hits) == 1 and hits[0]['role'] == 'model') if result.get('status') == 200 else len(hits) == 0
elif mode.startswith('core-'):
result['observed_expected'] = result.get('ok') == expected_status and transport_ok
if expected_status and result.get('ok') is True:
if mode == 'core-models':
result['observed_expected'] &= result['result'][0]['id'] == 'local-repro'
elif mode == 'core-test':
result['observed_expected'] &= result['result']['success'] is True
elif mode == 'core-complete':
result['observed_expected'] &= result['result']['text'] == 'ok'
else:
chunks = result['result']['chunks']
result['observed_expected'] &= ''.join(chunk['delta'] for chunk in chunks) == 'ok' and any(chunk['done'] for chunk in chunks)
else:
# Model listing decodes JSON before checking HTTP status, so
# a plain-text 502 can surface as a decode error instead.
result['observed_expected'] &= bool(result.get('error'))
else:
expected_peer = ('[::1]:{}'.format(ipv6_port) if url == ipv6_endpoint else '127.0.0.1:{}'.format(model_port)) if expected_role == 'model' else '127.0.0.1:{}'.format(explicit_port if expected_role == 'proxy-explicit' else proxy_port)
result['observed_expected'] = result.get('status') == expected_status and transport_ok and result.get('peer') == expected_peer
results.append(result)
print(json.dumps(result, ensure_ascii=False), flush=True)
report = {
'time_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'remote': 'aliyun', 'remote_ports': ports,
'local_model_port': model_port, 'local_proxy_port': proxy_port,
'real_system_proxy_modified': False,
'system_proxy_snapshot_before': original_settings,
'system_proxy_snapshot_after': registry_snapshot(),
'suite': suite,
'expectation': args.expect,
'real_proxy': args.real_proxy if suite == 'real-clash' else None,
'note': 'Real Clash suite uses the already-running local proxy without changing its configuration. System scenarios use actual Windows registry reader with process-local HKCU remapping. No GUI interaction or TUN toggling.',
'results': results,
}
output = ROOT / ('target/ai-proxy-12277/{}-{}-results.json'.format(args.expect, suite))
output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
if not all(result['observed_expected'] for result in results):
raise RuntimeError('At least one result differs from the expected baseline; inspect ' + str(output))
if registry_snapshot() != original_settings:
raise RuntimeError('Real Internet Settings unexpectedly changed')
print('REPORT=' + str(output), flush=True)
finally:
if tunnel is not None:
tunnel.terminate()
tunnel.wait(timeout=10)
if remote.stdin:
remote.stdin.close()
try:
remote.wait(timeout=10)
except subprocess.TimeoutExpired:
remote.terminate()
remote.wait(timeout=10)
if __name__ == '__main__':
main()
+76
View File
@@ -0,0 +1,76 @@
use dbx_ai_provider::ai::{build_ai_http_client, AiConfig};
mod registry;
async fn core_call(config: AiConfig, mode: &str) -> serde_json::Value {
use dbx_ai_provider::ai::{complete, list_models_core, stream, test_connection_core, AiCompletionRequest};
let request: AiCompletionRequest = serde_json::from_value(serde_json::json!({
"config": config, "systemPrompt": "", "messages": [{"role":"user","content":"hello"}],
"maxTokens": 16
}))
.unwrap();
let result = match mode {
"core-models" => list_models_core(&config).await.map(|models| serde_json::json!(models)),
"core-test" => test_connection_core(&config).await.map(|result| serde_json::json!(result)),
"core-complete" => complete(&request).await.map(|text| serde_json::json!({"text":text})),
"core-stream" => {
let chunks = std::sync::Mutex::new(Vec::new());
let cancelled = tokio::sync::Notify::new();
stream("issue-12277-proof", &request, &cancelled, |chunk| chunks.lock().unwrap().push(chunk))
.await
.map(|_| serde_json::json!({"chunks":chunks.into_inner().unwrap()}))
}
_ => unreachable!(),
};
match result {
Ok(value) => serde_json::json!({"ok":true,"result":value}),
Err(error) => serde_json::json!({"ok":false,"error":error}),
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let sandbox = registry::RegistrySandbox::from_env()?;
let args: Vec<String> = std::env::args().collect();
let endpoint = &args[1];
let mode = &args[2];
let config: AiConfig = serde_json::from_value(serde_json::json!({
"provider": "openai-compatible",
"endpoint": endpoint,
"proxyEnabled": mode == "explicit" || std::env::var("DBX_PROOF_EXPLICIT").as_deref() == Ok("1"),
"proxyUrl": args.get(3).map(String::as_str).unwrap_or(""),
"model": "local-repro", "maxRetries": 0, "enableThinking": false
}))?;
if mode.starts_with("core-") {
let result = core_call(config, mode).await;
drop(sandbox);
println!("{result}");
return Ok(());
}
let client = if mode == "direct-control" {
reqwest::Client::builder().no_proxy().timeout(std::time::Duration::from_secs(8)).build()?
} else if mode == "proxy-control" {
reqwest::Client::builder()
.proxy(reqwest::Proxy::all(&args[3])?)
.timeout(std::time::Duration::from_secs(8))
.build()?
} else {
build_ai_http_client(&config, 8)?
};
let response = if mode == "stream" {
client
.post(format!("{endpoint}/chat/completions"))
.json(&serde_json::json!({"model":"local-repro","stream":true,"messages":[]}))
.send()
.await?
} else {
client.get(format!("{endpoint}/models")).send().await?
};
let status = response.status().as_u16();
let peer = response.remote_addr().map(|addr| addr.to_string());
let body = response.text().await?;
drop(sandbox);
let url = reqwest::Url::parse(endpoint)?;
println!("{}", serde_json::json!({"status":status,"peer":peer,"body":body,"url_host":url.host_str()}));
Ok(())
}
+112
View File
@@ -0,0 +1,112 @@
//! Redirect HKCU only inside this proof process, using a disposable volatile key.
use std::{io, ptr};
use windows_sys::Win32::System::Registry::*;
fn wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(Some(0)).collect()
}
fn checked(status: u32) -> io::Result<()> {
if status == 0 {
Ok(())
} else {
Err(io::Error::from_raw_os_error(status as i32))
}
}
pub struct RegistrySandbox {
key: HKEY,
path: Vec<u16>,
redirected: bool,
}
impl RegistrySandbox {
pub fn from_env() -> io::Result<Option<Self>> {
let Ok(proxy) = std::env::var("DBX_PROOF_SYSTEM_PROXY") else {
return Ok(None);
};
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = wide(&format!("Software\\DBXIssue12277-{}-{stamp}", std::process::id()));
let mut sandbox = Self { key: ptr::null_mut(), path, redirected: false };
unsafe {
checked(RegCreateKeyExW(
HKEY_CURRENT_USER,
sandbox.path.as_ptr(),
0,
ptr::null(),
REG_OPTION_VOLATILE,
KEY_ALL_ACCESS,
ptr::null(),
&mut sandbox.key,
ptr::null_mut(),
))?;
}
let settings_path = wide("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings");
let mut settings = ptr::null_mut();
unsafe {
checked(RegCreateKeyExW(
sandbox.key,
settings_path.as_ptr(),
0,
ptr::null(),
REG_OPTION_VOLATILE,
KEY_ALL_ACCESS,
ptr::null(),
&mut settings,
ptr::null_mut(),
))?;
let setup = (|| -> io::Result<()> {
let enabled =
if std::env::var("DBX_PROOF_SYSTEM_ENABLED").as_deref() == Ok("0") { 0_u32 } else { 1_u32 };
checked(RegSetValueExW(
settings,
wide("ProxyEnable").as_ptr(),
0,
REG_DWORD,
enabled.to_le_bytes().as_ptr(),
4,
))?;
for (name, value) in [
("ProxyServer", proxy),
("ProxyOverride", std::env::var("DBX_PROOF_PROXY_OVERRIDE").unwrap_or_default()),
] {
let data = wide(&value);
checked(RegSetValueExW(
settings,
wide(name).as_ptr(),
0,
REG_SZ,
data.as_ptr().cast(),
(data.len() * 2) as u32,
))?;
}
Ok(())
})();
RegCloseKey(settings);
setup?;
checked(RegOverridePredefKey(HKEY_CURRENT_USER, sandbox.key))?;
}
sandbox.redirected = true;
Ok(Some(sandbox))
}
}
impl Drop for RegistrySandbox {
fn drop(&mut self) {
unsafe {
if self.redirected {
if let Err(error) = checked(RegOverridePredefKey(HKEY_CURRENT_USER, ptr::null_mut())) {
eprintln!("registry mapping restore failed: {error}");
return;
}
}
if !self.key.is_null() {
RegCloseKey(self.key);
// Exact unique proof key only; never the user's Internet Settings key.
if let Err(error) = checked(RegDeleteTreeW(HKEY_CURRENT_USER, self.path.as_ptr())) {
eprintln!("temporary registry key cleanup failed: {error}");
}
}
}
}
}