One firmware: the Debug Console in every build, off until switched on, with the device's own token
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s

There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

Over USB serial only: debug on, debug token <value>, debug token new.
scripts/flash.sh --debug uses them to set a device up with the developer's
token. scripts/rdbg.py takes the token from -t, $RORO_DEBUG_TOKEN or the
file, answers the challenge, and fetches a release's ELF to decode a crash.

Gone: the cardputer-adv-debug environment, RORO_DEBUG, the +debug version,
scripts/debug_flags.py, update install ... force, and the rule that a Debug
Build doesn't install releases. Old clients and old firmwares don't talk to
each other.

Against the builds it replaces: 30 KB more flash and 88 bytes more static
RAM than the release, 4 KB less RAM than the Debug Build. 468 host tests.
Checked on the device: off by default, login, the pause after wrong tokens,
Safe Mode with the console, the setting surviving an update, debug off.

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-06 22:59:35 +02:00
co-authored by Claude Opus 5.5
parent 1353e6a5f9
commit c68741cc46
65 changed files with 1245 additions and 374 deletions
+100 -36
View File
@@ -1,4 +1,3 @@
#ifdef RORO_DEBUG
#include "services/debug_console.h"
@@ -7,6 +6,7 @@
#include <algorithm>
#include <esp_core_dump.h>
#include <esp_flash.h>
#include <esp_random.h>
#include <SD.h>
#include <unistd.h>
@@ -29,14 +29,6 @@ constexpr uint32_t kAuthTimeoutMs = 10000;
constexpr size_t kMaxLine = 240;
constexpr size_t kMaxQueued = 8;
// Compares without stopping at the first difference, so timing says nothing about the token.
bool sameToken(const std::string& a, const char* b) {
size_t n = strlen(b);
uint8_t diff = a.size() != n;
for (size_t i = 0; i < n; i++) diff |= (i < a.size() ? a[i] : 0) ^ b[i];
return diff == 0;
}
// Reads one line (without its \r\n) within `timeoutMs`; false on a timeout, a drop or an overlong line.
bool readLine(NetworkClient& c, std::string& line, uint32_t timeoutMs) {
line.clear();
@@ -77,12 +69,40 @@ void sendCoreDump(NetworkClient& client) {
} // namespace
DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage)
: wifi_(wifi), storage_(storage), lock_(xSemaphoreCreateMutex()) {}
DebugConsole::DebugConsole(WifiService& wifi, StorageService& storage, Settings& settings)
: wifi_(wifi), storage_(storage), settings_(settings), lock_(xSemaphoreCreateMutex()) {}
void DebugConsole::start() {
std::string DebugConsole::freshToken() {
uint8_t random[debug::kTokenRandom];
esp_fill_random(random, sizeof random); // the hardware generator: true random with the radio on
return debug::makeToken(random);
}
void DebugConsole::switchOn(Settings& settings) {
if (settings.getString(Setting::DebugToken).empty()) settings.setString(Setting::DebugToken, freshToken());
settings.setBool(Setting::DebugConsole, true);
}
void DebugConsole::tick(uint32_t) { apply(); }
// The main loop's side. The task frees what it holds and clears task_ when it sees wanted_ go:
// until then a new one isn't started, so switching off and on again quickly takes a tick or two.
void DebugConsole::apply() {
const std::string& token = settings_.getString(Setting::DebugToken);
bool want = settings_.getBool(Setting::DebugConsole) && !token.empty(); // no token, nobody could get in: stay closed
xSemaphoreTake(lock_, portMAX_DELAY);
if (token != token_) {
token_ = token;
tokenSeq_ = tokenSeq_ + 1;
}
xSemaphoreGive(lock_);
wanted_ = want;
if (!want || task_) return;
if (!console.openRing()) return (void)console.println("debug: no memory for the console");
console.captureEspLogs();
if (!task_) xTaskCreate(taskEntry, "debug", 6144, this, 1, &task_);
TaskHandle_t made = nullptr;
if (xTaskCreate(taskEntry, "debug", 6144, this, 1, &made) == pdPASS) task_ = made;
else console.closeRing();
}
bool DebugConsole::takeCommand(std::string& line) {
@@ -96,38 +116,82 @@ bool DebugConsole::takeCommand(std::string& line) {
return any;
}
bool DebugConsole::takeAlert(std::string& text) {
xSemaphoreTake(lock_, portMAX_DELAY);
bool any = !alert_.empty();
if (any) text = std::move(alert_);
alert_.clear();
xSemaphoreGive(lock_);
return any;
}
void DebugConsole::taskEntry(void* self) { static_cast<DebugConsole*>(self)->listen(); }
void DebugConsole::listen() {
NetworkServer server(kPort);
bool listening = false;
for (;;) {
bool up = wifi_.state() == WifiController::State::Connected;
if (up && !listening) {
server.begin();
listening = true;
} else if (!up && listening) {
server.end();
listening = false;
}
if (listening) {
Counted<NetworkClient> client(server.accept(), net::User::DebugConsole);
if (client) {
client.setNoDelay(true);
if (authenticate(client)) serve(client);
client.stop();
{
NetworkServer server(kPort);
bool listening = false;
while (wanted_) {
bool up = wifi_.state() == WifiController::State::Connected;
if (up && !listening) {
server.begin();
listening = true;
} else if (!up && listening) {
server.end();
listening = false;
}
if (listening) {
Counted<NetworkClient> client(server.accept(), net::User::DebugConsole);
if (client) {
client.setNoDelay(true);
if (authenticate(client)) serve(client);
client.stop();
}
}
vTaskDelay(pdMS_TO_TICKS(200));
}
vTaskDelay(pdMS_TO_TICKS(200));
if (listening) server.end();
}
// Switched off: nothing is left behind (Q189). The commands a client queued die with it.
xSemaphoreTake(lock_, portMAX_DELAY);
commands_.clear();
xSemaphoreGive(lock_);
console.closeRing();
task_ = nullptr;
vTaskDelete(nullptr);
}
// The token never crosses the network (Q193): the device sends 16 random bytes, the client sends
// back their HMAC-SHA256 keyed by the token. A recorded answer is no use for the next challenge.
bool DebugConsole::authenticate(NetworkClient& client) {
std::string token;
if (readLine(client, token, kAuthTimeoutMs) && sameToken(token, RORO_DEBUG_TOKEN)) return true;
console.printf("debug: refused a client from %s\n", client.remoteIP().toString().c_str());
if (gate_.locked(millis())) {
client.print("locked\n");
return false;
}
xSemaphoreTake(lock_, portMAX_DELAY);
std::string token = token_;
xSemaphoreGive(lock_);
uint8_t nonce[debug::kNonceBytes];
esp_fill_random(nonce, sizeof nonce);
client.printf("%s debug console, challenge %s\n", kProductName, debug::toHex(nonce, sizeof nonce).c_str());
std::string answer;
bool answered = readLine(client, answer, kAuthTimeoutMs);
if (answered && !token.empty() && debug::sameText(answer, debug::answerFor(token, nonce))) {
gate_.succeeded();
return true;
}
std::string from = client.remoteIP().toString().c_str();
console.printf("debug: refused a client from %s\n", from.c_str());
delay(1000); // no quick retries
client.print("denied\n");
if (answered && gate_.failed(millis())) { // a connection that says nothing isn't a guess
console.printf("debug: %d wrong tokens in a row, closed for %lu s\n", debug::AuthGate::kMaxFailures,
(unsigned long)(debug::AuthGate::kLockMs / 1000));
xSemaphoreTake(lock_, portMAX_DELAY);
alert_ = "Console closed, wrong tokens: " + from; // an Event's text holds 47 characters
xSemaphoreGive(lock_);
}
return false;
}
@@ -135,10 +199,11 @@ void DebugConsole::serve(NetworkClient& client) {
console.printf("debug: client %s connected\n", client.remoteIP().toString().c_str());
client.printf("%s %s debug console. 'help' lists the commands. Backlog follows.\n", kProductName, versionString());
connected_ = true;
uint32_t tokenSeq = tokenSeq_;
uint32_t pos = console.oldest();
uint8_t buf[512];
std::string line;
while (client.connected()) {
while (client.connected() && wanted_ && tokenSeq == tokenSeq_) { // switched off, or a new token: out
// Console output since last time, including the replies to this client's commands.
uint32_t skipped = 0;
size_t n;
@@ -313,4 +378,3 @@ void DebugConsole::screenshot(NetworkClient& client) {
} // namespace roro
#endif