diff --git a/README.md b/README.md index 894a351..ba0d0c5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,20 @@ twcore call -X POST -d '{"model":"claude-sonnet-4-5","route":"default"}' /dryrun The configuration text the control plane hands out has the key masked, and a write through the control plane cannot change it. +A desktop app on another machine connects through the remote control port, +`listen.control.remote`. It is opened in addition to the local channel, with the +same key and handshake: + +``` +twcore remote enable --allow 192.168.1.0/24 # the port is picked at random the first time +twcore remote disable +twcore control-key # prints the key; the address and port go to stderr +``` + +Sources outside `allow_from` are closed without a reply, a source that fails the +handshake five times in a minute is ignored for a minute, and a remote connection +cannot stop the core, take the diagnostic bundle, or change `listen.control`. + ## License MIT diff --git a/README.zh-CN.md b/README.zh-CN.md index 11bdfe4..deb1ca0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,18 @@ twcore call -X POST -d '{"model":"claude-sonnet-4-5","route":"default"}' /dryrun 控制面发出去的配置原文里钥匙是打码的,经控制面的写入也改不了它。 +另一台机器上的桌面端经远程控制端口(`listen.control.remote`)连进来。它是在本机 +通道之外另开的,钥匙和握手都一样: + +``` +twcore remote enable --allow 192.168.1.0/24 # 端口第一次随机挑 +twcore remote disable +twcore control-key # 标准输出是钥匙,地址和端口在标准错误 +``` + +`allow_from` 之外的来源直接关掉、不回任何字节;同一来源一分钟内握手失败五次, +之后一分钟不理它;远程连接不能关 core、不能取诊断包、不能改 `listen.control`。 + ## License MIT diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 307f046..b6b3902 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -81,6 +81,14 @@ enum Command { #[arg(long)] rotate: bool, }, + /// Show, open or close the remote control port, which a desktop app on another machine + /// connects to + // + // 只能在 core 这台机器上开关:经控制面进来的一方改不了自己进来的那扇门 + Remote { + #[command(subcommand)] + what: Option, + }, /// Send one request to the running core's control plane and print the response // // **curl 敲不开控制面了**:每条连接先握手。调试、脚本、smoke 用这个 —— @@ -114,6 +122,27 @@ enum Command { }, } +#[derive(Subcommand)] +enum RemoteCmd { + /// Print whether the remote control port is open and where to connect + Show, + /// Open the remote control port. A running core starts listening within a second + Enable { + /// loopback, all, an interface name such as eth0, or an address; all by default + #[arg(long)] + bind: Option, + /// The port; a random one is picked the first time + #[arg(long)] + port: Option, + /// A source allowed to connect (CIDR or address); repeat for several. Replaces the + /// list; the private ranges by default + #[arg(long = "allow")] + allow: Vec, + }, + /// Close the remote control port; the port and the allowed sources are kept + Disable, +} + #[derive(Subcommand)] enum ConfigCmd { /// Print the configuration as it is, with its version @@ -162,6 +191,7 @@ fn main() -> Result<()> { Command::Speed { provider, proxy } => cmd_speed(&path, provider, proxy), Command::Config { what } => cmd_config(&path, what), Command::ControlKey { rotate } => cmd_control_key(&path, rotate), + Command::Remote { what } => cmd_remote(&path, what.unwrap_or(RemoteCmd::Show)), Command::Call { path: endpoint, method, @@ -416,13 +446,95 @@ fn cmd_control_key(path: &Path, rotate: bool) -> Result<()> { with the previous one; the desktop app on this machine reconnects by itself, and one \ on another machine needs the new key)" ); + print_remote(path); return Ok(()); } // 还没有钥匙的旧配置:补上再打印。`serve` 起来时也会这样补,这里先补 // 不改变任何行为,只是省得让人先去起一次 core tw_config::control_key::ensure_file(path)?; let key = tw_link::read_key(path)?; + // **标准输出只有钥匙**:`$(twcore control-key)` 拿到的就是它。连接要的其余 + // 几样(地址、端口)走标准错误,终端上照样看得见 println!("{}", key.to_hex()); + print_remote(path); + Ok(()) +} + +/// 远程控制端口开没开、从别的机器该连哪儿。**按配置文件说**:core 可能没在跑, +/// 在跑的话它听的就是这里写的(绑不上时 `twcore call /status` 说为什么)。 +fn print_remote(path: &Path) { + let Ok(cfg) = tw_config::load(path) else { + return; + }; + match cfg.listen.control.remote.filter(|r| r.enabled) { + None => eprintln!("remote control: off (twcore remote enable opens it)"), + Some(r) => { + let addrs = match r.bind.resolve() { + Ok(ip) => tw_control::remote::reachable(std::net::SocketAddr::new(ip, r.port)), + Err(e) => { + eprintln!("remote control: port {}, but {e}", r.port); + return; + } + }; + if addrs.is_empty() { + eprintln!( + "remote control: port {}, listening on loopback only, so no other machine can \ + connect", + r.port + ); + } else { + eprintln!( + "remote control: port {}; connect to {}", + r.port, + addrs.join(" or ") + ); + } + eprintln!("allowed sources: {}", r.allow_from.join(", ")); + } + } +} + +fn cmd_remote(path: &Path, what: RemoteCmd) -> Result<()> { + if !path.exists() { + anyhow::bail!( + "{} does not exist. twcore init writes it; twcore serve writes it on first start", + path.display() + ); + } + match what { + RemoteCmd::Show => {} + RemoteCmd::Enable { bind, port, allow } => { + let bind = match bind { + None => None, + Some(b) => Some( + serde_yaml_ng::from_value::(serde_yaml_ng::Value::String( + b.trim().to_string(), + )) + .with_context(|| { + format!( + "--bind takes loopback, all, an interface name or an address; it \ + reads {b}" + ) + })?, + ), + }; + let e = tw_config::remote::Enable { + bind, + port, + allow_from: (!allow.is_empty()).then_some(allow), + }; + tw_config::remote::enable_file(path, &e)?; + eprintln!("(a running core opens the port within a second)"); + } + RemoteCmd::Disable => { + tw_config::remote::disable_file(path)?; + eprintln!( + "(a running core closes the port within a second, and the connections made \ + through it)" + ); + } + } + print_remote(path); Ok(()) } @@ -434,7 +546,15 @@ fn cmd_init(path: &Path, force: bool) -> Result<()> { path.display() ); } - let cfg = tw_config::generate_initial(); + let mut cfg = tw_config::generate_initial(); + // **服务器上手工部署才跑 init**:远程控制端口这一节写出来、关着,端口现挑。 + // 要用时把 enabled 改成 true(或者 twcore remote enable) + cfg.listen.control.remote = Some(tw_config::RemoteListen { + enabled: false, + bind: tw_config::Bind::All, + port: tw_config::generate_remote_port(cfg.listen.gateway.port), + allow_from: tw_config::default_allow_from(), + }); let key = cfg.clients[0].key.clone(); write_config(path, &cfg)?; println!("wrote {}", path.display()); @@ -446,7 +566,8 @@ fn cmd_init(path: &Path, force: bool) -> Result<()> { println!(); println!( "The configuration also holds the control key, which the desktop app connects with; \ - twcore control-key prints it." + twcore control-key prints it. To let a desktop app on another machine connect, run \ + twcore remote enable." ); println!(); println!("Next: add an upstream under providers, then run twcore serve."); @@ -720,6 +841,7 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> // 是能改配置**。安全模式就是「只有这一半」。 let control = tw_control::ControlState { shutdown: shutdown.clone(), + remote: Default::default(), started: std::time::Instant::now(), gateway: state.clone(), cfg: manager, diff --git a/crates/tw-api/msg-codes.txt b/crates/tw-api/msg-codes.txt index 480620b..1ab08c4 100644 --- a/crates/tw-api/msg-codes.txt +++ b/crates/tw-api/msg-codes.txt @@ -5,6 +5,7 @@ # test only produced by tests; never reaches a UI config.bad_allow_from config.bad_base_url +config.bad_remote_allow_from config.blank_models_only config.control_key_invalid config.control_key_missing @@ -45,6 +46,8 @@ config.no_clients config.output_limit_range config.rejected config.rejected_at +config.remote_port_is_gateway +config.remote_port_zero config.reserved_name config.rotate.no_provider config.rotate.read_back_differs @@ -129,6 +132,9 @@ control.proxy_not_found control.reassign_to_deleted_route control.records_unreadable control.redirect_must_be_app_scheme +control.remote.control_section_locked +control.remote.diagnostics_refused +control.remote.shutdown_refused control.request_body_gone control.request_body_truncated control.request_not_found diff --git a/crates/tw-api/src/lib.rs b/crates/tw-api/src/lib.rs index b4c2d42..8dc406e 100644 --- a/crates/tw-api/src/lib.rs +++ b/crates/tw-api/src/lib.rs @@ -500,7 +500,13 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt"); /// `/mcp/*` 和 `ClientsChanged` / `ScanAlert` 两个事件都删了,只留 /// `POST /clients/{id}/key`;更换密钥不再同步客户端的配置(`KeyRotated` 没有 /// `synced` / `failed` 了),删密钥也不再查它是不是写在一个接管着的客户端里。 -pub const CONTROL_API_VERSION: u32 = 19; +/// +/// **20 加了远程控制端口**(`listen.control.remote`)。`Status` 多了 +/// `remote_control`(开没开、听在哪、为什么没听上)和 `gateway_reachable` +/// (别的机器连网关用哪几个地址)。从远程端口进来的连接不能关 core、不能 +/// 取诊断包、不能改 `listen.control` 这一节(403,`control.remote.*`)。 +/// 照 19 写的客户端会缺这两个字段。 +pub const CONTROL_API_VERSION: u32 = 20; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS))] @@ -533,6 +539,36 @@ pub struct Status { /// /// 重启网关之前要看它 —— 重启会掐断所有还没结束的流。 pub in_flight: usize, + /// 远程控制端口此刻的样子。 + pub remote_control: RemoteControlView, + /// 别的机器连网关该用的地址(`地址:端口`),**不含回环**。 + /// + /// 网关绑在一张网卡上时就是那一个;绑 `all` 时是这台机器每张网卡的地址 + /// (每张一个,有 IPv4 用 IPv4);只绑回环时是空的 —— 别的机器根本连不上。 + /// + /// **core 不知道对方是从哪条路过来的**(NAT、端口转发、域名都看不见)。 + /// 桌面端连远程 core 时,优先用它自己连控制面时拨的那个主机加上网关的 + /// 端口(`gateway_addr` 里的端口):那个主机名已经被证明从那台 Mac 上 + /// 连得通。这里的清单是给它核对和兜底用的。 + pub gateway_reachable: Vec, +} + +/// 远程控制端口(`listen.control.remote`)。 +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS))] +pub struct RemoteControlView { + /// 配置里开着吗 + pub enabled: bool, + /// **此刻真的在听的地址**。开着却是空的,原因在 `error`;换端口没换成时 + /// 这里仍是旧的那个 + pub addr: Option, + /// 配置里的地址没能听上的原因(端口被占、网卡没有地址) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// 放行哪些来源。本机永远放行 + pub allow_from: Vec, + /// 别的机器连这个端口用的地址(`地址:端口`),规则同 `Status.gateway_reachable` + pub reachable: Vec, } /// 一次请求的观测事件。UI 的实时列表吃这个。 @@ -3992,6 +4028,8 @@ mod tests { providers: 1, uptime_secs: 0, in_flight: 3, + remote_control: RemoteControlView::default(), + gateway_reachable: vec![], }; let back: Status = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); assert_eq!(back.gateway_addr.as_deref(), Some("127.0.0.1:8788")); @@ -4004,7 +4042,8 @@ mod tests { // 而不是「gateway_addr 是空字符串」这种约定。 let json = r#"{"api_version":1,"version":"x","pid":1,"gateway_addr":null, "config_path":"/x","clients":0,"providers":0,"uptime_secs":0, - "in_flight":0}"#; + "in_flight":0,"gateway_reachable":[], + "remote_control":{"enabled":false,"addr":null,"allow_from":[],"reachable":[]}}"#; let s: Status = serde_json::from_str(json).unwrap(); assert!(s.gateway_addr.is_none()); } diff --git a/crates/tw-config/src/init.rs b/crates/tw-config/src/init.rs index 9845a36..03b53c6 100644 --- a/crates/tw-config/src/init.rs +++ b/crates/tw-config/src/init.rs @@ -44,6 +44,7 @@ pub fn generate_initial() -> Config { listen: Listen { control: ControlListen { key: Some(generate_control_key().to_hex()), + remote: None, }, ..Default::default() }, diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index f0701a9..94bd88b 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -18,6 +18,7 @@ mod probes; pub mod proxy; pub mod refs; pub mod reload; +pub mod remote; mod retention; mod security; pub mod store; @@ -257,9 +258,57 @@ pub struct ControlListen { /// 不是一句解析器的原话。 #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, - // ── 远程控制端口(`remote: { enabled, bind, port, allow_from }`)加在这里。 - // 它是**另开的**一个网络端口,给另一台机器上的桌面端用,本机的通道照旧; - // 钥匙还是上面这一把。 + /// 远程控制端口:给另一台机器上的桌面端用。**另开的**一个网络端口,本机的 + /// 通道(socket、Windows 的回环端口)照旧在;钥匙还是上面这一把。 + /// + /// 不写,或者 `enabled: false`,就不听。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote: Option, +} + +/// `listen.control.remote`。 +/// +/// ```yaml +/// remote: +/// enabled: true +/// bind: all # 和网关同样的写法:loopback / all / 网卡名 / 地址 +/// port: 23483 # 写进配置时随机生成 +/// allow_from: [192.168.1.0/24] +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RemoteListen { + #[serde(default)] + pub enabled: bool, + /// 默认 `all`:这个端口存在的理由就是让别的机器连进来,只听回环等于没开。 + #[serde(default = "bind_all")] + pub bind: Bind, + /// **没有默认值**,写进配置时随机挑一个([`generate_remote_port`]):一个 + /// 人人都知道的固定端口只会招来更多扫描,而它也省不了谁一步 —— 连接时 + /// 反正要从服务器上抄钥匙,端口跟着一起抄。 + pub port: u16, + /// 和网关一样:不写是私网段([`default_allow_from`]),写成空列表就是只有 + /// 本机。名单之外的来源 accept 之后立刻关掉,一个字节都不回。 + #[serde(default = "default_allow_from")] + pub allow_from: Vec, +} + +fn bind_all() -> Bind { + Bind::All +} + +/// 远程控制端口从哪一段里挑:不需要特权,也躲开系统分给临时连接的那一段 +/// (Linux 默认 32768 起,Windows 49152 起)。 +pub const REMOTE_PORT_RANGE: std::ops::RangeInclusive = 20000..=32000; + +/// 随机挑一个远程控制端口,**避开网关的端口**。 +pub fn generate_remote_port(gateway_port: u16) -> u16 { + loop { + let p = rand::random_range(REMOTE_PORT_RANGE); + if p != gateway_port { + return p; + } + } } /// **钥匙不打印。**`Config` 会整个落进 Debug 输出,而日志是会被贴进 issue 的。 @@ -267,6 +316,7 @@ impl std::fmt::Debug for ControlListen { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ControlListen") .field("key", &self.key.as_ref().map(|_| "")) + .field("remote", &self.remote) .finish() } } diff --git a/crates/tw-config/src/remote.rs b/crates/tw-config/src/remote.rs new file mode 100644 index 0000000..56f5f27 --- /dev/null +++ b/crates/tw-config/src/remote.rs @@ -0,0 +1,227 @@ +//! `listen.control.remote` 在配置原文里的几件事:写出这一节、打开、关上。 +//! +//! 和钥匙一样**都是最小替换**:只动这一节里要动的那几个值,用户的注释和 +//! 排版原样留着。`twcore init` 和 `twcore remote enable/disable` 走这里 —— +//! 远程端口只能在服务器本机上开关,控制面改不了它(见 tw-control)。 + +use std::path::Path; + +use tw_yaml::{PatchError, Put, Scalar, Step}; + +use crate::control_key::EnsureError; +use crate::store; +use crate::{Bind, RemoteListen}; + +fn at(k: Option<&str>) -> Vec { + let mut p: Vec = ["listen", "control", "remote"] + .iter() + .map(|s| Step::key(*s)) + .collect(); + if let Some(k) = k { + p.push(Step::key(k)); + } + p +} + +/// 打开时要改的地方。`None` 的留着原样(第一次写出这一节时用默认值)。 +#[derive(Debug, Clone, Default)] +pub struct Enable { + pub bind: Option, + pub port: Option, + pub allow_from: Option>, +} + +fn flow_list(items: &[String]) -> String { + let quoted: Vec = items + .iter() + .map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))) + .collect(); + format!("[{}]", quoted.join(", ")) +} + +/// 这一节此刻在原文里是什么样。没写是 `None`。 +fn current(text: &str) -> Option { + let cfg: crate::Config = serde_yaml_ng::from_str(text).ok()?; + cfg.listen.control.remote +} + +fn gateway_port(text: &str) -> u16 { + serde_yaml_ng::from_str::(text) + .map(|c| c.listen.gateway.port) + .unwrap_or(crate::DEFAULT_GATEWAY_PORT) +} + +/// 没有这一节就写出来,**端口随机、默认关着**。已经有了就不动。 +/// +/// `twcore init` 用它:服务器上手工部署的人打开配置就能看到这一节,改一个 +/// `enabled` 就能用;端口是现挑的,不是人人都知道的那一个。 +pub fn ensure_section(text: &str, enabled: bool) -> Result, PatchError> { + if current(text).is_some() { + return Ok(None); + } + let port = crate::generate_remote_port(gateway_port(text)); + let block = format!( + "enabled: {enabled}\nbind: all\nport: {port}\nallow_from: {}", + flow_list(&crate::default_allow_from()) + ); + tw_yaml::put(text, &at(None), Put::Block(&block)).map(Some) +} + +/// 打开远程端口,按 `e` 改几处。没有这一节就先写出来(端口随机)。 +pub fn enable(text: &str, e: &Enable) -> Result { + let mut out = match ensure_section(text, true)? { + Some(t) => t, + None => tw_yaml::insert(text, &at(Some("enabled")), &Scalar::Bool(true))?, + }; + if let Some(b) = &e.bind { + out = tw_yaml::insert(&out, &at(Some("bind")), &Scalar::s(b.to_string()))?; + } + if let Some(p) = e.port { + out = tw_yaml::insert(&out, &at(Some("port")), &Scalar::Int(p.into()))?; + } + if let Some(list) = &e.allow_from { + out = tw_yaml::put(&out, &at(Some("allow_from")), Put::Inline(&flow_list(list)))?; + } + Ok(out) +} + +/// 关上远程端口:`enabled: false`,其余(端口、名单)留着,下次打开还是它们。 +/// 没有这一节就什么都不做。 +pub fn disable(text: &str) -> Result, PatchError> { + match current(text) { + Some(r) if r.enabled => { + tw_yaml::insert(text, &at(Some("enabled")), &Scalar::Bool(false)).map(Some) + } + _ => Ok(None), + } +} + +/// 改完先校验、再写,前后两版进历史。和 `control_key::rotate_file` 同一条路。 +fn write_checked( + path: &Path, + f: impl FnOnce(&str) -> Result, PatchError>, +) -> Result, EnsureError> { + let cur = store::read(path)?; + let Some(next) = f(&cur.text)? else { + return Ok(current(&cur.text)); + }; + let cfg = crate::try_parse(&next).map_err(|r| EnsureError::Rejected(Box::new(r)))?; + let _ = crate::history::snapshot(path, &cur.text, crate::history::Origin::Cli); + store::write_if_unchanged(path, &cur.fingerprint, &next)?; + let _ = crate::history::snapshot(path, &next, crate::history::Origin::Cli); + Ok(cfg.listen.control.remote) +} + +/// `twcore remote enable`。返回写下去的那一节。 +pub fn enable_file(path: &Path, e: &Enable) -> Result, EnsureError> { + write_checked(path, |t| enable(t, e).map(Some)) +} + +/// `twcore remote disable`。 +pub fn disable_file(path: &Path) -> Result, EnsureError> { + write_checked(path, disable) +} + +/// `twcore init`:写出一节关着的。 +pub fn ensure_section_file(path: &Path) -> Result, EnsureError> { + write_checked(path, |t| ensure_section(t, false)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "version: 1 # 别动我\nlisten:\n control:\n key: c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\nclients:\n - { name: default, key: tw-abc }\n"; + + fn parse(t: &str) -> crate::Config { + crate::try_parse(t).unwrap_or_else(|e| panic!("{e}\n{t}")) + } + + #[test] + fn the_section_is_written_closed_with_a_random_port_and_the_default_list() { + let t = ensure_section(BASE, false).unwrap().unwrap(); + assert!(t.starts_with("version: 1 # 别动我\n"), "{t}"); + let r = parse(&t).listen.control.remote.unwrap(); + assert!(!r.enabled); + assert_eq!(r.bind, Bind::All); + assert!(crate::REMOTE_PORT_RANGE.contains(&r.port)); + assert_eq!( + r.allow_from, + crate::default_allow_from(), + "默认名单要写出来" + ); + assert!(t.contains("allow_from:"), "{t}"); + // 已经有了就不动 + assert_eq!(ensure_section(&t, true).unwrap(), None); + // 两次各挑各的端口 + let ports: std::collections::HashSet = (0..20) + .map(|_| { + parse(&ensure_section(BASE, false).unwrap().unwrap()) + .listen + .control + .remote + .unwrap() + .port + }) + .collect(); + assert!(ports.len() > 1, "端口该是随机的:{ports:?}"); + } + + #[test] + fn enabling_changes_only_what_was_asked_and_disabling_keeps_the_port() { + let t = enable(BASE, &Enable::default()).unwrap(); + let r = parse(&t).listen.control.remote.unwrap(); + assert!(r.enabled); + let port = r.port; + + let t = enable( + &t, + &Enable { + bind: Some(Bind::Nic("en0".into())), + port: None, + allow_from: Some(vec!["192.168.1.0/24".into(), "fc00::/7".into()]), + }, + ) + .unwrap(); + let r = parse(&t).listen.control.remote.unwrap(); + assert_eq!(r.port, port, "没要求就不换端口"); + assert_eq!(r.bind, Bind::Nic("en0".into())); + assert_eq!(r.allow_from, ["192.168.1.0/24", "fc00::/7"]); + + let off = disable(&t).unwrap().unwrap(); + let r2 = parse(&off).listen.control.remote.unwrap(); + assert!(!r2.enabled); + assert_eq!(r2.port, port); + assert_eq!(r2.allow_from, r.allow_from); + assert_eq!(disable(&off).unwrap(), None, "已经关着就不写"); + assert_eq!(disable(BASE).unwrap(), None, "没有这一节就不写"); + } + + #[test] + fn the_port_never_lands_on_the_gateways() { + for _ in 0..200 { + assert_ne!(crate::generate_remote_port(20000), 20000); + } + } + + #[test] + fn the_file_helpers_validate_before_writing() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, BASE).unwrap(); + let r = enable_file(&p, &Enable::default()).unwrap().unwrap(); + assert!(r.enabled); + // 和网关同一个端口:不写 + let before = std::fs::read_to_string(&p).unwrap(); + let e = enable_file( + &p, + &Enable { + port: Some(crate::DEFAULT_GATEWAY_PORT), + ..Default::default() + }, + ); + assert!(e.is_err()); + assert_eq!(std::fs::read_to_string(&p).unwrap(), before); + assert!(!disable_file(&p).unwrap().unwrap().enabled); + } +} diff --git a/crates/tw-config/src/validate.rs b/crates/tw-config/src/validate.rs index 383551f..e1da1cb 100644 --- a/crates/tw-config/src/validate.rs +++ b/crates/tw-config/src/validate.rs @@ -74,6 +74,12 @@ pub enum ValidationError { ControlKeyMissing, #[error("{}", self.msg())] ControlKeyInvalid, + #[error("{}", self.msg())] + RemotePortZero, + #[error("{}", self.msg())] + RemotePortIsGateway { port: u16 }, + #[error("{}", self.msg())] + BadRemoteCidr { entry: String }, } impl ValidationError { @@ -208,6 +214,21 @@ impl ValidationError { "listen.control.key has to be 64 hexadecimal characters. twcore control-key \ --rotate writes a new one" ), + RemotePortZero => msg!( + "config.remote_port_zero" => + "listen.control.remote.port is 0; it has to be between 1 and 65535. twcore remote \ + enable picks a free one" + ), + RemotePortIsGateway { port } => msg!( + "config.remote_port_is_gateway", port = port => + "listen.control.remote.port is {port}, the same as the gateway's port. The two \ + need different ports" + ), + BadRemoteCidr { entry } => msg!( + "config.bad_remote_allow_from", entry = entry => + "`{entry}` in listen.control.remote.allow_from is wrong: not a valid IP address or \ + CIDR; it is written as 192.168.0.0/16" + ), } } } @@ -426,6 +447,23 @@ pub fn validate(cfg: &Config) -> Result<(), ValidationError> { } Some(_) => {} } + // 远程控制端口。**没开也照样查**:开关一拨就生效,写错的地方要在写下去 + // 的那一刻说,不是等到有人打开它的时候 + if let Some(r) = &cfg.listen.control.remote { + if r.port == 0 { + return Err(ValidationError::RemotePortZero); + } + if r.port == cfg.listen.gateway.port { + return Err(ValidationError::RemotePortIsGateway { port: r.port }); + } + for entry in &r.allow_from { + if entry.parse::().is_err() && entry.parse::().is_err() { + return Err(ValidationError::BadRemoteCidr { + entry: entry.clone(), + }); + } + } + } Ok(()) } @@ -533,6 +571,7 @@ mod tests { listen: Listen { control: crate::ControlListen { key: Some("c0ffee00".repeat(8)), + remote: None, }, ..Default::default() }, diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index e106db6..974272c 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -7,7 +7,7 @@ //! 说明写给手写配置文件的人:这个字段管什么、不写是什么意思、写错了会怎样。 //! 两种语言各写一遍,**不是互译的字面对照**,各自按各自的习惯说。 -use super::{Def, Kind, Lang, Row, Section, T2, Ty}; +use super::{Def, Kind, Lang, Row, Section, T2}; use tw_config::proxy::ProxyAuth; use tw_config::*; use tw_engine::rule::When; @@ -290,17 +290,15 @@ pub fn sections() -> Vec
{ }, Section { path: "listen.control.remote", - ty: Ty::Pending { - probe: "version: 1\nlisten:\n control:\n remote: {}\n", - }, + ty: checked!(RemoteListen, "{port: 20000}"), 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`:不为控制面开任何网络端口。", + "Listen on the remote port. Unset or `false`: no network port is opened for control. `twcore remote enable` and `twcore remote disable` switch it; a running core follows within a second.", + "是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。`twcore remote enable` / `twcore remote disable` 切换它;运行中的 core 在一秒内跟上。", ), ), row( @@ -315,10 +313,10 @@ pub fn sections() -> Vec
{ row( "port", Kind::Int, - Def::Said(t("generated", "自动生成")), + Def::Required, t( - "TCP port. There is no fixed default: a random free port is written when the section is generated, like the key.", - "TCP 端口。没有固定默认值:和密钥一样,生成这一节时写入一个随机端口。", + "TCP port. There is no fixed default: `twcore init` and `twcore remote enable` write a random port between 20000 and 32000 (never the gateway's) when they write this section. It cannot be 0 or the gateway's port.", + "TCP 端口。没有固定默认值:`twcore init` 和 `twcore remote enable` 写出这一节时随机写入 20000 到 32000 之间的一个端口(不会和网关相同)。不能是 0,也不能和网关端口相同。", ), ), row( @@ -326,8 +324,8 @@ pub fn sections() -> Vec
{ 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`。其他来源的连接在握手之前关闭,不回任何字节。", + "Sources that may connect, as for `listen.gateway.allow_from`, except that this machine is not let in automatically (it has the local channel). A connection from anywhere else is closed before the handshake, without a byte in reply; narrowing the list also closes open connections it no longer allows. A source that fails the handshake 5 times within a minute is ignored for a minute.", + "允许连接的来源,写法同 `listen.gateway.allow_from`,但本机不会自动放行(本机有本地通道)。其他来源的连接在握手之前关闭,不回任何字节;收窄名单时,已经连着、不再放行的连接也随即断开。同一来源一分钟内握手失败 5 次,之后一分钟不理它。", ), ), ], diff --git a/crates/tw-control/src/config.rs b/crates/tw-control/src/config.rs index 1737fd7..5c00d57 100644 --- a/crates/tw-control/src/config.rs +++ b/crates/tw-control/src/config.rs @@ -61,6 +61,9 @@ pub enum ApplyError { /// 这次写入改了(或删了)控制面的钥匙。经控制面进来的写入不能动它。 #[error("{}", self.msg())] ControlKeyLocked, + /// 从远程端口进来的写入改了 `listen.control` 这一节。 + #[error("{}", self.msg())] + RemoteControlLocked, } impl ApplyError { @@ -85,6 +88,12 @@ impl ApplyError { "listen.control.key cannot be changed from here. Run twcore control-key --rotate \ on the machine the core runs on, or edit the configuration file there" ), + ApplyError::RemoteControlLocked => msg!( + "control.remote.control_section_locked" => + "listen.control cannot be changed over a remote connection: it holds the port \ + this connection came in through. Change it on the server with twcore remote, or \ + by editing the configuration file there" + ), } } } @@ -241,6 +250,13 @@ impl ConfigManager { if parse(next.listen.control.key.as_deref()) != parse(in_effect.as_deref()) { return Err(ApplyError::ControlKeyLocked); } + // 远程进来的改不了自己进来的那扇门:改错一个端口、一条放行网段,就再也 + // 连不上了,而服务器上的人未必在 + if crate::remote::is_remote() + && next.listen.control.remote != self.gateway.config().listen.control.remote + { + return Err(ApplyError::RemoteControlLocked); + } // 改之前那一版进历史。**这一步在写盘之前** —— 写完再存的话, // 中间崩一次就永远丢了那一版,而那恰恰是最需要它的时刻。 let _ = tw_config::history::snapshot(&self.path, &cur.text, origin); diff --git a/crates/tw-control/src/diagnostics.rs b/crates/tw-control/src/diagnostics.rs index 35695ef..f0c0d26 100644 --- a/crates/tw-control/src/diagnostics.rs +++ b/crates/tw-control/src/diagnostics.rs @@ -29,7 +29,11 @@ fn line(out: &mut String, k: &str, v: impl std::fmt::Display) { } /// 攒一份诊断包。**只读,不写任何文件。** -pub async fn bundle(State(s): State) -> String { +pub async fn bundle(State(s): State) -> Result { + // 生成在服务器上、写的是服务器的文件系统:远程拿不到它 + if crate::remote::is_remote() { + return Err(crate::remote::refused(crate::remote::Refused::Diagnostics)); + } let mut out = String::new(); let cfg = s.config(); let _ = writeln!(out, "# ThinkWatch diagnostics bundle\n"); @@ -318,5 +322,5 @@ pub async fn bundle(State(s): State) -> String { out, "\n---\n\nThis file carries no request or response bodies. They are in the request detail view." ); - out + Ok(out) } diff --git a/crates/tw-control/src/gate.rs b/crates/tw-control/src/gate.rs index a259886..4907ed7 100644 --- a/crates/tw-control/src/gate.rs +++ b/crates/tw-control/src/gate.rs @@ -65,26 +65,25 @@ impl Gate { } /// 握手。失败时该回的都回过了,这里只记一行,**不记钥匙**。 - pub(crate) async fn admit(&self, stream: S) -> Option> + pub(crate) async fn admit(&self, stream: S) -> Result, LinkError> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { - match self.acceptor.accept(stream).await { + let r = self.acceptor.accept(stream).await; + match &r { Ok(a) => { tracing::debug!(app = %a.hello.app, "a control-plane client connected"); - Some(a) } Err(e @ (LinkError::WrongKey | LinkError::VersionMismatch { .. })) => { // 桌面端和 core 版本不一致、拿着旧钥匙的,都是用户看得到的状态, // 值得在日志里留一行 tracing::info!("a control-plane connection was turned away: {e}"); - None } Err(e) => { tracing::debug!("a control-plane handshake did not finish: {e}"); - None } } + r } /// 等到钥匙换成了别的(不再是 `used`)。 diff --git a/crates/tw-control/src/lib.rs b/crates/tw-control/src/lib.rs index 4dfd6dd..8d3674c 100644 --- a/crates/tw-control/src/lib.rs +++ b/crates/tw-control/src/lib.rs @@ -32,6 +32,7 @@ mod gate; pub mod keys; pub mod listen; pub mod pricing; +pub mod remote; pub mod replay; pub mod resources; pub mod rotation; @@ -65,6 +66,8 @@ pub struct ControlState { pub zai: Arc, /// 请网关退出的那个开关。控制面上的 `POST /shutdown` 扳它,主循环等它。 pub shutdown: Shutdown, + /// 远程控制端口此刻在听哪儿、谁被晾着。 + pub remote: Arc, } impl ControlState { @@ -160,6 +163,10 @@ async fn interfaces() -> Json> { /// 理由见 [`shutdown`] 那个模块:Windows 上没有 SIGTERM,而桌面端要在改完 /// 配置之后重启 core、在装更新之前停掉它并且等它真的退出。 async fn ask_shutdown(State(s): State) -> (StatusCode, Json) { + // 服务器上 core 归 systemd 管:从远程关掉它,就只能去服务器上重新拉起 + if remote::is_remote() { + return remote::refused(remote::Refused::Shutdown); + } tracing::info!("asked to shut down over the control plane"); // **先把话说完再退。**立刻扳开关的话,这条响应可能还没写出去进程就没了, // 而客户端看到的是连接被重置 —— 和「core 崩了」长得一模一样,偏偏这是 @@ -178,6 +185,19 @@ async fn ask_shutdown(State(s): State) -> (StatusCode, Json) async fn status(State(s): State) -> Json { let cfg = s.config(); let listening = s.gateway.listening(); + let rl = s.remote.listening(); + let gateway_reachable = listening + .primary() + .map(remote::reachable) + .unwrap_or_default(); + let rcfg = cfg.listen.control.remote.as_ref(); + let remote_control = tw_api::RemoteControlView { + enabled: rcfg.is_some_and(|r| r.enabled), + addr: rl.addr.map(|a| a.to_string()), + error: rl.error, + allow_from: rcfg.map(|r| r.allow_from.clone()).unwrap_or_default(), + reachable: rl.addr.map(remote::reachable).unwrap_or_default(), + }; Json(tw_api::Status { api_version: tw_api::CONTROL_API_VERSION, version: env!("CARGO_PKG_VERSION").to_string(), @@ -192,6 +212,8 @@ async fn status(State(s): State) -> Json { providers: cfg.providers.len(), uptime_secs: s.started.elapsed().as_secs(), in_flight: s.gateway.live.count(), + remote_control, + gateway_reachable, }) } @@ -1249,7 +1271,7 @@ pub(crate) fn apply_fail(e: ApplyError) -> Fail { } ApplyError::Edit(EditError::NotFound { .. }) => StatusCode::NOT_FOUND, // 不是请求写错了,是这条路上不许改 - ApplyError::ControlKeyLocked => StatusCode::FORBIDDEN, + ApplyError::ControlKeyLocked | ApplyError::RemoteControlLocked => StatusCode::FORBIDDEN, ApplyError::Rejected(_) | ApplyError::Build(_) | ApplyError::BadPath(_) @@ -1429,10 +1451,14 @@ pub fn socket_path_fits(path: &Path) -> Result<(), ControlError> { /// **每条连接先握手**(见 `gate`),握上了才交给 HTTP。门装在这一层,不装进 /// `router()`:`router()` 是路由表本身,十几个集成测试直接拿它跑处理函数, /// 它们测的不是门。 +/// +/// 远程控制端口(`listen.control.remote`)在这里一并跟起来。**它是另开的**: +/// 绑不上、写错了都不影响本机的通道,原因记在 `Status.remote_control`。 pub async fn serve(state: ControlState, at: &tw_api::control::Address) -> Result<(), ControlError> { use tw_api::control::Address; let gate = gate::Gate::new(&state); - let app = router(state); + let app = router(state.clone()); + tokio::spawn(remote::follow(state, app.clone(), gate.clone())); match at { #[cfg(unix)] Address::Socket(path) => serve_socket(app, gate, path).await, @@ -1468,7 +1494,7 @@ async fn serve_socket(app: Router, gate: gate::Gate, path: &Path) -> Result<(), tracing::info!(path = %path.display(), "the control plane is listening"); loop { match listener.accept().await { - Ok((stream, _)) => hand_off(stream, app.clone(), gate.clone()), + Ok((stream, _)) => hand_off(stream, app.clone(), gate.clone(), None), Err(e) => tracing::warn!("the control plane could not accept a connection: {e}"), } } @@ -1513,7 +1539,7 @@ async fn serve_loopback( tracing::info!(port, "the control plane is listening on loopback"); loop { match listener.accept().await { - Ok((stream, _)) => hand_off(stream, app.clone(), gate.clone()), + Ok((stream, _)) => hand_off(stream, app.clone(), gate.clone(), None), Err(e) => tracing::warn!("the control plane could not accept a connection: {e}"), } } @@ -1523,38 +1549,87 @@ async fn serve_loopback( /// /// **每种传输共用**:它们的差别只在怎么拿到这个流,拿到之后的每一件事 /// (握手、协议协商、错误怎么记)都该一模一样 —— 写两遍就是两遍会漂。 -/// 远程端口接进来的连接也走这里。 -fn hand_off(stream: S, app: Router, gate: gate::Gate) +/// 远程端口接进来的连接也走这里,多带一个 [`RemoteConn`]。 +fn hand_off(stream: S, app: Router, gate: gate::Gate, remote: Option) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, { tokio::spawn(async move { - let Some(link) = gate.admit(stream).await else { - return; + let link = match gate.admit(stream).await { + Ok(l) => l, + Err(e) => { + // 远程来源的失败要记账(节流)。版本不一致不算:钥匙是对的 + if let Some(r) = remote + && !matches!(e, tw_link::LinkError::VersionMismatch { .. }) + { + (r.on_failure)(); + } + return; + } }; let io = hyper_util::rt::TokioIo::new(link.stream); let svc = hyper::service::service_fn(move |req| { use tower::ServiceExt; app.clone().oneshot(req) }); - let builder = - hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()); - let conn = builder.serve_connection(io, svc); - tokio::select! { - r = conn => { - if let Err(e) = r { - tracing::debug!("a control-plane connection ended: {e}"); + let Some(mut r) = remote else { + let builder = + hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()); + let conn = builder.serve_connection(io, svc); + tokio::select! { + r = conn => { + if let Err(e) = r { + tracing::debug!("a control-plane connection ended: {e}"); + } + } + // 钥匙换了:用旧钥匙进来的这一条断开(理由见 `gate`)。连接直接丢掉, + // 事件流也跟着断,对面重连时按新钥匙握手 + _ = gate.key_changed_from(&link.key) => { + tracing::info!("the control key changed; closing a connection made with the previous one"); } } - // 钥匙换了:用旧钥匙进来的这一条断开(理由见 `gate`)。连接直接丢掉, - // 事件流也跟着断,对面重连时按新钥匙握手 - _ = gate.key_changed_from(&link.key) => { - tracing::info!("the control key changed; closing a connection made with the previous one"); + return; + }; + // 远程连接**只说 HTTP/1.1**:请求在这条连接自己的任务里处理,「这是远程」 + // 的标记(`remote::is_remote`)一路都在。HTTP/2 会把每个请求派到别的任务上, + // 标记就丢了 —— 丢了的样子是远程连接能关掉 core + let conn = hyper::server::conn::http1::Builder::new().serve_connection(io, svc); + remote::as_remote(async { + tokio::select! { + r = conn => { + if let Err(e) = r { + tracing::debug!("a remote control connection ended: {e}"); + } + } + _ = gate.key_changed_from(&link.key) => { + tracing::info!("the control key changed; closing a remote connection made with the previous one"); + } + // 远程端口关了或换了地址:从它进来的一起断开 + _ = r.closed.changed() => { + tracing::info!("the remote control port closed; closing a connection made through it"); + } + // 名单改了、把这个来源划出去了:现在就断,不等它重连 + _ = &mut r.revoked => { + tracing::info!("allow_from no longer lets this source in; closing its remote connection"); + } } - } + }) + .await; }); } +/// 从远程端口进来的一条连接多带的东西。 +pub(crate) struct RemoteConn { + /// 握手没成:记一笔(节流) + pub(crate) on_failure: Box, + /// 远程端口停了,这条也断 + pub(crate) closed: tokio::sync::watch::Receiver<()>, + /// 放行名单不再放它了,这条也断 + pub(crate) revoked: std::pin::Pin + Send>>, + /// 占着一个并发名额,连接结束时还回去 + pub(crate) _slot: tokio::sync::OwnedSemaphorePermit, +} + /// 在起任何东西之前问一句:这个地址听得起来吗。 /// /// **不是等到 bind 的那一刻才发现。**那时网关已经在监听、客户端可能已经 diff --git a/crates/tw-control/src/remote.rs b/crates/tw-control/src/remote.rs new file mode 100644 index 0000000..a45b7bd --- /dev/null +++ b/crates/tw-control/src/remote.rs @@ -0,0 +1,492 @@ +//! 远程控制端口:`listen.control.remote`。 +//! +//! # 是另开的一扇门,不是换一扇 +//! +//! 本机的通道(socket、Windows 的回环端口)照旧在。远程端口绑不上、写错了 +//! 放行名单、把自己挡在外面,服务器上的人照样能从本机的通道改回来;网关也 +//! 不受影响。所以这里的任何失败都**只记下来**(`Status.remote_control.error`), +//! 不让 core 退出。 +//! +//! # 进门之前的三道筛子 +//! +//! 1. **来源**:`allow_from` 之外的地址 accept 之后立刻关掉,一个字节都不回 —— +//! 不向扫描者暴露这是什么服务。和网关不同,**回环不自动放行**:这台机器 +//! 上的人有 socket,远程端口是给别的机器的。 +//! 2. **节流**:同一个来源一分钟里握手失败 [`MAX_FAILURES`] 次,接下来一分钟 +//! 它的连接一律直接关掉。钥匙是 256 位的,猜不出来;这一道挡的是有人拿 +//! 连接去耗我们的握手。 +//! 3. **并发**:远程连接最多 [`MAX_CONNECTIONS`] 条,多出来的直接关掉。 +//! +//! 过了这三道才握手(和本机同一个 `gate`),握上了才是 HTTP。 +//! +//! # 远程连接做不了的事 +//! +//! 关 core(服务器上它归 systemd 管)、取诊断包(写的是服务器的文件系统)、 +//! 改 `listen.control` 这一节(远程连接改得动自己进来的那扇门,改错了就再也 +//! 连不上)。**在 core 这边判**,不靠桌面端的白名单:见 [`is_remote`]。 + +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use axum::Router; +use axum::http::StatusCode; +use tokio::net::TcpListener; +use tokio::sync::{Semaphore, watch}; +use tw_types::{Msg, msg}; + +use crate::gate::Gate; +use crate::{ControlState, Fail, fail}; + +/// 一分钟里失败几次就先不理它。 +pub const MAX_FAILURES: usize = 5; +/// 数失败次数的窗口,也是不理它的时长。 +pub const WINDOW: Duration = Duration::from_secs(60); +/// 同时最多几条远程连接。桌面端一台机器用两三条(请求 + 事件流)。 +pub const MAX_CONNECTIONS: usize = 32; + +tokio::task_local! { + /// 这条连接是从远程端口进来的。在 `hand_off` 里给整条连接设上,处理函数 + /// 和 `ConfigManager` 里用 [`is_remote`] 问。 + static REMOTE: bool; +} + +/// 此刻处理的请求是不是从远程端口进来的。 +/// +/// **不在任何连接里(测试直接调处理函数、core 自己的后台任务)就是本机。** +/// 远程连接只走 HTTP/1.1(见 `hand_off`),请求在连接自己的任务里处理, +/// 这个标记一定在。 +pub fn is_remote() -> bool { + REMOTE.try_with(|r| *r).unwrap_or(false) +} + +/// 在「远程」这个标记下跑 `f`。 +pub(crate) async fn as_remote(f: F) -> F::Output { + REMOTE.scope(true, f).await +} + +/// 远程连接做不了的那几件事,说给人听。 +pub(crate) fn refused(what: Refused) -> Fail { + let m = match what { + Refused::Shutdown => msg!( + "control.remote.shutdown_refused" => + "The core cannot be stopped over a remote connection. On the server it is managed \ + by the service manager, for example systemctl stop twcore." + ), + Refused::Diagnostics => msg!( + "control.remote.diagnostics_refused" => + "The diagnostic bundle is only available on the machine the core runs on." + ), + }; + fail(StatusCode::FORBIDDEN, m) +} + +pub(crate) enum Refused { + Shutdown, + Diagnostics, +} + +/// 此刻在听哪儿,上一次换监听为什么没换成。 +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Listening { + pub addr: Option, + pub error: Option, +} + +/// 远程端口的运行时状态。`ControlState` 里一份。 +pub struct Remote { + listening: Mutex, + strikes: Mutex>, + slots: Arc, + /// 每换入一次配置跳一下。已经连着的远程连接听它,名单把自己划出去了就断开 + reloads: watch::Sender, +} + +impl Default for Remote { + fn default() -> Self { + Self { + listening: Mutex::default(), + strikes: Mutex::default(), + slots: Arc::new(Semaphore::new(MAX_CONNECTIONS)), + reloads: watch::channel(0).0, + } + } +} + +#[derive(Default)] +struct Strikes { + at: Vec, + until: Option, +} + +impl Remote { + pub fn listening(&self) -> Listening { + self.listening.lock().map(|g| g.clone()).unwrap_or_default() + } + + fn set_listening(&self, next: Listening) { + if let Ok(mut g) = self.listening.lock() { + *g = next; + } + } + + /// 这个来源此刻被晾着吗。 + fn benched(&self, ip: IpAddr, now: Instant) -> bool { + self.strikes + .lock() + .ok() + .and_then(|g| g.get(&ip).and_then(|s| s.until)) + .is_some_and(|until| now < until) + } + + /// 记一次握手失败。满了就晾它一分钟。 + fn strike(&self, ip: IpAddr, now: Instant) { + let Ok(mut g) = self.strikes.lock() else { + return; + }; + // 顺手清掉早就没事了的,表不会一直长 + g.retain(|_, s| { + s.at.retain(|t| now.duration_since(*t) < WINDOW); + !s.at.is_empty() || s.until.is_some_and(|u| now < u) + }); + let s = g.entry(ip).or_default(); + s.at.push(now); + if s.at.len() >= MAX_FAILURES { + s.at.clear(); + s.until = Some(now + WINDOW); + tracing::info!( + %ip, + "a remote source failed the handshake {MAX_FAILURES} times within a minute; \ + its connections are closed for a minute" + ); + } + } +} + +/// 来源在不在放行名单里。**回环不自动放行**(见模块头)。 +fn allowed(entries: &[String], ip: IpAddr) -> bool { + let ip = ip.to_canonical(); + entries.iter().any(|e| match e.parse::() { + Ok(a) => a.to_canonical() == ip, + Err(_) => e.parse::().is_ok_and(|c| c.contains(ip)), + }) +} + +/// 别的机器连 `addr` 该用的地址:回环是空的;`0.0.0.0` / `::` 换成这台机器 +/// 每张网卡的地址。 +pub fn reachable(addr: SocketAddr) -> Vec { + let port = addr.port(); + let show = |ip: IpAddr| SocketAddr::new(ip, port).to_string(); + let ip = addr.ip(); + if ip.is_loopback() { + return Vec::new(); + } + if ip.is_unspecified() { + return tw_config::nics::by_name() + .into_iter() + .filter(|n| !n.addr.is_loopback()) + .map(|n| show(n.addr)) + .collect(); + } + vec![show(ip)] +} + +/// 一个在听的远程端口。丢掉它(或者 `stop` 发一下)就不再接新连接,**从它 +/// 进来的连接也一起断开**:关掉远程端口的意思就是现在谁都不能从远程进来。 +struct Running { + want: SocketAddr, + stop: watch::Sender<()>, + /// accept 循环。**它结束了,监听的 socket 才真的放开** —— 同一个端口换地址 + /// 时要等到这一刻才能绑新的 + task: tokio::task::JoinHandle<()>, +} + +impl Running { + /// 停下,等 socket 放开。从它进来的连接随之断开。 + async fn close(self) { + drop(self.stop); + let _ = self.task.await; + } +} + +/// 跟着配置开关远程端口。`serve` 起一次,一直跑。 +/// +/// 配置每换入一次就对一遍:开了没、地址变没变。**换地址先绑新的**,绑不上 +/// 就守着旧的、记下原因 —— 和网关换监听同一个规矩。放行名单不用重绑,每次 +/// accept 现读。 +pub(crate) async fn follow(state: ControlState, app: Router, gate: Gate) { + use tokio::sync::broadcast::error::RecvError; + let mut events = state.bus().subscribe(); + let mut running: Option = None; + loop { + apply(&state, &app, &gate, &mut running).await; + // 已经连着的远程连接各自对一遍名单(见 `revoked`) + state.remote.reloads.send_modify(|n| *n += 1); + loop { + match events.recv().await { + Ok(tw_api::Event::ConfigReloaded { .. }) | Err(RecvError::Lagged(_)) => break, + Ok(_) => {} + Err(RecvError::Closed) => return, + } + } + } +} + +async fn apply(state: &ControlState, app: &Router, gate: &Gate, running: &mut Option) { + let r = &state.remote; + let cfg = state.config(); + let want = match &cfg.listen.control.remote { + Some(x) if x.enabled => x.clone(), + _ => { + if running.take().is_some() { + tracing::info!("the remote control port is closed"); + } + r.set_listening(Listening::default()); + return; + } + }; + let held = r.listening().addr; + let addr = match want.bind.resolve() { + Ok(ip) => SocketAddr::new(ip, want.port), + Err(e) => { + tracing::warn!(%e, "the remote control port's address cannot be resolved"); + r.set_listening(Listening { + addr: held, + error: Some(tw_gateway::listen::unresolved(&e)), + }); + return; + } + }; + if let Some(run) = running.as_ref() + && run.want == addr + { + r.set_listening(Listening { + addr: held, + error: None, + }); + return; + } + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + // **同一个端口、地址有重叠**(`all` 换成一张网卡,或者反过来):旧的还占着, + // 新的绑不上。先放开旧的再绑;新的还是绑不上,就把旧的原样绑回去 + Err(e) + if e.kind() == std::io::ErrorKind::AddrInUse + && running + .as_ref() + .is_some_and(|run| run.want.port() == addr.port()) => + { + let old = running.take().expect("checked just above"); + let old_want = old.want; + old.close().await; + match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + tracing::warn!(%addr, %e, "the remote control port cannot be listened on"); + let error = Some(tw_gateway::listen::bind_failure(addr, &e)); + match TcpListener::bind(old_want).await { + Ok(l) => { + let actual = l.local_addr().ok(); + *running = Some(start(state, app, gate, old_want, l)); + r.set_listening(Listening { + addr: actual, + error, + }); + } + Err(e2) => { + tracing::warn!(%old_want, %e2, "the previous remote control address could not be taken back either"); + r.set_listening(Listening { addr: None, error }); + } + } + return; + } + } + } + Err(e) => { + tracing::warn!(%addr, %e, "the remote control port cannot be listened on"); + r.set_listening(Listening { + addr: held, + error: Some(tw_gateway::listen::bind_failure(addr, &e)), + }); + return; + } + }; + let actual = listener.local_addr().ok(); + // 旧的那个关掉:不再接新连接,从它进来的也断开 + if let Some(old) = running.take() { + old.close().await; + } + *running = Some(start(state, app, gate, addr, listener)); + r.set_listening(Listening { + addr: actual, + error: None, + }); + tracing::info!(addr = ?actual, "the remote control port is listening"); +} + +fn start( + state: &ControlState, + app: &Router, + gate: &Gate, + want: SocketAddr, + listener: TcpListener, +) -> Running { + let (stop, stopped) = watch::channel(()); + let task = tokio::spawn(accept_loop( + listener, + stopped, + state.clone(), + app.clone(), + gate.clone(), + )); + Running { want, stop, task } +} + +/// 等到这个来源不再被放行:名单改了把它划出去,或者远程端口关了。 +/// +/// **名单收窄要当场生效**:撤掉一台机器的意思是它现在就进不来,不是等它下次 +/// 重连。和换钥匙断开旧连接是同一个道理。 +async fn revoked(state: ControlState, mut reloads: watch::Receiver, ip: IpAddr) { + loop { + if reloads.changed().await.is_err() { + return std::future::pending().await; + } + let entries = state + .config() + .listen + .control + .remote + .as_ref() + .filter(|r| r.enabled) + .map(|r| r.allow_from.clone()) + .unwrap_or_default(); + if !allowed(&entries, ip) { + return; + } + } +} + +async fn accept_loop( + listener: TcpListener, + mut stopped: watch::Receiver<()>, + state: ControlState, + app: Router, + gate: Gate, +) { + loop { + let (stream, peer) = tokio::select! { + _ = stopped.changed() => return, + r = listener.accept() => match r { + Ok(x) => x, + Err(e) => { + tracing::warn!("the remote control port could not accept a connection: {e}"); + continue; + } + }, + }; + let ip = peer.ip().to_canonical(); + let entries = state + .config() + .listen + .control + .remote + .as_ref() + .map(|r| r.allow_from.clone()) + .unwrap_or_default(); + // 三道筛子都是**直接关掉**:一个字节都不回 + if !allowed(&entries, ip) { + tracing::debug!(%ip, "a remote connection from outside allow_from was closed"); + continue; + } + if state.remote.benched(ip, Instant::now()) { + tracing::debug!(%ip, "a remote connection from a source being ignored was closed"); + continue; + } + let Ok(slot) = state.remote.slots.clone().try_acquire_owned() else { + tracing::info!(%ip, "too many remote control connections; one was closed"); + continue; + }; + let remote = state.remote.clone(); + // 订阅在这里、在 accept 的这一刻:之后的每一次换入都看得到 + let reloads = state.remote.reloads.subscribe(); + crate::hand_off( + stream, + app.clone(), + gate.clone(), + Some(crate::RemoteConn { + on_failure: Box::new(move || remote.strike(ip, Instant::now())), + closed: stopped.clone(), + revoked: Box::pin(revoked(state.clone(), reloads, ip)), + _slot: slot, + }), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allow_from_is_a_plain_list_and_loopback_is_not_waved_through() { + let l = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + let ip = |s: &str| s.parse::().unwrap(); + assert!(allowed(&l(&["192.168.0.0/16"]), ip("192.168.3.4"))); + assert!(!allowed(&l(&["192.168.0.0/16"]), ip("10.0.0.1"))); + assert!( + !allowed(&l(&["192.168.0.0/16"]), ip("127.0.0.1")), + "回环不自动放行" + ); + assert!(allowed(&l(&["127.0.0.1"]), ip("127.0.0.1"))); + // 绑 `::` 时 IPv4 的来源报成映射地址 + assert!(allowed(&l(&["10.0.0.0/8"]), ip("::ffff:10.1.2.3"))); + assert!(!allowed(&[], ip("10.1.2.3")), "空名单谁都不放"); + } + + #[test] + fn five_failures_in_a_minute_bench_a_source_for_a_minute() { + let r = Remote::default(); + let ip: IpAddr = "10.0.0.9".parse().unwrap(); + let other: IpAddr = "10.0.0.8".parse().unwrap(); + let t0 = Instant::now(); + for i in 0..MAX_FAILURES - 1 { + r.strike(ip, t0 + Duration::from_secs(i as u64)); + assert!(!r.benched(ip, t0 + Duration::from_secs(i as u64))); + } + r.strike(ip, t0 + Duration::from_secs(10)); + assert!(r.benched(ip, t0 + Duration::from_secs(11))); + assert!( + !r.benched(other, t0 + Duration::from_secs(11)), + "只晾它自己" + ); + assert!( + !r.benched(ip, t0 + Duration::from_secs(71)), + "一分钟之后放出来" + ); + } + + #[test] + fn failures_spread_over_more_than_a_minute_do_not_add_up() { + let r = Remote::default(); + let ip: IpAddr = "10.0.0.9".parse().unwrap(); + let t0 = Instant::now(); + for i in 0..10u64 { + r.strike(ip, t0 + Duration::from_secs(i * 20)); + } + assert!(!r.benched(ip, t0 + Duration::from_secs(181))); + } + + #[test] + fn a_loopback_bind_is_not_reachable_and_a_concrete_one_is_itself() { + assert!(reachable("127.0.0.1:9000".parse().unwrap()).is_empty()); + assert_eq!( + reachable("192.168.1.5:9000".parse().unwrap()), + ["192.168.1.5:9000"] + ); + for a in reachable("0.0.0.0:9000".parse().unwrap()) { + assert!(a.ends_with(":9000"), "{a}"); + assert!(!a.starts_with("127."), "{a}"); + } + } +} diff --git a/crates/tw-control/tests/bundle.rs b/crates/tw-control/tests/bundle.rs index 30f1b23..aa95239 100644 --- a/crates/tw-control/tests/bundle.rs +++ b/crates/tw-control/tests/bundle.rs @@ -28,6 +28,7 @@ fn bed_with_store(store: Option String) -> Bed { let events = bus.subscribe(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/client_key.rs b/crates/tw-control/tests/client_key.rs index a508086..a21ece3 100644 --- a/crates/tw-control/tests/client_key.rs +++ b/crates/tw-control/tests/client_key.rs @@ -26,6 +26,7 @@ fn bed() -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store, diff --git a/crates/tw-control/tests/contract.rs b/crates/tw-control/tests/contract.rs index 73911e0..7ada07b 100644 --- a/crates/tw-control/tests/contract.rs +++ b/crates/tw-control/tests/contract.rs @@ -23,6 +23,7 @@ fn app() -> (tempfile::TempDir, axum::Router) { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/dryrun.rs b/crates/tw-control/tests/dryrun.rs index cb5a0d0..baaad34 100644 --- a/crates/tw-control/tests/dryrun.rs +++ b/crates/tw-control/tests/dryrun.rs @@ -61,6 +61,7 @@ fn app_with(text: &str) -> (tempfile::TempDir, axum::Router) { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/gate.rs b/crates/tw-control/tests/gate.rs index 822d821..8f30731 100644 --- a/crates/tw-control/tests/gate.rs +++ b/crates/tw-control/tests/gate.rs @@ -49,6 +49,7 @@ fn bed() -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/in_flight.rs b/crates/tw-control/tests/in_flight.rs index 9e4a9a0..07bd94f 100644 --- a/crates/tw-control/tests/in_flight.rs +++ b/crates/tw-control/tests/in_flight.rs @@ -21,6 +21,7 @@ fn app() -> (tempfile::TempDir, tw_observe::EventBus, axum::Router) { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus.clone())), gateway: gw, store: None, diff --git a/crates/tw-control/tests/keys.rs b/crates/tw-control/tests/keys.rs index 0bd4d22..a74f808 100644 --- a/crates/tw-control/tests/keys.rs +++ b/crates/tw-control/tests/keys.rs @@ -58,6 +58,7 @@ fn bed(yaml: &str) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/listen.rs b/crates/tw-control/tests/listen.rs index 85b5c6e..c20cccc 100644 --- a/crates/tw-control/tests/listen.rs +++ b/crates/tw-control/tests/listen.rs @@ -35,6 +35,7 @@ fn bed(yaml: &str) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw.clone(), store: None, diff --git a/crates/tw-control/tests/live_state.rs b/crates/tw-control/tests/live_state.rs index 01422c8..f57b810 100644 --- a/crates/tw-control/tests/live_state.rs +++ b/crates/tw-control/tests/live_state.rs @@ -25,6 +25,7 @@ fn control( let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw.clone(), store, diff --git a/crates/tw-control/tests/models.rs b/crates/tw-control/tests/models.rs index b8c13dc..3c54a4a 100644 --- a/crates/tw-control/tests/models.rs +++ b/crates/tw-control/tests/models.rs @@ -31,6 +31,7 @@ fn bed(yaml: &str) -> Bed { let events = bus.subscribe(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/pricing.rs b/crates/tw-control/tests/pricing.rs index e09a60d..60e12ac 100644 --- a/crates/tw-control/tests/pricing.rs +++ b/crates/tw-control/tests/pricing.rs @@ -56,6 +56,7 @@ fn bed_with(yaml: &str, updater: Updater) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/remote.rs b/crates/tw-control/tests/remote.rs new file mode 100644 index 0000000..ffb33b8 --- /dev/null +++ b/crates/tw-control/tests/remote.rs @@ -0,0 +1,483 @@ +//! 远程控制端口:真的 TCP、真的握手。 +//! +//! 放行名单、节流、并发之外,要紧的是两件事:**远程进来的做不了那几件事** +//! (关 core、取诊断包、改 `listen.control`),以及**它跟着配置走** —— 开、关、 +//! 换端口都不用重启,绑不上也不连累本机的通道。 + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tw_api::control::{Address, ControlKey}; +use tw_control::{ConfigManager, ControlState}; +use tw_link::LinkError; + +const KEY: &str = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"; + +fn yaml(port: u16, enabled: bool, allow: &str) -> String { + yaml_at("loopback", port, enabled, allow) +} + +fn yaml_at(bind: &str, port: u16, enabled: bool, allow: &str) -> String { + format!( + "version: 1\nlisten:\n control:\n key: {KEY}\n remote:\n enabled: {enabled}\n bind: {bind}\n port: {port}\n allow_from: {allow}\nclients:\n - name: default\n key: tw-aaaa\n" + ) +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +struct Bed { + dir: tempfile::TempDir, + state: ControlState, + local: Address, +} + +impl Bed { + fn path(&self) -> std::path::PathBuf { + self.dir.path().join("config.yaml") + } + /// 像手改文件一样换一份配置,从文件监听那条路换入 + async fn rewrite(&self, text: &str) { + std::fs::write(self.path(), text).unwrap(); + self.state.cfg.reload_from_disk().await.unwrap(); + } + /// 等远程端口的状态变成 `f` 认可的样子 + async fn until( + &self, + f: impl Fn(&tw_control::remote::Listening) -> bool, + ) -> tw_control::remote::Listening { + for _ in 0..300 { + let l = self.state.remote.listening(); + if f(&l) { + return l; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!( + "the remote port never got there: {:?}", + self.state.remote.listening() + ); + } +} + +async fn bed(text: String) -> Bed { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("config.yaml"); + std::fs::write(&p, &text).unwrap(); + let cfg = tw_config::try_parse(&text).unwrap(); + let gw = tw_gateway::AppState::new(cfg).unwrap(); + let bus = gw.bus.clone(); + let state = ControlState { + shutdown: Default::default(), + remote: Default::default(), + cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), + gateway: gw, + store: None, + started: std::time::Instant::now(), + price_updater: Default::default(), + chatgpt: Default::default(), + zai: Default::default(), + }; + let local = Address::Loopback { + port_file: d.path().join("control.port"), + }; + let (s2, at) = (state.clone(), local.clone()); + tokio::spawn(async move { tw_control::serve(s2, &at).await }); + for _ in 0..300 { + if std::fs::read_to_string(d.path().join("control.port")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .is_some() + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + Bed { + dir: d, + state, + local, + } +} + +type Sender = hyper::client::conn::http1::SendRequest; + +async fn open_tcp( + addr: SocketAddr, + key: &str, +) -> Result<(Sender, tokio::task::JoinHandle<()>), LinkError> { + let s = tokio::net::TcpStream::connect(addr) + .await + .map_err(LinkError::unreachable)?; + let (link, _) = tw_link::connect(s, &ControlKey::parse(key).unwrap(), "remote test").await?; + let (sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(link)) + .await + .unwrap(); + let done = tokio::spawn(async move { + let _ = conn.await; + }); + Ok((sender, done)) +} + +async fn open_local(b: &Bed) -> Sender { + let Address::Loopback { port_file } = &b.local else { + unreachable!() + }; + let port: u16 = std::fs::read_to_string(port_file) + .unwrap() + .trim() + .parse() + .unwrap(); + open_tcp(SocketAddr::from(([127, 0, 0, 1], port)), KEY) + .await + .unwrap() + .0 +} + +async fn send( + s: &mut Sender, + method: &str, + path: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let r = s + .send_request( + Request::builder() + .method(method) + .uri(path) + .header("host", "localhost") + .header("content-type", "application/json") + .body(if body.is_null() { + Body::empty() + } else { + Body::from(body.to_string()) + }) + .unwrap(), + ) + .await + .unwrap(); + let st = r.status(); + let b = axum::body::to_bytes(Body::new(r.into_body()), 4 << 20) + .await + .unwrap(); + let v = serde_json::from_slice(&b) + .unwrap_or_else(|_| serde_json::Value::String(String::from_utf8_lossy(&b).into())); + (st, v) +} + +/// 放行的来源握得上手,状态里说得出开着、听在哪。 +#[tokio::test] +async fn an_allowed_source_gets_in_and_the_status_says_where_it_listens() { + let port = free_port(); + let b = bed(yaml(port, true, "[127.0.0.1]")).await; + let l = b.until(|l| l.addr.is_some()).await; + assert_eq!(l.addr.unwrap().port(), port); + let (mut s, _) = open_tcp(l.addr.unwrap(), KEY).await.unwrap(); + let (st, v) = send(&mut s, "GET", "/status", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::OK, "{v}"); + let rc = &v["remote_control"]; + assert_eq!(rc["enabled"], true, "{v}"); + assert_eq!(rc["addr"], format!("127.0.0.1:{port}"), "{v}"); + assert_eq!(rc["allow_from"], serde_json::json!(["127.0.0.1"]), "{v}"); + // 只听回环:别的机器根本连不上,说实话 + assert_eq!(rc["reachable"], serde_json::json!([]), "{v}"); + assert_eq!(v["api_version"], tw_api::CONTROL_API_VERSION); +} + +/// 名单外的来源:accept 之后直接关掉,一个字节都不回 —— 客户端看到的是「被关了」, +/// 不是「钥匙不对」。 +#[tokio::test] +async fn a_source_outside_allow_from_is_closed_without_a_word() { + let port = free_port(); + let b = bed(yaml(port, true, "[10.0.0.0/8]")).await; + let l = b.until(|l| l.addr.is_some()).await; + let mut raw = tokio::net::TcpStream::connect(l.addr.unwrap()) + .await + .unwrap(); + use tokio::io::AsyncReadExt; + let mut buf = Vec::new(); + let n = tokio::time::timeout(Duration::from_secs(5), raw.read_to_end(&mut buf)) + .await + .expect("该立刻关掉") + .unwrap_or(0); + assert_eq!(n, 0, "一个字节都不该回:{buf:?}"); + assert!(matches!( + open_tcp(l.addr.unwrap(), KEY).await, + Err(LinkError::Closed) + )); + // 名单改了立刻生效,不用重绑 + b.rewrite(&yaml(port, true, "[127.0.0.0/8]")).await; + let mut ok = false; + for _ in 0..100 { + if open_tcp(l.addr.unwrap(), KEY).await.is_ok() { + ok = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(ok, "改了名单之后该放行"); +} + +/// 同一个来源一分钟里握手失败五次,之后连对的钥匙也直接关掉。 +#[tokio::test] +async fn five_wrong_keys_from_one_source_bench_it() { + let port = free_port(); + let b = bed(yaml(port, true, "[127.0.0.1]")).await; + let addr = b.until(|l| l.addr.is_some()).await.addr.unwrap(); + let wrong = "1".repeat(64); + for _ in 0..tw_control::remote::MAX_FAILURES { + assert!(matches!( + open_tcp(addr, &wrong).await, + Err(LinkError::WrongKey) + )); + } + // 记账在握手失败之后的那个任务里:给它一点时间 + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + matches!(open_tcp(addr, KEY).await, Err(LinkError::Closed)), + "被晾着的来源该直接关掉" + ); + // 本机的通道不受影响 + let mut s = open_local(&b).await; + assert_eq!( + send(&mut s, "GET", "/status", serde_json::Value::Null) + .await + .0, + StatusCode::OK + ); +} + +/// 远程进来的:关不了 core、拿不到诊断包、改不了 `listen.control`;别的照常。 +/// 同样的事从本机的通道做得了。 +#[tokio::test] +async fn a_remote_connection_cannot_stop_the_core_take_diagnostics_or_move_its_own_door() { + let port = free_port(); + let b = bed(yaml(port, true, "[127.0.0.1]")).await; + let addr = b.until(|l| l.addr.is_some()).await.addr.unwrap(); + let (mut r, _) = open_tcp(addr, KEY).await.unwrap(); + + let (st, v) = send(&mut r, "POST", "/shutdown", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + assert_eq!(v["code"], "control.remote.shutdown_refused", "{v}"); + + let (st, v) = send(&mut r, "GET", "/diagnostics", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + assert_eq!(v["code"], "control.remote.diagnostics_refused", "{v}"); + + // 整份写回,只动了远程端口的放行名单 + let (_, cur) = send(&mut r, "GET", "/config", serde_json::Value::Null).await; + let text = cur["text"].as_str().unwrap().to_string(); + let moved = text.replace("allow_from: [127.0.0.1]", "allow_from: [0.0.0.0/0]"); + assert_ne!(moved, text); + let (st, v) = send( + &mut r, + "PUT", + "/config", + serde_json::json!({ "base_version": cur["version"], "text": moved }), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + assert_eq!(v["code"], "control.remote.control_section_locked", "{v}"); + // 按字段改也一样 + let (st, v) = send( + &mut r, + "PATCH", + "/config", + serde_json::json!({ + "base_version": cur["version"], + "ops": [{ "op": "replace", "path": "/listen/control/remote/enabled", "value": false }] + }), + ) + .await; + assert_eq!(st, StatusCode::FORBIDDEN, "{v}"); + assert!( + b.state + .config() + .listen + .control + .remote + .as_ref() + .unwrap() + .enabled + ); + + // 改别的照常 + let edited = text.replace("tw-aaaa", "tw-bbbb"); + let (st, v) = send( + &mut r, + "PUT", + "/config", + serde_json::json!({ "base_version": cur["version"], "text": edited }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + + // 本机的通道:诊断包拿得到,名单改得了 + let mut l = open_local(&b).await; + let (st, _) = send(&mut l, "GET", "/diagnostics", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::OK); + let (_, cur) = send(&mut l, "GET", "/config", serde_json::Value::Null).await; + let text = cur["text"].as_str().unwrap().to_string(); + let (st, v) = send( + &mut l, + "PUT", + "/config", + serde_json::json!({ + "base_version": cur["version"], + "text": text.replace("allow_from: [127.0.0.1]", "allow_from: [127.0.0.0/8]") + }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{v}"); + // 远程那一下不该扳动开关:开关扳过的话,这里立刻就等到了 + assert!( + tokio::time::timeout(Duration::from_millis(300), b.state.shutdown.asked()) + .await + .is_err(), + "远程那一下扳动了退出的开关" + ); +} + +/// 跟着配置走:关掉就不听、从它进来的连接断开;换端口就换过去;绑不上就守着 +/// 旧的并说为什么,本机的通道照常。 +#[tokio::test] +async fn it_follows_the_configuration_live() { + let p1 = free_port(); + let b = bed(yaml(p1, true, "[127.0.0.1]")).await; + let a1 = b.until(|l| l.addr.is_some()).await.addr.unwrap(); + let (mut s, conn) = open_tcp(a1, KEY).await.unwrap(); + assert_eq!( + send(&mut s, "GET", "/status", serde_json::Value::Null) + .await + .0, + StatusCode::OK + ); + + // 关掉:从它进来的那条断开,端口不再有人听 + b.rewrite(&yaml(p1, false, "[127.0.0.1]")).await; + b.until(|l| l.addr.is_none()).await; + tokio::time::timeout(Duration::from_secs(5), conn) + .await + .expect("关掉远程端口,从它进来的连接该断开") + .unwrap(); + assert!(matches!( + open_tcp(a1, KEY).await, + Err(LinkError::Unreachable(_)) + )); + + // 换个端口打开 + let p2 = free_port(); + b.rewrite(&yaml(p2, true, "[127.0.0.1]")).await; + let a2 = b + .until(|l| l.addr.is_some_and(|a| a.port() == p2)) + .await + .addr + .unwrap(); + assert!(open_tcp(a2, KEY).await.is_ok()); + + // 换到一个被占着的端口:守着旧的,说为什么 + let taken = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let p3 = taken.local_addr().unwrap().port(); + b.rewrite(&yaml(p3, true, "[127.0.0.1]")).await; + let l = b.until(|l| l.error.is_some()).await; + assert_eq!(l.addr.unwrap().port(), p2, "旧的该还在听"); + assert_eq!(l.error.unwrap().code, "gw.listen.port_taken"); + assert!(open_tcp(a2, KEY).await.is_ok()); + let mut local = open_local(&b).await; + let (st, v) = send(&mut local, "GET", "/status", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::OK); + assert_eq!( + v["remote_control"]["error"]["code"], "gw.listen.port_taken", + "{v}" + ); + drop(taken); +} + +/// 起来时就绑不上:本机的通道和状态照常,原因在状态里。 +#[tokio::test] +async fn a_port_that_cannot_be_bound_at_start_does_not_take_the_local_channel_down() { + let taken = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = taken.local_addr().unwrap().port(); + let b = bed(yaml(port, true, "[127.0.0.1]")).await; + let l = b.until(|l| l.error.is_some()).await; + assert!(l.addr.is_none()); + let mut s = open_local(&b).await; + let (st, v) = send(&mut s, "GET", "/status", serde_json::Value::Null).await; + assert_eq!(st, StatusCode::OK); + assert_eq!(v["remote_control"]["enabled"], true); + assert_eq!(v["remote_control"]["addr"], serde_json::Value::Null); + drop(taken); +} + +/// 名单收窄:已经连着、现在不再放行的来源**当场**断开,不等它重连。名单改了 +/// 却仍放行它的,连接照常。 +#[tokio::test] +async fn narrowing_allow_from_closes_the_connections_it_no_longer_lets_in() { + let port = free_port(); + let b = bed(yaml(port, true, "[127.0.0.1]")).await; + let addr = b.until(|l| l.addr.is_some()).await.addr.unwrap(); + let (mut s, conn) = open_tcp(addr, KEY).await.unwrap(); + assert_eq!( + send(&mut s, "GET", "/status", serde_json::Value::Null) + .await + .0, + StatusCode::OK + ); + + // 换成一份仍然放行它的名单:连接不动 + b.rewrite(&yaml(port, true, "[127.0.0.0/8]")).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(!conn.is_finished(), "名单仍放行它,连接不该断"); + assert_eq!( + send(&mut s, "GET", "/status", serde_json::Value::Null) + .await + .0, + StatusCode::OK + ); + + // 把它划出去:当场断开 + b.rewrite(&yaml(port, true, "[10.0.0.0/8]")).await; + tokio::time::timeout(Duration::from_secs(5), conn) + .await + .expect("名单不再放行,已经连着的也该断开") + .unwrap(); + // 端口本身还开着(没换地址),只是它进不来了 + assert_eq!(b.state.remote.listening().addr, Some(addr)); + assert!(matches!(open_tcp(addr, KEY).await, Err(LinkError::Closed))); +} + +/// 同一个端口从 `all` 换到一个具体地址、再换回来:地址有重叠,先绑新的会撞上 +/// 旧的自己。要能当场换过去,不报错。 +#[tokio::test] +async fn the_same_port_moves_between_all_and_a_specific_address_live() { + let port = free_port(); + let b = bed(yaml_at("all", port, true, "[127.0.0.1]")).await; + let l = b.until(|l| l.addr.is_some()).await; + assert!(l.addr.unwrap().ip().is_unspecified(), "{l:?}"); + let local: SocketAddr = ([127, 0, 0, 1], port).into(); + assert!(open_tcp(local, KEY).await.is_ok()); + + b.rewrite(&yaml_at("127.0.0.1", port, true, "[127.0.0.1]")) + .await; + let l = b + .until(|l| l.addr.is_some_and(|a| a.ip().is_loopback()) || l.error.is_some()) + .await; + assert_eq!(l.error, None, "同一个端口换地址不该失败"); + assert_eq!(l.addr, Some(local)); + assert!(open_tcp(local, KEY).await.is_ok()); + + b.rewrite(&yaml_at("all", port, true, "[127.0.0.1]")).await; + let l = b + .until(|l| l.addr.is_some_and(|a| a.ip().is_unspecified()) || l.error.is_some()) + .await; + assert_eq!(l.error, None, "换回 all 也不该失败"); + assert!(open_tcp(local, KEY).await.is_ok()); +} diff --git a/crates/tw-control/tests/replay.rs b/crates/tw-control/tests/replay.rs index 63eb931..41c5673 100644 --- a/crates/tw-control/tests/replay.rs +++ b/crates/tw-control/tests/replay.rs @@ -139,6 +139,7 @@ fn app(config: &str) -> (tempfile::TempDir, axum::Router) { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: Some(Arc::new(tokio::sync::Mutex::new(rec))), diff --git a/crates/tw-control/tests/resources.rs b/crates/tw-control/tests/resources.rs index 59de065..7925a3a 100644 --- a/crates/tw-control/tests/resources.rs +++ b/crates/tw-control/tests/resources.rs @@ -57,6 +57,7 @@ fn bed(yaml: &str) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/routes.rs b/crates/tw-control/tests/routes.rs index a5d22a8..4824fa0 100644 --- a/crates/tw-control/tests/routes.rs +++ b/crates/tw-control/tests/routes.rs @@ -86,6 +86,7 @@ fn bed(yaml: &str) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/security.rs b/crates/tw-control/tests/security.rs index 862eb9d..49b7c66 100644 --- a/crates/tw-control/tests/security.rs +++ b/crates/tw-control/tests/security.rs @@ -114,6 +114,7 @@ fn bed_with(yaml: &str, seed: impl FnOnce(&tw_store::Db)) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: Some(Arc::new(tokio::sync::Mutex::new(rec))), diff --git a/crates/tw-control/tests/sessions.rs b/crates/tw-control/tests/sessions.rs index baed27b..7f6b6b4 100644 --- a/crates/tw-control/tests/sessions.rs +++ b/crates/tw-control/tests/sessions.rs @@ -65,6 +65,7 @@ fn app(rows: &[tw_store::db::RequestRow]) -> (tempfile::TempDir, axum::Router) { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: Some(Arc::new(tokio::sync::Mutex::new(rec))), diff --git a/crates/tw-control/tests/speed.rs b/crates/tw-control/tests/speed.rs index 1832632..d486c90 100644 --- a/crates/tw-control/tests/speed.rs +++ b/crates/tw-control/tests/speed.rs @@ -25,6 +25,7 @@ fn bed(yaml: &str) -> Bed { let bus = gw.bus.clone(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/crates/tw-control/tests/zai.rs b/crates/tw-control/tests/zai.rs index 8e93759..cdf716f 100644 --- a/crates/tw-control/tests/zai.rs +++ b/crates/tw-control/tests/zai.rs @@ -282,6 +282,7 @@ async fn bed(extra: impl FnOnce(&Endpoints) -> String) -> Bed { let events = bus.subscribe(); let state = ControlState { shutdown: Default::default(), + remote: Default::default(), cfg: Arc::new(ConfigManager::new(p, gw.clone(), bus)), gateway: gw, store: None, diff --git a/docs/config.md b/docs/config.md index 256bc73..5dc2074 100644 --- a/docs/config.md +++ b/docs/config.md @@ -246,10 +246,10 @@ addition to the local channel, so a mistake here (a port that is taken, an | Field | Type | Default | Description | |---|---|---|---| -| `enabled` | bool | `false` | Listen on the remote port. Unset or `false`: no network port is opened for control. | +| `enabled` | bool | `false` | Listen on the remote port. Unset or `false`: no network port is opened for control. `twcore remote enable` and `twcore remote disable` switch it; a running core follows within a second. | | `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. | +| `port` | integer | **required** | TCP port. There is no fixed default: `twcore init` and `twcore remote enable` write a random port between 20000 and 32000 (never the gateway's) when they write this section. It cannot be 0 or the gateway's port. | +| `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`, except that this machine is not let in automatically (it has the local channel). A connection from anywhere else is closed before the handshake, without a byte in reply; narrowing the list also closes open connections it no longer allows. A source that fails the handshake 5 times within a minute is ignored for a minute. | ```yaml @@ -259,7 +259,7 @@ listen: remote: enabled: true bind: all - port: 41327 # random, written when the section is generated + port: 23483 # random, written when the section is generated allow_from: [192.168.1.0/24] ``` diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 99796f5..3fd04b1 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -175,10 +175,10 @@ twcore control-key --rotate # 更换密钥;已连接的应用需要重新连 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `enabled` | 布尔 | `false` | 是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。 | +| `enabled` | 布尔 | `false` | 是否监听远程端口。不写或 `false`:不为控制面开任何网络端口。`twcore remote enable` / `twcore remote disable` 切换它;运行中的 core 在一秒内跟上。 | | `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`。其他来源的连接在握手之前关闭,不回任何字节。 | +| `port` | 整数 | **必填** | TCP 端口。没有固定默认值:`twcore init` 和 `twcore remote enable` 写出这一节时随机写入 20000 到 32000 之间的一个端口(不会和网关相同)。不能是 0,也不能和网关端口相同。 | +| `allow_from` | 字符串列表 | `[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]` | 允许连接的来源,写法同 `listen.gateway.allow_from`,但本机不会自动放行(本机有本地通道)。其他来源的连接在握手之前关闭,不回任何字节;收窄名单时,已经连着、不再放行的连接也随即断开。同一来源一分钟内握手失败 5 次,之后一分钟不理它。 | ```yaml @@ -188,7 +188,7 @@ listen: remote: enabled: true bind: all - port: 41327 # 随机生成,在生成这一节时写入 + port: 23483 # 随机生成,在生成这一节时写入 allow_from: [192.168.1.0/24] ``` diff --git a/docs/server.md b/docs/server.md index d6174ac..e0b2c29 100644 --- a/docs/server.md +++ b/docs/server.md @@ -69,9 +69,18 @@ three things: 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. + `allow_from`. `twcore init` writes this section with `enabled: false` and + a random port between 20000 and 32000. The same can be done with a + command, which also writes the section if the file has none: + + ```sh + sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 + ``` + + `--allow` can be repeated and replaces the list; `--bind` and `--port` + change the interface and the port. `twcore remote disable` closes the + port again and keeps the rest, and `twcore remote` shows the current + state. A running core follows within a second. 3. **Add at least one upstream** under `providers`, or add it later from the desktop app. @@ -87,7 +96,7 @@ listen: remote: enabled: true bind: all - port: 41327 # written by twcore init + port: 23483 # written by twcore init, at random allow_from: [192.168.1.0/24] clients: - name: default @@ -154,6 +163,19 @@ Show the control key on the server: sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key ``` +``` +9f2c…e41a +remote control: port 23483; connect to 192.168.1.20:23483 +allowed sources: 192.168.1.0/24 +``` + +The first line, the key, is the only thing on standard output, so +`$(twcore control-key)` in a script gets just the key. The two lines after +it go to standard error: the port, the addresses of this server's +interfaces that listen on it, and the allowed sources. When the port is +closed the second line reads `remote control: off (twcore remote enable +opens it)`. + In the desktop app, open **Settings → Connections → Add remote connection** and enter: @@ -164,11 +186,22 @@ and enter: 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. +different versions. `allow_from` for this port does not let the server +itself in automatically; commands on the server use the local channel. + +A source that fails the handshake five times within a minute is ignored +for a minute. Removing a network from `allow_from` also closes the +connections already open from it. 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. +`twcore control-key --rotate` on the server; connections made with the old +key are closed at once, and connected apps then have to be given the new +key. + +A remote connection can do everything the app does on its own Mac except +three things, which the server refuses: stopping core (systemd runs it), +taking the diagnostic bundle, and changing `listen.control`, the section +it came in through. Do those on the server. ### Point clients at the server diff --git a/docs/server.zh-CN.md b/docs/server.zh-CN.md index 05432b5..4f4b3aa 100644 --- a/docs/server.zh-CN.md +++ b/docs/server.zh-CN.md @@ -48,7 +48,13 @@ alias twc='sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore' 以 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` 这一节时,自行添加,端口任选一个空闲的。 +2. **打开远程控制端口**:`listen.control.remote.enabled: true`,并在其 `allow_from` 中列出桌面应用所在的网段。`twcore init` 生成的配置带有这一节,`enabled: false`,端口是 20000 到 32000 之间随机的一个。也可以用命令完成,文件中没有这一节时命令会一并写出: + + ```sh + sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore remote enable --allow 192.168.1.0/24 + ``` + + `--allow` 可以写多次,替换整个名单;`--bind`、`--port` 改网卡和端口。`twcore remote disable` 关闭端口,其余设置保留;`twcore remote` 查看当前状态。运行中的 core 在一秒内跟上。 3. **在 `providers` 下添加至少一个上游**,也可以之后在桌面应用中添加。 ```yaml @@ -63,7 +69,7 @@ listen: remote: enabled: true bind: all - port: 41327 # twcore init 生成 + port: 23483 # twcore init 随机生成 allow_from: [192.168.1.0/24] clients: - name: default @@ -119,15 +125,27 @@ unit 以 `thinkwatch` 身份运行 core,失败后自动重启,并把它与 sudo -u thinkwatch THINKWATCH_HOME=/var/lib/thinkwatch twcore control-key ``` +``` +9f2c…e41a +remote control: port 23483; connect to 192.168.1.20:23483 +allowed sources: 192.168.1.0/24 +``` + +第一行是密钥,也是标准输出上唯一的内容,脚本里 `$(twcore control-key)` 取到的就是密钥。后两行在标准错误上:端口、这台服务器上在该端口监听的网卡地址,以及放行的来源。端口关闭时第二行是 `remote control: off (twcore remote enable opens it)`。 + 在桌面应用中打开 **设置 → 连接 → 添加远程连接**,填写: - **地址**:服务器的主机名或 IP 地址; - **控制端口**:`listen.control.remote.port` 的值; - **密钥**:`twcore control-key` 输出的 64 个字符。 -应用在保存前先试连,失败时说明原因:无响应(检查地址、端口、防火墙和 `enabled`)、连接被关闭(本机地址可能不在 `allow_from` 中)、密钥不正确、版本不一致。 +应用在保存前先试连,失败时说明原因:无响应(检查地址、端口、防火墙和 `enabled`)、连接被关闭(本机地址可能不在 `allow_from` 中)、密钥不正确、版本不一致。这个端口的 `allow_from` 不会自动放行服务器本机;服务器上的命令走本地通道。 + +同一来源一分钟内握手失败五次,之后一分钟不理它。从 `allow_from` 中删掉一个网段,已经从那里连着的连接也随即断开。 + +密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`:用旧密钥建立的连接立即断开,之后已连接的应用需要填入新密钥。 -密钥保存在 Mac 的钥匙串中。要更换密钥,在服务器上执行 `twcore control-key --rotate`,之后已连接的应用需要填入新密钥。 +远程连接能做应用在本机能做的一切,只有三件事服务器会拒绝:停止 core(它由 systemd 管理)、生成诊断包、修改 `listen.control`(这条连接进来的那一节)。这三件事在服务器上操作。 ### 让客户端指向服务器