Skip to content

Subscribe files

Source files: 31 · checked against Etemenanki 555b7df
  • Etemenanki/subscribe/Cargo.toml
  • Etemenanki/subscribe/src/lib.rs
  • Etemenanki/subscribe/src/proxy_servers.rs
  • Etemenanki/subscribe/src/route.rs
  • Etemenanki/subscribe/src/dns.rs
  • Etemenanki/subscribe/example-subscribe.toml
  • Etemenanki/subscribe/tests/unit/subscribe.rs
  • Etemenanki/app/src/subscribe.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/transport.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/src/routes.rs
  • Etemenanki/app/src/api.rs
  • Etemenanki/ffi/src/proxy.rs
  • Etemenanki/ffi/src/error.rs
  • Etemenanki/protocols/src/dns/mod.rs
  • Etemenanki/protocols/src/dns/hosts.rs
  • Etemenanki/protocols/src/hysteria/config.rs
  • Etemenanki/protocols/src/wireguard/config.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/supervisor/src/build/dns.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/app/tests/unit/subscribe.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/unit/lower.rs
  • Etemenanki/app/tests/unit/api.rs
  • Etemenanki/app/tests/integration/e2e_api.rs
  • Etemenanki/ffi/tests/proxy.rs

A subscribe file is what a provider hands an Etemenanki client: the proxy servers it offers (nodes), the DNS setup it suggests, and route groups that say which traffic goes to which node. It is a TOML file of its own, separate from the app’s config. The etemenanki-subscribe crate defines its format and nothing else. etemenanki-app reads the file that the config’s [subscribe] section names and merges it into the config before lowering. The supervisor never sees a subscribe file, only the outbounds, route rules and DNS spec it became.

This page is for contributors who change the format crate or app/src/subscribe.rs. The rest of the config schema, Sources and lowering in general are on etemenanki-app: from TOML to a spec. How the files are reloaded is on etemenanki-app: running, reloading and shutting down, and how a client switches a group’s node is on The REST API (etemenanki-webclient). The operator’s guide to subscribe files is a separate page.

Component File → symbol Owns Leaves to others
Format subscribe/src/lib.rs → Subscribe, with the modules proxy_servers, dns and route The types, their serde shape, refusing unknown and missing keys, writing a file back with toml::to_string Every check that spans entries: unique names, an inherit naming a real group, a pick naming a node. Parsing DNS URLs. Any I/O.
Reading app/src/config.rs → read_sources, sources_from, sources_given Putting the file’s bytes into Sources beside the config’s bytes Parsing the file
Merge app/src/subscribe.rs → parse, apply Parsing the bytes; the cross-entry checks; rewriting the parsed Config: nodes into outbounds, groups into rules, [dns] into Config::subscribe_dns Strings with a syntax of their own (UUIDs, keys, PSKs, CIDRs, ports, regexes, DNS URLs)
Built-ins app/src/subscribe.rs → ensure_builtins Creating direct and blackhole when a route names them, with or without a subscribe file —
Lowering app/src/lower.rs → lower, lower_dns, lower_route, lower_outbound Turning the merged Config into a Spec. A subscribe [dns] becomes DnsSpec::Split. Parsing UUIDs, WireGuard keys, Shadowsocks 2022 PSKs (form and length), CIDRs, ports, regexes and DNS URLs. Tag references, TLS material, the Salamander key length, building resolvers and the route table, which are the supervisor’s (Validation and apply errors)

The merge only touches the client side. Every [[inbound]] stays the config’s, and so do the config’s own outbounds and balancers. apply does no I/O and starts nothing. It rewrites the Config value that lower then lowers as it would lower any config.

subscribe/Cargo.toml names the crate etemenanki-subscribe at version 0.1.1. It is published to a private Cargo registry. Its only dependencies are serde and toml: it depends on no other workspace crate. In the workspace, only etemenanki-app depends on it (requirement 0.1.0). The FFI reaches it through the app.

subscribe/src/lib.rs denies clippy::unwrap_used, clippy::expect_used and clippy::panic with #![deny(...)], and lifts the three lints again for tests with #![cfg_attr(test, allow(...))], since tests exercise known-good inputs. The crate’s own documentation gives the parse and write calls: toml::from_str::<Subscribe> and toml::to_string.

Derive Types
Clone, PartialEq, Eq, Serialize, Deserialize Every type in the crate
Copy The four enums without data: VmessSecurity, ShadowsocksMethod, ProxyServerIpv6DnsPolicy, Network
Default Subscribe (empty), Tls (no name, verified), VmessSecurity (Auto), RouteGroupInherit (Default). Stream implements it by hand, as plain TCP.

The app’s SubscribeConfig, which is not part of the crate, derives only Deserialize, Clone and PartialEq.

This file uses all three parts, with a node for every protocol except SOCKS and HTTP, and placeholder values:

sub.toml
[[proxy_server]]
name = "vless-ws"
server = "proxy.example.com"
port = 443
protocol = "vless"
id = "11111111-2222-3333-4444-555555555555"
stream = { network = "ws", path = "/v", tls = { server_name = "cdn.example.com" } }
[[proxy_server]]
name = "vmess-grpc"
server = "proxy.example.com"
port = 443
protocol = "vmess"
id = "11111111-2222-3333-4444-666666666666"
security = "chacha20-poly1305"
stream = { network = "grpc", service_name = "GunService", tls = {} }
[[proxy_server]]
name = "trojan-tls"
server = "proxy.example.com"
port = 443
protocol = "trojan"
password = "replace-with-a-long-random-password"
stream = { network = "tcp", tls = {} }
[[proxy_server]]
name = "ss-2022"
server = "203.0.113.7"
port = 8388
protocol = "shadowsocks"
method = "2022-blake3-aes-128-gcm"
password = "AAAAAAAAAAAAAAAAAAAAAA=="
[[proxy_server]]
name = "hy2"
server = "proxy.example.com"
port = 8443
protocol = "hysteria2"
password = "replace-with-a-long-random-password"
obfs = { type = "salamander", password = "replace-with-a-long-random-password" }
[[proxy_server]]
name = "wg"
server = "198.51.100.1"
port = 51820
protocol = "wireguard"
private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
peer_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
address = ["192.0.2.2", "2001:db8::2"]
reserved = [1, 2, 3]
[dns]
pre_proxy = ["udp://192.0.2.53:53"]
through_proxy = ["https://198.51.100.53/dns-query", "tls://203.0.113.53:853#dns.example.com"]
use_hosts = true
connect_ipv6 = "preferred"
resolve_ipv6 = true
[[route_group]]
name = "Ads"
inherit = "blackhole"
matches = [{ geosite = ["category-ads-all"] }]
[[route_group]]
name = "Streaming"
inherit = { group = "Proxy" }
matches = [
{ geosite = ["netflix"] },
{ domain_suffix = ["video.example.com"] },
]
[[route_group]]
name = "Local"
inherit = "direct"
matches = [
{ geoip = ["private"] },
{ cidr = ["192.0.2.0/24"] },
]
[[route_group]]
name = "Proxy"
matches = [
{ domain_full = ["api.example.com"] },
{ port = ["443", "8000-9000"] },
{ network = ["udp"] },
]

The Shadowsocks 2022 key here is a 16-byte placeholder, the length 2022-blake3-aes-128-gcm needs. A real one comes from openssl rand -base64 16 (32 bytes, openssl rand -base64 32, for the other two 2022 methods). A real WireGuard private key comes from wg genkey, and its public key from wg pubkey. With the config in the worked example, this file passes --test with the pinned binary once the geodata paths point at real files.

The crate ships its own example, subscribe/example-subscribe.toml, with four groups of the same shape (its third group is Domestic, where this file has Local), seven nodes (one for every protocol except HTTP), and every inherit form. Both test suites read it with include_str!.

subscribe/src/lib.rs
#[serde(deny_unknown_fields)]
pub struct Subscribe {
#[serde(default, rename = "proxy_server", skip_serializing_if = "Vec::is_empty")]
pub proxy_servers: Vec<ProxyServer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns: Option<DnsConfig>,
#[serde(default, rename = "route_group", skip_serializing_if = "Vec::is_empty")]
pub route_groups: Vec<RouteGroup>,
}
fn is_default<T: Default + PartialEq>(value: &T) -> bool;
  • All three fields carry #[serde(default)], so each part is optional and an empty file parses into Subscribe::default(), which the struct also derives. There is no container-level #[serde(default)].
  • deny_unknown_fields refuses any other top-level table, for example a mistyped [dsn].
  • is_default is the crate-private helper behind every skip_serializing_if = "is_default". It keeps default values out of a written file “so it stays as short as a hand-written one”.
subscribe/src/proxy_servers.rs
pub struct ProxyServer {
/// What a user sees and picks for a route group. Unique within the file.
pub name: String,
/// Host name or IP literal.
pub server: String,
pub port: u16,
#[serde(flatten)]
pub protocol: Protocol,
}
#[serde(tag = "protocol", rename_all = "snake_case")]
pub enum Protocol {
Vless(Vless),
Vmess(Vmess),
Trojan(Trojan),
Shadowsocks(Shadowsocks),
Socks(Socks),
Http(Http),
Hysteria2(Hysteria2),
Wireguard(Wireguard),
}

A node is one [[proxy_server]] table. The keys every node has (name, server, port) sit in the same table as the keys of its protocol. protocol picks the Protocol variant, and that variant reads the rest of the table.

ProxyServer cannot carry deny_unknown_fields, because serde does not allow it together with flatten. Unknown keys are refused anyway: serde passes every key that ProxyServer does not claim down to the protocol struct, and every protocol struct denies unknown fields. So a misspelt pasword, or an id on a Trojan node, fails the parse. The test a_mistyped_or_missing_key_is_rejected pins both cases.

The accepted protocol values are exactly the eight variant names: vless, vmess, trojan, shadowsocks, socks, http, hysteria2 and wireguard. The app’s outbound aliases (hy2, hysteria) are not accepted here, and there is no freedom or blackhole node. direct and blackhole are built-in targets of the merge instead (below).

Key names follow the app’s [[outbound]] settings, so a node reads like the outbound it becomes.

The variants’ structs and the two cipher enums, with the serde attributes on fields left out:

subscribe/src/proxy_servers.rs
#[serde(deny_unknown_fields)]
pub struct Vless { pub id: String, pub stream: Stream }
#[serde(deny_unknown_fields)]
pub struct Vmess { pub id: String, pub security: VmessSecurity, pub stream: Stream }
#[serde(deny_unknown_fields)]
pub struct Trojan { pub password: String, pub stream: Stream }
#[serde(deny_unknown_fields)]
pub struct Shadowsocks { pub method: ShadowsocksMethod, pub password: String, pub stream: Stream }
// `Http` has the same three fields.
#[serde(deny_unknown_fields)]
pub struct Socks { pub user: Option<String>, pub pass: Option<String>, pub stream: Stream }
#[serde(deny_unknown_fields)]
pub struct Hysteria2 {
pub password: String,
pub server_name: Option<String>,
pub allow_insecure: bool,
pub obfs: Option<Hysteria2Obfs>,
}
#[serde(deny_unknown_fields)]
pub struct Wireguard {
pub private_key: String,
pub peer_public_key: String,
pub preshared_key: Option<String>,
pub address: Vec<IpAddr>,
pub mtu: Option<usize>,
pub keepalive: Option<u16>,
pub reserved: Option<[u8; 3]>,
}
pub enum VmessSecurity {
#[default]
#[serde(rename = "auto")]
Auto,
#[serde(rename = "aes-128-gcm")]
Aes128Gcm,
#[serde(rename = "chacha20-poly1305")]
Chacha20Poly1305,
}
pub enum ShadowsocksMethod {
#[serde(rename = "aes-128-gcm")]
Aes128Gcm,
#[serde(rename = "aes-256-gcm")]
Aes256Gcm,
#[serde(rename = "chacha20-poly1305", alias = "chacha20-ietf-poly1305")]
Chacha20Poly1305,
#[serde(rename = "xchacha20-poly1305", alias = "xchacha20-ietf-poly1305")]
Xchacha20Poly1305,
#[serde(rename = "2022-blake3-aes-128-gcm")]
Blake3Aes128Gcm,
#[serde(rename = "2022-blake3-aes-256-gcm")]
Blake3Aes256Gcm,
#[serde(rename = "2022-blake3-chacha20-poly1305")]
Blake3Chacha20Poly1305,
}

Every Option field carries #[serde(default, skip_serializing_if = "Option::is_none")]. Every stream, Vmess::security and Hysteria2::allow_insecure carries #[serde(default, skip_serializing_if = "is_default")]. The other fields, Wireguard::address included, have no attribute: they are required, and always written.

Protocol Key Type Required Default Notes
all name string yes Becomes the outbound’s tag. Uniqueness is checked by the merge.
all server string yes Host name or IP literal. An IPv6 literal is written bare, without brackets.
all port u16 yes
vless id string yes The user UUID, stored as written and parsed by lowering
vmess id string yes As for VLESS
vmess security string (enum) no "auto" auto (the same as aes-128-gcm), aes-128-gcm, chacha20-poly1305. Spelt exactly so: the enum has no case folding, whereas the app’s own security key is lower-cased by app/src/lower.rs → parse_security and takes AUTO.
trojan password string yes
shadowsocks method string (enum) yes aes-128-gcm, aes-256-gcm, chacha20-poly1305 (alias chacha20-ietf-poly1305), xchacha20-poly1305 (alias xchacha20-ietf-poly1305), 2022-blake3-aes-128-gcm, 2022-blake3-aes-256-gcm, 2022-blake3-chacha20-poly1305. Aliases are read, and the canonical name is written.
shadowsocks password string yes For a 2022-* method, the base64 PSK. Several PSKs joined by : (iPSK:…:uPSK) select the multi-user identity chain.
socks, http user, pass string no An account exists only with user: app/src/lower.rs → proxy_account makes none without it, so a node with pass alone dials without authentication, and --test accepts it. pass defaults to empty.
vless, vmess, trojan, shadowsocks, socks, http stream table no plain TCP See Stream and Tls
hysteria2 password string yes
hysteria2 server_name string no the node’s server SNI and the name the certificate is checked against. The default is filled in by lowering.
hysteria2 allow_insecure bool no false
hysteria2 obfs table no none { type = "salamander", password = "…" }. See Hysteria2Obfs.
wireguard private_key, peer_public_key string yes Base64 keys
wireguard preshared_key string no Base64 key
wireguard address array of IPs yes Tunnel addresses assigned to this client. Parsed as IpAddr by the format, so a malformed address fails the parse.
wireguard mtu integer no 1420 The default is protocols/src/wireguard/config.rs → DEFAULT_MTU, filled in by lowering
wireguard keepalive u16 no none Persistent keepalive, in seconds
wireguard reserved array of 3 integers no none [u8; 3]: exactly three values, each 0 to 255. The three reserved header bytes some providers use to tell clients apart.

Hysteria 2 runs over QUIC, which always uses TLS, so it has no stream and its TLS keys sit in the node table, as they do in the app’s settings. WireGuard has no stream either. Its peer endpoint is the node’s server and port, so the app’s endpoint key does not appear.

subscribe/src/proxy_servers.rs
#[serde(tag = "network", rename_all = "snake_case", deny_unknown_fields)]
pub enum Stream {
Tcp { tls: Option<Tls> },
Ws { path: Option<String>, host: Option<String>, tls: Option<Tls> },
Grpc { service_name: String, authority: Option<String>, tls: Option<Tls> },
}
impl Default for Stream {
fn default() -> Self { Stream::Tcp { tls: None } }
}
#[serde(deny_unknown_fields)]
pub struct Tls {
pub server_name: Option<String>,
pub allow_insecure: bool,
}

Every Option and bool field above carries #[serde(default, skip_serializing_if = …)], so only network and gRPC’s service_name are required.

network Keys Defaults (filled in by lowering)
tcp, or no stream at all tls Plain TCP without tls
ws path, host, tls path is /. host falls back to tls.server_name, then to the node’s server.
grpc service_name (required), authority, tls authority falls back to tls.server_name, then to the node’s server

TLS is on exactly when the tls table is present, including as an empty tls = {}. That one rule covers what the app writes as network = "tls" and as security = "tls", and a subscribe file cannot express an unknown security value at all. Inside tls, server_name is the SNI and the name checked against the certificate, and defaults to the node’s server. There is no ca_file: the certificate is checked against the system roots unless allow_insecure is true.

subscribe/src/proxy_servers.rs
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Hysteria2Obfs {
Salamander { password: String },
}

obfs is a table picked by type. salamander is the only type, and its password is required. The app’s flat obfs_password key does not exist in a subscribe file: a_mistyped_or_missing_key_is_rejected refuses a node that has it beside obfs. The password is stored as written. The format does not check its length. The supervisor refuses a key shorter than 4 bytes (supervisor/src/build/validate.rs → MIN_SALAMANDER_PSK) when the outbound is built.

subscribe/src/dns.rs
#[serde(deny_unknown_fields)]
pub struct DnsConfig {
pub pre_proxy: Vec<String>,
pub through_proxy: Vec<String>,
pub use_hosts: bool,
pub connect_ipv6: ProxyServerIpv6DnsPolicy,
pub resolve_ipv6: bool,
}
#[serde(rename_all = "snake_case")]
pub enum ProxyServerIpv6DnsPolicy {
Required,
Preferred,
Tolerated,
Forbidden,
}

A provider with no suggestion leaves [dns] out. A provider that writes [dns] spells out every key, so the client never has to guess what an omitted key was meant to be. No field has #[serde(default)], and DnsConfig has no Default. A [dns] table that misses any of the five keys fails the parse.

Key Meaning
pre_proxy Servers queried directly, before any proxy is up. They resolve the proxy servers’ own host names and the host names of the through_proxy servers. May be empty.
through_proxy Servers queried through the proxy. May be empty.
use_hosts Answer from the system hosts file before asking any server
connect_ipv6 Which address family reaches a proxy server whose name has both A and AAAA records: required (only IPv6; a server with no AAAA record cannot be reached), preferred (IPv6 when both exist), tolerated (IPv4 when both exist), forbidden (only IPv4; AAAA records are ignored)
resolve_ipv6 Whether AAAA answers are returned to the applications behind the client

The server strings are stored as written. The app parses them while lowering, with protocols/src/dns/mod.rs → Backend::from_url:

Form Server Default port
"system" The host resolver —
"udp://192.0.2.53:53" Plain DNS. A # fragment is refused. 53
"tls://203.0.113.53:853#dns.example.com" DNS over TLS. The fragment is the TLS server name, and may not be empty. Without one, the host is the name; for an IP host, its text. 853
"https://198.51.100.53/dns-query" DNS over HTTPS. A URL without a path gets /dns-query. An authority with userinfo (user@…) or without a host is refused: dns: "<url>" has no usable host. 443

The host in every form is host, host:port, [v6] or [v6]:port, so an IPv6 address is bracketed, as in udp://[2001:db8::53]:53. That differs from a node’s server, which takes an IPv6 literal bare. A bare IPv6 host fails, because the text after its first colon is read as the port: dns: server "udp://2001:db8::53": has an invalid port.

RouteGroup, RouteGroupInherit and RouteMatch

Section titled “RouteGroup, RouteGroupInherit and RouteMatch”
subscribe/src/route.rs
#[serde(deny_unknown_fields)]
pub struct RouteGroup {
pub name: String,
#[serde(default, skip_serializing_if = "is_default")]
pub inherit: RouteGroupInherit,
#[serde(default)]
pub matches: Vec<RouteMatch>,
}
#[serde(rename_all = "snake_case")]
pub enum RouteGroupInherit {
#[default]
Default,
#[serde(rename = "blackhole")]
BlackHole,
Direct,
Group(String),
}
#[serde(rename_all = "snake_case")]
pub enum RouteMatch {
DomainFull(Vec<String>),
DomainSuffix(Vec<String>),
DomainKeyword(Vec<String>),
DomainRegex(Vec<String>),
Cidr(Vec<String>),
Port(Vec<String>),
Network(Vec<Network>),
Geosite(Vec<String>),
Geoip(Vec<String>),
}
#[serde(rename_all = "snake_case")]
pub enum Network { Tcp, Udp }

Each group sends the traffic it matches to one node, which the user picks in the client. Any [[proxy_server]] can be picked. Groups are tried top to bottom and the first match wins. Traffic that no group matches goes to the default node.

  • name is what the client shows. It is unique within the file, which the merge checks.

  • inherit is what the group uses until the user picks a node for it:

    Written Variant The group’s traffic goes to
    absent, or inherit = "default" Default The default node
    inherit = "direct" Direct direct, without a proxy
    inherit = "blackhole" BlackHole blackhole: connections are dropped silently
    inherit = { group = "Proxy" } Group("Proxy") Whatever the group Proxy goes to
  • matches is a list of one-key tables. The group matches a connection when any one of them matches, and each one matches when any of its values does. Absent is an empty list, which matches nothing.

Kind Values Matches
domain_full Domains The exact domain
domain_suffix Domains The domain or any of its subdomains
domain_keyword Substrings A substring of the domain
domain_regex Patterns An unanchored regex over the lower-cased domain. Lookaround and backreferences are not supported.
cidr CIDRs, "192.0.2.0/24" The destination address. Only IP destinations match.
port "443" or a range "1000-2000" The destination port
network "tcp", "udp" The flow’s network
geosite "code" or "code@attr" A geosite list
geoip "code", or "!code" for the addresses outside it A GeoIP list

The kinds and value syntax are those of the app’s [[route.rule]] matchers, see Route model. The app’s inbound_tag and source_cidr are left out, because they depend on the client’s own setup, which a provider cannot know. network values are enum variants, so anything but tcp or udp fails the parse. The other values are strings that the format does not check: lowering parses the CIDRs, ports and regexes and lower-cases the domains, and the supervisor resolves the geo codes when it builds the route (below).

Rule Where Effect
deny_unknown_fields Subscribe, each protocol struct, Tls, DnsConfig, RouteGroup, and the internally tagged Stream and Hysteria2Obfs A mistyped key is an error, not a silently dropped setting, as in the app config
No deny_unknown_fields, flatten instead ProxyServer Unknown node keys reach the protocol struct, which refuses them
Internally tagged enums Protocol by protocol, Stream by network, Hysteria2Obfs by type The tag key sits beside the variant’s keys
Externally tagged enums, no deny_unknown_fields RouteGroupInherit, RouteMatch, and the string enums Network, VmessSecurity, ShadowsocksMethod, ProxyServerIpv6DnsPolicy A unit variant is a string ("direct"); a variant with data is a one-key table ({ group = "Proxy" }, { port = ["443"] }). An unknown variant fails the parse by itself.
Every key required DnsConfig A partial [dns] fails the parse
skip_serializing_if The two top-level arrays when empty, None, and default values through is_default A written file omits defaults. Every other vector is always written: Wireguard::address, pre_proxy, through_proxy, the value lists inside RouteMatch, and RouteGroup::matches (as matches = [] when empty).
Aliases ShadowsocksMethod chacha20-ietf-poly1305 and xchacha20-ietf-poly1305 are read; the canonical names are written
Fixed-size and typed values port: u16, keepalive: Option<u16>, reserved: Option<[u8; 3]>, address: Vec<IpAddr>, mtu: Option<usize> Out-of-range numbers and malformed addresses fail the parse

When a parse fails on one of a node’s protocol keys, the position in the error’s first line is the node’s [[proxy_server]] header, not the key’s line. A missing [dns] key is reported at the [dns] header.

Left out Why What the node gets
ca_file (TLS and Hysteria 2) It names a file on the client, and a provider cannot know the client’s file system The system roots, unless allow_insecure
address_family It tunes the client, which is not the provider’s to set Derived from [dns].connect_ipv6 when the file has [dns], else auto
max_concurrent_streams (Hysteria 2) As above protocols/src/hysteria/config.rs → DEFAULT_MAX_CONCURRENT_STREAMS = 102,400
endpoint (WireGuard) The endpoint is the node’s server and port Built from them
security in stream The presence of tls says it —
inbound_tag, source_cidr matchers They depend on the client’s own setup —
app/src/config.rs
pub struct Config {
// …
#[serde(default)]
pub subscribe: Option<SubscribeConfig>,
// …
/// The subscribe file's `[dns]`, once `subscribe::apply` has merged the
/// file in. Never read from the config file itself.
#[serde(skip)]
pub subscribe_dns: Option<etemenanki_subscribe::dns::DnsConfig>,
}
#[serde(deny_unknown_fields)]
pub struct SubscribeConfig {
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub routes: BTreeMap<String, CompactString>,
}
impl SubscribeConfig {
pub fn describe(&self) -> String;
}
Item Meaning
path The subscribe file. A relative path is taken from the working directory, like every other path in the config. It is required when the config is loaded from disk, and not read by a front end that is handed the file’s contents (the mobile FFI).
routes [subscribe.routes]: route group name → the node that carries its traffic. The key default picks the node for traffic that no group matches. A value may name any node, outbound or balancer tag, and "direct" and "blackhole" are always available. It is a BTreeMap, so the merge checks the picks in key order.
describe() Names the file in messages: the path as written, or the subscribe file when there is no path.
Config::subscribe_dns #[serde(skip)]. A [subscribe_dns] table in the config file is an unknown key under Config’s deny_unknown_fields, so only the merge can set it.

The bytes a configuration is built from are Sources { config: Vec<u8>, subscribe: Option<Vec<u8>> }. Its three constructors decide whether a subscribe file is part of them:

Constructor Used by Subscribe file Refuses
read_sources(path) Instance::start, Instance::reload, the REST API’s reads Read from [subscribe] path when the config parses and has [subscribe] A config file that cannot be read, with the plain OS error; then as sources_from
sources_from(config) read_sources, instance::check_bytes (--test) As above, with the config bytes already in hand [subscribe] needs a path naming the subscribe file (InvalidInput); subscribe file <path>: <os error> (the kind of the OS error)
sources_given(config, subscribe) The FFI’s Proxy::start and Proxy::reload The bytes handed in; any path is not read the config has a [subscribe] section, but no subscribe file was given; a subscribe file was given, but the config has no [subscribe] section to pick its routes in (both InvalidInput)

A config that does not parse is still a set of sources: the constructors return its parse error beside the bytes, and effective reports it. The agreement check of sources_given runs only when the config parses. The full description of Sources is on etemenanki-app: from TOML to a spec.

app/src/config.rs
pub fn effective(parsed: io::Result<Config>, sources: &Sources) -> io::Result<Config> {
let mut cfg = parsed?;
match &sources.subscribe {
Some(bytes) => {
let file = crate::subscribe::parse(bytes)?;
crate::subscribe::apply(&mut cfg, &file)?;
}
None => crate::subscribe::ensure_builtins(&mut cfg, false)?,
}
Ok(cfg)
}

effective is the only caller of parse and apply on the load path, and every load goes through it: --test, start, every reload, the REST API’s check of an edit, and the FFI. routes::Snapshot::of is the one other caller of parse, outside the load path (below). With a subscribe file effective parses and merges. Without one it still creates the built-in outbounds a route names, but in the lenient mode (below).

app/src/subscribe.rs
pub fn parse(bytes: &[u8]) -> io::Result<Subscribe>;

parse checks UTF-8 with std::str::from_utf8, then runs toml::from_str. Both errors become InvalidInput with the text prefixed by subscribe: , through the private helper invalid, which every error of the module uses.

flowchart LR
  files["config file and subscribe file"]
  sources["Sources"]
  cfg["parse_bytes: Config"]
  subfile["subscribe::parse: Subscribe"]
  apply["subscribe::apply"]
  plain["ensure_builtins, lenient"]
  lower["lower::lower"]
  sup["supervisor check or apply"]
  files -->|"read_sources, sources_from, sources_given"| sources
  sources --> cfg
  cfg -->|"subscribe bytes present"| subfile
  subfile --> apply
  apply --> lower
  cfg -->|"no subscribe bytes"| plain
  plain --> lower
  lower -->|"Spec"| sup

The merge sits between parsing and lowering, and works on Config values only. What reaches the supervisor is a Spec in which nothing marks an outbound as a node or a rule as a group.

app/src/subscribe.rs
pub const DEFAULT_ROUTE: &str = "default";
pub const DIRECT: &str = "direct";
pub const BLACKHOLE: &str = "blackhole";
pub fn apply(cfg: &mut Config, file: &Subscribe) -> io::Result<()>;

apply merges file into cfg, whose [subscribe] named it. It clones cfg.subscribe first and returns Ok(()) at once when there is none. The sources constructors never pair a subscribe file with a config that has no [subscribe]. Otherwise it runs these stages, and the first error ends the merge:

flowchart TB
  nodes["1. nodes appended as outbounds"]
  groups["2. group names checked"]
  picks["3. picks checked"]
  drop["4. config rules replaced, default taken"]
  each["5. each group resolved and turned into rules"]
  builtins["6. direct and blackhole ensured, strict"]
  dns["7. the file's DNS setup kept"]
  nodes --> groups --> picks --> drop --> each --> builtins --> dns
  1. Nodes. A set of taken tags starts with every config outbound’s tag and every balancer’s tag. Each node’s name is inserted in file order. A name already in the set fails the merge with node "<name>" is defined twice, or shares its name with a config outbound, which also covers a clash with a balancer. Otherwise node_outbound turns the node into an OutboundConfig, which is pushed after the config’s own outbounds. When the file has [dns], its connect_ipv6 is mapped to an address family once and passed to every node. Nodes come first because every later check resolves names against them.
  2. Groups. A group named default fails with a route group may not be named "default": [subscribe.routes] uses that key for the default node. A second group with a taken name fails with route group "<name>" is defined twice. The groups go into a HashMap by name for the inherit walk.
  3. Picks. Every [subscribe.routes] entry is checked, in key order. The key must be default or a group name, else [subscribe.routes] names "<key>", which is not a route group in <file>. The value must be a taken tag (a config outbound, a balancer or a node), direct or blackhole, else [subscribe.routes] "<key>" = "<tag>": no node, outbound or balancer has that name. The code comment gives the reason: a typo in either must fail the config, not leave traffic on a node the user did not choose.
  4. Rules and default. When the config has [[route.rule]] entries, a warning says that they are ignored. They are replaced wholesale in stage 5, even when the file has no groups. [route]’s geoip and geosite paths still apply. [subscribe.routes] default, when present, overwrites cfg.route.default. The default tag that inherit = "default" means is then cfg.route.default, or else the first outbound of the merged list: the config’s first outbound, or the first node when the config has none. With neither, the merge fails with no node or outbound to send unmatched traffic to. Lowering later takes the same fallback for the route table’s default.
  5. Groups into rules. For each group in file order, resolve_group finds its tag and push_group_rules appends its rules. The new list replaces cfg.route.rules.
  6. Built-ins. ensure_builtins(cfg, true) creates direct and blackhole if the rules or the default name them, and refuses a look-alike.
  7. DNS. When the file has [dns], it is cloned into cfg.subscribe_dns. If the config’s own [dns] is not the all-default table, a warning says it is ignored. cfg.dns itself is left in place; lowering does not read it while subscribe_dns is set.

The config that goes with the file above:

config.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[route]
geoip = "/etc/etemenanki/geoip.dat"
geosite = "/etc/etemenanki/geosite.dat"
[subscribe]
path = "/etc/etemenanki/sub.toml"
[subscribe.routes]
default = "vless-ws"
Proxy = "vmess-grpc"

The config has no outbounds of its own, so the merged outbounds are vless-ws, vmess-grpc, trojan-tls, ss-2022, hy2, wg, direct, blackhole. The last two are created in stage 6, direct before blackhole. The merged route:

# From group Matchers Outbound Why
1 Ads geosite = ["category-ads-all"] blackhole No pick; inherits blackhole
2 Streaming domain_suffix = ["video.example.com"], geosite = ["netflix"] vmess-grpc No pick; inherits Proxy, whose pick is vmess-grpc
3 Local cidr = ["192.0.2.0/24"], geoip = ["private"] direct No pick; inherits direct
4 Proxy domain_full = ["api.example.com"], port = ["443", "8000-9000"] vmess-grpc Picked
5 Proxy network = "udp" vmess-grpc The group’s network matcher, as a rule of its own
default vless-ws [subscribe.routes] default
app/src/subscribe.rs
fn node_outbound(node: &ProxyServer, server_family: Option<&'static str>) -> OutboundConfig;
fn address_family(policy: ProxyServerIpv6DnsPolicy) -> &'static str;
fn stream_config(stream: &Stream) -> StreamConfig;
fn tls_config(tls: &Tls) -> TlsConfig;
fn credentials(set: &mut impl FnMut(&str, toml::Value), user: Option<&str>, pass: Option<&str>);
fn shadowsocks_method(method: ShadowsocksMethod) -> &'static str;

node_outbound writes the node as the [[outbound]] table an operator would have written: tag is the node’s name, protocol is the variant’s name, and the protocol keys go into the opaque settings table as toml::Values. Lowering then reads that table with the same settings structs as for any outbound, so a node is checked exactly as a hand-written outbound is.

protocol server, port settings keys written stream
vless The node’s id stream_config
vmess The node’s id, and always security: auto, aes-128-gcm or chacha20-poly1305 stream_config
trojan The node’s password stream_config
shadowsocks The node’s method (the canonical name from shadowsocks_method), password stream_config
socks, http The node’s user and pass, each only when present (credentials) stream_config
hysteria2 The node’s password; server_name when set; allow_insecure = true only when true; obfs = "salamander" and obfs_password when obfs is set Default (none)
wireguard Unset: the peer is in endpoint private_key, peer_public_key, preshared_key when set, endpoint = "<server>:<port>", address as an array of strings, mtu, keepalive and reserved when set, and endpoint_address_family when the file has [dns] Default (none)

Every value is a toml::Value::String except these: Hysteria 2’s allow_insecure is a Boolean; WireGuard’s mtu is an Integer (mtu as i64), keepalive an Integer, and reserved an Array of three Integers.

Hysteria 2’s obfs table is written as the app’s two keys, obfs and obfs_password, which app/src/lower.rs → lower_obfs reads.

WireGuard’s endpoint is server:port as one string. An IPv6 literal is written bare, for example server = "2001:db8::1" gives endpoint = "2001:db8::1:51820": app/src/lower.rs → lower_wireguard splits the endpoint at its last colon.

Node stream App [outbound.stream]
Absent, or { network = "tcp" } StreamConfig::default(): no network, which is TCP
{ network = "tcp", tls = … } network = "tls", tls from tls_config
{ network = "ws", … } network = "ws", security = "tls" when tls is present, ws.path and ws.host as given, tls from tls_config or the default
{ network = "grpc", … } network = "grpc", security = "tls" when tls is present, grpc.service_name and grpc.authority as given, tls from tls_config or the default

tls_config copies server_name and allow_insecure and leaves ca_file, cert_file and key_file unset. app/src/transport.rs → resolve_stream then validates the stream as it does any outbound’s, and app/src/lower.rs → outbound_transport fills in the WebSocket Host, the gRPC authority and the TLS server name from tls.server_name, then server. See etemenanki-app: from TOML to a spec.

connect_ipv6 address_family written
required ipv6_only
preferred prefer_ipv6
tolerated prefer_ipv4
forbidden ipv4_only

For every node but WireGuard, the value goes into the outbound’s address_family, which lowering puts into the transport of a proxy outbound (OutboundTransportSpec::address_family) or into Hysteria2OutboundSpec::address_family: the family the node’s server is reached with. A WireGuard outbound’s own address_family governs what is reached inside the tunnel, so for a WireGuard node the value moves to settings.endpoint_address_family and address_family stays unset. The test connect_ipv6_decides_how_every_node_server_is_reached pins both.

Without [dns] in the file, nothing is written and lowering uses auto. The config’s own outbounds, and the built-in direct and blackhole, never get a family from the file.

Item Value Where
WebSocket path / app/src/transport.rs → resolve_stream
WebSocket Host, gRPC authority, TLS server name tls.server_name, else server app/src/lower.rs → outbound_transport
TLS verification VerifyMode::System, or Insecure with allow_insecure app/src/lower.rs → verify_mode
VMess security = "auto" AES-128-GCM app/src/lower.rs → parse_security
Hysteria 2 server_name The node’s server app/src/lower.rs → lower_hysteria2_outbound
Hysteria 2 concurrent streams 102,400 protocols/src/hysteria/config.rs → DEFAULT_MAX_CONCURRENT_STREAMS
WireGuard mtu 1420 app/src/lower.rs → lower_wireguard, with protocols/src/wireguard/config.rs → DEFAULT_MTU
An absent address family auto app/src/lower.rs → parse_family
app/src/subscribe.rs
pub(crate) fn resolve_group<'a>(
group: &'a RouteGroup,
groups: &HashMap<&str, &'a RouteGroup>,
picks: &BTreeMap<String, CompactString>,
default_tag: &CompactString,
resolved: &mut HashMap<&'a str, CompactString>,
) -> io::Result<CompactString>;

resolve_group returns the tag a group’s traffic goes to: the user’s pick, else what the group inherits, following { group = … } chains. It walks from the group, keeping the names it has passed in chain:

flowchart TB
  at["at = the group"]
  known{"at already resolved?"}
  picked{"at has a pick?"}
  push["push at onto chain"]
  inherit{"at.inherit"}
  exists{"named group exists?"}
  cycle{"named group already in chain?"}
  done["record the tag for at and the chain"]
  missing["error: not a route group"]
  cyc["error: inherit in a cycle"]
  at --> known
  known -->|"yes, its tag"| done
  known -->|no| picked
  picked -->|"yes, the pick"| done
  picked -->|no| push --> inherit
  inherit -->|"default, direct, blackhole"| done
  inherit -->|"group"| exists
  exists -->|no| missing
  exists -->|yes| cycle
  cycle -->|yes| cyc
  cycle -->|"no: at = that group"| known
  • default resolves to default_tag, direct to DIRECT and blackhole to BLACKHOLE.
  • An inherit naming a group that does not exist fails with route group "<name>" inherits "<other>", which is not a route group.
  • An inherit back into the chain fails with route groups inherit in a cycle: A -> B -> C -> A: the chain joined by ->, then the group it would return to. A group inheriting itself gives A -> A.
  • When the walk ends, the tag is recorded in resolved for the group it ended at and for every name in the chain. apply shares one resolved map across all groups, so a later group that reaches a resolved group stops there.
app/src/subscribe.rs
fn push_group_rules(rules: &mut Vec<RuleConfig>, group: &RouteGroup, outbound: &CompactString);
fn empty_rule(outbound: CompactString) -> RuleConfig;

The matchers inside one app rule are alternatives, which is what a group’s list is, so one rule carries them all. Every matcher kind except network is appended to that rule’s list of the same kind. When a group has two entries of one kind, their values end up in one list.

network is the exception, because a RuleConfig holds a single network value. Each distinct network value, in first-seen order and without duplicates, becomes a rule of its own with only that network, placed after the group’s main rule and sent to the same outbound. That is the same thing as one rule matching either network. The test network_matchers_become_rules_of_their_own pins the layout: a group with { port = ["443"] } and { network = ["tcp", "udp"] } gives three rules, port first, then tcp, then udp.

The main rule is pushed only when it has at least one non-network matcher. A group whose matches list is empty therefore adds no rule at all. It still exists for picks and inherits, and it is still resolved, so its inherit is checked like any other.

app/src/lower.rs → lower_route turns each merged rule into a RouteRuleSpec, as for a config’s own rules:

Matcher Lowered by Check
domain_suffix, domain_keyword, domain_full to_ascii_lowercase, once per value None
domain_regex environment/src/routing.rs → parse_domain_regexes: all of a rule’s patterns compiled together into one matcher (DomainRegexSet::new) Each pattern must compile, and so must the set
cidr app/src/lower.rs → parse_cidr, as IpCidr A valid address and prefix length
port environment/src/routing.rs → parse_port_match A number or range; a lower bound above the upper bound is refused
network app/src/lower.rs → parse_network Always tcp or udp here, since the format has already checked it
geosite, geoip Named, not loaded The supervisor loads the sets that rules use, from [route]’s paths, when it builds the route

Inside a rule the matchers are ordered by kind, not by the file’s order. That changes nothing: in environment/src/routing.rs a rule is picked when any of its matchers matches, and the first matching rule wins. So a group’s rules keep the file’s group order, and the route is first-match over the groups top to bottom. See Route model and The plane: routing each flow.

app/src/routes.rs builds the route view that the REST API and the FFI show. Snapshot::of parses the subscribe bytes a second time with subscribe::parse, outside the load path, and maps its error to ControlError::Invalid. It does not merge: the view describes the files as written. It reuses resolve_group, DEFAULT_ROUTE, DIRECT and BLACKHOLE from this module, so the view agrees with the merge:

  • the targets are the config’s own outbounds and balancers, then the nodes as TargetKind::Node, then direct and blackhole unless a target already listed carries the name;
  • the default target is the [subscribe.routes] default pick, else [route] default, else the config’s first outbound, else the first node, which is the merge’s fallback;
  • a group’s displayed target is the tag resolve_group gives, the tag the merge would send it to.

A pick is changed by editing [subscribe.routes] in the config file with toml_edit. The subscribe file itself is never written. See The REST API (etemenanki-webclient) and Mobile library (etemenanki-ffi).

app/src/subscribe.rs
pub(crate) fn ensure_builtins(cfg: &mut Config, strict: bool) -> io::Result<()>;
fn ensure_builtin(cfg: &mut Config, tag: &str, protocol: &str, strict: bool) -> io::Result<()>;

ensure_builtins makes direct and blackhole exist on demand, so a group can inherit them and a pick can name them without the config defining them. For (DIRECT, "freedom") and then (BLACKHOLE, "blackhole"), it checks whether cfg.route.default or any rule’s outbound names the tag. Only then does ensure_builtin run for it:

What carries the tag Lenient (strict = false, no subscribe file) Strict (strict = true, from apply)
Nothing A new outbound is pushed: the tag, protocol freedom or blackhole, no server, port, stream or address family, and an empty settings table The same
A config outbound of the same protocol (freedom or its alias direct; blackhole or its alias block) Kept as it is Kept as it is
A config outbound of another protocol Kept: the config wrote both the tag and the routes naming it Refused: "direct" is routed to, but the outbound tagged "direct" is "socks", not "freedom"
A balancer Kept Refused: "direct" is routed to, but it names a balancer, not a "freedom" outbound

The strict mode exists because a subscribe file’s routes were written by the provider: traffic meant to go direct must not quietly leave through a proxy that happens to be tagged direct. Strict mode checks every route that names the tag after the merge, the default included, whether the config or [subscribe.routes] set it. Nodes are outbounds by then too, and no node protocol is freedom or blackhole, so a node named direct is refused as soon as a route names direct, and likewise for blackhole.

Without a subscribe file, the lenient mode still creates the built-ins. The doc comment of direct_and_blackhole_exist_when_a_route_names_them gives the reason: the REST API can then offer both as targets and still edit nothing but [route] default.

A subscribe file with [dns] replaces the config’s [dns] completely. apply stores the table in Config::subscribe_dns, and app/src/lower.rs → lower_dns checks that field first:

app/src/lower.rs
fn lower_dns(cfg: &Config) -> io::Result<DnsSpec> {
let Some(sub) = &cfg.subscribe_dns else {
return lower_single_dns(&cfg.dns).map(DnsSpec::Single);
};
let backends = |urls: &[String]| -> io::Result<Vec<Backend>> {
urls.iter().map(|url| Backend::from_url(url)).collect()
};
Ok(DnsSpec::Split {
pre_proxy: backends(&sub.pre_proxy)?,
through_proxy: backends(&sub.through_proxy)?,
use_hosts: sub.use_hosts,
resolve_ipv6: sub.resolve_ipv6,
})
}
[dns] key In the spec
pre_proxy DnsSpec::Split::pre_proxy, each URL through Backend::from_url
through_proxy DnsSpec::Split::through_proxy, the same way
use_hosts use_hosts
connect_ipv6 Not in DnsSpec. It became each node’s address family during the merge.
resolve_ipv6 resolve_ipv6

A subscribe [dns] is the only way etemenanki-app produces DnsSpec::Split. Lowering keeps system in through_proxy (a_subscribe_file_splits_dns pins that): refusing it is left to the supervisor. supervisor/src/build/dns.rs → build_dns builds the resolvers with protocols/src/dns/mod.rs → Resolver::with_upstreams, which refuses system behind a dialer and a server written by name when there is no bootstrap resolver to look it up. system in pre_proxy is accepted. From a Split spec the supervisor builds:

Built Servers Reached Used for
Dns::servers (the direct resolver) pre_proxy; system allowed, a name refused From this host: no dialer, no bootstrap The nodes’ server names, and the names of through_proxy servers
Dns::destinations (the proxied resolver) through_proxy; system refused, a name looked up by the direct resolver Routed by the route table alone (the DNS service does not intercept these queries), so the route groups decide which target carries them The destinations that freedom outbounds, the built-in direct included, and WireGuard tunnels dial, and the DNS service’s answers. With through_proxy empty, this is the direct resolver.
Dns::service — — Applications’ port-53 flows, answered from destinations, with AAAA answers only under resolve_ipv6

So with a subscribe [dns], the names that direct traffic dials are also resolved by the through_proxy servers. With use_hosts, both resolvers answer from the system hosts file first. protocols/src/dns/hosts.rs → Hosts::system reads it when the DNS is built: a missing file is an empty table, and any other read error fails the build (building dns failed: …). The details are on Name resolution and the DNS service and DNS resolver.

Without [dns] in the file, the config’s own [dns] lowers to DnsSpec::Single as usual.

  • Sources holds the subscribe file’s bytes beside the config’s, so an edit to only the subscribe file changes the sources a reload reads. How a reload compares sources is on etemenanki-app: running, reloading and shutting down.
  • Every load that reaches the merge runs parse, merge and lowering again, then hands the spec to the supervisor: a start (the app’s or the FFI’s), --test and the REST API’s check of an edit (both through supervisor::check), and a reload whose sources changed, whether the app’s own or the FFI’s reload or set_route. No merged state is kept between loads, so the warnings of apply are logged by every load that reaches the merge, --test included. The app-reload page says when a reload stops before the merge.
  • The watcher follows the subscribe file that [subscribe] path names; see etemenanki-app: running, reloading and shutting down.
  • What an apply keeps when nodes change is decided by the supervisor; see Planning and applying a change.
  • A front end that is handed the file’s contents calls sources_given, and its [subscribe] needs no path. The FFI’s Proxy::start and Proxy::reload do this. Its set_route edits the config bytes it holds and reloads them with the subscribe bytes it already has. See Mobile library (etemenanki-ffi).
Invariant Mechanism Pinned by
A mistyped or missing key anywhere in a subscribe file fails the parse, inside nodes too deny_unknown_fields on every struct but ProxyServer and on the internally tagged Stream and Hysteria2Obfs; flatten hands unknown node keys to a denying protocol struct; unknown variants of the other enums fail by themselves; no defaults in DnsConfig subscribe/tests/unit/subscribe.rs → a_mistyped_or_missing_key_is_rejected (node key, key of another protocol, stream key, TLS key, unknown protocol, top-level table, DNS key, missing DNS key, Salamander without a password, flat obfs_password, group key, inbound_tag matcher)
A file written by toml::to_string reads back equal Only defaults are skipped when writing a_written_subscription_reads_back_unchanged
The example parses into the expected typed values; no stream is plain TCP Serde shape; Stream::default the_example_parses_into_typed_nodes_groups_and_dns
The server side stays the config’s; nodes follow the config’s own outbounds; direct and blackhole come last apply stage 1 pushes after the config’s outbounds; ensure_builtin pushes at the end app/tests/unit/subscribe.rs → the_file_supplies_outbounds_rules_and_dns_while_inbounds_stay
[subscribe.routes] default wins over [route] default, and the config’s own rules are gone Stage 4 the_file_supplies_outbounds_rules_and_dns_while_inbounds_stay
A group goes to its pick, else what it inherits, following group chains; rules keep the file’s group order resolve_group, groups iterated in file order a_group_goes_to_its_pick_else_what_it_inherits
Each network value is a rule of its own after the group’s other matchers push_group_rules network_matchers_become_rules_of_their_own
A pick of an unknown group or tag, a node clashing with a config outbound, an inherit cycle, an inherit of a missing group and a group named default each fail the config Stages 1 to 3, resolve_group a_mistyped_pick_or_a_broken_file_fails_the_config
A route to direct never leaves through a non-freedom outbound, or a balancer, tagged direct ensure_builtin in strict mode a_direct_tag_that_is_not_freedom_is_refused_when_routed_to (a SOCKS outbound); app/tests/unit/api.rs → an_edit_that_would_not_start_is_422_and_leaves_the_file_untouched (a balancer: the pick is refused with 422 and an error containing names a balancer)
Every node the example lowers to is an outbound the supervisor builds, together with the split DNS setup node_outbound writes the keys lowering reads every_lowered_node_builds_and_the_dns_setup_with_it (the example with its groups removed, since the test has no geodata, through supervisor::check)
connect_ipv6 decides how every node’s server is reached; WireGuard gets it as endpoint_address_family address_family, node_outbound connect_ipv6_decides_how_every_node_server_is_reached
Without a subscribe file, a route naming direct or blackhole still gets the built-in ensure_builtins(cfg, false) in effective app/tests/unit/config.rs → direct_and_blackhole_exist_when_a_route_names_them
Without a subscribe file, a config’s own outbound tagged direct keeps the tag whatever its protocol Lenient mode a_plain_configs_own_outbound_keeps_its_builtin_tag
A [subscribe] read from disk needs a path sources_from a_subscribe_section_read_from_disk_needs_a_path
Handed contents, the config and the file must agree on whether there is a subscribe file sources_given given_sources_must_agree_on_a_subscribe_file; through the FFI, ffi/tests/proxy.rs → set_route_and_reload_switch_like_the_rest_api (a subscribe file with a config that has no [subscribe] is FfiError::Config, and what runs is unchanged)
A subscribe [dns] lowers to Split, keeping system in through_proxy for build_dns to refuse lower_dns app/tests/unit/lower.rs → a_subscribe_file_splits_dns
The route view agrees with the merge: nodes are targets, direct and blackhole are added, each group shows the tag it resolves to, and the default falls back to the first outbound routes.rs reuses resolve_group and the merge’s constants app/tests/unit/api.rs → lists_each_groups_pick_and_inherit_and_the_targets, without_a_subscribe_file_the_default_route_is_the_one_pick
End to end, a group’s rule routes real flows, and switching its pick sends new flows of the group to the new target Merge, lowering, apply app/tests/integration/e2e_api.rs → a_switch_sends_new_flows_of_the_group_through_the_new_target

The errors of the format, the merge, the agreement checks and lowering are io::Errors of kind InvalidInput, and reach the app as LoadError::Config. The supervisor’s errors (validation, building DNS, building the route) are supervisor/src/build/apply.rs → ApplyError values: Invalid (<resource>: <reason>) for a spec that breaks a rule, and Build (building <resource> failed: <source>) for one that cannot be constructed. They reach the app as LoadError::Apply. app/src/instance.rs → From<LoadError> for ControlError decides what a REST API or FFI client that caused the load is told:

LoadError ControlError
Config of kind InvalidInput or InvalidData: the errors of the merge and of lowering below, a [subscribe] without path, and a config that does not parse Invalid
Config of any other kind, such as the OS error of a subscribe file that cannot be read Failed
Apply with ApplyError::Bind or ApplyError::Stopped Failed
Any other Apply, such as the Salamander key length or building dns failed: … Invalid

So every error that a subscribe file’s content causes is Invalid: the config is invalid, rather than the load having failed. The REST API answers it with 422, and ffi/src/error.rs turns it into FfiError::Config. The REST API’s own reads (app/src/api.rs → read) prefix a read error with <config path>: , and map InvalidInput to Invalid and any other kind to Failed; see The REST API (etemenanki-webclient).

All of these begin with subscribe: . The texts were captured with the pinned binary under --test.

Condition Text after subscribe:
The file is not UTF-8 The Utf8Error text, for example invalid utf-8 sequence of 1 bytes from index 0
The file does not parse: bad syntax, an unknown or missing key, an unknown protocol, a value of the wrong type or out of range TOML parse error at line <n>, column <m>, the first line of the parser’s message
A node’s name is already a config outbound’s tag, a balancer’s tag or an earlier node’s name node "<name>" is defined twice, or shares its name with a config outbound
A group is named default a route group may not be named "default": [subscribe.routes] uses that key for the default node
Two groups share a name route group "<name>" is defined twice
A [subscribe.routes] key is neither default nor a group [subscribe.routes] names "<key>", which is not a route group in <file>
A [subscribe.routes] value names no node, outbound, balancer, direct or blackhole [subscribe.routes] "<key>" = "<tag>": no node, outbound or balancer has that name
No default is set and there is no outbound at all no node or outbound to send unmatched traffic to
The inherit walk reaches a group name that does not exist route group "<name>" inherits "<other>", which is not a route group
The inherit walk returns to a group already in its chain route groups inherit in a cycle: A -> B -> C -> A
A route names direct or blackhole, and an outbound of another protocol carries the tag "direct" is routed to, but the outbound tagged "direct" is "socks", not "freedom", or "blackhole" is routed to, but the outbound tagged "blackhole" is "freedom", not "blackhole"
A route names direct or blackhole, and a balancer carries the tag "direct" is routed to, but it names a balancer, not a "freedom" outbound

<file> is SubscribeConfig::describe: the path as written, or the subscribe file.

A node, a group value or a DNS URL that the format stored as a string is checked by lowering or by the supervisor. These errors carry no subscribe: prefix. The outbound errors name the node, since its name is the outbound’s tag, and the matcher errors name the value but not the group. Captured with the pinned binary:

Where Condition Text
app/src/lower.rs → parse_uuid A VLESS or VMess id that is not a UUID outbound <name>: invalid uuid "<id>": <uuid error>, for example … invalid uuid "not-a-uuid": invalid character: found `n` at 0
app/src/lower.rs → lower_wireguard A WireGuard key that does not parse outbound <name>: invalid wireguard private_key, and likewise peer_public_key or preshared_key
app/src/lower.rs → lower_shadowsocks_outbound A 2022 PSK that is not base64 outbound <name>: decode PSK: <base64 error>, for example … decode PSK: Invalid input length: 5
app/src/lower.rs → lower_shadowsocks_outbound A 2022 PSK too short for its method outbound <name>: shadowsocks-2022: PSK too short (16 < 32) for a 16-byte key and a 32-byte method
supervisor/src/build/validate.rs (supervisor validation) A Salamander password under 4 bytes outbound <name>@v0: salamander obfs key must be at least 4 bytes
protocols/src/dns/mod.rs → Backend::from_url A DNS URL with another scheme dns: server "ftp://192.0.2.53": unknown scheme "ftp"
Backend::from_url A DNS server without a scheme dns: server "192.0.2.53": expected "system" or a udp://, tls:// or https:// url
Backend::from_url A udp:// URL with a fragment dns: server "udp://192.0.2.53#x": a udp server has no name to verify
Backend::from_url A tls:// URL with an empty fragment dns: server "tls://192.0.2.53#": empty server name
Backend::from_url An IPv6 host without brackets dns: server "udp://2001:db8::53": has an invalid port
Backend::from_url An https:// authority with userinfo, or without a host dns: "https://user@198.51.100.53/dns-query" has no usable host
Resolver::with_upstreams, from build_dns system among through_proxy building dns failed: dns: the system resolver cannot be reached through a proxy
Resolver::with_upstreams, from build_dns A pre_proxy server written by name building dns failed: dns: server "dns.example.com" is a name, and nothing resolves it; write its address
app/src/lower.rs → parse_cidr A bad CIDR invalid cidr "192.0.2.0/33": invalid length for network: Network length 33 is too long for Ipv4 (maximum: 32)
environment/src/routing.rs → parse_port_match An inverted port range invalid port spec: "2000-1000" has a lower bound above its upper bound
environment/src/routing.rs → DomainRegexSet::new A regex that does not compile invalid domain regex "<pattern>": regex parse error: …, the parser’s message over several lines; when each pattern compiles alone but the set does not, domain regex set of <n> patterns: <error>
environment/src/routing.rs → build_geo_data, from the supervisor’s route build A geosite matcher without [route] geosite building route failed: a geosite matcher is used but no geosite file is configured

Some lowering errors cannot come from a subscribe file, because node_outbound always writes a well-formed value. wireguard endpoint must be host:port and invalid wireguard endpoint port cannot, since the endpoint is always <server>:<port> with a u16 port. ws stream needs ws.host or server and grpc stream needs grpc.authority or server cannot, since every node but WireGuard has a server. unknown vmess security and unknown shadowsocks method cannot, since the enums write only names lowering accepts. config defines no outbounds cannot either: apply fails first with no node or outbound to send unmatched traffic to.

When Line Level, target
--test configuration invalid: <error>, then exit with failure ERROR, etemenanki_app
Start failed to start: <error> ERROR, etemenanki_app
Reload, refused by the merge, lowering, or the supervisor’s validation or build reload: <error>; keeping the running config ERROR, etemenanki_app::instance
Reload, a subscribe file that cannot be read The reload fails, and the error is logged ERROR, etemenanki_app::instance

For example, under --test: configuration invalid: subscribe: route groups inherit in a cycle: A -> B -> C -> A, and configuration invalid: subscribe file missing.toml: No such file or directory (os error 2).

apply logs two warnings, both at WARN with target etemenanki_app::subscribe. Neither stops the merge.

Line When
[subscribe] is set, so the config's <n> [[route.rule]] entries are ignored; routing comes from the route groups in <file> The config has at least one [[route.rule]]
<file> has a [dns] section, so the config's [dns] is ignored The file has [dns] and the config’s [dns] sets any key, even backend = "system"

Nothing else in the format crate or the merge logs.

There is nothing to cancel. parse, apply, ensure_builtins and the helpers are synchronous functions over values in memory. They spawn no task, hold no lock, open no channel and set no timer. They run inside whichever load called effective, and a failure leaves the running configuration as it was, because nothing reaches the supervisor until the whole merged config has lowered.

Item Value Where
DEFAULT_ROUTE "default" app/src/subscribe.rs. Reserved: no group may take the name.
DIRECT "direct", a freedom outbound when created app/src/subscribe.rs
BLACKHOLE "blackhole", a blackhole outbound when created app/src/subscribe.rs
Node port u16 ProxyServer::port
WireGuard reserved Exactly 3 values of 0 to 255 [u8; 3]
WireGuard keepalive u16 seconds Wireguard::keepalive
WireGuard mtu 1420 when absent protocols/src/wireguard/config.rs → DEFAULT_MTU
Hysteria 2 concurrent streams 102,400, not settable from the file protocols/src/hysteria/config.rs → DEFAULT_MAX_CONCURRENT_STREAMS
Salamander password At least 4 bytes supervisor/src/build/validate.rs → MIN_SALAMANDER_PSK
network values per rule One, hence a rule per network value RuleConfig::network
DNS default ports 53 (udp), 853 (tls), 443 (https) Backend::from_url
Layer File What it covers
Format subscribe/tests/unit/subscribe.rs the_example_parses_into_typed_nodes_groups_and_dns (node order, a gRPC VMess stream with an empty tls, a 2022 method, a node without stream as plain TCP, WireGuard addresses and reserved, the DNS policy, every inherit form, a domain_suffix and a network matcher), a_written_subscription_reads_back_unchanged, a_mistyped_or_missing_key_is_rejected
Merge app/tests/unit/subscribe.rs the_file_supplies_outbounds_rules_and_dns_while_inbounds_stay, a_group_goes_to_its_pick_else_what_it_inherits, network_matchers_become_rules_of_their_own, a_mistyped_pick_or_a_broken_file_fails_the_config, a_direct_tag_that_is_not_freedom_is_refused_when_routed_to, every_lowered_node_builds_and_the_dns_setup_with_it, connect_ipv6_decides_how_every_node_server_is_reached. The helper merged runs effective over Sources built in memory, so no file is read.
Sources and built-ins app/tests/unit/config.rs direct_and_blackhole_exist_when_a_route_names_them, a_plain_configs_own_outbound_keeps_its_builtin_tag, a_subscribe_section_read_from_disk_needs_a_path, given_sources_must_agree_on_a_subscribe_file
DNS lowering app/tests/unit/lower.rs a_subscribe_file_splits_dns
Route view and API app/tests/unit/api.rs lists_each_groups_pick_and_inherit_and_the_targets (nodes as node targets after the config’s own outbound, direct and blackhole added, a group’s own pick winning over its inherit, a group without a pick showing the default target), without_a_subscribe_file_the_default_route_is_the_one_pick (the default falls back to the first outbound; picking direct writes no outbound for it), an_edit_that_would_not_start_is_422_and_leaves_the_file_untouched (a config with a balancer tagged direct and a subscribe file: picking direct for a group is 422 with names a balancer)
FFI ffi/tests/proxy.rs set_route_and_reload_switch_like_the_rest_api: a group’s pick switched with set_route sends the group’s flow to the new target; reloading the files that run reports unchanged; a subscribe file handed with a config that has no [subscribe] is FfiError::Config and leaves the pick as it was
End to end app/tests/integration/e2e_api.rs a_switch_sends_new_flows_of_the_group_through_the_new_target: a group that inherits blackhole drops flows to 127.0.0.0/8 at start. After the pick is switched to a SOCKS node through PUT /v1/routes/Local, they go through it; the test then terminates the node and checks that the group is cut off, which proves the flows went through the node and not direct. After the switch to direct they go direct, GET /v1/routes shows the pick, and the config file carries it.

No test covers the no node or outbound to send unmatched traffic to error, two groups with one name, the strict refusal of a look-alike blackhole, or either warning. A change to those paths needs a test in app/tests/unit/subscribe.rs. See Testing for how the suites are laid out.