428 lines
14 KiB
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 & 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;
|
|
}
|