diff options
| author | Your Name <you@example.com> | 2026-05-16 04:46:32 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-16 04:46:32 +0530 |
| commit | 50b5975ac8793d6d820c35b5999f8a909f64e71b (patch) | |
| tree | 2592f9e7a671af2aca56e46887e50b8ad8e418b6 | |
| parent | 3f46bb83cb1041889034c88adce1895dd330793f (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.md | 70 | ||||
| -rw-r--r-- | PLAN.md | 98 | ||||
| -rw-r--r-- | main/captive_portal.c | 50 | ||||
| -rw-r--r-- | main/dns_server.c | 61 | ||||
| -rw-r--r-- | main/firewall.c | 93 | ||||
| -rw-r--r-- | main/firewall.h | 5 | ||||
| -rw-r--r-- | main/session.c | 25 | ||||
| -rw-r--r-- | main/session.h | 1 | ||||
| -rw-r--r-- | main/tollgate_api.c | 69 | ||||
| -rw-r--r-- | tests/phase2.mjs | 21 |
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 |
| 62 | 1. 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) |
| 63 | 2. 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) |
| 64 | 3. 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 |
| 65 | 4. 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.) |
| 66 | 5. Mint a test token: `make mint-token AMOUNT=21` | 77 | - [x] HTTP request logging for captive detection endpoints |
| 67 | 6. 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 |
| 68 | 7. 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 | ||
| @@ -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 | ||
| 106 | static esp_err_t portal_handler(httpd_req_t *req) | 111 | static 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) | |||
| 139 | static esp_err_t whoami_handler(httpd_req_t *req) | 145 | static 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 | ||
| 188 | static 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 | |||
| 177 | static esp_err_t catchall_handler(httpd_req_t *req) | 198 | static 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 | |||
| 191 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; | 211 | static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = whoami_handler }; |
| 192 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; | 212 | static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = usage_handler }; |
| 193 | static const httpd_uri_t uri_reset = { .uri = "/reset_authentication", .method = HTTP_GET, .handler = reset_auth_handler }; | 213 | static const httpd_uri_t uri_reset = { .uri = "/reset_authentication", .method = HTTP_GET, .handler = reset_auth_handler }; |
| 214 | static const httpd_uri_t uri_gen204 = { .uri = "/generate_204", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 215 | static const httpd_uri_t uri_hotspot = { .uri = "/hotspot-detect.html", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 216 | static const httpd_uri_t uri_canonical = { .uri = "/canonical.html", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 217 | static const httpd_uri_t uri_success = { .uri = "/success.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 218 | static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 219 | static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 220 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | ||
| 194 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; | 221 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; |
| 195 | 222 | ||
| 196 | esp_err_t captive_portal_start(void) | 223 | esp_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 | ||
| 19 | static const char *TAG = "dns_server"; | 23 | static const char *TAG = "dns_server"; |
| 20 | 24 | ||
| @@ -47,6 +51,7 @@ typedef struct { | |||
| 47 | static auth_entry_t s_auth_list[MAX_AUTH_IPS]; | 51 | static auth_entry_t s_auth_list[MAX_AUTH_IPS]; |
| 48 | static int s_auth_count = 0; | 52 | static int s_auth_count = 0; |
| 49 | static TaskHandle_t s_dns_task = NULL; | 53 | static TaskHandle_t s_dns_task = NULL; |
| 54 | static TaskHandle_t s_dot_task = NULL; | ||
| 50 | static volatile bool s_dns_running = false; | 55 | static volatile bool s_dns_running = false; |
| 51 | static esp_ip4_addr_t s_ap_ip; | 56 | static esp_ip4_addr_t s_ap_ip; |
| 52 | static esp_ip4_addr_t s_upstream_dns; | 57 | static 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 | ||
| 233 | static 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 | |||
| 230 | esp_err_t dns_server_start(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns) | 276 | esp_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 | ||
| 13 | typedef struct { | 17 | typedef 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 | ||
| 17 | static fw_client_t s_clients[MAX_CLIENTS]; | 22 | static fw_client_t s_clients[MAX_CLIENTS]; |
| 18 | static int s_client_count = 0; | 23 | static int s_client_count = 0; |
| 19 | 24 | ||
| 25 | static struct netif *get_ap_netif(void) | ||
| 26 | { | ||
| 27 | return netif_get_by_index(NETIF_NO_INDEX); | ||
| 28 | } | ||
| 29 | |||
| 30 | esp_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 | |||
| 20 | esp_err_t firewall_init(esp_ip4_addr_t ap_ip) | 64 | esp_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 | ||
| 59 | void firewall_grant_access(uint32_t client_ip) | 103 | static 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 | |||
| 111 | static 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 | |||
| 121 | void 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 | ||
| 77 | void firewall_revoke_access(uint32_t client_ip) | 147 | void 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 | ||
| 102 | bool firewall_is_client_allowed(uint32_t client_ip) | 173 | bool 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; | 178 | bool firewall_is_mac_allowed(const char *mac) |
| 179 | { | ||
| 180 | return find_client_by_mac(mac) != NULL; | ||
| 108 | } | 181 | } |
| 109 | 182 | ||
| 110 | int firewall_client_count(void) | 183 | int 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 | |||
| 9 | esp_err_t firewall_init(esp_ip4_addr_t ap_ip); | 11 | esp_err_t firewall_init(esp_ip4_addr_t ap_ip); |
| 10 | void firewall_enable_nat(void); | 12 | void firewall_enable_nat(void); |
| 11 | void firewall_disable_nat(void); | 13 | void firewall_disable_nat(void); |
| @@ -13,6 +15,9 @@ void firewall_grant_access(uint32_t client_ip); | |||
| 13 | void firewall_revoke_access(uint32_t client_ip); | 15 | void firewall_revoke_access(uint32_t client_ip); |
| 14 | void firewall_revoke_all(void); | 16 | void firewall_revoke_all(void); |
| 15 | bool firewall_is_client_allowed(uint32_t client_ip); | 17 | bool firewall_is_client_allowed(uint32_t client_ip); |
| 18 | bool firewall_is_mac_allowed(const char *mac); | ||
| 16 | int firewall_client_count(void); | 19 | int firewall_client_count(void); |
| 17 | 20 | ||
| 21 | esp_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 | ||
| 32 | static 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 | |||
| 32 | session_t *session_create(uint32_t client_ip, uint64_t allotment_ms, | 39 | session_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 | ||
| 109 | session_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 | |||
| 100 | void session_extend(session_t *session, uint64_t additional_ms) | 120 | void 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 | ||
| 26 | session_t *session_find_by_ip(uint32_t client_ip); | 26 | session_t *session_find_by_ip(uint32_t client_ip); |
| 27 | session_t *session_find_by_mac(const char *mac); | ||
| 27 | 28 | ||
| 28 | void session_extend(session_t *session, uint64_t additional_ms); | 29 | void 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 | ||
| 11 | static const char *TAG = "tollgate_api"; | 13 | static 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) | |||
| 310 | static esp_err_t api_get_whoami(httpd_req_t *req) | 346 | static 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 |
| 89 | console.log('\nTest: GET :2121/whoami'); | 102 | console.log('\nTest: GET :2121/whoami'); |
| 90 | const bodyWhoami = curlBody(`${API}/whoami`); | 103 | const bodyWhoami = curlBody(`${API}/whoami`); |
| 91 | assert(bodyWhoami && bodyWhoami.startsWith('mac='), '/whoami returns mac=...'); | 104 | assert(bodyWhoami && bodyWhoami.includes('mac='), '/whoami returns mac=...'); |
| 92 | 105 | ||
| 93 | // Test: Portal has payment form | 106 | // Test: Portal has payment form |
| 94 | console.log('\nTest: Portal has payment form'); | 107 | console.log('\nTest: Portal has payment form'); |