iroh-ssh is a Rust SSH relay that tunnels SSH over iroh, a peer-to-peer overlay based on QUIC. You run a server on the target machine, and clients connect through the iroh network using just the server’s node ID — no IP address needed. The server binary is a single statically-linked executable that does one thing: negotiate a QUIC connection and forward the byte stream to the local SSH daemon on port 22.
Because it serves no files, executes no commands, and authenticates nobody (the local SSH daemon handles that), its runtime requirements are minimal. This makes it an unusually good candidate for systemd’s strictest sandboxing.
Dependencies
The binary is built with static linking, so it has no runtime library dependencies. Inside the chroot it needs only itself and five config files:
| |
Plus one writable bind mount for the key directory: /etc/iroh-ssh.
How systemd manages the chroot
The traditional chroot workflow is a shell script: create directories, copy or bind-mount files, invoke chroot /path command, clean up. Systemd replaces the entire ceremony with declarative directives that execute at process start — before a single instruction of the daemon runs.
RootDirectory
RootDirectory=/var/lib/iroh-ssh-chroot is the entry point. Before starting the process, systemd opens a handle to the directory, creates a private mount namespace, and calls pivot_root() into it. The process inherits a filesystem tree where that directory is /. It never calls chroot() itself, so the empty CapabilityBoundingSet= has no conflict — the capability is never needed.
Once the root is pivoted, only files inside that directory are visible. To expose host paths, you declare bind mounts. Systemd applies them inside the same mount namespace, after the pivot but before execve():
| |
Each line is a real kernel MS_BIND mount in a private namespace that only the unit can see. No mount --bind in the execution path, no cleanup needed on stop.
Interaction with PrivateUsers=yes
PrivateUsers=yes wraps the mount namespace in a user namespace where irohssh (UID 993 on the host) appears as root (UID 0) inside. Two consequences:
- Files owned by UID 993 on the host appear as root-owned inside the chroot — the key directory works without changing permissions.
- The process sees itself as root, but with
CapabilityBoundingSet=empty, there are zero capabilities mapped into the namespace.
Interaction with ProtectSystem=strict
ProtectSystem=strict makes the root filesystem read-only on top of the chroot. This means the bind-mounted binary gets two layers of protection: the chroot hides the host’s copy, and ProtectSystem=strict makes the local mount read-only. The only writable path is the one explicitly listed in ReadWritePaths=/etc/iroh-ssh.
The surprising ergonomics
The entire sandbox — namespace creation, root pivot, bind mounts, capability drops, seccomp filters — is set up in one unit file, before the daemon starts. You never need a shell, mount, chroot, ip netns, or cleanup script. The setup is declarative, and it is undoable: stopping the unit tears down every namespace and mount.
The unit
| |
The key restrictions: chroot, user namespace, zero capabilities, seccomp (@system-service), no new privileges, no device access.
What breaks when you forget a bind mount
The failures are distinct and mostly silent — systemd starts the process, the process lacks something, and the behaviour depends on what is missing.
Missing libs on a dynamically-linked binary
The first attempt used the glibc-linked binary from a package-manager install. The chroot had no /lib or /usr/lib. Systemd starts the process, the kernel tries to execve(), finds no ld-linux-aarch64.so.1 — and fails immediately:
| |
Status 203 is systemd’s wrapper for a failed execve(). The journal shows nothing else: no core dump, no log line from the binary (it never started), just the exit code. The process restarts in a 3-second loop, each time hitting the same wall.
The fix: either bind-mount /lib and /usr/lib into the chroot, or rebuild with static linking. After building a static binary (via -C target-feature=+crt-static), the lib bind mounts are gone.
Missing /etc/ssl/certs
The process starts normally and prints its connection string. But when a client connects, the TLS handshake with the iroh relay fails because the CA certificate bundle is not available:
| |
On the debug side, rustls logs certificate verification errors. The server appears to run but never completes a relay handshake. Remote connections time out.
Missing /etc/resolv.conf
If the chroot lacks a resolv.conf, DNS resolution for the iroh relay node ID fails at startup. The relay address is discovered through the iroh DHT, which requires DNS for the bootstrap nodes. Without it:
| |
The process may still listen for direct connections, but cannot discover the relay.
Missing /etc/passwd
Inside the user namespace the process sees itself as UID 0. But getpwuid_r() in libc needs a passwd file to map that UID to a username. Without a bind-mounted /etc/passwd, the lookup returns ENOENT and the connection string prints user@node-id instead of irohssh@node-id. The tunnel still works — forwarding does not depend on username resolution — but diagnostics look wrong.
Why OpenSSH cannot live here
An OpenSSH sshd inside the same chroot would need a shell, PAM libraries, a writable /run/ for privilege-separation sockets, a syslog socket, terminfo databases, SUID helpers (sftp-server), and NSS modules. Each dependency widens the attack surface. iroh-ssh bypasses this entirely because it is a pure tunnel that does not authenticate users, start shells, or serve files.
The security profile scores 1.1 OK on systemd-analyze security — the practical floor for any network-facing service that still allows AF_INET and AF_NETLINK.
Verification
| |
Take
Systemd sandboxing is usually toothless because daemons need too many exceptions. iroh-ssh is the exception: a static Rust binary whose chroot fits in a directory listing. If your daemon only forwards bytes between a socket and an overlay network, the sandbox should be this tight.
References
- iroh-ssh
systemd.exec(5),systemd-analyze security(1)