入站与出站的构建
源码文件:48 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
katana/src/inbound.rskatana/src/outbound/mod.rskatana/src/outbound/freedom.rskatana/src/outbound/proxy.rskatana/src/runtime.rskatana/src/main.rskatana/src/router.rskatana/src/serve.rskatana/src/config.rskatana/src/api/mod.rskatana/src/traffic.rskatana/src/manager/mod.rskatana/src/manager/node.rskatana/src/manager/proxy.rskatana/src/manager/transport.rskatana/src/connector.rskatana/tests/unit/inbound.rskatana/tests/unit/outbound.rskatana/tests/unit/connector.rskatana/tests/unit/e2e.rskatana/tests/integration/xray_interop.rskatana/tests/integration/hysteria_interop.rsEtemenanki/concepts/src/client.rsEtemenanki/concepts/src/link.rsEtemenanki/protocols/src/transports/accept.rsEtemenanki/protocols/src/transports/connect.rsEtemenanki/protocols/src/transports/tls/config.rsEtemenanki/protocols/src/transports/ws/endpoint.rsEtemenanki/protocols/src/transports/grpc/settings.rsEtemenanki/protocols/src/hysteria/server/authenticator.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/server/endpoint.rsEtemenanki/protocols/src/hysteria/server/masquerade.rsEtemenanki/protocols/src/ss_2022/crypto.rsEtemenanki/protocols/src/ss_2022/users.rsEtemenanki/protocols/src/ss_legacy/aead.rsEtemenanki/protocols/src/ss_legacy/users.rsEtemenanki/protocols/src/socks/udp_link.rsEtemenanki/protocols/src/socks/protocol.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/tests/unit/socks/protocol.rsEtemenanki/protocols/src/vless/codec.rsEtemenanki/protocols/src/vmess/codec.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/protocols/src/wireguard/connector.rsEtemenanki/protocols/src/wireguard/device.rsEtemenanki/protocols/src/helpers/address_family.rs
katana 从配置构建两类对象。入站依据面板对节点的描述(NodeInfo)和节点的用户(UserInfo),再加上本地的证书和 Hysteria 设置构建而成。stream 节点得到一个传输层和一张协议用户表;Hysteria 2 节点得到一个 QUIC 监听器和一个认证器。出站依据配置文件中的 [[outbound]] 条目,每个进程只构建一次,所有节点都路由到同一个池。
本页面向修改 src/inbound.rs 或 src/outbound/ 的贡献者,内容包括:每个构建函数的签名,哪些节点特性会被拒绝、错误文本是什么,用户凭据如何变成表条目,以及每种出站协议如何在内核的客户端运行时之上组装。构建出的对象如何绑定和提供服务,见 监听器与服务循环;每个流如何使用出站池,见 Connector 与 UDP fan-out。
| 构建函数 | 输入 | 输出 | 调用方 |
|---|---|---|---|
build_transport |
&NodeInfo、&CertConfig |
InboundTransport |
TransportManager::start |
build_protocol |
&NodeInfo、有效用户、enable_vless 标志、tag 解析函数 |
StreamProtocol |
TransportManager::start、ProxyManager::refresh |
build_hysteria_authenticator |
&HysteriaConfig、有效用户、tag 解析函数 |
Authenticator<UserTag> |
TransportManager::start、ProxyManager::refresh |
build_hysteria |
&NodeInfo、&HysteriaConfig、&CertConfig、sniff、认证器 |
Hy2Inbound<UserTag> |
TransportManager::start |
validate_hysteria |
&HysteriaConfig、&CertConfig |
io::Result<()> |
runtime::test_config(katana --test) |
build_outbounds |
&Config |
出站池,HashMap<CompactString, Arc<Outbound>> |
runtime::run、apply_reload、test_config |
build_outbound |
一个 &OutboundConfig、共享的 &Resolver |
Outbound |
build_outbounds |
这些函数都不绑定 socket、不 spawn task,也不拨号。入站构建函数会从磁盘读取证书和私钥文件,build_outbounds 会读取 dns.ca_file,除此之外全是对配置的纯判断。正因为这种分离,TransportManager::start 才能在绑定之前运行所有构建函数,错误的节点永远不会只绑定一半;ProxyManager::refresh 也才能先构建替换用的表,出错时直接丢弃,而不碰正在运行的表。
节点如何构建
Section titled “节点如何构建”src/manager/transport.rs 中的 TransportManager::start 按 node.node_type 选择构建函数。两个分支都会在绑定之前完成所有可能失败的构建。
flowchart TB
S["TransportManager::start"] --> E["build_user_entries:暂存条目与有效用户"]
E --> P["traffic.prepare"]
P --> K{"node_type"}
K -->|"Hysteria2"| HA["build_hysteria_authenticator"]
HA --> HB["build_hysteria"]
HB --> BD["bind_datagram"]
K -->|"V2ray, Trojan, Shadowsocks"| BT["build_transport"]
BT --> BP["build_protocol"]
BP --> BL["bind_listener"]
BD --> C["traffic.commit,然后开始服务"]
BL --> C
src/manager/mod.rs 中的 build_user_entries 决定哪些用户会交给构建函数。它用 user_key(by_email, u) 计算每个用户的 AuthKey。uuid 无法解析的 V2ray 用户会被记录为 skipping user <uid>: uuid is not a valid UUID,并且不进入 valid_users。构建函数只会看到 valid_users。
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_transport(node: &NodeInfo, cert: &CertConfig) -> io::Result<InboundTransport>;
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>>;
pub fn build_hysteria( node: &NodeInfo, cfg: &HysteriaConfig, cert: &CertConfig, sniff: bool, authenticator: Authenticator<UserTag>,) -> io::Result<Hy2Inbound<UserTag>>;
pub fn validate_hysteria(cfg: &HysteriaConfig, cert: &CertConfig) -> io::Result<()>;
const HY2_MAX_CONNECTIONS: usize = 4096;传输层和用户表有意分成两个值。用户集刷新只替换 StreamProtocol(ProxyManager 把它放在一个 ArcSwap 中),监听器和所有在用连接都保持不变。Hysteria 监听器自己持有 UDP socket,所以那里只通过 Hy2Inbound::set_authenticator 替换认证器。刷新顺序见 准入与用户表。
每张表的载荷都是 Arc<UserTag>,而不是用户的流量计数器:
pub enum AuthKey { Uuid(Uuid), Name(CompactString),}
pub struct UserTag { pub key: AuthKey, pub uid: i64,}构建函数不自己构造 tag,而是调用 tag_for。TransportManager::start 和 ProxyManager::refresh 都把它定义为 |u| user_tag(by_email, u),其中 by_email = node.node_type.keys_by_email()。这个唯一的来源保证了表中的键与暂存流量注册表中的键一致:
NodeType |
keys_by_email() |
tag 中的 AuthKey |
原因 |
|---|---|---|---|
V2ray |
false |
AuthKey::Uuid(parsed uuid) |
VMess 或 VLESS 账户就是一个 UUID。 |
Trojan |
true |
AuthKey::Name(traffic_email(u)) |
密钥由 UUID 派生,因此由标签来识别用户。 |
Shadowsocks |
true |
AuthKey::Name(traffic_email(u)) |
同上。 |
Hysteria2 |
true |
AuthKey::Name(traffic_email(u)) |
同上。 |
src/api/mod.rs 中的 traffic_email(u) 在面板的 email 非空时取该值,否则取 uid 的十进制字符串。它也是每张协议表上报该用户时使用的标签。
UserTag::unattributed() 是一个 uid 为 -1、AuthKey::Name 为空的 tag,任何已注册用户都无法与它匹配。katana 在两处使用它:一是 Shadowsocks 配置类型要求提供、而 katana 从不用它服务的单密码槽位;二是 validate_hysteria 构建后即丢弃的试运行表中占位用户的 tag。
build_transport 按固定顺序执行检查,遇到第一个失败就返回。内核未实现的特性会以 io::ErrorKind::Unsupported 拒绝,消息中写明特性名,由 unsupported(feature) 生成:
node requests kernel-unsupported feature: <feature>检查顺序如下:
node.enable_reality拒绝为REALITY。node.accept_proxy_protocol拒绝为PROXY protocol accept。cert.reject_unknown_sni拒绝为cert.reject_unknown_sni。没有任何监听器会强制校验 SNI,接受这个键就等于承诺了一项并未生效的控制。cert.mode为"dns"、"http"或"tls"(即 ACME 模式)时拒绝为ACME cert mode "dns"(模式以Debug格式带引号打印)。node.enable_tls且cert.mode不是"file"时,以InvalidInput失败:TLS node requires cert.mode = "file"。- 映射传输层。不支持的传输层在这里被拒绝,此时尚未读取任何证书文件。
- 对 TLS 节点,
read_cert_key要求cert.cert_file和cert.key_file都非空(TLS node requires cert.cert_file and cert.key_file),读取这两个文件,再由ServerConfig::from_pem解析。
检查 1 到 4 适用于所有节点,包括不启用 TLS 的节点,因此即使是明文节点,ACME 和 reject_unknown_sni 的拒绝也会触发。明文节点若使用其他任何 cert.mode(包括未知字符串)都能构建成功,其证书文件也从不会被读取。
Transport 到 InboundTransport 的映射:
node.transport |
不启用 TLS | 启用 TLS | 读取的 NodeInfo 字段 |
|---|---|---|---|
Tcp |
InboundTransport::Tcp |
InboundTransport::Tls(config),ALPN Alpn::None |
无 |
Ws |
InboundTransport::ws(path, host, None) |
同左,改为 Some(config),ALPN Alpn::None |
path、host |
Grpc |
InboundTransport::grpc(service_name, None) |
同左,改为 Some(config),ALPN Alpn::Http2 |
service_name |
HttpUpgrade |
拒绝:httpupgrade transport |
拒绝 | |
SplitHttp |
拒绝:splithttp transport |
拒绝 | |
Other(o) |
拒绝:transport "<o>" |
拒绝 |
- WebSocket。 空的
path变为"/";WsRoute::new还会补上缺失的开头/。空的host变为None。非空的host会使该路由要求Host头的主机名部分(去掉:port后)与它相等,比较时不区分大小写;没有Host头的请求会被拒绝。 - gRPC。
service_name原样传递。GrpcPaths::new在/<service_name>/Tun和/<service_name>/TunMulti上提供服务。 - ALPN。 只有 gRPC 设置 ALPN。使用
Alpn::Http2时,客户端若提供h2,OpenSSL 的 select 回调就选中它,否则不带 ALPN 继续握手。使用Alpn::None时不安装回调。 - TLS 版本。
ServerConfig::from_pem基于 Mozilla intermediate 配置构建 OpenSSL acceptor,最低版本为 TLS 1.2。第一个 PEM 证书是叶证书,其余作为证书链加入。不含证书的 bundle 以no certificate in PEM bundle失败,私钥不匹配则在check_private_key中失败。 - 被忽略的字段。
build_transport不读取authority、header和headers。它们只参与NodeInfo::transport_eq,因此修改它们仍会重建监听器。
InboundTransport::accept 用 TRANSPORT_HANDSHAKE_TIMEOUT(10 秒)限制传输层自身的握手步骤(TLS、WebSocket 升级,或 gRPC 的 TLS 加 HTTP/2 preface);纯 TCP 节点没有这一步。见 传输层:TCP 与 TLS 和 传输层:WebSocket 与 gRPC。
无论节点类型如何,build_protocol 首先以 VLESS XTLS flow 拒绝非空的 node.vless_flow,然后按 node.node_type 分支:
| 节点类型 | 表 | 凭据 | 标签 |
|---|---|---|---|
V2ray,且 enable_vless 或 node.enable_vless 为真 |
vless::Validator,每个用户一次 add(uuid, tag) |
解析后的 UUID | 无;tag 携带 AuthKey::Uuid |
其他 V2ray |
AccountValidator::from_users(accounts) |
解析后的 UUID,仅 VMess AEAD | 无 |
Trojan |
trojan::Validator::new(&trojan_users) |
password 为面板发来的 UUID 字符串 |
email: traffic_email(u) |
Shadowsocks |
build_shadowsocks |
见下文 | traffic_email(u) |
Hysteria2 |
拒绝:hysteria2 does not build as a stream protocol server |
enable_vless 是本地的 [node.api] enable_vless 标志,与面板自己的标志取逻辑或。面板的 alter_id 不会被读取:AccountValidator 只认证 VMess AEAD 头。
两个辅助函数产生针对单个用户的错误。它们都以 uid 指明用户,从不打印凭据:
fn parse_uuid(u: &UserInfo) -> io::Result<Uuid>;fn user_tag( tag_for: &impl Fn(&UserInfo) -> Option<Arc<UserTag>>, u: &UserInfo,) -> io::Result<Arc<UserTag>>;parse_uuid 以 user <uid>: uuid is not a valid UUID 失败,user_tag 以 user <uid> has no key 失败。在当前调用方下两者都不会触发:valid_users 只包含 UUID 可解析的 V2ray 用户,而且每个有效用户都有 tag。保留它们是为了让构建函数单独使用时也保持正确。
当所有 V2ray 用户的 UUID 都无法解析时,valid_users 为空,构建函数不报错,产出一张空表。随后监听器照常绑定,但不会认证任何人。
Shadowsocks 表
Section titled “Shadowsocks 表”build_shadowsocks 以 shadowsocks node requires at least one user 拒绝空的用户列表。否则内核配置类型的单密码和单 PSK 回退会以无归属 tag 接受流量。节点管理器从不为空用户列表构建监听器(而是拆除监听器),所以这项检查属于防御性措施。
加密方式按以下顺序在两张表中查找:
ss_2022::Method::from_name(node.cypher_method):精确且区分大小写地匹配2022-blake3-aes-128-gcm、2022-blake3-aes-256-gcm或2022-blake3-chacha20-poly1305。ss_legacy::Method::from_name(node.cypher_method):不区分大小写,匹配aes-128-gcm、aes-256-gcm、chacha20-poly1305和xchacha20-poly1305,以及别名aead_aes_128_gcm、aead_aes_256_gcm、chacha20-ietf-poly1305、aead_chacha20_poly1305和xchacha20-ietf-poly1305。
其他值一律以 shadowsocks cipher "<method>" 拒绝。
SIP022(Shadowsocks 2022),多用户
Section titled “SIP022(Shadowsocks 2022),多用户”Ss2022ServerConfig::from_password(m, &node.server_key, Arc::new(UserTag::unattributed()))把面板的server_key按标准 base64 解码,并规整为m.key_len()字节。这就是身份 PSK(iPSK)。比key_len长的密钥会被折叠;较短的以shadowsocks-2022: PSK too short (<n> < <key_len>)失败,base64 错误则以decode PSK: …失败。- 每个用户的 PSK(uPSK)是
u.uuid文本的前key_len个字节,而不是解析后的 16 字节。36 个字符的 UUID 总是足够长。更短的字符串以shadowsocks-2022 user <uid> key too short (< <key_len>)失败。这与面板的约定一致:客户端的密码是server_key:base64(first key_len characters of the UUID)。 - 用户以
(Ss2022User { psk, email: traffic_email(u) }, tag)的形式加入config.users。 ss_2022::Validator::from_config(&config)构建扩展身份头(Extended Identity Header)表,以每个 uPSK 的哈希为键。由于 katana 总是有用户,它总会返回Some。它以shadowsocks-2022: multi-user requires an aes-gcm method拒绝2022-blake3-chacha20-poly1305,因此 katana 的 Shadowsocks 2022 节点只支持两种 AES-GCM 方式。
key_len() 对 2022-blake3-aes-128-gcm 为 16,对另外两种方式为 32。
SIP004(AEAD),多用户
Section titled “SIP004(AEAD),多用户”每个用户变为 ShadowsocksUser { password: u.uuid.clone(), email: traffic_email(u) }。Resolved::new 用 evp_bytes_to_key(password, key_len) 为每个用户派生一次主密钥。配置中唯一的 password 是空字符串,配以无归属 tag;由于 users 非空,Resolved::new 从不使用它。
Shadowsocks 的线上格式见 Shadowsocks AEAD 和 Shadowsocks 2022。
Hysteria 2 认证器
Section titled “Hysteria 2 认证器”出于与 Shadowsocks 相同的原因,build_hysteria_authenticator 以 hysteria2 node has no users 拒绝空的用户列表。然后它按 [node.hysteria] credential 构建两种内核表之一:
credential |
内核构造函数 | 客户端在 Hysteria-Auth 中发送的内容 |
每个用户的条目 |
|---|---|---|---|
"" 或 "uuid"(默认) |
Authenticator::passwords |
用户的 UUID 字符串 | (u.uuid, traffic_email(u), tag) |
"user_pass" |
Authenticator::user_pass |
<label>:<uuid>,在第一个冒号处拆分;标签部分转为 ASCII 小写后再比较 |
(traffic_email(u), u.uuid, traffic_email(u), tag) |
| 其他值 | 无 | 拒绝 |
未知的类型以 unknown hysteria credential kind "<kind>" (expected "uuid" or "user_pass") 失败,而不是回退到默认值,因为这个类型决定了是否有任何客户端能够认证。
标签是 traffic_email(u),也就是 user_key(by_email = true, u) 为该用户的 tag 生成键时使用的同一个字符串。因此监听器报告的流所属用户与准入查找的用户总是一致。uuid 之所以是默认值,是因为面板节点代理以 UUID 标识用户,而面板的用户列表中没有单独的密码。
内核构造函数自带的拒绝会原样透出:
| 构造函数 | 条件 | 错误 |
|---|---|---|
passwords |
UUID 字符串为空 | hysteria2: a user needs a credential |
passwords |
两个用户的 UUID 相同 | hysteria2: two users share one credential |
user_pass |
标签或 UUID 为空 | hysteria2: a user needs both a name and a password |
user_pass |
标签中含有 : |
hysteria2: a username cannot contain ':' — it separates the two on the wire |
user_pass |
两个标签转为 ASCII 小写后相同 | hysteria2: two users share a name once lower-cased |
| 两者 | 没有用户 | hysteria2: the user table is empty |
只要有一个用户有问题,整张表都会失败。刷新时正在运行的表保持不变;冷启动时节点无法启动,节点管理器会重试启动(见 失败路径与取消)。
Hysteria 2 监听器
Section titled “Hysteria 2 监听器”build_hysteria 一次性产出整个监听器,因为 QUIC endpoint 持有 UDP socket 并自行认证连接。它的检查按以下顺序进行:
node.port == 0以hysteria2 node needs a port失败。cert.mode != "file"以hysteria2 node requires cert.mode = "file"失败。TLS 握手在 QUIC 内部进行,所以不存在明文模式。cert.reject_unknown_sni与 stream 节点一样,以cert.reject_unknown_sni拒绝。- 混淆,来自
node.obfs_type和node.obfs_password,两者为空时均视为未设置。见下表。 - 伪装,来自
[node.hysteria.masquerade]。三个字段都未设置时为Masquerade::default();否则为Masquerade::new(status or 404, body or "404 page not found\n", content_type or "text/plain; charset=utf-8"),它以hysteria2: 233 is the authentication success status and cannot be used for the masquerade拒绝状态码 233,并以hysteria2: <status> is not an HTTP status code拒绝 100 到 999 以外的状态码。 - UDP。
udp = true时,udp_idle_timeout默认为 60,且必须在2..=600秒之间(udp_idle_timeout must be between 2 and 600 seconds)。udp = false时,若设置了udp_idle_timeout,则以udp_idle_timeout is set but udp is not enabled失败。 - 证书,放在最后:
read_cert_key读取文件(路径为空时的消息与前面共用,即TLS node requires cert.cert_file and cert.key_file),hy2_endpoint::server_config构建 ALPN 为h3的 rustls TLS 1.3 配置。
读取证书有意放在最后。前面每一步都是对配置的判断,如果真正出错的是一处笔误,却报告文件缺失,对排查毫无帮助。单元测试依赖这一顺序:它们传入并不存在的证书路径,仍然期望得到各自的配置错误。
obfs_type |
obfs_password |
结果 |
|---|---|---|
| 空 | 空 | 不混淆 |
| 空 | 已设置 | obfs_password is set but obfs is not; did you mean obfs = "salamander"? |
salamander |
少于 4 字节或为空 | obfs_password must be at least 4 bytes for salamander |
salamander |
4 字节或更多 | Obfs::Salamander { psk } |
| 其他值 | 任意 | unknown obfs "<obfs>" (expected "salamander") |
匹配区分大小写。混淆属于线上格式的一部分,因此它来自 NodeInfo:对面板描述的节点,取面板的答复;对本地描述的节点,取 [node.hysteria](当 [node.hysteria] port 非零时,NodeManager::node_info 会把它复制进来)。其他所有监听器设置都来自本地。
结果包装了一个 ListenerConfig:
pub struct ListenerConfig<T> { pub connection: Arc<ServerConfig<T>>, pub quic: quinn::ServerConfig, pub obfs: Option<Obfs>, pub max_connections: usize,}katana 在 ServerConfig<T> 中填入放在 ArcSwap 里的认证器、伪装、sniff、UDP 空闲超时,以及 circuit_permits: Arc::new(Semaphore::new(MAX_LIVE_CONNECTIONS_PER_NODE)),并把 max_connections 设为 HY2_MAX_CONNECTIONS。QUIC 限制每个连接的流数,但不限制连接数,因此这两个上限就是 Hysteria 节点对应 TCP 路径在用连接上限的机制。监听器如何使用它们,见 Hysteria 2:服务端。
不依赖面板检查 Hysteria 节点
Section titled “不依赖面板检查 Hysteria 节点”validate_hysteria 让 katana --test 能够判断 Hysteria 节点的本地设置,这是其他节点类型的试运行做不到的。它运行真实的构建函数,保证试运行与启动不会产生偏差:
- 它构建一个占位的
NodeInfo:node_type: Hysteria2、enable_tls: true,port取[node.hysteria] port,为零时取1,obfs_type/obfs_password从[node.hysteria] obfs和obfs_password复制。 - 它通过
build_hysteria_authenticator用一个占位用户(uid0,email 和 UUID 均为"placeholder")构建认证器,从而检查凭据类型。 - 它调用
build_hysteria(&node, cfg, cert, false, auth),然后丢弃得到的监听器。
runtime::test_config 对每个 api.node_type 解析为 Hysteria2 的 [[node]] 调用它,并在错误前加上节点 ID:
configuration error: node 1: unknown hysteria credential kind "totp" (expected "uuid" or "user_pass")由于它会读取证书和私钥并构建 QUIC TLS 配置,--test 能发现有问题的 Hysteria 证书。但它发现不了 stream 节点的证书问题,因为 stream 节点的传输层要等面板应答后才会构建。由于占位端口为 1,needs a port 检查在试运行中永远不会触发。
src/inbound.rs 中的所有拒绝,按各构建函数的检查顺序排列。Unsupported 消息带有前缀 node requests kernel-unsupported feature: 。
| 构建函数 | 条件 | 类型 | 消息 |
|---|---|---|---|
build_transport |
enable_reality |
Unsupported |
REALITY |
build_transport |
accept_proxy_protocol |
Unsupported |
PROXY protocol accept |
build_transport |
cert.reject_unknown_sni |
Unsupported |
cert.reject_unknown_sni |
build_transport |
cert.mode 为 dns、http 或 tls |
Unsupported |
ACME cert mode "dns" |
build_transport |
启用 TLS 且 cert.mode != "file" |
InvalidInput |
TLS node requires cert.mode = "file" |
build_transport |
httpupgrade |
Unsupported |
httpupgrade transport |
build_transport |
splithttp 或 xhttp |
Unsupported |
splithttp transport |
build_transport |
其他任何传输层 | Unsupported |
transport "quic" |
build_transport |
启用 TLS,cert_file 或 key_file 为空 |
InvalidInput |
TLS node requires cert.cert_file and cert.key_file |
build_transport |
文件不可读、PEM 有误 | 不定 | 操作系统或 OpenSSL 的错误 |
build_protocol |
vless_flow 非空 |
Unsupported |
VLESS XTLS flow |
build_protocol |
Hysteria2 |
Unsupported |
hysteria2 does not build as a stream protocol server |
build_protocol |
V2ray 用户的 UUID 有误 | InvalidInput |
user <uid>: uuid is not a valid UUID |
build_protocol |
用户没有 tag | InvalidInput |
user <uid> has no key |
build_shadowsocks |
没有用户 | InvalidInput |
shadowsocks node requires at least one user |
build_shadowsocks |
server_key 有误 |
InvalidInput |
decode PSK: … 或 shadowsocks-2022: PSK too short (n < k) |
build_shadowsocks |
UUID 文本短于 key_len |
InvalidInput |
shadowsocks-2022 user <uid> key too short (< k) |
build_shadowsocks |
2022-blake3-chacha20-poly1305 |
InvalidInput |
shadowsocks-2022: multi-user requires an aes-gcm method |
build_shadowsocks |
未知加密方式 | Unsupported |
shadowsocks cipher "rc4-md5" |
build_hysteria_authenticator |
没有用户 | InvalidInput |
hysteria2 node has no users |
build_hysteria_authenticator |
未知的 credential |
InvalidInput |
unknown hysteria credential kind "totp" (expected "uuid" or "user_pass") |
build_hysteria |
port == 0 |
InvalidInput |
hysteria2 node needs a port |
build_hysteria |
cert.mode != "file" |
InvalidInput |
hysteria2 node requires cert.mode = "file" |
build_hysteria |
cert.reject_unknown_sni |
Unsupported |
cert.reject_unknown_sni |
build_hysteria |
混淆或伪装 | InvalidInput |
见上文各表 |
build_hysteria |
UDP 空闲超时 | InvalidInput |
见上文第 6 步 |
build_hysteria |
证书 | 不定 | TLS node requires cert.cert_file and cert.key_file、操作系统错误,或 hy2_endpoint::server_config 给出的证书或 TLS 设置错误,例如 hysteria2: the certificate file contains no certificates 或 hysteria2: certificate and key do not match: … |
src/runtime.rs 中的 build_outbounds 构建整个进程唯一的出站池:
pub fn build_outbounds(cfg: &Config) -> io::Result<HashMap<CompactString, Arc<Outbound>>>;- 如果设置了
dns.ca_file就读取它,并用Resolver::from_spec(ResolverSpec { backend, server, server_name, url, ca_pem })构建一个Resolver。所有出站共享它,也因此共享它的缓存。 - 预置保留 tag。
direct和freedom各是一个Outbound::Direct(FreedomConnector::new(resolver, AddressFamilyStrategy::Auto));block和blackhole各是Outbound::Block。 - 按文件顺序处理每个
[[outbound]]:如果 tag 已在映射中,以duplicate/reserved outbound tag <tag>拒绝;否则插入Arc::new(build_outbound(entry, &resolver)?)。
第一个错误就会使整个池构建失败。启动时,run 记录 failed to build outbounds: <error> 并以失败退出。重载时,apply_reload 只在 [[outbound]] 列表与正在运行的不同时才重建池;出错时记录 reload: bad outbounds, keeping current config: <error>,什么都不应用。由于这项比较只看 [[outbound]],单独修改 [dns] 不会重建池,正在运行的解析器会一直保留,直到下次 [[outbound]] 变化或 katana 重启。在 --test 下它打印 configuration error: <error>。之后构建的路由器按 tag 引用条目;规则中的 tag 若不在池中,路由器构建失败;没有 [node.route] default 的节点把未匹配的流路由到 direct。见 进程运行时与重载。
构建出站不会打开任何 socket。代理出站为每个流拨号到上游;WireGuard 隧道在第一个路由到它的流到来时才建立。
const HTTP_BUF: usize = 16 * 1024;const SOCKS_BUF: usize = 16 * 1024;const VLESS_BUF: usize = 16 * 1024;const VMESS_BUF: usize = 32 * 1024;const SS_BUF: usize = 20 * 1024;const SS2022_BUF: usize = 32 * 1024;
pub enum Outbound { Direct(FreedomConnector), Block, Socks(Box<SocksOutbound>), Http(ProxyClient<HTTP_BUF, HttpConnect, NoUdp>), Vmess(ProxyClient<VMESS_BUF, VMessStream, VMessDatagram>), Vless(ProxyClient<VLESS_BUF, VlessStream, VlessDatagram>), ShadowsocksLegacy(ProxyClient<SS_BUF, SsStream, NoUdp>), Shadowsocks2022(ProxyClient<SS2022_BUF, Ss2022Stream, NoUdp>), Wireguard(WgConnector),}
pub type StreamFuture = Pin<Box<dyn Future<Output = io::Result<OutboundStream>> + Send>>;pub type DatagramFuture = Pin<Box<dyn Future<Output = io::Result<OutboundDatagram>> + Send>>;
impl Outbound { pub fn connect_stream(&self, dest: &Destination) -> StreamFuture; pub fn connect_datagram(&self, dest: &Destination) -> DatagramFuture;}
pub fn refused() -> io::Error;
pub struct SocksOutbound { transport: TransportConnector, server: Destination, auth: Option<(CompactString, CompactString)>, tcp: ProxyClient<SOCKS_BUF, SocksConnect, NoUdp>,}
pub fn build_outbound( cfg: &crate::config::OutboundConfig, resolver: &Resolver,) -> io::Result<Outbound>;每个 *_BUF 常量是该协议客户端运行时的缓冲区大小:codec 打开的最大线上帧,加上 codec 预留的空间。ProxyClientRuntime::new 断言 BUF_SIZE > Codec::STAGING_RESERVE,所以常量过小不会导致构建失败,而是在第一次拨号时 panic。
refused() 是 io::Error::new(io::ErrorKind::PermissionDenied, "refused")。消息有意不给出任何原因。
共享的包装类型和结果类型位于 src/outbound/proxy.rs:
pub type NoUdp = NoCodec<Destination, io::Error>;
pub type Make<S, D> = Box<dyn FnMut(Destination) -> link::Outbound<S, D> + Send>;
pub struct ProxyClient<const BUF: usize, S, D> { inner: Mutex<ProxyClientConnector<BUF, Make<S, D>, TransportConnector, Destination>>,}
impl<const BUF: usize, S, D> ProxyClient<BUF, S, D>where S: ProxyCoreEncode<Target = Destination, Error = io::Error>, D: ProxyCoreEncodeDatagram<Target = Destination, Error = io::Error>,{ pub fn new(make: Make<S, D>, transport: TransportConnector, server: Destination) -> Self; pub fn connect( &self, dest: Destination, ) -> ProxyClientConnecting<BUF, S, D, TransportConnector>;}
pub trait Stream: AsyncRead + AsyncWrite + Send {}pub type ProxyStream = Pin<Box<dyn Stream>>;
pub enum OutboundStream { Tcp(TcpStream), Proxy(ProxyStream), Wg(WgStream),}
pub struct OutboundDatagram(Box<dyn DatagramLink<Addr = Destination> + Send>);
impl OutboundDatagram { pub fn new<D: DatagramLink<Addr = Destination> + Send + 'static>(link: D) -> Self;}ProxyClient包装内核的ProxyClientConnector。内核的Connector::connect接收&mut self,因为make是FnMut,拨号器也是可变的。池分发的是Arc<Outbound>,所以包装类型加了一个parking_lot::Mutex。锁只在connect运行make构建 codec、并向TransportConnector索取装箱的拨号 future 期间持有。持锁期间不做任何 I/O,也不 await 任何东西。Make<S, D>按目的地选择 codec。支持 UDP 的协议在dest.network == DialNetwork::Udp时返回link::Outbound::Datagram(..),否则返回link::Outbound::Stream(..)。不支持 UDP 的协议以NoUdp作为D,这个 codec 类型永远不会被构建。ProxyClientConnecting只有在上游已拨通、codec 握手已完成、暂存的握手字节已 flush 之后才就绪。因此上游拒绝表现为连接错误,而不是之后才失败的中继。OutboundStream是封闭的枚举,而不是装箱的 trait 对象,用来表示两种具体情况:Tcp是直连,Wg是经隧道的连接。Proxy把客户端运行时装箱,因为每种协议的运行时都是各自的类型。它的AsyncRead和AsyncWrite实现把每次调用转发给对应变体。OutboundDatagram把任意DatagramLink<Addr = Destination>装箱,因此FanOut可以在一个集合中持有来自不同出站的链路。
proxy_stream 和 proxy_datagram 把 ProxyClientConnecting 的结果转换成这些类型。如果拨号产出的是另一种链路,它们以 a TCP flow was dialed as datagrams 或 a UDP flow was dialed as a stream 失败。使用上面的 make 闭包时不会发生这种情况,因为链路种类由目的地的网络决定。
构建单个出站
Section titled “构建单个出站”flowchart TB
A["build_outbound(cfg, resolver)"] --> L["protocol = cfg.protocol 转为小写"]
L --> F["parse_address_family"]
F --> D{"direct 或 freedom?"}
D -->|"是"| DR["Outbound::Direct(FreedomConnector)"]
D -->|"否"| S["parse_server(server, port)"]
S --> T["TransportConnector::new(TransportKind::Tcp, ...)"]
T --> P{"protocol"}
P --> PC["socks、http、vmess、vless:ProxyClient"]
P --> SS["shadowsocks 或 ss:build_shadowsocks_outbound"]
P --> WG["wireguard 或 wg:build_wireguard_outbound"]
P --> X["其他:unknown outbound protocol"]
build_outbound 按顺序执行以下步骤:
- 协议名。
cfg.protocol转为 ASCII 小写,因此"SOCKS5"和"socks5"等价。 - 地址族。
parse_address_family对所有协议(包括direct)解析address_family。未设置或空字符串表示AddressFamilyStrategy::Auto。解析器会去除首尾空白、转小写并把-映射为_,接受auto、ipv4_only、ipv6_only、prefer_ipv4和prefer_ipv6,以及ipv4、v4、4等简写别名。其他值以outbound <tag> invalid address_family "<value>"失败。 - 直连。
direct和freedom立即返回Outbound::Direct(FreedomConnector::new(resolver.clone(), address_family))。只有这两个协议不需要server和port。条目的 tag 仍须不同于四个保留 tag:direct、freedom、block和blackhole。 - 服务器。
parse_server(&cfg.server, cfg.port)以outbound needs a non-empty server and non-zero port (got "<server>":<port>)失败。能解析为 IP 地址的服务器变为Remote::IpAddr,否则为Remote::Domain。网络为DialNetwork::Tcp。 - 传输层。 所有代理都通过纯 TCP 到达上游:
TransportConnector::new(TransportKind::Tcp, Dialer::new(SocketOptions::default()), resolver.clone(), address_family)。不支持通过 TLS、WebSocket 或 gRPC 连接上游。因此address_family决定的是上游名称如何解析。 - 协议。 每种协议一个分支,见下文。未知名称以
unknown outbound protocol "<protocol>"失败。
protocol |
变体 | 必填字段 | UDP |
|---|---|---|---|
direct、freedom |
Direct |
无 | 支持,ResolvingUdp |
socks、socks5 |
Socks |
server、port |
支持,UDP ASSOCIATE |
http |
Http |
server、port |
不支持 |
vmess |
Vmess |
server、port、uuid |
支持,发往打开隧道时的目的地 |
vless |
Vless |
server、port、uuid |
支持,发往打开隧道时的目的地 |
shadowsocks、ss |
ShadowsocksLegacy 或 Shadowsocks2022 |
server、port、method、password |
不支持 |
wireguard、wg |
Wireguard |
server、port、private_key、public_key、local_address |
支持 |
构建错误从不回显凭据的值。大多数错误会指明出站的 tag;Shadowsocks 2022 的密钥错误直接来自内核的 decode_psk 和 normalise_psk(decode PSK: …、shadowsocks-2022: PSK too short (n < k)),既不含 tag 也不含密钥。
SOCKS5 与 HTTP
Section titled “SOCKS5 与 HTTP”userpass(cfg) 只有在用户名和密码都设置时才返回 Some((username, password))。缺少任何一个,客户端就不提供认证。ProxyClient 的 make 闭包为每个流构建 SocksConnect::new(&dest, auth) 或 HttpConnect::new(&dest, auth)。
SocksOutbound 为 UDP ASSOCIATE 单独保存一份传输层、服务器和凭据,因为它不是单个流之上的 codec:
- 用
transport.dial(&server)拨一条新的控制流。 SocksUdpLink::associate(control, auth, bind)依次完成方法协商、认证和UDP ASSOCIATE三轮交互。请求不指明来源:它声明的是0.0.0.0:0(encode_request(CMD_UDP_ASSOCIATE, None)),因为本地 socket 要等知道中继之后才绑定。服务器给出的中继地址必须是 IP 地址。bind闭包按中继的地址族绑定一个未指定地址的本地 UDP socket(0.0.0.0:0或[::]:0),设为非阻塞,再用tokio::net::UdpSocket::from_std转换。
链路在 UDP 关联的整个生命周期内保持控制流打开;控制流关闭时,关联随之结束。它把发往中继的每个数据包包上 SOCKS UDP 头,并跳过来自其他地址或无法解析的回复。同一个关联的所有数据包都从这一个 socket 发出,因此会检查来源的上游(例如 Etemenanki 的 socks 入站)会把关联限定在 katana 控制连接所来自的地址,以及它转发的第一个 katana 数据报的端口上。见关联接收谁的数据报。
katana v3.0.1 基于 etemenanki-protocols 2.0.1 构建,其中 poll_recv_from 把回复的来源与中继地址作为普通的 SocketAddr 比较。从 2.0.2 起,它比较的是 endpoint(from) 与 endpoint(relay),即规范形式的 IP 和端口,因此双栈 socket 也能接收 IPv4 中继的回复,即使它把这些回复的来源报告为 IPv4 映射地址。由于 katana 绑定的 socket 与中继属于同一地址族,两种比较接受的都恰好是中继的回复。固定这一行为的测试是在 2.0.2 中加入的:protocols/tests/pipeline/socks.rs 中的 udp_link_ignores_datagrams_not_from_the_relay 和 udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay,以及 protocols/tests/unit/socks/protocol.rs 中的 endpoint_sees_through_ipv4_mapping_and_ignores_flow_info;见客户端:SocksUdpLink。
VMess 与 VLESS
Section titled “VMess 与 VLESS”require_uuid 以 outbound <tag> needs a uuid 或 outbound <tag>: uuid is not a valid UUID 失败。
parse_security 映射转为小写后的 VMess security。未设置和 auto 都选择 AES-128-GCM:
security |
Security |
|---|---|
未设置、auto、aes-128-gcm、aes128gcm |
Aes128Gcm |
chacha20-poly1305、chacha20poly1305 |
ChaCha20Poly1305 |
| 其他值 | unsupported vmess security "<value>" |
global_padding 作为全局填充选项传给两种 VMess codec。make 闭包为目的地构建 VMessStream/VMessDatagram 或 VlessStream/VlessDatagram。
Shadowsocks
Section titled “Shadowsocks”build_shadowsocks_outbound 要求提供 password(shadowsocks outbound <tag> needs a password),并像入站一样按 method 选择代际:
- Shadowsocks 2022(
ss_2022::Method::from_name,精确匹配)。password按:拆分。每一段做 base64 解码(decode_psk,会去除空白),再规整为该方式的密钥长度(normalise_psk,较长的密钥会被折叠,较短的会被拒绝)。最后一个密钥是用户的 uPSK,之前的密钥(如果有)构成身份链。任何一段为空(包括密码整体为空)都会以shadowsocks-2022: PSK too short (0 < k)失败。make闭包构建Ss2022Stream::new(m, psk.clone(), keys.clone(), &dest)。 - 旧版 AEAD(
ss_legacy::Method::from_name,不区分大小写)。主密钥为evp_bytes_to_key(password, m.key_len()),在构建时派生一次。闭包构建SsStream::new(m, key.clone(), &dest)。 - 其他值以
Unsupported失败:unsupported shadowsocks cipher "<method>"。未设置的method视为空字符串。
两个代际都不承载 UDP。
WireGuard
Section titled “WireGuard”build_wireguard_outbound 把条目转换为一个 WgConfig 和一个 WgConnector:
- endpoint 是解析后的服务器,其网络切换为
DialNetwork::Udp。 private_key和public_key为必填,由parse_key解析,它接受 32 字节的 base64 或 hex 编码。错误为wireguard outbound <tag> needs a <field>或wireguard outbound <tag> <field>: <parse error>。- 每个
local_address在第一个/处截断,去除首尾空白后解析为IpAddr。前缀长度被直接丢弃,不做检查。错误的地址以wireguard outbound <tag> invalid local_address "<value>": <error>失败,空列表以wireguard outbound <tag> needs at least one local_address失败。 validate_wg_address_family要求ipv4_only有 IPv4 本地地址、ipv6_only有 IPv6 本地地址,否则以wireguard outbound <tag> address_family ipv6_only needs an IPv6 local_address(或对应的 IPv4 形式)失败。其他策略不做检查。pre_shared_key可选,解析方式与其他密钥相同。mtu默认为DEFAULT_MTU(1420)。keepalive变为persistent_keepalive。reserved必须恰好为 3 字节(wireguard outbound <tag> reserved must be exactly 3 bytes)。- 结果为
WgConnector::with_address_family(wg, address_family).with_resolver(resolver.clone())。
这里的 address_family 和共享解析器作用于经隧道到达的目的地。对端 endpoint 在隧道建立时另行解析,使用主机解析器(tokio::net::lookup_host),取第一个地址。每个本地地址以 /32 或 /128 安装到隧道接口上。WgConnector 的各个克隆共享同一个惰性启动的隧道槽位,因此路由到该出站的所有流都使用同一个设备。隧道的生命周期见 WireGuard。
KatanaConnector 在准入、路由和审计之后为 TCP 流调用 connect_stream,并把结果包进 Metered。当一个 UDP 数据包路由到 FanOut 尚无子链路的出站时,FanOut 调用 connect_datagram。流所属的用户永远不会传到出站:代理客户端用出站自己的凭据认证,而用户已经在本端计过费。
sequenceDiagram participant K as KatanaConnector participant O as Outbound::Vless participant P as ProxyClient participant T as TransportConnector participant U as Upstream K->>O: connect_stream(dest) O->>P: connect(dest.clone()) P->>P: 加锁,make(dest) 构建 VlessStream,解锁 P->>T: connect(server) 返回装箱的拨号 future O-->>K: StreamFuture K->>T: poll:向 server 拨号 TCP T->>U: TCP 连接 K->>U: poll:codec 握手,flush 暂存字节 U-->>K: 就绪 K->>K: OutboundStream::Proxy,包进 Metered
| 变体 | connect_stream |
connect_datagram(dest) |
|---|---|---|
Direct |
FreedomConnector::connect,OutboundStream::Tcp |
bind_udp(),一个 ResolvingUdp;不使用 dest |
Block |
Err(refused()) |
Err(refused()) |
Socks |
使用 SocksConnect 的 ProxyClient |
基于新控制流的 SocksUdpLink;不使用 dest |
Http |
使用 HttpConnect 的 ProxyClient |
http carries no datagrams |
Vmess、Vless |
ProxyClient,stream codec |
ProxyClient,针对 dest 的数据报 codec |
ShadowsocksLegacy |
使用 SsStream 的 ProxyClient |
shadowsocks carries no datagrams |
Shadowsocks2022 |
使用 Ss2022Stream 的 ProxyClient |
shadowsocks-2022 carries no datagrams |
Wireguard |
WgConnector::connect(anonymous(dest)),OutboundStream::Wg |
同一调用,OutboundDatagram |
“carries no datagrams” 这类错误使用 io::ErrorKind::Unsupported。KatanaConnector 会在调用 connect_stream 之前拒绝路由到 Block 的流,所以 Block 分支只是兜底。
anonymous(dest) 构建 WgConnector 所需的 Flow<()>:目的地、空的用户名和密码、() 作为用户数据,没有来源地址。隧道只读取目的地。如果 WireGuard 拨号返回了另一种链路,该分支以 wireguard dialed a TCP flow as UDP 或 wireguard dialed a UDP flow as TCP 失败。
返回的每个 future 都是 'static 且 Send 的:它持有所需对象的克隆(FreedomConnector、WgConnector、SOCKS 传输层和凭据,或 ProxyClientConnecting),而不是对池的借用。因此在拨号进行中,重载也可以替换池。
const MAX_RESOLVED_NAMES: usize = 256;
#[derive(Clone)]pub struct FreedomConnector { dialer: Dialer, resolver: Resolver, strategy: AddressFamilyStrategy,}
impl FreedomConnector { pub fn new(resolver: Resolver, strategy: AddressFamilyStrategy) -> Self; pub async fn connect(&self, dest: &Destination) -> io::Result<TcpStream>; pub fn bind_udp(&self) -> io::Result<ResolvingUdp>;}- TCP。
connect用destination_to_socketaddrs(dest, strategy, &resolver)解析,按策略过滤并排序地址,再由dialer.tcp.connect_any(&addrs)依次尝试。拨号器使用SocketOptions::default()。 - UDP。
bind_udp为策略允许的每个地址族绑定一个 socket(Ipv4Only:V4;Ipv6Only:V6;其他:两者都绑定),记录哪些实际绑定成功,并返回一个ResolvingUdp。它对每个域名目标只解析一次、一次只进行一个查询,按先进先出最多记住MAX_RESOLVED_NAMES个名称,目标没有可用地址时丢弃该数据包。细节见 Connector 与 UDP fan-out。 - 默认值。
FreedomConnector::default()使用Resolver::default()和Auto。测试用它在没有[dns]的情况下构建池。
| 不变量 | 由谁保证 | 由谁固定 |
|---|---|---|
| 内核未实现的节点特性会被拒绝,从不被静默忽略 | build_transport 和 build_protocol 中的 unsupported() |
tests/unit/inbound.rs 中的 reality_rejected、vless_flow_rejected、httpupgrade_rejected、acme_cert_mode_rejected、ss_unsupported_cipher_rejected |
reject_unknown_sni = true 以 Unsupported 拒绝并写明键名;false 可以构建 |
build_transport 检查 3 |
tests/unit/inbound.rs 中的 reject_unknown_sni_is_refused_rather_than_ignored |
TLS 节点需要 cert.mode = "file" |
build_transport 检查 5 |
tests/unit/inbound.rs 中的 tls_without_cert_file_errors |
| 所有可能失败的构建都在绑定 socket 之前完成 | TransportManager::start 中的构建顺序 |
结构性保证;构建函数不接收 socket |
| 表中的键与注册表中的键一致 | 两者都使用 user_tag(by_email, u) 和 NodeType::keys_by_email() |
tests/unit/e2e.rs 中的 vmess_traffic_is_metered_and_reported、a_hysteria_node_relays_and_meters |
| Hysteria 客户端默认用面板 UUID 认证 | credential 为 "" 或 "uuid" 时选择 Authenticator::passwords |
tests/integration/hysteria_interop.rs 中的 a_real_client_proxies_through_a_katana_hysteria_node |
| 面板从未签发的凭据无法认证 | 认证器只包含面板的用户 | tests/unit/e2e.rs 中的 a_hysteria_node_refuses_an_unknown_credential |
未知的凭据类型被拒绝;接受 ""、uuid 和 user_pass |
build_hysteria_authenticator 中的 match |
tests/unit/inbound.rs 中的 hysteria_refuses_an_unknown_credential_kind、hysteria_accepts_both_credential_kinds |
| Hysteria 配置错误先于证书缺失报告 | build_hysteria 中 read_cert_key 最后运行 |
每个 hysteria_refuses_* 测试都传入不存在的证书路径 |
| 混淆不能被静默关闭或削弱 | build_hysteria 中的混淆 match |
hysteria_refuses_an_obfs_password_with_no_obfs、hysteria_refuses_an_unknown_obfs_rather_than_disabling_it、hysteria_refuses_a_short_obfs_password |
| 面板的混淆设置会传到监听器 | NodeInfo.obfs_type 和 obfs_password 输入 build_hysteria |
tests/unit/e2e.rs 中的 a_panel_described_hysteria_node_serves_obfuscated_traffic |
| 伪装永远不会以认证成功的状态码应答 | Masquerade::new 拒绝 233 |
hysteria_refuses_a_masquerade_that_says_authenticated |
| UDP 空闲超时在范围内,且只在启用 UDP 时设置 | build_hysteria 中的 udp 代码块 |
hysteria_refuses_an_out_of_range_udp_idle_timeout、hysteria_refuses_an_idle_timeout_without_udp |
| 没有 TLS 就没有 Hysteria 节点 | cert.mode != "file" 检查 |
hysteria_requires_a_certificate |
| 试运行与启动执行相同的 Hysteria 检查 | validate_hysteria 调用真实的构建函数 |
tests/unit/inbound.rs 中所有 hysteria_* 测试都经由 validate_hysteria |
| katana 从不通过单密钥回退提供 Shadowsocks 服务 | 空用户检查;回退槽位携带 UserTag::unattributed() |
ss_legacy_builds、ss2022_builds 覆盖多用户构建;空用户检查没有测试 |
stream 传输层与真实客户端互通,包括使用 ALPN h2 的 gRPC |
build_transport 映射 |
tests/integration/xray_interop.rs 中的 vmess_tcp_plain、vmess_ws_plain、vmess_grpc_plain、vmess_tcp_tls、vless_ws_tls、vless_grpc_tls、trojan_tcp_tls 及各 _mux 变体 |
address_family 适用于所有出站协议 |
在协议分支之前执行 parse_address_family,并传给 TransportConnector |
tests/unit/outbound.rs 中的 address_family_applies_to_every_protocol |
无效的 address_family 对所有协议都会被拒绝 |
parse_address_family |
an_invalid_address_family_is_rejected_for_every_protocol |
direct 不需要服务器 |
parse_server 之前的提前返回 |
direct_is_configurable_as_an_outbound |
| WireGuard 使用单一地址族策略时,必须有该地址族的本地地址 | validate_wg_address_family |
wireguard_ipv4_only_builds_with_ipv4_address、wireguard_ipv6_only_requires_ipv6_address |
| 出站 UUID 从不在错误中回显 | require_uuid 只指明 tag |
a_malformed_uuid_is_refused_without_echoing_it |
| Shadowsocks 代际由加密方式决定;未知加密方式被拒绝 | build_shadowsocks_outbound |
shadowsocks_builds_both_generations |
| SOCKS 出站的 UDP 关联只接受服务器指定的中继发来的回复 | SocksUdpLink::poll_recv_from 跳过其他任何来源 |
Etemenanki protocols/tests/pipeline/socks.rs 中的 udp_link_ignores_datagrams_not_from_the_relay,于 etemenanki-protocols 2.0.2 加入 |
| 代理出站能够中继,字节计入入站用户 | Metered 之下的 ProxyClient |
tests/unit/e2e.rs 中的 proxy_outbound_relays_and_meters |
| 被阻止的流会被拒绝,且不给出原因 | refused() |
tests/unit/connector.rs 中的 a_blocked_destination_is_refused |
| 保留 tag 不能被重新定义,且 tag 唯一 | build_outbounds 先预置保留 tag,并拒绝已存在的键 |
v3.0.1 中没有单元测试;可用 katana --test 复现 |
失败路径与取消
Section titled “失败路径与取消”- 冷构建。 任何入站构建函数的错误都会在绑定之前从
TransportManager::start传播出去。首次启动时,这个错误只会让一次启动尝试失败:节点管理器记录node <id>: initial start failed: <error>; retrying in <n>s,然后重试,并重新从面板读取节点及其用户。等待时间从 1 秒开始,每次失败后翻倍,最多 60 秒,且不超过节点的轮询周期(controller.update_periodic)。等待期间若有配置修改到达,会立即开始下一次尝试。节点会一直重试,直到构建成功或其 task 被取消。重建时记录node <id>: rebuild failed: <error>。重建会先拆除旧的一代实例再构建,所以构建错误会让节点处于没有监听器的状态,直到之后某个周期构建成功。见 节点管理器。 - 刷新。
ProxyManager::refresh先构建替换用的表。出错时记录proxy refresh build failed, keeping current: <error>,不做任何改动:注册表、租约和正在运行的表都保持不变。但节点管理器仍会把新的用户列表记为当前值,所以只有当之后某次轮询带来不同的用户列表或节点限速时,才会再次尝试刷新。 - 出站池。 见 出站池:第一个错误就会使整个池被拒绝,启动时、重载时和
--test下都是如此。 - 拨号错误表现为解析器、TCP 连接、上游握手或隧道的
io::Error。对 TCP 流,connector 的 future 失败,服务端运行时将其视为连接失败。对 UDP 数据包,FanOut丢弃该数据包,关联继续存在。 - 不支持 UDP。 对
Http、ShadowsocksLegacy或Shadowsocks2022调用connect_datagram会立即以Unsupported失败,FanOut丢弃该数据包。 - 取消。 出站代码不 spawn 任何 task。每个
StreamFuture和DatagramFuture都归连接的运行时所有,drop 它就会取消拨号并关闭它打开的所有 socket。ProxyClient的互斥锁从不跨.await持有,所以被取消的拨号不会让它保持锁定。WireGuard 设备 task 属于隧道槽位,而不属于任何一次拨号。
| 常量 | 值 | 位置 | 含义 |
|---|---|---|---|
HTTP_BUF |
16 KiB | src/outbound/mod.rs |
HTTP CONNECT 客户端运行时缓冲区 |
SOCKS_BUF |
16 KiB | src/outbound/mod.rs |
SOCKS5 客户端运行时缓冲区 |
VLESS_BUF |
16 KiB | src/outbound/mod.rs |
VLESS 客户端运行时缓冲区 |
VMESS_BUF |
32 KiB | src/outbound/mod.rs |
VMess 客户端运行时缓冲区 |
SS_BUF |
20 KiB | src/outbound/mod.rs |
Shadowsocks AEAD 客户端运行时缓冲区 |
SS2022_BUF |
32 KiB | src/outbound/mod.rs |
Shadowsocks 2022 客户端运行时缓冲区 |
MAX_RESOLVED_NAMES |
256 | src/outbound/freedom.rs |
一个直连 UDP 关联记住的名称数 |
HY2_MAX_CONNECTIONS |
4096 | src/inbound.rs |
每个 Hysteria 节点的并发 QUIC 连接数 |
MAX_LIVE_CONNECTIONS_PER_NODE |
65,536 | src/serve.rs |
Hysteria 节点 circuit_permits 信号量的大小 |
udp_idle_timeout |
2 到 600 s,默认 60 | build_hysteria |
Hysteria UDP 关联在空闲多久后退役 |
| Salamander PSK | 至少 4 字节 | build_hysteria |
obfs_password 的最小长度 |
| 伪装默认值 | 404、404 page not found\n、text/plain; charset=utf-8 |
build_hysteria |
设置了部分字段时,用于未设置的字段 |
key_len() |
16 或 32 字节 | ss_2022::Method |
Shadowsocks 2022 iPSK 和 uPSK 的长度 |
DEFAULT_MTU |
1420 | protocols/src/wireguard/config.rs |
未设置 mtu 时的 WireGuard 隧道 MTU |
reserved |
恰好 3 字节 | build_wireguard_outbound |
WireGuard 头部保留字节 |
TRANSPORT_HANDSHAKE_TIMEOUT |
10 s | protocols/src/transports/accept.rs |
入站 TLS、WebSocket 或 gRPC 握手 |
| 测试 | 文件 | 固定的行为 |
|---|---|---|
vmess_tcp_builds |
tests/unit/inbound.rs |
纯 TCP 的 V2ray 节点能构建出传输层和 VMess 表。 |
vless_when_enabled |
tests/unit/inbound.rs |
node.enable_vless 构建出 VLESS 表。 |
trojan_builds |
tests/unit/inbound.rs |
以 UUID 为密码构建出 Trojan 表。 |
reality_rejected |
tests/unit/inbound.rs |
enable_reality 使 build_transport 失败。 |
vless_flow_rejected |
tests/unit/inbound.rs |
xtls-rprx-vision 使 build_protocol 失败。 |
ss_legacy_builds |
tests/unit/inbound.rs |
aes-128-gcm 构建出 SIP004 表。 |
ss2022_builds |
tests/unit/inbound.rs |
2022-blake3-aes-128-gcm 配合 16 字节的 base64 server_key 构建出多用户表。 |
ss_unsupported_cipher_rejected |
tests/unit/inbound.rs |
rc4-md5 被拒绝。 |
httpupgrade_rejected |
tests/unit/inbound.rs |
httpupgrade 传输层被拒绝。 |
acme_cert_mode_rejected |
tests/unit/inbound.rs |
cert.mode = "dns" 被拒绝。 |
tls_without_cert_file_errors |
tests/unit/inbound.rs |
使用默认 cert.mode = "none" 的 TLS 节点失败。 |
reject_unknown_sni_is_refused_rather_than_ignored |
tests/unit/inbound.rs |
返回 Unsupported,消息写明键名;默认值可以构建。 |
hysteria_requires_a_certificate |
tests/unit/inbound.rs |
cert.mode = "none" 被拒绝,消息中写明 cert.mode。 |
hysteria_refuses_an_obfs_password_with_no_obfs |
tests/unit/inbound.rs |
设置了密码但未设置类型时的消息。 |
hysteria_refuses_an_unknown_obfs_rather_than_disabling_it |
tests/unit/inbound.rs |
obfs = "gecko" 被拒绝。 |
hysteria_refuses_a_short_obfs_password |
tests/unit/inbound.rs |
2 字节的 Salamander 密码被拒绝。 |
hysteria_refuses_an_out_of_range_udp_idle_timeout |
tests/unit/inbound.rs |
1 秒和 601 秒被拒绝。 |
hysteria_refuses_an_idle_timeout_without_udp |
tests/unit/inbound.rs |
未启用 udp 却设置超时被拒绝。 |
hysteria_refuses_a_masquerade_that_says_authenticated |
tests/unit/inbound.rs |
伪装状态码 233 被拒绝。 |
hysteria_refuses_an_unknown_credential_kind |
tests/unit/inbound.rs |
credential = "totp" 被拒绝。 |
hysteria_accepts_both_credential_kinds |
tests/unit/inbound.rs |
""、uuid 和 user_pass 仍会因证书不可读而失败,但从不报凭据类型错误。 |
wireguard_ipv4_only_builds_with_ipv4_address |
tests/unit/outbound.rs |
ipv4_only 配合 10.0.0.2/32 可以构建。 |
wireguard_ipv6_only_requires_ipv6_address |
tests/unit/outbound.rs |
ipv6_only 只配 IPv4 地址时以 needs an IPv6 local_address 失败。 |
address_family_applies_to_every_protocol |
tests/unit/outbound.rs |
socks、http、vmess 和 vless 接受 ipv6_only。 |
an_invalid_address_family_is_rejected_for_every_protocol |
tests/unit/outbound.rs |
ipv7 在五种协议下都以 invalid address_family 失败。 |
direct_is_configurable_as_an_outbound |
tests/unit/outbound.rs |
不带服务器和端口的 direct 构建出 Outbound::Direct。 |
a_malformed_uuid_is_refused_without_echoing_it |
tests/unit/outbound.rs |
VMess 和 VLESS 的 UUID 错误不包含该值。 |
shadowsocks_builds_both_generations |
tests/unit/outbound.rs |
aes-256-gcm 构建旧版;2022 方式可以从单个 PSK 和 iPSK:uPSK 链构建;rc4-md5 失败。 |
proxy_outbound_relays_and_meters |
tests/unit/e2e.rs |
VMess 客户端、katana 节点、VLESS 出站和上游 VLESS 服务器中继 50,000 字节,面板为 uid 1001 收到这些字节。 |
a_hysteria_node_relays_and_meters |
tests/unit/e2e.rs |
客户端用面板 UUID 认证,中继 50,000 字节,这些字节被上报。 |
a_hysteria_node_refuses_an_unknown_credential |
tests/unit/e2e.rs |
面板从未签发的 UUID 无法连接。 |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
tests/unit/e2e.rs |
首次启动失败的节点会持续重试,重新向面板请求,并在端口空闲后开始中继。 |
a_panel_described_hysteria_node_serves_obfuscated_traffic |
tests/unit/e2e.rs |
面板的 obfs 和 obfs-password 传到监听器:使用相同 Salamander 密钥的客户端能够中继。 |
a_blocked_destination_is_refused |
tests/unit/connector.rs |
路由到 block 的流被拒绝。 |
vmess_tcp_plain … trojan_tcp_tls_mux |
tests/integration/xray_interop.rs |
以 Xray 作为客户端,通过 TCP、WebSocket 和 gRPC,在启用和不启用 TLS 与 mux 的情况下连接 katana 节点。 |
a_real_client_proxies_through_a_katana_hysteria_node |
tests/integration/hysteria_interop.rs |
上游 Hysteria 客户端只用面板 UUID 认证并完成代理。 |
单元测试用 cargo test 运行。集成测试启动真实的 katana 二进制,并用从参考源码构建的独立客户端驱动它;工具链或源码树缺失时,各测试会自行跳过。见 测试。
新增出站协议
Section titled “新增出站协议”- 在
src/config.rs的OutboundConfig中添加该协议的字段。这个结构体是deny_unknown_fields,所以未声明的字段会导致解析错误。 - 选一个大于该 codec
STAGING_RESERVE的缓冲区常量,并在Outbound中添加一个变体。如果协议是通往上游的 TCP 流之上的 codec,就使用ProxyClient<BUF, S, D>;如果不承载数据报,D用NoUdp。 - 在
connect_stream和connect_datagram中添加分支。两个 match 都是穷尽的,编译器会列出缺失的分支。不支持 UDP 的协议返回no_udp("<name>")。 - 在
build_outbound中添加分支。错误要指明cfg.tag,绝不包含凭据。 - 在
tests/unit/outbound.rs中a_malformed_uuid_is_refused_without_echoing_it和an_invalid_address_family_is_rejected_for_every_protocol旁边添加测试,并把新协议加入它们的协议列表。