Files
roro9stack/site/content/dev/milestones/n1.md
T
twislaandClaude Opus 5.5 9808013fc0
CI / build (pull_request) Successful in 1m49s
Site / build (pull_request) Successful in 10s
Shell: tls, ntp and netstat (#90)
The rest of the issue's list. tls makes a handshake that checks nothing,
then says the certificate in words: who it is for, who signed it, until
when, and whether this device's roots and the name asked for accept it,
with the reason when they don't. ntp compares a time server's clock with
the device's. netstat lists what listens and what is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 02:54:59 +02:00

14 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 after it. 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

Against a real server (the maintainer's own, 2026-10-08): a configuration uploaded from a phone through the Storage App's sharing (issue #88) 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, port and traceroute each 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; cancel stops it within a fifth of a second.
  • ping and traceroute share a raw ICMP socket: a traceroute is echo requests allowed one hop, then two, then three, and the routers' complaints are the list.
  • nslookup asks 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.
  • port is a connection attempt that is not waited for: open, refused, or five seconds of nothing.
  • ifconfig and arp read 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.
  • netstat reads 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_probe in 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.