|
|
@@ -8,7 +8,7 @@
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* Provides REST API for Home Assistant integration
|
|
|
|
* Provides REST API for Home Assistant integration
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
// version 1.8 Initial release
|
|
|
|
// version 2.1 Initial release
|
|
|
|
#include <WiFi.h>
|
|
|
|
#include <WiFi.h>
|
|
|
|
#include <WebServer.h>
|
|
|
|
#include <WebServer.h>
|
|
|
|
#include <WiFiClient.h>
|
|
|
|
#include <WiFiClient.h>
|
|
|
@@ -24,6 +24,13 @@
|
|
|
|
#define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532
|
|
|
|
#define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532
|
|
|
|
#define NFC_POLL_MS 500 // idle detection interval (ms)
|
|
|
|
#define NFC_POLL_MS 500 // idle detection interval (ms)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Credentials & shared secrets ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
// Loaded from secrets.h which is NOT committed to version control.
|
|
|
|
|
|
|
|
// Copy secrets.h.example → secrets.h, fill in your values, then flash.
|
|
|
|
|
|
|
|
#include "secrets.h"
|
|
|
|
|
|
|
|
#include "mbedtls/md.h" // HMAC-SHA256 — API request authentication
|
|
|
|
|
|
|
|
#include <time.h> // time() — NTP-based timestamp validation
|
|
|
|
|
|
|
|
|
|
|
|
HardwareSerial nfcSerial(1); // UART1
|
|
|
|
HardwareSerial nfcSerial(1); // UART1
|
|
|
|
PN532_HSU pn532hsu(nfcSerial);
|
|
|
|
PN532_HSU pn532hsu(nfcSerial);
|
|
|
|
PN532 nfc(pn532hsu);
|
|
|
|
PN532 nfc(pn532hsu);
|
|
|
@@ -167,6 +174,28 @@ void setup() {
|
|
|
|
Serial.println(" HTTP server will start anyway \u2014 accessible once WiFi reconnects.");
|
|
|
|
Serial.println(" HTTP server will start anyway \u2014 accessible once WiFi reconnects.");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── NTP time sync ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
// Required for HMAC timestamp validation in verifyAPIRequest().
|
|
|
|
|
|
|
|
// Syncs wall-clock time; if no internet the timestamp check is skipped but
|
|
|
|
|
|
|
|
// HMAC verification still runs (proving knowledge of API_SECRET).
|
|
|
|
|
|
|
|
configTime(0, 0, "pool.ntp.org", "time.google.com");
|
|
|
|
|
|
|
|
if (wifi_ok) {
|
|
|
|
|
|
|
|
Serial.print("NTP sync ");
|
|
|
|
|
|
|
|
time_t _ntp_now = time(nullptr);
|
|
|
|
|
|
|
|
for (int _i = 0; _i < 20 && _ntp_now < 1577836800L; _i++) {
|
|
|
|
|
|
|
|
delay(500); Serial.print("."); _ntp_now = time(nullptr);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
Serial.println();
|
|
|
|
|
|
|
|
if (_ntp_now > 1577836800L) {
|
|
|
|
|
|
|
|
char _tbuf[32];
|
|
|
|
|
|
|
|
strftime(_tbuf, sizeof(_tbuf), "%Y-%m-%d %H:%M:%S UTC", gmtime(&_ntp_now));
|
|
|
|
|
|
|
|
Serial.printf("\u2713 NTP synced: %s\n", _tbuf);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
Serial.println("\u26a0 NTP sync failed \u2014 timestamp check disabled");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
// Setup API endpoints
|
|
|
|
// Setup API endpoints
|
|
|
|
server.on("/", handleRoot);
|
|
|
|
server.on("/", handleRoot);
|
|
|
|
server.on("/api/status", HTTP_GET, handleStatus);
|
|
|
|
server.on("/api/status", HTTP_GET, handleStatus);
|
|
|
@@ -187,6 +216,10 @@ void setup() {
|
|
|
|
server.on("/nfc/config", HTTP_POST, handleNFCConfigSet);
|
|
|
|
server.on("/nfc/config", HTTP_POST, handleNFCConfigSet);
|
|
|
|
server.on("/debug", HTTP_GET, handleDebug);
|
|
|
|
server.on("/debug", HTTP_GET, handleDebug);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Collect HMAC auth headers for verifyAPIRequest() ─────────────────────────
|
|
|
|
|
|
|
|
const char* _api_hdrs[] = {"X-Request-Time", "X-Request-Sig"};
|
|
|
|
|
|
|
|
server.collectHeaders(_api_hdrs, 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Start server
|
|
|
|
// Start server
|
|
|
|
server.begin();
|
|
|
|
server.begin();
|
|
|
|
Serial.println("\n✓ HTTP server started on port 80");
|
|
|
|
Serial.println("\n✓ HTTP server started on port 80");
|
|
|
@@ -398,6 +431,8 @@ void scanWiFiNetworks() {
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
void handleRoot() {
|
|
|
|
void handleRoot() {
|
|
|
|
|
|
|
|
if (!requireAuth()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Read all inputs first to get current state
|
|
|
|
// Read all inputs first to get current state
|
|
|
|
input1_state = digitalRead(DIN1_PIN);
|
|
|
|
input1_state = digitalRead(DIN1_PIN);
|
|
|
|
input2_state = digitalRead(DIN2_PIN);
|
|
|
|
input2_state = digitalRead(DIN2_PIN);
|
|
|
@@ -635,11 +670,93 @@ void handleRoot() {
|
|
|
|
server.send(200, "text/html", html);
|
|
|
|
server.send(200, "text/html", html);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Web UI authentication helper ────────────────────────────────────────
|
|
|
|
|
|
|
|
bool requireAuth() {
|
|
|
|
|
|
|
|
if (!server.authenticate(WEB_USER, WEB_PASSWORD)) {
|
|
|
|
|
|
|
|
server.requestAuthentication(DIGEST_AUTH, "ESP32-C6 Control Panel",
|
|
|
|
|
|
|
|
"Login required");
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── HMAC-SHA256 API request verification ─────────────────────────────────────
|
|
|
|
|
|
|
|
// Returns true if the request carries a valid X-Request-Sig header (or if
|
|
|
|
|
|
|
|
// API_SECRET is empty, meaning auth is disabled).
|
|
|
|
|
|
|
|
// Message format: "METHOD:path:unix_timestamp" e.g. "GET:/status:1710512345"
|
|
|
|
|
|
|
|
// Timestamp must be within ±60 s of board time (requires NTP sync).
|
|
|
|
|
|
|
|
bool verifyAPIRequest() {
|
|
|
|
|
|
|
|
// ── Option 1: browser UI — valid Digest Auth credentials ─────────────────
|
|
|
|
|
|
|
|
// The browser cannot generate HMAC headers, so we accept Digest Auth as an
|
|
|
|
|
|
|
|
// alternative. requireAuth() is still the sole gate on the HTML pages.
|
|
|
|
|
|
|
|
if (server.authenticate(WEB_USER, WEB_PASSWORD)) return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Option 2: driver API — valid HMAC-SHA256 signature ───────────────────
|
|
|
|
|
|
|
|
// If no secret configured, allow all requests (backward compat / first boot)
|
|
|
|
|
|
|
|
if (strlen(API_SECRET) == 0) return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String ts_str = server.header("X-Request-Time");
|
|
|
|
|
|
|
|
String sig_str = server.header("X-Request-Sig");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (ts_str.length() == 0 || sig_str.length() == 0) {
|
|
|
|
|
|
|
|
server.send(401, "application/json", "{\"error\":\"missing auth headers\"}");
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Replay prevention: only validate timestamp when NTP is synced
|
|
|
|
|
|
|
|
time_t now = time(nullptr);
|
|
|
|
|
|
|
|
if (now > 1000000000L) { // NTP synced (year > 2001)
|
|
|
|
|
|
|
|
long ts = ts_str.toInt();
|
|
|
|
|
|
|
|
if (abs((long)now - ts) > 60) {
|
|
|
|
|
|
|
|
server.send(401, "application/json", "{\"error\":\"timestamp out of window\"}");
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Build expected message: "METHOD:path:timestamp"
|
|
|
|
|
|
|
|
String method = (server.method() == HTTP_POST) ? "POST" : "GET";
|
|
|
|
|
|
|
|
String msg = method + ":" + server.uri() + ":" + ts_str;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Compute HMAC-SHA256
|
|
|
|
|
|
|
|
uint8_t hmac_out[32];
|
|
|
|
|
|
|
|
mbedtls_md_context_t ctx;
|
|
|
|
|
|
|
|
mbedtls_md_init(&ctx);
|
|
|
|
|
|
|
|
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
|
|
|
|
|
|
|
|
mbedtls_md_hmac_starts(&ctx,
|
|
|
|
|
|
|
|
(const unsigned char *)API_SECRET, strlen(API_SECRET));
|
|
|
|
|
|
|
|
mbedtls_md_hmac_update(&ctx,
|
|
|
|
|
|
|
|
(const unsigned char *)msg.c_str(), msg.length());
|
|
|
|
|
|
|
|
mbedtls_md_hmac_finish(&ctx, hmac_out);
|
|
|
|
|
|
|
|
mbedtls_md_free(&ctx);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Hex-encode result
|
|
|
|
|
|
|
|
char expected[65];
|
|
|
|
|
|
|
|
for (int i = 0; i < 32; i++)
|
|
|
|
|
|
|
|
sprintf(expected + i * 2, "%02x", hmac_out[i]);
|
|
|
|
|
|
|
|
expected[64] = '\0';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Constant-time comparison
|
|
|
|
|
|
|
|
if (sig_str.length() != 64) {
|
|
|
|
|
|
|
|
server.send(401, "application/json", "{\"error\":\"invalid signature\"}");
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
uint8_t diff = 0;
|
|
|
|
|
|
|
|
for (int i = 0; i < 64; i++)
|
|
|
|
|
|
|
|
diff |= (uint8_t)sig_str[i] ^ (uint8_t)expected[i];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (diff != 0) {
|
|
|
|
|
|
|
|
server.send(401, "application/json", "{\"error\":\"invalid signature\"}");
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Status request monitoring
|
|
|
|
// Status request monitoring
|
|
|
|
static unsigned long last_status_log = 0;
|
|
|
|
static unsigned long last_status_log = 0;
|
|
|
|
static int status_request_count = 0;
|
|
|
|
static int status_request_count = 0;
|
|
|
|
|
|
|
|
|
|
|
|
void handleStatus() {
|
|
|
|
void handleStatus() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
status_request_count++;
|
|
|
|
status_request_count++;
|
|
|
|
|
|
|
|
|
|
|
|
// Read fresh input states
|
|
|
|
// Read fresh input states
|
|
|
@@ -679,6 +796,7 @@ void handleStatus() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleRelayOn() {
|
|
|
|
void handleRelayOn() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
if (!server.hasArg("relay")) {
|
|
|
|
if (!server.hasArg("relay")) {
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
|
|
return;
|
|
|
|
return;
|
|
|
@@ -705,6 +823,7 @@ void handleRelayOn() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleRelayOff() {
|
|
|
|
void handleRelayOff() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
if (!server.hasArg("relay")) {
|
|
|
|
if (!server.hasArg("relay")) {
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
|
|
return;
|
|
|
|
return;
|
|
|
@@ -731,6 +850,7 @@ void handleRelayOff() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleRelayStatus() {
|
|
|
|
void handleRelayStatus() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
if (!server.hasArg("relay")) {
|
|
|
|
if (!server.hasArg("relay")) {
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
|
|
return;
|
|
|
|
return;
|
|
|
@@ -754,6 +874,7 @@ void handleRelayStatus() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleInputStatus() {
|
|
|
|
void handleInputStatus() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
if (!server.hasArg("input")) {
|
|
|
|
if (!server.hasArg("input")) {
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}");
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}");
|
|
|
|
return;
|
|
|
|
return;
|
|
|
@@ -779,6 +900,7 @@ void handleInputStatus() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleLEDOn() {
|
|
|
|
void handleLEDOn() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
digitalWrite(LED_PIN, LOW); // LED is active-low
|
|
|
|
digitalWrite(LED_PIN, LOW); // LED is active-low
|
|
|
|
led_state = true;
|
|
|
|
led_state = true;
|
|
|
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
|
|
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
|
|
@@ -786,6 +908,7 @@ void handleLEDOn() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleLEDOff() {
|
|
|
|
void handleLEDOff() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
digitalWrite(LED_PIN, HIGH); // LED is active-low
|
|
|
|
digitalWrite(LED_PIN, HIGH); // LED is active-low
|
|
|
|
led_state = false;
|
|
|
|
led_state = false;
|
|
|
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
|
|
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
|
|
@@ -804,6 +927,9 @@ void handleNotFound() {
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
void handleRegister() {
|
|
|
|
void handleRegister() {
|
|
|
|
|
|
|
|
// NOTE: /register is the bootstrap handshake — it intentionally has no HMAC
|
|
|
|
|
|
|
|
// guard because the api_secret cannot be exchanged until after registration.
|
|
|
|
|
|
|
|
// All other API endpoints require verifyAPIRequest().
|
|
|
|
if (!server.hasArg("callback_url")) {
|
|
|
|
if (!server.hasArg("callback_url")) {
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}");
|
|
|
|
server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}");
|
|
|
|
return;
|
|
|
|
return;
|
|
|
@@ -916,6 +1042,7 @@ void postInputEvent(int input_num, bool state) {
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
void handleNFCStatus() {
|
|
|
|
void handleNFCStatus() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
bool present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
|
|
|
|
bool present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
|
|
|
|
String json = "{";
|
|
|
|
String json = "{";
|
|
|
|
json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ",";
|
|
|
|
json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ",";
|
|
|
@@ -962,6 +1089,7 @@ int nfcRelayPin(int rnum) {
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
void handleNFCConfigGet() {
|
|
|
|
void handleNFCConfigGet() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
String json = "{";
|
|
|
|
String json = "{";
|
|
|
|
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
|
|
|
|
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
|
|
|
|
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
|
|
|
|
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
|
|
|
@@ -971,6 +1099,7 @@ void handleNFCConfigGet() {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void handleNFCConfigSet() {
|
|
|
|
void handleNFCConfigSet() {
|
|
|
|
|
|
|
|
if (!verifyAPIRequest()) return;
|
|
|
|
if (server.hasArg("auth_uid")) {
|
|
|
|
if (server.hasArg("auth_uid")) {
|
|
|
|
String u = server.arg("auth_uid");
|
|
|
|
String u = server.arg("auth_uid");
|
|
|
|
u.trim();
|
|
|
|
u.trim();
|
|
|
@@ -1016,6 +1145,8 @@ void handleNFCConfigSet() {
|
|
|
|
// ============================================
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
|
|
void handleDebug() {
|
|
|
|
void handleDebug() {
|
|
|
|
|
|
|
|
if (!requireAuth()) return;
|
|
|
|
|
|
|
|
|
|
|
|
String out = "=== ESP32-C6 Debug ===\n";
|
|
|
|
String out = "=== ESP32-C6 Debug ===\n";
|
|
|
|
out += "Uptime: " + String(millis() / 1000) + " s\n";
|
|
|
|
out += "Uptime: " + String(millis() / 1000) + " s\n";
|
|
|
|
out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n";
|
|
|
|
out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n";
|
|
|
|