diff --git a/README.md b/README.md index a318158..7a24b9c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ It provides basic "infrastructure" and "Framework" for specific developments. - Control the on-board LED (set brightness; 0-> off; 100->full brightness) - Ping (that returns the current uptime as JSON) - Add (takes 2 integers and returns the result of adding those integers) -- Admin Web-UI +- Admin Web-UI - Role based security configuration - Roles are centrally maintained - User management @@ -43,9 +43,12 @@ It provides basic "infrastructure" and "Framework" for specific developments. - search - view - clear logs - - HTTPS configuration - - By default a self-signed certificate is used - - Allows the set up of certificates for HTTPS (file upload) + - Networking configuration + - Host name + - DHCP or static IPv4 parameters + - HTTPS certificate configuration + - By default a self-signed certificate is used + - Allows the set up of certificates for HTTPS (file upload) - Firmware update handling - Upload new firmware via file selector - Check URL for new firmware button (this will reach out to a configurable (in the code) URL to to try and find new firmware. If new firmware is available it offers to install.) @@ -69,8 +72,9 @@ It provides basic "infrastructure" and "Framework" for specific developments. - Once the user entered submitted information, the information is stored in non volatile memory, and the device is restarted. - On subsequent startups, the device looks for configuraiton stored in non volatile memory - The factory reset functionality deletes the information from the non volatile memory, which will lead to the setup screen. -- Logging is done based on log level. - - The logs are stored in non volatile memory +- Logging is done based on log level. + - Log levels are `Error`, `Warn`, `SecurityAudit`, `Info`, and `Debug`; `SecurityAudit` records security-relevant events such as login/logout, invalid bearer tokens, authorization failures, unknown API URLs/methods, and security configuration changes. + - The logs are stored in non volatile memory - they need to be implemented as a ring buffer that occupies a configurable space in non volatile memory - There is a standard API endpoint that returns the logs (restricted to user role Debugger) @@ -81,17 +85,20 @@ The firmware is split by responsibility: | Path | Responsibility | | --- | --- | | `src/main.cpp` | Arduino `setup()`/`loop()` and boot orchestration | -| `src/app.h` | Shared constants, state, structs, and function declarations | +| `include/app.h` | Framework constants, state, structs, and function declarations | +| `include/custom_api.h` | Custom API handler declarations | | `src/config/api_definitions.cpp` | Central API catalog, route handlers, and default role/public access mapping | +| `src/config/api_definitions_custom.cpp` | Custom API catalog and default public access mapping | | `src/core/state.cpp` | Global state, project/device identity, hashing, persisted settings | | `src/core/logging.cpp` | LittleFS log ring buffer | | `src/core/auth.cpp` | Users, roles, tokens, and API authorization | | `src/core/device.cpp` | LED control, factory reset, and WiFi connection | | `src/util/json_utils.cpp` | Small JSON response and request parsing helpers | | `src/web/ui.cpp` | LittleFS-backed HTML serving and captive-portal helper pages | -| `src/web/routes.cpp` | Page/captive route registration and generic API route registration from `apiDefs` | +| `src/web/routes.cpp` | Page/captive route registration and generic API route registration from framework and custom API catalogs | | `src/handlers/handlers_setup.cpp` | Setup and WiFi scan route handlers | -| `src/handlers/handlers_api.cpp` | Public/device API handlers and API ACL handlers | +| `src/handlers/handlers_custom_api.cpp` | Custom API handlers for ping, add, and LED brightness | +| `src/handlers/handlers_api.cpp` | API ACL list/detail management handlers | | `src/handlers/handlers_auth.cpp` | Login, users, roles, and password route handlers | | `src/handlers/handlers_admin.cpp` | Settings, logs, and certificate route handlers | | `src/handlers/handlers_ota.cpp` | Firmware upload and OTA route handlers | @@ -116,6 +123,7 @@ The firmware in `src/main.cpp` implements the boilerplate as a compact Arduino E - Ring-buffer logging in LittleFS with default maximum size of 50 KiB. - Firmware and LittleFS filesystem upload OTA and update-from-URL hooks. - HTTPS certificate storage API. The default Arduino `WebServer` runs HTTP; stored certificate material is available for applications that add TLS termination. +- Network settings for hostname plus DHCP/static IPv4 configuration. ### Web UI files @@ -134,6 +142,8 @@ After changing files in `data/`, upload the filesystem image as well as the firm pio run -t uploadfs ``` +In the PlatformIO UI, use the `Upload Firmware and Filesystem` project task when you want one action to upload both firmware and the LittleFS image that contains the web UI. + Uploading the filesystem image replaces the LittleFS contents, including stored log files. WiFi configuration, users, roles, and settings are stored in NVS preferences and are not part of that filesystem image. ### Provisioning @@ -162,6 +172,24 @@ The default project name is `TSL-Embedded`. The firmware combines the project na On normal boot, the device connects to the configured WiFi and serves the Admin UI at the IP printed to serial. +### Networking + +Open `Networking` in the Admin UI to configure the station-mode host name and IP parameters. + +The host name field is optional. If it is empty, the firmware uses the generated default host name based on `PROJECT_NAME` and the chip suffix, for example `TSL-Embedded-BDF5F0`. A custom host name must be 1-31 characters and may contain only letters, digits, and hyphens. It cannot start or end with a hyphen. + +Address mode defaults to DHCP. To use a static IPv4 address, select `Static IPv4` and provide: + +| Field | Required | Example | +| --- | --- | --- | +| Static IP | Yes | `192.168.1.50` | +| Gateway | Yes | `192.168.1.1` | +| Subnet mask | Yes | `255.255.255.0` | +| DNS 1 | No | `192.168.1.1` | +| DNS 2 | No | `8.8.8.8` | + +Networking changes are stored immediately, but they apply on the next WiFi reconnect or reboot. + ### Factory reset The default reset pin is GPIO4. Hold GPIO4 LOW during boot for 10 seconds to clear stored configuration and logs, then the device restarts into setup mode. @@ -190,6 +218,51 @@ The response contains a bearer token. Pass it to protected APIs: Authorization: Bearer ``` +### HTTPS certificate configuration + +The Admin UI stores HTTPS certificate material so applications built on this boilerplate can use it when adding TLS termination. The default Arduino `WebServer` used by this project serves HTTP only; uploading a certificate stores the material in NVS preferences but does not by itself switch the built-in web server to HTTPS. + +To configure the stored certificate material: + +1. Log in as a user with the `Sysadmin` role. +2. Open `Networking` -> `HTTPS Certificate`. +3. Select a certificate file and click `Save certificate`. +4. Use `Load current` to verify what is currently stored. + +The upload file must be a plain text PEM-style file. Use UTF-8 or ASCII text and preserve the PEM block line breaks exactly. The file may use `.pem`, `.cer`, `.crt`, or `.txt`. + +Valid content is one or more PEM blocks, for example a certificate chain: + +```text +-----BEGIN CERTIFICATE----- +...base64 certificate data... +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +...base64 intermediate certificate data... +-----END CERTIFICATE----- +``` + +If your TLS integration expects both the certificate and private key from this stored value, put both PEM blocks in the same text file: + +```text +-----BEGIN CERTIFICATE----- +...base64 certificate data... +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +...base64 private key data... +-----END PRIVATE KEY----- +``` + +Do not upload binary DER, PKCS#12/PFX, or password-protected keystore files directly. Convert those to PEM text first. The equivalent API is: + +```http +POST /api/cert +Authorization: Bearer +Content-Type: application/json + +{"certificate":"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"} +``` + ### API response shape Every API returns JSON with a `success` field. Errors include an `error` string: @@ -241,6 +314,9 @@ Protected APIs and their default roles: | `POST /api/settings` | `Sysadmin` | | `GET /api/logs` | `Debugger` | | `POST /api/logs/clear` | `Debugger` | +| `GET /api/files` | `Debugger` | +| `GET /api/files/download` | `Debugger` | +| `POST /api/factory-reset` | `Sysadmin` | | `POST /api/ota/check` | `Sysadmin` | | `POST /api/ota/run` | `Sysadmin` | | `POST /api/update` | `Sysadmin` | diff --git a/data/admin.html b/data/admin.html index 075de1b..eb943d4 100644 --- a/data/admin.html +++ b/data/admin.html @@ -8,24 +8,30 @@ body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650} main{max-width:1040px;margin:0 auto;padding:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px} section,.box{background:white;border:1px solid #d8dee2;border-radius:8px;padding:14px} - label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit} + h3{margin-top:0}label{display:block;font-size:13px;margin:10px 0 4px}input,select,textarea,button{font:inherit} input,select,textarea{width:100%;box-sizing:border-box;padding:9px;border:1px solid #b9c2c8;border-radius:6px} - button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px} + textarea{min-height:110px;resize:vertical}button{border:0;border-radius:6px;background:#1261a6;color:white;padding:9px 12px;cursor:pointer;margin-top:10px} + button:disabled{opacity:.65;cursor:wait}.spinner{display:inline-block;width:1em;height:1em;border:2px solid rgba(255,255,255,.55);border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px;margin-right:6px}@keyframes spin{to{transform:rotate(360deg)}} button.secondary{background:#607d8b}button.danger{background:#b3261e} pre{white-space:pre-wrap;background:#111;color:#d7ffd7;padding:10px;border-radius:6px;max-height:300px;overflow:auto} - .row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none}.muted{color:#607d8b;font-size:13px} + .row{display:flex;gap:8px;align-items:end}.row>*{flex:1}.hidden{display:none!important}.muted{color:#607d8b;font-size:13px} .tabs{display:flex;gap:6px;border-bottom:1px solid #cfd8dc;margin:0 0 14px;overflow-x:auto} .tab{background:transparent;color:#263238;border-radius:6px 6px 0 0;margin:0;padding:10px 14px;white-space:nowrap} .tab.active{background:#1261a6;color:white}.panel{display:none}.panel.active{display:block} + .subtabs{margin-top:4px}.subtab{font-size:14px;padding:8px 12px}.subpanel{display:none}.subpanel.active{display:block} .rangeRow{display:flex;gap:12px;align-items:center}.rangeRow input{flex:1}.rangeRow output{min-width:3ch;text-align:right;font-weight:650} - .list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0}.item.active{background:#1261a6;color:white} + .list{display:flex;flex-direction:column;gap:6px;margin-top:10px}.item{background:#eef3f6;color:#263238;text-align:left;margin:0;padding:9px 12px;box-sizing:border-box}.item:hover{background:#dbe7ed} + .meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}.badge{background:#eef3f6;border-radius:6px;padding:4px 7px;font-size:12px;color:#263238} .checkGrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-top:8px}.checkGrid label{display:flex;gap:6px;align-items:center;margin:0}.checkGrid input{width:auto} .pill{display:inline-flex;gap:6px;align-items:center;background:#eef3f6;border-radius:6px;padding:5px 8px;margin:4px 4px 0 0}.pill button{margin:0;padding:3px 7px;background:#b3261e} .detailTitle{margin:0 0 8px}.inlineCheck{display:flex;gap:8px;align-items:center;margin-top:10px}.inlineCheck input{width:auto} + .toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}.toolbar button{margin-top:0}.detailActions{display:flex;gap:8px;flex-wrap:wrap}.detailActions button{flex:0 1 auto} + .fullWidth{grid-column:1/-1}.busyChip{position:fixed;right:14px;top:10px;z-index:10;background:#1261a6;color:white;border-radius:6px;padding:7px 10px;box-shadow:0 2px 10px rgba(0,0,0,.22);font-size:13px}
ESP32-C3 Admin
+

Login

@@ -38,56 +44,137 @@
- + +

Status

-

LED

0
+

LED

0

Add API

-
-

Roles

-

Users

-

User Details

Select a user

-

API Security

-

API Details

Select an API

+
+
+ + + +
+ +
+
+

User Overview

+
+
+ +
+ +
+
+

Role Overview

+
+
+ +
+ +
+
+

API Overview

+
+
+ +
+
+
+

Network

+

HTTPS Certificate

-

Logs

-

Settings

-

Updates

+

Settings

+

Updates

+

Maintenance

+

Files

+

Logs

diff --git a/src/app.h b/include/app.h similarity index 88% rename from src/app.h rename to include/app.h index 52a687c..57fbec8 100644 --- a/src/app.h +++ b/include/app.h @@ -38,8 +38,9 @@ extern const IPAddress SETUP_AP_SUBNET; enum LogLevel : uint8_t { LOG_ERROR = 0, LOG_WARN = 1, - LOG_INFO = 2, - LOG_DEBUG = 3 + LOG_SECURITY_AUDIT = 2, + LOG_INFO = 3, + LOG_DEBUG = 4 }; struct User { @@ -73,12 +74,21 @@ extern Preferences prefs; extern Token tokens[]; extern ApiDef apiDefs[]; extern const size_t API_DEF_COUNT; +extern ApiDef customApiDefs[]; +extern const size_t CUSTOM_API_DEF_COUNT; extern LogLevel currentLogLevel; extern size_t maxLogBytes; extern bool setupMode; extern uint8_t ledBrightness; extern bool ledInverted; extern String updateUrl; +extern String networkHostname; +extern bool networkDhcp; +extern String networkIp; +extern String networkGateway; +extern String networkSubnet; +extern String networkDns1; +extern String networkDns2; String jsonEscape(const String &s); String jsonOk(const String &payload = ""); @@ -92,6 +102,7 @@ String jsonRolesValue(const String &json, const char *key, const String &fallbac String chipId(); String deviceName(); +String defaultDeviceName(); String sha256(const String &input); String passwordHash(const String &password); String prefString(const char *key, const String &fallback = ""); @@ -113,7 +124,7 @@ String createToken(const User &user); Token *currentToken(); ApiDef *findApi(const String &path, const String &method); String configuredRole(ApiDef *api); -bool authorize(const String &path, const String &method); +bool authorize(); void appendLog(LogLevel level, const String &message); @@ -131,9 +142,6 @@ void handleAdminPage(); void handleWifiScan(); void handleSetupSubmit(); -void handlePing(); -void handleAdd(); -void handleLed(); void handleLogin(); void handleLogout(); void handleMe(); @@ -149,6 +157,11 @@ void handleSettingsGet(); void handleSettingsPost(); void handleLogsGet(); void handleLogsClear(); +void handleFilesList(); +void handleFileDownload(); +void handleFactoryReset(); +void handleConfigExport(); +void handleConfigImport(); void handleOtaCheck(); void handleOtaRun(); void handleUpdateUploadDone(); diff --git a/include/custom_api.h b/include/custom_api.h new file mode 100644 index 0000000..dbd8c34 --- /dev/null +++ b/include/custom_api.h @@ -0,0 +1,5 @@ +#pragma once + +void handlePing(); +void handleAdd(); +void handleLed(); diff --git a/platformio.ini b/platformio.ini index 9769150..f620ffc 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,8 +8,9 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html -[env:seeed_xiao_esp32c3] -platform = espressif32 +[env:seeed_xiao_esp32c3] +platform = espressif32 board = seeed_xiao_esp32c3 framework = arduino board_build.filesystem = littlefs +extra_scripts = scripts/platformio_targets.py diff --git a/scripts/platformio_targets.py b/scripts/platformio_targets.py new file mode 100644 index 0000000..dc0b2f7 --- /dev/null +++ b/scripts/platformio_targets.py @@ -0,0 +1,9 @@ +Import("env") + +env.AddCustomTarget( + "uploadall", + ["upload", "uploadfs"], + [], + title="Upload Firmware and Filesystem", + description="Upload firmware and the LittleFS image from data/", +) diff --git a/src/config/api_definitions.cpp b/src/config/api_definitions.cpp index 3e2a8bd..712d06f 100644 --- a/src/config/api_definitions.cpp +++ b/src/config/api_definitions.cpp @@ -1,11 +1,8 @@ -#include "../app.h" +#include "app.h" ApiDef apiDefs[] = { {"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr}, {"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, nullptr}, - {"/api/ping", "GET", "", true, handlePing, nullptr}, - {"/api/add", "POST", "", true, handleAdd, nullptr}, - {"/api/led", "POST", "", true, handleLed, nullptr}, {"/api/login", "POST", "", true, handleLogin, nullptr}, {"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr}, {"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr}, @@ -21,6 +18,11 @@ ApiDef apiDefs[] = { {"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr}, {"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr}, {"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr}, + {"/api/files", "GET", "Debugger", false, handleFilesList, nullptr}, + {"/api/files/download", "GET", "Debugger", false, handleFileDownload, nullptr}, + {"/api/factory-reset", "POST", "Sysadmin", false, handleFactoryReset, nullptr}, + {"/api/config/export", "GET", "Sysadmin", false, handleConfigExport, nullptr}, + {"/api/config/import", "POST", "Sysadmin", false, handleConfigImport, nullptr}, {"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr}, {"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr}, {"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk}, diff --git a/src/config/api_definitions_custom.cpp b/src/config/api_definitions_custom.cpp new file mode 100644 index 0000000..2a25f60 --- /dev/null +++ b/src/config/api_definitions_custom.cpp @@ -0,0 +1,10 @@ +#include "app.h" +#include "custom_api.h" + +ApiDef customApiDefs[] = { + {"/api/ping", "GET", "", true, handlePing, nullptr}, + {"/api/add", "POST", "", true, handleAdd, nullptr}, + {"/api/led", "POST", "", true, handleLed, nullptr}, +}; + +const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]); diff --git a/src/core/auth.cpp b/src/core/auth.cpp index b552523..75218cb 100644 --- a/src/core/auth.cpp +++ b/src/core/auth.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" bool hasRole(const String &roles, const String &role) { if (role.length() == 0) return true; @@ -112,13 +112,14 @@ bool deleteCustomRole(const String &role) { for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles); saveUsers(users, count); - for (size_t i = 0; i < API_DEF_COUNT; i++) { - if (configuredRole(&apiDefs[i]) == role) { - String fallback = apiDefs[i].publicByDefault ? "PUBLIC" : apiDefs[i].defaultRole; + for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) { + ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT]; + if (configuredRole(api) == role) { + String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole; String key = "acl"; - key += apiDefs[i].method[0]; - for (size_t j = 0; j < strlen(apiDefs[i].path); j++) { - char c = apiDefs[i].path[j]; + key += api->method[0]; + for (size_t j = 0; j < strlen(api->path); j++) { + char c = api->path[j]; if (isalnum(c)) key += c; } prefs.putString(key.substring(0, 15).c_str(), fallback); @@ -208,6 +209,8 @@ static String bearerToken() { return ""; } +static String requestMethodName(); + Token *currentToken() { String value = bearerToken(); if (!value.length()) return nullptr; @@ -217,6 +220,7 @@ Token *currentToken() { return &tokens[i]; } } + appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri()); return nullptr; } @@ -234,6 +238,9 @@ ApiDef *findApi(const String &path, const String &method) { for (size_t i = 0; i < API_DEF_COUNT; i++) { if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[i]; } + for (size_t i = 0; i < CUSTOM_API_DEF_COUNT; i++) { + if (path == customApiDefs[i].path && method == customApiDefs[i].method) return &customApiDefs[i]; + } return nullptr; } @@ -243,22 +250,44 @@ String configuredRole(ApiDef *api) { return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole); } -bool authorize(const String &path, const String &method) { +static String requestMethodName() { + switch (server.method()) { + case HTTP_GET: + return "GET"; + case HTTP_POST: + return "POST"; + case HTTP_DELETE: + return "DELETE"; + case HTTP_PUT: + return "PUT"; + case HTTP_PATCH: + return "PATCH"; + default: + return ""; + } +} + +bool authorize() { + String path = server.uri(); + String method = requestMethodName(); ApiDef *api = findApi(path, method); String required = configuredRole(api); if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true; Token *token = currentToken(); if (!token) { + if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path); sendJson(401, jsonError("Authentication required")); return false; } User user; if (!findUser(token->user, user) || !user.active) { + appendLog(LOG_SECURITY_AUDIT, "inactive or unknown user token for " + token->user + " on " + method + " " + path); sendJson(401, jsonError("User is inactive")); return false; } token->roles = user.roles; if (!hasRole(user.roles, required)) { + appendLog(LOG_SECURITY_AUDIT, "authorization denied for " + user.name + " on " + method + " " + path + " requires " + required); sendJson(403, jsonError("Missing role " + required)); return false; } diff --git a/src/core/device.cpp b/src/core/device.cpp index e6a2a01..6b0e56a 100644 --- a/src/core/device.cpp +++ b/src/core/device.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" #include #include @@ -11,9 +11,9 @@ void applyLed() { } void factoryReset() { - appendLog(LOG_WARN, "factory reset requested"); prefs.clear(); LittleFS.remove("/logs.txt"); + appendLog(LOG_SECURITY_AUDIT, "factory reset requested"); } void checkFactoryResetPin() { @@ -37,6 +37,21 @@ bool connectWifi() { String name = deviceName(); WiFi.mode(WIFI_STA); WiFi.setHostname(name.c_str()); + if (!networkDhcp) { + IPAddress ip; + IPAddress gateway; + IPAddress subnet; + IPAddress dns1; + IPAddress dns2; + bool ok = ip.fromString(networkIp) && gateway.fromString(networkGateway) && subnet.fromString(networkSubnet); + if (ok) { + if (!networkDns1.length() || !dns1.fromString(networkDns1)) dns1 = gateway; + if (!networkDns2.length() || !dns2.fromString(networkDns2)) dns2 = IPAddress(0, 0, 0, 0); + if (!WiFi.config(ip, gateway, subnet, dns1, dns2)) appendLog(LOG_WARN, "static IP configuration failed; continuing with DHCP"); + } else { + appendLog(LOG_WARN, "static IP configuration is incomplete or invalid; continuing with DHCP"); + } + } WiFi.begin(ssid.c_str(), pass.c_str()); appendLog(LOG_INFO, "connecting to WiFi " + ssid); uint32_t start = millis(); diff --git a/src/core/logging.cpp b/src/core/logging.cpp index 596e2cc..86efa44 100644 --- a/src/core/logging.cpp +++ b/src/core/logging.cpp @@ -1,11 +1,21 @@ -#include "../app.h" +#include "app.h" #include +#include + +static String timestamp() { + time_t now = time(nullptr); + struct tm tm; + localtime_r(&now, &tm); + char buffer[20]; + strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm); + return String(buffer); +} void appendLog(LogLevel level, const String &message) { if (level > currentLogLevel) return; - const char *names[] = {"ERROR", "WARN", "INFO", "DEBUG"}; - String line = String(millis()) + " " + names[level] + " " + message + "\n"; + const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"}; + String line = timestamp() + " " + names[level] + " " + message + "\n"; File f = LittleFS.open("/logs.txt", "a"); if (f) { f.print(line); diff --git a/src/core/state.cpp b/src/core/state.cpp index 03ed188..2dd3346 100644 --- a/src/core/state.cpp +++ b/src/core/state.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" #include @@ -25,6 +25,13 @@ bool setupMode = false; uint8_t ledBrightness = 0; bool ledInverted = DEFAULT_LED_INVERTED; String updateUrl = DEFAULT_UPDATE_URL; +String networkHostname; +bool networkDhcp = true; +String networkIp; +String networkGateway; +String networkSubnet; +String networkDns1; +String networkDns2; String chipId() { uint64_t mac = ESP.getEfuseMac(); @@ -33,7 +40,7 @@ String chipId() { return String(buf); } -String deviceName() { +String defaultDeviceName() { String suffix = chipId().substring(6); String base = PROJECT_NAME_VALUE; base.trim(); @@ -52,6 +59,10 @@ String deviceName() { return base + "-" + suffix; } +String deviceName() { + return networkHostname.length() ? networkHostname : defaultDeviceName(); +} + String sha256(const String &input) { uint8_t digest[32]; mbedtls_md_context_t ctx; @@ -81,9 +92,23 @@ void loadSettings() { prefs.putBool("ledInv", DEFAULT_LED_INVERTED); prefs.putBool("ledPolV2", true); } - currentLogLevel = (LogLevel)prefs.getUChar("logLevel", LOG_INFO); + uint8_t rawLogLevel = prefs.getUChar("logLevel", LOG_INFO); + if (!prefs.getBool("logLevelV2", false)) { + if (prefs.isKey("logLevel") && rawLogLevel >= 2) rawLogLevel++; + rawLogLevel = constrain(rawLogLevel, 0, 4); + prefs.putUChar("logLevel", rawLogLevel); + prefs.putBool("logLevelV2", true); + } + currentLogLevel = (LogLevel)constrain(rawLogLevel, 0, 4); maxLogBytes = prefs.getUInt("logMax", 50 * 1024); ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED); ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100); updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL); + networkHostname = prefString("netHost", ""); + networkDhcp = prefs.getBool("netDhcp", true); + networkIp = prefString("netIp", ""); + networkGateway = prefString("netGw", ""); + networkSubnet = prefString("netMask", ""); + networkDns1 = prefString("netDns1", ""); + networkDns2 = prefString("netDns2", ""); } diff --git a/src/handlers/handlers_admin.cpp b/src/handlers/handlers_admin.cpp index d1b463c..375dd2e 100644 --- a/src/handlers/handlers_admin.cpp +++ b/src/handlers/handlers_admin.cpp @@ -1,32 +1,88 @@ -#include "../app.h" +#include "app.h" #include +static String networkSettingsJson(bool includeStatus) { + String out = "\"hostname\":\"" + jsonEscape(networkHostname) + "\",\"dhcp\":" + String(networkDhcp ? "true" : "false") + ",\"ip\":\"" + jsonEscape(networkIp) + "\",\"gateway\":\"" + jsonEscape(networkGateway) + "\",\"subnet\":\"" + jsonEscape(networkSubnet) + "\",\"dns1\":\"" + jsonEscape(networkDns1) + "\",\"dns2\":\"" + jsonEscape(networkDns2) + "\""; + if (includeStatus) out += ",\"defaultHostname\":\"" + jsonEscape(defaultDeviceName()) + "\",\"currentIp\":\"" + WiFi.localIP().toString() + "\""; + return out; +} + +static bool validHostnameValue(const String &hostname) { + if (!hostname.length()) return true; + if (hostname.length() > 31) return false; + if (hostname.startsWith("-") || hostname.endsWith("-")) return false; + for (size_t i = 0; i < hostname.length(); i++) { + char c = hostname[i]; + if (!(isalnum(c) || c == '-')) return false; + } + return true; +} + +static bool validIpValue(const String &value) { + IPAddress ip; + return value.length() && ip.fromString(value); +} + void handleSettingsGet() { - if (!authorize("/api/settings", "GET")) return; - String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "}"; + if (!authorize()) return; + String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}"; sendJson(200, jsonOk(out)); } void handleSettingsPost() { - if (!authorize("/api/settings", "POST")) return; + if (!authorize()) return; String body = requestBody(); - currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 3); + String nextHostname = jsonStringValue(body, "hostname", networkHostname); + nextHostname.trim(); + bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp); + String nextIp = jsonStringValue(body, "ip", networkIp); + String nextGateway = jsonStringValue(body, "gateway", networkGateway); + String nextSubnet = jsonStringValue(body, "subnet", networkSubnet); + String nextDns1 = jsonStringValue(body, "dns1", networkDns1); + String nextDns2 = jsonStringValue(body, "dns2", networkDns2); + nextIp.trim(); + nextGateway.trim(); + nextSubnet.trim(); + nextDns1.trim(); + nextDns2.trim(); + if (!validHostnameValue(nextHostname)) return sendJson(400, jsonError("Hostname must be 1-31 letters, digits, or hyphens, and cannot start or end with a hyphen")); + if (!nextDhcp) { + if (!validIpValue(nextIp) || !validIpValue(nextGateway) || !validIpValue(nextSubnet)) return sendJson(400, jsonError("Static IP, gateway, and subnet must be valid IPv4 addresses")); + if (nextDns1.length() && !validIpValue(nextDns1)) return sendJson(400, jsonError("DNS 1 must be a valid IPv4 address")); + if (nextDns2.length() && !validIpValue(nextDns2)) return sendJson(400, jsonError("DNS 2 must be a valid IPv4 address")); + } + currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4); maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024); ledInverted = jsonBoolValue(body, "ledInverted", ledInverted); ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100); updateUrl = jsonStringValue(body, "updateUrl", updateUrl); + networkHostname = nextHostname; + networkDhcp = nextDhcp; + networkIp = nextIp; + networkGateway = nextGateway; + networkSubnet = nextSubnet; + networkDns1 = nextDns1; + networkDns2 = nextDns2; prefs.putUChar("logLevel", currentLogLevel); prefs.putUInt("logMax", maxLogBytes); prefs.putBool("ledInv", ledInverted); prefs.putUChar("ledBright", ledBrightness); prefs.putString("updateUrl", updateUrl); + prefs.putString("netHost", networkHostname); + prefs.putBool("netDhcp", networkDhcp); + prefs.putString("netIp", networkIp); + prefs.putString("netGw", networkGateway); + prefs.putString("netMask", networkSubnet); + prefs.putString("netDns1", networkDns1); + prefs.putString("netDns2", networkDns2); applyLed(); + appendLog(LOG_SECURITY_AUDIT, "settings updated"); sendJson(200, jsonOk()); } void handleLogsGet() { - if (!authorize("/api/logs", "GET")) return; + if (!authorize()) return; File f = LittleFS.open("/logs.txt", "r"); String logs = f ? f.readString() : ""; if (f) f.close(); @@ -34,20 +90,335 @@ void handleLogsGet() { } void handleLogsClear() { - if (!authorize("/api/logs/clear", "POST")) return; + if (!authorize()) return; LittleFS.remove("/logs.txt"); + appendLog(LOG_SECURITY_AUDIT, "logs cleared"); + sendJson(200, jsonOk()); +} + +static bool validFsPath(const String &path) { + return path.length() && path.startsWith("/") && path.indexOf("..") < 0; +} + +static String fsPathArg(const String &fallback = "/") { + String path = server.arg("path"); + if (!path.length()) path = fallback; + return path; +} + +static void appendFileJson(String &out, bool &first, File &file) { + String path = file.name(); + if (!path.startsWith("/")) path = "/" + path; + if (!first) out += ","; + out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.size()) + "}"; + first = false; +} + +static void appendFilesJson(String &out, bool &first, File &dir) { + File file = dir.openNextFile(); + while (file) { + if (file.isDirectory()) { + appendFilesJson(out, first, file); + } else { + appendFileJson(out, first, file); + } + file.close(); + file = dir.openNextFile(); + } +} + +void handleFilesList() { + if (!authorize()) return; + File root = LittleFS.open("/", "r"); + if (!root) return sendJson(500, jsonError("Filesystem root not available")); + + String out = "\"files\":["; + bool first = true; + appendFilesJson(out, first, root); + root.close(); + out += "]"; + sendJson(200, jsonOk(out)); +} + +void handleFileDownload() { + if (!authorize()) return; + String path = fsPathArg(""); + if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path")); + File file = LittleFS.open(path, "r"); + if (!file || file.isDirectory()) return sendJson(404, jsonError("File not found")); + String name = path.substring(path.lastIndexOf('/') + 1); + server.sendHeader("Content-Disposition", "attachment; filename=\"" + name + "\""); + server.streamFile(file, "application/octet-stream"); + file.close(); +} + +void handleFactoryReset() { + if (!authorize()) return; + factoryReset(); + sendJson(200, jsonOk("\"restart\":true")); + delay(500); + ESP.restart(); +} + +static String apiPrefKey(const String &path, const String &method) { + String key = "acl"; + key += method[0]; + for (size_t i = 0; i < path.length(); i++) { + char c = path[i]; + if (isalnum(c)) key += c; + } + return key.substring(0, 15); +} + +static String jsonArrayStringList(const String &items) { + String out = "["; + int start = 0; + bool first = true; + while (start <= (int)items.length()) { + int end = items.indexOf('|', start); + if (end < 0) end = items.length(); + String item = items.substring(start, end); + if (item.length()) { + if (!first) out += ","; + out += "\"" + jsonEscape(item) + "\""; + first = false; + } + start = end + 1; + if (!items.length()) break; + } + out += "]"; + return out; +} + +static String extractJsonArray(const String &json, const char *key) { + String needle = "\"" + String(key) + "\""; + int p = json.indexOf(needle); + if (p < 0) return ""; + p = json.indexOf('[', p + needle.length()); + if (p < 0) return ""; + int start = p; + int depth = 0; + bool inString = false; + bool esc = false; + for (; p < (int)json.length(); p++) { + char c = json[p]; + if (esc) { + esc = false; + } else if (c == '\\') { + esc = inString; + } else if (c == '"') { + inString = !inString; + } else if (!inString && c == '[') { + depth++; + } else if (!inString && c == ']') { + depth--; + if (depth == 0) return json.substring(start, p + 1); + } + } + return ""; +} + +static String objectAt(const String &array, int &pos) { + while (pos < (int)array.length() && array[pos] != '{') pos++; + if (pos >= (int)array.length()) return ""; + int start = pos; + int depth = 0; + bool inString = false; + bool esc = false; + for (; pos < (int)array.length(); pos++) { + char c = array[pos]; + if (esc) { + esc = false; + } else if (c == '\\') { + esc = inString; + } else if (c == '"') { + inString = !inString; + } else if (!inString && c == '{') { + depth++; + } else if (!inString && c == '}') { + depth--; + if (depth == 0) return array.substring(start, ++pos); + } + } + return ""; +} + +static String stringArrayToRoles(const String &array) { + String out; + int p = 0; + while (p < (int)array.length()) { + while (p < (int)array.length() && array[p] != '"') p++; + if (p >= (int)array.length()) break; + p++; + String item; + bool esc = false; + while (p < (int)array.length()) { + char c = array[p++]; + if (esc) { + item += c == 'n' ? '\n' : c == 'r' ? '\r' : c; + esc = false; + } else if (c == '\\') { + esc = true; + } else if (c == '"') { + break; + } else { + item += c; + } + } + if (item.length()) { + if (out.length()) out += "|"; + out += item; + } + } + return out; +} + +static String cleanCustomRolesBackup(const String &roles) { + String out; + int start = 0; + while (start <= (int)roles.length()) { + int end = roles.indexOf('|', start); + if (end < 0) end = roles.length(); + String role = roles.substring(start, end); + if (validName(role) && !isSystemRole(role) && !hasRole(out, role)) { + if (out.length()) out += "|"; + out += role; + } + start = end + 1; + if (!roles.length()) break; + } + return out; +} + +void handleConfigExport() { + if (!authorize()) return; + User users[MAX_USERS]; + size_t userCount = parseUsers(users, MAX_USERS); + + String out = "{"; + out += "\"version\":1"; + out += ",\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(false) + "}"; + out += ",\"customRoles\":" + jsonArrayStringList(prefString("roles", "")); + out += ",\"users\":["; + for (size_t i = 0; i < userCount; i++) { + if (i) out += ","; + out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"passwordHash\":\"" + jsonEscape(users[i].passwordHash) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}"; + } + out += "],\"apiAcls\":["; + for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) { + ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT]; + if (i) out += ","; + out += "{\"path\":\"" + jsonEscape(api->path) + "\",\"method\":\"" + jsonEscape(api->method) + "\",\"role\":\"" + jsonEscape(configuredRole(api)) + "\"}"; + } + out += "],\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"}"; + + server.sendHeader("Cache-Control", "no-store"); + server.sendHeader("Content-Disposition", "attachment; filename=\"config-backup.json\""); + server.send(200, "application/json", out); +} + +void handleConfigImport() { + if (!authorize()) return; + String body = requestBody(); + if (!body.length()) return sendJson(400, jsonError("Configuration JSON is required")); + + String customRoles = cleanCustomRolesBackup(stringArrayToRoles(extractJsonArray(body, "customRoles"))); + prefs.putString("roles", customRoles); + + User users[MAX_USERS]; + size_t userCount = 0; + String usersArray = extractJsonArray(body, "users"); + int pos = 0; + while (userCount < MAX_USERS) { + String item = objectAt(usersArray, pos); + if (!item.length()) break; + String username = jsonStringValue(item, "username"); + String password = jsonStringValue(item, "passwordHash"); + if (!validName(username) || !password.length()) return sendJson(400, jsonError("Invalid user in backup")); + users[userCount].name = username; + users[userCount].passwordHash = password; + users[userCount].roles = cleanRoles(jsonStringValue(item, "roles", "WebUIConnect")); + users[userCount].active = jsonBoolValue(item, "active", true); + userCount++; + } + if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user")); + bool hasActiveSysadmin = false; + for (size_t i = 0; i < userCount; i++) { + if (users[i].active && hasRole(users[i].roles, "Sysadmin")) hasActiveSysadmin = true; + } + if (!hasActiveSysadmin) return sendJson(400, jsonError("Backup must contain an active Sysadmin user")); + saveUsers(users, userCount); + + currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4); + maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024); + updateUrl = jsonStringValue(body, "updateUrl", updateUrl); + ledInverted = jsonBoolValue(body, "ledInverted", ledInverted); + ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100); + String nextHostname = jsonStringValue(body, "hostname", networkHostname); + nextHostname.trim(); + bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp); + String nextIp = jsonStringValue(body, "ip", networkIp); + String nextGateway = jsonStringValue(body, "gateway", networkGateway); + String nextSubnet = jsonStringValue(body, "subnet", networkSubnet); + String nextDns1 = jsonStringValue(body, "dns1", networkDns1); + String nextDns2 = jsonStringValue(body, "dns2", networkDns2); + nextIp.trim(); + nextGateway.trim(); + nextSubnet.trim(); + nextDns1.trim(); + nextDns2.trim(); + if (validHostnameValue(nextHostname)) networkHostname = nextHostname; + networkDhcp = nextDhcp; + if (networkDhcp || (validIpValue(nextIp) && validIpValue(nextGateway) && validIpValue(nextSubnet))) { + networkIp = nextIp; + networkGateway = nextGateway; + networkSubnet = nextSubnet; + if (!nextDns1.length() || validIpValue(nextDns1)) networkDns1 = nextDns1; + if (!nextDns2.length() || validIpValue(nextDns2)) networkDns2 = nextDns2; + } + prefs.putUChar("logLevel", currentLogLevel); + prefs.putUInt("logMax", maxLogBytes); + prefs.putString("updateUrl", updateUrl); + prefs.putBool("ledInv", ledInverted); + prefs.putUChar("ledBright", ledBrightness); + prefs.putString("netHost", networkHostname); + prefs.putBool("netDhcp", networkDhcp); + prefs.putString("netIp", networkIp); + prefs.putString("netGw", networkGateway); + prefs.putString("netMask", networkSubnet); + prefs.putString("netDns1", networkDns1); + prefs.putString("netDns2", networkDns2); + prefs.putString("cert", jsonStringValue(body, "certificate", "")); + + String aclsArray = extractJsonArray(body, "apiAcls"); + pos = 0; + while (true) { + String item = objectAt(aclsArray, pos); + if (!item.length()) break; + String path = jsonStringValue(item, "path"); + String method = jsonStringValue(item, "method", "GET"); + method.toUpperCase(); + String role = jsonStringValue(item, "role", "PUBLIC"); + ApiDef *api = findApi(path, method); + if (api && isKnownRole(role)) { + prefs.putString(apiPrefKey(path, method).c_str(), role); + } + } + + applyLed(); + appendLog(LOG_SECURITY_AUDIT, "configuration restored from backup"); sendJson(200, jsonOk()); } void handleCertGet() { - if (!authorize("/api/cert", "GET")) return; + if (!authorize()) return; sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"")); } void handleCertPost() { - if (!authorize("/api/cert", "POST")) return; + if (!authorize()) return; String cert = jsonStringValue(requestBody(), "certificate"); prefs.putString("cert", cert); - appendLog(LOG_INFO, "HTTPS certificate material saved"); + appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved"); sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\"")); } diff --git a/src/handlers/handlers_api.cpp b/src/handlers/handlers_api.cpp index 8a87100..cec39e9 100644 --- a/src/handlers/handlers_api.cpp +++ b/src/handlers/handlers_api.cpp @@ -1,41 +1,20 @@ -#include "../app.h" - -void handlePing() { - if (!authorize("/api/ping", "GET")) return; - sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\"")); -} - -void handleAdd() { - if (!authorize("/api/add", "POST")) return; - String body = requestBody(); - int a = jsonIntValue(body, "a", server.arg("a").toInt()); - int b = jsonIntValue(body, "b", server.arg("b").toInt()); - sendJson(200, jsonOk("\"result\":" + String(a + b))); -} - -void handleLed() { - if (!authorize("/api/led", "POST")) return; - ledBrightness = constrain(jsonIntValue(requestBody(), "brightness", server.arg("brightness").toInt()), 0, 100); - prefs.putUChar("ledBright", ledBrightness); - applyLed(); - appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness)); - sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness))); -} +#include "app.h" void handleApisGet() { - if (!authorize("/api/apis", "GET")) return; + if (!authorize()) return; String out = "\"apis\":["; - for (size_t i = 0; i < API_DEF_COUNT; i++) { + for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) { + ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT]; if (i) out += ","; - String role = configuredRole(&apiDefs[i]); - out += "{\"path\":\"" + String(apiDefs[i].path) + "\",\"method\":\"" + String(apiDefs[i].method) + "\",\"role\":\"" + jsonEscape(role) + "\"}"; + String role = configuredRole(api); + out += "{\"path\":\"" + String(api->path) + "\",\"method\":\"" + String(api->method) + "\",\"role\":\"" + jsonEscape(role) + "\"}"; } out += "]"; sendJson(200, jsonOk(out)); } void handleApisPost() { - if (!authorize("/api/apis", "POST")) return; + if (!authorize()) return; String body = requestBody(); String path = jsonStringValue(body, "path"); String method = jsonStringValue(body, "method", "GET"); @@ -51,6 +30,6 @@ void handleApisPost() { if (isalnum(c)) key += c; } prefs.putString(key.substring(0, 15).c_str(), role); - appendLog(LOG_INFO, "API ACL saved " + method + " " + path + " -> " + role); + appendLog(LOG_SECURITY_AUDIT, "API ACL saved " + method + " " + path + " -> " + role); sendJson(200, jsonOk()); } diff --git a/src/handlers/handlers_auth.cpp b/src/handlers/handlers_auth.cpp index 5216350..158ed5f 100644 --- a/src/handlers/handlers_auth.cpp +++ b/src/handlers/handlers_auth.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" void handleLogin() { String body = requestBody(); @@ -6,29 +6,32 @@ void handleLogin() { String password = jsonStringValue(body, "password"); User user; if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) { - appendLog(LOG_WARN, "failed login for " + username); + appendLog(LOG_SECURITY_AUDIT, "failed login for " + username); return sendJson(401, jsonError("Invalid username or password")); } String token = createToken(user); - appendLog(LOG_INFO, "login " + username); + appendLog(LOG_SECURITY_AUDIT, "login " + username); sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\"")); } void handleLogout() { - if (!authorize("/api/logout", "POST")) return; + if (!authorize()) return; Token *tok = currentToken(); - if (tok) *tok = Token(); + if (tok) { + appendLog(LOG_SECURITY_AUDIT, "logout " + tok->user); + *tok = Token(); + } sendJson(200, jsonOk()); } void handleMe() { - if (!authorize("/api/me", "GET")) return; + if (!authorize()) return; Token *tok = currentToken(); sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\"")); } void handleUsersGet() { - if (!authorize("/api/users", "GET")) return; + if (!authorize()) return; User users[8]; size_t count = parseUsers(users, MAX_USERS); String out = "\"users\":["; @@ -41,7 +44,7 @@ void handleUsersGet() { } void handleUsersPost() { - if (!authorize("/api/users", "POST")) return; + if (!authorize()) return; String body = requestBody(); String username = jsonStringValue(body, "username"); String password = jsonStringValue(body, "password"); @@ -61,12 +64,12 @@ void handleUsersPost() { users[idx].active = active; if (idx == count) count++; saveUsers(users, count); - appendLog(LOG_INFO, "user saved " + username); + appendLog(LOG_SECURITY_AUDIT, "user saved " + username); sendJson(200, jsonOk()); } void handlePassword() { - if (!authorize("/api/password", "POST")) return; + if (!authorize()) return; Token *tok = currentToken(); String password = jsonStringValue(requestBody(), "password"); User users[8]; @@ -75,7 +78,7 @@ void handlePassword() { if (users[i].name == tok->user) { users[i].passwordHash = passwordHash(password); saveUsers(users, count); - appendLog(LOG_INFO, "password changed " + tok->user); + appendLog(LOG_SECURITY_AUDIT, "password changed " + tok->user); return sendJson(200, jsonOk()); } } @@ -83,22 +86,22 @@ void handlePassword() { } void handleRolesGet() { - if (!authorize("/api/roles", "GET")) return; + if (!authorize()) return; sendJson(200, jsonOk(rolesJson())); } void handleRolesPost() { - if (!authorize("/api/roles", "POST")) return; + if (!authorize()) return; String role = jsonStringValue(requestBody(), "role"); if (!addCustomRole(role)) return sendJson(400, jsonError("Role is invalid or already exists")); - appendLog(LOG_INFO, "role added " + role); + appendLog(LOG_SECURITY_AUDIT, "role added " + role); sendJson(200, jsonOk(rolesJson())); } void handleRolesDelete() { - if (!authorize("/api/roles", "DELETE")) return; + if (!authorize()) return; String role = jsonStringValue(requestBody(), "role", server.arg("role")); if (!deleteCustomRole(role)) return sendJson(400, jsonError("Role cannot be deleted")); - appendLog(LOG_INFO, "role deleted " + role); + appendLog(LOG_SECURITY_AUDIT, "role deleted " + role); sendJson(200, jsonOk(rolesJson())); } diff --git a/src/handlers/handlers_custom_api.cpp b/src/handlers/handlers_custom_api.cpp new file mode 100644 index 0000000..0cc2893 --- /dev/null +++ b/src/handlers/handlers_custom_api.cpp @@ -0,0 +1,24 @@ +#include "app.h" +#include "custom_api.h" + +void handlePing() { + if (!authorize()) return; + sendJson(200, jsonOk("\"uptimeMs\":" + String(millis()) + ",\"version\":\"" + APP_VERSION + "\",\"name\":\"" + jsonEscape(deviceName()) + "\",\"ip\":\"" + WiFi.localIP().toString() + "\"")); +} + +void handleAdd() { + if (!authorize()) return; + String body = requestBody(); + int a = jsonIntValue(body, "a", server.arg("a").toInt()); + int b = jsonIntValue(body, "b", server.arg("b").toInt()); + sendJson(200, jsonOk("\"result\":" + String(a + b))); +} + +void handleLed() { + if (!authorize()) return; + ledBrightness = constrain(jsonIntValue(requestBody(), "brightness", server.arg("brightness").toInt()), 0, 100); + prefs.putUChar("ledBright", ledBrightness); + applyLed(); + appendLog(LOG_INFO, "LED brightness set to " + String(ledBrightness)); + sendJson(200, jsonOk("\"brightness\":" + String(ledBrightness))); +} diff --git a/src/handlers/handlers_ota.cpp b/src/handlers/handlers_ota.cpp index d81e442..7aa2c0b 100644 --- a/src/handlers/handlers_ota.cpp +++ b/src/handlers/handlers_ota.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" #include #include @@ -160,7 +160,7 @@ static bool streamPackageFromUrl(const String &url, String &error) { } void handleOtaCheck() { - if (!authorize("/api/ota/check", "POST")) return; + if (!authorize()) return; HTTPClient http; http.begin(updateUrl); int code = http.GET(); @@ -170,8 +170,8 @@ void handleOtaCheck() { } void handleOtaRun() { - if (!authorize("/api/ota/run", "POST")) return; - appendLog(LOG_INFO, "package URL update started"); + if (!authorize()) return; + appendLog(LOG_SECURITY_AUDIT, "package URL update started"); String error; if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error)); sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true")); @@ -180,7 +180,7 @@ void handleOtaRun() { } void handleUpdateUploadDone() { - if (!authorize("/api/update", "POST")) return; + if (!authorize()) return; bool ok = packageState.stage == PKG_DONE; String error = packageState.error.length() ? packageState.error : "Incomplete update package"; sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error)); @@ -193,8 +193,8 @@ void handleUpdateUploadDone() { void handleUpdateUploadChunk() { HTTPUpload &upload = server.upload(); if (upload.status == UPLOAD_FILE_START) { - if (!authorize("/api/update", "POST")) return; - appendLog(LOG_INFO, "package upload started"); + if (!authorize()) return; + appendLog(LOG_SECURITY_AUDIT, "package upload started"); resetPackageState(); } else if (upload.status == UPLOAD_FILE_WRITE) { feedPackageBytes(upload.buf, upload.currentSize); diff --git a/src/handlers/handlers_setup.cpp b/src/handlers/handlers_setup.cpp index a8f25e5..b854a70 100644 --- a/src/handlers/handlers_setup.cpp +++ b/src/handlers/handlers_setup.cpp @@ -1,7 +1,7 @@ -#include "../app.h" +#include "app.h" void handleWifiScan() { - if (!setupMode && !authorize("/api/settings", "GET")) return; + if (!setupMode && !authorize()) return; int n = WiFi.scanNetworks(); String out = "\"networks\":["; for (int i = 0; i < n; i++) { @@ -13,7 +13,7 @@ void handleWifiScan() { } void handleSetupSubmit() { - if (!setupMode && !authorize("/api/settings", "POST")) return; + if (!setupMode && !authorize()) return; String body = requestBody(); String ssid = jsonStringValue(body, "ssid"); String wifiPass = jsonStringValue(body, "wifiPass"); @@ -26,7 +26,7 @@ void handleSetupSubmit() { User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true}; saveUsers(&u, 1); prefs.putBool("configured", true); - appendLog(LOG_INFO, "initial setup saved"); + appendLog(LOG_SECURITY_AUDIT, "initial setup saved"); sendJson(200, jsonOk("\"restart\":true")); delay(500); ESP.restart(); diff --git a/src/util/json_utils.cpp b/src/util/json_utils.cpp index 03cb599..31afc45 100644 --- a/src/util/json_utils.cpp +++ b/src/util/json_utils.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" static int findJsonKey(const String &json, const char *key) { String needle = "\"" + String(key) + "\""; diff --git a/src/web/routes.cpp b/src/web/routes.cpp index 4c18bf0..6cfe442 100644 --- a/src/web/routes.cpp +++ b/src/web/routes.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" static HTTPMethod httpMethod(const char *method) { if (strcmp(method, "GET") == 0) return HTTP_GET; @@ -9,9 +9,9 @@ static HTTPMethod httpMethod(const char *method) { return HTTP_ANY; } -static void registerApiRoutes() { - for (size_t i = 0; i < API_DEF_COUNT; i++) { - ApiDef &api = apiDefs[i]; +static void registerApiDefs(ApiDef *defs, size_t count) { + for (size_t i = 0; i < count; i++) { + ApiDef &api = defs[i]; if (api.uploadHandler) { server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler); } else { @@ -20,6 +20,28 @@ static void registerApiRoutes() { } } +static void registerApiRoutes() { + registerApiDefs(apiDefs, API_DEF_COUNT); + registerApiDefs(customApiDefs, CUSTOM_API_DEF_COUNT); +} + +static String requestMethodName() { + switch (server.method()) { + case HTTP_GET: + return "GET"; + case HTTP_POST: + return "POST"; + case HTTP_DELETE: + return "DELETE"; + case HTTP_PUT: + return "PUT"; + case HTTP_PATCH: + return "PATCH"; + default: + return "UNKNOWN"; + } +} + void registerRoutes() { static const char *headers[] = {"Authorization", "X-Auth-Token"}; server.collectHeaders(headers, 2); @@ -38,10 +60,16 @@ void registerRoutes() { server.onNotFound([]() { if (setupMode) { - if (!handleHtmlFileRequest()) redirectToSetupPage(); + if (server.uri().startsWith("/api/")) { + appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri()); + sendJson(404, jsonError("Not found")); + } else if (!handleHtmlFileRequest()) { + redirectToSetupPage(); + } } else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) { if (!handleHtmlFileRequest()) handleAdminPage(); } else { + appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri()); sendJson(404, jsonError("Not found")); } }); diff --git a/src/web/ui.cpp b/src/web/ui.cpp index e989beb..7eb8ce1 100644 --- a/src/web/ui.cpp +++ b/src/web/ui.cpp @@ -1,4 +1,4 @@ -#include "../app.h" +#include "app.h" #include