Turn an old phone into a local WebRTC camera for the Knoxnet VMS — no cloud, no third-party video services, just a mobile-first web app and a small Node.js receiver running on your LAN.
Free open source software under the Apache License 2.0. The phone web app can be hosted publicly as a static site, while signaling, WebRTC media, RTSP, and pairing stay on your local network.
This repo contains:
web/— React + Vite + TypeScript mobile web app (the camera UI).receiver/— Node.js + Express + WebSocket signaling server with a dashboard for accepting cameras and previewing their live streams.bridge/— standalone Node.js bridge that manages MediaMTX, allocates local RTSP paths, and relays accepted browser-camera WebRTC offers into MediaMTX via WHIP.docs/— security model + Knoxnet VMS integration plan.
Publishing guides:
- Cloudflare Pages hosting for
https://cam.knoxnetvms.com. - Phone-to-phone Direct View using Cloudflare Durable Objects for signaling only.
- Cloudflare signaling security for the Direct View Worker route and validation model.
- WireGuard remote camera mode for phones away from the receiver LAN.
- GitHub publishing and releases.
- Knoxnet VMS sidecar/plugin model.
Browsers cannot:
- Expose RTSP / ONVIF directly. The phone remains a browser WebRTC publisher.
The optional local
bridge/service receives that WebRTC flow through MediaMTX WHIP and exposes RTSP on the desktop/NVR host. - Read the device IP / MAC, or assign DHCP/static IP on the phone. The Network page surfaces this honestly.
- Run
getUserMediaover plainhttp://on most phones — see the HTTPS caveat below.
┌────────────────────┐ WebSocket signaling ┌────────────────────┐
│ Phone browser │ ◀────────────────────▶ │ receiver/ │
│ (web/, the cam) │ │ pairing dashboard │
└─────────┬──────────┘ └─────────┬──────────┘
│ WebRTC media after operator accepts │ BRIDGE_URL
▼ ▼
┌────────────────────┐ WHIP ingest ┌────────────────────┐
│ MediaMTX │ ◀───────────────────── │ bridge/ │
│ local restreamer │ │ path/API wrapper │
└─────────┬──────────┘ └────────────────────┘
│
▼
rtsp://<host>:8554/<camera-slug> → Knoxnet VMS or any RTSP client
- Node.js ≥ 20 and npm ≥ 10.
- A phone + a desktop on the same Wi-Fi, or a WireGuard VPN where the phone can reach the receiver.
cd /home/operator1/Documents/knoxnet-browser-cam
npm install
npm run local:cloud-phoneFor normal local use, start the bridge + receiver and let the QR open the Cloudflare-hosted phone app:
npm run local:cloud-phoneThat QR opens https://cam.knoxnetvms.com, but embeds a local receiver URL such
as wss://<lan-or-vpn-ip>:8787/ws. Cloudflare serves only the phone app shell;
signaling, video, bridge, and RTSP stay direct to your receiver/bridge over LAN
or WireGuard. Because the phone app is HTTPS, use WSS for the receiver.
For active development of the phone app, use npm run dev:all. It starts the
RTSP bridge, the receiver with HTTPS/WSS signaling, and the HTTPS Vite phone app
together:
| Process | Default URL | Purpose |
|---|---|---|
| bridge | http://localhost:8790 | RTSP path API + bridge dashboard |
| receiver | https://:8787 | Dashboard + WSS signaling + /api |
| web | https://:5173 | The phone-side UI (Vite HTTPS) |
On this development machine, MediaMTX is available at:
MEDIAMTX_BINARY="/home/operator1/Documents/Knoxnet-VMS/mediamtx/mediamtx" npm run dev:allThe bridge also auto-detects that path. Run npm run doctor if RTSP does not
come up; an RTSP URL is playable only after the bridge ingest status says
publishing.
Open the receiver dashboard on the desktop:
https://<lan-ip>:8787/The browser may warn about the local self-signed certificate. Accept it for the
desktop dashboard and for the phone app origin. On iPhone, also open
https://<lan-ip>:8787/ in Safari once and accept the receiver certificate
before scanning the QR; otherwise Safari may allow the camera app on :5173
while still rejecting the wss://<lan-ip>:8787/ws signaling upgrade. This is
local dev TLS only.
Before scanning, you can confirm that the QR points at the phone app, not the
dashboard:
curl -k https://<lan-ip>:8787/api/info | jq .phonePairingUrlYou can smoke-test the receiver's local HTTPS/WSS upgrade from the desktop with:
npm run test:wssThe test starts a temporary WSS receiver on a high localhost port. Set
WSS_SMOKE_PORT=18878 first if you want to force a specific port.
For a receiver + web app without the RTSP bridge:
npm run dev:httpsFor just the receiver with the Cloudflare-hosted phone app:
npm run receiver:cloud-phoneFor just the receiver with local Vite phone-app URL defaults:
npm run receiver:dev-phoneFor desktop-only HTTP testing:
npm run devOn boot, the receiver prints its pairing code and a pairing URL (plus an ASCII QR). Example log:
[receiver] Knoxnet browser-cam receiver listening on https://0.0.0.0:8787
[receiver] WebSocket signaling at wss://192.168.1.42:8787/ws
[receiver] Pairing code (one-time print): X7K9PA
[receiver] Phone app URL: https://cam.knoxnetvms.com
[receiver] Pairing URL: https://cam.knoxnetvms.com/?receiver=wss%3A%2F%2F192.168.1.42%3A8787%2Fws&pair=X7K9PA&autostart=1
[receiver] Scan this URL with the iPhone Camera app to open the phone app.
[receiver] Dashboard: https://192.168.1.42:8787/
[receiver] From now on, the pairing code is redacted: X****A
ip addr show | awk '/inet 192\.|inet 10\.|inet 172\./{print $2}'
# or
ifconfig | grep -E 'inet (192|10|172)\.'Use that address as <lan-ip> everywhere below.
- On desktop: run
npm run local:cloud-phonefor normal use, ornpm run dev:allwhen editingweb/. Then open the receiver dashboard. The dashboard shows the phone app origin, the embedded receiver WSS URL, and the dashboard URL separately. - On iPhone: open
https://cam.knoxnetvms.comand scan the dashboard QR with the in-app scanner, or scan it with the native Camera app. It openshttps://cam.knoxnetvms.com/?receiver=wss://<lan-ip>:8787/ws&pair=<code>&autostart=1for normal use, orhttps://<lan-ip>:5173/...in dev mode. - Phone: if the receiver uses a self-signed local TLS certificate, open
https://<lan-ip>:8787/in Safari once and accept it before scanning. Then open/return to the QR URL and allow camera permission. The app fills the receiver URL and pairing code automatically and tries to start streaming. If permission was blocked, allow camera access in the browser prompt or Settings, then tap Retry camera access. - Desktop dashboard: click Accept on the pending camera. With
npm run dev:all, the receiver already hasBRIDGE_URL=http://localhost:8790, so it allocates a MediaMTX path and shows the RTSP URL after accept. - Trust for fast reconnect: after the first successful pair, click
Trust this device on the receiver dashboard. The phone keeps a harmless
browser-generated
deviceIdin localStorage; the receiver stores only that ID, display name, timestamps, trust/auto-accept flags, and last session inreceiver/data/known-devices.jsonby default. No secrets or media are stored. - RTSP clients / Knoxnet VMS: trust the device, wait until the dashboard
shows bridge ingest
publishing, then add the displayed Stable RTSP URL / NVR URL (rtsp://<host>:8554/<camera-slug>) to Knoxnet VMS, VLC, or your NVR. The phone still does not host RTSP; MediaMTX does.
On reconnect, trusted known devices still need the current pairing code but can
move from hello to accepted/streaming without another manual dashboard click
when AUTO_ACCEPT_KNOWN=true. The phone keeps a separate desired-streaming
state after Start is tapped, automatically retries dropped WebSocket/WebRTC/WHIP
connections with capped backoff, and keeps trying until Stop is tapped.
For VMS/NVR recording, use the dashboard's Stable RTSP URL / NVR URL after
the phone is trusted. The bridge keys that RTSP path by the browser-generated
deviceId when available instead of the transient receiver session id, so phone
reconnects republish into the same MediaMTX path whenever possible.
During a phone Wi-Fi drop, app background, receiver restart, or bridge outage,
the phone keeps retrying while Start remains active. The dashboard may show the
RTSP path as recovering or offline; clients may see temporary stream loss,
but they should retry the same URL. The bridge retains offline paths for
RTSP_PATH_GRACE_MS (default 10 minutes) before cleanup so VLC/Knoxnet VMS/NVR
configs do not churn during normal reconnects.
For best long-running results:
- Keep the phone on wired power and on the same Wi-Fi as the receiver/bridge.
- Keep the screen awake. Wake Lock is requested automatically where browsers support it, but iOS may still suspend background tabs or drop locks.
- Run the bridge/receiver under a supervisor such as systemd or PM2 for production use.
- Watch thermal throttling on older phones; avoid direct sun and remove thick cases if the phone gets hot.
- Older phones are usually happiest at
720p,15fps, and2 Mbps. - MediaMTX is used as a publisher-backed RTSP path here. No placeholder always-on video source is currently configured, so RTSP clients may disconnect during upstream loss; configure clients/NVRs to retry the same stable URL.
The bridge is intentionally separate from Knoxnet VMS:
cd /home/operator1/Documents/knoxnet-browser-cam
npm run bridge
curl http://localhost:8790/api/healthOpen the standalone bridge dashboard at:
http://<host>:8790/
The dashboard is a dense dark/green camera list for bridge-managed RTSP paths.
It shows each camera name, session/path, ingest status, RTSP URL, last-seen
time, copy/remove actions, and a detail preview panel. Browser previews are
honest: browsers cannot play RTSP directly, so the bridge only embeds a live
preview when MediaMTX WebRTC egress is configured and the path is publishing.
The generated MediaMTX config enables WebRTC on MEDIAMTX_WEBRTC_PORT and the
dashboard uses http://<BRIDGE_PUBLIC_HOST>:8889/<camera-path> as the preview
candidate. If that URL is unavailable, the dashboard shows a placeholder and
instructions to use VLC/Knoxnet VMS with the RTSP URL or enable MediaMTX
WebRTC/HLS egress.
Important bridge environment variables:
| Var | Default | Purpose |
|---|---|---|
BRIDGE_HOST |
127.0.0.1 |
Bridge REST API bind address. Keep local unless another trusted host must allocate paths. |
BRIDGE_HTTP_PORT |
8790 |
Bridge REST API port. |
BRIDGE_PUBLIC_HOST |
auto-detected LAN IP | Host placed in generated RTSP URLs. |
MEDIAMTX_BINARY |
mediamtx |
MediaMTX executable to spawn. |
MEDIAMTX_MANAGE_PROCESS |
true |
Set false to point at an already-running MediaMTX. |
MEDIAMTX_BIND_HOST |
0.0.0.0 |
MediaMTX RTSP/WebRTC bind host. |
MEDIAMTX_RTSP_PORT |
8554 |
RTSP egress port. |
MEDIAMTX_WEBRTC_PORT |
8889 |
MediaMTX WHIP/WHEP HTTP port. |
MEDIAMTX_API_PORT |
9997 |
MediaMTX local control API port. |
RTSP_PATH_GRACE_MS |
600000 |
How long the bridge retains an offline stable RTSP path before cleanup. |
RTSP_AUTH_REQUIRED |
true |
Require MediaMTX read credentials for RTSP/NVR clients. Set false only for closed-network development. |
RTSP_USERNAME |
knoxnet |
Username for RTSP/NVR read access. |
RTSP_PASSWORD |
generated | Password for RTSP/NVR read access. If omitted, the bridge generates one on first start. |
RTSP_PASSWORD_FILE |
<BRIDGE_RUNTIME_DIR>/rtsp-password |
File used to persist the generated password with restrictive permissions. |
VMS_INTEGRATION_TOKEN |
generated | Receiver API bearer token for Knoxnet VMS calls to /api/vms/v1/*. |
VMS_INTEGRATION_TOKEN_FILE |
<RECEIVER_DATA_DIR>/vms-integration-token |
File used to persist the generated VMS integration token. |
VMS_MANAGED_MODE |
false |
Set true when Knoxnet VMS owns pairing/trust/RTSP management; the dashboard shows a managed-mode banner. |
Current RTSP status: the bridge implements stable path allocation,
MediaMTX config generation/process management, receiver-side WHIP relay, and
dashboard Stable RTSP URL / NVR URL display. RTSP read authentication is enabled
by default; the dashboard shows redacted URLs such as
rtsp://knoxnet:****@<bridge-host>:8554/<camera-slug> and provides separate
copy actions for credentialed NVR URLs and URLs without embedded credentials.
If RTSP_PASSWORD is not set, the first bridge start generates a strong random
password, writes it to RTSP_PASSWORD_FILE, and logs it once. Rotate credentials
by setting a new RTSP_PASSWORD or stopping the bridge, deleting the generated
password file, and starting the bridge again. Knoxnet VMS can also use the
authenticated receiver API to inspect auth state, rotate generated credentials,
and fetch credentialed RTSP URLs without exposing credentials to the phone app.
The receiver marks cameras
negotiating while the WHIP offer is being posted to MediaMTX and only marks
the bridge publishing after MediaMTX returns an answer. A complete end-to-end
RTSP stream requires a MediaMTX binary available on PATH, in
/home/operator1/Documents/Knoxnet-VMS/mediamtx/mediamtx, or via
MEDIAMTX_BINARY. WHIP trickle-ICE PATCH support is marked as follow-up in code.
Never expose RTSP directly to the internet; use WireGuard or another private VPN
for remote NVR access.
Knoxnet VMS integration API: start receiver/bridge as usual, then call
/api/vms/v1/* on the receiver with
Authorization: Bearer <VMS_INTEGRATION_TOKEN>. If the token env var is omitted,
the receiver generates one on first start, saves it to
receiver/data/vms-integration-token, and logs it once. See
docs/knoxnet-vms-integration.md for the
endpoint list covering status, cameras, events, accept/trust, device settings,
cleanup, RTSP credential lifecycle, credentialed RTSP URLs, and MediaMTX/WHIP
diagnostics.
https://cam.knoxnetvms.com is the default phone app URL for normal receiver
pairing. This is useful because users do not need to run or trust a local Vite
HTTPS app just to open the camera UI on an iPhone.
The hosted app is the front door for two flows: Local VMS Camera, which pairs
this phone with your local receiver/bridge for Knoxnet or RTSP, and Direct
View, which is phone-to-phone browser viewing. Its in-app QR scanner recognizes
both receiver pairing URLs (?receiver=...&pair=...) and Direct View joins
(/join/<roomToken>). The app also links to the GitHub repository so users can
download/run the local receiver that creates the pairing QR and RTSP bridge.
What it does:
- Serves the static phone app shell over a trusted public HTTPS origin.
- Lets the receiver QR embed a LAN or VPN WSS URL such as
wss://10.44.0.1:8787/ws. - Keeps pairing, signaling, WebRTC media, bridge ingest, and RTSP local/direct.
What it does not do:
- It does not relay video or WebSocket signaling through Cloudflare.
- It does not make a receiver behind NAT reachable by itself.
- It does not make RTSP safe to expose to the public internet.
For phones away from the LAN, use the receiver dashboard's Remote Camera Setup
(WireGuard) wizard first: it generates configs, copy-paste receiver commands,
the WireGuard peer QR, and a separate Knoxnet VPN pairing QR. The phone then
joins the private VPN and reaches the receiver at a VPN IP, for example
wss://10.44.0.1:8787/ws. See
docs/wireguard-remote-camera.md for manual
fallback instructions.
Most phone browsers refuse getUserMedia on plain http://<lan-ip> (only
http://localhost is exempt). Pick one workaround:
- On the phone, open
chrome://flags/#unsafely-treat-insecure-origin-as-secure. - Add
http://<lan-ip>:5173(or:8787, whichever you'll use) to the list. - Relaunch Chrome on the phone.
The root script starts the receiver with WSS and Vite HTTPS together:
# from repo root
npm run dev:httpsIt creates a local self-signed cert in .cert/ using openssl, then runs the
receiver and Vite dev server with that cert. On the phone, scan the dashboard QR
or visit https://<lan-ip>:5173/?receiver=wss://<lan-ip>:8787/ws&pair=….
Accept the certificate warning. The receiver and WebRTC media path remain on
your LAN; no cloud video service is introduced.
Use something like caddy with --internal CAs, or mkcert + nginx, to
front both ports with a single HTTPS origin. Out of scope here; see Caddy's
tls internal docs.
| Script | What it does |
|---|---|
npm install |
Installs all workspaces (web + receiver + bridge). |
npm run dev |
Runs receiver and Vite dev server over HTTP for desktop-only development. |
npm run dev:all |
Active phone-app development: bridge + WSS receiver + HTTPS Vite app. |
npm run dev:cert |
Creates/reuses .cert/knoxnet-dev.* for local HTTPS/WSS. |
npm run bridge |
Just the bridge (tsx watch src/server.ts). |
npm run dev:bridge |
Alias for the bridge dev server. |
npm run doctor |
Prints MediaMTX detection and RTSP health check commands. |
npm run dev:https |
Runs WSS receiver and HTTPS Vite app without the RTSP bridge. |
npm run receiver:dev-phone |
Just the WSS receiver with local Vite phone-app URL defaults for https://<lan-ip>:5173. |
npm run receiver:cloud-phone |
Just the WSS receiver with QR URLs opening https://cam.knoxnetvms.com. |
npm run local:cloud-phone |
Bridge + WSS receiver for local/VPN use with the Cloudflare-hosted phone app. |
npm run receiver |
Just the receiver (tsx watch src/server.ts); normal QR URLs open https://cam.knoxnetvms.com unless overridden. |
npm run web |
Just the Vite dev server on :5173. |
npm run build:web |
Builds only the static phone web app into web/dist. |
npm run deploy:pages |
Deploys web/dist to Cloudflare Pages project knoxnet-browser-cam without rebuilding. |
npm run build |
tsc --noEmit + vite build for web/; tsc -p + static-copy for receiver. |
npm run test:urls |
Checks receiver URL builders so QR pairing stays on the phone app origin. |
npm run test:wss |
Starts a temporary TLS receiver and verifies the localhost WSS upgrade accepts hello. |
npm run typecheck |
Type-check both workspaces without emitting. |
npm start |
Runs the compiled receiver from receiver/dist. |
| Var | Default | Purpose |
|---|---|---|
PORT |
8787 |
HTTP + WS port |
HOST |
0.0.0.0 |
Bind address |
PUBLIC_HOST |
auto-detected LAN IP | Hostname printed into the pairing URL and /api/info |
PHONE_APP_URL |
https://cam.knoxnetvms.com |
Full phone web app origin. Set to https://<lan-ip>:5173 for local Vite or another hosted app. |
PHONE_APP_ENV |
unset | Set dev to derive a local Vite app URL from PHONE_APP_SCHEME://PUBLIC_HOST:PHONE_APP_PORT. |
PHONE_APP_SCHEME |
https when WSS=true, else http in dev mode |
Scheme used for the derived local phone app URL. |
PHONE_APP_PORT |
5173 in dev mode |
Port used for the derived local phone app URL. |
WSS / HTTPS |
false |
If true, receiver serves HTTPS dashboard and WSS signaling using TLS_KEY_PATH / TLS_CERT_PATH. |
TLS_KEY_PATH |
.cert/knoxnet-dev.key |
TLS key used when WSS=true. |
TLS_CERT_PATH |
.cert/knoxnet-dev.crt |
TLS certificate used when WSS=true. |
PAIRING_CODE |
random | Override the random code (e.g. for tests). Stored uppercased. |
AUTO_ACCEPT_KNOWN |
true |
Auto-accept dashboard-trusted known devices after they present the current pairing code. |
AUTO_ACCEPT_ALL |
false |
If true, any camera with the current pairing code is auto-accepted. Closed-network test rigs only. |
AUTO_ACCEPT |
false |
Legacy alias for AUTO_ACCEPT_ALL=true. Prefer the explicit flags above. |
STALE_CAMERA_TTL_MS |
300000 |
Pending/disconnected sessions older than this are removed; known-device records are kept. |
RECEIVER_DATA_DIR |
receiver/data |
Local metadata directory for known devices. Gitignored. |
KNOWN_DEVICES_PATH |
RECEIVER_DATA_DIR/known-devices.json |
Override the known-device JSON file path. |
RECEIVER_NAME |
<hostname>-knoxnet-receiver |
Display name surfaced in /api/info and on the phone status row. |
BRIDGE_URL |
unset | Optional bridge API URL, e.g. http://localhost:8790, used to allocate RTSP paths and relay WHIP offers. |
The frontend is served from the static public origin
https://cam.knoxnetvms.com by default and still works LAN/VPN-only for media:
- The static frontend is just HTML+JS — it has no server-side logic.
- The phone's
?receiver=wss://<local-or-vpn-ip>:8787/ws&pair=<code>parameter directs WebRTC signaling at the receiver. Media never traverses the Cloudflare Pages host. - Pairing codes and operator acceptance still gate every connection.
- Remote cameras need a reachable private path such as WireGuard. Cloudflare Pages alone does not make a receiver reachable from the internet.
- Cloudflare Pages deployment commands and custom-domain setup are documented
in
docs/cloudflare-pages.md. - GitHub repo/release commands are documented in
docs/github-release.md.
- Package the standalone
bridge/service into Knoxnet VMS and manage its lifecycle/config there — seedocs/knoxnet-vms-integration.md. - Multi-camera per receiver (already supported by the data model; UI exists, needs polish under load).
- Optional TURN server for when phone/desktop aren't on the same subnet.
See docs/security.md.