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.
plan9portinstalled on both ends (9,listen1,u9fs,9pfuse)openssh-clienton the client,openssh-serveron the remoteshellhostautosshfor a self-healing tunnelfuse(orfuse3) on the client for9pfuse- Optional:
torsockand a running local Tor instance (SocksPort 9050)
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.
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 shellopens an interactive session toshelland forwards local port5640to127.0.0.1:5640on the far side. Because the forward target is127.0.0.1,u9fsnever 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:listen1binds127.0.0.1:5640on the remote side and, for each incoming connection, execsu9fs.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 thedenzukouid.
-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.
With the tunnel from step 1 still attached, mount the forwarded port:
USER=denzuko 9 9pfuse -A 10 'tcp!localhost!5640' ~/n/shellUSER=denzukosets the uname9pfusepresents to the server, matching the-u denzukoexport.-A 10sets the attach/auth timeout in seconds; raise it over a slow or high-latency link.tcp!localhost!5640is the dial string pointed at the local end of the SSH forward.~/n/shellis the mountpoint. Plan9port convention is to keep 9P mounts under~/n/<name>; create it first withmkdir -p ~/n/shell.
At this point ~/n/shell on the client is the remote working directory,
served over 9P inside the SSH tunnel.
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 0disables autossh's own monitoring port and relies on the SSHServerAlive*keepalives instead, which avoids opening an extra port for autossh's health check.-Nmeans no remote shell, just forwarding.-fbackgrounds after authentication.ExitOnForwardFailure yesmakes autossh treat a failed-Lbind 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.
Once the autossh tunnel is confirmed listening on 127.0.0.1:5640:
new9nsdrops 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.
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
shellis a.onionservice 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. Pointingtorsock sshat a clearnet hostname still hides the client's source IP from the SSH endpoint, but the traffic still exits Tor onto the clearnet before reachingshell. - DNS leakage.
torsockhandles the socket-level redirection, but double check thatsshis not resolving the hostname itself outside the wrapped call (this matters more for plainsshthan for.onionaddresses, which are not resolved via normal DNS at all). - 9P stays inside the tunnel. Only the SSH connection itself needs
torsock; the-Lforwarded port and everythinglisten1/u9fs/9pfusedo 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 nonestill applies. Theu9fs -a nonereasoning 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.