add bed cooler project.
This commit is contained in:
295
src/core/auth.cpp
Normal file
295
src/core/auth.cpp
Normal 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
68
src/core/device.cpp
Normal 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
43
src/core/logging.cpp
Normal 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
117
src/core/state.cpp
Normal 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", "");
|
||||
}
|
||||
Reference in New Issue
Block a user