9remote 0.1.46 → 0.1.49
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 +848 -0
- package/dist/cli.cjs +57 -56
- package/dist/server.cjs +53 -50
- package/dist/ui/assets/index-CWkDNGlQ.js +8 -0
- package/dist/ui/assets/index-dbQMrYNp.css +1 -0
- package/dist/ui/index.html +17 -0
- package/index.js +374 -618
- package/package.json +39 -22
- /package/{scripts → cli/scripts}/install.js +0 -0
- /package/{utils → cli/utils}/apiKey.js +0 -0
- /package/{utils → cli/utils}/cloudflared.js +0 -0
- /package/{utils → cli/utils}/machineId.js +0 -0
- /package/{utils → cli/utils}/state.js +0 -0
- /package/{utils → cli/utils}/token.js +0 -0
- /package/{utils → cli/utils}/updateChecker.js +0 -0
package/cli/index.js
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import inquirer from "inquirer";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import qrcode from "qrcode-terminal";
|
|
6
|
+
import { spawn, execSync } from "child_process";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import dns from "dns";
|
|
12
|
+
import https from "https";
|
|
13
|
+
import { promisify } from "util";
|
|
14
|
+
import { getConsistentMachineId } from "./utils/machineId.js";
|
|
15
|
+
import { generateApiKeyWithMachine } from "./utils/apiKey.js";
|
|
16
|
+
import { loadKey, saveKey, loadState, saveState, clearState } from "./utils/state.js";
|
|
17
|
+
import { createTempKey } from "./utils/token.js";
|
|
18
|
+
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
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parse --skip-update flag
|
|
55
|
+
const skipUpdate = process.argv.includes("--skip-update");
|
|
56
|
+
|
|
57
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
const PROJECT_ROOT = path.resolve(__dirname, "../..");
|
|
59
|
+
// When bundled (dist/cli.cjs), server.cjs is in same dist/ folder
|
|
60
|
+
// When dev (server/cli/index.js), use server/index.js directly
|
|
61
|
+
const STANDALONE_SERVER = path.resolve(__dirname, "../dist/server.cjs");
|
|
62
|
+
const DEV_SERVER = path.resolve(__dirname, "../index.js");
|
|
63
|
+
const WORKER_URL = "https://9remote.cc";
|
|
64
|
+
const SERVER_PORT = 2208;
|
|
65
|
+
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
|
66
|
+
const MAX_RESTART_ATTEMPTS = 10;
|
|
67
|
+
const RESTART_WINDOW_MS = 60000; // 1 minute
|
|
68
|
+
|
|
69
|
+
// Orange color from gitbook (#E68A6E)
|
|
70
|
+
const ORANGE = chalk.rgb(230, 138, 110);
|
|
71
|
+
const ORANGE_DIM = chalk.rgb(200, 120, 95);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get current version from package.json
|
|
75
|
+
*/
|
|
76
|
+
function getVersion() {
|
|
77
|
+
// When bundled, version is injected at build time
|
|
78
|
+
if (typeof __CLI_VERSION__ !== "undefined") {
|
|
79
|
+
return __CLI_VERSION__;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Dev mode: read from package.json
|
|
83
|
+
try {
|
|
84
|
+
const packagePath = path.join(__dirname, "..", "package.json");
|
|
85
|
+
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
|
|
86
|
+
return packageJson.version;
|
|
87
|
+
} catch {
|
|
88
|
+
return "unknown";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Show banner
|
|
94
|
+
*/
|
|
95
|
+
function showBanner() {
|
|
96
|
+
const version = getVersion();
|
|
97
|
+
const width = Math.min(44, process.stdout.columns || 44);
|
|
98
|
+
|
|
99
|
+
console.log("");
|
|
100
|
+
console.log(ORANGE("╔" + "═".repeat(width - 2) + "╗"));
|
|
101
|
+
console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
|
|
102
|
+
|
|
103
|
+
const title = `🚀 9Remote v${version}`;
|
|
104
|
+
const titlePadding = Math.floor((width - 2 - title.length) / 2);
|
|
105
|
+
console.log(
|
|
106
|
+
ORANGE("║") +
|
|
107
|
+
" ".repeat(titlePadding) +
|
|
108
|
+
ORANGE.bold(title) +
|
|
109
|
+
" ".repeat(width - 2 - titlePadding - title.length) +
|
|
110
|
+
ORANGE("║")
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const subtitle = "Remote terminal access from anywhere";
|
|
114
|
+
const subtitlePadding = Math.floor((width - 2 - subtitle.length) / 2);
|
|
115
|
+
console.log(
|
|
116
|
+
ORANGE("║") +
|
|
117
|
+
" ".repeat(subtitlePadding) +
|
|
118
|
+
chalk.gray(subtitle) +
|
|
119
|
+
" ".repeat(width - 2 - subtitlePadding - subtitle.length) +
|
|
120
|
+
ORANGE("║")
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
console.log(ORANGE("║") + " ".repeat(width - 2) + ORANGE("║"));
|
|
124
|
+
console.log(ORANGE("╚" + "═".repeat(width - 2) + "╝"));
|
|
125
|
+
console.log("");
|
|
126
|
+
}
|
|
127
|
+
|
|
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
|
+
/**
|
|
140
|
+
* Helper: Show QR code for connect URL
|
|
141
|
+
*/
|
|
142
|
+
function showQRCode(url, title = "📱 Scan QR to connect:") {
|
|
143
|
+
console.log(ORANGE(`\n${title}`));
|
|
144
|
+
qrcode.generate(url, {
|
|
145
|
+
small: true,
|
|
146
|
+
type: 'terminal',
|
|
147
|
+
margin: 0,
|
|
148
|
+
}, qr => {
|
|
149
|
+
const lines = qr.trim().split('\n');
|
|
150
|
+
console.log(lines.join('\n'));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Helper: Show connection info
|
|
156
|
+
*/
|
|
157
|
+
async function showConnectionInfo(selectedKey, tunnelUrl) {
|
|
158
|
+
const tempKeyData = await createTempKey(selectedKey, WORKER_URL);
|
|
159
|
+
|
|
160
|
+
if (!tempKeyData) {
|
|
161
|
+
console.log(chalk.red("❌ Failed to create temp key"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
|
|
166
|
+
const width = Math.min(44, process.stdout.columns || 55);
|
|
167
|
+
|
|
168
|
+
// Push ready state to UI
|
|
169
|
+
pushUiState({
|
|
170
|
+
step: 3,
|
|
171
|
+
tunnelUrl,
|
|
172
|
+
oneTimeKey: tempKeyData.tempKey,
|
|
173
|
+
qrUrl: connectUrl,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
showQRCode(connectUrl);
|
|
177
|
+
|
|
178
|
+
console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
|
|
179
|
+
|
|
180
|
+
console.log(ORANGE("═".repeat(width)));
|
|
181
|
+
|
|
182
|
+
// App URL
|
|
183
|
+
const appLabel = "App URL";
|
|
184
|
+
const appValue = `${WORKER_URL}/login`;
|
|
185
|
+
console.log(chalk.white(appLabel.padEnd(14)) + chalk.gray(appValue));
|
|
186
|
+
|
|
187
|
+
// One-Time Key
|
|
188
|
+
const keyLabel = "One-Time Key";
|
|
189
|
+
const keyValue = tempKeyData.tempKey;
|
|
190
|
+
console.log(chalk.white(keyLabel.padEnd(14)) + ORANGE.bold(keyValue));
|
|
191
|
+
|
|
192
|
+
// Permanent Key
|
|
193
|
+
const permLabel = "Key";
|
|
194
|
+
const permValue = selectedKey;
|
|
195
|
+
console.log(chalk.white(permLabel.padEnd(14)) + chalk.gray(permValue));
|
|
196
|
+
|
|
197
|
+
console.log(ORANGE("═".repeat(width)));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Kill process on specific port
|
|
202
|
+
*/
|
|
203
|
+
function killProcessOnPort(port) {
|
|
204
|
+
try {
|
|
205
|
+
if (process.platform === "win32") {
|
|
206
|
+
execSync(`for /f "tokens=5" %a in ('netstat -aon ^| findstr :${port}') do taskkill /F /PID %a`, { stdio: "ignore" });
|
|
207
|
+
} else {
|
|
208
|
+
const nullDevice = "/dev/null";
|
|
209
|
+
execSync(`lsof -ti:${port} | xargs kill -9 2>${nullDevice} || true`, { stdio: "ignore" });
|
|
210
|
+
}
|
|
211
|
+
} catch { }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Start server with auto-restart on crash
|
|
216
|
+
*/
|
|
217
|
+
function startServerWithRestart(onReady, onServerCrash) {
|
|
218
|
+
const restartTimes = [];
|
|
219
|
+
let currentProcess = null;
|
|
220
|
+
let isShuttingDown = false;
|
|
221
|
+
let isFirstStart = true;
|
|
222
|
+
|
|
223
|
+
const spawnServer = () => {
|
|
224
|
+
// Only kill port on first start, not on restart
|
|
225
|
+
if (isFirstStart) {
|
|
226
|
+
killProcessOnPort(SERVER_PORT);
|
|
227
|
+
isFirstStart = false;
|
|
228
|
+
} else {
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Use dev server if exists (development), otherwise use standalone (npm package)
|
|
232
|
+
const useDevServer = process.env.NODE_ENV === "development" && fs.existsSync(DEV_SERVER);
|
|
233
|
+
const serverPath = useDevServer ? DEV_SERVER : STANDALONE_SERVER;
|
|
234
|
+
|
|
235
|
+
if (!fs.existsSync(serverPath)) {
|
|
236
|
+
console.error(`❌ Server not found: ${serverPath}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
currentProcess = spawn("node", [serverPath], {
|
|
241
|
+
cwd: path.dirname(serverPath),
|
|
242
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
243
|
+
detached: false,
|
|
244
|
+
env: { ...process.env, PORT: String(SERVER_PORT) }
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
currentProcess.on("exit", (code, signal) => {
|
|
249
|
+
if (isShuttingDown) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check if it's a crash (non-zero exit code or unexpected signal)
|
|
254
|
+
if (code !== 0 || signal) {
|
|
255
|
+
console.log(chalk.red(`\n💥 Server crashed (code: ${code}, signal: ${signal})`));
|
|
256
|
+
|
|
257
|
+
// Check restart limit
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
restartTimes.push(now);
|
|
260
|
+
|
|
261
|
+
// Remove old restart times outside window
|
|
262
|
+
while (restartTimes.length > 0 && restartTimes[0] < now - RESTART_WINDOW_MS) {
|
|
263
|
+
restartTimes.shift();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (restartTimes.length > MAX_RESTART_ATTEMPTS) {
|
|
267
|
+
console.log(chalk.red(`❌ Too many restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`));
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log(chalk.yellow(`🔄 Restarting server... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`));
|
|
272
|
+
console.log(ORANGE_DIM("⚠️ [DEBUG] NOTE: Tunnel connection may be stale - will restart tunnel"));
|
|
273
|
+
|
|
274
|
+
// ✅ Callback để restart cloudflared
|
|
275
|
+
if (onServerCrash) {
|
|
276
|
+
console.log(chalk.yellow("✅ Restarting tunnel connection..."));
|
|
277
|
+
onServerCrash();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Wait a bit before restart
|
|
281
|
+
setTimeout(() => {
|
|
282
|
+
spawnServer();
|
|
283
|
+
}, 1000);
|
|
284
|
+
} else {
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
currentProcess.on("error", (err) => {
|
|
289
|
+
console.log(chalk.red(`❌ Server error: ${err.message}`));
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (onReady) {
|
|
293
|
+
onReady(currentProcess);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
spawnServer();
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
getProcess: () => currentProcess,
|
|
301
|
+
shutdown: () => {
|
|
302
|
+
isShuttingDown = true;
|
|
303
|
+
if (currentProcess) {
|
|
304
|
+
currentProcess.kill();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Helper: Setup exit handler for server processes
|
|
312
|
+
*/
|
|
313
|
+
let exitHandlerRegistered = false;
|
|
314
|
+
|
|
315
|
+
function setupExitHandler(serverManager, tunnelProcess, apiKey) {
|
|
316
|
+
if (exitHandlerRegistered) return;
|
|
317
|
+
exitHandlerRegistered = true;
|
|
318
|
+
|
|
319
|
+
process.on("SIGINT", async () => {
|
|
320
|
+
console.log(chalk.yellow("\n\n🛑 Stopping server..."));
|
|
321
|
+
|
|
322
|
+
serverManager.shutdown();
|
|
323
|
+
tunnelProcess.kill();
|
|
324
|
+
resetRestartCounter();
|
|
325
|
+
clearState();
|
|
326
|
+
|
|
327
|
+
console.log(chalk.green("✅ Server stopped"));
|
|
328
|
+
process.exit(0);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get first non-internal LAN IPv4 address
|
|
334
|
+
*/
|
|
335
|
+
function getLanIp() {
|
|
336
|
+
const interfaces = os.networkInterfaces();
|
|
337
|
+
for (const iface of Object.values(interfaces)) {
|
|
338
|
+
for (const addr of iface) {
|
|
339
|
+
if (addr.family === "IPv4" && !addr.internal) return addr.address;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
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
|
+
/** Push UI state to server via HTTP */
|
|
365
|
+
async function pushUiState(data) {
|
|
366
|
+
try {
|
|
367
|
+
await fetch(`http://localhost:${SERVER_PORT}/api/ui/state`, {
|
|
368
|
+
method: "POST",
|
|
369
|
+
headers: { "Content-Type": "application/json" },
|
|
370
|
+
body: JSON.stringify(data),
|
|
371
|
+
});
|
|
372
|
+
} catch { /* server may not be ready yet */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Helper: Start server and tunnel
|
|
377
|
+
*/
|
|
378
|
+
async function startServerAndTunnel(selectedKey) {
|
|
379
|
+
console.log(ORANGE("\n🚀 Starting server..."));
|
|
380
|
+
pushUiState({ step: 1 });
|
|
381
|
+
|
|
382
|
+
// Kill existing cloudflared process
|
|
383
|
+
try {
|
|
384
|
+
killCloudflared();
|
|
385
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
386
|
+
} catch { }
|
|
387
|
+
|
|
388
|
+
// Reuse existing shortId or generate new one
|
|
389
|
+
const existingState = loadState();
|
|
390
|
+
const shortId = existingState?.shortId || generateShortId();
|
|
391
|
+
|
|
392
|
+
// Create session first
|
|
393
|
+
try {
|
|
394
|
+
const sessionResponse = await fetch(`${WORKER_URL}/api/session/create`, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: { "Content-Type": "application/json" },
|
|
397
|
+
body: JSON.stringify({ apiKey: selectedKey, shortId })
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (!sessionResponse.ok) {
|
|
401
|
+
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)}`));
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const sessionData = await sessionResponse.json();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
console.log(chalk.red(`❌ Failed to create session: ${error.message}`));
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// tunnelProcess declared here so the serverManager crash callback can reference it
|
|
414
|
+
let tunnelProcess = null;
|
|
415
|
+
|
|
416
|
+
// Skip spawning server if already running (e.g. nodemon in dev mode)
|
|
417
|
+
const alreadyRunning = await isServerRunning();
|
|
418
|
+
|
|
419
|
+
// Start server with auto-restart (skip if already running)
|
|
420
|
+
const serverManager = alreadyRunning
|
|
421
|
+
? { 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
|
+
});
|
|
481
|
+
|
|
482
|
+
// Wait for server to start (skip if already running)
|
|
483
|
+
if (!alreadyRunning) await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
484
|
+
|
|
485
|
+
console.log(ORANGE("✅ Creating tunnel..."));
|
|
486
|
+
pushUiState({ step: 2 });
|
|
487
|
+
|
|
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;
|
|
499
|
+
try {
|
|
500
|
+
tunnelData = await createNamedTunnel(selectedKey);
|
|
501
|
+
console.log(ORANGE(`✅ Tunnel ID: ${tunnelData.tunnelId}`));
|
|
502
|
+
} 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) {
|
|
525
|
+
serverManager.shutdown();
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
|
|
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
|
+
console.log(ORANGE(`✅ Tunnel URL: ${tunnelUrl}`));
|
|
550
|
+
const lanIpLog = getLanIp();
|
|
551
|
+
if (lanIpLog) console.log(ORANGE(`✅ Local IP: ${lanIpLog}:${SERVER_PORT} (LAN direct available)`));
|
|
552
|
+
console.log(ORANGE(`✅ Connection established`));
|
|
553
|
+
|
|
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 { }
|
|
567
|
+
|
|
568
|
+
// Save state (persist shortId for reuse on restart)
|
|
569
|
+
saveState({
|
|
570
|
+
apiKey: selectedKey,
|
|
571
|
+
shortId,
|
|
572
|
+
tunnelUrl,
|
|
573
|
+
serverPid: serverManager.getProcess()?.pid,
|
|
574
|
+
tunnelPid: tunnelProcess.pid
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return { serverManager, tunnelProcess, tunnelUrl };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Show main menu
|
|
583
|
+
*/
|
|
584
|
+
async function mainMenu() {
|
|
585
|
+
console.clear();
|
|
586
|
+
showBanner();
|
|
587
|
+
|
|
588
|
+
const { action } = await inquirer.prompt([
|
|
589
|
+
{
|
|
590
|
+
type: "list",
|
|
591
|
+
name: "action",
|
|
592
|
+
message: "Select action:",
|
|
593
|
+
choices: [
|
|
594
|
+
{ name: "🚀 Start Server", value: "start" },
|
|
595
|
+
{ name: "🔑 Manage Key", value: "key" },
|
|
596
|
+
{ name: "❌ Exit", value: "exit" }
|
|
597
|
+
]
|
|
598
|
+
}
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
switch (action) {
|
|
602
|
+
case "start":
|
|
603
|
+
await startServer();
|
|
604
|
+
break;
|
|
605
|
+
case "key":
|
|
606
|
+
await manageKey();
|
|
607
|
+
break;
|
|
608
|
+
case "exit":
|
|
609
|
+
console.log(chalk.gray("Goodbye!"));
|
|
610
|
+
process.exit(0);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Start server with single key
|
|
616
|
+
*/
|
|
617
|
+
async function startServer() {
|
|
618
|
+
const machineId = await getConsistentMachineId();
|
|
619
|
+
let keyData = loadKey();
|
|
620
|
+
|
|
621
|
+
// Auto create key if none exists
|
|
622
|
+
if (!keyData.key) {
|
|
623
|
+
console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
|
|
624
|
+
const { key } = generateApiKeyWithMachine(machineId);
|
|
625
|
+
keyData = saveKey(machineId, key, "Default");
|
|
626
|
+
console.log(chalk.green("✅ Default key created!"));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const result = await startServerAndTunnel(keyData.key);
|
|
630
|
+
if (!result) {
|
|
631
|
+
await inquirer.prompt([{ type: "input", name: "c", message: "Press Enter to go back..." }]);
|
|
632
|
+
await mainMenu();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const { serverManager, tunnelProcess, tunnelUrl } = result;
|
|
637
|
+
|
|
638
|
+
await showConnectionInfo(keyData.key, tunnelUrl);
|
|
639
|
+
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
640
|
+
|
|
641
|
+
// Keep process alive
|
|
642
|
+
await new Promise(() => { });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Manage single key menu
|
|
647
|
+
*/
|
|
648
|
+
async function manageKey() {
|
|
649
|
+
const machineId = await getConsistentMachineId();
|
|
650
|
+
let keyData = loadKey();
|
|
651
|
+
|
|
652
|
+
// Auto create key if none exists
|
|
653
|
+
if (!keyData.key) {
|
|
654
|
+
console.log(chalk.yellow("\n⚠️ No key found. Creating default key..."));
|
|
655
|
+
const { key } = generateApiKeyWithMachine(machineId);
|
|
656
|
+
keyData = saveKey(machineId, key, "Default");
|
|
657
|
+
console.log(chalk.green("✅ Default key created!"));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
console.log(ORANGE("\n🔑 Manage Key"));
|
|
661
|
+
console.log(chalk.gray("━".repeat(30)));
|
|
662
|
+
console.log(chalk.white(`Key: ${keyData.key}`));
|
|
663
|
+
console.log(chalk.gray(`Created: ${keyData.createdAt}\n`));
|
|
664
|
+
|
|
665
|
+
const { action } = await inquirer.prompt([
|
|
666
|
+
{
|
|
667
|
+
type: "list",
|
|
668
|
+
name: "action",
|
|
669
|
+
message: "Action:",
|
|
670
|
+
choices: [
|
|
671
|
+
{ name: "🔐 Create One-Time Key", value: "oneTime" },
|
|
672
|
+
{ name: "🔄 Regenerate Key", value: "regenerate" },
|
|
673
|
+
{ name: chalk.gray("← Back"), value: "back" }
|
|
674
|
+
]
|
|
675
|
+
}
|
|
676
|
+
]);
|
|
677
|
+
|
|
678
|
+
if (action === "oneTime") {
|
|
679
|
+
console.log(chalk.gray("\nCreating one-time key..."));
|
|
680
|
+
const tempKeyData = await createTempKey(keyData.key, WORKER_URL);
|
|
681
|
+
|
|
682
|
+
if (tempKeyData) {
|
|
683
|
+
const connectUrl = `${WORKER_URL}/login?k=${tempKeyData.tempKey}`;
|
|
684
|
+
const width = Math.min(50, process.stdout.columns || 50);
|
|
685
|
+
|
|
686
|
+
showQRCode(connectUrl);
|
|
687
|
+
|
|
688
|
+
console.log(chalk.gray(`\nQR will expire in 30 minutes (one-time use)\n`));
|
|
689
|
+
|
|
690
|
+
console.log(ORANGE("═".repeat(width)));
|
|
691
|
+
|
|
692
|
+
// App URL
|
|
693
|
+
const appLabel = "App URL";
|
|
694
|
+
const appValue = `${WORKER_URL}/login`;
|
|
695
|
+
console.log(chalk.white(appLabel.padEnd(16)) + chalk.gray(appValue));
|
|
696
|
+
|
|
697
|
+
// One-Time Key
|
|
698
|
+
const keyLabel = "One-Time Key";
|
|
699
|
+
const keyValue = tempKeyData.tempKey;
|
|
700
|
+
console.log(chalk.white(keyLabel.padEnd(16)) + ORANGE.bold(keyValue));
|
|
701
|
+
|
|
702
|
+
console.log(ORANGE("═".repeat(width)));
|
|
703
|
+
} else {
|
|
704
|
+
console.log(chalk.red("❌ Failed to create one-time key"));
|
|
705
|
+
}
|
|
706
|
+
await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (action === "regenerate") {
|
|
710
|
+
const { confirm } = await inquirer.prompt([
|
|
711
|
+
{
|
|
712
|
+
type: "confirm",
|
|
713
|
+
name: "confirm",
|
|
714
|
+
message: chalk.yellow("⚠️ This will replace your current key. Continue?"),
|
|
715
|
+
default: false
|
|
716
|
+
}
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
if (confirm) {
|
|
720
|
+
const { key } = generateApiKeyWithMachine(machineId);
|
|
721
|
+
keyData = saveKey(machineId, key, keyData.name);
|
|
722
|
+
|
|
723
|
+
console.log(chalk.green(`\n✅ Key regenerated: ${keyData.key}`));
|
|
724
|
+
await inquirer.prompt([{ type: "input", name: "continue", message: "Press Enter to continue..." }]);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
await mainMenu();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Auto start dev server (--auto flag)
|
|
733
|
+
*/
|
|
734
|
+
async function autoStartDev() {
|
|
735
|
+
showBanner();
|
|
736
|
+
|
|
737
|
+
const machineId = await getConsistentMachineId();
|
|
738
|
+
let keyData = loadKey();
|
|
739
|
+
|
|
740
|
+
// Auto create key if none exists
|
|
741
|
+
if (!keyData.key) {
|
|
742
|
+
console.log(chalk.yellow("⚠️ No key found. Creating default key..."));
|
|
743
|
+
const { key } = generateApiKeyWithMachine(machineId);
|
|
744
|
+
keyData = saveKey(machineId, key, "Default");
|
|
745
|
+
console.log(chalk.green("✅ Default key created!"));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
console.log(chalk.gray(`Using key: ${keyData.key.slice(0, 20)}... (${keyData.name})`));
|
|
749
|
+
|
|
750
|
+
const result = await startServerAndTunnel(keyData.key);
|
|
751
|
+
if (!result) {
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const { serverManager, tunnelProcess, tunnelUrl } = result;
|
|
756
|
+
|
|
757
|
+
await showConnectionInfo(keyData.key, tunnelUrl);
|
|
758
|
+
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
759
|
+
|
|
760
|
+
// Push stats to UI every 5s
|
|
761
|
+
const startTime = Date.now();
|
|
762
|
+
setInterval(() => {
|
|
763
|
+
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
764
|
+
const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
|
|
765
|
+
pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
|
|
766
|
+
}, 5000);
|
|
767
|
+
|
|
768
|
+
// Keep process alive
|
|
769
|
+
await new Promise(() => { });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Check if server is already running on SERVER_PORT
|
|
775
|
+
*/
|
|
776
|
+
async function isServerRunning() {
|
|
777
|
+
try {
|
|
778
|
+
const res = await fetch(`http://localhost:${SERVER_PORT}/api/health`);
|
|
779
|
+
return res.ok;
|
|
780
|
+
} catch {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* UI mode: start server (if not running) + tunnel + open browser
|
|
787
|
+
* Same as "Start Server" in TUI but auto-opens browser
|
|
788
|
+
*/
|
|
789
|
+
async function startUiMode() {
|
|
790
|
+
showBanner();
|
|
791
|
+
|
|
792
|
+
const machineId = await getConsistentMachineId();
|
|
793
|
+
let keyData = loadKey();
|
|
794
|
+
|
|
795
|
+
if (!keyData.key) {
|
|
796
|
+
const { key } = generateApiKeyWithMachine(machineId);
|
|
797
|
+
keyData = saveKey(machineId, key, "Default");
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const result = await startServerAndTunnel(keyData.key);
|
|
801
|
+
if (!result) process.exit(1);
|
|
802
|
+
|
|
803
|
+
const { serverManager, tunnelProcess, tunnelUrl } = result;
|
|
804
|
+
|
|
805
|
+
await showConnectionInfo(keyData.key, tunnelUrl);
|
|
806
|
+
|
|
807
|
+
// Open browser pointing to UI
|
|
808
|
+
const url = `http://localhost:${SERVER_PORT}`;
|
|
809
|
+
const openCmd = process.platform === "darwin" ? "open"
|
|
810
|
+
: process.platform === "win32" ? "start"
|
|
811
|
+
: "xdg-open";
|
|
812
|
+
spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
813
|
+
console.log(chalk.green(`\n🌐 UI ready at ${url}`));
|
|
814
|
+
|
|
815
|
+
setupExitHandler(serverManager, tunnelProcess, keyData.key);
|
|
816
|
+
|
|
817
|
+
// Push stats to UI every 5s
|
|
818
|
+
const startTime = Date.now();
|
|
819
|
+
setInterval(() => {
|
|
820
|
+
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
821
|
+
const h = Math.floor(uptime / 3600), m = Math.floor((uptime % 3600) / 60), s = uptime % 60;
|
|
822
|
+
pushUiState({ uptime: `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` });
|
|
823
|
+
}, 5000);
|
|
824
|
+
|
|
825
|
+
await new Promise(() => { });
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Start app
|
|
829
|
+
async function start() {
|
|
830
|
+
// Check and auto-update (exits if update started)
|
|
831
|
+
const hasUpdate = await checkAndUpdate(skipUpdate);
|
|
832
|
+
if (hasUpdate) return;
|
|
833
|
+
|
|
834
|
+
const command = process.argv[2];
|
|
835
|
+
|
|
836
|
+
if (command === "ui") {
|
|
837
|
+
// UI mode: 9remote ui
|
|
838
|
+
await startUiMode();
|
|
839
|
+
} else if (command === "start" || process.argv.includes("--auto")) {
|
|
840
|
+
// Direct start: 9remote start
|
|
841
|
+
await autoStartDev();
|
|
842
|
+
} else {
|
|
843
|
+
// Menu mode: 9remote
|
|
844
|
+
await mainMenu();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
start().catch(console.error);
|