Compare commits

...
Author SHA1 Message Date
twislaandClaude Opus 5.5 01a8e2a233 Devlog: "Six releases behind", the WireGuard tunnel, the documentation caught up, and nine network commands (v0.19.0 to v0.21.0)
Site / build (pull_request) Successful in 10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 03:12:03 +02:00
twisla 0498916740 Merge pull request 'Shell: tls, ntp and netstat (#90)' (#92) from net-tools-2 into main
Site / build (push) Successful in 12s
CI / build (push) Successful in 2m53s
2026-10-08 01:00:42 +00:00
twislaandClaude Opus 5.5 298407b5cf N1: tls, ntp and netstat ship as v0.21.0
CI / build (pull_request) Successful in 1m32s
Site / build (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 02:58:39 +02:00
twislaandClaude Opus 5.5 9808013fc0 Shell: tls, ntp and netstat (#90)
CI / build (pull_request) Successful in 1m49s
Site / build (pull_request) Successful in 10s
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
twisla 9b6457d1ec Merge pull request 'Shell: ping, nslookup, port, traceroute, ifconfig and arp (#90)' (#91) from net-tools into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 3m8s
2026-10-08 00:38:15 +00:00
twislaandClaude Opus 5.5 a8f267416b N1: the network commands ship as v0.20.0
CI / build (pull_request) Successful in 1m37s
Site / build (pull_request) Successful in 13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 02:35:49 +02:00
twislaandClaude Opus 5.5 ed7abdcaf5 Shell: ping, nslookup, port, traceroute, ifconfig and arp (#90)
CI / build (pull_request) Successful in 1m38s
Site / build (pull_request) Successful in 11s
Network troubleshooting from the device itself, in the Shell and over both
consoles. ping, nslookup, port and traceroute each run on a task of their
own and print as they go, to the console that asked; one at a time, and
`cancel` stops it. ifconfig and arp answer at once: the interfaces (Wi-Fi
and the VPN), which is the default route, the DNS servers, the neighbours.

nslookup asks a DNS server itself, so it can say which server answered and
in how long, and ask another. A sized ping finds what a tunnel really
carries.

With a how-to, "When the network doesn't work", and the rest of the docs.
tls, ntp and netstat from the issue's list are not in this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-08 02:29:04 +02:00
twisla 86172c0342 Merge pull request 'VPN: a WireGuard tunnel (#8)' (#87) from vpn-wireguard into main
Site / build (push) Successful in 17s
CI / build (push) Successful in 2m59s
2026-10-07 23:52:04 +00:00
26 changed files with 1662 additions and 8 deletions
+5 -2
View File
@@ -13,7 +13,7 @@ What it does today:
- **Wi-Fi Tools:** the networks around, sorted, filtered, logged.
- **Notes:** plain text files of any size, saved by themselves.
- **Storage:** the SD card: copy, move, rename, delete; viewers for text, hex, pictures (PNG, JPEG, BMP, GIF), tracks, captures and update files; **sharing with a phone's browser**.
- **Shell:** the firmware's commands on the device itself, with completion.
- **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
- **System:** load, tasks, memory, network, battery, live.
- **VPN:** a WireGuard tunnel.
- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot.
@@ -185,7 +185,7 @@ A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on
## Shell
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise.
The Shell App (docs/milestones/S1.md) runs the commands below on the device's own screen and keyboard: no PC, no cable, no Wi-Fi. **It shows the replies to its own commands and nothing else**: the console knows who each line is printed for, so a listing read by another task a moment later is still the Shell's, and what USB or the Debug Console asked for is not. Ctrl+b shows everything instead. Tab completes a command word by word (`lora st` gives `lora status`) and, past it, a path on the SD card (`ls /no` gives `ls /notes/`), Fn with up and down recalls earlier lines, Alt with up and down scrolls back. **An App's name with a capital opens it** (`Notes`, `Irc`, `Wifi`, `Gnss`, `Gemini`, `Lora`, `Storage`, `System`, `Settings`), from the consoles too. `rm` is Unix's, with a question: a folder needs `-r`; a file, or a folder with something in it, is asked about unless `-f` (`rm -rf`); an empty folder goes without a word. `*` and `?` in a name stand for several files (`rm /notes/*.txt` asks once, with the count; 64 at most). `clear` empties the screen and `quit` leaves. It is trusted like the USB port: `debug on` and `debug token` work from it. It uses about 7 KB of memory while it is open, and none otherwise. **For the network:** `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp` and `netstat` (issue #90).
## Development aids
@@ -241,6 +241,9 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `coredump erase` | Forgets the core dump |
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
+62 -2
View File
@@ -1,6 +1,6 @@
# N1 — Network tools
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. SSH (#2) is not started.
**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**. 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.
@@ -70,7 +70,7 @@ The test keys were made for the purpose and deleted. Two rounds: first with the
| 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.
**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.
@@ -81,3 +81,63 @@ The test keys were made for the purpose and deleted. Two rounds: first with the
**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.
+300
View File
@@ -0,0 +1,300 @@
#include "net_probe.h"
#include <algorithm>
#include <cstring>
#include "ipv4.h"
namespace roro::net {
namespace {
std::vector<std::string> words(const std::string& text) {
std::vector<std::string> out;
size_t at = 0;
while (at < text.size()) {
while (at < text.size() && text[at] == ' ') at++;
size_t end = text.find(' ', at);
if (end == std::string::npos) end = text.size();
if (end > at) out.push_back(text.substr(at, end - at));
at = end;
}
return out;
}
bool number(const std::string& s, long& out) {
if (s.empty() || s.size() > 6) return false;
out = 0;
for (char c : s) {
if (c < '0' || c > '9') return false;
out = out * 10 + (c - '0');
}
return true;
}
uint16_t be16(const uint8_t* p) { return static_cast<uint16_t>((p[0] << 8) | p[1]); }
} // namespace
std::string parsePing(const std::string& args, PingArgs& out) {
static const char* const kUsage = "ping <host> [count] [size]";
auto w = words(args);
if (w.empty() || w.size() > 3 || !validHost(w[0])) return kUsage;
PingArgs a;
a.host = w[0];
long n;
if (w.size() > 1) {
if (!number(w[1], n) || n < 1 || n > 100) return "a count from 1 to 100";
a.count = static_cast<int>(n);
}
if (w.size() > 2) {
if (!number(w[2], n) || n > 1400) return "a size from 0 to 1400 bytes";
a.size = static_cast<int>(n);
}
out = a;
return "";
}
std::string parsePort(const std::string& args, PortArgs& out) {
static const char* const kUsage = "port <host> <port>";
auto w = words(args);
if (w.size() == 1) { // host:port
size_t colon = w[0].rfind(':');
if (colon == std::string::npos) return kUsage;
w = {w[0].substr(0, colon), w[0].substr(colon + 1)};
}
long n;
if (w.size() != 2 || !validHost(w[0])) return kUsage;
if (!number(w[1], n) || n < 1 || n > 65535) return "a port from 1 to 65535";
out.host = w[0];
out.port = static_cast<uint16_t>(n);
return "";
}
std::string parseLookup(const std::string& args, LookupArgs& out) {
static const char* const kUsage = "nslookup <name> [server's address]";
auto w = words(args);
uint32_t ip;
if (w.empty() || w.size() > 2 || !validHost(w[0])) return kUsage;
if (w.size() == 2 && !parseIpv4(w[1], ip)) return "the server as an address: 9.9.9.9";
out.name = w[0];
out.server = w.size() == 2 ? w[1] : "";
return "";
}
uint16_t inetChecksum(const uint8_t* data, size_t len) {
uint32_t sum = 0;
for (size_t i = 0; i + 1 < len; i += 2) sum += static_cast<uint32_t>((data[i] << 8) | data[i + 1]);
if (len & 1) sum += static_cast<uint32_t>(data[len - 1] << 8);
while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);
return static_cast<uint16_t>(~sum);
}
size_t buildEcho(uint8_t* out, size_t max, uint16_t id, uint16_t seq, size_t payload) {
size_t len = 8 + payload;
if (len > max) return 0;
out[0] = 8; // echo request
out[1] = 0;
out[2] = out[3] = 0;
out[4] = static_cast<uint8_t>(id >> 8);
out[5] = static_cast<uint8_t>(id);
out[6] = static_cast<uint8_t>(seq >> 8);
out[7] = static_cast<uint8_t>(seq);
for (size_t i = 0; i < payload; i++) out[8 + i] = static_cast<uint8_t>('a' + i % 26);
uint16_t sum = inetChecksum(out, len);
out[2] = static_cast<uint8_t>(sum >> 8);
out[3] = static_cast<uint8_t>(sum);
return len;
}
IcmpAnswer parseIcmp(const uint8_t* packet, size_t len) {
IcmpAnswer a;
if (len < 20 || (packet[0] >> 4) != 4) return a;
size_t header = static_cast<size_t>(packet[0] & 0x0F) * 4;
if (header < 20 || len < header + 8 || packet[9] != 1) return a; // not ICMP
const uint8_t* icmp = packet + header;
size_t left = len - header;
if (icmp[0] == 0 && icmp[1] == 0) { // echo reply
a.kind = IcmpAnswer::Kind::Echo;
a.id = be16(icmp + 4);
a.seq = be16(icmp + 6);
return a;
}
if (icmp[0] != 11 && icmp[0] != 3) return a;
// Inside: the IP header of the packet it is about, and that packet's first 8 bytes.
if (left < 8 + 20) return a;
const uint8_t* inner = icmp + 8;
size_t innerHeader = static_cast<size_t>(inner[0] & 0x0F) * 4;
if ((inner[0] >> 4) != 4 || innerHeader < 20 || left < 8 + innerHeader + 8 || inner[9] != 1 || inner[innerHeader] != 8) return a;
a.kind = icmp[0] == 11 ? IcmpAnswer::Kind::TimeExceeded : IcmpAnswer::Kind::Unreachable;
a.id = be16(inner + innerHeader + 4);
a.seq = be16(inner + innerHeader + 6);
return a;
}
void PingStats::add(uint32_t ms) {
minMs = back ? std::min(minMs, ms) : ms;
maxMs = std::max(maxMs, ms);
sumMs += ms;
back++;
}
std::string PingStats::summary() const {
int lost = sent ? (sent - back) * 100 / sent : 0;
std::string s = std::to_string(back) + "/" + std::to_string(sent) + " back, " + std::to_string(lost) + "% lost"; // short: a Shell line is 38 characters
if (back) s += ", " + std::to_string(minMs) + "/" + std::to_string(sumMs / static_cast<uint32_t>(back)) + "/" + std::to_string(maxMs) + " ms";
return s;
}
size_t buildDnsQuery(uint8_t* out, size_t max, uint16_t id, const std::string& name) {
if (name.empty() || name.size() > 253 || 12 + name.size() + 2 + 4 > max) return 0;
std::memset(out, 0, 12);
out[0] = static_cast<uint8_t>(id >> 8);
out[1] = static_cast<uint8_t>(id);
out[2] = 0x01; // recursion wanted
out[5] = 1; // one question
size_t at = 12;
for (size_t from = 0; from <= name.size();) {
size_t dot = name.find('.', from);
if (dot == std::string::npos) dot = name.size();
size_t n = dot - from;
if (n == 0 && dot == name.size()) break; // a final dot
if (n == 0 || n > 63) return 0;
out[at++] = static_cast<uint8_t>(n);
std::memcpy(out + at, name.data() + from, n);
at += n;
from = dot + 1;
}
out[at++] = 0;
out[at++] = 0;
out[at++] = 1; // A
out[at++] = 0;
out[at++] = 1; // IN
return at;
}
namespace {
// Reads a name at `at`, following the pointers DNS shortens names with. Where the name ends in
// the message (not where a pointer led), or 0 if it is broken.
size_t readName(const uint8_t* m, size_t len, size_t at, std::string* out) {
size_t end = 0;
int jumps = 0;
while (at < len) {
uint8_t n = m[at];
if (n == 0) return end ? end : at + 1;
if ((n & 0xC0) == 0xC0) {
if (at + 1 >= len || ++jumps > 8) return 0;
if (!end) end = at + 2;
at = static_cast<size_t>(((n & 0x3F) << 8) | m[at + 1]);
continue;
}
if (n > 63 || at + 1 + n > len) return 0;
if (out) {
if (!out->empty()) *out += '.';
out->append(reinterpret_cast<const char*>(m + at + 1), n);
}
at += 1 + static_cast<size_t>(n);
}
return 0;
}
} // namespace
bool parseDnsAnswer(const uint8_t* m, size_t len, uint16_t id, DnsAnswer& out) {
if (len < 12 || be16(m) != id || !(m[2] & 0x80)) return false;
DnsAnswer a;
a.truncated = m[2] & 0x02;
a.rcode = m[3] & 0x0F;
int questions = be16(m + 4), answers = be16(m + 6);
size_t at = 12;
for (int i = 0; i < questions; i++) {
at = readName(m, len, at, nullptr);
if (!at || at + 4 > len) return false;
at += 4;
}
for (int i = 0; i < answers; i++) {
at = readName(m, len, at, nullptr);
if (!at || at + 10 > len) return false;
uint16_t type = be16(m + at), size = be16(m + at + 8);
at += 10;
if (at + size > len) return false;
if (type == 1 && size == 4) a.addresses.push_back((static_cast<uint32_t>(m[at]) << 24) | (m[at + 1] << 16) | (m[at + 2] << 8) | m[at + 3]);
if (type == 5) {
std::string name;
if (readName(m, len, at, &name)) a.alias = name;
}
at += size;
}
out = a;
return true;
}
void buildNtpRequest(uint8_t out[kNtpPacket]) {
std::memset(out, 0, kNtpPacket);
out[0] = 0x23; // no warning, version 4, a client
}
bool parseNtpAnswer(const uint8_t* p, size_t len, NtpAnswer& out) {
if (len < kNtpPacket || (p[0] & 0x07) != 4) return false; // not a server's
if (p[1] == 0 || p[1] > 15) return false; // "kiss of death", or not synchronised
uint32_t secs = (static_cast<uint32_t>(p[40]) << 24) | (p[41] << 16) | (p[42] << 8) | p[43];
uint32_t frac = (static_cast<uint32_t>(p[44]) << 24) | (p[45] << 16) | (p[46] << 8) | p[47];
if (!secs) return false;
// NTP counts from 1900 and wraps in 2036: a small number is the era after.
constexpr int64_t k1900To1970 = 2208988800LL;
int64_t since1900 = secs < 0x80000000u ? static_cast<int64_t>(secs) + 4294967296LL : static_cast<int64_t>(secs);
out.stratum = p[1];
out.seconds = since1900 - k1900To1970;
out.millis = static_cast<uint32_t>((static_cast<uint64_t>(frac) * 1000) >> 32);
return true;
}
std::string clockOffset(int64_t ownMs, int64_t serverMs) {
int64_t diff = ownMs - serverMs, size = diff < 0 ? -diff : diff;
if (size < 100) return "right, to 0.1 s";
std::string amount = size < 10000 ? std::to_string(size / 1000) + "." + std::to_string(size % 1000 / 100) + " s"
: size < 120000 ? std::to_string(size / 1000) + " s"
: size < 7200000 ? std::to_string(size / 60000) + " min"
: size < 172800000LL ? std::to_string(size / 3600000) + " h" : std::to_string(size / 86400000LL) + " days";
return amount + (diff > 0 ? " ahead" : " behind");
}
std::string certName(const std::string& dn) {
for (const char* key : {"CN=", "O="}) {
size_t at = 0;
while ((at = dn.find(key, at)) != std::string::npos) {
if (at == 0 || dn[at - 1] == ' ' || dn[at - 1] == ',') {
size_t from = at + std::strlen(key), end = dn.find(", ", from);
std::string name = dn.substr(from, end == std::string::npos ? std::string::npos : end - from);
// An old kind of string comes out as "#" and hex, type and length first: read it.
if (name.size() > 5 && name[0] == '#' && name.size() % 2 == 1) {
std::string plain;
for (size_t i = 5; i + 1 < name.size(); i += 2) {
auto digit = [](char c) { return c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1; };
int hi = digit(name[i]), lo = digit(name[i + 1]);
if (hi < 0 || lo < 0 || hi * 16 + lo < 0x20 || hi * 16 + lo > 0x7E) return name;
plain += static_cast<char>(hi * 16 + lo);
}
return plain;
}
return name;
}
at++;
}
}
return dn;
}
namespace {
// Days since a fixed day long ago (the civil calendar, leap years and all).
long dayNumber(int y, int m, int d) {
y -= m <= 2;
long era = (y >= 0 ? y : y - 399) / 400;
long yoe = y - era * 400, doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
return era * 146097 + yoe * 365 + yoe / 4 - yoe / 100 + doy;
}
} // namespace
int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2) { return static_cast<int>(dayNumber(y2, m2, d2) - dayNumber(y1, m1, d1)); }
const char* portLabel(uint16_t port, bool tcp) {
if (tcp) return port == 3232 ? "updates" : port == 2323 ? "Debug Console" : port == 80 ? "sharing" : "";
return port == 68 ? "DHCP" : port == 123 ? "NTP" : "";
}
} // namespace roro::net
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
// The parts of the network troubleshooting commands (issue #90) that need no network: what was
// asked, the packets to send, and what the answers mean. The sockets are src/services/net_tools.h (a different name on purpose: two headers of one name find themselves).
namespace roro::net {
// --- what was typed
struct PingArgs {
std::string host;
int count = 4; // 1 to 100
int size = 56; // bytes of payload, 0 to 1400: with its headers a ping is 28 more
};
// "ping <host> [count] [size]". "" or how to ask.
std::string parsePing(const std::string& args, PingArgs& out);
struct PortArgs {
std::string host;
uint16_t port = 0;
};
// "port <host> <port>", or host:port.
std::string parsePort(const std::string& args, PortArgs& out);
struct LookupArgs {
std::string name, server; // server: an address, or "" for the one in use
};
std::string parseLookup(const std::string& args, LookupArgs& out);
// --- ICMP: ping and traceroute
uint16_t inetChecksum(const uint8_t* data, size_t len);
// An echo request: 8 bytes of header and `payload` bytes after it. The length written, or 0 if
// it doesn't fit.
size_t buildEcho(uint8_t* out, size_t max, uint16_t id, uint16_t seq, size_t payload);
struct IcmpAnswer {
enum class Kind { Other, Echo, TimeExceeded, Unreachable } kind = Kind::Other;
uint16_t id = 0, seq = 0; // of the echo request it answers
};
// `packet` as a raw socket hands it over: the IP header first. A router's "time exceeded" and
// "unreachable" carry the start of the packet they are about, which is where id and seq come from.
IcmpAnswer parseIcmp(const uint8_t* packet, size_t len);
// What a run of pings came to: "3/4 back, 25% lost, 12/25/41 ms" (the least, the mean, the most).
struct PingStats {
int sent = 0, back = 0;
uint32_t minMs = 0, maxMs = 0, sumMs = 0;
void add(uint32_t ms);
std::string summary() const;
};
// --- DNS: nslookup
// A query for the IPv4 addresses of `name`. The length written, or 0 if that isn't a name.
size_t buildDnsQuery(uint8_t* out, size_t max, uint16_t id, const std::string& name);
struct DnsAnswer {
int rcode = 0; // 0: fine, 3: no such name
bool truncated = false; // the answer didn't fit in one packet
std::vector<uint32_t> addresses;
std::string alias; // the last name a CNAME led to, if any
};
// False if it isn't the answer to query `id`, or is cut short.
bool parseDnsAnswer(const uint8_t* message, size_t len, uint16_t id, DnsAnswer& out);
// --- NTP: the clock's offset
constexpr size_t kNtpPacket = 48;
void buildNtpRequest(uint8_t out[kNtpPacket]);
struct NtpAnswer {
int stratum = 0; // 1: a reference clock; 2 and up: that many steps from one
int64_t seconds = 0; // the server's clock when it answered, UTC since 1970
uint32_t millis = 0; // and the part of a second
};
// False if it isn't a server's answer, or says the server has no time to give.
bool parseNtpAnswer(const uint8_t* packet, size_t len, NtpAnswer& out);
// "0.3 s ahead", "12 s behind", "right, to 0.1 s": this clock against the server's, both in ms.
std::string clockOffset(int64_t ownMs, int64_t serverMs);
// --- TLS: who a certificate is for
// The common name out of a certificate's subject or issuer as mbedTLS prints it
// ("C=US, O=Let's Encrypt, CN=R11"): the CN, else the O, else all of it.
std::string certName(const std::string& dn);
// Whole days from one date to another (negative: the second is earlier).
int daysBetween(int y1, int m1, int d1, int y2, int m2, int d2);
// --- netstat
// What listens on a port of this firmware, or "".
const char* portLabel(uint16_t port, bool tcp);
} // namespace roro::net
+6
View File
@@ -39,6 +39,9 @@ install <path.ota> Update from SD
update check | update list | update status | update install <tag> the project's releases on Gitea
sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal
Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command
ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time
tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one
ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected
vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token
@@ -110,6 +113,9 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `coredump erase` | Forgets the core dump |
| `loop spin on` / `loop spin off` | Make the main loop spin without resting, to compare load and radio noise |
| `crash abort` / `crash wdt` | Crash on purpose, or hang the main loop until the watchdog fires |
| `ping <host> [count] [size]` / `nslookup <name> [server]` / `port <host> <port>` / `traceroute <host>` / `cancel` | Network troubleshooting (issue #90): does a host answer and how fast; a name's addresses, from which DNS server and in how long; is a TCP port open, refused or silent; the routers on the way. Each runs on a task of its own and prints as it goes, one at a time; `cancel` stops it |
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
+62 -2
View File
@@ -8,7 +8,7 @@ docs = true
source = "docs/milestones/N1.md"
tag = "N1"
+++
**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. SSH (#2) is not started.
**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**. 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.
@@ -78,7 +78,7 @@ The test keys were made for the purpose and deleted. Two rounds: first with the
| 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.
**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.
@@ -89,3 +89,63 @@ The test keys were made for the purpose and deleted. Two rounds: first with the
**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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+312
View File
@@ -0,0 +1,312 @@
+++
title = '''Six releases behind'''
description = '''roro9stack gets a WireGuard tunnel, and the tools to find out why a network doesn't work: ping, nslookup, traceroute, a TLS check and the rest. In between, I looked at the project's own website and found that the documentation had stopped keeping up six releases earlier, and nobody had noticed, me included.'''
date = 2026-10-08T03:15:00+02:00
[extra]
topics = '''ESP32-S3 · WireGuard · Documentation'''
read_label = '''Read what was missing →'''
uid = '''<b>ifconfig:</b> vpn 10.9.0.2/32 mtu 1420, up, default route'''
dek = "Three more releases of [roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer: a VPN (v0.19.0) and nine commands for troubleshooting a network from the device itself (v0.20.0 and v0.21.0). The tunnel crashed the device the first time it was started and then turned out to be the easy part. The hard part was noticing that the user guide described a firmware from the day before."
byline = '''measured before deciding, for once; then promised more than the network stack could do'''
[extra.sign]
label = "Releases shipped with the documentation behind"
note = "v0.13.0 to v0.18.0. Each had its own page updated, and nothing around it."
count = "6"
tone = "red"
[[extra.cast]]
name = "The tunnel"
role = "WireGuard, one peer, IPv4"
text = "Costs under 2 KB of memory once it's up, which on this device is close to free. Stopped the device dead the first time it was asked to start."
[[extra.cast]]
name = "lwIP"
role = "the network stack"
text = "Has a lock, and in this firmware it checks that you hold it. Has no routing table, which I found out after promising one."
[[extra.cast]]
name = "The test peer"
role = "a WireGuard server in a container"
text = "Could reach the device. The device couldn't reach it. So the server called the client, which WireGuard doesn't mind at all."
[[extra.cast]]
name = "The home page"
role = "of this site"
text = "Said the Storage App opens text, hex, captures and tracks. It had been showing pictures for four releases and serving files to phones for one."
[[extra.cast]]
name = "ping"
role = "and eight friends"
text = "nslookup, port, traceroute, tls, ntp, ifconfig, arp, netstat. The first thing I did with them was measure my own tunnel, and learn something."
+++
## TL;DR
- **A WireGuard VPN** (**v0.19.0**): copy a client `.conf` to the card, import it in Settings, switch it on. One tunnel, to one server. It carries everything, or the tunnel's own subnet.
- **It costs 63 KB of flash and under 2 KB of memory.** The library crashed the firmware on its first call; the fix was three lines of ours.
- **I promised split tunnels by `AllowedIPs` and couldn't deliver:** the network stack routes by one subnet or by default, nothing finer.
- **The documentation was six releases behind.** Every feature had its own page. The home page, Settings, the Status Bar, the how-tos, the glossary and the README's first paragraph had none of it.
- **Nine network commands** in the Shell (**v0.20.0**, **v0.21.0**): `ping`, `nslookup`, `port`, `traceroute`, `tls`, `ntp`, `ifconfig`, `arp`, `netstat`.
- A ping of 1392 bytes crosses my tunnel and one of 1393 doesn't. I now know my tunnel's MTU to the byte, from a device with a 240-pixel screen.
- 546 host tests, 13 more than last time.
## The cast
{{ cast() }}
## Measured first, for once
The issue for the VPN had a line I'd written days ago and am glad of: *measure both libraries first.* So before any design, a trial firmware with the maintained library in it, a throwaway WireGuard server in a container, and a real tunnel.
{% table() %}
| | Cost |
|---|---|
| Flash, the library | 43 KB |
| Flash, with the service, the Settings page and the commands | 63 KB |
| Static RAM | 1.2 KB |
| Heap with the tunnel up | 1.8 KB |
{% end %}
On a device where a TLS connection takes 52 KB, a VPN for 1.8 is a gift. That number alone decided most of the design round: no memory floors, no "close IRC first", nothing to ration.
Getting to that number took three surprises.
### It stopped on the first call
{% code(caption="The first `wg up`. The crash report named the line.") %}
```
assert failed: netif_add /IDF/components/lwip/lwip/src/core/netif.c:297
(Required to lock TCPIP core functionality!)
```
{% end %}
The library talks to lwIP, the network stack, through its low-level functions, and takes no lock while it does. Most builds don't mind. This firmware's framework is built with the check switched on, and so the first `netif_add` was the last thing the device did.
The fix isn't in the library. Every call into it is made with the lock held, on our side: a three-line guard object. The library is used exactly as published.
### The server had to call the client
My device was on a guest Wi-Fi. The test server was on another network, and the guest network doesn't let its devices open connections inward. No handshake. For a while it looked like the library didn't work.
It worked fine. WireGuard doesn't have clients and servers, only peers, and either can call the other. I gave the device a fixed port to listen on, told the *server* where the device was, and the server started the handshake. Twenty-five pings of twenty-five, through a tunnel set up backwards.
(Later, on a network where the device could reach out, the normal direction worked first time. And then against my real server, which is the test that counts.)
### "What AllowedIPs say"
In the design round I'd agreed to this: *what goes through the tunnel is what the file's `AllowedIPs` line says.* Home subnets through the tunnel, the rest out over Wi-Fi. That's what every WireGuard client does.
Then I read how lwIP decides where a packet goes. It has two rules: the packet is for an interface's own subnet, or it goes to the default interface. There is no routing table to add "192.168.1.0/24 via the tunnel" to.
So the firmware does one of two things, and says which when you import a file:
{% table() %}
| The file says | What happens |
|---|---|
| `AllowedIPs = 0.0.0.0/0` | Everything goes through the tunnel |
| Anything else | The one subnet the device's tunnel address is in |
{% end %}
A home network *behind* the server needs the first kind. An import with more ranges than that says so: "through it 10.9.0.0/24, not 1 other range". I'd rather the screen admit a limit than the documentation.
It also changed an answer I'd given about a kill switch. I'd said there wouldn't be one. With everything routed into the tunnel, there is: while the server is silent the default route still points into a tunnel that has nowhere to send, and nothing leaves. Not by decision. By construction.
{{ figure(src="vpn.png", alt="The Cardputer's Settings, VPN page at 2x: VPN On, Start with Wi-Fi On, Import /vpn/wg0.conf, Forget it; then in blue It is up, heard 66 s ago, and in grey the server's address and This device 10.9.0.2, through it everything. The Status Bar shows VPN in blue.", width=480, height=270, caption="Settings → VPN, against the test server. No key appears on this page, or anywhere else: the private key goes in with the file and is never shown again.") }}
## Taking it down took my connection with it
One more, because it's the kind of bug that only shows when you test the way you work.
The tunnel puts its own DNS servers in while it's up, and has to give the old ones back when it stops. The Wi-Fi settings already had a way to get DHCP's servers back: ask for a new lease. So I called that.
A new lease reconnects Wi-Fi. Reconnecting Wi-Fi drops every connection, including the Debug Console session I had just typed `vpn down` into. The command worked and I never saw it say so.
The tunnel now remembers what was there and puts it back. It also notices when a DHCP renewal replaces its servers mid-flight, puts its own back in, and keeps the renewed ones for later.
## Six releases behind
With the tunnel working against my real server, I went to the website to see how it read. And then I went through the rest of the site, and it got worse with every page.
- The **home page** described a Storage App that opens "text, hex, captures, tracks and update files". It had been showing pictures since v0.16.0 and serving the card to phones since v0.18.0.
- The **Notes** card didn't say that a note can be any size, which it can since v0.15.0.
- **Settings**, in the guide, had no VPN row. The **Status Bar** table had no `VPN`.
- There was **no how-to** for anything built since: nothing on moving files with a phone, nothing on screenshots.
- The **README** opened by calling this "a Meshtastic-compatible mesh messenger". It listens to a mesh. It has never sent a message.
- Every milestone document had a **status line** from days ago. One still said a feature was "in a pull request" that had long been merged and released.
None of this was neglect in the usual sense. Each feature *had* been documented: its own guide page, its section in the README, its design notes. That was the trap. Every pull request looked finished because the page about the new thing was there. Nobody was looking at the pages about the old things, which is where a reader starts.
Six releases went out like that.
What changed isn't a resolution to be more careful, which lasts a week. It's a list, the same one every time: the home page's cards, every Settings row, the Status Bar, a how-to for anything a person would want to do, the FAQ, the glossary, the README's opening, the status lines, a screenshot of each new screen. A feature isn't done until each of those has been asked "does the new thing show here?"
The catch-up went into the same pull request as the VPN: three new how-tos, five new screenshots, and a README that starts with what the firmware does today.
The two releases after it were documented as they were built. That's two. Ask me again at twenty.
## Nine commands for a bad network day
A VPN, addresses you can set by hand, a file server: this device now has enough networking to have network problems. And until today, the only thing it could tell you was `wifi status`.
{% table() %}
| Command | Tells you |
|---|---|
| `ping` | Does it answer, and how fast |
| `nslookup` | A name's addresses, which DNS server answered, in how long |
| `port` | A TCP port: open, refused, or silent |
| `traceroute` | The routers on the way |
| `tls` | A certificate: who for, who by, until when, and whether *this device* trusts it |
| `ntp` | A time server's clock against this one |
| `ifconfig` | The interfaces, and **which one is the default route** |
| `arp` | The neighbours on the Wi-Fi |
| `netstat` | What the device itself listens on |
{% end %}
They run in the Shell and over both consoles. The slow ones each get a task of their own and print as they go; `cancel` stops one.
{{ figure(src="shell.png", alt="The Shell at 2x with VPN in the Status Bar: ping: 1 4 ms, 2 7 ms, 3 4 ms, then ping: 3 sent, 3 back, 0% lost, 4/5/7 with ms wrapped onto the next line; then nslookup roro9stack.net, 10.9.0.1 answered in 6 ms, 65.21.233.110.", width=480, height=270, caption="The first build, in the Shell. The ping summary is one character too long for the screen and drops its `ms` onto a line of its own. It reads `3/3 back, 0% lost, 4/5/7 ms` now.") }}
Three things I like about them.
**`nslookup` asks the server itself.** The system's resolver gives you an address and nothing else. This sends its own question over UDP, so it can say *which* server answered and how long it took, and it can ask a different server to compare. When a name doesn't resolve, that's the whole diagnosis.
**`tls` checks nothing, then checks everything.** IRC, Gemini and updates all fail with "TLS error" and no way to see why. `tls` makes a handshake that accepts any certificate, so that a bad one can be looked at, and then judges it itself against the roots this device actually trusts:
{% code(caption="Four servers, four verdicts.") %}
```
tls: for git.twis.la, by YE2
tls: valid 2026-09-15 to 2026-12-14, 67 days left
tls: this device trusts it
tls: for geminiprotocol.net, by geminiprotocol.net
tls: NOT trusted here: not signed by a root this device has
tls: valid 2015-04-09 to 2015-04-12, EXPIRED 4197 days ago
tls: NOT trusted here: expired, not signed by a root this device has
tls: for *.badssl.com, by YR1
tls: NOT trusted here: not for that name
```
{% end %}
The second one is fine, by the way: a Gemini capsule signs its own certificate, and the browser trusts it on first sight.
**A ping with a size finds a tunnel's MTU.** This is the one I didn't expect to use within the hour:
{% code(caption="Through my tunnel. 1392 bytes plus 28 of headers is 1420, WireGuard's usual.") %}
```
$ ping 9.9.9.9 2 1392
ping: 2/2 back, 0% lost, 27/30/33 ms
$ ping 9.9.9.9 2 1393
ping: 0/2 back, 100% lost
```
{% end %}
One byte. And `ifconfig` on the same device, same minute:
{% code() %}
```
ifconfig: vpn 10.9.0.2/32 mtu 1420, up, default route
ifconfig: wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up
ifconfig: dns 10.9.0.1
```
{% end %}
"default route" on the `vpn` line is the answer to the first question anybody has with a VPN up.
## All of them, on the device
Typed on the Cardputer's own keyboard, in the Shell, with the tunnel up. `VPN` in the Status Bar is the tunnel; everything below went through it.
{{ figure(src="ifconfig.png", alt="The Shell: ifconfig prints vpn 10.9.0.2/32 mtu 1420, up, default route; wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up; dns 10.9.0.1.", width=480, height=270, caption="`ifconfig`. The first line answers the first question: with the tunnel up, everything leaves through it.") }}
{{ figure(src="ping.png", alt="The Shell: ping 9.9.9.9 3 prints 9.9.9.9, 56 bytes, then 1 25 ms, 2 25 ms, 3 33 ms, and 3/3 back, 0% lost, 25/27/33 ms.", width=480, height=270, caption="`ping`, with a count. The summary fits on one line now.") }}
{{ figure(src="nslookup.png", alt="The Shell: nslookup www.wikipedia.org prints 10.9.0.1 answered in 293 ms, www.wikipedia.org is dyna.wikimedia.org, and 185.15.59.224.", width=480, height=270, caption="`nslookup`. Which server answered, how long it took, the alias the name really is, and the address.") }}
{{ figure(src="port.png", alt="The Shell: port git.twis.la 443 prints 65.21.233.110:443 open, 48 ms.", width=480, height=270, caption="`port`. Open, in 48 ms. The other two answers are `refused` and nothing at all for five seconds.") }}
{{ figure(src="tls.png", alt="The Shell after tls git.twis.la: for git.twis.la, by YE2; valid 2026-09-15 to 2026-12-14, 67 days left; this device trusts it; sha256 and 64 hexadecimal digits over two lines.", width=480, height=270, caption="`tls`. The verdict, and the fingerprint that a Gemini pin is made of. The first line scrolled off: the handshake took 0.7 s.") }}
{{ figure(src="ntp.png", alt="The Shell: ntp prints pool.ntp.org (162.159.200.123), stratum 3, 23 ms away, and this clock is right, to 0.1 s.", width=480, height=270, caption="`ntp`. The clock gates TLS and the VPN, so it's worth being able to ask.") }}
{{ figure(src="netstat.png", alt="The Shell: netstat prints tcp 2323 listens (Debug Console), tcp 3232 listens (updates), tcp 172.16.42.25:2323 - 172.16.42.249:51552, udp 49974, udp 49973, udp 68 (DHCP).", width=480, height=270, caption="`netstat`. What the device offers the network right now. The one connection is the Debug Console session that pressed these keys.") }}
No picture of `traceroute` or `arp`: the first would list my provider's routers and the second my machines' hardware addresses, and neither belongs on a website. The traceroute through the tunnel was nine hops, my own server first.
## The same mistake, twice, in three hours
For the file sharing, the testable half of the code went in a file called `web_share.h`, and the service that uses it in another file called `web_share.h`, in another folder. One included the other by name and got itself. I wrote that up in [the last post](/devlog/roro9stack-share/) as one of the two things that went wrong.
For the network commands, the testable half went in `net_tools.h`, and the service in `net_tools.h`.
Same error message. Same fix. I'd like to say the second time took less long to spot.
Two smaller ones:
- **A refused connection isn't called refused.** lwIP reports it as "reset". My first `port` command told me a port on my own PC had "no route to it".
- **My test tool lied about concurrency.** The script that sends commands waits "until the console is quiet". A ping prints every second, so the console was never quiet, so my "second command while a ping runs" was sent after the ping had finished. It ran, and I briefly believed the one-at-a-time guard didn't work.
## What I didn't check
- **From far away.** My real server answered, but the device was on the server's own network, reaching it by its public name.
- **Roaming** from one Wi-Fi to another with the tunnel wanted.
- **IRC through the tunnel.**
- `tls` with IRC connected, when there shouldn't be the memory and it should say so.
- The network commands **without** the VPN: every test went through the tunnel or to the local network.
## By the numbers
{% table() %}
| | |
|---|---|
| Releases | 3 |
| Heap a WireGuard tunnel costs | 1.8 KB |
| Flash it costs | 63 KB |
| Calls into the library before the device stopped | 1 |
| Lines to fix that | 3 |
| Ways lwIP can route a packet | 2 |
| Releases that shipped with the docs behind | 6 |
| How-tos written in one sitting to catch up | 3 |
| Network commands | 9 |
| Flash they cost | 20 KB |
| Bytes between a ping that crosses my tunnel and one that doesn't | 1 |
| Times I gave two headers the same name | 2 |
| Host tests | 546 |
{% end %}
## Where it stands
{% steps() %}
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
10. ~~Pictures, a screenshot key, the card in a phone's browser.~~ v0.16.0 to v0.18.0, [Press w](/devlog/roro9stack-share/).
11. ~~A WireGuard tunnel, and the documentation caught up.~~ v0.19.0, this post.
12. ~~Nine commands for a bad network day.~~ v0.20.0 and v0.21.0, this post.
13. Next: M4, the mesh, which still wants a second node. And maybe SSH, now that there is a tunnel to reach things through.
{% end %}
{% signoff() %}
The VPN took a library, a lock and an evening. The documentation took a look. I'd spent a day shipping features to a website that described yesterday's firmware, with every pull request looking complete because its own page was there. The tunnel carries exactly 1420 bytes, and I know that because the device told me.
{% end %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+4
View File
@@ -73,6 +73,10 @@ In the Storage App, press <kbd>w</kbd>: the device serves a small web page to an
<kbd>Fn</kbd> + <kbd>p</kbd>, on any screen. The picture goes to `/screenshots` on the SD card. See [Take a screenshot](/howto/screenshot/).
## The network doesn't work: how do I find out why?
From the device itself, in the Shell: `ifconfig`, `ping`, `nslookup`, `port` and `traceroute`. [When the network doesn't work](/howto/network-check/) puts them in order.
## Do I need an SD card?
For the radio, GNSS position, Wi-Fi tools, IRC chat and Gemini browsing, no. For anything that is *kept*, yes: notes, IRC logs, Wi-Fi scan logs, GNSS Tracks, LoRa captures, saved Gemini pages and update files. See [Find your files on the SD card](/howto/sd-files/).
+18
View File
@@ -43,6 +43,24 @@ Type an App's name **with a capital letter** to open it, without going back to t
The capital is the difference: every command is in small letters, every App starts with a capital. <kbd>Tab</kbd> completes them too.
## The network
For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes; one runs at a time, and `cancel` stops it.
| Command | Tells you |
|---|---|
| `ping 10.9.0.1` | Whether a host answers, and how fast. A count and a size may follow: `ping 10.9.0.1 10 1392` |
| `nslookup roro9stack.net` | A name's addresses, which DNS server answered and in how long. Another server may follow: `nslookup roro9stack.net 9.9.9.9` |
| `port git.twis.la 443` | Whether a TCP port is **open**, **refused** (the host is there, nothing listens) or silent (down, or filtered) |
| `traceroute 9.9.9.9` | The routers on the way, one a line |
| `tls git.twis.la` | A secure connection's certificate: who it is for, who signed it, until when, and **whether this device trusts it**. A port may follow (443 if not) |
| `ntp` | A time server's clock against this device's, and how far the server is. Another server may follow |
| `ifconfig` | The interfaces (Wi-Fi, and the [VPN](/guide/vpn/) when it is up), their addresses, **which one is the default route**, and the DNS servers |
| `arp` | The neighbours heard on the Wi-Fi: is the gateway there at all |
| `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now |
A host can be a name or an address. [When the network doesn't work](/howto/network-check/) puts them in order.
## Deleting
`rm` works as it does on Unix, with one addition: it asks.
+2
View File
@@ -60,6 +60,8 @@ vpn auto on|off start with Wi-Fi
`vpn up` with a number of seconds is for trying a new configuration from a distance: if it cuts you off, it comes back by itself.
To see it work: `ifconfig` shows the tunnel and whether it is the default route, and `ping`, `nslookup`, `port` and `traceroute` go the way real traffic goes. See [When the network doesn't work](/howto/network-check/).
## Limits
One tunnel, to one server. IPv4 only: IPv6 addresses in the file are left out. The server can be an address or a name.
+1 -1
View File
@@ -1,6 +1,6 @@
+++
title = "How-tos"
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, take a screenshot, record a track, capture radio packets, and what to try when something does not work."
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work."
template = "guide-index.html"
page_template = "guide-page.html"
sort_by = "weight"
+84
View File
@@ -0,0 +1,84 @@
+++
title = "When the network doesn't work"
description = "Find out where it stops, from the device itself: the Wi-Fi, the gateway, the names, the port, the route, the tunnel."
weight = 12
[extra]
tag = "Network"
+++
Open the [Shell](/guide/shell/) and go down this list. Each step says what a good answer looks like; the first bad one is where the trouble is.
1. **`ifconfig`**: is there an address, and where does traffic go?
```
ifconfig: vpn 10.9.0.2/32 mtu 1420, up, default route
ifconfig: wifi 172.16.42.25/21 gw 172.16.42.1 mtu 1500, up
ifconfig: dns 10.9.0.1
```
No `wifi` line, or `down`: it is the Wi-Fi itself ([Settings](/guide/settings/#wi-fi)). "default route" says which way everything leaves: on `vpn`, everything goes through the tunnel.
2. **`ping` the gateway** (the `gw` address): is the local network there?
```
ping: 4/4 back, 0% lost, 3/3/4 ms
```
Nothing back: `arp` shows whether the gateway was ever heard.
3. **`ping 9.9.9.9`**: is the internet there, without names? If the gateway answers and this doesn't, the trouble is beyond your network, or in the tunnel.
4. **`nslookup roro9stack.net`**: do names resolve?
```
nslookup: 10.9.0.1 answered in 6 ms
nslookup: 65.21.233.110
```
"no answer from": that DNS server is unreachable. Try another to compare: `nslookup roro9stack.net 9.9.9.9`. If that one answers, change the servers ([DNS and NTP](/guide/settings/#dns-and-ntp)).
5. **`port <host> <port>`**: is the service there? `open` is good. `refused` means the machine is up and nothing listens on that port. "no answer" means it is down, or a firewall drops it.
6. **`traceroute <host>`**: where does it stop? The last router that answers is the last one that works.
7. **`tls <host>`**: a secure connection fails ("TLS error")? This shows the certificate and the verdict:
```
tls: git.twis.la:443 answered in 709 ms
tls: for git.twis.la, by YE2
tls: valid 2026-09-15 to 2026-12-14, 67 days left
tls: this device trusts it
```
"NOT trusted here" comes with the reason: **expired**, **not for that name**, or **not signed by a root this device has**. The last is normal for a Gemini capsule, which signs its own certificate, and a problem for an update server. It needs about 70 KB of free memory: close IRC first if it says so.
8. **`ntp`**: is the clock right? A clock that is wrong by more than a few minutes breaks secure connections and the VPN.
```
ntp: pool.ntp.org (195.72.61.39), stratum 2, 50 ms away
ntp: this clock is right, to 0.1 s
```
## What is the device itself offering?
`netstat` lists what it listens on, and who is connected:
```
netstat: tcp 3232 listens (updates)
netstat: tcp 2323 listens (Debug Console)
netstat: tcp 172.16.42.25:2323 - 172.16.42.249:51858
```
The update port is always there (updates are signed). The Debug Console and sharing appear only while you have them on.
## With the VPN up
- `ifconfig` shows the `vpn` line, and "default route" on it if everything goes through.
- `ping` the server's tunnel address first: that is the tunnel itself.
- **Finding the tunnel's real packet size:** `ping 9.9.9.9 2 1392` (1392 bytes, plus 28 of headers, is 1420: WireGuard's usual). If that comes back and 1393 doesn't, the tunnel carries 1420. If 1392 doesn't come back either, try smaller: something on the way allows less, and the server's configuration wants a lower `MTU`.
## Good to know
- `cancel` stops a `ping` or a `traceroute`.
- Some hosts and routers don't answer pings or traceroutes at all; a silent hop (`*`) in the middle of a route that goes on is normal.
- The same commands work over the [Debug Console](/dev/debug/).
+1 -1
View File
@@ -18,7 +18,7 @@ You need a WireGuard server, and a **client configuration** made on it for the C
## Check it
From another machine on the VPN, ping the Cardputer's tunnel address (the `Address` line of the file). Or on the device, in the [Shell](/guide/shell/): `vpn status`.
On the device, in the [Shell](/guide/shell/): `vpn status`, then `ifconfig` (a `vpn` line, up) and `ping` the server's tunnel address. Or from another machine on the VPN, ping the Cardputer's tunnel address (the `Address` line of the file). More in [When the network doesn't work](/howto/network-check/).
## If it stays dim
+8
View File
@@ -12,6 +12,7 @@
#include "apps/demo_app.h"
#include "apps/note_editor.h"
#include "apps/shell_app.h"
#include "services/net_tools.h"
#include "services/vpn_service.h"
#include "services/web_share.h"
#include "apps/gemini_app.h"
@@ -89,6 +90,7 @@ static IrcService* irc;
static UpdateService* update;
static DebugConsole* debugConsole;
static VpnService* vpnService; // not in Safe Mode
static NetTools netTools; // ping, nslookup and the rest (issue #90)
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -229,6 +231,7 @@ void setup() {
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); };
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
apps->registerApp({"system", "System", false,
@@ -674,6 +677,9 @@ static const char* const kHelp =
"update check | update list | update status | update install <tag> the project's releases on Gitea\n"
"sd card | sd list | cat <path> | log <text> | burst | sound on|off | short | normal\n"
"Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n"
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
"debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n"
@@ -866,6 +872,8 @@ static void runCommand(String line, bool fromSerial = false) {
else console.println("Not now: Setup is running");
return;
}
if (netTools.command(line.c_str())) return;
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
if (line == "screenshot" || line.startsWith("screenshot ")) {
+451
View File
@@ -0,0 +1,451 @@
#include "services/net_tools.h"
#include <Arduino.h>
#include <NetworkClientSecure.h>
#include <lwip/etharp.h>
#include <lwip/dns.h>
#include <lwip/netdb.h>
#include <lwip/netif.h>
#include <lwip/priv/tcp_priv.h>
#include <lwip/sockets.h>
#include <lwip/tcpip.h>
#include <lwip/udp.h>
#include <mbedtls/x509_crt.h>
#include <sys/time.h>
#include <esp_random.h>
#include <algorithm>
#include <memory>
#include "ipv4.h"
#include "net_probe.h"
#include "platform/ca_roots.h"
namespace roro {
namespace {
constexpr int kMaxHops = 20;
constexpr uint32_t kPingEveryMs = 1000, kPingWaitMs = 1000, kHopWaitMs = 2000, kPortWaitMs = 5000, kDnsWaitMs = 3000;
// A TLS handshake peaks at about 52 KB of heap; below this it isn't tried.
constexpr size_t kTlsNeedsFree = 70 * 1024;
struct LwipLock {
LwipLock() { LOCK_TCPIP_CORE(); }
~LwipLock() { UNLOCK_TCPIP_CORE(); }
};
std::string text(uint32_t networkOrder) { return net::formatIpv4(lwip_ntohl(networkOrder)); }
// A name or an address, as an address in network order. False: it doesn't resolve.
bool resolve(const std::string& host, uint32_t& out) {
uint32_t ip;
if (net::parseIpv4(host, ip)) {
out = lwip_htonl(ip);
return true;
}
struct addrinfo hints = {};
hints.ai_family = AF_INET;
struct addrinfo* found = nullptr;
if (lwip_getaddrinfo(host.c_str(), nullptr, &hints, &found) != 0 || !found) return false;
out = reinterpret_cast<struct sockaddr_in*>(found->ai_addr)->sin_addr.s_addr;
lwip_freeaddrinfo(found);
return true;
}
void waitMs(int socket, uint32_t ms) {
struct timeval tv = {static_cast<time_t>(ms / 1000), static_cast<suseconds_t>((ms % 1000) * 1000)};
lwip_setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
}
} // namespace
struct NetTools::Job {
enum class Kind { Ping, Trace, Port, Lookup, Tls, Ntp } kind;
NetTools* owner;
Console::Origin from;
net::PingArgs ping;
net::PortArgs port;
net::LookupArgs lookup;
bool stopped() const { return owner->stop_; }
// Sends one echo request and waits for what answers it. The time in ms, or -1; `from` and
// `kind` say who answered and how.
int echo(int socket, uint32_t to, uint16_t id, uint16_t seq, int size, uint32_t waitFor, uint32_t& from, net::IcmpAnswer::Kind& kind);
void runPing();
void runTrace();
void runPort();
void runLookup();
void runTls();
void runNtp();
};
int NetTools::Job::echo(int socket, uint32_t to, uint16_t id, uint16_t seq, int size, uint32_t waitFor, uint32_t& from, net::IcmpAnswer::Kind& kind) {
uint8_t packet[8 + 1400], answer[128];
size_t len = net::buildEcho(packet, sizeof packet, id, seq, static_cast<size_t>(size));
struct sockaddr_in dest = {};
dest.sin_family = AF_INET;
dest.sin_addr.s_addr = to;
uint32_t sent = micros();
if (lwip_sendto(socket, packet, len, 0, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) < 0) return -2;
while (!stopped()) {
uint32_t gone = (micros() - sent) / 1000;
if (gone >= waitFor) break;
waitMs(socket, std::min<uint32_t>(waitFor - gone, 200)); // short waits: `cancel` is noticed
struct sockaddr_in who = {};
socklen_t wholen = sizeof who;
int n = lwip_recvfrom(socket, answer, sizeof answer, 0, reinterpret_cast<struct sockaddr*>(&who), &wholen);
if (n <= 0) continue;
net::IcmpAnswer a = net::parseIcmp(answer, static_cast<size_t>(n));
if (a.kind == net::IcmpAnswer::Kind::Other || a.id != id || a.seq != seq) continue; // somebody else's
from = who.sin_addr.s_addr;
kind = a.kind;
return static_cast<int>((micros() - sent + 500) / 1000);
}
return -1;
}
void NetTools::Job::runPing() {
uint32_t to;
if (!resolve(ping.host, to)) return (void)console.printf("ping: %s doesn't resolve\n", ping.host.c_str());
int s = lwip_socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (s < 0) return (void)console.println("ping: error no socket");
console.printf("ping: %s, %d bytes\n", text(to).c_str(), ping.size);
uint16_t id = static_cast<uint16_t>(esp_random());
net::PingStats stats;
for (int seq = 1; seq <= ping.count && !stopped(); seq++) {
uint32_t started = millis(), from = 0;
net::IcmpAnswer::Kind kind = net::IcmpAnswer::Kind::Other;
stats.sent++;
int ms = echo(s, to, id, static_cast<uint16_t>(seq), ping.size, kPingWaitMs, from, kind);
if (ms == -2) console.printf("ping: %d not sent: no route, or too big\n", seq);
else if (ms < 0) console.printf("ping: %d no answer\n", seq);
else if (kind == net::IcmpAnswer::Kind::Echo) {
stats.add(static_cast<uint32_t>(ms));
console.printf("ping: %d %d ms\n", seq, ms);
} else {
console.printf("ping: %d %s says %s\n", seq, text(from).c_str(), kind == net::IcmpAnswer::Kind::TimeExceeded ? "too many hops" : "unreachable");
}
while (seq < ping.count && !stopped() && millis() - started < kPingEveryMs) delay(50);
}
lwip_close(s);
console.printf("ping: %s%s\n", stopped() ? "stopped, " : "", stats.summary().c_str());
}
// An echo request allowed one hop, then two, then three: each router that drops it says so, and
// that is the list.
void NetTools::Job::runTrace() {
uint32_t to;
if (!resolve(ping.host, to)) return (void)console.printf("traceroute: %s doesn't resolve\n", ping.host.c_str());
int s = lwip_socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (s < 0) return (void)console.println("traceroute: error no socket");
console.printf("traceroute: to %s, %d hops at most\n", text(to).c_str(), kMaxHops);
uint16_t id = static_cast<uint16_t>(esp_random());
bool arrived = false;
for (int hop = 1; hop <= kMaxHops && !stopped() && !arrived; hop++) {
int ttl = hop;
lwip_setsockopt(s, IPPROTO_IP, IP_TTL, &ttl, sizeof ttl);
uint32_t from = 0;
net::IcmpAnswer::Kind kind = net::IcmpAnswer::Kind::Other;
int ms = echo(s, to, id, static_cast<uint16_t>(hop), 32, kHopWaitMs, from, kind);
if (ms < 0) console.printf("traceroute: %2d *\n", hop);
else console.printf("traceroute: %2d %s %d ms%s\n", hop, text(from).c_str(), ms, kind == net::IcmpAnswer::Kind::Unreachable ? " unreachable" : "");
arrived = ms >= 0 && kind != net::IcmpAnswer::Kind::TimeExceeded;
}
lwip_close(s);
console.printf("traceroute: %s\n", stopped() ? "stopped" : arrived ? "arrived" : "not reached");
}
void NetTools::Job::runPort() {
uint32_t to;
if (!resolve(port.host, to)) return (void)console.printf("port: %s doesn't resolve\n", port.host.c_str());
int s = lwip_socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) return (void)console.println("port: error no socket");
lwip_fcntl(s, F_SETFL, lwip_fcntl(s, F_GETFL, 0) | O_NONBLOCK);
struct sockaddr_in dest = {};
dest.sin_family = AF_INET;
dest.sin_port = lwip_htons(port.port);
dest.sin_addr.s_addr = to;
uint32_t started = millis();
int error = lwip_connect(s, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) == 0 ? 0 : errno;
bool answered = error == 0;
while (error == EINPROGRESS && !answered && !stopped() && millis() - started < kPortWaitMs) {
fd_set writable, failed;
FD_ZERO(&writable);
FD_ZERO(&failed);
FD_SET(s, &writable);
FD_SET(s, &failed);
struct timeval tv = {0, 200000};
if (lwip_select(s + 1, nullptr, &writable, &failed, &tv) > 0) {
socklen_t len = sizeof error;
lwip_getsockopt(s, SOL_SOCKET, SO_ERROR, &error, &len);
answered = true;
}
}
uint32_t ms = millis() - started;
lwip_close(s);
std::string where = text(to) + ":" + std::to_string(port.port);
if (answered && error == 0) console.printf("port: %s open, %lu ms\n", where.c_str(), (unsigned long)ms);
else if (answered && (error == ECONNREFUSED || error == ECONNRESET)) console.printf("port: %s refused, %lu ms: the host is there, nothing listens\n", where.c_str(), (unsigned long)ms);
else if (answered || error != EINPROGRESS) console.printf("port: %s no route to it (error %d)\n", where.c_str(), error);
else if (stopped()) console.println("port: stopped");
else console.printf("port: %s no answer in %lu s: down, or filtered\n", where.c_str(), (unsigned long)(kPortWaitMs / 1000));
}
// Asks one server directly, so that the answer says which server and how long, which the
// system's own resolver doesn't.
void NetTools::Job::runLookup() {
uint32_t server = 0;
if (!lookup.server.empty()) resolve(lookup.server, server);
else {
LwipLock lock;
const ip_addr_t* first = dns_getserver(0);
if (first && IP_IS_V4(first)) server = ip_2_ip4(first)->addr;
}
if (!server) return (void)console.println("nslookup: no DNS server is set");
int s = lwip_socket(AF_INET, SOCK_DGRAM, 0);
if (s < 0) return (void)console.println("nslookup: error no socket");
uint8_t query[300], answer[512];
uint16_t id = static_cast<uint16_t>(esp_random());
size_t len = net::buildDnsQuery(query, sizeof query, id, lookup.name);
struct sockaddr_in dest = {};
dest.sin_family = AF_INET;
dest.sin_port = lwip_htons(53);
dest.sin_addr.s_addr = server;
uint32_t started = millis();
net::DnsAnswer result;
bool got = false;
if (len && lwip_sendto(s, query, len, 0, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) >= 0) {
while (!got && !stopped() && millis() - started < kDnsWaitMs) {
waitMs(s, 200);
int n = lwip_recv(s, answer, sizeof answer, 0);
if (n > 0) got = net::parseDnsAnswer(answer, static_cast<size_t>(n), id, result);
}
}
uint32_t ms = millis() - started;
lwip_close(s);
if (!got) return (void)console.printf("nslookup: no answer from %s in %lu s\n", text(server).c_str(), (unsigned long)(kDnsWaitMs / 1000));
console.printf("nslookup: %s answered in %lu ms\n", text(server).c_str(), (unsigned long)ms);
if (!result.alias.empty()) console.printf("nslookup: %s is %s\n", lookup.name.c_str(), result.alias.c_str());
for (uint32_t ip : result.addresses) console.printf("nslookup: %s\n", net::formatIpv4(ip).c_str());
if (result.rcode == 3) console.printf("nslookup: there is no %s\n", lookup.name.c_str());
else if (result.rcode) console.printf("nslookup: the server refused (code %d)\n", result.rcode);
else if (result.addresses.empty()) console.printf("nslookup: %s has no IPv4 address%s\n", lookup.name.c_str(), result.truncated ? " in a first packet" : "");
}
// A handshake that checks nothing, to see the certificate whatever it is; then the certificate is
// checked here, against this device's own roots and the name asked for, and the answer is said in
// words. It is what the Update Service's connection would have decided.
void NetTools::Job::runTls() {
if (ESP.getFreeHeap() < kTlsNeedsFree)
return (void)console.printf("tls: not enough memory (%u KB free, %u needed): close IRC or a Gemini page\n", (unsigned)(ESP.getFreeHeap() / 1024),
(unsigned)(kTlsNeedsFree / 1024));
NetworkClientSecure tls;
tls.setInsecure();
uint32_t started = millis();
if (!tls.connect(port.host.c_str(), port.port, 8000)) {
char why[100] = "";
tls.lastError(why, sizeof why);
return (void)console.printf("tls: no handshake with %s:%u in %lu ms: %s\n", port.host.c_str(), (unsigned)port.port, (unsigned long)(millis() - started),
why[0] ? why : "no connection");
}
console.printf("tls: %s:%u answered in %lu ms\n", port.host.c_str(), (unsigned)port.port, (unsigned long)(millis() - started));
const mbedtls_x509_crt* cert = tls.getPeerCertificate();
if (!cert) {
tls.stop();
return (void)console.println("tls: it showed no certificate");
}
char dn[200];
std::string subject = mbedtls_x509_dn_gets(dn, sizeof dn, &cert->subject) > 0 ? net::certName(dn) : "?";
std::string issuer = mbedtls_x509_dn_gets(dn, sizeof dn, &cert->issuer) > 0 ? net::certName(dn) : "?";
console.printf("tls: for %s, by %s\n", subject.c_str(), issuer.c_str());
const mbedtls_x509_time& from = cert->valid_from;
const mbedtls_x509_time& to = cert->valid_to;
time_t now = time(nullptr);
struct tm today;
gmtime_r(&now, &today);
bool clock = today.tm_year + 1900 >= 2024;
int left = net::daysBetween(today.tm_year + 1900, today.tm_mon + 1, today.tm_mday, to.year, to.mon, to.day);
console.printf("tls: valid %04d-%02d-%02d to %04d-%02d-%02d", from.year, from.mon, from.day, to.year, to.mon, to.day);
if (!clock) console.println(" (this clock isn't set)");
else if (left >= 0) console.printf(", %d days left\n", left);
else console.printf(", EXPIRED %d days ago\n", -left);
mbedtls_x509_crt roots;
mbedtls_x509_crt_init(&roots);
uint32_t flags = 0;
bool parsed = mbedtls_x509_crt_parse(&roots, reinterpret_cast<const unsigned char*>(kTrustedRootsPem), sizeof kTrustedRootsPem) == 0;
int verdict = parsed ? mbedtls_x509_crt_verify(const_cast<mbedtls_x509_crt*>(cert), &roots, nullptr, port.host.c_str(), &flags, nullptr, nullptr) : -1;
mbedtls_x509_crt_free(&roots);
if (verdict == 0) console.println("tls: this device trusts it");
else {
std::string why;
if ((flags & MBEDTLS_X509_BADCERT_EXPIRED) || (clock && left < 0)) why += ", expired";
if (flags & MBEDTLS_X509_BADCERT_FUTURE) why += ", not valid yet";
if (flags & MBEDTLS_X509_BADCERT_CN_MISMATCH) why += ", not for that name";
if (flags & MBEDTLS_X509_BADCERT_NOT_TRUSTED) why += ", not signed by a root this device has";
if (why.empty()) why = ", it doesn't check out";
console.printf("tls: NOT trusted here: %s\n", why.c_str() + 2);
}
uint8_t sha[32];
if (tls.getFingerprintSHA256(sha)) {
char hex[65];
for (int i = 0; i < 32; i++) std::snprintf(hex + i * 2, 3, "%02x", sha[i]);
console.printf("tls: sha256 %s\n", hex);
}
tls.stop();
}
// Asks a time server and compares with this device's clock, allowing for half the round trip.
void NetTools::Job::runNtp() {
uint32_t to;
if (!resolve(port.host, to)) return (void)console.printf("ntp: %s doesn't resolve\n", port.host.c_str());
int s = lwip_socket(AF_INET, SOCK_DGRAM, 0);
if (s < 0) return (void)console.println("ntp: error no socket");
uint8_t packet[net::kNtpPacket];
net::buildNtpRequest(packet);
struct sockaddr_in dest = {};
dest.sin_family = AF_INET;
dest.sin_port = lwip_htons(123);
dest.sin_addr.s_addr = to;
uint32_t sent = micros();
net::NtpAnswer answer;
bool got = false;
struct timeval own = {};
if (lwip_sendto(s, packet, sizeof packet, 0, reinterpret_cast<struct sockaddr*>(&dest), sizeof dest) >= 0) {
while (!got && !stopped() && (micros() - sent) / 1000 < kDnsWaitMs) {
waitMs(s, 200);
int n = lwip_recv(s, packet, sizeof packet, 0);
if (n <= 0) continue;
gettimeofday(&own, nullptr);
got = net::parseNtpAnswer(packet, static_cast<size_t>(n), answer);
}
}
uint32_t tripMs = (micros() - sent) / 1000;
lwip_close(s);
if (!got) return (void)console.printf("ntp: no answer from %s (%s) in %lu s\n", port.host.c_str(), text(to).c_str(), (unsigned long)(kDnsWaitMs / 1000));
console.printf("ntp: %s (%s), stratum %d, %lu ms away\n", port.host.c_str(), text(to).c_str(), answer.stratum, (unsigned long)tripMs);
int64_t ownMs = static_cast<int64_t>(own.tv_sec) * 1000 + own.tv_usec / 1000, serverMs = answer.seconds * 1000 + answer.millis + tripMs / 2;
if (own.tv_sec < 1700000000) console.println("ntp: this clock isn't set");
else console.printf("ntp: this clock is %s\n", net::clockOffset(ownMs, serverMs).c_str());
}
void NetTools::task(void* arg) {
Job* job = static_cast<Job*>(arg);
{
Console::As as(job->from); // the lines go to the console that asked (the Shell shows only its own)
switch (job->kind) {
case Job::Kind::Ping: job->runPing(); break;
case Job::Kind::Trace: job->runTrace(); break;
case Job::Kind::Port: job->runPort(); break;
case Job::Kind::Lookup: job->runLookup(); break;
case Job::Kind::Tls: job->runTls(); break;
case Job::Kind::Ntp: job->runNtp(); break;
}
}
NetTools* owner = job->owner;
delete job;
owner->busy_ = false;
vTaskDelete(nullptr);
}
void NetTools::start(Job* job) {
job->owner = this;
job->from = console.origin();
stop_ = false;
busy_ = true;
// A TLS handshake needs far more stack than a ping.
if (xTaskCreate(task, "nettool", job->kind == Job::Kind::Tls ? 12288 : 6144, job, 1, nullptr) != pdPASS) {
busy_ = false;
delete job;
console.println("net: error not enough memory for it");
}
}
bool NetTools::command(const std::string& line) {
size_t space = line.find(' ');
std::string name = line.substr(0, space), args = space == std::string::npos ? "" : line.substr(space + 1);
if (name == "ifconfig") {
LwipLock lock;
for (struct netif* n = netif_list; n; n = n->next) {
if (n->name[0] == 'l' && n->name[1] == 'o') continue;
const char* label = n->name[0] == 's' && n->name[1] == 't' ? "wifi" : n->name[0] == 'w' && n->name[1] == 'g' ? "vpn" : nullptr;
char raw[4] = {n->name[0], n->name[1], static_cast<char>('0' + n->num % 10), 0};
bool up = netif_is_up(n) && netif_is_link_up(n);
console.printf("ifconfig: %s %s/%d", label ? label : raw, text(netif_ip4_addr(n)->addr).c_str(), __builtin_popcount(netif_ip4_netmask(n)->addr));
if (netif_ip4_gw(n)->addr) console.printf(" gw %s", text(netif_ip4_gw(n)->addr).c_str());
console.printf(" mtu %u, %s%s\n", (unsigned)n->mtu, up ? "up" : "down", n == netif_default ? ", default route" : "");
}
std::string servers;
for (u8_t i = 0; i < DNS_MAX_SERVERS; i++) {
const ip_addr_t* d = dns_getserver(i);
if (d && IP_IS_V4(d) && ip_2_ip4(d)->addr) servers += " " + text(ip_2_ip4(d)->addr);
}
console.printf("ifconfig: dns%s\n", servers.empty() ? " none" : servers.c_str());
return true;
}
if (name == "arp") {
LwipLock lock;
int found = 0;
for (size_t i = 0; i < ARP_TABLE_SIZE; i++) {
ip4_addr_t* ip;
struct netif* nif;
struct eth_addr* mac;
if (!etharp_get_entry(i, &ip, &nif, &mac)) continue;
found++;
console.printf("arp: %-15s %02x:%02x:%02x:%02x:%02x:%02x\n", text(ip->addr).c_str(), mac->addr[0], mac->addr[1], mac->addr[2], mac->addr[3], mac->addr[4],
mac->addr[5]);
}
if (!found) console.println("arp: nobody heard yet on this network");
return true;
}
if (name == "netstat") {
LwipLock lock;
for (struct tcp_pcb_listen* p = tcp_listen_pcbs.listen_pcbs; p; p = p->next) {
const char* label = net::portLabel(p->local_port, true);
console.printf("netstat: tcp %u listens%s%s%s\n", (unsigned)p->local_port, *label ? " (" : "", label, *label ? ")" : "");
}
for (struct tcp_pcb* p = tcp_active_pcbs; p; p = p->next) {
std::string local = IP_IS_V4(&p->local_ip) ? text(ip_2_ip4(&p->local_ip)->addr) : "::", remote = IP_IS_V4(&p->remote_ip) ? text(ip_2_ip4(&p->remote_ip)->addr) : "::";
console.printf("netstat: tcp %s:%u - %s:%u\n", local.c_str(), (unsigned)p->local_port, remote.c_str(), (unsigned)p->remote_port);
}
for (struct udp_pcb* p = udp_pcbs; p; p = p->next) {
const char* label = net::portLabel(p->local_port, false);
console.printf("netstat: udp %u%s%s%s\n", (unsigned)p->local_port, *label ? " (" : "", label, *label ? ")" : "");
}
return true;
}
if (name != "ping" && name != "traceroute" && name != "port" && name != "nslookup" && name != "tls" && name != "ntp") return false;
std::unique_ptr<Job> job(new Job());
std::string why;
if (name == "ping") {
job->kind = Job::Kind::Ping;
why = net::parsePing(args, job->ping);
} else if (name == "traceroute") {
job->kind = Job::Kind::Trace;
why = net::parsePing(args, job->ping);
if (!why.empty() || args.find(' ') != std::string::npos) why = "traceroute <host>";
} else if (name == "port") {
job->kind = Job::Kind::Port;
why = net::parsePort(args, job->port);
} else if (name == "tls") { // the port may be left out: 443
job->kind = Job::Kind::Tls;
bool onlyHost = !args.empty() && args.find(' ') == std::string::npos && args.find(':') == std::string::npos;
why = net::parsePort(onlyHost ? args + " 443" : args, job->port);
if (!why.empty() && why.find("port <") == 0) why = "tls <host> [port]";
} else if (name == "ntp") { // the server may be left out: the first one in use
job->kind = Job::Kind::Ntp;
job->port.host = !args.empty() ? args : ntpServer ? ntpServer() : "";
if (job->port.host.empty() || !net::validHost(job->port.host)) why = "ntp [server]";
} else {
job->kind = Job::Kind::Lookup;
why = net::parseLookup(args, job->lookup);
}
if (!why.empty()) return console.printf("%s: %s\n", name.c_str(), why.c_str()), true;
if (busy_) return console.printf("%s: another one is running: `cancel` stops it\n", name.c_str()), true;
start(job.release());
return true;
}
} // namespace roro
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <atomic>
#include <functional>
#include <string>
#include "platform/console.h"
namespace roro {
// The network troubleshooting commands (issue #90): ping, nslookup, port, traceroute, tls, ntp,
// and ifconfig, arp, netstat. The first six take time, so each runs on a task of its own and
// prints its lines as they come, to the console that asked; one at a time, and `cancel` stops it.
// The other three answer at once. The packets and their meaning are lib/net/src/net_probe.h, which is host-tested.
class NetTools {
public:
// True if `line` was one of its commands (answered, started, or refused with a reason).
bool command(const std::string& line);
bool busy() const { return busy_; }
void cancel() { stop_ = true; }
// The time server `ntp` asks when none is named: the first one in Settings.
std::function<std::string()> ntpServer;
private:
struct Job;
void start(Job* job);
static void task(void* arg);
std::atomic<bool> busy_{false}, stop_{false};
};
} // namespace roro
+216
View File
@@ -0,0 +1,216 @@
#include <unity.h>
#include <string>
#include "ipv4.h"
#include "net_probe.h"
using namespace roro::net;
void setUp() {}
void tearDown() {}
void test_what_was_typed() {
PingArgs p;
TEST_ASSERT_EQUAL_STRING("", parsePing("example.org", p).c_str());
TEST_ASSERT_EQUAL_STRING("example.org", p.host.c_str());
TEST_ASSERT_EQUAL_INT(4, p.count);
TEST_ASSERT_EQUAL_INT(56, p.size);
TEST_ASSERT_EQUAL_STRING("", parsePing(" 10.9.0.1 10 1372 ", p).c_str());
TEST_ASSERT_EQUAL_STRING("10.9.0.1", p.host.c_str());
TEST_ASSERT_EQUAL_INT(10, p.count);
TEST_ASSERT_EQUAL_INT(1372, p.size);
TEST_ASSERT_EQUAL_STRING("ping <host> [count] [size]", parsePing("", p).c_str());
TEST_ASSERT_EQUAL_STRING("a count from 1 to 100", parsePing("a.example 0", p).c_str());
TEST_ASSERT_EQUAL_STRING("a count from 1 to 100", parsePing("a.example lots", p).c_str());
TEST_ASSERT_EQUAL_STRING("a size from 0 to 1400 bytes", parsePing("a.example 4 9000", p).c_str());
TEST_ASSERT_EQUAL_INT(10, p.count); // a refused line changes nothing
PortArgs t;
TEST_ASSERT_EQUAL_STRING("", parsePort("irc.libera.chat 6697", t).c_str());
TEST_ASSERT_EQUAL_STRING("irc.libera.chat", t.host.c_str());
TEST_ASSERT_EQUAL_UINT16(6697, t.port);
TEST_ASSERT_EQUAL_STRING("", parsePort("10.9.0.1:2323", t).c_str());
TEST_ASSERT_EQUAL_UINT16(2323, t.port);
TEST_ASSERT_EQUAL_STRING("port <host> <port>", parsePort("10.9.0.1", t).c_str());
TEST_ASSERT_EQUAL_STRING("a port from 1 to 65535", parsePort("10.9.0.1 70000", t).c_str());
LookupArgs l;
TEST_ASSERT_EQUAL_STRING("", parseLookup("roro9stack.net", l).c_str());
TEST_ASSERT_EQUAL_STRING("", l.server.c_str());
TEST_ASSERT_EQUAL_STRING("", parseLookup("roro9stack.net 9.9.9.9", l).c_str());
TEST_ASSERT_EQUAL_STRING("9.9.9.9", l.server.c_str());
TEST_ASSERT_EQUAL_STRING("the server as an address: 9.9.9.9", parseLookup("roro9stack.net dns.quad9.net", l).c_str());
}
void test_an_echo_request_and_its_answers() {
uint8_t echo[64];
size_t len = buildEcho(echo, sizeof echo, 0xBEEF, 7, 32);
TEST_ASSERT_EQUAL_size_t(40, len);
TEST_ASSERT_EQUAL_UINT8(8, echo[0]);
TEST_ASSERT_EQUAL_UINT16(0, inetChecksum(echo, len)); // a packet with its checksum in sums to nothing
TEST_ASSERT_EQUAL_size_t(0, buildEcho(echo, 20, 1, 1, 32));
// The reply, as a raw socket hands it: an IP header, then the same with type 0.
uint8_t reply[20 + 40] = {0x45, 0, 0, 60, 0, 0, 0, 0, 64, 1};
std::copy(echo, echo + len, reply + 20);
reply[20] = 0;
IcmpAnswer a = parseIcmp(reply, sizeof reply);
TEST_ASSERT_TRUE(a.kind == IcmpAnswer::Kind::Echo);
TEST_ASSERT_EQUAL_HEX16(0xBEEF, a.id);
TEST_ASSERT_EQUAL_UINT16(7, a.seq);
// A router on the way: "time exceeded", with the start of our packet inside it.
uint8_t exceeded[20 + 8 + 20 + 8] = {0x45, 0, 0, 56, 0, 0, 0, 0, 250, 1};
exceeded[20] = 11;
uint8_t* inner = exceeded + 28;
inner[0] = 0x45;
inner[9] = 1;
std::copy(echo, echo + 8, inner + 20);
a = parseIcmp(exceeded, sizeof exceeded);
TEST_ASSERT_TRUE(a.kind == IcmpAnswer::Kind::TimeExceeded);
TEST_ASSERT_EQUAL_HEX16(0xBEEF, a.id);
TEST_ASSERT_EQUAL_UINT16(7, a.seq);
exceeded[20] = 3;
TEST_ASSERT_TRUE(parseIcmp(exceeded, sizeof exceeded).kind == IcmpAnswer::Kind::Unreachable);
// Not ours to read: someone else's ping to us, a cut packet, UDP.
TEST_ASSERT_TRUE(parseIcmp(reply, 22).kind == IcmpAnswer::Kind::Other);
reply[20] = 8;
TEST_ASSERT_TRUE(parseIcmp(reply, sizeof reply).kind == IcmpAnswer::Kind::Other);
reply[20] = 0;
reply[9] = 17;
TEST_ASSERT_TRUE(parseIcmp(reply, sizeof reply).kind == IcmpAnswer::Kind::Other);
TEST_ASSERT_TRUE(parseIcmp(exceeded, 40).kind == IcmpAnswer::Kind::Other);
}
void test_the_summary() {
PingStats s;
s.sent = 4;
TEST_ASSERT_EQUAL_STRING("0/4 back, 100% lost", s.summary().c_str());
s.add(23);
s.add(12);
s.add(41);
TEST_ASSERT_EQUAL_STRING("3/4 back, 25% lost, 12/25/41 ms", s.summary().c_str());
}
void test_a_dns_query() {
uint8_t q[64];
size_t len = buildDnsQuery(q, sizeof q, 0x1234, "roro9stack.net");
const uint8_t want[] = {0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 10, 'r', 'o', 'r', 'o', '9', 's', 't', 'a', 'c', 'k', 3, 'n', 'e', 't', 0, 0, 1, 0, 1};
TEST_ASSERT_EQUAL_size_t(sizeof want, len);
TEST_ASSERT_EQUAL_UINT8_ARRAY(want, q, sizeof want);
TEST_ASSERT_EQUAL_size_t(len, buildDnsQuery(q, sizeof q, 0x1234, "roro9stack.net.")); // a final dot is the same name
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, sizeof q, 1, ""));
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, sizeof q, 1, "a..b"));
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, sizeof q, 1, std::string(64, 'a') + ".net"));
TEST_ASSERT_EQUAL_size_t(0, buildDnsQuery(q, 20, 1, "roro9stack.net"));
}
void test_a_dns_answer() {
// www.example.org is an alias of example.org, which has two addresses. Names after the first
// are pointers back into the message, as servers send them.
const uint8_t msg[] = {
0x12, 0x34, 0x81, 0x80, 0, 1, 0, 3, 0, 0, 0, 0,
3, 'w', 'w', 'w', 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'o', 'r', 'g', 0, 0, 1, 0, 1, // the question, at 12
0xC0, 12, 0, 5, 0, 1, 0, 0, 1, 0, 0, 2, 0xC0, 16, // CNAME -> example.org (pointer to 16)
0xC0, 16, 0, 1, 0, 1, 0, 0, 1, 0, 0, 4, 93, 184, 216, 34,
0xC0, 16, 0, 1, 0, 1, 0, 0, 1, 0, 0, 4, 93, 184, 216, 35,
};
DnsAnswer a;
TEST_ASSERT_TRUE(parseDnsAnswer(msg, sizeof msg, 0x1234, a));
TEST_ASSERT_EQUAL_INT(0, a.rcode);
TEST_ASSERT_FALSE(a.truncated);
TEST_ASSERT_EQUAL_size_t(2, a.addresses.size());
TEST_ASSERT_EQUAL_STRING("93.184.216.34", formatIpv4(a.addresses[0]).c_str());
TEST_ASSERT_EQUAL_STRING("93.184.216.35", formatIpv4(a.addresses[1]).c_str());
TEST_ASSERT_EQUAL_STRING("example.org", a.alias.c_str());
TEST_ASSERT_FALSE(parseDnsAnswer(msg, sizeof msg, 0x9999, a)); // someone else's answer
for (size_t cut = 0; cut < sizeof msg; cut++) parseDnsAnswer(msg, cut, 0x1234, a); // cut anywhere: no crash
TEST_ASSERT_FALSE(parseDnsAnswer(msg, sizeof msg - 3, 0x1234, a));
uint8_t none[sizeof msg];
std::copy(msg, msg + sizeof msg, none);
none[3] = 0x83; // no such name
none[7] = 0;
TEST_ASSERT_TRUE(parseDnsAnswer(none, 33, 0x1234, a));
TEST_ASSERT_EQUAL_INT(3, a.rcode);
TEST_ASSERT_EQUAL_size_t(0, a.addresses.size());
// A pointer that points at itself must not hang the reader.
uint8_t loop[] = {0x12, 0x34, 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0, 0xC0, 12, 0, 1, 0, 1};
TEST_ASSERT_FALSE(parseDnsAnswer(loop, sizeof loop, 0x1234, a));
}
void test_ntp() {
uint8_t req[kNtpPacket];
buildNtpRequest(req);
TEST_ASSERT_EQUAL_HEX8(0x23, req[0]);
TEST_ASSERT_EQUAL_UINT8(0, req[47]);
// A server's answer: stratum 2, transmit time 2026-10-08 00:45:12.5 UTC.
uint8_t ans[kNtpPacket] = {0x24, 2};
uint32_t secs = 1791420312u + 2208988800u;
ans[40] = static_cast<uint8_t>(secs >> 24);
ans[41] = static_cast<uint8_t>(secs >> 16);
ans[42] = static_cast<uint8_t>(secs >> 8);
ans[43] = static_cast<uint8_t>(secs);
ans[44] = 0x80; // half a second
NtpAnswer a;
TEST_ASSERT_TRUE(parseNtpAnswer(ans, sizeof ans, a));
TEST_ASSERT_EQUAL_INT(2, a.stratum);
TEST_ASSERT_TRUE(a.seconds == 1791420312);
TEST_ASSERT_EQUAL_UINT32(500, a.millis);
TEST_ASSERT_FALSE(parseNtpAnswer(ans, 40, a)); // cut short
ans[1] = 0;
TEST_ASSERT_FALSE(parseNtpAnswer(ans, sizeof ans, a)); // "go away"
ans[1] = 2;
ans[0] = 0x23;
TEST_ASSERT_FALSE(parseNtpAnswer(ans, sizeof ans, a)); // a client's packet, not a server's
// After 2036 the count starts again from zero.
ans[0] = 0x24;
ans[40] = ans[41] = ans[42] = 0;
ans[43] = 10;
TEST_ASSERT_TRUE(parseNtpAnswer(ans, sizeof ans, a));
TEST_ASSERT_TRUE(a.seconds == 4294967296LL + 10 - 2208988800LL);
TEST_ASSERT_EQUAL_STRING("right, to 0.1 s", clockOffset(1000000, 1000040).c_str());
TEST_ASSERT_EQUAL_STRING("0.3 s ahead", clockOffset(1000300, 1000000).c_str());
TEST_ASSERT_EQUAL_STRING("2.5 s behind", clockOffset(1000000, 1002500).c_str());
TEST_ASSERT_EQUAL_STRING("45 s behind", clockOffset(0, 45000).c_str());
TEST_ASSERT_EQUAL_STRING("3 min ahead", clockOffset(180000, 0).c_str());
TEST_ASSERT_EQUAL_STRING("5 h behind", clockOffset(0, 18000000).c_str());
TEST_ASSERT_EQUAL_STRING("20552 days behind", clockOffset(0, 1775700000000LL).c_str()); // a clock still in 1970
}
void test_certificates_and_ports() {
TEST_ASSERT_EQUAL_STRING("R11", certName("C=US, O=Let's Encrypt, CN=R11").c_str());
TEST_ASSERT_EQUAL_STRING("git.twis.la", certName("CN=git.twis.la").c_str());
TEST_ASSERT_EQUAL_STRING("Example Org", certName("C=BE, O=Example Org").c_str());
TEST_ASSERT_EQUAL_STRING("C=BE", certName("C=BE").c_str());
TEST_ASSERT_EQUAL_STRING("x", certName("O=Not this, DCN=nor this, CN=x").c_str());
TEST_ASSERT_EQUAL_STRING("*.badssl.com", certName("OU=PositiveSSL Wildcard, CN=#140C2A2E62616473736C2E636F6D").c_str()); // an old kind of string
TEST_ASSERT_EQUAL_STRING("#14zz", certName("CN=#14zz").c_str());
TEST_ASSERT_EQUAL_INT(1, daysBetween(2026, 10, 7, 2026, 10, 8));
TEST_ASSERT_EQUAL_INT(53, daysBetween(2026, 10, 8, 2026, 11, 30));
TEST_ASSERT_EQUAL_INT(-8, daysBetween(2026, 10, 8, 2026, 9, 30));
TEST_ASSERT_EQUAL_INT(366, daysBetween(2028, 1, 1, 2029, 1, 1)); // a leap year
TEST_ASSERT_EQUAL_INT(365, daysBetween(2100, 1, 1, 2101, 1, 1)); // and a year that isn't one
TEST_ASSERT_EQUAL_STRING("updates", portLabel(3232, true));
TEST_ASSERT_EQUAL_STRING("Debug Console", portLabel(2323, true));
TEST_ASSERT_EQUAL_STRING("sharing", portLabel(80, true));
TEST_ASSERT_EQUAL_STRING("", portLabel(80, false));
TEST_ASSERT_EQUAL_STRING("DHCP", portLabel(68, false));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_what_was_typed);
RUN_TEST(test_an_echo_request_and_its_answers);
RUN_TEST(test_the_summary);
RUN_TEST(test_a_dns_query);
RUN_TEST(test_a_dns_answer);
RUN_TEST(test_ntp);
RUN_TEST(test_certificates_and_ports);
return UNITY_END();
}