fix: oom exceptions for OPDS, KOSync, and OTA via wolfssl (#2475)

This commit is contained in:
Justin Mitchell
2026-07-12 01:53:26 +03:00
committed by GitHub
parent 1f5669a08a
commit 3e627112f6
13 changed files with 413 additions and 233 deletions
+2 -2
View File
@@ -327,8 +327,8 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u min"
STR_SLEEP_NEVER: "Nikdy"
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
STR_STEP_HINT_FRONT: "Predné tlačidlá:"
STR_STEP_HINT_SIDE: "Bočné tlačidlá:"
STR_ADD_SERVER: "Pridať server"
STR_SERVER_NAME: "Názov servera"
STR_NO_SERVERS: "Nie sú nakonfigurované žiadne OPDS servery"
+77 -132
View File
@@ -2,10 +2,10 @@
#include <ArduinoJson.h>
#include <Logging.h>
#include <esp_crt_bundle.h>
#include <esp_http_client.h>
#include <SecureHttpClient.h>
#include <base64.h>
#include <ctime>
#include <string>
#include "KOReaderCredentialStore.h"
@@ -16,82 +16,35 @@ namespace {
constexpr char DEVICE_NAME[] = "CrossPoint";
constexpr char DEVICE_ID[] = "crosspoint-reader";
// Small TLS buffers to fit in ESP32-C3's limited heap (~46KB free after WiFi).
// KOSync payloads are tiny JSON (<1KB), so 2KB buffers are sufficient.
// Default 16KB buffers cause OOM during TLS handshake.
constexpr int HTTP_BUF_SIZE = 2048;
// Cloudflare tunnels send a 3-cert Google Trust Services chain. During the TLS handshake
// mbedTLS makes many small allocations that collectively consume ~48KB of heap. With only
// ~50KB free after WiFi connects, the session drove min-free-ever down to 2600 bytes before
// failing with MBEDTLS_ERR_X509_ALLOC_FAILED (-0x2880). Check total free heap (not max
// contiguous block) because the failure mode is aggregate exhaustion, not one large alloc.
// KOSync's TLS-1.3 servers can't be reached through the precompiled system
// mbedTLS (TLS 1.3 is stubbed out), so requests run over wolfSSL via
// SecureHttpClient. The handshake still needs working heap; gate on it. wolfSSL's
// footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative
// floor. Check both total free heap and largest contiguous block so fragmented
// heap does not fall through into a failed TLS allocation path.
constexpr uint32_t MIN_HEAP_FOR_TLS = 55000;
// Response buffer for reading HTTP body
struct ResponseBuffer {
char* data = nullptr;
int len = 0;
int capacity = 0;
~ResponseBuffer() { free(data); }
bool ensure(int size) {
if (size <= capacity) return true;
char* newData = (char*)realloc(data, size);
if (!newData) return false;
data = newData;
capacity = size;
return true;
}
};
// HTTP event handler to collect response body
esp_err_t httpEventHandler(esp_http_client_event_t* evt) {
auto* buf = static_cast<ResponseBuffer*>(evt->user_data);
if (evt->event_id == HTTP_EVENT_ON_DATA && buf) {
if (buf->ensure(buf->len + evt->data_len + 1)) {
memcpy(buf->data + buf->len, evt->data, evt->data_len);
buf->len += evt->data_len;
buf->data[buf->len] = '\0';
} else {
LOG_ERR("KOSync", "Response buffer allocation failed (%d bytes)", evt->data_len);
}
}
return ESP_OK;
// Apply the shared KOSync auth headers after begin(). x-auth-* is the native
// KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility.
void applyAuthHeaders(freeink::SecureHttpClient& http) {
http.addHeader("Accept", "application/vnd.koreader.v1+json");
http.addHeader("x-auth-user", KOREADER_STORE.getUsername());
http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password());
const std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword();
const String encoded = base64::encode(credentials.c_str());
http.addHeader("Authorization", std::string("Basic ") + encoded.c_str());
}
// Create configured esp_http_client with small TLS buffers
esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
esp_http_client_method_t method = HTTP_METHOD_GET) {
esp_http_client_config_t config = {};
config.url = url;
config.event_handler = httpEventHandler;
config.user_data = buf;
config.method = method;
config.timeout_ms = 15000;
config.buffer_size = HTTP_BUF_SIZE;
config.buffer_size_tx = HTTP_BUF_SIZE;
config.crt_bundle_attach = esp_crt_bundle_attach;
// HTTP Basic Auth for Calibre-Web-Automated compatibility
config.username = KOREADER_STORE.getUsername().c_str();
config.password = KOREADER_STORE.getPassword().c_str();
config.auth_type = HTTP_AUTH_TYPE_BASIC;
esp_http_client_handle_t client = esp_http_client_init(&config);
if (!client) return nullptr;
// KOSync auth headers
if (esp_http_client_set_header(client, "Accept", "application/vnd.koreader.v1+json") != ESP_OK ||
esp_http_client_set_header(client, "x-auth-user", KOREADER_STORE.getUsername().c_str()) != ESP_OK ||
esp_http_client_set_header(client, "x-auth-key", KOREADER_STORE.getMd5Password().c_str()) != ESP_OK) {
LOG_ERR("KOSync", "Failed to set auth headers");
esp_http_client_cleanup(client);
return nullptr;
// True when free heap is too low to risk a TLS handshake.
bool insufficientHeap() {
const uint32_t freeHeap = ESP.getFreeHeap();
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap,
maxAllocHeap, MIN_HEAP_FOR_TLS);
return true;
}
return client;
return false;
}
} // namespace
@@ -102,26 +55,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
const uint32_t freeHeap = ESP.getFreeHeap();
LOG_DBG("KOSync", "Authenticating: %s (heap: %u)", url.c_str(), (unsigned)freeHeap);
if (freeHeap < MIN_HEAP_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u)", freeHeap, MIN_HEAP_FOR_TLS);
return LOW_MEMORY;
const std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
LOG_DBG("KOSync", "Authenticating: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
if (insufficientHeap()) return LOW_MEMORY;
freeink::SecureHttpClient http;
http.setInsecure();
if (!http.begin(url)) {
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
return NETWORK_ERROR;
}
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf);
if (!client) return NETWORK_ERROR;
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
applyAuthHeaders(http);
const int httpCode = http.GET();
http.end();
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
LOG_DBG("KOSync", "Auth response: %d (err: %d)", httpCode, err);
LOG_DBG("KOSync", "Auth response: %d", httpCode);
if (err != ESP_OK) return NETWORK_ERROR;
if (httpCode <= 0) return NETWORK_ERROR;
if (httpCode == 200) return OK;
if (httpCode == 401) return AUTH_FAILED;
return SERVER_ERROR;
@@ -135,30 +86,31 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
const uint32_t freeHeap = ESP.getFreeHeap();
LOG_DBG("KOSync", "Getting progress: %s (heap: %u)", url.c_str(), (unsigned)freeHeap);
if (freeHeap < MIN_HEAP_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u)", freeHeap, MIN_HEAP_FOR_TLS);
return LOW_MEMORY;
const std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
LOG_DBG("KOSync", "Getting progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
if (insufficientHeap()) return LOW_MEMORY;
freeink::SecureHttpClient http;
http.setInsecure();
if (!http.begin(url)) {
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
return NETWORK_ERROR;
}
applyAuthHeaders(http);
const int httpCode = http.GET();
lastHttpCode = httpCode;
LOG_DBG("KOSync", "Get progress response: %d", httpCode);
if (httpCode <= 0) {
http.end();
return NETWORK_ERROR;
}
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf);
if (!client) return NETWORK_ERROR;
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
LOG_DBG("KOSync", "Get progress response: %d (err: %d)", httpCode, err);
if (err != ESP_OK) return NETWORK_ERROR;
if (httpCode == 200 && buf.data) {
if (httpCode == 200) {
JsonDocument doc;
const DeserializationError error = deserializeJson(doc, buf.data);
const DeserializationError error = deserializeJson(doc, http.getString().c_str());
http.end();
if (error) {
LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str());
@@ -176,6 +128,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
return OK;
}
http.end();
if (httpCode == 401) return AUTH_FAILED;
if (httpCode == 404) return NOT_FOUND;
return SERVER_ERROR;
@@ -188,13 +141,9 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
return NO_CREDENTIALS;
}
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
const uint32_t freeHeap = ESP.getFreeHeap();
LOG_DBG("KOSync", "Updating progress: %s (heap: %u)", url.c_str(), (unsigned)freeHeap);
if (freeHeap < MIN_HEAP_FOR_TLS) {
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u)", freeHeap, MIN_HEAP_FOR_TLS);
return LOW_MEMORY;
}
const std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
LOG_DBG("KOSync", "Updating progress: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
if (insufficientHeap()) return LOW_MEMORY;
// Build JSON body
JsonDocument doc;
@@ -209,25 +158,21 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
LOG_DBG("KOSync", "Request body: %s", body.c_str());
ResponseBuffer buf;
esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_PUT);
if (!client) return NETWORK_ERROR;
if (esp_http_client_set_header(client, "Content-Type", "application/json") != ESP_OK ||
esp_http_client_set_post_field(client, body.c_str(), body.length()) != ESP_OK) {
LOG_ERR("KOSync", "Failed to set request body");
esp_http_client_cleanup(client);
freeink::SecureHttpClient http;
http.setInsecure();
if (!http.begin(url)) {
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
return NETWORK_ERROR;
}
esp_err_t err = esp_http_client_perform(client);
const int httpCode = esp_http_client_get_status_code(client);
applyAuthHeaders(http);
http.addHeader("Content-Type", "application/json");
const int httpCode = http.sendRequest("PUT", body);
http.end();
lastHttpCode = httpCode;
esp_http_client_cleanup(client);
LOG_DBG("KOSync", "Update progress response: %d (err: %d)", httpCode, err);
LOG_DBG("KOSync", "Update progress response: %d", httpCode);
if (err != ESP_OK) return NETWORK_ERROR;
if (httpCode <= 0) return NETWORK_ERROR;
if (httpCode == 200 || httpCode == 202) return OK;
if (httpCode == 401) return AUTH_FAILED;
return SERVER_ERROR;
+55 -18
View File
@@ -5,6 +5,17 @@
#include <cstring>
namespace {
constexpr size_t ENTRY_STORAGE_CAPACITY = 64;
constexpr size_t MAX_ENTRIES = ENTRY_STORAGE_CAPACITY - 2;
constexpr size_t MAX_TITLE_CHARS = 160;
constexpr size_t MAX_AUTHOR_CHARS = 120;
constexpr size_t MAX_ID_CHARS = 128;
constexpr size_t MAX_HREF_CHARS = 768;
constexpr size_t MAX_SEARCH_TEMPLATE_CHARS = 768;
constexpr size_t MAX_PAGE_URL_CHARS = 768;
} // namespace
OpdsParser::OpdsParser() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
@@ -12,6 +23,7 @@ OpdsParser::OpdsParser() {
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
return;
}
entries.reserve(ENTRY_STORAGE_CAPACITY);
XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData);
@@ -71,6 +83,8 @@ void OpdsParser::clear() {
currentEntry = OpdsEntry{};
currentText.clear();
inEntry = inTitle = inAuthor = inAuthorName = inId = false;
collectCurrentEntry = false;
feedTruncated = false;
}
std::vector<OpdsEntry> OpdsParser::getBooks() const {
@@ -88,9 +102,33 @@ const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) {
return nullptr;
}
void OpdsParser::assignBounded(std::string& target, const char* value, const size_t maxLen) {
if (!value) {
target.clear();
return;
}
target.assign(value, strnlen(value, maxLen));
}
void OpdsParser::appendBounded(std::string& target, const char* value, const size_t len, const size_t maxLen) {
if (target.size() >= maxLen) return;
const size_t remaining = maxLen - target.size();
target.append(value, len < remaining ? len : remaining);
}
void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) {
auto* self = static_cast<OpdsParser*>(userData);
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
self->inEntry = true;
self->collectCurrentEntry = self->entries.size() < MAX_ENTRIES;
self->feedTruncated = self->feedTruncated || !self->collectCurrentEntry;
self->currentEntry = OpdsEntry{};
self->currentText.clear();
self->inTitle = self->inAuthor = self->inAuthorName = self->inId = false;
return;
}
if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) {
const char* href = findAttribute(atts, "href");
if (href) {
@@ -98,17 +136,16 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
const char* type = findAttribute(atts, "type");
if (rel && strcmp(rel, "search") == 0) {
std::string sHref(href);
if (sHref.find("{searchTerms}") != std::string::npos) {
self->searchTemplate = sHref;
if (strstr(href, "{searchTerms}") != nullptr) {
assignBounded(self->searchTemplate, href, MAX_SEARCH_TEMPLATE_CHARS);
}
} else if (rel && strcmp(rel, "next") == 0 && !self->inEntry) {
self->nextPageUrl = href;
assignBounded(self->nextPageUrl, href, MAX_PAGE_URL_CHARS);
} else if (rel && strcmp(rel, "previous") == 0 && !self->inEntry) {
self->prevPageUrl = href;
assignBounded(self->prevPageUrl, href, MAX_PAGE_URL_CHARS);
}
if (self->inEntry) {
if (self->inEntry && self->collectCurrentEntry) {
if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr &&
strcmp(type, "application/epub+zip") == 0) {
// Prefer plain EPUB links over derived formats when multiple
@@ -119,25 +156,19 @@ void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, cons
self->currentEntry.href.find("/epub/") != std::string::npos);
if (self->currentEntry.type != OpdsEntryType::BOOK || (isPlainEpub && !alreadyHasPlainEpub)) {
self->currentEntry.type = OpdsEntryType::BOOK;
self->currentEntry.href = href;
assignBounded(self->currentEntry.href, href, MAX_HREF_CHARS);
}
} else if (type && strstr(type, "application/atom+xml") != nullptr) {
if (self->currentEntry.type != OpdsEntryType::BOOK) {
self->currentEntry.type = OpdsEntryType::NAVIGATION;
self->currentEntry.href = href;
assignBounded(self->currentEntry.href, href, MAX_HREF_CHARS);
}
}
}
}
}
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
self->inEntry = true;
self->currentEntry = OpdsEntry{};
return;
}
if (!self->inEntry) return;
if (!self->inEntry || !self->collectCurrentEntry) return;
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
self->inTitle = true;
@@ -157,10 +188,11 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<OpdsParser*>(userData);
if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) {
if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
if (self->collectCurrentEntry && !self->currentEntry.title.empty() && !self->currentEntry.href.empty()) {
self->entries.push_back(self->currentEntry);
}
self->inEntry = false;
self->collectCurrentEntry = false;
} else if (self->inEntry) {
if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) {
if (self->inTitle) self->currentEntry.title = self->currentText;
@@ -179,7 +211,12 @@ void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) {
void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<OpdsParser*>(userData);
if (self->inTitle || self->inAuthorName || self->inId) {
self->currentText.append(s, len);
if (!self->collectCurrentEntry) return;
if (self->inTitle) {
appendBounded(self->currentText, s, len, MAX_TITLE_CHARS);
} else if (self->inAuthorName) {
appendBounded(self->currentText, s, len, MAX_AUTHOR_CHARS);
} else if (self->inId) {
appendBounded(self->currentText, s, len, MAX_ID_CHARS);
}
}
+5
View File
@@ -61,6 +61,7 @@ class OpdsParser final : public Print {
void flush() override;
bool error() const;
bool truncated() const { return feedTruncated; }
operator bool() { return !error(); }
@@ -93,6 +94,8 @@ class OpdsParser final : public Print {
std::string prevPageUrl;
// Helper to find attribute value
static const char* findAttribute(const XML_Char** atts, const char* name);
static void assignBounded(std::string& target, const char* value, size_t maxLen);
static void appendBounded(std::string& target, const char* value, size_t len, size_t maxLen);
XML_Parser parser = nullptr;
std::vector<OpdsEntry> entries;
@@ -105,6 +108,8 @@ class OpdsParser final : public Print {
bool inAuthor = false;
bool inAuthorName = false;
bool inId = false;
bool collectCurrentEntry = false;
bool errorOccured = false;
bool feedTruncated = false;
};