From bb7aa5d5ca08c5532d366bfbc5ed68810f951d85 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Sat, 27 Jun 2026 15:58:42 -0500 Subject: [PATCH] Implementation of ESP32C3 boilerplate -- not yet finished but functional. --- .gitignore | 5 + .vscode/extensions.json | 10 + README.md | 314 +++++++++++++++++++++++++++++++ data/admin.html | 94 +++++++++ data/setup.html | 47 +++++ include/README | 37 ++++ lib/README | 46 +++++ platformio.ini | 15 ++ scripts/create_update_package.py | 35 ++++ src/app.h | 159 ++++++++++++++++ src/config/api_definitions.cpp | 31 +++ src/core/auth.cpp | 266 ++++++++++++++++++++++++++ src/core/device.cpp | 53 ++++++ src/core/logging.cpp | 32 ++++ src/core/state.cpp | 89 +++++++++ src/handlers/handlers_admin.cpp | 53 ++++++ src/handlers/handlers_api.cpp | 56 ++++++ src/handlers/handlers_auth.cpp | 104 ++++++++++ src/handlers/handlers_ota.cpp | 202 ++++++++++++++++++++ src/handlers/handlers_setup.cpp | 33 ++++ src/main.cpp | 38 ++++ src/util/json_utils.cpp | 106 +++++++++++ src/web/routes.cpp | 48 +++++ src/web/ui.cpp | 48 +++++ test/README | 11 ++ 25 files changed, 1932 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 README.md create mode 100644 data/admin.html create mode 100644 data/setup.html create mode 100644 include/README create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 scripts/create_update_package.py create mode 100644 src/app.h create mode 100644 src/config/api_definitions.cpp create mode 100644 src/core/auth.cpp create mode 100644 src/core/device.cpp create mode 100644 src/core/logging.cpp create mode 100644 src/core/state.cpp create mode 100644 src/handlers/handlers_admin.cpp create mode 100644 src/handlers/handlers_api.cpp create mode 100644 src/handlers/handlers_auth.cpp create mode 100644 src/handlers/handlers_ota.cpp create mode 100644 src/handlers/handlers_setup.cpp create mode 100644 src/main.cpp create mode 100644 src/util/json_utils.cpp create mode 100644 src/web/routes.cpp create mode 100644 src/web/ui.cpp create mode 100644 test/README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5762142 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0678aa0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..a318158 --- /dev/null +++ b/README.md @@ -0,0 +1,314 @@ +# Boilerplate project for ESP32 (c3) development. +## Specifications +### Summary +This project is a starting point for ESP32-C3 development. +It provides basic "infrastructure" and "Framework" for specific developments. + +### Functionality +- Enables the user to connect to their WiFi network + - Has a mechanism to "Factory reset" the device +- Eases updates by providing OTA update mechanism using update-server URL +- Role based User Management + - Standard Roles + - Sysadmin (Can administer the system, update, change settings) + - UserAdmin (Can administer user accounts) + - WebUIConnect (Allows logging in to the Web UI) + - Debugger (Allowed to use API Test UI in the Web UI) +- Secure, Role based Rest API for all functions + - API functions access can be configured either as "Public" or a user role can be assigned to determine access (only users who have the corresponding rolle can call that API). + - User authentication needs to return a token that is needed to be passed into all rest API requests (unless the called API endpoint is public) + - All admin functions (including HTTPS configuration, logs, password changes, ...) are available in the API by default + - All API calls will return a JSON that returns + - Result of the last call (either success or detailed error description) + - If applicable, the result of the call + - There are 3 boilerplate APIs that are available publicly by default + - 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 + - Role based security configuration + - Roles are centrally maintained + - User management + - Users can change their password + - Users can be assigned zero or more roles + - Allows to interactively calling of the Rest APIs via the web UI + - The corresponding user token is preset with the current user's token by decfault, but it can be overwritten + - This functionality is only accessible when the user has the role Debugger + - Enables secrity configuration of the exposed API functions + - Configuration of logging + - log level + - log max size in non volatile memory (default 50kb) + - Viewing- and management of logs + - filter + - search + - view + - clear logs + - HTTPS 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.) + + +### Technical details +- The target platform is ESP32-C3 with Arduino Framework +- Memory is to be treated as a sparse resource, so the size of the code must be kept small +- The API layer exclusively uses JSON messages to communicate in both direcitons +- The Web UI is to be implemented as a Vanilla JS single page web site UI that communicates with the backend via the REST API. +- All documentation goes into the README.md file +- The Admin UI is implemented in an extendable way, so that developers can easily use the same mechnism to implement their own UIs +- The "factory reset" function should execute when a definable PIN (choose a good default) is pulled high- or low on boot and held that way for 10 seconds. +- When starting after initial flashing or factory reset, the device needs to act as an unsecured access point with a defined SSID . + - The user can then connect to that AP and is presented with a basic user interface (setup screen) where the user can + - Choose their WIFI + - Enter the password of their WIFI (can be left empty for unsecuried wifi) + - Change the admin useranme (defaults to "admin") + - Enter an Admin password (can be left empty) + - A submit button that submits the entered information + - 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 + - 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) + +### Source organization + +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 | +| `src/config/api_definitions.cpp` | Central API catalog, route handlers, and default role/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/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_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 | +| `data/setup.html` | First-run provisioning UI served from LittleFS | +| `data/admin.html` | Admin UI served from LittleFS | + +## Current implementation + +The firmware in `src/main.cpp` implements the boilerplate as a compact Arduino ESP32-C3 application using built-in ESP32 Arduino libraries only: + +- WiFi provisioning access point and first-run setup UI. +- Factory reset on boot by holding `FACTORY_RESET_PIN` high/low default behavior: GPIO4 held LOW for 10 seconds. +- Admin Web UI served from `/`. +- Token-based login with role checks. +- Configurable API access control where each route can be `PUBLIC` or require one role. +- Public boilerplate APIs: ping, add, and LED brightness. +- LED brightness is persisted in non-volatile memory, applied on boot, and loaded into the Admin UI slider. +- User management with the standard roles `Sysadmin`, `UserAdmin`, `WebUIConnect`, and `Debugger`. +- Custom role management. System roles are protected and cannot be deleted. +- Active/inactive user accounts with role checkboxes in the Admin UI. +- API security management through a list/detail UI with a role dropdown per API. +- 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. + +### Web UI files + +The setup and admin pages are stored as editable HTML files in the PlatformIO `data/` directory and are served from LittleFS by request path: + +| URL | File | +| --- | --- | +| `/` in setup mode | `/setup.html` | +| `/` in normal mode | `/admin.html` | +| `/setup.html` | `/setup.html` | +| `/admin.html` | `/admin.html` | + +After changing files in `data/`, upload the filesystem image as well as the firmware: + +```sh +pio run -t uploadfs +``` + +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 + +After initial flashing or factory reset, the device starts an unsecured access point: + +```text +TSL-Embedded-XXXXXX +``` + +Connect to the access point and open: + +```text +http://192.168.1.1/ +``` + +While in setup mode, the device also runs a captive-portal DNS responder. Most phones and laptops will automatically open or suggest the setup page after joining the AP; unknown HTTP requests are redirected to `http://192.168.1.1/`. + +The setup page lets you choose WiFi, set the WiFi password, set the admin username, and set the admin password. The default admin username is `admin`; the password may be empty. After submit, the device stores the configuration in non-volatile memory and restarts. + +The default project name is `TSL-Embedded`. The firmware combines the project name with the chip suffix to form the device name, for example `TSL-Embedded-BDF5F0`. That name is used as the setup AP SSID, the station-mode WiFi hostname, and the mDNS hostname. Override the project name at compile time if needed: + +```cpp +#define PROJECT_NAME "MyProject" +``` + +On normal boot, the device connects to the configured WiFi and serves the Admin UI at the IP printed to serial. + +### 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. + +The defaults can be changed at compile time: + +```cpp +#define FACTORY_RESET_PIN 4 +#define FACTORY_RESET_ACTIVE_LEVEL LOW +``` + +### Authentication + +Login: + +```http +POST /api/login +Content-Type: application/json + +{"username":"admin","password":""} +``` + +The response contains a bearer token. Pass it to protected APIs: + +```http +Authorization: Bearer +``` + +### API response shape + +Every API returns JSON with a `success` field. Errors include an `error` string: + +```json +{"success":false,"error":"Authentication required"} +``` + +### Public boilerplate APIs + +Ping: + +```http +GET /api/ping +``` + +Add two integers: + +```http +POST /api/add +Content-Type: application/json + +{"a":1,"b":2} +``` + +Set LED brightness from 0 to 100: + +```http +POST /api/led +Content-Type: application/json + +{"brightness":50} +``` + +### Admin APIs + +Protected APIs and their default roles: + +| API | Role | +| --- | --- | +| `POST /api/logout` | `WebUIConnect` | +| `GET /api/me` | `WebUIConnect` | +| `GET /api/apis` | `Sysadmin` | +| `POST /api/apis` | `Sysadmin` | +| `GET /api/users` | `UserAdmin` | +| `POST /api/users` | `UserAdmin` | +| `POST /api/password` | `WebUIConnect` | +| `GET /api/settings` | `Sysadmin` | +| `POST /api/settings` | `Sysadmin` | +| `GET /api/logs` | `Debugger` | +| `POST /api/logs/clear` | `Debugger` | +| `POST /api/ota/check` | `Sysadmin` | +| `POST /api/ota/run` | `Sysadmin` | +| `POST /api/update` | `Sysadmin` | +| `GET /api/cert` | `Sysadmin` | +| `POST /api/cert` | `Sysadmin` | + +Change API security: + +```http +POST /api/apis +Authorization: Bearer +Content-Type: application/json + +{"path":"/api/led","method":"POST","role":"WebUIConnect"} +``` + +Use `"role":"PUBLIC"` to make a known API public. + +### Firmware and filesystem updates + +Because the Admin UI is stored in LittleFS, OTA releases are distributed as one combined update package. The package contains both the LittleFS filesystem image and the firmware image, so UI and firmware changes cannot drift apart during an update. + +| Artifact | Purpose | Build output | +| --- | --- | --- | +| `firmware.bin` | Intermediate ESP32 application firmware | `.pio/build/seeed_xiao_esp32c3/firmware.bin` | +| `littlefs.bin` | Intermediate files from `data/`, including `admin.html` and `setup.html` | `.pio/build/seeed_xiao_esp32c3/littlefs.bin` | +| `update.tslpkg` | Single OTA package containing LittleFS plus firmware | `.pio/build/seeed_xiao_esp32c3/update.tslpkg` | + +Create the single OTA package: + +```sh +pio run +pio run -t buildfs +python scripts/create_update_package.py +``` + +For a direct USB flash during development: + +```sh +pio run -t upload +pio run -t uploadfs +``` + +For OTA updates, host `update.tslpkg` on your update server. The Admin UI has one update package URL. The default is: + +```text +http://example.com/update.tslpkg +``` + +The package format is intentionally small: + +| Offset | Size | Value | +| --- | --- | --- | +| `0` | 8 | Magic bytes `TSLUPD1\0` | +| `8` | 4 | Header size, little-endian `uint32`, currently `32` | +| `12` | 4 | LittleFS image size, little-endian `uint32` | +| `16` | 4 | Firmware image size, little-endian `uint32` | +| `20` | 12 | Reserved, zero-filled | +| `32` | variable | LittleFS image bytes | +| `32 + littlefsSize` | variable | Firmware image bytes | + +Upload an update package from the Admin UI or post multipart form data to: + +```http +POST /api/update +``` + +`/api/ota/check` checks reachability and content length for the configured package URL. `/api/ota/run` streams the package from the URL, applies the LittleFS image first, applies the firmware image second, and restarts. + +Installing an update package replaces the LittleFS partition, including stored log files. WiFi configuration, users, roles, API ACLs, and settings are stored in NVS preferences and are not part of the LittleFS image. + diff --git a/data/admin.html b/data/admin.html new file mode 100644 index 0000000..075de1b --- /dev/null +++ b/data/admin.html @@ -0,0 +1,94 @@ + + + + + ESP32-C3 Admin + + + +
ESP32-C3 Admin
+
+
+

Login

+ + + +

+    
+ +
+ + + diff --git a/data/setup.html b/data/setup.html new file mode 100644 index 0000000..ab893df --- /dev/null +++ b/data/setup.html @@ -0,0 +1,47 @@ + + + + + ESP32-C3 first-run setup + + + +
ESP32-C3 first-run setup
+
+
+

Network and admin setup

+ + + + + + + + + +

+    
+
+ + + diff --git a/include/README b/include/README new file mode 100644 index 0000000..630164d --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..8d3ee2a --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..9769150 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,15 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:seeed_xiao_esp32c3] +platform = espressif32 +board = seeed_xiao_esp32c3 +framework = arduino +board_build.filesystem = littlefs diff --git a/scripts/create_update_package.py b/scripts/create_update_package.py new file mode 100644 index 0000000..72e53bc --- /dev/null +++ b/scripts/create_update_package.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +import argparse +import struct +from pathlib import Path + +MAGIC = b"TSLUPD1\0" +HEADER_SIZE = 32 + + +def main(): + parser = argparse.ArgumentParser(description="Create a combined ESP32 update package.") + parser.add_argument("--firmware", default=".pio/build/seeed_xiao_esp32c3/firmware.bin") + parser.add_argument("--filesystem", default=".pio/build/seeed_xiao_esp32c3/littlefs.bin") + parser.add_argument("--output", default=".pio/build/seeed_xiao_esp32c3/update.tslpkg") + args = parser.parse_args() + + firmware_path = Path(args.firmware) + filesystem_path = Path(args.filesystem) + output_path = Path(args.output) + + firmware = firmware_path.read_bytes() + filesystem = filesystem_path.read_bytes() + output_path.parent.mkdir(parents=True, exist_ok=True) + + header = MAGIC + struct.pack(" +#include +#include +#include +#include + +#ifndef LED_BUILTIN +#define LED_BUILTIN 8 +#endif + +#ifndef FACTORY_RESET_PIN +#define FACTORY_RESET_PIN 4 +#endif + +#ifndef FACTORY_RESET_ACTIVE_LEVEL +#define FACTORY_RESET_ACTIVE_LEVEL LOW +#endif + +#ifndef PROJECT_NAME +#define PROJECT_NAME "TSL-Embedded" +#endif + +extern const char *APP_VERSION; +extern const char *PROJECT_NAME_VALUE; +extern const char *DEFAULT_ADMIN; +extern const char *DEFAULT_UPDATE_URL; +extern const uint32_t FACTORY_RESET_HOLD_MS; +extern const size_t MAX_USERS; +extern const size_t MAX_TOKENS; +extern const bool DEFAULT_LED_INVERTED; +extern const byte DNS_PORT; +extern const IPAddress SETUP_AP_IP; +extern const IPAddress SETUP_AP_GATEWAY; +extern const IPAddress SETUP_AP_SUBNET; + +enum LogLevel : uint8_t { + LOG_ERROR = 0, + LOG_WARN = 1, + LOG_INFO = 2, + LOG_DEBUG = 3 +}; + +struct User { + String name; + String passwordHash; + String roles; + bool active; +}; + +struct Token { + String value; + String user; + String roles; + uint32_t lastSeen; +}; + +typedef void (*RouteHandler)(); + +struct ApiDef { + const char *path; + const char *method; + const char *defaultRole; + bool publicByDefault; + RouteHandler handler; + RouteHandler uploadHandler; +}; + +extern WebServer server; +extern DNSServer dnsServer; +extern Preferences prefs; +extern Token tokens[]; +extern ApiDef apiDefs[]; +extern const size_t API_DEF_COUNT; +extern LogLevel currentLogLevel; +extern size_t maxLogBytes; +extern bool setupMode; +extern uint8_t ledBrightness; +extern bool ledInverted; +extern String updateUrl; + +String jsonEscape(const String &s); +String jsonOk(const String &payload = ""); +String jsonError(const String &message); +void sendJson(int code, const String &body); +String requestBody(); +String jsonStringValue(const String &json, const char *key, const String &fallback = ""); +int jsonIntValue(const String &json, const char *key, int fallback = 0); +bool jsonBoolValue(const String &json, const char *key, bool fallback = false); +String jsonRolesValue(const String &json, const char *key, const String &fallback = ""); + +String chipId(); +String deviceName(); +String sha256(const String &input); +String passwordHash(const String &password); +String prefString(const char *key, const String &fallback = ""); +void loadSettings(); + +bool hasRole(const String &roles, const String &role); +bool validName(const String &s); +String cleanRoles(const String &roles); +bool isKnownRole(const String &role); +bool isSystemRole(const String &role); +String allRoles(); +String rolesJson(); +bool addCustomRole(const String &role); +bool deleteCustomRole(const String &role); +size_t parseUsers(User *users, size_t maxUsers); +void saveUsers(User *users, size_t count); +bool findUser(const String &name, User &user); +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); + +void appendLog(LogLevel level, const String &message); + +void applyLed(); +void factoryReset(); +void checkFactoryResetPin(); +bool connectWifi(); + +void handleSetupPage(); +bool handleHtmlFileRequest(); +void redirectToSetupPage(); +void handleCaptiveProbe(); +void handleFavicon(); +void handleAdminPage(); + +void handleWifiScan(); +void handleSetupSubmit(); +void handlePing(); +void handleAdd(); +void handleLed(); +void handleLogin(); +void handleLogout(); +void handleMe(); +void handleUsersGet(); +void handleUsersPost(); +void handlePassword(); +void handleRolesGet(); +void handleRolesPost(); +void handleRolesDelete(); +void handleApisGet(); +void handleApisPost(); +void handleSettingsGet(); +void handleSettingsPost(); +void handleLogsGet(); +void handleLogsClear(); +void handleOtaCheck(); +void handleOtaRun(); +void handleUpdateUploadDone(); +void handleUpdateUploadChunk(); +void handleCertGet(); +void handleCertPost(); + +void registerRoutes(); diff --git a/src/config/api_definitions.cpp b/src/config/api_definitions.cpp new file mode 100644 index 0000000..3e2a8bd --- /dev/null +++ b/src/config/api_definitions.cpp @@ -0,0 +1,31 @@ +#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}, + {"/api/apis", "GET", "Sysadmin", false, handleApisGet, nullptr}, + {"/api/apis", "POST", "Sysadmin", false, handleApisPost, nullptr}, + {"/api/users", "GET", "UserAdmin", false, handleUsersGet, nullptr}, + {"/api/users", "POST", "UserAdmin", false, handleUsersPost, nullptr}, + {"/api/roles", "GET", "UserAdmin", false, handleRolesGet, nullptr}, + {"/api/roles", "POST", "UserAdmin", false, handleRolesPost, nullptr}, + {"/api/roles", "DELETE", "UserAdmin", false, handleRolesDelete, nullptr}, + {"/api/password", "POST", "WebUIConnect", false, handlePassword, nullptr}, + {"/api/settings", "GET", "Sysadmin", false, handleSettingsGet, nullptr}, + {"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr}, + {"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr}, + {"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr}, + {"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr}, + {"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr}, + {"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk}, + {"/api/cert", "GET", "Sysadmin", false, handleCertGet, nullptr}, + {"/api/cert", "POST", "Sysadmin", false, handleCertPost, nullptr}, +}; + +const size_t API_DEF_COUNT = sizeof(apiDefs) / sizeof(apiDefs[0]); diff --git a/src/core/auth.cpp b/src/core/auth.cpp new file mode 100644 index 0000000..b552523 --- /dev/null +++ b/src/core/auth.cpp @@ -0,0 +1,266 @@ +#include "../app.h" + +bool hasRole(const String &roles, const String &role) { + if (role.length() == 0) return true; + int start = 0; + while (start <= (int)roles.length()) { + int end = roles.indexOf('|', start); + if (end < 0) end = roles.length(); + if (roles.substring(start, end) == role) return true; + start = end + 1; + } + return false; +} + +bool validName(const String &s) { + if (s.length() == 0 || s.length() > 31) return false; + for (size_t i = 0; i < s.length(); i++) { + char c = s[i]; + if (!(isalnum(c) || c == '_' || c == '-' || c == '.')) return false; + } + return true; +} + +String cleanRoles(const String &roles) { + String out; + String known = allRoles(); + int start = 0; + while (start <= (int)known.length()) { + int end = known.indexOf('|', start); + if (end < 0) end = known.length(); + String role = known.substring(start, end); + if (role.length() && hasRole(roles, role)) { + if (out.length()) out += "|"; + out += role; + } + start = end + 1; + } + return out; +} + +bool isKnownRole(const String &role) { + return role == "PUBLIC" || hasRole(allRoles(), role); +} + +bool isSystemRole(const String &role) { + return role == "Sysadmin" || role == "UserAdmin" || role == "WebUIConnect" || role == "Debugger"; +} + +String allRoles() { + String roles = "Sysadmin|UserAdmin|WebUIConnect|Debugger"; + String custom = prefString("roles", ""); + int start = 0; + while (start <= (int)custom.length()) { + int end = custom.indexOf('|', start); + if (end < 0) end = custom.length(); + String role = custom.substring(start, end); + if (role.length() && !hasRole(roles, role)) roles += "|" + role; + start = end + 1; + if (!custom.length()) break; + } + return roles; +} + +String rolesJson() { + String roles = allRoles(); + String out = "\"roles\":["; + int start = 0; + bool first = true; + while (start <= (int)roles.length()) { + int end = roles.indexOf('|', start); + if (end < 0) end = roles.length(); + String role = roles.substring(start, end); + if (role.length()) { + if (!first) out += ","; + out += "{\"name\":\"" + jsonEscape(role) + "\",\"system\":" + String(isSystemRole(role) ? "true" : "false") + "}"; + first = false; + } + start = end + 1; + } + out += "]"; + return out; +} + +bool addCustomRole(const String &role) { + if (!validName(role) || isKnownRole(role)) return false; + String custom = prefString("roles", ""); + if (custom.length()) custom += "|"; + custom += role; + prefs.putString("roles", custom); + return true; +} + +bool deleteCustomRole(const String &role) { + if (!validName(role) || isSystemRole(role) || !hasRole(prefString("roles", ""), role)) return false; + String custom = prefString("roles", ""); + String kept; + int start = 0; + while (start <= (int)custom.length()) { + int end = custom.indexOf('|', start); + if (end < 0) end = custom.length(); + String item = custom.substring(start, end); + if (item.length() && item != role) { + if (kept.length()) kept += "|"; + kept += item; + } + start = end + 1; + } + prefs.putString("roles", kept); + + User users[8]; + size_t count = parseUsers(users, MAX_USERS); + 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; + 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]; + if (isalnum(c)) key += c; + } + prefs.putString(key.substring(0, 15).c_str(), fallback); + } + } + return true; +} + +static String defaultUsers() { + return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSysadmin|UserAdmin|WebUIConnect|Debugger\t1\n"; +} + +static String usersText() { + String text = prefString("users", ""); + return text.length() ? text : defaultUsers(); +} + +size_t parseUsers(User *users, size_t maxUsers) { + String text = usersText(); + size_t count = 0; + int start = 0; + while (start < (int)text.length() && count < maxUsers) { + int end = text.indexOf('\n', start); + if (end < 0) end = text.length(); + String line = text.substring(start, end); + int a = line.indexOf('\t'); + int b = line.indexOf('\t', a + 1); + int c = line.indexOf('\t', b + 1); + if (a > 0 && b > a) { + users[count].name = line.substring(0, a); + users[count].passwordHash = line.substring(a + 1, b); + users[count].roles = cleanRoles(c > b ? line.substring(b + 1, c) : line.substring(b + 1)); + users[count].active = c > b ? line.substring(c + 1).toInt() != 0 : true; + count++; + } + start = end + 1; + } + return count; +} + +void saveUsers(User *users, size_t count) { + String text; + for (size_t i = 0; i < count; i++) { + text += users[i].name + "\t" + users[i].passwordHash + "\t" + cleanRoles(users[i].roles) + "\t" + String(users[i].active ? "1" : "0") + "\n"; + } + prefs.putString("users", text); +} + +bool findUser(const String &name, User &user) { + User users[8]; + size_t count = parseUsers(users, MAX_USERS); + for (size_t i = 0; i < count; i++) { + if (users[i].name == name) { + user = users[i]; + return true; + } + } + return false; +} + +String createToken(const User &user) { + String value = sha256(String(esp_random(), HEX) + ":" + user.name + ":" + String(millis())); + int slot = 0; + uint32_t oldest = tokens[0].lastSeen; + for (int i = 0; i < (int)MAX_TOKENS; i++) { + if (!tokens[i].value.length()) { + slot = i; + break; + } + if (tokens[i].lastSeen < oldest) { + oldest = tokens[i].lastSeen; + slot = i; + } + } + tokens[slot].value = value; + tokens[slot].user = user.name; + tokens[slot].roles = user.roles; + tokens[slot].lastSeen = millis(); + return value; +} + +static String bearerToken() { + String auth = server.header("Authorization"); + if (auth.startsWith("Bearer ")) return auth.substring(7); + if (server.hasHeader("X-Auth-Token")) return server.header("X-Auth-Token"); + if (server.hasArg("token")) return server.arg("token"); + return ""; +} + +Token *currentToken() { + String value = bearerToken(); + if (!value.length()) return nullptr; + for (size_t i = 0; i < MAX_TOKENS; i++) { + if (tokens[i].value == value) { + tokens[i].lastSeen = millis(); + return &tokens[i]; + } + } + return nullptr; +} + +static String apiKey(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); +} + +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]; + } + return nullptr; +} + +String configuredRole(ApiDef *api) { + if (!api) return ""; + String key = apiKey(api->path, api->method); + return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole); +} + +bool authorize(const String &path, const String &method) { + ApiDef *api = findApi(path, method); + String required = configuredRole(api); + if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true; + Token *token = currentToken(); + if (!token) { + sendJson(401, jsonError("Authentication required")); + return false; + } + User user; + if (!findUser(token->user, user) || !user.active) { + sendJson(401, jsonError("User is inactive")); + return false; + } + token->roles = user.roles; + if (!hasRole(user.roles, required)) { + sendJson(403, jsonError("Missing role " + required)); + return false; + } + return true; +} diff --git a/src/core/device.cpp b/src/core/device.cpp new file mode 100644 index 0000000..e6a2a01 --- /dev/null +++ b/src/core/device.cpp @@ -0,0 +1,53 @@ +#include "../app.h" + +#include +#include + +void applyLed() { + uint8_t pct = constrain(ledBrightness, 0, 100); + uint8_t duty = map(pct, 0, 100, 0, 255); + if (ledInverted) duty = 255 - duty; + analogWrite(LED_BUILTIN, duty); +} + +void factoryReset() { + appendLog(LOG_WARN, "factory reset requested"); + prefs.clear(); + LittleFS.remove("/logs.txt"); +} + +void checkFactoryResetPin() { + pinMode(FACTORY_RESET_PIN, INPUT_PULLUP); + if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) return; + uint32_t start = millis(); + while (millis() - start < FACTORY_RESET_HOLD_MS) { + delay(100); + if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) { + return; + } + } + factoryReset(); + ESP.restart(); +} + +bool connectWifi() { + String ssid = prefString("wifiSsid", ""); + String pass = prefString("wifiPass", ""); + if (!ssid.length()) return false; + String name = deviceName(); + WiFi.mode(WIFI_STA); + WiFi.setHostname(name.c_str()); + WiFi.begin(ssid.c_str(), pass.c_str()); + appendLog(LOG_INFO, "connecting to WiFi " + ssid); + uint32_t start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) { + delay(250); + } + if (WiFi.status() == WL_CONNECTED) { + MDNS.begin(name.c_str()); + appendLog(LOG_INFO, "WiFi connected " + WiFi.localIP().toString()); + return true; + } + appendLog(LOG_WARN, "WiFi connection failed; entering setup AP"); + return false; +} diff --git a/src/core/logging.cpp b/src/core/logging.cpp new file mode 100644 index 0000000..596e2cc --- /dev/null +++ b/src/core/logging.cpp @@ -0,0 +1,32 @@ +#include "../app.h" + +#include + +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"; + File f = LittleFS.open("/logs.txt", "a"); + if (f) { + f.print(line); + f.close(); + } + f = LittleFS.open("/logs.txt", "r"); + if (!f) return; + size_t size = f.size(); + if (size <= maxLogBytes) { + f.close(); + return; + } + size_t keep = maxLogBytes > 1024 ? maxLogBytes - 1024 : maxLogBytes; + f.seek(size - keep, SeekSet); + String tail = f.readString(); + f.close(); + int newline = tail.indexOf('\n'); + if (newline >= 0) tail = tail.substring(newline + 1); + f = LittleFS.open("/logs.txt", "w"); + if (f) { + f.print(tail); + f.close(); + } +} diff --git a/src/core/state.cpp b/src/core/state.cpp new file mode 100644 index 0000000..03ed188 --- /dev/null +++ b/src/core/state.cpp @@ -0,0 +1,89 @@ +#include "../app.h" + +#include + +const char *APP_VERSION = "0.1.0"; +const char *PROJECT_NAME_VALUE = PROJECT_NAME; +const char *DEFAULT_ADMIN = "admin"; +const char *DEFAULT_UPDATE_URL = "http://example.com/update.tslpkg"; +const uint32_t FACTORY_RESET_HOLD_MS = 10000; +const size_t MAX_USERS = 8; +const size_t MAX_TOKENS = 8; +const bool DEFAULT_LED_INVERTED = true; +const byte DNS_PORT = 53; +const IPAddress SETUP_AP_IP(192, 168, 1, 1); +const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1); +const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0); + +WebServer server(80); +DNSServer dnsServer; +Preferences prefs; +Token tokens[8]; +LogLevel currentLogLevel = LOG_INFO; +size_t maxLogBytes = 50 * 1024; +bool setupMode = false; +uint8_t ledBrightness = 0; +bool ledInverted = DEFAULT_LED_INVERTED; +String updateUrl = DEFAULT_UPDATE_URL; + +String chipId() { + uint64_t mac = ESP.getEfuseMac(); + char buf[13]; + snprintf(buf, sizeof(buf), "%04X%08X", (uint16_t)(mac >> 32), (uint32_t)mac); + return String(buf); +} + +String deviceName() { + String suffix = chipId().substring(6); + String base = PROJECT_NAME_VALUE; + base.trim(); + for (size_t i = 0; i < base.length(); i++) { + char c = base[i]; + if (!(isalnum(c) || c == '-')) base.setCharAt(i, '-'); + } + while (base.startsWith("-")) base.remove(0, 1); + while (base.endsWith("-")) base.remove(base.length() - 1); + if (!base.length()) base = "ESP32C3"; + + const size_t maxHostnameLength = 31; + size_t maxBaseLength = maxHostnameLength - 1 - suffix.length(); + if (base.length() > maxBaseLength) base = base.substring(0, maxBaseLength); + while (base.endsWith("-")) base.remove(base.length() - 1); + return base + "-" + suffix; +} + +String sha256(const String &input) { + uint8_t digest[32]; + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 0); + mbedtls_md_starts(&ctx); + mbedtls_md_update(&ctx, (const unsigned char *)input.c_str(), input.length()); + mbedtls_md_finish(&ctx, digest); + mbedtls_md_free(&ctx); + char out[65]; + for (int i = 0; i < 32; i++) snprintf(out + (i * 2), 3, "%02x", digest[i]); + out[64] = 0; + return String(out); +} + +String passwordHash(const String &password) { + return sha256(chipId() + ":" + password); +} + +String prefString(const char *key, const String &fallback) { + return prefs.isKey(key) ? prefs.getString(key, fallback) : fallback; +} + +void loadSettings() { + prefs.begin("boiler", false); + if (!prefs.getBool("ledPolV2", false)) { + prefs.putBool("ledInv", DEFAULT_LED_INVERTED); + prefs.putBool("ledPolV2", true); + } + currentLogLevel = (LogLevel)prefs.getUChar("logLevel", LOG_INFO); + 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); +} diff --git a/src/handlers/handlers_admin.cpp b/src/handlers/handlers_admin.cpp new file mode 100644 index 0000000..d1b463c --- /dev/null +++ b/src/handlers/handlers_admin.cpp @@ -0,0 +1,53 @@ +#include "../app.h" + +#include + +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) + "}"; + sendJson(200, jsonOk(out)); +} + +void handleSettingsPost() { + if (!authorize("/api/settings", "POST")) return; + String body = requestBody(); + currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 3); + 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); + prefs.putUChar("logLevel", currentLogLevel); + prefs.putUInt("logMax", maxLogBytes); + prefs.putBool("ledInv", ledInverted); + prefs.putUChar("ledBright", ledBrightness); + prefs.putString("updateUrl", updateUrl); + applyLed(); + sendJson(200, jsonOk()); +} + +void handleLogsGet() { + if (!authorize("/api/logs", "GET")) return; + File f = LittleFS.open("/logs.txt", "r"); + String logs = f ? f.readString() : ""; + if (f) f.close(); + sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\"")); +} + +void handleLogsClear() { + if (!authorize("/api/logs/clear", "POST")) return; + LittleFS.remove("/logs.txt"); + sendJson(200, jsonOk()); +} + +void handleCertGet() { + if (!authorize("/api/cert", "GET")) return; + sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"")); +} + +void handleCertPost() { + if (!authorize("/api/cert", "POST")) return; + String cert = jsonStringValue(requestBody(), "certificate"); + prefs.putString("cert", cert); + appendLog(LOG_INFO, "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 new file mode 100644 index 0000000..8a87100 --- /dev/null +++ b/src/handlers/handlers_api.cpp @@ -0,0 +1,56 @@ +#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))); +} + +void handleApisGet() { + if (!authorize("/api/apis", "GET")) return; + String out = "\"apis\":["; + for (size_t i = 0; i < API_DEF_COUNT; i++) { + if (i) out += ","; + String role = configuredRole(&apiDefs[i]); + out += "{\"path\":\"" + String(apiDefs[i].path) + "\",\"method\":\"" + String(apiDefs[i].method) + "\",\"role\":\"" + jsonEscape(role) + "\"}"; + } + out += "]"; + sendJson(200, jsonOk(out)); +} + +void handleApisPost() { + if (!authorize("/api/apis", "POST")) return; + String body = requestBody(); + String path = jsonStringValue(body, "path"); + String method = jsonStringValue(body, "method", "GET"); + method.toUpperCase(); + String role = jsonStringValue(body, "role", "PUBLIC"); + ApiDef *api = findApi(path, method); + if (!api) return sendJson(404, jsonError("Unknown API")); + if (!isKnownRole(role)) return sendJson(400, jsonError("Set one known role or PUBLIC")); + String key = "acl"; + key += method[0]; + for (size_t i = 0; i < path.length(); i++) { + char c = path[i]; + if (isalnum(c)) key += c; + } + prefs.putString(key.substring(0, 15).c_str(), role); + appendLog(LOG_INFO, "API ACL saved " + method + " " + path + " -> " + role); + sendJson(200, jsonOk()); +} diff --git a/src/handlers/handlers_auth.cpp b/src/handlers/handlers_auth.cpp new file mode 100644 index 0000000..5216350 --- /dev/null +++ b/src/handlers/handlers_auth.cpp @@ -0,0 +1,104 @@ +#include "../app.h" + +void handleLogin() { + String body = requestBody(); + String username = jsonStringValue(body, "username"); + String password = jsonStringValue(body, "password"); + User user; + if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) { + appendLog(LOG_WARN, "failed login for " + username); + return sendJson(401, jsonError("Invalid username or password")); + } + String token = createToken(user); + appendLog(LOG_INFO, "login " + username); + sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\"")); +} + +void handleLogout() { + if (!authorize("/api/logout", "POST")) return; + Token *tok = currentToken(); + if (tok) *tok = Token(); + sendJson(200, jsonOk()); +} + +void handleMe() { + if (!authorize("/api/me", "GET")) return; + Token *tok = currentToken(); + sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\"")); +} + +void handleUsersGet() { + if (!authorize("/api/users", "GET")) return; + User users[8]; + size_t count = parseUsers(users, MAX_USERS); + String out = "\"users\":["; + for (size_t i = 0; i < count; i++) { + if (i) out += ","; + out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}"; + } + out += "]"; + sendJson(200, jsonOk(out)); +} + +void handleUsersPost() { + if (!authorize("/api/users", "POST")) return; + String body = requestBody(); + String username = jsonStringValue(body, "username"); + String password = jsonStringValue(body, "password"); + String roles = cleanRoles(jsonRolesValue(body, "roles", "WebUIConnect")); + bool active = jsonBoolValue(body, "active", true); + if (!validName(username)) return sendJson(400, jsonError("Username is invalid")); + User users[8]; + size_t count = parseUsers(users, MAX_USERS); + size_t idx = count; + for (size_t i = 0; i < count; i++) { + if (users[i].name == username) idx = i; + } + if (idx == count && count >= MAX_USERS) return sendJson(400, jsonError("Maximum user count reached")); + users[idx].name = username; + if (password.length() || idx == count) users[idx].passwordHash = passwordHash(password); + users[idx].roles = roles; + users[idx].active = active; + if (idx == count) count++; + saveUsers(users, count); + appendLog(LOG_INFO, "user saved " + username); + sendJson(200, jsonOk()); +} + +void handlePassword() { + if (!authorize("/api/password", "POST")) return; + Token *tok = currentToken(); + String password = jsonStringValue(requestBody(), "password"); + User users[8]; + size_t count = parseUsers(users, MAX_USERS); + for (size_t i = 0; i < count; i++) { + if (users[i].name == tok->user) { + users[i].passwordHash = passwordHash(password); + saveUsers(users, count); + appendLog(LOG_INFO, "password changed " + tok->user); + return sendJson(200, jsonOk()); + } + } + sendJson(404, jsonError("User not found")); +} + +void handleRolesGet() { + if (!authorize("/api/roles", "GET")) return; + sendJson(200, jsonOk(rolesJson())); +} + +void handleRolesPost() { + if (!authorize("/api/roles", "POST")) 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); + sendJson(200, jsonOk(rolesJson())); +} + +void handleRolesDelete() { + if (!authorize("/api/roles", "DELETE")) 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); + sendJson(200, jsonOk(rolesJson())); +} diff --git a/src/handlers/handlers_ota.cpp b/src/handlers/handlers_ota.cpp new file mode 100644 index 0000000..d81e442 --- /dev/null +++ b/src/handlers/handlers_ota.cpp @@ -0,0 +1,202 @@ +#include "../app.h" + +#include +#include +#include + +static const uint8_t PACKAGE_HEADER_SIZE = 32; +static const char PACKAGE_MAGIC[8] = {'T', 'S', 'L', 'U', 'P', 'D', '1', 0}; + +enum PackageStage : uint8_t { + PKG_HEADER, + PKG_FILESYSTEM, + PKG_FIRMWARE, + PKG_DONE, + PKG_ERROR +}; + +struct PackageState { + PackageStage stage; + uint8_t header[PACKAGE_HEADER_SIZE]; + size_t headerRead; + uint32_t filesystemSize; + uint32_t firmwareSize; + uint32_t remaining; + bool filesystemEnded; + String error; +}; + +static PackageState packageState; + +static uint32_t readLe32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static void resetPackageState() { + packageState.stage = PKG_HEADER; + packageState.headerRead = 0; + packageState.filesystemSize = 0; + packageState.firmwareSize = 0; + packageState.remaining = 0; + packageState.filesystemEnded = false; + packageState.error = ""; +} + +static bool failPackage(const String &message) { + packageState.stage = PKG_ERROR; + packageState.error = message; + Update.abort(); + if (packageState.filesystemEnded) LittleFS.begin(false); + return false; +} + +static bool beginPackagePart(uint32_t size, int command, const char *label) { + if (command == U_SPIFFS) LittleFS.end(); + if (!Update.begin(size, command)) { + return failPackage(String(label) + " update begin failed: " + Update.errorString()); + } + packageState.remaining = size; + return true; +} + +static bool finishPackagePart(const char *label) { + if (!Update.end(true)) { + return failPackage(String(label) + " update failed: " + Update.errorString()); + } + return true; +} + +static bool parsePackageHeader() { + if (memcmp(packageState.header, PACKAGE_MAGIC, sizeof(PACKAGE_MAGIC)) != 0) { + return failPackage("Invalid update package magic"); + } + uint32_t headerSize = readLe32(packageState.header + 8); + packageState.filesystemSize = readLe32(packageState.header + 12); + packageState.firmwareSize = readLe32(packageState.header + 16); + if (headerSize != PACKAGE_HEADER_SIZE) return failPackage("Unsupported update package header"); + if (!packageState.filesystemSize || !packageState.firmwareSize) return failPackage("Update package must contain filesystem and firmware images"); + packageState.stage = PKG_FILESYSTEM; + return beginPackagePart(packageState.filesystemSize, U_SPIFFS, "Filesystem"); +} + +static bool feedPackageBytes(const uint8_t *data, size_t length) { + while (length && packageState.stage != PKG_ERROR && packageState.stage != PKG_DONE) { + if (packageState.stage == PKG_HEADER) { + size_t n = min(length, (size_t)PACKAGE_HEADER_SIZE - packageState.headerRead); + memcpy(packageState.header + packageState.headerRead, data, n); + packageState.headerRead += n; + data += n; + length -= n; + if (packageState.headerRead == PACKAGE_HEADER_SIZE && !parsePackageHeader()) return false; + } else { + size_t n = min(length, (size_t)packageState.remaining); + if (Update.write((uint8_t *)data, n) != n) { + return failPackage(String(packageState.stage == PKG_FILESYSTEM ? "Filesystem" : "Firmware") + " write failed"); + } + packageState.remaining -= n; + data += n; + length -= n; + + if (packageState.remaining == 0) { + if (packageState.stage == PKG_FILESYSTEM) { + if (!finishPackagePart("Filesystem")) return false; + packageState.filesystemEnded = true; + packageState.stage = PKG_FIRMWARE; + if (!beginPackagePart(packageState.firmwareSize, U_FLASH, "Firmware")) return false; + } else { + if (!finishPackagePart("Firmware")) return false; + packageState.stage = PKG_DONE; + } + } + } + } + if (length && packageState.stage == PKG_DONE) return failPackage("Trailing bytes in update package"); + return packageState.stage != PKG_ERROR; +} + +static bool streamPackageFromUrl(const String &url, String &error) { + resetPackageState(); + HTTPClient http; + http.begin(url); + int code = http.GET(); + if (code != HTTP_CODE_OK) { + http.end(); + error = "Update package URL returned HTTP " + String(code); + return false; + } + + uint8_t buffer[1024]; + WiFiClient *stream = http.getStreamPtr(); + int expectedLength = http.getSize(); + int receivedLength = 0; + uint32_t lastRead = millis(); + while (expectedLength < 0 || receivedLength < expectedLength) { + int available = stream->available(); + if (available > 0) { + int wanted = min(available, (int)sizeof(buffer)); + if (expectedLength >= 0) wanted = min(wanted, expectedLength - receivedLength); + int n = stream->readBytes(buffer, wanted); + if (n > 0) { + receivedLength += n; + lastRead = millis(); + if (!feedPackageBytes(buffer, n)) break; + } + } else { + if (!http.connected()) break; + if (millis() - lastRead > 30000) { + failPackage("Update package download timed out"); + break; + } + delay(10); + } + } + http.end(); + + if (packageState.stage != PKG_DONE) { + error = packageState.error.length() ? packageState.error : "Incomplete update package"; + return false; + } + return true; +} + +void handleOtaCheck() { + if (!authorize("/api/ota/check", "POST")) return; + HTTPClient http; + http.begin(updateUrl); + int code = http.GET(); + int size = http.getSize(); + http.end(); + sendJson(code > 0 && code < 400 ? 200 : 502, jsonOk("\"version\":\"" + String(APP_VERSION) + "\",\"url\":\"" + jsonEscape(updateUrl) + "\",\"httpStatus\":" + String(code) + ",\"contentLength\":" + String(size))); +} + +void handleOtaRun() { + if (!authorize("/api/ota/run", "POST")) return; + appendLog(LOG_INFO, "package URL update started"); + String error; + if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error)); + sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true")); + delay(500); + ESP.restart(); +} + +void handleUpdateUploadDone() { + if (!authorize("/api/update", "POST")) 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)); + if (ok) { + delay(500); + ESP.restart(); + } +} + +void handleUpdateUploadChunk() { + HTTPUpload &upload = server.upload(); + if (upload.status == UPLOAD_FILE_START) { + if (!authorize("/api/update", "POST")) return; + appendLog(LOG_INFO, "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 new file mode 100644 index 0000000..a8f25e5 --- /dev/null +++ b/src/handlers/handlers_setup.cpp @@ -0,0 +1,33 @@ +#include "../app.h" + +void handleWifiScan() { + if (!setupMode && !authorize("/api/settings", "GET")) return; + int n = WiFi.scanNetworks(); + String out = "\"networks\":["; + for (int i = 0; i < n; i++) { + if (i) out += ","; + out += "{\"ssid\":\"" + jsonEscape(WiFi.SSID(i)) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + ",\"open\":" + String(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "true" : "false") + "}"; + } + out += "]"; + sendJson(200, jsonOk(out)); +} + +void handleSetupSubmit() { + if (!setupMode && !authorize("/api/settings", "POST")) return; + String body = requestBody(); + String ssid = jsonStringValue(body, "ssid"); + String wifiPass = jsonStringValue(body, "wifiPass"); + String admin = jsonStringValue(body, "admin", DEFAULT_ADMIN); + String adminPass = jsonStringValue(body, "adminPass"); + if (!ssid.length()) return sendJson(400, jsonError("WiFi SSID is required")); + if (!validName(admin)) return sendJson(400, jsonError("Admin username is invalid")); + prefs.putString("wifiSsid", ssid); + prefs.putString("wifiPass", wifiPass); + User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true}; + saveUsers(&u, 1); + prefs.putBool("configured", true); + appendLog(LOG_INFO, "initial setup saved"); + sendJson(200, jsonOk("\"restart\":true")); + delay(500); + ESP.restart(); +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..58410f1 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,38 @@ +#include "app.h" + +#include + +void setup() { + Serial.begin(9600); + delay(200); + Serial.println("Booting"); + LittleFS.begin(true); + loadSettings(); + pinMode(LED_BUILTIN, OUTPUT); + applyLed(); + checkFactoryResetPin(); + + bool configured = prefs.getBool("configured", false); + setupMode = !configured || !connectWifi(); + if (setupMode) { + String ssid = deviceName(); + WiFi.mode(WIFI_AP_STA); + WiFi.softAPConfig(SETUP_AP_IP, SETUP_AP_GATEWAY, SETUP_AP_SUBNET); + WiFi.softAP(ssid.c_str()); + dnsServer.start(DNS_PORT, "*", SETUP_AP_IP); + appendLog(LOG_INFO, "setup AP started " + ssid); + Serial.println("Setup AP: " + ssid + " http://" + SETUP_AP_IP.toString() + "/"); + } else { + Serial.println("Admin UI: http://" + WiFi.localIP().toString() + "/"); + Serial.println("Admin UI local: http://" + deviceName() + ".local/"); + } + + registerRoutes(); + server.begin(); + appendLog(LOG_INFO, "HTTP server started"); +} + +void loop() { + if (setupMode) dnsServer.processNextRequest(); + server.handleClient(); +} diff --git a/src/util/json_utils.cpp b/src/util/json_utils.cpp new file mode 100644 index 0000000..03cb599 --- /dev/null +++ b/src/util/json_utils.cpp @@ -0,0 +1,106 @@ +#include "../app.h" + +static int findJsonKey(const String &json, const char *key) { + String needle = "\"" + String(key) + "\""; + int p = json.indexOf(needle); + if (p < 0) return -1; + p = json.indexOf(':', p + needle.length()); + return p < 0 ? -1 : p + 1; +} + +String jsonEscape(const String &s) { + String out; + out.reserve(s.length() + 8); + for (size_t i = 0; i < s.length(); i++) { + char c = s[i]; + if (c == '"' || c == '\\') { + out += '\\'; + out += c; + } else if (c == '\n') { + out += "\\n"; + } else if (c == '\r') { + out += "\\r"; + } else { + out += c; + } + } + return out; +} + +String jsonOk(const String &payload) { + return String("{\"success\":true") + (payload.length() ? "," + payload : "") + "}"; +} + +String jsonError(const String &message) { + return "{\"success\":false,\"error\":\"" + jsonEscape(message) + "\"}"; +} + +void sendJson(int code, const String &body) { + server.sendHeader("Cache-Control", "no-store"); + server.send(code, "application/json", body); +} + +String requestBody() { + return server.hasArg("plain") ? server.arg("plain") : ""; +} + +String jsonStringValue(const String &json, const char *key, const String &fallback) { + int p = findJsonKey(json, key); + if (p < 0) return fallback; + while (p < (int)json.length() && isspace(json[p])) p++; + if (p >= (int)json.length() || json[p] != '"') return fallback; + p++; + String out; + bool esc = false; + for (; p < (int)json.length(); p++) { + char c = json[p]; + if (esc) { + out += c == 'n' ? '\n' : c == 'r' ? '\r' : c; + esc = false; + } else if (c == '\\') { + esc = true; + } else if (c == '"') { + return out; + } else { + out += c; + } + } + return fallback; +} + +int jsonIntValue(const String &json, const char *key, int fallback) { + int p = findJsonKey(json, key); + if (p < 0) return fallback; + while (p < (int)json.length() && isspace(json[p])) p++; + return json.substring(p).toInt(); +} + +bool jsonBoolValue(const String &json, const char *key, bool fallback) { + int p = findJsonKey(json, key); + if (p < 0) return fallback; + while (p < (int)json.length() && isspace(json[p])) p++; + if (json.substring(p, p + 4) == "true") return true; + if (json.substring(p, p + 5) == "false") return false; + return fallback; +} + +String jsonRolesValue(const String &json, const char *key, const String &fallback) { + int p = findJsonKey(json, key); + if (p < 0) return fallback; + while (p < (int)json.length() && isspace(json[p])) p++; + if (json[p] == '"') return jsonStringValue(json, key, fallback); + if (json[p] != '[') return fallback; + String roles; + p++; + while (p < (int)json.length() && json[p] != ']') { + while (p < (int)json.length() && json[p] != '"' && json[p] != ']') p++; + if (p >= (int)json.length() || json[p] == ']') break; + p++; + String role; + while (p < (int)json.length() && json[p] != '"') role += json[p++]; + if (roles.length()) roles += "|"; + roles += role; + p++; + } + return roles.length() ? roles : fallback; +} diff --git a/src/web/routes.cpp b/src/web/routes.cpp new file mode 100644 index 0000000..4c18bf0 --- /dev/null +++ b/src/web/routes.cpp @@ -0,0 +1,48 @@ +#include "../app.h" + +static HTTPMethod httpMethod(const char *method) { + if (strcmp(method, "GET") == 0) return HTTP_GET; + if (strcmp(method, "POST") == 0) return HTTP_POST; + if (strcmp(method, "DELETE") == 0) return HTTP_DELETE; + if (strcmp(method, "PUT") == 0) return HTTP_PUT; + if (strcmp(method, "PATCH") == 0) return HTTP_PATCH; + return HTTP_ANY; +} + +static void registerApiRoutes() { + for (size_t i = 0; i < API_DEF_COUNT; i++) { + ApiDef &api = apiDefs[i]; + if (api.uploadHandler) { + server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler); + } else { + server.on(api.path, httpMethod(api.method), api.handler); + } + } +} + +void registerRoutes() { + static const char *headers[] = {"Authorization", "X-Auth-Token"}; + server.collectHeaders(headers, 2); + + server.on("/", HTTP_GET, setupMode ? handleSetupPage : handleAdminPage); + server.on("/favicon.ico", HTTP_GET, handleFavicon); + server.on("/generate_204", HTTP_GET, handleCaptiveProbe); + server.on("/gen_204", HTTP_GET, handleCaptiveProbe); + server.on("/hotspot-detect.html", HTTP_GET, handleCaptiveProbe); + server.on("/library/test/success.html", HTTP_GET, handleCaptiveProbe); + server.on("/connecttest.txt", HTTP_GET, handleCaptiveProbe); + server.on("/ncsi.txt", HTTP_GET, handleCaptiveProbe); + server.on("/fwlink", HTTP_GET, handleCaptiveProbe); + + registerApiRoutes(); + + server.onNotFound([]() { + if (setupMode) { + if (!handleHtmlFileRequest()) redirectToSetupPage(); + } else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) { + if (!handleHtmlFileRequest()) handleAdminPage(); + } else { + sendJson(404, jsonError("Not found")); + } + }); +} diff --git a/src/web/ui.cpp b/src/web/ui.cpp new file mode 100644 index 0000000..e989beb --- /dev/null +++ b/src/web/ui.cpp @@ -0,0 +1,48 @@ +#include "../app.h" + +#include + +static bool serveHtmlFile(const String &path) { + if (!path.startsWith("/") || path.indexOf("..") >= 0 || !path.endsWith(".html")) return false; + File file = LittleFS.open(path, "r"); + if (!file) return false; + server.streamFile(file, "text/html"); + file.close(); + return true; +} + +void handleSetupPage() { + if (!serveHtmlFile("/setup.html")) { + sendJson(500, jsonError("Missing /setup.html in LittleFS")); + } +} + +bool handleHtmlFileRequest() { + String path = server.uri(); + if (path == "/") path = setupMode ? "/setup.html" : "/admin.html"; + return serveHtmlFile(path); +} + +void redirectToSetupPage() { + server.sendHeader("Location", String("http://") + SETUP_AP_IP.toString() + "/", true); + server.sendHeader("Cache-Control", "no-store"); + server.send(302, "text/plain", ""); +} + +void handleCaptiveProbe() { + if (setupMode) { + redirectToSetupPage(); + } else { + server.send(204, "text/plain", ""); + } +} + +void handleFavicon() { + server.send(204, "image/x-icon", ""); +} + +void handleAdminPage() { + if (!serveHtmlFile("/admin.html")) { + sendJson(500, jsonError("Missing /admin.html in LittleFS")); + } +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..b0416ad --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html