From 38aa9ec3801f5895e09866fe92cb8e44fb987cee Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 16 May 2026 11:56:43 +0530 Subject: Unique SSID/IP per board + captive detection fix + mint list in portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive unique SSID (TollGate-{MAC4}{MAC5}) and AP IP (10.{b5}.{subnet}.1) from factory MAC — boards no longer conflict - Board A: TollGate-377C @ 10.55.85.1, Board B: TollGate-5050 @ 10.80.10.1 - Captive portal detection URIs return 200 with portal HTML (matching esp32-mesh working approach) instead of 302 redirect - Dynamic AP IP in portal HTML via __AP_IP__ template substitution - Supported mints section in portal page (shows mint URL, tap to copy) - Fixed mint URL to testnut.cashu.space (was stale in SPIFFS) - DoT reject server on port 853 for DNS-over-TLS fallback - DNS hijack: NXDOMAIN for all non-A queries, no forwarding for unauthed - Playwright tests updated for 200 response on detection URIs - Phase 2 test suite: 20/21 pass (test 22 expiry ping route issue) - Tests 25-27 deferred to Phase 3 (Board B as second client) --- CHECKLIST.md | 5 +- PLAN.md | 8 ++-- main/captive_portal.c | 104 +++++++++++++++++++++++++++++------------- main/captive_portal.h | 2 +- main/config.c | 26 +++++++++++ main/config.h | 9 ++++ main/tollgate_main.c | 22 ++++++--- tests/captive-portal.spec.mjs | 14 +++--- tests/phase2.mjs | 3 -- 9 files changed, 136 insertions(+), 57 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index 2ac593c..d5711b4 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -63,9 +63,8 @@ - [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING - [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING -### Tests Not Yet Run (need Playwright) -- [ ] Test 24: Portal payment form visible in browser (Playwright) -- [ ] Test 25: Two clients pay independently +### Tests Not Yet Run (deferred to Phase 3 — will use Board B as second client) +- [ ] Test 25: Two clients pay independently (laptop + Board B) - [ ] Test 26: Client isolation (only payer gets internet) - [ ] Test 27: Full e2e: portal → pay → browse diff --git a/PLAN.md b/PLAN.md index 2406158..d43344b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -74,10 +74,10 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco | 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | PASS | | 22 | Session expiry | Wait for allotment | Internet blocked | PASS | | 23 | Session renewal | Second payment | Allotment extended | PASS | -| 24 | Portal payment form | Playwright paste token | Checkmark shown | TODO | -| 25 | Two clients pay independently | Two POSTs | Both authenticated | TODO | -| 26 | Client isolation | Only payer gets internet | Non-payer blocked | TODO | -| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | TODO | +| 24 | Portal payment form | Playwright paste token | Checkmark shown | PASS | +| 25 | Two clients pay independently | Two POSTs | Both authenticated | Phase 3 | +| 26 | Client isolation | Only payer gets internet | Non-payer blocked | Phase 3 | +| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | Phase 3 | **Captive Portal Fix:** Added DoT reject server on port 853 (TCP RST forces DNS-over-TLS fallback to plain DNS), DNS hijack returns NXDOMAIN for all non-A query types, explicit 302 redirect handlers for all captive detection URIs. Needs verification with actual GrapheneOS phone. diff --git a/main/captive_portal.c b/main/captive_portal.c index 0ae46ab..1f7340e 100644 --- a/main/captive_portal.c +++ b/main/captive_portal.c @@ -11,8 +11,9 @@ static const char *TAG = "captive_portal"; static httpd_handle_t s_server = NULL; +static char s_ap_ip_str[16] = "10.0.0.1"; -static const char PORTAL_HTML[] = \ +static const char PORTAL_HTML_TEMPLATE[] = \ "" "" "" @@ -27,9 +28,15 @@ static const char PORTAL_HTML[] = \ "max-width:400px;width:100%;text-align:center}" "h1{font-size:28px;margin-bottom:8px;color:#f7931a}" ".subtitle{color:#888;margin-bottom:24px;font-size:14px}" -".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:24px}" +".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:16px}" ".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" ".price-unit{color:#888;font-size:14px}" +".mints{background:#252525;border-radius:12px;padding:12px;margin-bottom:16px;text-align:left}" +".mints-title{color:#888;font-size:12px;margin-bottom:8px}" +".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all;" +"background:#1a1a1a;padding:8px;border-radius:6px;position:relative;cursor:pointer}" +".mint-url:active{opacity:0.7}" +".mint-hint{color:#666;font-size:10px;margin-top:4px}" "#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" "#status.success{display:block;background:#1a472a;color:#4caf50}" "#status.error{display:block;background:#471a1a;color:#f44336}" @@ -49,6 +56,11 @@ static const char PORTAL_HTML[] = \ "
Loading...
" "
sats per minute
" "" +"
" +"
SUPPORTED MINTS
" +"
Loading...
" +"
Tap to copy • Mint tokens at this URL before paying
" +"
" "" "" "
" @@ -58,16 +70,25 @@ static const char PORTAL_HTML[] = \ "const statusEl=document.getElementById('status');" "const payBtn=document.getElementById('payBtn');" "const tokenInput=document.getElementById('tokenInput');" -"fetch('http://192.168.4.1:2121/').then(r=>r.json()).then(d=>{" -"if(d.tags){const p=d.tags.find(t=>t[0]==='price_per_step');if(p)priceEl.textContent=p[2]||'21';}" -"}).catch(()=>{priceEl.textContent='21';});" +"const mintUrlEl=document.getElementById('mintUrl');" +"fetch('http://__AP_IP__:2121/').then(r=>r.json()).then(d=>{" +"if(d.tags){" +"const p=d.tags.find(t=>t[0]==='price_per_step');if(p){priceEl.textContent=p[2]||'21';" +"if(p[4]){mintUrlEl.textContent=p[4];}}" +"}" +"}).catch(()=>{priceEl.textContent='21';mintUrlEl.textContent='Error loading mint URL';});" +"function copyMint(){" +"const url=mintUrlEl.textContent;" +"if(navigator.clipboard){navigator.clipboard.writeText(url);" +"mintUrlEl.textContent='Copied!';setTimeout(()=>{mintUrlEl.textContent=url;},1000);}" +"}" "function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" "function payToken(){" "const token=tokenInput.value.trim();" "if(!token||!token.startsWith('cashuA')){showStatus('Please paste a valid Cashu token','error');return;}" "payBtn.disabled=true;" "showStatus('Processing payment...','processing');" -"fetch('http://192.168.4.1:2121/',{method:'POST',body:token}).then(r=>{" +"fetch('http://__AP_IP__:2121/',{method:'POST',body:token}).then(r=>{" "if(r.ok)return r.json();" "return r.json().then(d=>{throw new Error(d.content||'Payment failed');});" "}).then(d=>{" @@ -91,28 +112,47 @@ static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out) return ESP_FAIL; } -static bool is_captive_detection_uri(const char *uri) -{ - return strcmp(uri, "/generate_204") == 0 || - strcmp(uri, "/hotspot-detect.html") == 0 || - strcmp(uri, "/canonical.html") == 0 || - strcmp(uri, "/success.txt") == 0 || - strcmp(uri, "/ncsi.txt") == 0 || - strcmp(uri, "/connecttest.txt") == 0 || - strcmp(uri, "/wpad.dat") == 0 || - strcmp(uri, "/redirect") == 0 || - strcmp(uri, "/kindle-wifi/wifistub.html") == 0 || - strcmp(uri, "/fwlink") == 0 || - strcmp(uri, "/connectivity-check.html") == 0 || - strcmp(uri, "/generate_204/") == 0 || - strcmp(uri, "/hotspot-detect.html/") == 0; -} +static esp_err_t portal_handler(httpd_req_t *req); static esp_err_t portal_handler(httpd_req_t *req) { ESP_LOGI(TAG, "GET %s from client", req->uri); httpd_resp_set_type(req, "text/html"); - httpd_resp_send(req, PORTAL_HTML, strlen(PORTAL_HTML)); + + char *html = NULL; + const char *tpl = PORTAL_HTML_TEMPLATE; + size_t tpl_len = strlen(tpl); + int count = 0; + const char *p = tpl; + while ((p = strstr(p, "__AP_IP__")) != NULL) { count++; p += 9; } + + size_t ip_len = strlen(s_ap_ip_str); + html = malloc(tpl_len + count * (ip_len > 9 ? ip_len - 9 : 0) + 1); + if (!html) { + httpd_resp_send_500(req); + return ESP_OK; + } + + char *out = html; + const char *src = tpl; + while (*src) { + const char *found = strstr(src, "__AP_IP__"); + if (found) { + memcpy(out, src, found - src); + out += found - src; + memcpy(out, s_ap_ip_str, ip_len); + out += ip_len; + src = found + 9; + } else { + strcpy(out, src); + out += strlen(src); + break; + } + } + *out = '\0'; + + httpd_resp_send(req, html, out - html); + free(html); return ESP_OK; } @@ -187,19 +227,18 @@ static esp_err_t reset_auth_handler(httpd_req_t *req) static esp_err_t redirect_to_portal_handler(httpd_req_t *req) { - ESP_LOGI(TAG, "Captive detect: GET %s → 302 → http://192.168.4.1/", req->uri); - httpd_resp_set_status(req, "302 Found"); - httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); - httpd_resp_set_hdr(req, "Connection", "close"); - httpd_resp_send(req, NULL, 0); - return ESP_OK; + ESP_LOGI(TAG, "Captive detect: GET %s → 200 portal HTML", req->uri); + return portal_handler(req); } static esp_err_t catchall_handler(httpd_req_t *req) { - ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://192.168.4.1/", req->uri); + ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str); httpd_resp_set_status(req, "302 Found"); - httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); + + char location[64]; + snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); + httpd_resp_set_hdr(req, "Location", location); httpd_resp_set_hdr(req, "Connection", "close"); httpd_resp_send(req, NULL, 0); return ESP_OK; @@ -220,9 +259,10 @@ static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; -esp_err_t captive_portal_start(void) +esp_err_t captive_portal_start(const char *ap_ip_str) { if (s_server) return ESP_OK; + strncpy(s_ap_ip_str, ap_ip_str, sizeof(s_ap_ip_str) - 1); httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.max_uri_handlers = 20; diff --git a/main/captive_portal.h b/main/captive_portal.h index 30d8c3e..06eb860 100644 --- a/main/captive_portal.h +++ b/main/captive_portal.h @@ -4,7 +4,7 @@ #include "esp_http_server.h" #include "esp_err.h" -esp_err_t captive_portal_start(void); +esp_err_t captive_portal_start(const char *ap_ip_str); void captive_portal_stop(void); httpd_handle_t captive_portal_get_server(void); diff --git a/main/config.c b/main/config.c index b44c3c5..d7837bc 100644 --- a/main/config.c +++ b/main/config.c @@ -1,8 +1,12 @@ #include "config.h" #include "esp_log.h" #include "esp_spiffs.h" +#include "esp_system.h" +#include "esp_mac.h" +#include "lwip/ip4_addr.h" #include "cJSON.h" #include +#include static const char *TAG = "tollgate_config"; static tollgate_config_t g_config; @@ -140,3 +144,25 @@ esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config) g_config.current_network = (g_config.current_network + 1) % g_config.network_count; return tollgate_config_get_wifi(wifi_config); } + +void tollgate_config_derive_unique(tollgate_config_t *cfg) +{ + if (cfg->unique_derived) return; + + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + + snprintf(cfg->ap_ssid + strlen(cfg->ap_ssid), + TOLLGATE_MAX_AP_SSID_LEN - strlen(cfg->ap_ssid), + "-%02X%02X", mac[4], mac[5]); + + uint8_t b5 = mac[4]; + uint8_t b6 = mac[5]; + uint8_t subnet = (b5 ^ b6) % 200 + 10; + IP4_ADDR(&cfg->ap_ip, 10, b5, subnet, 1); + snprintf(cfg->ap_ip_str, sizeof(cfg->ap_ip_str), IPSTR, IP2STR(&cfg->ap_ip)); + + cfg->unique_derived = true; + + ESP_LOGI(TAG, "Unique config: SSID='%s', AP_IP=%s", cfg->ap_ssid, cfg->ap_ip_str); +} diff --git a/main/config.h b/main/config.h index d26b7ae..dd3fe05 100644 --- a/main/config.h +++ b/main/config.h @@ -3,6 +3,8 @@ #include "esp_err.h" #include "esp_wifi.h" +#include "esp_netif.h" +#include #define TOLLGATE_MAX_WIFI_NETWORKS 5 #define TOLLGATE_MAX_MINT_URLS 3 @@ -25,12 +27,19 @@ typedef struct { uint8_t ap_channel; uint8_t ap_max_conn; + esp_ip4_addr_t ap_ip; + char ap_ip_str[16]; + char mint_url[256]; char lnurl_url[256]; int price_per_step; int step_size_ms; + + bool unique_derived; } tollgate_config_t; +void tollgate_config_derive_unique(tollgate_config_t *cfg); + esp_err_t tollgate_config_init(void); const tollgate_config_t *tollgate_config_get(void); esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config); diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 04f64b9..30fad8d 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c @@ -18,9 +18,6 @@ #include "tollgate_api.h" #define MAX_STA_RETRY 5 -#define AP_IP_ADDR "192.168.4.1" -#define AP_SUBNET "255.255.255.0" - static const char *TAG = "tollgate_main"; static EventGroupHandle_t s_wifi_event_group; @@ -31,6 +28,7 @@ static esp_netif_t *s_ap_netif = NULL; static int s_retry_count = 0; static bool s_services_running = false; static SemaphoreHandle_t s_services_mutex = NULL; +static char s_ap_ip_str[16] = "10.0.0.1"; static void start_services(void); static void stop_services(void); @@ -109,8 +107,9 @@ static void start_services(void) firewall_init(ap_ip_info.ip); session_manager_init(); + const tollgate_config_t *cfg = tollgate_config_get(); dns_server_start(ap_ip_info.ip, upstream_dns); - captive_portal_start(); + captive_portal_start(cfg->ap_ip_str); tollgate_api_start(); s_services_running = true; @@ -140,10 +139,18 @@ static void wifi_create_ap_netif(void) { s_ap_netif = esp_netif_create_default_wifi_ap(); + const tollgate_config_t *cfg = tollgate_config_get(); + esp_ip4_addr_t ap_ip = cfg->ap_ip; + esp_ip4_addr_t ap_gw = cfg->ap_ip; + esp_ip4_addr_t ap_mask; + IP4_ADDR(&ap_mask, 255, 255, 255, 0); + + strncpy(s_ap_ip_str, cfg->ap_ip_str, sizeof(s_ap_ip_str) - 1); + esp_netif_ip_info_t ip_info = { - .ip.addr = esp_ip4addr_aton(AP_IP_ADDR), - .gw.addr = esp_ip4addr_aton(AP_IP_ADDR), - .netmask.addr = esp_ip4addr_aton(AP_SUBNET), + .ip.addr = ap_ip.addr, + .gw.addr = ap_gw.addr, + .netmask.addr = ap_mask.addr, }; ESP_ERROR_CHECK(esp_netif_dhcps_stop(s_ap_netif)); ESP_ERROR_CHECK(esp_netif_set_ip_info(s_ap_netif, &ip_info)); @@ -190,6 +197,7 @@ void app_main(void) ESP_ERROR_CHECK(ret); ESP_ERROR_CHECK(tollgate_config_init()); + tollgate_config_derive_unique((tollgate_config_t *)tollgate_config_get()); ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); diff --git a/tests/captive-portal.spec.mjs b/tests/captive-portal.spec.mjs index acd2a40..bc7a1fa 100644 --- a/tests/captive-portal.spec.mjs +++ b/tests/captive-portal.spec.mjs @@ -32,18 +32,18 @@ test.describe('Captive Portal - Phase 2', () => { await expect(btn).toHaveText(/Pay/); }); - test('captive detection URIs return 302 redirect', async ({ request }) => { + test('captive detection URIs return portal HTML (200)', async ({ request }) => { const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']; for (const uri of uris) { - const resp = await request.fetch(`${PORTAL_URL}${uri}`, { maxRedirects: 0, ignoreHTTPSErrors: true }); - expect(resp.status()).toBe(302); - const location = resp.headers()['location']; - expect(location).toBe('http://192.168.4.1/'); + const resp = await request.fetch(`${PORTAL_URL}${uri}`); + expect(resp.status()).toBe(200); + const body = await resp.text(); + expect(body).toContain('TollGate'); } }); - test('captive detection redirects to portal page', async ({ page }) => { - await page.goto(`${PORTAL_URL}/generate_204`); + test('catch-all URIs redirect to portal page', async ({ page }) => { + await page.goto(`${PORTAL_URL}/some-random-page`); await expect(page.locator('h1')).toHaveText('TollGate'); }); diff --git a/tests/phase2.mjs b/tests/phase2.mjs index 5ee08f7..b3d347b 100644 --- a/tests/phase2.mjs +++ b/tests/phase2.mjs @@ -84,9 +84,6 @@ if (TEST_TOKEN) { } catch { pingOk = false; } - try { - execSync(`echo '${sudoPw}' | sudo -S ip route del default via 192.168.4.1 dev wlp59s0 metric 50 2>/dev/null`, { encoding: 'utf8', timeout: 5000 }); - } catch {} assert(pingOk, 'Internet works'); // Test 20: Spent token -- cgit v1.2.3