#include "WebPanelServer.h" #if defined(ESP_PLATFORM) && WITH_WEB_PANEL #include #include #include #include #include #include "../mqtt/generated/WebPanelCert.h" namespace { constexpr size_t kWebServerStackSize = 8192; constexpr size_t kWebPasswordBufferSize = 80; constexpr size_t kWebCommandBufferSize = 192; constexpr size_t kWebReplyBufferSize = 256; constexpr size_t kWebJsonBufferSize = 2048; #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; } size_t appendJsonEscaped(char* dst, size_t dst_size, size_t offset, const char* src) { if (dst == nullptr || dst_size == 0) { return offset; } for (size_t i = 0; src != nullptr && src[i] != 0 && offset + 2 < dst_size; ++i) { char c = src[i]; if (c == '\\' || c == '"') { if (offset + 2 >= dst_size) break; dst[offset++] = '\\'; dst[offset++] = c; } else if (c == '\n') { if (offset + 2 >= dst_size) break; dst[offset++] = '\\'; dst[offset++] = 'n'; } else if (c == '\r') { if (offset + 2 >= dst_size) break; dst[offset++] = '\\'; dst[offset++] = 'r'; } else if (c == '\t') { if (offset + 2 >= dst_size) break; dst[offset++] = '\\'; dst[offset++] = 't'; } else { dst[offset++] = c; } } dst[(offset < dst_size) ? offset : (dst_size - 1)] = 0; return offset; } bool appendJsonField(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) { int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":\"", comma ? "," : "", key); if (written < 0 || offset + static_cast(written) >= dst_size) { return false; } offset += static_cast(written); offset = appendJsonEscaped(dst, dst_size, offset, value != nullptr ? value : ""); if (offset + 2 >= dst_size) { return false; } dst[offset++] = '"'; dst[offset] = 0; return true; } bool appendJsonFieldRaw(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) { int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":", comma ? "," : "", key); if (written < 0 || offset + static_cast(written) >= dst_size) { return false; } offset += static_cast(written); if (value == nullptr) { value = "null"; } written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s", value); if (written < 0 || offset + static_cast(written) >= dst_size) { return false; } offset += static_cast(written); return true; } const char kWebPanelHtml[] PROGMEM = R"HTML( Repeater Config

Repeater Config

Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.

Run CLI Command

Only the allowlisted commands exposed by this panel will run here.

)HTML"; } // namespace WebPanelServer::WebPanelServer() : _runner(nullptr), _server(nullptr), _token{0}, _route_context{this} { } void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) { _runner = runner; } bool WebPanelServer::start() { if (_server != nullptr || _runner == nullptr) { return _server != nullptr; } if (_token[0] == 0) { refreshToken(); } httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); config.httpd.max_open_sockets = 2; config.httpd.max_uri_handlers = 5; 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 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 bootstrap_uri = {.uri = "/api/bootstrap", .method = HTTP_GET, .handler = &WebPanelServer::handleBootstrap, .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, &login_uri); httpd_register_uri_handler(_server, &command_uri); httpd_register_uri_handler(_server, &bootstrap_uri); httpd_register_uri_handler(_server, &stats_uri); WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str()); return true; } void WebPanelServer::stop() { if (_server != nullptr) { WEB_PANEL_LOG("server stopped"); httpd_ssl_stop(_server); _server = nullptr; } _token[0] = 0; } bool WebPanelServer::isRunning() const { return _server != nullptr; } bool WebPanelServer::hasSessionToken() const { return _token[0] != 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); } httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN); } 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(); 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"); } 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::handleBootstrap(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* reply = allocScratchBuffer(kWebReplyBufferSize); char* json = allocScratchBuffer(kWebJsonBufferSize); if (reply == nullptr || json == nullptr) { freeScratchBuffer(reply); freeScratchBuffer(json); return httpd_resp_send_500(req); } const struct { const char* key; const char* command; } fields[] = { {"name", "get name"}, {"mqtt_iata", "get mqtt.iata"}, {"mqtt_owner", "get mqtt.owner"}, {"mqtt_email", "get mqtt.email"}, {"advert_interval", "get advert.interval"}, {"flood_interval", "get flood.advert.interval"}, {"flood_max", "get flood.max"}, }; size_t offset = 0; json[offset++] = '{'; json[offset] = 0; for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { memset(reply, 0, kWebReplyBufferSize); ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); const char* value = reply; if (value[0] == '>' && value[1] == ' ') { value += 2; } if (strcmp(value, "-") == 0) { value = ""; } if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, value, i != 0)) { freeScratchBuffer(reply); freeScratchBuffer(json); return httpd_resp_send_500(req); } } if (offset + 2 >= kWebJsonBufferSize) { freeScratchBuffer(reply); freeScratchBuffer(json); return httpd_resp_send_500(req); } json[offset++] = '}'; json[offset] = 0; 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, json, HTTPD_RESP_USE_STRLEN); freeScratchBuffer(reply); freeScratchBuffer(json); 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"); } char* reply = allocScratchBuffer(kWebReplyBufferSize); char* json = allocScratchBuffer(kWebJsonBufferSize); if (reply == nullptr || json == nullptr) { freeScratchBuffer(reply); freeScratchBuffer(json); return httpd_resp_send_500(req); } 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"}, }; size_t offset = 0; json[offset++] = '{'; json[offset] = 0; for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) { memset(reply, 0, kWebReplyBufferSize); ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize); if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, reply, i != 0)) { freeScratchBuffer(reply); freeScratchBuffer(json); return httpd_resp_send_500(req); } } if (offset + 2 >= kWebJsonBufferSize) { freeScratchBuffer(reply); freeScratchBuffer(json); return httpd_resp_send_500(req); } json[offset++] = '}'; json[offset] = 0; 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, json, HTTPD_RESP_USE_STRLEN); freeScratchBuffer(reply); freeScratchBuffer(json); 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; } #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; } #endif