Skip to content

Building inbounds and outbounds

Source files: 48 · checked against 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 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.

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.

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.

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;

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:

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

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:

  1. node.enable_reality refuses REALITY.
  2. node.accept_proxy_protocol refuses PROXY protocol accept.
  3. cert.reject_unknown_sni refuses cert.reject_unknown_sni. No listener enforces SNI, so accepting the key would promise a control that is not in force.
  4. cert.mode of "dns", "http" or "tls" (the ACME modes) refuses ACME cert mode "dns" (the mode is printed with Debug quoting).
  5. node.enable_tls with any cert.mode other than "file" fails with InvalidInput: TLS node requires cert.mode = "file".
  6. The transport is mapped. Unsupported transports are refused here, before any certificate file is read.
  7. For a TLS node, read_cert_key requires both cert.cert_file and cert.key_file to be non-empty (TLS node requires cert.cert_file and cert.key_file), reads both files, and ServerConfig::from_pem parses 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 path becomes "/"; WsRoute::new also adds a missing leading /. An empty host becomes None. A non-empty host makes the route require a Host header whose name part (any :port removed) equals it, compared case-insensitively; a request without Host is refused.
  • gRPC. service_name is passed through unchanged. GrpcPaths::new serves /<service_name>/Tun and /<service_name>/TunMulti.
  • ALPN. Only gRPC sets ALPN. With Alpn::Http2, the OpenSSL select callback picks h2 when the client offers it, and otherwise continues the handshake without ALPN. With Alpn::None, no callback is installed.
  • TLS versions. ServerConfig::from_pem builds 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 with no certificate in PEM bundle, and a mismatched key fails in check_private_key.
  • Ignored fields. authority, header and headers are not read by build_transport. They take part only in NodeInfo::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.

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:

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 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.

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:

  1. ss_2022::Method::from_name(node.cypher_method): an exact, case-sensitive match of 2022-blake3-aes-128-gcm, 2022-blake3-aes-256-gcm or 2022-blake3-chacha20-poly1305.
  2. ss_legacy::Method::from_name(node.cypher_method): case-insensitive, aes-128-gcm, aes-256-gcm, chacha20-poly1305 and xchacha20-poly1305, with the aliases aead_aes_128_gcm, aead_aes_256_gcm, chacha20-ietf-poly1305, aead_chacha20_poly1305 and xchacha20-ietf-poly1305.

Anything else is refused with shadowsocks cipher "<method>".

  1. Ss2022ServerConfig::from_password(m, &node.server_key, Arc::new(UserTag::unattributed())) decodes the panel’s server_key as standard base64 and normalises it to m.key_len() bytes. This is the identity PSK (iPSK). A key longer than key_len is folded; a shorter one fails with shadowsocks-2022: PSK too short (<n> < <key_len>), and bad base64 with decode PSK: ….
  2. Each user’s PSK (uPSK) is the first key_len bytes of the text of u.uuid, not its parsed 16 bytes. A 36-character UUID always has enough. A shorter string fails with shadowsocks-2022 user <uid> key too short (< <key_len>). This matches the panel convention in which a client’s password is server_key:base64(first key_len characters of the UUID).
  3. The users are pushed onto config.users as (Ss2022User { psk, email: traffic_email(u) }, tag).
  4. 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 returns Some. It refuses 2022-blake3-chacha20-poly1305 with shadowsocks-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.

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.

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).

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:

  1. node.port == 0 fails with hysteria2 node needs a port.
  2. cert.mode != "file" fails with hysteria2 node requires cert.mode = "file". The TLS handshake is inside QUIC, so there is no plaintext mode.
  3. cert.reject_unknown_sni is refused as cert.reject_unknown_sni, as for stream nodes.
  4. Obfuscation, from node.obfs_type and node.obfs_password, each treated as absent when empty. See the table below.
  5. Masquerade, from [node.hysteria.masquerade]. With all three fields unset it is Masquerade::default(). Otherwise Masquerade::new(status or 404, body or "404 page not found\n", content_type or "text/plain; charset=utf-8"), which refuses status 233 with hysteria2: 233 is the authentication success status and cannot be used for the masquerade and a status outside 100 to 999 with hysteria2: <status> is not an HTTP status code.
  6. UDP. With udp = true, udp_idle_timeout defaults to 60 and must be in 2..=600 seconds (udp_idle_timeout must be between 2 and 600 seconds). With udp = false, a set udp_idle_timeout fails with udp_idle_timeout is set but udp is not enabled.
  7. Certificate, last: read_cert_key reads the files (its empty-path message is the shared TLS node requires cert.cert_file and cert.key_file), and hy2_endpoint::server_config builds a rustls TLS 1.3 config with ALPN h3.

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:

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 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 NodeInfo with node_type: Hysteria2, enable_tls: true, port set to [node.hysteria] port or to 1 when that is zero, and obfs_type/obfs_password copied from [node.hysteria] obfs and obfs_password.
  • It builds the authenticator through build_hysteria_authenticator from one placeholder user (uid 0, 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.

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: …

build_outbounds in src/runtime.rs builds the process’s one outbound pool:

src/runtime.rs
pub fn build_outbounds(cfg: &Config) -> io::Result<HashMap<CompactString, Arc<Outbound>>>;
  1. Read dns.ca_file when it is set, and build one Resolver with Resolver::from_spec(ResolverSpec { backend, server, server_name, url, ca_pem }). Every outbound shares it, and so its cache.
  2. Seed the reserved tags. direct and freedom are each an Outbound::Direct(FreedomConnector::new(resolver, AddressFamilyStrategy::Auto)). block and blackhole are each Outbound::Block.
  3. For each [[outbound]] in file order, refuse a tag already in the map with duplicate/reserved outbound tag <tag>, then insert Arc::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.

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>;

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:

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 wraps the kernel’s ProxyClientConnector. The kernel Connector::connect takes &mut self, because make is an FnMut and the dialer is mutable. The pool hands out Arc<Outbound>, so the wrapper adds a parking_lot::Mutex. The lock is held only while connect runs make to build the codec and asks TransportConnector for 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 return link::Outbound::Datagram(..) when dest.network == DialNetwork::Udp and link::Outbound::Stream(..) otherwise. Protocols without UDP use NoUdp for D, a codec type that is never built.
  • ProxyClientConnecting resolves 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.
  • OutboundStream is a closed enum rather than a boxed trait object for the two concrete cases. Tcp is a direct connection and Wg a tunnelled one. Proxy boxes the client runtime, since every protocol’s runtime is its own type. Its AsyncRead and AsyncWrite implementations forward each call to the variant.
  • OutboundDatagram boxes any DatagramLink<Addr = Destination>, so FanOut can 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.

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:

  1. Protocol name. cfg.protocol is ASCII-lowercased, so "SOCKS5" and "socks5" are the same.
  2. Address family. parse_address_family parses address_family for every protocol, including direct. Unset, or an empty string, means AddressFamilyStrategy::Auto. The parser trims, lowercases and maps - to _, and accepts auto, ipv4_only, ipv6_only, prefer_ipv4 and prefer_ipv6 plus short aliases such as ipv4, v4 and 4. Anything else fails with outbound <tag> invalid address_family "<value>".
  3. Direct. direct and freedom return Outbound::Direct(FreedomConnector::new(resolver.clone(), address_family)) at once. They are the only protocols that need no server and port. The entry’s tag must still differ from the four reserved tags direct, freedom, block and blackhole.
  4. Server. parse_server(&cfg.server, cfg.port) fails with outbound needs a non-empty server and non-zero port (got "<server>":<port>). A server that parses as an IP address becomes Remote::IpAddr, anything else Remote::Domain. The network is DialNetwork::Tcp.
  5. 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_family therefore governs how the upstream’s name is resolved.
  6. 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.

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:

  1. Dial a new control stream with transport.dial(&server).
  2. SocksUdpLink::associate(control, auth, bind) runs the method, authentication and UDP ASSOCIATE rounds. The request names no source: it declares 0.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.
  3. The bind closure binds an unspecified local UDP socket of the relay’s family (0.0.0.0:0 or [::]:0), sets it non-blocking and converts it with tokio::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.

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.

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). password is 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 as shadowsocks-2022: PSK too short (0 < k). The make closure builds Ss2022Stream::new(m, psk.clone(), keys.clone(), &dest).
  • Legacy AEAD (ss_legacy::Method::from_name, case-insensitive). The master key is evp_bytes_to_key(password, m.key_len()), derived once at build time. The closure builds SsStream::new(m, key.clone(), &dest).
  • Anything else fails with Unsupported: unsupported shadowsocks cipher "<method>". An unset method is treated as the empty string.

Neither generation carries UDP.

build_wireguard_outbound turns the entry into a WgConfig and a WgConnector:

  1. The endpoint is the parsed server with its network switched to DialNetwork::Udp.
  2. private_key and public_key are required and parsed by parse_key, which accepts base64 or hex encodings of 32 bytes. Errors read wireguard outbound <tag> needs a <field> or wireguard outbound <tag> <field>: <parse error>.
  3. Each local_address is cut at the first /, trimmed and parsed as an IpAddr. The prefix length is discarded without being checked. A bad address fails with wireguard outbound <tag> invalid local_address "<value>": <error>, and an empty list with wireguard outbound <tag> needs at least one local_address.
  4. validate_wg_address_family requires an IPv4 local address for ipv4_only and an IPv6 one for ipv6_only. It fails with wireguard outbound <tag> address_family ipv6_only needs an IPv6 local_address (or the IPv4 form). The other strategies are not checked.
  5. pre_shared_key is optional and parsed like the other keys. mtu defaults to DEFAULT_MTU (1420). keepalive becomes persistent_keepalive. reserved must be exactly 3 bytes (wireguard outbound <tag> reserved must be exactly 3 bytes).
  6. 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.

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.

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 resolves with destination_to_socketaddrs(dest, strategy, &resolver), which filters and orders the addresses by the strategy, then dialer.tcp.connect_any(&addrs) tries them in turn. The dialer uses SocketOptions::default().
  • UDP. bind_udp binds one socket per family the strategy allows (Ipv4Only: V4; Ipv6Only: V6; otherwise both), records which ones actually bound, and returns a ResolvingUdp. It resolves each domain target once, one lookup at a time, remembers at most MAX_RESOLVED_NAMES names 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() uses Resolver::default() and Auto. Tests use it to build a pool without [dns].
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
  • Cold build. An error from any inbound builder propagates out of TransportManager::start before a bind. On the first start the error fails one bootstrap attempt: the node manager logs node <id>: initial start failed: <error>; retrying in <n>s and 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 logs node <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::refresh builds the replacement table first. On error it logs proxy 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::Error of 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 packet FanOut drops the packet and the association continues.
  • No UDP. connect_datagram on Http, ShadowsocksLegacy or Shadowsocks2022 fails at once with Unsupported, and FanOut drops the packet.
  • Cancellation. The outbound code spawns no task. Every StreamFuture and DatagramFuture is owned by the connection’s runtime, and dropping it cancels the dial and closes any socket it opened. The ProxyClient mutex 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.
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.

  1. Add the protocol’s fields to OutboundConfig in src/config.rs. The struct is deny_unknown_fields, so a field that is not declared is a parse error.
  2. Pick a buffer constant that exceeds the codec’s STAGING_RESERVE, and add a variant to Outbound. Use ProxyClient<BUF, S, D> when the protocol is a codec over a TCP stream to an upstream, and NoUdp for D if it carries no datagrams.
  3. Add arms to connect_stream and connect_datagram. Both matches are exhaustive, so the compiler lists what is missing. A protocol without UDP returns no_udp("<name>").
  4. Add an arm to build_outbound. Make errors name cfg.tag and never the credential.
  5. Add tests to tests/unit/outbound.rs next to a_malformed_uuid_is_refused_without_echoing_it and an_invalid_address_family_is_rejected_for_every_protocol, and add the new protocol to their protocol lists.