Sanitized runbook from a real migration on Intel macOS + LAN Ubuntu (2026-07).
Secrets (UUID / Reality keys / IPs that identify you) are redacted as <…>.
| Old stack | Problem |
|---|---|
Clash for Windows + Premium core 2023.05.29 |
Upstream Clash core is archived / deprecated |
| Same core | No VLESS / Reality (needs Clash Meta → mihomo) |
Working phone client (Shadowrocket) does not prove desktop CFW can use the same node.
| Client | Role | Notes |
|---|---|---|
| Clash Verge Rev | GUI + embedded verge-mihomo | Best daily driver on macOS |
| mihomo (Homebrew formula) | Headless CLI core | No GUI required; same YAML |
You do not need the GUI if you are fine with:
mihomo -d ~/.config/mihomo # reads ~/.config/mihomo/config.yaml
# mixed-port default in our profile: 7890
export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890GUI is nicer for TUN / System Proxy / tray switching.
Other options (not used here): FlClashX, CLI-only mihomo + launchd, paid Stash.
Input was a single Clash-Meta proxy snippet (type: vless + reality-opts + flow: xtls-rprx-vision).
That snippet alone is not a full profile. Wrap it:
mixed-port: 7890
allow-lan: false
mode: rule
log-level: info
ipv6: true
external-controller: 127.0.0.1:9090
dns:
enable: true
enhanced-mode: fake-ip
fake-ip-filter:
- "*.lan"
- "*.local"
- "+.local"
nameserver:
- 1.1.1.1
- 8.8.8.8
proxies:
- name: Az Sg
type: vless
server: <SERVER_IP>
port: 443
uuid: <UUID>
network: tcp
udp: true
tls: true
flow: xtls-rprx-vision
servername: support.apple.com # Reality SNI / camouflage site
reality-opts:
public-key: <REALITY_PUBLIC_KEY>
short-id: <REALITY_SHORT_ID>
client-fingerprint: chrome
proxy-groups:
- name: PROXY
type: select
proxies:
- Az Sg
- DIRECT
rules:
# Critical under TUN: keep LAN / Tailscale / localhost DIRECT
- IP-CIDR,127.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,10.0.0.0/8,DIRECT,no-resolve
- IP-CIDR,172.16.0.0/12,DIRECT,no-resolve
- IP-CIDR,192.168.0.0/16,DIRECT,no-resolve
- IP-CIDR,100.64.0.0/10,DIRECT,no-resolve
- MATCH,PROXY| Shadowrocket | YAML / meaning |
|---|---|
| Type VLESS | type: vless |
| Address / Port | server / port |
| Password / UUID | uuid (not Shadowrocket’s internal uuid field) |
| Flow | xtls: 2 ↔ xtls-rprx-vision |
| Transport none | network: tcp / obfs: none |
| TLS → Reality | tls: true + reality-opts |
| SNI / peer | servername / peer |
| Public Key / Short ID | reality-opts.public-key / short-id |
| Fingerprint Chrome | client-fingerprint: chrome |
Share-link shape (also importable):
vless://<UUID>@<SERVER>:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=<SNI>&fp=chrome&pbk=<PUBLIC_KEY>&sid=<SHORT_ID>&type=tcp&headerType=none#Az%20Sg
brew install mihomo
mkdir -p ~/.config/mihomo
cp ~/azp-mihomo.yaml ~/.config/mihomo/config.yaml
mihomo -t -d ~/.config/mihomo # config test
mihomo -d ~/.config/mihomo # run (foreground / nohup / brew services)Smoke test:
curl -x http://127.0.0.1:7890 --http1.1 -s https://api.ipify.org
# expect egress IP == VLESS server (or its egress), not your home WANbrew install --cask clash-verge-rev stalled on a slow GitHub asset (~63 MB).
Workaround that worked:
- Start mihomo CLI with the working VLESS profile (above).
- Download the cask DMG through that proxy:
export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890
curl -fL --http1.1 -o Clash.Verge_<ver>_x64.dmg \
https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v<ver>/Clash.Verge_<ver>_x64.dmg
shasum -a 256 Clash.Verge_<ver>_x64.dmg # must match brew cask sha256- Install either by mounting the DMG → copy
Clash Verge.app→/Applications,
or place the DMG in Homebrew’s download cache under the expected hash name and re-runbrew install --cask clash-verge-rev.
First launch: install Service Mode helper (needed for TUN).
App support dir (macOS):
~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/
├── profiles.yaml # index of profiles
├── profiles/
│ ├── Lxxxxx.yaml # local profile body (Clash/mihomo YAML)
│ ├── Merge.yaml # merge overlay companion
│ └── Script.js # script companion
├── verge.yaml # GUI settings (system proxy, TUN, ports…)
└── clash-verge.yaml # runtime config generated for verge-mihomo
- Field name is
type, notitype(older docs/code sometimes sayitype). - Local profile UIDs conventionally look like
L…; merge/script companions are often literallyMerge/Scriptwith filesMerge.yaml/Script.js. - If you write
profiles.yamlwhile the app is running, quit the app first — on startup it may rewrite the file. - After writing the profile body + index, restart the app (or use Profiles → refresh / Use).
Minimal profiles.yaml that stuck after restart:
# Profiles Config for Clash Verge
current: LazSg001
items:
- uid: LazSg001
type: local
name: Az Sg (VLESS Reality)
file: LazSg001.yaml
desc: Converted from azp.yaml
updated: 1785044763
option:
merge: Merge
script: Script
- uid: Merge
type: merge
name: null
file: Merge.yaml
updated: 1785044763
- uid: Script
type: script
name: null
file: Script.js
updated: 1785044763Python sketch:
from pathlib import Path
import time
app = Path.home() / "Library/Application Support/io.github.clash-verge-rev.clash-verge-rev"
uid = "LazSg001"
body = Path("azp-mihomo.yaml").read_text() # full profile YAML
(app / "profiles" / f"{uid}.yaml").write_text(body)
now = int(time.time())
(app / "profiles.yaml").write_text(f"""# Profiles Config for Clash Verge
current: {uid}
items:
- uid: {uid}
type: local
name: Az Sg (VLESS Reality)
file: {uid}.yaml
updated: {now}
option:
merge: Merge
script: Script
- uid: Merge
type: merge
name: null
file: Merge.yaml
updated: {now}
- uid: Script
type: script
name: null
file: Script.js
updated: {now}
""")
# then: quit Clash Verge → reopen| Setting | Value |
|---|---|
Verge mixed-port |
7897 (see verge.yaml → verge_mixed_port) |
| CLI mihomo profile | 7890 |
| Old Clash for Windows | 7891 (~/.config/clash) |
Do not run CFW + Verge + CLI mihomo on overlapping ports. Prefer one core.
- Profiles → select the local profile → Use
- Proxies → mode Rule or Global → select Az Sg (not DIRECT)
- Home → System Proxy on; TUN optional (needs helper)
- Confirm:
curl -x http://127.0.0.1:7897 --http1.1 -s https://api.ipify.org
~/.ssh/config pattern:
Host local_ubuntu
HostName <LAN_IP> # e.g. 192.168.x.x
User <user>
Port 22
IdentityFile ~/.ssh/<key>
IdentitiesOnly yes
ServerAliveInterval 20
ServerAliveCountMax 6
TCPKeepAlive yesWith TUN + MATCH,PROXY only, LAN SSH can hang or mis-route. Fix in the profile rules (section 2): IP-CIDR,192.168.0.0/16,DIRECT,no-resolve (+ Tailscale 100.64.0.0/10 if used).
Verify:
ssh -o ConnectTimeout=8 -o BatchMode=yes local_ubuntu 'hostname; whoami'Optional: ProxyCommand=none if a global ProxyCommand exists.
Not primarily org entitlement. GitHub serves Claude ids by egress geo:
| Path | Claude in /models |
|---|---|
| Direct / CN egress | 0 |
| Via overseas node (e.g. SG VLESS) | 8+ |
Node / copilot-api / OpenCode ignore macOS System Proxy. CFW TUN/Mixin used to hide this by capturing all TCP. With System Proxy only, they cache a Claude-less catalog at process start.
Fix (chezmoi helpers):
proxy-status # Verge → 7897; Ubuntu mihomo → 7890
copilot-proxy restart # COPILOT_HTTP_PROXY=auto → --proxy-env
copilot-proxy doctor # A/B: direct 0 Claude vs via-proxy N Claude
copilot-model --auto # Claude > Codex > GPT > Gemini from served listOpenCode (if TUN healthy): no env needed. If TUN broken / System Proxy only:
HTTPS_PROXY=http://127.0.0.1:7897 opencode # Mac Verge
HTTPS_PROXY=http://127.0.0.1:7890 opencode # Ubuntu mihomo- Prefer Rule mode + LAN DIRECT rules; use Global only when debugging.
- Never run CFW + Verge/mihomo TUN together — CFW owns
198.18.0.1and plain HTTPS RSTs while-x :PORTstill works. - Bootstrap chicken-and-egg: phone hotspot or any working proxy to fetch the first DMG/binary.
- Core binary names: GUI uses
verge-mihomo; CLI installsmihomo. Same Meta API.
# CLI client (Ubuntu / headless)
systemctl --user status mihomo
mihomo -t -d ~/.config/mihomo
# Egress (prefer plain once TUN works)
curl -fsS https://api.ipify.org; echo
curl -x http://127.0.0.1:7897 --http1.1 -s https://api.ipify.org; echo # Mac Verge
curl -x http://127.0.0.1:7890 --http1.1 -s https://api.ipify.org; echo # Ubuntu
# macOS system proxy (set by Verge)
scutil --proxy | egrep 'HTTPEnable|HTTPPort|HTTPSEnable|SOCKSEnable'
# Copilot
copilot-proxy doctor
copilot-model --auto| Piece | Value |
|---|---|
| Binary | ~/.local/bin/mihomo |
| Config | ~/.config/mihomo/config.yaml |
| Unit | ~/.config/systemd/user/mihomo.service (no AmbientCapabilities — user systemd → exit 218) |
| Caps | sudo setcap 'cap_net_admin,cap_net_bind_service=+ep' ~/.local/bin/mihomo |
| Mixed port | 7890 (after CFW stopped) |
| Controller | 127.0.0.1:9090 |
| TUN | enable: true, stack: system → iface Meta (198.18.0.1/30) |
| Outbound NIC | interface-name: eno1 |
| PROXY / Final | Final → PROXY → Han Lee Az Sg (not DIRECT) |
One-shot after install:
mihomo-takeover-tun # stops clash-core-service, setcap, port→7890, restart
# Do NOT run bare `mihomo` while the user unit is active (port bind errors)Verify:
ip -br addr | grep Meta
curl -fsS https://api.ipify.org; echo # should be SG without env
curl -fsS -x http://127.0.0.1:7890 https://api.ipify.org; echo
systemctl --user status mihomoOptional. Headless mihomo + TUN already covers Node/OpenCode/git.
Clash Verge Rev ships Linux AppImage/deb if you want a tray GUI — same core family. Prefer one stack (Verge or systemd mihomo), not both.
mihomo speaks the Clash Meta external-controller HTTP API. Existing Television channels already target that:
| Channel | Role |
|---|---|
tv clash |
Browse local YAML (proxies / groups / rules) |
tv clash-api |
Live /proxies, groups, rules, connections; Alt+S switch group |
# On Ubuntu (controller auto-discovers 127.0.0.1:9090)
tv clash-api
# From Mac → Ubuntu controller (SSH tunnel; Ubuntu binds controller to localhost)
ssh -N -L 19090:127.0.0.1:9090 local_ubuntu &
CLASH_CONTROLLER=127.0.0.1:19090 tv clash-api
# Explicit remote LAN only if you rebind controller (security trade-off):
# external-controller: 0.0.0.0:9090 # then:
# CLASH_CONTROLLER=192.168.x.x:9090 tv clash-apiKeybindings (see chezmoi docs/tools/tv.md): Alt+S switch proxy into a group, Alt+T latency, Alt+R reload config, Alt+D open dashboard.
Verge often exposes the API on a unix socket only (/tmp/verge/verge-mihomo.sock), not 127.0.0.1:9097. tv clash-api expects HTTP today.
Workarounds:
- Verge Settings → enable External Controller on an HTTP port, or
- Bridge:
socat TCP-LISTEN:9097,reuseaddr,fork UNIX-CONNECT:/tmp/verge/verge-mihomo.sockthenCLASH_CONTROLLER=127.0.0.1:9097 tv clash-api, or - Drive the Ubuntu mihomo controller over SSH tunnel (above).
Shell loopback helpers (proxy-status, withproxy, …) already probe Verge 7897 / mihomo 7890 / 17890.
| macOS (Clash Verge Rev) | Ubuntu (mihomo user systemd) | |
|---|---|---|
| Core | embedded verge-mihomo |
~/.local/bin/mihomo |
| Config | Verge profiles/*.yaml + overlay |
~/.config/mihomo/config.yaml |
| Mixed port | 7897 | 7890 |
| Controller | unix sock (or enable HTTP) | 127.0.0.1:9090 → tv clash-api |
| System proxy | Verge GUI | optional; TUN covers most apps |
| TUN | Verge TUN Mode, stack: system |
tun.enable + setcap (not AmbientCapabilities) |
| Quit CFW | Required — remove Login Item | sudo systemctl disable --now clash-core-service |
| Node / OpenCode | TUN or HTTPS_PROXY=…:7897 |
TUN or HTTPS_PROXY=…:7890 |
Tailscale CGNAT (100.64.0.0/10, MagicDNS *.ts.net) needs protection at three layers. A mihomo DIRECT rule alone is not enough for browsers that first send traffic to macOS System Proxy (127.0.0.1:7897) — that path dials “DIRECT” inside the core and often times out / 502s.
| Layer | Where | What to add |
|---|---|---|
| System Proxy bypass | macOS ExceptionsList / Verge verge.yaml → system_proxy_bypass |
100.64.0.0/10, *.ts.net (keep use_default_bypass: true) |
| TUN route exclude | tun.route-exclude-address |
100.64.0.0/10, fd7a:115c:a1e0::/48 |
| Rules (defense in depth) | profile / clash-rules direct |
DOMAIN-SUFFIX,ts.net, IP-CIDR,100.64.0.0/10, IP-CIDR6,fd7a:115c:a1e0::/48,no-resolve |
Do not put tailscale.com in DIRECT/bypass on GFW networks — control-plane may still need the overseas proxy. Only bypass tailnet addresses / *.ts.net.
Putting a rules: block in profiles/Merge.yaml can replace the whole profile rule list (left us with 3 rules → everything DIRECT). Keep Merge tun-only; put Tailscale DIRECT lines in the profile (or in public clash-rules).
| Check | Result |
|---|---|
http://100.77.43.16:39000/ direct / plain |
HTTP 302 → /login (Grafana) |
| Browser → same URL | Grafana login page |
Route to 100.77.43.16 |
Tailscale iface (utun10), not mihomo TUN |
| macOS ExceptionsList | includes 100.64.0.0/10, *.ts.net |
| Public egress via Verge / TUN | 104.215.179.243 (SG) after rules restored |
| Ubuntu mihomo TUN exclude + rules | applied; plain egress still SG |
| Repo / surface | Owns |
|---|---|
| clash-rules | Public routing policy (direct includes Tailscale IP/MagicDNS) |
Per-device profile (Verge Merge / Ubuntu config.yaml) |
TUN excludes, System Proxy bypass, LAN DNS |
| DockerCompose-V2Ray | Server / vault credentials (leave as-is for now) |
| Future personal repo | Encrypted node providers, base mihomo profiles, soft-router align, one-click bootstrap |
- Mihomo wiki: https://wiki.metacubex.one
- Clash Verge Rev: https://github.com/clash-verge-rev/clash-verge-rev
- Homebrew:
brew info --cask clash-verge-rev/brew info mihomo - Chezmoi TV Clash channels:
docs/tools/tv.md(tv clash,tv clash-api) - Tailscale CGNAT:
100.64.0.0/10/ ULAfd7a:115c:a1e0::/48