Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions include/GithubRootCA.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#pragma once

namespace SQM
{

// Root CAs covering api.github.com (chains through Sectigo to
// "USERTrust ECC Certification Authority") and the release-asset
// redirect target objects.githubusercontent.com / *.github.io CDN
// (chains through Let's Encrypt to "ISRG Root X1").
//
// Extracted from the local system trust store and verified against the
// live TLS chains for both domains (openssl verify) rather than
// hand-typed, since a single wrong byte in a pinned cert silently
// breaks or weakens validation.
//
// These are long-lived roots (2035/2038 expiry) but GitHub's
// intermediate chain can still change: if OTA checks start failing
// with TLS handshake errors before firmware can be updated, that's the
// first thing to re-verify (`openssl s_client -connect
// api.github.com:443 -showcerts`).
static const char *GITHUB_ROOT_CA_PEM =
// USERTrust ECC Certification Authority (root for api.github.com)
"-----BEGIN CERTIFICATE-----\n"
"MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL\n"
"MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl\n"
"eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT\n"
"JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx\n"
"MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT\n"
"Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg\n"
"VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm\n"
"aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo\n"
"I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng\n"
"o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G\n"
"A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD\n"
"VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB\n"
"zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW\n"
"RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=\n"
"-----END CERTIFICATE-----\n"
// ISRG Root X1 (root for the objects.githubusercontent.com / GitHub Pages CDN)
"-----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";

} // namespace SQM
76 changes: 76 additions & 0 deletions include/OtaUpdater.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#pragma once

#include <string>
#include <vector>
#include <functional>

namespace SQM
{

struct GithubRelease
{
std::string tag; // e.g. "v0.0.3"
std::string name; // release title
bool prerelease = false; // GitHub's native beta/stable flag
std::string publishedAt;
std::string firmwareAssetUrl; // browser_download_url for sqmeter-firmware-<tag>.bin
size_t firmwareAssetSize = 0;
std::string fsAssetUrl; // browser_download_url for sqmeter-littlefs-<tag>.bin
size_t fsAssetSize = 0;
};

// Parses a GitHub "list releases" API JSON body and returns entries that
// have BOTH a sqmeter-firmware-*.bin and a sqmeter-littlefs-*.bin asset,
// filtered by track ("stable" -> prerelease == false, "beta" ->
// prerelease == true). A release missing either asset is skipped
// entirely - firmware and web UI must always be flashed as a matched
// pair to avoid frontend/backend drift.
// Exposed standalone (no networking) so it's unit-testable natively.
std::vector<GithubRelease> parseGithubReleases(const std::string &json, const std::string &track);

class OtaUpdater
{
public:
enum class Phase
{
Idle,
Checking,
Downloading,
Writing,
Done,
Error
};

using ProgressCallback = std::function<void(int percent)>;
using ErrorCallback = std::function<void(const char *message)>;
using RestartCallback = std::function<void()>;

OtaUpdater(ProgressCallback onProgress, ErrorCallback onError, RestartCallback onRestart);

// Blocking HTTPS GET against the GitHub releases API. Safe to call
// from a request handler - completes in a couple seconds.
std::vector<GithubRelease> checkForUpdate(const std::string &track, std::string &error);

// Starts the download+flash of BOTH the firmware and filesystem
// assets on its own FreeRTOS task so the caller (an ESPAsyncWebServer
// request handler) returns immediately instead of blocking the web
// server for the duration of the download. Only reboots after both
// have been written successfully, so the device never runs a
// mismatched firmware/web-UI pair.
// Returns false if an update is already in progress.
bool applyUpdate(const GithubRelease &release);

Phase phase() const { return currentPhase; }

private:
void runApply(GithubRelease release);
bool downloadAndFlashFirmware(const std::string &url, size_t expectedSize, int progressFrom, int progressTo);
bool downloadAndFlashFilesystem(const std::string &url, size_t expectedSize, int progressFrom, int progressTo);

ProgressCallback progressCb;
ErrorCallback errorCb;
RestartCallback restartCb;
volatile Phase currentPhase = Phase::Idle;
};

} // namespace SQM
4 changes: 4 additions & 0 deletions include/WebServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "calculations/SkyQuality.h"
#include "TimeManager.h"
#include "MQTTClient.h"
#include "OtaUpdater.h"
#include <ESPAsyncWebServer.h>
#include <AsyncWebSocket.h>
#include <ArduinoJson.h>
Expand Down Expand Up @@ -112,11 +113,14 @@ namespace SQM
std::string pendingWifiSSID;
std::string pendingWifiPassword;

std::unique_ptr<OtaUpdater> otaUpdater;

// Setup route handlers
void setupStaticRoutes();
void setupAPIRoutes();
void setupWebSocket();
void setupOTA();
void setupGithubUpdates();

// API endpoint handlers
void handleGetStatus(AsyncWebServerRequest *request);
Expand Down
Loading
Loading