From cbfba4e6083e1531e3b07701a5e5b5be17e6626d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:41:48 +0000 Subject: [PATCH] fix(gateway): start on loopback when the interface to listen on is not there yet twcore used to resolve `listen.gateway.bind` once before starting and exit when the interface was missing or offline. On Windows the desktop app can bind the gateway to the WSL virtual adapter (`vEthernet (WSL)`) so that clients inside WSL reach it under NAT networking. That adapter only exists once WSL has started and gets a new address every time WSL restarts, so a gateway started at login could not start at all, and every client on the machine lost it along with the WSL ones. Resolution now happens inside the listener: when the interface is not there, or a fixed address is not on this machine yet, the gateway listens on loopback only and says why in the listen status. It asks the system again every three seconds and adds the interface once it appears, moves to its new address when it changes, and drops back to loopback when it goes away. A loopback that cannot be bound (the port is taken) is still fatal at startup, as before. A periodic check never asks a retiring listener to give its port up first; that dance briefly stops accepting connections, and repeating it every few seconds against a port someone else holds would turn into a periodic outage. Only a real relisten (config change, save) does it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01JpGLt1RZLUuYEXYwao3yGk --- bin/twcore/src/main.rs | 18 ++- crates/tw-config/src/lib.rs | 7 +- crates/tw-config/tests/manual/schema.rs | 4 +- crates/tw-control/tests/listen.rs | 7 +- crates/tw-gateway/src/listen.rs | 134 +++++++++++++++++++---- crates/tw-gateway/tests/hotreload.rs | 140 +++++++++++++++++++++++- docs/config.md | 2 +- docs/config.zh-CN.md | 2 +- 8 files changed, 266 insertions(+), 48 deletions(-) diff --git a/bin/twcore/src/main.rs b/bin/twcore/src/main.rs index 303d71d5..d092a2c1 100644 --- a/bin/twcore/src/main.rs +++ b/bin/twcore/src/main.rs @@ -739,14 +739,10 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> if let Some(p) = port { listen.port = p; } - // **`bind` 写的是网卡名时,这一步要问系统。**问不出来就在这里停, - // 而不是带着一个猜出来的地址起监听 —— 错误里说清是哪张网卡,以及 - // 这台机器上真有哪些 - let addrs = listen - .addrs() - .with_context(|| format!("resolving bind: {}", listen.bind))?; - // 配置里写的那个排在最后;日志和报错里说它,不说顺带开着的回环 - let addr = *addrs.last().expect("addrs() never returns an empty list"); + // **`bind` 写的是网卡名时,这里不去问系统。**那张网卡此刻不在(开机自启时 + // WSL 还没起来,它的虚拟网卡也就还没有)不该让网关起不来:监听那一边先只 + // 听回环,网卡出现了再补上,换了地址也跟着换,见 `tw_gateway::serve_at` + let listen_at = format!("{}:{}", listen.bind, listen.port); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -907,10 +903,10 @@ fn cmd_serve(path: &Path, port: Option, safe: bool, parent: Option) -> // 探测要打网络,而网关不该因为一次探测慢而起不来。 tw_gateway::models::spawn(state.clone()); - tracing::info!(%addr, "starting"); + tracing::info!(listen = %listen_at, "starting"); tokio::select! { - r = tw_gateway::serve_at(state, addrs, !overridden) => { - r.with_context(|| format!("{addr} could not be listened on. If the port is taken, check whether an earlier instance has fully exited")) + r = tw_gateway::serve_at(state, listen, !overridden) => { + r.with_context(|| format!("{listen_at} could not be listened on. If the port is taken, check whether an earlier instance has fully exited")) } msg = control_dead => { anyhow::bail!("{}", msg.unwrap_or_else(|_| "the control plane stopped".into())) diff --git a/crates/tw-config/src/lib.rs b/crates/tw-config/src/lib.rs index 94bd88bf..40dcb756 100644 --- a/crates/tw-config/src/lib.rs +++ b/crates/tw-config/src/lib.rs @@ -414,8 +414,8 @@ impl Default for GatewayListen { /// /// 写名字而不是写地址,是因为地址会变:DHCP 续租、换个 Wi-Fi, /// `192.168.1.5` 就不在了,网关起不来,而系统给的错误只有一句 -/// 「Can't assign requested address」。名字不会变,每次启动现问系统它 -/// 当下是哪个地址。**写死的地址仍然收** —— 有人就是要钉住那一个。 +/// 「Can't assign requested address」。名字不会变,网关每隔几秒现问系统它 +/// 当下是哪个地址,变了就换过去;网卡暂时不在时先只听回环,等它出现。**写死的地址仍然收** —— 有人就是要钉住那一个。 #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Bind { #[default] @@ -450,7 +450,8 @@ pub enum BindError { impl Bind { /// 要监听的那个地址。**网卡名要问系统**,所以这一步可能失败。 /// - /// 每次都现问,不缓存:换网络之后重启网关,拿到的就该是新地址。 + /// 每次都现问,不缓存:换了网络,下一次问拿到的就该是新地址(网关的 + /// 监听那一边每隔几秒问一次,见 `tw_gateway::serve_at`)。 pub fn resolve(&self) -> Result { match self { Bind::Loopback => Ok(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), diff --git a/crates/tw-config/tests/manual/schema.rs b/crates/tw-config/tests/manual/schema.rs index 974272c7..04c34683 100644 --- a/crates/tw-config/tests/manual/schema.rs +++ b/crates/tw-config/tests/manual/schema.rs @@ -243,8 +243,8 @@ pub fn sections() -> Vec
{ 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。", + "`loopback` is this machine only; `all` is every interface; an interface name (`en0`, `eth0`) is looked up again every few seconds and follows address changes, and while the interface is not there the gateway listens on 127.0.0.1 only and adds it once it appears; a fixed IP address stops working when the address changes. Binding one interface also listens on 127.0.0.1.", + "`loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)每隔几秒重新解析,地址变了也能跟上,网卡暂时不在时先只监听 127.0.0.1,出现后再补上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。", ), ), row( diff --git a/crates/tw-control/tests/listen.rs b/crates/tw-control/tests/listen.rs index 78c83f4f..68524f97 100644 --- a/crates/tw-control/tests/listen.rs +++ b/crates/tw-control/tests/listen.rs @@ -219,12 +219,7 @@ async fn the_status_follows_the_listener_after_a_save() { let p1 = spare_port(); let b = bed(&yaml(p1)); let gw = b.gw.clone(); - let want = tw_config::try_parse(&b.file()) - .unwrap() - .listen - .gateway - .addrs() - .unwrap(); + let want = tw_config::try_parse(&b.file()).unwrap().listen.gateway; tokio::spawn(async move { tw_gateway::serve_at(gw, want, true).await.unwrap() }); tokio::time::sleep(Duration::from_millis(80)).await; diff --git a/crates/tw-gateway/src/listen.rs b/crates/tw-gateway/src/listen.rs index 54b4d219..f5c1c923 100644 --- a/crates/tw-gateway/src/listen.rs +++ b/crates/tw-gateway/src/listen.rs @@ -239,35 +239,116 @@ pub(crate) async fn serve_detached( Ok(actual) } +/// 多久再问一次系统:要听的网卡出现了没有、地址变了没有。 +/// +/// **网卡的出现和换地址没有通知可等。**WSL 的虚拟网卡要等 WSL 起来才有,WSL +/// 每重启一次它换一个地址;DHCP 续租、换 Wi-Fi 也一样。问一次是一次 +/// `getifaddrs`(Windows 上是 `GetAdaptersAddresses`),几秒一次不值一提; +/// 而等得太久,刚起来的 WSL 里的客户端就要多连不上几秒。 +const RECHECK: std::time::Duration = std::time::Duration::from_secs(3); + +/// 这一轮要听哪些地址,以及听不全的原因。 +/// +/// **网卡此刻不在,就先只听回环。**绑的是一张网卡时回环本来就要听(见 +/// `GatewayListen::addrs`),网卡那一个等它出现再补上 —— 不能因为一张迟到的 +/// 网卡让整个网关起不来:开机自启时 WSL 还没起,这台电脑上的客户端也跟着 +/// 全部断线。 +fn plan(listen: &tw_config::GatewayListen) -> (Vec, Option) { + match listen.addrs() { + Ok(a) => (a, None), + Err(e) => (loopback_only(listen.port), Some(unresolved(&e))), + } +} + +fn loopback_only(port: u16) -> Vec { + vec![SocketAddr::new(std::net::Ipv4Addr::LOCALHOST.into(), port)] +} + +/// 这个地址此刻不是这台机器的 —— 网卡的地址刚被收走,或者写死的那个地址还没 +/// 出现。**回环和通配地址不算**:它们绑不上从来不是「还没出现」。 +fn absent(a: SocketAddr, e: &std::io::Error) -> bool { + e.kind() == std::io::ErrorKind::AddrNotAvailable + && !a.ip().is_loopback() + && !a.ip().is_unspecified() +} + /// 起服务。`follow` 为真时**跟着配置里的监听地址走**(「温」那一级热重载)。 /// /// 命令行给了 `--port` 时 `follow` 为假:那是一个显式的覆盖,不该被配置 -/// 文件推翻。 +/// 文件推翻 —— 但起始那份设置里的网卡照样跟着:它出现、换地址时补上或换过去。 /// -/// 起始那一次绑不上是致命的,错误带着地址交给调用方 —— 那时还没有旧的 -/// 可以守,守护进程据此重启或进安全模式。之后的每一次换监听都不会让它返回。 -pub async fn serve_at(state: AppState, want: Vec, follow: bool) -> std::io::Result<()> { +/// 起始那一次**回环绑不上是致命的**(多半是端口被占),错误带着地址交给调用方 +/// —— 那时还没有旧的可以守,守护进程据此重启或进安全模式。网卡此刻不在、写死 +/// 的地址此刻不是这台机器的,都不致命:先只听回环,[`RECHECK`] 一轮轮再问, +/// 它出现了就补上。之后的每一次换监听都不会让它返回。 +pub async fn serve_at( + state: AppState, + listen: tw_config::GatewayListen, + follow: bool, +) -> std::io::Result<()> { + let (want, mut note) = plan(&listen); let mut bound = Vec::with_capacity(want.len()); - for (a, l) in bind_all(&want) - .await - .map_err(|(a, e)| std::io::Error::new(e.kind(), format!("{}", bind_failure(a, &e))))? - { - bound.push(start(&state, a, l)?); + for a in want { + match TcpListener::bind(a).await { + Ok(l) => bound.push(start(&state, a, l)?), + Err(e) if absent(a, &e) => { + tracing::warn!(%a, %e, "not an address of this machine yet; listening without it until it is"); + note = Some(bind_failure(a, &e)); + } + Err(e) => { + return Err(std::io::Error::new( + e.kind(), + format!("{}", bind_failure(a, &e)), + )); + } + } } - state.set_listening(snapshot(&bound, None)); - if !follow { - // 监听器各自在自己的任务里跑。这个 future 活着,它们就不收到停止信号 - std::future::pending::<()>().await; + if let Some(e) = ¬e { + tracing::warn!(%e, "listening on loopback only for now; the rest is added once it appears"); } + state.set_listening(snapshot(&bound, note)); + // 眼下这些监听器是照着哪份设置绑的。**设置没变、网卡却解析不出来了**,是 + // 我们跟着的那张网卡没了(WSL 关了):退回只听回环,等它回来。设置变了 + // 而新的解析不出来,那是另一回事,见下面 + let mut following = (listen.bind.clone(), listen.port); + let mut recheck = tokio::time::interval(RECHECK); + recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + recheck.tick().await; loop { - state.relisten_signal().notified().await; - let want = match state.runtime().config.listen.gateway.addrs() { - Ok(a) => a, - // **新配置的地址算不出来就守住旧的。**`bind` 指着一张刚被拔掉 + // 被叫到的(配置换了、界面上存了一次)和到点再问的,**只有一处不同**: + // 到点再问不去请旧的让出端口 —— 那一下有几毫秒不接新连接,端口真被 + // 别的程序占着时,每几秒来一次就成了每几秒断一次 + let asked = tokio::select! { + _ = state.relisten_signal().notified() => true, + _ = recheck.tick() => false, + }; + let listen = if follow { + state.runtime().config.listen.gateway.clone() + } else { + listen.clone() + }; + let spec = (listen.bind.clone(), listen.port); + let (want, note) = match listen.addrs() { + Ok(a) => (a, None), + // 跟着的那张网卡没了:它那一个退场,回环照听,等它回来再补上。 + // **留着它没有用** —— 那个地址上已经没人连得过来,而它回来时地址 + // 多半换了(WSL 每重启一次换一个)。 + // + // 此刻本来就只在听回环时也一样:没有什么正在工作的要守,改了端口 + // 就该在新端口的回环上听,不必等网卡出现 + Err(e) if spec == following || bound.iter().all(|b| b.want.ip().is_loopback()) => { + if asked || bound.iter().any(|b| !b.want.ip().is_loopback()) { + tracing::warn!(%e, "the interface is gone; listening on loopback only until it is back"); + } + (loopback_only(listen.port), Some(unresolved(&e))) + } + // **新配置的地址算不出来就守住旧的。**`bind` 刚改成一张此刻不在 // 的网卡时,正确的动作不是把一个正在工作的监听器拆掉 —— 那会 - // 让所有客户端立刻断线,而它们本来好好的。 + // 让连着的客户端立刻断线,而它们本来好好的。它出现了再换过去 Err(e) => { - tracing::error!(%e, "the new listen address cannot be resolved; keeping the current one"); + if asked { + tracing::error!(%e, "the new listen address cannot be resolved; keeping the current one"); + } state.set_listening(snapshot(&bound, Some(unresolved(&e)))); continue; } @@ -275,7 +356,8 @@ pub async fn serve_at(state: AppState, want: Vec, follow: bool) -> s let have: Vec = bound.iter().map(|b| b.want).collect(); if want == have { // 通知来了但地址没变(比如又改回去了)。上一次没换成的那句话到此作废 - state.set_listening(snapshot(&bound, None)); + following = spec; + state.set_listening(snapshot(&bound, note)); continue; } // **还要的地址原样留着,只绑新增的** —— 从「仅本机」换到「局域网」时, @@ -295,7 +377,8 @@ pub async fn serve_at(state: AppState, want: Vec, follow: bool) -> s // 挡路的可能是我们自己要退场的那几个(见模块文档):先让它们放掉 // 端口再试。**只在同端口时这么做** —— 别的端口被占,让出来也没用 Err((a, e)) - if e.kind() == std::io::ErrorKind::AddrInUse + if asked + && e.kind() == std::io::ErrorKind::AddrInUse && gone.iter().any(|b| b.want.port() == a.port()) => { let back: Vec = gone.iter().map(|b| b.want).collect(); @@ -325,7 +408,11 @@ pub async fn serve_at(state: AppState, want: Vec, follow: bool) -> s } } Err((a, e)) => { - tracing::error!(%a, %e, "the new listen address cannot be bound; keeping the current one"); + // 到点再问时每一轮都会走到这里(端口还被占着、网卡的地址还没 + // 真正可用),只在被叫到的那一次记错误,免得日志每几秒一行 + if asked { + tracing::error!(%a, %e, "the new listen address cannot be bound; keeping the current one"); + } bound = keep.into_iter().chain(gone).collect(); bound.sort_by_key(|b| have.iter().position(|w| *w == b.want)); state.set_listening(snapshot(&bound, Some(bind_failure(a, &e)))); @@ -336,9 +423,10 @@ pub async fn serve_at(state: AppState, want: Vec, follow: bool) -> s for (a, l) in listeners { bound.push(start(&state, a, l)?); } + following = spec; // 配置里写的那个排在最后,和 `addrs()` 同一个顺序 bound.sort_by_key(|b| want.iter().position(|w| *w == b.want)); - state.set_listening(snapshot(&bound, None)); + state.set_listening(snapshot(&bound, note)); } } diff --git a/crates/tw-gateway/tests/hotreload.rs b/crates/tw-gateway/tests/hotreload.rs index d8a0c0e5..346f7f2f 100644 --- a/crates/tw-gateway/tests/hotreload.rs +++ b/crates/tw-gateway/tests/hotreload.rs @@ -532,7 +532,7 @@ async fn changing_the_port_actually_moves_the_listener() { /// 起一个跟着配置走的网关,等它绑上。 async fn following(state: &tw_gateway::AppState, c: &Config) { let s = state.clone(); - let want = c.listen.gateway.addrs().unwrap(); + let want = c.listen.gateway.clone(); tokio::spawn(async move { tw_gateway::serve_at(s, want, true).await.unwrap() }); tokio::time::sleep(Duration::from_millis(80)).await; } @@ -810,3 +810,141 @@ async fn when_the_new_address_is_really_taken_the_old_one_is_taken_back() { assert!(ask(gw).await.contains("\"a\""), "旧的地址不该停"); drop(squatter); } + +/// 起一个网关,**起始那一次绑定不许失败**:等它交回监听状态。 +async fn started(state: &tw_gateway::AppState, c: &Config) -> tw_gateway::Listening { + let s = state.clone(); + let listen = c.listen.gateway.clone(); + let serving = tokio::spawn(async move { tw_gateway::serve_at(s, listen, true).await }); + for _ in 0..100 { + assert!( + !serving.is_finished(), + "the gateway gave up: {:?}", + serving.await + ); + let now = state.listening(); + if !now.addrs.is_empty() { + return now; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("the gateway never started listening"); +} + +#[tokio::test] +async fn an_interface_that_is_not_there_yet_does_not_stop_the_gateway_from_starting() { + // **开机自启时 WSL 还没起来**,它的虚拟网卡也就还不在。以前这一步让 twcore + // 直接退出,这台电脑上的客户端跟着全部连不上;现在先只听回环,网卡出现了 + // 再补上 + let (up, _) = counting_upstream("a").await; + let mut c = cfg(vec![provider("a", up)], vec![]); + let port = spare_port(); + c.listen.gateway.port = port; + c.listen.gateway.bind = bind("tw-not-here0"); + let state = tw_gateway::AppState::new(c.clone()).unwrap(); + let now = started(&state, &c).await; + + let lo: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + assert_eq!(now.addrs, [lo]); + assert_eq!( + now.error.as_ref().map(|m| m.code.as_str()), + Some("gw.listen.no_such_nic"), + "界面上要说清为什么只听了本机: {now:?}" + ); + assert!(ask(lo).await.contains("\"a\""), "回环该照常服务"); + + // 换成一个解析得出来的设置:照常换过去,那句话也跟着消失 + let mut next = c.clone(); + next.listen.gateway.bind = bind("all"); + let want = next.listen.gateway.addrs().unwrap(); + state.reload(next).unwrap(); + // `switch` 看见错误就回来,而这时错误本来就在:等地址换过去 + for _ in 0..100 { + if state.listening().addrs == want { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let now = state.listening(); + assert_eq!(now.addrs, want, "{now:?}"); + assert_eq!(now.error, None, "{now:?}"); + assert!(ask(lo).await.contains("\"a\"")); +} + +#[tokio::test] +async fn an_address_that_is_not_on_this_machine_yet_does_not_stop_the_gateway_from_starting() { + // 写死的地址此刻不是这台机器的(网卡还没拿到它):和网卡不在一样,先只听 + // 回环。198.51.100.1 是文档专用的地址段,哪台机器上都不会有 + let (up, _) = counting_upstream("a").await; + let mut c = cfg(vec![provider("a", up)], vec![]); + let port = spare_port(); + c.listen.gateway.port = port; + c.listen.gateway.bind = bind("198.51.100.1"); + let state = tw_gateway::AppState::new(c.clone()).unwrap(); + let now = started(&state, &c).await; + + let lo: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + assert_eq!(now.addrs, [lo]); + assert_eq!( + now.error.as_ref().map(|m| m.code.as_str()), + Some("gw.listen.addr_unavailable"), + "{now:?}" + ); + assert!(ask(lo).await.contains("\"a\"")); +} + +#[tokio::test] +async fn a_taken_loopback_port_still_stops_the_gateway_from_starting() { + // 回环那一个绑不上不是「还没出现」:端口被占,照旧交给调用方,守护进程 + // 据此重启或进安全模式 + let squatter = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = squatter.local_addr().unwrap().port(); + let (up, _) = counting_upstream("a").await; + let mut c = cfg(vec![provider("a", up)], vec![]); + c.listen.gateway.port = port; + c.listen.gateway.bind = bind("tw-not-here0"); + let state = tw_gateway::AppState::new(c.clone()).unwrap(); + let r = tokio::time::timeout( + Duration::from_secs(2), + tw_gateway::serve_at(state, c.listen.gateway.clone(), true), + ) + .await + .expect("serve_at should give up rather than wait"); + assert_eq!( + r.unwrap_err().kind(), + std::io::ErrorKind::AddrInUse, + "端口被占要照旧报出来" + ); +} + +#[tokio::test] +async fn a_new_port_takes_effect_on_loopback_while_the_interface_is_still_missing() { + // 网卡还没出现、只在听回环时改了端口:没有什么正在工作的要守,回环换到 + // 新端口上,网卡那一个照旧等它出现 + let (up, _) = counting_upstream("a").await; + let mut c = cfg(vec![provider("a", up)], vec![]); + c.listen.gateway.port = spare_port(); + c.listen.gateway.bind = bind("tw-not-here0"); + let state = tw_gateway::AppState::new(c.clone()).unwrap(); + started(&state, &c).await; + + let p2 = spare_port(); + let lo2: SocketAddr = format!("127.0.0.1:{p2}").parse().unwrap(); + let mut next = c.clone(); + next.listen.gateway.port = p2; + state.reload(next).unwrap(); + for _ in 0..100 { + if state.listening().addrs == [lo2] { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let now = state.listening(); + assert_eq!(now.addrs, [lo2], "{now:?}"); + assert_eq!( + now.error.as_ref().map(|m| m.code.as_str()), + Some("gw.listen.no_such_nic"), + "{now:?}" + ); + assert!(ask(lo2).await.contains("\"a\"")); +} diff --git a/docs/config.md b/docs/config.md index 5dc20746..aa8cf718 100644 --- a/docs/config.md +++ b/docs/config.md @@ -180,7 +180,7 @@ clients send requests to, and the control channel the desktop app and | 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. | +| `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 again every few seconds and follows address changes, and while the interface is not there the gateway listens on 127.0.0.1 only and adds it once it appears; 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. | diff --git a/docs/config.zh-CN.md b/docs/config.zh-CN.md index 3fd04b11..e2a37f93 100644 --- a/docs/config.zh-CN.md +++ b/docs/config.zh-CN.md @@ -127,7 +127,7 @@ core 在哪里接受连接。连接分两种:客户端发送请求的 AI 网 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| -| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `loopback` | `loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)在启动时解析,地址变了也能跟上;写死的 IP 地址在地址变化后失效。绑定单张网卡时同时监听 127.0.0.1。 | +| `bind` | `loopback` \| `all` \| 网卡名 \| IP 地址 | `loopback` | `loopback` 只有本机;`all` 所有网卡;网卡名(`en0`、`eth0`)每隔几秒重新解析,地址变了也能跟上,网卡暂时不在时先只监听 127.0.0.1,出现后再补上;写死的 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`。 |