Skip to content

Panel clients

Source files: 19 · checked against katana v3.0.1
  • katana/src/api/mod.rs
  • katana/src/api/newv2board.rs
  • katana/src/api/sspanel.rs
  • katana/src/config.rs
  • katana/src/runtime.rs
  • katana/src/inbound.rs
  • katana/src/main.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/transport.rs
  • katana/src/rule.rs
  • katana/Cargo.toml
  • katana/tests/unit/api/newv2board.rs
  • katana/tests/unit/api/sspanel.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/runtime.rs
  • katana/tests/support/mod.rs
  • katana/tests/integration/xray_interop.rs

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.

The module does five things:

  1. Fetch and normalize the node description (node_info) and the user list (user_list) into panel-independent structs.
  2. Save bandwidth with a per-endpoint ETag cache, and report “unchanged” as Ok(None) instead of re-parsing an identical body.
  3. Report per-user traffic (report_user_traffic) and audit hits (report_illegal) in each panel’s body format.
  4. Build the audit rule set (node_rule) from the panel plus an optional local rule file.
  5. 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 NodeInfo rebuilds 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 → RuleManager does that with the DetectRule values 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.

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:

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

Result 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.

PanelClient::new chooses the variant:

src/api/mod.rs
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:

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

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_type or api.enable_vless that changes the node_type parameter; "V2ray" to "v2ray" does not, and neither does enable_vless on a Trojan node.
  • Any other edit to panel_type or [node.api] reaches the running node as StaticUpdate::Config. NodeManager::apply_static builds a new client with PanelClient::new, calls inherit_routes on 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 as api.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 logs node 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.

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

parse 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).

src/api/mod.rs
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.

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

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

UserInfo 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
src/api/mod.rs
pub struct DetectRule {
pub id: i64,
pub pattern: Regex,
}
pub struct DetectResult {
pub uid: i64,
pub rule_id: i64,
}
pub struct RouteRule {
pub match_: Vec<String>,
pub action: String,
}

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.

src/api/mod.rs
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.

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

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.

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

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 before error_for_status, so 304 is never an error.
  • set ignores an empty value, and a header that is not valid visible ASCII (HeaderValue::to_str fails) is skipped. Such a response, like one with no ETag at all, is still parsed, but the cache keeps whatever it held for that endpoint: the next request carries the previous ETag, or no If-None-Match if there was none.
  • The cache lives as long as the client, and forget_etags (EtagCache::clear on either client) empties it. NodeManager::try_bootstrap calls forget_etags at 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_port of 0, an SSPanel ret other 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"
src/api/mod.rs
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 1s

When 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.

src/api/mod.rs
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.

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

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_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.

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

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.

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.

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.

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.

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.

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

base_query 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”
src/api/sspanel.rs
fn node_info_from(&self, resp: &NodeInfoResponse) -> Result<NodeInfo>;
fn parse_custom_config(&self, resp: &NodeInfoResponse) -> Result<NodeInfo>;
fn parse_legacy(&self, resp: &NodeInfoResponse) -> Result<NodeInfo>;
fn compare_version(v1: &str, v2: &str) -> i32;

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 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.

parse_legacy_v2ray splits server on ; and needs at least six parts:

address;port;alter_id;transport_or_tls;transport_or_tls;extras
example.com;443;0;ws;tls;path=/ws|host=proxy.example.com|servicename=svc
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: "...".

parse_legacy_trojan reads the port and host with three regexes compiled once in LazyLock statics:

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

In 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.

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 } ] }
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

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.

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.

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.

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.

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.