Browser-automation tooling generally assumes the browser and the thing driving it live on the
same machine — it talks to a local browser extension over a socket in /tmp, and that's the end
of the design. Move the driving process to a headless server and browser control simply vanishes. This is
the general trick for getting it back: SSH can forward a UNIX domain socket, not just a TCP
port, so the desktop's socket can be made to appear on the server at the same path.
Before anything can be forwarded, you have to know what you're forwarding. The instinct is to look for a
listening port, and for a lot of local integrations that instinct is simply wrong — plenty of them use a
UNIX socket in /tmp or $XDG_RUNTIME_DIR instead, which is invisible to the usual
commands:
# TCP listeners — will show NOTHING for a socket-based integration
ss -lnt
# UNIX domain sockets — this is where local IPC actually lives
ss -lxp | grep -i <toolname>
Run the second one while the integration is connected and working. What you're looking for is a
LISTEN entry with a path and an owning process — that tells you both the socket to forward and,
just as importantly, which side listens. The listener is the server; the other side dials in. Get
that backwards and you'll forward in the wrong direction and get nowhere.
OpenSSH's -L and -R both accept socket paths in place of ports. Which one you want
depends on where the listener is and which machine can SSH to which. In the common homelab case the desktop
has no SSH server running, so the desktop must initiate — that's -R, publishing its local socket
onto the remote box:
# ON THE DESKTOP, with the browser + extension running
SOCK=$(ls /tmp/<tool-bridge-dir>/*.sock | head -1)
ssh -N -o ExitOnForwardFailure=yes -R "$SOCK:$SOCK" user@headless-box
Forwarding it to the identical path on the far side is the point — the remote process is going to look for that exact filename, and it has no idea the socket is a tunnel. Once connected, tools on the remote side typically report the browser as local, because from their perspective it is.
Three requirements on the receiving box, all easy to miss:
AllowStreamLocalForwarding yes in sshd_config — it's the default, but confirm
with sudo sshd -T | grep streamlocal rather than assuming.0700). SSH will create the
socket, not the directory holding it.StreamLocalBindUnlink defaults to no, which means a leftover socket
file from a dropped connection blocks the next one from binding. Delete the file before each
connect — this is the single most likely reason a tunnel that worked once refuses to come back.python3 - <<'EOF'
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.settimeout(4)
s.connect("/tmp/<tool-bridge-dir>/<name>.sock"); print("connect: OK")
s.sendall(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
print(s.recv(256) or "clean EOF - reachable")
EOF
A clean connect followed by the far side closing the connection is a good sign: something real accepted it
and rejected your protocol guess. A refused connection means the tunnel, not the app, is broken.
Worth knowing before you spend an hour on it: many tools decide at process startup whether an integration exists, and register it then. If the socket only becomes reachable afterwards, a runtime "reconnect" command in the already-running process may never pick it up — it'll keep insisting nothing is installed no matter how healthy the tunnel is.
If a status screen reports the integration as disabled or undetected while you can prove the socket is live, restart the process with whatever flag enables that integration rather than toggling it from inside. Look for a resume/continue flag so you don't lose your working state in the process.
A hand-run ssh -N dies with its terminal, and a hardcoded socket path goes stale the moment the
broker restarts. A small supervisor script fixes both: discover the socket at start, poll for the filename
changing, and rebuild the tunnel when it does.
while :; do
SOCK=""
while [ -z "$SOCK" ]; do
SOCK=$(ls -1t "$DIR"/*.sock 2>/dev/null | head -1)
[ -z "$SOCK" ] && sleep 5 # browser not running yet; wait, don't exit
done
# clear the stale remote socket (StreamLocalBindUnlink defaults to no)
ssh -o BatchMode=yes "$REMOTE" "mkdir -p '$DIR' && chmod 700 '$DIR' && rm -f '$SOCK'" || { sleep 5; continue; }
ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
-o BatchMode=yes -R "$SOCK:$SOCK" "$REMOTE" &
SSHPID=$!
while kill -0 "$SSHPID" 2>/dev/null; do # restart if the broker pid changes
[ "$(ls -1t "$DIR"/*.sock 2>/dev/null | head -1)" != "$SOCK" ] && { kill "$SSHPID"; break; }
sleep 5
done
wait "$SSHPID" 2>/dev/null
done
Wrap that in a user service — not a system one. The socket lives in the user's session and only exists while they're logged in with the browser running:
[Unit]
Description=Browser bridge tunnel to headless box
After=network-online.target
[Service]
Environment=REMOTE=user@headless-box
ExecStart=%h/.local/bin/bridge-tunnel.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
systemctl --user enable --now bridge-tunnel.service
BatchMode=yes with a passphrase-less key
dedicated to this, or point the unit at your agent explicitly with
Environment=SSH_AUTH_SOCK=.... A passphrase-protected key that "works when I test it by hand"
will fail silently under systemd, because your interactive shell had an agent and the service doesn't.
Skip loginctl enable-linger here, and skip it deliberately. Lingering keeps user services
running after logout, which sounds like robustness but is wrong for this: the thing being forwarded doesn't
exist when you're logged out, so all lingering buys you is a service retrying into nothing.
This gives a remote machine control of the browser holding every session you're logged into — cloud provider consoles, your router or firewall UI, hypervisor, source control, email. There's no sandbox in the middle; an action taken through that tunnel carries exactly the authority you have sitting at the desk.
That may be a perfectly reasonable trade for a box you already trust with SSH keys to your infrastructure.
It's still worth making the decision on purpose rather than by default: enable the service if you want the
capability standing, or leave it disabled and systemctl --user start it only for the sessions
that need it. A separate browser profile for automation is the stronger option if you want the capability
without exposing your day-to-day logins.
If the extension-based path can't be made to work, browsers themselves expose a remote-debugging protocol —
launch Chrome with --remote-debugging-port against a dedicated profile directory, reverse-forward
that TCP port, and drive it with any CDP client (Playwright's connectOverCDP, for one). It needs
no extension at all. The trade is that you're scripting the browser directly rather than using whatever
higher-level tool surface the extension provides.