准入与用户表
源码文件:18 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
katana/src/connector.rskatana/src/traffic.rskatana/src/meter.rskatana/src/serve.rskatana/src/inbound.rskatana/src/api/mod.rskatana/src/manager/mod.rskatana/src/manager/proxy.rskatana/src/manager/transport.rskatana/src/manager/node.rskatana/tests/unit/connector.rskatana/tests/unit/traffic.rskatana/tests/unit/meter.rskatana/tests/unit/serve.rskatana/tests/unit/e2e.rsEtemenanki/concepts/src/net.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/hysteria/server/inbound.rs
协议核心(core)负责判断凭据是否有效;katana 则负责判断一个流属于哪个面板用户、该用户是否仍可打开流,以及如何结束已被面板移除的用户的流。这分为两部分:一是协议用户表携带的载荷,即 UserTag;二是 Admission,每个流在拨号前都要经过它,对节点的流量注册表做一次查找。面板的用户列表变化时,katana 会重建用户表和注册表并将二者发布出去,发布顺序保证未变化的用户保留连接,而已离开的用户失去连接。
本页面向修改 src/connector.rs、src/traffic.rs 或 src/manager/ 的贡献者,内容包括:每个流的准入路径、ProxyManager::refresh 实际执行的用户集刷新过程,以及这一顺序背后的竞态分析。字节计数与上报见 流量计费,计量出站见 计量与限速。
| 组件 | 负责 | 交给其他部分 |
|---|---|---|
协议用户表中的 UserTag |
标明凭据所属的注册表键和 uid。 | 计数器:每个流单独查找,从不存放在表中。 |
NodeTraffic(src/traffic.rs) |
持有注册表 AuthKey → Arc<UserCounter>。暂存下一个用户集(prepare)并将其换入(commit)。 |
退役连接。它只计算哪些键必须移除(cancel_keys)。 |
Admission(src/connector.rs) |
依据注册表准入流,并发放该用户的租约。提交暂存的用户集,并取消已离开或被重新绑定的用户的租约。 | 构建用户表,以及决定何时刷新。 |
KatanaConnector::connect |
对每个流调用 admit,把租约发布给所在连接,并用 Gate 包装出站。 |
路由和审计细节,见 Connector 与 UDP 扇出。 |
ProxyManager::refresh(src/manager/proxy.rs) |
构建替换用的表或认证器,先提交,再发布。 | 判断一次变化应走刷新还是重建,见 节点管理器。 |
TransportManager::start(src/manager/transport.rs) |
冷路径:暂存、构建、绑定、直接提交,并创建新的 Admission。 |
拆除上一 generation(一代实例)。 |
AuthKey 与 UserTag
Section titled “AuthKey 与 UserTag”AuthKey 是注册表的键。内核的 UserAuthorization 派生了 Eq 但没有派生 Hash,因此 katana 维护自己的投影类型,派生 Clone、Eq 和 Hash。UserTag 派生 Clone 和 Eq,但不派生 Hash:它是载荷,从不作为键。
pub enum AuthKey { Uuid(Uuid), Name(CompactString),}
pub struct UserTag { pub key: AuthKey, pub uid: i64,}
impl UserTag { pub fn unattributed() -> Self}面板用户得到哪种变体由节点类型决定。src/api/mod.rs 中的 NodeType::keys_by_email 是唯一做出这一决定的地方,src/manager/mod.rs 则把 UserInfo 映射为对应的键和标签:
pub(crate) fn user_key(by_email: bool, u: &UserInfo) -> Option<AuthKey>pub(crate) fn user_tag(by_email: bool, u: &UserInfo) -> Option<Arc<UserTag>>pub(crate) fn build_user_entries( node: &NodeInfo, users: &[UserInfo],) -> (Vec<UserEntry>, Vec<UserInfo>)| 节点类型 | keys_by_email() |
AuthKey |
|---|---|---|
V2ray(VMess 或 VLESS) |
false |
AuthKey::Uuid,由 UserInfo.uuid 解析得到 |
Trojan、Shadowsocks、Hysteria2 |
true |
AuthKey::Name(traffic_email(u)):面板邮箱;邮箱为空时为 uid 的字符串形式 |
对于以 UUID 为键的节点,build_user_entries 会跳过 UUID 无法解析的用户,并记录 skipping user <uid>: uuid is not a valid UUID。日志只包含 uid,从不包含 UUID 原值。被跳过的用户既没有注册表条目,也没有用户表条目。
UserTag::unattributed() 返回 uid 为 -1 的 AuthKey::Name("")。它在 src/inbound.rs 中有两个用途。一是 Shadowsocks 服务端配置(Ss2022ServerConfig::from_password 和旧版的 ShadowsocksServerConfig)要求提供一个单密码载荷,而 katana 从不通过它提供服务,这个未归属标签就用来填这个位置。二是配置预检 validate_hysteria 会基于一个占位用户构建真实的认证器,并以同样方式给它打标签。任何已注册用户都无法匹配这个标签,因为 lookup 只会找到已注册的 uid。
用户表携带什么
Section titled “用户表携带什么”内核中的每个协议核心都对用户载荷 T 泛型,并向其 connector 交出 Flow<T>。载荷位于 NetworkUser 中的一个 Arc 之后:
pub struct NetworkUser<T> { pub authorization: UserAuthorization, pub user_data: std::sync::Arc<T>,}pub struct Flow<T> { pub destination: Destination, pub user: NetworkUser<T>, pub sniffed: Option<SniffedBehavior>, pub source: Option<IpAddr>,}katana 用 T = UserTag 实例化所有用户表。流式节点的表是 StreamProtocol 的各个变体,Hysteria 2 的表是 Authenticator<UserTag>:
pub enum StreamProtocol { Vmess(Arc<AccountValidator<UserTag>>), Vless(Arc<vless::Validator<UserTag>>), Trojan(Arc<trojan::Validator<UserTag>>), ShadowsocksLegacy(Arc<Resolved<UserTag>>), Shadowsocks2022 { config: Arc<Ss2022ServerConfig<UserTag>>, validator: Option<Arc<ss_2022::Validator<UserTag>>>, },}
pub fn build_protocol( node: &NodeInfo, users: &[UserInfo], enable_vless: bool, tag_for: impl Fn(&UserInfo) -> Option<Arc<UserTag>>,) -> io::Result<StreamProtocol>
pub fn build_hysteria_authenticator( cfg: &HysteriaConfig, users: &[UserInfo], tag_for: impl Fn(&UserInfo) -> Option<Arc<UserTag>>,) -> io::Result<Authenticator<UserTag>>两个构建函数都从 tag_for 获取标签,tag_for 就是按节点的 keys_by_email() 调用的 user_tag。因此用户表和注册表的键始终一致。如果某个用户无法由 tag_for 打上标签,就是硬性构建错误 user <uid> has no key,因为传入的每个用户此前都已暂存过。
pub struct NodeTraffic { users: RwLock<HashMap<AuthKey, Arc<UserCounter>>>, residuals: Mutex<HashMap<i64, (u64, u64)>>, draining: Mutex<Vec<Arc<UserCounter>>>,}
pub struct UserEntry { pub key: AuthKey, pub uid: i64, pub rate: u64,}
pub struct PreparedUsers { next: HashMap<AuthKey, Arc<UserCounter>>, carried: Vec<(Arc<UserCounter>, Arc<UserCounter>)>, orphaned: Vec<Arc<UserCounter>>, cancel_keys: HashSet<AuthKey>,}
impl PreparedUsers { pub fn cancel_keys(&self) -> &HashSet<AuthKey>}
impl NodeTraffic { pub fn prepare(&self, entries: Vec<UserEntry>) -> PreparedUsers pub fn commit(&self, prepared: PreparedUsers) pub fn lookup(&self, key: &AuthKey, uid: i64) -> Option<Arc<UserCounter>>}这些锁都是 parking_lot 锁。每个 NodeManager 拥有一个 NodeTraffic,它的生命周期长于所有监听器 generation,因此未变化用户的计数器在重建之后仍然延续。
-
prepare获取users读锁,把每个条目与当前注册表比较。它不做任何修改,也不读取字节总数,因此可以在任何状态改变之前构建用户表,构建失败也不会有影响。每个条目属于以下四种情况之一:注册表中有该键? uid 相同? 速率相同? next中的计数器旧计数器去向 键是否在 cancel_keys中是 是 是 同一个 Arc保留 否 是 是 否 新的 UserCountercarried否 是 否 任意 新的 UserCounterorphaned是 否 不适用 不适用 新的 UserCounter不适用 否 处理完所有条目后,注册表中凡是不在
next里的键,其计数器进入orphaned,键进入cancel_keys。速率变化之所以要换新计数器,是因为TokenBucket在构造时就固定了速率。 -
commit先获取users写锁,再获取draining锁,与snapshot的加锁顺序一致。它把carried和orphaned中的旧计数器移入draining,并用next替换映射。同时持有两把锁,保证快照看到的每个被换出的计数器恰好只出现在一个位置。它不读取也不等待任何计数器,所以仍在向被换出计数器写入的连接不会阻塞它。 -
lookup只有在key注册到的恰好是同一个uid时才返回计数器:src/traffic.rs self.users.read().get(key).filter(|c| c.uid == uid).cloned()uid 过滤对被重新绑定的凭据很重要。变化之前构建的用户表,其标签里仍是旧 uid,查找会拒绝它,而不是向该凭据现在所属的账户计费。
Admission
Section titled “Admission”pub struct Admission { traffic: Arc<NodeTraffic>, leases: Mutex<HashMap<AuthKey, CancellationToken>>,}
impl Admission { pub fn new(traffic: Arc<NodeTraffic>) -> Self pub fn admit(&self, tag: &UserTag) -> Option<(Arc<UserCounter>, CancellationToken)> pub fn commit(&self, prepared: PreparedUsers) pub fn retire_all(&self)}租约是一个 tokio_util 的 CancellationToken,在当前监听器 generation 上每个曾有流被准入的 AuthKey 各有一个。该用户的每个流和每个连接都持有它的一个克隆,取消它就会一次性退役所有这些流和连接。整个机制只有两个很短的函数,其正确性取决于锁的作用范围:
pub fn admit(&self, tag: &UserTag) -> Option<(Arc<UserCounter>, CancellationToken)> { let mut leases = self.leases.lock(); let counter = self.traffic.lookup(&tag.key, tag.uid)?; let lease = leases.entry(tag.key.clone()).or_default().clone(); Some((counter, lease))}
pub fn commit(&self, prepared: PreparedUsers) { let mut leases = self.leases.lock(); let leaving: Vec<AuthKey> = prepared.cancel_keys().iter().cloned().collect(); self.traffic.commit(prepared); for key in leaving { if let Some(lease) = leases.remove(&key) { lease.cancel(); } }}admit 在持有 leases 期间完成查找并插入租约;commit 在持有同一把锁期间替换注册表并取消租约。retire_all 清空映射并取消所有租约,TransportManager::shutdown 和 TransportManager 的 Drop 通过 ProxyManager::retire_all 调用它。
Admission 位于 Dispatcher 中,而 Dispatcher 属于某一个监听器 generation。重建时会基于同一个 NodeTraffic 创建一个新的 Admission,其租约映射为空。
租约槽与 connector
Section titled “租约槽与 connector”pub type LeaseSlot = watch::Sender<Option<CancellationToken>>;
impl KatanaConnector { pub fn new( disp: Arc<Dispatcher>, source: Option<IpAddr>, lease: Option<Arc<LeaseSlot>>, ) -> Self}
impl Connector<Flow<UserTag>> for KatanaConnector { type Stream = Metered<OutboundStream>; type Datagram = FanOut; type Future = ConnectFuture; fn connect(&mut self, flow: Flow<UserTag>) -> ConnectFuture;}传输层每交出一个流,serve_stream 就创建一个 watch 通道:每个 TCP、TLS 或 WebSocket 连接一个,gRPC 上每个 HTTP/2 流一个。它把发送端以 Some(Arc<LeaseSlot>) 的形式交给 connector,接收端留给该连接的驱动逻辑。同一连接上的所有流都属于同一个用户,因为协议核心每个连接只认证一次,mux 子流继承承载连接的用户(Flow::toward)。第一个被准入的流用 send_if_modified 发布自己的租约,之后的流不改动已发布的值。驱动逻辑等待它:
async fn until_retired(mut lease: watch::Receiver<Option<CancellationToken>>)已发布的租约被取消时,until_retired 随即完成。在有流被准入之前,连接还没有用户,这个 future 不会完成;如果发送端在发布任何租约之前就被 drop,它会永远保持 pending。握手完成后,drive 将它与运行时、进度看门狗一起 select,它触发时 drive 返回 Ok(())。在此之前就被取消的租约同样有效,因为被取消的 token 会一直保持取消状态,until_retired 在第一次 poll 时就会完成。随后 drop 运行时,会关闭客户端流、所有出站,以及所有尚未打开的 mux 子流。
Hysteria 2 监听器自行运行运行时:每个代理流一个,每个连接的数据报再各一个。因此 run_hysteria 为每个客户端构建 connector 时传入 lease: None。已退役用户的 Hysteria 流改由其 Gate 结束。
每个出站上的闸门
Section titled “每个出站上的闸门”impl Gate { pub fn new(counter: Arc<UserCounter>, retired: CancellationToken) -> Self pub fn poll_open(&mut self, cx: &mut Context<'_>) -> Poll<io::Result<()>>}connect 用准入得到的计数器和租约构建 Gate。Metered 在每次读写前调用 poll_open。FanOut 在每次接收时调用它,也在每次通过路由和审计的发送时调用它:被路由器阻断或被审计禁止的数据报在询问闸门之前就被丢弃。闸门首先检查退役状态。租约一旦取消,所有传输都以 io::ErrorKind::ConnectionAborted 失败,消息为 the user was retired。由于闸门把 waker 注册到了取消 future 上,因此即使一个读操作正停在沉默的对端上,用户退役时它也会被唤醒。
pub enum Tables { Stream(ArcSwap<StreamProtocol>), Hysteria { server: Hy2Inbound<UserTag>, cfg: HysteriaConfig, },}
enum Replacement { Stream(StreamProtocol), Hysteria(Arc<Authenticator<UserTag>>),}
impl ProxyManager { pub fn refresh(&self, node: &NodeInfo, users: &[UserInfo], enable_vless: bool) pub fn retire_all(&self)}两种 Tables 形态的替换方式不同。流式节点在每个连接上基于当时的用户表构建协议核心,所以整张表放在一个 ArcSwap 中,一次 store 即可替换。Hysteria 2 监听器自己持有 UDP socket,重建它会断开所有已连接的客户端,因此只替换它的认证器:
impl<T> Hy2Inbound<T> { pub fn set_authenticator(&self, authenticator: Arc<Authenticator<T>>)}监听器在 QUIC 连接发送 /auth 请求时加载当前认证器。已经通过 /auth 的客户端保留其被准入时的身份。已离开的 Hysteria 用户靠每个流的准入检查来退役。
connect 在返回 future 之前同步地完成准入。因此判定依据的是协议核心发出该流时的注册表状态,发生在任何拨号之前。
sequenceDiagram
participant Core as 协议核心
participant Conn as KatanaConnector
participant Adm as Admission
participant Reg as NodeTraffic
participant Slot as LeaseSlot
Core->>Conn: connect(携带 UserTag 的 Flow)
Conn->>Adm: admit(tag)
Adm->>Adm: 锁住 leases
Adm->>Reg: lookup(key, uid)
alt 键注册到此 uid
Reg-->>Adm: 计数器
Adm->>Adm: lease = entry(key).or_default()
Adm-->>Conn: 计数器和租约
Conn->>Slot: 尚无租约时发布租约
Conn->>Conn: Gate::new(counter, lease)
Conn-->>Core: UDP 得到 FanOut,TCP 经路由、审计、拨号后包装为 Metered
else 未注册,或属于其他 uid
Reg-->>Adm: None
Adm-->>Conn: None
Conn-->>Core: Err(refused),PermissionDenied
end
被拒绝的准入不记录日志。已离开用户的客户端在察觉之前会不断重试,每次尝试都记一行日志会淹没日志。
单个键的租约
Section titled “单个键的租约”stateDiagram-v2 [*] --> Absent Absent --> Live: 该键首次 admit Live --> Live: admit 克隆它 Live --> Live: 刷新,未变化或速率变化 Live --> Cancelled: commit,键在 cancel_keys 中 Live --> Cancelled: retire_all Cancelled --> [*]: 从映射中移除
commit 和 retire_all 在取消租约的同时把它从映射中移除。如果该用户在之后的某次刷新中回来,下一次 admit 会插入一个新的 token。仍持有旧 token 的流和连接保持退役状态。
当面板的用户列表不为空、监听器正在运行、节点的传输层和协议均未变化(NodeInfo::transport_eq、NodeInfo::protocol_eq),没有本地配置修改强制重建,并且用户列表有差异(user_set_differs,对完整 UserInfo 值做与顺序无关的集合比较)或节点限速发生变化时,NodeManager::reconcile 调用 ProxyManager::refresh。调用期间它持有节点的 transport 互斥锁。ProxyManager::refresh 随后按顺序执行以下步骤:
-
暂存。
build_user_entries(node, users)返回注册表条目以及键解析成功的用户。NodeTraffic::prepare(entries)返回PreparedUsers。此时还没有任何改变。 -
构建替换对象。 对于
Tables::Stream,build_protocol构建新的StreamProtocol;对于Tables::Hysteria,build_hysteria_authenticator依据保存的HysteriaConfig构建新的认证器。两者使用的tag_for与暂存条目时所用的相同。出错时,refresh记录proxy refresh build failed, keeping current: <error>并返回。PreparedUsers未经提交即被 drop,因此注册表、租约和用户表都保持不变。 -
在 leases 锁下提交。
self.dispatcher.admission.commit(prepared)获取leases,NodeTraffic::commit换入新注册表,并把被换出的计数器移入 draining 集合。然后commit移除并取消cancel_keys中每个键的租约,也就是已离开的用户和被重新绑定到其他 uid 的凭据。它不等待这些流结束,它们的计数器进入 draining。 -
发布用户表。 对于流式节点,
ArcSwap::store装入新的StreamProtocol,此后接受的连接使用它。对于 Hysteria 节点,Hy2Inbound::set_authenticator装入新的认证器,监听器、其 socket 和所有存活连接都不受影响。Tables/Replacement不匹配属于unreachable!("table kind changed without a rebuild"),因为替换对象就是从它将要应用到的同一分支构建出来的。
sequenceDiagram
participant NM as NodeManager
participant PM as ProxyManager
participant Reg as NodeTraffic
participant Adm as Admission
participant Tab as Tables
NM->>PM: refresh(node, users, enable_vless)
PM->>Reg: prepare(entries)
Reg-->>PM: PreparedUsers
PM->>PM: build_protocol 或 build_hysteria_authenticator
alt 构建失败
PM-->>NM: 记录错误,保持当前状态
else 构建成功
PM->>Adm: commit(prepared)
Adm->>Adm: 锁住 leases
Adm->>Reg: commit,替换注册表,暂存被换出的计数器
Adm->>Adm: 移除并取消 cancel_keys 的租约
PM->>Tab: ArcSwap store 或 set_authenticator
end
为什么先换注册表
Section titled “为什么先换注册表”在第 3 步和第 4 步之间,新注册表已经生效,而认证仍由旧用户表完成。这段窗口内的新连接依据旧表认证,长连接则在整个生命周期内一直使用它加载的那张表。已离开的用户在此期间仍可能通过认证,但他打开的每个流都会在 admit 被拒绝。被重新绑定的凭据所携带的旧 uid 标签会被 lookup 的 uid 过滤拒绝。新加入的用户在新表开始接受他之前就已经在注册表中了。
反过来的顺序会让新用户出问题。如果先换用户表,新加入的用户可能依据新表通过认证,而旧注册表会拒绝他打开的每个流。
刷新对每类用户的影响
Section titled “刷新对每类用户的影响”| 用户 | 提交后的注册表 | 租约 | 已打开的流和连接 | 新流 |
|---|---|---|---|---|
| 未变化:键、uid 和速率都相同 | 同一个 Arc<UserCounter> |
保留 | 继续,计量不中断 | 准入到同一个计数器 |
| 速率变化:键和 uid 相同 | 新计数器,旧计数器进入 draining | 保留 | 继续使用旧计数器及其令牌桶,按旧速率运行直到结束 | 准入到新计数器,使用新速率 |
| 重新绑定:键相同,uid 不同 | 为新 uid 建新计数器,旧计数器以旧 uid 进入 draining | 取消 | 流式连接结束,所有 Gate 拒绝传输 |
旧 uid 标签被拒绝;来自新表的新 uid 标签得到新租约 |
| 已离开:键未再次列出 | 移除,计数器进入 draining | 取消 | 流式连接结束,所有 Gate 拒绝传输 |
拒绝 |
| 新用户 | 新计数器 | 首次 admit 时创建 | 不适用 | 新表接受该用户后即可准入 |
在速率变化用户的旧流结束之前,其新旧流各自消耗不同的令牌桶。
传输层或协议变化、路由或出站变化、静态配置重建,或者从空用户列表恢复,都走 NodeManager::rebuild。这条路径先拆除旧监听器,再构建新的。TransportManager::start 不使用 Admission::commit:
flowchart TB A["NodeManager::tear_down"] --> B["TransportManager::shutdown"] B --> C["Scope::shutdown,作用域内任务已结束"] C --> D["ProxyManager::retire_all,所有租约已取消"] D --> E["release_listener,QUIC 端口已释放"] E --> F["TransportManager::start: prepare"] F --> G["构建用户表和传输层,绑定"] G --> H["直接调用 NodeTraffic::commit"] H --> I["用 Admission::new 创建新的 Dispatcher"] I --> J["启动 accept 循环或 run_hysteria"]
tests/unit/e2e.rs 中的 route_change_drops_connections 确保这条路径会结束所有已打开的连接。这里直接提交是安全的:上一 generation 的作用域已关闭,其租约已取消;新 generation 的监听器尚未开始接受连接,所以在提交与第一次 accept 之间不会有流到达 Admission,也就没有需要取消的租约。start 遵循与 refresh 相同的原则:所有可能失败的步骤(构建 Hysteria 2 认证器和监听器,或流式传输层和协议表,以及绑定 socket)都在提交之前执行。失败时返回错误,注册表保持不变。由于旧监听器已经不在,节点在之后某个周期重建成功之前不提供任何服务。若发生在节点首次启动时,这次失败只结束一次引导(bootstrap)尝试,NodeManager::bootstrap 会以退避方式重试,直到节点启动成功或被关闭。
还有两条路径在没有监听器运行时直接提交空集:用户列表为空时的 reconcile(在 tear_down 之后),以及没有用户时的 bring_up。两者都调用 traffic.commit(traffic.prepare(Vec::new())),因此所有计数器进入 draining,也没有任何监听。
两个任务在 Admission 中交汇:连接任务从 connect 调用 admit,节点任务从 refresh 调用 commit。二者的临界区都在 Admission.leases 下执行,因此被串行化。对于在本次刷新中离开的用户,只存在两种交错:
sequenceDiagram
participant F as 连接任务
participant L as leases 锁
participant N as 节点任务
alt admit 先拿到锁
F->>L: admit,lookup 命中旧注册表,插入租约
L-->>F: 计数器和租约
N->>L: commit,替换注册表
N->>L: 移除并取消租约
Note over F: Gate 中止,until_retired 触发
else commit 先拿到锁
N->>L: commit,替换注册表,如有租约则取消
F->>L: admit,lookup 在新注册表中未命中
L-->>F: None,流被拒绝
end
因此,一个流要么在提交之前被准入、随后被提交退役,要么依据新注册表接受检查。如果没有这把共享锁,就可能出现第三种交错:admit 在旧注册表中查到用户,commit 接着替换注册表,却找不到要取消的租约,然后 admit 插入一个永远不会被取消的新 token。在查找和插入期间一直持有 leases,就排除了这种情况。
向 LeaseSlot 发布发生在 admit 释放锁之后,所以发布时租约可能已被取消。这没有害处:CancellationToken 会保持取消状态,until_retired 会立即完成,Gate 会拒绝第一次传输。
| 不变量 | 机制 | 由什么保证 |
|---|---|---|
| 用户表载荷从不携带计数器。 | 每张表都基于 UserTag 构建,计数器来自 admit 中的 NodeTraffic::lookup。 |
结构上由表的类型保证。tests/unit/connector.rs 中的 an_admitted_stream_is_billed_to_its_user 确保计费落在查找到的计数器上 |
| 不在注册表中的键打不开任何流。 | lookup 返回 None,connect 返回 refused()(PermissionDenied)。 |
tests/unit/connector.rs 中的 a_user_the_registry_does_not_know_is_refused |
| 被重新绑定到其他 uid 的凭据不会通过旧用户表向新账户计费。 | lookup 按 c.uid == uid 过滤。 |
tests/unit/connector.rs 中的 a_credential_rebound_to_another_uid_is_refused,tests/unit/traffic.rs 中的 rebound_credential_reports_the_old_uid_separately |
| 不存在被准入的流持有后续提交遗漏的租约。 | admit 与 commit 共用 Admission.leases。 |
结构上保证。tests/unit/connector.rs 中的 the_lease_reaches_the_connection_and_goes_with_the_user 覆盖顺序执行的情形 |
| 退役用户会结束其连接,包括尚未打开的子流。 | 第一个被准入的流把租约发布到 LeaseSlot,drive 对 until_retired 做 select。 |
tests/unit/connector.rs 中的 the_lease_reaches_the_connection_and_goes_with_the_user,tests/unit/serve.rs 中的 a_retired_users_connection_ends |
| 已退役用户的已打开流不再传输任何字节。 | Gate::poll_open 首先检查租约,并返回 ConnectionAborted。 |
tests/unit/meter.rs 中的 a_retired_users_stream_refuses_to_move 和 retiring_the_user_wakes_a_parked_read |
| 未变化用户在刷新后保留其连接和计数器。 | uid 和速率都相同时 prepare 复用该 Arc,且其键不在 cancel_keys 中。 |
tests/unit/e2e.rs 中的 unchanged_user_survives_user_refresh(VMess)、tests/unit/e2e.rs 中的 repeated_user_refreshes_never_disturb_a_live_connection(Hysteria 2) |
| 用户集变化从不重建 Hysteria 2 监听器;被退役的 Hysteria 用户停止,其他用户继续。 | Tables::Hysteria 只替换认证器,由准入负责退役用户。 |
tests/unit/e2e.rs 中的 a_retired_user_stops_while_the_rest_keep_their_connections |
| 速率变化的用户保留租约,其新流使用新速率。 | 对于 uid 相同的速率变化,prepare 把它放入 carried,而不是 cancel_keys。 |
计数器一侧:tests/unit/traffic.rs 中的 rate_change_drains_old_counter_and_reports_once。租约一侧没有针对性测试 |
| 构建失败不改变任何状态。 | refresh 在 commit 之前构建,start 在 NodeTraffic::commit 之前构建和绑定,且 prepare 不做修改。 |
没有测试覆盖 |
| 被换出的计数器既不会丢失,也不会重复上报。 | commit 按 snapshot 的顺序同时持有 users 和 draining,并且不读取字节总数。 |
tests/unit/traffic.rs 中的 a_departed_users_late_bytes_are_still_reported、draining_counter_with_live_writer_is_retained、dropped_user_bytes_become_residuals |
| 同一节点同一时刻只运行一对 prepare/commit。 | NodeManager::run 在一个任务中处理轮询和静态更新,refresh 在 transport 互斥锁下运行。 |
结构上保证 |
三把锁都是 parking_lot 锁,只在少量映射操作期间持有,从不跨越 .await。
| 锁 | 类型 | 获取方 | 顺序 |
|---|---|---|---|
Admission.leases |
Mutex<HashMap<AuthKey, CancellationToken>> |
admit、commit、retire_all |
第 1 |
NodeTraffic.users |
RwLock<HashMap<AuthKey, Arc<UserCounter>>> |
读:lookup、prepare、snapshot;写:commit |
第 2 |
NodeTraffic.draining |
Mutex<Vec<Arc<UserCounter>>> |
commit、snapshot、prune_draining |
第 3,在 users 之后 |
没有任何路径会在持有靠后的锁时再获取靠前的锁。prepare 和 snapshot 从不触碰 leases。
失败路径与取消
Section titled “失败路径与取消”| 情形 | 发生什么 | 表现 |
|---|---|---|
| 来自未注册键或过期 uid 的流 | connect 返回一个已就绪的 future,结果为 refused() |
io::ErrorKind::PermissionDenied,refused。不记录日志 |
| 流打开期间租约被取消 | Gate::poll_open 让下一次传输失败,停住的读操作被唤醒 |
io::ErrorKind::ConnectionAborted,the user was retired |
| 流式连接上的租约被取消 | until_retired 完成,drive 返回 Ok(()),运行时被 drop |
客户端连接关闭。计为正常结束,而非握手失败 |
| Hysteria 2 连接上的租约被取消 | katana 不关闭 QUIC 连接。每个流的 Gate 拒绝传输,新流在 admit 被拒绝 |
客户端的代理流和 UDP 停止中继 |
refresh 中构建替换对象失败 |
不提交也不发布。reconcile 仍把新用户列表记为当前值,因此在用户列表或节点限速再次变化之前不会重试刷新 |
以 error 级别记录 proxy refresh build failed, keeping current: <error> |
TransportManager::start 中构建或绑定失败 |
在 NodeTraffic::commit 之前返回错误,节点没有监听器。若发生在重建中,下一次轮询发现没有监听器,会再次重建。若发生在初次启动时,这次引导尝试失败,1 秒后重试,间隔逐次翻倍,最长 60 秒;若轮询周期更短,则以轮询周期为上限。每次尝试都会重新向面板获取节点及其用户 |
node <id>: rebuild failed: <error> 或 node <id>: initial start failed: <error>; retrying in <n>s |
| 监听器被拆除 | TransportManager::shutdown:关闭作用域、retire_all、release_listener。Drop 会取消作用域 token 并调用 retire_all 作为兜底 |
该节点的所有连接结束 |
Admission::commit 从不等待它所退役的流。它只取消 token 然后返回。被退役的流在各自的任务上自行结束,它们之后写入的数据都落在处于 draining 状态的计数器中,流量计费 会继续上报这些计数器。
| 数量 | 上限 | 由什么保证 |
|---|---|---|
Admission.leases 中的条目 |
最多每个在当前 generation 上曾有流被准入的已注册 AuthKey 一个 |
只有 lookup 成功后才插入条目;键在 cancel_keys 中时移除;retire_all 会清空 |
| 每个连接的租约 | 一个,即第一个被准入流的租约 | send_if_modified 只向空槽发布 |
| 处于 draining 状态的计数器 | 没有写入方残留(Arc::strong_count == 1)后即被清理 |
NodeTraffic::snapshot;关闭流量上报时由 prune_draining 清理 |
| 残余量(residual) | 每个 uid 一个条目 | NodeTraffic::add_residuals 按 uid 合并 |
| 未归属标签 | uid -1,空的 AuthKey::Name |
UserTag::unattributed |
准入路径本身不定义任何超时或大小上限。包围它的握手期限、预认证上限和进度看门狗见 服务连接。
| 测试 | 文件 | 保证 |
|---|---|---|
a_user_the_registry_does_not_know_is_refused |
tests/unit/connector.rs |
空注册表以 PermissionDenied 拒绝流。 |
a_credential_rebound_to_another_uid_is_refused |
tests/unit/connector.rs |
键转移到 uid 2 后,携带旧 uid 的标签被拒绝。 |
an_admitted_stream_is_billed_to_its_user |
tests/unit/connector.rs |
lookup 找到的计数器收到该流的字节,上行 5、下行 5。 |
the_lease_reaches_the_connection_and_goes_with_the_user |
tests/unit/connector.rs |
第一个流向槽中发布一个未取消的租约。对空集执行 Admission::commit 会取消它,下一个流被拒绝。 |
a_retired_users_connection_ends |
tests/unit/serve.rs |
取消已发布的租约后,drive 在一秒内以 Ok(()) 结束。 |
a_retired_users_stream_refuses_to_move |
tests/unit/meter.rs |
通过已退役 Gate 的写操作以 ConnectionAborted 失败。 |
retiring_the_user_wakes_a_parked_read |
tests/unit/meter.rs |
停在沉默对端上的读操作在退役时被唤醒。 |
rate_change_drains_old_counter_and_reports_once |
tests/unit/traffic.rs |
uid 和速率相同则保留计数器。速率变化会装入新计数器,旧字节只上报一次。 |
draining_counter_with_live_writer_is_retained |
tests/unit/traffic.rs |
仍有存活写入方的 draining 计数器会持续被上报,直到写入方 drop。 |
dropped_user_bytes_become_residuals |
tests/unit/traffic.rs |
已离开用户的字节在其离开后仍会被上报。 |
a_departed_users_late_bytes_are_still_reported |
tests/unit/traffic.rs |
在移除该用户的提交之后写入的字节仍会被上报,且只上报一次。 |
rebound_credential_reports_the_old_uid_separately |
tests/unit/traffic.rs |
被重新绑定凭据的新计数器从零开始,旧字节仍归属旧 uid。 |
set_users_drops_absent |
tests/unit/traffic.rs |
未再次列出的键离开注册表。 |
unchanged_user_survives_user_refresh |
tests/unit/e2e.rs |
VMess 连接在新增用户的刷新后存活,在移除其用户的刷新后结束,两段载荷都被上报。 |
a_retired_user_stops_while_the_rest_keep_their_connections |
tests/unit/e2e.rs |
在 Hysteria 2 节点上,被退役用户停止中继,而另一用户原有的 QUIC 连接保持存活并继续中继。 |
repeated_user_refreshes_never_disturb_a_live_connection |
tests/unit/e2e.rs |
六次增删另一用户的刷新后,Hysteria 2 连接仍然存活并在中继。 |
a_hysteria_node_refuses_an_unknown_credential |
tests/unit/e2e.rs |
基于面板用户构建的认证器拒绝面板从未签发的凭据。 |
route_change_drops_connections |
tests/unit/e2e.rs |
冷路径:修改 [node.route] 会重建监听器,已打开的 VMess 连接被断开。 |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
tests/unit/e2e.rs |
绑定失败的初次启动会被重试,重新询问面板,并在端口空闲后开始提供服务。 |
NodeTraffic::set_users 在一步中完成 prepare 和 commit,只在测试中编译。运行时总是分两个阶段分别调用。