add bed cooler project.

This commit is contained in:
2026-06-28 13:15:53 -05:00
commit 0f2a03044c
46 changed files with 3597 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
#include "app.h"
ApiDef apiDefs[] = {
{"/api/wifi/scan", "GET", "Sysadmin", false, handleWifiScan, nullptr},
{"/api/setup", "POST", "Sysadmin", false, handleSetupSubmit, nullptr},
{"/api/login", "POST", "", true, handleLogin, nullptr},
{"/api/logout", "POST", "WebUIConnect", false, handleLogout, nullptr},
{"/api/me", "GET", "WebUIConnect", false, handleMe, nullptr},
{"/api/apis", "GET", "Sysadmin", false, handleApisGet, nullptr},
{"/api/apis", "POST", "Sysadmin", false, handleApisPost, nullptr},
{"/api/users", "GET", "UserAdmin", false, handleUsersGet, nullptr},
{"/api/users", "POST", "UserAdmin", false, handleUsersPost, nullptr},
{"/api/roles", "GET", "UserAdmin", false, handleRolesGet, nullptr},
{"/api/roles", "POST", "UserAdmin", false, handleRolesPost, nullptr},
{"/api/roles", "DELETE", "UserAdmin", false, handleRolesDelete, nullptr},
{"/api/password", "POST", "WebUIConnect", false, handlePassword, nullptr},
{"/api/settings", "GET", "Sysadmin", false, handleSettingsGet, nullptr},
{"/api/settings", "POST", "Sysadmin", false, handleSettingsPost, nullptr},
{"/api/logs", "GET", "Debugger", false, handleLogsGet, nullptr},
{"/api/logs/clear", "POST", "Debugger", false, handleLogsClear, nullptr},
{"/api/files", "GET", "Debugger", false, handleFilesList, nullptr},
{"/api/files/download", "GET", "Debugger", false, handleFileDownload, nullptr},
{"/api/factory-reset", "POST", "Sysadmin", false, handleFactoryReset, nullptr},
{"/api/config/export", "GET", "Sysadmin", false, handleConfigExport, nullptr},
{"/api/config/import", "POST", "Sysadmin", false, handleConfigImport, nullptr},
{"/api/ota/check", "POST", "Sysadmin", false, handleOtaCheck, nullptr},
{"/api/ota/run", "POST", "Sysadmin", false, handleOtaRun, nullptr},
{"/api/update", "POST", "Sysadmin", false, handleUpdateUploadDone, handleUpdateUploadChunk},
{"/api/cert", "GET", "Sysadmin", false, handleCertGet, nullptr},
{"/api/cert", "POST", "Sysadmin", false, handleCertPost, nullptr},
};
const size_t API_DEF_COUNT = sizeof(apiDefs) / sizeof(apiDefs[0]);

View File

@@ -0,0 +1,12 @@
#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},
{"/api/uptime", "GET", "WebUIConnect", false, handleUptime, nullptr},
{"/api/uptime/events", "GET", "WebUIConnect", false, handleUptimeEvents, nullptr},
};
const size_t CUSTOM_API_DEF_COUNT = sizeof(customApiDefs) / sizeof(customApiDefs[0]);

295
src/core/auth.cpp Normal file
View File

@@ -0,0 +1,295 @@
#include "app.h"
bool hasRole(const String &roles, const String &role) {
if (role.length() == 0) return true;
int start = 0;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
if (roles.substring(start, end) == role) return true;
start = end + 1;
}
return false;
}
bool validName(const String &s) {
if (s.length() == 0 || s.length() > 31) return false;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (!(isalnum(c) || c == '_' || c == '-' || c == '.')) return false;
}
return true;
}
String cleanRoles(const String &roles) {
String out;
String known = allRoles();
int start = 0;
while (start <= (int)known.length()) {
int end = known.indexOf('|', start);
if (end < 0) end = known.length();
String role = known.substring(start, end);
if (role.length() && hasRole(roles, role)) {
if (out.length()) out += "|";
out += role;
}
start = end + 1;
}
return out;
}
bool isKnownRole(const String &role) {
return role == "PUBLIC" || hasRole(allRoles(), role);
}
bool isSystemRole(const String &role) {
return role == "Sysadmin" || role == "UserAdmin" || role == "WebUIConnect" || role == "Debugger";
}
String allRoles() {
String roles = "Sysadmin|UserAdmin|WebUIConnect|Debugger";
String custom = prefString("roles", "");
int start = 0;
while (start <= (int)custom.length()) {
int end = custom.indexOf('|', start);
if (end < 0) end = custom.length();
String role = custom.substring(start, end);
if (role.length() && !hasRole(roles, role)) roles += "|" + role;
start = end + 1;
if (!custom.length()) break;
}
return roles;
}
String rolesJson() {
String roles = allRoles();
String out = "\"roles\":[";
int start = 0;
bool first = true;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
String role = roles.substring(start, end);
if (role.length()) {
if (!first) out += ",";
out += "{\"name\":\"" + jsonEscape(role) + "\",\"system\":" + String(isSystemRole(role) ? "true" : "false") + "}";
first = false;
}
start = end + 1;
}
out += "]";
return out;
}
bool addCustomRole(const String &role) {
if (!validName(role) || isKnownRole(role)) return false;
String custom = prefString("roles", "");
if (custom.length()) custom += "|";
custom += role;
prefs.putString("roles", custom);
return true;
}
bool deleteCustomRole(const String &role) {
if (!validName(role) || isSystemRole(role) || !hasRole(prefString("roles", ""), role)) return false;
String custom = prefString("roles", "");
String kept;
int start = 0;
while (start <= (int)custom.length()) {
int end = custom.indexOf('|', start);
if (end < 0) end = custom.length();
String item = custom.substring(start, end);
if (item.length() && item != role) {
if (kept.length()) kept += "|";
kept += item;
}
start = end + 1;
}
prefs.putString("roles", kept);
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles);
saveUsers(users, count);
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (configuredRole(api) == role) {
String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole;
String key = "acl";
key += api->method[0];
for (size_t j = 0; j < strlen(api->path); j++) {
char c = api->path[j];
if (isalnum(c)) key += c;
}
prefs.putString(key.substring(0, 15).c_str(), fallback);
}
}
return true;
}
static String defaultUsers() {
return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSysadmin|UserAdmin|WebUIConnect|Debugger\t1\n";
}
static String usersText() {
String text = prefString("users", "");
return text.length() ? text : defaultUsers();
}
size_t parseUsers(User *users, size_t maxUsers) {
String text = usersText();
size_t count = 0;
int start = 0;
while (start < (int)text.length() && count < maxUsers) {
int end = text.indexOf('\n', start);
if (end < 0) end = text.length();
String line = text.substring(start, end);
int a = line.indexOf('\t');
int b = line.indexOf('\t', a + 1);
int c = line.indexOf('\t', b + 1);
if (a > 0 && b > a) {
users[count].name = line.substring(0, a);
users[count].passwordHash = line.substring(a + 1, b);
users[count].roles = cleanRoles(c > b ? line.substring(b + 1, c) : line.substring(b + 1));
users[count].active = c > b ? line.substring(c + 1).toInt() != 0 : true;
count++;
}
start = end + 1;
}
return count;
}
void saveUsers(User *users, size_t count) {
String text;
for (size_t i = 0; i < count; i++) {
text += users[i].name + "\t" + users[i].passwordHash + "\t" + cleanRoles(users[i].roles) + "\t" + String(users[i].active ? "1" : "0") + "\n";
}
prefs.putString("users", text);
}
bool findUser(const String &name, User &user) {
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) {
if (users[i].name == name) {
user = users[i];
return true;
}
}
return false;
}
String createToken(const User &user) {
String value = sha256(String(esp_random(), HEX) + ":" + user.name + ":" + String(millis()));
int slot = 0;
uint32_t oldest = tokens[0].lastSeen;
for (int i = 0; i < (int)MAX_TOKENS; i++) {
if (!tokens[i].value.length()) {
slot = i;
break;
}
if (tokens[i].lastSeen < oldest) {
oldest = tokens[i].lastSeen;
slot = i;
}
}
tokens[slot].value = value;
tokens[slot].user = user.name;
tokens[slot].roles = user.roles;
tokens[slot].lastSeen = millis();
return value;
}
static String bearerToken() {
String auth = server.header("Authorization");
if (auth.startsWith("Bearer ")) return auth.substring(7);
if (server.hasHeader("X-Auth-Token")) return server.header("X-Auth-Token");
if (server.hasArg("token")) return server.arg("token");
return "";
}
static String requestMethodName();
Token *currentToken() {
String value = bearerToken();
if (!value.length()) return nullptr;
for (size_t i = 0; i < MAX_TOKENS; i++) {
if (tokens[i].value == value) {
tokens[i].lastSeen = millis();
return &tokens[i];
}
}
appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri());
return nullptr;
}
static String apiKey(const String &path, const String &method) {
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
return key.substring(0, 15);
}
ApiDef *findApi(const String &path, const String &method) {
for (size_t i = 0; i < API_DEF_COUNT; i++) {
if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[i];
}
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;
}
String configuredRole(ApiDef *api) {
if (!api) return "";
String key = apiKey(api->path, api->method);
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole);
}
static String requestMethodName() {
switch (server.method()) {
case HTTP_GET:
return "GET";
case HTTP_POST:
return "POST";
case HTTP_DELETE:
return "DELETE";
case HTTP_PUT:
return "PUT";
case HTTP_PATCH:
return "PATCH";
default:
return "";
}
}
bool authorize() {
String path = server.uri();
String method = requestMethodName();
ApiDef *api = findApi(path, method);
String required = configuredRole(api);
if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true;
Token *token = currentToken();
if (!token) {
if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path);
sendJson(401, jsonError("Authentication required"));
return false;
}
User user;
if (!findUser(token->user, user) || !user.active) {
appendLog(LOG_SECURITY_AUDIT, "inactive or unknown user token for " + token->user + " on " + method + " " + path);
sendJson(401, jsonError("User is inactive"));
return false;
}
token->roles = user.roles;
if (!hasRole(user.roles, required)) {
appendLog(LOG_SECURITY_AUDIT, "authorization denied for " + user.name + " on " + method + " " + path + " requires " + required);
sendJson(403, jsonError("Missing role " + required));
return false;
}
return true;
}

68
src/core/device.cpp Normal file
View File

@@ -0,0 +1,68 @@
#include "app.h"
#include <ESPmDNS.h>
#include <LittleFS.h>
void applyLed() {
uint8_t pct = constrain(ledBrightness, 0, 100);
uint8_t duty = map(pct, 0, 100, 0, 255);
if (ledInverted) duty = 255 - duty;
analogWrite(LED_BUILTIN, duty);
}
void factoryReset() {
prefs.clear();
LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
}
void checkFactoryResetPin() {
pinMode(FACTORY_RESET_PIN, INPUT_PULLUP);
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) return;
uint32_t start = millis();
while (millis() - start < FACTORY_RESET_HOLD_MS) {
delay(100);
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) {
return;
}
}
factoryReset();
ESP.restart();
}
bool connectWifi() {
String ssid = prefString("wifiSsid", "");
String pass = prefString("wifiPass", "");
if (!ssid.length()) return false;
String name = deviceName();
WiFi.mode(WIFI_STA);
WiFi.setHostname(name.c_str());
if (!networkDhcp) {
IPAddress ip;
IPAddress gateway;
IPAddress subnet;
IPAddress dns1;
IPAddress dns2;
bool ok = ip.fromString(networkIp) && gateway.fromString(networkGateway) && subnet.fromString(networkSubnet);
if (ok) {
if (!networkDns1.length() || !dns1.fromString(networkDns1)) dns1 = gateway;
if (!networkDns2.length() || !dns2.fromString(networkDns2)) dns2 = IPAddress(0, 0, 0, 0);
if (!WiFi.config(ip, gateway, subnet, dns1, dns2)) appendLog(LOG_WARN, "static IP configuration failed; continuing with DHCP");
} else {
appendLog(LOG_WARN, "static IP configuration is incomplete or invalid; continuing with DHCP");
}
}
WiFi.begin(ssid.c_str(), pass.c_str());
appendLog(LOG_INFO, "connecting to WiFi " + ssid);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
delay(250);
}
if (WiFi.status() == WL_CONNECTED) {
MDNS.begin(name.c_str());
appendLog(LOG_INFO, "WiFi connected " + WiFi.localIP().toString());
return true;
}
appendLog(LOG_WARN, "WiFi connection failed; entering setup AP");
return false;
}

43
src/core/logging.cpp Normal file
View File

@@ -0,0 +1,43 @@
#include "app.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) {
if (level > currentLogLevel) return;
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
String line = timestamp() + " " + names[level] + " " + message + "\n";
if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR);
File f = LittleFS.open(LOG_FILE_PATH, "a");
if (f) {
f.print(line);
f.close();
}
f = LittleFS.open(LOG_FILE_PATH, "r");
if (!f) return;
size_t size = f.size();
if (size <= maxLogBytes) {
f.close();
return;
}
size_t keep = maxLogBytes > 1024 ? maxLogBytes - 1024 : maxLogBytes;
f.seek(size - keep, SeekSet);
String tail = f.readString();
f.close();
int newline = tail.indexOf('\n');
if (newline >= 0) tail = tail.substring(newline + 1);
f = LittleFS.open(LOG_FILE_PATH, "w");
if (f) {
f.print(tail);
f.close();
}
}

117
src/core/state.cpp Normal file
View File

@@ -0,0 +1,117 @@
#include "app.h"
#include <mbedtls/md.h>
const char *APP_VERSION = "0.1.0";
const char *PROJECT_NAME_VALUE = PROJECT_NAME;
const char *DEFAULT_ADMIN = "admin";
const char *DEFAULT_UPDATE_URL = "http://example.com/update.tslpkg";
const uint32_t FACTORY_RESET_HOLD_MS = 10000;
const size_t MAX_USERS = 8;
const size_t MAX_TOKENS = 8;
const bool DEFAULT_LED_INVERTED = true;
const byte DNS_PORT = 53;
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
const char *WWW_ROOT = "/www";
const char *LOG_DIR = "/log";
const char *LOG_FILE_PATH = "/log/logs.txt";
WebServer server(80);
DNSServer dnsServer;
Preferences prefs;
Token tokens[8];
LogLevel currentLogLevel = LOG_INFO;
size_t maxLogBytes = 50 * 1024;
bool setupMode = false;
uint8_t ledBrightness = 0;
bool ledInverted = DEFAULT_LED_INVERTED;
String updateUrl = DEFAULT_UPDATE_URL;
String networkHostname;
bool networkDhcp = true;
String networkIp;
String networkGateway;
String networkSubnet;
String networkDns1;
String networkDns2;
String chipId() {
uint64_t mac = ESP.getEfuseMac();
char buf[13];
snprintf(buf, sizeof(buf), "%04X%08X", (uint16_t)(mac >> 32), (uint32_t)mac);
return String(buf);
}
String defaultDeviceName() {
String suffix = chipId().substring(6);
String base = PROJECT_NAME_VALUE;
base.trim();
for (size_t i = 0; i < base.length(); i++) {
char c = base[i];
if (!(isalnum(c) || c == '-')) base.setCharAt(i, '-');
}
while (base.startsWith("-")) base.remove(0, 1);
while (base.endsWith("-")) base.remove(base.length() - 1);
if (!base.length()) base = "ESP32C3";
const size_t maxHostnameLength = 31;
size_t maxBaseLength = maxHostnameLength - 1 - suffix.length();
if (base.length() > maxBaseLength) base = base.substring(0, maxBaseLength);
while (base.endsWith("-")) base.remove(base.length() - 1);
return base + "-" + suffix;
}
String deviceName() {
return networkHostname.length() ? networkHostname : defaultDeviceName();
}
String sha256(const String &input) {
uint8_t digest[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 0);
mbedtls_md_starts(&ctx);
mbedtls_md_update(&ctx, (const unsigned char *)input.c_str(), input.length());
mbedtls_md_finish(&ctx, digest);
mbedtls_md_free(&ctx);
char out[65];
for (int i = 0; i < 32; i++) snprintf(out + (i * 2), 3, "%02x", digest[i]);
out[64] = 0;
return String(out);
}
String passwordHash(const String &password) {
return sha256(chipId() + ":" + password);
}
String prefString(const char *key, const String &fallback) {
return prefs.isKey(key) ? prefs.getString(key, fallback) : fallback;
}
void loadSettings() {
prefs.begin("boiler", false);
if (!prefs.getBool("ledPolV2", false)) {
prefs.putBool("ledInv", DEFAULT_LED_INVERTED);
prefs.putBool("ledPolV2", true);
}
uint8_t rawLogLevel = prefs.getUChar("logLevel", LOG_INFO);
if (!prefs.getBool("logLevelV2", false)) {
if (prefs.isKey("logLevel") && rawLogLevel >= 2) rawLogLevel++;
rawLogLevel = constrain(rawLogLevel, 0, 4);
prefs.putUChar("logLevel", rawLogLevel);
prefs.putBool("logLevelV2", true);
}
currentLogLevel = (LogLevel)constrain(rawLogLevel, 0, 4);
maxLogBytes = prefs.getUInt("logMax", 50 * 1024);
ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED);
ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100);
updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL);
networkHostname = prefString("netHost", "");
networkDhcp = prefs.getBool("netDhcp", true);
networkIp = prefString("netIp", "");
networkGateway = prefString("netGw", "");
networkSubnet = prefString("netMask", "");
networkDns1 = prefString("netDns1", "");
networkDns2 = prefString("netDns2", "");
}

View File

@@ -0,0 +1,435 @@
#include "app.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() {
if (!authorize()) return;
String out = "\"settings\":{\"projectName\":\"" + jsonEscape(PROJECT_NAME_VALUE) + "\",\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(true) + "}";
sendJson(200, jsonOk(out));
}
void handleSettingsPost() {
if (!authorize()) return;
String body = requestBody();
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
nextHostname.trim();
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
String nextIp = jsonStringValue(body, "ip", networkIp);
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
nextIp.trim();
nextGateway.trim();
nextSubnet.trim();
nextDns1.trim();
nextDns2.trim();
if (!validHostnameValue(nextHostname)) return sendJson(400, jsonError("Hostname must be 1-31 letters, digits, or hyphens, and cannot start or end with a hyphen"));
if (!nextDhcp) {
if (!validIpValue(nextIp) || !validIpValue(nextGateway) || !validIpValue(nextSubnet)) return sendJson(400, jsonError("Static IP, gateway, and subnet must be valid IPv4 addresses"));
if (nextDns1.length() && !validIpValue(nextDns1)) return sendJson(400, jsonError("DNS 1 must be a valid IPv4 address"));
if (nextDns2.length() && !validIpValue(nextDns2)) return sendJson(400, jsonError("DNS 2 must be a valid IPv4 address"));
}
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
networkHostname = nextHostname;
networkDhcp = nextDhcp;
networkIp = nextIp;
networkGateway = nextGateway;
networkSubnet = nextSubnet;
networkDns1 = nextDns1;
networkDns2 = nextDns2;
prefs.putUChar("logLevel", currentLogLevel);
prefs.putUInt("logMax", maxLogBytes);
prefs.putBool("ledInv", ledInverted);
prefs.putUChar("ledBright", ledBrightness);
prefs.putString("updateUrl", updateUrl);
prefs.putString("netHost", networkHostname);
prefs.putBool("netDhcp", networkDhcp);
prefs.putString("netIp", networkIp);
prefs.putString("netGw", networkGateway);
prefs.putString("netMask", networkSubnet);
prefs.putString("netDns1", networkDns1);
prefs.putString("netDns2", networkDns2);
applyLed();
appendLog(LOG_SECURITY_AUDIT, "settings updated");
sendJson(200, jsonOk());
}
void handleLogsGet() {
if (!authorize()) return;
File f = LittleFS.open(LOG_FILE_PATH, "r");
String logs = f ? f.readString() : "";
if (f) f.close();
sendJson(200, jsonOk("\"logs\":\"" + jsonEscape(logs) + "\""));
}
void handleLogsClear() {
if (!authorize()) return;
LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "logs cleared");
sendJson(200, jsonOk());
}
static bool validFsPath(const String &path) {
return path.length() && path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
}
static String fsPathArg(const String &fallback = "/") {
String path = server.arg("path");
if (!path.length()) path = fallback;
if (!path.startsWith("/")) path = "/" + path;
while (path.length() > 1 && path.endsWith("/")) path.remove(path.length() - 1);
return path;
}
static String joinedFsPath(const String &parent, const String &name) {
if (name.startsWith("/")) return name;
if (parent == "/") return "/" + name;
return parent + "/" + name;
}
static void appendFsEntryJson(String &out, bool &first, const String &parent, File &file) {
String path = file.name();
path = joinedFsPath(parent, path);
if (!first) out += ",";
out += "{\"name\":\"" + jsonEscape(path.substring(path.lastIndexOf('/') + 1)) + "\",\"path\":\"" + jsonEscape(path) + "\",\"size\":" + String(file.isDirectory() ? 0 : file.size()) + ",\"directory\":" + String(file.isDirectory() ? "true" : "false") + "}";
first = false;
}
static void appendDirectoryJson(String &out, bool &first, const String &path, File &dir) {
File file = dir.openNextFile();
while (file) {
appendFsEntryJson(out, first, path, file);
file.close();
file = dir.openNextFile();
}
}
void handleFilesList() {
if (!authorize()) return;
String path = fsPathArg("/");
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
File dir = LittleFS.open(path, "r");
if (!dir && path != "/") dir = LittleFS.open(path + "/", "r");
if (!dir) return sendJson(404, jsonError("Directory not found"));
if (!dir.isDirectory()) {
dir.close();
return sendJson(400, jsonError("Path is not a directory"));
}
String out = "\"path\":\"" + jsonEscape(path) + "\",\"files\":[";
bool first = true;
appendDirectoryJson(out, first, path, dir);
dir.close();
out += "]";
sendJson(200, jsonOk(out));
}
void handleFileDownload() {
if (!authorize()) return;
String path = fsPathArg("");
if (!validFsPath(path)) return sendJson(400, jsonError("Invalid path"));
File file = LittleFS.open(path, "r");
if (!file || file.isDirectory()) return sendJson(404, jsonError("File not found"));
String name = path.substring(path.lastIndexOf('/') + 1);
server.sendHeader("Content-Disposition", "attachment; filename=\"" + name + "\"");
server.streamFile(file, "application/octet-stream");
file.close();
}
void handleFactoryReset() {
if (!authorize()) return;
factoryReset();
sendJson(200, jsonOk("\"restart\":true"));
delay(500);
ESP.restart();
}
static String apiPrefKey(const String &path, const String &method) {
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
return key.substring(0, 15);
}
static String jsonArrayStringList(const String &items) {
String out = "[";
int start = 0;
bool first = true;
while (start <= (int)items.length()) {
int end = items.indexOf('|', start);
if (end < 0) end = items.length();
String item = items.substring(start, end);
if (item.length()) {
if (!first) out += ",";
out += "\"" + jsonEscape(item) + "\"";
first = false;
}
start = end + 1;
if (!items.length()) break;
}
out += "]";
return out;
}
static String extractJsonArray(const String &json, const char *key) {
String needle = "\"" + String(key) + "\"";
int p = json.indexOf(needle);
if (p < 0) return "";
p = json.indexOf('[', p + needle.length());
if (p < 0) return "";
int start = p;
int depth = 0;
bool inString = false;
bool esc = false;
for (; p < (int)json.length(); p++) {
char c = json[p];
if (esc) {
esc = false;
} else if (c == '\\') {
esc = inString;
} else if (c == '"') {
inString = !inString;
} else if (!inString && c == '[') {
depth++;
} else if (!inString && c == ']') {
depth--;
if (depth == 0) return json.substring(start, p + 1);
}
}
return "";
}
static String objectAt(const String &array, int &pos) {
while (pos < (int)array.length() && array[pos] != '{') pos++;
if (pos >= (int)array.length()) return "";
int start = pos;
int depth = 0;
bool inString = false;
bool esc = false;
for (; pos < (int)array.length(); pos++) {
char c = array[pos];
if (esc) {
esc = false;
} else if (c == '\\') {
esc = inString;
} else if (c == '"') {
inString = !inString;
} else if (!inString && c == '{') {
depth++;
} else if (!inString && c == '}') {
depth--;
if (depth == 0) return array.substring(start, ++pos);
}
}
return "";
}
static String stringArrayToRoles(const String &array) {
String out;
int p = 0;
while (p < (int)array.length()) {
while (p < (int)array.length() && array[p] != '"') p++;
if (p >= (int)array.length()) break;
p++;
String item;
bool esc = false;
while (p < (int)array.length()) {
char c = array[p++];
if (esc) {
item += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
break;
} else {
item += c;
}
}
if (item.length()) {
if (out.length()) out += "|";
out += item;
}
}
return out;
}
static String cleanCustomRolesBackup(const String &roles) {
String out;
int start = 0;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
String role = roles.substring(start, end);
if (validName(role) && !isSystemRole(role) && !hasRole(out, role)) {
if (out.length()) out += "|";
out += role;
}
start = end + 1;
if (!roles.length()) break;
}
return out;
}
void handleConfigExport() {
if (!authorize()) return;
User users[MAX_USERS];
size_t userCount = parseUsers(users, MAX_USERS);
String out = "{";
out += "\"version\":1";
out += ",\"settings\":{\"logLevel\":" + String(currentLogLevel) + ",\"maxLogBytes\":" + String(maxLogBytes) + ",\"updateUrl\":\"" + jsonEscape(updateUrl) + "\",\"ledInverted\":" + String(ledInverted ? "true" : "false") + ",\"ledBrightness\":" + String(ledBrightness) + "," + networkSettingsJson(false) + "}";
out += ",\"customRoles\":" + jsonArrayStringList(prefString("roles", ""));
out += ",\"users\":[";
for (size_t i = 0; i < userCount; i++) {
if (i) out += ",";
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"passwordHash\":\"" + jsonEscape(users[i].passwordHash) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
}
out += "],\"apiAcls\":[";
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (i) out += ",";
out += "{\"path\":\"" + jsonEscape(api->path) + "\",\"method\":\"" + jsonEscape(api->method) + "\",\"role\":\"" + jsonEscape(configuredRole(api)) + "\"}";
}
out += "],\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\"}";
server.sendHeader("Cache-Control", "no-store");
server.sendHeader("Content-Disposition", "attachment; filename=\"config-backup.json\"");
server.send(200, "application/json", out);
}
void handleConfigImport() {
if (!authorize()) return;
String body = requestBody();
if (!body.length()) return sendJson(400, jsonError("Configuration JSON is required"));
String customRoles = cleanCustomRolesBackup(stringArrayToRoles(extractJsonArray(body, "customRoles")));
prefs.putString("roles", customRoles);
User users[MAX_USERS];
size_t userCount = 0;
String usersArray = extractJsonArray(body, "users");
int pos = 0;
while (userCount < MAX_USERS) {
String item = objectAt(usersArray, pos);
if (!item.length()) break;
String username = jsonStringValue(item, "username");
String password = jsonStringValue(item, "passwordHash");
if (!validName(username) || !password.length()) return sendJson(400, jsonError("Invalid user in backup"));
users[userCount].name = username;
users[userCount].passwordHash = password;
users[userCount].roles = cleanRoles(jsonStringValue(item, "roles", "WebUIConnect"));
users[userCount].active = jsonBoolValue(item, "active", true);
userCount++;
}
if (!userCount) return sendJson(400, jsonError("Backup must contain at least one user"));
bool hasActiveSysadmin = false;
for (size_t i = 0; i < userCount; i++) {
if (users[i].active && hasRole(users[i].roles, "Sysadmin")) hasActiveSysadmin = true;
}
if (!hasActiveSysadmin) return sendJson(400, jsonError("Backup must contain an active Sysadmin user"));
saveUsers(users, userCount);
currentLogLevel = (LogLevel)constrain(jsonIntValue(body, "logLevel", currentLogLevel), 0, 4);
maxLogBytes = constrain(jsonIntValue(body, "maxLogBytes", maxLogBytes), 4096, 128 * 1024);
updateUrl = jsonStringValue(body, "updateUrl", updateUrl);
ledInverted = jsonBoolValue(body, "ledInverted", ledInverted);
ledBrightness = constrain(jsonIntValue(body, "ledBrightness", ledBrightness), 0, 100);
String nextHostname = jsonStringValue(body, "hostname", networkHostname);
nextHostname.trim();
bool nextDhcp = jsonBoolValue(body, "dhcp", networkDhcp);
String nextIp = jsonStringValue(body, "ip", networkIp);
String nextGateway = jsonStringValue(body, "gateway", networkGateway);
String nextSubnet = jsonStringValue(body, "subnet", networkSubnet);
String nextDns1 = jsonStringValue(body, "dns1", networkDns1);
String nextDns2 = jsonStringValue(body, "dns2", networkDns2);
nextIp.trim();
nextGateway.trim();
nextSubnet.trim();
nextDns1.trim();
nextDns2.trim();
if (validHostnameValue(nextHostname)) networkHostname = nextHostname;
networkDhcp = nextDhcp;
if (networkDhcp || (validIpValue(nextIp) && validIpValue(nextGateway) && validIpValue(nextSubnet))) {
networkIp = nextIp;
networkGateway = nextGateway;
networkSubnet = nextSubnet;
if (!nextDns1.length() || validIpValue(nextDns1)) networkDns1 = nextDns1;
if (!nextDns2.length() || validIpValue(nextDns2)) networkDns2 = nextDns2;
}
prefs.putUChar("logLevel", currentLogLevel);
prefs.putUInt("logMax", maxLogBytes);
prefs.putString("updateUrl", updateUrl);
prefs.putBool("ledInv", ledInverted);
prefs.putUChar("ledBright", ledBrightness);
prefs.putString("netHost", networkHostname);
prefs.putBool("netDhcp", networkDhcp);
prefs.putString("netIp", networkIp);
prefs.putString("netGw", networkGateway);
prefs.putString("netMask", networkSubnet);
prefs.putString("netDns1", networkDns1);
prefs.putString("netDns2", networkDns2);
prefs.putString("cert", jsonStringValue(body, "certificate", ""));
String aclsArray = extractJsonArray(body, "apiAcls");
pos = 0;
while (true) {
String item = objectAt(aclsArray, pos);
if (!item.length()) break;
String path = jsonStringValue(item, "path");
String method = jsonStringValue(item, "method", "GET");
method.toUpperCase();
String role = jsonStringValue(item, "role", "PUBLIC");
ApiDef *api = findApi(path, method);
if (api && isKnownRole(role)) {
prefs.putString(apiPrefKey(path, method).c_str(), role);
}
}
applyLed();
appendLog(LOG_SECURITY_AUDIT, "configuration restored from backup");
sendJson(200, jsonOk());
}
void handleCertGet() {
if (!authorize()) return;
sendJson(200, jsonOk("\"certificate\":\"" + jsonEscape(prefString("cert", "")) + "\""));
}
void handleCertPost() {
if (!authorize()) return;
String cert = jsonStringValue(requestBody(), "certificate");
prefs.putString("cert", cert);
appendLog(LOG_SECURITY_AUDIT, "HTTPS certificate material saved");
sendJson(200, jsonOk("\"note\":\"certificate is stored for applications that enable TLS termination\""));
}

View File

@@ -0,0 +1,35 @@
#include "app.h"
void handleApisGet() {
if (!authorize()) return;
String out = "\"apis\":[";
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (i) out += ",";
String role = configuredRole(api);
out += "{\"path\":\"" + String(api->path) + "\",\"method\":\"" + String(api->method) + "\",\"role\":\"" + jsonEscape(role) + "\"}";
}
out += "]";
sendJson(200, jsonOk(out));
}
void handleApisPost() {
if (!authorize()) return;
String body = requestBody();
String path = jsonStringValue(body, "path");
String method = jsonStringValue(body, "method", "GET");
method.toUpperCase();
String role = jsonStringValue(body, "role", "PUBLIC");
ApiDef *api = findApi(path, method);
if (!api) return sendJson(404, jsonError("Unknown API"));
if (!isKnownRole(role)) return sendJson(400, jsonError("Set one known role or PUBLIC"));
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
prefs.putString(key.substring(0, 15).c_str(), role);
appendLog(LOG_SECURITY_AUDIT, "API ACL saved " + method + " " + path + " -> " + role);
sendJson(200, jsonOk());
}

View File

@@ -0,0 +1,107 @@
#include "app.h"
void handleLogin() {
String body = requestBody();
String username = jsonStringValue(body, "username");
String password = jsonStringValue(body, "password");
User user;
if (!findUser(username, user) || !user.active || user.passwordHash != passwordHash(password)) {
appendLog(LOG_SECURITY_AUDIT, "failed login for " + username);
return sendJson(401, jsonError("Invalid username or password"));
}
String token = createToken(user);
appendLog(LOG_SECURITY_AUDIT, "login " + username);
sendJson(200, jsonOk("\"token\":\"" + token + "\",\"username\":\"" + jsonEscape(user.name) + "\",\"roles\":\"" + jsonEscape(user.roles) + "\""));
}
void handleLogout() {
if (!authorize()) return;
Token *tok = currentToken();
if (tok) {
appendLog(LOG_SECURITY_AUDIT, "logout " + tok->user);
*tok = Token();
}
sendJson(200, jsonOk());
}
void handleMe() {
if (!authorize()) return;
Token *tok = currentToken();
sendJson(200, jsonOk("\"username\":\"" + jsonEscape(tok->user) + "\",\"roles\":\"" + jsonEscape(tok->roles) + "\""));
}
void handleUsersGet() {
if (!authorize()) return;
User users[8];
size_t count = parseUsers(users, MAX_USERS);
String out = "\"users\":[";
for (size_t i = 0; i < count; i++) {
if (i) out += ",";
out += "{\"username\":\"" + jsonEscape(users[i].name) + "\",\"roles\":\"" + jsonEscape(users[i].roles) + "\",\"active\":" + String(users[i].active ? "true" : "false") + "}";
}
out += "]";
sendJson(200, jsonOk(out));
}
void handleUsersPost() {
if (!authorize()) return;
String body = requestBody();
String username = jsonStringValue(body, "username");
String password = jsonStringValue(body, "password");
String roles = cleanRoles(jsonRolesValue(body, "roles", "WebUIConnect"));
bool active = jsonBoolValue(body, "active", true);
if (!validName(username)) return sendJson(400, jsonError("Username is invalid"));
User users[8];
size_t count = parseUsers(users, MAX_USERS);
size_t idx = count;
for (size_t i = 0; i < count; i++) {
if (users[i].name == username) idx = i;
}
if (idx == count && count >= MAX_USERS) return sendJson(400, jsonError("Maximum user count reached"));
users[idx].name = username;
if (password.length() || idx == count) users[idx].passwordHash = passwordHash(password);
users[idx].roles = roles;
users[idx].active = active;
if (idx == count) count++;
saveUsers(users, count);
appendLog(LOG_SECURITY_AUDIT, "user saved " + username);
sendJson(200, jsonOk());
}
void handlePassword() {
if (!authorize()) return;
Token *tok = currentToken();
String password = jsonStringValue(requestBody(), "password");
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) {
if (users[i].name == tok->user) {
users[i].passwordHash = passwordHash(password);
saveUsers(users, count);
appendLog(LOG_SECURITY_AUDIT, "password changed " + tok->user);
return sendJson(200, jsonOk());
}
}
sendJson(404, jsonError("User not found"));
}
void handleRolesGet() {
if (!authorize()) return;
sendJson(200, jsonOk(rolesJson()));
}
void handleRolesPost() {
if (!authorize()) return;
String role = jsonStringValue(requestBody(), "role");
if (!addCustomRole(role)) return sendJson(400, jsonError("Role is invalid or already exists"));
appendLog(LOG_SECURITY_AUDIT, "role added " + role);
sendJson(200, jsonOk(rolesJson()));
}
void handleRolesDelete() {
if (!authorize()) return;
String role = jsonStringValue(requestBody(), "role", server.arg("role"));
if (!deleteCustomRole(role)) return sendJson(400, jsonError("Role cannot be deleted"));
appendLog(LOG_SECURITY_AUDIT, "role deleted " + role);
sendJson(200, jsonOk(rolesJson()));
}

View File

@@ -0,0 +1,87 @@
#include "app.h"
#include "custom_api.h"
#include <DallasTemperature.h>
#include <OneWire.h>
static OneWire oneWire(DS18B20_PIN);
static DallasTemperature temperatureSensors(&oneWire);
static bool temperatureStarted = false;
static bool temperaturePending = false;
static float lastTemperatureC = NAN;
static uint32_t temperatureRequestMs = 0;
static void beginTemperatureSensor() {
if (temperatureStarted) return;
temperatureSensors.begin();
temperatureSensors.setResolution(12);
temperatureSensors.setWaitForConversion(false);
temperatureSensors.requestTemperatures();
temperatureRequestMs = millis();
temperaturePending = true;
temperatureStarted = true;
}
static void updateTemperatureSensor() {
beginTemperatureSensor();
uint32_t now = millis();
if (temperaturePending && now - temperatureRequestMs >= 750) {
float value = temperatureSensors.getTempCByIndex(0);
if (value != DEVICE_DISCONNECTED_C) lastTemperatureC = value;
temperaturePending = false;
}
if (!temperaturePending && now - temperatureRequestMs >= 1000) {
temperatureSensors.requestTemperatures();
temperatureRequestMs = now;
temperaturePending = true;
}
}
static String temperatureJsonFields() {
updateTemperatureSensor();
String json = "\"pin\":" + String(DS18B20_PIN);
json += ",\"sensorConnected\":";
json += isnan(lastTemperatureC) ? "false" : "true";
json += ",\"temperatureC\":";
json += isnan(lastTemperatureC) ? "null" : String(lastTemperatureC, 2);
json += ",\"temperatureF\":";
json += isnan(lastTemperatureC) ? "null" : String((lastTemperatureC * 9.0f / 5.0f) + 32.0f, 2);
return json;
}
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)));
}
void handleUptime() {
if (!authorize()) return;
sendJson(200, jsonOk(temperatureJsonFields()));
}
void handleUptimeEvents() {
if (!authorize()) return;
String payload = "retry: 1000\n";
payload += "event: temperature\n";
payload += "data: {" + temperatureJsonFields() + "}\n\n";
server.sendHeader("Cache-Control", "no-store");
server.sendHeader("Connection", "close");
server.send(200, "text/event-stream", payload);
}

View File

@@ -0,0 +1,202 @@
#include "app.h"
#include <HTTPClient.h>
#include <LittleFS.h>
#include <Update.h>
static const uint8_t PACKAGE_HEADER_SIZE = 32;
static const char PACKAGE_MAGIC[8] = {'T', 'S', 'L', 'U', 'P', 'D', '1', 0};
enum PackageStage : uint8_t {
PKG_HEADER,
PKG_FILESYSTEM,
PKG_FIRMWARE,
PKG_DONE,
PKG_ERROR
};
struct PackageState {
PackageStage stage;
uint8_t header[PACKAGE_HEADER_SIZE];
size_t headerRead;
uint32_t filesystemSize;
uint32_t firmwareSize;
uint32_t remaining;
bool filesystemEnded;
String error;
};
static PackageState packageState;
static uint32_t readLe32(const uint8_t *p) {
return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24);
}
static void resetPackageState() {
packageState.stage = PKG_HEADER;
packageState.headerRead = 0;
packageState.filesystemSize = 0;
packageState.firmwareSize = 0;
packageState.remaining = 0;
packageState.filesystemEnded = false;
packageState.error = "";
}
static bool failPackage(const String &message) {
packageState.stage = PKG_ERROR;
packageState.error = message;
Update.abort();
if (packageState.filesystemEnded) LittleFS.begin(false);
return false;
}
static bool beginPackagePart(uint32_t size, int command, const char *label) {
if (command == U_SPIFFS) LittleFS.end();
if (!Update.begin(size, command)) {
return failPackage(String(label) + " update begin failed: " + Update.errorString());
}
packageState.remaining = size;
return true;
}
static bool finishPackagePart(const char *label) {
if (!Update.end(true)) {
return failPackage(String(label) + " update failed: " + Update.errorString());
}
return true;
}
static bool parsePackageHeader() {
if (memcmp(packageState.header, PACKAGE_MAGIC, sizeof(PACKAGE_MAGIC)) != 0) {
return failPackage("Invalid update package magic");
}
uint32_t headerSize = readLe32(packageState.header + 8);
packageState.filesystemSize = readLe32(packageState.header + 12);
packageState.firmwareSize = readLe32(packageState.header + 16);
if (headerSize != PACKAGE_HEADER_SIZE) return failPackage("Unsupported update package header");
if (!packageState.filesystemSize || !packageState.firmwareSize) return failPackage("Update package must contain filesystem and firmware images");
packageState.stage = PKG_FILESYSTEM;
return beginPackagePart(packageState.filesystemSize, U_SPIFFS, "Filesystem");
}
static bool feedPackageBytes(const uint8_t *data, size_t length) {
while (length && packageState.stage != PKG_ERROR && packageState.stage != PKG_DONE) {
if (packageState.stage == PKG_HEADER) {
size_t n = min(length, (size_t)PACKAGE_HEADER_SIZE - packageState.headerRead);
memcpy(packageState.header + packageState.headerRead, data, n);
packageState.headerRead += n;
data += n;
length -= n;
if (packageState.headerRead == PACKAGE_HEADER_SIZE && !parsePackageHeader()) return false;
} else {
size_t n = min(length, (size_t)packageState.remaining);
if (Update.write((uint8_t *)data, n) != n) {
return failPackage(String(packageState.stage == PKG_FILESYSTEM ? "Filesystem" : "Firmware") + " write failed");
}
packageState.remaining -= n;
data += n;
length -= n;
if (packageState.remaining == 0) {
if (packageState.stage == PKG_FILESYSTEM) {
if (!finishPackagePart("Filesystem")) return false;
packageState.filesystemEnded = true;
packageState.stage = PKG_FIRMWARE;
if (!beginPackagePart(packageState.firmwareSize, U_FLASH, "Firmware")) return false;
} else {
if (!finishPackagePart("Firmware")) return false;
packageState.stage = PKG_DONE;
}
}
}
}
if (length && packageState.stage == PKG_DONE) return failPackage("Trailing bytes in update package");
return packageState.stage != PKG_ERROR;
}
static bool streamPackageFromUrl(const String &url, String &error) {
resetPackageState();
HTTPClient http;
http.begin(url);
int code = http.GET();
if (code != HTTP_CODE_OK) {
http.end();
error = "Update package URL returned HTTP " + String(code);
return false;
}
uint8_t buffer[1024];
WiFiClient *stream = http.getStreamPtr();
int expectedLength = http.getSize();
int receivedLength = 0;
uint32_t lastRead = millis();
while (expectedLength < 0 || receivedLength < expectedLength) {
int available = stream->available();
if (available > 0) {
int wanted = min(available, (int)sizeof(buffer));
if (expectedLength >= 0) wanted = min(wanted, expectedLength - receivedLength);
int n = stream->readBytes(buffer, wanted);
if (n > 0) {
receivedLength += n;
lastRead = millis();
if (!feedPackageBytes(buffer, n)) break;
}
} else {
if (!http.connected()) break;
if (millis() - lastRead > 30000) {
failPackage("Update package download timed out");
break;
}
delay(10);
}
}
http.end();
if (packageState.stage != PKG_DONE) {
error = packageState.error.length() ? packageState.error : "Incomplete update package";
return false;
}
return true;
}
void handleOtaCheck() {
if (!authorize()) return;
HTTPClient http;
http.begin(updateUrl);
int code = http.GET();
int size = http.getSize();
http.end();
sendJson(code > 0 && code < 400 ? 200 : 502, jsonOk("\"version\":\"" + String(APP_VERSION) + "\",\"url\":\"" + jsonEscape(updateUrl) + "\",\"httpStatus\":" + String(code) + ",\"contentLength\":" + String(size)));
}
void handleOtaRun() {
if (!authorize()) return;
appendLog(LOG_SECURITY_AUDIT, "package URL update started");
String error;
if (!streamPackageFromUrl(updateUrl, error)) return sendJson(502, jsonError(error));
sendJson(200, jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true"));
delay(500);
ESP.restart();
}
void handleUpdateUploadDone() {
if (!authorize()) return;
bool ok = packageState.stage == PKG_DONE;
String error = packageState.error.length() ? packageState.error : "Incomplete update package";
sendJson(ok ? 200 : 500, ok ? jsonOk("\"restart\":true,\"filesystemUpdated\":true,\"firmwareUpdated\":true") : jsonError(error));
if (ok) {
delay(500);
ESP.restart();
}
}
void handleUpdateUploadChunk() {
HTTPUpload &upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
if (!authorize()) return;
appendLog(LOG_SECURITY_AUDIT, "package upload started");
resetPackageState();
} else if (upload.status == UPLOAD_FILE_WRITE) {
feedPackageBytes(upload.buf, upload.currentSize);
}
}

View File

@@ -0,0 +1,33 @@
#include "app.h"
void handleWifiScan() {
if (!setupMode && !authorize()) return;
int n = WiFi.scanNetworks();
String out = "\"networks\":[";
for (int i = 0; i < n; i++) {
if (i) out += ",";
out += "{\"ssid\":\"" + jsonEscape(WiFi.SSID(i)) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + ",\"open\":" + String(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "true" : "false") + "}";
}
out += "]";
sendJson(200, jsonOk(out));
}
void handleSetupSubmit() {
if (!setupMode && !authorize()) return;
String body = requestBody();
String ssid = jsonStringValue(body, "ssid");
String wifiPass = jsonStringValue(body, "wifiPass");
String admin = jsonStringValue(body, "admin", DEFAULT_ADMIN);
String adminPass = jsonStringValue(body, "adminPass");
if (!ssid.length()) return sendJson(400, jsonError("WiFi SSID is required"));
if (!validName(admin)) return sendJson(400, jsonError("Admin username is invalid"));
prefs.putString("wifiSsid", ssid);
prefs.putString("wifiPass", wifiPass);
User u{admin, passwordHash(adminPass), "Sysadmin|UserAdmin|WebUIConnect|Debugger", true};
saveUsers(&u, 1);
prefs.putBool("configured", true);
appendLog(LOG_SECURITY_AUDIT, "initial setup saved");
sendJson(200, jsonOk("\"restart\":true"));
delay(500);
ESP.restart();
}

38
src/main.cpp Normal file
View File

@@ -0,0 +1,38 @@
#include "app.h"
#include <LittleFS.h>
void setup() {
Serial.begin(9600);
delay(200);
Serial.println("Booting");
LittleFS.begin(true);
loadSettings();
pinMode(LED_BUILTIN, OUTPUT);
applyLed();
checkFactoryResetPin();
bool configured = prefs.getBool("configured", false);
setupMode = !configured || !connectWifi();
if (setupMode) {
String ssid = deviceName();
WiFi.mode(WIFI_AP_STA);
WiFi.softAPConfig(SETUP_AP_IP, SETUP_AP_GATEWAY, SETUP_AP_SUBNET);
WiFi.softAP(ssid.c_str());
dnsServer.start(DNS_PORT, "*", SETUP_AP_IP);
appendLog(LOG_INFO, "setup AP started " + ssid);
Serial.println("Setup AP: " + ssid + " http://" + SETUP_AP_IP.toString() + "/");
} else {
Serial.println("Admin UI: http://" + WiFi.localIP().toString() + "/");
Serial.println("Admin UI local: http://" + deviceName() + ".local/");
}
registerRoutes();
server.begin();
appendLog(LOG_INFO, "HTTP server started");
}
void loop() {
if (setupMode) dnsServer.processNextRequest();
server.handleClient();
}

106
src/util/json_utils.cpp Normal file
View File

@@ -0,0 +1,106 @@
#include "app.h"
static int findJsonKey(const String &json, const char *key) {
String needle = "\"" + String(key) + "\"";
int p = json.indexOf(needle);
if (p < 0) return -1;
p = json.indexOf(':', p + needle.length());
return p < 0 ? -1 : p + 1;
}
String jsonEscape(const String &s) {
String out;
out.reserve(s.length() + 8);
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (c == '"' || c == '\\') {
out += '\\';
out += c;
} else if (c == '\n') {
out += "\\n";
} else if (c == '\r') {
out += "\\r";
} else {
out += c;
}
}
return out;
}
String jsonOk(const String &payload) {
return String("{\"success\":true") + (payload.length() ? "," + payload : "") + "}";
}
String jsonError(const String &message) {
return "{\"success\":false,\"error\":\"" + jsonEscape(message) + "\"}";
}
void sendJson(int code, const String &body) {
server.sendHeader("Cache-Control", "no-store");
server.send(code, "application/json", body);
}
String requestBody() {
return server.hasArg("plain") ? server.arg("plain") : "";
}
String jsonStringValue(const String &json, const char *key, const String &fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
if (p >= (int)json.length() || json[p] != '"') return fallback;
p++;
String out;
bool esc = false;
for (; p < (int)json.length(); p++) {
char c = json[p];
if (esc) {
out += c == 'n' ? '\n' : c == 'r' ? '\r' : c;
esc = false;
} else if (c == '\\') {
esc = true;
} else if (c == '"') {
return out;
} else {
out += c;
}
}
return fallback;
}
int jsonIntValue(const String &json, const char *key, int fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
return json.substring(p).toInt();
}
bool jsonBoolValue(const String &json, const char *key, bool fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
if (json.substring(p, p + 4) == "true") return true;
if (json.substring(p, p + 5) == "false") return false;
return fallback;
}
String jsonRolesValue(const String &json, const char *key, const String &fallback) {
int p = findJsonKey(json, key);
if (p < 0) return fallback;
while (p < (int)json.length() && isspace(json[p])) p++;
if (json[p] == '"') return jsonStringValue(json, key, fallback);
if (json[p] != '[') return fallback;
String roles;
p++;
while (p < (int)json.length() && json[p] != ']') {
while (p < (int)json.length() && json[p] != '"' && json[p] != ']') p++;
if (p >= (int)json.length() || json[p] == ']') break;
p++;
String role;
while (p < (int)json.length() && json[p] != '"') role += json[p++];
if (roles.length()) roles += "|";
roles += role;
p++;
}
return roles.length() ? roles : fallback;
}

76
src/web/routes.cpp Normal file
View File

@@ -0,0 +1,76 @@
#include "app.h"
static HTTPMethod httpMethod(const char *method) {
if (strcmp(method, "GET") == 0) return HTTP_GET;
if (strcmp(method, "POST") == 0) return HTTP_POST;
if (strcmp(method, "DELETE") == 0) return HTTP_DELETE;
if (strcmp(method, "PUT") == 0) return HTTP_PUT;
if (strcmp(method, "PATCH") == 0) return HTTP_PATCH;
return HTTP_ANY;
}
static void registerApiDefs(ApiDef *defs, size_t count) {
for (size_t i = 0; i < count; i++) {
ApiDef &api = defs[i];
if (api.uploadHandler) {
server.on(api.path, httpMethod(api.method), api.handler, api.uploadHandler);
} else {
server.on(api.path, httpMethod(api.method), api.handler);
}
}
}
static void registerApiRoutes() {
registerApiDefs(apiDefs, API_DEF_COUNT);
registerApiDefs(customApiDefs, CUSTOM_API_DEF_COUNT);
}
static String requestMethodName() {
switch (server.method()) {
case HTTP_GET:
return "GET";
case HTTP_POST:
return "POST";
case HTTP_DELETE:
return "DELETE";
case HTTP_PUT:
return "PUT";
case HTTP_PATCH:
return "PATCH";
default:
return "UNKNOWN";
}
}
void registerRoutes() {
static const char *headers[] = {"Authorization", "X-Auth-Token"};
server.collectHeaders(headers, 2);
server.on("/", HTTP_GET, setupMode ? handleSetupPage : handleAdminPage);
server.on("/favicon.ico", HTTP_GET, handleFavicon);
server.on("/generate_204", HTTP_GET, handleCaptiveProbe);
server.on("/gen_204", HTTP_GET, handleCaptiveProbe);
server.on("/hotspot-detect.html", HTTP_GET, handleCaptiveProbe);
server.on("/library/test/success.html", HTTP_GET, handleCaptiveProbe);
server.on("/connecttest.txt", HTTP_GET, handleCaptiveProbe);
server.on("/ncsi.txt", HTTP_GET, handleCaptiveProbe);
server.on("/fwlink", HTTP_GET, handleCaptiveProbe);
registerApiRoutes();
server.onNotFound([]() {
if (setupMode) {
if (server.uri().startsWith("/api/")) {
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
sendJson(404, jsonError("Not found"));
} else if (!handleHtmlFileRequest()) {
redirectToSetupPage();
}
} else if (server.method() == HTTP_GET && !server.uri().startsWith("/api/")) {
if (!handleHtmlFileRequest()) handleAdminPage();
} else {
appendLog(LOG_SECURITY_AUDIT, "undefined URL or method " + requestMethodName() + " " + server.uri());
sendJson(404, jsonError("Not found"));
}
});
}

69
src/web/ui.cpp Normal file
View File

@@ -0,0 +1,69 @@
#include "app.h"
#include <LittleFS.h>
static bool validPublicPath(const String &path) {
return path.startsWith("/") && path.indexOf("..") < 0 && path.indexOf('\\') < 0;
}
static String contentTypeFor(const String &path) {
if (path.endsWith(".html")) return "text/html";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
if (path.endsWith(".ico")) return "image/x-icon";
return "application/octet-stream";
}
static bool serveWwwFile(const String &path) {
if (!validPublicPath(path)) return false;
String fsPath = String(WWW_ROOT) + path;
File file = LittleFS.open(fsPath, "r");
if (!file || file.isDirectory()) {
if (file) file.close();
return false;
}
server.sendHeader("Cache-Control", path.endsWith(".html") ? "no-store" : "max-age=3600");
server.streamFile(file, contentTypeFor(path));
file.close();
return true;
}
void handleSetupPage() {
if (!serveWwwFile("/setup.html")) {
sendJson(500, jsonError("Missing /www/setup.html in LittleFS"));
}
}
bool handleHtmlFileRequest() {
String path = server.uri();
if (path == "/") path = setupMode ? "/setup.html" : "/admin.html";
return serveWwwFile(path);
}
void redirectToSetupPage() {
server.sendHeader("Location", String("http://") + SETUP_AP_IP.toString() + "/", true);
server.sendHeader("Cache-Control", "no-store");
server.send(302, "text/plain", "");
}
void handleCaptiveProbe() {
if (setupMode) {
redirectToSetupPage();
} else {
server.send(204, "text/plain", "");
}
}
void handleFavicon() {
if (!serveWwwFile("/favicon.ico")) server.send(204, "image/x-icon", "");
}
void handleAdminPage() {
if (!serveWwwFile("/admin.html")) {
sendJson(500, jsonError("Missing /www/admin.html in LittleFS"));
}
}