upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-16 11:56:43 +0530
committerYour Name <you@example.com>2026-05-16 11:56:43 +0530
commit38aa9ec3801f5895e09866fe92cb8e44fb987cee (patch)
treec702c27cd59fa0e73bc3e8665e1582e6b9509cf6
parentee4e13680f522253f94e8ebdea5df80332afc495 (diff)
Unique SSID/IP per board + captive detection fix + mint list in portal
- 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)
-rw-r--r--CHECKLIST.md5
-rw-r--r--PLAN.md8
-rw-r--r--main/captive_portal.c104
-rw-r--r--main/captive_portal.h2
-rw-r--r--main/config.c26
-rw-r--r--main/config.h9
-rw-r--r--main/tollgate_main.c22
-rw-r--r--tests/captive-portal.spec.mjs14
-rw-r--r--tests/phase2.mjs3
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 @@
63- [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING 63- [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING
64- [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING 64- [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING
65 65
66### Tests Not Yet Run (need Playwright) 66### Tests Not Yet Run (deferred to Phase 3 — will use Board B as second client)
67- [ ] Test 24: Portal payment form visible in browser (Playwright) 67- [ ] Test 25: Two clients pay independently (laptop + Board B)
68- [ ] Test 25: Two clients pay independently
69- [ ] Test 26: Client isolation (only payer gets internet) 68- [ ] Test 26: Client isolation (only payer gets internet)
70- [ ] Test 27: Full e2e: portal → pay → browse 69- [ ] Test 27: Full e2e: portal → pay → browse
71 70
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
74| 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | PASS | 74| 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | PASS |
75| 22 | Session expiry | Wait for allotment | Internet blocked | PASS | 75| 22 | Session expiry | Wait for allotment | Internet blocked | PASS |
76| 23 | Session renewal | Second payment | Allotment extended | PASS | 76| 23 | Session renewal | Second payment | Allotment extended | PASS |
77| 24 | Portal payment form | Playwright paste token | Checkmark shown | TODO | 77| 24 | Portal payment form | Playwright paste token | Checkmark shown | PASS |
78| 25 | Two clients pay independently | Two POSTs | Both authenticated | TODO | 78| 25 | Two clients pay independently | Two POSTs | Both authenticated | Phase 3 |
79| 26 | Client isolation | Only payer gets internet | Non-payer blocked | TODO | 79| 26 | Client isolation | Only payer gets internet | Non-payer blocked | Phase 3 |
80| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | TODO | 80| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | Phase 3 |
81 81
82**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. 82**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.
83 83
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 @@
11 11
12static const char *TAG = "captive_portal"; 12static const char *TAG = "captive_portal";
13static httpd_handle_t s_server = NULL; 13static httpd_handle_t s_server = NULL;
14static char s_ap_ip_str[16] = "10.0.0.1";
14 15
15static const char PORTAL_HTML[] = \ 16static const char PORTAL_HTML_TEMPLATE[] = \
16"<!DOCTYPE html>" 17"<!DOCTYPE html>"
17"<html><head>" 18"<html><head>"
18"<meta charset='utf-8'>" 19"<meta charset='utf-8'>"
@@ -27,9 +28,15 @@ static const char PORTAL_HTML[] = \
27"max-width:400px;width:100%;text-align:center}" 28"max-width:400px;width:100%;text-align:center}"
28"h1{font-size:28px;margin-bottom:8px;color:#f7931a}" 29"h1{font-size:28px;margin-bottom:8px;color:#f7931a}"
29".subtitle{color:#888;margin-bottom:24px;font-size:14px}" 30".subtitle{color:#888;margin-bottom:24px;font-size:14px}"
30".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:24px}" 31".price{background:#252525;border-radius:12px;padding:16px;margin-bottom:16px}"
31".price-amount{font-size:36px;font-weight:bold;color:#f7931a}" 32".price-amount{font-size:36px;font-weight:bold;color:#f7931a}"
32".price-unit{color:#888;font-size:14px}" 33".price-unit{color:#888;font-size:14px}"
34".mints{background:#252525;border-radius:12px;padding:12px;margin-bottom:16px;text-align:left}"
35".mints-title{color:#888;font-size:12px;margin-bottom:8px}"
36".mint-url{font-family:monospace;font-size:11px;color:#f7931a;word-break:break-all;"
37"background:#1a1a1a;padding:8px;border-radius:6px;position:relative;cursor:pointer}"
38".mint-url:active{opacity:0.7}"
39".mint-hint{color:#666;font-size:10px;margin-top:4px}"
33"#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}" 40"#status{margin-top:16px;padding:12px;border-radius:8px;display:none;font-size:14px}"
34"#status.success{display:block;background:#1a472a;color:#4caf50}" 41"#status.success{display:block;background:#1a472a;color:#4caf50}"
35"#status.error{display:block;background:#471a1a;color:#f44336}" 42"#status.error{display:block;background:#471a1a;color:#f44336}"
@@ -49,6 +56,11 @@ static const char PORTAL_HTML[] = \
49"<div class='price-amount' id='price'>Loading...</div>" 56"<div class='price-amount' id='price'>Loading...</div>"
50"<div class='price-unit'>sats per minute</div>" 57"<div class='price-unit'>sats per minute</div>"
51"</div>" 58"</div>"
59"<div class='mints'>"
60"<div class='mints-title'>SUPPORTED MINTS</div>"
61"<div class='mint-url' id='mintUrl' onclick='copyMint()'>Loading...</div>"
62"<div class='mint-hint'>Tap to copy &bull; Mint tokens at this URL before paying</div>"
63"</div>"
52"<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>" 64"<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>"
53"<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>" 65"<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>"
54"<div id='status'></div>" 66"<div id='status'></div>"
@@ -58,16 +70,25 @@ static const char PORTAL_HTML[] = \
58"const statusEl=document.getElementById('status');" 70"const statusEl=document.getElementById('status');"
59"const payBtn=document.getElementById('payBtn');" 71"const payBtn=document.getElementById('payBtn');"
60"const tokenInput=document.getElementById('tokenInput');" 72"const tokenInput=document.getElementById('tokenInput');"
61"fetch('http://192.168.4.1:2121/').then(r=>r.json()).then(d=>{" 73"const mintUrlEl=document.getElementById('mintUrl');"
62"if(d.tags){const p=d.tags.find(t=>t[0]==='price_per_step');if(p)priceEl.textContent=p[2]||'21';}" 74"fetch('http://__AP_IP__:2121/').then(r=>r.json()).then(d=>{"
63"}).catch(()=>{priceEl.textContent='21';});" 75"if(d.tags){"
76"const p=d.tags.find(t=>t[0]==='price_per_step');if(p){priceEl.textContent=p[2]||'21';"
77"if(p[4]){mintUrlEl.textContent=p[4];}}"
78"}"
79"}).catch(()=>{priceEl.textContent='21';mintUrlEl.textContent='Error loading mint URL';});"
80"function copyMint(){"
81"const url=mintUrlEl.textContent;"
82"if(navigator.clipboard){navigator.clipboard.writeText(url);"
83"mintUrlEl.textContent='Copied!';setTimeout(()=>{mintUrlEl.textContent=url;},1000);}"
84"}"
64"function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" 85"function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}"
65"function payToken(){" 86"function payToken(){"
66"const token=tokenInput.value.trim();" 87"const token=tokenInput.value.trim();"
67"if(!token||!token.startsWith('cashuA')){showStatus('Please paste a valid Cashu token','error');return;}" 88"if(!token||!token.startsWith('cashuA')){showStatus('Please paste a valid Cashu token','error');return;}"
68"payBtn.disabled=true;" 89"payBtn.disabled=true;"
69"showStatus('Processing payment...','processing');" 90"showStatus('Processing payment...','processing');"
70"fetch('http://192.168.4.1:2121/',{method:'POST',body:token}).then(r=>{" 91"fetch('http://__AP_IP__:2121/',{method:'POST',body:token}).then(r=>{"
71"if(r.ok)return r.json();" 92"if(r.ok)return r.json();"
72"return r.json().then(d=>{throw new Error(d.content||'Payment failed');});" 93"return r.json().then(d=>{throw new Error(d.content||'Payment failed');});"
73"}).then(d=>{" 94"}).then(d=>{"
@@ -91,28 +112,47 @@ static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out)
91 return ESP_FAIL; 112 return ESP_FAIL;
92} 113}
93 114
94static bool is_captive_detection_uri(const char *uri) 115static esp_err_t portal_handler(httpd_req_t *req);
95{
96 return strcmp(uri, "/generate_204") == 0 ||
97 strcmp(uri, "/hotspot-detect.html") == 0 ||
98 strcmp(uri, "/canonical.html") == 0 ||
99 strcmp(uri, "/success.txt") == 0 ||
100 strcmp(uri, "/ncsi.txt") == 0 ||
101 strcmp(uri, "/connecttest.txt") == 0 ||
102 strcmp(uri, "/wpad.dat") == 0 ||
103 strcmp(uri, "/redirect") == 0 ||
104 strcmp(uri, "/kindle-wifi/wifistub.html") == 0 ||
105 strcmp(uri, "/fwlink") == 0 ||
106 strcmp(uri, "/connectivity-check.html") == 0 ||
107 strcmp(uri, "/generate_204/") == 0 ||
108 strcmp(uri, "/hotspot-detect.html/") == 0;
109}
110 116
111static esp_err_t portal_handler(httpd_req_t *req) 117static esp_err_t portal_handler(httpd_req_t *req)
112{ 118{
113 ESP_LOGI(TAG, "GET %s from client", req->uri); 119 ESP_LOGI(TAG, "GET %s from client", req->uri);
114 httpd_resp_set_type(req, "text/html"); 120 httpd_resp_set_type(req, "text/html");
115 httpd_resp_send(req, PORTAL_HTML, strlen(PORTAL_HTML)); 121
122 char *html = NULL;
123 const char *tpl = PORTAL_HTML_TEMPLATE;
124 size_t tpl_len = strlen(tpl);
125 int count = 0;
126 const char *p = tpl;
127 while ((p = strstr(p, "__AP_IP__")) != NULL) { count++; p += 9; }
128
129 size_t ip_len = strlen(s_ap_ip_str);
130 html = malloc(tpl_len + count * (ip_len > 9 ? ip_len - 9 : 0) + 1);
131 if (!html) {
132 httpd_resp_send_500(req);
133 return ESP_OK;
134 }
135
136 char *out = html;
137 const char *src = tpl;
138 while (*src) {
139 const char *found = strstr(src, "__AP_IP__");
140 if (found) {
141 memcpy(out, src, found - src);
142 out += found - src;
143 memcpy(out, s_ap_ip_str, ip_len);
144 out += ip_len;
145 src = found + 9;
146 } else {
147 strcpy(out, src);
148 out += strlen(src);
149 break;
150 }
151 }
152 *out = '\0';
153
154 httpd_resp_send(req, html, out - html);
155 free(html);
116 return ESP_OK; 156 return ESP_OK;
117} 157}
118 158
@@ -187,19 +227,18 @@ static esp_err_t reset_auth_handler(httpd_req_t *req)
187 227
188static esp_err_t redirect_to_portal_handler(httpd_req_t *req) 228static esp_err_t redirect_to_portal_handler(httpd_req_t *req)
189{ 229{
190 ESP_LOGI(TAG, "Captive detect: GET %s → 302 → http://192.168.4.1/", req->uri); 230 ESP_LOGI(TAG, "Captive detect: GET %s → 200 portal HTML", req->uri);
191 httpd_resp_set_status(req, "302 Found"); 231 return portal_handler(req);
192 httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/");
193 httpd_resp_set_hdr(req, "Connection", "close");
194 httpd_resp_send(req, NULL, 0);
195 return ESP_OK;
196} 232}
197 233
198static esp_err_t catchall_handler(httpd_req_t *req) 234static esp_err_t catchall_handler(httpd_req_t *req)
199{ 235{
200 ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://192.168.4.1/", req->uri); 236 ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str);
201 httpd_resp_set_status(req, "302 Found"); 237 httpd_resp_set_status(req, "302 Found");
202 httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); 238
239 char location[64];
240 snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str);
241 httpd_resp_set_hdr(req, "Location", location);
203 httpd_resp_set_hdr(req, "Connection", "close"); 242 httpd_resp_set_hdr(req, "Connection", "close");
204 httpd_resp_send(req, NULL, 0); 243 httpd_resp_send(req, NULL, 0);
205 return ESP_OK; 244 return ESP_OK;
@@ -220,9 +259,10 @@ static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method
220static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 259static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler };
221static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; 260static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler };
222 261
223esp_err_t captive_portal_start(void) 262esp_err_t captive_portal_start(const char *ap_ip_str)
224{ 263{
225 if (s_server) return ESP_OK; 264 if (s_server) return ESP_OK;
265 strncpy(s_ap_ip_str, ap_ip_str, sizeof(s_ap_ip_str) - 1);
226 266
227 httpd_config_t config = HTTPD_DEFAULT_CONFIG(); 267 httpd_config_t config = HTTPD_DEFAULT_CONFIG();
228 config.max_uri_handlers = 20; 268 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 @@
4#include "esp_http_server.h" 4#include "esp_http_server.h"
5#include "esp_err.h" 5#include "esp_err.h"
6 6
7esp_err_t captive_portal_start(void); 7esp_err_t captive_portal_start(const char *ap_ip_str);
8void captive_portal_stop(void); 8void captive_portal_stop(void);
9httpd_handle_t captive_portal_get_server(void); 9httpd_handle_t captive_portal_get_server(void);
10 10
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 @@
1#include "config.h" 1#include "config.h"
2#include "esp_log.h" 2#include "esp_log.h"
3#include "esp_spiffs.h" 3#include "esp_spiffs.h"
4#include "esp_system.h"
5#include "esp_mac.h"
6#include "lwip/ip4_addr.h"
4#include "cJSON.h" 7#include "cJSON.h"
5#include <string.h> 8#include <string.h>
9#include <stdio.h>
6 10
7static const char *TAG = "tollgate_config"; 11static const char *TAG = "tollgate_config";
8static tollgate_config_t g_config; 12static tollgate_config_t g_config;
@@ -140,3 +144,25 @@ esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config)
140 g_config.current_network = (g_config.current_network + 1) % g_config.network_count; 144 g_config.current_network = (g_config.current_network + 1) % g_config.network_count;
141 return tollgate_config_get_wifi(wifi_config); 145 return tollgate_config_get_wifi(wifi_config);
142} 146}
147
148void tollgate_config_derive_unique(tollgate_config_t *cfg)
149{
150 if (cfg->unique_derived) return;
151
152 uint8_t mac[6];
153 esp_read_mac(mac, ESP_MAC_WIFI_STA);
154
155 snprintf(cfg->ap_ssid + strlen(cfg->ap_ssid),
156 TOLLGATE_MAX_AP_SSID_LEN - strlen(cfg->ap_ssid),
157 "-%02X%02X", mac[4], mac[5]);
158
159 uint8_t b5 = mac[4];
160 uint8_t b6 = mac[5];
161 uint8_t subnet = (b5 ^ b6) % 200 + 10;
162 IP4_ADDR(&cfg->ap_ip, 10, b5, subnet, 1);
163 snprintf(cfg->ap_ip_str, sizeof(cfg->ap_ip_str), IPSTR, IP2STR(&cfg->ap_ip));
164
165 cfg->unique_derived = true;
166
167 ESP_LOGI(TAG, "Unique config: SSID='%s', AP_IP=%s", cfg->ap_ssid, cfg->ap_ip_str);
168}
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 @@
3 3
4#include "esp_err.h" 4#include "esp_err.h"
5#include "esp_wifi.h" 5#include "esp_wifi.h"
6#include "esp_netif.h"
7#include <stdbool.h>
6 8
7#define TOLLGATE_MAX_WIFI_NETWORKS 5 9#define TOLLGATE_MAX_WIFI_NETWORKS 5
8#define TOLLGATE_MAX_MINT_URLS 3 10#define TOLLGATE_MAX_MINT_URLS 3
@@ -25,12 +27,19 @@ typedef struct {
25 uint8_t ap_channel; 27 uint8_t ap_channel;
26 uint8_t ap_max_conn; 28 uint8_t ap_max_conn;
27 29
30 esp_ip4_addr_t ap_ip;
31 char ap_ip_str[16];
32
28 char mint_url[256]; 33 char mint_url[256];
29 char lnurl_url[256]; 34 char lnurl_url[256];
30 int price_per_step; 35 int price_per_step;
31 int step_size_ms; 36 int step_size_ms;
37
38 bool unique_derived;
32} tollgate_config_t; 39} tollgate_config_t;
33 40
41void tollgate_config_derive_unique(tollgate_config_t *cfg);
42
34esp_err_t tollgate_config_init(void); 43esp_err_t tollgate_config_init(void);
35const tollgate_config_t *tollgate_config_get(void); 44const tollgate_config_t *tollgate_config_get(void);
36esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config); 45esp_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 @@
18#include "tollgate_api.h" 18#include "tollgate_api.h"
19 19
20#define MAX_STA_RETRY 5 20#define MAX_STA_RETRY 5
21#define AP_IP_ADDR "192.168.4.1"
22#define AP_SUBNET "255.255.255.0"
23
24static const char *TAG = "tollgate_main"; 21static const char *TAG = "tollgate_main";
25 22
26static EventGroupHandle_t s_wifi_event_group; 23static EventGroupHandle_t s_wifi_event_group;
@@ -31,6 +28,7 @@ static esp_netif_t *s_ap_netif = NULL;
31static int s_retry_count = 0; 28static int s_retry_count = 0;
32static bool s_services_running = false; 29static bool s_services_running = false;
33static SemaphoreHandle_t s_services_mutex = NULL; 30static SemaphoreHandle_t s_services_mutex = NULL;
31static char s_ap_ip_str[16] = "10.0.0.1";
34 32
35static void start_services(void); 33static void start_services(void);
36static void stop_services(void); 34static void stop_services(void);
@@ -109,8 +107,9 @@ static void start_services(void)
109 firewall_init(ap_ip_info.ip); 107 firewall_init(ap_ip_info.ip);
110 session_manager_init(); 108 session_manager_init();
111 109
110 const tollgate_config_t *cfg = tollgate_config_get();
112 dns_server_start(ap_ip_info.ip, upstream_dns); 111 dns_server_start(ap_ip_info.ip, upstream_dns);
113 captive_portal_start(); 112 captive_portal_start(cfg->ap_ip_str);
114 tollgate_api_start(); 113 tollgate_api_start();
115 114
116 s_services_running = true; 115 s_services_running = true;
@@ -140,10 +139,18 @@ static void wifi_create_ap_netif(void)
140{ 139{
141 s_ap_netif = esp_netif_create_default_wifi_ap(); 140 s_ap_netif = esp_netif_create_default_wifi_ap();
142 141
142 const tollgate_config_t *cfg = tollgate_config_get();
143 esp_ip4_addr_t ap_ip = cfg->ap_ip;
144 esp_ip4_addr_t ap_gw = cfg->ap_ip;
145 esp_ip4_addr_t ap_mask;
146 IP4_ADDR(&ap_mask, 255, 255, 255, 0);
147
148 strncpy(s_ap_ip_str, cfg->ap_ip_str, sizeof(s_ap_ip_str) - 1);
149
143 esp_netif_ip_info_t ip_info = { 150 esp_netif_ip_info_t ip_info = {
144 .ip.addr = esp_ip4addr_aton(AP_IP_ADDR), 151 .ip.addr = ap_ip.addr,
145 .gw.addr = esp_ip4addr_aton(AP_IP_ADDR), 152 .gw.addr = ap_gw.addr,
146 .netmask.addr = esp_ip4addr_aton(AP_SUBNET), 153 .netmask.addr = ap_mask.addr,
147 }; 154 };
148 ESP_ERROR_CHECK(esp_netif_dhcps_stop(s_ap_netif)); 155 ESP_ERROR_CHECK(esp_netif_dhcps_stop(s_ap_netif));
149 ESP_ERROR_CHECK(esp_netif_set_ip_info(s_ap_netif, &ip_info)); 156 ESP_ERROR_CHECK(esp_netif_set_ip_info(s_ap_netif, &ip_info));
@@ -190,6 +197,7 @@ void app_main(void)
190 ESP_ERROR_CHECK(ret); 197 ESP_ERROR_CHECK(ret);
191 198
192 ESP_ERROR_CHECK(tollgate_config_init()); 199 ESP_ERROR_CHECK(tollgate_config_init());
200 tollgate_config_derive_unique((tollgate_config_t *)tollgate_config_get());
193 ESP_ERROR_CHECK(esp_netif_init()); 201 ESP_ERROR_CHECK(esp_netif_init());
194 ESP_ERROR_CHECK(esp_event_loop_create_default()); 202 ESP_ERROR_CHECK(esp_event_loop_create_default());
195 203
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', () => {
32 await expect(btn).toHaveText(/Pay/); 32 await expect(btn).toHaveText(/Pay/);
33 }); 33 });
34 34
35 test('captive detection URIs return 302 redirect', async ({ request }) => { 35 test('captive detection URIs return portal HTML (200)', async ({ request }) => {
36 const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt']; 36 const uris = ['/generate_204', '/hotspot-detect.html', '/canonical.html', '/success.txt'];
37 for (const uri of uris) { 37 for (const uri of uris) {
38 const resp = await request.fetch(`${PORTAL_URL}${uri}`, { maxRedirects: 0, ignoreHTTPSErrors: true }); 38 const resp = await request.fetch(`${PORTAL_URL}${uri}`);
39 expect(resp.status()).toBe(302); 39 expect(resp.status()).toBe(200);
40 const location = resp.headers()['location']; 40 const body = await resp.text();
41 expect(location).toBe('http://192.168.4.1/'); 41 expect(body).toContain('TollGate');
42 } 42 }
43 }); 43 });
44 44
45 test('captive detection redirects to portal page', async ({ page }) => { 45 test('catch-all URIs redirect to portal page', async ({ page }) => {
46 await page.goto(`${PORTAL_URL}/generate_204`); 46 await page.goto(`${PORTAL_URL}/some-random-page`);
47 await expect(page.locator('h1')).toHaveText('TollGate'); 47 await expect(page.locator('h1')).toHaveText('TollGate');
48 }); 48 });
49 49
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) {
84 } catch { 84 } catch {
85 pingOk = false; 85 pingOk = false;
86 } 86 }
87 try {
88 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 });
89 } catch {}
90 assert(pingOk, 'Internet works'); 87 assert(pingOk, 'Internet works');
91 88
92 // Test 20: Spent token 89 // Test 20: Spent token