etemenanki-app: from TOML to a spec
Source files: 41 · checked against Etemenanki 555b7df
Etemenanki/app/src/lib.rsEtemenanki/app/src/config.rsEtemenanki/app/src/lower.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/main.rsEtemenanki/app/src/api.rsEtemenanki/app/src/subscribe.rsEtemenanki/supervisor/src/supervisor.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/entity/user.rsEtemenanki/supervisor/src/topology/spec_plan/mod.rsEtemenanki/supervisor/src/topology/spec_plan/inbound.rsEtemenanki/supervisor/src/topology/spec_plan/outbound.rsEtemenanki/supervisor/src/topology/spec_plan/transport.rsEtemenanki/supervisor/src/topology/spec_plan/route.rsEtemenanki/supervisor/src/topology/spec_plan/dns.rsEtemenanki/supervisor/src/topology/inbound/mod.rsEtemenanki/supervisor/src/topology/balancer.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/helpers/address_family.rsEtemenanki/protocols/src/ss_2022/users.rsEtemenanki/protocols/src/ss_2022/crypto.rsEtemenanki/protocols/src/ss_legacy/aead.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/protocols/src/tun/config.rsEtemenanki/protocols/src/tun/device.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/hysteria/server/config.rsEtemenanki/protocols/src/hysteria/server/authenticator.rsEtemenanki/environment/src/routing.rsEtemenanki/webclient/src/listen.rsEtemenanki/ffi/src/client.rsEtemenanki/app/tests/unit/config.rsEtemenanki/app/tests/unit/lower.rsEtemenanki/app/tests/unit/transport.rsEtemenanki/app/tests/unit/subscribe.rsEtemenanki/app/tests/unit/api.rsEtemenanki/app/tests/integration/e2e_dns.rsEtemenanki/app/tests/integration/e2e_balancer.rs
etemenanki-app is the TOML front end of the supervisor. It turns one config file, and the subscribe file the config may name, into a Spec<UserKey>: the desired state that etemenanki_supervisor validates, builds and runs. The path has two stages. app/src/config.rs reads the bytes, parses them into plain structs and merges the subscribe file, and app/src/lower.rs → lower turns the merged Config into a spec. Apart from the REST API listener, which main.rs binds itself, nothing in the app builds a listener, an outbound or a router: construction belongs to the supervisor.
This page follows that path from bytes to spec: every section struct of the schema, every lowering step and the check it makes, the stream rules shared by inbounds and outbounds, and what --test does with the result. Read it before adding a config key, a protocol or a check to the app. The rules the supervisor owns are on Validation, the typed spec on The spec, the subscribe merge on Subscribe files, and the file watcher and reloads on Running and reloading.
Responsibilities
Section titled “Responsibilities”The config and lowering code:
- parses the config strictly, so a misspelled key is an error rather than a default;
- reads the subscribe file the config names and merges it into the config;
- reads the certificate, key and CA files that the chosen stream shapes and protocols use, and passes their bytes into the spec;
- applies the defaults the schema promises, such as loopback for an absent
listenorfailoverfor an absentstrategy; - names every user by a name the config writes (
UserKey), never by a credential; - fills every gap in an outbound transport (WebSocket host, gRPC authority, TLS server name), so the spec carries none;
- refuses strings that do not parse and key combinations that a spec cannot express.
It leaves out:
- every rule about how the typed pieces fit together: duplicate tags, references to tags that do not exist, balancer members, two users presenting one credential, credential lengths, limits of at least 1, value ranges. The supervisor checks these when it applies the spec;
- parsing certificates, keys and CA bundles, and loading geodata: the supervisor’s builders do both;
- binding, device creation and any network I/O.
One owner per rule
Section titled “One owner per rule”app/src/lower.rs states the split in its module comment: the app owns what only the TOML front end can know, and the supervisor owns everything about how the typed pieces fit. “A rule checked here as well would be a second copy that drifts, and a rule checked only here would be missing for every other front end.” So lower lowers faithfully rather than helpfully: a rule naming an outbound that does not exist lowers fine, and the supervisor refuses it.
| Owner | Refuses | Captured with --test |
|---|---|---|
| etemenanki-app, while parsing | Unknown keys and table names, wrong types, missing required fields, bytes that are not UTF-8 | TOML parse error at line 1, column 1 (first line) |
| etemenanki-app, while lowering | Strings that do not parse (UUIDs, method names, CIDRs, ports, keys, address families, networks, security values, strategies, DNS backends), files that cannot be read, users without the name they are keyed by, two users under one name, and key combinations with no meaning | inbound in: users[0] has no email; a user of this inbound is named by its email |
The supervisor, in validate |
Tags, references, balancer members, user sets and credentials, ranges and limits, platform support | route references unknown outbound missing |
| The supervisor, in its builders | Certificates, keys and CA bundles that do not parse, geodata that cannot be loaded | building inbound in failed: no certificate in PEM bundle |
app/tests/unit/lower.rs → semantic_problems_are_left_to_the_supervisor pins the split from the app’s side. It lowers a config written to break supervisor rules, among them a duplicate inbound tag, a TUN MTU of 1000, a Hysteria 2 inbound with both a password and users, a 1-second UDP idle timeout, zero circuits, a one-byte salamander key, a Trojan password shared by two users, a WireGuard ipv6_only family with only an IPv4 address, a Hysteria 2 client with an empty password and zero streams, and a balancer and a rule that name a missing outbound. It asserts that lowering accepts the config. It then checks a number of those values in the spec: the rule’s missing outbound, the MTU of 1000, the Hysteria 2 password beside its user set, the timeout and the zero circuits, the WireGuard family, the zero stream limit, the balancer’s members, and the two users that share one password. The rules, their order and their texts are on Validation.
The crate
Section titled “The crate”etemenanki-app is a library and a binary. app/src/lib.rs declares the library’s modules, and app/src/main.rs is the thin binary on top. app/Cargo.toml sets the version to 2.1.1, which etemenanki-app --version prints.
| Module | What it holds | Described on |
|---|---|---|
config |
The schema, parse_bytes, Sources and effective |
this page |
lower |
lower, UserKey, and every per-protocol lowering |
this page |
transport |
tls_layer, resolve_stream, reject_stream_settings |
this page |
instance |
build, Built, Lowering, Read, check, check_bytes, LoadError; Core, Instance, Reload |
this page for the first seven; Running and reloading for the rest |
subscribe |
parse, apply, ensure_builtins |
Subscribe files |
api, routes |
api::options, ConfigFile, route views and edits |
REST API |
The FFI crate depends on the library and reuses config and lower with a lowering of its own (ffi/src/client.rs → lowering), described on FFI.
Callers
Section titled “Callers”Every caller runs the same three functions in the same order: read the sources, config::effective, then a lowering. They differ in where the bytes come from and what receives the spec.
| Caller | Reads | Lowers with | Hands the spec to |
|---|---|---|---|
--test (main.rs → instance::check) |
std::fs::read(path), then config::sources_from |
instance::build |
etemenanki_supervisor::check, a dry run |
Start (Instance::start → Core::start) |
config::read_sources(&path), handed over as a Read |
Arc::new(build) as the Lowering |
SupervisorBuilder::start |
Reload (Instance::reload → Core::reload_with) |
config::read_sources(&path), inside a closure that returns a Read |
the Lowering given at start |
Supervisor::apply |
REST API edit (api.rs → ConfigFile::set_route) |
the edited bytes, through instance::check_bytes |
instance::build |
etemenanki_supervisor::check, before the file is written |
| FFI | config::sources_given(config, subscribe) |
the FFI’s lowering (ffi/src/client.rs): lower, then its own rewrites |
a Core of its own |
The start, reload and API paths are on Running and reloading and REST API. This page covers the part they share and the --test path.
Key types
Section titled “Key types”Config
Section titled “Config”#[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, #[serde(default)] pub subscribe: Option<SubscribeConfig>, #[serde(default)] pub api: Option<ApiConfig>, /// The subscribe file's `[dns]`, once `subscribe::apply` has merged it. #[serde(skip)] pub subscribe_dns: Option<etemenanki_subscribe::dns::DnsConfig>,}Every struct in the schema carries #[serde(deny_unknown_fields)]. The array names are singular in TOML through rename: [[inbound]], [[outbound]], [[balancer]], [[route.rule]]. subscribe_dns is never read from the file; only the merge sets it. Config and every struct under it except the per-protocol settings derive Clone and PartialEq, and Config, LogConfig, DnsConfig, StreamConfig, its three sub-tables and RouteConfig also derive Default. subscribe::apply compares cfg.dns with DnsConfig::default() to decide whether to warn that the config’s [dns] is ignored. No struct of the schema derives Debug, so a parsed config, with the passwords in it, cannot be formatted whole.
Which code reads each part of Config:
| Field | Read by |
|---|---|
log |
main.rs → init_tracing only, for [log].level, and only when RUST_LOG is unset or does not parse. lower never reads it. |
dns |
lower_dns, unless subscribe_dns is set |
inbounds, outbounds, balancers, route |
lower |
subscribe |
config::sources_from (the path), config::sources_given (whether the section is there), subscribe::apply (the picks), Instance::watched_files (the path; see Running and reloading) and app/src/routes.rs (the picks the REST API shows and edits). lower never reads it. |
api |
api::options, called by instance::build after lower |
subscribe_dns |
lower_dns |
Sections
Section titled “Sections”pub struct LogConfig { pub level: Option<String>, // an EnvFilter directive; None → "info"}
pub struct DnsConfig { pub backend: Option<String>, // "system" (default), "udp", "tls", "https" pub server: Option<String>, // IP:port; every backend but system pub server_name: Option<String>, // tls only pub url: Option<String>, // https only pub ca_file: Option<String>, // tls and https}
pub struct SubscribeConfig { pub path: Option<PathBuf>, pub routes: BTreeMap<String, CompactString>, // [subscribe.routes]}
pub struct ApiConfig { pub listen: String, // default etemenanki_webclient::DEFAULT_LISTEN pub secret: Option<String>,}LogConfig.levelis handed toEnvFilter::new, so it takes a level name (error,warn,info,debug,trace) or any directiveRUST_LOGaccepts. A validRUST_LOGtakes precedence over it; see Running and reloading.DnsConfig. The default backend is the host resolver on purpose: the struct’s comment says it is the only one that honours/etc/hosts,nsswitch.confand search domains, so a DNS client as default would change what names resolve to.serveris required by every backend butsystem,httpsincluded, because taking the address from the URL would mean resolving the resolver.SubscribeConfig.pathis optional to the schema. A config loaded from disk must set it (read_sourcesrefuses one without); a front end handed the subscribe file’s contents, such as the FFI, reads no path. A relative path is taken from the working directory, like every path in the config.describe()returns the path, orthe subscribe filewhen there is none; the merge’s messages name the file with it, for example[subscribe.routes] names "<key>", which is not a route group in <file>. Inroutes, the keydefaultpicks the node for traffic that no group matches, and a value may name any outbound or balancer tag, ordirectandblackhole, which are always available. The rest of whatroutespicks is on Subscribe files.ApiConfig.listendefaults toDEFAULT_LISTEN="127.0.0.1:9090"(webclient/src/listen.rs).api::optionsparses it withListen::from_str: a value containing a/is a Unix socket path, relative ones such as./api.sockincluded, and anything else must be anip:port, so a host name such aslocalhost:9090is refused.secretis required unlesslistenis loopback or a Unix socket. The REST API itself is on REST API.
Inbounds and outbounds
Section titled “Inbounds and outbounds”pub struct InboundConfig { pub tag: CompactString, pub protocol: String, pub listen: Option<String>, // an IP, or a path starting with '/' pub port: Option<u16>, pub stream: StreamConfig, pub address_family: Option<String>, pub sniffing: bool, // default true pub settings: toml::Value, // default: an empty table}
pub struct OutboundConfig { pub tag: CompactString, pub protocol: String, pub server: Option<String>, pub port: Option<u16>, pub stream: StreamConfig, pub address_family: Option<String>, pub settings: toml::Value, // default: an empty table}
pub struct StreamConfig { pub network: Option<String>, // "tcp" (default), "tls", "ws", "grpc" pub security: Option<String>, // "tls" or "none" pub tls: TlsConfig, pub ws: WsStreamConfig, pub grpc: GrpcStreamConfig,}
pub struct TlsConfig { pub server_name: Option<String>, pub allow_insecure: bool, // default false pub ca_file: Option<String>, pub cert_file: Option<String>, pub key_file: Option<String>,}
pub struct WsStreamConfig { pub path: Option<String>, pub host: Option<String> }pub struct GrpcStreamConfig { pub service_name: Option<String>, pub authority: Option<String> }InboundConfig.listen defaults to loopback, not to every interface. The field’s comment gives the reason: a listen key that failed to deserialise (a typo, a wrong nesting) used to fall back to 0.0.0.0 and put a proxy meant to be local on the network. deny_unknown_fields catches the typo, and the loopback default catches what it does not; a server that wants every interface says so.
InboundConfig.address_family is deserialized, and nothing reads it. sniffing is copied into InboundSpec.sniff; which flows are inspected, and for how long, is on Sniffing.
StreamConfig.security composes TLS under a ws or grpc network; the field’s comment calls network = "tls" a TLS-over-TCP network kept for back-compat. How the two keys combine is under tls_layer.
Balancers and routes
Section titled “Balancers and routes”pub struct BalancerConfig { pub tag: CompactString, pub outbounds: Vec<CompactString>, // required; order is the failover priority pub strategy: Option<String>, // "failover" (default) or "round_robin" pub probe_interval: Option<u64>, // seconds pub probe_timeout: Option<u64>, // seconds}
pub struct RouteConfig { pub default: Option<CompactString>, pub geoip: Option<PathBuf>, pub geosite: Option<PathBuf>, pub rules: Vec<RuleConfig>, // [[route.rule]]}
pub struct RuleConfig { pub outbound: CompactString, // required pub domain_suffix: Vec<CompactString>, pub domain_keyword: Vec<CompactString>, pub domain_full: Vec<CompactString>, pub domain_regex: Vec<String>, pub cidr: Vec<String>, pub source_cidr: Vec<String>, pub port: Vec<String>, pub network: Option<String>, // "tcp" or "udp" pub inbound_tag: Vec<CompactString>, pub geosite: Vec<CompactString>, pub geoip: Vec<CompactString>,}Per-protocol settings
Section titled “Per-protocol settings”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 lower deserializes the table into the protocol’s own struct only after it has matched on protocol (inbound_settings, outbound_settings, both cfg.settings.clone().try_into()). Every settings struct also carries deny_unknown_fields, so for every protocol that reads settings, a typo inside it fails in stage 2 with the invalid settings prefix. The freedom and blackhole outbounds read no settings and deserialize none.
| Struct | Used for | Fields (required in bold) | Defaults |
|---|---|---|---|
SocksInboundSettings |
inbound socks |
auth, accounts, udp, udp_bind (an IP) |
auth = "none", udp = true; whole struct defaults |
HttpInboundSettings |
inbound http |
accounts, allow_transparent |
allow_transparent = false; whole struct defaults |
Account |
accounts entries |
user, pass |
none |
TrojanInboundSettings |
inbound trojan |
users of TrojanUserCfg { password, email } |
users = []; password required, email defaults to "" |
UuidUsersSettings |
inbound vless, vmess |
users of IdUser { id, email } |
users = []; id required, email defaults to "" |
ShadowsocksInboundSettings |
inbound shadowsocks |
method, password, users (alias clients) of ShadowsocksUserCfg { password, email } |
users = []; email defaults to "" |
Hysteria2InboundSettings |
inbound hysteria2 |
cert_file, key_file, password, users of Hysteria2UserCfg { user, pass, email }, udp, udp_idle_timeout, max_connections, max_circuits, obfs, obfs_password, masquerade |
udp = false; whole struct defaults; user and pass required per user |
Hysteria2MasqueradeCfg |
the masquerade sub-table |
status, body, content_type |
whole struct defaults; values applied in lower_hysteria2_inbound |
TunInboundSettings |
inbound tun |
name, mtu, address, routes, udp, udp_idle_timeout, max_flows |
whole struct defaults; values applied in lower_tun |
ProxyOutboundSettings |
outbound socks, http |
user, pass |
whole struct defaults |
TrojanOutboundSettings |
outbound trojan |
password |
none |
VlessOutboundSettings |
outbound vless |
id |
none |
VmessOutboundSettings |
outbound vmess |
id, security |
none |
ShadowsocksOutboundSettings |
outbound shadowsocks |
method, password |
none |
Hysteria2OutboundSettings |
outbound hysteria2 |
password, server_name, allow_insecure, ca_file, obfs, obfs_password, max_concurrent_streams |
allow_insecure = false |
WireguardOutboundSettings |
outbound wireguard |
private_key, peer_public_key, preshared_key, endpoint, endpoint_address_family, address (IPs), mtu, keepalive, reserved (3 bytes) |
none |
IdUser.email, TrojanUserCfg.email and ShadowsocksUserCfg.email default to an empty string on purpose: the schema accepts a missing email so that lower can refuse it with a message naming the inbound and the user’s position, rather than a serde error.
The Hysteria 2 outbound’s TLS keys (server_name, allow_insecure, ca_file) sit in settings, not under [outbound.stream.tls], and a [stream] network other than tcp, or a security other than none, is refused (reject_stream_settings). Hysteria 2 is not parameterised by a dialer, so its TLS settings are its own rather than a stream’s.
Schema defaults and required fields
Section titled “Schema defaults and required fields”Serde applies these defaults itself:
| Field | Default | Mechanism |
|---|---|---|
InboundConfig.sniffing |
true |
default_sniffing() |
InboundConfig.settings, OutboundConfig.settings |
an empty TOML table | default_settings() |
ApiConfig.listen |
"127.0.0.1:9090" |
default_api_listen() |
TlsConfig.allow_insecure |
false |
#[serde(default)] |
Every Option |
None |
#[serde(default)] |
Every Vec and map except BalancerConfig.outbounds and WireguardOutboundSettings.address |
empty | #[serde(default)] |
SocksInboundSettings |
auth = "none", accounts = [], udp = true, udp_bind = None |
its Default impl, with #[serde(default)] on the struct |
The fields without a default are required, and a missing one is a parse error: tag and protocol on every [[inbound]] and [[outbound]], tag and outbounds on every [[balancer]], outbound on every [[route.rule]]. Because the default settings is an empty table, a protocol whose settings struct has a required field fails with invalid settings: missing field … when the table is absent, for example outbound o: invalid settings: missing field `address` for a WireGuard outbound without address.
Every other default (loopback listen, WebSocket path /, the Hysteria 2 and TUN limits) is applied by lower, next to the check that uses it. They are listed under Limits.
Sources and the read functions
Section titled “Sources and the read functions”#[derive(Clone, PartialEq, Default)]pub struct Sources { pub config: Vec<u8>, pub subscribe: Option<Vec<u8>>,}
pub fn load(path: &Path) -> io::Result<Config>;pub fn parse_bytes(bytes: &[u8]) -> io::Result<Config>;pub fn read_sources(path: &Path) -> io::Result<(Sources, io::Result<Config>)>;pub fn sources_from(config: Vec<u8>) -> io::Result<(Sources, io::Result<Config>)>;pub fn sources_given(config: Vec<u8>, subscribe: Option<Vec<u8>>) -> io::Result<(Sources, io::Result<Config>)>;pub fn effective(parsed: io::Result<Config>, sources: &Sources) -> io::Result<Config>;Sources holds the config file’s bytes and, when there is one, the subscribe file’s bytes. How a reload uses them is on Running and reloading. The read functions return the parse beside the bytes, because the subscribe file’s path is known only once the config has parsed. A read failure, a [subscribe] without the path that sources_from needs, and a disagreement between the config and the files handed to sources_given are outer errors; a config that does not parse is still a set of sources, and effective reports its parse error.
instance.rs names the read’s type, since Core::start takes one and Core::reload_with takes a closure that returns one:
pub type Read = io::Result<(Sources, io::Result<Config>)>;lower, UserKey, Built and check
Section titled “lower, UserKey, Built and check”pub type UserKey = UserName;
pub fn lower(cfg: &Config) -> io::Result<Spec<UserKey>>;pub struct Built { pub spec: Spec<UserKey>, pub api: Option<ApiOptions>,}
pub fn build(cfg: &Config) -> io::Result<Built>; // lower, then api::optionspub type Lowering = Arc<dyn Fn(&Config) -> io::Result<Built> + Send + Sync>;
pub async fn check(path: &Path) -> Result<(), LoadError>;pub async fn check_bytes(config: Vec<u8>) -> Result<(), LoadError>;
pub enum LoadError { Config(#[from] io::Error), // #[error(transparent)] Apply(#[from] ApplyError), // #[error(transparent)]}buildis the app’s lowering:lower(cfg)?, thenapi::options(cfg)?. The spec is lowered first, so an inbound error is reported before an[api]error.Loweringis how aCoreturns a merged config into what it runs. The app passesArc::new(build); the FFI passes a closure that callslower, refuses inbounds that serve other hosts, serves the platform’s TUN descriptor on the config’s TUN inbound (adding one when the config has none), and returnsapi: None. Its rules are on FFI.LoadErrorjoins the two sources of failure. Both variants are transparent, so the text a caller logs is the inner error’s own.
UserName, Spec and every spec type come from the supervisor crate and are documented on The spec and Users, principals and sessions.
Data flow
Section titled “Data flow”flowchart TB bytes["config bytes"] --> parse["parse_bytes"] parse -->|"parsed, has a subscribe section"| sub["read the subscribe file"] parse --> eff["effective"] sub --> eff eff -->|"subscribe file"| merge["subscribe::parse and subscribe::apply"] eff -->|"no subscribe file"| builtins["subscribe::ensure_builtins, not strict"] merge --> lower["lower"] builtins --> lower lower --> api["api::options"] api --> built["Built: spec and api"] built -->|"--test, API edit"| check["etemenanki_supervisor::check"] built -->|"start"| start["SupervisorBuilder::start"] built -->|"reload"| apply["Supervisor::apply"]
Stage 1: parsing and merging
Section titled “Stage 1: parsing and merging”parse_bytes and load
Section titled “parse_bytes and load”parse_bytes checks that the bytes are UTF-8 and hands the text to toml::from_str. Both failures are io::ErrorKind::InvalidData:
| Failure | Message |
|---|---|
| Not UTF-8 | The Utf8Error display, for example invalid utf-8 sequence of 1 bytes from index 0 |
| TOML syntax, an unknown key, a wrong type, a missing required field | The toml error display; its first line is TOML parse error at line N, column M. |
load is std::fs::read followed by parse_bytes. It parses the file as written and does not merge a subscribe file; init_tracing and Instance::watched_files use it.
Parsing checks shape and key names only. Whether protocol = "vlesss" or network = "WS" means anything is decided in stage 2, which is why stage 2 owns almost every error message.
Reading the sources
Section titled “Reading the sources”| Function | Used by | Config bytes | Subscribe bytes |
|---|---|---|---|
read_sources(path) |
Instance::start, Instance::reload, the REST API’s reads |
std::fs::read(path) |
through sources_from |
sources_from(config) |
read_sources, check_bytes |
given | read from [subscribe].path when the config parses and has a [subscribe] |
sources_given(config, subscribe) |
the FFI | given | given; [subscribe].path is not read |
sources_from reads the subscribe file only when the config parses. When the config has a [subscribe] without a path, it fails with [subscribe] needs a path naming the subscribe file (InvalidInput). A subscribe file that cannot be read fails with subscribe file <path>: <os error>, keeping the OS error’s kind.
sources_given checks that both sides agree, but only when the config parses:
| Config | Subscribe bytes | Result (InvalidInput) |
|---|---|---|
has [subscribe] |
none | the config has a [subscribe] section, but no subscribe file was given |
no [subscribe] |
given | a subscribe file was given, but the config has no [subscribe] section to pick its routes in |
The config must have a [subscribe] exactly when a subscribe file is given, because [subscribe.routes] picks among what the file defines.
effective: the merged config
Section titled “effective: the merged config”effective(parsed, sources) returns the parse error if there is one. Otherwise:
- With subscribe bytes, it calls
subscribe::parse(errors startsubscribe:, for examplesubscribe: TOML parse error at line 1, column 1) and thensubscribe::apply. The merge appends each node as an outbound after the config’s own, replaces the config’s route rules with the file’s route groups, and, when the file has[dns], setscfg.subscribe_dns. Every rule of the merge is on Subscribe files. - Without, it calls
subscribe::ensure_builtins(&mut cfg, false). When[route].defaultor any rule namesdirectorblackhole, and no outbound or balancer carries that tag, it appends afreedomoutbound taggeddirector ablackholeoutbound taggedblackhole, in that order, after the config’s own outbounds. Not strict, it never fails: an outbound or balancer of the config’s own that holds the tag is what the tag means, whatever its protocol, because the config wrote both the tag and the routes that name it.
Because the built-ins are appended after the config’s outbounds, they never change which outbound is first, and so never change the default route of a config that has outbounds. A config with no [[outbound]] at all whose [route] default = "direct" gets the built-in direct as its only outbound and passes --test. In a config with no [[outbound]] and no [route].default, the first built-in appended becomes the first outbound and so the default route: direct when any rule names it, otherwise blackhole when a rule names that.
Stage 2: lowering
Section titled “Stage 2: lowering”The order
Section titled “The order”flowchart LR cfg["Config, merged"] --> def["default tag: first outbound"] def --> dns["lower_dns"] dns --> ob["lower_outbound, each in file order"] ob --> bal["lower_balancer, each"] bal --> rt["lower_route"] rt --> ib["lower_inbound, each in file order"] ib --> spec["Spec"]
- Default tag. The tag of the first outbound (after the merge) becomes the route’s default unless
[route].defaultnames another. With no outbounds at all,lowerfails at once withconfig defines no outbounds. - DNS, then outbounds in file order, then balancers, then the route table, then inbounds in file order.
lower_inboundreturns anInboundSpecand, for protocols that take users written inline, aUserSettagged with the inbound’s own tag. The sets are collected intoSpec.user_sets.Spec.policiesisPolicies::default(). EveryInboundSpec.user_removaland everyOutboundSpec.drainisNone, and everyUserSpec.speed_limitisNone: the app sets no policy and no speed limit.
Each step returns on its first error, so this order decides which error an operator sees. A config with an unknown inbound protocol and an unknown outbound protocol reports the outbound; a bad [dns] is reported before either. A rule with both a bad CIDR and an unknown outbound reports the CIDR, because the reference is the supervisor’s to check and the supervisor never sees a spec that failed to lower.
Helpers
Section titled “Helpers”| Function | Signature | What it does |
|---|---|---|
invalid |
fn invalid(msg: String) -> io::Error |
An InvalidInput error with msg |
within |
fn within(ctx: &str) -> impl Fn(io::Error) -> io::Error + '_ |
Prefixes a helper’s error with ctx: , keeping its kind. Used for errors from code that does not know which object it was called for: the Shadowsocks 2022 key functions and Strategy::parse. |
read_file |
fn read_file(ctx: &str, key: &str, path: &str) -> io::Result<Vec<u8>> |
std::fs::read, failing with <ctx>: cannot read <key> "<path>": <os error> and the OS error’s kind |
parse_host |
fn parse_host(host: &str) -> Remote |
Remote::IpAddr when the text parses as an IP address, Remote::Domain otherwise |
parse_uuid |
fn parse_uuid(ctx: &str, id: &str) -> io::Result<Uuid> |
Uuid::parse_str, failing with <ctx>: invalid uuid "<id>": <error> |
parse_family |
fn parse_family(ctx: &str, key: &str, raw: Option<&str>) -> io::Result<AddressFamilyStrategy> |
Absent is Auto. Otherwise AddressFamilyStrategy::from_str, whose own error (unknown address family strategy) is replaced by <ctx>: invalid <key> "<value>" |
parse_cidr |
fn parse_cidr(s: &str) -> io::Result<IpCidr> |
A route rule’s cidr or source_cidr value, failing with invalid cidr "<value>": <parser error> |
parse_network |
fn parse_network(s: &str) -> io::Result<routing::TargetNetwork> |
A route rule’s network: exactly tcp or udp, otherwise invalid rule network "<value>" (expected "tcp" or "udp") |
lower_obfs |
fn lower_obfs(ctx, obfs, obfs_password) -> io::Result<Option<ObfsSpec>> |
Hysteria 2 obfuscation on either side; see below |
verify_mode |
fn verify_mode(ctx, prefix, allow_insecure, ca_file) -> io::Result<VerifyMode> |
How a client checks the server it dialed; see below |
AddressFamilyStrategy::from_str (protocols/src/helpers/address_family.rs) trims the value, lower-cases it and turns - into _ before it matches:
| Value | Strategy |
|---|---|
absent, "", auto |
Auto |
ipv4, v4, 4, ipv4_only, ipv4only |
Ipv4Only |
ipv6, v6, 6, ipv6_only, ipv6only |
Ipv6Only |
prefer_ipv4, prefer_v4, ipv4_prefer, v4_prefer |
PreferIpv4 |
prefer_ipv6, prefer_v6, ipv6_prefer, v6_prefer |
PreferIpv6 |
lower_obfs refuses an obfs it does not know and an obfs_password without obfs, because either would otherwise quietly mean no obfuscation, and the operator would get a node that works and is trivially fingerprinted:
obfs |
obfs_password |
Result |
|---|---|---|
| absent | absent | None |
| absent | set | <ctx>: obfs_password is set but obfs is not; did you mean obfs = "salamander"? |
salamander |
set or absent | ObfsSpec::Salamander { psk }, the password’s bytes, or empty when absent |
| anything else | any | <ctx>: unknown obfs "<value>" (expected "salamander") |
The key’s length (at least MIN_SALAMANDER_PSK = 4 bytes, supervisor/src/build/validate.rs) is the supervisor’s rule: an absent or short password lowers, and --test then refuses it with inbound in: salamander obfs key must be at least 4 bytes.
verify_mode takes a prefix, where the keys sit in the config: tls. under [stream], empty in Hysteria 2’s settings. Its messages name the keys as written.
allow_insecure |
ca_file |
VerifyMode |
|---|---|---|
true |
set | refused: <ctx>: <prefix>allow_insecure and <prefix>ca_file cannot both be set |
true |
absent | Insecure |
false |
set | CustomCa(bytes), the file read with read_file under the key <prefix>ca_file |
false |
absent | System |
fn lower_dns(cfg: &Config) -> io::Result<DnsSpec>;fn lower_single_dns(dns: &DnsConfig) -> io::Result<Backend>;- With
subscribe_dns,lower_dnsbuildsDnsSpec::Split: each URL ofpre_proxyandthrough_proxythroughBackend::from_url, anduse_hostsandresolve_ipv6copied. The config’s[dns]is not read at all, not even itsca_file. The URL forms and the file’sconnect_ipv6are on Subscribe files. - Without,
lower_single_dnsreadsca_filewhen it is set, whatever the backend (dns: cannot read ca_file "<path>": <os error>), and callsBackend::from_spec. The result isDnsSpec::Single(backend). With no[dns], that isSingle(Backend::System).
Backend::from_spec (protocols/src/dns/mod.rs) defaults the backend to system and refuses, all with InvalidInput:
| Problem | Message |
|---|---|
udp, tls or https without server |
dns: the udp backend needs a server address (likewise tls, https) |
server that is not an IP:port socket address |
dns: invalid server address: invalid socket address syntax |
tls without server_name |
dns: the tls backend needs a server name to verify against |
https without url |
dns: the https backend needs the resolver's url |
url not starting https:// |
dns: "<url>" is not an https:// url |
url with an empty authority or a userinfo part |
dns: "<url>" has no usable host |
url with another malformed authority |
dns: "<url>" has no host, has an invalid port, has an unclosed [ or has junk after ] |
| any other backend | dns: unknown backend "<value>" (expected "system", "udp", "tls" or "https") |
A url without a path gets /dns-query. Each backend reads only its own keys: server_name belongs to tls, url to https, and the CA bytes are kept for those two only. https dials server; from the URL it takes the host, which becomes the TLS server name and the Host header, and the path. For the tls and https backends the CA bytes are parsed when the supervisor builds the resolver, so a file that holds no certificate passes lowering and fails --test with building dns failed: no certificate in CA PEM bundle. The URL rules are on DNS resolver, and how the resolvers are built on Name resolution and the DNS service.
Outbounds
Section titled “Outbounds”fn lower_outbound(cfg: &OutboundConfig) -> io::Result<OutboundSpec>;Every outbound lowers to OutboundSpec { tag, protocol, drain: None }. The context ctx is outbound <tag>.
protocol |
Steps, in order | OutboundProtocolSpec |
|---|---|---|
freedom, direct |
reject_outbound_stream (as freedom); address_family |
Freedom { address_family } |
blackhole, block |
reject_outbound_stream (as blackhole) |
Blackhole |
socks |
proxy_upstream; settings |
Socks { upstream, account } |
http |
proxy_upstream; settings |
Http { upstream, account } |
trojan |
proxy_upstream; settings |
Trojan { upstream, password } |
vless |
proxy_upstream; settings; parse_uuid |
Vless { upstream, id } |
vmess |
proxy_upstream; settings; parse_uuid; parse_security |
Vmess { upstream, id, security } |
shadowsocks |
proxy_upstream; settings; lower_shadowsocks_outbound |
Shadowsocks { .. } or Ss2022 { .. } |
hysteria2, hysteria, hy2 |
reject_outbound_stream (as hysteria2); settings; lower_hysteria2_outbound |
Hysteria2(Hysteria2OutboundSpec) |
wireguard |
reject_outbound_stream (as wireguard); settings; lower_wireguard |
Wireguard(WireguardSpec) |
| anything else | refused: outbound <tag>: unknown protocol "<value>" |
reject_outbound_stream runs reject_stream_settings under the protocol name the table gives: a [stream] network other than tcp, or a security other than none, is refused. freedom and blackhole never read server, port or settings, and blackhole does not read address_family either. wireguard takes its peer from settings.endpoint, not from server and port.
Proxy clients: proxy_upstream
Section titled “Proxy clients: proxy_upstream”fn proxy_upstream(cfg: &OutboundConfig, ctx: &str) -> io::Result<ProxyUpstream>;fn outbound_transport(cfg: &OutboundConfig, ctx: &str) -> io::Result<OutboundTransportSpec>;fn upstream_dest(cfg: &OutboundConfig, ctx: &str, network: DialNetwork) -> io::Result<Destination>;proxy_upstream runs outbound_transport first and upstream_dest(.., DialNetwork::Tcp) second, so an outbound with no server whose stream needs a name reports the stream message (outbound o: tls stream needs tls.server_name or server) rather than missing server. upstream_dest fails with <ctx>: missing server or <ctx>: missing port, and turns server into a Destination with parse_host.
outbound_transport parses address_family, calls resolve_stream, and fills every gap, because an outbound knows whom it dials. When no name is there, the config is refused rather than a name guessed: a certificate verified against the wrong name is the failure the TLS layer exists to avoid.
| Gap | Fallback chain | Refused with |
|---|---|---|
WebSocket Host |
ws.host → tls.server_name → server |
<ctx>: ws stream needs ws.host or server |
gRPC :authority |
grpc.authority → tls.server_name → server |
<ctx>: grpc stream needs grpc.authority or server |
| TLS server name, when the shape layers TLS | tls.server_name → server |
<ctx>: <network> stream needs tls.server_name or server, where <network> is tls, ws+tls or grpc+tls |
The host and authority chains apply whether or not the shape layers TLS. When the shape layers TLS, ClientTlsSpec { server_name, verify } is built with verify_mode(ctx, "tls.", ..). The result is OutboundTransportSpec { shape, tls, address_family }, with Some in every host and authority: the supervisor checks that an outbound transport has no gaps, so this function is the one place they are filled. a_server_config_lowers_to_its_spec pins a WebSocket outbound whose host and SNI both come from tls.server_name.
proxy_account turns ProxyOutboundSettings into Option<Account>: None when user is absent, otherwise Account { user, pass } with pass defaulting to an empty string.
parse_security lower-cases VMess security: absent, auto and aes-128-gcm give Security::Aes128Gcm, chacha20-poly1305 gives Security::ChaCha20Poly1305, and anything else fails with <ctx>: unknown vmess security "<lower-cased value>".
Shadowsocks
Section titled “Shadowsocks”lower_shadowsocks_outbound picks the family by the prefix 2022-:
- Legacy AEAD.
SsMethod::from_name, which lower-cases the name and acceptsaes-128-gcm(oraead_aes_128_gcm),aes-256-gcm(oraead_aes_256_gcm),chacha20-poly1305(orchacha20-ietf-poly1305,aead_chacha20_poly1305) andxchacha20-poly1305(orxchacha20-ietf-poly1305). Anything else:<ctx>: unknown shadowsocks method "<value>". The password is kept as aSecret<String>. - Shadowsocks 2022.
Ss2022Method::from_name, exact names only:2022-blake3-aes-128-gcm(16-byte key),2022-blake3-aes-256-gcmand2022-blake3-chacha20-poly1305(32-byte keys). Anything else:<ctx>: unknown shadowsocks-2022 method "<value>". Thepasswordis aniPSK:…:uPSKchain: it is split on:, and each part goes throughdecode_psk(standard base64, trimmed) andnormalise_psk. The last key becomespskand the rest becomeidentity_psks, in the order written.
normalise_psk (protocols/src/ss_2022/users.rs) keeps a key of the method’s length, folds a longer one to that length (fold_key: SHA-256 of the key, truncated), and refuses a shorter one with shadowsocks-2022: PSK too short (<n> < <len>). A bad base64 part fails with decode PSK: <error>. Both reach the operator through within, for example outbound o: decode PSK: Invalid symbol 33, offset 3.. An empty password splits into one empty part, so it fails as outbound o: shadowsocks-2022: PSK too short (0 < 16); the shadowsocks-2022 password is empty branch after the loop cannot be reached from a config.
Hysteria 2
Section titled “Hysteria 2”lower_hysteria2_outbound runs, in order: upstream_dest(.., DialNetwork::Udp), the outbound’s address_family, verify_mode(ctx, "", ..), lower_obfs, and the server name.
Hysteria2OutboundSpec field |
From |
|---|---|
server |
server and port, as a UDP destination |
server_name |
settings.server_name, else server. The fallback’s own <ctx>: missing server cannot be reached, since upstream_dest has already required server. |
password |
settings.password, as written |
verify |
verify_mode with an empty prefix: outbound o: allow_insecure and ca_file cannot both be set, outbound o: cannot read ca_file "<path>": <os error> |
obfs |
lower_obfs |
max_concurrent_streams |
settings.max_concurrent_streams, else DEFAULT_MAX_CONCURRENT_STREAMS = 102,400 |
address_family |
the outbound’s address_family |
An empty password and max_concurrent_streams = 0 lower as written; the supervisor refuses them as outbound o@v0: hysteria2 password must not be empty and outbound o@v0: max_concurrent_streams must be at least 1.
WireGuard
Section titled “WireGuard”lower_wireguard parses, in order:
private_key,peer_public_keyandpreshared_keywithwireguard::parse_key, which trims the text and accepts 32 bytes in standard base64 or in hex.lower_wireguardreplacesparse_key’s own error (invalid WireGuard key: expected base64 or hex encoding of 32 bytes) with one that names the key:<ctx>: invalid wireguard private_key(likewisepeer_public_key,preshared_key).endpoint, split at its last:(<ctx>: wireguard endpoint must be host:port), the port parsed as au16(<ctx>: invalid wireguard endpoint port), the host throughparse_host. The endpoint is a UDPDestination, a proxy server like any other, resolved with the supervisor’s server resolver.endpoint_address_family(<ctx>: invalid endpoint_address_family "<value>"): which family of a named endpoint’s addresses is dialed.- The outbound’s own
address_family: the family used inside the tunnel.
mtu defaults to wireguard::DEFAULT_MTU = 1420, the value Xray-core uses. address, keepalive and reserved are copied. Whether address holds an address of the family address_family asks for is the supervisor’s rule: outbound o@v0: wireguard address_family ipv6_only needs an IPv6 address.
Balancers
Section titled “Balancers”fn lower_balancer(cfg: &BalancerConfig) -> io::Result<BalancerSpec>;BalancerSpec field |
From |
|---|---|
tag |
tag |
members |
outbounds, copied without any check |
strategy |
Strategy::parse(strategy) when set, else Strategy::Failover. The names are case-sensitive: failover or round_robin. An unknown one fails through within as balancer b: unknown balancer strategy "random" (expected "failover" or "round_robin"). |
probe_interval |
probe_interval in whole seconds, else DEFAULT_PROBE_INTERVAL = 30 s |
probe_timeout |
probe_timeout in whole seconds, else DEFAULT_PROBE_TIMEOUT = 5 s |
The defaults live in supervisor/src/topology/balancer.rs. Unknown members, an empty member list, a tag that collides with an outbound, and a member that no TCP health probe can reach are the supervisor’s to refuse; see Outbounds and balancers.
The route table
Section titled “The route table”fn lower_route(cfg: &RouteConfig, first_outbound: CompactString) -> io::Result<RouteSpec>;Each [[route.rule]] becomes a RouteRuleSpec { matchers, outbound }. The matchers are pushed in a fixed order of kinds, whatever order the keys appear in:
| Order | Key | Matcher | Checked here |
|---|---|---|---|
| 1 | domain_suffix |
RouteMatch::DomainSuffix, one per value |
lower-cased once with to_ascii_lowercase |
| 2 | domain_keyword |
RouteMatch::DomainKeyword |
lower-cased |
| 3 | domain_full |
RouteMatch::DomainFull |
lower-cased |
| 4 | domain_regex |
one RouteMatch::DomainRegex(DomainRegexSet) for all of the rule’s patterns |
routing::parse_domain_regexes: invalid domain regex "<pattern>": <regex error>, naming the first pattern that does not compile; domain regex set of <n> patterns: <error> if each compiles alone but the set does not |
| 5 | cidr |
RouteMatch::Cidr |
parse_cidr, as an IpCidr: invalid cidr "<value>": <parser error> |
| 6 | source_cidr |
RouteMatch::SourceCidr |
as cidr |
| 7 | inbound_tag |
RouteMatch::InboundTag |
copied |
| 8 | network |
RouteMatch::Network |
parse_network, exactly tcp or udp: invalid rule network "<value>" (expected "tcp" or "udp") |
| 9 | port |
RouteMatch::PortRange(lo, hi) |
routing::parse_port_match: a port or lo-hi, each side trimmed; invalid port spec: "<value>", or invalid port spec: "<value>" has a lower bound above its upper bound |
| 10 | geosite |
RouteMatch::GeoSite |
copied |
| 11 | geoip |
RouteMatch::GeoIp |
copied |
These messages carry no prefix: they name the value, not the rule. Domains are lower-cased here once rather than on every match. Regex patterns are compiled here into one set per rule, so an invalid pattern fails the config rather than becoming a rule that never matches.
RouteSpec.default is [route].default, else the first outbound’s tag. RouteSpec carries the geoip and geosite paths; the supervisor loads the sets the rules use when it compiles the routes (building route failed: a geosite matcher is used but no geosite file is configured). How the table matches is on Route model, and how it is compiled on Routing plane.
Inbounds
Section titled “Inbounds”fn lower_inbound(cfg: &InboundConfig) -> io::Result<(InboundSpec, Option<UserSet<UserKey>>)>;Every inbound lowers to InboundSpec { tag, bind, sniff: cfg.sniffing, protocol, users, user_removal: None }, where users is the tag of the returned user set (the inbound’s own tag), or None when there is no set.
flowchart TB
p{"protocol"}
p -->|"tun"| tun["lower_tun"]
p -->|"socks, shadowsocks, hysteria2"| rej["reject_inbound_stream"]
rej --> listen["resolve_listen"]
p -->|"http, trojan, vless, vmess"| listen
listen -->|"socks, shadowsocks, hysteria2"| own["settings and protocol lowering, table below"]
listen -->|"http, trojan, vless, vmess"| users["settings, then users"]
users --> unix{"Unix listen?"}
unix -->|"yes"| plain["reject_stream_settings; shape Tcp, no TLS"]
unix -->|"no"| rs["resolve_stream"]
rs --> tls["TLS shape: read tls.cert_file and tls.key_file"]
p -->|"wireguard, anything else"| no["refused"]
protocol |
Steps, in order | BindSpec |
InboundProtocolSpec |
User set |
|---|---|---|---|---|
tun |
lower_tun |
Tun(TunSource::Create(DeviceSpec)) |
Tun(TunSpec) |
none |
socks |
reject_inbound_stream; resolve_listen; settings; auth |
Tcp or Unix |
Socks { udp, udp_bind } |
auth = "password": account_set over the accounts, keyed by user. auth = "none": none. Anything else: <ctx>: unknown socks auth "<value>". |
http |
resolve_listen; settings; accounts; inbound_transport |
Tcp or Unix |
Http { allow_transparent, transport } |
account_set over the accounts, keyed by user, when there are any; none otherwise (open mode) |
trojan |
resolve_listen; settings; users; inbound_transport |
Tcp or Unix |
Trojan { transport } |
always, keyed by email, credential password |
vless, vmess |
resolve_listen; settings; users (email, then parse_uuid); inbound_transport |
Tcp or Unix |
Vless { transport } or Vmess { transport } |
always, keyed by email, credential uuid |
shadowsocks |
reject_inbound_stream; resolve_listen; settings; method and key; users |
Tcp or Unix |
Ss2022 { method, psk } or Shadowsocks { method, password } |
the users, keyed by email, when there are any; none otherwise (shared mode) |
hysteria2, hysteria, hy2 |
reject_inbound_stream; resolve_listen, which must give an IP; settings; lower_hysteria2_inbound |
Udp { host, port } |
Hysteria2(Hysteria2InboundSpec) |
the users, keyed by email or else user, when there are any |
wireguard |
refused: <ctx>: wireguard cannot be used as an inbound (no server implementation) |
|||
| anything else | refused: <ctx>: unknown protocol "<value>" |
The protocol is matched before listen and port are read, so an inbound with an unknown protocol and no port reports the unknown protocol. Trojan, VLESS and VMess have no open or shared mode, so they always name a set, even an empty one. Whether a protocol that needs a set has one, and whether the credentials in it collide, is the supervisor’s rule.
resolve_listen and BindSpec
Section titled “resolve_listen and BindSpec”enum Listen { Ip { host: String, port: u16 }, Unix(PathBuf),}
fn resolve_listen(cfg: &InboundConfig) -> io::Result<Listen>;fn tcp_bind(listen: Listen) -> BindSpec;listen |
port |
Result | Refused with |
|---|---|---|---|
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 |
Only a value starting with / is a Unix path; ./x.sock is a host. The host is kept as text and not resolved or checked: an address the host does not own, a port in use or a privileged port all lower and pass --test, and fail only when the supervisor binds. tcp_bind maps Listen::Ip to BindSpec::Tcp and Listen::Unix to BindSpec::Unix. Hysteria 2 builds BindSpec::Udp from Listen::Ip and refuses Listen::Unix with inbound <tag>: hysteria2 listens on UDP and cannot use a unix socket. The display forms of BindSpec, used in logs and bind errors, are <host>:<port> for TCP, udp <host>:<port>, unix:<path>, tun <name> (or tun auto) and tun fd <n>; see Serving.
inbound_transport
Section titled “inbound_transport”fn inbound_transport(cfg: &InboundConfig, listen: &Listen, proto: &str) -> io::Result<InboundTransportSpec>;- Unix listener. A Unix socket is local, and TLS, WebSocket or gRPC on it “would only disguise it from itself”. The function runs
reject_stream_settingswith the protocol name<proto> over a unix socket(for exampleinbound in: protocol vless over a unix socket does not support stream network "ws") and returnsStreamShape::Tcpwith no TLS. No certificate is read. - IP listener.
resolve_streamgives the shape. Whenshape.uses_tls(), bothtls.cert_fileandtls.key_filemust be set (<ctx>: tls stream needs tls.cert_file, thentls.key_file), and both files are read withread_file:inbound in: cannot read tls.cert_file "/etc/etemenanki/missing.pem": No such file or directory (os error 2). The result isServerTlsSpec { cert_pem, key_pem: Secret }.
The PEM bytes are not parsed here: the unit tests write CERT BYTES and KEY BYTES into the files, and lowering accepts them. The supervisor parses them when it builds the handler (building inbound in failed: no certificate in PEM bundle). A WebSocket inbound keeps host as written, since an inbound has nothing to borrow a name from, and a gRPC inbound’s authority has no inbound meaning.
Shadowsocks inbound
Section titled “Shadowsocks inbound”reject_inbound_stream(cfg, "shadowsocks") runs first, then resolve_listen and the settings.
methodstarting with2022-.Ss2022Method::from_name(<ctx>: unknown shadowsocks-2022 method "<value>"). The serverpasswordgoes throughdecode_pskandnormalise_psk, so it is sized to the method here (inbound in: decode PSK: …,inbound in: shadowsocks-2022: PSK too short (16 < 32)). Each user’s password is only decoded (InlineUsers::ss2022_psk, errors likeinbound in: users[0]: decode PSK: …) and stored asCredentials.ss2022_psk. It is sized by the inbound that admits the user, since the size comes from that inbound’s method; a short one fails--testfrom the supervisor asinbound in: user a@example.com: shadowsocks-2022: PSK too short (16 < 32).- Otherwise.
SsMethod::from_name(<ctx>: unknown shadowsocks method "<value>"), the server password kept as aSecret, and each user’spasswordasCredentials.password.
The users list may also be written clients, as Xray accepts. Without users, the server password alone is the shared mode and no set is returned.
Hysteria 2 inbound
Section titled “Hysteria 2 inbound”fn lower_hysteria2_inbound(cfg: &InboundConfig, ctx: &str, s: Hysteria2InboundSettings) -> io::Result<(InboundProtocolSpec, Option<UserSet<UserKey>>)>;| Step | Rule | Refused with |
|---|---|---|
| Certificate | cert_file and key_file both set, then both read. The certificate is what makes this a TLS server at all, so there is no default. |
<ctx>: hysteria2 needs both cert_file and key_file; <ctx>: cannot read cert_file "<path>": <os error> (likewise key_file) |
| Users | Only when users is not empty. Each user is keyed by email when it is set, by user otherwise, with Credentials.account = Account { user, pass }. The key keeps user as written; the server lower-cases user names and compares the one a client sends case-insensitively (protocols/src/hysteria/server/authenticator.rs). |
<ctx>: users[<i>] has no user, and the duplicate-name error |
| Masquerade | All three fields absent: None. Any one set: MasqueradeSpec with status 404, body "404 page not found\n" and content_type "text/plain; charset=utf-8" for the fields left out, the answer an unconfigured Go server gives |
the status is checked by the supervisor |
| Obfuscation | lower_obfs |
as in the table above |
| UDP | udp = true: udp_idle_timeout in seconds, 60 when absent (a literal in lower_hysteria2_inbound, not a named constant). udp = false: udp_idle_timeout must be absent, because a timeout for a relay that is off most likely means the operator meant to turn UDP on. |
<ctx>: udp_idle_timeout is set but udp is not enabled |
| Limits | max_connections, else DEFAULT_MAX_CONNECTIONS = 262,144; max_circuits, else DEFAULT_MAX_CIRCUITS = 4,194,304 |
checked by the supervisor |
password and users are both lowered as written: that exactly one of them is set is the supervisor’s rule (inbound in: a shared password and a user set cannot both be set; a credential would have two answers, inbound in: hysteria2 needs a shared password or a user set). So are the 2 to 600 second range of the idle timeout, the salamander key length, limits of at least 1, and the account rules (inbound <tag>: user <name>: a hysteria2 account needs a name without ':' and a password). The app always sets user_auth: Hysteria2UserAuth::Account, so users authenticate as user:pass. The server side of the protocol is on Hysteria 2 server.
TUN inbound
Section titled “TUN inbound”fn lower_tun(cfg: &InboundConfig) -> io::Result<(BindSpec, InboundProtocolSpec)>;A TUN inbound owns an interface, not a socket. lower_tun refuses a [stream] network other than tcp or a security other than none (reject_inbound_stream: protocol tun does not support stream network …), then listen or port (<ctx>: tun owns a network interface and has no listener; remove listen/port), then parses the settings:
| Setting | Lowered to | Default | Refused with |
|---|---|---|---|
name |
DeviceSpec.name |
None: the kernel picks one |
|
mtu |
DeviceSpec.mtu |
tun::DEFAULT_MTU = 1500 |
the floor, MIN_TUN_MTU = 1280, is the supervisor’s: inbound t: tun mtu must be at least 1280 |
address |
DeviceSpec.addresses, each cidr::IpInet as (address, prefix length); host bits allowed |
none | <ctx>: bad tun address "<value>": <error> |
routes |
DeviceSpec.routes, each cidr::IpCidr as (network, prefix length); host bits refused |
none | <ctx>: bad tun route "<value>": <error>, for example host part of address was not zero |
udp |
TunSpec.udp |
true |
|
udp_idle_timeout |
TunSpec.udp_idle_timeout, seconds |
DEFAULT_UDP_IDLE_TIMEOUT = 60 s |
|
max_flows |
TunSpec.max_flows |
DEFAULT_MAX_FLOWS = 65,536 |
The result is BindSpec::Tun(TunSource::Create(device)). Whether routes is supported on the platform is the supervisor’s rule, as is the MTU floor: routes are installed only on Linux, and on every other platform validate refuses a non-empty routes through tun::check_platform (tun routes are installed only on Linux; add them with the OS route tool). --test does not create the device. The FFI replaces this bind with TunSource::Fd over the descriptor the phone supplies; see FFI and TUN.
Users: UserKey and InlineUsers
Section titled “Users: UserKey and InlineUsers”UserKey is UserName, from supervisor/src/entity/user.rs:
pub enum UserName { Email(CompactString), Username(CompactString),}The app keys each user by the name the config already gives it:
| Protocol | Key | Credential in Credentials |
|---|---|---|
| Trojan | Email(email) |
password |
| Shadowsocks (legacy) | Email(email) |
password |
| Shadowsocks 2022 | Email(email) |
ss2022_psk, decoded |
| VLESS, VMess | Email(email) |
uuid |
| SOCKS, HTTP | Username(user) |
account |
| Hysteria 2 | Email(email) when set, else Username(user) |
account |
The type’s comment gives the reasons. Being written in the config, the key is the same on every reload, so an unchanged user stays the same entry of its set, and a user whose password changed is the same user with new credentials rather than one removed and another added. Being a name and never a credential, it is what logs and usage reports can carry.
InlineUsers builds one set per inbound:
struct InlineUsers { ctx: String, // "inbound <tag>" list: &'static str, // "users" or "accounts" set: UserSet<UserKey>, // tag = the inbound's tag listed_at: HashMap<UserKey, usize>, // where each key was first listed}| Method | Does | Refused with |
|---|---|---|
new(cfg, list) |
An empty set tagged with the inbound’s tag | |
entry(at) |
The context for one entry: inbound <tag>: <list>[<at>], so users[<at>] or accounts[<at>] |
|
email(at, email) |
UserName::Email |
inbound <tag>: users[<i>] has no email; a user of this inbound is named by its email |
username(at, user) |
UserName::Username |
inbound <tag>: accounts[<i>] has no user (or users[<i>] for Hysteria 2) |
add(at, key, credentials) |
Inserts UserSpec { credentials, speed_limit: None } and remembers at |
inbound <tag>: <list>[<first>] and <list>[<at>] are both named "<name>" |
uuid(at, id) |
Credentials { uuid, .. } |
inbound <tag>: users[<i>]: invalid uuid "<id>": <error> |
ss2022_psk(at, password) |
Credentials { ss2022_psk, .. }, decoded, not sized |
inbound <tag>: users[<i>]: decode PSK: <error> |
into_set() |
The finished UserSet |
The set is a map, so a second user under a taken key would silently replace the first; add refuses it and names both entries, never a credential. Two different names presenting one credential is a question of what the inbound admits, and is the supervisor’s (inbound in: users a@example.com and b@example.com present the same credential). The per-user entry errors are checked in list order, and for VLESS and VMess the email is checked before the UUID.
with_password(password) and with_account(user, pass) build the other two credential kinds; the password in each is wrapped in Secret.
SOCKS and HTTP accounts go through account_set(cfg, accounts): an InlineUsers listed as accounts, each entry keyed by username(at, &a.user) and added with with_account(&a.user, &a.pass).
Shared stream rules: app/src/transport.rs
Section titled “Shared stream rules: app/src/transport.rs”Lowering an inbound’s transport and an outbound’s are near-duplicates, and every rule written into only one of them would be missing from the other. So the three functions that read a [.stream] block live in one module, and both sides call them. The shape they produce, StreamShape, is the supervisor’s (supervisor/src/topology/spec_plan/transport.rs), with uses_tls().
pub fn tls_layer(network: &str, security: Option<&str>, ctx: &str) -> io::Result<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 names the object (inbound <tag> or outbound <tag>) and prefixes every message. Every error is InvalidInput.
tls_layer
Section titled “tls_layer”tls_layer decides whether TLS goes under the network. It validates security (trimmed) against the network instead of comparing it with "tls". A plaintext branch taken because a string did not match would build a listener or dialer that contradicts the operator, and the only symptom would be a handshake failing after the proxy credential had gone out on the wire: Trojan writes hex(SHA224(password)), VLESS writes the raw UUID.
network |
security absent, "" or none |
security = "tls" |
any other security |
|---|---|---|---|
tcp |
plaintext | refused: <ctx>: 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") |
refused: <ctx>: unknown stream security "<value>" (expected "tls" or "none") |
tls |
TLS | TLS; the redundant value is accepted, since a config carried over from Xray habitually writes both | refused, same message |
ws, grpc |
plaintext | TLS | refused, same message |
| anything else | Ok(false), left to the caller |
Ok(false) |
Ok(false) |
The tcp + tls pair is Xray’s spelling of TLS over TCP; it used to build a plaintext transport and never read the certificate. Security values are case-sensitive: TLS is refused as unknown.
resolve_stream
Section titled “resolve_stream”resolve_stream takes network (default tcp), calls tls_layer, and builds the shape:
network |
StreamShape |
Refused with |
|---|---|---|
tcp |
Tcp |
|
tls |
Tls |
|
ws |
Ws { path: ws.path or "/", host: ws.host, tls } |
|
grpc |
Grpc { service: grpc.service_name, authority: grpc.authority, tls } |
<ctx>: grpc stream needs grpc.service_name |
| anything else | <ctx>: unknown stream network "<value>" |
An unknown network is reported here, with this message, which is why tls_layer leaves it alone even when security is also wrong. tls_layer and resolve_stream trim security but not network, and the default applies only to an absent key: network = " ws" fails as outbound o: unknown stream network " ws", and so does network = "".
reject_stream_settings
Section titled “reject_stream_settings”For protocols that never use a transport. The configured network and security used to be discarded in silence, so an operator who believed an inbound was disguised as WebSocket got a bare port instead. The function accepts a network of absent, "" or tcp and a security of absent, "" or none (both trimmed), and refuses the rest:
<ctx>: protocol <proto> does not support stream network "<value>"<ctx>: protocol <proto> does not support stream security "<value>"
It checks only those two keys. Its callers are reject_inbound_stream for the socks, shadowsocks, hysteria2 and tun inbounds, inbound_transport for every Unix listener (with proto = <protocol> over a unix socket), and reject_outbound_stream for the freedom, blackhole, hysteria2 and wireguard outbounds. The transports themselves are on TCP and TLS transports and WebSocket and gRPC transports.
What --test does
Section titled “What --test does”/// A minimal Xray-core-style proxy runtime.#[derive(Parser)]#[command(version, about)]struct Args { /// Path to the TOML configuration file. #[arg(short, long, default_value = "config.toml")] config: PathBuf, /// Validate the configuration and exit without binding any listener. #[arg(long)] test: bool,}#[command(version, about)] gives the binary --version (etemenanki-app 2.1.1) and a --help headed by the doc comment. main parses the arguments, initialises tracing, and with --test calls instance::check(&args.config). init_tracing uses RUST_LOG when it is set and parses; only when it is unset or invalid does it read the file with config::load for [log].level, and a config that cannot be read or parsed then gives info. See Running and reloading. The check:
checkreads the file (std::fs::read) and callscheck_bytes.check_bytesrunsconfig::sources_from, which parses the config and reads the subscribe file it names, thenconfig::effectiveandinstance::build, which lowers the spec and builds the[api]options.- It hands the spec to
etemenanki_supervisor::check, which validates it and runs the build half of an apply without binding: it creates a throwaway actor (Actor::new(SocketOptions::default())), plans against its empty running state, and callspreparewithbind = false.
On success main prints Configuration OK. with println! and exits with ExitCode::SUCCESS. On failure it logs configuration invalid: <error> at ERROR through tracing and exits with ExitCode::FAILURE (status 1). Both go to stdout: the tracing_subscriber::fmt() subscriber writes there, with a timestamp and the etemenanki_app target before the message, and with ANSI colour codes unless NO_COLOR is set, whether or not stdout is a terminal. Because the failure line goes through tracing, a RUST_LOG that filters out ERROR (empty, or off) leaves only the exit status.
$ etemenanki-app --test -c config.tomlConfiguration OK.$ etemenanki-app --test -c config.toml2026-01-01T00:00:00.000000Z ERROR etemenanki_app: configuration invalid: inbound in: users[0] has no email; a user of this inbound is named by its email$ etemenanki-app --test -c config.toml2026-01-01T00:00:00.000000Z ERROR etemenanki_app: configuration invalid: route references unknown outbound missingWhat --test reads and builds, and what it leaves to a start:
Covered by --test |
Not covered |
|---|---|
| The config file and the subscribe file it names | Binding TCP, UDP and Unix listeners: a port in use, an address the host does not own, a privileged port, a non-socket file at a Unix path |
Every file lowering reads: an inbound’s tls.cert_file and tls.key_file when its shape layers TLS, the Hysteria 2 inbound’s cert_file and key_file, the ca_file of a dialer that layers TLS and of a Hysteria 2 outbound, and [dns].ca_file unless a subscribe file’s [dns] replaces it |
Creating a TUN device and installing its routes |
Every lowering rule on this page, and every rule of validate |
Opening the Hysteria 2 endpoint on its socket, and preparing a TUN device |
Parsing the certificates, keys and CA bundles in the spec (the DNS CA for the tls and https backends); the Hysteria 2 QUIC server config and masquerade |
Anything on the network: resolving upstreams, reaching them, probing balancer members |
| Resolvers, outbounds, balancers (without probe tasks), compiled routes with the geodata files the rules use, handlers and user tables | Starting the REST API listener. [api] is parsed and checked, not bound. |
The [api] options |
A change a running supervisor would refuse as disruptive: check plans from nothing |
So a start and --test agree on every rule and every constructor except the bind-time ones. The dry run itself, step by step, is on Validation.
Error texts
Section titled “Error texts”Where a message comes from
Section titled “Where a message comes from”Captured with the pinned binary through --test (each line follows configuration invalid: ):
| Prefix | Raised by | Example |
|---|---|---|
| none | check or read_sources, reading the config file |
No such file or directory (os error 2): the config’s own path is not named |
| none | parse_bytes |
TOML parse error at line 1, column 1 (first line), invalid utf-8 sequence of 1 bytes from index 0 |
| none | sources_from |
[subscribe] needs a path naming the subscribe file |
subscribe file <path>: |
sources_from, reading the subscribe file |
subscribe file /etc/etemenanki/sub.toml: No such file or directory (os error 2) |
subscribe: |
subscribe::parse, subscribe::apply |
subscribe: TOML parse error at line 1, column 1 (first line) |
| none | lower |
config defines no outbounds |
dns: |
lower_single_dns, Backend::from_spec |
dns: cannot read ca_file "/etc/etemenanki/dns-ca.pem": No such file or directory (os error 2), dns: the udp backend needs a server address |
outbound <tag>: |
lower_outbound and its helpers |
outbound o: ws+tls stream needs tls.server_name or server, outbound o: invalid wireguard private_key |
outbound <tag>: invalid settings: |
outbound_settings |
outbound o: invalid settings: unknown field `pasword`, expected `password` |
balancer <tag>: |
lower_balancer, through within |
balancer b: unknown balancer strategy "random" (expected "failover" or "round_robin") |
| none | lower_route |
invalid cidr "192.0.2.0/33": invalid length for network: Network length 33 is too long for Ipv4 (maximum: 32), invalid port spec: "90-80" has a lower bound above its upper bound |
inbound <tag>: |
lower_inbound and its helpers |
inbound in: port is required, inbound in: tls stream needs tls.key_file |
inbound <tag>: users[<i>] or accounts[<i>] |
InlineUsers |
inbound in: users[0]: invalid uuid "nope": invalid character: found `n` at 0, inbound in: users[0] and users[1] are both named "a@example.com" |
inbound <tag>: invalid settings: |
inbound_settings |
inbound in: invalid settings: unknown field `acounts`, expected one of `auth`, `accounts`, `udp`, `udp_bind` |
[api] |
api::options |
[api] listen 0.0.0.0:9090: an API reachable from other hosts needs a secret; listen on a loopback address or a unix socket, or set one; also [api] listen "<value>": expected ip:port, or a path (containing a /) for a unix socket and [api] secret is empty |
The supervisor’s messages follow once lowering has succeeded. Their full catalogue is on Validation; these are the shapes, captured the same way:
| Shape | Example |
|---|---|
duplicate <kind> tag <tag> |
duplicate outbound tag direct, duplicate balancer tag direct |
<from> references unknown <kind> <tag> |
route references unknown outbound nowhere, balancer b references unknown outbound missing |
balancer <tag> … |
balancer b has no members, balancer b: outbound h has no upstream a TCP health probe can reach |
inbound <tag>: <reason> |
inbound t: tun mtu must be at least 1280, inbound in: socks over a unix socket has no local IP for UDP associate; set udp_bind or turn udp off |
| an outbound rule | outbound o@v0: wireguard address_family ipv6_only needs an IPv6 address |
building <resource> failed: <error> |
building outbound o@v1 failed: no certificate in CA PEM bundle, building dns failed: no certificate in CA PEM bundle |
Lowering and the supervisor both prefix an inbound’s messages with inbound <tag>: . The lowering texts are the ones listed on this page and raised in app/src/lower.rs or app/src/transport.rs, such as inbound in: port is required; the supervisor’s are raised in supervisor/src/build/validate.rs and listed on Validation, such as inbound t: tun mtu must be at least 1280. Searching the text in those files tells which side refused.
A regex error is the one lowering message that continues on further lines: invalid domain regex "(unclosed": regex parse error: is followed by the regex crate’s own lines. An invalid settings message ends with a newline, which the log shows as an empty line after it.
Around the message
Section titled “Around the message”Each caller adds its own context when it logs:
| Caller | Log line |
|---|---|
--test |
configuration invalid: <error> |
| First start | failed to start: <error>, exit status 1 |
| Reload | the lines a failed reload logs are on Running and reloading |
| REST API edit | none; the error becomes the HTTP answer. See REST API. |
Error kinds
Section titled “Error kinds”| Kind | Raised for |
|---|---|
InvalidData |
Bytes that are not UTF-8, and TOML errors, from parse_bytes |
InvalidInput |
Every refusal made by lower and its route parsers (parse_port_match, parse_domain_regexes), transport.rs, sources_from (missing path), sources_given, subscribe, api::options, Backend::from_spec, Strategy::parse, and the settings deserialization |
| the OS error’s own kind | A file that cannot be read: read_file and the subscribe file keep e.kind() while adding context; within keeps the kind of what it wraps |
The kind decides how the REST API classifies a lowering failure: InvalidData and InvalidInput are the config’s fault, anything else is not. impl From<LoadError> for ControlError makes the mapping; see REST API.
Invariants
Section titled “Invariants”| Invariant | Mechanism | Pinned by |
|---|---|---|
| A misspelled key is an error, never a default | #[serde(deny_unknown_fields)] on every section struct, and on the settings struct of every protocol that reads settings |
a_mistyped_key_is_rejected_rather_than_ignored (app/tests/unit/config.rs: an inbound key, a settings key, a table name, a TLS key); syntax_errors_are_refused (invalid settings, allow_insecrue) |
An inbound without listen binds loopback |
resolve_listen defaults the host to 127.0.0.1 |
a_server_config_lowers_to_its_spec (the vless-in inbound) |
An unknown or contradictory security never builds plaintext |
tls_layer validates against the network |
every test in app/tests/unit/transport.rs from tcp_without_security_is_plaintext to an_unknown_network_is_left_to_the_caller |
A network or security a protocol cannot honour is refused, not discarded |
reject_stream_settings |
a_default_or_plain_tcp_stream_is_accepted, a_transport_the_protocol_cannot_honour_is_rejected, security_on_a_protocol_without_a_transport_is_rejected; syntax_errors_are_refused (Hysteria 2 on both sides) |
| Inbounds and outbounds apply the same stream rules | both call transport::resolve_stream |
the transport.rs tests exercise the shared functions |
| A Unix listener carries no transport, no port and no certificate | resolve_listen, inbound_transport |
a_unix_listen_lowers_to_a_plain_stream; syntax_errors_are_refused (has no port, abstract unix sockets) |
| A spec’s outbound transport has no gaps | outbound_transport fills host, authority and SNI or refuses |
a_server_config_lowers_to_its_spec (host and SNI from tls.server_name) |
| A user is keyed by a name the config writes, never by a credential | InlineUsers::email and InlineUsers::username |
users_are_keyed_by_the_name_the_config_gives_them, a_user_without_the_name_it_is_keyed_by_is_refused |
| A changed password keeps the user’s key | the key is the name | a_user_keeps_its_key_when_its_password_changes |
| Two users under one name are refused; the error names both entries and no credential | InlineUsers::add |
two_users_with_one_name_are_refused |
The passwords and private keys a config writes are Secrets in the spec |
Secret::new wraps every password, Shadowsocks 2022 PSK, TLS private key, WireGuard private or preshared key, and salamander key the app lowers |
a_spec_never_prints_a_credential |
| The parsed config is never formatted whole | no struct of the schema derives Debug |
none; a {:?} of a Config does not compile |
| Lowering is a pure function of the config and the files it reads | no clock, no network, no global state | lowering_twice_gives_the_same_spec |
| Lowering is faithful: the supervisor’s rules are left to it | no tag, reference, range or limit checks in lower |
semantic_problems_are_left_to_the_supervisor |
An unknown obfs, or obfs_password without obfs, never means no obfuscation |
lower_obfs |
syntax_errors_are_refused (obfs is not, unknown obfs "salamandr" (expected "salamander")) |
| A UDP idle timeout with UDP off is refused | lower_hysteria2_inbound |
syntax_errors_are_refused (udp is not enabled) |
| Hysteria 2 answers to its aliases on both sides | one match arm for hysteria2, hysteria and hy2 |
hysteria2_answers_to_its_aliases |
| A TUN inbound lowers to a device the inbound creates, with the schema’s defaults | lower_tun |
a_tun_inbound_lowers_to_its_device; syntax_errors_are_refused (remove listen/port, bad tun route) |
| A DNS backend that needs a server has one | Backend::from_spec |
a_backend_without_its_server_is_rejected (app/tests/integration/e2e_dns.rs, through --test) |
A tls DNS backend has a name to verify against |
Backend::from_spec |
a_dns_backend_needs_what_it_names |
A subscribe [dns] becomes a split spec |
lower_dns |
a_subscribe_file_splits_dns |
| A config read from disk names its subscribe file | sources_from |
a_subscribe_section_read_from_disk_needs_a_path |
| Given sources agree on whether there is a subscribe file | sources_given |
given_sources_must_agree_on_a_subscribe_file |
direct and blackhole exist when a route names them; a config’s own outbound under that tag wins |
ensure_builtins(cfg, false) |
direct_and_blackhole_exist_when_a_route_names_them, a_plain_configs_own_outbound_keeps_its_builtin_tag |
--test refuses what a start would, short of binds |
the same effective and build, then supervisor::check |
a_member_with_no_upstream_is_refused (app/tests/integration/e2e_balancer.rs, through --test); every_lowered_node_builds_and_the_dns_setup_with_it (app/tests/unit/subscribe.rs, lower then check) |
Failure paths and cancellation
Section titled “Failure paths and cancellation”- First error wins. Every function returns
io::Result, and each stage stops at the first error. The order that decides which error an operator sees: read the config, parse it, read the subscribe file, merge, thenlower(default tag, DNS, outbounds in file order, balancers, route rules in order, inbounds in file order; inside an inbound, the steps in the tables above), then[api], then the supervisor’svalidateand builders. - What a failed lowering leaves. Nothing.
lowerbuilds plain data and opens nothing, so a failed lowering drops what it built. On a reload that is why the running supervisor is untouched until a whole spec exists; see Running and reloading. - Blocking and cancellation.
lower,effectiveand the read functions are synchronous and contain no.await, so nothing can cancel them halfway. Their file reads are blockingstd::fs::readcalls on the calling thread;check_bytesandCore::reload_withrun them inside an async function. They spawn no task, open no socket and start no timer.etemenanki_supervisor::checkis async and runs on the caller’s task; whatever it builds is dropped when it returns. - The config read twice by
--test. WhenRUST_LOGis unset or invalid,--testreads the config file twice: once ininit_tracingfor[log].level, once incheck.
Limits
Section titled “Limits”Defaults applied while parsing and lowering:
| Setting | Default | Where |
|---|---|---|
Inbound listen |
127.0.0.1 |
resolve_listen |
Inbound sniffing |
true |
default_sniffing |
[stream].network |
tcp |
resolve_stream |
| WebSocket path | / |
resolve_stream |
SOCKS inbound auth, udp |
none, true |
SocksInboundSettings::default |
Hysteria 2 inbound udp |
false |
Hysteria2InboundSettings |
Hysteria 2 inbound udp_idle_timeout with UDP on |
60 s, a literal | lower_hysteria2_inbound |
Hysteria 2 inbound max_connections |
DEFAULT_MAX_CONNECTIONS = 262,144 |
protocols/src/hysteria/server/config.rs |
Hysteria 2 inbound max_circuits |
DEFAULT_MAX_CIRCUITS = 4,194,304 |
protocols/src/hysteria/server/config.rs |
| Hysteria 2 masquerade, any field set | status 404, body 404 page not found and a newline, text/plain; charset=utf-8 |
lower_hysteria2_inbound |
Hysteria 2 outbound max_concurrent_streams |
DEFAULT_MAX_CONCURRENT_STREAMS = 102,400 |
protocols/src/hysteria/config.rs |
Hysteria 2 outbound server_name |
server |
lower_hysteria2_outbound |
TUN mtu |
tun::DEFAULT_MTU = 1500 |
protocols/src/tun/config.rs |
TUN udp, udp_idle_timeout, max_flows |
true, 60 s, 65,536 |
protocols/src/tun/config.rs |
WireGuard mtu |
wireguard::DEFAULT_MTU = 1420 |
protocols/src/wireguard/config.rs |
Balancer strategy |
failover |
lower_balancer |
Balancer probe_interval, probe_timeout |
30 s, 5 s | supervisor/src/topology/balancer.rs |
| Route default | the first outbound after the merge | lower |
[dns].backend |
system |
Backend::from_spec |
| DNS-over-HTTPS path | /dns-query when the URL has none |
protocols/src/dns/mod.rs |
address_family, endpoint_address_family |
Auto |
parse_family |
VMess security |
aes-128-gcm |
parse_security |
[api].listen |
127.0.0.1:9090 |
webclient/src/listen.rs |
The ranges and floors that apply to these values (a Hysteria 2 UDP idle timeout within HY2_UDP_IDLE = 2 to 600 s, a TUN MTU of at least MIN_TUN_MTU = 1280, limits of at least 1, a salamander key of at least MIN_SALAMANDER_PSK = 4 bytes, all in supervisor/src/build/validate.rs) are enforced by the supervisor and listed on Validation. The limits of the whole system are collected on Limits, timeouts and memory.
The unit tests are #[path] modules of the library (app/src/config.rs → ../tests/unit/config.rs, and likewise for lower.rs, transport.rs, subscribe.rs and api.rs), so they run with:
cargo test -p etemenanki-app --libThe integration tests start the real binary (CARGO_BIN_EXE_etemenanki-app) and run with cargo test -p etemenanki-app --test integration, for example cargo test -p etemenanki-app --test integration e2e_dns.
| File | Test | Behaviour it pins |
|---|---|---|
app/tests/unit/config.rs |
parses_tls_ca_file |
[outbound.stream.tls] ca_file reaches the struct |
a_mistyped_key_is_rejected_rather_than_ignored |
lisen, acounts inside settings, [rout] and ca_fil are all refused |
|
route_rules_accept_every_matcher |
Every matcher key parses into its own rule field | |
direct_and_blackhole_exist_when_a_route_names_them |
Without a subscribe file, direct and blackhole are appended after the config’s outbounds |
|
a_plain_configs_own_outbound_keeps_its_builtin_tag |
A config’s own direct outbound, even a socks one, is kept and nothing is added |
|
a_subscribe_section_read_from_disk_needs_a_path |
sources_from refuses a [subscribe] without path as InvalidInput |
|
given_sources_must_agree_on_a_subscribe_file |
sources_given reads no path, and refuses a section without a file and a file without a section |
|
app/tests/unit/transport.rs |
tcp_without_security_is_plaintext, tcp_with_tls_is_rejected_and_names_the_fix, tls_network_carries_tls_with_or_without_a_redundant_security, ws_and_grpc_layer_tls_only_when_asked, an_unknown_security_is_rejected_on_every_network, security_is_trimmed_before_matching, an_unknown_network_is_left_to_the_caller |
The tls_layer matrix, row by row |
a_default_or_plain_tcp_stream_is_accepted, a_transport_the_protocol_cannot_honour_is_rejected, security_on_a_protocol_without_a_transport_is_rejected |
reject_stream_settings |
|
app/tests/unit/lower.rs |
a_server_config_lowers_to_its_spec |
One inbound of every user-admitting protocol, two outbounds (proxy and direct) and a balancer: binds, shapes, TLS bytes, user keys and credentials, SS2022 server key sized to 16 bytes while a user key stays 32, open HTTP, Hysteria 2 masquerade defaults, outbound gap filling, balancer strategy, lower-cased domains and matcher order, DnsSpec::Single(Backend::System), default policies |
lowering_twice_gives_the_same_spec |
Determinism | |
a_spec_never_prints_a_credential |
The spec’s Debug output contains none of six fixture secrets (three passwords, two account passwords and the salamander key), and the printed user keys contain no credential |
|
users_are_keyed_by_the_name_the_config_gives_them |
The key table above, including a Hysteria 2 user with an email |
|
a_user_keeps_its_key_when_its_password_changes |
Same keys, different UserSpec |
|
a_user_without_the_name_it_is_keyed_by_is_refused |
Missing or empty email and user for all eight cases, each message starting inbound in: |
|
two_users_with_one_name_are_refused |
Both positions named, the name quoted, no credential in the message | |
a_subscribe_file_splits_dns |
subscribe_dns becomes DnsSpec::Split |
|
a_dns_backend_needs_what_it_names |
tls without server_name is refused; udp with a server lowers |
|
syntax_errors_are_refused |
The app’s own refusals, 22 cases, and config defines no outbounds for an empty config |
|
semantic_problems_are_left_to_the_supervisor |
The split described under One owner per rule | |
a_unix_listen_lowers_to_a_plain_stream |
BindSpec::Unix with StreamShape::Tcp and no TLS |
|
a_tun_inbound_lowers_to_its_device |
TunSource::Create with addresses, routes and defaults |
|
hysteria2_answers_to_its_aliases |
hysteria2, hysteria, hy2 on both sides, with the default stream limit |
|
app/tests/unit/subscribe.rs |
every_lowered_node_builds_and_the_dns_setup_with_it |
The example subscribe file’s nodes and DNS setup, with its route groups removed because they name geodata the test does not have, merge, lower and pass etemenanki_supervisor::check |
app/tests/unit/api.rs |
api_options_come_from_the_api_section, an_api_open_to_other_hosts_needs_a_secret |
api::options: the default listen, Unix paths, the secret rule, an empty secret, unknown keys |
app/tests/integration/e2e_dns.rs |
a_backend_without_its_server_is_rejected |
--test exits non-zero for backend = "udp" without server |
app/tests/integration/e2e_balancer.rs |
a_member_with_no_upstream_is_refused |
--test exits non-zero for a balancer over freedom |
The unit tests write CERT BYTES and KEY BYTES as certificate files (tls_files), which is enough because lowering only reads them. Paths without a dedicated test: the Shadowsocks 2022 outbound key chain, the WireGuard key and endpoint messages, verify_mode reading a CA file, and the exact read_file message.
Every other integration test also starts the binary on a config it writes, so each one passes through this lowering on the way. e2e_unix (SOCKS and HTTP inbounds on a Unix socket), e2e_tun (a TUN inbound that creates its device; it needs CAP_NET_ADMIN and skips without it), e2e_hysteria_inbound (the Hysteria 2 inbound against the upstream client) and e2e_route_context (the inbound_tag, source_cidr and network matchers) exercise what those lowerings produce end to end; see Testing.
A new check first needs an owner. A rule about how typed pieces fit together belongs in the supervisor’s validate, with a test in supervisor/tests/unit/validate.rs. A rule about the TOML belongs in lower.rs or transport.rs, with a case in syntax_errors_are_refused that asserts on a distinctive part of the message.