Compare commits
2 Commits
codex/dung
...
codex/dung
| Author | SHA1 | Date | |
|---|---|---|---|
| 75e8fec4fe | |||
| 33dbf37ae3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ data/
|
|||||||
*.log
|
*.log
|
||||||
frontend/dist
|
frontend/dist
|
||||||
.webka-cache.json
|
.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.
|
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`.
|
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
|
## 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.
|
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.
|
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.
|
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
|
python3 scripts/smoke.py
|
||||||
# Optional POSIX integration test: reserves port 3002, tests startup/restart/stop.
|
# Optional POSIX integration test: reserves port 3002, tests startup/restart/stop.
|
||||||
python3 scripts/test-launcher.py
|
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.
|
`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)), '..');
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
export function parseArgs(args) {
|
export function readSettings(path = join(root, 'webka.config.json')) {
|
||||||
let host = '127.0.0.1', port = '3000';
|
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++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
if (arg === '--help' || arg === '-h') return { help: true };
|
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 (!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('--host=')) host = arg.slice(7);
|
||||||
else if (arg.startsWith('--port=')) port = 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 (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.');
|
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 hostname = host => isIP(host) === 6 ? `[${host}]` : host;
|
||||||
const url = (host, port) => `http://${hostname(host)}:${port}`;
|
const url = (host, port) => `http://${hostname(host)}:${port}`;
|
||||||
@@ -33,20 +53,30 @@ function privateIPv4(ip) {
|
|||||||
const parts = ip.split('.').map(Number);
|
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));
|
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 wildcard = host === '0.0.0.0' || host === '::';
|
||||||
const local = url(wildcard ? (host === '::' ? '::1' : '127.0.0.1') : host, port);
|
const local = url(wildcard ? (host === '::' ? '::1' : '127.0.0.1') : host, port);
|
||||||
const loopback = host === '127.0.0.1' || host === '::1' || wildcard;
|
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 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];
|
const automatic = [origin, local, ...(loopback ? [`http://localhost:${port}`] : []), ...lan];
|
||||||
// Explicit HTTPS/reverse-proxy setups retain their exact configured origin.
|
// 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]) {
|
for (const value of [origin, ...allowed]) {
|
||||||
const parsed = new URL(value);
|
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}`);
|
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) {
|
function fingerprint(paths) {
|
||||||
@@ -72,9 +102,9 @@ function assertPort(host, port) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function main(args = process.argv.slice(2)) {
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const [major, minor] = process.versions.node.split('.').map(Number);
|
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.');
|
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 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 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.
|
// Observe exit failures immediately while checking readiness.
|
||||||
let failure;
|
let failure;
|
||||||
completion.catch(e => { failure = e; });
|
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 (!ready && !stopping) { stop(); await completion; throw new Error('The server did not become ready. Check the error above.'); }
|
||||||
if (stopping) { await completion; return; }
|
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}`);
|
console.log(`\nWebka is ready!\n This computer: ${urls.local}`);
|
||||||
for (const address of urls.lan) console.log(` LAN: ${address}`);
|
for (const address of urls.lan) console.log(` LAN: ${address}`);
|
||||||
if (process.env.WEBKA_ORIGIN) console.log(` Public URL: ${urls.origin}`);
|
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`);
|
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;
|
await completion;
|
||||||
} finally {
|
} finally {
|
||||||
if (child && child.exitCode === null) stop();
|
if (child && child.exitCode === null) stop();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('default and named host/port options', () => {
|
||||||
assert.deepEqual(parseArgs([]), { host: '127.0.0.1', port: 3000 });
|
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_ORIGIN: 'https://webka.local/login' }));
|
||||||
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ALLOWED_ORIGINS: '*' }));
|
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