EIS-BLE-S3/main/captive_portal.c

428 lines
14 KiB
C

#include "captive_portal.h"
#include "wifi_cfg.h"
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_http_server.h"
#include "esp_wifi.h"
#include "esp_system.h"
#include "lwip/sockets.h"
#define DNS_PORT 53
#define HTTP_PORT 80
#define MAX_SCAN_AP 20
static httpd_handle_t s_httpd;
static TaskHandle_t s_dns_task;
static int s_dns_sock = -1;
static bool s_active;
static const uint8_t AP_IP[4] = {192, 168, 4, 1};
/* ------------------------------------------------------------------ */
/* Config page HTML */
/* ------------------------------------------------------------------ */
static const char CONFIG_PAGE[] =
"<!DOCTYPE html>"
"<html><head>"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
"<title>EIS4 Setup</title>"
"<style>"
"*{box-sizing:border-box;margin:0;padding:0}"
"body{font-family:system-ui,-apple-system,sans-serif;background:#111;"
"color:#e0e0e0;padding:20px;max-width:400px;margin:0 auto}"
"h1{font-size:1.4em;margin-bottom:20px;color:#fff}"
"label{display:block;margin:12px 0 4px;font-size:.9em;color:#aaa}"
"input{width:100%;padding:10px;border:1px solid #333;border-radius:6px;"
"background:#1a1a1a;color:#fff;font-size:1em}"
"input:focus{outline:none;border-color:#4a9eff}"
"button{width:100%;padding:12px;border:none;border-radius:6px;"
"background:#4a9eff;color:#fff;font-size:1em;cursor:pointer;margin-top:12px}"
"button:active{background:#3580d4}"
".btn-s{background:#333;margin-top:8px;padding:8px;font-size:.9em}"
".nets{list-style:none;max-height:200px;overflow-y:auto;"
"border:1px solid #333;border-radius:6px;margin-top:4px}"
".nets li{padding:10px;cursor:pointer;border-bottom:1px solid #222;"
"display:flex;justify-content:space-between}"
".nets li:hover,.nets li.sel{background:#1a2a3a}"
".rs{color:#666;font-size:.85em}"
".hint{font-size:.8em;color:#666;margin-top:4px}"
"#st{margin-top:16px;padding:10px;border-radius:6px;display:none}"
".ok{background:#1a3a1a;color:#4caf50;display:block!important}"
".err{background:#3a1a1a;color:#f44;display:block!important}"
"</style></head><body>"
"<h1>EIS4 Setup</h1>"
"<form id=\"f\" method=\"POST\" action=\"/save\">"
"<label>WiFi Network</label>"
"<ul class=\"nets\" id=\"nl\"><li>Scanning...</li></ul>"
"<button type=\"button\" class=\"btn-s\" onclick=\"scan()\">Rescan</button>"
"<label>Network Name</label>"
"<input type=\"text\" name=\"ssid\" id=\"ssid\" "
"placeholder=\"Select above or type for hidden network\">"
"<label>WiFi Password</label>"
"<input type=\"password\" name=\"pass\" id=\"pass\">"
"<label>EIS4 Network Password</label>"
"<input type=\"text\" name=\"ap_pass\" id=\"ap_pass\" "
"placeholder=\"Leave blank for open\">"
"<p class=\"hint\">Minimum 8 characters, or leave blank for open network</p>"
"<button type=\"submit\">Save &amp; Restart</button>"
"</form>"
"<div id=\"st\"></div>"
"<script>"
"function scan(){"
"var ul=document.getElementById('nl');"
"ul.innerHTML='<li>Scanning...</li>';"
"fetch('/scan').then(function(r){return r.json()}).then(function(d){"
"ul.innerHTML='';"
"d.forEach(function(n){"
"var li=document.createElement('li');"
"var nm=document.createElement('span');nm.textContent=n.s;"
"var rs=document.createElement('span');rs.className='rs';"
"rs.textContent=n.r+'dBm';"
"li.appendChild(nm);li.appendChild(rs);"
"li.onclick=function(){"
"document.querySelectorAll('.nets li').forEach(function(l){"
"l.classList.remove('sel')});"
"li.classList.add('sel');"
"document.getElementById('ssid').value=n.s;};"
"ul.appendChild(li);});"
"if(!d.length)ul.innerHTML='<li>No networks found</li>';"
"}).catch(function(){ul.innerHTML='<li>Scan failed</li>';});}"
"scan();"
"document.getElementById('f').onsubmit=function(e){"
"e.preventDefault();"
"var s=document.getElementById('st');"
"var ssid=document.getElementById('ssid').value;"
"if(!ssid){s.className='err';s.textContent='Enter a network name';return;}"
"var ap=document.getElementById('ap_pass').value;"
"if(ap&&ap.length<8){"
"s.className='err';s.textContent='AP password must be at least 8 characters';"
"return;}"
"var b='ssid='+encodeURIComponent(ssid)"
"+'&pass='+encodeURIComponent(document.getElementById('pass').value)"
"+'&ap_pass='+encodeURIComponent(ap);"
"s.className='';s.style.display='none';"
"fetch('/save',{method:'POST',"
"headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b})"
".then(function(r){"
"if(r.ok){s.className='ok';s.textContent='Saved. Restarting device...';}"
"else{s.className='err';s.textContent='Invalid configuration';}})"
".catch(function(){s.className='ok';"
"s.textContent='Device is restarting...';});};"
"</script></body></html>";
/* ------------------------------------------------------------------ */
/* URL parameter helpers */
/* ------------------------------------------------------------------ */
static void url_decode(char *dst, const char *src, size_t dst_sz)
{
size_t di = 0;
while (*src && di < dst_sz - 1) {
if (*src == '%' && src[1] && src[2]) {
char hex[3] = {src[1], src[2], 0};
dst[di++] = (char)strtol(hex, NULL, 16);
src += 3;
} else if (*src == '+') {
dst[di++] = ' ';
src++;
} else {
dst[di++] = *src++;
}
}
dst[di] = '\0';
}
static void get_param(const char *body, const char *key,
char *val, size_t val_sz)
{
val[0] = '\0';
size_t klen = strlen(key);
const char *p = body;
while ((p = strstr(p, key)) != NULL) {
if ((p == body || *(p - 1) == '&') && p[klen] == '=') {
p += klen + 1;
const char *end = strchr(p, '&');
size_t len = end ? (size_t)(end - p) : strlen(p);
char raw[128];
if (len >= sizeof(raw)) len = sizeof(raw) - 1;
memcpy(raw, p, len);
raw[len] = '\0';
url_decode(val, raw, val_sz);
return;
}
p++;
}
}
/* ------------------------------------------------------------------ */
/* JSON escape for SSIDs */
/* ------------------------------------------------------------------ */
static int json_escape(char *dst, size_t dst_sz, const char *src)
{
size_t di = 0;
while (*src && di < dst_sz - 2) {
if (*src == '"' || *src == '\\') {
if (di + 2 >= dst_sz) break;
dst[di++] = '\\';
}
dst[di++] = *src++;
}
dst[di] = '\0';
return (int)di;
}
/* ------------------------------------------------------------------ */
/* HTTP handlers */
/* ------------------------------------------------------------------ */
static esp_err_t handle_root(httpd_req_t *req)
{
httpd_resp_set_type(req, "text/html");
httpd_resp_send(req, CONFIG_PAGE, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t handle_scan(httpd_req_t *req)
{
wifi_scan_config_t scan_cfg = {0};
esp_wifi_scan_start(&scan_cfg, true);
uint16_t count = 0;
esp_wifi_scan_get_ap_num(&count);
if (count > MAX_SCAN_AP) count = MAX_SCAN_AP;
wifi_ap_record_t *aps = calloc(count ? count : 1, sizeof(wifi_ap_record_t));
if (!aps) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, NULL);
return ESP_FAIL;
}
esp_wifi_scan_get_ap_records(&count, aps);
char json[2048];
int off = 0;
off += snprintf(json + off, sizeof(json) - off, "[");
for (int i = 0; i < count && off < (int)sizeof(json) - 80; i++) {
if (!aps[i].ssid[0]) continue;
/* skip duplicates (scan is sorted by rssi, first is strongest) */
bool dup = false;
for (int j = 0; j < i; j++) {
if (strcmp((char *)aps[i].ssid, (char *)aps[j].ssid) == 0) {
dup = true;
break;
}
}
if (dup) continue;
char escaped[66];
json_escape(escaped, sizeof(escaped), (char *)aps[i].ssid);
if (off > 1) off += snprintf(json + off, sizeof(json) - off, ",");
off += snprintf(json + off, sizeof(json) - off,
"{\"s\":\"%s\",\"r\":%d}", escaped, aps[i].rssi);
}
off += snprintf(json + off, sizeof(json) - off, "]");
free(aps);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, json, off);
return ESP_OK;
}
static esp_err_t handle_save(httpd_req_t *req)
{
char buf[256];
int n = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (n <= 0) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, NULL);
return ESP_FAIL;
}
buf[n] = '\0';
char ssid[WIFI_CFG_SSID_MAX] = {0};
char pass[WIFI_CFG_PASS_MAX] = {0};
char ap_pass[WIFI_CFG_PASS_MAX] = {0};
get_param(buf, "ssid", ssid, sizeof(ssid));
get_param(buf, "pass", pass, sizeof(pass));
get_param(buf, "ap_pass", ap_pass, sizeof(ap_pass));
if (!ssid[0]) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing SSID");
return ESP_FAIL;
}
if (ap_pass[0] && strlen(ap_pass) < 8) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"AP password must be 8+ characters or empty");
return ESP_FAIL;
}
printf("Portal: saving STA \"%s\", AP pass %s\n",
ssid, ap_pass[0] ? "set" : "open");
wifi_cfg_set_sta(ssid, pass);
wifi_cfg_set_ap("EIS4", ap_pass);
httpd_resp_sendstr(req, "OK");
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
return ESP_OK;
}
static esp_err_t handle_catchall(httpd_req_t *req)
{
httpd_resp_set_status(req, "302 Found");
httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/");
httpd_resp_send(req, NULL, 0);
return ESP_OK;
}
/* ------------------------------------------------------------------ */
/* DNS redirect server */
/* ------------------------------------------------------------------ */
static void dns_task(void *arg)
{
(void)arg;
s_dns_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s_dns_sock < 0) {
printf("DNS: socket failed\n");
vTaskDelete(NULL);
return;
}
struct sockaddr_in bind_addr = {
.sin_family = AF_INET,
.sin_port = htons(DNS_PORT),
.sin_addr.s_addr = htonl(INADDR_ANY),
};
if (bind(s_dns_sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
printf("DNS: bind failed\n");
close(s_dns_sock);
s_dns_sock = -1;
vTaskDelete(NULL);
return;
}
printf("DNS: redirecting all queries to " "%d.%d.%d.%d\n",
AP_IP[0], AP_IP[1], AP_IP[2], AP_IP[3]);
uint8_t buf[256];
struct sockaddr_in src;
socklen_t slen;
for (;;) {
slen = sizeof(src);
int n = recvfrom(s_dns_sock, buf, sizeof(buf), 0,
(struct sockaddr *)&src, &slen);
if (n < 12) {
if (n < 0) break;
continue;
}
/* walk question section to find its end */
int qend = 12;
bool valid = true;
while (qend < n && buf[qend] != 0) {
uint8_t ll = buf[qend];
if (ll > 63 || qend + ll + 1 > n) { valid = false; break; }
qend += ll + 1;
}
if (!valid) continue;
qend += 5; /* null terminator + QTYPE(2) + QCLASS(2) */
if (qend > n) continue;
/* build response: header + question (copied) + one A answer */
uint8_t rsp[280];
if (qend + 16 > (int)sizeof(rsp)) continue;
memcpy(rsp, buf, qend);
rsp[2] = 0x81; rsp[3] = 0x80; /* flags: standard response */
rsp[6] = 0x00; rsp[7] = 0x01; /* 1 answer RR */
int off = qend;
rsp[off++] = 0xC0; rsp[off++] = 0x0C; /* name pointer */
rsp[off++] = 0x00; rsp[off++] = 0x01; /* type A */
rsp[off++] = 0x00; rsp[off++] = 0x01; /* class IN */
rsp[off++] = 0x00; rsp[off++] = 0x00;
rsp[off++] = 0x00; rsp[off++] = 0x01; /* TTL 1s */
rsp[off++] = 0x00; rsp[off++] = 0x04; /* rdlength */
rsp[off++] = AP_IP[0]; rsp[off++] = AP_IP[1];
rsp[off++] = AP_IP[2]; rsp[off++] = AP_IP[3];
sendto(s_dns_sock, rsp, off, 0, (struct sockaddr *)&src, slen);
}
vTaskDelete(NULL);
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
esp_err_t captive_portal_start(void)
{
if (s_active) return ESP_OK;
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = HTTP_PORT;
cfg.stack_size = 6144;
cfg.uri_match_fn = httpd_uri_match_wildcard;
cfg.max_uri_handlers = 8;
esp_err_t err = httpd_start(&s_httpd, &cfg);
if (err != ESP_OK) {
printf("Portal: HTTP start failed: %d\n", err);
return err;
}
const httpd_uri_t uri_scan = {"/scan", HTTP_GET, handle_scan, NULL};
const httpd_uri_t uri_save = {"/save", HTTP_POST, handle_save, NULL};
const httpd_uri_t uri_root = {"/", HTTP_GET, handle_root, NULL};
const httpd_uri_t uri_catch = {"/*", HTTP_GET, handle_catchall, NULL};
httpd_register_uri_handler(s_httpd, &uri_scan);
httpd_register_uri_handler(s_httpd, &uri_save);
httpd_register_uri_handler(s_httpd, &uri_root);
httpd_register_uri_handler(s_httpd, &uri_catch);
xTaskCreate(dns_task, "dns", 3072, NULL, 5, &s_dns_task);
s_active = true;
printf("Portal: active on port %d\n", HTTP_PORT);
return ESP_OK;
}
esp_err_t captive_portal_stop(void)
{
if (!s_active) return ESP_OK;
if (s_httpd) {
httpd_stop(s_httpd);
s_httpd = NULL;
}
if (s_dns_sock >= 0) {
close(s_dns_sock);
s_dns_sock = -1;
}
s_active = false;
printf("Portal: stopped\n");
return ESP_OK;
}
bool captive_portal_is_active(void)
{
return s_active;
}