mirrorball
DocsGuides

Port syntax

Every port form mirrorball accepts, what each one expands to, the rejections and their messages, and how --bind and --auto-port change the local side.

Every positional argument after the host is a port spec. Each one expands into one or more forwards, and each forward becomes one -L argument on the ssh command line.

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

The grammar is a deliberately strict subset of ssh's own -L grammar. ssh allows a leading bind address ([bind:]port:host:hostport), which makes 0.0.0.0:8080:80 and 8080:db:5432 impossible to tell apart without resolving the middle field. mirrorball (mirb on the command line) refuses that ambiguity: the first field is always a local port, and the bind address lives on --bind.


The forms

FormExampleExpands to
PORT3000127.0.0.1:3000localhost:3000
LOCAL:REMOTE8080:80127.0.0.1:8080localhost:80
LOCAL:HOST:REMOTE8080:db.internal:5432127.0.0.1:8080db.internal:5432
START-END3000-3005six forwards, same port on both sides
LSTART-LEND:RSTART-REND8000-8005:9000-9005six forwards, zipped in order
LSTART-LEND:HOST:RSTART-REND8000-8002:db.internal:9000-9002three forwards to a third host
IPv6 middle field8080:[::1]:5432127.0.0.1:8080[::1]:5432

localhost in the "expands to" column means localhost as the ssh host sees it — the machine you connected to, not yours. That is the default whenever you do not name a middle field, and it is the overwhelmingly common case: the service lives on the box you SSH'd into.

A bare port

$ mirb 10.0.0.7 3000

One forward: bind 127.0.0.1:3000 here, connect onward to localhost:3000 there. A bare port means both sides, so you never have to write 3000:3000.

Remapping the port

$ mirb 10.0.0.7 8080:80

The remote nginx is on 80, which you cannot bind locally without root (see privileged ports). 8080:80 puts it on http://localhost:8080.

Reaching a third host

The middle field is resolved by the remote sshd, not by you. This is how you reach a database that only has a private address:

$ mirb bastion.example.com 5432:db.internal:5432

db.internal never has to resolve on your machine. It has to resolve on bastion. This field is often confused with -J, which solves a different problem; see Bastions and jump hosts for the two side by side.

Ranges

$ mirb 10.0.0.7 3000-3005

Six forwards: 3000→3000, 3001→3001, … 3005→3005. A one-port range (3000-3000) is legal and means exactly that port.

Paired ranges are zipped in order, element by element:

$ mirb 10.0.0.7 8000-8005:9000-9005

gives 8000→9000, 8001→9001, … 8005→9005. Both sides must be the same length, and a middle field is allowed: 8000-8002:db.internal:9000-9002.

Several specs at once

Specs are processed in the order you typed them, and the resulting -L arguments keep that order. Verified output for a three-spec invocation:

$ mirb example.test '34900:[::1]:5432' 34901:db.internal:5432 34902
ssh -N -T \
  -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
  -o ConnectTimeout=10 \
  -L 127.0.0.1:34900:[::1]:5432 \
  -L 127.0.0.1:34901:db.internal:5432 \
  -L 127.0.0.1:34902:localhost:34902 \
  example.test

Note that the bind address is always written explicitly into the -L spec, even when it is the default. (-o BatchMode=yes joins the list whenever nobody could answer a passphrase prompt — --background, or a stdin that is not a TTY.) See How it works for why the other flags are there.


IPv6

An IPv6 literal in the middle field must be bracketed, because the spec is colon-delimited and 8080:::1:5432 is not parseable by anything, including ssh:

$ mirb 10.0.0.7 '8080:[::1]:5432'
$ mirb 10.0.0.7 '8080:[2001:db8::8a2e:370:7334]:443'
$ mirb 10.0.0.7 '8000-8001:[fe80::1]:9000-9001'

Quote them in a shell — square brackets are glob characters in bash and zsh.

mirrorball keeps the brackets when it builds the -L argument, because that is the form OpenSSH requires. Brackets are only for IPv6 literals; a bracketed hostname is rejected rather than silently unwrapped.

InputMessage
8080:::1:5432'8080:::1:5432' has 5 colon-separated parts; expected at most 3 — hint: IPv6 literals must be bracketed: 8080:[::1]:5432.
8080:[::1:5432unbalanced '[' in '8080:[::1:5432'
8080:[::1]]:5432unbalanced ']' in '8080:[::1]]:5432'
8080:host]:5432unbalanced ']' in '8080:host]:5432'
8080:[example.com]:5432'[example.com]' in '8080:[example.com]:5432' is bracketed but is not an IPv6 address
8080:[]:5432empty bracketed host in '8080:[]:5432'
8080:[::1]x:5432malformed bracketed host '[::1]x' in '8080:[::1]x:5432'

The ssh host itself is a separate argument with its own rules — mirb '[::1]' 3000 and mirb 'user@[2001:db8::1]:2222' 3000 both work — covered in How it works.


What gets rejected

Every rejection below is a USAGE error and exits 2. See Exit codes.

Port numbers

Ports run 165535 on both sides. Port 0 is excluded deliberately: to the kernel it means "pick any free port", and mirrorball would then have nothing honest to print in the ready line.

$ mirb example.test 0
mirb: error: port 0 in '0' is out of range (1-65535)
mirb: hint: Port 0 means "any free port", which mirb cannot report back.

abc, 30 00, 3000.5, 0x1f and +3000 are all refused rather than coerced — a port that silently became 3000 would be worse than an error.

A host in the local slot gets a hint pointing at the right flag, because this is the single most common mistake the grammar produces:

$ mirb example.test localhost:8080
mirb: error: 'localhost' in 'localhost:8080' is not a port number
mirb: hint: The first field is always a port. To change the bind address use --bind.

Structure

InputMessage
' ' (blank)empty port specification
8080:'8080:' has an empty remote port
:80':80' has an empty local port
8080::80'8080::80' has an empty host field
8080:my host:80host 'my host' in '8080:my host:80' contains whitespace
8080:db:5432:6000'8080:db:5432:6000' has 4 colon-separated parts; expected at most 3

The grammar hint on all of these is the same one line: Write PORT, LOCAL:REMOTE, or LOCAL:HOST:REMOTE — e.g. 3000, 8080:80, 8080:db.internal:5432.

Whitespace around a spec is trimmed and forgiven — mirb example.test ' 3000 ' works, and the spec recorded on the forward is the trimmed 3000. Whitespace inside one is not.

Ranges

InputMessage
3005-3000range 3005-3000 in '3005-3000' ends before it starts (3005 > 3000)
3000-'3000-' in '3000-' is not a valid port range
8080:-80'-80' in '8080:-80' is not a valid port range
3000-3005-3010'3000-3005-3010' in '3000-3005-3010' is not a valid port range
8000-8005:9000-9002'8000-8005:9000-9002' pairs ranges of different sizes: 8000-8005 spans 6 ports but 9000-9002 spans 3
8000-8005:9000same, …8000-8005 spans 6 ports but 9000 spans 1

Duplicate local ports

Two specs cannot claim the same local port. This is caught at parse time rather than at bind time, because ssh's ExitOnForwardFailure would turn the second bind into an opaque failure and leave you to work out which two of your arguments collided.

$ mirb example.test 3000 3000
mirb: error: '3000' is listed twice; local port 3000 can only be bound once
mirb: hint: Drop one of them, or move it to a free local port.

$ mirb example.test 3000-3005 3002:80
mirb: error: local port 3002 is claimed by both '3000-3005' and '3002:80'
mirb: hint: Drop one of them, or move it to a free local port.

The remote side has no such rule — reaching one remote port from two local ports is a perfectly reasonable thing to want:

$ mirb bastion 5432:db-a:5432 5433:db-b:5432

The 256-port expansion cap

A single range argument may expand to at most 256 forwards.

$ mirb example.test 1-65535
mirb: error: range 1-65535 in '1-65535' spans 65535 ports; mirb forwards at most 256 at once
mirb: hint: Narrow the range, or list the ports you actually need.

Each forward becomes its own -L argument and its own listening socket, so mirb host 1-65535 is never a real request — it is a typo or a port scan. mirrorball counts the span before allocating anything, so rejecting it costs nothing.

Exactly 256 is accepted; 257 is not:

$ mirb example.test 1000-1256
mirb: error: range 1000-1256 in '1000-1256' spans 257 ports; mirb forwards at most 256 at once

The cap is global, not per range argument. mirb host 1000-1255 2000-2255 is two legal ranges totalling 512 forwards, and is refused: the limit bounds the argv and the socket count handed to ssh, and neither cares how you spelled it. The cap exists to catch a nonsense range, not to police a total; the practical ceiling on a real invocation is your file-descriptor limit and the MaxSessions of the remote sshd.


--auto-port: taking the next free local port

By default a busy local port is a hard failure, exit 4:

$ mirb example.test 34940
mirb: error: localhost:34940 is already in use by bun (pid 44318)
mirb: hint: Pass --auto-port to take the next free port, or choose another local port.

mirrorball tries to bind each local port itself before spawning ssh, which is what lets it name the process holding the port (via lsof, best-effort — a missing or slow lsof just drops the "by …" clause).

--auto-port turns that failure into an upward search for a free port:

$ mirb --background --json --auto-port example.test 34940 34941

With something already holding 34940, that produces:

You asked forYou gotRemote port
349403494134940
349413494234941

Three things to note:

  • Only the local port moves. The remote side is what you asked for, untouched.
  • The search is linear and adjacent+1, +2, … up to 100 tries. A random high port would be free more often and guessable much less; "3000 was taken, here is 3001" is the point.
  • A shifted forward still knows what you typed. The spec that produced it is kept, so mirb ls and the live display can show the request next to the result.

If the whole window is busy you get a bounded error rather than a scan to 65535:

mirb: error: no free local port between 34941 and 35040
mirb: hint: Free some ports, or name a local port explicitly.

Privileged ports

Local ports below 1024 need root, and --auto-port deliberately does not rescue them:

$ mirb example.test 80
mirb: error: localhost:80 needs root: ports below 1024 are privileged
mirb: hint: Use a local port above 1023, e.g. 8080:80.

Shifting past a privileged port would mean skipping the entire 1–1023 range and handing back a port bearing no relation to the one requested. "80 is taken, here is 1024" is not a guess anyone would make. The hint offers the shift people already do by hand: 8080:80, 8443:443.

Both port failures exit 4.


--bind: choosing the local address

--bind sets the local address for every forward in the command. There is no per-spec bind address — that is the ambiguity the grammar exists to avoid.

The default is 127.0.0.1, which is loopback: only this machine can reach the forward.

$ mirb --bind ::1 example.test 34910

Addresses mirrorball treats as loopback, and therefore accepts with no further ceremony:

Accepted as loopbackNote
(unset)the default, 127.0.0.1
localhostprefer a literal; see below
127.0.0.1127.255.255.255the whole 127.0.0.0/8 block, as a full dotted quad
::1, [::1]brackets are accepted and stripped before the local bind

Everything else — 0.0.0.0, *, ::, a LAN address like 192.168.1.10, and also near-misses like 127.1, 127.0.0.256 or 127.evil.com — counts as exposed. The test is a whitelist of loopback spellings rather than a blacklist of dangerous ones, so it fails closed: 127.evil.com is a hostname whose owner chooses where it resolves, and a prefix test would have published your forward to the internet without a word.

Binding beyond loopback publishes the forward to your network

--bind 0.0.0.0 makes every forward in the session reachable by every machine that can reach yours. A staging database, an admin panel, a dev server with no auth — all of it, to anyone on the coffee-shop Wi-Fi.

GatewayPorts does not save you. Contrary to a widespread belief it governs -L as well as -R, and an explicit 0.0.0.0 or * bypasses it entirely. Verified against OpenSSH 10.2p1 by reaching the forward from a second machine on the LAN. The bind address is the only thing standing between you and exposure.

Because of that, mirrorball refuses a non-loopback bind unless you also pass --expose:

$ mirb example.test 34700 --bind 0.0.0.0
mirb: error: --bind 0.0.0.0 would publish these forwards beyond this machine
mirb: hint: Anyone who can reach this host on the network could use the tunnel. Pass --expose to confirm.

This is an error rather than a confirmation prompt on purpose: mirrorball has to behave the same for a human and for an agent, and a prompt on a non-interactive path is worse than useless — a cancelled prompt exits 0, so a script would read success and carry on.

--expose

--expose is the acknowledgement. On its own it also implies the wildcard, so you do not have to look up an address you were going to type anyway:

$ mirb --expose example.test 34701            # binds 0.0.0.0
$ mirb --bind 0.0.0.0 --expose example.test 34701
$ mirb --bind '*' --expose example.test 34711
$ mirb --bind 192.168.13.22 --expose example.test 34720   # one interface only

All four are verified working. --bind always wins over --expose's implied wildcard, so combining them narrows the exposure rather than widening it.

Once a session is exposed, mirrorball says so continuously — the live display carries a persistent banner (exposed on … — reachable from your network) above the forwards and paints the exposed local addresses in the alert colour; the plain-text reporter used in CI and redirected output prints a warning: exposed on … line. A one-shot notice would scroll away, and the entire risk of a non-loopback bind is that it is invisible.

Bind addresses that fail

The bind address is checked against this machine before ssh is spawned, so a wrong one fails immediately instead of after authentication:

$ mirb --bind 127.0.0.2 example.test 34432
mirb: error: cannot bind 127.0.0.2:34432: EADDRNOTAVAIL
mirb: hint: Check the bind address is one this machine actually has.

An IPv6 bind address works either way — --bind ::1 and --bind '[::1]' both bind loopback. Brackets are ssh's spelling, needed to disambiguate a colon-delimited -L spec; mirrorball strips them before asking the kernel to listen and puts them back before handing the spec to ssh.

Prefer a literal address over a name. --bind localhost passes the exposure check, but the name is resolved twice by two different things — ssh resolves it when it binds, and mirrorball's pre-flight and readiness probe use 127.0.0.1 — and on a machine where localhost prefers ::1 those two can disagree, leaving a forward that is bound but never reports ready. --bind 127.0.0.1 or --bind ::1 says exactly what you mean.


See also

Last updated on

On this page