Config to running pipeline
Source files: 27 · checked against Etemenanki 596916d
Etemenanki/app/src/main.rsEtemenanki/app/src/config.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/inbound/tun.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/outbound/proxy.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/router.rsEtemenanki/app/src/balancer.rsEtemenanki/environment/src/routing.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/tun/device.rsEtemenanki/protocols/src/tun/config.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/server/authenticator.rsEtemenanki/protocols/src/hysteria/server/masquerade.rsEtemenanki/protocols/src/transports/tls/config.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/app/tests/unit/config.rsEtemenanki/app/tests/unit/inbound.rsEtemenanki/app/tests/unit/outbound.rsEtemenanki/app/tests/unit/transport.rsEtemenanki/app/tests/unit/balancer.rsEtemenanki/app/tests/integration/e2e_balancer.rsEtemenanki/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.
Responsibilities
Section titled “Responsibilities”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--testrefuses 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.
Three callers, one build
Section titled “Three callers, one build”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 |
fn test_config(path: &Path) -> io::Result<()>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.tomlConfiguration OK.$ etemenanki-app --test -c config.toml2026-01-01T00:00:00.000000Z ERROR etemenanki_app: configuration invalid: outbound a: security = "tls" is not valid with network = "tcp"; use network = "tls" for TLS over plain TCP (security = "tls" layers TLS under network = "ws" or "grpc")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.
Stage 1: parsing
Section titled “Stage 1: parsing”Entry points
Section titled “Entry points”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 |
The schema
Section titled “The schema”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.
#[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.
Opaque settings tables
Section titled “Opaque settings tables”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:
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.
ConfigDiff
Section titled “ConfigDiff”#[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) -> ConfigDiffdiff 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 changedThe 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.
Stage 2: instance::build
Section titled “Stage 2: instance::build”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.
Data flow
Section titled “Data flow”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"]
Step by step
Section titled “Step by step”-
Default outbound tag. The tag of the first
[[outbound]]becomesdefault_tag. With no outbounds at all the build fails at once withconfig 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. -
One resolver for the generation. If
[dns].ca_fileis set, the file is read (std::fs::read) whatever the backend. ThenResolver::from_spec(ResolverSpec { backend, server, server_name, url, ca_pem })builds the resolver, withsystemas the default backend. Its own checks all start withdns::dns: the udp backend needs a server address(likewisetlsandhttps);dns: invalid server address: <error>:servermust be anIP:portsocket 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 thedns:prefix. The build does not contact the resolver. Every outbound and every balancer probe of this generation gets a clone of this oneResolver, so they share one cache. A reload builds a new resolver, so the cache starts empty in each generation. See DNS. -
Outbounds, in file order. For each
[[outbound]], the tag is checked against the tags already in the map (duplicate outbound tag: <tag>), thenbuild_outbound(ob, &resolver)builds it and the result is inserted asArc<Outbound>into aHashMap<CompactString, Arc<Outbound>>. A duplicate is detected at its second occurrence, after the first has been built. -
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_optreturnsNonewhen the member’sserverorportis absent, which is the normal case forfreedom,blackholeandwireguard, and always forhysteria2(and its aliases), which has a server but listens on UDP only; strategygoes throughStrategy::parse(failoverwhen absent; otherwisefailoverorround_robin, case-sensitive):unknown balancer strategy "<value>" (expected "failover" or "round_robin").Balancer::newthen 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 inBuilt.balancerswithprobe_intervalandprobe_timeoutin seconds (defaultsDEFAULT_PROBE_INTERVALandDEFAULT_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].defaultmay name them. See Outbounds and balancers. - its tag must not already be in the map:
-
Router.
build_router(&cfg.route, &outbounds, &default_tag)compiles[[route.rule]]in order into aRouteTable, resolves each rule’soutboundand the default to anArc<Outbound>, and loads geodata. Details below. -
Inbounds, in file order. Each tag is checked with a
HashSet(duplicate inbound tag: <tag>), thenbuild_inbound(ib)returns the inbound and itsBindSpec, stored as aBuiltInbound. 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.
The router step
Section titled “The router step”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.
The inbound side
Section titled “The inbound side”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”#[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) -> BindSpecEvery 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”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 |
Per-protocol construction
Section titled “Per-protocol construction”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.
authmust benoneorpassword(inbound <tag>: unknown socks auth "<value>"). On a Unix listener withudp = truethe build requiresudp_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::newreceivescfg.sniffingas well, since SOCKS drives its own connection. - VLESS and VMess. Every
idgoes throughUuid::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>"orinbound <tag>: unknown shadowsocks method "<value>". For 2022 methods,Ss2022ServerConfig::from_passwordandSs2022User::from_passworddecode the keys, and their errors come from the protocols crate without the tag prefix.
The inbound transport comes from one of two helpers:
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.
Hysteria 2 inbound
Section titled “Hysteria 2 inbound”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.
TUN inbound
Section titled “TUN inbound”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
mtuorDEFAULT_MTUand refuses anything under 1280 (inbound <tag>: tun mtu must be at least 1280); - parses each
addressascidr::IpInet(host bits allowed) and each entry ofroutesascidr::IpCidr(host bits refused), with errorsbad tun address "<value>"andbad tun route "<value>"; - builds a
DeviceSpec { name, mtu, addresses, routes }and callstun::check_platform, which refusesrouteson anything but Linux.tun::opencalls the same function when the generation opens the device, so--testand a start agree; - builds
TunConfigwithudp(defaulttrue),udp_idle_timeout(defaultDEFAULT_UDP_IDLE_TIMEOUT) andmax_flows(defaultDEFAULT_MAX_FLOWS), and callswithout_sniffing()whensniffing = 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.
The outbound side
Section titled “The outbound side”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.
Proxy clients and their buffers
Section titled “Proxy clients and their buffers”The proxy protocols share one wrapper:
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 |
Per-protocol construction
Section titled “Per-protocol construction”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 inSocksOutboundbeside the TCPProxyClient, becauseUDP ASSOCIATEneeds its own control connection per datagram link. - VLESS and VMess parse
idas a UUID (outbound <tag>: invalid uuid …). VMesssecurityis case-insensitive: absent,autoandaes-128-gcmgiveSecurity::Aes128Gcm,chacha20-poly1305givesSecurity::ChaCha20Poly1305, and anything else fails withunknown vmess security "<value>", which carries no tag prefix. - Shadowsocks 2022 splits
passwordon:into an identity key chain ending in the user key, decoding and normalising each withdecode_pskandnormalise_psk. - Hysteria 2 runs
reject_stream,parse_settings, thenupstream_destwithDialNetwork::Udpandparse_address_family, and thenbuild_hy2_config. That function refuses an emptypassword(hysteria2 password must not be empty),allow_insecuretogether withca_file(allow_insecure and ca_file cannot both be set), the same obfuscation mistakes as the inbound, andmax_concurrent_streams = 0(max_concurrent_streams must be at least 1; defaultDEFAULT_MAX_CONCURRENT_STREAMS, no upper bound), all with theoutbound <tag>:prefix. It readsca_filefrom disk.server_namefalls back toserver. TLS material lives insettings, not in[outbound.stream.tls], which is why the stream block is refused. - WireGuard runs
reject_stream,parse_settingsandparse_address_family, thenvalidate_wg_address_family, which refusesipv4_onlywithout an IPv4addressandipv6_onlywithout an IPv6 one (outbound <tag>: wireguard address_family ipv6_only needs an IPv6 address).build_wg_configthen parses the keys withparse_key(outbound <tag>: invalid wireguard private_key, and likewisepeer_public_keyandpreshared_key), splitsendpointat the last:(wireguard endpoint must be host:port,invalid wireguard endpoint port), and defaultsmtutowireguard::DEFAULT_MTU. address_familyis parsed byparse_address_familyforfreedom, the transport-based protocols,hysteria2andwireguard:outbound <tag>: invalid address_family "<value>". Absent meansAddressFamilyStrategy::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.
Transport shaping: app/src/transport.rs
Section titled “Transport shaping: app/src/transport.rs”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:
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.
Handing over to the generation
Section titled “Handing over to the generation”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.
Invariants
Section titled “Invariants”| 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 |
Failure paths
Section titled “Failure paths”One error type, first failure wins
Section titled “One error type, first failure wins”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.
Message prefixes
Section titled “Message prefixes”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: .
What --test cannot see
Section titled “What --test cannot see”--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 |
Cancellation and resources
Section titled “Cancellation and resources”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.
Limits
Section titled “Limits”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.