DUNGEONS-34: simplify external HTTPS proxy setup

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 17:42:55 +05:00
parent 33dbf37ae3
commit 75e8fec4fe
6 changed files with 306 additions and 21 deletions

View File

@@ -10,22 +10,42 @@ import { setTimeout as delay } from 'node:timers/promises';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export function parseArgs(args) {
let host = '127.0.0.1', port = '3000';
export function readSettings(path = join(root, 'webka.config.json')) {
if (!existsSync(path)) return {};
let settings;
try { settings = JSON.parse(readFileSync(path, 'utf8')); }
catch { throw new Error('webka.config.json must contain valid JSON.'); }
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new Error('webka.config.json must contain an object.');
for (const key of Object.keys(settings)) if (!['homepage', 'host', 'port'].includes(key)) throw new Error(`Unknown webka.config.json setting: ${key}`);
return settings;
}
export function normalizeHomepage(value) {
if (typeof value !== 'string' || !value.trim()) throw new Error('Homepage must be an HTTPS address, for example https://camera.example.com.');
let parsed;
try { parsed = new URL(value.includes('://') ? value.trim() : `https://${value.trim()}`); }
catch { throw new Error('Homepage must be a valid HTTPS hostname or IP address.'); }
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash || ['0.0.0.0', '[::]'].includes(parsed.hostname)) throw new Error('Homepage must be an HTTPS address without a path, credentials, or query (use the address served by your proxy).');
return parsed.origin;
}
export function parseArgs(args, settings = {}) {
let host = settings.host, port = String(settings.port ?? '3000'), homepage = settings.homepage;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') return { help: true };
if (arg === '--host' || arg === '--port') {
if (arg === '--host' || arg === '--port' || arg === '--homepage') {
if (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`${arg} needs a value.`);
if (arg === '--host') host = args[++i]; else port = args[++i];
if (arg === '--host') host = args[++i]; else if (arg === '--port') port = args[++i]; else homepage = args[++i];
} else if (arg.startsWith('--host=')) host = arg.slice(7);
else if (arg.startsWith('--port=')) port = arg.slice(7);
else throw new Error(`Unknown argument: ${arg}. Use --host and --port (two ordinary hyphens).`);
else if (arg.startsWith('--homepage=')) homepage = arg.slice(11);
else throw new Error(`Unknown argument: ${arg}. Use --host, --port, or --homepage (two ordinary hyphens).`);
}
if (homepage !== undefined) homepage = normalizeHomepage(homepage);
host ??= homepage ? '0.0.0.0' : '127.0.0.1';
if (host === 'localhost') host = '127.0.0.1';
if (!isIP(host)) throw new Error('--host must be an IPv4/IPv6 address or localhost.');
if (typeof host !== 'string' || !isIP(host)) throw new Error('--host must be an IPv4/IPv6 address or localhost.');
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('--port must be between 1 and 65535.');
return { host, port: Number(port) };
return { host, port: Number(port), ...(homepage ? { homepage } : {}) };
}
const hostname = host => isIP(host) === 6 ? `[${host}]` : host;
const url = (host, port) => `http://${hostname(host)}:${port}`;
@@ -33,20 +53,30 @@ function privateIPv4(ip) {
const parts = ip.split('.').map(Number);
return isIP(ip) === 4 && (parts[0] === 10 || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31));
}
export function addresses({ host, port }, interfaces = networkInterfaces(), env = process.env) {
export function addresses({ host, port, homepage }, interfaces = networkInterfaces(), env = process.env) {
const wildcard = host === '0.0.0.0' || host === '::';
const local = url(wildcard ? (host === '::' ? '::1' : '127.0.0.1') : host, port);
const loopback = host === '127.0.0.1' || host === '::1' || wildcard;
const lan = wildcard ? [...new Set(Object.values(interfaces).flat().filter(item => item && !item.internal && item.family === 'IPv4' && privateIPv4(item.address)).map(item => url(item.address, port)))] : [];
const origin = env.WEBKA_ORIGIN || lan[0] || (loopback ? `http://localhost:${port}` : local);
const origin = homepage || env.WEBKA_ORIGIN || lan[0] || (loopback ? `http://localhost:${port}` : local);
const automatic = [origin, local, ...(loopback ? [`http://localhost:${port}`] : []), ...lan];
// Explicit HTTPS/reverse-proxy setups retain their exact configured origin.
const allowed = env.WEBKA_ALLOWED_ORIGINS !== undefined ? env.WEBKA_ALLOWED_ORIGINS.split(',').filter(Boolean) : env.WEBKA_ORIGIN ? [origin] : automatic;
const allowed = homepage ? [origin] : env.WEBKA_ALLOWED_ORIGINS !== undefined ? env.WEBKA_ALLOWED_ORIGINS.split(',').filter(Boolean) : env.WEBKA_ORIGIN ? [origin] : automatic;
for (const value of [origin, ...allowed]) {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value || parsed.username || parsed.password) throw new Error(`Use an exact http(s) origin without a path: ${value}`);
}
return { local, lan, origin, allowed: [...new Set(allowed)] };
return { local, lan, origin, allowed: [...new Set(allowed)], proxy: !!homepage };
}
export function connectionEnv(config, urls, env = process.env) {
return {
WEBKA_BIND: `${hostname(config.host)}:${config.port}`,
WEBKA_ORIGIN: urls.origin,
WEBKA_ALLOWED_ORIGINS: urls.allowed.join(','),
WEBKA_PUBLIC_PORT: String(new URL(urls.origin).port || (urls.origin.startsWith('https:') ? 443 : 80)),
WEBKA_MDNS: urls.proxy ? '0' : (env.WEBKA_MDNS || '0'),
};
}
function fingerprint(paths) {
@@ -72,9 +102,9 @@ function assertPort(host, port) {
}
export async function main(args = process.argv.slice(2)) {
const config = parseArgs(args);
const config = parseArgs(args, args.includes('--help') || args.includes('-h') ? {} : readSettings());
if (config.help) {
console.log('Usage: npm run dev:all -- [--host 127.0.0.1] [--port 3000]\nBuilds and runs the frontend + backend together. Ctrl+C stops the app.');
console.log('Usage: npm run dev:all -- [--host 127.0.0.1] [--port 3000] [--homepage https://camera.example.com]\nA homepage enables external HTTPS proxy mode, defaults --host to 0.0.0.0, and disables mDNS.\nOptional webka.config.json saves homepage, host, and port. CLI options override saved settings.\nBuilds and runs the frontend + backend together. Ctrl+C stops the app.');
return;
}
const [major, minor] = process.versions.node.split('.').map(Number);
@@ -137,7 +167,7 @@ export async function main(args = process.argv.slice(2)) {
if (metadata.status !== 0) throw new Error('Could not locate the Rust executable.');
const executable = join(JSON.parse(metadata.stdout).target_directory, 'debug', process.platform === 'win32' ? 'webka.exe' : 'webka');
const data = resolve(root, process.env.WEBKA_DATA || 'data');
const { proc, completion } = run(executable, [], { ...process.env, WEBKA_BIND: `${hostname(config.host)}:${config.port}`, WEBKA_ORIGIN: urls.origin, WEBKA_ALLOWED_ORIGINS: urls.allowed.join(','), WEBKA_PUBLIC_PORT: String(new URL(urls.origin).port || (urls.origin.startsWith('https:') ? 443 : 80)), WEBKA_ASSETS: join(root, 'frontend/.output/public'), WEBKA_DATA: data });
const { proc, completion } = run(executable, [], { ...process.env, ...connectionEnv(config, urls), WEBKA_ASSETS: join(root, 'frontend/.output/public'), WEBKA_DATA: data });
// Observe exit failures immediately while checking readiness.
let failure;
completion.catch(e => { failure = e; });
@@ -153,11 +183,15 @@ export async function main(args = process.argv.slice(2)) {
}
if (!ready && !stopping) { stop(); await completion; throw new Error('The server did not become ready. Check the error above.'); }
if (stopping) { await completion; return; }
console.log(`\nWebka is ready!\n This computer: ${urls.local}`);
for (const address of urls.lan) console.log(` LAN: ${address}`);
if (process.env.WEBKA_ORIGIN) console.log(` Public URL: ${urls.origin}`);
if (urls.proxy) {
console.log(`\nWebka is ready!\n Open in browser: ${urls.origin}\n Proxy upstream: HTTP to this Webka computer's LAN IP, port ${config.port}\n HTTPS: handled by your existing proxy\n mDNS: disabled`);
} else {
console.log(`\nWebka is ready!\n This computer: ${urls.local}`);
for (const address of urls.lan) console.log(` LAN: ${address}`);
if (process.env.WEBKA_ORIGIN) console.log(` Public URL: ${urls.origin}`);
if (urls.lan.length) console.log('Use the localhost/127.0.0.1 URL to capture on this computer. Capturing on a phone requires trusted HTTPS (see README).');
}
console.log(` Setup token: ${join(data, 'setup-token')}\n Stop: Ctrl+C\n`);
if (urls.lan.length) console.log('Use the localhost/127.0.0.1 URL to capture on this computer. Capturing on a phone requires trusted HTTPS (see README).\n');
await completion;
} finally {
if (child && child.exitCode === null) stop();