diff --git a/.gitignore b/.gitignore index 8db356c..4b894ec 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ data/ *.log frontend/dist .webka-cache.json +webka.config.json diff --git a/README.md b/README.md index 7f28162..79ec6c6 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,58 @@ The terminal prints the local URL, LAN URLs when available, and the setup-token Press **Ctrl+C** to stop the app and any build subprocesses. Accounts remain in `data/`. The first login page creates the administrator account using the token from `data/setup-token`. The administrator cannot be deleted; resetting its password ends its sessions. -For HTTPS behind a reverse proxy, set `WEBKA_ORIGIN` to the exact public origin before launching. This override disables automatic HTTP aliases; optional `WEBKA_ALLOWED_ORIGINS` is a comma-separated list of additional trusted exact origins. Browser capture on another LAN device still requires trusted HTTPS; plain LAN HTTP permits viewing only. +For an existing HTTPS proxy, use the homepage setting below. Browser capture on another LAN device still requires a trusted HTTPS address. For frontend hot reload, the separate development workflow remains available: run `cargo run --manifest-path backend/Cargo.toml` and `npm --prefix frontend run dev` in separate terminals, then open `http://localhost:3000`. +## Existing Nginx Proxy Manager on another machine + +Set just the address you want to open in a browser (the address served by NPM): + +```sh +npm run dev:all -- --homepage https://camera.example.com --port 3002 +``` + +Replace `https://camera.example.com` with your actual HTTPS domain or IP, including a public port if needed. A bare hostname/IP is treated as HTTPS. A trailing slash is accepted; hosting under a subpath is not supported. + +This mode automatically: + +- Listens on `0.0.0.0` so the other machine can reach Webka (unless you explicitly set `--host`). +- Serves the frontend and API over **HTTP** on the chosen internal port; your existing NPM handles HTTPS. +- Uses the homepage for QR/share links, secure cookies, and allowed request origins. +- Disables mDNS, even if `WEBKA_MDNS=1` was previously set. No Caddy, `.local` hostname, or certificates on the Webka computer are needed. + +In **Nginx Proxy Manager → Proxy Hosts**, configure: + +| Setting | Value | +|---|---| +| Domain Names | Your homepage hostname, e.g. `camera.example.com` | +| Scheme | `http` | +| Forward Hostname / IP | The LAN IP of the **Webka computer**, e.g. `192.168.1.50` | +| Forward Port | `3002` (or your `--port`) | +| Websockets Support | Enabled | +| SSL | Select your existing trusted certificate; enable Force SSL | + +Open the homepage HTTPS address on both the host and viewer. Its hostname must resolve to the **NPM machine**, and NPM must be able to reach the Webka machine's LAN IP/port. The homepage is the browser-facing address, not the internal upstream address. For an HTTPS IP address, the certificate must cover that IP and NPM must route it to Webka. See the [official NPM guide](https://nginxproxymanager.com/guide/) and [WebSocket proxy support](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/develop/backend/templates/proxy_host.conf). + +To save the address, create `webka.config.json` in the repository root (or copy `webka.config.example.json`): + +```json +{ + "homepage": "https://camera.example.com", + "port": 3002 +} +``` + +Then start with only `npm run dev:all`. `homepage` alone is sufficient if the internal port is the default `3000`. Optional `host` and `port` may also be saved; command-line options override the file. Restart to apply changes. This machine-specific file is ignored by Git. Homepage mode takes priority over legacy `WEBKA_ORIGIN`, `WEBKA_ALLOWED_ORIGINS`, and `WEBKA_MDNS` values. + +Legacy manual proxy setup via `WEBKA_ORIGIN` and `WEBKA_ALLOWED_ORIGINS` remains supported when no homepage is configured. The launcher does not configure NPM or provision its certificates. The proxy carries page/API/signaling traffic; WebRTC media remains peer-to-peer on the LAN. + ## Use on your LAN A webpage **cannot launch a Rust server** on the device running its browser. Start the Rust service once on a computer on your LAN. A phone, tablet, or computer can then sign in as the administrator and act as the capturing host. This repository does not include a native iOS/Android service wrapper. Each service instance owns its account database. -Browser camera/screen APIs require a **secure context**: localhost on the service machine, or trusted HTTPS on a LAN device. LAN HTTP IP addresses cannot capture camera/screen. For LAN use, use the included `Caddyfile.example` with a trusted local certificate: +Browser camera/screen APIs require a **secure context**: localhost on the service machine, or trusted HTTPS on a LAN device. LAN HTTP IP addresses cannot capture camera/screen. If you already have an HTTPS proxy, use the homepage mode above. If you do not, the included `Caddyfile.example` is an optional alternative with a trusted local certificate: 1. Point `webka.local` at the service computer, through mDNS (`WEBKA_MDNS=1`), your router, or a hosts entry. `.local` resolution and multicast support vary by network/device; a local DNS name or IP with a matching trusted certificate also works. 2. Configure Caddy with the example below, and install/trust Caddy’s local CA on **every** client device. Keep this CA private. @@ -93,6 +136,8 @@ cargo build --manifest-path backend/Cargo.toml python3 scripts/smoke.py # Optional POSIX integration test: reserves port 3002, tests startup/restart/stop. python3 scripts/test-launcher.py +# Optional POSIX HTTPS proxy integration test (requires OpenSSL). +python3 scripts/test-proxy.py ``` `test:install` checks the frontend lockfile from a temporary folder without `node_modules`. When updating dependencies, run it with both npm 10 (bundled with Node 22) and npm 11; root `npm install` only installs the launcher package and does not repair the frontend lockfile. diff --git a/scripts/dev-all.mjs b/scripts/dev-all.mjs index 9cce13d..695755a 100644 --- a/scripts/dev-all.mjs +++ b/scripts/dev-all.mjs @@ -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(); diff --git a/scripts/dev-all.test.mjs b/scripts/dev-all.test.mjs index 75db7e5..fb65edc 100644 --- a/scripts/dev-all.test.mjs +++ b/scripts/dev-all.test.mjs @@ -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' }); +}); diff --git a/scripts/test-proxy.py b/scripts/test-proxy.py new file mode 100644 index 0000000..686c1c7 --- /dev/null +++ b/scripts/test-proxy.py @@ -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) diff --git a/webka.config.example.json b/webka.config.example.json new file mode 100644 index 0000000..4fb8bdc --- /dev/null +++ b/webka.config.example.json @@ -0,0 +1,3 @@ +{ + "homepage": "https://camera.example.com" +}