fix: improve Windows tunnel support
This commit is contained in:
@@ -149,6 +149,84 @@ interface TunnelProviderCapability {
|
|||||||
modes?: TunnelProviderModeDescriptor[];
|
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 => {
|
const getProviderLabel = (provider: string): string => {
|
||||||
if (provider === 'cloudflare') {
|
if (provider === 'cloudflare') {
|
||||||
return 'Cloudflare';
|
return 'Cloudflare';
|
||||||
@@ -281,6 +359,7 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
const [isSavingTtl, setIsSavingTtl] = React.useState(false);
|
const [isSavingTtl, setIsSavingTtl] = React.useState(false);
|
||||||
const [isSavingMode, setIsSavingMode] = React.useState(false);
|
const [isSavingMode, setIsSavingMode] = React.useState(false);
|
||||||
const [tunnelProvider, setTunnelProvider] = React.useState<string>('cloudflare');
|
const [tunnelProvider, setTunnelProvider] = React.useState<string>('cloudflare');
|
||||||
|
const [dependencyInstallInfo, setDependencyInstallInfo] = React.useState<TunnelDependencyInstallInfo>(() => createTunnelDependencyInstallInfo('cloudflare'));
|
||||||
const [providerCapabilities, setProviderCapabilities] = React.useState<TunnelProviderCapability[]>([]);
|
const [providerCapabilities, setProviderCapabilities] = React.useState<TunnelProviderCapability[]>([]);
|
||||||
const [tunnelMode, setTunnelMode] = React.useState<TunnelMode>('quick');
|
const [tunnelMode, setTunnelMode] = React.useState<TunnelMode>('quick');
|
||||||
const [managedLocalConfigPath, setManagedLocalConfigPath] = React.useState<string | null>(null);
|
const [managedLocalConfigPath, setManagedLocalConfigPath] = React.useState<string | null>(null);
|
||||||
@@ -400,13 +479,47 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
() => tunnelModeOptions.some((option) => option.value === 'managed-remote' || option.value === 'managed-local'),
|
() => tunnelModeOptions.some((option) => option.value === 'managed-remote' || option.value === 'managed-local'),
|
||||||
[tunnelModeOptions],
|
[tunnelModeOptions],
|
||||||
);
|
);
|
||||||
const installCommand = tunnelProvider === 'ngrok'
|
const displayedDependencyInstallInfo = React.useMemo(() => {
|
||||||
? 'brew install ngrok'
|
if (dependencyInstallInfo.provider === tunnelProvider) {
|
||||||
: 'brew install cloudflared';
|
return dependencyInstallInfo;
|
||||||
|
}
|
||||||
|
return createTunnelDependencyInstallInfo(tunnelProvider);
|
||||||
|
}, [dependencyInstallInfo, tunnelProvider]);
|
||||||
const openExternal = React.useCallback(async (url: string) => {
|
const openExternal = React.useCallback(async (url: string) => {
|
||||||
await openExternalUrl(url);
|
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<boolean | null> => {
|
||||||
|
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) => {
|
const checkAvailabilityAndStatus = React.useCallback(async (signal: AbortSignal) => {
|
||||||
try {
|
try {
|
||||||
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
|
const [checkRes, statusRes, settingsRes, providersRes] = await Promise.all([
|
||||||
@@ -416,7 +529,7 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
runtimeFetch('/api/openchamber/tunnel/providers', { signal }),
|
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 statusData = (await statusRes.json()) as TunnelStatusResponse;
|
||||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
const providersData = providersRes.ok ? await providersRes.json() : {};
|
const providersData = providersRes.ok ? await providersRes.json() : {};
|
||||||
@@ -440,6 +553,7 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string'
|
const loadedManagedLocalConfigPath = typeof settingsData?.managedLocalTunnelConfigPath === 'string'
|
||||||
? settingsData.managedLocalTunnelConfigPath.trim() || null
|
? settingsData.managedLocalTunnelConfigPath.trim() || null
|
||||||
: null;
|
: null;
|
||||||
|
const dependencyAvailable = applyDependencyCheck(checkData, loadedProvider);
|
||||||
|
|
||||||
const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets);
|
const loadedPresetsFromStatus = sanitizePresets(statusData?.managedRemoteTunnelPresets);
|
||||||
const loadedHostname = typeof statusData.managedRemoteTunnelHostname === 'string'
|
const loadedHostname = typeof statusData.managedRemoteTunnelHostname === 'string'
|
||||||
@@ -484,14 +598,14 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(checkData.available ? 'idle' : 'not-available');
|
setState(dependencyAvailable ? 'idle' : 'not-available');
|
||||||
} catch {
|
} catch {
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
setState('error');
|
setState('error');
|
||||||
setErrorMessage(t('settings.openchamber.tunnel.toast.checkAvailabilityFailed'));
|
setErrorMessage(t('settings.openchamber.tunnel.toast.checkAvailabilityFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [t]);
|
}, [applyDependencyCheck, t]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -738,7 +852,8 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
? tunnelMode
|
? tunnelMode
|
||||||
: toUiTunnelMode(capability?.modes?.[0]?.key);
|
: toUiTunnelMode(capability?.modes?.[0]?.key);
|
||||||
await saveTunnelSettings({ tunnelProvider: provider, tunnelMode: defaultMode });
|
await saveTunnelSettings({ tunnelProvider: provider, tunnelMode: defaultMode });
|
||||||
}, [providerCapabilities, saveTunnelSettings, tunnelMode]);
|
void refreshTunnelDependencyCheck(provider);
|
||||||
|
}, [providerCapabilities, refreshTunnelDependencyCheck, saveTunnelSettings, tunnelMode]);
|
||||||
|
|
||||||
const handleBrowseManagedLocalConfig = React.useCallback(async () => {
|
const handleBrowseManagedLocalConfig = React.useCallback(async () => {
|
||||||
const result = await requestFileAccess({
|
const result = await requestFileAccess({
|
||||||
@@ -1192,10 +1307,12 @@ export const TunnelSettings: React.FC = () => {
|
|||||||
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
|
<div className="flex items-start gap-2 rounded-lg border border-[var(--status-warning)]/30 bg-[var(--status-warning)]/5 p-3">
|
||||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="typography-meta font-medium text-foreground">{getProviderLabel(tunnelProvider)} tunnel dependency was not found.</p>
|
<p className="typography-meta font-medium text-foreground">
|
||||||
|
{t('settings.openchamber.tunnel.notAvailable.dependencyNotFound', { dependency: displayedDependencyInstallInfo.dependency })}
|
||||||
|
</p>
|
||||||
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
|
<p className="typography-meta text-muted-foreground/70">{t('settings.openchamber.tunnel.notAvailable.installHint')}</p>
|
||||||
<code className="typography-code block rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
<code className="typography-code block rounded bg-muted/50 px-2 py-1 text-xs text-foreground">
|
||||||
{installCommand}
|
{displayedDependencyInstallInfo.installCommand}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.tunnel.section.redeemedAccessLinks': 'Redeemed access links',
|
'settings.openchamber.tunnel.section.redeemedAccessLinks': 'Redeemed access links',
|
||||||
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': 'Saved managed remote tunnels',
|
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': 'Saved managed remote tunnels',
|
||||||
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared not found',
|
'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.notAvailable.installHint': 'Install it to enable remote tunnel access:',
|
||||||
'settings.openchamber.tunnel.field.provider': 'Provider',
|
'settings.openchamber.tunnel.field.provider': 'Provider',
|
||||||
'settings.openchamber.tunnel.field.providerPlaceholder': 'Select provider',
|
'settings.openchamber.tunnel.field.providerPlaceholder': 'Select provider',
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Enlaces de acceso canjeados",
|
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Enlaces de acceso canjeados",
|
||||||
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneles remotos gestionados guardados",
|
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneles remotos gestionados guardados",
|
||||||
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared no encontrado",
|
"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.notAvailable.installHint": "Instálalo para habilitar el acceso al túnel remoto:",
|
||||||
"settings.openchamber.tunnel.field.provider": "Proveedor",
|
"settings.openchamber.tunnel.field.provider": "Proveedor",
|
||||||
"settings.openchamber.tunnel.field.providerPlaceholder": "Seleccionar proveedor",
|
"settings.openchamber.tunnel.field.providerPlaceholder": "Seleccionar proveedor",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.tunnel.section.redeemedAccessLinks': '사용된 접속 링크',
|
'settings.openchamber.tunnel.section.redeemedAccessLinks': '사용된 접속 링크',
|
||||||
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '저장된 Managed Remote 터널',
|
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '저장된 Managed Remote 터널',
|
||||||
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared를 찾을 수 없음',
|
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared를 찾을 수 없음',
|
||||||
|
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency}를 찾을 수 없습니다.',
|
||||||
'settings.openchamber.tunnel.notAvailable.installHint': '원격 터널 접속을 사용하려면 설치하세요:',
|
'settings.openchamber.tunnel.notAvailable.installHint': '원격 터널 접속을 사용하려면 설치하세요:',
|
||||||
'settings.openchamber.tunnel.field.provider': '프로바이더',
|
'settings.openchamber.tunnel.field.provider': '프로바이더',
|
||||||
'settings.openchamber.tunnel.field.providerPlaceholder': '프로바이더 선택',
|
'settings.openchamber.tunnel.field.providerPlaceholder': '프로바이더 선택',
|
||||||
|
|||||||
@@ -814,6 +814,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.tunnel.field.tunnelSessionTtl': 'TTL sesji tunelu',
|
'settings.openchamber.tunnel.field.tunnelSessionTtl': 'TTL sesji tunelu',
|
||||||
'settings.openchamber.tunnel.field.tunnelType': 'Typ tunelu',
|
'settings.openchamber.tunnel.field.tunnelType': 'Typ tunelu',
|
||||||
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'Nie znaleziono cloudflared',
|
'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.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.cloudflareConnectorTarget': 'Docelowy adres konektora Cloudflare:',
|
||||||
'settings.openchamber.tunnel.note.cloudflareConnectorTargetUse': 'Dla docelowego adresu konektora Cloudflare użyj',
|
'settings.openchamber.tunnel.note.cloudflareConnectorTargetUse': 'Dla docelowego adresu konektora Cloudflare użyj',
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Links de acesso resgatados",
|
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Links de acesso resgatados",
|
||||||
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneis remotos gerenciados salvos",
|
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Túneis remotos gerenciados salvos",
|
||||||
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared não encontrado",
|
"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.notAvailable.installHint": "Instale para habilitar o acesso ao túnel remoto:",
|
||||||
"settings.openchamber.tunnel.field.provider": "Provedor",
|
"settings.openchamber.tunnel.field.provider": "Provedor",
|
||||||
"settings.openchamber.tunnel.field.providerPlaceholder": "Selecionar provedor",
|
"settings.openchamber.tunnel.field.providerPlaceholder": "Selecionar provedor",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Використані посилання доступу",
|
"settings.openchamber.tunnel.section.redeemedAccessLinks": "Використані посилання доступу",
|
||||||
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Збережені керовані віддалені тунелі",
|
"settings.openchamber.tunnel.section.savedManagedRemoteTunnels": "Збережені керовані віддалені тунелі",
|
||||||
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared не знайдено",
|
"settings.openchamber.tunnel.notAvailable.cloudflaredNotFound": "cloudflared не знайдено",
|
||||||
|
"settings.openchamber.tunnel.notAvailable.dependencyNotFound": "{dependency} не знайдено.",
|
||||||
"settings.openchamber.tunnel.notAvailable.installHint": "Встановіть його, щоб увімкнути віддалений доступ через тунель:",
|
"settings.openchamber.tunnel.notAvailable.installHint": "Встановіть його, щоб увімкнути віддалений доступ через тунель:",
|
||||||
"settings.openchamber.tunnel.field.provider": "Провайдер",
|
"settings.openchamber.tunnel.field.provider": "Провайдер",
|
||||||
"settings.openchamber.tunnel.field.providerPlaceholder": "Виберіть провайдера",
|
"settings.openchamber.tunnel.field.providerPlaceholder": "Виберіть провайдера",
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兑换访问链接',
|
'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兑换访问链接',
|
||||||
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已保存的 Managed Remote 隧道',
|
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已保存的 Managed Remote 隧道',
|
||||||
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '未找到 cloudflared',
|
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '未找到 cloudflared',
|
||||||
|
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '未找到 {dependency}。',
|
||||||
'settings.openchamber.tunnel.notAvailable.installHint': '安装它以启用远程隧道访问:',
|
'settings.openchamber.tunnel.notAvailable.installHint': '安装它以启用远程隧道访问:',
|
||||||
'settings.openchamber.tunnel.field.provider': '提供商',
|
'settings.openchamber.tunnel.field.provider': '提供商',
|
||||||
'settings.openchamber.tunnel.field.providerPlaceholder': '选择提供商',
|
'settings.openchamber.tunnel.field.providerPlaceholder': '选择提供商',
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兌換存取連結',
|
'settings.openchamber.tunnel.section.redeemedAccessLinks': '已兌換存取連結',
|
||||||
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已儲存的 managed remote tunnel',
|
'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': '已儲存的 managed remote tunnel',
|
||||||
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '找不到 cloudflared',
|
'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': '找不到 cloudflared',
|
||||||
|
'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '找不到 {dependency}。',
|
||||||
'settings.openchamber.tunnel.notAvailable.installHint': '安裝它以啟用 remote tunnel 存取:',
|
'settings.openchamber.tunnel.notAvailable.installHint': '安裝它以啟用 remote tunnel 存取:',
|
||||||
'settings.openchamber.tunnel.field.provider': '供應商',
|
'settings.openchamber.tunnel.field.provider': '供應商',
|
||||||
'settings.openchamber.tunnel.field.providerPlaceholder': '選擇供應商',
|
'settings.openchamber.tunnel.field.providerPlaceholder': '選擇供應商',
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import os from 'os';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import yaml from 'yaml';
|
import yaml from 'yaml';
|
||||||
|
import {
|
||||||
|
createExecutableSearchEnv,
|
||||||
|
resolveExecutableLaunchTarget,
|
||||||
|
} from './tunnels/executable-search.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
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_REMOTE = 'managed-remote';
|
||||||
const TUNNEL_MODE_MANAGED_LOCAL = 'managed-local';
|
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() {
|
export async function checkCloudflaredAvailable() {
|
||||||
const cfPath = await searchPathFor('cloudflared');
|
const target = resolveExecutableLaunchTarget('cloudflared');
|
||||||
if (cfPath) {
|
if (target) {
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(cfPath, ['--version'], {
|
const result = spawnSync(target.command, ['--version'], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
env: target.env,
|
||||||
});
|
});
|
||||||
if (result.status === 0) {
|
if (result.status === 0) {
|
||||||
return { available: true, path: cfPath, version: result.stdout.trim() };
|
return { available: true, path: target.command, version: result.stdout.trim() };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore
|
// Ignore
|
||||||
@@ -102,7 +72,7 @@ const spawnCloudflared = (args, envOverrides = {}, resolvedBinaryPath = 'cloudfl
|
|||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...createExecutableSearchEnv(),
|
||||||
CF_TELEMETRY_DISABLE: '1',
|
CF_TELEMETRY_DISABLE: '1',
|
||||||
...envOverrides,
|
...envOverrides,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,59 +1,29 @@
|
|||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn, spawnSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import {
|
||||||
import path from 'path';
|
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 DEFAULT_STARTUP_TIMEOUT_MS = 30000;
|
||||||
const NGROK_API_URL = 'http://127.0.0.1:4040/api/tunnels';
|
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 <your-ngrok-token>';
|
const NGROK_AUTHTOKEN_HELP = 'Run: ngrok config add-authtoken <your-ngrok-token>';
|
||||||
|
const getNgrokInstallInfo = () => getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK);
|
||||||
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() {
|
export async function checkNgrokAvailable() {
|
||||||
const ngrokPath = await searchPathFor('ngrok');
|
const target = resolveExecutableLaunchTarget('ngrok');
|
||||||
if (ngrokPath) {
|
if (target) {
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(ngrokPath, ['version'], {
|
const result = spawnSync(target.command, ['version'], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
env: target.env,
|
||||||
});
|
});
|
||||||
if (result.status === 0) {
|
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 {
|
} catch {
|
||||||
// Ignore and report unavailable below.
|
// Ignore and report unavailable below.
|
||||||
@@ -67,16 +37,19 @@ export async function checkNgrokAuthtokenConfigured(ngrokPath = null) {
|
|||||||
return { configured: true, detail: 'NGROK_AUTHTOKEN is set.' };
|
return { configured: true, detail: 'NGROK_AUTHTOKEN is set.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedPath = ngrokPath || await searchPathFor('ngrok');
|
const target = ngrokPath
|
||||||
if (!resolvedPath) {
|
? { command: ngrokPath, env: createExecutableSearchEnv() }
|
||||||
return { configured: false, detail: `ngrok is not installed. Install it with: ${NGROK_INSTALL_HELP}` };
|
: resolveExecutableLaunchTarget('ngrok');
|
||||||
|
if (!target) {
|
||||||
|
return { configured: false, detail: getNgrokInstallInfo().message };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = spawnSync(resolvedPath, ['config', 'check'], {
|
const result = spawnSync(target.command, ['config', 'check'], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
env: target.env,
|
||||||
});
|
});
|
||||||
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
||||||
if (result.status === 0) {
|
if (result.status === 0) {
|
||||||
@@ -118,10 +91,138 @@ export async function checkNgrokApiReachability({ fetchImpl = globalThis.fetch,
|
|||||||
const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, {
|
const spawnNgrok = (args, resolvedBinaryPath = 'ngrok') => spawn(resolvedBinaryPath, args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
env: process.env,
|
env: createExecutableSearchEnv(),
|
||||||
killSignal: 'SIGINT',
|
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 !== '<nil>') {
|
||||||
|
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 !== '<nil>' && !/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) {
|
async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
|
||||||
if (typeof fetchImpl !== 'function') {
|
if (typeof fetchImpl !== 'function') {
|
||||||
return null;
|
return null;
|
||||||
@@ -133,9 +234,9 @@ async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
|
|||||||
}
|
}
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const tunnels = Array.isArray(payload?.tunnels) ? payload.tunnels : [];
|
const tunnels = Array.isArray(payload?.tunnels) ? payload.tunnels : [];
|
||||||
const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && typeof entry?.public_url === 'string');
|
const httpsTunnel = tunnels.find((entry) => entry?.proto === 'https' && normalizeNgrokPublicUrl(entry?.public_url));
|
||||||
const fallbackTunnel = tunnels.find((entry) => typeof entry?.public_url === 'string');
|
const fallbackTunnel = tunnels.find((entry) => normalizeNgrokPublicUrl(entry?.public_url));
|
||||||
return httpsTunnel?.public_url || fallbackTunnel?.public_url || null;
|
return normalizeNgrokPublicUrl(httpsTunnel?.public_url) || normalizeNgrokPublicUrl(fallbackTunnel?.public_url);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -144,54 +245,87 @@ async function fetchNgrokPublicUrl(fetchImpl = globalThis.fetch) {
|
|||||||
export async function startNgrokQuickTunnel({ port }) {
|
export async function startNgrokQuickTunnel({ port }) {
|
||||||
const ngrokCheck = await checkNgrokAvailable();
|
const ngrokCheck = await checkNgrokAvailable();
|
||||||
if (!ngrokCheck.available) {
|
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);
|
const authtokenCheck = await checkNgrokAuthtokenConfigured(ngrokCheck.path);
|
||||||
if (!authtokenCheck.configured) {
|
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)) {
|
if (!Number.isFinite(port)) {
|
||||||
throw new Error('A local port is required to start an ngrok tunnel');
|
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;
|
let publicUrl = null;
|
||||||
|
const recentOutput = [];
|
||||||
|
|
||||||
child.stdout.on('data', () => {
|
const captureOutput = (chunk) => {
|
||||||
// Keep stream drained; ngrok exposes the URL via its local API.
|
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) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
process.stderr.write(chunk.toString('utf8'));
|
const text = captureOutput(chunk);
|
||||||
});
|
process.stderr.write(text);
|
||||||
|
|
||||||
child.on('error', (error) => {
|
|
||||||
console.error(`Ngrok error: ${error.message}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
let settled = false;
|
||||||
|
const finish = (handler, value) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
clearInterval(checkReady);
|
clearInterval(checkReady);
|
||||||
|
child.off('error', onError);
|
||||||
|
child.off('exit', onExit);
|
||||||
|
handler(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
try { child.kill('SIGINT'); } catch { /* ignore */ }
|
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);
|
}, DEFAULT_STARTUP_TIMEOUT_MS);
|
||||||
|
|
||||||
const checkReady = setInterval(async () => {
|
const checkReady = setInterval(async () => {
|
||||||
publicUrl = await fetchNgrokPublicUrl();
|
publicUrl = publicUrl || await fetchNgrokPublicUrl();
|
||||||
if (publicUrl) {
|
if (publicUrl) {
|
||||||
clearTimeout(timeout);
|
finish(resolve, null);
|
||||||
clearInterval(checkReady);
|
|
||||||
resolve(null);
|
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
child.once('exit', (code) => {
|
const onError = (error) => {
|
||||||
clearTimeout(timeout);
|
finish(reject, new Error(`Ngrok failed to start: ${error.message}`));
|
||||||
clearInterval(checkReady);
|
};
|
||||||
reject(new Error(`Ngrok exited while starting (code ${code ?? 'unknown'})`));
|
|
||||||
});
|
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 {
|
return {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,10 @@ This module contains tunnel provider orchestration for OpenChamber, including pr
|
|||||||
|
|
||||||
## Entrypoints and structure
|
## Entrypoints and structure
|
||||||
- `packages/web/server/lib/tunnels/index.js`: tunnel service orchestration.
|
- `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/registry.js`: provider registry.
|
||||||
- `packages/web/server/lib/tunnels/managed-config.js`: managed remote tunnel token/preset persistence runtime.
|
- `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/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/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/cloudflare.js`: Cloudflare tunnel provider implementation.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
normalizeTunnelStartRequest,
|
normalizeTunnelStartRequest,
|
||||||
validateTunnelStartRequest,
|
validateTunnelStartRequest,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { getTunnelDependencyInstallInfo } from './install-help.js';
|
||||||
|
|
||||||
export function createTunnelService({
|
export function createTunnelService({
|
||||||
registry,
|
registry,
|
||||||
@@ -82,8 +83,9 @@ export function createTunnelService({
|
|||||||
|
|
||||||
let publicUrl = provider.resolvePublicUrl(getController());
|
let publicUrl = provider.resolvePublicUrl(getController());
|
||||||
const activeMode = resolveActiveMode();
|
const activeMode = resolveActiveMode();
|
||||||
|
const activeProvider = resolveActiveProvider();
|
||||||
|
|
||||||
if (publicUrl && activeMode !== request.mode) {
|
if (publicUrl && (activeMode !== request.mode || activeProvider !== request.provider)) {
|
||||||
stop();
|
stop();
|
||||||
publicUrl = null;
|
publicUrl = null;
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ export function createTunnelService({
|
|||||||
const missingDependencyMessage = typeof availability?.message === 'string' && availability.message.trim().length > 0
|
const missingDependencyMessage = typeof availability?.message === 'string' && availability.message.trim().length > 0
|
||||||
? availability.message
|
? availability.message
|
||||||
: (request.provider === TUNNEL_PROVIDER_CLOUDFLARE
|
: (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`);
|
: `Required dependency for provider '${request.provider}' is missing`);
|
||||||
throw new TunnelServiceError('missing_dependency', missingDependencyMessage);
|
throw new TunnelServiceError('missing_dependency', missingDependencyMessage);
|
||||||
}
|
}
|
||||||
@@ -102,11 +104,22 @@ export function createTunnelService({
|
|||||||
const activePort = Number.isFinite(getActivePort?.()) ? getActivePort() : null;
|
const activePort = Number.isFinite(getActivePort?.()) ? getActivePort() : null;
|
||||||
const originUrl = activePort !== null ? `http://127.0.0.1:${activePort}` : undefined;
|
const originUrl = activePort !== null ? `http://127.0.0.1:${activePort}` : undefined;
|
||||||
|
|
||||||
const controller = await provider.start(request, {
|
let controller;
|
||||||
activePort,
|
try {
|
||||||
originUrl,
|
controller = await provider.start(request, {
|
||||||
...options,
|
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;
|
controller.provider = request.provider;
|
||||||
setController(controller);
|
setController(controller);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
TUNNEL_PROVIDER_CLOUDFLARE,
|
TUNNEL_PROVIDER_CLOUDFLARE,
|
||||||
TunnelServiceError,
|
TunnelServiceError,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { getTunnelDependencyInstallInfo } from '../install-help.js';
|
||||||
|
|
||||||
export const cloudflareTunnelProviderCapabilities = {
|
export const cloudflareTunnelProviderCapabilities = {
|
||||||
provider: TUNNEL_PROVIDER_CLOUDFLARE,
|
provider: TUNNEL_PROVIDER_CLOUDFLARE,
|
||||||
@@ -97,16 +98,21 @@ export function createCloudflareTunnelProvider() {
|
|||||||
checkAvailability: async () => {
|
checkAvailability: async () => {
|
||||||
const result = await checkCloudflaredAvailable();
|
const result = await checkCloudflaredAvailable();
|
||||||
if (result.available) {
|
if (result.available) {
|
||||||
return result;
|
return {
|
||||||
|
...result,
|
||||||
|
...getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
message: 'cloudflared is not installed. Install it with: brew install cloudflared',
|
...installInfo,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
diagnose: async (request = {}) => {
|
diagnose: async (request = {}) => {
|
||||||
const dependency = await checkCloudflaredAvailable();
|
const dependency = await checkCloudflaredAvailable();
|
||||||
const network = await checkCloudflareApiReachability();
|
const network = await checkCloudflareApiReachability();
|
||||||
|
const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_CLOUDFLARE);
|
||||||
|
|
||||||
const providerChecks = [
|
const providerChecks = [
|
||||||
{
|
{
|
||||||
@@ -115,7 +121,7 @@ export function createCloudflareTunnelProvider() {
|
|||||||
status: dependency.available ? 'pass' : 'fail',
|
status: dependency.available ? 'pass' : 'fail',
|
||||||
detail: dependency.available
|
detail: dependency.available
|
||||||
? (dependency.version || dependency.path || 'cloudflared available')
|
? (dependency.version || dependency.path || 'cloudflared available')
|
||||||
: 'cloudflared is not installed. Install it with: brew install cloudflared',
|
: installInfo.message,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'network',
|
id: 'network',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TUNNEL_PROVIDER_NGROK,
|
TUNNEL_PROVIDER_NGROK,
|
||||||
TunnelServiceError,
|
TunnelServiceError,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
import { getTunnelDependencyInstallInfo } from '../install-help.js';
|
||||||
|
|
||||||
export const ngrokTunnelProviderCapabilities = {
|
export const ngrokTunnelProviderCapabilities = {
|
||||||
provider: TUNNEL_PROVIDER_NGROK,
|
provider: TUNNEL_PROVIDER_NGROK,
|
||||||
@@ -37,17 +38,22 @@ export function createNgrokTunnelProvider() {
|
|||||||
checkAvailability: async () => {
|
checkAvailability: async () => {
|
||||||
const result = await checkNgrokAvailable();
|
const result = await checkNgrokAvailable();
|
||||||
if (result.available) {
|
if (result.available) {
|
||||||
return result;
|
return {
|
||||||
|
...result,
|
||||||
|
...getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
message: 'ngrok is not installed. Install it with: brew install ngrok',
|
...installInfo,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
diagnose: async () => {
|
diagnose: async () => {
|
||||||
const dependency = await checkNgrokAvailable();
|
const dependency = await checkNgrokAvailable();
|
||||||
const authtoken = await checkNgrokAuthtokenConfigured(dependency.path);
|
const authtoken = await checkNgrokAuthtokenConfigured(dependency.path);
|
||||||
const network = await checkNgrokApiReachability();
|
const network = await checkNgrokApiReachability();
|
||||||
|
const installInfo = getTunnelDependencyInstallInfo(TUNNEL_PROVIDER_NGROK);
|
||||||
const startupReady = dependency.available && authtoken.configured && network.reachable;
|
const startupReady = dependency.available && authtoken.configured && network.reachable;
|
||||||
const providerChecks = [
|
const providerChecks = [
|
||||||
{
|
{
|
||||||
@@ -56,7 +62,7 @@ export function createNgrokTunnelProvider() {
|
|||||||
status: dependency.available ? 'pass' : 'fail',
|
status: dependency.available ? 'pass' : 'fail',
|
||||||
detail: dependency.available
|
detail: dependency.available
|
||||||
? (dependency.version || dependency.path || 'ngrok available')
|
? (dependency.version || dependency.path || 'ngrok available')
|
||||||
: 'ngrok is not installed. Install it with: brew install ngrok',
|
: installInfo.message,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'authtoken',
|
id: 'authtoken',
|
||||||
|
|||||||
@@ -220,10 +220,15 @@ export const createTunnelRoutesRuntime = (dependencies) => {
|
|||||||
available: result.available,
|
available: result.available,
|
||||||
provider: requestedProvider,
|
provider: requestedProvider,
|
||||||
version: result.version || null,
|
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) {
|
} catch (error) {
|
||||||
console.warn('Tunnel dependency check failed:', 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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,45 @@ const SUPPORTED_TUNNEL_PROVIDERS = new Set([
|
|||||||
TUNNEL_PROVIDER_NGROK,
|
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) {
|
export function normalizeTunnelProvider(value) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return TUNNEL_PROVIDER_CLOUDFLARE;
|
return TUNNEL_PROVIDER_CLOUDFLARE;
|
||||||
@@ -111,22 +150,7 @@ export function normalizeOptionalPath(value) {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
let resolved;
|
return resolveTunnelConfigPath(trimmed);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSupportedTunnelMode(mode) {
|
export function isSupportedTunnelMode(mode) {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user