节点管理器
源码文件:19 个 · 核对版本 katana v3.0.1
katana/src/manager/node.rskatana/src/manager/mod.rskatana/src/manager/transport.rskatana/src/manager/proxy.rskatana/src/main.rskatana/src/runtime.rskatana/src/serve.rskatana/src/api/mod.rskatana/src/api/newv2board.rskatana/src/api/sspanel.rskatana/src/config.rskatana/src/traffic.rskatana/src/rule.rskatana/src/router.rskatana/tests/unit/e2e.rskatana/tests/unit/runtime.rskatana/tests/unit/traffic.rskatana/tests/unit/rule.rskatana/tests/integration/sniff.rs
katana 配置中的每个 [[node]] 条目都会变成一个 NodeManager,运行在一个独立的 tokio 任务中。管理器是节点的控制循环:它向面板(对于在本地描述的 Hysteria 2 节点,则是向配置文件)询问节点应当是什么样子,维持一个与之相符的监听器,并把节点的流量和审计命中发送给面板。它还会接收进程根在热重载时分发下来的配置文件更新。
本页面向修改 src/manager/ 的贡献者,内容包括:管理器的状态与锁、run 循环从引导重试到退出的全过程、决定节点需要重建多少的 reconcile 阶梯、静态更新路径、上报路径,以及 src/manager/mod.rs 中的若干小型辅助函数。它所拥有的监听器子树(TransportManager 和 ProxyManager)在这里只介绍到管理器驱动它的程度;服务连接、准入和流量计费各有专门的页面。
NodeManager 负责:
- 期望状态。 它最近一次应用的节点描述和用户列表(
Cur),之后的每次变化都与之做差异比较。 - 监听器子树。 任一时刻最多一个
TransportManager,由bring_up构建,由tear_down丢弃。 - 节点的共享注册表。 一个
NodeTraffic(按用户的计数器)和一个RuleManager(审计规则与命中)。二者的生命周期都长于每一次监听器重建,正因如此,字节计数在重建前后保持连续。 - 编译好的路由器。 节点的
[node.route]基于全局出站池编译而成,二者任一变化时重新编译。 - 上报。 每个轮询周期一次流量上报和一次违规访问上报,退出时再做一次最终 flush。
它有意不负责:
- 面板 HTTP 协议(由它持有的
PanelClient负责,见面板客户端页面);只有当一次配置编辑改变了构建客户端所用的设置时,它才会换上一个新客户端; - 节点身份以及重载时的重新 spawn(
src/runtime.rs在发送更新之前就已决定); - 每连接的服务、握手截止时间和准入(
src/serve.rs、src/connector.rs)。
flowchart TB runtime["runtime::spawn_built"] nm["NodeManager(每个节点一个任务)"] api["PanelClient"] tm["TransportManager(一个监听器 generation)"] pm["ProxyManager(用户表,预认证许可)"] traffic["NodeTraffic(Arc,共享)"] rules["RuleManager(Arc,共享)"] runtime -->|"spawn run(),持有 static_tx"| nm nm -->|"node_info、user_list、node_rule、上报"| api nm -->|"bring_up / tear_down"| tm tm --> pm nm --- traffic nm --- rules pm --- traffic
NodeManager
Section titled “NodeManager”pub struct NodeManager { api: Mutex<Arc<PanelClient>>, cfg: Mutex<NodeConfig>, pool: Mutex<Arc<HashMap<CompactString, Arc<Outbound>>>>, router: Mutex<Arc<Router<Outbound>>>, traffic: Arc<NodeTraffic>, rules: Arc<RuleManager>, transport: tokio::sync::Mutex<Option<TransportManager>>, cur: tokio::sync::Mutex<Cur>,}
impl NodeManager { pub fn new( api: PanelClient, cfg: NodeConfig, pool: Arc<HashMap<CompactString, Arc<Outbound>>>, ) -> io::Result<Arc<Self>>;
fn api(&self) -> Arc<PanelClient>;
pub async fn run( self: Arc<Self>, shutdown: CancellationToken, mut static_rx: mpsc::Receiver<StaticUpdate>, );}new 用 build_router(&cfg.route, &pool, &CompactString::from("direct")) 编译初始路由器,因此任何路由器构建错误都会让构造函数失败:路由或 default 引用了未知的出站 tag、cidr 或 port 条目无效,或者 geodata 无法加载。启动时,runtime::spawn_node 会把它记录为 node <id>: build router: <error> 并跳过该节点;只要至少还有一个其他节点 spawn 成功,进程仍会启动。重载时,运行时会在触碰任何运行中节点之前,先构建本次重载新增的每个节点(包括路由器),只要有一个构建失败,就拒绝整个重载。
这里的 Mutex 是 parking_lot::Mutex。这些字段分属两类锁:
| 字段 | 锁 | 写入方 | 读取方 |
|---|---|---|---|
api |
parking_lot::Mutex |
new、apply_static(StaticUpdate::Config) |
node_info、try_bootstrap、poll_cycle、refresh_rules、上报,全部经由 api() |
cfg |
parking_lot::Mutex |
apply_static(StaticUpdate::Config) |
几乎所有方法,每次读取一个字段 |
pool |
parking_lot::Mutex |
apply_static(StaticUpdate::Outbounds) |
rebuild_router |
router |
parking_lot::Mutex |
apply_static(StaticUpdate::Config)、rebuild_router |
bring_up(克隆进新的 Dispatcher) |
traffic |
无(Arc,内部加锁) |
bring_up、reconcile、ProxyManager::refresh |
report_traffic |
rules |
无(Arc,内部加锁) |
refresh_rules |
report_illegal、每个流的审计检查 |
transport |
tokio::sync::Mutex |
bring_up、tear_down |
reconcile(监听器检查、用户刷新) |
cur |
tokio::sync::Mutex |
set_cur |
poll_cycle、reconcile、apply_static、refresh_rules、report_illegal |
轮询循环和静态更新在同一个任务中依次运行,因此这些锁从不发生争用。它们存在的唯一目的,是让共享的 Arc<NodeManager> 上那些接收 &self 的方法能够携带可变状态。新增代码时,以下两条规则保证这一点继续成立:
parking_lot的 guard 从不跨越.await持有。每次使用都是一个简短表达式,例如self.cfg.lock().controller.listen_ip.clone(),或者一个复制出所需字段后就释放 guard 的代码块。api()从锁中克隆出Arc<PanelClient>,因此面板请求运行时不持有 guard;apply_static替换客户端时已在进行中的请求,会在旧客户端上完成。编译器也会兜底:tokio::spawn要求run的 future 是Send,而parking_lot的 guard 只有在开启lock_api的send_guardfeature 时才是Send,katana 的依赖图中没有任何地方开启它。- 两个 tokio mutex 从不同时持有。每个方法只获取其中一个,复制出所需内容,并在获取另一个之前释放它。
#[derive(Default)]struct Cur { node: Option<NodeInfo>, users: Vec<UserInfo>, node_tag: CompactString,}Cur 是最近一次应用的期望状态,而不是最近一次拉取到的状态。node 只有在引导完成之前才是 None;apply_static 据此判断节点是否已经启动。node_tag 是该状态的审计与上报 tag,refresh_rules 以它为键存放规则,report_illegal 也以它为键取出命中。
StaticUpdate
Section titled “StaticUpdate”pub enum StaticUpdate { Config(Box<NodeConfig>), Outbounds(Arc<HashMap<CompactString, Arc<Outbound>>>),}运行时通过一个容量为 16 的有界 mpsc channel 发送这些更新,该 channel 在 spawn 节点时创建(runtime::spawn_built)。Config 携带本节点新的 [[node]] 块,只在该块发生变化而节点身份未变时发送。身份是元组 (panel_type lowercased, api.host, api.node_id, api.key, panel node type);其中任一项变化,运行时都会取消该节点并 spawn 一个新节点。面板节点类型是 newV2board 请求中所带的 node_type(V2ray 系节点,即 api.node_type 为 V2ray、Vmess 或 Vless,开启 enable_vless 时为 vless,否则为小写的 api.node_type),因为 UniProxy 通过节点 id 和该类型来查找节点。对 SSPanel 而言它为空,因为 SSPanel 只凭 id 查找节点。因此,Config 可能会修改构建面板客户端所用的 [node.api] 字段,例如 timeout、speed_limit 或 rule_list_path;节点会为此重建客户端(见 StaticUpdate::Config)。Outbounds 携带重建后的全局出站池,每当 [[outbound]] 列表变化,就发送给运行时持有 handle 的每个节点。
它所驱动的子树
Section titled “它所驱动的子树”impl TransportManager { pub async fn start( node: &NodeInfo, cert: &CertConfig, listen_ip: &str, enable_vless: bool, sniff: bool, users: &[UserInfo], traffic: Arc<NodeTraffic>, rules: Arc<RuleManager>, router: Arc<Router<Outbound>>, node_tag: CompactString, hysteria: &HysteriaConfig, ) -> io::Result<Self>;
pub fn proxy(&self) -> &Arc<ProxyManager>;
pub async fn shutdown(self);}impl ProxyManager { pub fn refresh(&self, node: &NodeInfo, users: &[UserInfo], enable_vless: bool); pub fn retire_all(&self);}TransportManager::start 先构建所有可能失败的部分(传输层、协议表,或 Hysteria 认证器与服务端),并绑定 socket,然后才提交暂存的用户集,因此一个有问题的描述永远不会造成半绑定状态。ProxyManager::refresh 只替换运行中监听器的用户表。除此之外,管理器不调用子树上的任何其他方法。
run 的生命周期
Section titled “run 的生命周期”if self.bootstrap(&shutdown, &mut static_rx).await { self.serve(&shutdown, &mut static_rx).await;}self.tear_down().await;self.report_traffic().await;self.report_illegal().await;run 分三个阶段。bootstrap 把节点启动起来,一直重试到启动成功或令牌被取消,并返回节点是否已启动。serve 是稳态循环,只在引导成功后运行。两种情况下都会执行退出步骤,包括节点在启动之前就被取消的情况。
sequenceDiagram
participant RT as runtime
participant NM as NodeManager::run
participant P as PanelClient
participant TM as TransportManager
RT->>NM: tokio::spawn(nm.run(shutdown, static_rx))
loop bootstrap,直到启动成功或关闭
NM->>P: forget_etags()、node_info()、user_list()
NM->>TM: bring_up(node, users)
NM->>NM: set_cur(Some(node), users, tag)
NM->>P: node_rule(),除非 disable_get_rule
Note over NM: 失败时等待,并应用静态更新
end
loop serve,直到关闭
alt interval 触发
NM->>NM: poll_cycle(false)
else 静态更新
NM->>NM: apply_static(update),可能重置 interval
end
end
NM->>TM: tear_down()
NM->>P: report_user_traffic()、report_illegal()
bootstrap 反复调用 try_bootstrap,直到某次尝试成功。一次尝试依次执行以下步骤,遇到第一个失败的步骤即停止:
- 全新读取。
api().forget_etags()清空客户端的 ETag 缓存。失败的尝试读到的内容都没有被应用,而面板对曾经回答过的请求会返回 304 Not Modified,这会让本次尝试无从开始。 - 节点描述。
node_info返回面板给出的结果,但[node.hysteria].port非零的 Hysteria 2 节点除外(见下一节)。 - 端口检查。
port == 0的描述会被拒绝。 - 用户。
api().user_list()必须返回一个列表。空列表也可以接受。 - Tag。
node_tag(&node, &listen_ip)。 - 监听器。
bring_up(&node, &users)。用户列表为空时,它提交一个空注册表且不绑定任何端口,因此节点以无监听状态启动,之后第一次看到用户的轮询会构建监听器。 - 状态。
set_cur(Some(node), users, tag)。 - 规则。
refresh_rules(),除非设置了controller.disable_get_rule。
一次尝试会因以下原因之一失败:
| 条件 | 原因 |
|---|---|
node_info 返回 Ok(None) |
panel returned no node info |
node_info 返回 Err |
node_info failed: <error> |
| 描述中的端口为 0 | panel returned port 0 |
user_list 返回 Ok(None) |
panel returned no user list |
user_list 返回 Err |
user_list failed: <error> |
bring_up 失败(构建或绑定) |
initial start failed: <error> |
newV2board 客户端自身就会拒绝值为 0 的 server_port,错误为 newV2board: server port must be > 0,因此在该面板上,端口为 0 会让本次尝试以 node_info failed 失败。只有当客户端把 0 端口原样传递下来时才会走到端口为 0 这一行,例如 SSPanel 的 offset_port_node 为 "0"。
失败后,bootstrap 以 error 级别记录 node <id>: <reason>; retrying in <n>s 并等待。等待时间从 BOOTSTRAP_RETRY_MIN(1 秒)开始,每次失败后翻倍,最多到 BOOTSTRAP_RETRY_MAX(60 秒),并且永远不超过轮询周期(poll_period()),因此每隔几秒同步一次的节点也会每隔几秒重试一次。在 update_periodic 为默认值 60 秒时,各次等待依次为 1、2、4、8、16、32 秒,之后为 60 秒。
每次等待都是一个只创建一次的 tokio::time::sleep,因此等待期间到达的更新无法把重试往后推。等待期间,节点会在静态更新到达时应用它们,这也让运行时的有界 channel 不会被填满:
Outbounds更新保存出站池并重新编译路由器。此时还没有需要重建的监听器。Config更新被应用或被拒绝(见StaticUpdate::Config),并立即结束等待,因为这次编辑可能正是修复。由于节点尚未启动,它只保存新的配置、客户端和路由器,下一次尝试会用新客户端读取,并按新配置构建。
尝试和等待都放在一个 biased 的 select! 中,其第一个分支是取消令牌,因此仍在启动过程中的节点一旦被取消就会立即停止,并丢弃所有进行中的面板请求。
节点处于这个循环期间,不会在其端口上提供任何服务。进程和其他节点不受影响。
本地 Hysteria 2 描述
Section titled “本地 Hysteria 2 描述”node_info 每次调用都会从当前 cfg 读取 api.node_type、[node.hysteria] 和 api.speed_limit。当节点类型解析为 NodeType::Hysteria2 且 hysteria.port != 0 时,它自行构建 NodeInfo,从不询问面板:
NodeInfo 字段 |
值 |
|---|---|
node_type |
NodeType::Hysteria2 |
port |
hysteria.port |
speed_limit |
以 Mbps 为单位的 api.speed_limit 乘以 1_000_000.0 / 8.0,再转换为 u64(转换是饱和的,因此负值变为 0,即不限速) |
transport、enable_tls |
Transport::Tcp、true:QUIC 监听器没有 stream 传输层,其 TLS 是握手的一部分 |
obfs_type、obfs_password |
hysteria.obfs、hysteria.obfs_password,未设置时为空 |
| 其他所有字段 | 空或 false |
hysteria.port == 0 时,Hysteria 2 节点和其他节点类型一样向面板询问。面板无法表达的设置(凭据来源、UDP 中继、伪装)在两种情况下都来自 [node.hysteria],由 bring_up 读取。
由于这条路径不会失败,本地描述的 Hysteria 2 节点在 poll_cycle 中永远不会回退到最近一次应用的描述。
fn poll_period(&self) -> Duration { Duration::from_secs(self.cfg.lock().controller.update_periodic.max(1))}update_periodic 默认 60 秒(src/config.rs 中的 default_update_periodic),并被限制为至少 1 秒。serve 据此构建一个 tokio::time::interval,并立即 await 第一次 tick,而 interval 的第一次 tick 会立即完成。因此第一次 poll_cycle 在引导结束一个完整周期之后才运行,而不是紧接着引导运行。该 interval 使用 tokio 默认的 MissedTickBehavior::Burst:当一个周期耗时超过间隔时(例如多个面板请求各自等到超时),错过的 tick 会接连触发。
select 循环
Section titled “select 循环”loop { tokio::select! { _ = shutdown.cancelled() => break, _ = interval.tick() => self.poll_cycle(false).await, Some(update) = static_rx.recv() => { self.apply_static(update).await; let new_period = self.poll_period(); if new_period != period { period = new_period; interval = tokio::time::interval(period); interval.tick().await; } } }}- 轮询和静态更新严格串行。轮询期间到达的静态更新会等待轮询结束,反之亦然。
- 每次静态更新之后,循环都会重新计算周期。如果
update_periodic发生变化,它会替换 interval 并再次消耗掉立即完成的那次 tick,因此下一次轮询发生在更新之后一个新周期。 - 如果所有 sender 都被丢弃,
recv()返回None,Some(update)模式匹配失败,select!在本次迭代中禁用该分支。循环继续轮询。 - 取消只在迭代之间被观察到。在
poll_cycle或apply_static(它本身可能会运行一次轮询周期)执行期间到达的取消,要等该调用返回后才生效。当多个分支同时就绪时,tokio::select!随机选择一个,因此令牌被取消时已在队列中的静态更新,可能在循环退出前被应用,也可能不会。一个周期内的面板请求受 HTTP 客户端超时约束,即api.timeout秒,为 0 时取 5 秒(ApiConfig::timeout_secs)。
监听器先关闭,这样读取计数器时不会有中继仍在累加字节,最终上报也就包含了直到关闭时刻的全部流量,而不会丢掉最多一个轮询周期的流量。这次 flush 只尝试一次:如果面板拒绝,失败会被记录,这些字节随任务一起丢失。对于在引导期间被取消的节点,通常没有什么需要拆除或上报的;退出步骤仍会执行,这样某次尝试已经绑定的监听器 generation 也能按顺序关闭(见 rebuild、bring_up 与 tear_down)。无论是进程关闭还是重载移除某个节点,运行时都会在取消节点后 await 它的任务,因此 flush 会在进程退出之前完成。
async fn poll_cycle(&self, rebuild: bool)定时器以 rebuild = false 调用它。apply_static 在一次配置编辑之后立即调用它一次,当编辑改变了构建监听器所依据的内容时传入 rebuild = true(见 StaticUpdate::Config)。每次调用按顺序执行以下步骤,不论前面的步骤返回了什么:
| 步骤 | 调用 | Ok(None) 时 |
Err 时 |
|---|---|---|---|
| 1 | node_info() |
沿用 cur.node |
以 warn 级别记录 node <id>: node_info: <error>,沿用 cur.node |
| 2 | api().user_list() |
沿用 cur.users |
以 warn 级别记录 node <id>: user_list: <error>,沿用 cur.users |
| 3 | reconcile(node, users, rebuild) |
||
| 4 | refresh_rules(),除非 disable_get_rule |
保留现有规则 | 以 warn 级别记录,保留现有规则 |
| 5 | report_traffic() |
恢复残余量,以 warn 级别记录 |
|
| 6 | report_illegal() |
以 warn 级别记录 |
Ok(None) 是面板客户端表示“未修改”的结果(针对缓存 ETag 的 HTTP 304)。把它和请求失败同样视为“没有新内容”,正是不稳定的面板不会扰动运行中节点的原因:回退使用的描述恰好就是已经应用的那一份,所以 reconcile 找不到任何需要改变的地方。
端口为 0 的描述同样被视为未命中,因为监听器无法据此提供服务。该周期以 error 级别记录 node <id>: refreshed port is 0, keeping the last one,保留 cur.node,但仍会对面板返回的用户列表执行 reconcile,因此在面板的描述不可用期间,用户变化依然会被应用。与引导时一样,返回 server_port 为 0 的 newV2board 面板不会走到这个检查:它的客户端返回 Err,所以该周期走的是步骤 1 的回退。
回退通过 .expect("node_info present after bootstrap") 读取 cur.node。这一点成立,是因为 serve 只有在 bootstrap 返回 true 之后才运行,而这只会发生在 try_bootstrap 执行了 set_cur(Some(node), …) 之后,之后的每次 set_cur 也都传入 Some。apply_static 只在 cur.node 已经是 Some 时才运行轮询周期。
Reconcile
Section titled “Reconcile”async fn reconcile(&self, node: NodeInfo, users: Vec<UserInfo>, rebuild: bool)reconcile 选取第一个适用的分支,分支顺序按“针对当前变化断开的连接最少”排列。rebuild 会强制走完全重建分支,用于面板的回答无法体现的本地编辑:
stateDiagram-v2 [*] --> UsersCheck UsersCheck --> TearDown: users 为空 UsersCheck --> ListenerCheck: 有用户 ListenerCheck --> FullRebuild: 强制 rebuild,或没有 transport ListenerCheck --> FullRebuild: transport_eq 或 protocol_eq 为 false ListenerCheck --> UserCheck: 监听器和协议相同 UserCheck --> Refresh: user_set_differs 或 speed_limit 变化 UserCheck --> Unchanged: 没有任何差异 TearDown --> SetCur FullRebuild --> SetCur Refresh --> SetCur Unchanged --> SetCur SetCur --> [*]
| 分支 | 触发条件 | 动作 | 对连接的影响 |
|---|---|---|---|
| 无用户 | users.is_empty() |
tear_down(),然后 traffic.commit(traffic.prepare(Vec::new())) |
所有连接断开;所有计数器转入 draining |
| 完全重建 | rebuild 为 true,或没有 TransportManager,或 !node.transport_eq(prev),或 !node.protocol_eq(prev) |
rebuild(&node, &users) |
所有连接断开 |
| 原地刷新 | user_set_differs(&cur.users, &users),或 node.speed_limit 与上一个节点描述不同 |
transport.proxy().refresh(&node, &users, enable_vless) |
未变化的用户保留连接;已离开和被重新绑定的用户被退役 |
| 无变化 | 以上都不满足 | 无 | 无 |
每个分支最后都执行 set_cur(Some(node), users, tag),其中 tag 由新描述和当前的 controller.listen_ip 计算得出。
修改这段代码时,有几个细节需要注意:
- “没有 transport”就是重试路径。 重建失败时,
transport保持None。下一个周期会因为!has_transport再次走完全重建分支;无论节点是因为重建失败还是因为经历了无用户阶段而处于无监听状态,都是这样恢复的,无需任何显式重试循环。 enable_vless来自配置。 原地刷新向ProxyManager::refresh传入的是cfg.api.enable_vless,而不是node.enable_vless,与bring_up传给TransportManager::start的一致。speed_limit属于用户令牌桶的变化。 下面两个相等性比较都不包含它,因为节点限速只用于计算每个用户的速率(determine_rate)。它的变化会为有效速率发生变化的用户创建新的计数器;这些用户的现有连接保持打开,之后新开的流按新速率运行。- 刷新是事务性的。
ProxyManager::refresh在发布任何内容之前先构建替换用的表。如果构建失败,它记录proxy refresh build failed, keeping current: <error>,表和注册表都保持不变。构建成功时,它先提交注册表,再替换表,因此新加入的用户绝不会出现“已被新表认证、但注册表仍拒绝其流”的情况。
什么会强制完全重建
Section titled “什么会强制完全重建”src/api/mod.rs 中的 NodeInfo::transport_eq 和 NodeInfo::protocol_eq 把描述拆分为重建关心的两层:
NodeInfo 字段 |
比较方 | 变化意味着 |
|---|---|---|
port、transport、host、path、service_name、authority、enable_tls、header、headers、enable_reality、accept_proxy_protocol、obfs_type、obfs_password |
transport_eq |
完全重建 |
node_type、enable_vless、vless_flow、cypher_method、server_key |
protocol_eq |
完全重建 |
speed_limit |
都不比较 | 原地刷新 |
混淆字段放在 transport_eq 中,是因为它们属于线上格式的一部分:如果不重建,监听器会继续使用旧密钥,把所有已换用新密钥的客户端拒之门外。
rebuild、bring_up 与 tear_down
Section titled “rebuild、bring_up 与 tear_down”async fn rebuild(&self, node: &NodeInfo, users: &[UserInfo]);async fn bring_up(&self, node: &NodeInfo, users: &[UserInfo]) -> io::Result<()>;async fn tear_down(&self);rebuild就是tear_down之后接bring_up。bring_up的错误记录为node <id>: rebuild failed: <error>,节点保持无监听状态,直到下一个周期重试。- 没有用户时,
bring_up提交一个空注册表并返回Ok(()),不做绑定。否则它从cfg中复制出controller.cert、controller.listen_ip、api.enable_vless、!controller.disable_sniffing和[node.hysteria],克隆当前路由器,获取transport锁,调用TransportManager::start,保存结果并记录node <id>: listening on <listen_ip>:<port>。由于锁在start之前就已获取,start完成与保存结果之间没有任何.await。因此,一次在绑定之后被关闭打断的引导尝试已经保存了它的 generation,run的tear_down会按顺序关闭它,包括等待 QUIC endpoint 释放其 UDP socket。 tear_down把TransportManager从槽位中取出,并 awaitTransportManager::shutdown:它取消 accept scope 并等待其下的所有任务,让所有用户租约退役;对于 Hysteria 2 节点,还会 await QUIC endpoint,直到它释放 UDP socket。正是最后这一步让rebuild能够紧接着绑定同一个 UDP 端口。
TransportManager 还实现了 Drop 作为兜底:如果某个 generation 未经 shutdown 就被丢弃,它会取消 scope 并让所有用户退役。它无法等待任务结束,因此管理器中的每条路径都使用 shutdown。
绑定发生在旧 TransportManager 已消失之后的全新 TransportManager 中,因此完全重建期间会有一个端口关闭的短暂窗口。如果重建时绑定失败(例如另一个进程在这个窗口内占用了该端口),节点会保持无监听状态,直到之后某个周期成功。
各周期间的监听器状态
Section titled “各周期间的监听器状态”stateDiagram-v2 [*] --> Bootstrap Bootstrap --> Bootstrap: 尝试失败,等待后重试 Bootstrap --> Dark: 没有用户 Bootstrap --> Serving: bring_up 成功 Bootstrap --> Stopped: 关闭 Serving --> Serving: 原地刷新或无变化 Serving --> Serving: 完全重建成功 Serving --> Dark: 用户为空,或重建失败 Dark --> Serving: 下一个周期带着用户重建 Serving --> Stopped: 关闭 Dark --> Stopped: 关闭 Stopped --> [*]
Dark 表示任务仍然存活,但不持有 TransportManager。Bootstrap 会一直循环,直到节点启动成功或被停止;只要令牌仍有效,任务就不会结束。
async fn apply_static(&self, u: StaticUpdate)StaticUpdate::Config
Section titled “StaticUpdate::Config”一次配置编辑要么整体生效,要么完全不生效。apply_static 先构建这次编辑所需的内容,然后保存,最后用一次轮询周期应用它:
flowchart TB
start["StaticUpdate::Config(new)"]
client{"panel_type 或 api 是否变化?"}
newclient["PanelClient::new(new)"]
route{"route 是否变化?"}
router["build_router(new route, pool)"]
refused["拒绝:保留运行中的配置"]
store["保存 cfg、router、client"]
resync{"已引导,且需要 rebuild 或有新客户端?"}
poll["poll_cycle(rebuild)"]
stored["仅保存,稍后读取"]
start --> client
client -->|是| newclient
client -->|否| route
newclient -->|Err| refused
newclient -->|Ok| route
route -->|是| router
route -->|否| store
router -->|Err| refused
router -->|Ok| store
store --> resync
resync -->|是| poll
resync -->|否| stored
- 客户端。 当
panel_type或任一[node.api]字段不同时,PanelClient::new用新配置块构建一个客户端。客户端在构造时复制设置(节点类型、VLESS、限速覆盖、规则文件、超时),如果不换新客户端,节点会继续按旧值工作。新客户端会对旧客户端调用inherit_routes:newV2board 客户端接手旧客户端最近读到的路由,因此由其中block条目派生的审计规则会一直有效,直到新客户端自己读取节点配置。 - 路由器。 当
route不同时,build_router基于当前出站池编译新的[node.route],以"direct"作为回退 tag。 - 拒绝。 只要任一构建失败,管理器就以
error级别记录node <id>: config edit refused, keeping the running one: <error>并返回。这次编辑的任何内容都不会被保存,包括那些不需要构建的字段。 - 保存。 新
cfg替换旧配置。新路由器替换router并记录node <id>: router rebuilt;新客户端替换api。 - 应用。 当以下任一项变化时,这次编辑需要重建监听器:
route、controller.listen_ip、controller.cert、controller.disable_sniffing、api.enable_vless或[node.hysteria]中的任何字段。监听器是依据这些本地设置构建的,而它们变化时面板的回答并不会变。如果节点已完成引导(cur.node为Some),且这次编辑需要重建或带来了新客户端,apply_static会立即运行一次poll_cycle(rebuild)。
这次轮询是一个完整的周期。新客户端不持有任何 ETag,因此面板会完整回答,节点和用户都按编辑后的设置读取。随后,设置了 rebuild 时 reconcile 走完全重建分支,否则走新回答所需的最窄分支。这次轮询也会刷新规则,并发送流量和违规访问上报,与定时器触发的轮询一样。它不会重置轮询间隔。
对于仍处于引导阶段的节点,不会运行轮询:这次编辑会被保存,若无法构建则如第 3 步所述被拒绝。无论哪种情况,等待都立即结束,下一次尝试会用新客户端读取,并按新配置构建。
| 变化的字段 | 发生什么 | 何时生效 |
|---|---|---|
route(整个 [node.route] 表) |
新路由器,然后 poll_cycle(true):完全重建 |
立即 |
controller.listen_ip、controller.cert、controller.disable_sniffing |
poll_cycle(true):完全重建 |
立即 |
[node.hysteria] 的任何字段 |
poll_cycle(true):完全重建 |
立即 |
api.enable_vless |
新客户端,然后 poll_cycle(true):完全重建。在 newV2board 上对 V2ray 或 Vmess 节点而言,这会改变身份,运行时会重新 spawn 该节点 |
立即 |
api.speed_limit、api.rule_list_path |
新客户端,然后 poll_cycle(false):原地刷新,除非新回答改变了传输层或协议;同一次轮询会刷新规则 |
立即 |
api.timeout 以及身份之外的其他 [node.api] 字段(vless_flow、device_limit、disable_custom_config、SSPanel 上的 node_type、newV2board 上仅大小写不同的 node_type 变化),以及仅大小写不同的 panel_type 变化 |
新客户端,然后 poll_cycle(false):由 reconcile 阶梯根据新回答决定 |
立即 |
controller.update_periodic |
保存;select 循环替换 interval | 下一次轮询,在一个新周期之后 |
controller.disable_get_rule、controller.disable_upload_traffic |
保存 | 下一个轮询周期 |
路由变化会重建整个传输层,而不是取消已认证的连接,因为要求是一次路由编辑必须断开节点上的所有连接,包括仍处于握手阶段的连接。仅取消用户租约会漏掉这些连接。重建保持字节计数的连续性:NodeTraffic 持续存在,因此 prepare 会复用 key、uid 和速率都未变化的用户的计数器。
StaticUpdate::Outbounds
Section titled “StaticUpdate::Outbounds”新的出站池替换 pool,rebuild_router 重新编译路由器,apply_route_change 依据 cur.node 和 cur.users 重建传输层。出站池变化时每个节点都会收到这个更新,因此一次 [[outbound]] 编辑会断开所有已启动节点上的所有连接。仍处于引导阶段的节点还没有 cur.node,因此只会保留新路由器供下一次尝试使用。
runtime::apply_reload 在移除、重新配置或新增任何节点之前,先把 Outbounds 发送给每个运行中的节点。由此产生两个后果:
- 被同一次重载移除的节点,仍可能基于新出站池重建一次。运行时在把更新放入队列后立即取消它的令牌,而两者同时就绪时
serve循环会随机选一个,因此这次重建是否发生取决于时序。 - 一次同时修改了
[[outbound]]和某个节点[[node]]块(且该修改会触发重建)的重载,会让该节点连续重建两次:一次因为Outbounds,另一次因为Config,经由它的poll_cycle(true)。select 循环依次处理二者,中间可能夹着一次定时器轮询。
rebuild_router
Section titled “rebuild_router”fn rebuild_router(&self)只有 Outbounds 路径会调用它。它基于新出站池编译当前的 cfg.route,以 "direct" 作为回退 tag。成功时替换 router 并记录 node <id>: router rebuilt。失败时记录 node <id>: route rebuild failed, keeping current: <error> 并保留旧路由器。apply_route_change 仍会执行,因此新监听器绑定时使用的是出站池变化之前的路由器。
大多数路由器错误在此之前就会被发现。运行时在发送 Outbounds 之前就已构建好新的出站池,并且在构建失败时拒绝整个重载,因此有问题的 [[outbound]] 永远不会到达节点。它还会在触碰运行中节点之前,先构建本次重载新增的每个节点(包括路由器)。apply_static 会拒绝无法编译的路由编辑。留给 rebuild_router 的只剩重载所保留节点的未变化路由:路由或 default 引用了新出站池中已不存在的 tag(例如某个 [[outbound]] 被移除之后),或者 geodata 已无法加载。
refresh_rules
Section titled “refresh_rules”async fn refresh_rules(&self)api().node_rule() 返回 Ok(Some(rules)) 时,通过 RuleManager::update 替换 cur.node_tag 下存放的规则;如果新列表的 id 和模式字符串与现有列表相同且顺序一致,RuleManager::update 会跳过写入。Ok(None) 保留当前规则,Err 以 warn 级别记录为 node <id>: node_rule: <error>。只有 SSPanel 客户端会在这里发起请求,也只有它会返回 Ok(None) 或 Err。newV2board 客户端总是返回 Ok(Some):它根据 api.rule_list_path 和最近一次返回了响应体的 node_info 所缓存的 block 路由(或从被它替换的客户端继承而来的路由)构建列表,不联系面板。
report_traffic
Section titled “report_traffic”async fn report_traffic(&self)设置了 controller.disable_upload_traffic 时,它调用 traffic.clear_residuals() 和 traffic.prune_draining() 后返回。既然永远不会发送任何数据,这两个集合都不能在进程的生命周期内持续增长。
否则:
traffic.snapshot()为每个活跃计数器、每个 draining 计数器和每个暂存的残余量各返回一行。没有剩余写入方(Arc::strong_count == 1)的 draining 计数器会作为无计数器的行输出,并从 draining 集合中移除。- 跳过
up == 0 && down == 0的行。 - 其余行按
uid用饱和加法求和。同一用户的残余量行或 draining 行可能与活跃计数器同时存在,而 newV2board 的载荷以 uid 为键,两行会互相覆盖。 - 带计数器的行进入提交列表,不带计数器的行进入残余量列表。
- 如果没有任何 uid 有字节,就不发送。
- 一次
api().report_user_traffic(&reports)调用携带所有 uid。 - 成功时,提交列表中的每个计数器执行
commit_reported(up, down),它精确减去已上报的数量,因此请求期间新增的字节留待下一次上报。 - 失败时,
traffic.restore_residuals(residuals)把无计数器的行重新暂存,日志为node <id>: report traffic: <error>。活跃计数器没有被改动,因此仍保有它们的字节。
这就是上报失败不丢任何数据、重试也不会重复计费的原因。注册表一侧(prepare、commit、draining 和残余量)见流量计费页面。
report_illegal
Section titled “report_illegal”async fn report_illegal(&self)它取出 cur.node_tag 下记录的命中(RuleManager::drain),有命中时通过 api().report_illegal 发送。命中在请求之前就已取出,因此上报失败时会记录为 node <id>: report illegal: <error>,这些命中不会再次发送。newV2board 客户端的 report_illegal 是空操作;SSPanel 客户端会排除本地规则(规则 id 为 -1,来自 rule_list_path)的命中,没有面板规则被命中时不发送任何内容。
src/manager/mod.rs 中的辅助函数
Section titled “src/manager/mod.rs 中的辅助函数”pub(crate) fn user_key(by_email: bool, u: &UserInfo) -> Option<AuthKey>;pub(crate) fn user_tag(by_email: bool, u: &UserInfo) -> Option<Arc<UserTag>>;pub(crate) fn node_tag(node: &NodeInfo, listen_ip: &str) -> CompactString;pub(crate) fn build_user_entries( node: &NodeInfo, users: &[UserInfo],) -> (Vec<UserEntry>, Vec<UserInfo>);pub(crate) fn user_set_differs(a: &[UserInfo], b: &[UserInfo]) -> bool;| 辅助函数 | 行为 |
|---|---|
user_key |
若 by_email 为真,返回 AuthKey::Name(traffic_email(u)):面板邮箱,邮箱为空时为 uid 的字符串形式。否则返回 u.uuid 的 AuthKey::Uuid,无法解析时返回 None。by_email 来自 NodeType::keys_by_email,对 V2ray 为 false,对 Trojan、Shadowsocks 和 Hysteria2 为 true。 |
user_tag |
把 user_key 连同用户的 uid 包装为 Arc<UserTag>:即协议用户表为每个凭据携带的载荷。 |
node_tag |
Type_listenip_port,其中 Type 为 V2ray、Trojan、Shadowsocks 或 Hysteria2,例如 V2ray_0.0.0.0_443。用作审计规则的键、dispatcher 的 tag 以及握手失败日志的 tag。 |
build_user_entries |
为每个有 key 的用户生成一个 UserEntry { key, uid, rate },其中 rate = determine_rate(node.speed_limit, u.speed_limit),同时返回这些用户的列表。没有 key 的用户(只可能出现在 uuid 无法解析的 V2ray 节点上)会被跳过,并以 warn 级别记录 skipping user <uid>: uuid is not a valid UUID;日志只包含 uid,从不包含凭据。 |
user_set_differs |
对整个 UserInfo(它派生了 Eq 和 Hash)做与顺序无关的集合比较。长度不同即视为不同;否则检查 b 的每个条目是否都在由 a 构成的 HashSet 中。任何字段变化,包括密码或限速,都算作差异。 |
src/traffic.rs 中的 determine_rate 返回两个限制中较小的那个,其中 0 表示该侧不限速:(0, 0) 为 0,(0, u) 为 u,(n, 0) 为 n,(n, u) 为 n.min(u)。
TransportManager::start 和 ProxyManager::refresh 都经过 build_user_entries,并且都把同一个 by_email 传给 user_tag。这是注册表键和协议表键唯一的推导位置,因此二者不会在“某种节点类型如何为用户生成键”这一点上产生分歧。
| 不变量 | 保证机制 | 对应测试 |
|---|---|---|
| 用户集变化时,未变化用户的现有连接保留,已离开用户的连接断开。 | reconcile 把用户变化交给 ProxyManager::refresh;NodeTraffic::prepare 保留 key、uid 和速率都相同的计数器,并列出需要退役的已离开用户的 key。 |
tests/unit/e2e.rs 中的 unchanged_user_survives_user_refresh |
| Hysteria 2 的用户刷新从不重新绑定 UDP socket。 | Tables::Hysteria 只替换认证器(Hy2Inbound::set_authenticator);准入会拒绝已退役用户的新流。 |
tests/unit/e2e.rs 中的 a_retired_user_stops_while_the_rest_keep_their_connections、repeated_user_refreshes_never_disturb_a_live_connection |
| 路由编辑会断开节点上的所有连接。 | apply_static 构建新路由器并运行 poll_cycle(true),其中的 reconcile 走完全重建分支。 |
tests/unit/e2e.rs 中的 route_change_drops_connections |
| 无法启动的节点会持续重试,并在原因消除后启动。 | bootstrap 以有上限、逐次翻倍的等待重试 try_bootstrap;每次尝试都调用 forget_etags,因此 304 不会让它缺少节点描述或用户。 |
tests/unit/e2e.rs 中的 a_node_comes_up_once_the_panel_answers、a_node_whose_port_is_taken_comes_up_once_it_is_free |
| 仍在重试引导的节点能及时停止。 | bootstrap 在每次尝试和每次等待期间,都在 biased 的 select! 中优先监听令牌。 |
tests/unit/e2e.rs 中的 a_node_that_never_bootstraps_still_stops |
| 对构建面板客户端所用字段的配置编辑,会在运行中的节点上生效。 | apply_static 构建新的 PanelClient 并运行 poll_cycle,后者不带 ETag 读取并执行 reconcile。 |
tests/unit/runtime.rs 中的 an_sspanel_api_edit_takes_effect_in_place、a_client_edit_takes_effect_without_dropping_connections |
| 含有无法构建节点的重载不会改动运行中的节点。 | runtime::apply_reload 在应用任何内容之前,先构建新增节点并检查每个变化节点的面板客户端;apply_static 拒绝客户端或路由器无法构建的编辑。 |
tests/unit/runtime.rs 中的 a_reload_with_a_node_that_does_not_build_changes_nothing |
| 每个中继的字节都归属到已认证的 uid 并被上报。 | bring_up 与 dispatcher 共享同一个 NodeTraffic;report_traffic 按 uid 求和。 |
tests/unit/e2e.rs 中的 vmess_traffic_is_metered_and_reported、proxy_outbound_relays_and_meters、a_hysteria_node_relays_and_meters |
| 由面板描述的 Hysteria 2 节点从面板获取端口和混淆设置。 | hysteria.port == 0 时 node_info 询问面板;obfs_type 和 obfs_password 属于 transport_eq。 |
tests/unit/e2e.rs 中的 a_panel_described_hysteria_node_serves_obfuscated_traffic |
| 面板从未签发的凭据无法代理。 | 认证器只由 build_user_entries 产出的有效用户构建。 |
tests/unit/e2e.rs 中的 a_hysteria_node_refuses_an_unknown_credential |
| 上报失败不丢字节,重试不重复计费。 | commit_reported 只在成功时运行,并减去已上报的数量;失败时恢复残余量行。 |
tests/unit/traffic.rs 中的 restored_residuals_are_retried、commit_reported_preserves_concurrent、rate_change_drains_old_counter_and_reports_once、a_departed_users_late_bytes_are_still_reported、rebound_credential_reports_the_old_uid_separately |
| 用户的有效速率是较小的非零限制。 | determine_rate |
tests/unit/traffic.rs 中的 determine_rate_min_nonzero |
| 相同的规则列表不会替换已存放的列表;取出的命中会被清空。 | RuleManager::update 比较 id 和模式字符串;RuleManager::drain 清空集合。 |
tests/unit/rule.rs 中的 update_skips_identical_ruleset、detect_records_and_drains |
disable_sniffing 会传递到监听器。 |
bring_up 把 !controller.disable_sniffing 传给 TransportManager::start。 |
tests/integration/sniff.rs 中的 a_sniffed_host_reaches_a_domain_rule、disable_sniffing_stops_the_domain_rule_matching |
poll_cycle 运行时 cur.node 始终为 Some。 |
serve 只在 bootstrap 返回 true 之后运行,而这要求 try_bootstrap 已执行 set_cur(Some(node), …);apply_static 只在 cur.node 为 Some 时轮询;其他每次 set_cur 都传入 Some。 |
无测试;否则 poll_cycle 会让节点任务 panic |
| 不会泄漏任何监听器 generation。 | 每条路径都经过 tear_down,它会 await TransportManager::shutdown;Drop 作为兜底执行取消。 |
无专门测试 |
失败路径与取消
Section titled “失败路径与取消”| 失败 | 位置 | 结果 |
|---|---|---|
| spawn 时路由器构建失败(未知出站 tag、错误的匹配条件、geodata) | NodeManager::new |
启动时节点不会被 spawn,只有在一个节点都没 spawn 成功时进程才退出;在新增该节点的重载中,整个重载被拒绝 |
引导时 node_info 或 user_list 出错或返回 Ok(None)、端口为 0,或 bring_up 出错 |
try_bootstrap |
本次尝试失败并记录日志;1 秒后重试,逐次翻倍到 60 秒,且不超过轮询周期;Config 编辑会让它立即重试 |
| 引导时用户列表为空 | try_bootstrap |
节点以无监听状态启动;第一次带用户的轮询会构建监听器 |
轮询时 node_info 或 user_list 出错 |
poll_cycle |
沿用最近一次应用的值;不做任何改变 |
| 轮询得到端口为 0 的描述 | poll_cycle |
保留最近一次应用的描述;面板返回的用户列表仍会被 reconcile |
| 客户端或路由器无法构建的配置编辑 | apply_static |
整个编辑被拒绝;节点继续按当前配置运行 |
| 完全重建失败 | rebuild |
节点无监听;每个周期通过 !has_transport 重试 |
| 原地刷新构建失败 | ProxyManager::refresh |
表和注册表不变 |
Outbounds 更新后的路由器重建失败 |
rebuild_router |
保留旧路由器;传输层仍会重建 |
| 流量上报失败 | report_traffic |
恢复残余量,计数器不变;下个周期重试 |
| 违规访问上报失败 | report_illegal |
命中被丢弃 |
| 最终 flush 失败 | run 退出 |
字节随任务一起丢失 |
取消来自节点的 CancellationToken,它是进程根令牌的子令牌。运行时在收到 SIGINT 或 SIGTERM 时取消它,在重载移除该节点或改变其身份时也会取消它。引导阶段用一个 biased 的 select! 监听令牌,既包住每次尝试,也覆盖每次等待,因此尚未启动的节点被取消时会立即停止,并丢弃所有进行中的面板请求。在 serve 循环内部,取消在迭代之间生效。随后运行时 await 该任务,其中包括监听器拆除和两次最终上报。
| 名称 | 值 | 位置 |
|---|---|---|
update_periodic 默认值 |
60 秒 | src/config.rs 中的 default_update_periodic |
| 最小轮询周期 | 1 秒 | NodeManager::poll_period(update_periodic.max(1)) |
| 静态更新 channel | 16 个更新 | runtime::spawn_built 中的 mpsc::channel(16);channel 满时运行时的 send().await 会等待;节点在 bootstrap 和 serve 中都会取出其中的更新 |
| 引导首次重试等待 | 1 秒 | src/manager/node.rs 中的 BOOTSTRAP_RETRY_MIN |
| 引导最长重试等待 | 60 秒,若轮询周期更短则取轮询周期 | src/manager/node.rs 中的 BOOTSTRAP_RETRY_MAX;NodeManager::bootstrap 把每次等待限制在 poll_period() 以内 |
| 面板请求超时 | api.timeout 秒,为 0 时 5 秒 |
src/config.rs 中的 ApiConfig::timeout_secs |
| 路由器回退 tag | "direct" |
NodeManager::new、apply_static、rebuild_router |
| 每个监听器 generation 的预认证 stream 数(stream 节点) | 512 | src/manager/proxy.rs 中的 MAX_PREAUTH_STREAMS_PER_NODE |
| 握手失败告警阈值 | 每秒超过 10 次 | src/manager/proxy.rs 中的 HANDSHAKE_FAILURE_ALERT_PER_SEC |
最后两项属于 bring_up 创建的 ProxyManager,因此完全重建会重置它们,原地刷新则不会。
管理器本身没有单元测试,而是由 tests/unit/e2e.rs 做端到端覆盖(src/main.rs 把该文件作为 e2e 模块编译进二进制的测试 harness),并由 tests/unit/runtime.rs 中的重载测试覆盖(src/runtime.rs 把该文件编译为它的 tests 模块)。
每个 e2e 测试都会在一个 loopback 端口上启动一个手写的假 newV2board 面板,它提供 /UniProxy/config 和 /UniProxy/user,并记录每个 /UniProxy/push 请求体。fake_panel、fake_panel_dynamic 和 fake_panel_with_config 都是 fake_panel_quirky 的薄封装;fake_panel_quirky 接收一个 Quirks 值,并额外返回节点配置被请求的次数:
Quirks 字段 |
作用 |
|---|---|
config_failures |
在第一次正常回答之前,先对这么多个 /UniProxy/config 请求返回 500 Internal Server Error,模拟尚未就绪的面板 |
etags |
为每个回答打上 ETag,并对带回该 tag 的请求返回 304 Not Modified |
fake_sspanel 提供一个 SSPanel mod_mu 面板,其中有一个通过旧式 server 字符串描述的 V2ray 节点和一个用户。test_node_cfg 设置 update_periodic = 1;e2e 的 spawn_node 辅助函数用传入的配置构建真实的 newV2board PanelClient 和 NodeManager,spawn run,并返回静态更新 sender,使 channel 保持打开。运行时测试则像 runtime::run 那样持有节点,并通过 apply_reload 传入编辑后的配置。
| 测试 | 它证明了管理器的什么行为 |
|---|---|
vmess_traffic_is_metered_and_reported |
引导会绑定 VMess 节点;report_traffic 以 uid 1001 上报中继的字节 |
unchanged_user_survives_user_refresh |
新增用户时保留用户 A 的连接;移除 A 时断开其连接;A 在两个阶段中的字节都被上报 |
proxy_outbound_relays_and_meters |
new 中构建的路由器能到达代理出站,计量仍能正确归属字节 |
route_change_drops_connections |
route 不同的 StaticUpdate::Config 会断开现有连接 |
a_hysteria_node_relays_and_meters |
本地 Hysteria 2 描述(hysteria.port != 0)能绑定、中继并上报 |
a_hysteria_node_refuses_an_unknown_credential |
Hysteria 2 认证器只包含面板用户 |
a_retired_user_stops_while_the_rest_keep_their_connections |
Hysteria 2 刷新让用户 B 退役,而 A 的 QUIC 连接继续工作 |
a_panel_described_hysteria_node_serves_obfuscated_traffic |
hysteria.port == 0 时,端口和 Salamander 密钥来自面板 |
repeated_user_refreshes_never_disturb_a_live_connection |
连续多次刷新后,一条 QUIC 连接依然完好 |
a_node_comes_up_once_the_panel_answers |
前两次配置请求失败时,引导会重试,节点在第三次请求后绑定并中继 |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
绑定失败会被重试;面板通过 ETag 返回 304 时,每次尝试仍会完整读取节点和用户 |
a_node_that_never_bootstraps_still_stops |
针对始终失败的面板重试的节点,在取消后 2 秒内停止 |
tests/unit/runtime.rs 中涉及管理器的重载测试:
| 测试 | 它证明了管理器的什么行为 |
|---|---|
an_sspanel_node_is_its_panel_node_id、a_newv2board_node_is_also_the_type_it_asks_for |
哪些 [[node]] 编辑会改变身份(重新 spawn),哪些会作为 Config 更新到达节点 |
a_newv2board_type_edit_respawns_the_node |
在 newV2board V2ray 节点上关闭 enable_vless 会重新 spawn 该节点,新节点提供 VMess 服务 |
a_reload_with_a_node_that_does_not_build_changes_nothing |
未知的 node_type 加上一次路由编辑,不会改动运行中的节点及其现有连接 |
an_sspanel_api_edit_takes_effect_in_place |
在 SSPanel 上关闭 enable_vless 会到达运行中的节点,节点重建其客户端和监听器,并提供 VMess 服务 |
a_client_edit_takes_effect_without_dropping_connections |
一次 rule_list_path 编辑得到新客户端,其规则立即拒绝通往目标的新流,而已打开的连接继续中继 |
在 katana 仓库目录中运行:
cargo test --locked e2e::cargo test --locked runtime::tests::端口为 0 的检查、Outbounds 更新、report_traffic 中按 uid 合并以及退出时的 flush,都没有单独的测试覆盖。如果修改了其中任何一项,请在 tests/unit/e2e.rs 中添加测试。try_bootstrap 和 poll_cycle 中的端口为 0 检查,需要一个解析后端口为 0 的描述。newV2board 客户端先以 i64 检查 server_port 是否为 0,然后才把它转换为 u16,因此通过假面板只有截断后为 0 的值(例如 65536)才能走到这些检查。