mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
feat: on-demand tunnel start via POST /tunnel/start
pair-agent now auto-starts the ngrok tunnel without restarting the server. New POST /tunnel/start endpoint reads authtoken from env, ~/.gstack/ngrok.env, or ngrok's native config. CLI detects ngrok availability and calls the endpoint automatically. Zero manual steps when ngrok is installed and authed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+16
-62
@@ -617,72 +617,26 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
||||
// No tunnel active. Check if ngrok is available and auto-start.
|
||||
const ngrokAvailable = isNgrokAvailable();
|
||||
if (ngrokAvailable) {
|
||||
console.log('[browse] ngrok is available. Starting tunnel...');
|
||||
// Restart server with tunnel enabled
|
||||
console.log('[browse] ngrok detected. Starting tunnel...');
|
||||
try {
|
||||
const restartResp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
||||
const tunnelResp = await fetch(`http://127.0.0.1:${state.port}/tunnel/start`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${state.token}`,
|
||||
},
|
||||
body: JSON.stringify({ command: 'restart', args: [] }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}).catch(() => null);
|
||||
// Wait for server to come back, then restart with tunnel
|
||||
await Bun.sleep(1000);
|
||||
} catch {}
|
||||
// Restart the server process with BROWSE_TUNNEL=1
|
||||
console.log('[browse] Restarting server with tunnel...');
|
||||
const serverScript = resolveServerScript();
|
||||
const proc = Bun.spawn(['bun', 'run', serverScript], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile, BROWSE_TUNNEL: '1' },
|
||||
});
|
||||
proc.unref();
|
||||
// Wait for server to come back with tunnel
|
||||
const deadline = Date.now() + 15000;
|
||||
let tunnelUrl: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
await Bun.sleep(500);
|
||||
const newState = readState();
|
||||
if (newState && await isServerHealthy(newState.port)) {
|
||||
try {
|
||||
const healthResp = await fetch(`http://127.0.0.1:${newState.port}/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
const health = await healthResp.json() as any;
|
||||
if (health.tunnel?.url) {
|
||||
tunnelUrl = health.tunnel.url;
|
||||
// Update state for the rest of the function
|
||||
state.port = newState.port;
|
||||
state.token = newState.token;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (tunnelUrl) {
|
||||
console.log(`[browse] Tunnel active: ${tunnelUrl}\n`);
|
||||
serverUrl = tunnelUrl;
|
||||
// Re-create setup key with the new server (old one used old root token)
|
||||
const newPairResp = await fetch(`http://127.0.0.1:${state.port}/pair`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${state.token}`,
|
||||
},
|
||||
body: JSON.stringify({ clientId: clientName, admin }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { 'Authorization': `Bearer ${state.token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (newPairResp.ok) {
|
||||
const newData = await newPairResp.json() as typeof pairData;
|
||||
pairData.setup_key = newData.setup_key;
|
||||
pairData.expires_at = newData.expires_at;
|
||||
pairData.scopes = newData.scopes;
|
||||
const tunnelData = await tunnelResp.json() as any;
|
||||
if (tunnelResp.ok && tunnelData.url) {
|
||||
console.log(`[browse] Tunnel active: ${tunnelData.url}\n`);
|
||||
serverUrl = tunnelData.url;
|
||||
} else {
|
||||
console.warn(`[browse] Tunnel failed: ${tunnelData.error || 'unknown error'}`);
|
||||
if (tunnelData.hint) console.warn(`[browse] ${tunnelData.hint}`);
|
||||
console.warn('[browse] Using localhost (same-machine only).\n');
|
||||
serverUrl = pairData.server_url;
|
||||
}
|
||||
} else {
|
||||
console.warn('[browse] Failed to start tunnel. Using localhost (same-machine only).\n');
|
||||
} catch (err: any) {
|
||||
console.warn(`[browse] Tunnel failed: ${err.message}`);
|
||||
console.warn('[browse] Using localhost (same-machine only).\n');
|
||||
serverUrl = pairData.server_url;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1374,6 +1374,77 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /tunnel/start — start ngrok tunnel on demand (root-only) ──
|
||||
if (url.pathname === '/tunnel/start' && req.method === 'POST') {
|
||||
if (!isRootRequest(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||||
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (tunnelActive) {
|
||||
return new Response(JSON.stringify({ url: tunnelUrl, already_active: true }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
// Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config
|
||||
let authtoken = process.env.NGROK_AUTHTOKEN;
|
||||
if (!authtoken) {
|
||||
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
|
||||
if (fs.existsSync(ngrokEnvPath)) {
|
||||
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
|
||||
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
|
||||
if (match) authtoken = match[1].trim();
|
||||
}
|
||||
}
|
||||
if (!authtoken) {
|
||||
// Check ngrok's native config files
|
||||
const ngrokConfigs = [
|
||||
path.join(process.env.HOME || '', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
|
||||
path.join(process.env.HOME || '', '.config', 'ngrok', 'ngrok.yml'),
|
||||
path.join(process.env.HOME || '', '.ngrok2', 'ngrok.yml'),
|
||||
];
|
||||
for (const conf of ngrokConfigs) {
|
||||
try {
|
||||
const content = fs.readFileSync(conf, 'utf-8');
|
||||
const match = content.match(/authtoken:\s*(.+)/);
|
||||
if (match) { authtoken = match[1].trim(); break; }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (!authtoken) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'No ngrok authtoken found',
|
||||
hint: 'Run: ngrok config add-authtoken YOUR_TOKEN',
|
||||
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const ngrok = await import('@ngrok/ngrok');
|
||||
const domain = process.env.NGROK_DOMAIN;
|
||||
const forwardOpts: any = { addr: server!.port, authtoken };
|
||||
if (domain) forwardOpts.domain = domain;
|
||||
|
||||
tunnelListener = await ngrok.forward(forwardOpts);
|
||||
tunnelUrl = tunnelListener.url();
|
||||
tunnelActive = true;
|
||||
console.log(`[browse] Tunnel started on demand: ${tunnelUrl}`);
|
||||
|
||||
// Update state file
|
||||
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
||||
stateContent.tunnel = { url: tunnelUrl, domain: domain || null, startedAt: new Date().toISOString() };
|
||||
const tmpState = config.stateFile + '.tmp';
|
||||
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmpState, config.stateFile);
|
||||
|
||||
return new Response(JSON.stringify({ url: tunnelUrl }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({
|
||||
error: `Failed to start tunnel: ${err.message}`,
|
||||
}), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
// Refs endpoint — auth required, does NOT reset idle timer
|
||||
if (url.pathname === '/refs') {
|
||||
if (!validateAuth(req)) {
|
||||
|
||||
Reference in New Issue
Block a user