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:

1
2
3
4
5
6
7
8
/var/lib/iroh-ssh-chroot/
├── etc/
   ├── passwd             bind-mounted from host
   ├── resolv.conf        bind-mounted from host
   ├── hosts              bind-mounted from host
   └── ssl/certs/         bind-mounted from host
└── usr/local/bin/
    └── iroh-ssh           bind-mounted from host

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():

1
2
3
4
5
6
BindReadOnlyPaths=/usr/local/bin/iroh-ssh:/usr/local/bin/iroh-ssh  # host path : chroot path
BindReadOnlyPaths=/etc/passwd:/etc/passwd
BindReadOnlyPaths=/etc/resolv.conf:/etc/resolv.conf
BindReadOnlyPaths=/etc/hosts:/etc/hosts
BindReadOnlyPaths=/etc/ssl/certs:/etc/ssl/certs
BindPaths=/etc/iroh-ssh:/etc/iroh-ssh                               # writable

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:

  1. Files owned by UID 993 on the host appear as root-owned inside the chroot — the key directory works without changing permissions.
  2. 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# /etc/systemd/system/iroh-ssh-server.service
[Unit]
Description=SSH over Iroh (headless chrooted instance)
After=network.target

[Service]
Type=simple
User=irohssh
Group=irohssh
WorkingDirectory=/etc/iroh-ssh
RootDirectory=/var/lib/iroh-ssh-chroot
PrivateUsers=yes

BindReadOnlyPaths=/usr/local/bin/iroh-ssh:/usr/local/bin/iroh-ssh
BindReadOnlyPaths=/etc/passwd:/etc/passwd
BindReadOnlyPaths=/etc/resolv.conf:/etc/resolv.conf
BindReadOnlyPaths=/etc/hosts:/etc/hosts
BindReadOnlyPaths=/etc/ssl/certs:/etc/ssl/certs

BindPaths=/etc/iroh-ssh:/etc/iroh-ssh

ExecStart=/usr/local/bin/iroh-ssh server -p --key-dir /etc/iroh-ssh --ssh-port 22
Restart=on-failure
RestartSec=3s

PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/etc/iroh-ssh
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
CapabilityBoundingSet=
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes
RemoveIPC=yes
PrivateDevices=yes
MemoryDenyWriteExecute=yes
UMask=0077
SystemCallFilter=@system-service
SystemCallFilter=~@resources @privileged
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK

[Install]
WantedBy=multi-user.target

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:

1
2
sudo systemctl status iroh-ssh-server.service
→ Main PID: 1719352 (code=exited, status=203/EXEC)

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:

1
2
3
4
Jun 14 22:54:07 server iroh-ssh[8413]: Connect to this this machine:
Jun 14 22:54:07 server iroh-ssh[8413]:   iroh-ssh user@<node-id>
Jun 14 22:54:07 server iroh-ssh[8413]: Waiting for incoming connections...
                                                         ← nothing happens

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:

1
ERROR relay: failed to resolve relay node: dns error: Temporary failure in name resolution

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

1
2
sudo systemd-analyze security iroh-ssh-server.service
# → Overall exposure level: 1.1 OK 🙂

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)