DUNGEONS-34: simplify external HTTPS proxy setup
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ data/
|
||||
*.log
|
||||
frontend/dist
|
||||
.webka-cache.json
|
||||
webka.config.json
|
||||
|
||||
49
README.md
49
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.
|
||||
|
||||
@@ -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; }
|
||||
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();
|
||||
|
||||
@@ -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
164
scripts/test-proxy.py
Normal 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)
|
||||
3
webka.config.example.json
Normal file
3
webka.config.example.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"homepage": "https://camera.example.com"
|
||||
}
|
||||
Reference in New Issue
Block a user