Skip to content

Users, principals and sessions

Source files: 37 · checked against Etemenanki 555b7df · katana v4.1.1
  • Etemenanki/supervisor/src/entity/user.rs
  • Etemenanki/supervisor/src/entity/session.rs
  • Etemenanki/supervisor/src/entity/id.rs
  • Etemenanki/supervisor/src/build/users.rs
  • Etemenanki/supervisor/src/build/inbound.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/topology/flow.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/topology/spec_plan/mod.rs
  • Etemenanki/supervisor/src/topology/spec_plan/inbound.rs
  • Etemenanki/supervisor/src/topology/spec_plan/plan.rs
  • Etemenanki/supervisor/src/policy.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/system/listener.rs
  • Etemenanki/supervisor/src/serve.rs
  • Etemenanki/supervisor/src/connector.rs
  • Etemenanki/supervisor/src/track/mod.rs
  • Etemenanki/supervisor/src/track/sampler.rs
  • Etemenanki/supervisor/src/entity/usage.rs
  • Etemenanki/protocols/src/vmess/accounts.rs
  • Etemenanki/protocols/src/hysteria/server/authenticator.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_legacy/users.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/webclient/src/tracking.rs
  • Etemenanki/supervisor/tests/unit/session.rs
  • Etemenanki/supervisor/tests/unit/validate.rs
  • Etemenanki/supervisor/tests/unit/plan.rs
  • Etemenanki/supervisor/tests/unit/usage.rs
  • Etemenanki/supervisor/tests/hot_swap.rs
  • Etemenanki/supervisor/tests/tracking.rs
  • Etemenanki/supervisor/tests/support/mod.rs
  • Etemenanki/protocols/tests/unit/vmess/accounts.rs
  • Etemenanki/app/tests/integration/e2e_reload.rs
  • katana/src/lower/mod.rs
  • katana/src/manager/node.rs

On a server, users change far more often than anything else in a spec, and a panel may hold many of them. The supervisor therefore keeps users out of every inbound: an inbound names a UserSet by tag, and a set can be replaced on its own without reconciling listeners, outbounds or routes. What connects a user to live traffic is a principal, a small per-inbound identity that the protocol’s user table hands back on a successful handshake, and a session, one accepted connection registered so it can be found by its user, revoked, closed and listed.

This page is for contributors who change supervisor/src/entity/user.rs, supervisor/src/entity/session.rs, supervisor/src/build/users.rs or the user paths in supervisor/src/supervisor.rs. It covers the user model, which credential each protocol admits by, how principals survive an apply, the session registry, binding a session on its first flow and the race that binding closes, the removal policies, the user-edit path (set_users, upsert_user, remove_user), and close and sessions. How listeners accept and open sessions is on Listeners and the serve loop; byte counting per session is on Per-user usage accounting; the per-user pacer behind speed_limit is on Tracking flows, stats and speed limits.

Component File → symbol Owns
User model entity/user.rs → UserId, UserName, UserSpec, Credentials, UserSet What a user is and every credential they may present.
Admission build/users.rs → credential_kind, admit, UserKeys Which users an inbound admits, by which credential, as which principal; which old principals are revoked; the UserKey of every user ever seen.
Principal topology/flow.rs → Principal The payload a user table returns and every flow carries: user key, label, and the revocation mark.
User tables build/inbound.rs → UserTable, user_table The protocol-specific table built from one inbound’s admissions.
Storing tables system/listener.rs → Listener::prepare_users, Listener::store_users Checking a table fits the listener, then storing it into the handler it serves.
Session registry entity/session.rs → Sessions, Session, enforce, close_after Every live session, the index by user, binding, revocation, closing and statistics.
Removal policy policy.rs → UserRemovalPolicy What revocation does to sessions already bound.
Actor paths supervisor.rs → Actor::prepare, Actor::commit, Actor::edit_users, Actor::close, Actor::sessions Running all of the above in order, serialised with applies.

What it leaves to others:

  • Deciding a credential is valid. Each protocol core checks the credential against its user table and returns the principal on success. See Server cores and the protocol pages.
  • Accepting connections. The accept loops open sessions and hand each connection a connector bound to its session. See Listeners and the serve loop.
  • Counting bytes and billing. A session’s Wire and the usage ledger account it opens are on Per-user usage accounting.
  • Pacing. A user’s speed_limit is enforced by the tracker’s per-user pacer. See Tracking flows, stats and speed limits.
  • Semantic checks of a whole spec. validate is on Validation and apply errors; this page covers only validate_admission, which the user-edit path runs on its own.
  • Where users come from. A front end lowers its own format into user sets. etemenanki-app makes one set per inbound from the users written inline under it, tagged with the inbound’s tag (etemenanki-app: from TOML to a spec); katana makes one set per node from the panel’s user list . “Where users come from”, under Data flow, summarises both.
supervisor/src/entity/user.rs
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum UserName {
Email(CompactString),
Username(CompactString),
}
impl UserName {
pub fn as_str(&self) -> &str;
}
impl fmt::Display for UserName; // writes as_str()
pub trait UserId: Clone + Eq + Ord + Hash + Debug + Send + Sync + 'static {
/// The name nodes surface for this user.
fn name(&self) -> UserName;
}
impl UserId for UserName {
fn name(&self) -> UserName { self.clone() }
}

UserName is what a node names a user by: the email of a Trojan, Shadowsocks or Hysteria 2 user, or the username of an account. It is never a secret, so it is what logs and the protocols’ own user labels carry. The two variants are different keys: Email("alice") and Username("alice") are two users.

UserId is what a front end keys its users by, and every generic in the supervisor (Spec<U>, UserSet<U>, Supervisor<U>, Selector<U>, SessionInfo<U>, UsageDelta<U>) is over it. It must be tied to the user’s auth info in one direction or the other: either derived from it, as etemenanki-app does by using UserName itself (app/src/lower.rs → pub type UserKey = UserName), or deriving it, as katana does with its panel id:

katana: src/lower/mod.rs
pub struct Uid(pub i64);
impl UserId for Uid {
fn name(&self) -> UserName {
UserName::Username(self.0.to_string().into())
}
}

So a katana user’s name, and every label derived from it, is the panel id in decimal.

supervisor/src/entity/user.rs (derives omitted)
/// One user: every credential they may present. Who they are is their key
/// in the [`UserSet`].
pub struct UserSpec {
pub credentials: Credentials,
pub speed_limit: Option<std::num::NonZeroU64>,
}
pub struct Credentials {
/// For VLESS and VMess.
pub uuid: Option<Uuid>,
/// For Trojan and Shadowsocks.
pub password: Option<Secret<String>>,
/// For Shadowsocks 2022: the user's PSK, decoded. Each inbound admitting
/// the user normalizes it to its own method's key length, since that is
/// where the size comes from.
pub ss2022_psk: Option<Secret<Vec<u8>>>,
/// For SOCKS, HTTP and Hysteria 2.
pub account: Option<Account>,
}
impl Credentials {
/// The credential of `kind`, if the user has one.
pub fn get(&self, kind: CredentialKind) -> Option<Credential>;
}
pub enum CredentialKind { Uuid, Password, Ss2022Psk, Account }
/// One credential: what an inbound admitted a user by.
pub enum Credential {
Uuid(Uuid),
Password(Secret<String>),
Ss2022Psk(Secret<Vec<u8>>),
Account(Account),
}
/// A user name and password pair.
pub struct Account {
pub user: CompactString,
pub pass: Secret<String>,
}

A user carries at most one credential of each kind, and who they are is their key in the set, not a field. An inbound reads only the kind its protocol takes, so one user can be admitted by inbounds of different protocols: a UUID on a VLESS inbound and a password on a Trojan inbound, for example. A Hysteria 2 inbound reads account, or password when its user_auth is Password (see the table below). Credentials::get(kind) clones the one credential of that kind into a Credential. Secret compares as its contents, so a changed password is a changed spec (see The spec: desired state).

The Shadowsocks 2022 PSK is stored decoded but not sized. Each inbound admitting the user normalises it to its own method’s key length with ss_2022::users::normalise_psk, since the size comes from the method: a key of the right length is used as is; a longer one is folded down to the first key-length bytes of its SHA-256 digest (ss_2022::crypto::fold_key, sing’s Key(key, keyLength)); a shorter one is refused with shadowsocks-2022: PSK too short (<len> < <key_len>).

entity/user.rs → Account (a name and password pair) is unrelated to entity/usage.rs → Account (a user’s usage account in the ledger).

speed_limit is payload bytes per second shared by every flow of the user, both directions, TCP and UDP, with a one-second burst; a transfer is charged in full after it moves and nothing else moves until the debt is repaid. None is unlimited. It is not part of how the user is admitted, so changing it keeps the user’s principal and sessions. The pacer is described on Tracking flows, stats and speed limits.

supervisor/src/entity/user.rs (derives omitted)
pub struct UserSet<U: UserId> {
pub tag: CompactString,
pub users: BTreeMap<U, UserSpec>,
}

Keyed by UserId, so a change to one user is a change to one entry and the set can be diffed entry by entry. Spec::user_sets holds every set; InboundSpec::users names the set an inbound admits, and InboundSpec::user_removal overrides the removal policy for that inbound’s sessions:

supervisor/src/topology/spec_plan/inbound.rs
pub struct InboundSpec {
pub tag: CompactString,
pub bind: BindSpec,
pub sniff: bool,
pub protocol: InboundProtocolSpec,
pub users: Option<CompactString>,
pub user_removal: Option<UserRemovalPolicy>,
}

The planner treats each set as a resource: Step::Build(Resource::UserSet(tag)) when the set is new or differs from the running one, Step::Reuse otherwise. A changed set on its own publishes no plane and binds, stops or swaps no listener (topology/spec_plan/plan.rs → plan); the apply only rebuilds the user tables of the inbounds admitting it. The user-set steps themselves do nothing in prepare or commit: they only land in ApplyReport::built and ApplyReport::reused. The work is driven by the inbounds, through their admissions (see “Users in an apply” below). A set that the new spec drops gets no step at all; an inbound still naming it fails validate first.

build/users.rs → credential_kind maps the protocol to the one kind it reads. An inbound admits every user of its set that holds a credential of that kind; a user without one is skipped on that inbound. InboundSpec::users = None is the protocol’s open or shared mode, which some protocols do not have.

InboundProtocolSpec credential_kind Read from UserTable variant, built from the admitted users users: None
Socks Account account Socks(SocksInbound): SocksAuth::Password, a map of user name to password and principal SocksAuth::None(Principal::anonymous()), no authentication
Http Account account Http(HttpServerConfig): accounts, a map of user name to password and principal no accounts; every client is anonymous
Trojan Password password Trojan(trojan::Validator) over TrojanUser entries refused: needs a set
Vless Uuid uuid Vless(vless::Validator) from (Uuid, principal) pairs refused: needs a set
Vmess Uuid uuid Vmess(Vec<(Uuid, Arc<Principal>)>), stored with AccountValidator::set_users refused: needs a set
Shadowsocks Password password Shadowsocks(Resolved) over ShadowsocksUser entries the server password only
Ss2022 Ss2022Psk ss2022_psk Ss2022(Ss2022Users): the server config with one Ss2022User per user, and ss_2022::Validator::from_config the server psk only
Hysteria2, user_auth = Account Account account Hysteria2(Authenticator) from Authenticator::user_pass shared_password, through Authenticator::shared
Hysteria2, user_auth = Password Password password Hysteria2(Authenticator) from Authenticator::passwords shared_password, through Authenticator::shared
Tun none — UserTable::None a TUN device admits no users

A TUN inbound that names a set anyway is not refused: validate_admission returns at once for an inbound whose protocol has no credential kind, admit admits nobody on it, and its flows stay anonymous. The set has no effect there.

Hysteria2InboundSpec holds the two Hysteria 2 choices:

supervisor/src/topology/spec_plan/inbound.rs (abridged)
pub struct Hysteria2InboundSpec {
/// The one password every client presents, when the inbound admits no
/// user set. Exactly one of this and [`InboundSpec::users`] is set.
pub shared_password: Option<Secret<String>>,
// ...
/// Which credential the inbound's user set is admitted by. Ignored when
/// [`shared_password`](Self::shared_password) is set.
pub user_auth: Hysteria2UserAuth,
}
/// Which credential a Hysteria 2 inbound's user set is admitted by.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Hysteria2UserAuth {
/// Upstream's `user:pass`, from each user's `account`.
#[default]
Account,
/// The whole auth string is the user's `password` (panel node agents send
/// the user's UUID).
Password,
}

validate enforces “exactly one” with the two Hysteria 2 errors under Errors below. Account is upstream Hysteria’s user:pass convention, split on the first colon with the name lower-cased on both sides. Password takes the whole auth string as one opaque credential. Panel-driven node agents admit Hysteria 2 users this way, with each user’s UUID as the password: katana lowers a user’s UUID into password when user_auth is Password. The shared-password authenticator is built with the empty label and Principal::anonymous(): the empty label is this crate’s spelling of an unlabelled user, since upstream’s fixed "user" would look like an account that does not exist. The authenticator itself is described on Hysteria 2: server.

Where a protocol carries a label of its own (TrojanUser::email, ShadowsocksUser::email, Ss2022User::email, the Hysteria 2 entry label), user_table fills it with the principal’s label. The Shadowsocks 2022 table also normalises each user’s PSK to the inbound’s method here; a key that does not normalise fails the table build with the message inbound <tag>: user <label>: <error>, although validate_admission refuses such a key before a table is ever built.

The protocol tables carry their own guards against a credential with two owners or an unusable credential. validate_admission (and, for the shared password, validate) refuses the same cases on the spec before a table is built, so a table the supervisor builds does not reach these guards in practice:

Table constructor Guard
ss_legacy::users::Resolved::new Users sharing a password share a key, so only the first is kept, with the warning shadowsocks: users "<first>" and "<other>" share a password; only "<first>" is matched.
ss_2022::Validator::from_config Users sharing an identity hash: only the first is kept, with the warning shadowsocks-2022: users "<first>" and "<other>" share a key; only "<first>" is matched.
Authenticator::user_pass hysteria2: a user needs both a name and a password, hysteria2: a username cannot contain ':' — it separates the two on the wire, hysteria2: two users share a name once lower-cased
Authenticator::passwords hysteria2: a user needs a credential, hysteria2: two users share one credential
Authenticator::shared hysteria2: the password must not be empty (pre-empted by validate’s the shared password must not be empty)

VMess is the one table not replaced as a whole: the handler keeps its Arc<AccountValidator>, and AccountValidator::set_users rebuilds the user snapshot inside it, then stores the new snapshot. It keeps the validator’s replay state: a user that stays (same UUID) keeps its place in the scan order, its hit count and its expanded key schedule, and takes its new payload; a new user joins at the end; a UUID listed twice is kept once, first payload winning; an auth id seen before the swap stays a replay after it. The validator itself is described on VMess: keys and authentication.

supervisor/src/entity/id.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SessionId(u64); // Display: "session#<n>"
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct UserKey(u64);
impl SessionId { pub const fn new(raw: u64) -> Self; pub const fn get(self) -> u64; }
impl UserKey { pub const fn new(raw: u64) -> Self; pub const fn get(self) -> u64; }

A SessionId names one connection an inbound accepted (a socket, one HTTP/2 stream of the gRPC transport, or a QUIC connection) and is unique within a supervisor’s lifetime. A UserKey is the data plane’s name for a user: an index the supervisor hands out for each UserId it has seen, so connections, pacers and usage accounts carry a u64 rather than the front end’s key type. The same file defines ResourceKind::UserSet (displayed as user set) and Resource::UserSet(tag) (displayed as user set <tag>), which errors and apply reports use. Its other identities belong to other pages: OutboundId (one version of one outbound, displayed as <tag>@v<n>) to Planning and applying a change and Outbounds, UDP fan-out and balancers, FlowId (displayed as flow#<n>) to Tracking flows, stats and speed limits, and RuleId (a rule’s index in the route table of the plane that routed the flow) to The plane: routing each flow.

build/users.rs hands the keys out:

supervisor/src/build/users.rs
#[derive(Clone)]
pub(crate) struct UserKeys<U> {
keys: HashMap<U, UserKey>,
ids: Vec<U>,
}
impl<U: UserId> UserKeys<U> {
pub(crate) fn key(&mut self, id: &U) -> UserKey; // hands out a new key on first sight
pub(crate) fn get(&self, id: &U) -> Option<UserKey>;
pub(crate) fn id(&self, key: UserKey) -> Option<&U>;
pub(crate) fn ids(&self) -> &[U]; // key n names ids()[n - 1]
}
pub(crate) fn id_of<U>(ids: &[U], key: UserKey) -> Option<&U>;

Keys count up from 1, so key n names ids[n - 1]; id_of does that lookup with checked arithmetic, and key 0 names nobody. A key outlives its user’s removal, so sessions a Keep policy left running can still be found by the user’s id, and their last usage is still reported under it. A user who leaves and comes back gets the same key. A key is handed out only when admit creates a principal, so a user who is in no admitting inbound’s set, or who lacks the credential kind of every inbound that reads their set, has no key.

supervisor/src/topology/flow.rs
pub type Flow = etemenanki_protocols::flow::Flow<Principal>;
#[derive(Debug)]
pub struct Principal {
user: Option<UserKey>,
label: CompactString,
revoked: OnceLock<UserRemovalPolicy>,
}
impl Principal {
pub fn user(user: UserKey, label: CompactString) -> Arc<Self>;
pub fn anonymous() -> Arc<Self>;
pub fn user_key(&self) -> Option<UserKey>;
pub fn label(&self) -> &str;
pub fn revoked(&self) -> Option<UserRemovalPolicy>;
pub(crate) fn revoke(&self, policy: UserRemovalPolicy);
}
pub fn anonymous_user() -> NetworkUser<Principal>;

A principal is who a flow belongs to: the user an inbound admitted it as, or nobody. It is the T of every protocol’s user table, so a successful handshake yields a Flow whose user.user_data is an Arc<Principal>, and a mux sub-flow inherits its carrier’s through Flow::toward.

  • Principal::user(key, label) is a user admitted by one inbound. The label is id.name().as_str(), the same string the protocols surface as the user label.
  • Principal::anonymous() has no key and an empty label. It is the payload of every open or shared mode, of TUN flows, and of flows the supervisor opens itself. anonymous_user() wraps it in a NetworkUser with an empty UserAuthorization::UsernamePassword, for a resolver’s queries through the route table (see Name resolution and the DNS service).
  • revoked is set once the user is no longer admitted by the inbound this principal belongs to, to that inbound’s removal policy. It is a OnceLock (let _ = self.revoked.set(policy)), so the mark is set once and revoked() reports the first policy. The actor revokes a principal when it leaves an inbound’s admissions, and a principal that left is never admitted again, so each principal is revoked once in practice. A session already bound to a revoked principal is handled by the policy Sessions::revoke was called with; a session that would bind to it only now is refused, whatever the policy.

Each inbound holds its own principal per user. Removing a user from one inbound, or taking away the credential that inbound reads, revokes that inbound’s principal without touching the same user’s sessions on another inbound.

The same file defines FlowContext (the inbound tag and captured source a flow entered with), which is described on The plane: routing each flow.

supervisor/src/build/users.rs (derives omitted)
pub(crate) struct Admitted {
pub principal: Arc<Principal>,
pub credential: Credential,
}
pub(crate) type Admissions<U> = BTreeMap<U, Admitted>;
pub(crate) fn credential_kind(protocol: &InboundProtocolSpec) -> Option<CredentialKind>;
pub(crate) fn admit<U: UserId>(
kind: Option<CredentialKind>,
set: Option<&UserSet<U>>,
old: Option<&Admissions<U>>,
keys: &mut UserKeys<U>,
) -> (Admissions<U>, Vec<Arc<Principal>>);

Admissions is who one inbound admits, keyed by user id, with the principal each user’s flows carry and the credential they were admitted by. The actor keeps one per inbound tag (Actor::admissions: HashMap<CompactString, Admissions<U>>). admit computes the next one and the principals it drops:

  1. With no kind (a TUN inbound) or no set (open mode), nobody is admitted.
  2. Otherwise, for each user of the set in key order, the credential of kind is read; a user without one is skipped.
  3. The user keeps their old principal when old admitted them and they still hold the exact credential they were admitted by. That credential is looked up by its own kind, not the inbound’s current kind, so an inbound whose protocol changed (a VLESS inbound turned Trojan, say) keeps its users’ principals, and with them their live sessions, as long as the old credential is unchanged.
  4. Otherwise the user gets a new principal, Principal::user(keys.key(id), id.name()).
  5. Every principal of old that is not the principal of the same user in the result (compared with Arc::ptr_eq) is returned as revoked: users who left, lost the credential, or changed it.

The new Admitted::credential is always the credential of the inbound’s current kind. speed_limit plays no part, which is why a speed-limit change keeps sessions.

flowchart TB
  U["user in the inbound's set"] --> K{"holds the kind the protocol reads?"}
  K -- no --> S["not admitted here"]
  K -- yes --> O{"admitted before, same credential still held?"}
  O -- yes --> P["keeps the old principal"]
  O -- no --> N["new principal with keys.key(id)"]
  S --> R["the user's old principal here, if any, is revoked"]
  N --> R
supervisor/src/entity/session.rs
pub(crate) struct Sessions {
root: CancellationToken,
tracker: TaskTracker, // where removal grace timers run
ledger: Arc<Ledger>,
next: AtomicU64, // the next SessionId, from 1
inner: Mutex<Inner>, // parking_lot
}
struct Inner {
live: HashMap<SessionId, Entry>,
by_user: HashMap<UserKey, HashSet<SessionId>>,
}
struct Entry {
inbound: CompactString,
source: Option<IpAddr>,
token: CancellationToken,
principal: Option<Arc<Principal>>,
wire: Arc<Wire>,
started: Instant,
}
pub(crate) enum Scope<'a> {
One(SessionId),
User(UserKey),
Inbound(&'a str),
All,
}

One Sessions belongs to each supervisor. Actor::new creates it with the supervisor’s root token, its TaskTracker and its usage Ledger, hands it to Tracker::new, and shares it with every accept loop through serve::Shared. The live map and the user index sit behind the one inner lock (parking_lot); next is an atomic that open advances while holding that lock. A session’s own bound flag and usage account live in its Session handle, and a revocation mark lives in each Principal. Nothing on this page holds the lock across an .await.

supervisor/src/entity/session.rs
impl Sessions {
pub(crate) fn new(root: CancellationToken, tracker: TaskTracker, ledger: Arc<Ledger>) -> Arc<Self>;
pub(crate) fn root(&self) -> &CancellationToken;
pub(crate) fn ledger(&self) -> &Arc<Ledger>;
pub(crate) fn open(
self: &Arc<Self>,
inbound: CompactString,
source: Option<IpAddr>,
wire: Wire,
stop: &CancellationToken,
) -> Option<Arc<Session>>;
pub(crate) fn revoke(&self, principals: &[Arc<Principal>], policy: UserRemovalPolicy);
pub(crate) fn close(&self, scope: Scope<'_>) -> usize;
pub(crate) fn stats(&self) -> Vec<SessionStats>;
fn remove(&self, id: SessionId);
}
Method Under inner Does
root no The root token, “cancelled at shutdown, once the grace for live sessions is over”. run_hysteria_inbound reads it to close its endpoint once the root fires (see Listeners and the serve loop).
ledger no The usage ledger sessions bind their users’ accounts in. The sampler calls ledger().reconcile() at the start of every tick (track/sampler.rs → Sampler::tick).
open whole call Returns None if stop, the accepting listener’s token, is cancelled. Otherwise takes the next id (fetch_add(1, Relaxed)), makes the session’s token a child of root, and inserts an unbound Entry.
revoke whole call For each principal: Principal::revoke(policy), then enforce(token, policy, tracker) on every session in by_user[key] whose bound principal is that same Arc. Anonymous principals are skipped. The policy applied is the one passed in.
close whole call Cancels every not-yet-cancelled token in the scope and returns how many it cancelled. Scope::One is one map lookup and Scope::User reads the user’s by_user entry, but Scope::Inbound scans every live session (live.values().filter(…)), as Scope::All does.
stats listing only Copies each entry and a clone of its Wire under the lock, reads the byte counts after releasing it, and sorts by id.
remove whole call Removes the entry and its id from by_user, dropping the user’s index entry when it empties. Only Session::drop calls it.

stats reads the counts outside the lock because reading a QUIC session’s bytes takes its connection’s lock, and opens and admissions must not wait on that.

Three private helpers in the same file finish the picture:

Helper Does
enforce(token, policy, tracker) Applies a removal policy to one bound session: Keep does nothing, Close cancels the token, CloseAfter(grace) calls close_after(token.clone(), grace, tracker). Only Sessions::revoke calls it.
closed() The io::ErrorKind::PermissionDenied error the session was closed.
revoked() The io::ErrorKind::PermissionDenied error the user was removed before the session opened a flow.
supervisor/src/entity/session.rs
pub struct Session {
id: SessionId,
token: CancellationToken,
sessions: Arc<Sessions>,
bound: AtomicBool,
wire: Arc<Wire>,
account: OnceLock<Arc<Account>>, // entity::usage::Account
}
impl Session {
pub fn id(&self) -> SessionId;
pub fn token(&self) -> &CancellationToken;
pub fn wire(&self) -> &Arc<Wire>;
pub fn admit(&self, principal: &Arc<Principal>) -> io::Result<()>;
}
impl Drop for Session;

A Session is the handle the connection’s tasks and its connector share as Arc<Session>. It is registered for as long as a handle exists. Its token is cancelled when the session is to be closed; the connection task, spawned with Shared::spawn_until(token, …), stops when it fires (see Listeners and the serve loop).

supervisor/src/entity/session.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionStats {
pub id: SessionId,
pub inbound: CompactString,
pub source: Option<IpAddr>,
pub user: Option<UserKey>,
pub user_label: CompactString, // empty for nobody
pub up: u64, // wire bytes from the client
pub down: u64, // wire bytes to the client
pub started: Instant,
}

Tracker::sessions() returns these directly (Sessions::stats()), without going through the actor. A front end reaches the tracker through Supervisor::tracker(); the webclient builds its session list this way (webclient/src/tracking.rs).

supervisor/src/policy.rs
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum UserRemovalPolicy {
Keep,
#[default]
Close,
CloseAfter(Duration),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Policies {
pub user_removal: UserRemovalPolicy,
pub drain: DrainPolicy,
}

A removal policy decides what happens to a user’s live sessions on an inbound when that inbound’s principal for them is revoked: the user was removed from the set, lost the credential the inbound reads, or changed it.

Policy Sessions bound to the principal New sessions presenting it
Keep Run on until they end on their own, and may still open flows. Refused.
Close (default) Token cancelled at once. Refused.
CloseAfter(grace) A timer task on the supervisor’s tracker cancels the token after grace, unless the session ends first. Refused.

Close is the default because revoking access is meant to take effect. Spec::policies.user_removal is the supervisor-wide default and InboundSpec::user_removal overrides it per inbound (supervisor.rs → removal_policy, inbound.user_removal.unwrap_or(spec.policies.user_removal)). etemenanki-app and katana both leave the override unset and use Policies::default(), so in both a revoked principal’s sessions are closed at once (Close). DrainPolicy, the same file’s policy for the live flows of a replaced or removed outbound version, is on Planning and applying a change.

The CloseAfter timer is:

supervisor/src/entity/session.rs
fn close_after(token: CancellationToken, grace: Duration, tracker: &TaskTracker) {
tracker.spawn(async move {
tokio::select! {
_ = token.cancelled() => {}
_ = tokio::time::sleep(grace) => token.cancel(),
}
});
}

enforce spawns one such task per bound session. It ends early when the token fires for any other reason: an explicit close, the session’s own end (Drop cancels the token), or shutdown (the token is a child of the root).

supervisor/src/supervisor.rs
impl<U: UserId> Supervisor<U> {
pub async fn set_users(&self, set: &str, users: BTreeMap<U, UserSpec>) -> Result<(), ApplyError>;
pub async fn upsert_user(&self, set: &str, id: U, user: UserSpec) -> Result<(), ApplyError>;
pub async fn remove_user(&self, set: &str, id: U) -> Result<bool, ApplyError>;
pub async fn close(&self, selector: Selector<U>) -> usize;
pub async fn sessions(&self) -> Vec<SessionInfo<U>>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Selector<U> {
Session(SessionId),
User(U), // every session of the user, on any inbound
Inbound(CompactString),
All,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionInfo<U> {
pub id: SessionId,
pub inbound: CompactString,
pub source: Option<IpAddr>,
pub user: Option<U>,
pub up: u64,
pub down: u64,
pub started: Instant,
}
enum UserEdit<U> {
Set(BTreeMap<U, UserSpec>),
Upsert(U, UserSpec),
Remove(U),
}

Each call is a command on the actor’s channel (mpsc::channel(16)): Command::Users(set, UserEdit, reply), Command::Close(selector, reply) or Command::Sessions(reply), each answered on a oneshot. They are therefore serialised with applies, update and shutdown. set_users replaces the set’s users, upsert_user adds a user or replaces their spec, remove_user removes one and returns whether they were in the set. All three go through the private Supervisor::users, which returns what Actor::edit_users returns: Ok(bool), whether the set changed. set_users and upsert_user drop the bool (.map(|_| ())); remove_user returns it.

Supervisor::ask maps a send to a closed channel, or a dropped reply, to ApplyError::Stopped. The user edits return that error; close turns it into 0 (unwrap_or(0)) and sessions into an empty list (unwrap_or_default()).

No front end at the pinned revisions calls set_users, upsert_user or remove_user. etemenanki-app and katana change users only by applying whole specs; katana starts each node’s supervisor with Supervisor::start and applies later specs with apply_with and ApplyOptions { allow_disruptive: true }. katana calls close(Selector::All) on a node after an apply that changes the node’s transport or protocol, or that follows a local config edit (src/manager/node.rs; see Node manager). Neither calls Supervisor::sessions.

etemenanki-app lowers the users written inline under each inbound into one set tagged with the inbound’s tag (app/src/lower.rs → InlineUsers; see etemenanki-app: from TOML to a spec), keyed by UserName (pub type UserKey = UserName):

App protocol Key Credential users: None when
trojan UserName::Email(email) password never: the inbound always names a set, even an empty one
vless, vmess UserName::Email(email) uuid never: the inbound always names a set, even an empty one
shadowsocks (legacy methods) UserName::Email(email) password users is empty
shadowsocks (2022 methods) UserName::Email(email) ss2022_psk, decoded from base64 users is empty
socks UserName::Username(user) account auth = "none"
http UserName::Username(user) account accounts is empty
hysteria2 UserName::Email(email), or UserName::Username(user) when email is empty account users is empty
tun — — always

The app never sets speed_limit (always None), always lowers Hysteria 2 with Hysteria2UserAuth::Account, and leaves user_removal unset. It refuses a user without the name its protocol keys it by: inbound <tag>: users[<n>] has no email; a user of this inbound is named by its email for an email-keyed user, and inbound <tag>: <list>[<n>] has no user for an account (<list> is accounts, or users for Hysteria 2). Two entries under one key are refused too (see Errors).

katana keys users by Uid, the panel id, and puts a node’s users into the one set its inbound admits, tagged USER_SET ("users"). Each user’s speed_limit is determine_rate(node, user): the smaller of the node’s and the user’s limit where both are non-zero, the non-zero one otherwise, and None when both are 0. katana skips a user it cannot lower, a user listed twice, and a user whose credential an earlier user already presents (Hysteria 2 account names compared lower-cased), each with a warn log that starts skipping user <uid>: , so its sets do not trip validate_admission’s shared-credential rule .

Listener Session per Opened by
TCP, plain or TLS or WebSocket accepted socket the accept loop, right after accept, before the transport handshake
TCP with the gRPC transport HTTP/2 stream the serve loop (Connection::serve_socket), once per stream the transport yields
Unix socket accepted socket, source = None the accept loop
Hysteria 2 QUIC connection, whose Wire reads the connection’s own byte counts the connector closure (make in run_hysteria_inbound) the listener calls per connection
TUN none TUN flows carry Principal::anonymous() and have no session: a device admits no users, so there is nobody to close them for

When the Hysteria 2 listener’s stop token is already cancelled, open returns None and make hands the connection a connector with no session and a token it cancelled itself, so the connection is closed at once: in the code’s words, “no session escapes the close of a removed inbound”.

Each session’s token is a child of the supervisor’s root, not of the listener, so stopping or reconfiguring a listener does not cancel it. The supervisor cancels a session’s token on a Close revocation or when a CloseAfter grace passes, on an explicit close, on Step::CloseSessions, on a refused first bind, when the last handle drops, and at shutdown through the root (see Cancellation below). The session is bound to a user later, by its first flow. The details of each accept path are on Listeners and the serve loop.

stateDiagram-v2
  [*] --> Unbound: opened at accept
  Unbound --> Bound: first flow, principal not revoked
  Unbound --> Cancelled: first flow presents a revoked principal
  Unbound --> Cancelled: close, inbound removed, shutdown
  Bound --> Cancelled: revoked under Close, or CloseAfter grace passes
  Bound --> Cancelled: close, inbound removed, shutdown
  Bound --> Deregistered: connection ends, last handle dropped
  Unbound --> Deregistered: connection ends, last handle dropped
  Cancelled --> Deregistered: last handle dropped
  Deregistered --> [*]

Session::drop does three things in order: it cancels the token (so a grace timer waiting on it ends now rather than at its deadline), closes the user’s usage account for this session if it was bound (Account::close folds in what the session carried since the ledger last settled it, which Ledger::reconcile does once per sampler tick; see Per-user usage accounting), and removes the entry and its index entry from the registry.

AppConnector::connect calls session.admit(&flow.user.user_data) for every flow of a connection that has a session, TCP and UDP alike, before anything is routed (see The plane: routing each flow). An Err becomes the flow’s dial result, so the protocol core sees a failed dial.

Session::admit runs these checks:

  1. Closed? If the token is cancelled, return closed(): the session was closed.

  2. Already bound? If bound (an AtomicBool, read with Acquire) is set, return Ok(()) at once. Later flows never take the registry lock, and a later flow presenting another principal does not rebind the session.

  3. Lock the registry. If the entry is gone, return closed().

  4. Bind, if nobody has. If the entry has no principal yet:

    • if principal.revoked() is set, cancel the token and return revoked(): the user was removed before the session opened a flow, whatever the policy was;
    • otherwise store the principal in the entry and, for a user principal, insert the session into by_user[key].

    If another flow bound the entry first, this step does nothing.

  5. Open the usage account. After releasing the lock, and only for the flow that bound a user principal: Ledger::bind(key, id, wire) opens the user’s account on this session’s Wire, and the result goes into account. The session cannot drop meanwhile, because its flow holds a handle. Ledger::bind starts the session’s watermark at (0, 0), and the Wire has counted since Sessions::open, so what the session carried before its first flow bound it, such as its protocol handshake, is billed to the user it binds to. A session that never binds a user bills nobody. What a Wire counts is on Per-user usage accounting.

  6. Mark bound. Store bound = true with Release and return Ok(()).

Both refusals are io::ErrorKind::PermissionDenied. Neither Session::admit nor Sessions::revoke logs anything.

A session whose first flow presents a revoked principal is refused under every policy, Keep included. It authenticated against a user table that has since dropped the user, and a removal policy only spares sessions that were live under the user at the removal.

A handshake authenticates against whatever user table it loaded: most protocol cores load their handler’s table when they are built, and a VMess handshake reads the validator’s user snapshot when it runs (see “Storing a table” below). An apply or a user edit replaces the table while handshakes are in flight, so a client can authenticate against the old table after the user was dropped from the new one. The commit is ordered so that such a handshake never yields a session its policy would spare:

  1. Every principal the change drops is revoked, under the registry lock, and its policy is applied to the sessions already bound to it.
  2. Only then is any new user table stored.

Session::admit checks revoked() under the same lock before it indexes the session. For a handshake that authenticated against the old table and returned an old principal P, there are two orders, and both end correctly:

sequenceDiagram
  participant C as Connection
  participant R as Sessions
  participant A as Actor commit
  alt the first flow binds before the revocation
    C->>R: admit(P), P not revoked
    R->>R: bind, index under the user key
    A->>R: revoke(P, policy)
    R->>C: policy applied to the bound session
  else the revocation comes first
    A->>R: revoke(P, policy)
    C->>R: admit(P)
    R->>C: PermissionDenied, token cancelled
  end
  A->>A: store the new user table

A handshake against the new table gets the user’s current principal, which is not revoked. A handshake by a user whose principal was kept gets that principal, also not revoked.

An apply handles users in its prepare and commit phases (the phases themselves are on Planning and applying a change). In prepare, after outbounds, balancers and routes, and before any handler is built:

  1. Stage keys. keys = self.keys.clone(): new keys are handed out on a copy, kept only if the apply commits.

  2. Admit per inbound. For every inbound of the new spec, find its set by InboundSpec::users, then admit(credential_kind(protocol), set, self.admissions.get(tag), &mut keys). The previous admissions are looked up by inbound tag, so an inbound that keeps its tag keeps its principals across a rebind, a handler swap or a change of set. Non-empty revocations are collected with the inbound’s removal policy.

  3. Build with the principals. A new or changed inbound gets a whole handler from build_handler(spec, admitted, circuits), whose user table carries the new admissions. An unchanged inbound with a running listener gets only a table, user_table(spec, admitted) then Listener::prepare_users, and only if its admissions differ from the running ones (same_admissions: same length, same ids in order, the same principal Arcs, equal credentials). An inbound whose users did not change is not touched at all.

The commit, which cannot fail, then runs:

  1. commit_keys(keys): adopt the staged keys and append the ids of new keys to the usage book’s name list, before any session can bind them.

  2. publish_speed_limits(&spec): give the tracker the limit of every user in the spec’s sets that has a key; a user who never got one (see Identities) is skipped. A user in several sets gets the smallest of their limits. Tracker::set_speed_limits then sets every pacer it already holds to its user’s new limit, or to unlimited when the user has none in the map, and creates a pacer for each newly limited key. Flows already open share their user’s pacer, so they are paced at the new rate too (Tracking flows, stats and speed limits). Done only here, so a refused apply cannot change live limits.

  3. Publish the plane, if the plan says so.

  4. Sessions::revoke for every collected revocation.

  5. Start new listeners, swap handlers, and store the prepared user tables (Listener::store_users).

  6. Run the plan’s remaining steps; Step::CloseSessions closes every session of an inbound whose tag is gone, bound or not and whatever the removal policy (Sessions::close(Scope::Inbound(tag))).

  7. Replace self.admissions with the new map. The admissions of a removed or renamed inbound go with it; its sessions were closed in the previous step.

A refused apply drops the staged keys, admissions and tables, and changes no principal, no table and no speed limit. The dry run check(spec) runs the same admission and table building on a fresh actor.

Actor::edit_users(set, edit) changes one set without reconciling anything else. It never plans, never publishes a plane, and never binds, swaps or stops a listener.

sequenceDiagram
  participant F as Front end
  participant A as Actor
  participant L as Listener
  participant R as Sessions
  F->>A: set_users, upsert_user or remove_user
  A->>A: edit a copy of the running spec
  loop every inbound admitting the set
    A->>A: validate_admission, admit on staged keys
    A->>L: user_table, then prepare_users
  end
  A->>A: commit_keys, publish_speed_limits
  A->>R: revoke every dropped principal
  A->>L: store_users for each inbound
  A->>A: keep admissions and the edited spec
  A-->>F: Ok
  1. Copy the running spec. A started actor always has one; without it the edit fails with ApplyError::Stopped.

  2. Find the set. With no set of that tag, fail with ApplyError::Invalid on Resource::UserSet(tag), reason no such user set.

  3. Edit the copy. Set replaces the map, Upsert inserts, Remove removes. Set and Upsert always count as a change; Remove counts only if the user was there. With no change, return Ok(false) and do nothing else.

  4. Prepare every inbound admitting the set (InboundSpec::users == Some(set)), in spec order, with keys staged on a copy:

    • validate_admission(inbound, set), the one rule of validate that reads a set’s users;
    • admit against the inbound’s running admissions;
    • find the listener by bind. Every inbound of a committed spec has one, and the code expects it (every running inbound has its listener);
    • user_table, then prepare_users; a failure is ApplyError::Build on Resource::Inbound(tag).

    Any failure returns before anything is stored, so a refused edit stores no table.

  5. Commit. commit_keys, publish_speed_limits over the edited spec, then Sessions::revoke for every inbound’s dropped principals under that inbound’s policy, all before the first table is stored, as in an apply. Then, per inbound, store_users and record the new admissions.

  6. Adopt the spec. state.spec becomes the edited copy. state.versions is untouched. Return Ok(true).

Unlike an apply, the edit path rebuilds and stores the table of every inbound admitting the set even when that inbound’s admissions did not change, for example after a speed-limit-only upsert_user. The table then carries the same principals, so no session is affected.

An edit of a set that no inbound names prepares nothing: it adopts the unchanged keys, publishes the speed limits of the edited spec (a user of that set has a key only if some inbound admitted the same id before), replaces the set in the running spec and returns Ok(true).

Because the edited spec becomes the running one, the next apply plans against it. A front end that later applies a spec of its own replaces what the edit did, with the same revocation rules.

Listener::prepare_users(table) refuses a table of another kind with the handler is not of the kind its listener serves: a stream listener takes a table that UserTable::fits the protocol of the handler it serves, a Hysteria 2 listener takes UserTable::Hysteria2, a TUN listener takes UserTable::None. The admission paths above build the table from the same inbound spec the running handler was built from, so this is a guard, not an expected failure. store_users then cannot fail:

Listener store_users does
Stream UserTable::store_into the handler currently in the listener’s watch channel: ArcSwap::store of the new table for SOCKS, HTTP, Trojan, VLESS, Shadowsocks and Shadowsocks 2022; AccountValidator::set_users for VMess.
Hysteria 2 Hy2Inbound::set_authenticator(Arc::new(authenticator)), which stores it into the listener’s current connection config.
TUN nothing.

store_users replaces the table inside the handler the listener currently serves. What a protocol reads it through differs:

  • The SOCKS, HTTP, Trojan, VLESS, Shadowsocks and Shadowsocks 2022 paths of serve_connection load the table once, with load_full(), when they build the core. A core built before the store keeps the table it loaded.
  • A VMess core holds the handler’s Arc<AccountValidator> itself (VMessCore::new(validator.clone(), …)) and authenticates against the user snapshot current at its handshake; in set_users’ own words, “handshakes in flight finish against whichever snapshot they loaded”.
  • A Hysteria 2 connection authenticates on its /auth request against the authenticator its connection config holds then.

A connection that has already authenticated keeps the principal it was admitted as. What reaches its session is the revocation.

Supervisor::close(selector) maps the selector to a scope and returns how many sessions it closed:

Selector Scope Reaches
Session(id) One(id) that session, if it is live
User(id) User(key), with key = keys.get(id) every session bound to any principal of the user, on every inbound, including sessions a Keep policy left running after a removal; 0 for an id that never had a key
Inbound(tag) Inbound(tag) every session of the inbound, bound or not
All All every session

A close only cancels tokens, and counts only those it cancelled, so overlapping closes do not count a session twice. The connection tasks then end as they see the cancellation; the entries leave the registry when their last handles drop. A closed session opens no further flow: admit answers the session was closed.

Supervisor::sessions() takes Sessions::stats() through the actor and maps each UserKey back to the front end’s id with keys.id(key), giving SessionInfo<U>. SessionInfo has no label: SessionStats::user_label is dropped in the mapping. A session not bound yet, or bound to an anonymous principal, has user: None. Front ends that need the list without waiting behind an apply read Supervisor::tracker().sessions() instead, which gives SessionStats with the UserKey and the label.

  • One principal per user per inbound. Revoking an inbound’s principal touches only the sessions bound to that Arc, never the same user’s sessions on another inbound. Pinned by revoking_under_close_ends_only_the_sessions_of_that_principal.
  • A principal lives as long as its credential. A user keeps their principal while they stay admitted by the exact credential they were admitted by, across applies, handler swaps and protocol changes, and a speed-limit change never replaces it. The speed-limit case is pinned end to end by a_speed_limit_change_keeps_the_users_sessions.
  • Revocation precedes the new table. In both an apply and a user edit, every dropped principal is revoked before any table is stored, and binding checks revocation under the lock revocation holds. The bind-side check is pinned by a_session_whose_first_flow_presents_a_revoked_principal_is_refused_under_every_policy; the ordering itself lives in Actor::commit and Actor::edit_users and has no test of its own.
  • A session binds once. The first admitted flow fixes the session’s principal; later flows never rebind it. Pinned by the_first_flow_binds_the_session_to_its_principal.
  • A revocation mark is set once. Principal::revoke sets a OnceLock, so revoked() reports the first policy. At bind, the mark alone decides the refusal, whatever the policy; Sessions::revoke applies the policy it is passed to the sessions already bound. The actor revokes a principal only as it leaves an inbound’s admissions. No test pins the first-wins rule.
  • Keys are stable. A UserKey is handed out once per UserId, counts from 1, and is published to the usage book’s name list (commit_keys) before a table that could bind it is stored. UsageBook::name panics with UserKey(<n>) was bound before it was published if that ever breaks, because taken bytes cannot be put back.
  • A refused change changes nothing. Keys, principals, tables and speed limits are staged in prepare and adopted only in commit. Pinned by a_refused_spec_changes_nothing, in which a refused spec that also swaps the user set leaves the old user logging in and the new one refused.
  • Users never touch the data plane. A user-set change alone publishes no plane and binds, stops or swaps no listener. Pinned by a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds.
  • Sessions outlive listeners, not the supervisor. Every session token is a child of the root. Pinned by cancelling_the_root_cancels_every_session.
  • A stopped listener opens no session. open checks the listener’s stop token under the registry lock, and the commit stops a removed inbound’s listener before it sweeps that inbound’s sessions under the same lock, so a connection accepted as its listener stops is either swept or never opened. A Hysteria 2 connection that finds no session gets a pre-cancelled token and is closed at once. Pinned by a_stopped_listener_opens_no_session.
  • The registry forgets what ended. A dropped session leaves both live and by_user, so a close by user never counts it. Pinned by a_dropped_session_leaves_the_user_index and a_session_is_listed_with_its_bytes_until_its_last_handle_drops.
  • Grace timers are supervisor work. They run on the supervisor’s TaskTracker and end with the session or with shutdown. Pinned by shutdown_ends_pending_grace_timers.

The ApplyError texts below are the Display of the error a library caller receives; the ones marked “app” are also what etemenanki-app --test prints after configuration invalid: for an inline user list.

Where Text Variant
Session::admit, first flow with a revoked principal the user was removed before the session opened a flow io::ErrorKind::PermissionDenied; the token is cancelled
Session::admit on a closed or deregistered session the session was closed io::ErrorKind::PermissionDenied
edit_users, unknown set user set <tag>: no such user set ApplyError::Invalid
any user edit once the actor has stopped the supervisor has shut down ApplyError::Stopped
validate_admission, two users with one credential (app) inbound tr-in: users alice@example.com and bob@example.com present the same credential ApplyError::Invalid
validate_admission, two account names equal (Hysteria 2: equal once lower-cased) (app) inbound hy2-in: users Alice and alice present the same username ApplyError::Invalid
validate_admission, a Shadowsocks 2022 user key that does not normalise (app) inbound ss-in: user alice@example.com: shadowsocks-2022: PSK too short (16 < 32) ApplyError::Invalid
validate_admission, a Hysteria 2 account with an empty name or password, or : in the name (app) inbound hy2-in: user a:b: a hysteria2 account needs a name without ':' and a password ApplyError::Invalid
validate_admission, an empty Hysteria 2 password credential inbound <tag>: user <name>: a hysteria2 password must not be empty ApplyError::Invalid
validate, an inbound naming a missing set inbound <tag> references unknown user set <set> ApplyError::UnknownReference
validate, Trojan, VLESS or VMess without a set inbound <tag>: the protocol has no open mode and needs a user set ApplyError::Invalid
validate, Hysteria 2 with both or neither inbound <tag>: a shared password and a user set cannot both be set; a credential would have two answers / inbound <tag>: hysteria2 needs a shared password or a user set ApplyError::Invalid
building a table the protocol refuses (app) building inbound ss-in failed: shadowsocks-2022: multi-user requires an aes-gcm method ApplyError::Build
prepare_users, a table of another kind (guard) building inbound <tag> failed: the handler is not of the kind its listener serves ApplyError::Build

validate_admission checks only the credential kind the inbound reads, so two users sharing a password are no conflict on an inbound that admits by account. It compares Hysteria 2 account names lower-cased, because Hysteria 2 compares them case-insensitively on the wire, and only there. etemenanki-app refuses two inline users with the same name before the supervisor sees them (inbound socks-in: accounts[0] and accounts[1] are both named "alice"), because the set is a map and the second entry would silently replace the first.

Supervisor::close answers 0 and Supervisor::sessions answers an empty list once the actor is gone, rather than an error: ask gives them ApplyError::Stopped, which they map away.

Token or task Parent Cancelled by
Session token the supervisor root (Sessions::root) Close revocation, a CloseAfter timer, close, Step::CloseSessions, a refused first bind, Session::drop, or the root at shutdown
CloseAfter timer task spawned on the supervisor’s TaskTracker ends when its session token fires, or cancels it after the grace
Root token none Actor::shutdown, after the shutdown grace

Actor::shutdown(grace) runs in this order:

  1. stops every listener;
  2. cancels every balancer probe token;
  3. closes the task tracker and waits up to grace for connections to end;
  4. cancels the root, and every session token with it, which ends every grace timer;
  5. waits for the tracker to empty (every session has then folded its last bytes into its ledger account);
  6. drops the listeners, then stops the background tasks (the sampler and, if one was set, the usage sink task) and awaits them.

What the sink reports then is on Per-user usage accounting. Supervisor::shutdown sends Command::Shutdown and waits for it. When every Supervisor handle is dropped without one, the actor’s command loop ends and it runs shutdown(Duration::ZERO) itself. Cancelling a token never removes a session from the registry by itself; removal happens when the last handle drops.

Item Value Where
First SessionId 1, then one more per open (AtomicU64, Relaxed) entity/session.rs → Sessions::new
First UserKey 1; key n names the nth id build/users.rs → UserKeys::key
Revocation mark per principal set once (OnceLock); revoked() reports the first policy topology/flow.rs → Principal::revoke
close cost One: one lookup; User: the user’s index entry; Inbound and All: every live session entity/session.rs → Sessions::close
Actor command channel 16 commands supervisor.rs → SupervisorBuilder::start
CloseAfter timers one task per bound session per revocation entity/session.rs → close_after
speed_limit a NonZeroU64 in bytes per second, or None for unlimited; the smallest across a user’s sets entity/user.rs, supervisor.rs → publish_speed_limits
Credentials per user one per CredentialKind, four kinds entity/user.rs → Credentials
Test File Pins
a_session_is_listed_with_its_bytes_until_its_last_handle_drops supervisor/tests/unit/session.rs stats fields for an unbound session, and deregistration on the last drop
a_dropped_session_leaves_the_user_index same a close by user does not count ended sessions
the_first_flow_binds_the_session_to_its_principal same the first principal wins; a later flow does not rebind
revoking_under_close_ends_only_the_sessions_of_that_principal same per-inbound principals; the revocation’s policy is recorded on the principal
revoking_under_keep_ends_no_session same Keep spares the session and its later flows
revoking_under_close_after_ends_the_session_once_the_grace_passes same the CloseAfter timer, on paused time
a_session_whose_first_flow_presents_a_revoked_principal_is_refused_under_every_policy same the race: refused with PermissionDenied, token cancelled, never bound
closing_one_session_closes_only_it, closing_a_user_closes_their_sessions_on_every_inbound, closing_an_inbound_closes_its_sessions_bound_or_not, closing_all_closes_every_session same each Scope
a_session_already_closed_is_not_counted_again same close counts
cancelling_the_root_cancels_every_session same tokens are children of the root
a_stopped_listener_opens_no_session same open checks the stop token
removing_a_user_closes_only_their_sessions_and_refuses_them_after supervisor/tests/hot_swap.rs remove_user under Close and Keep on a SOCKS inbound; the removed user’s next handshake fails; close(Selector::User)
a_speed_limit_change_keeps_the_users_sessions same set_users with a new limit keeps the principal and the session id
a_refused_spec_changes_nothing same a refused apply leaves the user set as it was
removing_an_inbound_closes_its_sessions same Step::CloseSessions
shutdown_ends_pending_grace_timers same an hour-long CloseAfter timer does not hold shutdown
a_hysteria2_user_set_admits_by_password supervisor/tests/tracking.rs Hysteria2UserAuth::Password: the whole auth string is the password, the label is the user’s name, a stranger is refused, close(Selector::User)
a_hysteria2_session_bills_its_quic_connections_bytes same a Hysteria 2 session’s label and close by user
two_users_may_not_present_the_same_credential, a_shared_credential_of_another_kind_is_no_conflict, hysteria2_usernames_collide_case_insensitively, a_hysteria2_username_may_not_hold_a_colon, a_hysteria2_password_may_not_be_empty, a_shadowsocks_2022_user_key_must_normalise_to_the_method supervisor/tests/unit/validate.rs validate_admission
an_inbound_must_name_a_known_user_set, trojan_vless_and_vmess_need_a_user_set same the user-set references validate checks
a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds supervisor/tests/unit/plan.rs users are planned apart from listeners and the plane
an_inbound_renamed_on_the_same_bind_swaps_and_closes_the_old_tags_sessions same sessions belong to a tag
every_byte_is_taken_exactly_once supervisor/tests/unit/usage.rs a property test over opens, binds, removals under Close and re-adds under the same key with a new principal
set_users_replaces_the_set_but_not_the_replay_state, set_users_keeps_a_surviving_user_where_its_hits_put_it protocols/tests/unit/vmess/accounts.rs how a VMess table is stored
a_reload_that_removes_a_user_closes_only_their_connections app/tests/integration/e2e_reload.rs the same rule through etemenanki-app’s reload: the removed user’s connection closes and they cannot log in, another user’s connection runs on

The shared test fixtures in supervisor/tests/support/mod.rs key SOCKS and HTTP users as UserName::Username (accounts, account) and VLESS users as UserName::Email (vless_users). No test calls upsert_user directly; it shares edit_users with the two edits that are tested. The new principal a changed credential gets is exercised only through the same code path as a removal. No test revokes one principal twice, so the first-wins rule of Principal::revoke has no test of its own.