Skip to content

etemenanki-app: from TOML to a spec

Source files: 41 · checked against Etemenanki 555b7df
  • Etemenanki/app/src/lib.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/api.rs
  • Etemenanki/app/src/subscribe.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/entity/user.rs
  • Etemenanki/supervisor/src/topology/spec_plan/mod.rs
  • Etemenanki/supervisor/src/topology/spec_plan/inbound.rs
  • Etemenanki/supervisor/src/topology/spec_plan/outbound.rs
  • Etemenanki/supervisor/src/topology/spec_plan/transport.rs
  • Etemenanki/supervisor/src/topology/spec_plan/route.rs
  • Etemenanki/supervisor/src/topology/spec_plan/dns.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/topology/balancer.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_legacy/aead.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/server/authenticator.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/webclient/src/listen.rs
  • Etemenanki/ffi/src/client.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/app/tests/unit/transport.rs
  • Etemenanki/app/tests/unit/subscribe.rs
  • Etemenanki/app/tests/unit/api.rs
  • Etemenanki/app/tests/integration/e2e_dns.rs
  • Etemenanki/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.

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 listen or failover for an absent strategy;
  • 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.

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.

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.

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.

app/src/config.rs
#[derive(Deserialize, Clone, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default, rename = "inbound")]
pub inbounds: Vec<InboundConfig>,
#[serde(default, rename = "outbound")]
pub outbounds: Vec<OutboundConfig>,
#[serde(default, rename = "balancer")]
pub balancers: Vec<BalancerConfig>,
#[serde(default)]
pub route: RouteConfig,
#[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
app/src/config.rs
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. level is handed to EnvFilter::new, so it takes a level name (error, warn, info, debug, trace) or any directive RUST_LOG accepts. A valid RUST_LOG takes 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.conf and search domains, so a DNS client as default would change what names resolve to. server is required by every backend but system, https included, because taking the address from the URL would mean resolving the resolver.
  • SubscribeConfig. path is optional to the schema. A config loaded from disk must set it (read_sources refuses 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, or the subscribe file when 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>. In routes, the key default picks the node for traffic that no group matches, and a value may name any outbound or balancer tag, or direct and blackhole, which are always available. The rest of what routes picks is on Subscribe files.
  • ApiConfig. listen defaults to DEFAULT_LISTEN = "127.0.0.1:9090" (webclient/src/listen.rs). api::options parses it with Listen::from_str: a value containing a / is a Unix socket path, relative ones such as ./api.sock included, and anything else must be an ip:port, so a host name such as localhost:9090 is refused. secret is required unless listen is loopback or a Unix socket. The REST API itself is on REST API.
app/src/config.rs
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.

app/src/config.rs
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>,
}

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.

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.

app/src/config.rs
#[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:

app/src/instance.rs
pub type Read = io::Result<(Sources, io::Result<Config>)>;
app/src/lower.rs
pub type UserKey = UserName;
pub fn lower(cfg: &Config) -> io::Result<Spec<UserKey>>;
app/src/instance.rs
pub struct Built {
pub spec: Spec<UserKey>,
pub api: Option<ApiOptions>,
}
pub fn build(cfg: &Config) -> io::Result<Built>; // lower, then api::options
pub 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)]
}
  • build is the app’s lowering: lower(cfg)?, then api::options(cfg)?. The spec is lowered first, so an inbound error is reported before an [api] error.
  • Lowering is how a Core turns a merged config into what it runs. The app passes Arc::new(build); the FFI passes a closure that calls lower, 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 returns api: None. Its rules are on FFI.
  • LoadError joins 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.

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"]

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.

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(parsed, sources) returns the parse error if there is one. Otherwise:

  • With subscribe bytes, it calls subscribe::parse (errors start subscribe: , for example subscribe: TOML parse error at line 1, column 1) and then subscribe::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], sets cfg.subscribe_dns. Every rule of the merge is on Subscribe files.
  • Without, it calls subscribe::ensure_builtins(&mut cfg, false). When [route].default or any rule names direct or blackhole, and no outbound or balancer carries that tag, it appends a freedom outbound tagged direct or a blackhole outbound tagged blackhole, 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.

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"]
  1. Default tag. The tag of the first outbound (after the merge) becomes the route’s default unless [route].default names another. With no outbounds at all, lower fails at once with config defines no outbounds.
  2. DNS, then outbounds in file order, then balancers, then the route table, then inbounds in file order.
  3. lower_inbound returns an InboundSpec and, for protocols that take users written inline, a UserSet tagged with the inbound’s own tag. The sets are collected into Spec.user_sets.
  4. Spec.policies is Policies::default(). Every InboundSpec.user_removal and every OutboundSpec.drain is None, and every UserSpec.speed_limit is None: 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.

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
app/src/lower.rs
fn lower_dns(cfg: &Config) -> io::Result<DnsSpec>;
fn lower_single_dns(dns: &DnsConfig) -> io::Result<Backend>;
  • With subscribe_dns, lower_dns builds DnsSpec::Split: each URL of pre_proxy and through_proxy through Backend::from_url, and use_hosts and resolve_ipv6 copied. The config’s [dns] is not read at all, not even its ca_file. The URL forms and the file’s connect_ipv6 are on Subscribe files.
  • Without, lower_single_dns reads ca_file when it is set, whatever the backend (dns: cannot read ca_file "<path>": <os error>), and calls Backend::from_spec. The result is DnsSpec::Single(backend). With no [dns], that is Single(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.

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

app/src/lower.rs
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>".

lower_shadowsocks_outbound picks the family by the prefix 2022-:

  • Legacy AEAD. SsMethod::from_name, which lower-cases the name and accepts aes-128-gcm (or aead_aes_128_gcm), aes-256-gcm (or aead_aes_256_gcm), chacha20-poly1305 (or chacha20-ietf-poly1305, aead_chacha20_poly1305) and xchacha20-poly1305 (or xchacha20-ietf-poly1305). Anything else: <ctx>: unknown shadowsocks method "<value>". The password is kept as a Secret<String>.
  • Shadowsocks 2022. Ss2022Method::from_name, exact names only: 2022-blake3-aes-128-gcm (16-byte key), 2022-blake3-aes-256-gcm and 2022-blake3-chacha20-poly1305 (32-byte keys). Anything else: <ctx>: unknown shadowsocks-2022 method "<value>". The password is an iPSK:…:uPSK chain: it is split on :, and each part goes through decode_psk (standard base64, trimmed) and normalise_psk. The last key becomes psk and the rest become identity_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.

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.

lower_wireguard parses, in order:

  1. private_key, peer_public_key and preshared_key with wireguard::parse_key, which trims the text and accepts 32 bytes in standard base64 or in hex. lower_wireguard replaces parse_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 (likewise peer_public_key, preshared_key).
  2. endpoint, split at its last : (<ctx>: wireguard endpoint must be host:port), the port parsed as a u16 (<ctx>: invalid wireguard endpoint port), the host through parse_host. The endpoint is a UDP Destination, a proxy server like any other, resolved with the supervisor’s server resolver.
  3. endpoint_address_family (<ctx>: invalid endpoint_address_family "<value>"): which family of a named endpoint’s addresses is dialed.
  4. 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.

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

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

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

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

app/src/lower.rs
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_settings with the protocol name <proto> over a unix socket (for example inbound in: protocol vless over a unix socket does not support stream network "ws") and returns StreamShape::Tcp with no TLS. No certificate is read.
  • IP listener. resolve_stream gives the shape. When shape.uses_tls(), both tls.cert_file and tls.key_file must be set (<ctx>: tls stream needs tls.cert_file, then tls.key_file), and both files are read with read_file: inbound in: cannot read tls.cert_file "/etc/etemenanki/missing.pem": No such file or directory (os error 2). The result is ServerTlsSpec { 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.

reject_inbound_stream(cfg, "shadowsocks") runs first, then resolve_listen and the settings.

  • method starting with 2022-. Ss2022Method::from_name (<ctx>: unknown shadowsocks-2022 method "<value>"). The server password goes through decode_psk and normalise_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 like inbound in: users[0]: decode PSK: …) and stored as Credentials.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 --test from the supervisor as inbound 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 a Secret, and each user’s password as Credentials.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.

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

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

UserKey is UserName, from supervisor/src/entity/user.rs:

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:

app/src/lower.rs
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).

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

app/src/transport.rs
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 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 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 = "".

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.

app/src/main.rs
/// 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:

  1. check reads the file (std::fs::read) and calls check_bytes.
  2. check_bytes runs config::sources_from, which parses the config and reads the subscribe file it names, then config::effective and instance::build, which lowers the spec and builds the [api] options.
  3. 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 calls prepare with bind = 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.toml
Configuration OK.

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

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.

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

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)
  • 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, then lower (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’s validate and builders.
  • What a failed lowering leaves. Nothing. lower builds 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, effective and the read functions are synchronous and contain no .await, so nothing can cancel them halfway. Their file reads are blocking std::fs::read calls on the calling thread; check_bytes and Core::reload_with run them inside an async function. They spawn no task, open no socket and start no timer. etemenanki_supervisor::check is async and runs on the caller’s task; whatever it builds is dropped when it returns.
  • The config read twice by --test. When RUST_LOG is unset or invalid, --test reads the config file twice: once in init_tracing for [log].level, once in check.

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:

Terminal window
cargo test -p etemenanki-app --lib

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