JavaEye

Where -L and -R actually resolve, and four more surprises from moving a browser off my laptop

This post describes a two-machine setup on macOS in October 2026: one laptop, one Mac mini on the same LAN, one SSH tunnel, and one Chrome instance. Forwarding behavior and Chrome’s DevTools checks have both changed recently and will change again. Test the directions on your own machines before copying the flags.

I moved my automation browser off my laptop this week. The laptop is the machine I close the lid on, carry around, and suspend without warning. The mini sits on a shelf and runs all night. If a job needs a logged-in session at 3 a.m., the mini is the machine that should hold it.

The plan was one SSH tunnel and a small launch script for Chrome. It took most of an afternoon, and the afternoon had five traps in it. All five come from the same misunderstanding: an SSH forward is easy to draw and easy to get backwards.

Trap one: the address after the port resolves on the far side

The forward I use for DevTools looks like this:

ssh -L 9333:127.0.0.1:9444 mini

-L means: listen on my laptop at port 9333, and when something connects, ask the remote host to open a connection to 127.0.0.1:9444. That 127.0.0.1 is the mini’s loopback, because the connection is opened from the mini. It reaches the mini’s own Chrome, which listens on 9444. This is what I wanted.

The forward that gives Chrome internet access is the mirror image:

ssh -R 11087:127.0.0.1:10987 mini

-R means: have the mini listen on 11087, and when something connects there, forward it back over the tunnel and open the connection from my laptop to 127.0.0.1:10987. That address is my laptop’s loopback, where Clash accepts HTTP proxy traffic. Chrome on the mini gets --proxy-server=http://127.0.0.1:11087, and every request makes a round trip: mini Chrome, mini loopback, sshd, tunnel, laptop Clash, internet.

The trap is assuming the first address belongs to “where I am” and the second to “where I am going”. Both addresses are evaluated on a side chosen by the flag, and the side is not the one you typed the command from. Reverse the two and you get a tunnel that connects, accepts traffic, and delivers it to the wrong service or to nothing. There is no error message. That is the worst property of a misdirected forward.

Trap two: sshd takes the remote port before your app can

While setting up the first version I tried writing both forwards as -R, on the theory that they were the same shape pointed in different directions. On the mini, sshd immediately bound the port. Chrome started on 9444 without complaint, but the tunnel forwarded to a port that Chrome was not on. When I moved Chrome to the port sshd had claimed, Chrome failed at startup with a message about not being able to start the http server for devtools.

Two symptoms, one cause. On the remote side, sshd is itself a process that binds ports, and it binds early. From the outside, a listener owned by sshd looks exactly like a listener owned by your application. lsof on the remote resolves the argument in one line, but only if you remember that the port might not belong to the process you are debugging.

The rule I now keep in the plist comments: forward from the machine you are sitting at with -L, forward to the machine you are sitting at with -R, and never use -R for traffic that starts and ends on the remote side.

If you lock the tunnel key down, there is one more place where names must match exactly. PermitOpen and PermitListen compare the requested address literally: OpenSSH does no pattern matching and no address lookup on the names you supply. A key restricted to permitlisten="127.0.0.1:11087" refuses a command that writes -R 11087:localhost:10987 with administratively prohibited, even though both names point at the same loopback. Pick one spelling and use it in the ssh command, the config file, and the key.

The version I use leaves the key unable to run a shell at all:

restrict,port-forwarding,permitopen="127.0.0.1:9444",permitlisten="127.0.0.1:11087",command="/bin/false" ssh-ed25519 AAAA...

restrict removes PTY, agent forwarding, X11 and every future capability first; port-forwarding adds back only the two forwards named in the permit list, and command="/bin/false" turns any attempt to run a command into a no-op. A stolen copy of this key can open those two ports and nothing else.

Trap three: killing Chrome does not kill Chrome

The mini runs Chrome with a dedicated profile and --remote-debugging-port=9444. One morning the debugging port had no listener, so I did the obvious thing: kill the process and launch it again. A Chrome window appeared. The port was still empty.

The old process had survived SIGTERM. It still held the profile lock, and the new launch did not start a new browser. It detected the existing profile, handed the command line to the old instance, and exited. The old instance had never been started with the debugging flag, so there was nothing to connect to.

The fix is to kill the whole tree and clear the locks:

pkill -9 -f chrome-gemini
rm -f ~/chrome-gemini/Singleton*
open -na "Google Chrome" --args --user-data-dir=$HOME/chrome-gemini \
  --remote-debugging-port=9444 --proxy-server=http://127.0.0.1:11087 \
  --no-first-run --no-default-browser-check --start-maximized

SingletonLock, SingletonSocket, SingletonCookie are the artifacts that make the delegation work. Leaving any of them behind means the next launch joins a zombie instead of replacing it.

One Chrome change looks like this trap and is not. Since Chrome 136, --remote-debugging-port is ignored unless the same command line also passes a non-default --user-data-dir. An older launch script that relies on the default profile now starts a normal browser with an empty debugging port and no error. My script already used a dedicated profile, so the symptom I hit was the lock. If you are porting a script written before 2025, check both the profile flag and the locks.

This one interacts badly with the tunnel. You restart the browser, assume DevTools is back, and the tunnel is now forwarding to a closed port. A curl against 9333 returns nothing, which reads as “the tunnel is down”. The tunnel is fine. The thing at the other end of it is not.

Trap four: Chrome rejects WebSocket clients that announce an Origin

Chrome 155 refuses DevTools WebSocket connections that carry an Origin header. The error is explicit once you see it: Rejected an incoming WebSocket connection from the ... origin. Python’s websocket-client sends an Origin by default, so a script that works against every other service fails here with a 403 that looks like authentication.

The fix is one flag: suppress_origin=True. Node’s built-in WebSocket implementation sends no Origin at all, which is why the JavaScript driver never hit this.

The diagnostic signature is useful. curl http://127.0.0.1:9444/json/version returns JSON, because curl sends no Origin. The WebSocket client fails. When one client works and another does not against the same endpoint, the difference is in the request headers, not the network. I have now lost an hour to this twice, once here and once against a different service.

Trap five: the proxy exit lives on the machine that runs the proxy

mini Chrome is launched with --proxy-server=http://127.0.0.1:11087, and 11087 on the mini is the sshd listener from trap one. Nothing on the mini knows which country the traffic exits from. The exit node is a setting in Clash on the laptop, reached through the tunnel.

This matters when the far site starts refusing your region. Gemini told me it did not support my region while the tunnel was healthy and the mini was doing exactly what I configured. The fix was to switch nodes on the laptop and clear the connection pool, because long-lived connections to the old node can survive the switch and keep using the old exit. None of that touches the mini. If you sit down at the mini and try to debug the region error there, you are editing the wrong machine.

What keeps the tunnel alive, and what does not

The tunnel runs as a launchd agent with RunAtLoad and KeepAlive. Killing the ssh process brings it back in about six seconds, which I measured by killing it and watching the log. The launchd details are in the previous post.

The caveat is that KeepAlive supervises ssh and nothing else. Chrome on the mini has no supervisor. It died once while idle, and the tunnel stayed green the whole time, forwarding into a closed port. The health check needs two steps:

curl -m 5 http://127.0.0.1:9333/json/version      # end-to-end
ssh mini.local 'lsof -nP -iTCP:9444 -sTCP:LISTEN' # which half died

There is a quieter failure above that one. ExitOnForwardFailure defaults to no, so when a forward cannot be set up, ssh prints one warning to stderr and keeps the session running without the forward. The process is alive, launchctl list is green, and the port was never bound. KeepAlive will restart a dead process; it cannot restart a process that refuses to die.

Two settings turn that into something I can see:

ssh -o ExitOnForwardFailure=yes \
    -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
    -R 11087:127.0.0.1:10987 mini

and on the mini, in sshd_config:

ClientAliveInterval 15
ClientAliveCountMax 3

With ExitOnForwardFailure=yes a failed bind kills the client, and KeepAlive brings it back. With keepalives on both ends, a session whose network died is reclaimed in about 45 seconds — which is also the longest a dead session can hold the -R port and keep the replacement from binding it.

There is a nastier variant. An established TCP connection can outlive the ability to create a new one. I once had the CDP tunnel working while ssh mini returned No route to host for every new session. The tunnel process held an ESTABLISHED socket; the network refused new handshakes. So “my tunnel is up” is not evidence that I can restart the browser on the other side. Two channels, two checks.

The five traps in one table

TrapSymptomWrong assumptionFix
DirectionTunnel connects, traffic goes nowhere usefuladdresses resolve where the command is typed-L target resolves locally, -R target resolves on the remote
Port grabbedChrome or devtools fails to bindthe port is free because your app has not startedsshd binds -R ports early; check lsof on the remote
Zombie Chromewindow opens, debug port emptykill terminated the browserpkill -9 plus remove Singleton*, then relaunch
Origin headerWebSocket 403 while curl worksthe devtools endpoint is brokensuppress the Origin header (or use a client that sends none)
Proxy exitregion change has no effect on the minithe mini’s setting is what controls the exitswitch nodes where the proxy runs, and clear the old pool

One line of advice that cost me the most time: write down which side every 127.0.0.1 belongs to, in the file that launches the tunnel. I keep a comment block above the forwards now. In three weeks I will read -R 11087:127.0.0.1:10987 and get it wrong unless the file tells me not to.