Understand what the external controller can and cannot do
Clash’s external controller is a local HTTP API exposed by the running core. It allows another program to inspect runtime state and request controlled changes without opening the graphical client. A failover script can read the nodes inside a policy group, test their reachability, select a suitable node, and verify that the core accepted the change. This is different from editing the YAML file: the API changes the active in-memory state, while the configuration file remains unchanged unless the client or core explicitly saves it.
The controller is useful when a graphical client is not convenient, when a small server needs unattended operation, or when a monitoring system must react to node failures. It is also useful for repeatable operations across FlClash, Clash Verge Rev, and other clients using a compatible Clash or mihomo core. However, endpoint availability and response details can vary between core versions. Always test the exact core bundled with the client instead of assuming that every “Clash-compatible” implementation exposes identical behavior.
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
external-controller: 127.0.0.1:9090
secret: replace-with-a-long-random-secret
external-controller defines the listening address and port. Binding it to 127.0.0.1 keeps the API available only to programs on the same device. The common port 9090 is only an example; another application may already be using it, or the client may generate a different value. The secret protects API requests with a bearer token. It does not encrypt traffic, so exposing the controller on a public address without an encrypted and authenticated access layer is unsafe.
Confirm the active controller before writing a script
Open the client’s settings or inspect the active configuration and record the actual controller address, controller port, and secret. In FlClash or another graphical client, the visible configuration may be an override generated from a subscription. Editing a downloaded subscription file directly can be ineffective because the next update may replace it. Prefer a persistent local override or the client’s own settings when you need to retain external-controller, secret, proxy groups, or health-check options.
On a desktop system, first check whether the port is listening. On Windows, use Get-NetTCPConnection -LocalPort 9090 or netstat -ano | findstr :9090. On macOS and Linux, use lsof -nP -iTCP:9090 -sTCP:LISTEN or ss -lntp | grep 9090. A refusal usually means that the core is stopped, the port is wrong, or another process owns the address. A timeout may indicate that the controller is bound to a different interface or blocked by a firewall.
Design a proxy group that scripts can control
A failover workflow is easier to maintain when the configuration has one clear group dedicated to normal traffic. The group name is part of the API path, so avoid changing it casually. Names containing spaces, slashes, question marks, or non-ASCII characters must be URL-encoded when they are placed in a request path. A simple name such as Node Select is readable in the client, but a script must refer to it as Node%20Select or generate the encoded path programmatically.
proxy-groups:
- name: Node Select
type: select
proxies:
- Auto Select
- Singapore 01
- Japan 01
- Germany 01
- name: Auto Select
type: url-test
url: https://www.gstatic.com/generate_204
interval: 300
tolerance: 50
proxies:
- Singapore 01
- Japan 01
- Germany 01
rules:
- MATCH,Node Select
A select group gives the user or script explicit control. A url-test group performs periodic tests and chooses a node according to the core’s health-check logic. These two strategies solve different problems. Use url-test when latency-based automatic selection is sufficient. Use select when the failover program must choose a node based on several conditions, such as latency, an HTTP status code, a region preference, or a recent failure count.
Do not put every node into every group. A subscription may contain hundreds of entries, including expired servers, duplicate names, and nodes intended for a specific service. Keep a small operational group for the nodes that have been reviewed. This reduces API response size, shortens test time, and makes the selected state easier to audit. If a group contains another group, remember that the API may return the child policy name rather than the final physical node. Your script should decide whether it is controlling the top-level group or inspecting the child group.
Inspect groups and current selections
The most useful starting endpoint is GET /proxies. It returns the runtime proxy and policy-group map. A typical request with a secret looks like this:
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $CLASH_SECRET" \
"http://127.0.0.1:9090/proxies"
The response contains entries such as name, type, now, and all. For a policy group, now is commonly the currently selected member and all lists available members. Do not parse the whole response with a regular expression. Use a JSON parser so that names containing punctuation, spaces, or escaped characters are handled correctly.
curl --fail-with-body --silent \
-H "Authorization: Bearer $CLASH_SECRET" \
"http://127.0.0.1:9090/proxies/Node%20Select"
Some mihomo versions and compatible cores provide additional fields for proxy health, delay, history, or alive status. Treat these fields as optional. A robust script should continue to work when an optional field is absent, and should not confuse an old delay value with a current successful connection. Runtime information is a snapshot; it can become stale immediately after a network change.
| Operation | Method and endpoint | Purpose |
|---|---|---|
| List runtime entries | GET /proxies |
Find group names, node names, and current selections |
| Inspect one group | GET /proxies/{name} |
Read the group type, members, and selected member |
| Switch a group | PUT /proxies/{name} |
Request a new active member |
| Test one node | GET /proxies/{name}/delay |
Measure delay against a chosen URL and timeout |
| Inspect active connections | GET /connections |
Determine whether existing flows still use the previous node |
Test latency and reachability before switching
Latency testing is a useful filter, but it is not the same as proving that a website or application will work. The delay endpoint normally tests a target URL through a selected proxy. The request includes a URL and a timeout in milliseconds. For example:
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $CLASH_SECRET" \
--get "http://127.0.0.1:9090/proxies/Singapore%2001/delay" \
--data-urlencode "url=https://www.gstatic.com/generate_204" \
--data-urlencode "timeout=5000"
The returned JSON commonly includes a numeric delay. A successful result of 120 means that the request completed within the test conditions; it does not mean every destination will respond in 120 milliseconds. Some nodes reject the selected test domain, some networks treat Google-owned endpoints differently, and some protocols need extra time during the first handshake. Choose a stable HTTPS URL that is relevant to the traffic you care about, and use the same URL when comparing candidates.
Set a practical timeout. A value of 3000 to 5000 milliseconds is suitable for an interactive desktop check, while a longer value may be necessary for a distant server or a mobile network. Do not classify every timeout as a permanent failure after one attempt. Run two or three tests with a short interval, then use the median or the best valid result. This prevents a single packet loss event from causing unnecessary node changes.
Use more than one condition in a failover decision
- Reachability: Require a valid HTTP response from the delay endpoint. A missing or non-numeric delay should be treated as a failed probe.
- Upper bound: Reject nodes above a threshold such as
800 mswhen the workflow is intended for interactive browsing. - Stability: Require two successful probes or maintain a small failure counter instead of switching after one transient timeout.
- Cooldown: Avoid switching repeatedly between two similar nodes. A cooldown of several minutes prevents a flapping network from creating constant route changes.
- Preference: Sort candidates by region, provider, or a manually maintained priority before comparing delay.
- Current state: Do not switch when the current node is still healthy and within the acceptable threshold.
Probe the candidates sequentially when the group is small. For a large group, limit the test set or use the core’s built-in url-test group. Parallel requests can reduce total time, but they may create a burst of connections and make the subscription provider or server interpret the activity as abuse. A failover tool should be conservative: its purpose is to restore useful connectivity, not to generate continuous traffic against every node.
Switch nodes through a safe API workflow
To change a select group, send a JSON body containing the target member name to PUT /proxies/{group}. The group and member names must be encoded correctly. The following shell example uses Python for JSON parsing and URL encoding, avoiding fragile text substitutions:
#!/usr/bin/env bash
set -euo pipefail
: "${CLASH_API:=http://127.0.0.1:9090}"
: "${CLASH_SECRET:?Set CLASH_SECRET first}"
GROUP="${1:-Node Select}"
TARGET="${2:?Usage: $0 'Node Select' 'Singapore 01'}"
GROUP_PATH="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$GROUP")"
curl --fail-with-body --silent --show-error \
-X PUT \
-H "Authorization: Bearer $CLASH_SECRET" \
-H "Content-Type: application/json" \
--data "$(python3 -c 'import json,sys; print(json.dumps({"name": sys.argv[1]}))' "$TARGET")" \
"$CLASH_API/proxies/$GROUP_PATH"
echo "Switch requested: $GROUP -> $TARGET"
A successful HTTP response only confirms that the request was accepted by the controller. The script should perform a second GET request and verify that the group’s now value equals the requested member. If the value does not change, the group may be a url-test group, the target may not be a valid member, the client may have reloaded the configuration, or another automation process may have switched it at the same time.
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $CLASH_SECRET" \
"$CLASH_API/proxies/$GROUP_PATH" | python3 -c '
import json, sys
state = json.load(sys.stdin)
print("type:", state.get("type"))
print("selected:", state.get("now"))
print("members:", ", ".join(state.get("all", [])))
'
For a production workflow, add locking so that two timer jobs cannot switch the same group concurrently. Validate the requested member against the group’s all list before sending the PUT. Record the timestamp, old selection, target selection, probe result, and API status in a local log. Keep the script idempotent: calling it with the already selected healthy node should do nothing rather than trigger another switch.
Understand existing connections after a switch
Changing a policy group affects new traffic according to the core’s routing behavior. Existing TCP connections may continue through the old outbound until they close, and long-lived WebSocket or HTTP/2 sessions may remain attached to the previous path. If an application appears stuck after a successful switch, inspect GET /connections and close only the affected connection through the connection-management endpoint supported by the running core. Avoid deleting every connection as a default response because that interrupts downloads, logins, and other unrelated sessions.
If the group is used by a rule such as MATCH,Node Select, confirm that the application’s traffic actually reaches that rule. A more specific rule above it may route the application to another group, DIRECT, or REJECT. An API switch can therefore be correct while the observed website continues using a different policy. Review the rule order and the connection’s reported metadata before changing the script.
Secure the API and diagnose failures from logs
The external controller should be treated like an administrative service. Keep it on loopback whenever the automation runs on the same machine. Do not use 0.0.0.0:9090 simply because a phone or another computer cannot reach 127.0.0.1. If remote administration is necessary, restrict the listening address with a firewall, allow only a trusted network, use a VPN or an authenticated reverse proxy, and protect the connection with TLS. The secret should be long, randomly generated, and excluded from shell history, screenshots, repositories, and shared configuration files.
- Use an environment variable or operating-system credential store instead of hard-coding the secret in the script.
- Send the
Authorization: Bearerheader only to the configured controller address. - Do not print full request headers or URLs if they contain credentials.
- Limit script permissions so that other local users cannot read the secret.
- Prefer a dedicated controller port and document it separately from the mixed proxy port.
- Rotate the secret after sharing a diagnostic archive or changing the set of trusted automation tools.
When the API fails, start with the HTTP layer before examining node quality. A 401 or 403 points to an incorrect or missing secret. A 404 may indicate an unsupported endpoint, a wrongly encoded group name, or a path that differs in the current core. A 405 usually means that the HTTP method is wrong. A 400 often indicates invalid JSON, a missing name field, or a target that is not a member of the group. A 500 requires checking the core log and the exact configuration state.
| Symptom | Likely cause | Next check |
|---|---|---|
| Connection refused | Core stopped, wrong port, or controller disabled | Check the listening socket and active YAML |
| Unauthorized | Missing, stale, or incorrectly formatted bearer secret | Confirm the secret and request header without exposing it |
| Group not found | Wrong name, encoding, or configuration reload | List /proxies and compare the exact name |
| Delay timeout | Dead node, unsuitable test URL, DNS issue, or restrictive timeout | Repeat the probe and test DNS and the node independently |
| Switch appears ineffective | Traffic uses another rule, existing connections persist, or an auto group overwrote the selection | Inspect rules, connections, group type, and reload events |
Increase the core’s log level temporarily when normal logs do not reveal enough information. info is generally adequate for routine operation; debug can expose routing, DNS, and connection details but may produce sensitive and very large logs. Reproduce one failure, capture the relevant time window, then restore the previous level. Look for configuration reloads, DNS resolution errors, TLS handshake failures, timeout messages, rule matches, and controller request errors. A successful node delay test followed by a failed application request often points to destination-specific blocking, DNS behavior, SNI handling, or a rule mismatch rather than a completely dead node.
A dependable design therefore follows a fixed sequence: confirm the controller is local and authenticated, discover the exact policy-group name, read its current selection, probe a limited candidate set, apply a switch only when the current path fails, verify the new state, and record the result. Add cooldowns, retries, and clear logs before scheduling the script with Task Scheduler, launchd, cron, or another service manager. This approach is safer than repeatedly cycling through nodes, and it remains understandable when a network outage, configuration reload, or client update changes the runtime state.