From 1263d86314fc0760d9be8eea415ccecbc047a5eb Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 15 May 2026 22:27:14 +0530 Subject: 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) --- main/CMakeLists.txt | 3 + main/captive_portal.c | 35 +++-- main/cashu.c | 241 ++++++++++++++++++++++++++++++++++ main/cashu.h | 42 ++++++ main/session.c | 164 +++++++++++++++++++++++ main/session.h | 44 +++++++ main/tollgate_api.c | 355 ++++++++++++++++++++++++++++++++++++++++++++++++++ main/tollgate_api.h | 10 ++ main/tollgate_main.c | 10 ++ 9 files changed, 891 insertions(+), 13 deletions(-) create mode 100644 main/cashu.c create mode 100644 main/cashu.h create mode 100644 main/session.c create mode 100644 main/session.h create mode 100644 main/tollgate_api.c create mode 100644 main/tollgate_api.h (limited to 'main') 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" "dns_server.c" "captive_portal.c" "firewall.c" + "cashu.c" + "session.c" + "tollgate_api.c" INCLUDE_DIRS "." "${IDF_PATH}/components/lwip/include/apps" REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 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[] = \ "font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" ".btn:hover{background:#e8850f}" ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" +"textarea{width:100%;height:80px;background:#252525;border:1px solid #333;border-radius:8px;" +"color:#fff;padding:12px;font-family:monospace;font-size:12px;margin-top:8px;resize:none}" "" "" "
" @@ -47,25 +49,32 @@ static const char PORTAL_HTML[] = \ "
Loading...
" "
sats per minute
" "
" -"" +"" +"" "
" "" "" ""; 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 @@ +#include "cashu.h" +#include "config.h" +#include "esp_log.h" +#include "esp_http_client.h" +#include "cJSON.h" +#include "mbedtls/base64.h" +#include "mbedtls/sha256.h" +#include + +static const char *TAG = "cashu"; + +static const char V3_PREFIX[] = "cashuA"; +static const size_t V3_PREFIX_LEN = 6; + +static int b64url_decode(const char *input, size_t input_len, char *out, size_t out_size, size_t *out_len) +{ + char b64[1024]; + size_t b64_len = input_len; + if (b64_len >= sizeof(b64)) return -1; + memcpy(b64, input, b64_len); + b64[b64_len] = '\0'; + + for (size_t i = 0; i < b64_len; i++) { + if (b64[i] == '-') b64[i] = '+'; + else if (b64[i] == '_') b64[i] = '/'; + } + while (b64_len % 4 != 0 && b64_len < sizeof(b64) - 1) { + b64[b64_len++] = '='; + } + b64[b64_len] = '\0'; + + size_t olen = 0; + int ret = mbedtls_base64_decode((unsigned char *)out, out_size, &olen, + (const unsigned char *)b64, b64_len); + if (ret != 0) return -1; + *out_len = olen; + return 0; +} + +static esp_err_t parse_proofs_array(cJSON *arr, cashu_token_t *out) +{ + if (!cJSON_IsArray(arr)) return ESP_FAIL; + int count = cJSON_GetArraySize(arr); + if (count > CASHU_MAX_PROOFS) return ESP_FAIL; + + out->proof_count = 0; + out->total_amount = 0; + for (int i = 0; i < count; i++) { + cJSON *proof = cJSON_GetArrayItem(arr, i); + cJSON *amt = cJSON_GetObjectItemCaseSensitive(proof, "amount"); + cJSON *id = cJSON_GetObjectItemCaseSensitive(proof, "id"); + cJSON *secret = cJSON_GetObjectItemCaseSensitive(proof, "secret"); + cJSON *c = cJSON_GetObjectItemCaseSensitive(proof, "C"); + + if (!amt || !cJSON_IsNumber(amt)) return ESP_FAIL; + + out->proofs[i].amount = (uint64_t)amt->valuedouble; + out->total_amount += out->proofs[i].amount; + + if (id && cJSON_IsString(id)) { + strncpy(out->proofs[i].id, id->valuestring, sizeof(out->proofs[i].id) - 1); + } + if (secret && cJSON_IsString(secret)) { + strncpy(out->proofs[i].secret, secret->valuestring, sizeof(out->proofs[i].secret) - 1); + } + if (c && cJSON_IsString(c)) { + strncpy(out->proofs[i].c, c->valuestring, sizeof(out->proofs[i].c) - 1); + } + out->proof_count++; + } + return ESP_OK; +} + +esp_err_t cashu_decode_token(const char *token_str, cashu_token_t *out) +{ + if (!token_str || !out) return ESP_FAIL; + memset(out, 0, sizeof(*out)); + + size_t len = strlen(token_str); + if (len <= V3_PREFIX_LEN) { + ESP_LOGE(TAG, "Token too short"); + return ESP_FAIL; + } + if (strncmp(token_str, V3_PREFIX, V3_PREFIX_LEN) != 0) { + ESP_LOGE(TAG, "Token missing cashuA prefix"); + return ESP_FAIL; + } + + char json_buf[2048]; + size_t json_len = 0; + if (b64url_decode(token_str + V3_PREFIX_LEN, len - V3_PREFIX_LEN, + json_buf, sizeof(json_buf) - 1, &json_len) != 0) { + ESP_LOGE(TAG, "Base64url decode failed"); + return ESP_FAIL; + } + json_buf[json_len] = '\0'; + + cJSON *root = cJSON_Parse(json_buf); + if (!root) { + ESP_LOGE(TAG, "JSON parse failed"); + return ESP_FAIL; + } + + cJSON *token_arr = cJSON_GetObjectItemCaseSensitive(root, "token"); + if (token_arr && cJSON_IsArray(token_arr)) { + cJSON *first = cJSON_GetArrayItem(token_arr, 0); + if (!first) { cJSON_Delete(root); return ESP_FAIL; } + + cJSON *mint = cJSON_GetObjectItemCaseSensitive(first, "mint"); + if (mint && cJSON_IsString(mint)) { + strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1); + } + + cJSON *proofs = cJSON_GetObjectItemCaseSensitive(first, "proofs"); + if (proofs) { + esp_err_t ret = parse_proofs_array(proofs, out); + if (ret != ESP_OK) { cJSON_Delete(root); return ret; } + } + } else { + cJSON *mint = cJSON_GetObjectItemCaseSensitive(root, "mint"); + if (mint && cJSON_IsString(mint)) { + strncpy(out->mint_url, mint->valuestring, sizeof(out->mint_url) - 1); + } + + cJSON *proofs = cJSON_GetObjectItemCaseSensitive(root, "proofs"); + if (proofs) { + esp_err_t ret = parse_proofs_array(proofs, out); + if (ret != ESP_OK) { cJSON_Delete(root); return ret; } + } + } + + cJSON_Delete(root); + + if (out->proof_count == 0) { + ESP_LOGE(TAG, "No proofs in token"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Decoded token: %d proofs, total=%llu, mint=%s", + out->proof_count, (unsigned long long)out->total_amount, out->mint_url); + return ESP_OK; +} + +static void sha256_hex(const char *data, size_t data_len, char *hex_out) +{ + uint8_t hash[32]; + mbedtls_sha256((const unsigned char *)data, data_len, hash, 0); + for (int i = 0; i < 32; i++) { + sprintf(hex_out + i * 2, "%02x", hash[i]); + } + hex_out[64] = '\0'; +} + +esp_err_t cashu_check_proof_states(const char *mint_url, const cashu_token_t *token, + cashu_proof_state_t *states, int *state_count) +{ + cJSON *ys_arr = cJSON_CreateArray(); + for (int i = 0; i < token->proof_count; i++) { + char y_hex[65]; + sha256_hex(token->proofs[i].secret, strlen(token->proofs[i].secret), y_hex); + cJSON_AddItemToArray(ys_arr, cJSON_CreateString(y_hex)); + strncpy(states[i].y_hex, y_hex, sizeof(states[i].y_hex) - 1); + states[i].spent = false; + } + *state_count = token->proof_count; + + char *ys_json = cJSON_PrintUnformatted(ys_arr); + cJSON_Delete(ys_arr); + + char post_body[2048]; + snprintf(post_body, sizeof(post_body), "{\"Ys\":%s}", ys_json); + cJSON_free(ys_json); + + char url[512]; + snprintf(url, sizeof(url), "%s/v1/checkstate", mint_url); + + char resp_buf[4096]; + int resp_len = 0; + + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_POST, + .timeout_ms = 10000, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return ESP_FAIL; + + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_err_t err = esp_http_client_open(client, strlen(post_body)); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + return err; + } + esp_http_client_write(client, post_body, strlen(post_body)); + + resp_len = esp_http_client_read(client, resp_buf, sizeof(resp_buf) - 1); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (status != 200 || resp_len <= 0) { + ESP_LOGE(TAG, "checkstate returned %d", status); + return ESP_FAIL; + } + resp_buf[resp_len] = '\0'; + + cJSON *root = cJSON_Parse(resp_buf); + if (!root) return ESP_FAIL; + + cJSON *states_arr = cJSON_GetObjectItemCaseSensitive(root, "states"); + if (!states_arr || !cJSON_IsArray(states_arr)) { + cJSON_Delete(root); + return ESP_FAIL; + } + + int n = cJSON_GetArraySize(states_arr); + for (int i = 0; i < n && i < token->proof_count; i++) { + cJSON *s = cJSON_GetArrayItem(states_arr, i); + cJSON *state = cJSON_GetObjectItemCaseSensitive(s, "state"); + if (state && cJSON_IsString(state)) { + states[i].spent = (strcmp(state->valuestring, "SPENT") == 0); + } + } + + cJSON_Delete(root); + return ESP_OK; +} + +uint64_t cashu_calculate_allotment_ms(uint64_t token_amount, uint64_t price_per_step, + uint64_t step_size_ms) +{ + if (price_per_step == 0) return 0; + return (token_amount / price_per_step) * step_size_ms; +} + +bool cashu_is_mint_accepted(const char *mint_url) +{ + if (!mint_url || mint_url[0] == '\0') return false; + const tollgate_config_t *cfg = tollgate_config_get(); + if (strstr(mint_url, cfg->mint_url) != NULL) return true; + return (strcmp(mint_url, cfg->mint_url) == 0); +} 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 @@ +#ifndef CASHU_H +#define CASHU_H + +#include "esp_err.h" +#include +#include + +#define CASHU_MAX_PROOFS 10 +#define CASHU_MAX_SECRET_LEN 128 +#define CASHU_MAX_ID_LEN 16 +#define CASHU_MAX_C_LEN 128 + +typedef struct { + uint64_t amount; + char id[CASHU_MAX_ID_LEN]; + char secret[CASHU_MAX_SECRET_LEN]; + char c[CASHU_MAX_C_LEN]; +} cashu_proof_t; + +typedef struct { + cashu_proof_t proofs[CASHU_MAX_PROOFS]; + int proof_count; + char mint_url[256]; + uint64_t total_amount; +} cashu_token_t; + +typedef struct { + char y_hex[65]; + bool spent; +} cashu_proof_state_t; + +esp_err_t cashu_decode_token(const char *token_str, cashu_token_t *out); + +esp_err_t cashu_check_proof_states(const char *mint_url, const cashu_token_t *token, + cashu_proof_state_t *states, int *state_count); + +uint64_t cashu_calculate_allotment_ms(uint64_t token_amount, uint64_t price_per_step, + uint64_t step_size_ms); + +bool cashu_is_mint_accepted(const char *mint_url); + +#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 @@ +#include "session.h" +#include "firewall.h" +#include "dns_server.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include + +#define SPENT_SECRETS_MAX 100 + +static const char *TAG = "session"; +static session_t s_sessions[SESSION_MAX_CLIENTS]; +static int s_session_count = 0; + +static char s_spent_secrets[SPENT_SECRETS_MAX][65]; +static int s_spent_count = 0; + +static int64_t get_time_ms(void) +{ + return (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; +} + +esp_err_t session_manager_init(void) +{ + memset(s_sessions, 0, sizeof(s_sessions)); + s_session_count = 0; + s_spent_count = 0; + ESP_LOGI(TAG, "Session manager initialized"); + return ESP_OK; +} + +session_t *session_create(uint32_t client_ip, uint64_t allotment_ms, + const char *spent_secrets[], int secret_count) +{ + session_t *existing = session_find_by_ip(client_ip); + if (existing) { + session_extend(existing, allotment_ms); + for (int i = 0; i < secret_count && s_spent_count < SPENT_SECRETS_MAX; i++) { + strncpy(s_spent_secrets[s_spent_count], spent_secrets[i], 64); + s_spent_secrets[s_spent_count][64] = '\0'; + s_spent_count++; + } + return existing; + } + + if (s_session_count >= SESSION_MAX_CLIENTS) { + for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { + if (!s_sessions[i].active || session_is_expired(&s_sessions[i])) { + session_revoke(&s_sessions[i]); + break; + } + } + } + + for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { + if (!s_sessions[i].active) { + s_sessions[i].client_ip = client_ip; + s_sessions[i].allotment_ms = allotment_ms; + s_sessions[i].start_time_ms = get_time_ms(); + s_sessions[i].active = true; + s_sessions[i].spent_secret_count = 0; + + for (int j = 0; j < secret_count && j < 5; j++) { + strncpy(s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count], + spent_secrets[j], 64); + s_sessions[i].spent_secrets[s_sessions[i].spent_secret_count][64] = '\0'; + s_sessions[i].spent_secret_count++; + } + + for (int j = 0; j < secret_count && s_spent_count < SPENT_SECRETS_MAX; j++) { + strncpy(s_spent_secrets[s_spent_count], spent_secrets[j], 64); + s_spent_secrets[s_spent_count][64] = '\0'; + s_spent_count++; + } + + s_session_count++; + firewall_grant_access(client_ip); + + esp_ip4_addr_t ip = { .addr = client_ip }; + ESP_LOGI(TAG, "Session created: " IPSTR " allotment=%llums", IP2STR(&ip), + (unsigned long long)allotment_ms); + return &s_sessions[i]; + } + } + + ESP_LOGW(TAG, "No free session slots"); + return NULL; +} + +session_t *session_find_by_ip(uint32_t client_ip) +{ + for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { + if (s_sessions[i].active && s_sessions[i].client_ip == client_ip) { + return &s_sessions[i]; + } + } + return NULL; +} + +void session_extend(session_t *session, uint64_t additional_ms) +{ + if (!session || !session->active) return; + session->allotment_ms += additional_ms; + esp_ip4_addr_t ip = { .addr = session->client_ip }; + ESP_LOGI(TAG, "Session extended: " IPSTR " +%llums (total=%llu)", IP2STR(&ip), + (unsigned long long)additional_ms, (unsigned long long)session->allotment_ms); +} + +bool session_is_expired(const session_t *session) +{ + if (!session || !session->active) return true; + int64_t elapsed = get_time_ms() - session->start_time_ms; + return elapsed >= (int64_t)session->allotment_ms; +} + +bool session_is_secret_spent(const char *secret) +{ + for (int i = 0; i < s_spent_count; i++) { + if (strncmp(s_spent_secrets[i], secret, 64) == 0) return true; + } + return false; +} + +void session_check_expiry(void) +{ + for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { + if (s_sessions[i].active && session_is_expired(&s_sessions[i])) { + esp_ip4_addr_t ip = { .addr = s_sessions[i].client_ip }; + ESP_LOGI(TAG, "Session expired: " IPSTR, IP2STR(&ip)); + session_revoke(&s_sessions[i]); + } + } +} + +void session_revoke(session_t *session) +{ + if (!session || !session->active) return; + firewall_revoke_access(session->client_ip); + session->active = false; + s_session_count--; +} + +void session_revoke_all(void) +{ + for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { + if (s_sessions[i].active) { + session_revoke(&s_sessions[i]); + } + } +} + +int session_active_count(void) +{ + int count = 0; + for (int i = 0; i < SESSION_MAX_CLIENTS; i++) { + if (s_sessions[i].active) count++; + } + return count; +} + +void session_tick(void) +{ + session_check_expiry(); +} 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 @@ +#ifndef SESSION_H +#define SESSION_H + +#include "esp_err.h" +#include +#include + +#define SESSION_MAX_CLIENTS 10 +#define SESSION_MAX_MAC_LEN 18 + +typedef struct { + uint32_t client_ip; + char mac[SESSION_MAX_MAC_LEN]; + uint64_t allotment_ms; + int64_t start_time_ms; + bool active; + char spent_secrets[5][65]; + int spent_secret_count; +} session_t; + +esp_err_t session_manager_init(void); + +session_t *session_create(uint32_t client_ip, uint64_t allotment_ms, + const char *spent_secrets[], int secret_count); + +session_t *session_find_by_ip(uint32_t client_ip); + +void session_extend(session_t *session, uint64_t additional_ms); + +bool session_is_expired(const session_t *session); + +bool session_is_secret_spent(const char *secret); + +void session_check_expiry(void); + +void session_revoke(session_t *session); + +void session_revoke_all(void); + +int session_active_count(void); + +void session_tick(void); + +#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 @@ +#include "tollgate_api.h" +#include "cashu.h" +#include "config.h" +#include "session.h" +#include "esp_log.h" +#include "cJSON.h" +#include "lwip/sockets.h" +#include "lwip/netdb.h" +#include + +static const char *TAG = "tollgate_api"; +static httpd_handle_t s_api_server = NULL; + +static const char *TOLLGATE_PUBKEY = "0000000000000000000000000000000000000000000000000000000000000000"; + +static esp_err_t get_client_ip(httpd_req_t *req, uint32_t *ip_out) +{ + int sockfd = httpd_req_to_sockfd(req); + struct sockaddr_in addr; + socklen_t addr_len = sizeof(addr); + if (getpeername(sockfd, (struct sockaddr *)&addr, &addr_len) == 0) { + *ip_out = addr.sin_addr.s_addr; + return ESP_OK; + } + return ESP_FAIL; +} + +static cJSON *create_notice(const char *level, const char *code, const char *content) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "kind", 21023); + cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY); + cJSON *tags = cJSON_CreateArray(); + cJSON *level_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(level_tag, cJSON_CreateString("level")); + cJSON_AddItemToArray(level_tag, cJSON_CreateString(level)); + cJSON_AddItemToArray(tags, level_tag); + cJSON *code_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(code_tag, cJSON_CreateString("code")); + cJSON_AddItemToArray(code_tag, cJSON_CreateString(code)); + cJSON_AddItemToArray(tags, code_tag); + cJSON_AddItemToObject(root, "tags", tags); + cJSON_AddStringToObject(root, "content", content); + return root; +} + +static cJSON *create_session_event(uint32_t client_ip, uint64_t allotment_ms) +{ + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "kind", 1022); + cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY); + + cJSON *tags = cJSON_CreateArray(); + + cJSON *p_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(p_tag, cJSON_CreateString("p")); + cJSON_AddItemToArray(p_tag, cJSON_CreateString("unknown")); + cJSON_AddItemToArray(tags, p_tag); + + esp_ip4_addr_t ip = { .addr = client_ip }; + char ip_str[16]; + snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip)); + cJSON *dev_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(dev_tag, cJSON_CreateString("device-identifier")); + cJSON_AddItemToArray(dev_tag, cJSON_CreateString("mac")); + cJSON_AddItemToArray(dev_tag, cJSON_CreateString(ip_str)); + cJSON_AddItemToArray(tags, dev_tag); + + cJSON *allotment_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(allotment_tag, cJSON_CreateString("allotment")); + char allotment_str[32]; + snprintf(allotment_str, sizeof(allotment_str), "%llu", (unsigned long long)allotment_ms); + cJSON_AddItemToArray(allotment_tag, cJSON_CreateString(allotment_str)); + cJSON_AddItemToArray(tags, allotment_tag); + + cJSON *metric_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(metric_tag, cJSON_CreateString("metric")); + cJSON_AddItemToArray(metric_tag, cJSON_CreateString("milliseconds")); + cJSON_AddItemToArray(tags, metric_tag); + + cJSON_AddItemToObject(root, "tags", tags); + cJSON_AddStringToObject(root, "content", ""); + return root; +} + +static esp_err_t api_get_discovery(httpd_req_t *req) +{ + const tollgate_config_t *cfg = tollgate_config_get(); + + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "kind", 10021); + cJSON_AddStringToObject(root, "pubkey", TOLLGATE_PUBKEY); + + cJSON *tags = cJSON_CreateArray(); + + cJSON *metric_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(metric_tag, cJSON_CreateString("metric")); + cJSON_AddItemToArray(metric_tag, cJSON_CreateString("milliseconds")); + cJSON_AddItemToArray(tags, metric_tag); + + cJSON *step_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(step_tag, cJSON_CreateString("step_size")); + char step_str[32]; + snprintf(step_str, sizeof(step_str), "%d", cfg->step_size_ms); + cJSON_AddItemToArray(step_tag, cJSON_CreateString(step_str)); + cJSON_AddItemToArray(tags, step_tag); + + cJSON *price_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("price_per_step")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("cashu")); + char price_str[32]; + snprintf(price_str, sizeof(price_str), "%d", cfg->price_per_step); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(price_str)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("sat")); + cJSON_AddItemToArray(price_tag, cJSON_CreateString(cfg->mint_url)); + cJSON_AddItemToArray(price_tag, cJSON_CreateString("1")); + cJSON_AddItemToArray(tags, price_tag); + + cJSON *tips_tag = cJSON_CreateArray(); + cJSON_AddItemToArray(tips_tag, cJSON_CreateString("tips")); + cJSON_AddItemToArray(tips_tag, cJSON_CreateString("1")); + cJSON_AddItemToArray(tips_tag, cJSON_CreateString("2")); + cJSON_AddItemToArray(tips_tag, cJSON_CreateString("5")); + cJSON_AddItemToArray(tags, tips_tag); + + cJSON_AddItemToObject(root, "tags", tags); + cJSON_AddStringToObject(root, "content", ""); + + char *json = cJSON_PrintUnformatted(root); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(root); + return ESP_OK; +} + +static esp_err_t api_post_payment(httpd_req_t *req) +{ + uint32_t client_ip = 0; + get_client_ip(req, &client_ip); + + int content_len = req->content_len; + if (content_len <= 0 || content_len > 16384) { + cJSON *notice = create_notice("error", "payment-error-invalid", "Invalid request body"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + + char *body = malloc(content_len + 1); + if (!body) { + cJSON *notice = create_notice("error", "session-error", "Out of memory"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + int received = httpd_req_recv(req, body, content_len); + if (received <= 0) { + free(body); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, "bad request", 11); + return ESP_OK; + } + body[received] = '\0'; + + ESP_LOGI(TAG, "Payment received: %d bytes", received); + + cashu_token_t token; + esp_err_t err = cashu_decode_token(body, &token); + free(body); + + if (err != ESP_OK) { + cJSON *notice = create_notice("error", "payment-error-invalid", "Failed to decode Cashu token"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + + const char *mint_url = token.mint_url[0] ? token.mint_url : tollgate_config_get()->mint_url; + if (!cashu_is_mint_accepted(mint_url)) { + cJSON *notice = create_notice("error", "payment-error-mint-not-accepted", "Mint not accepted"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "402 Payment Required"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + + for (int i = 0; i < token.proof_count; i++) { + if (session_is_secret_spent(token.proofs[i].secret)) { + cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "402 Payment Required"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + } + + cashu_proof_state_t states[CASHU_MAX_PROOFS]; + int state_count = 0; + err = cashu_check_proof_states(mint_url, &token, states, &state_count); + if (err != ESP_OK) { + cJSON *notice = create_notice("error", "payment-error-verification", "Failed to verify token with mint"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "502 Bad Gateway"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + + for (int i = 0; i < state_count; i++) { + if (states[i].spent) { + cJSON *notice = create_notice("error", "payment-error-token-spent", "Token already spent"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "402 Payment Required"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + } + + const tollgate_config_t *cfg = tollgate_config_get(); + uint64_t allotment = cashu_calculate_allotment_ms(token.total_amount, cfg->price_per_step, cfg->step_size_ms); + if (allotment == 0) { + cJSON *notice = create_notice("error", "payment-error-insufficient", "Token value too low"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "402 Payment Required"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + + const char *secrets[5]; + for (int i = 0; i < token.proof_count && i < 5; i++) { + secrets[i] = token.proofs[i].secret; + } + session_t *session = session_create(client_ip, allotment, secrets, token.proof_count); + if (!session) { + cJSON *notice = create_notice("error", "session-error", "Failed to create session"); + char *json = cJSON_PrintUnformatted(notice); + httpd_resp_set_status(req, "503 Service Unavailable"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(notice); + return ESP_OK; + } + + cJSON *session_event = create_session_event(client_ip, allotment); + char *json = cJSON_PrintUnformatted(session_event); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json, strlen(json)); + cJSON_free(json); + cJSON_Delete(session_event); + return ESP_OK; +} + +static esp_err_t api_get_usage(httpd_req_t *req) +{ + uint32_t client_ip = 0; + get_client_ip(req, &client_ip); + + session_t *session = session_find_by_ip(client_ip); + if (!session || !session->active) { + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, "-1/-1", 5); + return ESP_OK; + } + + int64_t elapsed = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS - session->start_time_ms; + int64_t remaining = session->allotment_ms - elapsed; + if (remaining < 0) remaining = 0; + + char resp[64]; + snprintf(resp, sizeof(resp), "%lld/%llu", (long long)remaining, (unsigned long long)session->allotment_ms); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; +} + +static esp_err_t api_get_whoami(httpd_req_t *req) +{ + uint32_t client_ip = 0; + char resp[64]; + if (get_client_ip(req, &client_ip) == ESP_OK) { + esp_ip4_addr_t ip = { .addr = client_ip }; + snprintf(resp, sizeof(resp), "mac=" IPSTR, IP2STR(&ip)); + } else { + snprintf(resp, sizeof(resp), "mac=unknown"); + } + httpd_resp_set_type(req, "text/plain"); + httpd_resp_send(req, resp, strlen(resp)); + return ESP_OK; +} + +static const httpd_uri_t uri_discovery = { .uri = "/", .method = HTTP_GET, .handler = api_get_discovery }; +static const httpd_uri_t uri_payment = { .uri = "/", .method = HTTP_POST, .handler = api_post_payment }; +static const httpd_uri_t uri_usage = { .uri = "/usage", .method = HTTP_GET, .handler = api_get_usage }; +static const httpd_uri_t uri_whoami = { .uri = "/whoami", .method = HTTP_GET, .handler = api_get_whoami }; + +esp_err_t tollgate_api_start(void) +{ + if (s_api_server) return ESP_OK; + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = 2121; + config.ctrl_port = 32769; + config.max_uri_handlers = 10; + + esp_err_t ret = httpd_start(&s_api_server, &config); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to start API server: %s", esp_err_to_name(ret)); + return ret; + } + + httpd_register_uri_handler(s_api_server, &uri_discovery); + httpd_register_uri_handler(s_api_server, &uri_payment); + httpd_register_uri_handler(s_api_server, &uri_usage); + httpd_register_uri_handler(s_api_server, &uri_whoami); + + ESP_LOGI(TAG, "TollGate API started on port 2121"); + return ESP_OK; +} + +void tollgate_api_stop(void) +{ + if (s_api_server) { + httpd_stop(s_api_server); + s_api_server = NULL; + } +} 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 @@ +#ifndef TOLLGATE_API_H +#define TOLLGATE_API_H + +#include "esp_err.h" +#include "esp_http_server.h" + +esp_err_t tollgate_api_start(void); +void tollgate_api_stop(void); + +#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 @@ #include "dns_server.h" #include "captive_portal.h" #include "firewall.h" +#include "session.h" +#include "tollgate_api.h" #define MAX_STA_RETRY 5 #define AP_IP_ADDR "192.168.4.1" @@ -105,9 +107,11 @@ static void start_services(void) upstream_dns.addr = dns_addr->addr; firewall_init(ap_ip_info.ip); + session_manager_init(); dns_server_start(ap_ip_info.ip, upstream_dns); captive_portal_start(); + tollgate_api_start(); s_services_running = true; if (s_services_mutex) xSemaphoreGive(s_services_mutex); @@ -123,6 +127,7 @@ static void stop_services(void) } captive_portal_stop(); + tollgate_api_stop(); dns_server_stop(); firewall_disable_nat(); firewall_revoke_all(); @@ -218,4 +223,9 @@ void app_main(void) ESP_ERROR_CHECK(esp_wifi_start()); ESP_LOGI(TAG, "WiFi AP+STA started, waiting for connection..."); + + while (1) { + vTaskDelay(pdMS_TO_TICKS(1000)); + session_tick(); + } } -- cgit v1.2.3