More work on the ESP32C3 Boilerplate

This commit is contained in:
2026-06-27 20:00:17 -05:00
parent bb7aa5d5ca
commit 54ae1e66da
21 changed files with 833 additions and 146 deletions

View File

@@ -43,7 +43,10 @@ It provides basic "infrastructure" and "Framework" for specific developments.
- search - search
- view - view
- clear logs - clear logs
- HTTPS configuration - Networking configuration
- Host name
- DHCP or static IPv4 parameters
- HTTPS certificate configuration
- By default a self-signed certificate is used - By default a self-signed certificate is used
- Allows the set up of certificates for HTTPS (file upload) - Allows the set up of certificates for HTTPS (file upload)
- Firmware update handling - Firmware update handling
@@ -70,6 +73,7 @@ It provides basic "infrastructure" and "Framework" for specific developments.
- On subsequent startups, the device looks for configuraiton stored in non volatile memory - 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. - 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. - 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 - 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 - 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) - 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 | | Path | Responsibility |
| --- | --- | | --- | --- |
| `src/main.cpp` | Arduino `setup()`/`loop()` and boot orchestration | | `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.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/state.cpp` | Global state, project/device identity, hashing, persisted settings |
| `src/core/logging.cpp` | LittleFS log ring buffer | | `src/core/logging.cpp` | LittleFS log ring buffer |
| `src/core/auth.cpp` | Users, roles, tokens, and API authorization | | `src/core/auth.cpp` | Users, roles, tokens, and API authorization |
| `src/core/device.cpp` | LED control, factory reset, and WiFi connection | | `src/core/device.cpp` | LED control, factory reset, and WiFi connection |
| `src/util/json_utils.cpp` | Small JSON response and request parsing helpers | | `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/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_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_auth.cpp` | Login, users, roles, and password route handlers |
| `src/handlers/handlers_admin.cpp` | Settings, logs, and certificate route handlers | | `src/handlers/handlers_admin.cpp` | Settings, logs, and certificate route handlers |
| `src/handlers/handlers_ota.cpp` | Firmware upload and OTA 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. - Ring-buffer logging in LittleFS with default maximum size of 50 KiB.
- Firmware and LittleFS filesystem upload OTA and update-from-URL hooks. - 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. - 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 ### 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 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. 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 ### 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. 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 ### 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 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 <token> Authorization: Bearer <token>
``` ```
### 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 <token>
Content-Type: application/json
{"certificate":"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"}
```
### API response shape ### API response shape
Every API returns JSON with a `success` field. Errors include an `error` string: 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` | | `POST /api/settings` | `Sysadmin` |
| `GET /api/logs` | `Debugger` | | `GET /api/logs` | `Debugger` |
| `POST /api/logs/clear` | `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/check` | `Sysadmin` |
| `POST /api/ota/run` | `Sysadmin` | | `POST /api/ota/run` | `Sysadmin` |
| `POST /api/update` | `Sysadmin` | | `POST /api/update` | `Sysadmin` |

View File

@@ -8,24 +8,30 @@
body{margin:0}.top{background:#263238;color:white;padding:14px 18px;font-weight:650} 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} 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} 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} 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} 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} 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} .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{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} .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} .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} .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} .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} .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}
</style> </style>
</head> </head>
<body> <body>
<div class="top">ESP32-C3 Admin</div> <div class="top">ESP32-C3 Admin</div>
<div id="busyChip" class="busyChip hidden"><span class="spinner"></span>Working...</div>
<main> <main>
<div id="loginPanel" class="box"> <div id="loginPanel" class="box">
<h2>Login</h2> <h2>Login</h2>
@@ -38,56 +44,137 @@
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="dashboard" onclick="showTab('dashboard')">Dashboard</button> <button class="tab active" data-tab="dashboard" onclick="showTab('dashboard')">Dashboard</button>
<button class="tab" data-tab="access" onclick="showTab('access')">Access</button> <button class="tab" data-tab="access" onclick="showTab('access')">Access</button>
<button class="tab" data-tab="ops" onclick="showTab('ops')">Operations</button> <button class="tab" data-tab="network" onclick="showTab('network')">Networking</button>
<button class="tab" data-tab="ops" onclick="showTab('ops')">System</button>
</div> </div>
<div id="dashboard" class="panel active"><div class="grid"> <div id="dashboard" class="panel active"><div class="grid">
<section><h3>Status</h3><button onclick="call('GET','/api/ping')">Ping</button><button class="secondary" onclick="loadMe()">Me</button><pre id="statusOut"></pre></section> <section><h3>Status</h3><button onclick="call('GET','/api/ping')">Ping</button><button class="secondary" onclick="loadMe()">Me</button><pre id="statusOut"></pre></section>
<section><h3>LED</h3><label>Brightness</label><div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0" oninput="ledVal.value=value"><output id="ledVal">0</output></div><button onclick="setLed()">Apply</button></section> <section><h3>LED</h3><label>Brightness</label><div class="rangeRow"><input id="brightness" type="range" min="0" max="100" value="0" oninput="ledVal.value=value"><output id="ledVal">0</output></div><button onclick="setLed()">Apply</button><pre id="ledOut"></pre></section>
<section><h3>Add API</h3><div class="row"><div><label>A</label><input id="a" type="number" value="1"></div><div><label>B</label><input id="b" type="number" value="2"></div></div><button onclick="addNums()">Add</button><pre id="addOut"></pre></section> <section><h3>Add API</h3><div class="row"><div><label>A</label><input id="a" type="number" value="1"></div><div><label>B</label><input id="b" type="number" value="2"></div></div><button onclick="addNums()">Add</button><pre id="addOut"></pre></section>
</div></div> </div></div>
<div id="access" class="panel"><div class="grid"> <div id="access" class="panel">
<section><h3>Roles</h3><div class="row"><div><label>Custom role</label><input id="roleName"></div><div><button onclick="addRole()">Add</button></div></div><div id="rolesOut"></div><pre id="roleMsg"></pre></section> <div class="tabs subtabs">
<section><h3>Users</h3><div class="row"><button onclick="loadAccess()">Refresh</button><button class="secondary" onclick="newUserRecord()">New</button></div><div id="userList" class="list"></div></section> <button class="tab subtab active" data-subtab="users" onclick="showAccessTab('users')">User Management</button>
<section><h3>User Details</h3><h4 id="userTitle" class="detailTitle">Select a user</h4><label>Name</label><input id="userName"><label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged"><label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label><label>Roles</label><div id="userRoles" class="checkGrid"></div><button onclick="saveSelectedUser()">Save user</button><pre id="usersOut"></pre></section> <button class="tab subtab" data-subtab="roles" onclick="showAccessTab('roles')">Role Management</button>
<section><h3>API Security</h3><button onclick="loadApis()">Refresh</button><div id="apiList" class="list"></div></section> <button class="tab subtab" data-subtab="apis" onclick="showAccessTab('apis')">API Management</button>
<section><h3>API Details</h3><h4 id="apiTitle" class="detailTitle">Select an API</h4><label>Path</label><input id="apiPath" readonly><label>Method</label><input id="apiMethod" readonly><label>Required role</label><select id="apiRole"></select><button onclick="saveSelectedApi()">Save ACL</button><pre id="apisOut"></pre></section> </div>
<div id="users" class="subpanel active">
<section id="usersOverview">
<div class="toolbar"><h3>User Overview</h3><button onclick="loadUsers()">Refresh</button><button class="secondary" onclick="newUserRecord()">New</button></div>
<div id="userList" class="list"></div>
</section>
<section id="userDetail" class="hidden">
<div class="toolbar"><button class="secondary" onclick="showUserOverview()">Back</button><h3 id="userTitle" class="detailTitle">User Detail</h3></div>
<label>Name</label><input id="userName">
<label>Password</label><input id="userPass" type="password" placeholder="Leave blank to keep unchanged">
<label class="inlineCheck"><input id="userActive" type="checkbox" checked> Active</label>
<label>Roles</label><div id="userRoles" class="checkGrid"></div>
<div class="detailActions"><button onclick="saveSelectedUser()">Save user</button></div>
<pre id="usersOut"></pre>
</section>
</div>
<div id="roles" class="subpanel">
<section id="rolesOverview">
<div class="toolbar"><h3>Role Overview</h3><button onclick="loadRoles()">Refresh</button><button class="secondary" onclick="newRoleRecord()">New</button></div>
<div id="rolesOut" class="list"></div>
</section>
<section id="roleDetail" class="hidden">
<div class="toolbar"><button class="secondary" onclick="showRoleOverview()">Back</button><h3 id="roleTitle" class="detailTitle">Role Detail</h3></div>
<label>Name</label><input id="roleName">
<div id="roleMeta" class="meta"></div>
<div class="detailActions"><button id="roleSave" onclick="addRole()">Save role</button><button id="roleDelete" class="danger" onclick="deleteSelectedRole()">Delete role</button></div>
<pre id="roleMsg"></pre>
</section>
</div>
<div id="apis" class="subpanel">
<section id="apisOverview">
<div class="toolbar"><h3>API Overview</h3><button onclick="loadApis()">Refresh</button></div>
<div id="apiList" class="list"></div>
</section>
<section id="apiDetail" class="hidden">
<div class="toolbar"><button class="secondary" onclick="showApiOverview()">Back</button><h3 id="apiTitle" class="detailTitle">API Detail</h3></div>
<label>Path</label><input id="apiPath" readonly>
<label>Method</label><input id="apiMethod" readonly>
<label>Required role</label><select id="apiRole"></select>
<button onclick="saveSelectedApi()">Save ACL</button>
<pre id="apisOut"></pre>
<h3>Test</h3>
<label>JSON body</label><textarea id="apiTestBody" placeholder='{"key":"value"}'></textarea>
<div class="detailActions"><button onclick="testSelectedApi()">Run test</button><button class="secondary" onclick="clearApiTestBody()">Clear body</button></div>
<pre id="apiTestOut"></pre>
</section>
</div>
</div>
<div id="network" class="panel"><div class="grid">
<section><h3>Network</h3><label>Host name</label><input id="netHost" placeholder="Default host name"><label>Address mode</label><select id="netDhcp" onchange="syncNetworkMode()"><option value="true">DHCP</option><option value="false">Static IPv4</option></select><div id="netCurrent" class="muted"></div><label>Static IP</label><input id="netIp" inputmode="decimal" placeholder="192.168.1.50"><label>Gateway</label><input id="netGateway" inputmode="decimal" placeholder="192.168.1.1"><label>Subnet mask</label><input id="netSubnet" inputmode="decimal" placeholder="255.255.255.0"><div class="row"><div><label>DNS 1</label><input id="netDns1" inputmode="decimal" placeholder="192.168.1.1"></div><div><label>DNS 2</label><input id="netDns2" inputmode="decimal" placeholder="8.8.8.8"></div></div><button id="networkSaveBtn" onclick="saveNetwork()">Save network</button><pre id="networkOut"></pre></section>
<section><h3>HTTPS Certificate</h3><label>Certificate file</label><input id="certFile" type="file" accept=".pem,.cer,.crt,.txt,application/x-pem-file,application/pkix-cert"><button id="certSaveBtn" onclick="saveCertFile()">Save certificate</button><button class="secondary" onclick="loadCert()">Load current</button><pre id="certOut"></pre></section>
</div></div> </div></div>
<div id="ops" class="panel"><div class="grid"> <div id="ops" class="panel"><div class="grid">
<section><h3>Logs</h3><div class="row"><button onclick="loadLogs()">Load</button><button class="danger" onclick="clearLogs()">Clear</button></div><pre id="logsOut"></pre></section> <section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2">SecurityAudit</option><option value="3" selected>Info</option><option value="4">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button onclick="saveSettings()">Save</button><pre id="settingsOut"></pre></section>
<section><h3>Settings</h3><label>Log level</label><select id="logLevel"><option value="0">Error</option><option value="1">Warn</option><option value="2" selected>Info</option><option value="3">Debug</option></select><label>Max log bytes</label><input id="logMax" type="number" value="51200"><label>Update package URL</label><input id="upd"><button onclick="saveSettings()">Save</button><pre id="settingsOut"></pre></section> <section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button id="uploadPkgBtn" onclick="uploadPkg()">Upload package</button><button class="secondary" onclick="checkOta()">Check URL</button><button class="danger" onclick="runOta()">Install from URL</button><pre id="fwOut"></pre></section>
<section><h3>Updates</h3><label>Update package</label><input id="pkg" type="file"><button onclick="uploadPkg()">Upload package</button><button class="secondary" onclick="checkOta()">Check URL</button><button class="danger" onclick="runOta()">Install from URL</button><pre id="fwOut"></pre></section> <section><h3>Maintenance</h3><button onclick="downloadConfig()">Download config</button><label>Restore configuration</label><input id="configFile" type="file" accept=".json,application/json"><button id="restoreConfigBtn" class="secondary" onclick="restoreConfig()">Restore config</button><button class="danger" onclick="factoryResetDevice()">Factory reset</button><pre id="maintOut"></pre></section>
<section class="fullWidth"><h3>Files</h3><button onclick="loadFiles()">Refresh</button><div id="fileList" class="list"></div><pre id="filesOut"></pre></section>
<section class="fullWidth"><h3>Logs</h3><div class="row"><button onclick="loadLogs()">Load</button><button class="danger" onclick="clearLogs()">Clear</button></div><pre id="logsOut"></pre></section>
</div></div> </div></div>
</div> </div>
</main> </main>
<script> <script>
let token='';const el=id=>document.getElementById(id); let token='';const el=id=>document.getElementById(id);
async function req(m,u,b){let o={method:m,headers:{'Content-Type':'application/json'}};if(token)o.headers.Authorization='Bearer '+token;if(b!==undefined)o.body=JSON.stringify(b);let r=await fetch(u,o);return await r.text()} let busyCount=0;function setGlobalBusy(on){busyCount+=on?1:-1;if(busyCount<0)busyCount=0;el('busyChip').classList.toggle('hidden',busyCount==0)}
function showTab(id){document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('active',b.dataset.tab==id));document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',p.id==id))} async function tracked(p){setGlobalBusy(true);try{return await p()}finally{setGlobalBusy(false)}}
let roles=[],users=[],apis=[],selectedUser='',selectedApi=-1; async function apiFetch(u,o){return await tracked(()=>fetch(u,o))}
async function req(m,u,b){let o={method:m,headers:{'Content-Type':'application/json'}};if(token)o.headers.Authorization='Bearer '+token;if(b!==undefined&&m!='GET')o.body=JSON.stringify(b);let r=await apiFetch(u,o);return await r.text()}
function showTab(id){document.querySelectorAll('.tab[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab==id));document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',p.id==id));if(id=='network')loadSettings()}
function showAccessTab(id){document.querySelectorAll('.subtab').forEach(b=>b.classList.toggle('active',b.dataset.subtab==id));document.querySelectorAll('.subpanel').forEach(p=>p.classList.toggle('active',p.id==id));if(id=='users')showUserOverview();if(id=='roles')showRoleOverview();if(id=='apis')showApiOverview()}
let roles=[],users=[],apis=[],selectedUser='',selectedRole='',selectedApi=-1;
async function doLogin(){let t=await req('POST','/api/login',{username:el('user').value,password:el('pass').value});el('loginOut').textContent=t;let j=JSON.parse(t);if(j.success){token=j.token;el('loginPanel').classList.add('hidden');el('app').classList.remove('hidden');loadMe();loadSettings();loadAccess()}} async function doLogin(){let t=await req('POST','/api/login',{username:el('user').value,password:el('pass').value});el('loginOut').textContent=t;let j=JSON.parse(t);if(j.success){token=j.token;el('loginPanel').classList.add('hidden');el('app').classList.remove('hidden');loadMe();loadSettings();loadAccess()}}
async function call(m,u){el('statusOut').textContent=await req(m,u)}async function loadMe(){el('statusOut').textContent=await req('GET','/api/me')} async function call(m,u){el('statusOut').textContent=await req(m,u)}async function loadMe(){el('statusOut').textContent=await req('GET','/api/me')}
async function setLed(){el('statusOut').textContent=await req('POST','/api/led',{brightness:+el('brightness').value})}async function addNums(){el('addOut').textContent=await req('POST','/api/add',{a:+el('a').value,b:+el('b').value})} async function setLed(){el('ledOut').textContent=await req('POST','/api/led',{brightness:+el('brightness').value})}async function addNums(){el('addOut').textContent=await req('POST','/api/add',{a:+el('a').value,b:+el('b').value})}
function roleNames(){return roles.map(r=>r.name)}function roleChecks(id,selected){el(id).innerHTML=roleNames().map(r=>'<label><input type=checkbox value="'+r+'" '+(selected.includes(r)?'checked':'')+'> '+r+'</label>').join('')} function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function roleNames(){return roles.map(r=>r.name)}function roleChecks(id,selected){el(id).innerHTML=roleNames().map(r=>'<label><input type=checkbox value="'+esc(r)+'" '+(selected.includes(r)?'checked':'')+'> '+esc(r)+'</label>').join('')}
function selectedRoles(){return [...el('userRoles').querySelectorAll('input:checked')].map(x=>x.value)} function selectedRoles(){return [...el('userRoles').querySelectorAll('input:checked')].map(x=>x.value)}
function renderRoles(){el('rolesOut').innerHTML=roles.map(r=>'<span class=pill>'+r.name+(r.system?'':' <button onclick="deleteRole(\''+r.name+'\')">Delete</button>')+'</span>').join('')} function showUserOverview(){el('usersOverview').classList.remove('hidden');el('userDetail').classList.add('hidden')}
async function loadRoles(){let j=JSON.parse(await req('GET','/api/roles'));if(j.success){roles=j.roles;renderRoles();roleOptions();if(selectedUser)selectUser(selectedUser)}} function showRoleOverview(){el('rolesOverview').classList.remove('hidden');el('roleDetail').classList.add('hidden')}
async function addRole(){let t=await req('POST','/api/roles',{role:el('roleName').value});el('roleMsg').textContent=t;el('roleName').value='';await loadAccess()} function showApiOverview(){el('apisOverview').classList.remove('hidden');el('apiDetail').classList.add('hidden')}
async function deleteRole(r){let t=await req('DELETE','/api/roles',{role:r});el('roleMsg').textContent=t;await loadAccess()} function renderRoles(){el('rolesOut').innerHTML=roles.map((r,i)=>'<button class=item onclick="selectRole('+i+')"><strong>'+esc(r.name)+'</strong><span class=meta><span class=badge>'+(r.system?'System':'Custom')+'</span></span></button>').join('')}
function renderUsers(){el('userList').innerHTML=users.map(u=>'<button class="item '+(u.username==selectedUser?'active':'')+'" onclick="selectUser(\''+u.username+'\')">'+u.username+(u.active?'':' (inactive)')+'</button>').join('')} async function loadRoles(){let j=JSON.parse(await req('GET','/api/roles'));if(j.success){roles=j.roles;renderRoles();roleOptions();if(selectedUser&&el('userDetail').classList.contains('hidden')==false)selectUser(selectedUser)}}
async function loadUsers(){let j=JSON.parse(await req('GET','/api/users'));if(j.success){users=j.users;renderUsers();if(selectedUser)selectUser(selectedUser)}} function newRoleRecord(){selectedRole='';el('rolesOverview').classList.add('hidden');el('roleDetail').classList.remove('hidden');el('roleTitle').textContent='New role';el('roleName').value='';el('roleName').readOnly=false;el('roleMeta').innerHTML='';el('roleSave').classList.remove('hidden');el('roleDelete').classList.add('hidden');el('roleMsg').textContent=''}
function newUserRecord(){selectedUser='';renderUsers();el('userTitle').textContent='New user';el('userName').value='';el('userName').readOnly=false;el('userPass').value='';el('userActive').checked=true;roleChecks('userRoles',[])} function selectRole(i){let r=roles[i];if(!r)return;selectedRole=r.name;el('rolesOverview').classList.add('hidden');el('roleDetail').classList.remove('hidden');el('roleTitle').textContent=r.name;el('roleName').value=r.name;el('roleName').readOnly=true;el('roleMeta').innerHTML='<span class=badge>'+(r.system?'System role':'Custom role')+'</span>';el('roleSave').classList.add('hidden');el('roleDelete').classList.toggle('hidden',r.system);el('roleMsg').textContent=''}
function selectUser(name){selectedUser=name;let u=users.find(x=>x.username==name);if(!u)return;renderUsers();el('userTitle').textContent=name;el('userName').value=name;el('userName').readOnly=true;el('userPass').value='';el('userActive').checked=u.active;roleChecks('userRoles',(u.roles||'').split('|'))} async function addRole(){let t=await req('POST','/api/roles',{role:el('roleName').value});el('roleMsg').textContent=t;await loadAccess();showRoleOverview()}
async function saveSelectedUser(){let t=await req('POST','/api/users',{username:el('userName').value,password:el('userPass').value,active:el('userActive').checked,roles:selectedRoles()});el('usersOut').textContent=t;selectedUser=el('userName').value;await loadUsers()} async function deleteSelectedRole(){if(!selectedRole)return;let t=await req('DELETE','/api/roles',{role:selectedRole});el('roleMsg').textContent=t;selectedRole='';await loadAccess();showRoleOverview()}
function roleOptions(){let opts='<option>PUBLIC</option>'+roleNames().map(r=>'<option>'+r+'</option>').join('');el('apiRole').innerHTML=opts} function jsArg(s){return JSON.stringify(String(s)).replace(/"/g,'&quot;')}
function renderApis(){el('apiList').innerHTML=apis.map((a,i)=>'<button class="item '+(i==selectedApi?'active':'')+'" onclick="selectApi('+i+')">'+a.method+' '+a.path+'</button>').join('')} function renderUsers(){el('userList').innerHTML=users.map(u=>'<button class=item onclick="selectUser('+jsArg(u.username)+')"><strong>'+esc(u.username)+'</strong><span class=meta><span class=badge>'+(u.active?'Active':'Inactive')+'</span><span class=badge>'+esc(u.roles||'No roles')+'</span></span></button>').join('')}
async function loadApis(){let j=JSON.parse(await req('GET','/api/apis'));if(j.success){apis=j.apis;renderApis();if(selectedApi>=0)selectApi(selectedApi)}} async function loadUsers(){let j=JSON.parse(await req('GET','/api/users'));if(j.success){users=j.users;renderUsers();if(selectedUser&&el('userDetail').classList.contains('hidden')==false)selectUser(selectedUser)}}
function selectApi(i){selectedApi=i;let a=apis[i];if(!a)return;renderApis();el('apiTitle').textContent=a.method+' '+a.path;el('apiPath').value=a.path;el('apiMethod').value=a.method;roleOptions();el('apiRole').value=a.role} function newUserRecord(){selectedUser='';el('usersOverview').classList.add('hidden');el('userDetail').classList.remove('hidden');el('userTitle').textContent='New user';el('userName').value='';el('userName').readOnly=false;el('userPass').value='';el('userActive').checked=true;roleChecks('userRoles',[]);el('usersOut').textContent=''}
function selectUser(name){selectedUser=name;let u=users.find(x=>x.username==name);if(!u)return;el('usersOverview').classList.add('hidden');el('userDetail').classList.remove('hidden');el('userTitle').textContent=name;el('userName').value=name;el('userName').readOnly=true;el('userPass').value='';el('userActive').checked=u.active;roleChecks('userRoles',(u.roles||'').split('|'));el('usersOut').textContent=''}
async function saveSelectedUser(){let t=await req('POST','/api/users',{username:el('userName').value,password:el('userPass').value,active:el('userActive').checked,roles:selectedRoles()});el('usersOut').textContent=t;selectedUser=el('userName').value;await loadUsers();showUserOverview()}
function roleOptions(){let opts='<option>PUBLIC</option>'+roleNames().map(r=>'<option>'+esc(r)+'</option>').join('');el('apiRole').innerHTML=opts}
function renderApis(){el('apiList').innerHTML=apis.map((a,i)=>'<button class=item onclick="selectApi('+i+')"><strong>'+esc(a.method+' '+a.path)+'</strong><span class=meta><span class=badge>'+esc(a.role)+'</span></span></button>').join('')}
async function loadApis(){let j=JSON.parse(await req('GET','/api/apis'));if(j.success){apis=j.apis;renderApis();if(selectedApi>=0&&el('apiDetail').classList.contains('hidden')==false)selectApi(selectedApi)}}
function selectApi(i){selectedApi=i;let a=apis[i];if(!a)return;el('apisOverview').classList.add('hidden');el('apiDetail').classList.remove('hidden');el('apiTitle').textContent=a.method+' '+a.path;el('apiPath').value=a.path;el('apiMethod').value=a.method;roleOptions();el('apiRole').value=a.role;el('apisOut').textContent='';el('apiTestOut').textContent=''}
async function saveSelectedApi(){if(selectedApi<0)return;let t=await req('POST','/api/apis',{path:el('apiPath').value,method:el('apiMethod').value,role:el('apiRole').value});el('apisOut').textContent=t;await loadApis()} async function saveSelectedApi(){if(selectedApi<0)return;let t=await req('POST','/api/apis',{path:el('apiPath').value,method:el('apiMethod').value,role:el('apiRole').value});el('apisOut').textContent=t;await loadApis()}
function clearApiTestBody(){el('apiTestBody').value=''}
async function testSelectedApi(){if(selectedApi<0)return;let a=apis[selectedApi],body=el('apiTestBody').value.trim(),payload=undefined;if(body){try{payload=JSON.parse(body)}catch(e){el('apiTestOut').textContent='Invalid JSON body';return}}el('apiTestOut').textContent=await req(a.method,a.path,payload)}
async function loadAccess(){await loadRoles();await loadUsers();await loadApis()} async function loadAccess(){await loadRoles();await loadUsers();await loadApis()}
async function loadLogs(){el('logsOut').textContent=await req('GET','/api/logs')}async function clearLogs(){el('logsOut').textContent=await req('POST','/api/logs/clear')} function fileName(p){let i=String(p).lastIndexOf('/');return i>=0?p.substring(i+1):p}
async function loadSettings(){let t=await req('GET','/api/settings');el('settingsOut').textContent=t;let j=JSON.parse(t);if(j.success){el('logLevel').value=j.settings.logLevel;el('logMax').value=j.settings.maxLogBytes;el('upd').value=j.settings.updateUrl;el('brightness').value=j.settings.ledBrightness;el('ledVal').value=j.settings.ledBrightness}} function renderFiles(files){el('fileList').innerHTML=files.map(f=>'<div class=item><div class=detailActions><button onclick="downloadFile('+jsArg(f.path)+')">Download</button><div><strong>'+esc(f.path)+'</strong><span class=meta><span class=badge>'+f.size+' bytes</span></span></div></div></div>').join('')}
async function loadFiles(){let t=await req('GET','/api/files');el('filesOut').textContent='';try{let j=JSON.parse(t);if(j.success){renderFiles(j.files||[]);if(!(j.files||[]).length)el('filesOut').textContent='No files found'}else{el('filesOut').textContent=t}}catch(e){el('filesOut').textContent=t}}
async function downloadFile(path){let r=await apiFetch('/api/files/download?path='+encodeURIComponent(path),{headers:{Authorization:'Bearer '+token}});if(!r.ok){el('filesOut').textContent=await r.text();return}let b=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download=fileName(path)||'download';document.body.appendChild(a);a.click();URL.revokeObjectURL(a.href);a.remove()}
async function downloadConfig(){let r=await apiFetch('/api/config/export',{headers:{Authorization:'Bearer '+token}});if(!r.ok){el('maintOut').textContent=await r.text();return}let b=await r.blob(),a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='config-backup.json';document.body.appendChild(a);a.click();URL.revokeObjectURL(a.href);a.remove();el('maintOut').textContent='Configuration backup downloaded'}
async function restoreConfig(){if(!el('configFile').files.length){el('maintOut').textContent='Select a configuration JSON file first';return}if(!confirm('Restore configuration from this file? WiFi settings will not change.'))return;setBusy('restoreConfigBtn',true,'Restoring');try{await tracked(async()=>{let body=await el('configFile').files[0].text();let r=await fetch('/api/config/import',{method:'POST',headers:{'Content-Type':'application/json',Authorization:'Bearer '+token},body});el('maintOut').textContent=await r.text();if(r.ok){loadSettings();loadAccess()}})}catch(e){el('maintOut').textContent='Restore failed: '+e.message}finally{setBusy('restoreConfigBtn',false,'Restore config')}}
async function loadCert(){let t=await req('GET','/api/cert');try{let j=JSON.parse(t);el('certOut').textContent=j.success?(j.certificate||'No certificate stored'):t}catch(e){el('certOut').textContent=t}}
async function saveCertFile(){if(!el('certFile').files.length){el('certOut').textContent='Select a PEM/CER/CRT file first';return}setBusy('certSaveBtn',true,'Saving');try{await tracked(async()=>{let certificate=await el('certFile').files[0].text();el('certOut').textContent=await req('POST','/api/cert',{certificate})})}catch(e){el('certOut').textContent='Certificate save failed: '+e.message}finally{setBusy('certSaveBtn',false,'Save certificate')}}
async function factoryResetDevice(){if(!confirm('Factory reset this device and restart?'))return;el('maintOut').textContent=await req('POST','/api/factory-reset')}
async function loadLogs(){let t=await req('GET','/api/logs');try{let j=JSON.parse(t);el('logsOut').textContent=j.success?(j.logs||''):t}catch(e){el('logsOut').textContent=t}}async function clearLogs(){let t=await req('POST','/api/logs/clear');el('logsOut').textContent=t;try{let j=JSON.parse(t);if(j.success)el('logsOut').textContent='Logs cleared'}catch(e){}}
function syncNetworkMode(){let s=el('netDhcp').value=='false';['netIp','netGateway','netSubnet','netDns1','netDns2'].forEach(id=>el(id).disabled=!s)}
async function loadSettings(){let t=await req('GET','/api/settings');el('settingsOut').textContent=t;let j=JSON.parse(t);if(j.success){el('logLevel').value=j.settings.logLevel;el('logMax').value=j.settings.maxLogBytes;el('upd').value=j.settings.updateUrl;el('brightness').value=j.settings.ledBrightness;el('ledVal').value=j.settings.ledBrightness;el('netHost').value=j.settings.hostname||'';el('netHost').placeholder=j.settings.defaultHostname||'Default host name';el('netDhcp').value=String(j.settings.dhcp!==false);el('netIp').value=j.settings.ip||'';el('netGateway').value=j.settings.gateway||'';el('netSubnet').value=j.settings.subnet||'';el('netDns1').value=j.settings.dns1||'';el('netDns2').value=j.settings.dns2||'';el('netCurrent').textContent='Current IP: '+(j.settings.currentIp||'not connected');syncNetworkMode()}}
async function saveSettings(){el('settingsOut').textContent=await req('POST','/api/settings',{logLevel:+el('logLevel').value,maxLogBytes:+el('logMax').value,updateUrl:el('upd').value,ledBrightness:+el('brightness').value})} async function saveSettings(){el('settingsOut').textContent=await req('POST','/api/settings',{logLevel:+el('logLevel').value,maxLogBytes:+el('logMax').value,updateUrl:el('upd').value,ledBrightness:+el('brightness').value})}
async function uploadPkg(){let fd=new FormData();fd.append('package',el('pkg').files[0]);let r=await fetch('/api/update',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd});el('fwOut').textContent=await r.text()} async function saveNetwork(){setBusy('networkSaveBtn',true,'Saving');try{let body={hostname:el('netHost').value,dhcp:el('netDhcp').value=='true',ip:el('netIp').value,gateway:el('netGateway').value,subnet:el('netSubnet').value,dns1:el('netDns1').value,dns2:el('netDns2').value};let t=await req('POST','/api/settings',body);el('networkOut').textContent=t+'\\nNetwork changes apply on the next WiFi reconnect or reboot.';if(JSON.parse(t).success)await loadSettings()}catch(e){el('networkOut').textContent='Network save failed: '+e.message}finally{setBusy('networkSaveBtn',false,'Save network')}}
function setBusy(id,busy,label){let b=el(id);if(!b)return;b.disabled=busy;b.innerHTML=busy?'<span class=spinner></span>'+label:label}
async function uploadPkg(){if(!el('pkg').files.length){el('fwOut').textContent='Select an update package first';return}setBusy('uploadPkgBtn',true,'Uploading');el('fwOut').textContent='Uploading update package...';try{let fd=new FormData();fd.append('package',el('pkg').files[0]);let r=await apiFetch('/api/update',{method:'POST',headers:{Authorization:'Bearer '+token},body:fd});el('fwOut').textContent=await r.text()}catch(e){el('fwOut').textContent='Upload failed: '+e.message}finally{setBusy('uploadPkgBtn',false,'Upload package')}}
async function checkOta(){el('fwOut').textContent=await req('POST','/api/ota/check')}async function runOta(){el('fwOut').textContent=await req('POST','/api/ota/run')} async function checkOta(){el('fwOut').textContent=await req('POST','/api/ota/check')}async function runOta(){el('fwOut').textContent=await req('POST','/api/ota/run')}
</script> </script>
</body> </body>

View File

@@ -38,8 +38,9 @@ extern const IPAddress SETUP_AP_SUBNET;
enum LogLevel : uint8_t { enum LogLevel : uint8_t {
LOG_ERROR = 0, LOG_ERROR = 0,
LOG_WARN = 1, LOG_WARN = 1,
LOG_INFO = 2, LOG_SECURITY_AUDIT = 2,
LOG_DEBUG = 3 LOG_INFO = 3,
LOG_DEBUG = 4
}; };
struct User { struct User {
@@ -73,12 +74,21 @@ extern Preferences prefs;
extern Token tokens[]; extern Token tokens[];
extern ApiDef apiDefs[]; extern ApiDef apiDefs[];
extern const size_t API_DEF_COUNT; extern const size_t API_DEF_COUNT;
extern ApiDef customApiDefs[];
extern const size_t CUSTOM_API_DEF_COUNT;
extern LogLevel currentLogLevel; extern LogLevel currentLogLevel;
extern size_t maxLogBytes; extern size_t maxLogBytes;
extern bool setupMode; extern bool setupMode;
extern uint8_t ledBrightness; extern uint8_t ledBrightness;
extern bool ledInverted; extern bool ledInverted;
extern String updateUrl; 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 jsonEscape(const String &s);
String jsonOk(const String &payload = ""); String jsonOk(const String &payload = "");
@@ -92,6 +102,7 @@ String jsonRolesValue(const String &json, const char *key, const String &fallbac
String chipId(); String chipId();
String deviceName(); String deviceName();
String defaultDeviceName();
String sha256(const String &input); String sha256(const String &input);
String passwordHash(const String &password); String passwordHash(const String &password);
String prefString(const char *key, const String &fallback = ""); String prefString(const char *key, const String &fallback = "");
@@ -113,7 +124,7 @@ String createToken(const User &user);
Token *currentToken(); Token *currentToken();
ApiDef *findApi(const String &path, const String &method); ApiDef *findApi(const String &path, const String &method);
String configuredRole(ApiDef *api); String configuredRole(ApiDef *api);
bool authorize(const String &path, const String &method); bool authorize();
void appendLog(LogLevel level, const String &message); void appendLog(LogLevel level, const String &message);
@@ -131,9 +142,6 @@ void handleAdminPage();
void handleWifiScan(); void handleWifiScan();
void handleSetupSubmit(); void handleSetupSubmit();
void handlePing();
void handleAdd();
void handleLed();
void handleLogin(); void handleLogin();
void handleLogout(); void handleLogout();
void handleMe(); void handleMe();
@@ -149,6 +157,11 @@ void handleSettingsGet();
void handleSettingsPost(); void handleSettingsPost();
void handleLogsGet(); void handleLogsGet();
void handleLogsClear(); void handleLogsClear();
void handleFilesList();
void handleFileDownload();
void handleFactoryReset();
void handleConfigExport();
void handleConfigImport();
void handleOtaCheck(); void handleOtaCheck();
void handleOtaRun(); void handleOtaRun();
void handleUpdateUploadDone(); void handleUpdateUploadDone();

5
include/custom_api.h Normal file
View File

@@ -0,0 +1,5 @@
#pragma once
void handlePing();
void handleAdd();
void handleLed();

View File

@@ -13,3 +13,4 @@ platform = espressif32
board = seeed_xiao_esp32c3 board = seeed_xiao_esp32c3
framework = arduino framework = arduino
board_build.filesystem = littlefs board_build.filesystem = littlefs
extra_scripts = scripts/platformio_targets.py

View File

@@ -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/",
)

View File

@@ -1,11 +1,8 @@
#include "../app.h" #include "app.h"
ApiDef apiDefs[] = { ApiDef apiDefs[] = {
{"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr}, {"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr},
{"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, 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/login", "POST", "", true, handleLogin, nullptr},
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr}, {"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr}, {"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
@@ -21,6 +18,11 @@ ApiDef apiDefs[] = {
{"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr}, {"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr},
{"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr}, {"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr},
{"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, 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/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr},
{"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr}, {"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr},
{"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk}, {"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},

View File

@@ -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]);

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
bool hasRole(const String &roles, const String &role) { bool hasRole(const String &roles, const String &role) {
if (role.length() == 0) return true; 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); for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles);
saveUsers(users, count); saveUsers(users, count);
for (size_t i = 0; i < API_DEF_COUNT; i++) { for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
if (configuredRole(&apiDefs[i]) == role) { ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
String fallback = apiDefs[i].publicByDefault ? "PUBLIC" : apiDefs[i].defaultRole; if (configuredRole(api) == role) {
String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole;
String key = "acl"; String key = "acl";
key += apiDefs[i].method[0]; key += api->method[0];
for (size_t j = 0; j < strlen(apiDefs[i].path); j++) { for (size_t j = 0; j < strlen(api->path); j++) {
char c = apiDefs[i].path[j]; char c = api->path[j];
if (isalnum(c)) key += c; if (isalnum(c)) key += c;
} }
prefs.putString(key.substring(0, 15).c_str(), fallback); prefs.putString(key.substring(0, 15).c_str(), fallback);
@@ -208,6 +209,8 @@ static String bearerToken() {
return ""; return "";
} }
static String requestMethodName();
Token *currentToken() { Token *currentToken() {
String value = bearerToken(); String value = bearerToken();
if (!value.length()) return nullptr; if (!value.length()) return nullptr;
@@ -217,6 +220,7 @@ Token *currentToken() {
return &tokens[i]; return &tokens[i];
} }
} }
appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri());
return nullptr; return nullptr;
} }
@@ -234,6 +238,9 @@ ApiDef *findApi(const String &path, const String &method) {
for (size_t i = 0; i < API_DEF_COUNT; i++) { for (size_t i = 0; i < API_DEF_COUNT; i++) {
if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[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; return nullptr;
} }
@@ -243,22 +250,44 @@ String configuredRole(ApiDef *api) {
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole); 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); ApiDef *api = findApi(path, method);
String required = configuredRole(api); String required = configuredRole(api);
if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true; if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true;
Token *token = currentToken(); Token *token = currentToken();
if (!token) { if (!token) {
if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path);
sendJson(401, jsonError("Authentication required")); sendJson(401, jsonError("Authentication required"));
return false; return false;
} }
User user; User user;
if (!findUser(token->user, user) || !user.active) { 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")); sendJson(401, jsonError("User is inactive"));
return false; return false;
} }
token->roles = user.roles; token->roles = user.roles;
if (!hasRole(user.roles, required)) { 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)); sendJson(403, jsonError("Missing role " + required));
return false; return false;
} }

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
#include <ESPmDNS.h> #include <ESPmDNS.h>
#include <LittleFS.h> #include <LittleFS.h>
@@ -11,9 +11,9 @@ void applyLed() {
} }
void factoryReset() { void factoryReset() {
appendLog(LOG_WARN, "factory reset requested");
prefs.clear(); prefs.clear();
LittleFS.remove("/logs.txt"); LittleFS.remove("/logs.txt");
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
} }
void checkFactoryResetPin() { void checkFactoryResetPin() {
@@ -37,6 +37,21 @@ bool connectWifi() {
String name = deviceName(); String name = deviceName();
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
WiFi.setHostname(name.c_str()); 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()); WiFi.begin(ssid.c_str(), pass.c_str());
appendLog(LOG_INFO, "connecting to WiFi " + ssid); appendLog(LOG_INFO, "connecting to WiFi " + ssid);
uint32_t start = millis(); uint32_t start = millis();

View File

@@ -1,11 +1,21 @@
#include "../app.h" #include "app.h"
#include <LittleFS.h> #include <LittleFS.h>
#include <time.h>
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) { void appendLog(LogLevel level, const String &message) {
if (level > currentLogLevel) return; if (level > currentLogLevel) return;
const char *names[] = {"ERROR", "WARN", "INFO", "DEBUG"}; const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
String line = String(millis()) + " " + names[level] + " " + message + "\n"; String line = timestamp() + " " + names[level] + " " + message + "\n";
File f = LittleFS.open("/logs.txt", "a"); File f = LittleFS.open("/logs.txt", "a");
if (f) { if (f) {
f.print(line); f.print(line);

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
#include <mbedtls/md.h> #include <mbedtls/md.h>
@@ -25,6 +25,13 @@ bool setupMode = false;
uint8_t ledBrightness = 0; uint8_t ledBrightness = 0;
bool ledInverted = DEFAULT_LED_INVERTED; bool ledInverted = DEFAULT_LED_INVERTED;
String updateUrl = DEFAULT_UPDATE_URL; String updateUrl = DEFAULT_UPDATE_URL;
String networkHostname;
bool networkDhcp = true;
String networkIp;
String networkGateway;
String networkSubnet;
String networkDns1;
String networkDns2;
String chipId() { String chipId() {
uint64_t mac = ESP.getEfuseMac(); uint64_t mac = ESP.getEfuseMac();
@@ -33,7 +40,7 @@ String chipId() {
return String(buf); return String(buf);
} }
String deviceName() { String defaultDeviceName() {
String suffix = chipId().substring(6); String suffix = chipId().substring(6);
String base = PROJECT_NAME_VALUE; String base = PROJECT_NAME_VALUE;
base.trim(); base.trim();
@@ -52,6 +59,10 @@ String deviceName() {
return base + "-" + suffix; return base + "-" + suffix;
} }
String deviceName() {
return networkHostname.length() ? networkHostname : defaultDeviceName();
}
String sha256(const String &input) { String sha256(const String &input) {
uint8_t digest[32]; uint8_t digest[32];
mbedtls_md_context_t ctx; mbedtls_md_context_t ctx;
@@ -81,9 +92,23 @@ void loadSettings() {
prefs.putBool("ledInv", DEFAULT_LED_INVERTED); prefs.putBool("ledInv", DEFAULT_LED_INVERTED);
prefs.putBool("ledPolV2", true); 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); maxLogBytes = prefs.getUInt("logMax", 50 * 1024);
ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED); ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED);
ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100); ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100);
updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL); 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", "");
} }

View File

@@ -1,32 +1,88 @@
#include "../app.h" #include "app.h"
#include <LittleFS.h> #include <LittleFS.h>
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() { void handleSettingsGet() {
if (!authorize("/api/settings", "GET")) return; if (!authorize()) return;
String out = "\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "}"; 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)); sendJson(200, jsonOk(out));
} }
void handleSettingsPost() { void handleSettingsPost() {
if (!authorize("/api/settings", "POST")) return; if (!authorize()) return;
String body = requestBody(); 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); maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted); ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100); ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
updateUrl = jsonStringValue(body, "updateUrl", updateUrl); updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
networkHostname = nextHostname;
networkDhcp = nextDhcp;
networkIp = nextIp;
networkGateway = nextGateway;
networkSubnet = nextSubnet;
networkDns1 = nextDns1;
networkDns2 = nextDns2;
prefs.putUChar("logLevel", currentLogLevel); prefs.putUChar("logLevel", currentLogLevel);
prefs.putUInt("logMax", maxLogBytes); prefs.putUInt("logMax", maxLogBytes);
prefs.putBool("ledInv", ledInverted); prefs.putBool("ledInv", ledInverted);
prefs.putUChar("ledBright", ledBrightness); prefs.putUChar("ledBright", ledBrightness);
prefs.putString("updateUrl", updateUrl); 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(); applyLed();
appendLog(LOG_SECURITY_AUDIT, "settings updated");
sendJson(200, jsonOk()); sendJson(200, jsonOk());
} }
void handleLogsGet() { void handleLogsGet() {
if (!authorize("/api/logs", "GET")) return; if (!authorize()) return;
File f = LittleFS.open("/logs.txt", "r"); File f = LittleFS.open("/logs.txt", "r");
String logs = f ? f.readString() : ""; String logs = f ? f.readString() : "";
if (f) f.close(); if (f) f.close();
@@ -34,20 +90,335 @@ void handleLogsGet() {
} }
void handleLogsClear() { void handleLogsClear() {
if (!authorize("/api/logs/clear", "POST")) return; if (!authorize()) return;
LittleFS.remove("/logs.txt"); 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()); sendJson(200, jsonOk());
} }
void handleCertGet() { void handleCertGet() {
if (!authorize("/api/cert", "GET")) return; if (!authorize()) return;
sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"")); sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\""));
} }
void handleCertPost() { void handleCertPost() {
if (!authorize("/api/cert", "POST")) return; if (!authorize()) return;
String cert = jsonStringValue(requestBody(), "certificate"); String cert = jsonStringValue(requestBody(), "certificate");
prefs.putString("cert", cert); 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\"")); sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\""));
} }

View File

@@ -1,41 +1,20 @@
#include "../app.h" #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() { void handleApisGet() {
if (!authorize("/api/apis", "GET")) return; if (!authorize()) return;
String out = "\"apis\":["; 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 += ","; if (i) out += ",";
String role = configuredRole(&apiDefs[i]); String role = configuredRole(api);
out += "{\"path\":\"" + String(apiDefs[i].path) + "\",\"method\":\"" + String(apiDefs[i].method) + "\",\"role\":\"" + jsonEscape(role) + "\"}"; out += "{\"path\":\"" + String(api->path) + "\",\"method\":\"" + String(api->method) + "\",\"role\":\"" + jsonEscape(role) + "\"}";
} }
out += "]"; out += "]";
sendJson(200, jsonOk(out)); sendJson(200, jsonOk(out));
} }
void handleApisPost() { void handleApisPost() {
if (!authorize("/api/apis", "POST")) return; if (!authorize()) return;
String body = requestBody(); String body = requestBody();
String path = jsonStringValue(body, "path"); String path = jsonStringValue(body, "path");
String method = jsonStringValue(body, "method", "GET"); String method = jsonStringValue(body, "method", "GET");
@@ -51,6 +30,6 @@ void handleApisPost() {
if (isalnum(c)) key += c; if (isalnum(c)) key += c;
} }
prefs.putString(key.substring(0, 15).c_str(), role); 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()); sendJson(200, jsonOk());
} }

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
void handleLogin() { void handleLogin() {
String body = requestBody(); String body = requestBody();
@@ -6,29 +6,32 @@ void handleLogin() {
String password = jsonStringValue(body, "password"); String password = jsonStringValue(body, "password");
User user; User user;
if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) { 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")); return sendJson(401, jsonError("Invalid username or password"));
} }
String token = createToken(user); 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) + "\"")); sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\""));
} }
void handleLogout() { void handleLogout() {
if (!authorize("/api/logout", "POST")) return; if (!authorize()) return;
Token *tok = currentToken(); Token *tok = currentToken();
if (tok) *tok = Token(); if (tok) {
appendLog(LOG_SECURITY_AUDIT, "logout " + tok->user);
*tok = Token();
}
sendJson(200, jsonOk()); sendJson(200, jsonOk());
} }
void handleMe() { void handleMe() {
if (!authorize("/api/me", "GET")) return; if (!authorize()) return;
Token *tok = currentToken(); Token *tok = currentToken();
sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\"")); sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\""));
} }
void handleUsersGet() { void handleUsersGet() {
if (!authorize("/api/users", "GET")) return; if (!authorize()) return;
User users[8]; User users[8];
size_t count = parseUsers(users, MAX_USERS); size_t count = parseUsers(users, MAX_USERS);
String out = "\"users\":["; String out = "\"users\":[";
@@ -41,7 +44,7 @@ void handleUsersGet() {
} }
void handleUsersPost() { void handleUsersPost() {
if (!authorize("/api/users", "POST")) return; if (!authorize()) return;
String body = requestBody(); String body = requestBody();
String username = jsonStringValue(body, "username"); String username = jsonStringValue(body, "username");
String password = jsonStringValue(body, "password"); String password = jsonStringValue(body, "password");
@@ -61,12 +64,12 @@ void handleUsersPost() {
users[idx].active = active; users[idx].active = active;
if (idx == count) count++; if (idx == count) count++;
saveUsers(users, count); saveUsers(users, count);
appendLog(LOG_INFO, "user saved " + username); appendLog(LOG_SECURITY_AUDIT, "user saved " + username);
sendJson(200, jsonOk()); sendJson(200, jsonOk());
} }
void handlePassword() { void handlePassword() {
if (!authorize("/api/password", "POST")) return; if (!authorize()) return;
Token *tok = currentToken(); Token *tok = currentToken();
String password = jsonStringValue(requestBody(), "password"); String password = jsonStringValue(requestBody(), "password");
User users[8]; User users[8];
@@ -75,7 +78,7 @@ void handlePassword() {
if (users[i].name == tok->user) { if (users[i].name == tok->user) {
users[i].passwordHash = passwordHash(password); users[i].passwordHash = passwordHash(password);
saveUsers(users, count); saveUsers(users, count);
appendLog(LOG_INFO, "password changed " + tok->user); appendLog(LOG_SECURITY_AUDIT, "password changed " + tok->user);
return sendJson(200, jsonOk()); return sendJson(200, jsonOk());
} }
} }
@@ -83,22 +86,22 @@ void handlePassword() {
} }
void handleRolesGet() { void handleRolesGet() {
if (!authorize("/api/roles", "GET")) return; if (!authorize()) return;
sendJson(200, jsonOk(rolesJson())); sendJson(200, jsonOk(rolesJson()));
} }
void handleRolesPost() { void handleRolesPost() {
if (!authorize("/api/roles", "POST")) return; if (!authorize()) return;
String role = jsonStringValue(requestBody(), "role"); String role = jsonStringValue(requestBody(), "role");
if (!addCustomRole(role)) return sendJson(400, jsonError("Role is invalid or already exists")); 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())); sendJson(200, jsonOk(rolesJson()));
} }
void handleRolesDelete() { void handleRolesDelete() {
if (!authorize("/api/roles", "DELETE")) return; if (!authorize()) return;
String role = jsonStringValue(requestBody(), "role", server.arg("role")); String role = jsonStringValue(requestBody(), "role", server.arg("role"));
if (!deleteCustomRole(role)) return sendJson(400, jsonError("Role cannot be deleted")); 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())); sendJson(200, jsonOk(rolesJson()));
} }

View File

@@ -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)));
}

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
#include <HTTPClient.h> #include <HTTPClient.h>
#include <LittleFS.h> #include <LittleFS.h>
@@ -160,7 +160,7 @@ static bool streamPackageFromUrl(const String &url, String &error) {
} }
void handleOtaCheck() { void handleOtaCheck() {
if (!authorize("/api/ota/check", "POST")) return; if (!authorize()) return;
HTTPClient http; HTTPClient http;
http.begin(updateUrl); http.begin(updateUrl);
int code = http.GET(); int code = http.GET();
@@ -170,8 +170,8 @@ void handleOtaCheck() {
} }
void handleOtaRun() { void handleOtaRun() {
if (!authorize("/api/ota/run", "POST")) return; if (!authorize()) return;
appendLog(LOG_INFO, "package URL update started"); appendLog(LOG_SECURITY_AUDIT, "package URL update started");
String error; String error;
if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error)); if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error));
sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true")); sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true"));
@@ -180,7 +180,7 @@ void handleOtaRun() {
} }
void handleUpdateUploadDone() { void handleUpdateUploadDone() {
if (!authorize("/api/update", "POST")) return; if (!authorize()) return;
bool ok = packageState.stage == PKG_DONE; bool ok = packageState.stage == PKG_DONE;
String error = packageState.error.length() ? packageState.error : "Incomplete update package"; String error = packageState.error.length() ? packageState.error : "Incomplete update package";
sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error)); sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error));
@@ -193,8 +193,8 @@ void handleUpdateUploadDone() {
void handleUpdateUploadChunk() { void handleUpdateUploadChunk() {
HTTPUpload &upload = server.upload(); HTTPUpload &upload = server.upload();
if (upload.status == UPLOAD_FILE_START) { if (upload.status == UPLOAD_FILE_START) {
if (!authorize("/api/update", "POST")) return; if (!authorize()) return;
appendLog(LOG_INFO, "package upload started"); appendLog(LOG_SECURITY_AUDIT, "package upload started");
resetPackageState(); resetPackageState();
} else if (upload.status == UPLOAD_FILE_WRITE) { } else if (upload.status == UPLOAD_FILE_WRITE) {
feedPackageBytes(upload.buf, upload.currentSize); feedPackageBytes(upload.buf, upload.currentSize);

View File

@@ -1,7 +1,7 @@
#include "../app.h" #include "app.h"
void handleWifiScan() { void handleWifiScan() {
if (!setupMode && !authorize("/api/settings", "GET")) return; if (!setupMode && !authorize()) return;
int n = WiFi.scanNetworks(); int n = WiFi.scanNetworks();
String out = "\"networks\":["; String out = "\"networks\":[";
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
@@ -13,7 +13,7 @@ void handleWifiScan() {
} }
void handleSetupSubmit() { void handleSetupSubmit() {
if (!setupMode && !authorize("/api/settings", "POST")) return; if (!setupMode && !authorize()) return;
String body = requestBody(); String body = requestBody();
String ssid = jsonStringValue(body, "ssid"); String ssid = jsonStringValue(body, "ssid");
String wifiPass = jsonStringValue(body, "wifiPass"); String wifiPass = jsonStringValue(body, "wifiPass");
@@ -26,7 +26,7 @@ void handleSetupSubmit() {
User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true}; User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true};
saveUsers(&u, 1); saveUsers(&u, 1);
prefs.putBool("configured", true); prefs.putBool("configured", true);
appendLog(LOG_INFO, "initial setup saved"); appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
sendJson(200, jsonOk("\"restart\":true")); sendJson(200, jsonOk("\"restart\":true"));
delay(500); delay(500);
ESP.restart(); ESP.restart();

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
static int findJsonKey(const String &json, const char *key) { static int findJsonKey(const String &json, const char *key) {
String needle = "\"" + String(key) + "\""; String needle = "\"" + String(key) + "\"";

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
static HTTPMethod httpMethod(const char *method) { static HTTPMethod httpMethod(const char *method) {
if (strcmp(method, "GET") == 0) return HTTP_GET; if (strcmp(method, "GET") == 0) return HTTP_GET;
@@ -9,9 +9,9 @@ static HTTPMethod httpMethod(const char *method) {
return HTTP_ANY; return HTTP_ANY;
} }
static void registerApiRoutes() { static void registerApiDefs(ApiDef *defs, size_t count) {
for (size_t i = 0; i < API_DEF_COUNT; i++) { for (size_t i = 0; i < count; i++) {
ApiDef &api = apiDefs[i]; ApiDef &api = defs[i];
if (api.uploadHandler) { if (api.uploadHandler) {
server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler); server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler);
} else { } 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() { void registerRoutes() {
static const char *headers[] = {"Authorization", "X-Auth-Token"}; static const char *headers[] = {"Authorization", "X-Auth-Token"};
server.collectHeaders(headers, 2); server.collectHeaders(headers, 2);
@@ -38,10 +60,16 @@ void registerRoutes() {
server.onNotFound([]() { server.onNotFound([]() {
if (setupMode) { 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/")) { } else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) {
if (!handleHtmlFileRequest()) handleAdminPage(); if (!handleHtmlFileRequest()) handleAdminPage();
} else { } else {
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
sendJson(404, jsonError("Not found")); sendJson(404, jsonError("Not found"));
} }
}); });

View File

@@ -1,4 +1,4 @@
#include "../app.h" #include "app.h"
#include <LittleFS.h> #include <LittleFS.h>