用户、principal 与会话
源码文件:37 个 · 核对版本 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
在服务端,用户变动的频率远高于 spec(期望状态)中的其他任何内容,而且一个面板可能管着大量用户。因此 supervisor(监管器)把用户放在所有入站之外:入站按 tag 引用一个 UserSet,一个用户集可以单独替换,而不必调和监听器、出站或路由。把用户和实时流量联系起来的是两样东西:一是 principal(身份主体),一个按入站区分的小型身份,由协议的用户表在握手成功时交回;二是会话,即一个被接受的连接,它登记在册,以便按用户查找、撤销、关闭和列出。
本页面向修改 supervisor/src/entity/user.rs、supervisor/src/entity/session.rs、supervisor/src/build/users.rs 或 supervisor/src/supervisor.rs 中用户相关路径的贡献者。内容包括:用户模型、各协议按哪种凭据准入、principal 如何在一次应用(apply)之后保留下来、会话注册表、会话在第一个流上的绑定以及这种绑定消除的竞争、移除策略、用户编辑路径(set_users、upsert_user、remove_user),以及 close 和 sessions。监听器如何接受连接并打开会话见监听器与服务循环;按会话统计字节见按用户的用量计费;speed_limit 背后按用户的 pacer(限速器)见流跟踪、统计与限速。
| 组件 | 文件 → 符号 | 负责 |
|---|---|---|
| 用户模型 | entity/user.rs → UserId、UserName、UserSpec、Credentials、UserSet |
用户是什么,以及用户可能出示的每一种凭据。 |
| 准入 | build/users.rs → credential_kind、admit、UserKeys |
入站准入哪些用户、凭哪种凭据、以哪个 principal 准入;哪些旧 principal 被撤销;以及见过的每个用户的 UserKey。 |
| Principal | topology/flow.rs → Principal |
用户表返回、每个流都携带的载荷:用户 key、标签和撤销标记。 |
| 用户表 | build/inbound.rs → UserTable、user_table |
由一个入站的准入结果构建的协议专用表。 |
| 存储用户表 | system/listener.rs → Listener::prepare_users、Listener::store_users |
检查一张表是否适合该监听器,然后把它存入监听器所服务的 handler(处理器)。 |
| 会话注册表 | entity/session.rs → Sessions、Session、enforce、close_after |
每个存活会话、按用户的索引、绑定、撤销、关闭和统计。 |
| 移除策略 | policy.rs → UserRemovalPolicy |
撤销对已绑定的会话做什么。 |
| Actor 路径 | supervisor.rs → Actor::prepare、Actor::commit、Actor::edit_users、Actor::close、Actor::sessions |
按顺序执行以上所有工作,并与应用串行。 |
它交给其他部分的工作:
- 判断凭据是否有效。 每个协议核心用自己的用户表检查凭据,成功时返回 principal。见服务端协议核心和各协议页面。
- 接受连接。 accept 循环打开会话,并交给每个连接一个绑定到其会话的 connector(连接器)。见监听器与服务循环。
- 统计字节与计费。 会话的
Wire以及它打开的用量账本账户见按用户的用量计费。 - 限速。 用户的
speed_limit由 tracker 按用户的 pacer 执行。见流跟踪、统计与限速。 - 整个 spec 的语义检查。
validate见校验与应用错误;本页只讲validate_admission,用户编辑路径会单独运行它。 - 用户从哪里来。 前端程序把自己的格式降为 spec 中的用户集。etemenanki-app 为每个入站用写在它下面的内联用户建一个用户集,tag 与入站的 tag 相同(etemenanki-app:从 TOML 到 spec);katana 为每个节点用面板的用户列表建一个用户集 。“数据流”下的“用户从哪里来”一节概括了两者。
用户名与用户 id
Section titled “用户名与用户 id”#[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; // 输出 as_str()
pub trait UserId: Clone + Eq + Ord + Hash + Debug + Send + Sync + 'static { /// 节点对外展示的该用户的名字。 fn name(&self) -> UserName;}
impl UserId for UserName { fn name(&self) -> UserName { self.clone() }}UserName 是节点称呼一个用户所用的名字:Trojan、Shadowsocks 或 Hysteria 2 用户的 email,或者账户的用户名。它从来不是机密,所以日志和各协议自己的用户标签带的都是它。两个变体是不同的 key:Email("alice") 和 Username("alice") 是两个用户。
UserId 是前端程序给用户建索引所用的 key,supervisor 中的每个泛型(Spec<U>、UserSet<U>、Supervisor<U>、Selector<U>、SessionInfo<U>、UsageDelta<U>)都以它为参数。它必须在某个方向上与用户的认证信息挂钩:要么由认证信息而来,如 etemenanki-app 直接把 UserName 本身当作 key(app/src/lower.rs → pub type UserKey = UserName);要么反过来由它派生出名字,如 katana 用面板 id:
pub struct Uid(pub i64);
impl UserId for Uid { fn name(&self) -> UserName { UserName::Username(self.0.to_string().into()) }}所以 katana 用户的名字,以及由它派生的每个标签,都是十进制的面板 id。
用户及其凭据
Section titled “用户及其凭据”/// 一个用户:他可能出示的每一种凭据。他是谁,由他在/// [`UserSet`] 中的 key 决定。pub struct UserSpec { pub credentials: Credentials, pub speed_limit: Option<std::num::NonZeroU64>,}
pub struct Credentials { /// 用于 VLESS 和 VMess。 pub uuid: Option<Uuid>, /// 用于 Trojan 和 Shadowsocks。 pub password: Option<Secret<String>>, /// 用于 Shadowsocks 2022:用户的 PSK,已解码。每个准入该用户的入站 /// 把它规范化为自己加密方法的密钥长度,因为长度由方法决定。 pub ss2022_psk: Option<Secret<Vec<u8>>>, /// 用于 SOCKS、HTTP 和 Hysteria 2。 pub account: Option<Account>,}
impl Credentials { /// `kind` 类的凭据,如果用户有的话。 pub fn get(&self, kind: CredentialKind) -> Option<Credential>;}
pub enum CredentialKind { Uuid, Password, Ss2022Psk, Account }
/// 一个凭据:入站准入用户时所凭的东西。pub enum Credential { Uuid(Uuid), Password(Secret<String>), Ss2022Psk(Secret<Vec<u8>>), Account(Account),}
/// 一对用户名和密码。pub struct Account { pub user: CompactString, pub pass: Secret<String>,}一个用户每种凭据至多一个;他是谁由他在用户集中的 key 决定,而不是由某个字段决定。入站只读取自己协议所需的那一种凭据,所以同一个用户可以被不同协议的入站准入,例如在 VLESS 入站上凭 UUID,在 Trojan 入站上凭密码。Hysteria 2 入站读取 account,当它的 user_auth 为 Password 时则读取 password(见下表)。Credentials::get(kind) 把该种类的那一个凭据克隆成一个 Credential。Secret 按内容比较,所以密码变了就是 spec 变了(见 spec:期望状态)。
Shadowsocks 2022 的 PSK 以解码后的形式存储,但不定长。每个准入该用户的入站用 ss_2022::users::normalise_psk 把它规范化为自己方法的密钥长度,因为长度由方法决定:长度正好的密钥原样使用;更长的密钥被折叠为其 SHA-256 摘要的前 key-length 个字节(ss_2022::crypto::fold_key,即 sing 的 Key(key, keyLength));更短的密钥被拒绝,报错 shadowsocks-2022: PSK too short (<len> < <key_len>)。
entity/user.rs → Account(一对用户名和密码)与 entity/usage.rs → Account(用量账本中用户的用量账户)没有关系。
speed_limit 是每秒的载荷字节数,由该用户的所有流共享,双向、TCP 和 UDP 都算在内,允许一秒的突发;一次传输在完成之后按全额扣除,欠额还清之前其他传输都不能进行。None 表示不限速。它不参与用户的准入,所以修改它会保留用户的 principal 和会话。pacer 见流跟踪、统计与限速。
pub struct UserSet<U: UserId> { pub tag: CompactString, pub users: BTreeMap<U, UserSpec>,}用户集以 UserId 为 key,所以对一个用户的修改就是对一个条目的修改,用户集可以逐条比较差异。Spec::user_sets 保存所有用户集;InboundSpec::users 指定入站准入的用户集,InboundSpec::user_removal 为该入站的会话覆盖移除策略:
pub struct InboundSpec { pub tag: CompactString, pub bind: BindSpec, pub sniff: bool, pub protocol: InboundProtocolSpec, pub users: Option<CompactString>, pub user_removal: Option<UserRemovalPolicy>,}规划器把每个用户集当作一种资源:用户集是新的或与运行中的不同时,生成 Step::Build(Resource::UserSet(tag)),否则生成 Step::Reuse。仅仅改变用户集不会发布 plane(数据平面),也不会绑定、停止或替换任何监听器(topology/spec_plan/plan.rs → plan);这次应用只重建准入该用户集的入站的用户表。用户集步骤本身在准备(prepare)和提交(commit)阶段什么都不做:它们只出现在 ApplyReport::built 和 ApplyReport::reused 里。实际工作由入站通过各自的准入结果来驱动(见下文“一次应用中的用户”)。新 spec 删掉的用户集不会得到任何步骤;仍引用它的入站会先在 validate 中失败。
入站按哪种凭据准入
Section titled “入站按哪种凭据准入”build/users.rs → credential_kind 把协议映射到它读取的那一种凭据。入站准入其用户集中所有持有该种凭据的用户;没有该凭据的用户在这个入站上被跳过。InboundSpec::users = None 是协议的开放模式或共享模式,有些协议没有这种模式。
InboundProtocolSpec |
credential_kind |
读取字段 | UserTable 变体,由被准入的用户构建 |
users: None |
|---|---|---|---|---|
Socks |
Account |
account |
Socks(SocksInbound):SocksAuth::Password,用户名到密码和 principal 的映射 |
SocksAuth::None(Principal::anonymous()),不认证 |
Http |
Account |
account |
Http(HttpServerConfig):accounts,用户名到密码和 principal 的映射 |
没有账户;每个客户端都是 anonymous |
Trojan |
Password |
password |
Trojan(trojan::Validator),基于 TrojanUser 条目 |
拒绝:必须有用户集 |
Vless |
Uuid |
uuid |
Vless(vless::Validator),由 (Uuid, principal) 对构建 |
拒绝:必须有用户集 |
Vmess |
Uuid |
uuid |
Vmess(Vec<(Uuid, Arc<Principal>)>),用 AccountValidator::set_users 存储 |
拒绝:必须有用户集 |
Shadowsocks |
Password |
password |
Shadowsocks(Resolved),基于 ShadowsocksUser 条目 |
只用服务端 password |
Ss2022 |
Ss2022Psk |
ss2022_psk |
Ss2022(Ss2022Users):服务端配置,每个用户一个 Ss2022User,以及 ss_2022::Validator::from_config |
只用服务端 psk |
Hysteria2,user_auth = Account |
Account |
account |
Hysteria2(Authenticator),由 Authenticator::user_pass 构建 |
shared_password,通过 Authenticator::shared |
Hysteria2,user_auth = Password |
Password |
password |
Hysteria2(Authenticator),由 Authenticator::passwords 构建 |
shared_password,通过 Authenticator::shared |
Tun |
无 | — | UserTable::None |
TUN 设备不准入任何用户 |
TUN 入站即使引用了用户集也不会被拒绝:对于协议没有凭据种类的入站,validate_admission 立即返回,admit 在它上面不准入任何人,它的流保持匿名。用户集在那里不起作用。
Hysteria2InboundSpec 保存 Hysteria 2 的两个选择:
pub struct Hysteria2InboundSpec { /// 入站不准入用户集时,每个客户端出示的那一个密码。 /// 它与 [`InboundSpec::users`] 恰好设置其中一个。 pub shared_password: Option<Secret<String>>, // ... /// 入站的用户集凭哪种凭据准入。设置了 /// [`shared_password`](Self::shared_password) 时忽略。 pub user_auth: Hysteria2UserAuth,}
/// Hysteria 2 入站的用户集凭哪种凭据准入。#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub enum Hysteria2UserAuth { /// 上游的 `user:pass`,取自每个用户的 `account`。 #[default] Account, /// 整个认证字符串就是用户的 `password`(面板节点 agent 发送的是 /// 用户的 UUID)。 Password,}validate 用下文“错误”中的两条 Hysteria 2 错误来保证“恰好一个”。Account 是上游 Hysteria 的 user:pass 约定:在第一个冒号处拆分,表里的用户名和客户端发来的用户名都转为小写。Password 把整个认证字符串当作一个不透明的凭据。面板驱动的节点 agent 用这种方式准入 Hysteria 2 用户,以每个用户的 UUID 作为密码:当 user_auth 为 Password 时,katana 把用户的 UUID 降为 spec 中的 password。共享密码认证器用空标签和 Principal::anonymous() 构建:空标签是本 crate 对“无标签用户”的写法,因为上游固定使用的 "user" 看起来会像一个并不存在的账户。认证器本身见 Hysteria 2:服务端。
如果协议有自己的标签字段(TrojanUser::email、ShadowsocksUser::email、Ss2022User::email、Hysteria 2 条目的标签),user_table 会用 principal 的标签填充它。Shadowsocks 2022 的表还会在这里把每个用户的 PSK 规范化为该入站的方法;无法规范化的密钥会让建表失败,消息为 inbound <tag>: user <label>: <error>,不过 validate_admission 早在建表之前就会拒绝这样的密钥。
各协议的表自带防护,应对一个凭据有两个所有者或凭据不可用的情形。validate_admission(对共享密码则是 validate)会在建表之前就在 spec 上拒绝同样的情形,所以 supervisor 构建的表实际上不会走到这些防护:
| 表构造函数 | 防护 |
|---|---|
ss_legacy::users::Resolved::new |
共用同一密码的用户也共用同一把密钥,所以只保留第一个,并发出警告 shadowsocks: users "<first>" and "<other>" share a password; only "<first>" is matched。 |
ss_2022::Validator::from_config |
共用同一 identity hash 的用户:只保留第一个,并发出警告 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(会先被 validate 的 the shared password must not be empty 拦下) |
VMess 是唯一一张不整体替换的表:handler 保留自己的 Arc<AccountValidator>,由 AccountValidator::set_users 在其中重建用户快照,再存入新快照。它保留验证器的重放状态:留下的用户(UUID 不变)保持其在扫描顺序中的位置、命中计数和展开后的密钥调度,并换上新的载荷;新用户加到末尾;列出两次的 UUID 只保留一次,以第一个载荷为准;替换之前见过的 auth id 在替换之后仍算重放。验证器本身见 VMess:密钥与认证。
#[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; }SessionId 指称入站接受的一个连接(一个 socket、gRPC 传输层的一条 HTTP/2 stream,或一条 QUIC 连接),在一个 supervisor 的生命周期内唯一。UserKey 是数据平面对用户的称呼:supervisor 为它见过的每个 UserId 分配一个索引,于是连接、pacer 和用量账户携带的是一个 u64,而不是前端程序的 key 类型。同一文件还定义了 ResourceKind::UserSet(显示为 user set)和 Resource::UserSet(tag)(显示为 user set <tag>),供错误和应用报告使用。文件中的其他标识属于别的页面:OutboundId(一个出站的一个版本,显示为 <tag>@v<n>)见规划与应用变更和出站、UDP 扇出与负载均衡器;FlowId(显示为 flow#<n>)见流跟踪、统计与限速;RuleId(规则在为该流选路的那个 plane 的路由表中的索引)见 plane:为每个流选路。
key 由 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; // 第一次见到时分配新 key 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 指称 ids()[n - 1]}
pub(crate) fn id_of<U>(ids: &[U], key: UserKey) -> Option<&U>;key 从 1 开始递增,所以 key n 指称 ids[n - 1];id_of 用带检查的算术完成这次查找,key 0 不指称任何人。key 比它的用户活得久:Keep 策略保留下来的会话仍能按用户 id 找到,它们最后的用量也仍记在这个 id 名下。离开后又回来的用户拿到同一个 key。只有 admit 创建 principal 时才分配 key,所以一个不在任何准入入站的用户集里的用户,或者缺少所有读取其用户集的入站所需凭据种类的用户,都没有 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>;principal 表示一个流属于谁:入站准入它时的那个用户,或者无人。它是每个协议用户表的 T,所以一次成功的握手产生一个 Flow,其 user.user_data 是一个 Arc<Principal>;mux 子流通过 Flow::toward 继承其承载连接的 principal。
Principal::user(key, label)是被某一个入站准入的用户。标签是id.name().as_str(),与各协议对外展示的用户标签是同一个字符串。Principal::anonymous()没有 key,标签为空。它是所有开放模式或共享模式、TUN 流以及 supervisor 自己打开的流的载荷。anonymous_user()把它包装成一个带空UserAuthorization::UsernamePassword的NetworkUser,供解析器经由路由表发出的查询使用(见域名解析与 DNS 服务)。revoked在用户不再被该 principal 所属的入站准入时设置,值为该入站的移除策略。它是一个OnceLock(let _ = self.revoked.set(policy)),所以标记只设置一次,revoked()报告的是第一次的策略。actor 在 principal 离开某个入站的准入结果时撤销它,而离开的 principal 不会再被准入,所以实际上每个 principal 只被撤销一次。已经绑定到被撤销 principal 的会话,按调用Sessions::revoke时传入的策略处理;此时才要绑定到它的会话则一律被拒绝,不论策略是什么。
每个入站为每个用户持有自己的 principal。把用户从一个入站移除,或者拿走该入站读取的凭据,会撤销该入站的 principal,而不影响同一用户在其他入站上的会话。
同一文件还定义了 FlowContext(流进入时的入站 tag 和捕获的源地址),见 plane:为每个流选路。
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 表示一个入站准入了谁,以用户 id 为 key,记录每个用户的流所携带的 principal,以及用户被准入时所凭的凭据。actor 按入站 tag 各保存一份(Actor::admissions: HashMap<CompactString, Admissions<U>>)。admit 计算下一份准入结果以及它丢弃的 principal:
- 没有凭据种类(TUN 入站)或没有用户集(开放模式)时,不准入任何人。
- 否则按 key 的顺序遍历用户集中的每个用户,读取
kind类的凭据;没有的用户被跳过。 - 如果
old准入过该用户,并且用户仍持有当初准入时所凭的那个凭据(完全一致),用户就保留旧 principal。这个凭据按它自己的种类查找,而不是按入站当前的种类,所以协议变了的入站(比如 VLESS 入站改成了 Trojan)只要旧凭据没变,就保留其用户的 principal,也就保留了他们的存活会话。 - 否则用户得到一个新 principal:
Principal::user(keys.key(id), id.name())。 old中每个不是结果中同一用户 principal 的 principal(用Arc::ptr_eq比较)都作为已撤销返回:离开的用户、失去凭据的用户,或者改了凭据的用户。
新的 Admitted::credential 总是入站当前种类的凭据。speed_limit 不参与准入,这就是修改限速会保留会话的原因。
flowchart TB
U["入站用户集中的用户"] --> K{"持有协议读取的凭据种类?"}
K -- 否 --> S["不在此入站准入"]
K -- 是 --> O{"之前被准入过,且仍持有同一凭据?"}
O -- 是 --> P["保留旧 principal"]
O -- 否 --> N["用 keys.key(id) 创建新 principal"]
S --> R["用户在此入站的旧 principal(如有)被撤销"]
N --> R
pub(crate) struct Sessions { root: CancellationToken, tracker: TaskTracker, // 移除宽限计时器在这里运行 ledger: Arc<Ledger>, next: AtomicU64, // 下一个 SessionId,从 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,}每个 supervisor 有一个 Sessions。Actor::new 用 supervisor 的根 token、它的 TaskTracker 和它的用量 Ledger 创建它,把它交给 Tracker::new,并通过 serve::Shared 与每个 accept 循环共享。存活表和用户索引都在同一把 inner 锁(parking_lot)之后;next 是一个原子量,由 open 在持有这把锁时推进。会话自己的 bound 标志和用量账户在它的 Session 句柄里,撤销标记在每个 Principal 里。本页涉及的代码都不会跨 .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);}| 方法 | 持有 inner |
作用 |
|---|---|---|
root |
否 | 根 token,按代码注释的说法是 “cancelled at shutdown, once the grace for live sessions is over”,即在关停(shutdown)时、存活会话的宽限期结束后取消。run_hysteria_inbound 读取它,以便在根 token 触发后关闭自己的 endpoint(见监听器与服务循环)。 |
ledger |
否 | 会话在其中绑定用户账户的用量账本。采样器在每个 tick 开始时调用 ledger().reconcile()(track/sampler.rs → Sampler::tick)。 |
open |
整个调用 | 如果 stop(接受该连接的监听器的 token)已取消,返回 None。否则取下一个 id(fetch_add(1, Relaxed)),把会话的 token 设为 root 的子 token,并插入一个未绑定的 Entry。 |
revoke |
整个调用 | 对每个 principal:先 Principal::revoke(policy),再对 by_user[key] 中绑定的 principal 正是同一个 Arc 的每个会话执行 enforce(token, policy, tracker)。匿名 principal 被跳过。应用的是传入的策略。 |
close |
整个调用 | 取消范围内每个尚未取消的 token,返回取消的数量。Scope::One 是一次表查找,Scope::User 读取该用户的 by_user 条目,而 Scope::Inbound 与 Scope::All 一样会扫描每个存活会话(live.values().filter(…))。 |
stats |
仅在列出时 | 在锁内复制每个条目及其 Wire 的克隆,释放锁之后再读取字节计数,并按 id 排序。 |
remove |
整个调用 | 移除条目,并把它的 id 从 by_user 中删除,用户的索引条目为空时一并删除。只有 Session::drop 调用它。 |
stats 在锁外读取计数,因为读取 QUIC 会话的字节数要拿该连接的锁,而打开会话和准入不应等待这把锁。
同一文件中的三个私有辅助函数补全了全貌:
| 辅助函数 | 作用 |
|---|---|
enforce(token, policy, tracker) |
对一个已绑定的会话应用移除策略:Keep 什么都不做,Close 取消 token,CloseAfter(grace) 调用 close_after(token.clone(), grace, tracker)。只有 Sessions::revoke 调用它。 |
closed() |
io::ErrorKind::PermissionDenied 错误 the session was closed。 |
revoked() |
io::ErrorKind::PermissionDenied 错误 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;Session 是连接的各个任务与其 connector 以 Arc<Session> 形式共享的句柄。只要还有句柄存在,它就一直登记在册。会话要被关闭时,它的 token 被取消;连接任务用 Shared::spawn_until(token, …) spawn,token 触发时停止(见监听器与服务循环)。
#[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, // 无人时为空 pub up: u64, // 来自客户端的线上字节数 pub down: u64, // 发往客户端的线上字节数 pub started: Instant,}Tracker::sessions() 直接返回这些(Sessions::stats()),不经过 actor。前端程序通过 Supervisor::tracker() 拿到 tracker;webclient 就是这样构建它的会话列表的(webclient/src/tracking.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,}移除策略决定:当某个入站为某个用户持有的 principal 被撤销时(用户被移出用户集、失去该入站读取的凭据,或者改了这个凭据),该用户在这个入站上的存活会话会怎样。
| 策略 | 绑定到该 principal 的会话 | 出示它的新会话 |
|---|---|---|
Keep |
继续运行,直到自行结束,并且仍可打开流。 | 拒绝。 |
Close(默认) |
token 立即取消。 | 拒绝。 |
CloseAfter(grace) |
supervisor 的 TaskTracker 上的一个计时器任务在 grace 之后取消 token,除非会话先行结束。 |
拒绝。 |
Close 是默认值,因为撤销访问就是要让它生效。Spec::policies.user_removal 是 supervisor 范围的默认值,InboundSpec::user_removal 按入站覆盖它(supervisor.rs → removal_policy,即 inbound.user_removal.unwrap_or(spec.policies.user_removal))。etemenanki-app 和 katana 都不设置覆盖值,使用 Policies::default(),所以在两者中,被撤销 principal 的会话都会立即关闭(Close)。同一文件中的 DrainPolicy 是针对被替换或被移除的出站版本上存活流的策略,见规划与应用变更。
CloseAfter 计时器如下:
fn close_after(token: CancellationToken, grace: Duration, tracker: &TaskTracker) { tracker.spawn(async move { tokio::select! { _ = token.cancelled() => {} _ = tokio::time::sleep(grace) => token.cancel(), } });}enforce 为每个已绑定的会话 spawn 一个这样的任务。token 因其他原因触发时,它会提前结束:显式关闭、会话自身结束(Drop 会取消 token),或者关停(token 是根 token 的子 token)。
supervisor 的用户 API
Section titled “supervisor 的用户 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), // 该用户在任意入站上的全部会话 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),}每个调用都是 actor 通道(mpsc::channel(16))上的一条命令:Command::Users(set, UserEdit, reply)、Command::Close(selector, reply) 或 Command::Sessions(reply),各自在一个 oneshot 上得到回复。因此它们与应用、update 和关停串行执行。set_users 替换用户集中的用户,upsert_user 添加一个用户或替换其 spec,remove_user 移除一个用户并返回该用户原本是否在集合里。三者都经过私有的 Supervisor::users,它返回 Actor::edit_users 的结果:Ok(bool),表示用户集是否改变。set_users 和 upsert_user 丢弃这个 bool(.map(|_| ()));remove_user 返回它。
Supervisor::ask 把向已关闭通道发送、或回复被丢弃的情况映射为 ApplyError::Stopped。用户编辑返回这个错误;close 把它变成 0(unwrap_or(0)),sessions 把它变成空列表(unwrap_or_default())。
在本文依据的版本中,没有前端程序调用 set_users、upsert_user 或 remove_user。etemenanki-app 和 katana 都只通过应用整个 spec 来改变用户;katana 用 Supervisor::start 启动每个节点的 supervisor,之后用 apply_with 和 ApplyOptions { allow_disruptive: true } 应用后续的 spec。一次应用改变了节点的传输层或协议,或者这次应用来自本地配置编辑时,katana 会在该节点上调用 close(Selector::All)(src/manager/node.rs;见节点管理器)。两者都不调用 Supervisor::sessions。
用户从哪里来
Section titled “用户从哪里来”etemenanki-app 把每个入站下内联书写的用户降为 spec 中的一个用户集,tag 与入站的 tag 相同(app/src/lower.rs → InlineUsers;见etemenanki-app:从 TOML 到 spec),以 UserName 为 key(pub type UserKey = UserName):
| App 协议 | Key | 凭据 | users: None 的条件 |
|---|---|---|---|
trojan |
UserName::Email(email) |
password |
从不:入站总是引用一个用户集,即使它是空的 |
vless、vmess |
UserName::Email(email) |
uuid |
从不:入站总是引用一个用户集,即使它是空的 |
shadowsocks(旧式方法) |
UserName::Email(email) |
password |
users 为空 |
shadowsocks(2022 方法) |
UserName::Email(email) |
ss2022_psk,由 base64 解码 |
users 为空 |
socks |
UserName::Username(user) |
account |
auth = "none" |
http |
UserName::Username(user) |
account |
accounts 为空 |
hysteria2 |
UserName::Email(email);email 为空时为 UserName::Username(user) |
account |
users 为空 |
tun |
— | — | 总是 |
app 从不设置 speed_limit(总是 None),总是以 Hysteria2UserAuth::Account 把 Hysteria 2 降为 spec,也不设置 user_removal。缺少其协议用作 key 的名字的用户会被拒绝:以 email 为 key 的用户报 inbound <tag>: users[<n>] has no email; a user of this inbound is named by its email,账户报 inbound <tag>: <list>[<n>] has no user(<list> 是 accounts,Hysteria 2 则是 users)。同一 key 下的两个条目也会被拒绝(见“错误”)。
katana 以 Uid(面板 id)作为用户的 key,把一个节点的用户放进它的入站准入的那一个用户集,tag 为 USER_SET("users")。每个用户的 speed_limit 是 determine_rate(node, user):节点和用户的限速都非零时取较小者,只有一个非零时取那一个,两者都为 0 时为 None。katana 会跳过无法降为 spec 的用户、重复列出的用户,以及凭据已被前面某个用户出示过的用户(Hysteria 2 账户名按小写比较),每种情况都记录一条以 skipping user <uid>: 开头的 warn 日志,因此它构建的用户集不会触发 validate_admission 的共享凭据规则 。
会话从哪里来
Section titled “会话从哪里来”| 监听器 | 每个会话对应 | 由谁打开 |
|---|---|---|
| TCP,明文、TLS 或 WebSocket | 一个接受的 socket | accept 循环,在 accept 之后立即打开,早于传输层握手 |
| 使用 gRPC 传输层的 TCP | 一条 HTTP/2 stream | 服务循环(Connection::serve_socket),传输层每交出一条 stream 打开一次 |
| Unix socket | 一个接受的 socket,source = None |
accept 循环 |
| Hysteria 2 | 一条 QUIC 连接,其 Wire 读取该连接自己的字节计数 |
监听器为每条连接调用的 connector 闭包(run_hysteria_inbound 中的 make) |
| TUN | 无 | TUN 流携带 Principal::anonymous(),没有会话:设备不准入任何用户,所以不存在需要为之关闭这些流的人 |
如果 Hysteria 2 监听器的 stop token 已被取消,open 返回 None,make 交给该连接一个没有会话、token 已被它自己取消的 connector,于是连接立即关闭。代码注释的说法是 “no session escapes the close of a removed inbound”,即被移除的入站关闭时,不会有会话漏网。
每个会话的 token 是 supervisor 根 token 的子 token,而不是监听器 token 的子 token,所以停止或重新配置监听器不会取消它。supervisor 在以下情况取消会话的 token:Close 撤销或 CloseAfter 宽限期过去、显式 close、Step::CloseSessions、首次绑定被拒、最后一个句柄被丢弃,以及关停时经由根 token(见下文“取消”)。会话稍后才由它的第一个流绑定到用户。各 accept 路径的细节见监听器与服务循环。
会话的生命周期
Section titled “会话的生命周期”stateDiagram-v2 state "未绑定" as Unbound state "已绑定" as Bound state "已取消" as Cancelled state "已注销" as Deregistered [*] --> Unbound: accept 时打开 Unbound --> Bound: 第一个流,principal 未被撤销 Unbound --> Cancelled: 第一个流出示已撤销的 principal Unbound --> Cancelled: close、入站被移除、关停 Bound --> Cancelled: 在 Close 下被撤销,或 CloseAfter 宽限期过去 Bound --> Cancelled: close、入站被移除、关停 Bound --> Deregistered: 连接结束,最后一个句柄被丢弃 Unbound --> Deregistered: 连接结束,最后一个句柄被丢弃 Cancelled --> Deregistered: 最后一个句柄被丢弃 Deregistered --> [*]
Session::drop 按顺序做三件事:取消 token(这样等在它上面的宽限计时器现在就结束,而不是等到截止时间);如果会话已绑定,关闭用户为这个会话开的用量账户(Account::close 把会话自账本上次结算以来承载的字节并入账户,Ledger::reconcile 每个采样器 tick 结算一次,见按用户的用量计费);从注册表中移除条目及其索引条目。
在第一个流上绑定会话
Section titled “在第一个流上绑定会话”AppConnector::connect 对有会话的连接的每个流调用 session.admit(&flow.user.user_data),TCP 和 UDP 都一样,而且发生在任何路由之前(见 plane:为每个流选路)。Err 会成为该流的拨号结果,所以协议核心看到的是一次失败的拨号。
Session::admit 依次做这些检查:
-
已关闭? 如果 token 已取消,返回
closed():the session was closed。 -
已绑定? 如果
bound(一个AtomicBool,以Acquire读取)已设置,立即返回Ok(())。之后的流从不拿注册表的锁,出示另一个 principal 的后续流也不会重新绑定会话。 -
锁住注册表。 如果条目已不存在,返回
closed()。 -
若无人绑定,则绑定。 如果条目还没有 principal:
- 若
principal.revoked()已设置,取消 token 并返回revoked():the user was removed before the session opened a flow,不论当时的策略是什么; - 否则把 principal 存入条目;如果是用户 principal,再把会话插入
by_user[key]。
如果另一个流已先行绑定了条目,这一步什么都不做。
- 若
-
打开用量账户。 在释放锁之后,并且只对绑定了用户 principal 的那个流:
Ledger::bind(key, id, wire)在这个会话的Wire上打开用户的账户,结果存入account。在此期间会话不会被丢弃,因为它的流持有一个句柄。Ledger::bind让会话的水位线从(0, 0)开始,而Wire从Sessions::open起就在计数,所以会话在第一个流绑定它之前承载的字节(例如协议握手)会计入它所绑定的用户。从未绑定用户的会话不向任何人计费。Wire统计什么见按用户的用量计费。 -
标记为已绑定。 以
Release存储bound = true,返回Ok(())。
两种拒绝都是 io::ErrorKind::PermissionDenied。Session::admit 和 Sessions::revoke 都不记录任何日志。
第一个流出示已撤销 principal 的会话,在任何策略下都会被拒绝,包括 Keep。它是对着一张此后已经删掉该用户的用户表完成认证的,而移除策略只放过移除发生时已经在该用户名下存活的会话。
撤销与握手竞争
Section titled “撤销与握手竞争”握手对着它加载的那张用户表认证:大多数协议核心在构建时加载其 handler 的表,而 VMess 握手在运行时读取验证器的用户快照(见下文“存储用户表”)。应用或用户编辑会在握手进行中替换这张表,所以客户端可能在用户已从新表中删除之后,仍对着旧表完成认证。提交阶段的顺序保证这样的握手永远不会得到一个能被其策略放过的会话:
- 变更丢弃的每个 principal 都在注册表锁下被撤销,其策略应用到已经绑定到它的会话上。
- 之后才存储任何新的用户表。
Session::admit 在索引会话之前,在同一把锁下检查 revoked()。对于一次对着旧表认证、返回旧 principal P 的握手,有两种先后顺序,结果都正确:
sequenceDiagram
participant C as 连接
participant R as Sessions
participant A as Actor 的 commit
alt 第一个流在撤销之前绑定
C->>R: admit(P),P 未被撤销
R->>R: 绑定,按用户 key 建立索引
A->>R: revoke(P, policy)
R->>C: 对已绑定的会话应用策略
else 撤销先发生
A->>R: revoke(P, policy)
C->>R: admit(P)
R->>C: PermissionDenied,token 被取消
end
A->>A: 存储新的用户表
对着新表的握手得到用户当前的 principal,它没有被撤销。principal 被保留的用户,其握手得到的就是那个 principal,同样没有被撤销。
一次应用中的用户
Section titled “一次应用中的用户”应用在准备和提交阶段处理用户(阶段本身见规划与应用变更)。在准备阶段,处理完出站、负载均衡器和路由之后、构建任何 handler 之前:
-
暂存 key。
keys = self.keys.clone():新 key 在副本上分配,只有应用提交时才保留。 -
逐个入站准入。 对新 spec 的每个入站,按
InboundSpec::users找到它的用户集,然后调用admit(credential_kind(protocol), set, self.admissions.get(tag), &mut keys)。之前的准入结果按入站 tag 查找,所以 tag 不变的入站在重新绑定、handler 替换或更换用户集之后仍保留其 principal。非空的撤销结果连同该入站的移除策略一起收集起来。 -
用这些 principal 构建。 新增或变更的入站由
build_handler(spec, admitted, circuits)得到一个完整的 handler,其用户表带着新的准入结果。未变更且监听器正在运行的入站只得到一张表:先user_table(spec, admitted),再Listener::prepare_users,而且只在其准入结果与运行中的不同时才这样做(same_admissions:长度相同、id 顺序相同、principalArc相同、凭据相等)。用户没有变化的入站完全不受影响。
提交阶段不会失败,它接着执行:
-
commit_keys(keys):采用暂存的 key,并把新 key 的 id 追加到用量簿的名字列表中,这发生在任何会话能绑定它们之前。 -
publish_speed_limits(&spec):把 spec 各用户集中每个有 key 的用户的限速交给 tracker;从未得到 key 的用户(见“标识”)被跳过。一个用户出现在多个用户集中时,取其中最小的限速。Tracker::set_speed_limits随后把它已持有的每个 pacer 设为其用户的新限速,用户不在映射中时设为不限速,并为每个新受限的 key 创建一个 pacer。已经打开的流共享其用户的 pacer,所以也按新速率限速(流跟踪、统计与限速)。只在这里做这件事,所以被拒绝的应用无法改变正在生效的限速。 -
如果计划要求,发布 plane。
-
对收集到的每个撤销调用
Sessions::revoke。 -
启动新监听器、替换 handler,并存储准备好的用户表(
Listener::store_users)。 -
执行计划的其余步骤;
Step::CloseSessions关闭 tag 已不存在的入站的每个会话,无论是否已绑定,也无论移除策略是什么(Sessions::close(Scope::Inbound(tag)))。 -
用新的映射替换
self.admissions。被移除或改名的入站的准入结果随之消失;它的会话已在上一步关闭。
被拒绝的应用会丢弃暂存的 key、准入结果和用户表,不改变任何 principal、表或限速。演练 check(spec) 在一个全新的 actor 上执行同样的准入和建表。
Actor::edit_users(set, edit) 只修改一个用户集,不调和其他任何东西。它从不做规划、从不发布 plane,也从不绑定、替换或停止监听器。
sequenceDiagram
participant F as 前端程序
participant A as Actor
participant L as Listener
participant R as Sessions
F->>A: set_users、upsert_user 或 remove_user
A->>A: 编辑运行中 spec 的副本
loop 每个准入该用户集的入站
A->>A: validate_admission,在暂存的 key 上 admit
A->>L: user_table,然后 prepare_users
end
A->>A: commit_keys,publish_speed_limits
A->>R: 撤销每个被丢弃的 principal
A->>L: 为每个入站 store_users
A->>A: 保留准入结果和编辑后的 spec
A-->>F: Ok
-
复制运行中的 spec。 已启动的 actor 总有一份;没有的话,编辑以
ApplyError::Stopped失败。 -
找到用户集。 没有该 tag 的用户集时,以
Resource::UserSet(tag)上的ApplyError::Invalid失败,原因是no such user set。 -
编辑副本。
Set替换映射,Upsert插入,Remove删除。Set和Upsert总算作变更;Remove只有在用户原本存在时才算。没有变更时返回Ok(false),不做其他任何事。 -
准备每个准入该用户集的入站(
InboundSpec::users == Some(set)),按 spec 中的顺序进行,key 暂存在副本上:validate_admission(inbound, set),这是validate中唯一读取用户集用户的规则;- 对照该入站运行中的准入结果执行
admit; - 按 bind 找到监听器。已提交 spec 的每个入站都有监听器,代码对此用了
expect(every running inbound has its listener); user_table,然后prepare_users;失败时报Resource::Inbound(tag)上的ApplyError::Build。
任何失败都会在存储任何东西之前返回,所以被拒绝的编辑不会存储任何表。
-
提交。 依次执行
commit_keys、基于编辑后 spec 的publish_speed_limits,再按各入站的策略对每个入站被丢弃的 principal 调用Sessions::revoke,这些都在存储第一张表之前完成,与应用中相同。之后逐个入站执行store_users并记录新的准入结果。 -
采用编辑后的 spec。
state.spec变为编辑后的副本。state.versions不变。返回Ok(true)。
与应用不同,即使某个入站的准入结果没有变化(例如只改了限速的 upsert_user),编辑路径也会为每个准入该用户集的入站重建并存储用户表。此时表里带的是同样的 principal,所以不影响任何会话。
编辑一个没有入站引用的用户集时,什么都不准备:它采用未变的 key,发布编辑后 spec 的限速(该用户集中的用户只有在某个入站之前准入过同一 id 时才有 key),在运行中的 spec 里替换这个用户集,并返回 Ok(true)。
由于编辑后的 spec 成为运行中的 spec,下一次应用会以它为基准做规划。之后应用自己 spec 的前端程序会覆盖编辑的效果,撤销规则相同。
Listener::prepare_users(table) 拒绝其他种类的表,报错 the handler is not of the kind its listener serves:流式监听器接受能 UserTable::fits 其所服务 handler 协议的表,Hysteria 2 监听器接受 UserTable::Hysteria2,TUN 监听器接受 UserTable::None。上述准入路径用构建运行中 handler 的同一个入站 spec 来构建表,所以这是一道防护,而不是预期中的失败。之后 store_users 不会失败:
| 监听器 | store_users 的作用 |
|---|---|
| 流式 | 对监听器 watch 通道中当前的 handler 执行 UserTable::store_into:SOCKS、HTTP、Trojan、VLESS、Shadowsocks 和 Shadowsocks 2022 对新表做 ArcSwap::store;VMess 调用 AccountValidator::set_users。 |
| Hysteria 2 | Hy2Inbound::set_authenticator(Arc::new(authenticator)),把它存入监听器当前的连接配置。 |
| TUN | 什么都不做。 |
store_users 替换的是监听器当前所服务 handler 内部的表。各协议读取它的方式不同:
serve_connection中 SOCKS、HTTP、Trojan、VLESS、Shadowsocks 和 Shadowsocks 2022 的路径在构建协议核心时用load_full()加载一次表。在存储之前构建的协议核心保留它加载的那张表。- VMess 协议核心自己持有 handler 的
Arc<AccountValidator>(VMessCore::new(validator.clone(), …)),对着握手时的当前用户快照认证;用set_users自己注释里的话说,“handshakes in flight finish against whichever snapshot they loaded”,即进行中的握手对着各自加载的快照完成。 - Hysteria 2 连接在
/auth请求上,对着其连接配置当时持有的认证器认证。
已经完成认证的连接保留它被准入时的 principal。作用到它的会话上的是撤销。
Supervisor::close(selector) 把选择器映射为一个范围,并返回关闭的会话数:
Selector |
Scope |
作用范围 |
|---|---|---|
Session(id) |
One(id) |
该会话,如果它还存活 |
User(id) |
User(key),其中 key = keys.get(id) |
绑定到该用户任一 principal 的每个会话,覆盖所有入站,包括移除后被 Keep 策略保留运行的会话;从未有过 key 的 id 返回 0 |
Inbound(tag) |
Inbound(tag) |
该入站的每个会话,无论是否已绑定 |
All |
All |
每个会话 |
关闭只取消 token,并且只计入它取消的那些,所以重叠的关闭不会把一个会话算两次。连接任务在看到取消后结束;条目在最后一个句柄被丢弃时离开注册表。已关闭的会话不会再打开流:admit 返回 the session was closed。
Supervisor::sessions() 经由 actor 取得 Sessions::stats(),并用 keys.id(key) 把每个 UserKey 映射回前端程序的 id,得到 SessionInfo<U>。SessionInfo 没有标签:映射时丢掉了 SessionStats::user_label。尚未绑定、或绑定到匿名 principal 的会话,其 user 为 None。不想排在应用后面等待列表的前端程序改为读取 Supervisor::tracker().sessions(),它给出带 UserKey 和标签的 SessionStats。
- 每个入站、每个用户一个 principal。 撤销一个入站的 principal 只影响绑定到那个
Arc的会话,从不影响同一用户在其他入站上的会话。由revoking_under_close_ends_only_the_sessions_of_that_principal固定。 - principal 与其凭据同寿。 只要用户仍凭当初准入时的那个凭据(完全一致)被准入,他就保留自己的 principal,跨越应用、handler 替换和协议变更;修改限速从不替换它。限速这种情况由
a_speed_limit_change_keeps_the_users_sessions端到端固定。 - 撤销先于新表。 在应用和用户编辑中,每个被丢弃的 principal 都在存储任何表之前被撤销,而绑定时对撤销的检查与撤销持有同一把锁。绑定一侧的检查由
a_session_whose_first_flow_presents_a_revoked_principal_is_refused_under_every_policy固定;顺序本身写在Actor::commit和Actor::edit_users中,没有专门的测试。 - 会话只绑定一次。 第一个被准入的流确定会话的 principal;之后的流从不重新绑定。由
the_first_flow_binds_the_session_to_its_principal固定。 - 撤销标记只设置一次。
Principal::revoke设置一个OnceLock,所以revoked()报告第一次的策略。绑定时只凭这个标记决定是否拒绝,与策略无关;Sessions::revoke把传给它的策略应用到已经绑定的会话上。actor 只在 principal 离开某个入站的准入结果时撤销它。没有测试固定“第一次为准”这条规则。 - key 稳定。 每个
UserId只分配一次UserKey,从 1 开始计数,并且在存储任何可能绑定它的表之前发布到用量簿的名字列表(commit_keys)。一旦这一点被破坏,UsageBook::name会以UserKey(<n>) was bound before it was publishedpanic,因为已经取走的字节无法放回。 - 被拒绝的变更不改变任何东西。 key、principal、表和限速在准备阶段暂存,只在提交阶段采用。由
a_refused_spec_changes_nothing固定:其中被拒绝的 spec 还替换了用户集,结果旧用户仍能登录,新用户仍被拒绝。 - 用户从不触及数据平面。 仅改变用户集不会发布 plane,也不会绑定、停止或替换监听器。由
a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds固定。 - 会话比监听器活得久,但不比 supervisor 久。 每个会话 token 都是根 token 的子 token。由
cancelling_the_root_cancels_every_session固定。 - 已停止的监听器不打开会话。
open在注册表锁下检查监听器的 stop token,而提交阶段先停止被移除入站的监听器,再在同一把锁下清扫该入站的会话,所以在监听器停止之际被接受的连接,要么被清扫,要么根本没有打开会话。找不到会话的 Hysteria 2 连接得到一个预先取消的 token,立即关闭。由a_stopped_listener_opens_no_session固定。 - 注册表会忘掉已结束的会话。 被丢弃的会话同时离开
live和by_user,所以按用户关闭从不计入它。由a_dropped_session_leaves_the_user_index和a_session_is_listed_with_its_bytes_until_its_last_handle_drops固定。 - 宽限计时器是 supervisor 的任务。 它们运行在 supervisor 的
TaskTracker上,随会话结束或关停而结束。由shutdown_ends_pending_grace_timers固定。
失败路径与取消
Section titled “失败路径与取消”下面的 ApplyError 文本是库调用方收到的错误的 Display;标有“app”的,也是 etemenanki-app --test 对内联用户列表在 configuration invalid: 之后打印的内容。
| 位置 | 文本 | 变体 |
|---|---|---|
Session::admit,第一个流出示已撤销的 principal |
the user was removed before the session opened a flow |
io::ErrorKind::PermissionDenied;token 被取消 |
Session::admit,会话已关闭或已注销 |
the session was closed |
io::ErrorKind::PermissionDenied |
edit_users,未知用户集 |
user set <tag>: no such user set |
ApplyError::Invalid |
| actor 停止后的任何用户编辑 | the supervisor has shut down |
ApplyError::Stopped |
validate_admission,两个用户使用同一凭据(app) |
inbound tr-in: users alice@example.com and bob@example.com present the same credential |
ApplyError::Invalid |
validate_admission,两个账户名相同(Hysteria 2:转小写后相同)(app) |
inbound hy2-in: users Alice and alice present the same username |
ApplyError::Invalid |
validate_admission,Shadowsocks 2022 用户密钥无法规范化(app) |
inbound ss-in: user alice@example.com: shadowsocks-2022: PSK too short (16 < 32) |
ApplyError::Invalid |
validate_admission,Hysteria 2 账户的用户名或密码为空,或用户名中含 :(app) |
inbound hy2-in: user a:b: a hysteria2 account needs a name without ':' and a password |
ApplyError::Invalid |
validate_admission,Hysteria 2 密码凭据为空 |
inbound <tag>: user <name>: a hysteria2 password must not be empty |
ApplyError::Invalid |
validate,入站引用不存在的用户集 |
inbound <tag> references unknown user set <set> |
ApplyError::UnknownReference |
validate,Trojan、VLESS 或 VMess 没有用户集 |
inbound <tag>: the protocol has no open mode and needs a user set |
ApplyError::Invalid |
validate,Hysteria 2 两者都设置或都未设置 |
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 |
| 构建协议拒绝的表(app) | building inbound ss-in failed: shadowsocks-2022: multi-user requires an aes-gcm method |
ApplyError::Build |
prepare_users,另一种类的表(防护) |
building inbound <tag> failed: the handler is not of the kind its listener serves |
ApplyError::Build |
validate_admission 只检查入站读取的那一种凭据,所以在按账户准入的入站上,两个用户共用一个密码不算冲突。它只在 Hysteria 2 上按小写比较账户名,因为 Hysteria 2 在线上不区分大小写地比较它们。etemenanki-app 在 supervisor 看到之前就会拒绝两个同名的内联用户(inbound socks-in: accounts[0] and accounts[1] are both named "alice"),因为用户集是一个映射,第二个条目会悄无声息地替换第一个。
actor 不在了之后,Supervisor::close 返回 0,Supervisor::sessions 返回空列表,而不是错误:ask 给它们的是 ApplyError::Stopped,它们把这个错误映射掉了。
| token 或任务 | 父级 | 由谁取消 |
|---|---|---|
| 会话 token | supervisor 根 token(Sessions::root) |
Close 撤销、CloseAfter 计时器、close、Step::CloseSessions、首次绑定被拒、Session::drop,或关停时的根 token |
CloseAfter 计时器任务 |
spawn 在 supervisor 的 TaskTracker 上 |
会话 token 触发时结束,或在宽限期过后取消该 token |
| 根 token | 无 | Actor::shutdown,在关停宽限期之后 |
Actor::shutdown(grace) 按以下顺序执行:
- 停止每个监听器;
- 取消每个负载均衡器的探测 token;
- 关闭任务 tracker,最多等待
grace让连接结束; - 取消根 token,每个会话 token 随之取消,这会结束每个宽限计时器;
- 等待 tracker 清空(此时每个会话都已把最后的字节并入其账本账户);
- 丢弃监听器,然后停止后台任务(采样器,以及设置了的话,用量 sink 任务)并 await 它们。
关停时 sink 报告什么见按用户的用量计费。Supervisor::shutdown 发送 Command::Shutdown 并等待它完成。如果所有 Supervisor 句柄都在没有关停的情况下被丢弃,actor 的命令循环结束,它会自己运行 shutdown(Duration::ZERO)。取消 token 本身从不把会话从注册表中移除;移除发生在最后一个句柄被丢弃时。
| 项目 | 值 | 位置 |
|---|---|---|
第一个 SessionId |
1,之后每次 open 加一(AtomicU64,Relaxed) |
entity/session.rs → Sessions::new |
第一个 UserKey |
1;key n 指称第 n 个 id |
build/users.rs → UserKeys::key |
| 每个 principal 的撤销标记 | 只设置一次(OnceLock);revoked() 报告第一次的策略 |
topology/flow.rs → Principal::revoke |
close 的开销 |
One:一次查找;User:该用户的索引条目;Inbound 和 All:每个存活会话 |
entity/session.rs → Sessions::close |
| Actor 命令通道 | 16 条命令 | supervisor.rs → SupervisorBuilder::start |
CloseAfter 计时器 |
每次撤销、每个已绑定会话一个任务 | entity/session.rs → close_after |
speed_limit |
以字节每秒计的 NonZeroU64,或 None 表示不限速;取用户所在各用户集中的最小值 |
entity/user.rs,supervisor.rs → publish_speed_limits |
| 每个用户的凭据 | 每种 CredentialKind 一个,共四种 |
entity/user.rs → Credentials |
| 测试 | 文件 | 固定的规则 |
|---|---|---|
a_session_is_listed_with_its_bytes_until_its_last_handle_drops |
supervisor/tests/unit/session.rs |
未绑定会话的 stats 字段,以及最后一个句柄被丢弃时的注销 |
a_dropped_session_leaves_the_user_index |
同上 | 按用户关闭不计入已结束的会话 |
the_first_flow_binds_the_session_to_its_principal |
同上 | 第一个 principal 为准;后续的流不会重新绑定 |
revoking_under_close_ends_only_the_sessions_of_that_principal |
同上 | 按入站区分的 principal;撤销的策略记录在 principal 上 |
revoking_under_keep_ends_no_session |
同上 | Keep 放过会话及其后续的流 |
revoking_under_close_after_ends_the_session_once_the_grace_passes |
同上 | CloseAfter 计时器,在暂停的时间下测试 |
a_session_whose_first_flow_presents_a_revoked_principal_is_refused_under_every_policy |
同上 | 竞争:以 PermissionDenied 拒绝,token 被取消,从未绑定 |
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 |
同上 | 每种 Scope |
a_session_already_closed_is_not_counted_again |
同上 | 关闭的计数 |
cancelling_the_root_cancels_every_session |
同上 | token 是根 token 的子 token |
a_stopped_listener_opens_no_session |
同上 | open 检查 stop token |
removing_a_user_closes_only_their_sessions_and_refuses_them_after |
supervisor/tests/hot_swap.rs |
在 SOCKS 入站上以 Close 和 Keep 执行 remove_user;被移除用户的下一次握手失败;close(Selector::User) |
a_speed_limit_change_keeps_the_users_sessions |
同上 | 用新限速调用 set_users 会保留 principal 和会话 id |
a_refused_spec_changes_nothing |
同上 | 被拒绝的应用让用户集保持原样 |
removing_an_inbound_closes_its_sessions |
同上 | Step::CloseSessions |
shutdown_ends_pending_grace_timers |
同上 | 一小时的 CloseAfter 计时器不会拖住关停 |
a_hysteria2_user_set_admits_by_password |
supervisor/tests/tracking.rs |
Hysteria2UserAuth::Password:整个认证字符串就是密码,标签是用户名,陌生人被拒绝,close(Selector::User) |
a_hysteria2_session_bills_its_quic_connections_bytes |
同上 | Hysteria 2 会话的标签和按用户关闭 |
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 |
同上 | validate 检查的用户集引用 |
a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds |
supervisor/tests/unit/plan.rs |
用户与监听器和 plane 分开规划 |
an_inbound_renamed_on_the_same_bind_swaps_and_closes_the_old_tags_sessions |
同上 | 会话属于某个 tag |
every_byte_is_taken_exactly_once |
supervisor/tests/unit/usage.rs |
一个属性测试,覆盖打开、绑定、Close 下的移除,以及以同一 key、新 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 |
VMess 表如何存储 |
a_reload_that_removes_a_user_closes_only_their_connections |
app/tests/integration/e2e_reload.rs |
通过 etemenanki-app 的重载验证同一规则:被移除用户的连接关闭,且该用户无法再登录,另一个用户的连接继续运行 |
supervisor/tests/support/mod.rs 中共享的测试夹具把 SOCKS 和 HTTP 用户的 key 设为 UserName::Username(accounts、account),把 VLESS 用户的 key 设为 UserName::Email(vless_users)。没有测试直接调用 upsert_user;它与另外两个经过测试的编辑共用 edit_users。凭据改变后得到的新 principal,只经由与移除相同的代码路径被覆盖到。没有测试把一个 principal 撤销两次,所以 Principal::revoke 的“第一次为准”规则没有专门的测试。