Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
126 changes: 124 additions & 2 deletions bin/twcore/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoteCmd>,
},
/// Send one request to the running core's control plane and print the response
//
// **curl 敲不开控制面了**:每条连接先握手。调试、脚本、smoke 用这个 ——
Expand Down Expand Up @@ -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<String>,
/// The port; a random one is picked the first time
#[arg(long)]
port: Option<u16>,
/// A source allowed to connect (CIDR or address); repeat for several. Replaces the
/// list; the private ranges by default
#[arg(long = "allow")]
allow: Vec<String>,
},
/// 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<tw_config::Bind>(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(())
}

Expand All @@ -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());
Expand All @@ -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.");
Expand Down Expand Up @@ -720,6 +841,7 @@ fn cmd_serve(path: &Path, port: Option<u16>, safe: bool, parent: Option<u32>) ->
// 是能改配置**。安全模式就是「只有这一半」。
let control = tw_control::ControlState {
shutdown: shutdown.clone(),
remote: Default::default(),
started: std::time::Instant::now(),
gateway: state.clone(),
cfg: manager,
Expand Down
6 changes: 6 additions & 0 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down Expand Up @@ -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<String>,
}

/// 远程控制端口(`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<String>,
/// 配置里的地址没能听上的原因(端口被占、网卡没有地址)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<Msg>,
/// 放行哪些来源。本机永远放行
pub allow_from: Vec<String>,
/// 别的机器连这个端口用的地址(`地址:端口`),规则同 `Status.gateway_reachable`
pub reachable: Vec<String>,
}

/// 一次请求的观测事件。UI 的实时列表吃这个。
Expand Down Expand Up @@ -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"));
Expand All @@ -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());
}
Expand Down
1 change: 1 addition & 0 deletions crates/tw-config/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub fn generate_initial() -> Config {
listen: Listen {
control: ControlListen {
key: Some(generate_control_key().to_hex()),
remote: None,
},
..Default::default()
},
Expand Down
Loading
Loading