Files
shoes/CONFIG.md

128 KiB

Configuration Reference

shoes uses YAML configuration files. Multiple configuration types can be combined in a single file or split across multiple files.

Table of Contents

WireGuard

WireGuard uses protocol: {type: wireguard} in ordinary client and server configs. It carries TCP/UDP flows over a userspace GotaTun device and smoltcp stack, not a TCP stream wrapper. Unix builds support WireGuard; Windows rejects it.

See server and client examples. Their demonstration keys must be replaced before deployment. Generate a private key with wg genkey and derive its public key with wg pubkey. Never share the private key.

Field Meaning
private_key Base64-encoded 32-byte private key
addresses Nonempty local tunnel CIDRs; at most one address per family
peer_client_chain Optional client chain carrying encrypted UDP packets to peers; accepts normal group, inline, and pool syntax
mtu Inner IP MTU, default 1408; supported range 1280-1420
resource_limits Same fields as TUN, with bounded WireGuard defaults

These fields live inside protocol. WireGuard implies UDP; transport: udp is optional. TCP/QUIC transports and their socket settings are rejected.

A client has one remote peer. Its ordinary address identifies that peer; public_key, optional pre_shared_key, allowed_ips, and persistent_keepalive belong directly in protocol, not a peers list:

- client_group: vpn
  client_proxies:
    - address: "vpn.example:51820"
      protocol:
        type: wireguard
        private_key: "<client-private-key>"
        public_key: "<server-public-key>"
        addresses: ["10.77.0.2/32"]
        allowed_ips: ["0.0.0.0/0"]
        persistent_keepalive: 25

Omitted client allowed_ips defaults to ["0.0.0.0/0", "::/0"]; an explicit empty list rejects. addresses remains required. Routes for an address family without a local tunnel address fail closed. These settings do not install host routes. For multiple upstreams, configure separate clients or a group pool.

A server has a nonempty protocol.peers list. Each peer requires public_key and explicit nonempty allowed_ips. Optional fields are pre_shared_key, endpoint: "host:port", and persistent_keepalive in seconds (zero disables it). Omit endpoint for a peer that initiates toward a directly listening server:

- address: "0.0.0.0:51820"
  protocol:
    type: wireguard
    wireguard_name: gateway
    private_key: "<server-private-key>"
    addresses: ["10.77.0.1/32"]
    peers:
      - public_key: "<client-public-key>"
        allowed_ips: ["10.77.0.2/32"]
  rules:
    - masks: ["192.168.10.0/24"]
      action: allow

Peer hostnames are resolved through native bootstrap DNS during preparation and refreshed on reload. Direct mode selects the first resolved address compatible with the bound socket at startup. Authenticated WireGuard roaming can update the active peer address without a reload in direct mode. Overlapping allowed prefixes between different peers, duplicate keys, and self-peering are rejected. Assign each client distinct tunnel addresses.

The server address must be one literal UDP socket address with a nonzero port, not a hostname, port range, or Unix socket path. 0.0.0.0:51820 listens only on IPv4; [::]:51820 uses a dual-stack socket where supported. Direct clients bind an ephemeral dual-stack wildcard, with IPv4 fallback when IPv6 is unavailable. Outer sockets retain mobile socket protection and interface binding. Optional bind_interface belongs at the client-config level for clients and inside protocol for servers; Linux/Android/Fuchsia support it. No OS interface, route, firewall rule, or NAT rule is created.

Servers require explicit nonempty top-level rules and may specify top-level dns and sniff. Missing, null, or empty rules reject; rules: block-all is valid. protocol.wireguard_name gives a server a stable, case-sensitive identity. When omitted, null, or empty, the server's public key supplies its identity instead. Duplicate names or duplicate unnamed public-key identities reject configuration, even with different listen addresses. Name-based and key-based identities are separate. Client deduplication is unchanged. Reordering servers or adding clients does not change server identity or private Fake DNS ownership. Use an explicit name to preserve that ownership across key rotation; changing the effective identity creates a different context and existing state files reject the new owner. Rules use the normal destination masks, overrides, and client chains. Clients accept replies to their outgoing flows, not unsolicited inbound flows. Per-peer authorization beyond cryptokey source validation is not supplied by destination rules. Authenticated peers can reach the gateway's local services unless rules block them. The server example blocks loopback and link-local destinations; restrict private networks too when peers should not access the gateway's LAN.

Use the client group from the first hop, or put the full client config inline:

client_chain:
  - vpn
  - address: "10.77.0.10:1080"
    protocol: {type: socks}

The second hop is optional. WireGuard cannot appear inside TLS/WebSocket or at later hops. A missing route, address family, failed endpoint, or unavailable peer never causes direct fallback.

To put proxies before WireGuard, configure peer_client_chain inside its protocol:

peer_client_chain:
  - address: "relay.example:443"
    protocol:
      type: tls
      protocol:
        type: vless
        user_id: "b85798ef-e9dc-46a4-9a87-8da4499d36d0"
        udp_enabled: true

The effective path is Shoes -> peer client chain -> WireGuard peer -> optional inner proxy chain -> destination. The routing rule still selects WireGuard at hop zero. See the complete detour example.

Peer chains use the normal inline, named-group and pool syntax. Their final hop must support UDP; earlier hops may be TCP-only. VLESS, VMess and compatible Shadowsocks/AnyTLS UDP-over-TCP configurations work. Shoes' SOCKS outbound only implements CONNECT, and Trojan outbound UDP is not implemented, so neither can be the final UDP hop. Pools select UDP-capable final members. Selection remains fixed for the life of an association; reconnection may select another member.

One persistent association is shared by all inner flows to each resolved peer destination; peers with the same resolved destination share an association. No native WireGuard UDP listener is opened. Configure physical interface binding on the first carrier client, not the WireGuard socket. A proxied server must omit its top-level listen address and specify endpoint and nonzero persistent_keepalive on every peer. It initiates handshakes to establish return mappings, then its ordinary rules handle incoming tunnel flows. It cannot accept arbitrary public peers through the proxy. See the proxied server example. peer_client_chain conflicts with a server listen address or WireGuard socket interface binding. Clients without keepalive initiate a handshake when an outbound inner flow needs one.

Detoured destinations are fixed for the configuration generation. Their message streams do not expose source addresses, so arbitrary peer address roaming is not supported. Peer hostname changes require reload. Peer and outer-proxy bootstrap use native DNS independently of routed DNS and the server's dns. Ensure host DNS and host routes do not themselves require the tunnel being established.

Outer connect and blocked-write attempts time out after 10 seconds. Failed streams retry with backoff from 250 ms to 10 seconds, never falling back to direct UDP. Offline/overflow packets are dropped rather than indefinitely buffered; WireGuard and inner TCP perform their normal retries. Use persistent_keepalive to maintain idle proxy UDP mappings. Silent blackholes without stream errors remain UDP loss. Queues hold at most 64 packets per destination and 64 aggregate received packets, with a 2048-byte outer datagram limit, above the supported inner MTU plus WireGuard overhead. Shutdown waits for workers to release their associations.

The peer chain can itself start with another WireGuard client. Dependencies start first, regardless of declaration order; self-dependencies and indirect cycles (including pool alternatives) are rejected. A direct WireGuard-in-WireGuard carrier requires round_up(inner_mtu, 16) + 80 <= carrier_mtu, conservatively accounting for IPv6, UDP and WireGuard overhead. For example, inner MTU 1280 fits carrier MTU 1408. A TCP proxy after the carrier segments packets instead. External path MTU still needs operator tuning; UDP-over-TCP can introduce head-of-line blocking.

Identical clients share one device per configuration generation, even when copied between different groups or inline locations. Identity is the configured peer address, derived client public key, and server public key, not a resolved or roaming address. WireGuard defaults and equivalent key/address/route encodings, plus carrier interface and TCP socket defaults, are normalized before comparison. Local address prefixes are canonicalized to host prefixes; routing prefixes retain their meaning. Other carrier protocol settings are compared as configured. Conflicting addresses, routes, PSKs, MTUs, limits, interfaces, or peer chains for the same identity reject rather than silently merging. Different identities and servers remain separate. Unused client groups open no sockets. Unreferenced WireGuard-routed DNS groups are validated but not prepared; selecting the group through a server or TUN dns field activates its devices.

DNS remains independently configured. Merely defining a WireGuard client does not tunnel the application's default native DNS. Explicit TCP DNS, DoT, and DoH client chains may use WireGuard with a literal upstream IP. Use server_name for TLS authentication where needed. UDP DNS, HTTP/3 DNS, hostname upstreams on WireGuard chains, and bootstrap dependencies on WireGuard-routed DNS groups are rejected. Endpoint bootstrap itself is native DNS, not DNS through the not-yet-active tunnel.

This is TCP/UDP proxying, not arbitrary IP routing. Remote destinations see the gateway's outgoing proxy connection, not the original client's source address. A non-Shoes gateway must provide the required routes and forwarding or NAT for destinations behind it. ICMP forwarding, IP fragment reassembly, peer-to-peer IP forwarding, WARP reserved bytes, and wg-quick import are not implemented.

Fragmented input and unsupported IPv6 extension chains are dropped before transport parsing. TCP advertises an MSS derived from the MTU. Outbound UDP writes larger than MTU minus 28 bytes (IPv4) or 48 bytes (IPv6) are dropped and counted without closing the flow; they are never truncated. UDP queue-budget exhaustion also drops individual writes. At MTU 1408 the payload limits are 1380 and 1360 bytes. Oversized UDP responses on inbound forwarding are dropped. Large DNS replies should use TCP-based transports. Reduce MTU for constrained underlays; automatic path-MTU discovery is not provided. Packets received from a peer above the local MTU are also dropped. Configure matching MTUs on both sides, including peers whose default is 1420 rather than Shoes' 1408; TCP MSS negotiation does not protect UDP traffic.

Default admission limits are 256 TCP connections, 32 KiB TCP buffers, a 32 MiB TCP buffer budget, 1024 UDP sessions, 4096 UDP destinations, 64 destinations per inbound UDP session, and 8 MiB queued UDP payloads. UDP forwarding and outbound associations have separate bounded pools. Missing or null limit fields use WireGuard defaults rather than the unlimited TUN defaults. Packet and command queues are bounded at 64. These budgets cover modeled Shoes-owned state, not total process RSS or all GotaTun state.

The TCP buffers bound the advertised receive window and in-flight data across the tunnel. A 32 KiB window limits a single flow to roughly 5 Mbit/s at 50 ms RTT or 2.6 Mbit/s at 100 ms RTT, before other bottlenecks. For higher-bandwidth, higher-latency links, set resource_limits.tcp_buffer_size to, for example, 262144 (256 KiB). Four buffers are budgeted per flow, so retaining 256 connections at that size also requires tcp_memory_bytes: 268435456 (256 MiB); the default 32 MiB budget would admit only 32 connections. Tune against measured RTT, throughput, and available memory rather than increasing buffers unconditionally.

Periodic logs report endpoint flow/drop counts and each peer's public key, handshake age, and byte counters. Private keys and PSKs are redacted.

Preparation failures retain the old configuration. Reload activation stops existing tunnel flows and rebuilds endpoints; sessions are not migrated. Bind failures during activation trigger rollback to the previous configuration, but disconnected tunnel sessions are not restored. If rollback fails, Shoes exits with status 1. Shutdown waits for buffered UDP tasks to release their sockets before rebind. No host route cleanup is necessary.

Configuration Structure

A configuration file is a YAML array containing one or more configuration entries. Each entry can be:

  • Server Config - Defines a proxy server instance
  • TUN Config - Defines a TUN/VPN device for transparent proxying
  • Client Config Group - Defines reusable upstream proxy configurations
  • Rule Config Group - Defines reusable routing rules
  • Named PEM - Defines reusable certificate/key data
# Server configs have 'address' or 'path', except proxied WireGuard servers
- address: "0.0.0.0:8080"
  protocol: ...

# TUN configs have 'device_name' or 'device_fd'
- device_name: "tun0"
  address: "10.0.0.1"
  ...

# Client config groups have 'client_group'
- client_group: my-upstream
  client_proxy: ...

# Rule config groups have 'rule_group'
- rule_group: my-rules
  rules: ...

# Named PEMs have 'pem'
- pem: my-cert
  path: /path/to/cert.pem

Ordered Healthy Fallback

The CLI supports opt-in ordered fallback without a Clash API listener:

- client_group: outbound
  type: fallback
  health_check:
    url: "https://probe.example.com/generate_204"
    interval_secs: 30
    timeout_ms: 5000
    expected_status: 204
  client_proxies:
    - name: primary
      address: "primary.example.com:1080"
      protocol: {type: socks}
    - name: backup
      address: "backup.example.com:1080"
      protocol: {type: socks}

Rules use client_chain: outbound. health_check is required only for fallback and rejected on other group types. The removed strategy field is not accepted. The URL must be HTTP(S), at most 2048 bytes, without credentials or fragments. interval_secs is required, 1..86400; timeout_ms defaults to 5000, 1..32767; expected_status defaults to 204, 100..599. Unknown fields, empty groups, duplicate immediate members and selector-only default are rejected. Anonymous inline members are supported.

For each new flow, explicit nested selector choices and final-hop UDP capability are applied first. Fallback chooses the first member with a healthy eligible concrete path. If none is healthy, never-sampled paths may be tried in order. Failed and stale paths are excluded; all-down returns an error, never implicit DIRECT. A sample becomes stale after three intervals plus the probe timeout. Probes skip paths excluded by manual selector choices, rechecking those choices after admission. Reselecting a skipped path schedules a check without waiting another interval, still subject to shared admission and transport deadlines. Previously sampled paths that became stale while unselected remain excluded until a new check succeeds; reselection does not make an old sample Unknown. New flows can therefore fail closed while rechecks are pending or locally blocked. Manual choices are never rewritten or persisted by health checking. Resolver-chain selector changes trigger rechecks without erasing existing samples: DNS caches and retained connections are not concrete-path identity. Relevant selector changes fence in-flight probe results, including A-to-B-to-A changes; unrelated selectors and inactive nested branches do not. Fixed outer path choices do not erase samples. DNS-chain checks use the same configured bootstrap resolver as forwarding. A fallback-checked chain cannot contain WireGuard whose peer_client_chain has more than one member at any hop, including recursively nested WireGuard carriers. All configured alternatives count, even branches that cannot carry UDP. It also cannot contain NaiveProxy (including wrapped NaiveProxy) after a hop with multiple members: its retained H2 session is not scoped to the selected prefix. These combinations are rejected, not probed approximately. Fixed WireGuard carriers, NaiveProxy at hop 0 or behind a single fixed prefix, and ordinary chains without fallback remain supported. The restrictions also cover DNS chains and unused groups available to controller/URL-test probes; resolver-only dependencies do not count as the fallback chain's transport path. A fallback containing a selector may choose its next member, but never another branch inside that selector. Fallback member and healthy-before-unknown priorities are resolved across complete eligible paths before URL-test latency and round-robin pool choices. Paths outside each fallback branch remain eligible, so an unrelated round-robin branch is not globally overridden by health.

Checks send HEAD through the complete configured TCP chain, require the expected status, verify HTTPS certificates, and do not follow redirects. They share the controller and URL-test probe engine and its 16 concurrent permits. The total deadline includes admission, DNS, connect, TLS and response headers. Admission exhaustion or local resource shortages do not mark a path failed; expiry after queued admission preserves the previous sample without refreshing its timestamp. Successful and failed checks both wait the configured interval, jittered by +/-10%, before retrying. Unused groups do not start transports; checks start when a runtime chain is instantiated. Controller delay requests and URL-test targets can instantiate additional fallback chains. Their checks last while the cached probe connector lives (normally the generation) and obey that target's selectors, not selectors enclosing it in routing rules. They share the same check and concurrency limits.

Health is generation-local and keyed by canonical full-path configuration, resolver identity, selector constraints and probe settings, not names or member positions. Equivalent checks share one sample; different prefixes/endpoints do not. Each chain is limited to 256 concrete paths and each generation to 1024 active checks. Large pool products are rejected instead of spawning unbounded probes. Due checks are admitted oldest-first so repeated checks cannot indefinitely overtake waiting paths. Saturation can make samples stale; choose intervals/timeouts that fit the shared budget. Reload cancels and joins old checks before starting the new generation. Draining sessions keep the old generation's health snapshot at cancellation, without further probes or aging; rollback/resume restores normal freshness checks. Samples are not persisted. Pause time counts toward sample age after resume, so a long rejected activation can require successful rechecks before the resumed fallback accepts new flows.

Checks use the existing probe destination restrictions (including loopback, link-local and synthetic Fake-IP protection). Forbidden literal URLs, including configured or retained Fake-IP ranges, are rejected during validation. Proxy-side DNS remains subject to the remote proxy's policy. A TCP sample does not prove UDP reachability, nor does one probe endpoint measure all destinations. Application errors never update health, and application bytes are never replayed. Existing TCP streams, UDP associations and pooled transports keep their selected member until reopened. There is no latency scoring, passive cooldown, hysteresis, affinity, or all-down last-resort mode. FFI rejects fallback because it does not own this scheduler.

Controller delay history remains separate from fallback samples. Fallback catalog entries expose members and capabilities; there is no single global now member because selection depends on the concrete chain and transport.

Clash Controller

The CLI can expose a documented subset of the Clash API. The controller is disabled unless a listener is configured. It does not change existing groups' round-robin behavior.

- clash_api_address: "0.0.0.0:9090"
  secret: "replace-with-a-strong-secret"
  cors_origins: ["https://dashboard.example.com"]

- clash_api_path: "/run/shoes/clash.sock"
  secret: "replace-with-a-local-secret"

- cache_file_path: "./cache.db"

clash_api_address accepts one address or a list, using the ordinary server address syntax. Use either clash_api_address or clash_api_path per entry. Multiple entries share selectors, connections, counters, and resource budgets; they are not separate tenants. Each has its own secret and browser origins. Listener definitions and cache_file_path require a restart to change.

Every listener requires a nonempty secret. HTTP uses Authorization: Bearer <secret>; supported WebSocket upgrades also accept ?token=. Browser origins must exactly match cors_origins, unless cors_origins: ["*"] explicitly allows any origin, including opaque (null) origins. This applies to HTTP and WebSocket requests and does not disable authentication. Prefer exact origins when possible. With *, any website can attempt authenticated requests; use a long random secret. The default empty list permits authenticated clients without an Origin header but rejects requests carrying one. Only a standalone * acts as a wildcard; literal null entries are not accepted. Optional allow_private_network: true enables private-network preflight responses for allowed origins, not mixed-content bypass. Protect TCP with a firewall and external TLS termination. Unix sockets require an absolute path in an existing owner-only directory and are created with mode 0600. Browsers need an HTTP reverse proxy to use a Unix socket.

Opt into manual selection explicitly:

- client_group: outbound
  type: selector
  default: east
  client_proxies:
    - name: east
      address: "east.example.com:1080"
      protocol: {type: socks}
    - name: west
      address: "west.example.com:1080"
      protocol: {type: socks}

Rules continue to use client_chain: outbound. A selector chooses an immediate named member or referenced group; inline selector members require name. The group discriminant is type: round-robin (the default), selector, url-test, or fallback. The removed strategy field and unknown types are errors. Nested round-robin weights, duplicate membership, and existing UDP filtering are preserved. A selected branch without UDP support fails new UDP flows instead of silently falling back. Selection changes affect new flows only; existing streams, UDP destinations, DNS connections, and WireGuard associations remain pinned until reopened. Optional client and server/TUN name fields provide stable dashboard identities. Generated anonymous names are stable only for unchanged definition sites.

Automatic selection does not require a controller listener or a cache file:

- client_group: fastest
  type: url-test
  url: "https://probe.example.com/generate_204"
  interval: 300       # seconds, 1..86400
  timeout: 5000       # milliseconds, 1..60000, no longer than interval
  tolerance: 50       # milliseconds, 0..65535
  lazy: true
  client_proxies:
    - name: east
      address: "east.example.com:1080"
      protocol: {type: socks}
    - name: west
      address: "west.example.com:1080"
      protocol: {type: socks}

Only url is required for url-test; the values above are the other defaults. Literal URL addresses denied by probe policy (including loopback, link-local, multicast, unspecified, and synthetic benchmark addresses) are configuration errors. Locally resolved hostnames are subject to the same destination policy at probe time. Literal URLs overlapping configured or retained fake-IP ranges are also rejected. default is selector-only. url, interval, timeout, tolerance, and lazy are url-test-only. Members must be nonempty and distinct; references to nested groups are supported. Explicit names are recommended for automatic inline members.

Selection uses the fastest fresh healthy result for the configured URL, among members eligible for that hop and transport. A healthy incumbent stays selected unless another member improves its delay by more than tolerance. TCP and UDP have separate incumbents; an automatic group can choose a UDP-capable alternative even when its fastest TCP member does not support UDP. Nested manual selectors still restrict eligibility, and intermediate chain hops only need TCP support. Changing selection affects new connections, not established streams or associations.

There is one initial check after activation, including for lazy groups. Thereafter lazy: true checks only groups used by new forwarding connections during the last interval. Probes, dashboard reads, and WireGuard carrier maintenance never count as use. New outbound connections through a WireGuard endpoint wake its carrier groups, including nested carriers, without changing their existing associations. Inbound-only endpoints and long-lived flows need lazy: false for continuous checks. lazy: false checks idle groups too. Samples older than 2 * interval + timeout are stale and cannot win on delay. Without fresh healthy results (startup, all dead, stale, or locally unavailable probes), retain the eligible incumbent, or choose the first eligible member in configuration order. This is best-effort routing, not fail-closed routing.

Unambiguous remote connection errors request a rate-limited recheck (at most once per second); they do not mark the configured URL dead based on a different user destination. Local overload and unsupported operations do not penalize health. HTTP/2 adapters preserve matching transport-error causes for this classification. This is best effort when a protocol replaces a failure with a generic cancellation: for example, XHTTP upload cancellation can lose the original cause and count as a remote failure. User payloads are never retried across candidates. Probes use HTTP HEAD and accept any HTTP response status, without redirects; HTTPS certificates are verified. Local probe lookups use guarded system DNS, not a server's configured DNS group. API probes with the same normalized URL and wildcard/default expected status also update selection. Other URLs and explicit status expectations cannot overwrite it.

Periodic and API probes share a 16-probe limit. Each periodic round's timeout includes admission/queue time; members not admitted before the deadline retain their previous health. A deadline expiry after queueing is inconclusive and also preserves prior health; successful probes and explicit remote errors still update it. Members with no full-budget attempt, then the oldest full-budget attempts, lead each round, independently of health. Local failures cannot monopolize these slots, and queued attempts can receive a full budget on a later round. Tied members rotate on admission. Due groups with the oldest unqueued admission get priority, with stable catalog order breaking ties. Under sustained saturation, reduce group size, increase timeout/interval, or expect unprobed/stale members. Scheduling has one-second granularity and does not overlap rounds within a group; other groups can run while a slow round is in flight. Activation/reload cancels old work and rejects obsolete results; an interrupted round is immediately eligible when its generation resumes. New committed generations start with unprobed health.

The controller reports type: URLTest, effective TCP now, optional udpNow, testUrl, and selected-member health (unprobed, healthy, dead, or stale). Its alive is true only for fresh healthy results. Manual selection PUTs for automatic groups are rejected; automatic choices are neither pinned nor persisted. Leaf histories remain bounded to ten samples per URL and eight URLs. Automatic selection retains its configured-URL samples and full-budget attempt timestamps independently of API eviction; other attempt URLs are capped at eight per member for each of filtered and unfiltered probes. Automatic checks test each immediate member as a standalone outbound, not every surrounding chain or destination. URL-test groups are not supported by FFI.

Only one cache_file_path is allowed across all input files. Its absence disables persistence; relative paths resolve against the process working directory. The file is Shoes-specific versioned JSON, not a sing-box/Mihomo database. Parent directories must exist, and Unix cache/lock files must be private. Explicit API choices take precedence over default, then the first member. Startup/reload never save defaults or rewrite YAML. Every accepted selection PUT saves before publishing the new choice. Failed saves leave routing unchanged. An error after atomic replacement can leave an unacknowledged choice on disk; logs report this durability uncertainty. Saved entries for removed groups are retained. Both reads and writes are limited to 1 MiB; remove obsolete entries only while Shoes is stopped.

Supported endpoints: /version, read-only /configs, /proxies, /group, selector PUTs, node/group /delay, read-only /rules, /connections snapshots and DELETE cancellation, plus WebSocket/NDJSON /traffic, /memory, and /logs. Provider collections return {"providers":{}}. Traffic counters measure outbound application payload, not headers, padding, or retransmissions. Probes, internal DNS/carrier traffic, and unauthenticated camouflage/fallback forwarding are excluded; synthetic fake-DNS answers and ICMP are not represented as connections. Connection speeds are derived by the dashboard from successive byte counters. Process/GeoIP data is not fabricated.

Optional connection_notes: true on a Clash API entry (clash_api_address or clash_api_path) adds setup notes to active /connections objects, over HTTP and WebSocket. It defaults to false; changing it requires a restart. If any controller enables it, all controllers expose notes. In zashboard, open a connection's details and select Raw to inspect notes (elapsedUs, phase, text), alongside its existing generation, destination, selected chain, and rule metadata.

Notes describe preparation, routing choices, inbound-resolver calls, and outbound setup. They are best-effort: rejected/failed setup and closed connections have no retained history or separate debug endpoint. A dashboard may retain previously sampled connections, but short-lived connections can be missed. DNS notes cover existing routing/dial call boundaries, not resolver cache/upstream internals or remote DNS. Initial UDP prechecks and local-DNS handling are not observed. Notes contain hostnames and addresses, never payloads, credentials, or error messages; errors use only their kind. They may reveal outbound proxy server hostnames and IP addresses not otherwise exposed by the API.

At most 256 connections (including pending setup) collect notes at once. Each buffer holds 32 events with at most 128 UTF-8 bytes of text each, plus an overflow summary when read. New connections skip notes while all slots are occupied. Buffers reserve at most 8 KiB each, 2 MiB in aggregate, excluding allocator/RSS overhead and snapshot copies/JSON. Closing or abandoning a connection releases its buffer when its last reader finishes. The budget is shared across reloads. With notes disabled, no note buffers or resolver wrappers are allocated. When enabled, notes add setup work and snapshot copying/serialization: the controller samples every second even without subscribers; HTTP requests and custom-interval WebSocket subscriptions can request additional snapshots.

Delay tests send HTTP HEAD through actual outbounds, verify HTTPS certificates, and never follow redirects. A leaf test is standalone, not a test of every chain containing that leaf. Unused WireGuard nodes remain inactive and cannot be probed until referenced by a forwarding or DNS rule. Direct probe destinations cannot be loopback, link-local, unspecified, multicast, or synthetic fake-IP addresses. Remote proxies may resolve names differently: this is authenticated administrative network access, not an API for untrusted users.

The compatibility target is zashboard revision 6225aac84439425ae4f4493437b110a314753436, not full Mihomo parity. Only rule mode is offered, with no synthetic GLOBAL. Use disableUpgradeCore=1 in the dashboard backend settings. Unsupported controls can still be visible: config editing, API reload/restart, upgrades, provider updates, rule disabling, DNS/cache maintenance, and dashboard storage sync return explicit errors, never fake success. No UI download/hosting is included. Controller/cache entries are rejected by FFI.

See the complete example. Controller connections share a limit of 64, subscriptions 16, probes 16, and bound sockets 16. Request heads must finish within 15 seconds of accept or the preceding response; ordinary request bodies have a 10-second deadline and 64 KiB limit. Established subscriptions are exempt from header idle expiry and have bounded queues/write deadlines. Responses are limited to 32 MiB. An oversized connection snapshot closes its WebSocket with code 1009 and reason response exceeds 32 MiB; tracking continues without truncating the connection list. Configuring a controller, cache_file_path, or any selector/url-test/fallback group enables the catalog and its 16,384-node limit. Names must be nonempty, unique, at most 256 bytes, and contain no control characters. DIRECT, REJECT, and GLOBAL are reserved. Configuration preparation rejects /rules or base /proxies responses larger than 32 MiB. Group delay requests support at most 1,024 immediate members.

Server Config

WireGuard servers require one literal UDP address with a nonzero port, or omit the bind location when peer_client_chain is configured. They do not support port ranges, multiple bind addresses, or Unix socket paths. See WireGuard.

# Bind to IP address and port
address: "0.0.0.0:8080"        # IPv4
address: "[::]:8080"           # IPv6
address: "0.0.0.0:443-445"     # Port range

# OR bind to multiple addresses and port ranges (`address` is also accepted)
addresses:
  - "127.0.0.1:22223"
  - "172.17.0.1:22223-22225"

# OR bind to Unix socket (TCP only)
path: "/tmp/shoes.sock"

# Protocol configuration (required)
protocol: ServerProxyConfig

# Transport layer (default: tcp)
transport: tcp | quic          # WireGuard only: udp (default, required)

# TCP settings (only when transport: tcp)
tcp_settings:
  no_delay: true               # Default: true

# QUIC settings (required when transport: quic)
quic_settings:
  cert: string                 # TLS certificate (path or named PEM)
  key: string                  # TLS private key (path or named PEM)
  alpn_protocols: [string]     # Optional ALPN protocols
  client_ca_certs: [string]    # Optional client CA certificates
  client_fingerprints: [string] # Optional client certificate fingerprints
  num_endpoints: int           # Optional, 0 = auto (based on thread count)
  key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below

# Routing rules (default: allow-all-direct; WireGuard requires explicit nonempty rules)
rules: string | [RuleConfig]

Sudoku TCP Mask

sudoku_mask is an outer protocol wrapper for both TCP clients and servers. It implements Xray FinalMask Sudoku, not the standalone Sudoku tunnel protocol. It transforms the TCP carrier bytes without changing the inner proxy protocol. Masks must form a consecutive outermost prefix of each client hop or server listener. Masks beneath any non-mask protocol are rejected during configuration validation. Native UDP and QUIC hops reject masks anywhere in their protocol tree, including inside XHTTP. UDP/XUDP forwarded inside a supported TCP proxy stream remains supported.

# Outbound example: wire -> Sudoku -> TLS -> VLESS
address: proxy.example:443
transport: tcp
protocol:
  type: sudoku_mask
  password: "shared-mask-password"  # Default: empty; exact bytes, not trimmed
  ascii: prefer_entropy             # Default; also entropy, prefer_ascii, ascii
  padding_min: 0                    # Default: 0 percent
  padding_max: 0                    # Default: 0 percent
  protocol:
    type: tls
    protocol:
      type: vless
      user_id: "550e8400-e29b-41d4-a716-446655440000"

For an inbound, use the same mask options around the usual server protocol configuration, for example sudoku_mask -> tls with a TLS default_target containing VLESS. Default TLS/REALITY SNI still comes from the outbound address. The client writes classic encoding and reads packed encoding; the server uses the inverse. There is no negotiation, header, symmetric mode, or automatic password/layout mismatch detection. Configure both peers together.

Options are the same in both roles:

Field Behavior
ascii Case-insensitive, trimmed mode name. ASCII mode ignores custom tables, even invalid ones.
custom_table Optional eight-character layout containing two x, two p, and four v, e.g. xpxvvpvv. Empty means the entropy layout.
custom_tables Nonempty list overrides custom_table; normalized duplicates are removed in first-seen order. Layouts rotate per classic byte or packed six-bit group, including across writes.
padding_min, padding_max Unsigned 32-bit percentages, clamped to 100; maximum is raised to minimum if needed. Each direction chooses one inclusive probability for its lifetime.

Custom patterns are lowercased, trimmed, and have spaces removed. An empty entry in custom_tables selects entropy. Camel-case aliases customTable, customTables, paddingMin, and paddingMax are accepted. Unknown fields fail validation.

Placement matches Xray, with the root closest to the wire:

  • mask -> TLS/REALITY -> VLESS, mask -> WebSocket -> VLESS, and mask -> TLS/REALITY -> XHTTP -> VLESS match Xray's wire-outside placement.
  • TLS -> mask -> VLESS, TLS -> mask -> XHTTP, and XHTTP -> mask -> VLESS are rejected. Masks cannot be inserted inside TLS, WebSocket, or an XHTTP logical session.
  • XHTTP masks wrap each TCP carrier before TLS/REALITY, if configured. Mask state persists for the carrier's lifetime across pooled requests; new carriers start with fresh state.
  • Consecutive outer masks compose in nesting order, including at later chain hops and in pools. Xray's public mask list [A, B] corresponds to Shoes root B containing A. Use distinct matching options for each stage.

Existing Vision and NaiveProxy immediate-child constraints still apply inside the outer masks. A mask remains active during Vision DIRECT and disables raw TCP splice. A mask outside REALITY also prevents unmasked probes from reaching its usual camouflage fallback. Masked TLS listeners cannot serve unmasked ACME TLS-ALPN-01 challenges.

Security and cost: this is obfuscation, not encryption or authentication. Packed downlink is not password-keyed; classic's Go-compatible shuffle has a reduced seed space. Retain actual transport security. Classic uplink expands data 4x without padding, approximately 4.5x at 10%, and up to 9x plus one byte per write. Packed downlink approaches 4/3x without padding; small writes incur extra termination bytes. Each mask retains 32 KiB read and 32 KiB write buffers, plus codec state. Stacking compounds expansion and memory. No speedup or traffic classification resistance is promised.

Server Protocols

HTTP

protocol:
  type: http
  username: string?            # Optional authentication
  password: string?

Accepts HTTP/1.0 and HTTP/1.1 CONNECT and absolute-form forwarding, plus multiplexed TCP CONNECT over HTTP/2. H2 is detected from its connection preface; cleartext H2 requires prior knowledge, not HTTP Upgrade. Authentication applies independently to every H2 stream. A successful CONNECT response is sent only after the destination connects.

H2 connections with no active tunnels close after 60 seconds; PINGs and rejected authentication attempts do not extend that interval. Missing credentials receive a 407 challenge. Incorrect credentials also begin connection shutdown, preventing repeated password guesses on one carrier.

For HTTPS proxying, wrap HTTP in a TLS server and explicitly configure alpn_protocols: [h2, http/1.1] on its TLS target. HTTP does not change the parent layer's ALPN defaults. H2 GET/POST forwarding, extended CONNECT, CONNECT-UDP, and HTTP/3 are not supported. This is standard HTTP proxying, not NaiveProxy or XHTTP.

SOCKS5

protocol:
  type: socks                  # Aliases: socks5
  username: string?
  password: string?
  udp_enabled: true            # Default: true (enables UDP ASSOCIATE)

Mixed (HTTP + SOCKS5)

protocol:
  type: mixed                  # Aliases: http+socks, socks+http
  username: string?
  password: string?
  udp_enabled: true            # Default: true (enables UDP ASSOCIATE for SOCKS5)

Auto-detects HTTP (including H2 CONNECT) or SOCKS5.

Shadowsocks

protocol:
  type: shadowsocks            # Aliases: ss
  cipher: string               # See supported ciphers below
  password: string

# Supported ciphers:
# - aes-128-gcm
# - aes-256-gcm
# - chacha20-ietf-poly1305
# - 2022-blake3-aes-128-gcm
# - 2022-blake3-aes-256-gcm
# - 2022-blake3-chacha20-ietf-poly1305

VMess

protocol:
  type: vmess
  cipher: string               # aes-128-gcm, chacha20-poly1305, none
  user_id: string              # UUID
  udp_enabled: true            # Default: true (enables XUDP)

Note: VMess AEAD mode is always enabled. The legacy force_aead field is deprecated and non-AEAD mode is no longer supported.

Replay protection uses a process-wide cache of at most 65,536 Auth-IDs, shared across credentials, listeners, and reloads. Entries remain protected through their timestamp's 120-second validity window; future-dated IDs can occupy a slot for about four minutes. A full cache rejects new authentication rather than evicting live entries. Heavy traffic from one credential can therefore exhaust capacity for other VMess users in the same process. A backward system-clock step also pauses authentication until the clock catches up.

VLESS

protocol:
  type: vless
  user_id: string              # UUID
  udp_enabled: true            # Default: true (enables XUDP)
  fallback: string?            # Optional fallback destination for failed auth (e.g., "127.0.0.1:80")

Trojan

protocol:
  type: trojan
  password: string
  shadowsocks:                 # Optional encryption layer
    cipher: string
    password: string

Snell v3

protocol:
  type: snell
  cipher: string               # aes-128-gcm, aes-256-gcm, chacha20-ietf-poly1305
  password: string
  udp_enabled: true            # Default: true
  udp_num_sockets: 1           # Default: 1, sockets per UDP session

TLS Server

protocol:
  type: tls

  # Standard TLS targets (by SNI)
  tls_targets:                 # Aliases: sni_targets, targets
    "example.com":
      cert: string             # Certificate (path or named PEM)
      key: string              # Private key (path or named PEM)
      alpn_protocols: [string] # Optional ALPN
      client_ca_certs: [string] # Optional client CA certs
      client_fingerprints: [string] # Optional client cert fingerprints
      vision: false            # Enable Vision (requires VLESS inner protocol)
      key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below
      protocol: ServerProxyConfig
      override_rules: [RuleConfig] # Optional rule override

  # Default TLS target (for unmatched/no SNI)
  default_tls_target:          # Aliases: default_target
    cert: string
    key: string
    # ... same fields as tls_targets

  # Reality targets (by SNI)
  reality_targets:
    "www.cloudflare.com":
      private_key: string      # X25519 private key (base64url)
      mldsa65_seed: string?     # Optional 32-byte ML-DSA-65 seed (unpadded base64url)
      short_ids: [string]      # Valid client IDs (hex, 0-16 chars)
      dest: string             # Fallback destination (e.g., "example.com:443")
      dest_client_chain: ClientChain?  # Optional proxy chain for reaching dest
      max_time_diff: 60000     # Max timestamp diff in ms (default: 60000)
      min_client_version: [1, 8, 0]  # Optional [major, minor, patch]
      max_client_version: [2, 0, 0]  # Optional [major, minor, patch]
      cipher_suites: [string]  # Optional TLS 1.3 cipher suites (see below)
      vision: false            # Enable Vision (requires VLESS inner protocol)
      protocol: ServerProxyConfig
      override_rules: [RuleConfig]

  # ShadowTLS v3 targets (by SNI)
  shadowtls_targets:
    "example.com":
      password: string
      handshake:
        # Local handshake (with own certificate):
        cert: string
        key: string
        alpn_protocols: [string]
        client_ca_certs: [string]
        client_fingerprints: [string]
        # OR Remote handshake (proxy to real server):
        address: string        # e.g., "google.com:443"
        client_proxies: [ClientConfig] # Optional proxies for handshake
      protocol: ServerProxyConfig
      override_rules: [RuleConfig]

  # Buffer size for TLS (optional, min 16384)
  tls_buffer_size: int

WebSocket

protocol:
  type: websocket              # Aliases: ws
  targets:
    - matching_path: string?   # Optional path filter (e.g., "/ws")
      matching_headers:        # Optional header filters
        X-Custom-Header: "value"
      max_early_data: 0        # 0: automatic standard-header reception, up to 2048 bytes
      early_data_header_name: string? # With positive max: empty/omitted selects path suffix
      protocol: ServerProxyConfig
      ping_type: ping-frame    # disabled | ping-frame | empty-frame
      override_rules: [RuleConfig]

WebSocket early data carries an initial byte prefix in the HTTP Upgrade request, not in a WebSocket frame and not as TLS 0-RTT. Clients send it only when explicitly enabled. Servers with omitted or zero max_early_data automatically accept it from Sec-WebSocket-Protocol, up to 2048 decoded bytes. A positive server limit selects explicit mode and only the configured carrier is read; there is no automatic fallback to the standard header. To change the automatic cap, set both a positive max_early_data and early_data_header_name: Sec-WebSocket-Protocol.

A target with a static Sec-WebSocket-Protocol filter in matching_headers suppresses automatic reception: its token remains a selector, with no prefix or subprotocol echo. Otherwise an unclaimed nonempty subprotocol token is treated as early data, not ordinary subprotocol negotiation. A valid base64url token such as chat injects decoded bytes; an invalid token is rejected. There is no general server-side off switch. Missing or uniquely empty headers remain plain WebSocket upgrades; duplicated carrier headers are rejected in automatic mode, even when the final value is empty.

Support is not negotiated: a successful upgrade, including an absent subprotocol echo, does not prove the server accepted the prefix. Automatic reception prevents the standard-header/default-server mismatch, but mismatched custom headers or path suffixes reaching a target without matching_path can still silently lose the prefix, particularly with raw port forwarding. Explicit path/custom-header targets also do not read the standard header. Configure matching carriers; no replay can recover a mismatched prefix safely.

Explicit targets accept at most max_early_data decoded bytes. Malformed, noncanonical or oversized base64url is rejected before the upgrade. Missing or empty early data is allowed. Decoded bytes precede all framed payload.

With early_data_header_name: Sec-WebSocket-Protocol, Shoes interoperates with Xray's /ws?ed=2048 client configuration and sing-box's named-header mode. Shoes uses explicit fields; do not put ?ed= in its matching_path. Other nonreserved HTTP header names are supported for sing-box peers. Names are case-insensitive; an explicit carrier cannot also appear in matching_headers. Use sing-box 1.14.3 or newer for client-side early data: older clients can discard the first response bytes when they arrive together with the HTTP upgrade.

With a positive limit and no header name, encoded bytes are appended directly to matching_path, as in sing-box's path mode. This requires an explicit, query-free server path. Requests matching multiple targets are rejected if any candidate has a positive limit or the first matching target would automatically receive data (including a duplicated carrier). Otherwise legacy first-match routing remains, including when the first target has a static subprotocol selector. A generic automatic target before a matching static-selector target is ambiguous for a nonempty token; target order is not changed to prefer selectors. Use disjoint paths/header filters rather than overlapping prefix paths or catch-all targets on the same listener.

Limits must fit the HTTP budget: each request/header line is less than 4096 bytes, the header block at most 32768 bytes, and fewer than 40 nonempty lines. Configuration validation includes base64 expansion and rejects limits that cannot fit. The aggregate 32768-byte cap is new and also applies to plain WebSocket upgrades. Clients now reject duplicate accept/subprotocol response headers and malformed status codes in both modes. Early data is visible to HTTP intermediaries and their logs; use TLS when confidentiality is required.

Port Forward

protocol:
  type: forward                # Aliases: port_forward, portforward
  targets: string | [string]   # Target address(es)

Hysteria2

protocol:
  type: hysteria2
  password: string
  udp_enabled: true            # Default: true

TUIC v5

protocol:
  type: tuic                   # Aliases: tuicv5
  uuid: string                 # UUID
  password: string
  zero_rtt_handshake: false    # Default: false (enables 0-RTT for lower latency)

AnyTLS

protocol:
  type: anytls
  users:                       # One or more users
    - name: string?            # Optional display name
      password: string         # User password
  udp_enabled: true            # Default: true (enables UDP over TCP)
  padding_scheme: [string]?    # Optional custom padding (e.g., ["stop=8", "0=30-30"])
  fallback: string?            # Optional fallback destination for failed auth

AnyTLS is a TLS-based multiplexing proxy protocol with traffic obfuscation. Should be used within TLS or Reality.

NaiveProxy

protocol:
  type: naiveproxy             # Aliases: naive
  users:                       # One or more users
    - name: string?            # Optional display name
      username: string         # Basic Auth username
      password: string         # Basic Auth password
  padding: true                # Default: true (enables padding protocol)
  udp_enabled: true            # Default: true (enables UDP over TCP)
  fallback: string?            # Optional path to serve static files for probe resistance

NaiveProxy implements HTTP/2 CONNECT with padding for censorship resistance. Should be used within TLS with alpn_protocols: ["h2"].

TUN Config

TUN (network TUNnel) devices operate at the IP layer (Layer 3), allowing shoes to act as a transparent VPN.

# Linux: Create TUN device by name
device_name: string            # Device name (e.g., "tun0")
address: string                # Device IP address (e.g., "10.0.0.1")
netmask: string?               # Netmask (e.g., "255.255.255.0")
destination: string?           # Gateway/destination (Linux only)

# iOS/Android: Use existing file descriptor
device_fd: int                 # FD from VpnService (Android) or NEPacketTunnelProvider (iOS)

# Common settings
mtu: 1500                      # Default: 1500 (Linux), 9000 (Android), 4064 (iOS)
tcp_enabled: true              # Default: true
udp_enabled: true              # Default: true
icmp_enabled: true             # Default: true
dns_hijack: true               # Optional: intercept TCP/UDP port 53 independently of fake-IP

# Routing rules
rules: [RuleConfig]

Platform notes:

  • Linux: Requires root or CAP_NET_ADMIN. Creates device with specified name/address.
  • Android: Use device_fd from VpnService.Builder.establish(). Routes configured via VpnService.
  • iOS: Use device_fd from NEPacketTunnelProvider.packetFlow.

Example (Linux):

- device_name: "tun0"
  address: "10.0.0.1"
  netmask: "255.255.255.0"
  mtu: 1500
  tcp_enabled: true
  udp_enabled: true
  rules:
    - masks: "0.0.0.0/0"
      action: allow
      client_chain:
        address: "proxy.example.com:443"
        protocol:
          type: tls
          protocol:
            type: vless
            user_id: "uuid"

Managed Linux Desktop Capture

auto_route: true gives the Shoes process ownership of one nonpersistent TUN, private routing table, policy rules, nftables table, and optional per-link DNS configuration. It captures host-originated TCP/UDP using conntrack-aware output marking. Both protocols use the existing TUN engine unless auto_redirect: true selects kernel TCP REDIRECT. The system nft executable runs only for control-plane changes and reconciliation, not per packet or connection.

See the desktop example and the systemd unit. The example forwards DIRECT; replace its rules with the desired proxy policy before using it as a proxy.

- device_name: shoes0
  auto_route: true
  auto_redirect: true
  addresses: [10.233.0.1/30, 'fd73:686f:6573::1/126']
  bootstrap_dns: ['1.1.1.1:53', '9.9.9.9:53']
  auto_dns: true
  dns_hijack: true
  dns:
    servers: ['udp://1.1.1.1', 'tcp://1.1.1.1']
  rules: allow-all-direct
Field Managed Behavior
auto_route Enables process-owned Linux capture; default false
auto_redirect Uses kernel TCP REDIRECT; requires auto_route; default false
addresses One local CIDR per family; cannot combine with manual address, netmask, destination, or supplied FDs
route_address Captured destination CIDRs; default [0.0.0.0/0, '::/0']
route_exclude_address Explicit destination exclusions, including DNS; default empty
bootstrap_dns Required literal real DNS socket addresses, never a loopback stub or TUN subnet
auto_dns Attempts to publish transient systemd-resolved settings on the owned link; default true
dns_hijack Intercepts TCP/UDP port 53; defaults true when managed, otherwise follows fake-IP policy
linux.table Private route table; default 12000
linux.rule_priority Policy rule priority; default 12000
linux.capture_mark Capture bit; default 536870912 (0x20000000)
linux.bypass_mark Shoes socket bypass bit; default 1073741824 (0x40000000)
linux.mark_mask Combined reserved bits; default 1610612736 (0x60000000)

Requirements: Linux, /dev/net/tun, trusted system nft with JSON support, and network-administration privileges. Automatic DNS additionally requires a running systemd-resolved, busctl with JSON support, and authorization to configure the owned link. Automatic DNS setup and cleanup are best-effort, with bounded helper calls. Missing services, failed namespace checks, and configuration errors produce warnings rather than stopping capture. Failed setup may leave partial per-link settings until cleanup; system DNS routing through Shoes is not guaranteed in that case. Resolver settings are not continuously monitored or restored after external changes, and resolver restarts do not stop the proxy. Namespace checks assume a standard local system bus whose listener and daemon share a PID namespace, with procfs mounted for that namespace; resolved must share Shoes' network namespace. Custom bus arrangements should use auto_dns: false. Restart Shoes to reapply automatic DNS after restarting the resolver or system bus. The supplied service runs as root with a bounded capability set. File capabilities on Shoes alone do not grant privileges to nft children or authorize resolved changes.

Use auto_dns: false for externally managed DNS. Shoes never rewrites /etc/resolv.conf. Bootstrap and native resolution use protected Shoes sockets while managed mode is active; listener bind addresses must be literal IPs. Real DNS does not require fake-IP. Automatic DNS requires interception and room after each interface address for its adjacent virtual DNS endpoint. Settings are published after ingress workers start, without a separate DNS readiness probe. The owned link disables DNS-over-TLS, DNSSEC validation, LLMNR, and mDNS; upstream encryption and DNS policy belong in Shoes' dns configuration. Cleanup attempts to revert per-link settings on the owned interface, even if another manager changed them while Shoes was running. Removing the owned TUN also discards its transient resolver settings. Other links are not changed. The managed TUN subnet is reserved for ingress: forwarding a connection back to that subnet is rejected, except local DNS handling.

The initial route must exist before OUTPUT capture, so every configured family requires an underlying main-table default route without source or TOS constraints. For explicitly IPv4-only capture, configure only an IPv4 address and route_address: [0.0.0.0/0]; IPv6 then remains native. This is not an IPv6 blocking policy.

Loopback, local destinations, multicast, broadcast, link-local destinations, and connected networks remain native. DNS to connected networks is intercepted unless explicitly excluded. Replies to inbound services remain native. Existing tracked native flows remain native, including unreplied UDP; captured flows retain their selection. Traffic excluded from conntrack by foreign NOTRACK rules remains native. New TCP capture starts only with connection establishment. Invalid conntrack packets selected for capture are dropped. UDP flows created before conntrack was enabled cannot be identified reliably and may reset. Applications explicitly bound to a physical device remain native in both modes; address-bound TCP/UDP clients are supported. REDIRECT requires an unused adjacent virtual peer in each TUN subnet. Its private routes use that peer as their next hop, and only traffic routed through that peer is redirected. Traffic to the peer itself, including virtual DNS, stays on the TUN engine. Foreign-marked traffic is left to existing policy. Unsupported protocols remain native; ICMP handling is the existing synthetic echo behavior, not remote ICMP forwarding. This is host capture, not gateway or container forwarding.

Conflicting tables, priorities, mark reservations, or preceding nonlocal policy rules cause startup rejection. Other VPNs and firewall managers require explicit coexistence validation; Shoes does not override foreign firewall decisions. REDIRECT also rejects native routes using its virtual peer as a gateway and opaque nexthop-object routes. Captured replies carry the capture mark before mark-aware reverse-path filtering. No universal VPN, firewall, resolver-manager, or distribution compatibility is promised.

Reload retains the interface, capture, and redirect listener identities. Network topology changes require restart and are rejected before retiring live workers. Ordinary worker reloads can reset existing connections and briefly interrupt traffic, but do not disable capture. Failed candidates roll back on the same network lease. Unexpected owned routing changes or a missing private firewall table cause orderly shutdown. Resolver-setting changes do not.

Shoes exclusively owns its token-named inet nftables table, including all of its contents. Do not add rules, chains, or sets to that table: external changes inside it are neither audited nor preserved. Cleanup removes the exact owned table even if its contents have changed; other tables are left untouched. Connected-network sets are updated atomically through nftables and cached in memory, not journaled as firewall snapshots.

Shutdown removes capture and attempts DNS cleanup before retiring ingress. SIGKILL cannot run cleanup: the service's ExecStopPost invokes shoes cleanup-tun, and the next managed startup also reconciles stale state. Recovery uses a private namespace-scoped journal under /run/shoes to track cleanup identity and pending operations, not firewall policies. Interface and routing ownership checks remain strict; conflicting changes are refused, with errors reported. Temporary outages and lost connections are possible. Cached fake addresses may outlive shutdown. There is no kill switch, no automatic DIRECT fallback on upstream failure, and no uninterrupted fail-open guarantee. Outstanding helper processes retain the namespace lock, so recovery fails rather than racing a helper that could still commit. The service terminates remaining helpers before ExecStopPost; standalone operators must let them finish or stop them before retrying cleanup. The service retries without a start-rate limit, including while a configured family is waiting for its underlying default route. Read failures during reconciliation currently trigger cleanup and a supervised restart, not in-place repair. Cleanup errors retain the journal for retry. Until the owned table is removed, stale TCP REDIRECT rules can continue to reject connections.

Client Config

Used in rules to specify upstream proxies.

address: string                # Proxy server address (e.g., "proxy.example.com:1080")
protocol: ClientProxyConfig
transport: tcp | quic          # Default: tcp; WireGuard only: udp (default, required)
bind_interface: string         # Optional, Linux/Android/Fuchsia only

tcp_settings:
  no_delay: true

quic_settings:
  verify: true                 # Default: true
  server_fingerprints: [string]
  sni_hostname: string
  alpn_protocols: [string]
  cert: string                 # Client certificate for mTLS
  key: string                  # Client key for mTLS
  key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below

WireGuard clients use address for their peer endpoint and reject TCP/QUIC socket settings. See WireGuard for their protocol fields.

Client Protocols

Hysteria2 Client

address: proxy.example.com:443
quic_settings:
  sni_hostname: proxy.example.com
  verify: true
protocol:
  type: hysteria2 # alias: hy2
  password: your-password
  udp_enabled: true

Transport defaults to QUIC and ALPN to h3. TLS verification, certificate fingerprints, client certificates, and key-exchange groups use quic_settings. The protocol must be outermost at chain hop 0. It can carry subsequent stream-based proxies, but cannot follow another proxy or run inside TLS, WebSocket, or another stream wrapper.

TCP streams and UDP associations share an authenticated connection. Connection replacement never migrates existing flows or replays application bytes. UDP queues and fragmented-packet reassembly are bounded; overload drops packets. UDP requires permission from the server. TCP target errors surface when reading the response, allowing application writes before the server's success response. HTTP/3 authentication headers are limited to 16 KiB decoded. The H3 dependency rejects buffered frames larger than 1 MiB from their declared length and discards unknown-frame payloads incrementally, without a lifetime byte budget. These limits do not apply to proxied TCP or UDP traffic.

Congestion control is adaptive (Quinn's default CUBIC), with unknown receive bandwidth advertised as zero. Brutal and fixed-bandwidth negotiation are not implemented; up_mbps, down_mbps, and 0-RTT options are rejected.

Optional obfs: {type: salamander, password: your-obfs-password} wraps QUIC datagrams with Salamander obfuscation (minimum four password bytes). The peer must use the same setting. It adds eight bytes per wire datagram; the minimum path UDP payload is therefore 1208 bytes. Shoes' Hysteria2 server does not yet implement this option.

Optional port hopping uses a dedicated connection-owned endpoint:

protocol:
  type: hysteria2
  password: your-password
  hopping:
    ports: '443,20000-30000' # Inclusive ranges, nonzero ports
    interval_secs: 30       # Minimum 5; default 30
    # Alternatively: min_interval_secs: 5, max_interval_secs: 30

All listed ports must reach the same server connection state (usually through server-side UDP port redirection). A hostname is resolved once per connection; hops retain that IP and randomly choose a destination port. Each hop creates a fresh protected, interface-bound socket and retains the preceding socket for late replies until the next hop. Hopping can be combined with Salamander.

TUIC Client

address: proxy.example.com:443
quic_settings:
  sni_hostname: proxy.example.com
  verify: true
protocol:
  type: tuic # alias: tuicv5; version 5 only
  uuid: 01234567-89ab-4def-8123-456789abcdef
  password: your-password
  udp_enabled: true
  udp_relay_mode: native # native QUIC datagrams or quic unidirectional streams
  heartbeat_interval_secs: 10 # 1..30

Transport defaults to QUIC, ALPN to h3. Placement and TLS settings follow the Hysteria2 client rules above. QUIC datagrams are required for heartbeats even with stream-mode UDP. Authentication uses the current TLS session exporter; TUIC has no authentication acknowledgement or TCP success response. Wrong credentials surface when the peer closes the connection, not necessarily during stream setup. Client 0-RTT is not supported.

UDP associations share a bounded, connection-owned receiver. Association IDs are never reused within a connection; exhaustion creates a new connection while existing flows retain the old one. Explicit shutdown sends DISSOCIATE with a deadline; dropping an association queues a bounded best-effort notification. Up to 64 command writers run concurrently. Admission has a five-second deadline; once admitted, a write completes even if its caller is cancelled, or fails when the connection closes. This prevents partial commands from disrupting other flows. The default incoming uni-stream credit is 1024 (reduced by an explicit max_streams_per_connection), with at most 64 concurrent command readers. Very small stream limits can make external servers reject bursty stream-mode UDP replies; prefer native mode when strict stream limits are required.

Direct

protocol:
  type: direct

HTTP

protocol:
  type: http
  username: string?
  password: string?
  http_version: h1             # h1 (default) or h2
  resolve_hostname: false      # Resolve destinations locally before CONNECT

Both versions establish TCP CONNECT tunnels. When the H2 proxy is the first and only hop, eligible routes reuse a connection for multiple concurrent tunnels. Chained H2 hops use the carrier established through the selected prefix rather than sharing tunnels across routes. H1 retains its existing one-tunnel-per-connection behavior. There is no automatic version fallback or replay after a failed CONNECT.

For H2 over TLS, set http_version: h2 here and alpn_protocols: [h2] on the enclosing TLS client. The handshake must negotiate h2. Omitted or explicit http_version: h1 preserves legacy behavior: it sends HTTP/1.1 without checking the negotiated ALPN. Set alpn_protocols: [http/1.1] on the enclosing TLS client when the peer requires ALPN to match the HTTP version. Cleartext H2 uses prior knowledge. See examples/http2-proxy.yaml.

SOCKS5

protocol:
  type: socks
  username: string?
  password: string?

Shadowsocks

protocol:
  type: shadowsocks
  cipher: string
  password: string

For 2022-blake3-aes-128-gcm and 2022-blake3-aes-256-gcm, a client can connect to a multi-user server using password: "server-key:user-key". Each component is independently base64-encoded and must decode to 16 bytes for AES-128 or 32 bytes for AES-256. For identity relays, use password: "relay-key:server-key:user-key", with identity keys in connection order and the user key last. These are SS2022 extensible identity headers (EIH), not separate entries in client_chain.

Single-key SS2022 configurations are unchanged. Identity chains are client-only and are not supported by 2022-blake3-chacha20-poly1305. Legacy Shadowsocks passwords may still contain literal colons. Shoes does not accept multi-user identity chains in server configurations.

udp_enabled: true on the client uses the same identity handshake for SagerNet UDP-over-TCP and requires a UoT-capable upstream. It does not enable native SS2022 UDP. See examples/shadowsocks-2022-client.yaml.

Snell

protocol:
  type: snell
  cipher: string
  password: string

VMess

protocol:
  type: vmess
  cipher: string
  user_id: string
  h2mux:                         # Optional h2mux multiplexing (see below)
    max_connections: 4
    min_streams: 4
    max_streams: 0
    padding: false

Note: VMess AEAD mode is always enabled. The legacy aead field is deprecated.

VLESS

protocol:
  type: vless
  user_id: string
  h2mux:                         # Optional h2mux multiplexing (see below)
    max_connections: 4
    min_streams: 4
    max_streams: 0
    padding: false

Trojan

protocol:
  type: trojan
  password: string
  shadowsocks:                 # Optional
    cipher: string
    password: string
  h2mux:                         # Optional h2mux multiplexing (see below)
    max_connections: 4
    min_streams: 4
    max_streams: 0
    padding: false

H2MUX Multiplexing

H2MUX multiplexes multiple proxy streams over a single HTTP/2 connection, reducing connection overhead. Compatible with sing-box. Available for VMess, VLESS, and Trojan client protocols.

h2mux:
  max_connections: 4           # Maximum connections to maintain (default: 4)
  min_streams: 4               # Min streams before opening new connection (default: 4)
  max_streams: 0               # Max streams per connection, 0 = unlimited (default: 0)
  padding: false               # Enable padding for traffic obfuscation (default: false)

Server support: H2MUX is auto-detected on servers. No configuration needed.

TLS Client

protocol:
  type: tls
  verify: true                 # Default: true
  server_fingerprints: [string]
  sni_hostname: string
  alpn_protocols: [string]
  tls_buffer_size: int
  cert: string                 # Client certificate for mTLS
  key: string                  # Client key for mTLS
  vision: false                # Enable Vision (requires VLESS inner protocol)
  key_exchange_groups: [X25519MLKEM768] # Optional; see TLS Key Exchange below
  protocol: ClientProxyConfig

TLS Key Exchange

key_exchange_groups is an optional, ordered allowlist for ordinary TLS clients, TLS server targets (including default_tls_target), and client/server quic_settings. It applies to that TLS layer, regardless of the inner proxy protocol. Supported names are case-sensitive:

  • X25519MLKEM768: native rustls X25519 + ML-KEM-768 hybrid.
  • SecP256r1MLKEM768: native rustls P-256 + ML-KEM-768 hybrid.
  • X25519, SecP256r1, SecP384r1: classical groups.

Omitting the field preserves rustls defaults: currently X25519+ML-KEM-768 is preferred, with classical fallback. An explicit list replaces those defaults; the first client group normally supplies the initial key share. Empty lists, duplicates, null, unknown names, standalone ML-KEM, and SecP384r1MLKEM1024 are rejected. No custom cryptographic implementations are used.

Client QUIC allowlists require a non-direct client at the first chain hop (hop 0), where Shoes creates the QUIC transport. Explicit allowlists on direct clients or later hops are rejected, including entries resolved from groups or pools. Use ordinary TLS in a later hop's protocol when that hop needs its own hybrid-protected tunnel.

To require hybrid exchange without classical fallback:

key_exchange_groups: [X25519MLKEM768, SecP256r1MLKEM768]

Hybrid-only lists force TLS 1.3 and disable early application data. TLS 1.3 resumption remains enabled and still performs a fresh exchange using an allowed hybrid group. Peers offering only classical groups or TLS 1.2 cannot connect. For opportunistic hybrid exchange with explicit classical fallback, use a mixed list such as [X25519MLKEM768, X25519, SecP256r1].

Hybrid-only lists cannot be combined with vision: true on the same TLS layer: Vision direct copy bypasses that layer's encryption. They also cannot be combined with TUIC zero_rtt_handshake: true. Ordinary outer TLS or QUIC can still protect a separate inner Vision connection. The setting does not apply to REALITY, VLESS encryption, or ShadowTLS camouflage handshakes; it is rejected on ShadowTLS configurations and with deprecated shadowtls_password.

Hybrid key exchange protects session-key establishment, not certificate signatures. Certificate verification, fingerprints, and mTLS work as before. verify: false without a trusted fingerprint still permits active impersonation.

Reality Client

protocol:
  type: reality
  public_key: string           # Server's X25519 public key (base64url)
  mldsa65_verify: string?      # Optional pinned 1952-byte ML-DSA-65 key (unpadded base64url)
  short_id: string             # Your client ID (hex, 0-16 chars)
  sni_hostname: string         # SNI to send (must match server's reality_targets key)
  cipher_suites: [string]      # Optional TLS 1.3 cipher suites (see below)
  vision: false                # Enable Vision (requires VLESS inner protocol)
  protocol: ClientProxyConfig  # Inner protocol (typically VLESS)

Reality cipher suites: Valid values are TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256. If not specified, all three are offered/supported.

Reality offers X25519MLKEM768 first, with X25519 fallback. Servers follow the camouflage target's selected group; choose a PQ-preferring TLS 1.3 target to negotiate hybrid key exchange. Legacy X25519 clients remain supported. Debug logs report the authenticated connection's negotiated_group.

For Xray servers, use v25.5.16 or newer. Xray v25.4.30 and earlier cannot synthesize a hybrid-selected target handshake: when a PQ-capable target selects X25519MLKEM768, those servers forward to the real target and Shoes rejects its certificate. X25519 fallback does not prevent this failure because the target has already selected the hybrid group.

mldsa65_seed enables optional server signing. Clients with mldsa65_verify require a matching signature and reject unsigned or incorrectly signed connections. These fields correspond to Xray's mldsa65Seed and mldsa65Verify; neither replaces the existing X25519 key. Omit the fields to disable this extra authentication; empty or incorrectly sized values are invalid.

Enable server signing before distributing verification keys. The camouflage certificate flight must have room for the extra 3309-byte signature and DER overhead; insufficient capacity falls back to the real target instead of silently removing the signature. Removing signing later requires coordinating clients that already pin its key. Vision's direct mode relies on the inner TLS connection for payload encryption, not the outer Reality hybrid exchange.

ShadowTLS Client

protocol:
  type: shadowtls
  password: string
  sni_hostname: string?        # Optional SNI override
  protocol: ClientProxyConfig

WebSocket Client

protocol:
  type: websocket
  matching_path: string?
  matching_headers:
    header_name: string
  max_early_data: 0            # Decoded-byte limit; 0 keeps eager upgrades
  early_data_header_name: string? # Empty/omitted: path suffix
  ping_type: ping-frame        # disabled | ping-frame | empty-frame
  protocol: ClientProxyConfig

Example for Xray or sing-box protocol-header mode:

protocol:
  type: websocket
  matching_path: /ws
  matching_headers:
    Host: proxy.example.com
  max_early_data: 2048
  early_data_header_name: Sec-WebSocket-Protocol
  protocol:
    type: vless
    user_id: "11111111-1111-4111-8111-111111111111"

Early-enabled clients require an explicit matching_headers.Host identifying the proxy, not the tunneled destination. Omit early_data_header_name for sing-box path-suffix mode; that mode does not support query strings. Sending remains off by default. Default Shoes servers accept standard-header early data up to 2048 bytes without additional server configuration; path and custom-header carriers require explicit server configuration.

The first nonempty write contributes up to the configured limit; remaining bytes use ordinary WebSocket frames. For vectored writes, only the first nonempty slice contributes to the early prefix. Inner protocol headers normally make up this first write, not the application's first payload. A read, flush, shutdown or enabled ping before the first write performs an ordinary upgrade instead. Empty reads/writes do not initiate it. Upgrade failures are terminal and never replay the accepted prefix. Deferred upgrades retain the 60-second setup timeout.

Port Forward (No-op)

protocol:
  type: portforward            # Aliases: noop

Passes through the raw connection without protocol wrapping. Useful for testing or transparent proxying.

AnyTLS Client

protocol:
  type: anytls
  password: string             # User password
  udp_enabled: true            # Default: true (enables UDP over TCP)
  padding_scheme: [string]?    # Optional custom padding scheme

NaiveProxy Client

protocol:
  type: naiveproxy             # Aliases: naive
  username: string             # Basic Auth username
  password: string             # Basic Auth password
  padding: true                # Default: true (enables padding protocol)

NaiveProxy retains its own HTTP/2 client transport policies, independent of http_version: h2 on standard HTTP proxies: 256 KiB stream and connection receive windows, the H2 library's 16 MiB response-header decoding limit, and no periodic HTTP/2 PING watchdog. Peer stream limits and timeouts still apply. In particular, Caddy v2.11.7 defaults to a one-minute request-body idle timeout, which can end a quiet CONNECT tunnel; connection-level PINGs do not reset that timeout.

Native DNS Freshness

Omitting a listener's dns or leaving dns.servers empty uses native OS/NSS hostname resolution with a 60-second application memoization window. The same default resolver serves controller probes. The window starts when a successful lookup finishes; an entry expires at the window boundary. All returned usable addresses and their original order are retained. Errors and empty results are not cached, and expired answers are not served on failure. Each cache has a lazy, bounded 10,000-entry LRU keyed by exact hostname; results are shared across ports, with each caller receiving its requested port. Eviction can cause earlier refresh. Concurrent misses share a lookup, up to 1,024 participating callers per resolver. Excess callers look up independently; this is not an OS lookup concurrency cap. Cancelling the owner makes surviving callers retry within their remaining deadlines. No background refresh is added, and already-running OS calls cannot be stopped.

Explicit dns: {servers: system} continues to use OS/NSS lookup without application memoization or concurrent lookup sharing. To select a different fixed window, use a system server options object, inline or in a named DNS group:

dns:
  servers:
    url: system
    native_cache_secs: 15

native_cache_secs accepts an integer from 0 to 4294967295 seconds. Omitted or 0 disables memoization for this explicit system entry. It is rejected on non-system upstreams, including when set to 0; unknown options and invalid values are rejected. The option belongs to the server options object, not the outer dns object. Existing timeout_secs remains a lookup deadline, not cache freshness; connect_timeout_secs and attempts do not control native caching.

This is a load/freshness trade-off, not DNS TTL compliance: the OS lookup API does not provide record TTLs. Shorter windows increase OS lookups; disabling the Shoes cache cannot bypass caches in the OS, NSS modules, or upstream resolvers. Native hosts-file/NSS behavior is preserved. New flows can see updated answers after expiry; established flows retain their pinned destination. This setting controls native hostname lookup caching, not every DNS-related cache. Hickory caches and Fake-IP mappings/TTLs are unchanged.

See the DNS tuning example.

Fake DNS

Fake DNS is opt-in on a server (including WireGuard) or TUN configuration:

dns:
  servers: "tls://1.1.1.1"
  fake_ip: true

It intercepts logical TCP and UDP destinations on port 53 after inbound authentication/admission, before destination rules. It does not open a DNS listener or intercept encrypted DNS. Intercepted DNS is answered locally or forwarded through dns.servers, never to the application's requested resolver. An explicit rule override to port 53 is ordinary forwarding, not interception.

A/AAAA synthesis creates scoped mappings. Later connections to those addresses restore the hostname before routing or hostname sniffing. A restored hostname is resolved normally when a rule or direct connection needs a real IP; a proxy can receive the hostname instead. With sniff: true, visible TLS SNI or HTTP Host takes precedence for hostname rules only; the restored name remains the connection target and is what IP rules resolve. Both hints are client-controlled, so a different sniffed name can bypass a hostname deny rule on the restored name. Unmapped addresses inside the receiving context's ranges fail closed. Addresses from another context's non-overlapping range are not recognized and are routed as ordinary IPs. Overlapping ranges may map to an unrelated hostname in the receiving context. Use one shared named DNS group for inbounds serving the same clients. Established flows keep their chosen target.

false disables the feature. An options object enables it with these defaults:

fake_ip:
  ipv4_range: "198.18.0.0/15"
  ipv6_range: null
  max_entries: 65536
  bypass_domains: []
  missing_family: forward
  https_records: forward
  state_file: null
  • Pools must be CIDRs with usable addresses; reserve non-conflicting ranges and route them through the same ingress. The base and base+1 addresses are reserved, as is IPv4 broadcast. Real upstream, bootstrap, proxy, configured forwarding/fallback targets, and literal rule-override addresses cannot be inside any active or retained fake range. These checks apply even to inbounds without Fake DNS or with it explicitly disabled.
  • TUN interface and gateway addresses cannot overlap any active or retained pool, except its reserved base+1 address. An address inside overlapping pools must be reserved in every one of them, regardless of the TUN's DNS context.
  • max_entries bounds retained domain mappings, not the allocation range. Least-recently-used domains are evicted; sequential allocation traverses the entire range before reuse. Synthetic answers have a 60-second TTL. This is not a guarantee that a mapping remains valid for 60 seconds under pressure.
  • bypass_domains uses the normal hostname-mask suffix matching, including subdomains. Excluded names and IP-shaped questions are forwarded.
  • missing_family: forward forwards queries for a disabled family; nodata returns an empty successful answer instead.
  • https_records: forward preserves HTTPS/SVCB answers; suppress returns empty successful answers for both types. Forwarded address hints and ECH can bypass hostname-based routing; suppression trades away those capabilities.
  • Other record types use real upstream exchange. Valid negative/error answers are retained. Multi-question queries receive FORMERR; TSIG/SIG(0), zone transfers, and non-query operations are refused. Synthesized answers are unsigned and do not claim DNSSEC authentication.

Named DNS groups share one pool across every referencing inbound:

- dns_group: shared-fake
  dns_servers: ["tls://1.1.1.1"]
  fake_ip: true
- address: "0.0.0.0:1080"
  protocol: {type: socks}
  dns: {servers: shared-fake}

Inline fake_ip creates a private context, even when servers references a named group. Omit the inline override to share the group's mappings. Combining fake-enabled groups requires an explicit policy on the composed group.

Private context identity uses the server transport and configured listen addresses (including their order), or the Unix socket path. TUN contexts use device_name (default when omitted); WireGuard contexts use the name/public-key identity described above. Changing these inputs changes private pool and snapshot ownership. Use a named dns_group when ownership must survive listener changes or address reordering. An existing state file with a different owner is rejected.

Compatible reloads retain mappings, including explicit disable/re-enable under the same group/inline identity. Pool ranges, capacity, or state-file ownership changes require restart. Without state_file, restart loses the mappings. Previously active synthetic ranges and endpoints remain excluded from real DNS, carrier resolution, and configured forwarding/override targets until restart, including after a context is removed. With a state file, snapshots are private, atomically replaced, locked against multiple writers, and checked for pool and context identity compatibility. Create its parent directory before startup; protect it because the file records queried domains. State filenames must not end in .lock (case-insensitive); that suffix is reserved for ownership locks. Cursor reservations are persisted before issuance; a crash can lose recent mappings, which then fail closed rather than restoring an obsolete owner. Back up or explicitly remove incompatible state before changing its pool configuration. Persistent families need at least two allocatable addresses, and persistent pools are limited to 383,473 entries to fit the snapshot read limit; unusually long context names reduce the available snapshot capacity. Reservations may skip unused addresses after restart; Windows power-loss durability and state-file ACLs require platform-specific verification.

Omitted servers uses system DNS. Desktop discovery is a periodically refreshed endpoint snapshot, not the OS's complete split/scoped DNS policy. Keep discovered resolvers and any loopback stub's own upstream traffic outside TUN capture to avoid recursive interception. Android/iOS hosts must supply underlying real dns.servers explicitly; automatic VPN system discovery is not supported. Host-local TUN deployments should configure explicit real upstreams: native hostname lookups can otherwise re-enter the host's intercepted resolver. Fake-enabled system configurations fail startup if no usable endpoints can be discovered. Later upstream failures produce DNS failure, not a public-provider fallback. UDP and HTTP/3 upstreams remain direct-only; TCP/TLS/HTTPS can use client chains. UDP truncation retries TCP only when that transport is explicitly configured. Groups with mixed upstream settings try their remaining configured resolvers for a complete successful or negative reply before returning a truncated answer. The retained truncated reply is returned if fallback fails or reaches the forwarding deadline; it still passes the real-address guards. Local UDP DNS peers process one query at a time with bounded backpressure; a forwarded query can delay subsequent synthetic answers until its deadline.

SOCKS, UoT, H2MUX, XUDP, Hysteria2, and TUIC preserve the original logical reply address. Snell's reply format supports only IP addresses: hostname-addressed DNS requires a real lookup for its reply identity and fails if that lookup fails. Literal-address DNS interception remains fully local on Snell.

See the shared-pool example.

Rules System

Rules determine how incoming connections are routed.

TCP Hostname Sniffing

Set sniff: true on a server (including WireGuard) or TUN configuration to use visible TLS ClientHello SNI or cleartext HTTP/1.0 and HTTP/1.1 Host for hostname rules. The default is false. The option applies to logical TCP streams, including multiplexed and QUIC-carried TCP, not UDP/QUIC Initial packets, HTTP/2 or HTTP/3 application headers, or the outer TLS carrier handshake. Nested override_rules inherit the setting.

Sniffing applies to literal IP targets, including restored Fake DNS mappings, when hostname rules exist. Explicitly supplied hostnames are not sniffed. The sniffed hostname takes precedence over the restored DNS name for hostname rules only. IP/CIDR masks and the outbound destination still use the original real IP or restored DNS hostname, including through proxy chains. IP rules resolve the restored hostname when needed, never the sniffed name. An explicit rule override_address can still change the destination.

Capture is best-effort, limited to 300 ms and 64 KiB of wire data. Unknown, malformed, oversized, or incomplete TLS or HTTP falls back to ordinary routing using the original IP or restored DNS hostname; captured bytes are replayed unchanged. Transport errors still terminate the connection. Server-first applications can incur the full delay. When more client bytes are needed, response-gated protocols send success before routing/dialing; a later failure closes or resets the stream rather than sending another status.

HTTP inspection accepts origin-form requests (GET /path HTTP/1.1) and OPTIONS *, on any destination port. It requires a complete CRLF-delimited header section with exactly one valid Host and at most 128 fields. Host names are matched case-insensitively with an optional terminal DNS dot; a numeric port is validated but never changes the destination or rule-matching port. IP-literal Host values, duplicate Host fields, absolute-form requests, and CONNECT requests produce no hint. Request bodies are not parsed or awaited.

The probe sees application bytes after proxy setup: the outer CONNECT headers are not sniffed. A plaintext request inside a tunnel exposes Host; an inner HTTPS connection still exposes only visible TLS SNI. Normal HTTP forward-proxy requests are rewritten to origin-form before reaching the probe.

Routing happens once per logical TCP stream. Later keep-alive or pipelined requests with different Host values, and traffic after an HTTP Upgrade, remain on the first route without another hostname check. Existing sniff: true configurations now also use HTTP Host; omitted or false settings are unchanged. See the HTTP Host example.

SNI and Host are client-controlled, not authenticated identity or a domain firewall. Put mandatory IP deny rules before hostname allows. With ECH, only the visible outer name is available, not the encrypted inner hostname. For Fake DNS flows, that outer name takes precedence over the restored hostname for hostname rules. Disable sniff when routing must follow the restored name, or configure rules for the ECH public names.

Rule Config

rules:
  - masks: Mask | [Mask]      # Strings, typed predicates, file sources, or group references
    no_resolve: boolean?      # Default false; known IPs still participate
    action: allow | block     # Default allow
    # For action: allow
    override_address: string?  # Optional address override
    client_chain: ClientChain | [ClientChain]  # Proxy chain(s) for routing

Client Chains

Client chains define how traffic is routed through upstream proxies. Each chain is a sequence of "hops" - proxies that traffic passes through in order.

# Single proxy (simplest form)
client_chain: my-proxy-group           # Reference a named group
client_chain:                          # Or inline config
  address: "proxy.example.com:1080"
  protocol:
    type: socks

# Multi-hop chain (traffic goes: client -> hop1 -> hop2 -> target)
client_chain:
  chain:
    - first-proxy-group
    - second-proxy-group

# Multiple chains (round-robin selection)
client_chains:
  - us-proxy-group                     # Chain 1: single hop
  - chain: [proxy1, proxy2]            # Chain 2: multi-hop

# Load balancing at a hop (pool)
client_chain:
  chain:
    - pool: [us-proxies, eu-proxies]   # Round-robin between pool members
    - final-proxy

Migration note: The client_proxy / client_proxies fields still work but are deprecated. Please migrate to client_chain / client_chains.

Mask Syntax

# IP/CIDR masks
masks: "0.0.0.0/0"             # Any destination, including hostnames and IPv6
masks: "::/0"                  # Same legacy universal predicate
masks: "192.168.0.0/16"        # Subnet
masks: "10.0.0.1:80"           # Specific IP and port

# Hostname masks
masks: { subdomains: [google.com] } # Descendants, excluding the apex
masks: "example.com"           # Name and all descendants

# Multiple masks
masks:
  - "192.168.0.0/16"
  - "10.0.0.0/8"
  - "internal.com"

Built-in Rule Groups

  • allow-all-direct - Allow all connections, direct routing
  • block-all - Block all connections

Typed Destination Predicates

masks accepts legacy strings, typed predicates, file sources, and action-free group references. A list combines its entries with OR. Predicate objects contain one operator: all, any, not, or a leaf. The same forms work recursively inside all/any/not; strings always remain legacy masks, never group names or paths. Lists inside a typed leaf are alternatives.

Keep mixed legacy masks and imported groups in one OR list: splitting them into separate policies can change ordered DNS evaluation.

rules:
  - masks:
      all:
        - domain_suffix: [example.com]
        - destination_port: [443]
    action: allow
  - masks: { any_destination: true }
    action: block

Leaves: domain (exact), domain_suffix (apex and descendants), subdomains (descendants only), domain_pattern (* matches one label), domain_keyword, domain_regex, ip_cidr, destination_port, destination_port_range (inclusive "8000:9000"), and any_destination: true. Native domain literals and keywords are case-insensitive. Regexes are unanchored and case-sensitive unless they enable case folding; backreferences, lookaround and non-ASCII patterns are unsupported. Typed ports are 1-65535. Empty sets match nothing; empty all/any nodes are rejected.

Policies still use first-match order. Inside one policy, a known OR match or AND miss can avoid DNS, regardless of predicate order. An earlier unresolved policy cannot be bypassed by a later domain rule. IP predicates use the selected local DNS answer, pinning it for direct dialing; lookup failure aborts routing. Direct multi-address fallback is disabled after this pinning. Hostname-forwarding proxy protocols may still resolve independently at the exit.

no_resolve: true on a rule prohibits initiating DNS, but consumes an address already supplied/resolved. Without an address, IP predicates remain unknown, including under not; an undecidable policy falls through even when its action is block. Domain predicates without a hostname are false, including for literal-IP connections without a sniff hint. No reverse DNS is performed.

Migration: legacy *.x and .x masks are rejected at startup and reload; replace them explicitly with subdomains or domain_pattern. Bare masks still include the apex and descendants. Legacy /0 masks remain universal without DNS, but typed ip_cidr: ["::/0"] is IPv6-only and requires an IP. Other legacy IPv6 masks no longer match IPv4 through numeric overlap; mapped-only legacy masks normalize to IPv4 with a warning. Explicit typed mapped IPv6 predicates must use IPv4 spelling. Invalid supplied hostnames (including non-ASCII, whitespace, empty labels and excessive length) are now rejected even with catch-all-only rules; use A-label/punycode names. Invalid sniff hints are ignored. Matching strips one root dot and lowercases ASCII without changing the dial target.

Local Rule Sources

Declare an action-free rule_group containing file paths, or load a file inline under masks. Sources are read and compiled during validation, including --dry-run, before a reload stops existing listeners. Invalid sources reject the candidate. Automatic reload watches imported files as well as configs. A parseable rejected candidate keeps its source paths watched, so creating or repairing a missing file can recover without another config edit. Active sources remain watched until a replacement activates. --no-reload disables automatic full reloads; SIGHUP still reloads, and live server-certificate rotation remains enabled. Established connections retain their original rules until they drain. New routing caches belong to the new configuration; Fake DNS pools are preserved separately.

- rule_group: blocked
  rules:
    - lists/domains.srs
    - path: lists/addresses.json
      format: singbox_json

- address: "0.0.0.0:1080"
  protocol: {type: socks}
  rules:
    - masks: {rule_group: blocked}
      action: block
    - masks: {path: lists/extra.srs}
      action: block
    - masks:
        - "192.168.0.0/16"
        - "internal.example"
        - path: lists/direct.txt
          behavior: domain
      action: allow

Sources and groups also compose with typed predicates:

rules:
  - masks:
      all:
        - rule_group: blocked
        - destination_port: [443]
        - not: {path: lists/exceptions.srs}
    action: block

File groups OR their sources together. A source is a path string inside a group's rules, or an object with path and optional format, behavior, code, and attributes. Inline sources under masks require the object form; a mask string is always an IP/CIDR/hostname. For example, masks: blocked matches that hostname, not the group above. No filesystem or name-existence heuristic changes this.

Actions and routing options belong to the referencing policy, not the sources. Omitting action still means allow; an action-free group does not imply block. Source objects reject action fields and cannot be mixed with predicate operators. Sources with the same canonical path and decoding options share one compiled set, including across named groups and inline uses. DAT codes and attribute names are case-insensitive; attribute order and duplicates do not affect sharing. For MRS, omitted and explicitly supplied behavior values use separate cache entries. Each generation accepts at most 1,024 distinct sources.

Explicit format overrides detection. Otherwise SRS and Zstd signatures select the SRS and MRS decoders, followed by case-insensitive .srs, .mrs, .json, .yaml/.yml, and .txt suffixes. Zstd data must still contain a valid MRS payload. Unknown formats require an explicit format; DAT always requires geosite_dat or geoip_dat. YAML/text still require behavior.

Source paths are relative to the declaring config file, including inline sources inside nested predicates and protocol overrides, even when multiple configs are combined. FFI string configs require absolute source paths. Existing PEM cert/key paths remain relative to the working directory; absolute paths avoid mixing these conventions.

SRS versions 1-5 and sing-box source versions 1-5 support destination domain, suffix, keyword, regex, IP, port, inversion and nested logical rules. Unsupported fields, tags and versions fail validation, including inside inverted expressions. Source address alternatives form an OR; destination ports constrain that OR. An empty top-level set matches nothing. Empty individual rules are invalid. sing-box literal spelling is preserved: uppercase domain/suffix/keyword entries cannot match normalized lowercase queries and produce a warning. Regex patterns retain their own case flags; only the bounded ASCII Rust regex subset is accepted.

Files are limited to 64 MiB, inflated payloads to 256 MiB, and compilation has a 512 MiB conservative allocation budget. Each source is limited to 2 million entries. Temporary decode/build charges are released between sources; retained indexes count toward the generation budget. These are safety ceilings, not a mobile memory guarantee. Runtime regex caches are separate per worker and generation; capture tracking is disabled and each lazy-DFA cache is capped at 256 KiB. Regex groups accept at most 512 KiB of pattern text, with syntax-proportional compilation reserves. Remote URLs and GeoIP .db (MMDB) files are not supported; use country SRS files from sing-geoip instead.

Mihomo providers use format: mihomo_yaml (a payload string list), format: mihomo_text (one entry per line, blank lines and #/// comments ignored; one leading UTF-8 BOM is accepted), or format: mrs. YAML/text require behavior: domain, ipcidr, or classical. MRS supports domain/IP only; an optional configured behavior must match its header. MRS accepts one zstd frame with at most an 8 MiB window. YAML supports block and flow sequences in one mapping containing only payload; tags, anchors, aliases and multiple documents are rejected. Parser read-ahead between events is limited to 128 KiB, including comments and whitespace.

Domain providers distinguish bare exact names, +.example.com (apex and all descendants), .example.com (descendants only), and * as one whole nonempty label. Literal labels are case-insensitive. Classical providers accept DOMAIN, DOMAIN-SUFFIX, DOMAIN-KEYWORD, DOMAIN-REGEX, IP-CIDR, IP-CIDR6, and DST-PORT. Entries are alternatives, not paired independent lists. IP entries may end with ,no-resolve; destination ports accept 80/443/8000-9000. Other operators/options fail validation. Classical keywords and regexes are case-insensitive, with inline regex case overrides preserved. Unlike some Mihomo classical paths, Shoes normalizes incoming query names to lowercase.

Xray geodata uses explicit format: geosite_dat or format: geoip_dat, with a required case-insensitive code: CN category selector. GeoSite optionally accepts attributes: [ads]: every listed attribute must have a true boolean value on the domain. Unlike Xray's key-presence filters, false, integer and key-only attributes do not satisfy this filter. Inline @ selectors and other selection syntax are rejected. GeoIP supports reverse_match and separate IPv4/IPv6 membership. The loader scans protobuf framing without decoding unselected category bodies; selected entries reject unknown fields/enums and unsupported regex syntax. DAT has no relation to the MMDB .db files in sing-geoip releases.

Example Rules

rules:
  # Direct connection for local networks
  - masks: ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"]
    action: allow
    client_chain:
      protocol:
        type: direct

  # Block specific domains
  - masks:
      any:
        - subdomains: [ads.example.com]
        - domain_suffix: [tracking.example.com]
    action: block

  # Route through upstream proxy
  - masks: "0.0.0.0/0"
    action: allow
    client_chain:
      address: "proxy.example.com:1080"
      protocol:
        type: socks

Named Groups

Client Proxy Group

- client_group: my-upstream
  client_proxies:              # Define proxies in this group
    - address: "proxy1.example.com:1080"
      protocol:
        type: socks
    - address: "proxy2.example.com:1080"
      protocol:
        type: socks

# Reference in rules
- address: "0.0.0.0:8080"
  protocol:
    type: http
  rules:
    - masks: "0.0.0.0/0"
      action: allow
      client_chain: my-upstream  # Reference by name

Rule Group

A group contains either ordered policies or action-free file sources. Member shape determines its kind: objects with masks are policies, even when action is omitted; paths and {path: ...} objects are sources. Empty groups and mixed kinds are rejected. Both kinds share one namespace, including built-in names. rule is an alias for rules; either accepts one item directly or a nonempty list.

Policy groups are referenced from rules or override_rules, preserving order, actions, and routing options. Source groups are referenced only with masks: {rule_group: name}, with the caller's action. Cross-kind references are errors: actions are never discarded or invented. Group nesting is not supported.

- rule_group: standard-rules
  rules:
    - masks: ["192.168.0.0/16"]
      action: allow
      client_chain:
        protocol:
          type: direct
    - masks: "0.0.0.0/0"
      action: allow
      client_chain: my-upstream

# Reference in server config
- address: "0.0.0.0:8080"
  protocol:
    type: http
  rules: standard-rules        # Reference by name

Named PEMs

Define certificates once and reference throughout configuration.

# From file
- pem: my-cert
  path: /path/to/certificate.pem

# Inline data
- pem: my-key
  data: |
    -----BEGIN PRIVATE KEY-----
    ...
    -----END PRIVATE KEY-----

# Reference in config
- address: "0.0.0.0:443"
  protocol:
    type: tls
    tls_targets:
      "example.com":
        cert: my-cert          # Reference by name
        key: my-key
        protocol:
          type: http

Certificate Sources

Ordinary TLS targets and QUIC listeners can share a named certificate source. Use certificate_source instead of cert and key; combining them is an error. Existing inline certificates, file paths, and named PEMs remain supported.

- certificate_source: public
  type: files
  cert: /etc/shoes/certs/fullchain.pem
  key: /etc/shoes/certs/privkey.pem

- address: "0.0.0.0:443"
  protocol:
    type: tls
    tls_targets:
      proxy.example.com:
        certificate_source: public
        protocol: {type: trojan, password: change-me}

- address: "0.0.0.0:443"
  transport: quic
  quic_settings:
    certificate_source: public
  protocol: {type: hysteria2, password: change-me}

There is one runtime worker per source, not per listener. File sources use the same filesystem watcher machinery as configuration files, with a 250 ms quiet period after the last relevant event. They support atomic rename and symlink replacement. Both the full certificate chain and its matching private key must validate before publication. Separate file writes, missing files, malformed data, key mismatches, and invalid validity periods leave the last good pair in place. Failed updates retry every second; a 60-second internal reconciliation also recovers missed events or unavailable watches. Neither interval is a configuration setting. Errors are rate-limited and never include private material. An expired source fails new handshakes rather than serving a substitute certificate. Existing connections continue uninterrupted.

Initial file-source loading (including legacy paths) rejects expired or not-yet-valid chains, malformed or misordered chains, and PEM files larger than 1 MiB. A rejected configuration reload leaves the current listeners running and watches the candidate's file paths so repairing the pair retries the reload. After activation, identity-only changes use live rotation, not a full reload.

Upgrade warning: These checks also apply to existing cert/key file paths and file-backed named PEMs, even without a configuration change. Previously accepted expired or not-yet-valid certificates and misordered bundles now reject at startup; running file sources stop serving at expiry, including when clients disable verification or pin a fingerprint. Renew expired certificates, put bundles in leaf-to-issuer order, remove expired cross-signs and certificates outside that chain, and synchronize the system clock before starting Shoes. Child-to-issuer signatures must use algorithms supported by the TLS provider: no SHA-1, and RSA issuer keys must be at least 2048 bits. Reissue certificates with unsupported signatures. Inline-only pairs retain their previous static behavior.

Legacy cert/key paths, including paths referenced through named PEMs, also rotate live through these watches. Inline-only pairs stay static. This applies to ordinary inbound TLS and QUIC, including Hysteria2, TUIC and XHTTP. Outbound client certificates, trust roots, and local ShadowTLS handshake certificates instead trigger a full CLI configuration reload when their referenced files change, including named PEM references. If a file serves both roles, a full reload is still necessary. REALITY's handshake behavior is unchanged.

New handshakes use the current certificate without replacing TCP listeners or QUIC endpoints. TLS session state is scoped to the certificate generation and the listener's security policy: sessions resume within a generation, but not across rotation or expiration. Existing TCP and QUIC sessions are not retired.

Built-in ACME

ACME uses instant-acme and explicitly configured domains. Start with your CA's staging directory. No certificate is issued during --dry-run.

- certificate_source: public
  type: acme
  domains: [proxy.example.com]
  directory_url: https://acme-staging-v02.api.letsencrypt.org/directory
  state_dir: /var/lib/shoes/acme/public
  contacts: [mailto:operator@example.com]
  # Read the CA's terms, then set this to its exact meta.termsOfService URL.
  terms_of_service: https://example.invalid/replace-with-accepted-ca-terms-url
  challenge:
    type: http01
    listen: ["0.0.0.0:8080"]

Reference public from TLS or QUIC as in the file-source example. Domains must be unique lowercase ASCII DNS names (use punycode for internationalized names). Every named TLS target using an ACME source must be covered by its domains. There is no issuance based on incoming SNI.

terms_of_service is explicit acceptance of that exact URL, not blanket acceptance of future terms. Shoes checks the directory's meta.termsOfService before account operations and refuses a changed URL. Review new terms and update the configuration to accept them. If a private CA publishes no terms, omit this field; a subsequently advertised terms URL still requires explicit acceptance. Accounts requiring external account binding are not supported in this version.

HTTP-01 requirements:

  • The CA must reach public TCP port 80 for every domain, forwarded to one of the explicit listen addresses. Shoes never implicitly binds port 80.
  • The challenge listener is independent of proxy listeners, so UDP-only QUIC deployments can use HTTP-01. A reverse proxy/CDN must forward the exact /.well-known/acme-challenge/ path and original Host header.
  • Listen addresses must not overlap another HTTP-01 source or a TCP proxy listener. Use a single multi-domain source when sharing the challenge address. Dry-run checks literal addresses and port ranges; a hostname-bound TCP listener on the same port is conservatively treated as a conflict.
  • Only exact, pending, unexpired GET challenges are answered. Unknown tokens return 404. Headers are limited to 8 KiB / 32 fields, with at most 64 concurrent connections per address and a 10-second request lifetime.

For TLS-ALPN-01, replace the challenge mapping with:

  challenge: {type: tls_alpn01}

The CA must reach public TCP port 443, forwarded to an existing outermost Shoes TLS listener covering every configured domain. This is not QUIC, ShadowTLS or REALITY validation. An intermediate TLS terminator must pass the handshake through, including SNI and ALPN. Shoes only answers the sole offered protocol acme-tls/1 for a pending configured SNI. The short-lived challenge response uses the RFC 8737 critical acmeIdentifier extension and never enters the proxy handler or replaces the ordinary certificate. Normal ALPN, client authentication, fallback routing and QUIC policy are unchanged.

At first start, proxy and challenge listeners bind before issuance begins. Normal handshakes for a source without a certificate fail until issuance succeeds; Servers ready means listeners are bound, not that ACME has finished. A valid cached certificate allows immediate startup during a CA outage.

Shoes persists the account key before registration, account credentials, in-progress orders and CSR keys, and the certificate/key pair in one atomically replaced, synced state file. Do not edit it or share it between sources. On Unix, the state directory must be owned by the running user with mode 0700, and files use 0600. Other platforms use their inherited filesystem access controls; place the directory in private application storage. An exclusive OS file lock prevents multiple processes or embedded lifecycles from owning the same directory. Back up the whole directory securely. Changing the CA or domain list requires a distinct state directory; staging and production must never share one.

Renewal normally begins with one third of the certificate's lifetime remaining. ARI is checked while idle and can request earlier renewal when the CA and certificate support it. Its Retry-After applies only to ARI; optional checks, including account setup, cannot delay due renewal. The source worker alone schedules requests and durable deadlines. Pending orders use a five-second polling interval unless the CA requests a longer wait. Transient failures preserve valid material and use jittered exponential backoff from 60 seconds to 6 hours, honoring longer CA Retry-After values. Retry-After waits over 100 years or outside the platform clock range are rejected. Each worker step is bounded to 10 minutes. Pending and processing orders are polled until the CA completes, invalidates, or removes them. A completed pair is persisted before it becomes live. Storage errors do not publish an unpersisted replacement. Order retry deadlines, including successful finalization and resumed orders, survive restart; startup logs an inherited delay rather than bypassing it. The failure backoff resets after installation or healthy idle, not after a deferred wake or a successful intermediate order step. Its exponent is kept in memory; the selected deadline survives restart. ARI retry deadlines also survive restart, including failed ARI responses. Missing or invalid orders are discarded so a later attempt can create a new one. Certificate failures and inherited retry delays are visible with the default error-only logging; successful issuance and rotation use the info level. During deferred polling, pending challenge responses remain available until their 10-minute lease expires, the order leaves the pending state, or the source is stopped.

CA traffic goes directly through the OS resolver and Shoes' protected TCP socket helper, not through proxy rules or the certificate being issued. HTTPS verifies the CA hostname and public roots. Optional ca_cert: /path/to/test-ca.crt replaces those roots for a private CA. Requests have a 30-second total timeout, 32 KiB response header limit and 1 MiB response body limit; redirects are not followed. Plain HTTP directories/endpoints are accepted only on numeric loopback addresses for local testing. Never disable verification for a public CA.

Configuration reload reuses unchanged sources and their workers, accounts and pending challenges. Changed sources hand off storage and challenge listeners when a validated reload commits. Retired challenge ports are released before new proxy listeners bind; failed preparation does not mutate live sources. When an existing proxy address moves to HTTP-01, its challenge bind is deferred until the old proxy has stopped. Narrowing a proxy wildcard within the same IP family is also supported. Other challenge addresses are still bound during preparation: widening bindings, changing IP families, or replacing a challenge wildcard binding may require a restart or two separate reloads to avoid conflicts. Removed sources are canceled. CLI and mobile/FFI use the same manager, with no process-global certificate registry. Mobile deployments still need externally reachable challenge ports and persistent application storage.

--dry-run validates configuration, references, domain coverage, challenge compatibility, certificate material and declared address conflicts without contacting a CA, binding ports, or creating ACME state. An unissued ACME source is a valid configuration, not a self-signed placeholder. Existing readable state is checked for CA/domain identity mismatches without locking or modifying it; the check need not run as the service user. Actual OS port conflicts, storage ownership and permissions are checked during runtime preparation.

There is no built-in DNS-01, wildcard ACME issuance, DNS vendor API, shell hook, Tailscale provider, or Cloudflare Origin CA provider. For wildcard certificates or DNS-01, run an external client such as acme.sh, lego or Certbot and use a file source. Manually supplied wildcard certificates remain supported.

Examples: file rotation and reuse and ACME with shared TCP/QUIC consumers.

Advanced Features

Vision (XTLS-Vision)

Vision optimizes TLS-in-TLS scenarios by detecting inner TLS traffic and switching to direct mode for zero-copy performance.

Requirements:

  • Inner protocol MUST be VLESS
  • Works with both TLS and Reality
# TLS + Vision
protocol:
  type: tls
  tls_targets:
    "example.com":
      cert: cert.pem
      key: key.pem
      vision: true
      alpn_protocols: ["http/1.1"]
      protocol:
        type: vless
        user_id: "uuid"

# Reality + Vision
protocol:
  type: tls
  reality_targets:
    "www.google.com":
      private_key: "..."
      short_ids: ["..."]
      dest: "www.google.com:443"
      vision: true
      protocol:
        type: vless
        user_id: "uuid"

XUDP Multiplexing

Automatically enabled for VMess and VLESS when udp_enabled: true. Multiplexes UDP traffic over a single connection.

Proxy Chaining

Protocol nesting (wrap one protocol in another):

client_chain:
  address: "proxy.example.com:443"
  protocol:
    type: tls
    protocol:
      type: vmess
      cipher: aes-128-gcm
      user_id: "uuid"

Multi-hop chains (route through multiple proxies sequentially):

client_chain:
  chain:
    - address: "proxy1.example.com:1080"
      protocol:
        type: socks
    - address: "proxy2.example.com:443"
      protocol:
        type: tls
        protocol:
          type: vless
          user_id: "uuid"

QUIC Socket Buffers

global_limits.quic_socket_buffer requests the UDP receive and send buffer size for each QUIC listener, outbound QUIC proxy endpoint and DNS-over-HTTP/3 socket. It defaults to 8388608 bytes (8 MiB); allowed values are 65536 through 16777216 bytes. This is per socket, not per QUIC connection, and is separate from QUIC flow-control windows and userspace buffer admission limits. Ordinary UDP forwarding sockets keep their existing buffer sizes.

- global_limits:
    quic_socket_buffer: 8388608

Requests are best-effort: if the OS clamps or rejects the size, startup continues with the available buffers. Shoes checks the resulting sizes and warns at most once per receive/send direction per process when the request is not satisfied or cannot be verified. Smaller buffers can limit throughput during bursts.

On Linux, administrators can allow the default request with net.core.rmem_max=8388608 and net.core.wmem_max=8388608; Shoes does not change sysctls or require elevated privileges for buffer tuning. Linux reports double the requested buffer size for accounting overhead; Shoes normalizes this when checking the result. The limits do not preallocate their maximum memory, but queued traffic consumes memory across all endpoints. Raising kernel ceilings does not resize existing sockets. Configuration reloads recreate QUIC listeners and can disconnect their active sessions; see Hot Reloading.

Hot Reloading

Configuration changes are automatically detected. The CLI also watches referenced outbound TLS/QUIC client certificates and keys, client-auth CA bundles, local ShadowTLS certificates/keys, and ACME custom ca_cert files. Changing ACME trust roots replaces the affected renewal worker after successful preparation, while retaining its account and issued certificate.

Automatic full reloads wait for 5 seconds of quiet after the last relevant filesystem event. Writes to config, rule, or reload-only PEM dependencies share that debounce window. Separate certificate/key writes are coalesced; an invalid or incomplete candidate is rejected without retiring the running listeners. Active and parseable candidate dependencies remain watched, so repairing or creating a missing PEM file retries the reload without another config edit. Unix SIGHUP bypasses debounce; shutdown also interrupts it immediately.

--no-reload disables automatic full-reload watches, not live server-certificate rotation or ACME renewal. On Unix, SIGHUP still requests a reload. Embedded/FFI instances use the same live server-certificate watcher, but have no full config reload loop: reload-only PEM changes require restarting the embedded instance.

On Linux, if directory permissions prevent watching a readable configuration's parent, startup falls back to direct file watches and logs a warning. In-place edits remain detectable, but atomic replacements may be missed; use SIGHUP or grant directory read permission when replacing files.

Replacement configurations, certificates, and DNS resolvers are prepared while the current listeners continue serving. A preparation failure leaves the current configuration running. Unchanged fixed TCP listen addresses retain their sockets across generations. New-generation TCP and Unix listeners accept queued connections only after activation completes; connections may wait in the kernel backlog during that interval.

If activation fails, Shoes stops the candidate services and restores the previous configuration, including control state, resource limits, DNS ownership, and certificate workers. A successful rollback keeps the process running; if rollback also fails, Shoes exits with status 1 rather than serving a partial generation. Existing TCP connections begin draining when their old listeners retire, for up to global_limits.reload_grace_secs. That deadline still applies after rollback. TUN, WireGuard, and QUIC services are stopped and restarted; their existing sessions are not migrated or restored by rollback.

Certificate-source file changes and ACME renewal do not trigger this full reload. They rotate live without replacing listeners or closing QUIC connections; see Certificate Sources.

QUIC listener sockets must finish closing before replacements bind. Closing endpoints get up to one second for best-effort close delivery, then their drivers are stopped locally so peer-controlled timers cannot delay reloads. If the previous generation's sockets do not retire within the five-second socket-release guard, Shoes exits with an error rather than announcing a partially ready generation.

Candidate QUIC buffer sizes are used during DNS preparation, but global_limits admission caps change only at activation. DNS bootstrap over HTTP/3 or QUIC proxy chains therefore needs headroom under the live cap. A cap-increasing reload may require a restart if preparation cannot fit. Lowering a cap does not revoke existing reservations, including those made during preparation; new admissions are blocked until usage falls sufficiently. At cold startup, preparation uses the initial unlimited admission caps.

On macOS, edit the configured file path or use SIGHUP when writing through a hard-link alias in another directory. Automatic detection of such alias writes is backend-dependent. System-volume directory symlinks are resolved without watching their immutable links.

SIGINT/SIGTERM (or Ctrl-C on Windows) stop listeners and exit with status 0. Process shutdown does not wait for the TCP reload grace period.

mTLS (Mutual TLS)

Require client certificates for authentication:

# Server side
protocol:
  type: tls
  tls_targets:
    "example.com":
      cert: server.crt
      key: server.key
      client_ca_certs: [ca.crt]  # Required CA
      client_fingerprints: ["sha256:..."]  # Optional specific certs
      protocol: ...

# Client side
client_chain:
  address: "example.com:443"
  protocol:
    type: tls
    cert: client.crt
    key: client.key
    protocol: ...

Command Line

shoes [OPTIONS] <config.yaml> [config.yaml...]

OPTIONS:
  -t, --threads NUM    Worker threads (default: CPU count)
  -d, --dry-run        Validate config and certificates without starting listeners
  --no-reload          Disable automatic reloads on file changes

COMMANDS:
  check <config.yaml>                           Alias for --dry-run
  version                                      Alias for --version
  generate-reality-keypair                       Generate Reality X25519 keypair
  generate-reality-mldsa65-keypair                Generate optional Reality ML-DSA-65 keys
  generate-shadowsocks-2022-password <cipher>    Generate Shadowsocks 2022 password

check and --dry-run exit with status 0 for valid configuration and 1 for errors. They do not open listeners, create a file watcher, or perform DNS bootstrap networking. Runtime failures such as unavailable bind addresses remain possible after a successful check.

Configuration Diagnostics

Check the same set of files that will be used at startup:

shoes check groups.yaml listeners.yaml

Configuration decoding failures identify the input file and the entry's zero-based config[N] index. Rule-group decoding also identifies rules[N] (or rules for a single object). For example, an unsupported field in the second rule source of the third entry reports config[2], rules[1], and the unknown field. YAML syntax and duplicate-key errors retain the parser's original diagnostic and line/column information when available, rather than adding an entry prefix. For config[N] decoding errors, a trailing line/column (if any) marks the start of the file's top-level list, not entry N or the failing field; use the config[N] and rules[N] paths for that context.

Errors during certificate loading and semantic validation name the input file set, not an inferred single culprit: group references can span files. Checks use the native validation path, including graph and transport-composition checks. Named PEM entries accept only pem and exactly one of path or data; unknown fields are rejected rather than ignored. TCP settings likewise reject unknown fields; their only current field is no_delay (default true).

Diagnostics do not include full YAML excerpts. They are not a redacted config export: user-defined names, paths, and some invalid values can appear in errors. Review diagnostics before sharing them. No normalized-config output is provided.

Offline regression coverage fully validates the representative SOCKS, HTTP-through-SOCKS, multi-hop, controller, and local rule-source examples. Other examples may need deployment-specific certificates, keys, files, or platform support; parsing an example alone does not establish runtime validity.

Optional reference generation, editor schema support, and strict subset conversion are described in Configuration Tooling Follow-Ups.

Tips

Generate Keys

Reality keypair:

shoes generate-reality-keypair

Optional Reality post-quantum authentication keys:

shoes generate-reality-mldsa65-keypair

Keep mldsa65_seed on the server; distribute only mldsa65_verify to clients.

Shadowsocks 2022 password:

shoes generate-shadowsocks-2022-password 2022-blake3-aes-256-gcm

UUID:

uuidgen

TLS certificate fingerprint:

openssl x509 -in cert.pem -noout -fingerprint -sha256

Security Best Practices

  • Use strong, random passwords
  • Keep private keys secure
  • Use 127.0.0.1 instead of 0.0.0.0 for local-only access
  • Use firewall rules to restrict access
  • Enable client certificate authentication for sensitive services
  • Use Vision with Reality for maximum privacy

Performance Tips

  • Enable vision: true for TLS-in-TLS scenarios
  • Use tcp_settings.no_delay: true for low latency
  • Set quic_settings.num_endpoints to match worker threads
  • Use QUIC transport for high-latency or lossy networks

Common Issues

  • "Address already in use": Change port or stop conflicting service
  • "Permission denied": Ports < 1024 require root/admin
  • Reality connection fails: Verify keys match, UUID matches, SNI matches server's reality_targets key
  • Vision not working: Ensure inner protocol is VLESS
  • Config validation fails: Run with --dry-run for detailed errors