porthole opens URLs from remote machines in your local browser. A tool on a
NixOS remote calls xdg-open http://localhost:8888. The URL arrives in the
default browser on your Mac.
If the URL points at a loopback port on the remote, porthole first creates an SSH tunnel for that port. The browser then reaches the remote service through the tunnel. You do nothing.
OAuth flows work too. An authorize URL carries its loopback callback in the
query string (redirect_uri and friends), and the browser is redirected
there without porthole ever seeing that URL. The daemon sniffs the callback
port out of every URL it opens and pre-tunnels it, so the identity provider's
redirect back to localhost lands on a live tunnel.
- One binary serves every role: client, daemon, and admin tool.
- The remote runs no daemon. The client writes one JSON line to a Unix socket.
- The daemon owns a persistent SSH master per host (via launchd): it carries the
RemoteForwardfor request delivery and serves as the control socket for tunnel forwards. When the connection dies (laptop sleep, network loss), the supervisor auto-reconnects with backoff. - The daemon on macOS validates the URL, manages tunnels, and calls
open(1). - Tunnels ride the supervisor's multiplexed SSH connection: a tunnel is one
ssh -O forwardround trip on the master's control socket — no extra ssh processes, and forwards die with the connection. When the master is down (reconnecting), a spawnedssh -N -Lchild provides the forward instead. - OAuth callback tunnels don't linger. The reaper watches them with lsof and tears one down once the flow's redirect has ridden it and gone quiet (or the flow never lands at all), so a fixed callback port — AWS SSO's is fixed — is never squatted for days, blocking local tools from binding it.
- If no SSH session is up, the client spools the URL to disk. The next successful call flushes the spool, oldest first.
See ARCHITECTURE.md for the full design.
Everything is a Nix flake. Both sides come from this repository.
imports = [ inputs.porthole.homeModules.porthole-daemon ];
programs.porthole = {
enable = true;
hosts = [ "dev1" "launchpad" ]; # must match your ssh Host aliases
};The module runs the daemon under launchd. The daemon owns its SSH master
connections — you no longer need an interactive SSH session for porthole to
work. Your existing ssh, herdr --remote, and Zed sessions are unaffected
(they keep their own multiplexing).
imports = [ inputs.porthole.homeModules.porthole-remote ];
programs.porthole.enable = true;The module installs the client, registers it as the MIME handler for HTTP and
HTTPS, and sets $BROWSER. Also set this in the NixOS configuration of each
remote:
services.openssh.settings.StreamLocalBindUnlink = true;On the remote, nothing changes. Every entry point routes to the client:
xdg-open <url>, which honors$BROWSER- tools that read
$BROWSERdirectly gio open <url>through the registered MIME handler- a plain
open <url>shim for macOS-style callers porthole open <url>directly- Ctrl+click on a loopback URL in a herdr pane — the home-manager
module links a plugin manifest that routes loopback URLs to
porthole openinstead of a dead browser tab. This is automatic when herdr is installed; without herdr, every other entry point still works.
The clipboard bridges too: cat foo.txt | pbcopy puts stdin on the Mac's
clipboard. Attached to a terminal this speaks OSC 52 directly and needs
nothing else running. Detached, it goes through the daemon. The binary
also answers to lemonade — the one clipboard provider Neovim probes
for with no display-server gate — so on remotes every Neovim yanks to
the Mac with zero editor configuration. The bridge is one-way:
lemonade paste fails loudly rather than paste nothing silently.
Clipboard writes are never spooled — a late paste would clobber the
current clipboard with stale content.
Note: some tools refuse to open a browser when no display is present.
xdg-open consults the MIME database only under has_display, and gcloud
(check_browser.py) skips the browser entirely unless DISPLAY,
WAYLAND_DISPLAY, or MIR_SOCKET is set — it never reads $BROWSER.
The module sets DISPLAY=:0 to pass these gates; the browser they reach
is porthole. Your shell must source home-manager's session variables file
for any of this to take effect.
On the Mac, porthole status shows the listening sockets and live tunnels.
ph is a short symlink for the impatient.
nix develop enters the dev shell. The system tests are shell rigs, not
cargo tests:
scripts/smoke.sh— client, daemon, junk flood, stale socket reclaimscripts/smoke-tunnel.sh— tunnel policy, status, graceful shutdownscripts/smoke-e2e.sh— the full system over real SSH on one box
The remote build ships without the daemon and never compiles tokio:
nix build .#porthole-remote
The daemon cargo feature gates the daemon, status, and tunnel subcommands
plus the tokio dependency.
MIT. See LICENSE.