From 9808013fc0029404a12e2837c1a3f166cf78dedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Thu, 8 Oct 2026 02:54:59 +0200 Subject: [PATCH] Shell: tls, ntp and netstat (#90) The rest of the issue's list. tls makes a handshake that checks nothing, then says the certificate in words: who it is for, who signed it, until when, and whether this device's roots and the name asked for accept it, with the reason when they don't. ntp compares a time server's clock with the device's. netstat lists what listens and what is connected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT --- README.md | 7 +- docs/milestones/N1.md | 22 +++- lib/net/src/net_probe.cpp | 73 +++++++++++++ lib/net/src/net_probe.h | 27 +++++ site/content/dev/debug/commands.md | 6 +- site/content/dev/milestones/n1.md | 22 +++- site/content/guide/shell.md | 5 +- site/content/howto/network-check.md | 30 ++++++ src/main.cpp | 4 +- src/services/net_tools.cpp | 142 ++++++++++++++++++++++++- src/services/net_tools.h | 11 +- test/test_net_probe/test_net_probe.cpp | 62 +++++++++++ 12 files changed, 395 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 054d3b8..7246681 100644 --- a/README.md +++ b/README.md @@ -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, including `ping`, `nslookup`, `port`, `traceroute` and `ifconfig`. +- **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. **For the network:** `ping`, `nslookup`, `port`, `traceroute`, `ifconfig` and `arp` (issue #90). +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 @@ -242,7 +242,8 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `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 [count] [size]` / `nslookup [server]` / `port ` / `traceroute ` / `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 | -| `ifconfig` / `arp` | 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 | +| `tls [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 ` / `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 | diff --git a/docs/milestones/N1.md b/docs/milestones/N1.md index 0d8fbe3..cf5a27f 100644 --- a/docs/milestones/N1.md +++ b/docs/milestones/N1.md @@ -1,6 +1,6 @@ # N1 — Network tools -**Status:** in progress. The WireGuard tunnel (issue #8) shipped as **v0.19.0**. The first network troubleshooting commands (issue #90) shipped as **v0.20.0**: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig`, `arp`; `tls`, `ntp` and `netstat` are still to do. 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` after it. SSH (#2) is not started. **Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours. @@ -121,3 +121,23 @@ With a tunnel, fixed addresses and a file server on the device, "is it the netwo **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 [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. diff --git a/lib/net/src/net_probe.cpp b/lib/net/src/net_probe.cpp index 1e0580b..c54871a 100644 --- a/lib/net/src/net_probe.cpp +++ b/lib/net/src/net_probe.cpp @@ -224,4 +224,77 @@ bool parseDnsAnswer(const uint8_t* m, size_t len, uint16_t id, DnsAnswer& out) { 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(p[40]) << 24) | (p[41] << 16) | (p[42] << 8) | p[43]; + uint32_t frac = (static_cast(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(secs) + 4294967296LL : static_cast(secs); + out.stratum = p[1]; + out.seconds = since1900 - k1900To1970; + out.millis = static_cast((static_cast(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(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(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 diff --git a/lib/net/src/net_probe.h b/lib/net/src/net_probe.h index 45800bb..aa3401a 100644 --- a/lib/net/src/net_probe.h +++ b/lib/net/src/net_probe.h @@ -68,4 +68,31 @@ struct DnsAnswer { // 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 diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 7924219..f79a40f 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -40,7 +40,8 @@ update check | update list | update status | update install the project' sd card | sd list | cat | log | 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 [count] [size] | nslookup [server] | port | traceroute | cancel is it there, does its name resolve, is its port open, which way; one at a time -ifconfig | arp the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard +tls [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 @@ -113,7 +114,8 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `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 [count] [size]` / `nslookup [server]` / `port ` / `traceroute ` / `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 | -| `ifconfig` / `arp` | 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 | +| `tls [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 ` / `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 | diff --git a/site/content/dev/milestones/n1.md b/site/content/dev/milestones/n1.md index 600e84c..9cfbbf8 100644 --- a/site/content/dev/milestones/n1.md +++ b/site/content/dev/milestones/n1.md @@ -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**. The first network troubleshooting commands (issue #90) shipped as **v0.20.0**: `ping`, `nslookup`, `port`, `traceroute`, `ifconfig`, `arp`; `tls`, `ntp` and `netstat` are still to do. 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` after it. SSH (#2) is not started. **Goal:** reach things from the device that aren't on the Wi-Fi it happens to be on, and keep its traffic private on a network that isn't yours. @@ -129,3 +129,23 @@ With a tunnel, fixed addresses and a file server on the device, "is it the netwo **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 [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. diff --git a/site/content/guide/shell.md b/site/content/guide/shell.md index 4cb2e06..5d7010c 100644 --- a/site/content/guide/shell.md +++ b/site/content/guide/shell.md @@ -45,7 +45,7 @@ The capital is the difference: every command is in small letters, every App star ## The network -For the day the network doesn't do what it should. Each of the first four takes a moment and prints as it goes; one runs at a time, and `cancel` stops it. +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 | |---|---| @@ -53,8 +53,11 @@ For the day the network doesn't do what it should. Each of the first four takes | `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. diff --git a/site/content/howto/network-check.md b/site/content/howto/network-check.md index 89336f1..25c15aa 100644 --- a/site/content/howto/network-check.md +++ b/site/content/howto/network-check.md @@ -41,6 +41,36 @@ Open the [Shell](/guide/shell/) and go down this list. Each step says what a goo 6. **`traceroute `**: where does it stop? The last router that answers is the last one that works. +7. **`tls `**: 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. diff --git a/src/main.cpp b/src/main.cpp index 76c4d3d..8afeff8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -231,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, @@ -677,7 +678,8 @@ static const char* const kHelp = "sd card | sd list | cat | log | 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 [count] [size] | nslookup [server] | port | traceroute | cancel is it there, does its name resolve, is its port open, which way; one at a time\n" - "ifconfig | arp the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard\n" + "tls [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" diff --git a/src/services/net_tools.cpp b/src/services/net_tools.cpp index fe5fbea..716c1e8 100644 --- a/src/services/net_tools.cpp +++ b/src/services/net_tools.cpp @@ -2,12 +2,18 @@ #include +#include + #include #include #include #include +#include #include #include +#include +#include +#include #include @@ -16,12 +22,15 @@ #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(); } @@ -53,7 +62,7 @@ void waitMs(int socket, uint32_t ms) { } // namespace struct NetTools::Job { - enum class Kind { Ping, Trace, Port, Lookup } kind; + enum class Kind { Ping, Trace, Port, Lookup, Tls, Ntp } kind; NetTools* owner; Console::Origin from; net::PingArgs ping; @@ -68,6 +77,8 @@ struct NetTools::Job { 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) { @@ -223,6 +234,103 @@ void NetTools::Job::runLookup() { 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(kTrustedRootsPem), sizeof kTrustedRootsPem) == 0; + int verdict = parsed ? mbedtls_x509_crt_verify(const_cast(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(&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(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(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(arg); { @@ -232,6 +340,8 @@ void NetTools::task(void* arg) { 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; @@ -245,7 +355,8 @@ void NetTools::start(Job* job) { job->from = console.origin(); stop_ = false; busy_ = true; - if (xTaskCreate(task, "nettool", 6144, job, 1, nullptr) != pdPASS) { + // 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"); @@ -289,7 +400,23 @@ bool NetTools::command(const std::string& line) { if (!found) console.println("arp: nobody heard yet on this network"); return true; } - if (name != "ping" && name != "traceroute" && name != "port" && name != "nslookup") return false; + 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(new Job()); std::string why; if (name == "ping") { @@ -302,6 +429,15 @@ bool NetTools::command(const std::string& line) { } 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 [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); diff --git a/src/services/net_tools.h b/src/services/net_tools.h index f81e03a..69323e0 100644 --- a/src/services/net_tools.h +++ b/src/services/net_tools.h @@ -1,22 +1,25 @@ #pragma once #include +#include #include #include "platform/console.h" namespace roro { -// The network troubleshooting commands (issue #90): ping, nslookup, port, traceroute, ifconfig, -// arp. The first four 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 two answer at -// once. The packets and their meaning are lib/net/src/net_probe.h, which is host-tested. +// 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 ntpServer; private: struct Job; diff --git a/test/test_net_probe/test_net_probe.cpp b/test/test_net_probe/test_net_probe.cpp index 60daeee..8d9d784 100644 --- a/test/test_net_probe/test_net_probe.cpp +++ b/test/test_net_probe/test_net_probe.cpp @@ -143,6 +143,66 @@ void test_a_dns_answer() { 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(secs >> 24); + ans[41] = static_cast(secs >> 16); + ans[42] = static_cast(secs >> 8); + ans[43] = static_cast(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); @@ -150,5 +210,7 @@ int main() { 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(); }