feat: add Ngrok tunnel provider (#1415)
Adds Ngrok quick tunnel support Adds desktop tunnel docs across locales Improves tunnel settings provider and TTL labels
This commit is contained in:
committed by
GitHub
parent
25ed8e6abb
commit
00a7807168
@@ -14,6 +14,7 @@ import { createTunnelAuth } from './lib/opencode/tunnel-auth.js';
|
||||
import { createManagedTunnelConfigRuntime } from './lib/tunnels/managed-config.js';
|
||||
import { createTunnelProviderRegistry } from './lib/tunnels/registry.js';
|
||||
import { createCloudflareTunnelProvider } from './lib/tunnels/providers/cloudflare.js';
|
||||
import { createNgrokTunnelProvider } from './lib/tunnels/providers/ngrok.js';
|
||||
import { createRequestSecurityRuntime } from './lib/security/request-security.js';
|
||||
import {
|
||||
TUNNEL_MODE_MANAGED_LOCAL,
|
||||
@@ -449,6 +450,7 @@ let activeTunnelController = null;
|
||||
let globalWatcherStartPromise = null;
|
||||
const tunnelProviderRegistry = createTunnelProviderRegistry([
|
||||
createCloudflareTunnelProvider(),
|
||||
createNgrokTunnelProvider(),
|
||||
]);
|
||||
tunnelProviderRegistry.seal();
|
||||
const tunnelAuthController = createTunnelAuth();
|
||||
|
||||
@@ -637,14 +637,12 @@ export async function startCloudflareTunnel({ originUrl, port }) {
|
||||
|
||||
export function printTunnelWarning() {
|
||||
console.log(`
|
||||
⚠️ Cloudflare Quick Tunnel Limitations:
|
||||
⚠️ Quick Tunnel Limitations:
|
||||
|
||||
• Maximum 200 concurrent requests
|
||||
• Server-Sent Events (SSE) are NOT supported
|
||||
• Provider limits may apply
|
||||
• URLs are temporary and will expire when the tunnel stops
|
||||
• Password protection is required for tunnel access
|
||||
|
||||
For production use, set up a managed remote Cloudflare Tunnel:
|
||||
https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/
|
||||
For production use, set up a persistent provider tunnel or static domain.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
|
||||
const NGROK_API_URL = 'http://127.0.0.1:4040/api/tunnels';
|
||||
const NGROK_INSTALL_HELP = 'brew install ngrok';
|
||||
const NGROK_AUTHTOKEN_HELP = 'Run: ngrok config add-authtoken <your-ngrok-token>';
|
||||
|
||||
async function searchPathFor(command) {
|
||||
const pathValue = process.env.PATH || '';
|
||||
const segments = pathValue.split(path.delimiter).filter(Boolean);
|
||||
const WINDOWS_EXTENSIONS = process.platform === 'win32'
|
||||
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
||||
.split(';')
|
||||
.map((ext) => ext.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`))
|
||||
: [''];
|
||||
|
||||
for (const dir of segments) {
|
||||
for (const ext of WINDOWS_EXTENSIONS) {
|
||||
const fileName = process.platform === 'win32' ? `${command}${ext}` : command;
|
||||
const candidate = path.join(dir, fileName);
|
||||
try {
|
||||
const stats = fs.statSync(candidate);
|
||||
if (!stats.isFile()) {
|
||||
continue;
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return candidate;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function checkNgrokAvailable() {
|
||||
const ngrokPath = await searchPathFor('ngrok');
|
||||
if (ngrokPath) {
|
||||
try {
|
||||
const result = spawnSync(ngrokPath, ['version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status === 0) {
|
||||
return { available: true, path: ngrokPath, version: result.stdout.trim() || result.stderr.trim() };
|
||||
}
|
||||
} catch {
|
||||
// Ignore and report unavailable below.
|
||||
}
|
||||
}
|
||||
return { available: false, path: null, version: null };
|
||||
}
|
||||
|
||||
export async function checkNgrokAuthtokenConfigured(ngrokPath = null) {
|
||||
if (typeof process.env.NGROK_AUTHTOKEN === 'string' && process.env.NGROK_AUTHTOKEN.trim().length > 0) {
|
||||
return { configured: true, detail: 'NGROK_AUTHTOKEN is set.' };
|
||||
}
|
||||
|
||||
const resolvedPath = ngrokPath || await searchPathFor('ngrok');
|
||||
if (!resolvedPath) {
|
||||
return { configured: false, detail: `ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(resolvedPath, ['config', 'check'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||
if (result.status === 0) {
|
||||
return { configured: true, detail: output || 'ngrok config is valid.' };
|
||||
}
|
||||
return { configured: false, detail: output || NGROK_AUTHTOKEN_HELP };
|
||||
} catch (error) {
|
||||
return {
|
||||
configured: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkNgrokApiReachability({ fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
return { reachable: false, status: null, error: 'Fetch API is unavailable in this runtime.' };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetchImpl('https://api.ngrok.com/', {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return { reachable: true, status: response.status, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
reachable: false,
|
||||
status: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
killSignal: 'SIGINT',
|
||||
});
|
||||
|
||||
async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetchImpl(NGROK_API_URL, { method: 'GET' });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const tunnels = Array.isArray(payload?.tunnels) ? payload.tunnels : [];
|
||||
const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && typeof entry?.public_url === 'string');
|
||||
const fallbackTunnel = tunnels.find((entry) => typeof entry?.public_url === 'string');
|
||||
return httpsTunnel?.public_url || fallbackTunnel?.public_url || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startNgrokQuickTunnel({ port }) {
|
||||
const ngrokCheck = await checkNgrokAvailable();
|
||||
if (!ngrokCheck.available) {
|
||||
throw new Error(`ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}`);
|
||||
}
|
||||
|
||||
const authtokenCheck = await checkNgrokAuthtokenConfigured(ngrokCheck.path);
|
||||
if (!authtokenCheck.configured) {
|
||||
throw new Error(`ngrok authtoken is not configured. ${NGROK_AUTHTOKEN_HELP}`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(port)) {
|
||||
throw new Error('A local port is required to start an ngrok tunnel');
|
||||
}
|
||||
|
||||
const child = spawnNgrok(['http', String(port)], ngrokCheck.path);
|
||||
let publicUrl = null;
|
||||
|
||||
child.stdout.on('data', () => {
|
||||
// Keep stream drained; ngrok exposes the URL via its local API.
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(chunk.toString('utf8'));
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`Ngrok error: ${error.message}`);
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(checkReady);
|
||||
try { child.kill('SIGINT'); } catch { /* ignore */ }
|
||||
reject(new Error('Ngrok tunnel URL not received within 30 seconds'));
|
||||
}, DEFAULT_STARTUP_TIMEOUT_MS);
|
||||
|
||||
const checkReady = setInterval(async () => {
|
||||
publicUrl = await fetchNgrokPublicUrl();
|
||||
if (publicUrl) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(checkReady);
|
||||
resolve(null);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(checkReady);
|
||||
reject(new Error(`Ngrok exited while starting (code ${code ?? 'unknown'})`));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'quick',
|
||||
stop: () => {
|
||||
try {
|
||||
child.kill('SIGINT');
|
||||
} catch {
|
||||
// Ignore.
|
||||
}
|
||||
},
|
||||
process: child,
|
||||
getPublicUrl: () => publicUrl,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ This module contains tunnel provider orchestration for OpenChamber, including pr
|
||||
- `packages/web/server/lib/tunnels/routes.js`: tunnel API route registration and request orchestration runtime.
|
||||
- `packages/web/server/lib/tunnels/types.js`: tunnel constants, normalization, and shared type helpers.
|
||||
- `packages/web/server/lib/tunnels/providers/cloudflare.js`: Cloudflare tunnel provider implementation.
|
||||
- `packages/web/server/lib/tunnels/providers/ngrok.js`: Ngrok quick tunnel provider implementation.
|
||||
|
||||
## Public exports (routes.js)
|
||||
- `createTunnelRoutesRuntime(dependencies)`: creates tunnel routes runtime and helpers.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
checkNgrokApiReachability,
|
||||
checkNgrokAuthtokenConfigured,
|
||||
checkNgrokAvailable,
|
||||
startNgrokQuickTunnel,
|
||||
} from '../../ngrok-tunnel.js';
|
||||
|
||||
import {
|
||||
TUNNEL_INTENT_EPHEMERAL_PUBLIC,
|
||||
TUNNEL_MODE_QUICK,
|
||||
TUNNEL_PROVIDER_NGROK,
|
||||
TunnelServiceError,
|
||||
} from '../types.js';
|
||||
|
||||
export const ngrokTunnelProviderCapabilities = {
|
||||
provider: TUNNEL_PROVIDER_NGROK,
|
||||
defaults: {
|
||||
mode: TUNNEL_MODE_QUICK,
|
||||
optionDefaults: {},
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
key: TUNNEL_MODE_QUICK,
|
||||
label: 'Quick Tunnel',
|
||||
intent: TUNNEL_INTENT_EPHEMERAL_PUBLIC,
|
||||
requires: [],
|
||||
supports: ['sessionTTL'],
|
||||
stability: 'beta',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function createNgrokTunnelProvider() {
|
||||
return {
|
||||
id: TUNNEL_PROVIDER_NGROK,
|
||||
capabilities: ngrokTunnelProviderCapabilities,
|
||||
checkAvailability: async () => {
|
||||
const result = await checkNgrokAvailable();
|
||||
if (result.available) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
message: 'ngrok is not installed. Install it with: brew install ngrok',
|
||||
};
|
||||
},
|
||||
diagnose: async () => {
|
||||
const dependency = await checkNgrokAvailable();
|
||||
const authtoken = await checkNgrokAuthtokenConfigured(dependency.path);
|
||||
const network = await checkNgrokApiReachability();
|
||||
const startupReady = dependency.available && authtoken.configured && network.reachable;
|
||||
const providerChecks = [
|
||||
{
|
||||
id: 'dependency',
|
||||
label: 'ngrok installed',
|
||||
status: dependency.available ? 'pass' : 'fail',
|
||||
detail: dependency.available
|
||||
? (dependency.version || dependency.path || 'ngrok available')
|
||||
: 'ngrok is not installed. Install it with: brew install ngrok',
|
||||
},
|
||||
{
|
||||
id: 'authtoken',
|
||||
label: 'ngrok authtoken configured',
|
||||
status: authtoken.configured ? 'pass' : 'fail',
|
||||
detail: authtoken.configured
|
||||
? authtoken.detail
|
||||
: (authtoken.detail || 'Run: ngrok config add-authtoken <your-ngrok-token>'),
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'ngrok API reachable',
|
||||
status: network.reachable ? 'pass' : 'fail',
|
||||
detail: network.reachable
|
||||
? (network.status ? `HTTP ${network.status}` : 'Reachable')
|
||||
: (network.error || 'Could not reach api.ngrok.com'),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
providerChecks,
|
||||
modes: [
|
||||
{
|
||||
mode: TUNNEL_MODE_QUICK,
|
||||
checks: [
|
||||
{
|
||||
id: 'startup_readiness',
|
||||
label: 'Provider startup readiness',
|
||||
status: startupReady ? 'pass' : 'fail',
|
||||
detail: startupReady
|
||||
? 'Provider dependency, auth, and network checks passed.'
|
||||
: 'Resolve provider checks before starting tunnels.',
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
ready: startupReady,
|
||||
failures: startupReady ? 0 : 1,
|
||||
warnings: 0,
|
||||
},
|
||||
ready: startupReady,
|
||||
blockers: startupReady ? [] : ['Resolve provider checks before starting tunnels.'],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
start: async (request, context = {}) => {
|
||||
if (request.mode !== TUNNEL_MODE_QUICK) {
|
||||
throw new TunnelServiceError('mode_unsupported', `Ngrok only supports '${TUNNEL_MODE_QUICK}' mode right now`);
|
||||
}
|
||||
return startNgrokQuickTunnel({ port: context.activePort });
|
||||
},
|
||||
stop: (controller) => {
|
||||
controller?.stop?.();
|
||||
},
|
||||
resolvePublicUrl: (controller) => controller?.getPublicUrl?.() ?? null,
|
||||
getMetadata: () => null,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export const TUNNEL_PROVIDER_CLOUDFLARE = 'cloudflare';
|
||||
export const TUNNEL_PROVIDER_NGROK = 'ngrok';
|
||||
|
||||
export const TUNNEL_MODE_QUICK = 'quick';
|
||||
export const TUNNEL_MODE_MANAGED_REMOTE = 'managed-remote';
|
||||
@@ -34,6 +35,7 @@ export class TunnelServiceError extends Error {
|
||||
|
||||
const SUPPORTED_TUNNEL_PROVIDERS = new Set([
|
||||
TUNNEL_PROVIDER_CLOUDFLARE,
|
||||
TUNNEL_PROVIDER_NGROK,
|
||||
]);
|
||||
|
||||
export function normalizeTunnelProvider(value) {
|
||||
|
||||
Reference in New Issue
Block a user