fix: improve Windows tunnel support

This commit is contained in:
Bohdan Triapitsyn
2026-06-05 15:01:22 +03:00
parent e823497cf1
commit 16fdff530b
24 changed files with 936 additions and 151 deletions
@@ -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.
@@ -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);
});
});
+20 -7
View File
@@ -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);
@@ -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,
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',
@@ -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',
+6 -1
View File
@@ -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 });
}
});
+40 -16
View File
@@ -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) {
@@ -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/);
});
});