Run a personal VLESS / VMess / AnyTLS / hysteria2 VPN for selected sites while the corporate VPN keeps the default route, without the two clients fighting over the tunnel.
📖 Product introduction · 中文版 — a slide deck covering the problem, the architecture, onboarding, and a tour of the monitor. Start there if you'd rather see it than read it.
Do the whole setup with Shadowrocket (or any working VPN) ON — rowt up
downloads sing-box for you, so there's no separate fetch step. Only switch to
the corp VPN once it's up and working.
# --- with Shadowrocket ON the whole time ---
# 1. install
brew install tanghong123/tap/rowt # or: ./install.sh
# 2. bring your servers in — import from another client you already use:
rowt server import # from Shadowrocket (default)
# or: rowt server import --from clash-verge | v2box | flclash
$EDITOR ~/.config/rowt/import-review.json # delete stale servers/subs
rowt server import --apply # (or: rowt server add '<vless://…>' / rowt sub add '<url>')
# 3. set up & start (auto-fetches sing-box if missing, then renders/starts/proxies)
rowt up host # host mode (the common one); 'rowt up' auto-detects, 'up vm' forces vm, 'up local' runs with no tunnel
# --- now switch networks ---
# 4. quit Shadowrocket, connect the CORP VPN, then verify:
rowt explain www.google.com # which lane a destination takes (was 'rowt route')
rowt status # mode / server / proxy / reachability(Mode vm also needs its image — up vm fetches that too if it isn't cached.
Probing for host-vs-vm is most accurate with the corp VPN on, so if you let
rowt up auto-detect, re-run it once after connecting corp.)
One-time shell setup (recommended). Add this to your ~/.zshrc (after
brew shellenv):
eval "$(rowt shell-init)"It gives you tab-completion for every subcommand, the rowt-proxy-on /
rowt-proxy-off aliases used below, rowt-remote-on / -off for using another
tailnet node's rowt, rowt-remote-system-on / -off for doing that system-wide,
and optional rowt-share-on / -off / -status helpers for sharing rowt inside
your Tailscale tailnet — idempotent, so it's safe to keep in your rc.
Day to day:
rowt-proxy-on # point THIS shell's curl/git/npm/… at rowt (rowt-proxy-off to undo)
rowt-remote-on rowt-mac # client: point this shell at rowt-mac:17890 (rowt-remote-off to undo)
rowt-remote-system-on rowt-mac # client: point macOS apps there (system-off to undo; needs admin)
rowt-share-on # tailnet-only TCP forward :17890 -> rowt :7890 (rowt-share-off to undo)
rowt escape add youtube.com # send another site through the personal tunnel
rowt corp add '*.intranet.example.com' '10.0.0.0/8' # send a domain or CIDR into the corp VPN
rowt block add ads.example.com # sinkhole an ad/telemetry domain (no DNS, no dial)
rowt use JP # pick a server (rowt ping shows the fastest)
rowt status # is it working? (mode / server / proxy / reachability)
rowt speed <url> # is a lane FAST enough? (a lane can be reachable and still unusable)
rowt monitor # live full-screen dashboard (connections, errors, server health)
rowt reload # after switching Wi-Fi ↔ wired ↔ hotspot
rowt watch install # (optional) auto-reload on every network change
rowt down # stop everythingrowt-share-on keeps sing-box bound to 127.0.0.1; it asks Tailscale Serve to
publish a raw TCP forward on tailnet port 17890. Other tailnet devices can use
<this-mac>.ts.net:17890 as either an HTTP or SOCKS5 proxy for TCP traffic. Set
ROWT_SHARE_PORT to change the published port and ROWT_PORT if rowt itself uses
a non-default port. Access is governed by the tailnet policy: restrict that TCP
port to trusted devices, because shared clients can use every rowt lane,
including corp. This never uses Tailscale Funnel and does not expose rowt to the
ordinary LAN or public internet. This is access control, not port hiding: a
tailnet peer allowed by policy can discover 17890 by scanning the Mac's
Tailscale IP; a non-tailnet or policy-denied host cannot reach it. The Serve
mapping persists across restarts; rowt-share-off removes it, and
rowt-share-status shows both backend and Serve state.
On a client node, rowt-remote-on <remote-host> points that shell's lowercase
and uppercase HTTP/HTTPS proxy variables at http://<remote-host>:17890 and its
all_proxy variables at socks5h://<remote-host>:17890; the latter keeps DNS on
the remote side so rowt receives the hostname it needs for lane classification.
Pass a Tailscale MagicDNS name or IP, not a URL. Bare IPv6 addresses are bracketed
automatically. A single-label machine name such as aries-black is matched
case-insensitively against tailscale status --json and expanded to its unique
full DNSName (without the trailing dot). If the Tailscale CLI or jq is
unavailable, or the name is unknown, the helper keeps the short name and lets
MagicDNS resolve it normally; an ambiguous match is rejected. Set
ROWT_SHARE_PORT before calling it for a non-default Serve port.
Before exporting anything, rowt-remote-on checks that the resolved host and
Serve port are reachable; failure prints the rejected endpoint and leaves the
existing proxy environment untouched. Success also prints the selected endpoint.
rowt-remote-off clears the same six variables. These helpers affect only the
current shell and do not change the client's macOS system proxy.
For GUI applications on a macOS client, rowt-remote-system-on <remote-host>
uses the same expansion and points the active physical network service's HTTP,
HTTPS, and SOCKS settings at the remote Serve port. It first refuses an
unreachable port, is idempotent, needs admin only when the settings differ, and
remembers that service for a paired
rowt-remote-system-off in the same shell. It deliberately leaves the service's
existing proxy bypass list untouched: whether private/corp destinations should
stay local or traverse remote rowt is client-specific policy. The off helper
disables all three proxy types on the remembered (otherwise current) service.
Do not run the rowt watchdog on that client while using this system helper: both
manage the same macOS proxy settings and the watcher may restore local rowt.
rowt monitor is the htop-style live view (with confirmed, reversible
controls — server switch, lane routing, proxy toggle) — press ? for keys,
q to quit. Great for watching what's going through escape vs direct, spotting
domains that are failing (candidates for rowt escape add), and checking server
latency at a glance. See Monitor (TUI).
Tired of running rowt reload by hand after every network switch? rowt watch install sets up a LaunchAgent that does it for you — it re-applies on each
network change, but only while the router is up, and skips when nothing actually
moved. rowt watch status shows whether it's active; rowt watch uninstall
removes it. (It installs a scoped passwordless-sudo rule for just the
networksetup proxy toggles so a Wi-Fi↔Ethernet switch doesn't prompt; that's
removed on uninstall.)
Forgot where you left off? rowt onboard (or just rowt with no arguments)
prints a checklist of these steps with the exact next command to run.
That's it. The rest of this README explains how the three-way split works and the full command set.
sing-box is fetched, not hand-installed. rowt downloads a pinned
sing-boxinto~/.config/rowt/bin/— but that download needs internet from GitHub, which in China means it must happen while a working VPN (Shadowrocket) is on.rowt upauto-fetches it if it's missing, which is why the common path above runs the whole setup (throughrowt up) with Shadowrocket on, then switches to the corp VPN. You can also pre-download explicitly withrowt fetch [host|vm].For mode
vmthe guest can't borrow the host's VPN (it's bridged onto the LAN), soup vmalso fetches the ubuntu image + the linux sing-box into~/.config/rowt/cache/(if not already there); it then boots from the local image and installs sing-box into the guest from that cache — the VM never reaches GitHub itself.Alternatives if GitHub is blocked:
brew install sing-box(rowt will use it), or download the tarball yourself andSINGBOX_TARBALL=/path/to/it rowt fetch host. Other deps (brew,jq,python3,curl) ship with macOS; modevmalsobrew installs Lima.
The trick: don't run a second tun. rowt runs sing-box on the host as
a rule-router — a mixed HTTP+SOCKS proxy on 127.0.0.1:7890 — that splits
traffic three ways. Because it's a userspace proxy, there's no default-route
war with the corp client.
CLI tools (claude, git, npm, curl, …) don't respect the macOS system
proxy — they only honour the http_proxy / https_proxy / all_proxy
environment variables. rowt gives you rowt-proxy-on / rowt-proxy-off for a
local rowt and rowt-remote-on <remote-host> / rowt-remote-off for a
tailnet-shared rowt (from eval "$(rowt shell-init)") to set and clear those,
but if you
hop between networks a lot — corp VPN, home Wi-Fi, a hotspot, a plane —
or you have several proxy apps around (rowt, Shadowrocket, a corp client),
the right value keeps changing, and it's easy to forget which one is live.
Running a command through the wrong (or a stale) proxy env then fails in
confusing ways, and toggling rowt-proxy-on/-off by hand before every command
gets tedious.
rowt run <command> [args…] does it for you: it figures out which proxy path
can actually reach the internet right now, sets the env accordingly, and runs
your command with it — no manual toggling.
rowt run claude # run claude through whatever path reaches the net
rowt run git pull # a one-off git through the working proxy
rowt run npm installIt probes in order and uses the first that reaches the target:
- the proxy already set in your shell (
http(s)_proxy/all_proxy); - the macOS system proxy (exported as env for this run);
- rowt's own port (
127.0.0.1:7890) — only when the router is up and the system proxy is off (the "rowt is on but I don't want it hijacking everything" case); - no proxy at all (direct).
If none of them reach the target, rowt run stops and does not run the
command (exit 1) — so you never silently launch something into a dead network.
"Reaches the target" means the host actually answered (any 2xx/3xx/4xx — a
200, a redirect, even a 401/404 all prove the path works). The default
target is https://www.google.com/ — a domain that's genuinely blocked on a
restricted network, over HTTPS so a captive portal or poisoned DNS can't fake
it. (A CDN connectivity host like gstatic.com can stay reachable even when
Google proper is blocked — a false positive — which is why the real domain is
used.) Override with ROWT_RUN_TARGET to gate on whatever you actually need,
e.g. an API:
ROWT_RUN_TARGET=https://api.anthropic.com/ rowt run claudeEverything after run is the command, so its own flags (--help, -v, …) pass
straight through; use rowt help run for run's own documentation.
This is exactly why "router up, system proxy off" is a first-class mode —
and rowt run is built for it. On hotel/airline Wi-Fi you first have to load a
captive-portal page and authenticate. If the macOS system proxy is
pointed at rowt, that breaks: rowt can't reach the portal, and macOS's own
captive-portal detection gets confused, so the auth page never appears. Keeping
the system proxy off lets the portal flow work normally.
So the flow is:
rowt down(or justrowt proxy off) so the system proxy is off —rowt statusshowssystem proxy: No, androwt monitorshowsrouter: running+sys proxy: off. Keep the router up.- Connect to the Wi-Fi and finish the captive-portal login in your browser.
- Now run the tools that need the tunnel with
rowt run— it routes just that command through rowt's port via env, without ever touching the system proxy (so it can't re-break the portal). It's captive-portal-aware too: the defaulthttps://www.google.com/target uses HTTPS, so a portal can't fake a success —rowt runrefuses to launch until you've actually authenticated and the open internet is reachable.
rowt monitor's split of router (is the tunnel engine up) vs sys proxy (is system traffic being pointed at it) makes this mode legible at a
glance.
| bucket | list | where it goes | example |
|---|---|---|---|
| escape | config/escape-domains.txt |
personal VLESS tunnel | google, youtube, github |
| corp | config/corp-domains.txt (domains and CIDRs) |
into the corp VPN (via the OS routing table, so the corp client's own routes carry it) | *.corp.example.com, 10.0.0.0/8 |
| direct | everything else (the default) | straight out the physical NIC, bypassing both corp and escape | baidu, the China internet |
How each is enforced inside sing-box:
- escape → the VLESS selector (its uplink is bound to the physical NIC / or a VM, per mode).
- corp → a
directoutbound with no interface binding — the OS routing table decides, so anything the corp VPN has a route for (internal IPs, its DNS) rides the tunnel. Corp domains also resolve via the system resolver (corp DNS) so intranet names resolve. - direct → a
directoutbound bound to the physical NIC (en0), resolving via a China DNS (223.5.5.5) over that NIC — no corp-DNS leak, China-optimal IPs.
Unlisted traffic defaults to direct; set ROWT_FINAL=corp to send the
catch-all through the corp VPN instead. Edit a list then reload:
rowt reload.
A plain suffix like google.com matches google.com and *.google.com — but
not google.com.hk, google.de, google.co.jp, and the ~200 other ccTLDs.
Instead of enumerating them, add a maintained sing-geosite
category to any lane file with a geosite: line:
# config/escape-domains.txt
geosite:google # all Google domains (search, gmail, youtube, gstatic, …), all ccTLDs
geosite:telegram
api.anthropic.com # normal suffixes still work alongside
It's fetched + cached (rowt fetch host, or automatically on the next up) and
rendered as a sing-box rule_set for that lane — the same mechanism the block lane
already uses for the ad/tracker set. It sits after your hand suffixes (an explicit
entry still wins) and before the ad-block set. Supported in the escape and
block lanes (block a whole service with geosite:tiktok); not corp, which
routes internal domains/CIDRs. A category that isn't cached yet is skipped with a
warning until you rowt fetch host.
geosite: is always an explicit, deliberate choice — rowt never swaps a specific
domain for a whole-service category behind your back. As a convenience, adding a
plain domain shows (does not apply) any categories that also cover it, so you can
opt in if you want:
$ rowt escape add cloudfront.net
added: cloudfront.net
cloudfront.net is also covered by these geosite categories — add a whole service if you want:
rowt escape add geosite:amazon
A specific domain always beats a geosite: rule (suffix rules are matched first),
so keeping individual domains alongside a broad category is safe and deterministic.
Fixing the tunnel-uplink problem (so escape traffic doesn't get swallowed by the
corp tunnel) is done two ways, picked automatically by probe:
| mode | how the uplink escapes | when |
|---|---|---|
| host | VLESS outbound with bind_interface=<physical NIC> — forced out the physical interface |
corp enforces via routes (common). Compact, no VM, works while travelling. |
| vm | a bridged Lima VM runs the VLESS tunnel; the host forwards escape traffic to it over SOCKS. The VM has its own network stack. | corp enforces via a packet filter and bind_interface can't bypass it. |
In both modes the system-proxy target stays 127.0.0.1:7890; only the escape
outbound differs (direct VLESS vs. SOCKS→VM), so switching modes is transparent.
Homebrew (recommended):
brew install tanghong123/tap/rowtFrom source:
./install.sh # copies to ~/.local/share/rowt, symlinks ~/.local/bin/rowt
rowt versionA second command, rowt-rust, ships alongside rowt on Apple Silicon. It is a
Rust port of the whole CLI, and it is a preview you can ignore: nothing runs
it for you, rowt does not delegate to it, and removing it changes nothing.
It exists so the two can be run side by side on the same config:
rowt status # the shell
rowt-rust status # the port, same config, same outputEvery one of the 37 command arms answers natively and is compared against the
shell — stdout, exit status, the config tree with file modes, the argv trace and
the audit log — over 241 cases (tests/parity/). What is NOT yet proven is time
on real networks, which is what the preview is for. If the two ever disagree on
your machine, that is worth reporting: the whole design of the port is that they
should not.
Two smaller binaries ship next to it, rowt-render and rowt-watch-tick. Those
are inert unless you turn a comparison on (ROWT_RENDER_SHADOW=1,
ROWT_WATCH_SHADOW=1), which makes rowt record where the Rust would have
disagreed with it, and act on its own answer regardless.
The installer is version-guarded: re-running it does nothing if the installed
copy is the same or newer than the source (--force overrides; --uninstall
removes it). --prefix DIR / --bindir DIR change the locations. You can also
just run it in place from the repo as ./bin/rowt without installing.
# one or more servers (vless:// , anytls:// , or hysteria2:// / hy2://)…
./bin/rowt server add 'vless://<uuid>@host:port?security=reality&sni=...&pbk=...&sid=...#JP' \
'anytls://<pass>@host2:port?sni=...#US' \
'hysteria2://<pass>@host3:443?sni=...&insecure=0#SG'
# …a subscription link (add several with --add)…
./bin/rowt sub add 'https://example.com/sub/xxxxx'
# …or migrate straight from Shadowrocket (see below):
./bin/rowt server import
# with the CORP VPN connected:
./bin/rowt upserver import reads a proxy client you already use and writes a source-independent,
editable review file (~/.config/rowt/import-review.json). Delete the entries you
don't want (stale servers/subs), then apply — --apply doesn't care which source it
came from:
./bin/rowt server import # from Shadowrocket (default)
./bin/rowt server import --from clash-verge # …or Clash Verge Rev
./bin/rowt server import --from v2box # …or V2Box
./bin/rowt server import --from flclash # …or FlClash
$EDITOR ~/.config/rowt/import-review.json
./bin/rowt server import --apply # import what remains; fetches the subs freshEach source contributes its servers (only the protocols rowt speaks — VLESS /
VMess / AnyTLS / hysteria2; others are counted as skipped) and, where it has them,
its subscription URLs (added to subs.txt so they stay auto-updating — e.g. a
Clash Verge remote profile). Clash sources need yq
(brew install yq); V2Box is read from its local database. The apps don't need to be
running — rowt reads their on-disk config.
Anything you already have is skipped and reported: servers are matched by
identity (address / port / credentials — not name), so importing the same node
under a different name (say Elm when you already have Hong-Server) won't create a
duplicate; and a subscription URL already in subs.txt is skipped too (matched
ignoring a display-only name= param). import --apply is source-independent: it
applies whatever's in the review file, regardless of which client it came from.
VLESS and AnyTLS servers import; Shadowsocks/other protocols are reported as
skipped. PROXY-rule domains are merged into your escape list.
./bin/rowt server rm <tag> # remove a manual server (or `server clear`)
./bin/rowt sub # list subscriptions (numbered)
./bin/rowt sub rm <n> # remove a subscription (or `sub clear`)A server that came from a subscription is removed by dropping that subscription
(individually-dead nodes are auto-avoided by use auto).
up runs probe (chooses host/vm), brings up the VM if needed, renders the
configs, starts the router, and points the macOS system proxy at it. Then edit
which sites escape and reload:
$EDITOR config/escape-domains.txt
./bin/rowt reloadEvery configured server (VLESS, AnyTLS, or hysteria2, from manual imports and/or
subscriptions) is a member of a sing-box selector group named escape.
./bin/rowt server # list servers; * marks the live one
./bin/rowt ping # parallel latency test (router must run)
./bin/rowt use JP # pin a specific server (manual — the default)
./bin/rowt use auto # opt into auto-selection insteadManual is the default (use <tag>): a plain selector that never
health-checks its members, so a flaky or dead subscription server just sits
idle and can't spin the CPU. Use ping to find a good one, then pin it —
switching between pinned servers is live via sing-box's Clash API.
use auto switches to a urltest that auto-picks the fastest live server
and re-probes them every ROWT_AUTO_INTERVAL (default 20m). Toggling
auto↔manual re-renders and restarts (the urltest appears/disappears).
The selector lives wherever the tunnel runs (host in mode host, the VM in mode
vm), so selection works identically for both. sub add remembers the URLs;
sub update re-fetches them.
Mostly no. rowt proxy on sets the macOS system proxy (SOCKS +
HTTP + HTTPS) to 127.0.0.1:7890, and GUI apps that honour it (Safari, Chrome,
most Electron apps) just work — the app sends all its traffic to the one
proxy and sing-box decides escape/corp/direct per destination. The app never
needs to know about the three buckets.
Exceptions:
- Terminal / CLI tools (
curl,git,npm, …) ignore the macOS system proxy. Point them at the router with env vars:eval "$(./bin/rowt proxy env)" # sets http_proxy/https_proxy/all_proxy eval "$(./bin/rowt proxy env --off)" # unset them
- Apps with their own proxy setting (some browsers/extensions): set them to
SOCKS5
127.0.0.1:7890(or HTTP127.0.0.1:7890). - Apps that bypass proxies entirely aren't routed by escape; they follow the OS (i.e. the corp VPN / direct) as usual.
escape only routes what reaches its proxy — great for web browsers and normal apps, but a proxy can't catch everything a full packet tunnel does. You'd be better off with a full tunnel when you need to route:
- Games and other UDP-heavy apps,
- Voice/video calls (Zoom, Teams, WhatsApp/Telegram calls), WebRTC, QUIC/HTTP3,
- Torrents / P2P, mail clients, or other non-HTTP protocols,
- Apps that ignore proxy settings entirely.
The catch is that a full tunnel (Shadowrocket-direct) is exactly what conflicts
with the corp VPN — that's why this tool exists. So the trade is: escape =
coexists with corp, covers proxy-aware apps; Shadowrocket-direct = covers
everything, but can't run next to the corp client. If you need full coverage for
one app, mode vm is the middle path (point that app/device at the VM).
See DESIGN.md for the full packet- and DNS-level walkthrough of how routing works with the corp VPN on, and a Shadowrocket→escape mapping table.
The default policy is direct, but web apps quietly reach out to domains that
may be blocked or need the tunnel — and it's rarely obvious which. Two read-only
tools tell you exactly what's happening, so you can move just those domains into
escape (or corp) instead of tunnelling everything. (Both read live state; the
per-lane error capture needs the router running, i.e. after rowt up.)
What's flowing right now — rowt connections. A live snapshot of active
connections, aggregated by host, showing which lane each is on, bytes up/down, and
the rule that matched. This is the only view that shows successful traffic:
$ rowt connections
13 active connections: escape=7 direct=6
escape api.anthropic.com:443 2× ↑35.7M ↓160K domain_suffix
escape claude.ai:443 1× ↑31K ↓13K domain_suffix
direct gateway.icloud.com:443 1× ↑5K ↓5K final
…
rowt connections escape filters to one lane; rowt connections -w refreshes
every 2s (Ctrl-C to stop).
What's failing — rowt <lane> errors [period]. sing-box runs quietly (warn
level), so it only logs failed/refused connections. The router sorts those per
lane into ~/.config/rowt/log/lane-<lane>.log (timestamp⇥domain⇥reason), and
errors summarizes them by domain, categorizing the reason:
$ rowt direct errors 10m
direct lane — 12 failed connection(s) in the last 10m, across 3 domain(s):
8 timeout rr1.googlevideo.com
3 reset x.com
1 dns gateway.icloud.com
→ tunnel the real ones: rowt escape add <domain> (timeout/reset/refused ⇒ likely blocked; dns ⇒ often transient)
timeout/reset/refused⇒ the site is almost certainly blocked — a prime escape candidate.dns⇒ usually a transient resolver blip, not a routing problem.
Because only failures are logged, an empty escape errors means that lane had
no errors — not that it carried no traffic (for that, use connections).
Periods take minute granularity — 5m 10m 1h 24h 7d all (default 10m; block errors defaults to 24h). rowt <lane> log live-tails the raw log. All these logs
rotate automatically (bounded disk, 9 generations kept).
The workflow — find and fix a misbehaving app (keeping the default DIRECT):
# 1. reproduce the problem with the app on the default policy, then:
rowt direct errors 10m # which domains couldn't be reached directly
# 2. tunnel the blocked ones (timeout/reset/refused):
rowt escape add googlevideo.com x.com # or 'rowt corp add <host>' for an intranet name
# editing a lane auto-reloads the router
# 3. confirm the new routing:
rowt explain rr1.googlevideo.com # -> escape (explains which rule matched)
rowt connections escape # watch them actually flow through escaperowt explain <domain> explains the lane any destination would take and why,
without hitting the site — handy for sanity-checking a change. And rowt block errors [period] (default 24h) shows what the ad/telemetry sinkhole refused, so you
can spot a chatty tracker or confirm the block lane is doing its job.
Commands are grouped by noun. rowt help prints the full list, annotated by how
often you'll reach for each (● everyday · ◐ occasional · ○ advanced).
Every command has detailed help: rowt <command> --help (or rowt help <command>).
Lifecycle
| command | what it does |
|---|---|
onboard |
guided getting-started checklist — shows how far you are and the exact next command. rowt with no args shows it too. |
up [host|vm|local] [--force] |
ensure sing-box → probe (if no mode) → render → start router → proxy on. local = no tunnel at all, for when you are already outside the censored network: the escape lane's rules retarget to direct (keeping their precedence over broader block entries), block/corp/direct are unchanged, and no server is needed. A bare up chooses it when the ROWT_GFW_CANARIES answer over the physical NIC. Idempotent (no-op if already up), except vm mode re-detects the VM's DHCP IP and re-wires if it moved; --force does a full rebuild. Switching to host mode powers the VM down. |
down |
tear everything down: system proxy off, kill sing-box (incl. strays), VM down. |
restart |
bounce the tunnel in place (host or vm, whichever is active) — no re-render, no proxy change. Use if sing-box is stuck/high-CPU. |
reload |
re-detect the network interface, re-render, restart, re-apply the proxy — run after switching Wi-Fi ↔ wired ↔ hotspot. |
watch <install|uninstall|status> |
install/remove a LaunchAgent that (a) runs reload on every network change (debounced; a no-op when neither the interface nor the active-service proxy moved) and (b) on a timer probes the escape tunnel and auto-recovers it — a wedged tunnel (router up, not carrying traffic; 3 failed probes) or a crashed one (router down while your intent is up, same boot), via reload, cooldown-gated and verified by a re-probe. It also runs once at login: if rowt isn't running but the system proxy is still set to 127.0.0.1:7890, it clears it, so rowt's proxy effect never outlives a reboot. install also adds a scoped passwordless-sudo rule for the networksetup proxy toggles (so a Wi-Fi↔Ethernet switch doesn't prompt); uninstall removes both. Recoveries are recorded in audit. |
status |
mode, servers, proxy state, reachability and config validity (absorbs the old doctor). |
explain <domain|ip> |
explain which lane a destination takes — escape (proxy), corp (into the corp VPN), block, or direct (pass-through) — and which rule matched. Mirrors the real routing: a --domain (whole-host) entry wins outright, else hand-list domain suffixes win by longest match across all three lanes, then corp CIDR (on the resolved IP), then final; adds a live HTTP check if the router is running. (route still works as a hidden alias.) |
report |
full offline diagnostic (deps, configs, per-server reachability, DNS, through-proxy tests, log + audit tail) → ~/.config/rowt/diag-*.txt, secrets masked, for sharing. |
audit [-n N|all|path|clear] |
the mutation trail — one line per state-changing op, whether you ran it or the watch agent did, with BEGIN/END/ABORT, timing, and a by=<parent>(<tty>) field that says whether it was hands-on (by=zsh) or the watchdog (by=launchd). BEGIN is written before the work, so even a command that hangs leaves a trace. Read-only commands aren't recorded. → ~/.config/rowt/log/audit.log. |
metrics [status|top|path|query] |
per-domain traffic history — a collector sidecar records bytes in/out per domain/lane into a tiered SQLite store (5s → 1y). status shows liveness; top [secs] the heaviest domains; path the store path + schema; query "<SQL>" a read-only SQL passthrough. Surfaced interactively in monitor via the v flip. See Traffic metrics. |
config [list|export|import] |
back up / move the whole setup to another machine. export bundles just the source-of-truth files (server pool, subscriptions, escape/corp/block lane rules) into a .tgz; import <file> restores them and re-renders. Skips the machine-specific host.json/state/binary — those regenerate via render/up. Bundle holds credentials: move it encrypted. |
monitor |
full-screen TUI (htop-style) — the live view of everything at once: connections + throughput, errors/blocked over a rolling window, and server health, plus confirmed, reversible controls (server switch, lane routing, proxy toggle). See Monitor (TUI). |
run <command> [args…] |
run a command through whatever proxy path actually reaches the internet — probes, in order, the current shell proxy env → the macOS system proxy → rowt's port (if the router is up and the system proxy is off) → direct, and execs the command with the first where the target host answers (default https://www.google.com/; override ROWT_RUN_TARGET). Aborts without running if none work. Handy for CLI tools (claude, git, npm…) that ignore the system proxy: rowt run claude. |
Servers & selection
| command | what it does |
|---|---|
server list |
list servers (* = active). |
server add '<vless://|vmess://|anytls://|hysteria2://…>' [more…] |
add manual server(s) from link(s), deduped. |
server rm <tag> / server clear |
remove a manual server / clear all manual. |
server import [--from shadowrocket|clash-verge|v2box|flclash] [--apply] |
import servers and subs from another client via an editable, source-independent review file. |
server import <file.json> |
restore manual servers from a server dump (round-trips). |
server dump [file] |
export the manual servers as JSON (backup; has secrets). Subscription servers come from their subs — use sub dump. |
sub list|add <url>|rm <n>|update|clear |
manage subscriptions. |
sub import [--apply] |
same as server import (Shadowrocket). |
sub import <file> |
restore subscriptions from a sub dump (round-trips). |
sub dump [file] |
export the subscription URLs (one per line). |
use <tag> / use auto |
pin a server (manual, nothing probed) or auto-pick the fastest live server. |
ping [tag] |
parallel latency test through the tunnel (fastest first, *=active). ROWT_PING_URL (default Cloudflare) / ROWT_PING_TIMEOUT (8s). |
probe |
with corp VPN up, test all servers (default route vs physical NIC) and pick host or vm. |
Routing lanes — escape and corp share the same verbs:
| command | what it does |
|---|---|
escape / corp / block (no verb) |
list the lane. |
… add <d>… / … rm <d>… |
add / remove domains (corp also takes CIDRs). Reloads if running. |
… add --domain <d>… |
match the whole host only, not its subdomains — stored as domain:<host>, rendered as a sing-box domain rule instead of domain_suffix. --domain-suffix names the default explicitly. Applies to every entry of that add/rm, from any position. |
… add --force <d>… |
add an entry that is a whole namespace. Lane entries are suffixes, so com is every .com and co.uk is every .co.uk; both are declined unless you say --force. |
… import <file> |
batch-add one domain per line from a file (merges; never replaces). |
… clear |
remove every entry (keeps the file's comment header). Reloads if running. |
… dump [file] |
export the lane (stdout, or to a file for backup/versioning). |
direct errors [5m|10m|1h|…|all] |
which domains failed on the default DIRECT lane in that window (default 10m) — your escape candidates. Reason is categorized (timeout/reset/refused ⇒ likely blocked; dns ⇒ transient). Reproduce a misbehaving app, then direct errors 10m and escape add the real ones. |
<lane> errors [5m|…|all] |
same for any lane: block errors (default 24h) = what got sinkholed; escape errors / corp errors = failures on those lanes. Only failed/refused connections are logged — an empty list means no errors, not no traffic. |
<lane> log |
live-tail that lane's connection-error log. |
connections [lane|-w] |
live view of active connections and which lane each is on (escape/direct/corp/block), with bytes up/down and the matched rule. Unlike errors, this shows successful traffic — "what's actually going through escape right now". -w refreshes every 2s. |
Suffix vs whole-host. A lane entry is a domain_suffix by default, and
that is almost always what you want: z.com covers z.com and a.z.com, and —
since sing-box matches on a label boundary — does not cover xz.com. Reach
for --domain only when one host must go somewhere its own subdomains should
not:
rowt corp add --domain dev.g.alicdn.com # just this host into the corp VPN…
rowt escape add alicdn.com # …while the rest of the CDN escapesThe two kinds are different entries, so the same name can sit in different lanes one way each; the exact rule is emitted first and wins for that one host.
Whole-namespace entries are declined. Because entries are suffixes, escape add com would route every .com through the tunnel — silently and completely.
add refuses two shapes: a single label (com, cn, .com — any bare TLD)
and a bare registry suffix (co.uk, com.cn, ne.jp). It is matched by
shape, not a list of TLDs, so it stays correct as new ones appear;
bbc.co.uk and google.com are unaffected. Add --force when you mean it —
a single label is exactly right for an internal namespace on the corp lane
(rowt corp add --force lan), which is why this is a guard and not a ban. The
monitor applies the same rule with no override, and paints the entry red as you
type it.
The router captures each failed/refused connection per lane (timestamp⇥domain⇥reason)
into ~/.config/rowt/log/lane-<lane>.log — the block flood is diverted out of
host.log; direct/corp/escape failures are kept in host.log too. All rotate.
The block lane is an ad/telemetry sinkhole: matching domains are refused
instantly — no DNS lookup, no dial — which stops the direct-lane retry storm
(dead ad/tracker hosts retried in a tight loop) that can spike sing-box CPU. It's
additive on top of a large geosite ad/tracker rule-set (thousands of
domains) that rowt fetch host caches and rowt renders in automatically when
present (offline-safe: the hand list works without it). Block runs before
corp/escape/direct.
Proxy & internals
| command | what it does |
|---|---|
proxy status|check|on|off|env [--off] |
show / verify / set / unset the macOS system proxy; env prints CLI env exports. on/off are idempotent — they read the current state first (no sudo) and only invoke admin for what's actually wrong, so re-running never prompts if already correct. on is a no-op unless the router is running (else it would just point the system proxy at a dead port and break traffic) — run rowt up first, or proxy on --force to override. check exits 0 iff fully configured (used to re-apply after the OS config drifts). |
shell-init |
shell integration to eval in your rc — defines rowt-proxy-on/-off, client-side rowt-remote-on <remote-host>/-off, system-wide rowt-remote-system-on <remote-host>/-off, the Tailscale rowt-share-on/-off/-status helpers, and loads tab-completion for subcommands (zsh/bash), idempotent. Add eval "$(rowt shell-init)" to ~/.zshrc. |
completion <zsh|bash> |
print a tab-completion script (normally auto-loaded by shell-init; defers to the live command set so it never drifts). |
render |
regenerate the sing-box configs from current state. |
fetch [host|vm|both] |
pre-download while a VPN is on so up works offline. host = the macOS sing-box binary; vm = the ubuntu image + linux sing-box tarball into ~/.config/rowt/cache/ (then up vm boots from the local image and installs sing-box into the guest from that cache — the VM never reaches GitHub itself). Default both. |
router up|down|restart|status|log |
the local rule-router process — the always-on proxy on 127.0.0.1:7890 (the front door your system proxy points at), which runs in both modes. (router is a process, not a mode — switch modes with up host/up vm.) |
vm up|down|restart|status|log|delete |
the bridged Lima VM (mode vm). |
version |
print the version (major.minor.revision). |
rowt monitor is a full-screen terminal UI for watching a running router — the
observe-everything companion to the one-shot status / connections / errors
commands. Everything is derived on a 2-second tick, and on top of that it offers
a small set of confirmed, reversible overrides — server switch, lane routing,
and the system-proxy toggle — each just a front-end to the same rowt commands
you could type. Everything else stays observe-only.
rowt monitor # live view (falls back to a demo fixture if nothing is running)
rowt monitor --fixtures # force the offline demo
rowt monitor --theme light # pin the palette; also --theme dark|auto, or ROWT_MONITOR_THEMEThemes. The TUI ships a dark and a light palette — same layout, same glyphs,
same keys, only the colors change. --theme auto (the default) reads the
terminal's actual background (COLORFGBG, then an OSC 11 query with a 100 ms
budget) and picks light only for a near-paper background; anything dimmer, or no
answer at all, stays dark. Pin it with --theme dark|light if your terminal
reports its background wrongly, or if you switch light/dark mid-session.
Layout (reflows at 130 columns — side-by-side above, stacked below):
- identity band — mode/interface, active server + latency, router
(
running · N%CPU, ordown · <reason>),sys proxy,watch(the auto-reload agent),collector(the metrics sidecar), uptime, and a status dot: green LIVE (breathing), red DOWN (router unreachable), orange ERROR (active server failing its probe / auto-mode with nothing reachable), grey PAUSED. - live connections — press
vto pan this pane across three views: live → ▲ upload → ▼ download (wrapping).hoststays pinned as the first column while the rest of the columns change; selection rides along, soe/c/b/d/y/fwork in every view.- live — a table of domains (host:port, concurrency, cumulative bytes, matched rule), colored by lane. Byte totals persist per domain across short-lived connections; a domain with no live connection stays as a greyed dormant row (concurrency 0), sorted after the live ones.
- upload / download — per-domain byte history from the metrics store (see
Traffic metrics) over four trailing-window columns chosen
by an
s-selectable band:recent(1m/5m/1h/24h) ·days(1d/3d/5d/7d) ·year(7d/30d/120d/1y). The same greyed list includes top historical domains not currently connected. - The header rows are a per-lane aggregate of whatever the pane shows (all /
escape / corp / direct), and stay all-lanes even when
ffilters the detail list. Block-lane traffic is excluded. - Press
/to search hosts by regex (case-insensitive; a literal-substring fallback if it doesn't compile). It filters the detail rows of both panes at once and composes AND with the lane filter, but — likef— never touches the header aggregate. Full line-editing with a block cursor;↵commits (and persists acrossv/s/f),escclears.
- errors & blocked — failures and sinkholed domains over a rolling window
(
5m/10m/1h/24h), colored by category (dns = transient, timeout/reset/ refused = persistent, blocked = purple). - server health —
N up / N down, and a marquee of the reachable pool with latencies (the active server marked▶).Tabinto it and←→picks a chip. Servers are probed through the tunnel against Google'sgenerate_204every 10 min (pressrto re-probe now).
Navigation: ↑↓/jk move (the first press locks a row by domain, so a
mid-tick re-sort can't shift what you act on; Esc unlocks; leaving a pane forgets
its selection) · ←→/hl switch pane / pick a server chip (the strip freezes
in place and wraps at the ends) · Tab cycle focus (connections → errors → health)
· v flip the connections pane (live / ↑ upload / ↓ download) · s span (the
metrics timescale band) · f (or 1/2/3, 0) lane filter · / search
hosts (regex, filters both panes; ↵ commit, esc clear) · w / [ ]
errors window · y copy the selected domain · p pause · ? help · q quit —
all focus-independent. Mouse: wheel scrolls the list
under the pointer; click a lane / window tab / row, a server chip (selects it in
place), or sys proxy to toggle it (hover-highlights).
Controls (confirmed, reversible — each maps to a rowt command):
e/c/b/d— route the locked domain into escape / corp / block, ordback to direct. Armed by the first press (a confirm bar previews the change); a second press of the same key or↵commits,Esccancels. Edits are batched — one router reload fires ~7s after the last edit settles.E/C/B/D— the same four edits on the host's parent suffix rather than the host:x.y.z.com→z.com, so one keystroke covers the whole service. Registry second levels stay whole (x.y.z.co.uk→z.co.uk, neverco.uk). The entry is bare: sing-box matchesdomain_suffixon a label boundary, soz.comcovers the apex and every subdomain but notxz.com— a leading dot would only drop the apex. With nothing broader to add (an IP, or a host that already is its registrable domain likex.com) the shifted key is inert and says so, rather than silently doing the lowercase edit. Undo anEwithD— removal is exact-line, sodon the host won't remove a suffix entry.- The confirm bar has two phases. For the first ½ second it is a plain confirmation — press the same key again to apply, or another arm key to re-target. After that it turns amber with a block cursor and the entry becomes an editable field, at which point the arm keys type instead of committing. Only colour and the cursor distinguish them; nothing moves.
- Editing works from the left, because a proposed entry is nearly always too
specific rather than too short. The cursor starts on the first character;
Ctrl-Wdrops the leading label (i.ytimg.com→ytimg.com→com) — the manual form of whatEcomputes;←→/Home/Endmove,Backspace/Deletecut,↵applies what's in the field. There is no kill-line: an empty field is a cancel, andEscsays that directly. An armed edit auto-cancels after 10s idle, measured from the last keypress, so typing keeps it alive. Note printable keys go to the field once editable, soqtypes rather than quits —Escfirst. An over-broad entry (com,co.uk) shows red and is refused; the CLI's--forcehas no equivalent here, deliberately. u— switch the active outbound server to the selected chip (live, immediate).o— toggle the macOS system proxy on/off (immediate).
Sources: the clash API (127.0.0.1:9090), host.json, state/servers.json,
and lane-*.log. Env: ROWT_MONITOR_PROBE_INTERVAL (secs, default 600),
ROWT_PING_URL (probe target). It's a small Rust/ratatui binary built and
installed alongside rowt (also runnable standalone as rowt-monitor).
A lightweight always-on collector records per-domain, per-lane bytes in/out
over time — so you can see where your bandwidth actually went, from the last five
seconds to the last year. It runs as a sidecar of the router (started once the
router comes up healthy, stopped with it — no separate service to manage), holding
the clash API's /connections websocket and diffing each connection's cumulative
counters into 5-second buckets. Set ROWT_METRICS=off to disable it.
Storage is a single tiered SQLite file at ~/.config/rowt/metrics/traffic.db,
consolidated RRD-style so it stays tiny (well under ~15 MB):
| tier | resolution | retained |
|---|---|---|
sample_5s |
5 seconds | 1 hour |
sample_1m |
1 minute | 24 hours |
sample_1h |
1 hour | 90 days |
sample_1d |
1 day | 1 year |
Each row is (ts, domain, lane, bytes_up, bytes_dn); a meta table holds the
collector heartbeat. Bytes moved by connections too short-lived to sample land in
an (unattributed) row, reconciled against the router's monotonic grand totals so
the books always balance.
rowt metrics # collector status: running? last write, size, rows
rowt metrics top [seconds] # top 20 domains by download over the last N s (default 3600)
rowt metrics path # the SQLite path + the table schema
rowt metrics query "<SQL>" # run a read-only SQL query against the storequery opens the DB read-only, so an arbitrary statement can never mutate the
collector's data. Example — total bytes moved today:
rowt metrics query "SELECT sum(bytes_up) up, sum(bytes_dn) dn FROM sample_1m"The richer, interactive view is the monitor: rowt monitor, then v to flip
the connections pane through the upload/download history (see
Monitor).
With the corp VPN connected, probe:
- checks the server is reachable via the default route (sanity: server up / online);
- checks it's reachable when bound to the physical NIC (
curl --interface).
If (2) works → host mode. If (2) fails while (1) works → the corp client is enforcing at the packet-filter layer, so → vm mode.
- Fail-closed: listed domains only ever use the
escapeoutbound. If the tunnel is down they fail, they don't silently leak onto the corp path. - Home LAN excluded by corp VPN: the host→VM SOCKS hop rides the LAN, which
corp full-tunnel clients exclude, so mode
vmneeds no route exclusion at home. - Remote DNS: the router sniffs the destination host and hands the domain to the escape server, so escape lookups resolve at the exit — no leak to corp DNS.
- Safe to leave running with the corp VPN off. The escape and direct buckets work regardless; only corp-listed sites need the corp VPN and will simply fail until you connect it. No leak, no conflict — connect corp only when you need work sites.
- Pinned sing-box (
SINGBOX_VERSION, default 1.13.14 — ≥1.12 for AnyTLS) is downloaded to~/.config/rowt/bin/so configs always match the schema. - No secrets in the repo: servers/subscriptions live under
~/.config/rowt/(mode 600); the routing lists are seeded there from the repo templates on first use, so imports/edits never touch the tracked repo. - Live switching: sing-box's Clash API (
127.0.0.1:9090, or the VM's LAN IP in modevm) is protected by a random secret stored in the state file.
- macOS on Apple Silicon (Intel works too),
brew,jq,python3,curl. - Mode
vmadditionally installs Lima + socket_vmnet via brew and writes a bridged network to~/.lima/_config/networks.yaml(needs onesudoto authorizesocket_vmnet—limactl sudoers). - Bridge over Ethernet when you can — bridging onto Wi-Fi is the flaky path.
bin/rowt main tool (subcommands above)
config/vless-parse.py vless:// / anytls:// link → sing-box outbound (stdlib)
config/sr-import.py Shadowrocket store + rules → servers/subs/domains
config/escape-domains.txt template for bucket 1 (escape) — seeded into ~/.config
config/corp-domains.txt template for bucket 2 (corp), domains + CIDRs
lima/rowt-vm.yaml bridged Lima VM template (mode vm)
The live, user-editable copies of the two *-domains.txt lists live at
~/.config/rowt/; the repo files are just first-run templates.
⚠️ Routing around a mandated corporate VPN may violate acceptable-use policy. This tool is for a personal machine at home; confirm it's sanctioned before relying on it.