Prerequisites and config file structure
Before going further, confirm two things: the client already connects normally (you've finished the tutorial's main path), and you know which subscription your current config came from. Every advanced tweak in this handbook assumes there's a working baseline underneath it — without a stable baseline, there's no reliable way to verify later changes.
A mihomo config file is a single YAML document. Its top-level keys split cleanly into five areas, and this handbook's chapters map onto them one-to-one:
- Inbound:
port,socks-port,mixed-port,tun— where traffic enters; - Outbound:
proxies,proxy-providers,proxy-groups— where traffic can exit; - Routing:
rules,rule-providers— which traffic goes through which exit; - Resolution:
dns,sniffer— how domains become addresses and back; - Control:
external-controller,external-ui,secret— how runtime state gets exposed.
The minimal runnable skeleton
Strip out every optional field and a bootable config looks like this. Every code snippet in later chapters is meant to sit on top of this skeleton:
mixed-port: 7890
mode: rule
log-level: info
proxies: [] # usually supplied by a subscription
proxy-groups:
- name: Proxy Selector
type: select
proxies:
- DIRECT
rules:
- GEOIP,CN,DIRECT
- MATCH,Proxy Selector
YAML is unforgiving about indentation: use two spaces consistently, never tabs, and quote any value that contains a colon, a hash, or starts with a special character. Indentation is always the first suspect behind a launch crash — see the blog post "Fixing Clash Launch Crashes" for a troubleshooting path.
Platform differences: iOS vs. desktop
On iOS, Clash Plus runs on top of a Network Extension, and its config management follows a "subscription as base + override as delta" model — you rarely edit the subscription text directly; instead you stack your own changes on top inside the client (covered in Chapter 6). Desktop clients like Clash Verge Rev and FlClash let you edit the full config directly. Regardless of client, everything in this handbook operates at the kernel level, so the syntax is universal.
One habit worth building: after every config change, run the fixed loop "reload config → check the logs → verify behavior." The blog post "Reading Clash Runtime Logs" walks through log levels and common error meanings line by line, and this verification loop comes up repeatedly in the chapters ahead.
Policy group types and real-world layouts
Policy groups (proxy-groups) sit between rules and nodes. When a rule matches, its target can be a single node, a built-in exit (DIRECT / REJECT), or a policy group — and a group's own members can be nodes or yet another group. Understanding that "groups can contain groups" is the key to building any non-trivial routing setup.
mihomo offers five group types, each with different selection logic:
| Type | Selection logic | Typical use |
|---|---|---|
select | Manual pick, stays until you switch it again | Top-level entry group, manual region picks |
url-test | Periodic latency test, auto-locks the fastest member | Auto-picking the best node in a region |
fallback | Uses the first available member in list order, falls back on failure | Primary/backup switching, safety-net exit |
load-balance | Spreads connections across members by hash or round robin | Splitting heavy traffic across multiple nodes |
relay | Chains members in sequence as a relay path | Special routing needs, rarely used day to day |
Four key parameters for auto-testing groups
url-test and fallback both rely on health checks driven by four parameters. url is the test target — a lightweight endpoint that returns 204 by convention; interval is the check period in seconds, and 300 is a safe default (setting it too low just wastes node traffic for no benefit); tolerance is the switch-over margin in milliseconds — if the latency gap between old and new node is under this value, no switch happens, which prevents two similarly fast nodes from flapping back and forth; lazy: true pauses testing while a group is idle, worth enabling on mobile devices to save power and data.
In practice: a three-layer structure
Most setups only need three clean layers: a manual top-level entry on top, auto-test and failover in the middle, subscription nodes at the bottom. Point every rule at the top-level entry, and daily switching only ever touches that one group:
proxy-groups:
- name: Proxy Selector
type: select
proxies:
- Auto Speed Test
- Fallback
- DIRECT
- name: Auto Speed Test
type: url-test
url: https://www.gstatic.com/generate_204
interval: 300
tolerance: 50
lazy: true
use:
- main-sub
- name: Fallback
type: fallback
url: https://www.gstatic.com/generate_204
interval: 300
use:
- main-sub
The use field in this example references a proxy provider (proxy-providers, covered in Chapter 6), so a group's members sync automatically as the subscription updates — no manual node names needed. Two things to watch: group names referenced in rules must match exactly, and even one extra space will break startup; and groups referencing each other can't form a loop — the kernel refuses to load and errors out immediately at startup.
Need finer splits by use case (say, streaming on its own group)? Just add another select group with the same pattern and drop "Auto Speed Test" and "Proxy Selector" in as members. Start with the skeleton and add on top — that's far easier to maintain than stacking a dozen groups from the start.
Rule-set subscription management
Writing thousands of rule lines directly into rules has two costs: the config itself becomes bulky and hard to read, and any rule change forces an edit to the main config. rule-providers externalizes rules into standalone files that the kernel refreshes automatically on a schedule — the main config only needs a single reference line.
rule-providers:
streaming:
type: http
behavior: domain
format: yaml
url: https://example.com/rules/streaming.yaml
path: ./rule-sets/streaming.yaml
interval: 86400
rules:
- RULE-SET,streaming,Proxy Selector
- GEOIP,CN,DIRECT
- MATCH,Proxy Selector
Field by field: type is either http (fetched remotely) or file (local); url and path are the remote address and local cache path respectively; interval is the auto-refresh period in seconds — since rule-set content doesn't change often, 86400 (one day) is plenty. format supports yaml, text, and mrs — mrs is mihomo's binary rule format, smaller and faster to load, so prefer it whenever the rule source offers it.
behavior: the field most people trip on
behavior declares the shape of the rule set's content and must match the actual file:
| behavior | Content shape | Example line |
|---|---|---|
domain | Plain domain list (supports +. wildcards) | +.example.com |
ipcidr | Plain IP range list | 203.0.113.0/24 |
classical | Full rule lines (with their own match type) | DOMAIN-SUFFIX,example.com |
When behavior doesn't match the content, the rule set usually fails silently — no error, it just never matches. If routing "looks configured but doesn't work," check this first. Also, ipcidr rule sets can add no-resolve at the end of the RULE-SET line to skip triggering an unnecessary domain lookup for a pure IP match.
Relationship to GeoSite / GeoIP
GEOSITE and GEOIP rules are backed by another kind of pre-packaged rule set — two binary databases that catalog domains and IP ranges by country or site category. They don't conflict with rule-providers: the geo databases cover broad general categories, while rule-providers fill in personalized entries. A stale database directly causes routing misses; see the blog post "Updating the GeoIP and GeoSite Databases" for how to update. For the syntax of each rule type in rules and the top-down priority principle, the blog post "Clash Custom Rule Syntax Explained" covers it in full — not repeated here.
DNS configuration tuning
Start with "why should the kernel handle DNS itself." Rule-based routing depends heavily on resolution results: GEOIP,CN,DIRECT needs a domain resolved to an IP before it can judge origin. If resolution is left to the system and the system gets back a tampered address, GEOIP will mistake traffic that should go through the proxy for a direct connection — the rule is correct, but the site still won't load. Letting the kernel resolve domains itself over trusted encrypted DNS is the foundation of accurate routing.
dns:
enable: true
listen: 0.0.0.0:1053
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
default-nameserver:
- 223.5.5.5
nameserver:
- https://doh.pub/dns-query
- https://dns.alidns.com/dns-query
proxy-server-nameserver:
- https://doh.pub/dns-query
nameserver-policy:
"geosite:cn":
- https://doh.pub/dns-query
Three nameserver groups, three jobs
These fields are easy to mix up — remember them by "who gets resolved": default-nameserver only accepts plain IPs and exists solely to resolve the DoH domains listed under nameserver itself — the bootstrap that breaks the chicken-and-egg loop; nameserver is the main resolver handling all regular queries, and DoH (https:// prefix) or DoT (tls:// prefix) is recommended since plain port-53 queries get hijacked on some networks; proxy-server-nameserver handles resolving your proxy nodes' own server domains, pinned to a resolver reachable without the proxy, avoiding a deadlock where "resolving the node's domain needs the proxy, but the proxy needs the node."
nameserver-policy: routing resolution by domain
nameserver-policy lets you assign specific resolvers by domain pattern. The most common case, as in the example, is geosite:cn: mainland Chinese sites are pinned to a mainland DoH resolver so you get the nearest CDN address, while every other domain falls back to the main nameserver. The key can also be a plain domain pattern (like "+.internal.example.com" pointed at an internal DNS server), which suits office network setups.
The most direct way to verify DNS config is working is to check resolution records in the logs: which domain, which resolver, what came back. If dns resolve failed shows up in batches, check whether default-nameserver is reachable first — see the blog post "Reading Clash Runtime Logs" for the troubleshooting path.
The enhanced-mode: fake-ip setting in this example is the most important switch at the DNS/TUN boundary, and it deserves its own chapter — read on.
TUN mode and Fake-IP
System-wide proxy settings only cover apps that are willing to read them — command-line tools, some games, and background services bypass them outright. TUN mode takes a more thorough approach: it creates a virtual network interface and, paired with the routing table, funnels all of a device's traffic into the kernel for processing. Apps have no way around it, since every packet they send has to cross that interface.
Platform differences up front: on iOS, Clash Plus runs on a Network Extension tunneling mechanism that's already functionally equivalent to TUN — there's no separate switch to flip, nor a need for one. The tun config block below is mainly for desktop and router deployments (Windows needs admin rights to load the virtual adapter driver; macOS requires approving the network extension in System Settings). For running the kernel directly on a router, see the blog post "Running mihomo Directly on a Router".
tun:
enable: true
stack: mixed
auto-route: true
auto-detect-interface: true
dns-hijack:
- any:53
stack picks the protocol stack implementation: system uses the OS stack — good performance, platform-dependent compatibility; gvisor uses a userspace stack — steadier compatibility; mixed combines the strengths of both and is the sensible default. auto-route writes routing table entries automatically, and auto-detect-interface identifies the physical uplink adapter to prevent routing loops — leave both enabled. dns-hijack: any:53 intercepts every plaintext DNS query bound for port 53 and hands it to the kernel — the piece that guarantees "resolution always passes through the kernel" under TUN mode.
Fake-IP: one less round trip in resolution
Back to enhanced-mode from the previous chapter. Under the traditional redir-host mode, the kernel has to perform a real resolution before it can answer a DNS query; fake-ip mode instead hands back an address from a reserved block (198.18.0.1/16 by default) immediately, while remembering "this fake IP maps to this domain." When the app connects using the fake IP, the kernel reverses the mapping to recover the domain and matches it against rules — traffic headed for a proxy gets its domain handed straight to the remote resolver, the local resolution step is skipped entirely, connections open faster, and domain matching is more accurate.
| Comparison | fake-ip | redir-host |
|---|---|---|
| Response speed | Instant fake address | Waits for real resolution |
| Rule matching | Domain is always available, domain rules fully apply | Some cases degrade to IP-only matching |
| Compatibility | A handful of cases needing a real IP require filtering | No fake-address side effects |
Compatibility issues are handled with fake-ip-filter: list any domain that must get a real IP — LAN device discovery, NTP time sync, some game matchmaking platforms — so they go through real resolution instead:
dns:
fake-ip-filter:
- "*.lan"
- "+.local"
- time.apple.com
- "+.stun.*.*"
Switching between fake-ip and redir-host can leave stale resolution caches in the system and browser, which shows up as some sites failing to load for a while afterward. Restart the client's network connection after switching, and reboot the device if needed before re-testing.
Domain sniffing: recovering lost domains
The previous two chapters solved "how domains get resolved," but a category of traffic never touches the kernel's DNS at all: apps with their own built-in encrypted DNS (like a browser's internal DoH), or apps that hardcode a server IP directly. When this kind of connection reaches the kernel, all it carries is a destination IP — the carefully maintained domain rules (DOMAIN-SUFFIX, GEOSITE) never fire, and routing falls back entirely on IP rules, which hurts accuracy considerably.
Domain sniffing (sniffer) recovers the domain from the traffic itself: the ClientHello in a TLS handshake carries an SNI field, an HTTP request header carries a Host, and a QUIC initial packet can likewise reveal the target domain. The kernel reads these fields early in connection setup and feeds the recovered domain back to the routing engine, so domain rules apply again.
sniffer:
enable: true
sniff:
HTTP:
ports: [80, 8080-8880]
override-destination: true
TLS:
ports: [443, 8443]
QUIC:
ports: [443, 8443]
force-domain:
- "+.v2ex.com"
skip-domain:
- "Mijia Cloud"
The three protocol blocks each declare which ports to attempt sniffing on for that protocol; override-destination means the sniffed domain overrides the connection's original target address, and it's usually left on. force-domain lists domains that get sniffing-based override even when resolution looks normal, useful when a CDN domain and its IP don't line up cleanly; skip-domain is the opposite — it excludes targets where sniffing actually causes problems, such as some smart-home private protocols that get misidentified.
As for cost: sniffing only reads the initial handshake bytes of each connection, so the overhead is negligible. It complements fake-ip rather than replacing it — fake-ip guarantees traffic that goes through the kernel's DNS keeps its domain, while sniffing covers the traffic that bypasses the kernel's DNS entirely. Domain rule coverage is only complete when both are enabled together.
Local overrides and multi-subscription merging
A problem everyone eventually hits: the subscription file is generated server-side, so any rule you add or DNS setting you tweak directly inside it gets wiped out wholesale the next time the subscription refreshes. The right approach is to leave the subscription untouched and declare your own changes as an override — after every subscription refresh, the client automatically reapplies the override on top.
Two override styles
Mainstream clients offer two kinds. Declarative (merge): write a YAML snippet stating which top-level keys get replaced and which lists get items appended at the head or tail — intuitive and hard to get wrong. Scripted: write a function that receives the full config object and returns a modified one — flexible, but you own the debugging. Both Clash Plus and Clash Verge Rev have a built-in override entry, and declarative merge is enough for most needs. Taking common merge semantics as an example:
# Override snippet: replace dns wholesale, prepend two rules
dns:
enable: true
enhanced-mode: fake-ip
prepend-rules:
- DOMAIN-SUFFIX,internal.example.com,DIRECT
- PROCESS-NAME,Terminal,DIRECT
One rule covers all of merge: a plain top-level key gets replaced wholesale; a key with a prepend / append prefix gets its list appended to. Since rules match top-down, prepend your custom rules if you want them to take priority, or append them at the tail if they're only meant as a fallback.
Multi-subscription merging: proxy-providers
With multiple subscriptions on hand, there's no need to keep switching configs in the client. proxy-providers declares each subscription as a node collection, and policy groups reference the collection via use — nodes from every subscription flow into one unified config:
proxy-providers:
main-sub:
type: http
url: https://example.com/sub?token=xxxx
path: ./providers/main.yaml
interval: 43200
health-check:
enable: true
url: https://www.gstatic.com/generate_204
interval: 600
backup-sub:
type: http
url: https://backup.example.com/sub?token=xxxx
path: ./providers/backup.yaml
interval: 43200
health-check:
enable: true
url: https://www.gstatic.com/generate_204
interval: 600
The fields mirror Chapter 2's rule-providers: interval controls the auto-refresh period, and 43200 (half a day) is plenty for most subscriptions; health-check tags nodes in the collection with availability status, which url-test / fallback groups consume directly. The use: [main-sub] in Chapter 1's example references exactly this — a group's members grow or shrink automatically as the subscription updates, without a single line changed in the main config. If a subscription fails to refresh, check the fetch error in the logs first, then confirm the URL hasn't expired.
The token in a subscription URL is equivalent to an account credential: don't screenshot it, don't paste it into a public support thread. The token=xxxx in the example is a placeholder, not a real value.
External control and the dashboard
Every bit of runtime state — node latency, active connections, live logs, the current mode — is exposed through a RESTful API called the external controller. The connection list in the client, the web dashboard, command-line queries: underneath, they all talk to this same API.
external-controller: 127.0.0.1:9090
secret: "your-secret"
external-ui: ./ui
external-controller declares the listen address and port; secret is the access token that every request must carry; external-ui points at a static dashboard directory, which the kernel serves under the /ui path on the same port — just open it in a browser. Most web dashboards are pure front-end projects; unpack one into that directory and it works.
Talking to the kernel directly with curl
# List every policy group and node status
curl -H "Authorization: Bearer your-secret" \
http://127.0.0.1:9090/proxies
# Switch the current exit of the "Proxy Selector" group
curl -X PUT -H "Authorization: Bearer your-secret" \
-d '{"name": "Auto Speed Test"}' \
http://127.0.0.1:9090/proxies/Proxy Selector
# Live log stream / active connections
curl -H "Authorization: Bearer your-secret" \
http://127.0.0.1:9090/logs
curl -H "Authorization: Bearer your-secret" \
http://127.0.0.1:9090/connections
Four endpoints cover most needs: /proxies (groups and nodes), /connections (active connections — the most direct way to check "which exit is this traffic taking"), /logs (live log stream), and /configs (read and hot-reload the running config). Automation scripts — say, switching groups automatically on network change — revolve around these same endpoints.
Security baseline: secret must always be set and sufficiently complex; keep the listen address at 127.0.0.1 by default, and only change it to a LAN address if you genuinely need LAN-wide management — exposing the control interface unauthenticated on 0.0.0.0 hands anyone on the same network control over the device's entire traffic routing.
A note for iOS: Clash Plus's built-in dashboard consumes this same API internally, so ordinary use never requires manually configuring anything in this section. The scenarios that genuinely need external control are desktop debugging and headless router deployments.