Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
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 firstnetif_addstopped 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 inplatformio.ini. - One peer, IPv4.
- The older
ciniml/WireGuard-ESP32was 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.confas 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) andVpnAuto.
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.