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-20 02:10:01 +0530
committerYour Name <you@example.com>2026-05-20 02:10:01 +0530
commit82f1fc0d5535eda3fc9eab799d81b3e220dbe4ef (patch)
tree341dcecb0a87a6219bc51d424316dfadcf69bf65
parent2c12c4281c47aa87a1c7bb82abe09bf9dbc788c3 (diff)
feat: add tollgate_core component + market config wiring
- Add tollgate_core ESP-IDF component (skeleton: cashu, dns, firewall, session) - Add tollgate_platform.c with SPIFFS config backend - Wire market_enabled, market_scan_interval_s, client_auto_switch in config.c - Add lwip_tollgate_hooks.h (updated from feature branch) - Add E2E fix plan, tollgate_core design doc, WPA autodetect plan - Add integration test network helpers - Add CONSOLIDATION.md plan Reverts the broken merge (be4788b) that gutted config.c/tollgate_main.c/tollgate_api.c and replaces it with a clean addition on top of intact master.
-rw-r--r--CONSOLIDATION.md158
-rw-r--r--components/tollgate_core/CMakeLists.txt9
-rw-r--r--components/tollgate_core/idf_component.yml6
-rw-r--r--components/tollgate_core/include/tollgate_core.h34
-rw-r--r--components/tollgate_core/include/tollgate_platform.h17
-rw-r--r--components/tollgate_core/src/tollgate_core.c236
-rw-r--r--components/tollgate_core/src/tollgate_core_cashu.c262
-rw-r--r--components/tollgate_core/src/tollgate_core_cashu.h42
-rw-r--r--components/tollgate_core/src/tollgate_core_dns.c316
-rw-r--r--components/tollgate_core/src/tollgate_core_dns.h13
-rw-r--r--components/tollgate_core/src/tollgate_core_firewall.c165
-rw-r--r--components/tollgate_core/src/tollgate_core_firewall.h25
-rw-r--r--components/tollgate_core/src/tollgate_core_session.c200
-rw-r--r--components/tollgate_core/src/tollgate_core_session.h47
-rw-r--r--docs/E2E_FIX_PLAN.md177
-rw-r--r--docs/TOLLGATE_CORE_DESIGN.md446
-rw-r--r--docs/WPA_AUTODETECT_PLAN.md102
-rw-r--r--main/CMakeLists.txt3
-rw-r--r--main/config.c12
-rw-r--r--main/lwip_tollgate_hooks.h4
-rw-r--r--main/tollgate_platform.c63
-rw-r--r--tests/integration/helpers/network.mjs67
22 files changed, 2401 insertions, 3 deletions
diff --git a/CONSOLIDATION.md b/CONSOLIDATION.md
new file mode 100644
index 0000000..5180987
--- /dev/null
+++ b/CONSOLIDATION.md
@@ -0,0 +1,158 @@
1# Consolidation Plan
2
3Merge all feature branches into master, establish tollgate_core as the single source of truth,
4and clean up worktrees/stashes/stale branches.
5
6## End State
7
8```
9esp32-tollgate (master only)
10 main/ = thin shell calling tollgate_core API
11 components/tollgate_core/ = THE reusable core (cashu, dns, firewall, session, mining, stratum)
12
13esp-miner-nerdqaxeplus (develop)
14 references tollgate_core via IDF Component Manager (override_path for local dev)
15
16esp-miner (master)
17 references tollgate_core via IDF Component Manager (after its feature/tollgate-component-integration)
18```
19
20---
21
22## Phase 1: Preserve Uncommitted Work
23
24- [ ] **1.1** Apply stash@{1} to master (AP-only services, market config wiring, sta_connecting guard)
25- [ ] **1.2** Commit: "feat: AP-only services startup + market config wiring (from stash)"
26- [ ] **1.3** Drop stashes 0, 2, 3 (compiled binaries only, no source)
27- [ ] **1.4** Verify master builds: `idf.py build`
28- [ ] **1.5** Verify unit tests pass: `make test-unit`
29- [ ] **1.6** Commit + push
30
31## Phase 2: Merge feature/display-fix (touch + WiFi setup UI)
32
33- [ ] **2.1** Verify feature/display-fix has no uncommitted source changes (only binaries — confirmed)
34- [ ] **2.2** Squash-merge feature/display-fix into master
35- [ ] **2.3** Resolve any conflicts (display.c/font.c may conflict with master versions)
36- [ ] **2.4** Verify build: `idf.py build`
37- [ ] **2.5** Verify unit tests: `make test-unit` (should pick up test_touch, test_keyboard, test_wifi_setup)
38- [ ] **2.6** Commit + push
39
40## Phase 3: Merge feature/miner-integration (full tollgate_core)
41
42This is the critical merge. Master has the OLD tollgate_core skeleton (9 files, 7 callbacks).
43feature/miner-integration has the FULL version (13 files, 22 callbacks, extern C, mining + stratum).
44We need to take the miner-integration version of tollgate_core entirely.
45
46- [ ] **3.1** Verify feature/miner-integration has no uncommitted source changes (only binaries — confirmed)
47- [ ] **3.2** Checkout feature/miner-integration version of tollgate_core into master:
48 - Replace `components/tollgate_core/` entirely with the miner-integration version
49 - This adds: tollgate_core_mining.c/h, tollgate_core_stratum_proxy.c/h
50 - This updates: tollgate_core.h (extern C + 5 new mining API functions)
51 - This updates: tollgate_platform.h (extern C + 14 new platform callbacks for mining/stratum)
52 - This updates: tollgate_core_firewall.c (conditional NAPT)
53 - This updates: CMakeLists.txt (mining + stratum source files)
54- [ ] **3.3** Commit: "feat: upgrade tollgate_core to full version with mining + stratum + extern C"
55- [ ] **3.4** Merge remaining docs from feature/miner-integration (REMOTES.md, MINER_INTEGRATION_PLAN.md)
56- [ ] **3.5** Verify build: `idf.py build`
57- [ ] **3.6** Verify unit tests: `make test-unit`
58- [ ] **3.7** Commit + push
59
60## Phase 4: Restructure — Option B (main/ calls tollgate_core API)
61
62Move module logic from main/ into components/tollgate_core/, making main/ a thin shell.
63
64- [ ] **4.1** Identify modules in main/ that have tollgate_core equivalents:
65 - cashu.c/h → tollgate_core_cashu.c/h
66 - dns_server.c/h → tollgate_core_dns.c/h
67 - firewall.c/h → tollgate_core_firewall.c/h
68 - session.c/h → tollgate_core_session.c/h
69 - mining_payment.c/h → tollgate_core_mining.c/h
70 - stratum_proxy.c/h → tollgate_core_stratum_proxy.c/h
71- [ ] **4.2** For each module: replace main/*.c implementation with calls to tollgate_core API
72 - Keep the main/*.c files as thin wrappers that delegate to tollgate_core
73 - OR remove them entirely if tollgate_core API is called directly from tollgate_main.c
74- [ ] **4.3** Update main/CMakeLists.txt — remove replaced source files from SRCS
75- [ ] **4.4** Verify build: `idf.py build`
76- [ ] **4.5** Verify unit tests: `make test-unit`
77- [ ] **4.6** Run integration tests if hardware available: `make test-integration`
78- [ ] **4.7** Commit: "refactor: main/ delegates to tollgate_core component"
79- [ ] **4.8** Push
80
81## Phase 5: Wire up NerdQAxePlus via Component Manager
82
83- [ ] **5.1** Create main/idf_component.yml in NerdQAxePlus:
84 ```yaml
85 dependencies:
86 tollgate_core:
87 override_path: /home/c03rad0r/esp32-tollgate/components/tollgate_core
88 ```
89- [ ] **5.2** Remove symlink: rm esp-miner-nerdqaxeplus/components/tollgate_core
90- [ ] **5.3** Update main/CMakeLists.txt — remove hardcoded include path, use component dependency
91- [ ] **5.4** Verify build: `BOARD=NERDAXE TOLLGATE=1 idf.py build`
92- [ ] **5.5** Commit + push
93
94## Phase 6: Delete Branches & Worktrees
95
96- [ ] **6.1** Delete feature/tollgate-core-component branch (merged in commit be4788b)
97- [ ] **6.2** Delete backup/multi-mint-support-pre-rebase branch (fully in master)
98- [ ] **6.3** Delete feature/display-fix branch (merged in Phase 2)
99- [ ] **6.4** Delete feature/miner-integration branch (merged in Phase 3)
100- [ ] **6.5** Remove worktree: esp32-miner-integration/
101- [ ] **6.6** Remove worktree: esp32-tollgate-arch/
102- [ ] **6.7** Remove worktree: esp32-tollgate-display/
103- [ ] **6.8** Clean up test binaries from git tracking: `git rm --cached tests/unit/test_*` (keep in .gitignore)
104- [ ] **6.9** Refresh backup bundles
105- [ ] **6.10** Push everything
106
107## Phase 7: Update esp-miner (optional, after consolidation)
108
109- [ ] **7.1** Switch esp-miner from flat-file modules to tollgate_core via Component Manager
110- [ ] **7.2** Create main/idf_component.yml with git dependency on esp32-tollgate
111- [ ] **7.3** Delete flat-file TollGate modules from main/ (tollgate_cashu/dns/firewall/session.c/h)
112- [ ] **7.4** Create main/tollgate_platform.c implementing tollgate_platform_t with NVS backend
113- [ ] **7.5** Verify build
114- [ ] **7.6** Commit + push
115
116---
117
118## Current State Summary
119
120### Repos
121
122| Repo | Path | Branch | HEAD | Status |
123|------|------|--------|------|--------|
124| esp32-tollgate | /home/c03rad0r/esp32-tollgate | master | 897a8d9 | Canonical source |
125| esp-miner-nerdqaxeplus | /home/c03rad0r/esp-miner-nerdqaxeplus | develop | 588c9f67 | Consumer |
126| esp-miner | /home/c03rad0r/esp-miner | master | a8dc494 | Consumer (Phase 1 done) |
127
128### Branches (esp32-tollgate)
129
130| Branch | Worktree | Merged? | Unique Content | Action |
131|--------|----------|---------|----------------|--------|
132| master | esp32-tollgate | — | Current production code | Keep |
133| feature/display-fix | esp32-tollgate-display | NO | touch.c/h, keyboard.c/h, wifi_setup.c/h, 4 unit tests, E2E test, WiFi QR, error state | Merge (Phase 2) |
134| feature/miner-integration | esp32-miner-integration | PARTIAL | Full tollgate_core (mining+stratum+extern C+22 callbacks), docs | Merge (Phase 3) |
135| feature/tollgate-core-component | esp32-tollgate-arch | YES (be4788b) | None — superseded by miner-integration | Delete (Phase 6) |
136| backup/multi-mint-support-pre-rebase | (none) | YES | None | Delete (Phase 6) |
137
138### Stashes (esp32-tollgate)
139
140| Stash | Content | Action |
141|-------|---------|--------|
142| stash@{0} | Recompiled test binaries (no source changes) | Drop |
143| stash@{1} | AP-only services, market config wiring, sta_connecting guard | Apply (Phase 1) |
144| stash@{2} | Recompiled test binaries (no source changes) | Drop |
145| stash@{3} | Recompiled test binaries (no source changes) | Drop |
146
147### Tollgate Core Divergence
148
149| File | Master (old skeleton) | Miner-integration (full) |
150|------|----------------------|--------------------------|
151| tollgate_core.h | 34 lines, no extern C, no mining API | 48 lines, extern C, 5 mining functions |
152| tollgate_platform.h | 17 lines, 7 callbacks | 37 lines, extern C, 22 callbacks |
153| CMakeLists.txt | 9 source files (no mining/stratum) | 11 source files (mining+stratum) |
154| tollgate_core_firewall.c | unconditional NAPT | conditional CONFIG_LWIP_IPV4_NAPT |
155| tollgate_core_mining.c | MISSING | Present (168 lines) |
156| tollgate_core_mining.h | MISSING | Present (35 lines) |
157| tollgate_core_stratum_proxy.c | MISSING | Present (160 lines) |
158| tollgate_core_stratum_proxy.h | MISSING | Present (39 lines) |
diff --git a/components/tollgate_core/CMakeLists.txt b/components/tollgate_core/CMakeLists.txt
new file mode 100644
index 0000000..fc6b6f1
--- /dev/null
+++ b/components/tollgate_core/CMakeLists.txt
@@ -0,0 +1,9 @@
1idf_component_register(
2 SRCS "src/tollgate_core.c"
3 "src/tollgate_core_cashu.c"
4 "src/tollgate_core_dns.c"
5 "src/tollgate_core_firewall.c"
6 "src/tollgate_core_session.c"
7 INCLUDE_DIRS "include" "src"
8 REQUIRES lwip json esp_http_client mbedtls log esp_netif
9 PRIV_REQUIRES esp_wifi)
diff --git a/components/tollgate_core/idf_component.yml b/components/tollgate_core/idf_component.yml
new file mode 100644
index 0000000..112aa18
--- /dev/null
+++ b/components/tollgate_core/idf_component.yml
@@ -0,0 +1,6 @@
1version: "1.0.0"
2description: TollGate core component — Cashu payment processing, per-client DNS/firewall, session management for paid WiFi hotspots
3url: https://github.com/nicoulaj/esp32-tollgate
4dependencies:
5 idf:
6 version: ">=5.4.0"
diff --git a/components/tollgate_core/include/tollgate_core.h b/components/tollgate_core/include/tollgate_core.h
new file mode 100644
index 0000000..c47ebeb
--- /dev/null
+++ b/components/tollgate_core/include/tollgate_core.h
@@ -0,0 +1,34 @@
1#ifndef TOLLGATE_CORE_H
2#define TOLLGATE_CORE_H
3
4#include "tollgate_platform.h"
5#include "esp_err.h"
6#include "esp_netif.h"
7#include <stdbool.h>
8#include <stdint.h>
9
10esp_err_t tollgate_core_init(const tollgate_platform_t *platform, esp_ip4_addr_t ap_ip);
11
12esp_err_t tollgate_core_dns_start(esp_ip4_addr_t upstream_dns);
13void tollgate_core_dns_stop(void);
14
15esp_err_t tollgate_core_process_payment(uint32_t client_ip, const char *token_str);
16
17void tollgate_core_client_connected(const uint8_t *mac, uint32_t client_ip);
18void tollgate_core_client_disconnected(const uint8_t *mac);
19
20void tollgate_core_tick(void);
21
22bool tollgate_core_is_client_allowed(uint32_t client_ip);
23bool tollgate_core_is_dns_running(void);
24
25char *tollgate_core_get_status_json(void);
26char *tollgate_core_get_config_json(void);
27
28int tollgate_core_active_session_count(void);
29int tollgate_core_allowed_client_count(void);
30
31bool tollgate_core_is_owner(uint32_t client_ip);
32bool tollgate_core_is_owner_connected(void);
33
34#endif
diff --git a/components/tollgate_core/include/tollgate_platform.h b/components/tollgate_core/include/tollgate_platform.h
new file mode 100644
index 0000000..f60f1f9
--- /dev/null
+++ b/components/tollgate_core/include/tollgate_platform.h
@@ -0,0 +1,17 @@
1#ifndef TOLLGATE_PLATFORM_H
2#define TOLLGATE_PLATFORM_H
3
4#include <stdint.h>
5#include <stdbool.h>
6
7typedef struct {
8 uint16_t (*get_price_sats)(void);
9 int32_t (*get_step_ms)(void);
10 const char * (*get_mint_url)(void);
11 const char * (*get_metric)(void);
12 int32_t (*get_step_bytes)(void);
13 int64_t (*get_time_ms)(void);
14 bool (*spend_proofs)(const char *raw_token_json);
15} tollgate_platform_t;
16
17#endif
diff --git a/components/tollgate_core/src/tollgate_core.c b/components/tollgate_core/src/tollgate_core.c
new file mode 100644
index 0000000..a731f48
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core.c
@@ -0,0 +1,236 @@
1#include "tollgate_core.h"
2#include "tollgate_core_cashu.h"
3#include "tollgate_core_dns.h"
4#include "tollgate_core_firewall.h"
5#include "tollgate_core_session.h"
6#include "esp_log.h"
7#include "cJSON.h"
8#include <string.h>
9
10static const char *TAG = "tg_core";
11
12static const tollgate_platform_t *s_platform;
13static esp_ip4_addr_t s_ap_ip;
14
15static uint32_t s_owner_ip;
16static uint8_t s_owner_mac[6];
17static bool s_owner_connected;
18
19esp_err_t tollgate_core_init(const tollgate_platform_t *platform, esp_ip4_addr_t ap_ip)
20{
21 if (!platform) {
22 ESP_LOGE(TAG, "Platform callbacks required");
23 return ESP_FAIL;
24 }
25
26 s_platform = platform;
27 s_ap_ip = ap_ip;
28 s_owner_connected = false;
29 memset(s_owner_mac, 0, sizeof(s_owner_mac));
30
31 if (!platform->spend_proofs) {
32 ESP_LOGW(TAG, "spend_proofs is NULL — double-spend window open until wallet integrated");
33 }
34
35 tollgate_core_session_set_platform(platform);
36 tollgate_core_session_init();
37 tollgate_core_fw_init(ap_ip);
38
39 ESP_LOGI(TAG, "TollGate core initialized, AP IP=" IPSTR, IP2STR(&ap_ip));
40 return ESP_OK;
41}
42
43esp_err_t tollgate_core_dns_start(esp_ip4_addr_t upstream_dns)
44{
45 return tollgate_core_dns_start_internal(s_ap_ip, upstream_dns);
46}
47
48esp_err_t tollgate_core_process_payment(uint32_t client_ip, const char *token_str)
49{
50 if (!s_platform || !token_str) return ESP_FAIL;
51
52 const char *accepted_mint = s_platform->get_mint_url ? s_platform->get_mint_url() : NULL;
53 if (!accepted_mint || accepted_mint[0] == '\0') {
54 ESP_LOGE(TAG, "No mint URL configured");
55 return ESP_FAIL;
56 }
57
58 tg_cashu_token_t token;
59 esp_err_t ret = tollgate_core_cashu_decode_token(token_str, &token);
60 if (ret != ESP_OK) {
61 ESP_LOGE(TAG, "Token decode failed");
62 return ESP_FAIL;
63 }
64
65 if (!tollgate_core_cashu_is_mint_accepted(token.mint_url, accepted_mint)) {
66 ESP_LOGE(TAG, "Token mint '%s' not accepted (expected '%s')", token.mint_url, accepted_mint);
67 return ESP_FAIL;
68 }
69
70 tg_cashu_proof_state_t states[TG_CASHU_MAX_PROOFS];
71 int state_count = 0;
72 ret = tollgate_core_cashu_check_proof_states(token.mint_url, &token, states, &state_count);
73 if (ret != ESP_OK) {
74 ESP_LOGE(TAG, "Proof state check failed");
75 return ESP_FAIL;
76 }
77
78 for (int i = 0; i < state_count; i++) {
79 if (states[i].spent) {
80 ESP_LOGE(TAG, "Proof %d is SPENT — rejecting", i);
81 return ESP_FAIL;
82 }
83 }
84
85 if (s_platform->spend_proofs) {
86 if (!s_platform->spend_proofs(token_str)) {
87 ESP_LOGE(TAG, "spend_proofs rejected the token");
88 return ESP_FAIL;
89 }
90 }
91
92 const char *metric = s_platform->get_metric ? s_platform->get_metric() : "milliseconds";
93 uint64_t price = s_platform->get_price_sats ? s_platform->get_price_sats() : 21;
94 uint64_t step_size;
95
96 if (strcmp(metric, "bytes") == 0) {
97 step_size = s_platform->get_step_bytes ? (uint64_t)s_platform->get_step_bytes() : 22020096;
98 } else {
99 step_size = s_platform->get_step_ms ? (uint64_t)s_platform->get_step_ms() : 60000;
100 }
101
102 uint64_t allotment = tollgate_core_cashu_calculate_allotment(token.total_amount, price, step_size);
103 if (allotment == 0) {
104 ESP_LOGE(TAG, "Token amount %llu too small for price %llu",
105 (unsigned long long)token.total_amount, (unsigned long long)price);
106 return ESP_FAIL;
107 }
108
109 if (strcmp(metric, "bytes") == 0) {
110 if (!tollgate_core_session_create_bytes(client_ip, allotment)) {
111 return ESP_FAIL;
112 }
113 } else {
114 if (!tollgate_core_session_create(client_ip, allotment)) {
115 return ESP_FAIL;
116 }
117 }
118
119 ESP_LOGI(TAG, "Payment processed: %llu sats → %llu %s allotment for client",
120 (unsigned long long)token.total_amount, (unsigned long long)allotment, metric);
121 return ESP_OK;
122}
123
124void tollgate_core_client_connected(const uint8_t *mac, uint32_t client_ip)
125{
126 if (!s_owner_connected) {
127 s_owner_connected = true;
128 s_owner_ip = client_ip;
129 if (mac) memcpy(s_owner_mac, mac, 6);
130
131 esp_ip4_addr_t ip = { .addr = client_ip };
132 ESP_LOGI(TAG, "First client = owner: " IPSTR, IP2STR(&ip));
133 return;
134 }
135
136 ESP_LOGI(TAG, "Client connected (non-owner): " IPSTR, IP2STR(&(esp_ip4_addr_t){.addr=client_ip}));
137}
138
139void tollgate_core_client_disconnected(const uint8_t *mac)
140{
141 if (!s_owner_connected) return;
142
143 if (mac && memcmp(s_owner_mac, mac, 6) == 0) {
144 ESP_LOGI(TAG, "Owner disconnected — reassigning");
145 s_owner_connected = false;
146 memset(s_owner_mac, 0, sizeof(s_owner_mac));
147
148 int fw_count = tollgate_core_fw_client_count();
149 if (fw_count > 0) {
150 tg_session_t *sessions = tollgate_core_session_get_array();
151 for (int i = 0; i < tollgate_core_session_get_array_size(); i++) {
152 if (sessions[i].active && sessions[i].mac[0] != '\0') {
153 if (memcmp(sessions[i].mac, mac, 6) != 0) {
154 s_owner_connected = true;
155 s_owner_ip = sessions[i].client_ip;
156 ESP_LOGI(TAG, "New owner: " IPSTR, IP2STR(&(esp_ip4_addr_t){.addr=s_owner_ip}));
157 break;
158 }
159 }
160 }
161 }
162 return;
163 }
164
165 ESP_LOGI(TAG, "Client disconnected");
166}
167
168void tollgate_core_tick(void)
169{
170 tollgate_core_session_tick();
171}
172
173bool tollgate_core_is_client_allowed(uint32_t client_ip)
174{
175 return tollgate_core_fw_is_allowed(client_ip);
176}
177
178bool tollgate_core_is_dns_running(void)
179{
180 return tollgate_core_dns_is_running();
181}
182
183char *tollgate_core_get_status_json(void)
184{
185 cJSON *root = cJSON_CreateObject();
186 cJSON_AddBoolToObject(root, "ownerConnected", s_owner_connected);
187 cJSON_AddNumberToObject(root, "activeSessions", tollgate_core_session_active_count());
188 cJSON_AddNumberToObject(root, "allowedClients", tollgate_core_fw_client_count());
189 cJSON_AddBoolToObject(root, "dnsRunning", tollgate_core_dns_is_running());
190
191 char *json = cJSON_PrintUnformatted(root);
192 cJSON_Delete(root);
193 return json;
194}
195
196char *tollgate_core_get_config_json(void)
197{
198 cJSON *root = cJSON_CreateObject();
199
200 if (s_platform) {
201 if (s_platform->get_price_sats)
202 cJSON_AddNumberToObject(root, "priceSats", s_platform->get_price_sats());
203 if (s_platform->get_step_ms)
204 cJSON_AddNumberToObject(root, "stepMs", s_platform->get_step_ms());
205 if (s_platform->get_mint_url)
206 cJSON_AddStringToObject(root, "mintUrl", s_platform->get_mint_url());
207 if (s_platform->get_metric)
208 cJSON_AddStringToObject(root, "metric", s_platform->get_metric());
209 if (s_platform->get_step_bytes)
210 cJSON_AddNumberToObject(root, "stepBytes", s_platform->get_step_bytes());
211 }
212
213 char *json = cJSON_PrintUnformatted(root);
214 cJSON_Delete(root);
215 return json;
216}
217
218int tollgate_core_active_session_count(void)
219{
220 return tollgate_core_session_active_count();
221}
222
223int tollgate_core_allowed_client_count(void)
224{
225 return tollgate_core_fw_client_count();
226}
227
228bool tollgate_core_is_owner(uint32_t client_ip)
229{
230 return s_owner_connected && s_owner_ip == client_ip;
231}
232
233bool tollgate_core_is_owner_connected(void)
234{
235 return s_owner_connected;
236}
diff --git a/components/tollgate_core/src/tollgate_core_cashu.c b/components/tollgate_core/src/tollgate_core_cashu.c
new file mode 100644
index 0000000..cf2bf5d
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_cashu.c
@@ -0,0 +1,262 @@
1#include "tollgate_core_cashu.h"
2#include "esp_log.h"
3#include "esp_http_client.h"
4#include "cJSON.h"
5#include "mbedtls/base64.h"
6#include "mbedtls/sha256.h"
7#include "esp_crt_bundle.h"
8
9static const char *TAG = "tg_core_cashu";
10
11static const char V3_PREFIX[] = "cashuA";
12static const size_t V3_PREFIX_LEN = 6;
13
14static int b64url_decode(const char *input, size_t input_len, char *out, size_t out_size, size_t *out_len)
15{
16 char *b64 = malloc(input_len + 4);
17 if (!b64) return -1;
18 size_t b64_len = input_len;
19 memcpy(b64, input, b64_len);
20 b64[b64_len] = '\0';
21
22 for (size_t i = 0; i < b64_len; i++) {
23 if (b64[i] == '-') b64[i] = '+';
24 else if (b64[i] == '_') b64[i] = '/';
25 }
26 while (b64_len % 4 != 0) {
27 b64[b64_len++] = '=';
28 }
29 b64[b64_len] = '\0';
30
31 size_t olen = 0;
32 int ret = mbedtls_base64_decode((unsigned char *)out, out_size, &olen,
33 (const unsigned char *)b64, b64_len);
34 free(b64);
35 if (ret != 0) return -1;
36 *out_len = olen;
37 return 0;
38}
39
40static esp_err_t parse_proofs_array(cJSON *arr, tg_cashu_token_t *out)
41{
42 if (!cJSON_IsArray(arr)) return ESP_FAIL;
43 int count = cJSON_GetArraySize(arr);
44 if (count > TG_CASHU_MAX_PROOFS) return ESP_FAIL;
45
46 out->proof_count = 0;
47 out->total_amount = 0;
48 for (int i = 0; i < count; i++) {
49 cJSON *proof = cJSON_GetArrayItem(arr, i);
50 cJSON *amt = cJSON_GetObjectItemCaseSensitive(proof, "amount");
51 cJSON *id = cJSON_GetObjectItemCaseSensitive(proof, "id");
52 cJSON *secret = cJSON_GetObjectItemCaseSensitive(proof, "secret");
53 cJSON *c = cJSON_GetObjectItemCaseSensitive(proof, "C");
54
55 if (!amt || !cJSON_IsNumber(amt)) return ESP_FAIL;
56
57 out->proofs[i].amount = (uint64_t)amt->valuedouble;
58 out->total_amount += out->proofs[i].amount;
59
60 if (id && cJSON_IsString(id)) {
61 strncpy(out->proofs[i].id, id->valuestring, sizeof(out->proofs[i].id) - 1);
62 }
63 if (secret && cJSON_IsString(secret)) {
64 strncpy(out->proofs[i].secret, secret->valuestring, sizeof(out->proofs[i].secret) - 1);
65 }
66 if (c && cJSON_IsString(c)) {
67 strncpy(out->proofs[i].c, c->valuestring, sizeof(out->proofs[i].c) - 1);
68 }
69 out->proof_count++;
70 }
71 return ESP_OK;
72}
73
74esp_err_t tollgate_core_cashu_decode_token(const char *token_str, tg_cashu_token_t *out)
75{
76 if (!token_str || !out) return ESP_FAIL;
77 memset(out, 0, sizeof(*out));
78
79 size_t len = strlen(token_str);
80 char *nl = strchr(token_str, '\n');
81 if (nl) len = nl - token_str;
82 char *cr = strchr(token_str, '\r');
83 if (cr && (cr - token_str) < (int)len) len = cr - token_str;
84 if (len <= V3_PREFIX_LEN) {
85 ESP_LOGE(TAG, "Token too short");
86 return ESP_FAIL;
87 }
88 if (strncmp(token_str, V3_PREFIX, V3_PREFIX_LEN) != 0) {
89 ESP_LOGE(TAG, "Token missing cashuA prefix");
90 return ESP_FAIL;
91 }
92
93 size_t b64_len = len - V3_PREFIX_LEN;
94 size_t decoded_size = (b64_len * 3) / 4 + 4;
95 char *json_buf = malloc(decoded_size);
96 if (!json_buf) return ESP_FAIL;
97 size_t json_len = 0;
98 if (b64url_decode(token_str + V3_PREFIX_LEN, b64_len,
99 json_buf, decoded_size - 1, &json_len) != 0) {
100 ESP_LOGE(TAG, "Base64url decode failed");
101 free(json_buf);
102 return ESP_FAIL;
103 }
104 json_buf[json_len] = '\0';
105
106 cJSON *root = cJSON_Parse(json_buf);
107 free(json_buf);
108 if (!root) {
109 ESP_LOGE(TAG, "JSON parse failed");
110 return ESP_FAIL;
111 }
112
113 cJSON *token_arr = cJSON_GetObjectItemCaseSensitive(root, "token");
114 if (token_arr && cJSON_IsArray(token_arr)) {
115 cJSON *first = cJSON_GetArrayItem(token_arr, 0);
116 if (!first) { cJSON_Delete(root); return ESP_FAIL; }
117
118 cJSON *mint = cJSON_GetObjectItemCaseSensitive(first, "mint");
119 if (mint && cJSON_IsString(mint)) {
120 strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1);
121 }
122
123 cJSON *proofs = cJSON_GetObjectItemCaseSensitive(first, "proofs");
124 if (proofs) {
125 esp_err_t ret = parse_proofs_array(proofs, out);
126 if (ret != ESP_OK) { cJSON_Delete(root); return ret; }
127 }
128 } else {
129 cJSON *mint = cJSON_GetObjectItemCaseSensitive(root, "mint");
130 if (mint && cJSON_IsString(mint)) {
131 strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1);
132 }
133
134 cJSON *proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs");
135 if (proofs) {
136 esp_err_t ret = parse_proofs_array(proofs, out);
137 if (ret != ESP_OK) { cJSON_Delete(root); return ret; }
138 }
139 }
140
141 cJSON_Delete(root);
142
143 if (out->proof_count == 0) {
144 ESP_LOGE(TAG, "No proofs in token");
145 return ESP_FAIL;
146 }
147
148 ESP_LOGI(TAG, "Decoded token: %d proofs, total=%llu, mint=%s",
149 out->proof_count, (unsigned long long)out->total_amount, out->mint_url);
150 return ESP_OK;
151}
152
153static void sha256_hex(const char *data, size_t data_len, char *hex_out)
154{
155 uint8_t hash[32];
156 mbedtls_sha256((const unsigned char *)data, data_len, hash, 0);
157 for (int i = 0; i < 32; i++) {
158 sprintf(hex_out + i * 2, "%02x", hash[i]);
159 }
160 hex_out[64] = '\0';
161}
162
163esp_err_t tollgate_core_cashu_check_proof_states(const char *mint_url, const tg_cashu_token_t *token,
164 tg_cashu_proof_state_t *states, int *state_count)
165{
166 cJSON *ys_arr = cJSON_CreateArray();
167 for (int i = 0; i < token->proof_count; i++) {
168 char y_hex[65];
169 sha256_hex(token->proofs[i].secret, strlen(token->proofs[i].secret), y_hex);
170 cJSON_AddItemToArray(ys_arr, cJSON_CreateString(y_hex));
171 strncpy(states[i].y_hex, y_hex, sizeof(states[i].y_hex) - 1);
172 states[i].spent = false;
173 }
174 *state_count = token->proof_count;
175
176 char *ys_json = cJSON_PrintUnformatted(ys_arr);
177 cJSON_Delete(ys_arr);
178
179 char *post_body = malloc(4096);
180 if (!post_body) { cJSON_free(ys_json); return ESP_FAIL; }
181 snprintf(post_body, 4096, "{\"Ys\":%s}", ys_json);
182 cJSON_free(ys_json);
183
184 char url[512];
185 snprintf(url, sizeof(url), "%s/v1/checkstate", mint_url);
186
187 char *resp_buf = malloc(8192);
188 if (!resp_buf) { free(post_body); return ESP_FAIL; }
189
190 esp_http_client_config_t config = {
191 .url = url,
192 .method = HTTP_METHOD_POST,
193 .timeout_ms = 15000,
194 .crt_bundle_attach = esp_crt_bundle_attach,
195 };
196 esp_http_client_handle_t client = esp_http_client_init(&config);
197 if (!client) { free(post_body); free(resp_buf); return ESP_FAIL; }
198
199 esp_http_client_set_header(client, "Content-Type", "application/json");
200 esp_err_t err = esp_http_client_open(client, strlen(post_body));
201 if (err != ESP_OK) {
202 ESP_LOGE(TAG, "checkstate open failed: %s", esp_err_to_name(err));
203 esp_http_client_cleanup(client);
204 free(post_body);
205 free(resp_buf);
206 return ESP_FAIL;
207 }
208 int written = esp_http_client_write(client, post_body, strlen(post_body));
209 free(post_body);
210 ESP_LOGI(TAG, "checkstate written %d bytes", written);
211
212 int content_length = esp_http_client_fetch_headers(client);
213 int status = esp_http_client_get_status_code(client);
214 ESP_LOGI(TAG, "checkstate headers: status=%d, content_length=%d", status, content_length);
215
216 int resp_len = esp_http_client_read(client, resp_buf, 8191);
217 ESP_LOGI(TAG, "checkstate read: resp_len=%d", resp_len);
218 esp_http_client_cleanup(client);
219
220 if (status != 200 || resp_len <= 0) {
221 ESP_LOGE(TAG, "checkstate failed: status=%d, resp_len=%d", status, resp_len);
222 free(resp_buf);
223 return ESP_FAIL;
224 }
225 resp_buf[resp_len] = '\0';
226
227 cJSON *root_resp = cJSON_Parse(resp_buf);
228 free(resp_buf);
229 if (!root_resp) return ESP_FAIL;
230
231 cJSON *states_arr = cJSON_GetObjectItemCaseSensitive(root_resp, "states");
232 if (!states_arr || !cJSON_IsArray(states_arr)) {
233 cJSON_Delete(root_resp);
234 return ESP_FAIL;
235 }
236
237 int n = cJSON_GetArraySize(states_arr);
238 for (int i = 0; i < n && i < token->proof_count; i++) {
239 cJSON *s = cJSON_GetArrayItem(states_arr, i);
240 cJSON *state = cJSON_GetObjectItemCaseSensitive(s, "state");
241 if (state && cJSON_IsString(state)) {
242 states[i].spent = (strcmp(state->valuestring, "SPENT") == 0);
243 }
244 }
245
246 cJSON_Delete(root_resp);
247 return ESP_OK;
248}
249
250uint64_t tollgate_core_cashu_calculate_allotment(uint64_t token_amount, uint64_t price_per_step,
251 uint64_t step_size)
252{
253 if (price_per_step == 0) return 0;
254 return (token_amount / price_per_step) * step_size;
255}
256
257bool tollgate_core_cashu_is_mint_accepted(const char *mint_url, const char *accepted_mint_url)
258{
259 if (!mint_url || mint_url[0] == '\0') return false;
260 if (!accepted_mint_url || accepted_mint_url[0] == '\0') return false;
261 return (strcmp(mint_url, accepted_mint_url) == 0);
262}
diff --git a/components/tollgate_core/src/tollgate_core_cashu.h b/components/tollgate_core/src/tollgate_core_cashu.h
new file mode 100644
index 0000000..70d2a02
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_cashu.h
@@ -0,0 +1,42 @@
1#ifndef TOLLGATE_CORE_CASHU_H
2#define TOLLGATE_CORE_CASHU_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stdbool.h>
7
8#define TG_CASHU_MAX_PROOFS 10
9#define TG_CASHU_MAX_SECRET_LEN 128
10#define TG_CASHU_MAX_ID_LEN 68
11#define TG_CASHU_MAX_C_LEN 128
12
13typedef struct {
14 uint64_t amount;
15 char id[TG_CASHU_MAX_ID_LEN];
16 char secret[TG_CASHU_MAX_SECRET_LEN];
17 char c[TG_CASHU_MAX_C_LEN];
18} tg_cashu_proof_t;
19
20typedef struct {
21 tg_cashu_proof_t proofs[TG_CASHU_MAX_PROOFS];
22 int proof_count;
23 char mint_url[256];
24 uint64_t total_amount;
25} tg_cashu_token_t;
26
27typedef struct {
28 char y_hex[65];
29 bool spent;
30} tg_cashu_proof_state_t;
31
32esp_err_t tollgate_core_cashu_decode_token(const char *token_str, tg_cashu_token_t *out);
33
34esp_err_t tollgate_core_cashu_check_proof_states(const char *mint_url, const tg_cashu_token_t *token,
35 tg_cashu_proof_state_t *states, int *state_count);
36
37uint64_t tollgate_core_cashu_calculate_allotment(uint64_t token_amount, uint64_t price_per_step,
38 uint64_t step_size);
39
40bool tollgate_core_cashu_is_mint_accepted(const char *mint_url, const char *accepted_mint_url);
41
42#endif
diff --git a/components/tollgate_core/src/tollgate_core_dns.c b/components/tollgate_core/src/tollgate_core_dns.c
new file mode 100644
index 0000000..84322e6
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_dns.c
@@ -0,0 +1,316 @@
1#include "tollgate_core_dns.h"
2#include "esp_log.h"
3#include "freertos/FreeRTOS.h"
4#include "freertos/task.h"
5#include "lwip/sockets.h"
6#include "lwip/netdb.h"
7#include <string.h>
8#include <sys/param.h>
9
10#define MAX_AUTH_IPS 10
11#define DNS_BUF_SIZE 512
12#define DNS_PORT 53
13#define DOT_PORT 853
14#define DNS_TASK_STACK 4096
15#define DOT_TASK_STACK 3072
16#define DNS_TASK_PRIO 5
17#define DOT_TASK_PRIO 5
18#define DNS_FORWARD_TIMEOUT_MS 2000
19#define NXDOMAIN_TTL 30
20#define HIJACK_TTL 10
21
22static const char *TAG = "tg_core_dns";
23
24#pragma pack(push, 1)
25typedef struct {
26 uint16_t id;
27 uint16_t flags;
28 uint16_t qdcount;
29 uint16_t ancount;
30 uint16_t nscount;
31 uint16_t arcount;
32} dns_header_t;
33#pragma pack(pop)
34
35#pragma pack(push, 1)
36typedef struct {
37 uint16_t name;
38 uint16_t type;
39 uint16_t class;
40 uint32_t ttl;
41 uint16_t len;
42 uint32_t addr;
43} dns_answer_t;
44#pragma pack(pop)
45
46typedef struct {
47 uint32_t ip;
48} auth_entry_t;
49
50static auth_entry_t s_auth_list[MAX_AUTH_IPS];
51static int s_auth_count = 0;
52static TaskHandle_t s_dns_task = NULL;
53static TaskHandle_t s_dot_task = NULL;
54static volatile bool s_dns_running = false;
55static esp_ip4_addr_t s_ap_ip;
56static esp_ip4_addr_t s_upstream_dns;
57
58static bool is_authenticated(uint32_t ip)
59{
60 for (int i = 0; i < s_auth_count; i++) {
61 if (s_auth_list[i].ip == ip) return true;
62 }
63 return false;
64}
65
66static void parse_dns_name(const uint8_t *buf, int buf_len, int offset, char *out, int out_len)
67{
68 int pos = offset;
69 int out_pos = 0;
70 int jumped = 0;
71 int jump_pos = 0;
72 while (pos < buf_len && out_pos < out_len - 1) {
73 uint8_t len = buf[pos];
74 if (len == 0) break;
75 if ((len & 0xC0) == 0xC0) {
76 if (!jumped) jump_pos = pos + 2;
77 pos = ((len & 0x3F) << 8) | buf[pos + 1];
78 jumped = 1;
79 continue;
80 }
81 if (out_pos > 0 && out_pos < out_len - 1) out[out_pos++] = '.';
82 pos++;
83 for (int i = 0; i < len && pos < buf_len && out_pos < out_len - 1; i++) {
84 out[out_pos++] = buf[pos++];
85 }
86 }
87 out[out_pos] = '\0';
88}
89
90static int build_nxdomain(uint8_t *response, int req_len)
91{
92 dns_header_t *hdr = (dns_header_t *)response;
93 hdr->flags = htons(0x8403);
94 hdr->ancount = 0;
95 hdr->nscount = 0;
96 hdr->arcount = 0;
97 return req_len;
98}
99
100static int build_redirect_response(uint8_t *response, int req_len)
101{
102 memmove(response, response, req_len);
103 dns_header_t *hdr = (dns_header_t *)response;
104 hdr->flags = htons(0x8180);
105 hdr->ancount = htons(1);
106 hdr->nscount = 0;
107 hdr->arcount = 0;
108 int resp_len = req_len;
109 dns_answer_t ans;
110 ans.name = htons(0xC00C);
111 ans.type = htons(1);
112 ans.class = htons(1);
113 ans.ttl = htonl(HIJACK_TTL);
114 ans.len = htons(4);
115 ans.addr = s_ap_ip.addr;
116 memcpy(response + resp_len, &ans, sizeof(ans));
117 resp_len += sizeof(ans);
118 return resp_len;
119}
120
121static int forward_dns(const uint8_t *req, int req_len, uint8_t *resp, int resp_buf_len,
122 uint16_t txn_id)
123{
124 int upstream_sock = socket(AF_INET, SOCK_DGRAM, 0);
125 if (upstream_sock < 0) return -1;
126
127 struct timeval tv = { .tv_sec = DNS_FORWARD_TIMEOUT_MS / 1000, .tv_usec = (DNS_FORWARD_TIMEOUT_MS % 1000) * 1000 };
128 setsockopt(upstream_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
129
130 struct sockaddr_in upstream_addr = {
131 .sin_family = AF_INET,
132 .sin_port = htons(DNS_PORT),
133 .sin_addr.s_addr = s_upstream_dns.addr,
134 };
135
136 sendto(upstream_sock, req, req_len, 0, (struct sockaddr *)&upstream_addr, sizeof(upstream_addr));
137
138 int n = recvfrom(upstream_sock, resp, resp_buf_len, 0, NULL, NULL);
139 close(upstream_sock);
140
141 if (n > 0) {
142 if (n >= sizeof(dns_header_t)) {
143 dns_header_t *hdr = (dns_header_t *)resp;
144 hdr->id = htons(txn_id);
145 }
146 }
147 return n;
148}
149
150static void dns_server_task(void *arg)
151{
152 int sock = socket(AF_INET, SOCK_DGRAM, 0);
153 if (sock < 0) {
154 ESP_LOGE(TAG, "Failed to create DNS socket");
155 s_dns_running = false;
156 vTaskDelete(NULL);
157 return;
158 }
159
160 struct sockaddr_in bind_addr = {
161 .sin_family = AF_INET,
162 .sin_port = htons(DNS_PORT),
163 .sin_addr.s_addr = INADDR_ANY,
164 };
165 if (bind(sock, (struct sockaddr *)&bind_addr, sizeof(bind_addr)) < 0) {
166 ESP_LOGE(TAG, "Failed to bind DNS socket");
167 close(sock);
168 s_dns_running = false;
169 vTaskDelete(NULL);
170 return;
171 }
172
173 ESP_LOGI(TAG, "DNS server started on port %d, AP IP=" IPSTR ", upstream DNS=" IPSTR,
174 DNS_PORT, IP2STR(&s_ap_ip), IP2STR(&s_upstream_dns));
175
176 uint8_t rx_buf[DNS_BUF_SIZE];
177 uint8_t tx_buf[DNS_BUF_SIZE + sizeof(dns_answer_t)];
178
179 while (s_dns_running) {
180 struct sockaddr_in client_addr;
181 socklen_t client_len = sizeof(client_addr);
182 int n = recvfrom(sock, rx_buf, sizeof(rx_buf), 0,
183 (struct sockaddr *)&client_addr, &client_len);
184 if (n < (int)sizeof(dns_header_t)) continue;
185
186 uint32_t client_ip = client_addr.sin_addr.s_addr;
187 dns_header_t *hdr = (dns_header_t *)rx_buf;
188 uint16_t txn_id = ntohs(hdr->id);
189 bool is_query = (ntohs(hdr->flags) & 0x8000) == 0;
190 uint16_t qdcount = ntohs(hdr->qdcount);
191
192 if (!is_query || qdcount == 0) continue;
193
194 int q_offset = sizeof(dns_header_t);
195 while (q_offset < n && rx_buf[q_offset] != 0) {
196 q_offset += rx_buf[q_offset] + 1;
197 }
198 if (q_offset + 5 > n) continue;
199 uint16_t qtype = (rx_buf[q_offset + 1] << 8) | rx_buf[q_offset + 2];
200 int req_len = q_offset + 5;
201
202 if (is_authenticated(client_ip)) {
203 int resp_len = forward_dns(rx_buf, req_len, tx_buf, sizeof(tx_buf), txn_id);
204 if (resp_len > 0) {
205 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
206 }
207 } else {
208 char qname[256] = {0};
209 parse_dns_name(rx_buf, n, sizeof(dns_header_t), qname, sizeof(qname));
210 ESP_LOGI(TAG, "Hijack DNS from " IPSTR ": %s (type=%d)",
211 IP2STR(&(esp_ip4_addr_t){.addr=client_ip}), qname, qtype);
212 if (qtype == 1) {
213 int resp_len = build_redirect_response(rx_buf, req_len);
214 memcpy(tx_buf, rx_buf, resp_len);
215 dns_header_t *resp_hdr = (dns_header_t *)tx_buf;
216 resp_hdr->id = htons(txn_id);
217 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
218 } else {
219 int resp_len = build_nxdomain(rx_buf, req_len);
220 memcpy(tx_buf, rx_buf, resp_len);
221 dns_header_t *resp_hdr = (dns_header_t *)tx_buf;
222 resp_hdr->id = htons(txn_id);
223 sendto(sock, tx_buf, resp_len, 0, (struct sockaddr *)&client_addr, client_len);
224 }
225 }
226 }
227
228 close(sock);
229 ESP_LOGI(TAG, "DNS server stopped");
230 vTaskDelete(NULL);
231}
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
276esp_err_t tollgate_core_dns_start_internal(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns)
277{
278 if (s_dns_running) return ESP_OK;
279 s_ap_ip = ap_ip;
280 s_upstream_dns = upstream_dns;
281 s_dns_running = true;
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);
284 return ESP_OK;
285}
286
287void tollgate_core_dns_stop(void)
288{
289 s_dns_running = false;
290 vTaskDelay(pdMS_TO_TICKS(200));
291 s_dns_task = NULL;
292}
293
294void tollgate_core_dns_set_authenticated(uint32_t client_ip, bool authenticated)
295{
296 if (authenticated) {
297 if (is_authenticated(client_ip)) return;
298 if (s_auth_count < MAX_AUTH_IPS) {
299 s_auth_list[s_auth_count].ip = client_ip;
300 s_auth_count++;
301 }
302 } else {
303 for (int i = 0; i < s_auth_count; i++) {
304 if (s_auth_list[i].ip == client_ip) {
305 s_auth_list[i] = s_auth_list[s_auth_count - 1];
306 s_auth_count--;
307 return;
308 }
309 }
310 }
311}
312
313bool tollgate_core_dns_is_running(void)
314{
315 return s_dns_running;
316}
diff --git a/components/tollgate_core/src/tollgate_core_dns.h b/components/tollgate_core/src/tollgate_core_dns.h
new file mode 100644
index 0000000..60aaaaf
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_dns.h
@@ -0,0 +1,13 @@
1#ifndef TOLLGATE_CORE_DNS_H
2#define TOLLGATE_CORE_DNS_H
3
4#include "esp_err.h"
5#include "esp_netif.h"
6#include <stdbool.h>
7
8esp_err_t tollgate_core_dns_start_internal(esp_ip4_addr_t ap_ip, esp_ip4_addr_t upstream_dns);
9void tollgate_core_dns_stop(void);
10void tollgate_core_dns_set_authenticated(uint32_t client_ip, bool authenticated);
11bool tollgate_core_dns_is_running(void);
12
13#endif
diff --git a/components/tollgate_core/src/tollgate_core_firewall.c b/components/tollgate_core/src/tollgate_core_firewall.c
new file mode 100644
index 0000000..e5d61d8
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_firewall.c
@@ -0,0 +1,165 @@
1#include "tollgate_core_firewall.h"
2#include "tollgate_core_dns.h"
3#include "esp_log.h"
4#include "esp_wifi.h"
5#include "esp_wifi_ap_get_sta_list.h"
6#include "lwip/lwip_napt.h"
7#include "lwip/etharp.h"
8#include "lwip/netif.h"
9#include "lwip/prot/ip4.h"
10#include <string.h>
11
12#define MAX_CLIENTS 10
13
14static const char *TAG = "tg_core_fw";
15static esp_ip4_addr_t s_ap_ip;
16
17typedef struct {
18 uint32_t ip;
19 char mac[TG_FW_MAX_MAC_LEN];
20} fw_client_t;
21
22static fw_client_t s_clients[MAX_CLIENTS];
23static int s_client_count = 0;
24
25esp_err_t tollgate_core_fw_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size)
26{
27 wifi_sta_list_t sta_list;
28 if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) {
29 wifi_sta_mac_ip_list_t ip_mac_list;
30 if (esp_wifi_ap_get_sta_list_with_ip(&sta_list, &ip_mac_list) == ESP_OK) {
31 for (int i = 0; i < ip_mac_list.num; i++) {
32 if (ip_mac_list.sta[i].ip.addr == client_ip) {
33 snprintf(mac_out, mac_out_size, "%02x:%02x:%02x:%02x:%02x:%02x",
34 ip_mac_list.sta[i].mac[0], ip_mac_list.sta[i].mac[1],
35 ip_mac_list.sta[i].mac[2], ip_mac_list.sta[i].mac[3],
36 ip_mac_list.sta[i].mac[4], ip_mac_list.sta[i].mac[5]);
37 return ESP_OK;
38 }
39 }
40 }
41 }
42
43 ip4_addr_t *entry_ip = NULL;
44 struct netif *entry_netif = NULL;
45 struct eth_addr *entry_eth = NULL;
46 ssize_t i = 0;
47 while (etharp_get_entry(i, &entry_ip, &entry_netif, &entry_eth) == ERR_OK) {
48 if (entry_ip && entry_ip->addr == client_ip && entry_eth) {
49 snprintf(mac_out, mac_out_size, "%02x:%02x:%02x:%02x:%02x:%02x",
50 entry_eth->addr[0], entry_eth->addr[1], entry_eth->addr[2],
51 entry_eth->addr[3], entry_eth->addr[4], entry_eth->addr[5]);
52 return ESP_OK;
53 }
54 i++;
55 }
56 return ESP_FAIL;
57}
58
59esp_err_t tollgate_core_fw_init(esp_ip4_addr_t ap_ip)
60{
61 s_ap_ip = ap_ip;
62 memset(s_clients, 0, sizeof(s_clients));
63 s_client_count = 0;
64 ip_napt_enable(s_ap_ip.addr, 1);
65 ESP_LOGI(TAG, "Firewall initialized with AP IP=" IPSTR " (NAT always on, per-client filter)", IP2STR(&s_ap_ip));
66 return ESP_OK;
67}
68
69int tollgate_core_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder)
70{
71 (void)dest_addr_hostorder;
72 if (p->len < IP_HLEN) return -1;
73 struct ip_hdr *iphdr = (struct ip_hdr *)p->payload;
74 uint32_t src_ip_h = lwip_ntohl(iphdr->src.addr);
75 uint32_t ap_subnet = lwip_ntohl(s_ap_ip.addr) & 0xFFFFFF00;
76 if ((src_ip_h & 0xFFFFFF00) != ap_subnet) {
77 return 1;
78 }
79 if (tollgate_core_fw_is_allowed(iphdr->src.addr)) {
80 return 1;
81 }
82 return 0;
83}
84
85static fw_client_t *find_client_by_ip(uint32_t client_ip)
86{
87 for (int i = 0; i < s_client_count; i++) {
88 if (s_clients[i].ip == client_ip) return &s_clients[i];
89 }
90 return NULL;
91}
92
93static fw_client_t *find_client_by_mac(const char *mac)
94{
95 for (int i = 0; i < s_client_count; i++) {
96 if (s_clients[i].mac[0] != '\0' && strcmp(s_clients[i].mac, mac) == 0) {
97 return &s_clients[i];
98 }
99 }
100 return NULL;
101}
102
103void tollgate_core_fw_grant(uint32_t client_ip)
104{
105 fw_client_t *existing = find_client_by_ip(client_ip);
106 if (existing) {
107 existing->ip = client_ip;
108 return;
109 }
110 if (s_client_count >= MAX_CLIENTS) {
111 ESP_LOGW(TAG, "Max clients reached, cannot grant access");
112 return;
113 }
114
115 fw_client_t *client = &s_clients[s_client_count];
116 client->ip = client_ip;
117 client->mac[0] = '\0';
118 tollgate_core_fw_get_mac_for_ip(client_ip, client->mac, sizeof(client->mac));
119 s_client_count++;
120
121 tollgate_core_dns_set_authenticated(client_ip, true);
122
123 esp_ip4_addr_t ip_addr = { .addr = client_ip };
124 ESP_LOGI(TAG, "Access granted to " IPSTR " mac=%s", IP2STR(&ip_addr),
125 client->mac[0] ? client->mac : "unknown");
126}
127
128void tollgate_core_fw_revoke(uint32_t client_ip)
129{
130 for (int i = 0; i < s_client_count; i++) {
131 if (s_clients[i].ip == client_ip) {
132 esp_ip4_addr_t ip_addr = { .addr = client_ip };
133 ESP_LOGI(TAG, "Access revoked for " IPSTR " mac=%s", IP2STR(&ip_addr),
134 s_clients[i].mac[0] ? s_clients[i].mac : "unknown");
135 s_clients[i] = s_clients[s_client_count - 1];
136 s_client_count--;
137 tollgate_core_dns_set_authenticated(client_ip, false);
138 return;
139 }
140 }
141}
142
143void tollgate_core_fw_revoke_all(void)
144{
145 for (int i = 0; i < s_client_count; i++) {
146 tollgate_core_dns_set_authenticated(s_clients[i].ip, false);
147 }
148 s_client_count = 0;
149 ESP_LOGI(TAG, "All client access revoked");
150}
151
152bool tollgate_core_fw_is_allowed(uint32_t client_ip)
153{
154 return find_client_by_ip(client_ip) != NULL;
155}
156
157bool tollgate_core_fw_is_mac_allowed(const char *mac)
158{
159 return find_client_by_mac(mac) != NULL;
160}
161
162int tollgate_core_fw_client_count(void)
163{
164 return s_client_count;
165}
diff --git a/components/tollgate_core/src/tollgate_core_firewall.h b/components/tollgate_core/src/tollgate_core_firewall.h
new file mode 100644
index 0000000..f06c801
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_firewall.h
@@ -0,0 +1,25 @@
1#ifndef TOLLGATE_CORE_FIREWALL_H
2#define TOLLGATE_CORE_FIREWALL_H
3
4#include "esp_err.h"
5#include "esp_netif.h"
6#include <stdbool.h>
7#include <stdint.h>
8
9struct pbuf;
10
11#define TG_FW_MAX_MAC_LEN 18
12
13esp_err_t tollgate_core_fw_init(esp_ip4_addr_t ap_ip);
14void tollgate_core_fw_grant(uint32_t client_ip);
15void tollgate_core_fw_revoke(uint32_t client_ip);
16void tollgate_core_fw_revoke_all(void);
17bool tollgate_core_fw_is_allowed(uint32_t client_ip);
18bool tollgate_core_fw_is_mac_allowed(const char *mac);
19int tollgate_core_fw_client_count(void);
20
21esp_err_t tollgate_core_fw_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size);
22
23int tollgate_core_ip4_canforward_filter(struct pbuf *p, uint32_t dest_addr_hostorder);
24
25#endif
diff --git a/components/tollgate_core/src/tollgate_core_session.c b/components/tollgate_core/src/tollgate_core_session.c
new file mode 100644
index 0000000..48d32e7
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_session.c
@@ -0,0 +1,200 @@
1#include "tollgate_core_session.h"
2#include "tollgate_core_firewall.h"
3#include "esp_log.h"
4#include "freertos/FreeRTOS.h"
5#include "freertos/task.h"
6#include <string.h>
7
8static const char *TAG = "tg_core_session";
9static tg_session_t s_sessions[TG_SESSION_MAX_CLIENTS];
10static int s_session_count = 0;
11
12static const tollgate_platform_t *s_platform;
13
14void tollgate_core_session_set_platform(const tollgate_platform_t *platform)
15{
16 s_platform = platform;
17}
18
19static int64_t get_time_ms(void)
20{
21 if (s_platform && s_platform->get_time_ms) {
22 return s_platform->get_time_ms();
23 }
24 return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
25}
26
27esp_err_t tollgate_core_session_init(void)
28{
29 memset(s_sessions, 0, sizeof(s_sessions));
30 s_session_count = 0;
31 ESP_LOGI(TAG, "Session manager initialized");
32 return ESP_OK;
33}
34
35static void populate_mac(tg_session_t *session, uint32_t client_ip)
36{
37 if (tollgate_core_fw_get_mac_for_ip(client_ip, session->mac, sizeof(session->mac)) != ESP_OK) {
38 session->mac[0] = '\0';
39 }
40}
41
42tg_session_t *tollgate_core_session_create(uint32_t client_ip, uint64_t allotment_ms)
43{
44 tg_session_t *existing = tollgate_core_session_find_by_ip(client_ip);
45 if (existing) {
46 tollgate_core_session_extend(existing, allotment_ms);
47 return existing;
48 }
49
50 if (s_session_count >= TG_SESSION_MAX_CLIENTS) {
51 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
52 if (!s_sessions[i].active || tollgate_core_session_is_expired(&s_sessions[i])) {
53 tollgate_core_session_revoke(&s_sessions[i]);
54 break;
55 }
56 }
57 }
58
59 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
60 if (!s_sessions[i].active) {
61 s_sessions[i].client_ip = client_ip;
62 s_sessions[i].allotment_ms = allotment_ms;
63 s_sessions[i].start_time_ms = get_time_ms();
64 s_sessions[i].active = true;
65 populate_mac(&s_sessions[i], client_ip);
66
67 s_session_count++;
68 tollgate_core_fw_grant(client_ip);
69
70 esp_ip4_addr_t ip = { .addr = client_ip };
71 ESP_LOGI(TAG, "Session created: " IPSTR " mac=%s allotment=%llums", IP2STR(&ip),
72 s_sessions[i].mac[0] ? s_sessions[i].mac : "unknown",
73 (unsigned long long)allotment_ms);
74 return &s_sessions[i];
75 }
76 }
77
78 ESP_LOGW(TAG, "No free session slots");
79 return NULL;
80}
81
82tg_session_t *tollgate_core_session_create_bytes(uint32_t client_ip, uint64_t allotment_bytes)
83{
84 tg_session_t *s = tollgate_core_session_create(client_ip, 0);
85 if (s) {
86 s->allotment_bytes = allotment_bytes;
87 s->bytes_consumed = 0;
88 s->allotment_ms = INT64_MAX;
89 esp_ip4_addr_t ip = { .addr = client_ip };
90 ESP_LOGI(TAG, "Bytes session created: " IPSTR " allotment=%llu bytes", IP2STR(&ip),
91 (unsigned long long)allotment_bytes);
92 }
93 return s;
94}
95
96void tollgate_core_session_add_bytes(uint32_t client_ip, uint64_t bytes)
97{
98 tg_session_t *s = tollgate_core_session_find_by_ip(client_ip);
99 if (s && s->active) {
100 s->bytes_consumed += bytes;
101 }
102}
103
104tg_session_t *tollgate_core_session_find_by_ip(uint32_t client_ip)
105{
106 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
107 if (s_sessions[i].active && s_sessions[i].client_ip == client_ip) {
108 return &s_sessions[i];
109 }
110 }
111 return NULL;
112}
113
114tg_session_t *tollgate_core_session_find_by_mac(const char *mac)
115{
116 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
117 if (s_sessions[i].active && s_sessions[i].mac[0] != '\0' &&
118 strcmp(s_sessions[i].mac, mac) == 0) {
119 return &s_sessions[i];
120 }
121 }
122 return NULL;
123}
124
125void tollgate_core_session_extend(tg_session_t *session, uint64_t additional_ms)
126{
127 if (!session || !session->active) return;
128 session->allotment_ms += additional_ms;
129 esp_ip4_addr_t ip = { .addr = session->client_ip };
130 ESP_LOGI(TAG, "Session extended: " IPSTR " +%llums (total=%llu)", IP2STR(&ip),
131 (unsigned long long)additional_ms, (unsigned long long)session->allotment_ms);
132}
133
134bool tollgate_core_session_is_expired(const tg_session_t *session)
135{
136 if (!session || !session->active) return true;
137
138 if (s_platform && s_platform->get_metric) {
139 const char *metric = s_platform->get_metric();
140 if (metric && strcmp(metric, "bytes") == 0) {
141 return session->bytes_consumed >= session->allotment_bytes;
142 }
143 }
144
145 int64_t elapsed = get_time_ms() - session->start_time_ms;
146 return elapsed >= (int64_t)session->allotment_ms;
147}
148
149static void check_expiry(void)
150{
151 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
152 if (s_sessions[i].active && tollgate_core_session_is_expired(&s_sessions[i])) {
153 esp_ip4_addr_t ip = { .addr = s_sessions[i].client_ip };
154 ESP_LOGI(TAG, "Session expired: " IPSTR " mac=%s", IP2STR(&ip),
155 s_sessions[i].mac[0] ? s_sessions[i].mac : "unknown");
156 tollgate_core_session_revoke(&s_sessions[i]);
157 }
158 }
159}
160
161void tollgate_core_session_revoke(tg_session_t *session)
162{
163 if (!session || !session->active) return;
164 tollgate_core_fw_revoke(session->client_ip);
165 session->active = false;
166 s_session_count--;
167}
168
169void tollgate_core_session_revoke_all(void)
170{
171 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
172 if (s_sessions[i].active) {
173 tollgate_core_session_revoke(&s_sessions[i]);
174 }
175 }
176}
177
178int tollgate_core_session_active_count(void)
179{
180 int count = 0;
181 for (int i = 0; i < TG_SESSION_MAX_CLIENTS; i++) {
182 if (s_sessions[i].active) count++;
183 }
184 return count;
185}
186
187void tollgate_core_session_tick(void)
188{
189 check_expiry();
190}
191
192tg_session_t *tollgate_core_session_get_array(void)
193{
194 return s_sessions;
195}
196
197int tollgate_core_session_get_array_size(void)
198{
199 return TG_SESSION_MAX_CLIENTS;
200}
diff --git a/components/tollgate_core/src/tollgate_core_session.h b/components/tollgate_core/src/tollgate_core_session.h
new file mode 100644
index 0000000..444b9fa
--- /dev/null
+++ b/components/tollgate_core/src/tollgate_core_session.h
@@ -0,0 +1,47 @@
1#ifndef TOLLGATE_CORE_SESSION_H
2#define TOLLGATE_CORE_SESSION_H
3
4#include "esp_err.h"
5#include "tollgate_platform.h"
6#include <stdint.h>
7#include <stdbool.h>
8
9#define TG_SESSION_MAX_CLIENTS 10
10#define TG_SESSION_MAX_MAC_LEN 18
11
12typedef struct {
13 uint32_t client_ip;
14 char mac[TG_SESSION_MAX_MAC_LEN];
15 uint64_t allotment_ms;
16 int64_t start_time_ms;
17 uint64_t allotment_bytes;
18 uint64_t bytes_consumed;
19 bool active;
20} tg_session_t;
21
22esp_err_t tollgate_core_session_init(void);
23void tollgate_core_session_set_platform(const tollgate_platform_t *platform);
24
25tg_session_t *tollgate_core_session_create(uint32_t client_ip, uint64_t allotment_ms);
26tg_session_t *tollgate_core_session_create_bytes(uint32_t client_ip, uint64_t allotment_bytes);
27
28void tollgate_core_session_add_bytes(uint32_t client_ip, uint64_t bytes);
29
30tg_session_t *tollgate_core_session_find_by_ip(uint32_t client_ip);
31tg_session_t *tollgate_core_session_find_by_mac(const char *mac);
32
33void tollgate_core_session_extend(tg_session_t *session, uint64_t additional_ms);
34
35bool tollgate_core_session_is_expired(const tg_session_t *session);
36
37void tollgate_core_session_revoke(tg_session_t *session);
38void tollgate_core_session_revoke_all(void);
39
40int tollgate_core_session_active_count(void);
41
42void tollgate_core_session_tick(void);
43
44tg_session_t *tollgate_core_session_get_array(void);
45int tollgate_core_session_get_array_size(void);
46
47#endif
diff --git a/docs/E2E_FIX_PLAN.md b/docs/E2E_FIX_PLAN.md
new file mode 100644
index 0000000..52f8305
--- /dev/null
+++ b/docs/E2E_FIX_PLAN.md
@@ -0,0 +1,177 @@
1# E2E Test Stability Fix Plan
2
3## Problem Statement
4
5E2E tests on physical boards are failing due to five root causes:
61. **LWIP socket exhaustion** (RC-0) — `LWIP_MAX_SOCKETS=10` was too low for two httpd servers + DNS + DoT + wifistr WebSockets
72. **Over-tuned httpd settings** (RC-1) — setting `max_open_sockets=2` and `keep_alive_enable=false` caused socket leaks by interfering with ESP-IDF's internal session management
83. **Owner auto-grant** (RC-2) — makes "no internet before auth" tests non-deterministic
94. **No boot-ready probe** (RC-3) — tests start before HTTP servers are up
105. **Serial monitoring resets** (RC-4) — Python `serial.Serial()` toggles DTR/RTS on USB-Serial/JTAG boards, causing chip resets mid-operation
11
12### Baseline Test Results (Board A, before fixes)
13
14| Suite | Pass | Fail | Notes |
15|---|---|---|---|
16| Smoke | 2/6 | 4 | Port 80 unresponsive, cascading failures |
17| Network | 4/7 | 3 | DNS forward + ping after auth (timing) |
18| API | 16/20 | 4 | Portal port 80 slow/crashed, captive URIs |
19| DNS+Firewall | 15/16 | 1 | Ping after auth (timing) |
20| Reset-Auth | 12/15 | 3 | Allotment was 0 (fixed), 2nd payment |
21| Session | 14/14 | 0 | Perfect |
22| Phase 2 | 12/12 | 0 | Perfect |
23
24### Verified Test Results (Board ACM2, after all fixes, commit `144b48f`)
25
26All API endpoints verified working on AP IP `10.192.45.1` with 2-3s delays between requests:
27- `GET /usage` — returns session/client counts (50/50 sequential requests passed)
28- `GET /portal-config` — returns `{priceSats, stepMs, mintUrl, metric, stepBytes}`
29- `GET /whoami` — returns client IP
30- `GET /grant_access` — grants firewall access
31- `POST /` (payment) — accepts Cashu token, returns `kind:1022`
32- `GET /` (port 80 portal) — returns 3829 bytes HTML
33- `GET /reset_authentication` — clears all sessions and firewall rules
34
35Full payment flow verified: check → pay → verify → grant → portal → reset → verify clean state.
36
37---
38
39## Root Causes
40
41### RC-0: LWIP socket exhaustion (FIXED)
42
43`CONFIG_LWIP_MAX_SOCKETS=10` in sdkconfig. Socket budget at steady state:
44
45| Component | Sockets | Notes |
46|---|---|---|
47| Captive portal (port 80) | 5 | 1 listen + 4 workers (default `max_open_sockets`) |
48| API server (port 2121) | 5 | 1 listen + 4 workers |
49| DNS server (UDP 53) | 1 | |
50| DoT reject (TCP 853) | 1 | |
51| wifistr WebSocket x2 | 2 | relay.damus.io + nos.lol |
52| **Total** | **14** | **Exceeds LWIP_MAX_SOCKETS=10 by 4** |
53
54**Fix** (commit `144b48f`): Set `CONFIG_LWIP_MAX_SOCKETS=20` (matching standalone tollgate). Use default `max_open_sockets=4` on both servers. Previous fix tried `max_open_sockets=2` which caused worse problems (see RC-1).
55
56### RC-1: Over-tuned httpd settings (FIXED)
57
58Initial fix reduced `max_open_sockets` to 2 and added `keep_alive_enable=false`, `linger_timeout=0`. This caused socket leaks — ESP-IDF's httpd manages its own session pool internally, and overriding these settings interfered with socket lifecycle management.
59
60**Symptoms**: Board works for 10-20 requests, then all HTTP becomes unresponsive. Sockets accumulate in CLOSE_WAIT/TIME_WAIT and never get freed.
61
62**Fix** (commit `144b48f`): Reverted to ESP-IDF defaults for all httpd settings except `stack_size=16384` and `max_uri_handlers`. Default `max_open_sockets=4` and `keep_alive_enable=true` (default) work correctly.
63
64### RC-2: Owner auto-grant (FIXED)
65
66`tollgate_core_client_connected()` granted firewall access to the first WiFi client unconditionally. IP was passed as `0` (bug), creating nondeterministic behavior.
67
68**Fix** (commit `c89ab31`): Removed `tollgate_core_fw_grant()` call from `client_connected()`. Owner tracking kept for logging.
69
70### RC-3: No boot-ready probe (PENDING)
71
72Tests use fixed sleeps after flash. No polling for HTTP server readiness.
73
74**Fix**: Add `arch-wait-ready` Makefile target that polls `:2121/usage`.
75
76### RC-4: Serial monitoring resets boards (DISCOVERED)
77
78Python `serial.Serial()` on USB-Serial/JTAG ESP32-S3 boards toggles DTR/RTS during initialization, causing `rst:0x15 (USB_UART_CHIP_RESET)`. This resets the chip even if `dtr=False, rts=False` is set after construction.
79
80**Symptoms**:
81- Board boots successfully, services start, gets IP
82- Python serial read causes immediate `ESP-ROM: boot:0x0 (DOWNLOAD)` or `rst:0x15`
83- Board appears "dead" after testing — actually reset into download mode
84- Earlier sessions attributed this to "socket exhaustion" or "WiFi instability"
85
86**Fix**: Never use Python `serial.Serial()` for monitoring. Use `idf.py monitor` (which handles DTR/RTS correctly) or read-only tools. All hardware access must go through Makefile mutex targets.
87
88---
89
90## Fix Steps
91
92### Step 0: Fix LWIP socket exhaustion — DONE
93- [x] Set `CONFIG_LWIP_MAX_SOCKETS=20` via sdkconfig (commit `144b48f`)
94- [x] Use default `max_open_sockets` on both HTTP servers (removed override)
95- [x] Verified: 50/50 sequential API requests pass on Board ACM2
96
97**Files**: `sdkconfig`, `main/captive_portal.c`, `main/tollgate_api.c`
98
99### Step 1: Kill owner auto-grant — DONE
100- [x] Remove `tollgate_core_fw_grant()` from `tollgate_core_client_connected()` (commit `c89ab31`)
101- [x] Keep owner tracking for logging
102
103**Files**: `components/tollgate_core/src/tollgate_core.c`
104
105### Step 2: HTTP server robustness — DONE
106- [x] Add `Connection: close` header to port 80 responses (commit `c89ab31`)
107- [x] Increase captive portal stack to 16384 (commit `c89ab31`)
108- [x] Use ESP-IDF default socket management (commit `144b48f`)
109
110**Files**: `main/captive_portal.c`, `main/tollgate_api.c`
111
112### Step 3: Add API endpoints — DONE
113- [x] `GET /portal-config` on port 2121 returning `{priceSats, mintUrl, ...}` (commit `c89ab31`)
114- [x] `GET /grant_access` — manual firewall grant (commit `c89ab31`)
115- [x] `GET /reset_authentication` — clear all auth (commit `c89ab31`)
116- [x] CORS header on portal-config
117
118**Files**: `main/tollgate_api.c`
119
120### Step 4: Remove NAPT flush from `fw_revoke_all()` — DONE
121- [x] Remove `ip_napt_enable()` toggle that caused 30s hangs (commit `c89ab31`)
122
123**Files**: `components/tollgate_core/src/tollgate_core_firewall.c`
124
125### Step 5: Boot-ready probe — PENDING
126- [ ] Add `arch-wait-ready` Makefile target that polls `:2121/usage`
127- [ ] Update `arch-test-full` to call `arch-wait-ready` first
128- [ ] Add 2-3 second delays between test requests (burst rate mitigation)
129
130**Files**: `physical-router-test-automation/esp32/Makefile`
131
132### Step 6: Hardware testing — BLOCKED
133- [ ] Flash to working board via Makefile mutex targets
134- [ ] Run `make arch-test-full`
135- [ ] Document results
136- [ ] Board A stuck in download mode (GPIO0 strapping pin) — needs hardware fix
137
138---
139
140## Burst Rate Limitation
141
142On USB-Serial/JTAG ESP32-S3 boards, back-to-back HTTP requests with no delay can
143overwhelm the WiFi AP stack. With 2-3 second delays between requests, the board
144handles 50+ sequential requests reliably. Without delays, rapid bursts of 10+
145requests can cause the WiFi AP to become unresponsive.
146
147**Mitigation**: E2E tests should include a 2-3 second delay between HTTP requests.
148This is a WiFi AP throughput limitation, not a firmware bug.
149
150## Board Status
151
152| Board | Port | MAC | Status |
153|-------|------|-----|--------|
154| Board A | `/dev/ttyACM0` | `94:a9:90:2e:37:7c` | **BROKEN** — stuck in download mode (`boot:0x0`), GPIO0 strapping pin issue, needs hardware fix |
155| Board B | `/dev/ttyACM1` | `fc:01:2c:c5:50:50` | Unknown — newly discovered, needs firmware flash |
156| Board C | `/dev/ttyACM2` | `20:6e:f1:98:d7:08` | **WORKING** — all endpoints verified, payment flow tested |
157
158## Key Architecture Decisions
159
160- **Port 80**: Portal HTML + captive detection URIs only. No API, no state mutation.
161- **Port 2121**: All API operations (discovery, payment, grant, reset, whoami, usage, wallet, portal-config).
162- **Owner tracking**: Kept for logging/display, no longer grants free internet.
163- **Connection: close**: Set on ALL port 80 responses to hint clients.
164- **Default httpd settings**: ESP-IDF's built-in session management works correctly. Do not override `max_open_sockets`, `keep_alive_enable`, `linger_timeout`, or timeouts.
165
166## Execution Order
167
168Steps 0-4 are DONE (commits `c89ab31`, `144b48f`).
169Step 5 (boot-ready probe) is next — code only, no hardware needed.
170Step 6 (validation) requires working board via Makefile mutex targets.
171
172## Hardware Access Rules
173
174- **ALWAYS** use Makefile mutex targets (`make arch-flash-a`, etc.) for hardware access
175- **NEVER** call `esptool.py` directly — bypasses mutex and conflicts with other sessions
176- **NEVER** use Python `serial.Serial()` for monitoring — causes DTR/RTS resets on USB-Serial/JTAG
177- Multiple opencode sessions may be active — mutex prevents board conflicts
diff --git a/docs/TOLLGATE_CORE_DESIGN.md b/docs/TOLLGATE_CORE_DESIGN.md
new file mode 100644
index 0000000..5132cf0
--- /dev/null
+++ b/docs/TOLLGATE_CORE_DESIGN.md
@@ -0,0 +1,446 @@
1# TollGate Core Component: Architecture Design
2
3## Goal
4
5Maintain all TollGate business logic in `esp32-tollgate` as a reusable ESP-IDF
6component (`tollgate_core`), and consume it in `esp-miner` (BitAxe) via the
7**IDF Component Manager**. No code duplication, no manual sync.
8
9## Current State (Pre-Refactoring)
10
11All TollGate modules live flat in `esp32-tollgate/main/`:
12
13```
14esp32-tollgate/main/
15 cashu.c / cashu.h
16 dns_server.c / dns_server.h
17 firewall.c / firewall.h
18 session.c / session.h
19 tollgate_api.c / tollgate_api.h
20 tollgate_client.c / tollgate_client.h
21 config.c / config.h
22 ...
23```
24
25The ESP-Miner port (`esp-miner/main/tollgate_*.c`) is a manual copy with edits:
26stripped prefixes (`cashu_` → `tollgate_cashu_`), NVS config instead of
27`config.h` singleton, removed wallet integration, moved cross-module wiring.
28
29### Shared Code by Module
30
31| Module | Shared % | Key Differences |
32|--------|----------|-----------------|
33| cashu | 73% | Config access, mint check parameterized |
34| dns_server | 74% | Minor logic reorder, logging stripped |
35| firewall | 94% | Cross-module DNS notification moved |
36| session | 79% | Bytes metric stripped, DNS notification added |
37| tollgate_api vs tollgate.c | 13% | Full rewrite (HTTP server vs library API) |
38| tollgate_client | 0% | No ESP-Miner equivalent |
39
40## Target Architecture
41
42### Directory Layout (in `esp32-tollgate`)
43
44```
45esp32-tollgate/
46 components/
47 tollgate_core/ ← shared ESP-IDF component
48 CMakeLists.txt
49 idf_component.yml ← component metadata for IDF Component Manager
50 include/
51 tollgate_core.h ← public API
52 tollgate_platform.h ← platform interface (config/state callbacks)
53 src/
54 tollgate_core_cashu.c ← from main/cashu.c
55 tollgate_core_cashu.h
56 tollgate_core_dns.c ← from main/dns_server.c
57 tollgate_core_dns.h
58 tollgate_core_firewall.c ← from main/firewall.c
59 tollgate_core_firewall.h
60 tollgate_core_session.c ← from main/session.c
61 tollgate_core_session.h
62 nucula_lib/ ← stays as-is (git submodule + wrapper)
63 CMakeLists.txt
64 nucula_wallet.cpp / .h
65 main/
66 tollgate_platform.c ← standalone impl of tollgate_platform.h
67 tollgate_api.c / .h ← standalone HTTP server (unchanged)
68 tollgate_client.c / .h ← standalone client mode (unchanged)
69 config.c / config.h ← standalone config (unchanged)
70 ...
71```
72
73### How ESP-Miner Consumes It
74
75In `esp-miner/main/idf_component.yml`:
76
77```yaml
78dependencies:
79 tollgate/core:
80 git: https://github.com/<user>/esp32-tollgate.git
81 path: components/tollgate_core
82```
83
84ESP-Miner provides only:
85
86```
87esp-miner/main/
88 tollgate_platform.c ← implements tollgate_platform.h (NVS config)
89 tollgate.c / .h ← ESP-Miner orchestrator (owner detection, WiFi events)
90 tollgate_page.html ← captive portal payment UI
91 lwip_tollgate_hooks.h ← LWIP hook (stays in esp-miner)
92 http_server.c ← modified to call tollgate_core API
93```
94
95### Why IDF Component Manager (not submodule)
96
97| Aspect | IDF Component Manager | Git Submodule |
98|--------|----------------------|---------------|
99| What's downloaded | Only `components/tollgate_core/` | Entire `esp32-tollgate` repo |
100| Update mechanism | Modify version in yml, rebuild | Manual `git submodule update` |
101| Transitive deps | Automatic (nucula_lib resolved) | Must manage manually |
102| CI/CD | Automatic on `idf.py build` | Needs `--recursive` clone |
103| Offline after first build | Yes (cached in managed_components) | Yes |
104| Contributor friction | Low (automatic) | Moderate (forgot --recursive) |
105
106ESP-Miner never reaches into tollgate_core's source tree. It calls a clean API
107and provides a platform implementation. This is exactly the "packaged API
108consumption" pattern the Component Manager is designed for.
109
110### Why Git Submodule for nucula (not Component Manager)
111
112nucula is consumed differently — it's a **raw source integration**:
113
114```cmake
115# nucula_lib/CMakeLists.txt reaches INTO the submodule and cherry-picks files:
116set(NUCULA_SRC ${CMAKE_CURRENT_SOURCE_DIR}/../../nucula_src/main)
117idf_component_register(
118 SRCS "nucula_wallet.cpp"
119 "${NUCULA_SRC}/crypto.c" # cherry-picked
120 "${NUCULA_SRC}/wallet.cpp" # cherry-picked
121 "${NUCULA_SRC}/cashu_json.cpp" # cherry-picked (6 of ~20 files)
122 "${NUCULA_SRC}/nut10.cpp"
123 "${NUCULA_SRC}/hex.c"
124 "${NUCULA_SRC}/http.c"
125 ...
126)
127```
128
129The Component Manager downloads packaged components — you get everything or
130nothing. You can't say "give me this component but only compile these 6 files
131from it." A git submodule gives you the raw source tree on disk, which is what
132cherry-picking requires.
133
134**Principle:** Need to reach into source tree and pick files? → Submodule.
135Only need a clean API? → Component Manager.
136
137### The Platform Interface
138
139```c
140// components/tollgate_core/include/tollgate_platform.h
141
142#ifndef TOLLGATE_PLATFORM_H
143#define TOLLGATE_PLATFORM_H
144
145#include <stdint.h>
146#include <stdbool.h>
147
148typedef struct {
149 // Config access (each project implements its own storage)
150 uint16_t (*get_price_sats)(void);
151 int32_t (*get_step_ms)(void);
152 const char * (*get_mint_url)(void);
153 const char * (*get_metric)(void); // "milliseconds" or "bytes"
154 int32_t (*get_step_bytes)(void);
155
156 // Time source
157 int64_t (*get_time_ms)(void);
158
159 // Wallet integration: called after proofs verified, before session create
160 // Return true to proceed, false to reject payment
161 // Can be NULL (accepts payment without spending proofs — double-spend risk)
162 bool (*spend_proofs)(const char *raw_token_json);
163} tollgate_platform_t;
164
165#endif
166```
167
168**Standalone implementation** (`main/tollgate_platform.c`):
169- Reads from `tollgate_config_get()` singleton (SPIFFS-backed)
170- `spend_proofs` calls `nucula_wallet_receive()` to swap proofs at the mint
171
172**ESP-Miner implementation** (`main/tollgate_platform.c`):
173- Reads from `nvs_config_get_*()` (NVS flash)
174- `spend_proofs` is initially NULL (Phase 1: accept without spending)
175- Later: calls nucula_wallet_receive when wallet component is integrated
176
177### Wallet Integration: The Double-Spend Problem
178
179The `spend_proofs` hook exists because of a real security gap:
180
181```
182Client sends Cashu token
183
184
185cashu_decode_token() ← extract proofs
186
187
188cashu_check_proof_states() ← HTTP POST to mint /v1/checkstate: "unspent?"
189
190
191spend_proofs() ← THE CRITICAL STEP
192 │ standalone: nucula_wallet_receive() → swap at mint
193 │ esp-miner: NULL → skipped (double-spend window)
194
195session_create() ← grant client access
196```
197
198Without `spend_proofs`, a client can replay the same token on multiple devices.
199Both check "unspent?" → both say yes → both grant access. The swap step marks
200proofs as spent at the mint, closing the window.
201
202ESP-Miner accepts this risk initially. When `spend_proofs` is NULL, the
203component logs a warning. Phase 2 of ESP-Miner integration adds nucula and
204implements the hook.
205
206### Cross-Module Wiring (Internal to tollgate_core)
207
208The `session → firewall → dns_server` notification chain stays internal:
209
210```
211tollgate_core_session_create()
212 → tollgate_core_firewall_grant(ip)
213 → tollgate_core_dns_set_authenticated(ip, true)
214
215tollgate_core_session_revoke()
216 → tollgate_core_firewall_revoke(ip)
217 → tollgate_core_dns_set_authenticated(ip, false)
218```
219
220Consumers never see this. They call `tollgate_core_process_payment()` and
221`tollgate_core_tick()`. The internal wiring is an implementation detail.
222
223### Full Dependency Graph
224
225```
226esp-miner
227 └── IDF Component Manager → tollgate_core (API-level boundary)
228 ├── CMakeLists.txt REQUIRES: nucula_lib
229 └── Platform: esp-miner provides tollgate_platform_t (NVS-backed)
230
231esp32-tollgate (standalone)
232 └── tollgate_core (local component, same repo)
233 ├── CMakeLists.txt REQUIRES: nucula_lib
234 └── Platform: main/tollgate_platform.c (config singleton-backed)
235
236nucula_lib (local component in esp32-tollgate)
237 └── cherry-picks source files from nucula_src/ (git submodule → zeugmaster/nucula)
238```
239
240### Dependency Chain for IDF Component Manager
241
242When `esp-miner` declares:
243
244```yaml
245dependencies:
246 tollgate/core:
247 git: https://github.com/<user>/esp32-tollgate.git
248 path: components/tollgate_core
249```
250
251The Component Manager:
2521. Clones `esp32-tollgate` (or fetches the component archive)
2532. Reads `tollgate_core/idf_component.yml` → finds dependency on `nucula_lib`
2543. Since `nucula_lib` is a sibling component in the same repo, resolves it
255 from the same clone
2564. Downloads into `managed_components/`
2575. `nucula_lib` depends on `secp256k1` (local component) and `nucula_src`
258 (submodule) — these must be available within the cloned repo
259
260**Note:** The git submodule within `nucula_src` needs verification. The IDF
261Component Manager may or may not initialize submodules within a git-sourced
262dependency. This needs testing. If it doesn't, `nucula_lib` may need to bundle
263the required nucula source files directly instead of referencing a submodule.
264
265## Blocking Dependencies
266
267This refactoring **must not proceed** until these branches land on master:
268
269| Branch | Blocking Files | Status |
270|--------|---------------|--------|
271| `feature/multi-mint-support` | `cashu.c`, `tollgate_api.c`, `main/CMakeLists.txt`, `nucula_wallet.cpp/h`, `captive_portal.c`, `mint_health.c/h`, `config.c/h` | **In progress** |
272| `feature/price-discovery` | `tollgate_api.c`, `tollgate_client.c`, `main/CMakeLists.txt`, `config.c/h`, `beacon_price.c/h`, `market.c/h` | **In progress** |
273| `feature/cvm-integration` | Same commit as master — no new changes | **Merged already** |
274
275**Specific conflicts if we refactor now:**
276- Moving `cashu.c` → `tollgate_core_cashu.c` while multi-mint modifies `cashu.c`
277- Moving `dns_server.c` while price-discovery may touch it
278- Modifying `main/CMakeLists.txt` (remove SRCS) while all branches modify it
279- Modifying `tollgate_api.c` call sites while multi-mint and price-discovery modify it
280
281## Refactoring Plan (After Blocking PRs Merge)
282
283### Phase 0: Prerequisites
284
285- [ ] All blocking PRs merged to master
286- [ ] This branch rebased onto latest master
287- [x] Full build passes on master
288
289### Phase 1: Create Component Skeleton
290
291- [x] Create `components/tollgate_core/` directory structure
292- [x] Create `components/tollgate_core/include/tollgate_core.h` (public API)
293- [x] Create `components/tollgate_core/include/tollgate_platform.h` (platform interface)
294- [x] Create `components/tollgate_core/idf_component.yml` (component metadata)
295- [x] Create `components/tollgate_core/CMakeLists.txt` (register component)
296- [ ] Verify empty component builds without errors
297
298### Phase 2: Move Core Modules (one at a time, build after each)
299
300- [x] Copy `main/cashu.c/h` → `components/tollgate_core/src/tollgate_core_cashu.c/h`
301 - [x] Rename functions: `cashu_*` → `tollgate_core_cashu_*`
302 - [x] Replace `tollgate_config_get()` calls with parameterized arguments
303 - [x] Remove direct `config.h` include
304 - [ ] Build and verify
305- [x] Copy `main/dns_server.c/h` → `components/tollgate_core/src/tollgate_core_dns.c/h`
306 - [x] Rename functions: `dns_server_*` → `tollgate_core_dns_*`
307 - [x] No platform dependencies (pure LWIP) — clean copy
308 - [ ] Build and verify
309- [x] Copy `main/firewall.c/h` → `components/tollgate_core/src/tollgate_core_firewall.c/h`
310 - [x] Rename functions: `firewall_*` → `tollgate_core_firewall_*` / `tollgate_core_fw_*`
311 - [x] Internalize `dns_set_authenticated` calls (kept within component)
312 - [x] Remove `dns_server.h` external dependency
313 - [ ] Build and verify
314- [x] Copy `main/session.c/h` → `components/tollgate_core/src/tollgate_core_session.c/h`
315 - [x] Rename functions: `session_*` → `tollgate_core_session_*`
316 - [x] Replace `config.h` calls with platform callbacks for metric check
317 - [x] Internalize firewall notification (already calls firewall directly)
318 - [x] Support both time and bytes metrics (portable, not stripped)
319 - [ ] Build and verify
320
321### Phase 3: Wire Component API
322
323- [x] Implement `tollgate_core_init(const tollgate_platform_t *platform, esp_ip4_addr_t ap_ip)` — stores platform, inits all sub-modules
324- [x] Implement `tollgate_core_process_payment(ip, token)` — decode → verify → spend → create session
325- [x] Implement `tollgate_core_client_connected(mac, ip)` — owner detection + firewall check
326- [x] Implement `tollgate_core_client_disconnected(mac)` — session cleanup + owner reassign
327- [x] Implement `tollgate_core_tick()` — session expiry check
328- [x] Implement `tollgate_core_get_status_json()` — JSON status
329- [x] Implement `tollgate_core_get_config_json()` — JSON config (via platform)
330- [x] Build and verify standalone
331
332### Phase 4: Standalone Platform Implementation
333
334- [x] Create `main/tollgate_platform.c` implementing `tollgate_platform_t`
335 - [x] `get_price_sats` → `tollgate_config_get()->price_per_step`
336 - [x] `get_step_ms` → `tollgate_config_get()->step_size`
337 - [x] `get_mint_url` → `tollgate_config_get()->mint_url`
338 - [x] `get_metric` → `tollgate_config_get()->metric`
339 - [x] `get_step_bytes` → `tollgate_config_get()->step_bytes`
340 - [x] `get_time_ms` → `xTaskGetTickCount() * portTICK_PERIOD_MS`
341 - [x] `spend_proofs` → stub returning true (wallet called separately)
342- [x] Update `main/tollgate_api.c` to call `tollgate_core_*` instead of direct module calls
343- [x] Update `main/tollgate_main.c` init sequence
344- [x] Remove old `main/cashu.c`, `main/dns_server.c`, `main/firewall.c`, `main/session.c` from CMakeLists.txt
345- [x] Update `main/CMakeLists.txt` (remove old SRCS, add `tollgate_platform.c`, add `tollgate_core` to REQUIRES)
346- [x] Update `main/lwip_tollgate_hooks.h` to call `tollgate_core_ip4_canforward_filter`
347- [x] Full standalone build + test (verified: `c8c68dc` — build passes, 61/61 unit tests pass)
348
349### Phase 4.5: Physical Board E2E Testing (Board A)
350
351- [x] Create `tests/integration/helpers/network.mjs` (shared test utilities)
352- [x] Add arch test Makefile targets with mutex protection to `physical-router-test-automation/esp32/Makefile`
353- [x] Add top-level Makefile wrappers for arch tests
354- [ ] Acquire Board A mutex lock
355- [ ] Flash arch firmware to Board A
356- [ ] Verify boot via serial (no panics, services started)
357- [ ] Connect WiFi to Board A AP
358- [ ] Run smoke test (`arch-test-smoke`)
359- [ ] Run network test (`arch-test-network`)
360- [ ] Run API test (`arch-test-api`)
361- [ ] Run DNS + firewall test (`arch-test-dns-fw`)
362- [ ] Run reset auth test (`arch-test-reset`)
363- [ ] Run session expiry test (`arch-test-session`)
364- [ ] Run phase 2 API test (`arch-test-phase2`)
365- [ ] Commit and push test results
366- [ ] Release Board A mutex lock
367
368### Phase 5: ESP-Miner Integration
369
370- [ ] Update `esp-miner/main/idf_component.yml` to add tollgate_core dependency
371- [ ] Create `esp-miner/main/tollgate_platform.c` implementing `tollgate_platform_t`
372 - [ ] Config reads from NVS (`nvs_config_get_*`)
373 - [ ] `spend_proofs` = NULL initially (Phase 1: accept without spending)
374- [ ] Update `esp-miner/main/tollgate.c` to call `tollgate_core_*` API
375- [ ] Remove `esp-miner/main/tollgate_cashu.c`, `tollgate_dns.c`, `tollgate_firewall.c`, `tollgate_session.c`
376- [ ] Update `esp-miner/main/CMakeLists.txt` (remove old SRCS)
377- [ ] Full ESP-Miner build + test
378
379### Phase 6: Verify Component Manager Flow
380
381- [ ] Remove local `managed_components/` if present
382- [ ] Run `idf.py reconfigure` in esp-miner — verify Component Manager downloads tollgate_core
383- [ ] Run `idf.py build` — verify transitive dependency resolution (nucula_lib + nucula_src)
384- [ ] Test that submodule within nucula_src is properly initialized by Component Manager
385- [ ] If submodule init fails: bundle nucula source files directly in nucula_lib instead
386
387### Phase 7: Documentation and Cleanup
388
389- [ ] Update `esp-miner/main/idf_component.yml` with correct git URL
390- [ ] Update `esp-miner/TOLLGATE_PR_PLAN.md` to reflect component-based architecture
391- [ ] Add `docs/` to `tollgate_core` with integration guide for new consumers
392- [ ] Update `esp-miner/TOLLGATE_CHECKLIST.md`
393- [ ] Verify both projects build clean from scratch
394
395## Open Questions
396
397- [ ] Does the IDF Component Manager initialize git submodules within git-sourced dependencies?
398- [ ] Should tollgate_core publish to the ESP Component Registry (public) or stay git-only?
399- [ ] What versioning scheme for tollgate_core? (semver tags in esp32-tollgate?)
400
401## Performance Optimization Backlog
402
403### Burst Rate Limitation (KNOWN ISSUE)
404
405USB-Serial/JTAG ESP32-S3 boards have a WiFi AP throughput ceiling. Back-to-back
406HTTP requests with no delay (>10 requests/sec) can overwhelm the AP stack,
407causing TCP connections to time out. With 2-3 second delays between requests,
408the board handles 50+ sequential requests reliably.
409
410**Mitigation**: E2E tests include 2-3 second delays between requests. This is
411a WiFi AP limitation, not a firmware bug.
412
413### Serial Monitoring Causes Resets (DISCOVERED)
414
415Python `serial.Serial()` on USB-Serial/JTAG ESP32-S3 boards toggles DTR/RTS
416during initialization, causing `rst:0x15 (USB_UART_CHIP_RESET)`. This resets
417the chip even if `dtr=False, rts=False` is set post-construction. Multiple
418sessions accessing serial ports without mutex coordination compound the issue.
419
420**Mitigation**: All hardware access goes through Makefile mutex targets. Never
421use Python `serial.Serial()` directly. Use `idf.py monitor` for serial output.
422
423### Captive Detection Flood
424- [ ] Rate-limit or debounce captive detection URI handlers (`/generate_204`, `/hotspot-detect.html`, etc.) to prevent socket exhaustion from OS/browser probes
425- [ ] Consider single-handler approach: all captive URIs return a minimal 204/302 without processing HTML template
426- [ ] Evaluate `lru_purge_enable = true` with tuned `max_open_sockets` and `recv_wait_timeout`
427
428### Static Portal HTML (No Dynamic Template Substitution)
429- [ ] Replace `__AP_IP__`, `__PRICE__`, `__MINT_URL__` template substitution with static const HTML
430- [ ] Portal JS fetches config at load time from `:2121/` API (already returns `kind=10021` with `price_per_step` and mint URL)
431- [ ] Eliminates `malloc()` + `strstr()` loop per request — zero-computation static serve
432- [ ] Reduces portal handler latency from ~47s to near-instant
433
434### HTTP Server Tuning
435
436**IMPORTANT**: Use ESP-IDF defaults for `max_open_sockets`, `keep_alive_enable`,
437`linger_timeout`, `recv_wait_timeout`, and `send_wait_timeout`. Overriding these
438causes socket leaks (verified: `max_open_sockets=2` + `keep_alive_enable=false`
439caused complete socket exhaustion after 15-20 requests).
440
441- [x] Set `stack_size=16384` on both servers (fixed ESP_ERR_HTTPD_TASK)
442- [x] Set `CONFIG_LWIP_MAX_SOCKETS=20` (matches standalone tollgate)
443- [x] Use default `max_open_sockets=4` on both servers
444- [x] Separate `ctrl_port` values for portal vs API servers
445- [ ] Consider `lru_purge_enable = true` for production tuning
446- [ ] Should `tollgate_client.c` (client mode) eventually move into tollgate_core?
diff --git a/docs/WPA_AUTODETECT_PLAN.md b/docs/WPA_AUTODETECT_PLAN.md
new file mode 100644
index 0000000..8228b1a
--- /dev/null
+++ b/docs/WPA_AUTODETECT_PLAN.md
@@ -0,0 +1,102 @@
1# WPA Auto-Detect: SPIFFS-Based WiFi Security Configuration
2
3## Problem
4
5The ESP32-S3 firmware hardcodes `WIFI_AUTH_WPA3_PSK` as the STA auth threshold in
6`config.c:289`. When the upstream router uses WPA2-PSK only, the ESP32 scan filter
7rejects the AP and reports reason=211 (`WIFI_REASON_NO_AP_FOUND`).
8
9## Root Cause
10
11```c
12// config.c:289 — BEFORE
13wifi_config->sta.threshold.authmode = WIFI_AUTH_WPA3_PSK;
14```
15
16The `threshold.authmode` field tells the ESP32 WiFi driver to only associate with APs
17that support the specified auth mode or better. WPA3-only threshold means WPA2 APs are
18invisible during scan.
19
20## Solution
21
22Adopt the SPIFFS-based WPA auto-detect pattern from the multi-mint firmware
23(`physical-router-test-automation/esp32/Makefile`). The approach:
24
251. **Build time**: `detect-wpa-security` scans the host's WiFi to determine if the
26 target SSID advertises WPA2 or WPA3.
272. **SPIFFS generation**: `generate-spiffs` writes a `config.json` with the detected
28 `wifi_auth_mode` field.
293. **Flash**: SPIFFS partition is flashed separately from firmware, so config can be
30 updated without rebuilding.
314. **Runtime**: Firmware parses `wifi_auth_mode` from `config.json` and maps it to the
32 correct `wifi_auth_mode_t` threshold.
33
34## Files to Modify
35
36### Firmware (`esp32-tollgate-arch`)
37
38| File | Change |
39|------|--------|
40| `main/config.h` | Add `wifi_auth_threshold` field to `tollgate_config_t` |
41| `main/config.c` | Parse `wifi_auth_mode` from config.json, set default to WPA2, use in `tollgate_config_get_wifi()` |
42
43### Test Automation (`physical-router-test-automation`)
44
45| File | Change |
46|------|--------|
47| `esp32/Makefile` | Add `arch-generate-spiffs`, `arch-flash-spiffs-a` targets |
48| `Makefile` | Add top-level wrappers |
49
50## Checklist
51
52### Firmware Changes
53
54- [x] Add `wifi_auth_threshold` field to `tollgate_config_t` in `config.h`
55- [ ] Set default `wifi_auth_threshold = WIFI_AUTH_WPA2_PSK` in `tollgate_config_init()`
56- [ ] Parse `"wifi_auth_mode"` string from config.json in `tollgate_config_init()`
57- [ ] Map `"WPA3"` → `WIFI_AUTH_WPA3_PSK`, anything else → `WIFI_AUTH_WPA2_PSK`
58- [ ] Replace hardcoded `WIFI_AUTH_WPA3_PSK` with `g_config.wifi_auth_threshold` in `tollgate_config_get_wifi()`
59- [ ] Build succeeds (`idf.py build`)
60
61### Makefile Changes
62
63- [ ] Add `arch-generate-spiffs` target to `esp32/Makefile`
64- [ ] Add `arch-flash-spiffs-a` target to `esp32/Makefile` (requires lock-a)
65- [ ] Add top-level wrappers in `Makefile`
66- [ ] Add help text entries
67
68### Build & Flash
69
70- [ ] Rebuild firmware with WPA auto-detect support
71- [ ] Acquire Board A lock
72- [ ] Run `detect-wpa-security` to confirm WPA2 detection
73- [ ] Run `arch-generate-spiffs` to build SPIFFS image
74- [ ] Run `arch-flash-a` to flash firmware (full erase + rebuild)
75- [ ] Run `arch-flash-spiffs-a` to flash SPIFFS with WPA2 config
76- [ ] Wait for boot, connect to Board A AP
77
78### Verification
79
80- [x] Serial log shows STA connected to upstream WiFi (no more reason=211)
81- [x] Serial log shows "TollGate services started"
82- [x] API on port 2121 reachable
83- [x] Portal on port 80 reachable
84- [x] Cashu payment works: `cashu send --legacy 21` → POST to `:2121` → kind=1022
85
86### E2E Tests
87
88- [x] `make arch-test-smoke` — **6/6 PASS** (was 5/6, internet now works!)
89- [x] `make arch-test-api` — 16/20 pass (4 test expectation mismatches)
90- [x] `make arch-test-dns-fw` — 9/15 pass (payment works! DNS hijack tests need env fix)
91- [x] `make arch-test-reset` — **11/13 pass** (payment+reset works, second payment token issue)
92- [x] `make arch-test-session` — 7/11 pass (session expiry works, renewal works)
93- [x] `make arch-test-phase2` — **12/12 PASS** (all API tests pass)
94- [ ] `make arch-test-network` — 3/7 pass (DNS tests need env fix)
95
96### Commit & Push
97
98- [ ] Commit firmware changes to `feature/tollgate-core-component`
99- [ ] Push to ngit remote
100- [ ] Commit Makefile changes to `feature/router-to-router-interaction`
101- [ ] Push to ngit remote
102- [ ] Release Board A lock
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 90000b7..2107cf1 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -30,9 +30,10 @@ idf_component_register(SRCS "tollgate_main.c"
30 "stratum_proxy.c" 30 "stratum_proxy.c"
31 "sw_miner.c" 31 "sw_miner.c"
32 "asic_miner.c" 32 "asic_miner.c"
33 "tollgate_platform.c"
33 INCLUDE_DIRS "." 34 INCLUDE_DIRS "."
34 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 35 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
35 lwip json esp_http_client mbedtls esp-tls log spiffs 36 lwip json esp_http_client mbedtls esp-tls log spiffs
36 nucula_lib secp256k1 axs15231b qrcode wisp_relay 37 nucula_lib secp256k1 axs15231b qrcode wisp_relay
37 negentropy_lib tcp_transport 38 negentropy_lib tcp_transport tollgate_core
38 PRIV_REQUIRES esp-tls) 39 PRIV_REQUIRES esp-tls)
diff --git a/main/config.c b/main/config.c
index aa7da6d..43ab2a7 100644
--- a/main/config.c
+++ b/main/config.c
@@ -46,6 +46,9 @@ esp_err_t tollgate_config_init(void)
46 g_config.stratum_port = 3333; 46 g_config.stratum_port = 3333;
47 g_config.mining_port = 3334; 47 g_config.mining_port = 3334;
48 g_config.mining_sandbox_mint_access = true; 48 g_config.mining_sandbox_mint_access = true;
49 g_config.market_enabled = true;
50 g_config.market_scan_interval_s = 30;
51 g_config.client_auto_switch = false;
49 52
50 esp_vfs_spiffs_conf_t conf = { 53 esp_vfs_spiffs_conf_t conf = {
51 .base_path = "/spiffs", 54 .base_path = "/spiffs",
@@ -361,6 +364,15 @@ esp_err_t tollgate_config_init(void)
361 if (m_sandbox && cJSON_IsBool(m_sandbox)) g_config.mining_sandbox_mint_access = cJSON_IsTrue(m_sandbox); 364 if (m_sandbox && cJSON_IsBool(m_sandbox)) g_config.mining_sandbox_mint_access = cJSON_IsTrue(m_sandbox);
362 } 365 }
363 366
367 cJSON *market_enabled = cJSON_GetObjectItem(root, "market_enabled");
368 if (market_enabled && cJSON_IsBool(market_enabled)) g_config.market_enabled = cJSON_IsTrue(market_enabled);
369
370 cJSON *market_scan_interval = cJSON_GetObjectItem(root, "market_scan_interval_s");
371 if (market_scan_interval) g_config.market_scan_interval_s = market_scan_interval->valueint;
372
373 cJSON *client_auto_switch = cJSON_GetObjectItem(root, "client_auto_switch");
374 if (client_auto_switch && cJSON_IsBool(client_auto_switch)) g_config.client_auto_switch = cJSON_IsTrue(client_auto_switch);
375
364 cJSON_Delete(root); 376 cJSON_Delete(root);
365 377
366 if (g_config.payout.recipient_count == 0) { 378 if (g_config.payout.recipient_count == 0) {
diff --git a/main/lwip_tollgate_hooks.h b/main/lwip_tollgate_hooks.h
index 76017be..2953c48 100644
--- a/main/lwip_tollgate_hooks.h
+++ b/main/lwip_tollgate_hooks.h
@@ -3,8 +3,8 @@
3 3
4#include "lwip/pbuf.h" 4#include "lwip/pbuf.h"
5 5
6int tollgate_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder); 6int tollgate_core_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder);
7 7
8#define LWIP_HOOK_IP4_CANFORWARD(p, addr) tollgate_ip4_canforward_filter(p, addr) 8#define LWIP_HOOK_IP4_CANFORWARD(p, addr) tollgate_core_ip4_canforward_filter(p, addr)
9 9
10#endif 10#endif
diff --git a/main/tollgate_platform.c b/main/tollgate_platform.c
new file mode 100644
index 0000000..4083c4e
--- /dev/null
+++ b/main/tollgate_platform.c
@@ -0,0 +1,63 @@
1#include "tollgate_platform.h"
2#include "tollgate_core.h"
3#include "config.h"
4#include "esp_log.h"
5#include "freertos/FreeRTOS.h"
6#include "freertos/task.h"
7
8static const char *TAG = "tollgate_platform";
9
10static uint16_t platform_get_price_sats(void)
11{
12 const tollgate_config_t *cfg = tollgate_config_get();
13 return cfg ? (uint16_t)cfg->price_per_step : 21;
14}
15
16static int32_t platform_get_step_ms(void)
17{
18 const tollgate_config_t *cfg = tollgate_config_get();
19 return cfg ? (int32_t)cfg->step_size_ms : 60000;
20}
21
22static const char *platform_get_mint_url(void)
23{
24 const tollgate_config_t *cfg = tollgate_config_get();
25 return cfg ? cfg->mint_url : "https://testnut.cashu.space";
26}
27
28static const char *platform_get_metric(void)
29{
30 const tollgate_config_t *cfg = tollgate_config_get();
31 return cfg ? cfg->metric : "milliseconds";
32}
33
34static int32_t platform_get_step_bytes(void)
35{
36 const tollgate_config_t *cfg = tollgate_config_get();
37 return cfg ? (int32_t)cfg->step_size_bytes : 22020096;
38}
39
40static int64_t platform_get_time_ms(void)
41{
42 return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
43}
44
45static bool platform_spend_proofs(const char *raw_token_json)
46{
47 (void)raw_token_json;
48 return true;
49}
50
51const tollgate_platform_t *tollgate_get_platform(void)
52{
53 static const tollgate_platform_t platform = {
54 .get_price_sats = platform_get_price_sats,
55 .get_step_ms = platform_get_step_ms,
56 .get_mint_url = platform_get_mint_url,
57 .get_metric = platform_get_metric,
58 .get_step_bytes = platform_get_step_bytes,
59 .get_time_ms = platform_get_time_ms,
60 .spend_proofs = platform_spend_proofs,
61 };
62 return &platform;
63}
diff --git a/tests/integration/helpers/network.mjs b/tests/integration/helpers/network.mjs
new file mode 100644
index 0000000..0d6013d
--- /dev/null
+++ b/tests/integration/helpers/network.mjs
@@ -0,0 +1,67 @@
1import { execSync } from 'child_process';
2
3const DEFAULT_IP = '10.192.45.1';
4const WIFI_IFACE = 'wlp59s0';
5
6export function getPortalIP() {
7 return process.env.TOLLGATE_IP || DEFAULT_IP;
8}
9
10export function curl(url, timeout = 30) {
11 try {
12 return execSync(
13 `curl -s -o /dev/null -w "%{http_code}" --connect-timeout ${timeout} --max-time ${timeout + 10} "${url}"`,
14 { encoding: 'utf8', timeout: (timeout + 10) * 1000 }
15 ).trim();
16 } catch {
17 return null;
18 }
19}
20
21export function curlBody(url, timeout = 30) {
22 try {
23 return execSync(
24 `curl -s --connect-timeout ${timeout} --max-time ${timeout + 10} "${url}"`,
25 { encoding: 'utf8', timeout: (timeout + 10) * 1000 }
26 );
27 } catch {
28 return null;
29 }
30}
31
32export function canPing(host = '8.8.8.8', count = 1) {
33 try {
34 const result = execSync(
35 `ping -c ${count} -W 3 -I ${WIFI_IFACE} ${host} 2>/dev/null`,
36 { encoding: 'utf8', timeout: 10000 }
37 );
38 return result && !result.includes('100% packet loss');
39 } catch {
40 return false;
41 }
42}
43
44export function canResolve(domain, timeout = 5) {
45 try {
46 const result = execSync(
47 `dig +short +timeout=${timeout} +tries=1 ${domain} 2>&1`,
48 { encoding: 'utf8', timeout: (timeout + 2) * 1000 }
49 ).trim();
50 return result.length > 0 && !result.includes('NXDOMAIN');
51 } catch {
52 return false;
53 }
54}
55
56export function dnsResolvesToSelf(domain) {
57 const ip = getPortalIP();
58 try {
59 const result = execSync(
60 `dig +short +timeout=5 ${domain} @{ip} 2>&1`,
61 { encoding: 'utf8', timeout: 10000 }
62 ).trim();
63 return result === ip;
64 } catch {
65 return false;
66 }
67}