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.
Files changed (2) hide show
  1. package/hop +183 -14
  2. 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
- if (code !== null) {
2352
- log(`[Tunnel] Disconnected (code ${code}). Restarting in 3s...`, YELLOW);
2353
- setTimeout(() => {
2354
- if (!shuttingDown) startTunnelWithRestart();
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
- if (code !== null) {
2460
- log(`[Tunnel] Disconnected (code ${code}). Restarting in 3s...`, YELLOW);
2461
- setTimeout(() => {
2462
- if (!shuttingDown) startTunnelWithRestart();
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
- if (state) return resolve(state);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {