Skip to content

Config to running pipeline

Source files: 27 · checked against Etemenanki 596916d
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/inbound/tun.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/outbound/proxy.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/router.rs
  • Etemenanki/app/src/balancer.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/server/authenticator.rs
  • Etemenanki/protocols/src/hysteria/server/masquerade.rs
  • Etemenanki/protocols/src/transports/tls/config.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/unit/inbound.rs
  • Etemenanki/app/tests/unit/outbound.rs
  • Etemenanki/app/tests/unit/transport.rs
  • Etemenanki/app/tests/unit/balancer.rs
  • Etemenanki/app/tests/integration/e2e_balancer.rs
  • Etemenanki/app/tests/integration/e2e_dns.rs

etemenanki-app turns one TOML file into a set of live objects in two stages. config::parse_bytes deserializes the file into plain Config structs, and instance::build lowers those structs into a Built value: every outbound, every balancer, the router and every inbound fully constructed and validated, with nothing bound. Only then does spawn_generation bind sockets and start tasks.

This page follows that path, from bytes to Built, down to the individual builder functions, the checks each one makes, and the error text each check produces. Read it before you add a protocol, a config key, or a validation rule to the app. Serving the bound listeners and swapping generations on reload have their own pages.

The build pipeline:

  • parses the file strictly, so a misspelled key is an error rather than a default;
  • validates every value the builders understand (protocols, networks, security, strategies, tags, paths, limits) and fails on the first problem;
  • reads the files the built objects need (certificates, keys, CA bundles, and geodata a rule references) while building, so a missing file fails the build, not the first connection;
  • resolves every tag to an Arc<Outbound> at build time, so nothing looks a tag up while traffic flows;
  • decides what each inbound will bind (BindSpec) at build time, so --test refuses the same configs a start would.

It deliberately leaves out:

  • binding sockets, opening TUN devices and spawning accept loops (instance::spawn_generation, see Serving inbounds);
  • starting balancer health probes (also spawn_generation, against the generation’s token);
  • swapping generations and the reload policy (see Generations and hot reload);
  • any network I/O. The build resolves no names and dials nothing: WireGuard and Hysteria 2 outbounds connect on their first flow.

instance::build is the only place a config becomes objects. Three callers share it and differ only in what happens around it:

Caller Reads the file with Then Bind failures
--test (main.rs → test_config) config::load instance::build, then drops the Built Not checked: nothing is bound
First start (Instance::start) std::fs::read + config::parse_bytes, keeping the bytes build, then spawn_generation(built, true) The first one aborts the start
Reload (Instance::reload) std::fs::read + config::parse_bytes, skipped if the bytes are unchanged build, log the ConfigDiff, tear down the old generation, spawn_generation(built, false) Logged per inbound; the others still start
app/src/main.rs
fn test_config(path: &Path) -> io::Result<()>
app/src/instance.rs
pub fn build(cfg: &Config) -> io::Result<Built>
async fn spawn_generation(built: Built, strict: bool) -> io::Result<Generation>
impl Instance {
pub async fn start(path: PathBuf) -> io::Result<Self>
pub async fn reload(&self)
}

test_config calls config::load and instance::build and nothing else. On success main prints Configuration OK. and exits with ExitCode::SUCCESS; on failure it logs configuration invalid: <error> at ERROR level and exits with ExitCode::FAILURE (status 1). Both lines go to stdout: the tracing_subscriber::fmt() subscriber that init_tracing installs writes to stdout, with a timestamp and the etemenanki_app target in front of the message.

$ etemenanki-app --test -c config.toml
Configuration OK.

Before any of this, init_tracing reads the file once more with config::load to pick up [log].level. It ignores every error there (the level falls back to info), and a RUST_LOG that EnvFilter can parse wins over the file.

app/src/config.rs
pub fn load(path: &Path) -> io::Result<Config>
pub fn parse_bytes(bytes: &[u8]) -> io::Result<Config>

load is std::fs::read followed by parse_bytes. parse_bytes exists separately so Instance::reload can compare the raw bytes with the last ones it saw before it parses anything. It fails in two ways, both with io::ErrorKind::InvalidData:

Failure Error text
The file is not UTF-8 The Utf8Error display, for example invalid utf-8 sequence of 1 bytes from index 0
TOML or serde rejects it The toml error display, which starts TOML parse error at line N, column M and quotes the offending line

Config is a tree of serde::Deserialize structs. Every struct in the tree carries #[serde(deny_unknown_fields)], and every one derives Clone + PartialEq so a reload can diff two configs.

app/src/config.rs
#[derive(Deserialize, Clone, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default, rename = "inbound")]
pub inbounds: Vec<InboundConfig>,
#[serde(default, rename = "outbound")]
pub outbounds: Vec<OutboundConfig>,
#[serde(default, rename = "balancer")]
pub balancers: Vec<BalancerConfig>,
#[serde(default)]
pub route: RouteConfig,
}
#[derive(Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct InboundConfig {
pub tag: CompactString,
pub protocol: String,
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub stream: StreamConfig,
#[serde(default)]
pub address_family: Option<String>,
#[serde(default = "default_sniffing")]
pub sniffing: bool,
#[serde(default = "default_settings")]
pub settings: toml::Value,
}
#[derive(Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct OutboundConfig {
pub tag: CompactString,
pub protocol: String,
#[serde(default)]
pub server: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub stream: StreamConfig,
#[serde(default)]
pub address_family: Option<String>,
#[serde(default = "default_settings")]
pub settings: toml::Value,
}

The other structs follow the same pattern: LogConfig, DnsConfig, StreamConfig (with TlsConfig, WsStreamConfig and GrpcStreamConfig), BalancerConfig, RouteConfig and RuleConfig. The array names are singular in TOML ([[inbound]], [[route.rule]]) through rename.

Almost every value is an Option or a plain String at this stage. Parsing checks shape and key names only. Whether protocol = "vlesss" or network = "WS" means anything is decided by the builders in stage 2, which is why stage 2 owns almost every error message.

A few defaults are applied by serde itself:

Field Default Mechanism
InboundConfig.sniffing true default_sniffing()
InboundConfig.settings, OutboundConfig.settings an empty TOML table default_settings()
TlsConfig.allow_insecure false #[serde(default)]
Every Option field None #[serde(default)]
Every Vec field except BalancerConfig.outbounds empty #[serde(default)]

The fields without a default are required, and serde reports them as missing field: tag and protocol on every [[inbound]] and [[outbound]], tag and outbounds on every [[balancer]], and outbound on every [[route.rule]].

Everything else that looks like a default, such as listen falling back to 127.0.0.1 or a WebSocket path falling back to /, is applied by a builder, next to the check that uses it.

The per-protocol keys live under [inbound.settings] and [outbound.settings], which stage 1 keeps as an opaque toml::Value. The outer schema stays the same for every protocol, and each builder deserializes the table into its own struct only after it has matched on protocol:

app/src/inbound/mod.rs
fn parse_settings<T: serde::de::DeserializeOwned>(cfg: &InboundConfig) -> io::Result<T>

app/src/outbound/mod.rs has the same function for OutboundConfig. Both call cfg.settings.clone().try_into() and wrap any failure as InvalidInput with the message inbound <tag>: invalid settings: <serde error> (or outbound <tag>: …). The settings structs (SocksInboundSettings, TrojanOutboundSettings, Hysteria2InboundSettings, TunInboundSettings, WireguardOutboundSettings and the rest, all in app/src/config.rs) also carry deny_unknown_fields, so a typo inside settings fails in stage 2 rather than stage 1:

configuration invalid: inbound in: invalid settings: unknown field `acounts`, expected one of `auth`, `accounts`, `udp`, `udp_bind`

Because the default settings value is an empty table, a protocol whose settings struct has a required field fails with missing field when the table is absent. A protocol whose builder never calls parse_settings (freedom, blackhole) does not look at the table at all.

app/src/config.rs
#[derive(Debug, Default)]
pub struct ConfigDiff {
pub inbounds_added: Vec<CompactString>,
pub inbounds_removed: Vec<CompactString>,
pub inbounds_changed: Vec<CompactString>,
pub outbounds_added: Vec<CompactString>,
pub outbounds_removed: Vec<CompactString>,
pub outbounds_changed: Vec<CompactString>,
pub route_changed: bool,
pub log_changed: bool,
}
pub fn diff(old: &Config, new: &Config) -> ConfigDiff

diff pairs inbounds and outbounds by tag and compares the whole InboundConfig or OutboundConfig with PartialEq, so any change inside a block, including its settings table, marks that tag as changed. [route] and [log] are compared as wholes. Its Display form is what the reload log line prints:

config reload: inbounds +[new-in] -[old-in] ~[vless-in]; outbounds ~[proxy]; route changed

The diff is informational only. Instance::reload computes it after a successful build, logs it, and rebuilds the whole generation regardless of what it says. It does not cover [[balancer]] or [dns], so an edit confined to those logs config reload: no changes and still swaps the generation.

app/src/instance.rs
pub struct Built {
router: Arc<Router>,
inbounds: Vec<BuiltInbound>,
balancers: Vec<(Arc<Balancer>, Duration, Duration)>,
resolver: Resolver,
}
struct BuiltInbound {
tag: CompactString,
bind: BindSpec,
kind: InboundKind,
}

Built holds everything a generation needs and nothing that is bound. balancers keeps each balancer with its probe interval and timeout, so the probes can be started later against the generation’s cancellation token. resolver is kept for those probes. The fields are private: only spawn_generation consumes a Built.

flowchart TB
  CFG["Config"] --> DEF["default tag: first outbound"]
  DEF --> RES["Resolver::from_spec"]
  RES --> OB["build_outbound, once per outbound"]
  OB --> MAP["tag map of Arc Outbound"]
  MAP --> BAL["balancers join the tag map"]
  BAL --> RT["build_router, geodata"]
  RT --> IB["build_inbound, once per inbound"]
  IB --> BUILT["Built"]
  BUILT --> Q{"caller"}
  Q -->|"--test"| OK["Configuration OK."]
  Q -->|"start or reload"| GEN["spawn_generation: bind, spawn"]
  1. Default outbound tag. The tag of the first [[outbound]] becomes default_tag. With no outbounds at all the build fails at once with config defines no outbounds. Apart from the order in which errors surface, this is the only thing the position of an outbound in the file decides.

  2. One resolver for the generation. If [dns].ca_file is set, the file is read (std::fs::read) whatever the backend. Then Resolver::from_spec(ResolverSpec { backend, server, server_name, url, ca_pem }) builds the resolver, with system as the default backend. Its own checks all start with dns::

    • dns: the udp backend needs a server address (likewise tls and https);
    • dns: invalid server address: <error>: server must be an IP:port socket address, not a host name;
    • dns: the tls backend needs a server name to verify against;
    • dns: the https backend needs the resolver's url, dns: "<url>" is not an https:// url, dns: "<url>" has no usable host;
    • dns: unknown backend "<value>" (expected "system", "udp", "tls" or "https").

    A CA file that holds no certificate fails in the TLS layer with no certificate in CA PEM bundle, without the dns: prefix. The build does not contact the resolver. Every outbound and every balancer probe of this generation gets a clone of this one Resolver, so they share one cache. A reload builds a new resolver, so the cache starts empty in each generation. See DNS.

  3. Outbounds, in file order. For each [[outbound]], the tag is checked against the tags already in the map (duplicate outbound tag: <tag>), then build_outbound(ob, &resolver) builds it and the result is inserted as Arc<Outbound> into a HashMap<CompactString, Arc<Outbound>>. A duplicate is detected at its second occurrence, after the first has been built.

  4. Balancers join the tag map. For each [[balancer]]:

    • its tag must not already be in the map: balancer tag <tag> collides with an outbound tag. The map already holds earlier balancers, so two balancers with one tag fail with the same message;
    • every member tag must name an entry in the map and an [[outbound]] entry in the config: balancer <tag> references unknown outbound tag: <member>. The second lookup means a balancer cannot have another balancer as a member;
    • every member must have an upstream a TCP connect can probe (outbound::upstream_dest_opt): balancer <tag>: outbound <member> has no upstream a TCP health probe can reach, so it cannot be balanced. upstream_dest_opt returns None when the member’s server or port is absent, which is the normal case for freedom, blackhole and wireguard, and always for hysteria2 (and its aliases), which has a server but listens on UDP only;
    • strategy goes through Strategy::parse (failover when absent; otherwise failover or round_robin, case-sensitive): unknown balancer strategy "<value>" (expected "failover" or "round_robin"). Balancer::new then refuses an empty member list: a balancer needs at least one outbound;
    • the balancer is inserted into the tag map as Outbound::Balanced(Arc<Balancer>), and kept in Built.balancers with probe_interval and probe_timeout in seconds (defaults DEFAULT_PROBE_INTERVAL and DEFAULT_PROBE_TIMEOUT).

    Balancers are built after every outbound, so they may name outbounds defined anywhere in the file, and before the router, so rules and [route].default may name them. See Outbounds and balancers.

  5. Router. build_router(&cfg.route, &outbounds, &default_tag) compiles [[route.rule]] in order into a RouteTable, resolves each rule’s outbound and the default to an Arc<Outbound>, and loads geodata. Details below.

  6. Inbounds, in file order. Each tag is checked with a HashSet (duplicate inbound tag: <tag>), then build_inbound(ib) returns the inbound and its BindSpec, stored as a BuiltInbound. Inbound and outbound tags live in separate namespaces: an inbound may share a tag with an outbound.

When build returns, the local tag map is dropped. The Arc<Outbound> values survive only where something holds them: the route table, its default, and balancer members. An outbound nothing refers to is still built, and so still validated, and then dropped.

app/src/router.rs
pub type Router = routing::Router<Outbound>;
pub fn build_router(
cfg: &crate::config::RouteConfig,
outbounds: &HashMap<CompactString, Arc<Outbound>>,
default_tag: &CompactString,
) -> io::Result<Router>

For each rule, build_router turns every field into RouteMatch values in a fixed order: domain_suffix, domain_keyword and domain_full (lower-cased with to_ascii_lowercase), domain_regex (compiled by routing::parse_domain_regex), cidr and source_cidr (parsed as IpCidr), inbound_tag, network, port (routing::parse_port_match), geosite and geoip. It then resolves the rule’s outbound. Because the matchers come first, a rule with both a bad CIDR and an unknown outbound reports the CIDR.

Check Error text
Rule or default names a tag not in the map route references unknown outbound tag: <tag>
domain_regex that does not compile invalid domain regex "<value>": <regex error>
Bad CIDR in cidr or source_cidr invalid cidr "<value>": <parser error>
network other than tcp or udp invalid rule network "<value>" (expected "tcp" or "udp")
Bad port or range invalid port spec: "<value>", or … has a lower bound above its upper bound
A geosite code with no [route].geosite file a geosite matcher is used but no geosite file is configured
A geoip code with no [route].geoip file a geoip matcher is used but no geoip file is configured
Code missing from the file geosite code not found: <code>, geoip code not found: <code> (NotFound)
File does not decode geosite decode: …, geoip decode: …, or geoip cidr: … for a malformed entry (InvalidData)

[route].default, when set, replaces default_tag. The geodata step, routing::build_geo_data, reads a .dat file only when at least one rule references a code from it, decodes the whole list, keeps only the referenced codes (compared case-insensitively) in GeoData, and drops the rest. A geosite reference may carry an attribute (code@attr) and a geoip reference may be negated (!code); the lookup uses the bare code. A configured geodata path that no rule uses is never opened. The matching engine itself is described in Route model.

app/src/inbound/mod.rs
pub fn build_inbound(cfg: &InboundConfig) -> io::Result<(InboundKind, BindSpec)>

build_inbound returns two things that always belong together: the object that will serve connections and the description of what to bind for it. spawn_generation later binds the BindSpec and matches the resulting listener against the InboundKind.

What gets bound: resolve_listen and BindSpec

Section titled “What gets bound: resolve_listen and BindSpec”
app/src/inbound/mod.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindSpec {
Tcp { host: String, port: u16 },
Udp { host: String, port: u16 },
Unix(PathBuf),
Tun(DeviceSpec),
}
enum Listen {
Ip { host: String, port: u16 },
Unix(PathBuf),
}
fn resolve_listen(cfg: &InboundConfig) -> io::Result<Listen>
fn bind_for(listen: Listen) -> BindSpec

Every protocol except tun starts with resolve_listen, which reads listen and port:

listen port Result Error otherwise
Starts with / must be absent Listen::Unix(path) inbound <tag>: a unix socket listen has no port; remove port
Starts with @ any refused inbound <tag>: abstract unix sockets are not supported; use a filesystem path
Anything else required Listen::Ip { host, port } inbound <tag>: port is required
Absent required Listen::Ip { host: "127.0.0.1", port } inbound <tag>: port is required

bind_for maps Listen::Ip to BindSpec::Tcp and Listen::Unix to BindSpec::Unix. Hysteria 2 builds BindSpec::Udp from Listen::Ip itself, and TUN builds BindSpec::Tun(DeviceSpec). The Display form of BindSpec is what the log prints: 127.0.0.1:1080, udp 0.0.0.0:443, unix:/run/x.sock, tun <name> (or tun auto).

The host is kept as a string and not resolved or checked here. A listen address the host does not own, a port already in use, or a privileged port all pass the build and fail only at bind time.

InboundConfig.address_family is deserialized but no inbound builder reads it.

What serves it: InboundKind, StreamInbound, StreamProtocol

Section titled “What serves it: InboundKind, StreamInbound, StreamProtocol”
app/src/inbound/mod.rs
pub enum InboundKind {
Stream(Arc<StreamInbound>),
Hysteria2(Hy2Inbound<()>),
Tun(TunInbound<()>),
}
pub struct StreamInbound {
pub protocol: StreamProtocol,
pub transport: InboundTransport,
pub sniff: bool,
}
pub enum StreamProtocol {
Socks(SocksInbound<()>),
Http(Arc<HttpServerConfig<()>>),
Trojan(Arc<trojan::Validator<()>>),
Vless(Arc<vless::Validator<()>>),
Vmess(Arc<AccountValidator<()>>),
Shadowsocks(Arc<Resolved<()>>),
Ss2022 {
config: Arc<Ss2022ServerConfig<()>>,
validator: Option<Arc<ss_2022::Validator<()>>>,
},
}

The () type parameter is the per-user payload. The app attaches none; katana instantiates the same protocol types with its own user type. StreamProtocol holds what each accepted connection’s protocol core is built from: a validator or server config built once per generation and shared by Arc. StreamProtocol::name gives the protocol’s name for logs ("shadowsocks-2022" for Ss2022).

The three InboundKind variants are the three kinds of listener:

Variant Owns Bound as Served by
Stream nothing; one socket per accepted client BindSpec::Tcp or BindSpec::Unix serve::run_stream_inbound
Hysteria2 a UDP socket carrying QUIC BindSpec::Udp serve::run_hysteria_inbound
Tun a network interface BindSpec::Tun inbound::tun::run_tun_inbound

build_inbound handles tun first, before resolve_listen. For every other value it runs resolve_listen and only then dispatches on cfg.protocol, so an inbound with an unknown protocol and no port reports port is required rather than the unknown protocol:

protocol Stream block Settings struct Builds BindSpec
socks refused (reject_stream) SocksInboundSettings StreamProtocol::Socks(SocksInbound), transport always Tcp Tcp or Unix
http transport_for HttpInboundSettings StreamProtocol::Http(Arc<HttpServerConfig>) Tcp or Unix
trojan transport_for TrojanInboundSettings StreamProtocol::Trojan(Arc<trojan::Validator>) Tcp or Unix
vless transport_for UuidUsersSettings StreamProtocol::Vless(Arc<vless::Validator>) Tcp or Unix
vmess transport_for UuidUsersSettings StreamProtocol::Vmess(Arc<AccountValidator>) Tcp or Unix
shadowsocks refused (reject_stream) ShadowsocksInboundSettings Ss2022 when method starts with 2022-, else Shadowsocks; transport always Tcp Tcp or Unix
hysteria2, hysteria, hy2 refused Hysteria2InboundSettings InboundKind::Hysteria2 via build_hysteria2_inbound Udp only
tun refused TunInboundSettings InboundKind::Tun via build_tun_inbound Tun
wireguard refused: wireguard cannot be used as an inbound (no server implementation)
anything else refused: inbound <tag>: unknown protocol "<value>"

Protocol-specific checks along the way:

  • SOCKS. auth must be none or password (inbound <tag>: unknown socks auth "<value>"). On a Unix listener with udp = true the build requires udp_bind, because the UDP relay is placed on the listener’s local IP and a Unix socket has none: inbound <tag>: socks over a unix socket has no local IP for UDP associate; set udp_bind or udp = false. SocksInbound::new receives cfg.sniffing as well, since SOCKS drives its own connection.
  • VLESS and VMess. Every id goes through Uuid::parse_str: inbound <tag>: invalid uuid "<value>": <error>.
  • Shadowsocks. The method name picks the family. An unknown name fails with inbound <tag>: unknown shadowsocks-2022 method "<value>" or inbound <tag>: unknown shadowsocks method "<value>". For 2022 methods, Ss2022ServerConfig::from_password and Ss2022User::from_password decode the keys, and their errors come from the protocols crate without the tag prefix.

The inbound transport comes from one of two helpers:

app/src/inbound/mod.rs
fn build_inbound_transport(cfg: &InboundConfig) -> io::Result<InboundTransport>
fn transport_for(
cfg: &InboundConfig,
listen: &Listen,
proto: &str,
transport: impl FnOnce() -> io::Result<InboundTransport>,
) -> io::Result<InboundTransport>

transport_for builds the transport lazily. On an IP listener it calls the closure, which runs build_inbound_transport. On a Unix listener it never calls it: it runs reject_stream_settings with the protocol name <proto> over a unix socket, which refuses a network other than tcp and a security other than none, and returns InboundTransport::Tcp. A Unix listener therefore never reads a certificate it will not use.

flowchart LR
  P["protocol"] --> T{"tun?"}
  T -->|yes| TUN["build_tun_inbound"]
  T -->|no| L["resolve_listen"]
  L --> U{"Unix or IP"}
  U -->|Unix| R["only tcp and none allowed, transport Tcp"]
  U -->|IP| S["resolve_stream"]
  S --> TLS["read cert and key if TLS"]
  TLS --> IT["InboundTransport"]

build_inbound_transport turns the validated StreamShape into an InboundTransport, reading tls.cert_file and tls.key_file from disk when TLS is layered:

StreamShape InboundTransport TLS ALPN
Tcp InboundTransport::Tcp
Tls InboundTransport::Tls(ServerConfig) Alpn::None
Ws { path, host, tls } InboundTransport::ws(path, host, tls); host, when set, must match the request Host Alpn::Http1
Grpc { service, tls, .. } InboundTransport::grpc(service, tls); the shape’s authority is ignored on a server Alpn::Http2

A missing path fails with inbound <tag>: tls stream needs tls.cert_file (or tls.key_file). A path that does not exist fails with the bare OS error from std::fs::read, such as No such file or directory (os error 2), which does not name the file.

app/src/inbound/mod.rs
fn build_hysteria2_inbound(
cfg: &InboundConfig,
s: Hysteria2InboundSettings,
) -> io::Result<Hy2Inbound<()>>

Before this function runs, build_inbound has refused any stream block and any Unix listen (inbound <tag>: hysteria2 listens on UDP and cannot use a unix socket) and parsed the settings. The function then checks, in order. Its own messages are prefixed inbound <tag>: ; errors from the protocols crate (the certificate, the authenticator) and file reads pass through without it:

Check Rule Error text
Certificate cert_file and key_file both set; both files read; hy2_endpoint::server_config builds the QUIC TLS config hysteria2 needs both cert_file and key_file
Credential exactly one of password and users password and users cannot both be set; a credential would have two answers, hysteria2 needs password or users
Shared password Authenticator::shared refuses an empty password hysteria2: the password must not be empty, without the tag prefix
Users Authenticator::user_pass refuses an empty name or password, a name containing :, and two names equal once lower-cased hysteria2: a user needs both a name and a password, hysteria2: a username cannot contain ':' …, hysteria2: two users share a name once lower-cased, all without the tag prefix
Masquerade all three fields absent gives 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 (the authentication success status) and a number that is not an HTTP status code inbound <tag>: hysteria2: 233 is the authentication success status …, inbound <tag>: hysteria2: <status> is not an HTTP status code
Obfuscation obfs absent needs obfs_password absent; obfs must be salamander with an obfs_password of at least 4 bytes obfs_password is set but obfs is not; did you mean obfs = "salamander"?, obfs_password must be at least 4 bytes for salamander, unknown obfs "<value>" (expected "salamander")
UDP idle timeout udp defaults to false. With udp = true: 2 to 600 seconds, default 60; with udp = false: must be absent udp_idle_timeout must be between 2 and 600 seconds, udp_idle_timeout is set but udp is not enabled
Limits max_connections and max_circuits at least 1; defaults DEFAULT_MAX_CONNECTIONS and DEFAULT_MAX_CIRCUITS max_connections must be at least 1, max_circuits must be at least 1

The result is Hy2Inbound::new(ListenerConfig { connection, quic, obfs, max_connections }). connection is an Arc<ServerConfig> holding the authenticator in an ArcSwap (the app never swaps it: a config change rebuilds the generation), the masquerade, sniff: cfg.sniffing, the UDP idle timeout, and circuit_permits: Arc<Semaphore> sized to max_circuits. See Hysteria 2 server.

app/src/inbound/tun.rs
pub fn build_tun_inbound(cfg: &InboundConfig) -> io::Result<(InboundKind, BindSpec)>

A TUN inbound owns an interface, not a socket. build_tun_inbound refuses a stream block, then refuses listen or port: inbound <tag>: tun owns a network interface and has no listener; remove listen/port. It then:

  • takes mtu or DEFAULT_MTU and refuses anything under 1280 (inbound <tag>: tun mtu must be at least 1280);
  • parses each address as cidr::IpInet (host bits allowed) and each entry of routes as cidr::IpCidr (host bits refused), with errors bad tun address "<value>" and bad tun route "<value>";
  • builds a DeviceSpec { name, mtu, addresses, routes } and calls tun::check_platform, which refuses routes on anything but Linux. tun::open calls the same function when the generation opens the device, so --test and a start agree;
  • builds TunConfig with udp (default true), udp_idle_timeout (default DEFAULT_UDP_IDLE_TIMEOUT) and max_flows (default DEFAULT_MAX_FLOWS), and calls without_sniffing() when sniffing = false.

Creating the device needs privileges and happens only in bind_inbound, so --test cannot tell whether the process will be allowed to create it. See TUN.

app/src/outbound/mod.rs
pub fn build_outbound(cfg: &OutboundConfig, resolver: &Resolver) -> io::Result<Outbound>
pub enum Outbound {
Freedom(FreedomConnector),
Blackhole,
Socks(Box<SocksOutbound>),
Http(ProxyClient<HTTP_BUF, HttpConnect, NoUdp>),
Trojan(ProxyClient<TROJAN_BUF, TrojanStream, TrojanDatagram>),
Vless(ProxyClient<VLESS_BUF, VlessStream, VlessDatagram>),
Vmess(ProxyClient<VMESS_BUF, VMessStream, VMessDatagram>),
Shadowsocks(ProxyClient<SS_BUF, SsStream, NoUdp>),
Ss2022(ProxyClient<SS2022_BUF, Ss2022Stream, NoUdp>),
Wireguard(WgConnector),
Hysteria2(Hy2Connector),
Balanced(Arc<crate::balancer::Balancer>),
}

Outbound is a closed enum: dispatch is a match in connect_stream and connect_datagram, not a trait object. build_outbound never produces Balanced; only instance::build does, in the balancer step.

The proxy protocols share one wrapper:

app/src/outbound/proxy.rs
pub type NoUdp = NoCodec<Destination, io::Error>;
pub type Make<S, D> = Box<dyn FnMut(Flow) -> 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, flow: Flow) -> ProxyClientConnecting<BUF, S, D, TransportConnector>
}

The builder does the expensive, per-config work once and captures the result in the Make closure: the Trojan password hash (password_hash), the VLESS UUID bytes, the VMess UUID and Security, the legacy Shadowsocks key (evp_bytes_to_key), or the Shadowsocks 2022 key chain. Per flow, the closure only picks the stream codec or the datagram codec from flow.destination.network. The parking_lot::Mutex is held only while connect builds the dial future. NoUdp fills the datagram slot for protocols that carry no datagrams. See Client runtime.

BUF is the client runtime’s buffer size, a const generic, so each protocol’s runtime is sized at compile time to the largest wire frame it opens plus what its codec reserves:

Constant Value Used by
HTTP_BUF 16 KiB (16 * 1024) http
SOCKS_BUF 16 KiB socks (TCP)
TROJAN_BUF 16 KiB trojan
VLESS_BUF 16 KiB vless
VMESS_BUF 32 KiB vmess
SS_BUF 20 KiB shadowsocks (legacy AEAD)
SS2022_BUF 32 KiB shadowsocks with a 2022- method
protocol Variant Stream block Server and port Settings struct
freedom, direct Freedom(FreedomConnector) refused not read none
blackhole, block Blackhole refused not read none
socks Socks(Box<SocksOutbound>) build_transport required, TCP ProxyOutboundSettings
http Http(ProxyClient) build_transport required, TCP ProxyOutboundSettings
trojan Trojan(ProxyClient) build_transport required, TCP TrojanOutboundSettings
vless Vless(ProxyClient) build_transport required, TCP VlessOutboundSettings
vmess Vmess(ProxyClient) build_transport required, TCP VmessOutboundSettings
shadowsocks Ss2022 or Shadowsocks by method prefix build_transport required, TCP ShadowsocksOutboundSettings
hysteria2, hysteria, hy2 Hysteria2(Hy2Connector) refused required, UDP Hysteria2OutboundSettings
wireguard Wireguard(WgConnector) refused not read; settings.endpoint instead WireguardOutboundSettings
anything else refused: outbound <tag>: unknown protocol "<value>"

For the protocols that dial over a transport, the order is: build_transport, then upstream_dest (outbound <tag>: missing server, outbound <tag>: missing port), then parse_settings. The remaining checks:

  • SOCKS keeps the TransportConnector, the server and the credentials in SocksOutbound beside the TCP ProxyClient, because UDP ASSOCIATE needs its own control connection per datagram link.
  • VLESS and VMess parse id as a UUID (outbound <tag>: invalid uuid …). VMess security is case-insensitive: absent, auto and aes-128-gcm give Security::Aes128Gcm, chacha20-poly1305 gives Security::ChaCha20Poly1305, and anything else fails with unknown vmess security "<value>", which carries no tag prefix.
  • Shadowsocks 2022 splits password on : into an identity key chain ending in the user key, decoding and normalising each with decode_psk and normalise_psk.
  • Hysteria 2 runs reject_stream, parse_settings, then upstream_dest with DialNetwork::Udp and parse_address_family, and then build_hy2_config. That function refuses an empty password (hysteria2 password must not be empty), allow_insecure together with ca_file (allow_insecure and ca_file cannot both be set), the same obfuscation mistakes as the inbound, and max_concurrent_streams = 0 (max_concurrent_streams must be at least 1; default DEFAULT_MAX_CONCURRENT_STREAMS, no upper bound), all with the outbound <tag>: prefix. It reads ca_file from disk. server_name falls back to server. TLS material lives in settings, not in [outbound.stream.tls], which is why the stream block is refused.
  • WireGuard runs reject_stream, parse_settings and parse_address_family, then validate_wg_address_family, which refuses ipv4_only without an IPv4 address and ipv6_only without an IPv6 one (outbound <tag>: wireguard address_family ipv6_only needs an IPv6 address). build_wg_config then parses the keys with parse_key (outbound <tag>: invalid wireguard private_key, and likewise peer_public_key and preshared_key), splits endpoint at the last : (wireguard endpoint must be host:port, invalid wireguard endpoint port), and defaults mtu to wireguard::DEFAULT_MTU.
  • address_family is parsed by parse_address_family for freedom, the transport-based protocols, hysteria2 and wireguard: outbound <tag>: invalid address_family "<value>". Absent means AddressFamilyStrategy::Auto.

WgConnector and Hy2Connector hold empty slots that fill on the first flow, so neither dials during the build. Each gets the generation’s resolver through with_resolver.

The inbound and outbound builders both turn a [.stream] block into a transport. The validation lives in one module so that a rule added for one side cannot be missing on the other:

app/src/transport.rs
pub fn tls_layer(network: &str, security: Option<&str>, ctx: &str) -> io::Result<bool>
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamShape {
Tcp,
Tls,
Ws { path: String, host: Option<String>, tls: bool },
Grpc { service: String, authority: Option<String>, tls: bool },
}
pub fn resolve_stream(stream: &StreamConfig, ctx: &str) -> io::Result<StreamShape>
pub fn reject_stream_settings(stream: &StreamConfig, proto: &str, ctx: &str) -> io::Result<()>

ctx is the object’s name, inbound <tag> or outbound <tag>, and prefixes every message.

tls_layer decides whether TLS goes under the network. security is trimmed and then validated against the network instead of being compared with "tls", so an unknown value can never quietly mean plaintext:

network security absent, "" or none security = "tls" any other security
tcp plaintext refused: security = "tls" is not valid with network = "tcp"; use network = "tls" … refused: unknown stream security "<value>" (expected "tls" or "none")
tls TLS TLS (the redundant value is accepted) refused, same message
ws, grpc plaintext TLS refused, same message
anything else Ok(false): left to the caller Ok(false) Ok(false)

resolve_stream takes network (default tcp), calls tls_layer, and builds the StreamShape. ws.path defaults to /. grpc.service_name is required (grpc stream needs grpc.service_name). An unknown network fails here with unknown stream network "<value>", which is why tls_layer leaves it alone.

reject_stream_settings is for protocols that never use a transport. It checks two keys: it accepts only a network of absent, "" or tcp and a security of absent, "" or none (both trimmed), and refuses the rest with protocol <proto> does not support stream network "<value>" or protocol <proto> does not support stream security "<value>". Its callers are the inbound socks, shadowsocks, hysteria2, tun and every Unix listener, and the outbound freedom, blackhole, hysteria2 and wireguard.

On the outbound side, build_transport adds what an outbound can fill in because it knows whom it dials:

Shape Fallback chain Error when all are absent
TLS server name (Tls, and Ws/Grpc with TLS) tls.server_name → server <network> stream needs tls.server_name or server, where <network> is tls, ws+tls or grpc+tls
WebSocket Host ws.host → tls.server_name → server ws stream needs ws.host or server
gRPC :authority grpc.authority → tls.server_name → server grpc stream needs grpc.authority or server

build_transport runs before upstream_dest, so an outbound with no server whose stream falls through one of these chains reports the stream message above rather than missing server.

client_tls_config picks the VerifyMode: Insecure for allow_insecure, CustomCa(pem) for ca_file (read from disk now), System otherwise, and refuses both at once: outbound <tag>: tls.allow_insecure and tls.ca_file cannot both be set. ALPN follows the shape as on the inbound side (None, Http1, Http2). The result is TransportConnector::new(kind, Dialer::default(), resolver.clone(), address_family). The transports themselves are described in TCP and TLS transports and WebSocket and gRPC transports.

spawn_generation(built, strict) is where build output meets the operating system. It creates the generation’s CancellationToken, starts one probe task per balancer member with Balancer::spawn_probe, and then, for each BuiltInbound in order, calls bind_inbound:

BindSpec What bind_inbound does
Tcp { host, port } std::net::TcpListener::bind, non-blocking, into StreamListener::Tcp
Udp { host, port } std::net::UdpSocket::bind
Unix(path) removes a stale socket at path, refuses any other file there (<path> exists and is not a socket, AlreadyExists), binds a UnixListener
Tun(spec) tun::open(spec), logging inbound <tag> owns tun device <name>

The listener is then matched with the InboundKind: Stream with a stream listener, Hysteria2 with a UDP socket, Tun with a device descriptor. build_inbound returns each kind together with its bind, so the mismatch arm (inbound <tag>: listener does not match its kind) cannot be reached from a config.

With strict (first start), the first bind failure cancels the token and returns inbound <tag> bind <bind> failed: <error> with the original ErrorKind; main logs failed to start: <error> and exits 1. Without it (reload), the same text is logged and the next inbound is tried. The rest of this story, including teardown order and what a reload keeps, is on Generations and hot reload.

Invariant Mechanism Pinned by
A misspelled key is an error, never a default #[serde(deny_unknown_fields)] on every config and settings struct a_mistyped_key_is_rejected_rather_than_ignored (app/tests/unit/config.rs); an_unknown_setting_is_refused (inbound.rs); hysteria2_refuses_an_unknown_setting (outbound.rs)
An inbound without listen is local only resolve_listen defaults the host to 127.0.0.1 an_inbound_defaults_to_loopback (config.rs)
An unknown or contradictory security never builds plaintext tls_layer validates against the network an_unknown_security_is_rejected_on_every_network, tcp_with_tls_is_rejected_and_names_the_fix, security_is_trimmed_before_matching, and the rest of app/tests/unit/transport.rs
A network or security a protocol cannot honour is refused, not discarded reject_stream_settings, called by reject_stream in both builders a_transport_the_protocol_cannot_honour_is_rejected, security_on_a_protocol_without_a_transport_is_rejected (transport.rs); a_stream_block_is_refused (inbound.rs); hysteria2_refuses_a_stream_block (outbound.rs)
Inbound and outbound apply the same stream rules both call transport::resolve_stream the transport.rs tests exercise the shared functions
A Unix listener carries no transport and no port resolve_listen, transport_for a_unix_listen_takes_no_port, a_socket_listen_needs_a_port, a_stream_protocol_builds_a_unix_stack (inbound.rs)
SOCKS UDP on a Unix listener has a relay address the udp_bind check in build_inbound socks_udp_over_a_unix_socket_needs_udp_bind (inbound.rs)
Hysteria 2 binds UDP, never TCP or Unix build_inbound returns BindSpec::Udp and refuses Listen::Unix a_working_config_builds, hysteria2_refuses_a_unix_listen (inbound.rs)
A TUN inbound has no listener and a valid device spec build_tun_inbound, check_platform tun_owns_its_interface_and_takes_no_listener, tun_refuses_an_mtu_below_the_stack_floor, tun_parses_addresses_and_routes (inbound.rs)
Every balancer member can be health-probed upstream_dest_opt returns None for Hysteria 2 and for any outbound without both server and port hysteria2_cannot_be_balanced (outbound.rs); a_member_with_no_upstream_is_refused (app/tests/integration/e2e_balancer.rs, through --test)
A balancer has members and a known strategy Balancer::new, Strategy::parse a_balancer_needs_at_least_one_member, strategy_names_are_validated (balancer.rs)
A DNS backend that needs a server has one Resolver::from_spec a_backend_without_its_server_is_rejected (app/tests/integration/e2e_dns.rs, through --test)
--test refuses what a start would both run instance::build; BindSpec and check_platform are decided in the build the two --test integration tests above
Tags are unique and every reference resolves before traffic flows duplicate and collision checks in instance::build; build_router resolves to Arc<Outbound> no dedicated test; the checks live only in instance::build and build_router

Every stage returns io::Result. Validation failures are io::ErrorKind::InvalidInput, parse failures InvalidData, a missing geodata code NotFound, and non-Linux TUN routes Unsupported; file reads propagate the OS error unchanged. build stops at the first error, so the order of the steps decides which error an operator sees: the default-outbound check, then [dns], then outbounds in file order, then balancers, then routing rules in order, the default and geodata, and finally inbounds in file order.

The message says where the error is only if the code that raised it added context:

Prefix Raised by
inbound <tag>: resolve_listen, build_inbound, parse_settings, the transport helpers, build_hysteria2_inbound, build_tun_inbound (which also wraps check_platform)
outbound <tag>: build_outbound, parse_settings, build_transport, client_tls_config, build_hy2_config, build_wg_config, parse_address_family
balancer <tag> … the balancer step in instance::build
dns: Resolver::from_spec
none config defines no outbounds, tag uniqueness (duplicate outbound tag: …, duplicate inbound tag: …), routing (route references unknown outbound tag: …, invalid cidr …, invalid domain regex …, the geodata messages), Strategy::parse, Balancer::new, parse_security, the wireguard inbound refusal, errors returned by protocol constructors (TLS certificate and key parsing, the Hysteria 2 authenticator’s hysteria2: …, Shadowsocks 2022 key decoding), and every file-read error

When a file named in the config is missing, the message is the OS error alone, such as No such file or directory (os error 2). Check every path in the config when you see it.

Each caller adds one more prefix when it logs the error: main writes configuration invalid: for --test and failed to start: for a first start, and Instance::reload writes reload: cannot read <path>: , reload: parse failed, keeping current config: or reload: build failed, keeping current config: .

--test stops after build, so anything that needs the operating system’s cooperation is checked only at start or reload:

Not checked by --test Checked when
Port in use, address not local, privileged port bind_inbound
A non-socket file at a Unix listen path bind_inbound
Permission to create a TUN device, name already taken tun::open
Upstream names resolving, upstreams reachable first flow, and balancer probes
WireGuard handshake, Hysteria 2 authentication first flow through that outbound

build is a synchronous function with no .await, so nothing can cancel it halfway. It spawns no task, opens no socket and starts no timer; its file reads are blocking reads on the calling thread. A failed build releases everything by dropping it, which is why a reload can build the new config while the old generation keeps serving. The generation’s CancellationToken does not exist until spawn_generation, and balancer probes, accept loops and device tasks are tied to it there.

Defaults and bounds applied during the build:

Name Value Where
default listen 127.0.0.1 resolve_listen
default WebSocket path / resolve_stream
DEFAULT_PROBE_INTERVAL 30 s app/src/balancer.rs
DEFAULT_PROBE_TIMEOUT 5 s app/src/balancer.rs
DEFAULT_MAX_CONNECTIONS 4096 protocols/src/hysteria/server/config.rs, Hysteria 2 inbound
DEFAULT_MAX_CIRCUITS 65_536 protocols/src/hysteria/server/config.rs, Hysteria 2 inbound
Hysteria 2 inbound udp_idle_timeout default 60 s, range 2 to 600 s build_hysteria2_inbound
Salamander obfs_password at least 4 bytes both Hysteria 2 builders
DEFAULT_MAX_CONCURRENT_STREAMS 102_400 protocols/src/hysteria/config.rs, Hysteria 2 outbound
tun::DEFAULT_MTU 1500, floor 1280 protocols/src/tun/config.rs, build_tun_inbound
DEFAULT_UDP_IDLE_TIMEOUT (TUN) 60 s protocols/src/tun/config.rs
DEFAULT_MAX_FLOWS (TUN) 65_536 protocols/src/tun/config.rs
wireguard::DEFAULT_MTU 1420 protocols/src/wireguard/config.rs
Client runtime buffers 16 to 32 KiB, see Proxy clients and their buffers app/src/outbound/mod.rs

The per-listener handshake and connection caps are applied when serving, not during the build; see Limits.

The unit tests are compiled into the etemenanki-app binary through #[path] modules and run with cargo test -p etemenanki-app --bin etemenanki-app. The integration tests start the real binary and run with cargo test -p etemenanki-app --test integration.

File What it pins
app/tests/unit/config.rs parses_tls_ca_file, a_mistyped_key_is_rejected_rather_than_ignored (top-level key, settings key, table name, TLS key), an_inbound_defaults_to_loopback, route_rules_accept_every_matcher
app/tests/unit/transport.rs the whole tls_layer matrix, row by row, and reject_stream_settings
app/tests/unit/inbound.rs the Hysteria 2 inbound checks (a_working_config_builds, a_certificate_is_required, a_credential_is_required, a_password_and_a_user_table_cannot_both_be_set, an_obfs_password_without_obfs_is_refused, a_short_obfs_password_is_refused, a_masquerade_that_says_authenticated_is_refused, an_out_of_range_udp_idle_timeout_is_refused, a_udp_idle_timeout_without_udp_is_refused, a_zero_limit_is_refused, a_username_with_a_colon_is_refused, two_users_that_collide_once_lower_cased_are_refused, a_stream_block_is_refused, an_unknown_setting_is_refused), the Unix listener shape and the TUN builder
app/tests/unit/outbound.rs WireGuard address family (wireguard_ipv4_only_builds_with_ipv4_address, wireguard_ipv6_only_requires_ipv6_address) and the Hysteria 2 outbound checks, including hysteria2_accepts_its_aliases, hysteria2_refuses_only_a_zero_stream_limit, the_default_stream_limit_is_configurable_as_well_as_defaulted, hysteria2_requires_a_server and hysteria2_cannot_be_balanced
app/tests/unit/balancer.rs Balancer::new and Strategy::parse at build time, and selection at run time
app/tests/integration/e2e_balancer.rs a_member_with_no_upstream_is_refused: --test exits non-zero
app/tests/integration/e2e_dns.rs a_backend_without_its_server_is_rejected: --test exits non-zero

When you add a validation rule, add a unit test next to the builder that enforces it and assert on a distinctive part of the message, as the existing tests do. When the rule should hold for both sides, put it in app/src/transport.rs rather than in one builder. For how the suites are organised, see Testing.