← Back to Wiki
Networking / Automation

Control Chrome on Your Desktop From a Headless Server Over SSH

Move your automation to a headless server and browser control disappears. The tool talks to a browser extension over a socket in /tmp. No socket, no browser. SSH can forward a UNIX domain socket, not just a TCP port. That is the whole trick. Your desktop's socket shows up on the server at the same path.

Share on X

First find the channel, and do not assume it is TCP

You cannot forward what you have not found. The instinct is to look for a listening port. That instinct is wrong here. Local integrations use a UNIX socket in /tmp or $XDG_RUNTIME_DIR instead, and the usual commands never show it:

# TCP listeners. Shows 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. You want a LISTEN entry with a path and an owning process. That gives you the socket to forward. It also tells you which side listens. The listener is the server. The other side dials in. Get that backwards and you forward the wrong direction.

BE WARNED: a pid in the socket filename is not always the pid of your app session. The common pattern is one shared broker process, with individual sessions connecting to it as clients. So the filename tracks the broker's lifetime. It changes every time the broker restarts. A hardcoded path will break.

Forward the socket, not a port

OpenSSH's -L and -R both take socket paths in place of ports. Pick based on where the listener sits and which machine can SSH to which. Most homelab desktops run no SSH server. So the desktop initiates. That is -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

Forward it to the identical path on the far side. The remote process looks for that exact filename. It has no idea the socket is a tunnel. Tools on the remote side then report the browser as local, because from where they sit it is.

Three requirements on the receiving box. All easy to miss:

BE WARNED: a socket file on the far side only 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 is good. Something real accepted it and rejected your protocol guess. A refused connection means the tunnel is broken, not the app.

The startup-flag trap

Many tools decide at process startup whether an integration exists, and register it then. Make the socket reachable after that and a runtime "reconnect" command never picks it up. The running process keeps insisting nothing is installed. Your tunnel can be perfectly healthy the whole time.

So if a status screen calls the integration disabled while you can prove the socket is live, restart the process with the flag that enables it. Do not toggle it from inside. Look for a resume flag so you keep your working state.

BE WARNED: do not probe with a one-shot or headless invocation. Run the tool in a non-interactive "print" mode to see if it creates its socket and you get a confident false negative. Tools skip 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. 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. 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. It only exists while they are 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
BE WARNED: 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 with Environment=SSH_AUTH_SOCK=.... A passphrase-protected key that works when you test it by hand fails silently under systemd. Your interactive shell had an agent. The service does not.

Skip loginctl enable-linger here, and skip it on purpose. Lingering keeps user services running after logout. That sounds like robustness. It is wrong for this. The socket does not exist when you are logged out. All lingering buys you is a service retrying into nothing.

Be honest about what this hands over

This hands a remote machine the browser holding every session you are logged into. Cloud provider consoles. Your router or firewall UI. Hypervisor. Source control. Email. There is no sandbox in the middle. An action taken through that tunnel carries exactly the authority you have sitting at the desk.

That can be a fine trade for a box you already trust with SSH keys to your infrastructure. Make the decision on purpose. Enable the service if you want the capability standing. Or leave it disabled and run systemctl --user start only for the sessions that need it. A separate browser profile for automation is the stronger option. You get the capability without handing over your day to day logins.

The fallback worth knowing

If the extension path will not work, use the browser's own remote-debugging protocol. Launch Chrome with --remote-debugging-port against a dedicated profile directory. Reverse-forward that TCP port. Drive it with any CDP client, such as Playwright's connectOverCDP. It needs no extension at all. The trade is that you script the browser directly instead of using the higher-level tool surface the extension gives you.