9remote 0.1.52 → 0.1.53
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/cli/index.js +68 -222
- package/cli/utils/cloudflared.js +106 -0
- package/dist/cli.cjs +56 -56
- package/dist/server.cjs +42 -40
- package/dist/ui/assets/index-BIuRs677.js +8 -0
- package/dist/ui/assets/{index-0y2xzwGf.css → index-DhKgENYK.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/index.js +70 -2
- package/package.json +6 -4
- package/dist/ui/assets/index-Cu8zxWgo.js +0 -8
package/cli/index.js
CHANGED
|
@@ -8,48 +8,12 @@ import path from "path";
|
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import os from "os";
|
|
11
|
-
import dns from "dns";
|
|
12
|
-
import https from "https";
|
|
13
|
-
import { promisify } from "util";
|
|
14
11
|
import { getConsistentMachineId } from "./utils/machineId.js";
|
|
15
12
|
import { generateApiKeyWithMachine } from "./utils/apiKey.js";
|
|
16
13
|
import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
|
|
17
14
|
import { createTempKey } from "./utils/token.js";
|
|
18
15
|
import { checkAndUpdate } from "./utils/updateChecker.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
// DNS resolver using Cloudflare — ensures tunnel domains resolve immediately
|
|
22
|
-
const cfResolver = new dns.Resolver();
|
|
23
|
-
cfResolver.setServers(["1.1.1.1", "1.0.0.1"]);
|
|
24
|
-
const cfResolve4 = promisify(cfResolver.resolve4.bind(cfResolver));
|
|
25
|
-
|
|
26
|
-
/** Fetch via IP with correct TLS SNI — bypasses system DNS */
|
|
27
|
-
function fetchWithCfDns(url, timeoutMs = 5000) {
|
|
28
|
-
return new Promise(async (resolve, reject) => {
|
|
29
|
-
try {
|
|
30
|
-
const parsed = new URL(url);
|
|
31
|
-
const [ip] = await cfResolve4(parsed.hostname);
|
|
32
|
-
const req = https.request({
|
|
33
|
-
hostname: ip,
|
|
34
|
-
port: 443,
|
|
35
|
-
path: parsed.pathname + parsed.search,
|
|
36
|
-
method: "GET",
|
|
37
|
-
headers: { host: parsed.hostname },
|
|
38
|
-
servername: parsed.hostname,
|
|
39
|
-
rejectUnauthorized: true
|
|
40
|
-
}, (res) => {
|
|
41
|
-
let body = "";
|
|
42
|
-
res.on("data", d => { body += d; });
|
|
43
|
-
res.on("end", () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body }));
|
|
44
|
-
});
|
|
45
|
-
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error("timeout")); });
|
|
46
|
-
req.on("error", reject);
|
|
47
|
-
req.end();
|
|
48
|
-
} catch (err) {
|
|
49
|
-
reject(err);
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
}
|
|
16
|
+
import { spawnQuickTunnel, killCloudflared, resetRestartCounter } from "./utils/cloudflared.js";
|
|
53
17
|
|
|
54
18
|
// Parse --skip-update flag
|
|
55
19
|
const skipUpdate = process.argv.includes("--skip-update");
|
|
@@ -62,7 +26,6 @@ const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
|
|
|
62
26
|
const DEV_SERVER = path.resolve(__dirname, "../index.js");
|
|
63
27
|
const WORKER_URL = "https://9remote.cc";
|
|
64
28
|
const SERVER_PORT = 2208;
|
|
65
|
-
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
|
66
29
|
const MAX_RESTART_ATTEMPTS = 10;
|
|
67
30
|
const RESTART_WINDOW_MS = 60000; // 1 minute
|
|
68
31
|
|
|
@@ -125,17 +88,6 @@ function showBanner() {
|
|
|
125
88
|
console.log("");
|
|
126
89
|
}
|
|
127
90
|
|
|
128
|
-
/**
|
|
129
|
-
* Generate short random ID for tunnel subdomain
|
|
130
|
-
*/
|
|
131
|
-
function generateShortId() {
|
|
132
|
-
let result = "";
|
|
133
|
-
for (let i = 0; i < 6; i++) {
|
|
134
|
-
result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
|
|
135
|
-
}
|
|
136
|
-
return result;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
91
|
/**
|
|
140
92
|
* Helper: Show QR code for connect URL
|
|
141
93
|
*/
|
|
@@ -165,12 +117,15 @@ async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
|
165
117
|
const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
|
|
166
118
|
const width = Math.min(44, process.stdout.columns || 55);
|
|
167
119
|
|
|
168
|
-
// Push ready state to UI
|
|
120
|
+
// Push ready state to UI (include permanentKey, expiresAt, workerUrl for server-side key generation)
|
|
169
121
|
pushUiState({
|
|
170
122
|
step: 3,
|
|
171
123
|
tunnelUrl,
|
|
172
124
|
oneTimeKey: tempKeyData.tempKey,
|
|
125
|
+
oneTimeKeyExpiresAt: tempKeyData.expiresAt,
|
|
126
|
+
permanentKey: selectedKey,
|
|
173
127
|
qrUrl: connectUrl,
|
|
128
|
+
workerUrl: WORKER_URL,
|
|
174
129
|
});
|
|
175
130
|
|
|
176
131
|
showQRCode(connectUrl);
|
|
@@ -237,11 +192,15 @@ function startServerWithRestart(onReady, onServerCrash) {
|
|
|
237
192
|
process.exit(1);
|
|
238
193
|
}
|
|
239
194
|
|
|
195
|
+
// Strip NODE_ENV=development when running standalone server (production build)
|
|
196
|
+
const spawnEnv = { ...process.env, PORT: String(SERVER_PORT) };
|
|
197
|
+
if (!useDevServer) delete spawnEnv.NODE_ENV;
|
|
198
|
+
|
|
240
199
|
currentProcess = spawn("node", [serverPath], {
|
|
241
200
|
cwd: path.dirname(serverPath),
|
|
242
201
|
stdio: ["ignore", "inherit", "inherit"],
|
|
243
202
|
detached: false,
|
|
244
|
-
env:
|
|
203
|
+
env: spawnEnv,
|
|
245
204
|
});
|
|
246
205
|
|
|
247
206
|
|
|
@@ -342,25 +301,6 @@ function getLanIp() {
|
|
|
342
301
|
return null;
|
|
343
302
|
}
|
|
344
303
|
|
|
345
|
-
/**
|
|
346
|
-
* Helper: Create Named Tunnel via Worker API
|
|
347
|
-
*/
|
|
348
|
-
async function createNamedTunnel(apiKey) {
|
|
349
|
-
const response = await fetch(`${WORKER_URL}/api/tunnel/create`, {
|
|
350
|
-
method: "POST",
|
|
351
|
-
headers: { "Content-Type": "application/json" },
|
|
352
|
-
body: JSON.stringify({ apiKey })
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
if (!response.ok) {
|
|
356
|
-
const error = await response.json();
|
|
357
|
-
throw new Error(error.error || "Failed to create tunnel");
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const data = await response.json();
|
|
361
|
-
return data;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
304
|
/** Push UI state to server via HTTP */
|
|
365
305
|
async function pushUiState(data) {
|
|
366
306
|
try {
|
|
@@ -373,7 +313,25 @@ async function pushUiState(data) {
|
|
|
373
313
|
}
|
|
374
314
|
|
|
375
315
|
/**
|
|
376
|
-
*
|
|
316
|
+
* Update session tunnelUrl on worker + push to UI
|
|
317
|
+
*/
|
|
318
|
+
async function updateTunnelUrl(selectedKey, tunnelUrl) {
|
|
319
|
+
const lanIp = getLanIp();
|
|
320
|
+
try {
|
|
321
|
+
await fetch(`${WORKER_URL}/api/session/update`, {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { "Content-Type": "application/json" },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
apiKey: selectedKey,
|
|
326
|
+
tunnelUrl,
|
|
327
|
+
localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
|
|
328
|
+
})
|
|
329
|
+
});
|
|
330
|
+
} catch { }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Helper: Start server and quick tunnel
|
|
377
335
|
*/
|
|
378
336
|
async function startServerAndTunnel(selectedKey) {
|
|
379
337
|
console.log(ORANGE("\n🚀 Starting server..."));
|
|
@@ -382,193 +340,65 @@ async function startServerAndTunnel(selectedKey) {
|
|
|
382
340
|
// Kill existing cloudflared process
|
|
383
341
|
try {
|
|
384
342
|
killCloudflared();
|
|
385
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
343
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
386
344
|
} catch { }
|
|
387
345
|
|
|
388
|
-
//
|
|
389
|
-
const existingState = loadState();
|
|
390
|
-
const shortId = existingState?.shortId || generateShortId();
|
|
391
|
-
|
|
392
|
-
// Create session first
|
|
346
|
+
// Create session on worker
|
|
393
347
|
try {
|
|
394
348
|
const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
|
|
395
349
|
method: "POST",
|
|
396
350
|
headers: { "Content-Type": "application/json" },
|
|
397
|
-
body: JSON.stringify({ apiKey: selectedKey
|
|
351
|
+
body: JSON.stringify({ apiKey: selectedKey })
|
|
398
352
|
});
|
|
399
|
-
|
|
400
353
|
if (!sessionResponse.ok) {
|
|
401
354
|
const text = await sessionResponse.text();
|
|
402
|
-
console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${
|
|
403
|
-
console.log(chalk.yellow(`Response: ${text.substring(0, 200)}`));
|
|
355
|
+
console.log(chalk.red(`❌ Failed to create session: ${sessionResponse.status} ${text.substring(0, 200)}`));
|
|
404
356
|
return null;
|
|
405
357
|
}
|
|
406
|
-
|
|
407
|
-
const sessionData = await sessionResponse.json();
|
|
408
358
|
} catch (error) {
|
|
409
359
|
console.log(chalk.red(`❌ Failed to create session: ${error.message}`));
|
|
410
360
|
return null;
|
|
411
361
|
}
|
|
412
362
|
|
|
413
|
-
// tunnelProcess declared here so the serverManager crash callback can reference it
|
|
414
|
-
let tunnelProcess = null;
|
|
415
|
-
|
|
416
363
|
// Skip spawning server if already running (e.g. nodemon in dev mode)
|
|
417
364
|
const alreadyRunning = await isServerRunning();
|
|
418
|
-
|
|
419
|
-
// Start server with auto-restart (skip if already running)
|
|
420
365
|
const serverManager = alreadyRunning
|
|
421
366
|
? { getProcess: () => null, shutdown: () => {} }
|
|
422
|
-
: startServerWithRestart(null,
|
|
423
|
-
// Callback khi server crash - đợi server ready rồi gửi SIGHUP
|
|
424
|
-
|
|
425
|
-
if (!tunnelProcess) {
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Wait for server to be ready
|
|
430
|
-
const maxWait = 60000; // 60s
|
|
431
|
-
const checkInterval = 1000; // 1s
|
|
432
|
-
const maxRetries = Math.floor(maxWait / checkInterval);
|
|
433
|
-
let serverReady = false;
|
|
434
|
-
|
|
435
|
-
for (let i = 0; i < maxRetries; i++) {
|
|
436
|
-
try {
|
|
437
|
-
const response = await fetch(`http://localhost:${SERVER_PORT}/api/health`, {
|
|
438
|
-
method: "GET",
|
|
439
|
-
timeout: 2000
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
if (response.ok) {
|
|
443
|
-
const data = await response.json();
|
|
444
|
-
if (data.status === "ok") {
|
|
445
|
-
serverReady = true;
|
|
446
|
-
console.log(chalk.green(`✅ Server ready after ${i + 1}s`));
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
} catch (err) {
|
|
451
|
-
// Server not ready yet
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (i % 5 === 0) {
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (!serverReady) {
|
|
461
|
-
console.log(chalk.red("❌ Server not ready after 60s - skipping tunnel reconnect"));
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Server ready - send SIGHUP to cloudflared to reconnect
|
|
466
|
-
try {
|
|
467
|
-
process.kill(tunnelProcess.pid, "SIGHUP");
|
|
468
|
-
console.log(chalk.green("✅ SIGHUP sent - cloudflared should reconnect"));
|
|
469
|
-
} catch (err) {
|
|
470
|
-
// Fallback: kill and restart
|
|
471
|
-
try {
|
|
472
|
-
tunnelProcess.kill();
|
|
473
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
474
|
-
tunnelProcess = await startTunnel(token);
|
|
475
|
-
console.log(chalk.green("✅ Tunnel restarted"));
|
|
476
|
-
} catch (restartErr) {
|
|
477
|
-
console.log(chalk.red(`❌ Failed to restart tunnel: ${restartErr.message}`));
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
});
|
|
367
|
+
: startServerWithRestart(null, null);
|
|
481
368
|
|
|
482
|
-
|
|
483
|
-
if (!alreadyRunning) await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
369
|
+
if (!alreadyRunning) await new Promise(resolve => setTimeout(resolve, 2000));
|
|
484
370
|
|
|
485
|
-
console.log(ORANGE("✅
|
|
371
|
+
console.log(ORANGE("✅ Starting tunnel..."));
|
|
486
372
|
pushUiState({ step: 2 });
|
|
487
373
|
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
await ensureCloudflared();
|
|
491
|
-
} catch (error) {
|
|
492
|
-
console.log(chalk.red(`❌ Failed to install cloudflared: ${error.message}`));
|
|
493
|
-
serverManager.shutdown();
|
|
494
|
-
return null;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Create Named Tunnel via Worker API
|
|
498
|
-
let tunnelData;
|
|
374
|
+
// Spawn quick tunnel — URL comes directly from cloudflared stdout
|
|
375
|
+
let tunnelProcess, tunnelUrl;
|
|
499
376
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
377
|
+
const result = await spawnQuickTunnel(SERVER_PORT, async (newUrl) => {
|
|
378
|
+
// URL rotated — update worker + UI
|
|
379
|
+
console.log(ORANGE(`🔄 Tunnel URL rotated: ${newUrl}`));
|
|
380
|
+
await updateTunnelUrl(selectedKey, newUrl);
|
|
381
|
+
pushUiState({ tunnelUrl: newUrl });
|
|
382
|
+
});
|
|
383
|
+
tunnelProcess = result.child;
|
|
384
|
+
tunnelUrl = result.tunnelUrl;
|
|
502
385
|
} catch (error) {
|
|
503
|
-
console.log(chalk.red(`❌ Failed to
|
|
504
|
-
serverManager.shutdown();
|
|
505
|
-
return null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const { token, hostname: tunnelUrl } = tunnelData;
|
|
509
|
-
|
|
510
|
-
// Spawn cloudflared with token and auto-restart callback
|
|
511
|
-
console.log(ORANGE("✅ Starting tunnel..."));
|
|
512
|
-
|
|
513
|
-
const startTunnel = async (tunnelToken) => {
|
|
514
|
-
try {
|
|
515
|
-
tunnelProcess = await spawnCloudflared(tunnelToken, startTunnel);
|
|
516
|
-
return tunnelProcess;
|
|
517
|
-
} catch (error) {
|
|
518
|
-
console.log(chalk.red(`❌ Failed to start cloudflared: ${error.message}`));
|
|
519
|
-
return null;
|
|
520
|
-
}
|
|
521
|
-
};
|
|
522
|
-
|
|
523
|
-
tunnelProcess = await startTunnel(token);
|
|
524
|
-
if (!tunnelProcess) {
|
|
386
|
+
console.log(chalk.red(`❌ Failed to start tunnel: ${error.message}`));
|
|
525
387
|
serverManager.shutdown();
|
|
526
388
|
return null;
|
|
527
389
|
}
|
|
528
390
|
|
|
529
|
-
// Verify tunnel reachable via Cloudflare DNS + https.request (bypasses system DNS)
|
|
530
|
-
const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
531
|
-
let tunnelReady = false;
|
|
532
|
-
for (let i = 0; i < 30; i++) {
|
|
533
|
-
try {
|
|
534
|
-
const res = await fetchWithCfDns(`${tunnelUrl}/api/health`);
|
|
535
|
-
if (res.ok) { tunnelReady = true; break; }
|
|
536
|
-
} catch { }
|
|
537
|
-
process.stdout.write(`\r Verifying tunnel ${spinners[i % spinners.length]} (${i * 2}s)`);
|
|
538
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
539
|
-
}
|
|
540
|
-
process.stdout.write("\r" + " ".repeat(40) + "\r");
|
|
541
|
-
|
|
542
|
-
if (!tunnelReady) {
|
|
543
|
-
console.log(chalk.red("❌ Tunnel not reachable from outside"));
|
|
544
|
-
serverManager.shutdown();
|
|
545
|
-
tunnelProcess.kill();
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
391
|
console.log(ORANGE(`✅ Tunnel URL: ${tunnelUrl}`));
|
|
550
|
-
const
|
|
551
|
-
if (
|
|
392
|
+
const lanIp = getLanIp();
|
|
393
|
+
if (lanIp) console.log(ORANGE(`✅ Local IP: ${lanIp}:${SERVER_PORT} (LAN direct available)`));
|
|
552
394
|
console.log(ORANGE(`✅ Connection established`));
|
|
553
395
|
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
try {
|
|
557
|
-
await fetch(`${WORKER_URL}/api/session/update`, {
|
|
558
|
-
method: "POST",
|
|
559
|
-
headers: { "Content-Type": "application/json" },
|
|
560
|
-
body: JSON.stringify({
|
|
561
|
-
apiKey: selectedKey,
|
|
562
|
-
tunnelUrl,
|
|
563
|
-
localIp: lanIp ? `${lanIp}:${SERVER_PORT}` : null
|
|
564
|
-
})
|
|
565
|
-
});
|
|
566
|
-
} catch { }
|
|
396
|
+
// Save tunnelUrl to worker DB
|
|
397
|
+
await updateTunnelUrl(selectedKey, tunnelUrl);
|
|
567
398
|
|
|
568
|
-
// Save state
|
|
399
|
+
// Save local state
|
|
569
400
|
saveState({
|
|
570
401
|
apiKey: selectedKey,
|
|
571
|
-
shortId,
|
|
572
402
|
tunnelUrl,
|
|
573
403
|
serverPid: serverManager.getProcess()?.pid,
|
|
574
404
|
tunnelPid: tunnelProcess.pid
|
|
@@ -756,6 +586,7 @@ async function autoStartDev() {
|
|
|
756
586
|
|
|
757
587
|
await showConnectionInfo(keyData.key, tunnelUrl);
|
|
758
588
|
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
589
|
+
setupKeyRegenListener();
|
|
759
590
|
|
|
760
591
|
// Push stats to UI every 5s
|
|
761
592
|
const startTime = Date.now();
|
|
@@ -770,6 +601,20 @@ async function autoStartDev() {
|
|
|
770
601
|
}
|
|
771
602
|
|
|
772
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Listen for key regen request from server UI
|
|
606
|
+
*/
|
|
607
|
+
function setupKeyRegenListener() {
|
|
608
|
+
process.on("9remote:regenerate-key", async () => {
|
|
609
|
+
const machineId = await getConsistentMachineId();
|
|
610
|
+
const { key } = generateApiKeyWithMachine(machineId);
|
|
611
|
+
const existing = loadKey();
|
|
612
|
+
saveKey(machineId, key, existing?.name || "Default");
|
|
613
|
+
pushUiState({ permanentKey: key });
|
|
614
|
+
console.log(chalk.green(`✅ Key regenerated: ${key}`));
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
773
618
|
/**
|
|
774
619
|
* Check if server is already running on SERVER_PORT
|
|
775
620
|
*/
|
|
@@ -813,6 +658,7 @@ async function startUiMode() {
|
|
|
813
658
|
console.log(chalk.green(`\n🌐 UI ready at ${url}`));
|
|
814
659
|
|
|
815
660
|
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
661
|
+
setupKeyRegenListener();
|
|
816
662
|
|
|
817
663
|
// Push stats to UI every 5s
|
|
818
664
|
const startTime = Date.now();
|
package/cli/utils/cloudflared.js
CHANGED
|
@@ -163,6 +163,112 @@ const LOG_IGNORE = [
|
|
|
163
163
|
"Updated to new configuration"
|
|
164
164
|
];
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Parse trycloudflare.com URL from cloudflared log output
|
|
168
|
+
*/
|
|
169
|
+
function parseQuickTunnelUrl(message) {
|
|
170
|
+
const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi;
|
|
171
|
+
const candidates = [];
|
|
172
|
+
for (const match of message.matchAll(regex)) {
|
|
173
|
+
if (match[1] === "api") continue;
|
|
174
|
+
candidates.push(`https://${match[1]}.trycloudflare.com`);
|
|
175
|
+
}
|
|
176
|
+
return candidates.length ? candidates[candidates.length - 1] : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn cloudflared quick tunnel (no account needed)
|
|
181
|
+
* @param {number} localPort - Local port to tunnel
|
|
182
|
+
* @param {Function} onUrlUpdate - Called when URL changes after initial connect
|
|
183
|
+
* @returns {Promise<{child, tunnelUrl}>}
|
|
184
|
+
*/
|
|
185
|
+
export async function spawnQuickTunnel(localPort, onUrlUpdate = null) {
|
|
186
|
+
const binaryPath = await ensureCloudflared();
|
|
187
|
+
|
|
188
|
+
// Use temp config to avoid conflicting with ~/.cloudflared/config.yml
|
|
189
|
+
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "9remote-quick-"));
|
|
190
|
+
const configPath = path.join(configDir, "config.yml");
|
|
191
|
+
fs.writeFileSync(configPath, "# quick-tunnel\n", "utf8");
|
|
192
|
+
|
|
193
|
+
let cleaned = false;
|
|
194
|
+
const cleanup = () => {
|
|
195
|
+
if (cleaned) return;
|
|
196
|
+
cleaned = true;
|
|
197
|
+
try { fs.rmSync(configDir, { recursive: true, force: true }); } catch { }
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const child = spawn(
|
|
201
|
+
binaryPath,
|
|
202
|
+
["tunnel", "--url", `http://localhost:${localPort}`, "--config", configPath, "--no-autoupdate"],
|
|
203
|
+
{ detached: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
fs.writeFileSync(PID_FILE, child.pid.toString());
|
|
207
|
+
isIntentionalShutdown = false;
|
|
208
|
+
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
let resolved = false;
|
|
211
|
+
let lastUrl = null;
|
|
212
|
+
|
|
213
|
+
const timeout = setTimeout(() => {
|
|
214
|
+
if (resolved) return;
|
|
215
|
+
resolved = true;
|
|
216
|
+
cleanup();
|
|
217
|
+
reject(new Error("Quick tunnel timed out after 90s"));
|
|
218
|
+
}, 90000);
|
|
219
|
+
|
|
220
|
+
const handleLog = (data) => {
|
|
221
|
+
const msg = data.toString();
|
|
222
|
+
const tunnelUrl = parseQuickTunnelUrl(msg);
|
|
223
|
+
if (!tunnelUrl) return;
|
|
224
|
+
|
|
225
|
+
if (!resolved) {
|
|
226
|
+
resolved = true;
|
|
227
|
+
lastUrl = tunnelUrl;
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
cleanup();
|
|
230
|
+
resolve({ child, tunnelUrl });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// URL rotated after initial connect — notify caller
|
|
235
|
+
if (tunnelUrl !== lastUrl) {
|
|
236
|
+
lastUrl = tunnelUrl;
|
|
237
|
+
onUrlUpdate?.(tunnelUrl);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
child.stdout.on("data", handleLog);
|
|
242
|
+
child.stderr.on("data", handleLog);
|
|
243
|
+
|
|
244
|
+
child.on("error", (err) => {
|
|
245
|
+
if (resolved) return;
|
|
246
|
+
resolved = true;
|
|
247
|
+
clearTimeout(timeout);
|
|
248
|
+
cleanup();
|
|
249
|
+
reject(err);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
child.on("exit", (code) => {
|
|
253
|
+
cleanup();
|
|
254
|
+
if (!resolved) {
|
|
255
|
+
resolved = true;
|
|
256
|
+
clearTimeout(timeout);
|
|
257
|
+
reject(new Error(`cloudflared exited with code ${code}`));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (!isIntentionalShutdown && restartCallback) {
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
restartTimes.push(now);
|
|
263
|
+
restartTimes = restartTimes.filter(t => t > now - RESTART_WINDOW_MS);
|
|
264
|
+
if (restartTimes.length <= MAX_RESTART_ATTEMPTS) {
|
|
265
|
+
setTimeout(() => restartCallback(localPort), 2000);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
166
272
|
/**
|
|
167
273
|
* Spawn cloudflared tunnel
|
|
168
274
|
* @param {string} tunnelToken
|