Files
roro9stack/docs/milestones/N1.md
T
twislaandClaude Opus 5.5 ea0a892d71
CI / build (pull_request) Successful in 1m33s
Site / build (pull_request) Failing after 14m49s
N1: the tunnel checked with the device calling the peer, by name, with a preshared key (#8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 23:39:26 +02:00

8.3 KiB

N1 — Network tools

Status: in progress. The WireGuard tunnel (issue #8) is built. SSH (#2) is not started.

Goal: reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours.

The WireGuard tunnel (issue #8)

A WireGuard client: the Cardputer joins a WireGuard network over whatever Wi-Fi it is on.

Measured before deciding (2026-10-07)

The issue asked for the libraries to be measured first. esphome/wireguard 0.4.8 (maintained, published the same week; BSD-3-Clause) was built into a trial firmware and a tunnel brought up against a throwaway peer in a container.

Cost
Flash, the library 43 KB
Flash, with our service, page and commands 63 KB
Static RAM 1.2 KB
Heap with the tunnel up 1.8 KB
  • It crashes this build as shipped. The library calls lwIP's raw functions without taking lwIP's lock, and this framework is built to check for that (CONFIG_LWIP_CHECK_THREAD_SAFETY): the first netif_add stopped the device. Every call into it is made with the lock held, on our side; the library is not changed.
  • One address range is allowed by default; more need CONFIG_WIREGUARD_MAX_SRC_IPS, set in platformio.ini.
  • One peer, IPv4.
  • The older ciniml/WireGuard-ESP32 was last touched in 2021 and was not tried.

Decisions (design round 2026-10-07)

# Decision
Q243 esphome/wireguard, pinned at 0.4.8, with lwIP's lock taken around every call.
Q244 Configured by importing a standard .conf from the card (/vpn/wg0.conf), from Settings or with vpn import. Nothing is typed on the device.
Q245 The private key comes in that file, as WireGuard configurations are handed out. It is kept in the device's settings, never shown and never printed. After an import Settings offers to delete the file: the card comes out, and the key is in it in clear.
Q246 One tunnel, one peer.
Q247 A switch, and "Start with Wi-Fi" (off by default). The switch is for now: it doesn't outlast a restart. The tunnel waits for the clock, since a handshake carries the time and a server refuses one older than the last it saw; the clock is set over plain Wi-Fi first.
Q248 Narrowed while building. Either everything goes through the tunnel, or one subnet does. With 0.0.0.0/0 in AllowedIPs the tunnel is the default route. Otherwise only the subnet this device's tunnel address is in is routed: the widest allowed range that holds it. A home network behind the server can't be reached without the full tunnel: lwIP routes by an interface's own subnet or by default, and has no table for anything finer. The import says how many ranges it can't reach.
Q249 Not as planned. With everything through the tunnel, nothing leaves while the server is silent: the default route stays in the tunnel, which has nowhere to send. That is a kill switch, by construction and not by choice. With one subnet, packets for it go out on Wi-Fi again while the tunnel has no peer.
Q250 The file's DNS servers are used while the tunnel is up, if they can be reached through it; what was there before goes back when it stops.
Q251 The Debug Console and the Update Service answer over the tunnel as they do on Wi-Fi: the console still wants its token and an update its signature.
Q252 VPN in the Status Bar while the tunnel is wanted, bright once the server has answered. Settings > VPN has the state, the server, this device's address, what goes through it and how long ago the server was heard. vpn status, up, down, import, forget, auto. A Toast when it comes up and when the server stops answering.
Q253 PresharedKey, MTU and ListenPort from the file; keepalive 25 s if the file has none; the tunnel is taken down with the Wi-Fi it was on and started afresh on the next. No IPv6.

As built

  • lib/net/src/wg_config.h (host-tested, 6 tests): reads a .conf as people write them (any case, comments, CRLF, IPv6 entries left out), refuses what it can't use with the line and the field and never the key, writes it back tidy for the settings store, and says what will be routed.
  • VpnService (src/services/vpn_service): the tunnel is up when it is wanted, Wi-Fi is connected and the clock is set. It holds lwIP's lock around the library, adds the allowed ranges, makes the tunnel the default route for "everything", and puts the DNS servers in and out. A DHCP renewal that replaces them is noticed: the tunnel's go back in, and the renewed ones are what is restored later.
  • The tunnel's own packets never go into the tunnel: the library sends them on the interface that was the default when it started.
  • Connections that came in over Wi-Fi stay on Wi-Fi with everything routed into the tunnel: a reply leaves by the interface whose address it carries.
  • vpn up <seconds> takes the tunnel down again by itself: for trying a configuration from afar, when a wrong one could cut the connection it was sent over.
  • Settings: VpnConfig (the .conf, checked on every load) and VpnAuto.

Checks on the device (2026-10-07, against a WireGuard peer in a container)

The test keys were made for the purpose and deleted. Two rounds: first with the device on a guest Wi-Fi that can't open connections to the machine the test peer ran on, so the peer called the device (ListenPort), which WireGuard allows either way round; then on a network where the device called the peer, as it normally would.

Check Result
vpn import, then the file removed "imported, through it 10.9.0.0/24"; the configuration survives a firmware update
vpn up Up within seconds; VPN bright in the Status Bar; a Toast
From the peer, through the tunnel 25 pings of 25, 1300 bytes too; the Debug Console's greeting on TCP 2323; TCP 3232 answers
DNS The file's server while up (wifi status says (VPN)), DHCP's back after vpn down, with no reconnection
Everything through the tunnel The device stays reachable over Wi-Fi; an update check's HTTPS to the release server is seen inside the tunnel at the peer
vpn up 100 Down by itself after 100 s
"Start with Wi-Fi", then a restart Up by itself 40 s after the restart, once Wi-Fi and the clock were there
The peer silenced After three minutes: "no answer yet", a Toast, VPN dim. With everything through the tunnel, an update check then fails: nothing leaves. The peer back: up again in under half a minute, and a Toast
vpn forget "not set"; DNS as before
The device calling the peer, the server given by name, with a PresharedKey and MTU = 1280 Up in seconds; the peer shows the device's address and port as the endpoint; pings through it
One subnet: a connection the device opens to the peer's tunnel address Seen inside the tunnel at the peer
Everything: a Gemini page from a public capsule Fetched (TLS, 1,184 bytes), and seen inside the tunnel at the peer. Free memory fell to 45.9 KB at the lowest
Memory 106.1 KB free before, 104.3 KB with the tunnel up, 106.2 KB after
Settings > VPN The four rows, the state and "heard 66 s ago", the server, the address; no key anywhere on it

Not checked: a real server across the internet (both rounds were on a local network). That the MTU is what limits a packet (larger pings were answered too, in pieces). Roaming from one Wi-Fi to another with the tunnel wanted. IRC through the tunnel. A day of uptime.

What went wrong while building it

The device stopped on the first try, on lwIP's "Required to lock TCPIP core functionality!". The library was written for builds that don't check; ours does. The fix is three lines of ours, and the crash report named netif_add and the line that called it.

Taking the tunnel down reconnected Wi-Fi. The first version gave DHCP's DNS servers back by asking for a new lease, which is how the Wi-Fi settings do it, and which drops every connection: the Debug Console session that had typed vpn down among them. The servers that were there are now simply remembered and put back.

"What AllowedIPs say" was more than the network stack can do. The design round promised split tunnels by AllowedIPs. lwIP has no routing table: it can send by an interface's subnet, or by default. So it is one subnet or everything, and the import tells which.