面板客户端
源码文件:19 个 · 核对版本 katana v3.0.1
katana/src/api/mod.rskatana/src/api/newv2board.rskatana/src/api/sspanel.rskatana/src/config.rskatana/src/runtime.rskatana/src/inbound.rskatana/src/main.rskatana/src/manager/mod.rskatana/src/manager/node.rskatana/src/manager/proxy.rskatana/src/manager/transport.rskatana/src/rule.rskatana/Cargo.tomlkatana/tests/unit/api/newv2board.rskatana/tests/unit/api/sspanel.rskatana/tests/unit/e2e.rskatana/tests/unit/runtime.rskatana/tests/support/mod.rskatana/tests/integration/xray_interop.rs
api 模块是 katana 与面板之间唯一的接触点。它把两套互不相关的 HTTP API,即 SSPanel 的 mod_mu 和 newV2board 的 UniProxy,统一成一套很小的词汇:一个描述监听器的 NodeInfo、一组 UserInfo、一组编译好的审计规则,以及反方向的两个上报调用。它之上的所有部分,包括节点管理器、监听器构建器和流量计费,都只看到这套词汇。
在新增面板字段、支持新的节点类型、修改响应的解析方式,或改动任何可能让面板密钥进入日志的代码之前,请先阅读本页。同一套行为在用户视角下的说明见 Xboard / V2board 和 SSPanel 两个指南页面。
该模块负责五件事:
- 获取并归一化节点描述(
node_info)和用户列表(user_list),转换为与面板无关的结构体。 - 节省带宽:每个端点各有一份 ETag 缓存,“未变化”以
Ok(None)返回,而不是重新解析一份相同的 body。 - 上报每个用户的流量(
report_user_traffic)和审计命中(report_illegal),采用各面板自己的 body 格式。 - 构建审计规则集(
node_rule),来源为面板加上可选的本地规则文件。 - 不让面板密钥出现在状态错误和日志中,因为两种面板都把密钥放在查询字符串里。
它有意不负责:
- 判断变化意味着什么。新的
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,并在配置修改涉及客户端构造所依据的字段时重建并替换客户端。 |
PanelClient
Section titled “PanelClient”两个客户端之间没有共享 trait。PanelClient 是一个普通枚举,每个方法都是一个两分支的 match,转发给具体的客户端:
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 负责选择变体:
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 中 |
因此,配置修改只能通过新的客户端到达面板。新客户端如何产生,取决于节点的身份:
type NodeId = (String, String, u32, String, String);// (panel_type lowercased, api.host, api.node_id, api.key, api::panel_node_type(cfg))pub fn panel_node_type(cfg: &NodeConfig) -> String;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。重载一侧的说明见运行时与重载,节点一侧见节点管理器。
NodeType
Section titled “NodeType”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)。
Transport
Section titled “Transport”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,随后它们也会被同一个构建器拒绝。
NodeInfo
Section titled “NodeInfo”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 对传输层差异和协议差异都执行同一次完整重建。这两个方法仍然标明了是哪一层发生了变化,添加字段时也应在这里扩展。
UserInfo、UserTraffic 与流量标签
Section titled “UserInfo、UserTraffic 与流量标签”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 |
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 无需发请求。
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。节点限速与用户限速如何合并为一个速率,见限速。
HTTP 层
Section titled “HTTP 层”客户端与超时
Section titled “客户端与超时”pub fn build_http_client(timeout_secs: u64) -> Result<reqwest::Client>;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,因此会复用连接池中的连接,直到某次配置修改把面板客户端连同其连接池一起替换掉。不同节点之间不共享客户端,即使两个节点指向同一个面板。
ETag 缓存与 HTTP 304
Section titled “ETag 缓存与 HTTP 304”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"不含 URL 的错误
Section titled “不含 URL 的错误”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,不包含密钥。
本地规则文件
Section titled “本地规则文件”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);或节点被重新启动时。
newV2board (UniProxy)
Section titled “newV2board (UniProxy)”端点与查询参数
Section titled “端点与查询参数”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 选择,而不是由响应中的任何内容决定。
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 节点能够解析,随后被监听器构建器拒绝。
Trojan 与 Hysteria 2
Section titled “Trojan 与 Hysteria 2”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,见节点管理器。
Shadowsocks
Section titled “Shadowsocks”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 被忽略。
规则与违规上报
Section titled “规则与违规上报”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 没有用于审计命中的端点。
SSPanel (mod_mu)
Section titled “SSPanel (mod_mu)”端点与外层封装
Section titled “端点与外层封装”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 或旧版字符串”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
Section titled “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 提供服务。
旧版 V2ray 字符串
Section titled “旧版 V2ray 字符串”parse_legacy_v2ray 按 ; 拆分 server,至少需要六段:
address;port;alter_id;transport_or_tls;transport_or_tls;extrasexample.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: "..." 失败。
旧版 Trojan 字符串
Section titled “旧版 Trojan 字符串”parse_legacy_trojan 用三个在 LazyLock 静态变量中只编译一次的正则表达式读取端口和主机:
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。
流量、检测规则与检测日志
Section titled “流量、检测规则与检测日志”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 } ] }两种面板对照
Section titled “两种面板对照”| 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 |
节点管理器如何使用结果
Section titled “节点管理器如何使用结果”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 作为规则返回。 |
tests/unit/api/sspanel.rs:
| 测试 | 固定的行为 |
|---|---|
compare_version_cases |
相等、更低、更高、空值,以及末段为非零或零的更长版本号。 |
legacy_v2ray_ws_tls |
端口、传输层和 TLS 来自第 1、3、4 段;path、host、servicename 来自 extras;100 Mbps 换算为 12 500 000 B/s。 |
legacy_trojan_grpc |
内部端口优先;grpc 和 servicename 项;TLS 开启。 |
custom_config_v2ray_vless |
版本 2021.11 走 custom_config 路径;offset_port_node、network、security、enable_vless = "1";50 Mbps 换算为 6 250 000 B/s。 |
disable_custom_forces_legacy |
禁用时,即使版本为 2022.1,旧版字符串也优先于 custom_config。 |
shadowsocks_rejected |
Shadowsocks 节点失败。 |
tests/unit/e2e.rs 从 src/main.rs 以 mod e2e 编译进二进制 crate。它运行一个真实的 NodeManager 和一个真实的 PanelClient,通过回环 HTTP 访问手写的模拟面板:
| 模拟面板 | 提供的内容 |
|---|---|
fake_panel、fake_panel_dynamic、fake_panel_with_config |
newV2board:/UniProxy/config 和 /UniProxy/user,并记录每个 /UniProxy/push body。 |
fake_panel_quirky |
同上,外加 Quirks:config_failures 让最先的若干次 config 请求返回 500;etags 给每个应答加上 ETag,并像 Xboard 那样对回传该 ETag 的请求返回 304。它还会统计 config 请求次数。 |
fake_sspanel |
SSPanel mod_mu:一个以旧版 server 字符串描述的 V2ray 节点、一个用户(uid 1001)、空的 detect_rules 列表,上报一律回应 {"ret":1}。 |
最直接覆盖本模块的测试:
| 测试 | 固定的行为 |
|---|---|
vmess_traffic_is_metered_and_reported |
config 和 user 响应被解析为一个可用的 VMess 节点,推送 body 把字节归属到 uid 1001。 |
unchanged_user_survives_user_refresh |
变化后的用户响应在下一次轮询时被采纳:新增第二个用户时,第一个用户的现有连接保持存活;随后移除第一个用户时,该连接断开;并且推送把两个阶段的字节都归属到 uid 1001。 |
a_panel_described_hysteria_node_serves_obfuscated_traffic |
面板下发的 obfs 和 obfs-password 到达监听器。 |
a_node_comes_up_once_the_panel_answers |
面板对前两次 config 请求返回 500,节点因此推迟启动;它在第一个真正的应答到来时启动并中继流量。 |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
绑定失败后对一个会对重复 ETag 回应 304 的面板重试;重试完整读取节点和用户,端口空出后节点启动。 |
a_node_that_never_bootstraps_still_stops |
对一个始终失败的面板反复重试 bootstrap 的节点,在关闭后 2 秒内停止。 |
tests/unit/runtime.rs 作为 tests 模块编译进 src/runtime.rs。它检查节点身份,并使用同样的模拟面板对运行中的节点执行重载:
| 测试 | 固定的行为 |
|---|---|
an_sspanel_node_is_its_panel_node_id |
在 SSPanel 上,只有 panel_type(不计大小写)、api.host、api.node_id 和 api.key 会改变身份。 |
a_newv2board_node_is_also_the_type_it_asks_for |
在 newV2board 上,node_type 参数也是身份的一部分;api.timeout、api.speed_limit 和 api.rule_list_path 不是。 |
a_node_is_logged_by_its_panel_node_not_its_key |
对 newV2board 的 V2ray 节点,display_id 追加 /v2ray;对 SSPanel 不打印节点类型;从不打印密钥。 |
a_newv2board_type_edit_respawns_the_node |
在 newV2board 上关闭 enable_vless 会重新启动节点,之后节点提供 VMess。 |
a_reload_with_a_node_that_does_not_build_changes_nothing |
node_type 拼写错误会让整次重载被拒绝:运行中的节点、其记录的配置和一条存活连接都不受影响。 |
an_sspanel_api_edit_takes_effect_in_place |
对接 fake_sspanel,关闭 enable_vless 会就地重新配置节点;重建的客户端把节点描述为 VMess,VLESS 连接停止。 |
a_client_edit_takes_effect_without_dropping_connections |
在 newV2board 上设置 rule_list_path 会重建客户端:发往列表中目标地址的新流被拒绝,而一条存活连接继续中继。 |
tests/integration/ 下的集成测试用真实的二进制对接 tests/support/mod.rs → FakePanel,body 来自 v2ray_config_body、trojan_config_body 和 hysteria_config_body。
没有测试检查错误脱敏。修改 error_for_status,或修改 get、get_data、post_data 中的错误 context 时,需要新增一个检查它的测试。