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 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 { ensureCloudflared, spawnCloudflared, killCloudflared, resetRestartCounter } from "./utils/cloudflared.js";
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: { ...process.env, PORT: String(SERVER_PORT) }
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
- * Helper: Start server and tunnel
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, 1000));
343
+ await new Promise(resolve => setTimeout(resolve, 500));
386
344
  } catch { }
387
345
 
388
- // Reuse existing shortId or generate new one
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, shortId })
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} ${sessionResponse.statusText}`));
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, async () => {
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
- // Wait for server to start (skip if already running)
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("✅ Creating tunnel..."));
371
+ console.log(ORANGE("✅ Starting tunnel..."));
486
372
  pushUiState({ step: 2 });
487
373
 
488
- // Ensure cloudflared binary
489
- try {
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
- tunnelData = await createNamedTunnel(selectedKey);
501
- console.log(ORANGE(`✅ Tunnel ID: ${tunnelData.tunnelId}`));
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 create tunnel: ${error.message}`));
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 lanIpLog = getLanIp();
551
- if (lanIpLog) console.log(ORANGE(`✅ Local IP: ${lanIpLog}:${SERVER_PORT} (LAN direct available)`));
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
- // Update session with tunnelUrl + localIp (worker detects publicIp via CF-Connecting-IP)
555
- const lanIp = lanIpLog;
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 (persist shortId for reuse on restart)
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();
@@ -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