Skip to content
➜cat case-studies/vps-rescue.md

vps-rescue: SSH back into your VPS when the network gives up

CLIENT

Self-initiated (open source)

ROLE

Open-source maintainer

DURATION

2026, one afternoon

TECH_STACK

Node.js 20+TypeScriptTailscale APIcommander@napi-rs/keyringvitest

PROJECT_URL

view_site

Published

on npm

Day-one

Contributor PR

Node 20+22

CI green

MIT

License

➜

CONTEXT

If you are on Indian wifi and your Hostinger VPS stops answering SSH from home but works fine from your phone hotspot, you have hit an ISP null-route. Some upstream carrier silently drops packets to a slice of budget-VPS IP space, you do not find out until your Monday is half over, and the standard fix (tunnel through a mesh like Tailscale) is just annoying enough that nobody automates it for themselves.

➜

THE CHALLENGE

I wrote the workaround out as a Reddit comment one Monday after I had solved it for myself. The CLI version was the next obvious move, with one constraint: do not ship something that needs a defended trust model. No server. No held API keys. No SaaS dashboard. Client-side only, your token in your OS keychain, blast radius bounded to one five-minute auth window.

➜

APPROACH

A small CLI that does the diagnose-and-tunnel dance, then gets out of the way. No web service, no daemon, no operator. Tailscale does the actual tunneling. The CLI just orchestrates the steps: probe the connection, mint a 5-minute auth key, render the install one-liner, poll until the new device joins the tailnet, drop an idempotent block into your ~/.ssh/config. A wrapper, not a fork. Designed to keep working as Tailscale's API evolves without much maintenance from me.

No operator infrastructure

Nothing to host, nothing to sign in to, no server holding your API tokens. The CLI is your client. Your API token lives in your OS keychain. The blast radius of any compromise is the ephemeral five-minute auth key you minted for one provisioning.

Diagnose first, decide second

Before tunneling, the CLI probes TCP, DNS, and traceroute, then classifies the failure into one of five buckets: reachable, isp_blocked, path_blocked, host_offline, dns_failure. If your VPS is actually down, no amount of Tailscale will fix it. The tool tells you that instead of pretending.

Pluggable Transport interface

Tailscale is the day-one transport, but the architecture treats it as one of several. cloudflared, AWS SSM, Tor: each can implement the same Transport interface. The plan is to add transports as users hit the gaps, not to predict them.

Round-trip-tested shell escaping

The provisioning step generates a bash one-liner with your auth key and SSH public key embedded. That string travels through your terminal, your clipboard, the provider's browser console, and back into bash on the VPS. The escape function has unit tests that round-trip through a real `bash -c` invocation, because string templating without proof of escaping is how production scripts get owned.

vps-rescue · timeout → ssh, in 9 steps 9/9
ssh: connect to host 72.61.x.x port 22: Operation timed out
~vps-rescue ssh [email protected]
probing 72.61.x.x ...
dns ok · tcp port 22 silent · traceroute dies at hop 6
✗ diagnosis: path_blocked (upstream transit drop)
minting ephemeral tailscale auth key (ttl 5 min) ...
✓ key minted (single-use, expires in 5 min)
rendering install one-liner (auth key + ssh pubkey embedded) ...
✓ copied to clipboard. paste in provider browser console
~# (pasted into hostinger browser console)
curl -fsSL tailscale.com/install.sh | sh
tailscale up --auth-key=tskey-auth-redacted
echo "ssh-ed25519 AAAA... yousuf@mac" >> /root/.ssh/authorized_keys
installing tailscale on srv1517907 ...
joining tailnet as srv1517907 ...
ssh pubkey installed for root
polling tailscale api (every 2s) ...
✓ device joined: srv1517907 → 100.81.71.63
updating ~/.ssh/config (idempotent block: # vps-rescue:srv1517907) ...
✓ host alias added: srv1517907
~ssh srv1517907
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-124-generic)
Last login: Mon Jun 22 11:14:32 2026 from 100.x.x.x
root@srv1517907:~#
tunnel reached the box. replay the flow:
➜

OUTCOMES

Shipped end-to-end in one afternoon. Published to npm as `vps-rescue`, tagged a v0.1.0 GitHub release, set up CI on Node 20 and 22 across Ubuntu and macOS, filed eight starter issues (three good-first-issues, five help-wanted), wrote SECURITY.md and CODE_OF_CONDUCT.md, and merged a contributor PR for a Linux clipboard edge case inside half an hour of going live. Download counts and stars are not the point of this note. This is about how it was built.

➜

LEARNINGS

Built in an afternoon, paired with a coding agent in Cursor (Claude). The agent did the typing for the boring code (the bash escape function, the SSH config rewriter, the CI yaml, the issue templates) and caught two real bugs along the way: a Tailscale API validation issue and an off-by-one in the polling loop. The actual decisions stayed with me. Not putting it on a server. Overengineering the escape function so the generated bash one-liner actually survives a real shell. Shipping a wrapper instead of trying to fork Tailscale. Reviewing a stranger's PR inside half an hour and being willing to merge. Most useful tools in 2026 get built this way and pretending otherwise in a portfolio note did not feel right.

  • The boring engineering (escape functions, SSH config blocks, idempotent device detection) is where rushed tools quietly break. Ninety minutes of round-trip tests on day one beats a weekend of debugging silent failures later.
  • A small wrapper that ages well beats a bigger fork that does more. Tailscale will keep solving the hard problems; vps-rescue just needs to stay glue around them.
  • Choosing no operator infrastructure changes the kinds of features you stop considering. A SaaS version would have grown an email list, an auth model, telemetry, and a maintenance horizon I did not want.
  • A contributor PR inside the first hour is the cleanest signal that a project landed for the right audience. Reviewing it inside half an hour, with follow-up issues filed for the parts the PR did not touch, sets the contribution tempo for everything that comes after.
➜ EOF[rendered: 5 sections]