Compare commits
2 Commits
codex/dung
...
codex/dung
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f9f55d600 | |||
| 26e6b2247e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ data/
|
||||
.env
|
||||
*.log
|
||||
frontend/dist
|
||||
.webka-cache.json
|
||||
|
||||
32
README.md
32
README.md
@@ -2,25 +2,31 @@
|
||||
|
||||
A private camera and screen streaming app for your local network. Nuxt 3 / Vue 3 / TypeScript, Pinia, English/Russian/Spanish, and an Axum/Rust service with SQLite and browser-to-browser WebRTC.
|
||||
|
||||
## Run locally
|
||||
## One-command start
|
||||
|
||||
Requirements: Node **22.12+**, npm, stable Rust, and a browser with WebRTC. Run these commands from the repository root:
|
||||
Install Node **22.12+** (npm included) and stable Rust/Cargo once. Then, from the repository root:
|
||||
|
||||
```sh
|
||||
npm --prefix frontend ci
|
||||
npm --prefix frontend run build
|
||||
WEBKA_BIND=127.0.0.1:3000 cargo run --manifest-path backend/Cargo.toml
|
||||
npm run dev:all
|
||||
```
|
||||
|
||||
Open **http://localhost:3000**. The first login page creates the administrator account. Read the one-time setup token from `data/setup-token` on the server device and enter it in the form. The administrator account cannot be deleted. Password resets end all of the account’s sessions, including your own if you reset the administrator password.
|
||||
|
||||
For development, run the Rust backend on its default `127.0.0.1:3001` and the frontend in another terminal:
|
||||
This starts **http://127.0.0.1:3000**. To listen on all network interfaces at port 3002:
|
||||
|
||||
```sh
|
||||
npm --prefix frontend run dev
|
||||
npm run dev:all -- --host 0.0.0.0 --port 3002
|
||||
```
|
||||
|
||||
Use `http://localhost:3000` (not `127.0.0.1:3000`); the configured origin must match the browser URL exactly. The development proxy forwards REST and WebSocket traffic to Rust.
|
||||
The `--` after `dev:all` passes the options to the launcher. Use ordinary double hyphens. `npm start -- --host 0.0.0.0 --port 3002` is an alias. `--help` shows usage.
|
||||
|
||||
The launcher installs frontend dependencies automatically on the first run, builds Nuxt, builds Rust, and runs one server serving both the frontend and API on the chosen port. First startup needs internet and can take several minutes. Subsequent runs reuse unchanged dependencies/frontend output; Cargo builds incrementally. Restart the command after code changes to rebuild. This convenience command does not provide hot reload.
|
||||
|
||||
The terminal prints the local URL, LAN URLs when available, and the setup-token file location. Open the local URL on the server computer for camera capture; use a printed LAN URL on other devices to watch. The QR code uses the detected LAN address when listening on all interfaces. Local and detected private IPv4 origins are explicitly allowed; arbitrary origins remain blocked. If the selected port is busy, startup stops with an actionable message.
|
||||
|
||||
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 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`.
|
||||
|
||||
## Use on your LAN
|
||||
|
||||
@@ -40,7 +46,7 @@ Browser camera/screen APIs require a **secure context**: localhost on the servic
|
||||
|
||||
4. Open `https://webka.local`, create the admin account, add viewer accounts in **Accounts**, enable preview, and choose **Go live**. Share the QR code/local address. Each viewer signs in with an account created on this service.
|
||||
|
||||
Caddy terminates HTTPS and proxies to the loopback Rust server. Cookies gain `Secure` automatically when `WEBKA_ORIGIN` starts with `https://`. WebSocket handshakes and all mutations validate the exact Origin. No CORS wildcard or trusted forwarded-IP headers are used. The login throttle sees the reverse proxy as one IP, so its ten-attempts-per-minute limit is shared behind that proxy.
|
||||
Caddy terminates HTTPS and proxies to the loopback Rust server. Cookies gain `Secure` automatically when `WEBKA_ORIGIN` starts with `https://`. WebSocket handshakes and all mutations validate an explicitly configured Origin. No CORS wildcard or trusted forwarded-IP headers are used. The login throttle sees the reverse proxy as one IP, so its ten-attempts-per-minute limit is shared behind that proxy.
|
||||
|
||||
Use a private LAN and firewall; do not expose the service through public port forwarding. The app uses **no cloud accounts, public STUN/TURN, telemetry, CDNs, or external fonts**. WebRTC connects peers directly with local ICE candidates. Client isolation, VLANs, and firewalls that block peer UDP can prevent streams; no cloud relay fallback is provided. The one active host sends a separate peer stream to each viewer (32 signaling connections total, including the host). Capacity depends on the host device and Wi-Fi.
|
||||
|
||||
@@ -50,6 +56,7 @@ Use a private LAN and firewall; do not expose the service through public port fo
|
||||
|---|---|---|
|
||||
| `WEBKA_BIND` | `127.0.0.1:3001` | Rust listener |
|
||||
| `WEBKA_ORIGIN` | `http://localhost:3000` | Exact public origin; used for CSRF checks, QR and secure cookies |
|
||||
| `WEBKA_ALLOWED_ORIGINS` | none | Additional trusted exact origins; launcher configures local/LAN aliases |
|
||||
| `WEBKA_DATA` | `data` | Local database and persistent secrets |
|
||||
| `WEBKA_ASSETS` | `frontend/.output/public` | Generated frontend; paths are relative to the current directory |
|
||||
| `WEBKA_MDNS` | off | Set `1` to advertise `_webka._tcp.local.` and `webka.local` |
|
||||
@@ -74,6 +81,7 @@ Nuxt 3 is retained to match the requested stack. Its upstream documentation now
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
npm run test:launcher
|
||||
cargo fmt --manifest-path backend/Cargo.toml --check
|
||||
cargo test --manifest-path backend/Cargo.toml
|
||||
cargo clippy --manifest-path backend/Cargo.toml --all-targets -- -D warnings
|
||||
@@ -82,6 +90,8 @@ npm --prefix frontend run build
|
||||
node frontend/scripts/check-locales.mjs
|
||||
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
|
||||
```
|
||||
|
||||
See `docs/API.md` for REST/signaling contracts and `docs/MANUAL_TEST_PLAN.md` for real-device checks. Design references: [Apple’s introduction](https://www.apple.com/uk/newsroom/2025/06/apple-introduces-a-delightful-and-elegant-new-software-design/) and [Meet Liquid Glass](https://developer.apple.com/videos/play/wwdc2025/219/). No Apple assets are included. Nothing is published by the build commands.
|
||||
|
||||
@@ -71,6 +71,7 @@ struct App {
|
||||
key: Arc<Vec<u8>>,
|
||||
setup: Arc<String>,
|
||||
origin: Arc<String>,
|
||||
allowed_origins: Arc<Vec<String>>,
|
||||
secure: bool,
|
||||
peers: Arc<Mutex<Peers>>,
|
||||
limits: Arc<Mutex<HashMap<String, (i64, u32)>>>,
|
||||
@@ -232,11 +233,14 @@ async fn guard(State(app): State<App>, req: Request, next: Next) -> Response {
|
||||
axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS
|
||||
);
|
||||
if (mutation || req.uri().path() == "/api/signal")
|
||||
&& req
|
||||
&& !req
|
||||
.headers()
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
!= Some(app.origin.as_str())
|
||||
.is_some_and(|origin| {
|
||||
origin == app.origin.as_str()
|
||||
|| app.allowed_origins.iter().any(|allowed| allowed == origin)
|
||||
})
|
||||
{
|
||||
return ApiError(StatusCode::FORBIDDEN, "origin").into_response();
|
||||
}
|
||||
@@ -701,6 +705,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
key: Arc::new(key.into_bytes()),
|
||||
setup: Arc::new(setup),
|
||||
secure: origin.starts_with("https://"),
|
||||
allowed_origins: Arc::new(
|
||||
std::env::var("WEBKA_ALLOWED_ORIGINS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
),
|
||||
origin: Arc::new(origin),
|
||||
peers: Default::default(),
|
||||
limits: Default::default(),
|
||||
|
||||
@@ -14,6 +14,7 @@ async fn app() -> (App, tempfile::TempDir) {
|
||||
key: Arc::new(vec![42; 32]),
|
||||
setup: Arc::new("setup-test".into()),
|
||||
origin: Arc::new("http://localhost:3000".into()),
|
||||
allowed_origins: Arc::new(vec![]),
|
||||
secure: false,
|
||||
peers: Default::default(),
|
||||
limits: Default::default(),
|
||||
@@ -556,3 +557,36 @@ async fn saturated_signaling_queue_fails_closed() {
|
||||
send(&client, json!({"type":"viewer-left"}));
|
||||
assert!(*rx.borrow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launcher_origins_are_explicit_and_port_specific() {
|
||||
let (mut app, _dir) = app().await;
|
||||
app.allowed_origins = Arc::new(vec![
|
||||
"http://127.0.0.1:3002".into(),
|
||||
"http://192.168.1.23:3002".into(),
|
||||
]);
|
||||
for (origin, expected) in [
|
||||
("http://127.0.0.1:3002", StatusCode::UNAUTHORIZED),
|
||||
("http://192.168.1.23:3002", StatusCode::UNAUTHORIZED),
|
||||
("http://localhost:3000", StatusCode::UNAUTHORIZED),
|
||||
("http://192.168.1.23:3003", StatusCode::FORBIDDEN),
|
||||
("http://evil.example", StatusCode::FORBIDDEN),
|
||||
("null", StatusCode::FORBIDDEN),
|
||||
] {
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/logout")
|
||||
.header("origin", origin)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router(app.clone(), "/none".into())
|
||||
.oneshot(req)
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
expected,
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# API and transport
|
||||
|
||||
All API paths are same-origin. Mutations and WebSocket upgrades require an `Origin` matching `WEBKA_ORIGIN` exactly. JSON requests use `Content-Type: application/json`. Authentication is the signed `webka_session` cookie. Error responses are `{ "error": "translationKey" }`; framework validation errors are shown as a translated generic error by the frontend.
|
||||
All API paths are same-origin. Mutations and WebSocket upgrades require an `Origin` matching `WEBKA_ORIGIN` or an explicit `WEBKA_ALLOWED_ORIGINS` entry exactly. JSON requests use `Content-Type: application/json`. Authentication is the signed `webka_session` cookie. Error responses are `{ "error": "translationKey" }`; framework validation errors are shown as a translated generic error by the frontend.
|
||||
|
||||
| Method | Path | Access | Result |
|
||||
|---|---|---|---|
|
||||
|
||||
11
package.json
Normal file
11
package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "webka",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:all": "node scripts/dev-all.mjs",
|
||||
"start": "node scripts/dev-all.mjs",
|
||||
"test:launcher": "node --test scripts/dev-all.test.mjs"
|
||||
},
|
||||
"engines": { "node": ">=22.12.0" }
|
||||
}
|
||||
170
scripts/dev-all.mjs
Normal file
170
scripts/dev-all.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, isIP } from 'node:net';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
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';
|
||||
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 (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`${arg} needs a value.`);
|
||||
if (arg === '--host') host = args[++i]; else port = 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).`);
|
||||
}
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (!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) };
|
||||
}
|
||||
const hostname = host => isIP(host) === 6 ? `[${host}]` : host;
|
||||
const url = (host, port) => `http://${hostname(host)}:${port}`;
|
||||
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) {
|
||||
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 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;
|
||||
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)] };
|
||||
}
|
||||
|
||||
function fingerprint(paths) {
|
||||
const hash = createHash('sha256');
|
||||
const ignored = new Set(['node_modules', '.nuxt', '.output', 'dist', '.git']);
|
||||
function visit(path) {
|
||||
for (const entry of readdirSync(path, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
|
||||
if (ignored.has(entry.name)) continue;
|
||||
const file = join(path, entry.name);
|
||||
if (entry.isDirectory()) visit(file);
|
||||
else if (entry.isFile()) hash.update(file.slice(root.length)).update(readFileSync(file));
|
||||
}
|
||||
}
|
||||
for (const path of paths) visit(path);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
function assertPort(host, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', e => reject(new Error(e.code === 'EADDRINUSE' ? `Port ${port} is already in use. Choose another --port or stop the existing app.` : e.message)));
|
||||
server.listen({ host, port }, () => server.close(resolve));
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
const config = parseArgs(args);
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||
if (major < 22 || (major === 22 && minor < 12)) throw new Error('Webka requires Node.js 22.12 or newer.');
|
||||
if (spawnSync('cargo', ['--version'], { stdio: 'ignore' }).status !== 0) throw new Error('Rust/Cargo is missing. Install stable Rust, then run this command again.');
|
||||
if (!process.env.npm_execpath) throw new Error('Start with npm run dev:all so the launcher can install frontend dependencies.');
|
||||
const urls = addresses(config);
|
||||
await assertPort(config.host, config.port);
|
||||
let child = null, stopping = false, killTimer;
|
||||
function stop() {
|
||||
stopping = true;
|
||||
if (child && child.exitCode === null) {
|
||||
// On Unix, kill only this launcher's process group, including build subprocesses.
|
||||
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
else {
|
||||
try { process.kill(-child.pid, 'SIGINT'); } catch {}
|
||||
const pid = child.pid;
|
||||
killTimer = setTimeout(() => { try { process.kill(-pid, 'SIGKILL'); } catch {} }, 5000);
|
||||
killTimer.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
||||
function run(command, args, env = process.env) {
|
||||
if (stopping) throw new Error('Startup cancelled.');
|
||||
const proc = spawn(command, args, { cwd: root, env, stdio: 'inherit', detached: process.platform !== 'win32' });
|
||||
child = proc;
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
proc.once('error', reject);
|
||||
proc.once('exit', (code, signal) => {
|
||||
clearTimeout(killTimer); if (child === proc) child = null;
|
||||
if (code === 0 || stopping) resolve(); else reject(new Error(`${command} failed (${signal || code}).`));
|
||||
});
|
||||
});
|
||||
return { proc, completion };
|
||||
}
|
||||
try {
|
||||
const cacheFile = join(root, '.webka-cache.json');
|
||||
let cache = {};
|
||||
try { cache = JSON.parse(readFileSync(cacheFile, 'utf8')); } catch {}
|
||||
const deps = createHash('sha256').update(readFileSync(join(root, 'frontend/package-lock.json'))).update(readFileSync(join(root, 'frontend/package.json'))).update(`${process.platform}-${process.arch}-${major}`).digest('hex');
|
||||
if (cache.dependencies !== deps || !existsSync(join(root, 'frontend/node_modules/nuxt/package.json'))) {
|
||||
console.log('\nInstalling frontend dependencies (first run or changed lockfile)…');
|
||||
await run(process.execPath, [process.env.npm_execpath, '--prefix', 'frontend', 'ci']).completion;
|
||||
if (stopping) return;
|
||||
cache = { dependencies: deps }; writeFileSync(cacheFile, JSON.stringify(cache));
|
||||
}
|
||||
const source = fingerprint([join(root, 'frontend')]);
|
||||
if (cache.frontend !== source || !existsSync(join(root, 'frontend/.output/public/index.html'))) {
|
||||
console.log('\nBuilding the frontend…');
|
||||
await run(process.execPath, [process.env.npm_execpath, '--prefix', 'frontend', 'run', 'build']).completion;
|
||||
if (stopping) return;
|
||||
cache.frontend = source; writeFileSync(cacheFile, JSON.stringify(cache));
|
||||
} else console.log('\nFrontend is up to date.');
|
||||
console.log('\nPreparing the Rust server…');
|
||||
await run('cargo', ['build', '--locked', '--manifest-path', 'backend/Cargo.toml']).completion;
|
||||
if (stopping) return;
|
||||
// Locate Cargo's actual target directory, including CARGO_TARGET_DIR overrides.
|
||||
const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1', '--manifest-path', 'backend/Cargo.toml'], { cwd: root, encoding: 'utf8' });
|
||||
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 });
|
||||
// Observe exit failures immediately while checking readiness.
|
||||
let failure;
|
||||
completion.catch(e => { failure = e; });
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
|
||||
if (failure) throw failure;
|
||||
if (proc.exitCode !== null) throw new Error('Server exited before becoming ready.');
|
||||
try {
|
||||
const response = await fetch(`${urls.local}/api/bootstrap`, { signal: AbortSignal.timeout(1000) });
|
||||
if (response.ok) { ready = true; break; }
|
||||
} catch {}
|
||||
await delay(200);
|
||||
}
|
||||
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}`);
|
||||
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();
|
||||
process.off('SIGINT', stop); process.off('SIGTERM', stop);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch(error => { console.error(`\nWebka: ${error.message}`); process.exitCode = 1; });
|
||||
}
|
||||
28
scripts/dev-all.test.mjs
Normal file
28
scripts/dev-all.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseArgs, addresses } from './dev-all.mjs';
|
||||
|
||||
test('default and named host/port options', () => {
|
||||
assert.deepEqual(parseArgs([]), { host: '127.0.0.1', port: 3000 });
|
||||
assert.deepEqual(parseArgs(['--host', '0.0.0.0', '--port', '3002']), { host: '0.0.0.0', port: 3002 });
|
||||
assert.deepEqual(parseArgs(['--port=3002', '--host=localhost']), { host: '127.0.0.1', port: 3002 });
|
||||
for (const args of [['--port', '0'], ['--port', '65536'], ['--port', '3abc'], ['--host', 'evil.example'], ['--host'], ['—port', '3002'], ['--unknown']]) assert.throws(() => parseArgs(args));
|
||||
});
|
||||
const networks = { en0: [{ address: '192.168.1.23', family: 'IPv4', internal: false }], other: [{ address: '8.8.8.8', family: 'IPv4', internal: false }], lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }] };
|
||||
test('LAN listener keeps camera-capable loopback and explicit LAN origins', () => {
|
||||
const result = addresses({ host: '0.0.0.0', port: 3002 }, networks, {});
|
||||
assert.equal(result.origin, 'http://192.168.1.23:3002');
|
||||
assert.equal(result.local, 'http://127.0.0.1:3002');
|
||||
assert.deepEqual(result.allowed.sort(), ['http://192.168.1.23:3002', 'http://127.0.0.1:3002', 'http://localhost:3002'].sort());
|
||||
assert.ok(!result.allowed.some(origin => origin.includes('0.0.0.0') || origin.includes('8.8.8.8')));
|
||||
});
|
||||
test('loopback default and IPv6 formatting', () => {
|
||||
assert.equal(addresses(parseArgs([]), networks, {}).origin, 'http://localhost:3000');
|
||||
assert.deepEqual(addresses({ host: '::1', port: 3002 }, {}, {}).allowed, ['http://localhost:3002', 'http://[::1]:3002']);
|
||||
});
|
||||
test('explicit HTTPS origin does not silently authorize HTTP aliases', () => {
|
||||
const result = addresses({ host: '0.0.0.0', port: 3002 }, networks, { WEBKA_ORIGIN: 'https://webka.local' });
|
||||
assert.deepEqual(result.allowed, ['https://webka.local']);
|
||||
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ORIGIN: 'https://webka.local/login' }));
|
||||
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ALLOWED_ORIGINS: '*' }));
|
||||
});
|
||||
92
scripts/test-launcher.py
Normal file
92
scripts/test-launcher.py
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""POSIX integration test for the actual npm launcher, including teardown."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
origin = 'http://127.0.0.1:3002'
|
||||
|
||||
def stop(proc):
|
||||
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 on Ctrl+C')
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='webka-launcher-') as temporary:
|
||||
data = Path(temporary, 'data')
|
||||
env = dict(os.environ, WEBKA_DATA=str(data), WEBKA_MDNS='0')
|
||||
for key in ['WEBKA_ORIGIN', 'WEBKA_ALLOWED_ORIGINS']:
|
||||
env.pop(key, None)
|
||||
for iteration in range(2):
|
||||
logfile = Path(temporary, f'launch-{iteration}.log')
|
||||
with logfile.open('w') as output:
|
||||
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--host', '0.0.0.0', '--port', '3002'], 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 timed out: ' + logfile.read_text()[-2000:])
|
||||
if iteration == 1:
|
||||
assert 'Frontend is up to date.' in logfile.read_text()
|
||||
assert 'Installing frontend dependencies' not in logfile.read_text()
|
||||
for route in ['/login', '/host', '/watch', '/settings/accounts', '/settings/general']:
|
||||
with urllib.request.urlopen(origin + route, timeout=3) as response:
|
||||
assert response.status == 200
|
||||
with urllib.request.urlopen(origin + '/api/bootstrap') as response:
|
||||
assert json.load(response)['needs_setup'] is (iteration == 0)
|
||||
body = json.dumps({'username': 'owner', 'password': 'launch-test-password-123', 'setup_token': (data / 'setup-token').read_text()}).encode()
|
||||
if iteration == 0:
|
||||
req = urllib.request.Request(origin + '/api/bootstrap', data=body, headers={'Origin': origin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 201
|
||||
req = urllib.request.Request(origin + '/api/login', data=body, headers={'Origin': origin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
cookie = response.headers['Set-Cookie'].split(';')[0]
|
||||
req = urllib.request.Request(origin + '/api/status', headers={'Cookie': cookie})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
status = json.load(response)
|
||||
assert ':3002/watch' in status['local_url']
|
||||
lan_origin = status['local_url'].removesuffix('/watch')
|
||||
req = urllib.request.Request(origin + '/api/login', data=body, headers={'Origin': lan_origin, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req) as response:
|
||||
assert response.status == 200
|
||||
req = urllib.request.Request(origin + '/api/logout', data=b'{}', headers={'Origin': 'http://evil.example', 'Content-Type': 'application/json', 'Cookie': cookie})
|
||||
try:
|
||||
urllib.request.urlopen(req)
|
||||
raise AssertionError('Untrusted origin accepted')
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == 403
|
||||
finally:
|
||||
stop(proc)
|
||||
# Assert that the actual server, not just npm, released the listener.
|
||||
with socket.socket() as server:
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(('0.0.0.0', 3002))
|
||||
print(f'PASS: launch {iteration + 1}, routes, local/LAN auth, QR port, rejected foreign origin, Ctrl+C cleanup.', flush=True)
|
||||
with socket.socket() as occupied:
|
||||
occupied.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
occupied.bind(('127.0.0.1', 3002))
|
||||
occupied.listen()
|
||||
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--port', '3002'], cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=20)
|
||||
assert proc.returncode != 0 and 'already in use' in stderr, stdout + stderr
|
||||
finally:
|
||||
stop(proc)
|
||||
print('PASS: occupied port fails before building or starting a server.', flush=True)
|
||||
Reference in New Issue
Block a user