174 lines
5.9 KiB
JavaScript
174 lines
5.9 KiB
JavaScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import { EXIT_CODE, TunnelCliError } from './cli-errors.js';
|
|
import {
|
|
assertSafeBrowserPort,
|
|
resolveConfiguredBindHost,
|
|
buildLocalUrl,
|
|
detectLanIPv4Address,
|
|
formatHostForUrl,
|
|
} from './cli-network.js';
|
|
import { discoverRunningInstances } from './cli-lifecycle.js';
|
|
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
|
|
import { createRemoteClientAuthRuntime } from '../../server/lib/client-auth/remote-clients.js';
|
|
import {
|
|
intro as clackIntro,
|
|
outro as clackOutro,
|
|
log as clackLog,
|
|
isJsonMode,
|
|
isQuietMode,
|
|
printJson,
|
|
logStatus,
|
|
} from '../cli-output.js';
|
|
|
|
const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json';
|
|
|
|
async function resolveConnectUrlServerUrl(options) {
|
|
let hostOverride = options.host;
|
|
if (typeof hostOverride !== 'string' && !process.env.OPENCHAMBER_HOST) {
|
|
const storedOptions = readInstanceOptions(await getInstanceFilePath(options.port));
|
|
if (typeof storedOptions?.host === 'string' && storedOptions.host.trim()) {
|
|
hostOverride = storedOptions.host.trim();
|
|
}
|
|
}
|
|
|
|
const bindHost = resolveConfiguredBindHost(hostOverride);
|
|
if (!isWildcardBindHost(bindHost)) {
|
|
return {
|
|
serverUrl: buildLocalUrl(options.port, '/', hostOverride).replace(/\/+$/, ''),
|
|
source: 'configured-host',
|
|
};
|
|
}
|
|
|
|
const lanAddress = await detectLanIPv4Address();
|
|
if (!lanAddress) {
|
|
return {
|
|
serverUrl: buildLocalUrl(options.port, '/').replace(/\/+$/, ''),
|
|
source: 'loopback-fallback',
|
|
};
|
|
}
|
|
|
|
return {
|
|
serverUrl: `http://${formatHostForUrl(lanAddress)}:${options.port}`,
|
|
source: 'lan-detected',
|
|
};
|
|
}
|
|
|
|
function isWildcardBindHost(host) {
|
|
return host === '0.0.0.0' || host === '::' || host === '[::]';
|
|
}
|
|
|
|
function normalizeServerUrlForConnection(value) {
|
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
if (!trimmed) return null;
|
|
try {
|
|
const parsed = new URL(trimmed);
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return null;
|
|
}
|
|
parsed.hash = '';
|
|
return parsed.toString().replace(/\/+$/, '');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getOpenChamberDataDir() {
|
|
return process.env.OPENCHAMBER_DATA_DIR
|
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
|
: path.join(os.homedir(), '.config', 'openchamber');
|
|
}
|
|
|
|
function buildClientConnectionPayload({ serverUrl, token, label }) {
|
|
const params = new URLSearchParams();
|
|
params.set('v', '1');
|
|
params.set('server', serverUrl.trim().replace(/\/+$/, ''));
|
|
params.set('token', token.trim());
|
|
if (label?.trim()) params.set('label', label.trim());
|
|
return `openchamber://connect?${params.toString()}`;
|
|
}
|
|
|
|
async function displayTunnelQrCode(url) {
|
|
try {
|
|
const qrcode = await import('qrcode-terminal');
|
|
console.log('\n📱 Scan this QR code to access the tunnel:\n');
|
|
qrcode.default.generate(url, { small: true });
|
|
console.log('');
|
|
} catch (error) {
|
|
console.warn(`Warning: Could not generate QR code: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function createConnectUrlCommand({ serveCommand }) {
|
|
return async function connectUrlCommand(options = {}) {
|
|
assertSafeBrowserPort(options.port, { context: 'OpenChamber connect-url' });
|
|
const explicitServerUrl = options.server ? normalizeServerUrlForConnection(options.server) : null;
|
|
if (options.server && !explicitServerUrl) {
|
|
throw new TunnelCliError('Invalid --server URL. Use an http:// or https:// URL.', EXIT_CODE.USAGE_ERROR);
|
|
}
|
|
|
|
const running = await discoverRunningInstances();
|
|
const serverState = running.some((entry) => entry.port === options.port)
|
|
? { port: options.port, autoStarted: false }
|
|
: await (async () => {
|
|
await serveCommand({
|
|
port: options.port,
|
|
explicitPort: true,
|
|
host: options.host,
|
|
uiPassword: options.uiPassword,
|
|
apiOnly: options.apiOnly,
|
|
suppressUnsafePortWarning: true,
|
|
suppressUiPasswordWarning: true,
|
|
suppressStartupSummary: true,
|
|
suppressQuietOutput: true,
|
|
});
|
|
return { port: options.port, autoStarted: true };
|
|
})();
|
|
|
|
const resolvedServerUrl = explicitServerUrl
|
|
? { serverUrl: explicitServerUrl, source: 'explicit' }
|
|
: await resolveConnectUrlServerUrl(options);
|
|
const serverUrl = resolvedServerUrl.serverUrl;
|
|
const label = options.name || `OpenChamber ${serverUrl}`;
|
|
const runtime = createRemoteClientAuthRuntime({
|
|
fsPromises: fs.promises,
|
|
path,
|
|
crypto,
|
|
storePath: path.join(getOpenChamberDataDir(), REMOTE_CLIENTS_FILE_NAME),
|
|
});
|
|
const result = await runtime.createClient({ label });
|
|
const connectUrl = buildClientConnectionPayload({ serverUrl, token: result.token, label });
|
|
|
|
if (isJsonMode(options)) {
|
|
printJson({ serverUrl, connectUrl, token: result.token, client: result.client, autoStarted: serverState.autoStarted });
|
|
return;
|
|
}
|
|
|
|
if (isQuietMode(options)) {
|
|
process.stdout.write(`${connectUrl}\n`);
|
|
return;
|
|
}
|
|
|
|
clackIntro('OpenChamber connect URL');
|
|
if (serverState.autoStarted) {
|
|
logStatus('success', `started OpenChamber on port ${options.port}`);
|
|
}
|
|
logStatus('success', connectUrl);
|
|
clackLog.info(`Server URL: ${serverUrl}`);
|
|
if (resolvedServerUrl.source === 'lan-detected') {
|
|
clackLog.info('Detected a LAN address because OpenChamber is bound to all interfaces. Use --server to override it.');
|
|
} else if (resolvedServerUrl.source === 'loopback-fallback') {
|
|
clackLog.warn('OpenChamber is bound to all interfaces, but no LAN address was detected. Use --server to provide a reachable URL.');
|
|
}
|
|
clackLog.info('Copy this connection link into another OpenChamber client. The token is shown only once.');
|
|
if (options.qr === true) {
|
|
await displayTunnelQrCode(connectUrl);
|
|
}
|
|
clackOutro('connect URL generated');
|
|
};
|
|
}
|
|
|
|
export { createConnectUrlCommand };
|