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 04:46:32 +0530
committerYour Name <you@example.com>2026-05-16 04:46:32 +0530
commit50b5975ac8793d6d820c35b5999f8a909f64e71b (patch)
tree2592f9e7a671af2aca56e46887e50b8ad8e418b6
parent3f46bb83cb1041889034c88adce1895dd330793f (diff)
Captive portal detection fix + Phase 2 tests 16-18,20 passing (17/17)
- Add DoT reject server on port 853 (TCP RST forces DNS-over-TLS fallback) - DNS hijack returns NXDOMAIN for all non-A query types (no forwarding for unauthed) - Shorter TTL on hijack responses (10s) for faster captive detection - Explicit 302 redirect handlers for /generate_204, /hotspot-detect.html, etc. - HTTP and DNS request logging for debugging captive detection - Per-MAC tracking in firewall (find_by_mac, get_mac_for_ip with ARP fallback) - Session MAC tracking (session_find_by_mac) - Phase 2 test 18: add route through TollGate before ping test - All 17 Phase 2 tests pass (15-21 + whoami + portal form)
-rw-r--r--CHECKLIST.md70
-rw-r--r--PLAN.md98
-rw-r--r--main/captive_portal.c50
-rw-r--r--main/dns_server.c61
-rw-r--r--main/firewall.c93
-rw-r--r--main/firewall.h5
-rw-r--r--main/session.c25
-rw-r--r--main/session.h1
-rw-r--r--main/tollgate_api.c69
-rw-r--r--tests/phase2.mjs21
10 files changed, 376 insertions, 117 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md
index ba17adc..dc16b0b 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -21,55 +21,71 @@
21- [x] Fix ping tests (use `-I wlp59s0`) 21- [x] Fix ping tests (use `-I wlp59s0`)
22- [x] Tests 1-14: ALL PASSING 22- [x] Tests 1-14: ALL PASSING
23 23
24## Phase 2: E-Cash Payments — IN PROGRESS 24## Phase 2: E-Cash Payments — IN PROGRESS (commit `3f46bb8` + uncommitted fixes)
25### Code Written (commit `1263d86`) 25### Code Written
26- [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation) 26- [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation)
27- [x] Implement session.c/h (time-based allotment, expiry, secret tracking) 27- [x] Implement session.c/h (time-based allotment, expiry, secret tracking, MAC tracking)
28- [x] Implement tollgate_api.c/h (:2121 server, GET/POST /, /usage, /whoami) 28- [x] Implement tollgate_api.c/h (:2121 server, GET/POST /, /usage, /whoami)
29- [x] Update captive portal HTML with payment form 29- [x] Update captive portal HTML with payment form (Cashu token textarea + "Pay & Connect")
30- [x] Wire into tollgate_main.c (session_init, api_start, session_tick loop) 30- [x] Wire into tollgate_main.c (session_init, api_start, session_tick loop)
31- [x] Per-MAC access tracking: `firewall_get_mac_for_ip()` using `esp_wifi_ap_get_sta_list_with_ip()` + ARP fallback
32- [x] Two httpd instances: port 80 (captive portal) and port 2121 (TollGate API)
31 33
32### Bug Fixes (commit `aed51d8`) 34### Bug Fixes
33- [x] Stack overflow: httpd stack_size increased to 16384 in tollgate_api.c 35- [x] Stack overflow: httpd stack_size increased to 32768 (TLS+mbedTLS needs ~20KB)
34- [x] Heap allocations: b64, json_buf, post_body, resp_buf moved to heap in cashu.c 36- [x] Heap allocations: cashu_token_t, cashu_proof_state_t, json_buf, post_body all heap-allocated
35- [x] .env: MINT_URL updated to testnut.cashu.space 37- [x] TLS to mint: `esp_crt_bundle_attach` + `esp-tls` in CMakeLists.txt REQUIRES
36- [x] Makefile: replaced Go-based tokens target with nutshell wallet targets 38- [x] HTTP client: `open/write/fetch_headers/read` pattern (not `perform`)
39- [x] Token decode: dynamic `json_buf` sizing `malloc((b64_len * 3) / 4 + 4)`, strip trailing `\n`/`\r`
40- [x] POST body recv: loop `httpd_req_recv` until all `content_len` bytes read
41- [x] `secret_count` bug: capped at `MIN(proof_count, 5)` before `session_create`
42- [x] `config.c` default mint URL fixed to `testnut.cashu.space`
43- [x] Makefile: nutshell wallet targets (wallet-setup, wallet-info, mint-token, send-token)
44- [x] `tests/phase2.mjs`: `/whoami` test checks `includes('mac=')`
37 45
38### Infrastructure (ready now) 46### Infrastructure
39- [x] Upstream gateway on enx00e04c633a90 (192.168.2.0/24, metric 101, default route) 47- [x] Upstream gateway on enx00e04c633a90 (192.168.2.0/24, metric 101, default route)
40- [x] OpenWRT TollGate on enx00e04c683d2d (10.47.41.0/24, metric 20100, never-default) 48- [x] OpenWRT TollGate on enx00e04c683d2d (10.47.41.0/24, metric 20100, never-default)
41- [x] WiFi wlp59s0 free for ESP32 TollGate connection 49- [x] WiFi wlp59s0 free for ESP32 TollGate connection
42- [x] NetworkManager profile "TollGate-ESP32" created (manual 192.168.4.2/24, autoconnect=no) 50- [x] NetworkManager profile "TollGate-ESP32" created (manual 192.168.4.2/24, autoconnect=no)
51- [x] Mint URL verified: `testnut.cashu.space` works; `nofee.testnut.cashu.space` and `nofees.testnut.cashu.space` both broken
43 52
44### Tests Passing 53### Tests Passing
45- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING 54- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING
55- [x] Test 16: Valid payment (POST :2121/ with valid Cashu token → kind=1022 session) — PASSING
56- [x] Test 17: Usage tracking after payment (GET :2121/usage → active usage) — PASSING
57- [x] Test 18: Internet after payment (ping through TollGate works) — PASSING
58- [x] Test 19: Invalid token rejected (POST garbage → 400, kind=21023) — PASSING
59- [x] Test 20: Spent token rejected (reuse token → kind=21023) — PASSING
60- [x] Test 21: Wrong mint rejected (POST token from wrong mint → kind=21023) — PASSING
61- [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING
62- [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING
46 63
47### Tests Blocked (need hardware flash + test) 64### Tests Not Yet Run (need hardware + time)
48- [ ] Test 16: Valid payment (POST :2121/ with valid Cashu token → kind=1022 session)
49- [ ] Test 17: Usage tracking after payment (GET :2121/usage → active usage)
50- [ ] Test 18: Internet after payment (ping through TollGate works)
51- [ ] Test 19: Invalid token rejected (POST garbage → 400, kind=21023)
52- [ ] Test 20: Spent token rejected (reuse token → 402, kind=21023)
53- [ ] Test 21: Wrong mint rejected (POST token from wrong mint → 402)
54- [ ] Test 22: Session expiry (wait for allotment → internet blocked) 65- [ ] Test 22: Session expiry (wait for allotment → internet blocked)
55- [ ] Test 23: Session renewal (second payment → allotment extended) 66- [ ] Test 23: Session renewal (second payment → allotment extended)
56- [ ] Test 24: Portal payment form visible in browser 67- [ ] Test 24: Portal payment form visible in browser (Playwright)
57- [ ] Test 25: Two clients pay independently 68- [ ] Test 25: Two clients pay independently
58- [ ] Test 26: Client isolation (only payer gets internet) 69- [ ] Test 26: Client isolation (only payer gets internet)
59- [ ] Test 27: Full e2e: portal → pay → browse 70- [ ] Test 27: Full e2e: portal → pay → browse
60 71
61### Next Steps (TDD cycle) 72### Captive Portal Detection Fix
621. Flash firmware to ESP32 board A (`make flash-a`) 73- [x] Added DoT reject server on port 853 (TCP RST forces DNS fallback to port 53)
632. Connect WiFi to TollGate AP: `nmcli con up TollGate-ESP32` 74- [x] DNS hijack now returns NXDOMAIN for ALL non-A query types (prevents DNS leaks)
643. Run Phase 2 discovery test: `TOLLGATE_IP=192.168.4.1 node tests/phase2.mjs` 75- [x] Shorter TTL on hijack responses (10s) for faster detection
654. If Test 15 still passes, proceed to Test 19 (invalid token — no mint needed) 76- [x] Explicit 302 redirect handlers for all captive detection URIs (/generate_204, /hotspot-detect.html, etc.)
665. Mint a test token: `make mint-token AMOUNT=21` 77- [x] HTTP request logging for captive detection endpoints
676. Run full Phase 2 with token: `TEST_TOKEN=$(cashu --env-mint testnut.cashu.space send --legacy 21) TOLLGATE_IP=192.168.4.1 node tests/phase2.mjs` 78- [x] DNS query logging for unauthenticated clients
687. Fix any failures, commit + push when tests pass 79- [ ] **Needs verification with actual GrapheneOS phone**
69 80
70## Phase 3: nucula Wallet + Reseller — NOT STARTED 81## Phase 3: nucula Wallet + ESP32-to-ESP32 Payments — NOT STARTED
71- [ ] Extract nucula wallet into components/cashu_wallet/ 82- [ ] Extract nucula wallet into components/cashu_wallet/
72- [ ] Replace simple melt with Wallet::receive() 83- [ ] Replace simple melt with Wallet::receive()
73- [ ] Implement payout.c/h (background melt-to-LN) 84- [ ] Implement payout.c/h (background melt-to-LN)
74- [ ] Implement upstream_client.c/h (reseller mode) 85- [ ] Implement upstream_client.c/h (reseller mode)
86- [ ] ESP32-to-ESP32 payments (ESP32 generates/proves tokens to pay another ESP32 TollGate)
75- [ ] Tests 28-38 87- [ ] Tests 28-38
88
89## Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED
90- [ ] ESP32 pays OpenWRT TollGate using Cashu tokens
91- [ ] Interoperability testing with existing OpenWRT TollGate on enx00e04c683d2d
diff --git a/PLAN.md b/PLAN.md
index c4373fb..a62959d 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -23,9 +23,9 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
23| Testing | Playwright + curl + pyserial | 23| Testing | Playwright + curl + pyserial |
24| Build | Makefile | 24| Build | Makefile |
25 25
26## Three-Phase Plan 26## Four-Phase Plan
27 27
28### Phase 1: Captive Portal + Firewall (No Payments) 28### Phase 1: Captive Portal + Firewall (No Payments) — COMPLETE
29 29
30**Goal:** WiFi repeater with captive portal that gates internet access. Validates DNS hijack, NAT, DHCP, firewall. 30**Goal:** WiFi repeater with captive portal that gates internet access. Validates DNS hijack, NAT, DHCP, firewall.
31 31
@@ -35,51 +35,55 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
35- Captive portal HTML on port 80 35- Captive portal HTML on port 80
36 36
37**14 Test Cases:** 37**14 Test Cases:**
38| # | Test | Method | Pass Criteria | 38| # | Test | Method | Pass Criteria | Status |
39|---|------|--------|---------------| 39|---|------|--------|---------------|--------|
40| 1 | Boot and AP appears | Serial + nmcli | SSID visible in scan | 40| 1 | Boot and AP appears | Serial + nmcli | SSID visible in scan | PASS |
41| 2 | DHCP lease | nmcli connect | Gets IP in 192.168.4.0/24 | 41| 2 | DHCP lease | nmcli connect | Gets IP in 192.168.4.0/24 | PASS |
42| 3 | Captive portal serves HTML | GET / | 200, contains "TollGate" | 42| 3 | Captive portal serves HTML | GET / | 200, contains "TollGate" | PASS |
43| 4 | Captive detection URIs work | GET /generate_204 etc. | All return portal HTML | 43| 4 | Captive detection URIs work | GET /generate_204 etc. | All return portal HTML | PASS |
44| 5 | DNS hijack before auth | nslookup google.com | Resolves to 192.168.4.1 | 44| 5 | DNS hijack before auth | nslookup google.com | Resolves to 192.168.4.1 | PASS |
45| 6 | No internet before auth | ping 8.8.8.8 | Fails | 45| 6 | No internet before auth | ping 8.8.8.8 | Fails | PASS |
46| 7 | /whoami returns MAC | GET /whoami | Returns mac=XX:XX:... | 46| 7 | /whoami returns MAC | GET /whoami | Returns mac=XX:XX:... | PASS |
47| 8 | /usage returns no session | GET /usage | Returns -1/-1 | 47| 8 | /usage returns no session | GET /usage | Returns -1/-1 | PASS |
48| 9 | Grant access via API | GET /grant_access | 200, status granted | 48| 9 | Grant access via API | GET /grant_access | 200, status granted | PASS |
49| 10 | DNS forward after auth | nslookup google.com | Resolves to real IP | 49| 10 | DNS forward after auth | nslookup google.com | Resolves to real IP | PASS |
50| 11 | Internet after auth | ping 8.8.8.8 | Succeeds | 50| 11 | Internet after auth | ping 8.8.8.8 | Succeeds | PASS |
51| 12 | HTTP browsing works | Playwright | Page loads | 51| 12 | HTTP browsing works | Playwright | Page loads | PASS |
52| 13 | Reset auth | GET /reset_authentication | 200 | 52| 13 | Reset auth | GET /reset_authentication | 200 | PASS |
53| 14 | Internet blocked after reset | ping 8.8.8.8 | Fails | 53| 14 | Internet blocked after reset | ping 8.8.8.8 | Fails | PASS |
54 54
55### Phase 2: E-Cash Payments (Simple Melt-to-LNURL) 55### Phase 2: E-Cash Payments IN PROGRESS
56 56
57**Goal:** Replace free access with Cashu payment. ESP32 parses token, melts via mint API to operator's LNURL. 57**Goal:** Replace free access with Cashu payment. ESP32 parses token, checks proof state via mint API, grants time-based session.
58 58
59**New Endpoints:** 59**Endpoints:**
60- `GET /` on :2121 — TollGate advertisement (kind=10021) 60- `GET /` on :2121 — TollGate advertisement (kind=10021)
61- `POST /` on :2121 — Accept Cashu token, melt, return session (kind=1022) or notice (kind=21023) 61- `POST /` on :2121 — Accept Cashu token, validate, return session (kind=1022) or error (kind=21023)
62- `GET /usage` on :2121 — Session usage info
63- `GET /whoami` on :2121 — Client IP + MAC
62 64
63**13 Additional Test Cases:** 65**13 Additional Test Cases:**
64| # | Test | Method | Pass Criteria | 66| # | Test | Method | Pass Criteria | Status |
65|---|------|--------|---------------| 67|---|------|--------|---------------|--------|
66| 15 | Advertisement valid | GET :2121/ | kind=10021 with price_per_step | 68| 15 | Advertisement valid | GET :2121/ | kind=10021 with price_per_step | PASS |
67| 16 | Valid payment | POST :2121/ with token | kind=1022 session | 69| 16 | Valid payment | POST :2121/ with token | kind=1022 session | PASS |
68| 17 | Usage tracking | GET :2121/usage | 0/allotment | 70| 17 | Usage tracking | GET :2121/usage | 0/allotment | PASS |
69| 18 | Internet after payment | ping | Succeeds | 71| 18 | Internet after payment | ping | Succeeds | PASS |
70| 19 | Invalid token | POST :2121/ garbage | kind=21023 error | 72| 19 | Invalid token | POST :2121/ garbage | kind=21023 error | PASS |
71| 20 | Spent token | Reuse token | kind=21023 spent error | 73| 20 | Spent token | Reuse token | kind=21023 spent error | PASS |
72| 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | 74| 21 | Wrong mint | Token from unaccepted mint | kind=21023 mint error | PASS |
73| 22 | Session expiry | Wait for allotment | Internet blocked | 75| 22 | Session expiry | Wait for allotment | Internet blocked | TODO |
74| 23 | Session renewal | Second payment | Allotment extended | 76| 23 | Session renewal | Second payment | Allotment extended | TODO |
75| 24 | Portal payment form | Playwright paste token | Checkmark shown | 77| 24 | Portal payment form | Playwright paste token | Checkmark shown | TODO |
76| 25 | Two clients pay independently | Two POSTs | Both authenticated | 78| 25 | Two clients pay independently | Two POSTs | Both authenticated | TODO |
77| 26 | Client isolation | Only payer gets internet | Non-payer blocked | 79| 26 | Client isolation | Only payer gets internet | Non-payer blocked | TODO |
78| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | 80| 27 | Full e2e: portal→pay→browse | Playwright | Complete flow | TODO |
79 81
80### Phase 3: nucula Wallet Integration + Reseller 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.
81 83
82**Goal:** Integrate nucula's full Cashu wallet. ESP32 holds balance, can be a reseller. 84### Phase 3: nucula Wallet + ESP32-to-ESP32 Payments — NOT STARTED
85
86**Goal:** Integrate nucula's full Cashu wallet. ESP32 holds balance, can be a reseller. ESP32-to-ESP32 direct payments.
83 87
84**11 Additional Test Cases:** 88**11 Additional Test Cases:**
85| # | Test | Method | Pass Criteria | 89| # | Test | Method | Pass Criteria |
@@ -96,4 +100,8 @@ Build a TollGate firmware for two ESP32 devices, following the [TollGate protoco
96| 37 | 5 consecutive payments | Loop | All authenticated | 100| 37 | 5 consecutive payments | Loop | All authenticated |
97| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | 101| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak |
98 102
99## Total: 38 Tests across 3 phases 103### Phase 4: ESP32-to-OpenWRT TollGate Interop — NOT STARTED
104
105**Goal:** ESP32 can pay OpenWRT TollGate using Cashu tokens. Full interoperability with existing OpenWRT-based TollGate infrastructure.
106
107## Total: 38 Tests across 4 phases
diff --git a/main/captive_portal.c b/main/captive_portal.c
index 17f672f..0ae46ab 100644
--- a/main/captive_portal.c
+++ b/main/captive_portal.c
@@ -100,11 +100,17 @@ static bool is_captive_detection_uri(const char *uri)
100 strcmp(uri, "/ncsi.txt") == 0 || 100 strcmp(uri, "/ncsi.txt") == 0 ||
101 strcmp(uri, "/connecttest.txt") == 0 || 101 strcmp(uri, "/connecttest.txt") == 0 ||
102 strcmp(uri, "/wpad.dat") == 0 || 102 strcmp(uri, "/wpad.dat") == 0 ||
103 strcmp(uri, "/redirect") == 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;
104} 109}
105 110
106static esp_err_t portal_handler(httpd_req_t *req) 111static esp_err_t portal_handler(httpd_req_t *req)
107{ 112{
113 ESP_LOGI(TAG, "GET %s from client", req->uri);
108 httpd_resp_set_type(req, "text/html"); 114 httpd_resp_set_type(req, "text/html");
109 httpd_resp_send(req, PORTAL_HTML, strlen(PORTAL_HTML)); 115 httpd_resp_send(req, PORTAL_HTML, strlen(PORTAL_HTML));
110 return ESP_OK; 116 return ESP_OK;
@@ -139,12 +145,17 @@ static esp_err_t status_handler(httpd_req_t *req)
139static esp_err_t whoami_handler(httpd_req_t *req) 145static esp_err_t whoami_handler(httpd_req_t *req)
140{ 146{
141 uint32_t client_ip; 147 uint32_t client_ip;
142 char resp[64]; 148 char resp[96];
143 if (get_client_ip(req, &client_ip) == ESP_OK) { 149 if (get_client_ip(req, &client_ip) == ESP_OK) {
150 char mac[18] = {0};
144 esp_ip4_addr_t ip = { .addr = client_ip }; 151 esp_ip4_addr_t ip = { .addr = client_ip };
145 snprintf(resp, sizeof(resp), "mac=" IPSTR, IP2STR(&ip)); 152 if (firewall_get_mac_for_ip(client_ip, mac, sizeof(mac)) == ESP_OK) {
153 snprintf(resp, sizeof(resp), "ip=" IPSTR " mac=%s", IP2STR(&ip), mac);
154 } else {
155 snprintf(resp, sizeof(resp), "ip=" IPSTR " mac=unknown", IP2STR(&ip));
156 }
146 } else { 157 } else {
147 snprintf(resp, sizeof(resp), "mac=unknown"); 158 snprintf(resp, sizeof(resp), "ip=unknown mac=unknown");
148 } 159 }
149 httpd_resp_set_type(req, "text/plain"); 160 httpd_resp_set_type(req, "text/plain");
150 httpd_resp_send(req, resp, strlen(resp)); 161 httpd_resp_send(req, resp, strlen(resp));
@@ -174,13 +185,22 @@ static esp_err_t reset_auth_handler(httpd_req_t *req)
174 return ESP_OK; 185 return ESP_OK;
175} 186}
176 187
188static esp_err_t redirect_to_portal_handler(httpd_req_t *req)
189{
190 ESP_LOGI(TAG, "Captive detect: GET %s → 302 → http://192.168.4.1/", req->uri);
191 httpd_resp_set_status(req, "302 Found");
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}
197
177static esp_err_t catchall_handler(httpd_req_t *req) 198static esp_err_t catchall_handler(httpd_req_t *req)
178{ 199{
179 if (is_captive_detection_uri(req->uri)) { 200 ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://192.168.4.1/", req->uri);
180 return portal_handler(req);
181 }
182 httpd_resp_set_status(req, "302 Found"); 201 httpd_resp_set_status(req, "302 Found");
183 httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/"); 202 httpd_resp_set_hdr(req, "Location", "http://192.168.4.1/");
203 httpd_resp_set_hdr(req, "Connection", "close");
184 httpd_resp_send(req, NULL, 0); 204 httpd_resp_send(req, NULL, 0);
185 return ESP_OK; 205 return ESP_OK;
186} 206}
@@ -191,6 +211,13 @@ static const httpd_uri_t uri_status = { .uri = "/api/status", .method = HTTP_GET
191static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; 211static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler };
192static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; 212static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler };
193static const httpd_uri_t uri_reset = { .uri = "/reset_authentication", .method = HTTP_GET, .handler = reset_auth_handler }; 213static const httpd_uri_t uri_reset = { .uri = "/reset_authentication", .method = HTTP_GET, .handler = reset_auth_handler };
214static const httpd_uri_t uri_gen204 = { .uri = "/generate_204", .method = HTTP_GET, .handler = redirect_to_portal_handler };
215static const httpd_uri_t uri_hotspot = { .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = redirect_to_portal_handler };
216static const httpd_uri_t uri_canonical = { .uri = "/canonical.html", .method = HTTP_GET, .handler = redirect_to_portal_handler };
217static const httpd_uri_t uri_success = { .uri = "/success.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
218static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
219static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
220static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler };
194static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; 221static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler };
195 222
196esp_err_t captive_portal_start(void) 223esp_err_t captive_portal_start(void)
@@ -198,7 +225,7 @@ esp_err_t captive_portal_start(void)
198 if (s_server) return ESP_OK; 225 if (s_server) return ESP_OK;
199 226
200 httpd_config_t config = HTTPD_DEFAULT_CONFIG(); 227 httpd_config_t config = HTTPD_DEFAULT_CONFIG();
201 config.max_uri_handlers = 10; 228 config.max_uri_handlers = 20;
202 config.uri_match_fn = httpd_uri_match_wildcard; 229 config.uri_match_fn = httpd_uri_match_wildcard;
203 230
204 esp_err_t ret = httpd_start(&s_server, &config); 231 esp_err_t ret = httpd_start(&s_server, &config);
@@ -213,6 +240,13 @@ esp_err_t captive_portal_start(void)
213 httpd_register_uri_handler(s_server, &uri_whoami); 240 httpd_register_uri_handler(s_server, &uri_whoami);
214 httpd_register_uri_handler(s_server, &uri_usage); 241 httpd_register_uri_handler(s_server, &uri_usage);
215 httpd_register_uri_handler(s_server, &uri_reset); 242 httpd_register_uri_handler(s_server, &uri_reset);
243 httpd_register_uri_handler(s_server, &uri_gen204);
244 httpd_register_uri_handler(s_server, &uri_hotspot);
245 httpd_register_uri_handler(s_server, &uri_canonical);
246 httpd_register_uri_handler(s_server, &uri_success);
247 httpd_register_uri_handler(s_server, &uri_ncsi);
248 httpd_register_uri_handler(s_server, &uri_connecttest);
249 httpd_register_uri_handler(s_server, &uri_wpad);
216 httpd_register_uri_handler(s_server, &uri_catchall); 250 httpd_register_uri_handler(s_server, &uri_catchall);
217 251
218 ESP_LOGI(TAG, "Captive portal started on port 80"); 252 ESP_LOGI(TAG, "Captive portal started on port 80");
diff --git a/main/dns_server.c b/main/dns_server.c
index 733e771..15a729f 100644
--- a/main/dns_server.c
+++ b/main/dns_server.c
@@ -11,10 +11,14 @@
11#define MAX_PENDING 50 11#define MAX_PENDING 50
12#define DNS_BUF_SIZE 512 12#define DNS_BUF_SIZE 512
13#define DNS_PORT 53 13#define DNS_PORT 53
14#define DOT_PORT 853
14#define DNS_TASK_STACK 4096 15#define DNS_TASK_STACK 4096
16#define DOT_TASK_STACK 3072
15#define DNS_TASK_PRIO 5 17#define DNS_TASK_PRIO 5
18#define DOT_TASK_PRIO 5
16#define DNS_FORWARD_TIMEOUT_MS 2000 19#define DNS_FORWARD_TIMEOUT_MS 2000
17#define NXDOMAIN_TTL 30 20#define NXDOMAIN_TTL 30
21#define HIJACK_TTL 10
18 22
19static const char *TAG = "dns_server"; 23static const char *TAG = "dns_server";
20 24
@@ -47,6 +51,7 @@ typedef struct {
47static auth_entry_t s_auth_list[MAX_AUTH_IPS]; 51static auth_entry_t s_auth_list[MAX_AUTH_IPS];
48static int s_auth_count = 0; 52static int s_auth_count = 0;
49static TaskHandle_t s_dns_task = NULL; 53static TaskHandle_t s_dns_task = NULL;
54static TaskHandle_t s_dot_task = NULL;
50static volatile bool s_dns_running = false; 55static volatile bool s_dns_running = false;
51static esp_ip4_addr_t s_ap_ip; 56static esp_ip4_addr_t s_ap_ip;
52static esp_ip4_addr_t s_upstream_dns; 57static esp_ip4_addr_t s_upstream_dns;
@@ -106,7 +111,7 @@ static int build_redirect_response(uint8_t *response, int req_len)
106 ans.name = htons(0xC00C); 111 ans.name = htons(0xC00C);
107 ans.type = htons(1); 112 ans.type = htons(1);
108 ans.class = htons(1); 113 ans.class = htons(1);
109 ans.ttl = htonl(NXDOMAIN_TTL); 114 ans.ttl = htonl(HIJACK_TTL);
110 ans.len = htons(4); 115 ans.len = htons(4);
111 ans.addr = s_ap_ip.addr; 116 ans.addr = s_ap_ip.addr;
112 memcpy(response + resp_len, &ans, sizeof(ans)); 117 memcpy(response + resp_len, &ans, sizeof(ans));
@@ -201,23 +206,21 @@ static void dns_server_task(void *arg)
201 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); 206 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
202 } 207 }
203 } else { 208 } else {
209 char qname[256] = {0};
210 parse_dns_name(rx_buf, n, sizeof(dns_header_t), qname, sizeof(qname));
211 ESP_LOGI(TAG, "Hijack DNS from " IPSTR ": %s (type=%d)", IP2STR(&(esp_ip4_addr_t){.addr=client_ip}), qname, qtype);
204 if (qtype == 1) { 212 if (qtype == 1) {
205 int resp_len = build_redirect_response(rx_buf, req_len); 213 int resp_len = build_redirect_response(rx_buf, req_len);
206 memcpy(tx_buf, rx_buf, resp_len); 214 memcpy(tx_buf, rx_buf, resp_len);
207 dns_header_t *resp_hdr = (dns_header_t *)tx_buf; 215 dns_header_t *resp_hdr = (dns_header_t *)tx_buf;
208 resp_hdr->id = htons(txn_id); 216 resp_hdr->id = htons(txn_id);
209 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); 217 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
210 } else if (qtype == 28) { 218 } else {
211 int resp_len = build_nxdomain(rx_buf, req_len); 219 int resp_len = build_nxdomain(rx_buf, req_len);
212 memcpy(tx_buf, rx_buf, resp_len); 220 memcpy(tx_buf, rx_buf, resp_len);
213 dns_header_t *resp_hdr = (dns_header_t *)tx_buf; 221 dns_header_t *resp_hdr = (dns_header_t *)tx_buf;
214 resp_hdr->id = htons(txn_id); 222 resp_hdr->id = htons(txn_id);
215 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len); 223 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
216 } else {
217 int resp_len = forward_dns(rx_buf, req_len, tx_buf, sizeof(tx_buf), &client_addr, txn_id);
218 if (resp_len > 0) {
219 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
220 }
221 } 224 }
222 } 225 }
223 } 226 }
@@ -227,6 +230,49 @@ static void dns_server_task(void *arg)
227 vTaskDelete(NULL); 230 vTaskDelete(NULL);
228} 231}
229 232
233static void dot_reject_task(void *arg)
234{
235 int sock = socket(AF_INET, SOCK_STREAM, 0);
236 if (sock < 0) {
237 ESP_LOGE(TAG, "Failed to create DoT reject socket");
238 vTaskDelete(NULL);
239 return;
240 }
241
242 int opt = 1;
243 setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
244
245 struct sockaddr_in bind_addr = {
246 .sin_family = AF_INET,
247 .sin_port = htons(DOT_PORT),
248 .sin_addr.s_addr = INADDR_ANY,
249 };
250 if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
251 ESP_LOGE(TAG, "Failed to bind DoT reject socket on port %d", DOT_PORT);
252 close(sock);
253 vTaskDelete(NULL);
254 return;
255 }
256
257 listen(sock, 1);
258 ESP_LOGI(TAG, "DoT reject server on port %d (forces DNS fallback to port 53)", DOT_PORT);
259
260 while (s_dns_running) {
261 struct sockaddr_in client_addr;
262 socklen_t client_len = sizeof(client_addr);
263 int client_sock = accept(sock, (struct sockaddr *)&client_addr, &client_len);
264 if (client_sock >= 0) {
265 struct linger ling = { .l_onoff = 1, .l_linger = 0 };
266 setsockopt(client_sock, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
267 close(client_sock);
268 }
269 }
270
271 close(sock);
272 ESP_LOGI(TAG, "DoT reject server stopped");
273 vTaskDelete(NULL);
274}
275
230esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns) 276esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns)
231{ 277{
232 if (s_dns_running) return ESP_OK; 278 if (s_dns_running) return ESP_OK;
@@ -234,6 +280,7 @@ esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns)
234 s_upstream_dns = upstream_dns; 280 s_upstream_dns = upstream_dns;
235 s_dns_running = true; 281 s_dns_running = true;
236 xTaskCreate(dns_server_task, "dns_server", DNS_TASK_STACK, NULL, DNS_TASK_PRIO, &s_dns_task); 282 xTaskCreate(dns_server_task, "dns_server", DNS_TASK_STACK, NULL, DNS_TASK_PRIO, &s_dns_task);
283 xTaskCreate(dot_reject_task, "dot_reject", DOT_TASK_STACK, NULL, DOT_TASK_PRIO, &s_dot_task);
237 return ESP_OK; 284 return ESP_OK;
238} 285}
239 286
diff --git a/main/firewall.c b/main/firewall.c
index 8087b54..f349ab1 100644
--- a/main/firewall.c
+++ b/main/firewall.c
@@ -1,7 +1,11 @@
1#include "firewall.h" 1#include "firewall.h"
2#include "dns_server.h" 2#include "dns_server.h"
3#include "esp_log.h" 3#include "esp_log.h"
4#include "esp_wifi.h"
5#include "esp_wifi_ap_get_sta_list.h"
4#include "lwip/lwip_napt.h" 6#include "lwip/lwip_napt.h"
7#include "lwip/etharp.h"
8#include "lwip/netif.h"
5#include <string.h> 9#include <string.h>
6 10
7#define MAX_CLIENTS 10 11#define MAX_CLIENTS 10
@@ -12,11 +16,51 @@ static bool s_nat_enabled = false;
12 16
13typedef struct { 17typedef struct {
14 uint32_t ip; 18 uint32_t ip;
19 char mac[FW_MAX_MAC_LEN];
15} fw_client_t; 20} fw_client_t;
16 21
17static fw_client_t s_clients[MAX_CLIENTS]; 22static fw_client_t s_clients[MAX_CLIENTS];
18static int s_client_count = 0; 23static int s_client_count = 0;
19 24
25static struct netif *get_ap_netif(void)
26{
27 return netif_get_by_index(NETIF_NO_INDEX);
28}
29
30esp_err_t firewall_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size)
31{
32 wifi_sta_list_t sta_list;
33 if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) {
34 wifi_sta_mac_ip_list_t ip_mac_list;
35 if (esp_wifi_ap_get_sta_list_with_ip(&sta_list, &ip_mac_list) == ESP_OK) {
36 for (int i = 0; i < ip_mac_list.num; i++) {
37 if (ip_mac_list.sta[i].ip.addr == client_ip) {
38 snprintf(mac_out, mac_out_size, "%02x:%02x:%02x:%02x:%02x:%02x",
39 ip_mac_list.sta[i].mac[0], ip_mac_list.sta[i].mac[1],
40 ip_mac_list.sta[i].mac[2], ip_mac_list.sta[i].mac[3],
41 ip_mac_list.sta[i].mac[4], ip_mac_list.sta[i].mac[5]);
42 return ESP_OK;
43 }
44 }
45 }
46 }
47
48 ip4_addr_t *entry_ip = NULL;
49 struct netif *entry_netif = NULL;
50 struct eth_addr *entry_eth = NULL;
51 ssize_t i = 0;
52 while (etharp_get_entry(i, &entry_ip, &entry_netif, &entry_eth) == ERR_OK) {
53 if (entry_ip && entry_ip->addr == client_ip && entry_eth) {
54 snprintf(mac_out, mac_out_size, "%02x:%02x:%02x:%02x:%02x:%02x",
55 entry_eth->addr[0], entry_eth->addr[1], entry_eth->addr[2],
56 entry_eth->addr[3], entry_eth->addr[4], entry_eth->addr[5]);
57 return ESP_OK;
58 }
59 i++;
60 }
61 return ESP_FAIL;
62}
63
20esp_err_t firewall_init(esp_ip4_addr_t ap_ip) 64esp_err_t firewall_init(esp_ip4_addr_t ap_ip)
21{ 65{
22 s_ap_ip = ap_ip; 66 s_ap_ip = ap_ip;
@@ -56,34 +100,61 @@ void firewall_disable_nat(void)
56 ESP_LOGI(TAG, "NAT disabled"); 100 ESP_LOGI(TAG, "NAT disabled");
57} 101}
58 102
59void firewall_grant_access(uint32_t client_ip) 103static fw_client_t *find_client_by_ip(uint32_t client_ip)
60{ 104{
61 for (int i = 0; i < s_client_count; i++) { 105 for (int i = 0; i < s_client_count; i++) {
62 if (s_clients[i].ip == client_ip) return; 106 if (s_clients[i].ip == client_ip) return &s_clients[i];
107 }
108 return NULL;
109}
110
111static fw_client_t *find_client_by_mac(const char *mac)
112{
113 for (int i = 0; i < s_client_count; i++) {
114 if (s_clients[i].mac[0] != '\0' && strcmp(s_clients[i].mac, mac) == 0) {
115 return &s_clients[i];
116 }
117 }
118 return NULL;
119}
120
121void firewall_grant_access(uint32_t client_ip)
122{
123 fw_client_t *existing = find_client_by_ip(client_ip);
124 if (existing) {
125 existing->ip = client_ip;
126 return;
63 } 127 }
64 if (s_client_count >= MAX_CLIENTS) { 128 if (s_client_count >= MAX_CLIENTS) {
65 ESP_LOGW(TAG, "Max clients reached, cannot grant access"); 129 ESP_LOGW(TAG, "Max clients reached, cannot grant access");
66 return; 130 return;
67 } 131 }
68 s_clients[s_client_count].ip = client_ip; 132
133 fw_client_t *client = &s_clients[s_client_count];
134 client->ip = client_ip;
135 client->mac[0] = '\0';
136 firewall_get_mac_for_ip(client_ip, client->mac, sizeof(client->mac));
69 s_client_count++; 137 s_client_count++;
138
70 dns_server_set_client_authenticated(client_ip, true); 139 dns_server_set_client_authenticated(client_ip, true);
71 update_nat(); 140 update_nat();
72 141
73 esp_ip4_addr_t ip_addr = { .addr = client_ip }; 142 esp_ip4_addr_t ip_addr = { .addr = client_ip };
74 ESP_LOGI(TAG, "Access granted to " IPSTR, IP2STR(&ip_addr)); 143 ESP_LOGI(TAG, "Access granted to " IPSTR " mac=%s", IP2STR(&ip_addr),
144 client->mac[0] ? client->mac : "unknown");
75} 145}
76 146
77void firewall_revoke_access(uint32_t client_ip) 147void firewall_revoke_access(uint32_t client_ip)
78{ 148{
79 for (int i = 0; i < s_client_count; i++) { 149 for (int i = 0; i < s_client_count; i++) {
80 if (s_clients[i].ip == client_ip) { 150 if (s_clients[i].ip == client_ip) {
151 esp_ip4_addr_t ip_addr = { .addr = client_ip };
152 ESP_LOGI(TAG, "Access revoked for " IPSTR " mac=%s", IP2STR(&ip_addr),
153 s_clients[i].mac[0] ? s_clients[i].mac : "unknown");
81 s_clients[i] = s_clients[s_client_count - 1]; 154 s_clients[i] = s_clients[s_client_count - 1];
82 s_client_count--; 155 s_client_count--;
83 dns_server_set_client_authenticated(client_ip, false); 156 dns_server_set_client_authenticated(client_ip, false);
84 update_nat(); 157 update_nat();
85 esp_ip4_addr_t ip_addr = { .addr = client_ip };
86 ESP_LOGI(TAG, "Access revoked for " IPSTR, IP2STR(&ip_addr));
87 return; 158 return;
88 } 159 }
89 } 160 }
@@ -101,10 +172,12 @@ void firewall_revoke_all(void)
101 172
102bool firewall_is_client_allowed(uint32_t client_ip) 173bool firewall_is_client_allowed(uint32_t client_ip)
103{ 174{
104 for (int i = 0; i < s_client_count; i++) { 175 return find_client_by_ip(client_ip) != NULL;
105 if (s_clients[i].ip == client_ip) return true; 176}
106 } 177
107 return false; 178bool firewall_is_mac_allowed(const char *mac)
179{
180 return find_client_by_mac(mac) != NULL;
108} 181}
109 182
110int firewall_client_count(void) 183int firewall_client_count(void)
diff --git a/main/firewall.h b/main/firewall.h
index 91a89b0..e5d492a 100644
--- a/main/firewall.h
+++ b/main/firewall.h
@@ -6,6 +6,8 @@
6#include <stdbool.h> 6#include <stdbool.h>
7#include <stdint.h> 7#include <stdint.h>
8 8
9#define FW_MAX_MAC_LEN 18
10
9esp_err_t firewall_init(esp_ip4_addr_t ap_ip); 11esp_err_t firewall_init(esp_ip4_addr_t ap_ip);
10void firewall_enable_nat(void); 12void firewall_enable_nat(void);
11void firewall_disable_nat(void); 13void firewall_disable_nat(void);
@@ -13,6 +15,9 @@ void firewall_grant_access(uint32_t client_ip);
13void firewall_revoke_access(uint32_t client_ip); 15void firewall_revoke_access(uint32_t client_ip);
14void firewall_revoke_all(void); 16void firewall_revoke_all(void);
15bool firewall_is_client_allowed(uint32_t client_ip); 17bool firewall_is_client_allowed(uint32_t client_ip);
18bool firewall_is_mac_allowed(const char *mac);
16int firewall_client_count(void); 19int firewall_client_count(void);
17 20
21esp_err_t firewall_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size);
22
18#endif 23#endif
diff --git a/main/session.c b/main/session.c
index 6d9c334..5d2efee 100644
--- a/main/session.c
+++ b/main/session.c
@@ -29,6 +29,13 @@ esp_err_t session_manager_init(void)
29 return ESP_OK; 29 return ESP_OK;
30} 30}
31 31
32static void populate_mac(session_t *session, uint32_t client_ip)
33{
34 if (firewall_get_mac_for_ip(client_ip, session->mac, sizeof(session->mac)) != ESP_OK) {
35 session->mac[0] = '\0';
36 }
37}
38
32session_t *session_create(uint32_t client_ip, uint64_t allotment_ms, 39session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
33 const char *spent_secrets[], int secret_count) 40 const char *spent_secrets[], int secret_count)
34{ 41{
@@ -59,6 +66,7 @@ session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
59 s_sessions[i].start_time_ms = get_time_ms(); 66 s_sessions[i].start_time_ms = get_time_ms();
60 s_sessions[i].active = true; 67 s_sessions[i].active = true;
61 s_sessions[i].spent_secret_count = 0; 68 s_sessions[i].spent_secret_count = 0;
69 populate_mac(&s_sessions[i], client_ip);
62 70
63 for (int j = 0; j < secret_count && j < 5; j++) { 71 for (int j = 0; j < secret_count && j < 5; j++) {
64 strncpy(s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count], 72 strncpy(s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count],
@@ -77,7 +85,8 @@ session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
77 firewall_grant_access(client_ip); 85 firewall_grant_access(client_ip);
78 86
79 esp_ip4_addr_t ip = { .addr = client_ip }; 87 esp_ip4_addr_t ip = { .addr = client_ip };
80 ESP_LOGI(TAG, "Session created: " IPSTR " allotment=%llums", IP2STR(&ip), 88 ESP_LOGI(TAG, "Session created: " IPSTR " mac=%s allotment=%llums", IP2STR(&ip),
89 s_sessions[i].mac[0] ? s_sessions[i].mac : "unknown",
81 (unsigned long long)allotment_ms); 90 (unsigned long long)allotment_ms);
82 return &s_sessions[i]; 91 return &s_sessions[i];
83 } 92 }
@@ -97,6 +106,17 @@ session_t *session_find_by_ip(uint32_t client_ip)
97 return NULL; 106 return NULL;
98} 107}
99 108
109session_t *session_find_by_mac(const char *mac)
110{
111 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
112 if (s_sessions[i].active && s_sessions[i].mac[0] != '\0' &&
113 strcmp(s_sessions[i].mac, mac) == 0) {
114 return &s_sessions[i];
115 }
116 }
117 return NULL;
118}
119
100void session_extend(session_t *session, uint64_t additional_ms) 120void session_extend(session_t *session, uint64_t additional_ms)
101{ 121{
102 if (!session || !session->active) return; 122 if (!session || !session->active) return;
@@ -126,7 +146,8 @@ void session_check_expiry(void)
126 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { 146 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
127 if (s_sessions[i].active && session_is_expired(&s_sessions[i])) { 147 if (s_sessions[i].active && session_is_expired(&s_sessions[i])) {
128 esp_ip4_addr_t ip = { .addr = s_sessions[i].client_ip }; 148 esp_ip4_addr_t ip = { .addr = s_sessions[i].client_ip };
129 ESP_LOGI(TAG, "Session expired: " IPSTR, IP2STR(&ip)); 149 ESP_LOGI(TAG, "Session expired: " IPSTR " mac=%s", IP2STR(&ip),
150 s_sessions[i].mac[0] ? s_sessions[i].mac : "unknown");
130 session_revoke(&s_sessions[i]); 151 session_revoke(&s_sessions[i]);
131 } 152 }
132 } 153 }
diff --git a/main/session.h b/main/session.h
index e7d78d4..8e2d48d 100644
--- a/main/session.h
+++ b/main/session.h
@@ -24,6 +24,7 @@ session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
24 const char *spent_secrets[], int secret_count); 24 const char *spent_secrets[], int secret_count);
25 25
26session_t *session_find_by_ip(uint32_t client_ip); 26session_t *session_find_by_ip(uint32_t client_ip);
27session_t *session_find_by_mac(const char *mac);
27 28
28void session_extend(session_t *session, uint64_t additional_ms); 29void session_extend(session_t *session, uint64_t additional_ms);
29 30
diff --git a/main/tollgate_api.c b/main/tollgate_api.c
index 2af04bc..efb5cdf 100644
--- a/main/tollgate_api.c
+++ b/main/tollgate_api.c
@@ -2,10 +2,12 @@
2#include "cashu.h" 2#include "cashu.h"
3#include "config.h" 3#include "config.h"
4#include "session.h" 4#include "session.h"
5#include "firewall.h"
5#include "esp_log.h" 6#include "esp_log.h"
6#include "cJSON.h" 7#include "cJSON.h"
7#include "lwip/sockets.h" 8#include "lwip/sockets.h"
8#include "lwip/netdb.h" 9#include "lwip/netdb.h"
10#include "freertos/task.h"
9#include <string.h> 11#include <string.h>
10 12
11static const char *TAG = "tollgate_api"; 13static const char *TAG = "tollgate_api";
@@ -179,11 +181,22 @@ static esp_err_t api_post_payment(httpd_req_t *req)
179 181
180 ESP_LOGI(TAG, "Payment received: %d bytes", total); 182 ESP_LOGI(TAG, "Payment received: %d bytes", total);
181 183
182 cashu_token_t token; 184 cashu_token_t *token = malloc(sizeof(cashu_token_t));
183 esp_err_t err = cashu_decode_token(body, &token); 185 if (!token) {
186 cJSON *notice = create_notice("error", "session-error", "Out of memory");
187 char *json = cJSON_PrintUnformatted(notice);
188 httpd_resp_set_status(req, "503 Service Unavailable");
189 httpd_resp_set_type(req, "application/json");
190 httpd_resp_send(req, json, strlen(json));
191 cJSON_free(json);
192 cJSON_Delete(notice);
193 return ESP_OK;
194 }
195 esp_err_t err = cashu_decode_token(body, token);
184 free(body); 196 free(body);
185 197
186 if (err != ESP_OK) { 198 if (err != ESP_OK) {
199 free(token);
187 cJSON *notice = create_notice("error", "payment-error-invalid", "Failed to decode Cashu token"); 200 cJSON *notice = create_notice("error", "payment-error-invalid", "Failed to decode Cashu token");
188 char *json = cJSON_PrintUnformatted(notice); 201 char *json = cJSON_PrintUnformatted(notice);
189 httpd_resp_set_status(req, "400 Bad Request"); 202 httpd_resp_set_status(req, "400 Bad Request");
@@ -194,8 +207,9 @@ static esp_err_t api_post_payment(httpd_req_t *req)
194 return ESP_OK; 207 return ESP_OK;
195 } 208 }
196 209
197 const char *mint_url = token.mint_url[0] ? token.mint_url : tollgate_config_get()->mint_url; 210 const char *mint_url = token->mint_url[0] ? token->mint_url : tollgate_config_get()->mint_url;
198 if (!cashu_is_mint_accepted(mint_url)) { 211 if (!cashu_is_mint_accepted(mint_url)) {
212 free(token);
199 cJSON *notice = create_notice("error", "payment-error-mint-not-accepted", "Mint not accepted"); 213 cJSON *notice = create_notice("error", "payment-error-mint-not-accepted", "Mint not accepted");
200 char *json = cJSON_PrintUnformatted(notice); 214 char *json = cJSON_PrintUnformatted(notice);
201 httpd_resp_set_status(req, "402 Payment Required"); 215 httpd_resp_set_status(req, "402 Payment Required");
@@ -206,8 +220,9 @@ static esp_err_t api_post_payment(httpd_req_t *req)
206 return ESP_OK; 220 return ESP_OK;
207 } 221 }
208 222
209 for (int i = 0; i < token.proof_count; i++) { 223 for (int i = 0; i < token->proof_count; i++) {
210 if (session_is_secret_spent(token.proofs[i].secret)) { 224 if (session_is_secret_spent(token->proofs[i].secret)) {
225 free(token);
211 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent"); 226 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent");
212 char *json = cJSON_PrintUnformatted(notice); 227 char *json = cJSON_PrintUnformatted(notice);
213 httpd_resp_set_status(req, "402 Payment Required"); 228 httpd_resp_set_status(req, "402 Payment Required");
@@ -219,10 +234,24 @@ static esp_err_t api_post_payment(httpd_req_t *req)
219 } 234 }
220 } 235 }
221 236
222 cashu_proof_state_t states[CASHU_MAX_PROOFS]; 237 cashu_proof_state_t *states = malloc(CASHU_MAX_PROOFS * sizeof(cashu_proof_state_t));
238 if (!states) {
239 free(token);
240 cJSON *notice = create_notice("error", "session-error", "Out of memory");
241 char *json = cJSON_PrintUnformatted(notice);
242 httpd_resp_set_status(req, "503 Service Unavailable");
243 httpd_resp_set_type(req, "application/json");
244 httpd_resp_send(req, json, strlen(json));
245 cJSON_free(json);
246 cJSON_Delete(notice);
247 return ESP_OK;
248 }
223 int state_count = 0; 249 int state_count = 0;
224 err = cashu_check_proof_states(mint_url, &token, states, &state_count); 250 err = cashu_check_proof_states(mint_url, token, states, &state_count);
251 ESP_LOGI(TAG, "Stack HWM after checkstate: %u", uxTaskGetStackHighWaterMark(NULL));
225 if (err != ESP_OK) { 252 if (err != ESP_OK) {
253 free(states);
254 free(token);
226 cJSON *notice = create_notice("error", "payment-error-verification", "Failed to verify token with mint"); 255 cJSON *notice = create_notice("error", "payment-error-verification", "Failed to verify token with mint");
227 char *json = cJSON_PrintUnformatted(notice); 256 char *json = cJSON_PrintUnformatted(notice);
228 httpd_resp_set_status(req, "502 Bad Gateway"); 257 httpd_resp_set_status(req, "502 Bad Gateway");
@@ -235,6 +264,8 @@ static esp_err_t api_post_payment(httpd_req_t *req)
235 264
236 for (int i = 0; i < state_count; i++) { 265 for (int i = 0; i < state_count; i++) {
237 if (states[i].spent) { 266 if (states[i].spent) {
267 free(states);
268 free(token);
238 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent"); 269 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent");
239 char *json = cJSON_PrintUnformatted(notice); 270 char *json = cJSON_PrintUnformatted(notice);
240 httpd_resp_set_status(req, "402 Payment Required"); 271 httpd_resp_set_status(req, "402 Payment Required");
@@ -247,8 +278,10 @@ static esp_err_t api_post_payment(httpd_req_t *req)
247 } 278 }
248 279
249 const tollgate_config_t *cfg = tollgate_config_get(); 280 const tollgate_config_t *cfg = tollgate_config_get();
250 uint64_t allotment = cashu_calculate_allotment_ms(token.total_amount, cfg->price_per_step, cfg->step_size_ms); 281 uint64_t allotment = cashu_calculate_allotment_ms(token->total_amount, cfg->price_per_step, cfg->step_size_ms);
251 if (allotment == 0) { 282 if (allotment == 0) {
283 free(states);
284 free(token);
252 cJSON *notice = create_notice("error", "payment-error-insufficient", "Token value too low"); 285 cJSON *notice = create_notice("error", "payment-error-insufficient", "Token value too low");
253 char *json = cJSON_PrintUnformatted(notice); 286 char *json = cJSON_PrintUnformatted(notice);
254 httpd_resp_set_status(req, "402 Payment Required"); 287 httpd_resp_set_status(req, "402 Payment Required");
@@ -259,11 +292,14 @@ static esp_err_t api_post_payment(httpd_req_t *req)
259 return ESP_OK; 292 return ESP_OK;
260 } 293 }
261 294
295 int secret_count = token->proof_count > 5 ? 5 : token->proof_count;
262 const char *secrets[5]; 296 const char *secrets[5];
263 for (int i = 0; i < token.proof_count && i < 5; i++) { 297 for (int i = 0; i < secret_count; i++) {
264 secrets[i] = token.proofs[i].secret; 298 secrets[i] = token->proofs[i].secret;
265 } 299 }
266 session_t *session = session_create(client_ip, allotment, secrets, token.proof_count); 300 session_t *session = session_create(client_ip, allotment, secrets, secret_count);
301 free(states);
302 free(token);
267 if (!session) { 303 if (!session) {
268 cJSON *notice = create_notice("error", "session-error", "Failed to create session"); 304 cJSON *notice = create_notice("error", "session-error", "Failed to create session");
269 char *json = cJSON_PrintUnformatted(notice); 305 char *json = cJSON_PrintUnformatted(notice);
@@ -310,12 +346,17 @@ static esp_err_t api_get_usage(httpd_req_t *req)
310static esp_err_t api_get_whoami(httpd_req_t *req) 346static esp_err_t api_get_whoami(httpd_req_t *req)
311{ 347{
312 uint32_t client_ip = 0; 348 uint32_t client_ip = 0;
313 char resp[64]; 349 char resp[96];
314 if (get_client_ip(req, &client_ip) == ESP_OK) { 350 if (get_client_ip(req, &client_ip) == ESP_OK) {
351 char mac[18] = {0};
315 esp_ip4_addr_t ip = { .addr = client_ip }; 352 esp_ip4_addr_t ip = { .addr = client_ip };
316 snprintf(resp, sizeof(resp), "mac=" IPSTR, IP2STR(&ip)); 353 if (firewall_get_mac_for_ip(client_ip, mac, sizeof(mac)) == ESP_OK) {
354 snprintf(resp, sizeof(resp), "ip=" IPSTR " mac=%s", IP2STR(&ip), mac);
355 } else {
356 snprintf(resp, sizeof(resp), "ip=" IPSTR " mac=unknown", IP2STR(&ip));
357 }
317 } else { 358 } else {
318 snprintf(resp, sizeof(resp), "mac=unknown"); 359 snprintf(resp, sizeof(resp), "ip=unknown mac=unknown");
319 } 360 }
320 httpd_resp_set_type(req, "text/plain"); 361 httpd_resp_set_type(req, "text/plain");
321 httpd_resp_send(req, resp, strlen(resp)); 362 httpd_resp_send(req, resp, strlen(resp));
diff --git a/tests/phase2.mjs b/tests/phase2.mjs
index 3136da3..fa29337 100644
--- a/tests/phase2.mjs
+++ b/tests/phase2.mjs
@@ -72,9 +72,22 @@ if (TEST_TOKEN) {
72 72
73 // Test 18: Internet after payment 73 // Test 18: Internet after payment
74 console.log('\nTest 18: Internet works after payment'); 74 console.log('\nTest 18: Internet works after payment');
75 await sleep(1000); 75 await sleep(1500);
76 const ping18 = execSync('ping -c 2 -W 2 -I wlp59s0 8.8.8.8', { encoding: 'utf8', timeout: 10000 }); 76 const sudoPw = process.env.SUDO_PW || 'c03rad0r123';
77 assert(ping18 && !ping18.includes('100% packet loss'), 'Internet works'); 77 try {
78 execSync(`echo '${sudoPw}' | sudo -S ip route add default via 192.168.4.1 dev wlp59s0 metric 50 2>/dev/null`, { encoding: 'utf8', timeout: 5000 });
79 } catch {}
80 let pingOk = false;
81 try {
82 const ping18 = execSync('ping -c 3 -W 3 8.8.8.8', { encoding: 'utf8', timeout: 15000 });
83 pingOk = ping18 && !ping18.includes('100% packet loss');
84 } catch {
85 pingOk = false;
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');
78 91
79 // Test 20: Spent token 92 // Test 20: Spent token
80 console.log('\nTest 20: Reuse token (should fail)'); 93 console.log('\nTest 20: Reuse token (should fail)');
@@ -88,7 +101,7 @@ if (TEST_TOKEN) {
88// Test: whoami on :2121 101// Test: whoami on :2121
89console.log('\nTest: GET :2121/whoami'); 102console.log('\nTest: GET :2121/whoami');
90const bodyWhoami = curlBody(`${API}/whoami`); 103const bodyWhoami = curlBody(`${API}/whoami`);
91assert(bodyWhoami && bodyWhoami.startsWith('mac='), '/whoami returns mac=...'); 104assert(bodyWhoami && bodyWhoami.includes('mac='), '/whoami returns mac=...');
92 105
93// Test: Portal has payment form 106// Test: Portal has payment form
94console.log('\nTest: Portal has payment form'); 107console.log('\nTest: Portal has payment form');