Updates from Gitea, step 2: the connection (check and list work on the device)

The roots (ISRG X1 and X2), an HTTPS client that reads the answer as a
stream, GiteaReleases (the latest and a list of ten), the Update
Service's requests, install from Gitea through the existing install path,
the daily check's schedule, the Check for updates setting, and console
commands: update check | list | status | install <tag>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 15:20:58 +02:00
co-authored by Claude Sonnet 5.5
parent b0e8226943
commit 5754ae5b57
12 changed files with 616 additions and 3 deletions
+62
View File
@@ -0,0 +1,62 @@
#pragma once
namespace roro {
// The roots this device trusts for what it fetches over HTTPS (docs/milestones/R1.md, Q162): the two
// ISRG roots Let's Encrypt chains end in, not the framework's bundle of about 130 CAs. Public
// certificates, from https://letsencrypt.org/certs/ (SHA-256 fingerprints below).
//
// ISRG Root X1 RSA 4096 valid until 2035-06-04
// 96:BC:EC:06:26:49:76:F3:74:60:77:9A:CF:28:C5:A7:CF:E8:A3:C0:AA:E1:1A:8F:FC:EE:05:C0:BD:DF:08:C6
// ISRG Root X2 ECDSA P-384 valid until 2040-09-17
// 69:72:9B:8E:15:A8:6E:FC:17:7A:57:AF:B7:17:1D:FC:64:AD:D2:8C:2F:CA:8C:F1:50:7E:34:45:3C:CB:14:70
//
// If the server's CA ever changes, the next firmware has to carry the new root and come from the PC.
inline constexpr char kTrustedRootsPem[] =
"-----BEGIN CERTIFICATE-----\n"
"MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
"TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n"
"cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n"
"WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n"
"ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n"
"MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n"
"h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n"
"0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n"
"A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n"
"T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n"
"B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n"
"B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n"
"KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n"
"OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n"
"jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n"
"qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n"
"rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n"
"HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n"
"hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n"
"ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n"
"3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n"
"NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n"
"ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n"
"TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n"
"jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n"
"oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n"
"4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n"
"mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n"
"emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n"
"-----END CERTIFICATE-----\n"
"-----BEGIN CERTIFICATE-----\n"
"MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n"
"CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n"
"R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n"
"MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n"
"ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n"
"EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n"
"+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n"
"ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n"
"AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n"
"zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n"
"tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n"
"/q4AaOeMSQ+2b1tbFfLn\n"
"-----END CERTIFICATE-----\n";
} // namespace roro
+112
View File
@@ -0,0 +1,112 @@
#include "platform/https_get.h"
#include <Arduino.h>
#include <esp_heap_caps.h>
#include <algorithm>
#include <ctime>
#include "platform/ca_roots.h"
#include "version.h"
namespace roro {
namespace {
constexpr size_t kFloor = 55 * 1024; // Q86, Q172: no connection below this
constexpr time_t kClockSetAfter = 1700000000; // 2023-11: anything earlier is the clock's default
// What mbedTLS says in the way it says it, for the cases a person can act on.
std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
char text[100] = "";
int err = tls.lastError(text, sizeof text);
std::string detail = text;
if (detail.find("X509") != std::string::npos || detail.find("certificate") != std::string::npos)
return "The certificate of " + host + " isn't accepted";
if (err != 0 && !detail.empty()) return "TLS to " + host + ": " + detail;
return "Can't connect to " + host;
}
} // namespace
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
close();
if (time(nullptr) < kClockSetAfter) return "The clock isn't set yet: certificates can't be checked";
if (esp_get_free_heap_size() < kFloor) return "Not enough memory: close IRC or a Gemini page";
tls_.setCACert(kTrustedRootsPem);
tls_.setTimeout(15);
if (!tls_.connect(host.c_str(), 443)) return whyNotConnected(tls_, host);
raw_.reset(new (std::nothrow) uint8_t[kRaw]);
if (!raw_) return "Not enough memory";
tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() +
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n")
.c_str());
release::HttpHeadParser parser;
while (!parser.complete()) {
int n = readRaw(raw_.get(), kRaw);
if (n <= 0) return "The server " + host + " stopped answering";
size_t used = parser.feed(reinterpret_cast<const char*>(raw_.get()), n);
if (parser.failed()) return host + " didn't answer with HTTP";
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used);
}
head_ = parser.head();
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
left_ = head_.chunked ? -1 : head_.contentLength;
return "";
}
int HttpsGet::readRaw(uint8_t* into, size_t len) {
uint32_t since = millis();
while (!tls_.available()) {
if (!tls_.connected()) return 0;
if (millis() - since > kStallMs) return -1;
delay(5);
}
return tls_.read(into, len);
}
int HttpsGet::read(uint8_t* buf, size_t len) {
if (done_ || len == 0) return 0;
if (!head_.chunked && left_ == 0) return done_ = true, 0;
for (;;) {
// Raw bytes: first those that came with the head, then the connection's.
size_t want = head_.chunked ? std::min(len, kRaw) : len;
if (!head_.chunked && left_ > 0) want = std::min<size_t>(want, left_);
int n;
if (earlyAt_ < early_.size()) {
n = static_cast<int>(std::min(want, early_.size() - earlyAt_));
std::copy(early_.data() + earlyAt_, early_.data() + earlyAt_ + n, head_.chunked ? raw_.get() : buf);
earlyAt_ += n;
} else {
n = readRaw(head_.chunked ? raw_.get() : buf, want);
}
if (n < 0) return -1;
if (n == 0) { // the server closed: the end, if it's where it said it would be
done_ = true;
bool whole = head_.chunked ? chunks_.done() : left_ <= 0;
return whole ? 0 : -1;
}
if (!head_.chunked) {
if (left_ > 0) left_ -= n;
return n;
}
size_t out = chunks_.decode(raw_.get(), n, buf);
if (chunks_.failed()) return -1;
if (out > 0) return static_cast<int>(out);
if (chunks_.done()) return done_ = true, 0;
}
}
void HttpsGet::close() {
tls_.stop();
raw_.reset();
std::string().swap(early_);
earlyAt_ = 0;
done_ = false;
head_ = release::HttpHead();
chunks_ = release::ChunkedDecoder();
}
} // namespace roro
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <NetworkClientSecure.h>
#include <memory>
#include <string>
#include "http_head.h"
#include "platform/counted_client.h"
namespace roro {
// One HTTPS GET, read as a stream: the connection checks the server's chain and name against the
// roots in ca_roots.h, the head is parsed, and the body comes out whole whether the server sent it
// with a length or in chunks. Used by the Update Service to read Gitea's answers and to download a
// release. Redirects aren't followed: the device only goes where it was told to (Q163).
class HttpsGet {
public:
explicit HttpsGet(net::User user) : tls_(user) {}
~HttpsGet() { close(); }
// Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen.
std::string open(const std::string& host, const std::string& path, const char* accept);
long contentLength() const { return head_.contentLength; } // -1: not known
// Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled
// before the end.
int read(uint8_t* buf, size_t len);
void close();
private:
static constexpr size_t kRaw = 1024;
static constexpr uint32_t kStallMs = 10000;
int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs
Counted<NetworkClientSecure> tls_;
release::HttpHead head_;
release::ChunkedDecoder chunks_;
std::unique_ptr<uint8_t[]> raw_;
std::string early_; // body bytes that arrived with the head
size_t earlyAt_ = 0;
long left_ = -1; // body bytes still to come, if the server said how many
bool done_ = false;
};
} // namespace roro