Traffic accounting
Source files: 19 · checked against katana v3.0.1
katana/src/traffic.rskatana/src/manager/node.rskatana/src/manager/mod.rskatana/src/manager/proxy.rskatana/src/manager/transport.rskatana/src/runtime.rskatana/src/connector.rskatana/src/meter.rskatana/src/serve.rskatana/src/api/mod.rskatana/src/api/newv2board.rskatana/src/api/sspanel.rskatana/src/config.rskatana/tests/unit/traffic.rskatana/tests/unit/connector.rskatana/tests/unit/meter.rskatana/tests/unit/e2e.rskatana/tests/unit/api/newv2board.rskatana/tests/unit/runtime.rs
Every node in katana owns one NodeTraffic registry. It maps each authorized credential to a UserCounter, which holds the user’s upload and download totals and the token bucket that enforces the user’s speed limit. Flows write into those counters; once per poll cycle the node manager snapshots them, sends the totals to the panel, and, when the panel accepts the report, subtracts exactly what the report carried.
This page covers src/traffic.rs in depth and the reporting code around it in src/manager/node.rs and the two panel clients. It is for contributors who change how bytes are counted, how user-set changes move counters around, or how a report is built and reconciled. The operator’s view of the same behaviour is on Traffic reporting.
Responsibilities
Section titled “Responsibilities”src/traffic.rs owns:
- The counters. One
UserCounterper credential and uid, with twoAtomicU64totals and oneTokenBucket. - The registry. Which credential bills to which counter right now, and which counter a new flow gets (
NodeTraffic::lookup). - User-set transitions. A two-phase
prepareandcommitthat keeps unchanged users’ counters, gives new and rate-changed users fresh ones, and parks every outgoing counter so its bytes are still reported. - Report bookkeeping.
snapshotreads every counter that still has unreported bytes,commit_reportedsubtracts what a successful report carried, andrestore_residualshands back the counter-less rows of a failed one. - The rate function.
determine_ratecombines the node and user limits into one bucket rate.
It deliberately leaves out:
- Moving bytes and waiting on the bucket.
src/meter.rsdoes that (see Metering). - Deciding who may open a flow and cancelling departed users’ connections.
Admissioninsrc/connector.rsdoes that on top oflookupandPreparedUsers::cancel_keys(see Admission). - Talking to the panel.
NodeManager::report_trafficinsrc/manager/node.rsdrives the report throughPanelClient(see Panel clients).
Nothing in NodeTraffic is persisted. The registry lives as long as its NodeManager, which creates it in NodeManager::new and keeps it across listener rebuilds and across every swap of the node’s PanelClient.
Key types
Section titled “Key types”TokenBucket
Section titled “TokenBucket”A byte bucket that refills at rate bytes per second and holds at most one second of rate. A rate of 0 means unlimited: every method returns None before it touches the lock.
pub struct TokenBucket { rate: u64, state: Mutex<BucketState>,}
struct BucketState { tokens: f64, last: Instant,}
impl TokenBucket { pub fn new(rate: u64) -> Self; pub fn charge(&self, n: usize) -> Option<Instant>; pub fn ready_at(&self) -> Option<Instant>; fn refill(&self, st: &mut BucketState) -> Instant; fn repaid_at(&self, st: &BucketState, now: Instant) -> Option<Instant>;}Mutex is parking_lot::Mutex and Instant is tokio::time::Instant, so tests can drive the bucket with paused time.
| Method | What it does |
|---|---|
new |
Starts full: tokens = rate, last = Instant::now(). |
refill |
Credits elapsed * rate tokens since last, capped at rate (the one-second burst), and moves last to now. |
charge |
Refills, then subtracts n in full, even if that drives tokens negative. Returns the instant the debt is paid back, or None while the balance is non-negative. |
ready_at |
Refills, then answers the same question without charging anything. |
repaid_at |
now + (-tokens / rate) seconds when tokens < 0.0, else None. |
The balance is allowed to go negative. That debt is what makes the limit a property of bytes rather than of chunk sizes: a 30 000-byte write against a 10 000 B/s bucket costs 10 000 bytes of burst plus two seconds of debt, and the next transfer from any of the user’s flows waits out those two seconds. A bucket that waited for “enough tokens” before each piece would have to let an oversized piece through after at most one refill period.
The bucket never blocks. charge and ready_at only report a deadline. In src/meter.rs, Gate::poll_open waits until ready_at returns None before each transfer, and Gate::sent and Gate::received call charge after it. Both directions of every flow a user has share the one bucket in that user’s counter.
consume(&self, n: usize) (charge, then sleep until the returned instant) exists only under #[cfg(test)].
UserCounter
Section titled “UserCounter”pub struct UserCounter { pub uid: i64, up: AtomicU64, down: AtomicU64, pub rate: u64, pub bucket: TokenBucket,}
impl UserCounter { pub(crate) fn new(uid: i64, rate: u64) -> Self; pub fn add_up(&self, n: u64); pub fn add_down(&self, n: u64); pub fn up(&self) -> u64; pub fn down(&self) -> u64; pub fn commit_reported(&self, up: u64, down: u64);}add_upandadd_downarefetch_addwithOrdering::Relaxed.Gate::sentcallsadd_upfor bytes the user sent,Gate::receivedcallsadd_downfor bytes the user received.upanddownare relaxed loads. They are two independent reads, not a consistent pair; nothing depends on them being one, because each is later committed on its own.commit_reportedisfetch_subof exactly the amounts passed in, one per direction. It never stores zero.uidandrateare immutable. A rate change cannot be applied to an existing counter because the bucket’s rate is fixed at construction; it replaces the counter instead (see prepare and commit).
A flow holds its counter as an Arc<UserCounter> inside its Gate for as long as the flow lives. That strong reference is what the registry later uses to tell whether a retired counter can still receive bytes.
Identity: AuthKey, UserTag, UserEntry
Section titled “Identity: AuthKey, UserTag, UserEntry”pub enum AuthKey { Uuid(Uuid), Name(CompactString),}
pub struct UserTag { pub key: AuthKey, pub uid: i64,}
pub struct UserEntry { pub key: AuthKey, pub uid: i64, pub rate: u64,}AuthKeyis the registry key. The kernel’sUserAuthorizationderivesEqbut notHash, so katana uses its own hashable key.user_keyinsrc/manager/mod.rspicks it from the node type: a V2ray (VMess or VLESS) user is keyed byAuthKey::Uuid, and a Trojan, Shadowsocks or Hysteria 2 user byAuthKey::Nameof their email label (traffic_email, which falls back to the uid as a string when the email is empty).UserTagis what the inbound’s user tables carry for each credential, instead of a counter. A flow resolves its tag to a counter when it opens. A user table can outlive the refresh that replaced it (a long-lived connection keeps the table it authenticated against), so a counter baked into the table would keep billing a departed user’s new flows to a counter nobody reports.UserTag::unattributed()iskey = AuthKey::Name(""),uid = -1: the placeholder for the single-password slot a Shadowsocks table requires and katana never serves from. It is meant to match no registered user, so it resolves to no counter.UserEntryis one row of the next user set.build_user_entriesinsrc/manager/mod.rsbuilds them from the panel’sUserInfolist, withrate: determine_rate(node.speed_limit, u.speed_limit). On a V2ray node, a user whoseuuiddoes not parse has no key and is skipped with the warningskipping user {uid}: uuid is not a valid UUID, which names the uid and never the credential. Email-keyed node types always produce a key.
determine_rate
Section titled “determine_rate”pub fn determine_rate(node_bps: u64, user_bps: u64) -> u64;The smaller of the two non-zero limits, where 0 means unlimited on that side:
node_bps |
user_bps |
Result |
|---|---|---|
0 |
0 |
0 (unlimited) |
0 |
u |
u |
n |
0 |
n |
n |
u |
min(n, u) |
Both inputs are bytes per second. The panel clients convert Mbps with mbps_to_bps in src/api/mod.rs, which multiplies by MBPS_TO_BPS = 1_000_000.0 / 8.0 and returns 0 for any value at or below zero. How each panel fills the two limits is on Speed limits.
NodeTraffic
Section titled “NodeTraffic”#[derive(Default)]pub struct NodeTraffic { users: RwLock<HashMap<AuthKey, Arc<UserCounter>>>, residuals: Mutex<HashMap<i64, (u64, u64)>>, draining: Mutex<Vec<Arc<UserCounter>>>,}A counter with unreported bytes lives in exactly one of three places:
| Field | Holds | Who can still write to it |
|---|---|---|
users |
The current registry: one counter per credential. New flows get their counter from here. | Every live flow of that credential, plus any new flow. |
draining |
Counters that left the registry (rate change, departure, rebound) and may still have writers: connections opened before the change that are still winding down. | Only flows that already hold the Arc. No new flow can reach them. |
residuals |
Plain (up, down) totals per uid that a failed report handed back. No counter, only numbers. |
Nobody. |
The methods, grouped by who calls them:
impl NodeTraffic { pub fn new() -> Self;
// user-set changes pub fn prepare(&self, entries: Vec<UserEntry>) -> PreparedUsers; pub fn commit(&self, prepared: PreparedUsers);
// flows pub fn lookup(&self, key: &AuthKey, uid: i64) -> Option<Arc<UserCounter>>;
// reporting pub fn snapshot(&self) -> Vec<TrafficSnapshot>; pub fn restore_residuals(&self, rows: Vec<(i64, u64, u64)>); pub fn clear_residuals(&self); pub fn prune_draining(&self);
fn add_residuals(&self, rows: Vec<(i64, u64, u64)>);}lookup returns the registered counter only if it belongs to the same uid as the tag: self.users.read().get(key).filter(|c| c.uid == uid).cloned(). A departed credential finds nothing, and so does a credential that now belongs to another uid. Admission::admit turns that None into a refused flow.
set_users (commit(prepare(entries)) in one call) and get (lookup by UserAuthorization regardless of uid) exist only under #[cfg(test)].
PreparedUsers
Section titled “PreparedUsers”pub struct PreparedUsers { next: HashMap<AuthKey, Arc<UserCounter>>, carried: Vec<(Arc<UserCounter>, Arc<UserCounter>)>, orphaned: Vec<Arc<UserCounter>>, cancel_keys: HashSet<AuthKey>,}
impl PreparedUsers { pub fn cancel_keys(&self) -> &HashSet<AuthKey>;}| Field | Contents |
|---|---|
next |
The complete next registry. |
carried |
(fresh, old) pairs where a rate change forced a new counter for the same uid. The old one drains; its connections are kept. |
orphaned |
Counters whose credential left the set or now belongs to another uid. They drain too. |
cancel_keys |
The keys whose live connections must be cancelled: exactly the keys of orphaned. Rate-changed keys are not in it. |
TrafficSnapshot
Section titled “TrafficSnapshot”pub struct TrafficSnapshot { pub uid: i64, pub up: u64, pub down: u64, pub counter: Option<Arc<UserCounter>>,}counter decides how the row is reconciled after the report:
Some(counter): the bytes still sit in that counter. A successful report subtracts them from it; a failed one leaves them there.None: the bytes have already been taken out ofNodeTraffic(a drained residual, or a draining counter’s final row). A successful report lets them go; a failed one must hand them back withrestore_residuals.
Panel-facing types
Section titled “Panel-facing types”pub struct UserTraffic { pub uid: i64, pub upload: i64, pub download: i64,}
impl PanelClient { pub async fn report_user_traffic(&self, t: &[UserTraffic]) -> Result<()>;}PanelClient dispatches to the client for the configured panel. Result is anyhow::Result.
Where counters live
Section titled “Where counters live”flowchart LR P["user set from the panel"] --> PR["prepare"] PR --> CM["commit"] CM --> U["users (registry)"] CM --> D["draining"] F["new flow"] -->|lookup| U G["Gate of a live flow"] -->|"add_up / add_down"| C["Arc UserCounter"] U -. holds .-> C D -. holds .-> C S["snapshot"] --> U S --> D S --> R["residuals"] X["failed report"] -->|restore_residuals| R
prepare and commit
Section titled “prepare and commit”A user-set change is two calls. prepare builds the next registry without mutating anything and without reading any byte total; commit publishes it. Between the two, the caller builds the next user tables, and a build error can abandon the PreparedUsers with nothing changed.
prepare
Section titled “prepare”prepare holds users.read() for its whole run and decides, for each UserEntry:
flowchart TB
E["UserEntry key, uid, rate"] --> Q{"key in registry?"}
Q -->|no| N["fresh counter"]
Q -->|yes| S{"same uid and same rate?"}
S -->|yes| K["keep the existing Arc"]
S -->|no| UID{"same uid?"}
UID -->|"yes: rate change"| CA["fresh counter, old one to carried"]
UID -->|"no: rebound"| OR["fresh counter, old one to orphaned, key to cancel_keys"]
Afterwards, every registry key that is not in next is a user who left: its counter goes to orphaned and its key to cancel_keys.
| Change | Counter | Old counter | Live connections |
|---|---|---|---|
| Unchanged key, uid and rate | The same Arc |
None | Kept, metering uninterrupted |
| New key | Fresh | None | None yet |
| Same key and uid, new rate | Fresh, at the new rate | carried, drains |
Kept, still billing and limited by the old counter |
| Same key, different uid (rebound) | Fresh, for the new uid | orphaned, drains, still reported under the old uid |
Cancelled |
| Key no longer listed | None | orphaned, drains |
Cancelled |
A rate change therefore takes effect for new flows only. Connections that were open before it keep the old counter, and with it the old bucket, until they close. Both counters report under the same uid, and report_traffic merges them into one row.
commit
Section titled “commit”pub fn commit(&self, prepared: PreparedUsers) { let PreparedUsers { next, carried, orphaned, cancel_keys: _, } = prepared; let mut users = self.users.write(); let mut draining = self.draining.lock(); draining.extend(carried.into_iter().map(|(_fresh, old)| old)); draining.extend(orphaned); *users = next;}commit takes both locks together, in the same order snapshot takes them, and moves every outgoing counter into draining in the same critical section that swaps the registry. A snapshot therefore sees each outgoing counter in exactly one place. If a counter could be seen both still registered and already draining, its bytes would be reported twice and then subtracted twice.
Neither phase reads a byte total, so neither waits for the old generation’s connections to stop. Whatever they write after the commit lands in a draining counter that snapshot keeps reading.
Callers
Section titled “Callers”Every caller runs on the node’s own task (NodeManager::run), so user-set changes and reports are serialized.
| Caller | When | Order |
|---|---|---|
ProxyManager::refresh (src/manager/proxy.rs) |
The user set or the node speed limit changed, and the listener is kept. | prepare, build the replacement tables (an error returns and changes nothing), Admission::commit, then swap the tables. |
TransportManager::start (src/manager/transport.rs) |
A listener is built: bootstrap and every rebuild. | prepare, build and bind everything that can fail, then traffic.commit. No one is serving yet, so no one needs cancelling. |
NodeManager::reconcile and NodeManager::bring_up (src/manager/node.rs) |
The panel returned an empty user list. | commit(prepare(Vec::new())): every counter drains. |
Admission::commit holds the admission leases lock, calls NodeTraffic::commit, and only then cancels the lease of every key in cancel_keys. Because Admission::admit calls lookup under the same leases lock, a flow is either admitted before the commit (and then cancelled by it) or checked against the registry the commit published. The registry is published before the new tables, so a newly added user can never authenticate against the new table and then be refused by a registry that has not heard of them. Details are on Admission.
A counter’s life
Section titled “A counter’s life”stateDiagram-v2 [*] --> Registered: prepare creates it, commit publishes it Registered --> Registered: report succeeds, commit_reported Registered --> Draining: rate change, departure or rebound Draining --> Draining: snapshot while a writer holds it Draining --> FinalRow: snapshot sees strong_count 1 FinalRow --> [*]: report succeeds FinalRow --> Residual: report fails, restore_residuals Residual --> Reported: next snapshot drains the map Reported --> [*]: report succeeds Reported --> Residual: report fails
A registered or draining counter is never reset: a report only ever subtracts what it carried. Once the last flow drops its Arc, only the draining vector holds the counter, nothing can reach it to write, and its next snapshot row is final. After that the bytes exist only as numbers, in a snapshot row or in residuals, until a report succeeds.
snapshot
Section titled “snapshot”snapshot produces one row per counter that may have unreported bytes, plus one row per parked residual.
- Take
users.read(), thendraining.lock(), and hold both. - For every registered counter, push a row with its current
up,downandcounter: Some(clone). - For every draining counter:
- Read
Arc::strong_countbefore the bytes. If it is1, only the vector holds the counter, so no writer is left, and the count can never rise again. - If it is
1, issuestd::sync::atomic::fence(Ordering::Acquire). The fence pairs with the release decrement in the last writer’sArcdrop, so every byte that writer added is visible to the loads that follow. - Read
uid,upanddown. - If it was alone, push the row with
counter: Noneandswap_removethe counter fromdraining. That row is final. - Otherwise push the row with
counter: Some(clone)and keep the counter.
- Read
- Release both locks.
- Take
residuals.lock()anddrain()the whole map into rows withcounter: None.
The result is not merged: a uid can appear more than once, for example a live counter, a draining one after a rate change, and a residual from an earlier failed report. Rows with zero bytes are included; report_traffic skips them.
| Lock | Kind | Taken by |
|---|---|---|
NodeTraffic::users |
parking_lot::RwLock |
prepare (read), commit (write), lookup (read), snapshot (read) |
NodeTraffic::draining |
parking_lot::Mutex |
commit, snapshot, prune_draining |
NodeTraffic::residuals |
parking_lot::Mutex |
add_residuals, restore_residuals, clear_residuals, snapshot |
Admission::leases |
parking_lot::Mutex |
Admission::admit, Admission::commit, Admission::retire_all |
The order is leases, then users, then draining. residuals is never held together with any of them. No NodeTraffic or Admission lock is held across an .await: the report request runs with none of them held, while flows keep writing into the atomics.
The report cycle
Section titled “The report cycle”NodeManager::poll_cycle fetches node info and users, reconciles, refreshes rules, then calls report_traffic, then report_illegal. The timer runs it every update_periodic seconds (default_update_periodic returns 60; poll_period applies .max(1)); a config edit and shutdown add the runs listed under When a report runs.
sequenceDiagram
participant N as NodeManager task
participant T as NodeTraffic
participant F as Flows (Gate)
participant P as PanelClient
participant S as Panel
N->>T: snapshot()
T-->>N: rows (counter Some or None)
N->>N: skip zero rows, merge by uid, split commits and residuals
F->>T: add_up / add_down (keeps going)
N->>P: report_user_traffic(reports)
P->>S: POST
alt Ok
S-->>P: accepted
P-->>N: Ok(())
N->>T: commit_reported(up, down) per counter row
else Err (send error, timeout, HTTP 4xx or 5xx, SSPanel body or ret)
P-->>N: Err
N->>T: restore_residuals(counter-less rows)
N->>N: warn "report traffic"
end
When a report runs
Section titled “When a report runs”| Trigger | What runs | Where |
|---|---|---|
Timer tick, every update_periodic seconds. The first tick comes one full interval after the node first comes up. |
poll_cycle(false) |
NodeManager::serve |
A config edit, once the node is up, that swaps in a new PanelClient (any [node.api] change that keeps the identity tuple, or a panel_type edit that changes only its letter case) or forces a listener rebuild (route, listen_ip, cert, disable_sniffing, enable_vless, [node.hysteria]) |
poll_cycle(rebuild) at once, report included |
NodeManager::apply_static |
| Shutdown, removal or respawn of the node | tear_down, then report_traffic and report_illegal, one attempt each |
NodeManager::run |
A report run by a config edit is an ordinary report: the node keeps its NodeTraffic, and report_traffic reads the client through self.api() when it runs, so the report after an edit goes out through the new client. The edit does not move the timer, unless it also changes update_periodic: then serve starts a new interval and the next tick comes one full new period later. While the node is still coming up, its bootstrap retrying, no report runs, and a config edit polls nothing: the node stores it, or refuses it if it does not build, and starts the next attempt at once.
report_traffic
Section titled “report_traffic”report_traffic(&self) in src/manager/node.rs, step by step:
- If
[node.controller].disable_upload_trafficis set:clear_residuals(),prune_draining(), return. See Reporting disabled. snapshot().- For each row with any non-zero direction:
- add it into
totals: HashMap<i64, (u64, u64)>withsaturating_add, keyed by uid; - if
counterisSome, push(counter, up, down)tocommits; - if
counterisNone, push(uid, up, down)toresiduals.
- add it into
- If
totalsis empty, return without a request. - Convert
totalstoVec<UserTraffic>, casting eachu64toi64. self.api.report_user_traffic(&reports).await.- On
Ok(()), callcounter.commit_reported(up, down)for every entry incommits, with the amounts that row carried. - On
Err(e), callself.traffic.restore_residuals(residuals)and lognode {id}: report traffic: {e}atWARN. The counter rows need no action: their bytes were never taken out.
Two separate views are kept on purpose:
- The wire view is merged by uid, because the newV2board payload is a JSON object keyed by uid, where a second row would silently overwrite the first.
- The commit view is one entry per counter. A uid with a live and a draining counter gets two
commit_reportedcalls, each subtracting what was read from that counter.
commit_reported acts on the Arc, not on a registry key, so the subtraction lands on the right counter wherever it sits by then.
Two cycles, worked through
Section titled “Two cycles, worked through”One registered counter for uid 7, a flow that keeps writing, a first report that fails and a second that succeeds. The numbers are the counter’s up total.
sequenceDiagram participant F as Flow (Gate) participant C as UserCounter uid 7 participant N as report_traffic participant P as Panel F->>C: add_up(1000), up = 1000 N->>C: snapshot reads 1000 F->>C: add_up(200), up = 1200 N->>P: push uid 7 upload 1000 P-->>N: Err Note over N,C: counter row, nothing to restore, up stays 1200 N->>C: next snapshot reads 1200 N->>P: push uid 7 upload 1200 F->>C: add_up(50), up = 1250 P-->>N: Ok N->>C: commit_reported(1200, 0), up = 50 Note over C: the 50 bytes wait for the next cycle
The failed report cost nothing: its 1000 bytes were never taken out of the counter, so the second snapshot carries them together with the 200 that arrived meanwhile. The 50 bytes written while the second request was in flight survive the commit because it subtracts 1200 instead of storing zero. For a counter-less row (a residual or a draining counter’s final row) the failure branch is what keeps the bytes: restore_residuals parks them again, and the next snapshot drains them into its result.
What goes on the wire
Section titled “What goes on the wire”newv2board::Client::report_user_traffic builds a HashMap<String, [i64; 2]> from uid to [upload, download] and sends it as JSON to PUSH_PATH = "/api/v1/server/UniProxy/push", with node_id, node_type and token in the query string.
{ "1001": [52428800, 1073741824] }Success is any status that passes error_for_status (below 400). The body is not read.
sspanel::Client::report_user_traffic maps each row to TrafficItem { user_id, u, d } and posts PostData { data } to /mod_mu/users/traffic through post_data, with key, muKey and node_id in the query string.
{ "data": [{ "user_id": 1001, "u": 52428800, "d": 1073741824 }] }Success needs a status below 400, a response body that can be read in full, and, when the body parses as the Envelope (ret and data, both #[serde(default)]), ret == 1. Because ret defaults to 0, a JSON object without ret fails; a body that does not parse as the envelope at all counts as success. A ret other than 1 fails with /mod_mu/users/traffic: panel returned ret={ret}.
Both clients use the node’s shared reqwest::Client, built by build_http_client(api.timeout_secs()) with a whole-request timeout. ApiConfig::timeout_secs returns [node.api].timeout, or 5 when it is 0. An edit to timeout swaps in a new client for the same node and NodeTraffic, so the report that the edit runs, and every later one, uses the new timeout. error_for_status strips the URL from the error, because the panel key travels in the query string.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| A failed report loses no bytes. | Counter rows are only ever reduced by commit_reported, which runs only on Ok. Counter-less rows are handed back with restore_residuals on Err. |
The registry half: restored_residuals_are_retried (tests/unit/traffic.rs). The branch in report_traffic that chooses between the two outcomes has no test. |
| Bytes that arrive while a report is in flight are kept. | commit_reported is a fetch_sub of the amounts in the report, never a store of zero. |
commit_reported_preserves_concurrent, a_departed_users_late_bytes_are_still_reported (tests/unit/traffic.rs) |
| No counter is reported or committed twice in one cycle. | commit and snapshot hold users and draining together in the same order, so an outgoing counter is in exactly one of them. |
rate_change_drains_old_counter_and_reports_once (tests/unit/traffic.rs), sequentially. No test races commit against snapshot. |
| A draining counter’s bytes are reported until its last writer is gone, and its final row once. | strong_count == 1 read before the bytes, the Acquire fence, and swap_remove in the same critical section. |
draining_counter_with_live_writer_is_retained, a_departed_users_late_bytes_are_still_reported (tests/unit/traffic.rs) |
| A departed user’s bytes are still reported, once. | prepare moves the counter to orphaned, commit to draining, snapshot emits the final row and drops the counter; residuals is drained by snapshot. |
dropped_user_bytes_become_residuals (tests/unit/traffic.rs) |
| Bytes stay with the uid that earned them. | A rebound credential’s old counter is orphaned rather than reused; lookup filters on uid, so a table pinned by an old connection cannot bill the new account. |
rebound_credential_reports_the_old_uid_separately (tests/unit/traffic.rs), a_credential_rebound_to_another_uid_is_refused (tests/unit/connector.rs) |
| An unchanged user keeps one counter across refreshes and rebuilds. | prepare reuses the existing Arc when uid and rate match; NodeTraffic outlives every TransportManager and every PanelClient swap. |
rate_change_drains_old_counter_and_reports_once (first half), unchanged_user_survives_user_refresh (tests/unit/e2e.rs) for a user refresh. Byte continuity across a listener rebuild has no test. |
| A rate change reaches new flows. | The fresh counter carries a fresh TokenBucket at the new rate; lookup returns it to every new flow. |
rate_change_drains_old_counter_and_reports_once |
| A departed user opens no new flows. | lookup returns None; Admission::commit cancels the user’s lease after publishing the registry. |
a_user_the_registry_does_not_know_is_refused, the_lease_reaches_the_connection_and_goes_with_the_user (tests/unit/connector.rs) |
| Bookkeeping state is bounded. | add_residuals merges by uid with saturating_add and skips all-zero rows, so residuals holds at most one entry per uid however often users churn; draining counters leave the vector at their final snapshot. |
Pruning: draining_counter_with_live_writer_is_retained, rate_change_drains_old_counter_and_reports_once. The merge by uid in add_residuals has no dedicated test. |
| A report is never abandoned between snapshot and outcome. | In NodeManager::serve, poll_cycle runs inside a tokio::select! handler body, for a timer tick and for a config edit alike, and the shutdown branch is not polled while a handler runs, so a cancelled node finishes its cycle first. The bootstrap attempt that bootstrap races against shutdown never reports. |
Not pinned by a dedicated test. |
Two derived properties follow from these:
- A counter never goes below zero. The only subtraction is
commit_reported, of an amount read from that same counter earlier, while every other operation only adds. Reporting the same counter twice would break this, which is one more reason the single-location invariant matters. - Idle users cost nothing on the wire. Zero rows are skipped, and a cycle with nothing to send makes no request.
Failure paths and cancellation
Section titled “Failure paths and cancellation”A report fails
Section titled “A report fails”report_user_traffic returns Err for a connection or send error, the request timeout, a status of 400 or above, and on SSPanel a response body that cannot be read or an envelope whose ret is not 1. katana does not retry within the cycle and has no backoff: the next attempt is the next poll_cycle, whose snapshot contains the same bytes plus whatever arrived since. A long outage ends in one larger report.
Shutdown and node replacement
Section titled “Shutdown and node replacement”When the node’s CancellationToken fires, NodeManager::run leaves its poll loop, or stops retrying its bootstrap, calls tear_down (which shuts the TransportManager down: stops accepting, retires every user, releases the listener), then runs report_traffic and report_illegal once more. A node stopped before its listener was ever bound has counted no bytes, so this final report_traffic finds nothing and sends no request.
The same path runs when a reload removes a node or changes its identity tuple (panel_type, api.host, node_id, api.key, panel node type), since apply_reload cancels the old node’s token and awaits its task before a new NodeManager, with a new, empty NodeTraffic, starts. The panel node type comes from panel_node_type in src/api/mod.rs:
- On newV2board it is the
node_typethe UniProxy requests carry:vlesswhenenable_vlessis set andnode_typeisV2ray,VmessorVless(in any letter case), otherwise the lowercasednode_type. UniProxy finds a node by its id and this type, so an edit that changes the value names another panel node: anode_typechange beyond letter case (except between V2ray-family names whileenable_vlessis set, since all of them ask forvless), or anenable_vlesstoggle on aV2rayorVmessnode. katana respawns the node with a freshNodeTrafficinstead of billing one panel node’s counters to another. The old node’s final report goes out through its old client, to the panel node its bytes were counted for. - On SSPanel it is empty, because
mod_mufinds a node by its id alone.
Every other edit, api.timeout included, reaches the running node as a config update. The node keeps its NodeTraffic; an edit to [node.api] swaps in a new PanelClient and runs a report at once (see When a report runs). A reload in which any node does not build (an unknown node_type, for example) is refused whole, so no running node is stopped or replaced by it. Process runtime and reload covers the reload side.
That final report is a single attempt. If it fails, its bytes go down with the old NodeTraffic. A crash or SIGKILL loses everything not yet reported, because nothing is written to disk.
Empty user list
Section titled “Empty user list”reconcile tears the listener down and calls commit(prepare(Vec::new())). Every registered counter moves to draining; the cycle’s own report_traffic then reports them, and they leave the vector once their connections are gone. bring_up does the same when it is asked to start a node with no users.
Reporting disabled
Section titled “Reporting disabled”With disable_upload_traffic set, report_traffic never calls snapshot:
clear_residualsempties the residual map every cycle.prune_drainingkeeps only draining counters withstrong_count > 1, discarding the bytes of the rest.- Registered counters are neither read nor reduced. They keep accumulating, and their buckets keep limiting.
The flag is read at every cycle and can be changed by a same-identity reload. Setting it back to false makes the next snapshot report everything the registered counters gathered in the meantime.
Cancelled flows
Section titled “Cancelled flows”Cancelling a user’s lease ends their connections in two places: every Gate::poll_open returns ConnectionAborted (“the user was retired”) once the lease is cancelled, and on a stream listener the connection task watches the lease its first admitted flow published (until_retired in src/serve.rs) and ends the whole connection. Accounting does not wait for that: bytes moved before the flow ends are already in the counter, and the counter’s last Arc drop is what lets the next snapshot emit its final row.
Limits
Section titled “Limits”| What | Value | Where |
|---|---|---|
| Bucket capacity (burst) | One second of rate: min(tokens + elapsed * rate, rate) |
TokenBucket::refill |
| Initial balance | Full: rate tokens |
TokenBucket::new |
| Unlimited | rate == 0 |
TokenBucket::charge, TokenBucket::ready_at, determine_rate |
| Mbps conversion | MBPS_TO_BPS = 1_000_000.0 / 8.0, decimal megabits |
src/api/mod.rs |
| Report interval | update_periodic, default 60 s, at least 1 s |
default_update_periodic, NodeManager::poll_period |
| First report | One full interval after the node first comes up; the first interval.tick() is consumed before the loop. A config edit that swaps the panel client or forces a listener rebuild reports at once. |
NodeManager::serve, NodeManager::apply_static |
| Report timeout | [node.api].timeout, 5 s when 0, for the whole request; an edit applies from the next report |
ApiConfig::timeout_secs, build_http_client |
| Retries | None within a cycle; the next cycle carries the bytes | NodeManager::report_traffic |
| Residual map size | At most one entry per uid | NodeTraffic::add_residuals |
| Counter width | u64 per direction; merged totals use saturating_add; sent as i64 |
UserCounter, report_traffic, UserTraffic |
| Persistence | None | NodeManager::new |
tests/unit/traffic.rs (included into src/traffic.rs as its tests module):
| Test | Pins |
|---|---|
determine_rate_min_nonzero |
The four cases of determine_rate. |
rate_change_drains_old_counter_and_reports_once |
Same uid and rate keeps the counter and its bytes; a rate change gives a fresh empty counter at the new rate; the old bytes are reported once and the drained counter disappears. |
draining_counter_with_live_writer_is_retained |
A draining counter with a writer reports with counter: Some, keeps accumulating, and becomes one final counter: None row after the writer drops. |
dropped_user_bytes_become_residuals |
A departed user’s bytes appear once as a counter-less row, then never again. |
a_departed_users_late_bytes_are_still_reported |
Bytes written after the removing commit are reported; after a commit, the final row carries only the remainder. |
restored_residuals_are_retried |
A restored row reappears in the next snapshot; clear_residuals empties the map. |
rebound_credential_reports_the_old_uid_separately |
The old uid keeps its bytes; the new uid starts at zero. |
set_users_drops_absent |
An unlisted key no longer resolves. |
commit_reported_preserves_concurrent |
An increment between the read and the commit survives. |
token_bucket_unlimited_is_instant |
rate == 0 never waits. |
token_bucket_rate_limits |
After the burst, 10 000 bytes at 10 000 B/s take at least 0.8 s of wall-clock time. |
a_chunk_larger_than_the_burst_is_still_limited |
A 30 000-byte charge at 10 000 B/s takes exactly two seconds (paused time). |
debt_holds_back_the_next_charge_too |
A 25 000-byte charge leaves 1.5 s of debt that the next one-byte charge waits out. |
an_idle_bucket_banks_one_second_and_no_more |
After 60 idle seconds, a 20 000-byte charge still leaves one second of debt. |
Around it:
| File | Tests | Pins |
|---|---|---|
tests/unit/meter.rs |
the_limit_holds_however_the_writes_are_sized, the_limit_is_shared_by_both_directions, each_direction_is_billed_to_the_user |
The bucket’s debt as seen through Metered, and the direction each byte is billed to. |
tests/unit/connector.rs |
a_user_the_registry_does_not_know_is_refused, a_credential_rebound_to_another_uid_is_refused, an_admitted_stream_is_billed_to_its_user, the_lease_reaches_the_connection_and_goes_with_the_user, udp_is_billed_after_routing_and_blocked_packets_are_free |
lookup as the admission check, and that billed bytes land in the registered counter. |
tests/unit/e2e.rs |
vmess_traffic_is_metered_and_reported, unchanged_user_survives_user_refresh, proxy_outbound_relays_and_meters, a_hysteria_node_relays_and_meters |
The whole path against a fake UniProxy panel that records every push body: at least the relayed bytes reach the panel under the right uid, across a user refresh and a user’s removal. The assertions are lower bounds, so they do not prove exactly-once reporting. |
tests/unit/runtime.rs |
an_sspanel_node_is_its_panel_node_id, a_newv2board_node_is_also_the_type_it_asks_for, a_newv2board_type_edit_respawns_the_node |
Which edits change the identity tuple, and so respawn the node with a fresh NodeTraffic: on newV2board node_type V2ray to Trojan, or an enable_vless toggle on a V2ray node, does; a case-only node_type change, enable_vless on a Trojan node, and on both panels an api.timeout change do not. |
tests/unit/api/newv2board.rs |
push_body_shape |
That a HashMap<String, [i64; 2]> serializes to the {"uid": [upload, download]} shape. It builds the map itself and does not call report_user_traffic. |
report_traffic’s own branches (the merge by uid, the split into commits and residuals, and the failure branch) are covered only through the end-to-end tests, and those exercise the success path. A change to that function should come with a test that makes the fake panel fail a push and checks that the next push carries the same bytes. See Testing for how to run the suites.