Building inbounds and outbounds
Source files: 48 · checked against 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 builds two kinds of objects from configuration. Inbounds are built from what a panel says about a node (NodeInfo) and its users (UserInfo), plus the local certificate and Hysteria settings. A stream node gets a transport and a protocol user table. A Hysteria 2 node gets one QUIC listener and an authenticator. Outbounds are built once per process from the [[outbound]] entries in the config file, and every node routes into the same pool.
This page is for contributors who change src/inbound.rs or src/outbound/. It covers each builder’s signature, exactly which node features are refused and with what error text, how user credentials become table entries, and how every outbound protocol is assembled on top of the kernel’s client runtime. Binding and serving the built objects are described in Listeners and the serve loop. The per-flow use of the pool is described in Connector and UDP fan-out.
Responsibilities
Section titled “Responsibilities”| Builder | Input | Output | Called from |
|---|---|---|---|
build_transport |
&NodeInfo, &CertConfig |
InboundTransport |
TransportManager::start |
build_protocol |
&NodeInfo, the valid users, the enable_vless flag, a tag resolver |
StreamProtocol |
TransportManager::start, ProxyManager::refresh |
build_hysteria_authenticator |
&HysteriaConfig, the valid users, a tag resolver |
Authenticator<UserTag> |
TransportManager::start, ProxyManager::refresh |
build_hysteria |
&NodeInfo, &HysteriaConfig, &CertConfig, sniff, the authenticator |
Hy2Inbound<UserTag> |
TransportManager::start |
validate_hysteria |
&HysteriaConfig, &CertConfig |
io::Result<()> |
runtime::test_config (katana --test) |
build_outbounds |
&Config |
the pool, HashMap<CompactString, Arc<Outbound>> |
runtime::run, apply_reload, test_config |
build_outbound |
one &OutboundConfig, the shared &Resolver |
Outbound |
build_outbounds |
None of these functions binds a socket, spawns a task or dials anything. The inbound builders read the certificate and key files from disk; build_outbounds reads dns.ca_file. Everything else is a pure judgement about configuration. That separation is what lets TransportManager::start run every builder before it binds, so a bad node never half-binds, and what lets ProxyManager::refresh build a replacement table and discard it on error without touching the running one.
Inbounds
Section titled “Inbounds”How a node is built
Section titled “How a node is built”TransportManager::start in src/manager/transport.rs chooses the builders by node.node_type. Both branches finish every fallible build before they bind.
flowchart TB
S["TransportManager::start"] --> E["build_user_entries: staged entries and valid users"]
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, then serve"]
BL --> C
build_user_entries in src/manager/mod.rs decides which users reach the builders. It computes each user’s AuthKey with user_key(by_email, u). A V2ray user whose uuid does not parse is logged as skipping user <uid>: uuid is not a valid UUID and left out of valid_users. The builders only ever see valid_users.
Key types
Section titled “Key types”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;The transport and the user table are separate values on purpose. A user-set refresh replaces only the StreamProtocol, which ProxyManager holds in an ArcSwap, while the listener and every live connection stay as they are. A Hysteria listener owns its UDP socket, so there only the authenticator is swapped, through Hy2Inbound::set_authenticator. See Admission and user tables for the refresh order.
Every table’s payload is an Arc<UserTag>, never the user’s traffic counter:
pub enum AuthKey { Uuid(Uuid), Name(CompactString),}
pub struct UserTag { pub key: AuthKey, pub uid: i64,}The builders do not construct tags themselves. They call tag_for, which TransportManager::start and ProxyManager::refresh both define as |u| user_tag(by_email, u), with by_email = node.node_type.keys_by_email(). That single source makes the key in the table and the key in the staged traffic registry agree:
NodeType |
keys_by_email() |
AuthKey in the tag |
Why |
|---|---|---|---|
V2ray |
false |
AuthKey::Uuid(parsed uuid) |
A VMess or VLESS account is a UUID. |
Trojan |
true |
AuthKey::Name(traffic_email(u)) |
The secret is derived from the UUID, so the label identifies the user. |
Shadowsocks |
true |
AuthKey::Name(traffic_email(u)) |
Same. |
Hysteria2 |
true |
AuthKey::Name(traffic_email(u)) |
Same. |
traffic_email(u) in src/api/mod.rs is the panel’s email when it is non-empty, and otherwise the uid as a decimal string. It is also the label each protocol table reports for the user.
UserTag::unattributed() is a tag with uid -1 and an empty AuthKey::Name, which no registered user can match. katana uses it in two places: the single-password slot that the Shadowsocks config types require and katana never serves from, and the placeholder user’s tag in the dry-run table that validate_hysteria builds and discards.
The transport
Section titled “The transport”build_transport runs its checks in a fixed order and returns at the first failure. Features the kernel does not implement are refused with io::ErrorKind::Unsupported and a message that names the feature, built by unsupported(feature):
node requests kernel-unsupported feature: <feature>The order is:
node.enable_realityrefusesREALITY.node.accept_proxy_protocolrefusesPROXY protocol accept.cert.reject_unknown_snirefusescert.reject_unknown_sni. No listener enforces SNI, so accepting the key would promise a control that is not in force.cert.modeof"dns","http"or"tls"(the ACME modes) refusesACME cert mode "dns"(the mode is printed withDebugquoting).node.enable_tlswith anycert.modeother than"file"fails withInvalidInput:TLS node requires cert.mode = "file".- The transport is mapped. Unsupported transports are refused here, before any certificate file is read.
- For a TLS node,
read_cert_keyrequires bothcert.cert_fileandcert.key_fileto be non-empty (TLS node requires cert.cert_file and cert.key_file), reads both files, andServerConfig::from_pemparses them.
Checks 1 to 4 apply to every node, including nodes without TLS, so the ACME and reject_unknown_sni refusals fire even on a plaintext node. A plaintext node with any other cert.mode, including an unknown string, builds, and its certificate files are never read.
The mapping from Transport to InboundTransport:
node.transport |
Without TLS | With TLS | NodeInfo fields read |
|---|---|---|---|
Tcp |
InboundTransport::Tcp |
InboundTransport::Tls(config), ALPN Alpn::None |
none |
Ws |
InboundTransport::ws(path, host, None) |
the same with Some(config), ALPN Alpn::None |
path, host |
Grpc |
InboundTransport::grpc(service_name, None) |
the same with Some(config), ALPN Alpn::Http2 |
service_name |
HttpUpgrade |
refused: httpupgrade transport |
refused | |
SplitHttp |
refused: splithttp transport |
refused | |
Other(o) |
refused: transport "<o>" |
refused |
- WebSocket. An empty
pathbecomes"/";WsRoute::newalso adds a missing leading/. An emptyhostbecomesNone. A non-emptyhostmakes the route require aHostheader whose name part (any:portremoved) equals it, compared case-insensitively; a request withoutHostis refused. - gRPC.
service_nameis passed through unchanged.GrpcPaths::newserves/<service_name>/Tunand/<service_name>/TunMulti. - ALPN. Only gRPC sets ALPN. With
Alpn::Http2, the OpenSSL select callback picksh2when the client offers it, and otherwise continues the handshake without ALPN. WithAlpn::None, no callback is installed. - TLS versions.
ServerConfig::from_pembuilds an OpenSSL acceptor from the Mozilla intermediate profile with a minimum of TLS 1.2. The first PEM certificate is the leaf and the rest are added as the chain. A bundle with no certificate fails withno certificate in PEM bundle, and a mismatched key fails incheck_private_key. - Ignored fields.
authority,headerandheadersare not read bybuild_transport. They take part only inNodeInfo::transport_eq, so changing them still rebuilds the listener.
InboundTransport::accept bounds the transport’s own handshake step (TLS, the WebSocket upgrade, or TLS plus the HTTP/2 preface for gRPC) by TRANSPORT_HANDSHAKE_TIMEOUT (10 seconds); a plain TCP node has no such step. See TCP and TLS transports and WebSocket and gRPC transports.
The protocol table
Section titled “The protocol table”build_protocol first refuses a non-empty node.vless_flow with VLESS XTLS flow, whatever the node type. It then branches on node.node_type:
| Node type | Table | Credential | Label |
|---|---|---|---|
V2ray with enable_vless or node.enable_vless |
vless::Validator, one add(uuid, tag) per user |
the parsed UUID | none; the tag carries AuthKey::Uuid |
V2ray otherwise |
AccountValidator::from_users(accounts) |
the parsed UUID, VMess AEAD only | none |
Trojan |
trojan::Validator::new(&trojan_users) |
password is the UUID string as sent by the panel |
email: traffic_email(u) |
Shadowsocks |
build_shadowsocks |
see below | traffic_email(u) |
Hysteria2 |
refused: hysteria2 does not build as a stream protocol server |
enable_vless is the local [node.api] enable_vless flag; it is OR-ed with the panel’s own flag. The panel’s alter_id is not read: AccountValidator authenticates VMess AEAD headers only.
Two helpers produce the per-user errors. Both name the user by uid and never print the credential:
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 fails with user <uid>: uuid is not a valid UUID, and user_tag with user <uid> has no key. With the current callers neither can fire: valid_users holds only V2ray users whose UUID parses, and every valid user has a tag. They keep the builders correct on their own.
When every V2ray user has an unparseable UUID, valid_users is empty and the builder produces an empty table without error. The listener then binds and authenticates nobody.
Shadowsocks tables
Section titled “Shadowsocks tables”build_shadowsocks refuses an empty user list with shadowsocks node requires at least one user. The single-password and single-PSK fallbacks of the kernel config types would otherwise accept traffic under the unattributed tag. The node manager never builds a listener for an empty user list (it tears the listener down instead), so this guard is defensive.
The cipher is looked up in two tables, in this order:
ss_2022::Method::from_name(node.cypher_method): an exact, case-sensitive match of2022-blake3-aes-128-gcm,2022-blake3-aes-256-gcmor2022-blake3-chacha20-poly1305.ss_legacy::Method::from_name(node.cypher_method): case-insensitive,aes-128-gcm,aes-256-gcm,chacha20-poly1305andxchacha20-poly1305, with the aliasesaead_aes_128_gcm,aead_aes_256_gcm,chacha20-ietf-poly1305,aead_chacha20_poly1305andxchacha20-ietf-poly1305.
Anything else is refused with shadowsocks cipher "<method>".
SIP022 (Shadowsocks 2022), multi-user
Section titled “SIP022 (Shadowsocks 2022), multi-user”Ss2022ServerConfig::from_password(m, &node.server_key, Arc::new(UserTag::unattributed()))decodes the panel’sserver_keyas standard base64 and normalises it tom.key_len()bytes. This is the identity PSK (iPSK). A key longer thankey_lenis folded; a shorter one fails withshadowsocks-2022: PSK too short (<n> < <key_len>), and bad base64 withdecode PSK: ….- Each user’s PSK (uPSK) is the first
key_lenbytes of the text ofu.uuid, not its parsed 16 bytes. A 36-character UUID always has enough. A shorter string fails withshadowsocks-2022 user <uid> key too short (< <key_len>). This matches the panel convention in which a client’s password isserver_key:base64(first key_len characters of the UUID). - The users are pushed onto
config.usersas(Ss2022User { psk, email: traffic_email(u) }, tag). ss_2022::Validator::from_config(&config)builds the Extended Identity Header table, keyed by the hash of each uPSK. Because katana always has users, it always returnsSome. It refuses2022-blake3-chacha20-poly1305withshadowsocks-2022: multi-user requires an aes-gcm method, so a katana Shadowsocks 2022 node serves only the two AES-GCM methods.
key_len() is 16 for 2022-blake3-aes-128-gcm and 32 for the other two methods.
SIP004 (AEAD), multi-user
Section titled “SIP004 (AEAD), multi-user”Each user becomes ShadowsocksUser { password: u.uuid.clone(), email: traffic_email(u) }. Resolved::new derives every user’s master key once, with evp_bytes_to_key(password, key_len). The config’s single password is the empty string with the unattributed tag, and because users is non-empty Resolved::new never uses it.
The Shadowsocks wire formats are described in Shadowsocks and Shadowsocks 2022.
The Hysteria 2 authenticator
Section titled “The Hysteria 2 authenticator”build_hysteria_authenticator refuses an empty user list with hysteria2 node has no users, for the same reason as Shadowsocks. It then builds one of two kernel tables according to [node.hysteria] credential:
credential |
Kernel constructor | What the client sends in Hysteria-Auth |
Entry per user |
|---|---|---|---|
"" or "uuid" (default) |
Authenticator::passwords |
the user’s UUID string | (u.uuid, traffic_email(u), tag) |
"user_pass" |
Authenticator::user_pass |
<label>:<uuid>, split on the first colon; the label part is compared after ASCII lower-casing |
(traffic_email(u), u.uuid, traffic_email(u), tag) |
| anything else | none | refused |
An unknown kind fails with unknown hysteria credential kind "<kind>" (expected "uuid" or "user_pass") instead of falling back to a default, because the kind decides whether any client can authenticate at all.
The label is traffic_email(u), the same string user_key(by_email = true, u) keys the user’s tag by. What the listener reports as the flow’s user and what admission looks up therefore always agree. uuid is the default because panel node agents key users by UUID, and the panel’s user list has no separate password.
The kernel constructors add their own refusals, which surface unchanged:
| Constructor | Condition | Error |
|---|---|---|
passwords |
an empty UUID string | hysteria2: a user needs a credential |
passwords |
two users with the same UUID | hysteria2: two users share one credential |
user_pass |
an empty label or UUID | hysteria2: a user needs both a name and a password |
user_pass |
a label containing : |
hysteria2: a username cannot contain ':' — it separates the two on the wire |
user_pass |
two labels equal after ASCII lower-casing | hysteria2: two users share a name once lower-cased |
| both | no users | hysteria2: the user table is empty |
A single bad user fails the whole table. On a refresh the running table stays in place; on a cold start the node does not come up, and the node manager retries the start (see Failure paths and cancellation).
The Hysteria 2 listener
Section titled “The Hysteria 2 listener”build_hysteria produces the whole listener in one piece, because the QUIC endpoint owns the UDP socket and authenticates connections itself. Its checks run in this order:
node.port == 0fails withhysteria2 node needs a port.cert.mode != "file"fails withhysteria2 node requires cert.mode = "file". The TLS handshake is inside QUIC, so there is no plaintext mode.cert.reject_unknown_sniis refused ascert.reject_unknown_sni, as for stream nodes.- Obfuscation, from
node.obfs_typeandnode.obfs_password, each treated as absent when empty. See the table below. - Masquerade, from
[node.hysteria.masquerade]. With all three fields unset it isMasquerade::default(). OtherwiseMasquerade::new(status or 404, body or "404 page not found\n", content_type or "text/plain; charset=utf-8"), which refuses status 233 withhysteria2: 233 is the authentication success status and cannot be used for the masqueradeand a status outside 100 to 999 withhysteria2: <status> is not an HTTP status code. - UDP. With
udp = true,udp_idle_timeoutdefaults to 60 and must be in2..=600seconds (udp_idle_timeout must be between 2 and 600 seconds). Withudp = false, a setudp_idle_timeoutfails withudp_idle_timeout is set but udp is not enabled. - Certificate, last:
read_cert_keyreads the files (its empty-path message is the sharedTLS node requires cert.cert_file and cert.key_file), andhy2_endpoint::server_configbuilds a rustls TLS 1.3 config with ALPNh3.
Reading the certificate comes last on purpose. Every earlier step is a judgement about the config, and reporting a missing file instead of the typo that is really wrong would not help. The unit tests rely on this order: they pass certificate paths that do not exist and still expect each config error.
obfs_type |
obfs_password |
Result |
|---|---|---|
| empty | empty | no obfuscation |
| empty | set | obfs_password is set but obfs is not; did you mean obfs = "salamander"? |
salamander |
fewer than 4 bytes, or empty | obfs_password must be at least 4 bytes for salamander |
salamander |
4 bytes or more | Obfs::Salamander { psk } |
| anything else | any | unknown obfs "<obfs>" (expected "salamander") |
The match is case-sensitive. Obfuscation is part of the wire, so it comes from NodeInfo: the panel’s answer for a panel-described node, or [node.hysteria] for a locally described one (NodeManager::node_info copies it in when [node.hysteria] port is non-zero). Every other listener setting is local.
The result wraps a ListenerConfig:
pub struct ListenerConfig<T> { pub connection: Arc<ServerConfig<T>>, pub quic: quinn::ServerConfig, pub obfs: Option<Obfs>, pub max_connections: usize,}katana fills ServerConfig<T> with the authenticator in an ArcSwap, the masquerade, sniff, the UDP idle timeout and circuit_permits: Arc::new(Semaphore::new(MAX_LIVE_CONNECTIONS_PER_NODE)), and sets max_connections to HY2_MAX_CONNECTIONS. QUIC bounds streams per connection but not connections, so these two limits are the Hysteria node’s counterpart of the TCP path’s live-connection cap. What the listener does with them is described in Hysteria 2 server.
Checking a Hysteria node without the panel
Section titled “Checking a Hysteria node without the panel”validate_hysteria lets katana --test judge a Hysteria node’s local settings, which a dry run could not do for any other node type. It runs the real builders so the dry run and startup cannot drift apart:
- It builds a placeholder
NodeInfowithnode_type: Hysteria2,enable_tls: true,portset to[node.hysteria] portor to1when that is zero, andobfs_type/obfs_passwordcopied from[node.hysteria] obfsandobfs_password. - It builds the authenticator through
build_hysteria_authenticatorfrom one placeholder user (uid0, email and UUID"placeholder"), so the credential kind is checked. - It calls
build_hysteria(&node, cfg, cert, false, auth)and discards the listener.
runtime::test_config calls it for each [[node]] whose api.node_type parses as Hysteria2, and prefixes errors with the node ID:
configuration error: node 1: unknown hysteria credential kind "totp" (expected "uuid" or "user_pass")Because it reads the certificate and key and builds the QUIC TLS config, --test catches a bad Hysteria certificate. It does not catch a bad certificate on a stream node, whose transport is only built once the panel has answered. With a placeholder port of 1, the needs a port check never fires in a dry run.
Refusal matrix
Section titled “Refusal matrix”Every refusal in src/inbound.rs, in the order each builder checks it. Unsupported messages carry the prefix node requests kernel-unsupported feature: .
| Builder | Condition | Kind | Message |
|---|---|---|---|
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 is dns, http or tls |
Unsupported |
ACME cert mode "dns" |
build_transport |
TLS and cert.mode != "file" |
InvalidInput |
TLS node requires cert.mode = "file" |
build_transport |
httpupgrade |
Unsupported |
httpupgrade transport |
build_transport |
splithttp or xhttp |
Unsupported |
splithttp transport |
build_transport |
any other transport | Unsupported |
transport "quic" |
build_transport |
TLS, empty cert_file or key_file |
InvalidInput |
TLS node requires cert.cert_file and cert.key_file |
build_transport |
unreadable file, bad PEM | varies | the OS or OpenSSL error |
build_protocol |
non-empty vless_flow |
Unsupported |
VLESS XTLS flow |
build_protocol |
Hysteria2 |
Unsupported |
hysteria2 does not build as a stream protocol server |
build_protocol |
V2ray user, bad UUID | InvalidInput |
user <uid>: uuid is not a valid UUID |
build_protocol |
user without a tag | InvalidInput |
user <uid> has no key |
build_shadowsocks |
no users | InvalidInput |
shadowsocks node requires at least one user |
build_shadowsocks |
bad server_key |
InvalidInput |
decode PSK: … or shadowsocks-2022: PSK too short (n < k) |
build_shadowsocks |
UUID text shorter than 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 |
unknown cipher | Unsupported |
shadowsocks cipher "rc4-md5" |
build_hysteria_authenticator |
no users | InvalidInput |
hysteria2 node has no users |
build_hysteria_authenticator |
unknown 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 |
obfuscation or masquerade | InvalidInput |
see the tables above |
build_hysteria |
UDP idle timeout | InvalidInput |
see step 6 above |
build_hysteria |
certificate | varies | TLS node requires cert.cert_file and cert.key_file, the OS error, or a certificate or TLS setup error from hy2_endpoint::server_config, such as hysteria2: the certificate file contains no certificates or hysteria2: certificate and key do not match: … |
Outbounds
Section titled “Outbounds”The pool
Section titled “The pool”build_outbounds in src/runtime.rs builds the process’s one outbound pool:
pub fn build_outbounds(cfg: &Config) -> io::Result<HashMap<CompactString, Arc<Outbound>>>;- Read
dns.ca_filewhen it is set, and build oneResolverwithResolver::from_spec(ResolverSpec { backend, server, server_name, url, ca_pem }). Every outbound shares it, and so its cache. - Seed the reserved tags.
directandfreedomare each anOutbound::Direct(FreedomConnector::new(resolver, AddressFamilyStrategy::Auto)).blockandblackholeare eachOutbound::Block. - For each
[[outbound]]in file order, refuse a tag already in the map withduplicate/reserved outbound tag <tag>, then insertArc::new(build_outbound(entry, &resolver)?).
The first error aborts the whole pool. At startup run logs failed to build outbounds: <error> and exits with failure. On a reload, apply_reload rebuilds the pool only when the [[outbound]] list differs from the running one; on error it logs reload: bad outbounds, keeping current config: <error> and applies nothing. Because the check looks only at [[outbound]], an edit to [dns] alone does not rebuild the pool, and the running resolver stays until [[outbound]] next changes or katana restarts. Under --test it prints configuration error: <error>. Routers built later refer to entries by tag; a rule naming a tag that is not in the pool fails the router build, and a node without [node.route] default routes unmatched flows to direct. See Process runtime and reload.
Building an outbound opens no socket. A proxy outbound dials its upstream per flow; the WireGuard tunnel comes up on the first flow routed to it.
Key types
Section titled “Key types”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>;Each *_BUF constant is the client runtime’s buffer size for that protocol: the largest wire frame the codec opens, plus what the codec reserves. ProxyClientRuntime::new asserts BUF_SIZE > Codec::STAGING_RESERVE, so a constant that is too small panics on the first dial rather than failing to build.
refused() is io::Error::new(io::ErrorKind::PermissionDenied, "refused"). The message deliberately gives no reason.
The shared wrapper and result types live in 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;}ProxyClientwraps the kernel’sProxyClientConnector. The kernelConnector::connecttakes&mut self, becausemakeis anFnMutand the dialer is mutable. The pool hands outArc<Outbound>, so the wrapper adds aparking_lot::Mutex. The lock is held only whileconnectrunsmaketo build the codec and asksTransportConnectorfor its boxed dial future. No I/O happens and nothing is awaited under the lock.Make<S, D>picks the codec per destination. Protocols with UDP returnlink::Outbound::Datagram(..)whendest.network == DialNetwork::Udpandlink::Outbound::Stream(..)otherwise. Protocols without UDP useNoUdpforD, a codec type that is never built.ProxyClientConnectingresolves only once the upstream is dialed, the codec’s handshake has run and the staged handshake bytes are flushed. A refused upstream is therefore a connect error, not a relay that fails later.OutboundStreamis a closed enum rather than a boxed trait object for the two concrete cases.Tcpis a direct connection andWga tunnelled one.Proxyboxes the client runtime, since every protocol’s runtime is its own type. ItsAsyncReadandAsyncWriteimplementations forward each call to the variant.OutboundDatagramboxes anyDatagramLink<Addr = Destination>, soFanOutcan hold links from different outbounds in one collection.
proxy_stream and proxy_datagram turn a ProxyClientConnecting result into these types. When the dial produced the other kind they fail with a TCP flow was dialed as datagrams or a UDP flow was dialed as a stream. With the make closures above that cannot happen, since the destination’s network picks the kind.
Building one outbound
Section titled “Building one outbound”flowchart TB
A["build_outbound(cfg, resolver)"] --> L["protocol = cfg.protocol lowercased"]
L --> F["parse_address_family"]
F --> D{"direct or freedom?"}
D -->|"yes"| DR["Outbound::Direct(FreedomConnector)"]
D -->|"no"| S["parse_server(server, port)"]
S --> T["TransportConnector::new(TransportKind::Tcp, ...)"]
T --> P{"protocol"}
P --> PC["socks, http, vmess, vless: ProxyClient"]
P --> SS["shadowsocks or ss: build_shadowsocks_outbound"]
P --> WG["wireguard or wg: build_wireguard_outbound"]
P --> X["anything else: unknown outbound protocol"]
build_outbound runs these steps in order:
- Protocol name.
cfg.protocolis ASCII-lowercased, so"SOCKS5"and"socks5"are the same. - Address family.
parse_address_familyparsesaddress_familyfor every protocol, includingdirect. Unset, or an empty string, meansAddressFamilyStrategy::Auto. The parser trims, lowercases and maps-to_, and acceptsauto,ipv4_only,ipv6_only,prefer_ipv4andprefer_ipv6plus short aliases such asipv4,v4and4. Anything else fails withoutbound <tag> invalid address_family "<value>". - Direct.
directandfreedomreturnOutbound::Direct(FreedomConnector::new(resolver.clone(), address_family))at once. They are the only protocols that need noserverandport. The entry’s tag must still differ from the four reserved tagsdirect,freedom,blockandblackhole. - Server.
parse_server(&cfg.server, cfg.port)fails withoutbound needs a non-empty server and non-zero port (got "<server>":<port>). A server that parses as an IP address becomesRemote::IpAddr, anything elseRemote::Domain. The network isDialNetwork::Tcp. - Transport. Every proxy reaches its upstream over plain TCP:
TransportConnector::new(TransportKind::Tcp, Dialer::new(SocketOptions::default()), resolver.clone(), address_family). TLS, WebSocket and gRPC toward an upstream are not supported.address_familytherefore governs how the upstream’s name is resolved. - Protocol. One arm per protocol, described below. An unknown name fails with
unknown outbound protocol "<protocol>".
protocol |
Variant | Required fields | UDP |
|---|---|---|---|
direct, freedom |
Direct |
none | yes, ResolvingUdp |
socks, socks5 |
Socks |
server, port |
yes, UDP ASSOCIATE |
http |
Http |
server, port |
no |
vmess |
Vmess |
server, port, uuid |
yes, toward the opening destination |
vless |
Vless |
server, port, uuid |
yes, toward the opening destination |
shadowsocks, ss |
ShadowsocksLegacy or Shadowsocks2022 |
server, port, method, password |
no |
wireguard, wg |
Wireguard |
server, port, private_key, public_key, local_address |
yes |
No build error echoes a credential’s value. Most name the outbound’s tag; the Shadowsocks 2022 key errors come straight from the kernel’s decode_psk and normalise_psk (decode PSK: …, shadowsocks-2022: PSK too short (n < k)) and name neither the tag nor the key.
SOCKS5 and HTTP
Section titled “SOCKS5 and HTTP”userpass(cfg) returns Some((username, password)) only when both are set. With either one missing, the client offers no authentication. The ProxyClient’s make closure builds SocksConnect::new(&dest, auth) or HttpConnect::new(&dest, auth) per flow.
SocksOutbound keeps its own copy of the transport, the server and the credentials for UDP ASSOCIATE, which is not a codec over one stream:
- Dial a new control stream with
transport.dial(&server). SocksUdpLink::associate(control, auth, bind)runs the method, authentication andUDP ASSOCIATErounds. The request names no source: it declares0.0.0.0:0(encode_request(CMD_UDP_ASSOCIATE, None)), because the local socket is bound only after the relay is known. The server’s relay address must be an IP address.- The
bindclosure binds an unspecified local UDP socket of the relay’s family (0.0.0.0:0or[::]:0), sets it non-blocking and converts it withtokio::net::UdpSocket::from_std.
The link holds the control stream open for the life of the association; when the control stream closes, the association ends. It wraps each packet in the SOCKS UDP header toward the relay, and skips replies from any other address or that do not parse. Every packet of one association leaves from that one socket, so an upstream that checks sources, as the Etemenanki socks inbound does, holds the association to the address katana’s control connection came from and the port of the first katana datagram it forwards. See Whom an association hears.
katana v3.0.1 builds on etemenanki-protocols 2.0.1, where poll_recv_from compares the reply’s source with the relay address as a plain SocketAddr. From 2.0.2 it compares endpoint(from) with endpoint(relay), the canonical IP and the port, so a dual-stack socket also hears an IPv4 relay whose replies it reports as IPv4-mapped. Because katana binds a socket of the relay’s own family, both comparisons accept exactly the relay’s replies. The tests that pin this were added in 2.0.2: udp_link_ignores_datagrams_not_from_the_relay and udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay in protocols/tests/pipeline/socks.rs, and endpoint_sees_through_ipv4_mapping_and_ignores_flow_info in protocols/tests/unit/socks/protocol.rs; see Client side: SocksUdpLink.
VMess and VLESS
Section titled “VMess and VLESS”require_uuid fails with outbound <tag> needs a uuid or outbound <tag>: uuid is not a valid UUID.
parse_security maps VMess security, lowercased. Unset and auto both select AES-128-GCM:
security |
Security |
|---|---|
unset, auto, aes-128-gcm, aes128gcm |
Aes128Gcm |
chacha20-poly1305, chacha20poly1305 |
ChaCha20Poly1305 |
| anything else | unsupported vmess security "<value>" |
global_padding is passed to both VMess codecs as the global-padding option. The make closures build VMessStream/VMessDatagram or VlessStream/VlessDatagram for the destination.
Shadowsocks
Section titled “Shadowsocks”build_shadowsocks_outbound requires password (shadowsocks outbound <tag> needs a password) and picks the generation from method, like the inbound:
- Shadowsocks 2022 (
ss_2022::Method::from_name, exact match).passwordis split on:. Each part is base64-decoded (decode_psk, which trims whitespace) and normalised to the method’s key length (normalise_psk, which folds a longer key and refuses a shorter one). The last key is the user’s uPSK, and the ones before it, if any, form the identity chain. Any empty part, including an empty password, fails asshadowsocks-2022: PSK too short (0 < k). Themakeclosure buildsSs2022Stream::new(m, psk.clone(), keys.clone(), &dest). - Legacy AEAD (
ss_legacy::Method::from_name, case-insensitive). The master key isevp_bytes_to_key(password, m.key_len()), derived once at build time. The closure buildsSsStream::new(m, key.clone(), &dest). - Anything else fails with
Unsupported:unsupported shadowsocks cipher "<method>". An unsetmethodis treated as the empty string.
Neither generation carries UDP.
WireGuard
Section titled “WireGuard”build_wireguard_outbound turns the entry into a WgConfig and a WgConnector:
- The endpoint is the parsed server with its network switched to
DialNetwork::Udp. private_keyandpublic_keyare required and parsed byparse_key, which accepts base64 or hex encodings of 32 bytes. Errors readwireguard outbound <tag> needs a <field>orwireguard outbound <tag> <field>: <parse error>.- Each
local_addressis cut at the first/, trimmed and parsed as anIpAddr. The prefix length is discarded without being checked. A bad address fails withwireguard outbound <tag> invalid local_address "<value>": <error>, and an empty list withwireguard outbound <tag> needs at least one local_address. validate_wg_address_familyrequires an IPv4 local address foripv4_onlyand an IPv6 one foripv6_only. It fails withwireguard outbound <tag> address_family ipv6_only needs an IPv6 local_address(or the IPv4 form). The other strategies are not checked.pre_shared_keyis optional and parsed like the other keys.mtudefaults toDEFAULT_MTU(1420).keepalivebecomespersistent_keepalive.reservedmust be exactly 3 bytes (wireguard outbound <tag> reserved must be exactly 3 bytes).- The result is
WgConnector::with_address_family(wg, address_family).with_resolver(resolver.clone()).
Here address_family and the shared resolver apply to destinations reached through the tunnel. The peer endpoint is resolved separately when the tunnel comes up, with the host resolver (tokio::net::lookup_host), taking the first address. Each local address is installed on the tunnel interface as a /32 or /128. WgConnector clones share one lazily started tunnel slot, so every flow routed to the outbound uses one device. The tunnel’s lifecycle is described in WireGuard.
Dialing
Section titled “Dialing”KatanaConnector calls connect_stream for a TCP flow after admission, routing and audit, and wraps the result in Metered. FanOut calls connect_datagram when a UDP packet routes to an outbound it has no sub-link for. The flow’s user never reaches the outbound: a proxy client authenticates with the outbound’s own credentials, and the user was already billed on this side.
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: lock, make(dest) builds VlessStream, unlock P->>T: connect(server) returns a boxed dial O-->>K: StreamFuture K->>T: poll: dial TCP to server T->>U: TCP connect K->>U: poll: codec handshake, flush staged bytes U-->>K: ready K->>K: OutboundStream::Proxy, wrapped in Metered
| Variant | connect_stream |
connect_datagram(dest) |
|---|---|---|
Direct |
FreedomConnector::connect, OutboundStream::Tcp |
bind_udp(), a ResolvingUdp; dest unused |
Block |
Err(refused()) |
Err(refused()) |
Socks |
ProxyClient with SocksConnect |
a SocksUdpLink over a new control stream; dest unused |
Http |
ProxyClient with HttpConnect |
http carries no datagrams |
Vmess, Vless |
ProxyClient, stream codec |
ProxyClient, datagram codec for dest |
ShadowsocksLegacy |
ProxyClient with SsStream |
shadowsocks carries no datagrams |
Shadowsocks2022 |
ProxyClient with Ss2022Stream |
shadowsocks-2022 carries no datagrams |
Wireguard |
WgConnector::connect(anonymous(dest)), OutboundStream::Wg |
the same call, OutboundDatagram |
The “carries no datagrams” errors use io::ErrorKind::Unsupported. KatanaConnector refuses a flow routed to Block before it calls connect_stream, so the Block arms are a backstop.
anonymous(dest) builds the Flow<()> that WgConnector needs: the destination, an empty username and password, () as user data and no source. The tunnel reads only the destination. If a WireGuard dial returns the other link kind, the arm fails with wireguard dialed a TCP flow as UDP or wireguard dialed a UDP flow as TCP.
Every returned future is 'static and Send: it owns clones of what it needs (the FreedomConnector, the WgConnector, the SOCKS transport and credentials, or the ProxyClientConnecting), not a borrow of the pool. A reload can swap the pool while dials are in flight.
The direct outbound
Section titled “The direct outbound”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.
connectresolves withdestination_to_socketaddrs(dest, strategy, &resolver), which filters and orders the addresses by the strategy, thendialer.tcp.connect_any(&addrs)tries them in turn. The dialer usesSocketOptions::default(). - UDP.
bind_udpbinds one socket per family the strategy allows (Ipv4Only: V4;Ipv6Only: V6; otherwise both), records which ones actually bound, and returns aResolvingUdp. It resolves each domain target once, one lookup at a time, remembers at mostMAX_RESOLVED_NAMESnames first in, first out, and drops a packet whose target has no usable address. The details are on Connector and UDP fan-out. - Default.
FreedomConnector::default()usesResolver::default()andAuto. Tests use it to build a pool without[dns].
Invariants
Section titled “Invariants”| Invariant | Enforced by | Pinned by |
|---|---|---|
| A node feature the kernel does not implement is refused, never silently ignored | unsupported() in build_transport and build_protocol |
reality_rejected, vless_flow_rejected, httpupgrade_rejected, acme_cert_mode_rejected, ss_unsupported_cipher_rejected in tests/unit/inbound.rs |
reject_unknown_sni = true is refused with Unsupported and names the key; false builds |
build_transport check 3 |
reject_unknown_sni_is_refused_rather_than_ignored in tests/unit/inbound.rs |
A TLS node needs cert.mode = "file" |
build_transport check 5 |
tls_without_cert_file_errors in tests/unit/inbound.rs |
| Every fallible build finishes before a socket is bound | Builder order in TransportManager::start |
Structural; the builders take no socket |
| Table keys and registry keys agree | Both use user_tag(by_email, u) with NodeType::keys_by_email() |
vmess_traffic_is_metered_and_reported, a_hysteria_node_relays_and_meters in tests/unit/e2e.rs |
| A Hysteria client authenticates with the panel UUID by default | credential "" or "uuid" selects Authenticator::passwords |
a_real_client_proxies_through_a_katana_hysteria_node in tests/integration/hysteria_interop.rs |
| A credential the panel never issued does not authenticate | The authenticator holds only the panel’s users | a_hysteria_node_refuses_an_unknown_credential in tests/unit/e2e.rs |
An unknown credential kind is refused; "", uuid and user_pass are accepted |
The match in build_hysteria_authenticator |
hysteria_refuses_an_unknown_credential_kind, hysteria_accepts_both_credential_kinds in tests/unit/inbound.rs |
| Hysteria config errors are reported before a missing certificate | read_cert_key runs last in build_hysteria |
Every hysteria_refuses_* test passes nonexistent certificate paths |
| Obfuscation cannot be silently disabled or weakened | The obfuscation match in build_hysteria |
hysteria_refuses_an_obfs_password_with_no_obfs, hysteria_refuses_an_unknown_obfs_rather_than_disabling_it, hysteria_refuses_a_short_obfs_password |
| The panel’s obfuscation reaches the listener | NodeInfo.obfs_type and obfs_password feed build_hysteria |
a_panel_described_hysteria_node_serves_obfuscated_traffic in tests/unit/e2e.rs |
| The masquerade never answers with the auth-success status | Masquerade::new refuses 233 |
hysteria_refuses_a_masquerade_that_says_authenticated |
| The UDP idle timeout is in range and only set with UDP | The udp block in build_hysteria |
hysteria_refuses_an_out_of_range_udp_idle_timeout, hysteria_refuses_an_idle_timeout_without_udp |
| No TLS means no Hysteria node | cert.mode != "file" check |
hysteria_requires_a_certificate |
| The dry run and startup apply the same Hysteria checks | validate_hysteria calls the real builders |
All hysteria_* tests in tests/unit/inbound.rs go through validate_hysteria |
| katana never serves Shadowsocks from the single-key fallback | Empty-user guard; fallback slot carries UserTag::unattributed() |
ss_legacy_builds, ss2022_builds cover the multi-user build; no test for the empty guard |
Stream transports interoperate with a real client, including gRPC with ALPN h2 |
build_transport mapping |
vmess_tcp_plain, vmess_ws_plain, vmess_grpc_plain, vmess_tcp_tls, vless_ws_tls, vless_grpc_tls, trojan_tcp_tls and the _mux variants in tests/integration/xray_interop.rs |
address_family applies to every outbound protocol |
parse_address_family before the protocol switch; passed to TransportConnector |
address_family_applies_to_every_protocol in tests/unit/outbound.rs |
An invalid address_family is refused for every protocol |
parse_address_family |
an_invalid_address_family_is_rejected_for_every_protocol |
direct needs no server |
The early return before parse_server |
direct_is_configurable_as_an_outbound |
| A WireGuard single-family strategy has a local address of that family | validate_wg_address_family |
wireguard_ipv4_only_builds_with_ipv4_address, wireguard_ipv6_only_requires_ipv6_address |
| An outbound UUID is never echoed in an error | require_uuid names the tag only |
a_malformed_uuid_is_refused_without_echoing_it |
| The Shadowsocks generation follows the cipher; unknown ciphers are refused | build_shadowsocks_outbound |
shadowsocks_builds_both_generations |
| A SOCKS outbound’s UDP association accepts replies only from the relay the server named | SocksUdpLink::poll_recv_from skips any other source |
udp_link_ignores_datagrams_not_from_the_relay in Etemenanki protocols/tests/pipeline/socks.rs, added in etemenanki-protocols 2.0.2 |
| A proxy outbound relays and the bytes are billed to the inbound user | ProxyClient under Metered |
proxy_outbound_relays_and_meters in tests/unit/e2e.rs |
| A blocked flow is refused with no reason given | refused() |
a_blocked_destination_is_refused in tests/unit/connector.rs |
| Reserved tags cannot be redefined, and tags are unique | build_outbounds seeds the reserved tags first and refuses existing keys |
No unit test at v3.0.1; reproducible with katana --test |
Failure paths and cancellation
Section titled “Failure paths and cancellation”- Cold build. An error from any inbound builder propagates out of
TransportManager::startbefore a bind. On the first start the error fails one bootstrap attempt: the node manager logsnode <id>: initial start failed: <error>; retrying in <n>sand tries again, reading the node and its users from the panel afresh. The wait starts at 1 second and doubles after each failure up to 60 seconds, capped at the node’s poll period (controller.update_periodic). A config edit that arrives during the wait starts the next attempt at once. The node keeps retrying until the build succeeds or its task is cancelled. On a rebuild it logsnode <id>: rebuild failed: <error>. A rebuild tears the old generation down before it builds, so a build error leaves the node without a listener until a later cycle builds successfully. See Node manager. - Refresh.
ProxyManager::refreshbuilds the replacement table first. On error it logsproxy refresh build failed, keeping current: <error>and changes nothing: the registry, the leases and the running table all stay. The node manager still records the new user list as current, so the refresh is tried again only when a later poll brings a different user list or node speed limit. - Pool. See The pool: the first error rejects the whole pool at startup, on reload and under
--test. - Dial errors surface as the
io::Errorof the resolver, the TCP connect, the upstream handshake or the tunnel. For a TCP flow the connector’s future fails, and the server runtime treats that as a failed connect. For a UDP packetFanOutdrops the packet and the association continues. - No UDP.
connect_datagramonHttp,ShadowsocksLegacyorShadowsocks2022fails at once withUnsupported, andFanOutdrops the packet. - Cancellation. The outbound code spawns no task. Every
StreamFutureandDatagramFutureis owned by the connection’s runtime, and dropping it cancels the dial and closes any socket it opened. TheProxyClientmutex is never held across an.await, so a cancelled dial cannot leave it locked. The WireGuard device task belongs to the tunnel slot, not to any one dial.
Limits
Section titled “Limits”| Constant | Value | Where | Meaning |
|---|---|---|---|
HTTP_BUF |
16 KiB | src/outbound/mod.rs |
HTTP CONNECT client runtime buffer |
SOCKS_BUF |
16 KiB | src/outbound/mod.rs |
SOCKS5 client runtime buffer |
VLESS_BUF |
16 KiB | src/outbound/mod.rs |
VLESS client runtime buffer |
VMESS_BUF |
32 KiB | src/outbound/mod.rs |
VMess client runtime buffer |
SS_BUF |
20 KiB | src/outbound/mod.rs |
Shadowsocks AEAD client runtime buffer |
SS2022_BUF |
32 KiB | src/outbound/mod.rs |
Shadowsocks 2022 client runtime buffer |
MAX_RESOLVED_NAMES |
256 | src/outbound/freedom.rs |
Names one direct UDP association remembers |
HY2_MAX_CONNECTIONS |
4096 | src/inbound.rs |
Concurrent QUIC connections per Hysteria node |
MAX_LIVE_CONNECTIONS_PER_NODE |
65,536 | src/serve.rs |
Size of a Hysteria node’s circuit_permits semaphore |
udp_idle_timeout |
2 to 600 s, default 60 | build_hysteria |
Idle time before a Hysteria UDP association is retired |
| Salamander PSK | at least 4 bytes | build_hysteria |
Minimum obfs_password length |
| Masquerade defaults | 404, 404 page not found\n, text/plain; charset=utf-8 |
build_hysteria |
Used for any field left unset when one is set |
key_len() |
16 or 32 bytes | ss_2022::Method |
Shadowsocks 2022 iPSK and uPSK length |
DEFAULT_MTU |
1420 | protocols/src/wireguard/config.rs |
WireGuard tunnel MTU when mtu is unset |
reserved |
exactly 3 bytes | build_wireguard_outbound |
WireGuard reserved header bytes |
TRANSPORT_HANDSHAKE_TIMEOUT |
10 s | protocols/src/transports/accept.rs |
Inbound TLS, WebSocket or gRPC handshake |
| Test | File | Pins |
|---|---|---|
vmess_tcp_builds |
tests/unit/inbound.rs |
A plain TCP V2ray node builds a transport and a VMess table. |
vless_when_enabled |
tests/unit/inbound.rs |
node.enable_vless builds a VLESS table. |
trojan_builds |
tests/unit/inbound.rs |
A Trojan table builds from UUID passwords. |
reality_rejected |
tests/unit/inbound.rs |
enable_reality fails build_transport. |
vless_flow_rejected |
tests/unit/inbound.rs |
xtls-rprx-vision fails build_protocol. |
ss_legacy_builds |
tests/unit/inbound.rs |
aes-128-gcm builds a SIP004 table. |
ss2022_builds |
tests/unit/inbound.rs |
2022-blake3-aes-128-gcm with a 16-byte base64 server_key builds a multi-user table. |
ss_unsupported_cipher_rejected |
tests/unit/inbound.rs |
rc4-md5 is refused. |
httpupgrade_rejected |
tests/unit/inbound.rs |
The httpupgrade transport is refused. |
acme_cert_mode_rejected |
tests/unit/inbound.rs |
cert.mode = "dns" is refused. |
tls_without_cert_file_errors |
tests/unit/inbound.rs |
A TLS node with the default cert.mode = "none" fails. |
reject_unknown_sni_is_refused_rather_than_ignored |
tests/unit/inbound.rs |
Unsupported, message names the key; the default builds. |
hysteria_requires_a_certificate |
tests/unit/inbound.rs |
cert.mode = "none" is refused with a message naming cert.mode. |
hysteria_refuses_an_obfs_password_with_no_obfs |
tests/unit/inbound.rs |
The password-without-type message. |
hysteria_refuses_an_unknown_obfs_rather_than_disabling_it |
tests/unit/inbound.rs |
obfs = "gecko" is refused. |
hysteria_refuses_a_short_obfs_password |
tests/unit/inbound.rs |
A 2-byte Salamander password is refused. |
hysteria_refuses_an_out_of_range_udp_idle_timeout |
tests/unit/inbound.rs |
1 and 601 seconds are refused. |
hysteria_refuses_an_idle_timeout_without_udp |
tests/unit/inbound.rs |
A timeout without udp is refused. |
hysteria_refuses_a_masquerade_that_says_authenticated |
tests/unit/inbound.rs |
Masquerade status 233 is refused. |
hysteria_refuses_an_unknown_credential_kind |
tests/unit/inbound.rs |
credential = "totp" is refused. |
hysteria_accepts_both_credential_kinds |
tests/unit/inbound.rs |
"", uuid and user_pass still fail on the unreadable certificate, but never with a credential-kind error. |
wireguard_ipv4_only_builds_with_ipv4_address |
tests/unit/outbound.rs |
ipv4_only with 10.0.0.2/32 builds. |
wireguard_ipv6_only_requires_ipv6_address |
tests/unit/outbound.rs |
ipv6_only with only an IPv4 address fails with needs an IPv6 local_address. |
address_family_applies_to_every_protocol |
tests/unit/outbound.rs |
socks, http, vmess and vless accept ipv6_only. |
an_invalid_address_family_is_rejected_for_every_protocol |
tests/unit/outbound.rs |
ipv7 fails with invalid address_family for five protocols. |
direct_is_configurable_as_an_outbound |
tests/unit/outbound.rs |
direct with no server and port builds Outbound::Direct. |
a_malformed_uuid_is_refused_without_echoing_it |
tests/unit/outbound.rs |
The VMess and VLESS UUID errors do not contain the value. |
shadowsocks_builds_both_generations |
tests/unit/outbound.rs |
aes-256-gcm builds legacy; a 2022 method builds from one PSK and from an iPSK:uPSK chain; rc4-md5 fails. |
proxy_outbound_relays_and_meters |
tests/unit/e2e.rs |
A VMess client, a katana node, a VLESS outbound and an upstream VLESS server relay 50,000 bytes, and the panel receives them for uid 1001. |
a_hysteria_node_relays_and_meters |
tests/unit/e2e.rs |
A client authenticates with its panel UUID, relays 50,000 bytes, and they are reported. |
a_hysteria_node_refuses_an_unknown_credential |
tests/unit/e2e.rs |
A UUID the panel never issued does not connect. |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
tests/unit/e2e.rs |
A node whose first start fails keeps retrying, asks the panel again, and relays once its port is free. |
a_panel_described_hysteria_node_serves_obfuscated_traffic |
tests/unit/e2e.rs |
The panel’s obfs and obfs-password reach the listener: a client with the same Salamander key relays. |
a_blocked_destination_is_refused |
tests/unit/connector.rs |
A flow routed to block is refused. |
vmess_tcp_plain … trojan_tcp_tls_mux |
tests/integration/xray_interop.rs |
Xray as client against a katana node over TCP, WebSocket and gRPC, with and without TLS and mux. |
a_real_client_proxies_through_a_katana_hysteria_node |
tests/integration/hysteria_interop.rs |
The upstream Hysteria client authenticates with only the panel UUID and proxies. |
The unit tests run with cargo test. The integration tests start the real katana binary and drive it with independent clients built from reference sources; each one skips itself when its toolchain or source tree is missing. See Testing.
Adding an outbound protocol
Section titled “Adding an outbound protocol”- Add the protocol’s fields to
OutboundConfiginsrc/config.rs. The struct isdeny_unknown_fields, so a field that is not declared is a parse error. - Pick a buffer constant that exceeds the codec’s
STAGING_RESERVE, and add a variant toOutbound. UseProxyClient<BUF, S, D>when the protocol is a codec over a TCP stream to an upstream, andNoUdpforDif it carries no datagrams. - Add arms to
connect_streamandconnect_datagram. Both matches are exhaustive, so the compiler lists what is missing. A protocol without UDP returnsno_udp("<name>"). - Add an arm to
build_outbound. Make errors namecfg.tagand never the credential. - Add tests to
tests/unit/outbound.rsnext toa_malformed_uuid_is_refused_without_echoing_itandan_invalid_address_family_is_rejected_for_every_protocol, and add the new protocol to their protocol lists.