images / cm5-io-silt-gateway

cm5-io-silt-gateway

Silt Gateway on a Raspberry Pi Compute Module 5 (eMMC) on its IO Board: join a tailnet from a browser, on Tailscale or your own control server, and share this network or offer an exit node with nothing installed on the machines behind it.

What it does

  • claimClaim the board from a browser with an SSH public key; SSH is key-only from then on
  • gatewaySilt Gateway: join a tailnet and choose what this board shares, from a browser
  • tailscaletailscaled, joined at boot, optionally routing its LAN

Compute Module 5: flashing and first boot

A CM5 with eMMC is written over USB from another computer, using the Raspberry Pi rpiboot tool, which makes the module's eMMC appear as a disk.

1. Check the download. Compare its SHA-256 with the one on this page (shasum -a 256 sdcard.img on macOS, sha256sum sdcard.img on Linux).

2. Put the module in USB boot mode. On the CM5 IO Board, fit the jumper that disables eMMC boot (or hold the nRPIBOOT button), and connect the IO Board's USB-C device port to your computer.

3. Expose the eMMC. Build and run rpiboot from https://github.com/raspberrypi/usbboot (sudo ./rpiboot -d mass-storage-gadget64). The eMMC appears as a new disk.

4. Write it. As for an SD card: find the new disk (diskutil list external on macOS, lsblk on Linux), then

sudo dd if=sdcard.img of=/dev/rdiskN bs=4m && sync     # macOS
sudo dd if=sdcard.img of=/dev/sdX bs=4M conv=fsync     # Linux

5. Boot. Remove the jumper, connect Ethernet, and power the IO Board.

# claim: claim a fresh board from a browser

A board built with feature:claim boots with nobody's key on it and no way in over the network. Its claim page, on port 8022, takes your SSH public key once:

boot ─▶ S49silt-claim prints   Claim page: OK, claim this board at:
                                 http://192.168.68.140:8022/  (eth0)
open it ─▶ paste ~/.ssh/id_ed25519.pub ─▶ Claim
        ─▶ /root/.ssh/authorized_keys written, the page stops
ssh root@192.168.68.140   ← your key, and only your key
A fresh board asks for its owner's key
A fresh board asks for its owner's key
A paste that is not a usable public key is refused, with the reason
A paste that is not a usable public key is refused, with the reason
Claimed: the command to sign in, and the key's fingerprint
Claimed: the command to sign in, and the key's fingerprint
  • SSH never takes a password. dropbear runs with -s (/etc/default/dropbear), root's included, so before the claim nobody gets in over the network and after it only the key given on the page does.
  • The first visitor claims the board, as the first visitor chooses Silt Gateway's password. That is a choice for a bench or a home network.
  • The serial console still logs in. Whoever holds the board owns it, and the console is the way back for an owner who loses the key: delete /root/.ssh/authorized_keys and reboot, and the page is back.
  • Port 8022, not 80, so the page sits beside an image's own page (Silt Gateway's is on 80) without either knowing about the other.
  • Nothing is baked in. Every board flashed from one image starts the same; the key is the board's, not the image's. dev-ssh bakes a key in at build time instead. Use one or the other.

The page checks a key the way dropbear will read it (its type, and every field its blob should hold) and shows its SHA256 fingerprint, as ssh-keygen -lf ~/.ssh/id_ed25519.pub prints it, so you can see it is yours. A pasted private key is refused with a reason.

On an A/B board

An update replaces the root filesystem, and the key with it, which would open a claimed board to claiming again. The ab pack keeps root's keys on the data partition (/root/.ssh links to /data/ssh), so a board is claimed once and stays claimed through every update.

Adding it to an image

One word in the image's compose:

(compose image:pi5-tailscale feature:gateway feature:claim)

silt-claimd is standard-library Go, like silt-gatewayd: nothing to fetch at build time.

The screenshots

screens/shoot.mjs takes them from the real silt-claimd with a key made and thrown away on the spot (npm i playwright@1.48.2 && node shoot.mjs in screens/). Retake them when the page changes.

# gateway: Silt Gateway

A board that joins a tailnet and shares its network, set up from a browser. Plug it in, open http://<board>/, choose a password, and join: with an auth key, or by following a sign-in link. On Tailscale's coordination server, or on your own (Headscale), by giving its URL. Then choose what the board offers:

  • subnet routes: the networks it is on, reachable from anywhere on the tailnet with nothing installed on the machines in them;
  • the exit node: devices on the tailnet send their internet traffic out through this board;
  • other routers' routes: whether this board reaches what other gateways share.

Each route shows whether the control server has approved it yet.

First visit: choose the gateway's password
First visit: choose the gateway's password
Join a tailnet, with an auth key or a sign-in link
Join a tailnet, with an auth key or a sign-in link
On the tailnet: its name, addresses and control server
On the tailnet: its name, addresses and control server
The networks it shares, each with its approval
The networks it shares, each with its approval

How it fits

image:pi*-tailscale  ── feature:tailscale  tailscaled, its init script, CA certs,
                                           a clock, TUN and NAT in the kernel
feature:gateway      ── silt-gatewayd      this page, on :80
feature:claim        ── silt-claimd        SSH key-only; the owner's key from a page on :8022

silt-gatewayd keeps no Tailscale state. tailscaled's state file holds the node key, the control server and the routes; the page reads them with tailscale status --json and tailscale debug prefs on every request and changes them with tailscale login and tailscale set, so nothing can drift. The only thing it keeps is the owner's password, as a PBKDF2 hash in /var/lib/silt-gateway/password.

The kernel's forwarding is switched on whenever tailscaled holds a route or the exit node (and after a reboot, from what tailscaled holds), never off.

Images

imagewhat
pi5-silt-gatewayimage:pi5-tailscale + feature:gateway + feature:claim
cm5-io-silt-gatewayimage:cm5-io-tailscale + feature:gateway: a Compute Module 5 (eMMC) on its IO Board
pi3-silt-gatewayimage:pi3-tailscale + feature:gateway + feature:claim: a Raspberry Pi 3 Model B
qemu-silt-gatewayimage:qemu-aarch64 + feature:tailscale + feature:gateway + feature:claim: the same gateway, no board needed

Trying it in QEMU

Build it like any image; the output has Image, rootfs.ext4 and Buildroot's own start-qemu.sh:

# on the build machine (once: sudo apt install qemu-system-arm)
kconf build qemu-silt-gateway -o out/qemu-silt-gateway
cd out/qemu-silt-gateway/images
./start-qemu.sh --use-system-qemu -m 1024 -monitor unix:/tmp/gw-qemu.sock,server,nowait

The guest's network is QEMU's user network (eth0), which reaches the internet but takes no connections. Forward the page to the build machine from another shell, through QEMU's monitor:

echo "hostfwd_add eth0 tcp:127.0.0.1:8080-:80" | nc -NU /tmp/gw-qemu.sock
echo "hostfwd_add eth0 tcp:127.0.0.1:8022-:8022" | nc -NU /tmp/gw-qemu.sock   # claim page
echo "hostfwd_add eth0 tcp:127.0.0.1:2222-:22" | nc -NU /tmp/gw-qemu.sock     # ssh, once claimed

and from your own machine, ssh -L 8080:127.0.0.1:8080 to the build machine and open http://localhost:8080/. Joining works as on a board; routes to the QEMU user network (10.0.2.0/24) are suggested, which is enough to see approval work.

Security

  • The first visitor chooses the password, as with most home appliances: set it before putting the board on a network you don't trust. The same holds for the SSH key on the claim page (feature:claim, see claim/README.md).
  • Sessions are an HttpOnly, SameSite=Strict cookie; every change is a JSON request, which a page on another site can't send with it.
  • Five wrong passwords in a minute lock sign-in for the rest of that minute.
  • To reset a forgotten password, delete /var/lib/silt-gateway/password on the board (serial console) and reload the page.

Name

"Silt Gateway" is a working name. Tailscale is a trademark of Tailscale Inc.; this product uses the open-source Tailscale client and is not affiliated with it. Check its terms before selling a device built on it.

Development

cd br2-external/package/silt-gateway/src
go test ./...
go run ./cmd/silt-gatewayd -http 127.0.0.1:8080 -data /tmp/gw \
  -tailscale "$(command -v tailscale)" -socket /var/run/tailscale/tailscaled.sock

The tests read internal/tailnet/testdata, captured from tailscale 1.78.1 (the version Buildroot 2025.02.16 ships).

The screenshots

screens/shoot.mjs takes them from the real page, served by silt-gateway-demo over a stand-in for tailscaled on an example tailnet (npm i playwright@1.48.2 && node shoot.mjs in screens/). The demo is not part of the image: the package builds ./cmd/silt-gatewayd only.

On the board

Every file below is checked in the built image before it is released.

  • /etc/default/dropbear
  • /etc/default/tailscale
  • /etc/init.d/S49silt-claim
  • /etc/init.d/S50dropbear
  • /etc/init.d/S95tailscale
  • /etc/init.d/S96silt-gateway
  • /usr/bin/silt-claimd
  • /usr/bin/silt-gatewayd
  • /usr/bin/tailscale
  • /usr/sbin/tailscaled

Kernel

Stated by this image and checked in the kernel that was built.

CONFIG_EXT4_FS=y
CONFIG_NETFILTER=y
CONFIG_NFT_MASQ=m
CONFIG_NFT_NAT=m
CONFIG_NF_CONNTRACK=m
CONFIG_NF_NAT=m
CONFIG_NF_TABLES=m
CONFIG_NF_TABLES_IPV4=y
CONFIG_NF_TABLES_IPV6=y
CONFIG_TUN=m

Hardware it can use

Read from the board's own device tree.

csi-cameradevicetree:bcm2712-rpi-cm5-cm5io at /axi/pcie@1000120000/rp1/csi@110000
mmusoc:bcm2712
nvmedevicetree:bcm2712-rpi-cm5-cm5io at /axi/pcie@1000120000
sdcarddevicetree:bcm2712-rpi-cm5-cm5io at /axi/mmc@1100000
serial-consoledevicetree:bcm2712-rpi-cm5-cm5io at /soc@107c000000/serial@7d001000
wifi-sdiodevicetree:bcm2712-rpi-cm5-cm5io at /axi/mmc@1100000/wifi@1

What's in the image

Measured from the image file.

cm5-io-silt-gateway.img                                288.0 MB
├── p1 vfat                            █                32.0 MB
│   ├── /Image                         ██████████       24.7 MB
│   ├── /overlays                      ▏                 733 KB
│   ├── /bcm2712-rpi-cm5-cm5io.dtb     ▏                  77 KB
│   ├── /config.txt                    ▏                  548 B
│   └── /cmdline.txt                   ▏                   66 B
└── p2 ext4                            ████████████    256.0 MB
    ├── /usr                           ██               51.2 MB
    │   ├── /usr/bin                   ████████████     47.5 MB
    │   ├── /usr/lib                   ▏                 2.8 MB
    │   └── /usr/lib64                 ▏                 2.8 MB
    ├── /lib                           █                26.7 MB
    ├── /lib64                         █                26.7 MB
    ├── /bin                           ▏                 802 KB
    ├── /linuxrc                       ▏                 802 KB
    └── /etc                           ▏                 249 KB

Check it yourself

Everything here was read from the image file, and each line has the command that shows it to you. The commands need a few Linux tools; on a Mac, run them in a container:

docker run --rm -it -v "$PWD":/w -w /w debian:bookworm sh -c 'apt-get update -qq && apt-get install -y -qq fdisk e2fsprogs mtools binutils device-tree-compiler checksec >/dev/null && exec bash'

Disk

SHA-2569f37d9f823bc929cc0acceff88318210c69cc0324be3a4e5d29eaeb4cebd575d
sha256sum cm5-io-silt-gateway.img
Partition tableMBR (dos), disk identifier 0x00000000
sfdisk -d cm5-io-silt-gateway.img | grep '^label'
Partitionsp1 vfat 32.0 MB, p2 ext4 256.0 MB
sfdisk -d cm5-io-silt-gateway.img
Partition 1vfat, 32.0 MB, 25.5 MB used
dd if=cm5-io-silt-gateway.img of=p1.img bs=512 skip=1 count=65536 status=none && mkdir -p p1 && mcopy -s -n -i p1.img '::*' p1/
Partition 2ext4, 256.0 MB, 78.9 MB used
dd if=cm5-io-silt-gateway.img of=p2.img bs=512 skip=65537 count=524288 status=none && mkdir -p p2 && debugfs -R 'rdump / p2' p2.img

Boot

Firmware settings (config.txt)kernel=Image; disable_overscan=1
grep -v -e '^#' -e '^$' p1/config.txt
Kernel6.18.54-v8-16k, built with GCC 13.4.0 (/Image)
strings p1/Image | grep -m1 'Linux version'
Kernel modules1911 modules for 6.18.54-v8-16k, the version of the kernel that boots
ls p2/lib/modules && find p2/lib/modules -name '*.ko*' | wc -l
Device trees2 device trees; the firmware picks the board's
dtc -q -I dtb -O dts p1//*.dtb | grep -m2 -E 'model|compatible'

System

Operating systemBuildroot 2025.02.16
cat p2/etc/os-release
Started at bootseedrng, syslogd, klogd, sysctl, network, chronyd, silt-claim, crond, dropbear, tailscale, silt-gateway
ls p2/etc/init.d
Consolelogin prompt on console at 0 baud
grep getty p2/etc/inittab

Login

Root passwordno password: the console logs in as root
grep '^root:' p2/etc/shadow
SSHpublic key only (dropbear -s: no password logins)
cat p2/etc/default/dropbear
Accounts with a shellroot (/bin/sh), sync (/bin/sync); the other 8 accounts have no shell
grep -v -E ':(/usr)?/s?bin/(nologin|false)$' p2/etc/passwd

Programs

ELF programs13 programs, aarch64
Position independent (PIE)9 of 13; not /usr/bin/silt-claimd, /usr/bin/silt-gatewayd, /usr/bin/tailscale, /usr/bin/tailscaled
Full RELRO9 of 13; not /usr/bin/silt-claimd, /usr/bin/silt-gatewayd, /usr/bin/tailscale, /usr/bin/tailscaled
Stack canary9 of 13; not /usr/bin/silt-claimd, /usr/bin/silt-gatewayd, /usr/bin/tailscale, /usr/bin/tailscaled
Non-executable stack (NX)all 13
for d in p2/bin p2/sbin p2/usr/bin p2/usr/sbin; do checksec --dir=$d; done
Dynamic loader/lib/ld-linux-aarch64.so.1, on the disk (asked for by 13 programs)
find p2/bin p2/sbin p2/usr/bin p2/usr/sbin -maxdepth 1 -type f -exec readelf -l {} + 2>/dev/null | grep -o 'interpreter: [^]]*' | sort | uniq -c; ls -l p2/lib/ld-linux-aarch64.so.1
Shared librariesall 7 libraries the programs need, directly or through another library, are on the disk
find p2/bin p2/sbin p2/usr/bin p2/usr/sbin -maxdepth 1 -type f -exec readelf -d {} + 2>/dev/null | sed -n 's/.*Shared library: \[\(.*\)\]/\1/p' | sort -u | while read l; do ls -d p2/lib/$l p2/usr/lib/$l p2/lib64/$l p2/usr/lib64/$l 2>/dev/null || echo "missing: $l"; done