Subscribe files
Source files: 31 · checked against Etemenanki 555b7df
Etemenanki/subscribe/Cargo.tomlEtemenanki/subscribe/src/lib.rsEtemenanki/subscribe/src/proxy_servers.rsEtemenanki/subscribe/src/route.rsEtemenanki/subscribe/src/dns.rsEtemenanki/subscribe/example-subscribe.tomlEtemenanki/subscribe/tests/unit/subscribe.rsEtemenanki/app/src/subscribe.rsEtemenanki/app/src/config.rsEtemenanki/app/src/lower.rsEtemenanki/app/src/transport.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/main.rsEtemenanki/app/src/routes.rsEtemenanki/app/src/api.rsEtemenanki/ffi/src/proxy.rsEtemenanki/ffi/src/error.rsEtemenanki/protocols/src/dns/mod.rsEtemenanki/protocols/src/dns/hosts.rsEtemenanki/protocols/src/hysteria/config.rsEtemenanki/protocols/src/wireguard/config.rsEtemenanki/environment/src/routing.rsEtemenanki/supervisor/src/build/dns.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/app/tests/unit/subscribe.rsEtemenanki/app/tests/unit/config.rsEtemenanki/app/tests/unit/lower.rsEtemenanki/app/tests/unit/api.rsEtemenanki/app/tests/integration/e2e_api.rsEtemenanki/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.
Responsibilities
Section titled “Responsibilities”| 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.
Key types
Section titled “Key types”The format crate
Section titled “The format crate”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.
A complete file
Section titled “A complete file”This file uses all three parts, with a node for every protocol except SOCKS and HTTP, and placeholder values:
[[proxy_server]]name = "vless-ws"server = "proxy.example.com"port = 443protocol = "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 = 443protocol = "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 = 443protocol = "trojan"password = "replace-with-a-long-random-password"stream = { network = "tcp", tls = {} }
[[proxy_server]]name = "ss-2022"server = "203.0.113.7"port = 8388protocol = "shadowsocks"method = "2022-blake3-aes-128-gcm"password = "AAAAAAAAAAAAAAAAAAAAAA=="
[[proxy_server]]name = "hy2"server = "proxy.example.com"port = 8443protocol = "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 = 51820protocol = "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 = trueconnect_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
Section titled “Subscribe”#[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 intoSubscribe::default(), which the struct also derives. There is no container-level#[serde(default)]. deny_unknown_fieldsrefuses any other top-level table, for example a mistyped[dsn].is_defaultis the crate-private helper behind everyskip_serializing_if = "is_default". It keeps default values out of a written file “so it stays as short as a hand-written one”.
ProxyServer and Protocol
Section titled “ProxyServer and Protocol”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.
Keys per protocol
Section titled “Keys per protocol”The variants’ structs and the two cipher enums, with the serde attributes on fields left out:
#[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.
Stream and Tls
Section titled “Stream and Tls”#[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.
Hysteria2Obfs
Section titled “Hysteria2Obfs”#[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.
DnsConfig and ProxyServerIpv6DnsPolicy
Section titled “DnsConfig and ProxyServerIpv6DnsPolicy”#[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”#[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.
-
nameis what the client shows. It is unique within the file, which the merge checks. -
inheritis what the group uses until the user picks a node for it:Written Variant The group’s traffic goes to absent, or inherit = "default"DefaultThe default node inherit = "direct"Directdirect, without a proxyinherit = "blackhole"BlackHoleblackhole: connections are dropped silentlyinherit = { group = "Proxy" }Group("Proxy")Whatever the group Proxygoes to -
matchesis 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).
Serde rules
Section titled “Serde rules”| 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.
What the format leaves out
Section titled “What the format leaves out”| 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 | — |
Where the file comes from
Section titled “Where the file comes from”[subscribe] in the config
Section titled “[subscribe] in the config”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. |
Sources
Section titled “Sources”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.
effective
Section titled “effective”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).
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.
Data flow
Section titled “Data flow”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.
The merge: apply
Section titled “The merge: apply”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
- Nodes. A set of taken tags starts with every config outbound’s tag and every balancer’s tag. Each node’s
nameis inserted in file order. A name already in the set fails the merge withnode "<name>" is defined twice, or shares its name with a config outbound, which also covers a clash with a balancer. Otherwisenode_outboundturns the node into anOutboundConfig, which is pushed after the config’s own outbounds. When the file has[dns], itsconnect_ipv6is mapped to an address family once and passed to every node. Nodes come first because every later check resolves names against them. - Groups. A group named
defaultfails witha route group may not be named "default": [subscribe.routes] uses that key for the default node. A second group with a taken name fails withroute group "<name>" is defined twice. The groups go into aHashMapby name for the inherit walk. - Picks. Every
[subscribe.routes]entry is checked, in key order. The key must bedefaultor 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),directorblackhole, 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. - 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]’sgeoipandgeositepaths still apply.[subscribe.routes] default, when present, overwritescfg.route.default. The default tag thatinherit = "default"means is thencfg.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 withno node or outbound to send unmatched traffic to. Lowering later takes the same fallback for the route table’s default. - Groups into rules. For each group in file order,
resolve_groupfinds its tag andpush_group_rulesappends its rules. The new list replacescfg.route.rules. - Built-ins.
ensure_builtins(cfg, true)createsdirectandblackholeif the rules or the default name them, and refuses a look-alike. - DNS. When the file has
[dns], it is cloned intocfg.subscribe_dns. If the config’s own[dns]is not the all-default table, a warning says it is ignored.cfg.dnsitself is left in place; lowering does not read it whilesubscribe_dnsis set.
A worked example
Section titled “A worked example”The config that goes with the file above:
[[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 |
Nodes as outbounds
Section titled “Nodes as outbounds”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.
stream_config
Section titled “stream_config”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.
Address families from connect_ipv6
Section titled “Address families from connect_ipv6”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.
What lowering fills in
Section titled “What lowering fills in”| 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 |
Route groups as rules
Section titled “Route groups as rules”resolve_group
Section titled “resolve_group”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
defaultresolves todefault_tag,directtoDIRECTandblackholetoBLACKHOLE.- 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 givesA -> A. - When the walk ends, the tag is recorded in
resolvedfor the group it ended at and for every name in the chain.applyshares oneresolvedmap across all groups, so a later group that reaches a resolved group stops there.
push_group_rules
Section titled “push_group_rules”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.
From rules to the route table
Section titled “From rules to the route table”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.
Other walkers
Section titled “Other walkers”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, thendirectandblackholeunless a target already listed carries the name; - the default target is the
[subscribe.routes] defaultpick, 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_groupgives, 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).
Built-in direct and blackhole
Section titled “Built-in direct and blackhole”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:
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.
Reloads, the API and the FFI
Section titled “Reloads, the API and the FFI”Sourcesholds 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),
--testand the REST API’s check of an edit (both throughsupervisor::check), and a reload whose sources changed, whether the app’s own or the FFI’sreloadorset_route. No merged state is kept between loads, so the warnings ofapplyare logged by every load that reaches the merge,--testincluded. The app-reload page says when a reload stops before the merge. - The watcher follows the subscribe file that
[subscribe] pathnames; 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 nopath. The FFI’sProxy::startandProxy::reloaddo this. Itsset_routeedits the config bytes it holds and reloads them with the subscribe bytes it already has. See Mobile library (etemenanki-ffi).
Invariants
Section titled “Invariants”| 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 |
Failure paths and cancellation
Section titled “Failure paths and cancellation”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).
Errors of the merge
Section titled “Errors of the merge”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.
Errors a file causes after the merge
Section titled “Errors a file causes after the merge”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.
How the app prints them
Section titled “How the app prints them”| 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).
Log lines
Section titled “Log lines”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.
Cancellation
Section titled “Cancellation”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.
Limits
Section titled “Limits”| 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.