Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
22 KiB
+++ title = "Network tools" description = "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." weight = 100
[extra]
docs = true
source = "docs/milestones/N1.md"
tag = "N1"
+++
Status: in progress. The WireGuard tunnel (issue #8) shipped as v0.19.0. The network troubleshooting commands (issue #90) shipped in two parts: ping, nslookup, port, traceroute, ifconfig and arp as v0.20.0; tls, ntp and netstat as v0.21.0. The SSH client (issue #2) shipped as v0.22.0.
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 |
Against a real server (the maintainer's own, 2026-10-08): a configuration put on the card by the maintainer and imported in Settings; the server named by host name, on a port of its own, the device's address a /32, everything through the tunnel, "Start with Wi-Fi" on. The tunnel is up, and from another machine the device answers on its tunnel address: pings, and the Debug Console.
Not checked: from a network far from the server (the device was on the server's own network, reaching it by its public name). 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.
Network troubleshooting commands (issue #90)
With a tunnel, fixed addresses and a file server on the device, "is it the network or is it me" needed another machine to answer.
Decisions (2026-10-08; built on the issue's list, without a round of questions)
- The familiar names:
ping,nslookup,traceroute,ifconfig,arp.port <host> <port>for "is that TCP port open", which has no single familiar name. - In this version: those six. Not yet:
tls(why a certificate fails),ntp(the clock's offset),netstat(what listens). The issue stays open for them. - Commands only, in the Shell and over both consoles; no page in an App.
- One line an answer, short: a Shell line is 38 characters.
As built
lib/net/src/net_probe.h(host-tested, 5 tests): what was typed; the ICMP echo request and what answers it, a router's "time exceeded" included; the DNS query and its answer, with the pointers names are shortened by.NetTools(src/services/net_tools):ping,nslookup,portandtracerouteeach run on a task of their own, made for the command and gone after it, printing to the console that asked (the Shell shows only its own replies). One at a time;cancelstops it within a fifth of a second.pingandtracerouteshare a raw ICMP socket: a traceroute is echo requests allowed one hop, then two, then three, and the routers' complaints are the list.nslookupasks one server itself, over UDP, and so can say which server answered and how long it took, which the system's resolver doesn't; and it can ask a server that isn't the configured one.portis a connection attempt that is not waited for: open, refused, or five seconds of nothing.ifconfigandarpread lwIP's own lists, with its lock held.- Cost: 12 KB of flash. A 6 KB task while a command runs (2.6 KB of it never used), nothing otherwise.
Checks on the device (2026-10-08, with the VPN up and everything routed through it)
| Check | Result |
|---|---|
ifconfig |
vpn 10.9.0.2/32 mtu 1420, up, default route; wifi ... gw ... mtu 1500, up; the DNS server |
arp |
The gateway and one other machine |
ping of a neighbour, of a name |
4 of 4 in 3 to 4 ms; 3 of 3 in about 50 ms |
ping 9.9.9.9 2 1392, then 1393 |
Both back; neither back: the tunnel carries 1420 bytes exactly |
nslookup |
The address, the server and the time; an alias followed; with another server; "there is no nope.invalid" |
port |
open, 52 ms; refused; "no answer in 5 s"; "doesn't resolve" |
traceroute 9.9.9.9 |
Nine hops, the tunnel's server first, "arrived" |
| A second command while a ping runs | "another one is running: cancel stops it" |
cancel |
"stopped", with the count so far |
| In the Shell | Tab completes them; the lines appear there and only there |
Not checked: without the VPN (every check went through the tunnel, or to the local network); a network that drops ICMP; the commands in Safe Mode, where they are not offered.
Found on the way: a refused connection is reported by lwIP as "reset", not "refused"; the first version called it "no route". And the header for the tested half was first given the same name as the service's, which makes a file include itself: the same mistake as an hour before, in the same way.
The rest of the list: tls, ntp, netstat (2026-10-08)
tls <host> [port]makes a handshake that checks nothing, so that a bad certificate can be looked at, and then checks it itself: against this device's roots (ca_roots.h, the ones the Update Service trusts) and the name asked for. It says who the certificate is for, who signed it, from when to when with the days left, the verdict with its reasons, and the SHA-256 that a Gemini pin is. It runs on a 12 KB task and isn't tried with less than 70 KB free: a handshake peaks at about 52 KB.ntp [server]sends one SNTP request and compares the answer with the device's clock, allowing for half the round trip. With no server it asks the first one in Settings.netstatreads lwIP's own lists: what listens, labelled where the firmware knows what it is, what is connected, and the UDP ports in use.- Host tests: the NTP packet and the year 2036, the offset in words, a certificate's name (an old string type that mbedTLS prints as hex included), days between dates. 7 tests in
test/test_net_probein all.
| Check on the device | Result |
|---|---|
tls git.twis.la |
709 ms; for git.twis.la, 67 days left, "this device trusts it", the SHA-256 |
tls geminiprotocol.net 1965 |
"NOT trusted here: not signed by a root this device has": a capsule signs its own |
tls expired.badssl.com |
"EXPIRED 4197 days ago" |
tls wrong.host.badssl.com |
"NOT trusted here: not for that name" |
tls to a port that isn't TLS |
"no handshake ... An invalid SSL record was received" |
ntp |
The server, its stratum, 50 ms away; "this clock is right, to 0.1 s" |
netstat |
The update port and the Debug Console listening, the console's own connection, the UDP ports |
Memory during a tls |
44.5 KB free at the lowest, from 104 KB |
Not checked: tls with IRC connected (it should refuse for lack of memory); ntp against a clock that is wrong; netstat while sharing.
The SSH client (issue #2)
A terminal on another machine: one session to a shell, from the SSH App.
Measured before deciding (2026-10-08)
ewpa/LibSSH-ESP32 5.10.0 (libssh on mbedTLS) was built into a trial firmware and a session opened against OpenSSH in a container, with a password.
| Measured in the trial | As built | |
|---|---|---|
| Flash | 120 KB | 292 KB |
| Static RAM | 1.2 KB | |
| The session's task stack | 13 KB used | 13.7 KB used of 20 KB |
| Free heap with a session open | 49 KB of 99 KB: it costs about 50 KB, the stack included | |
| Lowest free heap during a login | 30 KB | |
| Key exchange (curve25519, ed25519 host key) | 227 ms |
- The trial undercounted the flash. It logged in with a password. Signing with a key of the device's own (Q255) links libssh's table of multiples of the Ed25519 base point:
ge25519.c.oalone is 109 KB. The rest of the difference is the public-key code, the terminal and three fonts. The firmware is at 71% of its slot. - libssh carries its own curve25519 (
src/external/curve25519_ref.c) with the names libsodium uses, and libsodium is already here for WireGuard: two definitions don't link. A build script (scripts/libssh_filter.py) leaves libssh's copy out, and it uses libsodium's. It has to be apre:script: libraries are built before anypost:one runs. - A session and a TLS connection don't fit together: IRC holds 40 KB.
Decisions (design round 2026-10-08)
| # | Decision |
|---|---|
| Q254 | LibSSH-ESP32 5.10.0, with its duplicate curve file left out of the build. |
| Q255 | A password, typed each time and never stored, or a key the device makes for itself (Ed25519, no passphrase, kept in the settings store). Its public half is shown, written to /ssh/id_ed25519.pub and printed by ssh status. Keys made elsewhere aren't imported. |
| Q256 | A terminal good enough for a shell, less, top, nano and vim: cursor movement, erasing, sixteen colours, scroll regions, the alternate screen, the cursor keys' two modes. TERM=xterm. No mouse. |
| Q257 | Five text sizes, changed with Ctrl and + or - (the user's change to the round: the proposal was a setting). 4x6, 5x8, 6x10, 6x13 and 9x15 pixels: from 60 x 20 to 26 x 8 characters. The far end is told the new size; the choice is kept. |
| Q258 | 100 lines of scrollback, as text, with Alt and up or down, as in the Shell. Not on the alternate screen. |
| Q259 | Keys: Ctrl with a letter; Tab; the backtick key sends Esc, as it is printed; Alt with it types a backtick; Fn with the arrow keys; Shift with those for Page Up and Down; Ctrl+Alt+q disconnects. Fn with backtick is Home, as everywhere. |
| Q260 | The session outlives the App's time in front. SSH in the Status Bar while one is open. |
| Q261 | Not started under 75 KB free, with the reason in words. |
| Q262 | Up to eight hosts remembered, the last used first, once a login has succeeded. Forgetting one forgets its server's fingerprint too, unless another remembered host is the same server. |
| Q263 | Trust on first use, on the SHA-256 fingerprint; sixteen servers remembered. A changed key is a warning, with Cancel selected. |
| Q264 | UTF-8 in, the fonts' Latin-1 out: what they lack is ?, box-drawing lines are + - |. |
| Q265 | ssh user@host in the Shell opens the App and connects there. The password is never asked on a console. |
| Q266 | Not built: port forwarding, SFTP and scp, jump hosts, agent forwarding, keys with a passphrase, more than one session. |
As built
lib/term(host-tested, 12 tests intest/test_terminal):Terminal, the screen a program's output makes, with its history and the replies a program asks for;encodeKey, what a key sends;SshHostsandSshKnownHosts, the two lists kept in the settings store as lines of text.SshService(src/services/ssh_service): one session on a task of its own (20 KB of stack). The task and the main loop share the terminal, the bytes to send and the state under one lock. Its questions (is this the right server? the password?) are states it waits in until the App answers; the settings store is only written from the main loop. The password and the private key are overwritten after use.SshApp(src/apps/ssh_app): the hosts, the entry, the session, the key page. It draws the grid a run of same-coloured cells at a time, at most every 60 ms.- Keys that aren't characters now say what was held with them (
lib/input/src/key_mapper.cpp): the arrows, Enter, Del, Tab and Back carry Shift, Ctrl and Alt. The terminal needs it for Page Up and Alt+backtick. It also makes two documented keys work from the real keyboard, which until now only worked from the Debug Console'skeycommand: Ctrl with Fn and up or down in a note, and Shift+Tab in Gemini. - IRC doesn't try to connect with less than 60 KB free (
IrcService::kNeedFree): see below. - Settings:
SshHosts,SshKnown,SshKey,SshPublic,SshFont.
Checks on the device (2026-10-08, against OpenSSH 9.7 in a container on the same network)
| Check | Result |
|---|---|
ssh tester@host:2222 from the Debug Console |
The App opens; the fingerprint shown is the one ssh-keygen -lf prints on the server |
| Trust it, a password | A shell; stty size says 15 48, $TERM is xterm |
ls -la, top, vim (insert, Esc, :wq) |
Drawn right: top's reverse-video header, vim's alternate screen and what was there before coming back; the file is on the server |
| Ctrl with + and - | stty size says 12 40, then 20 60; top redraws for it |
seq 1 60, Alt with up |
The history, in grey, with how far back in the corner |
| Fn+backtick, then the App again | The Launcher with SSH in the Status Bar; the session as it was |
sleep 100, Ctrl+C; Alt+backtick |
Interrupted; echo `id -u` prints 1000 |
Ctrl+Alt+q; exit |
"Disconnected"; "The session ended" |
This device's key, its public half in authorized_keys |
"Accepted publickey" in the server's log; no password asked |
| The server's host keys replaced | "THE SERVER'S KEY CHANGED" with the new fingerprint, Cancel selected. Cancel: "Not trusted: not connected". Replace: it connects, and doesn't ask again |
| A wrong password | "Wrong password", and asked again; Back gives up |
| A port nothing listens on | "Nothing listens there: the connection was refused" |
ssh nobody, ssh a@, a port of 99999 |
Refused, each with its reason |
| Forgetting a host | Asked, then gone from the list |
irc start with a session open |
"not enough memory: close the SSH session, retrying in 5 s", and no attempt: the lowest free heap doesn't move |
| Memory | 99 KB free before, 49 KB with a session open, 30 KB at the lowest during a login, 99 KB again after |
| Stacks | ssh 6.8 KB free of 20 KB; loopTask 1.9 KB free, as before |
Not checked: the refusal under 75 KB free (it is one comparison, and wasn't provoked). A server on the internet, or through the VPN. Wi-Fi lost in the middle of a session. Servers other than OpenSSH. nano, less, htop, tmux. Keyboard-interactive logins (two-factor prompts). A session left open for hours.
What went wrong while building it
- IRC, started with a session open, took the free heap down to 236 bytes. A test script's keys went to the Launcher instead of the terminal and opened the IRC App, which connects when opened. Its TLS handshake found no memory, failed, and tried again with its usual back-off, six times; nothing crashed and it never connected, but 236 bytes is no margin at all. IRC now looks at the free heap before each attempt and says "not enough memory: close the SSH session" instead of trying.
- The build script did nothing as a
post:script: the libraries were already built when it ran. - A failed connection was first shown as an empty terminal with its reason squeezed on the last line, and a host was remembered before anyone had logged in to it. Both changed: the reason has a page, and a host is remembered once a login succeeds.
- The trust question didn't fit its dialog: the fingerprint is 50 characters. It is now split over two lines, under one line of words.