跳转到内容

进程运行时与重载

源码文件:14 个 · 核对版本 katana v3.0.1
  • katana/src/main.rs
  • katana/src/runtime.rs
  • katana/src/config.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/tests/unit/runtime.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/support/mod.rs
  • katana/tests/integration/xray_interop.rs
  • katana/tests/integration/sniff.rs
  • katana/tests/integration/hysteria_interop.rs

src/runtime.rs 是 katana 进程的根。它加载一次配置文件,构建全局出站池,为每个 [[node]] 启动一个 NodeManager 任务,然后进入一个循环,等待关闭信号或配置目录中的变化。文件变化时,它把新配置与正在运行的配置比较,构建这些差异所需的一切,只要其中任何一项失败就拒绝整次重载。否则它按固定顺序分发差异:新的日志过滤器、发给每个节点的新出站池、被移除的节点,最后按文件顺序遍历一次新的 [[node]] 列表,重新配置保留的节点并启动新增的节点。

本页面向修改这个循环、节点身份规则或 StaticUpdate 通道的贡献者,从进程的视角描述运行时。节点收到更新之后做什么,在 节点管理器 页面有详细说明;同一套机制在运维视角下的描述见 热重载。

关注点 负责者 交给谁
命令行、--test、退出码 src/main.rs → main,src/runtime.rs → test_config 用法错误交给 clap
日志 subscriber 与运行中的级别切换 src/runtime.rs → init_tracing、LogReload tracing_subscriber::reload
出站池及其共享的 DNS 解析器 src/runtime.rs → build_outbounds 每个条目交给 src/outbound → build_outbound
每个节点一个任务,连同它的通道和取消令牌 src/runtime.rs → build_node、spawn_built、spawn_node、NodeHandle 节点内部的一切交给 NodeManager::run
配置文件监视与防抖 src/runtime.rs → setup_watcher 以及 run 中的循环 notify crate
计算重载差异并分发 src/runtime.rs → apply_reload、identity NodeManager::apply_static
SIGINT 与 SIGTERM src/runtime.rs → wait_for_shutdown 各节点自己的拆除流程和最终上报

运行时从不接触监听器、用户表或流量计数器。节点边界以下的一切只能通过每个节点的两个通道触达:用于停止节点的 CancellationToken,以及用于驱动节点的 mpsc::Sender<StaticUpdate>。

与独立 app 不同,katana 没有整个进程的 generation(一代实例)切换。取而代之的是,一次重载在应用任何东西之前先完成构建和校验:新的出站池、重载新增的每个节点,以及每个表有变化的节点的面板客户端。其中任何一项失败,正在运行的进程都原样保持不变。此后,每个节点在更新要求时原地重建自己的监听器。app 的做法见 Generation 与热重载。

src/main.rs 解析两个参数后交给运行时:

#[derive(Parser)]
#[command(version, about)]
struct Args {
#[arg(short, long, default_value = "config.toml")]
config: PathBuf,
#[arg(long)]
test: bool,
}
#[tokio::main]
async fn main() -> ExitCode

main 总是先调用 runtime::init_tracing(&args.config),因此试运行与真实运行使用同一个 subscriber 输出日志。带 --test 时,它调用 runtime::test_config,成功时向 stdout 打印 Configuration OK,失败时向 stderr 打印 configuration error: <error>,然后返回。否则它 await runtime::run(args.config, reload)。运行时是 #[tokio::main] 提供的默认多线程 Tokio 运行时。

退出码 何时
0 --test 通过;run 在收到关闭信号后返回;或 clap 打印了 --help 或 --version
1 --test 失败;或启动时配置未能加载、出站池未能构建、文件中没有 [[node]],或没有任何节点能够启动
2 clap 报告的用法错误(clap 的默认行为)

已经启动但无法就绪的节点(例如面板不可达)不会改变退出码:它在自己的任务中不断重试;见 节点任务生命周期。

pub type LogReload = Box<dyn Fn(&str) + Send + Sync>;
type Pool = Arc<HashMap<CompactString, Arc<Outbound>>>;
type NodeId = (String, String, u32, String, String);
  • LogReload 是 init_tracing 返回的设置函数。[log].level 变化时,运行时以级别字符串调用它。
  • Pool 是全局出站池,以 tag 为键。它是一个 Arc,因此一次重载可以把同一个 map 交给每个节点而无需复制;每个节点持有自己的一份 Arc 克隆,并据此编译自己的路由器。
  • NodeId 是 [[node]] 在多次重载之间据以匹配的身份;见 节点身份。
fn identity(cfg: &NodeConfig) -> NodeId
fn display_id(id: &NodeId) -> String

identity 构造元组 (panel_type lowercased, api.host, api.node_id, api.key, api::panel_node_type(cfg)),其中 panel_type 转为小写。第五个元素是面板据以找到该节点的节点类型,见 节点身份;对 sspanel 而言它是空字符串。

display_id 把身份渲染为日志中的形式。节点类型非空时打印 panel_type@host#node_id/node_type,例如 newv2board@https://panel.example.com#1/v2ray;对 sspanel 则打印 panel_type@host#node_id,例如 sspanel@https://panel.example.com#1。它有意丢弃第四个元素:身份中带有 api.key,而 display_id 是运行时打印身份的唯一方式,因此密钥永远不会出现在重载日志行中。

struct NodeHandle {
id: NodeId,
cfg: NodeConfig,
task: JoinHandle<()>,
static_tx: mpsc::Sender<StaticUpdate>,
shutdown: CancellationToken,
}

每个已启动的节点对应一个 handle,保存在 run 所拥有的 Vec<NodeHandle> 中。它就是运行时对节点的全部认知:

字段 用途
id 与下一份配置中的身份进行匹配
cfg 最近一次发给节点的 [[node]] 表,用 != 比较以决定是否需要发送 StaticUpdate::Config
task 节点被移除时和关闭时被 await,确保节点的最终流量上报完成后运行时才继续
static_tx 节点更新通道的发送端,容量 16
shutdown 根令牌的子令牌;取消它只会停止这一个节点

更新类型位于 src/manager/mod.rs:

pub enum StaticUpdate {
Config(Box<NodeConfig>),
Outbounds(Arc<HashMap<CompactString, Arc<Outbound>>>),
}

Config 携带一个身份不变但内容有变化的 [[node]] 表。Outbounds 携带重新构建的出站池。身份变化由运行时自行处理(移除加新增),因此节点永远不会收到属于另一个面板节点的 Config。Config 仍可能改动节点面板客户端构建时所依据的 [node.api] 字段,例如 api.timeout;此时节点会自行构建一个新客户端(见 节点如何应用静态更新)。

impl NodeManager {
pub fn new(
api: PanelClient,
cfg: NodeConfig,
pool: Arc<HashMap<CompactString, Arc<Outbound>>>,
) -> io::Result<Arc<Self>>
pub async fn run(
self: Arc<Self>,
shutdown: CancellationToken,
mut static_rx: mpsc::Receiver<StaticUpdate>,
)
async fn bootstrap(
&self,
shutdown: &CancellationToken,
static_rx: &mut mpsc::Receiver<StaticUpdate>,
) -> bool
async fn serve(
&self,
shutdown: &CancellationToken,
static_rx: &mut mpsc::Receiver<StaticUpdate>,
)
async fn apply_static(&self, u: StaticUpdate)
}

NodeManager::new 以 direct 为默认 tag,针对出站池编译节点的路由器;如果某条路由引用了未知的出站 tag 则失败。它不绑定任何端口。管理器以 Mutex<Arc<PanelClient>> 持有自己的面板客户端,以便 apply_static 换入新的客户端。run 是节点任务的主体:先 bootstrap 直到节点就绪,然后 serve,最后拆除并做最终上报。apply_static 是 bootstrap 和 serve 对来自 static_rx 的每条消息调用的函数。

pub async fn run(config_path: PathBuf, reload: LogReload) -> ExitCode
fn spawn_node(cfg: &NodeConfig, pool: &Pool, root: &CancellationToken) -> Option<NodeHandle>
fn build_node(cfg: &NodeConfig, pool: &Pool) -> anyhow::Result<Arc<NodeManager>>
fn spawn_built(nm: Arc<NodeManager>, cfg: &NodeConfig, root: &CancellationToken) -> NodeHandle
async fn apply_reload(
cfg: &mut Config,
pool: &mut Pool,
handles: &mut Vec<NodeHandle>,
new: Config,
reload: &LogReload,
root: &CancellationToken,
)
fn setup_watcher(
config_path: &Path,
ev_tx: mpsc::UnboundedSender<()>,
) -> notify::Result<RecommendedWatcher>
pub fn build_outbounds(cfg: &Config) -> io::Result<HashMap<CompactString, Arc<Outbound>>>
pub fn test_config(path: &Path) -> anyhow::Result<()>
pub fn init_tracing(config_path: &Path) -> LogReload
async fn wait_for_shutdown()

运行时为节点构建的面板客户端来自 src/api/mod.rs:

impl PanelClient {
pub fn new(cfg: &NodeConfig) -> anyhow::Result<Self>
}
pub fn panel_node_type(cfg: &NodeConfig) -> String

run 在进程整个生命周期内持有三份可变状态:cfg: Config(最近一次应用的配置)、pool: Pool 和 handles: Vec<NodeHandle>。其他一切都只在单个步骤内有效。

  1. 加载。 config::load(&config_path) 读取文件并用 toml 解析为 Config。所有配置结构体都带有 #[serde(deny_unknown_fields)],因此拼错的键会在这里失败。出错时 run 记录 failed to load config: … 并返回 ExitCode::FAILURE。

  2. 构建出站池。 build_outbounds(&cfg) 构建解析器和每个出站。出错时 run 记录 failed to build outbounds: … 并失败。

  3. 要求存在节点。 cfg.nodes 为空时记录 config defines no [[node]] entries 并失败。这项检查在构建出站池之后,因此错误的 [[outbound]] 会先被报告。

  4. 启动节点。 run 创建根 CancellationToken,并按文件顺序对每个 [[node]] 调用 spawn_node。无法构建的节点会被记录并跳过。如果一个 handle 都没有得到,run 记录 no nodes could be started 并失败。

  5. 监视文件。 创建一个无界的 mpsc::unbounded_channel::<()>(),并把发送端交给 setup_watcher。如果监视器无法建立,run 记录 config watcher disabled (no live reload): … 并在没有监视器的情况下继续运行。返回的 RecommendedWatcher 保存在局部变量 _watcher 中直到 run 返回;丢弃它会停止监视。

  6. 挂上信号处理。 一个分离的任务 await wait_for_shutdown(),然后取消根令牌。

  7. 进入循环。 run 进入 select 循环 中描述的循环。

build_outbounds 为整个进程生成一个出站池。它先通过 Resolver::from_spec(ResolverSpec { … }) 从 [dns] 构建一个 Resolver,如果设置了 dns.ca_file 则从磁盘读取。池中所有出站共享这个解析器,从而共享它的缓存,因为这些出站要解析的域名大量重叠。

在查看文件之前,它先预置四个保留 tag:

Tag 处理器
direct Outbound::Direct(FreedomConnector::new(resolver.clone(), AddressFamilyStrategy::Auto))
block Outbound::Block
freedom 与 direct 构建方式相同、但彼此独立的第二个 Outbound::Direct(Xray 别名)
blackhole Outbound::Block(Xray 别名)

随后每个 [[outbound]] 用 build_outbound(entry, &resolver) 构建,并以其 tag 插入。如果 tag 已经存在,无论是保留 tag 还是前面某个条目用过的 tag,整个出站池都会以 duplicate/reserved outbound tag <tag> 失败。这项检查是精确的 HashMap 查找,因此区分大小写:Direct 会被当作普通 tag 接受。

各协议的具体构建见 入站与出站,解析器见 DNS。

启动一个节点分为两半。build_node(cfg, pool) 完成所有可能失败的工作,且不绑定任何端口:

  1. PanelClient::new(cfg) 按 panel_type(不区分大小写)选择客户端:sspanel 构建 PanelClient::Sspanel,newv2board 或 v2board 构建 PanelClient::NewV2board,其他值以 unknown panel_type "<value>" 失败,其中的值以小写形式显示。两种客户端的构造函数还会以 unknown node_type "<value>" 拒绝未知的 api.node_type,并用面板超时构建各自的 reqwest::Client。
  2. NodeManager::new(api, cfg.clone(), pool.clone()) 编译路由器。失败变为 build router: <error>。

spawn_built(nm, cfg, root) 不会失败:

  1. mpsc::channel(16) 创建更新通道,root.child_token() 创建节点自己的令牌。
  2. tokio::spawn(nm.run(shutdown.clone(), static_rx)) 启动任务,并连同 identity(cfg) 和一份配置克隆返回 handle。

启动时,spawn_node 先调用 build_node,再调用 spawn_built。build_node 失败时,它记录 node <node_id>: <error>(路由器失败时为 node <node_id>: build router: <error>)并返回 None,因此一个错误的 [[node]] 永远不会让进程中止。

重载不使用 spawn_node。它在第一阶段为每个新增节点调用 build_node,如果构建了新出站池就针对新池,否则针对正在运行的池;只有在整次重载被接受之后,才用 spawn_built 启动这些预先构建好的管理器;见 apply_reload。

从运行时一侧看,节点任务会经历下面这些状态。运行时不直接观察其中任何一个:它只持有 JoinHandle,并且只在 await 时才会用到它。

stateDiagram-v2
  [*] --> Bootstrap: tokio spawn
  Bootstrap --> Serving: 节点就绪
  Bootstrap --> Waiting: 本次尝试失败
  Waiting --> Bootstrap: 退避时间已到或收到 StaticUpdate Config
  Serving --> Serving: 轮询 tick 或 StaticUpdate
  Bootstrap --> Draining: 令牌被取消
  Waiting --> Draining: 令牌被取消
  Serving --> Draining: 令牌被取消
  Draining --> Exited: 拆除、上报流量、上报违规
  Exited --> [*]
  • Bootstrap(引导)。 NodeManager::bootstrap 在一个针对令牌的 biased tokio::select! 中运行一次尝试 try_bootstrap,因此被取消的节点会立即停止,即使正处于某个面板请求当中。每次尝试先对面板客户端调用 forget_etags,使面板完整作答,而不是对早先尝试已经发过的请求回以 304 Not Modified。随后这次尝试先获取节点信息(来自面板;对于设置了 [node.hysteria].port 的 Hysteria 2 节点,则来自配置),再获取用户列表,然后调用 bring_up,最后在未设置 controller.disable_get_rule 时拉取审计规则。每个面板请求都受面板超时限制(api.timeout,未设置时为 5 秒,来自 ApiConfig::timeout_secs)。一次尝试会因以下原因之一失败:

    原因 起因
    panel returned no node info 节点信息请求得到 304 Not Modified
    node_info failed: <error> 节点信息请求失败
    panel returned port 0 节点信息中的端口为 0。面板给出端口 0 时,只有 SSPanel 节点会出现这一行:newV2board 客户端自己就会拒绝值为 0 的 server_port,因此在那里本次尝试以 node_info failed: newV2board: server port must be > 0 失败。newV2board 的 server_port 若转换为 u16 后截断为 0(例如 65536),会通过那项检查并走到这一项
    panel returned no user list 用户列表请求得到 304 Not Modified
    user_list failed: <error> 用户列表请求失败
    initial start failed: <error> bring_up 失败,例如端口仍被另一个进程占用

    用户列表是必需的:没有用户列表,节点不会就绪。不过空列表不算失败;节点以零个用户就绪、不绑定任何端口,与协调阶梯对没有用户的节点的处理一致。规则拉取失败不会让这次尝试失败。

  • Waiting(等待)。 一次尝试失败后,节点记录 node <node_id>: <reason>; retrying in <n>s 并等待。第一次等待为 BOOTSTRAP_RETRY_MIN(1 s);每次尝试后等待时间翻倍,最多到 BOOTSTRAP_RETRY_MAX(60 s),并且每次等待都不超过轮询周期,所以每隔几秒轮询一次的节点也会以同样的频率重试。整个等待由一个计时器覆盖,因此期间到达的更新不会推迟重试。等待期间,节点会应用通道中的每个 StaticUpdate;由于还没有任何东西在运行,更新只是保存它携带的内容。StaticUpdate::Config 会结束等待并立即开始下一次尝试,因为这次修改可能正是修复。下一次等待仍然翻倍。

  • Serving(服务中)。 对令牌、轮询间隔和 static_rx 做 tokio::select!。每处理一个 StaticUpdate 后,节点重新读取 controller.update_periodic,如果它变了,就替换 interval 并消耗掉其立即触发的第一个 tick,使下一次轮询发生在一个新周期之后。

  • Draining(排空)。 无论处于哪个状态,令牌一旦触发,run 就调用 tear_down(它会 await TransportManager::shutdown),然后调用 report_traffic 和 report_illegal。此时所有中继都已停止,因此上报的计数器是最终值。在就绪之前被停止的节点通常没有监听器也没有流量,但一次在绑定端口之后被打断的尝试仍会以这种方式拆除。

节点任务永远不会自行结束。只有当它的令牌被取消时它才会结束,即被某次移除该节点的重载或被关闭流程取消。面板不可达的节点会在 Bootstrap 与 Waiting 之间往复,每次尝试记录一行日志;进程的其余部分不受影响。节点一侧的描述见 节点管理器。

setup_watcher 监视的是配置文件的父目录,而不是文件本身。编辑器常见的保存方式是先写临时文件,再把它 rename 覆盖原文件,这会替换文件监视所附着的 inode;目录监视则能看到两种保存方式。如果路径没有父目录部分(只是 config.toml),就监视 .。监视模式为 RecursiveMode::NonRecursive。

notify 在它自己的线程上通过 std::sync::mpsc 通道投递事件,因此运行时用一个普通 OS 线程把事件桥接到 Tokio:

flowchart LR
  N["notify RecommendedWatcher"] -- "notify 的 Result of Event" --> S["std mpsc 通道"]
  S --> T["桥接线程"]
  T -- "unit tick" --> U["tokio 无界 mpsc"]
  U --> L["run 中的 select 循环"]
  • 桥接线程对每条消息转发一个 (),不论消息内容。它不查看事件类型或路径,监视器错误也像普通事件一样被转发为一个 tick。因此该目录中任何条目上的任何事件都会触发一次重载尝试:证书被重写、另一个文件被创建,在 Linux 上甚至是某个文件被打开,因为 notify 的 inotify 后端(notify 8.2)订阅了打开事件。如果配置文件本身没有变化,这次尝试会解析同一个文件,找不到差异,什么也不做:在同一路径上重写的证书不会被重载拾取。
  • 监视器被丢弃(其 recv 失败)或 Tokio 接收端消失(其 send 失败)时,线程结束。这两者都在 run 返回时发生。
  • Tokio 通道是无界的,但它只携带 (),并且循环在每次重载时都会将其清空。

循环收到一个 tick 后,固定睡眠 500 ms(Duration::from_millis(500)),然后用 try_recv 把通道清空,再执行重载。这是从第一个事件开始计算的固定窗口,而不是滑动窗口:一次保存产生的一串事件会合并为一次重载。

在 config::load 或 apply_reload 运行期间到达的 tick 不会被清掉;它们留在通道中,当前重载一结束就会触发下一次重载。因此在一次缓慢的重载期间所做的保存永远不会丢失,代价是多一次重载。

在 Linux 上,这条规则会让循环自我驱动。config::load 在清空通道之后打开被监视目录中的配置文件,打开操作产生一个事件,这个 tick 又触发下一次重载。启动时的加载发生在监视器建立之前,因此刚启动的进程是安静的;但一旦目录中出现第一个事件,katana 在此后的整个生命周期里大约每 500 ms 就会重新读取一次配置文件。每一轮都会解析文件,发现没有变化,什么也不应用,所以每个周期的代价是一次文件读取和解析。这也意味着,重载错误会在每个周期再次被记录,大约每秒两次,直到文件被修正:被拒绝的重载永远不会替换正在运行的 cfg,所以下一轮会发现同样的差异并以同样的方式失败。这适用于文件无法解析时的 config reload failed, keeping current: …、出站池无法构建时的 reload: bad outbounds, keeping current config: …,以及 apply_reload 拒绝某个节点时的 reload: node <display_id>: <error>; keeping current config。用 strace 观察,配置文件在启动时被打开两次(分别由 init_tracing 和 run 打开),在目录中有文件被写入之前不会再被打开,此后大约每 500 ms 打开一次。

loop {
tokio::select! {
_ = root.cancelled() => break,
Some(()) = ev_rx.recv() => { /* debounce, load, apply_reload */ }
}
}
  • 如果 config::load 失败,循环记录 config reload failed, keeping current: … 并保留现状。由于下一次重载是与正在运行的配置比较,而不是与被拒绝的配置比较,目录中之后的任何事件都会重新尝试同一个文件。
  • 如果监视器从未建立,它的发送端已在 setup_watcher 内部被丢弃,ev_rx.recv() 返回 None,模式不匹配,该分支被禁用;此后循环只等待令牌。
  • 防抖睡眠和 apply_reload 运行在分支体内部,而不是作为 select 的分支。重载期间到达的信号会立即取消根令牌(从而取消每个节点的令牌),但循环要等重载返回后才会观察到它。

apply_reload 以可变引用接收正在运行的 cfg、pool 和 handles,并把一份新解析的 Config 应用到它们之上。它的顺序是固定的:

sequenceDiagram
  participant L as run 循环
  participant R as apply_reload
  participant B as build_outbounds
  participant K as 保留的节点
  participant X as 被移除的节点
  participant N as 新节点
  L->>R: apply_reload(new)
  opt 出站列表不同
    R->>B: build_outbounds(new)
    B-->>R: 新出站池或错误
    Note over R: 出错则记录日志并返回,什么都不应用
  end
  loop 每个新节点表,按文件顺序
    R->>R: 新增节点执行 build_node,有变化的保留节点执行 PanelClient new
    Note over R: 出错则记录日志并返回,什么都不应用
  end
  opt 日志级别不同
    R->>R: reload(level)
  end
  opt 构建了新出站池
    R->>K: StaticUpdate Outbounds
    R->>X: StaticUpdate Outbounds
  end
  R->>X: shutdown.cancel()
  X-->>R: 最终上报后任务结束
  loop 每个新节点表,按文件顺序
    alt 身份匹配某个 handle 且表有差异
      R->>K: StaticUpdate Config
    else 身份不匹配任何 handle
      R->>N: 用预先构建的管理器 spawn_built
    end
  end
  R-->>L: cfg 被替换为 new
  1. 校验出站池。 只有当 new.outbounds != cfg.outbounds 时,apply_reload 才会调用 build_outbounds(&new)。失败时记录 reload: bad outbounds, keeping current config: …,并在产生任何副作用之前返回,因此日志级别、出站池、节点和 cfg 都保持原样。

  2. 构建节点。 运行时按文件顺序遍历一次新的 [[node]] 列表,把每张表与正在运行的 handle 对照:

    • 保留的节点,表未变。 无需构建。
    • 保留的节点,表有变化。 构建 PanelClient::new(node_cfg) 后即丢弃,因此节点无法为之构建客户端的修改,例如 sspanel 上未知的 api.node_type,会在这里被拒绝。节点的 [node.route] 不在这里编译;见第 6 步。
    • 新增的节点。 build_node(node_cfg, pool) 构建面板客户端和管理器;如果第 1 步构建了新池就针对新池,否则针对正在运行的池。管理器留给第 6 步使用。不绑定也不启动任何东西。
    • 身份与本轮已构建过的某张表相同的第二张表。 无需构建;第 6 步把它当作对第一张表的重新配置。

    第一个失败会记录 reload: node <display_id>: <error>; keeping current config 并返回。什么都不应用:不切换日志级别、不替换出站池、不移除节点,也不提交 cfg。已经构建的管理器被丢弃;它们都没有绑定过任何端口。由于身份变化就是一次移除加一次新增,正是这项检查保证了替代节点无法构建的修改不会把正在运行的节点移除。

  3. 切换日志级别。 如果 new.log.level != cfg.log.level,运行时调用 reload(level)(键被删除时取 "info"),并记录 reload: log level → <level>。见 日志。

  4. 广播出站池。 如果构建了新池,就替换 *pool,并把 StaticUpdate::Outbounds(pool.clone()) 发送给每个 handle,包括第 5 步即将移除的节点。这样的节点可能会先重建一次监听器,然后才看到自己的令牌被取消。运行时记录 reload: outbound pool rebuilt。

  5. 移除节点。 新配置中的身份被收集到一个 HashSet<NodeId> 中。handles 被逐个取出;身份不在其中的 handle 会记录 reload: removing node <display_id>,取消其令牌并 await 其 JoinHandle。移除依次进行,每一个都要等待该节点完成最终的流量和审计上报。

  6. 在一次遍历中重新配置与新增。 运行时再次按文件顺序遍历新的 [[node]] 列表,处理到哪张表就处理哪张,因此重新配置和新增是交错进行的:

    • 保留的节点。 如果身份匹配某个 handle,运行时把 handle 的 cfg 与新表比较。如果不同,就把新表存入 handle,发送 StaticUpdate::Config(Box::new(node_cfg.clone())),并记录 reload: reconfigured node <display_id>。
    • 新增的节点。 如果身份不匹配任何 handle,就用 spawn_built 启动第 2 步为它构建的管理器,这一步不会失败,运行时记录 reload: added node <display_id>。

    运行时不编译保留节点的路由。引用了未知出站 tag 的 [node.route] 修改能通过第 2 步并被发送出去;由节点自己拒绝这次修改(见 节点如何应用静态更新)。此时 handle 的 cfg 已经保存了被拒绝的表,因此之后保持该表不变的重载不会发送任何东西:被拒绝的修改在表再次变化之前不会被重新发送。

  7. 提交。 *cfg = new。只有通过了第 1 步和第 2 步的重载才会走到这里。

配置部分 比较方式 发送形式
[[outbound]] 列表 Vec<OutboundConfig> != 向每个节点发送 StaticUpdate::Outbounds
[log].level Option<String> != 调用一次 LogReload
[dns] 不比较 无
[[node]] 身份 NodeId 相等 移除并启动;新增的节点在第 2 步构建
[[node]] 的其余部分 NodeConfig 与 NodeHandle.cfg 做 != 向该节点发送 StaticUpdate::Config;它的面板客户端在第 2 步构建

出站列表作为整个 Vec 进行比较,因此即使只是调整 [[outbound]] 表的顺序、没有改动任何一张表,也算作变化:它会重建出站池以及每个节点的监听器。

[dns] 只在 build_outbounds 内部读取,而重载只有在出站列表变化时才会调用它。因此单独修改 [dns] 不会生效,直到下一次修改 [[outbound]] 或重启。

  • 先移除,后新增。 当一次身份变化变成一次移除加一次新增时,旧节点的监听器会先关闭、其任务被 await,然后才启动替代节点,因此替代节点可以绑定同一个端口。替代节点的管理器在第 2 步就已构建,但构建好的管理器在启动之前不绑定任何东西。同一次保存中端口从被移除的节点转移到新增节点时,情况也一样。
  • 更新只入队,不等待。 static_tx.send(…).await 在消息进入通道后就返回。运行时不等待节点应用它,因此第 6 步新增的节点可以与仍在为同一轮中较早发送的更新而重建的保留节点并发引导。只有移除是同步的。
  • 通道上的背压。 send 会等待容量。节点在服务期间和两次引导尝试之间的等待期间都会从通道取消息,因此只有在节点忙于某一项工作时(例如一次正在进行的引导尝试),通道才会积压消息;只有在此期间堆积了 16 个更新时,重载才需要等待。节点任务不会自行结束,所以 handle 的接收端在运行时取消该节点之前始终保持打开。如果 send 仍然失败,错误会被忽略。
  • 两个更新可能意味着两次重建。 当一次保存同时修改了出站列表和某个节点的表时,该节点先收到 Outbounds,再收到 Config,每一个都可能重建它的监听器。同时重命名某个出站和使用它的规则就依赖这个顺序:节点先用旧规则针对新池编译失败(并保留旧路由器),然后在 Config 到达时编译新规则。
  • 接受空节点列表。 与启动时不同,apply_reload 不要求存在任何 [[node]]。一个不含节点的文件会移除所有节点,进程在没有节点的情况下继续运行。

新配置中的一个 [[node]] 表,当且仅当其 NodeId 元组与某个正在运行的节点相等时,才被视为同一个节点:

位置 来源 比较方式
0 panel_type to_ascii_lowercase(),因此 NewV2board 等于 newv2board
1 api.host 精确字符串:末尾多一个 / 就是不同的身份
2 api.node_id u32
3 api.key 精确字符串
4 面板节点类型,来自 api::panel_node_type(cfg) 精确字符串。对 newv2board 和 v2board 而言取 newv2board::node_type_param:开启了 api.enable_vless 的 V2ray 系节点(api.node_type 为 V2ray、Vmess 或 Vless,不区分大小写)为 vless,否则为转为小写的 api.node_type。对 sspanel 为空

身份说明一张 [[node]] 表服务的是哪个面板节点:哪个面板、面板上的哪个节点、使用哪套凭据。在 newV2board 上节点类型也是其中的一部分,因为 UniProxy 按节点 id 和请求时所带的节点类型来查找节点:同一个 node_id 以 vless 请求和以 vmess 请求是两个面板节点,各有自己的用户和流量。sspanel 的 mod_mu 只按 id 查找节点,所以那里不包含类型。

修改任何一个身份字段都会移除该节点并启动一个新节点。在 newV2board 上,这包括 api.node_type 的变化(仅大小写不同的除外),以及 V2ray 或 Vmess 节点上 api.enable_vless 的变化;Vless 节点无论如何都以 vless 请求,而这个开关对其他节点类型没有意义。其他所有字段,包括 api.timeout,以及 sspanel 上的 api.node_type 和 api.enable_vless,都以 StaticUpdate::Config 的形式到达正在运行的节点。其后果是:

  • 被移除的节点用旧的面板连接发送最终上报,新节点以空的 NodeTraffic 启动,因此不会有计数器从一个面板节点带到另一个面板节点。
  • 文件中的顺序无关紧要:匹配依据的是身份,而不是位置。
  • 两个身份相同的 [[node]] 表不会被拒绝。启动时两者都会被启动;重载时两者都匹配第一个 handle。这样的配置是有歧义的,其行为取决于 find。

面板客户端在构造时会复制 [node.api] 中的字段:newv2board::Client::new 保存 api.node_type、api.enable_vless、api.speed_limit、api.rule_list_path 和超时;sspanel::Client::new 还保存 api.vless_flow 和 api.disable_custom_config。节点不会在这些字段被修改后继续沿用旧客户端:当 panel_type 或 [node.api] 中的任何内容变化时,apply_static 会根据新表构建一个新的 PanelClient 并换入。因此身份只需覆盖指的是哪个面板节点,而不必覆盖客户端构建时依据的每个字段。

src/manager/node.rs 中的 NodeManager::apply_static 是接收端。它在节点自己的任务中、两次轮询之间或两次引导尝试之间运行,因此静态更新与面板轮询永远不会交错。

StaticUpdate::Config(new_cfg) 要么整体接受,要么整体不接受。apply_static 在保存任何东西之前先构建这次修改所需的东西:

  1. 客户端。 如果 panel_type 或 api 中的任何内容与正在运行的配置不同,PanelClient::new(&new_cfg) 构建一个新客户端,inherit_routes 把旧 newV2board 客户端最近读到的路由复制进来,使由它们派生的审计规则继续生效,直到新客户端自己读取节点配置。sspanel 每次刷新都会请求自己的规则,因此没有需要继承的东西。
  2. 路由器。 如果 [node.route] 不同,build_router 以 direct 为默认 tag,针对节点当前的出站池编译新路由。
  3. 拒绝或保存。 只要任一构建失败,节点就记录 node <node_id>: config edit refused, keeping the running one: <error> 并返回。什么都不保存:节点保留自己的配置、客户端、路由器和监听器。否则它依次保存新配置、新路由器(记录 node <node_id>: router rebuilt)和新客户端。
  4. 重新同步。 如果节点已完成引导,而这次修改需要重建监听器或带来了新客户端,节点会立即运行一次 poll_cycle(rebuild),它同时也会刷新审计规则并上报流量。仍在引导中的节点没有可应用修改的对象:它的下一次尝试(Config 会让它立即开始)会用新客户端读取,并根据新配置构建。
变化内容 先构建 然后
[node.route]、controller.listen_ip、controller.cert 中的任何内容、controller.disable_sniffing、api.enable_vless、[node.hysteria] 中的任何内容 路由变化时构建路由器;api 字段变化时构建客户端 poll_cycle(true):协调阶梯走传输层重建分支,根据面板的回答完整重建监听器
仅大小写不同的 panel_type 变化,或 [node.api] 中的其他任何字段,例如 api.speed_limit、api.rule_list_path 或 api.timeout 客户端 poll_cycle(false):新客户端不持有 ETag,因此节点信息和用户会被完整读取,协调阶梯只在回答确有需要时才断开连接
其他任何内容,例如 controller.update_periodic 或 controller.disable_get_rule 无 保存;在用到的地方读取

controller.cert 按配置值(mode、cert_file、key_file、reject_unknown_sni)比较,而不是按文件内容比较:在同一路径上重写证书不算变化。

监听器重建会结束节点上的每条连接,包括仍处于握手阶段的连接。只有传输层重建才能保证这一点,因此路由修改会强制一次传输层重建,而不是让用户作用域退役。NodeTraffic 在重建后依然存在,所以重建前后都存在的用户会保留各自的计数器。如果面板的回答中没有用户,协调阶梯会拆除监听器而不是重建它。

StaticUpdate::Outbounds(pool) 替换节点的出站池,然后 rebuild_router 重新编译路由器,apply_route_change 做一次完整的监听器重建,不论节点的规则是否用到了发生变化的出站。任何 [[outbound]] 修改都会让每个节点上的每条连接断开。这是仍在使用 rebuild_router 和 apply_route_change 的唯一路径。

如果 rebuild_router 失败(例如某条规则引用的 tag 在新出站池中已不存在),它会记录 node <id>: route rebuild failed, keeping current: … 并保留之前的路由器,监听器仍然会被重建。运行时只在 第 2 步 中、针对重载新增的节点才用新出站池编译路由;katana --test 会检查每个节点。

init_tracing 在 --test 或 run 之前安装一次全局 subscriber:

pub fn init_tracing(config_path: &Path) -> LogReload
  1. 初始过滤器是 EnvFilter::try_from_default_env(),它读取 RUST_LOG。如果 RUST_LOG 未设置或无法解析,init_tracing 会自行加载配置文件并使用 [log].level;如果该键不存在或文件无法加载,则使用 "info"。这个值经过 EnvFilter::new,它会丢弃无效指令并在 stderr 上给出提示,而不是失败。
  2. 过滤器被包装进 tracing_subscriber::reload::Layer::new,registry 装上这一层以及写入 stdout 的 fmt::layer()。
  3. 返回的 LogReload 闭包用 EnvFilter::try_new 解析新级别。成功时调用 handle.reload(f) 并忽略其结果;失败时记录 invalid log level "<level>": <error> 并保留当前过滤器。

重载所应用的级别会完全替换过滤器,包括启动时来自 RUST_LOG 的过滤器。在重载中删除 [log].level 会设为 info,而不是恢复 RUST_LOG 的设置。

--test 运行 test_config(path),它会构建真实启动时构建的一切,唯独不启动节点任务。它不绑定任何端口,也不发送任何面板请求。

pub fn test_config(path: &Path) -> anyhow::Result<()>
顺序 检查项 错误示例(--test 的输出)
1 config::load:UTF-8、TOML 语法、未知键 configuration error: config parse error: TOML parse error at line 1, column 1 …
2 build_outbounds:DNS 后端与 CA 文件、每个出站、保留 tag 与重复 tag configuration error: duplicate/reserved outbound tag direct
3 至少一个 [[node]] configuration error: config defines no [[node]] entries
4 每个节点:PanelClient::new(panel_type、api.node_type) configuration error: unknown panel_type "xboard"
5 每个节点:以 direct 为默认 tag 针对出站池执行 build_router configuration error: route references unknown outbound tag: nope
6 每个 Hysteria 2 节点:对 [node.hysteria] 和 controller.cert 执行 inbound::validate_hysteria 带 node <node_id>: 前缀

Hysteria 2 检查是唯一的协议检查,因为 Hysteria 2 节点的参数可以是本地的;其他所有节点类型的端口、传输层和 TLS 标志都来自面板,而试运行不会联系面板。test_config 在遇到第一个错误时停止。它比真实启动更严格:run 遇到面板客户端或路由器构建失败的 [[node]] 时会记录日志、跳过它并为其他节点提供服务,而 --test 会因此失败。它也比重载更严格:重载只为新增的节点编译路由器,并且不执行 Hysteria 2 检查。文件读取产生的错误只带有操作系统的消息,例如 dns.ca_file 或配置文件缺失时的 configuration error: No such file or directory (os error 2)。

由于 init_tracing 先运行,无效的 [log].level 不会让 --test 失败:被丢弃的指令会在 stderr 上报告,运行仍然打印 Configuration OK。

wait_for_shutdown 在收到第一个 SIGINT(tokio::signal::ctrl_c)或(在 Unix 上)SIGTERM(SignalKind::terminate())时返回。如果无法安装 SIGTERM 处理器,它只等待 SIGINT。没有 SIGHUP 处理器;重载只由监视器驱动。

分离的信号任务取消根令牌。由于每个节点的令牌都是根令牌的 child_token(),取消会同时到达所有节点。随后循环 break,记录 shutting down,再次取消根令牌(无操作),并依次 await handles 中的每个 JoinHandle。每个节点按 节点任务生命周期 中的描述排空,因此关闭所需时间取决于最慢节点的拆除和最终面板上报。然后 run 返回 ExitCode::SUCCESS。

Tokio 的信号处理器一旦注册就会保持安装,因此排空期间的第二个 SIGINT 或 SIGTERM 不会让进程提前结束。

不变量 由谁保证 由什么固定
出站池、新增节点或有变化的面板客户端构建失败的重载不改变任何东西 apply_reload 先构建出站池,再构建每个新增节点和每个有变化节点的客户端,并在产生任何副作用之前返回 tests/unit/runtime.rs 中的 a_reload_with_a_node_that_does_not_build_changes_nothing 重载一处节点类型拼写错误连同一处路由修改,并断言正在运行的节点没有被替换、handle 的 cfg 和运行时的 cfg 都没有变化,且一条打开的连接仍在中继。build_outbounds 的错误可用 katana --test 复现
新节点绑定之前,被移除节点的端口已经释放 移除操作在任何 spawn_built 之前 await 每个被移除节点的 JoinHandle 无测试;依赖 apply_reload 中语句的顺序
节点停止时会上报其最终计数器 bootstrap 或 serve 返回后,无论节点是否就绪过,NodeManager::run 都调用 tear_down,然后是 report_traffic 和 report_illegal;运行时 await 该任务 由 tests/unit/e2e.rs 中的 vmess_traffic_is_metered_and_reported 覆盖,它在最后一个断言之前取消令牌并 join 任务;该测试也接受来自周期性上报的总量,因此并未单独验证最终的刷新
路由修改会断开节点上的每条连接 apply_static → poll_cycle(true) → reconcile → rebuild tests/unit/e2e.rs 中的 route_change_drops_connections 通过容量为 16 的通道发送 StaticUpdate::Config,并断言打开的 VMess 连接结束
节点永远不会收到属于另一个面板节点的 Config identity 覆盖面板、host、节点 id、密钥和面板节点类型;不匹配的会被移除并重新启动 an_sspanel_node_is_its_panel_node_id 和 a_newv2board_node_is_also_the_type_it_asks_for 固定了哪些修改会改变身份;a_newv2board_type_edit_respawns_the_node 在一个 newV2board V2ray 节点上关闭 VLESS,并断言出现了新通道且节点不再提供 VLESS
对面板客户端字段的修改原地生效 panel_type 或 api 变化时,apply_static 构建新的 PanelClient,然后运行一次轮询 an_sspanel_api_edit_takes_effect_in_place 在一个 sspanel 节点上关闭 VLESS,并断言通道不变且节点不再提供 VLESS;a_client_edit_takes_effect_without_dropping_connections 设置 api.rule_list_path,并断言新的流被拒绝,而一条打开的连接继续中继
面板密钥永远不会出现在重载日志行中 display_id 省略密钥元素,且它是运行时格式化身份的唯一方式 a_node_is_logged_by_its_panel_node_not_its_key 断言 newV2board 节点和 sspanel 节点的精确渲染结果
无法就绪的节点会不断重试,并且在被取消时仍会停止 bootstrap 以退避方式重试 try_bootstrap,并在 biased select! 中监听令牌 tests/unit/e2e.rs 中的 a_node_comes_up_once_the_panel_answers、a_node_whose_port_is_taken_comes_up_once_it_is_free 和 a_node_that_never_bootstraps_still_stops
启动时一个错误的 [[node]] 永远不会让其他节点停止 spawn_node 返回 None 并记录日志;启动时只要求至少一个 handle,而不是全部 无测试
任何重载修改都不会丢失 重载期间的 tick 保留在队列中并触发下一次重载,因为防抖只在 config::load 之前清空一次 无测试
保留 tag 不能被重新定义 build_outbounds 在插入条目之前预置它们,并拒绝已存在的键 无测试;可用 katana --test 复现
失败 位置 结果
启动时配置无法加载 run failed to load config: …,退出码 1
启动时出站池无法构建 run failed to build outbounds: …,退出码 1
启动时没有 [[node]] run config defines no [[node]] entries,退出码 1
启动时某个节点无法构建 spawn_node node <node_id>: …;跳过它,其他节点照常启动
启动时所有节点都构建失败 run no nodes could be started,退出码 1
监视器无法建立 run config watcher disabled (no live reload): …;在没有重载的情况下运行
重载时配置无法加载 select 循环 config reload failed, keeping current: …;在下一个事件时重试
新出站池无法构建 apply_reload reload: bad outbounds, keeping current config: …;什么都不应用
新日志级别无效 LogReload 闭包 invalid log level …;保留旧过滤器,重载的其余部分照常应用。由于闭包没有返回值,仍会记录 reload: log level → …,且无效值会被提交,因此同一个值不会被重试
新增节点或有变化节点的面板客户端无法构建 apply_reload,第 2 步 reload: node <display_id>: <error>; keeping current config;什么都不应用
重载被拒绝 select 循环 cfg 不会被提交,因此自我驱动循环之后的每一轮都会再次拒绝它,并大约每秒两次记录该错误,直到文件被修正;见 防抖
节点路由无法针对新出站池编译 NodeManager::rebuild_router,用于 StaticUpdate::Outbounds 保留旧路由器;监听器仍会重建
节点配置修改无法构建(面板客户端或路由) NodeManager::apply_static node <node_id>: config edit refused, keeping the running one: …;什么都不保存。运行时已经记下了这张表,因此在表再次变化之前这次修改不会被重新发送
节点的一次引导尝试失败 NodeManager::bootstrap node <node_id>: <reason>; retrying in <n>s;以退避方式重试,直到节点就绪或被取消;见 节点任务生命周期
向已结束的节点发送更新 static_tx.send 错误被忽略

引导退避在 src/manager/node.rs 中有具名常量;运行时的其他数字都是 src/runtime.rs 和 src/manager/node.rs 中的字面量。

值 位置 含义
500 ms run 中的 Duration::from_millis(500) 第一个事件之后的防抖窗口
16 spawn_built 中的 mpsc::channel(16) 每个节点在 send 开始等待之前可排队的 StaticUpdate 数
1 s BOOTSTRAP_RETRY_MIN 第一次引导尝试失败后的等待;每次尝试后翻倍
60 s BOOTSTRAP_RETRY_MAX 两次引导尝试之间最长的等待;轮询周期更短时以轮询周期为上限
无界 run 中的 mpsc::unbounded_channel::<()>() 监视器 tick,每次重载时清空
"info" init_tracing 和 apply_reload [log].level 不存在时的日志级别
5 s ApiConfig::timeout_secs api.timeout 为 0 时的面板请求超时
60 s,最小 1 s default_update_periodic、NodeManager::poll_period 轮询周期

src/runtime.rs 把 tests/unit/runtime.rs 挂载为自己的单元测试模块。它的行为从四个方向被覆盖:

  • 重载决策,位于 tests/unit/runtime.rs。
    • 身份,不涉及任何 I/O。an_sspanel_node_is_its_panel_node_id 断言在 sspanel 上只有 panel_type(大小写变化除外)、api.host、api.node_id 和 api.key 会改变身份,而 api.node_type、api.enable_vless、api.timeout 以及其他 [node.api] 字段不会。a_newv2board_node_is_also_the_type_it_asks_for 断言在 newV2board 上 api.node_type,以及 V2ray 节点上的 api.enable_vless 也会改变身份,而仅大小写不同的 node_type 修改、api.timeout、api.speed_limit 和 api.rule_list_path 不会。a_node_is_logged_by_its_panel_node_not_its_key 固定了两种面板的 display_id 渲染结果。
    • 针对一个运行中节点的重载。Runtime 测试框架像 run 那样持有 cfg、pool、根令牌和 handles,用 spawn_node 针对 tests/unit/e2e.rs 中的假面板启动一个节点,并直接调用 apply_reload。这些测试通过用 same_channel 比较 handle 的 static_tx 来区分重新启动与原地修改。它们是 a_newv2board_type_edit_respawns_the_node、a_reload_with_a_node_that_does_not_build_changes_nothing、an_sspanel_api_edit_takes_effect_in_place 和 a_client_edit_takes_effect_without_dropping_connections,见 不变量。
  • 契约的节点一侧,位于 tests/unit/e2e.rs。测试辅助函数 spawn_node 仿照运行时自己的实现:它构建一个 PanelClient::NewV2board,调用 NodeManager::new,创建 mpsc::channel(16),并针对一个假面板启动 nm.run(shutdown, static_rx)。
    • route_change_drops_connections 发送一个 [node.route] 不同的 StaticUpdate::Config,并断言一条活动连接结束。
    • vmess_traffic_is_metered_and_reported 取消节点的令牌,await 任务,然后检查假面板收到的总量。
    • a_node_comes_up_once_the_panel_answers 让假面板对节点配置请求两次回以 500,并断言节点随后就绪并能中继。
    • a_node_whose_port_is_taken_comes_up_once_it_is_free 在一个对重复请求回以 304 Not Modified 的面板为节点服务期间占住节点的端口,在第二次节点配置请求之后释放端口,并断言节点就绪。
    • a_node_that_never_bootstraps_still_stops 让面板对每次节点配置请求都失败,并断言任务在令牌被取消后 2 秒内结束。
  • 整个进程的启动,位于 tests/integration/。tests/support/mod.rs 中的 spawn_katana 在一个测试目录中以 -c katana.toml 运行真实的二进制,从而在一个裸相对路径上覆盖 init_tracing、run、build_outbounds、spawn_node 和监视器。互通测试 vmess_tcp_plain、vless_ws_tls、trojan_tcp_tls 以及 tests/integration/xray_interop.rs 中的其余测试、tests/integration/sniff.rs 中的两个测试,以及 tests/integration/hysteria_interop.rs 中的 a_real_client_proxies_through_a_katana_hysteria_node 都以这种方式启动。它们用 go 构建对端(Xray 或 Hysteria 客户端),在工具链或对端源码树缺失时打印 SKIP: … 并通过。它们都不修改配置文件。a_real_client_proxies_through_a_katana_hysteria_node 在节点绑定端口之后把 client.yaml 写入被监视的目录,从而启动 防抖 中描述的自我驱动重载循环;这些重载每一次都发现没有变化。因此监视器、防抖以及 select 循环的重载分支只能在手动验证时用变化了的配置覆盖;apply_reload 本身由上面的单元测试驱动。
  • 试运行,手动验证。test_config 调用的构建函数与真实启动相同(config::load、build_outbounds、PanelClient::new、build_router),因此用一份小配置和 katana --test -c <file> 就能复现上面试运行表中的每一行。

修改 apply_reload 时,上面描述的步骤顺序就是需要保持的契约,而新重载路径的测试应当放在 tests/unit/runtime.rs 中,使用其中的 Runtime 测试框架。测试如何组织和运行,见 测试。