upleb.uk

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

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-15 22:27:14 +0530
committerYour Name <you@example.com>2026-05-15 22:27:14 +0530
commit1263d86314fc0760d9be8eea415ccecbc047a5eb (patch)
tree778130f0beb59d52f68e0e5f11388bf4b1470130 /main
parenta7d0a672d59bf8985a6fc0e61b49015fabd96513 (diff)
Phase 2 WIP: Cashu payment endpoints, session tracking, updated checklist
- Add cashu.c/h: Cashu token decode (cashuA/base64url), proof state check via mint API, allotment calculator - Add session.c/h: time-based session management with allotment/expiry, spent secret tracking - Add tollgate_api.c/h: HTTP server on :2121 with GET / (kind=10021 discovery), POST / (payment processing), /usage, /whoami - Update captive portal HTML: replace Grant Free Access with Cashu token paste form + Pay & Connect button - Update tollgate_main.c: wire in session manager, TollGate API, 1s session tick loop - Add tests/phase2.mjs: Phase 2 test suite (discovery, invalid token, wrong mint, valid payment) - Update CHECKLIST.md: reflect Phase 1 complete, Phase 2 in progress with known bugs Known issues (not yet flashed): - Stack overflow crash in httpd POST handler (need stack_size=16384 + heap allocations) - cashu_decode_token uses 2KB stack buffer (needs heap alloc) - Mint URL should be testnut.cashu.space (nofee.testnut has API compat issues)
Diffstat (limited to 'main')
-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
9 files changed, 891 insertions, 13 deletions
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}