mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 08:15:46 +00:00
fix(ai): bypass proxies for loopback requests, fix IPv6 parsing
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/__pycache__/
|
||||
Generated
+1548
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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 覆盖流式聊天,
|
||||
非流式聊天仍由前述核心入口测试覆盖。
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user