WireGuard on a Kobo E-Reader

This post describes installing WireGuard on a Kobo e-reader running KOReader, so that all of the device's traffic is routed through a WireGuard tunnel.

Device inventory

The Kobo runs Linux on the following:

  • Kernel 4.9.56 (no in-kernel WireGuard; that requires 5.6+).
  • armv7l, hard-float.
  • glibc 2.11.1.
  • BusyBox userland. No iptables, no bash, no wg-quick.
  • /dev/net/tun exists.
  • /mnt/onboard is a vfat user partition. Unix permissions do not persist across remount, but binaries execute normally.

Because the kernel does not include WireGuard, the only viable implementation is wireguard-go (userspace) plus the wg control tool.

SSH access

KOReader ships a bundled dropbear SSH server at /mnt/onboard/.adds/koreader/dropbear. The stock Kobo openssh service is disabled by default.

Installing an authorized key at /root/.ssh/authorized_keys did not work. This build of dropbear uses relative paths for its host keys and authorized-keys file, resolved against its working directory /mnt/onboard/.adds/koreader/. The correct location is:

/mnt/onboard/.adds/koreader/settings/SSH/authorized_keys

Copying the public key there enabled key-based SSH login as root.

Note: the -n flag in this dropbear build means "disable password checking" — any password is accepted. This is a KOReader convenience, not a security feature.

Cross-compiling the binaries

Both binaries were cross-compiled on a NixOS host using nix-shell.

wireguard-go

Static Go build, no CGO:

nix-shell -p go --run '
  CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 \
    go build -trimpath -ldflags="-s -w" -o wireguard-go .
'

Output: 3.1 MB statically-linked armv7l ELF.

wg (from wireguard-tools)

The prebuilt pkgsCross.armv7l-hf-multiplatform.wireguard-tools derivation links dynamically against a glibc stored in the Nix store, which is not usable on the Kobo. Linking statically against pkgsCross.armv7l-hf-multiplatform.glibc.static works:

GLIBC_STATIC=$(nix-build --no-out-link "<nixpkgs>" \
  -A pkgsCross.armv7l-hf-multiplatform.glibc.static)

nix-shell -p pkgsCross.armv7l-hf-multiplatform.buildPackages.gcc --run "
  CC=armv7l-unknown-linux-gnueabihf-gcc \
  LDFLAGS='-static -L$GLIBC_STATIC/lib' \
    make wg
"

Output: 768 KB static armv7l binary. The linker warns that getaddrinfo needs a matching glibc at runtime; in practice, endpoint hostname resolution works on the device.

Bring-up script

wg-quick requires bash and iptables, neither of which is available on this device. A minimal replacement is written in POSIX shell using BusyBox utilities.

For a full-tunnel configuration, the standard wg-quick approach is used:

  • Leave the original default route in place.
  • Add two more-specific routes covering all of 0.0.0.0/0: 0.0.0.0/1 dev wg0 and 128.0.0.0/1 dev wg0.
  • Add a /32 pin route for the WireGuard endpoint IP via the original gateway, so packets to the endpoint do not route through the tunnel itself.

Core of the script:

ENDPOINT_IP=$(nslookup "$ENDPOINT_HOST" | awk '
  /^Name:/ { p=1; next }
  p && /^Address[ :]/ { ip=$NF; sub(":.*","",ip); print ip; exit }
')

ORIG_GW=$(ip route show default | awk '{for(i=1;i<=NF;i++) if($i=="via") print $(i+1)}')
ORIG_DEV=$(ip route show default | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}')

ip route add "$ENDPOINT_IP"/32 via "$ORIG_GW" dev "$ORIG_DEV"

wireguard-go -f "$IF" </dev/null >"$STATE/wg.log" 2>&1 &
while [ ! -S /var/run/wireguard/$IF.sock ]; do sleep 0.5; done

wg setconf "$IF" "$ETC/wg0.conf"
ip addr add 10.69.0.4/24 dev "$IF"
ip link set mtu 1420 up dev "$IF"

ip route add 0.0.0.0/1 dev "$IF"
ip route add 128.0.0.0/1 dev "$IF"

cp /etc/resolv.conf /tmp/wireguard/resolv.conf.bak
printf 'nameserver 10.69.0.1\n' > /etc/resolv.conf

A companion wg-down script reverses each step: removes routes, restores resolv.conf, deletes the interface, and terminates wireguard-go.

wireguard-go daemonization

Running wireguard-go wg0 without the foreground flag caused the process to exit with status 1 and no output. The Linux banner it prints ("kernel has first-class support…") is unconditional and not a real failure indicator.

Foreground mode (-f) worked reliably. The daemonization path in Go re-execs the binary via os.StartProcess, which fails on this device — possibly related to executing from vfat or to file-descriptor inheritance. The workaround is to run wireguard-go -f and background it from the shell:

wireguard-go -f "$IF" </dev/null >"$LOG" 2>&1 &

Persistence

Two components: a menu-driven kill switch via NickelMenu, and an auto-start watchdog launched from KOReader's start script.

NickelMenu entries

Config file at /mnt/onboard/.adds/nm/wireguard:

menu_item:main:WireGuard - Connect:cmd_spawn:quiet:/mnt/onboard/.adds/wireguard/bin/wg-up --full
menu_item:main:WireGuard - Disconnect:cmd_spawn:quiet:/mnt/onboard/.adds/wireguard/bin/wg-down
menu_item:main:WireGuard - Enable auto-start:cmd_spawn:quiet:touch /mnt/onboard/.adds/wireguard/autostart.enabled
menu_item:main:WireGuard - Disable auto-start:cmd_spawn:quiet:rm -f /mnt/onboard/.adds/wireguard/autostart.enabled

Note: NickelMenu labels must not contain :. The colon is the field separator, and any label containing one produces a parse error that disables the entire NM menu, including entries provided by other packages.

Watchdog

A polling script at /mnt/onboard/.adds/wireguard/bin/wg-watchdog.sh checks every 15 seconds for two conditions: the autostart.enabled flag file exists, and a default route via wlan0 is present. When both are true and wg0 is not up, it runs wg-up --full. When wifi goes away, it runs wg-down.

Boot hook

The watchdog is launched from /mnt/onboard/.adds/koreader/koreader.sh. An initial attempt to hook it into /etc/init.d/ssh did not work: that init script exits early when the stock Kobo sshd is disabled, so the appended snippet was never reached.

Snippet added after koreader.sh's relocalize-to-tmp block:

WG_WATCHDOG=/mnt/onboard/.adds/wireguard/bin/wg-watchdog.sh
if [ -x "$WG_WATCHDOG" ] && ! pgrep -f wg-watchdog\.sh >/dev/null 2>&1; then
    setsid "$WG_WATCHDOG" </dev/null >/dev/null 2>&1 &
fi
  • setsid starts the watchdog in its own session so it survives KOReader exiting.
  • The pgrep check prevents double-launching.

Consequence: WireGuard auto-connects only after KOReader has been launched at least once since boot. On this device, KOReader is the primary reader, so this is acceptable.

Layout on the device

  • /mnt/onboard/.adds/wireguard/bin/wireguard-go — 3.1 MB static armv7 Go binary.
  • /mnt/onboard/.adds/wireguard/bin/wg — 768 KB static armv7 C binary.
  • /mnt/onboard/.adds/wireguard/bin/wg-up, wg-down — POSIX shell scripts.
  • /mnt/onboard/.adds/wireguard/bin/wg-watchdog.sh — polling script.
  • /mnt/onboard/.adds/wireguard/etc/wg0.conf — peer configuration.
  • /mnt/onboard/.adds/wireguard/autostart.enabled — flag file consumed by the watchdog.
  • /mnt/onboard/.adds/nm/wireguard — NickelMenu entries.
  • /mnt/onboard/.adds/koreader/koreader.sh — patched to launch the watchdog.

Notes on process

  • NickelMenu label restrictions are documented but easy to miss. A single malformed entry disables the whole menu, which on this device also disables the ability to launch KOReader, which also disables SSH. Recovery required mounting the device via USB.
  • The correct boot hook location depended on which SSH service is in use. Testing across a reboot before adding any UI-critical changes would have caught this earlier.
  • The wireguard-go daemonization failure was worked around rather than diagnosed. The root cause is unclear.
  • nix-shell with pkgsCross provided a working cross-compile pipeline with minimal setup. pkgsCross.<target>.glibc.static is worth knowing about; pkgsCross.<target>.pkgsStatic.<package> triggers a full toolchain rebuild and is not a shortcut.