Skip to content

Instantly share code, notes, and snippets.

@denzuko
Created July 8, 2026 18:57
Show Gist options
  • Select an option

  • Save denzuko/6844a363aeafd454010f90f34b476626 to your computer and use it in GitHub Desktop.

Select an option

Save denzuko/6844a363aeafd454010f90f34b476626 to your computer and use it in GitHub Desktop.
9P over SSH, with plan9port (listen1, u9fs, 9pfuse) and autossh Side note: routing the tunnel through Tor with torsock

Tunneling 9P over SSH with plan9port

This gist documents a working pattern for exporting a filesystem with u9fs, listening for 9P connections with plan9port's listen1, tunneling that connection over SSH with autossh, and mounting it locally with 9pfuse. Three unshare-based namespace aliases are included for isolating the client side mount in its own mount/PID/UTS/cgroup namespace. A closing side note covers wrapping the whole thing in torsock for a zero trust transport that does not depend on the source IP being trusted.

Prerequisites

  • plan9port installed on both ends (9, listen1, u9fs, 9pfuse)
  • openssh-client on the client, openssh-server on the remote shell host
  • autossh for a self-healing tunnel
  • fuse (or fuse3) on the client for 9pfuse
  • Optional: torsock and a running local Tor instance (SocksPort 9050)

Namespace aliases

These three aliases build a scratch namespace with unshare before doing anything filesystem-related. They are not strictly required for the tunnel itself, but they keep the mount contained and disposable.

alias newns='NS=$(mktemp -d) && unshare -rmnfpiuC -w "$NS" /usr/bin/env HOME=/ PS1="% " sh -c "mount --make-rprivate /; mount -t proc proc /proc; exec sh"; rm -rf "$NS"'
alias newnetns='NS=$(mktemp -d) && unshare -rmfpiuC -w "$NS" /usr/bin/env HOME=/ PS1="% " sh -c "mount --make-rprivate /; mount -t proc proc /proc; exec sh"; rm -rf "$NS"'
alias new9ns='NS=$(mktemp -d) && unshare -rmfpiuC -w "$NS" /usr/bin/env HOME=/ PS1="% " HOSTOWNER=denzuko sh -c "mount --make-rprivate /; mount -t proc proc /proc; mount -t 9p 127.0.0.1 / -o trans=tcp,port=5640,uname=${HOSTOWNER},version=9p2000; exec sh"'
Alias Namespaces unshared Purpose
newns mount, net, pid, IPC, UTS, cgroup General-purpose scratch shell, new root mount view
newnetns mount, pid, IPC, UTS, cgroup (net kept) Same as above but keeps the host's network namespace, needed because the 9P tcp connection has to reach the SSH-forwarded loopback port
new9ns mount, pid, IPC, UTS, cgroup (net kept) Boots straight into a 9P root, mounting 127.0.0.1:5640 as / with uname=denzuko, version=9p2000

A subtlety worth flagging: new9ns mounts / from the 9P server on entry, so the SSH -L forward and the listen1/u9fs export on the far end must already be live before new9ns runs, or the mount step fails. newnetns is the right shell to open the tunnel from, since it retains routing to the forwarded loopback port; new9ns is the shell to boot once the tunnel is confirmed up.

1. Export the filesystem on the remote host

On the shell host, u9fs speaks 9P against a directory, and listen1 accepts the TCP connection and hands it to u9fs per-connection:

ssh -t -L 5640:127.0.0.1:5640 shell "/bin/bash -l -c '9 listen1 tcp!127.0.0.1!5640 u9fs -a none -u denzuko \$(pwd)'"

Breaking that down:

  • ssh -t -L 5640:127.0.0.1:5640 shell opens an interactive session to shell and forwards local port 5640 to 127.0.0.1:5640 on the far side. Because the forward target is 127.0.0.1, u9fs never has to bind anything reachable off-host; the only thing exposed is the SSH session itself.
  • 9 listen1 tcp!127.0.0.1!5640 u9fs -a none -u denzuko $(pwd) is the plan9port dial string form: listen1 binds 127.0.0.1:5640 on the remote side and, for each incoming connection, execs u9fs.
  • u9fs -a none -u denzuko $(pwd) disables 9P-level authentication (-a none, since SSH is already doing the authenticating and encrypting) and exports the remote's current working directory under the denzuko uid.

-a none is only safe because the transport is already an authenticated, encrypted SSH channel. Do not run u9fs -a none on a socket that is reachable by anything other than the loopback interface.

2. Mount it on the client

With the tunnel from step 1 still attached, mount the forwarded port:

USER=denzuko 9 9pfuse -A 10 'tcp!localhost!5640' ~/n/shell
  • USER=denzuko sets the uname 9pfuse presents to the server, matching the -u denzuko export.
  • -A 10 sets the attach/auth timeout in seconds; raise it over a slow or high-latency link.
  • tcp!localhost!5640 is the dial string pointed at the local end of the SSH forward.
  • ~/n/shell is the mountpoint. Plan9port convention is to keep 9P mounts under ~/n/<name>; create it first with mkdir -p ~/n/shell.

At this point ~/n/shell on the client is the remote working directory, served over 9P inside the SSH tunnel.

3. Make the tunnel persistent with autossh

The manual ssh -t -L ... above dies with the terminal. For a tunnel that survives network drops and keeps listen1/u9fs running, wrap it in autossh:

autossh -M 0 -N -f \
  -o "ServerAliveInterval 15" \
  -o "ServerAliveCountMax 3" \
  -o "ExitOnForwardFailure yes" \
  -L 5640:127.0.0.1:5640 \
  shell "/bin/bash -l -c '9 listen1 tcp!127.0.0.1!5640 u9fs -a none -u denzuko /home/denzuko'"
  • -M 0 disables autossh's own monitoring port and relies on the SSH ServerAlive* keepalives instead, which avoids opening an extra port for autossh's health check.
  • -N means no remote shell, just forwarding.
  • -f backgrounds after authentication.
  • ExitOnForwardFailure yes makes autossh treat a failed -L bind as a restart condition rather than silently continuing without the forward.

Note the remote command uses a fixed path (/home/denzuko) rather than $(pwd), since a non-interactive, backgrounded session has no shell working directory to inherit.

4. Bring up the client namespace

Once the autossh tunnel is confirmed listening on 127.0.0.1:5640:

new9ns

drops into a fresh namespace whose root is the 9P export itself, rather than a FUSE mount at ~/n/shell. Use whichever of steps 2 or 4 fits the task: 9pfuse for a mount visible in an existing shell, new9ns for a disposable namespace that is the export.


Side note: sending the tunnel over Tor with torsock

For a zero trust posture, where the SSH endpoint's reachability should not depend on the client's source IP being recognized or allow-listed, wrap the SSH connection in torsock so it dials out through a local Tor SocksPort instead of directly:

torsock ssh -t -L 5640:127.0.0.1:5640 shell \
  "/bin/bash -l -c '9 listen1 tcp!127.0.0.1!5640 u9fs -a none -u denzuko \$(pwd)'"

or for the persistent form:

torsock autossh -M 0 -N -f \
  -o "ServerAliveInterval 15" \
  -o "ServerAliveCountMax 3" \
  -o "ExitOnForwardFailure yes" \
  -L 5640:127.0.0.1:5640 \
  shell "/bin/bash -l -c '9 listen1 tcp!127.0.0.1!5640 u9fs -a none -u denzuko /home/denzuko'"

torsock is an LD_PRELOAD wrapper that intercepts the process's socket calls and redirects outbound TCP connections through Tor's local SocksPort, so no change to the ssh or autossh invocation itself is needed beyond prefixing the command.

A few points worth being deliberate about:

  • Endpoint identity. If shell is a .onion service rather than a clearnet host reached through an exit relay, name resolution and connection setup stay inside the Tor network end to end, which is the stronger zero trust property. Pointing torsock ssh at a clearnet hostname still hides the client's source IP from the SSH endpoint, but the traffic still exits Tor onto the clearnet before reaching shell.
  • DNS leakage. torsock handles the socket-level redirection, but double check that ssh is not resolving the hostname itself outside the wrapped call (this matters more for plain ssh than for .onion addresses, which are not resolved via normal DNS at all).
  • 9P stays inside the tunnel. Only the SSH connection itself needs torsock; the -L forwarded port and everything listen1/u9fs/ 9pfuse do afterward ride inside that already-tunneled, already-Tor'd channel, so no separate torification of the 9P traffic is needed or possible in any meaningful sense, since it never touches a socket of its own.
  • Latency. 9P is chatty for small files (many round trips per attach/ walk/read cycle). Layered on top of Tor's own multi-hop latency, this is noticeably slower than a direct SSH tunnel. Fine for a shell tree or config export; not a good fit for anything large or read-heavy.
  • -a none still applies. The u9fs -a none reasoning from step 1 holds here too: it is safe only because the outer transport (SSH, itself now carried over Tor) is doing the authentication. Nothing about routing through Tor changes that requirement.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment