← Back to Wiki
Networking / Automation

Control Chrome on Your Desktop From a Headless Server Over SSH

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.

Share on X

First, find the actual channel — and don't assume it's TCP

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.

Check which process owns it. If the socket's filename contains a pid, don't assume it's the pid of the app session you're looking at. A common pattern is a single shared broker process that individual sessions connect to as clients — meaning the filename tracks the broker's lifetime, and changes whenever it restarts. That detail decides whether a hardcoded path will keep working tomorrow. (It won't.)

Forward the socket, not a port

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:

Verify data actually flows, not just that a file appeared. A socket file showing up on the far side proves SSH bound it — nothing more. Connect to it and confirm something answers before you go debugging the application layer:
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.

The startup-flag trap

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.

Don't probe with a one-shot/headless invocation. Testing whether a tool creates its socket by running it in a non-interactive "print" mode can produce a confident false negative — plenty of tools skip setting up interactive integrations entirely in that mode. Probe with the real interactive process.

Make it a service, not a terminal you must not close

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
A user service has no SSH agent. Use 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.

Be honest about what this hands over

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.

The fallback worth knowing

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.