upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 03:18:04 +0530
committerYour Name <you@example.com>2026-05-19 03:18:04 +0530
commiteeb9d2d1dfd38dd19fa641e6f733c917a3d1d005 (patch)
tree4a3e3cf14290992a87968a833d7f041c45a7bf3b
parent81f2dc52dc42d01c89dff45a5407ec40b8863052 (diff)
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
-rw-r--r--CHECKLIST-CVM-RELAY.md35
-rw-r--r--Makefile5
-rw-r--r--main/CMakeLists.txt7
-rw-r--r--main/cvm_server.c49
-rw-r--r--main/tollgate_main.c12
-rw-r--r--tests/integration/test-cvm-mcp-relay.mjs200
6 files changed, 278 insertions, 30 deletions
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 @@
1# CVM Relay Stability Checklist
2
3## Pre-flight
4- [x] Create worktree `/home/c03rad0r/esp32-tollgate-cvm-relay`
5- [x] Create branch `feature/cvm-relay-stability` from master
6- [x] Document plan in PLAN-CVM-RELAY.md
7
8## Task 3: Fix Relay Disconnect
9- [x] Modify `cvm_relay_task()` inner loop: 1s TLS read timeout
10- [x] Decouple ping timer from read success
11- [x] Add consecutive-timeout counter for real disconnect detection
12- [x] Handle relay close frames (opcode 0x08)
13- [x] `make test-unit` passes (61/61)
14- [x] Build firmware
15- [x] Lock Board B: `make lock-b PHASE="cvm-relay-stability"`
16- [x] Verify Board B port: `esptool.py --port /dev/ttyACM1 chip_id`
17- [x] Flash Board B via `make flash-b`
18- [x] Monitor serial: confirm WS connected, no disconnect in 120s
19- [ ] Unlock Board B (deferred — may need more testing)
20
21## Task 1: Test get_sessions & get_usage via Relay
22- [x] Write `tests/integration/test-cvm-mcp-relay.mjs`
23- [x] Test: `get_sessions` returns JSON array via relay (0 active sessions)
24- [x] Test: `get_usage` returns metric/price/step fields via relay
25- [x] Both tests PASS on Board B (via `make test-cvm-mcp`)
26
27## Task 2: Test Non-Owner Auth Rejection via Relay
28- [x] Test: non-owner request gets no response (12s wait)
29- [x] Test: owner control request succeeds after non-owner test
30- [x] Both tests PASS on Board B (via `make test-cvm-mcp`)
31
32## Final
33- [x] `make test-unit` — 61 tests pass
34- [x] Commit: 3 commits on feature/cvm-relay-stability
35- [ ] 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:
280 @echo "=== Running CVM integration test ===" 280 @echo "=== Running CVM integration test ==="
281 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs 281 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm.mjs
282 282
283test-cvm-mcp:
284 $(call _require_board_lock)
285 @echo "=== Running CVM MCP relay integration test ==="
286 TOLLGATE_IP=$(TOLLGATE_IP) $(NODE) tests/integration/test-cvm-mcp-relay.mjs
287
283# ────────────────────────────────────────────── 288# ──────────────────────────────────────────────
284# Wallet 289# Wallet
285# ────────────────────────────────────────────── 290# ──────────────────────────────────────────────
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"
16 "nip04.c" 16 "nip04.c"
17 "mcp_handler.c" 17 "mcp_handler.c"
18 "cvm_server.c" 18 "cvm_server.c"
19 "display.c"
20 "font.c"
21 "local_relay.c"
22 "relay_selector.c"
23 "sync_manager.c"
24 INCLUDE_DIRS "." 19 INCLUDE_DIRS "."
25 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server 20 REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server
26 lwip json esp_http_client mbedtls esp-tls log spiffs 21 lwip json esp_http_client mbedtls esp-tls log spiffs
27 nucula_lib secp256k1 axs15231b qrcode wisp_relay 22 nucula_lib secp256k1 esp_timer
28 PRIV_REQUIRES esp-tls) 23 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);
30#define CVM_WS_BUF_SIZE 8192 30#define CVM_WS_BUF_SIZE 8192
31#define CVM_MAX_RESPONSE_SIZE 4096 31#define CVM_MAX_RESPONSE_SIZE 4096
32#define CVM_RECONNECT_DELAY_MS 5000 32#define CVM_RECONNECT_DELAY_MS 5000
33#define CVM_WS_READ_TIMEOUT_MS 1000
34#define CVM_WS_PING_INTERVAL_S 30
35#define CVM_WS_MAX_CONSECUTIVE_TIMEOUTS 65
33 36
34static char *parse_ws_text_frame(const uint8_t *buf, int len) 37static char *parse_ws_text_frame(const uint8_t *buf, int len)
35{ 38{
@@ -553,25 +556,47 @@ static void cvm_relay_task(void *arg)
553 return; 556 return;
554 } 557 }
555 558
559 int64_t last_ping_time = (int64_t)esp_timer_get_time() / 1000000;
560 int consecutive_timeouts = 0;
561
556 while (g_running) { 562 while (g_running) {
557 int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1); 563 int rlen = esp_tls_conn_read(tls, buf, CVM_WS_BUF_SIZE - 1);
558 if (rlen < 0) { 564 if (rlen < 0) {
559 ESP_LOGW(TAG, "Read error on %s (rlen=%d)", relay_url, rlen); 565 consecutive_timeouts++;
560 break; 566 if (consecutive_timeouts >= CVM_WS_MAX_CONSECUTIVE_TIMEOUTS) {
561 } 567 ESP_LOGW(TAG, "Read timeout on %s (%d consecutive)", relay_url, consecutive_timeouts);
562 if (rlen == 0) { 568 break;
569 }
570 } else if (rlen == 0) {
571 ESP_LOGW(TAG, "Connection closed by %s", relay_url);
563 break; 572 break;
564 } 573 } else {
565 574 consecutive_timeouts = 0;
566 if ((buf[0] & 0x0F) == 0x01) { 575 if ((buf[0] & 0x0F) == 0x01) {
567 char *text = parse_ws_text_frame(buf, rlen); 576 char *text = parse_ws_text_frame(buf, rlen);
568 if (text) { 577 if (text) {
569 if (strlen(text) > 0) { 578 if (strlen(text) > 0) {
570 process_relay_message(relay_url, text); 579 process_relay_message(tls, relay_url, text);
580 }
581 free(text);
571 } 582 }
572 free(text); 583 } else if ((buf[0] & 0x0F) == 0x09) {
584 ESP_LOGD(TAG, "Relay ping received, sending pong");
585 uint8_t pong[2] = {0x8A, 0x00};
586 esp_tls_conn_write(tls, pong, 2);
587 } else if ((buf[0] & 0x0F) == 0x08) {
588 ESP_LOGW(TAG, "Relay sent close frame");
589 break;
573 } 590 }
574 } 591 }
592
593 int64_t now = (int64_t)esp_timer_get_time() / 1000000;
594 if (now - last_ping_time >= CVM_WS_PING_INTERVAL_S) {
595 uint8_t ping[2] = {0x89, 0x00};
596 esp_tls_conn_write(tls, ping, 2);
597 last_ping_time = now;
598 ESP_LOGD(TAG, "Sent WS keepalive ping");
599 }
575 } 600 }
576 601
577 free(buf); 602 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 @@
23#include "tollgate_client.h" 23#include "tollgate_client.h"
24#include "lightning_payout.h" 24#include "lightning_payout.h"
25#include "cvm_server.h" 25#include "cvm_server.h"
26#include "display.h"
27#include "local_relay.h"
28#include "relay_selector.h"
29#include "sync_manager.h"
30 26
31#define MAX_STA_RETRY 5 27#define MAX_STA_RETRY 5
32static const char *TAG = "tollgate_main"; 28static const char *TAG = "tollgate_main";
@@ -182,11 +178,6 @@ static void start_services(void)
182 s_services_running = true; 178 s_services_running = true;
183 if (s_services_mutex) xSemaphoreGive(s_services_mutex); 179 if (s_services_mutex) xSemaphoreGive(s_services_mutex);
184 ESP_LOGI(TAG, "=== TollGate services started ==="); 180 ESP_LOGI(TAG, "=== TollGate services started ===");
185
186 display_set_state(DISPLAY_READY);
187 char portal_url[128];
188 snprintf(portal_url, sizeof(portal_url), "http://%s/", cfg->ap_ip_str);
189 display_update(cfg->ap_ssid, 0, 0, portal_url);
190} 181}
191 182
192static void stop_services(void) 183static void stop_services(void)
@@ -270,9 +261,6 @@ void app_main(void)
270{ 261{
271 ESP_LOGI(TAG, "=== TollGate ESP32 Starting ==="); 262 ESP_LOGI(TAG, "=== TollGate ESP32 Starting ===");
272 263
273 display_init();
274 display_set_state(DISPLAY_BOOT);
275
276 esp_err_t ret = nvs_flash_init(); 264 esp_err_t ret = nvs_flash_init();
277 if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { 265 if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
278 ESP_ERROR_CHECK(nvs_flash_erase()); 266 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 @@
1import { execSync } from 'child_process';
2
3const IP = process.env.TOLLGATE_IP || '10.192.45.1';
4const RELAY = 'wss://relay.primal.net';
5const OWNER_NSEC = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
6
7let passed = 0, failed = 0;
8
9function assert(condition, test) {
10 if (condition) { console.log(` \u2713 ${test}`); passed++; }
11 else { console.log(` \u2717 ${test}`); failed++; }
12}
13
14function nak(args, timeout = 15000) {
15 try {
16 return execSync(`timeout ${timeout / 1000} nak ${args}`, {
17 encoding: 'utf8',
18 stdio: ['pipe', 'pipe', 'pipe'],
19 timeout
20 }).trim();
21 } catch (e) {
22 return e.stdout ? e.stdout.trim() : '';
23 }
24}
25
26function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
27
28function generateId() {
29 return Math.floor(Math.random() * 100000);
30}
31
32async function sendMcpRequest(nsec, method, params, timeout = 15000) {
33 const id = generateId();
34 const content = JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} });
35
36 const boardNpub = nak(`key public ${OWNER_NSEC}`);
37
38 const result = nak(
39 `event -k 25910 -c '${content.replace(/'/g, "'\\''")}' -p ${boardNpub} --sec ${nsec} ${RELAY}`,
40 timeout
41 );
42
43 const reqEventId = result.match(/"id":"([a-f0-9]{64})"/);
44 return { reqEventId: reqEventId ? reqEventId[1] : null, id, content };
45}
46
47async function waitForResponse(reqEventId, timeout = 20000) {
48 const start = Date.now();
49 while (Date.now() - start < timeout) {
50 const result = nak(
51 `req -k 25910 -l 5 --tag e=${reqEventId} ${RELAY}`,
52 8000
53 );
54 if (result.length > 0 && result.includes('"id"')) {
55 return result;
56 }
57 await sleep(2000);
58 }
59 return null;
60}
61
62function extractMcpContent(rawEvent) {
63 try {
64 const lines = rawEvent.split('\n').filter(l => l.trim().startsWith('{'));
65 for (const line of lines) {
66 try {
67 const event = JSON.parse(line);
68 if (event.content) {
69 return JSON.parse(event.content);
70 }
71 } catch {}
72 }
73 } catch {}
74 return null;
75}
76
77async function runTests() {
78 console.log(`\n=== CVM MCP Relay Integration Tests ===`);
79 console.log(`Target: ${IP}`);
80 console.log(`Relay: ${RELAY}\n`);
81
82 const boardNpub = nak(`key public ${OWNER_NSEC}`);
83 console.log(`Board npub: ${boardNpub}`);
84 assert(boardNpub.length === 64, 'Board npub derived correctly');
85
86 console.log('\n--- Pre-flight: Board is reachable ---');
87 try {
88 const apiResult = execSync(`curl -s --connect-timeout 5 http://${IP}:2121/usage`, { encoding: 'utf8', timeout: 10000 });
89 assert(apiResult.length > 0, 'API /usage responds (board online)');
90 } catch (e) {
91 console.log(' WARNING: Board API not reachable. Tests may fail.');
92 }
93
94 console.log('\n--- Test 1: MCP initialize via relay ---');
95 {
96 const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'initialize');
97 assert(reqEventId !== null, 'Initialize request published');
98 if (reqEventId) {
99 const resp = await waitForResponse(reqEventId);
100 if (resp) {
101 assert(resp.includes('protocolVersion'), 'Response has protocolVersion');
102 assert(resp.includes('TollGate'), 'Response has server name TollGate');
103 } else {
104 assert(false, 'Got response for initialize');
105 }
106 }
107 }
108
109 console.log('\n--- Test 2: get_sessions via relay ---');
110 {
111 const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_sessions' });
112 assert(reqEventId !== null, 'get_sessions request published');
113 if (reqEventId) {
114 const resp = await waitForResponse(reqEventId);
115 if (resp) {
116 assert(resp.includes('content'), 'Response has content');
117 const mcp = extractMcpContent(resp);
118 if (mcp && mcp.result && mcp.result.content) {
119 const text = mcp.result.content[0]?.text;
120 if (text) {
121 const sessions = JSON.parse(text);
122 assert(Array.isArray(sessions), 'get_sessions returns array');
123 console.log(` Active sessions: ${sessions.filter(s => s.active).length}`);
124 }
125 } else {
126 assert(false, 'get_sessions MCP response parseable');
127 }
128 } else {
129 assert(false, 'Got response for get_sessions');
130 }
131 }
132 }
133
134 console.log('\n--- Test 3: get_usage via relay ---');
135 {
136 const { reqEventId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_usage' });
137 assert(reqEventId !== null, 'get_usage request published');
138 if (reqEventId) {
139 const resp = await waitForResponse(reqEventId);
140 if (resp) {
141 assert(resp.includes('content'), 'Response has content');
142 const mcp = extractMcpContent(resp);
143 if (mcp && mcp.result && mcp.result.content) {
144 const text = mcp.result.content[0]?.text;
145 if (text) {
146 const usage = JSON.parse(text);
147 assert(usage.metric !== undefined, 'get_usage returns metric');
148 assert(usage.price_per_step !== undefined, 'get_usage returns price_per_step');
149 assert(usage.step_size_ms !== undefined, 'get_usage returns step_size_ms');
150 console.log(` metric=${usage.metric}, price=${usage.price_per_step}, step=${usage.step_size_ms}ms`);
151 }
152 } else {
153 assert(false, 'get_usage MCP response parseable');
154 }
155 } else {
156 assert(false, 'Got response for get_usage');
157 }
158 }
159 }
160
161 console.log('\n--- Test 4: Non-owner auth rejection ---');
162 {
163 const throwawayNsec = nak('key generate');
164 const throwawayNpub = nak(`key public ${throwawayNsec}`);
165 console.log(` Throwaway npub: ${throwawayNpub.substring(0, 16)}...`);
166 assert(throwawayNsec.length === 64, 'Generated throwaway nsec');
167
168 const { reqEventId } = await sendMcpRequest(throwawayNsec, 'tools/call', { name: 'get_config' });
169 assert(reqEventId !== null, 'Non-owner request published');
170 if (reqEventId) {
171 const resp = await waitForResponse(reqEventId, 12000);
172 if (resp && resp.includes('"id"')) {
173 assert(false, 'Non-owner should NOT get a response');
174 } else {
175 assert(true, 'Non-owner correctly ignored (no response)');
176 }
177 }
178
179 await sleep(2000);
180
181 console.log(' Control: sending owner request to verify board still responsive...');
182 const { reqEventId: ctrlId } = await sendMcpRequest(OWNER_NSEC, 'tools/call', { name: 'get_config' });
183 if (ctrlId) {
184 const ctrlResp = await waitForResponse(ctrlId);
185 if (ctrlResp) {
186 assert(true, 'Owner control request after rejection test: PASS');
187 } else {
188 assert(false, 'Owner control request should get response');
189 }
190 }
191 }
192
193 console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
194 process.exit(failed > 0 ? 1 : 0);
195}
196
197runTests().catch(e => {
198 console.error('Test error:', e.message);
199 process.exit(1);
200});