hop2 0.8.0 → 0.9.0
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/hop +183 -14
- package/package.json +1 -1
package/hop
CHANGED
|
@@ -140,6 +140,27 @@ function writePrivateFile(filePath, data, mode = PRIVATE_FILE_MODE) {
|
|
|
140
140
|
|
|
141
141
|
ensurePrivateDir(HOME_DIR);
|
|
142
142
|
|
|
143
|
+
// Sanity check: detect nested config directories (common after manual moves)
|
|
144
|
+
function validateConfigDir() {
|
|
145
|
+
const suspiciousDirs = ['.hop-shell', '.hop-shell2', '.hop2'];
|
|
146
|
+
for (const dir of suspiciousDirs) {
|
|
147
|
+
const nested = path.join(HOME_DIR, dir);
|
|
148
|
+
if (fs.existsSync(nested) && fs.statSync(nested).isDirectory()) {
|
|
149
|
+
// Check if it contains config files that should be in HOME_DIR
|
|
150
|
+
const nestedFiles = ['.auth_secret', '.password_hash', '.domain-config.json'];
|
|
151
|
+
for (const f of nestedFiles) {
|
|
152
|
+
if (fs.existsSync(path.join(nested, f))) {
|
|
153
|
+
console.error(`\n${RED}ERROR: Config files found in nested directory: ${nested}${RESET}`);
|
|
154
|
+
console.error(`Expected config files directly in: ${HOME_DIR}`);
|
|
155
|
+
console.error(`\nFix with: mv "${nested}"/* "${nested}"/.[!.]* "${HOME_DIR}/" && rmdir "${nested}"\n`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
validateConfigDir();
|
|
163
|
+
|
|
143
164
|
const STATE_FILE = path.join(HOME_DIR, '.tunnel-state');
|
|
144
165
|
const DEFAULT_SECRET_FILE = path.join(HOME_DIR, '.auth_secret');
|
|
145
166
|
const DOMAIN_CONFIG_FILE = path.join(HOME_DIR, '.domain-config.json');
|
|
@@ -2348,12 +2369,10 @@ tunnel: ${domainConfig.tunnelName}
|
|
|
2348
2369
|
|
|
2349
2370
|
tunnel.on('close', (code) => {
|
|
2350
2371
|
if (shuttingDown) return; // Don't restart during cleanup
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
}, 3000);
|
|
2356
|
-
}
|
|
2372
|
+
log(`[Tunnel] Disconnected (code ${code}). Restarting in 3s...`, YELLOW);
|
|
2373
|
+
setTimeout(() => {
|
|
2374
|
+
if (!shuttingDown) startTunnelWithRestart();
|
|
2375
|
+
}, 3000);
|
|
2357
2376
|
});
|
|
2358
2377
|
|
|
2359
2378
|
tunnel.on('error', (err) => {
|
|
@@ -2402,7 +2421,7 @@ tunnel: ${domainConfig.tunnelName}
|
|
|
2402
2421
|
|
|
2403
2422
|
// For quick tunnels, parse URL from output
|
|
2404
2423
|
let urlFound = false;
|
|
2405
|
-
return new Promise((resolve) => {
|
|
2424
|
+
return new Promise((resolve, reject) => {
|
|
2406
2425
|
tunnel.stderr.on('data', (data) => {
|
|
2407
2426
|
if (urlFound) return;
|
|
2408
2427
|
const output = data.toString();
|
|
@@ -2425,6 +2444,16 @@ tunnel: ${domainConfig.tunnelName}
|
|
|
2425
2444
|
resolve({ tunnel, publicUrl: parsedUrl });
|
|
2426
2445
|
}
|
|
2427
2446
|
});
|
|
2447
|
+
|
|
2448
|
+
// Timeout - don't hang forever waiting for cloudflared
|
|
2449
|
+
setTimeout(() => {
|
|
2450
|
+
if (!urlFound) {
|
|
2451
|
+
tunnel.kill();
|
|
2452
|
+
log(`\n${RED}ERROR: Cloudflare tunnel failed to start (timeout after 30s)${RESET}`, RED);
|
|
2453
|
+
log(`Check your internet connection or try again.`, YELLOW);
|
|
2454
|
+
process.exit(1);
|
|
2455
|
+
}
|
|
2456
|
+
}, 30000);
|
|
2428
2457
|
});
|
|
2429
2458
|
}
|
|
2430
2459
|
|
|
@@ -2456,12 +2485,10 @@ credentials-file: ${path.resolve(credentialsPath)}
|
|
|
2456
2485
|
|
|
2457
2486
|
tunnel.on('close', (code) => {
|
|
2458
2487
|
if (shuttingDown) return; // Don't restart during cleanup
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
}, 3000);
|
|
2464
|
-
}
|
|
2488
|
+
log(`[Tunnel] Disconnected (code ${code}). Restarting in 3s...`, YELLOW);
|
|
2489
|
+
setTimeout(() => {
|
|
2490
|
+
if (!shuttingDown) startTunnelWithRestart();
|
|
2491
|
+
}, 3000);
|
|
2465
2492
|
});
|
|
2466
2493
|
|
|
2467
2494
|
tunnel.on('error', (err) => {
|
|
@@ -2507,7 +2534,16 @@ credentials-file: ${path.resolve(credentialsPath)}
|
|
|
2507
2534
|
});
|
|
2508
2535
|
}
|
|
2509
2536
|
|
|
2537
|
+
let tunnelRestartInProgress = false;
|
|
2538
|
+
|
|
2510
2539
|
async function startTunnelWithRestart() {
|
|
2540
|
+
// Prevent concurrent restarts
|
|
2541
|
+
if (tunnelRestartInProgress) {
|
|
2542
|
+
log(`[Tunnel] Restart already in progress, skipping.`, YELLOW);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
tunnelRestartInProgress = true;
|
|
2546
|
+
|
|
2511
2547
|
try {
|
|
2512
2548
|
const { tunnel, publicUrl } = CLIENT_TUNNEL_CONFIG
|
|
2513
2549
|
? await startClientTunnel(true)
|
|
@@ -2523,12 +2559,60 @@ async function startTunnelWithRestart() {
|
|
|
2523
2559
|
}
|
|
2524
2560
|
|
|
2525
2561
|
// Sessions are managed by hay, no tmux styling needed
|
|
2562
|
+
tunnelRestartInProgress = false;
|
|
2526
2563
|
} catch (e) {
|
|
2564
|
+
tunnelRestartInProgress = false;
|
|
2527
2565
|
log(`[Tunnel] Failed to restart: ${e.message}. Retrying in 5s...`, RED);
|
|
2528
2566
|
setTimeout(startTunnelWithRestart, 5000);
|
|
2529
2567
|
}
|
|
2530
2568
|
}
|
|
2531
2569
|
|
|
2570
|
+
// Tunnel health monitor - detects stale connections (e.g., after VPN change)
|
|
2571
|
+
let tunnelHealthInterval = null;
|
|
2572
|
+
const TUNNEL_HEALTH_CHECK_INTERVAL = 30000; // Check every 30s
|
|
2573
|
+
const TUNNEL_HEALTH_TIMEOUT = 10000; // 10s timeout for health check
|
|
2574
|
+
|
|
2575
|
+
function startTunnelHealthMonitor() {
|
|
2576
|
+
if (tunnelHealthInterval) return;
|
|
2577
|
+
|
|
2578
|
+
tunnelHealthInterval = setInterval(async () => {
|
|
2579
|
+
if (shuttingDown || !currentPublicUrl) return;
|
|
2580
|
+
|
|
2581
|
+
try {
|
|
2582
|
+
const https = require('https');
|
|
2583
|
+
const healthCheck = new Promise((resolve, reject) => {
|
|
2584
|
+
const req = https.get(currentPublicUrl, { timeout: TUNNEL_HEALTH_TIMEOUT }, (res) => {
|
|
2585
|
+
resolve(res.statusCode);
|
|
2586
|
+
});
|
|
2587
|
+
req.on('error', reject);
|
|
2588
|
+
req.on('timeout', () => {
|
|
2589
|
+
req.destroy();
|
|
2590
|
+
reject(new Error('Timeout'));
|
|
2591
|
+
});
|
|
2592
|
+
});
|
|
2593
|
+
|
|
2594
|
+
await healthCheck;
|
|
2595
|
+
// Any response (even 401) means tunnel is working
|
|
2596
|
+
} catch (e) {
|
|
2597
|
+
log(`[Tunnel] Health check failed: ${e.message}. Restarting tunnel...`, YELLOW);
|
|
2598
|
+
if (currentTunnelProc && !currentTunnelProc.killed) {
|
|
2599
|
+
try { currentTunnelProc.kill('SIGTERM'); } catch (err) { }
|
|
2600
|
+
// The 'close' handler will trigger restart
|
|
2601
|
+
} else {
|
|
2602
|
+
// Cloudflared already dead, restart directly
|
|
2603
|
+
startTunnelWithRestart();
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
}, TUNNEL_HEALTH_CHECK_INTERVAL);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function stopTunnelHealthMonitor() {
|
|
2610
|
+
if (tunnelHealthInterval) {
|
|
2611
|
+
clearInterval(tunnelHealthInterval);
|
|
2612
|
+
tunnelHealthInterval = null;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2532
2616
|
function waitForStart(authEmitter, publicUrl, label) {
|
|
2533
2617
|
return new Promise((resolve) => {
|
|
2534
2618
|
console.clear();
|
|
@@ -2686,7 +2770,8 @@ function waitForDaemonReady(timeoutMs = 20000) {
|
|
|
2686
2770
|
const start = Date.now();
|
|
2687
2771
|
const check = () => {
|
|
2688
2772
|
const state = isServerRunning();
|
|
2689
|
-
|
|
2773
|
+
// State file now written early, so also check that URL is populated
|
|
2774
|
+
if (state && state.url) return resolve(state);
|
|
2690
2775
|
if (Date.now() - start > timeoutMs) {
|
|
2691
2776
|
return reject(new Error('Timed out waiting for hop daemon'));
|
|
2692
2777
|
}
|
|
@@ -3164,6 +3249,9 @@ async function main() {
|
|
|
3164
3249
|
startTime: Date.now()
|
|
3165
3250
|
}));
|
|
3166
3251
|
|
|
3252
|
+
// Start tunnel health monitoring (detects stale connections after VPN changes)
|
|
3253
|
+
startTunnelHealthMonitor();
|
|
3254
|
+
|
|
3167
3255
|
await waitForStart(authEmitter, publicUrl, `Hop-${hostname}`);
|
|
3168
3256
|
|
|
3169
3257
|
await startLocalSession(proxyServer, tunnelProc, publicUrl);
|
|
@@ -3292,6 +3380,56 @@ async function main() {
|
|
|
3292
3380
|
process.exit(0);
|
|
3293
3381
|
}
|
|
3294
3382
|
|
|
3383
|
+
// Handle `hop health` command - check tunnel health and optionally restart
|
|
3384
|
+
if (process.argv[2] === 'health') {
|
|
3385
|
+
const state = isServerRunning();
|
|
3386
|
+
if (!state) {
|
|
3387
|
+
log(`Hop is not running.`, RED);
|
|
3388
|
+
process.exit(1);
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
log(`\nChecking tunnel health...`, BOLD);
|
|
3392
|
+
log(` URL: ${state.url}`, RESET);
|
|
3393
|
+
|
|
3394
|
+
const https = require('https');
|
|
3395
|
+
const startTime = Date.now();
|
|
3396
|
+
|
|
3397
|
+
const checkHealth = () => new Promise((resolve) => {
|
|
3398
|
+
const req = https.get(state.url, { timeout: 10000 }, (res) => {
|
|
3399
|
+
// 5xx errors mean Cloudflare couldn't reach origin (tunnel broken)
|
|
3400
|
+
const ok = res.statusCode < 500;
|
|
3401
|
+
resolve({ ok, status: res.statusCode, time: Date.now() - startTime });
|
|
3402
|
+
});
|
|
3403
|
+
req.on('error', (e) => resolve({ ok: false, error: e.message, time: Date.now() - startTime }));
|
|
3404
|
+
req.on('timeout', () => {
|
|
3405
|
+
req.destroy();
|
|
3406
|
+
resolve({ ok: false, error: 'Timeout (10s)', time: Date.now() - startTime });
|
|
3407
|
+
});
|
|
3408
|
+
});
|
|
3409
|
+
|
|
3410
|
+
const result = await checkHealth();
|
|
3411
|
+
|
|
3412
|
+
if (result.ok) {
|
|
3413
|
+
log(` Status: ${GREEN}OK${RESET} (${result.status}) - ${result.time}ms`, GREEN);
|
|
3414
|
+
} else {
|
|
3415
|
+
const reason = result.error || (result.status ? `HTTP ${result.status}` : 'Unknown error');
|
|
3416
|
+
log(` Status: ${RED}FAILED${RESET} - ${reason} (${result.time}ms)`, RED);
|
|
3417
|
+
|
|
3418
|
+
if (process.argv[3] === '--restart' || process.argv[3] === '-r') {
|
|
3419
|
+
log(`\nRestarting tunnel...`, YELLOW);
|
|
3420
|
+
try {
|
|
3421
|
+
process.kill(state.pid, 'SIGUSR1'); // Signal daemon to restart tunnel
|
|
3422
|
+
log(`Restart signal sent. Check again in a few seconds.`, GREEN);
|
|
3423
|
+
} catch (e) {
|
|
3424
|
+
log(`Failed to signal daemon: ${e.message}`, RED);
|
|
3425
|
+
}
|
|
3426
|
+
} else {
|
|
3427
|
+
log(`\nRun ${CYAN}hop health --restart${RESET} to force tunnel restart.`, RESET);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
process.exit(result.ok ? 0 : 1);
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3295
3433
|
// Handle `hop domain <hostname>` command - setup custom domain
|
|
3296
3434
|
if (process.argv[2] === 'domain') {
|
|
3297
3435
|
const hostname = process.argv[3];
|
|
@@ -3666,6 +3804,19 @@ async function main() {
|
|
|
3666
3804
|
}
|
|
3667
3805
|
|
|
3668
3806
|
loadAuthSecret();
|
|
3807
|
+
|
|
3808
|
+
// Write state file early to prevent race condition where multiple daemons spawn
|
|
3809
|
+
// Other hop processes will see this and wait instead of spawning another daemon
|
|
3810
|
+
if (DAEMON_FLAG) {
|
|
3811
|
+
writePrivateFile(STATE_FILE, JSON.stringify({
|
|
3812
|
+
pid: process.pid,
|
|
3813
|
+
port: BASE_PORT,
|
|
3814
|
+
url: null, // Not yet known, will be updated after tunnel starts
|
|
3815
|
+
sessionSecret: SESSION_SECRET,
|
|
3816
|
+
startTime: Date.now()
|
|
3817
|
+
}));
|
|
3818
|
+
}
|
|
3819
|
+
|
|
3669
3820
|
await checkAndInstallDependencies();
|
|
3670
3821
|
await ensureHayLoaded();
|
|
3671
3822
|
const authEmitter = new EventEmitter();
|
|
@@ -3682,12 +3833,17 @@ async function main() {
|
|
|
3682
3833
|
startTime: Date.now()
|
|
3683
3834
|
}));
|
|
3684
3835
|
|
|
3836
|
+
// Start tunnel health monitoring (detects stale connections after VPN changes)
|
|
3837
|
+
startTunnelHealthMonitor();
|
|
3838
|
+
|
|
3685
3839
|
if (DAEMON_FLAG) {
|
|
3686
3840
|
const cleanup = () => {
|
|
3687
3841
|
if (shuttingDown) return;
|
|
3688
3842
|
shuttingDown = true;
|
|
3689
3843
|
log(`Cleaning up...`, YELLOW);
|
|
3690
3844
|
|
|
3845
|
+
stopTunnelHealthMonitor();
|
|
3846
|
+
|
|
3691
3847
|
if (fs.existsSync(STATE_FILE)) {
|
|
3692
3848
|
try { fs.unlinkSync(STATE_FILE); } catch (e) { }
|
|
3693
3849
|
}
|
|
@@ -3703,6 +3859,19 @@ async function main() {
|
|
|
3703
3859
|
process.on('SIGINT', cleanup);
|
|
3704
3860
|
process.on('SIGTERM', cleanup);
|
|
3705
3861
|
process.on('exit', cleanup);
|
|
3862
|
+
|
|
3863
|
+
// SIGUSR1 = restart tunnel (used by `hop health --restart`)
|
|
3864
|
+
process.on('SIGUSR1', () => {
|
|
3865
|
+
log(`[Tunnel] Received restart signal. Restarting tunnel...`, YELLOW);
|
|
3866
|
+
if (currentTunnelProc && !currentTunnelProc.killed) {
|
|
3867
|
+
try { currentTunnelProc.kill('SIGTERM'); } catch (e) { }
|
|
3868
|
+
// The 'close' handler will trigger startTunnelWithRestart
|
|
3869
|
+
} else {
|
|
3870
|
+
// Cloudflared already dead, restart directly
|
|
3871
|
+
startTunnelWithRestart();
|
|
3872
|
+
}
|
|
3873
|
+
});
|
|
3874
|
+
|
|
3706
3875
|
await new Promise(() => {});
|
|
3707
3876
|
}
|
|
3708
3877
|
|
package/package.json
CHANGED