mirrorball
Docs

mirrorball

Instant SSH port forwarding that tells you the truth about whether the tunnel actually works.

$ mirb 10.0.0.7 3000 3010 8080

Three ports forwarded from 10.0.0.7, with no subcommand, no flags, and no -L syntax — and, more importantly, with an honest answer to the question ssh never answers: is this tunnel actually carrying anything?

mirrorball (mirb) does not implement SSH. It runs the ssh on your PATH and watches what happens, so your ssh_config, your agent, your keys, your ProxyJump chain and your hardware token all work exactly as they already do. If ssh myhost works, mirb myhost 3000 works.


The problem

Here is the incantation, the one everybody keeps in a scratch file and edits by hand:

$ ssh -N -L 3000:localhost:3000 -L 3010:localhost:3010 -L 8080:localhost:8080 10.0.0.7

It prints nothing and sits there. That silence is doing three different jobs, and there is no way to tell which one:

It is silent when the tunnel works. You find out by switching to a browser and trying.

It is silent when the tunnel does not work. If one of those -L binds fails — something is already on 3000 — ssh writes a warning to stderr and nothing else: it keeps running, the session looks healthy, and the exit status is 0. A tunnel that never bound looks exactly like one that did. The fix is -o ExitOnForwardFailure=yes, which almost nobody types, because it is the fourth thing you would have to remember about a command you already resented typing.

It is silent about the far end. ssh accepts connections on the local port before it asks the remote sshd to open a channel. So the socket is up whether or not anything is listening over there, and curl localhost:3000 failing tells you nothing about which half is broken. The twenty minutes that follow usually go into debugging an ssh config that was never wrong, for a service somebody forgot to start.

mirrorball closes all three. It pre-flights every local port before ssh sees a packet, so a busy port is an instant error naming the process that holds it. It always passes ExitOnForwardFailure=yes, so a failed bind is a failed session. And it opens a real connection through each finished tunnel, so it can distinguish:

readyThe tunnel is up and something answered on the far end.
refusedThe tunnel is perfect. Nothing is listening on the far end.
failedThe forward never came up at all.

That middle state is the product. It is the difference between "debug your ssh setup" and "start your server", and no amount of staring at ssh -N will give it to you.


Sixty seconds

Install it:

curl -fsSL https://mirb.dev/install.sh | sh

Forward a port:

$ mirb 10.0.0.7 3000
  mirb ⇄ 10.0.0.7                                      up 4s

   ●  localhost:3000   ←  localhost:3000               ready

   ssh 22030 · reconnects 0 · ^C to stop

Forward several, in whatever shape you need:

$ mirb 10.0.0.7 3000 8080:80 5432:db.internal:5432

3000 is the same port at both ends. 8080:80 maps a privileged remote port to an unprivileged local one. 5432:db.internal:5432 reaches a third host as seen from 10.0.0.7 — the database you cannot ssh into directly. Ranges work too: 3000-3005.

Leave it running after your shell is gone:

$ mirb --background --name api 10.0.0.7 3000 8080:80
  lxa2xk  10.0.0.7  ready
    ● localhost:3000 ← localhost:3000  ready
    ● localhost:8080 ← localhost:80  ready
  stop it with: mirb stop lxa2xk

mirrorball does not return until the detached supervisor has proved the tunnel works — a session id handed back before the ports are listening is a trap, not a feature. The supervisor reconnects through lid closes, VPN flaps and network changes, keeping the same id and the same local ports.

Then manage what is up:

$ mirb ls
  ID      NAME  HOST      FORWARDS                UP  STATUS
  lxa2xk  api   10.0.0.7  3000 ← 3000, 8080 ← 80  1s  ● ready
$ mirb logs -f api
$ mirb stop lxa2xk

Name the things you forward every day:

# ~/.config/mirb/config.toml
[profiles.api]
host = "deploy@10.0.0.7"
ports = [3000, "8080:80"]
$ mirb api
$ mirb api 9229     # the profile, plus a debugger port

And when a program is reading instead of a person, mirrorball notices and switches to JSON on its own — NDJSON while a tunnel is live, a single {ok, data, meta} envelope for one-shot commands. Progress and errors stay on stderr in every mode, so redirecting stdout leaves you a clean, parseable file.

$ mirb --json 10.0.0.7 3000
{"event":"session.start","ts":"2026-08-19T08:44:09.882Z","id":"mb_hnjvhl5gzqfhc","target":{"host":"10.0.0.7","raw":"10.0.0.7"},"forwards":[{"localPort":3000,"bindAddress":"127.0.0.1","remoteHost":"localhost","remotePort":3000,"source":"3000"}]}
{"event":"forward.bound","ts":"2026-08-19T08:44:09.939Z","localPort":3000}
{"event":"forward.ready","ts":"2026-08-19T08:44:10.692Z","localPort":3000}
{"event":"session.ready","ts":"2026-08-19T08:44:10.693Z","id":"mb_hnjvhl5gzqfhc","ready":1,"total":1}

Where to go next

Getting started

  • Installation — every channel, plus verifying, upgrading and uninstalling.
  • Quick start — a working tunnel in thirty seconds, then a few steps past it.
  • Concepts — target, forward, session, profile, and the readiness model in full.

Guides

Reference

Explanation

  • How it works — the exact ssh command mirrorball builds, and why each flag is there.
  • Architecture — the module layers and the state on disk.
  • Design decisions — the non-obvious calls, and what each one cost.

Contributing


mirrorball is MIT licensed. Source, issues and releases: github.com/heysanil/mirrorball-cli.

Last updated on

On this page