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-17 16:39:31 +0530
committerYour Name <you@example.com>2026-05-17 16:39:31 +0530
commit0c2c67b463d6a90aaa0bb69bf3c91dba1d9ec3ec (patch)
treeafd9d9bca2d184825ebf7413ec31830e14131030
parent3342c8e7b4f645c75470d3d893d09037a672cfd2 (diff)
feat: per-client NAT filtering via LWIP_HOOK_IP4_CANFORWARD
- Add lwip_tollgate_hooks.h defining LWIP_HOOK_IP4_CANFORWARD macro - Inject hook into lwIP build via CMakeLists.txt ESP_IDF_LWIP_HOOK_FILENAME - Filter forwarded packets by source IP against firewall allowed list - Only filter packets from AP subnet (10.192.45.0/24), allow all others - Fix byte order bug: use network byte order for firewall_is_client_allowed - NAT always enabled, removed global NAT toggle functions - Remove spent-secret tracking from session.c (mint is authority) - Remove unused get_ap_netif() function - Reduce API server stack from 32KB to 16KB (fixes ESP_ERR_HTTPD_TASK) - Add esp_random.h stub for unit tests - All 186 unit tests passing - Verified on hardware: block->pay->allow->revoke->block E2E works
-rw-r--r--CHECKLIST.md382
-rw-r--r--CMakeLists.txt4
-rw-r--r--PLAN.md75
-rw-r--r--main/firewall.c51
-rw-r--r--main/firewall.h6
-rw-r--r--main/lwip_tollgate_hooks.h10
-rw-r--r--main/session.c48
-rw-r--r--main/session.h10
-rw-r--r--main/tollgate_api.c25
-rw-r--r--main/tollgate_main.c1
-rw-r--r--tests/unit/stubs/esp_random.h6
-rwxr-xr-xtests/unit/test_identitybin296728 -> 297880 bytes
-rwxr-xr-xtests/unit/test_mcp_handlerbin0 -> 38736 bytes
-rwxr-xr-xtests/unit/test_nip04bin0 -> 298776 bytes
-rw-r--r--tests/unit/test_session.c37
-rwxr-xr-xtests/unit/test_tollgate_clientbin51904 -> 51992 bytes
16 files changed, 237 insertions, 418 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md
index 5cedd30..b71bd14 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -43,34 +43,10 @@
43- [x] Makefile: nutshell wallet targets (wallet-setup, wallet-info, mint-token, send-token) 43- [x] Makefile: nutshell wallet targets (wallet-setup, wallet-info, mint-token, send-token)
44- [x] `tests/phase2.mjs`: `/whoami` test checks `includes('mac=')` 44- [x] `tests/phase2.mjs`: `/whoami` test checks `includes('mac=')`
45 45
46### Infrastructure
47- [x] Upstream gateway on enx00e04c633a90 (192.168.2.0/24, metric 101, default route)
48- [x] WiFi wlp59s0 free for ESP32 TollGate connection
49- [x] Mint URL verified: `testnut.cashu.space` works (auto-pays invoices)
50
51### Tests Passing 46### Tests Passing
52- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING 47- [x] Tests 15-24: ALL PASSING
53- [x] Test 16: Valid payment (POST :2121/ with valid Cashu token → kind=1022 session) — PASSING
54- [x] Test 17: Usage tracking after payment (GET :2121/usage → active usage) — PASSING
55- [x] Test 18: Internet after payment (ping through TollGate works) — PASSING
56- [x] Test 19: Invalid token rejected (POST garbage → 400, kind=21023) — PASSING
57- [x] Test 20: Spent token rejected (reuse token → kind=21023) — PASSING
58- [x] Test 21: Wrong mint rejected (POST token from wrong mint → kind=21023) — PASSING
59- [x] Test 22: Session expiry (wait for allotment → internet blocked) — PASSING
60- [x] Test 23: Session renewal (second payment → allotment extended) — PASSING
61- [x] Test: /whoami returns ip=X.X.X.X mac=XX:XX:XX:XX:XX:XX — PASSING
62- [x] Test: Portal has payment form (Cashu token input + Pay button) — PASSING
63
64### Captive Portal Detection Fix
65- [x] Added DoT reject server on port 853 (TCP RST forces DNS fallback to port 53)
66- [x] DNS hijack now returns NXDOMAIN for ALL non-A query types (prevents DNS leaks)
67- [x] Shorter TTL on hijack responses (10s) for faster detection
68- [x] Explicit 302 redirect handlers for all captive detection URIs (/generate_204, /hotspot-detect.html, etc.)
69- [x] HTTP request logging for captive detection endpoints
70- [x] DNS query logging for unauthenticated clients
71- [x] Verified working with GrapheneOS phone (commit `236b61d`)
72 48
73## Phase 3: On-Device Wallet + Nostr Identity + Wifistr — IN PROGRESS 49## Phase 3: On-Device Wallet + Nostr Identity + Wifistr — COMPLETE
74### nucula Wallet Integration 50### nucula Wallet Integration
75- [x] Add nucula as git submodule (`nucula_src/`) 51- [x] Add nucula as git submodule (`nucula_src/`)
76- [x] Create `components/secp256k1/` (symlink to nucula's libsecp256k1) 52- [x] Create `components/secp256k1/` (symlink to nucula's libsecp256k1)
@@ -79,271 +55,125 @@
79- [x] All wallet operations tested on Board A: pay, swap, send, persistence 55- [x] All wallet operations tested on Board A: pay, swap, send, persistence
80 56
81### Nostr Identity Derivation (identity.c/h) 57### Nostr Identity Derivation (identity.c/h)
82- [x] Create `identity.h` — API: `identity_init(nsec_hex)`, derived value accessors 58- [x] HMAC-SHA512 derivation via mbedtls, npub via secp256k1
83- [x] Create `identity.c` — HMAC-SHA512 derivation via mbedtls, npub via secp256k1 59- [x] Derive STA/AP MAC, SSID, AP IP from nsec
84- [x] Derive STA MAC: `tollgate_derive(nsec, "sta-mac", 0)` → 6 bytes, locally administered
85- [x] Derive AP MAC: `tollgate_derive(nsec, "ap-mac", 0)` → 6 bytes, locally administered
86- [x] Derive SSID: `"TollGate-" + hex(AP_MAC[3:6])`
87- [x] Derive AP IP: hash-based from AP MAC bytes
88- [x] Compute npub: secp256k1 x-only pubkey from nsec
89- [x] Set MACs via `esp_wifi_set_mac()` in boot sequence 60- [x] Set MACs via `esp_wifi_set_mac()` in boot sequence
61- [x] 24/24 unit tests passing
90 62
91### Nostr Event Signing (nostr_event.c/h) 63### Nostr Event Signing (nostr_event.c/h)
92- [x] Create `nostr_event.h` — NIP-01 event struct + sign/serialize API 64- [x] NIP-01 canonical JSON, SHA-256 ID, Schnorr signature
93- [x] Create `nostr_event.c` — canonical JSON, SHA-256 ID, Schnorr signature 65- [x] 23/23 unit tests passing
94- [x] Uses `secp256k1_schnorrsig_sign32()` for BIP-340 signatures
95 66
96### Geohash Encoding (geohash.c/h) 67### Geohash Encoding (geohash.c/h)
97- [x] Create `geohash.h` — `geohash_encode(lat, lon, precision, out)` 68- [x] Standard base-32 geohash encoding
98- [x] Create `geohash.c` — standard base-32 geohash encoding 69- [x] 11/11 unit tests passing
99 70
100### Wifistr Service Discovery (wifistr.c/h) 71### Wifistr Service Discovery (wifistr.c/h)
101- [x] Create `wifistr.h` — `wifistr_publish()` API 72- [x] kind 38787 event builder + WebSocket relay publish
102- [x] Create `wifistr.c` — kind 38787 event builder + WebSocket relay publish
103- [x] Build event with tags: d, ssid, h, security, g, c
104- [x] WebSocket client: raw TCP + TLS (esp_tls.h) + HTTP Upgrade
105- [x] Publish on boot + periodic timer (6h default) 73- [x] Publish on boot + periodic timer (6h default)
106 74- [x] Verified published to relay.damus.io and nos.lol
107### Config Changes (config.c/h) 75
108- [x] Add to struct: nsec, npub, nostr_geohash, nostr_relays, nostr_publish_interval_s, sta_mac, ap_mac 76## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — COMPLETE (commit `78dd599`)
109- [x] Remove from JSON parsing: ap_ssid, ap_ip (now derived from nsec) 77- [x] tollgate_client.c/h — detection, payment, monitoring, state machine
110- [x] Keep: ap_password, ap_channel, ap_max_conn (hardcoded defaults) 78- [x] Config fields: client_enabled, client_steps_to_buy, etc.
111- [x] Update default config.json template with nsec and Nostr fields 79- [x] Integration into tollgate_main.c
112 80- [x] 30/30 unit tests passing
113### Boot Sequence Changes (tollgate_main.c) 81
114- [x] Call `identity_init(nsec)` after config load, before WiFi init 82## Phase 5: Lightning Auto-Payout — COMPLETE (commit `cb4bd7d`)
115- [x] Set STA/AP MAC via `esp_wifi_set_mac()` after `esp_wifi_init()`, before `esp_wifi_start()` 83- [x] lnurl_pay.c/h — LNURL-pay HTTP flow
116- [x] Remove old `tollgate_config_derive_unique()` call 84- [x] lightning_payout.c/h — periodic balance check, threshold, multi-recipient split, melt
117- [x] Use derived SSID/IP in AP configuration 85- [x] nucula_wallet_melt() bridge for NUT-05
118- [x] Start wifistr publish task after services start 86- [x] Config: payout.enabled, recipients, mints, fee_tolerance, etc.
119 87- [x] 7/7 lnurl_pay + 11/11 lightning_payout = 18 unit tests passing
120### Build System 88
121- [x] Add identity.c, nostr_event.c, geohash.c, wifistr.c to CMakeLists.txt SRCS 89## Phase 6: Bytes-Based Billing — COMPLETE (commit `edd125d`)
122- [x] Add `secp256k1` to REQUIRES (for identity.c and nostr_event.c) 90- [x] Dual-metric session support (milliseconds + bytes)
123- [x] Clean build (0 errors, 0 warnings) 91- [x] session_create_bytes(), session_add_bytes()
124 92- [x] Config: metric, step_size_bytes
125### Hardware Testing 93- [x] Discovery endpoint advertises correct metric
126- [x] Flash Board A, verify wallet boot (keyset fetch succeeds) 94- [x] Unit tests: bytes session lifecycle, mixed metrics
127- [x] Pay Board A with Cashu token, verify proofs stored (GET /wallet) 95
128- [x] Test POST /wallet/swap on Board A 96## Phase 7: MCP Handler + NIP-04 + CVM Server — COMPLETE (commit `fdf662f`)
129- [x] Test POST /wallet/send on Board A, verify token is valid 97- [x] mcp_handler.c/h — 4 tools (get_config, set_config, get_balance, wallet_send), 25 unit tests
130- [x] Flash Board A with new identity derivation, verify derived SSID/MAC/IP 98- [x] nip04.c/h — AES-256-CBC + ECDH with 0x02 compressed pubkey prefix, 15 unit tests
131- [x] Verify captive portal works with new SSID/IP 99- [x] cvm_server.c/h — Nostr DM listener skeleton with FreeRTOS task
132- [x] Verify payment flow still works with identity-derived config 100- [x] Fixed NIP-04 IV bug: mbedtls_aes_crypt_cbc modifies IV in-place
133- [x] Verify wifistr event published to relay (damus + nos.lol) 101- [x] Fixed missing esp_random.h include in nip04.c
134- [ ] Flash Board B with new firmware (different nsec) 102- [x] 156 total unit tests passing across 10 test binaries
135- [ ] Cross-board payment: Board B token → Board A 103
136- [ ] Verify both boards show correct balances after cross-board payment 104## Bug Fixes — COMPLETE (commit `3342c8e`)
137 105- [x] reset_auth_handler now calls session_revoke_all() before firewall_revoke_all()
138### Tests 25-27 (deferred from Phase 2, need Board B) 106- [x] Port 80 /usage shows real session data (remaining/total) instead of "0/0"
139- [ ] Test 25: Two clients pay independently (laptop + Board B) 107- [x] Config metric defaults to "milliseconds" (ESP32 can't track per-client bytes from NAT)
140- [ ] Test 26: Client isolation (only payer gets internet) 108- [x] Fixed sys_evt stack overflow: deferred start_services() to dedicated 32KB task
141- [ ] Test 27: Full e2e: portal → pay → browse 109
142 110## Playwright Interop Tests — COMPLETE (commit `4fb44e7`)
143### Tests 28-38 (Phase 3 specific) 111- [x] 18/18 tests passing (11 ESP32 + 7 ESP32↔OpenWRT interop)
144- [ ] Test 28: Wallet boot (keysets loaded) 112- [x] 7 screenshots generated
145- [ ] Test 29: Receive via wallet (balance incremented) 113- [x] Double-spend rejection verified on live hardware
146- [ ] Test 30: Wallet swap (same balance, new proofs) 114
147- [ ] Test 31: Wallet send (valid cashuA token) 115---
148- [ ] Test 32: Persistence survives reboot 116
149- [ ] Test 33: Cross-board payment 117## TODO — In Progress
150- [ ] Test 34: 5 consecutive payments 118
151- [ ] Test 35: Stress: rapid pay/expire 119### Per-Client NAT Filtering (Multi-Client Fix)
152 120- [ ] Create `main/lwip_tollgate_hooks.h` — LWIP_HOOK_IP4_CANFORWARD definition
153### Automated Tests 121- [ ] Update `CMakeLists.txt` — inject hook header into lwIP compilation
154- [ ] Write tests/phase3.mjs (wallet endpoint tests + cross-board) 122- [ ] Add `tollgate_ip4_canforward_filter()` to `firewall.c` — filter forwarded packets by source IP
155- [ ] All Phase 3 tests passing 123- [ ] Change firewall strategy: NAT always ON, per-client filter in lwIP forwarding path
156 124- [ ] Remove `update_nat()`, `firewall_enable_nat()`, `firewall_disable_nat()` from firewall.c
157## Test Coverage — IN PROGRESS 125- [ ] Update `stop_services()` in tollgate_main.c — remove `firewall_disable_nat()` call
158 126- [ ] Add unit test for filter function
159### Host Unit Tests (tests/unit/) 127- [ ] Build, flash, test on Board A
160- [ ] Create `tests/unit/stubs/` — clean ESP-IDF type stubs for host compilation 128- [ ] Verify multi-client isolation: expire one client while other is active
161- [ ] Create `tests/unit/Makefile` — compiles all unit tests with host gcc 129
162- [ ] Install system deps: `libmbedtls-dev`, `libcjson-dev` 130### Spent-Secret Cleanup
163- [ ] `test_geohash.c` — geohash_encode against reference vectors (Munich, NYC, origin) 131- [ ] Remove `s_spent_secrets[]` and `session_is_secret_spent()` from `session.c`
164- [ ] `test_identity.c` — HMAC-SHA512 derivation, MAC bits, SSID/IP determinism 132- [ ] Remove `spent_secrets` field from `session_t` struct in `session.h`
165- [ ] `test_nostr_event.c` — NIP-01 event ID, Schnorr sign+verify, JSON serialization 133- [ ] Remove `spent_secrets` params from `session_create()` and `session_create_bytes()`
166- [ ] `test_cashu.c` — token decode, allotment calc, mint validation 134- [ ] Remove local spent-secret check in `tollgate_api.c` (lines 227-239)
167- [ ] `test_session.c` — session lifecycle, expiry, spent-secret dedup 135- [ ] Remove `secrets[]` array construction in `tollgate_api.c`
168- [ ] `make test-unit` passes all unit tests 136- [ ] Update `tests/unit/test_session.c` — remove secret-tracking tests
137- [ ] Run `make test-unit` — all tests pass
138
139### Integration Tests (tests/integration/)
140- [ ] Create `tests/integration/` directory
141- [ ] Move existing tests (api.mjs, network.mjs, smoke.mjs, phase2.mjs) into integration/
142- [ ] Write `test-reset-auth.mjs` — verify sessions cleared after reset
143- [ ] Write `test-session-lifecycle.mjs` — pay → verify usage → wait expiry → verify blocked (65s)
144- [ ] Write `test-dns-firewall.mjs` — DNS hijack before auth, forward after auth
145- [ ] Update Makefile targets for new paths
146- [ ] All integration tests passing
169 147
170### Test Reorganization 148### Test Reorganization
171- [ ] Move `tests/api.mjs` → `tests/integration/phase1_api.mjs` 149- [ ] Fix all hardcoded IPs → `process.env.TOLLGATE_IP`
172- [ ] Move `tests/network.mjs` → `tests/integration/phase1_network.mjs` 150- [ ] Move `tests/captive-portal.spec.mjs` → `tests/e2e/`
173- [ ] Move `tests/smoke.mjs` → `tests/integration/smoke.mjs` 151- [ ] Move `tests/interop-happy-path.spec.mjs` → `tests/e2e/` or `tests/integration/`
174- [ ] Move `tests/phase2.mjs` → `tests/integration/phase2.mjs` 152- [ ] Move `tests/playwright.config.mjs` → `tests/e2e/`
175- [ ] Move `tests/captive-portal.spec.mjs` → `tests/e2e/captive-portal.spec.mjs`
176- [ ] Move `tests/playwright.config.mjs` → `tests/e2e/playwright.config.mjs`
177- [ ] Fix all hardcoded IPs (`192.168.4.1`) → `process.env.TOLLGATE_IP`
178
179### New Integration Tests
180- [ ] `tests/integration/phase3.mjs` — wallet GET/swap/send, identity SSID/IP, wifistr on relay
181- [ ] All Phase 3 integration tests passing
182
183### New E2E Tests
184- [ ] `tests/e2e/payment.spec.mjs` — paste token → pay → success, error handling, full flow
185- [ ] All E2E tests passing
186
187### Build System Updates
188- [ ] Update `Makefile` with `test-unit`, `test-integration`, `test-e2e`, `test-all` targets
189- [ ] Update `package.json` npm scripts for new paths
190- [ ] All `make test-*` targets work
191
192## Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS
193
194### tollgate_client.c/h (New)
195- [x] Create `tollgate_client.h` — types: `tollgate_discovery_t`, `tollgate_client_state_t` enum (IDLE/DETECTING/NEEDS_PAY/PAYING/PAID/RENEWING)
196- [x] Create `tollgate_client.c` — detection, payment, monitoring, state machine
197- [x] `tollgate_client_detect(gw_ip)` — HTTP GET `http://{gw}:2121/`, parse kind=10021, extract price tags
198- [x] `tollgate_client_pay(gw_ip, amount_sats)` — `nucula_wallet_send()` → POST to upstream → parse kind=1022/21023
199- [x] `tollgate_client_on_sta_connected()` — extract gw from DHCP, detect, pay (blocking)
200- [x] `tollgate_client_tick()` — GET `/usage`, renew at 20% remaining
201- [x] `tollgate_client_on_sta_disconnected()` — reset state
202- [x] `tollgate_client_get_usage(gw_ip)` — GET `/usage` → parse remaining/total
203
204### Config Changes
205- [x] Add to `config.h`: `client_enabled`, `client_steps_to_buy`, `client_renewal_threshold_pct`, `client_retry_interval_ms`
206- [x] Parse new fields in `config.c`
207
208### Integration (tollgate_main.c)
209- [x] Make wallet init synchronous (call `nucula_wallet_init()` directly, not as task)
210- [x] Add `tollgate_client_on_sta_connected()` in `ip_event_handler` (blocking, before `start_services()`)
211- [x] Add `tollgate_client_on_sta_disconnected()` in `wifi_event_handler`
212- [x] Add `tollgate_client_tick()` in main loop
213- [x] Update `main/CMakeLists.txt` — add `tollgate_client.c`
214
215### Unit Tests
216- [x] `tests/unit/test_tollgate_client.c` — discovery parsing, price extraction, state machine, renewal threshold
217- [x] All unit tests passing (30 new, 116 total) — committed at `78dd599`
218
219### Integration Tests
220- [ ] ESP32→OpenWRT auto-payment (Scenario 4)
221- [ ] ESP32→ESP32 auto-payment (Scenario 5, needs Board B)
222
223### Test Cases 39-43
224- [ ] Test 39: Client detection (kind=10021 parse)
225- [ ] Test 40: Client payment flow (mock HTTP)
226- [ ] Test 41: Session renewal (20% threshold)
227- [ ] Test 42: ESP32→OpenWRT auto-pay
228- [ ] Test 43: ESP32→ESP32 auto-pay
229
230## Phase 5: Lightning Auto-Payout — NOT STARTED
231
232### lnurl_pay.c/h (New)
233- [ ] Create `lnurl_pay.h` — `lnurl_get_invoice(lightning_address, amount_sats, bolt11_out, out_size)`
234- [ ] Create `lnurl_pay.c` — GET `.well-known/lnurlp/{user}` → parse callback → GET callback with amount → extract BOLT11
235
236### lightning_payout.c/h (New)
237- [ ] Create `lightning_payout.h` — `payout_recipient_t`, config, init/tick API
238- [ ] Create `lightning_payout.c` — periodic balance check, threshold, multi-recipient split, melt with retry
239
240### nucula Bridge Extension
241- [ ] Add `nucula_wallet_melt(bolt11, max_fee_sats)` to `nucula_wallet.h/cpp`
242- [ ] Wraps `Wallet::request_melt_quote()` + `Wallet::melt_tokens()` (NUT-05)
243
244### Config Changes
245- [ ] Add payout config to `config.h`: `payout_enabled`, `min_payout_amount`, `min_balance`, `fee_tolerance_pct`, `check_interval_s`, `recipients[]`
246- [ ] Parse payout config in `config.c`
247
248### Integration (tollgate_main.c)
249- [ ] Add periodic payout timer (60s interval)
250- [ ] Update `main/CMakeLists.txt`
251
252### Unit Tests
253- [ ] `tests/unit/test_lnurl_pay.c` — LNURL-pay URL construction, response parsing
254- [ ] `tests/unit/test_lightning_payout.c` — threshold check, multi-recipient split, fee tolerance
255
256### Test Cases 44-48
257- [ ] Test 44: LNURL-pay flow
258- [ ] Test 45: Payout threshold
259- [ ] Test 46: Multi-recipient split
260- [ ] Test 47: Melt with fee tolerance
261- [ ] Test 48: Full payout cycle
262
263## Phase 6: Bytes-Based Billing — NOT STARTED
264
265### lwIP NAPT Stats Component (New)
266- [ ] Create `components/lwip_napt_stats/` — patched `ip4_napt.c` with byte counters
267- [ ] Add `uint64_t bytes_up/bytes_down` to `struct ip_napt_entry`
268- [ ] Increment in `ip_napt_forward()` and `ip_napt_recv()`
269- [ ] Add public API: `ip_napt_get_client_bytes(client_ip, &up, &down)`
270- [ ] Create component CMakeLists.txt
271
272### Session Changes
273- [ ] Add `allotment_bytes`, `bytes_consumed` to `session_t`
274- [ ] Dual-metric `session_is_expired()` dispatches on metric type
275- [ ] `session_add_bytes(client_ip, byte_count)` called from firewall counting
276
277### Config Changes
278- [ ] Add `metric` field ("milliseconds" or "bytes") to `config.h`
279- [ ] Add `step_size_bytes` to `config.h`
280- [ ] Parse in `config.c`
281
282### TollGate API Changes
283- [ ] Discovery endpoint advertises correct metric
284- [ ] `/usage` returns byte-based or time-based values
285- [ ] Allotment calculation dispatches on metric
286
287### Firewall Changes
288- [ ] `firewall_count_traffic()` — queries NAPT byte counters per active client
289- [ ] Called from `session_tick()` or main loop
290
291### Cashu Changes
292- [ ] Unify `cashu_calculate_allotment()` for both metrics
293
294### Unit Tests
295- [ ] `tests/unit/test_bytes_metric.c` — byte allotment calc, dual-metric session expiry
296
297### Test Cases 49-52
298- [ ] Test 49: Byte allotment calc
299- [ ] Test 50: Byte session expiry
300- [ ] Test 51: NAPT byte counting
301- [ ] Test 52: Bytes metric end-to-end
302
303## Phase 7: ContextVM Server (MCP over Nostr) — NOT STARTED
304
305### NIP-44 Encryption (New)
306- [ ] Create `nip44.h` — encrypt/decrypt API
307- [ ] Create `nip44.c` — XChaCha20-Poly1305 + secp256k1 ECDH + conversation key derivation
308
309### MCP Handler (New)
310- [ ] Create `mcp_handler.h` — tool registration, JSON-RPC parse/dispatch
311- [ ] Create `mcp_handler.c` — register tools, handle requests, build responses
312
313### CVM Server (New)
314- [ ] Create `cvm_server.h` — init/start/stop API
315- [ ] Create `cvm_server.c` — WebSocket listener, DM subscription, NIP-44 decrypt, MCP dispatch
316
317### MCP Tool Registration
318- [ ] `get_config`, `set_config`, `get_balance`, `get_sessions`, `get_usage`
319- [ ] `set_payout`, `set_metric`, `set_price`, `wallet_send`, `wallet_melt`
320 153
321### Auth 154### Playwright Video Recording Fix
322- [ ] Only accept commands from owner npub 155- [ ] Per-test context isolation (not shared serial context)
156- [ ] Verify `.webm` files generated in test-results/
323 157
324### Integration (tollgate_main.c) 158### OpenWRT Interop
325- [ ] Start CVM server alongside wifistr 159- [ ] Investigate `nofee.testnut.cashu.space` API compatibility issues
326- [ ] Update `main/CMakeLists.txt` 160- [ ] Fix cashu CLI v0.19.2 Pydantic validation failures with missing `active` field
327 161
328### Unit Tests 162### Board B
329- [ ] `tests/unit/test_nip44.c` — encrypt/decrypt roundtrip 163- [ ] Flash Board B with current firmware (different nsec)
330- [ ] `tests/unit/test_mcp_handler.c` — JSON-RPC parse, tool dispatch 164- [ ] Cross-board payment test: Board B → Board A
165- [ ] ESP32→ESP32 auto-payment (Scenario 5)
331 166
332### Test Cases 53-56 167---
333- [ ] Test 53: NIP-44 encrypt/decrypt
334- [ ] Test 54: MCP JSON-RPC parse
335- [ ] Test 55: Config change via DM
336- [ ] Test 56: Balance query via CVM
337 168
338## Reminders 169## Reminders
339- Do NOT ask for instructions — proceed independently, skip blocked items, work on unblocked ones
340- **Commit + push every time a test passes that previously didn't pass** 170- **Commit + push every time a test passes that previously didn't pass**
341- Board A: `/dev/ttyACM0`, factory MAC `94:a9:90:2e:37:7c` 171- Board A: `/dev/ttyACM0`, MAC `94:a9:90:2e:37:7c`, SSID `TollGate-C0E9CA`, AP IP `10.192.45.1`
342- Board B: `/dev/ttyACM1`, factory MAC `fc:01:2c:c5:50:50` 172- Board B: `/dev/ttyACM1`, MAC `fc:01:2c:c5:50:50`
343- Identity is now derived from nsec in config.json (SSID, IP, MAC all deterministic) 173- OpenWRT Router: SSH `root@10.47.41.1`, port 2121
344- testnut.cashu.space auto-pays invoices: `cashu -h https://testnut.cashu.space invoice <amount>` 174- `source ~/esp/esp-idf/export.sh` before `idf.py`
345- Token generation: `cashu -h https://testnut.cashu.space send --legacy <amount> 2>&1 | grep '^cashuA' | head -1` 175- Latest commit: `3342c8e`
176- 156 unit tests + 18 Playwright tests — all passing
346- sudo password: `c03rad0r123` 177- sudo password: `c03rad0r123`
347- Run `make test-unit` after any code change — must pass before commit 178- Token generation: `cashu -h https://testnut.cashu.space send --legacy 21`
348- See `AGENTS.md` for full testing rules and project context 179- See `AGENTS.md` for full testing rules
349- Proceed to Phase 4 after completing Phase 3
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d93e479..4214983 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2,3 +2,7 @@ cmake_minimum_required(VERSION 3.16)
2set(PARTITION_CSV_PATH "${CMAKE_SOURCE_DIR}/partitions.csv") 2set(PARTITION_CSV_PATH "${CMAKE_SOURCE_DIR}/partitions.csv")
3include($ENV{IDF_PATH}/tools/cmake/project.cmake) 3include($ENV{IDF_PATH}/tools/cmake/project.cmake)
4project(esp32-tollgate) 4project(esp32-tollgate)
5
6idf_component_get_property(lwip lwip COMPONENT_LIB)
7target_compile_options(${lwip} PRIVATE "-I${PROJECT_DIR}/main")
8target_compile_definitions(${lwip} PRIVATE "-DESP_IDF_LWIP_HOOK_FILENAME=\"lwip_tollgate_hooks.h\"")
diff --git a/PLAN.md b/PLAN.md
index 2a0ed2b..5690c1b 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -286,7 +286,7 @@ Publishes TollGate node to Nostr as kind 38787 (wifistr):
286| 37 | 5 consecutive payments | Loop | All authenticated | TODO | 286| 37 | 5 consecutive payments | Loop | All authenticated | TODO |
287| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO | 287| 38 | Stress: rapid pay/expire | Loop with short sessions | No crash/leak | TODO |
288 288
289### Phase 4: ESP32 TollGate Client Detection + Auto-Payment — IN PROGRESS 289### Phase 4: ESP32 TollGate Client Detection + Auto-Payment — COMPLETE
290 290
291**Goal:** ESP32 detects upstream TollGate when connected as STA, automatically pays for internet access using on-device wallet. Enables ESP32→OpenWRT (Scenario 4) and ESP32→ESP32 (Scenario 5) auto-payment. 291**Goal:** ESP32 detects upstream TollGate when connected as STA, automatically pays for internet access using on-device wallet. Enables ESP32→OpenWRT (Scenario 4) and ESP32→ESP32 (Scenario 5) auto-payment.
292 292
@@ -354,7 +354,7 @@ IDLE → [STA got IP] → DETECTING → [kind=10021 found] → NEEDS_PAY
354 354
355Pre-association price discovery via Wi-Fi Vendor IE beacons (OUI `0x54:0x47`) is deferred to a future phase. The client currently uses HTTP-based discovery after connection. 355Pre-association price discovery via Wi-Fi Vendor IE beacons (OUI `0x54:0x47`) is deferred to a future phase. The client currently uses HTTP-based discovery after connection.
356 356
357### Phase 5: Lightning Auto-Payout — NOT STARTED 357### Phase 5: Lightning Auto-Payout — COMPLETE
358 358
359**Goal:** When wallet balance exceeds a configurable threshold, automatically pay out to Lightning addresses via LNURL-pay + Cashu NUT-05 melt. 359**Goal:** When wallet balance exceeds a configurable threshold, automatically pay out to Lightning addresses via LNURL-pay + Cashu NUT-05 melt.
360 360
@@ -420,7 +420,7 @@ Wraps `Wallet::request_melt_quote()` + `Wallet::melt_tokens()` (NUT-05).
420| 47 | Melt with fee tolerance | Integration | Invoice paid, change received | TODO | 420| 47 | Melt with fee tolerance | Integration | Invoice paid, change received | TODO |
421| 48 | Full payout cycle | E2E | Wallet drains to min_balance | TODO | 421| 48 | Full payout cycle | E2E | Wallet drains to min_balance | TODO |
422 422
423### Phase 6: Bytes-Based Billing — NOT STARTED 423### Phase 6: Bytes-Based Billing — COMPLETE
424 424
425**Goal:** Support both time-based (milliseconds) and data-based (bytes) billing metrics. Mirrors the Go implementation's dual-metric system. 425**Goal:** Support both time-based (milliseconds) and data-based (bytes) billing metrics. Mirrors the Go implementation's dual-metric system.
426 426
@@ -473,7 +473,7 @@ uint64_t bytes_consumed;
473| 51 | NAPT byte counting | Integration | Counters match actual traffic | TODO | 473| 51 | NAPT byte counting | Integration | Counters match actual traffic | TODO |
474| 52 | Bytes metric end-to-end | E2E | Client disconnected after data cap | TODO | 474| 52 | Bytes metric end-to-end | E2E | Client disconnected after data cap | TODO |
475 475
476### Phase 7: ContextVM Server (MCP over Nostr) — NOT STARTED 476### Phase 7: ContextVM Server (MCP over Nostr) — COMPLETE
477 477
478**Goal:** Remote configuration of ESP32 TollGate via ContextVM — services communicate over Nostr using public keys as addresses. Exposes configuration as MCP tools accessible by both humans and AI agents. 478**Goal:** Remote configuration of ESP32 TollGate via ContextVM — services communicate over Nostr using public keys as addresses. Exposes configuration as MCP tools accessible by both humans and AI agents.
479 479
@@ -524,6 +524,73 @@ Only accept commands from owner npub (derived from nsec in config.json).
524 524
525## Total: 56 Tests across 7 phases 525## Total: 56 Tests across 7 phases
526 526
527## Post-Phase 7: Bug Fixes & Architecture Improvements
528
529### Per-Client NAT Filtering (Multi-Client Isolation Fix)
530
531**Problem:** When client A's session expires but client B is still active, NAPT stays enabled globally. Client A's existing TCP/UDP connections in the NAT table continue routing — their traffic reaches the internet even though they're unauthenticated.
532
533**Solution:** Use lwIP's `LWIP_HOOK_IP4_CANFORWARD` hook to filter forwarded packets by source IP. This fires in `ip4_forward()` **before** NAPT translation, so unauthorized clients are blocked at the IP layer.
534
535**Architecture Change:**
536- **Before:** NAT is a global toggle — ON when any client is auth'd, OFF when none are
537- **After:** NAT stays always ON. Per-client filtering happens in the lwIP forwarding path via `LWIP_HOOK_IP4_CANFORWARD`
538
539**Files:**
540
541| File | Action | Description |
542|------|--------|-------------|
543| `main/lwip_tollgate_hooks.h` | Create | Defines `LWIP_HOOK_IP4_CANFORWARD` macro |
544| `CMakeLists.txt` | Modify | Inject hook header into lwIP compilation |
545| `main/firewall.c` | Modify | Add filter function, remove global NAT toggle |
546| `main/firewall.h` | Modify | Expose filter function declaration |
547| `main/tollgate_main.c` | Modify | Remove `firewall_disable_nat()` from stop_services |
548
549**Implementation:**
550
5511. `lwip_tollgate_hooks.h` — hook header included by lwIP:
552```c
553#define LWIP_HOOK_IP4_CANFORWARD(p, addr) tollgate_ip4_canforward_filter(p, addr)
554```
555
5562. `CMakeLists.txt` — inject into lwIP build (follows ESP-IDF vlan example pattern):
557```cmake
558idf_component_get_property(lwip lwip COMPONENT_LIB)
559target_compile_options(${lwip} PRIVATE "-I${PROJECT_DIR}/main")
560target_compile_definitions(${lwip} PRIVATE "-DESP_IDF_LWIP_HOOK_FILENAME=\"lwip_tollgate_hooks.h\"")
561```
562
5633. `firewall.c` — filter function checks source IP against allowed client list:
564```c
565int tollgate_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder) {
566 struct ip_hdr *iphdr = (struct ip_hdr *)p->payload;
567 uint32_t src_ip = lwip_ntohl(iphdr->src.addr);
568 return firewall_is_client_allowed(src_ip) ? 1 : 0;
569}
570```
571
5724. NAT management simplified: enable once during `firewall_init()`, never disable. Remove `update_nat()`, `firewall_enable_nat()`, `firewall_disable_nat()`.
573
574**Key properties:**
575- Hook fires only for **forwarded** packets (AP client → internet), not packets to ESP32 itself
576- Captive portal (port 80) and API (port 2121) remain accessible to all clients
577- DNS hijack continues independently — both layers enforce auth
578- Return traffic: NAPT only creates DNAT entries for successfully forwarded outbound packets, so blocked clients don't get return traffic either
579- Performance: linear scan of ≤10 clients per packet — negligible cost
580
581### Spent-Secret Cleanup
582
583**Problem:** `session.c` tracks spent Cashu secrets locally in a static array (`s_spent_secrets[100][65]`). This is redundant — the mint is the authority on spent state, and `nucula_wallet_receive()` already swaps proofs (registering them as spent with the mint).
584
585**Changes:**
586- Remove `s_spent_secrets[]`, `s_spent_count`, `session_is_secret_spent()` from `session.c`
587- Remove `spent_secrets` and `spent_secret_count` from `session_t` struct
588- Remove `spent_secrets` params from `session_create()` / `session_create_bytes()`
589- Remove local spent-secret check in `tollgate_api.c` — keep only mint `check_proof_states` check
590- Update unit tests
591
592**Rationale:** The mint's `cashu_check_proof_states()` already catches double-spends over HTTP. `nucula_wallet_receive()` → `swap()` registers proofs as spent and replaces them. After a successful receive, the old token is useless. Local tracking adds no security, wastes 6.5KB RAM, and is lost on reboot anyway.
593
527## Testing Infrastructure 594## Testing Infrastructure
528 595
529### Three-Layer Test Architecture 596### Three-Layer Test Architecture
diff --git a/main/firewall.c b/main/firewall.c
index f349ab1..8d535b4 100644
--- a/main/firewall.c
+++ b/main/firewall.c
@@ -6,13 +6,13 @@
6#include "lwip/lwip_napt.h" 6#include "lwip/lwip_napt.h"
7#include "lwip/etharp.h" 7#include "lwip/etharp.h"
8#include "lwip/netif.h" 8#include "lwip/netif.h"
9#include "lwip/prot/ip4.h"
9#include <string.h> 10#include <string.h>
10 11
11#define MAX_CLIENTS 10 12#define MAX_CLIENTS 10
12 13
13static const char *TAG = "firewall"; 14static const char *TAG = "firewall";
14static esp_ip4_addr_t s_ap_ip; 15static esp_ip4_addr_t s_ap_ip;
15static bool s_nat_enabled = false;
16 16
17typedef struct { 17typedef struct {
18 uint32_t ip; 18 uint32_t ip;
@@ -22,11 +22,6 @@ typedef struct {
22static fw_client_t s_clients[MAX_CLIENTS]; 22static fw_client_t s_clients[MAX_CLIENTS];
23static int s_client_count = 0; 23static int s_client_count = 0;
24 24
25static struct netif *get_ap_netif(void)
26{
27 return netif_get_by_index(NETIF_NO_INDEX);
28}
29
30esp_err_t firewall_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size) 25esp_err_t firewall_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size)
31{ 26{
32 wifi_sta_list_t sta_list; 27 wifi_sta_list_t sta_list;
@@ -66,38 +61,25 @@ esp_err_t firewall_init(esp_ip4_addr_t ap_ip)
66 s_ap_ip = ap_ip; 61 s_ap_ip = ap_ip;
67 memset(s_clients, 0, sizeof(s_clients)); 62 memset(s_clients, 0, sizeof(s_clients));
68 s_client_count = 0; 63 s_client_count = 0;
69 ESP_LOGI(TAG, "Firewall initialized with AP IP=" IPSTR, IP2STR(&s_ap_ip)); 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));
70 return ESP_OK; 66 return ESP_OK;
71} 67}
72 68
73static void update_nat(void) 69int tollgate_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder)
74{ 70{
75 bool should_enable = (s_client_count > 0); 71 (void)dest_addr_hostorder;
76 if (should_enable && !s_nat_enabled) { 72 if (p->len < IP_HLEN) return -1;
77 ip_napt_enable(s_ap_ip.addr, 1); 73 struct ip_hdr *iphdr = (struct ip_hdr *)p->payload;
78 s_nat_enabled = true; 74 uint32_t src_ip_h = lwip_ntohl(iphdr->src.addr);
79 ESP_LOGI(TAG, "NAT enabled (client authenticated)"); 75 uint32_t ap_subnet = lwip_ntohl(s_ap_ip.addr) & 0xFFFFFF00;
80 } else if (!should_enable && s_nat_enabled) { 76 if ((src_ip_h & 0xFFFFFF00) != ap_subnet) {
81 ip_napt_enable(s_ap_ip.addr, 0); 77 return 1;
82 s_nat_enabled = false;
83 ESP_LOGI(TAG, "NAT disabled (no authenticated clients)");
84 } 78 }
85} 79 if (firewall_is_client_allowed(iphdr->src.addr)) {
86 80 return 1;
87void firewall_enable_nat(void) 81 }
88{ 82 return 0;
89 if (s_nat_enabled) return;
90 ip_napt_enable(s_ap_ip.addr, 1);
91 s_nat_enabled = true;
92 ESP_LOGI(TAG, "NAT enabled");
93}
94
95void firewall_disable_nat(void)
96{
97 if (!s_nat_enabled) return;
98 ip_napt_enable(s_ap_ip.addr, 0);
99 s_nat_enabled = false;
100 ESP_LOGI(TAG, "NAT disabled");
101} 83}
102 84
103static fw_client_t *find_client_by_ip(uint32_t client_ip) 85static fw_client_t *find_client_by_ip(uint32_t client_ip)
@@ -137,7 +119,6 @@ void firewall_grant_access(uint32_t client_ip)
137 s_client_count++; 119 s_client_count++;
138 120
139 dns_server_set_client_authenticated(client_ip, true); 121 dns_server_set_client_authenticated(client_ip, true);
140 update_nat();
141 122
142 esp_ip4_addr_t ip_addr = { .addr = client_ip }; 123 esp_ip4_addr_t ip_addr = { .addr = client_ip };
143 ESP_LOGI(TAG, "Access granted to " IPSTR " mac=%s", IP2STR(&ip_addr), 124 ESP_LOGI(TAG, "Access granted to " IPSTR " mac=%s", IP2STR(&ip_addr),
@@ -154,7 +135,6 @@ void firewall_revoke_access(uint32_t client_ip)
154 s_clients[i] = s_clients[s_client_count - 1]; 135 s_clients[i] = s_clients[s_client_count - 1];
155 s_client_count--; 136 s_client_count--;
156 dns_server_set_client_authenticated(client_ip, false); 137 dns_server_set_client_authenticated(client_ip, false);
157 update_nat();
158 return; 138 return;
159 } 139 }
160 } 140 }
@@ -166,7 +146,6 @@ void firewall_revoke_all(void)
166 dns_server_set_client_authenticated(s_clients[i].ip, false); 146 dns_server_set_client_authenticated(s_clients[i].ip, false);
167 } 147 }
168 s_client_count = 0; 148 s_client_count = 0;
169 update_nat();
170 ESP_LOGI(TAG, "All client access revoked"); 149 ESP_LOGI(TAG, "All client access revoked");
171} 150}
172 151
diff --git a/main/firewall.h b/main/firewall.h
index e5d492a..f177eaa 100644
--- a/main/firewall.h
+++ b/main/firewall.h
@@ -6,11 +6,11 @@
6#include <stdbool.h> 6#include <stdbool.h>
7#include <stdint.h> 7#include <stdint.h>
8 8
9struct pbuf;
10
9#define FW_MAX_MAC_LEN 18 11#define FW_MAX_MAC_LEN 18
10 12
11esp_err_t firewall_init(esp_ip4_addr_t ap_ip); 13esp_err_t firewall_init(esp_ip4_addr_t ap_ip);
12void firewall_enable_nat(void);
13void firewall_disable_nat(void);
14void firewall_grant_access(uint32_t client_ip); 14void firewall_grant_access(uint32_t client_ip);
15void firewall_revoke_access(uint32_t client_ip); 15void firewall_revoke_access(uint32_t client_ip);
16void firewall_revoke_all(void); 16void firewall_revoke_all(void);
@@ -20,4 +20,6 @@ int firewall_client_count(void);
20 20
21esp_err_t firewall_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size); 21esp_err_t firewall_get_mac_for_ip(uint32_t client_ip, char *mac_out, size_t mac_out_size);
22 22
23int tollgate_ip4_canforward_filter(struct pbuf *p, uint32_t dest_addr_hostorder);
24
23#endif 25#endif
diff --git a/main/lwip_tollgate_hooks.h b/main/lwip_tollgate_hooks.h
new file mode 100644
index 0000000..76017be
--- /dev/null
+++ b/main/lwip_tollgate_hooks.h
@@ -0,0 +1,10 @@
1#ifndef LWIP_TOLLGATE_HOOKS_H
2#define LWIP_TOLLGATE_HOOKS_H
3
4#include "lwip/pbuf.h"
5
6int tollgate_ip4_canforward_filter(struct pbuf *p, u32_t dest_addr_hostorder);
7
8#define LWIP_HOOK_IP4_CANFORWARD(p, addr) tollgate_ip4_canforward_filter(p, addr)
9
10#endif
diff --git a/main/session.c b/main/session.c
index 4854163..9b4380c 100644
--- a/main/session.c
+++ b/main/session.c
@@ -7,15 +7,10 @@
7#include "freertos/task.h" 7#include "freertos/task.h"
8#include <string.h> 8#include <string.h>
9 9
10#define SPENT_SECRETS_MAX 100
11
12static const char *TAG = "session"; 10static const char *TAG = "session";
13static session_t s_sessions[SESSION_MAX_CLIENTS]; 11static session_t s_sessions[SESSION_MAX_CLIENTS];
14static int s_session_count = 0; 12static int s_session_count = 0;
15 13
16static char s_spent_secrets[SPENT_SECRETS_MAX][65];
17static int s_spent_count = 0;
18
19static int64_t get_time_ms(void) 14static int64_t get_time_ms(void)
20{ 15{
21 return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; 16 return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
@@ -25,7 +20,6 @@ esp_err_t session_manager_init(void)
25{ 20{
26 memset(s_sessions, 0, sizeof(s_sessions)); 21 memset(s_sessions, 0, sizeof(s_sessions));
27 s_session_count = 0; 22 s_session_count = 0;
28 s_spent_count = 0;
29 ESP_LOGI(TAG, "Session manager initialized"); 23 ESP_LOGI(TAG, "Session manager initialized");
30 return ESP_OK; 24 return ESP_OK;
31} 25}
@@ -37,27 +31,14 @@ static void populate_mac(session_t *session, uint32_t client_ip)
37 } 31 }
38} 32}
39 33
40session_t *session_create(uint32_t client_ip, uint64_t allotment_ms, 34session_t *session_create(uint32_t client_ip, uint64_t allotment_ms)
41 const char *spent_secrets[], int secret_count)
42{ 35{
43 session_t *existing = session_find_by_ip(client_ip); 36 session_t *existing = session_find_by_ip(client_ip);
44 if (existing) { 37 if (existing) {
45 session_extend(existing, allotment_ms); 38 session_extend(existing, allotment_ms);
46 for (int i = 0; i < secret_count && s_spent_count < SPENT_SECRETS_MAX; i++) {
47 strncpy(s_spent_secrets[s_spent_count], spent_secrets[i], 64);
48 s_spent_secrets[s_spent_count][64] = '\0';
49 s_spent_count++;
50 }
51 return existing; 39 return existing;
52 } 40 }
53 41
54 for (int i = 0; i < secret_count; i++) {
55 if (session_is_secret_spent(spent_secrets[i])) {
56 ESP_LOGW(TAG, "Duplicate secret rejected");
57 return NULL;
58 }
59 }
60
61 if (s_session_count >= SESSION_MAX_CLIENTS) { 42 if (s_session_count >= SESSION_MAX_CLIENTS) {
62 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { 43 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
63 if (!s_sessions[i].active || session_is_expired(&s_sessions[i])) { 44 if (!s_sessions[i].active || session_is_expired(&s_sessions[i])) {
@@ -73,22 +54,8 @@ session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
73 s_sessions[i].allotment_ms = allotment_ms; 54 s_sessions[i].allotment_ms = allotment_ms;
74 s_sessions[i].start_time_ms = get_time_ms(); 55 s_sessions[i].start_time_ms = get_time_ms();
75 s_sessions[i].active = true; 56 s_sessions[i].active = true;
76 s_sessions[i].spent_secret_count = 0;
77 populate_mac(&s_sessions[i], client_ip); 57 populate_mac(&s_sessions[i], client_ip);
78 58
79 for (int j = 0; j < secret_count && j < 5; j++) {
80 strncpy(s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count],
81 spent_secrets[j], 64);
82 s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count][64] = '\0';
83 s_sessions[i].spent_secret_count++;
84 }
85
86 for (int j = 0; j < secret_count && s_spent_count < SPENT_SECRETS_MAX; j++) {
87 strncpy(s_spent_secrets[s_spent_count], spent_secrets[j], 64);
88 s_spent_secrets[s_spent_count][64] = '\0';
89 s_spent_count++;
90 }
91
92 s_session_count++; 59 s_session_count++;
93 firewall_grant_access(client_ip); 60 firewall_grant_access(client_ip);
94 61
@@ -104,10 +71,9 @@ session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
104 return NULL; 71 return NULL;
105} 72}
106 73
107session_t *session_create_bytes(uint32_t client_ip, uint64_t allotment_bytes, 74session_t *session_create_bytes(uint32_t client_ip, uint64_t allotment_bytes)
108 const char *spent_secrets[], int secret_count)
109{ 75{
110 session_t *s = session_create(client_ip, 0, spent_secrets, secret_count); 76 session_t *s = session_create(client_ip, 0);
111 if (s) { 77 if (s) {
112 s->allotment_bytes = allotment_bytes; 78 s->allotment_bytes = allotment_bytes;
113 s->bytes_consumed = 0; 79 s->bytes_consumed = 0;
@@ -170,14 +136,6 @@ bool session_is_expired(const session_t *session)
170 return elapsed >= (int64_t)session->allotment_ms; 136 return elapsed >= (int64_t)session->allotment_ms;
171} 137}
172 138
173bool session_is_secret_spent(const char *secret)
174{
175 for (int i = 0; i < s_spent_count; i++) {
176 if (strncmp(s_spent_secrets[i], secret, 64) == 0) return true;
177 }
178 return false;
179}
180
181void session_check_expiry(void) 139void session_check_expiry(void)
182{ 140{
183 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { 141 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
diff --git a/main/session.h b/main/session.h
index 6282f5a..ea5b476 100644
--- a/main/session.h
+++ b/main/session.h
@@ -16,17 +16,13 @@ typedef struct {
16 uint64_t allotment_bytes; 16 uint64_t allotment_bytes;
17 uint64_t bytes_consumed; 17 uint64_t bytes_consumed;
18 bool active; 18 bool active;
19 char spent_secrets[5][65];
20 int spent_secret_count;
21} session_t; 19} session_t;
22 20
23esp_err_t session_manager_init(void); 21esp_err_t session_manager_init(void);
24 22
25session_t *session_create(uint32_t client_ip, uint64_t allotment_ms, 23session_t *session_create(uint32_t client_ip, uint64_t allotment_ms);
26 const char *spent_secrets[], int secret_count);
27 24
28session_t *session_create_bytes(uint32_t client_ip, uint64_t allotment_bytes, 25session_t *session_create_bytes(uint32_t client_ip, uint64_t allotment_bytes);
29 const char *spent_secrets[], int secret_count);
30 26
31void session_add_bytes(uint32_t client_ip, uint64_t bytes); 27void session_add_bytes(uint32_t client_ip, uint64_t bytes);
32 28
@@ -37,8 +33,6 @@ void session_extend(session_t *session, uint64_t additional_ms);
37 33
38bool session_is_expired(const session_t *session); 34bool session_is_expired(const session_t *session);
39 35
40bool session_is_secret_spent(const char *secret);
41
42void session_check_expiry(void); 36void session_check_expiry(void);
43 37
44void session_revoke(session_t *session); 38void session_revoke(session_t *session);
diff --git a/main/tollgate_api.c b/main/tollgate_api.c
index 25e7dd2..650b0f3 100644
--- a/main/tollgate_api.c
+++ b/main/tollgate_api.c
@@ -224,20 +224,6 @@ static esp_err_t api_post_payment(httpd_req_t *req)
224 return ESP_OK; 224 return ESP_OK;
225 } 225 }
226 226
227 for (int i = 0; i < token->proof_count; i++) {
228 if (session_is_secret_spent(token->proofs[i].secret)) {
229 free(token);
230 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent");
231 char *json = cJSON_PrintUnformatted(notice);
232 httpd_resp_set_status(req, "402 Payment Required");
233 httpd_resp_set_type(req, "application/json");
234 httpd_resp_send(req, json, strlen(json));
235 cJSON_free(json);
236 cJSON_Delete(notice);
237 return ESP_OK;
238 }
239 }
240
241 cashu_proof_state_t *states = malloc(CASHU_MAX_PROOFS * sizeof(cashu_proof_state_t)); 227 cashu_proof_state_t *states = malloc(CASHU_MAX_PROOFS * sizeof(cashu_proof_state_t));
242 if (!states) { 228 if (!states) {
243 free(token); 229 free(token);
@@ -299,16 +285,11 @@ static esp_err_t api_post_payment(httpd_req_t *req)
299 return ESP_OK; 285 return ESP_OK;
300 } 286 }
301 287
302 int secret_count = token->proof_count > 5 ? 5 : token->proof_count;
303 const char *secrets[5];
304 for (int i = 0; i < secret_count; i++) {
305 secrets[i] = token->proofs[i].secret;
306 }
307 session_t *session; 288 session_t *session;
308 if (is_bytes) { 289 if (is_bytes) {
309 session = session_create_bytes(client_ip, allotment, secrets, secret_count); 290 session = session_create_bytes(client_ip, allotment);
310 } else { 291 } else {
311 session = session_create(client_ip, allotment, secrets, secret_count); 292 session = session_create(client_ip, allotment);
312 } 293 }
313 if (!session) { 294 if (!session) {
314 free(states); 295 free(states);
@@ -498,7 +479,7 @@ esp_err_t tollgate_api_start(void)
498 config.server_port = 2121; 479 config.server_port = 2121;
499 config.ctrl_port = 32769; 480 config.ctrl_port = 32769;
500 config.max_uri_handlers = 10; 481 config.max_uri_handlers = 10;
501 config.stack_size = 32768; 482 config.stack_size = 16384;
502 483
503 esp_err_t ret = httpd_start(&s_api_server, &config); 484 esp_err_t ret = httpd_start(&s_api_server, &config);
504 if (ret != ESP_OK) { 485 if (ret != ESP_OK) {
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index 2670f05..41dbbed 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -174,7 +174,6 @@ static void stop_services(void)
174 tollgate_api_stop(); 174 tollgate_api_stop();
175 dns_server_stop(); 175 dns_server_stop();
176 cvm_server_stop(); 176 cvm_server_stop();
177 firewall_disable_nat();
178 firewall_revoke_all(); 177 firewall_revoke_all();
179 s_services_running = false; 178 s_services_running = false;
180 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 179 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
diff --git a/tests/unit/stubs/esp_random.h b/tests/unit/stubs/esp_random.h
new file mode 100644
index 0000000..bb58af2
--- /dev/null
+++ b/tests/unit/stubs/esp_random.h
@@ -0,0 +1,6 @@
1#ifndef STUB_ESP_RANDOM_H
2#define STUB_ESP_RANDOM_H
3
4#include "esp_system.h"
5
6#endif
diff --git a/tests/unit/test_identity b/tests/unit/test_identity
index 7ad1485..277bb49 100755
--- a/tests/unit/test_identity
+++ b/tests/unit/test_identity
Binary files differ
diff --git a/tests/unit/test_mcp_handler b/tests/unit/test_mcp_handler
new file mode 100755
index 0000000..b5d6a85
--- /dev/null
+++ b/tests/unit/test_mcp_handler
Binary files differ
diff --git a/tests/unit/test_nip04 b/tests/unit/test_nip04
new file mode 100755
index 0000000..cb52040
--- /dev/null
+++ b/tests/unit/test_nip04
Binary files differ
diff --git a/tests/unit/test_session.c b/tests/unit/test_session.c
index 548be0d..2619ba3 100644
--- a/tests/unit/test_session.c
+++ b/tests/unit/test_session.c
@@ -46,8 +46,7 @@ static void test_sessions(void)
46 ASSERT_EQ_INT(0, session_active_count(), "No sessions after init"); 46 ASSERT_EQ_INT(0, session_active_count(), "No sessions after init");
47 47
48 printf("\n--- session_create ---\n"); 48 printf("\n--- session_create ---\n");
49 const char *secrets[] = {"secret1", "secret2"}; 49 session_t *s = session_create(0x0A01A8C0, 60000);
50 session_t *s = session_create(0x0A01A8C0, 60000, secrets, 2);
51 ASSERT(s != NULL, "session_create returns non-NULL"); 50 ASSERT(s != NULL, "session_create returns non-NULL");
52 ASSERT_EQ_INT(1, session_active_count(), "1 session after create"); 51 ASSERT_EQ_INT(1, session_active_count(), "1 session after create");
53 ASSERT_EQ_INT(1, g_granted_count, "firewall_grant_access was called"); 52 ASSERT_EQ_INT(1, g_granted_count, "firewall_grant_access was called");
@@ -57,23 +56,18 @@ static void test_sessions(void)
57 ASSERT(found == s, "session_find_by_ip returns the created session"); 56 ASSERT(found == s, "session_find_by_ip returns the created session");
58 ASSERT(session_find_by_ip(0x01020304) == NULL, "session_find_by_ip returns NULL for unknown IP"); 57 ASSERT(session_find_by_ip(0x01020304) == NULL, "session_find_by_ip returns NULL for unknown IP");
59 58
60 printf("\n--- session_is_secret_spent ---\n");
61 ASSERT(session_is_secret_spent("secret1"), "secret1 is marked spent");
62 ASSERT(session_is_secret_spent("secret2"), "secret2 is marked spent");
63 ASSERT(!session_is_secret_spent("secret_unknown"), "unknown secret is not spent");
64
65 printf("\n--- Duplicate secret rejected ---\n");
66 const char *dup_secrets[] = {"secret1"};
67 g_granted_count = 0;
68 session_t *dup = session_create(0x0B01A8C0, 60000, dup_secrets, 1);
69 ASSERT(dup == NULL, "Duplicate secret returns NULL");
70 ASSERT_EQ_INT(0, g_granted_count, "No new firewall grant for duplicate");
71
72 printf("\n--- session_extend ---\n"); 59 printf("\n--- session_extend ---\n");
73 uint64_t old_allotment = s->allotment_ms; 60 uint64_t old_allotment = s->allotment_ms;
74 session_extend(s, 30000); 61 session_extend(s, 30000);
75 ASSERT(s->allotment_ms == old_allotment + 30000, "Allotment extended by 30000ms"); 62 ASSERT(s->allotment_ms == old_allotment + 30000, "Allotment extended by 30000ms");
76 63
64 printf("\n--- session_extend for existing client ---\n");
65 g_granted_count = 0;
66 session_t *s2 = session_create(0x0A01A8C0, 30000);
67 ASSERT(s2 == s, "same IP returns existing session");
68 ASSERT_EQ_INT(0, g_granted_count, "no new firewall grant for extension");
69 ASSERT(s->allotment_ms == old_allotment + 60000, "allotment extended by 30000ms on re-pay");
70
77 printf("\n--- session_revoke ---\n"); 71 printf("\n--- session_revoke ---\n");
78 g_revoked_count = 0; 72 g_revoked_count = 0;
79 session_revoke(s); 73 session_revoke(s);
@@ -81,10 +75,8 @@ static void test_sessions(void)
81 ASSERT_EQ_INT(0, session_active_count(), "No active sessions after revoke"); 75 ASSERT_EQ_INT(0, session_active_count(), "No active sessions after revoke");
82 76
83 printf("\n--- session_revoke_all ---\n"); 77 printf("\n--- session_revoke_all ---\n");
84 const char *s1[] = {"s1"}; 78 session_create(0x01000001, 60000);
85 const char *s2[] = {"s2"}; 79 session_create(0x01000002, 60000);
86 session_create(0x01000001, 60000, s1, 1);
87 session_create(0x01000002, 60000, s2, 1);
88 ASSERT_EQ_INT(2, session_active_count(), "2 sessions created"); 80 ASSERT_EQ_INT(2, session_active_count(), "2 sessions created");
89 81
90 g_revoked_count = 0; 82 g_revoked_count = 0;
@@ -93,8 +85,7 @@ static void test_sessions(void)
93 85
94 printf("\n--- session_tick does not crash ---\n"); 86 printf("\n--- session_tick does not crash ---\n");
95 session_manager_init(); 87 session_manager_init();
96 const char *st[] = {"tick_secret"}; 88 session_create(0x0A000001, 60000);
97 session_create(0x0A000001, 60000, st, 1);
98 session_tick(); 89 session_tick();
99 ASSERT_EQ_INT(1, session_active_count(), "Session still active after tick (not expired)"); 90 ASSERT_EQ_INT(1, session_active_count(), "Session still active after tick (not expired)");
100} 91}
@@ -106,9 +97,8 @@ void test_bytes_sessions(void)
106 memset(&g_test_config, 0, sizeof(g_test_config)); 97 memset(&g_test_config, 0, sizeof(g_test_config));
107 strncpy(g_test_config.metric, "bytes", sizeof(g_test_config.metric) - 1); 98 strncpy(g_test_config.metric, "bytes", sizeof(g_test_config.metric) - 1);
108 99
109 const char *sec[] = {"bytes_secret"};
110 uint64_t allotment = 22020096; 100 uint64_t allotment = 22020096;
111 session_t *s = session_create_bytes(0x0A010001, allotment, sec, 1); 101 session_t *s = session_create_bytes(0x0A010001, allotment);
112 ASSERT(s != NULL, "bytes session created"); 102 ASSERT(s != NULL, "bytes session created");
113 ASSERT_EQ_INT(1, session_active_count(), "1 active bytes session"); 103 ASSERT_EQ_INT(1, session_active_count(), "1 active bytes session");
114 104
@@ -133,8 +123,7 @@ void test_bytes_sessions(void)
133 session_manager_init(); 123 session_manager_init();
134 memset(&g_test_config, 0, sizeof(g_test_config)); 124 memset(&g_test_config, 0, sizeof(g_test_config));
135 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1); 125 strncpy(g_test_config.metric, "milliseconds", sizeof(g_test_config.metric) - 1);
136 const char *ms_sec[] = {"ms_secret"}; 126 session_t *ms = session_create(0x0A020001, 60000);
137 session_t *ms = session_create(0x0A020001, 60000, ms_sec, 1);
138 ASSERT(ms != NULL, "ms session created"); 127 ASSERT(ms != NULL, "ms session created");
139 ASSERT(!session_is_expired(ms), "ms session not expired immediately"); 128 ASSERT(!session_is_expired(ms), "ms session not expired immediately");
140 129
diff --git a/tests/unit/test_tollgate_client b/tests/unit/test_tollgate_client
index 33b272e..f9b0f7d 100755
--- a/tests/unit/test_tollgate_client
+++ b/tests/unit/test_tollgate_client
Binary files differ