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) --- CHECKLIST.md | 89 ++++++++----- 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 ++ tests/phase2.mjs | 101 ++++++++++++++ 11 files changed, 1045 insertions(+), 49 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 create mode 100644 tests/phase2.mjs diff --git a/CHECKLIST.md b/CHECKLIST.md index e5f99a9..fe5894f 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -4,52 +4,69 @@ - [x] Create project directory and git repo - [x] Create .env, .env.example, .gitignore - [x] Persist PLAN.md and CHECKLIST.md -- [ ] Create ESP-IDF project skeleton -- [ ] Create Makefile with detect targets -- [ ] Run `make detect-all` — identify ESP32 boards +- [x] Create ESP-IDF project skeleton (CMakeLists, partitions.csv, sdkconfig.defaults) +- [x] Create Makefile with detect/build/flash/test targets +- [x] Run `make detect-all` — identified both boards as ESP32-S3 (16MB flash) +- [x] Fix ESP-IDF v5.4.1 installation (was deeply corrupted, re-cloned) -## Phase 1: Captive Portal + Firewall -- [ ] Implement tollgate_main.c (WiFi AP+STA, event loop) -- [ ] Implement config.c/h (JSON config loading) -- [ ] Implement dns_server.c/h (DNS hijack/forward) -- [ ] Implement captive_portal.c/h (HTTP :80, portal HTML) -- [ ] Implement firewall.c/h (NAPT, per-IP auth) -- [ ] Set up test infrastructure (Playwright, helpers) -- [ ] Test 1: Boot and AP appears -- [ ] Test 2: DHCP lease -- [ ] Test 3: Captive portal serves HTML -- [ ] Test 4: Captive detection URIs work -- [ ] Test 5: DNS hijack before auth -- [ ] Test 6: No internet before auth -- [ ] Test 7: /whoami returns MAC -- [ ] Test 8: /usage returns no session -- [ ] Test 9: Grant access via API -- [ ] Test 10: DNS forward after auth -- [ ] Test 11: Internet after auth -- [ ] Test 12: HTTP browsing works -- [ ] Test 13: Reset auth -- [ ] Test 14: Internet blocked after reset +## Phase 1: Captive Portal + Firewall — COMPLETE +- [x] Implement tollgate_main.c (WiFi AP+STA, event loop) +- [x] Implement config.c/h (SPIFFS JSON config loading) +- [x] Implement dns_server.c/h (DNS hijack/forward per-client) +- [x] Implement captive_portal.c/h (HTTP :80, portal HTML) +- [x] Implement firewall.c/h (NAPT on/off per auth state) +- [x] Set up test infrastructure (Node.js tests, helpers, Playwright) +- [x] Fix WiFi init order bug (netif before esp_wifi_init, set_mode before set_config) +- [x] Fix DNS hijack test (nslookup exits 1 for AAAA, catch stderr) +- [x] Fix ping tests (use `-I wlp59s0` to force through TollGate AP) +- [x] Test 1: Boot and AP appears +- [x] Test 2: DHCP lease +- [x] Test 3: Captive portal serves HTML +- [x] Test 4: Captive detection URIs work (8 URIs) +- [x] Test 5: DNS hijack before auth +- [x] Test 6: No internet before auth +- [x] Test 7: /whoami returns MAC +- [x] Test 8: /usage returns no session +- [x] Test 9: Grant access via API +- [x] Test 10: DNS forward after auth +- [x] Test 11: Internet after auth +- [x] Test 12: HTTP browsing works +- [x] Test 13: Reset auth +- [x] Test 14: Internet blocked after reset +- [x] **All 20 API tests pass, all 6 smoke tests pass** +- [x] Committed: `a7d0a67` -## Phase 2: E-Cash Payments (Simple Melt) -- [ ] Implement payment.c/h (Cashu token parse + melt) -- [ ] Implement session.c/h (time-based metering) -- [ ] Implement tollgate_api.c/h (:2121 endpoints) -- [ ] Update captive portal HTML with payment form -- [ ] Test 15: Advertisement valid -- [ ] Test 16: Valid payment -- [ ] Test 17: Usage tracking +## Phase 2: E-Cash Payments — IN PROGRESS (code written, bugs to fix) +- [x] Implement cashu.c/h (Cashu token parse, base64url, checkstate, mint validation) +- [x] Implement session.c/h (time-based allotment, expiry, secret tracking) +- [x] Implement tollgate_api.c/h (:2121 server, GET/POST /, /usage, /whoami) +- [x] Update captive portal HTML with payment form (token textarea, Pay & Connect button) +- [x] Wire into tollgate_main.c (session_init, api_start, session_tick loop) +- [x] Test 15: Advertisement valid (kind=10021 with price_per_step) — PASSING +- [ ] **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 +- [ ] **BUG FIX: cashu_decode_token has 2048B stack buffer** — move json_buf to heap +- [ ] **BUG FIX: cashu_check_proof_states has 4096B stack buffer** — move resp_buf to heap +- [ ] Test 16: Valid payment (needs valid Cashu token from nutshell) +- [ ] Test 17: Usage tracking after payment - [ ] Test 18: Internet after payment -- [ ] Test 19: Invalid token rejected +- [ ] Test 19: Invalid token rejected — blocked by stack overflow crash - [ ] Test 20: Spent token rejected -- [ ] Test 21: Wrong mint rejected +- [ ] Test 21: Wrong mint rejected — blocked by stack overflow crash - [ ] Test 22: Session expiry - [ ] Test 23: Session renewal -- [ ] Test 24: Portal payment form +- [ ] Test 24: Portal payment form — blocked by stack overflow crash - [ ] Test 25: Two clients pay independently - [ ] Test 26: Client isolation - [ ] Test 27: Full e2e browser flow -## Phase 3: nucula Wallet + Reseller +## Infrastructure Setup — TODO (before next hardware session) +- [ ] Update .env: change mint from nofee.testnut.cashu.space → testnut.cashu.space +- [ ] Update Makefile: add nutshell wallet targets (mint-token, send-token, balance) +- [ ] Create Ansible playbook for full dev environment setup +- [ ] Create NetworkManager profile for TollGate testing (ethernet=upstream, wifi=tollgate only) +- [ ] Verify network routing works (ethernet default route, WiFi 192.168.4.0/24 only) + +## Phase 3: nucula Wallet + Reseller — NOT STARTED - [ ] Extract nucula wallet into components/cashu_wallet/ - [ ] Replace simple melt with Wallet::receive() - [ ] 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" "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(); + } } 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 @@ +import { execSync } from 'child_process'; + +const IP = process.env.TOLLGATE_IP || '192.168.4.1'; +const API = `http://${IP}:2121`; +let passed = 0, failed = 0; + +function assert(condition, test) { + if (condition) { console.log(` ✓ ${test}`); passed++; } + else { console.log(` ✗ ${test}`); failed++; } +} + +function curlBody(url, options = {}) { + const cmd = options.method + ? `curl -s --connect-timeout 5 --max-time 10 -X ${options.method} ${options.data ? `-d '${options.data.replace(/'/g, "'\\''")}'` : ''} "${url}"` + : `curl -s --connect-timeout 5 --max-time 10 "${url}"`; + try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } + catch { return null; } +} + +function curlStatus(url, options = {}) { + 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}"`; + try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }).trim(); } + catch { return null; } +} + +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +console.log(`\n=== Phase 2 Tests (target: ${API}) ===\n`); + +// Test 15: Advertisement valid +console.log('Test 15: GET :2121/ returns kind=10021 advertisement'); +const body15 = curlBody(`${API}/`); +const json15 = body15 ? JSON.parse(body15) : null; +assert(json15 && json15.kind === 10021, 'kind=10021'); +assert(json15 && json15.tags && json15.tags.some(t => t[0] === 'price_per_step'), 'Has price_per_step tag'); +assert(json15 && json15.tags && json15.tags.some(t => t[0] === 'step_size'), 'Has step_size tag'); +assert(json15 && json15.tags && json15.tags.some(t => t[0] === 'metric'), 'Has metric tag'); + +// Test 19: Invalid token +console.log('\nTest 19: POST :2121/ with invalid token'); +const body19 = curlBody(`${API}/`, { method: 'POST', data: 'garbage_not_a_token' }); +const json19 = body19 ? JSON.parse(body19) : null; +assert(json19 && json19.kind === 21023, 'Returns kind=21023 notice'); +assert(json19 && json19.tags && json19.tags.some(t => t[0] === 'code'), 'Has error code tag'); +const status19 = curlStatus(`${API}/`, { method: 'POST', data: 'garbage_not_a_token' }); +assert(status19 === '400', 'Returns HTTP 400'); + +// Test 21: Wrong mint (token from wrong mint) +console.log('\nTest 21: POST :2121/ with wrong mint token'); +const wrongMintToken = 'cashuA' + Buffer.from(JSON.stringify({ + token: [{ mint: 'https://wrong.mint.example.com', proofs: [{ amount: 21, secret: 'test', id: '00'.repeat(8), C: '02'.repeat(33) }] }] +})).toString('base64url'); +const body21 = curlBody(`${API}/`, { method: 'POST', data: wrongMintToken }); +const json21 = body21 ? JSON.parse(body21) : null; +assert(json21 && json21.kind === 21023, 'Returns kind=21023'); +const codeTag21 = json21 && json21.tags && json21.tags.find(t => t[0] === 'code'); +assert(codeTag21 && codeTag21[1] === 'payment-error-mint-not-accepted', 'Error code: mint-not-accepted'); + +// Test valid token (if provided) +const TEST_TOKEN = process.env.TEST_TOKEN; +if (TEST_TOKEN) { + console.log('\nTest 16: POST :2121/ with valid token'); + const body16 = curlBody(`${API}/`, { method: 'POST', data: TEST_TOKEN }); + const json16 = body16 ? JSON.parse(body16) : null; + assert(json16 && json16.kind === 1022, 'Returns kind=1022 session'); + assert(json16 && json16.tags && json16.tags.some(t => t[0] === 'allotment'), 'Has allotment tag'); + + // Test 17: Usage tracking + console.log('\nTest 17: GET :2121/usage after payment'); + const body17 = curlBody(`${API}/usage`); + assert(body17 && !body17.includes('-1/-1'), 'Returns active usage'); + + // Test 18: Internet after payment + console.log('\nTest 18: Internet works after payment'); + await sleep(1000); + const ping18 = execSync('ping -c 2 -W 2 -I wlp59s0 8.8.8.8', { encoding: 'utf8', timeout: 10000 }); + assert(ping18 && !ping18.includes('100% packet loss'), 'Internet works'); + + // Test 20: Spent token + console.log('\nTest 20: Reuse token (should fail)'); + const body20 = curlBody(`${API}/`, { method: 'POST', data: TEST_TOKEN }); + const json20 = body20 ? JSON.parse(body20) : null; + assert(json20 && json20.kind === 21023, 'Returns kind=21023 for spent token'); +} else { + console.log('\n ⚠ Skipping tests 16-20: Set TEST_TOKEN env var with a valid Cashu token'); +} + +// Test: whoami on :2121 +console.log('\nTest: GET :2121/whoami'); +const bodyWhoami = curlBody(`${API}/whoami`); +assert(bodyWhoami && bodyWhoami.startsWith('mac='), '/whoami returns mac=...'); + +// Test: Portal has payment form +console.log('\nTest: Portal has payment form'); +const bodyPortal = curlBody(`http://${IP}/`); +assert(bodyPortal && bodyPortal.includes('cashuA'), 'Portal has Cashu token input'); +assert(bodyPortal && bodyPortal.includes('Pay & Connect') || bodyPortal && bodyPortal.includes('Pay'), 'Portal has Pay button'); + +// Summary +console.log(`\n=== Phase 2 Results: ${passed} passed, ${failed} failed ===\n`); +process.exit(failed > 0 ? 1 : 0); -- cgit v1.2.3