Skip to content

The spec: desired state

Source files: 47 · checked against Etemenanki 555b7df · katana v4.1.1
  • 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/spec_plan/plan.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/topology/balancer.rs
  • Etemenanki/supervisor/src/entity/user.rs
  • Etemenanki/supervisor/src/entity/id.rs
  • Etemenanki/supervisor/src/policy.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/users.rs
  • Etemenanki/supervisor/src/build/inbound.rs
  • Etemenanki/supervisor/src/build/outbound.rs
  • Etemenanki/supervisor/src/build/dns.rs
  • Etemenanki/supervisor/src/build/route.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/system/listener.rs
  • Etemenanki/concepts/src/net.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/protocols/src/helpers/address_family.rs
  • Etemenanki/protocols/src/transports/tls/config.rs
  • Etemenanki/protocols/src/transports/ws/endpoint.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/hysteria/server/masquerade.rs
  • Etemenanki/protocols/src/hysteria/server/endpoint.rs
  • Etemenanki/protocols/src/hysteria/server/config.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/ffi/src/client.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/supervisor/tests/unit/validate.rs
  • Etemenanki/supervisor/tests/unit/plan.rs
  • Etemenanki/supervisor/tests/hot_swap.rs
  • Etemenanki/environment/tests/unit/routing.rs
  • Etemenanki/ffi/tests/proxy.rs
  • katana/src/lower/mod.rs
  • katana/src/lower/inbound.rs

A Spec<U> is everything a supervisor is asked to run, written as typed data: the listeners and the protocols served on them, the upstreams, the balancers, the route table, name resolution, the user sets that inbounds admit, and the policies for live connections. A front end (etemenanki-app’s TOML loader, katana’s panel lowering, the FFI client) lowers its own format into a spec and hands it to Supervisor::start, apply or update. The supervisor validates it, plans a reconcile against the spec it already runs, and builds only what changed.

This page documents every type under supervisor/src/topology/spec_plan/, and the types they are made of from topology/inbound/mod.rs, entity/user.rs and policy.rs: what each field means, what the builders do with it, which rules apply to it, and how two specs are compared. It is for authors of a front end, and for contributors who add a field, a protocol or a transport. The rules and their full error texts are on Validation. What a difference between two specs leads to (versions, swaps, drains) is on Reconcile.

Paths without a crate directory, such as build/validate.rs or supervisor.rs, are under supervisor/src/. Paths in other crates are written from the repository root, such as app/src/lower.rs.

A spec:

  • names every resource by a tag, and every reference between resources is a tag: a route names an outbound or a balancer, a balancer names outbounds, an inbound names a user set;
  • carries every value typed. It holds a Destination rather than host text, a Uuid, a method enum, decoded key bytes and PEM bytes, never the string they were parsed from. So two specs compare with ==, and plan can tell a resource to keep from one to rebuild without asking where either spec came from;
  • holds its secret values in Secret<T> (the fields are listed under Secret<T> below), which compares by value and prints as Secret(..);
  • is plain data with public fields. Its only constructors are Secret::new and From<T> for Secret<T>, and its only fallible helpers are Strategy::parse and DomainRegexSet::new, which a front end calls while it lowers. A spec that breaks a rule can be built; applying it refuses it whole.

A spec does not:

  • read files. The front end reads certificates, keys and CA bundles and passes their bytes. When the supervisor builds from a spec, two fields lead it to read a file: RouteSpec.geoip and geosite are paths that build/route.rs → compile_routes reads, and DnsSpec::Split { use_hosts: true } has build/dns.rs → build_dns read the system hosts file.
  • check its own rules. The types carry what they can: a WireGuard key is a [u8; 32], a speed limit a NonZeroU64. The rules about how a spec’s values fit together (which fields go together, which tags exist, which values are in range) live in build/validate.rs → validate, which is pure: no sockets, no files, no clock. A front end checks what exists only in its own format, such as a key that is set while the feature it belongs to is off.
  • hold runtime state. The only resource a spec can hold is a supplied TUN descriptor (SuppliedTun, an Arc<OwnedFd>).
  • know how it was written. Nothing in it records a file name, a line number or a panel field.
Front end Lowering U What it sets
etemenanki-app app/src/lower.rs → lower, after the subscribe file is merged lower::UserKey, the app’s type alias of UserName One user set per inbound that takes users, tagged with the inbound’s own tag. Every user’s speed_limit is None. Policies::default() and no per-inbound or per-outbound override. Hysteria2UserAuth::Account. Every outbound’s WebSocket host, gRPC authority and SNI filled in.
katana src/lower/mod.rs → node_spec, for each node Uid, the panel’s numeric user id, named UserName::Username with the id in decimal One user set tagged users, with each user’s credentials derived from their UUID. Policies::default(). Hysteria2UserAuth::Password, unless [node.hysteria] credential = "user_pass".
etemenanki-ffi ffi/src/client.rs → lowering: the app’s lower, then refuse_servers, then supply_tun the app’s lower::UserKey The app’s spec, with every server inbound refused and the TUN inbound’s bind replaced by TunSource::Fd over the descriptor the platform supplies.

The FFI client runs as a client on a phone, so refuse_servers refuses any inbound that serves a proxy protocol to other hosts: Trojan, VLESS, VMess, Shadowsocks, Shadowsocks 2022, Hysteria 2, and SOCKS or HTTP bound anywhere but a Unix socket, localhost or a loopback address. The refusal reads inbound "<tag>" serves <protocol>, a server protocol; a client runs only a tun inbound and local socks or http inbounds, where <protocol> is, for example, trojan or socks beyond this device. supply_tun then serves the platform’s descriptor:

  • A config with one TUN inbound keeps it, with its MTU, UDP relay, flow limit and sniffing. The platform owns the interface, so a configured name, addresses or routes are ignored with a WARN: inbound <tag>: the platform owns the tun interface, so its name, addresses and routes in the config are ignored.
  • A config with none gets one tagged tun (TUN_TAG), with sniffing on, udp on and the defaults of protocols/src/tun/config.rs.
  • A config with more than one is refused: the config has more than one tun inbound; the platform supplies one device.

How etemenanki-app lowers its TOML is on etemenanki-app: from TOML to a spec. How katana lowers a node is on its lowering page. How the FFI client rewrites the app’s spec is on Mobile library (etemenanki-ffi).

supervisor/src/topology/spec_plan/mod.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spec<U: UserId> {
pub inbounds: Vec<InboundSpec>,
pub outbounds: Vec<OutboundSpec>,
pub balancers: Vec<BalancerSpec>,
pub route: RouteSpec,
pub dns: DnsSpec,
/// The users inbounds admit, as their own resource: a user set can be
/// swapped without reconciling anything else.
pub user_sets: Vec<UserSet<U>>,
/// The supervisor-wide defaults, which an inbound or outbound may
/// override.
pub policies: Policies,
}

U is the key the front end already uses for its users (see UserId and UserName). It appears only in user_sets; everything else is the same for every front end. Spec has no Default, and neither have RouteSpec and DnsSpec, so a front end always names the default route target and a DNS backend itself.

Field Identity inside the spec Built by Page
inbounds tag unique among inbounds; bind unique among inbounds build/inbound.rs → build_handler, system/listener.rs → bind Serving
outbounds tag unique among outbounds and balancers build/outbound.rs → build_outbound Outbounds
balancers tag unique among outbounds and balancers topology/balancer.rs → Balancer::new Outbounds
route one per spec build/route.rs → compile_routes Routing plane
dns one per spec build/dns.rs → build_dns DNS
user_sets tag unique among user sets build/users.rs → admit, build/inbound.rs → user_table Users and sessions
policies one per spec read when a drain or a removal is resolved Policies

The public paths are etemenanki_supervisor::topology::spec_plan for Spec, Secret, ObfsSpec and everything re-exported from its private submodules inbound, outbound, transport, route and dns; etemenanki_supervisor::topology::inbound for BindSpec, TunSource and SuppliedTun; etemenanki_supervisor::entity::user for the user types; etemenanki_supervisor::policy for the policies; and etemenanki_supervisor::topology::balancer::Strategy. The planner lives beside the spec, in the public submodule spec_plan::plan.

Types from other crates that a spec holds:

Type Path Held by
Destination, DialNetwork, Remote etemenanki_concepts::net ProxyUpstream.server, Hysteria2OutboundSpec.server, WireguardSpec.endpoint
AddressFamilyStrategy etemenanki_protocols::helpers::address_family Freedom, OutboundTransportSpec, Hysteria2OutboundSpec, WireguardSpec (twice)
Method (imported as SsMethod) etemenanki_protocols::ss_legacy Shadowsocks inbound and outbound
Method (imported as Ss2022Method) etemenanki_protocols::ss_2022 Shadowsocks 2022 inbound and outbound
VerifyMode etemenanki_protocols::transports::tls ClientTlsSpec, Hysteria2OutboundSpec
Security etemenanki_protocols::vmess VMess outbound
DeviceSpec etemenanki_protocols::tun TunSource::Create
Backend etemenanki_protocols::dns DnsSpec
RouteMatch etemenanki_environment::routing RouteRuleSpec
Uuid uuid Credentials.uuid, VLESS and VMess outbounds
CompactString compact_str every tag, Account.user, UserName

Tags name resources, and they are how resources refer to each other:

flowchart LR
  inbound["InboundSpec"]
  set["UserSet"]
  rule["RouteRuleSpec"]
  dflt["RouteSpec default"]
  balancer["BalancerSpec"]
  outbound["OutboundSpec"]
  inbound -->|"users: set tag"| set
  rule -->|"outbound: tag"| outbound
  rule -->|"outbound: tag"| balancer
  dflt -->|"tag"| outbound
  dflt -->|"tag"| balancer
  balancer -->|"members: outbound tags"| outbound
  • Three namespaces. Inbound tags, user-set tags, and outbound plus balancer tags. A route names an outbound and a balancer the same way, so those two kinds share one namespace, and a balancer whose tag an outbound already has is refused as duplicate balancer tag <tag>. An inbound or a user set may carry an outbound’s tag. etemenanki-app relies on the separation: it tags each inbound’s user set with the inbound’s own tag.
  • No empty tag. The empty tag is the supervisor’s own. It names the target through which its DNS service is reached (supervisor.rs → internal_id) and the blackhole of the empty plane a supervisor starts with, so no spec tag can collide with them. The refusal reads inbound : a inbound tag must not be empty, or outbound @v0: a outbound tag must not be empty.
  • Every reference resolves. A route rule or the route default must name an outbound or a balancer (route references unknown outbound missing). A balancer member must name an outbound, never another balancer (balancer outer references unknown outbound inner). An inbound’s users must name a user set (inbound socks references unknown user set missing).

The error texts above are ApplyError Display forms. Every rule, in the order validate checks it, is on Validation.

supervisor/src/topology/spec_plan/mod.rs
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct Secret<T>(T);
impl<T> Secret<T> {
pub fn new(value: T) -> Self;
/// The secret itself, for the code that has to put it on the wire.
pub fn expose(&self) -> &T;
}
impl<T> From<T> for Secret<T>;
impl<T> fmt::Debug for Secret<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(..)")
}
}
  • Clone, PartialEq, Eq and Hash are derived, so a secret compares and hashes as its contents. A changed password is a changed spec, and the resource holding it is rebuilt.
  • Debug is written by hand and prints Secret(..): neither the value nor its length. Secret has no Display.
  • The inner value is private, and expose() is the only way to read it. In the supervisor, validate calls it to check lengths, emptiness and collisions, and the builders call it where the value goes into a protocol’s own configuration.

Where Secret appears in a spec:

Field T
InboundProtocolSpec::Shadowsocks.password, OutboundProtocolSpec::Trojan.password, OutboundProtocolSpec::Shadowsocks.password String
InboundProtocolSpec::Ss2022.psk, OutboundProtocolSpec::Ss2022.psk and each of its identity_psks Vec<u8>
Hysteria2InboundSpec.shared_password, Hysteria2OutboundSpec.password String
ServerTlsSpec.key_pem Vec<u8>
ObfsSpec::Salamander.psk Vec<u8>
WireguardSpec.private_key, WireguardSpec.preshared_key [u8; 32]
Account.pass String
Credentials.password, Credential::Password String
Credentials.ss2022_psk, Credential::Ss2022Psk Vec<u8>

A Trojan listener over WebSocket and TLS, and a SOCKS listener on a Unix socket, both admitting one user set, with everything sent direct except one blocked domain:

Illustrative
use std::collections::BTreeMap;
use std::time::Duration;
use etemenanki_environment::routing::RouteMatch;
use etemenanki_protocols::dns::Backend;
use etemenanki_protocols::helpers::address_family::AddressFamilyStrategy;
use etemenanki_supervisor::entity::user::{Account, Credentials, UserName, UserSet, UserSpec};
use etemenanki_supervisor::policy::{Policies, UserRemovalPolicy};
use etemenanki_supervisor::topology::inbound::BindSpec;
use etemenanki_supervisor::topology::spec_plan::*;
use etemenanki_supervisor::Supervisor;
// The front end reads the files; the spec carries bytes.
let cert_pem = std::fs::read("/etc/etemenanki/cert.pem")?;
let key_pem = Secret::new(std::fs::read("/etc/etemenanki/key.pem")?);
// One user with two credentials: Trojan admits the password, SOCKS the account.
let alice = UserSpec {
credentials: Credentials {
password: Some(Secret::new("replace-with-a-long-random-password".into())),
account: Some(Account {
user: "alice".into(),
pass: Secret::new("replace-with-a-long-random-password".into()),
}),
..Credentials::default()
},
speed_limit: None,
};
let spec: Spec<UserName> = Spec {
inbounds: vec![
InboundSpec {
tag: "trojan-in".into(),
bind: BindSpec::Tcp { host: "0.0.0.0".into(), port: 443 },
sniff: true,
protocol: InboundProtocolSpec::Trojan {
transport: InboundTransportSpec {
shape: StreamShape::Ws { path: "/t".into(), host: None, tls: true },
tls: Some(ServerTlsSpec { cert_pem, key_pem }),
},
},
users: Some("people".into()),
user_removal: None,
},
InboundSpec {
tag: "socks-in".into(),
bind: BindSpec::Unix("/run/etemenanki/socks.sock".into()),
sniff: false,
protocol: InboundProtocolSpec::Socks { udp: false, udp_bind: None },
users: Some("people".into()),
user_removal: Some(UserRemovalPolicy::CloseAfter(Duration::from_secs(30))),
},
],
outbounds: vec![
OutboundSpec {
tag: "direct".into(),
protocol: OutboundProtocolSpec::Freedom { address_family: AddressFamilyStrategy::Auto },
drain: None,
},
OutboundSpec { tag: "block".into(), protocol: OutboundProtocolSpec::Blackhole, drain: None },
],
balancers: Vec::new(),
route: RouteSpec {
rules: vec![RouteRuleSpec {
matchers: vec![RouteMatch::DomainSuffix("ads.example.com".into())],
outbound: "block".into(),
}],
default: "direct".into(),
geoip: None,
geosite: None,
},
dns: DnsSpec::Single(Backend::System),
user_sets: vec![UserSet {
tag: "people".into(),
users: BTreeMap::from([(UserName::Email("alice@example.com".into()), alice)]),
}],
policies: Policies::default(),
};
let (supervisor, _report) = Supervisor::start(spec).await?;
// Later: an edit of the running spec, validated and planned like any apply.
supervisor.update(|spec| spec.route.default = "block".into()).await?;
flowchart LR
  fe["front end: TOML, panel API, FFI"]
  spec["Spec"]
  validate["validate"]
  plan["plan against RunningState"]
  prepare["prepare: build from the spec"]
  commit["commit"]
  running["RunningState.spec"]
  users["set_users, upsert_user, remove_user"]
  fe -->|"lower"| spec
  spec --> validate --> plan --> prepare --> commit --> running
  running -->|"compared with =="| plan
  running -->|"update: clone, then edit"| spec
  users -->|"edit one set, store the edited spec"| running
  1. Lowering. The front end parses its format, reads files, fills defaults and gaps, and builds a Spec. Everything that can fail on syntax fails here, as the front end’s own error.
  2. Hand-over. SupervisorBuilder::start and Supervisor::start take the first spec, apply and apply_with take a whole new one, and update and update_with take a closure FnOnce(&mut Spec<U>). The actor clones the running spec, runs the closure on the clone, and applies the result like any other spec. check takes &Spec<U> and builds everything without binding.
  3. Validation. plan(running, desired) calls validate(desired) first. A spec that breaks a rule is refused whole, and nothing is planned (an_invalid_spec_is_refused_without_a_plan).
  4. Planning. plan compares the desired spec with RunningState.spec, resource by resource (see Equality semantics), and emits Build, Reuse and the other steps. The actor refuses a plan with a Disrupt step unless the apply allows disruption.
  5. Prepare and commit. Prepare builds what the plan says from the spec: build_dns, build_outbound, compile_routes (which reads the geo files), and build_handler, which parses the certificate and key and builds the inbound’s user table with user_table. It binds the listeners of new binds. Commit makes it live and stores the desired spec as RunningState.spec.
  6. User edits. set_users, upsert_user and remove_user change one set in a clone of the running spec (Actor::edit_users). For each inbound that names the set, the actor runs validate_admission, not the whole validate, and rebuilds that inbound’s user table. Only when every table is built does it store them and the edited clone as the running spec. A remove_user for a user the set does not hold returns false and changes nothing. A later update starts from the edited set, and a later apply compares its sets with the edited ones.

What each phase does in full is on Reconcile.

supervisor/src/topology/spec_plan/inbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundSpec {
pub tag: CompactString,
pub bind: BindSpec,
pub sniff: bool,
pub protocol: InboundProtocolSpec,
pub users: Option<CompactString>,
pub user_removal: Option<UserRemovalPolicy>,
}
Field Meaning
tag Names the inbound. Routes match it with RouteMatch::InboundTag, and sessions belong to it.
bind What is bound: the listener’s identity across applies. An inbound whose bind is unchanged keeps its socket or device, and the handler behind it is swapped.
sniff Whether to read a domain out of a flow addressed by IP. The builder passes it to every kind of handler: the stream inbound, the SOCKS inbound, the Hysteria 2 connection config, and the TUN inbound (without_sniffing when it is false). See Sniffing.
protocol The protocol served, with its settings. See InboundProtocolSpec.
users The tag of the UserSet this inbound admits. Each user is admitted by the one credential kind the protocol takes, and a user without it is skipped here. None is the protocol’s open or shared mode.
user_removal Overrides Policies.user_removal for the sessions of this inbound’s users.

The file topology/inbound/mod.rs also defines what an inbound spec is built into: InboundHandler, StreamInbound, StreamProtocol, Ss2022Users, Hy2Handler and TunHandler. Those runtime types are on Serving.

supervisor/src/topology/inbound/mod.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindSpec {
Tcp { host: String, port: u16 },
Udp { host: String, port: u16 },
Unix(PathBuf),
Tun(TunSource),
}
impl fmt::Display for BindSpec;
Variant Display Serves
Tcp { host, port } host:port, for example 127.0.0.1:1080 SOCKS, HTTP, Trojan, VLESS, VMess, Shadowsocks, Shadowsocks 2022
Udp { host, port } udp host:port, for example udp 0.0.0.0:8443 Hysteria 2
Unix(path) unix: and the path, for example unix:/run/etemenanki/proxy.sock The same stream protocols as Tcp, without a transport layer
Tun(TunSource::Create(spec)) tun and the interface name, or tun auto when spec.name is None TUN
Tun(TunSource::Fd(device)) tun fd and the raw descriptor number TUN

The Display form is what the errors about a bind print:

inbound b: 127.0.0.1:1080 is bound by another inbound
inbound h2: udp 0.0.0.0:8443 is bound by another inbound
inbound b: unix:/run/etemenanki/proxy.sock is bound by another inbound
inbound t2: tun ete0 is bound by another inbound

Equality is derived, so two binds are the same bind exactly when every field is equal:

  • TCP and UDP on the same port are different binds. A Hysteria 2 listener may share a port number with a SOCKS listener (the_same_port_over_udp_is_another_bind).
  • Two TUN inbounds that both leave the interface name to the kernel and ask for the same MTU, addresses and routes are the same bind. etemenanki-app refuses such a config with inbound t2: tun auto is bound by another inbound.
supervisor/src/topology/inbound/mod.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TunSource {
/// Created, addressed and routed by the inbound.
Create(DeviceSpec),
/// Handed over already configured.
Fd(SuppliedTun),
}
impl TunSource {
pub fn mtu(&self) -> u16;
}
#[derive(Debug, Clone)]
pub struct SuppliedTun {
pub fd: Arc<OwnedFd>,
/// What the platform configured the interface with; at least 1280.
pub mtu: u16,
}
impl PartialEq for SuppliedTun {
fn eq(&self, other: &Self) -> bool {
self.fd.as_raw_fd() == other.fd.as_raw_fd() && self.mtu == other.mtu
}
}
impl Eq for SuppliedTun {}
protocols/src/tun/device.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceSpec {
/// Interface name; `None` lets the kernel pick one.
pub name: Option<String>,
pub mtu: u16,
/// `(address, prefix length)` pairs assigned to the interface.
pub addresses: Vec<(IpAddr, u8)>,
/// `(network, prefix length)` pairs routed into the interface.
pub routes: Vec<(IpAddr, u8)>,
}
  • Create. The inbound creates the interface, assigns addresses and installs routes (tun::open), which needs CAP_NET_ADMIN on Linux. The routes are scoped to the device and never removed explicitly: the kernel drops the interface and its routes when the device’s last descriptor closes. check_platform refuses what the platform cannot honour; off Linux, a non-empty routes gives tun routes are installed only on Linux; add them with the OS route tool. On Android and iOS tun::open refuses every device: tun devices are created by the system VPN API here; adopt its descriptor.
  • Fd. A mobile VPN API’s descriptor. The platform owns the interface, its addresses and its routes, so check_platform does not apply. The inbound serves duplicates of fd (tun::adopt) and closes them when it stops; fd itself closes when its last holder drops it, and the spec is one of its holders. adopt makes its duplicate non-blocking, and a duplicate shares the descriptor’s file status flags, so fd becomes non-blocking too.
  • Equality of a supplied device. OwnedFd has no equality, so SuppliedTun compares the raw descriptor number and the MTU. The code’s reason: an open descriptor’s number is unique while it is open, and both values hold theirs open. An apply therefore keeps the device only while its spec names that same descriptor.
  • MTU. TunSource::mtu reads either source. Both must be at least MIN_TUN_MTU = 1280, the smallest MTU the userspace stack serves (inbound t: tun mtu must be at least 1280).

A change to a TUN inbound is a disruptive change: the plan gives it a Disrupt step, and the apply needs ApplyOptions::allow_disruptive. etemenanki-app refuses a TUN change; a restart applies it. The TUN stack itself is on TUN.

supervisor/src/topology/spec_plan/inbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InboundProtocolSpec {
Socks { udp: bool, udp_bind: Option<IpAddr> },
Http { allow_transparent: bool, transport: InboundTransportSpec },
Trojan { transport: InboundTransportSpec },
Vless { transport: InboundTransportSpec },
Vmess { transport: InboundTransportSpec },
Shadowsocks { method: SsMethod, password: Secret<String> },
Ss2022 { method: Ss2022Method, psk: Secret<Vec<u8>> },
Hysteria2(Hysteria2InboundSpec),
Tun(TunSpec),
}

Every variant pairs with one kind of bind, and every variant admits users by one credential kind (build/users.rs → credential_kind):

Variant Bind Transport Admits users by users = None means
Socks Tcp or Unix none, plain stream account no authentication
Http Tcp or Unix InboundTransportSpec account no accounts: the open proxy mode
Trojan Tcp or Unix InboundTransportSpec password refused: no open mode
Vless Tcp or Unix InboundTransportSpec uuid refused: no open mode
Vmess Tcp or Unix InboundTransportSpec uuid refused: no open mode
Shadowsocks Tcp or Unix none, plain stream password only the server password
Ss2022 Tcp or Unix none, plain stream ss2022_psk only the server psk
Hysteria2 Udp QUIC, with its own TLS account, or password under Hysteria2UserAuth::Password only shared_password
Tun Tun none none: a TUN device admits no users the only mode

A protocol on the wrong kind of bind is refused with inbound <tag>: the protocol cannot be served on <bind> (each_protocol_is_served_only_on_its_kind_of_bind). Trojan, VLESS and VMess without a set are refused with the protocol has no open mode and needs a user set, because nobody could connect (trojan_vless_and_vmess_need_a_user_set).

A Unix listener is local, so it carries no transport. A stream protocol’s shape on one must be plain StreamShape::Tcp without TLS (a unix socket carries no transport; its shape must be plain tcp), and the builder ignores the transport of any inbound bound to Unix.

Field Meaning Rule
Socks.udp Whether UDP ASSOCIATE is served. none
Socks.udp_bind The IP reported to the client, and bound, for the UDP relay. None uses the listener’s local IP. A Unix listener has no local IP, so SOCKS on a Unix socket with udp = true needs a udp_bind: socks over a unix socket has no local IP for UDP associate; set udp_bind or turn udp off.
Http.allow_transparent Whether an origin-form request target is served, its target taken from the Host header. none

The protocol pages are SOCKS and HTTP.

These three have only a transport, and always need a user set. Trojan admits by password, VLESS and VMess by uuid. See Trojan, VLESS and VMess wire format.

Shadowsocks Ss2022
Methods SsMethod: Aes128Gcm, Aes256Gcm, ChaCha20Poly1305, XChaCha20Poly1305 Ss2022Method: Blake3Aes128Gcm (2022-blake3-aes-128-gcm, 16-byte key), Blake3Aes256Gcm (2022-blake3-aes-256-gcm, 32), Blake3ChaCha20Poly1305 (2022-blake3-chacha20-poly1305, 32)
Server secret password, admitted besides the users psk, decoded and exactly method.key_len() bytes. While the inbound admits no user it is the one session key; once it admits users, it is the identity PSK (iPSK) of the Extended Identity Header, and each user’s own key is their session key
User credential password ss2022_psk, decoded, sized by each admitting inbound
Users need any method an AES-GCM method

The server key of Ss2022 must be exactly the method’s key length, or the spec is refused with the shadowsocks-2022 key must be 32 bytes for this method (a_shadowsocks_2022_server_key_is_sized_to_its_method). etemenanki-app decodes the server key (ss_2022::users::decode_psk: standard base64 of the trimmed text, failing with decode PSK: <error>) and runs it through ss_2022::users::normalise_psk before it builds the spec. A longer key is folded to the method’s length, keeping the first key_len bytes of its SHA-256 (ss_2022::crypto::fold_key); a shorter one is refused by the app: inbound ss: shadowsocks-2022: PSK too short (16 < 32).

A user’s ss2022_psk is only decoded. Each inbound that admits the user normalises it to its own method, since that is where the size comes from: validate_admission checks it and user_table folds it. A longer key is folded down; a shorter one is refused, naming the user and not the key: inbound ss: user a@example.com: shadowsocks-2022: PSK too short (8 < 16) (a_shadowsocks_2022_user_key_must_normalise_to_the_method).

The Extended Identity Header is defined only for the AES-GCM methods, so an Ss2022 inbound with Blake3ChaCha20Poly1305 cannot admit users. The protocols crate refuses such a user table when it is built (protocols/src/ss_2022/users.rs → Validator::from_config), so the apply fails as a build error: building inbound ss failed: shadowsocks-2022: multi-user requires an aes-gcm method. See Shadowsocks and Shadowsocks 2022.

supervisor/src/topology/spec_plan/inbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hysteria2InboundSpec {
pub tls: ServerTlsSpec,
pub shared_password: Option<Secret<String>>,
pub masquerade: Option<MasqueradeSpec>,
pub obfs: Option<ObfsSpec>,
pub udp_idle_timeout: Option<Duration>,
pub max_connections: usize,
pub max_circuits: usize,
pub user_auth: Hysteria2UserAuth,
}
Field Meaning Rule
tls The certificate chain and key. QUIC always carries TLS; there is no plaintext Hysteria. Parsed at build by protocols/src/hysteria/server/endpoint.rs → server_config.
shared_password The one password every client presents, when the inbound admits no user set. Exactly one of this and InboundSpec.users is set, and it is not empty.
masquerade The answer to an unauthenticated request. None answers as Go’s http.NotFound does. See MasqueradeSpec below.
obfs Salamander obfuscation beneath QUIC. Key at least 4 bytes. A change is disruptive.
udp_idle_timeout How long a UDP association may stay quiet. None disables UDP relay. Between 2 and 600 seconds when set: udp_idle_timeout must be between 2 and 600 seconds.
max_connections Client connections served at once. At least 1: max_connections and max_circuits must be at least 1.
max_circuits Live circuits (proxied streams) across the whole listener. At least 1, with the same text.
user_auth Which credential the user set is admitted by. Ignored when shared_password is set. See Hysteria2UserAuth below.
  • Both or neither. Were a shared password and a user set both set, a credential could have two answers: a shared password and a user set cannot both be set; a credential would have two answers. Neither: hysteria2 needs a shared password or a user set. An empty shared password: the shared password must not be empty.
  • Idle range. HY2_UDP_IDLE = 2..=600 seconds, checked against Duration::as_secs, which drops any fraction of a second: 1.5 s counts as 1 and is refused, 600.9 s counts as 600 and is accepted. The code’s reason for the range: below it a busy association is swept between packets, above it a dead one holds its socket for ten minutes. None needs no bound, because it turns UDP off.
  • Front-end defaults. DEFAULT_MAX_CONNECTIONS (262,144) and DEFAULT_MAX_CIRCUITS (4,194,304), from protocols/src/hysteria/server/config.rs, are what katana always lowers and what etemenanki-app lowers when the config sets no max_connections or max_circuits. Both front ends default the UDP idle timeout to 60 seconds when UDP is on, a literal unwrap_or(60) in each rather than a named constant. The spec itself has no default.
  • Front-end checks. A spec cannot express a timeout for a relay that is off, so both front ends refuse that combination themselves: udp_idle_timeout is set but udp is not enabled. katana’s lowering (src/lower/inbound.rs) also checks the 2 to 600 second range and builds the masquerade with Masquerade::new before the supervisor does, so a node config that breaks either fails with the same reason text before a spec exists.
  • Changes on a running listener. A masquerade change swaps the handler without disrupting anyone (a_hysteria2_masquerade_change_swaps_without_disrupting). An obfs change is a Disrupt step with the reason the hysteria2 obfuscation changed; connected clients cannot follow the new key; an apply that does not allow disruption is refused with inbound <tag>: the hysteria2 obfuscation changed; connected clients cannot follow the new key; this ends its live connections and needs allow_disruptive. etemenanki-app refuses it, katana allows it. A max_circuits change gives the new handler a fresh circuit budget; when max_circuits is unchanged, the new handler shares the running listener’s budget, so live circuits keep counting against it (supervisor.rs → same_circuits). See Reconcile.

The listener itself is on Hysteria 2: server.

supervisor/src/topology/spec_plan/inbound.rs
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Hysteria2UserAuth {
/// Upstream's `user:pass`, from each user's `account`.
#[default]
Account,
/// The whole auth string is the user's `password`.
Password,
}
Variant Credential Admission rules
Account (default) The user’s account, presented as user:pass Usernames are compared case-insensitively on the wire, so two accounts that differ only in case collide: users <a> and <b> present the same username. The wire form splits on a colon, so a name holding : could never be presented, and an empty part is no credential: user <name>: a hysteria2 account needs a name without ':' and a password.
Password The user’s password, as the whole auth string Must not be empty: user <name>: a hysteria2 password must not be empty.

etemenanki-app always lowers Account. katana lowers Password by default, and gives each user their UUID as that password. The code’s reason: the node agents that panels are built for key their users by the UUID itself, and a panel’s user list carries no password field for the user:pass form. [node.hysteria] credential = "user_pass" selects Account, and katana refuses any other value than "", "uuid" and "user_pass".

supervisor/src/topology/spec_plan/inbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MasqueradeSpec {
/// Never a success status: that would tell the prober it had
/// authenticated.
pub status: u16,
pub body: String,
pub content_type: String,
}

validate builds a Masquerade from it (protocols/src/hysteria/server/masquerade.rs → Masquerade::new) to find out whether it is acceptable, so both refusals come from the protocols crate:

  • hysteria2: 233 is the authentication success status and cannot be used for the masquerade, printed by etemenanki-app as inbound h1: hysteria2: 233 is the authentication success status and cannot be used for the masquerade;
  • hysteria2: <status> is not an HTTP status code, for a status outside 100 to 999, which is what http::StatusCode::from_u16 accepts: inbound h1: hysteria2: 99 is not an HTTP status code.

The response carries the status, a Content-Type of content_type and a Content-Length of the body’s length. None builds Masquerade::default(): status 404, body 404 page not found\n, content type text/plain; charset=utf-8, byte for byte what an unconfigured upstream server answers. When a config sets only some masquerade keys, both front ends fill the rest with these same values.

supervisor/src/topology/spec_plan/inbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TunSpec {
/// Relay UDP flows as well as TCP connections.
pub udp: bool,
/// How long a UDP flow may stay quiet before it is retired.
pub udp_idle_timeout: Duration,
/// Flows tracked at once.
pub max_flows: usize,
}

The device itself, MTU included, is the inbound’s BindSpec::Tun, so TunSpec holds only what the stack does with it. build_handler builds a TunInbound from TunConfig { user: Principal::anonymous(), mtu: device.mtu(), udp, udp_idle_timeout, max_flows }: a TUN flow carries no credential, so every flow is anonymous. etemenanki-app fills what the config leaves out, and the FFI client’s own TUN inbound uses, the defaults of protocols/src/tun/config.rs: DEFAULT_MTU = 1500, DEFAULT_UDP_IDLE_TIMEOUT = 60 s and DEFAULT_MAX_FLOWS = 65,536, with udp on.

supervisor/src/topology/spec_plan/mod.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObfsSpec {
Salamander { psk: Secret<Vec<u8>> },
}

Salamander XORs each packet beneath QUIC with BLAKE2b-256(psk || salt). One type serves both sides: Hysteria2InboundSpec.obfs and Hysteria2OutboundSpec.obfs. The key is at least MIN_SALAMANDER_PSK = 4 bytes on either side (salamander obfs key must be at least 4 bytes, pinned by a_salamander_key_is_at_least_4_bytes_on_either_side). build/outbound.rs → obfs turns it into the protocols crate’s Obfs::Salamander for the listener and the connector alike. The wire side is on Hysteria 2: client.

supervisor/src/topology/spec_plan/transport.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamShape {
/// Plain TCP.
Tcp,
/// TLS over TCP: this project's own `network = "tls"`.
Tls,
Ws {
path: String,
/// The `Host` the config named, if any. Each side applies its own
/// fallback: an outbound may borrow `server`, an inbound has nothing
/// to borrow.
host: Option<String>,
tls: bool,
},
Grpc { service: String, authority: Option<String>, tls: bool },
}
impl StreamShape {
/// Whether TLS is layered under this shape, and so whether the TLS
/// material beside it must be present.
pub fn uses_tls(&self) -> bool;
}

Inbounds and outbounds share this one shape. A rule about which fields a network requires therefore cannot end up on one side only. In etemenanki-app, app/src/transport.rs → resolve_stream is the one function that derives a shape from a [.stream] block, for both sides.

uses_tls() is false for Tcp, true for Tls, and the tls flag for Ws and Grpc. What each shape is built into:

Shape Inbound (build/inbound.rs → build_transport) Outbound (build/outbound.rs → transport) ALPN
Tcp InboundTransport::Tcp TransportKind::Tcp none
Tls InboundTransport::Tls TransportKind::Tls none
Ws InboundTransport::ws(path, host, tls) TransportKind::ws(host, path, tls) http/1.1 when tls
Grpc InboundTransport::grpc(service, tls) TransportKind::grpc(authority, service, tls) h2 when tls

host and authority hold what the config named, if anything, and each side applies its own fallback. An inbound’s WebSocket listener accepts an upgrade only on its path, normalised as Xray does it: an ed= query parameter (Xray’s early-data setting) is removed, an empty path becomes /, and a path without a leading / gets one (protocols/src/transports/ws/endpoint.rs → normalize_path). Its host, when set, makes it accept only a request whose Host matches, case-insensitively and without its port (host_matches); None accepts any Host. A server does not check the authority a gRPC client sends, so an inbound’s authority has no meaning. The transports themselves are on TCP and TLS and WebSocket and gRPC.

supervisor/src/topology/spec_plan/transport.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundTransportSpec {
pub shape: StreamShape,
pub tls: Option<ServerTlsSpec>,
}
/// What a server presents: a certificate chain and its private key, both PEM.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerTlsSpec {
pub cert_pem: Vec<u8>,
pub key_pem: Secret<Vec<u8>>,
}
  • tls is Some exactly when shape.uses_tls() holds. The fields are public, so this is checked on apply, both ways: the transport layers tls but carries no tls settings, or the transport carries tls settings but does not layer tls (an_inbound_carries_tls_material_exactly_when_its_shape_layers_tls).
  • An inbound’s shape may have gaps: a WebSocket shape without host and a gRPC shape without authority are valid (an_inbound_needs_no_ws_host_or_grpc_authority). A server has nothing to borrow them from and needs neither.
  • Validation never parses PEM; its fixtures use b"cert" and b"key". The builders do: TlsServerConfig::from_pem(cert, key, alpn) for a stream transport, which uses OpenSSL’s mozilla_intermediate_v5 profile with a TLS 1.2 minimum, so TLS 1.2 and TLS 1.3 only, and hysteria::server::endpoint::server_config for Hysteria 2. A PEM that does not parse is a build error, not a validation error: building inbound t failed: no certificate in PEM bundle, or for Hysteria 2 building inbound h1 failed: hysteria2: the certificate file contains no certificates.

OutboundTransportSpec, ClientTlsSpec and VerifyMode

Section titled “OutboundTransportSpec, ClientTlsSpec and VerifyMode”
supervisor/src/topology/spec_plan/transport.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboundTransportSpec {
pub shape: StreamShape,
pub tls: Option<ClientTlsSpec>,
/// Which family of the upstream's addresses is dialed.
pub address_family: AddressFamilyStrategy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientTlsSpec {
/// The SNI sent, and the name the certificate is checked against.
pub server_name: String,
/// Which roots the certificate is verified against, if it is at all.
pub verify: VerifyMode,
}
protocols/src/transports/tls/config.rs
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VerifyMode {
/// Verify with the platform/OpenSSL default trust store.
System,
/// Verify with the platform/OpenSSL default trust store plus these PEM CAs.
CustomCa(Vec<u8>),
/// Disable certificate chain and hostname verification.
Insecure,
}

Unlike an inbound’s, an outbound’s shape carries no gaps: a WebSocket host and a gRPC authority must be Some. A gap that reaches the supervisor is the front end’s bug, and it is refused rather than guessed at: ws transport needs a host, grpc transport needs an authority (an_outbound_needs_a_ws_host_and_a_grpc_authority). tls is Some exactly when the shape uses TLS, with the same two texts as inbounds (an_outbound_carries_tls_settings_exactly_when_its_shape_layers_tls). The builder turns ClientTlsSpec into ClientConfig::with_verify_mode(server_name, verify, alpn), which sets a TLS 1.2 minimum. CustomCa adds the bundle’s certificates to the default trust store rather than replacing it, and a bundle holding no certificate fails the build: building outbound p@v1 failed: no certificate in CA PEM bundle. Insecure turns off both chain and host-name verification.

etemenanki-app fills the gaps in app/src/lower.rs → outbound_transport: the WebSocket host from ws.host, then tls.server_name, then server; the gRPC authority from grpc.authority along the same chain; and the SNI from tls.server_name, then server. A CA file is read into VerifyMode::CustomCa as bytes, so the spec never names it.

address_family chooses among the upstream server’s addresses, which the transport connector looks up with the supervisor’s server resolver (Dns::servers).

supervisor/src/topology/spec_plan/outbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboundSpec {
pub tag: CompactString,
pub protocol: OutboundProtocolSpec,
/// Overrides Policies::drain for the live flows of this outbound once it
/// is removed or changed.
pub drain: Option<DrainPolicy>,
}
/// Where a proxy client dials, and over what.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyUpstream {
/// The proxy server, always addressed over TCP.
pub server: Destination,
pub transport: OutboundTransportSpec,
}
concepts/src/net.rs
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Destination {
pub network: DialNetwork, // Unknown = 0, Tcp = 1, Udp = 2, Unix = 3
pub remote: Remote, // IpAddr(IpAddr) or Domain(CompactString)
pub port: u16,
}

The tag is what routes and balancers name. A changed spec under the same tag is a new version of that outbound, an OutboundId printed as tag@vN, and the flows of the version it replaces are handled by the drain policy (see Reconcile).

ProxyUpstream.server is always dialled over TCP: build/outbound.rs → tcp replaces its network with DialNetwork::Tcp whatever the spec says. etemenanki-app writes DialNetwork::Tcp there too, so its specs compare equal (see What a front end owes the comparison). A remote that is a domain is resolved when the transport dials. See Links and types for Destination.

supervisor/src/topology/spec_plan/outbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutboundProtocolSpec {
Freedom { address_family: AddressFamilyStrategy },
Blackhole,
Socks { upstream: ProxyUpstream, account: Option<Account> },
Http { upstream: ProxyUpstream, account: Option<Account> },
Trojan { upstream: ProxyUpstream, password: Secret<String> },
Vless { upstream: ProxyUpstream, id: Uuid },
Vmess { upstream: ProxyUpstream, id: Uuid, security: Security },
Shadowsocks { upstream: ProxyUpstream, method: SsMethod, password: Secret<String> },
Ss2022 {
upstream: ProxyUpstream,
method: Ss2022Method,
identity_psks: Vec<Secret<Vec<u8>>>,
psk: Secret<Vec<u8>>,
},
Hysteria2(Hysteria2OutboundSpec),
Wireguard(WireguardSpec),
}
Variant What it reaches Balancer member (TCP probe) Resolver it holds
Freedom The destination itself, from this host; address_family picks among the destination’s addresses no: no upstream Dns::destinations
Blackhole Nothing: EOF on read, packets swallowed no: no upstream none
Socks, Http upstream, with an optional Account yes: upstream.server Dns::servers
Trojan upstream; the password is hashed at build (trojan::protocol::password_hash) yes Dns::servers
Vless, Vmess upstream; id is the user’s UUID, and VMess adds security (Aes128Gcm or ChaCha20Poly1305) yes Dns::servers
Shadowsocks upstream; the key is derived from the password at build (evp_bytes_to_key(password, method.key_len())) yes Dns::servers
Ss2022 upstream; identity_psks in the order iPSK:…:uPSK writes them, empty for a single-user server, and psk the user’s own yes Dns::servers
Hysteria2 Its own QUIC connection, not a transport no: it listens on UDP Dns::servers
Wireguard Its own tunnel, not a transport no: it listens on UDP Dns::servers for the endpoint, Dns::destinations inside the tunnel
  • Probe targets. A balancer selects on health, and a TCP connect to the upstream is how health is learned. build/validate.rs → probe_target names the upstream for the seven proxy variants and nothing for the other four; WireGuard and Hysteria 2 listen on UDP, so a TCP connect would mark them down forever. A member without a probe target is refused: balancer pool: outbound direct has no upstream a TCP health probe can reach.
  • Resolvers. Every outbound but a blackhole holds a resolver built from the DnsSpec, which is why a changed DnsSpec rebuilds every outbound except the blackholes (a_dns_change_rebuilds_every_outbound_but_a_blackhole).
  • Shadowsocks 2022 keys. Every key, the user’s and each identity key, must be exactly method.key_len() bytes: shadowsocks-2022 keys must be 16 bytes for this method (shadowsocks_2022_client_keys_are_sized_to_their_method). Unlike an inbound’s user key, nothing folds them on the supervisor side. etemenanki-app splits the configured password on : (iPSK:…:uPSK), decodes each part and runs it through normalise_psk while it lowers; the last part becomes psk and the others identity_psks.

How each variant becomes an outbound handler, including its UDP support, is on Outbounds.

supervisor/src/topology/spec_plan/outbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hysteria2OutboundSpec {
/// The server's UDP endpoint.
pub server: Destination,
/// The SNI sent, and the name the certificate is checked against.
pub server_name: String,
pub password: Secret<String>,
pub verify: VerifyMode,
pub obfs: Option<ObfsSpec>,
/// Concurrent proxy streams on the connection. At least 1.
pub max_concurrent_streams: usize,
/// Which family of the server's addresses is dialed.
pub address_family: AddressFamilyStrategy,
}
  • server is dialled over UDP whatever its network says (build/outbound.rs → udp), and its name is looked up with Dns::servers, filtered and ordered by address_family.
  • password must not be empty (hysteria2 password must not be empty), and max_concurrent_streams must be at least 1 (max_concurrent_streams must be at least 1). Both are pinned by a_hysteria2_client_needs_a_password_and_a_stream.
  • When the config sets none, etemenanki-app fills max_concurrent_streams with DEFAULT_MAX_CONCURRENT_STREAMS = 102,400 from protocols/src/hysteria/config.rs, and server_name with the outbound’s server.
  • An unchanged spec is carried over by Arc with its QUIC connection, so a flow opened after an apply rides the connection an earlier flow opened. A changed one is a new version that dials a connection of its own (an_unchanged_hysteria2_outbound_keeps_its_quic_connection_across_apply).
supervisor/src/topology/spec_plan/outbound.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WireguardSpec {
pub private_key: Secret<[u8; 32]>,
pub peer_public_key: [u8; 32],
pub preshared_key: Option<Secret<[u8; 32]>>,
pub endpoint: Destination,
pub endpoint_address_family: AddressFamilyStrategy,
pub addresses: Vec<IpAddr>,
pub address_family: AddressFamilyStrategy,
pub mtu: usize,
pub keepalive: Option<u16>,
pub reserved: Option<[u8; 3]>,
}
Field Meaning
private_key, preshared_key This side’s key and the optional pre-shared key, 32 bytes each, as Secrets
peer_public_key The peer’s public key, 32 bytes, not secret
endpoint The peer’s UDP endpoint, a proxy server like any other: dialled over UDP whatever its network says
endpoint_address_family Which family of the endpoint’s addresses is dialled; the endpoint is looked up with Dns::servers
addresses The tunnel-local addresses
address_family Which family is used inside the tunnel, for destinations looked up with Dns::destinations
mtu The tunnel MTU, at the IP layer. When the config sets none, etemenanki-app fills DEFAULT_MTU = 1420 from protocols/src/wireguard/config.rs
keepalive The persistent keepalive interval, in seconds
reserved Xray’s 3-byte reserved header field

The two address-family fields answer different questions: endpoint_address_family is about reaching the peer over the host’s network, and address_family is about what is reached inside the tunnel. When address_family asks for one family only (Ipv4Only or Ipv6Only), an address of that family must be among addresses, because the userspace stack has no other source address to use: wireguard address_family ipv6_only needs an IPv6 address (a_wireguard_address_family_needs_an_address_of_that_family). The prefer_* strategies and Auto need nothing. The tunnel is on WireGuard.

protocols/src/helpers/address_family.rs
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum AddressFamilyStrategy {
#[default]
Auto,
Ipv4Only,
Ipv6Only,
PreferIpv4,
PreferIpv6,
}
Variant as_str Addresses used
Auto (default) auto All, in the resolver’s order, which already reflects the host’s address-selection rules
Ipv4Only ipv4_only IPv4 only
Ipv6Only ipv6_only IPv6 only
PreferIpv4 prefer_ipv4 All, IPv4 first (a stable sort, so the order within each family is kept)
PreferIpv6 prefer_ipv6 All, IPv6 first

It appears five times in a spec: Freedom.address_family, OutboundTransportSpec.address_family, Hysteria2OutboundSpec.address_family, and WireguardSpec.endpoint_address_family and address_family. The dialers that apply it are on Dialers.

A front end parses it with its FromStr, which trims the text, lower-cases it and reads - as _:

Accepted text Variant
empty, 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

Anything else fails with unknown address family strategy.

supervisor/src/topology/spec_plan/route.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteSpec {
pub rules: Vec<RouteRuleSpec>,
/// The outbound or balancer tag for a flow no rule matches.
pub default: CompactString,
/// The geoip `.dat` file `GeoIp` matchers look up.
pub geoip: Option<PathBuf>,
/// The geosite `.dat` file `GeoSite` matchers look up.
pub geosite: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteRuleSpec {
pub matchers: Vec<RouteMatch>,
/// The outbound or balancer tag.
pub outbound: CompactString,
}
  • First match. Rules are tried in order. The first rule with any matcher that matches wins, and a flow no rule matches takes default. A rule is tested with Iterator::any over its matchers: its matchers are alternatives, not conditions that must all hold. A rule whose matchers is empty matches no flow. A rule is identified by its index in rules (entity/id.rs → RuleId, a u32), so reordering the rules renumbers them.
  • Geo data. geoip and geosite are paths to .dat files rather than bytes. The code’s reason: a .dat file runs to megabytes, and only the sets the rules reference are loaded from it. build/route.rs → compile_routes collects the codes of every GeoSite and GeoIp matcher and passes them, with the two paths, to environment/src/routing.rs → build_geo_data, which reads and decodes a file only when some matcher needs it. See the geo codes and errors below.
  • Targets by tag. outbound and default name an outbound or a balancer. The compiled table names slots rather than outbounds, one per distinct tag, so an outbound can be rebuilt without recompiling the table (see Routing plane).

etemenanki-app lowers each [[route.rule]] into one RouteRuleSpec, with its matchers in a fixed order of kinds: domain_suffix, domain_keyword, domain_full, domain_regex (one set for the whole list), cidr, source_cidr, inbound_tag, network, port, geosite, geoip. Every key of the rule goes into that one list, so a rule that sets both domain_suffix and port matches a flow that satisfies either, not only one that satisfies both. It lower-cases the three plain domain kinds. When [route].default is absent, default is the first outbound’s tag.

The geo codes a matcher names:

Matcher Code Meaning
GeoSite code The geosite entry code, compared case-insensitively
GeoSite code@attr Only the domains of entry code that carry the attribute attr
GeoIp code The geoip entry code, compared case-insensitively
GeoIp !code Every IP destination outside the entry code

The loaded sets are keyed by the matcher’s text as written, such as cn, google@ads or !cn. A geo matcher without its file, an entry that does not exist or a file that does not decode fails the route’s build:

  • a geosite matcher is used but no geosite file is configured, printed as building route failed: a geosite matcher is used but no geosite file is configured, and the same for geoip;
  • geosite code not found: <code>, and geoip code not found: <code>;
  • geosite decode: <error>, and geoip decode: <error>, when the file is not the expected protobuf.

RouteMatch belongs to environment/src/routing.rs, the route model shared with katana:

Variant A flow matches when
GeoSite(code) Its domain is in the geosite set code
DomainSuffix(parent) Its domain is parent or a subdomain of it: example.com matches a.example.com, not notexample.com
DomainKeyword(needle) Its domain contains needle
DomainFull(name) Its domain is exactly name
DomainRegex(DomainRegexSet) Its domain matches any of the set’s patterns
GeoIp(code) Its destination is an IP in the geoip set code
Cidr(IpCidr) Its destination is an IP in the range
SourceCidr(IpCidr) The client’s own address is in the range
PortRange(lo, hi) Its destination port is between lo and hi, both included
Network(TargetNetwork) It is TCP, or UDP
InboundTag(tag) It arrived on the inbound tag

Domain matchers try both the domain the request named and a domain sniffed from the payload, each lower-cased once per flow. The DomainSuffix, DomainKeyword and DomainFull values are compared as written, so a front end writes them in lower case, as etemenanki-app does. A destination given as an IP matches no domain matcher unless a domain was sniffed, and Cidr and GeoIp never match a destination given as a domain. Each matcher reads one field of the router’s RouteTarget (environment/src/routing.rs): SourceCidr reads source, Network reads network and InboundTag reads inbound_tag, and a matcher whose field the router was not given does not match.

DomainRegexSet compiles a rule’s patterns into one regex::RegexSet, or several when one set would exceed the engine’s size limit (regexes_too_big_for_one_set_still_build). DomainRegexSet::new rejects a set with an invalid pattern and names it: invalid domain regex "(unclosed": regex parse error:, followed by the regex crate’s own explanation. RegexSet has no equality, so DomainRegexSet defines it on the source patterns: two rules are the same rule when they were spelled the same way, in the same order (domain_regex_equality_is_by_pattern). The matching rules are on Routing.

supervisor/src/topology/spec_plan/route.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BalancerSpec {
pub tag: CompactString,
/// Outbound tags, in priority order.
pub members: Vec<CompactString>,
pub strategy: Strategy,
/// How often each member is probed.
pub probe_interval: Duration,
/// How long one probe may take.
pub probe_timeout: Duration,
}
supervisor/src/topology/balancer.rs
pub const DEFAULT_PROBE_INTERVAL: Duration = Duration::from_secs(30);
pub const DEFAULT_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
/// The first healthy member in configured order. Order is the priority.
Failover,
/// Each healthy member in turn.
RoundRobin,
}
impl Strategy {
pub fn parse(s: &str) -> io::Result<Self>;
}
  • A balancer’s tag shares the outbound namespace: a route names a balancer exactly as it names an outbound.
  • members are outbound tags only, in priority order. Each must exist and have a TCP probe target (see OutboundProtocolSpec), and the list must not be empty (balancer <tag> has no members).
  • Strategy::parse accepts failover and round_robin, and refuses anything else with unknown balancer strategy "random" (expected "failover" or "round_robin"). etemenanki-app prefixes it with the balancer: balancer pool: unknown balancer strategy "random" (expected "failover" or "round_robin"). It lowers Failover when the config names no strategy.
  • DEFAULT_PROBE_INTERVAL (30 s) and DEFAULT_PROBE_TIMEOUT (5 s) are what a front end fills when its source names no interval or timeout. The spec has no default.
  • Prepare builds a balancer from its spec with Member::new(tag, target, probe) for each member, where target is the member’s built outbound and probe its probe_target, and then Balancer::new(members, strategy). Balancer::new refuses an empty list (a balancer needs at least one outbound), which validation has already refused as balancer <tag> has no members.

Probing, selection and what happens when every member is down are on Outbounds.

supervisor/src/topology/spec_plan/dns.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DnsSpec {
/// One resolver for everything: the proxy servers the outbounds dial and
/// the destinations this host reaches itself. Applications' DNS is
/// routed like any other flow.
Single(Backend),
/// Resolution split by what is being looked up, with applications' DNS
/// answered by the supervisor itself.
Split {
/// Asked directly. They look up the proxy servers, and the
/// `through_proxy` servers' own names.
pre_proxy: Vec<Backend>,
/// Reached through the route table. They look up everything else,
/// including every query applications send. Empty means the
/// `pre_proxy` servers answer those too.
through_proxy: Vec<Backend>,
/// Answer from the system hosts file before asking any server.
use_hosts: bool,
/// Whether `AAAA` answers are returned to applications.
resolve_ipv6: bool,
},
}
protocols/src/dns/mod.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Backend {
/// The host resolver (getaddrinfo). The default.
System,
Udp(ServerAddr),
Tls { server: ServerAddr, server_name: CompactString, ca_pem: Option<Vec<u8>> },
Https { server: ServerAddr, host: CompactString, path: CompactString, ca_pem: Option<Vec<u8>> },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerAddr {
Ip(SocketAddr),
Name { host: CompactString, port: u16 },
}

build/dns.rs → build_dns turns a DnsSpec into two resolvers and, for Split, a DNS service:

Looked up Single(backend) Split
The proxy servers the outbounds dial (Dns::servers) backend the pre_proxy servers, asked directly
The destinations this process reaches itself, through freedom and inside a WireGuard tunnel (Dns::destinations) the same resolver, so the outbounds share one cache the through_proxy servers, reached through the route table; the pre_proxy servers when through_proxy is empty
Applications’ own DNS queries routed like any other flow answered by the supervisor’s DNS service from the Dns::destinations resolver of the row above; AAAA answers only when resolve_ipv6
The system hosts file read only through getaddrinfo, by Backend::System; a server backend gets no hosts entries read by the supervisor and answered from first, for both resolvers, when use_hosts

In Single, Backend::System builds Resolver::system(), and any other backend a Resolver::with_upstreams whose ResolverOptions set only socket, the supervisor’s socket policy: no hosts entries, no dialer of its own and no bootstrap. Split builds with_upstreams resolvers from its lists, the through_proxy one dialling through the plane with the pre_proxy one as its bootstrap. With use_hosts, build_dns reads the system hosts file (Hosts::system()) and gives it to both resolvers; a failure to read it fails the build as building dns failed: <error>. A server named by address is a ServerAddr: ServerAddr::new(host, port) makes an IP literal Ip and anything else Name. A CA for a DNS-over-TLS or DNS-over-HTTPS server is carried as bytes (ca_pem), like every other certificate in a spec. etemenanki-app lowers [dns] into Single (a_dns_backend_needs_what_it_names) and a subscribe file’s [dns] into Split (a_subscribe_file_splits_dns). Resolution and the service are on DNS and DNS resolver.

supervisor/src/entity/user.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserSet<U: UserId> {
pub tag: CompactString,
pub users: BTreeMap<U, UserSpec>,
}
/// One user: every credential they may present. Who they are is their key
/// in the UserSet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserSpec {
pub credentials: Credentials,
pub speed_limit: Option<std::num::NonZeroU64>,
}

Users are their own resource. On a server they change far more often than anything else, and a panel may hold many of them, so they are not part of any inbound: an inbound names a set by tag, and a set can be replaced without reconciling listeners, outbounds or routes. A set is keyed by U, so a change to one user is a change to one entry, and two sets can be compared entry by entry.

speed_limit is the payload bytes per second shared by every flow of the user, in both directions, TCP and UDP. The bucket allows a one-second burst; a transfer is charged in full after it moves, and nothing else of the user’s moves until the debt is repaid. None is unlimited, and zero cannot be written. A speed-limit change keeps the user’s sessions, because a user is admitted by credential and the credential did not change (a_speed_limit_change_keeps_the_users_sessions). The same U in two sets is one user to the supervisor, and the smallest limit any set gives applies; None in one set does not lift a limit another set gives (supervisor.rs → publish_speed_limits). The limits are published only at commit, of an apply or a user edit, so a refused one leaves the live limits as they were. Pacing is on Tracking.

supervisor/src/entity/user.rs
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Credentials {
/// For VLESS and VMess.
pub uuid: Option<Uuid>,
/// For Trojan and Shadowsocks.
pub password: Option<Secret<String>>,
/// For Shadowsocks 2022: the user's PSK, decoded.
pub ss2022_psk: Option<Secret<Vec<u8>>>,
/// For SOCKS, HTTP and Hysteria 2.
pub account: Option<Account>,
}
impl Credentials {
pub fn get(&self, kind: CredentialKind) -> Option<Credential>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CredentialKind { Uuid, Password, Ss2022Psk, Account }
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Credential {
Uuid(Uuid),
Password(Secret<String>),
Ss2022Psk(Secret<Vec<u8>>),
Account(Account),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
pub user: CompactString,
pub pass: Secret<String>,
}

A user holds at most one credential of each kind, and an inbound reads the one kind its protocol takes, so one user can be admitted by inbounds of different protocols. Credentials::get(kind) returns that one credential as a Credential, the form admission and the user tables work with.

Inbound protocol CredentialKind Field read
SOCKS, HTTP Account account
Hysteria 2 with Hysteria2UserAuth::Account Account account
Hysteria 2 with Hysteria2UserAuth::Password Password password
Trojan, Shadowsocks Password password
VLESS, VMess Uuid uuid
Shadowsocks 2022 Ss2022Psk ss2022_psk
TUN none nothing: a TUN device admits no users

Within one inbound, no two users may present the same credential of the kind it reads: users <a> and <b> present the same credential, or … the same username for accounts. Credentials of other kinds may be shared (a_shared_credential_of_another_kind_is_no_conflict). Admission, principals and what happens to a user’s sessions when their credential changes are on Users and sessions.

supervisor/src/entity/user.rs
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum UserName {
Email(CompactString),
Username(CompactString),
}
impl UserName {
pub fn as_str(&self) -> &str;
}
impl fmt::Display for UserName; // the bare name, without the variant
pub trait UserId: Clone + Eq + Ord + Hash + Debug + Send + Sync + 'static {
/// The name nodes surface for this user.
fn name(&self) -> UserName;
}
impl UserId for UserName {
fn name(&self) -> UserName { self.clone() }
}
  • UserName is what a node names a user by: the email a user is configured with, or the username of an account. It is never a secret, so it is what logs and the protocols’ own user labels carry, and what admission errors print. The derived Ord sorts every Email before every Username.
  • UserId is what a front end keys its users by: whatever it already calls them. It is the key of a UserSet and the subject of a UsageDelta. Two properties make a good one. It is not a credential, because its name() is what logs, protocol labels and usage reports carry. And it stays the same when the user’s credentials change, so a password change is an edit of one entry rather than one user removed and another added. The code’s doc puts it as tied to the user’s auth info, either derived from it or deriving it: etemenanki-app keys users by their email or account name and uses UserName itself; katana keys users by the panel’s numeric id, and Uid::name returns UserName::Username of that id in decimal.
  • Inside the supervisor, each user is identified by the supervisor’s own entity::id::UserKey, a u64 newtype that connections carry instead of U. It is unrelated to etemenanki-app’s lower::UserKey. See Users and sessions and Usage.

etemenanki-app keys a Trojan, Shadowsocks, Shadowsocks 2022, VLESS or VMess user by its email, a SOCKS or HTTP account by its user name, and a Hysteria 2 user by its email when it has one and its user name otherwise (users_are_keyed_by_the_name_the_config_gives_them). Because the key is a name and never a credential, a user whose password changes is the same entry of its set with new credentials, rather than one user removed and another added (a_user_keeps_its_key_when_its_password_changes).

supervisor/src/policy.rs
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum UserRemovalPolicy {
Keep,
#[default]
Close,
CloseAfter(Duration),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DrainPolicy {
#[default]
Keep,
Close,
CloseAfter(Duration),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Policies {
pub user_removal: UserRemovalPolicy,
pub drain: DrainPolicy,
}
Policy Applies to Default, and why Override Resolved by
UserRemovalPolicy A user’s live sessions when the user is removed from a set, or their credentials change Close: revoking access is meant to take effect InboundSpec.user_removal supervisor.rs → removal_policy: the inbound’s override, else Policies.user_removal
DrainPolicy The live flows of an outbound version that is removed, or replaced by a new version Keep: a TCP flow cannot migrate to the successor mid-stream, so closing it would only turn a config change into dropped connections OutboundSpec.drain topology/spec_plan/plan.rs → plan (its drain_policy closure): the override of the outbound with that tag in the desired spec, or in the old spec when the tag was removed; else Policies.drain

What each variant has the removal or the drain do:

Variant UserRemovalPolicy DrainPolicy
Keep Closes none of the user’s live sessions Closes none of the old version’s flows; new flows go to the successor
Close Closes them at once Closes them at once (drain → Target::close_flows)
CloseAfter(grace) Closes those still open after grace Closes those still open after grace, from a task on the supervisor’s task tracker that the root token cancels at shutdown
  • A removed outbound drains under its own override from the old spec (a_removed_outbound_drains_under_its_own_policy). A changed outbound drains its old version under the desired spec’s override or default (a_drain_takes_the_supervisor_default_unless_the_outbound_overrides_it).
  • plan does not compare Spec.policies, so a spec that changes only the defaults builds nothing. An apply resolves its drains and removals with the desired spec’s defaults, and a user edit with the running spec’s, so new defaults govern the apply that brings them and every later apply and user edit.
  • The overrides are fields of InboundSpec and OutboundSpec, so changing one is a change of that inbound or outbound: the inbound’s handler is rebuilt and swapped, and the outbound is rebuilt as a new version.
  • Neither front end sets a policy. etemenanki-app and katana lower Policies::default() with no overrides, and only an embedder that builds its own spec changes them.

How drains and removals run is on Reconcile and Users and sessions.

The supervisor keeps the spec it last applied as RunningState.spec. An apply does not diff two configs; it compares two specs. Because every value is typed, a difference between two specs is a difference in meaning, never in spelling, and the planner decides what to keep without knowing which front end wrote either spec. plan compares resource by resource, not the spec as a whole:

Resource Matched across applies by Compared When unequal
DNS there is one the whole DnsSpec Build(Dns), and every outbound but a blackhole is rebuilt too
Outbound tag the whole OutboundSpec, drain included Build of the next version; the old version gets a Drain step
Balancer tag the whole BalancerSpec, and whether any member was rebuilt Build; health is learned afresh (a_balancer_is_rebuilt_exactly_when_one_of_its_members_is)
Route there is one the whole RouteSpec Build(Route), and nothing else is rebuilt (a_route_change_alone_rebuilds_only_the_route_table)
User set tag the whole UserSet Build(UserSet), which the apply lists in ApplyReport.built. Prepare does not act on that step: it recomputes every inbound’s admissions and rebuilds the user table of each running inbound whose admitted users, principals or credentials differ (supervisor.rs → same_admissions). A set change that leaves them equal, such as a speed_limit change or a user without the credential kind the inbound reads, rebuilds no table.
Inbound bind the whole InboundSpec, tag included On the same bind: Build and SwapHandler. On a new bind: Bind and Build. Disrupt is added for a TUN inbound that changes on the same bind, for a TUN inbound on a new bind whose tag named a TUN inbound before (the device changed), and for a Hysteria 2 obfs change.
Policies not compared — read when drains and removals are resolved

A running inbound whose tag no desired inbound carries gets CloseSessions, so renaming an inbound on the same bind swaps its handler and closes the sessions of its old tag (an_inbound_renamed_on_the_same_bind_swaps_and_closes_the_old_tags_sessions); see Reconcile. Any rebuilt DNS, outbound, balancer or route, or a removed outbound or balancer, adds one PublishPlane (removing_a_balancer_alone_republishes_the_plane). An unchanged spec plans only Reuse steps and publishes nothing (an_unchanged_spec_reuses_everything_and_publishes_nothing). The full planning rules are on Reconcile.

Equality is structural: every spec type derives PartialEq and Eq, except two whose equality is written by hand. Secret is listed as well: it derives equality, so a changed secret is a changed spec, and hides only its Debug.

Type Impl Equal when Why Pinned by
Secret<T> derived The contents are equal A changed secret must be a changed spec a_user_keeps_its_key_when_its_password_changes, which asserts the user’s entry differs
SuppliedTun by hand Same raw descriptor number and same mtu OwnedFd has no equality, and an open descriptor’s number is unique while it is open none
DomainRegexSet by hand Same source patterns, in the same order RegexSet has no equality; a rule is the rule it was spelled as domain_regex_equality_is_by_pattern

Typing removes differences of spelling before the comparison: a UUID parses to the same Uuid in upper or lower case, a base64 key decodes to the same bytes, a method name becomes one enum value, and etemenanki-app lower-cases route domains (Example.COM becomes DomainSuffix("example.com") in a_server_config_lowers_to_its_spec).

Order matters where the spec says it does:

List Order compared by the plan Why
Spec.inbounds, outbounds, balancers, user_sets no each is found by its bind or tag
UserSet.users no a BTreeMap
RouteSpec.rules yes the first match wins
RouteRuleSpec.matchers yes, though matching does not depend on it Vec equality
BalancerSpec.members yes priority order
DnsSpec::Split server lists yes Vec equality
Ss2022.identity_psks yes the identity chain’s order
WireguardSpec.addresses, DeviceSpec.addresses and routes yes Vec equality
  • Determinism. Lowering the same input twice must give equal specs (lowering_twice_gives_the_same_spec), or every apply rebuilds what did not change. A random value, a timestamp or a HashMap iteration order inside a spec defeats the planner.
  • Consistent fields the builder ignores. The builder overrides ProxyUpstream.server.network with TCP, and Hysteria2OutboundSpec.server.network and WireguardSpec.endpoint.network with UDP. A spec that differs only there is unequal, so the outbound is rebuilt although nothing about it changed. etemenanki-app always writes the network the builder uses (semantic_problems_are_left_to_the_supervisor asserts DialNetwork::Udp for both a WireGuard endpoint and a Hysteria 2 server).
  • Certificate and key bytes. A front end reads certificates, keys and CA bundles itself and puts their bytes in the spec: ServerTlsSpec, VerifyMode::CustomCa and a DNS backend’s ca_pem.
  • Users by name. An inbound names its set by tag, so a user change leaves every InboundSpec equal and touches neither the plane nor the listeners (a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds).
Invariant Enforced by Pinned by
Two specs compare with ==, resource by resource Derived equality, plus the hand-written impls of SuppliedTun and DomainRegexSet an_unchanged_spec_reuses_everything_and_publishes_nothing, lowering_twice_gives_the_same_spec
A value wrapped in Secret never reaches Debug output Secret’s hand-written Debug a_spec_never_prints_a_credential
Tags are unique within their namespace; outbounds and balancers share one validate → unique_tags and the collision check tags_are_unique_within_each_kind, a_balancer_may_not_share_an_outbounds_tag, an_inbound_may_share_an_outbounds_tag
No spec tag is empty unique_tags no_tag_may_be_empty
Every tag reference resolves, and a balancer member is an outbound validate a_route_rule_must_name_a_known_outbound_or_balancer, the_route_default_must_name_a_known_outbound_or_balancer, a_balancer_member_must_be_a_known_outbound, a_balancer_member_may_not_be_another_balancer, an_inbound_must_name_a_known_user_set
No two inbounds share a bind validate two_inbounds_may_not_share_a_bind, the_same_port_over_udp_is_another_bind
Each protocol is served only on its kind of bind validate_inbound each_protocol_is_served_only_on_its_kind_of_bind
TLS material is present exactly when the shape layers TLS, on both sides validate_inbound, outbound_transport an_inbound_carries_tls_material_exactly_when_its_shape_layers_tls, an_outbound_carries_tls_settings_exactly_when_its_shape_layers_tls
An outbound’s shape has no gaps; an inbound’s may outbound_transport an_outbound_needs_a_ws_host_and_a_grpc_authority, an_inbound_needs_no_ws_host_or_grpc_authority
A Unix listener carries only plain TCP validate_inbound a_unix_listener_carries_only_the_plain_tcp_shape
Trojan, VLESS and VMess always admit a user set validate_inbound trojan_vless_and_vmess_need_a_user_set
A Hysteria 2 inbound has exactly one of a shared password and a user set validate_inbound hysteria2_takes_a_shared_password_or_a_user_set_but_not_both
Within an inbound, one credential has one owner validate_admission two_users_may_not_present_the_same_credential, hysteria2_usernames_collide_case_insensitively
Users are keyed by a name, never a credential etemenanki-app’s InlineUsers users_are_keyed_by_the_name_the_config_gives_them, a_user_keeps_its_key_when_its_password_changes
etemenanki-app’s lowering is faithful: it leaves the supervisor’s rules to the supervisor app/src/lower.rs → lower semantic_problems_are_left_to_the_supervisor
A spec that breaks a rule is refused before anything is planned, and changes nothing plan calls validate first an_invalid_spec_is_refused_without_a_plan, a_refused_spec_changes_nothing
An unchanged outbound is carried over with its connection plan emits Reuse an_unchanged_hysteria2_outbound_keeps_its_quic_connection_across_apply

A spec itself cannot fail: it is data with public fields. The failures around it happen in these places.

Where What fails Text
Lowering, in the front end Parsing, reading files, what a spec cannot express, and the few spec helpers that validate their input DomainRegexSet::new: invalid domain regex "<pattern>": <regex error>, or domain regex set of <n> patterns: <error> when no single pattern is at fault. Strategy::parse: unknown balancer strategy "<s>" (expected "failover" or "round_robin"). normalise_psk: shadowsocks-2022: PSK too short (<n> < <k>). etemenanki-app’s own refusals include config defines no outbounds, <ctx>: udp_idle_timeout is set but udp is not enabled and <ctx>: unknown obfs "<obfs>" (expected "salamander"). The front end reports these as its own errors.
Validation A rule the types cannot carry An ApplyError: duplicate <kind> tag <tag>, <from> references unknown <kind> <tag>, balancer <tag> has no members, balancer <tag>: outbound <member> has no upstream a TCP health probe can reach, or <resource>: <reason> for Invalid
Disruption check, after planning A plan with a Disrupt step, applied without allow_disruptive ApplyError::Disruptive: inbound <tag>: <reason>; this ends its live connections and needs allow_disruptive
Build, during prepare Construction from a valid spec: a PEM that does not parse, geo data that cannot be read, a CA that does not load, a hosts file that cannot be read ApplyError::Build: building <resource> failed: <source>, for example building inbound t failed: no certificate in PEM bundle
Binding, during prepare The listener of a new bind cannot be opened ApplyError::Bind: inbound <tag>: binding <bind> failed: <source>, with the bind in the Display form of BindSpec
The actor A supervisor that has shut down, or a user edit naming no set ApplyError::Stopped: the supervisor has shut down; user set <tag>: no such user set

validate checks in a fixed order, and the first failure is the error: user-set tags, outbound tags, balancer tags, a balancer tag that is also an outbound’s, inbound tags; then each outbound; then each balancer (not empty, every member an outbound, every member probeable); then each route rule and the route default; then each inbound in turn: its bind against the binds before it, its user set’s existence, its own fields, and the admission of its set. The rules one by one are on Validation.

The builders also turn a few specs that validation refuses into errors of their own rather than panics: missing tls, missing ws host and missing grpc authority from build/outbound.rs → transport, and missing tls and inbound <tag>: protocol and bind do not match from build/inbound.rs. A spec reaches a builder only after validate, so these are not met through apply, update or check.

Invalid and Build print the resource with entity/id.rs → Resource’s Display:

Resource Printed as
Inbound(tag) inbound <tag>
Outbound(id) outbound <tag>@v<version>
Balancer(tag) balancer <tag>
UserSet(tag) user set <tag>
Route route
Dns dns

DuplicateTag and UnknownReference print the kind with ResourceKind’s Display: inbound, outbound, balancer or user set.

A refused spec is refused whole, and the running state keeps running exactly as it was (a_refused_spec_changes_nothing). That holds for update too: the actor edits a clone, so a closure whose result is refused leaves RunningState.spec unchanged. A refused user edit stores no table and leaves the running spec as it was. etemenanki-app prints these errors after configuration invalid: under --test, and after failed to start: when the first spec is refused. The complete list of rules and texts is on Validation.

A spec spawns nothing and holds no task, so there is nothing to cancel. Dropping a spec frees memory, and closes a supplied TUN descriptor when the spec held its last reference.

Bounds the supervisor enforces on a spec:

Constant or rule Value Defined in Applies to
MIN_TUN_MTU 1280 build/validate.rs TunSource::mtu(), both sources
HY2_UDP_IDLE 2 to 600 s, inclusive build/validate.rs Hysteria2InboundSpec.udp_idle_timeout when set
MIN_SALAMANDER_PSK 4 bytes build/validate.rs ObfsSpec::Salamander.psk, on both sides
Shadowsocks 2022 key length 16 bytes for Blake3Aes128Gcm, 32 for the other two methods protocols/src/ss_2022/crypto.rs → Method::key_len Exact for the server psk and every outbound key; at least that for a user’s ss2022_psk, longer keys folded
Counts at least 1 build/validate.rs max_connections, max_circuits, max_concurrent_streams
Masquerade status 100 to 999, except 233 protocols/src/hysteria/server/masquerade.rs → Masquerade::new MasqueradeSpec.status
Shadowsocks 2022 with users an AES-GCM method protocols/src/ss_2022/users.rs → Validator::from_config, at build InboundProtocolSpec::Ss2022.method when the inbound admits users
Key sizes 32 bytes the types WireGuard private, public and pre-shared keys
reserved 3 bytes the type WireguardSpec.reserved
Speed limit 1 byte/s or more NonZeroU64 UserSpec.speed_limit

Defaults the front ends fill in. The spec has none of its own, apart from the Default of Policies, Credentials, Hysteria2UserAuth and AddressFamilyStrategy:

Constant Value Defined in Field
DEFAULT_MAX_CONNECTIONS 262,144 protocols/src/hysteria/server/config.rs Hysteria2InboundSpec.max_connections
DEFAULT_MAX_CIRCUITS 4,194,304 protocols/src/hysteria/server/config.rs Hysteria2InboundSpec.max_circuits
DEFAULT_MAX_CONCURRENT_STREAMS 102,400 protocols/src/hysteria/config.rs Hysteria2OutboundSpec.max_concurrent_streams
tun::DEFAULT_MTU 1500 protocols/src/tun/config.rs DeviceSpec.mtu, SuppliedTun.mtu
DEFAULT_UDP_IDLE_TIMEOUT 60 s protocols/src/tun/config.rs TunSpec.udp_idle_timeout
DEFAULT_MAX_FLOWS 65,536 protocols/src/tun/config.rs TunSpec.max_flows
wireguard::DEFAULT_MTU 1420 protocols/src/wireguard/config.rs WireguardSpec.mtu
DEFAULT_PROBE_INTERVAL 30 s supervisor/src/topology/balancer.rs BalancerSpec.probe_interval
DEFAULT_PROBE_TIMEOUT 5 s supervisor/src/topology/balancer.rs BalancerSpec.probe_timeout

The limits that apply at run time (live connections, handshakes, buffers) are on Serving and the pages it links to.

A new field, variant or protocol touches the spec first. On the spec side:

  1. Add the field or variant with #[derive(Debug, Clone, PartialEq, Eq)]. Type the value: an enum rather than a string, decoded bytes rather than text, bytes rather than a path. Hold a secret value in Secret. A type without Eq needs a hand-written PartialEq that says what “the same” means, as DomainRegexSet and SuppliedTun do.
  2. Put every rule about how the value fits the rest of the spec in build/validate.rs, and add a test to supervisor/tests/unit/validate.rs that breaks exactly that rule in the otherwise valid spec() fixture, plus the edge that is still accepted. A front end checks only what its own format can say and a spec cannot.
  3. Consume the value in its builder: build/inbound.rs, build/outbound.rs, build/dns.rs or build/route.rs. Most matches over the spec’s enums are exhaustive, so the compiler lists the places to extend. Two have a catch-all arm that a new variant falls into silently, and a credential kind spans several files:
    • build/validate.rs → validate_outbound picks the transport to check with _ => None, so a new outbound variant that carries a ProxyUpstream is never checked for TLS settings, a WebSocket host or a gRPC authority until it is added there. probe_target is exhaustive and needs a decision for it too.
    • build/inbound.rs → stream_transport returns None for anything it does not list, so a new stream inbound that is not added there is served over plain TCP whatever its transport says.
    • A new credential kind touches CredentialKind, Credential, the Credentials field and Credentials::get in entity/user.rs, build/users.rs → credential_kind and kind_of, the identity match in build/validate.rs → validate_admission, and the user table in build/inbound.rs → user_table.
  4. If a running listener cannot take the change in place, return a reason from topology/spec_plan/plan.rs → swap_disruption, and pin it in supervisor/tests/unit/plan.rs.
  5. Lower it in every front end: app/src/lower.rs, katana’s src/lower/, and ffi/src/client.rs when it rewrites the spec. Check that lowering the same input twice still gives equal specs.

The rest of a new stream protocol (its user table, its driver, its handshake watchdog) is listed on Serving; the rest of a new outbound on Outbounds.

app/tests/unit/lower.rs pins what etemenanki-app lowers into a spec. It is compiled into the library, so run it with cargo test -p etemenanki-app --lib. Its certificate and key files hold CERT BYTES and KEY BYTES: lowering only reads them, and whether they parse is the supervisor’s to find out.

Test Behaviour it pins
a_server_config_lowers_to_its_spec Binds, including a loopback default for an absent listen; one set per inbound named by the inbound’s tag; TLS files read as bytes; the Shadowsocks 2022 server key sized to 16 bytes and a user’s key only decoded; HTTP without accounts has no set; SOCKS on a Unix socket; the masquerade defaults filled in; the WebSocket host and the SNI taken from tls.server_name; DialNetwork::Tcp for the upstream; lower-cased domains and the matcher order; DnsSpec::Single(Backend::System); Policies::default()
lowering_twice_gives_the_same_spec Lowering is deterministic
a_spec_never_prints_a_credential The Secret-wrapped values of its fixture are absent from Debug output
users_are_keyed_by_the_name_the_config_gives_them Email keys for Trojan, Shadowsocks, VLESS and VMess users; Username keys for accounts; a Hysteria 2 user by its email when it has one
a_user_keeps_its_key_when_its_password_changes Same key, unequal UserSpec
a_user_without_the_name_it_is_keyed_by_is_refused inbound <tag>: users[<i>] has no email; … and accounts[<i>] has no user
two_users_with_one_name_are_refused Both entries are named, and no credential is shown
a_subscribe_file_splits_dns, a_dns_backend_needs_what_it_names DnsSpec::Split from a subscribe file; DnsSpec::Single with a server backend
semantic_problems_are_left_to_the_supervisor The lowering is faithful: two inbounds with one tag, a TUN MTU of 1000, a Hysteria 2 inbound with both a password and users and an out-of-range idle timeout and zero circuits, two Trojan users with one password, a WireGuard family without an address of it, a Hysteria 2 outbound with an empty password and zero streams, and a rule and a balancer naming a missing outbound all reach the spec as written, for the supervisor to refuse
a_unix_listen_lowers_to_a_plain_stream A Unix listener gets StreamShape::Tcp without TLS
a_tun_inbound_lowers_to_its_device TunSource::Create with the device’s name, addresses and routes; TunSpec with the defaults
hysteria2_answers_to_its_aliases hysteria2, hysteria and hy2 all lower to Hysteria2; the outbound gets DEFAULT_MAX_CONCURRENT_STREAMS
syntax_errors_are_refused What only the TOML front end can check is refused while lowering, among them an invalid UUID, an unknown method or stream network, TLS settings on a plain stream, a missing port or certificate file, a UDP idle timeout with UDP off, obfs_password without obfs, an unknown obfs, and a config with no outbounds (config defines no outbounds)

supervisor/tests/unit/validate.rs holds one test per rule, each breaking exactly that rule in an otherwise valid spec. Its fixture spec() is a SOCKS listener on 127.0.0.1:1080, one freedom outbound tagged direct as the route default, and DnsSpec::Single(Backend::System); with_inbound and with_outbound add one resource to it. TLS material is any bytes, because validation never parses PEM. Run it with cargo test -p etemenanki-supervisor --lib. The Invariants table mixes files: its rows from “Tags are unique” to “Within an inbound, one credential has one owner” name tests of this file, and the others name tests of app/tests/unit/lower.rs, supervisor/tests/unit/plan.rs and supervisor/tests/hot_swap.rs. This file also holds:

Test Behaviour it pins
the_base_spec_is_valid The fixture passes
a_route_may_name_a_balancer A balancer tag is a valid route target
a_balancer_needs_a_member, a_balancer_member_needs_an_upstream_a_tcp_probe_can_reach EmptyBalancer; UnprobeableMember for freedom, blackhole, Hysteria 2 and WireGuard members
a_hysteria2_shared_password_may_not_be_empty, a_hysteria2_udp_idle_timeout_is_between_2_and_600_seconds, hysteria2_serves_at_least_one_connection_and_one_circuit The Hysteria 2 inbound’s ranges, including 2 and 600 accepted, 1 and 601 refused, and None accepted
a_salamander_key_is_at_least_4_bytes_on_either_side, a_masquerade_may_not_answer_with_the_success_status 3 bytes refused and 4 accepted on both sides; 233 refused and 404 accepted
a_tun_device_mtu_is_at_least_1280, socks_over_a_unix_socket_needs_a_udp_bind_to_serve_udp 1279 refused, 1280 accepted; SOCKS UDP on a Unix socket only with udp_bind
a_shadowsocks_2022_server_key_is_sized_to_its_method, shadowsocks_2022_client_keys_are_sized_to_their_method, a_shadowsocks_2022_user_key_must_normalise_to_the_method Exact server and client keys; a longer user key folded, a shorter one refused
a_wireguard_address_family_needs_an_address_of_that_family, a_hysteria2_client_needs_a_password_and_a_stream The outbound rules
a_shared_credential_of_another_kind_is_no_conflict, a_hysteria2_username_may_not_hold_a_colon, a_hysteria2_password_may_not_be_empty Admission rules that depend on the credential kind

Other tests that pin spec semantics:

File Test Behaviour it pins
supervisor/tests/unit/plan.rs an_unchanged_spec_reuses_everything_and_publishes_nothing Equal specs plan only Reuse
supervisor/tests/unit/plan.rs an_invalid_spec_is_refused_without_a_plan plan refuses a spec that breaks a rule before it plans anything
supervisor/tests/unit/plan.rs a_user_set_change_alone_touches_neither_the_plane_nor_the_inbounds Users as their own resource
supervisor/tests/unit/plan.rs a_route_change_alone_rebuilds_only_the_route_table The route is compared as a whole, apart from the outbounds
supervisor/tests/unit/plan.rs a_balancer_is_rebuilt_exactly_when_one_of_its_members_is, removing_a_balancer_alone_republishes_the_plane Balancer equality includes its members’ rebuilds; a removal alone publishes the plane
supervisor/tests/unit/plan.rs an_inbound_renamed_on_the_same_bind_swaps_and_closes_the_old_tags_sessions An inbound is matched by bind; its old tag’s sessions close
supervisor/tests/unit/plan.rs a_tun_settings_change_on_the_same_device_swaps_and_disrupts A TUN change is a Disrupt step
supervisor/tests/unit/plan.rs a_drain_takes_the_supervisor_default_unless_the_outbound_overrides_it, a_removed_outbound_drains_under_its_own_policy How DrainPolicy is resolved
supervisor/tests/unit/plan.rs a_dns_change_rebuilds_every_outbound_but_a_blackhole Every outbound but a blackhole holds the resolvers
supervisor/tests/unit/plan.rs a_hysteria2_masquerade_change_swaps_without_disrupting, a_hysteria2_obfs_change_swaps_and_disrupts Which Hysteria 2 fields a running listener takes in place
supervisor/tests/hot_swap.rs an_unchanged_hysteria2_outbound_keeps_its_quic_connection_across_apply An equal outbound spec keeps its built outbound
supervisor/tests/hot_swap.rs a_speed_limit_change_keeps_the_users_sessions speed_limit is not part of admission
supervisor/tests/hot_swap.rs a_refused_spec_changes_nothing A refused spec leaves what runs untouched
environment/tests/unit/routing.rs domain_regex_equality_is_by_pattern, invalid_domain_regex_is_rejected DomainRegexSet equality and its refusal
ffi/tests/proxy.rs a_server_inbound_is_refused, only_a_local_socks_inbound_is_served refuse_servers: a VLESS inbound is refused, and SOCKS is served only on loopback
ffi/tests/proxy.rs a_tun_device_carries_tcp_and_udp_to_the_outbound supply_tun adds a TUN inbound tagged tun to a config that has none

SuppliedTun’s equality, BindSpec’s Display and Secret’s Debug have no test of their own in the supervisor crate; the last is covered through the app’s a_spec_never_prints_a_credential. Add a test when you change any of them. See Testing for the test layout.