From eeb9d2d1dfd38dd19fa641e6f733c917a3d1d005 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 19 May 2026 03:18:04 +0530 Subject: feat: CVM relay stability fix + MCP relay integration tests Relay disconnect fix (cvm_server.c): - TLS read timeout reduced from 15s to 1s (short poll loop) - Ping timer fires every 30s independently of read activity - Consecutive timeout counter (65s) detects real disconnects - Handle relay close frames (opcode 0x08) explicitly - Result: 120s+ stable connection (previously ~37s disconnect cycle) MCP relay integration tests (17/17 pass via make test-cvm-mcp): - MCP initialize roundtrip via relay.primal.net - get_sessions returns session array - get_usage returns metric/price/step fields - Non-owner auth rejection (board silently drops) - Owner control request passes after rejection test Build fixes: - Remove display/font/axs15231b/qrcode deps (from display branch, not here) - Remove local_relay/relay_selector/sync_manager deps (from relay branch) - Add esp_timer to CMakeLists REQUIRES Host unit tests: 61/61 pass --- CHECKLIST-CVM-RELAY.md | 35 ++++++ Makefile | 5 + main/CMakeLists.txt | 7 +- main/cvm_server.c | 49 ++++++-- main/tollgate_main.c | 12 -- tests/integration/test-cvm-mcp-relay.mjs | 200 +++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 30 deletions(-) create mode 100644 CHECKLIST-CVM-RELAY.md create mode 100644 tests/integration/test-cvm-mcp-relay.mjs diff --git a/CHECKLIST-CVM-RELAY.md b/CHECKLIST-CVM-RELAY.md new file mode 100644 index 0000000..e7c512d --- /dev/null +++ b/CHECKLIST-CVM-RELAY.md @@ -0,0 +1,35 @@ +# CVM Relay Stability Checklist + +## Pre-flight +- [x] Create worktree `/home/c03rad0r/esp32-tollgate-cvm-relay` +- [x] Create branch `feature/cvm-relay-stability` from master +- [x] Document plan in PLAN-CVM-RELAY.md + +## Task 3: Fix Relay Disconnect +- [x] Modify `cvm_relay_task()` inner loop: 1s TLS read timeout +- [x] Decouple ping timer from read success +- [x] Add consecutive-timeout counter for real disconnect detection +- [x] Handle relay close frames (opcode 0x08) +- [x] `make test-unit` passes (61/61) +- [x] Build firmware +- [x] Lock Board B: `make lock-b PHASE="cvm-relay-stability"` +- [x] Verify Board B port: `esptool.py --port /dev/ttyACM1 chip_id` +- [x] Flash Board B via `make flash-b` +- [x] Monitor serial: confirm WS connected, no disconnect in 120s +- [ ] Unlock Board B (deferred — may need more testing) + +## Task 1: Test get_sessions & get_usage via Relay +- [x] Write `tests/integration/test-cvm-mcp-relay.mjs` +- [x] Test: `get_sessions` returns JSON array via relay (0 active sessions) +- [x] Test: `get_usage` returns metric/price/step fields via relay +- [x] Both tests PASS on Board B (via `make test-cvm-mcp`) + +## Task 2: Test Non-Owner Auth Rejection via Relay +- [x] Test: non-owner request gets no response (12s wait) +- [x] Test: owner control request succeeds after non-owner test +- [x] Both tests PASS on Board B (via `make test-cvm-mcp`) + +## Final +- [x] `make test-unit` — 61 tests pass +- [x] Commit: 3 commits on feature/cvm-relay-stability +- [ ] Push to remote (nostr git relay down — will retry later) diff --git a/Makefile b/Makefile index 10b7359..fba9c64 100644 --- a/Makefile +++ b/Makefile @@ -280,6 +280,11 @@ test-cvm: @echo "=== Running CVM integration test ===" TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs +test-cvm-mcp: + $(call _require_board_lock) + @echo "=== Running CVM MCP relay integration test ===" + TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm-mcp-relay.mjs + # ────────────────────────────────────────────── # Wallet # ────────────────────────────────────────────── diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 6408e14..a041bc1 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -16,13 +16,8 @@ idf_component_register(SRCS "tollgate_main.c" "nip04.c" "mcp_handler.c" "cvm_server.c" - "display.c" - "font.c" - "local_relay.c" - "relay_selector.c" - "sync_manager.c" INCLUDE_DIRS "." REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server lwip json esp_http_client mbedtls esp-tls log spiffs - nucula_lib secp256k1 axs15231b qrcode wisp_relay + nucula_lib secp256k1 esp_timer PRIV_REQUIRES esp-tls) diff --git a/main/cvm_server.c b/main/cvm_server.c index dd04047..644738b 100644 --- a/main/cvm_server.c +++ b/main/cvm_server.c @@ -30,6 +30,9 @@ static void publish_announcements_via_ws(esp_tls_t *tls); #define CVM_WS_BUF_SIZE 8192 #define CVM_MAX_RESPONSE_SIZE 4096 #define CVM_RECONNECT_DELAY_MS 5000 +#define CVM_WS_READ_TIMEOUT_MS 1000 +#define CVM_WS_PING_INTERVAL_S 30 +#define CVM_WS_MAX_CONSECUTIVE_TIMEOUTS 65 static char *parse_ws_text_frame(const uint8_t *buf, int len) { @@ -553,25 +556,47 @@ static void cvm_relay_task(void *arg) return; } + int64_t last_ping_time = (int64_t)esp_timer_get_time() / 1000000; + int consecutive_timeouts = 0; + while (g_running) { int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); if (rlen < 0) { - ESP_LOGW(TAG, "Read error on %s (rlen=%d)", relay_url, rlen); - break; - } - if (rlen == 0) { + consecutive_timeouts++; + if (consecutive_timeouts >= CVM_WS_MAX_CONSECUTIVE_TIMEOUTS) { + ESP_LOGW(TAG, "Read timeout on %s (%d consecutive)", relay_url, consecutive_timeouts); + break; + } + } else if (rlen == 0) { + ESP_LOGW(TAG, "Connection closed by %s", relay_url); break; - } - - if ((buf[0] & 0x0F) == 0x01) { - char *text = parse_ws_text_frame(buf, rlen); - if (text) { - if (strlen(text) > 0) { - process_relay_message(relay_url, text); + } else { + consecutive_timeouts = 0; + if ((buf[0] & 0x0F) == 0x01) { + char *text = parse_ws_text_frame(buf, rlen); + if (text) { + if (strlen(text) > 0) { + process_relay_message(tls, relay_url, text); + } + free(text); } - free(text); + } else if ((buf[0] & 0x0F) == 0x09) { + ESP_LOGD(TAG, "Relay ping received, sending pong"); + uint8_t pong[2] = {0x8A, 0x00}; + esp_tls_conn_write(tls, pong, 2); + } else if ((buf[0] & 0x0F) == 0x08) { + ESP_LOGW(TAG, "Relay sent close frame"); + break; } } + + int64_t now = (int64_t)esp_timer_get_time() / 1000000; + if (now - last_ping_time >= CVM_WS_PING_INTERVAL_S) { + uint8_t ping[2] = {0x89, 0x00}; + esp_tls_conn_write(tls, ping, 2); + last_ping_time = now; + ESP_LOGD(TAG, "Sent WS keepalive ping"); + } } free(buf); diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 4741765..fa7a692 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c @@ -23,10 +23,6 @@ #include "tollgate_client.h" #include "lightning_payout.h" #include "cvm_server.h" -#include "display.h" -#include "local_relay.h" -#include "relay_selector.h" -#include "sync_manager.h" #define MAX_STA_RETRY 5 static const char *TAG = "tollgate_main"; @@ -182,11 +178,6 @@ static void start_services(void) s_services_running = true; if (s_services_mutex) xSemaphoreGive(s_services_mutex); ESP_LOGI(TAG, "=== TollGate services started ==="); - - display_set_state(DISPLAY_READY); - char portal_url[128]; - snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str); - display_update(cfg->ap_ssid, 0, 0, portal_url); } static void stop_services(void) @@ -270,9 +261,6 @@ void app_main(void) { ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); - display_init(); - display_set_state(DISPLAY_BOOT); - esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); diff --git a/tests/integration/test-cvm-mcp-relay.mjs b/tests/integration/test-cvm-mcp-relay.mjs new file mode 100644 index 0000000..e5f42ba --- /dev/null +++ b/tests/integration/test-cvm-mcp-relay.mjs @@ -0,0 +1,200 @@ +import { execSync } from 'child_process'; + +const IP = process.env.TOLLGATE_IP || '10.192.45.1'; +const RELAY = 'wss://relay.primal.net'; +const OWNER_NSEC = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + +let passed = 0, failed = 0; + +function assert(condition, test) { + if (condition) { console.log(` \u2713 ${test}`); passed++; } + else { console.log(` \u2717 ${test}`); failed++; } +} + +function nak(args, timeout = 15000) { + try { + return execSync(`timeout ${timeout / 1000} nak ${args}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout + }).trim(); + } catch (e) { + return e.stdout ? e.stdout.trim() : ''; + } +} + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +function generateId() { + return Math.floor(Math.random() * 100000); +} + +async function sendMcpRequest(nsec, method, params, timeout = 15000) { + const id = generateId(); + const content = JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }); + + const boardNpub = nak(`key public ${OWNER_NSEC}`); + + const result = nak( + `event -k 25910 -c '${content.replace(/'/g, "'\\''")}' -p ${boardNpub} --sec ${nsec} ${RELAY}`, + timeout + ); + + const reqEventId = result.match(/"id":"([a-f0-9]{64})"/); + return { reqEventId: reqEventId ? reqEventId[1] : null, id, content }; +} + +async function waitForResponse(reqEventId, timeout = 20000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const result = nak( + `req -k 25910 -l 5 --tag e=${reqEventId} ${RELAY}`, + 8000 + ); + if (result.length > 0 && result.includes('"id"')) { + return result; + } + await sleep(2000); + } + return null; +} + +function extractMcpContent(rawEvent) { + try { + const lines = rawEvent.split('\n').filter(l => l.trim().startsWith('{')); + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.content) { + return JSON.parse(event.content); + } + } catch {} + } + } catch {} + return null; +} + +async function runTests() { + console.log(`\n=== CVM MCP Relay Integration Tests ===`); + console.log(`Target: ${IP}`); + console.log(`Relay: ${RELAY}\n`); + + const boardNpub = nak(`key public ${OWNER_NSEC}`); + console.log(`Board npub: ${boardNpub}`); + assert(boardNpub.length === 64, 'Board npub derived correctly'); + + console.log('\n--- Pre-flight: Board is reachable ---'); + try { + const apiResult = execSync(`curl -s --connect-timeout 5 http://${IP}:2121/usage`, { encoding: 'utf8', timeout: 10000 }); + assert(apiResult.length > 0, 'API /usage responds (board online)'); + } catch (e) { + console.log(' WARNING: Board API not reachable. Tests may fail.'); + } + + console.log('\n--- Test 1: MCP initialize via relay ---'); + { + const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'initialize'); + assert(reqEventId !== null, 'Initialize request published'); + if (reqEventId) { + const resp = await waitForResponse(reqEventId); + if (resp) { + assert(resp.includes('protocolVersion'), 'Response has protocolVersion'); + assert(resp.includes('TollGate'), 'Response has server name TollGate'); + } else { + assert(false, 'Got response for initialize'); + } + } + } + + console.log('\n--- Test 2: get_sessions via relay ---'); + { + const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_sessions' }); + assert(reqEventId !== null, 'get_sessions request published'); + if (reqEventId) { + const resp = await waitForResponse(reqEventId); + if (resp) { + assert(resp.includes('content'), 'Response has content'); + const mcp = extractMcpContent(resp); + if (mcp && mcp.result && mcp.result.content) { + const text = mcp.result.content[0]?.text; + if (text) { + const sessions = JSON.parse(text); + assert(Array.isArray(sessions), 'get_sessions returns array'); + console.log(` Active sessions: ${sessions.filter(s => s.active).length}`); + } + } else { + assert(false, 'get_sessions MCP response parseable'); + } + } else { + assert(false, 'Got response for get_sessions'); + } + } + } + + console.log('\n--- Test 3: get_usage via relay ---'); + { + const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_usage' }); + assert(reqEventId !== null, 'get_usage request published'); + if (reqEventId) { + const resp = await waitForResponse(reqEventId); + if (resp) { + assert(resp.includes('content'), 'Response has content'); + const mcp = extractMcpContent(resp); + if (mcp && mcp.result && mcp.result.content) { + const text = mcp.result.content[0]?.text; + if (text) { + const usage = JSON.parse(text); + assert(usage.metric !== undefined, 'get_usage returns metric'); + assert(usage.price_per_step !== undefined, 'get_usage returns price_per_step'); + assert(usage.step_size_ms !== undefined, 'get_usage returns step_size_ms'); + console.log(` metric=${usage.metric}, price=${usage.price_per_step}, step=${usage.step_size_ms}ms`); + } + } else { + assert(false, 'get_usage MCP response parseable'); + } + } else { + assert(false, 'Got response for get_usage'); + } + } + } + + console.log('\n--- Test 4: Non-owner auth rejection ---'); + { + const throwawayNsec = nak('key generate'); + const throwawayNpub = nak(`key public ${throwawayNsec}`); + console.log(` Throwaway npub: ${throwawayNpub.substring(0, 16)}...`); + assert(throwawayNsec.length === 64, 'Generated throwaway nsec'); + + const { reqEventId } = await sendMcpRequest(throwawayNsec, 'tools/call', { name: 'get_config' }); + assert(reqEventId !== null, 'Non-owner request published'); + if (reqEventId) { + const resp = await waitForResponse(reqEventId, 12000); + if (resp && resp.includes('"id"')) { + assert(false, 'Non-owner should NOT get a response'); + } else { + assert(true, 'Non-owner correctly ignored (no response)'); + } + } + + await sleep(2000); + + console.log(' Control: sending owner request to verify board still responsive...'); + const { reqEventId: ctrlId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_config' }); + if (ctrlId) { + const ctrlResp = await waitForResponse(ctrlId); + if (ctrlResp) { + assert(true, 'Owner control request after rejection test: PASS'); + } else { + assert(false, 'Owner control request should get response'); + } + } + } + + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(e => { + console.error('Test error:', e.message); + process.exit(1); +}); -- cgit v1.2.3