Panel clients
Source files: 19 · checked against 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
The api module is katana’s only contact with a panel. It turns two unrelated HTTP APIs, SSPanel’s mod_mu and newV2board’s UniProxy, into one small vocabulary: a NodeInfo that describes the listener, a list of UserInfo, a set of compiled audit rules, and two report calls going the other way. Everything above it, the node manager, the listener builders and traffic accounting, sees only that vocabulary.
Read this page before you add a panel field, support a new node type, change how a response is parsed, or touch anything that could put the panel key into a log line. The user-facing view of the same behaviour is on the Xboard / V2board and SSPanel guide pages.
Responsibilities
Section titled “Responsibilities”The module does five things:
- Fetch and normalize the node description (
node_info) and the user list (user_list) into panel-independent structs. - Save bandwidth with a per-endpoint ETag cache, and report “unchanged” as
Ok(None)instead of re-parsing an identical body. - Report per-user traffic (
report_user_traffic) and audit hits (report_illegal) in each panel’s body format. - Build the audit rule set (
node_rule) from the panel plus an optional local rule file. - Keep the panel key out of status errors and log lines, because both panels carry it in the query string.
It deliberately does not:
- decide what a change means. Whether a new
NodeInforebuilds the listener or only refreshes users is the node manager’s reconcile ladder, described on Node manager; - retry. A failed call returns an error. The node manager retries a failed bootstrap with a backoff from 1 s to 60 s, capped at the poll period, and after bootstrap the next poll is the retry;
- validate that the kernel can serve what the panel describes. REALITY, XTLS flow, unknown transports and unknown Shadowsocks ciphers parse fine here and are refused by the listener builders in
src/inbound.rs; - match destinations against rules.
src/rule.rs→RuleManagerdoes that with theDetectRulevalues this module produces.
| File | Contents |
|---|---|
src/api/mod.rs |
Shared models (NodeType, Transport, NodeInfo, UserInfo, UserTraffic, DetectRule, DetectResult, RouteRule), unit conversion, EtagCache, error_for_status, build_http_client, read_local_rules, panel_node_type and the PanelClient enum with its constructor PanelClient::new. |
src/api/newv2board.rs |
The UniProxy client, the free function node_type_param, and the private response structs (ServerConfig, NetworkSettings, Route, UserListResponse, UserResponse). |
src/api/sspanel.rs |
The mod_mu client, the custom_config and legacy server-string parsers, compare_version, and its private structs (Envelope, NodeInfoResponse, CustomConfig, UserResponse, PostData, TrafficItem, IllegalItem, RuleItem). |
src/runtime.rs |
The callers of PanelClient::new at startup (spawn_node), at reload (apply_reload) and in the dry run (test_config), and the node identity tuple (identity, display_id) that decides when a config edit respawns a node instead of reconfiguring it. |
src/manager/node.rs |
NodeManager, the only caller of the client’s methods. It retries bootstrap, and rebuilds and swaps the client when a config edit touches a field the client was built from. |
Key types
Section titled “Key types”PanelClient
Section titled “PanelClient”The two clients share no trait. PanelClient is a plain enum, and each method is a two-arm match that forwards to the concrete client:
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 is anyhow::Result. The enum keeps the calls statically dispatched and the futures concrete, so NodeManager can hold a PanelClient without boxing or an async trait. Adding a third panel means a new module, a new variant, an arm in new, and one new arm in each of the six forwarding methods (the five panel calls and forget_etags). inherit_routes and panel_node_type need an arm only if the new client caches state from responses, or if its panel finds a node by more than its id.
The Option in three of the return types has one meaning everywhere: Ok(None) is “not modified since last time” (HTTP 304), never “empty”. An empty user list is Ok(Some(vec![])).
| Method | 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 |
No request: built from the cached routes of the last config response, plus the local rule file. Always Ok(Some(_)). |
GET /mod_mu/func/detect_rules, plus the local rule file. |
report_illegal |
No-op, returns Ok(()). |
POST /mod_mu/users/detectlog |
forget_etags |
No request: EtagCache::clear. |
No request: EtagCache::clear. |
inherit_routes |
No request: copies the cached routes of old when both clients are newV2board. |
No-op: the rules are fetched on every refresh. |
Construction
Section titled “Construction”PanelClient::new chooses the variant:
impl PanelClient { pub fn new(cfg: &NodeConfig) -> Result<Self>;}panel_type is lowercased and matched against "sspanel", "newv2board" and its alias "v2board". Anything else fails with unknown panel_type "foo" (the lowercased value). Both Client::new functions then parse api.node_type with NodeType::parse and fail with unknown node_type "Vmess2" (the value as written). There are three callers in src/runtime.rs:
| Caller | When | On error |
|---|---|---|
test_config |
katana --test |
Reports the error without contacting the panel. |
spawn_node |
Startup | Logs node 1: unknown panel_type "foo" and does not start that node; the other nodes still start. |
apply_reload |
A config reload | Builds the client of every added or changed node before it touches a running one. If one does not build, it refuses the whole reload, for example reload: node sspanel@https://panel.example.com#1: unknown node_type "Vmess2"; keeping current config, and every node keeps running as it was. |
Each client copies what it needs from NodeConfig at construction and never reads the config again:
| Field copied | newV2board | SSPanel |
|---|---|---|
api.host, trailing / trimmed |
base_url |
base_url |
api.node_id |
node_id |
node_id |
api.key |
token |
key |
api.node_type |
node_type and node_type_param |
node_type |
api.enable_vless |
enable_vless and node_type_param |
enable_vless (legacy parse only) |
api.vless_flow |
not used | vless_flow (legacy parse only) |
api.speed_limit |
speed_limit_mbps |
speed_limit_mbps |
api.rule_list_path |
rule_list_path |
rule_list_path |
api.disable_custom_config |
not used | disable_custom_config |
api.timeout |
baked into http |
baked into http |
A config edit therefore reaches the panel only through a new client. How the new client is made depends on the node’s identity:
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;panel_node_type returns newv2board::node_type_param(&cfg.api) for a newV2board node and an empty string for SSPanel. UniProxy finds a node by its id and the node_type it is asked for, so one id asked as vless and as v2ray is two panel nodes, each with its own users and traffic. SSPanel’s mod_mu finds a node by its id alone. api.timeout is not part of the identity.
- An identity change removes the node and spawns a new one, with a fresh client, a fresh HTTP connection pool, an empty ETag cache and a fresh traffic registry, so one panel node’s counters are never billed to another. On newV2board this includes an edit to
api.node_typeorapi.enable_vlessthat changes thenode_typeparameter;"V2ray"to"v2ray"does not, and neither doesenable_vlesson a Trojan node. - Any other edit to
panel_typeor[node.api]reaches the running node asStaticUpdate::Config.NodeManager::apply_staticbuilds a new client withPanelClient::new, callsinherit_routeson it with the running client so newV2board’s cached block routes carry over, and swaps it in. It then polls the panel at once. The new client has its own HTTP connection pool and an empty ETag cache, so that poll reads the node and its users in full, and the reconcile ladder applies the narrowest change the answer needs, or a full rebuild when the edit also changes a setting the listener is built from, such asapi.enable_vless. A node that is still bootstrapping stores the new client and starts its next attempt at once. If the new client does not build, the node logsnode 1: config edit refused, keeping the running one: {e}and keeps its current client and config; the reload’s own check normally refuses such an edit first.
NodeManager holds the client as Mutex<Arc<PanelClient>> so the swap needs no &mut self; each call clones the Arc first. The reload side of this is on Runtime and reload, and the node side on Node manager.
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 lowercases its input first:
| Accepted (any case) | Variant | keys_by_email() |
|---|---|---|
v2ray, vmess, vless |
V2ray |
false: users are keyed by their parsed UUID |
trojan |
Trojan |
true |
shadowsocks |
Shadowsocks |
true |
hysteria2, hysteria, hy2 |
Hysteria2 |
true |
keys_by_email answers one question: is a user of this node identified by the UUID itself, or by its traffic label? VMess and VLESS authenticate with the UUID, so it is the natural key. Trojan, Shadowsocks and Hysteria 2 authenticate with a secret derived from the UUID, so the label (see traffic_email below) identifies the user. The answer is single-sourced on purpose: src/manager/mod.rs → build_user_entries, src/manager/transport.rs → TransportManager::start and src/manager/proxy.rs → refresh all call it (the last two pass the answer to user_tag), and if the staged traffic counters and the protocol’s user table disagreed on the key, no user would have a counter and the node would not start.
Note that vless parses to V2ray. Whether the node serves VLESS or VMess is decided in src/inbound.rs → build_protocol by api.enable_vless OR-ed with NodeInfo.enable_vless, not by the node type. newV2board and the legacy SSPanel parser copy NodeInfo.enable_vless from api.enable_vless; only SSPanel’s custom_config sets it from the panel. So node_type = "vless" without enable_vless = true serves VMess, and on newV2board it also sends node_type=vless to the panel while reading the VMess settings key (see newV2board).
Transport
Section titled “Transport”pub enum Transport { Tcp, Ws, Grpc, HttpUpgrade, SplitHttp, Other(String),}
impl Transport { pub fn parse(s: &str) -> Self;}parse never fails. It lowercases, maps "", tcp and raw to Tcp, ws and websocket to Ws, grpc and gun to Grpc, httpupgrade to HttpUpgrade, splithttp and xhttp to SplitHttp, and keeps anything else as Other(lowercased). Keeping the unknown value, instead of falling back to TCP, is what lets src/inbound.rs → build_transport refuse the node with a message that names the transport. HttpUpgrade and SplitHttp are parsed so their host can be read, and are then refused by the same builder.
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 is already in bytes per second. authority, headers and accept_proxy_protocol are never set by either panel parser: they are always empty or false. No listener builder reads header, authority or headers at this revision; they take part only in the comparison below.
NodeInfo derives Debug and Clone but not PartialEq. Equality is split into two hand-written methods, one per layer of the listener:
| Layer | Method | Fields compared |
|---|---|---|
| Transport and socket | transport_eq |
port, transport, host, path, service_name, authority, enable_tls, header, headers, enable_reality, accept_proxy_protocol, obfs_type, obfs_password |
| Proxy protocol | protocol_eq |
node_type, enable_vless, vless_flow, cypher_method, server_key |
| Neither | none | speed_limit |
Every field except speed_limit is in exactly one of the two methods. The split exists because the fields belong to different objects in the listener tree: the transport fields configure the TransportManager (the bound socket, TLS, WebSocket or gRPC framing, Hysteria obfuscation), and the protocol fields configure the proxy server inside it. port sits with the transport because a new port needs a new socket. obfs_type and obfs_password sit there too because Salamander scrambles every packet: a node that kept the old key would lock out every client that picked up the new one. speed_limit is in neither because a node-wide rate change only re-rates users; the reconcile ladder routes it to the user refresh, which keeps connections.
At this revision NodeManager::reconcile sends both a transport difference and a protocol difference to the same full rebuild. The two methods still name which layer changed, and they are the place to extend when you add a field.
UserInfo, UserTraffic and the traffic label
Section titled “UserInfo, UserTraffic and the traffic label”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 derives PartialEq, Eq and Hash, which src/manager/mod.rs → user_set_differs uses for an order-independent set comparison. Every field takes part, so a change to any of them, even to passwd, method or port that no builder reads, counts as a changed user set and triggers a user refresh.
traffic_email returns email when it is non-empty and the decimal uid otherwise. It is the label a user carries through the kernel: the Trojan authorization.username, the Shadowsocks user’s email, the Hysteria user name. It must be stable across polls and must map back to one uid.
| Panel | email in UserInfo |
Resulting label |
|---|---|---|
| newV2board | "{uuid}@v2board.user" |
11111111-2222-3333-4444-555555555555@v2board.user |
| SSPanel | empty | the uid, for example 42 |
Audit rules
Section titled “Audit rules”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,}A DetectRule is compiled once, when the rule set is fetched. Its id tells where it came from:
| Source | id |
|---|---|
| Local rule file | -1 |
SSPanel detect_rules |
the panel’s rule id |
newV2board routes entry with action = "block" |
the entry’s index in the whole routes array |
DetectResult is one recorded hit. RuleManager::detect records uid = -1 when the flow has no authenticated user. RouteRule is a newV2board-only copy of a routes entry, cached so node_rule needs no request.
Unit conversion
Section titled “Unit conversion”pub const MBPS_TO_BPS: f64 = 1_000_000.0 / 8.0;
pub fn mbps_to_bps(mbps: f64) -> u64;Panels speak decimal megabits per second; katana’s token buckets count bytes per second. mbps_to_bps multiplies by MBPS_TO_BPS (125 000) and truncates, and returns 0 (“no limit”) for zero or negative input. 10 Mbps is 1 250 000 B/s.
Both clients apply the same override rule: when api.speed_limit is greater than zero it replaces every panel-supplied limit. SSPanel applies it to the node limit and to each user’s limit (sspanel::Client::speed_limit_bps). newV2board applies it to each user’s limit only; its node limit is always 0. How the node limit and the user limit combine into one rate is on Speed limits.
The HTTP layer
Section titled “The HTTP layer”Client and timeout
Section titled “Client and timeout”pub fn build_http_client(timeout_secs: u64) -> Result<reqwest::Client>;impl ApiConfig { pub fn timeout_secs(&self) -> u64;}Each panel client owns one reqwest::Client, built with timeout(Duration::from_secs(timeout_secs)). timeout_secs returns api.timeout, or 5 when it is 0 (the serde default). reqwest applies this timeout to the whole request, from connecting until the body has been read, and katana adds no timeout of its own. The crate is built with default-features = false and the json, query and native-tls-vendored features, so on Linux HTTPS panels go through a vendored, statically built OpenSSL.
The reqwest::Client is shared by the calls of one node, so they reuse pooled connections, until a config edit replaces the panel client and its pool with it. Clients are not shared between nodes, even when two nodes point at the same panel.
ETag cache and HTTP 304
Section titled “ETag cache and 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);}Each client owns one EtagCache, keyed by endpoint name: "node", "users" and, for SSPanel, "rules". The lock is a parking_lot::Mutex, held only for the get, insert or clear and never across an .await. Both clients’ GET helpers (newv2board::Client::get and sspanel::Client::get_data) follow the same steps:
sequenceDiagram
participant NM as NodeManager
participant C as Panel client
participant E as EtagCache
participant P as Panel
NM->>C: node_info()
C->>E: get("node")
E-->>C: previous ETag or none
C->>P: GET with query and If-None-Match
alt 304 Not Modified
P-->>C: 304
C-->>NM: Ok(None)
else 4xx or 5xx
P-->>C: error status
C-->>NM: Err, URL stripped
else 2xx
P-->>C: 200 with ETag and body
C->>E: set("node", etag)
C->>C: read body, parse, map
C-->>NM: Ok(Some(NodeInfo))
end
The details that matter when you change this code:
- The 304 check (
resp.status().as_u16() == 304) runs beforeerror_for_status, so 304 is never an error. setignores an empty value, and a header that is not valid visible ASCII (HeaderValue::to_strfails) is skipped. Such a response, like one with noETagat all, is still parsed, but the cache keeps whatever it held for that endpoint: the next request carries the previous ETag, or noIf-None-Matchif there was none.- The cache lives as long as the client, and
forget_etags(EtagCache::clearon either client) empties it.NodeManager::try_bootstrapcallsforget_etagsat the start of every bootstrap attempt: nothing a failed attempt read was applied, and a panel that answered the retry with 304 would leave it nothing to start from. A client rebuilt for a config edit and a respawned node also start with an empty cache. - The ETag is stored as soon as the status is accepted, before the body is read and parsed. If that body then fails to parse, or is rejected (a newV2board
server_portof 0, an SSPanelretother than 1), the next request still carries its ETag. On a poll, a panel whose ETag is a hash of the content, as Xboard’s is, then answers 304 until the content changes, so the node keeps its previous state until the panel is corrected. A bootstrap attempt is not affected, because it clears the cache first.
This is what the requests look like for a newV2board node at bootstrap and on its first poll, against a panel that answered with the ETags "n1" and "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"Errors without URLs
Section titled “Errors without URLs”pub fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response>;Both panels authenticate with the key in the query string: newV2board as token, SSPanel as key and muKey. reqwest’s own Response::error_for_status builds an error whose message contains the full request URL, key included. katana’s error_for_status wraps it and calls reqwest::Error::without_url before converting to anyhow::Error, so a 4xx or 5xx error carries the status and never the URL. Every status check in both clients goes through this function; none calls reqwest’s method directly.
On top of that, every fallible step adds an anyhow context that names the path only, never the URL or the query: GET /api/v1/server/UniProxy/config, GET {path} body, POST UniProxy push, parse /mod_mu/users and so on. The node manager logs errors with {}, which prints only the outermost message: the log line names the path but not the HTTP status or the underlying cause. A rejected bootstrap request logs as follows, and the attempt is retried after the delay it names:
ERROR katana::manager::node: node 1: node_info failed: GET /api/v1/server/UniProxy/config; retrying in 1sWhen you add a request or an error message, keep to the same rule: name the path, never the URL, and never format api.key or a user’s UUID into a message. src/runtime.rs → display_id follows the same rule for node identities. It prints panel_type@host#node_id for SSPanel and panel_type@host#node_id/node_type for newV2board, for example newv2board@https://panel.example.com#1/v2ray, and leaves out the key.
The local rule file
Section titled “The local rule file”pub fn read_local_rules(path: &str) -> Vec<DetectRule>;api.rule_list_path names a text file with one regular expression per line. read_local_rules trims each line, skips blank lines and lines starting with #, and compiles the rest into DetectRule { id: -1, .. }. It never fails:
| Situation | Result |
|---|---|
path is empty |
No rules, no log line. |
| The file cannot be read | Warning cannot read rule_list_path {path}: {e}, no local rules. |
| A line is not a valid regex | Warning invalid local rule "{line}": {e}, that line is skipped. |
Both clients call it from node_rule, with a synchronous std::fs::read_to_string. The file is therefore re-read on every rule refresh that gets past the ETag check: on every poll for newV2board, and only when detect_rules returns a body for SSPanel. With SSPanel an edit to the local file is picked up the next time the panel’s rules change, whenever a config edit rebuilds the client (its empty ETag cache makes the next detect_rules request return a body), or when the node is respawned.
newV2board (UniProxy)
Section titled “newV2board (UniProxy)”Endpoints and query
Section titled “Endpoints and query”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>>;}Every request, GET and POST alike, carries the same three query parameters, in this order:
| Parameter | Value |
|---|---|
node_id |
api.node_id |
node_type |
node_type_param(&cfg.api), computed once in Client::new: "vless" for a V2ray-family node (V2ray, Vmess or Vless) with api.enable_vless = true; otherwise api.node_type lowercased, as written ("V2ray" becomes v2ray, "hy2" stays hy2) |
token |
api.key |
The API has no envelope: the response body is the object itself. Because the panel finds the node by node_id and node_type together, the same node_type_param value is also part of the node identity (see Construction).
Node configuration
Section titled “Node configuration”node_info fetches CONFIG_PATH with ETag key "node" and deserializes it into the private ServerConfig. Unknown JSON fields are ignored (no deny_unknown_fields), and every declared field has a serde default.
flowchart TB
A["GET config"] --> B{"status"}
B -->|304| N["Ok(None)"]
B -->|4xx or 5xx| E["Err"]
B -->|2xx| C["parse ServerConfig"]
C --> D{"server_port == 0"}
D -->|yes| E
D -->|no| R["cache routes"]
R --> T{"client node_type"}
T --> V["parse_v2ray"]
T --> TR["parse_trojan"]
T --> SS["parse_ss"]
T --> HY["parse_hysteria2"]
Two things happen before the per-type parser runs. A server_port of 0 (or a missing one) is refused with newV2board: server port must be > 0. Then the routes array is copied into self.routes for node_rule, even if the per-type parser fails afterwards. The per-type parser is chosen by the client’s own node_type, from the config, not by anything in the response.
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;This table maps the response to NodeInfo. “settings” is the NetworkSettings object chosen as described in the next section. server_port is declared as an i64, so it must be a JSON integer; a string or null fails the parse with parse UniProxy config response. It is checked for 0 and then converted with as u16, so a value outside the port range wraps. A value that wraps to 0 is caught by the node manager’s own port check.
NodeInfo field |
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 |
depends on transport, below | host |
empty | host |
path |
settings path |
empty | empty | empty |
service_name |
settings serviceName |
server_name |
empty | server_name |
enable_tls |
tls is 1 or 2 |
true |
false |
true |
enable_reality |
tls is 2 |
false |
false |
false |
enable_vless |
api.enable_vless |
false |
false |
false |
vless_flow |
flow |
empty | empty | empty |
cypher_method |
empty | empty | cipher |
empty |
server_key |
empty | empty | server_key |
empty |
header |
settings header, TCP only |
None |
None |
None |
obfs_type |
empty | empty | empty | obfs |
obfs_password |
empty | empty | empty | obfs-password |
authority, headers, accept_proxy_protocol |
empty, empty, false |
same | same | same |
A newV2board node never has a node-level speed limit: limits come only per user.
V2ray: which settings object, and where host comes from
Section titled “V2ray: which settings object, and where host comes from”The response can carry two settings objects. parse_v2ray reads network_settings (snake case) when api.enable_vless is on and networkSettings (camel case) otherwise, and ignores the other one. A body written under the key the client does not read yields no settings at all: an empty host, path and service_name, and no header. tests/support/mod.rs → v2ray_config_body writes the key according to its vless argument for this reason. Current Xboard writes networkSettings for every node type, so with Xboard a VLESS node gets no settings; the operator-side consequences are on Xboard / V2board.
path and service_name are copied from the chosen object whatever the transport. host and header depend on it:
transport |
host |
header |
|---|---|---|
Ws |
headers.Host (the key is matched case-sensitively) |
None |
Tcp |
empty | settings header |
HttpUpgrade, SplitHttp |
settings host if non-empty, else headers.Host |
None |
Grpc, Other(_) |
empty | None |
tls is an integer: 1 means TLS, 2 means REALITY (which also sets enable_tls), and 0, a missing field, null or any other integer means plain. The REALITY node parses and is then refused by the listener builder.
Trojan and Hysteria 2
Section titled “Trojan and Hysteria 2”parse_trojan does not read network, either settings object or tls: a newV2board Trojan node is always TCP with TLS. host is copied and server_name becomes service_name, but only the WebSocket transport reads host and only gRPC reads service_name, so on this TCP node they have no effect except through transport_eq: a change to either rebuilds the listener. The same holds for host and service_name on a Hysteria 2 node, whose listener reads neither.
parse_hysteria2 sets transport = Tcp and enable_tls = true, the only values that are true of a QUIC endpoint: it has no stream transport, and its TLS is part of the QUIC handshake. It reads the obfuscation type from obfs and the key from obfs-password, hyphenated, as the reference node agent spells it. up_mbps, down_mbps and ignore_client_bandwidth are deliberately not declared, so serde ignores them; the kernel does not implement Brutal congestion control, and katana’s per-user token bucket is the rate control. Everything a panel cannot express (credential format, UDP relay, masquerade) comes from [node.hysteria], and a Hysteria 2 node with a non-zero [node.hysteria].port never calls the client’s node_info at all. That branch is in NodeManager::node_info, described on Node manager.
Shadowsocks
Section titled “Shadowsocks”parse_ss accepts an obfs of empty, "plain" or "none", and refuses anything else with newV2board: shadowsocks obfs "http" is not supported. The check reads only obfs: plugin and plugin_opts, which Xboard sends for Shadowsocks, are not declared and are ignored. cipher becomes cypher_method and server_key is carried as the Shadowsocks 2022 server PSK. Whether the cipher is one the kernel supports is decided later, by src/inbound.rs → build_shadowsocks.
user_list fetches USER_PATH with ETag key "users":
{ "users": [ { "id": 1001, "uuid": "11111111-2222-3333-4444-555555555555", "speed_limit": 10 } ] }id and uuid are required: a user without either fails the whole response with parse UniProxy user response. A missing users key is an empty list.
UserInfo field |
Value |
|---|---|
uid |
id |
email |
"{uuid}@v2board.user" |
uuid |
uuid |
passwd |
uuid for a Shadowsocks node, empty otherwise |
method |
empty |
speed_limit |
mbps_to_bps(api.speed_limit) if it is greater than 0, else mbps_to_bps(speed_limit) |
port, alter_id |
0 |
speed_limit is declared as an i64 with a serde default, so the panel must send a JSON integer (Mbps) or leave the field out. A number written with a decimal point, even 10.0, and a null both fail the whole response with the same error: serde’s default covers only a missing field. Xboard sends null for a user without a speed limit, which is why Xboard / V2board asks operators for an explicit 0. 0 or a negative value means no limit.
The UUID is the credential for every protocol: VMess and VLESS use it directly, Trojan uses it as the password, a Shadowsocks 2022 node takes the first key_len bytes of the UUID string as the user PSK, an older Shadowsocks cipher uses it as the password, and Hysteria uses it as the auth string (with the default credential = "uuid"). The builders read uuid; the passwd copy set for Shadowsocks nodes only takes part in the user-set comparison.
Traffic push
Section titled “Traffic push”report_user_traffic posts a JSON object keyed by the uid as a string, with [upload, download] in bytes:
{ "1001": [123, 456], "1002": [0, 789] }The body is a HashMap<String, [i64; 2]>, so two rows with the same uid would overwrite each other. The caller prevents that: NodeManager::report_traffic merges live counters, residuals and draining rows by uid before it calls this method. Only the status is checked; the response body is ignored.
Rules and illegal reports
Section titled “Rules and illegal reports”node_rule sends no request. It starts from read_local_rules, then walks the routes cached by the last successful node_info. For each entry whose action is exactly "block", it joins the match strings with |, compiles the result as one regex, and adds it with the entry’s index in the array as id; the route’s own id field is not read. The strings are regex source, not escaped literals, and Xray-style prefixes such as domain: are not interpreted. A block entry with an empty or missing match compiles to the empty regex, which matches every destination. A route that does not compile is skipped with the warning invalid block rule [...]. The result is always Ok(Some(_)), and it is rebuilt on every poll.
Because the routes come from the cached config response, they change only when node_info returns a new body. A client rebuilt for a config edit has not read the config yet, so NodeManager::apply_static calls inherit_routes on it first: it copies the old client’s routes, and the block rules hold until the new client reads the config itself, even if the panel is unreachable at that moment. A Hysteria node described locally never fetches the config, so its rule set is the local file alone.
report_illegal returns Ok(()) without a request: the UniProxy API has no endpoint for audit hits.
SSPanel (mod_mu)
Section titled “SSPanel (mod_mu)”Endpoints and envelope
Section titled “Endpoints and envelope”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 always sends the key twice, as key and as muKey, and adds node_id when asked to:
| Call | Method and path | node_id in query |
ETag key |
|---|---|---|---|
node_info |
GET /mod_mu/nodes/{node_id}/info |
no, it is in the path | "node" |
user_list |
GET /mod_mu/users |
yes | "users" |
node_rule |
GET /mod_mu/func/detect_rules |
no | "rules" |
report_user_traffic |
POST /mod_mu/users/traffic |
yes | none |
report_illegal |
POST /mod_mu/users/detectlog |
yes | none |
Every response is wrapped in an envelope:
{ "ret": 1, "data": ... }get_data parses the envelope (error parse {path}) and requires ret == 1; any other value fails with {path}: panel returned ret={ret}, which prints the value the panel sent. Both fields have serde defaults, so a JSON object without ret counts as ret = 0, and a missing data is null, which the caller’s deserialization then rejects. It then returns data as a serde_json::Value for the caller to deserialize.
post_data checks the status first. It then tries to parse the body as an envelope: if that succeeds, ret must be 1, so a reply of {} fails with ret=0; if the body does not parse as an envelope, for example because it is empty or not JSON, the POST counts as successful.
Node info: custom_config or the legacy string
Section titled “Node info: custom_config or the legacy string”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;The node info data holds node_speedlimit (Mbps, a float), server (the legacy string), custom_config (a JSON object) and version. node_info_from picks the parser:
flowchart TB
A["node_info_from"] --> S{"node_type is Shadowsocks"}
S -->|yes| X1["Err: not supported"]
S -->|no| G{"disable_custom_config, or version below 2021.11"}
G -->|yes| L{"node_type"}
G -->|no| CC["parse_custom_config"]
L -->|V2ray| LV["parse_legacy_v2ray"]
L -->|Trojan| LT["parse_legacy_trojan"]
L -->|Hysteria2| X2["Err: needs custom_config"]
compare_version splits both strings on ., reads each segment as a decimal number while skipping any non-digit characters, treats a missing segment as 0, and returns 1, -1 or 0. So 2021.11.0 equals 2021.11, 2021.11.5 is greater, and an empty or missing version is below 2021.11 and selects the legacy parser.
SSPanel has no Shadowsocks node support in katana: the check runs first in node_info_from, after the request and the envelope but before either node parser, and fails with sspanel: Shadowsocks node type is not supported. A Hysteria 2 node must come through custom_config, because the legacy string has no room for obfuscation.
custom_config
Section titled “custom_config”custom_config missing or null is an error (custom_config is empty, disable custom config). Otherwise it is deserialized into the private CustomConfig, where every field is a string with a default of "" except header (any JSON) and enable_reality (a JSON boolean). A field of the wrong JSON type fails the parse with parse sspanel custom_config, so offset_port_node and enable_vless must be JSON strings such as "443" and "1", not numbers.
NodeInfo field |
Source | Notes |
|---|---|---|
port |
offset_port_node |
A string parsed as u16. Missing or non-numeric fails with invalid offset_port_node "...". |
speed_limit |
node_speedlimit (outside custom_config) |
Through the override rule and mbps_to_bps. |
transport |
network |
V2ray: Transport::parse. Trojan: Tcp when empty, else Transport::parse. Hysteria2: always Tcp. |
enable_tls |
security |
V2ray: "tls" or "xtls". Trojan and Hysteria2: always true. |
enable_vless |
enable_vless |
V2ray only, true when the string is "1". |
host |
host |
|
path |
path |
|
service_name |
servicename |
All lowercase. |
vless_flow |
flow |
|
cypher_method |
method |
|
server_key |
server_key |
|
header |
header |
|
enable_reality |
enable_reality |
A boolean, not a string. |
obfs_type |
obfs |
Named as the UniProxy panels name it. |
obfs_password |
obfs-password |
Hyphenated. |
Apart from transport, enable_tls and enable_vless, the fields are copied for every node type, so an SSPanel Trojan node can be served over WebSocket or gRPC.
Legacy V2ray string
Section titled “Legacy V2ray string”parse_legacy_v2ray splits server on ; and needs at least six parts:
address;port;alter_id;transport_or_tls;transport_or_tls;extrasexample.com;443;0;ws;tls;path=/ws|host=proxy.example.com|servicename=svc| Part | Used as |
|---|---|
0 address |
Ignored. |
1 port |
port, parsed as u16 (error invalid legacy port "..."). |
2 alter_id |
Ignored. |
| 3 and 4 | Either may be tls, which sets enable_tls. Any other non-empty value is the transport; if both are, part 4 wins. |
5 extras |
|-separated key=value items: path (the rest of the item, so a path may contain =), host, servicename, and headerType, which becomes header = {"type": "..."}. Other keys are ignored. |
enable_vless and vless_flow come from the local config (api.enable_vless, api.vless_flow) because the legacy string cannot carry them. An empty server fails with no server info in response, and fewer than six parts with malformed legacy v2ray server string: "...".
Legacy Trojan string
Section titled “Legacy Trojan string”parse_legacy_trojan reads the port and host with three regexes compiled once in LazyLock statics:
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=gsvcIn port=443#12345, 443 is the outside port and 12345 the inside port. The inside port wins when present, because it is the one the node binds. The host= value is captured as a run of word characters and dots, so it stops at the first other character: host=my-node.example.com yields my. The regexes search the whole string, the address included. The segment between the first and the second ; is then split on |, and each item is split on =: a grpc item switches the transport to Grpc whatever its value (grpc=0 included), and servicename sets service_name. enable_tls is always true. A port that is missing or does not fit a u16 fails with invalid legacy trojan port "...".
user_list fetches /mod_mu/users and deserializes data as an array:
| JSON field | Type | UserInfo field |
|---|---|---|
id |
integer, required | uid |
uuid |
string, required | uuid |
passwd |
string, default "" |
passwd |
method |
string, default "" |
method |
port |
u32, default 0 |
port, converted with as u16 |
node_speedlimit |
number (Mbps), default 0 |
speed_limit, through the override rule |
email stays empty, so the traffic label is the uid, and alter_id is 0. A user without id or uuid fails the whole list with parse sspanel user list.
Traffic, detect rules and detect log
Section titled “Traffic, detect rules and detect log”report_user_traffic wraps one item per user in data:
{ "data": [ { "user_id": 42, "u": 123, "d": 456 } ] }node_rule fetches /mod_mu/func/detect_rules, whose data is an array of { "id": 3, "regex": "..." }. It starts from read_local_rules, then appends one DetectRule per item with the panel’s id. Both fields have serde defaults: a missing id is 0, and a missing regex is the empty pattern, which matches every destination. A regex that does not compile is skipped with the warning invalid panel rule "...": {e}. A 304 returns Ok(None), and the node keeps its current rule set.
report_illegal drops every hit whose rule_id is negative, which removes all local-rule hits (id -1): the panel has no row for them. If nothing is left it sends no request. Otherwise it posts the rest, one item per distinct (uid, rule_id) pair; a hit on a flow with no authenticated user carries user_id -1:
{ "data": [ { "list_id": 3, "user_id": 42 } ] }The panels side by side
Section titled “The panels side by side”| newV2board | SSPanel | |
|---|---|---|
panel_type |
newv2board or v2board, any case |
sspanel, any case |
| Key in query | token |
key and muKey |
| Envelope | None | {ret, data}, ret must be 1 |
| Node types | V2ray, Trojan, Shadowsocks, Hysteria2 | V2ray, Trojan, Hysteria2 |
| Node speed limit | Never | node_speedlimit |
| Per-user speed limit | speed_limit |
node_speedlimit |
| Traffic label | {uuid}@v2board.user |
the uid |
| Audit rules | routes entries with action = "block" |
detect_rules |
| Audit reports | Not sent | detectlog, panel rules only |
| ETag keys | node, users |
node, users, rules |
How the node manager uses the results
Section titled “How the node manager uses the results”NodeManager in src/manager/node.rs is the only caller. Its bootstrap, poll loop and static updates run in one task, so a client never sees two of its own calls at once, and every method takes &self.
A bootstrap attempt (NodeManager::try_bootstrap) first calls forget_etags, then fetches the node and the users and brings the listener up. Any failure fails the whole attempt, which is logged at error level as node 1: {reason}; retrying in {n}s and retried. The wait starts at 1 s and doubles after each failure up to 60 s, but never exceeds the poll period (update_periodic, at least 1 s). A StaticUpdate::Config that arrives during the wait is applied and starts the next attempt at once; shutdown stops the retries.
| Call | Ok(Some(_)) |
Ok(None) |
Err(e) |
|---|---|---|---|
node_info, bootstrap |
Brings the node up; on SSPanel a port of 0 fails the attempt with panel returned port 0 (newV2board returns Err for it instead) |
Attempt fails with panel returned no node info |
Attempt fails with node_info failed: {e} |
node_info, poll |
Reconciled; on SSPanel a port of 0 logs refreshed port is 0, keeping the last one, keeps the last applied NodeInfo and still reconciles the users |
Last applied NodeInfo reused |
Warning, last applied NodeInfo reused |
user_list, bootstrap |
Used | Attempt fails with panel returned no user list |
Attempt fails with user_list failed: {e} |
user_list, poll |
Reconciled | Last applied users reused | Warning, last applied users reused |
node_rule |
RuleManager::update, a no-op if ids and patterns are unchanged |
Nothing | Warning |
report_user_traffic |
Counters committed | not returned | Residuals restored for the next poll, warning |
report_illegal |
not returned | not returned | Warning; the hits were already drained and are not re-queued |
node_rule runs at bootstrap and on every poll, but only when controller.disable_get_rule is off. report_user_traffic runs only when controller.disable_upload_traffic is off and some user has traffic to report, and report_illegal only when hits were recorded. Both report calls also run once more when the node shuts down. A listener that fails to start fails the bootstrap attempt too, with initial start failed: {e}. The bootstrap loop and how a reconcile is classified are on Node manager. The counters are on Traffic accounting.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| A 4xx or 5xx error never contains the request URL, and so never the panel key. | api::error_for_status calls reqwest::Error::without_url; contexts name the path only. |
No dedicated test. |
| A node identity is logged without the panel key. | display_id prints panel type, host, node id and, for newV2board, the node type. |
a_node_is_logged_by_its_panel_node_not_its_key in tests/unit/runtime.rs. |
| HTTP 304 means “unchanged” and never an error or an empty list. | The status check runs before error_for_status in get and get_data. |
No dedicated test. a_node_whose_port_is_taken_comes_up_once_it_is_free in tests/unit/e2e.rs runs against a fake panel (Quirks { etags: true }) that answers 304 to a repeated ETag. |
| An ETag is cached per endpoint, only from an accepted non-304 response, and never as an empty string. | EtagCache::set, called after error_for_status. |
No dedicated test. |
| Every bootstrap attempt reads the node and its users in full. | NodeManager::try_bootstrap calls forget_etags before its first request. |
a_node_whose_port_is_taken_comes_up_once_it_is_free in tests/unit/e2e.rs. |
Unknown panel_type or node_type fails at construction, so --test catches it and a reload that contains it changes nothing. |
PanelClient::new and NodeType::parse in both Client::new; test_config builds every client; apply_reload builds the client of every added or changed node before it touches a running one. |
a_reload_with_a_node_that_does_not_build_changes_nothing in tests/unit/runtime.rs; katana --test is not unit-tested. |
An edit to panel_type or any [node.api] field reaches the panel client. |
An identity change respawns the node; any other such edit makes NodeManager::apply_static build and swap in a new client. |
an_sspanel_api_edit_takes_effect_in_place, a_client_edit_takes_effect_without_dropping_connections and a_newv2board_type_edit_respawns_the_node in tests/unit/runtime.rs. |
| A rebuilt newV2board client enforces the block rules of the client it replaces until it reads the config. | PanelClient::inherit_routes, called by apply_static before the swap. |
a_rebuilt_client_keeps_the_routes_it_has_not_read in tests/unit/api/newv2board.rs. |
| A node never listens on port 0. | newV2board node_info bails on server_port == 0. NodeManager fails a bootstrap attempt on a port of 0 and retries it; on a poll it keeps the last applied NodeInfo and still reconciles the users. |
No dedicated test. |
The UniProxy node_type parameter says vless when a V2ray-family node enables VLESS (and for node_type = "vless" in any case), and the node identity uses the same value. |
newv2board::node_type_param, called by newv2board::Client::new and by api::panel_node_type for the identity. |
node_type_param_vless in tests/unit/api/newv2board.rs; a_newv2board_node_is_also_the_type_it_asks_for in tests/unit/runtime.rs. |
A VLESS node reads network_settings and a VMess node networkSettings. |
parse_v2ray chooses on self.enable_vless. |
vless_ws_tls and vless_grpc_tls in tests/integration/xray_interop.rs, through v2ray_config_body (skipped when Xray cannot be built). |
tls = 2 is REALITY, and REALITY implies TLS. |
parse_v2ray. |
parse_v2ray_reality_detected in tests/unit/api/newv2board.rs. |
| Shadowsocks obfuscation other than none is refused, not ignored. | parse_ss. |
parse_ss_obfs_rejected in tests/unit/api/newv2board.rs. |
| Obfuscation from the panel reaches the Hysteria listener. | parse_hysteria2 reads obfs and obfs-password; both are in transport_eq. |
a_panel_described_hysteria_node_serves_obfuscated_traffic in tests/unit/e2e.rs. |
| SSPanel never serves Shadowsocks. | First check in node_info_from. |
shadowsocks_rejected in tests/unit/api/sspanel.rs. |
disable_custom_config forces the legacy parser whatever the version. |
node_info_from. |
disable_custom_forces_legacy in tests/unit/api/sspanel.rs. |
Every NodeInfo field except speed_limit is compared by exactly one of transport_eq and protocol_eq. |
Hand-written methods; there is no derived PartialEq. |
No test; keep it by review. |
| Users of one node type are keyed the same way everywhere. | NodeType::keys_by_email, the single source. |
End-to-end tests that meter traffic per user, such as vmess_traffic_is_metered_and_reported and a_hysteria_node_relays_and_meters in tests/unit/e2e.rs. |
A configured api.speed_limit overrides every panel limit. |
newv2board::Client::user_list and sspanel::Client::speed_limit_bps. |
Conversion only: user_response_speed_limit_and_email, legacy_v2ray_ws_tls, custom_config_v2ray_vless. |
| Local-rule hits are never sent to SSPanel. | report_illegal filters rule_id >= 0. |
No dedicated test. |
The UniProxy push body is keyed by uid string with [upload, download]. |
HashMap<String, [i64; 2]> in report_user_traffic. |
push_body_shape in tests/unit/api/newv2board.rs; vmess_traffic_is_metered_and_reported reads real push bodies. |
Failure paths
Section titled “Failure paths”Nothing in this module retries, logs at error level or keeps state across a failure other than the ETag cache and the newV2board routes. Each call either returns a value or an anyhow::Error, and the node manager decides what to keep. The errors a contributor is likely to meet:
| Message (outermost) | Raised by | Cause |
|---|---|---|
unknown panel_type "..." |
PanelClient::new |
panel_type is not sspanel, newv2board or v2board. |
unknown node_type "..." |
both Client::new |
NodeType::parse returned None. |
GET {path} / POST {path} / POST UniProxy push |
request helpers | Connect failure, timeout, or a 4xx or 5xx status. |
GET {path} body / POST {path} body |
request helpers (POST … body is SSPanel post_data only) |
The body could not be read in full, including a timeout while reading. |
parse UniProxy config response / parse UniProxy user response |
newV2board | The body is not the expected JSON. |
newV2board: server port must be > 0 |
newV2board node_info |
server_port missing or 0. |
newV2board: shadowsocks obfs "..." is not supported |
parse_ss |
obfs is not empty, plain or none. |
parse {path} |
SSPanel get_data |
The body is not an envelope. |
{path}: panel returned ret=... |
SSPanel get_data, post_data |
ret is not 1. |
parse sspanel node info / parse sspanel user list / parse sspanel rules |
SSPanel | data has the wrong shape. |
sspanel: Shadowsocks node type is not supported |
node_info_from |
Shadowsocks node on SSPanel. |
custom_config is empty, disable custom config |
parse_custom_config |
custom_config missing or null on a new panel. |
parse sspanel custom_config |
parse_custom_config |
A custom_config field has the wrong JSON type. |
invalid offset_port_node "..." |
parse_custom_config |
Port missing or not a u16. |
sspanel: a hysteria2 node needs custom_config; the legacy server string cannot describe one |
parse_legacy |
Hysteria 2 on an old panel or with disable_custom_config. |
no server info in response |
legacy parsers | server is empty. |
malformed legacy v2ray server string: "..." |
parse_legacy_v2ray |
Fewer than six ;-separated parts. |
invalid legacy port "..." / invalid legacy trojan port "..." |
legacy parsers | Port missing or not a u16. |
Rule compilation problems are warnings, not errors: cannot read rule_list_path ..., invalid local rule ..., invalid panel rule ... and invalid block rule ... each drop the offending rule and keep the rest.
Cancellation needs no special handling here. Every method is one request with no background task. If its future is dropped at an .await, the ETag cache and the routes are either untouched or updated from a response whose status was already accepted.
Limits
Section titled “Limits”| Name | Value | Where |
|---|---|---|
| Request timeout | api.timeout seconds; 5 when 0 |
ApiConfig::timeout_secs, applied by build_http_client |
| Retries per call | none in this module | the node manager retries a failed bootstrap attempt, and after bootstrap the next poll retries |
| Bootstrap retry delay | 1 s, doubling after each failure up to 60 s, never longer than the poll period | BOOTSTRAP_RETRY_MIN, BOOTSTRAP_RETRY_MAX and NodeManager::poll_period in src/manager/node.rs |
| Poll period | update_periodic seconds, at least 1 s |
NodeManager::poll_period |
MBPS_TO_BPS |
1_000_000.0 / 8.0 = 125 000 |
src/api/mod.rs |
| Custom config version threshold | "2021.11" |
sspanel::Client::node_info_from |
| Legacy V2ray string | at least 6 ;-separated parts |
parse_legacy_v2ray |
| ETag keys | "node", "users", "rules" |
per client |
| Local rule id | -1 |
read_local_rules |
The unit tests live outside src/ and are compiled into the modules they test with #[cfg(test)] #[path = "../../tests/unit/api/…"] mod tests;. Being child modules, they can call the private parsers (parse_v2ray, parse_legacy_trojan, node_info_from) and build the private response structs directly, so they need no HTTP server.
tests/unit/api/newv2board.rs:
| Test | Pins |
|---|---|
node_type_param_vless |
vless for a VLESS-enabled V2ray node; otherwise the lowercased type (v2ray, trojan, shadowsocks). |
parse_v2ray_ws_tls |
ws transport, headers.Host as host, path, tls = 1 as TLS without REALITY. |
parse_v2ray_reality_detected |
tls = 2 sets both enable_reality and enable_tls. |
parse_trojan_fixed |
Trojan is TCP with TLS; host and server_name as service_name. |
parse_ss_fields |
cipher, server_key, TCP, no TLS. |
parse_ss_obfs_rejected |
obfs = "http" fails. |
user_response_speed_limit_and_email |
mbps_to_bps(10.0) is 1 250 000. |
push_body_shape |
{"1001": [123, 456]}. |
a_rebuilt_client_keeps_the_routes_it_has_not_read |
After inherit_routes, a new client’s node_rule returns the old client’s block route as a rule before the new client has fetched the config. |
tests/unit/api/sspanel.rs:
| Test | Pins |
|---|---|
compare_version_cases |
Equal, lower, higher, empty, a longer version with a non-zero or zero trailing segment. |
legacy_v2ray_ws_tls |
Port, transport and TLS from parts 1, 3 and 4; path, host, servicename from the extras; 100 Mbps as 12 500 000 B/s. |
legacy_trojan_grpc |
The inside port wins; grpc and servicename items; TLS on. |
custom_config_v2ray_vless |
Version 2021.11 takes the custom_config path; offset_port_node, network, security, enable_vless = "1"; 50 Mbps as 6 250 000 B/s. |
disable_custom_forces_legacy |
The legacy string wins over custom_config when disabled, even on version 2022.1. |
shadowsocks_rejected |
A Shadowsocks node fails. |
tests/unit/e2e.rs is compiled into the binary crate as mod e2e from src/main.rs. It runs a real NodeManager and a real PanelClient over loopback HTTP against hand-written panels:
| Fake panel | Serves |
|---|---|
fake_panel, fake_panel_dynamic, fake_panel_with_config |
newV2board: /UniProxy/config and /UniProxy/user, and records every /UniProxy/push body. |
fake_panel_quirky |
The same, with Quirks: config_failures answers that many config requests with a 500 first, and etags tags every answer with an ETag and answers a request that sends it back with 304, as Xboard does. It also counts the config requests. |
fake_sspanel |
SSPanel mod_mu: one V2ray node as a legacy server string, one user (uid 1001), an empty detect_rules list, and {"ret":1} for the reports. |
The tests that exercise this module most directly:
| Test | Pins |
|---|---|
vmess_traffic_is_metered_and_reported |
The config and user responses are parsed into a working VMess node, and the push body attributes the bytes to uid 1001. |
unchanged_user_survives_user_refresh |
A changed user response is picked up on the next poll: when a second user is added, the first user’s live connection survives; when the first user is then removed, that connection drops; and the pushes attribute both phases’ bytes to uid 1001. |
a_panel_described_hysteria_node_serves_obfuscated_traffic |
obfs and obfs-password from the panel reach the listener. |
a_node_comes_up_once_the_panel_answers |
A panel that answers the first two config requests with 500 delays the node; it comes up on the first real answer and relays. |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
A failed bind is retried against a panel that answers 304 to a repeated ETag; the retry reads the node and users in full and the node comes up once the port is free. |
a_node_that_never_bootstraps_still_stops |
A node retrying its bootstrap against a panel that always fails stops within 2 s of shutdown. |
tests/unit/runtime.rs is compiled into src/runtime.rs as its tests module. It checks the node identity and runs reloads against running nodes, using the same fake panels:
| Test | Pins |
|---|---|
an_sspanel_node_is_its_panel_node_id |
On SSPanel only panel_type (case aside), api.host, api.node_id and api.key change the identity. |
a_newv2board_node_is_also_the_type_it_asks_for |
On newV2board the node_type parameter is part of the identity too; api.timeout, api.speed_limit and api.rule_list_path are not. |
a_node_is_logged_by_its_panel_node_not_its_key |
display_id appends /v2ray for a newV2board V2ray node, prints no node type for SSPanel, and never prints the key. |
a_newv2board_type_edit_respawns_the_node |
Turning enable_vless off on newV2board respawns the node, which then serves VMess. |
a_reload_with_a_node_that_does_not_build_changes_nothing |
A node_type typo refuses the whole reload: the running node, its recorded config and a live connection are untouched. |
an_sspanel_api_edit_takes_effect_in_place |
Against fake_sspanel, turning enable_vless off reconfigures the node in place; the rebuilt client describes it as VMess and VLESS connections stop. |
a_client_edit_takes_effect_without_dropping_connections |
Setting rule_list_path on newV2board rebuilds the client: new flows to the listed destination are refused while a live connection keeps relaying. |
The integration tests under tests/integration/ run the real binary against tests/support/mod.rs → FakePanel, with bodies from v2ray_config_body, trojan_config_body and hysteria_config_body.
No test checks error redaction. A change to error_for_status, or to the error contexts in get, get_data or post_data, needs a new test that does.