diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a376c995..966ed587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,14 +187,14 @@ jobs: - name: Build run: cargo build --release -p twcore - # macOS 那边的 smoke 脚本在这里跑不了(`stat -f`、`curl --unix-socket`、 - # 一堆 BSD 的写法),移植它是另一件事。但**至少要证明这个二进制真的 - # 起得来、控制面真的应答** —— 那两样恰恰是这个平台上和 unix 完全不同的 - # 实现:回环端口换掉了 socket,凭据是唯一的门。 + # macOS 那边的 smoke 脚本在这里跑不了(`stat -f`、一堆 BSD 的写法), + # 移植它是另一件事。但**至少要证明这个二进制真的起得来、控制面真的应答** + # —— 那两样恰恰是这个平台上和 unix 完全不同的实现:回环端口换掉了 socket, + # 握手是唯一的门。 # # 不验这个的话,Windows 上的失败模式是「CI 全绿,而用户装上之后界面 # 永远停在连接页」。 - - name: It starts, and the control plane answers only with the token + - name: It starts, and the control plane answers only after the handshake shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -202,7 +202,8 @@ jobs: New-Item -ItemType Directory -Force -Path $home_ | Out-Null $env:THINKWATCH_HOME = $home_ $cfg = Join-Path $home_ "config.yaml" - $proc = Start-Process -FilePath "target\release\twcore.exe" ` + $bin = "target\release\twcore.exe" + $proc = Start-Process -FilePath $bin ` -ArgumentList "serve","--config",$cfg,"--port","18999" ` -PassThru -NoNewWindow ` -RedirectStandardOutput (Join-Path $home_ "out.log") ` @@ -222,26 +223,32 @@ jobs: } Start-Sleep -Milliseconds 200 } - $port = (Get-Content $portFile -Raw).Trim() - $token = (Get-Content (Join-Path $home_ "control.token") -Raw).Trim() - $url = "http://127.0.0.1:$port/status" + Start-Sleep -Milliseconds 200 + $port = (Get-Content $portFile -Raw).Trim() Write-Host "control plane on $port" try { - $no = Invoke-WebRequest -Uri $url -SkipHttpErrorCheck -TimeoutSec 10 - if ($no.StatusCode -ne 401) { throw "without a token the control plane answered $($no.StatusCode), not 401" } + # 不握手的 HTTP 进不来:core 回一个拒绝字节就断开 + $plainGotIn = $false + try { + $no = Invoke-WebRequest -Uri "http://127.0.0.1:$port/status" -SkipHttpErrorCheck -TimeoutSec 10 + if ($no.Content -match "api_version") { $plainGotIn = $true } + } catch { + Write-Host "plain HTTP was turned away: $($_.Exception.Message)" + } + if ($plainGotIn) { throw "plain HTTP without the handshake got a status back" } - $yes = Invoke-WebRequest -Uri $url -TimeoutSec 10 -Headers @{ Authorization = "Bearer $token" } - if ($yes.StatusCode -ne 200) { throw "with the token it answered $($yes.StatusCode)" } - $status = $yes.Content | ConvertFrom-Json - if (-not $status.version) { throw "the status carried no version: $($yes.Content)" } + # 握手之后:`twcore call` 读同一份配置里的钥匙,走和桌面端同一条握手 + $out = & $bin --config $cfg call /status + if ($LASTEXITCODE -ne 0) { throw "twcore call /status failed ($LASTEXITCODE)" } + $status = $out | ConvertFrom-Json + if (-not $status.version) { throw "the status carried no version: $out" } Write-Host "gateway $($status.version), api $($status.api_version)" # **请它退出,它就该退。**这条路在这个平台上没有替代品: # 没有 SIGTERM,而桌面端要靠它在改完配置后重启 core、在装更新 # 之前停掉它。只能强杀意味着 WAL 不收尾、在途请求断在半路。 - $bye = Invoke-WebRequest -Uri "http://127.0.0.1:$port/shutdown" -Method Post ` - -TimeoutSec 10 -Headers @{ Authorization = "Bearer $token" } - if ($bye.StatusCode -ne 202) { throw "asking it to exit answered $($bye.StatusCode)" } + $code = & $bin --config $cfg call -X POST --out (Join-Path $home_ "bye.json") /shutdown + if ("$code".Trim() -ne "202") { throw "asking it to exit answered $code" } # 等**进程**没了,不是等端口没了 —— 一个卡住的进程也可能丢掉监听 if (-not $proc.WaitForExit(10000)) { throw "it was asked to exit and is still running after 10s" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 655fcd6f..3dc49594 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,8 @@ surface lints you cannot reproduce — `rustup update stable` before blaming CI. `scripts/smoke.sh` runs the real binary against a real socket and a real -data plane. **It catches what unit tests structurally cannot** — file +data plane, talking to the control plane through `twcore call` (every +control connection starts with a Noise handshake, so curl cannot). **It catches what unit tests structurally cannot** — file permissions, socket path limits, an endpoint that simply isn't registered, a config field silently swallowed. This project's first four real bugs were all in those seams. Tests that hit the live network are @@ -81,6 +82,10 @@ clean the diff is: - **Anything that bypasses the main pipeline re-applies its protections.** Replay came close to being a legitimate way around redaction. +- **One door into the control plane.** Every transport (unix socket, + Windows loopback port, and the remote port to come) hands its + connections to the same handshake before HTTP. The control key never + leaves through the control plane and cannot be changed through it. ## The price list diff --git a/Cargo.lock b/Cargo.lock index 6642fd69..434c9a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -239,6 +249,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.7" @@ -306,6 +325,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -317,6 +347,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -331,6 +374,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.6" @@ -470,6 +524,31 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -484,6 +563,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -548,6 +628,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -1062,6 +1148,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1323,6 +1418,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1347,6 +1448,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1468,7 +1580,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1950,6 +2062,20 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.4", + "rustc_version", + "subtle", +] + [[package]] name = "socket2" version = "0.6.5" @@ -2472,7 +2598,6 @@ dependencies = [ "hyper", "hyper-util", "libc", - "rand 0.10.2", "reqwest", "serde", "serde_json", @@ -2491,6 +2616,7 @@ dependencies = [ "tw-engine", "tw-gateway", "tw-guard", + "tw-link", "tw-observe", "tw-pricing", "tw-scan", @@ -2578,6 +2704,20 @@ dependencies = [ "tw-dialect", ] +[[package]] +name = "tw-link" +version = "0.46.0" +dependencies = [ + "serde", + "serde_json", + "snow", + "tempfile", + "thiserror", + "tokio", + "tw-api", + "tw-yaml", +] + [[package]] name = "tw-observe" version = "0.46.0" @@ -2678,6 +2818,9 @@ dependencies = [ "anyhow", "chrono", "clap", + "http-body-util", + "hyper", + "hyper-util", "libc", "serde_yaml_ng", "tempfile", @@ -2690,6 +2833,7 @@ dependencies = [ "tw-control", "tw-gateway", "tw-guard", + "tw-link", "tw-observe", "tw-pricing", "tw-scan", @@ -2711,6 +2855,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index 7494f067..68fbbba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ members = [ "crates/tw-observe", "crates/tw-gateway", "crates/tw-control", + # 控制通道的握手与加密。桌面端按 tag 依赖它,所以只依赖契约层和 tw-yaml + "crates/tw-link", # ── bin ────────────────────────────────────────────── "bin/twcore", ] @@ -59,6 +61,7 @@ tw-watch = { path = "crates/tw-watch" } tw-guard = { path = "crates/tw-guard" } tw-breaker = { path = "crates/tw-breaker" } tw-dialect = { path = "crates/tw-dialect" } +tw-link = { path = "crates/tw-link" } # ── 版本与企业版 workspace 对齐,便于反向依赖时不打架 ──── tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "io-util", "signal"] } @@ -103,6 +106,9 @@ tokio-rustls = "0.26" # 栈,否则会出现「数据面信任的根证书」和「WS 信任的」是两套 tokio-tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } base64 = "0.22" +# 控制通道的握手(tw-link)。Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s 用到的 +# 三样算法之外一概不开:默认特性会带上 AES-GCM、SHA2 +snow = { version = "0.10", default-features = false, features = ["use-curve25519", "use-chacha20poly1305", "use-blake2", "use-getrandom"] } futures = "0.3" tokio-stream = "0.1" diff --git a/README.md b/README.md index 550e5d86..e00d1239 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Point a client (Claude Code, Codex, and friends) at a local port, and: tw-dialect · tw-guard · tw-breaker ← shared with the server edition tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch ← domain logic tw-config · tw-store · tw-scan · tw-adopt · tw-observe ← assembly -tw-gateway · tw-control ← data plane / control plane +tw-gateway · tw-control · tw-link ← data plane / control plane ``` The server edition depends on the top layer and nothing else: format @@ -71,6 +71,26 @@ scripts/smoke.sh # from a clean slate, exercise every path on the real `scripts/smoke.sh` touches nothing of yours — `HOME` and `THINKWATCH_HOME` both point at a temporary directory that is deleted when it finishes. +## The control plane + +The control plane listens on a unix socket (a loopback port on Windows). +Every connection starts with a handshake — +`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`, implemented in `tw-link` — keyed by +`listen.control.key` in `config.yaml`. `twcore serve` writes that key before it +starts listening (into a new configuration, or as one added line in an existing +one). HTTP runs inside the encrypted channel, so curl cannot talk to it; +`twcore call` can: + +``` +twcore control-key # print the key the desktop app connects with +twcore control-key --rotate # replace it; connections made with the old key are closed +twcore call /status +twcore call -X POST -d '{"model":"claude-sonnet-4-5","route":"default"}' /dryrun +``` + +The configuration text the control plane hands out has the key masked, and a +write through the control plane cannot change it. + ## License MIT diff --git a/README.zh-CN.md b/README.zh-CN.md index 7b8ba5ef..6e381246 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,7 @@ cargo run -p twcore -- serve # 起网关和控制面 tw-dialect · tw-guard · tw-breaker ← 与服务端版本共用 tw-types · tw-engine · tw-pricing · tw-yaml · tw-secret · tw-watch ← 领域逻辑 tw-config · tw-store · tw-scan · tw-adopt · tw-observe ← 装配 -tw-gateway · tw-control ← 数据面 / 控制面 +tw-gateway · tw-control · tw-link ← 数据面 / 控制面 ``` 服务端版本只依赖最上面一层:格式转换与用量解析(tw-dialect)、脱敏与工具调用 @@ -62,6 +62,23 @@ scripts/smoke.sh # 从零起,在真二进制上把每条路走一遍 `scripts/smoke.sh` 不碰你自己的任何东西 —— `HOME` 和 `THINKWATCH_HOME` 都指向一个临时目录,跑完就删。 +## 控制面 + +控制面听在一个 unix socket 上(Windows 上是一个回环端口)。每条连接先握手 +(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`,实现在 `tw-link`),钥匙是 +`config.yaml` 里的 `listen.control.key`。`twcore serve` 在开始监听之前写好它 +(新配置自带;旧配置只补这一行)。HTTP 跑在加密的通道里面,curl 连不上, +用 `twcore call`: + +``` +twcore control-key # 打印桌面端连接用的钥匙 +twcore control-key --rotate # 换一把;用旧钥匙建的连接随即断开 +twcore call /status +twcore call -X POST -d '{"model":"claude-sonnet-4-5","route":"default"}' /dryrun +``` + +控制面发出去的配置原文里钥匙是打码的,经控制面的写入也改不了它。 + ## License MIT diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index 8a994508..c77752df 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -26,6 +26,11 @@ tw-guard = { workspace = true } serde_yaml_ng = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +# `twcore call`:握手之后在加密的流上说 HTTP,和桌面端一样 +tw-link = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true, features = ["tokio"] } +http-body-util = { workspace = true } tracing-subscriber = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/bin/twcore/src/call.rs b/bin/twcore/src/call.rs new file mode 100644 index 00000000..9c8299c7 --- /dev/null +++ b/bin/twcore/src/call.rs @@ -0,0 +1,104 @@ +//! `twcore call`:往本机正在跑的 core 的控制面发一个请求。 +//! +//! 控制面的每条连接都要先握手(`tw-link`),curl 敲不开它了。这个命令读 +//! 同一份配置里的钥匙、走和桌面端同一条握手,于是调试和脚本(包括 CI 的 +//! smoke)照样能问 core。 + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use http_body_util::{BodyExt, Full}; +use hyper::body::Bytes; +use tw_api::control::Address; + +pub fn run( + config: &Path, + endpoint: &str, + method: &str, + data: Option, + out: Option, +) -> Result<()> { + let dir = config + .parent() + .map(PathBuf::from) + .unwrap_or_else(tw_api::data::dir); + let key = tw_link::read_key(config) + .with_context(|| format!("reading the control key from {}", config.display()))?; + let method: hyper::Method = method + .to_ascii_uppercase() + .parse() + .with_context(|| format!("`{method}` is not an HTTP method"))?; + let endpoint = if endpoint.starts_with('/') { + endpoint.to_string() + } else { + format!("/{endpoint}") + }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let (status, body) = rt.block_on(async move { + match Address::in_dir(&dir) { + #[cfg(unix)] + Address::Socket(p) => { + let s = tokio::net::UnixStream::connect(&p) + .await + .with_context(|| format!("connecting to {}", p.display()))?; + send(s, &key, method, &endpoint, data).await + } + #[cfg(not(unix))] + Address::Socket(p) => anyhow::bail!("{} is a unix socket", p.display()), + Address::Loopback { port_file } => { + let port: u16 = std::fs::read_to_string(&port_file) + .with_context(|| format!("reading {}", port_file.display()))? + .trim() + .parse() + .with_context(|| format!("{} holds no port number", port_file.display()))?; + let s = tokio::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, port)) + .await + .with_context(|| format!("connecting to 127.0.0.1:{port}"))?; + send(s, &key, method, &endpoint, data).await + } + } + })?; + match out { + Some(file) => { + std::fs::write(&file, &body).with_context(|| format!("writing {}", file.display()))?; + println!("{status}"); + } + None => { + use std::io::Write; + std::io::stdout().write_all(&body)?; + } + } + Ok(()) +} + +async fn send( + stream: S, + key: &tw_link::ControlKey, + method: hyper::Method, + endpoint: &str, + data: Option, +) -> Result<(u16, Bytes)> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let (link, _) = tw_link::connect(stream, key, concat!("twcore ", env!("CARGO_PKG_VERSION"))) + .await + .context("the control-plane handshake")?; + let (mut sender, conn) = + hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(link)).await?; + tokio::spawn(conn); + let mut req = hyper::Request::builder() + .method(method) + .uri(endpoint) + .header(hyper::header::HOST, "localhost"); + if data.is_some() { + req = req.header(hyper::header::CONTENT_TYPE, "application/json"); + } + let req = req.body(Full::new(Bytes::from(data.unwrap_or_default())))?; + let resp = sender.send_request(req).await?; + let status = resp.status().as_u16(); + let body = resp.into_body().collect().await?.to_bytes(); + Ok((status, body)) +} diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index c760e914..563c9d86 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +mod call; mod lockfile; mod proc; @@ -84,6 +85,32 @@ enum Command { #[command(subcommand)] what: ClientsCmd, }, + /// Print the control key, which the desktop app connects with + // + // 远程连接时用户在服务器上跑它,把钥匙抄进应用 + ControlKey { + /// Replace the key with a new one. A running core takes the new key within a second + /// and closes the connections made with the previous one + #[arg(long)] + rotate: bool, + }, + /// Send one request to the running core's control plane and print the response + // + // **curl 敲不开控制面了**:每条连接先握手。调试、脚本、smoke 用这个 —— + // 它读的是同一份配置里的钥匙,走的是和桌面端同一条握手 + Call { + /// The endpoint, such as /status or /history?limit=1 + path: String, + /// The HTTP method + #[arg(short = 'X', long, default_value = "GET")] + method: String, + /// A JSON request body + #[arg(short, long)] + data: Option, + /// Write the response body to this file and print only the status code + #[arg(short, long)] + out: Option, + }, } #[derive(Subcommand)] @@ -151,6 +178,13 @@ fn main() -> Result<()> { Command::Config { what } => cmd_config(&path, what), Command::Clients { what } => cmd_clients(&path, what), Command::Scan { project, inventory } => cmd_scan(&path, project, inventory), + Command::ControlKey { rotate } => cmd_control_key(&path, rotate), + Command::Call { + path: endpoint, + method, + data, + out, + } => call::run(&path, &endpoint, &method, data, out), } } @@ -551,6 +585,36 @@ fn print_l1(target: &str, via: Option<&str>, r: &tw_gateway::L1Result) { println!(); } +/// 打印控制面的钥匙,或者换一把。 +/// +/// **只在 core 那台机器上能跑**:它读写的是本机的配置文件。换钥匙不经过 +/// 控制面 —— 经控制面进来的一方改得动这扇门,就改得掉别人进来的路。 +fn cmd_control_key(path: &Path, rotate: bool) -> Result<()> { + if !path.exists() { + anyhow::bail!( + "{} does not exist. twcore serve writes it on first start; twcore init writes it \ + without starting", + path.display() + ); + } + if rotate { + let key = tw_config::control_key::rotate_file(path)?; + println!("{}", key.to_hex()); + eprintln!( + "(a running core takes the new key within a second and closes the connections made \ + with the previous one; the desktop app on this machine reconnects by itself, and one \ + on another machine needs the new key)" + ); + return Ok(()); + } + // 还没有钥匙的旧配置:补上再打印。`serve` 起来时也会这样补,这里先补 + // 不改变任何行为,只是省得让人先去起一次 core + tw_config::control_key::ensure_file(path)?; + let key = tw_link::read_key(path)?; + println!("{}", key.to_hex()); + Ok(()) +} + fn cmd_init(path: &Path, force: bool) -> Result<()> { if path.exists() && !force { anyhow::bail!( @@ -569,6 +633,11 @@ fn cmd_init(path: &Path, force: bool) -> Result<()> { "(configure a client with it. The tw- prefix tells a gateway key from an upstream API key.)" ); println!(); + println!( + "The configuration also holds the control key, which the desktop app connects with; \ + twcore control-key prints it." + ); + println!(); println!("Next: add an upstream under providers, then run twcore serve."); Ok(()) } @@ -579,10 +648,29 @@ fn write_config(path: &Path, cfg: &tw_config::Config) -> Result<()> { Ok(tw_config::write(path, cfg)?) } +/// 读配置、按 `serve` 会看到的样子校验。 +/// +/// **缺控制面的钥匙不算错**:`serve` 起来时会补上(只补那一行),这里在内存里 +/// 同样补一把再校验,说一句就好 —— 否则升级之后的第一次 check 会把一份好好的 +/// 配置报成坏的。 +fn check_like_serve(path: &Path) -> Result<(tw_config::Config, bool)> { + let text = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let (text, added) = match tw_config::control_key::ensure(&text) { + Ok(Some(with_key)) => (with_key, true), + _ => (text, false), + }; + let cfg = tw_config::try_parse(&text).map_err(|r| anyhow::anyhow!("{r}"))?; + Ok((cfg, added)) +} + fn cmd_check(path: &Path) -> Result<()> { - match tw_config::load(path) { - Ok(cfg) => { + match check_like_serve(path) { + Ok((cfg, key_missing)) => { println!("✅ {} is valid", path.display()); + if key_missing { + println!(" · it has no control key yet; twcore serve adds one when it starts"); + } println!( " {} gateway keys, {} upstreams", cfg.clients.len(), @@ -673,7 +761,7 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> .map(PathBuf::from) .unwrap_or_else(tw_api::data::dir); // **数据目录在这儿建,而且只在这儿。**后面每一步(锁、控制面的 socket、 - // 凭据文件)都会顺手 `create_dir_all`,谁先到谁建 —— 而 Windows 上「谁 + // 端口文件)都会顺手 `create_dir_all`,谁先到谁建 —— 而 Windows 上「谁 // 建的」决定了它的 ACL,也就决定了那份明文密钥同机的其他用户读不读得到。 tw_config::private_dir::create(&dir).with_context(|| format!("creating {}", dir.display()))?; let _lock = match LockFile::acquire(&dir)? { @@ -702,6 +790,17 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> tw_config::write(path, &cfg) .with_context(|| format!("writing the initial configuration {}", path.display()))?; tracing::info!(path = %path.display(), "first run; wrote an initial configuration"); + } else { + // **控制面的钥匙在监听之前写好**:桌面端看到 socket 出现就来读它。旧配置 + // 没有这一行就只补这一行(最小替换,注释和排版不动)—— 升级不需要迁移。 + // 补不上(YAML 写坏了)不在这里停:下一步加载会说清哪一行坏了 + match tw_config::control_key::ensure_file(path) { + Ok(true) => { + tracing::info!(path = %path.display(), "added a control key to the configuration") + } + Ok(false) => {} + Err(e) => tracing::warn!("a control key could not be added to the configuration: {e}"), + } } let cfg = tw_config::load(path).with_context(|| format!("loading {}", path.display()))?; // `--port` 是一个**显式的覆盖**,配置文件不该推翻它。所以给了它 @@ -731,10 +830,6 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> // 「该退了」的开关。**在起任何东西之前建好** —— 控制面和主循环各拿 // 一份,而控制面可能在主循环开始等之前就收到那条请求。 let shutdown = tw_control::Shutdown::default(); - // 控制面的凭据,同样在起任何东西之前拿到:桌面端从环境变量交过来, - // 手工启动时生成一个写进配置目录。取不到就停在这儿 —— 一个装不上门的 - // 控制面不该先起来再说。 - let control_token = tw_control::token::Token::resolve(&dir)?; let config_path = path.to_path_buf(); rt.block_on(async move { let state = tw_gateway::AppState::new(cfg.clone()) @@ -850,7 +945,7 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> // 太长)只在日志里躺着。退出让那句话有机会走到人眼前。 let (control_died, control_dead) = tokio::sync::oneshot::channel(); tokio::spawn(async move { - let r = tw_control::serve(control, &at, control_token).await; + let r = tw_control::serve(control, &at).await; let msg = match r { Err(e) => format!("{e}"), // serve 正常返回意味着 accept 循环结束了,同样是没了 diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 78076121..84a38742 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -99,6 +99,8 @@ adopt.warn.world_readable config.bad_allow_from config.bad_base_url config.blank_models_only +config.control_key_invalid +config.control_key_missing config.credential.bad_header_name config.credential.bad_header_value config.credential.chatgpt_without_login @@ -171,6 +173,7 @@ control.chatgpt_login.no_code control.chatgpt_login.page_error control.client_unknown control.config_stale +control.control_key_locked control.default_key_cannot_delete control.default_key_cannot_disable control.default_route_cannot_delete @@ -249,7 +252,6 @@ control.signin_gone control.signin_response_unusable control.store_off control.store_unavailable -control.unauthorized control.unknown_probe_class control.upstream_in_use control.upstream_not_found diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index 9fb505e9..fb66e65e 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -486,7 +486,14 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// **17 起「此刻有什么不对」都能问到**,不必从事件流开头听起:`Status.config_rejected` /// (磁盘上那份配置没通过校验、旧的还在服务)和 `ProviderView.writeback_failed` /// (换发的凭据没能写回配置)。半路才连上的一方(桌面端的提醒)按它们对账。 -pub const CONTROL_API_VERSION: u32 = 17; +/// +/// **18 起每条控制面连接先握手,Bearer 头没了。**握手是 Noise +/// (`tw-link`),钥匙是配置里的 `listen.control.key`,`TW_CONTROL_TOKEN` 和 +/// `control.token` 都不在了。和 12 一样,照 17 写的客户端**看不到这次跳号**: +/// 它连 HTTP 都说不上,握手第一条就被拒。版本号从这一版起在握手里交换 +/// (`tw_link::ClientHello.proto`),不一致时握手就说,不必等到 `/status`。 +/// 同一版起 `GET /config` 发出的正文里钥匙是打码的([`control::KEY_MASK`])。 +pub const CONTROL_API_VERSION: u32 = 18; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] @@ -4411,7 +4418,7 @@ mod tests { /// 数据目录在哪。 /// /// **在契约层,不在 tw-config。**core 和桌面端必须落到同一个目录 —— 端口文件、 -/// 凭据文件、配置都在里面,两边各算一遍、算得不一样的话,界面找不到一个正在 +/// socket、配置(连同控制面的钥匙)都在里面,两边各算一遍、算得不一样的话,界面找不到一个正在 /// 跑的网关。以前桌面端自己只看 `HOME`,Windows 上那个变量默认不存在,于是它 /// 落到当前目录下的 `.thinkwatch`,而 core 在 `%APPDATA%\ThinkWatch`。控制面 /// 的地址(`control::Address`)因为同样的理由搬到了这里。 @@ -4439,7 +4446,7 @@ pub mod data { }; } - /// 数据目录:配置、请求库、锁、控制面的端口和凭据文件都在这里。 + /// 数据目录:配置、请求库、锁、控制面的 socket 或端口文件都在这里。 /// /// `THINKWATCH_HOME` 最优先 —— 测试靠它隔离,用户靠它换地方。 /// @@ -4572,7 +4579,7 @@ pub mod data { } } -/// 控制面在哪儿、拿什么进门。 +/// 控制面在哪儿、拿哪把钥匙进门。 /// /// **这是契约的一部分,不是两边各自的约定。**core 在这儿听,桌面端到这儿连 /// —— 以前两边各拼一次 `<数据目录>/twcore.sock`,能对上只是因为那一行足够 @@ -4588,11 +4595,22 @@ pub mod control { pub const SOCKET_FILE: &str = "twcore.sock"; /// Windows 上控制面绑到哪个端口,由 core 写、由客户端读。 pub const PORT_FILE: &str = "control.port"; - /// 手工启动 core 时凭据落在哪。 - pub const TOKEN_FILE: &str = "control.token"; - /// 父进程把凭据交过来的环境变量。**走环境不走 argv** —— Windows 上任意 - /// 同用户进程都看得见别人的命令行。 - pub const TOKEN_ENV: &str = "TW_CONTROL_TOKEN"; + /// 数据目录下的配置文件。控制面的钥匙就写在它里面([`KEY_PATH`])。 + pub const CONFIG_FILE: &str = "config.yaml"; + /// 钥匙在配置里的位置:`listen.control.key`。 + /// + /// **两边拼的是同一个位置**:core 生成、补上、打码都照它找,桌面端读 + /// 也照它找。各写一遍字符串,漂掉的表现是「界面拿着空钥匙去敲门」。 + pub const KEY_PATH: [&str; 3] = ["listen", "control", "key"]; + /// 钥匙写成多少个十六进制字符。32 字节。 + pub const KEY_HEX_LEN: usize = 64; + /// 配置正文发给界面、写进历史时,钥匙换成这一串。 + /// + /// **和一把真钥匙一样长**(64 个字符):界面按光标位置问「这是哪一段」 + /// (`GET /config/at`),打码前后长度一样,后面每一处的偏移才对得上。 + /// 它不是十六进制,所以不会被错当成一把钥匙;整份写回时带着它,就是 + /// 「钥匙不动」。 + pub const KEY_MASK: &str = "hidden-see-twcore-control-key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; /// 控制面听在哪,客户端到哪儿连。 /// @@ -4608,7 +4626,7 @@ pub mod control { /// core 绑到一个系统给的空闲端口,再把号码写进这个文件。 /// /// 回环端口挡不住同机的任何进程,也问不出对端是谁(unix socket 问 - /// 得出,`SO_PEERCRED`)—— 那一档的门**全靠凭据**。 + /// 得出,`SO_PEERCRED`)—— 那一档的门**全靠握手**。 Loopback { port_file: PathBuf }, } @@ -4628,9 +4646,102 @@ pub mod control { } } - /// 凭据文件在哪。 - pub fn token_file(dir: &Path) -> PathBuf { - dir.join(TOKEN_FILE) + /// 配置文件在哪。 + pub fn config_file(dir: &Path) -> PathBuf { + dir.join(CONFIG_FILE) + } + + /// 控制面的钥匙:32 个随机字节,配置里写成 64 个十六进制字符。 + /// + /// 它是握手(`tw-link`,Noise 的 PSK)的全部凭据:不知道它的一方连第一条 + /// 握手消息都解不开。**住在契约层而不是 tw-link**:tw-config 要按它校验 + /// 配置,而 tw-config 不该为了一个格式检查背上整套加密库。生成随机数的 + /// 那一步在 tw-config(它本来就依赖 rand),这里只有格式,仍然没有 IO。 + #[derive(Clone, PartialEq, Eq)] + pub struct ControlKey([u8; 32]); + + /// 一串文字不是一把钥匙的原因。 + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum KeyFormatError { + /// 长度不对。`len` 是去掉首尾空白之后的字符数 + Length { len: usize }, + /// 有不是十六进制的字符 + NotHex, + } + + impl std::fmt::Display for KeyFormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KeyFormatError::Length { len } => write!( + f, + "the control key is {len} characters long; it has to be {KEY_HEX_LEN} \ + hexadecimal characters" + ), + KeyFormatError::NotHex => write!( + f, + "the control key has characters that are not hexadecimal; it has to be \ + {KEY_HEX_LEN} of 0-9 and a-f" + ), + } + } + } + + impl std::error::Error for KeyFormatError {} + + impl ControlKey { + /// 按配置里的写法读:正好 64 个十六进制字符,大小写都认,首尾空白不算。 + /// + /// **短了不是「弱一点」,是不收。**一把好猜的钥匙和没有钥匙差不多, + /// 而这把钥匙守着能改全部配置、能关掉 core 的那扇门。 + pub fn parse(s: &str) -> Result { + let s = s.trim(); + let len = s.chars().count(); + if len != KEY_HEX_LEN || s.len() != KEY_HEX_LEN { + return Err(KeyFormatError::Length { len }); + } + let mut out = [0u8; 32]; + for (i, pair) in s.as_bytes().chunks(2).enumerate() { + let hi = hex_digit(pair[0]).ok_or(KeyFormatError::NotHex)?; + let lo = hex_digit(pair[1]).ok_or(KeyFormatError::NotHex)?; + out[i] = (hi << 4) | lo; + } + Ok(Self(out)) + } + + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// 写进配置的样子:64 个小写十六进制字符。 + pub fn to_hex(&self) -> String { + let mut out = String::with_capacity(KEY_HEX_LEN); + for b in self.0 { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + } + out + } + } + + fn hex_digit(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } + } + + /// **不打印出来。**钥匙会跟着别的结构体一起落进 `tracing` 的 Debug 输出, + /// 而日志是会被整段贴进 issue 的。 + impl std::fmt::Debug for ControlKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ControlKey()") + } } #[cfg(test)] @@ -4645,7 +4756,49 @@ pub mod control { Address::Socket(p) => assert_eq!(p, d.join(SOCKET_FILE)), Address::Loopback { port_file } => assert_eq!(port_file, d.join(PORT_FILE)), } - assert_eq!(token_file(d), d.join(TOKEN_FILE)); + assert_eq!(config_file(d), d.join(CONFIG_FILE)); + } + + #[test] + fn a_key_is_exactly_64_hex_characters() { + let hex = "9f2c".repeat(16); + let k = ControlKey::parse(&hex).unwrap(); + assert_eq!(k.to_hex(), hex); + assert_eq!(k.as_bytes()[0], 0x9f); + // 大写也认,写回去是小写;首尾空白不算 + let loud = format!(" {} ", hex.to_uppercase()); + assert_eq!(ControlKey::parse(&loud).unwrap(), k); + assert_eq!( + ControlKey::parse(&hex[..62]), + Err(KeyFormatError::Length { len: 62 }) + ); + assert_eq!( + ControlKey::parse(&format!("{hex}00")), + Err(KeyFormatError::Length { len: 66 }) + ); + assert_eq!( + ControlKey::parse(""), + Err(KeyFormatError::Length { len: 0 }) + ); + let bad = format!("{}zz", &hex[..62]); + assert_eq!(ControlKey::parse(&bad), Err(KeyFormatError::NotHex)); + // 多字节字符:字节数凑得上 64,字符数凑不上 + let wide = format!("{}\u{e9}", &hex[..62]); + assert_eq!(wide.len(), 64); + assert!(ControlKey::parse(&wide).is_err()); + } + + /// 打码用的那一串长度和真钥匙一样,但它本身不是一把钥匙。 + #[test] + fn the_mask_is_as_long_as_a_key_and_is_not_one() { + assert_eq!(KEY_MASK.len(), KEY_HEX_LEN); + assert!(ControlKey::parse(KEY_MASK).is_err()); + } + + #[test] + fn a_key_does_not_print_itself() { + let k = ControlKey::from_bytes([0xab; 32]); + assert!(!format!("{k:?}").contains("abab")); } /// 平台决定用哪一档,不是调用方挑。 diff --git a/crates/tw-config/src/control_key.rs b/crates/tw-config/src/control_key.rs new file mode 100644 index 00000000..28ddcf8c --- /dev/null +++ b/crates/tw-config/src/control_key.rs @@ -0,0 +1,270 @@ +//! 控制面的钥匙在配置原文里的几件事:补上、换掉、打码、把打码的换回来。 +//! +//! **都是最小替换**(tw-yaml):只动 `listen.control.key` 那一个标量,用户的 +//! 注释和排版原样留着。钥匙是 core 替用户写进去的一行,不该顺手把整份 +//! 文件按 serde 的样子重排一遍。 + +use std::path::Path; + +use tw_api::control::{ControlKey, KEY_HEX_LEN, KEY_MASK, KEY_PATH}; +use tw_yaml::{PatchError, Scalar, Step}; + +use crate::store::{self, StoreError}; + +fn at() -> Vec { + KEY_PATH.iter().map(|k| Step::key(*k)).collect() +} + +/// 原文里写着的那个值,**不管它是不是一把能用的钥匙**。没写是 `None`。 +pub fn raw_in(text: &str) -> Result, PatchError> { + match tw_yaml::find(text, &at()) { + Ok(f) => Ok(Some(f.value)), + Err(PatchError::NotFound(_)) => Ok(None), + Err(e) => Err(e), + } +} + +/// 没有钥匙就补一把,**只加这一行**。返回改过的原文;已经有了是 `None`。 +/// +/// 写着一把坏钥匙(短了、不是十六进制)**不替换**:那是用户自己写的,悄悄 +/// 换掉等于让他手里那一把失效。校验会说它不对,`twcore control-key --rotate` +/// 换新的。 +pub fn ensure(text: &str) -> Result, PatchError> { + if raw_in(text)?.is_some() { + return Ok(None); + } + let key = crate::generate_control_key(); + tw_yaml::insert(text, &at(), &Scalar::s(key.to_hex())).map(Some) +} + +/// 换一把新的钥匙。没有就补上。 +pub fn rotate(text: &str) -> Result<(String, ControlKey), PatchError> { + let key = crate::generate_control_key(); + let out = tw_yaml::insert(text, &at(), &Scalar::s(key.to_hex()))?; + Ok((out, key)) +} + +/// 发给界面、写进历史之前,钥匙换成 [`KEY_MASK`]。 +/// +/// **读不成 YAML 也要打码**:写坏的配置照样会被界面拿去显示(让用户改), +/// 而那时按路径找不到那一行。退一步把原文里每一段正好 64 个十六进制字符 +/// 的连续串都换掉 —— 宁可多打一处,不能漏掉钥匙。 +pub fn mask(text: &str) -> String { + match tw_yaml::find(text, &at()) { + Ok(f) if !f.aliased => match tw_yaml::set(text, &at(), &Scalar::s(KEY_MASK)) { + Ok(out) => out, + Err(_) => mask_hex_runs(text), + }, + Err(PatchError::NotFound(_)) => text.to_string(), + _ => mask_hex_runs(text), + } +} + +/// 原文里每一段正好 [`KEY_HEX_LEN`] 个十六进制字符的连续串,换成打码。 +/// +/// 也给报错时摘出来的那一行用:一行读不成 YAML,按路径找不到钥匙。 +pub(crate) fn mask_hex_runs(text: &str) -> String { + let b = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + let mut last = 0; + while i < b.len() { + if !b[i].is_ascii_hexdigit() { + i += 1; + continue; + } + let start = i; + while i < b.len() && b[i].is_ascii_hexdigit() { + i += 1; + } + if i - start == KEY_HEX_LEN { + out.push_str(&text[last..start]); + out.push_str(KEY_MASK); + last = i; + } + } + out.push_str(&text[last..]); + out +} + +/// 界面整份写回来的原文里,钥匙那一处是打码的:换回 `real` 那一把。 +/// +/// **打码就是「钥匙不动」**:界面从来拿不到真钥匙,它写回来的只能是打码。 +/// 换回来之后这份原文的钥匙就是现在那一把,写入才过得了「控制面不能改钥匙」 +/// 那一关。没有 `real` 时原样返回 —— 校验会说它不是一把钥匙。 +pub fn unmask(text: &str, real: Option<&str>) -> Result { + if raw_in(text)?.as_deref() != Some(KEY_MASK) { + return Ok(text.to_string()); + } + match real { + Some(real) => tw_yaml::set(text, &at(), &Scalar::s(real)), + None => Ok(text.to_string()), + } +} + +/// `serve` 起控制面之前:配置文件里没有钥匙就补一把。返回改没改。 +/// +/// **先写钥匙、再监听**:桌面端看到 socket 出现就会来读钥匙。文件不存在 +/// 不在这里管 —— 那是生成初始配置那一步的事,那份配置自带钥匙。 +pub fn ensure_file(path: &Path) -> Result { + let cur = store::read(path)?; + match ensure(&cur.text)? { + None => Ok(false), + Some(next) => { + store::write_if_unchanged(path, &cur.fingerprint, &next)?; + Ok(true) + } + } +} + +/// 换钥匙并写回文件。`twcore control-key --rotate` 走这条。 +/// +/// **写之前校验**:换完钥匙的配置得是一份能用的配置,否则写下去等于把 +/// 一份坏配置交给正在跑的 core(它会拒绝、继续用旧钥匙),而命令行却说 +/// 换好了。 +pub fn rotate_file(path: &Path) -> Result { + let cur = store::read(path)?; + let (next, key) = rotate(&cur.text)?; + crate::try_parse(&next).map_err(|r| EnsureError::Rejected(Box::new(r)))?; + let _ = crate::history::snapshot(path, &cur.text, crate::history::Origin::Cli); + store::write_if_unchanged(path, &cur.fingerprint, &next)?; + let _ = crate::history::snapshot(path, &next, crate::history::Origin::Cli); + Ok(key) +} + +#[derive(Debug, thiserror::Error)] +pub enum EnsureError { + #[error(transparent)] + Store(#[from] StoreError), + #[error(transparent)] + Patch(#[from] PatchError), + #[error("{0}")] + Rejected(Box), +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + + fn key_of(text: &str) -> Option { + raw_in(text).unwrap() + } + + /// 旧配置没有钥匙:只多出钥匙这几行,注释和其余的一个字节不动。 + #[test] + fn a_key_is_added_without_touching_anything_else() { + let before = "version: 1 # 别动我\n# 上游\nproviders: []\nclients:\n - { name: default, key: tw-abc }\n"; + let after = ensure(before).unwrap().expect("没有钥匙就该补上"); + let k = key_of(&after).unwrap(); + assert!(ControlKey::parse(&k).is_ok(), "{after}"); + // 去掉新加的那几行,剩下的就是原文 + let kept: String = after + .lines() + .filter(|l| !(l.starts_with("listen:") || l.contains("control:") || l.contains(&k))) + .map(|l| format!("{l}\n")) + .collect(); + assert_eq!(kept, before, "{after}"); + crate::try_parse(&after).unwrap_or_else(|e| panic!("{e}\n{after}")); + // 第二次什么都不做 + assert_eq!(ensure(&after).unwrap(), None); + } + + /// 已经有 `listen.gateway` 的:钥匙加在同一个 `listen` 下面。 + #[test] + fn a_key_joins_an_existing_listen_section() { + let before = "version: 1\nlisten:\n gateway:\n port: 9000 # 自己改的\nclients:\n - { name: default, key: tw-abc }\n"; + let after = ensure(before).unwrap().unwrap(); + assert!(after.contains(" port: 9000 # 自己改的\n"), "{after}"); + let cfg = crate::try_parse(&after).unwrap_or_else(|e| panic!("{e}\n{after}")); + assert_eq!(cfg.listen.gateway.port, 9000); + assert!(cfg.listen.control.key().is_some()); + } + + /// 写坏了的钥匙不替换:那是用户的,校验会说它不对。 + #[test] + fn a_bad_key_is_left_for_the_user_to_fix() { + let before = "version: 1\nlisten:\n control:\n key: short\n"; + assert_eq!(ensure(before).unwrap(), None); + } + + #[test] + fn rotation_replaces_only_the_value() { + let before = format!("version: 1\nlisten:\n control:\n key: \"{HEX}\" # 钥匙\n"); + let (after, key) = rotate(&before).unwrap(); + assert_ne!(key.to_hex(), HEX); + assert_eq!( + after, + before.replace(HEX, &key.to_hex()), + "引号和注释都该留着" + ); + } + + /// 打码前后一样长:界面按光标位置问「这是哪一段」,偏移要对得上。 + #[test] + fn masking_keeps_the_length_and_the_quotes() { + for text in [ + format!("version: 1\nlisten:\n control:\n key: {HEX}\nclients: []\n"), + format!("version: 1\nlisten:\n control:\n key: \"{HEX}\"\nclients: []\n"), + format!("version: 1\nlisten: {{ control: {{ key: '{HEX}' }} }}\n"), + ] { + let m = mask(&text); + assert!(!m.contains(HEX), "{m}"); + assert!(m.contains(KEY_MASK), "{m}"); + assert_eq!(m.len(), text.len(), "{m}"); + assert_eq!(key_of(&m).as_deref(), Some(KEY_MASK)); + } + // 没有钥匙就原样 + assert_eq!(mask("version: 1\n"), "version: 1\n"); + } + + /// 写坏了的 YAML 找不到那一行,钥匙也不能漏出去。 + #[test] + fn masking_still_works_when_the_yaml_is_broken() { + let text = format!("version: 1\nlisten:\n control:\n key: {HEX}\n - oops: [\n"); + let m = mask(&text); + assert!(!m.contains(HEX), "{m}"); + assert!(m.contains(KEY_MASK), "{m}"); + // 别的十六进制串(比如 65 位的)不动 + let other = "a".repeat(65); + assert_eq!(mask_hex_runs(&other), other); + } + + #[test] + fn a_masked_key_written_back_becomes_the_real_one_again() { + let current = format!("version: 1\nlisten:\n control:\n key: {HEX}\n"); + let edited = mask(¤t).replace("version: 1", "version: 1 # 改了一处"); + let back = unmask(&edited, Some(HEX)).unwrap(); + assert_eq!(key_of(&back).as_deref(), Some(HEX)); + assert!(back.contains("# 改了一处")); + // 不是打码的就不碰:换了钥匙、删了钥匙,都原样交给后面那一关 + let other = current.replace(HEX, &"1".repeat(64)); + assert_eq!(unmask(&other, Some(HEX)).unwrap(), other); + assert_eq!(unmask("version: 1\n", Some(HEX)).unwrap(), "version: 1\n"); + // 不知道真钥匙是哪把:原样交出去,校验会说打码的那串不是钥匙 + assert_eq!(unmask(&edited, None).unwrap(), edited); + } + + #[test] + fn a_file_without_a_key_gets_one_and_a_second_pass_changes_nothing() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write( + &p, + "version: 1\nclients:\n - { name: default, key: tw-abc }\n", + ) + .unwrap(); + assert!(ensure_file(&p).unwrap()); + let text = std::fs::read_to_string(&p).unwrap(); + assert!(crate::try_parse(&text).is_ok(), "{text}"); + assert!(!ensure_file(&p).unwrap()); + assert_eq!(std::fs::read_to_string(&p).unwrap(), text); + + let before = key_of(&text).unwrap(); + let k = rotate_file(&p).unwrap(); + let after = std::fs::read_to_string(&p).unwrap(); + assert_eq!(key_of(&after).unwrap(), k.to_hex()); + assert_ne!(k.to_hex(), before); + } +} diff --git a/crates/tw-config/src/edit.rs b/crates/tw-config/src/edit.rs index 65bb4a20..6e98b2b4 100644 --- a/crates/tw-config/src/edit.rs +++ b/crates/tw-config/src/edit.rs @@ -392,6 +392,9 @@ mod tests { } const CFG: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: default key: tw-a diff --git a/crates/tw-config/src/history.rs b/crates/tw-config/src/history.rs index cd188353..b919a9cc 100644 --- a/crates/tw-config/src/history.rs +++ b/crates/tw-config/src/history.rs @@ -147,7 +147,10 @@ pub fn snapshot( origin.slug(), &version["blake3:".len()..] )); - store::write_atomic(&file, text)?; + // **钥匙不进历史**:存的是打码的原文,版本号仍按真原文算(文件名里那一段), + // 「哪一版是现在这版」照样对得上。回滚到这一版时打码换回当时磁盘上的钥匙 + // (见 [`crate::control_key::unmask`])—— 回滚不该顺手把门锁换掉 + store::write_atomic(&file, &crate::control_key::mask(text))?; prune(config_path)?; Ok(Some(Version { file, @@ -237,10 +240,15 @@ pub fn rollback(config_path: &Path, version: &str) -> Result String { format!("tw-{body}") } +/// 生成一把控制面的钥匙:32 个随机字节。 +/// +/// **`serve` 首次运行、`serve` 给旧配置补钥匙、`init`、`control-key --rotate` +/// 都用它**,不各生成各的。 +pub fn generate_control_key() -> tw_api::control::ControlKey { + let mut bytes = [0u8; 32]; + rand::fill(&mut bytes); + tw_api::control::ControlKey::from_bytes(bytes) +} + /// 一份还没有上游的骨架配置。 /// /// 它**是合法的**(见 `validate` 里那段注释):core 要能带着它起来, @@ -31,6 +41,12 @@ pub fn generate_initial() -> Config { key: generate_key(), ..Default::default() }], + listen: Listen { + control: ControlListen { + key: Some(generate_control_key().to_hex()), + }, + ..Default::default() + }, // 其余全是默认值:没有 provider、没有代理、没有规则。 // 层 0(不写规则也能跑)就是这份配置的形状。 ..Default::default() @@ -72,6 +88,24 @@ mod tests { assert!(crate::validate::validate(&c).is_ok()); } + /// 骨架配置里有钥匙,而网关那一段仍然是默认值、不写进文件。 + #[test] + fn the_skeleton_carries_a_control_key_and_nothing_else_under_listen() { + let c = generate_initial(); + assert!(c.listen.control.key().is_some()); + let text = serde_yaml_ng::to_string(&c).unwrap(); + assert!(text.contains("control:"), "{text}"); + assert!( + !text.contains("gateway"), + "默认的网关监听被写进文件了:{text}" + ); + assert_ne!( + generate_initial().listen.control.key, + c.listen.control.key, + "每份配置的钥匙都该是新生成的" + ); + } + #[test] fn the_generated_config_round_trips_through_yaml() { // 生成出来的东西必须自己能读回去,否则首次运行就废了。 diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index 4cc4b70c..f0701a92 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; pub mod chatgpt; +pub mod control_key; pub mod credential; pub mod edit; pub mod history; @@ -25,7 +26,7 @@ pub mod watch; mod wire; pub use credential::{CredentialError, Header, Headers, Secret, SecretResolveError, auth_header}; -pub use init::{generate_initial, generate_key}; +pub use init::{generate_control_key, generate_initial, generate_key}; pub use proxy::{DIRECT, OnProxyFail, Proxy, ProxyKind, SYSTEM}; pub use validate::ValidationError; @@ -229,8 +230,52 @@ impl Config { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Listen { - #[serde(default)] + #[serde(default, skip_serializing_if = "is_default")] pub gateway: GatewayListen, + /// 控制面。**钥匙在这里**,而钥匙每份配置都有 —— 所以 `listen` 这一段 + /// 从第一天起就在文件里,里面只有这一行。 + #[serde(default)] + pub control: ControlListen, +} + +/// 控制面怎么进。 +/// +/// 本机的通道(unix socket、Windows 的回环端口)不用配:它在哪儿由平台 +/// 决定(`tw_api::control::Address`),这里只有进门的钥匙。 +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ControlListen { + /// 64 个十六进制字符。**每一种通道共用这一把**:握手(`tw-link`)拿它当 + /// PSK,不知道它的一方连第一条握手消息都写不对。 + /// + /// `twcore serve` 在控制面起来之前保证它在(没有就生成、只补这一行); + /// 界面上看到的是打码的(`tw_api::control::KEY_MASK`),也改不了它 —— + /// 只有 `twcore control-key --rotate` 和直接改文件能换。 + /// + /// 这里是一串文字而不是 [`tw_api::control::ControlKey`]:写错了的时候, + /// 校验要能说一句带码的话([`ValidationError::ControlKeyInvalid`]),而 + /// 不是一句解析器的原话。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + // ── 远程控制端口(`remote: { enabled, bind, port, allow_from }`)加在这里。 + // 它是**另开的**一个网络端口,给另一台机器上的桌面端用,本机的通道照旧; + // 钥匙还是上面这一把。 +} + +/// **钥匙不打印。**`Config` 会整个落进 Debug 输出,而日志是会被贴进 issue 的。 +impl std::fmt::Debug for ControlListen { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ControlListen") + .field("key", &self.key.as_ref().map(|_| "")) + .finish() + } +} + +impl ControlListen { + /// 此刻的钥匙。校验过的配置里一定有;没有或写坏了是 `None`。 + pub fn key(&self) -> Option { + tw_api::control::ControlKey::parse(self.key.as_deref()?).ok() + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/tw-config/src/refs.rs b/crates/tw-config/src/refs.rs index c53783ec..a99821e6 100644 --- a/crates/tw-config/src/refs.rs +++ b/crates/tw-config/src/refs.rs @@ -312,6 +312,9 @@ mod tests { use super::*; const CFG: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-config/src/reload.rs b/crates/tw-config/src/reload.rs index 0458bc88..6f24e1a7 100644 --- a/crates/tw-config/src/reload.rs +++ b/crates/tw-config/src/reload.rs @@ -172,7 +172,8 @@ fn stage_of(e: &ValidationError) -> Stage { /// 进界面、可能被用户复制到 issue 里(统一脱敏)。 fn excerpt_of(text: &str, line: usize) -> Option { let raw = text.lines().nth(line.checked_sub(1)?)?; - let masked = tw_secret::mask_line(raw); + // 控制面的钥匙整个换掉:`mask_line` 留头留尾,而它是那扇门的全部凭据 + let masked = tw_secret::mask_line(&crate::control_key::mask_hex_runs(raw)); // 超长的行截断。**按字符边界截**,按字节切多字节字符会 panic。 const MAX: usize = 160; if masked.chars().count() <= MAX { @@ -186,26 +187,34 @@ fn excerpt_of(text: &str, line: usize) -> Option { mod tests { use super::*; - const GOOD: &str = "version: 1\nclients:\n - name: c\n key: tw-k\n"; + const GOOD: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\n"; #[test] fn a_good_config_goes_through() { assert!(try_parse(GOOD).is_ok()); } + /// 没有钥匙的配置不收:它一旦换进来,下一条连接谁都进不来。 + #[test] + fn a_config_without_a_control_key_is_refused_as_a_semantic_error() { + let r = try_parse("version: 1\nclients:\n - name: c\n key: tw-k\n").unwrap_err(); + assert_eq!(r.stage, Stage::Semantics, "{r:?}"); + assert_eq!(r.message.code, "config.control_key_missing"); + } + #[test] fn a_syntax_error_points_at_the_line() { // 「手抖打错一个字母」的典型:引号没闭合。 - let bad = "version: 1\nclients:\n - name: \"c\n key: tw-k\n"; + let bad = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: \"c\n key: tw-k\n"; let r = try_parse(bad).unwrap_err(); assert_eq!(r.stage, Stage::Syntax, "{r:?}"); assert!(r.line.is_some(), "语法错必须给行号:{r:?}"); } #[test] - fn a_config_with_neither_providers_nor_a_listen_block_still_loads() { - // 「六行」。零 provider 是首次运行的正常状态,而逼用户 - // 写一行 `providers: []` 只是为了让解析器高兴。 + fn a_config_without_providers_still_loads() { + // 零 provider 是首次运行的正常状态,而逼用户写一行 `providers: []` + // 只是为了让解析器高兴。`listen` 那一节只有控制面的钥匙。 let cfg = try_parse(GOOD).expect("最小配置该能加载"); assert!(cfg.providers.is_empty()); } @@ -213,7 +222,7 @@ mod tests { #[test] fn a_config_with_no_clients_at_all_gets_the_helpful_message_not_serdes() { // serde 的「missing field `clients`」说不出下一步做什么。 - let r = try_parse("version: 1\n").unwrap_err(); + let r = try_parse("version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\n").unwrap_err(); assert_eq!(r.stage, Stage::Semantics, "{r:?}"); assert!(r.message.text.contains("generated on first start"), "{r:?}"); } @@ -231,7 +240,7 @@ mod tests { #[test] fn a_semantic_error_has_no_line_number_because_there_is_no_honest_one() { // **编一个行号出来比不给更糟** —— 用户会盯着那一行看半天。 - let bad = "version: 1\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\n - name: a\n base_url: https://y\n key: k\n"; + let bad = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\n - name: a\n base_url: https://y\n key: k\n"; let r = try_parse(bad).unwrap_err(); assert_eq!(r.stage, Stage::Semantics, "{r:?}"); assert!(r.line.is_none(), "语义错不该编行号:{r:?}"); @@ -242,7 +251,7 @@ mod tests { fn a_route_pointing_at_a_deleted_group_is_caught_before_anything_swaps_in() { // **语法正确但语义错误的配置最危险**:YAML 完全合法,运行时却会 // 把请求路由到空处。 - let bad = "version: 1\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\nroutes:\n - name: 默认\n rules:\n - name: r\n to: 已经删掉的组\n"; + let bad = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\nroutes:\n - name: 默认\n rules:\n - name: r\n to: 已经删掉的组\n"; let r = try_parse(bad).unwrap_err(); assert_eq!(r.stage, Stage::Semantics); assert!(r.message.text.contains("已经删掉的组"), "{r:?}"); @@ -260,8 +269,7 @@ mod tests { fn the_excerpt_never_carries_a_key_in_the_clear() { // 出错那一行完全可能就是写着密钥的那一行,而这段文字要进日志、 // 进界面、可能被复制到 issue 里。 - let bad = - "version: 1\nclients:\n - name: c\n key: sk-ant-verysecretvalue\n kye: 1\n"; + let bad = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: sk-ant-verysecretvalue\n kye: 1\n"; let r = try_parse(bad).unwrap_err(); let ex = r.excerpt.unwrap_or_default(); assert!(!ex.contains("verysecretvalue"), "密钥原文进了摘录:{ex}"); @@ -271,7 +279,9 @@ mod tests { fn a_very_long_line_is_cut_on_a_character_boundary() { // 按字节切多字节字符会 panic —— 这个项目栽过两次。 let long = "很".repeat(500); - let bad = format!("version: 1\nx: {long}\nclients:\n - name: c\n kye: 1\n"); + let bad = format!( + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nx: {long}\nclients:\n - name: c\n kye: 1\n" + ); let r = try_parse(&bad).unwrap_err(); // 不 panic 就算过;顺便确认真的截了 if let Some(ex) = r.excerpt { diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index b1375a27..383551ff 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -70,6 +70,10 @@ pub enum ValidationError { UnknownRule { guard: &'static str, id: String }, #[error("{}", self.msg())] OutputLimitRange { max: usize, ceiling: usize }, + #[error("{}", self.msg())] + ControlKeyMissing, + #[error("{}", self.msg())] + ControlKeyInvalid, } impl ValidationError { @@ -193,6 +197,17 @@ impl ValidationError { "config.output_limit_range", max = max, ceiling = ceiling => "security.output_limit.max_chars is {max}; it has to be between 1 and {ceiling}" ), + ControlKeyMissing => msg!( + "config.control_key_missing" => + "the configuration has no listen.control.key, the key the desktop app connects \ + with. twcore serve writes one when it starts; twcore control-key --rotate \ + writes a new one" + ), + ControlKeyInvalid => msg!( + "config.control_key_invalid" => + "listen.control.key has to be 64 hexadecimal characters. twcore control-key \ + --rotate writes a new one" + ), } } } @@ -401,6 +416,16 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> { id: id.to_string(), }); } + // 控制面的钥匙。**缺了、短了、不是十六进制,整份配置都不收**,旧的继续 + // 服务:一份没有钥匙的配置换进来,下一条连接谁都进不来 —— 包括要把它 + // 改回去的那个界面;一把好猜的短钥匙和没有差不多 + match cfg.listen.control.key.as_deref() { + None => return Err(ValidationError::ControlKeyMissing), + Some(k) if tw_api::control::ControlKey::parse(k).is_err() => { + return Err(ValidationError::ControlKeyInvalid); + } + Some(_) => {} + } Ok(()) } @@ -505,7 +530,12 @@ mod tests { fn cfg(clients: Vec, providers: Vec) -> Config { Config { version: 1, - listen: Listen::default(), + listen: Listen { + control: crate::ControlListen { + key: Some("c0ffee00".repeat(8)), + }, + ..Default::default() + }, clients, providers, ..Default::default() @@ -528,6 +558,37 @@ mod tests { } } + /// 控制面的钥匙:缺了、短了、不是十六进制,都不收。 + #[test] + fn the_control_key_has_to_be_there_and_be_64_hex_characters() { + let with = |k: Option<&str>| { + let mut c = cfg(vec![c("d", "tw-1")], vec![]); + c.listen.control.key = k.map(str::to_string); + validate(&c) + }; + assert!(with(Some(&"ab".repeat(32))).is_ok()); + assert!(with(Some(&"AB".repeat(32))).is_ok(), "大写也是十六进制"); + assert!(matches!( + with(None), + Err(ValidationError::ControlKeyMissing) + )); + for bad in [ + "", + "abc", + &"ab".repeat(31), + &"zz".repeat(32), + tw_api::control::KEY_MASK, + ] { + assert!( + matches!(with(Some(bad)), Err(ValidationError::ControlKeyInvalid)), + "{bad}" + ); + } + let m = ValidationError::ControlKeyMissing.msg(); + assert_eq!(m.code, "config.control_key_missing"); + assert!(m.text.contains("twcore control-key --rotate"), "{m:?}"); + } + #[test] fn a_valid_minimal_config_passes() { assert!(validate(&cfg(vec![c("d", "tw-1")], vec![p("r", "https://x.com")])).is_ok()); diff --git a/crates/tw-control/Cargo.toml b/crates/tw-control/Cargo.toml index d953a4e2..46c76cce 100644 --- a/crates/tw-control/Cargo.toml +++ b/crates/tw-control/Cargo.toml @@ -10,10 +10,10 @@ description = "Control-plane API server over a unix socket" [dependencies] tw-api = { workspace = true } tw-types = { workspace = true } +# 每条控制面连接先握手 +tw-link = { workspace = true } # 列网卡用 getifaddrs —— 比引一个 crate 便宜 libc = { workspace = true } -# 生成控制面的凭据 -rand = { workspace = true } tw-config = { workspace = true } tw-adopt = { workspace = true } tw-scan = { workspace = true } diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index 9147bd66..1737fd7b 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -58,6 +58,9 @@ pub enum ApplyError { /// 还有别的配置在引用它,删不掉。**消息里要说清是谁。** #[error("{0}")] InUse(Msg), + /// 这次写入改了(或删了)控制面的钥匙。经控制面进来的写入不能动它。 + #[error("{}", self.msg())] + ControlKeyLocked, } impl ApplyError { @@ -77,6 +80,11 @@ impl ApplyError { {current}. Refresh and edit again" ), ApplyError::Edit(e) => e.msg(), + ApplyError::ControlKeyLocked => msg!( + "control.control_key_locked" => + "listen.control.key cannot be changed from here. Run twcore control-key --rotate \ + on the machine the core runs on, or edit the configuration file there" + ), } } } @@ -201,6 +209,12 @@ impl ConfigManager { /// /// `base_version` 是乐观并发的凭据:**对不上就是 409**, /// 而不是覆盖。`None` 表示调用方明确要覆盖(比如首次生成)。 + /// + /// **控制面的钥匙在这条路上改不了。**界面拿到的原文里钥匙是打码的,写回来 + /// 带着打码就换回生效的那一把;换成别的、删掉,一律拒绝 + /// ([`ApplyError::ControlKeyLocked`])。能换钥匙的只有 `twcore control-key + /// --rotate` 和直接改文件 —— 都得在 core 那台机器上。经控制面进来的一方 + /// 改得动这扇门,就改得掉别人进来的路。 pub async fn write( &self, new_text: &str, @@ -216,9 +230,17 @@ impl ConfigManager { current: cur.version(), }); } + let in_effect = self.gateway.config().listen.control.key.clone(); + // 换不回来(原文读不成 YAML)就原样往下走,校验会说它哪里坏了 + let unmasked = tw_config::control_key::unmask(new_text, in_effect.as_deref()).ok(); + let new_text = unmasked.as_deref().unwrap_or(new_text); // **先校验再写。**写完才发现读不回来,那份坏配置已经在盘上了 —— // 而用户下一次启动会撞上它。 - tw_config::try_parse(new_text).map_err(ApplyError::Rejected)?; + let next = tw_config::try_parse(new_text).map_err(ApplyError::Rejected)?; + let parse = |k: Option<&str>| k.and_then(|k| tw_api::control::ControlKey::parse(k).ok()); + if parse(next.listen.control.key.as_deref()) != parse(in_effect.as_deref()) { + return Err(ApplyError::ControlKeyLocked); + } // 改之前那一版进历史。**这一步在写盘之前** —— 写完再存的话, // 中间崩一次就永远丢了那一版,而那恰恰是最需要它的时刻。 let _ = tw_config::history::snapshot(&self.path, &cur.text, origin); @@ -487,7 +509,7 @@ fn bad_path(e: tw_yaml::PatchError) -> ApplyError { mod patch_seq_tests { use super::*; - const CFG: &str = "version: 1\nclients:\n # 首次运行生成的\n - name: default\n key: tw-aaa\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-a\n"; + const CFG: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n # 首次运行生成的\n - name: default\n key: tw-aaa\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-a\n"; /// 这三条是「界面能不能建东西」的全部依据。 #[test] @@ -556,13 +578,13 @@ mod msg_codes { assert_eq!(e.msg().code, "config.edit.multiline"); let e = ApplyError::Store(tw_config::StoreError::Missing { path: "/x".into() }); assert_eq!(e.msg().code, "config.store.missing"); - let e = ApplyError::Rejected(tw_config::try_parse("version: 1\n").unwrap_err()); + let e = ApplyError::Rejected(tw_config::try_parse("version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\n").unwrap_err()); assert_eq!(e.msg().code, "config.no_clients"); } #[test] fn a_patch_path_that_names_no_entry_has_its_own_code() { - let cfg = "version: 1\nclients:\n - name: c\n key: tw-k\n"; + let cfg = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\n"; let m = resolve_path(cfg, "/clients/不存在").unwrap_err(); assert_eq!(m.code, "control.patch.no_entry"); assert_eq!(m.arg("name"), "不存在"); diff --git a/crates/tw-control/src/diagnostics.rs b/crates/tw-control/src/diagnostics.rs index 71b2e2e8..35695efc 100644 --- a/crates/tw-control/src/diagnostics.rs +++ b/crates/tw-control/src/diagnostics.rs @@ -302,7 +302,10 @@ pub async fn bundle(State(s): State) -> String { // // `mask_config_yaml` 按**字段名**打(schema 就是答案),里面照旧 // 叠一层 `mask_body`:仔细的那一处不能被另一处抵消。 + // + // 控制面的钥匙**整个换掉**,不是留头留尾:它是那扇门的全部凭据。 Ok(text) => { + let text = tw_config::control_key::mask(&text); let _ = writeln!(out, "{}", tw_secret::mask_config_yaml(&text)); } Err(e) => { diff --git a/crates/tw-control/src/gate.rs b/crates/tw-control/src/gate.rs new file mode 100644 index 00000000..a259886e --- /dev/null +++ b/crates/tw-control/src/gate.rs @@ -0,0 +1,103 @@ +//! 控制面的门:每条连接先握手(`tw-link`),握上了才交给 HTTP。 +//! +//! # 为什么 socket 之外还要一道门 +//! +//! unix socket 那一套(`0700` 的文件,只有属主连得上)是文件系统给的保证, +//! **只在这个平台上成立**。Windows 上控制面只能落在回环 TCP 上,本机任意进程 +//! 都连得上;远程端口更不用说。所以门是另外装的一道,**每一种通道装的是 +//! 同一道**:只有一条鉴权代码路径,桌面版每天都在跑它。 +//! +//! # 钥匙换了 +//! +//! 钥匙是每次握手现取的(配置里生效的那一把),所以换钥匙之后,新连接自然 +//! 按新的算。**已经连着的也要断开**:用旧钥匙进来的连接不该在换锁之后还 +//! 一直开着 —— 换钥匙往往就是因为旧的那把不可信了。本机的桌面端断开之后 +//! 重读一次配置就接上了。 + +use tokio::sync::watch; +use tw_api::control::ControlKey; +use tw_link::{Acceptor, LinkError}; + +use crate::ControlState; + +/// 握手用的那一侧,外加「钥匙现在是哪一把」。克隆很便宜,每条连接一份。 +#[derive(Clone)] +pub(crate) struct Gate { + acceptor: Acceptor, + current: watch::Receiver>, +} + +impl Gate { + /// **要在 tokio 里调**:它起一个跟着配置重载更新钥匙的后台任务。 + pub(crate) fn new(state: &ControlState) -> Self { + let gw = state.gateway.clone(); + let acceptor = Acceptor::new( + move || gw.config().listen.control.key(), + env!("CARGO_PKG_VERSION"), + ); + let (tx, rx) = watch::channel(state.config().listen.control.key()); + let mut events = state.bus().subscribe(); + let gw = state.gateway.clone(); + tokio::spawn(async move { + use tokio::sync::broadcast::error::RecvError; + loop { + match events.recv().await { + // 丢了事件就不知道错过了什么:照样对一遍 + Ok(tw_api::Event::ConfigReloaded { .. }) | Err(RecvError::Lagged(_)) => { + let now = gw.config().listen.control.key(); + tx.send_if_modified(|k| { + if *k == now { + return false; + } + *k = now; + true + }); + } + Ok(_) => {} + Err(RecvError::Closed) => return, + } + } + }); + Self { + acceptor, + current: rx, + } + } + + /// 握手。失败时该回的都回过了,这里只记一行,**不记钥匙**。 + pub(crate) async fn admit(&self, stream: S) -> Option> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + match self.acceptor.accept(stream).await { + Ok(a) => { + tracing::debug!(app = %a.hello.app, "a control-plane client connected"); + Some(a) + } + Err(e @ (LinkError::WrongKey | LinkError::VersionMismatch { .. })) => { + // 桌面端和 core 版本不一致、拿着旧钥匙的,都是用户看得到的状态, + // 值得在日志里留一行 + tracing::info!("a control-plane connection was turned away: {e}"); + None + } + Err(e) => { + tracing::debug!("a control-plane handshake did not finish: {e}"); + None + } + } + } + + /// 等到钥匙换成了别的(不再是 `used`)。 + pub(crate) async fn key_changed_from(&self, used: &ControlKey) { + let mut rx = self.current.clone(); + loop { + if rx.borrow_and_update().as_ref() != Some(used) { + return; + } + if rx.changed().await.is_err() { + // 跟着配置的那个任务没了(core 在退出):不会再换了 + std::future::pending::<()>().await; + } + } + } +} diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 720ca931..2d088f08 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -4,8 +4,9 @@ //! 天然只有当前用户能连。 //! //! 但那是文件系统给的保证,**只在这个平台上成立** —— Windows 上没有对等物, -//! 控制面在那里只能落在回环 TCP 上。所以凭据这道门是另外装的一道,两个平台 -//! 都走它,见 [`token`]。 +//! 控制面在那里只能落在回环 TCP 上。所以每条连接先握手(`tw-link`,钥匙是 +//! 配置里的 `listen.control.key`),这道门是另外装的一道,每一种通道都走它, +//! 见 `gate`。 use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -27,6 +28,7 @@ pub mod config; mod contract; pub mod diagnostics; pub mod dryrun; +mod gate; pub mod keys; pub mod listen; pub mod pricing; @@ -37,7 +39,6 @@ pub mod routes; pub mod scan; pub mod security; pub mod shutdown; -pub mod token; pub mod zai; pub use config::{ApplyError, ConfigManager, resolve_path, spawn_watcher}; pub use shutdown::Shutdown; @@ -1143,12 +1144,15 @@ fn list_limit(q: &tw_api::ListQuery) -> usize { } /// 当前配置的原文。**文本模式直接显示它。** +/// +/// 控制面的钥匙是打码的(`tw_api::control::KEY_MASK`):界面用不着它,而 +/// 编辑器里的原文会被复制、截图。整份写回来时带着打码,就是钥匙不动。 async fn get_config(State(s): State) -> Result, Fail> { let c = s.cfg.current().map_err(unreadable_config)?; Ok(Json(tw_api::ConfigText { path: c.path.display().to_string(), version: c.version(), - text: c.text, + text: tw_config::control_key::mask(&c.text), })) } @@ -1282,6 +1286,8 @@ pub(crate) fn apply_fail(e: ApplyError) -> Fail { StatusCode::CONFLICT } ApplyError::Edit(EditError::NotFound { .. }) => StatusCode::NOT_FOUND, + // 不是请求写错了,是这条路上不许改 + ApplyError::ControlKeyLocked => StatusCode::FORBIDDEN, ApplyError::Rejected(_) | ApplyError::Build(_) | ApplyError::BadPath(_) @@ -1457,20 +1463,20 @@ pub fn socket_path_fits(path: &Path) -> Result<(), ControlError> { /// 两种传输,**挑哪一种不是调用方的事**:`Address::in_dir` 按平台给出这台 /// 机器上唯一可用的那一种(见 [`tw_api::control::Address`])。这里只负责把 /// 它听起来。 -pub async fn serve( - state: ControlState, - at: &tw_api::control::Address, - token: token::Token, -) -> Result<(), ControlError> { +/// +/// **每条连接先握手**(见 `gate`),握上了才交给 HTTP。门装在这一层,不装进 +/// `router()`:`router()` 是路由表本身,十几个集成测试直接拿它跑处理函数, +/// 它们测的不是门。 +pub async fn serve(state: ControlState, at: &tw_api::control::Address) -> Result<(), ControlError> { use tw_api::control::Address; - // 门装在这儿,不装进 `router()` —— 理由见 `token::guard` - let app = token::guard(router(state), token); + let gate = gate::Gate::new(&state); + let app = router(state); match at { #[cfg(unix)] - Address::Socket(path) => serve_socket(app, path).await, + Address::Socket(path) => serve_socket(app, gate, path).await, #[cfg(not(unix))] Address::Socket(path) => Err(ControlError::NoUnixSockets { path: path.clone() }), - Address::Loopback { port_file } => serve_loopback(app, port_file).await, + Address::Loopback { port_file } => serve_loopback(app, gate, port_file).await, } } @@ -1479,7 +1485,7 @@ pub async fn serve( /// 陈旧的 socket 文件直接删掉重建 —— 它和 lock 文件不一样,没有「另一个 /// 实例可能还在用」的歧义:单实例锁已经在上一步挡住了。 #[cfg(unix)] -async fn serve_socket(app: Router, path: &Path) -> Result<(), ControlError> { +async fn serve_socket(app: Router, gate: gate::Gate, path: &Path) -> Result<(), ControlError> { socket_path_fits(path)?; if path.exists() { let _ = std::fs::remove_file(path); @@ -1493,14 +1499,14 @@ async fn serve_socket(app: Router, path: &Path) -> Result<(), ControlError> { })?; { use std::os::unix::fs::PermissionsExt; - // 0700:只有当前用户能连。凭据那道门在它之外,不是替代它 —— + // 0700:只有当前用户能连。握手那道门在它之外,不是替代它 —— // 两道都在,而只有这一道是平台给的。 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)); } tracing::info!(path = %path.display(), "the control plane is listening"); loop { match listener.accept().await { - Ok((stream, _)) => hand_off(stream, app.clone()), + Ok((stream, _)) => hand_off(stream, app.clone(), gate.clone()), Err(e) => tracing::warn!("the control plane could not accept a connection: {e}"), } } @@ -1514,9 +1520,13 @@ async fn serve_socket(app: Router, path: &Path) -> Result<(), ControlError> { /// 端口文件**先绑后写**:写完才说得出真实的号码,而反过来(先写一个想要的 /// 号再去绑)会在绑失败时留下一个指向别人的文件。 /// -/// 这一档**挡不住同机的任何进程**,也问不出对端是谁 —— 门全在凭据上, -/// 见 [`token`]。 -async fn serve_loopback(app: Router, port_file: &Path) -> Result<(), ControlError> { +/// 这一档**挡不住同机的任何进程**,也问不出对端是谁 —— 门全在握手上, +/// 见 `gate`。 +async fn serve_loopback( + app: Router, + gate: gate::Gate, + port_file: &Path, +) -> Result<(), ControlError> { use std::net::Ipv4Addr; if let Some(dir) = port_file.parent() { let _ = std::fs::create_dir_all(dir); @@ -1541,32 +1551,44 @@ async fn serve_loopback(app: Router, port_file: &Path) -> Result<(), ControlErro tracing::info!(port, "the control plane is listening on loopback"); loop { match listener.accept().await { - Ok((stream, _)) => hand_off(stream, app.clone()), + Ok((stream, _)) => hand_off(stream, app.clone(), gate.clone()), Err(e) => tracing::warn!("the control plane could not accept a connection: {e}"), } } } -/// 一条连接交给 hyper。 +/// 一条连接:先握手,握上了交给 hyper。 /// -/// **两种传输共用**:它们的差别只在怎么拿到这个流,拿到之后的每一件事 -/// (协议协商、错误怎么记)都该一模一样 —— 写两遍就是两遍会漂。 -fn hand_off(stream: S, app: Router) +/// **每种传输共用**:它们的差别只在怎么拿到这个流,拿到之后的每一件事 +/// (握手、协议协商、错误怎么记)都该一模一样 —— 写两遍就是两遍会漂。 +/// 远程端口接进来的连接也走这里。 +fn hand_off(stream: S, app: Router, gate: gate::Gate) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, { tokio::spawn(async move { - let io = hyper_util::rt::TokioIo::new(stream); + let Some(link) = gate.admit(stream).await else { + return; + }; + let io = hyper_util::rt::TokioIo::new(link.stream); let svc = hyper::service::service_fn(move |req| { use tower::ServiceExt; app.clone().oneshot(req) }); - if let Err(e) = - hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) - .serve_connection(io, svc) - .await - { - tracing::debug!("a control-plane connection ended: {e}"); + let builder = + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()); + let conn = builder.serve_connection(io, svc); + tokio::select! { + r = conn => { + if let Err(e) = r { + tracing::debug!("a control-plane connection ended: {e}"); + } + } + // 钥匙换了:用旧钥匙进来的这一条断开(理由见 `gate`)。连接直接丢掉, + // 事件流也跟着断,对面重连时按新钥匙握手 + _ = gate.key_changed_from(&link.key) => { + tracing::info!("the control key changed; closing a connection made with the previous one"); + } } }); } diff --git a/crates/tw-control/src/rotation.rs b/crates/tw-control/src/rotation.rs index f2bf0197..3015165e 100644 --- a/crates/tw-control/src/rotation.rs +++ b/crates/tw-control/src/rotation.rs @@ -123,6 +123,8 @@ listen: gateway: bind: loopback port: 8788 + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: claude-code key: tw-testkey diff --git a/crates/tw-control/src/routes.rs b/crates/tw-control/src/routes.rs index 8e4c914b..d5de4c8b 100644 --- a/crates/tw-control/src/routes.rs +++ b/crates/tw-control/src/routes.rs @@ -706,7 +706,7 @@ mod msg_codes { fn cfg() -> tw_config::Config { tw_config::try_parse( - "version: 1\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\n", + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: c\n key: tw-k\nproviders:\n - name: a\n base_url: https://x\n key: k\n", ) .unwrap() } diff --git a/crates/tw-control/src/token.rs b/crates/tw-control/src/token.rs deleted file mode 100644 index 97a85541..00000000 --- a/crates/tw-control/src/token.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! 控制面的凭据。 -//! -//! # 为什么 socket 之外还要一道门 -//! -//! unix socket 那一套(`0700` 的文件,只有属主连得上)在 macOS、Linux 上够用,但它 -//! **是文件系统给的保证,不是我们给的**。Windows 上没有对等物:控制面在那里 -//! 只能落在 `127.0.0.1` 的 TCP 上,而本机任意进程都连得上一个回环端口,连上 -//! 之后也问不出对端是谁 —— unix socket 问得出(`SO_PEERCRED`),TCP 问不出。 -//! -//! 所以那里必须有一道自己的门。**两个平台装的是同一道**,不是只给 Windows -//! 装:一道只在一个平台上生效的防线,等于一道没人日常测的防线。 -//! -//! # token 从哪儿来 -//! -//! 优先环境变量。桌面端自己 spawn core(它从不接管已经在跑的),所以由它生成、 -//! 通过环境变量交过来。**不进 argv** —— Windows 的任务管理器和任意同用户进程 -//! 都看得见命令行 —— 也不落盘。 -//! -//! 没有环境变量,就是有人手工跑 `twcore serve`。那时自己生成一个,写进 -//! `<配置目录>/control.token`,下次再跑读回来。**不是每次现生成**:那样拿 -//! curl 调试的人每跑一次都得重抄一遍。 - -use std::path::{Path, PathBuf}; - -use axum::Json; -use axum::Router; -use axum::extract::{Request, State}; -use axum::http::{HeaderMap, StatusCode, header}; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; -use std::sync::Arc; -use tw_types::msg; - -// 名字住在契约层:桌面端要知道环境变量叫什么才能把 token 交过来,而它 -// 依赖的是 tw-api,够不着这个 crate。两边各写一遍字符串就是两边会漂。 -use tw_api::control::{TOKEN_ENV as ENV, token_file}; - -#[derive(Debug, thiserror::Error)] -pub enum TokenError { - #[error("the control-plane token file {path} could not be read: {source}")] - Read { - path: PathBuf, - source: std::io::Error, - }, - #[error("the control-plane token file {path} could not be written: {source}")] - Write { - path: PathBuf, - source: std::io::Error, - }, -} - -#[derive(Clone)] -pub struct Token(String); - -/// **不打印出来。**`Token` 会跟着别的结构体一起落进 `tracing` 的 Debug 输出, -/// 而日志是会被整段贴进 issue 的。 -impl std::fmt::Debug for Token { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("Token()") - } -} - -impl Token { - /// 环境变量优先,没有就读或建那个文件。 - pub fn resolve(dir: &Path) -> Result { - match Self::from_env() { - Some(t) => Ok(t), - None => Self::load_or_create(dir), - } - } - - pub fn from_env() -> Option { - let raw = std::env::var(ENV).ok()?; - let v = raw.trim(); - // 空字符串当作没给。**一个空 token 不是一道松的门,是一道开着的门** —— - // 下面的比较会对每一个同样没带凭据的请求成立。 - if v.is_empty() { - None - } else { - Some(Self(v.to_string())) - } - } - - fn load_or_create(dir: &Path) -> Result { - let path = token_file(dir); - match std::fs::read_to_string(&path) { - Ok(s) if !s.trim().is_empty() => return Ok(Self(s.trim().to_string())), - // 空文件:当没有过,重新生成盖掉。理由同 `from_env` 里那条。 - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(TokenError::Read { path, source }), - } - let token = Self::generate(); - write_private(&path, token.as_str())?; - Ok(token) - } - - /// 32 字节的随机数,写成十六进制。 - pub fn generate() -> Self { - let mut bytes = [0u8; 32]; - rand::fill(&mut bytes); - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - use std::fmt::Write; - let _ = write!(out, "{b:02x}"); - } - Self(out) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - - /// 对方给的那串对不对。 - /// - /// **恒定时间**:不在第一个不同的字节上提前返回。本机上的时序攻击不好做, - /// 但这里的差别只是一个 `|=` 和一个 `break`。 - pub fn matches(&self, presented: &str) -> bool { - let (want, got) = (self.0.as_bytes(), presented.as_bytes()); - // 长度不等直接否。**长度不是秘密** —— 自己生成的永远是 64 个十六进制 - // 字符 —— 所以为了不泄漏它去把比较写复杂没有意义。 - if want.len() != got.len() { - return false; - } - let mut diff = 0u8; - for (a, b) in want.iter().zip(got) { - diff |= a ^ b; - } - diff == 0 - } -} - -/// 建的时候就是 `0600`。 -/// -/// **不是建完再 `chmod`** —— 那中间有一个全世界可读的窗口,而窗口里躺着的 -/// 正是这道门的钥匙。已经存在的那个文件先删掉再建,否则 `mode` 对它不生效。 -fn write_private(path: &Path, contents: &str) -> Result<(), TokenError> { - use std::io::Write; - let err = |source| TokenError::Write { - path: path.to_path_buf(), - source, - }; - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).map_err(err)?; - } - let _ = std::fs::remove_file(path); - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - opts.open(path) - .map_err(err)? - .write_all(contents.as_bytes()) - .map_err(err) -} - -/// 把鉴权装到一份路由表上。 -/// -/// **装在 serve 那一层,不装进 `router()`。**`router()` 是路由表本身,十几个 -/// 集成测试直接拿它跑处理函数;把门焊进去,等于让每个测试都先学会开门 —— -/// 而它们测的不是门。门装在真正对外的那一步上,门自己在下面单独测。 -pub fn guard(app: Router, token: Token) -> Router { - app.layer(axum::middleware::from_fn_with_state(Arc::new(token), check)) -} - -async fn check(State(token): State>, req: Request, next: Next) -> Response { - match bearer(req.headers()) { - Some(got) if token.matches(got) => next.run(req).await, - _ => unauthorized(), - } -} - -/// `Authorization: Bearer `。 -/// -/// 方案名按 RFC 7235 大小写不敏感 —— 我们自己的客户端永远写 `Bearer`,但拿 -/// curl 手敲的人写 `bearer` 不该被当成凭据不对。 -fn bearer(h: &HeaderMap) -> Option<&str> { - let v = h.get(header::AUTHORIZATION)?.to_str().ok()?; - let (scheme, rest) = v.split_once(' ')?; - scheme.eq_ignore_ascii_case("bearer").then(|| rest.trim()) -} - -fn unauthorized() -> Response { - ( - StatusCode::UNAUTHORIZED, - Json(msg!( - "control.unauthorized" => - "The control plane needs the token it was started with. The desktop app passes it automatically; \ - a hand-written client reads it from control.token in the configuration directory." - )), - ) - .into_response() -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::routing::get; - use tower::ServiceExt; - - fn app(token: Token) -> Router { - guard(Router::new().route("/x", get(|| async { "ok" })), token) - } - - async fn status_with(auth: Option<&str>) -> StatusCode { - let token = Token("sesame".to_string()); - let mut req = Request::builder().uri("/x"); - if let Some(a) = auth { - req = req.header(header::AUTHORIZATION, a); - } - app(token) - .oneshot(req.body(Body::empty()).unwrap()) - .await - .unwrap() - .status() - } - - #[tokio::test] - async fn the_right_token_gets_in_and_nothing_else_does() { - assert_eq!(status_with(Some("Bearer sesame")).await, StatusCode::OK); - // 方案名大小写不敏感 - assert_eq!(status_with(Some("bearer sesame")).await, StatusCode::OK); - - assert_eq!( - status_with(None).await, - StatusCode::UNAUTHORIZED, - "没带凭据" - ); - assert_eq!( - status_with(Some("Bearer open")).await, - StatusCode::UNAUTHORIZED, - "凭据不对" - ); - assert_eq!( - status_with(Some("sesame")).await, - StatusCode::UNAUTHORIZED, - "少了方案名" - ); - assert_eq!( - status_with(Some("Basic sesame")).await, - StatusCode::UNAUTHORIZED, - "换了方案名" - ); - assert_eq!( - status_with(Some("Bearer ")).await, - StatusCode::UNAUTHORIZED, - "空凭据" - ); - } - - /// 前缀对上不算对上 —— 恒定时间那段比较里长度是先判的,别把它判反了。 - #[test] - fn a_prefix_is_not_a_match() { - let t = Token("sesame".to_string()); - assert!(t.matches("sesame")); - assert!(!t.matches("sesam")); - assert!(!t.matches("sesamee")); - assert!(!t.matches("")); - } - - #[test] - fn a_generated_token_is_64_hex_characters_and_never_the_same_twice() { - let a = Token::generate(); - let b = Token::generate(); - assert_eq!(a.as_str().len(), 64); - assert!(a.as_str().chars().all(|c| c.is_ascii_hexdigit())); - assert_ne!(a.as_str(), b.as_str()); - } - - /// 第二次启动要拿到同一个 —— 否则每跑一次,手工客户端都得重抄一遍。 - #[test] - fn the_file_is_written_once_and_read_back_after_that() { - let d = tempfile::tempdir().unwrap(); - let first = Token::load_or_create(d.path()).unwrap(); - let again = Token::load_or_create(d.path()).unwrap(); - assert_eq!(first.as_str(), again.as_str()); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(token_file(d.path())) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600, "token 文件不该有别人的份"); - } - } - - /// 空文件当作没有。**留着它等于门开着** - #[test] - fn an_empty_file_is_replaced_rather_than_trusted() { - let d = tempfile::tempdir().unwrap(); - std::fs::write(token_file(d.path()), " \n").unwrap(); - let t = Token::load_or_create(d.path()).unwrap(); - assert_eq!(t.as_str().len(), 64); - } - - /// 写进文件时带了换行,读回来不该把换行也当成凭据的一部分。 - #[test] - fn surrounding_whitespace_in_the_file_is_not_part_of_the_token() { - let d = tempfile::tempdir().unwrap(); - std::fs::write(token_file(d.path()), " abc123\n").unwrap(); - assert!(Token::load_or_create(d.path()).unwrap().matches("abc123")); - } - - /// 日志里不能出现它。 - #[test] - fn it_does_not_print_itself() { - let t = Token("sesame".to_string()); - assert!(!format!("{t:?}").contains("sesame")); - } -} diff --git a/crates/tw-control/tests/adopt.rs b/crates/tw-control/tests/adopt.rs index e88e2d9a..6244e868 100644 --- a/crates/tw-control/tests/adopt.rs +++ b/crates/tw-control/tests/adopt.rs @@ -11,7 +11,7 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; -const BASE: &str = "version: 1\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; struct Bed { _dir: tempfile::TempDir, diff --git a/crates/tw-control/tests/chatgpt.rs b/crates/tw-control/tests/chatgpt.rs index 84274c77..f8e255b0 100644 --- a/crates/tw-control/tests/chatgpt.rs +++ b/crates/tw-control/tests/chatgpt.rs @@ -244,6 +244,9 @@ async fn start_openai(o: Arc) -> Endpoints { // ---------------------------------------------------------------- 控制面 const RELAY: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-control/tests/contract.rs b/crates/tw-control/tests/contract.rs index c0f4934d..e8aa28b8 100644 --- a/crates/tw-control/tests/contract.rs +++ b/crates/tw-control/tests/contract.rs @@ -12,7 +12,7 @@ use tower::ServiceExt; use tw_api::{Endpoint, ErrorBody, Method, ep}; use tw_control::{ConfigManager, ControlState}; -const BASE: &str = "version: 1\nclients:\n - name: me\n key: tw-contract-test-key\nproviders:\n - name: official\n base_url: https://api.anthropic.com\n key: sk-x\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: me\n key: tw-contract-test-key\nproviders:\n - name: official\n base_url: https://api.anthropic.com\n key: sk-x\n"; fn app() -> (tempfile::TempDir, axum::Router) { let d = tempfile::tempdir().unwrap(); diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index 190107a9..97c57bf3 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -11,6 +11,9 @@ use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; const CFG: &str = r#"version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: 我 key: tw-k diff --git a/crates/tw-control/tests/gate.rs b/crates/tw-control/tests/gate.rs new file mode 100644 index 00000000..b053a8df --- /dev/null +++ b/crates/tw-control/tests/gate.rs @@ -0,0 +1,396 @@ +//! 控制面的门:真的 socket、真的握手、真的 HTTP。 +//! +//! 另外几个测试文件直接拿 `router()` 跑处理函数,门不在那里。这里起的是 +//! `tw_control::serve` —— 桌面端连的就是它 —— 断言三件事:握上手之后 HTTP +//! 和事件流原样能用;钥匙不对的进不来;钥匙换了,旧钥匙进来的连接被断开。 +//! +//! 还有一件和门同样要紧的:**钥匙不从控制面出去,也不能经控制面改掉。** + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tower::ServiceExt; +use tw_api::control::{Address, ControlKey, KEY_MASK}; +use tw_config::history::Origin; +use tw_control::{ConfigManager, ControlState}; +use tw_link::LinkError; + +const KEY: &str = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"; + +fn yaml(key: &str) -> String { + format!( + "version: 1\n# 注释留着\nlisten:\n control:\n key: {key}\nclients:\n - name: default\n key: tw-aaaa\n" + ) +} + +struct Bed { + dir: tempfile::TempDir, + state: ControlState, +} + +impl Bed { + fn file(&self) -> String { + std::fs::read_to_string(self.dir.path().join("config.yaml")).unwrap() + } + fn app(&self) -> axum::Router { + tw_control::router(self.state.clone()) + } +} + +fn bed() -> Bed { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, yaml(KEY)).unwrap(); + let cfg = tw_config::try_parse(&yaml(KEY)).unwrap(); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + let bus = gw.bus.clone(); + let state = ControlState { + shutdown: Default::default(), + cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), + gateway: gw, + store: None, + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + home: d.path().join("home"), + }; + Bed { dir: d, state } +} + +fn key(hex: &str) -> ControlKey { + ControlKey::parse(hex).unwrap() +} + +/// 起控制面,等它听起来。 +async fn listen(b: &Bed, at: Address) { + let state = b.state.clone(); + let at2 = at.clone(); + tokio::spawn(async move { tw_control::serve(state, &at2).await }); + for _ in 0..200 { + let up = match &at { + Address::Socket(p) => p.exists(), + // 文件出现和号码写完之间有一瞬:读得出号码才算 + Address::Loopback { port_file } => std::fs::read_to_string(port_file) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .is_some(), + }; + if up { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("the control plane never started listening"); +} + +type Stream = Box; +trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {} +impl AsyncStream for T {} + +async fn dial(at: &Address) -> Stream { + match at { + #[cfg(unix)] + Address::Socket(p) => Box::new(tokio::net::UnixStream::connect(p).await.unwrap()), + #[cfg(not(unix))] + Address::Socket(_) => unreachable!(), + Address::Loopback { port_file } => { + let port: u16 = std::fs::read_to_string(port_file) + .unwrap() + .trim() + .parse() + .unwrap(); + Box::new( + tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(), + ) + } + } +} + +type Sender = hyper::client::conn::http1::SendRequest; + +/// 握手,然后在加密的流上开一条 HTTP/1.1。 +async fn open( + at: &Address, + k: &ControlKey, +) -> Result<(Sender, tokio::task::JoinHandle<()>), LinkError> { + let (link, hello) = tw_link::connect(dial(at).await, k, "gate test").await?; + assert!(hello.accept); + assert_eq!(hello.core, env!("CARGO_PKG_VERSION")); + let (sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(link)) + .await + .unwrap(); + let done = tokio::spawn(async move { + let _ = conn.await; + }); + Ok((sender, done)) +} + +async fn get(s: &mut Sender, path: &str) -> (StatusCode, String) { + let r = s + .send_request( + Request::builder() + .uri(path) + .header("host", "localhost") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let st = r.status(); + let b = axum::body::to_bytes(Body::new(r.into_body()), 1 << 20) + .await + .unwrap(); + (st, String::from_utf8_lossy(&b).to_string()) +} + +/// 同一件事在两种传输上各走一遍:它们的差别只在怎么拿到流。 +async fn http_and_events_run_over_the_handshake(at: Address, b: &Bed) { + listen(b, at.clone()).await; + let (mut s, _) = open(&at, &key(KEY)).await.unwrap(); + let (st, body) = get(&mut s, "/status").await; + assert_eq!(st, StatusCode::OK, "{body}"); + assert!(body.contains("\"api_version\":18"), "{body}"); + // 同一条连接上接着发:keep-alive 照常 + let (st, body) = get(&mut s, "/config").await; + assert_eq!(st, StatusCode::OK); + assert!(!body.contains(KEY), "钥匙从 GET /config 出去了:{body}"); + assert!(body.contains(KEY_MASK), "{body}"); + + // 事件流:另开一条,发一件事,读得到 + let (mut ev, _) = open(&at, &key(KEY)).await.unwrap(); + let r = ev + .send_request( + Request::builder() + .uri("/events") + .header("host", "localhost") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r.status(), StatusCode::OK); + let mut body = Body::new(r.into_body()).into_data_stream(); + let bus = b.state.bus().clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + bus.emit(tw_api::Event::HealthChanged { + id: 7, + provider: "p-through-the-gate".into(), + state: tw_api::BreakerState::Open, + at_ms: 0, + }); + }); + let seen = tokio::time::timeout(Duration::from_secs(5), async { + use futures::StreamExt; + let mut all = String::new(); + while let Some(d) = body.next().await { + all.push_str(&String::from_utf8_lossy(&d.unwrap())); + if all.contains("p-through-the-gate") { + return true; + } + } + false + }) + .await + .unwrap_or(false); + assert!(seen, "事件流上没收到那件事"); + + // 钥匙不对:握手就被拒,说的是钥匙不对 + let wrong = key(&"1".repeat(64)); + assert!(matches!(open(&at, &wrong).await, Err(LinkError::WrongKey))); +} + +#[cfg(unix)] +#[tokio::test] +async fn over_the_unix_socket() { + let b = bed(); + let at = Address::Socket(b.dir.path().join("twcore.sock")); + http_and_events_run_over_the_handshake(at, &b).await; +} + +#[tokio::test] +async fn over_the_loopback_port() { + let b = bed(); + let at = Address::Loopback { + port_file: b.dir.path().join("control.port"), + }; + http_and_events_run_over_the_handshake(at, &b).await; +} + +/// 换钥匙:旧钥匙进来的连接被断开,旧钥匙再也进不来,新钥匙进得来。 +#[tokio::test] +async fn a_new_key_closes_the_connections_made_with_the_old_one() { + let b = bed(); + let at = Address::Loopback { + port_file: b.dir.path().join("control.port"), + }; + listen(&b, at.clone()).await; + let (mut s, conn) = open(&at, &key(KEY)).await.unwrap(); + assert_eq!(get(&mut s, "/status").await.0, StatusCode::OK); + + // 和 `twcore control-key --rotate` 一样:改文件,core 从文件监听那条路重载 + let path = b.dir.path().join("config.yaml"); + let new = tw_config::control_key::rotate_file(&path).unwrap(); + b.state.cfg.reload_from_disk().await.unwrap(); + + tokio::time::timeout(Duration::from_secs(5), conn) + .await + .expect("用旧钥匙进来的连接该被断开") + .unwrap(); + assert!(matches!( + open(&at, &key(KEY)).await, + Err(LinkError::WrongKey) + )); + let (mut s, _) = open(&at, &new).await.unwrap(); + assert_eq!(get(&mut s, "/status").await.0, StatusCode::OK); +} + +/// 配置里的钥匙写坏了(外部改动):配置不收,旧钥匙继续管用。 +#[tokio::test] +async fn a_broken_key_in_the_file_is_rejected_and_the_old_one_keeps_working() { + let b = bed(); + let path = b.dir.path().join("config.yaml"); + std::fs::write(&path, yaml("tooshort")).unwrap(); + assert!(b.state.cfg.reload_from_disk().await.is_err()); + let rejected = b.state.cfg.rejected().expect("该记下这次被拒"); + assert_eq!(rejected.message.code, "config.control_key_invalid"); + assert_eq!( + b.state.config().listen.control.key(), + Some(key(KEY)), + "旧钥匙该还在管用" + ); +} + +async fn call( + app: &axum::Router, + method: &str, + path: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let r = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let st = r.status(); + let b = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap(); + ( + st, + serde_json::from_slice(&b).unwrap_or(serde_json::Value::Null), + ) +} + +/// 编辑器整份写回:打码原样带回来就是钥匙不动,别处的改动照写。 +#[tokio::test] +async fn writing_back_the_masked_text_keeps_the_key() { + let b = bed(); + let app = b.app(); + let (_, got) = call(&app, "GET", "/config", serde_json::Value::Null).await; + let text = got["text"].as_str().unwrap().to_string(); + assert!(text.contains(KEY_MASK) && !text.contains(KEY), "{text}"); + let edited = text.replace("# 注释留着", "# 注释留着,改了一个字"); + let (st, body) = call( + &app, + "PUT", + "/config", + serde_json::json!({ "base_version": got["version"], "text": edited }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{body}"); + let file = b.file(); + assert!(file.contains(&format!("key: {KEY}")), "{file}"); + assert!(file.contains("改了一个字"), "{file}"); + assert!(!file.contains(KEY_MASK), "{file}"); +} + +/// 经控制面换掉、删掉钥匙,一律拒绝,文件不动。 +#[tokio::test] +async fn the_key_cannot_be_changed_or_removed_through_the_control_plane() { + let b = bed(); + let app = b.app(); + let before = b.file(); + let version = tw_config::store::version_of(&before); + + let other = "2".repeat(64); + for text in [ + yaml(&other), + // 删掉整节 + "version: 1\nclients:\n - name: default\n key: tw-aaaa\n".to_string(), + ] { + let (st, body) = call( + &app, + "PUT", + "/config", + serde_json::json!({ "base_version": version, "text": text }), + ) + .await; + // 删掉的那份先过不了校验(缺钥匙),换掉的那份撞上这条规矩;都不写 + assert!( + st == StatusCode::FORBIDDEN || st == StatusCode::BAD_REQUEST, + "{st} {body}" + ); + assert_eq!(b.file(), before); + } + let (st, body) = call( + &app, + "PUT", + "/config", + serde_json::json!({ "base_version": version, "text": yaml(&other) }), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(body["code"], "control.control_key_locked", "{body}"); + + let (st, body) = call( + &app, + "PATCH", + "/config", + serde_json::json!({ + "base_version": version, + "ops": [{ "op": "replace", "path": "/listen/control/key", "value": other }] + }), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(b.file(), before); +} + +/// 历史里没有钥匙;回滚回去,钥匙是现在这一把。 +#[tokio::test] +async fn a_rollback_through_the_control_plane_keeps_the_key() { + let b = bed(); + let app = b.app(); + let path = b.dir.path().join("config.yaml"); + let v0 = tw_config::store::version_of(&b.file()); + b.state + .cfg + .write(&yaml(KEY).replace("tw-aaaa", "tw-bbbb"), None, Origin::Ui) + .await + .unwrap(); + for v in tw_config::history::list(&path).unwrap() { + let t = tw_config::history::read(&v).unwrap(); + assert!(!t.contains(KEY), "历史里有钥匙:{t}"); + } + let (st, body) = call( + &app, + "POST", + "/config/rollback", + serde_json::json!({ "version": v0 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{body}"); + assert_eq!(b.file(), yaml(KEY)); +} diff --git a/crates/tw-control/tests/in_flight.rs b/crates/tw-control/tests/in_flight.rs index c1368da0..e70a8980 100644 --- a/crates/tw-control/tests/in_flight.rs +++ b/crates/tw-control/tests/in_flight.rs @@ -10,7 +10,7 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; -const BASE: &str = "version: 1\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; fn app() -> (tempfile::TempDir, tw_observe::EventBus, axum::Router) { let d = tempfile::tempdir().unwrap(); diff --git a/crates/tw-control/tests/keys.rs b/crates/tw-control/tests/keys.rs index 3059a02e..c0556fa2 100644 --- a/crates/tw-control/tests/keys.rs +++ b/crates/tw-control/tests/keys.rs @@ -12,6 +12,9 @@ use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 # 这把是默认的,别动 clients: - name: default @@ -127,6 +130,9 @@ async fn the_list_shows_the_value_and_says_which_one_is_the_default() { #[tokio::test] async fn default_key_names_one_even_when_it_is_not_called_default() { let b = bed("version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 default_key: codex clients: - name: default @@ -162,6 +168,9 @@ async fn a_new_key_gets_its_value_from_core_and_keeps_the_comments() { #[tokio::test] async fn renaming_a_key_carries_the_rules_and_the_default_with_it() { let b = bed("version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 default_key: codex clients: - name: codex @@ -221,6 +230,9 @@ async fn the_default_key_can_be_neither_deleted_nor_disabled() { #[tokio::test] async fn a_key_a_rule_still_points_at_cannot_be_deleted() { let b = bed("version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: default key: tw-aaaa diff --git a/crates/tw-control/tests/listen.rs b/crates/tw-control/tests/listen.rs index 79e3e5a0..b69922bc 100644 --- a/crates/tw-control/tests/listen.rs +++ b/crates/tw-control/tests/listen.rs @@ -96,6 +96,8 @@ clients: listen: gateway: port: {port} + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 " ) } @@ -132,7 +134,10 @@ async fn saving_writes_what_was_chosen_and_leaves_no_trace_of_defaults() { return; } assert_eq!(st, StatusCode::OK, "{v}"); - assert!(!b.file().contains("listen"), "{}", b.file()); + // `listen` 本身还在:控制面的钥匙住在它下面 + let file = b.file(); + assert!(!file.contains("gateway"), "{file}"); + assert!(file.contains("listen:\n control:\n key: "), "{file}"); } #[tokio::test] @@ -277,6 +282,8 @@ clients: listen: gateway: bind: all + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 "); let (_, ov) = call(&b.app, "GET", "/overview", serde_json::Value::Null).await; let default = serde_json::json!(tw_config::default_allow_from()); diff --git a/crates/tw-control/tests/live_state.rs b/crates/tw-control/tests/live_state.rs index 241465c9..fe75897a 100644 --- a/crates/tw-control/tests/live_state.rs +++ b/crates/tw-control/tests/live_state.rs @@ -84,7 +84,7 @@ fn dead_addr() -> SocketAddr { // ---------------------------------------------------------------- 还在跑的请求 -const BASE: &str = "version: 1\nclients:\n - name: 我\n key: tw-k\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-k\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; fn recorder(d: &tempfile::TempDir) -> Arc> { Arc::new(tokio::sync::Mutex::new(tw_store::Recorder::new( @@ -209,7 +209,7 @@ async fn the_overview_says_which_upstream_rejects_its_credential() { }; let d = tempfile::tempdir().unwrap(); let yaml = format!( - "version: 1\nclients:\n - name: 我\n key: tw-k\nproviders:\n - name: 中转\n base_url: http://{up}\n protocol: anthropic\n key: sk-stale\n" + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-k\nproviders:\n - name: 中转\n base_url: http://{up}\n protocol: anthropic\n key: sk-stale\n" ); let (gw, app) = control(&d, &yaml, None); let (_, v) = get(&app, "/overview").await; @@ -228,7 +228,7 @@ async fn the_overview_says_which_proxy_is_down_and_why() { let dead = dead_addr(); let d = tempfile::tempdir().unwrap(); let yaml = format!( - "version: 1\nclients:\n - name: 我\n key: tw-k\nproxies:\n - name: 代理一\n kind: http\n addr: {dead}\nproviders:\n - name: 中转\n base_url: http://127.0.0.1:9\n protocol: anthropic\n key: sk-x\n proxy: 代理一\n" + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-k\nproxies:\n - name: 代理一\n kind: http\n addr: {dead}\nproviders:\n - name: 中转\n base_url: http://127.0.0.1:9\n protocol: anthropic\n key: sk-x\n proxy: 代理一\n" ); let (gw, app) = control(&d, &yaml, None); let mut rx = gw.bus.subscribe(); diff --git a/crates/tw-control/tests/m2_acceptance.rs b/crates/tw-control/tests/m2_acceptance.rs index aad20619..8182e1da 100644 --- a/crates/tw-control/tests/m2_acceptance.rs +++ b/crates/tw-control/tests/m2_acceptance.rs @@ -21,6 +21,8 @@ version: 1 listen: gateway: port: 8788 # 换端口记得同步改客户端 + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: claude-code diff --git a/crates/tw-control/tests/m4_acceptance.rs b/crates/tw-control/tests/m4_acceptance.rs index acd86b2f..ee594dbd 100644 --- a/crates/tw-control/tests/m4_acceptance.rs +++ b/crates/tw-control/tests/m4_acceptance.rs @@ -20,7 +20,7 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; -const BASE: &str = "version: 1\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; struct Bed { _dir: tempfile::TempDir, diff --git a/crates/tw-control/tests/models.rs b/crates/tw-control/tests/models.rs index a07c3356..38899c99 100644 --- a/crates/tw-control/tests/models.rs +++ b/crates/tw-control/tests/models.rs @@ -97,6 +97,9 @@ async fn upstream() -> std::net::SocketAddr { fn config(up: std::net::SocketAddr, key: &str, extra: &str) -> String { format!( "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k @@ -301,6 +304,9 @@ async fn disabling_and_scoping_an_upstream_is_written_and_an_empty_scope_is_refu #[tokio::test] async fn the_dry_run_names_the_upstreams_it_skips_and_why() { let yaml = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-control/tests/pricing.rs b/crates/tw-control/tests/pricing.rs index e6647a2b..b7238d63 100644 --- a/crates/tw-control/tests/pricing.rs +++ b/crates/tw-control/tests/pricing.rs @@ -14,6 +14,9 @@ use tw_control::pricing::{Schedule, Updater}; use tw_control::{ConfigManager, ControlState}; const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-control/tests/replay.rs b/crates/tw-control/tests/replay.rs index 706629c9..91d0530d 100644 --- a/crates/tw-control/tests/replay.rs +++ b/crates/tw-control/tests/replay.rs @@ -179,7 +179,7 @@ async fn a_direct_upstream_is_replayed_directly_even_with_a_system_proxy() { system_proxy(); let upstream = answering(200, "from the upstream").await; let (_d, app) = app(&format!( - "version: 1\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n \ + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n \ - name: 本机\n base_url: http://{upstream}\n key: sk-x\n billing: free\n" )); @@ -195,7 +195,7 @@ async fn an_upstream_with_a_proxy_is_replayed_through_that_proxy() { let proxy = answering(200, "via its own proxy").await; // 没有人听的地址:直连的话只会连不上 let (_d, app) = app(&format!( - "version: 1\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproxies:\n \ + "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproxies:\n \ - {{ name: 自己的, type: http, addr: \"{proxy}\" }}\nproviders:\n \ - name: 本机\n base_url: http://127.0.0.1:9\n key: sk-x\n billing: free\n \ proxy: 自己的\n" diff --git a/crates/tw-control/tests/resources.rs b/crates/tw-control/tests/resources.rs index 1a4c75bb..c94b9c2c 100644 --- a/crates/tw-control/tests/resources.rs +++ b/crates/tw-control/tests/resources.rs @@ -12,6 +12,9 @@ use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-control/tests/routes.rs b/crates/tw-control/tests/routes.rs index 932d3320..6a2a0067 100644 --- a/crates/tw-control/tests/routes.rs +++ b/crates/tw-control/tests/routes.rs @@ -14,6 +14,9 @@ use tw_control::{ConfigManager, ControlState}; /// 没有默认路由:它由网关合成,兜底指向「全部上游」。 const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: claude-code key: tw-a diff --git a/crates/tw-control/tests/security.rs b/crates/tw-control/tests/security.rs index 2c690fa2..76b465c7 100644 --- a/crates/tw-control/tests/security.rs +++ b/crates/tw-control/tests/security.rs @@ -13,6 +13,9 @@ use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 # 默认那把 clients: - name: default diff --git a/crates/tw-control/tests/sessions.rs b/crates/tw-control/tests/sessions.rs index 26c6e44a..869b5be3 100644 --- a/crates/tw-control/tests/sessions.rs +++ b/crates/tw-control/tests/sessions.rs @@ -10,7 +10,7 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use tw_control::{ConfigManager, ControlState}; -const BASE: &str = "version: 1\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - name: 我\n key: tw-一把钥匙就够\nproviders:\n - name: 官方\n base_url: https://api.anthropic.com\n key: sk-x\n"; /// 一轮 Codex 的请求。按量计费的那一轮带着金额,不计费的记 $0。 fn turn(id: i64, billing: &str) -> tw_store::db::RequestRow { diff --git a/crates/tw-control/tests/speed.rs b/crates/tw-control/tests/speed.rs index 9679dc02..4c7ed4d8 100644 --- a/crates/tw-control/tests/speed.rs +++ b/crates/tw-control/tests/speed.rs @@ -113,6 +113,9 @@ async fn the_speed_run_goes_out_through_the_upstreams_own_proxy() { let proxy = proxy_that_answers(seen.clone()).await; let yaml = format!( "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k @@ -150,6 +153,9 @@ providers: async fn every_upstream_is_quoted_by_its_price_sheet_and_free_is_zero() { // 订阅账号也按价目表报价;不计费的那一家报 0,合计照样算得出来 let b = bed("version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k @@ -191,6 +197,9 @@ providers: #[tokio::test] async fn a_quote_covers_the_chosen_upstreams_and_marks_the_ones_that_cannot_serve_the_model() { let b = bed("version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-control/tests/sync.rs b/crates/tw-control/tests/sync.rs index 0f1b609d..88f4365f 100644 --- a/crates/tw-control/tests/sync.rs +++ b/crates/tw-control/tests/sync.rs @@ -10,7 +10,7 @@ use std::time::Duration; use tw_config::history::Origin; use tw_control::{ApplyError, ConfigManager}; -const BASE: &str = "version: 1\n# 别动我这句注释\nclients:\n - name: c\n key: tw-k\n"; +const BASE: &str = "version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\n# 别动我这句注释\nclients:\n - name: c\n key: tw-k\n"; fn setup() -> (tempfile::TempDir, Arc, tw_observe::EventBus) { let d = tempfile::tempdir().unwrap(); @@ -127,7 +127,7 @@ async fn writing_on_a_stale_version_is_refused_with_both_versions() { std::fs::write(mgr.path(), format!("{BASE}providers: []\n")).unwrap(); let e = mgr - .write("version: 1\nclients: []\n", Some(&stale), Origin::Ui) + .write("version: 1\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients: []\n", Some(&stale), Origin::Ui) .await .unwrap_err(); assert!(matches!(e, ApplyError::Stale { .. }), "{e:?}"); @@ -190,8 +190,9 @@ async fn every_write_leaves_the_previous_version_in_history() { .iter() .map(|v| tw_config::history::read(v).unwrap()) .collect(); + // 历史里存的钥匙是打码的 assert!( - texts.contains(&BASE.to_string()), + texts.contains(&tw_config::control_key::mask(BASE)), "改之前那一版没进历史:{texts:?}" ); } diff --git a/crates/tw-control/tests/zai.rs b/crates/tw-control/tests/zai.rs index d7a55671..01aeff46 100644 --- a/crates/tw-control/tests/zai.rs +++ b/crates/tw-control/tests/zai.rs @@ -194,6 +194,9 @@ async fn start_zai(z: Arc) -> Endpoints { // ---------------------------------------------------------------- 控制面 const BASE: &str = "version: 1 +listen: + control: + key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00 clients: - name: c key: tw-k diff --git a/crates/tw-link/Cargo.toml b/crates/tw-link/Cargo.toml new file mode 100644 index 00000000..7abeb067 --- /dev/null +++ b/crates/tw-link/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tw-link" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Control-channel handshake and encryption (Noise NNpsk0), shared by core and the desktop app" + +[dependencies] +# 钥匙的格式和控制面的版本号都在契约层 +tw-api = { workspace = true } +# 从 config.yaml 里读钥匙。**不拖 serde_yaml**:只找一个标量,tw-yaml 就是 +# 为「按路径找到那一段」写的,桌面端本来也要它 +tw-yaml = { workspace = true } +# Noise 的实现。**只开这一套算法**:X25519、ChaChaPoly、BLAKE2s,不带 AES、 +# SHA2、ring —— 握手名字里写着用哪几个,多开的只是多编译的 +snow = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros", "test-util"] } +tempfile = "3" diff --git a/crates/tw-link/src/handshake.rs b/crates/tw-link/src/handshake.rs new file mode 100644 index 00000000..df2cd9e2 --- /dev/null +++ b/crates/tw-link/src/handshake.rs @@ -0,0 +1,285 @@ +//! 两条握手消息。 + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tw_api::CONTROL_API_VERSION; +use tw_api::control::ControlKey; + +use crate::{ + ClientHello, HANDSHAKE_TIMEOUT, LinkError, MAX_HANDSHAKE_LEN, PROLOGUE, REJECT, SecureStream, + ServerHello, invalid, params, +}; + +/// 以应用的身份握手。`app` 是应用自己的版本,只给 core 的日志看。 +/// +/// 成功时拿到加密的流和 core 的回答(它的版本号在 `ServerHello.core` 里)。 +pub async fn connect( + stream: S, + key: &ControlKey, + app: &str, +) -> Result<(SecureStream, ServerHello), LinkError> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + connect_with_timeout(stream, key, app, HANDSHAKE_TIMEOUT).await +} + +/// 同 [`connect`],限时自己定。测试用得上,远程的慢链路也许用得上。 +pub async fn connect_with_timeout( + stream: S, + key: &ControlKey, + app: &str, + limit: Duration, +) -> Result<(SecureStream, ServerHello), LinkError> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + match tokio::time::timeout(limit, client(stream, key, app, CONTROL_API_VERSION)).await { + Ok(r) => r, + Err(_) => Err(LinkError::Timeout), + } +} + +/// `proto` 总是 [`CONTROL_API_VERSION`],只有测试会说别的,好看看版本不一致时 +/// 两边各说什么。 +pub(crate) async fn client( + mut stream: S, + key: &ControlKey, + app: &str, + proto: u32, +) -> Result<(SecureStream, ServerHello), LinkError> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut hs = snow::Builder::new(params()) + .prologue(PROLOGUE) + .and_then(|b| b.psk(0, key.as_bytes())) + .and_then(|b| b.build_initiator()) + .map_err(noise)?; + + let hello = serde_json::to_vec(&ClientHello { + proto, + app: app.to_string(), + }) + .map_err(|e| LinkError::Io(invalid(e.to_string())))?; + let mut buf = vec![0u8; MAX_HANDSHAKE_LEN]; + let n = hs.write_message(&hello, &mut buf).map_err(noise)?; + write_frame(&mut stream, &buf[..n]).await?; + + // 第一个字节决定是哪一种回答:REJECT,还是一条消息的长度头 + let first = match read_byte(&mut stream).await? { + None => return Err(LinkError::Closed), + Some(REJECT) => return Err(LinkError::WrongKey), + Some(b) => b, + }; + let second = read_byte(&mut stream).await?.ok_or(LinkError::Closed)?; + let len = u16::from_be_bytes([first, second]) as usize; + if len > MAX_HANDSHAKE_LEN { + return Err(LinkError::Io(invalid(format!( + "the second handshake message says it is {len} bytes long" + )))); + } + let mut msg = vec![0u8; len]; + stream + .read_exact(&mut msg) + .await + .map_err(LinkError::from_io)?; + // **这里解不开不是钥匙不对**:钥匙不对时 core 在第一条就解不开,回的是 + // REJECT。走到这一步还解不开,是数据在路上坏了,或者对面根本不是 core + let mut payload = vec![0u8; MAX_HANDSHAKE_LEN]; + let n = hs + .read_message(&msg, &mut payload) + .map_err(|e| LinkError::Io(invalid(format!("the core's answer is unreadable: {e}"))))?; + let answer: ServerHello = serde_json::from_slice(&payload[..n]) + .map_err(|e| LinkError::Io(invalid(format!("the core's answer is malformed: {e}"))))?; + if !answer.accept || answer.proto != proto { + return Err(LinkError::VersionMismatch { + ours: proto, + theirs: answer.proto, + peer_version: answer.core, + }); + } + let transport = hs.into_transport_mode().map_err(noise)?; + Ok((SecureStream::new(stream, transport), answer)) +} + +/// 钥匙从哪儿来。**每次握手问一次**:配置换了钥匙,下一条连接就用新的。 +/// +/// 返回 `None` 是「此刻没有钥匙」—— 那时谁来都一样被拒。 +pub type KeySource = Arc Option + Send + Sync>; + +/// 服务端这一侧:用哪把钥匙、自己报什么版本。 +/// +/// 克隆很便宜,每条连接一份。 +#[derive(Clone)] +pub struct Acceptor { + key: KeySource, + core: Arc, + limit: Duration, +} + +/// 握手成功的一条连接。 +pub struct Accepted { + pub stream: SecureStream, + /// 应用说了什么(版本号、应用版本) + pub hello: ClientHello, + /// 这条连接是用哪把钥匙握上的。钥匙换了之后,用旧钥匙进来的连接 + /// 该断开 —— 调用方拿它和当前的比 + pub key: ControlKey, +} + +impl std::fmt::Debug for Acceptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Acceptor") + .field("core", &self.core) + .field("limit", &self.limit) + .finish_non_exhaustive() + } +} + +impl Acceptor { + /// `core` 是 core 自己的版本,写进第二条消息给应用看。 + pub fn new( + key: impl Fn() -> Option + Send + Sync + 'static, + core: impl Into, + ) -> Self { + Self { + key: Arc::new(key), + core: Arc::from(core.into()), + limit: HANDSHAKE_TIMEOUT, + } + } + + /// 换一个限时。默认 [`HANDSHAKE_TIMEOUT`]。 + pub fn with_timeout(mut self, limit: Duration) -> Self { + self.limit = limit; + self + } + + /// 在一条刚接进来的连接上握手。 + /// + /// 失败时该回的都已经回过了(REJECT 或者 `accept: false`),调用方只管 + /// 记一行日志、丢掉这条连接。 + pub async fn accept(&self, stream: S) -> Result, LinkError> + where + S: AsyncRead + AsyncWrite + Unpin, + { + match tokio::time::timeout(self.limit, self.server(stream)).await { + Ok(r) => r, + Err(_) => Err(LinkError::Timeout), + } + } + + async fn server(&self, mut stream: S) -> Result, LinkError> + where + S: AsyncRead + AsyncWrite + Unpin, + { + let first = match read_byte(&mut stream).await? { + None => return Err(LinkError::Closed), + Some(b) => b, + }; + let second = read_byte(&mut stream).await?.ok_or(LinkError::Closed)?; + let len = u16::from_be_bytes([first, second]) as usize; + if len > MAX_HANDSHAKE_LEN { + // 不是我们的客户端(比如有人拿 curl 敲过来,`GE` 两个字节读成了 + // 一个很大的长度)。**照钥匙不对回**:对面分不清这两种,也不该分清 + reject(&mut stream).await; + return Err(LinkError::WrongKey); + } + let mut msg = vec![0u8; len]; + stream + .read_exact(&mut msg) + .await + .map_err(LinkError::from_io)?; + + // 钥匙在读完第一条之后才取:这条连接用的是此刻生效的那一把 + let Some(key) = (self.key)() else { + reject(&mut stream).await; + return Err(LinkError::WrongKey); + }; + let mut hs = snow::Builder::new(params()) + .prologue(crate::PROLOGUE) + .and_then(|b| b.psk(0, key.as_bytes())) + .and_then(|b| b.build_responder()) + .map_err(noise)?; + let mut payload = vec![0u8; MAX_HANDSHAKE_LEN]; + let n = match hs.read_message(&msg, &mut payload) { + Ok(n) => n, + Err(_) => { + reject(&mut stream).await; + return Err(LinkError::WrongKey); + } + }; + // 解得开却读不成:钥匙对,说的却不是这个协议。回一个 `accept: false`, + // 它至少能看到我们的版本号 + let hello: Option = serde_json::from_slice(&payload[..n]).ok(); + let accept = hello + .as_ref() + .is_some_and(|h| h.proto == CONTROL_API_VERSION); + let answer = serde_json::to_vec(&crate::ServerHello { + proto: CONTROL_API_VERSION, + core: self.core.to_string(), + accept, + }) + .map_err(|e| LinkError::Io(invalid(e.to_string())))?; + let mut buf = vec![0u8; MAX_HANDSHAKE_LEN]; + let n = hs.write_message(&answer, &mut buf).map_err(noise)?; + write_frame(&mut stream, &buf[..n]).await?; + let Some(hello) = hello else { + let _ = stream.shutdown().await; + return Err(LinkError::Io(invalid( + "the first handshake message decrypted but is not a hello", + ))); + }; + if !accept { + let _ = stream.shutdown().await; + return Err(LinkError::VersionMismatch { + ours: CONTROL_API_VERSION, + theirs: hello.proto, + peer_version: hello.app, + }); + } + let transport = hs.into_transport_mode().map_err(noise)?; + Ok(Accepted { + stream: SecureStream::new(stream, transport), + hello, + key, + }) + } +} + +/// 回那个明文字节,然后关掉写的一侧。**失败不管**:对面可能早就走了。 +async fn reject(stream: &mut S) { + let _ = stream.write_all(&[REJECT]).await; + let _ = stream.flush().await; + let _ = stream.shutdown().await; +} + +async fn write_frame(stream: &mut S, msg: &[u8]) -> Result<(), LinkError> { + let len = u16::try_from(msg.len()).expect("a handshake message is at most 1024 bytes"); + let mut out = Vec::with_capacity(2 + msg.len()); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(msg); + stream.write_all(&out).await.map_err(LinkError::from_io)?; + stream.flush().await.map_err(LinkError::from_io) +} + +/// 读一个字节;对面在这之前就关了是 `None`。 +async fn read_byte(stream: &mut S) -> Result, LinkError> { + let mut b = [0u8; 1]; + match stream.read(&mut b).await { + Ok(0) => Ok(None), + Ok(_) => Ok(Some(b[0])), + Err(e) => match LinkError::from_io(e) { + LinkError::Closed => Ok(None), + other => Err(other), + }, + } +} + +/// snow 自己的错误。**正常使用碰不到**:参数都是常量,缓冲区都够大。 +fn noise(e: snow::Error) -> LinkError { + LinkError::Io(std::io::Error::other(format!("noise: {e}"))) +} diff --git a/crates/tw-link/src/key.rs b/crates/tw-link/src/key.rs new file mode 100644 index 00000000..36418177 --- /dev/null +++ b/crates/tw-link/src/key.rs @@ -0,0 +1,98 @@ +//! 从 config.yaml 里读钥匙。 +//! +//! **只读不写**:钥匙由 core 生成、补上(`twcore serve` 在控制面起来之前 +//! 保证它在),桌面端和 `twcore call` 只从这里读。两边找的是同一个位置 +//! ([`tw_api::control::KEY_PATH`]),用的是同一个解析。 + +use std::path::Path; + +use tw_api::control::{ControlKey, KEY_PATH, KeyFormatError}; + +/// 读不到一把能用的钥匙。 +#[derive(Debug, thiserror::Error)] +pub enum KeyReadError { + /// 文件读不了。**不存在是常态**:core 第一次起来之前还没有配置,应用 + /// 把它和「socket 还没出现」当成同一件事 —— core 还没好,等一下再试 + #[error("{path} could not be read: {source}")] + Io { + path: std::path::PathBuf, + source: std::io::Error, + }, + /// 配置里没有 `listen.control.key`。core 起来时会补上,所以同样是「还没好」 + #[error("the configuration has no listen.control.key")] + Missing, + /// 写了,但不是一把钥匙 + #[error("listen.control.key is not a control key: {0}")] + Invalid(KeyFormatError), + /// 这份 YAML 找不到那个位置(写坏了,或者那里是个映射) + #[error("listen.control.key could not be read from the configuration: {0}")] + Unreadable(String), +} + +/// 读 `path` 这份配置里的钥匙。 +pub fn read_key(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|source| KeyReadError::Io { + path: path.to_path_buf(), + source, + })?; + key_in_config(&text) +} + +/// 同 [`read_key`],拿的是配置原文。 +pub fn key_in_config(text: &str) -> Result { + let at: Vec = KEY_PATH.iter().map(|k| tw_yaml::Step::key(*k)).collect(); + match tw_yaml::find(text, &at) { + Ok(found) => ControlKey::parse(&found.value).map_err(KeyReadError::Invalid), + Err(tw_yaml::PatchError::NotFound(_)) => Err(KeyReadError::Missing), + Err(e) => Err(KeyReadError::Unreadable(e.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; + + #[test] + fn it_finds_the_key_in_block_and_flow_style() { + let block = format!("version: 1\nlisten:\n control:\n key: {HEX}\n"); + assert_eq!(key_in_config(&block).unwrap().to_hex(), HEX); + let quoted = format!("version: 1\nlisten:\n control:\n key: \"{HEX}\" # the key\n"); + assert_eq!(key_in_config("ed).unwrap().to_hex(), HEX); + let flow = format!("version: 1\nlisten: {{ control: {{ key: '{HEX}' }} }}\n"); + assert_eq!(key_in_config(&flow).unwrap().to_hex(), HEX); + } + + #[test] + fn a_missing_key_says_missing_and_a_short_one_says_invalid() { + assert!(matches!( + key_in_config("version: 1\n"), + Err(KeyReadError::Missing) + )); + assert!(matches!( + key_in_config("version: 1\nlisten:\n control:\n key: abc\n"), + Err(KeyReadError::Invalid(KeyFormatError::Length { len: 3 })) + )); + // 打码的那一串不是钥匙 + let masked = format!( + "listen:\n control:\n key: {}\n", + tw_api::control::KEY_MASK + ); + assert!(matches!( + key_in_config(&masked), + Err(KeyReadError::Invalid(_)) + )); + } + + #[test] + fn a_missing_file_is_an_io_error_with_not_found() { + let d = tempfile::tempdir().unwrap(); + match read_key(&d.path().join("config.yaml")) { + Err(KeyReadError::Io { source, .. }) => { + assert_eq!(source.kind(), std::io::ErrorKind::NotFound) + } + other => panic!("{other:?}"), + } + } +} diff --git a/crates/tw-link/src/lib.rs b/crates/tw-link/src/lib.rs new file mode 100644 index 00000000..b0a8ec8b --- /dev/null +++ b/crates/tw-link/src/lib.rs @@ -0,0 +1,161 @@ +//! 控制通道:握手与加密。core 和桌面端共用这一份。 +//! +//! # 为什么不是 TLS +//! +//! 控制面的两端是同一个人的两个程序:本机的 socket、Windows 的回环端口, +//! 以及服务器上的远程端口。TLS 要证书,证书要么自签(每台 Mac 都得学会信它), +//! 要么去申请(服务器常常只有一个内网地址)。**两端本来就共享一把钥匙** +//! (`listen.control.key`),拿它当 Noise 的 PSK,双向鉴权、前向保密都有了, +//! 没有证书这回事。 +//! +//! # 协议 +//! +//! `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`,实现用 snow,这里不写任何密码算法。 +//! +//! ```text +//! 应用 → core [u16 长度][e, 加密的 ClientHello { proto, app }] +//! core → 应用 [u16 长度][e, ee, 加密的 ServerHello { proto, core, accept }] +//! 或者一个明文字节 REJECT,然后断开(第一条解不开:钥匙不对) +//! 之后双向 [u16 长度][密文]…… 每帧密文最多 65535 字节,含 16 字节的标签 +//! ``` +//! +//! PSK 放在第 0 位:第一条消息的载荷就是用钥匙加密的,不知道钥匙的一方连 +//! 第一条都写不对,core 读到的那一刻就知道。版本在握手里交换,不一致时 +//! core 回 `accept: false` 再断开 —— 应用拿到的是两个确切的版本号,不是一个 +//! 说不清的 HTTP 错误。 +//! +//! 握手之后是 [`SecureStream`],一个普通的 `AsyncRead + AsyncWrite`。hyper 的 +//! HTTP/1.1(包括 SSE 事件流)原样跑在它上面,端点、请求、响应一概不变。 +//! +//! # 用法 +//! +//! 客户端:拿到底层的流(unix socket、TCP),[`connect`]。服务端:一个 +//! [`Acceptor`],每条连接 [`Acceptor::accept`]。钥匙在服务端是**每次握手现取** +//! 的(一个闭包):配置重载换了钥匙,下一条连接就按新的算。 + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +mod handshake; +mod key; +mod stream; +#[cfg(test)] +mod tests; + +pub use handshake::{Accepted, Acceptor, KeySource, connect, connect_with_timeout}; +pub use key::{KeyReadError, key_in_config, read_key}; +pub use stream::SecureStream; +pub use tw_api::control::{ControlKey, KeyFormatError}; + +/// 握手的名字。两端必须一字不差。 +pub const PATTERN: &str = "Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s"; + +/// 混进握手哈希的前言。**换协议时改它**:两端前言不同,握手就对不上 —— +/// 不会有一个旧客户端误打误撞地和新协议握上手。 +pub const PROLOGUE: &[u8] = b"ThinkWatch control channel 1"; + +/// 握手最多等多久。两端都按它算:卡在半路的连接不能一直占着。 +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); + +/// core 解不开第一条消息时回的那个明文字节。 +/// +/// **它可以被伪造**,而伪造的后果只是应用显示一句「钥匙不对」—— 没有别的 +/// 效果。握手消息的长度头第一个字节永远是 0(握手消息不会超过 +/// [`MAX_HANDSHAKE_LEN`]),所以它和一条正常的第二条消息分得开。 +pub const REJECT: u8 = 0xFF; + +/// 一条握手消息最长多少。载荷只有两个版本号,几百字节足够;给个上限是 +/// 为了一条乱写的长度头不能让对端去等 64 KB。 +pub const MAX_HANDSHAKE_LEN: usize = 1024; + +/// 一帧密文最长多少:长度头是 u16。 +pub const MAX_FRAME: usize = 65535; + +/// ChaChaPoly 的标签长度。 +pub const TAG_LEN: usize = 16; + +/// 一帧里最多放多少明文。写得更长的会被拆成几帧。 +pub const MAX_PLAINTEXT: usize = MAX_FRAME - TAG_LEN; + +/// 第一条消息的载荷:应用说自己是谁。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientHello { + /// 应用讲的控制面版本([`tw_api::CONTROL_API_VERSION`]) + pub proto: u32, + /// 应用自己的版本,给日志看 + pub app: String, +} + +/// 第二条消息的载荷:core 的回答。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerHello { + /// core 讲的控制面版本 + pub proto: u32, + /// core 的版本(CalVer),应用据此说「服务器是 X」 + pub core: String, + /// 版本对得上吗。`false` 时 core 发完这条就断开 + pub accept: bool, +} + +/// 握手或传输的失败,**按应用要显示的状态分**。 +/// +/// 应用按变体选说法:连不上、被关了、钥匙不对、版本不一致、超时。 +/// `Io` 是剩下那些(数据坏了、读写出错),说法是「连接出错」。 +#[derive(Debug, thiserror::Error)] +pub enum LinkError { + /// 连不上:地址不对、端口没开、socket 文件不在。**这个 crate 自己不建连**, + /// 调用方建连失败时用 [`LinkError::unreachable`] 包成这一种,好让应用只认 + /// 一个错误类型。 + #[error("the control plane could not be reached: {0}")] + Unreachable(#[source] std::io::Error), + /// 连上了,对面什么都没说就关了。远程端口上不在放行名单里的来源就是这样 + #[error("the control plane closed the connection without answering")] + Closed, + /// 钥匙不对。客户端:core 回了 [`REJECT`]。服务端:第一条消息解不开 + #[error("the control key does not match")] + WrongKey, + /// 两边讲的控制面版本不一样。`ours` 是这一端的,`theirs` 是对面的; + /// `peer_version` 是对面程序自己的版本(客户端看到的是 core 的 CalVer, + /// 服务端看到的是应用的版本) + #[error( + "the control-plane versions differ: this side speaks {ours}, the other side speaks {theirs} ({peer_version})" + )] + VersionMismatch { + ours: u32, + theirs: u32, + peer_version: String, + }, + /// 握手没在 [`HANDSHAKE_TIMEOUT`] 之内走完 + #[error("the handshake did not finish in time")] + Timeout, + #[error("the control channel failed: {0}")] + Io(#[source] std::io::Error), +} + +impl LinkError { + /// 建连那一步的失败。 + pub fn unreachable(e: std::io::Error) -> Self { + LinkError::Unreachable(e) + } + + /// 读写时的失败。**对面断开的几种说法都归成 [`LinkError::Closed`]** —— + /// 对应用来说它们是同一件事,而 Windows 和 unix 报的错误码各不相同。 + pub(crate) fn from_io(e: std::io::Error) -> Self { + use std::io::ErrorKind::*; + match e.kind() { + UnexpectedEof | ConnectionReset | ConnectionAborted | BrokenPipe => LinkError::Closed, + _ => LinkError::Io(e), + } + } +} + +fn params() -> snow::params::NoiseParams { + PATTERN + .parse() + .expect("the pattern name is a constant that snow understands") +} + +fn invalid(what: impl Into) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, what.into()) +} diff --git a/crates/tw-link/src/stream.rs b/crates/tw-link/src/stream.rs new file mode 100644 index 00000000..69c72974 --- /dev/null +++ b/crates/tw-link/src/stream.rs @@ -0,0 +1,199 @@ +//! 握手之后的那条流:`[u16 长度][密文]` 一帧接一帧。 + +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll, ready}; + +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +use crate::{MAX_FRAME, MAX_PLAINTEXT, TAG_LEN, invalid}; + +/// 加密的流。读写都是明文,线上是一帧一帧的密文。 +/// +/// **写是缓冲一帧的**:`poll_write` 把收下的那一段加密成一帧放进缓冲, +/// 立刻报「收下了」,下一次写、`flush`、`shutdown` 之前先把它送完。所以 +/// 用它的一方要照常 `flush` —— hyper 会。 +/// +/// 每个方向一个计数器当 nonce(snow 管),2^64 帧用不完,不需要换钥。 +pub struct SecureStream { + inner: S, + noise: snow::TransportState, + /// 从底层读上来、还没拆成帧的字节:`raw[start..end]` + raw: Vec, + start: usize, + end: usize, + /// 解密好、还没交给调用方的明文:`plain[pos..]` + plain: Vec, + pos: usize, + /// 对面在帧边界上关了写的一侧 + eof: bool, + /// 加密好、还没送出去的一帧:`out[sent..]` + out: Vec, + sent: usize, +} + +impl SecureStream { + pub(crate) fn new(inner: S, noise: snow::TransportState) -> Self { + Self { + inner, + noise, + // 两帧的空间:一帧没读完时后面那一帧的开头也放得下,挪一次就够 + raw: vec![0u8; 2 * (2 + MAX_FRAME)], + start: 0, + end: 0, + plain: Vec::with_capacity(MAX_FRAME), + pos: 0, + eof: false, + out: Vec::with_capacity(2 + MAX_FRAME), + sent: 0, + } + } + + /// 底层的流。**只读**:绕过加密往里写会把帧打乱。 + pub fn get_ref(&self) -> &S { + &self.inner + } + + #[cfg(test)] + pub(crate) fn into_inner_for_tests(self) -> S { + self.inner + } +} + +impl std::fmt::Debug for SecureStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecureStream").finish_non_exhaustive() + } +} + +impl SecureStream { + /// 缓冲里已经有一整帧的话,解出来放进 `plain`。 + fn take_frame(&mut self) -> io::Result { + let have = self.end - self.start; + if have < 2 { + return Ok(false); + } + let len = u16::from_be_bytes([self.raw[self.start], self.raw[self.start + 1]]) as usize; + if len < TAG_LEN { + return Err(invalid(format!( + "a {len}-byte frame is too short to carry a tag" + ))); + } + if have < 2 + len { + return Ok(false); + } + let body = self.start + 2..self.start + 2 + len; + self.plain.resize(len, 0); + let n = self + .noise + .read_message(&self.raw[body], &mut self.plain) + .map_err(|e| invalid(format!("a frame did not decrypt: {e}")))?; + self.plain.truncate(n); + self.pos = 0; + self.start += 2 + len; + if self.start == self.end { + self.start = 0; + self.end = 0; + } + Ok(true) + } + + /// 把缓冲里那一帧送完。 + fn poll_drain(&mut self, cx: &mut Context<'_>) -> Poll> { + while self.sent < self.out.len() { + let n = ready!(Pin::new(&mut self.inner).poll_write(cx, &self.out[self.sent..]))?; + if n == 0 { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + self.sent += n; + } + self.out.clear(); + self.sent = 0; + Poll::Ready(Ok(())) + } +} + +impl AsyncRead for SecureStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + loop { + if this.pos < this.plain.len() { + let n = (this.plain.len() - this.pos).min(buf.remaining()); + buf.put_slice(&this.plain[this.pos..this.pos + n]); + this.pos += n; + return Poll::Ready(Ok(())); + } + // 空帧(零字节的明文)也是合法的一帧,解完接着读下一帧 + if this.take_frame()? { + continue; + } + if this.eof { + return Poll::Ready(Ok(())); + } + // 半帧挪到开头,腾出后面的空间 + if this.start > 0 && this.raw.len() - this.end < 2 + MAX_FRAME { + this.raw.copy_within(this.start..this.end, 0); + this.end -= this.start; + this.start = 0; + } + let mut rb = ReadBuf::new(&mut this.raw[this.end..]); + ready!(Pin::new(&mut this.inner).poll_read(cx, &mut rb))?; + let n = rb.filled().len(); + if n == 0 { + if this.end > this.start { + // 帧读到一半对面就关了:不是正常结束 + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + this.eof = true; + return Poll::Ready(Ok(())); + } + this.end += n; + } + } +} + +impl AsyncWrite for SecureStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + // 上一帧没送完就不收新的:否则缓冲会无限长 + ready!(this.poll_drain(cx))?; + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + let n = buf.len().min(MAX_PLAINTEXT); + this.out.resize(2 + n + TAG_LEN, 0); + let len = this + .noise + .write_message(&buf[..n], &mut this.out[2..]) + .map_err(|e| io::Error::other(format!("a frame did not encrypt: {e}")))?; + this.out.truncate(2 + len); + let len = u16::try_from(len).expect("a frame is at most 65535 bytes"); + this.out[..2].copy_from_slice(&len.to_be_bytes()); + this.sent = 0; + // 顺手送一下。送不完也已经收下了:下一次写或 flush 会接着送 + if let Poll::Ready(Err(e)) = this.poll_drain(cx) { + return Poll::Ready(Err(e)); + } + Poll::Ready(Ok(n)) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + ready!(this.poll_drain(cx))?; + Pin::new(&mut this.inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + ready!(this.poll_drain(cx))?; + Pin::new(&mut this.inner).poll_shutdown(cx) + } +} diff --git a/crates/tw-link/src/tests.rs b/crates/tw-link/src/tests.rs new file mode 100644 index 00000000..786bd373 --- /dev/null +++ b/crates/tw-link/src/tests.rs @@ -0,0 +1,320 @@ +//! 握手和传输,两端都在同一个进程里,中间是一根内存管道。 + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream, duplex}; +use tw_api::CONTROL_API_VERSION; + +use super::*; + +fn key(b: u8) -> ControlKey { + ControlKey::from_bytes([b; 32]) +} + +fn acceptor(k: ControlKey) -> Acceptor { + Acceptor::new(move || Some(k.clone()), "2026.9.30") +} + +/// 两端同时握手,各自拿到结果。 +async fn pair( + client_key: ControlKey, + server: Acceptor, +) -> ( + Result<(SecureStream, ServerHello), LinkError>, + Result, LinkError>, +) { + let (a, b) = duplex(64 * 1024); + let c = tokio::spawn(async move { connect(a, &client_key, "lite 2026.9.30").await }); + let s = server.accept(b).await; + (c.await.unwrap(), s) +} + +async fn linked() -> (SecureStream, SecureStream) { + let (c, s) = pair(key(7), acceptor(key(7))).await; + let (c, hello) = c.unwrap(); + let s = s.unwrap(); + assert!(hello.accept); + assert_eq!(hello.core, "2026.9.30"); + assert_eq!(hello.proto, CONTROL_API_VERSION); + assert_eq!(s.hello.app, "lite 2026.9.30"); + assert_eq!(s.key, key(7)); + (c, s.stream) +} + +#[tokio::test] +async fn a_round_trip_carries_bytes_both_ways() { + let (mut c, mut s) = linked().await; + c.write_all(b"GET /status HTTP/1.1\r\n\r\n").await.unwrap(); + c.flush().await.unwrap(); + let mut got = [0u8; 24]; + s.read_exact(&mut got).await.unwrap(); + assert_eq!(&got, b"GET /status HTTP/1.1\r\n\r\n"); + + s.write_all(b"HTTP/1.1 200 OK\r\n").await.unwrap(); + s.flush().await.unwrap(); + let mut got = [0u8; 17]; + c.read_exact(&mut got).await.unwrap(); + assert_eq!(&got, b"HTTP/1.1 200 OK\r\n"); +} + +/// 比一帧大得多的正文要拆成几帧,拼回来一个字节不差。**两个方向同时写**: +/// 管道只有 64 KB,一边光写不读就会卡住另一边 —— 真实的 socket 也一样。 +#[tokio::test] +async fn a_large_body_is_split_into_frames_and_arrives_whole() { + let (c, s) = linked().await; + let body: Vec = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect(); + let (mut cr, mut cw) = tokio::io::split(c); + let (mut sr, mut sw) = tokio::io::split(s); + + let up = body.clone(); + let writer = tokio::spawn(async move { + cw.write_all(&up).await.unwrap(); + cw.shutdown().await.unwrap(); + }); + let down = body.clone(); + let echo = tokio::spawn(async move { + sw.write_all(&down).await.unwrap(); + sw.shutdown().await.unwrap(); + }); + let mut at_server = Vec::new(); + sr.read_to_end(&mut at_server).await.unwrap(); + let mut at_client = Vec::new(); + cr.read_to_end(&mut at_client).await.unwrap(); + writer.await.unwrap(); + echo.await.unwrap(); + assert_eq!(at_server.len(), body.len()); + assert!(at_server == body, "服务端收到的和发出的不一样"); + assert!(at_client == body, "客户端收到的和发出的不一样"); +} + +/// 一连串很小的写,每一段都是一帧,读的一方照样按顺序拿全。 +#[tokio::test] +async fn many_small_writes_arrive_in_order() { + let (c, s) = linked().await; + let (_cr, mut cw) = tokio::io::split(c); + let (mut sr, _sw) = tokio::io::split(s); + let writer = tokio::spawn(async move { + for i in 0..20_000u32 { + cw.write_all(&i.to_be_bytes()[1..]).await.unwrap(); + } + cw.shutdown().await.unwrap(); + }); + let mut got = Vec::new(); + sr.read_to_end(&mut got).await.unwrap(); + writer.await.unwrap(); + assert_eq!(got.len(), 20_000 * 3); + for (i, chunk) in got.chunks(3).enumerate() { + assert_eq!(chunk, &(i as u32).to_be_bytes()[1..], "第 {i} 段"); + } +} + +/// 读的一方给的缓冲比一帧小:一帧的明文分几次读完,不丢不重。 +#[tokio::test] +async fn a_frame_can_be_read_a_few_bytes_at_a_time() { + let (mut c, mut s) = linked().await; + c.write_all(&[9u8; 1000]).await.unwrap(); + c.flush().await.unwrap(); + let mut total = 0; + let mut buf = [0u8; 7]; + while total < 1000 { + let n = s.read(&mut buf).await.unwrap(); + assert!(n > 0); + assert!(buf[..n].iter().all(|b| *b == 9)); + total += n; + } + assert_eq!(total, 1000); +} + +#[tokio::test] +async fn a_wrong_key_is_told_apart_on_both_sides() { + let (c, s) = pair(key(1), acceptor(key(2))).await; + assert!(matches!(c, Err(LinkError::WrongKey)), "{:?}", c.err()); + assert!(matches!(s, Err(LinkError::WrongKey)), "{:?}", s.err()); +} + +/// 此刻没有钥匙(配置里还没有):谁来都拒。 +#[tokio::test] +async fn with_no_key_in_effect_everyone_is_turned_away() { + let a = Acceptor::new(|| None, "x"); + let (c, s) = pair(key(1), a).await; + assert!(matches!(c, Err(LinkError::WrongKey))); + assert!(matches!(s, Err(LinkError::WrongKey))); +} + +/// 钥匙是每次握手现取的:换了之后,旧钥匙进不来、新钥匙进得来。 +#[tokio::test] +async fn a_rotated_key_takes_effect_on_the_next_handshake() { + let current = Arc::new(Mutex::new(key(1))); + let src = current.clone(); + let a = Acceptor::new(move || Some(src.lock().unwrap().clone()), "x"); + let (c, _) = pair(key(1), a.clone()).await; + assert!(c.is_ok()); + *current.lock().unwrap() = key(2); + let (c, s) = pair(key(1), a.clone()).await; + assert!(matches!(c, Err(LinkError::WrongKey))); + assert!(matches!(s, Err(LinkError::WrongKey))); + let (c, s) = pair(key(2), a).await; + assert!(c.is_ok()); + assert_eq!(s.unwrap().key, key(2)); +} + +/// 版本不一致:core 回 `accept: false` 就断开,两边各自拿到两个版本号。 +#[tokio::test] +async fn a_version_mismatch_names_both_versions() { + let (a, b) = duplex(64 * 1024); + let k = key(3); + let c = + tokio::spawn( + async move { handshake::client(a, &k, "lite old", CONTROL_API_VERSION - 1).await }, + ); + let s = acceptor(key(3)).accept(b).await; + match c.await.unwrap() { + Err(LinkError::VersionMismatch { + ours, + theirs, + peer_version, + }) => { + assert_eq!(ours, CONTROL_API_VERSION - 1); + assert_eq!(theirs, CONTROL_API_VERSION); + assert_eq!(peer_version, "2026.9.30"); + } + other => panic!("{:?}", other.err()), + } + match s { + Err(LinkError::VersionMismatch { + ours, + theirs, + peer_version, + }) => { + assert_eq!(ours, CONTROL_API_VERSION); + assert_eq!(theirs, CONTROL_API_VERSION - 1); + assert_eq!(peer_version, "lite old"); + } + other => panic!("{:?}", other.err()), + } +} + +/// 对面一声不吭:客户端按时放弃。 +#[tokio::test(start_paused = true)] +async fn a_silent_server_times_out() { + let (a, _b) = duplex(1024); + let r = connect(a, &key(1), "x").await; + assert!(matches!(r, Err(LinkError::Timeout)), "{:?}", r.err()); +} + +/// 连上来就不说话的连接不能一直占着服务端。 +#[tokio::test(start_paused = true)] +async fn a_silent_client_times_out() { + let (_a, b) = duplex(1024); + let r = acceptor(key(1)).accept(b).await; + assert!(matches!(r, Err(LinkError::Timeout)), "{:?}", r.err()); +} + +/// 对面读完第一条什么都不回就关了(远程端口上被放行名单挡住的样子)。 +#[tokio::test] +async fn a_server_that_hangs_up_is_closed_not_wrong_key() { + let (a, mut b) = duplex(64 * 1024); + let server = tokio::spawn(async move { + let mut buf = [0u8; 256]; + let _ = b.read(&mut buf).await; + drop(b); + }); + let r = connect(a, &key(1), "x").await; + server.await.unwrap(); + assert!(matches!(r, Err(LinkError::Closed)), "{:?}", r.err()); +} + +#[tokio::test] +async fn a_client_that_hangs_up_before_speaking_is_closed() { + let (a, b) = duplex(1024); + drop(a); + let r = acceptor(key(1)).accept(b).await; + assert!(matches!(r, Err(LinkError::Closed)), "{:?}", r.err()); +} + +/// 拿 curl 直接敲过来:服务端照钥匙不对回一个字节,不去等 18 KB。 +#[tokio::test] +async fn plain_http_gets_the_reject_byte() { + let (mut a, b) = duplex(64 * 1024); + let s = tokio::spawn(async move { acceptor(key(1)).accept(b).await }); + a.write_all(b"GET /status HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut got = Vec::new(); + a.read_to_end(&mut got).await.unwrap(); + assert_eq!(got, [REJECT]); + assert!(matches!(s.await.unwrap(), Err(LinkError::WrongKey))); +} + +/// 半关:客户端关了写的一侧,服务端在帧边界上读到结尾(0 字节,不是错误), +/// 之后照样能回话;服务端关了,客户端也读到结尾。 +#[tokio::test] +async fn half_close_is_an_orderly_end_of_stream() { + let (mut c, mut s) = linked().await; + c.write_all(b"last words").await.unwrap(); + c.shutdown().await.unwrap(); + let mut got = Vec::new(); + s.read_to_end(&mut got).await.unwrap(); + assert_eq!(got, b"last words"); + // 读到结尾之后再读还是结尾 + let mut more = [0u8; 4]; + assert_eq!(s.read(&mut more).await.unwrap(), 0); + + s.write_all(b"reply").await.unwrap(); + s.shutdown().await.unwrap(); + let mut got = Vec::new(); + c.read_to_end(&mut got).await.unwrap(); + assert_eq!(got, b"reply"); +} + +/// 一帧读到一半对面就没了:这不是正常结束,要报错。 +#[tokio::test] +async fn a_frame_cut_short_is_an_error_not_an_end() { + let (c, s) = pair_raw().await; + let (mut client, raw) = (c, s); + // 直接往底层写半个帧头 + 几个字节,然后关掉 + let mut raw = raw; + raw.write_all(&[0x00, 0x40, 1, 2, 3]).await.unwrap(); + drop(raw); + let mut buf = [0u8; 16]; + let e = client.read(&mut buf).await.unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::UnexpectedEof); +} + +/// 被改过的帧解不开:读的一方报错,不交出任何明文。 +#[tokio::test] +async fn a_tampered_frame_is_refused() { + let (mut client, mut raw) = pair_raw().await; + // 一帧 20 字节的「密文」:长度头对,内容是乱写的 + let mut frame = vec![0x00, 20]; + frame.extend_from_slice(&[0xAB; 20]); + raw.write_all(&frame).await.unwrap(); + let mut buf = [0u8; 16]; + let e = client.read(&mut buf).await.unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); +} + +/// 握手完成之后,服务端那一侧换成原始的管道,好往里写坏数据。 +async fn pair_raw() -> (SecureStream, DuplexStream) { + let (c, s) = pair(key(5), acceptor(key(5))).await; + let (c, _) = c.unwrap(); + let s = s.unwrap(); + // 拿回底层的管道:SecureStream 不交出可写的引用,这里只能借结构体拆开 + let raw = into_inner(s.stream); + (c, raw) +} + +fn into_inner(s: SecureStream) -> DuplexStream { + s.into_inner_for_tests() +} + +/// 限时可以自己定。 +#[tokio::test(start_paused = true)] +async fn the_limit_can_be_set() { + let (a, _b) = duplex(1024); + let started = tokio::time::Instant::now(); + let r = connect_with_timeout(a, &key(1), "x", Duration::from_millis(200)).await; + assert!(matches!(r, Err(LinkError::Timeout))); + assert!(started.elapsed() < Duration::from_secs(1)); +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 4d13dedc..9d4ec92d 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -18,11 +18,10 @@ export HOME="$FAKE_HOME" export THINKWATCH_HOME="$FAKE_HOME/.thinkwatch" mkdir -p "$THINKWATCH_HOME" "$FAKE_HOME/.claude" SOCK="$THINKWATCH_HOME/twcore.sock" -# 控制面要凭据。**这个脚本自己定它是什么**,通过环境变量交给 twcore —— -# 比等它自己生成一个、再去文件里把它捞回来少一步竞态(那个文件是在它 -# 启动过程中写的,而这里几乎同时就要用)。 -export TW_CONTROL_TOKEN="smoke-$$-$RANDOM" -AUTH=(-H "Authorization: Bearer $TW_CONTROL_TOKEN") +# 控制面的每条连接先握手(钥匙在 config.yaml 的 listen.control.key),curl +# 敲不开它。`twcore call` 读同一份配置里的钥匙、走和桌面端同一条握手。 +# 带 --out 时正文写进文件、只打印状态码,和原来 curl -o -w 的用法一样 +ctl() { "$BIN" --config "$CFG" call "$@"; } PORT=18999 UPPORT=18998 PASS=0; FAIL=0 @@ -176,7 +175,10 @@ security: mode: enforce """) PY +# 这份配置故意没写控制面的钥匙:老用户的配置就是这样。check 要认得它(钥匙 +# 由 serve 补上),serve 起来之后要只多出钥匙那几行 "$BIN" --config "$CFG" check >/dev/null 2>&1 && ok "check 认得这份配置" || bad "check 不认这份配置" +cp "$CFG" "$TMP/config.before" # ---------------------------------------------------------------- 起服务 step "起服务" @@ -198,6 +200,19 @@ fi CORE_PID=$! for _ in $(seq 1 40); do [ -S "$SOCK" ] && break; sleep 0.25; done [ -S "$SOCK" ] && ok "控制面 socket 起来了" || { bad "socket 没出现" "$(tail -3 "$TMP/core.log")"; exit 1; } +# **钥匙在监听之前就写好了**:socket 出现的那一刻,配置里已经有它 +KEY=$(python3 - "$CFG" <<'PY' +import re, sys +m = re.search(r'^ key: ([0-9a-f]{64})$', open(sys.argv[1], encoding='utf-8').read(), re.M) +print(m.group(1) if m else "") +PY +) +[ -n "$KEY" ] && ok "serve 给没有钥匙的配置补上了钥匙" || bad "配置里没有钥匙" "$(head -12 "$CFG")" +ADDED=$(diff "$TMP/config.before" "$CFG" | grep -c '^>') +REMOVED=$(diff "$TMP/config.before" "$CFG" | grep -c '^<') +[ "$ADDED" = 2 ] && [ "$REMOVED" = 0 ] && ok "只多出钥匙那两行,别的一个字节没动" \ + || bad "补钥匙改动了别的地方" "$(diff "$TMP/config.before" "$CFG" | head -8)" +[ "$("$BIN" --config "$CFG" control-key)" = "$KEY" ] && ok "control-key 打印的就是这把" || bad "control-key 打印的不是配置里那把" MODE=$(mode_of "$THINKWATCH_HOME/data.db" || echo -) [ "$MODE" = "600" ] && ok "data.db 是 0600" || bad "data.db 权限是 $MODE" @@ -215,7 +230,7 @@ else # 根本没被当成 UTF-8。所以把这一次的路由决策和 core 日志一起交出来 —— # 少了这些,CI 上的一次失败在本机复现不出来就只能靠猜。 sleep 0.5 - CHAIN=$(curl -s --unix-socket "$SOCK" "${AUTH[@]}" "http://localhost/history?limit=1" 2>/dev/null \ + CHAIN=$(ctl "/history?limit=1" 2>/dev/null \ | python3 -c 'import sys, json # /history 是个顶层数组。字段名在演化,所以打印整行而不是挑几个 —— # 挑错了名字就什么都看不到,而这段代码只在出事那一次跑。 @@ -255,7 +270,7 @@ echo "$S" | grep -q 'content_block_stop' && bad "切断之后还发了 content_b # 行一律没有用量,这笔钱就不在账上。 GOT="" for _ in $(seq 1 20); do - GOT=$(curl -s --unix-socket "$SOCK" "${AUTH[@]}" "http://localhost/history?limit=1" 2>/dev/null \ + GOT=$(ctl "/history?limit=1" 2>/dev/null \ | python3 -c 'import sys, json rows = json.load(sys.stdin) r = rows[0] if rows else {} @@ -283,7 +298,7 @@ else GOT="" # 落库是异步的:事件先过广播,再由存储层的任务写进去 for _ in $(seq 1 20); do - GOT=$(curl -s --unix-socket "$SOCK" "${AUTH[@]}" "http://localhost/history?limit=1" 2>/dev/null \ + GOT=$(ctl "/history?limit=1" 2>/dev/null \ | python3 -c 'import sys, json rows = json.load(sys.stdin) r = rows[0] if rows else {} @@ -299,21 +314,32 @@ print("ok" if good else json.dumps(r, ensure_ascii=False, sort_keys=True))' 2>/d fi # ---------------------------------------------------------------- 控制面 -step "控制面要凭据" +step "控制面要握手" sleep 1 -# **只有这里能证明门是真的。**单元测试测的是那一层中间件,而「它到底有没有 -# 被挂到真正对外的那份路由表上」只有真二进制加真 socket 答得出来 —— 漏挂的 +# **只有这里能证明门是真的。**单元测试测的是握手本身,而「它到底有没有挡在 +# 真正对外的那个 socket 前面」只有真二进制加真 socket 答得出来 —— 漏挂的 # 样子是所有测试照常通过,而控制面对整台机器敞着。 -NOAUTH=$(curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" http://localhost/status) -[ "$NOAUTH" = 401 ] && ok "不带凭据被拒" || bad "不带凭据竟然进去了" "$NOAUTH" -WRONG=$(curl -s -o /dev/null -w '%{http_code}' --unix-socket "$SOCK" \ - -H 'Authorization: Bearer not-the-one' http://localhost/status) -[ "$WRONG" = 401 ] && ok "凭据不对被拒" || bad "凭据不对竟然进去了" "$WRONG" +PLAIN=$(curl -s -m 5 -o "$TMP/plain" -w '%{http_code}' --unix-socket "$SOCK" http://localhost/status) +if [ "$PLAIN" != 200 ] && ! grep -q api_version "$TMP/plain" 2>/dev/null; then + ok "不握手的 HTTP 进不来" +else + bad "不握手的 HTTP 竟然进去了" "$PLAIN $(head -c 200 "$TMP/plain")" +fi +# 钥匙不对:另一份配置、同一个 socket +mkdir -p "$TMP/wrong" +printf 'listen:\n control:\n key: %s\n' "$(printf '0%.0s' $(seq 1 64))" > "$TMP/wrong/config.yaml" +ln -s "$SOCK" "$TMP/wrong/twcore.sock" +if "$BIN" --config "$TMP/wrong/config.yaml" call /status > "$TMP/wrong/out" 2> "$TMP/wrong/err"; then + bad "钥匙不对竟然进去了" "$(head -c 200 "$TMP/wrong/out")" +else + grep -q "does not match" "$TMP/wrong/err" && ok "钥匙不对被拒,而且说的是钥匙不对" \ + || bad "钥匙不对被拒,但说法不对" "$(cat "$TMP/wrong/err")" +fi +ctl /status | grep -q '"api_version"' && ok "钥匙对了就进得来" || bad "拿着对的钥匙也进不来" step "控制面(每个端点)" -get() { curl -s -o "$TMP/out" -w '%{http_code}' --unix-socket "$SOCK" "${AUTH[@]}" "http://localhost$1"; } -post() { curl -s -o "$TMP/out" -w '%{http_code}' --unix-socket "$SOCK" "${AUTH[@]}" -XPOST \ - -H 'content-type: application/json' -d "$2" "http://localhost$1"; } +get() { ctl --out "$TMP/out" "$1"; } +post() { ctl --out "$TMP/out" -X POST -d "$2" "$1"; } # **带上时间窗再打一次。**不带参数时一切正常、带上 `from_ms` 就 400, # 是这两个端点真实发生过的形态:`#[serde(flatten)]` 让 serde 走 @@ -340,11 +366,11 @@ C=$(post /scan '{"projects":["'"$TMP"'"]}'); [ "$C" = "200" ] && ok "POST /scan C=$(post /clients/plan '{"client":"claude-code"}'); [ "$C" = "200" ] && ok "POST /clients/plan" || bad "POST /clients/plan 返回 $C" # 页面打开时补问模型清单:立刻返回开始问的那几家,不等上游回话 C=$(post /models/refresh '{}'); [ "$C" = "200" ] && ok "POST /models/refresh" || bad "POST /models/refresh 返回 $C" -C=$(curl -s --unix-socket "$SOCK" "${AUTH[@]}" http://localhost/overview | python3 -c 'import json,sys;p=json.load(sys.stdin)["providers"][0];print(p["model_status"] in ("pending","listed","no_list","failed") and isinstance(p["model_fetching"],bool))') +C=$(ctl /overview | python3 -c 'import json,sys;p=json.load(sys.stdin)["providers"][0];print(p["model_status"] in ("pending","listed","no_list","failed") and isinstance(p["model_fetching"],bool))') [ "$C" = "True" ] && ok "/overview 带模型获取状态" || bad "/overview 的模型状态字段不对:$C" C=$(get /clients/claude-code/why); [ "$C" = "200" ] && ok "GET /clients/{id}/why" || bad "返回 $C" -ID=$(curl -s --unix-socket "$SOCK" "${AUTH[@]}" "http://localhost/history?limit=1" \ +ID=$(ctl "/history?limit=1" \ | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d[0]["id"] if d else 0)') if [ "$ID" != "0" ]; then C=$(get "/request/$ID"); [ "$C" = "200" ] && ok "GET /request/{id}" || bad "返回 $C" @@ -357,11 +383,18 @@ fi # ---------------------------------------------------------------- 诊断包 step "诊断包不带密钥出门" get /diagnostics >/dev/null -if grep -qE 'sk-upstream-smoke|tw-smoketestkey0123456789' "$TMP/out"; then +if grep -qE "sk-upstream-smoke|tw-smoketestkey0123456789|$KEY" "$TMP/out"; then bad "诊断包里有真密钥" else ok "诊断包里没有真密钥" fi +# 控制面的钥匙不从控制面出去:编辑器拿到的原文里是打码 +get /config >/dev/null +if grep -q "$KEY" "$TMP/out"; then + bad "GET /config 带出了控制面的钥匙" +else + ok "GET /config 里的钥匙是打码的" +fi # ---------------------------------------------------------------- 只有一份配置 step "配置目录里只有一份配置文件" @@ -452,9 +485,21 @@ fi # 这是整条链唯一被端到端验证的地方 —— 一条 HTTP 请求扳开关、主循环的 # select 醒过来、进程自己退出。单元测试能证明开关会被扳动,证明不了它 # 接在主循环上;而接错的样子是请求返回 202、进程稳稳地继续跑。 +# **换钥匙**:跑着的 core 从文件监听拿到新钥匙,旧的那把立刻进不来 +step "换钥匙" +NEW=$("$BIN" --config "$CFG" control-key --rotate 2>/dev/null) +[ -n "$NEW" ] && [ "$NEW" != "$KEY" ] && ok "control-key --rotate 换了一把" || bad "没换成" "$NEW" +sleep 1.5 +ctl /status | grep -q '"api_version"' && ok "新钥匙进得来" || bad "换完钥匙进不来了" "$(tail -3 "$TMP/core.log")" +printf 'listen:\n control:\n key: %s\n' "$KEY" > "$TMP/wrong/config.yaml" +if "$BIN" --config "$TMP/wrong/config.yaml" call /status >/dev/null 2>&1; then + bad "旧钥匙换掉之后还进得来" +else + ok "旧钥匙进不来了" +fi + step "请它退出,它就退" -CODE=$(curl -s -o "$TMP/out" -w '%{http_code}' --unix-socket "$SOCK" "${AUTH[@]}" \ - -XPOST http://localhost/shutdown) +CODE=$(ctl --out "$TMP/out" -X POST /shutdown) if [ "$CODE" = 202 ]; then ok "控制面收下了这条请求" else