跳转到内容

准入与用户表

源码文件:18 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
  • katana/src/connector.rs
  • katana/src/traffic.rs
  • katana/src/meter.rs
  • katana/src/serve.rs
  • katana/src/inbound.rs
  • katana/src/api/mod.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/transport.rs
  • katana/src/manager/node.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/meter.rs
  • katana/tests/unit/serve.rs
  • katana/tests/unit/e2e.rs
  • Etemenanki/concepts/src/net.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/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 是注册表的键。内核的 UserAuthorization 派生了 Eq 但没有派生 Hash,因此 katana 维护自己的投影类型,派生 Clone、Eq 和 Hash。UserTag 派生 Clone 和 Eq,但不派生 Hash:它是载荷,从不作为键。

src/traffic.rs
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 映射为对应的键和标签:

src/manager/mod.rs
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。

内核中的每个协议核心都对用户载荷 T 泛型,并向其 connector 交出 Flow<T>。载荷位于 NetworkUser 中的一个 Arc 之后:

concepts/src/net.rs
pub struct NetworkUser<T> {
pub authorization: UserAuthorization,
pub user_data: std::sync::Arc<T>,
}
protocols/src/flow.rs
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>:

src/inbound.rs
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,因为传入的每个用户此前都已暂存过。

src/traffic.rs
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 保留 否
    是 是 否 新的 UserCounter carried 否
    是 否 任意 新的 UserCounter orphaned 是
    否 不适用 不适用 新的 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,查找会拒绝它,而不是向该凭据现在所属的账户计费。

src/connector.rs
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 各有一个。该用户的每个流和每个连接都持有它的一个克隆,取消它就会一次性退役所有这些流和连接。整个机制只有两个很短的函数,其正确性取决于锁的作用范围:

src/connector.rs
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,其租约映射为空。

src/connector.rs
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 发布自己的租约,之后的流不改动已发布的值。驱动逻辑等待它:

src/serve.rs
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 结束。

src/meter.rs
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 上,因此即使一个读操作正停在沉默的对端上,用户退役时它也会被唤醒。

src/manager/proxy.rs
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,重建它会断开所有已连接的客户端,因此只替换它的认证器:

protocols/src/hysteria/server/inbound.rs
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

被拒绝的准入不记录日志。已离开用户的客户端在察觉之前会不断重试,每次尝试都记一行日志会淹没日志。

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 随后按顺序执行以下步骤:

  1. 暂存。 build_user_entries(node, users) 返回注册表条目以及键解析成功的用户。NodeTraffic::prepare(entries) 返回 PreparedUsers。此时还没有任何改变。

  2. 构建替换对象。 对于 Tables::Stream,build_protocol 构建新的 StreamProtocol;对于 Tables::Hysteria,build_hysteria_authenticator 依据保存的 HysteriaConfig 构建新的认证器。两者使用的 tag_for 与暂存条目时所用的相同。出错时,refresh 记录 proxy refresh build failed, keeping current: <error> 并返回。PreparedUsers 未经提交即被 drop,因此注册表、租约和用户表都保持不变。

  3. 在 leases 锁下提交。 self.dispatcher.admission.commit(prepared) 获取 leases,NodeTraffic::commit 换入新注册表,并把被换出的计数器移入 draining 集合。然后 commit 移除并取消 cancel_keys 中每个键的租约,也就是已离开的用户和被重新绑定到其他 uid 的凭据。它不等待这些流结束,它们的计数器进入 draining。

  4. 发布用户表。 对于流式节点,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

在第 3 步和第 4 步之间,新注册表已经生效,而认证仍由旧用户表完成。这段窗口内的新连接依据旧表认证,长连接则在整个生命周期内一直使用它加载的那张表。已离开的用户在此期间仍可能通过认证,但他打开的每个流都会在 admit 被拒绝。被重新绑定的凭据所携带的旧 uid 标签会被 lookup 的 uid 过滤拒绝。新加入的用户在新表开始接受他之前就已经在注册表中了。

反过来的顺序会让新用户出问题。如果先换用户表,新加入的用户可能依据新表通过认证,而旧注册表会拒绝他打开的每个流。

用户 提交后的注册表 租约 已打开的流和连接 新流
未变化:键、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。

情形 发生什么 表现
来自未注册键或过期 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,只在测试中编译。运行时总是分两个阶段分别调用。