diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79be20e..b222bbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -252,15 +252,42 @@ jobs: fi "$BIN" --help > /dev/null + # **两种形态。**裸二进制给桌面版的流水线和 `twcore upgrade`(它们只要 + # 那一个文件);压缩包给服务器上的首次安装(`scripts/install.sh`): + # 里面还有 systemd unit,装的 unit 和装的二进制出自同一个 commit。 + # 压缩包还保住了可执行位 —— 裸文件下载下来是 0644。 - name: Package + env: + TARGET: ${{ matrix.target }} run: | set -euo pipefail - mkdir -p dist - cp target/${{ matrix.target }}/release/twcore dist/twcore-${{ matrix.target }} + # 压缩包里的目录和裸二进制同名,所以在 dist 外面搭 + STAGE="$RUNNER_TEMP/stage/twcore-$TARGET" + mkdir -p dist "$STAGE" + cp "target/$TARGET/release/twcore" "dist/twcore-$TARGET" + install -m 0755 "target/$TARGET/release/twcore" "$STAGE/twcore" + install -m 0644 packaging/systemd/twcore.service LICENSE "$STAGE/" + tar -czf "dist/twcore-$TARGET.tar.gz" --owner=0 --group=0 -C "$RUNNER_TEMP/stage" "twcore-$TARGET" cd dist # 和另外两个平台同一种校验文件:` <文件名>` - sha256sum twcore-${{ matrix.target }} > twcore-${{ matrix.target }}.sha256 - cat twcore-${{ matrix.target }}.sha256 + sha256sum "twcore-$TARGET" > "twcore-$TARGET.sha256" + sha256sum "twcore-$TARGET.tar.gz" > "twcore-$TARGET.tar.gz.sha256" + cat ./*.sha256 + tar -tzvf "twcore-$TARGET.tar.gz" + + # 装脚本认的就是这个布局,这里照着它的步骤解一遍、跑一遍 —— 布局改了 + # 而脚本没跟上,在这里就挂,而不是在用户的服务器上。 + - name: The archive is what install.sh expects + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + T=$(mktemp -d) + (cd dist && sha256sum -c "twcore-$TARGET.tar.gz.sha256") + tar -xzf "dist/twcore-$TARGET.tar.gz" -C "$T" + test -x "$T/twcore-$TARGET/twcore" + test -f "$T/twcore-$TARGET/twcore.service" + "$T/twcore-$TARGET/twcore" --version - uses: softprops/action-gh-release@v2 # 排练不发布。 @@ -269,4 +296,6 @@ jobs: files: | dist/twcore-${{ matrix.target }} dist/twcore-${{ matrix.target }}.sha256 + dist/twcore-${{ matrix.target }}.tar.gz + dist/twcore-${{ matrix.target }}.tar.gz.sha256 generate_release_notes: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3dc4959..5012704 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,6 +87,38 @@ clean the diff is: connections to the same handshake before HTTP. The control key never leaves through the control plane and cannot be changed through it. +## The configuration reference + +`docs/config.md` and `docs/config.zh-CN.md` are written by hand, except the +field tables and the built-in rule lists: everything between +`` and `` is rendered from +`crates/tw-config/tests/manual/schema.rs`, and +`cargo test -p tw-config --test manual` fails when the two differ. + +That file declares every section of `config.yaml` against its Rust type, and +the test checks the declaration against the code rather than trusting it: + +- **Field names** come from serde itself (a probe deserializer records the + names a derived `Deserialize` asks for), so a field added to a config + type and not to the manual fails with the field's name. +- **Defaults are proven.** A declared default is written into a minimal + section and parsed; it has to mean the same as leaving the field out. A + field marked required has to fail without it. +- **Enum values** (`protocol`, `mode`, `type` …) are read from serde, not + copied. +- **Examples** in the manuals are parsed as configuration. + +When you change a config type, add or change its row (English and Chinese), +then regenerate: + +```bash +UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual +``` + +A section that is designed but not in the code yet is declared as +`Ty::Pending`: it is rendered, and the test fails as soon as the code starts +reading that field, so the declaration gets switched to the real type. + ## The price list Prices come in two layers. @@ -113,20 +145,36 @@ answered on the request itself. ## Cutting a release -`twcore` ships inside the desktop app's `.app`, so "which build is in -there" has to be a fact somebody can check rather than whatever sat in -a `target/` directory that afternoon. +`twcore` ships inside the desktop app, and on its own for servers, so +"which build is in there" has to be a fact somebody can check rather than +whatever sat in a `target/` directory that afternoon. 1. Bump `version` in the workspace `Cargo.toml`, land it on `main`. 2. Tag that commit `vX.Y.Z` and push the tag. -3. `release.yml` builds `twcore` for `aarch64-apple-darwin`, checks the +3. `release.yml` builds `twcore` for every target below, checks each binary actually runs and reports the version on the tag, and attaches - it to a GitHub Release with a `sha256`. + them to a GitHub Release, each with a `.sha256` (` `). + +| Target | Files | +|---|---| +| `aarch64-apple-darwin` | `twcore-aarch64-apple-darwin` | +| `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc` | `twcore-.exe` | +| `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu` | `twcore-`, and `twcore-.tar.gz` holding the binary, `twcore.service` and `LICENSE` | + +The bare binaries are what the desktop app's pipeline bundles and what +`twcore upgrade` downloads. The Linux tarballs are what +`scripts/install.sh` installs on a server; they carry the systemd unit so +the unit and the binary come from the same commit. The file names are a +contract with both: `twcore upgrade` has a test that reads `release.yml`. + +To try a change to `release.yml` without publishing, run it by hand +(`workflow_dispatch`): it builds and checks everything and uploads nothing. The desktop app pins `tw-api` to the same tag and bundles the binary from that release. Those two have to come from one commit: the binary speaks a protocol, and the app compiles a mirror of it. -Apple Silicon only, deliberately. An Intel user downloading a file that -will not open is worse served than one who finds no download at all; -supporting them means a universal binary, which is its own decision. +On macOS, Apple Silicon only, deliberately. An Intel user downloading a +file that will not open is worse served than one who finds no download +at all; supporting them means a universal binary, which is its own +decision. diff --git a/Cargo.lock b/Cargo.lock index 434c9a4..dca0c1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2816,13 +2816,18 @@ name = "twcore" version = "0.46.0" dependencies = [ "anyhow", + "axum", "chrono", "clap", "http-body-util", "hyper", "hyper-util", "libc", + "reqwest", + "serde", + "serde_json", "serde_yaml_ng", + "sha2", "tempfile", "tokio", "tracing", diff --git a/README.md b/README.md index e00d123..6d9eebc 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ cargo run -p twcore -- check # validate only, don't start cargo run -p twcore -- serve # start the gateway and control plane ``` +Every field of `config.yaml` is described in the +[configuration reference](docs/config.md). To run `twcore` on a Linux server +and manage it from the desktop app, see +[Running core on a server](docs/server.md). + ## What it does Point a client (Claude Code, Codex, and friends) at a local port, and: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6e38124..ad41673 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,6 +20,9 @@ cargo run -p twcore -- check # 只校验,不启动 cargo run -p twcore -- serve # 起网关和控制面 ``` +`config.yaml` 的每个字段见[配置手册](docs/config.zh-CN.md)。在 Linux 服务器上运行 +`twcore`、由桌面应用远程管理,见[在服务器上运行 core](docs/server.zh-CN.md)。 + ## 它做什么 把客户端(Claude Code、Codex 之类)指向本地的一个端口,然后: diff --git a/bin/twcore/Cargo.toml b/bin/twcore/Cargo.toml index c77752d..eb6f16d 100644 --- a/bin/twcore/Cargo.toml +++ b/bin/twcore/Cargo.toml @@ -32,6 +32,10 @@ hyper = { workspace = true } hyper-util = { workspace = true, features = ["tokio"] } http-body-util = { workspace = true } tracing-subscriber = { workspace = true } +# `twcore upgrade`:问 Release、下载、核对校验和。都是依赖树里已有的 +reqwest = { workspace = true } +serde = { workspace = true } +sha2 = { workspace = true } [target.'cfg(unix)'.dependencies] # 只剩一处在用:问一个 pid 还在不在(见 src/proc.rs) @@ -45,3 +49,6 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] tempfile = "3" +# `twcore upgrade` 的测试对着一个假的 GitHub 跑 +axum = { workspace = true } +serde_json = { workspace = true } diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 563c9d8..7414a60 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -12,6 +12,7 @@ use clap::{Parser, Subcommand}; mod call; mod lockfile; mod proc; +mod upgrade; use lockfile::{LockFile, LockOutcome}; @@ -111,6 +112,20 @@ enum Command { #[arg(short, long)] out: Option, }, + /// Replace this twcore with another release from GitHub; the configuration is left alone + // + // 只给单独装的 twcore 用(服务器上)。桌面应用包里的那份由应用更新,见 upgrade.rs + Upgrade { + /// Only compare with the release and report; change nothing + #[arg(long)] + check: bool, + /// Restart twcore.service afterwards when systemd runs it + #[arg(long)] + restart: bool, + /// Install this version rather than the latest, such as 0.47.0 + #[arg(long)] + version: Option, + }, } #[derive(Subcommand)] @@ -185,6 +200,15 @@ fn main() -> Result<()> { data, out, } => call::run(&path, &endpoint, &method, data, out), + Command::Upgrade { + check, + restart, + version, + } => upgrade::run(upgrade::Opts { + check, + restart, + version, + }), } } diff --git a/bin/twcore/src/upgrade.rs b/bin/twcore/src/upgrade.rs new file mode 100644 index 0000000..c2dcb45 --- /dev/null +++ b/bin/twcore/src/upgrade.rs @@ -0,0 +1,901 @@ +//! `twcore upgrade`:把这个二进制换成 GitHub Release 上的另一版。 +//! +//! **只给自己管自己的那种安装用** —— 服务器上 `scripts/install.sh` 装的、手工 +//! 放进 PATH 的。桌面应用包里的那一份由应用自己更新:换掉它,应用和 core 就 +//! 不是同一个 commit 出来的了(控制面协议是两边一起编的),所以在包里一律拒绝。 +//! +//! 一次升级的顺序,每一步失败都停在原地、什么都没动: +//! +//! 1. 问 Release(最新的,或 `--version` 指定的那一版),和自己的版本比; +//! 2. 能不能写可执行文件所在的目录 —— **下载之前就问**,免得下了二十兆才说要 sudo; +//! 3. 下载本平台的二进制和它的 `.sha256`,核对; +//! 4. 写进同目录的临时文件,跑一次 `--version`,报的得是要装的那一版; +//! 5. 改名盖过去。unix 上 rename 是原子的:任何时刻那个路径上要么是旧的、要么 +//! 是新的,正在跑的进程拿着旧的 inode 照常跑。 +//! +//! 配置和数据一概不碰。换完之后正在跑的服务还是旧版,重启它由人决定(或 `--restart`)。 + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +const REPO: &str = "ThinkWatchProject/ThinkWatch-Core"; +const API: &str = "https://api.github.com"; +/// install.sh 装的那个 unit +const UNIT: &str = "twcore"; + +pub struct Opts { + pub check: bool, + pub restart: bool, + pub version: Option, +} + +/// 三段数字的版本号。**不认预发布后缀** —— 这个项目不发预发布版,认了反而 +/// 要回答「0.47.0-rc1 比 0.47.0 新吗」这种用不上的问题。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version(u64, u64, u64); + +impl std::str::FromStr for Version { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + let t = s.trim(); + let t = t.strip_prefix('v').unwrap_or(t); + let parts: Vec<&str> = t.split('.').collect(); + let n = |p: &str| p.parse::().ok(); + match parts.as_slice() { + [a, b, c] => match (n(a), n(b), n(c)) { + (Some(a), Some(b), Some(c)) => Ok(Version(a, b, c)), + _ => bail!("`{s}` is not a version such as 0.47.0"), + }, + _ => bail!("`{s}` is not a version such as 0.47.0"), + } + } +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.0, self.1, self.2) + } +} + +/// 这个平台在 Release 里对应哪个构建。**和 release.yml 发的那几个一一对应**, +/// 没发的平台(Intel Mac)返回 `None`。 +pub fn target_for(os: &str, arch: &str) -> Option<&'static str> { + Some(match (os, arch) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + ("windows", "x86_64") => "x86_64-pc-windows-msvc", + ("windows", "aarch64") => "aarch64-pc-windows-msvc", + _ => return None, + }) +} + +/// Release 里的文件名:裸二进制,Windows 带 `.exe`。 +pub fn asset_name(target: &str) -> String { + if target.contains("windows") { + format!("twcore-{target}.exe") + } else { + format!("twcore-{target}") + } +} + +#[derive(Debug, Deserialize)] +pub struct Release { + pub tag_name: String, + pub assets: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Asset { + pub name: String, + pub browser_download_url: String, +} + +impl Release { + pub fn version(&self) -> Result { + self.tag_name.parse() + } + + /// 二进制和它的校验文件的下载地址。**两个缺一个都不装**:没有校验和的 + /// 二进制,装上去的是什么没人说得清。 + pub fn pick(&self, asset: &str) -> Result<(&str, &str)> { + let url = |name: &str| { + self.assets + .iter() + .find(|a| a.name == name) + .map(|a| a.browser_download_url.as_str()) + }; + let sha = format!("{asset}.sha256"); + match (url(asset), url(&sha)) { + (Some(b), Some(s)) => Ok((b, s)), + (None, _) => bail!( + "release {} has no {asset}, so there is no build for this machine in it", + self.tag_name + ), + (Some(_), None) => bail!( + "release {} has {asset} but not {sha}, so it cannot be verified", + self.tag_name + ), + } + } +} + +/// ` <文件名>` 里的那串十六进制,和下载到的字节比。 +pub fn verify(bytes: &[u8], sha_file: &str) -> Result<()> { + let want = sha_file + .split_whitespace() + .next() + .map(str::to_ascii_lowercase) + .filter(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit())) + .ok_or_else(|| anyhow!("the checksum file does not start with a SHA-256"))?; + let got: String = Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + if got != want { + bail!("the download's SHA-256 is {got}, and the release says {want}; nothing was changed"); + } + Ok(()) +} + +/// 这个可执行文件是不是桌面应用包里的那一份。 +/// +/// 三个平台的包各长各的样,认的是路径上的形状:macOS 的 `.app`;Linux 的 +/// AppImage 挂载点(`/tmp/.mount_…`)和它里面的 `ThinkWatch Lite` 目录;Windows 的 +/// 安装目录 `ThinkWatch Lite`。 +pub fn in_app_bundle(exe: &Path) -> bool { + exe.components().any(|c| { + let s = c.as_os_str().to_string_lossy(); + s.ends_with(".app") || s == "ThinkWatch Lite" || s.starts_with(".mount_") + }) +} + +/// 在目标旁边写一个临时文件,**同一个目录** —— rename 只在同一个文件系统上是原子的。 +/// +/// 扩展名留在最后(`.twcore.upgrade-123.exe`):Windows 按扩展名认可执行文件, +/// 下面那次 `--version` 要能跑起来。 +fn staging_path(exe: &Path) -> PathBuf { + let stem = exe + .file_stem() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "twcore".into()); + let ext = exe + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + exe.with_file_name(format!(".{stem}.upgrade-{}{ext}", std::process::id())) +} + +/// 能不能在这个目录里写。**下载之前问**,答案是「要 sudo」时不必先下二十兆。 +pub fn check_writable(exe: &Path) -> Result<()> { + let probe = staging_path(exe); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => bail!( + "{} cannot be written by this user. Run the upgrade with sudo", + exe.parent().unwrap_or(exe).display() + ), + Err(e) => Err(e).with_context(|| format!("writing next to {}", exe.display())), + } +} + +/// 写新文件、`check` 一下、盖过去。`check` 拿到的是临时文件的路径。 +/// +/// **任何一步失败,原来那个文件原样还在。** +pub fn replace(exe: &Path, bytes: &[u8], check: impl FnOnce(&Path) -> Result<()>) -> Result<()> { + let tmp = staging_path(exe); + let r = (|| { + write_executable(&tmp, bytes)?; + check(&tmp)?; + swap(&tmp, exe) + })(); + if r.is_err() { + let _ = std::fs::remove_file(&tmp); + } + r +} + +fn write_executable(path: &Path, bytes: &[u8]) -> Result<()> { + use std::io::Write; + let mut f = + std::fs::File::create(path).with_context(|| format!("creating {}", path.display()))?; + f.write_all(bytes)?; + // 落盘再改名:先改名后断电,路径上就是一个半截文件 + f.sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + } + Ok(()) +} + +#[cfg(unix)] +fn swap(new: &Path, exe: &Path) -> Result<()> { + std::fs::rename(new, exe).with_context(|| format!("replacing {}", exe.display())) +} + +/// Windows 不让覆盖一个正在跑的 exe,但让它改名。所以先把旧的挪开、再把新的 +/// 挪进来;第二步失败就挪回去。挪开的那个下次升级时删掉(那时它已经不在跑了)。 +#[cfg(windows)] +fn swap(new: &Path, exe: &Path) -> Result<()> { + let old = exe.with_extension("exe.old"); + let _ = std::fs::remove_file(&old); + std::fs::rename(exe, &old).with_context(|| format!("moving {} aside", exe.display()))?; + if let Err(e) = std::fs::rename(new, exe) { + let _ = std::fs::rename(&old, exe); + return Err(e).with_context(|| format!("replacing {}", exe.display())); + } + Ok(()) +} + +/// 跑一次新文件的 `--version`,报的得是要装的那一版。 +/// +/// 编得过但起不来的二进制(架构不对、glibc 太旧)在这里就露出来,而不是在 +/// 服务重启之后。 +fn runs_as(path: &Path, want: Version) -> Result<()> { + let out = run_fresh(std::process::Command::new(path).arg("--version")).with_context(|| { + format!( + "the downloaded twcore does not run on this machine ({})", + path.display() + ) + })?; + let text = String::from_utf8_lossy(&out.stdout); + let got = text + .split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()); + if !out.status.success() || got != Some(want) { + bail!( + "the downloaded twcore reports `{}` rather than {want}; nothing was changed", + text.trim() + ); + } + Ok(()) +} + +/// 跑一个刚写完的文件。 +/// +/// Linux 上,别的线程正好在 fork 的那一瞬间会连带拿着这个文件的写句柄, +/// exec 就报 `ETXTBSY`(文件正被写)—— 句柄随 exec 关掉,稍等再试就好。 +fn run_fresh(cmd: &mut std::process::Command) -> std::io::Result { + let mut tries = 0; + loop { + match cmd.output() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && tries < 10 => { + tries += 1; + std::thread::sleep(Duration::from_millis(50)); + } + r => return r, + } + } +} + +fn client() -> Result { + builder().build().context("creating the HTTP client") +} + +/// 走环境变量里的代理(`HTTPS_PROXY`):服务器上出网常常只有这一条路。 +fn builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .user_agent(concat!("twcore/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(300)) + // Release 的下载地址会跳到 GitHub 的存储域名,得跟;但不跟到 http 上去 + .redirect(reqwest::redirect::Policy::custom(|a| { + if a.url().scheme() == "http" + && a.previous().last().is_some_and(|u| u.scheme() == "https") + { + a.error("refused to follow a redirect from https to http") + } else if a.previous().len() >= 10 { + a.error("too many redirects") + } else { + a.follow() + } + })) +} + +async fn get(client: &reqwest::Client, url: &str) -> Result { + let r = client + .get(url) + .send() + .await + .with_context(|| format!("requesting {url}"))?; + if !r.status().is_success() { + bail!("{url} answered {}", r.status()); + } + Ok(r) +} + +/// 最新的一版,或者指定的那一版。 +pub async fn release( + client: &reqwest::Client, + api: &str, + want: Option, +) -> Result { + let url = match want { + Some(v) => format!("{api}/repos/{REPO}/releases/tags/v{v}"), + None => format!("{api}/repos/{REPO}/releases/latest"), + }; + let r = client + .get(&url) + .header("accept", "application/vnd.github+json") + .send() + .await + .with_context(|| format!("asking GitHub for the release ({url})"))?; + match r.status() { + s if s.is_success() => Ok(r.json().await.context("reading the release")?), + reqwest::StatusCode::NOT_FOUND => match want { + Some(v) => bail!("there is no release v{v}"), + None => bail!("there is no release yet"), + }, + s => bail!("GitHub answered {s} for {url}"), + } +} + +/// 做什么,由版本比较决定。 +#[derive(Debug, PartialEq, Eq)] +pub enum Plan { + /// 已经是它了 + Same, + /// 没指定版本,而这一版比最新的还新(自己编的、或者还没发出去的) + Ahead, + Install, +} + +pub fn plan(current: Version, target: Version, pinned: bool) -> Plan { + match current.cmp(&target) { + std::cmp::Ordering::Equal => Plan::Same, + // 指定了版本就照装,哪怕是降级 —— 服务器要和桌面应用对上版本,那一版 + // 可能正好比服务器上的旧 + std::cmp::Ordering::Greater if !pinned => Plan::Ahead, + _ => Plan::Install, + } +} + +/// 装好之后换了什么。给调用方决定要不要重启。 +#[derive(Debug)] +pub struct Installed { + pub to: Version, +} + +/// 一次升级,除了「说给人听」和「重启服务」之外的全部。拆出来是为了测试能 +/// 指一个假的 API 和一个假的可执行文件。 +pub async fn upgrade( + client: &reqwest::Client, + api: &str, + exe: &Path, + current: Version, + target: &str, + opts: &Opts, + run_check: bool, +) -> Result> { + let pinned = opts + .version + .as_deref() + .map(str::parse::) + .transpose()?; + let rel = release(client, api, pinned).await?; + let to = rel.version()?; + match plan(current, to, pinned.is_some()) { + Plan::Same => { + println!( + "twcore {current} is installed, and it is {}", + if pinned.is_some() { + "the version asked for" + } else { + "the latest release" + } + ); + return Ok(None); + } + Plan::Ahead => { + println!("twcore {current} is newer than the latest release, {to}; nothing to do"); + return Ok(None); + } + Plan::Install => {} + } + let asset = asset_name(target); + let (bin_url, sha_url) = rel.pick(&asset)?; + if opts.check { + let how = match pinned { + Some(v) => format!("twcore upgrade --version {v}"), + None => "twcore upgrade".to_string(), + }; + println!("twcore {current} is installed; {to} is available. To install it: {how}"); + return Ok(None); + } + check_writable(exe)?; + println!("downloading {asset} {to}"); + let sha = get(client, sha_url).await?.text().await?; + let bytes = get(client, bin_url).await?.bytes().await?; + verify(&bytes, &sha)?; + replace( + exe, + &bytes, + |p| if run_check { runs_as(p, to) } else { Ok(()) }, + )?; + println!("replaced {}: {current} → {to}", exe.display()); + Ok(Some(Installed { to })) +} + +/// systemd 在不在管这个服务。 +fn service_active() -> Option { + let st = std::process::Command::new("systemctl") + .args(["is-active", "--quiet", UNIT]) + .status() + .ok()?; + Some(st.success()) +} + +pub fn run(opts: Opts) -> Result<()> { + let exe = std::env::current_exe().context("finding this executable")?; + // 符号链接要落到真正的文件上:改名盖的是链接指向的那个 + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); + if in_app_bundle(&exe) { + bail!( + "{} is the copy inside the ThinkWatch Lite app, and the app updates it itself. \ + `twcore upgrade` is for a twcore installed on its own, such as on a server", + exe.display() + ); + } + let target = target_for(std::env::consts::OS, std::env::consts::ARCH).ok_or_else(|| { + anyhow!( + "no twcore is published for {}-{}", + std::env::consts::OS, + std::env::consts::ARCH + ) + })?; + let current: Version = env!("CARGO_PKG_VERSION").parse()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let done = rt.block_on(async { + let c = client()?; + upgrade(&c, API, &exe, current, target, &opts, true).await + })?; + let Some(done) = done else { + return Ok(()); + }; + // 重启是人的决定:换二进制不打断在途请求,重启会 + match ( + cfg!(target_os = "linux").then(service_active).flatten(), + opts.restart, + ) { + (Some(true), true) => { + let st = std::process::Command::new("systemctl") + .args(["restart", UNIT]) + .status() + .context("running systemctl restart")?; + if !st.success() { + bail!( + "systemctl restart {UNIT} failed; the new binary is in place, restart it by hand" + ); + } + println!("restarted {UNIT}.service; it now runs {}", done.to); + } + (Some(true), false) => { + println!("{UNIT}.service is still running the previous version. To switch:"); + println!(" sudo systemctl restart {UNIT}"); + } + (_, true) => println!( + "no running {UNIT}.service was found to restart; restart twcore wherever it runs" + ), + (_, false) => { + println!("a twcore that is running keeps the previous version until it is restarted") + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(s: &str) -> Version { + s.parse().unwrap() + } + + #[test] + fn versions_compare_as_numbers_not_text() { + assert!(v("0.10.0") > v("0.9.9")); + assert!(v("1.0.0") > v("0.99.99")); + assert_eq!(v("v0.47.0"), v("0.47.0")); + for bad in ["0.47", "0.47.0.1", "x.1.2", "0.47.0-rc1", ""] { + assert!(bad.parse::().is_err(), "{bad}"); + } + } + + #[test] + fn a_pinned_version_installs_even_when_it_is_older() { + // 服务器要和桌面应用对上版本,那一版可能比服务器上的旧 + assert_eq!(plan(v("0.48.0"), v("0.47.2"), true), Plan::Install); + assert_eq!(plan(v("0.48.0"), v("0.47.2"), false), Plan::Ahead); + assert_eq!(plan(v("0.47.2"), v("0.48.0"), false), Plan::Install); + assert_eq!(plan(v("0.48.0"), v("0.48.0"), true), Plan::Same); + } + + #[test] + fn every_published_platform_has_an_asset_and_others_have_none() { + assert_eq!( + target_for("linux", "x86_64").map(asset_name).as_deref(), + Some("twcore-x86_64-unknown-linux-gnu") + ); + assert_eq!( + target_for("linux", "aarch64").map(asset_name).as_deref(), + Some("twcore-aarch64-unknown-linux-gnu") + ); + assert_eq!( + target_for("macos", "aarch64").map(asset_name).as_deref(), + Some("twcore-aarch64-apple-darwin") + ); + assert_eq!( + target_for("windows", "x86_64").map(asset_name).as_deref(), + Some("twcore-x86_64-pc-windows-msvc.exe") + ); + // Intel Mac 没有构建:说没有,而不是装一个 arm64 的上去 + assert_eq!(target_for("macos", "x86_64"), None); + // 这台跑测试的机器本身得在表里 + assert!(target_for(std::env::consts::OS, std::env::consts::ARCH).is_some()); + } + + /// release.yml 发的文件名和这里拼的要一致 —— 两边各写一遍,对不上时 + /// `twcore upgrade` 找不到文件 + #[test] + fn the_release_workflow_publishes_what_upgrade_looks_for() { + let wf = include_str!("../../../.github/workflows/release.yml"); + for (os, arch) in [ + ("macos", "aarch64"), + ("linux", "x86_64"), + ("linux", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + let t = target_for(os, arch).unwrap(); + let name = asset_name(t); + let pattern = name.replace(t, "${{ matrix.target }}"); + assert!( + wf.contains(&format!("dist/{name}.sha256")) + || wf.contains(&format!("dist/{pattern}.sha256")), + "release.yml does not publish {name}.sha256" + ); + } + } + + fn release_with(names: &[&str]) -> Release { + Release { + tag_name: "v0.48.0".into(), + assets: names + .iter() + .map(|n| Asset { + name: n.to_string(), + browser_download_url: format!("https://example.com/{n}"), + }) + .collect(), + } + } + + #[test] + fn picking_needs_the_binary_and_its_checksum() { + let a = "twcore-x86_64-unknown-linux-gnu"; + let r = release_with(&[ + a, + "twcore-x86_64-unknown-linux-gnu.sha256", + "twcore-x86_64-unknown-linux-gnu.tar.gz", + ]); + let (b, s) = r.pick(a).unwrap(); + assert!(b.ends_with(a)); + assert!(s.ends_with(".sha256")); + let e = release_with(&[a]).pick(a).unwrap_err().to_string(); + assert!(e.contains("cannot be verified"), "{e}"); + let e = release_with(&[]).pick(a).unwrap_err().to_string(); + assert!(e.contains("no build"), "{e}"); + } + + #[test] + fn the_checksum_has_to_match() { + let body = b"twcore"; + let good: String = Sha256::digest(body) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + verify(body, &format!("{good} twcore-x86_64-unknown-linux-gnu\n")).unwrap(); + // 大写的十六进制也是同一个值 + verify(body, &good.to_uppercase()).unwrap(); + assert!(verify(b"twcorE", &good).is_err()); + assert!(verify(body, "not a checksum").is_err()); + assert!(verify(body, "").is_err()); + } + + #[test] + fn the_copy_inside_the_desktop_app_is_not_upgraded() { + for p in [ + "/Applications/ThinkWatch Lite.app/Contents/Resources/twcore", + "/tmp/.mount_ThinkWxyz/usr/lib/ThinkWatch Lite/twcore", + "/opt/ThinkWatch Lite/usr/lib/ThinkWatch Lite/twcore", + r"C:\Users\a\AppData\Local\ThinkWatch Lite\twcore.exe", + ] { + let p = PathBuf::from(p.replace('\\', std::path::MAIN_SEPARATOR_STR)); + assert!(in_app_bundle(&p), "{}", p.display()); + } + for p in ["/usr/local/bin/twcore", "/home/a/.local/bin/twcore"] { + assert!(!in_app_bundle(Path::new(p)), "{p}"); + } + } + + #[test] + fn replacing_is_all_or_nothing() { + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + + // 检查不过:旧的原样在,临时文件不留 + let e = replace(&exe, b"new", |_| bail!("does not run")).unwrap_err(); + assert!(e.to_string().contains("does not run")); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1); + + // 检查拿到的是写好的新文件,不是旧的 + replace(&exe, b"new", |p| { + assert_ne!(p, exe.as_path()); + assert_eq!(std::fs::read(p).unwrap(), b"new"); + Ok(()) + }) + .unwrap(); + assert_eq!(std::fs::read(&exe).unwrap(), b"new"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&exe).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755); + // 只剩它自己,没有临时文件 + assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1); + } + } + + /// 一个进程在跑着旧文件的时候换掉它:它照常跑完,路径上已经是新的。 + #[cfg(unix)] + #[test] + fn a_running_binary_can_be_replaced_under_it() { + use std::io::BufRead; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + // 脚本由 sh 打开之后才算「在跑」:等它先说一句,再去换 + write_executable(&exe, b"#!/bin/sh\necho started\nsleep 1\necho old\n").unwrap(); + let spawn = || { + std::process::Command::new(&exe) + .stdout(std::process::Stdio::piped()) + .spawn() + }; + let mut child = (0..10) + .find_map(|_| match spawn() { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(Duration::from_millis(50)); + None + } + r => Some(r.unwrap()), + }) + .unwrap(); + let mut lines = std::io::BufReader::new(child.stdout.take().unwrap()).lines(); + assert_eq!(lines.next().unwrap().unwrap(), "started"); + replace(&exe, b"#!/bin/sh\necho new\n", |_| Ok(())).unwrap(); + assert_eq!(lines.next().unwrap().unwrap(), "old"); + child.wait().unwrap(); + let now = run_fresh(&mut std::process::Command::new(&exe)).unwrap(); + assert_eq!(String::from_utf8_lossy(&now.stdout).trim(), "new"); + } + + // ── 对着一个假的 GitHub 跑一整遍 ───────────────────────── + + struct Fake { + base: String, + _task: tokio::task::JoinHandle<()>, + } + + /// 一个只认三条路径的 GitHub:最新 Release、按 tag 查的 Release、下载。 + async fn fake_github(tag: &'static str, binary: Vec, sha: String) -> Fake { + use axum::{Router, extract::State, routing::get}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let asset = asset_name(target_for(std::env::consts::OS, std::env::consts::ARCH).unwrap()); + let release = serde_json::json!({ + "tag_name": tag, + "assets": [ + { "name": asset, "browser_download_url": format!("{base}/dl/bin") }, + { "name": format!("{asset}.sha256"), "browser_download_url": format!("{base}/dl/sha") }, + ], + }); + #[derive(Clone)] + struct S { + release: serde_json::Value, + tag: &'static str, + binary: Vec, + sha: String, + } + let app = Router::new() + .route( + "/repos/ThinkWatchProject/ThinkWatch-Core/releases/latest", + get(|State(s): State| async move { axum::Json(s.release) }), + ) + .route( + "/repos/ThinkWatchProject/ThinkWatch-Core/releases/tags/{tag}", + get( + |State(s): State, axum::extract::Path(t): axum::extract::Path| async move { + if t == s.tag { + Ok(axum::Json(s.release)) + } else { + Err(axum::http::StatusCode::NOT_FOUND) + } + }, + ), + ) + .route("/dl/bin", get(|State(s): State| async move { s.binary })) + .route("/dl/sha", get(|State(s): State| async move { s.sha })) + .with_state(S { + release, + tag, + binary, + sha, + }); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Fake { base, _task: task } + } + + fn sha_of(b: &[u8]) -> String { + let h: String = Sha256::digest(b) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!("{h} twcore\n") + } + + fn opts(check: bool, version: Option<&str>) -> Opts { + Opts { + check, + restart: false, + version: version.map(str::to_string), + } + } + + fn target() -> &'static str { + target_for(std::env::consts::OS, std::env::consts::ARCH).unwrap() + } + + /// 假服务器在回环上,不能让开发机上的代理变量把请求带走 + fn client() -> reqwest::Client { + builder().no_proxy().build().unwrap() + } + + #[tokio::test] + async fn an_upgrade_downloads_verifies_and_replaces() { + // 新的「二进制」是一段脚本,`--version` 报 0.48.0 —— 于是「跑一次看它报 + // 什么」这一步也是真跑的 + let new = b"#!/bin/sh\necho twcore 0.48.0\n".to_vec(); + let fake = fake_github("v0.48.0", new.clone(), sha_of(&new)).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + let c = client(); + + // 只看不动 + let r = upgrade( + &c, + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(true, None), + cfg!(unix), + ) + .await + .unwrap(); + assert!(r.is_none()); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + + let r = upgrade( + &c, + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, None), + cfg!(unix), + ) + .await + .unwrap() + .expect("it installs"); + assert_eq!(r.to, v("0.48.0")); + assert_eq!(std::fs::read(&exe).unwrap(), new); + + // 已经是最新的:什么都不做 + let r = upgrade( + &c, + &fake.base, + &exe, + v("0.48.0"), + target(), + &opts(false, None), + cfg!(unix), + ) + .await + .unwrap(); + assert!(r.is_none()); + } + + #[tokio::test] + async fn a_download_that_does_not_match_its_checksum_changes_nothing() { + let fake = fake_github("v0.48.0", b"tampered".to_vec(), sha_of(b"original")).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + let e = upgrade( + &client(), + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, None), + false, + ) + .await + .unwrap_err() + .to_string(); + assert!(e.contains("SHA-256"), "{e}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 1); + } + + /// 下载下来的东西报的不是要装的那一版(发错了文件):不装。 + #[cfg(unix)] + #[tokio::test] + async fn a_binary_that_reports_another_version_is_not_installed() { + let wrong = b"#!/bin/sh\necho twcore 0.46.0\n".to_vec(); + let fake = fake_github("v0.48.0", wrong.clone(), sha_of(&wrong)).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + std::fs::write(&exe, b"old").unwrap(); + let e = upgrade( + &client(), + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, None), + true, + ) + .await + .unwrap_err() + .to_string(); + assert!(e.contains("0.46.0"), "{e}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old"); + } + + #[tokio::test] + async fn a_pinned_version_that_does_not_exist_says_so() { + let fake = fake_github("v0.48.0", Vec::new(), String::new()).await; + let d = tempfile::tempdir().unwrap(); + let exe = d.path().join("twcore"); + let e = upgrade( + &client(), + &fake.base, + &exe, + v("0.47.0"), + target(), + &opts(false, Some("0.9.9")), + false, + ) + .await + .unwrap_err() + .to_string(); + assert!(e.contains("no release v0.9.9"), "{e}"); + } +} diff --git a/crates/tw-config/tests/manual.rs b/crates/tw-config/tests/manual.rs new file mode 100644 index 0000000..649208f --- /dev/null +++ b/crates/tw-config/tests/manual.rs @@ -0,0 +1,649 @@ +//! 配置手册里的字段表是从这里生成的,手册和代码对不上就失败。 +//! +//! 手册是 `docs/config.md`(英文)和 `docs/config.zh-CN.md`(中文)。正文是人写的, +//! **字段表不是**:两个文件里每一处 +//! +//! ```text +//! +//! … +//! +//! ``` +//! +//! 之间的内容由 [`schema`] 里的声明渲染出来。声明和代码之间有三道核对: +//! +//! - **字段一个不多一个不少**:每一节的字段名问 serde 要([`fields`]),和声明的逐个比; +//! 代码里加了字段、手册没写,这里就挂,并且说出是哪一个。 +//! - **默认值是真的**:声明写「默认 8788」,就拿一份不写这个字段的和一份写了 8788 的 +//! 各解析一遍,两者得完全一样;「必填」的字段,删掉它就得解析失败。 +//! - **取值是生成的**:枚举的可选值问 serde 要,不在声明里抄。 +//! +//! 还没进代码的字段(远程控制那几项)声明成 [`Ty::Pending`]:照样出现在手册里, +//! 并且**一旦代码里有了这个字段,这里就挂**,逼着把它换成真正的核对。 +//! +//! 手册过期了:`UPDATE_CONFIG_DOCS=1 cargo test -p tw-config --test manual` 重写 +//! 两个文件里的生成段,正文不动。 + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde::de::{self, DeserializeOwned, Visitor}; + +const UPDATE: &str = "UPDATE_CONFIG_DOCS"; + +// ── 问 serde 要字段名 ───────────────────────────────────────── + +/// 一个什么都不给的反序列化器:derive 出来的 `Deserialize` 一开口就报出自己的 +/// 字段名(`deserialize_struct` 的 `fields`)或变体名(`deserialize_enum` 的 +/// `variants`),它记下来就停。**名字是 serde 真正认的那个**,改名、 +/// `rename_all` 都已经算进去了。 +struct Probe; + +#[derive(Debug)] +struct Found(Vec<&'static str>); + +impl std::fmt::Display for Found { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self.0) + } +} +impl std::error::Error for Found {} +impl de::Error for Found { + fn custom(_: T) -> Self { + Found(Vec::new()) + } +} + +impl<'de> de::Deserializer<'de> for Probe { + type Error = Found; + fn deserialize_any>(self, _: V) -> Result { + Err(Found(Vec::new())) + } + fn deserialize_struct>( + self, + _: &'static str, + fields: &'static [&'static str], + _: V, + ) -> Result { + Err(Found(fields.to_vec())) + } + fn deserialize_enum>( + self, + _: &'static str, + variants: &'static [&'static str], + _: V, + ) -> Result { + Err(Found(variants.to_vec())) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string + bytes byte_buf option unit unit_struct newtype_struct seq tuple + tuple_struct map identifier ignored_any + } +} + +/// 一个 derive 出来的结构体的字段名,或者一个枚举的变体名。 +pub fn fields() -> Vec<&'static str> { + match T::deserialize(Probe) { + Err(Found(v)) if !v.is_empty() => v, + _ => panic!( + "{} does not derive Deserialize as a struct or an enum, so its fields cannot be listed", + std::any::type_name::() + ), + } +} + +/// 写了这个值和不写,解析出来是不是同一个东西。`minimal` 是这一节最少要写的那几个字段。 +pub fn same_as_omitted( + minimal: &str, + field: &str, + value: &str, +) -> Result { + let base: serde_yaml_ng::Value = serde_yaml_ng::from_str(minimal).map_err(|e| e.to_string())?; + let mut with = base.clone(); + let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(value).map_err(|e| e.to_string())?; + with.as_mapping_mut() + .ok_or("the minimal form is not a mapping")? + .insert(field.into(), v); + let a: T = serde_yaml_ng::from_value(base).map_err(|e| format!("without it: {e}"))?; + let b: T = serde_yaml_ng::from_value(with).map_err(|e| format!("with it: {e}"))?; + let a = serde_yaml_ng::to_value(a).map_err(|e| e.to_string())?; + let b = serde_yaml_ng::to_value(b).map_err(|e| e.to_string())?; + Ok(a == b) +} + +/// 去掉这个字段还解析得了吗。 +pub fn parses_without(minimal: &str, field: &str) -> Result { + let mut base: serde_yaml_ng::Value = + serde_yaml_ng::from_str(minimal).map_err(|e| e.to_string())?; + base.as_mapping_mut() + .ok_or("the minimal form is not a mapping")? + .remove(field); + Ok(serde_yaml_ng::from_value::(base).is_ok()) +} + +// ── 声明的形状 ──────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Lang { + En, + Zh, +} + +/// 一句话的两种语言。 +#[derive(Clone, Copy)] +pub struct T2 { + pub en: &'static str, + pub zh: &'static str, +} + +impl T2 { + fn get(&self, l: Lang) -> &'static str { + match l { + Lang::En => self.en, + Lang::Zh => self.zh, + } + } +} + +/// 字段的类型。**写成节名的那几种要有对应的一节**,测试核对。 +#[derive(Clone, Copy)] +pub enum Kind { + Str, + Int, + Num, + Bool, + Strs, + /// 可以带 `${VAR}` 的字符串 + Secret, + /// `loopback` / `all` / 网卡名 / 地址 + Bind, + /// 一个字符串或一组字符串 + OneOrMany, + /// `30s` / `5m` / `1h` + Duration, + /// 比较式:`">200k"` + Compare, + /// 请求头名 → 值 + Headers, + /// 可选值由枚举生成 + Enum(fn() -> Vec<&'static str>), + /// 键 → 枚举值 + EnumMap(T2, fn() -> Vec<&'static str>), + /// 一个对象,见那一节 + Obj(&'static str), + /// 一组对象,见那一节 + Objs(&'static str), + /// 键 → 对象,见那一节 + ObjMap(T2, &'static str), +} + +#[derive(Clone, Copy)] +pub enum Def { + /// 不写就解析失败 + Required, + /// 可选,不写就没有;意思在说明里 + Unset, + /// 一个 YAML 值。**核对过**:写它和不写解析出来一样 + Is(&'static str), + /// 核对不了的默认值(生成的、在别处算的),只能照说 + Said(T2), + /// 一个对象:默认值见那一节 + Section, +} + +pub struct Row { + pub name: &'static str, + pub kind: Kind, + pub def: Def, + pub doc: T2, +} + +/// 这一节对着哪个 Rust 类型核对。 +pub enum Ty { + Code { + fields: fn() -> Vec<&'static str>, + same: fn(&str, &str, &str) -> Result, + parses_without: fn(&str, &str) -> Result, + /// 这一节最少要写的字段,YAML 映射 + minimal: &'static str, + }, + /// 还没进代码。**进了代码这里就挂**:`probe` 是一份把这一节写成 `{}` 的配置, + /// 现在它必须因为「不认识这个字段」被拒 + Pending { probe: &'static str }, +} + +pub struct Section { + /// `listen.gateway`、`providers[]`、`pricing.sheets[].models.*` + pub path: &'static str, + pub ty: Ty, + pub rows: Vec, +} + +/// 核对一节要的那几个函数,按类型生成。 +macro_rules! checked { + ($t:ty, $minimal:expr) => { + $crate::Ty::Code { + fields: $crate::fields::<$t>, + same: $crate::same_as_omitted::<$t>, + parses_without: $crate::parses_without::<$t>, + minimal: $minimal, + } + }; +} + +// 声明在另一个文件里。放在宏后面,宏才看得见 +#[path = "manual/schema.rs"] +mod schema; + +// ── 渲染 ────────────────────────────────────────────────────── + +/// 节名在手册里的锚点。 +pub fn anchor(path: &str) -> String { + let mut out = String::from("cfg-"); + let mut dash = false; + for c in path.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + out.push(c); + dash = false; + } else if !dash { + out.push('-'); + dash = true; + } + } + out.trim_end_matches('-').to_string() +} + +fn values(v: &[&str]) -> String { + v.iter() + .map(|s| format!("`{s}`")) + .collect::>() + .join(" \\| ") +} + +fn link(path: &str) -> String { + format!("[`{path}`](#{})", anchor(path)) +} + +fn kind(k: &Kind, l: Lang) -> String { + let zh = l == Lang::Zh; + let pick = |en: &str, z: &str| if zh { z.to_string() } else { en.to_string() }; + match k { + Kind::Str => pick("string", "字符串"), + Kind::Int => pick("integer", "整数"), + Kind::Num => pick("number", "数字"), + Kind::Bool => pick("bool", "布尔"), + Kind::Strs => pick("list of strings", "字符串列表"), + Kind::Secret => pick("string, `${VAR}` allowed", "字符串,可写 `${VAR}`"), + Kind::Bind => pick( + "`loopback` \\| `all` \\| interface name \\| IP address", + "`loopback` \\| `all` \\| 网卡名 \\| IP 地址", + ), + Kind::OneOrMany => pick("string or list of strings", "字符串或字符串列表"), + Kind::Duration => pick("duration (`30s`, `5m`, `1h`)", "时长(`30s`、`5m`、`1h`)"), + Kind::Compare => pick( + "comparison (`>200k`, `<=4k`, `==3`)", + "比较式(`>200k`、`<=4k`、`==3`)", + ), + Kind::Headers => pick("map of header name → value", "请求头名 → 值的映射"), + Kind::Enum(f) => values(&f()), + Kind::EnumMap(key, f) => format!( + "{} {} → {}", + pick("map of", "映射:"), + key.get(l), + values(&f()) + ), + Kind::Obj(p) => format!("{} {}", pick("object,", "对象,见"), link(p)), + Kind::Objs(p) => format!("{} {}", pick("list of", "对象列表,见"), link(p)), + Kind::ObjMap(key, p) => { + format!("{} {} → {}", pick("map of", "映射:"), key.get(l), link(p)) + } + } +} + +fn default(d: &Def, l: Lang) -> String { + match d { + Def::Required => match l { + Lang::En => "**required**".into(), + Lang::Zh => "**必填**".into(), + }, + Def::Unset | Def::Section => "—".into(), + Def::Is(v) => format!("`{v}`"), + Def::Said(t) => t.get(l).into(), + } +} + +/// 表格里的一格:竖线要转义,换行不能有。 +fn cell(s: &str) -> String { + s.replace('\n', " ") +} + +pub fn render_table(s: &Section, l: Lang) -> String { + let mut out = format!("\n\n", anchor(s.path)); + out += match l { + Lang::En => "| Field | Type | Default | Description |\n", + Lang::Zh => "| 字段 | 类型 | 默认值 | 说明 |\n", + }; + out += "|---|---|---|---|\n"; + for r in &s.rows { + out += &format!( + "| `{}` | {} | {} | {} |\n", + r.name, + cell(&kind(&r.kind, l)), + cell(&default(&r.def, l)), + cell(r.doc.get(l)), + ); + } + out +} + +// ── 核对 ────────────────────────────────────────────────────── + +fn check_section(s: &Section, all: &[Section], errs: &mut Vec) { + for r in &s.rows { + match r.kind { + Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) => { + if !all.iter().any(|x| x.path == p) { + errs.push(format!( + "{}.{} points at section `{p}`, which is not declared", + s.path, r.name + )); + } + if !matches!(r.def, Def::Section | Def::Is(_) | Def::Unset) { + errs.push(format!( + "{}.{} is an object; its default is the section's own", + s.path, r.name + )); + } + } + _ => { + if matches!(r.def, Def::Section) { + errs.push(format!("{}.{} is not an object", s.path, r.name)); + } + } + } + } + match &s.ty { + Ty::Code { + fields, + same, + parses_without, + minimal, + } => { + let code: BTreeSet<&str> = fields().into_iter().collect(); + // 指向一节还没进代码的对象的那一行,同样还没进代码:它由那一节的 + // `Ty::Pending` 看着,这里不数它 + let pending = |r: &Row| match r.kind { + Kind::Obj(p) | Kind::Objs(p) | Kind::ObjMap(_, p) => all + .iter() + .any(|x| x.path == p && matches!(x.ty, Ty::Pending { .. })), + _ => false, + }; + let declared: BTreeSet<&str> = s + .rows + .iter() + .filter(|r| !pending(r)) + .map(|r| r.name) + .collect(); + for f in code.difference(&declared) { + errs.push(format!( + "`{}.{f}` is in the code but not in the manual: add a row for it in \ + crates/tw-config/tests/manual/schema.rs, then regenerate with {UPDATE}=1", + s.path + )); + } + for f in declared.difference(&code) { + errs.push(format!( + "`{}.{f}` is in the manual but not in the code: remove its row from \ + crates/tw-config/tests/manual/schema.rs", + s.path + )); + } + for r in &s.rows { + if !code.contains(r.name) { + continue; + } + match r.def { + Def::Is(v) => match same(minimal, r.name, v) { + Ok(true) => {} + Ok(false) => errs.push(format!( + "`{}.{}`: the manual says the default is `{v}`, and writing that \ + changes what the configuration means, so it is not the default", + s.path, r.name + )), + Err(e) => errs.push(format!( + "`{}.{}`: the default `{v}` does not parse: {e}", + s.path, r.name + )), + }, + Def::Required => match parses_without(minimal, r.name) { + Ok(false) => {} + Ok(true) => errs.push(format!( + "`{}.{}` is marked required, and the section parses without it", + s.path, r.name + )), + Err(e) => errs.push(format!("`{}`: {e}", s.path)), + }, + _ => {} + } + } + // 最少的写法本身要能解析,而且只写了必填的 + let required: BTreeSet<&str> = s + .rows + .iter() + .filter(|r| matches!(r.def, Def::Required)) + .map(|r| r.name) + .collect(); + match serde_yaml_ng::from_str::(minimal) { + Ok(m) => { + let written: BTreeSet<&str> = m.keys().filter_map(|k| k.as_str()).collect(); + if written != required { + errs.push(format!( + "`{}`: the minimal form writes {written:?}, and the required fields \ + are {required:?}", + s.path + )); + } + } + Err(e) => errs.push(format!("`{}`: the minimal form: {e}", s.path)), + } + } + Ty::Pending { probe } => { + let segs: Vec<&str> = s + .path + .split('.') + .map(|p| p.trim_end_matches("[]")) + .collect(); + match serde_yaml_ng::from_str::(probe) { + Err(e) + if segs + .iter() + .any(|seg| e.to_string().contains(&format!("unknown field `{seg}`"))) => {} + other => errs.push(format!( + "`{}` is declared as not yet in the code, and the code now reads it ({}). \ + Replace `Ty::Pending` with checked!(TheType, minimal) in \ + crates/tw-config/tests/manual/schema.rs so the manual is checked against it", + s.path, + match other { + Ok(_) => "the probe parses".to_string(), + Err(e) => e.to_string(), + } + )), + } + } + } +} + +#[test] +fn every_section_matches_the_code() { + let all = schema::sections(); + let mut errs = Vec::new(); + let mut seen = BTreeSet::new(); + for s in &all { + if !seen.insert(s.path) { + errs.push(format!("section `{}` is declared twice", s.path)); + } + check_section(s, &all, &mut errs); + } + assert!(errs.is_empty(), "\n{}\n", errs.join("\n")); +} + +/// 拿 [`Probe`] 问一个手写 `Deserialize` 的类型会得到什么 —— 它说不出字段, +/// 所以 [`fields`] 必须报错而不是返回空。 +#[test] +fn the_probe_refuses_types_it_cannot_list() { + let r = std::panic::catch_unwind(fields::); + assert!(r.is_err()); + assert_eq!( + fields::(), + ["socks5", "socks5h", "http", "https"] + ); +} + +// ── 手册文件 ────────────────────────────────────────────────── + +fn workspace() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +const OPEN: &str = ""; + +/// 一个生成段该是什么。`what` 是标记里写的那句:`table listen.gateway`、`rules redact` +fn generate(what: &str, l: Lang, all: &[Section]) -> Result { + if let Some(path) = what.strip_prefix("table ") { + let s = all + .iter() + .find(|s| s.path == path) + .ok_or_else(|| format!("no section `{path}` is declared"))?; + return Ok(render_table(s, l)); + } + if let Some(kind) = what.strip_prefix("rules ") { + return schema::rules(kind, l).ok_or_else(|| format!("no rule list `{kind}`")); + } + Err(format!("`{what}` is not something that can be generated")) +} + +/// 把一份手册里的生成段全部换成该有的样子,顺便记下写了哪些。 +fn regenerate(text: &str, l: Lang, all: &[Section]) -> Result<(String, Vec), String> { + let mut out = String::new(); + let mut rest = text; + let mut used = Vec::new(); + while let Some(i) = rest.find(OPEN) { + let after = &rest[i + OPEN.len()..]; + let end = after + .find("-->") + .ok_or("a generated marker is not closed")?; + let what = after[..end].trim().to_string(); + let body_start = i + OPEN.len() + end + 3; + let close = rest[body_start..] + .find(CLOSE) + .ok_or_else(|| format!("`{what}` has no {CLOSE}"))?; + out += &rest[..body_start]; + out += "\n"; + out += &generate(&what, l, all)?; + out += CLOSE; + rest = &rest[body_start + close + CLOSE.len()..]; + used.push(what); + } + out += rest; + Ok((out, used)) +} + +#[test] +fn the_manual_is_what_the_code_says() { + let all = schema::sections(); + let update = std::env::var_os(UPDATE).is_some(); + let mut errs = Vec::new(); + for (file, l) in [ + ("docs/config.md", Lang::En), + ("docs/config.zh-CN.md", Lang::Zh), + ] { + let path = workspace().join(file); + // Windows 上的检出可能把换行转成了 CRLF,比的是内容不是换行 + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{file}: {e}")) + .replace("\r\n", "\n"); + let (next, used) = match regenerate(&text, l, &all) { + Ok(x) => x, + Err(e) => { + errs.push(format!("{file}: {e}")); + continue; + } + }; + // 每一节都得在手册里出现,而且只出现一次 + for s in &all { + let n = used + .iter() + .filter(|u| **u == format!("table {}", s.path)) + .count(); + if n != 1 { + errs.push(format!( + "{file}: `table {}` appears {n} times; it has to appear once", + s.path + )); + } + } + for kind in schema::RULE_LISTS { + if !used.iter().any(|u| *u == format!("rules {kind}")) { + errs.push(format!("{file}: `rules {kind}` is missing")); + } + } + if next != text { + if update { + std::fs::write(&path, next).unwrap(); + } else { + errs.push(format!( + "{file} is out of date with the code. Regenerate it:\n \ + {UPDATE}=1 cargo test -p tw-config --test manual" + )); + } + } + } + assert!(errs.is_empty(), "\n{}\n", errs.join("\n")); +} + +/// 手册里的 YAML 示例读得进来。**只查写法,不查引用** —— 示例里的 +/// `pricing: relay-discount` 指向的价目表写在另一段示例里。 +/// +/// 从第一列开始写的示例才是整份配置的一段;缩进开头的是某一项的片段, +/// 带 `…` 的是占位,都跳过。还没进代码的那几节同样跳过,[`Ty::Pending`] 管它们。 +#[test] +fn the_examples_in_the_manual_parse() { + let all = schema::sections(); + let pending: Vec<&str> = all + .iter() + .filter(|s| matches!(s.ty, Ty::Pending { .. })) + .map(|s| s.path.rsplit('.').next().unwrap_or(s.path)) + .collect(); + let mut errs = Vec::new(); + for file in [ + "docs/config.md", + "docs/config.zh-CN.md", + "docs/server.md", + "docs/server.zh-CN.md", + ] { + let text = std::fs::read_to_string(workspace().join(file)) + .unwrap() + .replace("\r\n", "\n"); + let mut rest = text.as_str(); + while let Some(i) = rest.find("```yaml\n") { + let body = &rest[i + 8..]; + let end = body.find("```").expect("an unclosed code block"); + let block = &body[..end]; + rest = &body[end + 3..]; + let skip = block.starts_with(' ') + || block.contains('…') + || pending.iter().any(|p| block.contains(&format!("{p}:"))); + if skip { + continue; + } + if let Err(e) = + serde_yaml_ng::from_str::(&format!("version: 1\n{block}")) + { + errs.push(format!("{file}: an example does not parse: {e}\n{block}")); + } + } + } + assert!(errs.is_empty(), "\n{}\n", errs.join("\n")); +} diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs new file mode 100644 index 0000000..e106db6 --- /dev/null +++ b/crates/tw-config/tests/manual/schema.rs @@ -0,0 +1,1440 @@ +//! config.yaml 的每一节、每一个字段,手册里那张表就是照这里渲染的。 +//! +//! **改了 tw-config(或者它引用的 tw-engine、tw-pricing)的配置类型,就改这里**: +//! 加一行、删一行、改默认值。`manual.rs` 会拿这份声明和代码逐项对,对不上时 +//! 说清楚是哪个字段、该怎么改。改完用 `UPDATE_CONFIG_DOCS=1` 重新生成手册。 +//! +//! 说明写给手写配置文件的人:这个字段管什么、不写是什么意思、写错了会怎样。 +//! 两种语言各写一遍,**不是互译的字面对照**,各自按各自的习惯说。 + +use super::{Def, Kind, Lang, Row, Section, T2, Ty}; +use tw_config::proxy::ProxyAuth; +use tw_config::*; +use tw_engine::rule::When; +use tw_engine::{Group, GroupType, RouteSet, Rule, SetAction}; +use tw_pricing::{PerMillion, PricingConfig, SheetDef}; + +const fn t(en: &'static str, zh: &'static str) -> T2 { + T2 { en, zh } +} + +const fn row(name: &'static str, kind: Kind, def: Def, doc: T2) -> Row { + Row { + name, + kind, + def, + doc, + } +} + +// 枚举的取值问 serde 要。函数指针要一个具体的函数,所以一个类型一个 +fn protocols() -> Vec<&'static str> { + super::fields::() +} +fn billings() -> Vec<&'static str> { + super::fields::() +} +fn proxy_kinds() -> Vec<&'static str> { + super::fields::() +} +fn on_proxy_fail() -> Vec<&'static str> { + super::fields::() +} +fn probe_actions() -> Vec<&'static str> { + super::fields::() +} +fn modes() -> Vec<&'static str> { + super::fields::() +} +fn tool_actions() -> Vec<&'static str> { + super::fields::() +} +fn content_actions() -> Vec<&'static str> { + super::fields::() +} +fn content_matches() -> Vec<&'static str> { + super::fields::() +} +fn group_types() -> Vec<&'static str> { + super::fields::() +} + +const RULE_ID: T2 = t("built-in rule id", "内置规则 id"); +const MODE_DOC: T2 = t( + "`off` does nothing; `observe` detects and records only, and changes nothing; `enforce` \ + detects and acts.", + "`off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。", +); +const ENABLE_DOC: T2 = t( + "Built-in rules to switch on that are off out of the box, by id.", + "打开出厂时关着的内置规则,按 id。", +); +const DISABLE_DOC: T2 = t( + "Built-in rules to switch off, by id.", + "关掉内置规则,按 id。", +); +const RULE_NAME: T2 = t( + "Name shown in logs and in the app; it identifies the rule and has to be unique within this \ + guard.", + "日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。", +); +const RULE_DISABLED: T2 = t( + "Switches the rule off and keeps it in the file.", + "停用这条规则,规则本身留在文件里。", +); + +/// 手册里要有的内置规则清单,``。 +pub const RULE_LISTS: &[&str] = &["redact", "inspect_tools", "hidden_text", "content"]; + +pub fn sections() -> Vec
{ + vec![ + // ── 顶层 ────────────────────────────────────────────── + Section { + path: "config", + ty: checked!(Config, "version: 1"), + rows: vec![ + row( + "version", + Kind::Int, + Def::Required, + t( + "Format version of this file. The only version is `1`. A file with a higher number was written by a newer twcore and is refused rather than half-understood.", + "文件格式的版本,目前只有 `1`。更大的数字说明文件出自更新的 twcore,整份拒绝,不按一知半解的方式读。", + ), + ), + row( + "listen", + Kind::Obj("listen"), + Def::Section, + t( + "Where the gateway and the control channel listen.", + "网关和控制通道在哪里监听。", + ), + ), + row( + "clients", + Kind::Objs("clients[]"), + Def::Is("[]"), + t( + "Gateway keys. At least one is required; `twcore init` and the first `twcore serve` write one named `default`.", + "网关密钥。至少要有一把;`twcore init` 和首次 `twcore serve` 会写入一把名为 `default` 的。", + ), + ), + row( + "providers", + Kind::Objs("providers[]"), + Def::Is("[]"), + t( + "Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured.", + "上游。一个都没有也是合法配置:控制面照常运行,请求得到「尚未配置上游」的错误。", + ), + ), + row( + "proxies", + Kind::Objs("proxies[]"), + Def::Is("[]"), + t( + "Outbound proxies, declared once and referred to by name from `providers[].proxy`.", + "出站代理。在这里声明一次,由 `providers[].proxy` 按名字引用。", + ), + ), + row( + "pricing", + Kind::Obj("pricing"), + Def::Section, + t( + "Refreshing the default price table, and price sheets of your own.", + "默认价目表是否定期刷新,以及自定义价目表。", + ), + ), + row( + "client_probes", + Kind::Obj("client_probes"), + Def::Section, + t( + "What happens to the helper requests clients send on their own (health checks, warm-ups, titles).", + "客户端自行发出的辅助请求(连通性检查、预热、起标题)如何处理。", + ), + ), + row( + "security", + Kind::Obj("security"), + Def::Section, + t( + "The five guards. All of them start in `observe` or `off`, so out of the box nothing is changed or blocked.", + "五项防护。出厂时都处在 `observe` 或 `off`,不改变、不拦截任何请求。", + ), + ), + row( + "retention", + Kind::Obj("retention"), + Def::Section, + t("How long request logs are kept.", "请求日志保留多久。"), + ), + row( + "groups", + Kind::Objs("groups[]"), + Def::Is("[]"), + t( + "Strategy groups: several upstreams behind one name, with a way to pick among them.", + "策略组:多个上游合用一个名字,并规定如何在其中选择。", + ), + ), + row( + "routes", + Kind::Objs("routes[]"), + Def::Is("[]"), + t( + "Routes. Without any, requests fail over across all upstreams in the order they are declared.", + "路由。一条都不写时,请求按上游的声明顺序故障转移。", + ), + ), + row( + "default_route", + Kind::Str, + Def::Unset, + t( + "The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none.", + "未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。", + ), + ), + row( + "default_key", + Kind::Str, + Def::Unset, + t( + "The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled.", + "没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。", + ), + ), + ], + }, + // ── listen ──────────────────────────────────────────── + Section { + path: "listen", + ty: checked!(Listen, "{}"), + rows: vec![ + row( + "gateway", + Kind::Obj("listen.gateway"), + Def::Section, + t( + "The AI gateway: the address clients send requests to.", + "AI 网关,即客户端发送请求的地址。", + ), + ), + row( + "control", + Kind::Obj("listen.control"), + Def::Section, + t( + "The control channel: how the desktop app and `twcore` commands reach core. It holds the control key, so every configuration has it.", + "控制通道,即桌面应用和 `twcore` 命令连接 core 的途径。其中有控制密钥,因此每份配置都有这一节。", + ), + ), + ], + }, + Section { + path: "listen.gateway", + ty: checked!(GatewayListen, "{}"), + rows: vec![ + row( + "bind", + Kind::Bind, + Def::Is("loopback"), + t( + "`loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up at start and follows address changes; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1.", + "`loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)在启动时解析,地址变了也能跟上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。", + ), + ), + row( + "port", + Kind::Int, + Def::Is("8788"), + t("TCP port of the gateway.", "网关的 TCP 端口。"), + ), + row( + "allow_from", + Kind::Strs, + Def::Is("[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]"), + t( + "Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. `[]` means this machine only; `0.0.0.0/0` allows everyone and has to be written out.", + "本机以外允许连接的来源,写 CIDR 网段或单个地址。本机始终放行。`[]` 表示只有本机;放行所有来源要明确写 `0.0.0.0/0`。", + ), + ), + ], + }, + Section { + path: "listen.control", + ty: checked!(ControlListen, "{}"), + rows: vec![ + row( + "key", + Kind::Str, + Def::Said(t("generated", "自动生成")), + t( + "The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. `twcore serve` writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with `twcore control-key`, replace it with `twcore control-key --rotate`.", + "控制密钥:64 个十六进制字符(32 字节)。所有控制连接,无论本地还是远程,都要证明持有这把密钥。缺失时 `twcore serve` 在开始监听前写入一把;格式不对的配置整份拒绝。用 `twcore control-key` 查看,`twcore control-key --rotate` 更换。", + ), + ), + row( + "remote", + Kind::Obj("listen.control.remote"), + Def::Section, + t( + "A network port for the desktop app on another machine. Additional to the local channel, never instead of it.", + "供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。", + ), + ), + ], + }, + Section { + path: "listen.control.remote", + ty: Ty::Pending { + probe: "version: 1\nlisten:\n control:\n remote: {}\n", + }, + rows: vec![ + row( + "enabled", + Kind::Bool, + Def::Is("false"), + t( + "Listen on the remote port. Unset or `false`: no network port is opened for control.", + "是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。", + ), + ), + row( + "bind", + Kind::Bind, + Def::Is("all"), + t( + "Interface to listen on, written as for `listen.gateway.bind`.", + "监听哪张网卡,写法同 `listen.gateway.bind`。", + ), + ), + row( + "port", + Kind::Int, + Def::Said(t("generated", "自动生成")), + t( + "TCP port. There is no fixed default: a random free port is written when the section is generated, like the key.", + "TCP 端口。没有固定默认值:和密钥一样,生成这一节时写入一个随机端口。", + ), + ), + row( + "allow_from", + Kind::Strs, + Def::Is("[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]"), + t( + "Sources that may connect, as for `listen.gateway.allow_from`. A connection from anywhere else is closed before the handshake, without a byte in reply.", + "允许连接的来源,写法同 `listen.gateway.allow_from`。其他来源的连接在握手之前关闭,不回任何字节。", + ), + ), + ], + }, + // ── clients ─────────────────────────────────────────── + Section { + path: "clients[]", + ty: checked!(Client, "{name: a, key: k}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the key; unique. Routing rules match it with `when.client`.", + "密钥的名字,不能重复。路由规则用 `when.client` 匹配它。", + ), + ), + row( + "key", + Kind::Str, + Def::Required, + t( + "The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique.", + "客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。", + ), + ), + row( + "max_concurrent", + Kind::Int, + Def::Unset, + t( + "Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused.", + "用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。", + ), + ), + row( + "allow", + Kind::Strs, + Def::Unset, + t( + "Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all.", + "这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。", + ), + ), + row( + "route", + Kind::Str, + Def::Unset, + t( + "Name of the route requests with this key take. Unset: `default_route`.", + "这把密钥的请求走哪条路由。不写:`default_route`。", + ), + ), + row( + "client", + Kind::Str, + Def::Unset, + t( + "The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one.", + "这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。", + ), + ), + row( + "disabled", + Kind::Bool, + Def::Is("false"), + t( + "Refuse every request made with this key, and keep the key.", + "拒绝使用这把密钥的所有请求,密钥本身保留。", + ), + ), + ], + }, + // ── providers ───────────────────────────────────────── + Section { + path: "providers[]", + ty: checked!(Provider, "{name: p, base_url: 'https://api.example.com'}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved.", + "上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。", + ), + ), + row( + "base_url", + Kind::Str, + Def::Required, + t( + "Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`).", + "接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。", + ), + ), + row( + "key", + Kind::Secret, + Def::Unset, + t( + "API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth`.", + "API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth` 同时写。", + ), + ), + row( + "headers", + Kind::Headers, + Def::Is("{}"), + t( + "Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set.", + "额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。", + ), + ), + row( + "oauth", + Kind::Obj("providers[].oauth"), + Def::Unset, + t( + "OAuth credential: an access token obtained from a refresh token. Instead of `key`.", + "OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。", + ), + ), + row( + "protocol", + Kind::Enum(protocols), + Def::Unset, + t( + "API format of the upstream. Unset: recognized from `base_url` for the official endpoints, otherwise treated as `anthropic`.", + "上游的接口格式。不写:官方地址按 `base_url` 识别,其余按 `anthropic` 处理。", + ), + ), + row( + "proxy", + Kind::Str, + Def::Is("direct"), + t( + "`direct`; `system`, the proxy in the core process's `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` environment variables; or the name of an entry in `proxies`.", + "`direct`;`system`,即 core 进程环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 中的代理;或 `proxies` 中某一项的名字。", + ), + ), + row( + "on_proxy_fail", + Kind::Enum(on_proxy_fail), + Def::Is("fail"), + t( + "When the proxy cannot be reached: `fail` the request, or go `direct`.", + "代理不可用时:请求失败(`fail`),或改为直连(`direct`)。", + ), + ), + row( + "models", + Kind::Strs, + Def::Is("[]"), + t( + "Models to assume when the upstream does not answer `/v1/models`.", + "上游不支持 `/v1/models` 时,按这份清单认定它提供的模型。", + ), + ), + row( + "models_only", + Kind::Strs, + Def::Unset, + t( + "Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use `disabled`.", + "只使用这家的这些模型,写 ID 或通配。范围外的模型不出现在模型列表里,也不会路由到这家。不写:全部。写空列表会被拒绝,暂停使用请用 `disabled`。", + ), + ), + row( + "billing", + Kind::Enum(billings), + Def::Is("per-token"), + t( + "`per-token`: cost is usage times the price in the upstream's price sheet, subscription accounts included. `free`: cost is recorded as 0.", + "`per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。", + ), + ), + row( + "pricing", + Kind::Str, + Def::Unset, + t( + "Name of a price sheet under `pricing.sheets`. Unset: the default price table.", + "`pricing.sheets` 中某张价目表的名字。不写:默认价目表。", + ), + ), + row( + "disabled", + Kind::Bool, + Def::Is("false"), + t( + "Take the upstream out of routing and out of the model list, and keep its configuration.", + "不参与路由,模型也不出现在模型列表里;配置原样保留。", + ), + ), + ], + }, + Section { + path: "providers[].oauth", + ty: checked!( + OAuth, + "{refresh: r, endpoint: 'https://auth.example.com/token'}" + ), + rows: vec![ + row( + "access", + Kind::Str, + Def::Unset, + t( + "Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use.", + "当前的 access token。每次刷新后由网关写回;不写则在第一次使用时换取。", + ), + ), + row( + "expires_at", + Kind::Str, + Def::Unset, + t( + "When `access` expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401.", + "`access` 的过期时间,RFC 3339(UTC),随 token 一起写回。不写:一直用到上游返回 401。", + ), + ), + row( + "refresh", + Kind::Str, + Def::Required, + t( + "Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file.", + "Refresh token。token 端点换发新的之后旧的即作废,因此网关会把新的写回本文件。", + ), + ), + row( + "endpoint", + Kind::Str, + Def::Required, + t("Token endpoint URL.", "token 端点的地址。"), + ), + row( + "client_id", + Kind::Str, + Def::Unset, + t( + "OAuth client id, if the endpoint wants one.", + "OAuth 客户端 ID,端点需要时填写。", + ), + ), + row( + "client_secret", + Kind::Str, + Def::Unset, + t( + "OAuth client secret, if the endpoint wants one.", + "OAuth 客户端密钥,端点需要时填写。", + ), + ), + row( + "refresh_before", + Kind::Duration, + Def::Unset, + t( + "How long before expiry to refresh. Unset or unreadable: `5m`.", + "提前多久刷新。不写或写法无法识别:`5m`。", + ), + ), + ], + }, + // ── proxies ─────────────────────────────────────────── + Section { + path: "proxies[]", + ty: checked!(Proxy, "{name: p, addr: '127.0.0.1:7890'}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name used in `providers[].proxy`. `direct` and `system` are built in.", + "`providers[].proxy` 引用的名字。`direct` 和 `system` 是内置的。", + ), + ), + row( + "type", + Kind::Enum(proxy_kinds), + Def::Is("socks5h"), + t( + "`socks5h` sends the host name to the proxy to resolve; `socks5` resolves it locally first. `http` and `https` are HTTP proxies.", + "`socks5h` 把域名交给代理解析;`socks5` 先在本地解析。`http` 和 `https` 是 HTTP 代理。", + ), + ), + row( + "addr", + Kind::Str, + Def::Required, + t("`host:port` of the proxy.", "代理的 `host:port`。"), + ), + row( + "auth", + Kind::Obj("proxies[].auth"), + Def::Unset, + t( + "User name and password, if the proxy wants them.", + "代理需要时填写用户名和密码。", + ), + ), + ], + }, + Section { + path: "proxies[].auth", + ty: checked!(ProxyAuth, "{user: u, pass: p}"), + rows: vec![ + row( + "user", + Kind::Str, + Def::Required, + t("User name.", "用户名。"), + ), + row( + "pass", + Kind::Secret, + Def::Required, + t("Password.", "密码。"), + ), + ], + }, + // ── pricing ─────────────────────────────────────────── + Section { + path: "pricing", + ty: checked!(PricingConfig, "{}"), + rows: vec![ + row( + "auto_update", + Kind::Bool, + Def::Is("true"), + t( + "Refresh the default price table from the network once a day. It is saved as `model_prices.json` beside `config.yaml`; the table built into the binary is used until then and when offline.", + "每天联网刷新一次默认价目表,保存为 `config.yaml` 旁边的 `model_prices.json`;此前以及离线时使用内置于程序中的价目表。", + ), + ), + row( + "sheets", + Kind::Objs("pricing.sheets[]"), + Def::Is("[]"), + t( + "Price sheets of your own. An upstream uses one with `providers[].pricing`.", + "自定义价目表。上游用 `providers[].pricing` 选用。", + ), + ), + ], + }, + Section { + path: "pricing.sheets[]", + ty: checked!(SheetDef, "{name: s}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t("Name of the sheet; unique.", "价目表的名字,不能重复。"), + ), + row( + "multiplier", + Kind::Num, + Def::Is("1"), + t( + "Applied to every price of the default table, cache and long-context prices included.", + "作用于默认价目表的全部单价,包括缓存和长上下文单价。", + ), + ), + row( + "models", + Kind::ObjMap(t("model id", "模型 ID"), "pricing.sheets[].models.*"), + Def::Is("{}"), + t( + "Prices for single models. They replace the default table's price for that model and are not multiplied.", + "单独定价的模型。它们取代默认价目表中该模型的单价,不乘倍率。", + ), + ), + ], + }, + Section { + path: "pricing.sheets[].models.*", + ty: checked!( + PerMillion, + "{input: 3, output: 15, cache_read: 0.3, cache_write_5m: 3.75, cache_write_1h: 6}" + ), + rows: vec![ + row( + "input", + Kind::Num, + Def::Required, + t( + "US dollars per million input tokens.", + "每百万输入 token 的美元价格。", + ), + ), + row( + "output", + Kind::Num, + Def::Required, + t( + "US dollars per million output tokens.", + "每百万输出 token 的美元价格。", + ), + ), + row( + "cache_read", + Kind::Num, + Def::Required, + t( + "US dollars per million tokens read from the prompt cache.", + "每百万缓存读取 token 的美元价格。", + ), + ), + row( + "cache_write_5m", + Kind::Num, + Def::Required, + t( + "US dollars per million tokens written to a 5-minute cache.", + "每百万写入 5 分钟缓存 token 的美元价格。", + ), + ), + row( + "cache_write_1h", + Kind::Num, + Def::Required, + t( + "US dollars per million tokens written to a 1-hour cache.", + "每百万写入 1 小时缓存 token 的美元价格。", + ), + ), + row( + "input_above_200k", + Kind::Num, + Def::Unset, + t( + "Input price once a request's input exceeds 200K tokens. Written together with `output_above_200k`, or neither.", + "单次请求输入超过 200K token 后的输入单价。与 `output_above_200k` 同时写或都不写。", + ), + ), + row( + "output_above_200k", + Kind::Num, + Def::Unset, + t( + "Output price once a request's input exceeds 200K tokens.", + "单次请求输入超过 200K token 后的输出单价。", + ), + ), + ], + }, + // ── client_probes ───────────────────────────────────── + Section { + path: "client_probes", + ty: checked!(ClientProbes, "{}"), + rows: vec![ + row( + "health_check", + Kind::Enum(probe_actions), + Def::Is("intercept"), + t( + "Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost.", + "连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。", + ), + ), + row( + "warmup", + Kind::Enum(probe_actions), + Def::Is("intercept"), + t( + "Warm-up requests. Answered locally by default.", + "预热请求。默认在本地应答。", + ), + ), + row( + "titling", + Kind::Enum(probe_actions), + Def::Is("passthrough"), + t( + "Requests that name a session. Passed through by default: intercepting them gives every session the same title.", + "为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。", + ), + ), + row( + "topic_detect", + Kind::Enum(probe_actions), + Def::Is("passthrough"), + t( + "Topic detection. Passed through by default.", + "话题检测。默认放行。", + ), + ), + row( + "suggestion", + Kind::Enum(probe_actions), + Def::Is("passthrough"), + t( + "Suggestions. Passed through by default.", + "建议。默认放行。", + ), + ), + ], + }, + // ── security ────────────────────────────────────────── + Section { + path: "security", + ty: checked!(Security, "{}"), + rows: vec![ + row( + "redact", + Kind::Obj("security.redact"), + Def::Section, + t( + "Outbound redaction: credentials found in a request are replaced before it leaves.", + "出站脱敏:请求发出前,把其中的凭据替换掉。", + ), + ), + row( + "inspect_tools", + Kind::Obj("security.inspect_tools"), + Def::Section, + t( + "Tool-call inspection: dangerous commands in the tool calls a model returns cut the response off.", + "工具调用审查:模型返回的工具调用中出现危险命令时切断响应。", + ), + ), + row( + "hidden_text", + Kind::Obj("security.hidden_text"), + Def::Section, + t( + "Hidden characters that people cannot see and models can read refuse the request.", + "人看不见、模型读得到的隐藏字符,出现时拒绝请求。", + ), + ), + row( + "content", + Kind::Obj("security.content"), + Def::Section, + t( + "Content filter: words or patterns in what the caller sends refuse the request.", + "内容过滤:调用方发送的内容中出现指定的词或写法时拒绝请求。", + ), + ), + row( + "output_limit", + Kind::Obj("security.output_limit"), + Def::Section, + t( + "Output length: a response longer than the limit is cut off.", + "输出长度:回答超过上限时切断。", + ), + ), + ], + }, + Section { + path: "security.redact", + ty: checked!(RedactPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row("enable", Kind::Strs, Def::Is("[]"), ENABLE_DOC), + row("disable", Kind::Strs, Def::Is("[]"), DISABLE_DOC), + row( + "custom", + Kind::Objs("security.redact.custom[]"), + Def::Is("[]"), + t( + "Rules of your own: whatever a pattern matches is treated as a credential.", + "自定义规则:正则匹配到的内容按凭据处理。", + ), + ), + ], + }, + Section { + path: "security.redact.custom[]", + ty: checked!(CustomRedactRule, "{name: n, pattern: p}"), + rows: vec![ + row("name", Kind::Str, Def::Required, RULE_NAME), + row( + "pattern", + Kind::Str, + Def::Required, + t("Regular expression.", "正则表达式。"), + ), + row("disabled", Kind::Bool, Def::Is("false"), RULE_DISABLED), + ], + }, + Section { + path: "security.inspect_tools", + ty: checked!(ToolPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row("enable", Kind::Strs, Def::Is("[]"), ENABLE_DOC), + row("disable", Kind::Strs, Def::Is("[]"), DISABLE_DOC), + row( + "actions", + Kind::EnumMap(RULE_ID, tool_actions), + Def::Is("{}"), + t( + "What a built-in rule does under `enforce`, written only where it differs from the factory setting (`rm-rf-root: record`).", + "内置规则在 `enforce` 下的处置,只写与出厂不同的(`rm-rf-root: record`)。", + ), + ), + row( + "custom", + Kind::Objs("security.inspect_tools.custom[]"), + Def::Is("[]"), + t( + "Rules of your own, matched against the arguments of a tool call.", + "自定义规则,按工具调用的参数匹配。", + ), + ), + ], + }, + Section { + path: "security.inspect_tools.custom[]", + ty: checked!(CustomToolRule, "{name: n, pattern: p}"), + rows: vec![ + row("name", Kind::Str, Def::Required, RULE_NAME), + row( + "pattern", + Kind::Str, + Def::Required, + t("Regular expression.", "正则表达式。"), + ), + row( + "action", + Kind::Enum(tool_actions), + Def::Is("record"), + t( + "Under `enforce`: `cut` the response off, or only `record` the match.", + "`enforce` 下切断响应(`cut`),或只记录(`record`)。", + ), + ), + row("disabled", Kind::Bool, Def::Is("false"), RULE_DISABLED), + ], + }, + Section { + path: "security.hidden_text", + ty: checked!(HiddenPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row( + "disable", + Kind::Strs, + Def::Is("[]"), + t( + "Kinds not to look for: `tag`, `bidi`.", + "不检查的种类:`tag`、`bidi`。", + ), + ), + ], + }, + Section { + path: "security.content", + ty: checked!(ContentPolicy, "{}"), + rows: vec![ + row("mode", Kind::Enum(modes), Def::Is("observe"), MODE_DOC), + row("enable", Kind::Strs, Def::Is("[]"), ENABLE_DOC), + row("disable", Kind::Strs, Def::Is("[]"), DISABLE_DOC), + row( + "actions", + Kind::EnumMap(RULE_ID, content_actions), + Def::Is("{}"), + t( + "What a built-in rule does under `enforce`, written only where it differs from the factory setting.", + "内置规则在 `enforce` 下的处置,只写与出厂不同的。", + ), + ), + row( + "custom", + Kind::Objs("security.content.custom[]"), + Def::Is("[]"), + t("Rules of your own.", "自定义规则。"), + ), + ], + }, + Section { + path: "security.content.custom[]", + ty: checked!(CustomContentRule, "{name: n, pattern: p}"), + rows: vec![ + row("name", Kind::Str, Def::Required, RULE_NAME), + row( + "pattern", + Kind::Str, + Def::Required, + t( + "A keyword, or a regular expression with `match: regex`. Case-insensitive either way.", + "关键词;`match: regex` 时为正则表达式。均不区分大小写。", + ), + ), + row( + "match", + Kind::Enum(content_matches), + Def::Is("contains"), + t( + "`contains`: the text contains `pattern`. `regex`: `pattern` is a regular expression.", + "`contains`:正文包含 `pattern`。`regex`:`pattern` 是正则表达式。", + ), + ), + row( + "action", + Kind::Enum(content_actions), + Def::Is("record"), + t( + "Under `enforce`: `block` the request, or only `record` the match.", + "`enforce` 下拒绝请求(`block`),或只记录(`record`)。", + ), + ), + row("disabled", Kind::Bool, Def::Is("false"), RULE_DISABLED), + ], + }, + Section { + path: "security.output_limit", + ty: checked!(OutputLimitPolicy, "{}"), + rows: vec![ + row( + "mode", + Kind::Enum(modes), + Def::Is("off"), + t( + "Off out of the box: no single limit suits every use. `observe` records long responses; `enforce` stops the stream at the limit.", + "出厂关闭:没有一个上限适合所有用途。`observe` 记录超长的回答;`enforce` 在超过上限处停止输出。", + ), + ), + row( + "max_chars", + Kind::Int, + Def::Is("100000"), + t( + "Limit in characters (Unicode scalar values), from 1 to 1000000.", + "上限,按字符(Unicode 标量)计,取值 1 到 1000000。", + ), + ), + ], + }, + // ── retention ───────────────────────────────────────── + Section { + path: "retention", + ty: checked!(Retention, "{}"), + rows: vec![ + row( + "body_days", + Kind::Int, + Def::Is("7"), + t( + "Days to keep request and response bodies.", + "请求和响应正文保留的天数。", + ), + ), + row( + "row_days", + Kind::Int, + Def::Is("90"), + t( + "Days to keep the record of each request (time, model, usage, cost).", + "每条请求记录(时间、模型、用量、费用)保留的天数。", + ), + ), + row( + "body_max_bytes", + Kind::Int, + Def::Is("2147483648"), + t( + "Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 2 GiB.", + "正文最多占用的字节数,超出时从最早的日期开始删除。默认 2 GiB。", + ), + ), + ], + }, + // ── groups / routes ─────────────────────────────────── + Section { + path: "groups[]", + ty: checked!(Group, "{name: g, providers: [a]}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the group; unique, and not the name of an upstream.", + "策略组的名字,不能重复,也不能和上游同名。", + ), + ), + row( + "type", + Kind::Enum(group_types), + Def::Is("fallback"), + t( + "`fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price.", + "`fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。", + ), + ), + row( + "providers", + Kind::Strs, + Def::Required, + t("Member upstreams, by name.", "成员上游的名字。"), + ), + row( + "session_affinity", + Kind::Bool, + Def::Is("true"), + t( + "Keep a session on the same upstream so its prompt cache keeps hitting. Turning it off under `load-balance` spreads every turn and loses the cache.", + "同一会话固定走同一家,使 prompt cache 持续命中。在 `load-balance` 下关闭会让每一轮都换一家,缓存随之失效。", + ), + ), + row( + "selected", + Kind::Str, + Def::Unset, + t( + "For `select`: the chosen member.", + "`select` 类型选中的成员。", + ), + ), + ], + }, + Section { + path: "routes[]", + ty: checked!(RouteSet, "{name: r}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name of the route; unique. `default` is the one keys use unless told otherwise.", + "路由的名字,不能重复。`default` 是密钥默认使用的那条。", + ), + ), + row( + "rules", + Kind::Objs("routes[].rules[]"), + Def::Is("[]"), + t( + "Evaluated top to bottom; the first rule with `to` or `deny` that matches decides where the request goes.", + "自上而下求值;第一条匹配且带有 `to` 或 `deny` 的规则决定请求去向。", + ), + ), + ], + }, + Section { + path: "routes[].rules[]", + ty: checked!(Rule, "{name: r}"), + rows: vec![ + row( + "name", + Kind::Str, + Def::Required, + t( + "Name shown in logs and in the traffic view.", + "日志和流量详情中显示的名字。", + ), + ), + row( + "when", + Kind::Obj("routes[].rules[].when"), + Def::Unset, + t( + "Conditions, all of which have to hold. Unset: matches every request.", + "条件,须全部满足。不写:匹配所有请求。", + ), + ), + row( + "to", + Kind::Str, + Def::Unset, + t( + "An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`.", + "上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。", + ), + ), + row( + "set", + Kind::Obj("routes[].rules[].set"), + Def::Unset, + t( + "Parameters to rewrite. Collected from every matching rule, not only the first.", + "改写请求参数。从所有匹配的规则累积,不只第一条。", + ), + ), + row( + "deny", + Kind::Str, + Def::Unset, + t( + "Refuse the request with this reason.", + "以这句原因拒绝请求。", + ), + ), + ], + }, + Section { + path: "routes[].rules[].when", + ty: checked!(When, "{}"), + rows: vec![ + row( + "model", + Kind::Str, + Def::Unset, + t( + "Requested model, glob (`claude-opus-*`).", + "请求的模型,可用通配(`claude-opus-*`)。", + ), + ), + row( + "client", + Kind::Str, + Def::Unset, + t( + "Name of the gateway key the request used, exactly.", + "请求所用网关密钥的名字,精确匹配。", + ), + ), + row( + "dialect", + Kind::Str, + Def::Unset, + t( + "API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`.", + "客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。", + ), + ), + row( + "input_tokens", + Kind::Compare, + Def::Unset, + t("Estimated input tokens.", "估算的输入 token 数。"), + ), + row( + "max_tokens", + Kind::Compare, + Def::Unset, + t( + "The request's `max_tokens`. A request without one never matches.", + "请求中的 `max_tokens`。未写该参数的请求不匹配。", + ), + ), + row( + "tool_count", + Kind::Compare, + Def::Unset, + t("Number of tools offered.", "请求中提供的工具数量。"), + ), + row( + "cache", + Kind::Bool, + Def::Unset, + t( + "Whether the request uses the prompt cache.", + "请求是否使用 prompt cache。", + ), + ), + row( + "tools", + Kind::Bool, + Def::Unset, + t("Whether the request offers tools.", "请求是否带工具。"), + ), + row( + "image", + Kind::Bool, + Def::Unset, + t( + "Whether the request contains an image.", + "请求是否包含图片。", + ), + ), + row( + "thinking", + Kind::Bool, + Def::Unset, + t("Whether extended thinking is on.", "是否开启扩展思考。"), + ), + row( + "stream", + Kind::Bool, + Def::Unset, + t("Whether the response is streamed.", "是否流式返回。"), + ), + row( + "intent", + Kind::OneOrMany, + Def::Unset, + t( + "A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing.", + "客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。", + ), + ), + row( + "provider_would_be", + Kind::OneOrMany, + Def::Unset, + t( + "The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`.", + "路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。", + ), + ), + ], + }, + Section { + path: "routes[].rules[].set", + ty: checked!(SetAction, "{}"), + rows: vec![ + row( + "model", + Kind::Str, + Def::Unset, + t( + "Send a different model. The prompt cache of the session is lost.", + "换成另一个模型发送。该会话的 prompt cache 随之失效。", + ), + ), + row( + "max_tokens", + Kind::Int, + Def::Unset, + t("Replace `max_tokens`.", "替换 `max_tokens`。"), + ), + row( + "thinking", + Kind::Bool, + Def::Unset, + t("Turn extended thinking on or off.", "开启或关闭扩展思考。"), + ), + row( + "only_at_session_start", + Kind::Bool, + Def::Is("false"), + t( + "Apply only when a session starts. Recorded and shown; not in effect yet.", + "只在会话开始时应用。目前只记录和显示,尚未生效。", + ), + ), + ], + }, + ] +} + +/// 一份内置规则清单,渲染成表。 +pub fn rules(kind: &str, l: Lang) -> Option { + let zh = l == Lang::Zh; + let yes = if zh { "开" } else { "on" }; + let no = if zh { "关" } else { "off" }; + let mut out = String::new(); + match kind { + "redact" => { + out += if zh { + "| id | 名称 | 出厂 |\n|---|---|---|\n" + } else { + "| id | Name | Out of the box |\n|---|---|---|\n" + }; + for b in tw_guard::redact::rules::BUILTINS { + let on = if b.on_by_default { yes } else { no }; + out += &format!("| `{}` | {} | {on} |\n", b.id, b.name); + } + } + "inspect_tools" => { + out += if zh { + "| id | 名称 | `enforce` 下出厂处置 |\n|---|---|---|\n" + } else { + "| id | Name | Under `enforce`, out of the box |\n|---|---|---|\n" + }; + for s in &tw_guard::tools::rules::builtin().dangerous { + let a = ToolAction::factory(s).slug(); + out += &format!("| `{}` | {} | `{a}` |\n", s.id, s.name); + } + } + "content" => { + out += if zh { + "| id | 名称 | 分组 | 出厂 | `enforce` 下出厂处置 |\n|---|---|---|---|---|\n" + } else { + "| id | Name | Group | Out of the box | Under `enforce`, out of the box |\n|---|---|---|---|---|\n" + }; + for b in tw_guard::content::builtins() { + let on = if b.on_by_default { yes } else { no }; + let a = ContentAction::factory(b).slug(); + out += &format!("| `{}` | {} | {} | {on} | `{a}` |\n", b.id, b.name, b.group); + } + } + "hidden_text" => { + out += if zh { + "| 种类 | 说明 |\n|---|---|\n" + } else { + "| Kind | What it is |\n|---|---|\n" + }; + for k in tw_guard::hidden::SMUGGLING { + let what = match (k.slug(), zh) { + ("tag", false) => { + "Unicode tag characters (U+E0000 to U+E007F): invisible everywhere, read by the model, able to carry a whole instruction." + } + ("tag", true) => { + "Unicode 标签字符(U+E0000 至 U+E007F):在任何地方都不可见,模型却能读到,足以藏下一整段指令。" + } + ("bidi", false) => { + "Bidirectional control characters: make the order shown differ from the order the model reads." + } + ("bidi", true) => "双向控制符:使显示顺序与模型读到的顺序不一致。", + (other, _) => panic!("hidden kind `{other}` has no description in the manual"), + }; + out += &format!("| `{}` | {what} |\n", k.slug()); + } + } + _ => return None, + } + Some(out) +} diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..256bc73 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,871 @@ +# Configuration reference + +[中文](config.zh-CN.md) + +ThinkWatch Core reads one file, `config.yaml`. This page describes every +field in it: what it does, its default, the values it takes, and how a +change reaches the running process. To run core on a server and control it +from the desktop app, see [Running core on a server](server.md). + +The field tables on this page are generated from the code, and a test fails +when they disagree, so a field listed here is a field the binary reads. + +## Where the file is + +| Platform | Default location | +|---|---| +| macOS, Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` replaces the directory, and `--config ` names the +file for a single command. The directory holds everything else core keeps +as well: the request database (`data.db`), the configuration history +(`history/`), the downloaded price table (`model_prices.json`) and the local +control socket (`twcore.sock`; on Windows a loopback port recorded in +`control.port`). The directory is private to its owner (`0700`), the file is +`0600`: it holds keys in plain text. + +`twcore serve` writes a starting configuration when there is none, and +`twcore init` writes one on request. Both produce this: + +```yaml +version: 1 +listen: + control: + key: 6629…753d # generated +clients: + - name: default + key: tw-… # generated +``` + +That is a complete, valid configuration. It has no upstream yet, so the +control plane runs and requests are answered with an error saying so. +Adding one upstream makes it forward: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## How the file is read + +- **A field name that is not in this reference is an error.** A misspelled + `prot:` is not ignored while the gateway quietly starts on the default + port; the whole file is refused and the message names the field. +- **Defaults are not written into the file.** Anything left out has the + default in the tables below. The app and the command line add a field + only when its value differs from the default, so what is in the file is + what someone chose. +- **`${VAR}` reads an environment variable** in fields marked "`${VAR}` + allowed": upstream keys, header values, proxy passwords. It is read from + the environment of the core process when a request is sent; an unset + variable fails that upstream's requests and names the variable. Under + systemd, that environment is the unit's `EnvironmentFile`. +- **Names are references.** Rules, groups and keys refer to upstreams, + groups, routes and price sheets by name. A name that points nowhere is an + error at load time rather than a rule that never matches. Renaming in the + app changes every reference in the same write. Names starting with `__` + are reserved for built-ins. +- **`version`** is the format version, `1`. A file with a higher version was + written by a newer twcore and is refused. + +## How a change takes effect + +There are three ways to change the configuration, and they all go through +the same path: the desktop app, `twcore config …`, and editing the file +in an editor. Core watches the file and reloads it within a second of a +save; nothing needs to be restarted. + +A new version is applied only if it passes every stage: + +1. It parses as YAML. +2. It matches the schema: known fields, the right types. +3. It is consistent: names are unique, references resolve, patterns + compile, CIDR ranges are well formed. +4. The runtime objects can be built from it. + +If any stage fails, **the previous configuration stays in service**, and the +error says which stage failed and where. A typo never takes the gateway +down. The desktop app shows the rejection until a valid version is saved. + +Changes to `listen.gateway` apply live as well: core opens the new +listener, and if it cannot (the port is taken) it keeps the old one and +reports why. Retention changes are applied on the next hourly clean-up. + +When two writers edit at once (the app and a hand edit), the second write +is refused with a version mismatch instead of overwriting the first. + +### History and rollback + +Every version that was in effect is kept in `history/` beside the file, +with where it came from (the app, the command line, an outside edit, a +rollback, a credential rotation). The last 50 are kept. + +```sh +twcore check # validate the file without starting anything +twcore config show # print it, with its version +twcore config history # list the versions, newest first +twcore config rollback 3f9a2c # go back to a version (a prefix is enough) +twcore config set /listen/gateway/port 8790 --int +``` + +These commands work on the file directly, so they work when core is not +running, which is when a rollback is most needed. A running core picks +their changes up like any other save. + +`twcore config set ` changes one value that is already written +in the file. The path names list items by their `name` +(`/providers/anthropic/base_url`); a number is an index (`/routes/0/rules/1/to`). +The value is a string unless `--int`, `--bool` or `--null` says otherwise. +The result is validated before it is written. To add a field that is not in +the file yet, edit the file. + +### Credentials the gateway writes back + +One write is not made by a person. When an upstream's OAuth token endpoint +issues a new refresh token, the old one stops working, so the gateway +writes the new token (and the access token with its expiry) back into +`providers[].oauth`. Only those values change; comments and layout are left +as they are. + +## Reference + +Each table lists every field of one section. "—" in the Default column means +the field is simply absent unless written; the description says what that +means. + +### Top level + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `version` | integer | **required** | Format version of this file. The only version is `1`. A file with a higher number was written by a newer twcore and is refused rather than half-understood. | +| `listen` | object, [`listen`](#cfg-listen) | — | Where the gateway and the control channel listen. | +| `clients` | list of [`clients[]`](#cfg-clients) | `[]` | Gateway keys. At least one is required; `twcore init` and the first `twcore serve` write one named `default`. | +| `providers` | list of [`providers[]`](#cfg-providers) | `[]` | Upstreams. None is a valid configuration: the control plane runs and requests are answered with an error saying no upstream is configured. | +| `proxies` | list of [`proxies[]`](#cfg-proxies) | `[]` | Outbound proxies, declared once and referred to by name from `providers[].proxy`. | +| `pricing` | object, [`pricing`](#cfg-pricing) | — | Refreshing the default price table, and price sheets of your own. | +| `client_probes` | object, [`client_probes`](#cfg-client_probes) | — | What happens to the helper requests clients send on their own (health checks, warm-ups, titles). | +| `security` | object, [`security`](#cfg-security) | — | The five guards. All of them start in `observe` or `off`, so out of the box nothing is changed or blocked. | +| `retention` | object, [`retention`](#cfg-retention) | — | How long request logs are kept. | +| `groups` | list of [`groups[]`](#cfg-groups) | `[]` | Strategy groups: several upstreams behind one name, with a way to pick among them. | +| `routes` | list of [`routes[]`](#cfg-routes) | `[]` | Routes. Without any, requests fail over across all upstreams in the order they are declared. | +| `default_route` | string | — | The route for keys that do not name one. Unset: the route named `default`, or the built-in failover when there is none. | +| `default_key` | string | — | The gateway key for clients that were not given a key of their own. Unset: the key named `default`, or the first key. It cannot be disabled. | + + +### `listen` + +Where core accepts connections. There are two kinds: the AI gateway that +clients send requests to, and the control channel the desktop app and +`twcore` commands use. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `gateway` | object, [`listen.gateway`](#cfg-listen-gateway) | — | The AI gateway: the address clients send requests to. | +| `control` | object, [`listen.control`](#cfg-listen-control) | — | The control channel: how the desktop app and `twcore` commands reach core. It holds the control key, so every configuration has it. | + + +#### `listen.gateway` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| interface name \| IP address | `loopback` | `loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up at start and follows address changes; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1. | +| `port` | integer | `8788` | TCP port of the gateway. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources other than this machine that may connect, as CIDR ranges or single addresses. This machine is always allowed. `[]` means this machine only; `0.0.0.0/0` allows everyone and has to be written out. | + + +When `bind` reaches beyond this machine, `allow_from` decides who gets in. +The gateway has no TLS: expose it on networks you trust, or put it behind a +tunnel or VPN. + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +The control channel is how the desktop app, and `twcore config` and +`twcore control-key` on the same machine, talk to core. Locally it is a +socket file in the data directory (a loopback port on Windows); no network +port is opened for it unless `remote` is enabled. + +Every control connection, local or remote, starts with a handshake that +proves both ends hold `key` +(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`: the key is the pre-shared key, +each connection negotiates fresh session keys, and the traffic is +encrypted). There are no certificates. A peer without the key cannot +complete the first message. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `key` | string | generated | The control key: 64 hexadecimal characters (32 bytes). Every control connection, local or remote, proves it knows this key. `twcore serve` writes one before listening if it is missing; a configuration where it is malformed is refused. Show it with `twcore control-key`, replace it with `twcore control-key --rotate`. | +| `remote` | object, [`listen.control.remote`](#cfg-listen-control-remote) | — | A network port for the desktop app on another machine. Additional to the local channel, never instead of it. | + + +`key` is written by `twcore serve` before the control channel starts +listening, if it is missing, and by `twcore init`. It is masked wherever the +configuration is shown or kept in the history; saving a masked value back +keeps the real one. A missing or malformed key (not 64 hexadecimal +characters) makes the whole file invalid, so it cannot be replaced by a +short, guessable one. + +```sh +twcore control-key # print the key, to paste into the desktop app +twcore control-key --rotate # replace it; connected apps have to reconnect +``` + +Both run on the machine where core runs. + +#### `listen.control.remote` + +A network port for a desktop app on another machine. It is opened in +addition to the local channel, so a mistake here (a port that is taken, an +`allow_from` that shuts you out) never locks out the machine itself: +`twcore config` and the local app keep working. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | bool | `false` | Listen on the remote port. Unset or `false`: no network port is opened for control. | +| `bind` | `loopback` \| `all` \| interface name \| IP address | `all` | Interface to listen on, written as for `listen.gateway.bind`. | +| `port` | integer | generated | TCP port. There is no fixed default: a random free port is written when the section is generated, like the key. | +| `allow_from` | list of strings | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | Sources that may connect, as for `listen.gateway.allow_from`. A connection from anywhere else is closed before the handshake, without a byte in reply. | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 hexadecimal characters + remote: + enabled: true + bind: all + port: 41327 # random, written when the section is generated + allow_from: [192.168.1.0/24] +``` + +A connection over the remote port cannot do three things, whatever the app +asks: shut core down (it is managed by systemd), change `listen.control` +(the door it came in through), or produce a diagnostic bundle (it would be +written on the server). Handshakes time out after 5 seconds; five failed +handshakes from one source within a minute block that source for a minute. + +### `clients` + +Gateway keys: the keys clients such as Claude Code and Codex send to the +gateway. A key is an identity. Limits, model scope and route are per key. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the key; unique. Routing rules match it with `when.client`. | +| `key` | string | **required** | The key clients send (as `x-api-key` or `Authorization: Bearer`). Generated keys start with `tw-` so they are not mistaken for an upstream's key. Unique. | +| `max_concurrent` | integer | — | Requests with this key that may run at once; the rest wait. Unset: no limit. `0` is refused. | +| `allow` | list of strings | — | Models this key may use, as model ids or globs (`claude-*`). Unset: every model. `[]`: none at all. | +| `route` | string | — | Name of the route requests with this key take. Unset: `default_route`. | +| `client` | string | — | The client this key was made for (`claude-code`, `codex`, …), recorded when the desktop app points a client at the gateway. A client has at most one. | +| `disabled` | bool | `false` | Refuse every request made with this key, and keep the key. | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +### `providers` + +Upstreams: the APIs requests are forwarded to. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the upstream; unique, and not the name of a group. Names starting with `__` are reserved. | +| `base_url` | string | **required** | Endpoint, `http://` or `https://`, up to the version segment where the provider documents one (`https://api.anthropic.com`, `https://api.openai.com/v1`). | +| `key` | string, `${VAR}` allowed | — | API key. It goes in the header the protocol expects: `x-api-key` (Anthropic), `Authorization: Bearer` (OpenAI), `x-goog-api-key` (Gemini). Leave it out for upstreams without a key, or when the credential is written in `headers`. Cannot be combined with `oauth`. | +| `headers` | map of header name → value | `{}` | Additional request headers, in the order written; values may use `${VAR}`, and `{{access_token}}` where `oauth` is set. At most 32. Headers HTTP or the gateway manages (`host`, `content-length`, `connection`, …) cannot be set. | +| `oauth` | object, [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth credential: an access token obtained from a refresh token. Instead of `key`. | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` | — | API format of the upstream. Unset: recognized from `base_url` for the official endpoints, otherwise treated as `anthropic`. | +| `proxy` | string | `direct` | `direct`; `system`, the proxy in the core process's `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` environment variables; or the name of an entry in `proxies`. | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | When the proxy cannot be reached: `fail` the request, or go `direct`. | +| `models` | list of strings | `[]` | Models to assume when the upstream does not answer `/v1/models`. | +| `models_only` | list of strings | — | Use only these of the upstream's models, as ids or globs. Others are not listed and are not routed here. Unset: all of them. Empty is refused; use `disabled`. | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`: cost is usage times the price in the upstream's price sheet, subscription accounts included. `free`: cost is recorded as 0. | +| `pricing` | string | — | Name of a price sheet under `pricing.sheets`. Unset: the default price table. | +| `disabled` | bool | `false` | Take the upstream out of routing and out of the model list, and keep its configuration. | + + +A credential is one of three things: `key`, which goes in the header the +protocol expects; `oauth`, a token obtained from a refresh token; or +`headers`, when the upstream wants something of its own. `headers` can be +combined with the other two, except for the header that already carries the +credential. + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +A ChatGPT account upstream (`protocol: chatgpt`) takes only the credential +the desktop app obtains by signing in; it cannot be written by hand. Claude +and Google subscription sign-ins are not supported; use an API key. + +#### `providers[].oauth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `access` | string | — | Current access token. Written back by the gateway after every refresh; unset means one is obtained on first use. | +| `expires_at` | string | — | When `access` expires, RFC 3339 in UTC. Written back with it. Unset: used until the upstream answers 401. | +| `refresh` | string | **required** | Refresh token. When the token endpoint issues a new one, the old one stops working, so the gateway writes the new one back into this file. | +| `endpoint` | string | **required** | Token endpoint URL. | +| `client_id` | string | — | OAuth client id, if the endpoint wants one. | +| `client_secret` | string | — | OAuth client secret, if the endpoint wants one. | +| `refresh_before` | duration (`30s`, `5m`, `1h`) | — | How long before expiry to refresh. Unset or unreadable: `5m`. | + + +The access token goes in the protocol's authorization header. To put it +somewhere else, write the header in `headers` with `{{access_token}}` where +the token goes: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +### `proxies` + +Outbound proxies. Different upstreams often need different ones, so there +is no global switch: an upstream picks one with `proxy`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name used in `providers[].proxy`. `direct` and `system` are built in. | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` sends the host name to the proxy to resolve; `socks5` resolves it locally first. `http` and `https` are HTTP proxies. | +| `addr` | string | **required** | `host:port` of the proxy. | +| `auth` | object, [`proxies[].auth`](#cfg-proxies-auth) | — | User name and password, if the proxy wants them. | + + +#### `proxies[].auth` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `user` | string | **required** | User name. | +| `pass` | string, `${VAR}` allowed | **required** | Password. | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail: fail` is the default because falling back silently sends a +request by a path you did not intend; you would believe you were on the +proxy while you were not. + +### `pricing` + +The cost of a request is its usage times the price of the model. Prices +come from the default price table (LiteLLM's public dataset, a copy of +which is built into the binary and refreshed daily) or from a price sheet +an upstream picks. A change of price applies to requests from then on, +never to ones already recorded. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `auto_update` | bool | `true` | Refresh the default price table from the network once a day. It is saved as `model_prices.json` beside `config.yaml`; the table built into the binary is used until then and when offline. | +| `sheets` | list of [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | Price sheets of your own. An upstream uses one with `providers[].pricing`. | + + +#### `pricing.sheets` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the sheet; unique. | +| `multiplier` | number | `1` | Applied to every price of the default table, cache and long-context prices included. | +| `models` | map of model id → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | Prices for single models. They replace the default table's price for that model and are not multiplied. | + + +#### `pricing.sheets[].models` + +Prices are in US dollars per million tokens, as printed on vendors' price +pages. Every field is written out; nothing is inferred when pricing. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `input` | number | **required** | US dollars per million input tokens. | +| `output` | number | **required** | US dollars per million output tokens. | +| `cache_read` | number | **required** | US dollars per million tokens read from the prompt cache. | +| `cache_write_5m` | number | **required** | US dollars per million tokens written to a 5-minute cache. | +| `cache_write_1h` | number | **required** | US dollars per million tokens written to a 1-hour cache. | +| `input_above_200k` | number | — | Input price once a request's input exceeds 200K tokens. Written together with `output_above_200k`, or neither. | +| `output_above_200k` | number | — | Output price once a request's input exceeds 200K tokens. | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +Some requests clients send are not the user's: connectivity checks, +warm-ups, session titles, topic detection, suggestions. Each class can be +answered locally (`intercept`, nothing is sent upstream), passed through +(`passthrough`), or handed to the routing rules (`route`, matched with +`when.intent`). The defaults intercept only what nobody would miss. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | Connectivity checks (`max_tokens: 1`). Answered locally by default: nothing is lost. | +| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | Warm-up requests. Answered locally by default. | +| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | Requests that name a session. Passed through by default: intercepting them gives every session the same title. | +| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | Topic detection. Passed through by default. | +| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | Suggestions. Passed through by default. | + + +### `security` + +Five guards, applied to every upstream alike. Each has a `mode`: `off`, +`observe` (detect and record, change nothing) or `enforce` (act). They start +in `observe`, except the output limit, which starts `off`. What `enforce` +does differs per guard, and each says so below. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `redact` | object, [`security.redact`](#cfg-security-redact) | — | Outbound redaction: credentials found in a request are replaced before it leaves. | +| `inspect_tools` | object, [`security.inspect_tools`](#cfg-security-inspect_tools) | — | Tool-call inspection: dangerous commands in the tool calls a model returns cut the response off. | +| `hidden_text` | object, [`security.hidden_text`](#cfg-security-hidden_text) | — | Hidden characters that people cannot see and models can read refuse the request. | +| `content` | object, [`security.content`](#cfg-security-content) | — | Content filter: words or patterns in what the caller sends refuse the request. | +| `output_limit` | object, [`security.output_limit`](#cfg-security-output_limit) | — | Output length: a response longer than the limit is cut off. | + + +#### `security.redact` + +Before a request leaves, credentials in it are looked for. Under `enforce` +they are replaced. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `custom` | list of [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | Rules of your own: whatever a pattern matches is treated as a credential. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Out of the box | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | on | +| `openai-project-key` | OpenAI project key | on | +| `openai-api-key` | OpenAI API key | on | +| `github-personal-token` | GitHub personal access token | on | +| `github-oauth-token` | GitHub OAuth token | on | +| `github-server-token` | GitHub server token | on | +| `github-user-token` | GitHub user token | on | +| `github-fine-grained-token` | GitHub fine-grained token | on | +| `slack-bot-token` | Slack bot token | on | +| `slack-user-token` | Slack user token | on | +| `slack-app-token` | Slack app token | on | +| `aws-access-key-id` | AWS access key ID | on | +| `aws-temporary-key-id` | AWS temporary access key ID | on | +| `google-api-key` | Google API key | on | +| `google-oauth-token` | Google OAuth token | on | +| `gitlab-token` | GitLab token | on | +| `stripe-live-key` | Stripe live key | on | +| `stripe-restricted-key` | Stripe restricted key | on | +| `npm-token` | npm token | on | +| `digitalocean-token` | DigitalOcean token | on | +| `sendgrid-key` | SendGrid key | on | +| `private-key` | Private key | on | +| `jwt` | JWT | on | +| `conn-string-password` | Connection string password | on | +| `internal-ip` | Internal IP address | off | +| `internal-domain` | Internal domain | off | + + +#### `security.inspect_tools` + +Tool calls a model returns are checked against the rules. Under `enforce`, +a match with rules set to `cut` stops the response, so the client never +receives a complete call to run. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `cut` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting (`rm-rf-root: record`). | +| `custom` | list of [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | Rules of your own, matched against the arguments of a tool call. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | Regular expression. | +| `action` | `cut` \| `record` | `record` | Under `enforce`: `cut` the response off, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Under `enforce`, out of the box | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | + + +#### `security.hidden_text` + +Characters people cannot see and models can read, in what the caller sends +(tool results included). Under `enforce`, the request is refused. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `disable` | list of strings | `[]` | Kinds not to look for: `tag`, `bidi`. | + + + +| Kind | What it is | +|---|---| +| `tag` | Unicode tag characters (U+E0000 to U+E007F): invisible everywhere, read by the model, able to carry a whole instruction. | +| `bidi` | Bidirectional control characters: make the order shown differ from the order the model reads. | + + +#### `security.content` + +Words or patterns in what the caller sends. Under `enforce`, a match with +rules set to `block` refuses the request. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` does nothing; `observe` detects and records only, and changes nothing; `enforce` detects and acts. | +| `enable` | list of strings | `[]` | Built-in rules to switch on that are off out of the box, by id. | +| `disable` | list of strings | `[]` | Built-in rules to switch off, by id. | +| `actions` | map of built-in rule id → `block` \| `record` | `{}` | What a built-in rule does under `enforce`, written only where it differs from the factory setting. | +| `custom` | list of [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | Rules of your own. | + + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the app; it identifies the rule and has to be unique within this guard. | +| `pattern` | string | **required** | A keyword, or a regular expression with `match: regex`. Case-insensitive either way. | +| `match` | `contains` \| `regex` | `contains` | `contains`: the text contains `pattern`. `regex`: `pattern` is a regular expression. | +| `action` | `block` \| `record` | `record` | Under `enforce`: `block` the request, or only `record` the match. | +| `disabled` | bool | `false` | Switches the rule off and keeps it in the file. | + + +Built-in rules: + + +| id | Name | Group | Out of the box | Under `enforce`, out of the box | +|---|---|---|---|---| +| `ignore-previous-instructions` | Ignore previous instructions | injection | on | `block` | +| `ignore-all-previous` | Ignore all previous | injection | on | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | on | `block` | +| `jailbreak` | Jailbreak | injection | off | `block` | +| `dan` | DAN | injection | off | `block` | +| `developer-mode` | Developer mode | injection | off | `block` | +| `you-are-now` | Persona manipulation | persona | off | `block` | +| `new-persona` | New persona | persona | off | `record` | +| `act-as` | Act as | persona | off | `record` | +| `pretend-to-be` | Pretend to be | persona | off | `record` | +| `system-prompt` | System prompt extraction | persona | off | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | off | `record` | +| `what-are-your-rules` | What are your rules | persona | off | `record` | +| `base64-wall` | Base64 smuggling | persona | off | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | off | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | off | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | off | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | off | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | off | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | off | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | off | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | off | `block` | + + +#### `security.output_limit` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `off` | Off out of the box: no single limit suits every use. `observe` records long responses; `enforce` stops the stream at the limit. | +| `max_chars` | integer | `100000` | Limit in characters (Unicode scalar values), from 1 to 1000000. | + + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + inspect_tools: + mode: enforce + output_limit: + mode: enforce + max_chars: 200000 +``` + +### `retention` + +Two limits, because the two kinds of data differ in size by three orders +of magnitude: request bodies are tens of kilobytes each, a request's record +a few hundred bytes. The byte limit covers bursts. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `body_days` | integer | `7` | Days to keep request and response bodies. | +| `row_days` | integer | `90` | Days to keep the record of each request (time, model, usage, cost). | +| `body_max_bytes` | integer | `2147483648` | Upper bound on the bytes bodies may take; beyond it the oldest days go first. The default is 2 GiB. | + + +### `groups` + +A group puts several upstreams behind one name. Rules send requests to a +group with `to`. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the group; unique, and not the name of an upstream. | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`: the first healthy member, in order. `select`: the member named in `selected`. `load-balance`: take turns. `url-test`: the fastest by measured time to first byte. `cheapest`: the lowest input price. | +| `providers` | list of strings | **required** | Member upstreams, by name. | +| `session_affinity` | bool | `true` | Keep a session on the same upstream so its prompt cache keeps hitting. Turning it off under `load-balance` spreads every turn and loses the cache. | +| `selected` | string | — | For `select`: the chosen member. | + + +`fallback` is the default because spreading a session across upstreams +loses the prompt cache, which is worth far more than any spread of load on +a single user's machine. + +### `routes` + +A route is a list of rules evaluated top to bottom. Each key takes the route +named in its `route`, otherwise `default_route`, otherwise the route named +`default`; without any, requests fail over across all upstreams in the +order they are declared. + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name of the route; unique. `default` is the one keys use unless told otherwise. | +| `rules` | list of [`routes[].rules[]`](#cfg-routes-rules) | `[]` | Evaluated top to bottom; the first rule with `to` or `deny` that matches decides where the request goes. | + + +#### `routes[].rules` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `name` | string | **required** | Name shown in logs and in the traffic view. | +| `when` | object, [`routes[].rules[].when`](#cfg-routes-rules-when) | — | Conditions, all of which have to hold. Unset: matches every request. | +| `to` | string | — | An upstream or a group, by name; `__all__` is every upstream in declared order. Not allowed together with `when.provider_would_be`. | +| `set` | object, [`routes[].rules[].set`](#cfg-routes-rules-set) | — | Parameters to rewrite. Collected from every matching rule, not only the first. | +| `deny` | string | — | Refuse the request with this reason. | + + +#### `routes[].rules[].when` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Requested model, glob (`claude-opus-*`). | +| `client` | string | — | Name of the gateway key the request used, exactly. | +| `dialect` | string | — | API format the client spoke: `anthropic`, `openai-chat`, `openai-responses`, `gemini`. | +| `input_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | Estimated input tokens. | +| `max_tokens` | comparison (`>200k`, `<=4k`, `==3`) | — | The request's `max_tokens`. A request without one never matches. | +| `tool_count` | comparison (`>200k`, `<=4k`, `==3`) | — | Number of tools offered. | +| `cache` | bool | — | Whether the request uses the prompt cache. | +| `tools` | bool | — | Whether the request offers tools. | +| `image` | bool | — | Whether the request contains an image. | +| `thinking` | bool | — | Whether extended thinking is on. | +| `stream` | bool | — | Whether the response is streamed. | +| `intent` | string or list of strings | — | A client helper request: `assistant_internal` for any of them, or one class (`titling`). Only classes set to `route` in `client_probes` reach routing. | +| `provider_would_be` | string or list of strings | — | The upstream routing chose. Such a rule is evaluated after routing, may only `set` or `deny`, and cannot have `to`. | + + +A comparison starts with `>`, `>=`, `<`, `<=` or `==`, and the number may +end in `k` or `m`: `">200k"`, `"<=4k"`. Without an operator it is an error, +not an equality: `"200k"` alone is refused. + +#### `routes[].rules[].set` + + + + +| Field | Type | Default | Description | +|---|---|---|---| +| `model` | string | — | Send a different model. The prompt cache of the session is lost. | +| `max_tokens` | integer | — | Replace `max_tokens`. | +| `thinking` | bool | — | Turn extended thinking on or off. | +| `only_at_session_start` | bool | `false` | Apply only when a session starts. Recorded and shown; not in effect yet. | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: long context goes to the official API + when: { input_tokens: ">200k" } + to: anthropic + - name: titles go to the cheap model + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: everything else + to: fast +default_route: default +``` + +## Environment variables + +| Variable | Effect | +|---|---| +| `THINKWATCH_HOME` | Data directory, instead of `~/.thinkwatch` (`%APPDATA%\ThinkWatch` on Windows). | +| `TWCORE_LOG` | Log filter, in `tracing` syntax (`info`, `debug`, `tw_gateway=debug`). | +| `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, `NO_PROXY` | Used by upstreams with `proxy: system`. | +| Any other | Read where the configuration writes `${NAME}`. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md new file mode 100644 index 0000000..99796f5 --- /dev/null +++ b/docs/config.zh-CN.md @@ -0,0 +1,757 @@ +# 配置手册 + +[English](config.md) + +ThinkWatch Core 只读一个文件:`config.yaml`。本文逐项说明其中每个字段的作用、默认值、可选值,以及改动如何进入正在运行的进程。在服务器上运行 core、由桌面应用远程管理,见[在服务器上运行 core](server.zh-CN.md)。 + +本文的字段表由代码生成,与代码不一致时测试失败。表中列出的字段,就是程序实际读取的字段。 + +## 文件位置 + +| 平台 | 默认位置 | +|---|---| +| macOS、Linux | `~/.thinkwatch/config.yaml` | +| Windows | `%APPDATA%\ThinkWatch\config.yaml` | + +`THINKWATCH_HOME` 替换整个目录;`--config <路径>` 为单条命令指定文件。core 的其余数据也在这个目录里:请求数据库(`data.db`)、配置历史(`history/`)、下载的价目表(`model_prices.json`),以及本地控制通道的 socket 文件(`twcore.sock`;Windows 上是回环端口,记录在 `control.port` 中)。目录只有所有者可访问(`0700`),配置文件权限为 `0600`:其中以明文保存密钥。 + +没有配置文件时,`twcore serve` 会写入一份初始配置;`twcore init` 也可以按需生成。两者生成的内容如下: + +```yaml +version: 1 +listen: + control: + key: 6629…753d # 自动生成 +clients: + - name: default + key: tw-… # 自动生成 +``` + +这已是一份完整、合法的配置。其中还没有上游,因此控制面照常运行,请求会得到「尚未配置上游」的错误。加上一个上游即可转发: + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +## 读取规则 + +- **本文没有的字段名一律是错误。**把 `port` 写成 `prot` 时,不会被悄悄忽略、让网关在默认端口上起来;整份配置被拒绝,错误信息指出那个字段。 +- **默认值不写进文件。**没写的字段取下表中的默认值。应用和命令行只在取值不同于默认值时才写入字段,因此文件里出现的都是有人做出的选择。 +- **`${VAR}` 读取环境变量**,适用于标注「可写 `${VAR}`」的字段:上游密钥、请求头的值、代理密码。取值来自 core 进程的环境,在发送请求时读取;变量未设置时,该上游的请求失败,错误信息指出变量名。在 systemd 下,这个环境就是 unit 的 `EnvironmentFile`。 +- **名字即引用。**规则、策略组、密钥按名字引用上游、策略组、路由和价目表。指向不存在的名字,在加载时就报错,而不是成为一条永不命中的规则。在应用里改名时,所有引用在同一次写入中一起修改。以 `__` 开头的名字保留给内置项。 +- **`version`** 是格式版本,目前为 `1`。版本更高的文件出自更新的 twcore,整份拒绝。 + +## 改动如何生效 + +修改配置有三种途径:桌面应用、`twcore config …` 命令、用编辑器直接修改文件。三者走同一条路径。core 监视配置文件,保存后一秒内重新加载,无需重启。 + +新版本要通过以下每一关才会换入: + +1. 能按 YAML 解析。 +2. 符合结构:字段名已知,类型正确。 +3. 自洽:名字不重复、引用都能找到、正则能编译、CIDR 写法正确。 +4. 能据此建立运行时对象。 + +任何一关失败,**原有配置继续服务**,错误信息说明失败在哪一关、哪个位置。写错一个字不会让网关停下。在保存出合法版本之前,桌面应用会一直显示这次拒绝。 + +`listen.gateway` 的改动同样即时生效:core 打开新的监听;打不开时(例如端口被占用)保留原监听并报告原因。保留期限的改动在下一次每小时的清理时生效。 + +两方同时修改时(应用和手工编辑),后写入的一方因版本不一致被拒绝,不会覆盖先写入的内容。 + +### 历史与回滚 + +每个生效过的版本都保存在配置文件旁边的 `history/` 目录中,并记录来源(应用、命令行、外部编辑、回滚、凭据轮换)。保留最近 50 个版本。 + +```sh +twcore check # 只校验配置,不启动任何服务 +twcore config show # 打印当前配置及其版本 +twcore config history # 列出历史版本,最新的在前 +twcore config rollback 3f9a2c # 回滚到某个版本(写前几位即可) +twcore config set /listen/gateway/port 8790 --int +``` + +这些命令直接操作文件,因此 core 没有运行时也能使用,而那往往正是最需要回滚的时候。正在运行的 core 会像对待其他保存一样接收这些改动。 + +`twcore config set <路径> <值>` 修改文件中已经写出的一个值。路径中的列表项按其 `name` 定位(`/providers/anthropic/base_url`);数字表示下标(`/routes/0/rules/1/to`)。值默认按字符串写入,`--int`、`--bool`、`--null` 另作指定。写入前先校验结果。要添加文件中还没有的字段,请直接编辑文件。 + +### 网关写回的凭据 + +有一种写入不是由人发起的。上游的 OAuth token 端点换发新的 refresh token 后,旧的随即作废,因此网关会把新 token(以及 access token 和过期时间)写回 `providers[].oauth`。只改这几个值,注释和排版保持原样。 + +## 字段参考 + +每张表列出一节的全部字段。「默认值」一栏为「—」表示不写就没有这个字段,其含义见说明。 + +### 顶层 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `version` | 整数 | **必填** | 文件格式的版本,目前只有 `1`。更大的数字说明文件出自更新的 twcore,整份拒绝,不按一知半解的方式读。 | +| `listen` | 对象,见 [`listen`](#cfg-listen) | — | 网关和控制通道在哪里监听。 | +| `clients` | 对象列表,见 [`clients[]`](#cfg-clients) | `[]` | 网关密钥。至少要有一把;`twcore init` 和首次 `twcore serve` 会写入一把名为 `default` 的。 | +| `providers` | 对象列表,见 [`providers[]`](#cfg-providers) | `[]` | 上游。一个都没有也是合法配置:控制面照常运行,请求得到「尚未配置上游」的错误。 | +| `proxies` | 对象列表,见 [`proxies[]`](#cfg-proxies) | `[]` | 出站代理。在这里声明一次,由 `providers[].proxy` 按名字引用。 | +| `pricing` | 对象,见 [`pricing`](#cfg-pricing) | — | 默认价目表是否定期刷新,以及自定义价目表。 | +| `client_probes` | 对象,见 [`client_probes`](#cfg-client_probes) | — | 客户端自行发出的辅助请求(连通性检查、预热、起标题)如何处理。 | +| `security` | 对象,见 [`security`](#cfg-security) | — | 五项防护。出厂时都处在 `observe` 或 `off`,不改变、不拦截任何请求。 | +| `retention` | 对象,见 [`retention`](#cfg-retention) | — | 请求日志保留多久。 | +| `groups` | 对象列表,见 [`groups[]`](#cfg-groups) | `[]` | 策略组:多个上游合用一个名字,并规定如何在其中选择。 | +| `routes` | 对象列表,见 [`routes[]`](#cfg-routes) | `[]` | 路由。一条都不写时,请求按上游的声明顺序故障转移。 | +| `default_route` | 字符串 | — | 未指定路由的密钥走哪条路由。不写:名为 `default` 的路由;没有这条路由时走内置的故障转移。 | +| `default_key` | 字符串 | — | 没有专用密钥的客户端使用哪一把。不写:名为 `default` 的那把,没有则取第一把。这把密钥不能停用。 | + + +### `listen` + +core 在哪里接受连接。连接分两种:客户端发送请求的 AI 网关,以及桌面应用和 `twcore` 命令使用的控制通道。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `gateway` | 对象,见 [`listen.gateway`](#cfg-listen-gateway) | — | AI 网关,即客户端发送请求的地址。 | +| `control` | 对象,见 [`listen.control`](#cfg-listen-control) | — | 控制通道,即桌面应用和 `twcore` 命令连接 core 的途径。其中有控制密钥,因此每份配置都有这一节。 | + + +#### `listen.gateway` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `loopback` | `loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)在启动时解析,地址变了也能跟上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。 | +| `port` | 整数 | `8788` | 网关的 TCP 端口。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 本机以外允许连接的来源,写 CIDR 网段或单个地址。本机始终放行。`[]` 表示只有本机;放行所有来源要明确写 `0.0.0.0/0`。 | + + +`bind` 超出本机范围时,由 `allow_from` 决定允许谁连接。网关不提供 TLS:只在可信的网络中开放,或放在隧道、VPN 之后。 + +```yaml +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] +``` + +#### `listen.control` + +控制通道供桌面应用,以及同一台机器上的 `twcore config`、`twcore control-key` 与 core 通信。本地通道是数据目录中的 socket 文件(Windows 上是回环端口);除非启用 `remote`,控制通道不开任何网络端口。 + +每条控制连接,无论本地还是远程,都以一次握手开始,证明双方都持有 `key`(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`:密钥作为预共享密钥,每条连接协商新的会话密钥,通信内容加密)。不使用证书。不持有密钥的一方无法完成第一条握手消息。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `key` | 字符串 | 自动生成 | 控制密钥:64 个十六进制字符(32 字节)。所有控制连接,无论本地还是远程,都要证明持有这把密钥。缺失时 `twcore serve` 在开始监听前写入一把;格式不对的配置整份拒绝。用 `twcore control-key` 查看,`twcore control-key --rotate` 更换。 | +| `remote` | 对象,见 [`listen.control.remote`](#cfg-listen-control-remote) | — | 供另一台机器上的桌面应用连接的网络端口。它是本地通道之外额外开的,不取代本地通道。 | + + +`key` 缺失时,由 `twcore serve` 在控制通道开始监听之前写入;`twcore init` 生成的配置也带有它。凡是显示配置或写入配置历史的地方,这个字段一律打码;把打码值原样存回时保留原值。密钥缺失或格式不对(不是 64 个十六进制字符)时整份配置无效,因此无法把它换成一把容易猜到的短密钥。 + +```sh +twcore control-key # 显示密钥,用于粘贴到桌面应用 +twcore control-key --rotate # 更换密钥;已连接的应用需要重新连接 +``` + +两条命令都在运行 core 的机器上执行。 + +#### `listen.control.remote` + +供另一台机器上的桌面应用连接的网络端口。它在本地通道之外额外开启,因此这里写错(端口被占用、`allow_from` 把自己挡在外面)也不会把本机锁在门外:`twcore config` 和本机应用照常可用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `enabled` | 布尔 | `false` | 是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。 | +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `all` | 监听哪张网卡,写法同 `listen.gateway.bind`。 | +| `port` | 整数 | 自动生成 | TCP 端口。没有固定默认值:和密钥一样,生成这一节时写入一个随机端口。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 允许连接的来源,写法同 `listen.gateway.allow_from`。其他来源的连接在握手之前关闭,不回任何字节。 | + + +```yaml +listen: + control: + key: 9f2c…e41a # 64 个十六进制字符 + remote: + enabled: true + bind: all + port: 41327 # 随机生成,在生成这一节时写入 + allow_from: [192.168.1.0/24] +``` + +经远程端口的连接无论应用如何请求,都不能做三件事:关闭 core(它由 systemd 管理)、修改 `listen.control`(它进来的那扇门)、生成诊断包(诊断包会写在服务器上)。握手限时 5 秒;同一来源一分钟内握手失败 5 次,封禁一分钟。 + +### `clients` + +网关密钥,即 Claude Code、Codex 等客户端向网关发送的密钥。密钥即身份:并发上限、模型范围、路由都按密钥设置。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 密钥的名字,不能重复。路由规则用 `when.client` 匹配它。 | +| `key` | 字符串 | **必填** | 客户端发送的密钥(放在 `x-api-key` 或 `Authorization: Bearer` 中)。生成的密钥以 `tw-` 开头,以免被误认作上游的密钥。不能重复。 | +| `max_concurrent` | 整数 | — | 用这把密钥同时进行的请求数上限,超出的排队等待。不写:不限。`0` 会被拒绝。 | +| `allow` | 字符串列表 | — | 这把密钥可用的模型,写模型 ID 或通配(`claude-*`)。不写:全部模型。`[]`:一个都不给。 | +| `route` | 字符串 | — | 这把密钥的请求走哪条路由。不写:`default_route`。 | +| `client` | 字符串 | — | 这把密钥是为哪个客户端生成的(`claude-code`、`codex` 等),由桌面应用接管客户端时写入。一个客户端最多一把。 | +| `disabled` | 布尔 | `false` | 拒绝使用这把密钥的所有请求,密钥本身保留。 | + + +```yaml +clients: + - name: default + key: tw-a3f9c8d1e5b2h7k4m6n8p2q4 + - name: build-server + key: tw-q8r2s4t6u8v2w4x6y8z2a4b6 + max_concurrent: 4 + allow: [claude-sonnet-*] + route: cheap +``` + +### `providers` + +上游,即请求被转发到的接口。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 上游的名字,不能重复,也不能和策略组同名。以 `__` 开头的名字保留给内置项。 | +| `base_url` | 字符串 | **必填** | 接口地址,`http://` 或 `https://`,按服务商文档写到版本段为止(`https://api.anthropic.com`、`https://api.openai.com/v1`)。 | +| `key` | 字符串,可写 `${VAR}` | — | API 密钥,放进协议规定的请求头:`x-api-key`(Anthropic)、`Authorization: Bearer`(OpenAI)、`x-goog-api-key`(Gemini)。上游不需要密钥、或凭据写在 `headers` 里时不写。不能和 `oauth` 同时写。 | +| `headers` | 请求头名 → 值的映射 | `{}` | 额外的请求头,按书写顺序发送;值可以用 `${VAR}`,配置了 `oauth` 时可以用 `{{access_token}}`。最多 32 个。HTTP 或网关管理的请求头(`host`、`content-length`、`connection` 等)不能设置。 | +| `oauth` | 对象,见 [`providers[].oauth`](#cfg-providers-oauth) | — | OAuth 凭据:用 refresh token 换取 access token。与 `key` 二选一。 | +| `protocol` | `anthropic` \| `openai-chat` \| `openai-responses` \| `gemini` \| `chatgpt` | — | 上游的接口格式。不写:官方地址按 `base_url` 识别,其余按 `anthropic` 处理。 | +| `proxy` | 字符串 | `direct` | `direct`;`system`,即 core 进程环境变量 `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY` 中的代理;或 `proxies` 中某一项的名字。 | +| `on_proxy_fail` | `fail` \| `direct` | `fail` | 代理不可用时:请求失败(`fail`),或改为直连(`direct`)。 | +| `models` | 字符串列表 | `[]` | 上游不支持 `/v1/models` 时,按这份清单认定它提供的模型。 | +| `models_only` | 字符串列表 | — | 只使用这家的这些模型,写 ID 或通配。范围外的模型不出现在模型列表里,也不会路由到这家。不写:全部。写空列表会被拒绝,暂停使用请用 `disabled`。 | +| `billing` | `per-token` \| `free` | `per-token` | `per-token`:费用为用量乘以所选价目表中的单价,订阅账号同样如此。`free`:费用记为 0。 | +| `pricing` | 字符串 | — | `pricing.sheets` 中某张价目表的名字。不写:默认价目表。 | +| `disabled` | 布尔 | `false` | 不参与路由,模型也不出现在模型列表里;配置原样保留。 | + + +凭据有三种写法:`key`,放进协议规定的请求头;`oauth`,用 refresh token 换取 token;`headers`,用于上游自有的鉴权方式。`headers` 可以和前两者同时使用,但不能再设置已经承载凭据的那个请求头。 + +```yaml +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} + + - name: relay + base_url: https://relay.example.com/v1 + protocol: openai-chat + headers: + X-Relay-Token: ${RELAY_TOKEN} + proxy: office + models_only: [gpt-4.1*, o3] + pricing: relay-discount + + - name: local + base_url: http://127.0.0.1:11434/v1 + protocol: openai-chat + billing: free +``` + +ChatGPT 账号上游(`protocol: chatgpt`)只接受桌面应用登录得到的凭据,不能手写。不支持 Claude 和 Google 的订阅登录,请使用 API 密钥。 + +#### `providers[].oauth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `access` | 字符串 | — | 当前的 access token。每次刷新后由网关写回;不写则在第一次使用时换取。 | +| `expires_at` | 字符串 | — | `access` 的过期时间,RFC 3339(UTC),随 token 一起写回。不写:一直用到上游返回 401。 | +| `refresh` | 字符串 | **必填** | Refresh token。token 端点换发新的之后旧的即作废,因此网关会把新的写回本文件。 | +| `endpoint` | 字符串 | **必填** | token 端点的地址。 | +| `client_id` | 字符串 | — | OAuth 客户端 ID,端点需要时填写。 | +| `client_secret` | 字符串 | — | OAuth 客户端密钥,端点需要时填写。 | +| `refresh_before` | 时长(`30s`、`5m`、`1h`) | — | 提前多久刷新。不写或写法无法识别:`5m`。 | + + +access token 默认放进协议的鉴权请求头。要放在别处,在 `headers` 中写出那个请求头,用 `{{access_token}}` 标出 token 的位置: + +```yaml + oauth: + refresh: ${VENDOR_REFRESH_TOKEN} + endpoint: https://auth.example.com/oauth/token + client_id: my-client + headers: + X-Access: Token {{access_token}} +``` + +### `proxies` + +出站代理。不同上游需要的代理往往不同,因此没有全局开关:由每个上游用 `proxy` 选择。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | `providers[].proxy` 引用的名字。`direct` 和 `system` 是内置的。 | +| `type` | `socks5` \| `socks5h` \| `http` \| `https` | `socks5h` | `socks5h` 把域名交给代理解析;`socks5` 先在本地解析。`http` 和 `https` 是 HTTP 代理。 | +| `addr` | 字符串 | **必填** | 代理的 `host:port`。 | +| `auth` | 对象,见 [`proxies[].auth`](#cfg-proxies-auth) | — | 代理需要时填写用户名和密码。 | + + +#### `proxies[].auth` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `user` | 字符串 | **必填** | 用户名。 | +| `pass` | 字符串,可写 `${VAR}` | **必填** | 密码。 | + + +```yaml +proxies: + - name: office + type: http + addr: proxy.example.com:3128 + auth: + user: alice + pass: ${PROXY_PASSWORD} +``` + +`on_proxy_fail` 默认为 `fail`:静默改为直连会让请求走一条意料之外的路径,而使用者仍以为请求经过了代理。 + +### `pricing` + +一次请求的费用为用量乘以模型单价。单价来自默认价目表(LiteLLM 的公开数据集,程序内置一份,每天联网刷新),或来自上游选用的自定义价目表。单价变动只影响此后的请求,不改变已记录请求的费用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `auto_update` | 布尔 | `true` | 每天联网刷新一次默认价目表,保存为 `config.yaml` 旁边的 `model_prices.json`;此前以及离线时使用内置于程序中的价目表。 | +| `sheets` | 对象列表,见 [`pricing.sheets[]`](#cfg-pricing-sheets) | `[]` | 自定义价目表。上游用 `providers[].pricing` 选用。 | + + +#### `pricing.sheets` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 价目表的名字,不能重复。 | +| `multiplier` | 数字 | `1` | 作用于默认价目表的全部单价,包括缓存和长上下文单价。 | +| `models` | 映射: 模型 ID → [`pricing.sheets[].models.*`](#cfg-pricing-sheets-models) | `{}` | 单独定价的模型。它们取代默认价目表中该模型的单价,不乘倍率。 | + + +#### `pricing.sheets[].models` + +单价以每百万 token 的美元计,与厂商价格页上的写法一致。每个字段都要写明,计价时不做任何推算。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `input` | 数字 | **必填** | 每百万输入 token 的美元价格。 | +| `output` | 数字 | **必填** | 每百万输出 token 的美元价格。 | +| `cache_read` | 数字 | **必填** | 每百万缓存读取 token 的美元价格。 | +| `cache_write_5m` | 数字 | **必填** | 每百万写入 5 分钟缓存 token 的美元价格。 | +| `cache_write_1h` | 数字 | **必填** | 每百万写入 1 小时缓存 token 的美元价格。 | +| `input_above_200k` | 数字 | — | 单次请求输入超过 200K token 后的输入单价。与 `output_above_200k` 同时写或都不写。 | +| `output_above_200k` | 数字 | — | 单次请求输入超过 200K token 后的输出单价。 | + + +```yaml +pricing: + sheets: + - name: relay-discount + multiplier: 0.8 + models: + claude-sonnet-4-5-thinking: + input: 3 + output: 15 + cache_read: 0.3 + cache_write_5m: 3.75 + cache_write_1h: 6 +``` + +### `client_probes` + +客户端发出的请求中,有一部分并非出自使用者:连通性检查、预热、会话标题、话题检测、建议。每一类都可以在本地应答(`intercept`,不向上游发送任何内容)、原样放行(`passthrough`),或交给路由规则(`route`,由 `when.intent` 匹配)。默认只拦下拦了也不会少任何东西的那几类。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `health_check` | `intercept` \| `passthrough` \| `route` | `intercept` | 连通性检查(`max_tokens: 1`)。默认在本地应答,不影响任何功能。 | +| `warmup` | `intercept` \| `passthrough` \| `route` | `intercept` | 预热请求。默认在本地应答。 | +| `titling` | `intercept` \| `passthrough` \| `route` | `passthrough` | 为会话起标题的请求。默认放行:拦下后所有会话都会是同一个标题。 | +| `topic_detect` | `intercept` \| `passthrough` \| `route` | `passthrough` | 话题检测。默认放行。 | +| `suggestion` | `intercept` \| `passthrough` \| `route` | `passthrough` | 建议。默认放行。 | + + +### `security` + +五项防护,对所有上游一视同仁。每一项都有 `mode`:`off`、`observe`(检测并记录,不改变任何行为)、`enforce`(处置)。出厂时除输出长度为 `off` 外,其余都是 `observe`。各项在 `enforce` 下的处置不同,分别见下文。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `redact` | 对象,见 [`security.redact`](#cfg-security-redact) | — | 出站脱敏:请求发出前,把其中的凭据替换掉。 | +| `inspect_tools` | 对象,见 [`security.inspect_tools`](#cfg-security-inspect_tools) | — | 工具调用审查:模型返回的工具调用中出现危险命令时切断响应。 | +| `hidden_text` | 对象,见 [`security.hidden_text`](#cfg-security-hidden_text) | — | 人看不见、模型读得到的隐藏字符,出现时拒绝请求。 | +| `content` | 对象,见 [`security.content`](#cfg-security-content) | — | 内容过滤:调用方发送的内容中出现指定的词或写法时拒绝请求。 | +| `output_limit` | 对象,见 [`security.output_limit`](#cfg-security-output_limit) | — | 输出长度:回答超过上限时切断。 | + + +#### `security.redact` + +请求发出前查找其中的凭据。`enforce` 下将其替换。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `custom` | 对象列表,见 [`security.redact.custom[]`](#cfg-security-redact-custom) | `[]` | 自定义规则:正则匹配到的内容按凭据处理。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 出厂 | +|---|---|---| +| `anthropic-api-key` | Anthropic API key | 开 | +| `openai-project-key` | OpenAI project key | 开 | +| `openai-api-key` | OpenAI API key | 开 | +| `github-personal-token` | GitHub personal access token | 开 | +| `github-oauth-token` | GitHub OAuth token | 开 | +| `github-server-token` | GitHub server token | 开 | +| `github-user-token` | GitHub user token | 开 | +| `github-fine-grained-token` | GitHub fine-grained token | 开 | +| `slack-bot-token` | Slack bot token | 开 | +| `slack-user-token` | Slack user token | 开 | +| `slack-app-token` | Slack app token | 开 | +| `aws-access-key-id` | AWS access key ID | 开 | +| `aws-temporary-key-id` | AWS temporary access key ID | 开 | +| `google-api-key` | Google API key | 开 | +| `google-oauth-token` | Google OAuth token | 开 | +| `gitlab-token` | GitLab token | 开 | +| `stripe-live-key` | Stripe live key | 开 | +| `stripe-restricted-key` | Stripe restricted key | 开 | +| `npm-token` | npm token | 开 | +| `digitalocean-token` | DigitalOcean token | 开 | +| `sendgrid-key` | SendGrid key | 开 | +| `private-key` | Private key | 开 | +| `jwt` | JWT | 开 | +| `conn-string-password` | Connection string password | 开 | +| `internal-ip` | Internal IP address | 关 | +| `internal-domain` | Internal domain | 关 | + + +#### `security.inspect_tools` + +按规则检查模型返回的工具调用。`enforce` 下命中处置为 `cut` 的规则时切断响应,客户端拿不到可执行的完整调用。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `cut` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的(`rm-rf-root: record`)。 | +| `custom` | 对象列表,见 [`security.inspect_tools.custom[]`](#cfg-security-inspect_tools-custom) | `[]` | 自定义规则,按工具调用的参数匹配。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 正则表达式。 | +| `action` | `cut` \| `record` | `record` | `enforce` 下切断响应(`cut`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | `enforce` 下出厂处置 | +|---|---|---| +| `curl-pipe-sh` | Download and run | `cut` | +| `base64-decode-exec` | Decode and run | `cut` | +| `exfil-env` | Send out environment variables | `cut` | +| `exfil-credentials` | Send out a credential file | `cut` | +| `exfil-credentials-reversed` | Send out a credential file (verb first) | `cut` | +| `ssh-key-read` | Read a private key or cloud credential | `cut` | +| `write-startup-item` | Write a startup item | `cut` | +| `crontab-install` | Install a scheduled job | `cut` | +| `rm-rf-root` | Delete home or root | `record` | +| `chmod-777` | World-writable permissions | `record` | + + +#### `security.hidden_text` + +调用方发送的内容中(包括工具结果)人看不见、模型读得到的字符。`enforce` 下拒绝请求。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `disable` | 字符串列表 | `[]` | 不检查的种类:`tag`、`bidi`。 | + + + +| 种类 | 说明 | +|---|---| +| `tag` | Unicode 标签字符(U+E0000 至 U+E007F):在任何地方都不可见,模型却能读到,足以藏下一整段指令。 | +| `bidi` | 双向控制符:使显示顺序与模型读到的顺序不一致。 | + + +#### `security.content` + +调用方发送的内容中出现的词或写法。`enforce` 下命中处置为 `block` 的规则时拒绝请求。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `observe` | `off` 不检测;`observe` 检测并记录,不改变任何行为;`enforce` 检测并处置。 | +| `enable` | 字符串列表 | `[]` | 打开出厂时关着的内置规则,按 id。 | +| `disable` | 字符串列表 | `[]` | 关掉内置规则,按 id。 | +| `actions` | 映射: 内置规则 id → `block` \| `record` | `{}` | 内置规则在 `enforce` 下的处置,只写与出厂不同的。 | +| `custom` | 对象列表,见 [`security.content.custom[]`](#cfg-security-content-custom) | `[]` | 自定义规则。 | + + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和应用里显示的名字,也是规则的标识;同一项防护里不能重名。 | +| `pattern` | 字符串 | **必填** | 关键词;`match: regex` 时为正则表达式。均不区分大小写。 | +| `match` | `contains` \| `regex` | `contains` | `contains`:正文包含 `pattern`。`regex`:`pattern` 是正则表达式。 | +| `action` | `block` \| `record` | `record` | `enforce` 下拒绝请求(`block`),或只记录(`record`)。 | +| `disabled` | 布尔 | `false` | 停用这条规则,规则本身留在文件里。 | + + +内置规则: + + +| id | 名称 | 分组 | 出厂 | `enforce` 下出厂处置 | +|---|---|---|---|---| +| `ignore-previous-instructions` | Ignore previous instructions | injection | 开 | `block` | +| `ignore-all-previous` | Ignore all previous | injection | 开 | `block` | +| `disregard-your-instructions` | Disregard your instructions | injection | 开 | `block` | +| `jailbreak` | Jailbreak | injection | 关 | `block` | +| `dan` | DAN | injection | 关 | `block` | +| `developer-mode` | Developer mode | injection | 关 | `block` | +| `you-are-now` | Persona manipulation | persona | 关 | `block` | +| `new-persona` | New persona | persona | 关 | `record` | +| `act-as` | Act as | persona | 关 | `record` | +| `pretend-to-be` | Pretend to be | persona | 关 | `record` | +| `system-prompt` | System prompt extraction | persona | 关 | `record` | +| `reveal-your-instructions` | Reveal instructions | persona | 关 | `record` | +| `what-are-your-rules` | What are your rules | persona | 关 | `record` | +| `base64-wall` | Base64 smuggling | persona | 关 | `record` | +| `zh-ignore-previous` | Ignore previous instructions (Chinese) | chinese | 关 | `block` | +| `zh-forget-your` | Forget your instructions (Chinese) | chinese | 关 | `block` | +| `zh-do-not-follow` | Do not follow (Chinese) | chinese | 关 | `block` | +| `zh-you-are-now` | You are now (Chinese) | chinese | 关 | `block` | +| `zh-role-play` | Role-play (Chinese) | chinese | 关 | `record` | +| `zh-reveal-your` | Reveal your instructions (Chinese) | chinese | 关 | `record` | +| `zh-system-prompt` | System prompt (Chinese) | chinese | 关 | `record` | +| `zh-jailbreak` | Jailbreak (Chinese) | chinese | 关 | `block` | + + +#### `security.output_limit` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `mode` | `off` \| `observe` \| `enforce` | `off` | 出厂关闭:没有一个上限适合所有用途。`observe` 记录超长的回答;`enforce` 在超过上限处停止输出。 | +| `max_chars` | 整数 | `100000` | 上限,按字符(Unicode 标量)计,取值 1 到 1000000。 | + + +```yaml +security: + redact: + mode: enforce + enable: [internal-ip] + custom: + - name: employee-id + pattern: 'EMP-\d{6}' + inspect_tools: + mode: enforce + output_limit: + mode: enforce + max_chars: 200000 +``` + +### `retention` + +设两个期限,是因为两类数据的体积相差三个数量级:一条请求的正文有几十 KB,一条请求记录只有几百字节。字节上限用于应对用量突增。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `body_days` | 整数 | `7` | 请求和响应正文保留的天数。 | +| `row_days` | 整数 | `90` | 每条请求记录(时间、模型、用量、费用)保留的天数。 | +| `body_max_bytes` | 整数 | `2147483648` | 正文最多占用的字节数,超出时从最早的日期开始删除。默认 2 GiB。 | + + +### `groups` + +策略组让多个上游合用一个名字。规则用 `to` 把请求交给策略组。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 策略组的名字,不能重复,也不能和上游同名。 | +| `type` | `fallback` \| `select` \| `load-balance` \| `url-test` \| `cheapest` | `fallback` | `fallback`:按顺序取第一个健康的。`select`:取 `selected` 指定的那个。`load-balance`:轮流。`url-test`:按实测首字节时间取最快的。`cheapest`:取输入单价最低的。 | +| `providers` | 字符串列表 | **必填** | 成员上游的名字。 | +| `session_affinity` | 布尔 | `true` | 同一会话固定走同一家,使 prompt cache 持续命中。在 `load-balance` 下关闭会让每一轮都换一家,缓存随之失效。 | +| `selected` | 字符串 | — | `select` 类型选中的成员。 | + + +默认类型为 `fallback`:把一个会话分散到多家上游会丢掉 prompt cache,而在单个使用者的机器上,分散负载换来的远不及缓存省下的。 + +### `routes` + +一条路由是一组自上而下求值的规则。每把密钥使用其 `route` 指定的路由;没有指定时用 `default_route`;再没有时用名为 `default` 的路由;一条路由都没有时,请求按上游的声明顺序故障转移。 + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 路由的名字,不能重复。`default` 是密钥默认使用的那条。 | +| `rules` | 对象列表,见 [`routes[].rules[]`](#cfg-routes-rules) | `[]` | 自上而下求值;第一条匹配且带有 `to` 或 `deny` 的规则决定请求去向。 | + + +#### `routes[].rules` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `name` | 字符串 | **必填** | 日志和流量详情中显示的名字。 | +| `when` | 对象,见 [`routes[].rules[].when`](#cfg-routes-rules-when) | — | 条件,须全部满足。不写:匹配所有请求。 | +| `to` | 字符串 | — | 上游或策略组的名字;`__all__` 表示按声明顺序的全部上游。不能与 `when.provider_would_be` 同时写。 | +| `set` | 对象,见 [`routes[].rules[].set`](#cfg-routes-rules-set) | — | 改写请求参数。从所有匹配的规则累积,不只第一条。 | +| `deny` | 字符串 | — | 以这句原因拒绝请求。 | + + +#### `routes[].rules[].when` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 请求的模型,可用通配(`claude-opus-*`)。 | +| `client` | 字符串 | — | 请求所用网关密钥的名字,精确匹配。 | +| `dialect` | 字符串 | — | 客户端使用的接口格式:`anthropic`、`openai-chat`、`openai-responses`、`gemini`。 | +| `input_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 估算的输入 token 数。 | +| `max_tokens` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中的 `max_tokens`。未写该参数的请求不匹配。 | +| `tool_count` | 比较式(`>200k`、`<=4k`、`==3`) | — | 请求中提供的工具数量。 | +| `cache` | 布尔 | — | 请求是否使用 prompt cache。 | +| `tools` | 布尔 | — | 请求是否带工具。 | +| `image` | 布尔 | — | 请求是否包含图片。 | +| `thinking` | 布尔 | — | 是否开启扩展思考。 | +| `stream` | 布尔 | — | 是否流式返回。 | +| `intent` | 字符串或字符串列表 | — | 客户端的辅助请求:`assistant_internal` 表示任意一类,也可以写具体的一类(`titling`)。只有在 `client_probes` 中设为 `route` 的类别才会进入路由。 | +| `provider_would_be` | 字符串或字符串列表 | — | 路由选中的上游。这类规则在路由完成后求值,只能 `set` 或 `deny`,不能写 `to`。 | + + +比较式以 `>`、`>=`、`<`、`<=` 或 `==` 开头,数字可以带 `k` 或 `m` 后缀:`">200k"`、`"<=4k"`。不带运算符是错误,不当作相等:单写 `"200k"` 会被拒绝。 + +#### `routes[].rules[].set` + + + + +| 字段 | 类型 | 默认值 | 说明 | +|---|---|---|---| +| `model` | 字符串 | — | 换成另一个模型发送。该会话的 prompt cache 随之失效。 | +| `max_tokens` | 整数 | — | 替换 `max_tokens`。 | +| `thinking` | 布尔 | — | 开启或关闭扩展思考。 | +| `only_at_session_start` | 布尔 | `false` | 只在会话开始时应用。目前只记录和显示,尚未生效。 | + + +```yaml +groups: + - name: fast + type: url-test + providers: [anthropic, relay] + +routes: + - name: default + rules: + - name: 长上下文走官方 + when: { input_tokens: ">200k" } + to: anthropic + - name: 标题用便宜模型 + when: { intent: titling } + set: { model: claude-haiku-4-5 } + - name: 其余 + to: fast +default_route: default +``` + +## 环境变量 + +| 变量 | 作用 | +|---|---| +| `THINKWATCH_HOME` | 数据目录,替代 `~/.thinkwatch`(Windows 上为 `%APPDATA%\ThinkWatch`)。 | +| `TWCORE_LOG` | 日志过滤,`tracing` 语法(`info`、`debug`、`tw_gateway=debug`)。 | +| `HTTPS_PROXY`、`HTTP_PROXY`、`ALL_PROXY`、`NO_PROXY` | 供 `proxy: system` 的上游使用。 | +| 其他 | 配置中写 `${NAME}` 的位置读取。 | diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000..d6174ac --- /dev/null +++ b/docs/server.md @@ -0,0 +1,206 @@ +# Running core on a server + +[中文](server.zh-CN.md) + +ThinkWatch Core runs without a desktop: on a Linux machine it is started by +systemd from its configuration file, and the ThinkWatch Lite app on a Mac +connects to it over the network to show traffic and change settings. Clients +anywhere on the network send their requests to the server's gateway. + +This page covers installing, configuring, starting, connecting and +upgrading. Every field mentioned is described in the +[configuration reference](config.md). + +## Requirements + +- Linux on x86_64 or aarch64, with glibc 2.35 or newer (Ubuntu 22.04, + Debian 12, or later). +- systemd. +- The server's core version has to match the desktop app's. The app checks + this when it connects and shows both versions if they differ. + +## 1. Install + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +To install a particular version, the one your desktop app expects: + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.47.0 +``` + +The script: + +1. downloads `twcore--unknown-linux-gnu.tar.gz` from the GitHub + release and checks it against the release's SHA-256 sum; +2. installs the binary as `/usr/local/bin/twcore`; +3. creates the system user `thinkwatch` and the data directory + `/var/lib/thinkwatch` (mode `0700`); +4. installs `/etc/systemd/system/twcore.service` and an empty + `/etc/thinkwatch/env`; +5. runs `twcore init` as `thinkwatch` if there is no configuration yet; +6. prints the next steps. It does not start the service. + +Running it again is safe: it replaces the binary and the unit, and leaves +the configuration, the environment file and the data alone. For later +upgrades, `twcore upgrade` is the shorter way (see below). + +To install by hand instead, download the tarball and its `.sha256` from the +[releases page](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases), +check it with `sha256sum -c`, and follow the steps above; the unit file is +in the tarball and in [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service). + +Every `twcore` command that reads the configuration has to run as the +service user with the service's data directory. The examples below spell +that out; a shell alias saves typing: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. Configure + +Open `/var/lib/thinkwatch/config.yaml` as root (`sudoedit` works) and change +three things: + +1. **Let clients on the network reach the gateway**: `listen.gateway.bind: + all`, and list their networks in `listen.gateway.allow_from`. +2. **Open the remote control port**: `listen.control.remote.enabled: true`, + and list the networks the desktop app connects from in its + `allow_from`. `twcore init` writes this section with a random port and + `enabled: false`; if your file has no `remote` section, add one with any + free port. +3. **Add at least one upstream** under `providers`, or add it later from the + desktop app. + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # written by twcore init; leave it as it is + remote: + enabled: true + bind: all + port: 41327 # written by twcore init + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # written by twcore init +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +Check the result without starting anything: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### Secrets in the environment + +`${NAME}` in the configuration reads the environment of the core process. +Under systemd that is `/etc/thinkwatch/env`, one `NAME=value` per line: + +```sh +sudoedit /etc/thinkwatch/env # created by the installer: root:thinkwatch, 0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +The file is read when the service starts: after changing it, restart the +service. Proxy variables here are what `proxy: system` uses. + +### Network + +Neither port has TLS. The control port is encrypted and authenticated by +its handshake; the gateway port carries requests in plain HTTP, like any +local model server. Keep both reachable only from networks you trust: set +`allow_from`, and open the two ports in the server's firewall to those +networks only. For access from outside, use a VPN or an SSH tunnel rather +than exposing the ports. + +## 3. Start + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +The unit runs core as `thinkwatch`, restarts it if it fails, and keeps it +out of the rest of the file system: it can write only its data directory. + +Configuration changes need no restart. Core reloads the file within a +second of a save, from an editor, `twcore config`, or the desktop app; a +change that does not validate is refused and the previous configuration +keeps serving. + +## 4. Connect the desktop app + +Show the control key on the server: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +In the desktop app, open **Settings → Connections → Add remote connection** +and enter: + +- **Address**: the server's host name or IP address; +- **Control port**: `listen.control.remote.port`; +- **Key**: the 64 characters `twcore control-key` printed. + +The app tests the connection before saving and says what is wrong if it +fails: no answer (address, port, firewall, `enabled`), connection closed +(this Mac's address is probably not in `allow_from`), wrong key, or +different versions. + +The key is kept in the Mac's keychain. To replace it, run +`twcore control-key --rotate` on the server; connected apps then have to be +given the new key. + +### Point clients at the server + +Clients use the server's gateway, `http://:8788`, with a gateway key +from `clients`. The desktop app can point the clients on the Mac at the +server (Clients page); on other machines, configure them by hand. + +## Upgrading + +```sh +sudo twcore upgrade --check # compare with the latest release, change nothing +sudo twcore upgrade --restart # install the latest release and restart the service +sudo twcore upgrade --version 0.48.0 --restart +``` + +`twcore upgrade` downloads the release for this machine, checks its +SHA-256 sum, and replaces `/usr/local/bin/twcore` in one step, so a failed +download never leaves a broken binary. The configuration and the data are +not touched. Without `--restart` it prints the command to restart the +service; the running process keeps the old version until then. + +Upgrade the server and the desktop app together: the app refuses to connect +to a core of another version and shows the command above with the version +it needs. + +## Uninstalling + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# The configuration, keys and request history: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/docs/server.zh-CN.md b/docs/server.zh-CN.md new file mode 100644 index 0000000..05432b5 --- /dev/null +++ b/docs/server.zh-CN.md @@ -0,0 +1,157 @@ +# 在服务器上运行 core + +[English](server.md) + +ThinkWatch Core 可以脱离桌面运行:在 Linux 机器上由 systemd 按配置文件启动,Mac 上的 ThinkWatch Lite 通过网络连接它,查看流量、修改设置。网络中各处的客户端把请求发往服务器的网关。 + +本文依次说明安装、配置、启动、连接和升级。文中提到的每个字段,详见[配置手册](config.zh-CN.md)。 + +## 要求 + +- x86_64 或 aarch64 的 Linux,glibc 2.35 或更新(Ubuntu 22.04、Debian 12 及以后)。 +- systemd。 +- 服务器上的 core 版本须与桌面应用一致。应用在连接时核对版本,不一致时显示双方的版本号。 + +## 1. 安装 + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +``` + +安装指定版本(即桌面应用要求的版本): + +```sh +curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.47.0 +``` + +安装脚本依次: + +1. 从 GitHub Release 下载 `twcore-<架构>-unknown-linux-gnu.tar.gz`,并用 Release 中的 SHA-256 校验; +2. 把程序安装为 `/usr/local/bin/twcore`; +3. 创建系统用户 `thinkwatch` 和数据目录 `/var/lib/thinkwatch`(权限 `0700`); +4. 安装 `/etc/systemd/system/twcore.service`,以及空的 `/etc/thinkwatch/env`; +5. 还没有配置时,以 `thinkwatch` 身份执行 `twcore init`; +6. 打印后续步骤。脚本不启动服务。 + +脚本可以重复执行:它替换程序和 unit 文件,不动配置、环境变量文件和数据。之后升级用 `twcore upgrade` 更简便(见下文)。 + +也可以手动安装:从 [Releases 页面](https://github.com/ThinkWatchProject/ThinkWatch-Core/releases)下载压缩包及其 `.sha256`,用 `sha256sum -c` 校验,再按上述步骤操作;unit 文件在压缩包中,也在 [`packaging/systemd/twcore.service`](../packaging/systemd/twcore.service)。 + +读取配置的 `twcore` 命令,都要以服务用户的身份、带上服务的数据目录执行。下文的示例都写全了;可以用一个 shell 别名简化: + +```sh +alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' +``` + +## 2. 配置 + +以 root 身份打开 `/var/lib/thinkwatch/config.yaml`(可用 `sudoedit`),修改三处: + +1. **让网络中的客户端能访问网关**:`listen.gateway.bind: all`,并在 `listen.gateway.allow_from` 中列出客户端所在的网段。 +2. **打开远程控制端口**:`listen.control.remote.enabled: true`,并在其 `allow_from` 中列出桌面应用所在的网段。`twcore init` 生成的配置带有这一节,端口随机,`enabled: false`;文件中没有 `remote` 这一节时,自行添加,端口任选一个空闲的。 +3. **在 `providers` 下添加至少一个上游**,也可以之后在桌面应用中添加。 + +```yaml +version: 1 +listen: + gateway: + bind: all + port: 8788 + allow_from: [192.168.1.0/24] + control: + key: 9f2c…e41a # twcore init 生成,保持原样 + remote: + enabled: true + bind: all + port: 41327 # twcore init 生成 + allow_from: [192.168.1.0/24] +clients: + - name: default + key: tw-… # twcore init 生成 +providers: + - name: anthropic + base_url: https://api.anthropic.com + key: ${ANTHROPIC_API_KEY} +``` + +只校验、不启动: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore check +``` + +### 用环境变量存放密钥 + +配置中的 `${NAME}` 读取 core 进程的环境变量。在 systemd 下,环境变量来自 `/etc/thinkwatch/env`,每行一个 `NAME=value`: + +```sh +sudoedit /etc/thinkwatch/env # 由安装脚本创建:root:thinkwatch,0640 +``` + +```ini +ANTHROPIC_API_KEY=sk-ant-… +HTTPS_PROXY=http://proxy.example.com:3128 +``` + +这个文件在服务启动时读取,修改后需要重启服务。其中的代理变量就是 `proxy: system` 使用的代理。 + +### 网络 + +两个端口都没有 TLS。控制端口由握手完成加密和鉴权;网关端口以明文 HTTP 传输请求,和本地模型服务一样。两个端口都只应对可信的网络开放:设置 `allow_from`,并在服务器防火墙中只对这些网段开放这两个端口。需要从外部访问时,使用 VPN 或 SSH 隧道,不要直接暴露端口。 + +## 3. 启动 + +```sh +sudo systemctl enable --now twcore +systemctl status twcore +journalctl -u twcore -f +``` + +unit 以 `thinkwatch` 身份运行 core,失败后自动重启,并把它与文件系统的其余部分隔开:它只能写自己的数据目录。 + +修改配置不需要重启。无论通过编辑器、`twcore config` 还是桌面应用保存,core 都会在一秒内重新加载;未通过校验的改动被拒绝,原有配置继续服务。 + +## 4. 连接桌面应用 + +在服务器上查看控制密钥: + +```sh +sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key +``` + +在桌面应用中打开 **设置 → 连接 → 添加远程连接**,填写: + +- **地址**:服务器的主机名或 IP 地址; +- **控制端口**:`listen.control.remote.port` 的值; +- **密钥**:`twcore control-key` 输出的 64 个字符。 + +应用在保存前先试连,失败时说明原因:无响应(检查地址、端口、防火墙和 `enabled`)、连接被关闭(本机地址可能不在 `allow_from` 中)、密钥不正确、版本不一致。 + +密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`,之后已连接的应用需要填入新密钥。 + +### 让客户端指向服务器 + +客户端使用服务器的网关 `http://<服务器>:8788`,以及 `clients` 中的一把网关密钥。桌面应用可以把这台 Mac 上的客户端改为指向服务器(客户端页);其他机器上的客户端需手动配置。 + +## 升级 + +```sh +sudo twcore upgrade --check # 与最新版本比较,不做任何改动 +sudo twcore upgrade --restart # 安装最新版本并重启服务 +sudo twcore upgrade --version 0.48.0 --restart +``` + +`twcore upgrade` 下载适合本机的版本,校验 SHA-256,一步替换 `/usr/local/bin/twcore`,下载失败也不会留下损坏的程序。配置和数据不受影响。不带 `--restart` 时只打印重启服务的命令;在重启之前,运行中的进程仍是旧版本。 + +服务器和桌面应用要一起升级:版本不一致时应用拒绝连接,并显示上面的命令及所需的版本。 + +## 卸载 + +```sh +sudo systemctl disable --now twcore +sudo rm /etc/systemd/system/twcore.service /usr/local/bin/twcore +sudo systemctl daemon-reload +# 配置、密钥和请求历史: +sudo rm -r /var/lib/thinkwatch /etc/thinkwatch +sudo userdel thinkwatch +``` diff --git a/packaging/systemd/twcore.service b/packaging/systemd/twcore.service new file mode 100644 index 0000000..76c00fe --- /dev/null +++ b/packaging/systemd/twcore.service @@ -0,0 +1,67 @@ +# ThinkWatch Core as a system service. +# +# Installed by scripts/install.sh as /etc/systemd/system/twcore.service. +# Setup and the configuration it reads: docs/server.md. +# +# Core keeps everything in its data directory, /var/lib/thinkwatch +# (THINKWATCH_HOME): config.yaml, the request database, the history and the +# local control socket. It is the only place the service can write. + +[Unit] +Description=ThinkWatch Core (AI gateway) +Documentation=https://github.com/ThinkWatchProject/ThinkWatch-Core/blob/main/docs/server.md +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=thinkwatch +Group=thinkwatch +Environment=THINKWATCH_HOME=/var/lib/thinkwatch +# ${VAR} in config.yaml reads this environment: upstream keys, proxy +# passwords, HTTPS_PROXY for `proxy: system`. Optional (the leading -). +EnvironmentFile=-/etc/thinkwatch/env +ExecStart=/usr/local/bin/twcore serve +# Core reloads config.yaml on its own; a restart is needed only for a new +# binary or a changed environment file. +Restart=on-failure +RestartSec=2s +# SIGTERM lets requests in flight finish and the database close cleanly. +KillSignal=SIGTERM +TimeoutStopSec=30s + +# Creates /var/lib/thinkwatch owned by the service user, private to it. +StateDirectory=thinkwatch +StateDirectoryMode=0700 +UMask=0077 + +# Hardening. Core needs the network, its data directory, and nothing else. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +ProtectClock=true +ProtectHostname=true +ProtectProc=invisible +RestrictSUIDSGID=true +RestrictRealtime=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged +# AF_UNIX for the local control socket, AF_NETLINK to look up an +# interface named in `bind`. +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +# Ports above 1024 need no capability. To listen on a lower one, set both +# AmbientCapabilities= and CapabilityBoundingSet= to CAP_NET_BIND_SERVICE. +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..b2631ad --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,206 @@ +#!/bin/sh +# Install ThinkWatch Core on a Linux server, as a systemd service. +# +# curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh +# ... | sudo sh -s -- --version 0.47.0 +# +# What it does, and the rest of the setup: docs/server.md. +# +# Running it again is safe. The binary and the unit are replaced; the +# configuration, /etc/thinkwatch/env and the data are never touched. The +# service is not started or restarted: that is left to the person running it. +# +# Options: +# --version X.Y.Z install this release instead of the latest +# --archive FILE install from a downloaded twcore-.tar.gz; its +# FILE.sha256 is checked when it is next to it +# +# POSIX sh on purpose: it runs before anything else is installed. + +set -eu + +REPO="ThinkWatchProject/ThinkWatch-Core" +BIN_DIR="/usr/local/bin" +USER_NAME="thinkwatch" +DATA_DIR="/var/lib/thinkwatch" +ETC_DIR="/etc/thinkwatch" +UNIT="/etc/systemd/system/twcore.service" + +say() { printf '%s\n' "$*"; } +die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; } + +VERSION="" +ARCHIVE="" +while [ $# -gt 0 ]; do + case "$1" in + --version) [ $# -ge 2 ] || die "--version needs a value"; VERSION="${2#v}"; shift 2 ;; + --version=*) VERSION="${1#--version=}"; VERSION="${VERSION#v}"; shift ;; + --archive) [ $# -ge 2 ] || die "--archive needs a file"; ARCHIVE="$2"; shift 2 ;; + --archive=*) ARCHIVE="${1#--archive=}"; shift ;; + -h|--help) + say "usage: install.sh [--version X.Y.Z] [--archive twcore-.tar.gz]" + say "Installs twcore into $BIN_DIR and a systemd unit. Guide: docs/server.md" + exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +# ── Where we are ────────────────────────────────────────────────────── + +[ "$(uname -s)" = Linux ] || die "this script installs the Linux server build. On macOS and Windows, core comes with the desktop app." +[ "$(id -u)" -eq 0 ] || die "run it as root (with sudo): it installs into $BIN_DIR and creates a system user." + +case "$(uname -m)" in + x86_64|amd64) TARGET="x86_64-unknown-linux-gnu" ;; + aarch64|arm64) TARGET="aarch64-unknown-linux-gnu" ;; + *) die "there is no build for $(uname -m); builds exist for x86_64 and aarch64." ;; +esac +ASSET="twcore-$TARGET.tar.gz" + +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1" | cut -d' ' -f1; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1" | cut -d' ' -f1; } +else + die "neither sha256sum nor shasum is installed, so the download cannot be verified." +fi + +fetch() { # URL FILE + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "$2" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$2" "$1" + else + die "neither curl nor wget is installed." + fi +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT INT TERM + +# ── Get the archive and check it ────────────────────────────────────── + +if [ -n "$ARCHIVE" ]; then + [ -f "$ARCHIVE" ] || die "$ARCHIVE does not exist." + cp "$ARCHIVE" "$TMP/$ASSET" + if [ -f "$ARCHIVE.sha256" ]; then + cp "$ARCHIVE.sha256" "$TMP/$ASSET.sha256" + else + say "note: no $ARCHIVE.sha256 next to the archive; it is installed unverified." + fi +else + if [ -n "$VERSION" ]; then + BASE="https://github.com/$REPO/releases/download/v$VERSION" + else + BASE="https://github.com/$REPO/releases/latest/download" + fi + say "downloading $ASSET (${VERSION:-latest release})" + fetch "$BASE/$ASSET" "$TMP/$ASSET" \ + || die "could not download $BASE/$ASSET. Check the version, and that this machine reaches github.com." + fetch "$BASE/$ASSET.sha256" "$TMP/$ASSET.sha256" \ + || die "could not download $BASE/$ASSET.sha256." +fi + +if [ -f "$TMP/$ASSET.sha256" ]; then + WANT=$(cut -d' ' -f1 "$TMP/$ASSET.sha256") + GOT=$(sha256 "$TMP/$ASSET") + [ "$WANT" = "$GOT" ] || die "the SHA-256 of $ASSET is $GOT, and the release says $WANT. Nothing was installed." + say "SHA-256 checked: $GOT" +fi + +tar -xzf "$TMP/$ASSET" -C "$TMP" || die "$ASSET could not be unpacked." +SRC="$TMP/twcore-$TARGET" +[ -f "$SRC/twcore" ] || die "$ASSET does not contain twcore-$TARGET/twcore." + +# Does it run here? A glibc older than the build's shows up now rather than +# as a service that never starts. +NEW_VERSION=$("$SRC/twcore" --version 2>&1) \ + || die "the downloaded twcore does not run on this machine: $NEW_VERSION (it needs glibc 2.35 or newer)." + +# ── Install ─────────────────────────────────────────────────────────── + +# Next to the old one and renamed over it: a running service keeps its +# binary, and there is never a half-written file at the real path. +install -d -m 0755 "$BIN_DIR" +install -m 0755 "$SRC/twcore" "$BIN_DIR/.twcore.new" +mv -f "$BIN_DIR/.twcore.new" "$BIN_DIR/twcore" +say "installed $BIN_DIR/twcore ($NEW_VERSION)" + +if ! id "$USER_NAME" >/dev/null 2>&1; then + NOLOGIN=$(command -v nologin 2>/dev/null || echo /bin/false) + useradd --system --user-group --home-dir "$DATA_DIR" --no-create-home \ + --shell "$NOLOGIN" --comment "ThinkWatch Core" "$USER_NAME" \ + || die "could not create the user $USER_NAME." + say "created the system user $USER_NAME" +fi +install -d -m 0700 -o "$USER_NAME" -g "$USER_NAME" "$DATA_DIR" + +install -d -m 0755 "$ETC_DIR" +if [ ! -e "$ETC_DIR/env" ]; then + { + say "# Environment of the twcore service, one NAME=value per line." + say "# \${NAME} in $DATA_DIR/config.yaml reads it. Restart the service after a change." + say "#ANTHROPIC_API_KEY=sk-ant-..." + say "#HTTPS_PROXY=http://proxy.example.com:3128" + } > "$ETC_DIR/env" + chown "root:$USER_NAME" "$ETC_DIR/env" + chmod 0640 "$ETC_DIR/env" + say "created $ETC_DIR/env" +fi + +HAS_SYSTEMD=0 +if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + HAS_SYSTEMD=1 +fi +if [ -f "$SRC/twcore.service" ]; then + install -d -m 0755 "$(dirname "$UNIT")" + install -m 0644 "$SRC/twcore.service" "$UNIT" + say "installed $UNIT" + if [ "$HAS_SYSTEMD" = 1 ]; then + systemctl daemon-reload + fi +fi + +as_service_user() { + if command -v runuser >/dev/null 2>&1; then + runuser -u "$USER_NAME" -- env THINKWATCH_HOME="$DATA_DIR" "$@" + else + # 单引号是有意的:$0、$@ 由 su 起的那个 shell 展开 + # shellcheck disable=SC2016 + su -s /bin/sh "$USER_NAME" -c 'THINKWATCH_HOME="$0" exec "$@"' "$DATA_DIR" "$@" + fi +} + +if [ ! -e "$DATA_DIR/config.yaml" ]; then + say "" + as_service_user "$BIN_DIR/twcore" init +fi + +# ── What next ───────────────────────────────────────────────────────── + +RUNNING=0 +if [ "$HAS_SYSTEMD" = 1 ] && systemctl is-active --quiet twcore 2>/dev/null; then + RUNNING=1 +fi + +say "" +if [ "$RUNNING" = 1 ]; then + say "The service is running the previous version. To switch to $NEW_VERSION:" + say " sudo systemctl restart twcore" +else + say "Next:" + say " 1. Edit $DATA_DIR/config.yaml (sudoedit works): set listen.gateway.bind to all," + say " enable listen.control.remote, and list your networks in both allow_from." + say " Keys for \${NAME} go in $ETC_DIR/env." + say " 2. Check it: sudo -u $USER_NAME THINKWATCH_HOME=$DATA_DIR twcore check" + if [ "$HAS_SYSTEMD" = 1 ]; then + say " 3. Start it: sudo systemctl enable --now twcore" + else + say " 3. This machine is not running systemd; start it with:" + say " sudo -u $USER_NAME THINKWATCH_HOME=$DATA_DIR twcore serve" + fi + say " 4. Get the key for the desktop app:" + say " sudo -u $USER_NAME THINKWATCH_HOME=$DATA_DIR twcore control-key" +fi +say "" +say "Guide: https://github.com/$REPO/blob/main/docs/server.md"