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();

View File

@@ -1,6 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs, addresses } from './dev-all.mjs';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseArgs, addresses, connectionEnv, readSettings } from './dev-all.mjs';
test('default and named host/port options', () => {
assert.deepEqual(parseArgs([]), { host: '127.0.0.1', port: 3000 });
@@ -26,3 +29,38 @@ test('explicit HTTPS origin does not silently authorize HTTP aliases', () => {
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ORIGIN: 'https://webka.local/login' }));
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ALLOWED_ORIGINS: '*' }));
});
test('homepage mode accepts proxy domain/IP, normalizes HTTPS and exposes the upstream', () => {
assert.deepEqual(parseArgs(['--homepage', 'camera.example.com', '--port', '3002']), { host: '0.0.0.0', port: 3002, homepage: 'https://camera.example.com' });
assert.deepEqual(parseArgs(['--homepage=https://192.168.1.10:8443/', '--host=127.0.0.1']), { host: '127.0.0.1', port: 3000, homepage: 'https://192.168.1.10:8443' });
for (const address of ['', 'http://camera.example.com', 'https://camera.example.com/watch', 'https://a:b@camera.example.com', 'https://camera.example.com?x=1', 'https://camera.example.com#x', 'https://0.0.0.0', 'https://[::]']) assert.throws(() => parseArgs(['--homepage', address]));
});
test('saved settings apply, with command-line overrides', () => {
const settings = { homepage: 'https://camera.example.com', port: 3002 };
assert.deepEqual(parseArgs([], settings), { host: '0.0.0.0', port: 3002, homepage: settings.homepage });
assert.deepEqual(parseArgs(['--homepage', 'https://other.example.com', '--port', '3003', '--host', '127.0.0.1'], settings), { host: '127.0.0.1', port: 3003, homepage: 'https://other.example.com' });
assert.throws(() => parseArgs([], { port: 'invalid' }));
});
test('configuration file loads a homepage and rejects malformed or misspelled settings', () => {
const directory = mkdtempSync(join(tmpdir(), 'webka-settings-'));
const path = join(directory, 'webka.config.json');
try {
assert.deepEqual(readSettings(path), {});
writeFileSync(path, JSON.stringify({ homepage: 'camera.example.com', port: 3002 }));
assert.deepEqual(parseArgs([], readSettings(path)), { host: '0.0.0.0', port: 3002, homepage: 'https://camera.example.com' });
for (const invalid of ['{', 'null', '[]', '{"homePage":"camera.example.com"}']) {
writeFileSync(path, invalid);
assert.throws(() => readSettings(path), /webka.config.json/);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('homepage wins over old HTTPS/mDNS environment settings without allowing HTTP aliases', () => {
const env = { WEBKA_MDNS: '1', WEBKA_ORIGIN: 'https://old.local', WEBKA_ALLOWED_ORIGINS: 'http://unsafe.example.com' };
const config = parseArgs(['--homepage', 'https://camera.example.com:8443', '--port', '3002']);
const urls = addresses(config, networks, env);
assert.equal(urls.origin, config.homepage);
assert.deepEqual(urls.allowed, [config.homepage]);
assert.deepEqual(connectionEnv(config, urls, env), { WEBKA_BIND: '0.0.0.0:3002', WEBKA_ORIGIN: config.homepage, WEBKA_ALLOWED_ORIGINS: config.homepage, WEBKA_MDNS: '0', WEBKA_PUBLIC_PORT: '8443' });
});

164
scripts/test-proxy.py Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""POSIX integration: HTTPS reverse proxy -> Webka HTTP upstream, no mDNS."""
import base64
import hashlib
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import signal
import socket
import ssl
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.request
root = Path(__file__).resolve().parents[1]
def free_port():
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
return sock.getsockname()[1]
upstream_port = free_port()
proxy_port = free_port()
homepage = f'https://127.0.0.1:{proxy_port}'
class Proxy(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args):
pass
def forward(self):
body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
headers = dict(self.headers)
headers['X-Forwarded-Proto'] = 'https'
headers['X-Forwarded-For'] = '192.168.1.99'
connection = http.client.HTTPConnection('127.0.0.1', upstream_port, timeout=5)
try:
connection.request(self.command, self.path, body=body, headers=headers)
response = connection.getresponse()
self.send_response(response.status)
for name, value in response.getheaders():
if name.lower() not in ['transfer-encoding', 'content-length', 'connection']:
self.send_header(name, value)
if response.status == 101:
self.send_header('Connection', 'Upgrade')
self.end_headers()
self.wfile.flush()
# Establish the WebSocket upgrade across TLS and forward its first server frame.
# Explicit HTTPConnection upgrade retains the underlying socket.
head = response.fp.read(2)
self.wfile.write(head)
size = head[1] & 127
if size == 126:
extended = response.fp.read(2)
self.wfile.write(extended)
size = int.from_bytes(extended, 'big')
self.wfile.write(response.fp.read(size))
self.wfile.flush()
self.close_connection = True
else:
data = response.read()
self.send_header('Content-Length', str(len(data)))
self.send_header('Connection', 'close')
self.end_headers()
self.wfile.write(data)
self.close_connection = True
finally:
connection.close()
do_GET = forward
do_POST = forward
with tempfile.TemporaryDirectory(prefix='webka-proxy-test-') as temporary:
directory = Path(temporary)
cert, key = directory / 'cert.pem', directory / 'key.pem'
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key), '-out', str(cert), '-days', '1', '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1'], check=True, capture_output=True)
context = ssl.create_default_context(cafile=str(cert))
data = directory / 'data'
# Deliberately stale values must be overridden by homepage mode.
env = dict(os.environ, WEBKA_DATA=str(data), WEBKA_MDNS='1', WEBKA_ORIGIN='https://old.local', WEBKA_ALLOWED_ORIGINS='http://old.example.com')
proxy = ThreadingHTTPServer(('127.0.0.1', proxy_port), Proxy)
server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
server_context.load_cert_chain(cert, key)
proxy.socket = server_context.wrap_socket(proxy.socket, server_side=True)
thread = threading.Thread(target=proxy.serve_forever, daemon=True)
thread.start()
logfile = directory / 'launch.log'
try:
with logfile.open('w') as output:
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--homepage', homepage, '--port', str(upstream_port)], cwd=root, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
try:
for _ in range(1200):
if proc.poll() is not None:
raise AssertionError(logfile.read_text())
if 'Webka is ready!' in logfile.read_text():
break
time.sleep(0.5)
else:
raise AssertionError('Startup timeout')
assert 'mDNS: disabled' in logfile.read_text()
assert f'Open in browser: {homepage}' in logfile.read_text()
def request(path, body=None, origin=homepage, cookie=None):
headers = {'Origin': origin, 'Content-Type': 'application/json'}
if cookie:
headers['Cookie'] = cookie
req = urllib.request.Request(homepage + path, data=json.dumps(body).encode() if body is not None else None, headers=headers)
return urllib.request.urlopen(req, context=context, timeout=5)
with request('/login') as response:
assert response.status == 200 and 'text/html' in response.headers['Content-Type']
credentials = {'username': 'owner', 'password': 'proxy-test-password123', 'setup_token': (data / 'setup-token').read_text()}
with request('/api/bootstrap', credentials) as response:
assert response.status == 201
with request('/api/login', credentials) as response:
assert 'Secure' in response.headers['Set-Cookie']
assert 'HttpOnly' in response.headers['Set-Cookie']
cookie = response.headers['Set-Cookie'].split(';')[0]
with request('/api/status', cookie=cookie) as response:
assert json.load(response)['local_url'] == homepage + '/watch'
with request('/api/session', cookie=cookie) as response:
assert json.load(response)['role'] == 'admin'
for wrong_origin in ['https://old.local', f'http://127.0.0.1:{upstream_port}', 'http://old.example.com']:
try:
request('/api/logout', {}, origin=wrong_origin, cookie=cookie)
raise AssertionError('Unexpected origin allowed')
except urllib.error.HTTPError as error:
assert error.code == 403
# A real WSS handshake is forwarded to the HTTP WebSocket upstream.
ws_key = base64.b64encode(os.urandom(16)).decode()
conn = http.client.HTTPSConnection('127.0.0.1', proxy_port, context=context, timeout=5)
conn.request('GET', '/api/signal?mode=host', headers={'Origin': homepage, 'Cookie': cookie, 'Connection': 'Upgrade', 'Upgrade': 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': ws_key})
response = conn.getresponse()
assert response.status == 101
expected = base64.b64encode(hashlib.sha1((ws_key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
assert response.headers['Sec-WebSocket-Accept'] == expected
head = response.fp.read(2)
size = head[1] & 127
if size == 126:
size = int.from_bytes(response.fp.read(2), 'big')
assert json.loads(response.fp.read(size))['type'] == 'welcome'
conn.close()
print('PASS: HTTPS proxy login, Secure cookie, public share URL, origin restrictions, WSS upgrade, mDNS disabled.', flush=True)
finally:
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGINT)
try:
proc.wait(timeout=12)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
raise AssertionError('Launcher did not stop')
finally:
proxy.shutdown()
proxy.server_close()
thread.join(timeout=5)
with socket.socket() as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('0.0.0.0', upstream_port))
print('PASS: proxy and launcher stopped; HTTP upstream port released.', flush=True)