#include "WebPanelServer.h" #if defined(ESP_PLATFORM) && WITH_WEB_PANEL #include #include #include #include #include #include "../mqtt/generated/WebPanelCert.h" namespace { #ifndef WEB_PANEL_STACK_SIZE #define WEB_PANEL_STACK_SIZE 6144 #endif #ifndef WEB_PANEL_IDLE_TIMEOUT_MS #define WEB_PANEL_IDLE_TIMEOUT_MS (15UL * 60UL * 1000UL) #endif constexpr size_t kWebServerStackSize = WEB_PANEL_STACK_SIZE; constexpr size_t kWebPasswordBufferSize = 80; constexpr size_t kWebCommandBufferSize = 192; constexpr size_t kWebReplyBufferSize = 256; constexpr size_t kWebStatsQueryBufferSize = 96; constexpr size_t kWebStatsReplyBufferSize = 4608; constexpr size_t kWebPageChunkSize = 768; constexpr unsigned long kWebIdleTimeoutMs = WEB_PANEL_IDLE_TIMEOUT_MS; #if defined(MQTT_DEBUG) && MQTT_DEBUG #define WEB_PANEL_LOG(fmt, ...) Serial.printf("[WEB] " fmt "\n", ##__VA_ARGS__) #else #define WEB_PANEL_LOG(...) do { } while (0) #endif char* allocScratchBuffer(size_t size) { void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (ptr == nullptr) { ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT); } return static_cast(ptr); } void freeScratchBuffer(void* ptr) { if (ptr != nullptr) { heap_caps_free(ptr); } } void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) { if (dst_size == 0) { return; } size_t di = 0; for (size_t i = 0; i < len && di + 2 < dst_size; ++i) { snprintf(&dst[di], dst_size - di, "%02X", src[i]); di += 2; } dst[(di < dst_size) ? di : (dst_size - 1)] = 0; } esp_err_t sendChunk(httpd_req_t* req, const char* text) { return httpd_resp_sendstr_chunk(req, text != nullptr ? text : ""); } esp_err_t sendProgmemChunked(httpd_req_t* req, const char* text) { if (text == nullptr) { return httpd_resp_send_chunk(req, nullptr, 0); } const size_t len = strlen(text); size_t offset = 0; while (offset < len) { const size_t chunk_len = ((len - offset) > kWebPageChunkSize) ? kWebPageChunkSize : (len - offset); if (httpd_resp_send_chunk(req, &text[offset], chunk_len) != ESP_OK) { httpd_resp_send_chunk(req, nullptr, 0); return ESP_FAIL; } offset += chunk_len; } return httpd_resp_send_chunk(req, nullptr, 0); } esp_err_t sendJsonEscapedChunk(httpd_req_t* req, const char* src) { char chunk[48]; size_t offset = 0; for (size_t i = 0; src != nullptr && src[i] != 0; ++i) { const char* escape = nullptr; char c = src[i]; switch (c) { case '\\': escape = "\\\\"; break; case '"': escape = "\\\""; break; case '\n': escape = "\\n"; break; case '\r': escape = "\\r"; break; case '\t': escape = "\\t"; break; default: break; } const char* fragment = escape; char single[2] = {c, 0}; if (fragment == nullptr) { fragment = single; } size_t frag_len = strlen(fragment); if (offset + frag_len >= sizeof(chunk) - 1) { chunk[offset] = 0; if (sendChunk(req, chunk) != ESP_OK) { return ESP_FAIL; } offset = 0; } memcpy(&chunk[offset], fragment, frag_len); offset += frag_len; } if (offset > 0) { chunk[offset] = 0; return sendChunk(req, chunk); } return ESP_OK; } esp_err_t sendJsonFieldChunk(httpd_req_t* req, const char* key, const char* value, bool comma) { char prefix[40]; int written = snprintf(prefix, sizeof(prefix), "%s\"%s\":\"", comma ? "," : "", key); if (written < 0 || static_cast(written) >= sizeof(prefix)) { return ESP_FAIL; } if (sendChunk(req, prefix) != ESP_OK) { return ESP_FAIL; } if (sendJsonEscapedChunk(req, value != nullptr ? value : "") != ESP_OK) { return ESP_FAIL; } return sendChunk(req, "\""); } bool getQueryValue(httpd_req_t* req, const char* key, char* value, size_t value_size) { if (req == nullptr || key == nullptr || value == nullptr || value_size == 0) { return false; } const size_t query_len = httpd_req_get_url_query_len(req); if (query_len == 0 || query_len + 1 > kWebStatsQueryBufferSize) { return false; } char query[kWebStatsQueryBufferSize]; if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) { return false; } return httpd_query_key_value(query, key, value, value_size) == ESP_OK; } esp_err_t sendLegacyStatsBundle(httpd_req_t* req, WebPanelCommandRunner* runner, char* reply) { const struct { const char* key; const char* command; } fields[] = { {"wifi", "get wifi.status"}, {"wifi_powersave", "get wifi.powersaving"}, {"core", "stats-core"}, {"radio", "stats-radio"}, {"packets", "stats-packets"}, {"memory", "memory"}, }; httpd_resp_set_type(req, "application/json; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); if (sendChunk(req, "{") != ESP_OK) { httpd_resp_sendstr_chunk(req, nullptr); return ESP_FAIL; } for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { memset(reply, 0, kWebReplyBufferSize); runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); if (sendJsonFieldChunk(req, fields[i].key, reply, i != 0) != ESP_OK) { httpd_resp_sendstr_chunk(req, nullptr); return ESP_FAIL; } } esp_err_t rc = sendChunk(req, "}"); if (rc == ESP_OK) { rc = httpd_resp_sendstr_chunk(req, nullptr); } else { httpd_resp_sendstr_chunk(req, nullptr); } return rc; } const char kWebPanelLoginHtml[] PROGMEM = R"HTML( Repeater Login

Repeater Config

Use the repeater admin password to unlock the panel.

)HTML"; const char kWebPanelStatsDisabledHtml[] PROGMEM = R"HTML( Stats Disabled

Stats Disabled

The dedicated stats page is currently disabled for this node.

Enable it with `set web.stats on` from the CLI or return to /app.

)HTML"; const char kWebPanelAppHtml[] PROGMEM = R"HTML( Repeater Config
MQTT IATA needs setting under MQTT Settings.
)HTML"; } // namespace WebPanelServer::WebPanelServer() : _runner(nullptr), _server(nullptr), _redirect_server(nullptr), _token{0}, _last_activity_ms(0), _route_context{this} { } void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) { _runner = runner; } bool WebPanelServer::start() { if (_server != nullptr || _runner == nullptr) { return _server != nullptr; } noteActivity(); httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); config.httpd.max_open_sockets = 2; config.httpd.max_uri_handlers = 7; config.httpd.max_resp_headers = 4; config.httpd.backlog_conn = 2; config.httpd.recv_wait_timeout = 2; config.httpd.send_wait_timeout = 2; config.httpd.stack_size = kWebServerStackSize; #if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5 config.servercert = reinterpret_cast(mqtt_web_panel_cert::kServerCertPem); config.servercert_len = sizeof(mqtt_web_panel_cert::kServerCertPem); #else // IDF 4.x uses the misnamed CA slot for the server certificate. config.cacert_pem = reinterpret_cast(mqtt_web_panel_cert::kServerCertPem); config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem); #endif config.prvtkey_pem = reinterpret_cast(mqtt_web_panel_cert::kServerKeyPem); config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem); esp_err_t rc = httpd_ssl_start(&_server, &config); if (rc != ESP_OK) { _server = nullptr; WEB_PANEL_LOG("server start failed rc=0x%x", static_cast(rc)); return false; } httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &WebPanelServer::handleIndex, .user_ctx = &_route_context}; httpd_uri_t app_uri = {.uri = "/app", .method = HTTP_GET, .handler = &WebPanelServer::handleApp, .user_ctx = &_route_context}; httpd_uri_t stats_page_uri = {.uri = "/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStatsPage, .user_ctx = &_route_context}; httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context}; httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context}; httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context}; httpd_register_uri_handler(_server, &index_uri); httpd_register_uri_handler(_server, &app_uri); httpd_register_uri_handler(_server, &stats_page_uri); httpd_register_uri_handler(_server, &login_uri); httpd_register_uri_handler(_server, &command_uri); httpd_register_uri_handler(_server, &stats_uri); httpd_config_t redirect_config = HTTPD_DEFAULT_CONFIG(); redirect_config.server_port = 80; // HTTPS already uses the default control port from HTTPD_SSL_CONFIG_DEFAULT(). // The redirect listener needs its own control port or startup will fail. redirect_config.ctrl_port = 32769; redirect_config.max_open_sockets = 2; redirect_config.max_uri_handlers = 1; redirect_config.max_resp_headers = 4; redirect_config.backlog_conn = 2; redirect_config.recv_wait_timeout = 2; redirect_config.send_wait_timeout = 2; redirect_config.stack_size = kWebServerStackSize; redirect_config.uri_match_fn = httpd_uri_match_wildcard; rc = httpd_start(&_redirect_server, &redirect_config); if (rc == ESP_OK) { httpd_uri_t redirect_uri = {.uri = "/*", .method = HTTP_GET, .handler = &WebPanelServer::handleHttpRedirect, .user_ctx = &_route_context}; httpd_register_uri_handler(_redirect_server, &redirect_uri); } else { _redirect_server = nullptr; WEB_PANEL_LOG("redirect server start failed rc=0x%x", static_cast(rc)); } WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str()); return true; } void WebPanelServer::stop() { if (_redirect_server != nullptr) { httpd_stop(_redirect_server); _redirect_server = nullptr; } if (_server != nullptr) { WEB_PANEL_LOG("server stopped"); httpd_ssl_stop(_server); _server = nullptr; } _token[0] = 0; _last_activity_ms = 0; } bool WebPanelServer::isRunning() const { return _server != nullptr; } bool WebPanelServer::hasSessionToken() const { return _token[0] != 0; } bool WebPanelServer::shouldAutoLock(unsigned long now_ms) const { if (_server == nullptr || _token[0] == 0 || kWebIdleTimeoutMs == 0 || _last_activity_ms == 0) { return false; } return now_ms - _last_activity_ms >= kWebIdleTimeoutMs; } void WebPanelServer::lockSession() { _token[0] = 0; _last_activity_ms = 0; } esp_err_t WebPanelServer::handleIndex(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr) { return httpd_resp_send_500(req); } ctx->self->noteActivity(); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return sendProgmemChunked(req, kWebPanelLoginHtml); } esp_err_t WebPanelServer::handleHttpRedirect(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr) { return httpd_resp_send_500(req); } char location[160]; const char* path = (req->uri != nullptr && req->uri[0] != 0) ? req->uri : "/"; snprintf(location, sizeof(location), "https://%s%s", WiFi.localIP().toString().c_str(), path); httpd_resp_set_status(req, "302 Found"); httpd_resp_set_hdr(req, "Location", location); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return httpd_resp_send(req, "", 0); } esp_err_t WebPanelServer::handleApp(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr) { return httpd_resp_send_500(req); } ctx->self->noteActivity(); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return sendProgmemChunked(req, kWebPanelAppHtml); } esp_err_t WebPanelServer::handleStatsPage(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr) { return httpd_resp_send_500(req); } ctx->self->noteActivity(); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); if (ctx->self->_runner != nullptr && !ctx->self->_runner->isWebStatsEnabled()) { return sendProgmemChunked(req, kWebPanelStatsDisabledHtml); } return sendProgmemChunked(req, kWebPanelAppHtml); } esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { return httpd_resp_send_500(req); } char* password = allocScratchBuffer(kWebPasswordBufferSize); if (password == nullptr) { return httpd_resp_send_500(req); } if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) { freeScratchBuffer(password); return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); } if (strcmp(password, ctx->self->_runner->getWebAdminPassword()) != 0) { freeScratchBuffer(password); WEB_PANEL_LOG("login denied"); return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password"); } freeScratchBuffer(password); ctx->self->refreshToken(); ctx->self->noteActivity(); WEB_PANEL_LOG("login accepted"); httpd_resp_set_type(req, "text/plain; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return httpd_resp_sendstr(req, ctx->self->_token); } esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { return httpd_resp_send_500(req); } if (!ctx->self->isAuthorized(req)) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } char* command = allocScratchBuffer(kWebCommandBufferSize); char* reply = allocScratchBuffer(kWebReplyBufferSize); if (command == nullptr || reply == nullptr) { freeScratchBuffer(command); freeScratchBuffer(reply); return httpd_resp_send_500(req); } if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) { freeScratchBuffer(command); freeScratchBuffer(reply); return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request"); } ctx->self->noteActivity(); memset(reply, 0, kWebReplyBufferSize); ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize); httpd_resp_set_type(req, "text/plain; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN); freeScratchBuffer(command); freeScratchBuffer(reply); return rc; } esp_err_t WebPanelServer::handleStats(httpd_req_t* req) { auto* ctx = static_cast(req->user_ctx); if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) { return httpd_resp_send_500(req); } if (!ctx->self->isAuthorized(req)) { return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized"); } ctx->self->noteActivity(); char view[16]; char series[24]; char* reply = allocScratchBuffer(kWebStatsReplyBufferSize); if (reply == nullptr) { freeScratchBuffer(reply); return httpd_resp_send_500(req); } if (getQueryValue(req, "view", view, sizeof(view)) && strcmp(view, "legacy") == 0) { esp_err_t legacy_rc = sendLegacyStatsBundle(req, ctx->self->_runner, reply); freeScratchBuffer(reply); return legacy_rc; } if (!ctx->self->_runner->isWebStatsEnabled()) { freeScratchBuffer(reply); httpd_resp_set_status(req, "503 Service Unavailable"); return httpd_resp_send(req, "Stats disabled", HTTPD_RESP_USE_STRLEN); } bool ok = false; if (getQueryValue(req, "series", series, sizeof(series))) { ok = ctx->self->_runner->formatWebStatsSeriesJson(series, reply, kWebStatsReplyBufferSize); } else { ok = ctx->self->_runner->formatWebStatsSummaryJson(reply, kWebStatsReplyBufferSize); } if (!ok || reply[0] == 0) { freeScratchBuffer(reply); return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "No stats data"); } httpd_resp_set_type(req, "application/json; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); esp_err_t rc = httpd_resp_send(req, reply, HTTPD_RESP_USE_STRLEN); freeScratchBuffer(reply); return rc; } bool WebPanelServer::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const { if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 || req->content_len >= static_cast(buffer_size)) { return false; } int remaining = req->content_len; int offset = 0; while (remaining > 0) { int read = httpd_req_recv(req, &buffer[offset], remaining); if (read <= 0) { return false; } offset += read; remaining -= read; } buffer[offset] = 0; return true; } void WebPanelServer::refreshToken() { uint8_t token[16]; esp_fill_random(token, sizeof(token)); bytesToHexUpper(token, sizeof(token), _token, sizeof(_token)); } bool WebPanelServer::isAuthorized(httpd_req_t* req) const { if (_token[0] == 0) { return false; } char token[40]; if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) { return false; } return strcmp(token, _token) == 0; } void WebPanelServer::noteActivity() { _last_activity_ms = millis(); } #else WebPanelServer::WebPanelServer() : _runner(nullptr) { } void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) { _runner = runner; } bool WebPanelServer::start() { return false; } void WebPanelServer::stop() { } bool WebPanelServer::isRunning() const { return false; } bool WebPanelServer::hasSessionToken() const { return false; } bool WebPanelServer::shouldAutoLock(unsigned long) const { return false; } void WebPanelServer::lockSession() { } #endif