Users, principals and sessions
Source files: 37 · checked against Etemenanki 555b7df · katana v4.1.1
Etemenanki/supervisor/src/entity/user.rsEtemenanki/supervisor/src/entity/session.rsEtemenanki/supervisor/src/entity/id.rsEtemenanki/supervisor/src/build/users.rsEtemenanki/supervisor/src/build/inbound.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/supervisor/src/topology/flow.rsEtemenanki/supervisor/src/topology/inbound/mod.rsEtemenanki/supervisor/src/topology/spec_plan/mod.rsEtemenanki/supervisor/src/topology/spec_plan/inbound.rsEtemenanki/supervisor/src/topology/spec_plan/plan.rsEtemenanki/supervisor/src/policy.rsEtemenanki/supervisor/src/supervisor.rsEtemenanki/supervisor/src/system/listener.rsEtemenanki/supervisor/src/serve.rsEtemenanki/supervisor/src/connector.rsEtemenanki/supervisor/src/track/mod.rsEtemenanki/supervisor/src/track/sampler.rsEtemenanki/supervisor/src/entity/usage.rsEtemenanki/protocols/src/vmess/accounts.rsEtemenanki/protocols/src/hysteria/server/authenticator.rsEtemenanki/protocols/src/ss_2022/users.rsEtemenanki/protocols/src/ss_legacy/users.rsEtemenanki/app/src/lower.rsEtemenanki/webclient/src/tracking.rsEtemenanki/supervisor/tests/unit/session.rsEtemenanki/supervisor/tests/unit/validate.rsEtemenanki/supervisor/tests/unit/plan.rsEtemenanki/supervisor/tests/unit/usage.rsEtemenanki/supervisor/tests/hot_swap.rsEtemenanki/supervisor/tests/tracking.rsEtemenanki/supervisor/tests/support/mod.rsEtemenanki/protocols/tests/unit/vmess/accounts.rsEtemenanki/app/tests/integration/e2e_reload.rskatana/src/lower/mod.rskatana/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.
Responsibilities
Section titled “Responsibilities”| 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
Wireand the usage ledger account it opens are on Per-user usage accounting. - Pacing. A user’s
speed_limitis enforced by the tracker’s per-user pacer. See Tracking flows, stats and speed limits. - Semantic checks of a whole spec.
validateis on Validation and apply errors; this page covers onlyvalidate_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.
Key types
Section titled “Key types”User names and ids
Section titled “User names and ids”#[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:
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.
A user and their credentials
Section titled “A user and their credentials”/// 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.
User sets
Section titled “User sets”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:
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.
Which credential an inbound admits by
Section titled “Which credential an inbound admits by”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:
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.
Identities
Section titled “Identities”#[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:
#[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.
Principal
Section titled “Principal”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 isid.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 aNetworkUserwith an emptyUserAuthorization::UsernamePassword, for a resolver’s queries through the route table (see Name resolution and the DNS service).revokedis set once the user is no longer admitted by the inbound this principal belongs to, to that inbound’s removal policy. It is aOnceLock(let _ = self.revoked.set(policy)), so the mark is set once andrevoked()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 policySessions::revokewas 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.
Admissions
Section titled “Admissions”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:
- With no kind (a TUN inbound) or no set (open mode), nobody is admitted.
- Otherwise, for each user of the set in key order, the credential of
kindis read; a user without one is skipped. - The user keeps their old principal when
oldadmitted 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. - Otherwise the user gets a new principal,
Principal::user(keys.key(id), id.name()). - Every principal of
oldthat is not the principal of the same user in the result (compared withArc::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
The session registry
Section titled “The session registry”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.
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. |
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).
#[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).
Removal policies
Section titled “Removal policies”#[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:
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).
The supervisor’s user API
Section titled “The supervisor’s user API”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.
Data flow
Section titled “Data flow”Where users come from
Section titled “Where users come from”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 .
Sessions and where they come from
Section titled “Sessions and where they come from”| 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.
A session’s life
Section titled “A session’s life”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.
Binding a session on its first flow
Section titled “Binding a session on its first flow”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:
-
Closed? If the token is cancelled, return
closed():the session was closed. -
Already bound? If
bound(anAtomicBool, read withAcquire) is set, returnOk(())at once. Later flows never take the registry lock, and a later flow presenting another principal does not rebind the session. -
Lock the registry. If the entry is gone, return
closed(). -
Bind, if nobody has. If the entry has no principal yet:
- if
principal.revoked()is set, cancel the token and returnrevoked():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.
- if
-
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’sWire, and the result goes intoaccount. The session cannot drop meanwhile, because its flow holds a handle.Ledger::bindstarts the session’s watermark at(0, 0), and theWirehas counted sinceSessions::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 aWirecounts is on Per-user usage accounting. -
Mark bound. Store
bound = truewithReleaseand returnOk(()).
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.
Revocation and the handshake race
Section titled “Revocation and the handshake race”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:
- Every principal the change drops is revoked, under the registry lock, and its policy is applied to the sessions already bound to it.
- 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.
Users in an apply
Section titled “Users in an apply”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:
-
Stage keys.
keys = self.keys.clone(): new keys are handed out on a copy, kept only if the apply commits. -
Admit per inbound. For every inbound of the new spec, find its set by
InboundSpec::users, thenadmit(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. -
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)thenListener::prepare_users, and only if its admissions differ from the running ones (same_admissions: same length, same ids in order, the same principalArcs, equal credentials). An inbound whose users did not change is not touched at all.
The commit, which cannot fail, then runs:
-
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. -
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_limitsthen 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. -
Publish the plane, if the plan says so.
-
Sessions::revokefor every collected revocation. -
Start new listeners, swap handlers, and store the prepared user tables (
Listener::store_users). -
Run the plan’s remaining steps;
Step::CloseSessionscloses every session of an inbound whose tag is gone, bound or not and whatever the removal policy (Sessions::close(Scope::Inbound(tag))). -
Replace
self.admissionswith 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.
A user edit
Section titled “A user edit”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
-
Copy the running spec. A started actor always has one; without it the edit fails with
ApplyError::Stopped. -
Find the set. With no set of that tag, fail with
ApplyError::InvalidonResource::UserSet(tag), reasonno such user set. -
Edit the copy.
Setreplaces the map,Upsertinserts,Removeremoves.SetandUpsertalways count as a change;Removecounts only if the user was there. With no change, returnOk(false)and do nothing else. -
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 ofvalidatethat reads a set’s users;admitagainst 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, thenprepare_users; a failure isApplyError::BuildonResource::Inbound(tag).
Any failure returns before anything is stored, so a refused edit stores no table.
-
Commit.
commit_keys,publish_speed_limitsover the edited spec, thenSessions::revokefor 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_usersand record the new admissions. -
Adopt the spec.
state.specbecomes the edited copy.state.versionsis untouched. ReturnOk(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.
Storing a table
Section titled “Storing a table”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_connectionload the table once, withload_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; inset_users’ own words, “handshakes in flight finish against whichever snapshot they loaded”. - A Hysteria 2 connection authenticates on its
/authrequest 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.
Closing sessions
Section titled “Closing sessions”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.
Listing sessions
Section titled “Listing sessions”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.
Invariants
Section titled “Invariants”- 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 byrevoking_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 inActor::commitandActor::edit_usersand 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::revokesets aOnceLock, sorevoked()reports the first policy. At bind, the mark alone decides the refusal, whatever the policy;Sessions::revokeapplies 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
UserKeyis handed out once perUserId, 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::namepanics withUserKey(<n>) was bound before it was publishedif 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.
openchecks 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 bya_stopped_listener_opens_no_session. - The registry forgets what ended. A dropped session leaves both
liveandby_user, so a close by user never counts it. Pinned bya_dropped_session_leaves_the_user_indexanda_session_is_listed_with_its_bytes_until_its_last_handle_drops. - Grace timers are supervisor work. They run on the supervisor’s
TaskTrackerand end with the session or with shutdown. Pinned byshutdown_ends_pending_grace_timers.
Failure paths and cancellation
Section titled “Failure paths and cancellation”Errors
Section titled “Errors”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.
Cancellation
Section titled “Cancellation”| 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:
- stops every listener;
- cancels every balancer probe token;
- closes the task tracker and waits up to
gracefor connections to end; - cancels the root, and every session token with it, which ends every grace timer;
- waits for the tracker to empty (every session has then folded its last bytes into its ledger account);
- 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.
Limits
Section titled “Limits”| 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.