跳转到内容

入站与出站的构建

源码文件:48 个 · 核对版本 katana v3.0.1 · Etemenanki 596916d
  • katana/src/inbound.rs
  • katana/src/outbound/mod.rs
  • katana/src/outbound/freedom.rs
  • katana/src/outbound/proxy.rs
  • katana/src/runtime.rs
  • katana/src/main.rs
  • katana/src/router.rs
  • katana/src/serve.rs
  • katana/src/config.rs
  • katana/src/api/mod.rs
  • katana/src/traffic.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/src/manager/proxy.rs
  • katana/src/manager/transport.rs
  • katana/src/connector.rs
  • katana/tests/unit/inbound.rs
  • katana/tests/unit/outbound.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/integration/xray_interop.rs
  • katana/tests/integration/hysteria_interop.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/protocols/src/transports/accept.rs
  • Etemenanki/protocols/src/transports/connect.rs
  • Etemenanki/protocols/src/transports/tls/config.rs
  • Etemenanki/protocols/src/transports/ws/endpoint.rs
  • Etemenanki/protocols/src/transports/grpc/settings.rs
  • Etemenanki/protocols/src/hysteria/server/authenticator.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/server/endpoint.rs
  • Etemenanki/protocols/src/hysteria/server/masquerade.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_legacy/aead.rs
  • Etemenanki/protocols/src/ss_legacy/users.rs
  • Etemenanki/protocols/src/socks/udp_link.rs
  • Etemenanki/protocols/src/socks/protocol.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • Etemenanki/protocols/src/vless/codec.rs
  • Etemenanki/protocols/src/vmess/codec.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/protocols/src/wireguard/connector.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/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 也才能先构建替换用的表,出错时直接丢弃,而不碰正在运行的表。

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。

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_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>,而不是用户的流量计数器:

src/traffic.rs
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>

检查顺序如下:

  1. node.enable_reality 拒绝为 REALITY。
  2. node.accept_proxy_protocol 拒绝为 PROXY protocol accept。
  3. cert.reject_unknown_sni 拒绝为 cert.reject_unknown_sni。没有任何监听器会强制校验 SNI,接受这个键就等于承诺了一项并未生效的控制。
  4. cert.mode 为 "dns"、"http" 或 "tls"(即 ACME 模式)时拒绝为 ACME cert mode "dns"(模式以 Debug 格式带引号打印)。
  5. node.enable_tls 且 cert.mode 不是 "file" 时,以 InvalidInput 失败:TLS node requires cert.mode = "file"。
  6. 映射传输层。不支持的传输层在这里被拒绝,此时尚未读取任何证书文件。
  7. 对 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 指明用户,从不打印凭据:

src/inbound.rs
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 为空,构建函数不报错,产出一张空表。随后监听器照常绑定,但不会认证任何人。

build_shadowsocks 以 shadowsocks node requires at least one user 拒绝空的用户列表。否则内核配置类型的单密码和单 PSK 回退会以无归属 tag 接受流量。节点管理器从不为空用户列表构建监听器(而是拆除监听器),所以这项检查属于防御性措施。

加密方式按以下顺序在两张表中查找:

  1. ss_2022::Method::from_name(node.cypher_method):精确且区分大小写地匹配 2022-blake3-aes-128-gcm、2022-blake3-aes-256-gcm 或 2022-blake3-chacha20-poly1305。
  2. 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>" 拒绝。

  1. 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: … 失败。
  2. 每个用户的 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)。
  3. 用户以 (Ss2022User { psk, email: traffic_email(u) }, tag) 的形式加入 config.users。
  4. 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。

每个用户变为 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。

出于与 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

只要有一个用户有问题,整张表都会失败。刷新时正在运行的表保持不变;冷启动时节点无法启动,节点管理器会重试启动(见 失败路径与取消)。

build_hysteria 一次性产出整个监听器,因为 QUIC endpoint 持有 UDP socket 并自行认证连接。它的检查按以下顺序进行:

  1. node.port == 0 以 hysteria2 node needs a port 失败。
  2. cert.mode != "file" 以 hysteria2 node requires cert.mode = "file" 失败。TLS 握手在 QUIC 内部进行,所以不存在明文模式。
  3. cert.reject_unknown_sni 与 stream 节点一样,以 cert.reject_unknown_sni 拒绝。
  4. 混淆,来自 node.obfs_type 和 node.obfs_password,两者为空时均视为未设置。见下表。
  5. 伪装,来自 [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 以外的状态码。
  6. 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 失败。
  7. 证书,放在最后: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:

protocols/src/hysteria/server/config.rs
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:服务端。

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 用一个占位用户(uid 0,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 构建整个进程唯一的出站池:

src/runtime.rs
pub fn build_outbounds(cfg: &Config) -> io::Result<HashMap<CompactString, Arc<Outbound>>>;
  1. 如果设置了 dns.ca_file 就读取它,并用 Resolver::from_spec(ResolverSpec { backend, server, server_name, url, ca_pem }) 构建一个 Resolver。所有出站共享它,也因此共享它的缓存。
  2. 预置保留 tag。direct 和 freedom 各是一个 Outbound::Direct(FreedomConnector::new(resolver, AddressFamilyStrategy::Auto));block 和 blackhole 各是 Outbound::Block。
  3. 按文件顺序处理每个 [[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 隧道在第一个路由到它的流到来时才建立。

src/outbound/mod.rs
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:

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 闭包时不会发生这种情况,因为链路种类由目的地的网络决定。

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

  1. 协议名。 cfg.protocol 转为 ASCII 小写,因此 "SOCKS5" 和 "socks5" 等价。
  2. 地址族。 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>" 失败。
  3. 直连。 direct 和 freedom 立即返回 Outbound::Direct(FreedomConnector::new(resolver.clone(), address_family))。只有这两个协议不需要 server 和 port。条目的 tag 仍须不同于四个保留 tag:direct、freedom、block 和 blackhole。
  4. 服务器。 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。
  5. 传输层。 所有代理都通过纯 TCP 到达上游:TransportConnector::new(TransportKind::Tcp, Dialer::new(SocketOptions::default()), resolver.clone(), address_family)。不支持通过 TLS、WebSocket 或 gRPC 连接上游。因此 address_family 决定的是上游名称如何解析。
  6. 协议。 每种协议一个分支,见下文。未知名称以 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 也不含密钥。

userpass(cfg) 只有在用户名和密码都设置时才返回 Some((username, password))。缺少任何一个,客户端就不提供认证。ProxyClient 的 make 闭包为每个流构建 SocksConnect::new(&dest, auth) 或 HttpConnect::new(&dest, auth)。

SocksOutbound 为 UDP ASSOCIATE 单独保存一份传输层、服务器和凭据,因为它不是单个流之上的 codec:

  1. 用 transport.dial(&server) 拨一条新的控制流。
  2. SocksUdpLink::associate(control, auth, bind) 依次完成方法协商、认证和 UDP ASSOCIATE 三轮交互。请求不指明来源:它声明的是 0.0.0.0:0(encode_request(CMD_UDP_ASSOCIATE, None)),因为本地 socket 要等知道中继之后才绑定。服务器给出的中继地址必须是 IP 地址。
  3. 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。

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。

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。

build_wireguard_outbound 把条目转换为一个 WgConfig 和一个 WgConnector:

  1. endpoint 是解析后的服务器,其网络切换为 DialNetwork::Udp。
  2. private_key 和 public_key 为必填,由 parse_key 解析,它接受 32 字节的 base64 或 hex 编码。错误为 wireguard outbound <tag> needs a <field> 或 wireguard outbound <tag> <field>: <parse error>。
  3. 每个 local_address 在第一个 / 处截断,去除首尾空白后解析为 IpAddr。前缀长度被直接丢弃,不做检查。错误的地址以 wireguard outbound <tag> invalid local_address "<value>": <error> 失败,空列表以 wireguard outbound <tag> needs at least one local_address 失败。
  4. validate_wg_address_family 要求 ipv4_only 有 IPv4 本地地址、ipv6_only 有 IPv6 本地地址,否则以 wireguard outbound <tag> address_family ipv6_only needs an IPv6 local_address(或对应的 IPv4 形式)失败。其他策略不做检查。
  5. pre_shared_key 可选,解析方式与其他密钥相同。mtu 默认为 DEFAULT_MTU(1420)。keepalive 变为 persistent_keepalive。reserved 必须恰好为 3 字节(wireguard outbound <tag> reserved must be exactly 3 bytes)。
  6. 结果为 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),而不是对池的借用。因此在拨号进行中,重载也可以替换池。

src/outbound/freedom.rs
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 复现
  • 冷构建。 任何入站构建函数的错误都会在绑定之前从 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 二进制,并用从参考源码构建的独立客户端驱动它;工具链或源码树缺失时,各测试会自行跳过。见 测试。

  1. 在 src/config.rs 的 OutboundConfig 中添加该协议的字段。这个结构体是 deny_unknown_fields,所以未声明的字段会导致解析错误。
  2. 选一个大于该 codec STAGING_RESERVE 的缓冲区常量,并在 Outbound 中添加一个变体。如果协议是通往上游的 TCP 流之上的 codec,就使用 ProxyClient<BUF, S, D>;如果不承载数据报,D 用 NoUdp。
  3. 在 connect_stream 和 connect_datagram 中添加分支。两个 match 都是穷尽的,编译器会列出缺失的分支。不支持 UDP 的协议返回 no_udp("<name>")。
  4. 在 build_outbound 中添加分支。错误要指明 cfg.tag,绝不包含凭据。
  5. 在 tests/unit/outbound.rs 中 a_malformed_uuid_is_refused_without_echoing_it 和 an_invalid_address_family_is_rejected_for_every_protocol 旁边添加测试,并把新协议加入它们的协议列表。