upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHECKLIST.md89
-rw-r--r--main/CMakeLists.txt3
-rw-r--r--main/captive_portal.c35
-rw-r--r--main/cashu.c241
-rw-r--r--main/cashu.h42
-rw-r--r--main/session.c164
-rw-r--r--main/session.h44
-rw-r--r--main/tollgate_api.c355
-rw-r--r--main/tollgate_api.h10
-rw-r--r--main/tollgate_main.c10
-rw-r--r--tests/phase2.mjs101
11 files changed, 1045 insertions, 49 deletions
diff --git a/CHECKLIST.md b/CHECKLIST.md
index e5f99a9..fe5894f 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -4,52 +4,69 @@
4- [x] Create project directory and git repo 4- [x] Create project directory and git repo
5- [x] Create .env, .env.example, .gitignore 5- [x] Create .env, .env.example, .gitignore
6- [x] Persist PLAN.md and CHECKLIST.md 6- [x] Persist PLAN.md and CHECKLIST.md
7- [ ] Create ESP-IDF project skeleton 7- [x] Create ESP-IDF project skeleton (CMakeLists, partitions.csv, sdkconfig.defaults)
8- [ ] Create Makefile with detect targets 8- [x] Create Makefile with detect/build/flash/test targets
9- [ ] Run `make detect-all` — identify ESP32 boards 9- [x] Run `make detect-all` — identified both boards as ESP32-S3 (16MB flash)
10- [x] Fix ESP-IDF v5.4.1 installation (was deeply corrupted, re-cloned)
10 11
11## Phase 1: Captive Portal + Firewall 12## Phase 1: Captive Portal + Firewall — COMPLETE
12- [ ] Implement tollgate_main.c (WiFi AP+STA, event loop) 13- [x] Implement tollgate_main.c (WiFi AP+STA, event loop)
13- [ ] Implement config.c/h (JSON config loading) 14- [x] Implement config.c/h (SPIFFS JSON config loading)
14- [ ] Implement dns_server.c/h (DNS hijack/forward) 15- [x] Implement dns_server.c/h (DNS hijack/forward per-client)
15- [ ] Implement captive_portal.c/h (HTTP :80, portal HTML) 16- [x] Implement captive_portal.c/h (HTTP :80, portal HTML)
16- [ ] Implement firewall.c/h (NAPT, per-IP auth) 17- [x] Implement firewall.c/h (NAPT on/off per auth state)
17- [ ] Set up test infrastructure (Playwright, helpers) 18- [x] Set up test infrastructure (Node.js tests, helpers, Playwright)
18- [ ] Test 1: Boot and AP appears 19- [x] Fix WiFi init order bug (netif before esp_wifi_init, set_mode before set_config)
19- [ ] Test 2: DHCP lease 20- [x] Fix DNS hijack test (nslookup exits 1 for AAAA, catch stderr)
20- [ ] Test 3: Captive portal serves HTML 21- [x] Fix ping tests (use `-I wlp59s0` to force through TollGate AP)
21- [ ] Test 4: Captive detection URIs work 22- [x] Test 1: Boot and AP appears
22- [ ] Test 5: DNS hijack before auth 23- [x] Test 2: DHCP lease
23- [ ] Test 6: No internet before auth 24- [x] Test 3: Captive portal serves HTML
24- [ ] Test 7: /whoami returns MAC 25- [x] Test 4: Captive detection URIs work (8 URIs)
25- [ ] Test 8: /usage returns no session 26- [x] Test 5: DNS hijack before auth
26- [ ] Test 9: Grant access via API 27- [x] Test 6: No internet before auth
27- [ ] Test 10: DNS forward after auth 28- [x] Test 7: /whoami returns MAC
28- [ ] Test 11: Internet after auth 29- [x] Test 8: /usage returns no session
29- [ ] Test 12: HTTP browsing works 30- [x] Test 9: Grant access via API
30- [ ] Test 13: Reset auth 31- [x] Test 10: DNS forward after auth
31- [ ] Test 14: Internet blocked after reset 32- [x] Test 11: Internet after auth
33- [x] Test 12: HTTP browsing works
34- [x] Test 13: Reset auth
35- [x] Test 14: Internet blocked after reset
36- [x] **All 20 API tests pass, all 6 smoke tests pass**
37- [x] Committed: `a7d0a67`
32 38
33## Phase 2: E-Cash Payments (Simple Melt) 39## Phase 2: E-Cash Payments — IN PROGRESS (code written, bugs to fix)
34- [ ] Implement payment.c/h (Cashu token parse + melt) 40- [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation)
35- [ ] Implement session.c/h (time-based metering) 41- [x] Implement session.c/h (time-based allotment, expiry, secret tracking)
36- [ ] Implement tollgate_api.c/h (:2121 endpoints) 42- [x] Implement tollgate_api.c/h (:2121 server, GET/POST /, /usage, /whoami)
37- [ ] Update captive portal HTML with payment form 43- [x] Update captive portal HTML with payment form (token textarea, Pay & Connect button)
38- [ ] Test 15: Advertisement valid 44- [x] Wire into tollgate_main.c (session_init, api_start, session_tick loop)
39- [ ] Test 16: Valid payment 45- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING
40- [ ] Test 17: Usage tracking 46- [ ] **BUG FIX: Stack overflow in httpd task** — POST to :2121 crashes (Guru Meditation LoadProhibited). Need to increase httpd stack_size to 16384 and heap-allocate large buffers in cashu.c
47- [ ] **BUG FIX: cashu_decode_token has 2048B stack buffer** — move json_buf to heap
48- [ ] **BUG FIX: cashu_check_proof_states has 4096B stack buffer** — move resp_buf to heap
49- [ ] Test 16: Valid payment (needs valid Cashu token from nutshell)
50- [ ] Test 17: Usage tracking after payment
41- [ ] Test 18: Internet after payment 51- [ ] Test 18: Internet after payment
42- [ ] Test 19: Invalid token rejected 52- [ ] Test 19: Invalid token rejected — blocked by stack overflow crash
43- [ ] Test 20: Spent token rejected 53- [ ] Test 20: Spent token rejected
44- [ ] Test 21: Wrong mint rejected 54- [ ] Test 21: Wrong mint rejected — blocked by stack overflow crash
45- [ ] Test 22: Session expiry 55- [ ] Test 22: Session expiry
46- [ ] Test 23: Session renewal 56- [ ] Test 23: Session renewal
47- [ ] Test 24: Portal payment form 57- [ ] Test 24: Portal payment form — blocked by stack overflow crash
48- [ ] Test 25: Two clients pay independently 58- [ ] Test 25: Two clients pay independently
49- [ ] Test 26: Client isolation 59- [ ] Test 26: Client isolation
50- [ ] Test 27: Full e2e browser flow 60- [ ] Test 27: Full e2e browser flow
51 61
52## Phase 3: nucula Wallet + Reseller 62## Infrastructure Setup — TODO (before next hardware session)
63- [ ] Update .env: change mint from nofee.testnut.cashu.space → testnut.cashu.space
64- [ ] Update Makefile: add nutshell wallet targets (mint-token, send-token, balance)
65- [ ] Create Ansible playbook for full dev environment setup
66- [ ] Create NetworkManager profile for TollGate testing (ethernet=upstream, wifi=tollgate only)
67- [ ] Verify network routing works (ethernet default route, WiFi 192.168.4.0/24 only)
68
69## Phase 3: nucula Wallet + Reseller — NOT STARTED
53- [ ] Extract nucula wallet into components/cashu_wallet/ 70- [ ] Extract nucula wallet into components/cashu_wallet/
54- [ ] Replace simple melt with Wallet::receive() 71- [ ] Replace simple melt with Wallet::receive()
55- [ ] Implement payout.c/h (background melt-to-LN) 72- [ ] Implement payout.c/h (background melt-to-LN)
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index a21a53f..97b4c37 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -3,6 +3,9 @@ idf_component_register(SRCS "tollgate_main.c"
3 "dns_server.c" 3 "dns_server.c"
4 "captive_portal.c" 4 "captive_portal.c"
5 "firewall.c" 5 "firewall.c"
6 "cashu.c"
7 "session.c"
8 "tollgate_api.c"
6 INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" 9 INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps"
7 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 10 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
8 lwip json esp_http_client mbedtls log spiffs 11 lwip json esp_http_client mbedtls log spiffs
diff --git a/main/captive_portal.c b/main/captive_portal.c
index acff9c2..17f672f 100644
--- a/main/captive_portal.c
+++ b/main/captive_portal.c
@@ -38,6 +38,8 @@ static const char PORTAL_HTML[] = \
38"font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" 38"font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}"
39".btn:hover{background:#e8850f}" 39".btn:hover{background:#e8850f}"
40".btn:disabled{background:#333;color:#666;cursor:not-allowed}" 40".btn:disabled{background:#333;color:#666;cursor:not-allowed}"
41"textarea{width:100%;height:80px;background:#252525;border:1px solid #333;border-radius:8px;"
42"color:#fff;padding:12px;font-family:monospace;font-size:12px;margin-top:8px;resize:none}"
41"</style>" 43"</style>"
42"</head><body>" 44"</head><body>"
43"<div class='card'>" 45"<div class='card'>"
@@ -47,25 +49,32 @@ static const char PORTAL_HTML[] = \
47"<div class='price-amount' id='price'>Loading...</div>" 49"<div class='price-amount' id='price'>Loading...</div>"
48"<div class='price-unit'>sats per minute</div>" 50"<div class='price-unit'>sats per minute</div>"
49"</div>" 51"</div>"
50"<button class='btn' id='grantBtn' onclick='grantAccess()'>Grant Free Access</button>" 52"<textarea id='tokenInput' placeholder='Paste your Cashu token here (cashuA...)'></textarea>"
53"<button class='btn' id='payBtn' onclick='payToken()'>Pay & Connect</button>"
51"<div id='status'></div>" 54"<div id='status'></div>"
52"</div>" 55"</div>"
53"<script>" 56"<script>"
54"const priceEl=document.getElementById('price');" 57"const priceEl=document.getElementById('price');"
55"const statusEl=document.getElementById('status');" 58"const statusEl=document.getElementById('status');"
56"const grantBtn=document.getElementById('grantBtn');" 59"const payBtn=document.getElementById('payBtn');"
57"fetch('/api/status').then(r=>r.json()).then(d=>{priceEl.textContent=d.price||'21';}).catch(()=>{priceEl.textContent='21';});" 60"const tokenInput=document.getElementById('tokenInput');"
61"fetch('http://192.168.4.1:2121/').then(r=>r.json()).then(d=>{"
62"if(d.tags){const p=d.tags.find(t=>t[0]==='price_per_step');if(p)priceEl.textContent=p[2]||'21';}"
63"}).catch(()=>{priceEl.textContent='21';});"
58"function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}" 64"function showStatus(msg,type){statusEl.textContent=msg;statusEl.className=type;}"
59"function grantAccess(){" 65"function payToken(){"
60" grantBtn.disabled=true;" 66"const token=tokenInput.value.trim();"
61" showStatus('Connecting...','processing');" 67"if(!token||!token.startsWith('cashuA')){showStatus('Please paste a valid Cashu token','error');return;}"
62" fetch('/grant_access').then(r=>r.json()).then(d=>{" 68"payBtn.disabled=true;"
63" if(d.status==='granted'){" 69"showStatus('Processing payment...','processing');"
64" showStatus('Connected! You have internet access.','success');" 70"fetch('http://192.168.4.1:2121/',{method:'POST',body:token}).then(r=>{"
65" grantBtn.textContent='Connected!';" 71"if(r.ok)return r.json();"
66" setTimeout(()=>{window.location.href='http://detectportal.firefox.com/success.txt';},2000);" 72"return r.json().then(d=>{throw new Error(d.content||'Payment failed');});"
67" }else{showStatus('Error: '+d.message,'error');grantBtn.disabled=false;}" 73"}).then(d=>{"
68" }).catch(e=>{showStatus('Connection error','error');grantBtn.disabled=false;});" 74"if(d.kind===1022){showStatus('Connected! You have internet access.','success');payBtn.textContent='Connected!';"
75"setTimeout(()=>{window.location.href='http://detectportal.firefox.com/success.txt';},2000);}"
76"else if(d.kind===21023){showStatus('Error: '+(d.content||'Unknown error'),'error');payBtn.disabled=false;}"
77"}).catch(e=>{showStatus(e.message||'Connection error','error');payBtn.disabled=false;});"
69"}" 78"}"
70"</script>" 79"</script>"
71"</body></html>"; 80"</body></html>";
diff --git a/main/cashu.c b/main/cashu.c
new file mode 100644
index 0000000..bafd000
--- /dev/null
+++ b/main/cashu.c
@@ -0,0 +1,241 @@
1#include "cashu.h"
2#include "config.h"
3#include "esp_log.h"
4#include "esp_http_client.h"
5#include "cJSON.h"
6#include "mbedtls/base64.h"
7#include "mbedtls/sha256.h"
8#include <string.h>
9
10static const char *TAG = "cashu";
11
12static const char V3_PREFIX[] = "cashuA";
13static const size_t V3_PREFIX_LEN = 6;
14
15static int b64url_decode(const char *input, size_t input_len, char *out, size_t out_size, size_t *out_len)
16{
17 char b64[1024];
18 size_t b64_len = input_len;
19 if (b64_len >= sizeof(b64)) return -1;
20 memcpy(b64, input, b64_len);
21 b64[b64_len] = '\0';
22
23 for (size_t i = 0; i < b64_len; i++) {
24 if (b64[i] == '-') b64[i] = '+';
25 else if (b64[i] == '_') b64[i] = '/';
26 }
27 while (b64_len % 4 != 0 && b64_len < sizeof(b64) - 1) {
28 b64[b64_len++] = '=';
29 }
30 b64[b64_len] = '\0';
31
32 size_t olen = 0;
33 int ret = mbedtls_base64_decode((unsigned char *)out, out_size, &olen,
34 (const unsigned char *)b64, b64_len);
35 if (ret != 0) return -1;
36 *out_len = olen;
37 return 0;
38}
39
40static esp_err_t parse_proofs_array(cJSON *arr, cashu_token_t *out)
41{
42 if (!cJSON_IsArray(arr)) return ESP_FAIL;
43 int count = cJSON_GetArraySize(arr);
44 if (count > 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 cashu_decode_token(const char *token_str, 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 if (len <= V3_PREFIX_LEN) {
81 ESP_LOGE(TAG, "Token too short");
82 return ESP_FAIL;
83 }
84 if (strncmp(token_str, V3_PREFIX, V3_PREFIX_LEN) != 0) {
85 ESP_LOGE(TAG, "Token missing cashuA prefix");
86 return ESP_FAIL;
87 }
88
89 char json_buf[2048];
90 size_t json_len = 0;
91 if (b64url_decode(token_str + V3_PREFIX_LEN, len - V3_PREFIX_LEN,
92 json_buf, sizeof(json_buf) - 1, &json_len) != 0) {
93 ESP_LOGE(TAG, "Base64url decode failed");
94 return ESP_FAIL;
95 }
96 json_buf[json_len] = '\0';
97
98 cJSON *root = cJSON_Parse(json_buf);
99 if (!root) {
100 ESP_LOGE(TAG, "JSON parse failed");
101 return ESP_FAIL;
102 }
103
104 cJSON *token_arr = cJSON_GetObjectItemCaseSensitive(root, "token");
105 if (token_arr && cJSON_IsArray(token_arr)) {
106 cJSON *first = cJSON_GetArrayItem(token_arr, 0);
107 if (!first) { cJSON_Delete(root); return ESP_FAIL; }
108
109 cJSON *mint = cJSON_GetObjectItemCaseSensitive(first, "mint");
110 if (mint && cJSON_IsString(mint)) {
111 strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1);
112 }
113
114 cJSON *proofs = cJSON_GetObjectItemCaseSensitive(first, "proofs");
115 if (proofs) {
116 esp_err_t ret = parse_proofs_array(proofs, out);
117 if (ret != ESP_OK) { cJSON_Delete(root); return ret; }
118 }
119 } else {
120 cJSON *mint = cJSON_GetObjectItemCaseSensitive(root, "mint");
121 if (mint && cJSON_IsString(mint)) {
122 strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1);
123 }
124
125 cJSON *proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs");
126 if (proofs) {
127 esp_err_t ret = parse_proofs_array(proofs, out);
128 if (ret != ESP_OK) { cJSON_Delete(root); return ret; }
129 }
130 }
131
132 cJSON_Delete(root);
133
134 if (out->proof_count == 0) {
135 ESP_LOGE(TAG, "No proofs in token");
136 return ESP_FAIL;
137 }
138
139 ESP_LOGI(TAG, "Decoded token: %d proofs, total=%llu, mint=%s",
140 out->proof_count, (unsigned long long)out->total_amount, out->mint_url);
141 return ESP_OK;
142}
143
144static void sha256_hex(const char *data, size_t data_len, char *hex_out)
145{
146 uint8_t hash[32];
147 mbedtls_sha256((const unsigned char *)data, data_len, hash, 0);
148 for (int i = 0; i < 32; i++) {
149 sprintf(hex_out + i * 2, "%02x", hash[i]);
150 }
151 hex_out[64] = '\0';
152}
153
154esp_err_t cashu_check_proof_states(const char *mint_url, const cashu_token_t *token,
155 cashu_proof_state_t *states, int *state_count)
156{
157 cJSON *ys_arr = cJSON_CreateArray();
158 for (int i = 0; i < token->proof_count; i++) {
159 char y_hex[65];
160 sha256_hex(token->proofs[i].secret, strlen(token->proofs[i].secret), y_hex);
161 cJSON_AddItemToArray(ys_arr, cJSON_CreateString(y_hex));
162 strncpy(states[i].y_hex, y_hex, sizeof(states[i].y_hex) - 1);
163 states[i].spent = false;
164 }
165 *state_count = token->proof_count;
166
167 char *ys_json = cJSON_PrintUnformatted(ys_arr);
168 cJSON_Delete(ys_arr);
169
170 char post_body[2048];
171 snprintf(post_body, sizeof(post_body), "{\"Ys\":%s}", ys_json);
172 cJSON_free(ys_json);
173
174 char url[512];
175 snprintf(url, sizeof(url), "%s/v1/checkstate", mint_url);
176
177 char resp_buf[4096];
178 int resp_len = 0;
179
180 esp_http_client_config_t config = {
181 .url = url,
182 .method = HTTP_METHOD_POST,
183 .timeout_ms = 10000,
184 };
185 esp_http_client_handle_t client = esp_http_client_init(&config);
186 if (!client) return ESP_FAIL;
187
188 esp_http_client_set_header(client, "Content-Type", "application/json");
189 esp_err_t err = esp_http_client_open(client, strlen(post_body));
190 if (err != ESP_OK) {
191 esp_http_client_cleanup(client);
192 return err;
193 }
194 esp_http_client_write(client, post_body, strlen(post_body));
195
196 resp_len = esp_http_client_read(client, resp_buf, sizeof(resp_buf) - 1);
197 int status = esp_http_client_get_status_code(client);
198 esp_http_client_cleanup(client);
199
200 if (status != 200 || resp_len <= 0) {
201 ESP_LOGE(TAG, "checkstate returned %d", status);
202 return ESP_FAIL;
203 }
204 resp_buf[resp_len] = '\0';
205
206 cJSON *root = cJSON_Parse(resp_buf);
207 if (!root) return ESP_FAIL;
208
209 cJSON *states_arr = cJSON_GetObjectItemCaseSensitive(root, "states");
210 if (!states_arr || !cJSON_IsArray(states_arr)) {
211 cJSON_Delete(root);
212 return ESP_FAIL;
213 }
214
215 int n = cJSON_GetArraySize(states_arr);
216 for (int i = 0; i < n && i < token->proof_count; i++) {
217 cJSON *s = cJSON_GetArrayItem(states_arr, i);
218 cJSON *state = cJSON_GetObjectItemCaseSensitive(s, "state");
219 if (state && cJSON_IsString(state)) {
220 states[i].spent = (strcmp(state->valuestring, "SPENT") == 0);
221 }
222 }
223
224 cJSON_Delete(root);
225 return ESP_OK;
226}
227
228uint64_t cashu_calculate_allotment_ms(uint64_t token_amount, uint64_t price_per_step,
229 uint64_t step_size_ms)
230{
231 if (price_per_step == 0) return 0;
232 return (token_amount / price_per_step) * step_size_ms;
233}
234
235bool cashu_is_mint_accepted(const char *mint_url)
236{
237 if (!mint_url || mint_url[0] == '\0') return false;
238 const tollgate_config_t *cfg = tollgate_config_get();
239 if (strstr(mint_url, cfg->mint_url) != NULL) return true;
240 return (strcmp(mint_url, cfg->mint_url) == 0);
241}
diff --git a/main/cashu.h b/main/cashu.h
new file mode 100644
index 0000000..17891c5
--- /dev/null
+++ b/main/cashu.h
@@ -0,0 +1,42 @@
1#ifndef CASHU_H
2#define CASHU_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stdbool.h>
7
8#define CASHU_MAX_PROOFS 10
9#define CASHU_MAX_SECRET_LEN 128
10#define CASHU_MAX_ID_LEN 16
11#define CASHU_MAX_C_LEN 128
12
13typedef struct {
14 uint64_t amount;
15 char id[CASHU_MAX_ID_LEN];
16 char secret[CASHU_MAX_SECRET_LEN];
17 char c[CASHU_MAX_C_LEN];
18} cashu_proof_t;
19
20typedef struct {
21 cashu_proof_t proofs[CASHU_MAX_PROOFS];
22 int proof_count;
23 char mint_url[256];
24 uint64_t total_amount;
25} cashu_token_t;
26
27typedef struct {
28 char y_hex[65];
29 bool spent;
30} cashu_proof_state_t;
31
32esp_err_t cashu_decode_token(const char *token_str, cashu_token_t *out);
33
34esp_err_t cashu_check_proof_states(const char *mint_url, const cashu_token_t *token,
35 cashu_proof_state_t *states, int *state_count);
36
37uint64_t cashu_calculate_allotment_ms(uint64_t token_amount, uint64_t price_per_step,
38 uint64_t step_size_ms);
39
40bool cashu_is_mint_accepted(const char *mint_url);
41
42#endif
diff --git a/main/session.c b/main/session.c
new file mode 100644
index 0000000..6d9c334
--- /dev/null
+++ b/main/session.c
@@ -0,0 +1,164 @@
1#include "session.h"
2#include "firewall.h"
3#include "dns_server.h"
4#include "esp_log.h"
5#include "freertos/FreeRTOS.h"
6#include "freertos/task.h"
7#include <string.h>
8
9#define SPENT_SECRETS_MAX 100
10
11static const char *TAG = "session";
12static session_t s_sessions[SESSION_MAX_CLIENTS];
13static int s_session_count = 0;
14
15static char s_spent_secrets[SPENT_SECRETS_MAX][65];
16static int s_spent_count = 0;
17
18static int64_t get_time_ms(void)
19{
20 return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS;
21}
22
23esp_err_t session_manager_init(void)
24{
25 memset(s_sessions, 0, sizeof(s_sessions));
26 s_session_count = 0;
27 s_spent_count = 0;
28 ESP_LOGI(TAG, "Session manager initialized");
29 return ESP_OK;
30}
31
32session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
33 const char *spent_secrets[], int secret_count)
34{
35 session_t *existing = session_find_by_ip(client_ip);
36 if (existing) {
37 session_extend(existing, allotment_ms);
38 for (int i = 0; i < secret_count && s_spent_count < SPENT_SECRETS_MAX; i++) {
39 strncpy(s_spent_secrets[s_spent_count], spent_secrets[i], 64);
40 s_spent_secrets[s_spent_count][64] = '\0';
41 s_spent_count++;
42 }
43 return existing;
44 }
45
46 if (s_session_count >= SESSION_MAX_CLIENTS) {
47 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
48 if (!s_sessions[i].active || session_is_expired(&s_sessions[i])) {
49 session_revoke(&s_sessions[i]);
50 break;
51 }
52 }
53 }
54
55 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
56 if (!s_sessions[i].active) {
57 s_sessions[i].client_ip = client_ip;
58 s_sessions[i].allotment_ms = allotment_ms;
59 s_sessions[i].start_time_ms = get_time_ms();
60 s_sessions[i].active = true;
61 s_sessions[i].spent_secret_count = 0;
62
63 for (int j = 0; j < secret_count && j < 5; j++) {
64 strncpy(s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count],
65 spent_secrets[j], 64);
66 s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count][64] = '\0';
67 s_sessions[i].spent_secret_count++;
68 }
69
70 for (int j = 0; j < secret_count && s_spent_count < SPENT_SECRETS_MAX; j++) {
71 strncpy(s_spent_secrets[s_spent_count], spent_secrets[j], 64);
72 s_spent_secrets[s_spent_count][64] = '\0';
73 s_spent_count++;
74 }
75
76 s_session_count++;
77 firewall_grant_access(client_ip);
78
79 esp_ip4_addr_t ip = { .addr = client_ip };
80 ESP_LOGI(TAG, "Session created: " IPSTR " allotment=%llums", IP2STR(&ip),
81 (unsigned long long)allotment_ms);
82 return &s_sessions[i];
83 }
84 }
85
86 ESP_LOGW(TAG, "No free session slots");
87 return NULL;
88}
89
90session_t *session_find_by_ip(uint32_t client_ip)
91{
92 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
93 if (s_sessions[i].active && s_sessions[i].client_ip == client_ip) {
94 return &s_sessions[i];
95 }
96 }
97 return NULL;
98}
99
100void session_extend(session_t *session, uint64_t additional_ms)
101{
102 if (!session || !session->active) return;
103 session->allotment_ms += additional_ms;
104 esp_ip4_addr_t ip = { .addr = session->client_ip };
105 ESP_LOGI(TAG, "Session extended: " IPSTR " +%llums (total=%llu)", IP2STR(&ip),
106 (unsigned long long)additional_ms, (unsigned long long)session->allotment_ms);
107}
108
109bool session_is_expired(const session_t *session)
110{
111 if (!session || !session->active) return true;
112 int64_t elapsed = get_time_ms() - session->start_time_ms;
113 return elapsed >= (int64_t)session->allotment_ms;
114}
115
116bool session_is_secret_spent(const char *secret)
117{
118 for (int i = 0; i < s_spent_count; i++) {
119 if (strncmp(s_spent_secrets[i], secret, 64) == 0) return true;
120 }
121 return false;
122}
123
124void session_check_expiry(void)
125{
126 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
127 if (s_sessions[i].active && session_is_expired(&s_sessions[i])) {
128 esp_ip4_addr_t ip = { .addr = s_sessions[i].client_ip };
129 ESP_LOGI(TAG, "Session expired: " IPSTR, IP2STR(&ip));
130 session_revoke(&s_sessions[i]);
131 }
132 }
133}
134
135void session_revoke(session_t *session)
136{
137 if (!session || !session->active) return;
138 firewall_revoke_access(session->client_ip);
139 session->active = false;
140 s_session_count--;
141}
142
143void session_revoke_all(void)
144{
145 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
146 if (s_sessions[i].active) {
147 session_revoke(&s_sessions[i]);
148 }
149 }
150}
151
152int session_active_count(void)
153{
154 int count = 0;
155 for (int i = 0; i < SESSION_MAX_CLIENTS; i++) {
156 if (s_sessions[i].active) count++;
157 }
158 return count;
159}
160
161void session_tick(void)
162{
163 session_check_expiry();
164}
diff --git a/main/session.h b/main/session.h
new file mode 100644
index 0000000..e7d78d4
--- /dev/null
+++ b/main/session.h
@@ -0,0 +1,44 @@
1#ifndef SESSION_H
2#define SESSION_H
3
4#include "esp_err.h"
5#include <stdint.h>
6#include <stdbool.h>
7
8#define SESSION_MAX_CLIENTS 10
9#define SESSION_MAX_MAC_LEN 18
10
11typedef struct {
12 uint32_t client_ip;
13 char mac[SESSION_MAX_MAC_LEN];
14 uint64_t allotment_ms;
15 int64_t start_time_ms;
16 bool active;
17 char spent_secrets[5][65];
18 int spent_secret_count;
19} session_t;
20
21esp_err_t session_manager_init(void);
22
23session_t *session_create(uint32_t client_ip, uint64_t allotment_ms,
24 const char *spent_secrets[], int secret_count);
25
26session_t *session_find_by_ip(uint32_t client_ip);
27
28void session_extend(session_t *session, uint64_t additional_ms);
29
30bool session_is_expired(const session_t *session);
31
32bool session_is_secret_spent(const char *secret);
33
34void session_check_expiry(void);
35
36void session_revoke(session_t *session);
37
38void session_revoke_all(void);
39
40int session_active_count(void);
41
42void session_tick(void);
43
44#endif
diff --git a/main/tollgate_api.c b/main/tollgate_api.c
new file mode 100644
index 0000000..5ada3c7
--- /dev/null
+++ b/main/tollgate_api.c
@@ -0,0 +1,355 @@
1#include "tollgate_api.h"
2#include "cashu.h"
3#include "config.h"
4#include "session.h"
5#include "esp_log.h"
6#include "cJSON.h"
7#include "lwip/sockets.h"
8#include "lwip/netdb.h"
9#include <string.h>
10
11static const char *TAG = "tollgate_api";
12static httpd_handle_t s_api_server = NULL;
13
14static const char *TOLLGATE_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000000";
15
16static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out)
17{
18 int sockfd = httpd_req_to_sockfd(req);
19 struct sockaddr_in addr;
20 socklen_t addr_len = sizeof(addr);
21 if (getpeername(sockfd, (struct sockaddr *)&addr, &addr_len) == 0) {
22 *ip_out = addr.sin_addr.s_addr;
23 return ESP_OK;
24 }
25 return ESP_FAIL;
26}
27
28static cJSON *create_notice(const char *level, const char *code, const char *content)
29{
30 cJSON *root = cJSON_CreateObject();
31 cJSON_AddNumberToObject(root, "kind", 21023);
32 cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY);
33 cJSON *tags = cJSON_CreateArray();
34 cJSON *level_tag = cJSON_CreateArray();
35 cJSON_AddItemToArray(level_tag, cJSON_CreateString("level"));
36 cJSON_AddItemToArray(level_tag, cJSON_CreateString(level));
37 cJSON_AddItemToArray(tags, level_tag);
38 cJSON *code_tag = cJSON_CreateArray();
39 cJSON_AddItemToArray(code_tag, cJSON_CreateString("code"));
40 cJSON_AddItemToArray(code_tag, cJSON_CreateString(code));
41 cJSON_AddItemToArray(tags, code_tag);
42 cJSON_AddItemToObject(root, "tags", tags);
43 cJSON_AddStringToObject(root, "content", content);
44 return root;
45}
46
47static cJSON *create_session_event(uint32_t client_ip, uint64_t allotment_ms)
48{
49 cJSON *root = cJSON_CreateObject();
50 cJSON_AddNumberToObject(root, "kind", 1022);
51 cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY);
52
53 cJSON *tags = cJSON_CreateArray();
54
55 cJSON *p_tag = cJSON_CreateArray();
56 cJSON_AddItemToArray(p_tag, cJSON_CreateString("p"));
57 cJSON_AddItemToArray(p_tag, cJSON_CreateString("unknown"));
58 cJSON_AddItemToArray(tags, p_tag);
59
60 esp_ip4_addr_t ip = { .addr = client_ip };
61 char ip_str[16];
62 snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip));
63 cJSON *dev_tag = cJSON_CreateArray();
64 cJSON_AddItemToArray(dev_tag, cJSON_CreateString("device-identifier"));
65 cJSON_AddItemToArray(dev_tag, cJSON_CreateString("mac"));
66 cJSON_AddItemToArray(dev_tag, cJSON_CreateString(ip_str));
67 cJSON_AddItemToArray(tags, dev_tag);
68
69 cJSON *allotment_tag = cJSON_CreateArray();
70 cJSON_AddItemToArray(allotment_tag, cJSON_CreateString("allotment"));
71 char allotment_str[32];
72 snprintf(allotment_str, sizeof(allotment_str), "%llu", (unsigned long long)allotment_ms);
73 cJSON_AddItemToArray(allotment_tag, cJSON_CreateString(allotment_str));
74 cJSON_AddItemToArray(tags, allotment_tag);
75
76 cJSON *metric_tag = cJSON_CreateArray();
77 cJSON_AddItemToArray(metric_tag, cJSON_CreateString("metric"));
78 cJSON_AddItemToArray(metric_tag, cJSON_CreateString("milliseconds"));
79 cJSON_AddItemToArray(tags, metric_tag);
80
81 cJSON_AddItemToObject(root, "tags", tags);
82 cJSON_AddStringToObject(root, "content", "");
83 return root;
84}
85
86static esp_err_t api_get_discovery(httpd_req_t *req)
87{
88 const tollgate_config_t *cfg = tollgate_config_get();
89
90 cJSON *root = cJSON_CreateObject();
91 cJSON_AddNumberToObject(root, "kind", 10021);
92 cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY);
93
94 cJSON *tags = cJSON_CreateArray();
95
96 cJSON *metric_tag = cJSON_CreateArray();
97 cJSON_AddItemToArray(metric_tag, cJSON_CreateString("metric"));
98 cJSON_AddItemToArray(metric_tag, cJSON_CreateString("milliseconds"));
99 cJSON_AddItemToArray(tags, metric_tag);
100
101 cJSON *step_tag = cJSON_CreateArray();
102 cJSON_AddItemToArray(step_tag, cJSON_CreateString("step_size"));
103 char step_str[32];
104 snprintf(step_str, sizeof(step_str), "%d", cfg->step_size_ms);
105 cJSON_AddItemToArray(step_tag, cJSON_CreateString(step_str));
106 cJSON_AddItemToArray(tags, step_tag);
107
108 cJSON *price_tag = cJSON_CreateArray();
109 cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step"));
110 cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu"));
111 char price_str[32];
112 snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step);
113 cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str));
114 cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat"));
115 cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url));
116 cJSON_AddItemToArray(price_tag, cJSON_CreateString("1"));
117 cJSON_AddItemToArray(tags, price_tag);
118
119 cJSON *tips_tag = cJSON_CreateArray();
120 cJSON_AddItemToArray(tips_tag, cJSON_CreateString("tips"));
121 cJSON_AddItemToArray(tips_tag, cJSON_CreateString("1"));
122 cJSON_AddItemToArray(tips_tag, cJSON_CreateString("2"));
123 cJSON_AddItemToArray(tips_tag, cJSON_CreateString("5"));
124 cJSON_AddItemToArray(tags, tips_tag);
125
126 cJSON_AddItemToObject(root, "tags", tags);
127 cJSON_AddStringToObject(root, "content", "");
128
129 char *json = cJSON_PrintUnformatted(root);
130 httpd_resp_set_type(req, "application/json");
131 httpd_resp_send(req, json, strlen(json));
132 cJSON_free(json);
133 cJSON_Delete(root);
134 return ESP_OK;
135}
136
137static esp_err_t api_post_payment(httpd_req_t *req)
138{
139 uint32_t client_ip = 0;
140 get_client_ip(req, &client_ip);
141
142 int content_len = req->content_len;
143 if (content_len <= 0 || content_len > 16384) {
144 cJSON *notice = create_notice("error", "payment-error-invalid", "Invalid request body");
145 char *json = cJSON_PrintUnformatted(notice);
146 httpd_resp_set_status(req, "400 Bad Request");
147 httpd_resp_set_type(req, "application/json");
148 httpd_resp_send(req, json, strlen(json));
149 cJSON_free(json);
150 cJSON_Delete(notice);
151 return ESP_OK;
152 }
153
154 char *body = malloc(content_len + 1);
155 if (!body) {
156 cJSON *notice = create_notice("error", "session-error", "Out of memory");
157 char *json = cJSON_PrintUnformatted(notice);
158 httpd_resp_set_status(req, "503 Service Unavailable");
159 httpd_resp_set_type(req, "application/json");
160 httpd_resp_send(req, json, strlen(json));
161 cJSON_free(json);
162 cJSON_Delete(notice);
163 return ESP_OK;
164 }
165 int received = httpd_req_recv(req, body, content_len);
166 if (received <= 0) {
167 free(body);
168 httpd_resp_set_status(req, "400 Bad Request");
169 httpd_resp_set_type(req, "text/plain");
170 httpd_resp_send(req, "bad request", 11);
171 return ESP_OK;
172 }
173 body[received] = '\0';
174
175 ESP_LOGI(TAG, "Payment received: %d bytes", received);
176
177 cashu_token_t token;
178 esp_err_t err = cashu_decode_token(body, &token);
179 free(body);
180
181 if (err != ESP_OK) {
182 cJSON *notice = create_notice("error", "payment-error-invalid", "Failed to decode Cashu token");
183 char *json = cJSON_PrintUnformatted(notice);
184 httpd_resp_set_status(req, "400 Bad Request");
185 httpd_resp_set_type(req, "application/json");
186 httpd_resp_send(req, json, strlen(json));
187 cJSON_free(json);
188 cJSON_Delete(notice);
189 return ESP_OK;
190 }
191
192 const char *mint_url = token.mint_url[0] ? token.mint_url : tollgate_config_get()->mint_url;
193 if (!cashu_is_mint_accepted(mint_url)) {
194 cJSON *notice = create_notice("error", "payment-error-mint-not-accepted", "Mint not accepted");
195 char *json = cJSON_PrintUnformatted(notice);
196 httpd_resp_set_status(req, "402 Payment Required");
197 httpd_resp_set_type(req, "application/json");
198 httpd_resp_send(req, json, strlen(json));
199 cJSON_free(json);
200 cJSON_Delete(notice);
201 return ESP_OK;
202 }
203
204 for (int i = 0; i < token.proof_count; i++) {
205 if (session_is_secret_spent(token.proofs[i].secret)) {
206 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent");
207 char *json = cJSON_PrintUnformatted(notice);
208 httpd_resp_set_status(req, "402 Payment Required");
209 httpd_resp_set_type(req, "application/json");
210 httpd_resp_send(req, json, strlen(json));
211 cJSON_free(json);
212 cJSON_Delete(notice);
213 return ESP_OK;
214 }
215 }
216
217 cashu_proof_state_t states[CASHU_MAX_PROOFS];
218 int state_count = 0;
219 err = cashu_check_proof_states(mint_url, &token, states, &state_count);
220 if (err != ESP_OK) {
221 cJSON *notice = create_notice("error", "payment-error-verification", "Failed to verify token with mint");
222 char *json = cJSON_PrintUnformatted(notice);
223 httpd_resp_set_status(req, "502 Bad Gateway");
224 httpd_resp_set_type(req, "application/json");
225 httpd_resp_send(req, json, strlen(json));
226 cJSON_free(json);
227 cJSON_Delete(notice);
228 return ESP_OK;
229 }
230
231 for (int i = 0; i < state_count; i++) {
232 if (states[i].spent) {
233 cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent");
234 char *json = cJSON_PrintUnformatted(notice);
235 httpd_resp_set_status(req, "402 Payment Required");
236 httpd_resp_set_type(req, "application/json");
237 httpd_resp_send(req, json, strlen(json));
238 cJSON_free(json);
239 cJSON_Delete(notice);
240 return ESP_OK;
241 }
242 }
243
244 const tollgate_config_t *cfg = tollgate_config_get();
245 uint64_t allotment = cashu_calculate_allotment_ms(token.total_amount, cfg->price_per_step, cfg->step_size_ms);
246 if (allotment == 0) {
247 cJSON *notice = create_notice("error", "payment-error-insufficient", "Token value too low");
248 char *json = cJSON_PrintUnformatted(notice);
249 httpd_resp_set_status(req, "402 Payment Required");
250 httpd_resp_set_type(req, "application/json");
251 httpd_resp_send(req, json, strlen(json));
252 cJSON_free(json);
253 cJSON_Delete(notice);
254 return ESP_OK;
255 }
256
257 const char *secrets[5];
258 for (int i = 0; i < token.proof_count && i < 5; i++) {
259 secrets[i] = token.proofs[i].secret;
260 }
261 session_t *session = session_create(client_ip, allotment, secrets, token.proof_count);
262 if (!session) {
263 cJSON *notice = create_notice("error", "session-error", "Failed to create session");
264 char *json = cJSON_PrintUnformatted(notice);
265 httpd_resp_set_status(req, "503 Service Unavailable");
266 httpd_resp_set_type(req, "application/json");
267 httpd_resp_send(req, json, strlen(json));
268 cJSON_free(json);
269 cJSON_Delete(notice);
270 return ESP_OK;
271 }
272
273 cJSON *session_event = create_session_event(client_ip, allotment);
274 char *json = cJSON_PrintUnformatted(session_event);
275 httpd_resp_set_type(req, "application/json");
276 httpd_resp_send(req, json, strlen(json));
277 cJSON_free(json);
278 cJSON_Delete(session_event);
279 return ESP_OK;
280}
281
282static esp_err_t api_get_usage(httpd_req_t *req)
283{
284 uint32_t client_ip = 0;
285 get_client_ip(req, &client_ip);
286
287 session_t *session = session_find_by_ip(client_ip);
288 if (!session || !session->active) {
289 httpd_resp_set_type(req, "text/plain");
290 httpd_resp_send(req, "-1/-1", 5);
291 return ESP_OK;
292 }
293
294 int64_t elapsed = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS - session->start_time_ms;
295 int64_t remaining = session->allotment_ms - elapsed;
296 if (remaining < 0) remaining = 0;
297
298 char resp[64];
299 snprintf(resp, sizeof(resp), "%lld/%llu", (long long)remaining, (unsigned long long)session->allotment_ms);
300 httpd_resp_set_type(req, "text/plain");
301 httpd_resp_send(req, resp, strlen(resp));
302 return ESP_OK;
303}
304
305static esp_err_t api_get_whoami(httpd_req_t *req)
306{
307 uint32_t client_ip = 0;
308 char resp[64];
309 if (get_client_ip(req, &client_ip) == ESP_OK) {
310 esp_ip4_addr_t ip = { .addr = client_ip };
311 snprintf(resp, sizeof(resp), "mac=" IPSTR, IP2STR(&ip));
312 } else {
313 snprintf(resp, sizeof(resp), "mac=unknown");
314 }
315 httpd_resp_set_type(req, "text/plain");
316 httpd_resp_send(req, resp, strlen(resp));
317 return ESP_OK;
318}
319
320static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery };
321static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment };
322static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage };
323static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami };
324
325esp_err_t tollgate_api_start(void)
326{
327 if (s_api_server) return ESP_OK;
328
329 httpd_config_t config = HTTPD_DEFAULT_CONFIG();
330 config.server_port = 2121;
331 config.ctrl_port = 32769;
332 config.max_uri_handlers = 10;
333
334 esp_err_t ret = httpd_start(&s_api_server, &config);
335 if (ret != ESP_OK) {
336 ESP_LOGE(TAG, "Failed to start API server: %s", esp_err_to_name(ret));
337 return ret;
338 }
339
340 httpd_register_uri_handler(s_api_server, &uri_discovery);
341 httpd_register_uri_handler(s_api_server, &uri_payment);
342 httpd_register_uri_handler(s_api_server, &uri_usage);
343 httpd_register_uri_handler(s_api_server, &uri_whoami);
344
345 ESP_LOGI(TAG, "TollGate API started on port 2121");
346 return ESP_OK;
347}
348
349void tollgate_api_stop(void)
350{
351 if (s_api_server) {
352 httpd_stop(s_api_server);
353 s_api_server = NULL;
354 }
355}
diff --git a/main/tollgate_api.h b/main/tollgate_api.h
new file mode 100644
index 0000000..23e0d75
--- /dev/null
+++ b/main/tollgate_api.h
@@ -0,0 +1,10 @@
1#ifndef TOLLGATE_API_H
2#define TOLLGATE_API_H
3
4#include "esp_err.h"
5#include "esp_http_server.h"
6
7esp_err_t tollgate_api_start(void);
8void tollgate_api_stop(void);
9
10#endif
diff --git a/main/tollgate_main.c b/main/tollgate_main.c
index 9eba61f..04f64b9 100644
--- a/main/tollgate_main.c
+++ b/main/tollgate_main.c
@@ -14,6 +14,8 @@
14#include "dns_server.h" 14#include "dns_server.h"
15#include "captive_portal.h" 15#include "captive_portal.h"
16#include "firewall.h" 16#include "firewall.h"
17#include "session.h"
18#include "tollgate_api.h"
17 19
18#define MAX_STA_RETRY 5 20#define MAX_STA_RETRY 5
19#define AP_IP_ADDR "192.168.4.1" 21#define AP_IP_ADDR "192.168.4.1"
@@ -105,9 +107,11 @@ static void start_services(void)
105 upstream_dns.addr = dns_addr->addr; 107 upstream_dns.addr = dns_addr->addr;
106 108
107 firewall_init(ap_ip_info.ip); 109 firewall_init(ap_ip_info.ip);
110 session_manager_init();
108 111
109 dns_server_start(ap_ip_info.ip, upstream_dns); 112 dns_server_start(ap_ip_info.ip, upstream_dns);
110 captive_portal_start(); 113 captive_portal_start();
114 tollgate_api_start();
111 115
112 s_services_running = true; 116 s_services_running = true;
113 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 117 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
@@ -123,6 +127,7 @@ static void stop_services(void)
123 } 127 }
124 128
125 captive_portal_stop(); 129 captive_portal_stop();
130 tollgate_api_stop();
126 dns_server_stop(); 131 dns_server_stop();
127 firewall_disable_nat(); 132 firewall_disable_nat();
128 firewall_revoke_all(); 133 firewall_revoke_all();
@@ -218,4 +223,9 @@ void app_main(void)
218 ESP_ERROR_CHECK(esp_wifi_start()); 223 ESP_ERROR_CHECK(esp_wifi_start());
219 224
220 ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection..."); 225 ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection...");
226
227 while (1) {
228 vTaskDelay(pdMS_TO_TICKS(1000));
229 session_tick();
230 }
221} 231}
diff --git a/tests/phase2.mjs b/tests/phase2.mjs
new file mode 100644
index 0000000..3136da3
--- /dev/null
+++ b/tests/phase2.mjs
@@ -0,0 +1,101 @@
1import { execSync } from 'child_process';
2
3const IP = process.env.TOLLGATE_IP || '192.168.4.1';
4const API = `http://${IP}:2121`;
5let passed = 0, failed = 0;
6
7function assert(condition, test) {
8 if (condition) { console.log(` ✓ ${test}`); passed++; }
9 else { console.log(` ✗ ${test}`); failed++; }
10}
11
12function curlBody(url, options = {}) {
13 const cmd = options.method
14 ? `curl -s --connect-timeout 5 --max-time 10 -X ${options.method} ${options.data ? `-d '${options.data.replace(/'/g, "'\\''")}'` : ''} "${url}"`
15 : `curl -s --connect-timeout 5 --max-time 10 "${url}"`;
16 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
17 catch { return null; }
18}
19
20function curlStatus(url, options = {}) {
21 const cmd = `curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 ${options.method ? `-X ${options.method}` : ''} ${options.data ? `-d '${options.data.replace(/'/g, "'\\''")}'` : ''} "${url}"`;
22 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }).trim(); }
23 catch { return null; }
24}
25
26async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
27
28console.log(`\n=== Phase 2 Tests (target: ${API}) ===\n`);
29
30// Test 15: Advertisement valid
31console.log('Test 15: GET :2121/ returns kind=10021 advertisement');
32const body15 = curlBody(`${API}/`);
33const json15 = body15 ? JSON.parse(body15) : null;
34assert(json15 && json15.kind === 10021, 'kind=10021');
35assert(json15 && json15.tags && json15.tags.some(t => t[0] === 'price_per_step'), 'Has price_per_step tag');
36assert(json15 && json15.tags && json15.tags.some(t => t[0] === 'step_size'), 'Has step_size tag');
37assert(json15 && json15.tags && json15.tags.some(t => t[0] === 'metric'), 'Has metric tag');
38
39// Test 19: Invalid token
40console.log('\nTest 19: POST :2121/ with invalid token');
41const body19 = curlBody(`${API}/`, { method: 'POST', data: 'garbage_not_a_token' });
42const json19 = body19 ? JSON.parse(body19) : null;
43assert(json19 && json19.kind === 21023, 'Returns kind=21023 notice');
44assert(json19 && json19.tags && json19.tags.some(t => t[0] === 'code'), 'Has error code tag');
45const status19 = curlStatus(`${API}/`, { method: 'POST', data: 'garbage_not_a_token' });
46assert(status19 === '400', 'Returns HTTP 400');
47
48// Test 21: Wrong mint (token from wrong mint)
49console.log('\nTest 21: POST :2121/ with wrong mint token');
50const wrongMintToken = 'cashuA' + Buffer.from(JSON.stringify({
51 token: [{ mint: 'https://wrong.mint.example.com', proofs: [{ amount: 21, secret: 'test', id: '00'.repeat(8), C: '02'.repeat(33) }] }]
52})).toString('base64url');
53const body21 = curlBody(`${API}/`, { method: 'POST', data: wrongMintToken });
54const json21 = body21 ? JSON.parse(body21) : null;
55assert(json21 && json21.kind === 21023, 'Returns kind=21023');
56const codeTag21 = json21 && json21.tags && json21.tags.find(t => t[0] === 'code');
57assert(codeTag21 && codeTag21[1] === 'payment-error-mint-not-accepted', 'Error code: mint-not-accepted');
58
59// Test valid token (if provided)
60const TEST_TOKEN = process.env.TEST_TOKEN;
61if (TEST_TOKEN) {
62 console.log('\nTest 16: POST :2121/ with valid token');
63 const body16 = curlBody(`${API}/`, { method: 'POST', data: TEST_TOKEN });
64 const json16 = body16 ? JSON.parse(body16) : null;
65 assert(json16 && json16.kind === 1022, 'Returns kind=1022 session');
66 assert(json16 && json16.tags && json16.tags.some(t => t[0] === 'allotment'), 'Has allotment tag');
67
68 // Test 17: Usage tracking
69 console.log('\nTest 17: GET :2121/usage after payment');
70 const body17 = curlBody(`${API}/usage`);
71 assert(body17 && !body17.includes('-1/-1'), 'Returns active usage');
72
73 // Test 18: Internet after payment
74 console.log('\nTest 18: Internet works after payment');
75 await sleep(1000);
76 const ping18 = execSync('ping -c 2 -W 2 -I wlp59s0 8.8.8.8', { encoding: 'utf8', timeout: 10000 });
77 assert(ping18 && !ping18.includes('100% packet loss'), 'Internet works');
78
79 // Test 20: Spent token
80 console.log('\nTest 20: Reuse token (should fail)');
81 const body20 = curlBody(`${API}/`, { method: 'POST', data: TEST_TOKEN });
82 const json20 = body20 ? JSON.parse(body20) : null;
83 assert(json20 && json20.kind === 21023, 'Returns kind=21023 for spent token');
84} else {
85 console.log('\n ⚠ Skipping tests 16-20: Set TEST_TOKEN env var with a valid Cashu token');
86}
87
88// Test: whoami on :2121
89console.log('\nTest: GET :2121/whoami');
90const bodyWhoami = curlBody(`${API}/whoami`);
91assert(bodyWhoami && bodyWhoami.startsWith('mac='), '/whoami returns mac=...');
92
93// Test: Portal has payment form
94console.log('\nTest: Portal has payment form');
95const bodyPortal = curlBody(`http://${IP}/`);
96assert(bodyPortal && bodyPortal.includes('cashuA'), 'Portal has Cashu token input');
97assert(bodyPortal && bodyPortal.includes('Pay &amp; Connect') || bodyPortal && bodyPortal.includes('Pay'), 'Portal has Pay button');
98
99// Summary
100console.log(`\n=== Phase 2 Results: ${passed} passed, ${failed} failed ===\n`);
101process.exit(failed > 0 ? 1 : 0);