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
This commit is contained in:
2026-10-08 02:54:59 +02:00
co-authored by Claude Opus 5.5
parent 9b6457d1ec
commit 9808013fc0
12 changed files with 395 additions and 16 deletions
+3 -1
View File
@@ -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 <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"
"ifconfig | arp the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard\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"
+139 -3
View File
@@ -2,12 +2,18 @@
#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>
@@ -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<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);
{
@@ -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> 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 <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);
+7 -4
View File
@@ -1,22 +1,25 @@
#pragma once
#include <atomic>
#include <functional>
#include <string>
#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<std::string()> ntpServer;
private:
struct Job;