feat: BearSSL transport (RU_USE_BEARSSL) + RuApp::appUpdaterBegin wrapper (v1.3.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 19:06:58 +02:00
co-authored by Claude Opus 4.8
parent 59add9eac7
commit ed158d2f26
6 changed files with 397 additions and 11 deletions
+2
View File
@@ -12,3 +12,5 @@ pendingReboot KEYWORD2
ruVersionParse KEYWORD2 ruVersionParse KEYWORD2
ruVersionCompare KEYWORD2 ruVersionCompare KEYWORD2
ruParseLatestRelease KEYWORD2 ruParseLatestRelease KEYWORD2
appUpdaterBegin KEYWORD2
appUpdaterPendingReboot KEYWORD2
+29 -8
View File
@@ -1,12 +1,33 @@
{ {
"name": "ReleaseUpdater", "name": "ReleaseUpdater",
"version": "1.1.0", "version": "1.3.0",
"description": "Self-update an ESP32 from GitHub/Gitea releases: version compare, asset selection, HTTPUpdate flashing, and an optional background service loop with idle-gate and UI/TLS hooks.", "description": "Self-update an ESP32 from GitHub/Gitea releases: version compare, asset selection, HTTPUpdate flashing, and an optional background service loop with idle-gate and UI/TLS hooks. Optional RU_USE_BEARSSL transport (ESP_SSLClient, PSRAM TLS buffers) for memory-tight devices.",
"keywords": ["esp32", "ota", "github", "gitea", "release", "self-update"], "keywords": [
"authors": [{ "name": "xPablo.cz", "url": "https://git.xpablo.cz/xPablo.cz" }], "esp32",
"ota",
"github",
"gitea",
"release",
"self-update"
],
"authors": [
{
"name": "xPablo.cz",
"url": "https://git.xpablo.cz/xPablo.cz"
}
],
"license": "MIT", "license": "MIT",
"frameworks": "arduino", "frameworks": "arduino",
"platforms": ["espressif32"], "platforms": [
"dependencies": [{ "name": "ArduinoJson", "version": "^7.0.0" }], "espressif32"
"build": { "libArchive": false } ],
} "dependencies": [
{
"name": "ArduinoJson",
"version": "^7.0.0"
}
],
"build": {
"libArchive": false
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
name=ReleaseUpdater name=ReleaseUpdater
version=1.1.0 version=1.3.0
author=xPablo.cz author=xPablo.cz
maintainer=xPablo.cz <https://git.xpablo.cz/xPablo.cz> maintainer=xPablo.cz <https://git.xpablo.cz/xPablo.cz>
sentence=Self-update an ESP32 from GitHub/Gitea releases. sentence=Self-update an ESP32 from GitHub/Gitea releases.
+225 -2
View File
@@ -1,6 +1,227 @@
/** @file ReleaseUpdater.cpp — ESP-only implementation (guarded so the native build skips it). */ /** @file ReleaseUpdater.cpp — ESP-only implementation (guarded so the native build skips it).
*
* Two TLS transports:
* - default: WiFiClientSecure (mbedTLS) + HTTPClient/HTTPUpdate. Simple, but mbedTLS keeps its ~16 KB
* RX/TX record buffers in INTERNAL RAM as two contiguous blocks — too much for a heavily loaded,
* fragmented device.
* - RU_USE_BEARSSL: ESP_SSLClient (BearSSL) with RX/TX buffers in PSRAM + a hand-rolled HTTP client
* that streams the firmware straight into Update. Frees internal RAM (matches this codebase's
* IcsClient pattern). Used on memory-tight devices (e.g. WorkspaceBuddy/InfoAssist).
*/
#include "ReleaseUpdater.hpp" #include "ReleaseUpdater.hpp"
#if defined(ARDUINO) #if defined(ARDUINO)
#if defined(RU_USE_BEARSSL)
// ================= BearSSL + PSRAM transport =================
#include <WiFi.h>
#include <WiFiClient.h>
#include <ESP_SSLClient.h>
#include <Update.h>
#include <esp_heap_caps.h>
#include <functional>
#include "ru_ca_roots.h"
// PSRAM TLS buffer sizes. BearSSL advertises Maximum Fragment Length (MFLN) derived from the RX buffer,
// so a smaller RX means smaller TLS records when the server supports MFLN — override with -D on
// memory-critical builds. The buffers themselves live in PSRAM, so the default is comfortable.
#ifndef RU_BEARSSL_RX
# define RU_BEARSSL_RX 4096
#endif
#ifndef RU_BEARSSL_TX
# define RU_BEARSSL_TX 2048
#endif
namespace {
struct RuUrl { String host; String path; uint16_t port; bool https; };
bool ruParseUrl(const String& url, RuUrl& out) {
int s;
if (url.startsWith("https://")) { out.https = true; out.port = 443; s = 8; }
else if (url.startsWith("http://")) { out.https = false; out.port = 80; s = 7; }
else return false;
const int slash = url.indexOf('/', s);
String hostport = slash < 0 ? url.substring(s) : url.substring(s, slash);
out.path = slash < 0 ? "/" : url.substring(slash);
const int colon = hostport.indexOf(':');
if (colon < 0) { out.host = hostport; }
else { out.host = hostport.substring(0, colon); out.port = (uint16_t) hostport.substring(colon + 1).toInt(); }
return out.host.length() > 0;
}
/** Read one CRLF-terminated line (without the trailing CRLF). false on timeout. */
bool ruReadLine(Client& c, String& out, uint32_t toMs) {
out = "";
const uint32_t t0 = millis();
while (millis() - t0 < toMs) {
const int ch = c.read();
if (ch < 0) { delay(1); continue; }
if (ch == '\n') { if (out.endsWith("\r")) out.remove(out.length() - 1); return true; }
out += (char) ch;
if (out.length() > 4096) return true; // header safety cap
}
return false;
}
/** Stream exactly `want` bytes from `c` to `sink`. Returns true iff all bytes were delivered. */
bool ruReadExactToSink(Client& c, size_t want, const std::function<bool(const uint8_t*, size_t)>& sink,
uint32_t toMs) {
static uint8_t buf[1460];
size_t got = 0;
uint32_t last = millis();
while (got < want) {
const size_t chunk = (want - got) < sizeof(buf) ? (want - got) : sizeof(buf);
const int n = c.read(buf, chunk);
if (n <= 0) {
if (millis() - last > toMs) return false;
delay(1);
continue;
}
last = millis();
if (!sink(buf, (size_t) n)) return false;
got += (size_t) n;
}
return true;
}
/** Stream a chunked-transfer-encoded body to `sink` (hex size line, data, CRLF, ... until 0). */
bool ruReadChunkedToSink(Client& c, const std::function<bool(const uint8_t*, size_t)>& sink, uint32_t toMs) {
for (;;) {
String szline;
if (!ruReadLine(c, szline, toMs)) return false;
const int semi = szline.indexOf(';'); // strip any chunk extension
if (semi >= 0) szline = szline.substring(0, semi);
szline.trim();
const long sz = strtol(szline.c_str(), nullptr, 16);
if (sz < 0) return false;
if (sz == 0) { String trailer; ruReadLine(c, trailer, toMs); return true; } // final CRLF/trailers
if (!ruReadExactToSink(c, (size_t) sz, sink, toMs)) return false;
String crlf;
if (!ruReadLine(c, crlf, toMs)) return false; // CRLF after the chunk data
}
}
/**
* @brief Hand-rolled HTTPS GET over BearSSL (PSRAM buffers, CA-validated). Follows up to 5 redirects.
* On a 200 with Content-Length, calls onBegin(len) once, then streams the body to onData().
* @return true on a fully-delivered 200 body; false otherwise (err set).
*/
bool ruBsslGet(const String& startUrl, const RuConfig& cfg,
const std::function<bool(long)>& onBegin,
const std::function<bool(const uint8_t*, size_t)>& onData,
String& err) {
String url = startUrl;
for (int hop = 0; hop <= 5; ++hop) {
RuUrl u;
if (!ruParseUrl(url, u)) { err = "bad url"; return false; }
WiFiClient base;
ESP_SSLClient ssl;
ssl.setClient(&base);
if (u.https) ssl.setCACert(RU_CA_ROOTS); // validate the chain (Let's-Encrypt/ISRG etc.)
ssl.setBufferSizes(RU_BEARSSL_RX, RU_BEARSSL_TX); // PSRAM IO buffers; RX size drives MFLN
ssl.setTimeout(20000);
if (!ssl.connect(u.host.c_str(), u.port)) { err = "TLS connect failed"; ssl.stop(); return false; }
// keep-alive: with Connection: close BearSSL can lose the final record to the close_notify race
// (diagnosed in IcsClient). We read exactly Content-Length bytes and stop() ourselves.
ssl.print("GET "); ssl.print(u.path); ssl.print(" HTTP/1.1\r\n");
ssl.print("Host: "); ssl.print(u.host); ssl.print("\r\n");
ssl.print("User-Agent: "); ssl.print(cfg.userAgent); ssl.print("\r\n");
ssl.print("Accept: application/octet-stream, application/vnd.github+json, */*\r\n");
ssl.print("Accept-Encoding: identity\r\nConnection: keep-alive\r\n\r\n");
String line;
if (!ruReadLine(ssl, line, 20000) || line.length() < 12) { err = "no status line"; ssl.stop(); return false; }
const int sp = line.indexOf(' ');
const int code = sp > 0 ? line.substring(sp + 1, sp + 4).toInt() : 0;
String location;
long contentLength = -1;
bool chunked = false;
for (;;) {
if (!ruReadLine(ssl, line, 20000)) { err = "header read failed"; ssl.stop(); return false; }
if (line.length() == 0) break;
String lower = line; lower.toLowerCase();
if (lower.startsWith("location:")) location = line.substring(9);
else if (lower.startsWith("content-length:")) contentLength = strtol(line.substring(15).c_str(), nullptr, 10);
else if (lower.startsWith("transfer-encoding:") && lower.indexOf("chunked") >= 0) chunked = true;
}
if (code >= 300 && code < 400 && location.length()) {
ssl.stop();
location.trim();
url = location.startsWith("http") ? location : ("https://" + u.host + location);
continue; // follow redirect (fresh TLS session)
}
if (code != 200) { err = "HTTP " + String(code); ssl.stop(); return false; }
if (!chunked && contentLength < 0) { err = "no content-length"; ssl.stop(); return false; }
// onBegin needs a size to reserve the OTA partition; the API (check) is chunked with no
// onBegin, but a firmware asset download is always Content-Length framed, so this is safe.
if (onBegin && !onBegin(contentLength)) { err = "begin rejected"; ssl.stop(); return false; }
const bool ok = chunked ? ruReadChunkedToSink(ssl, onData, 20000)
: ruReadExactToSink(ssl, (size_t) contentLength, onData, 20000);
ssl.stop();
if (!ok) { err = "short read"; return false; }
return true;
}
err = "too many redirects";
return false;
}
} // namespace
bool ReleaseUpdater::check(RuRelease& rel, String& err) {
// Accumulate the (small) releases/latest JSON into a PSRAM buffer, then parse.
constexpr size_t CAP = 12 * 1024;
char* body = static_cast<char*>(heap_caps_malloc(CAP, MALLOC_CAP_SPIRAM));
if (!body) body = static_cast<char*>(heap_caps_malloc(CAP, MALLOC_CAP_8BIT));
if (!body) { err = "check: alloc failed"; return false; }
size_t len = 0;
auto sink = [&](const uint8_t* d, size_t n) -> bool {
if (len + n >= CAP) n = CAP - 1 - len; // truncate defensively; tag_name/assets are near the top
memcpy(body + len, d, n); len += n;
return true;
};
const bool ok = ruBsslGet(_latestUrl(), _cfg, nullptr, sink, err);
if (ok) body[len] = '\0';
bool parsed = false;
if (ok) parsed = ruParseLatestRelease(body, _cfg.assetToken.c_str(), rel);
heap_caps_free(body);
if (!ok) return false;
if (!parsed) { err = "parse failed"; return false; }
return true;
}
bool ReleaseUpdater::downloadAndFlash(const RuRelease& rel, String& err) {
if (rel.assetUrl[0] == '\0') { err = "no asset for this board"; return false; }
bool begun = false;
size_t total = 0, written = 0;
auto onBegin = [&](long len) -> bool {
if (len <= 0) return false;
total = (size_t) len;
if (!Update.begin(total)) return false; // reserves the OTA partition (verifies size fits)
begun = true;
return true;
};
auto onData = [&](const uint8_t* d, size_t n) -> bool {
if (Update.write(const_cast<uint8_t*>(d), n) != n) return false;
written += n;
if (_progress) _progress(written, total);
return true;
};
const bool ok = ruBsslGet(String(rel.assetUrl), _cfg, onBegin, onData, err);
if (!ok) { if (begun) Update.abort(); return false; }
if (!Update.end(true)) { // finalises + verifies the image before switching
err = "Update.end failed (" + String(Update.getError()) + ")";
return false;
}
return true; // caller reboots
}
#else // !RU_USE_BEARSSL
// ================= WiFiClientSecure (mbedTLS) + HTTPClient/HTTPUpdate transport =================
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
#include <WiFiClient.h> #include <WiFiClient.h>
#include <HTTPClient.h> #include <HTTPClient.h>
@@ -86,4 +307,6 @@ bool ReleaseUpdater::downloadAndFlash(const RuRelease& rel, String& err) {
err = "update failed: " + httpUpdate.getLastErrorString() + " (" + String(httpUpdate.getLastError()) + ")"; err = "update failed: " + httpUpdate.getLastErrorString() + " (" + String(httpUpdate.getLastError()) + ")";
return false; // running firmware unchanged (HTTPUpdate verifies before switching) return false; // running firmware unchanged (HTTPUpdate verifies before switching)
} }
#endif // ARDUINO
#endif // RU_USE_BEARSSL
#endif // ARDUINO
+81
View File
@@ -0,0 +1,81 @@
/** @file RuAppUpdater.cpp — reusable wrapper over ReleaseUpdaterService (see AppUpdater.hpp). */
#include "RuAppUpdater.hpp"
#if defined(ARDUINO)
#include <cstdio>
namespace RuApp {
namespace {
ReleaseUpdaterService s_service;
/** Fire the app log sink with a printf-formatted "[ota] ..." line, if a sink is set. */
void logf(const AppHooks& h, LogLevel lvl, const char* fmt, ...) {
if (!h.log) return;
char buf[128];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
va_end(ap);
h.log(lvl, buf);
}
}
void appUpdaterBegin(const AppConfig& cfg, const AppHooks& hooks) {
if (!cfg.enabled) { logf(hooks, LogLevel::Warning, "[ota] disabled"); return; }
if (!cfg.owner || cfg.owner[0] == '\0' || !cfg.repo || cfg.repo[0] == '\0') {
logf(hooks, LogLevel::Warning, "[ota] enabled but owner/repo empty -> idle");
return;
}
RuServiceConfig sc;
sc.ru.apiBase = cfg.apiBase;
sc.ru.owner = cfg.owner;
sc.ru.repo = cfg.repo;
sc.ru.assetToken = cfg.assetToken;
sc.ru.current = cfg.current;
sc.ru.userAgent = cfg.userAgent;
sc.intervalMin = static_cast<uint32_t>(cfg.intervalMin);
sc.idleMaxWaitS = cfg.idleMaxWaitS;
RuServiceHooks h;
if (hooks.idleReady) h.idleReady = hooks.idleReady;
if (hooks.withNetGuard) h.withNetGuard = hooks.withNetGuard;
// Standard lifecycle handler: log + drive the optional overlay. Captures the app hooks by value.
h.onState = [hooks](const RuStateEvent& e) {
switch (e.state) {
case RuState::UpToDate:
logf(hooks, LogLevel::Debug, "[ota] up to date (%s)", e.tag);
break;
case RuState::NewFound:
logf(hooks, LogLevel::Info, "[ota] new release %s -> waiting for idle", e.tag);
break;
case RuState::Installing:
logf(hooks, LogLevel::Info, "[ota] installing %s", e.tag);
if (hooks.overlayShow) hooks.overlayShow(e.tag);
break;
case RuState::Downloading:
if (e.pct % 10 == 0) logf(hooks, LogLevel::Debug, "[ota] %d%%", e.pct);
if (hooks.overlayProgress) hooks.overlayProgress(e.pct);
break;
case RuState::Flashed:
logf(hooks, LogLevel::Info, "[ota] flashed %s; rebooting", e.tag);
if (hooks.overlayDone) hooks.overlayDone();
break;
case RuState::Error:
logf(hooks, LogLevel::Error, "[ota] error: %s", e.err);
if (hooks.overlayHide) hooks.overlayHide();
break;
default:
break;
}
};
s_service.begin(sc, h);
logf(hooks, LogLevel::Info, "[ota] task started (repo %s/%s, every %d min)",
cfg.owner, cfg.repo, cfg.intervalMin);
}
bool appUpdaterPendingReboot() { return s_service.pendingReboot(); }
} // namespace RuApp
#endif // ARDUINO
+59
View File
@@ -0,0 +1,59 @@
/**
* @file RuAppUpdater.hpp
* @brief Reusable application wrapper over ReleaseUpdaterService. Encapsulates the boilerplate every
* project repeated by hand: assemble the RuServiceConfig from plain config values, and install a
* standard onState handler that logs the lifecycle (English, "[ota] ...") and drives an optional
* full-screen progress overlay. The project supplies only its config values + a few hooks (idle
* predicate, TLS guard, overlay callbacks, log sink), so there is one implementation instead of
* one copy per firmware.
*/
#pragma once
#include "ReleaseUpdaterService.hpp"
#if defined(ARDUINO)
#include <Arduino.h>
#include <functional>
namespace RuApp {
/** Log severity passed to AppHooks::log (maps to the app's own trace levels). */
enum class LogLevel { Debug = 0, Info = 1, Warning = 2, Error = 3 };
/** Plain config values, read by the app from wherever it stores them (struct, key/value, ...). */
struct AppConfig {
bool enabled = false; ///< Master on/off.
const char* apiBase = "https://api.github.com"; ///< Releases API base (scheme picks transport).
const char* owner = ""; ///< Repo owner/org.
const char* repo = ""; ///< Repo name.
int intervalMin = 60; ///< Release-check period (minutes).
const char* assetToken = ""; ///< Board token (HW_VARIANT) used to pick the asset.
RuVersion current = {}; ///< Running firmware version (SW_VERSION_*).
const char* userAgent = "ESP32-OTA"; ///< HTTP User-Agent.
uint32_t idleMaxWaitS = 30u * 60u; ///< Cap on waiting for idleReady() before flashing.
};
/**
* @brief App-supplied hooks. Every field is optional; unset overlay/log hooks are simply not called,
* an unset idleReady means "always ready", and an unset withNetGuard runs the flash directly.
*/
struct AppHooks {
std::function<bool()> idleReady; ///< true when it is OK to install now.
std::function<void(const std::function<void()>&)> withNetGuard; ///< run body under the app's TLS lock.
std::function<void(const char* tag)> overlayShow; ///< show the install overlay (release tag).
std::function<void(int pct)> overlayProgress; ///< update the progress bar (0..100).
std::function<void()> overlayDone; ///< mark flashed (about to reboot).
std::function<void()> overlayHide; ///< remove the overlay (on error).
std::function<void(LogLevel, const char*)> log; ///< lifecycle log sink (e.g. TRACE).
};
/**
* @brief Assemble the service config + the standard onState (log + overlay dispatch) and start the
* background updater task. No-op (logs via hooks.log) if disabled or owner/repo are empty.
* Idempotent — a second call is ignored. Call once after the GUI, TLS gate and config are ready.
*/
void appUpdaterBegin(const AppConfig& cfg, const AppHooks& hooks);
/** @return true once an image has been flashed (the task reboots shortly after). */
bool appUpdaterPendingReboot();
} // namespace RuApp
#endif // ARDUINO