hop2 0.8.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-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
package/hop
ADDED
|
@@ -0,0 +1,3717 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn, execSync, execFileSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const httpProxy = require('http-proxy');
|
|
9
|
+
const { authenticator } = require('otplib');
|
|
10
|
+
const qrcode = require('qrcode-terminal');
|
|
11
|
+
const cookie = require('cookie');
|
|
12
|
+
const EventEmitter = require('events');
|
|
13
|
+
const url = require('url');
|
|
14
|
+
const { pathToFileURL } = require('url');
|
|
15
|
+
const net = require('net');
|
|
16
|
+
const WebSocket = require('ws');
|
|
17
|
+
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const DAEMON_FLAG = process.argv.includes('--daemon');
|
|
20
|
+
|
|
21
|
+
// Hay integration for terminal sharing (built from ./hay)
|
|
22
|
+
const HAY_ROOT = path.join(__dirname, 'hay');
|
|
23
|
+
let didBuildHay = false;
|
|
24
|
+
let hay = null;
|
|
25
|
+
let hayRooms = null;
|
|
26
|
+
|
|
27
|
+
function ensureHayBuilt() {
|
|
28
|
+
if (didBuildHay) return;
|
|
29
|
+
if (!fs.existsSync(HAY_ROOT)) return;
|
|
30
|
+
const webDistDir = path.join(HAY_ROOT, 'apps/web/dist');
|
|
31
|
+
const serverDistFile = path.join(HAY_ROOT, 'apps/server/dist/lib.js');
|
|
32
|
+
const cliDistFile = path.join(HAY_ROOT, 'apps/cli/dist/index.js');
|
|
33
|
+
const sharedDep = path.join(HAY_ROOT, 'node_modules/hay-shared/package.json');
|
|
34
|
+
const needsInstall = !fs.existsSync(sharedDep);
|
|
35
|
+
const needsBuild = !fs.existsSync(webDistDir) || !fs.existsSync(cliDistFile) || !fs.existsSync(serverDistFile);
|
|
36
|
+
if (!needsInstall && !needsBuild) return;
|
|
37
|
+
try {
|
|
38
|
+
console.log(`[Dev] Building Hay from: ${HAY_ROOT}`);
|
|
39
|
+
if (needsInstall) {
|
|
40
|
+
execFileSync('npm', ['--prefix', HAY_ROOT, 'install'], { stdio: 'inherit' });
|
|
41
|
+
}
|
|
42
|
+
if (needsBuild) {
|
|
43
|
+
execFileSync('npm', ['--prefix', HAY_ROOT, 'run', 'build'], { stdio: 'inherit' });
|
|
44
|
+
}
|
|
45
|
+
didBuildHay = true;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error(`[Dev] Failed to build Hay from: ${HAY_ROOT}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function ensureHayLoaded() {
|
|
52
|
+
if (hay && hayRooms) return;
|
|
53
|
+
ensureHayBuilt();
|
|
54
|
+
const libPath = path.join(HAY_ROOT, 'apps/server/dist/lib.js');
|
|
55
|
+
if (!fs.existsSync(libPath)) {
|
|
56
|
+
throw new Error('Hay server build missing. Run `npm --prefix hay run build`.');
|
|
57
|
+
}
|
|
58
|
+
hay = await import(pathToFileURL(libPath));
|
|
59
|
+
hayRooms = new hay.RoomManager(hay.createPty, process.cwd());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveHayWebDir() {
|
|
63
|
+
// Environment variable override for development
|
|
64
|
+
if (process.env.HAY_DEV_DIR && fs.existsSync(process.env.HAY_DEV_DIR)) {
|
|
65
|
+
return process.env.HAY_DEV_DIR;
|
|
66
|
+
}
|
|
67
|
+
// If local hay source exists, ensure it is built
|
|
68
|
+
ensureHayBuilt();
|
|
69
|
+
// Check for hay dev build nearby
|
|
70
|
+
const devPaths = [
|
|
71
|
+
path.join(__dirname, 'hay/apps/web/dist'),
|
|
72
|
+
path.join(__dirname, '../hay/apps/web/dist'),
|
|
73
|
+
path.join(__dirname, '../../hay/apps/web/dist'),
|
|
74
|
+
path.resolve(__dirname, '../hay/apps/web/dist'),
|
|
75
|
+
];
|
|
76
|
+
for (const p of devPaths) {
|
|
77
|
+
if (fs.existsSync(p)) {
|
|
78
|
+
console.log(`[Dev] Serving Hay from: ${p}`);
|
|
79
|
+
return p;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Fallback to bundled
|
|
83
|
+
return path.join(__dirname, 'hay-web');
|
|
84
|
+
}
|
|
85
|
+
const HAY_WEB_DIR = resolveHayWebDir();
|
|
86
|
+
|
|
87
|
+
// Resolve hay CLI path for local terminal
|
|
88
|
+
function resolveHayCliPath() {
|
|
89
|
+
// Check for hay CLI nearby
|
|
90
|
+
const cliPaths = [
|
|
91
|
+
path.join(__dirname, 'hay/apps/cli/dist/index.js'),
|
|
92
|
+
path.join(__dirname, '../hay/apps/cli/dist/index.js'),
|
|
93
|
+
path.join(__dirname, '../../hay/apps/cli/dist/index.js'),
|
|
94
|
+
path.resolve(__dirname, '../hay/apps/cli/dist/index.js'),
|
|
95
|
+
];
|
|
96
|
+
for (const p of cliPaths) {
|
|
97
|
+
if (fs.existsSync(p)) {
|
|
98
|
+
return p;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Fallback to bundled (if we ever bundle it)
|
|
102
|
+
const bundled = path.join(__dirname, 'hay-cli.js');
|
|
103
|
+
if (fs.existsSync(bundled)) {
|
|
104
|
+
return bundled;
|
|
105
|
+
}
|
|
106
|
+
throw new Error('Hay CLI not found. Build hay/apps/cli first.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Configure TOTP to accept codes ±1 time step (handles clock drift)
|
|
110
|
+
authenticator.options = { window: 1 };
|
|
111
|
+
|
|
112
|
+
// --- Configuration ---
|
|
113
|
+
// Use HOP_HOME env var or default to .hop2 (separate from original hop)
|
|
114
|
+
const HOME_DIR = process.env.HOP_HOME || path.join(os.homedir(), '.hop2');
|
|
115
|
+
const PRIVATE_DIR_MODE = 0o700;
|
|
116
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
117
|
+
|
|
118
|
+
function ensurePrivateDir(dirPath) {
|
|
119
|
+
try {
|
|
120
|
+
if (!fs.existsSync(dirPath)) {
|
|
121
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
122
|
+
} else {
|
|
123
|
+
fs.chmodSync(dirPath, PRIVATE_DIR_MODE);
|
|
124
|
+
}
|
|
125
|
+
} catch (e) { }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ensurePrivateFile(filePath, mode = PRIVATE_FILE_MODE) {
|
|
129
|
+
try {
|
|
130
|
+
if (fs.existsSync(filePath)) {
|
|
131
|
+
fs.chmodSync(filePath, mode);
|
|
132
|
+
}
|
|
133
|
+
} catch (e) { }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writePrivateFile(filePath, data, mode = PRIVATE_FILE_MODE) {
|
|
137
|
+
fs.writeFileSync(filePath, data, { mode });
|
|
138
|
+
ensurePrivateFile(filePath, mode);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
ensurePrivateDir(HOME_DIR);
|
|
142
|
+
|
|
143
|
+
const STATE_FILE = path.join(HOME_DIR, '.tunnel-state');
|
|
144
|
+
const DEFAULT_SECRET_FILE = path.join(HOME_DIR, '.auth_secret');
|
|
145
|
+
const DOMAIN_CONFIG_FILE = path.join(HOME_DIR, '.domain-config.json');
|
|
146
|
+
const DEFAULT_PASSWORD_FILE = path.join(HOME_DIR, '.password_hash');
|
|
147
|
+
const SESSION_COOKIE_NAME = 'tunnel_session';
|
|
148
|
+
const LOG_DIR = path.join(HOME_DIR, 'logs');
|
|
149
|
+
const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
150
|
+
let SECRET_FILE_PATH = DEFAULT_SECRET_FILE;
|
|
151
|
+
let PASSWORD_FILE_PATH = DEFAULT_PASSWORD_FILE;
|
|
152
|
+
const SESSIONS_FILE = path.join(HOME_DIR, '.sessions.json');
|
|
153
|
+
|
|
154
|
+
// Password hashing using scrypt (built-in, no external deps)
|
|
155
|
+
function hashPassword(password) {
|
|
156
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
157
|
+
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
158
|
+
return `${salt}:${hash}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function verifyPassword(password, stored) {
|
|
162
|
+
if (!stored) return false;
|
|
163
|
+
const [salt, hash] = stored.split(':');
|
|
164
|
+
if (!salt || !hash) return false;
|
|
165
|
+
const testHash = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
166
|
+
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(testHash, 'hex'));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getStoredPassword() {
|
|
170
|
+
if (fs.existsSync(PASSWORD_FILE_PATH)) {
|
|
171
|
+
ensurePrivateFile(PASSWORD_FILE_PATH);
|
|
172
|
+
return fs.readFileSync(PASSWORD_FILE_PATH, 'utf8').trim();
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function setPassword(password) {
|
|
178
|
+
const hashed = hashPassword(password);
|
|
179
|
+
writePrivateFile(PASSWORD_FILE_PATH, hashed);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clearPassword() {
|
|
183
|
+
if (fs.existsSync(PASSWORD_FILE_PATH)) {
|
|
184
|
+
fs.unlinkSync(PASSWORD_FILE_PATH);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isPasswordEnabled() {
|
|
189
|
+
return fs.existsSync(PASSWORD_FILE_PATH);
|
|
190
|
+
}
|
|
191
|
+
const HOP_SESSION_PREFIX = 'hop2-'; // Prefix for hop2 sessions (separate from hop which uses 'hop-')
|
|
192
|
+
|
|
193
|
+
ensurePrivateDir(LOG_DIR);
|
|
194
|
+
|
|
195
|
+
function toInternalSessionName(displayName) {
|
|
196
|
+
if (!displayName || typeof displayName !== 'string') return null;
|
|
197
|
+
if (!SESSION_NAME_RE.test(displayName)) return null;
|
|
198
|
+
return HOP_SESSION_PREFIX + displayName;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function toDisplaySessionName(internalName) {
|
|
202
|
+
if (!internalName || typeof internalName !== 'string') return '';
|
|
203
|
+
return internalName.slice(HOP_SESSION_PREFIX.length);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function loadSessionConfigs() {
|
|
207
|
+
if (fs.existsSync(SESSIONS_FILE)) {
|
|
208
|
+
try {
|
|
209
|
+
ensurePrivateFile(SESSIONS_FILE);
|
|
210
|
+
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
211
|
+
return data && typeof data === 'object' ? data : {};
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function saveSessionConfigs() {
|
|
220
|
+
writePrivateFile(SESSIONS_FILE, JSON.stringify(sessionConfigs, null, 2));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let sessionConfigs = loadSessionConfigs();
|
|
224
|
+
|
|
225
|
+
function getSessionConfig(name) {
|
|
226
|
+
return sessionConfigs[name] || null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function setSessionConfig(name, config) {
|
|
230
|
+
sessionConfigs[name] = config;
|
|
231
|
+
saveSessionConfigs();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function deleteSessionConfig(name) {
|
|
235
|
+
delete sessionConfigs[name];
|
|
236
|
+
saveSessionConfigs();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ensureTerminalSessionConfig(name, cwd) {
|
|
240
|
+
const existing = getSessionConfig(name);
|
|
241
|
+
if (!existing) {
|
|
242
|
+
setSessionConfig(name, {
|
|
243
|
+
type: 'terminal',
|
|
244
|
+
createdAt: new Date().toISOString(),
|
|
245
|
+
updatedAt: new Date().toISOString(),
|
|
246
|
+
cwd: cwd || undefined
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (cwd && existing.cwd !== cwd) {
|
|
251
|
+
setSessionConfig(name, {
|
|
252
|
+
...existing,
|
|
253
|
+
cwd,
|
|
254
|
+
updatedAt: new Date().toISOString()
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function setPortSessionConfig(name, port) {
|
|
260
|
+
const existing = getSessionConfig(name);
|
|
261
|
+
setSessionConfig(name, {
|
|
262
|
+
type: 'port',
|
|
263
|
+
port,
|
|
264
|
+
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
265
|
+
updatedAt: new Date().toISOString()
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Load domain config if exists
|
|
270
|
+
function getDomainConfig() {
|
|
271
|
+
if (fs.existsSync(DOMAIN_CONFIG_FILE)) {
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(fs.readFileSync(DOMAIN_CONFIG_FILE, 'utf8'));
|
|
274
|
+
} catch (e) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function saveDomainConfig(config) {
|
|
282
|
+
fs.writeFileSync(DOMAIN_CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check if cloudflared is logged in
|
|
286
|
+
function isCloudflaredLoggedIn() {
|
|
287
|
+
const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
|
|
288
|
+
return fs.existsSync(certPath);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Get existing tunnels
|
|
292
|
+
function listCloudflaredTunnels() {
|
|
293
|
+
try {
|
|
294
|
+
const output = execSync('cloudflared tunnel list -o json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
295
|
+
return JSON.parse(output);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Setup custom domain
|
|
302
|
+
async function setupDomain(hostname) {
|
|
303
|
+
const tunnelName = 'hop';
|
|
304
|
+
let tunnelCreatedByUs = false; // Track if we created it (for cleanup)
|
|
305
|
+
|
|
306
|
+
log(`\n${BOLD}Setting up custom domain: ${hostname}${RESET}\n`, CYAN);
|
|
307
|
+
|
|
308
|
+
// Step 1: Check cloudflared login
|
|
309
|
+
log(`[1/4] Checking Cloudflare authentication...`, BOLD);
|
|
310
|
+
if (!isCloudflaredLoggedIn()) {
|
|
311
|
+
log(` Opening browser for Cloudflare login...`, YELLOW);
|
|
312
|
+
log(` Please authorize in your browser.\n`, RESET);
|
|
313
|
+
try {
|
|
314
|
+
execSync('cloudflared tunnel login', { stdio: 'inherit' });
|
|
315
|
+
} catch (e) {
|
|
316
|
+
log(`Failed to authenticate with Cloudflare.`, RED);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
log(` Authenticated with Cloudflare.`, GREEN);
|
|
321
|
+
|
|
322
|
+
// Step 2: Check if tunnel exists, create if not
|
|
323
|
+
log(`[2/4] Setting up tunnel...`, BOLD);
|
|
324
|
+
const tunnels = listCloudflaredTunnels();
|
|
325
|
+
let tunnel = tunnels.find(t => t.name === tunnelName);
|
|
326
|
+
|
|
327
|
+
if (tunnel) {
|
|
328
|
+
log(` Using existing tunnel: ${tunnelName} (${tunnel.id})`, GREEN);
|
|
329
|
+
} else {
|
|
330
|
+
log(` Creating new tunnel: ${tunnelName}...`, YELLOW);
|
|
331
|
+
try {
|
|
332
|
+
execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf8' });
|
|
333
|
+
log(` Tunnel created.`, GREEN);
|
|
334
|
+
tunnelCreatedByUs = true;
|
|
335
|
+
// Re-fetch to get the ID
|
|
336
|
+
const newTunnels = listCloudflaredTunnels();
|
|
337
|
+
tunnel = newTunnels.find(t => t.name === tunnelName);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
log(`Failed to create tunnel: ${e.message}`, RED);
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Step 3: Setup DNS route - this validates domain ownership
|
|
345
|
+
log(`[3/4] Configuring DNS...`, BOLD);
|
|
346
|
+
let dnsSuccess = false;
|
|
347
|
+
try {
|
|
348
|
+
// Capture both stdout and stderr to check for errors
|
|
349
|
+
const result = execSync(`cloudflared tunnel route dns ${tunnelName} ${hostname} 2>&1`, {
|
|
350
|
+
encoding: 'utf8'
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Check for failure FIRST (before checking for "already exists" which can appear in error messages)
|
|
354
|
+
if (result.includes('Failed to add route') || result.includes('Failed to create record')) {
|
|
355
|
+
// This is an error, even if it contains "already exists"
|
|
356
|
+
log(`\n${RED}ERROR: DNS configuration failed.${RESET}`, RED);
|
|
357
|
+
log(`Details: ${result.trim()}`, YELLOW);
|
|
358
|
+
dnsSuccess = false;
|
|
359
|
+
} else if (result.includes('Added CNAME')) {
|
|
360
|
+
dnsSuccess = true;
|
|
361
|
+
log(` DNS configured: ${hostname} → tunnel`, GREEN);
|
|
362
|
+
} else if (result.includes('already exists') && !result.includes('Failed')) {
|
|
363
|
+
// Only treat as success if it's not part of a failure message
|
|
364
|
+
dnsSuccess = true;
|
|
365
|
+
log(` DNS already configured.`, GREEN);
|
|
366
|
+
} else {
|
|
367
|
+
// Unexpected output - treat as potential failure
|
|
368
|
+
log(` Unexpected response: ${result.trim()}`, YELLOW);
|
|
369
|
+
dnsSuccess = false;
|
|
370
|
+
}
|
|
371
|
+
} catch (e) {
|
|
372
|
+
const errorOutput = e.stdout || e.stderr || e.message || '';
|
|
373
|
+
|
|
374
|
+
// Check for failure indicators FIRST
|
|
375
|
+
if (errorOutput.includes('Failed to add route') || errorOutput.includes('Failed to create record')) {
|
|
376
|
+
log(`\n${RED}ERROR: DNS configuration failed.${RESET}`, RED);
|
|
377
|
+
log(`Details: ${errorOutput.trim()}`, YELLOW);
|
|
378
|
+
dnsSuccess = false;
|
|
379
|
+
} else if (errorOutput.includes('zone') || errorOutput.includes('not found') || errorOutput.includes('does not exist')) {
|
|
380
|
+
log(`\n${RED}ERROR: Domain "${hostname}" is not in your Cloudflare account.${RESET}`, RED);
|
|
381
|
+
log(`\nMake sure:`, YELLOW);
|
|
382
|
+
log(` 1. You own this domain`, RESET);
|
|
383
|
+
log(` 2. The domain is added to your Cloudflare account`, RESET);
|
|
384
|
+
log(` 3. You're using the correct Cloudflare account`, RESET);
|
|
385
|
+
dnsSuccess = false;
|
|
386
|
+
} else if (errorOutput.includes('authentication') || errorOutput.includes('unauthorized')) {
|
|
387
|
+
log(`\n${RED}ERROR: Authentication failed.${RESET}`, RED);
|
|
388
|
+
log(`Try: cloudflared tunnel login`, YELLOW);
|
|
389
|
+
dnsSuccess = false;
|
|
390
|
+
} else if (errorOutput.includes('already exists') && !errorOutput.includes('Failed')) {
|
|
391
|
+
// Only treat as success if it's a clean "already exists" not part of error
|
|
392
|
+
dnsSuccess = true;
|
|
393
|
+
log(` DNS already configured.`, GREEN);
|
|
394
|
+
} else {
|
|
395
|
+
log(`\n${RED}ERROR: DNS configuration failed.${RESET}`, RED);
|
|
396
|
+
log(`Details: ${errorOutput.trim()}`, YELLOW);
|
|
397
|
+
dnsSuccess = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// If DNS failed, clean up the tunnel we created
|
|
402
|
+
if (!dnsSuccess) {
|
|
403
|
+
if (tunnelCreatedByUs) {
|
|
404
|
+
log(`\nCleaning up...`, YELLOW);
|
|
405
|
+
try {
|
|
406
|
+
execSync(`cloudflared tunnel delete ${tunnelName}`, { stdio: 'ignore' });
|
|
407
|
+
log(` Deleted tunnel: ${tunnelName}`, RESET);
|
|
408
|
+
} catch (e) {
|
|
409
|
+
log(` Note: You may need to manually delete tunnel: cloudflared tunnel delete ${tunnelName}`, YELLOW);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
log(`\nSetup failed. Please fix the issue and try again.`, RED);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Step 4: Save config
|
|
417
|
+
log(`[4/4] Saving configuration...`, BOLD);
|
|
418
|
+
saveDomainConfig({
|
|
419
|
+
tunnelName,
|
|
420
|
+
tunnelId: tunnel ? tunnel.id : null,
|
|
421
|
+
hostname,
|
|
422
|
+
createdAt: new Date().toISOString()
|
|
423
|
+
});
|
|
424
|
+
log(` Configuration saved.`, GREEN);
|
|
425
|
+
|
|
426
|
+
log(`\n${BOLD}${GREEN}✓ Custom domain setup complete!${RESET}`, GREEN);
|
|
427
|
+
log(`\nYour hop URL: https://${hostname}`, BOLD);
|
|
428
|
+
|
|
429
|
+
// Require password for fixed domains
|
|
430
|
+
if (!isPasswordEnabled()) {
|
|
431
|
+
log(`\n${YELLOW}⚠ Password required for fixed domains.${RESET}`, YELLOW);
|
|
432
|
+
log(`A fixed URL is publicly known - password adds essential protection.\n`, RESET);
|
|
433
|
+
|
|
434
|
+
const readline = require('readline');
|
|
435
|
+
const stream = require('stream');
|
|
436
|
+
|
|
437
|
+
const askPassword = (prompt) => new Promise((resolve) => {
|
|
438
|
+
const rl = readline.createInterface({
|
|
439
|
+
input: process.stdin,
|
|
440
|
+
output: new stream.Writable({
|
|
441
|
+
write: (chunk, encoding, callback) => callback()
|
|
442
|
+
}),
|
|
443
|
+
terminal: true
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
process.stdout.write(prompt);
|
|
447
|
+
rl.question('', (answer) => {
|
|
448
|
+
rl.close();
|
|
449
|
+
process.stdout.write('\n');
|
|
450
|
+
resolve(answer);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const password = await askPassword('Set a password: ');
|
|
455
|
+
if (password.length < 4) {
|
|
456
|
+
log(`Password too short. Run ${CYAN}hop password set${RESET} to set later.`, RED);
|
|
457
|
+
} else {
|
|
458
|
+
const confirm = await askPassword('Confirm password: ');
|
|
459
|
+
if (password !== confirm) {
|
|
460
|
+
log(`Passwords don't match. Run ${CYAN}hop password set${RESET} to set later.`, RED);
|
|
461
|
+
} else {
|
|
462
|
+
setPassword(password);
|
|
463
|
+
log(`${GREEN}✓ Password set.${RESET}`, GREEN);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
log(`\nRun ${CYAN}hop${RESET} to start with your custom domain.`, RESET);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --- Multi-user management ---
|
|
472
|
+
const USERS_FILE = path.join(HOME_DIR, '.users.json');
|
|
473
|
+
|
|
474
|
+
function getUsers() {
|
|
475
|
+
if (fs.existsSync(USERS_FILE)) {
|
|
476
|
+
try {
|
|
477
|
+
return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
|
|
478
|
+
} catch (e) {
|
|
479
|
+
return {};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return {};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function saveUsers(users) {
|
|
486
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Get base domain from existing config (e.g., hop.example.com -> example.com)
|
|
490
|
+
function getBaseDomain() {
|
|
491
|
+
const config = getDomainConfig();
|
|
492
|
+
if (!config || !config.hostname) return null;
|
|
493
|
+
// Extract base domain: hop.example.com -> example.com
|
|
494
|
+
const parts = config.hostname.split('.');
|
|
495
|
+
if (parts.length >= 2) {
|
|
496
|
+
return parts.slice(-2).join('.');
|
|
497
|
+
}
|
|
498
|
+
return config.hostname;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Get subdomain prefix (e.g., hop.example.com -> hop)
|
|
502
|
+
function getSubdomainPrefix() {
|
|
503
|
+
const config = getDomainConfig();
|
|
504
|
+
if (!config || !config.hostname) return 'hop';
|
|
505
|
+
const parts = config.hostname.split('.');
|
|
506
|
+
if (parts.length >= 3) {
|
|
507
|
+
return parts[0];
|
|
508
|
+
}
|
|
509
|
+
return 'hop';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function addUser(username) {
|
|
513
|
+
// Validate username
|
|
514
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
|
515
|
+
log(`Invalid username. Use only letters, numbers, underscore, hyphen.`, RED);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const baseDomain = getBaseDomain();
|
|
520
|
+
const prefix = getSubdomainPrefix();
|
|
521
|
+
|
|
522
|
+
if (!baseDomain) {
|
|
523
|
+
log(`You must set up your own domain first.`, RED);
|
|
524
|
+
log(`Run: hop domain <your-subdomain.yourdomain.com>`, RESET);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!isCloudflaredLoggedIn()) {
|
|
529
|
+
log(`You must be logged in to Cloudflare.`, RED);
|
|
530
|
+
log(`Run: hop domain <hostname> to set up.`, RESET);
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const users = getUsers();
|
|
535
|
+
if (users[username]) {
|
|
536
|
+
log(`User "${username}" already exists.`, YELLOW);
|
|
537
|
+
log(`Hostname: ${users[username].hostname}`, RESET);
|
|
538
|
+
process.exit(0);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const tunnelName = `hop-${username}`;
|
|
542
|
+
const hostname = `${username}.${prefix}.${baseDomain}`;
|
|
543
|
+
let tunnelCreatedByUs = false;
|
|
544
|
+
|
|
545
|
+
log(`\n${BOLD}Adding user: ${username}${RESET}\n`, CYAN);
|
|
546
|
+
|
|
547
|
+
// Step 1: Create tunnel
|
|
548
|
+
log(`[1/3] Creating tunnel...`, BOLD);
|
|
549
|
+
const tunnels = listCloudflaredTunnels();
|
|
550
|
+
let tunnel = tunnels.find(t => t.name === tunnelName);
|
|
551
|
+
|
|
552
|
+
if (tunnel) {
|
|
553
|
+
log(` Tunnel already exists: ${tunnelName}`, GREEN);
|
|
554
|
+
} else {
|
|
555
|
+
try {
|
|
556
|
+
execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
557
|
+
log(` Tunnel created: ${tunnelName}`, GREEN);
|
|
558
|
+
tunnelCreatedByUs = true;
|
|
559
|
+
// Re-fetch to get the ID
|
|
560
|
+
const newTunnels = listCloudflaredTunnels();
|
|
561
|
+
tunnel = newTunnels.find(t => t.name === tunnelName);
|
|
562
|
+
} catch (e) {
|
|
563
|
+
log(`Failed to create tunnel: ${e.message}`, RED);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Step 2: Setup DNS
|
|
569
|
+
log(`[2/3] Configuring DNS...`, BOLD);
|
|
570
|
+
let dnsSuccess = false;
|
|
571
|
+
try {
|
|
572
|
+
const result = execSync(`cloudflared tunnel route dns ${tunnelName} ${hostname} 2>&1`, {
|
|
573
|
+
encoding: 'utf8'
|
|
574
|
+
});
|
|
575
|
+
// Check for failure FIRST
|
|
576
|
+
if (result.includes('Failed to add route') || result.includes('Failed to create record')) {
|
|
577
|
+
log(`\n${RED}ERROR: DNS failed: ${result.trim()}${RESET}`, RED);
|
|
578
|
+
dnsSuccess = false;
|
|
579
|
+
} else if (result.includes('Added CNAME')) {
|
|
580
|
+
dnsSuccess = true;
|
|
581
|
+
log(` DNS configured: ${hostname}`, GREEN);
|
|
582
|
+
} else if (result.includes('already exists') && !result.includes('Failed')) {
|
|
583
|
+
dnsSuccess = true;
|
|
584
|
+
log(` DNS already configured.`, GREEN);
|
|
585
|
+
} else {
|
|
586
|
+
log(` Unexpected response: ${result.trim()}`, YELLOW);
|
|
587
|
+
}
|
|
588
|
+
} catch (e) {
|
|
589
|
+
const errorOutput = e.stdout || e.stderr || e.message || '';
|
|
590
|
+
// Check for failure FIRST
|
|
591
|
+
if (errorOutput.includes('Failed to add route') || errorOutput.includes('Failed to create record')) {
|
|
592
|
+
log(`\n${RED}ERROR: DNS failed: ${errorOutput.trim()}${RESET}`, RED);
|
|
593
|
+
} else if (errorOutput.includes('already exists') && !errorOutput.includes('Failed')) {
|
|
594
|
+
dnsSuccess = true;
|
|
595
|
+
log(` DNS already configured.`, GREEN);
|
|
596
|
+
} else if (errorOutput.includes('zone') || errorOutput.includes('not found')) {
|
|
597
|
+
log(`\n${RED}ERROR: Domain not in your Cloudflare account.${RESET}`, RED);
|
|
598
|
+
} else {
|
|
599
|
+
log(`\n${RED}ERROR: DNS failed: ${errorOutput.trim()}${RESET}`, RED);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// If DNS failed, clean up
|
|
604
|
+
if (!dnsSuccess) {
|
|
605
|
+
if (tunnelCreatedByUs) {
|
|
606
|
+
log(`\nCleaning up...`, YELLOW);
|
|
607
|
+
try {
|
|
608
|
+
execSync(`cloudflared tunnel delete ${tunnelName}`, { stdio: 'ignore' });
|
|
609
|
+
log(` Deleted tunnel: ${tunnelName}`, RESET);
|
|
610
|
+
} catch (e) { }
|
|
611
|
+
}
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Step 3: Save user info
|
|
616
|
+
log(`[3/3] Saving user...`, BOLD);
|
|
617
|
+
|
|
618
|
+
// Find the credentials file
|
|
619
|
+
const cloudflaredDir = path.join(os.homedir(), '.cloudflared');
|
|
620
|
+
let credentialsFile = null;
|
|
621
|
+
if (tunnel && tunnel.id) {
|
|
622
|
+
credentialsFile = path.join(cloudflaredDir, `${tunnel.id}.json`);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
users[username] = {
|
|
626
|
+
tunnelName,
|
|
627
|
+
tunnelId: tunnel ? tunnel.id : null,
|
|
628
|
+
hostname,
|
|
629
|
+
credentialsFile,
|
|
630
|
+
createdAt: new Date().toISOString()
|
|
631
|
+
};
|
|
632
|
+
saveUsers(users);
|
|
633
|
+
log(` User saved.`, GREEN);
|
|
634
|
+
|
|
635
|
+
log(`\n${BOLD}${GREEN}✓ User "${username}" added!${RESET}`, GREEN);
|
|
636
|
+
log(`\nTheir URL: https://${hostname}`, BOLD);
|
|
637
|
+
log(`\nTo export credentials for them:`, RESET);
|
|
638
|
+
log(` ${CYAN}hop user export ${username}${RESET}`, RESET);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function removeUser(username) {
|
|
642
|
+
const users = getUsers();
|
|
643
|
+
|
|
644
|
+
if (!users[username]) {
|
|
645
|
+
log(`User "${username}" not found.`, RED);
|
|
646
|
+
process.exit(1);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const user = users[username];
|
|
650
|
+
|
|
651
|
+
log(`\n${BOLD}Removing user: ${username}${RESET}\n`, YELLOW);
|
|
652
|
+
|
|
653
|
+
// Delete tunnel
|
|
654
|
+
log(`[1/2] Deleting tunnel...`, BOLD);
|
|
655
|
+
try {
|
|
656
|
+
execSync(`cloudflared tunnel delete ${user.tunnelName}`, {
|
|
657
|
+
encoding: 'utf8',
|
|
658
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
659
|
+
});
|
|
660
|
+
log(` Tunnel deleted: ${user.tunnelName}`, GREEN);
|
|
661
|
+
} catch (e) {
|
|
662
|
+
log(` Warning: Could not delete tunnel (may already be gone).`, YELLOW);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Note: DNS record may remain, Cloudflare doesn't auto-delete it
|
|
666
|
+
log(`[2/2] Removing user record...`, BOLD);
|
|
667
|
+
delete users[username];
|
|
668
|
+
saveUsers(users);
|
|
669
|
+
log(` User removed.`, GREEN);
|
|
670
|
+
|
|
671
|
+
log(`\n${BOLD}${GREEN}✓ User "${username}" removed.${RESET}`, GREEN);
|
|
672
|
+
log(`\nNote: DNS record for ${user.hostname} may need manual removal in Cloudflare dashboard.`, YELLOW);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function listUsersCommand() {
|
|
676
|
+
const users = getUsers();
|
|
677
|
+
const userList = Object.entries(users);
|
|
678
|
+
|
|
679
|
+
if (userList.length === 0) {
|
|
680
|
+
log(`\nNo users configured.`, YELLOW);
|
|
681
|
+
log(`\nAdd a user: hop user add <username>`, RESET);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
log(`\n${BOLD}Configured users:${RESET}\n`, CYAN);
|
|
686
|
+
|
|
687
|
+
for (const [username, info] of userList) {
|
|
688
|
+
log(` ${BOLD}${username}${RESET}`, GREEN);
|
|
689
|
+
log(` URL: https://${info.hostname}`, RESET);
|
|
690
|
+
log(` Tunnel: ${info.tunnelName}`, RESET);
|
|
691
|
+
log(` Created: ${info.createdAt}`, RESET);
|
|
692
|
+
log('');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function exportUserCredentials(username) {
|
|
697
|
+
const users = getUsers();
|
|
698
|
+
|
|
699
|
+
if (!users[username]) {
|
|
700
|
+
log(`User "${username}" not found.`, RED);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const user = users[username];
|
|
705
|
+
const cloudflaredDir = path.join(os.homedir(), '.cloudflared');
|
|
706
|
+
const certFile = path.join(cloudflaredDir, 'cert.pem');
|
|
707
|
+
|
|
708
|
+
// Find credentials file
|
|
709
|
+
let credentialsFile = user.credentialsFile;
|
|
710
|
+
if (!credentialsFile || !fs.existsSync(credentialsFile)) {
|
|
711
|
+
// Try to find by tunnel ID
|
|
712
|
+
if (user.tunnelId) {
|
|
713
|
+
credentialsFile = path.join(cloudflaredDir, `${user.tunnelId}.json`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!credentialsFile || !fs.existsSync(credentialsFile)) {
|
|
718
|
+
log(`Credentials file not found for user "${username}".`, RED);
|
|
719
|
+
log(`The tunnel may need to be recreated.`, YELLOW);
|
|
720
|
+
process.exit(1);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Create export directory
|
|
724
|
+
const exportDir = path.join(HOME_DIR, 'exports', username);
|
|
725
|
+
if (!fs.existsSync(exportDir)) {
|
|
726
|
+
fs.mkdirSync(exportDir, { recursive: true });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Copy credentials
|
|
730
|
+
const exportCredentials = path.join(exportDir, 'credentials.json');
|
|
731
|
+
fs.copyFileSync(credentialsFile, exportCredentials);
|
|
732
|
+
|
|
733
|
+
// Create config.yml with hostname for client convenience
|
|
734
|
+
const exportConfig = `hostname: ${user.hostname}
|
|
735
|
+
tunnel: ${user.tunnelId || ''}
|
|
736
|
+
`;
|
|
737
|
+
const exportConfigPath = path.join(exportDir, 'config.yml');
|
|
738
|
+
writePrivateFile(exportConfigPath, exportConfig);
|
|
739
|
+
|
|
740
|
+
// Create README with hop client instructions
|
|
741
|
+
const readme = `# Hop Terminal for ${username}
|
|
742
|
+
|
|
743
|
+
Your hop URL: https://${user.hostname}
|
|
744
|
+
|
|
745
|
+
## Quick Start
|
|
746
|
+
|
|
747
|
+
1. Install hop globally:
|
|
748
|
+
npm install -g hop-shell
|
|
749
|
+
|
|
750
|
+
2. Start hop with credentials:
|
|
751
|
+
hop client ./credentials.json
|
|
752
|
+
|
|
753
|
+
3. On first run, you'll be prompted to:
|
|
754
|
+
- Set a password (required)
|
|
755
|
+
- Scan a QR code for 2FA (TOTP)
|
|
756
|
+
|
|
757
|
+
4. Open your URL and log in:
|
|
758
|
+
https://${user.hostname}
|
|
759
|
+
|
|
760
|
+
## Security
|
|
761
|
+
|
|
762
|
+
- credentials.json contains your tunnel secret - keep it safe!
|
|
763
|
+
- On first run, you MUST set up password + TOTP authentication
|
|
764
|
+
- Anyone accessing your URL will need YOUR password and TOTP code
|
|
765
|
+
- Your credentials are stored in ~/.hop-shell/clients/<tunnel-id>/
|
|
766
|
+
|
|
767
|
+
## Troubleshooting
|
|
768
|
+
|
|
769
|
+
If you need to reset your password/TOTP:
|
|
770
|
+
rm -rf ~/.hop-shell/clients/
|
|
771
|
+
|
|
772
|
+
Then run hop client again to set up fresh credentials.
|
|
773
|
+
`;
|
|
774
|
+
const exportReadme = path.join(exportDir, 'README.md');
|
|
775
|
+
fs.writeFileSync(exportReadme, readme);
|
|
776
|
+
|
|
777
|
+
log(`\n${BOLD}${GREEN}✓ Credentials exported for "${username}"${RESET}`, GREEN);
|
|
778
|
+
log(`\nExport location: ${exportDir}`, BOLD);
|
|
779
|
+
log(`\nFiles created:`, RESET);
|
|
780
|
+
log(` - credentials.json (tunnel credentials - keep secret!)`, RESET);
|
|
781
|
+
log(` - config.yml (hostname config)`, RESET);
|
|
782
|
+
log(` - README.md (setup instructions)`, RESET);
|
|
783
|
+
log(`\n${YELLOW}Send this folder to ${username}.${RESET}`, YELLOW);
|
|
784
|
+
log(`\nThey should:`, RESET);
|
|
785
|
+
log(` 1. Install hop: ${CYAN}npm install -g hop-shell${RESET}`, RESET);
|
|
786
|
+
log(` 2. Run: ${CYAN}hop client ./credentials.json${RESET}`, RESET);
|
|
787
|
+
log(` 3. Set up their password + TOTP on first run`, RESET);
|
|
788
|
+
log(`\nTheir URL will be: https://${user.hostname}`, CYAN);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Check if tunnel is already running
|
|
792
|
+
function isServerRunning() {
|
|
793
|
+
if (!fs.existsSync(STATE_FILE)) return null;
|
|
794
|
+
ensurePrivateFile(STATE_FILE);
|
|
795
|
+
try {
|
|
796
|
+
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
797
|
+
// Check if the process is still alive
|
|
798
|
+
try {
|
|
799
|
+
process.kill(state.pid, 0);
|
|
800
|
+
return state;
|
|
801
|
+
} catch (e) {
|
|
802
|
+
// Process is dead, clean up
|
|
803
|
+
fs.unlinkSync(STATE_FILE);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
} catch (e) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Generate or load session secret
|
|
812
|
+
let SESSION_SECRET;
|
|
813
|
+
const existingState = isServerRunning();
|
|
814
|
+
if (existingState) {
|
|
815
|
+
SESSION_SECRET = existingState.sessionSecret;
|
|
816
|
+
} else {
|
|
817
|
+
SESSION_SECRET = crypto.randomBytes(32).toString('hex');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Persistence for Stable QR Code
|
|
821
|
+
let AUTH_SECRET;
|
|
822
|
+
function loadAuthSecret() {
|
|
823
|
+
if (fs.existsSync(SECRET_FILE_PATH)) {
|
|
824
|
+
AUTH_SECRET = fs.readFileSync(SECRET_FILE_PATH, 'utf8').trim();
|
|
825
|
+
ensurePrivateFile(SECRET_FILE_PATH);
|
|
826
|
+
} else {
|
|
827
|
+
AUTH_SECRET = authenticator.generateSecret();
|
|
828
|
+
writePrivateFile(SECRET_FILE_PATH, AUTH_SECRET);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Port configuration
|
|
833
|
+
const BASE_PORT = existingState ? existingState.port : Math.floor(Math.random() * (50000 - 10000 + 1) + 10000);
|
|
834
|
+
const PROXY_PORT = BASE_PORT;
|
|
835
|
+
|
|
836
|
+
// Session Management (using hay server - no tmux/ttyd)
|
|
837
|
+
const activeSessions = {}; // { sessionName: { room: HayRoom } }
|
|
838
|
+
const startingSessions = new Set(); // session names being initialized
|
|
839
|
+
const sessionRenames = {}; // { oldName: newName } - track renames for styling updates
|
|
840
|
+
let nextPort = BASE_PORT + 1;
|
|
841
|
+
|
|
842
|
+
// --- Login Rate Limiting (Exponential Backoff) ---
|
|
843
|
+
const failedAttempts = new Map(); // ip -> { count: number, lastAttempt: number }
|
|
844
|
+
const FAILED_ATTEMPT_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
845
|
+
const FAILED_ATTEMPT_MAX = 1000;
|
|
846
|
+
|
|
847
|
+
function pruneFailedAttempts(now = Date.now()) {
|
|
848
|
+
for (const [ip, record] of failedAttempts.entries()) {
|
|
849
|
+
if (now - record.lastAttempt > FAILED_ATTEMPT_TTL_MS) {
|
|
850
|
+
failedAttempts.delete(ip);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (failedAttempts.size > FAILED_ATTEMPT_MAX) {
|
|
854
|
+
const entries = Array.from(failedAttempts.entries())
|
|
855
|
+
.sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
|
856
|
+
const overflow = failedAttempts.size - FAILED_ATTEMPT_MAX;
|
|
857
|
+
for (let i = 0; i < overflow; i++) {
|
|
858
|
+
failedAttempts.delete(entries[i][0]);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function checkRateLimit(ip) {
|
|
864
|
+
pruneFailedAttempts();
|
|
865
|
+
const record = failedAttempts.get(ip);
|
|
866
|
+
if (!record || record.count < 5) return { allowed: true };
|
|
867
|
+
|
|
868
|
+
const now = Date.now();
|
|
869
|
+
// 5 failures = 30s, 6 = 60s, 7 = 120s... (30 * 2^(n-5))
|
|
870
|
+
const waitSeconds = 30 * Math.pow(2, record.count - 5);
|
|
871
|
+
const lockedUntil = record.lastAttempt + (waitSeconds * 1000);
|
|
872
|
+
|
|
873
|
+
if (now < lockedUntil) {
|
|
874
|
+
return { allowed: false, remaining: Math.ceil((lockedUntil - now) / 1000) };
|
|
875
|
+
}
|
|
876
|
+
return { allowed: true };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function recordFailure(ip) {
|
|
880
|
+
const now = Date.now();
|
|
881
|
+
pruneFailedAttempts(now);
|
|
882
|
+
const record = failedAttempts.get(ip) || { count: 0, lastAttempt: 0 };
|
|
883
|
+
record.count++;
|
|
884
|
+
record.lastAttempt = now;
|
|
885
|
+
failedAttempts.set(ip, record);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function resetFailure(ip) {
|
|
889
|
+
failedAttempts.delete(ip);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Colors
|
|
893
|
+
const RESET = "\x1b[0m";
|
|
894
|
+
const GREEN = "\x1b[32m";
|
|
895
|
+
const YELLOW = "\x1b[33m";
|
|
896
|
+
const CYAN = "\x1b[36m";
|
|
897
|
+
const RED = "\x1b[31m";
|
|
898
|
+
const BOLD = "\x1b[1m";
|
|
899
|
+
const MAGENTA = "\x1b[35m";
|
|
900
|
+
|
|
901
|
+
function log(msg, color = RESET) {
|
|
902
|
+
console.log(`${color}${msg}${RESET}`);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function runCommand(command, args, opts = {}) {
|
|
906
|
+
return new Promise((resolve, reject) => {
|
|
907
|
+
const proc = spawn(command, args, { stdio: 'inherit', ...opts });
|
|
908
|
+
proc.on('close', (code) => {
|
|
909
|
+
if (code === 0) resolve();
|
|
910
|
+
else reject(new Error(`Command ${command} exited with code ${code}`));
|
|
911
|
+
});
|
|
912
|
+
proc.on('error', (err) => reject(err));
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function checkAndInstallDependencies() {
|
|
917
|
+
log(`[1/4] Checking dependencies...`, BOLD);
|
|
918
|
+
try {
|
|
919
|
+
await runCommand('brew', ['--version'], { stdio: 'ignore' });
|
|
920
|
+
} catch (e) {
|
|
921
|
+
log(` - Homebrew not found.`, YELLOW);
|
|
922
|
+
|
|
923
|
+
const readline = require('readline');
|
|
924
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
925
|
+
|
|
926
|
+
const answer = await new Promise(resolve => {
|
|
927
|
+
rl.question(` Install Homebrew now? (y/n) `, resolve);
|
|
928
|
+
});
|
|
929
|
+
rl.close();
|
|
930
|
+
|
|
931
|
+
if (answer.toLowerCase() !== 'y') {
|
|
932
|
+
log(`Error: Homebrew required. Install from https://brew.sh`, RED);
|
|
933
|
+
process.exit(1);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
log(` - Installing Homebrew...`, YELLOW);
|
|
937
|
+
try {
|
|
938
|
+
await runCommand('/bin/bash', ['-c',
|
|
939
|
+
'curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | bash'
|
|
940
|
+
], { stdio: 'inherit' });
|
|
941
|
+
|
|
942
|
+
// Add brew to PATH for Apple Silicon
|
|
943
|
+
const brewPaths = ['/opt/homebrew/bin', '/usr/local/bin'];
|
|
944
|
+
for (const p of brewPaths) {
|
|
945
|
+
if (fs.existsSync(path.join(p, 'brew'))) {
|
|
946
|
+
process.env.PATH = `${p}:${process.env.PATH}`;
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
log(` - Homebrew installed.`, GREEN);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
log(`Error: Failed to install Homebrew.`, RED);
|
|
953
|
+
process.exit(1);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const deps = ['cloudflared']; // tmux/ttyd not needed - using node-pty via hay
|
|
958
|
+
for (const dep of deps) {
|
|
959
|
+
try {
|
|
960
|
+
await runCommand('which', [dep], { stdio: 'ignore' });
|
|
961
|
+
log(` - ${dep} found.`, GREEN);
|
|
962
|
+
} catch (e) {
|
|
963
|
+
log(` - ${dep} NOT found. Installing...`, YELLOW);
|
|
964
|
+
try {
|
|
965
|
+
await runCommand('brew', ['install', dep]);
|
|
966
|
+
log(` - ${dep} installed.`, GREEN);
|
|
967
|
+
} catch (err) {
|
|
968
|
+
log(` - Failed to install ${dep}. Check Xcode license?`, RED);
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// List active hay rooms as sessions
|
|
976
|
+
function listActiveSessions() {
|
|
977
|
+
const sessions = [];
|
|
978
|
+
for (const [name, room] of hayRooms.rooms) {
|
|
979
|
+
sessions.push({
|
|
980
|
+
name,
|
|
981
|
+
windows: 1,
|
|
982
|
+
attached: room.clients.size > 0
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
return sessions;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function hopSessionExists(name) {
|
|
989
|
+
if (!name) return false;
|
|
990
|
+
return hayRooms.hasRoom(name) || activeSessions[name];
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function listAllSessionEntries() {
|
|
994
|
+
const haySessions = listActiveSessions().filter(s => s.name.startsWith(HOP_SESSION_PREFIX));
|
|
995
|
+
const sessionMap = new Map();
|
|
996
|
+
|
|
997
|
+
for (const s of haySessions) {
|
|
998
|
+
const config = getSessionConfig(s.name);
|
|
999
|
+
const type = config?.type || 'terminal';
|
|
1000
|
+
sessionMap.set(s.name, {
|
|
1001
|
+
...s,
|
|
1002
|
+
type,
|
|
1003
|
+
port: config?.port,
|
|
1004
|
+
displayName: toDisplaySessionName(s.name)
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
for (const [name, config] of Object.entries(sessionConfigs)) {
|
|
1009
|
+
if (!name.startsWith(HOP_SESSION_PREFIX)) continue;
|
|
1010
|
+
if (!sessionMap.has(name)) {
|
|
1011
|
+
sessionMap.set(name, {
|
|
1012
|
+
name,
|
|
1013
|
+
windows: null,
|
|
1014
|
+
attached: false,
|
|
1015
|
+
type: config.type || 'terminal',
|
|
1016
|
+
port: config.port,
|
|
1017
|
+
displayName: toDisplaySessionName(name)
|
|
1018
|
+
});
|
|
1019
|
+
} else if (config?.type === 'port') {
|
|
1020
|
+
const entry = sessionMap.get(name);
|
|
1021
|
+
entry.type = 'port';
|
|
1022
|
+
entry.port = config.port;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return Array.from(sessionMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Light theme for hay terminal
|
|
1030
|
+
function getTheme() {
|
|
1031
|
+
return JSON.stringify({
|
|
1032
|
+
'background': '#ffffff',
|
|
1033
|
+
'foreground': '#18181b',
|
|
1034
|
+
'cursor': '#7c3aed',
|
|
1035
|
+
'selectionBackground': '#ddd6fe',
|
|
1036
|
+
'black': '#18181b',
|
|
1037
|
+
'red': '#dc2626',
|
|
1038
|
+
'green': '#16a34a',
|
|
1039
|
+
'yellow': '#ca8a04',
|
|
1040
|
+
'blue': '#2563eb',
|
|
1041
|
+
'magenta': '#9333ea',
|
|
1042
|
+
'cyan': '#0891b2',
|
|
1043
|
+
'white': '#f4f4f5'
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Start or get existing session (uses hay for terminal sharing)
|
|
1048
|
+
function getOrCreateSession(sessionName, cwd) {
|
|
1049
|
+
if (!sessionName || typeof sessionName !== 'string' || !SESSION_NAME_RE.test(sessionName)) {
|
|
1050
|
+
throw new Error('Invalid session name');
|
|
1051
|
+
}
|
|
1052
|
+
const existingConfig = getSessionConfig(sessionName);
|
|
1053
|
+
if (existingConfig && existingConfig.type === 'port') {
|
|
1054
|
+
throw new Error('Session is configured as a port proxy');
|
|
1055
|
+
}
|
|
1056
|
+
ensureTerminalSessionConfig(sessionName, cwd);
|
|
1057
|
+
if (activeSessions[sessionName]) {
|
|
1058
|
+
return activeSessions[sessionName];
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const config = getSessionConfig(sessionName);
|
|
1062
|
+
const effectiveCwd = cwd || config?.cwd;
|
|
1063
|
+
|
|
1064
|
+
// Create hay room for this session (with optional cwd override)
|
|
1065
|
+
const room = hayRooms.getRoom(sessionName, { cols: 80, rows: 24 }, effectiveCwd);
|
|
1066
|
+
|
|
1067
|
+
room.on('empty', () => {
|
|
1068
|
+
delete activeSessions[sessionName];
|
|
1069
|
+
const config = getSessionConfig(sessionName);
|
|
1070
|
+
if (config && config.type !== 'port') {
|
|
1071
|
+
deleteSessionConfig(sessionName);
|
|
1072
|
+
}
|
|
1073
|
+
log(`Session ${toDisplaySessionName(sessionName)} closed.`, YELLOW);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
room.on('exit', () => {
|
|
1077
|
+
delete activeSessions[sessionName];
|
|
1078
|
+
log(`Session ${toDisplaySessionName(sessionName)} exited.`, YELLOW);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
activeSessions[sessionName] = { room };
|
|
1082
|
+
log(` Started session "${toDisplaySessionName(sessionName)}" (hay)`, GREEN);
|
|
1083
|
+
|
|
1084
|
+
return activeSessions[sessionName];
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Resolve current session name (follows rename chain)
|
|
1088
|
+
function resolveSessionName(name) {
|
|
1089
|
+
let current = name;
|
|
1090
|
+
while (sessionRenames[current]) {
|
|
1091
|
+
current = sessionRenames[current];
|
|
1092
|
+
}
|
|
1093
|
+
return current;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function getSessionSwitcherOverlay() {
|
|
1097
|
+
return `
|
|
1098
|
+
<style id="hop-session-switcher-style">
|
|
1099
|
+
#hop-session-fab {
|
|
1100
|
+
position: fixed;
|
|
1101
|
+
left: 0;
|
|
1102
|
+
top: 0;
|
|
1103
|
+
z-index: 2147483647;
|
|
1104
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
1105
|
+
user-select: none;
|
|
1106
|
+
}
|
|
1107
|
+
#hop-session-fab-btn {
|
|
1108
|
+
width: 44px;
|
|
1109
|
+
height: 44px;
|
|
1110
|
+
border-radius: 999px;
|
|
1111
|
+
background: rgba(24, 24, 27, 0.9);
|
|
1112
|
+
color: #fff;
|
|
1113
|
+
display: flex;
|
|
1114
|
+
align-items: center;
|
|
1115
|
+
justify-content: center;
|
|
1116
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
1117
|
+
box-shadow: 0 6px 18px rgba(0,0,0,0.2);
|
|
1118
|
+
cursor: pointer;
|
|
1119
|
+
padding: 0;
|
|
1120
|
+
}
|
|
1121
|
+
#hop-session-fab-btn:active { transform: scale(0.97); }
|
|
1122
|
+
#hop-session-fab-btn svg { width: 20px; height: 20px; }
|
|
1123
|
+
#hop-session-fab-menu {
|
|
1124
|
+
position: absolute;
|
|
1125
|
+
right: 0;
|
|
1126
|
+
bottom: 52px;
|
|
1127
|
+
min-width: 200px;
|
|
1128
|
+
max-width: min(82vw, 320px);
|
|
1129
|
+
background: #fff;
|
|
1130
|
+
color: #18181b;
|
|
1131
|
+
border: 1px solid #e4e4e7;
|
|
1132
|
+
border-radius: 12px;
|
|
1133
|
+
box-shadow: 0 12px 28px rgba(0,0,0,0.16);
|
|
1134
|
+
padding: 8px;
|
|
1135
|
+
display: none;
|
|
1136
|
+
}
|
|
1137
|
+
#hop-session-fab.open #hop-session-fab-menu { display: block; }
|
|
1138
|
+
#hop-session-fab-menu .hop-title {
|
|
1139
|
+
font-size: 12px;
|
|
1140
|
+
font-weight: 600;
|
|
1141
|
+
color: #71717a;
|
|
1142
|
+
padding: 6px 8px;
|
|
1143
|
+
text-transform: uppercase;
|
|
1144
|
+
letter-spacing: 0.06em;
|
|
1145
|
+
}
|
|
1146
|
+
#hop-session-fab-menu .hop-item,
|
|
1147
|
+
#hop-session-fab-menu .hop-link {
|
|
1148
|
+
display: flex;
|
|
1149
|
+
align-items: center;
|
|
1150
|
+
gap: 8px;
|
|
1151
|
+
width: 100%;
|
|
1152
|
+
border: none;
|
|
1153
|
+
background: transparent;
|
|
1154
|
+
padding: 8px 10px;
|
|
1155
|
+
border-radius: 8px;
|
|
1156
|
+
font-size: 14px;
|
|
1157
|
+
color: #18181b;
|
|
1158
|
+
text-decoration: none;
|
|
1159
|
+
cursor: pointer;
|
|
1160
|
+
}
|
|
1161
|
+
#hop-session-fab-menu .hop-item:hover,
|
|
1162
|
+
#hop-session-fab-menu .hop-link:hover { background: #f4f4f5; }
|
|
1163
|
+
#hop-session-fab-menu .hop-item.current { font-weight: 600; }
|
|
1164
|
+
#hop-session-fab-menu .hop-dot {
|
|
1165
|
+
width: 7px;
|
|
1166
|
+
height: 7px;
|
|
1167
|
+
border-radius: 999px;
|
|
1168
|
+
background: #22c55e;
|
|
1169
|
+
}
|
|
1170
|
+
#hop-session-fab-menu .hop-subtle { color: #71717a; font-size: 12px; }
|
|
1171
|
+
</style>
|
|
1172
|
+
<div id="hop-session-fab" role="button" aria-label="Sessions">
|
|
1173
|
+
<button id="hop-session-fab-btn" aria-label="Sessions">
|
|
1174
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
1175
|
+
<rect x="3" y="4" width="18" height="13" rx="2"></rect>
|
|
1176
|
+
<path d="M8 21h8"></path>
|
|
1177
|
+
<path d="M12 17v4"></path>
|
|
1178
|
+
</svg>
|
|
1179
|
+
</button>
|
|
1180
|
+
<div id="hop-session-fab-menu" aria-hidden="true">
|
|
1181
|
+
<div class="hop-title">Sessions</div>
|
|
1182
|
+
<div id="hop-session-fab-list"></div>
|
|
1183
|
+
<a class="hop-link" href="/sessions">Session Picker</a>
|
|
1184
|
+
</div>
|
|
1185
|
+
</div>
|
|
1186
|
+
<script>
|
|
1187
|
+
(function() {
|
|
1188
|
+
var fab = document.getElementById('hop-session-fab');
|
|
1189
|
+
var btn = document.getElementById('hop-session-fab-btn');
|
|
1190
|
+
var menu = document.getElementById('hop-session-fab-menu');
|
|
1191
|
+
var list = document.getElementById('hop-session-fab-list');
|
|
1192
|
+
if (!fab || !btn || !menu || !list) return;
|
|
1193
|
+
|
|
1194
|
+
function clamp(v, min, max) { return Math.min(Math.max(v, min), max); }
|
|
1195
|
+
function getCurrentSession() {
|
|
1196
|
+
var path = window.location.pathname || '';
|
|
1197
|
+
if (path.indexOf('/s/') === 0) {
|
|
1198
|
+
var rest = path.slice(3);
|
|
1199
|
+
var parts = rest.split('/');
|
|
1200
|
+
try { return decodeURIComponent(parts[0] || ''); } catch (e) { return ''; }
|
|
1201
|
+
}
|
|
1202
|
+
return '';
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
var stored = null;
|
|
1206
|
+
try { stored = JSON.parse(localStorage.getItem('hop-fab-pos') || 'null'); } catch (e) { stored = null; }
|
|
1207
|
+
function setPos(x, y) {
|
|
1208
|
+
fab.style.left = x + 'px';
|
|
1209
|
+
fab.style.top = y + 'px';
|
|
1210
|
+
}
|
|
1211
|
+
function initPos() {
|
|
1212
|
+
var pad = 12;
|
|
1213
|
+
var x = (stored && typeof stored.x === 'number') ? stored.x : (window.innerWidth - 56);
|
|
1214
|
+
var y = (stored && typeof stored.y === 'number') ? stored.y : (window.innerHeight - 120);
|
|
1215
|
+
x = clamp(x, pad, window.innerWidth - 56);
|
|
1216
|
+
y = clamp(y, pad, window.innerHeight - 56);
|
|
1217
|
+
setPos(x, y);
|
|
1218
|
+
}
|
|
1219
|
+
initPos();
|
|
1220
|
+
|
|
1221
|
+
var dragging = false;
|
|
1222
|
+
var moved = false;
|
|
1223
|
+
var startX = 0, startY = 0, origX = 0, origY = 0;
|
|
1224
|
+
|
|
1225
|
+
function onDown(e) {
|
|
1226
|
+
dragging = true;
|
|
1227
|
+
moved = false;
|
|
1228
|
+
var touch = e.touches ? e.touches[0] : e;
|
|
1229
|
+
startX = touch.clientX;
|
|
1230
|
+
startY = touch.clientY;
|
|
1231
|
+
var rect = fab.getBoundingClientRect();
|
|
1232
|
+
origX = rect.left;
|
|
1233
|
+
origY = rect.top;
|
|
1234
|
+
e.preventDefault();
|
|
1235
|
+
}
|
|
1236
|
+
function onMove(e) {
|
|
1237
|
+
if (!dragging) return;
|
|
1238
|
+
var touch = e.touches ? e.touches[0] : e;
|
|
1239
|
+
var dx = touch.clientX - startX;
|
|
1240
|
+
var dy = touch.clientY - startY;
|
|
1241
|
+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) moved = true;
|
|
1242
|
+
var x = clamp(origX + dx, 8, window.innerWidth - 56);
|
|
1243
|
+
var y = clamp(origY + dy, 8, window.innerHeight - 56);
|
|
1244
|
+
setPos(x, y);
|
|
1245
|
+
}
|
|
1246
|
+
function onUp() {
|
|
1247
|
+
if (!dragging) return;
|
|
1248
|
+
dragging = false;
|
|
1249
|
+
var rect = fab.getBoundingClientRect();
|
|
1250
|
+
try { localStorage.setItem('hop-fab-pos', JSON.stringify({ x: rect.left, y: rect.top })); } catch (e) { }
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
btn.addEventListener('touchstart', onDown, { passive: false });
|
|
1254
|
+
btn.addEventListener('touchmove', onMove, { passive: false });
|
|
1255
|
+
btn.addEventListener('touchend', onUp);
|
|
1256
|
+
btn.addEventListener('mousedown', onDown);
|
|
1257
|
+
window.addEventListener('mousemove', onMove);
|
|
1258
|
+
window.addEventListener('mouseup', onUp);
|
|
1259
|
+
|
|
1260
|
+
function closeMenu() {
|
|
1261
|
+
fab.classList.remove('open');
|
|
1262
|
+
menu.setAttribute('aria-hidden', 'true');
|
|
1263
|
+
}
|
|
1264
|
+
function toggleMenu() {
|
|
1265
|
+
if (fab.classList.contains('open')) {
|
|
1266
|
+
closeMenu();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
fab.classList.add('open');
|
|
1270
|
+
menu.setAttribute('aria-hidden', 'false');
|
|
1271
|
+
fetchSessions();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
btn.addEventListener('click', function(e) {
|
|
1275
|
+
if (moved) return;
|
|
1276
|
+
e.preventDefault();
|
|
1277
|
+
toggleMenu();
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
document.addEventListener('click', function(e) {
|
|
1281
|
+
if (!fab.contains(e.target)) closeMenu();
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
function fetchSessions() {
|
|
1285
|
+
fetch('/api/sessions')
|
|
1286
|
+
.then(function(r) { return r.json(); })
|
|
1287
|
+
.then(function(data) {
|
|
1288
|
+
list.innerHTML = '';
|
|
1289
|
+
var sessions = data.sessions || [];
|
|
1290
|
+
var active = data.active || [];
|
|
1291
|
+
if (!sessions.length) {
|
|
1292
|
+
var empty = document.createElement('div');
|
|
1293
|
+
empty.className = 'hop-item';
|
|
1294
|
+
empty.textContent = 'No sessions';
|
|
1295
|
+
list.appendChild(empty);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
var current = getCurrentSession();
|
|
1299
|
+
sessions.forEach(function(s) {
|
|
1300
|
+
var item = document.createElement('button');
|
|
1301
|
+
item.className = 'hop-item';
|
|
1302
|
+
if (s.name === current) item.classList.add('current');
|
|
1303
|
+
if (active.indexOf(s.name) !== -1) {
|
|
1304
|
+
var dot = document.createElement('span');
|
|
1305
|
+
dot.className = 'hop-dot';
|
|
1306
|
+
item.appendChild(dot);
|
|
1307
|
+
}
|
|
1308
|
+
var label = document.createElement('span');
|
|
1309
|
+
var suffix = s.type === 'port' ? (s.port ? ' (PORT ' + s.port + ')' : ' (PORT)') : '';
|
|
1310
|
+
label.textContent = (s.displayName || s.name) + suffix;
|
|
1311
|
+
item.appendChild(label);
|
|
1312
|
+
item.addEventListener('click', function(ev) {
|
|
1313
|
+
ev.stopPropagation();
|
|
1314
|
+
window.location.href = '/s/' + encodeURIComponent(s.name) + '/';
|
|
1315
|
+
});
|
|
1316
|
+
list.appendChild(item);
|
|
1317
|
+
});
|
|
1318
|
+
})
|
|
1319
|
+
.catch(function() {
|
|
1320
|
+
list.innerHTML = '<div class="hop-item hop-subtle">Failed to load</div>';
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
})();
|
|
1324
|
+
</script>
|
|
1325
|
+
`;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function shouldInjectSessionSwitcher(req) {
|
|
1329
|
+
if (!req || req.method !== 'GET') return false;
|
|
1330
|
+
const accept = String(req.headers?.accept || '').toLowerCase();
|
|
1331
|
+
if (!accept.includes('text/html')) return false;
|
|
1332
|
+
const dest = String(req.headers?.['sec-fetch-dest'] || '');
|
|
1333
|
+
if (dest && dest !== 'document') return false;
|
|
1334
|
+
return true;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function buildSessionPath(displayName) {
|
|
1338
|
+
return `/s/${encodeURIComponent(displayName)}/`;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function stripSessionParam(search) {
|
|
1342
|
+
if (!search) return '';
|
|
1343
|
+
const params = new URLSearchParams(search);
|
|
1344
|
+
params.delete('session');
|
|
1345
|
+
const qs = params.toString();
|
|
1346
|
+
return qs ? `?${qs}` : '';
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function parseSessionFromRequest(req, parsedUrl) {
|
|
1350
|
+
const pathname = parsedUrl.pathname || '';
|
|
1351
|
+
if (pathname.startsWith('/s/')) {
|
|
1352
|
+
const rest = pathname.slice(3);
|
|
1353
|
+
const parts = rest.split('/');
|
|
1354
|
+
const rawName = parts.shift() || '';
|
|
1355
|
+
let displayName = '';
|
|
1356
|
+
try {
|
|
1357
|
+
displayName = decodeURIComponent(rawName);
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
if (!SESSION_NAME_RE.test(displayName)) return null;
|
|
1362
|
+
const internalName = toInternalSessionName(displayName);
|
|
1363
|
+
const needsSlash = pathname === `/s/${rawName}`;
|
|
1364
|
+
const strippedPathname = '/' + parts.join('/');
|
|
1365
|
+
const normalizedPathname = strippedPathname === '/' ? '/' : strippedPathname;
|
|
1366
|
+
const strippedUrl = normalizedPathname + (parsedUrl.search || '');
|
|
1367
|
+
return {
|
|
1368
|
+
displayName,
|
|
1369
|
+
internalName,
|
|
1370
|
+
strippedPathname: normalizedPathname,
|
|
1371
|
+
strippedUrl,
|
|
1372
|
+
source: 'path',
|
|
1373
|
+
needsSlash
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const sessionParam = parsedUrl.query.session;
|
|
1378
|
+
if (sessionParam && SESSION_NAME_RE.test(sessionParam)) {
|
|
1379
|
+
const displayName = sessionParam;
|
|
1380
|
+
const internalName = toInternalSessionName(displayName);
|
|
1381
|
+
const strippedPathname = parsedUrl.pathname || '/';
|
|
1382
|
+
const strippedUrl = strippedPathname + stripSessionParam(parsedUrl.search || '');
|
|
1383
|
+
return {
|
|
1384
|
+
displayName,
|
|
1385
|
+
internalName,
|
|
1386
|
+
strippedPathname,
|
|
1387
|
+
strippedUrl,
|
|
1388
|
+
source: 'query'
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function shouldRedirectToSessionPath(req) {
|
|
1396
|
+
if (!req || req.method !== 'GET') return false;
|
|
1397
|
+
const accept = String(req.headers?.accept || '').toLowerCase();
|
|
1398
|
+
return accept.includes('text/html');
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Helper to get client IP (handles proxies)
|
|
1402
|
+
function getClientIp(req) {
|
|
1403
|
+
return req.headers['cf-connecting-ip'] ||
|
|
1404
|
+
req.headers['x-forwarded-for'] ||
|
|
1405
|
+
req.socket.remoteAddress ||
|
|
1406
|
+
'unknown';
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function waitForPort(port, host = '127.0.0.1', timeoutMs = 4000, intervalMs = 120) {
|
|
1410
|
+
const start = Date.now();
|
|
1411
|
+
return new Promise((resolve) => {
|
|
1412
|
+
const tryConnect = () => {
|
|
1413
|
+
const socket = net.connect({ port, host });
|
|
1414
|
+
socket.once('connect', () => {
|
|
1415
|
+
socket.end();
|
|
1416
|
+
resolve(true);
|
|
1417
|
+
});
|
|
1418
|
+
socket.once('error', () => {
|
|
1419
|
+
socket.destroy();
|
|
1420
|
+
if (Date.now() - start >= timeoutMs) {
|
|
1421
|
+
resolve(false);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
setTimeout(tryConnect, intervalMs);
|
|
1425
|
+
});
|
|
1426
|
+
};
|
|
1427
|
+
tryConnect();
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function sendStartingPage(res, displayName) {
|
|
1432
|
+
setNoCache(res);
|
|
1433
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1434
|
+
res.end(`<!DOCTYPE html>
|
|
1435
|
+
<html>
|
|
1436
|
+
<head>
|
|
1437
|
+
<meta charset="UTF-8">
|
|
1438
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1439
|
+
<meta http-equiv="refresh" content="1">
|
|
1440
|
+
<title>Starting Session</title>
|
|
1441
|
+
<style>
|
|
1442
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #18181b; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
1443
|
+
.box { text-align: center; padding: 2rem; max-width: 420px; }
|
|
1444
|
+
.spinner { width: 28px; height: 28px; border: 3px solid #e4e4e7; border-top-color: #7c3aed; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
|
|
1445
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1446
|
+
a { color: #7c3aed; text-decoration: none; }
|
|
1447
|
+
</style>
|
|
1448
|
+
</head>
|
|
1449
|
+
<body>
|
|
1450
|
+
<div class="box">
|
|
1451
|
+
<div class="spinner"></div>
|
|
1452
|
+
<h2>Starting session…</h2>
|
|
1453
|
+
<p>This can take a few seconds. Refreshing automatically.</p>
|
|
1454
|
+
<p><a href="${buildSessionPath(displayName)}">Click to retry</a></p>
|
|
1455
|
+
</div>
|
|
1456
|
+
</body>
|
|
1457
|
+
</html>`);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function setNoCache(res) {
|
|
1461
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
1462
|
+
res.setHeader('Pragma', 'no-cache');
|
|
1463
|
+
res.setHeader('Expires', '0');
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function appendSetCookie(res, cookies) {
|
|
1467
|
+
const existing = res.getHeader('Set-Cookie');
|
|
1468
|
+
if (!existing) {
|
|
1469
|
+
res.setHeader('Set-Cookie', cookies);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const merged = Array.isArray(existing) ? existing.concat(cookies) : [existing].concat(cookies);
|
|
1473
|
+
res.setHeader('Set-Cookie', merged);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function clearAuthCookies(res) {
|
|
1477
|
+
const expires = new Date(0);
|
|
1478
|
+
appendSetCookie(res, [
|
|
1479
|
+
cookie.serialize(SESSION_COOKIE_NAME, '', {
|
|
1480
|
+
httpOnly: true,
|
|
1481
|
+
secure: true,
|
|
1482
|
+
sameSite: 'lax',
|
|
1483
|
+
path: '/',
|
|
1484
|
+
expires,
|
|
1485
|
+
maxAge: 0
|
|
1486
|
+
}),
|
|
1487
|
+
cookie.serialize(SESSION_COOKIE_NAME, '', {
|
|
1488
|
+
httpOnly: true,
|
|
1489
|
+
secure: true,
|
|
1490
|
+
sameSite: 'lax',
|
|
1491
|
+
path: '/api',
|
|
1492
|
+
expires,
|
|
1493
|
+
maxAge: 0
|
|
1494
|
+
})
|
|
1495
|
+
]);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function escapeHtml(value) {
|
|
1499
|
+
return String(value)
|
|
1500
|
+
.replace(/&/g, '&')
|
|
1501
|
+
.replace(/</g, '<')
|
|
1502
|
+
.replace(/>/g, '>')
|
|
1503
|
+
.replace(/\"/g, '"')
|
|
1504
|
+
.replace(/'/g, ''');
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
let lastAuthAt = 0;
|
|
1508
|
+
function recordAuth(authEmitter) {
|
|
1509
|
+
lastAuthAt = Date.now();
|
|
1510
|
+
if (authEmitter) authEmitter.emit('authenticated');
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function startAuthProxy(authEmitter) {
|
|
1514
|
+
log(`[2/4] Starting Session Manager on port ${PROXY_PORT}...`, BOLD);
|
|
1515
|
+
|
|
1516
|
+
const server = http.createServer((req, res) => {
|
|
1517
|
+
const parsedUrl = url.parse(req.url, true);
|
|
1518
|
+
const cookies = cookie.parse(req.headers.cookie || '');
|
|
1519
|
+
const ip = getClientIp(req);
|
|
1520
|
+
const sessionInfo = parseSessionFromRequest(req, parsedUrl);
|
|
1521
|
+
|
|
1522
|
+
// Check auth via cookie OR Authorization header (internal hop-to-hop only)
|
|
1523
|
+
// Bearer token is read from state file by other hop processes on same machine
|
|
1524
|
+
const authHeader = req.headers.authorization;
|
|
1525
|
+
const tokenFromHeader = authHeader && authHeader.startsWith('Bearer ')
|
|
1526
|
+
? authHeader.substring(7)
|
|
1527
|
+
: null;
|
|
1528
|
+
const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET
|
|
1529
|
+
|| tokenFromHeader === SESSION_SECRET;
|
|
1530
|
+
|
|
1531
|
+
// API: Auth status (used by local CLI to wait for remote login)
|
|
1532
|
+
if (parsedUrl.pathname === '/api/auth' && req.method === 'GET') {
|
|
1533
|
+
if (!isAuthenticated) {
|
|
1534
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1535
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1539
|
+
res.end(JSON.stringify({ lastAuthAt }));
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// --- LOGIN PAGE ---
|
|
1544
|
+
if (!isAuthenticated) {
|
|
1545
|
+
const passwordRequired = isPasswordEnabled();
|
|
1546
|
+
const storedPasswordHash = getStoredPassword();
|
|
1547
|
+
|
|
1548
|
+
// API Login endpoint - authenticates via password (if enabled) + TOTP
|
|
1549
|
+
if (req.method === 'POST' && parsedUrl.pathname === '/api/login') {
|
|
1550
|
+
const limit = checkRateLimit(ip);
|
|
1551
|
+
if (!limit.allowed) {
|
|
1552
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1553
|
+
res.end(JSON.stringify({ success: false, error: `Too many attempts. Try again in ${limit.remaining}s.` }));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
let body = '';
|
|
1558
|
+
req.on('data', chunk => body += chunk);
|
|
1559
|
+
req.on('end', () => {
|
|
1560
|
+
try {
|
|
1561
|
+
const { totp, password } = JSON.parse(body);
|
|
1562
|
+
|
|
1563
|
+
// Check password if enabled
|
|
1564
|
+
if (passwordRequired && !verifyPassword(password || '', storedPasswordHash)) {
|
|
1565
|
+
recordFailure(ip);
|
|
1566
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1567
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid password' }));
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Check TOTP
|
|
1572
|
+
if (authenticator.check(totp, AUTH_SECRET)) {
|
|
1573
|
+
resetFailure(ip);
|
|
1574
|
+
appendSetCookie(res, [
|
|
1575
|
+
cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
|
|
1576
|
+
httpOnly: true,
|
|
1577
|
+
secure: true,
|
|
1578
|
+
sameSite: 'lax',
|
|
1579
|
+
path: '/',
|
|
1580
|
+
maxAge: 60 * 60 * 24 * 7
|
|
1581
|
+
})
|
|
1582
|
+
]);
|
|
1583
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1584
|
+
res.end(JSON.stringify({ success: true }));
|
|
1585
|
+
recordAuth(authEmitter);
|
|
1586
|
+
} else {
|
|
1587
|
+
recordFailure(ip);
|
|
1588
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1589
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid code' }));
|
|
1590
|
+
}
|
|
1591
|
+
} catch (e) {
|
|
1592
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1593
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// Web form login (support / and /s/<name>/)
|
|
1600
|
+
if (req.method === 'POST' && (parsedUrl.pathname === '/' || parsedUrl.pathname.startsWith('/s/'))) {
|
|
1601
|
+
const limit = checkRateLimit(ip);
|
|
1602
|
+
if (!limit.allowed) {
|
|
1603
|
+
res.writeHead(429, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1604
|
+
res.end(`Too many attempts. Try again in ${limit.remaining}s. <a href="/">Back</a>`);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
let body = '';
|
|
1609
|
+
req.on('data', chunk => body += chunk);
|
|
1610
|
+
req.on('end', () => {
|
|
1611
|
+
const params = new URLSearchParams(body);
|
|
1612
|
+
const token = params.get('token');
|
|
1613
|
+
const password = params.get('password');
|
|
1614
|
+
const requestedDisplayName = params.get('session') || sessionInfo?.displayName;
|
|
1615
|
+
const requestedInternalName = requestedDisplayName
|
|
1616
|
+
? toInternalSessionName(requestedDisplayName)
|
|
1617
|
+
: null;
|
|
1618
|
+
|
|
1619
|
+
try {
|
|
1620
|
+
// Check password if enabled
|
|
1621
|
+
if (passwordRequired && !verifyPassword(password || '', storedPasswordHash)) {
|
|
1622
|
+
recordFailure(ip);
|
|
1623
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1624
|
+
res.end('Invalid password. <a href="/">Try Again</a>');
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Check TOTP
|
|
1629
|
+
if (authenticator.check(token, AUTH_SECRET)) {
|
|
1630
|
+
resetFailure(ip);
|
|
1631
|
+
appendSetCookie(res, [
|
|
1632
|
+
cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
|
|
1633
|
+
httpOnly: true,
|
|
1634
|
+
secure: true,
|
|
1635
|
+
sameSite: 'lax',
|
|
1636
|
+
path: '/',
|
|
1637
|
+
maxAge: 60 * 60 * 24 * 7
|
|
1638
|
+
})
|
|
1639
|
+
]);
|
|
1640
|
+
const redirectTarget = requestedInternalName
|
|
1641
|
+
? buildSessionPath(requestedDisplayName)
|
|
1642
|
+
: '/sessions';
|
|
1643
|
+
res.writeHead(302, { 'Location': redirectTarget });
|
|
1644
|
+
res.end();
|
|
1645
|
+
recordAuth(authEmitter);
|
|
1646
|
+
} else {
|
|
1647
|
+
recordFailure(ip);
|
|
1648
|
+
res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1649
|
+
res.end('Invalid Code. <a href="/">Try Again</a>');
|
|
1650
|
+
}
|
|
1651
|
+
} catch (e) {
|
|
1652
|
+
res.writeHead(401);
|
|
1653
|
+
res.end('Error verifying code');
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// Show login page - with password field if enabled
|
|
1660
|
+
const passwordFieldHtml = passwordRequired ? `
|
|
1661
|
+
<div style="margin-bottom: 1.5rem;">
|
|
1662
|
+
<input type="password" name="password" id="passwordField" placeholder="Password"
|
|
1663
|
+
style="width: 100%; padding: 0.8rem 1rem; font-size: 1rem; border: 1px solid #e4e4e7; border-radius: 8px; background: #fff; color: #18181b; box-sizing: border-box;"
|
|
1664
|
+
required>
|
|
1665
|
+
</div>
|
|
1666
|
+
<p style="color: #71717a; margin-bottom: 1rem; font-size: 0.9rem;">Then enter your authenticator code:</p>
|
|
1667
|
+
` : '';
|
|
1668
|
+
|
|
1669
|
+
const passwordCheckJs = passwordRequired ? `
|
|
1670
|
+
const passwordField = document.getElementById('passwordField');
|
|
1671
|
+
if (!passwordField.value) {
|
|
1672
|
+
passwordField.focus();
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
` : '';
|
|
1676
|
+
|
|
1677
|
+
const requestedDisplayName = sessionInfo?.displayName;
|
|
1678
|
+
const requestedInternalName = requestedDisplayName
|
|
1679
|
+
? toInternalSessionName(requestedDisplayName)
|
|
1680
|
+
: null;
|
|
1681
|
+
const sessionFieldHtml = requestedInternalName
|
|
1682
|
+
? `<input type="hidden" name="session" value="${escapeHtml(requestedDisplayName)}">`
|
|
1683
|
+
: '';
|
|
1684
|
+
|
|
1685
|
+
clearAuthCookies(res);
|
|
1686
|
+
setNoCache(res);
|
|
1687
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
1688
|
+
res.end(`
|
|
1689
|
+
<html>
|
|
1690
|
+
<head>
|
|
1691
|
+
<meta charset="UTF-8">
|
|
1692
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1693
|
+
<title>Verify Identity</title>
|
|
1694
|
+
<style>
|
|
1695
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #18181b; display: flex; justify-content: center; align-items: center; height: 100vh; margin:0; }
|
|
1696
|
+
.box { text-align: center; max-width: 400px; padding: 2rem; }
|
|
1697
|
+
h2 { margin-bottom: 1.5rem; font-weight: 600; font-size: 1.5rem; }
|
|
1698
|
+
.code-inputs { display: flex; gap: 8px; justify-content: center; margin-bottom: 2rem; }
|
|
1699
|
+
.code-inputs input {
|
|
1700
|
+
width: 44px; height: 56px;
|
|
1701
|
+
font-size: 24px; text-align: center;
|
|
1702
|
+
border: 1px solid #e4e4e7; border-radius: 8px;
|
|
1703
|
+
background: #fff; color: #18181b;
|
|
1704
|
+
-moz-appearance: textfield;
|
|
1705
|
+
}
|
|
1706
|
+
.code-inputs input:focus { outline: none; border-color: #7c3aed; box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); }
|
|
1707
|
+
.code-inputs input::-webkit-outer-spin-button,
|
|
1708
|
+
.code-inputs input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
1709
|
+
input[type="password"]:focus { outline: none; border-color: #7c3aed; box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.1); }
|
|
1710
|
+
|
|
1711
|
+
button { font-size: 1rem; padding: 0.8rem 2.5rem; background: #18181b; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; width: 100%; transition: opacity 0.2s; }
|
|
1712
|
+
button:hover { opacity: 0.9; }
|
|
1713
|
+
.hidden-submit { position: absolute; left: -9999px; }
|
|
1714
|
+
</style>
|
|
1715
|
+
</head>
|
|
1716
|
+
<body>
|
|
1717
|
+
<div class="box">
|
|
1718
|
+
<h2>Security Check</h2>
|
|
1719
|
+
${passwordRequired ? '<p style="color: #71717a; margin-bottom: 1rem; font-size: 0.9rem;">Enter your password:</p>' : '<p style="color: #71717a; margin-bottom: 2rem; font-size: 0.9rem;">Please enter the verification code from your authenticator app to continue.</p>'}
|
|
1720
|
+
<form method="POST" id="authForm">
|
|
1721
|
+
${passwordFieldHtml}
|
|
1722
|
+
${sessionFieldHtml}
|
|
1723
|
+
<div class="code-inputs" id="inputs">
|
|
1724
|
+
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
|
|
1725
|
+
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
|
|
1726
|
+
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
|
|
1727
|
+
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
|
|
1728
|
+
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
|
|
1729
|
+
<input type="text" maxlength="1" inputmode="numeric" pattern="[0-9]*" required>
|
|
1730
|
+
</div>
|
|
1731
|
+
<input type="hidden" name="token" id="fullToken">
|
|
1732
|
+
<button type="submit">Verify</button>
|
|
1733
|
+
</form>
|
|
1734
|
+
</div>
|
|
1735
|
+
<script>
|
|
1736
|
+
const inputs = document.querySelectorAll('.code-inputs input');
|
|
1737
|
+
const form = document.getElementById('authForm');
|
|
1738
|
+
const hidden = document.getElementById('fullToken');
|
|
1739
|
+
|
|
1740
|
+
inputs.forEach((input, i) => {
|
|
1741
|
+
input.addEventListener('input', e => {
|
|
1742
|
+
if (e.target.value.length === 1) {
|
|
1743
|
+
if (i < inputs.length - 1) inputs[i + 1].focus();
|
|
1744
|
+
else submit();
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
input.addEventListener('keydown', e => {
|
|
1748
|
+
if (e.key === 'Backspace' && !e.target.value && i > 0) {
|
|
1749
|
+
inputs[i - 1].focus();
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
input.addEventListener('paste', e => {
|
|
1753
|
+
e.preventDefault();
|
|
1754
|
+
const text = (e.clipboardData || window.clipboardData).getData('text');
|
|
1755
|
+
const digits = text.replace(/[^0-9]/g, '').slice(0, 6);
|
|
1756
|
+
if (digits.length === 0) return;
|
|
1757
|
+
digits.split('').forEach((char, index) => {
|
|
1758
|
+
if (inputs[index]) inputs[index].value = char;
|
|
1759
|
+
});
|
|
1760
|
+
if (digits.length === 6) submit();
|
|
1761
|
+
else inputs[Math.min(digits.length, 5)].focus();
|
|
1762
|
+
});
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
function submit() {
|
|
1766
|
+
${passwordCheckJs}
|
|
1767
|
+
const code = Array.from(inputs).map(i => i.value).join('');
|
|
1768
|
+
if (code.length === 6) {
|
|
1769
|
+
hidden.value = code;
|
|
1770
|
+
form.submit();
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
form.addEventListener('submit', e => {
|
|
1775
|
+
const code = Array.from(inputs).map(i => i.value).join('');
|
|
1776
|
+
hidden.value = code;
|
|
1777
|
+
});
|
|
1778
|
+
</script>
|
|
1779
|
+
</body>
|
|
1780
|
+
</html>
|
|
1781
|
+
`);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// --- AUTHENTICATED ROUTES ---
|
|
1786
|
+
const sessionInfoAuth = parseSessionFromRequest(req, parsedUrl);
|
|
1787
|
+
const sessionName = sessionInfoAuth ? sessionInfoAuth.internalName : null;
|
|
1788
|
+
const displayName = sessionInfoAuth ? sessionInfoAuth.displayName : null;
|
|
1789
|
+
const strippedUrl = sessionInfoAuth ? sessionInfoAuth.strippedUrl : req.url;
|
|
1790
|
+
const strippedPathname = sessionInfoAuth ? sessionInfoAuth.strippedPathname : (parsedUrl.pathname || '/');
|
|
1791
|
+
|
|
1792
|
+
if (sessionInfoAuth && sessionInfoAuth.source === 'query' && shouldRedirectToSessionPath(req)) {
|
|
1793
|
+
res.writeHead(302, { 'Location': buildSessionPath(displayName) });
|
|
1794
|
+
res.end();
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
if (sessionInfoAuth && sessionInfoAuth.source === 'path' && sessionInfoAuth.needsSlash && shouldRedirectToSessionPath(req)) {
|
|
1798
|
+
const target = buildSessionPath(displayName) + (parsedUrl.search || '');
|
|
1799
|
+
res.writeHead(302, { 'Location': target });
|
|
1800
|
+
res.end();
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// API: Ping (triggers auth event for mobile login)
|
|
1805
|
+
if (parsedUrl.pathname === '/api/ping' && req.method === 'POST') {
|
|
1806
|
+
recordAuth(authEmitter);
|
|
1807
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1808
|
+
res.end(JSON.stringify({ ok: true, message: 'Mobile login' }));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// API: List sessions
|
|
1813
|
+
if (parsedUrl.pathname === '/api/sessions' && req.method === 'GET') {
|
|
1814
|
+
const sessions = listAllSessionEntries().map(s => ({
|
|
1815
|
+
name: s.displayName,
|
|
1816
|
+
displayName: s.displayName,
|
|
1817
|
+
windows: s.windows,
|
|
1818
|
+
attached: s.attached,
|
|
1819
|
+
type: s.type,
|
|
1820
|
+
port: s.port
|
|
1821
|
+
}));
|
|
1822
|
+
const activeSessionNames = Object.keys(activeSessions).map(toDisplaySessionName);
|
|
1823
|
+
const startingSessionNames = Array.from(startingSessions).map(toDisplaySessionName);
|
|
1824
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1825
|
+
res.end(JSON.stringify({ sessions, active: activeSessionNames, starting: startingSessionNames }));
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// API: Create session
|
|
1830
|
+
if (parsedUrl.pathname === '/api/sessions' && req.method === 'POST') {
|
|
1831
|
+
let body = '';
|
|
1832
|
+
req.on('data', chunk => body += chunk);
|
|
1833
|
+
req.on('end', () => {
|
|
1834
|
+
try {
|
|
1835
|
+
let { name, type, port, cwd } = JSON.parse(body);
|
|
1836
|
+
const displayName = name;
|
|
1837
|
+
const internalName = toInternalSessionName(displayName);
|
|
1838
|
+
if (!internalName) {
|
|
1839
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1840
|
+
res.end(JSON.stringify({ error: 'Invalid session name' }));
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
type = (type || 'terminal').toLowerCase();
|
|
1844
|
+
if (type !== 'terminal' && type !== 'port') {
|
|
1845
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1846
|
+
res.end(JSON.stringify({ error: 'Invalid session type' }));
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (type === 'port') {
|
|
1850
|
+
const parsedPort = parseInt(port, 10);
|
|
1851
|
+
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
|
1852
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1853
|
+
res.end(JSON.stringify({ error: 'Invalid port' }));
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
if (hopSessionExists(internalName)) {
|
|
1857
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1858
|
+
res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
const existingConfig = getSessionConfig(internalName);
|
|
1862
|
+
if (existingConfig && existingConfig.type === 'terminal') {
|
|
1863
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1864
|
+
res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
setPortSessionConfig(internalName, parsedPort);
|
|
1868
|
+
} else {
|
|
1869
|
+
const existingConfig = getSessionConfig(internalName);
|
|
1870
|
+
if (existingConfig && existingConfig.type === 'port') {
|
|
1871
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1872
|
+
res.end(JSON.stringify({ error: 'Port session already exists with this name' }));
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
getOrCreateSession(internalName, cwd);
|
|
1876
|
+
}
|
|
1877
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1878
|
+
res.end(JSON.stringify({ ok: true, name: displayName, type }));
|
|
1879
|
+
} catch (e) {
|
|
1880
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1881
|
+
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
1882
|
+
}
|
|
1883
|
+
});
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// API: Rename session
|
|
1888
|
+
if (parsedUrl.pathname === '/api/sessions/rename' && req.method === 'POST') {
|
|
1889
|
+
let body = '';
|
|
1890
|
+
req.on('data', chunk => body += chunk);
|
|
1891
|
+
req.on('end', () => {
|
|
1892
|
+
let { oldName, newName } = JSON.parse(body);
|
|
1893
|
+
const oldDisplayName = oldName;
|
|
1894
|
+
const newDisplayName = newName;
|
|
1895
|
+
oldName = toInternalSessionName(oldDisplayName);
|
|
1896
|
+
newName = toInternalSessionName(newDisplayName);
|
|
1897
|
+
// Validate BOTH names to prevent command injection
|
|
1898
|
+
if (!oldName) {
|
|
1899
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1900
|
+
res.end(JSON.stringify({ error: 'Invalid old name' }));
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
if (!newName) {
|
|
1904
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1905
|
+
res.end(JSON.stringify({ error: 'Invalid new name' }));
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
if (getSessionConfig(newName) || hopSessionExists(newName)) {
|
|
1909
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
1910
|
+
res.end(JSON.stringify({ error: 'Session name already in use' }));
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Update config for rename
|
|
1915
|
+
try {
|
|
1916
|
+
const config = getSessionConfig(oldName);
|
|
1917
|
+
if (config && config.type === 'port') {
|
|
1918
|
+
deleteSessionConfig(oldName);
|
|
1919
|
+
setSessionConfig(newName, { ...config, updatedAt: new Date().toISOString() });
|
|
1920
|
+
} else {
|
|
1921
|
+
if (!hopSessionExists(oldName)) {
|
|
1922
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1923
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
// Note: hay rooms can't be renamed - just update config tracking
|
|
1927
|
+
if (config) {
|
|
1928
|
+
deleteSessionConfig(oldName);
|
|
1929
|
+
setSessionConfig(newName, { ...config, type: 'terminal', updatedAt: new Date().toISOString() });
|
|
1930
|
+
} else {
|
|
1931
|
+
deleteSessionConfig(oldName);
|
|
1932
|
+
ensureTerminalSessionConfig(newName);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// Update internal map if it's managed by us
|
|
1937
|
+
if (activeSessions[oldName]) {
|
|
1938
|
+
activeSessions[newName] = activeSessions[oldName];
|
|
1939
|
+
delete activeSessions[oldName];
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// Track rename so styling intervals can follow
|
|
1943
|
+
sessionRenames[oldName] = newName;
|
|
1944
|
+
|
|
1945
|
+
// Rename log/exit files if they exist (optional cleanup)
|
|
1946
|
+
|
|
1947
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1948
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1951
|
+
res.end(JSON.stringify({ error: 'Rename failed' }));
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// API: Delete session
|
|
1958
|
+
if (parsedUrl.pathname === '/api/sessions/delete' && req.method === 'POST') {
|
|
1959
|
+
let body = '';
|
|
1960
|
+
req.on('data', chunk => body += chunk);
|
|
1961
|
+
req.on('end', () => {
|
|
1962
|
+
let { name } = JSON.parse(body);
|
|
1963
|
+
name = toInternalSessionName(name);
|
|
1964
|
+
// Validate name to prevent command injection
|
|
1965
|
+
if (!name) {
|
|
1966
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1967
|
+
res.end(JSON.stringify({ error: 'Invalid session name' }));
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
try {
|
|
1972
|
+
const config = getSessionConfig(name);
|
|
1973
|
+
if (config && config.type === 'port') {
|
|
1974
|
+
deleteSessionConfig(name);
|
|
1975
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1976
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const exists = hopSessionExists(name);
|
|
1981
|
+
if (!exists && !config) {
|
|
1982
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1983
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Close the hay room if it exists
|
|
1988
|
+
if (hayRooms.hasRoom(name)) {
|
|
1989
|
+
hayRooms.closeRoom(name);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Clean up internal tracking
|
|
1993
|
+
if (activeSessions[name]) {
|
|
1994
|
+
delete activeSessions[name];
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Always remove session config if present
|
|
1998
|
+
if (config) {
|
|
1999
|
+
deleteSessionConfig(name);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2003
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2004
|
+
} catch (e) {
|
|
2005
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2006
|
+
res.end(JSON.stringify({ error: 'Delete failed' }));
|
|
2007
|
+
}
|
|
2008
|
+
});
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// If no session selected, show session picker
|
|
2013
|
+
if (parsedUrl.pathname === '/sessions') {
|
|
2014
|
+
const sessionsFile = path.join(__dirname, 'sessions.html');
|
|
2015
|
+
let sessionsHtml = '<!-- sessions.html not found -->';
|
|
2016
|
+
try {
|
|
2017
|
+
sessionsHtml = fs.readFileSync(sessionsFile, 'utf8');
|
|
2018
|
+
} catch (e) {
|
|
2019
|
+
log(`Warning: sessions.html not found`, YELLOW);
|
|
2020
|
+
}
|
|
2021
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2022
|
+
res.end(sessionsHtml);
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
if (!sessionName) {
|
|
2026
|
+
const sessionsFile = path.join(__dirname, 'sessions.html');
|
|
2027
|
+
let sessionsHtml = '<!-- sessions.html not found -->';
|
|
2028
|
+
try {
|
|
2029
|
+
sessionsHtml = fs.readFileSync(sessionsFile, 'utf8');
|
|
2030
|
+
} catch (e) {
|
|
2031
|
+
log(`Warning: sessions.html not found`, YELLOW);
|
|
2032
|
+
}
|
|
2033
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2034
|
+
res.end(sessionsHtml);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
// --- CHECK FOR iOS - Intercept and inject virtual keyboard ---
|
|
2038
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
2039
|
+
const isIOS = /iPad|iPhone|iPod/.test(userAgent);
|
|
2040
|
+
const isMobile = isIOS || /Android/i.test(userAgent);
|
|
2041
|
+
const sessionConfig = getSessionConfig(sessionName);
|
|
2042
|
+
if (sessionConfig && sessionConfig.type === 'port') {
|
|
2043
|
+
const targetPort = sessionConfig.port;
|
|
2044
|
+
if (!targetPort) {
|
|
2045
|
+
res.writeHead(500);
|
|
2046
|
+
res.end('Port session missing target port.');
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
const shouldInject = isMobile && shouldInjectSessionSwitcher(req);
|
|
2050
|
+
if (shouldInject) {
|
|
2051
|
+
waitForPort(targetPort).then((ready) => {
|
|
2052
|
+
if (!ready) {
|
|
2053
|
+
sendStartingPage(res, displayName);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const headers = { ...req.headers };
|
|
2057
|
+
delete headers['accept-encoding'];
|
|
2058
|
+
const proxyReq = http.request({
|
|
2059
|
+
hostname: '127.0.0.1',
|
|
2060
|
+
port: targetPort,
|
|
2061
|
+
method: req.method,
|
|
2062
|
+
path: strippedUrl,
|
|
2063
|
+
headers
|
|
2064
|
+
}, (proxyRes) => {
|
|
2065
|
+
const contentType = String(proxyRes.headers['content-type'] || '').toLowerCase();
|
|
2066
|
+
const contentEncoding = String(proxyRes.headers['content-encoding'] || '').toLowerCase();
|
|
2067
|
+
const isHtml = contentType.includes('text/html');
|
|
2068
|
+
const isPlain = contentType.includes('text/plain');
|
|
2069
|
+
const canModify = (isHtml || isPlain) && (!contentEncoding || contentEncoding === 'identity');
|
|
2070
|
+
if (!canModify) {
|
|
2071
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
2072
|
+
proxyRes.pipe(res);
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
let chunks = [];
|
|
2076
|
+
proxyRes.on('data', chunk => chunks.push(chunk));
|
|
2077
|
+
proxyRes.on('end', () => {
|
|
2078
|
+
let html = Buffer.concat(chunks).toString('utf8');
|
|
2079
|
+
const overlay = getSessionSwitcherOverlay();
|
|
2080
|
+
if (isPlain && !html.includes('<html')) {
|
|
2081
|
+
const body = `<pre style="white-space:pre-wrap;font:14px/1.4 ui-monospace,Menlo,Monaco,Consolas,monospace;margin:24px;color:#18181b;">${escapeHtml(html)}</pre>`;
|
|
2082
|
+
html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body>${body}${overlay}</body></html>`;
|
|
2083
|
+
} else if (html.includes('</body>')) {
|
|
2084
|
+
html = html.split('</body>').join(overlay + '</body>');
|
|
2085
|
+
} else {
|
|
2086
|
+
html += overlay;
|
|
2087
|
+
}
|
|
2088
|
+
const outHeaders = { ...proxyRes.headers };
|
|
2089
|
+
delete outHeaders['content-length'];
|
|
2090
|
+
delete outHeaders['content-encoding'];
|
|
2091
|
+
if (isPlain) {
|
|
2092
|
+
outHeaders['content-type'] = 'text/html; charset=utf-8';
|
|
2093
|
+
}
|
|
2094
|
+
res.writeHead(proxyRes.statusCode || 200, outHeaders);
|
|
2095
|
+
res.end(html);
|
|
2096
|
+
});
|
|
2097
|
+
});
|
|
2098
|
+
proxyReq.on('error', () => {
|
|
2099
|
+
sendStartingPage(res, displayName);
|
|
2100
|
+
});
|
|
2101
|
+
req.pipe(proxyReq);
|
|
2102
|
+
});
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
waitForPort(targetPort).then((ready) => {
|
|
2107
|
+
if (!ready) {
|
|
2108
|
+
sendStartingPage(res, displayName);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${targetPort}`, ws: false });
|
|
2112
|
+
const originalUrl = req.url;
|
|
2113
|
+
req.url = strippedUrl;
|
|
2114
|
+
proxy.web(req, res, {}, () => {
|
|
2115
|
+
sendStartingPage(res, displayName);
|
|
2116
|
+
});
|
|
2117
|
+
req.url = originalUrl;
|
|
2118
|
+
});
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// Ensure session exists (creates hay room if needed)
|
|
2123
|
+
try {
|
|
2124
|
+
getOrCreateSession(sessionName);
|
|
2125
|
+
} catch (e) {
|
|
2126
|
+
res.writeHead(500);
|
|
2127
|
+
res.end('Failed to create session');
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// Serve hay web client (has built-in mobile keyboard)
|
|
2132
|
+
const reqPath = strippedPathname || '/';
|
|
2133
|
+
|
|
2134
|
+
// Map request path to file
|
|
2135
|
+
let filePath;
|
|
2136
|
+
if (reqPath === '/' || reqPath === '') {
|
|
2137
|
+
filePath = path.join(HAY_WEB_DIR, 'index.html');
|
|
2138
|
+
} else if (reqPath.startsWith('/assets/')) {
|
|
2139
|
+
filePath = path.join(HAY_WEB_DIR, reqPath);
|
|
2140
|
+
} else {
|
|
2141
|
+
// Fallback to index.html for SPA routing
|
|
2142
|
+
filePath = path.join(HAY_WEB_DIR, 'index.html');
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// Check if file exists
|
|
2146
|
+
if (!fs.existsSync(filePath)) {
|
|
2147
|
+
res.writeHead(404);
|
|
2148
|
+
res.end('Not Found');
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Determine content type
|
|
2153
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
2154
|
+
const contentTypes = {
|
|
2155
|
+
'.html': 'text/html; charset=utf-8',
|
|
2156
|
+
'.js': 'application/javascript',
|
|
2157
|
+
'.css': 'text/css',
|
|
2158
|
+
'.json': 'application/json',
|
|
2159
|
+
'.png': 'image/png',
|
|
2160
|
+
'.jpg': 'image/jpeg',
|
|
2161
|
+
'.svg': 'image/svg+xml',
|
|
2162
|
+
'.ico': 'image/x-icon'
|
|
2163
|
+
};
|
|
2164
|
+
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
2165
|
+
|
|
2166
|
+
// Read and serve the file
|
|
2167
|
+
let content = fs.readFileSync(filePath);
|
|
2168
|
+
|
|
2169
|
+
// For HTML files, inject config and fix asset paths
|
|
2170
|
+
if (ext === '.html') {
|
|
2171
|
+
let html = content.toString('utf8');
|
|
2172
|
+
const sessionBasePath = `/s/${displayName}/`;
|
|
2173
|
+
|
|
2174
|
+
// Inject hop session configuration
|
|
2175
|
+
const injectScript = `<script>
|
|
2176
|
+
// Hop session configuration - use /ws endpoint with room as query param
|
|
2177
|
+
window.__HOP_SESSION__ = ${JSON.stringify({
|
|
2178
|
+
room: displayName,
|
|
2179
|
+
name: 'Guest',
|
|
2180
|
+
wsUrl: '/ws'
|
|
2181
|
+
})};
|
|
2182
|
+
</script>`;
|
|
2183
|
+
html = html.replace('<head>', '<head>' + injectScript);
|
|
2184
|
+
|
|
2185
|
+
// Convert absolute asset paths to relative (remove leading /)
|
|
2186
|
+
html = html.replace(/src="\/assets\//g, 'src="assets/');
|
|
2187
|
+
html = html.replace(/href="\/assets\//g, 'href="assets/');
|
|
2188
|
+
|
|
2189
|
+
content = Buffer.from(html, 'utf8');
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// No caching in dev mode (when serving from hay)
|
|
2193
|
+
const isDevMode = !HAY_WEB_DIR.includes('hay-web');
|
|
2194
|
+
const headers = {
|
|
2195
|
+
'Content-Type': contentType,
|
|
2196
|
+
'Content-Length': content.length
|
|
2197
|
+
};
|
|
2198
|
+
if (isDevMode) {
|
|
2199
|
+
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
|
|
2200
|
+
headers['Pragma'] = 'no-cache';
|
|
2201
|
+
headers['Expires'] = '0';
|
|
2202
|
+
}
|
|
2203
|
+
res.writeHead(200, headers);
|
|
2204
|
+
res.end(content);
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
// WebSocket upgrade for terminal
|
|
2208
|
+
server.on('upgrade', (req, socket, head) => {
|
|
2209
|
+
const parsedUrl = url.parse(req.url, true);
|
|
2210
|
+
const cookies = cookie.parse(req.headers.cookie || '');
|
|
2211
|
+
|
|
2212
|
+
// Auth via cookie, Bearer token, or query param (for local CLI connections)
|
|
2213
|
+
const authHeader = req.headers.authorization || '';
|
|
2214
|
+
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
2215
|
+
const tokenFromQuery = parsedUrl.query.token;
|
|
2216
|
+
const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET ||
|
|
2217
|
+
bearerToken === SESSION_SECRET ||
|
|
2218
|
+
tokenFromQuery === SESSION_SECRET;
|
|
2219
|
+
|
|
2220
|
+
if (!isAuthenticated) {
|
|
2221
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
2222
|
+
socket.destroy();
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// Support both /ws?room=X (hay CLI format) and /s/{session}/ws (legacy)
|
|
2227
|
+
let sessionName = null;
|
|
2228
|
+
let sessionInfo = null;
|
|
2229
|
+
|
|
2230
|
+
if (parsedUrl.pathname === '/ws') {
|
|
2231
|
+
// Hay CLI format: /ws?room=X
|
|
2232
|
+
const roomParam = parsedUrl.query.room;
|
|
2233
|
+
if (roomParam && typeof roomParam === 'string') {
|
|
2234
|
+
sessionName = toInternalSessionName(roomParam);
|
|
2235
|
+
}
|
|
2236
|
+
} else {
|
|
2237
|
+
// Legacy format: /s/{session}/ws
|
|
2238
|
+
sessionInfo = parseSessionFromRequest(req, parsedUrl);
|
|
2239
|
+
sessionName = sessionInfo ? sessionInfo.internalName : null;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
if (!sessionName) {
|
|
2243
|
+
socket.write('HTTP/1.1 404 Session Not Found\r\n\r\n');
|
|
2244
|
+
socket.destroy();
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
const sessionConfig = getSessionConfig(sessionName);
|
|
2249
|
+
if (sessionConfig && sessionConfig.type === 'port') {
|
|
2250
|
+
const targetPort = sessionConfig.port;
|
|
2251
|
+
if (!targetPort) {
|
|
2252
|
+
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
2253
|
+
socket.destroy();
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${targetPort}`, ws: true });
|
|
2257
|
+
const originalUrl = req.url;
|
|
2258
|
+
req.url = sessionInfo ? sessionInfo.strippedUrl : req.url;
|
|
2259
|
+
proxy.ws(req, socket, head);
|
|
2260
|
+
req.url = originalUrl;
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Get or create hay session
|
|
2265
|
+
let session = activeSessions[sessionName];
|
|
2266
|
+
if (!session) {
|
|
2267
|
+
try {
|
|
2268
|
+
session = getOrCreateSession(sessionName);
|
|
2269
|
+
} catch (e) {
|
|
2270
|
+
socket.write('HTTP/1.1 500 Server Error\r\n\r\n');
|
|
2271
|
+
socket.destroy();
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// Create WebSocket server for this upgrade
|
|
2277
|
+
const wss = new WebSocket.Server({ noServer: true });
|
|
2278
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2279
|
+
// Parse query params for client info
|
|
2280
|
+
const wsUrl = new URL(req.url, 'http://localhost');
|
|
2281
|
+
const clientName = hay.sanitizeName(wsUrl.searchParams.get('name') || 'Anonymous');
|
|
2282
|
+
const cols = parseInt(wsUrl.searchParams.get('cols') || '80', 10);
|
|
2283
|
+
const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
|
|
2284
|
+
|
|
2285
|
+
const clientId = hay.generateClientId();
|
|
2286
|
+
const colorIndex = Math.floor(Math.random() * 1000);
|
|
2287
|
+
|
|
2288
|
+
// Attach client to hay room
|
|
2289
|
+
session.room.attachClient(
|
|
2290
|
+
{ id: clientId, name: clientName, colorIndex, cols, rows },
|
|
2291
|
+
hay.createSocketAdapter(ws)
|
|
2292
|
+
);
|
|
2293
|
+
});
|
|
2294
|
+
});
|
|
2295
|
+
|
|
2296
|
+
server.listen(PROXY_PORT, '127.0.0.1');
|
|
2297
|
+
return server;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// Tunnel manager - handles auto-restart and URL updates
|
|
2301
|
+
let currentTunnelProc = null;
|
|
2302
|
+
let currentPublicUrl = null;
|
|
2303
|
+
let shuttingDown = false; // Prevent restart during cleanup
|
|
2304
|
+
const tunnelEmitter = new EventEmitter();
|
|
2305
|
+
let CLIENT_TUNNEL_CONFIG = null; // { tunnelId, credentialsPath, hostname, configDir }
|
|
2306
|
+
|
|
2307
|
+
function startTunnel(isRestart = false) {
|
|
2308
|
+
const domainConfig = getDomainConfig();
|
|
2309
|
+
const useNamedTunnel = domainConfig && domainConfig.tunnelName && domainConfig.hostname;
|
|
2310
|
+
|
|
2311
|
+
if (!isRestart) {
|
|
2312
|
+
if (useNamedTunnel) {
|
|
2313
|
+
log(`[3/4] Opening Cloudflare Tunnel (${domainConfig.hostname})...`, BOLD);
|
|
2314
|
+
} else {
|
|
2315
|
+
log(`[3/4] Opening Cloudflare Tunnel...`, BOLD);
|
|
2316
|
+
}
|
|
2317
|
+
} else {
|
|
2318
|
+
log(`[Tunnel] Reconnecting...`, YELLOW);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// Use named tunnel if configured, otherwise quick tunnel
|
|
2322
|
+
let tunnel;
|
|
2323
|
+
let publicUrl;
|
|
2324
|
+
|
|
2325
|
+
const localTunnelHost = '127.0.0.1';
|
|
2326
|
+
|
|
2327
|
+
if (useNamedTunnel) {
|
|
2328
|
+
// Named tunnel with custom domain - URL is fixed
|
|
2329
|
+
publicUrl = `https://${domainConfig.hostname}`;
|
|
2330
|
+
|
|
2331
|
+
// Create config file for the tunnel
|
|
2332
|
+
const tunnelConfigPath = path.join(HOME_DIR, '.tunnel-config.yml');
|
|
2333
|
+
const tunnelConfig = `
|
|
2334
|
+
url: http://${localTunnelHost}:${PROXY_PORT}
|
|
2335
|
+
tunnel: ${domainConfig.tunnelName}
|
|
2336
|
+
`;
|
|
2337
|
+
fs.writeFileSync(tunnelConfigPath, tunnelConfig);
|
|
2338
|
+
|
|
2339
|
+
tunnel = spawn('cloudflared', [
|
|
2340
|
+
'tunnel',
|
|
2341
|
+
'--config', tunnelConfigPath,
|
|
2342
|
+
'run', domainConfig.tunnelName
|
|
2343
|
+
]);
|
|
2344
|
+
} else {
|
|
2345
|
+
// Quick tunnel - URL is random
|
|
2346
|
+
tunnel = spawn('cloudflared', ['tunnel', '--url', `http://${localTunnelHost}:${PROXY_PORT}`]);
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
tunnel.on('close', (code) => {
|
|
2350
|
+
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
|
+
}
|
|
2357
|
+
});
|
|
2358
|
+
|
|
2359
|
+
tunnel.on('error', (err) => {
|
|
2360
|
+
if (shuttingDown) return; // Don't restart during cleanup
|
|
2361
|
+
log(`[Tunnel] Error: ${err.message}. Restarting in 3s...`, RED);
|
|
2362
|
+
setTimeout(() => {
|
|
2363
|
+
if (!shuttingDown) startTunnelWithRestart();
|
|
2364
|
+
}, 3000);
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
// For named tunnels, resolve immediately with the known URL
|
|
2368
|
+
if (useNamedTunnel) {
|
|
2369
|
+
currentTunnelProc = tunnel;
|
|
2370
|
+
currentPublicUrl = publicUrl;
|
|
2371
|
+
|
|
2372
|
+
// Wait a moment for tunnel to establish, then resolve
|
|
2373
|
+
return new Promise((resolve) => {
|
|
2374
|
+
let resolved = false;
|
|
2375
|
+
|
|
2376
|
+
// Listen for successful connection or error
|
|
2377
|
+
tunnel.stderr.on('data', (data) => {
|
|
2378
|
+
const output = data.toString();
|
|
2379
|
+
// Look for connection established message
|
|
2380
|
+
if (!resolved && (output.includes('Registered tunnel connection') || output.includes('Connection'))) {
|
|
2381
|
+
resolved = true;
|
|
2382
|
+
if (isRestart) {
|
|
2383
|
+
tunnelEmitter.emit('urlChanged', publicUrl);
|
|
2384
|
+
log(`[Tunnel] Reconnected: ${publicUrl}`, GREEN);
|
|
2385
|
+
}
|
|
2386
|
+
resolve({ tunnel, publicUrl });
|
|
2387
|
+
}
|
|
2388
|
+
});
|
|
2389
|
+
|
|
2390
|
+
// Timeout fallback - assume connected after 5 seconds
|
|
2391
|
+
setTimeout(() => {
|
|
2392
|
+
if (!resolved) {
|
|
2393
|
+
resolved = true;
|
|
2394
|
+
if (isRestart) {
|
|
2395
|
+
tunnelEmitter.emit('urlChanged', publicUrl);
|
|
2396
|
+
}
|
|
2397
|
+
resolve({ tunnel, publicUrl });
|
|
2398
|
+
}
|
|
2399
|
+
}, 5000);
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// For quick tunnels, parse URL from output
|
|
2404
|
+
let urlFound = false;
|
|
2405
|
+
return new Promise((resolve) => {
|
|
2406
|
+
tunnel.stderr.on('data', (data) => {
|
|
2407
|
+
if (urlFound) return;
|
|
2408
|
+
const output = data.toString();
|
|
2409
|
+
const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
2410
|
+
|
|
2411
|
+
if (match) {
|
|
2412
|
+
const parsedUrl = match[0];
|
|
2413
|
+
if (parsedUrl.includes('api.trycloudflare.com')) return;
|
|
2414
|
+
|
|
2415
|
+
urlFound = true;
|
|
2416
|
+
currentTunnelProc = tunnel;
|
|
2417
|
+
currentPublicUrl = parsedUrl;
|
|
2418
|
+
|
|
2419
|
+
// Emit URL change event for restarts
|
|
2420
|
+
if (isRestart) {
|
|
2421
|
+
tunnelEmitter.emit('urlChanged', parsedUrl);
|
|
2422
|
+
log(`[Tunnel] New URL: ${parsedUrl}`, GREEN);
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
resolve({ tunnel, publicUrl: parsedUrl });
|
|
2426
|
+
}
|
|
2427
|
+
});
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function startClientTunnel(isRestart = false) {
|
|
2432
|
+
if (!CLIENT_TUNNEL_CONFIG) {
|
|
2433
|
+
throw new Error('Client tunnel config missing');
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
const { tunnelId, credentialsPath, hostname, configDir } = CLIENT_TUNNEL_CONFIG;
|
|
2437
|
+
const publicUrl = hostname ? `https://${hostname}` : null;
|
|
2438
|
+
|
|
2439
|
+
if (!isRestart) {
|
|
2440
|
+
log(`[3/4] Opening Cloudflare Tunnel${hostname ? ` (${hostname})` : ''}...`, BOLD);
|
|
2441
|
+
} else {
|
|
2442
|
+
log(`[Tunnel] Reconnecting...`, YELLOW);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
const tunnelConfigPath = path.join(configDir, 'tunnel-config.yml');
|
|
2446
|
+
const tunnelConfig = `
|
|
2447
|
+
url: http://127.0.0.1:${PROXY_PORT}
|
|
2448
|
+
tunnel: ${tunnelId}
|
|
2449
|
+
credentials-file: ${path.resolve(credentialsPath)}
|
|
2450
|
+
`;
|
|
2451
|
+
writePrivateFile(tunnelConfigPath, tunnelConfig);
|
|
2452
|
+
|
|
2453
|
+
const tunnel = spawn('cloudflared', ['tunnel', '--config', tunnelConfigPath, 'run'], {
|
|
2454
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
tunnel.on('close', (code) => {
|
|
2458
|
+
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
|
+
}
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
tunnel.on('error', (err) => {
|
|
2468
|
+
if (shuttingDown) return; // Don't restart during cleanup
|
|
2469
|
+
log(`[Tunnel] Error: ${err.message}. Restarting in 3s...`, RED);
|
|
2470
|
+
setTimeout(() => {
|
|
2471
|
+
if (!shuttingDown) startTunnelWithRestart();
|
|
2472
|
+
}, 3000);
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
currentTunnelProc = tunnel;
|
|
2476
|
+
currentPublicUrl = publicUrl;
|
|
2477
|
+
|
|
2478
|
+
return new Promise((resolve) => {
|
|
2479
|
+
let resolved = false;
|
|
2480
|
+
|
|
2481
|
+
tunnel.stderr.on('data', (data) => {
|
|
2482
|
+
const output = data.toString();
|
|
2483
|
+
if (!resolved && (output.includes('Registered tunnel connection') || output.includes('Connection'))) {
|
|
2484
|
+
resolved = true;
|
|
2485
|
+
if (isRestart) {
|
|
2486
|
+
if (publicUrl) {
|
|
2487
|
+
tunnelEmitter.emit('urlChanged', publicUrl);
|
|
2488
|
+
log(`[Tunnel] Reconnected: ${publicUrl}`, GREEN);
|
|
2489
|
+
} else {
|
|
2490
|
+
log(`[Tunnel] Reconnected.`, GREEN);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
resolve({ tunnel, publicUrl });
|
|
2494
|
+
}
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
// Timeout fallback - assume connected after 5 seconds
|
|
2498
|
+
setTimeout(() => {
|
|
2499
|
+
if (!resolved) {
|
|
2500
|
+
resolved = true;
|
|
2501
|
+
if (isRestart && publicUrl) {
|
|
2502
|
+
tunnelEmitter.emit('urlChanged', publicUrl);
|
|
2503
|
+
}
|
|
2504
|
+
resolve({ tunnel, publicUrl });
|
|
2505
|
+
}
|
|
2506
|
+
}, 5000);
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
async function startTunnelWithRestart() {
|
|
2511
|
+
try {
|
|
2512
|
+
const { tunnel, publicUrl } = CLIENT_TUNNEL_CONFIG
|
|
2513
|
+
? await startClientTunnel(true)
|
|
2514
|
+
: await startTunnel(true);
|
|
2515
|
+
|
|
2516
|
+
// Update state file
|
|
2517
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
2518
|
+
try {
|
|
2519
|
+
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
2520
|
+
state.url = publicUrl;
|
|
2521
|
+
writePrivateFile(STATE_FILE, JSON.stringify(state));
|
|
2522
|
+
} catch (e) { }
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// Sessions are managed by hay, no tmux styling needed
|
|
2526
|
+
} catch (e) {
|
|
2527
|
+
log(`[Tunnel] Failed to restart: ${e.message}. Retrying in 5s...`, RED);
|
|
2528
|
+
setTimeout(startTunnelWithRestart, 5000);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function waitForStart(authEmitter, publicUrl, label) {
|
|
2533
|
+
return new Promise((resolve) => {
|
|
2534
|
+
console.clear();
|
|
2535
|
+
log(`\n==================================================`, MAGENTA);
|
|
2536
|
+
log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
|
|
2537
|
+
log(`==================================================\n`, MAGENTA);
|
|
2538
|
+
|
|
2539
|
+
log(`TUNNEL URL:`, BOLD + GREEN);
|
|
2540
|
+
log(` ${publicUrl}`, GREEN);
|
|
2541
|
+
log(` Scan to open on mobile:`, RESET);
|
|
2542
|
+
|
|
2543
|
+
// Simple URL QR code - scan with any camera app
|
|
2544
|
+
qrcode.generate(publicUrl, { small: true });
|
|
2545
|
+
|
|
2546
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
2547
|
+
|
|
2548
|
+
// Standardized TOTP QR Code using the helper function
|
|
2549
|
+
showQrCode(label || 'Hop');
|
|
2550
|
+
|
|
2551
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
2552
|
+
|
|
2553
|
+
const readline = require('readline');
|
|
2554
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2555
|
+
|
|
2556
|
+
log(`\n[READY]`, GREEN + BOLD);
|
|
2557
|
+
log(`Press ENTER to launch local session, or login remotely...`, RESET);
|
|
2558
|
+
|
|
2559
|
+
let resolved = false;
|
|
2560
|
+
|
|
2561
|
+
const finish = (reason) => {
|
|
2562
|
+
if (resolved) return;
|
|
2563
|
+
resolved = true;
|
|
2564
|
+
rl.close();
|
|
2565
|
+
log(`\n${reason}. Launching local session...`, GREEN);
|
|
2566
|
+
setTimeout(resolve, 800);
|
|
2567
|
+
};
|
|
2568
|
+
|
|
2569
|
+
rl.question('', () => finish('Manual Start'));
|
|
2570
|
+
authEmitter.once('authenticated', () => finish('Remote Login'));
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function printStartupInfo(publicUrl, label) {
|
|
2575
|
+
console.clear();
|
|
2576
|
+
log(`\n==================================================`, MAGENTA);
|
|
2577
|
+
log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
|
|
2578
|
+
log(`==================================================\n`, MAGENTA);
|
|
2579
|
+
|
|
2580
|
+
log(`TUNNEL URL:`, BOLD + GREEN);
|
|
2581
|
+
log(` ${publicUrl}`, GREEN);
|
|
2582
|
+
log(` Scan to open on mobile:`, RESET);
|
|
2583
|
+
|
|
2584
|
+
qrcode.generate(publicUrl, { small: true });
|
|
2585
|
+
|
|
2586
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
2587
|
+
showQrCode(label || 'Hop');
|
|
2588
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
2589
|
+
log(`\n[READY]`, GREEN + BOLD);
|
|
2590
|
+
log(`Daemon started. Run ${CYAN}hop${RESET} to attach a local session.`, RESET);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function waitForStartPolling(state, label) {
|
|
2594
|
+
return new Promise((resolve) => {
|
|
2595
|
+
console.clear();
|
|
2596
|
+
log(`\n==================================================`, MAGENTA);
|
|
2597
|
+
log(` >>> HOP - TUNNEL SHELL <<<`, BOLD + MAGENTA);
|
|
2598
|
+
log(`==================================================\n`, MAGENTA);
|
|
2599
|
+
|
|
2600
|
+
log(`TUNNEL URL:`, BOLD + GREEN);
|
|
2601
|
+
log(` ${state.url}`, GREEN);
|
|
2602
|
+
log(` Scan to open on mobile:`, RESET);
|
|
2603
|
+
qrcode.generate(state.url, { small: true });
|
|
2604
|
+
|
|
2605
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
2606
|
+
showQrCode(label || 'Hop');
|
|
2607
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
2608
|
+
|
|
2609
|
+
const readline = require('readline');
|
|
2610
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
2611
|
+
|
|
2612
|
+
log(`\n[READY]`, GREEN + BOLD);
|
|
2613
|
+
log(`Press ENTER to launch local session, or login remotely...`, RESET);
|
|
2614
|
+
|
|
2615
|
+
let resolved = false;
|
|
2616
|
+
const startedAt = Date.now();
|
|
2617
|
+
let pollTimer = null;
|
|
2618
|
+
|
|
2619
|
+
const finish = (reason) => {
|
|
2620
|
+
if (resolved) return;
|
|
2621
|
+
resolved = true;
|
|
2622
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
2623
|
+
rl.close();
|
|
2624
|
+
log(`\n${reason}. Launching local session...`, GREEN);
|
|
2625
|
+
setTimeout(resolve, 800);
|
|
2626
|
+
};
|
|
2627
|
+
|
|
2628
|
+
rl.question('', () => finish('Manual Start'));
|
|
2629
|
+
|
|
2630
|
+
const poll = () => {
|
|
2631
|
+
if (resolved) return;
|
|
2632
|
+
const opts = {
|
|
2633
|
+
hostname: '127.0.0.1',
|
|
2634
|
+
port: state.port,
|
|
2635
|
+
path: '/api/auth',
|
|
2636
|
+
method: 'GET',
|
|
2637
|
+
headers: { 'Authorization': `Bearer ${state.sessionSecret}` }
|
|
2638
|
+
};
|
|
2639
|
+
const req = http.request(opts, res => {
|
|
2640
|
+
let data = '';
|
|
2641
|
+
res.on('data', c => data += c);
|
|
2642
|
+
res.on('end', () => {
|
|
2643
|
+
if (res.statusCode !== 200) return;
|
|
2644
|
+
try {
|
|
2645
|
+
const payload = JSON.parse(data);
|
|
2646
|
+
if (payload.lastAuthAt && payload.lastAuthAt >= startedAt) {
|
|
2647
|
+
finish('Remote Login');
|
|
2648
|
+
}
|
|
2649
|
+
} catch (e) { }
|
|
2650
|
+
});
|
|
2651
|
+
});
|
|
2652
|
+
req.on('error', () => { });
|
|
2653
|
+
req.end();
|
|
2654
|
+
};
|
|
2655
|
+
|
|
2656
|
+
pollTimer = setInterval(poll, 1000);
|
|
2657
|
+
poll();
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// Generate QR code and prompt for verification
|
|
2662
|
+
function showQrCode(label = 'Hop') {
|
|
2663
|
+
log(`[3/4] Setup Authenticator...`, BOLD);
|
|
2664
|
+
|
|
2665
|
+
// Manually construct URL for minimalist "Hop" label without username
|
|
2666
|
+
const otpauth = `otpauth://totp/${encodeURIComponent(label)}?secret=${AUTH_SECRET}&issuer=${encodeURIComponent(label)}`;
|
|
2667
|
+
|
|
2668
|
+
qrcode.generate(otpauth, { small: true });
|
|
2669
|
+
|
|
2670
|
+
log(`Scan this QR code with your Authenticator app.`, CYAN);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
function startDaemon() {
|
|
2674
|
+
const node = process.execPath;
|
|
2675
|
+
const script = __filename;
|
|
2676
|
+
const child = spawn(node, [script, '--daemon'], {
|
|
2677
|
+
detached: true,
|
|
2678
|
+
stdio: 'ignore',
|
|
2679
|
+
env: process.env
|
|
2680
|
+
});
|
|
2681
|
+
child.unref();
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
function waitForDaemonReady(timeoutMs = 20000) {
|
|
2685
|
+
return new Promise((resolve, reject) => {
|
|
2686
|
+
const start = Date.now();
|
|
2687
|
+
const check = () => {
|
|
2688
|
+
const state = isServerRunning();
|
|
2689
|
+
if (state) return resolve(state);
|
|
2690
|
+
if (Date.now() - start > timeoutMs) {
|
|
2691
|
+
return reject(new Error('Timed out waiting for hop daemon'));
|
|
2692
|
+
}
|
|
2693
|
+
setTimeout(check, 250);
|
|
2694
|
+
};
|
|
2695
|
+
check();
|
|
2696
|
+
});
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
async function startLocalSession(proxyServer, tunnelProc, publicUrl) {
|
|
2700
|
+
log(`[4/4] Attaching to session...`, BOLD);
|
|
2701
|
+
|
|
2702
|
+
// Check if there's already an active session (from remote login)
|
|
2703
|
+
const existingSessions = listActiveSessions();
|
|
2704
|
+
let displayName;
|
|
2705
|
+
let defaultSessionName;
|
|
2706
|
+
|
|
2707
|
+
let sessionCwd;
|
|
2708
|
+
if (existingSessions.length > 0) {
|
|
2709
|
+
// Connect to the first existing session (remote user already created it)
|
|
2710
|
+
defaultSessionName = existingSessions[0].name;
|
|
2711
|
+
displayName = toDisplaySessionName(defaultSessionName);
|
|
2712
|
+
log(`Connecting to existing session: ${displayName}`, RESET);
|
|
2713
|
+
} else {
|
|
2714
|
+
// Create new session - find next available name
|
|
2715
|
+
const cwdName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
2716
|
+
const existingNames = new Set(existingSessions.map(s => s.name));
|
|
2717
|
+
let counter = 1;
|
|
2718
|
+
displayName = `${cwdName}-${counter}`;
|
|
2719
|
+
defaultSessionName = toInternalSessionName(displayName);
|
|
2720
|
+
while (existingNames.has(defaultSessionName)) {
|
|
2721
|
+
counter++;
|
|
2722
|
+
displayName = `${cwdName}-${counter}`;
|
|
2723
|
+
defaultSessionName = toInternalSessionName(displayName);
|
|
2724
|
+
}
|
|
2725
|
+
log(`Starting session: ${displayName}`, RESET);
|
|
2726
|
+
sessionCwd = process.cwd();
|
|
2727
|
+
getOrCreateSession(defaultSessionName, sessionCwd);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
const cleanup = () => {
|
|
2731
|
+
if (shuttingDown) return;
|
|
2732
|
+
shuttingDown = true; // Prevent tunnel auto-restart
|
|
2733
|
+
log(`Cleaning up...`, YELLOW);
|
|
2734
|
+
|
|
2735
|
+
// Remove state file
|
|
2736
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
2737
|
+
try { fs.unlinkSync(STATE_FILE); } catch (e) { }
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// Remove session config for this local session
|
|
2741
|
+
try {
|
|
2742
|
+
const config = getSessionConfig(defaultSessionName);
|
|
2743
|
+
if (config && config.type !== 'port') {
|
|
2744
|
+
deleteSessionConfig(defaultSessionName);
|
|
2745
|
+
}
|
|
2746
|
+
} catch (e) { }
|
|
2747
|
+
|
|
2748
|
+
// Close all hay rooms
|
|
2749
|
+
hayRooms.closeAll();
|
|
2750
|
+
|
|
2751
|
+
// Kill cloudflared tunnel (use currentTunnelProc in case it was restarted)
|
|
2752
|
+
try { (currentTunnelProc || tunnelProc).kill('SIGTERM'); } catch (e) { }
|
|
2753
|
+
|
|
2754
|
+
// Close proxy server
|
|
2755
|
+
try { proxyServer.close(); } catch (e) { }
|
|
2756
|
+
|
|
2757
|
+
log(`Goodbye!`, GREEN);
|
|
2758
|
+
process.exit(0);
|
|
2759
|
+
};
|
|
2760
|
+
|
|
2761
|
+
const runLocalSessionLoop = () => {
|
|
2762
|
+
return new Promise((resolve) => {
|
|
2763
|
+
// Ensure session exists on server
|
|
2764
|
+
getOrCreateSession(defaultSessionName, sessionCwd);
|
|
2765
|
+
|
|
2766
|
+
// Spawn hay CLI to connect to the session
|
|
2767
|
+
const hayCli = resolveHayCliPath();
|
|
2768
|
+
const wsUrl = `ws://127.0.0.1:${BASE_PORT}/ws?token=${SESSION_SECRET}`;
|
|
2769
|
+
|
|
2770
|
+
const cliProc = spawn('node', [hayCli, '-s', wsUrl, '-r', displayName, '-n', 'Local'], {
|
|
2771
|
+
stdio: 'inherit',
|
|
2772
|
+
env: { ...process.env, FORCE_COLOR: '1', HAY_SHARE_URL: publicUrl || '' }
|
|
2773
|
+
});
|
|
2774
|
+
|
|
2775
|
+
cliProc.on('close', (code) => {
|
|
2776
|
+
resolve(code);
|
|
2777
|
+
});
|
|
2778
|
+
|
|
2779
|
+
cliProc.on('error', (err) => {
|
|
2780
|
+
log(`Failed to start hay CLI: ${err.message}`, RED);
|
|
2781
|
+
resolve(1);
|
|
2782
|
+
});
|
|
2783
|
+
});
|
|
2784
|
+
};
|
|
2785
|
+
|
|
2786
|
+
runLocalSessionLoop().then((code) => {
|
|
2787
|
+
// Ctrl+D (detach) exits hay CLI with code 10; keep tunnel alive.
|
|
2788
|
+
if (code === 10) {
|
|
2789
|
+
log(`\nDetached from local session. Tunnel is still running.`, YELLOW);
|
|
2790
|
+
log(`Press Ctrl+C to shut down.`, RESET);
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
log(`\nLocal session ended. Shutting down tunnel...`, YELLOW);
|
|
2794
|
+
cleanup();
|
|
2795
|
+
});
|
|
2796
|
+
|
|
2797
|
+
process.on('SIGINT', cleanup);
|
|
2798
|
+
process.on('SIGTERM', cleanup);
|
|
2799
|
+
process.on('exit', cleanup);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
async function attachToExistingTunnel(existingTunnel) {
|
|
2803
|
+
log(`Hop tunnel is already running!`, GREEN);
|
|
2804
|
+
log(`URL: ${existingTunnel.url}`, BOLD);
|
|
2805
|
+
|
|
2806
|
+
// Generate smart session name: dir-N
|
|
2807
|
+
const cwdName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
2808
|
+
let displayName = `${cwdName}-1`;
|
|
2809
|
+
let sessionName = toInternalSessionName(displayName);
|
|
2810
|
+
|
|
2811
|
+
log(`\nFinding next available session...`, RESET);
|
|
2812
|
+
|
|
2813
|
+
try {
|
|
2814
|
+
// Get existing sessions first
|
|
2815
|
+
const sessionsRes = await new Promise((resolve, reject) => {
|
|
2816
|
+
const opts = {
|
|
2817
|
+
hostname: '127.0.0.1',
|
|
2818
|
+
port: existingTunnel.port,
|
|
2819
|
+
path: '/api/sessions',
|
|
2820
|
+
method: 'GET',
|
|
2821
|
+
headers: { 'Authorization': `Bearer ${existingTunnel.sessionSecret}` }
|
|
2822
|
+
};
|
|
2823
|
+
http.get(opts, res => {
|
|
2824
|
+
let data = '';
|
|
2825
|
+
res.on('data', c => data += c);
|
|
2826
|
+
res.on('end', () => resolve(JSON.parse(data)));
|
|
2827
|
+
}).on('error', reject);
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
// Find next available number
|
|
2831
|
+
if (sessionsRes && sessionsRes.sessions && sessionsRes.active) {
|
|
2832
|
+
const allNames = new Set([...sessionsRes.active, ...sessionsRes.sessions.map(s => s.name)]);
|
|
2833
|
+
let counter = 1;
|
|
2834
|
+
while (allNames.has(`${cwdName}-${counter}`)) {
|
|
2835
|
+
counter++;
|
|
2836
|
+
}
|
|
2837
|
+
displayName = `${cwdName}-${counter}`;
|
|
2838
|
+
sessionName = toInternalSessionName(displayName);
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
log(`Creating new session: ${displayName}...`, RESET);
|
|
2842
|
+
|
|
2843
|
+
// Register session with local server
|
|
2844
|
+
const res = await new Promise((resolve, reject) => {
|
|
2845
|
+
const opts = {
|
|
2846
|
+
hostname: '127.0.0.1',
|
|
2847
|
+
port: existingTunnel.port,
|
|
2848
|
+
path: '/api/sessions',
|
|
2849
|
+
method: 'POST',
|
|
2850
|
+
headers: {
|
|
2851
|
+
'Content-Type': 'application/json',
|
|
2852
|
+
'Authorization': `Bearer ${existingTunnel.sessionSecret}`
|
|
2853
|
+
}
|
|
2854
|
+
};
|
|
2855
|
+
|
|
2856
|
+
const req = http.request(opts, (response) => {
|
|
2857
|
+
let data = '';
|
|
2858
|
+
response.on('data', c => data += c);
|
|
2859
|
+
response.on('end', () => {
|
|
2860
|
+
try {
|
|
2861
|
+
resolve(JSON.parse(data));
|
|
2862
|
+
} catch (e) {
|
|
2863
|
+
// Server returned non-JSON (likely HTML login page)
|
|
2864
|
+
reject(new Error(`Server returned non-JSON (status ${response.statusCode})`));
|
|
2865
|
+
}
|
|
2866
|
+
});
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
req.on('error', reject);
|
|
2870
|
+
req.write(JSON.stringify({ name: displayName, cwd: process.cwd() }));
|
|
2871
|
+
req.end();
|
|
2872
|
+
});
|
|
2873
|
+
|
|
2874
|
+
if (!res.ok && !res.success) throw new Error(res.error || 'Failed to create session');
|
|
2875
|
+
|
|
2876
|
+
} catch (e) {
|
|
2877
|
+
log(`Note: Could not register session with server: ${e.message}`, YELLOW);
|
|
2878
|
+
log(`Session will still work locally.`, RESET);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
log(`Attaching to ${displayName}...`, RESET);
|
|
2882
|
+
|
|
2883
|
+
// Spawn hay CLI to connect to session
|
|
2884
|
+
const hayCli = resolveHayCliPath();
|
|
2885
|
+
const wsUrl = `ws://127.0.0.1:${existingTunnel.port}/ws?token=${existingTunnel.sessionSecret}`;
|
|
2886
|
+
|
|
2887
|
+
const cliProc = spawn('node', [hayCli, '-s', wsUrl, '-r', displayName, '-n', 'Local'], {
|
|
2888
|
+
stdio: 'inherit',
|
|
2889
|
+
env: { ...process.env, FORCE_COLOR: '1', HAY_SHARE_URL: existingTunnel.url || '' }
|
|
2890
|
+
});
|
|
2891
|
+
|
|
2892
|
+
cliProc.on('close', (code) => {
|
|
2893
|
+
if (code === 10) {
|
|
2894
|
+
log(`\nDetached from local session.`, YELLOW);
|
|
2895
|
+
process.exit(0);
|
|
2896
|
+
}
|
|
2897
|
+
log(`\nSession disconnected.`, YELLOW);
|
|
2898
|
+
process.exit(code || 0);
|
|
2899
|
+
});
|
|
2900
|
+
|
|
2901
|
+
cliProc.on('error', (err) => {
|
|
2902
|
+
log(`\nConnection error: ${err.message}`, RED);
|
|
2903
|
+
process.exit(1);
|
|
2904
|
+
});
|
|
2905
|
+
|
|
2906
|
+
// Wait indefinitely (process will exit when CLI closes)
|
|
2907
|
+
await new Promise(() => {});
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
async function fetchSessionList(existingTunnel) {
|
|
2911
|
+
const sessionsRes = await new Promise((resolve, reject) => {
|
|
2912
|
+
const opts = {
|
|
2913
|
+
hostname: '127.0.0.1',
|
|
2914
|
+
port: existingTunnel.port,
|
|
2915
|
+
path: '/api/sessions',
|
|
2916
|
+
method: 'GET',
|
|
2917
|
+
headers: { 'Authorization': `Bearer ${existingTunnel.sessionSecret}` }
|
|
2918
|
+
};
|
|
2919
|
+
http.get(opts, res => {
|
|
2920
|
+
let data = '';
|
|
2921
|
+
res.on('data', c => data += c);
|
|
2922
|
+
res.on('end', () => {
|
|
2923
|
+
try {
|
|
2924
|
+
resolve(JSON.parse(data));
|
|
2925
|
+
} catch (e) {
|
|
2926
|
+
reject(new Error('Failed to parse sessions response'));
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
}).on('error', reject);
|
|
2930
|
+
});
|
|
2931
|
+
return sessionsRes || {};
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function pickSessionForDir(sessions, cwdName) {
|
|
2935
|
+
const prefix = `${cwdName}-`;
|
|
2936
|
+
const matches = sessions
|
|
2937
|
+
.filter(s => s.type !== 'port')
|
|
2938
|
+
.filter(s => s.displayName === cwdName || s.displayName.startsWith(prefix));
|
|
2939
|
+
if (matches.length === 0) return null;
|
|
2940
|
+
const scored = matches.map(s => {
|
|
2941
|
+
let score = Number.MAX_SAFE_INTEGER;
|
|
2942
|
+
if (s.displayName === cwdName) {
|
|
2943
|
+
score = 0;
|
|
2944
|
+
} else if (s.displayName.startsWith(prefix)) {
|
|
2945
|
+
const suffix = s.displayName.slice(prefix.length);
|
|
2946
|
+
const num = parseInt(suffix, 10);
|
|
2947
|
+
if (Number.isFinite(num)) score = num;
|
|
2948
|
+
}
|
|
2949
|
+
return { session: s, score };
|
|
2950
|
+
});
|
|
2951
|
+
scored.sort((a, b) => a.score - b.score || a.session.displayName.localeCompare(b.session.displayName));
|
|
2952
|
+
return scored[0].session;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
async function attachToNamedSession(existingTunnel, displayName) {
|
|
2956
|
+
const sessionsRes = await fetchSessionList(existingTunnel);
|
|
2957
|
+
const sessions = sessionsRes.sessions || [];
|
|
2958
|
+
const target = sessions.find(s => s.type !== 'port' && s.displayName === displayName);
|
|
2959
|
+
if (!target) {
|
|
2960
|
+
throw new Error(`Session not found: ${displayName}`);
|
|
2961
|
+
}
|
|
2962
|
+
log(`Attaching to ${displayName}...`, RESET);
|
|
2963
|
+
|
|
2964
|
+
const hayCli = resolveHayCliPath();
|
|
2965
|
+
const wsUrl = `ws://127.0.0.1:${existingTunnel.port}/ws?token=${existingTunnel.sessionSecret}`;
|
|
2966
|
+
|
|
2967
|
+
const cliProc = spawn('node', [hayCli, '-s', wsUrl, '-r', displayName, '-n', 'Local'], {
|
|
2968
|
+
stdio: 'inherit',
|
|
2969
|
+
env: { ...process.env, FORCE_COLOR: '1', HAY_SHARE_URL: existingTunnel.url || '' }
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
cliProc.on('close', (code) => {
|
|
2973
|
+
if (code === 10) {
|
|
2974
|
+
log(`\nDetached from local session.`, YELLOW);
|
|
2975
|
+
process.exit(0);
|
|
2976
|
+
}
|
|
2977
|
+
log(`\nSession disconnected.`, YELLOW);
|
|
2978
|
+
process.exit(code || 0);
|
|
2979
|
+
});
|
|
2980
|
+
|
|
2981
|
+
cliProc.on('error', (err) => {
|
|
2982
|
+
log(`\nConnection error: ${err.message}`, RED);
|
|
2983
|
+
process.exit(1);
|
|
2984
|
+
});
|
|
2985
|
+
|
|
2986
|
+
await new Promise(() => {});
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
async function attachToSessionForDir(existingTunnel) {
|
|
2990
|
+
const sessionsRes = await fetchSessionList(existingTunnel);
|
|
2991
|
+
const sessions = sessionsRes.sessions || [];
|
|
2992
|
+
const cwdName = path.basename(process.cwd()).replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
2993
|
+
const target = pickSessionForDir(sessions, cwdName);
|
|
2994
|
+
if (!target) {
|
|
2995
|
+
throw new Error(`No existing session for ${cwdName}`);
|
|
2996
|
+
}
|
|
2997
|
+
return attachToNamedSession(existingTunnel, target.displayName);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
async function main() {
|
|
3001
|
+
// Handle `hop client` - run as a user with exported credentials
|
|
3002
|
+
if (process.argv[2] === 'client') {
|
|
3003
|
+
const credentialsArg = process.argv[3];
|
|
3004
|
+
const hostnameArgIndex = process.argv.indexOf('--hostname');
|
|
3005
|
+
const hostnameOverride = hostnameArgIndex !== -1 ? process.argv[hostnameArgIndex + 1] : null;
|
|
3006
|
+
|
|
3007
|
+
if (!credentialsArg || credentialsArg === '--help' || credentialsArg === '-h') {
|
|
3008
|
+
log(`\n${BOLD}Hop Client Mode${RESET}\n`, CYAN);
|
|
3009
|
+
log(`Run hop with credentials from your admin.\n`, RESET);
|
|
3010
|
+
log(`${BOLD}Usage:${RESET}`, RESET);
|
|
3011
|
+
log(` hop client <credentials.json> Start tunnel with credentials`, RESET);
|
|
3012
|
+
log(` hop client <folder> Start tunnel (folder contains credentials.json)`, RESET);
|
|
3013
|
+
log('');
|
|
3014
|
+
log(`${BOLD}Options:${RESET}`, RESET);
|
|
3015
|
+
log(` --hostname <name> Override hostname if config.yml is missing`, RESET);
|
|
3016
|
+
log('');
|
|
3017
|
+
log(`${BOLD}First run:${RESET}`, RESET);
|
|
3018
|
+
log(` You'll be prompted to set a password and scan a QR code`, RESET);
|
|
3019
|
+
log(` for your authenticator app.`, RESET);
|
|
3020
|
+
log('');
|
|
3021
|
+
log(`${BOLD}Example:${RESET}`, RESET);
|
|
3022
|
+
log(` hop client ./my-credentials.json`, RESET);
|
|
3023
|
+
log('');
|
|
3024
|
+
process.exit(0);
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// Find credentials file
|
|
3028
|
+
let credentialsPath = credentialsArg;
|
|
3029
|
+
if (fs.existsSync(credentialsArg) && fs.statSync(credentialsArg).isDirectory()) {
|
|
3030
|
+
credentialsPath = path.join(credentialsArg, 'credentials.json');
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
if (!fs.existsSync(credentialsPath)) {
|
|
3034
|
+
log(`Credentials file not found: ${credentialsPath}`, RED);
|
|
3035
|
+
process.exit(1);
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
// Load credentials
|
|
3039
|
+
let credentials;
|
|
3040
|
+
try {
|
|
3041
|
+
credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
|
|
3042
|
+
} catch (e) {
|
|
3043
|
+
log(`Invalid credentials file: ${e.message}`, RED);
|
|
3044
|
+
process.exit(1);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// Client config is stored per-tunnel (based on tunnel ID)
|
|
3048
|
+
const tunnelId = credentials.TunnelID || credentials.tunnelId || path.basename(credentialsPath, '.json');
|
|
3049
|
+
const clientConfigDir = path.join(HOME_DIR, 'clients', tunnelId);
|
|
3050
|
+
ensurePrivateDir(clientConfigDir);
|
|
3051
|
+
|
|
3052
|
+
PASSWORD_FILE_PATH = path.join(clientConfigDir, '.password_hash');
|
|
3053
|
+
SECRET_FILE_PATH = path.join(clientConfigDir, '.auth_secret');
|
|
3054
|
+
const clientConfigFile = path.join(clientConfigDir, '.config.json');
|
|
3055
|
+
|
|
3056
|
+
// Load or create client config
|
|
3057
|
+
let clientConfig = {};
|
|
3058
|
+
if (fs.existsSync(clientConfigFile)) {
|
|
3059
|
+
try {
|
|
3060
|
+
clientConfig = JSON.parse(fs.readFileSync(clientConfigFile, 'utf8'));
|
|
3061
|
+
} catch (e) { }
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
let hostname = clientConfig.hostname || null;
|
|
3065
|
+
if (hostnameOverride) {
|
|
3066
|
+
hostname = hostnameOverride;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// Try to find hostname in config.yml in the export folder
|
|
3070
|
+
const configYmlPath = path.join(path.dirname(credentialsPath), 'config.yml');
|
|
3071
|
+
if (!hostname && fs.existsSync(configYmlPath)) {
|
|
3072
|
+
const configYml = fs.readFileSync(configYmlPath, 'utf8');
|
|
3073
|
+
const hostnameMatch = configYml.match(/hostname:\s*(\S+)/);
|
|
3074
|
+
if (hostnameMatch) hostname = hostnameMatch[1];
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
if (!hostname) {
|
|
3078
|
+
log(`Hostname not found.`, RED);
|
|
3079
|
+
log(`Ask your admin to export a fresh bundle or pass --hostname.`, YELLOW);
|
|
3080
|
+
process.exit(1);
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
if (clientConfig.hostname !== hostname) {
|
|
3084
|
+
clientConfig.hostname = hostname;
|
|
3085
|
+
writePrivateFile(clientConfigFile, JSON.stringify(clientConfig, null, 2));
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// First time setup - require password and TOTP
|
|
3089
|
+
const clientPasswordExists = isPasswordEnabled();
|
|
3090
|
+
const clientSecretExists = fs.existsSync(SECRET_FILE_PATH);
|
|
3091
|
+
|
|
3092
|
+
if (!clientPasswordExists || !clientSecretExists) {
|
|
3093
|
+
log(`\n${BOLD}${YELLOW}⚠ First time setup required${RESET}\n`, YELLOW);
|
|
3094
|
+
log(`Your tunnel URL will be publicly accessible.`, RESET);
|
|
3095
|
+
log(`Password + authenticator code protects your hop login.\n`, RESET);
|
|
3096
|
+
|
|
3097
|
+
const readline = require('readline');
|
|
3098
|
+
const stream = require('stream');
|
|
3099
|
+
|
|
3100
|
+
const askPassword = (prompt) => new Promise((resolve) => {
|
|
3101
|
+
const rl = readline.createInterface({
|
|
3102
|
+
input: process.stdin,
|
|
3103
|
+
output: new stream.Writable({
|
|
3104
|
+
write: (chunk, encoding, callback) => callback()
|
|
3105
|
+
}),
|
|
3106
|
+
terminal: true
|
|
3107
|
+
});
|
|
3108
|
+
process.stdout.write(prompt);
|
|
3109
|
+
rl.question('', (answer) => {
|
|
3110
|
+
rl.close();
|
|
3111
|
+
process.stdout.write('\n');
|
|
3112
|
+
resolve(answer);
|
|
3113
|
+
});
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
// Set password
|
|
3117
|
+
if (!clientPasswordExists) {
|
|
3118
|
+
const password = await askPassword('Set a password: ');
|
|
3119
|
+
if (password.length < 4) {
|
|
3120
|
+
log(`Password too short (min 4 characters).`, RED);
|
|
3121
|
+
process.exit(1);
|
|
3122
|
+
}
|
|
3123
|
+
const confirm = await askPassword('Confirm password: ');
|
|
3124
|
+
if (password !== confirm) {
|
|
3125
|
+
log(`Passwords do not match.`, RED);
|
|
3126
|
+
process.exit(1);
|
|
3127
|
+
}
|
|
3128
|
+
setPassword(password);
|
|
3129
|
+
log(`${GREEN}✓ Password set.${RESET}`, GREEN);
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
// Set up TOTP
|
|
3133
|
+
if (!clientSecretExists) {
|
|
3134
|
+
loadAuthSecret();
|
|
3135
|
+
log(`\n${BOLD}Scan this QR code with your authenticator app:${RESET}\n`, CYAN);
|
|
3136
|
+
const label = hostname ? `Hop-${hostname}` : `Hop-${tunnelId.slice(0, 8)}`;
|
|
3137
|
+
showQrCode(label);
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
log(`\n${GREEN}✓ Setup complete!${RESET}\n`, GREEN);
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
loadAuthSecret();
|
|
3144
|
+
|
|
3145
|
+
await checkAndInstallDependencies();
|
|
3146
|
+
await ensureHayLoaded();
|
|
3147
|
+
const authEmitter = new EventEmitter();
|
|
3148
|
+
|
|
3149
|
+
const proxyServer = startAuthProxy(authEmitter);
|
|
3150
|
+
CLIENT_TUNNEL_CONFIG = {
|
|
3151
|
+
tunnelId,
|
|
3152
|
+
credentialsPath,
|
|
3153
|
+
hostname,
|
|
3154
|
+
configDir: clientConfigDir
|
|
3155
|
+
};
|
|
3156
|
+
const { tunnel: tunnelProc, publicUrl } = await startClientTunnel();
|
|
3157
|
+
|
|
3158
|
+
// Save state for other terminals to find
|
|
3159
|
+
writePrivateFile(STATE_FILE, JSON.stringify({
|
|
3160
|
+
pid: process.pid,
|
|
3161
|
+
port: BASE_PORT,
|
|
3162
|
+
url: publicUrl,
|
|
3163
|
+
sessionSecret: SESSION_SECRET,
|
|
3164
|
+
startTime: Date.now()
|
|
3165
|
+
}));
|
|
3166
|
+
|
|
3167
|
+
await waitForStart(authEmitter, publicUrl, `Hop-${hostname}`);
|
|
3168
|
+
|
|
3169
|
+
await startLocalSession(proxyServer, tunnelProc, publicUrl);
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// Handle `hop start` command - start daemon and print QR codes
|
|
3174
|
+
if (process.argv[2] === 'start') {
|
|
3175
|
+
const existing = isServerRunning();
|
|
3176
|
+
if (!existing) {
|
|
3177
|
+
loadAuthSecret();
|
|
3178
|
+
await checkAndInstallDependencies();
|
|
3179
|
+
startDaemon();
|
|
3180
|
+
}
|
|
3181
|
+
const state = existing || await waitForDaemonReady();
|
|
3182
|
+
const domainConfig = getDomainConfig();
|
|
3183
|
+
const label = domainConfig?.hostname ? `Hop-${domainConfig.hostname}` : 'Hop';
|
|
3184
|
+
loadAuthSecret();
|
|
3185
|
+
printStartupInfo(state.url, label);
|
|
3186
|
+
process.exit(0);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
// Handle `hop attach` command - attach to existing session
|
|
3190
|
+
if (process.argv[2] === 'attach') {
|
|
3191
|
+
const existing = isServerRunning();
|
|
3192
|
+
if (!existing) {
|
|
3193
|
+
log(`Hop is not running.`, RED);
|
|
3194
|
+
process.exit(1);
|
|
3195
|
+
}
|
|
3196
|
+
const nameArg = process.argv[3];
|
|
3197
|
+
const displayName = nameArg && nameArg.startsWith(HOP_SESSION_PREFIX)
|
|
3198
|
+
? toDisplaySessionName(nameArg)
|
|
3199
|
+
: nameArg;
|
|
3200
|
+
if (displayName) {
|
|
3201
|
+
await attachToNamedSession(existing, displayName);
|
|
3202
|
+
} else {
|
|
3203
|
+
await attachToSessionForDir(existing);
|
|
3204
|
+
}
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// Handle `hop stop` command - stop daemon
|
|
3209
|
+
if (process.argv[2] === 'stop') {
|
|
3210
|
+
const state = isServerRunning();
|
|
3211
|
+
if (!state) {
|
|
3212
|
+
log(`Hop is not running.`, RED);
|
|
3213
|
+
process.exit(1);
|
|
3214
|
+
}
|
|
3215
|
+
try {
|
|
3216
|
+
process.kill(state.pid, 'SIGTERM');
|
|
3217
|
+
log(`Hop stopped.`, GREEN);
|
|
3218
|
+
} catch (e) {
|
|
3219
|
+
log(`Failed to stop hop: ${e.message}`, RED);
|
|
3220
|
+
process.exit(1);
|
|
3221
|
+
}
|
|
3222
|
+
process.exit(0);
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
// Handle `hop --help` or `hop help`
|
|
3226
|
+
if (process.argv[2] === '--help' || process.argv[2] === '-h' || process.argv[2] === 'help') {
|
|
3227
|
+
log(`\n${BOLD}Hop - Remote Terminal Access${RESET}\n`, CYAN);
|
|
3228
|
+
log(`${BOLD}Usage:${RESET}`, RESET);
|
|
3229
|
+
log(` hop Start tunnel (or attach to existing)`, RESET);
|
|
3230
|
+
log(` hop start Start hop daemon and print QR codes`, RESET);
|
|
3231
|
+
log(` hop attach [session] Attach to an existing session`, RESET);
|
|
3232
|
+
log(` hop stop Stop hop daemon`, RESET);
|
|
3233
|
+
log(` hop --help Show this help message`, RESET);
|
|
3234
|
+
log('');
|
|
3235
|
+
log(`${BOLD}URL & QR:${RESET}`, RESET);
|
|
3236
|
+
log(` hop url Print current tunnel URL`, RESET);
|
|
3237
|
+
log(` hop qr Show URL as QR code`, RESET);
|
|
3238
|
+
log('');
|
|
3239
|
+
log(`${BOLD}Custom Domain:${RESET}`, RESET);
|
|
3240
|
+
log(` hop domain Show current domain config`, RESET);
|
|
3241
|
+
log(` hop domain <hostname> Setup custom domain (e.g., hop.example.com)`, RESET);
|
|
3242
|
+
log(` hop domain-clear Remove custom domain, use random URLs`, RESET);
|
|
3243
|
+
log('');
|
|
3244
|
+
log(`${BOLD}Security:${RESET}`, RESET);
|
|
3245
|
+
log(` hop password Show password status`, RESET);
|
|
3246
|
+
log(` hop password set Set or change password`, RESET);
|
|
3247
|
+
log(` hop password clear Remove password protection`, RESET);
|
|
3248
|
+
log('');
|
|
3249
|
+
log(`${BOLD}Multi-user (admin):${RESET}`, RESET);
|
|
3250
|
+
log(` hop user list List all users`, RESET);
|
|
3251
|
+
log(` hop user add <name> Add a user (creates tunnel + DNS)`, RESET);
|
|
3252
|
+
log(` hop user remove <name> Remove a user`, RESET);
|
|
3253
|
+
log(` hop user export <name> Export credentials for a user`, RESET);
|
|
3254
|
+
log('');
|
|
3255
|
+
log(`${BOLD}Sessions:${RESET}`, RESET);
|
|
3256
|
+
log(` hop session list List sessions`, RESET);
|
|
3257
|
+
log(` hop session add <name> Add terminal session`, RESET);
|
|
3258
|
+
log(` hop session add <name> --port N Add port session (proxy)`, RESET);
|
|
3259
|
+
log(` hop session remove <name> Remove a session`, RESET);
|
|
3260
|
+
log('');
|
|
3261
|
+
log(`${BOLD}Client mode (for users):${RESET}`, RESET);
|
|
3262
|
+
log(` hop client <credentials> Run tunnel with exported credentials`, RESET);
|
|
3263
|
+
log('');
|
|
3264
|
+
log(`${BOLD}Maintenance:${RESET}`, RESET);
|
|
3265
|
+
log(` hop wipe Kill all hop sessions`, RESET);
|
|
3266
|
+
log('');
|
|
3267
|
+
process.exit(0);
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// Handle `hop url` command - prints current tunnel URL (easy to copy)
|
|
3271
|
+
if (process.argv[2] === 'url') {
|
|
3272
|
+
const state = isServerRunning();
|
|
3273
|
+
if (!state) {
|
|
3274
|
+
log(`Hop is not running.`, RED);
|
|
3275
|
+
process.exit(1);
|
|
3276
|
+
}
|
|
3277
|
+
// Print just the URL for easy copying/piping
|
|
3278
|
+
console.log(state.url);
|
|
3279
|
+
process.exit(0);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
// Handle `hop qr` command - shows QR code for current URL
|
|
3283
|
+
if (process.argv[2] === 'qr') {
|
|
3284
|
+
const state = isServerRunning();
|
|
3285
|
+
if (!state) {
|
|
3286
|
+
log(`Hop is not running.`, RED);
|
|
3287
|
+
process.exit(1);
|
|
3288
|
+
}
|
|
3289
|
+
log(`\nCurrent tunnel URL:`, BOLD);
|
|
3290
|
+
log(`${state.url}\n`, GREEN);
|
|
3291
|
+
qrcode.generate(state.url, { small: true });
|
|
3292
|
+
process.exit(0);
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
// Handle `hop domain <hostname>` command - setup custom domain
|
|
3296
|
+
if (process.argv[2] === 'domain') {
|
|
3297
|
+
const hostname = process.argv[3];
|
|
3298
|
+
if (!hostname) {
|
|
3299
|
+
// Show current domain config
|
|
3300
|
+
const config = getDomainConfig();
|
|
3301
|
+
if (config) {
|
|
3302
|
+
log(`\nCurrent custom domain configuration:`, BOLD);
|
|
3303
|
+
log(` Hostname: ${config.hostname}`, GREEN);
|
|
3304
|
+
log(` Tunnel: ${config.tunnelName}`, RESET);
|
|
3305
|
+
log(`\nTo change: hop domain <new-hostname>`, RESET);
|
|
3306
|
+
log(`To remove: hop domain-clear`, RESET);
|
|
3307
|
+
} else {
|
|
3308
|
+
log(`\nNo custom domain configured.`, YELLOW);
|
|
3309
|
+
log(`\nUsage: hop domain <hostname>`, RESET);
|
|
3310
|
+
log(`Example: hop domain hop.yourdomain.com`, RESET);
|
|
3311
|
+
}
|
|
3312
|
+
process.exit(0);
|
|
3313
|
+
}
|
|
3314
|
+
// Validate hostname format
|
|
3315
|
+
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*\.)+[a-zA-Z]{2,}$/.test(hostname)) {
|
|
3316
|
+
log(`Invalid hostname format: ${hostname}`, RED);
|
|
3317
|
+
log(`Example: hop.yourdomain.com`, RESET);
|
|
3318
|
+
process.exit(1);
|
|
3319
|
+
}
|
|
3320
|
+
await setupDomain(hostname);
|
|
3321
|
+
process.exit(0);
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// Handle `hop domain-clear` command - remove custom domain config
|
|
3325
|
+
if (process.argv[2] === 'domain-clear') {
|
|
3326
|
+
if (fs.existsSync(DOMAIN_CONFIG_FILE)) {
|
|
3327
|
+
fs.unlinkSync(DOMAIN_CONFIG_FILE);
|
|
3328
|
+
log(`Custom domain configuration removed.`, GREEN);
|
|
3329
|
+
log(`Hop will use random Cloudflare URLs again.`, RESET);
|
|
3330
|
+
} else {
|
|
3331
|
+
log(`No custom domain configured.`, YELLOW);
|
|
3332
|
+
}
|
|
3333
|
+
process.exit(0);
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// Handle `hop password` commands
|
|
3337
|
+
if (process.argv[2] === 'password') {
|
|
3338
|
+
const subcommand = process.argv[3];
|
|
3339
|
+
const readline = require('readline');
|
|
3340
|
+
|
|
3341
|
+
if (!subcommand || subcommand === 'status') {
|
|
3342
|
+
if (isPasswordEnabled()) {
|
|
3343
|
+
log(`Password protection: ${GREEN}ENABLED${RESET}`, BOLD);
|
|
3344
|
+
log(`\nTo change: hop password set`, RESET);
|
|
3345
|
+
log(`To remove: hop password clear`, RESET);
|
|
3346
|
+
} else {
|
|
3347
|
+
log(`Password protection: ${YELLOW}DISABLED${RESET}`, BOLD);
|
|
3348
|
+
log(`\nTo enable: hop password set`, RESET);
|
|
3349
|
+
}
|
|
3350
|
+
process.exit(0);
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
if (subcommand === 'set') {
|
|
3354
|
+
// Use muted readline for password input
|
|
3355
|
+
const askPassword = (prompt) => new Promise((resolve) => {
|
|
3356
|
+
const rl = readline.createInterface({
|
|
3357
|
+
input: process.stdin,
|
|
3358
|
+
output: new (require('stream').Writable)({
|
|
3359
|
+
write: (chunk, encoding, callback) => callback()
|
|
3360
|
+
}),
|
|
3361
|
+
terminal: true
|
|
3362
|
+
});
|
|
3363
|
+
|
|
3364
|
+
process.stdout.write(prompt);
|
|
3365
|
+
rl.question('', (answer) => {
|
|
3366
|
+
rl.close();
|
|
3367
|
+
process.stdout.write('\n');
|
|
3368
|
+
resolve(answer);
|
|
3369
|
+
});
|
|
3370
|
+
});
|
|
3371
|
+
|
|
3372
|
+
const password = await askPassword('Enter new password: ');
|
|
3373
|
+
if (password.length < 4) {
|
|
3374
|
+
log(`Password too short (min 4 characters).`, RED);
|
|
3375
|
+
process.exit(1);
|
|
3376
|
+
}
|
|
3377
|
+
|
|
3378
|
+
const confirm = await askPassword('Confirm password: ');
|
|
3379
|
+
if (password !== confirm) {
|
|
3380
|
+
log(`Passwords do not match.`, RED);
|
|
3381
|
+
process.exit(1);
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
setPassword(password);
|
|
3385
|
+
log(`\n${GREEN}✓ Password set successfully.${RESET}`, GREEN);
|
|
3386
|
+
log(`\nLogin will now require password + TOTP code.`, RESET);
|
|
3387
|
+
process.exit(0);
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
if (subcommand === 'clear') {
|
|
3391
|
+
if (!isPasswordEnabled()) {
|
|
3392
|
+
log(`Password is not set.`, YELLOW);
|
|
3393
|
+
process.exit(0);
|
|
3394
|
+
}
|
|
3395
|
+
clearPassword();
|
|
3396
|
+
log(`${GREEN}✓ Password protection removed.${RESET}`, GREEN);
|
|
3397
|
+
log(`\nLogin will only require TOTP code.`, RESET);
|
|
3398
|
+
process.exit(0);
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
// Unknown subcommand
|
|
3402
|
+
log(`\n${BOLD}Password commands:${RESET}\n`, CYAN);
|
|
3403
|
+
log(` hop password Show password status`, RESET);
|
|
3404
|
+
log(` hop password set Set or change password`, RESET);
|
|
3405
|
+
log(` hop password clear Remove password protection`, RESET);
|
|
3406
|
+
process.exit(0);
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
// Handle `hop user` commands - multi-user management
|
|
3410
|
+
if (process.argv[2] === 'user') {
|
|
3411
|
+
const subcommand = process.argv[3];
|
|
3412
|
+
const username = process.argv[4];
|
|
3413
|
+
|
|
3414
|
+
if (!subcommand || subcommand === 'list') {
|
|
3415
|
+
listUsersCommand();
|
|
3416
|
+
process.exit(0);
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
if (subcommand === 'add') {
|
|
3420
|
+
if (!username) {
|
|
3421
|
+
log(`Usage: hop user add <username>`, RED);
|
|
3422
|
+
process.exit(1);
|
|
3423
|
+
}
|
|
3424
|
+
await addUser(username);
|
|
3425
|
+
process.exit(0);
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
if (subcommand === 'remove' || subcommand === 'rm' || subcommand === 'delete') {
|
|
3429
|
+
if (!username) {
|
|
3430
|
+
log(`Usage: hop user remove <username>`, RED);
|
|
3431
|
+
process.exit(1);
|
|
3432
|
+
}
|
|
3433
|
+
await removeUser(username);
|
|
3434
|
+
process.exit(0);
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
if (subcommand === 'export') {
|
|
3438
|
+
if (!username) {
|
|
3439
|
+
log(`Usage: hop user export <username>`, RED);
|
|
3440
|
+
process.exit(1);
|
|
3441
|
+
}
|
|
3442
|
+
await exportUserCredentials(username);
|
|
3443
|
+
process.exit(0);
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
// Unknown subcommand - show help
|
|
3447
|
+
log(`\n${BOLD}User management commands:${RESET}\n`, CYAN);
|
|
3448
|
+
log(` hop user list List all users`, RESET);
|
|
3449
|
+
log(` hop user add <username> Add a new user`, RESET);
|
|
3450
|
+
log(` hop user remove <username> Remove a user`, RESET);
|
|
3451
|
+
log(` hop user export <username> Export credentials for a user`, RESET);
|
|
3452
|
+
process.exit(0);
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// Handle `hop session` commands
|
|
3456
|
+
if (process.argv[2] === 'session') {
|
|
3457
|
+
await ensureHayLoaded();
|
|
3458
|
+
const subcommand = process.argv[3];
|
|
3459
|
+
const nameArg = process.argv[4];
|
|
3460
|
+
|
|
3461
|
+
if (!subcommand || subcommand === 'list') {
|
|
3462
|
+
const sessions = listAllSessionEntries();
|
|
3463
|
+
if (sessions.length === 0) {
|
|
3464
|
+
log(`No sessions configured.`, YELLOW);
|
|
3465
|
+
process.exit(0);
|
|
3466
|
+
}
|
|
3467
|
+
log(`\n${BOLD}Sessions:${RESET}\n`, CYAN);
|
|
3468
|
+
sessions.forEach(s => {
|
|
3469
|
+
if (s.type === 'port') {
|
|
3470
|
+
log(` ${BOLD}${s.displayName}${RESET} ${YELLOW}[PORT ${s.port}]${RESET}`, RESET);
|
|
3471
|
+
} else {
|
|
3472
|
+
log(` ${BOLD}${s.displayName}${RESET} ${GREEN}[TERMINAL]${RESET}`, RESET);
|
|
3473
|
+
}
|
|
3474
|
+
});
|
|
3475
|
+
process.exit(0);
|
|
3476
|
+
}
|
|
3477
|
+
|
|
3478
|
+
if (subcommand === 'add') {
|
|
3479
|
+
if (!nameArg) {
|
|
3480
|
+
log(`Usage: hop session add <name> [--port <number>]`, RED);
|
|
3481
|
+
process.exit(1);
|
|
3482
|
+
}
|
|
3483
|
+
const displayName = nameArg;
|
|
3484
|
+
const name = toInternalSessionName(displayName);
|
|
3485
|
+
if (!name) {
|
|
3486
|
+
log(`Invalid session name.`, RED);
|
|
3487
|
+
process.exit(1);
|
|
3488
|
+
}
|
|
3489
|
+
const portArgIndex = process.argv.indexOf('--port');
|
|
3490
|
+
if (portArgIndex !== -1) {
|
|
3491
|
+
const portArg = parseInt(process.argv[portArgIndex + 1], 10);
|
|
3492
|
+
if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
|
|
3493
|
+
log(`Invalid port. Use 1-65535.`, RED);
|
|
3494
|
+
process.exit(1);
|
|
3495
|
+
}
|
|
3496
|
+
if (hopSessionExists(name)) {
|
|
3497
|
+
log(`Terminal session already exists with this name.`, RED);
|
|
3498
|
+
process.exit(1);
|
|
3499
|
+
}
|
|
3500
|
+
setPortSessionConfig(name, portArg);
|
|
3501
|
+
log(`Added port session "${displayName}" → localhost:${portArg}`, GREEN);
|
|
3502
|
+
process.exit(0);
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
const existingConfig = getSessionConfig(name);
|
|
3506
|
+
if (existingConfig && existingConfig.type === 'port') {
|
|
3507
|
+
log(`Port session already exists with this name.`, RED);
|
|
3508
|
+
process.exit(1);
|
|
3509
|
+
}
|
|
3510
|
+
ensureTerminalSessionConfig(name);
|
|
3511
|
+
log(`Added terminal session "${displayName}".`, GREEN);
|
|
3512
|
+
process.exit(0);
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
if (subcommand === 'remove' || subcommand === 'rm' || subcommand === 'delete') {
|
|
3516
|
+
if (!nameArg) {
|
|
3517
|
+
log(`Usage: hop session remove <name>`, RED);
|
|
3518
|
+
process.exit(1);
|
|
3519
|
+
}
|
|
3520
|
+
const displayName = nameArg;
|
|
3521
|
+
const name = toInternalSessionName(displayName);
|
|
3522
|
+
if (!name) {
|
|
3523
|
+
log(`Invalid session name.`, RED);
|
|
3524
|
+
process.exit(1);
|
|
3525
|
+
}
|
|
3526
|
+
const config = getSessionConfig(name);
|
|
3527
|
+
if (config && config.type === 'port') {
|
|
3528
|
+
deleteSessionConfig(name);
|
|
3529
|
+
log(`Removed port session "${displayName}".`, GREEN);
|
|
3530
|
+
process.exit(0);
|
|
3531
|
+
}
|
|
3532
|
+
// Close hay room if it exists
|
|
3533
|
+
if (hayRooms.hasRoom(name)) {
|
|
3534
|
+
hayRooms.closeRoom(name);
|
|
3535
|
+
}
|
|
3536
|
+
if (activeSessions[name]) {
|
|
3537
|
+
delete activeSessions[name];
|
|
3538
|
+
}
|
|
3539
|
+
deleteSessionConfig(name);
|
|
3540
|
+
log(`Removed terminal session "${displayName}".`, GREEN);
|
|
3541
|
+
process.exit(0);
|
|
3542
|
+
}
|
|
3543
|
+
|
|
3544
|
+
log(`\n${BOLD}Session commands:${RESET}\n`, CYAN);
|
|
3545
|
+
log(` hop session list List sessions`, RESET);
|
|
3546
|
+
log(` hop session add <name> Add terminal session`, RESET);
|
|
3547
|
+
log(` hop session add <name> --port N Add port session`, RESET);
|
|
3548
|
+
log(` hop session remove <name> Remove a session`, RESET);
|
|
3549
|
+
process.exit(0);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// Handle `hop wipe` command - kills all hop sessions
|
|
3553
|
+
if (process.argv[2] === 'wipe') {
|
|
3554
|
+
const existingTunnel = isServerRunning();
|
|
3555
|
+
|
|
3556
|
+
if (existingTunnel) {
|
|
3557
|
+
// Server is running - call API to delete sessions
|
|
3558
|
+
const http = require('http');
|
|
3559
|
+
|
|
3560
|
+
// First get list of sessions
|
|
3561
|
+
const sessionsRes = await new Promise((resolve, reject) => {
|
|
3562
|
+
const opts = {
|
|
3563
|
+
hostname: '127.0.0.1',
|
|
3564
|
+
port: existingTunnel.port,
|
|
3565
|
+
path: '/api/sessions',
|
|
3566
|
+
method: 'GET',
|
|
3567
|
+
headers: { 'Authorization': `Bearer ${existingTunnel.sessionSecret}` }
|
|
3568
|
+
};
|
|
3569
|
+
const req = http.request(opts, res => {
|
|
3570
|
+
let data = '';
|
|
3571
|
+
res.on('data', chunk => data += chunk);
|
|
3572
|
+
res.on('end', () => resolve({ status: res.statusCode, data }));
|
|
3573
|
+
});
|
|
3574
|
+
req.on('error', reject);
|
|
3575
|
+
req.end();
|
|
3576
|
+
});
|
|
3577
|
+
|
|
3578
|
+
const sessionsData = JSON.parse(sessionsRes.data);
|
|
3579
|
+
const sessions = sessionsData.sessions || [];
|
|
3580
|
+
|
|
3581
|
+
if (sessions.length === 0) {
|
|
3582
|
+
log(`No hop sessions to clean up.`, GREEN);
|
|
3583
|
+
process.exit(0);
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
log(`Killing ${sessions.length} hop session(s)...`, YELLOW);
|
|
3587
|
+
|
|
3588
|
+
for (const s of sessions) {
|
|
3589
|
+
try {
|
|
3590
|
+
await new Promise((resolve, reject) => {
|
|
3591
|
+
const opts = {
|
|
3592
|
+
hostname: '127.0.0.1',
|
|
3593
|
+
port: existingTunnel.port,
|
|
3594
|
+
path: '/api/sessions/delete',
|
|
3595
|
+
method: 'POST',
|
|
3596
|
+
headers: {
|
|
3597
|
+
'Content-Type': 'application/json',
|
|
3598
|
+
'Authorization': `Bearer ${existingTunnel.sessionSecret}`
|
|
3599
|
+
}
|
|
3600
|
+
};
|
|
3601
|
+
const req = http.request(opts, res => {
|
|
3602
|
+
let data = '';
|
|
3603
|
+
res.on('data', chunk => data += chunk);
|
|
3604
|
+
res.on('end', () => resolve({ status: res.statusCode, data }));
|
|
3605
|
+
});
|
|
3606
|
+
req.on('error', reject);
|
|
3607
|
+
req.write(JSON.stringify({ name: s.name }));
|
|
3608
|
+
req.end();
|
|
3609
|
+
});
|
|
3610
|
+
log(` - ${s.displayName || s.name}`, RESET);
|
|
3611
|
+
} catch (e) {
|
|
3612
|
+
log(` - ${s.displayName || s.name} (failed)`, YELLOW);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
log(`Done.`, GREEN);
|
|
3616
|
+
} else {
|
|
3617
|
+
// Server not running - just clean up config files
|
|
3618
|
+
const sessions = Object.keys(sessionConfigs).filter(s => s.startsWith(HOP_SESSION_PREFIX));
|
|
3619
|
+
if (sessions.length === 0) {
|
|
3620
|
+
log(`No hop sessions to clean up.`, GREEN);
|
|
3621
|
+
process.exit(0);
|
|
3622
|
+
}
|
|
3623
|
+
log(`Cleaning up ${sessions.length} session config(s)...`, YELLOW);
|
|
3624
|
+
for (const name of sessions) {
|
|
3625
|
+
deleteSessionConfig(name);
|
|
3626
|
+
log(` - ${toDisplaySessionName(name)}`, RESET);
|
|
3627
|
+
}
|
|
3628
|
+
log(`Done.`, GREEN);
|
|
3629
|
+
}
|
|
3630
|
+
process.exit(0);
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
process.stdout.write('\x1Bc'); // Clear screen
|
|
3634
|
+
|
|
3635
|
+
// Only one instance allowed
|
|
3636
|
+
if (!DAEMON_FLAG) {
|
|
3637
|
+
const existingTunnel = isServerRunning();
|
|
3638
|
+
if (existingTunnel) {
|
|
3639
|
+
await attachToExistingTunnel(existingTunnel);
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
// No server yet: start daemon and wait for it to come up
|
|
3644
|
+
loadAuthSecret();
|
|
3645
|
+
await checkAndInstallDependencies();
|
|
3646
|
+
startDaemon();
|
|
3647
|
+
const state = await waitForDaemonReady();
|
|
3648
|
+
const domainConfig = getDomainConfig();
|
|
3649
|
+
const label = domainConfig?.hostname ? `Hop-${domainConfig.hostname}` : 'Hop';
|
|
3650
|
+
loadAuthSecret();
|
|
3651
|
+
await waitForStartPolling(state, label);
|
|
3652
|
+
await attachToExistingTunnel(state);
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
// SERVER MODE: Start new tunnel
|
|
3657
|
+
|
|
3658
|
+
// Require password for custom domains
|
|
3659
|
+
const domainConfig = getDomainConfig();
|
|
3660
|
+
if (domainConfig && domainConfig.hostname && !isPasswordEnabled()) {
|
|
3661
|
+
log(`\n${RED}ERROR: Password required for custom domain.${RESET}`, RED);
|
|
3662
|
+
log(`\nYour fixed URL (${domainConfig.hostname}) is publicly known.`, YELLOW);
|
|
3663
|
+
log(`Password protection is required for security.\n`, RESET);
|
|
3664
|
+
log(`Run: ${CYAN}hop password set${RESET}`, RESET);
|
|
3665
|
+
process.exit(1);
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
loadAuthSecret();
|
|
3669
|
+
await checkAndInstallDependencies();
|
|
3670
|
+
await ensureHayLoaded();
|
|
3671
|
+
const authEmitter = new EventEmitter();
|
|
3672
|
+
|
|
3673
|
+
const proxyServer = startAuthProxy(authEmitter);
|
|
3674
|
+
const { tunnel: tunnelProc, publicUrl } = await startTunnel();
|
|
3675
|
+
|
|
3676
|
+
// Save state for other terminals to find
|
|
3677
|
+
writePrivateFile(STATE_FILE, JSON.stringify({
|
|
3678
|
+
pid: process.pid,
|
|
3679
|
+
port: BASE_PORT,
|
|
3680
|
+
url: publicUrl,
|
|
3681
|
+
sessionSecret: SESSION_SECRET,
|
|
3682
|
+
startTime: Date.now()
|
|
3683
|
+
}));
|
|
3684
|
+
|
|
3685
|
+
if (DAEMON_FLAG) {
|
|
3686
|
+
const cleanup = () => {
|
|
3687
|
+
if (shuttingDown) return;
|
|
3688
|
+
shuttingDown = true;
|
|
3689
|
+
log(`Cleaning up...`, YELLOW);
|
|
3690
|
+
|
|
3691
|
+
if (fs.existsSync(STATE_FILE)) {
|
|
3692
|
+
try { fs.unlinkSync(STATE_FILE); } catch (e) { }
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
hayRooms.closeAll();
|
|
3696
|
+
|
|
3697
|
+
try { (currentTunnelProc || tunnelProc).kill('SIGTERM'); } catch (e) { }
|
|
3698
|
+
try { proxyServer.close(); } catch (e) { }
|
|
3699
|
+
|
|
3700
|
+
process.exit(0);
|
|
3701
|
+
};
|
|
3702
|
+
|
|
3703
|
+
process.on('SIGINT', cleanup);
|
|
3704
|
+
process.on('SIGTERM', cleanup);
|
|
3705
|
+
process.on('exit', cleanup);
|
|
3706
|
+
await new Promise(() => {});
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
await waitForStart(authEmitter, publicUrl);
|
|
3710
|
+
|
|
3711
|
+
await startLocalSession(proxyServer, tunnelProc, publicUrl);
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
main().catch(err => {
|
|
3715
|
+
log(`Fatal: ${err.message}`, RED);
|
|
3716
|
+
process.exit(1);
|
|
3717
|
+
});
|