A proxied shell + exit-node picker for Tailscale userspace networking
Egress through Mullvad or your own relay — by country, city, or code.
ts finland— pick a Mullvad exit node by country and connect. Run it again to cycle to the next.
Status: Early release. macOS-focused (ships a launchd daemon for userspace-networking
tailscaled);tsitself is platform-agnostic.
$ ts finland
ts: exit node → fi-hel-wg-201.mullvad.ts.net · Finland Helsinki
$ ts finland # 1 match — idempotent on re-run
ts: exit node → fi-hel-wg-201.mullvad.ts.net · Finland Helsinki
$ ts usa # first US node; re-run to walk all 21
ts: exit node → us-chi-wg-301.mullvad.ts.net · USA Chicago, IL
(21 matches for 'usa'; run 'ts usa' again to cycle)
$ ts atl # city-code token → Atlanta
ts: exit node → us-atl-wg-001.mullvad.ts.net · USA Atlanta, GA
$ ts nas # your own relay, by hostname token
ts: exit node → nas.example.ts.net
$ ts off # clear the exit node (direct tailnet egress)
ts: exit node cleared (direct tailnet egress)
now: none (tailnet direct; set one: ts <country|city|code>)
$ ts # proxied shell through the current node
ts: env mode — socks5://127.0.0.1:1055 (HTTP_PROXY=http://127.0.0.1:1055)
exit node: us-atl-wg-001.mullvad.ts.net · USA Atlanta, GA
ssh/ping: tailnet hosts via daemon (ssh nas · ping nas)
Tailscale's userspace-networking mode runs tailscaled without a system VPN extension — it exposes a local SOCKS5/HTTP proxy (127.0.0.1:1055) instead of a TUN interface. That sidesteps macOS SIP / VPN-extension limits and lets any process egress through your tailnet (and any Mullvad exit node) just by setting proxy env vars. But two things are missing out of the box:
- No shell integration — you hand-set
ALL_PROXY/HTTPS_PROXYevery time. - No quick exit-node picker —
tailscale set --exit-node=<host>means typing full hostnames likeus-atl-wg-001.mullvad.ts.net.
ts fixes both: one command drops you into a proxied shell, and ts <country|city|code> selects + cycles exit nodes by a friendly name.
One line — clones the repo, symlinks ts onto ~/.local/bin, and checks deps + the proxy:
curl -fsSL https://raw.githubusercontent.com/monotykamary/tailscale-shell/main/install.sh | bashThis installs ts but does not build tailscale or set up the daemon — that needs Go + sudo, so the installer detects whether the userspace proxy is up and prints the exact next command if not. (Overrides: TS_INSTALL_DIR / TS_BIN_DIR.) The steps below are what it automates, and how to do them by hand.
ts needs tailscaled running with --tun=userspace-networking (the SOCKS5/HTTP proxy on 127.0.0.1:1055). On macOS, the included launchd installer sets that up:
git clone https://github.com/monotykamary/tailscale-shell
cd tailscale-shell
zsh install-tailscaled-userspace.shThis requires tailscale{,d} built from source — the App Store / Homebrew build uses a system VPN extension, not userspace networking. See Tailscale's build instructions; the binaries are expected at ~/go/bin/ (override with TAILSCALE_SRC_DIR=...).
Then bring the node up once:
sudo tailscale up
# if `tailscale ...` needs root, grant your user passwordless control:
sudo tailscale set --operator="$USER"On Linux, skip the installer and run it directly:
tailscaled --tun=userspace-networking \
--socks5-server=127.0.0.1:1055 \
--outbound-http-proxy-listen=127.0.0.1:1055 &Symlink it onto your PATH:
ln -sf "$PWD/ts" ~/.local/bin/ts(Or add the repo to your PATH.) Requires zsh, awk, and optionally jq (for the richer ts status output; falls back gracefully without it).
Enable Mullvad exit nodes in the Tailscale admin console (Exit nodes → Mullvad), and allow internet egress in your tailnet ACL:
{
"acls": [
{ "action": "accept", "src": ["autogroup:admin"], "dst": ["autogroup:internet:*"] }
]
}Your own tagged exit nodes (a home relay, a VPS) appear too — match them by hostname token (ts nas, ts home). ts status lists everything once it syncs.
| Command | Description |
|---|---|
ts |
Proxied login shell (ALL_PROXY / HTTP(S)_PROXY → the socks5/http proxy) |
ts <query> |
Select an exit node by country / city / code / hostname token; re-run to cycle |
ts off |
Clear the exit node (direct tailnet egress) |
ts status |
Proxy + current exit node health check |
ts help |
This help |
Matching is case-insensitive, in priority order:
- Hostname token —
us-atl-wg-001.mullvad.ts.netsplits into[us, atl, wg, 001, mullvad, ts, net]. Sots us,ts atl,ts mullvad, andts nasall match by token. Token matching is whyts usmeans USA and not Australia (which contains "us" as a substring). - Country — exact (
finland,usa) or word (republic→ Czech Republic). - City — substring (
helsinki,atlanta,los angeles).
Each query remembers its last selection in ~/.config/ts/exit-node-cycle. Re-running the same query advances to the next matching node and wraps around. One-node countries (like Finland) are idempotent.
| Var | Default | Purpose |
|---|---|---|
TS_SOCKS_SCHEME |
socks5 |
Use socks5h for remote DNS (MagicDNS names resolve through the proxy) |
TS_SOCKS_HOST / TS_SOCKS_PORT |
127.0.0.1 / 1055 |
The tailscale SOCKS5 proxy |
TS_HTTP_PORT |
1055 |
The tailscale HTTP proxy |
- Proxied shell:
tsexportsALL_PROXY=socks5://127.0.0.1:1055,HTTP_PROXY/HTTPS_PROXY=http://127.0.0.1:1055,NO_PROXY=localhost,127.0.0.1,::1, andNODE_USE_ENV_PROXY=1(Node ≥ 24), thenexecs a login shell. Everycurl/git/npm/pip/go/brewand Node app egresses through the current exit node. - Exit-node picker: parses
tailscale exit-node list, dedupes Mullvad's "Any" + named-city duplicate rows (the same node listed twice), matches your query, and callstailscale set --exit-node=<host>. The status label parses columns by 2+ spaces so a multi-word status likeselected but offline, last seen 14h agostays one field — and is shown when a node isn't healthy, so you know to cycle again. - MagicDNS caveat: under userspace networking,
100.100.100.100is NOT reachable from the host, so do not point system DNS there. MagicDNS names resolve only when the proxied client does remote DNS (TS_SOCKS_SCHEME=socks5h), or viatailscale ssh/tailscale nc/tailscale ping(which talk to the daemon directly). Go-built CLIs only groksocks5, notsocks5h. - In-shell
ssh&ping(MagicDNS without system DNS):tsalso shadowssshandpingonPATHwith small wrappers. Inside the env shell,ssh <node>reaches tailnet hosts viatailscale nc— the daemon resolves the MagicDNS name, so no system DNS and no hardcoded IP — and your~/.ssh/configisIncluded so yourHostaliases andUsersettings still apply. Only tailnet hosts (100.64.0.0/10IPs, or namestailscale ipresolves) are wrapped; public hosts pass through untouched.ping <node>routes totailscale pingfor tailnet hosts (ICMP can't reach100.xunder userspace networking) and to the realpingotherwise.
- Cycling is in
tailscale exit-node listorder — not latency-sorted. If you land on an offline node (the label saysselected but offline), just run the query again. True "nearest" would needtailscale pingper candidate (not implemented). - The
tsname shadows moreutils'ts(a timestamp-prefixing filter). Fine as long as~/.local/binis early in yourPATH. - The launchd installer is macOS-only;
tsitself is platform-agnostic and works anywheretailscaledruns in userspace mode. - The in-shell
sshwrapper appliesBatchMode yesto tailnet hosts (avoids auth-method hangs against Tailscale SSH). Override withssh -o BatchMode=no <node>if a tailnet host needs a password. If your tailnet's Tailscale SSH policy requires a browser "check", the first connection prints alogin.tailscale.com/a/…URL to approve in a browser — that's a tailnet ACL setting (sshcheckPeriod), notts.
MIT