跳转到内容

面板客户端

源码文件:19 个 · 核对版本 katana v3.0.1
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/src/config.rs
  • katana/src/runtime.rs
  • katana/src/inbound.rs
  • katana/src/main.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/transport.rs
  • katana/src/rule.rs
  • katana/Cargo.toml
  • katana/tests/unit/api/newv2board.rs
  • katana/tests/unit/api/sspanel.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/runtime.rs
  • katana/tests/support/mod.rs
  • katana/tests/integration/xray_interop.rs

api 模块是 katana 与面板之间唯一的接触点。它把两套互不相关的 HTTP API,即 SSPanel 的 mod_mu 和 newV2board 的 UniProxy,统一成一套很小的词汇:一个描述监听器的 NodeInfo、一组 UserInfo、一组编译好的审计规则,以及反方向的两个上报调用。它之上的所有部分,包括节点管理器、监听器构建器和流量计费,都只看到这套词汇。

在新增面板字段、支持新的节点类型、修改响应的解析方式,或改动任何可能让面板密钥进入日志的代码之前,请先阅读本页。同一套行为在用户视角下的说明见 Xboard / V2board 和 SSPanel 两个指南页面。

该模块负责五件事:

  1. 获取并归一化节点描述(node_info)和用户列表(user_list),转换为与面板无关的结构体。
  2. 节省带宽:每个端点各有一份 ETag 缓存,“未变化”以 Ok(None) 返回,而不是重新解析一份相同的 body。
  3. 上报每个用户的流量(report_user_traffic)和审计命中(report_illegal),采用各面板自己的 body 格式。
  4. 构建审计规则集(node_rule),来源为面板加上可选的本地规则文件。
  5. 不让面板密钥出现在状态错误和日志中,因为两种面板都把密钥放在查询字符串里。

它有意不负责:

  • 判断变化意味着什么。新的 NodeInfo 是重建监听器还是只刷新用户,由节点管理器的 reconcile 阶梯决定,见节点管理器;
  • 重试。调用失败时返回错误。节点管理器会以 1 秒到 60 秒的退避重试失败的 bootstrap,退避上限不超过轮询周期;bootstrap 之后,下一次轮询就是重试;
  • 验证内核能否提供面板所描述的服务。REALITY、XTLS flow、未知的传输层和未知的 Shadowsocks 加密方式在这里都能正常解析,由 src/inbound.rs 中的监听器构建器拒绝;
  • 用规则匹配目标地址。这由 src/rule.rs → RuleManager 使用本模块产出的 DetectRule 完成。
文件 内容
src/api/mod.rs 共享模型(NodeType、Transport、NodeInfo、UserInfo、UserTraffic、DetectRule、DetectResult、RouteRule)、单位换算、EtagCache、error_for_status、build_http_client、read_local_rules、panel_node_type,以及 PanelClient 枚举及其构造函数 PanelClient::new。
src/api/newv2board.rs UniProxy 客户端、自由函数 node_type_param,以及私有响应结构体(ServerConfig、NetworkSettings、Route、UserListResponse、UserResponse)。
src/api/sspanel.rs mod_mu 客户端、custom_config 解析器和旧版 server 字符串解析器、compare_version,以及它的私有结构体(Envelope、NodeInfoResponse、CustomConfig、UserResponse、PostData、TrafficItem、IllegalItem、RuleItem)。
src/runtime.rs PanelClient::new 的调用方:启动时(spawn_node)、重载时(apply_reload)和试运行时(test_config);以及节点身份元组(identity、display_id),它决定配置修改时是重新启动节点还是就地重新配置。
src/manager/node.rs NodeManager,客户端方法的唯一调用方。它负责重试 bootstrap,并在配置修改涉及客户端构造所依据的字段时重建并替换客户端。

两个客户端之间没有共享 trait。PanelClient 是一个普通枚举,每个方法都是一个两分支的 match,转发给具体的客户端:

src/api/mod.rs
pub enum PanelClient {
Sspanel(sspanel::Client),
NewV2board(newv2board::Client),
}
impl PanelClient {
pub fn new(cfg: &NodeConfig) -> Result<Self>;
pub async fn node_info(&self) -> Result<Option<NodeInfo>>;
pub async fn user_list(&self) -> Result<Option<Vec<UserInfo>>>;
pub async fn report_user_traffic(&self, t: &[UserTraffic]) -> Result<()>;
pub async fn node_rule(&self) -> Result<Option<Vec<DetectRule>>>;
pub async fn report_illegal(&self, d: &[DetectResult]) -> Result<()>;
pub fn forget_etags(&self);
pub fn inherit_routes(&self, old: &PanelClient);
}

Result 即 anyhow::Result。枚举让调用保持静态分发、future 保持具体类型,因此 NodeManager 可以持有 PanelClient,无需装箱,也无需 async trait。新增第三种面板意味着新增一个模块、一个变体、在 new 中加一个分支,并在六个转发方法(五个面板调用和 forget_etags)中各加一个分支。只有当新客户端会缓存来自响应的状态,或者它的面板不只按 id 查找节点时,inherit_routes 和 panel_node_type 才需要为它加分支。

其中三个返回类型里的 Option 在所有地方含义相同:Ok(None) 表示“自上次以来未修改”(HTTP 304),绝不表示“为空”。空的用户列表是 Ok(Some(vec![]))。

方法 newV2board SSPanel
node_info GET /api/v1/server/UniProxy/config GET /mod_mu/nodes/{node_id}/info
user_list GET /api/v1/server/UniProxy/user GET /mod_mu/users
report_user_traffic POST /api/v1/server/UniProxy/push POST /mod_mu/users/traffic
node_rule 不发请求:由上一次 config 响应中缓存的 routes 加上本地规则文件构建。总是返回 Ok(Some(_))。 GET /mod_mu/func/detect_rules,加上本地规则文件。
report_illegal 空操作,返回 Ok(())。 POST /mod_mu/users/detectlog
forget_etags 不发请求:EtagCache::clear。 不发请求:EtagCache::clear。
inherit_routes 不发请求:两个客户端都是 newV2board 时,复制 old 缓存的 routes。 空操作:每次刷新都会重新获取规则。

PanelClient::new 负责选择变体:

src/api/mod.rs
impl PanelClient {
pub fn new(cfg: &NodeConfig) -> Result<Self>;
}

panel_type 先转为小写,再与 "sspanel"、"newv2board" 及其别名 "v2board" 匹配。其他任何值都会以 unknown panel_type "foo"(小写后的值)失败。随后两个 Client::new 函数都用 NodeType::parse 解析 api.node_type,失败时报 unknown node_type "Vmess2"(按原样写出的值)。src/runtime.rs 中有三个调用方:

调用方 时机 出错时
test_config katana --test 报告错误,不联系面板。
spawn_node 启动 记录 node 1: unknown panel_type "foo",并且不启动该节点;其他节点照常启动。
apply_reload 配置重载 在触碰任何运行中的节点之前,先为每个新增或修改的节点构建客户端。只要有一个构建失败,就拒绝整次重载,例如 reload: node sspanel@https://panel.example.com#1: unknown node_type "Vmess2"; keeping current config,所有节点都保持原样运行。

每个客户端在构造时从 NodeConfig 复制所需的内容,之后不再读取配置:

复制的字段 newV2board SSPanel
api.host,去掉末尾的 / base_url base_url
api.node_id node_id node_id
api.key token key
api.node_type node_type 和 node_type_param node_type
api.enable_vless enable_vless 和 node_type_param enable_vless(仅旧版解析)
api.vless_flow 不使用 vless_flow(仅旧版解析)
api.speed_limit speed_limit_mbps speed_limit_mbps
api.rule_list_path rule_list_path rule_list_path
api.disable_custom_config 不使用 disable_custom_config
api.timeout 固化在 http 中 固化在 http 中

因此,配置修改只能通过新的客户端到达面板。新客户端如何产生,取决于节点的身份:

src/runtime.rs
type NodeId = (String, String, u32, String, String);
// (panel_type lowercased, api.host, api.node_id, api.key, api::panel_node_type(cfg))
src/api/mod.rs
pub fn panel_node_type(cfg: &NodeConfig) -> String;
src/api/newv2board.rs
pub fn node_type_param(api: &ApiConfig) -> String;

对于 newV2board 节点,panel_node_type 返回 newv2board::node_type_param(&cfg.api);对于 SSPanel,返回空字符串。UniProxy 按节点 id 加上请求中的 node_type 查找节点,因此同一个 id 分别以 vless 和 v2ray 请求,就是两个面板节点,各有自己的用户和流量。SSPanel 的 mod_mu 只按 id 查找节点。api.timeout 不属于身份。

  • 身份变化会移除该节点并启动一个新节点,带有全新的客户端、全新的 HTTP 连接池、空的 ETag 缓存和全新的流量登记表,因此一个面板节点的计数器永远不会被计到另一个节点上。在 newV2board 上,这包括会改变 node_type 参数的 api.node_type 或 api.enable_vless 修改;从 "V2ray" 改成 "v2ray" 不算,在 Trojan 节点上修改 enable_vless 也不算。
  • 对 panel_type 或 [node.api] 的其他任何修改以 StaticUpdate::Config 到达运行中的节点。NodeManager::apply_static 用 PanelClient::new 构建新客户端,以运行中的客户端为参数对它调用 inherit_routes,使 newV2board 缓存的 block routes 得以保留,然后把它换上,并立即轮询面板。新客户端有自己的 HTTP 连接池和空的 ETag 缓存,因此这次轮询会完整读取节点及其用户,随后 reconcile 阶梯按应答执行所需的最小变更;如果这次修改还改变了监听器构建所依据的设置(例如 api.enable_vless),则执行完整重建。仍在 bootstrap 中的节点会保存新客户端,并立即开始下一次尝试。如果新客户端构建失败,节点会记录 node 1: config edit refused, keeping the running one: {e},并保留当前的客户端和配置;不过通常重载自身的检查会先拒绝这样的修改。

NodeManager 以 Mutex<Arc<PanelClient>> 持有客户端,因此替换无需 &mut self;每次调用前先克隆 Arc。重载一侧的说明见运行时与重载,节点一侧见节点管理器。

src/api/mod.rs
pub enum NodeType {
V2ray,
Trojan,
Shadowsocks,
Hysteria2,
}
impl NodeType {
pub fn parse(s: &str) -> Option<Self>;
pub fn keys_by_email(&self) -> bool;
}

parse 会先把输入转为小写:

接受的值(不区分大小写) 变体 keys_by_email()
v2ray、vmess、vless V2ray false:用户以解析后的 UUID 为键
trojan Trojan true
shadowsocks Shadowsocks true
hysteria2、hysteria、hy2 Hysteria2 true

keys_by_email 只回答一个问题:该节点的用户是以 UUID 本身标识,还是以其流量标签标识?VMess 和 VLESS 用 UUID 认证,因此 UUID 是天然的键。Trojan、Shadowsocks 和 Hysteria 2 用从 UUID 派生的密钥认证,因此由标签(见下文的 traffic_email)标识用户。这个答案刻意只有一个来源:src/manager/mod.rs → build_user_entries、src/manager/transport.rs → TransportManager::start 和 src/manager/proxy.rs → refresh 都调用它(后两者把结果传给 user_tag)。如果暂存的流量计数器与协议的用户表在键上不一致,就没有任何用户能拿到计数器,节点也无法启动。

注意 vless 解析为 V2ray。节点提供 VLESS 还是 VMess,由 src/inbound.rs → build_protocol 根据 api.enable_vless 与 NodeInfo.enable_vless 的逻辑或决定,而不是由节点类型决定。newV2board 和 SSPanel 旧版解析器从 api.enable_vless 复制 NodeInfo.enable_vless;只有 SSPanel 的 custom_config 从面板设置它。因此,node_type = "vless" 而没有 enable_vless = true 时,节点提供的是 VMess;在 newV2board 上,它还会向面板发送 node_type=vless,同时读取的却是 VMess 的 settings 键(见 newV2board)。

src/api/mod.rs
pub enum Transport {
Tcp,
Ws,
Grpc,
HttpUpgrade,
SplitHttp,
Other(String),
}
impl Transport {
pub fn parse(s: &str) -> Self;
}

parse 永不失败。它先转为小写,把 ""、tcp 和 raw 映射为 Tcp,ws 和 websocket 映射为 Ws,grpc 和 gun 映射为 Grpc,httpupgrade 映射为 HttpUpgrade,splithttp 和 xhttp 映射为 SplitHttp,其他值保留为 Other(lowercased)。保留未知值而不是回退到 TCP,正是 src/inbound.rs → build_transport 能够拒绝该节点并在消息中写明传输层名称的原因。解析 HttpUpgrade 和 SplitHttp 是为了读取它们的 host,随后它们也会被同一个构建器拒绝。

src/api/mod.rs
pub struct NodeInfo {
pub node_type: NodeType,
pub port: u16,
pub speed_limit: u64,
pub transport: Transport,
pub host: String,
pub path: String,
pub service_name: String,
pub authority: String,
pub enable_tls: bool,
pub enable_vless: bool,
pub vless_flow: String,
pub cypher_method: String,
pub server_key: String,
pub header: Option<serde_json::Value>,
pub headers: HashMap<String, String>,
pub enable_reality: bool,
pub accept_proxy_protocol: bool,
pub obfs_type: String,
pub obfs_password: String,
}
impl NodeInfo {
pub fn transport_eq(&self, other: &NodeInfo) -> bool;
pub fn protocol_eq(&self, other: &NodeInfo) -> bool;
}

speed_limit 的单位已经是字节每秒。两个面板解析器都从不设置 authority、headers 和 accept_proxy_protocol:它们总是为空或 false。在当前版本中,没有任何监听器构建器读取 header、authority 或 headers;它们只参与下面的比较。

NodeInfo 派生了 Debug 和 Clone,但没有派生 PartialEq。相等性被拆成两个手写方法,分别对应监听器的一层:

层 方法 比较的字段
传输层与 socket transport_eq port、transport、host、path、service_name、authority、enable_tls、header、headers、enable_reality、accept_proxy_protocol、obfs_type、obfs_password
代理协议 protocol_eq node_type、enable_vless、vless_flow、cypher_method、server_key
都不比较 无 speed_limit

除 speed_limit 外,每个字段都恰好属于两个方法之一。这样拆分是因为这些字段属于监听器树中不同的对象:传输层字段配置 TransportManager(绑定的 socket、TLS、WebSocket 或 gRPC 分帧、Hysteria 混淆),协议字段配置其内部的代理服务端。port 归入传输层,因为新端口需要新的 socket。obfs_type 和 obfs_password 也归入传输层,因为 Salamander 会打乱每个数据包:如果节点保留旧密钥,所有已经拿到新密钥的客户端都会被拒之门外。speed_limit 两边都不属于,因为节点级速率变化只需要重新设定用户速率;reconcile 阶梯把它交给用户刷新处理,连接得以保留。

在当前版本中,NodeManager::reconcile 对传输层差异和协议差异都执行同一次完整重建。这两个方法仍然标明了是哪一层发生了变化,添加字段时也应在这里扩展。

src/api/mod.rs
pub struct UserInfo {
pub uid: i64,
pub email: String,
pub uuid: String,
pub passwd: String,
pub method: String,
pub speed_limit: u64,
pub port: u16,
pub alter_id: u16,
}
pub struct UserTraffic {
pub uid: i64,
pub upload: i64,
pub download: i64,
}
pub fn traffic_email(u: &UserInfo) -> compact_str::CompactString;

UserInfo 派生了 PartialEq、Eq 和 Hash,src/manager/mod.rs → user_set_differs 用它们做与顺序无关的集合比较。所有字段都参与比较,因此任何字段的变化,即使是没有构建器读取的 passwd、method 或 port,都算作用户集合发生变化,并触发一次用户刷新。

traffic_email 在 email 非空时返回 email,否则返回十进制的 uid。它是用户在内核中携带的标签:Trojan 的 authorization.username、Shadowsocks 用户的 email、Hysteria 的用户名。它必须在多次轮询之间保持稳定,并且只能映射回一个 uid。

面板 UserInfo 中的 email 得到的标签
newV2board "{uuid}@v2board.user" 11111111-2222-3333-4444-555555555555@v2board.user
SSPanel 空 uid,例如 42
src/api/mod.rs
pub struct DetectRule {
pub id: i64,
pub pattern: Regex,
}
pub struct DetectResult {
pub uid: i64,
pub rule_id: i64,
}
pub struct RouteRule {
pub match_: Vec<String>,
pub action: String,
}

DetectRule 在获取规则集时编译一次。它的 id 表明来源:

来源 id
本地规则文件 -1
SSPanel detect_rules 面板中的规则 id
newV2board 中 action = "block" 的 routes 条目 该条目在整个 routes 数组中的下标

DetectResult 是一次记录下来的命中。当流没有已认证用户时,RuleManager::detect 记录 uid = -1。RouteRule 是 newV2board 专用的 routes 条目副本,缓存下来使 node_rule 无需发请求。

src/api/mod.rs
pub const MBPS_TO_BPS: f64 = 1_000_000.0 / 8.0;
pub fn mbps_to_bps(mbps: f64) -> u64;

面板使用十进制的兆比特每秒;katana 的令牌桶按字节每秒计数。mbps_to_bps 乘以 MBPS_TO_BPS(125 000)后截断,对零或负数输入返回 0(“不限速”)。10 Mbps 即 1 250 000 B/s。

两个客户端采用相同的覆盖规则:api.speed_limit 大于零时,它会替换面板提供的所有限速。SSPanel 把它应用到节点限速和每个用户的限速上(sspanel::Client::speed_limit_bps)。newV2board 只把它应用到每个用户的限速上;它的节点限速始终为 0。节点限速与用户限速如何合并为一个速率,见限速。

src/api/mod.rs
pub fn build_http_client(timeout_secs: u64) -> Result<reqwest::Client>;
src/config.rs
impl ApiConfig {
pub fn timeout_secs(&self) -> u64;
}

每个面板客户端拥有一个 reqwest::Client,用 timeout(Duration::from_secs(timeout_secs)) 构建。timeout_secs 返回 api.timeout,为 0(serde 默认值)时返回 5。reqwest 把这个超时应用于整个请求,从建立连接直到读完 body,katana 自身不再额外设置超时。该 crate 以 default-features = false 并启用 json、query 和 native-tls-vendored feature 构建,因此在 Linux 上,HTTPS 面板经由 vendored、静态构建的 OpenSSL 访问。

一个节点的各次调用共享同一个 reqwest::Client,因此会复用连接池中的连接,直到某次配置修改把面板客户端连同其连接池一起替换掉。不同节点之间不共享客户端,即使两个节点指向同一个面板。

src/api/mod.rs
pub struct EtagCache {
map: Mutex<HashMap<&'static str, String>>,
}
impl EtagCache {
pub fn new() -> Self;
pub fn get(&self, key: &'static str) -> Option<String>;
pub fn set(&self, key: &'static str, value: String);
pub fn clear(&self);
}

每个客户端拥有一个 EtagCache,以端点名称为键:"node"、"users",SSPanel 还有 "rules"。锁是 parking_lot::Mutex,只在 get、insert 或 clear 期间持有,从不跨越 .await。两个客户端的 GET 辅助函数(newv2board::Client::get 和 sspanel::Client::get_data)遵循相同的步骤:

sequenceDiagram
  participant NM as NodeManager
  participant C as 面板客户端
  participant E as EtagCache
  participant P as 面板
  NM->>C: node_info()
  C->>E: get("node")
  E-->>C: 之前的 ETag 或无
  C->>P: 带查询参数和 If-None-Match 的 GET
  alt 304 Not Modified
    P-->>C: 304
    C-->>NM: Ok(None)
  else 4xx 或 5xx
    P-->>C: 错误状态码
    C-->>NM: Err,已去除 URL
  else 2xx
    P-->>C: 200,带 ETag 和 body
    C->>E: set("node", etag)
    C->>C: 读取 body、解析、映射
    C-->>NM: Ok(Some(NodeInfo))
  end

修改这段代码时需要注意的细节:

  • 304 检查(resp.status().as_u16() == 304)在 error_for_status 之前执行,因此 304 永远不是错误。
  • set 忽略空值,不是有效可见 ASCII 的响应头(HeaderValue::to_str 失败)也会被跳过。这样的响应与完全没有 ETag 的响应一样,仍会被解析,但缓存保留该端点原有的内容:下一次请求携带之前的 ETag;如果之前没有,就不带 If-None-Match。
  • 缓存与客户端同生命周期,forget_etags(在任一客户端上即 EtagCache::clear)会清空它。NodeManager::try_bootstrap 在每次 bootstrap 尝试开始时调用 forget_etags:失败的尝试读到的内容都没有被应用,如果面板对重试回应 304,重试就没有任何可以起步的数据。为配置修改而重建的客户端和重新启动的节点同样从空缓存开始。
  • 状态码一被接受,ETag 就会存下,早于读取和解析 body。如果之后 body 解析失败或被拒绝(newV2board 的 server_port 为 0,SSPanel 的 ret 不为 1),下一次请求仍会携带这个 ETag。在轮询时,对于 ETag 是内容哈希的面板(Xboard 就是如此),在内容变化之前会一直返回 304,因此节点会保持之前的状态,直到面板被修正。bootstrap 尝试不受影响,因为它会先清空缓存。

下面是一个 newV2board 节点在 bootstrap 和第一次轮询时发出的请求,面板返回的 ETag 为 "n1" 和 "u1":

GET /api/v1/server/UniProxy/config If-None-Match: (none)
GET /api/v1/server/UniProxy/user If-None-Match: (none)
GET /api/v1/server/UniProxy/config If-None-Match: "n1"
GET /api/v1/server/UniProxy/user If-None-Match: "u1"
src/api/mod.rs
pub fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response>;

两种面板都在查询字符串中携带密钥进行认证:newV2board 用 token,SSPanel 用 key 和 muKey。reqwest 自带的 Response::error_for_status 构造的错误消息包含完整的请求 URL,其中也包括密钥。katana 的 error_for_status 对它做了包装,在转换为 anyhow::Error 之前调用 reqwest::Error::without_url,因此 4xx 或 5xx 错误只带状态码,绝不带 URL。两个客户端中的每一处状态检查都经过这个函数;没有任何地方直接调用 reqwest 的方法。

此外,每个可能失败的步骤都会添加一个 anyhow context,只写路径,绝不写 URL 或查询字符串:GET /api/v1/server/UniProxy/config、GET {path} body、POST UniProxy push、parse /mod_mu/users 等。节点管理器用 {} 记录错误,只打印最外层的消息:日志行写出路径,但不包含 HTTP 状态码或底层原因。一个被拒绝的 bootstrap 请求会记录为如下内容,并在其中写明的延迟之后重试:

ERROR katana::manager::node: node 1: node_info failed: GET /api/v1/server/UniProxy/config; retrying in 1s

新增请求或错误消息时,请遵守同样的规则:写路径,不写 URL,也绝不把 api.key 或用户的 UUID 格式化进消息。src/runtime.rs → display_id 对节点身份也遵循同样的规则:对 SSPanel 打印 panel_type@host#node_id,对 newV2board 打印 panel_type@host#node_id/node_type,例如 newv2board@https://panel.example.com#1/v2ray,不包含密钥。

src/api/mod.rs
pub fn read_local_rules(path: &str) -> Vec<DetectRule>;

api.rule_list_path 指向一个每行一条正则表达式的文本文件。read_local_rules 对每行去除首尾空白,跳过空行和以 # 开头的行,其余编译为 DetectRule { id: -1, .. }。它永不失败:

情况 结果
path 为空 没有规则,不记录日志。
文件无法读取 警告 cannot read rule_list_path {path}: {e},没有本地规则。
某一行不是有效的正则表达式 警告 invalid local rule "{line}": {e},跳过该行。

两个客户端都在 node_rule 中用同步的 std::fs::read_to_string 调用它。因此,每次通过 ETag 检查的规则刷新都会重新读取该文件:newV2board 每次轮询都会读,SSPanel 只在 detect_rules 返回 body 时才读。使用 SSPanel 时,对本地文件的修改会在以下时机生效:面板规则下次变化时;配置修改重建客户端时(新客户端的 ETag 缓存为空,下一次 detect_rules 请求会返回 body);或节点被重新启动时。

src/api/newv2board.rs
const CONFIG_PATH: &str = "/api/v1/server/UniProxy/config";
const USER_PATH: &str = "/api/v1/server/UniProxy/user";
const PUSH_PATH: &str = "/api/v1/server/UniProxy/push";
pub fn node_type_param(api: &ApiConfig) -> String;
pub struct Client {
http: reqwest::Client,
base_url: String,
node_id: u32,
node_type_param: String,
token: String,
node_type: NodeType,
enable_vless: bool,
speed_limit_mbps: f64,
rule_list_path: String,
etags: EtagCache,
routes: Mutex<Vec<RouteRule>>,
}
impl Client {
pub fn new(cfg: &NodeConfig) -> Result<Self>;
pub fn forget_etags(&self);
pub fn inherit_routes(&self, old: &Client);
fn query(&self) -> [(&'static str, String); 3];
async fn get(&self, path: &str, etag_key: &'static str) -> Result<Option<bytes::Bytes>>;
}

每个请求,无论 GET 还是 POST,都按以下顺序携带相同的三个查询参数:

参数 值
node_id api.node_id
node_type node_type_param(&cfg.api),在 Client::new 中计算一次:对于 api.enable_vless = true 的 V2ray 系节点(V2ray、Vmess 或 Vless)为 "vless";否则为小写后的 api.node_type,保持原写法("V2ray" 变为 v2ray,"hy2" 仍为 hy2)
token api.key

该 API 没有外层封装:响应 body 就是对象本身。由于面板同时按 node_id 和 node_type 查找节点,同一个 node_type_param 值也是节点身份的一部分(见构造)。

node_info 以 ETag 键 "node" 获取 CONFIG_PATH,并反序列化为私有的 ServerConfig。未知的 JSON 字段会被忽略(没有 deny_unknown_fields),每个声明的字段都有 serde 默认值。

flowchart TB
  A["GET config"] --> B{"状态码"}
  B -->|304| N["Ok(None)"]
  B -->|4xx 或 5xx| E["Err"]
  B -->|2xx| C["解析 ServerConfig"]
  C --> D{"server_port == 0"}
  D -->|是| E
  D -->|否| R["缓存 routes"]
  R --> T{"客户端的 node_type"}
  T --> V["parse_v2ray"]
  T --> TR["parse_trojan"]
  T --> SS["parse_ss"]
  T --> HY["parse_hysteria2"]

在按类型的解析器运行之前会发生两件事。server_port 为 0(或缺失)时,以 newV2board: server port must be > 0 拒绝。然后把 routes 数组复制到 self.routes 中供 node_rule 使用,即使之后按类型的解析器失败也是如此。按类型的解析器由客户端自身来自配置的 node_type 选择,而不是由响应中的任何内容决定。

src/api/newv2board.rs
fn parse_v2ray(&self, cfg: &ServerConfig) -> Result<NodeInfo>;
fn parse_trojan(&self, cfg: &ServerConfig) -> NodeInfo;
fn parse_ss(&self, cfg: &ServerConfig) -> Result<NodeInfo>;
fn parse_hysteria2(&self, cfg: &ServerConfig) -> NodeInfo;

下表把响应映射到 NodeInfo。“settings”指按下一节所述选出的 NetworkSettings 对象。server_port 声明为 i64,因此必须是 JSON 整数;字符串或 null 会使解析失败,报 parse UniProxy config response。它先检查是否为 0,再用 as u16 转换,因此超出端口范围的值会回绕。回绕成 0 的值会被节点管理器自己的端口检查拦下。

NodeInfo 字段 V2ray Trojan Shadowsocks Hysteria2
node_type V2ray Trojan Shadowsocks Hysteria2
port server_port server_port server_port server_port
speed_limit 0 0 0 0
transport Transport::parse(network) Tcp Tcp Tcp
host 取决于传输层,见下文 host 空 host
path settings path 空 空 空
service_name settings serviceName server_name 空 server_name
enable_tls tls 为 1 或 2 true false true
enable_reality tls 为 2 false false false
enable_vless api.enable_vless false false false
vless_flow flow 空 空 空
cypher_method 空 空 cipher 空
server_key 空 空 server_key 空
header settings header,仅 TCP None None None
obfs_type 空 空 空 obfs
obfs_password 空 空 空 obfs-password
authority、headers、accept_proxy_protocol 空、空、false 同左 同左 同左

newV2board 节点永远没有节点级限速:限速只按用户设置。

V2ray:读取哪个 settings 对象,以及 host 从哪里来

Section titled “V2ray:读取哪个 settings 对象,以及 host 从哪里来”

响应中可能带有两个 settings 对象。启用 api.enable_vless 时,parse_v2ray 读取 network_settings(蛇形命名),否则读取 networkSettings(驼峰命名),并忽略另一个。如果 body 写在客户端不读取的那个键下,就等于没有任何 settings:host、path 和 service_name 为空,也没有 header。正因如此,tests/support/mod.rs → v2ray_config_body 会根据其 vless 参数决定写入哪个键。当前的 Xboard 对所有节点类型都写 networkSettings,因此在 Xboard 上 VLESS 节点拿不到任何 settings;对运维的影响见 Xboard / V2board。

无论传输层是什么,path 和 service_name 都从选中的对象复制。host 和 header 则取决于传输层:

transport host header
Ws headers.Host(键名区分大小写匹配) None
Tcp 空 settings header
HttpUpgrade、SplitHttp settings host 非空时取它,否则取 headers.Host None
Grpc、Other(_) 空 None

tls 是整数:1 表示 TLS,2 表示 REALITY(同时也设置 enable_tls),0、字段缺失、null 或任何其他整数都表示明文。REALITY 节点能够解析,随后被监听器构建器拒绝。

parse_trojan 不读取 network、任一 settings 对象或 tls:newV2board 的 Trojan 节点始终是带 TLS 的 TCP。host 会被复制,server_name 成为 service_name,但只有 WebSocket 传输层读取 host,只有 gRPC 读取 service_name,因此在这个 TCP 节点上,它们除了经由 transport_eq 之外没有任何作用:任一项变化都会重建监听器。Hysteria 2 节点上的 host 和 service_name 也是如此,它的监听器两者都不读取。

parse_hysteria2 设置 transport = Tcp 和 enable_tls = true,这是对 QUIC 端点唯一成立的取值:它没有流式传输层,而它的 TLS 是 QUIC 握手的一部分。它从 obfs 读取混淆类型,从带连字符的 obfs-password 读取密钥,与参考节点 agent 的拼写一致。up_mbps、down_mbps 和 ignore_client_bandwidth 刻意没有声明,因此 serde 会忽略它们;内核没有实现 Brutal 拥塞控制,katana 按用户的令牌桶才是速率控制手段。面板无法表达的内容(凭据格式、UDP 中继、伪装)来自 [node.hysteria];[node.hysteria].port 非零的 Hysteria 2 节点根本不会调用客户端的 node_info。这个分支位于 NodeManager::node_info,见节点管理器。

parse_ss 接受空值、"plain" 或 "none" 的 obfs,其他值一律以 newV2board: shadowsocks obfs "http" is not supported 拒绝。这项检查只读取 obfs:Xboard 为 Shadowsocks 发送的 plugin 和 plugin_opts 没有声明,会被忽略。cipher 成为 cypher_method,server_key 作为 Shadowsocks 2022 的服务端 PSK 携带。该加密方式是否为内核所支持,稍后由 src/inbound.rs → build_shadowsocks 判断。

user_list 以 ETag 键 "users" 获取 USER_PATH:

{ "users": [ { "id": 1001, "uuid": "11111111-2222-3333-4444-555555555555", "speed_limit": 10 } ] }

id 和 uuid 是必需的:缺少任一项的用户会让整个响应失败,报 parse UniProxy user response。缺少 users 键视为空列表。

UserInfo 字段 值
uid id
email "{uuid}@v2board.user"
uuid uuid
passwd Shadowsocks 节点为 uuid,否则为空
method 空
speed_limit api.speed_limit 大于 0 时为 mbps_to_bps(api.speed_limit),否则为 mbps_to_bps(speed_limit)
port、alter_id 0

speed_limit 声明为带 serde 默认值的 i64,因此面板必须发送 JSON 整数(Mbps)或省略该字段。带小数点的数字(即使是 10.0)和 null 都会让整个响应以同样的错误失败:serde 默认值只覆盖字段缺失的情况。Xboard 对没有限速的用户发送 null,这就是 Xboard / V2board 要求运维显式填写 0 的原因。0 或负值表示不限速。

UUID 是所有协议的凭据:VMess 和 VLESS 直接使用它,Trojan 把它用作密码,Shadowsocks 2022 节点取 UUID 字符串的前 key_len 个字节作为用户 PSK,较早的 Shadowsocks 加密方式把它用作密码,Hysteria 把它用作认证字符串(在默认的 credential = "uuid" 下)。构建器读取的是 uuid;为 Shadowsocks 节点设置的 passwd 副本只参与用户集合比较。

report_user_traffic POST 一个以 uid 字符串为键、值为 [upload, download](单位为字节)的 JSON 对象:

{ "1001": [123, 456], "1002": [0, 789] }

body 的类型是 HashMap<String, [i64; 2]>,因此 uid 相同的两行会互相覆盖。调用方避免了这种情况:NodeManager::report_traffic 在调用此方法之前,会按 uid 合并实时计数器、残余量和正在排空的行。这里只检查状态码;响应 body 被忽略。

node_rule 不发送请求。它从 read_local_rules 开始,然后遍历上一次成功的 node_info 缓存的 routes。对每个 action 恰好为 "block" 的条目,它用 | 连接 match 中的字符串,把结果编译为一个正则表达式,并以该条目在数组中的下标作为 id 加入;不读取 route 自身的 id 字段。这些字符串是正则源码,而不是经过转义的字面量,domain: 这类 Xray 风格的前缀也不会被解释。match 为空或缺失的 block 条目会编译成空正则,匹配所有目标地址。无法编译的 route 会被跳过,并记录警告 invalid block rule [...]。结果总是 Ok(Some(_)),并且每次轮询都会重建。

由于 routes 来自缓存的 config 响应,它们只有在 node_info 返回新 body 时才会变化。为配置修改而重建的客户端还没有读过 config,因此 NodeManager::apply_static 会先对它调用 inherit_routes:复制旧客户端的 routes,block 规则一直有效,直到新客户端自己读到 config,即使那时面板不可达也是如此。本地描述的 Hysteria 节点从不获取 config,因此它的规则集只有本地文件。

report_illegal 不发请求,直接返回 Ok(()):UniProxy API 没有用于审计命中的端点。

src/api/sspanel.rs
pub struct Client {
http: reqwest::Client,
base_url: String,
node_id: u32,
key: String,
node_type: NodeType,
enable_vless: bool,
vless_flow: String,
speed_limit_mbps: f64,
disable_custom_config: bool,
rule_list_path: String,
etags: EtagCache,
}
impl Client {
pub fn new(cfg: &NodeConfig) -> Result<Self>;
pub fn forget_etags(&self);
fn base_query(&self, with_node_id: bool) -> Vec<(&'static str, String)>;
async fn get_data(
&self,
path: &str,
with_node_id: bool,
etag_key: &'static str,
) -> Result<Option<serde_json::Value>>;
async fn post_data<T: Serialize>(&self, path: &str, body: &T) -> Result<()>;
}

base_query 总是把密钥发送两次,分别作为 key 和 muKey,并在需要时加上 node_id:

调用 方法与路径 查询中的 node_id ETag 键
node_info GET /mod_mu/nodes/{node_id}/info 否,它在路径中 "node"
user_list GET /mod_mu/users 是 "users"
node_rule GET /mod_mu/func/detect_rules 否 "rules"
report_user_traffic POST /mod_mu/users/traffic 是 无
report_illegal POST /mod_mu/users/detectlog 是 无

每个响应都包在一层外层封装中:

{ "ret": 1, "data": ... }

get_data 解析外层封装(错误为 parse {path}),并要求 ret == 1;其他任何值都以 {path}: panel returned ret={ret} 失败,其中打印面板发送的值。两个字段都有 serde 默认值,因此没有 ret 的 JSON 对象算作 ret = 0,缺失的 data 为 null,随后被调用方的反序列化拒绝。最后它把 data 作为 serde_json::Value 返回,由调用方反序列化。

post_data 先检查状态码,然后尝试把 body 解析为外层封装:解析成功时 ret 必须为 1,因此回复 {} 会以 ret=0 失败;如果 body 无法解析为外层封装,例如为空或不是 JSON,这次 POST 算作成功。

节点信息:custom_config 或旧版字符串

Section titled “节点信息:custom_config 或旧版字符串”
src/api/sspanel.rs
fn node_info_from(&self, resp: &NodeInfoResponse) -> Result<NodeInfo>;
fn parse_custom_config(&self, resp: &NodeInfoResponse) -> Result<NodeInfo>;
fn parse_legacy(&self, resp: &NodeInfoResponse) -> Result<NodeInfo>;
fn compare_version(v1: &str, v2: &str) -> i32;

节点信息的 data 包含 node_speedlimit(Mbps,浮点数)、server(旧版字符串)、custom_config(JSON 对象)和 version。node_info_from 选择解析器:

flowchart TB
  A["node_info_from"] --> S{"node_type 为 Shadowsocks"}
  S -->|是| X1["Err:不支持"]
  S -->|否| G{"disable_custom_config,或版本低于 2021.11"}
  G -->|是| L{"node_type"}
  G -->|否| CC["parse_custom_config"]
  L -->|V2ray| LV["parse_legacy_v2ray"]
  L -->|Trojan| LT["parse_legacy_trojan"]
  L -->|Hysteria2| X2["Err:需要 custom_config"]

compare_version 把两个字符串按 . 拆分,每段跳过非数字字符后按十进制数读取,缺失的段视为 0,返回 1、-1 或 0。因此 2021.11.0 等于 2021.11,2021.11.5 更大,而空的或缺失的 version 低于 2021.11,会选择旧版解析器。

在 katana 中,SSPanel 不支持 Shadowsocks 节点:这项检查在 node_info_from 中最先执行,位于请求和外层封装之后、任一节点解析器之前,失败时报 sspanel: Shadowsocks node type is not supported。Hysteria 2 节点必须通过 custom_config 提供,因为旧版字符串无法容纳混淆设置。

custom_config 缺失或为 null 时报错(custom_config is empty, disable custom config)。否则它被反序列化为私有的 CustomConfig,其中除 header(任意 JSON)和 enable_reality(JSON 布尔值)外,每个字段都是默认值为 "" 的字符串。JSON 类型不对的字段会使解析失败,报 parse sspanel custom_config,因此 offset_port_node 和 enable_vless 必须是 "443"、"1" 这样的 JSON 字符串,而不是数字。

NodeInfo 字段 来源 说明
port offset_port_node 字符串,按 u16 解析。缺失或非数字时以 invalid offset_port_node "..." 失败。
speed_limit node_speedlimit(在 custom_config 之外) 经过覆盖规则和 mbps_to_bps。
transport network V2ray:Transport::parse。Trojan:为空时为 Tcp,否则为 Transport::parse。Hysteria2:始终为 Tcp。
enable_tls security V2ray:"tls" 或 "xtls"。Trojan 和 Hysteria2:始终为 true。
enable_vless enable_vless 仅 V2ray,字符串为 "1" 时为 true。
host host
path path
service_name servicename 全小写。
vless_flow flow
cypher_method method
server_key server_key
header header
enable_reality enable_reality 布尔值,不是字符串。
obfs_type obfs 与 UniProxy 面板的命名一致。
obfs_password obfs-password 带连字符。

除 transport、enable_tls 和 enable_vless 外,其他字段对所有节点类型都会复制,因此 SSPanel 的 Trojan 节点可以通过 WebSocket 或 gRPC 提供服务。

parse_legacy_v2ray 按 ; 拆分 server,至少需要六段:

address;port;alter_id;transport_or_tls;transport_or_tls;extras
example.com;443;0;ws;tls;path=/ws|host=proxy.example.com|servicename=svc
段 用作
0 address 忽略。
1 port port,按 u16 解析(错误为 invalid legacy port "...")。
2 alter_id 忽略。
3 和 4 任一段都可以是 tls,它会设置 enable_tls。其他非空值是传输层;如果两段都是,第 4 段优先。
5 extras 以 | 分隔的 key=value 项:path(取该项的剩余部分,因此路径中可以包含 =)、host、servicename,以及 headerType,后者会变成 header = {"type": "..."}。其他键被忽略。

enable_vless 和 vless_flow 来自本地配置(api.enable_vless、api.vless_flow),因为旧版字符串无法携带它们。server 为空时以 no server info in response 失败,少于六段时以 malformed legacy v2ray server string: "..." 失败。

parse_legacy_trojan 用三个在 LazyLock 静态变量中只编译一次的正则表达式读取端口和主机:

src/api/sspanel.rs
static FIRST_PORT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"port=(\d+)#?").unwrap());
static SECOND_PORT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"port=\d+#(\d+)").unwrap());
static HOST_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"host=([\w.]+)\|?").unwrap());
example.com;port=443#12345|host=proxy.example.com|grpc=1|servicename=gsvc

在 port=443#12345 中,443 是外部端口,12345 是内部端口。存在内部端口时以它为准,因为节点绑定的是它。host= 的值被捕获为一串单词字符和点号,因此遇到第一个其他字符就会停止:host=my-node.example.com 得到的是 my。这些正则在整个字符串中搜索,包括地址部分。然后,第一个与第二个 ; 之间的段按 | 拆分,每项再按 = 拆分:grpc 项无论取值为何(包括 grpc=0)都会把传输层切换为 Grpc,servicename 设置 service_name。enable_tls 始终为 true。端口缺失或超出 u16 范围时以 invalid legacy trojan port "..." 失败。

user_list 获取 /mod_mu/users,并把 data 反序列化为数组:

JSON 字段 类型 UserInfo 字段
id 整数,必需 uid
uuid 字符串,必需 uuid
passwd 字符串,默认 "" passwd
method 字符串,默认 "" method
port u32,默认 0 port,用 as u16 转换
node_speedlimit 数字(Mbps),默认 0 speed_limit,经过覆盖规则

email 保持为空,因此流量标签是 uid,alter_id 为 0。缺少 id 或 uuid 的用户会让整个列表失败,报 parse sspanel user list。

report_user_traffic 把每个用户一项包装在 data 中:

{ "data": [ { "user_id": 42, "u": 123, "d": 456 } ] }

node_rule 获取 /mod_mu/func/detect_rules,其 data 是 { "id": 3, "regex": "..." } 组成的数组。它从 read_local_rules 开始,再为每一项追加一条带面板 id 的 DetectRule。两个字段都有 serde 默认值:缺失的 id 为 0,缺失的 regex 为空模式,匹配所有目标地址。无法编译的正则会被跳过,并记录警告 invalid panel rule "...": {e}。304 返回 Ok(None),节点保留当前的规则集。

report_illegal 丢弃所有 rule_id 为负的命中,也就是去掉所有本地规则的命中(id 为 -1):面板中没有对应的行。如果什么都不剩,就不发送请求。否则把剩下的发出去,每个不同的 (uid, rule_id) 对一项;没有已认证用户的流上的命中携带 user_id -1:

{ "data": [ { "list_id": 3, "user_id": 42 } ] }
newV2board SSPanel
panel_type newv2board 或 v2board,不区分大小写 sspanel,不区分大小写
查询中的密钥 token key 和 muKey
外层封装 无 {ret, data},ret 必须为 1
节点类型 V2ray、Trojan、Shadowsocks、Hysteria2 V2ray、Trojan、Hysteria2
节点限速 无 node_speedlimit
用户限速 speed_limit node_speedlimit
流量标签 {uuid}@v2board.user uid
审计规则 action = "block" 的 routes 条目 detect_rules
审计上报 不发送 detectlog,仅面板规则
ETag 键 node、users node、users、rules

src/manager/node.rs 中的 NodeManager 是唯一的调用方。它的 bootstrap、轮询循环和静态更新运行在同一个任务中,因此客户端不会同时遇到自己的两次调用,每个方法都接受 &self。

一次 bootstrap 尝试(NodeManager::try_bootstrap)先调用 forget_etags,再获取节点和用户并启动监听器。任何一步失败都会让整次尝试失败,以 error 级别记录为 node 1: {reason}; retrying in {n}s 并重试。等待时间从 1 秒开始,每次失败后翻倍,最多 60 秒,但永远不超过轮询周期(update_periodic,至少 1 秒)。等待期间到达的 StaticUpdate::Config 会被应用,并立即开始下一次尝试;关闭会停止重试。

调用 Ok(Some(_)) Ok(None) Err(e)
node_info,bootstrap 启动节点;在 SSPanel 上,端口为 0 时本次尝试以 panel returned port 0 失败(newV2board 对此直接返回 Err) 本次尝试以 panel returned no node info 失败 本次尝试以 node_info failed: {e} 失败
node_info,轮询 执行 reconcile;在 SSPanel 上,端口为 0 时记录 refreshed port is 0, keeping the last one,保留上次应用的 NodeInfo,仍然 reconcile 用户 沿用上次应用的 NodeInfo 警告,沿用上次应用的 NodeInfo
user_list,bootstrap 使用 本次尝试以 panel returned no user list 失败 本次尝试以 user_list failed: {e} 失败
user_list,轮询 执行 reconcile 沿用上次应用的用户 警告,沿用上次应用的用户
node_rule RuleManager::update,id 和模式都未变时为空操作 无操作 警告
report_user_traffic 提交计数器 不会返回 恢复残余量留待下次轮询,警告
report_illegal 不会返回 不会返回 警告;命中已被取出,不会重新入队

node_rule 在 bootstrap 和每次轮询时运行,但仅当 controller.disable_get_rule 关闭时。report_user_traffic 仅在 controller.disable_upload_traffic 关闭且有用户存在待上报流量时运行,report_illegal 仅在记录到命中时运行。两个上报调用在节点关闭时还会再运行一次。监听器启动失败同样会让 bootstrap 尝试失败,报 initial start failed: {e}。bootstrap 循环以及 reconcile 如何分类,见节点管理器。计数器见流量计费。

不变量 机制 固定于
4xx 或 5xx 错误从不包含请求 URL,因此从不包含面板密钥。 api::error_for_status 调用 reqwest::Error::without_url;context 只写路径。 没有专门的测试。
记录节点身份时不包含面板密钥。 display_id 打印面板类型、主机、节点 id,newV2board 还打印节点类型。 tests/unit/runtime.rs 中的 a_node_is_logged_by_its_panel_node_not_its_key。
HTTP 304 表示“未变化”,绝不是错误或空列表。 在 get 和 get_data 中,状态检查在 error_for_status 之前执行。 没有专门的测试。tests/unit/e2e.rs 中的 a_node_whose_port_is_taken_comes_up_once_it_is_free 对接一个会对重复 ETag 回应 304 的模拟面板(Quirks { etags: true })。
ETag 按端点缓存,只来自被接受的非 304 响应,且从不缓存空字符串。 EtagCache::set,在 error_for_status 之后调用。 没有专门的测试。
每次 bootstrap 尝试都完整读取节点及其用户。 NodeManager::try_bootstrap 在第一个请求之前调用 forget_etags。 tests/unit/e2e.rs 中的 a_node_whose_port_is_taken_comes_up_once_it_is_free。
未知的 panel_type 或 node_type 在构造时失败,因此 --test 能发现它,包含它的重载也不会改变任何东西。 PanelClient::new 和两个 Client::new 中的 NodeType::parse;test_config 构建每个客户端;apply_reload 在触碰任何运行中的节点之前,先为每个新增或修改的节点构建客户端。 tests/unit/runtime.rs 中的 a_reload_with_a_node_that_does_not_build_changes_nothing;katana --test 没有单元测试。
对 panel_type 或任何 [node.api] 字段的修改都会到达面板客户端。 身份变化会重新启动节点;其他此类修改会让 NodeManager::apply_static 构建并换上新客户端。 tests/unit/runtime.rs 中的 an_sspanel_api_edit_takes_effect_in_place、a_client_edit_takes_effect_without_dropping_connections 和 a_newv2board_type_edit_respawns_the_node。
重建的 newV2board 客户端在读到 config 之前,沿用被替换客户端的 block 规则。 PanelClient::inherit_routes,由 apply_static 在替换之前调用。 tests/unit/api/newv2board.rs 中的 a_rebuilt_client_keeps_the_routes_it_has_not_read。
节点永远不会在端口 0 上监听。 newV2board 的 node_info 在 server_port == 0 时 bail。NodeManager 在端口为 0 时让 bootstrap 尝试失败并重试;轮询时则保留上次应用的 NodeInfo,仍然 reconcile 用户。 没有专门的测试。
V2ray 系节点启用 VLESS 时(以及 node_type = "vless" 时,无论是否启用),UniProxy 的 node_type 参数为 vless,节点身份也使用同一个值。 newv2board::node_type_param,由 newv2board::Client::new 调用,也由 api::panel_node_type 为身份调用。 tests/unit/api/newv2board.rs 中的 node_type_param_vless;tests/unit/runtime.rs 中的 a_newv2board_node_is_also_the_type_it_asks_for。
VLESS 节点读取 network_settings,VMess 节点读取 networkSettings。 parse_v2ray 根据 self.enable_vless 选择。 tests/integration/xray_interop.rs 中的 vless_ws_tls 和 vless_grpc_tls,经由 v2ray_config_body(无法构建 Xray 时跳过)。
tls = 2 表示 REALITY,而 REALITY 意味着 TLS。 parse_v2ray。 tests/unit/api/newv2board.rs 中的 parse_v2ray_reality_detected。
非 none 的 Shadowsocks 混淆会被拒绝,而不是被忽略。 parse_ss。 tests/unit/api/newv2board.rs 中的 parse_ss_obfs_rejected。
面板下发的混淆设置会到达 Hysteria 监听器。 parse_hysteria2 读取 obfs 和 obfs-password;两者都在 transport_eq 中。 tests/unit/e2e.rs 中的 a_panel_described_hysteria_node_serves_obfuscated_traffic。
SSPanel 从不提供 Shadowsocks 服务。 node_info_from 中的第一项检查。 tests/unit/api/sspanel.rs 中的 shadowsocks_rejected。
无论版本如何,disable_custom_config 都强制使用旧版解析器。 node_info_from。 tests/unit/api/sspanel.rs 中的 disable_custom_forces_legacy。
除 speed_limit 外,每个 NodeInfo 字段都恰好由 transport_eq 和 protocol_eq 之一比较。 手写方法;没有派生的 PartialEq。 没有测试;靠审阅维持。
同一节点类型的用户在所有地方以相同方式作为键。 唯一来源 NodeType::keys_by_email。 按用户计量流量的端到端测试,例如 tests/unit/e2e.rs 中的 vmess_traffic_is_metered_and_reported 和 a_hysteria_node_relays_and_meters。
配置了 api.speed_limit 时,它覆盖面板的所有限速。 newv2board::Client::user_list 和 sspanel::Client::speed_limit_bps。 仅覆盖换算:user_response_speed_limit_and_email、legacy_v2ray_ws_tls、custom_config_v2ray_vless。
本地规则的命中从不发送给 SSPanel。 report_illegal 过滤 rule_id >= 0。 没有专门的测试。
UniProxy 推送 body 以 uid 字符串为键,值为 [upload, download]。 report_user_traffic 中的 HashMap<String, [i64; 2]>。 tests/unit/api/newv2board.rs 中的 push_body_shape;vmess_traffic_is_metered_and_reported 读取真实的推送 body。

本模块中没有任何地方重试、以 error 级别记录日志,或在失败之间保留状态(ETag 缓存和 newV2board 的 routes 除外)。每个调用要么返回一个值,要么返回一个 anyhow::Error,由节点管理器决定保留什么。贡献者可能遇到的错误:

消息(最外层) 抛出位置 原因
unknown panel_type "..." PanelClient::new panel_type 不是 sspanel、newv2board 或 v2board。
unknown node_type "..." 两个 Client::new NodeType::parse 返回 None。
GET {path} / POST {path} / POST UniProxy push 请求辅助函数 连接失败、超时,或 4xx、5xx 状态码。
GET {path} body / POST {path} body 请求辅助函数(POST … body 仅出现在 SSPanel 的 post_data) body 无法完整读取,包括读取期间超时。
parse UniProxy config response / parse UniProxy user response newV2board body 不是预期的 JSON。
newV2board: server port must be > 0 newV2board node_info server_port 缺失或为 0。
newV2board: shadowsocks obfs "..." is not supported parse_ss obfs 不是空值、plain 或 none。
parse {path} SSPanel get_data body 不是外层封装。
{path}: panel returned ret=... SSPanel get_data、post_data ret 不为 1。
parse sspanel node info / parse sspanel user list / parse sspanel rules SSPanel data 结构不对。
sspanel: Shadowsocks node type is not supported node_info_from SSPanel 上的 Shadowsocks 节点。
custom_config is empty, disable custom config parse_custom_config 新版面板上 custom_config 缺失或为 null。
parse sspanel custom_config parse_custom_config 某个 custom_config 字段的 JSON 类型不对。
invalid offset_port_node "..." parse_custom_config 端口缺失或不是 u16。
sspanel: a hysteria2 node needs custom_config; the legacy server string cannot describe one parse_legacy 旧版面板上或启用 disable_custom_config 时的 Hysteria 2 节点。
no server info in response 旧版解析器 server 为空。
malformed legacy v2ray server string: "..." parse_legacy_v2ray 以 ; 分隔的段少于六段。
invalid legacy port "..." / invalid legacy trojan port "..." 旧版解析器 端口缺失或不是 u16。

规则编译问题是警告而不是错误:cannot read rule_list_path ...、invalid local rule ...、invalid panel rule ... 和 invalid block rule ... 都只丢弃出问题的规则,保留其余规则。

这里的取消不需要特殊处理。每个方法都是一次请求,没有后台任务。如果它的 future 在某个 .await 处被丢弃,ETag 缓存和 routes 要么未被触碰,要么已根据一个状态码已被接受的响应更新。

名称 值 位置
请求超时 api.timeout 秒;为 0 时为 5 ApiConfig::timeout_secs,由 build_http_client 应用
每次调用的重试 本模块中没有 节点管理器重试失败的 bootstrap 尝试;bootstrap 之后由下一次轮询重试
bootstrap 重试延迟 1 秒,每次失败后翻倍,最多 60 秒,且永远不超过轮询周期 src/manager/node.rs 中的 BOOTSTRAP_RETRY_MIN、BOOTSTRAP_RETRY_MAX 和 NodeManager::poll_period
轮询周期 update_periodic 秒,至少 1 秒 NodeManager::poll_period
MBPS_TO_BPS 1_000_000.0 / 8.0 = 125 000 src/api/mod.rs
custom config 版本阈值 "2021.11" sspanel::Client::node_info_from
旧版 V2ray 字符串 至少 6 段以 ; 分隔的部分 parse_legacy_v2ray
ETag 键 "node"、"users"、"rules" 每个客户端
本地规则 id -1 read_local_rules

单元测试位于 src/ 之外,通过 #[cfg(test)] #[path = "../../tests/unit/api/…"] mod tests; 编译进被测模块。作为子模块,它们可以调用私有解析器(parse_v2ray、parse_legacy_trojan、node_info_from),并直接构造私有响应结构体,因此不需要 HTTP 服务器。

tests/unit/api/newv2board.rs:

测试 固定的行为
node_type_param_vless 启用 VLESS 的 V2ray 节点为 vless;否则为小写后的类型(v2ray、trojan、shadowsocks)。
parse_v2ray_ws_tls ws 传输层,headers.Host 作为 host,path,tls = 1 为 TLS 且不是 REALITY。
parse_v2ray_reality_detected tls = 2 同时设置 enable_reality 和 enable_tls。
parse_trojan_fixed Trojan 为带 TLS 的 TCP;host,以及作为 service_name 的 server_name。
parse_ss_fields cipher、server_key、TCP、无 TLS。
parse_ss_obfs_rejected obfs = "http" 失败。
user_response_speed_limit_and_email mbps_to_bps(10.0) 为 1 250 000。
push_body_shape {"1001": [123, 456]}。
a_rebuilt_client_keeps_the_routes_it_has_not_read 调用 inherit_routes 之后,新客户端在获取 config 之前,其 node_rule 就会把旧客户端的 block route 作为规则返回。

没有测试检查错误脱敏。修改 error_for_status,或修改 get、get_data、post_data 中的错误 context 时,需要新增一个检查它的测试。