From 16fdff530bdb49f7bd7956a003213f640e3fadaa Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 5 Jun 2026 15:01:22 +0300 Subject: [PATCH] fix: improve Windows tunnel support --- .../sections/openchamber/TunnelSettings.tsx | 135 ++++++++- .../ui/src/lib/i18n/messages/en.settings.ts | 1 + .../ui/src/lib/i18n/messages/es.settings.ts | 1 + .../ui/src/lib/i18n/messages/ko.settings.ts | 1 + .../ui/src/lib/i18n/messages/pl.settings.ts | 1 + .../src/lib/i18n/messages/pt-BR.settings.ts | 1 + .../ui/src/lib/i18n/messages/uk.settings.ts | 1 + .../src/lib/i18n/messages/zh-CN.settings.ts | 1 + .../src/lib/i18n/messages/zh-TW.settings.ts | 1 + packages/web/server/lib/cloudflare-tunnel.js | 50 +--- packages/web/server/lib/ngrok-tunnel.js | 278 +++++++++++++----- packages/web/server/lib/ngrok-tunnel.test.js | 50 ++++ .../web/server/lib/tunnels/DOCUMENTATION.md | 2 + .../server/lib/tunnels/executable-search.js | 140 +++++++++ .../lib/tunnels/executable-search.test.js | 96 ++++++ packages/web/server/lib/tunnels/index.js | 27 +- packages/web/server/lib/tunnels/index.test.js | 93 ++++++ .../web/server/lib/tunnels/install-help.js | 56 ++++ .../server/lib/tunnels/install-help.test.js | 31 ++ .../lib/tunnels/providers/cloudflare.js | 12 +- .../web/server/lib/tunnels/providers/ngrok.js | 12 +- packages/web/server/lib/tunnels/routes.js | 7 +- packages/web/server/lib/tunnels/types.js | 56 +++- packages/web/server/lib/tunnels/types.test.js | 34 +++ 24 files changed, 936 insertions(+), 151 deletions(-) create mode 100644 packages/web/server/lib/ngrok-tunnel.test.js create mode 100644 packages/web/server/lib/tunnels/executable-search.js create mode 100644 packages/web/server/lib/tunnels/executable-search.test.js create mode 100644 packages/web/server/lib/tunnels/index.test.js create mode 100644 packages/web/server/lib/tunnels/install-help.js create mode 100644 packages/web/server/lib/tunnels/install-help.test.js create mode 100644 packages/web/server/lib/tunnels/types.test.js diff --git a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx index 9cff5236..bd2e8a98 100644 --- a/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/TunnelSettings.tsx @@ -149,6 +149,84 @@ interface TunnelProviderCapability { modes?: TunnelProviderModeDescriptor[]; } +interface TunnelCheckResponse { + available?: boolean; + provider?: string | null; + version?: string | null; + dependency?: string | null; + installCommand?: string | null; + platform?: string | null; +} + +interface TunnelDependencyInstallInfo { + provider: string; + dependency: string; + installCommand: string; +} + +const getProviderDependencyName = (provider: string): string => (provider === 'ngrok' ? 'ngrok' : 'cloudflared'); + +const getClientInstallPlatform = (): string => { + if (typeof window !== 'undefined' && typeof window.__OPENCHAMBER_PLATFORM__ === 'string') { + const platform = window.__OPENCHAMBER_PLATFORM__; + if (platform === 'win32' || platform === 'darwin' || platform === 'linux') { + return platform; + } + } + + const browserPlatform = typeof navigator !== 'undefined' + ? `${navigator.platform || ''} ${navigator.userAgent || ''}`.toLowerCase() + : ''; + if (browserPlatform.includes('win')) { + return 'win32'; + } + if (browserPlatform.includes('mac')) { + return 'darwin'; + } + return 'linux'; +}; + +const getFallbackInstallCommand = (provider: string, platform = getClientInstallPlatform()): string => { + if (provider === 'ngrok') { + if (platform === 'win32') { + return 'winget install ngrok -s msstore'; + } + if (platform === 'darwin') { + return 'brew install ngrok'; + } + return 'https://ngrok.com/download'; + } + + if (platform === 'win32') { + return 'winget install --id Cloudflare.cloudflared'; + } + if (platform === 'darwin') { + return 'brew install cloudflared'; + } + return 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/'; +}; + +const createTunnelDependencyInstallInfo = (provider: string, checkData?: TunnelCheckResponse): TunnelDependencyInstallInfo => { + const responseProvider = typeof checkData?.provider === 'string' && checkData.provider.trim().length > 0 + ? checkData.provider.trim().toLowerCase() + : provider; + const dependency = typeof checkData?.dependency === 'string' && checkData.dependency.trim().length > 0 + ? checkData.dependency.trim() + : getProviderDependencyName(responseProvider); + const platform = typeof checkData?.platform === 'string' && checkData.platform.trim().length > 0 + ? checkData.platform.trim() + : getClientInstallPlatform(); + const installCommand = typeof checkData?.installCommand === 'string' && checkData.installCommand.trim().length > 0 + ? checkData.installCommand.trim() + : getFallbackInstallCommand(responseProvider, platform); + + return { + provider: responseProvider, + dependency, + installCommand, + }; +}; + const getProviderLabel = (provider: string): string => { if (provider === 'cloudflare') { return 'Cloudflare'; @@ -281,6 +359,7 @@ export const TunnelSettings: React.FC = () => { const [isSavingTtl, setIsSavingTtl] = React.useState(false); const [isSavingMode, setIsSavingMode] = React.useState(false); const [tunnelProvider, setTunnelProvider] = React.useState('cloudflare'); + const [dependencyInstallInfo, setDependencyInstallInfo] = React.useState(() => createTunnelDependencyInstallInfo('cloudflare')); const [providerCapabilities, setProviderCapabilities] = React.useState([]); const [tunnelMode, setTunnelMode] = React.useState('quick'); const [managedLocalConfigPath, setManagedLocalConfigPath] = React.useState(null); @@ -400,13 +479,47 @@ export const TunnelSettings: React.FC = () => { () => tunnelModeOptions.some((option) => option.value === 'managed-remote' || option.value === 'managed-local'), [tunnelModeOptions], ); - const installCommand = tunnelProvider === 'ngrok' - ? 'brew install ngrok' - : 'brew install cloudflared'; + const displayedDependencyInstallInfo = React.useMemo(() => { + if (dependencyInstallInfo.provider === tunnelProvider) { + return dependencyInstallInfo; + } + return createTunnelDependencyInstallInfo(tunnelProvider); + }, [dependencyInstallInfo, tunnelProvider]); const openExternal = React.useCallback(async (url: string) => { await openExternalUrl(url); }, []); + const applyDependencyCheck = React.useCallback((checkData: TunnelCheckResponse, fallbackProvider: string): boolean => { + setDependencyInstallInfo(createTunnelDependencyInstallInfo(fallbackProvider, checkData)); + return checkData.available === true; + }, []); + + const refreshTunnelDependencyCheck = React.useCallback(async (provider: string, signal?: AbortSignal): Promise => { + try { + const checkRes = await runtimeFetch('/api/openchamber/tunnel/check', { + query: { provider }, + ...(signal ? { signal } : {}), + }); + if (!checkRes.ok) { + return null; + } + const checkData = (await checkRes.json()) as TunnelCheckResponse; + if (signal?.aborted) { + return null; + } + const available = applyDependencyCheck(checkData, provider); + setState((current) => { + if (current === 'checking' || current === 'starting' || current === 'active' || current === 'stopping') { + return current; + } + return available ? 'idle' : 'not-available'; + }); + return available; + } catch { + return null; + } + }, [applyDependencyCheck]); + const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => { try { const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([ @@ -416,7 +529,7 @@ export const TunnelSettings: React.FC = () => { runtimeFetch('/api/openchamber/tunnel/providers', { signal }), ]); - const checkData = await checkRes.json(); + const checkData = (await checkRes.json()) as TunnelCheckResponse; const statusData = (await statusRes.json()) as TunnelStatusResponse; const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const providersData = providersRes.ok ? await providersRes.json() : {}; @@ -440,6 +553,7 @@ export const TunnelSettings: React.FC = () => { const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string' ? settingsData.managedLocalTunnelConfigPath.trim() || null : null; + const dependencyAvailable = applyDependencyCheck(checkData, loadedProvider); const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets); const loadedHostname = typeof statusData.managedRemoteTunnelHostname === 'string' @@ -484,14 +598,14 @@ export const TunnelSettings: React.FC = () => { return; } - setState(checkData.available ? 'idle' : 'not-available'); + setState(dependencyAvailable ? 'idle' : 'not-available'); } catch { if (!signal.aborted) { setState('error'); setErrorMessage(t('settings.openchamber.tunnel.toast.checkAvailabilityFailed')); } } - }, [t]); + }, [applyDependencyCheck, t]); React.useEffect(() => { const controller = new AbortController(); @@ -738,7 +852,8 @@ export const TunnelSettings: React.FC = () => { ? tunnelMode : toUiTunnelMode(capability?.modes?.[0]?.key); await saveTunnelSettings({ tunnelProvider: provider, tunnelMode: defaultMode }); - }, [providerCapabilities, saveTunnelSettings, tunnelMode]); + void refreshTunnelDependencyCheck(provider); + }, [providerCapabilities, refreshTunnelDependencyCheck, saveTunnelSettings, tunnelMode]); const handleBrowseManagedLocalConfig = React.useCallback(async () => { const result = await requestFileAccess({ @@ -1192,10 +1307,12 @@ export const TunnelSettings: React.FC = () => {
-

{getProviderLabel(tunnelProvider)} tunnel dependency was not found.

+

+ {t('settings.openchamber.tunnel.notAvailable.dependencyNotFound', { dependency: displayedDependencyInstallInfo.dependency })} +

{t('settings.openchamber.tunnel.notAvailable.installHint')}

- {installCommand} + {displayedDependencyInstallInfo.installCommand}
diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index ffa71a07..161f85ee 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -119,6 +119,7 @@ export const settingsDict = { 'settings.openchamber.tunnel.section.redeemedAccessLinks': 'Redeemed access links', 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': 'Saved managed remote tunnels', 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared not found', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} was not found.', 'settings.openchamber.tunnel.notAvailable.installHint': 'Install it to enable remote tunnel access:', 'settings.openchamber.tunnel.field.provider': 'Provider', 'settings.openchamber.tunnel.field.providerPlaceholder': 'Select provider', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index e57ad290..9ef24ebf 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -86,6 +86,7 @@ export const settingsDict = { "settings.openchamber.tunnel.section.redeemedAccessLinks": "Enlaces de acceso canjeados", "settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneles remotos gestionados guardados", "settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared no encontrado", + "settings.openchamber.tunnel.notAvailable.dependencyNotFound": "No se encontró {dependency}.", "settings.openchamber.tunnel.notAvailable.installHint": "Instálalo para habilitar el acceso al túnel remoto:", "settings.openchamber.tunnel.field.provider": "Proveedor", "settings.openchamber.tunnel.field.providerPlaceholder": "Seleccionar proveedor", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 38cdcde5..2590bb92 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -86,6 +86,7 @@ export const settingsDict = { 'settings.openchamber.tunnel.section.redeemedAccessLinks': '사용된 접속 링크', 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '저장된 Managed Remote 터널', 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared를 찾을 수 없음', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency}를 찾을 수 없습니다.', 'settings.openchamber.tunnel.notAvailable.installHint': '원격 터널 접속을 사용하려면 설치하세요:', 'settings.openchamber.tunnel.field.provider': '프로바이더', 'settings.openchamber.tunnel.field.providerPlaceholder': '프로바이더 선택', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 7986aa63..ac559b94 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -814,6 +814,7 @@ export const settingsDict = { 'settings.openchamber.tunnel.field.tunnelSessionTtl': 'TTL sesji tunelu', 'settings.openchamber.tunnel.field.tunnelType': 'Typ tunelu', 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'Nie znaleziono cloudflared', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': 'Nie znaleziono {dependency}.', 'settings.openchamber.tunnel.notAvailable.installHint': 'Zainstaluj go, aby włączyć zdalny dostęp przez tunel:', 'settings.openchamber.tunnel.note.cloudflareConnectorTarget': 'Docelowy adres konektora Cloudflare:', 'settings.openchamber.tunnel.note.cloudflareConnectorTargetUse': 'Dla docelowego adresu konektora Cloudflare użyj', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 88519cf0..58afd50c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -86,6 +86,7 @@ export const settingsDict = { "settings.openchamber.tunnel.section.redeemedAccessLinks": "Links de acesso resgatados", "settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneis remotos gerenciados salvos", "settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared não encontrado", + "settings.openchamber.tunnel.notAvailable.dependencyNotFound": "{dependency} não foi encontrado.", "settings.openchamber.tunnel.notAvailable.installHint": "Instale para habilitar o acesso ao túnel remoto:", "settings.openchamber.tunnel.field.provider": "Provedor", "settings.openchamber.tunnel.field.providerPlaceholder": "Selecionar provedor", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index d151721a..ae7c0d9a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -86,6 +86,7 @@ export const settingsDict = { "settings.openchamber.tunnel.section.redeemedAccessLinks": "Використані посилання доступу", "settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Збережені керовані віддалені тунелі", "settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared не знайдено", + "settings.openchamber.tunnel.notAvailable.dependencyNotFound": "{dependency} не знайдено.", "settings.openchamber.tunnel.notAvailable.installHint": "Встановіть його, щоб увімкнути віддалений доступ через тунель:", "settings.openchamber.tunnel.field.provider": "Провайдер", "settings.openchamber.tunnel.field.providerPlaceholder": "Виберіть провайдера", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index f712bbca..44a29ed0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -86,6 +86,7 @@ export const settingsDict = { 'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兑换访问链接', 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已保存的 Managed Remote 隧道', 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '未找到 cloudflared', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '未找到 {dependency}。', 'settings.openchamber.tunnel.notAvailable.installHint': '安装它以启用远程隧道访问:', 'settings.openchamber.tunnel.field.provider': '提供商', 'settings.openchamber.tunnel.field.providerPlaceholder': '选择提供商', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index ea52853a..dd5a2757 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -83,6 +83,7 @@ 'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兌換存取連結', 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已儲存的 managed remote tunnel', 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '找不到 cloudflared', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '找不到 {dependency}。', 'settings.openchamber.tunnel.notAvailable.installHint': '安裝它以啟用 remote tunnel 存取:', 'settings.openchamber.tunnel.field.provider': '供應商', 'settings.openchamber.tunnel.field.providerPlaceholder': '選擇供應商', diff --git a/packages/web/server/lib/cloudflare-tunnel.js b/packages/web/server/lib/cloudflare-tunnel.js index dedd92a6..3b433070 100644 --- a/packages/web/server/lib/cloudflare-tunnel.js +++ b/packages/web/server/lib/cloudflare-tunnel.js @@ -4,6 +4,10 @@ import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import yaml from 'yaml'; +import { + createExecutableSearchEnv, + resolveExecutableLaunchTarget, +} from './tunnels/executable-search.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -17,52 +21,18 @@ const TUNNEL_MODE_QUICK = 'quick'; const TUNNEL_MODE_MANAGED_REMOTE = 'managed-remote'; const TUNNEL_MODE_MANAGED_LOCAL = 'managed-local'; -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()) { - if (process.platform !== 'win32') { - try { - fs.accessSync(candidate, fs.constants.X_OK); - } catch { - continue; - } - } - return candidate; - } - } catch { - continue; - } - } - } - return null; -} - export async function checkCloudflaredAvailable() { - const cfPath = await searchPathFor('cloudflared'); - if (cfPath) { + const target = resolveExecutableLaunchTarget('cloudflared'); + if (target) { try { - const result = spawnSync(cfPath, ['--version'], { + const result = spawnSync(target.command, ['--version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + env: target.env, }); if (result.status === 0) { - return { available: true, path: cfPath, version: result.stdout.trim() }; + return { available: true, path: target.command, version: result.stdout.trim() }; } } catch { // Ignore @@ -102,7 +72,7 @@ const spawnCloudflared = (args, envOverrides = {}, resolvedBinaryPath = 'cloudfl stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: { - ...process.env, + ...createExecutableSearchEnv(), CF_TELEMETRY_DISABLE: '1', ...envOverrides, }, diff --git a/packages/web/server/lib/ngrok-tunnel.js b/packages/web/server/lib/ngrok-tunnel.js index e9087c87..9d4b00a0 100644 --- a/packages/web/server/lib/ngrok-tunnel.js +++ b/packages/web/server/lib/ngrok-tunnel.js @@ -1,59 +1,29 @@ import { spawn, spawnSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; +import { + createExecutableSearchEnv, + resolveExecutableLaunchTarget, +} from './tunnels/executable-search.js'; +import { getTunnelDependencyInstallInfo } from './tunnels/install-help.js'; +import { TUNNEL_PROVIDER_NGROK } from './tunnels/types.js'; 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_PUBLIC_URL_REGEX = /https:\/\/[^\s"']+/i; const NGROK_AUTHTOKEN_HELP = 'Run: ngrok config add-authtoken '; - -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; -} +const getNgrokInstallInfo = () => getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK); export async function checkNgrokAvailable() { - const ngrokPath = await searchPathFor('ngrok'); - if (ngrokPath) { + const target = resolveExecutableLaunchTarget('ngrok'); + if (target) { try { - const result = spawnSync(ngrokPath, ['version'], { + const result = spawnSync(target.command, ['version'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + env: target.env, }); if (result.status === 0) { - return { available: true, path: ngrokPath, version: result.stdout.trim() || result.stderr.trim() }; + return { available: true, path: target.command, version: result.stdout.trim() || result.stderr.trim() }; } } catch { // Ignore and report unavailable below. @@ -67,16 +37,19 @@ export async function checkNgrokAuthtokenConfigured(ngrokPath = null) { 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}` }; + const target = ngrokPath + ? { command: ngrokPath, env: createExecutableSearchEnv() } + : resolveExecutableLaunchTarget('ngrok'); + if (!target) { + return { configured: false, detail: getNgrokInstallInfo().message }; } try { - const result = spawnSync(resolvedPath, ['config', 'check'], { + const result = spawnSync(target.command, ['config', 'check'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, + env: target.env, }); const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); if (result.status === 0) { @@ -118,10 +91,138 @@ export async function checkNgrokApiReachability({ fetchImpl = globalThis.fetch, const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - env: process.env, + env: createExecutableSearchEnv(), killSignal: 'SIGINT', }); +const normalizeNgrokPublicUrl = (value) => { + if (typeof value !== 'string' || value.trim().length === 0) { + return null; + } + const trimmed = value.trim(); + try { + const parsed = new URL(trimmed); + if (parsed.protocol === 'https:' && parsed.hostname.includes('ngrok')) { + return parsed.toString().replace(/\/$/, ''); + } + } catch { + return null; + } + return null; +}; + +export function extractNgrokPublicUrlFromText(text) { + if (typeof text !== 'string' || text.trim().length === 0) { + return null; + } + + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + for (const line of lines) { + try { + const parsed = JSON.parse(line); + const parsedUrl = normalizeNgrokPublicUrl(parsed?.url) || normalizeNgrokPublicUrl(parsed?.public_url); + if (parsedUrl) { + return parsedUrl; + } + } catch { + // ngrok may emit non-JSON diagnostics even when log-format=json. + } + + const match = line.match(NGROK_PUBLIC_URL_REGEX); + const matchedUrl = normalizeNgrokPublicUrl(match?.[0]); + if (matchedUrl) { + return matchedUrl; + } + } + + return null; +} + +const normalizeNgrokDiagnosticText = (value) => { + if (typeof value !== 'string') { + return ''; + } + return value + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +}; + +export const summarizeNgrokOutput = (lines) => { + const nonEmptyLines = Array.isArray(lines) + ? lines.map((line) => String(line || '').trim()).filter(Boolean) + : []; + if (nonEmptyLines.length === 0) { + return ''; + } + + for (const line of [...nonEmptyLines].reverse()) { + try { + const parsed = JSON.parse(line); + const level = typeof parsed?.lvl === 'string' ? parsed.lvl.toLowerCase() : ''; + if (level !== 'eror' && level !== 'error' && level !== 'crit') { + continue; + } + const err = normalizeNgrokDiagnosticText(parsed?.err); + if (err && err !== '') { + return err; + } + } catch { + // Not a JSON ngrok log line. + } + } + + for (const line of [...nonEmptyLines].reverse()) { + try { + const parsed = JSON.parse(line); + const err = normalizeNgrokDiagnosticText(parsed?.err); + if (err && err !== '' && !/context canceled/i.test(err)) { + return err; + } + const msg = normalizeNgrokDiagnosticText(parsed?.msg); + if (msg && /failed|error|invalid|auth/i.test(msg)) { + return msg; + } + } catch { + // Not a JSON ngrok log line. + } + } + + const errorLines = nonEmptyLines + .filter((line) => /^ERROR:/i.test(line)) + .map((line) => normalizeNgrokDiagnosticText(line.replace(/^ERROR:\s*/i, ''))) + .filter(Boolean); + if (errorLines.length > 0) { + return errorLines.slice(0, 4).join(' '); + } + + const lastLine = [...nonEmptyLines].reverse().find((line) => line.trim().length > 0); + if (!lastLine) { + return ''; + } + try { + const parsed = JSON.parse(lastLine); + if (typeof parsed?.err === 'string' && parsed.err.trim().length > 0) { + return normalizeNgrokDiagnosticText(parsed.err); + } + if (typeof parsed?.msg === 'string' && parsed.msg.trim().length > 0) { + return normalizeNgrokDiagnosticText(parsed.msg); + } + } catch { + // Fall through to plain text output. + } + return normalizeNgrokDiagnosticText(lastLine); +}; + +const appendNgrokOutputSummary = (message, lines) => { + const summary = summarizeNgrokOutput(lines); + return summary ? `${message}: ${summary}` : message; +}; + async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) { if (typeof fetchImpl !== 'function') { return null; @@ -133,9 +234,9 @@ async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) { } 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; + const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && normalizeNgrokPublicUrl(entry?.public_url)); + const fallbackTunnel = tunnels.find((entry) => normalizeNgrokPublicUrl(entry?.public_url)); + return normalizeNgrokPublicUrl(httpsTunnel?.public_url) || normalizeNgrokPublicUrl(fallbackTunnel?.public_url); } catch { return null; } @@ -144,54 +245,87 @@ async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) { 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}`); + throw new Error(getNgrokInstallInfo().message); } const authtokenCheck = await checkNgrokAuthtokenConfigured(ngrokCheck.path); if (!authtokenCheck.configured) { - throw new Error(`ngrok authtoken is not configured. ${NGROK_AUTHTOKEN_HELP}`); + throw new Error(`ngrok authtoken is not configured. ${authtokenCheck.detail || 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); + const child = spawnNgrok(['http', '--log=stdout', '--log-format=json', `127.0.0.1:${port}`], ngrokCheck.path); let publicUrl = null; + const recentOutput = []; - child.stdout.on('data', () => { - // Keep stream drained; ngrok exposes the URL via its local API. + const captureOutput = (chunk) => { + const text = chunk.toString('utf8'); + const parsedUrl = extractNgrokPublicUrlFromText(text); + if (parsedUrl) { + publicUrl = parsedUrl; + } + + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + recentOutput.push(trimmed); + if (recentOutput.length > 200) { + recentOutput.shift(); + } + } + return text; + }; + + child.stdout.on('data', (chunk) => { + captureOutput(chunk); }); child.stderr.on('data', (chunk) => { - process.stderr.write(chunk.toString('utf8')); - }); - - child.on('error', (error) => { - console.error(`Ngrok error: ${error.message}`); + const text = captureOutput(chunk); + process.stderr.write(text); }); await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { + let settled = false; + const finish = (handler, value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); clearInterval(checkReady); + child.off('error', onError); + child.off('exit', onExit); + handler(value); + }; + + const timeout = setTimeout(() => { try { child.kill('SIGINT'); } catch { /* ignore */ } - reject(new Error('Ngrok tunnel URL not received within 30 seconds')); + finish(reject, new Error(appendNgrokOutputSummary('Ngrok tunnel URL not received within 30 seconds', recentOutput))); }, DEFAULT_STARTUP_TIMEOUT_MS); const checkReady = setInterval(async () => { - publicUrl = await fetchNgrokPublicUrl(); + publicUrl = publicUrl || await fetchNgrokPublicUrl(); if (publicUrl) { - clearTimeout(timeout); - clearInterval(checkReady); - resolve(null); + finish(resolve, null); } }, 250); - child.once('exit', (code) => { - clearTimeout(timeout); - clearInterval(checkReady); - reject(new Error(`Ngrok exited while starting (code ${code ?? 'unknown'})`)); - }); + const onError = (error) => { + finish(reject, new Error(`Ngrok failed to start: ${error.message}`)); + }; + + const onExit = (code) => { + finish(reject, new Error(appendNgrokOutputSummary(`Ngrok exited while starting (code ${code ?? 'unknown'})`, recentOutput))); + }; + + child.once('error', onError); + child.once('exit', onExit); }); return { diff --git a/packages/web/server/lib/ngrok-tunnel.test.js b/packages/web/server/lib/ngrok-tunnel.test.js new file mode 100644 index 00000000..4d0b40fd --- /dev/null +++ b/packages/web/server/lib/ngrok-tunnel.test.js @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'bun:test'; + +import { + extractNgrokPublicUrlFromText, + summarizeNgrokOutput, +} from './ngrok-tunnel.js'; + +describe('extractNgrokPublicUrlFromText', () => { + it('extracts public URL from ngrok JSON logs', () => { + const url = extractNgrokPublicUrlFromText('{"lvl":"info","msg":"started tunnel","url":"https://demo.ngrok-free.app"}\n'); + + expect(url).toBe('https://demo.ngrok-free.app'); + }); + + it('extracts public URL from ngrok text output', () => { + const url = extractNgrokPublicUrlFromText('Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:3000'); + + expect(url).toBe('https://demo.ngrok-free.app'); + }); + + it('ignores non-ngrok URLs', () => { + const url = extractNgrokPublicUrlFromText('{"url":"https://example.com"}\n'); + + expect(url).toBeNull(); + }); +}); + +describe('summarizeNgrokOutput', () => { + it('prefers actionable JSON error details over trailing help text', () => { + const summary = summarizeNgrokOutput([ + '{"err":"authentication failed: Your ngrok-agent version \\"3.3.1\\" is too old. The minimum supported agent version for your account is \\"3.20.0\\".\\r\\n\\r\\nERR_NGROK_121\\r\\n","lvl":"crit","msg":"command failed"}', + 'NAME:', + ' http - start an HTTP tunnel', + ]); + + expect(summary).toContain('Your ngrok-agent version "3.3.1" is too old'); + expect(summary).toContain('ERR_NGROK_121'); + }); + + it('skips bare ERROR lines and keeps the useful error text', () => { + const summary = summarizeNgrokOutput([ + 'ERROR:', + 'ERROR: authentication failed', + 'ERROR:', + 'ERROR: ERR_NGROK_121', + ]); + + expect(summary).toBe('authentication failed ERR_NGROK_121'); + }); +}); diff --git a/packages/web/server/lib/tunnels/DOCUMENTATION.md b/packages/web/server/lib/tunnels/DOCUMENTATION.md index 293b000c..44371481 100644 --- a/packages/web/server/lib/tunnels/DOCUMENTATION.md +++ b/packages/web/server/lib/tunnels/DOCUMENTATION.md @@ -5,8 +5,10 @@ This module contains tunnel provider orchestration for OpenChamber, including pr ## Entrypoints and structure - `packages/web/server/lib/tunnels/index.js`: tunnel service orchestration. +- `packages/web/server/lib/tunnels/executable-search.js`: cross-platform executable discovery, including Windows Store app aliases. - `packages/web/server/lib/tunnels/registry.js`: provider registry. - `packages/web/server/lib/tunnels/managed-config.js`: managed remote tunnel token/preset persistence runtime. +- `packages/web/server/lib/tunnels/install-help.js`: provider/platform install command metadata for missing tunnel dependencies. - `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. diff --git a/packages/web/server/lib/tunnels/executable-search.js b/packages/web/server/lib/tunnels/executable-search.js new file mode 100644 index 00000000..628ae06d --- /dev/null +++ b/packages/web/server/lib/tunnels/executable-search.js @@ -0,0 +1,140 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const getEnvValue = (env, keys) => { + for (const key of keys) { + const value = env?.[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return ''; +}; + +const normalizeSearchDirectoryKey = (directory, platform) => { + const trimmed = typeof directory === 'string' ? directory.trim() : ''; + return platform === 'win32' ? trimmed.toLowerCase() : trimmed; +}; + +const getWindowsAppsDirectory = (env) => { + const localAppData = getEnvValue(env, ['LOCALAPPDATA', 'LocalAppData', 'localappdata']); + if (localAppData) { + return path.win32.join(localAppData, 'Microsoft', 'WindowsApps'); + } + + const userProfile = getEnvValue(env, ['USERPROFILE', 'UserProfile', 'userprofile']); + if (userProfile) { + return path.win32.join(userProfile, 'AppData', 'Local', 'Microsoft', 'WindowsApps'); + } + + return path.win32.join(os.homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps'); +}; + +export function getExecutableSearchDirectories({ env = process.env, platform = process.platform } = {}) { + const delimiter = platform === 'win32' ? ';' : ':'; + const pathValue = getEnvValue(env, ['PATH', 'Path', 'path']); + const directories = pathValue.split(delimiter).map((entry) => entry.trim()).filter(Boolean); + + if (platform === 'win32') { + directories.push(getWindowsAppsDirectory(env)); + } + + const seen = new Set(); + const unique = []; + for (const directory of directories) { + const key = normalizeSearchDirectoryKey(directory, platform); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + unique.push(directory); + } + + return unique; +} + +export function createExecutableSearchEnv({ env = process.env, platform = process.platform } = {}) { + const delimiter = platform === 'win32' ? ';' : ':'; + const pathValue = getExecutableSearchDirectories({ env, platform }).join(delimiter); + const nextEnv = { ...env }; + + if (platform === 'win32') { + nextEnv.PATH = pathValue; + nextEnv.Path = pathValue; + nextEnv.path = pathValue; + } else { + nextEnv.PATH = pathValue; + } + + return nextEnv; +} + +const getExecutableExtensions = ({ env = process.env, platform = process.platform } = {}) => { + if (platform !== 'win32') { + return ['']; + } + + return (env.PATHEXT || env.PathExt || env.pathext || '.EXE;.CMD;.BAT;.COM') + .split(';') + .map((ext) => ext.trim().toLowerCase()) + .filter(Boolean) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)); +}; + +export function findExecutableOnPath(command, { + env = process.env, + platform = process.platform, + fsLike = fs, +} = {}) { + if (typeof command !== 'string' || command.trim().length === 0) { + return null; + } + + const pathApi = platform === 'win32' ? path.win32 : path; + const directories = getExecutableSearchDirectories({ env, platform }); + const extensions = getExecutableExtensions({ env, platform }); + const commandName = command.trim(); + + for (const directory of directories) { + for (const extension of extensions) { + const fileName = platform === 'win32' ? `${commandName}${extension}` : commandName; + const candidate = pathApi.join(directory, fileName); + try { + const stats = fsLike.statSync(candidate); + if (!stats.isFile()) { + continue; + } + if (platform !== 'win32') { + try { + fsLike.accessSync(candidate, fs.constants.X_OK); + } catch { + continue; + } + } + return candidate; + } catch { + continue; + } + } + } + + return null; +} + +export function resolveExecutableLaunchTarget(command, options = {}) { + const platform = options.platform || process.platform; + const resolvedPath = findExecutableOnPath(command, { ...options, platform }); + const env = createExecutableSearchEnv({ env: options.env || process.env, platform }); + if (resolvedPath) { + return { command: resolvedPath, env }; + } + + // Windows Store app execution aliases are launchable through CreateProcess + // but can reject fs.stat/fs.access with EACCES. Let the version probe decide. + if (platform === 'win32' && typeof command === 'string' && command.trim().length > 0) { + return { command: command.trim(), env }; + } + + return null; +} diff --git a/packages/web/server/lib/tunnels/executable-search.test.js b/packages/web/server/lib/tunnels/executable-search.test.js new file mode 100644 index 00000000..0eb8a589 --- /dev/null +++ b/packages/web/server/lib/tunnels/executable-search.test.js @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'bun:test'; + +import { + createExecutableSearchEnv, + findExecutableOnPath, + getExecutableSearchDirectories, + resolveExecutableLaunchTarget, +} from './executable-search.js'; + +describe('getExecutableSearchDirectories', () => { + it('adds the WindowsApps app-alias directory on Windows', () => { + const directories = getExecutableSearchDirectories({ + platform: 'win32', + env: { + PATH: 'C:\\Tools', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + }, + }); + + expect(directories).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps'); + }); + + it('reads Windows Path casing when PATH is not present', () => { + const directories = getExecutableSearchDirectories({ + platform: 'win32', + env: { + Path: 'C:\\Tools;C:\\MoreTools', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + }, + }); + + expect(directories[0]).toBe('C:\\Tools'); + expect(directories[1]).toBe('C:\\MoreTools'); + }); +}); + +describe('findExecutableOnPath', () => { + it('finds Windows Store app execution aliases even when PATH omits WindowsApps', () => { + const aliasPath = 'C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps\\ngrok.exe'; + const fsLike = { + statSync: (candidate) => { + if (candidate === aliasPath) { + return { isFile: () => true }; + } + throw new Error('not found'); + }, + accessSync: () => {}, + }; + + const resolved = findExecutableOnPath('ngrok', { + platform: 'win32', + env: { + PATH: 'C:\\Tools', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + PATHEXT: '.EXE;.CMD', + }, + fsLike, + }); + + expect(resolved).toBe(aliasPath); + }); +}); + +describe('resolveExecutableLaunchTarget', () => { + it('returns a Windows launch target with WindowsApps on PATH when stat lookup fails', () => { + const target = resolveExecutableLaunchTarget('ngrok', { + platform: 'win32', + env: { + PATH: 'C:\\Windows\\System32', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + }, + fsLike: { + statSync: () => { throw new Error('EACCES'); }, + accessSync: () => {}, + }, + }); + + expect(target?.command).toBe('ngrok'); + expect(target?.env.Path).toContain('C:\\Users\\Ada\\AppData\\Local\\Microsoft\\WindowsApps'); + }); +}); + +describe('createExecutableSearchEnv', () => { + it('keeps Windows PATH variants in sync', () => { + const env = createExecutableSearchEnv({ + platform: 'win32', + env: { + PATH: 'C:\\Windows\\System32', + LOCALAPPDATA: 'C:\\Users\\Ada\\AppData\\Local', + }, + }); + + expect(env.PATH).toBe(env.Path); + expect(env.path).toBe(env.Path); + }); +}); diff --git a/packages/web/server/lib/tunnels/index.js b/packages/web/server/lib/tunnels/index.js index 3d9f0e5f..77f621bf 100644 --- a/packages/web/server/lib/tunnels/index.js +++ b/packages/web/server/lib/tunnels/index.js @@ -5,6 +5,7 @@ import { normalizeTunnelStartRequest, validateTunnelStartRequest, } from './types.js'; +import { getTunnelDependencyInstallInfo } from './install-help.js'; export function createTunnelService({ registry, @@ -82,8 +83,9 @@ export function createTunnelService({ let publicUrl = provider.resolvePublicUrl(getController()); const activeMode = resolveActiveMode(); + const activeProvider = resolveActiveProvider(); - if (publicUrl && activeMode !== request.mode) { + if (publicUrl && (activeMode !== request.mode || activeProvider !== request.provider)) { stop(); publicUrl = null; } @@ -94,7 +96,7 @@ export function createTunnelService({ const missingDependencyMessage = typeof availability?.message === 'string' && availability.message.trim().length > 0 ? availability.message : (request.provider === TUNNEL_PROVIDER_CLOUDFLARE - ? 'cloudflared is not installed. Install it with: brew install cloudflared' + ? getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE).message : `Required dependency for provider '${request.provider}' is missing`); throw new TunnelServiceError('missing_dependency', missingDependencyMessage); } @@ -102,11 +104,22 @@ export function createTunnelService({ const activePort = Number.isFinite(getActivePort?.()) ? getActivePort() : null; const originUrl = activePort !== null ? `http://127.0.0.1:${activePort}` : undefined; - const controller = await provider.start(request, { - activePort, - originUrl, - ...options, - }); + let controller; + try { + controller = await provider.start(request, { + activePort, + originUrl, + ...options, + }); + } catch (error) { + if (error instanceof TunnelServiceError) { + throw error; + } + const message = error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'Failed to start tunnel'; + throw new TunnelServiceError('startup_failed', message); + } controller.provider = request.provider; setController(controller); diff --git a/packages/web/server/lib/tunnels/index.test.js b/packages/web/server/lib/tunnels/index.test.js new file mode 100644 index 00000000..4adc7694 --- /dev/null +++ b/packages/web/server/lib/tunnels/index.test.js @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'bun:test'; + +import { createTunnelService } from './index.js'; +import { + TUNNEL_INTENT_EPHEMERAL_PUBLIC, + TUNNEL_MODE_QUICK, + TUNNEL_PROVIDER_CLOUDFLARE, + TUNNEL_PROVIDER_NGROK, +} from './types.js'; + +const createProvider = ({ provider, start, stop, resolvePublicUrl }) => ({ + id: provider, + capabilities: { + provider, + modes: [{ key: TUNNEL_MODE_QUICK, intent: TUNNEL_INTENT_EPHEMERAL_PUBLIC }], + }, + checkAvailability: async () => ({ available: true }), + start, + stop, + resolvePublicUrl: resolvePublicUrl || ((controller) => controller?.getPublicUrl?.() ?? null), +}); + +const createRegistry = (providers) => ({ + get: (providerId) => providers[providerId] ?? null, +}); + +describe('createTunnelService', () => { + it('returns provider startup errors to route callers', async () => { + let controller = null; + const provider = createProvider({ + provider: TUNNEL_PROVIDER_NGROK, + start: async () => { + throw new Error('ngrok authtoken is not configured'); + }, + }); + const service = createTunnelService({ + registry: createRegistry({ [TUNNEL_PROVIDER_NGROK]: provider }), + getController: () => controller, + setController: (next) => { controller = next; }, + getActivePort: () => 3000, + }); + + try { + await service.start({ provider: TUNNEL_PROVIDER_NGROK, mode: TUNNEL_MODE_QUICK }); + throw new Error('Expected service.start to fail'); + } catch (error) { + expect(error.name).toBe('TunnelServiceError'); + expect(error.code).toBe('startup_failed'); + expect(error.message).toBe('ngrok authtoken is not configured'); + } + }); + + it('replaces an active quick tunnel when the provider changes', async () => { + let stopped = false; + let ngrokStarted = false; + let controller = { + provider: TUNNEL_PROVIDER_CLOUDFLARE, + mode: TUNNEL_MODE_QUICK, + stop: () => { stopped = true; }, + getPublicUrl: () => 'https://cloudflare.example', + }; + const cloudflareProvider = createProvider({ + provider: TUNNEL_PROVIDER_CLOUDFLARE, + start: async () => controller, + }); + const ngrokProvider = createProvider({ + provider: TUNNEL_PROVIDER_NGROK, + start: async () => { + ngrokStarted = true; + return { + mode: TUNNEL_MODE_QUICK, + getPublicUrl: () => 'https://demo.ngrok-free.app', + }; + }, + }); + const service = createTunnelService({ + registry: createRegistry({ + [TUNNEL_PROVIDER_CLOUDFLARE]: cloudflareProvider, + [TUNNEL_PROVIDER_NGROK]: ngrokProvider, + }), + getController: () => controller, + setController: (next) => { controller = next; }, + getActivePort: () => 3000, + }); + + const result = await service.start({ provider: TUNNEL_PROVIDER_NGROK, mode: TUNNEL_MODE_QUICK }); + + expect(stopped).toBe(true); + expect(ngrokStarted).toBe(true); + expect(result.provider).toBe(TUNNEL_PROVIDER_NGROK); + expect(result.publicUrl).toBe('https://demo.ngrok-free.app'); + }); +}); diff --git a/packages/web/server/lib/tunnels/install-help.js b/packages/web/server/lib/tunnels/install-help.js new file mode 100644 index 00000000..e31aa696 --- /dev/null +++ b/packages/web/server/lib/tunnels/install-help.js @@ -0,0 +1,56 @@ +import { + TUNNEL_PROVIDER_CLOUDFLARE, + TUNNEL_PROVIDER_NGROK, +} from './types.js'; + +const PROVIDER_INSTALL_INFO = { + [TUNNEL_PROVIDER_CLOUDFLARE]: { + dependency: 'cloudflared', + installUrl: 'https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/', + commands: { + darwin: 'brew install cloudflared', + win32: 'winget install --id Cloudflare.cloudflared', + linux: 'Download cloudflared from https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflared/downloads/', + }, + }, + [TUNNEL_PROVIDER_NGROK]: { + dependency: 'ngrok', + installUrl: 'https://ngrok.com/download', + commands: { + darwin: 'brew install ngrok', + win32: 'winget install ngrok -s msstore', + linux: 'Download ngrok from https://ngrok.com/download', + }, + }, +}; + +const normalizeInstallPlatform = (platform) => { + if (platform === 'darwin' || platform === 'win32' || platform === 'linux') { + return platform; + } + return 'linux'; +}; + +const createMissingDependencyMessage = ({ dependency, installCommand }) => { + if (installCommand.startsWith('Download ')) { + return `${dependency} is not installed. ${installCommand}`; + } + return `${dependency} is not installed. Install it with: ${installCommand}`; +}; + +export function getTunnelDependencyInstallInfo(provider, platform = process.platform) { + const providerInfo = PROVIDER_INSTALL_INFO[provider] || PROVIDER_INSTALL_INFO[TUNNEL_PROVIDER_CLOUDFLARE]; + const normalizedPlatform = normalizeInstallPlatform(platform); + const installCommand = providerInfo.commands[normalizedPlatform] || providerInfo.commands.linux; + + return { + dependency: providerInfo.dependency, + installCommand, + installUrl: providerInfo.installUrl, + platform: normalizedPlatform, + message: createMissingDependencyMessage({ + dependency: providerInfo.dependency, + installCommand, + }), + }; +} diff --git a/packages/web/server/lib/tunnels/install-help.test.js b/packages/web/server/lib/tunnels/install-help.test.js new file mode 100644 index 00000000..0bea9142 --- /dev/null +++ b/packages/web/server/lib/tunnels/install-help.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'bun:test'; + +import { getTunnelDependencyInstallInfo } from './install-help.js'; +import { + TUNNEL_PROVIDER_CLOUDFLARE, + TUNNEL_PROVIDER_NGROK, +} from './types.js'; + +describe('getTunnelDependencyInstallInfo', () => { + it('returns Windows cloudflared winget guidance', () => { + const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, 'win32'); + + expect(info.dependency).toBe('cloudflared'); + expect(info.installCommand).toBe('winget install --id Cloudflare.cloudflared'); + expect(info.message).toContain('Cloudflare.cloudflared'); + }); + + it('returns Windows ngrok winget guidance', () => { + const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK, 'win32'); + + expect(info.dependency).toBe('ngrok'); + expect(info.installCommand).toBe('winget install ngrok -s msstore'); + expect(info.message).toContain('ngrok -s msstore'); + }); + + it('keeps macOS Homebrew guidance', () => { + const info = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE, 'darwin'); + + expect(info.installCommand).toBe('brew install cloudflared'); + }); +}); diff --git a/packages/web/server/lib/tunnels/providers/cloudflare.js b/packages/web/server/lib/tunnels/providers/cloudflare.js index f0f5d412..176bd5f1 100644 --- a/packages/web/server/lib/tunnels/providers/cloudflare.js +++ b/packages/web/server/lib/tunnels/providers/cloudflare.js @@ -17,6 +17,7 @@ import { TUNNEL_PROVIDER_CLOUDFLARE, TunnelServiceError, } from '../types.js'; +import { getTunnelDependencyInstallInfo } from '../install-help.js'; export const cloudflareTunnelProviderCapabilities = { provider: TUNNEL_PROVIDER_CLOUDFLARE, @@ -97,16 +98,21 @@ export function createCloudflareTunnelProvider() { checkAvailability: async () => { const result = await checkCloudflaredAvailable(); if (result.available) { - return result; + return { + ...result, + ...getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE), + }; } + const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE); return { ...result, - message: 'cloudflared is not installed. Install it with: brew install cloudflared', + ...installInfo, }; }, diagnose: async (request = {}) => { const dependency = await checkCloudflaredAvailable(); const network = await checkCloudflareApiReachability(); + const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE); const providerChecks = [ { @@ -115,7 +121,7 @@ export function createCloudflareTunnelProvider() { status: dependency.available ? 'pass' : 'fail', detail: dependency.available ? (dependency.version || dependency.path || 'cloudflared available') - : 'cloudflared is not installed. Install it with: brew install cloudflared', + : installInfo.message, }, { id: 'network', diff --git a/packages/web/server/lib/tunnels/providers/ngrok.js b/packages/web/server/lib/tunnels/providers/ngrok.js index 6d4879ad..76d94399 100644 --- a/packages/web/server/lib/tunnels/providers/ngrok.js +++ b/packages/web/server/lib/tunnels/providers/ngrok.js @@ -11,6 +11,7 @@ import { TUNNEL_PROVIDER_NGROK, TunnelServiceError, } from '../types.js'; +import { getTunnelDependencyInstallInfo } from '../install-help.js'; export const ngrokTunnelProviderCapabilities = { provider: TUNNEL_PROVIDER_NGROK, @@ -37,17 +38,22 @@ export function createNgrokTunnelProvider() { checkAvailability: async () => { const result = await checkNgrokAvailable(); if (result.available) { - return result; + return { + ...result, + ...getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK), + }; } + const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK); return { ...result, - message: 'ngrok is not installed. Install it with: brew install ngrok', + ...installInfo, }; }, diagnose: async () => { const dependency = await checkNgrokAvailable(); const authtoken = await checkNgrokAuthtokenConfigured(dependency.path); const network = await checkNgrokApiReachability(); + const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK); const startupReady = dependency.available && authtoken.configured && network.reachable; const providerChecks = [ { @@ -56,7 +62,7 @@ export function createNgrokTunnelProvider() { status: dependency.available ? 'pass' : 'fail', detail: dependency.available ? (dependency.version || dependency.path || 'ngrok available') - : 'ngrok is not installed. Install it with: brew install ngrok', + : installInfo.message, }, { id: 'authtoken', diff --git a/packages/web/server/lib/tunnels/routes.js b/packages/web/server/lib/tunnels/routes.js index fa97cece..4d03edf2 100644 --- a/packages/web/server/lib/tunnels/routes.js +++ b/packages/web/server/lib/tunnels/routes.js @@ -220,10 +220,15 @@ export const createTunnelRoutesRuntime = (dependencies) => { available: result.available, provider: requestedProvider, version: result.version || null, + dependency: result.dependency || null, + installCommand: result.installCommand || null, + installUrl: result.installUrl || null, + platform: result.platform || process.platform, + message: result.message || null, }); } catch (error) { console.warn('Tunnel dependency check failed:', error); - res.json({ available: false, provider: null, version: null }); + res.json({ available: false, provider: null, version: null, dependency: null, installCommand: null, installUrl: null, platform: process.platform, message: null }); } }); diff --git a/packages/web/server/lib/tunnels/types.js b/packages/web/server/lib/tunnels/types.js index 71cda891..585ce779 100644 --- a/packages/web/server/lib/tunnels/types.js +++ b/packages/web/server/lib/tunnels/types.js @@ -38,6 +38,45 @@ const SUPPORTED_TUNNEL_PROVIDERS = new Set([ TUNNEL_PROVIDER_NGROK, ]); +const getPathApiForPlatform = (platform) => (platform === 'win32' ? path.win32 : path); + +export function isPathWithinDirectory(candidatePath, directoryPath, platform = process.platform) { + if (typeof candidatePath !== 'string' || typeof directoryPath !== 'string') { + return false; + } + + const pathApi = getPathApiForPlatform(platform); + const resolvedCandidate = pathApi.resolve(candidatePath); + const resolvedDirectory = pathApi.resolve(directoryPath); + const comparableCandidate = platform === 'win32' ? resolvedCandidate.toLowerCase() : resolvedCandidate; + const comparableDirectory = platform === 'win32' ? resolvedDirectory.toLowerCase() : resolvedDirectory; + const directoryPrefix = comparableDirectory.endsWith(pathApi.sep) + ? comparableDirectory + : `${comparableDirectory}${pathApi.sep}`; + + return comparableCandidate === comparableDirectory || comparableCandidate.startsWith(directoryPrefix); +} + +export function resolveTunnelConfigPath(value, home = os.homedir(), platform = process.platform) { + const pathApi = getPathApiForPlatform(platform); + let resolved; + if (value === '~') { + resolved = home; + } else if (value.startsWith('~/') || value.startsWith('~\\')) { + resolved = pathApi.join(home, value.slice(2)); + } else { + resolved = pathApi.resolve(value); + } + + if (!isPathWithinDirectory(resolved, home, platform)) { + throw new TunnelServiceError( + 'validation_error', + `Config path must be within the home directory (${home}). Got: ${resolved}` + ); + } + return resolved; +} + export function normalizeTunnelProvider(value) { if (typeof value !== 'string') { return TUNNEL_PROVIDER_CLOUDFLARE; @@ -111,22 +150,7 @@ export function normalizeOptionalPath(value) { if (!trimmed) { return null; } - let resolved; - if (trimmed === '~') { - resolved = os.homedir(); - } else if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { - resolved = path.join(os.homedir(), trimmed.slice(2)); - } else { - resolved = path.resolve(trimmed); - } - const home = os.homedir(); - if (resolved !== home && !resolved.startsWith(home + path.sep)) { - throw new TunnelServiceError( - 'validation_error', - `Config path must be within the home directory (${home}). Got: ${resolved}` - ); - } - return resolved; + return resolveTunnelConfigPath(trimmed); } export function isSupportedTunnelMode(mode) { diff --git a/packages/web/server/lib/tunnels/types.test.js b/packages/web/server/lib/tunnels/types.test.js new file mode 100644 index 00000000..ee15dd32 --- /dev/null +++ b/packages/web/server/lib/tunnels/types.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'bun:test'; + +import { + isPathWithinDirectory, + resolveTunnelConfigPath, +} from './types.js'; + +describe('tunnel config path normalization', () => { + it('allows Windows home paths with different drive casing', () => { + expect(isPathWithinDirectory( + 'c:\\Users\\Bohdan\\.cloudflared\\config.yml', + 'C:\\Users\\Bohdan', + 'win32' + )).toBe(true); + }); + + it('does not allow Windows sibling home directories', () => { + expect(isPathWithinDirectory( + 'C:\\Users\\Bohdan2\\.cloudflared\\config.yml', + 'C:\\Users\\Bohdan', + 'win32' + )).toBe(false); + }); + + it('resolves Windows tilde paths inside the provided home directory', () => { + expect(resolveTunnelConfigPath('~\\.cloudflared\\config.yml', 'C:\\Users\\Bohdan', 'win32')) + .toBe('C:\\Users\\Bohdan\\.cloudflared\\config.yml'); + }); + + it('rejects Windows paths outside the provided home directory', () => { + expect(() => resolveTunnelConfigPath('C:\\Temp\\config.yml', 'C:\\Users\\Bohdan', 'win32')) + .toThrow(/Config path must be within the home directory/); + }); +});