9remote 2.0.2 → 2.0.8
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/cli.cjs +1 -1
- package/dist/install.cjs +2 -0
- package/dist/ptyDaemon.cjs +1 -1
- package/dist/server.cjs +1 -1
- package/dist/ui/assets/{index-COWVKicT.css → index-BMHG73CL.css} +1 -1
- package/dist/ui/assets/index-Bg86Demx.js +8 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -6
- package/cli/index.js +0 -1330
- package/cli/scripts/install.js +0 -19
- package/cli/utils/apiKey.js +0 -77
- package/cli/utils/assets/trayIcon.ico +0 -0
- package/cli/utils/cloudflared.js +0 -493
- package/cli/utils/machineId.js +0 -22
- package/cli/utils/permissions.js +0 -45
- package/cli/utils/pids.js +0 -114
- package/cli/utils/state.js +0 -115
- package/cli/utils/token.js +0 -32
- package/cli/utils/tray.js +0 -251
- package/cli/utils/tui.js +0 -445
- package/cli/utils/updateChecker.js +0 -358
- package/dist/ui/assets/index-BWfJSBGG.js +0 -8
- package/index.js +0 -275
- package/lib/constants.js +0 -64
- package/lib/deviceApproval.js +0 -152
- package/lib/router.js +0 -134
- package/lib/socketio.js +0 -240
package/cli/scripts/install.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Postinstall: Check robotjs installation status
|
|
5
|
-
* robotjs is now in optionalDependencies - npm handles installation
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
async function checkRobotjs() {
|
|
9
|
-
try {
|
|
10
|
-
require.resolve("@hurdlegroup/robotjs");
|
|
11
|
-
console.log("✅ robotjs installed (Remote desktop available)");
|
|
12
|
-
} catch {
|
|
13
|
-
console.log("ℹ️ robotjs not available (Remote desktop disabled)");
|
|
14
|
-
console.log(" This is normal on headless Linux systems.");
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Run and always exit successfully
|
|
19
|
-
checkRobotjs().then(() => process.exit(0)).catch(() => process.exit(0));
|
package/cli/utils/apiKey.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
|
-
|
|
3
|
-
const API_KEY_SECRET = process.env.API_KEY_SECRET || "9remote-api-key-secret";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Generate 4-char random keyId
|
|
7
|
-
*/
|
|
8
|
-
function generateKeyId() {
|
|
9
|
-
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
10
|
-
let result = "";
|
|
11
|
-
for (let i = 0; i < 4; i++) {
|
|
12
|
-
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
13
|
-
}
|
|
14
|
-
return result;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Generate CRC (6-char HMAC)
|
|
19
|
-
*/
|
|
20
|
-
function generateCrc(machineId, keyId) {
|
|
21
|
-
return crypto
|
|
22
|
-
.createHmac("sha256", API_KEY_SECRET)
|
|
23
|
-
.update(machineId + keyId)
|
|
24
|
-
.digest("hex")
|
|
25
|
-
.slice(0, 6);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Generate API key with machineId embedded
|
|
30
|
-
* Format: sk-{machineId8}-{keyId4}-{crc6}
|
|
31
|
-
* @param {string} machineId - machine ID (uses first 8 chars)
|
|
32
|
-
* @returns {{ key: string, keyId: string }}
|
|
33
|
-
*/
|
|
34
|
-
export function generateApiKeyWithMachine(machineId) {
|
|
35
|
-
const shortId = machineId.slice(0, 8);
|
|
36
|
-
const keyId = generateKeyId();
|
|
37
|
-
const crc = generateCrc(shortId, keyId);
|
|
38
|
-
const key = `sk-${shortId}-${keyId}-${crc}`;
|
|
39
|
-
return { key, keyId };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Parse API key and extract machineId + keyId
|
|
44
|
-
* Format: sk-{machineId}-{keyId}-{crc8}
|
|
45
|
-
* @param {string} apiKey
|
|
46
|
-
* @returns {{ machineId: string, keyId: string } | null}
|
|
47
|
-
*/
|
|
48
|
-
export function parseApiKey(apiKey) {
|
|
49
|
-
if (!apiKey || !apiKey.startsWith("sk-")) return null;
|
|
50
|
-
|
|
51
|
-
const parts = apiKey.split("-");
|
|
52
|
-
|
|
53
|
-
if (parts.length === 4) {
|
|
54
|
-
const [, machineId, keyId, crc] = parts;
|
|
55
|
-
|
|
56
|
-
const expectedCrc = generateCrc(machineId, keyId);
|
|
57
|
-
if (crc !== expectedCrc) return null;
|
|
58
|
-
|
|
59
|
-
return { machineId, keyId };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Verify API key CRC — supports both old (16+6+8) and new (8+4+6) format
|
|
67
|
-
*/
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Verify API key CRC
|
|
71
|
-
* @param {string} apiKey
|
|
72
|
-
* @returns {boolean}
|
|
73
|
-
*/
|
|
74
|
-
export function verifyApiKeyCrc(apiKey) {
|
|
75
|
-
const parsed = parseApiKey(apiKey);
|
|
76
|
-
return parsed !== null;
|
|
77
|
-
}
|
|
Binary file
|
package/cli/utils/cloudflared.js
DELETED
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import https from "https";
|
|
4
|
-
import os from "os";
|
|
5
|
-
import { execSync, spawn } from "child_process";
|
|
6
|
-
import { writePid, readPid, clearPid } from "./pids.js";
|
|
7
|
-
|
|
8
|
-
// Network change detection
|
|
9
|
-
let networkMonitorInterval = null;
|
|
10
|
-
let lastNetworkState = null;
|
|
11
|
-
let currentTunnelToken = null;
|
|
12
|
-
|
|
13
|
-
const BIN_DIR = path.join(os.homedir(), ".9remote", "bin");
|
|
14
|
-
const BINARY_NAME = "cloudflared";
|
|
15
|
-
const IS_WINDOWS = os.platform() === "win32";
|
|
16
|
-
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
|
17
|
-
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
|
|
18
|
-
// Legacy PID file — kept for one-time cleanup of installs from older versions
|
|
19
|
-
const LEGACY_PID_FILE = path.join(os.homedir(), ".9remote", "cloudflared.pid");
|
|
20
|
-
|
|
21
|
-
// Track intentional shutdown to suppress exit logs
|
|
22
|
-
let isIntentionalShutdown = false;
|
|
23
|
-
|
|
24
|
-
// Auto-restart configuration
|
|
25
|
-
const MAX_RESTART_ATTEMPTS = 5;
|
|
26
|
-
const RESTART_WINDOW_MS = 60000; // 1 minute
|
|
27
|
-
let restartTimes = [];
|
|
28
|
-
let restartCallback = null;
|
|
29
|
-
|
|
30
|
-
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Platform mappings for cloudflared
|
|
34
|
-
*/
|
|
35
|
-
const PLATFORM_MAPPINGS = {
|
|
36
|
-
darwin: {
|
|
37
|
-
x64: "cloudflared-darwin-amd64.tgz",
|
|
38
|
-
arm64: "cloudflared-darwin-amd64.tgz"
|
|
39
|
-
},
|
|
40
|
-
win32: {
|
|
41
|
-
x64: "cloudflared-windows-amd64.exe"
|
|
42
|
-
},
|
|
43
|
-
linux: {
|
|
44
|
-
x64: "cloudflared-linux-amd64",
|
|
45
|
-
arm64: "cloudflared-linux-arm64"
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get download URL
|
|
51
|
-
*/
|
|
52
|
-
function getDownloadUrl() {
|
|
53
|
-
const platform = os.platform();
|
|
54
|
-
const arch = os.arch();
|
|
55
|
-
|
|
56
|
-
const platformMapping = PLATFORM_MAPPINGS[platform];
|
|
57
|
-
if (!platformMapping) {
|
|
58
|
-
throw new Error(`Unsupported platform: ${platform}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const binaryName = platformMapping[arch];
|
|
62
|
-
if (!binaryName) {
|
|
63
|
-
throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return `${GITHUB_BASE_URL}/${binaryName}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Emit progress at most every N ms to avoid render thrash
|
|
70
|
-
const PROGRESS_THROTTLE_MS = 150;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Download file from URL with progress tracking
|
|
74
|
-
* @param {string} url
|
|
75
|
-
* @param {string} dest
|
|
76
|
-
* @param {(percent: number) => void} [onProgress]
|
|
77
|
-
*/
|
|
78
|
-
async function downloadFile(url, dest, onProgress) {
|
|
79
|
-
return new Promise((resolve, reject) => {
|
|
80
|
-
const file = fs.createWriteStream(dest);
|
|
81
|
-
|
|
82
|
-
https.get(url, (response) => {
|
|
83
|
-
if ([301, 302].includes(response.statusCode)) {
|
|
84
|
-
file.close();
|
|
85
|
-
fs.unlinkSync(dest);
|
|
86
|
-
downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (response.statusCode !== 200) {
|
|
91
|
-
file.close();
|
|
92
|
-
fs.unlinkSync(dest);
|
|
93
|
-
reject(new Error(`Download failed with status ${response.statusCode}`));
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const total = parseInt(response.headers["content-length"] || "0", 10);
|
|
98
|
-
let received = 0;
|
|
99
|
-
let lastEmit = 0;
|
|
100
|
-
let lastPercent = -1;
|
|
101
|
-
|
|
102
|
-
response.on("data", (chunk) => {
|
|
103
|
-
received += chunk.length;
|
|
104
|
-
if (!onProgress || !total) return;
|
|
105
|
-
const now = Date.now();
|
|
106
|
-
const percent = Math.min(100, Math.floor((received / total) * 100));
|
|
107
|
-
if (percent !== lastPercent && now - lastEmit >= PROGRESS_THROTTLE_MS) {
|
|
108
|
-
lastEmit = now;
|
|
109
|
-
lastPercent = percent;
|
|
110
|
-
onProgress(percent);
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
response.pipe(file);
|
|
115
|
-
|
|
116
|
-
file.on("finish", () => {
|
|
117
|
-
if (onProgress && total) onProgress(100);
|
|
118
|
-
file.close(() => resolve(dest));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
file.on("error", (err) => {
|
|
122
|
-
file.close();
|
|
123
|
-
fs.unlinkSync(dest);
|
|
124
|
-
reject(err);
|
|
125
|
-
});
|
|
126
|
-
}).on("error", (err) => {
|
|
127
|
-
file.close();
|
|
128
|
-
if (fs.existsSync(dest)) fs.unlinkSync(dest);
|
|
129
|
-
reject(err);
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Ensure cloudflared binary exists
|
|
136
|
-
* @param {(progress: { phase: "download" | "extract", percent?: number }) => void} [onProgress]
|
|
137
|
-
*/
|
|
138
|
-
export async function ensureCloudflared(onProgress) {
|
|
139
|
-
if (!fs.existsSync(BIN_DIR)) {
|
|
140
|
-
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (fs.existsSync(BIN_PATH)) {
|
|
144
|
-
if (!IS_WINDOWS) {
|
|
145
|
-
fs.chmodSync(BIN_PATH, "755");
|
|
146
|
-
}
|
|
147
|
-
return BIN_PATH;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const url = getDownloadUrl();
|
|
151
|
-
const isArchive = url.endsWith(".tgz");
|
|
152
|
-
const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH;
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
onProgress?.({ phase: "download", percent: 0 });
|
|
156
|
-
await downloadFile(url, downloadDest, (percent) => {
|
|
157
|
-
onProgress?.({ phase: "download", percent });
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
if (isArchive) {
|
|
161
|
-
onProgress?.({ phase: "extract" });
|
|
162
|
-
execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true });
|
|
163
|
-
fs.unlinkSync(downloadDest);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!IS_WINDOWS) {
|
|
167
|
-
fs.chmodSync(BIN_PATH, "755");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return BIN_PATH;
|
|
171
|
-
} catch (error) {
|
|
172
|
-
console.error("❌ Failed to download cloudflared:", error.message);
|
|
173
|
-
throw error;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Log patterns to filter cloudflared output
|
|
178
|
-
const LOG_IGNORE = [
|
|
179
|
-
"INF Starting tunnel",
|
|
180
|
-
"INF Version",
|
|
181
|
-
"GOOS:",
|
|
182
|
-
"Settings:",
|
|
183
|
-
"Autoupdate frequency",
|
|
184
|
-
"Generated Connector",
|
|
185
|
-
"Initial protocol",
|
|
186
|
-
"ICMP proxy",
|
|
187
|
-
"Created ICMP",
|
|
188
|
-
"Starting metrics server",
|
|
189
|
-
"curve preferences",
|
|
190
|
-
"Updated to new configuration"
|
|
191
|
-
];
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Parse trycloudflare.com URL from cloudflared log output
|
|
195
|
-
*/
|
|
196
|
-
function parseQuickTunnelUrl(message) {
|
|
197
|
-
const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi;
|
|
198
|
-
const candidates = [];
|
|
199
|
-
for (const match of message.matchAll(regex)) {
|
|
200
|
-
if (match[1] === "api") continue;
|
|
201
|
-
candidates.push(`https://${match[1]}.trycloudflare.com`);
|
|
202
|
-
}
|
|
203
|
-
return candidates.length ? candidates[candidates.length - 1] : null;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Spawn cloudflared quick tunnel (no account needed)
|
|
208
|
-
* @param {number} localPort - Local port to tunnel
|
|
209
|
-
* @param {Function} onUrlUpdate - Called when URL changes after initial connect
|
|
210
|
-
* @returns {Promise<{child, tunnelUrl}>}
|
|
211
|
-
*/
|
|
212
|
-
export async function spawnQuickTunnel(localPort, onUrlUpdate = null) {
|
|
213
|
-
const binaryPath = await ensureCloudflared();
|
|
214
|
-
|
|
215
|
-
// Use temp config to avoid conflicting with ~/.cloudflared/config.yml
|
|
216
|
-
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "9remote-quick-"));
|
|
217
|
-
const configPath = path.join(configDir, "config.yml");
|
|
218
|
-
fs.writeFileSync(configPath, "# quick-tunnel\n", "utf8");
|
|
219
|
-
|
|
220
|
-
let cleaned = false;
|
|
221
|
-
const cleanup = () => {
|
|
222
|
-
if (cleaned) return;
|
|
223
|
-
cleaned = true;
|
|
224
|
-
try { fs.rmSync(configDir, { recursive: true, force: true }); } catch { }
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
const child = spawn(
|
|
228
|
-
binaryPath,
|
|
229
|
-
["tunnel", "--url", `http://localhost:${localPort}`, "--config", configPath, "--no-autoupdate", "--protocol", "http2"],
|
|
230
|
-
{ detached: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
writePid("cloudflared", child.pid);
|
|
234
|
-
isIntentionalShutdown = false;
|
|
235
|
-
|
|
236
|
-
return new Promise((resolve, reject) => {
|
|
237
|
-
let resolved = false;
|
|
238
|
-
let lastUrl = null;
|
|
239
|
-
|
|
240
|
-
const timeout = setTimeout(() => {
|
|
241
|
-
if (resolved) return;
|
|
242
|
-
resolved = true;
|
|
243
|
-
cleanup();
|
|
244
|
-
reject(new Error("Quick tunnel timed out after 90s"));
|
|
245
|
-
}, 90000);
|
|
246
|
-
|
|
247
|
-
const handleLog = (data) => {
|
|
248
|
-
const msg = data.toString();
|
|
249
|
-
const tunnelUrl = parseQuickTunnelUrl(msg);
|
|
250
|
-
if (!tunnelUrl) return;
|
|
251
|
-
|
|
252
|
-
if (!resolved) {
|
|
253
|
-
resolved = true;
|
|
254
|
-
lastUrl = tunnelUrl;
|
|
255
|
-
clearTimeout(timeout);
|
|
256
|
-
cleanup();
|
|
257
|
-
resolve({ child, tunnelUrl });
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// URL rotated after initial connect — notify caller
|
|
262
|
-
if (tunnelUrl !== lastUrl) {
|
|
263
|
-
lastUrl = tunnelUrl;
|
|
264
|
-
onUrlUpdate?.(tunnelUrl);
|
|
265
|
-
}
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
child.stdout.on("data", handleLog);
|
|
269
|
-
child.stderr.on("data", handleLog);
|
|
270
|
-
|
|
271
|
-
child.on("error", (err) => {
|
|
272
|
-
if (resolved) return;
|
|
273
|
-
resolved = true;
|
|
274
|
-
clearTimeout(timeout);
|
|
275
|
-
cleanup();
|
|
276
|
-
reject(err);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
child.on("exit", (code) => {
|
|
280
|
-
cleanup();
|
|
281
|
-
if (!resolved) {
|
|
282
|
-
resolved = true;
|
|
283
|
-
clearTimeout(timeout);
|
|
284
|
-
reject(new Error(`cloudflared exited with code ${code}`));
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (!isIntentionalShutdown && restartCallback) {
|
|
288
|
-
const now = Date.now();
|
|
289
|
-
restartTimes.push(now);
|
|
290
|
-
restartTimes = restartTimes.filter(t => t > now - RESTART_WINDOW_MS);
|
|
291
|
-
if (restartTimes.length <= MAX_RESTART_ATTEMPTS) {
|
|
292
|
-
setTimeout(() => restartCallback(localPort), 2000);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Spawn cloudflared tunnel
|
|
301
|
-
* @param {string} tunnelToken
|
|
302
|
-
* @param {Function} onRestart - Callback when tunnel needs restart
|
|
303
|
-
* @returns {ChildProcess}
|
|
304
|
-
*/
|
|
305
|
-
export async function spawnCloudflared(tunnelToken, onRestart = null) {
|
|
306
|
-
const binaryPath = await ensureCloudflared();
|
|
307
|
-
|
|
308
|
-
// Store restart callback and token for network change restart
|
|
309
|
-
if (onRestart) {
|
|
310
|
-
restartCallback = onRestart;
|
|
311
|
-
}
|
|
312
|
-
currentTunnelToken = tunnelToken;
|
|
313
|
-
|
|
314
|
-
const child = spawn(binaryPath, ["tunnel", "run", "--token", tunnelToken], {
|
|
315
|
-
detached: false,
|
|
316
|
-
windowsHide: true,
|
|
317
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
isIntentionalShutdown = false;
|
|
321
|
-
console.log(`✅ Cloudflared spawned with PID: ${child.pid}`);
|
|
322
|
-
|
|
323
|
-
// Wait for 4 connections before resolving (tunnel is truly ready)
|
|
324
|
-
await new Promise((resolve, reject) => {
|
|
325
|
-
let connectionCount = 0;
|
|
326
|
-
let resolved = false;
|
|
327
|
-
const timeout = setTimeout(() => {
|
|
328
|
-
if (!resolved) { resolved = true; resolve(child); }
|
|
329
|
-
}, 90000);
|
|
330
|
-
|
|
331
|
-
const handleLog = (data) => {
|
|
332
|
-
const msg = data.toString().trim();
|
|
333
|
-
if (LOG_IGNORE.some(pattern => msg.includes(pattern))) return;
|
|
334
|
-
if (isIntentionalShutdown) return;
|
|
335
|
-
if (msg.includes("Registered tunnel connection")) {
|
|
336
|
-
connectionCount++;
|
|
337
|
-
if (connectionCount <= 4) {
|
|
338
|
-
process.stdout.write(`\r ✔ Connection ${connectionCount}/4 established`);
|
|
339
|
-
if (connectionCount === 4) {
|
|
340
|
-
process.stdout.write("\n");
|
|
341
|
-
if (!resolved) { resolved = true; clearTimeout(timeout); resolve(child); }
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
child.stdout.on("data", handleLog);
|
|
349
|
-
child.stderr.on("data", handleLog);
|
|
350
|
-
child.on("error", (err) => { if (!resolved) { resolved = true; clearTimeout(timeout); reject(err); } });
|
|
351
|
-
child.on("exit", (code) => { if (!resolved) { resolved = true; clearTimeout(timeout); reject(new Error(`cloudflared exited with code ${code}`)); } });
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
child.on("error", (error) => {
|
|
355
|
-
console.error("❌ cloudflared error:", error);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
child.on("exit", (code, signal) => {
|
|
359
|
-
console.log(`⚠️ Cloudflared process exited (code: ${code}, signal: ${signal}, intentional: ${isIntentionalShutdown})`);
|
|
360
|
-
|
|
361
|
-
// Restart on ANY unexpected exit (including code 0 if not intentional)
|
|
362
|
-
if (!isIntentionalShutdown) {
|
|
363
|
-
console.log(`⚠️ Cloudflared unexpected exit detected - will restart`);
|
|
364
|
-
|
|
365
|
-
// Auto-restart logic
|
|
366
|
-
if (restartCallback) {
|
|
367
|
-
const now = Date.now();
|
|
368
|
-
restartTimes.push(now);
|
|
369
|
-
|
|
370
|
-
// Remove old restart times outside window
|
|
371
|
-
restartTimes = restartTimes.filter(t => t > now - RESTART_WINDOW_MS);
|
|
372
|
-
|
|
373
|
-
if (restartTimes.length <= MAX_RESTART_ATTEMPTS) {
|
|
374
|
-
console.log(`🔄 Restarting tunnel... (attempt ${restartTimes.length}/${MAX_RESTART_ATTEMPTS})`);
|
|
375
|
-
setTimeout(() => {
|
|
376
|
-
console.log(`🔄 Executing tunnel restart...`);
|
|
377
|
-
restartCallback(tunnelToken);
|
|
378
|
-
}, 2000);
|
|
379
|
-
} else {
|
|
380
|
-
console.log(`❌ Too many tunnel restarts (${MAX_RESTART_ATTEMPTS} in ${RESTART_WINDOW_MS / 1000}s). Giving up.`);
|
|
381
|
-
}
|
|
382
|
-
} else {
|
|
383
|
-
console.log(`⚠️ No restart callback registered`);
|
|
384
|
-
}
|
|
385
|
-
} else {
|
|
386
|
-
console.log(`ℹ️ Cloudflared exit ignored (intentional shutdown)`);
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
// Save PID
|
|
391
|
-
writePid("cloudflared", child.pid);
|
|
392
|
-
|
|
393
|
-
// Start network monitor
|
|
394
|
-
startNetworkMonitor();
|
|
395
|
-
|
|
396
|
-
return child;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Kill cloudflared process
|
|
401
|
-
*/
|
|
402
|
-
export function killCloudflared() {
|
|
403
|
-
// Clean up legacy PID file from older installs (one-time migration)
|
|
404
|
-
try {
|
|
405
|
-
if (fs.existsSync(LEGACY_PID_FILE)) {
|
|
406
|
-
const legacyPid = parseInt(fs.readFileSync(LEGACY_PID_FILE, "utf8"));
|
|
407
|
-
if (Number.isFinite(legacyPid)) {
|
|
408
|
-
isIntentionalShutdown = true;
|
|
409
|
-
try { process.kill(legacyPid); } catch {}
|
|
410
|
-
}
|
|
411
|
-
fs.unlinkSync(LEGACY_PID_FILE);
|
|
412
|
-
}
|
|
413
|
-
} catch {}
|
|
414
|
-
|
|
415
|
-
const pid = readPid("cloudflared");
|
|
416
|
-
if (!pid) return;
|
|
417
|
-
isIntentionalShutdown = true;
|
|
418
|
-
try {
|
|
419
|
-
process.kill(pid);
|
|
420
|
-
console.log(`✅ Cloudflared killed`);
|
|
421
|
-
} catch {}
|
|
422
|
-
clearPid("cloudflared");
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Reset restart counter and stop network monitor
|
|
427
|
-
*/
|
|
428
|
-
export function resetRestartCounter() {
|
|
429
|
-
restartTimes = [];
|
|
430
|
-
restartCallback = null;
|
|
431
|
-
currentTunnelToken = null;
|
|
432
|
-
stopNetworkMonitor();
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Get network state fingerprint (only active interfaces with IP)
|
|
437
|
-
*/
|
|
438
|
-
function getNetworkFingerprint() {
|
|
439
|
-
const interfaces = os.networkInterfaces();
|
|
440
|
-
const active = [];
|
|
441
|
-
|
|
442
|
-
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
443
|
-
if (!addrs) continue;
|
|
444
|
-
for (const addr of addrs) {
|
|
445
|
-
if (!addr.internal && addr.family === "IPv4") {
|
|
446
|
-
active.push(`${name}:${addr.address}`);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
return active.sort().join("|");
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Start network change monitor
|
|
456
|
-
*/
|
|
457
|
-
function startNetworkMonitor() {
|
|
458
|
-
if (networkMonitorInterval) return;
|
|
459
|
-
|
|
460
|
-
lastNetworkState = getNetworkFingerprint();
|
|
461
|
-
|
|
462
|
-
networkMonitorInterval = setInterval(() => {
|
|
463
|
-
const current = getNetworkFingerprint();
|
|
464
|
-
|
|
465
|
-
if (current !== lastNetworkState) {
|
|
466
|
-
console.log("🔄 Network change detected - restarting tunnel...");
|
|
467
|
-
lastNetworkState = current;
|
|
468
|
-
|
|
469
|
-
// Kill cloudflared (sets isIntentionalShutdown = true)
|
|
470
|
-
killCloudflared();
|
|
471
|
-
|
|
472
|
-
// Directly trigger restart instead of relying on exit event
|
|
473
|
-
// (exit event won't restart because isIntentionalShutdown = true)
|
|
474
|
-
if (restartCallback && currentTunnelToken) {
|
|
475
|
-
setTimeout(() => {
|
|
476
|
-
console.log("🔄 Restarting tunnel after network change...");
|
|
477
|
-
restartCallback(currentTunnelToken);
|
|
478
|
-
}, 2000);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}, 5000);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Stop network monitor
|
|
486
|
-
*/
|
|
487
|
-
function stopNetworkMonitor() {
|
|
488
|
-
if (networkMonitorInterval) {
|
|
489
|
-
clearInterval(networkMonitorInterval);
|
|
490
|
-
networkMonitorInterval = null;
|
|
491
|
-
}
|
|
492
|
-
lastNetworkState = null;
|
|
493
|
-
}
|
package/cli/utils/machineId.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import pkg from "node-machine-id";
|
|
2
|
-
const { machineIdSync } = pkg;
|
|
3
|
-
import crypto from "crypto";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Get consistent machine ID using node-machine-id with salt
|
|
7
|
-
* This ensures the same physical machine gets the same ID across runs
|
|
8
|
-
*
|
|
9
|
-
* @param {string} salt - Optional salt to use (defaults to environment variable)
|
|
10
|
-
* @returns {Promise<string>} Machine ID (16-character hex)
|
|
11
|
-
*/
|
|
12
|
-
export async function getConsistentMachineId(salt = null) {
|
|
13
|
-
const saltValue = salt || process.env.MACHINE_ID_SALT || "9remote-salt";
|
|
14
|
-
try {
|
|
15
|
-
const rawMachineId = machineIdSync();
|
|
16
|
-
const hashedMachineId = crypto.createHash("sha256").update(rawMachineId + saltValue).digest("hex");
|
|
17
|
-
return hashedMachineId.substring(0, 16);
|
|
18
|
-
} catch (error) {
|
|
19
|
-
console.error("Error getting machine ID:", error);
|
|
20
|
-
return crypto.randomUUID().replace(/-/g, "").substring(0, 16);
|
|
21
|
-
}
|
|
22
|
-
}
|
package/cli/utils/permissions.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared permission check logic (macOS TCC).
|
|
3
|
-
* Used by both the server (index.js) and TUI (tui.js via API).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { exec } from "child_process";
|
|
7
|
-
|
|
8
|
-
export const PERM_URLS = {
|
|
9
|
-
screenRecording: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
|
|
10
|
-
accessibility: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Check macOS permissions. Returns { screenRecording, accessibility }.
|
|
15
|
-
* On non-macOS always returns true for both.
|
|
16
|
-
* @returns {Promise<{screenRecording: boolean, accessibility: boolean}>}
|
|
17
|
-
*/
|
|
18
|
-
export function checkPermissions() {
|
|
19
|
-
return new Promise((resolve) => {
|
|
20
|
-
if (process.platform !== "darwin") {
|
|
21
|
-
resolve({ screenRecording: true, accessibility: true });
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let sr = false, ax = false, done = 0;
|
|
26
|
-
const finish = () => { if (++done === 2) resolve({ screenRecording: sr, accessibility: ax }); };
|
|
27
|
-
|
|
28
|
-
// Accessibility: reading UI elements truly requires AX permission (reflects revoke instantly)
|
|
29
|
-
exec(`osascript -e 'tell application "System Events" to tell process "Finder" to get name of every window'`,
|
|
30
|
-
{ timeout: 3000 }, (err) => { ax = !err; finish(); });
|
|
31
|
-
|
|
32
|
-
// Screen Recording: CGPreflightScreenCaptureAccess via CoreGraphics — reflects TCC state in realtime
|
|
33
|
-
exec(`osascript -e 'use framework "CoreGraphics"' -e "return (current application's CGPreflightScreenCaptureAccess()) as boolean"`,
|
|
34
|
-
{ timeout: 3000 }, (err, stdout) => { sr = !err && stdout.trim() === "true"; finish(); });
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Open System Preferences pane for a given permission type.
|
|
40
|
-
* @param {"screenRecording"|"accessibility"} type
|
|
41
|
-
*/
|
|
42
|
-
export function openPermissionPane(type) {
|
|
43
|
-
const url = PERM_URLS[type];
|
|
44
|
-
if (url && process.platform === "darwin") exec(`open "${url}"`, () => {});
|
|
45
|
-
}
|