跳转到内容

katana 内部结构

源码文件:43 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
  • katana/Cargo.toml
  • katana/src/main.rs
  • katana/src/runtime.rs
  • katana/src/config.rs
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/src/manager/transport.rs
  • katana/src/manager/proxy.rs
  • katana/src/serve.rs
  • katana/src/connector.rs
  • katana/src/meter.rs
  • katana/src/traffic.rs
  • katana/src/router.rs
  • katana/src/rule.rs
  • katana/src/inbound.rs
  • katana/src/outbound/mod.rs
  • katana/src/outbound/freedom.rs
  • katana/src/outbound/proxy.rs
  • katana/tests/integration.rs
  • katana/tests/support/mod.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/serve.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/inbound.rs
  • katana/tests/unit/runtime.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/transports/accept.rs
  • Etemenanki/protocols/src/hysteria/server/inbound.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/flow.rs
  • Etemenanki/app/src/instance.rs

katana 是一个面板节点 agent。一个进程服务一个或多个面板节点,让每个节点的用户列表与面板保持同步,并对每个用户的流量计费和限速。它有自己的仓库,是一个独立于 etemenanki-app 的程序。协议相关的机制来自 Etemenanki 内核;而让它成为节点 agent 的一切都由 katana 自己构建:面板客户端、节点生命周期、自己的服务循环、流准入、计量、流量计费和目标审计。

本页是写给贡献者的地图。它说明哪些部分来自内核、哪些位于 katana,列出每个模块,画出任务树,指出主要数据结构及其所有者,并把一个请求在 katana 中的路径与同一请求在 etemenanki-app 中的路径对照着走一遍。每一节都链接到详细介绍该部分的页面。

katana 依赖三个内核 crate,取自一个私有 Cargo registry。Cargo.toml 对每个 crate 都要求 2.0.0,Cargo.lock 把 etemenanki-concepts 和 etemenanki-environment 解析为 2.0.0,把 etemenanki-protocols 解析为 2.0.1。通过 etemenanki-protocols 2.0.1,katana 获得了 WireGuard 驱动中有上限的单连接上行队列,使慢隧道能对写入方施加背压;还获得了 mux 的修复:每个下行帧只发送一次,并保留一次 VMess 读取中的每一帧。它构建 etemenanki-protocols 时启用 vendored-openssl 和 hysteria 两个 feature。katana 不依赖 etemenanki-app。它的配置、服务循环、connector 和出站池都是自己的代码,只是写成了与 app 相同的形态。

内核 crate katana 使用的内容 使用位置
etemenanki-concepts 进度模式(showing_progress)下的服务端运行时 ProxyServerRuntime 及其 Traffic 增量;ProxyCoreDecode;Connector 和 DatagramLink trait 以及 link::Outbound;Destination、Remote、DialNetwork、NetworkUser;SniffedBehavior;客户端运行时 ProxyClientConnector 和 ProxyClientRuntime,以及 ProxyCoreEncode、ProxyCoreEncodeDatagram 和 NoCodec src/serve.rs、src/connector.rs、src/router.rs、src/outbound/
etemenanki-environment 路由模型 routing::{Router, RouteTable, RouteItem, RouteMatch, RouteTarget, build_geo_data, parse_port_match};拨号器 dial::{Dialer, SocketOptions, DualStackUdp} src/router.rs、src/outbound/
etemenanki-protocols 服务端协议核心 VMessCore、VlessCore、TrojanCore、ShadowsocksCore、Ss2022Core 及其用户校验器;Flow;HANDSHAKE_TIMEOUT 和 RELAY_IDLE_TIMEOUT;InboundTransport、TransportStream、TransportConnector 和 TLS ServerConfig;Hysteria 2 监听器 Hy2Inbound 及 Authenticator 和 Masquerade;客户端 codec VMessStream、VMessDatagram、VlessStream、VlessDatagram、SsStream、Ss2022Stream、HttpConnect、SocksConnect 和 SocksUdpLink;WireGuard 的 WgConnector;DNS Resolver;AddressFamilyStrategy src/serve.rs、src/connector.rs、src/inbound.rs、src/manager/、src/outbound/、src/runtime.rs

线路上的每个字节都归内核管:分帧、协议握手内的认证、嗅探、传输层、单连接运行时,以及路由器的匹配引擎。katana 自己从不解析任何代理协议。每个 SOCKS、HTTP、VMess、VLESS 和 Shadowsocks 出站都通过 TransportKind::Tcp 的 TransportConnector 拨号,因此 katana 不会朝上游构建任何 TLS、WebSocket 或 gRPC 传输层。WireGuard 出站通过 WgConnector 运行自己的隧道,不使用 TransportConnector。

关注点 模块 etemenanki-app 中最接近的对应物
UniProxy(Xboard、V2board)和 SSPanel mod_mu 的面板客户端,带 ETag 缓存 src/api/ 无;app 没有面板
进程根、配置监视器、节点的增加、移除与重新配置 src/runtime.rs app/src/instance.rs
节点生命周期:带重试的启动引导、轮询周期、协调阶梯、静态更新 src/manager/node.rs 无;app 整体替换 generation
一代监听器及其任务作用域 src/manager/transport.rs、src/serve.rs app/src/serve.rs
可替换的用户表和预认证上限 src/manager/proxy.rs 每代固定的表
流准入与用户退役 src/connector.rs → Admission 无
按流和按 UDP 包的路由与审计 src/connector.rs、src/router.rs、src/rule.rs app/src/connector.rs → AppConnector(仅路由)
计量与按用户限速 src/meter.rs、src/traffic.rs → TokenBucket 无
带排空计数器和残余量的流量计费 src/traffic.rs → NodeTraffic 无
出站池 src/outbound/ app/src/outbound/

行数统计基于 v3.0.1,含注释。源文件合计约 6,460 行,测试约 4,400 行。

文件 行数 职责
src/main.rs 54 clap Args(-c/--config,默认 config.toml;--test)、tracing 初始化、分派到 runtime::test_config 或 runtime::run。同时引入端到端测试模块。
src/runtime.rs 439 进程根:加载配置,构建出站池和共享的 DNS 解析器,为每个 [[node]] 构建(build_node)并启动(spawn_built)一个节点任务,运行配置监视器和 apply_reload,处理信号。另含 test_config 和 init_tracing。
src/config.rs 324 serde schema,每个 struct 都带 deny_unknown_fields;load 和 parse_bytes。
src/api/mod.rs 369 与面板无关的模型(NodeType、Transport、NodeInfo、UserInfo、DetectRule、DetectResult)、EtagCache、不带 URL 的 error_for_status、本地规则文件读取器、panel_node_type,以及 PanelClient 枚举及其构造函数 PanelClient::new、forget_etags 和 inherit_routes。
src/api/newv2board.rs 452 UniProxy 客户端及其解析器,以及 node_type_param。
src/api/sspanel.rs 576 mod_mu 客户端、custom_config 解析器与旧格式解析器、compare_version。
src/manager/mod.rs 97 StaticUpdate、user_key、user_tag、node_tag、build_user_entries、user_set_differs。
src/manager/node.rs 675 NodeManager:启动引导及其带退避的重试、轮询周期、协调阶梯、静态更新、规则刷新、流量与审计上报。
src/manager/transport.rs 177 TransportManager:一代已绑定的监听器、start、shutdown,以及作为兜底的 Drop。
src/manager/proxy.rs 239 ProxyManager:Tables、预认证信号量、握手失败采样器、accept_stream 和 refresh。
src/serve.rs 447 Scope、spawn_scoped、accept_loop、serve_socket、serve_stream、drive、until_retired、run_hysteria。
src/connector.rs 361 Admission、Dispatcher、KatanaConnector、LeaseSlot,以及 UDP 的 FanOut。
src/meter.rs 141 Gate 和 Metered<S> 流包装器。
src/traffic.rs 472 AuthKey、UserTag、TokenBucket、UserCounter、NodeTraffic、PreparedUsers、determine_rate。
src/router.rs 120 基于内核路由模型的 route_target 和 build_router。
src/rule.rs 76 RuleManager:按节点 tag 存放的审计规则,以及去重后的命中记录。
src/inbound.rs 548 StreamProtocol、build_transport、build_protocol、Shadowsocks 表、Hysteria 认证器与监听器构建函数、validate_hysteria。
src/outbound/mod.rs 533 Outbound、connect_stream、connect_datagram、build_outbound、SocksOutbound,以及 Shadowsocks、WireGuard 和地址族的构建函数。
src/outbound/freedom.rs 181 FreedomConnector(直连 TCP 和双栈 UDP)和 ResolvingUdp。
src/outbound/proxy.rs 179 ProxyClient、OutboundStream、OutboundDatagram。

在 src/api/ 之外,只有两个模块接触面板客户端,两者都用 src/api/mod.rs 中的 PanelClient::new 构建它:src/runtime.rs 构建每个节点的第一个客户端,并检查重载后节点的客户端;src/manager/node.rs 在 apply_static 中构建替换用的客户端,并发起每一次面板调用。src/inbound.rs 和 src/manager/ 中的构建函数读取 api 模型,数据路径上没有任何代码发起面板请求。

每个长期任务都挂在两个根之一上:进程的 root token,或某代监听器的 Scope。

flowchart TB
  main["runtime::run,主任务"]
  sig["信号任务"]
  bridge["监视器桥接,一个 std 线程"]
  node["NodeManager::run,每个节点一个"]
  tm["TransportManager 及其 accept Scope"]
  mon["认证监控,每 1 秒"]
  acc["accept_loop"]
  sock["serve_socket,每个套接字一个"]
  strm["serve_stream 和 drive,每个流一个"]
  hy["run_hysteria"]
  hyk["Hy2Inbound::run 及其内核任务"]
  sig -- "取消 root" --> main
  bridge -- "单元值通知" --> main
  main -- "tokio::spawn,带 root.child_token()" --> node
  node -- "绑定期间持有" --> tm
  tm --> mon
  tm -- "stream 节点" --> acc
  acc --> sock
  sock -- "ProxyManager::accept_stream" --> strm
  tm -- "Hysteria 2 节点" --> hy
  hy --> hyk
  • runtime::run 运行在主任务上,持有已加载的配置。它用 tokio::spawn 为每个节点启动一个 NodeManager::run,给每个节点一个 root token 的子 token 和一个 StaticUpdate 通道的接收端,并为每个节点保留一个 NodeHandle。
  • 信号任务在 wait_for_shutdown 中等待 SIGINT 或 SIGTERM,然后取消 root token。
  • 监视器桥接是一个 std::thread。它把配置文件所在父目录上的每个 notify 事件以 () 的形式转发到一个 tokio 通道。主任务休眠 500 ms 做防抖,用 try_recv 清空通道,重新解析文件并调用 apply_reload。
  • NodeManager::run 每个节点一个任务。它先运行 bootstrap:每次尝试都与节点 token 竞争;失败后,在自己的一个 select! 中等待节点 token、退避计时器和 StaticUpdate 接收端。节点启动之后,由 serve 运行稳态:一个针对节点 token、轮询 interval 和 StaticUpdate 接收端的 select!。面板轮询和静态更新共享这一个任务,因此一个节点永远不会同时协调两处变更。
  • 节点之下的一切都运行在该代监听器的 Scope 中。spawn_scoped 把每个 future 包进一个与作用域 token 竞争的 select!,并将其注册到作用域的 TaskTracker。认证监控、accept 循环、每个 serve_socket 和每个按流任务都在作用域内。在 Hysteria 2 节点上,作用域持有 run_hysteria。内核的 Hy2Inbound::run 拿到作用域 token,为每个代理流运行一个自己的运行时;当 [node.hysteria].udp 开启时,还为每条连接的 UDP 运行一个。
通道或 token 类型 从 → 到 用途
root CancellationToken 信号任务 → 主循环和每个节点 进程关闭。
NodeHandle.shutdown 子 CancellationToken apply_reload → 一个节点 重载时移除节点;root 触发时也会触发。
static_tx / static_rx StaticUpdate 的 mpsc::channel(16) apply_reload → 一个节点 某个 [[node]] 表发生变化,或出站池被重建。
ev_tx / ev_rx mpsc::unbounded_channel::<()>() 监视器桥接 → 主循环 “配置目录里有东西变了”。
Scope.token CancellationToken TransportManager → 每个作用域内任务 拆除一代监听器。
租约 每个 AuthKey 一个 CancellationToken Admission → 该用户的每个 Gate 和每条连接 让用户退役。
LeaseSlot watch::Sender<Option<CancellationToken>> KatanaConnector → 连接的 drive 在连接的第一个流被准入后,告诉该连接它持有哪份租约。

无论是进程停止、节点从配置中移除,还是监听器重建,一代监听器的拆除方式都相同:tear_down 把 TransportManager 从其 Option 中取出,并 await TransportManager::shutdown。下图展示完整的节点关闭过程。重建只执行其中 tear_down 的部分,随后调用 bring_up,不发送上报。

sequenceDiagram
  participant R as runtime::run
  participant N as NodeManager::run
  participant T as TransportManager
  participant S as accept Scope
  participant A as Admission
  participant P as 面板
  R->>N: 节点 token 被取消
  N->>T: tear_down 取出 Option,调用 shutdown
  T->>S: Scope::shutdown 取消、关闭、等待
  S-->>T: 所有作用域内任务均已结束
  T->>A: retire_all 取消每份租约
  T->>T: release_listener await Hy2Inbound::shutdown
  N->>P: report_traffic,然后 report_illegal
  R->>N: await JoinHandle

TransportManager::shutdown 只有在该代所有任务都已结束、每份租约都已取消,并且(在 QUIC 节点上)UDP 端口重新可用之后才返回。最后一步之所以存在,是因为 quinn 只有在其驱动在没有剩余连接的情况下被 poll 过之后才会释放套接字,而 NodeManager::rebuild 紧接着就会绑定同一端口。内核为这段等待设了上限:Hy2Inbound::shutdown 最多等待 DRAIN_TIMEOUT(3 秒)让端点空闲,然后每隔 RELEASE_POLL(20 ms)尝试重新绑定端口,最长 RELEASE_TIMEOUT(3 秒);如果端口始终未释放,则记录一条警告。

所有中继停止后计数器就是最终值,因此节点在任务返回前会发送最后一次流量与审计上报。

impl Drop for TransportManager 是兜底措施,用于未经 shutdown 就被丢弃的 generation:它取消作用域 token 并调用 retire_all。Drop 无法等待排空,所以每条路径都显式调用 shutdown。

src/runtime.rs
pub type LogReload = Box<dyn Fn(&str) + Send + Sync>;
type Pool = Arc<HashMap<CompactString, Arc<Outbound>>>;
type NodeId = (String, String, u32, String, String);
struct NodeHandle {
id: NodeId,
cfg: NodeConfig,
task: JoinHandle<()>,
static_tx: mpsc::Sender<StaticUpdate>,
shutdown: CancellationToken,
}
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
pub fn build_outbounds(cfg: &Config) -> io::Result<HashMap<CompactString, Arc<Outbound>>>
  • Pool 是进程唯一的出站池。build_outbounds 根据 [dns] 构建一个 Resolver,并与每个出站共享。它先放入保留 tag direct 和 freedom(AddressFamilyStrategy::Auto 的 FreedomConnector)以及 block 和 blackhole(Outbound::Block),再加入每个 [[outbound]]。重复或保留的 tag 会以 duplicate/reserved outbound tag <tag> 失败。每个节点持有池的 Arc,每个编译好的 Router 持有其规则所引用条目的 Arc<Outbound> 克隆。
  • build_node 用 PanelClient::new 构建节点的面板客户端,并构建其 NodeManager(后者会编译路由器);此时还没有绑定任何东西。spawn_built 创建节点的通道和子 token,并启动 NodeManager::run。spawn_node 在启动时把两者串起来,对构建失败的节点记录日志并跳过。
  • NodeId 是一个条目所服务的面板节点:(小写的 panel_type, api.host, api.node_id, api.key, 面板节点类型)。最后一个字段是 panel_node_type:在 newV2board 上,它是向 UniProxy 请求的 node_type(node_type_param:开启 enable_vless 的 V2ray 系节点,即 node_type 为 V2ray、Vmess 或 Vless,为 vless,否则为小写的 node_type),因为 UniProxy 按节点 id 和这个类型查找节点;在 SSPanel 上它为空。任一字段变化都会以全新的流量注册表重新启动该节点。display_id 为日志把身份渲染为 type@host#node_id,在 newV2board 上追加 /node_type,且从不显示 api.key。
  • apply_reload 按 NodeId 匹配节点。在触碰任何运行中的节点之前,它先构建本次重载新增的每个节点(build_node;出站有变化时基于新的出站池),并为每个 NodeConfig 有变化的保留节点构建面板客户端。只要其中任何一个失败,它就记录 reload: node <id>: <error>; keeping current config,什么都不应用。否则,它替换出站池并发送 StaticUpdate::Outbounds,取消并 await 每个身份已消失的节点,用 spawn_built 启动预先构建好的节点,并向每个 NodeConfig 有差异的保留节点发送 StaticUpdate::Config。
src/manager/mod.rs
pub enum StaticUpdate {
Config(Box<NodeConfig>),
Outbounds(Arc<HashMap<CompactString, Arc<Outbound>>>),
}
src/manager/node.rs
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>>;
pub async fn run(
self: Arc<Self>,
shutdown: CancellationToken,
mut static_rx: mpsc::Receiver<StaticUpdate>,
);
}
字段 生命周期 说明
api: Mutex<Arc<PanelClient>> 直到某次配置修改替换它 覆盖 sspanel::Client 和 newv2board::Client 的枚举,由 PanelClient::new 构建。每次调用都从锁中克隆出 Arc,因此不会在请求期间持有 guard。客户端保存着构建它时所用设置的副本,所以修改了 panel_type 或 [node.api] 的 StaticUpdate::Config 会构建一个新客户端,复制旧客户端学到的 newV2board 路由(inherit_routes,使由这些路由派生的审计规则在新客户端读取节点配置之前继续生效),再把它换上。每个客户端持有一个超时为 api.timeout 秒(0 表示 5 秒)的 reqwest::Client,以及一个按端点分键的 EtagCache:"node" 和 "users",SSPanel 上另有 "rules"。每次启动引导尝试都会先调用 forget_etags。
cfg、pool、router 节点任务 parking_lot::Mutex,由静态更新替换。配置修改在保存任何内容之前,先根据新的 route 编译路由器,编译失败则拒绝该修改。新的出站池经由 rebuild_router,它根据 cfg.route 和 pool 重新编译 router,编译失败时保留旧路由器。
traffic: Arc<NodeTraffic> 节点任务 比每一代监听器都活得久,因此字节总数在重建之间持续累计。
rules: Arc<RuleManager> 节点任务 审计规则按节点 tag Type_listenip_port(node_tag)存放。
transport 一代监听器 节点没有用户时为 None,重建失败后也为 None。
cur: Cur 节点任务 最近一次应用的 NodeInfo、用户列表和节点 tag。拉取失败或返回 304 时,轮询周期回退到它。
src/manager/transport.rs
pub struct TransportManager {
accept: Scope,
proxy: Arc<ProxyManager>,
}
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 async fn shutdown(self);
}
src/serve.rs
#[derive(Clone, Default)]
pub struct Scope {
pub token: CancellationToken,
tasks: TaskTracker,
}
pub fn spawn_scoped<F>(scope: &Scope, fut: F)
where
F: Future + Send + 'static,
F::Output: Send,
src/manager/proxy.rs
pub enum Tables {
Stream(ArcSwap<StreamProtocol>),
Hysteria {
server: Hy2Inbound<UserTag>,
cfg: HysteriaConfig,
},
}
pub struct ProxyManager {
tables: Tables,
sniff: bool,
dispatcher: Arc<Dispatcher>,
traffic: Arc<NodeTraffic>,
preauth: Arc<Semaphore>,
handshake_failures: AtomicU64,
node_tag: CompactString,
}
src/connector.rs
pub struct Dispatcher {
pub router: Arc<Router<Outbound>>,
pub rules: Arc<RuleManager>,
pub node_tag: CompactString,
pub admission: Admission,
}
pub struct Admission {
traffic: Arc<NodeTraffic>,
leases: Mutex<HashMap<AuthKey, CancellationToken>>,
}

一代监听器在整个生命周期内固定三样东西:已绑定的套接字、其 Dispatcher 中的 Router,以及 Tables 的种类。只有 Tables 内部的用户表会原地变化。因此,修改路由或出站池需要一代新的监听器。路由修改通过 poll_cycle(true) 强制产生新的一代,出站池修改则通过 apply_route_change,两者最终都调用 rebuild,按设计会断开该节点上的所有连接。用户列表变化则不需要。

  • Tables::Stream 把 StreamProtocol(Vmess、Vless、Trojan、ShadowsocksLegacy 或 Shadowsocks2022)放在一个 ArcSwap 中。accept_stream 对每个流调用一次 load_full(),所以一条连接在整个生命周期内都保留它认证时所用的那张表。
  • Tables::Hysteria 持有正在运行的监听器。刷新时只通过 Hy2Inbound::set_authenticator 替换其认证器;套接字和每条 QUIC 连接都保持不变。
  • handshake_failures 由认证监控每秒清空一次;若该秒内的失败次数超过 HANDSHAKE_FAILURE_ALERT_PER_SEC,监控会记录一条警告。它只负责检测,从不阻断。
src/connector.rs
pub type LeaseSlot = watch::Sender<Option<CancellationToken>>;
#[derive(Clone)]
pub struct KatanaConnector {
disp: Arc<Dispatcher>,
source: Option<IpAddr>,
lease: Option<Arc<LeaseSlot>>,
}
impl Connector<Flow<UserTag>> for KatanaConnector {
type Stream = Metered<OutboundStream>;
type Datagram = FanOut;
type Future = ConnectFuture;
fn connect(&mut self, flow: Flow<UserTag>) -> ConnectFuture;
}
src/traffic.rs
pub enum AuthKey {
Uuid(Uuid),
Name(CompactString),
}
pub struct UserTag {
pub key: AuthKey,
pub uid: i64,
}
pub struct NodeTraffic {
users: RwLock<HashMap<AuthKey, Arc<UserCounter>>>,
residuals: Mutex<HashMap<i64, (u64, u64)>>,
draining: Mutex<Vec<Arc<UserCounter>>>,
}
  • UserTag 是每张协议用户表的载荷,在内核的 NetworkUser::user_data 中以 Arc<UserTag> 出现。它只标识用户,不携带用户的计数器。每个流通过 NodeTraffic::lookup(key, uid) 查找计数器,只有当该凭据仍注册在同一个 uid 名下时才会成功。因此,即使一张表被长连接保持存活,也无法继续为面板已移除的用户计费或准入。
  • AuthKey 在 VMess 和 VLESS 节点上是 Uuid,在 Trojan、Shadowsocks 和 Hysteria 2 节点上是 Name(label)。NodeType::keys_by_email 是唯一决定这一点的地方,因此计数器和协议表使用的键始终一致。
  • KatanaConnector:每条 stream 连接构建一个(带 LeaseSlot)。在 Hysteria 2 节点上,内核为每个代理流以及每条连接的 UDP 运行时各构建一个,都不带 LeaseSlot。
  • Gate(src/meter.rs)把用户的 Arc<UserCounter> 和租约绑在一起。Metered<S> 用一个 Gate 包装每个 TCP 出站;FanOut 为每个 UDP 关联持有一个。
flowchart TB
  NM["NodeManager"]
  PC["PanelClient"]
  NT["NodeTraffic"]
  RM["RuleManager"]
  TM["TransportManager"]
  SC["Scope"]
  PM["ProxyManager"]
  TBL["Tables"]
  DI["Dispatcher"]
  AD["Admission"]
  RT["Router"]
  UC["UserCounter"]
  NM --> PC
  NM --> NT
  NM --> RM
  NM --> TM
  TM --> SC
  TM --> PM
  PM --> TBL
  PM --> DI
  DI --> RT
  DI --> RM
  DI --> AD
  AD --> NT
  NT --> UC

箭头从所有者指向被拥有者;共享的 Arc 从每个持有者各画一条。NodeTraffic 和 RuleManager 属于节点,并被共享给每一代监听器。每代的 Dispatcher 拿到节点 Router 的一个 Arc 克隆。每个 Gate 再多持有一个 Arc<UserCounter>,NodeTraffic::snapshot 正是依据这个引用计数判断一个排空中的计数器是否还有写入者。

锁 类型 保护的内容 获取者
NodeManager.api、.cfg、.pool、.router parking_lot::Mutex 面板客户端、静态配置、出站池、编译好的路由器 仅节点任务。
NodeManager.transport、.cur tokio::sync::Mutex 当前 generation、最近一次应用的状态 仅节点任务。
Tables::Stream ArcSwap<StreamProtocol> stream 用户表 accept_stream 读取,refresh 写入。
Admission.leases parking_lot::Mutex<HashMap<AuthKey, CancellationToken>> 租约 admit、commit、retire_all。
NodeTraffic.users parking_lot::RwLock 注册表 prepare、lookup 和 snapshot 读取;commit 写入。
NodeTraffic.draining、.residuals parking_lot::Mutex 即将移出的计数器、无计数器的字节 commit、snapshot、上报路径。
TokenBucket.state parking_lot::Mutex<BucketState> 余额和上次补充时间 charge、ready_at。
RuleManager.inbound、.results parking_lot::RwLock、parking_lot::Mutex 按 tag 的规则、按 tag 的命中 update、detect、drain。
ProxyClient.inner parking_lot::Mutex 内核客户端 connector 仅在构建拨号 future 时持有。

parking_lot 的 guard 不是 Send,而 NodeManager::run 由 tokio::spawn 启动,要求 future 为 Send。因此,跨 .await 持有节点的任何 parking_lot guard 都无法通过编译。节点把每个这类锁的持有限定在一个表达式或一个短代码块内。

锁发生嵌套时,获取顺序始终是 Admission.leases → NodeTraffic.users → NodeTraffic.draining。admit 先取 leases,再取 users。Admission::commit 取 leases,并在其中由 NodeTraffic::commit 先取 users、再取 draining。snapshot 先取 users、再取 draining,并且只在两者都释放后才取 residuals。另外,RuleManager::detect 在持有 inbound 时获取 results。

sequenceDiagram
  participant C as 客户端
  participant L as accept_loop
  participant K as serve_socket
  participant PM as ProxyManager
  participant D as drive 与运行时
  participant KC as KatanaConnector
  participant AD as Admission
  participant O as Outbound
  C->>L: TCP 连接
  L->>L: 尝试获取会话许可,然后等待阶段许可
  L->>K: spawn_scoped
  K->>K: InboundTransport::accept 执行 TLS、WebSocket 或 HTTP/2
  K->>PM: 对产出的每个流调用 accept_stream
  PM->>PM: 尝试获取预认证许可,load_full 用户表
  PM->>D: spawn_scoped serve_stream
  D->>D: 协议核心认证并解析请求
  D->>KC: connect(flow)
  KC->>AD: admit(tag)
  AD-->>KC: 计数器和租约
  KC->>KC: 发布租约,路由,审计
  KC->>O: connect_stream(destination)
  O-->>D: Metered 出站流
  D->>D: 已建立,释放预认证许可
  D->>C: 回复,然后中继,直到结束、退役或看门狗触发
  1. Accept。 accept_loop 用 try_acquire_owned 在 MAX_LIVE_CONNECTIONS_PER_NODE 之下占一个名额。超出上限的套接字被丢弃,并计为一次握手失败。随后套接字等待 MAX_TRANSPORT_STAGES_PER_NODE 之下的传输阶段名额,然后启动 serve_socket。
  2. 传输层。 serve_socket 运行 InboundTransport::accept。阶段名额在第一个流到达时或 TRANSPORT_HANDSHAKE_TIMEOUT 到期时释放,以先发生者为准。一个 gRPC 套接字为每个 HTTP/2 stream 产出一个流,它们通过 Arc<OwnedSemaphorePermit> 共享该套接字的存活连接名额。
  3. 预认证。 ProxyManager::accept_stream 用 try_acquire_owned 在 MAX_PREAUTH_STREAMS_PER_NODE 之下占一个名额,加载当前用户表并启动 serve_stream。超出上限的流被丢弃,并计为一次握手失败。
  4. 握手。 serve_stream 创建该连接的 LeaseSlot 和 KatanaConnector,基于用户表构建协议核心,并调用 drive。drive 把核心包装进 ProxyServerRuntime::new(..).showing_progress(),并给它 HANDSHAKE_TIMEOUT 的时间达到 is_established()。预认证名额在此时释放。
  5. 连接。 核心打开的每个流(请求本身、每个 mux 子流、每个 UDP 关联),运行时都会调用 KatanaConnector::connect。connect 对用户进行准入(注册表中不属于该 uid 的用户会以 PermissionDenied "refused" 被拒绝),把租约一次性发布到槽中,取得来源地址(flow.source,否则取对端地址)并构建一个 Gate。UDP 流变成一个 FanOut。TCP 流按 route_target(destination, sniffed, source) 路由。Outbound::Block 或审计命中会以 PermissionDenied "refused" 拒绝它;否则拨号所选出站,并用 Metered 包装。
  6. 中继。 drive 持续 poll 运行时,直到它结束、租约被取消(until_retired),或在 PROGRESS_WATCHDOG 时间内没有任何数据流动。每个含非零字段的 Traffic 增量都会重置看门狗。

各详细页面分别覆盖每一步。监听器与服务循环 覆盖第 1 至 4 步和第 6 步。准入 和 connector 与 UDP fan-out 覆盖第 5 步,计量 则讲述 Metered 如何处理每个字节。

Hysteria 2 节点没有 accept 循环。TransportManager::start 绑定一个 std::net::UdpSocket 并启动 run_hysteria,由它把套接字交给内核:

src/serve.rs
pub async fn run_hysteria(
inbound: Hy2Inbound<UserTag>,
socket: std::net::UdpSocket,
dispatcher: Arc<Dispatcher>,
token: CancellationToken,
)
sequenceDiagram
  participant C as QUIC 客户端
  participant H as Hy2Inbound
  participant KC as KatanaConnector
  participant AD as Admission
  participant O as Outbound
  C->>H: QUIC 握手,然后认证
  H->>H: 当前 Authenticator 把凭据映射为 UserTag
  C->>H: 代理流,或 UDP 包
  H->>KC: 工厂为客户端 IP 构建 connector
  H->>KC: connect(flow)
  KC->>AD: admit(tag)
  AD-->>KC: 计数器和租约
  KC->>O: 仅 TCP:路由、审计、拨号
  KC-->>H: Metered 流,UDP 则为 FanOut

内核监听器对每条 QUIC 连接进行认证,为每个代理流运行自己的运行时;启用 UDP 时,还为每条连接运行一个数据报运行时。它为每个这样的运行时调用工厂 move |ip| KatanaConnector::new(dispatcher.clone(), Some(ip), None),因此每个流依然经过准入、路由、审计和计量。这里没有 LeaseSlot,也没有 katana 持有的按连接任务。已退役用户的流之所以停止,是因为它们的出站拒绝继续传输:租约取消后 Gate::poll_open 返回 ConnectionAborted,新的流也无法通过准入。

内核侧的上限来自 katana 的 build_hysteria:max_connections: HY2_MAX_CONNECTIONS 限制 QUIC 连接数,大小为 MAX_LIVE_CONNECTIONS_PER_NODE 的 circuit_permits 则对代理流和 UDP 会话合计限制。内核侧见 Hysteria 2 服务端。

两个程序共享内核和服务循环的形态,其余几乎处处不同。

阶段 etemenanki-app katana
监听器设置 TOML 文件 面板的 NodeInfo,外加 TOML 文件中的 listen_ip、证书和 [node.hysteria]
提供的协议 SOCKS、HTTP、Trojan、VLESS、VMess、Shadowsocks、Shadowsocks 2022、Hysteria 2、TUN VMess、VLESS、Trojan、Shadowsocks、Shadowsocks 2022、Hysteria 2
Accept 循环 app/src/serve.rs → run_stream_inbound src/serve.rs → accept_loop
任务作用域 spawn_scoped(token, fut) 返回 JoinHandle spawn_scoped(&scope, fut) 注册到作用域的 TaskTracker
流类型 Flow<()> 加 FlowContext(入站 tag、来源) Flow<UserTag>;来源由 connector 携带
Connector AppConnector:路由,然后拨号 KatanaConnector:准入、路由、审计、拨号、计量
路由目标 目标地址、嗅探到的域名、网络、入站 tag、来源 目标地址、嗅探到的域名、网络、来源;[[route.rule]] 只匹配域名后缀、CIDR、端口、GeoSite 和 GeoIP
UDP 关联 FanOutLink:路由每个包 FanOut:对每个包路由、审计和计费
结束某个用户的连接 没有这个概念:用户在一代实例内固定 通过 Admission 取消租约
一次变更的代价 配置文件的任何改动都会构建一整代新实例,所有连接断开 按节点计:面板用户变化原地替换用户表;面板的传输层或协议变化,或配置文件中 listen_ip、证书块、disable_sniffing、api.enable_vless、[node.hysteria]、路由或出站的修改,会重建该节点的监听器;[node.api] 的其他修改会构建新的面板客户端,只有当它读到的节点信息改变了传输层或协议时才重建;面板身份变化会重新启动该节点;其他配置字段无需重建即可生效
Hysteria 2 run_hysteria_inbound,一个不区分用户的 connector run_hysteria,一个负责准入和计量的 connector

app 一侧的内容见 app 服务循环 和 generation 与重载。两个程序共享的连接生命周期见 连接生命周期。

节点任务在面板轮询和静态更新之间交替进行。

  • 启动引导(NodeManager::bootstrap,每次尝试调用一次 try_bootstrap):forget_etags,使面板返回完整应答;node_info;user_list;然后 bring_up、set_cur,以及在未设置 disable_get_rule 时执行 refresh_rules。当 node_info 出错或什么都没返回、端口为 0、user_list 出错或返回 304,或 bring_up 失败时,本次尝试失败。节点随后记录 node <id>: <reason>; retrying in <n>s 并等待:第一次失败后等 1 秒,之后每次翻倍,最多 60 秒,且从不超过轮询周期。等待期间到达的 StaticUpdate 会被应用;由于还没有任何东西在运行,它只保存所携带的内容,而 StaticUpdate::Config 会立即结束等待,因为这次修改可能正是修复。节点 token 会结束等待或当前尝试,随后 run 拆除该次尝试绑定的一切,并发送最后的上报。空的用户列表不算失败:bring_up 不绑定任何东西,节点等待第一次带来用户的轮询。对于 [node.hysteria].port 非零的 Hysteria 2 节点,node_info 在本地构建 NodeInfo,不向面板请求。
  • 轮询周期(poll_cycle(rebuild)),每 update_periodic 秒一次(至少 1 秒):拉取节点信息和用户,出错或返回 304 时回退到 cur;端口为 0 的节点信息也视为未取到,因此节点保留上一次的节点信息,但仍会应用用户;reconcile;在未设置 disable_get_rule 时刷新规则;report_traffic;report_illegal。定时器传入 rebuild = false。interval 的第一个 tick 会被消耗掉,因此第一次轮询发生在启动引导之后一个完整周期。设置了 disable_upload_traffic 时,report_traffic 不发送任何内容,并丢弃残余量和已无写入者的排空计数器。newV2board 客户端的 report_illegal 是空操作;只有 SSPanel 会收到审计命中,而且不包括本地规则(规则 id 为 -1)的命中。
  • 协调阶梯(reconcile),第一个匹配的分支生效:
    1. 没有用户:拆除监听器,并提交空的用户集合。
    2. 强制重建(rebuild = true)、没有监听器、NodeInfo::transport_eq 为 false,或 NodeInfo::protocol_eq 为 false:rebuild,即先拆除再调用 bring_up。
    3. user_set_differs,或节点限速发生变化:ProxyManager::refresh,保留监听器以及所有留下的用户(包括限速变化的用户)的连接;离开的用户和被重新绑定的用户会退役。
  • 静态更新(apply_static):StaticUpdate::Config 要么整体生效,要么完全不生效。它先构建这次修改需要的东西:panel_type 或 [node.api] 变化时用 PanelClient::new 构建新的面板客户端,路由变化时构建新的路由器。任一失败,节点就记录 config edit refused, keeping the running one,什么都不改变。否则,它保存配置、路由器和客户端。路由、listen_ip、证书块、disable_sniffing、api.enable_vless 或 [node.hysteria] 的变化会设置 rebuild。当设置了 rebuild 或客户端被替换,并且节点已完成启动引导时,apply_static 立即运行 poll_cycle(rebuild)。新客户端不持有任何 ETag,所以这次轮询会完整读取节点及其用户;协调阶梯随后按应答所需选择最窄的一步,或执行强制重建。因此,只改变客户端的修改(例如 rule_list_path 或 speed_limit)会保留所有连接,除非新的应答改变了传输层或协议。StaticUpdate::Outbounds 替换出站池,用 rebuild_router 重新编译路由器,然后重建。其他字段只被保存,在下次使用时读取;update_periodic 变化时会重启轮询 interval。

节点管理器 讲述协调阶梯,运行时与重载 讲述 apply_reload。面板客户端 讲述每次拉取发送和解析的内容。

不变量 机制 由什么固定
一切可能失败的东西都在监听器绑定之前构建,所以有问题的节点绝不会只绑定一半。 TransportManager::start 先构建传输层和协议表(或 Hysteria 监听器),最后才绑定;traffic.commit 在绑定之后执行。 间接由 tests/unit/inbound.rs 中的构建拒绝测试固定,例如 reality_rejected、vless_flow_rejected、tls_without_cert_file_errors、hysteria_requires_a_certificate
用户刷新绝不触碰监听器,也不触碰未变化用户的连接。 ProxyManager::refresh 替换 ArcSwap<StreamProtocol> 或调用 set_authenticator;PreparedUsers 保留未变化用户的计数器,且不把该用户放入 cancel_keys。 tests/unit/e2e.rs 中的 unchanged_user_survives_user_refresh、repeated_user_refreshes_never_disturb_a_live_connection
离开或被重新绑定的用户失去所有流,也拿不到新的流。 refresh 先发布注册表再发布用户表。Admission::commit 在 admit 同样会获取的 leases 锁下替换注册表,并取消 cancel_keys 中的租约。 a_retired_user_stops_while_the_rest_keep_their_connections(tests/unit/e2e.rs);the_lease_reaches_the_connection_and_goes_with_the_user、a_credential_rebound_to_another_uid_is_refused(tests/unit/connector.rs);a_retired_users_connection_ends(tests/unit/serve.rs)
只有已注册的凭据才会被准入,并且只计入它自己的 uid。 NodeTraffic::lookup 按 uid 过滤;表中携带的是 UserTag,不是计数器。 a_user_the_registry_does_not_know_is_refused、an_admitted_stream_is_billed_to_its_user(tests/unit/connector.rs)
修改路由或出站池会断开节点上的所有连接。 Router 在每代实例内固定。路由修改经由 poll_cycle(true),出站池修改经由 apply_route_change,两者都调用 rebuild。 route_change_drops_connections(tests/unit/e2e.rs)
只改变面板客户端的修改在生效时不会断开任何连接。 apply_static 替换客户端并运行 poll_cycle(false),只有新的应答改变了传输层或协议时,协调阶梯才会重建。 a_client_edit_takes_effect_without_dropping_connections(tests/unit/runtime.rs)
无法构建的重载或配置修改不会改变任何东西。 apply_reload 在触碰运行中的节点之前,先构建每个新增节点和每个已变化节点的面板客户端;apply_static 在保存任何内容之前先构建客户端和路由器。 a_reload_with_a_node_that_does_not_build_changes_nothing(tests/unit/runtime.rs)
无法启动的节点会持续重试,并且在被取消时仍会停止。 bootstrap 带退避重试,并让每次尝试和每次等待都与节点 token 竞争。 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(tests/unit/e2e.rs)
在用户变化前后,一个计数器的字节恰好上报一次。 NodeTraffic::commit 和 snapshot 以相同顺序同时持有 users 和 draining,因此一个计数器只会出现在一处。 rate_change_drains_old_counter_and_reports_once、draining_counter_with_live_writer_is_retained、a_departed_users_late_bytes_are_still_reported(tests/unit/traffic.rs)
在 katana 这一侧,上报既不丢字节也不重复计数。 commit_reported 只对已上报的数量执行 fetch_sub;上报失败时,restore_residuals 把无计数器的行放回去。 commit_reported_preserves_concurrent、restored_residuals_are_retried(tests/unit/traffic.rs)
限速是字节的属性,而不是 chunk 大小的属性。 TokenBucket::charge 总是完整扣除 n 并进入欠额;Gate::poll_open 一直等到 ready_at 为 None。 a_chunk_larger_than_the_burst_is_still_limited、debt_holds_back_the_next_charge_too(tests/unit/traffic.rs);the_limit_holds_however_the_writes_are_sized(tests/unit/meter.rs)
沉默的客户端无法在握手截止时间之后继续占用预认证名额。 drive 把整个握手包在 tokio::time::timeout(HANDSHAKE_TIMEOUT, ..) 中,并在每条退出路径上释放许可。 a_silent_client_is_dropped_at_the_handshake_deadline(tests/unit/serve.rs)
没有任何数据流动的连接会被结束。 drive 中的 PROGRESS_WATCHDOG sleep,由每个非零 Traffic 增量重置。 a_connection_that_stops_moving_is_dropped(tests/unit/serve.rs)
节点的任何 parking_lot guard 都不会跨 .await 持有。 这些 guard 不是 Send,而节点 future 交给了 tokio::spawn。 编译器
拆除之后不会留下该代的任何任务。 Scope::shutdown 取消、关闭 TaskTracker 并等待;Drop 兜底执行取消。 没有专门的测试;tests/unit/e2e.rs 中的每个测试都会取消其节点并 await 任务
失败 结果
启动时配置加载失败、出站池构建失败,或没有 [[node]] run 记录日志并返回 ExitCode::FAILURE。
无法建立配置监视器 run 记录 config watcher disabled (no live reload),继续服务但不再重载。
某个节点的面板客户端或路由器在启动时构建失败 spawn_node 记录日志并返回 None;其他节点照常启动。如果一个都没有启动,进程以失败退出。
某次启动引导尝试失败:node_info 出错或什么都没返回、端口为 0、user_list 出错或返回 304,或 bring_up 失败 bootstrap 记录原因,并在退避之后重试(1 秒,逐次翻倍到 60 秒,以轮询周期为上限);收到 StaticUpdate::Config 后则立即重试。取消节点 token 会结束重试。
某次轮询拉取失败或返回 304,或节点信息返回的端口为 0 poll_cycle 复用最近一次应用的节点信息或用户列表。下一个 tick 就是重试。
监听器重建失败 节点没有监听器。下一次轮询发现没有传输层,会再次重建。
用户刷新时表构建失败 refresh 记录日志并在发布任何内容之前返回;正在运行的表和注册表保持不变。
配置修改中的路由编译失败,或其 [node.api] 无法构建面板客户端 apply_static 记录 config edit refused, keeping the running one,不保存任何内容;监听器及其连接保持不变。
重建后的出站池使某个节点的路由无法编译 rebuild_router 保留旧路由器,但 apply_route_change 仍会重建监听器,因此连接会断开。
重载的配置无法解析、出站有误,或含有无法构建的节点 不调用 apply_reload,或 apply_reload 在应用任何内容之前返回;每个节点都保持原样继续运行。
流量上报失败 活跃和排空中的计数器不受影响;无计数器的行回到残余量中,留待下一周期。
审计上报失败 该周期的命中被丢弃,并记录一条警告。
accept 失败,且错误不是 ConnectionAborted 或 Interrupted accept_loop 退避 ACCEPT_ERROR_BACKOFF;若此期间作用域被取消,则停止。

取消始终向下传递:root token → 节点 token → 节点自己的 tear_down → Scope::shutdown → 租约。drive future 在其等待之处被丢弃,从而丢弃运行时、客户端流以及它拥有的每个出站。

常量 值 作用范围 定义位置
MAX_LIVE_CONNECTIONS_PER_NODE 65,536 每代监听器已接受的套接字;也用作 Hysteria 的 circuit_permits src/serve.rs
MAX_TRANSPORT_STAGES_PER_NODE 2,048 处于 TLS、WebSocket 或 HTTP/2 握手中的套接字;满时 accept 循环等待 src/serve.rs
TRANSPORT_HANDSHAKE_TIMEOUT 10 秒 套接字可占用阶段名额的时长 src/serve.rs
PROGRESS_WATCHDOG RELAY_IDLE_TIMEOUT + 60 秒 = 360 秒 两个方向都没有字节流动 src/serve.rs
ACCEPT_ERROR_BACKOFF 100 ms accept 出错之后 src/serve.rs
HANDSHAKE_TIMEOUT 10 秒 直到协议核心建立 内核,protocols/src/core/mod.rs
RELAY_IDLE_TIMEOUT 300 秒 协议核心自己的空闲截止时间 内核,protocols/src/core/mod.rs
MAX_PREAUTH_STREAMS_PER_NODE 512 处于协议握手中的流;超出的流被丢弃 src/manager/proxy.rs
HANDSHAKE_FAILURE_ALERT_PER_SEC 10 每秒失败次数超过该值时,采样器记录警告 src/manager/proxy.rs
HY2_MAX_CONNECTIONS 4,096 每个 Hysteria 2 节点的 QUIC 连接数 src/inbound.rs
DRAIN_TIMEOUT、RELEASE_TIMEOUT 各 3 秒 等待 Hysteria 端点空闲,再等待其端口释放 内核,protocols/src/hysteria/server/inbound.rs
MAX_SUBS 64 一个 UDP FanOut 保持打开的出站子链路数 src/connector.rs
MAX_RESOLVED_NAMES 256 一个直连 UDP 关联缓存的域名数 src/outbound/freedom.rs
静态更新通道 16 每个节点排队的 StaticUpdate 数 src/runtime.rs → spawn_built
重载防抖 500 ms 合并监视器事件 src/runtime.rs → run
轮询周期 update_periodic,至少 1 秒 poll_period src/manager/node.rs
BOOTSTRAP_RETRY_MIN 1 秒 第一次启动引导尝试失败后的等待时间;此后每失败一次翻倍 src/manager/node.rs
BOOTSTRAP_RETRY_MAX 60 秒 两次启动引导尝试之间的最长等待;轮询周期更短时以轮询周期为上限 src/manager/node.rs
面板请求超时 api.timeout,为 0 时取 5 秒 每个面板请求 src/config.rs → timeout_secs

出站客户端运行时为每种协议使用固定大小的缓冲区:HTTP_BUF、SOCKS_BUF 和 VLESS_BUF 为 16 KiB,SS_BUF 为 20 KiB,VMESS_BUF 和 SS2022_BUF 为 32 KiB(src/outbound/mod.rs)。

  • 文件夹tests
    • integration.rs 一个启动真实二进制的测试目标
    • 文件夹integration
      • xray_interop.rs
      • sniff.rs
      • hysteria_interop.rs
    • 文件夹support
      • mod.rs FakePanel、echo 服务器、证书、参考客户端构建
    • 文件夹unit
      • e2e.rs 针对假 UniProxy 和 SSPanel 面板运行 NodeManager
      • runtime.rs 针对运行中的节点执行 apply_reload
      • serve.rs
      • connector.rs
      • traffic.rs
      • meter.rs
      • rule.rs
      • inbound.rs
      • outbound.rs
      • 文件夹api
        • newv2board.rs
        • sspanel.rs
  • 单元测试位于 tests/unit/ 下,但编译进二进制自身的测试 harness。每个被测源文件末尾都有 #[cfg(test)]、一个指向 tests/unit/ 的 #[path] 属性和 mod tests;,因此测试可以访问私有项。src/main.rs 以同样的方式引入 tests/unit/e2e.rs,src/runtime.rs 引入 tests/unit/runtime.rs。
  • tests/unit/e2e.rs 让一个真实的 NodeManager 对接一个手写的 UniProxy 面板,并用内核自己的客户端驱动它:VMess 计量与上报、用户刷新、代理出站、路由变化、启动引导重试,以及 Hysteria 2 节点的各种情形。假 UniProxy 面板可以让最初几次节点配置请求失败,也可以像 Xboard 那样用 ETag 和 304 Not Modified 应答;启动引导重试测试两者都用到了。该文件还包含一个假的 SSPanel mod_mu 面板(fake_sspanel)。
  • tests/unit/runtime.rs 针对运行中的节点驱动 apply_reload:哪些修改会改变节点身份(an_sspanel_node_is_its_panel_node_id、a_newv2board_node_is_also_the_type_it_asks_for、a_node_is_logged_by_its_panel_node_not_its_key)、会重新启动节点的 newV2board 类型修改、因某个节点无法构建而被拒绝的重载,以及原地应用的 [node.api] 修改:一个会重建监听器的 SSPanel enable_vless 修改,和一个保留所有连接的 rule_list_path 修改。
  • tests/integration.rs 针对 FakePanel 启动构建好的 katana 二进制,并用 Xray 和 Hysteria 参考客户端连接。tests/support/mod.rs 在 Etemenanki checkout 中的 Xray-core 和 hysteria 参考源码树上用 go build 构建这些客户端。缺少 go 或某个源码树时,它打印 SKIP: …,测试不运行即通过。
  • 测试启用了 tokio 的 test-util feature,因此 tests/unit/serve.rs、tests/unit/traffic.rs 和 tests/unit/meter.rs 中的长截止时间都在暂停的时钟上运行(#[tokio::test(start_paused = true)])。

如何运行这些测试套件见 测试。