lunel-cli 0.1.77 → 0.1.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +243 -486
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,8 +14,6 @@ import { createServer, createConnection } from "net";
|
|
|
14
14
|
import { createInterface } from "readline";
|
|
15
15
|
const DEFAULT_PROXY_URL = normalizeGatewayUrl(process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev");
|
|
16
16
|
const MANAGER_URL = normalizeGatewayUrl(process.env.LUNEL_MANAGER_URL || "https://manager.lunel.dev");
|
|
17
|
-
const CLOUD_JOB_CONFIG_PATH = "/etc/lunel/job.json";
|
|
18
|
-
const VM_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
19
17
|
const CLI_ARGS = process.argv.slice(2);
|
|
20
18
|
import { createRequire } from "module";
|
|
21
19
|
const __require = createRequire(import.meta.url);
|
|
@@ -58,7 +56,6 @@ let aiManager = null;
|
|
|
58
56
|
let currentSessionCode = null;
|
|
59
57
|
let currentSessionPassword = null;
|
|
60
58
|
let currentPrimaryGateway = DEFAULT_PROXY_URL;
|
|
61
|
-
let currentBackupGateway = null;
|
|
62
59
|
let activeGatewayUrl = DEFAULT_PROXY_URL;
|
|
63
60
|
let shuttingDown = false;
|
|
64
61
|
let activeControlWs = null;
|
|
@@ -70,51 +67,6 @@ function logWithTimestamp(scope, message, fields) {
|
|
|
70
67
|
}
|
|
71
68
|
const activeTunnels = new Map();
|
|
72
69
|
const PORT_SYNC_INTERVAL_MS = 30_000;
|
|
73
|
-
let cloudJobConfig = null;
|
|
74
|
-
let vmHeartbeatTimer = null;
|
|
75
|
-
async function readCloudJobConfig() {
|
|
76
|
-
try {
|
|
77
|
-
const data = await fs.readFile(CLOUD_JOB_CONFIG_PATH, "utf-8");
|
|
78
|
-
return JSON.parse(data);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
function startVmHeartbeat(resumeToken) {
|
|
85
|
-
if (!cloudJobConfig?.session_code)
|
|
86
|
-
return;
|
|
87
|
-
if (vmHeartbeatTimer)
|
|
88
|
-
return;
|
|
89
|
-
const cfg = cloudJobConfig;
|
|
90
|
-
const sendHeartbeat = () => {
|
|
91
|
-
if (shuttingDown)
|
|
92
|
-
return;
|
|
93
|
-
fetch(`${MANAGER_URL}/v1/vm/heartbeat`, {
|
|
94
|
-
method: "POST",
|
|
95
|
-
headers: { "Content-Type": "application/json" },
|
|
96
|
-
body: JSON.stringify({
|
|
97
|
-
sandboxId: cfg.sandbox_id,
|
|
98
|
-
resumeToken,
|
|
99
|
-
sandmanUrl: cfg.sandman_url || "",
|
|
100
|
-
repoUrl: cfg.repo_url,
|
|
101
|
-
branch: cfg.branch,
|
|
102
|
-
vmProfile: cfg.vm_profile || "",
|
|
103
|
-
}),
|
|
104
|
-
}).catch((err) => {
|
|
105
|
-
if (!shuttingDown)
|
|
106
|
-
console.warn("[vm] heartbeat failed:", err.message);
|
|
107
|
-
});
|
|
108
|
-
};
|
|
109
|
-
sendHeartbeat();
|
|
110
|
-
vmHeartbeatTimer = setInterval(sendHeartbeat, VM_HEARTBEAT_INTERVAL_MS);
|
|
111
|
-
}
|
|
112
|
-
function stopVmHeartbeat() {
|
|
113
|
-
if (vmHeartbeatTimer) {
|
|
114
|
-
clearInterval(vmHeartbeatTimer);
|
|
115
|
-
vmHeartbeatTimer = null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
70
|
const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
|
|
119
71
|
const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
|
|
120
72
|
const TUNNEL_SETUP_BUDGET_MS = 18_000;
|
|
@@ -260,7 +212,6 @@ function parseExtraPortsFromArgs(args) {
|
|
|
260
212
|
}
|
|
261
213
|
const EXTRA_PORTS = parseExtraPortsFromArgs(CLI_ARGS);
|
|
262
214
|
const SCAN_PORTS = Array.from(new Set([...DEV_PORTS, ...EXTRA_PORTS])).sort((a, b) => a - b);
|
|
263
|
-
const RUN_ABCD_MODE = CLI_ARGS.includes("--run-abcd");
|
|
264
215
|
function samePortSet(a, b) {
|
|
265
216
|
if (a.length !== b.length)
|
|
266
217
|
return false;
|
|
@@ -340,45 +291,19 @@ function generatePersistentSecret(length) {
|
|
|
340
291
|
}
|
|
341
292
|
return out;
|
|
342
293
|
}
|
|
343
|
-
function normalizePairingRoot(input) {
|
|
344
|
-
try {
|
|
345
|
-
return fssync.realpathSync(input);
|
|
346
|
-
}
|
|
347
|
-
catch {
|
|
348
|
-
return path.resolve(input);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
294
|
async function readCliConfig() {
|
|
352
295
|
try {
|
|
353
296
|
const raw = await fs.readFile(CLI_CONFIG_PATH, "utf-8");
|
|
354
297
|
const parsed = JSON.parse(raw);
|
|
355
|
-
const pairings = Array.isArray(parsed.pairings)
|
|
356
|
-
? parsed.pairings
|
|
357
|
-
.filter((entry) => {
|
|
358
|
-
return Boolean(entry &&
|
|
359
|
-
typeof entry.secret === "string" &&
|
|
360
|
-
typeof entry.root === "string" &&
|
|
361
|
-
typeof entry.hostname === "string" &&
|
|
362
|
-
typeof entry.phoneId === "string" &&
|
|
363
|
-
typeof entry.pairedAt === "number" &&
|
|
364
|
-
typeof entry.lastUsedAt === "number");
|
|
365
|
-
})
|
|
366
|
-
.map((entry) => ({
|
|
367
|
-
...entry,
|
|
368
|
-
root: normalizePairingRoot(entry.root),
|
|
369
|
-
}))
|
|
370
|
-
: [];
|
|
371
298
|
return {
|
|
372
299
|
version: 1,
|
|
373
300
|
deviceId: typeof parsed.deviceId === "string" && parsed.deviceId ? parsed.deviceId : generatePersistentSecret(32),
|
|
374
|
-
pairings,
|
|
375
301
|
};
|
|
376
302
|
}
|
|
377
303
|
catch {
|
|
378
304
|
return {
|
|
379
305
|
version: 1,
|
|
380
306
|
deviceId: generatePersistentSecret(32),
|
|
381
|
-
pairings: [],
|
|
382
307
|
};
|
|
383
308
|
}
|
|
384
309
|
}
|
|
@@ -393,42 +318,6 @@ async function getCliConfig() {
|
|
|
393
318
|
}
|
|
394
319
|
return await cliConfigPromise;
|
|
395
320
|
}
|
|
396
|
-
async function persistCliConfig(mutator) {
|
|
397
|
-
const current = await getCliConfig();
|
|
398
|
-
const next = mutator({
|
|
399
|
-
version: 1,
|
|
400
|
-
deviceId: current.deviceId,
|
|
401
|
-
pairings: [...current.pairings],
|
|
402
|
-
});
|
|
403
|
-
cliConfigPromise = Promise.resolve(next);
|
|
404
|
-
await writeCliConfig(next);
|
|
405
|
-
return next;
|
|
406
|
-
}
|
|
407
|
-
async function getSavedPairingForRoot(root) {
|
|
408
|
-
const normalizedRoot = normalizePairingRoot(root);
|
|
409
|
-
const config = await getCliConfig();
|
|
410
|
-
const matches = config.pairings
|
|
411
|
-
.filter((entry) => entry.root === normalizedRoot)
|
|
412
|
-
.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
413
|
-
return matches[0] || null;
|
|
414
|
-
}
|
|
415
|
-
async function savePairing(pairing) {
|
|
416
|
-
await persistCliConfig((config) => {
|
|
417
|
-
const deduped = config.pairings.filter((entry) => entry.secret !== pairing.secret);
|
|
418
|
-
deduped.push(pairing);
|
|
419
|
-
deduped.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
420
|
-
return {
|
|
421
|
-
...config,
|
|
422
|
-
pairings: deduped.slice(0, 50),
|
|
423
|
-
};
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
async function removePairing(secret) {
|
|
427
|
-
await persistCliConfig((config) => ({
|
|
428
|
-
...config,
|
|
429
|
-
pairings: config.pairings.filter((entry) => entry.secret !== secret),
|
|
430
|
-
}));
|
|
431
|
-
}
|
|
432
321
|
// ============================================================================
|
|
433
322
|
// File System Handlers
|
|
434
323
|
// ============================================================================
|
|
@@ -2323,12 +2212,7 @@ async function processMessage(message) {
|
|
|
2323
2212
|
result = handleSystemPing();
|
|
2324
2213
|
break;
|
|
2325
2214
|
case "pairDevice": {
|
|
2326
|
-
|
|
2327
|
-
if (!phoneId) {
|
|
2328
|
-
throw Object.assign(new Error("phoneId is required"), { code: "EINVAL" });
|
|
2329
|
-
}
|
|
2330
|
-
result = { ...(await registerPersistentPairing(phoneId)) };
|
|
2331
|
-
break;
|
|
2215
|
+
throw Object.assign(new Error("pairDevice is no longer supported"), { code: "EINVAL" });
|
|
2332
2216
|
}
|
|
2333
2217
|
default:
|
|
2334
2218
|
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
@@ -2651,124 +2535,75 @@ function normalizeGatewayUrl(input) {
|
|
|
2651
2535
|
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "");
|
|
2652
2536
|
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
2653
2537
|
}
|
|
2654
|
-
async function
|
|
2655
|
-
const response = await fetch(`${MANAGER_URL}/
|
|
2656
|
-
method: "POST",
|
|
2657
|
-
headers: { "Content-Type": "application/json" },
|
|
2658
|
-
body: JSON.stringify(opts.requestedCode ? { requestedCode: opts.requestedCode } : {}),
|
|
2659
|
-
});
|
|
2538
|
+
async function createQrCode() {
|
|
2539
|
+
const response = await fetch(`${MANAGER_URL}/v2/qr`);
|
|
2660
2540
|
if (!response.ok) {
|
|
2661
|
-
throw new Error(`Failed to create
|
|
2541
|
+
throw new Error(`Failed to create QR code from manager: ${response.status}`);
|
|
2662
2542
|
}
|
|
2663
2543
|
return (await response.json());
|
|
2664
2544
|
}
|
|
2665
|
-
async function
|
|
2666
|
-
const
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2545
|
+
async function assembleWithCode(code) {
|
|
2546
|
+
const wsUrl = `${MANAGER_URL.replace(/^https:/, "wss:")}/v2/assemble?code=${encodeURIComponent(code)}&role=cli`;
|
|
2547
|
+
return await new Promise((resolve, reject) => {
|
|
2548
|
+
const ws = new WebSocket(wsUrl);
|
|
2549
|
+
let settled = false;
|
|
2550
|
+
const fail = (error) => {
|
|
2551
|
+
if (settled)
|
|
2552
|
+
return;
|
|
2553
|
+
settled = true;
|
|
2554
|
+
try {
|
|
2555
|
+
ws.close();
|
|
2556
|
+
}
|
|
2557
|
+
catch {
|
|
2558
|
+
// ignore
|
|
2559
|
+
}
|
|
2560
|
+
reject(error);
|
|
2561
|
+
};
|
|
2562
|
+
ws.on("message", (data) => {
|
|
2563
|
+
try {
|
|
2564
|
+
const parsed = JSON.parse(data.toString());
|
|
2565
|
+
if (parsed.type !== "assembled" || typeof parsed.code !== "string" || typeof parsed.password !== "string") {
|
|
2566
|
+
fail(new Error("Invalid assemble payload"));
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
if (settled)
|
|
2570
|
+
return;
|
|
2571
|
+
settled = true;
|
|
2572
|
+
ws.send(JSON.stringify({ type: "ack" }));
|
|
2573
|
+
resolve({ code: parsed.code, password: parsed.password });
|
|
2574
|
+
}
|
|
2575
|
+
catch (error) {
|
|
2576
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
2577
|
+
}
|
|
2578
|
+
});
|
|
2579
|
+
ws.on("close", (codeValue, reason) => {
|
|
2580
|
+
if (settled)
|
|
2581
|
+
return;
|
|
2582
|
+
fail(new Error(`Assemble socket closed (${codeValue}: ${reason.toString()})`));
|
|
2583
|
+
});
|
|
2584
|
+
ws.on("error", (error) => {
|
|
2585
|
+
fail(new Error(`Assemble socket error: ${error.message}`));
|
|
2586
|
+
});
|
|
2693
2587
|
});
|
|
2694
|
-
if (!response.ok) {
|
|
2695
|
-
throw new Error(`Failed to resolve session from manager: ${response.status}`);
|
|
2696
|
-
}
|
|
2697
|
-
const snapshot = await response.json();
|
|
2698
|
-
if (!snapshot.exists || !snapshot.valid || !snapshot.primary || !snapshot.resumeToken || !snapshot.code) {
|
|
2699
|
-
if (snapshot.reason === "session_finalized" || snapshot.reason === "not_found") {
|
|
2700
|
-
return null;
|
|
2701
|
-
}
|
|
2702
|
-
if (snapshot.state === "ended" || snapshot.state === "expired") {
|
|
2703
|
-
return null;
|
|
2704
|
-
}
|
|
2705
|
-
throw new Error(`Session resolve returned invalid snapshot (${snapshot.reason || "unknown"})`);
|
|
2706
|
-
}
|
|
2707
|
-
return {
|
|
2708
|
-
sessionId: snapshot.sessionId || "",
|
|
2709
|
-
code: snapshot.code || "",
|
|
2710
|
-
password: snapshot.resumeToken,
|
|
2711
|
-
primary: snapshot.primary,
|
|
2712
|
-
backup: snapshot.backup ?? null,
|
|
2713
|
-
state: snapshot.state,
|
|
2714
|
-
expiresAt: snapshot.expiresAt,
|
|
2715
|
-
};
|
|
2716
2588
|
}
|
|
2717
|
-
async function
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
const config = await getCliConfig();
|
|
2722
|
-
const response = await fetch(`${MANAGER_URL}/v1/pairings/register`, {
|
|
2723
|
-
method: "POST",
|
|
2724
|
-
headers: { "Content-Type": "application/json" },
|
|
2725
|
-
body: JSON.stringify({
|
|
2726
|
-
activeSecret: currentSessionPassword,
|
|
2727
|
-
phoneId,
|
|
2728
|
-
pcId: config.deviceId,
|
|
2729
|
-
root: ROOT_DIR,
|
|
2730
|
-
hostname: os.hostname(),
|
|
2731
|
-
}),
|
|
2732
|
-
});
|
|
2589
|
+
async function getAssignedProxyUrl(password) {
|
|
2590
|
+
const url = new URL("/v2/proxy", MANAGER_URL);
|
|
2591
|
+
url.searchParams.set("password", password);
|
|
2592
|
+
const response = await fetch(url);
|
|
2733
2593
|
if (!response.ok) {
|
|
2734
|
-
throw new Error(`Failed to
|
|
2594
|
+
throw new Error(`Failed to get proxy from manager: ${response.status}`);
|
|
2735
2595
|
}
|
|
2736
2596
|
const payload = await response.json();
|
|
2737
|
-
if (typeof payload.
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
typeof payload.pairedAt !== "number" ||
|
|
2742
|
-
typeof payload.lastUsedAt !== "number") {
|
|
2743
|
-
throw new Error("Manager returned invalid pairing response");
|
|
2744
|
-
}
|
|
2745
|
-
const normalized = {
|
|
2746
|
-
secret: payload.secret,
|
|
2747
|
-
hostname: payload.hostname,
|
|
2748
|
-
root: normalizePairingRoot(payload.root),
|
|
2749
|
-
phoneId: payload.phoneId,
|
|
2750
|
-
pairedAt: payload.pairedAt,
|
|
2751
|
-
lastUsedAt: payload.lastUsedAt,
|
|
2752
|
-
};
|
|
2753
|
-
await savePairing({
|
|
2754
|
-
secret: normalized.secret,
|
|
2755
|
-
root: normalized.root,
|
|
2756
|
-
hostname: normalized.hostname,
|
|
2757
|
-
phoneId: normalized.phoneId,
|
|
2758
|
-
pairedAt: normalized.pairedAt,
|
|
2759
|
-
lastUsedAt: normalized.lastUsedAt,
|
|
2760
|
-
});
|
|
2761
|
-
return normalized;
|
|
2597
|
+
if (typeof payload.proxyUrl !== "string" || !payload.proxyUrl) {
|
|
2598
|
+
throw new Error("Manager returned invalid proxy assignment");
|
|
2599
|
+
}
|
|
2600
|
+
return normalizeGatewayUrl(payload.proxyUrl);
|
|
2762
2601
|
}
|
|
2763
|
-
function displayQR(
|
|
2602
|
+
function displayQR(code) {
|
|
2764
2603
|
console.log("\n");
|
|
2765
2604
|
qrcode.generate(code, { small: true }, (qr) => {
|
|
2766
2605
|
console.log(qr);
|
|
2767
2606
|
console.log(`\n Session code: ${code}\n`);
|
|
2768
|
-
console.log(` Primary gateway: ${primaryGateway}`);
|
|
2769
|
-
if (backupGateway) {
|
|
2770
|
-
console.log(` Backup gateway: ${backupGateway}`);
|
|
2771
|
-
}
|
|
2772
2607
|
console.log(` Root directory: ${ROOT_DIR}\n`);
|
|
2773
2608
|
console.log(" Scan the QR code with the Lunel app to connect.");
|
|
2774
2609
|
console.log(" Press Ctrl+C to exit.\n");
|
|
@@ -2783,14 +2618,8 @@ function buildWsUrl(gatewayUrl, role, channel) {
|
|
|
2783
2618
|
if (currentSessionPassword) {
|
|
2784
2619
|
query.set("password", currentSessionPassword);
|
|
2785
2620
|
}
|
|
2786
|
-
else if (currentSessionCode) {
|
|
2787
|
-
query.set("code", currentSessionCode);
|
|
2788
|
-
if (currentBackupGateway) {
|
|
2789
|
-
query.set("backup", currentBackupGateway);
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
2621
|
else {
|
|
2793
|
-
throw new Error("missing
|
|
2622
|
+
throw new Error("missing password for websocket connect");
|
|
2794
2623
|
}
|
|
2795
2624
|
return `${wsBase}/v1/ws/${role}/${channel}?${query.toString()}`;
|
|
2796
2625
|
}
|
|
@@ -2798,7 +2627,6 @@ function gracefulShutdown() {
|
|
|
2798
2627
|
shuttingDown = true;
|
|
2799
2628
|
console.log("\nShutting down...");
|
|
2800
2629
|
void aiManager?.destroy();
|
|
2801
|
-
stopVmHeartbeat();
|
|
2802
2630
|
stopPortSync();
|
|
2803
2631
|
if (ptyProcess) {
|
|
2804
2632
|
ptyProcess.kill();
|
|
@@ -2820,212 +2648,195 @@ function gracefulShutdown() {
|
|
|
2820
2648
|
process.exit(0);
|
|
2821
2649
|
}
|
|
2822
2650
|
async function connectWebSocket() {
|
|
2823
|
-
const
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2651
|
+
const gatewayUrl = currentPrimaryGateway;
|
|
2652
|
+
await new Promise((resolve, reject) => {
|
|
2653
|
+
activeGatewayUrl = gatewayUrl;
|
|
2654
|
+
const controlUrl = buildWsUrl(gatewayUrl, "cli", "control");
|
|
2655
|
+
const dataUrl = buildWsUrl(gatewayUrl, "cli", "data");
|
|
2656
|
+
console.log(`Connecting to gateway ${gatewayUrl}...`);
|
|
2657
|
+
const controlWs = new WebSocket(controlUrl);
|
|
2658
|
+
const dataWs = new WebSocket(dataUrl);
|
|
2659
|
+
activeControlWs = controlWs;
|
|
2660
|
+
activeDataWs = dataWs;
|
|
2661
|
+
dataChannel = dataWs;
|
|
2662
|
+
let controlConnected = false;
|
|
2663
|
+
let dataConnected = false;
|
|
2664
|
+
let settled = false;
|
|
2665
|
+
let closeHandled = false;
|
|
2666
|
+
let closeReason = "";
|
|
2667
|
+
const failConnection = (reason) => {
|
|
2668
|
+
if (settled)
|
|
2669
|
+
return;
|
|
2670
|
+
settled = true;
|
|
2671
|
+
reject(new Error(reason));
|
|
2672
|
+
};
|
|
2673
|
+
const checkFullyConnected = () => {
|
|
2674
|
+
if (controlConnected && dataConnected && !settled) {
|
|
2675
|
+
settled = true;
|
|
2676
|
+
console.log("Connected to gateway (control + data channels).\n");
|
|
2677
|
+
resolve();
|
|
2678
|
+
}
|
|
2679
|
+
};
|
|
2680
|
+
const handleClose = (reason) => {
|
|
2681
|
+
if (closeHandled || shuttingDown)
|
|
2682
|
+
return;
|
|
2683
|
+
closeHandled = true;
|
|
2684
|
+
closeReason = reason;
|
|
2685
|
+
stopPortSync();
|
|
2686
|
+
cleanupAllTunnels();
|
|
2687
|
+
setTimeout(() => {
|
|
2688
|
+
if (shuttingDown)
|
|
2689
|
+
return;
|
|
2690
|
+
void handleConnectionDrop(closeReason);
|
|
2691
|
+
}, 50);
|
|
2692
|
+
};
|
|
2693
|
+
controlWs.on("open", () => {
|
|
2694
|
+
controlConnected = true;
|
|
2695
|
+
checkFullyConnected();
|
|
2696
|
+
});
|
|
2697
|
+
controlWs.on("message", async (data) => {
|
|
2698
|
+
try {
|
|
2699
|
+
const message = JSON.parse(data.toString());
|
|
2700
|
+
if ("type" in message) {
|
|
2701
|
+
if (message.type === "connected")
|
|
2844
2702
|
return;
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
const checkFullyConnected = () => {
|
|
2849
|
-
if (controlConnected && dataConnected && !settled) {
|
|
2850
|
-
settled = true;
|
|
2851
|
-
console.log("Connected to gateway (control + data channels).\n");
|
|
2852
|
-
resolve();
|
|
2853
|
-
}
|
|
2854
|
-
};
|
|
2855
|
-
const handleClose = (reason) => {
|
|
2856
|
-
if (closeHandled || shuttingDown)
|
|
2703
|
+
if (message.type === "peer_connected") {
|
|
2704
|
+
console.log("App connected!\n");
|
|
2705
|
+
startPortSync(controlWs);
|
|
2857
2706
|
return;
|
|
2858
|
-
closeHandled = true;
|
|
2859
|
-
closeReason = reason;
|
|
2860
|
-
stopPortSync();
|
|
2861
|
-
cleanupAllTunnels();
|
|
2862
|
-
setTimeout(() => {
|
|
2863
|
-
if (shuttingDown)
|
|
2864
|
-
return;
|
|
2865
|
-
void handleConnectionDrop(closeReason);
|
|
2866
|
-
}, 50);
|
|
2867
|
-
};
|
|
2868
|
-
controlWs.on("open", () => {
|
|
2869
|
-
controlConnected = true;
|
|
2870
|
-
checkFullyConnected();
|
|
2871
|
-
});
|
|
2872
|
-
controlWs.on("message", async (data) => {
|
|
2873
|
-
try {
|
|
2874
|
-
const message = JSON.parse(data.toString());
|
|
2875
|
-
if ("type" in message) {
|
|
2876
|
-
if (message.type === "connected")
|
|
2877
|
-
return;
|
|
2878
|
-
if (message.type === "session_password" && message.password) {
|
|
2879
|
-
if (!currentSessionPassword)
|
|
2880
|
-
resetReplayBuffer(); // new session
|
|
2881
|
-
currentSessionPassword = message.password;
|
|
2882
|
-
console.log("[session] received reconnect password");
|
|
2883
|
-
return;
|
|
2884
|
-
}
|
|
2885
|
-
if (message.type === "peer_connected") {
|
|
2886
|
-
console.log("App connected!\n");
|
|
2887
|
-
startPortSync(controlWs);
|
|
2888
|
-
return;
|
|
2889
|
-
}
|
|
2890
|
-
if (message.type === "peer_disconnected") {
|
|
2891
|
-
console.log("App disconnected. Waiting for reconnect window.\n");
|
|
2892
|
-
stopPortSync();
|
|
2893
|
-
return;
|
|
2894
|
-
}
|
|
2895
|
-
if (message.type === "app_disconnected") {
|
|
2896
|
-
if (message.reconnectDeadline) {
|
|
2897
|
-
console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
|
|
2898
|
-
}
|
|
2899
|
-
return;
|
|
2900
|
-
}
|
|
2901
|
-
if (message.type === "close_connection") {
|
|
2902
|
-
const reason = message.reason || "expired";
|
|
2903
|
-
console.log(`[session] closed by gateway: ${reason}`);
|
|
2904
|
-
if (reason === "session ended from app") {
|
|
2905
|
-
console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
|
|
2906
|
-
}
|
|
2907
|
-
gracefulShutdown();
|
|
2908
|
-
return;
|
|
2909
|
-
}
|
|
2910
|
-
}
|
|
2911
|
-
if (isProtocolResponse(message)) {
|
|
2912
|
-
// Ignore server/app responses forwarded over WS; CLI only processes requests.
|
|
2913
|
-
return;
|
|
2914
|
-
}
|
|
2915
|
-
if (isProtocolRequest(message)) {
|
|
2916
|
-
const response = await processMessage(message);
|
|
2917
|
-
sendResponseOnData(response, dataWs);
|
|
2918
|
-
return;
|
|
2919
|
-
}
|
|
2920
|
-
console.warn("[router] Ignoring non-request control frame");
|
|
2921
|
-
}
|
|
2922
|
-
catch (error) {
|
|
2923
|
-
console.error("Error processing control message:", error);
|
|
2924
2707
|
}
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
failConnection(`control close before ready (${code}: ${reason.toString()})`);
|
|
2708
|
+
if (message.type === "peer_disconnected") {
|
|
2709
|
+
console.log("App disconnected. Waiting for reconnect window.\n");
|
|
2710
|
+
stopPortSync();
|
|
2929
2711
|
return;
|
|
2930
2712
|
}
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
failConnection(`control ws error: ${error.message}`);
|
|
2713
|
+
if (message.type === "app_disconnected") {
|
|
2714
|
+
if (message.reconnectDeadline) {
|
|
2715
|
+
console.log(`[session] app disconnected, waiting until ${new Date(message.reconnectDeadline).toISOString()}`);
|
|
2716
|
+
}
|
|
2936
2717
|
return;
|
|
2937
2718
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
});
|
|
2944
|
-
dataWs.on("message", async (data) => {
|
|
2945
|
-
try {
|
|
2946
|
-
const raw = JSON.parse(data.toString());
|
|
2947
|
-
if (raw.type === "connected")
|
|
2948
|
-
return;
|
|
2949
|
-
// E2EE handshake messages (always plaintext)
|
|
2950
|
-
if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
|
|
2951
|
-
e2eeHandlePeerHello(raw.pubkey, dataWs);
|
|
2952
|
-
return;
|
|
2953
|
-
}
|
|
2954
|
-
if (raw.type === "e2ee_secure_ready") {
|
|
2955
|
-
e2eeHandlePeerReady();
|
|
2956
|
-
return;
|
|
2957
|
-
}
|
|
2958
|
-
// Reconnect request: reset E2EE so fresh handshake happens, then replay
|
|
2959
|
-
if (raw.ns === "system" && raw.action === "reconnect") {
|
|
2960
|
-
e2eeReset();
|
|
2961
|
-
const lastSeq = Number(raw.payload?.lastSeq ?? 0);
|
|
2962
|
-
const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
|
|
2963
|
-
console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
|
|
2964
|
-
// Replay without encryption — E2EE handshake hasn't completed yet
|
|
2965
|
-
for (const entry of toReplay)
|
|
2966
|
-
dataWs.send(JSON.stringify(entry.msg));
|
|
2967
|
-
return;
|
|
2968
|
-
}
|
|
2969
|
-
// Decrypt payload if E2EE is active
|
|
2970
|
-
let message = raw;
|
|
2971
|
-
if (e2eeActive && typeof raw.enc === "string") {
|
|
2972
|
-
try {
|
|
2973
|
-
message = { ...raw, payload: e2eeDecrypt(raw.enc) };
|
|
2974
|
-
delete message.enc;
|
|
2975
|
-
}
|
|
2976
|
-
catch (decErr) {
|
|
2977
|
-
console.error("[e2ee] decryption failed:", decErr.message);
|
|
2978
|
-
return;
|
|
2979
|
-
}
|
|
2980
|
-
}
|
|
2981
|
-
if (isProtocolResponse(message)) {
|
|
2982
|
-
// Ignore server/app responses forwarded over WS; CLI only processes requests.
|
|
2983
|
-
return;
|
|
2719
|
+
if (message.type === "close_connection") {
|
|
2720
|
+
const reason = message.reason || "expired";
|
|
2721
|
+
console.log(`[session] closed by gateway: ${reason}`);
|
|
2722
|
+
if (reason === "session ended from app") {
|
|
2723
|
+
console.log("[session] Run `npx lunel-cli` again and scan the new QR code to reconnect.");
|
|
2984
2724
|
}
|
|
2985
|
-
|
|
2986
|
-
const response = await processMessage(message);
|
|
2987
|
-
sendResponseOnData(response, dataWs);
|
|
2988
|
-
return;
|
|
2989
|
-
}
|
|
2990
|
-
console.warn("[router] Ignoring non-request data frame");
|
|
2991
|
-
}
|
|
2992
|
-
catch (error) {
|
|
2993
|
-
console.error("Error processing data message:", error);
|
|
2994
|
-
}
|
|
2995
|
-
});
|
|
2996
|
-
dataWs.on("close", (code, reason) => {
|
|
2997
|
-
// Reset backpressure state so reconnect starts fresh
|
|
2998
|
-
dataChannelPaused = false;
|
|
2999
|
-
if (dataChannelDrainTimer) {
|
|
3000
|
-
clearInterval(dataChannelDrainTimer);
|
|
3001
|
-
dataChannelDrainTimer = null;
|
|
3002
|
-
}
|
|
3003
|
-
if (!settled) {
|
|
3004
|
-
failConnection(`data close before ready (${code}: ${reason.toString()})`);
|
|
2725
|
+
gracefulShutdown();
|
|
3005
2726
|
return;
|
|
3006
2727
|
}
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
2728
|
+
}
|
|
2729
|
+
if (isProtocolResponse(message)) {
|
|
2730
|
+
// Ignore server/app responses forwarded over WS; CLI only processes requests.
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
if (isProtocolRequest(message)) {
|
|
2734
|
+
const response = await processMessage(message);
|
|
2735
|
+
sendResponseOnData(response, dataWs);
|
|
2736
|
+
return;
|
|
2737
|
+
}
|
|
2738
|
+
console.warn("[router] Ignoring non-request control frame");
|
|
2739
|
+
}
|
|
2740
|
+
catch (error) {
|
|
2741
|
+
console.error("Error processing control message:", error);
|
|
2742
|
+
}
|
|
2743
|
+
});
|
|
2744
|
+
controlWs.on("close", (code, reason) => {
|
|
2745
|
+
if (!settled) {
|
|
2746
|
+
failConnection(`control close before ready (${code}: ${reason.toString()})`);
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
handleClose(`control closed (${code}: ${reason.toString()})`);
|
|
2750
|
+
});
|
|
2751
|
+
controlWs.on("error", (error) => {
|
|
2752
|
+
if (!settled) {
|
|
2753
|
+
failConnection(`control ws error: ${error.message}`);
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
console.error("Control WebSocket error:", error.message);
|
|
2757
|
+
});
|
|
2758
|
+
dataWs.on("open", () => {
|
|
2759
|
+
dataConnected = true;
|
|
2760
|
+
checkFullyConnected();
|
|
2761
|
+
});
|
|
2762
|
+
dataWs.on("message", async (data) => {
|
|
2763
|
+
try {
|
|
2764
|
+
const raw = JSON.parse(data.toString());
|
|
2765
|
+
if (raw.type === "connected")
|
|
2766
|
+
return;
|
|
2767
|
+
// E2EE handshake messages (always plaintext)
|
|
2768
|
+
if (raw.type === "e2ee_hello" && typeof raw.pubkey === "string") {
|
|
2769
|
+
e2eeHandlePeerHello(raw.pubkey, dataWs);
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
if (raw.type === "e2ee_secure_ready") {
|
|
2773
|
+
e2eeHandlePeerReady();
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
// Reconnect request: reset E2EE so fresh handshake happens, then replay
|
|
2777
|
+
if (raw.ns === "system" && raw.action === "reconnect") {
|
|
2778
|
+
e2eeReset();
|
|
2779
|
+
const lastSeq = Number(raw.payload?.lastSeq ?? 0);
|
|
2780
|
+
const toReplay = replayBuffer.filter((e) => e.seq > lastSeq);
|
|
2781
|
+
console.log(`[replay] replaying ${toReplay.length} messages after seq ${lastSeq}`);
|
|
2782
|
+
// Replay without encryption — E2EE handshake hasn't completed yet
|
|
2783
|
+
for (const entry of toReplay)
|
|
2784
|
+
dataWs.send(JSON.stringify(entry.msg));
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
// Decrypt payload if E2EE is active
|
|
2788
|
+
let message = raw;
|
|
2789
|
+
if (e2eeActive && typeof raw.enc === "string") {
|
|
2790
|
+
try {
|
|
2791
|
+
message = { ...raw, payload: e2eeDecrypt(raw.enc) };
|
|
2792
|
+
delete message.enc;
|
|
3013
2793
|
}
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
if (!settled) {
|
|
3018
|
-
failConnection("connection timeout");
|
|
2794
|
+
catch (decErr) {
|
|
2795
|
+
console.error("[e2ee] decryption failed:", decErr.message);
|
|
2796
|
+
return;
|
|
3019
2797
|
}
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
2798
|
+
}
|
|
2799
|
+
if (isProtocolResponse(message)) {
|
|
2800
|
+
// Ignore server/app responses forwarded over WS; CLI only processes requests.
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
if (isProtocolRequest(message)) {
|
|
2804
|
+
const response = await processMessage(message);
|
|
2805
|
+
sendResponseOnData(response, dataWs);
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
console.warn("[router] Ignoring non-request data frame");
|
|
2809
|
+
}
|
|
2810
|
+
catch (error) {
|
|
2811
|
+
console.error("Error processing data message:", error);
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
dataWs.on("close", (code, reason) => {
|
|
2815
|
+
// Reset backpressure state so reconnect starts fresh
|
|
2816
|
+
dataChannelPaused = false;
|
|
2817
|
+
if (dataChannelDrainTimer) {
|
|
2818
|
+
clearInterval(dataChannelDrainTimer);
|
|
2819
|
+
dataChannelDrainTimer = null;
|
|
2820
|
+
}
|
|
2821
|
+
if (!settled) {
|
|
2822
|
+
failConnection(`data close before ready (${code}: ${reason.toString()})`);
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
handleClose(`data closed (${code}: ${reason.toString()})`);
|
|
2826
|
+
});
|
|
2827
|
+
dataWs.on("error", (error) => {
|
|
2828
|
+
if (!settled) {
|
|
2829
|
+
failConnection(`data ws error: ${error.message}`);
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
console.error("Data WebSocket error:", error.message);
|
|
2833
|
+
});
|
|
2834
|
+
setTimeout(() => {
|
|
2835
|
+
if (!settled) {
|
|
2836
|
+
failConnection("connection timeout");
|
|
2837
|
+
}
|
|
2838
|
+
}, 10000);
|
|
2839
|
+
});
|
|
3029
2840
|
}
|
|
3030
2841
|
async function handleConnectionDrop(reason) {
|
|
3031
2842
|
if (shuttingDown)
|
|
@@ -3042,16 +2853,7 @@ async function handleConnectionDrop(reason) {
|
|
|
3042
2853
|
const base = Math.min(250 * 2 ** (attempt - 1), 30_000);
|
|
3043
2854
|
const delayMs = Math.round(base * (0.8 + Math.random() * 0.4));
|
|
3044
2855
|
try {
|
|
3045
|
-
|
|
3046
|
-
if (!resolved) {
|
|
3047
|
-
console.error("[reconnect] session no longer exists or is finalized");
|
|
3048
|
-
gracefulShutdown();
|
|
3049
|
-
return;
|
|
3050
|
-
}
|
|
3051
|
-
currentSessionCode = resolved.code;
|
|
3052
|
-
currentSessionPassword = resolved.password;
|
|
3053
|
-
currentPrimaryGateway = normalizeGatewayUrl(resolved.primary);
|
|
3054
|
-
currentBackupGateway = resolved.backup ? normalizeGatewayUrl(resolved.backup) : null;
|
|
2856
|
+
currentPrimaryGateway = await getAssignedProxyUrl(currentSessionPassword);
|
|
3055
2857
|
await connectWebSocket();
|
|
3056
2858
|
console.log(`[reconnect] connected via ${activeGatewayUrl}`);
|
|
3057
2859
|
return;
|
|
@@ -3068,14 +2870,6 @@ async function main() {
|
|
|
3068
2870
|
if (EXTRA_PORTS.length > 0) {
|
|
3069
2871
|
console.log(`Extra ports enabled: ${EXTRA_PORTS.join(", ")}`);
|
|
3070
2872
|
}
|
|
3071
|
-
// Detect cloud VM mode
|
|
3072
|
-
cloudJobConfig = await readCloudJobConfig();
|
|
3073
|
-
if (cloudJobConfig?.session_code) {
|
|
3074
|
-
console.log(`[cloud] Running in VM mode (sandbox: ${cloudJobConfig.sandbox_id})`);
|
|
3075
|
-
}
|
|
3076
|
-
if (RUN_ABCD_MODE) {
|
|
3077
|
-
console.log("[review] Running in pinned review session mode (code: abcd)");
|
|
3078
|
-
}
|
|
3079
2873
|
try {
|
|
3080
2874
|
await getCliConfig();
|
|
3081
2875
|
console.log("Checking PTY runtime...");
|
|
@@ -3096,52 +2890,15 @@ async function main() {
|
|
|
3096
2890
|
checkDataChannelBackpressure();
|
|
3097
2891
|
}
|
|
3098
2892
|
});
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
console.log(`[cloud] Connected to session ${session.code} via ${session.primary}`);
|
|
3108
|
-
}
|
|
3109
|
-
else {
|
|
3110
|
-
const savedPairing = await getSavedPairingForRoot(ROOT_DIR);
|
|
3111
|
-
if (savedPairing) {
|
|
3112
|
-
try {
|
|
3113
|
-
const resolved = await resolveSessionByResumeToken(savedPairing.secret);
|
|
3114
|
-
if (resolved) {
|
|
3115
|
-
session = resolved;
|
|
3116
|
-
console.log(`[pairing] Waiting on saved connection for ${savedPairing.hostname}`);
|
|
3117
|
-
}
|
|
3118
|
-
else {
|
|
3119
|
-
await removePairing(savedPairing.secret);
|
|
3120
|
-
session = await createSessionFromManager();
|
|
3121
|
-
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3122
|
-
}
|
|
3123
|
-
}
|
|
3124
|
-
catch {
|
|
3125
|
-
await removePairing(savedPairing.secret);
|
|
3126
|
-
session = await createSessionFromManager();
|
|
3127
|
-
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3128
|
-
}
|
|
3129
|
-
}
|
|
3130
|
-
else {
|
|
3131
|
-
// Public mode: create a new session and show QR code
|
|
3132
|
-
session = await createSessionFromManager();
|
|
3133
|
-
displayQR(normalizeGatewayUrl(session.primary), session.backup ? normalizeGatewayUrl(session.backup) : null, session.code);
|
|
3134
|
-
}
|
|
3135
|
-
}
|
|
3136
|
-
currentPrimaryGateway = normalizeGatewayUrl(session.primary);
|
|
3137
|
-
currentBackupGateway = session.backup ? normalizeGatewayUrl(session.backup) : null;
|
|
3138
|
-
currentSessionPassword = session.password;
|
|
2893
|
+
const qr = await createQrCode();
|
|
2894
|
+
currentSessionCode = qr.code;
|
|
2895
|
+
displayQR(qr.code);
|
|
2896
|
+
const assembled = await assembleWithCode(qr.code);
|
|
2897
|
+
resetReplayBuffer();
|
|
2898
|
+
currentSessionCode = assembled.code;
|
|
2899
|
+
currentSessionPassword = assembled.password;
|
|
2900
|
+
currentPrimaryGateway = await getAssignedProxyUrl(assembled.password);
|
|
3139
2901
|
activeGatewayUrl = currentPrimaryGateway;
|
|
3140
|
-
currentSessionCode = session.code;
|
|
3141
|
-
// Start VM heartbeat in cloud mode (session.password = resumeToken)
|
|
3142
|
-
if (cloudJobConfig?.session_code) {
|
|
3143
|
-
startVmHeartbeat(session.password);
|
|
3144
|
-
}
|
|
3145
2902
|
await connectWebSocket();
|
|
3146
2903
|
}
|
|
3147
2904
|
catch (error) {
|