Add MediaMTX video bridge: pinned installer and supervised localhost process
- npm run video:install downloads the pinned MediaMTX v1.21.0 for this platform, verifies its SHA-256 against checksums committed in src/lib/mediamtx-install.ts, and installs it into gitignored bin/. - On server start, instrumentation launches MediaMTX with a generated owner-only config: API and WebRTC signaling on 127.0.0.1, only the ICE port (UDP 8189) on the LAN, a hashed per-boot API password, no anonymous users, unused protocols off. Restarts on crash with backoff, stops with the server; VIDEO_BRIDGE=off skips it. - Camera streams are added to MediaMTX at runtime, on demand, with the stored login; nothing with a password is written to disk or echoed. - rtspSourceWithLogin() reads a profile's RTSP URI over ONVIF. - Export vrek log (live video goal, decisions, codec finding). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c9270c7c0
commit
5824db8ea9
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,6 +18,8 @@
|
||||
/out/
|
||||
# stray build cache from a build run one level up
|
||||
/cameras/
|
||||
# MediaMTX binary installed by `npm run video:install` (vrek dec-xkn4z0e)
|
||||
/bin/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@ -710,3 +710,94 @@
|
||||
{"id":"evt-pvn59g6yvahc","type":"node.updated","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"target":1,"direction":"up","unit":"observation"}},"at":"2026-09-19T20:09:20.114Z","parents":["evt-se02cx1hnx2m"],"hash":"17f6bf3cbc5e7e08bc700192f5a0cb8093170ac3eac39e970979d31fe2348920"}
|
||||
{"id":"evt-5cca5zknswf6","type":"node.created","subject":"mea-dd2b0x7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"User observed the read-only camera settings and stream URLs working","body":"One observation by the user, in their own browser on 2026-09-19: they checked the per-camera settings page (streams with copyable RTSP URLs without credentials, image, time and network sections) and the dashboard summary, and reported it working (\"checked, looks good\").","status":"recorded","owner":null,"attrs":{"value":1,"applies_at":"2026-09-19"}},"at":"2026-09-19T20:09:21.856Z","parents":["evt-pvn59g6yvahc"],"hash":"4dff0305499dac5f03120673f1f9c8137e451bc81fc3b40f6e07074e32092a0a"}
|
||||
{"id":"evt-5p47w1dkzhx0","type":"edge.added","subject":"mea-dd2b0x7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-dd2b0x7","to":"gol-2nxgqjn"},"at":"2026-09-19T20:09:21.857Z","parents":["evt-5cca5zknswf6"],"hash":"9a13324d2983b2c6510a13b10497d65d2d6cd2247dc11d39ee3123f079ba5786"}
|
||||
{"id":"evt-c6ya332v04s4","type":"node.updated","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":"2026-09-20","deferred_because":"User deferred on 2026-09-19: revisit if related work comes up, or tomorrow morning at the latest."}}},"at":"2026-09-19T20:12:41.490Z","parents":["evt-5p47w1dkzhx0"],"hash":"8320f0be3e0dbeb75282895299f1fb283edb3dc23cb3a4ab21aacb2901941c1f"}
|
||||
{"id":"evt-bt77t8b5j2qx","type":"node.status_changed","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T20:12:41.491Z","parents":["evt-c6ya332v04s4"],"hash":"c5ccc9048ff1e9d5a1f8a6c03738424b28744124045385c39016b8ed176c21d0"}
|
||||
{"id":"evt-d4nw4d55xcke","type":"node.updated","subject":"iss-hg50epa","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":"2026-09-20","deferred_because":"User deferred on 2026-09-19: revisit if related work comes up, or tomorrow morning at the latest."}}},"at":"2026-09-19T20:12:43.295Z","parents":["evt-bt77t8b5j2qx"],"hash":"19605fd01bbdd1e714ab151aef49994a40964e64ded39748a45595e325e7f540"}
|
||||
{"id":"evt-ehjwyfy6529d","type":"node.status_changed","subject":"iss-hg50epa","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T20:12:43.297Z","parents":["evt-d4nw4d55xcke"],"hash":"f28bc24f279ed69e5c3cb5549740535a14094df673b55241f914f7bc323a9a11"}
|
||||
{"id":"evt-4nwm0qzxt2n4","type":"node.updated","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":"2026-09-20","deferred_because":"User deferred on 2026-09-19: revisit if related work comes up, or tomorrow morning at the latest."}}},"at":"2026-09-19T20:12:44.848Z","parents":["evt-ehjwyfy6529d"],"hash":"1bbf688c0f0ebe0e244f32227eb98e3db3027fc8b96737d851458eeb57458a41"}
|
||||
{"id":"evt-m1t2n80t4765","type":"node.status_changed","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T20:12:44.849Z","parents":["evt-4nwm0qzxt2n4"],"hash":"4f079b88730fb162146777937e75bb917b109d0c61823776ab98e4ac9682bda9"}
|
||||
{"id":"evt-cmthprybhxc3","type":"node.created","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Enable secure live video streaming and recording","body":"Watch each camera's live video in the browser at full stream quality, and record video, without exposing cameras or their logins. Why now: fnd-dw1pqcw and fnd-k1jyt83 show the still snapshots top out at 1200×536 while the main stream is 4096×1860, so full-detail viewing needs the video stream itself. \"Secure\" follows the existing principles: video reaches the browser only through this app, behind the admin session (pri-m1csgrm); camera credentials never leave the server and are never embedded in a URL the browser sees (pri-tyrxdz9); cameras are addressed by id from the registry, never by a client-supplied host. Browsers can't play RTSP directly, so this needs a server-side bridge (for example remuxing RTSP H.264/H.265 to WebRTC, fMP4 over WebSocket, or HLS) or an external media server; any new package or binary goes through a dependency question first (pri-mz2jxpb). Recording raises its own questions: where files live, retention and disk limits, continuous or event-triggered, whether recording runs on this server or on the cameras' SD cards or an NVR, and who can view or delete recordings. Scope, priority (weight) and a measurable target are not decided yet. Created by the user on 2026-09-19.","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T20:13:39.094Z","parents":["evt-m1t2n80t4765"],"hash":"d2776d775f7e834b409ecd46dbc395153fed3a194af4d564b7d75e247b027c12"}
|
||||
{"id":"evt-bp1pckjmt17c","type":"edge.added","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-sxakryh","to":"gol-6q1q5mr"},"at":"2026-09-19T20:13:39.097Z","parents":["evt-cmthprybhxc3"],"hash":"289a279de58c9a7b488e7e2964a9c94ba9af4dd81de962eae3cb6dc57a0c5d71"}
|
||||
{"id":"evt-xkqkhvy7q100","type":"edge.added","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-sxakryh","to":"pri-tyrxdz9"},"at":"2026-09-19T20:13:39.098Z","parents":["evt-bp1pckjmt17c"],"hash":"6f59edcc571f427ed15cb90affd9ea3421671978167ae1382c1bd5733cbc98ff"}
|
||||
{"id":"evt-3a720kgxps99","type":"edge.added","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-sxakryh","to":"pri-m1csgrm"},"at":"2026-09-19T20:13:39.099Z","parents":["evt-xkqkhvy7q100"],"hash":"e3ef4dda938f0014d2dfcc2a006070e95fa6adb1b167c6c1b0d43b685e3d1109"}
|
||||
{"id":"evt-y58jsd9yckz2","type":"edge.added","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-sxakryh","to":"pri-mz2jxpb"},"at":"2026-09-19T20:13:39.100Z","parents":["evt-3a720kgxps99"],"hash":"1b922906dbcb2479a85b4d89cbaf3d10da3f41469b9bba4b587d23821e342057"}
|
||||
{"id":"evt-3rcgvzeq636m","type":"node.updated","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":1}},"at":"2026-09-19T20:17:01.981Z","parents":["evt-y58jsd9yckz2"],"hash":"20edb17a83a64f10e7ea0278d249368670e4634fc7d44179ee62e3926dc61762"}
|
||||
{"id":"evt-jgnx569r7qpj","type":"node.created","subject":"dec-9xrjx82","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Live video: build live viewing first; design recording after it works, and when recording, record on motion/events","body":"User's answers on 2026-09-19 while analysing gol-sxakryh. Storage: live view first. Recording stays in the goal but isn't designed until live viewing works. Considered and set aside for now: recording to this server's own folder, camera SD cards, and an existing NVR. Rough sizing that informed this: the 4096×1860 main stream at about 5 Mbps is about 54 GB per camera per day continuous, and the 1200×536 sub stream at about 0.75 Mbps is about 8 GB per day, while the dev Mac has 72 GB free (92% full). What to record, once recording is designed: motion or camera events only, with pre- and post-roll, rather than continuous or manual clips. That needs the cameras' event feed (ONVIF events or ISAPI alertStream). Priority: the goal's weight is 1, the same as gol-6q1q5mr.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:17:05.372Z","parents":["evt-3rcgvzeq636m"],"hash":"9c2ce8e982a40f36fe1dba766e71b5647726efffbbc015a77df5501169bdcd28"}
|
||||
{"id":"evt-xyed6p4p0vn7","type":"edge.added","subject":"dec-9xrjx82","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-9xrjx82","to":"gol-sxakryh"},"at":"2026-09-19T20:17:05.374Z","parents":["evt-jgnx569r7qpj"],"hash":"9f68c40d0200cd96f666fcdf1b693e43b67afc1d27a48d249f81342db98549fd"}
|
||||
{"id":"evt-08xye0bsyq00","type":"node.created","subject":"que-588mjxt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"question","title":"Add MediaMTX as the video bridge, and how should it be installed and run?","body":"Dependency justification per pri-mz2jxpb, for gol-sxakryh. The user chose MediaMTX over go2rtc, ffmpeg run by the app, and a pure-JS RTSP client (2026-09-19); this question approves the dependency itself and how it's installed.\n\n(1) Problem: browsers can't play RTSP. Something has to repackage the cameras' H.264/H.265 streams, without re-encoding, into WebRTC (under 1 s latency) or HLS. Later it would also record motion clips with retention.\n\n(2) Why not the platform or our own code: Node has no RTSP/RTP or WebRTC media stack. Writing an RTSP client, RTP depacketizer and fMP4 muxer ourselves is weeks of protocol work (the rejected pure-JS option).\n\n(3) Size and reach: a single static Go binary with no runtime dependencies; the v1.21.0 darwin_arm64 archive is 26.7 MB. It runs as a separate server process and ships nothing to the browser. The browser only receives video, through our app.\n\n(4) Health, checked 2026-09-19 against GitHub and Homebrew: MIT license; latest release v1.21.0 on 2026-09-05, with releases about every 2 weeks; about 20.2k stars; code pushed 2026-09-19; Homebrew formula mediamtx 1.21.0. Eight published advisories (GHSA): 4 high DoS or argument-injection, 2 medium CORS or open redirect, 2 low. All are patched in 1.20.1 or earlier, so 1.21.0 is unaffected. That history argues for pinning to the latest release, updating promptly, and never exposing MediaMTX to the network directly.\n\n(5) Removal: MediaMTX sits behind our own route handlers and a small src/lib/video module, so replacing it (e.g. with go2rtc) touches only that module and the process config.\n\nSecurity design either way: MediaMTX listens on 127.0.0.1 only, with its control API bound to localhost and password-protected. The app gives it each camera's RTSP source, with credentials, over that API at runtime, so no camera password is written to MediaMTX's config file. The browser never talks to MediaMTX's HTTP ports. WebRTC signaling (WHEP) and any HLS go through our session-checked route handlers. With WebRTC, the media packets themselves flow browser↔MediaMTX over UDP/TCP ICE ports on the LAN, but only after an authenticated signaling exchange for that session. Those ICE ports need to be reachable from browsers on the LAN.","status":"open","owner":null,"attrs":{"options":[{"option":"Homebrew (brew install mediamtx)","consequence":"Easiest on this Mac; brew upgrade keeps it patched. The version isn't pinned by the repo, and other machines need brew or their own install. The app finds it on PATH or through a MEDIAMTX_BIN env var."},{"option":"Pinned release binary fetched by a script","consequence":"An npm script downloads a specific GitHub release for the platform, verifies its SHA-256 against a checksum committed in the repo, and unpacks it into a gitignored bin/ folder. Reproducible and pinned; upgrades are a deliberate one-line change. Slightly more to maintain."},{"option":"Docker container","consequence":"Pinned image tag; isolated from the host. On macOS, Docker's networking makes WebRTC UDP harder (host networking is limited), so HLS might be needed. Adds Docker as a runtime requirement."},{"option":"Don't add MediaMTX","consequence":"Revisit the bridge choice (go2rtc, ffmpeg, or pure JS)."}],"revisit":null}},"at":"2026-09-19T20:17:18.939Z","parents":["evt-xyed6p4p0vn7"],"hash":"693a91ef6088b1a444fa4d5cdf7dd71360e389242c91fa24126fdaf3a50b9c05"}
|
||||
{"id":"evt-pvt2jey438j0","type":"edge.added","subject":"que-588mjxt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"que-588mjxt","to":"gol-sxakryh"},"at":"2026-09-19T20:17:18.941Z","parents":["evt-08xye0bsyq00"],"hash":"b728e559e7d8faca41992447056e3ef12b628642f131877eb0e11e1a1233dc3e"}
|
||||
{"id":"evt-2wvwc6gerkcq","type":"edge.added","subject":"pri-mz2jxpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-mz2jxpb","to":"que-588mjxt"},"at":"2026-09-19T20:17:18.942Z","parents":["evt-pvt2jey438j0"],"hash":"adbe655043680bb6daee7ed603067680d1cb460661bd1eb0e931f9fac2289f73"}
|
||||
{"id":"evt-rx6e31ymn856","type":"edge.added","subject":"pri-tyrxdz9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-tyrxdz9","to":"que-588mjxt"},"at":"2026-09-19T20:17:18.943Z","parents":["evt-2wvwc6gerkcq"],"hash":"a21de4cfd3ea9b0ba282ec56c43b42568a87d091a6243ddda42121d19a56eb34"}
|
||||
{"id":"evt-g8hcdn117p6y","type":"edge.added","subject":"pri-m1csgrm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-m1csgrm","to":"que-588mjxt"},"at":"2026-09-19T20:17:18.944Z","parents":["evt-rx6e31ymn856"],"hash":"a6f9ab33a0ae6b61636d3b249101c58ab46df8ebbac7d8d8ae1f9556cd35e176"}
|
||||
{"id":"evt-vy7bgy5j61jv","type":"node.created","subject":"dec-xkn4z0e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Add MediaMTX as a pinned, checksum-verified release binary in a gitignored bin/, started and supervised by the app","body":"User's answers on 2026-09-19. Install: an npm script downloads a specific MediaMTX GitHub release (starting at v1.21.0) for the current platform, verifies its SHA-256 against checksums committed in the repo, and unpacks it into a gitignored bin/. Upgrades are a deliberate change to the pinned version and checksums. Rejected: Homebrew, because the repo doesn't pin the version, and Docker, because WebRTC networking on macOS is awkward. Process: the app starts MediaMTX as a child process from instrumentation.ts when the server boots. It writes a generated config that binds everything to 127.0.0.1 except the WebRTC ICE port, generates a random password for the local control API, restarts MediaMTX if it crashes, and stops it on exit. Rejected: running it as a separate service. Placement: live video plays the main stream in the pop-out and on the camera page; dashboard cards keep polling snapshots. Codec: first check each camera's stream codecs read-only. If any browser-bound stream is H.265, come back with the trade-off before changing anything. The security design in que-588mjxt applies: the browser never talks to MediaMTX's HTTP ports, signaling goes through session-checked routes, and camera credentials go to MediaMTX over its localhost API at runtime and are never written to disk.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:19:17.982Z","parents":["evt-g8hcdn117p6y"],"hash":"24473856f1f624027e1dee13c2abc895ae1a5acf43c2a486b266b08e7d136b21"}
|
||||
{"id":"evt-v2vjh2w920x8","type":"edge.added","subject":"dec-xkn4z0e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"answers","from":"dec-xkn4z0e","to":"que-588mjxt"},"at":"2026-09-19T20:19:17.984Z","parents":["evt-vy7bgy5j61jv"],"hash":"4dd4542f5633c261f82a582797dd6b056ffa2ff5cea67990d982d57a5bbf9553"}
|
||||
{"id":"evt-snf3gvcc99ab","type":"question.answered","subject":"que-588mjxt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"answer":"dec-xkn4z0e"},"at":"2026-09-19T20:19:17.985Z","parents":["evt-v2vjh2w920x8"],"hash":"2bfec6fb3bcc138288eb4a1536b213c3d6626d90a0a467140784e0924fc47cdf"}
|
||||
{"id":"evt-f87v7q4sy8bj","type":"edge.added","subject":"dec-xkn4z0e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-xkn4z0e","to":"gol-sxakryh"},"at":"2026-09-19T20:19:17.986Z","parents":["evt-snf3gvcc99ab"],"hash":"ac26c89fa172f4ae95b2f498ce2f4cb687aee159b6c4c271d007d6be0cf6a9a3"}
|
||||
{"id":"evt-xcgtprrxfh9h","type":"node.created","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Live video, first slice: main stream live in the pop-out and on the camera page via MediaMTX","body":"Parent for gol-sxakryh's first slice, following dec-9xrjx82 and dec-xkn4z0e. Done when every child is closed and the user has watched both cameras live in the pop-out and on the camera page, in their own browser, with the session required and no camera credentials visible to the browser (network tab, page source).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:19:22.713Z","parents":["evt-f87v7q4sy8bj"],"hash":"929e42446221e5751221c11870029a6a7c39d0d296f85c874025d31be9cdf11d"}
|
||||
{"id":"evt-9kenz6nax8wn","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-4atmz13","to":"gol-sxakryh"},"at":"2026-09-19T20:19:22.714Z","parents":["evt-xcgtprrxfh9h"],"hash":"e0657c1dd18abc8bd170436ef36e323227075df17a69c14f87d8dc6b995b476c"}
|
||||
{"id":"evt-01q96258sk5p","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-4atmz13","to":"area:video"},"at":"2026-09-19T20:19:22.715Z","parents":["evt-9kenz6nax8wn"],"hash":"293a5d229b2cdeb49ef634e4bfeca70840782c784bf4abce349449c59df77f68"}
|
||||
{"id":"evt-adhxfp2ph2hg","type":"node.created","subject":"iss-5xkn65m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Check both cameras' stream codecs (read-only)","body":"Per dec-xkn4z0e: read each camera's main and sub stream codec and H.264/H.265 profile. The dashboard info route and the settings page already report this over ONVIF, so no new device access is needed. Record the result as a finding. If a main stream is H.265, bring the trade-off (switch to H.264 with the stream editor vs keep H.265 with limited browser support) to the user before changing anything.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:19:28.262Z","parents":["evt-01q96258sk5p"],"hash":"51bd5b4e2f66b6e0f53713bb050947529f9306f96047b111a06bae01a60d43a2"}
|
||||
{"id":"evt-bq6m9abtyyth","type":"edge.added","subject":"iss-5xkn65m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-5xkn65m","to":"gol-sxakryh"},"at":"2026-09-19T20:19:28.265Z","parents":["evt-adhxfp2ph2hg"],"hash":"d30f9e43130b5e1dc9bbec9f1b9c69c19778d15fa867aa905e833f93a941dc09"}
|
||||
{"id":"evt-vsg26dcqe547","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-4atmz13","to":"iss-5xkn65m"},"at":"2026-09-19T20:19:28.266Z","parents":["evt-bq6m9abtyyth"],"hash":"22abf91ad24a4a9644bac25e5a1af562bac511b866e76428688c0567310aad97"}
|
||||
{"id":"evt-2fqcjmmnxp03","type":"edge.added","subject":"iss-5xkn65m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-5xkn65m","to":"area:video"},"at":"2026-09-19T20:19:28.267Z","parents":["evt-vsg26dcqe547"],"hash":"2c1e6de8ff08723aa79df7400543c537a154b27e1d640fbbde80ad325a329ac4"}
|
||||
{"id":"evt-bzvv0nj0cepf","type":"node.created","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Script to fetch the pinned MediaMTX release and verify its checksum","body":"Per dec-xkn4z0e. An npm script (e.g. npm run video:install, plain Node with no new packages) downloads MediaMTX v1.21.0 for the current OS/arch from GitHub releases, verifies the SHA-256 against checksums committed in the repo, and unpacks the binary into a gitignored bin/. It refuses a mismatch, is idempotent, and prints what it did. The README documents it and how to upgrade: bump the version and the checksums together. Tests use a local fake download and never reach the network (pri-e14bahk).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:19:30.899Z","parents":["evt-2fqcjmmnxp03"],"hash":"0382af9788622f5247050bb7ee1772f92bd1139661ea7bdf5edddc299d57994c"}
|
||||
{"id":"evt-r66mtwfsfcsk","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-qdc4vqw","to":"gol-sxakryh"},"at":"2026-09-19T20:19:30.901Z","parents":["evt-bzvv0nj0cepf"],"hash":"f42acece43c12f698b9fd53b49ff1f77a9af96d8cae27b6df07fbf0126737c8c"}
|
||||
{"id":"evt-mb41hr5spqnd","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-qdc4vqw","to":"scripts/"},"at":"2026-09-19T20:19:30.902Z","parents":["evt-r66mtwfsfcsk"],"hash":"957d2997aa46379af5f615bd06377b7dd05b8893fc17d3c8b58fca6b2271a83e"}
|
||||
{"id":"evt-52t6dk7zwmmc","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-qdc4vqw","to":".gitignore"},"at":"2026-09-19T20:19:30.903Z","parents":["evt-mb41hr5spqnd"],"hash":"3c3547a07081fe3973eae59b949f62c5c826d63f7bd53745e8db72d0495c7110"}
|
||||
{"id":"evt-9697egkf32ae","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-qdc4vqw","to":"README.md"},"at":"2026-09-19T20:19:30.904Z","parents":["evt-52t6dk7zwmmc"],"hash":"a8e3bb73a987209678f1a5ff01b561ff71824864551acca559d0bc10d11597a7"}
|
||||
{"id":"evt-fy7yac43gq30","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-qdc4vqw","to":"package.json"},"at":"2026-09-19T20:19:30.905Z","parents":["evt-9697egkf32ae"],"hash":"d0d2e04491577f40710329e90e0642a1bee749858de0a1c251a46da106a8c20a"}
|
||||
{"id":"evt-a9vd17x5p17v","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-4atmz13","to":"iss-qdc4vqw"},"at":"2026-09-19T20:19:30.906Z","parents":["evt-fy7yac43gq30"],"hash":"a4e699722f31ded9c3cbb6f11694b709f9c400e2ef0ca4aed9d08acd76475551"}
|
||||
{"id":"evt-4mg4959afkqf","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-qdc4vqw","to":"area:video"},"at":"2026-09-19T20:19:30.907Z","parents":["evt-a9vd17x5p17v"],"hash":"700a5496856112a77b055d98ff4ae61c1131545eb01eaa7b2d1407f67546542b"}
|
||||
{"id":"evt-vzyhdhhcnzq0","type":"node.created","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Start and supervise MediaMTX from the app, locked to localhost","body":"Per dec-xkn4z0e and the security design in que-588mjxt. A server-only src/lib/video module: instrumentation.ts starts bin/mediamtx (or MEDIAMTX_BIN) with a generated config written 0600 under .data/. The config binds RTSP, HLS, WebRTC HTTP and the control API to 127.0.0.1 and disables RTMP, SRT and anything else unused. Only the WebRTC ICE UDP/TCP port listens on the LAN. The control API is password-protected with a random per-boot secret. Paths are added at runtime over the API per camera and stream (source = the ONVIF RTSP URI with credentials, sourceOnDemand so a camera is only pulled while someone watches), and are never written into the config file. The module restarts MediaMTX on crash with backoff, stops it on exit, and exposes a status of running, missing binary or crashed that the UI can show. Tests replace the child process and HTTP API (pri-e14bahk).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:19:39.065Z","parents":["evt-4mg4959afkqf"],"hash":"0b470693fd064b59a50a196f3ef70303f7d6c72eec514f96d578206044685ddd"}
|
||||
{"id":"evt-f6yt1k8y4g24","type":"edge.added","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-nk6zrzv","to":"gol-sxakryh"},"at":"2026-09-19T20:19:39.066Z","parents":["evt-vzyhdhhcnzq0"],"hash":"565ab16757cc7902df68da51dfcfa7f19b936530194b3a1dc1cb1ec373e5e56a"}
|
||||
{"id":"evt-thv2y79eyt4j","type":"edge.added","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-nk6zrzv","to":"src/lib/video.ts"},"at":"2026-09-19T20:19:39.067Z","parents":["evt-f6yt1k8y4g24"],"hash":"8d6eeaa35ce39c0ecea75b506426ee06a6254f879259ca82be4b0f0d943f9cc9"}
|
||||
{"id":"evt-rhdqz22eny45","type":"edge.added","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-nk6zrzv","to":"src/instrumentation.ts"},"at":"2026-09-19T20:19:39.068Z","parents":["evt-thv2y79eyt4j"],"hash":"c55ef5581f8cba2f28fd20efbe6a7cf093a8775973dc8cd4bb0c94d7b5da0808"}
|
||||
{"id":"evt-wdnwjn7v0wa0","type":"edge.added","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-qdc4vqw","to":"iss-nk6zrzv"},"at":"2026-09-19T20:19:39.069Z","parents":["evt-rhdqz22eny45"],"hash":"295cac86c847500d674554be4f80bdbcaf647a9eaf41bdf0987c93085710846c"}
|
||||
{"id":"evt-yn2bzj0yy529","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-4atmz13","to":"iss-nk6zrzv"},"at":"2026-09-19T20:19:39.070Z","parents":["evt-wdnwjn7v0wa0"],"hash":"4fb5db76fb53ff0d4ad46d7e24cb07873f8a9c76cdcbc995556e6fddb1552f94"}
|
||||
{"id":"evt-xa17ccz2sv18","type":"edge.added","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-nk6zrzv","to":"area:video"},"at":"2026-09-19T20:19:39.071Z","parents":["evt-yn2bzj0yy529"],"hash":"92e737c93f9be8681425112b1061acd64fa246e715095e2ab8c429b7a3bebfe2"}
|
||||
{"id":"evt-sy87hwxarh0b","type":"node.created","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Session-checked WebRTC (WHEP) route and a live player in the pop-out and on the camera page","body":"Per dec-xkn4z0e. POST /api/cameras/[id]/live/whep (and the matching PATCH/DELETE for ICE candidates and teardown) checks access, validates the id against the registry, ensures the camera's MediaMTX path exists, and relays the SDP offer and answer to MediaMTX on localhost. The browser never gets a MediaMTX URL or any camera credential. The client player uses the browser's own RTCPeerConnection, with no new package, and shows connecting, playing, reconnecting and failed states. On failure it falls back to the snapshot polling that exists today. The pop-out and the camera page play the main stream; dashboard cards keep snapshots. Tests cover access, id validation, relaying and that no secret is leaked.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:19:43.980Z","parents":["evt-xa17ccz2sv18"],"hash":"fb3b55e34e4c54ad4f70ebb42cd298cbae2ba7bd7f393494d2f28acaaab8da3c"}
|
||||
{"id":"evt-09yyf3egw7nz","type":"edge.added","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-cbx21zy","to":"gol-sxakryh"},"at":"2026-09-19T20:19:43.982Z","parents":["evt-sy87hwxarh0b"],"hash":"44209c5bbd0dfe970928511f089a94011c2708718423255b346f5eb014ca2b9c"}
|
||||
{"id":"evt-1jrpnv8s0aa4","type":"edge.added","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-cbx21zy","to":"src/app/api/cameras/[id]/live/"},"at":"2026-09-19T20:19:43.983Z","parents":["evt-09yyf3egw7nz"],"hash":"2df17664d77be04ac46a70b5c6150330951268830bda641b90c927db31179722"}
|
||||
{"id":"evt-1sp412ncsf3f","type":"edge.added","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-cbx21zy","to":"src/app/cameras/[id]/live/"},"at":"2026-09-19T20:19:43.984Z","parents":["evt-1jrpnv8s0aa4"],"hash":"8ab7aa9a0695f4e59c52c4e618829d72a7079802214f6ff9aab901c7b8ab3f72"}
|
||||
{"id":"evt-szskvjs5njmp","type":"edge.added","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-cbx21zy","to":"src/app/cameras/[id]/page.tsx"},"at":"2026-09-19T20:19:43.985Z","parents":["evt-1sp412ncsf3f"],"hash":"42964f3be66dd70fe313a2de2e115ec3449fe4874f5dcadf86f8427689d2c95d"}
|
||||
{"id":"evt-7gk18akp3j4e","type":"edge.added","subject":"iss-5xkn65m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-5xkn65m","to":"iss-cbx21zy"},"at":"2026-09-19T20:19:43.988Z","parents":["evt-szskvjs5njmp"],"hash":"0da8fed42bda956ba7cac9708f61221b21899aa299c51d55a1844b2a4d9d770a"}
|
||||
{"id":"evt-v6hr8ddb8cbe","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-4atmz13","to":"iss-cbx21zy"},"at":"2026-09-19T20:19:43.989Z","parents":["evt-7gk18akp3j4e"],"hash":"196e239eb960add2a03357e03a34cd1056786471b9140880ebc7df4dff7aa2a8"}
|
||||
{"id":"evt-pwshfmse0gv2","type":"edge.added","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-cbx21zy","to":"area:video"},"at":"2026-09-19T20:19:43.990Z","parents":["evt-v6hr8ddb8cbe"],"hash":"32df6057310761d988de5fe3689daf7f98bd1efdebabb515e0ea059fdf894c9f"}
|
||||
{"id":"evt-f7n75vxq0qr9","type":"edge.added","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-nk6zrzv","to":"iss-cbx21zy"},"at":"2026-09-19T20:19:47.949Z","parents":["evt-pwshfmse0gv2"],"hash":"6ebc726f787f499fc68fcec66833d143bbd6b7c028173e5edcbec4c66e5e77cd"}
|
||||
{"id":"evt-t1r7rqcam1nn","type":"node.created","subject":"iss-g456j72","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Design motion-triggered recording (after live view works)","body":"Per dec-9xrjx82, deferred until live viewing works. Open questions to bring to the user then: where clips are stored (a folder or external drive; the dev Mac has 72 GB free), retention and disk cap, pre- and post-roll length, which event source (ONVIF PullPoint events or ISAPI alertStream), which stream to record (main is about 54 GB per day per camera continuous; motion-only is far less), and how clips are browsed, played and deleted behind the admin session. MediaMTX's own recording (fMP4 segments with recordDeleteAfter) is the likely mechanism.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:19:50.499Z","parents":["evt-f7n75vxq0qr9"],"hash":"c11853228019f865370bda76052a192920747304735be5ee2a12b77f06a2c8b3"}
|
||||
{"id":"evt-fkraf55tg4y4","type":"edge.added","subject":"iss-g456j72","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-g456j72","to":"gol-sxakryh"},"at":"2026-09-19T20:19:50.500Z","parents":["evt-t1r7rqcam1nn"],"hash":"2e91abfa055caff88d35c7d9e5f11db9e94489e1c87e5bf7f8be5187053b40f8"}
|
||||
{"id":"evt-mgw1r1gqy0z3","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-4atmz13","to":"iss-g456j72"},"at":"2026-09-19T20:19:50.501Z","parents":["evt-fkraf55tg4y4"],"hash":"db15dffece0e7f65826e2131a768f9f9b4caa432d72f7aba5775a6e5c266913e"}
|
||||
{"id":"evt-8c3bjp5jkx0r","type":"edge.added","subject":"iss-g456j72","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-g456j72","to":"area:video"},"at":"2026-09-19T20:19:50.502Z","parents":["evt-mgw1r1gqy0z3"],"hash":"857e3f60a4d06d265ab56616afd22a3aead6c4d6a385d7e83999f7a0ca9f035f"}
|
||||
{"id":"evt-dk6q5ft9s9np","type":"node.updated","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"target":6,"direction":"up","unit":"checks"}},"at":"2026-09-19T20:21:36.563Z","parents":["evt-8c3bjp5jkx0r"],"hash":"621e36aa3b5053cbaf5736fa8f86bf562814245314e9ec450d650e78ea1e427f"}
|
||||
{"id":"evt-n7y3jswtnkew","type":"node.updated","subject":"gol-sxakryh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Watch each camera's live video in the browser at full stream quality, and record video, without exposing cameras or their logins. Target (set by the user 2026-09-19): 6 checks, one per feature per camera across both cameras: (1) main stream watched live, (2) sub stream watched live, (3) recording enabled and validated (a motion clip recorded and played back). Each check is the user's own observation, recorded as a measurement. Why now: fnd-dw1pqcw and fnd-k1jyt83 show the still snapshots top out at 1200×536 while the main stream is 4096×1860, so full-detail viewing needs the video stream itself. \"Secure\" follows the existing principles: video reaches the browser only through this app, behind the admin session (pri-m1csgrm); camera credentials never leave the server and are never embedded in a URL the browser sees (pri-tyrxdz9); cameras are addressed by id from the registry, never by a client-supplied host. Bridge: MediaMTX (dec-xkn4z0e). Live view first, then motion-triggered recording (dec-9xrjx82). Priority 1. Created by the user on 2026-09-19."}},"at":"2026-09-19T20:21:38.836Z","parents":["evt-dk6q5ft9s9np"],"hash":"5c81cea5525238d092ca825fd46d3a6c7f4c9e21d483d5d6f68a4a4304169886"}
|
||||
{"id":"evt-44sycckwpv9g","type":"node.updated","subject":"iss-cbx21zy","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Per dec-xkn4z0e. POST /api/cameras/[id]/live/whep (and the matching PATCH/DELETE for ICE candidates and teardown) checks access, validates the id and the stream (main or sub, from the camera's own profiles) against the registry, ensures the MediaMTX path exists, and relays the SDP offer and answer to MediaMTX on localhost. The browser never gets a MediaMTX URL or any camera credential. The client player uses the browser's own RTCPeerConnection, with no new package, and shows connecting, playing, reconnecting and failed states. On failure it falls back to the snapshot polling that exists today. The pop-out and the camera page play the main stream by default, with a Main/Sub switch, because the goal's target counts watching both streams live on each camera. Dashboard cards keep snapshots. Tests cover access, id and stream validation, relaying and that no secret is leaked."}},"at":"2026-09-19T20:21:41.658Z","parents":["evt-n7y3jswtnkew"],"hash":"e1f3c7b6638fba35f3a78fad4338252824a1f5bcf36417d357afc3da9490d4c1"}
|
||||
{"id":"evt-9ng8fzbr1yn7","type":"node.created","subject":"fnd-srfdnmm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Both I91ET cameras stream H.264 on main (4096×1860 @ 20 fps) and sub (1200×536 @ 20 fps)","body":"Read on 2026-09-19 through the app's own /api/cameras/[id]/info route (ONVIF GetProfiles, the same read the dashboard makes), for cameras …af2e and …af54. Both report mainStream H264 4096×1860 at 20 fps and subStream H264 1200×536 at 20 fps. No H.265 anywhere, so both streams can go to browsers over WebRTC as they are, with no codec change needed. The H.264 profile (Main or High) wasn't in this read; WebRTC in Chrome and Safari decodes both. 4096×1860 needs H.264 level 5.1, which some older or low-power devices can't decode in hardware.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"http://localhost:3000/api/cameras/22ec0000-8b90-11b5-845d-d03bf404af54/info","note":"and the same route for …af2e; read in the user's signed-in browser"}],"as_of":"2026-09-19"}},"at":"2026-09-19T20:22:08.820Z","parents":["evt-44sycckwpv9g"],"hash":"36ff418aedae5f689ee2449b3cfdfb55898455e52f6c5601eab143a92e01941d"}
|
||||
{"id":"evt-xfxed11agevk","type":"edge.added","subject":"fnd-srfdnmm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-srfdnmm","to":"iss-cbx21zy"},"at":"2026-09-19T20:22:08.822Z","parents":["evt-9ng8fzbr1yn7"],"hash":"015ec05f5227c691e8b0f5432ad110a88af258d28cc927b8506e70a0cc51b3ec"}
|
||||
{"id":"evt-yxqd8n1sxthq","type":"edge.added","subject":"fnd-srfdnmm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-srfdnmm","to":"gol-sxakryh"},"at":"2026-09-19T20:22:08.823Z","parents":["evt-xfxed11agevk"],"hash":"bd40bd30c6c5cacfb77f6f6c3f4efe27d43dd58e53ec39bf5ee2eeaadfb0ec66"}
|
||||
{"id":"evt-m4z8jjddzhkt","type":"edge.added","subject":"fnd-srfdnmm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"fnd-srfdnmm","to":"iss-5xkn65m"},"at":"2026-09-19T20:22:08.824Z","parents":["evt-yxqd8n1sxthq"],"hash":"541dd6d2d09dded4b4c3b67538550a2f9a572b18923a3c3df1c84c76d2d85d6c"}
|
||||
{"id":"evt-2bmbv75gmsmp","type":"node.status_changed","subject":"iss-5xkn65m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T20:22:10.807Z","parents":["evt-m4z8jjddzhkt"],"hash":"772585e54de080aa75d6a6e7a3de7ffbe60c5805624912d4eaa338d23fde2a7c"}
|
||||
{"id":"evt-yjttarpgr6jc","type":"node.created","subject":"ver-rpgs2rj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"npm run video:install downloads the pinned MediaMTX v1.21.0 for this platform, refuses a checksum mismatch before writing, installs an executable into gitignored bin/, and is idempotent; tested without the network","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:24:23.115Z","parents":["evt-2bmbv75gmsmp"],"hash":"fe1d5dffbbcb9be1d412b28fd78316eb1c4d59cb4bebf59c497478c35a1c4366"}
|
||||
{"id":"evt-p4qevsh6qfdg","type":"edge.added","subject":"ver-rpgs2rj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-rpgs2rj","to":"iss-qdc4vqw"},"at":"2026-09-19T20:24:23.117Z","parents":["evt-yjttarpgr6jc"],"hash":"07e3bf7ea038c51d4b4fb8538cc458dd1100489228b74438f788f1f3439e0750"}
|
||||
{"id":"evt-vh4jb5zvascn","type":"verification.recorded","subject":"ver-rpgs2rj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/mediamtx-install.test.ts (18 tests: fakes, a loopback HTTP server, local tar). Real run on 2026-09-19 (darwin-arm64): \"Installed MediaMTX v1.21.0 … (checksum verified)\"; ./bin/mediamtx --version → v1.21.0; a second run reports already installed; git check-ignore shows bin/ ignored. Checksums match both the release's checksums.sha256 and GitHub's per-asset digests. Full suite 665 tests pass, 99.85% lines; tsc and eslint clean."},"at":"2026-09-19T20:24:23.118Z","parents":["evt-p4qevsh6qfdg"],"hash":"963a9304ba47d6f7aad6553a13d5f7409d77539bd61af939f7e5458f3c7647f8"}
|
||||
{"id":"evt-phz710emtb24","type":"node.status_changed","subject":"iss-qdc4vqw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T20:24:25.012Z","parents":["evt-vh4jb5zvascn"],"hash":"e580599882ff85e0d67d0594163feef29bf1b0dfa44cfd117173e37dcb108197"}
|
||||
{"id":"evt-ng74y2xergby","type":"node.created","subject":"ver-64pxyd3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The app writes a localhost-only MediaMTX config (hashed per-boot API password, only the app user, unused protocols off, only the ICE port on the LAN), starts and supervises MediaMTX from instrumentation with backoff restarts, stops it on exit, and adds camera streams at runtime without writing or leaking credentials; tested without starting a process","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:30:49.469Z","parents":["evt-phz710emtb24"],"hash":"dc7344c1fdf03d3d74ed2c7b94d0dad959b8fd3a4ff262c63b2c09a02465a974"}
|
||||
{"id":"evt-tns4qb1bcdxm","type":"edge.added","subject":"ver-64pxyd3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-64pxyd3","to":"iss-nk6zrzv"},"at":"2026-09-19T20:30:49.471Z","parents":["evt-ng74y2xergby"],"hash":"cf6e13ef56b529b85f63dba0ec1f5f8942eac398005860d6fb271021d5fa956f"}
|
||||
{"id":"evt-t6vq74hhtncf","type":"verification.recorded","subject":"ver-64pxyd3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/mediamtx-config.test.ts, mediamtx-supervisor.test.ts, video.test.ts, instrumentation.test.ts and camera.test.ts (\"rtspSourceWithLogin\"). npx vitest run --coverage → 50 files, 716 tests pass (99.86% lines); tsc and eslint clean; next build compiles; pgrep confirms no MediaMTX was started. Config keys checked against MediaMTX v1.21.0 mediamtx.yml, api/openapi.yaml and internal/conf/credential.go."},"at":"2026-09-19T20:30:49.472Z","parents":["evt-tns4qb1bcdxm"],"hash":"fb87ed56562e51703dcaae9114c97379937ed51a3beadad0a4ab6b2928a9db8d"}
|
||||
{"id":"evt-7crwj3hazzza","type":"node.created","subject":"ver-httak2e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real start by the user: next start launches MediaMTX, which accepts the generated config and logs that it's ready; the API and WebRTC signaling listen on 127.0.0.1 only and UDP 8189 on the LAN; stopping the server stops MediaMTX","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:30:51.951Z","parents":["evt-t6vq74hhtncf"],"hash":"9ee1f3369a9eaf97d1543261d4e85f6711d6f404530a4b0d880725d21b06b1b3"}
|
||||
{"id":"evt-wm83ze5endvt","type":"edge.added","subject":"ver-httak2e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-httak2e","to":"iss-nk6zrzv"},"at":"2026-09-19T20:30:51.959Z","parents":["evt-7crwj3hazzza"],"hash":"868729b9261d5bf84ea19aa5fa9a7bd363aa83608b35a710761305f7cd8fdf99"}
|
||||
{"id":"evt-484cpa2byz24","type":"verification.recorded","subject":"ver-httak2e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User test after npm run build && npm start: server log lines \"[mediamtx] …\", and lsof -nP -iTCP:9997 -iTCP:8889 -sTCP:LISTEN, plus lsof -nP -iUDP:8189"},"at":"2026-09-19T20:30:51.960Z","parents":["evt-wm83ze5endvt"],"hash":"96200c4f8f406548733af608474f3128a47b28b0601fce41e94dcb7b8b7da999"}
|
||||
{"id":"evt-kk0mpms2ndrc","type":"node.status_changed","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T20:30:53.636Z","parents":["evt-484cpa2byz24"],"hash":"a0f1b9b38c90ca0fde7fb267b6843865972b4a679487ea129bb233185566c121"}
|
||||
{"id":"evt-mqp5z5db3qh2","type":"node.created","subject":"ver-s2jz1b6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real start by the user: MediaMTX accepts the generated config; WebRTC signaling and the API listen on 127.0.0.1 only; ICE on UDP :8189","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:32:43.306Z","parents":["evt-kk0mpms2ndrc"],"hash":"87b055539e9a91661a2b101d5ec0dcebd0ddbe3aade39ce320d7b6e1814709a9"}
|
||||
{"id":"evt-82w4d5ax3yj1","type":"edge.added","subject":"ver-s2jz1b6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-s2jz1b6","to":"iss-nk6zrzv"},"at":"2026-09-19T20:32:43.307Z","parents":["evt-mqp5z5db3qh2"],"hash":"70b8928d3f8e5c7b4c4bf1f6624626e29633f504c8bf7e0990fde6a5b365ed91"}
|
||||
{"id":"evt-jfnjdtx7mzpj","type":"verification.recorded","subject":"ver-s2jz1b6","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User's server log on 2026-09-19 15:32:21: \"MediaMTX v1.21.0, darwin, arm64\"; \"configuration loaded from …/.data/mediamtx/mediamtx.yml\"; \"[WebRTC] started with listeners on 127.0.0.1:8889 (TCP/HTTP), :8189 (UDP/ICE)\"; \"[API] started with listener on 127.0.0.1:9997 (TCP/HTTP)\". No RTSP, RTMP, HLS, SRT or MoQ listeners were logged. Still unchecked: that stopping the server stops MediaMTX."},"at":"2026-09-19T20:32:43.308Z","parents":["evt-82w4d5ax3yj1"],"hash":"caceccc7fb50f3510276bd02b824faaa44c473d6149362980a77cbf8f80cdcb9"}
|
||||
{"id":"evt-jcapg6mxwgt5","type":"node.created","subject":"ver-sf1mzks","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Stopping the server stops MediaMTX","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T20:33:18.727Z","parents":["evt-jfnjdtx7mzpj"],"hash":"5918d8162fc349602d897856497b1a3dcd8cda0b94b28d75b57b4884cc991e22"}
|
||||
{"id":"evt-hm2by4azjzpr","type":"edge.added","subject":"ver-sf1mzks","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-sf1mzks","to":"iss-nk6zrzv"},"at":"2026-09-19T20:33:18.729Z","parents":["evt-jcapg6mxwgt5"],"hash":"4577cfb8165103ef5cb54882df743d2406588b380e9b9e035034c820db29aac7"}
|
||||
{"id":"evt-seev8nnntjvq","type":"verification.recorded","subject":"ver-sf1mzks","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User stopped the server on 2026-09-19; pgrep -fl mediamtx returned nothing."},"at":"2026-09-19T20:33:18.730Z","parents":["evt-hm2by4azjzpr"],"hash":"7e337610a33592ce39a4c01609bf0784bc530b1789ccc645a51f2de3356c7e23"}
|
||||
{"id":"evt-cx4e42e42ysm","type":"node.status_changed","subject":"iss-nk6zrzv","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T20:33:20.659Z","parents":["evt-seev8nnntjvq"],"hash":"667eceb5e7a655d386f58f9deaf543c0c48375f877b5d88fc9508d6dd3fd44c1"}
|
||||
{"id":"evt-ehjrnw0w1ajy","type":"node.created","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Stop a leftover MediaMTX from an earlier server run on startup","body":"Discovered while building iss-nk6zrzv. If the server dies without a normal exit (force-quit, killed terminal, crash; more likely with npm run dev), its MediaMTX child keeps running and holds ports 8189, 8889 and 9997. The next start's MediaMTX then can't bind and crash-loops with backoff. The workaround today is pkill mediamtx. Fix: record MediaMTX's pid in .data/mediamtx/, and on startup stop a leftover process only if that pid is still our bin/mediamtx (check the command, so an unrelated reused pid is never killed). Tests use fakes, never real processes.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T20:33:23.225Z","parents":["evt-cx4e42e42ysm"],"hash":"30f32672fd964bd376b73e864e78811dc91f41e99e50a608cbf9af4e651c9348"}
|
||||
{"id":"evt-x8ncj61f2m4j","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-yd2sq2q","to":"gol-sxakryh"},"at":"2026-09-19T20:33:23.226Z","parents":["evt-ehjrnw0w1ajy"],"hash":"5f625694d824ba7857c31191d52d2f86f8ae62f60e5cbfcfa2378f00e5b38b49"}
|
||||
{"id":"evt-kkggfhv82k8a","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-yd2sq2q","to":"src/lib/mediamtx-supervisor.ts"},"at":"2026-09-19T20:33:23.227Z","parents":["evt-x8ncj61f2m4j"],"hash":"11a9c2709d03b53b712c11a81abadaa5f64e95b29ec3af3995fec3c2d0a4a7c0"}
|
||||
{"id":"evt-t3hv9z68czv1","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-yd2sq2q","to":"src/lib/video.ts"},"at":"2026-09-19T20:33:23.228Z","parents":["evt-kkggfhv82k8a"],"hash":"5a7eccead42a1252f5d2669492cfbe2c15d037016121c6d1fb38e1235d294233"}
|
||||
{"id":"evt-r8ad4s4jhgpw","type":"edge.added","subject":"iss-4atmz13","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-4atmz13","to":"iss-yd2sq2q"},"at":"2026-09-19T20:33:23.229Z","parents":["evt-t3hv9z68czv1"],"hash":"ef606c14c0ef29a9538119652ef1482d7ed8b9ec4af4966afc80cdf614ea0173"}
|
||||
{"id":"evt-88zs5h5gg17f","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-yd2sq2q","to":"area:video"},"at":"2026-09-19T20:33:23.230Z","parents":["evt-r8ad4s4jhgpw"],"hash":"f04eaa848b5e077d22a68283a76b3d1be03ac60ff6fd3d0a720b234d509cf572"}
|
||||
{"id":"evt-69m4zgpr4mzg","type":"edge.added","subject":"iss-yd2sq2q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-yd2sq2q","to":"iss-nk6zrzv"},"at":"2026-09-19T20:33:23.231Z","parents":["evt-88zs5h5gg17f"],"hash":"c4b77e1ae675f302e51d418c5433781a0e4300741a9dd346ddf78bec9b826e40"}
|
||||
|
||||
20
README.md
20
README.md
@ -98,6 +98,26 @@ quietly turning security off; fix it or delete it.
|
||||
Run `npm run admin:create -- --force` (or delete the file and create it again). Changing the
|
||||
password signs out every existing session.
|
||||
|
||||
## Live video (MediaMTX)
|
||||
|
||||
Browsers can't play the cameras' RTSP streams, so live video goes through
|
||||
[MediaMTX](https://github.com/bluenviron/mediamtx), a single MIT-licensed binary that
|
||||
the app will run on this machine only (localhost). Install the pinned build with:
|
||||
|
||||
```bash
|
||||
npm run video:install
|
||||
```
|
||||
|
||||
This downloads the MediaMTX release pinned in `src/lib/mediamtx-install.ts` for your
|
||||
platform (macOS or Linux, Intel or ARM), checks its SHA-256 against the value committed
|
||||
there, and puts the binary in `./bin/` (not checked in). A download that doesn't match
|
||||
is refused and nothing is written. Re-running it does nothing if that build is already
|
||||
installed.
|
||||
|
||||
To upgrade, change `version` in `MEDIAMTX_RELEASE` and replace every `sha256` with the
|
||||
values from that release's `checksums.sha256` on GitHub (cross-check them against the
|
||||
per-file digests on the release page), then run `npm run video:install` again.
|
||||
|
||||
---
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"admin:create": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/create-admin.mts"
|
||||
"admin:create": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/create-admin.mts",
|
||||
"video:install": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/install-mediamtx.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
|
||||
18
scripts/install-mediamtx.mts
Normal file
18
scripts/install-mediamtx.mts
Normal file
@ -0,0 +1,18 @@
|
||||
// Installs the pinned MediaMTX video bridge into ./bin (vrek dec-xkn4z0e). The download is
|
||||
// checked against a SHA-256 committed in src/lib/mediamtx-install.ts before anything is
|
||||
// written. Safe to re-run: an installed matching build is left alone.
|
||||
//
|
||||
// npm run video:install
|
||||
import { installMediamtx } from "../src/lib/mediamtx-install.ts";
|
||||
|
||||
try {
|
||||
const result = await installMediamtx();
|
||||
console.log(
|
||||
result.status === "installed"
|
||||
? `Installed MediaMTX ${result.version} at ${result.binary} (checksum verified).`
|
||||
: `MediaMTX ${result.version} is already installed at ${result.binary}.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureSetupCode = vi.fn();
|
||||
vi.mock("./lib/setup-code", () => ({ ensureSetupCode }));
|
||||
// Never start a real MediaMTX from tests (pri-e14bahk).
|
||||
const startVideoBridge = vi.fn();
|
||||
vi.mock("./lib/video", () => ({ startVideoBridge }));
|
||||
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
@ -12,6 +15,7 @@ beforeEach(async () => {
|
||||
vi.stubEnv("ADMIN_AUTH_FILE", path.join(dir, "admin.json"));
|
||||
vi.stubEnv("NEXT_RUNTIME", "nodejs");
|
||||
ensureSetupCode.mockReset();
|
||||
startVideoBridge.mockReset().mockResolvedValue({ state: "starting", restarts: 0 });
|
||||
});
|
||||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
@ -42,5 +46,29 @@ describe("instrumentation register", () => {
|
||||
vi.stubEnv("NEXT_RUNTIME", "edge");
|
||||
await register();
|
||||
expect(ensureSetupCode).not.toHaveBeenCalled();
|
||||
expect(startVideoBridge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("video bridge (dec-xkn4z0e)", () => {
|
||||
it("starts it with the server", async () => {
|
||||
await register();
|
||||
expect(startVideoBridge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["VIDEO_BRIDGE", "off"],
|
||||
["NEXT_PHASE", "phase-production-build"],
|
||||
])("skips it when %s=%s", async (key, value) => {
|
||||
vi.stubEnv(key, value);
|
||||
await register();
|
||||
expect(startVideoBridge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the server up if it can't start", async () => {
|
||||
startVideoBridge.mockRejectedValue(new Error("EACCES .data"));
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(register()).resolves.toBeUndefined();
|
||||
expect(error).toHaveBeenCalledWith("[video] could not start the video bridge: EACCES .data");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Runs once when the server starts. If no admin login exists yet, prints the one-time
|
||||
* setup code (vrek dec-nw2hvff) so it's in the console before anyone opens /setup.
|
||||
* setup code (vrek dec-nw2hvff) so it's in the console before anyone opens /setup. Also
|
||||
* starts the MediaMTX video bridge (vrek dec-xkn4z0e) unless VIDEO_BRIDGE=off.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
@ -12,4 +13,12 @@ export async function register() {
|
||||
// A malformed admin file: say so loudly; every request will fail until it's fixed.
|
||||
console.error(`[auth] ${(err as Error).message}`);
|
||||
}
|
||||
if (process.env.VIDEO_BRIDGE === "off" || process.env.NEXT_PHASE === "phase-production-build") return;
|
||||
const { startVideoBridge } = await import("./lib/video");
|
||||
try {
|
||||
await startVideoBridge();
|
||||
} catch (err) {
|
||||
// Video is optional: the dashboard keeps working on snapshots without it.
|
||||
console.error(`[video] could not start the video bridge: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ const fake = await vi.hoisted(async () => {
|
||||
connect: (cam: FakeCam, done: (err: Error | null) => void) => void;
|
||||
deviceInformation: (done: Callback<Record<string, string>>) => void;
|
||||
snapshotUri: (options: { profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
||||
streamUri?: (options: { protocol: string; profileToken?: string }, done: Callback<{ uri?: string }>) => void;
|
||||
}
|
||||
class FakeCam extends EventEmitter {
|
||||
static instances: FakeCam[] = [];
|
||||
@ -36,6 +37,9 @@ const fake = await vi.hoisted(async () => {
|
||||
getSnapshotUri(options: { profileToken?: string }, cb: Callback<{ uri?: string }>) {
|
||||
FakeCam.script.snapshotUri(options, cb);
|
||||
}
|
||||
getStreamUri(options: { protocol: string; profileToken?: string }, cb: Callback<{ uri?: string }>) {
|
||||
FakeCam.script.streamUri!(options, cb);
|
||||
}
|
||||
digestAuth(challenges: string[], req: { method: string; path: string }) {
|
||||
return `Digest from=${challenges.length} ${req.method} ${req.path}`;
|
||||
}
|
||||
@ -117,6 +121,8 @@ function healthyCamera() {
|
||||
serialNumber: "SN1",
|
||||
}),
|
||||
snapshotUri: (_options, done) => done(null, { uri: advertisedUri() }),
|
||||
streamUri: (options, done) =>
|
||||
done(null, { uri: `rtsp://camera.internal:554/Streaming/Channels/${options.profileToken}?transportmode=unicast` }),
|
||||
};
|
||||
}
|
||||
|
||||
@ -426,3 +432,49 @@ describe("getSnapshot", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rtspSourceWithLogin (iss-nk6zrzv)", () => {
|
||||
it("asks for the main (first) or sub (second) profile's RTSP URI at the registry address", async () => {
|
||||
const asked: unknown[] = [];
|
||||
FakeCam.script.streamUri = (options, done) => {
|
||||
asked.push(options);
|
||||
done(null, { uri: `rtsp://camera.internal:554/ch/${options.profileToken}` });
|
||||
};
|
||||
expect(await camera.rtspSourceWithLogin(target(), "main")).toBe("rtsp://admin:pw@127.0.0.1:554/ch/p1");
|
||||
expect(await camera.rtspSourceWithLogin(target(), "sub")).toBe("rtsp://admin:pw@127.0.0.1:554/ch/p2");
|
||||
expect(asked).toEqual([
|
||||
{ protocol: "RTSP", profileToken: "p1" },
|
||||
{ protocol: "RTSP", profileToken: "p2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("URL-encodes the login", async () => {
|
||||
getCredentials.mockResolvedValue({ username: "cam@home", password: "p:w/#?" });
|
||||
expect(await camera.rtspSourceWithLogin(target(), "main")).toBe(
|
||||
"rtsp://cam%40home:p%3Aw%2F%23%3F@127.0.0.1:554/Streaming/Channels/p1?transportmode=unicast",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the login out when none is stored", async () => {
|
||||
getCredentials.mockResolvedValue(null);
|
||||
expect(await camera.rtspSourceWithLogin(target(), "main")).toBe(
|
||||
"rtsp://127.0.0.1:554/Streaming/Channels/p1?transportmode=unicast",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails for a stream the camera doesn't have", async () => {
|
||||
FakeCam.script.connect = (cam, done) => {
|
||||
cam.profiles = [media1Profile];
|
||||
done(null);
|
||||
};
|
||||
await expect(camera.rtspSourceWithLogin(target(), "sub")).rejects.toThrow("Camera has no sub stream");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an error", (done: (e: Error | null, v?: { uri?: string }) => void) => done(new Error("SOAP fault"))],
|
||||
["no URI", (done: (e: Error | null, v?: { uri?: string }) => void) => done(null, {})],
|
||||
])("rejects when the camera returns %s", async (_l, answer) => {
|
||||
FakeCam.script.streamUri = (_o, done) => answer(done);
|
||||
await expect(camera.rtspSourceWithLogin(target(), "main")).rejects.toThrow(/SOAP fault|no stream URI/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -289,3 +289,28 @@ export async function getSnapshot(target: CameraTarget, profileToken?: string):
|
||||
body: res.body,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's RTSP address for its main (first) or sub (second) stream, with the stored
|
||||
* login embedded, for the local video bridge only (vrek iss-nk6zrzv). Contains the camera
|
||||
* password: never return it to a client or log it.
|
||||
*/
|
||||
export async function rtspSourceWithLogin(target: CameraTarget, stream: "main" | "sub"): Promise<string> {
|
||||
const cam = await connect(target);
|
||||
const profile = (cam.profiles ?? [])[stream === "main" ? 0 : 1];
|
||||
if (!profile) throw new Error(`Camera has no ${stream} stream`);
|
||||
const uri = await new Promise<string>((resolve, reject) =>
|
||||
cam.getStreamUri({ protocol: "RTSP", profileToken: profileToken(profile) }, (err, res) =>
|
||||
err || !res?.uri ? reject(err ?? new Error("Camera returned no stream URI")) : resolve(res.uri),
|
||||
),
|
||||
);
|
||||
const url = new URL(uri);
|
||||
// Some cameras advertise an unreachable or internal hostname in the stream URI.
|
||||
url.hostname = target.host;
|
||||
const creds = await getCredentials(target.id);
|
||||
if (creds) {
|
||||
url.username = encodeURIComponent(creds.username);
|
||||
url.password = encodeURIComponent(creds.password);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
86
src/lib/mediamtx-config.test.ts
Normal file
86
src/lib/mediamtx-config.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mediamtxApiUrl,
|
||||
mediamtxConfig,
|
||||
mediamtxPasswordHash,
|
||||
mediamtxWebrtcUrl,
|
||||
pathConfig,
|
||||
pathName,
|
||||
} from "./mediamtx-config";
|
||||
|
||||
const login = { user: "app", pass: "0123456789abcdef" };
|
||||
const config = mediamtxConfig(login);
|
||||
const setting = (key: string) => config.split("\n").find((l) => l.startsWith(`${key}:`));
|
||||
|
||||
describe("mediamtxConfig (security settings, dec-xkn4z0e)", () => {
|
||||
it("binds the API and WebRTC signaling to localhost only", () => {
|
||||
expect(setting("apiAddress")).toBe("apiAddress: 127.0.0.1:9997");
|
||||
expect(setting("webrtcAddress")).toBe("webrtcAddress: 127.0.0.1:8889");
|
||||
});
|
||||
|
||||
it("opens only the WebRTC media port to the LAN", () => {
|
||||
expect(setting("webrtcLocalUDPAddress")).toBe('webrtcLocalUDPAddress: ":8189"');
|
||||
expect(setting("webrtcLocalTCPAddress")).toBe('webrtcLocalTCPAddress: ""');
|
||||
expect(mediamtxConfig(login, 9000)).toContain('webrtcLocalUDPAddress: ":9000"');
|
||||
});
|
||||
|
||||
it.each(["rtsp", "rtmp", "hls", "srt", "moq", "metrics", "pprof", "playback"])("turns %s off", (key) => {
|
||||
expect(setting(key)).toBe(`${key}: false`);
|
||||
});
|
||||
|
||||
it("defines only the app's localhost user, with a hashed password and no publish right", () => {
|
||||
expect(config).not.toContain("user: any");
|
||||
expect(config).not.toContain(login.pass);
|
||||
expect(config).toContain(`pass: "${mediamtxPasswordHash(login.pass)}"`);
|
||||
expect(config).toContain('ips: ["127.0.0.1","::1"]');
|
||||
expect(config).not.toContain("action: publish");
|
||||
expect(config).toMatch(/- action: api\n\s+- action: read/);
|
||||
});
|
||||
|
||||
it("allows no browser origins, records nothing and starts with no paths", () => {
|
||||
expect(setting("apiAllowOrigins")).toBe("apiAllowOrigins: []");
|
||||
expect(setting("webrtcAllowOrigins")).toBe("webrtcAllowOrigins: []");
|
||||
expect(config).toContain("pathDefaults:\n record: false");
|
||||
expect(setting("paths")).toBe("paths: {}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mediamtxPasswordHash", () => {
|
||||
it("uses MediaMTX's sha256:<base64> format", () => {
|
||||
const expected = createHash("sha256").update("secret").digest("base64");
|
||||
expect(mediamtxPasswordHash("secret")).toBe(`sha256:${expected}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("urls", () => {
|
||||
it("point at localhost", () => {
|
||||
expect(mediamtxApiUrl("/v3/info")).toBe("http://127.0.0.1:9997/v3/info");
|
||||
expect(mediamtxWebrtcUrl("/cam/whep")).toBe("http://127.0.0.1:8889/cam/whep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathName", () => {
|
||||
it("names one path per camera stream", () => {
|
||||
expect(pathName("22ec0000-8b90-11b5-845d-d03bf404af54", "sub")).toBe(
|
||||
"cam-22ec0000-8b90-11b5-845d-d03bf404af54-sub",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["../x", "", "ABC", "cam/../../etc"])("rejects a non-id %j", (id) => {
|
||||
expect(() => pathName(id, "main")).toThrow("Invalid camera id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathConfig", () => {
|
||||
it("pulls the camera over TCP only while someone watches, without recording", () => {
|
||||
expect(pathConfig("rtsp://u:p@10.0.0.2/ch1")).toEqual({
|
||||
source: "rtsp://u:p@10.0.0.2/ch1",
|
||||
sourceOnDemand: true,
|
||||
sourceOnDemandStartTimeout: "10s",
|
||||
sourceOnDemandCloseAfter: "10s",
|
||||
rtspTransport: "tcp",
|
||||
record: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
102
src/lib/mediamtx-config.ts
Normal file
102
src/lib/mediamtx-config.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* The MediaMTX configuration the app runs it with (vrek dec-xkn4z0e, iss-nk6zrzv). Pure
|
||||
* functions, so the security-relevant settings are pinned by tests.
|
||||
*
|
||||
* Everything listens on 127.0.0.1 except the WebRTC ICE port, which browsers on the LAN
|
||||
* must reach for media. Only the app's own API user exists (the defaults' anonymous
|
||||
* publish/read users are gone), and its password is stored as a SHA-256 hash. Camera
|
||||
* sources are added at runtime over the API, never written here.
|
||||
*/
|
||||
|
||||
export const MEDIAMTX_HOST = "127.0.0.1";
|
||||
export const MEDIAMTX_API_PORT = 9997;
|
||||
export const MEDIAMTX_WEBRTC_PORT = 8889;
|
||||
/** WebRTC media (ICE) on UDP; the one port that listens on the LAN. */
|
||||
export const MEDIAMTX_ICE_PORT = 8189;
|
||||
|
||||
export const mediamtxApiUrl = (path: string) => `http://${MEDIAMTX_HOST}:${MEDIAMTX_API_PORT}${path}`;
|
||||
export const mediamtxWebrtcUrl = (path: string) => `http://${MEDIAMTX_HOST}:${MEDIAMTX_WEBRTC_PORT}${path}`;
|
||||
|
||||
export interface ApiLogin {
|
||||
user: string;
|
||||
pass: string;
|
||||
}
|
||||
|
||||
/** MediaMTX's "sha256:" credential format: base64 of the SHA-256 of the password. */
|
||||
export function mediamtxPasswordHash(pass: string): string {
|
||||
return `sha256:${createHash("sha256").update(pass).digest("base64")}`;
|
||||
}
|
||||
|
||||
/** Only the settings we rely on; everything else keeps MediaMTX's defaults. */
|
||||
export function mediamtxConfig(login: ApiLogin, icePort = MEDIAMTX_ICE_PORT): string {
|
||||
const local = ["127.0.0.1", "::1"];
|
||||
const lines = [
|
||||
"# Generated by the cameras app on each start (vrek dec-xkn4z0e). Do not edit.",
|
||||
"logLevel: info",
|
||||
"logDestinations: [stdout]",
|
||||
"",
|
||||
"authMethod: internal",
|
||||
"authInternalUsers:",
|
||||
` - user: ${login.user}`,
|
||||
` pass: "${mediamtxPasswordHash(login.pass)}"`,
|
||||
` ips: ${JSON.stringify(local)}`,
|
||||
" permissions:",
|
||||
" - action: api",
|
||||
" - action: read",
|
||||
"",
|
||||
"api: true",
|
||||
`apiAddress: ${MEDIAMTX_HOST}:${MEDIAMTX_API_PORT}`,
|
||||
"apiAllowOrigins: []",
|
||||
"metrics: false",
|
||||
"pprof: false",
|
||||
"playback: false",
|
||||
"",
|
||||
"# Protocols the app doesn't use are off.",
|
||||
"rtsp: false",
|
||||
"rtmp: false",
|
||||
"hls: false",
|
||||
"srt: false",
|
||||
"moq: false",
|
||||
"",
|
||||
"webrtc: true",
|
||||
`webrtcAddress: ${MEDIAMTX_HOST}:${MEDIAMTX_WEBRTC_PORT}`,
|
||||
"webrtcAllowOrigins: []",
|
||||
`webrtcLocalUDPAddress: ":${icePort}"`,
|
||||
'webrtcLocalTCPAddress: ""',
|
||||
"webrtcIPsFromInterfaces: true",
|
||||
"webrtcICEServers2: []",
|
||||
"",
|
||||
"pathDefaults:",
|
||||
" record: false",
|
||||
"",
|
||||
"paths: {}",
|
||||
"",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export type StreamKind = "main" | "sub";
|
||||
|
||||
/** One MediaMTX path per camera stream, e.g. "cam-22ec…af54-main". */
|
||||
export function pathName(cameraId: string, stream: StreamKind): string {
|
||||
if (!/^[0-9a-f-]{32,36}$/.test(cameraId)) throw new Error("Invalid camera id");
|
||||
return `cam-${cameraId}-${stream}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime path configuration: pull the camera's RTSP stream only while someone is
|
||||
* watching, over TCP (no RTP ports to open towards the camera).
|
||||
*/
|
||||
export function pathConfig(source: string) {
|
||||
return {
|
||||
source,
|
||||
sourceOnDemand: true,
|
||||
sourceOnDemandStartTimeout: "10s",
|
||||
sourceOnDemandCloseAfter: "10s",
|
||||
rtspTransport: "tcp",
|
||||
record: false,
|
||||
};
|
||||
}
|
||||
185
src/lib/mediamtx-install.test.ts
Normal file
185
src/lib/mediamtx-install.test.ts
Normal file
@ -0,0 +1,185 @@
|
||||
// Network and archive steps use fakes, a loopback HTTP server and the local tar binary;
|
||||
// nothing is downloaded from the internet (vrek pri-e14bahk).
|
||||
import { execFile } from "node:child_process";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
defaultDeps,
|
||||
installMediamtx,
|
||||
MEDIAMTX_RELEASE,
|
||||
MediamtxInstallError,
|
||||
platformKey,
|
||||
releaseAsset,
|
||||
sha256Of,
|
||||
type InstallDeps,
|
||||
} from "./mediamtx-install";
|
||||
|
||||
const ARCHIVE = new TextEncoder().encode("pretend this is a tar.gz");
|
||||
const release = { version: "v9.9.9", sha256: { "darwin-arm64": sha256Of(ARCHIVE) } };
|
||||
|
||||
let dir: string;
|
||||
let binDir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(os.tmpdir(), "mediamtx-test-"));
|
||||
binDir = path.join(dir, "bin");
|
||||
});
|
||||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
/** A download that returns `data` and an extract that drops a fake binary. */
|
||||
function fakes(data = ARCHIVE, binary: string | null = "#!/bin/sh\necho mediamtx\n") {
|
||||
return {
|
||||
download: vi.fn<InstallDeps["download"]>(async () => data),
|
||||
extract: vi.fn<InstallDeps["extract"]>(async (_archive, into, name) => {
|
||||
if (binary !== null) await writeFile(path.join(into, name), binary);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("platformKey", () => {
|
||||
it.each([
|
||||
["darwin", "arm64", "darwin-arm64"],
|
||||
["darwin", "x64", "darwin-amd64"],
|
||||
["linux", "x64", "linux-amd64"],
|
||||
["linux", "arm64", "linux-arm64"],
|
||||
])("%s/%s → %s", (platform, arch, key) => {
|
||||
expect(platformKey(platform, arch)).toBe(key);
|
||||
});
|
||||
|
||||
it("defaults to this machine", () => {
|
||||
expect(platformKey()).toBe(platformKey(process.platform, process.arch));
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseAsset", () => {
|
||||
it("names the GitHub release archive and its pinned checksum", () => {
|
||||
expect(releaseAsset("darwin-arm64")).toEqual({
|
||||
file: "mediamtx_v1.21.0_darwin_arm64.tar.gz",
|
||||
url: "https://github.com/bluenviron/mediamtx/releases/download/v1.21.0/mediamtx_v1.21.0_darwin_arm64.tar.gz",
|
||||
sha256: MEDIAMTX_RELEASE.sha256["darwin-arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("pins a full SHA-256 for every supported platform", () => {
|
||||
for (const hash of Object.values(MEDIAMTX_RELEASE.sha256)) expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("refuses a platform with no pinned build", () => {
|
||||
expect(() => releaseAsset("win32-amd64")).toThrow(/No pinned MediaMTX build for win32-amd64\. Supported: darwin-arm64/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installMediamtx", () => {
|
||||
it("verifies, extracts and installs an executable binary with a version marker", async () => {
|
||||
const deps = fakes();
|
||||
const result = await installMediamtx({ binDir, key: "darwin-arm64", release, deps });
|
||||
|
||||
expect(result).toEqual({ status: "installed", version: "v9.9.9", binary: path.join(binDir, "mediamtx") });
|
||||
expect(deps.download).toHaveBeenCalledWith(
|
||||
"https://github.com/bluenviron/mediamtx/releases/download/v9.9.9/mediamtx_v9.9.9_darwin_arm64.tar.gz",
|
||||
);
|
||||
expect(deps.extract.mock.calls[0][2]).toBe("mediamtx");
|
||||
expect((await stat(result.binary)).mode & 0o777).toBe(0o755);
|
||||
expect(JSON.parse(await readFile(path.join(binDir, "mediamtx.version.json"), "utf8"))).toEqual({
|
||||
version: "v9.9.9",
|
||||
sha256: release.sha256["darwin-arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a matching install alone without downloading", async () => {
|
||||
await installMediamtx({ binDir, key: "darwin-arm64", release, deps: fakes() });
|
||||
const again = fakes();
|
||||
expect((await installMediamtx({ binDir, key: "darwin-arm64", release, deps: again })).status).toBe(
|
||||
"already-installed",
|
||||
);
|
||||
expect(again.download).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls when the pinned version changes or the binary is missing", async () => {
|
||||
await installMediamtx({ binDir, key: "darwin-arm64", release, deps: fakes() });
|
||||
const newer = { ...release, version: "v10.0.0" };
|
||||
expect((await installMediamtx({ binDir, key: "darwin-arm64", release: newer, deps: fakes() })).status).toBe(
|
||||
"installed",
|
||||
);
|
||||
|
||||
await rm(path.join(binDir, "mediamtx"));
|
||||
expect((await installMediamtx({ binDir, key: "darwin-arm64", release: newer, deps: fakes() })).status).toBe(
|
||||
"installed",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a download whose checksum doesn't match, writing nothing", async () => {
|
||||
const deps = fakes(new TextEncoder().encode("tampered"));
|
||||
await expect(installMediamtx({ binDir, key: "darwin-arm64", release, deps })).rejects.toThrow(
|
||||
/Checksum mismatch for mediamtx_v9\.9\.9_darwin_arm64\.tar\.gz.*Nothing was installed/,
|
||||
);
|
||||
expect(deps.extract).not.toHaveBeenCalled();
|
||||
await expect(stat(binDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("fails clearly when the archive has no binary", async () => {
|
||||
await expect(
|
||||
installMediamtx({ binDir, key: "darwin-arm64", release, deps: fakes(ARCHIVE, null) }),
|
||||
).rejects.toBeInstanceOf(MediamtxInstallError);
|
||||
await expect(stat(path.join(binDir, "mediamtx"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses an unsupported platform before downloading", async () => {
|
||||
const deps = fakes();
|
||||
await expect(installMediamtx({ binDir, key: "plan9-mips", release, deps })).rejects.toThrow(/No pinned/);
|
||||
expect(deps.download).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultDeps.extract", () => {
|
||||
it("pulls just the named file out of a real .tar.gz", async () => {
|
||||
const src = path.join(dir, "src");
|
||||
await (await import("node:fs/promises")).mkdir(src);
|
||||
await writeFile(path.join(src, "mediamtx"), "binary");
|
||||
await writeFile(path.join(src, "LICENSE"), "MIT");
|
||||
const archive = path.join(dir, "a.tar.gz");
|
||||
await promisify(execFile)("tar", ["-czf", archive, "-C", src, "mediamtx", "LICENSE"]);
|
||||
|
||||
const out = await mkdtemp(path.join(dir, "out-"));
|
||||
await defaultDeps.extract(archive, out, "mediamtx");
|
||||
expect(await readFile(path.join(out, "mediamtx"), "utf8")).toBe("binary");
|
||||
await expect(stat(path.join(out, "LICENSE"))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultDeps.download", () => {
|
||||
let server: Server;
|
||||
let base: string;
|
||||
let respond: (res: import("node:http").ServerResponse) => void;
|
||||
beforeEach(async () => {
|
||||
server = createServer((_req, res) => respond(res));
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
afterEach(() => new Promise<void>((r) => server.close(() => r())));
|
||||
|
||||
it("returns the body", async () => {
|
||||
respond = (res) => res.end("archive-bytes");
|
||||
expect(new TextDecoder().decode(await defaultDeps.download(`${base}/a.tar.gz`))).toBe("archive-bytes");
|
||||
});
|
||||
|
||||
it("fails on an HTTP error", async () => {
|
||||
respond = (res) => {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
};
|
||||
await expect(defaultDeps.download(`${base}/missing`)).rejects.toThrow("Download failed: HTTP 404");
|
||||
});
|
||||
|
||||
it("refuses an oversized download from its declared length", async () => {
|
||||
respond = (res) => {
|
||||
res.setHeader("content-length", String(200 * 1024 * 1024));
|
||||
res.end();
|
||||
};
|
||||
await expect(defaultDeps.download(`${base}/huge`)).rejects.toThrow(/Download too large/);
|
||||
});
|
||||
});
|
||||
156
src/lib/mediamtx-install.ts
Normal file
156
src/lib/mediamtx-install.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Downloads the pinned MediaMTX release and installs its binary into bin/ (vrek
|
||||
* dec-xkn4z0e, iss-qdc4vqw). The archive must match a SHA-256 committed here, so a changed
|
||||
* or tampered download is refused before anything is written.
|
||||
*
|
||||
* Plain Node on purpose (no "server-only", no path aliases, no TS-only syntax) so that
|
||||
* scripts/install-mediamtx.mts can run it directly with Node's type stripping.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { access, chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
/**
|
||||
* To upgrade: bump the version and replace every sha256 with the values from that
|
||||
* release's checksums.sha256 (cross-check against GitHub's per-asset digests).
|
||||
*/
|
||||
export const MEDIAMTX_RELEASE = {
|
||||
version: "v1.21.0",
|
||||
sha256: {
|
||||
"darwin-arm64": "159b8e8164022189e654b61ee8bb7edf2d3ff9354ff06cf4c315bb59f5cc9eca",
|
||||
"darwin-amd64": "2ee772efb7e2e365307599e41f849c54539f6019a2ebd4563be9b1256edfeb65",
|
||||
"linux-amd64": "e02e34c3337a35f20ac9e5aa31524566108964e6e37dbc46cf8292169f6c792b",
|
||||
"linux-arm64": "a8113b5928ba1a934b81557b61b8a07954b76921a4b567d54c7f086f8b39d9a2",
|
||||
} as Record<string, string>,
|
||||
};
|
||||
|
||||
const BINARY = "mediamtx";
|
||||
const MARKER = "mediamtx.version.json";
|
||||
/** Release archives are about 27 MB; anything far larger is not what we expect. */
|
||||
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
export class MediamtxInstallError extends Error {
|
||||
name = "MediamtxInstallError";
|
||||
}
|
||||
|
||||
/** Node's platform/arch as MediaMTX names them, e.g. "darwin-arm64", "linux-amd64". */
|
||||
export function platformKey(platform: string = process.platform, arch: string = process.arch): string {
|
||||
return `${platform}-${arch === "x64" ? "amd64" : arch}`;
|
||||
}
|
||||
|
||||
export interface ReleaseAsset {
|
||||
file: string;
|
||||
url: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export function releaseAsset(key: string, release = MEDIAMTX_RELEASE): ReleaseAsset {
|
||||
const sha256 = release.sha256[key];
|
||||
if (!sha256) {
|
||||
throw new MediamtxInstallError(
|
||||
`No pinned MediaMTX build for ${key}. Supported: ${Object.keys(release.sha256).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const [platform, arch] = key.split("-");
|
||||
const file = `mediamtx_${release.version}_${platform}_${arch}.tar.gz`;
|
||||
return { file, sha256, url: `https://github.com/bluenviron/mediamtx/releases/download/${release.version}/${file}` };
|
||||
}
|
||||
|
||||
export function sha256Of(data: Uint8Array): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
/** The network and archive steps, replaceable in tests (pri-e14bahk). */
|
||||
export interface InstallDeps {
|
||||
download(url: string): Promise<Uint8Array>;
|
||||
/** Extracts the single file `name` from the .tar.gz at `archive` into the folder `into`. */
|
||||
extract(archive: string, into: string, name: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const defaultDeps: InstallDeps = {
|
||||
async download(url) {
|
||||
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
|
||||
if (!res.ok) throw new MediamtxInstallError(`Download failed: HTTP ${res.status} for ${url}`);
|
||||
const length = Number(res.headers.get("content-length") ?? 0);
|
||||
if (length > MAX_ARCHIVE_BYTES) throw new MediamtxInstallError(`Download too large (${length} bytes)`);
|
||||
const data = new Uint8Array(await res.arrayBuffer());
|
||||
if (data.length > MAX_ARCHIVE_BYTES) throw new MediamtxInstallError(`Download too large (${data.length} bytes)`);
|
||||
return data;
|
||||
},
|
||||
async extract(archive, into, name) {
|
||||
await promisify(execFile)("tar", ["-xzf", archive, "-C", into, name]);
|
||||
},
|
||||
};
|
||||
|
||||
export interface InstallResult {
|
||||
status: "installed" | "already-installed";
|
||||
version: string;
|
||||
binary: string;
|
||||
}
|
||||
|
||||
async function exists(file: string) {
|
||||
return access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
async function installedMarker(binDir: string): Promise<{ version: string; sha256: string } | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(binDir, MARKER), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the pinned MediaMTX into `binDir` unless that exact build is already there.
|
||||
* Verifies the archive's SHA-256 before extracting, and swaps the binary in atomically.
|
||||
*/
|
||||
export async function installMediamtx({
|
||||
binDir = path.join(process.cwd(), "bin"),
|
||||
key = platformKey(),
|
||||
release = MEDIAMTX_RELEASE,
|
||||
deps = defaultDeps,
|
||||
}: { binDir?: string; key?: string; release?: typeof MEDIAMTX_RELEASE; deps?: InstallDeps } = {}): Promise<InstallResult> {
|
||||
const asset = releaseAsset(key, release);
|
||||
const binary = path.join(binDir, BINARY);
|
||||
const marker = await installedMarker(binDir);
|
||||
if (marker?.version === release.version && marker.sha256 === asset.sha256 && (await exists(binary))) {
|
||||
return { status: "already-installed", version: release.version, binary };
|
||||
}
|
||||
|
||||
const data = await deps.download(asset.url);
|
||||
const actual = sha256Of(data);
|
||||
if (actual !== asset.sha256) {
|
||||
throw new MediamtxInstallError(
|
||||
`Checksum mismatch for ${asset.file}: expected ${asset.sha256}, got ${actual}. Nothing was installed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const work = await mkdtemp(path.join(os.tmpdir(), "mediamtx-"));
|
||||
try {
|
||||
const archive = path.join(work, asset.file);
|
||||
await writeFile(archive, data);
|
||||
await deps.extract(archive, work, BINARY);
|
||||
const extracted = path.join(work, BINARY);
|
||||
if (!(await exists(extracted))) throw new MediamtxInstallError(`${asset.file} has no ${BINARY} binary`);
|
||||
|
||||
await mkdir(binDir, { recursive: true });
|
||||
// Copy next to the target first (tmp may be another volume), then rename into place.
|
||||
const staged = `${binary}.tmp`;
|
||||
await copyFile(extracted, staged);
|
||||
await chmod(staged, 0o755);
|
||||
await rename(staged, binary);
|
||||
await writeFile(
|
||||
path.join(binDir, MARKER),
|
||||
JSON.stringify({ version: release.version, sha256: asset.sha256 }, null, 2) + "\n",
|
||||
);
|
||||
return { status: "installed", version: release.version, binary };
|
||||
} finally {
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
184
src/lib/mediamtx-supervisor.test.ts
Normal file
184
src/lib/mediamtx-supervisor.test.ts
Normal file
@ -0,0 +1,184 @@
|
||||
// A fake child process and captured timers stand in for MediaMTX; no real process is
|
||||
// started (vrek pri-e14bahk).
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MediamtxSupervisor, type SupervisorOptions } from "./mediamtx-supervisor";
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
stdout = new PassThrough();
|
||||
stderr = new PassThrough();
|
||||
kill = vi.fn();
|
||||
}
|
||||
|
||||
let children: FakeChild[];
|
||||
let timers: { fn: () => void; ms: number }[];
|
||||
let clock: number;
|
||||
let logs: string[];
|
||||
let ready: { resolve: () => void; reject: (e: Error) => void }[];
|
||||
|
||||
beforeEach(() => {
|
||||
children = [];
|
||||
timers = [];
|
||||
clock = 0;
|
||||
logs = [];
|
||||
ready = [];
|
||||
});
|
||||
|
||||
function supervisor(overrides: Partial<SupervisorOptions> = {}) {
|
||||
const spawn = vi.fn(() => {
|
||||
const child = new FakeChild();
|
||||
children.push(child);
|
||||
return child;
|
||||
});
|
||||
const sup = new MediamtxSupervisor({
|
||||
binary: "/app/bin/mediamtx",
|
||||
configPath: "/app/.data/mediamtx/mediamtx.yml",
|
||||
cwd: "/app/.data/mediamtx",
|
||||
waitReady: () => new Promise<void>((resolve, reject) => ready.push({ resolve, reject })),
|
||||
spawn: spawn as unknown as SupervisorOptions["spawn"],
|
||||
setTimer: (fn, ms) => timers.push({ fn, ms }),
|
||||
clearTimer: vi.fn(),
|
||||
now: () => clock,
|
||||
log: (l) => logs.push(l),
|
||||
...overrides,
|
||||
});
|
||||
return { sup, spawn };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("MediamtxSupervisor", () => {
|
||||
it("starts MediaMTX with the config, becomes running when ready, and forwards its log", async () => {
|
||||
const { sup, spawn } = supervisor();
|
||||
expect(sup.status()).toEqual({ state: "stopped", restarts: 0 });
|
||||
sup.start();
|
||||
sup.start(); // idempotent
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith("/app/bin/mediamtx", ["/app/.data/mediamtx/mediamtx.yml"], {
|
||||
cwd: "/app/.data/mediamtx",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
expect(sup.status().state).toBe("starting");
|
||||
|
||||
ready[0].resolve();
|
||||
await tick();
|
||||
expect(sup.status().state).toBe("running");
|
||||
|
||||
children[0].stdout.write("INF MediaMTX v1.21.0\n\n");
|
||||
children[0].stderr.write("WAR something\n");
|
||||
await tick();
|
||||
expect(logs).toEqual(["[mediamtx] INF MediaMTX v1.21.0", "[mediamtx] WAR something"]);
|
||||
});
|
||||
|
||||
it("restarts after a crash with doubling backoff, capped at 30 s", async () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
const waits: number[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
children.at(-1)!.emit("exit", 1, null);
|
||||
const timer = timers.at(-1)!;
|
||||
waits.push(timer.ms);
|
||||
timer.fn();
|
||||
}
|
||||
expect(waits).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000]);
|
||||
expect(sup.status().restarts).toBe(7);
|
||||
expect(children).toHaveLength(8);
|
||||
expect(logs).toContain("[video] MediaMTX exited (code 1); restarting in 1 s");
|
||||
});
|
||||
|
||||
it("starts backoff over after a run that lasted a minute", () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
children[0].emit("exit", 1, null);
|
||||
timers[0].fn();
|
||||
children[1].emit("exit", null, "SIGKILL");
|
||||
expect(timers[1].ms).toBe(2000);
|
||||
expect(sup.status()).toMatchObject({ state: "crashed", detail: "MediaMTX exited (SIGKILL)" });
|
||||
|
||||
timers[1].fn();
|
||||
clock += 60_000;
|
||||
children[2].emit("exit", 1, null);
|
||||
expect(timers[2].ms).toBe(1000);
|
||||
});
|
||||
|
||||
it("reports a missing binary and doesn't retry", () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
children[0].emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }));
|
||||
expect(sup.status()).toEqual({
|
||||
state: "missing",
|
||||
detail: "MediaMTX is not installed. Run npm run video:install.",
|
||||
restarts: 0,
|
||||
});
|
||||
expect(timers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retries other spawn errors", () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
children[0].emit("error", Object.assign(new Error("EACCES"), { code: "EACCES" }));
|
||||
expect(sup.status()).toMatchObject({ state: "crashed", detail: "MediaMTX failed to start (EACCES)" });
|
||||
expect(timers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("kills a MediaMTX that never becomes ready, which then restarts", async () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
ready[0].reject(new Error("no answer"));
|
||||
await tick();
|
||||
expect(children[0].kill).toHaveBeenCalledWith("SIGTERM");
|
||||
children[0].emit("exit", null, "SIGTERM");
|
||||
expect(timers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores readiness and exits from a process it has replaced", async () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
const first = children[0];
|
||||
first.emit("exit", 1, null);
|
||||
timers[0].fn();
|
||||
ready[0].resolve();
|
||||
ready[0].reject?.(new Error("late"));
|
||||
await tick();
|
||||
expect(sup.status().state).toBe("starting");
|
||||
first.emit("exit", 1, null);
|
||||
first.emit("error", new Error("late"));
|
||||
expect(timers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stops MediaMTX and doesn't restart it", () => {
|
||||
const { sup } = supervisor();
|
||||
sup.start();
|
||||
const child = children[0];
|
||||
sup.stop();
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
expect(timers).toHaveLength(0);
|
||||
expect(sup.status().state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("cancels a pending restart when stopped", () => {
|
||||
const clearTimer = vi.fn();
|
||||
const { sup } = supervisor({ clearTimer });
|
||||
sup.start();
|
||||
children[0].emit("exit", 1, null);
|
||||
sup.stop();
|
||||
expect(clearTimer).toHaveBeenCalled();
|
||||
timers[0].fn();
|
||||
expect(children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses real timers, spawn and console by default", () => {
|
||||
const sup = new MediamtxSupervisor({
|
||||
binary: "/nonexistent/mediamtx",
|
||||
configPath: "x",
|
||||
cwd: "/",
|
||||
waitReady: () => new Promise(() => {}),
|
||||
spawn: (() => new FakeChild()) as unknown as SupervisorOptions["spawn"],
|
||||
});
|
||||
sup.start();
|
||||
sup.stop();
|
||||
expect(sup.status().state).toBe("stopped");
|
||||
});
|
||||
});
|
||||
139
src/lib/mediamtx-supervisor.ts
Normal file
139
src/lib/mediamtx-supervisor.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import "server-only";
|
||||
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Runs MediaMTX as a child process and keeps it running (vrek dec-xkn4z0e): restarts it
|
||||
* after a crash with exponential backoff, and stops it with the server. The spawn and
|
||||
* timer functions are injectable so tests never start a real process (pri-e14bahk).
|
||||
*/
|
||||
|
||||
export type BridgeState = "stopped" | "missing" | "starting" | "running" | "crashed";
|
||||
|
||||
export interface BridgeStatus {
|
||||
state: BridgeState;
|
||||
/** Short, fixed text for the UI and logs; never includes secrets. */
|
||||
detail?: string;
|
||||
restarts: number;
|
||||
}
|
||||
|
||||
export interface SupervisorOptions {
|
||||
binary: string;
|
||||
configPath: string;
|
||||
cwd: string;
|
||||
/** Resolves once MediaMTX answers (e.g. its API); rejects if it never does. */
|
||||
waitReady: () => Promise<void>;
|
||||
spawn?: typeof nodeSpawn;
|
||||
setTimer?: (fn: () => void, ms: number) => unknown;
|
||||
clearTimer?: (handle: unknown) => void;
|
||||
now?: () => number;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
const MIN_BACKOFF_MS = 1_000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
/** A run this long counts as healthy, so the next crash starts backoff from the minimum. */
|
||||
const STABLE_MS = 60_000;
|
||||
|
||||
export class MediamtxSupervisor {
|
||||
private child: ChildProcess | null = null;
|
||||
private timer: unknown = null;
|
||||
private backoff = MIN_BACKOFF_MS;
|
||||
private startedAt = 0;
|
||||
private stopping = false;
|
||||
private current: BridgeStatus = { state: "stopped", restarts: 0 };
|
||||
|
||||
private readonly spawnFn: typeof nodeSpawn;
|
||||
private readonly setTimer: (fn: () => void, ms: number) => unknown;
|
||||
private readonly clearTimer: (handle: unknown) => void;
|
||||
private readonly now: () => number;
|
||||
private readonly log: (line: string) => void;
|
||||
|
||||
constructor(private readonly opts: SupervisorOptions) {
|
||||
this.spawnFn = opts.spawn ?? nodeSpawn;
|
||||
this.setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms).unref());
|
||||
this.clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as NodeJS.Timeout));
|
||||
this.now = opts.now ?? Date.now;
|
||||
this.log = opts.log ?? ((line) => console.log(line));
|
||||
}
|
||||
|
||||
status(): BridgeStatus {
|
||||
return { ...this.current };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.child) return;
|
||||
this.stopping = false;
|
||||
this.launch();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopping = true;
|
||||
if (this.timer !== null) this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
this.child?.kill("SIGTERM");
|
||||
this.child = null;
|
||||
this.current = { ...this.current, state: "stopped", detail: undefined };
|
||||
}
|
||||
|
||||
private set(state: BridgeState, detail?: string) {
|
||||
this.current = { ...this.current, state, detail };
|
||||
}
|
||||
|
||||
private launch() {
|
||||
this.set("starting");
|
||||
this.startedAt = this.now();
|
||||
const child = this.spawnFn(this.opts.binary, [this.opts.configPath], {
|
||||
cwd: this.opts.cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
this.child = child;
|
||||
|
||||
const forward = (chunk: Buffer) => {
|
||||
for (const line of chunk.toString("utf8").split("\n")) if (line.trim()) this.log(`[mediamtx] ${line.trimEnd()}`);
|
||||
};
|
||||
child.stdout?.on("data", forward);
|
||||
child.stderr?.on("data", forward);
|
||||
|
||||
child.once("error", (err: NodeJS.ErrnoException) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
if (err.code === "ENOENT") {
|
||||
// Not installed: retrying won't help until someone runs the installer.
|
||||
this.set("missing", "MediaMTX is not installed. Run npm run video:install.");
|
||||
this.log(`[video] ${this.current.detail}`);
|
||||
return;
|
||||
}
|
||||
this.crashed(`MediaMTX failed to start (${err.code ?? "error"})`);
|
||||
});
|
||||
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.child !== child) return;
|
||||
this.child = null;
|
||||
if (this.stopping) return;
|
||||
this.crashed(`MediaMTX exited (${signal ?? `code ${code}`})`);
|
||||
});
|
||||
|
||||
this.opts.waitReady().then(
|
||||
() => {
|
||||
if (this.child === child) this.set("running");
|
||||
},
|
||||
() => {
|
||||
if (this.child !== child) return;
|
||||
this.log("[video] MediaMTX did not become ready; restarting it");
|
||||
child.kill("SIGTERM");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private crashed(detail: string) {
|
||||
if (this.now() - this.startedAt >= STABLE_MS) this.backoff = MIN_BACKOFF_MS;
|
||||
const wait = this.backoff;
|
||||
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
|
||||
this.current = { state: "crashed", detail, restarts: this.current.restarts + 1 };
|
||||
this.log(`[video] ${detail}; restarting in ${wait / 1000} s`);
|
||||
this.timer = this.setTimer(() => {
|
||||
this.timer = null;
|
||||
if (!this.stopping) this.launch();
|
||||
}, wait);
|
||||
}
|
||||
}
|
||||
167
src/lib/video.test.ts
Normal file
167
src/lib/video.test.ts
Normal file
@ -0,0 +1,167 @@
|
||||
// MediaMTX is replaced by a fake supervisor and a stubbed fetch; no process is started and
|
||||
// no network is used (vrek pri-e14bahk).
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BridgeStatus, MediamtxSupervisor, SupervisorOptions } from "./mediamtx-supervisor";
|
||||
|
||||
const rtspSourceWithLogin = vi.fn();
|
||||
vi.mock("./camera", () => ({ rtspSourceWithLogin }));
|
||||
|
||||
const video = await import("./video");
|
||||
|
||||
const ID = "22ec0000-8b90-11b5-845d-d03bf404af54";
|
||||
const target = { id: ID, host: "192.168.1.10", port: 80 };
|
||||
const SOURCE = "rtsp://camera:s3cret@192.168.1.10:554/Streaming/Channels/101";
|
||||
|
||||
let dir: string;
|
||||
let status: BridgeStatus;
|
||||
let options: SupervisorOptions;
|
||||
const fake = { start: vi.fn(), stop: vi.fn(), status: () => ({ ...status }) };
|
||||
const factory = (o: SupervisorOptions) => {
|
||||
options = o;
|
||||
return fake as unknown as MediamtxSupervisor;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(os.tmpdir(), "video-"));
|
||||
vi.spyOn(process, "cwd").mockReturnValue(dir);
|
||||
status = { state: "running", restarts: 0 };
|
||||
fake.start.mockReset();
|
||||
fake.stop.mockReset();
|
||||
rtspSourceWithLogin.mockReset().mockResolvedValue(SOURCE);
|
||||
video.resetVideoBridgeForTests();
|
||||
});
|
||||
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||
|
||||
function stubApi(handler: (url: string, init: RequestInit) => Response | Promise<Response>) {
|
||||
const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => handler(String(url), init ?? {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("startVideoBridge", () => {
|
||||
it("writes an owner-only localhost config with a hashed per-boot password and starts MediaMTX", async () => {
|
||||
const once = vi.spyOn(process, "once");
|
||||
expect(await video.startVideoBridge(factory)).toEqual(status);
|
||||
expect(fake.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
const bridgeDir = path.join(dir, ".data", "mediamtx");
|
||||
expect(options).toMatchObject({
|
||||
binary: path.join(dir, "bin", "mediamtx"),
|
||||
configPath: path.join(bridgeDir, "mediamtx.yml"),
|
||||
cwd: bridgeDir,
|
||||
});
|
||||
expect((await stat(bridgeDir)).mode & 0o777).toBe(0o700);
|
||||
expect((await stat(options.configPath)).mode & 0o777).toBe(0o600);
|
||||
|
||||
const config = await readFile(options.configPath, "utf8");
|
||||
expect(config).toContain("apiAddress: 127.0.0.1:9997");
|
||||
const auth = video.mediamtxAuthHeader();
|
||||
const pass = Buffer.from(auth.slice("Basic ".length), "base64").toString().split(":")[1];
|
||||
expect(pass).toMatch(/^[0-9a-f]{48}$/);
|
||||
expect(config).not.toContain(pass);
|
||||
|
||||
// Stopped with the server.
|
||||
const onExit = once.mock.calls.find(([event]) => event === "exit")![1] as () => void;
|
||||
onExit();
|
||||
expect(fake.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts only once per process", async () => {
|
||||
await video.startVideoBridge(factory);
|
||||
await video.startVideoBridge(factory);
|
||||
expect(fake.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses MEDIAMTX_BIN when set", async () => {
|
||||
vi.stubEnv("MEDIAMTX_BIN", "/opt/mediamtx");
|
||||
await video.startVideoBridge(factory);
|
||||
expect(options.binary).toBe("/opt/mediamtx");
|
||||
});
|
||||
|
||||
it("builds a real supervisor by default", async () => {
|
||||
vi.stubEnv("MEDIAMTX_BIN", path.join(dir, "no-such-binary"));
|
||||
const state = (await video.startVideoBridge()).state;
|
||||
expect(["starting", "missing"]).toContain(state);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(video.videoBridgeStatus().state).toBe("missing");
|
||||
});
|
||||
|
||||
it("waits for MediaMTX's API with the bridge login", async () => {
|
||||
let calls = 0;
|
||||
const fetchMock = stubApi(() => new Response(null, { status: ++calls < 3 ? 503 : 200 }));
|
||||
await video.startVideoBridge(factory);
|
||||
await expect(options.waitReady()).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://127.0.0.1:9997/v3/info");
|
||||
expect((fetchMock.mock.calls[0][1]!.headers as Record<string, string>).Authorization).toBe(
|
||||
video.mediamtxAuthHeader(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitForApi", () => {
|
||||
it("gives up after its attempts", async () => {
|
||||
stubApi(() => Promise.reject(new Error("ECONNREFUSED")));
|
||||
await expect(video.waitForApi({ user: "app", pass: "x" }, 3, 1)).rejects.toThrow("MediaMTX API did not answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoBridgeStatus / mediamtxAuthHeader before start", () => {
|
||||
it("reports stopped and has no login", () => {
|
||||
expect(video.videoBridgeStatus()).toEqual({ state: "stopped", restarts: 0 });
|
||||
expect(() => video.mediamtxAuthHeader()).toThrow("Video bridge not started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureStreamPath", () => {
|
||||
it("refuses while the bridge isn't running", async () => {
|
||||
await expect(video.ensureStreamPath(target, "main")).rejects.toThrow("Video bridge is not running");
|
||||
status = { state: "crashed", restarts: 1 };
|
||||
await video.startVideoBridge(factory);
|
||||
await expect(video.ensureStreamPath(target, "main")).rejects.toThrow("Video bridge is not running");
|
||||
});
|
||||
|
||||
it("adds the camera's stream as an on-demand path, then reuses it", async () => {
|
||||
const fetchMock = stubApi((url) =>
|
||||
new Response(null, { status: url.includes("/replace/") ? 404 : 200 }),
|
||||
);
|
||||
await video.startVideoBridge(factory);
|
||||
|
||||
expect(await video.ensureStreamPath(target, "main")).toBe(`cam-${ID}-main`);
|
||||
expect(rtspSourceWithLogin).toHaveBeenCalledWith(target, "main");
|
||||
const [replaceUrl] = fetchMock.mock.calls[0];
|
||||
const [addUrl, addInit] = fetchMock.mock.calls[1];
|
||||
expect(replaceUrl).toBe(`http://127.0.0.1:9997/v3/config/paths/replace/cam-${ID}-main`);
|
||||
expect(addUrl).toBe(`http://127.0.0.1:9997/v3/config/paths/add/cam-${ID}-main`);
|
||||
expect(JSON.parse(addInit!.body as string)).toMatchObject({ source: SOURCE, sourceOnDemand: true });
|
||||
|
||||
// Same source: no API call.
|
||||
await video.ensureStreamPath(target, "main");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("updates the path when the login changes, and re-adds everything after a restart", async () => {
|
||||
const fetchMock = stubApi(() => new Response(null, { status: 200 }));
|
||||
await video.startVideoBridge(factory);
|
||||
await video.ensureStreamPath(target, "sub");
|
||||
rtspSourceWithLogin.mockResolvedValue(SOURCE.replace("s3cret", "n3w"));
|
||||
await video.ensureStreamPath(target, "sub");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
status = { state: "running", restarts: 1 };
|
||||
await video.ensureStreamPath(target, "sub");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("reports a refusal without echoing MediaMTX's error, which may contain the password", async () => {
|
||||
stubApi(() => new Response(`invalid source ${SOURCE}`, { status: 400 }));
|
||||
await video.startVideoBridge(factory);
|
||||
const err = await video.ensureStreamPath(target, "main").catch((e: Error) => e);
|
||||
expect(err).toBeInstanceOf(video.VideoBridgeError);
|
||||
expect((err as Error).message).toBe("MediaMTX refused the stream configuration (HTTP 400)");
|
||||
expect((err as Error).message).not.toContain("s3cret");
|
||||
});
|
||||
});
|
||||
145
src/lib/video.ts
Normal file
145
src/lib/video.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import "server-only";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { rtspSourceWithLogin, type CameraTarget } from "./camera";
|
||||
import {
|
||||
mediamtxApiUrl,
|
||||
mediamtxConfig,
|
||||
pathConfig,
|
||||
pathName,
|
||||
type ApiLogin,
|
||||
type StreamKind,
|
||||
} from "./mediamtx-config";
|
||||
import { MediamtxSupervisor, type BridgeStatus } from "./mediamtx-supervisor";
|
||||
|
||||
/**
|
||||
* The local video bridge (vrek gol-sxakryh, dec-xkn4z0e): starts MediaMTX with a
|
||||
* localhost-only config and a per-boot API password, and adds camera streams to it on
|
||||
* demand. Kept on globalThis because instrumentation.ts and the app are bundled
|
||||
* separately; both must see the same bridge.
|
||||
*/
|
||||
|
||||
export class VideoBridgeError extends Error {
|
||||
name = "VideoBridgeError";
|
||||
}
|
||||
|
||||
interface Bridge {
|
||||
supervisor: MediamtxSupervisor;
|
||||
login: ApiLogin;
|
||||
/** Path name → source it was last configured with, to skip no-op updates. */
|
||||
paths: Map<string, string>;
|
||||
/** The restart count those paths belong to; a restarted MediaMTX has none. */
|
||||
pathsEpoch: number;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for("cameras.videoBridge");
|
||||
const g = globalThis as { [KEY]?: Bridge };
|
||||
|
||||
export function mediamtxBinary(): string {
|
||||
return process.env.MEDIAMTX_BIN || path.join(/* turbopackIgnore: true */ process.cwd(), "bin", "mediamtx");
|
||||
}
|
||||
|
||||
function bridgeDir(): string {
|
||||
return path.join(/* turbopackIgnore: true */ process.cwd(), ".data", "mediamtx");
|
||||
}
|
||||
|
||||
function authHeader(login: ApiLogin): string {
|
||||
return `Basic ${Buffer.from(`${login.user}:${login.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
/** Basic auth for MediaMTX's localhost endpoints (API and WebRTC signaling). */
|
||||
export function mediamtxAuthHeader(): string {
|
||||
const bridge = g[KEY];
|
||||
if (!bridge) throw new VideoBridgeError("Video bridge not started");
|
||||
return authHeader(bridge.login);
|
||||
}
|
||||
|
||||
async function api(login: ApiLogin, route: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(mediamtxApiUrl(route), {
|
||||
...init,
|
||||
headers: { ...init?.headers, Authorization: authHeader(login) },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
}
|
||||
|
||||
/** Polls MediaMTX's API until it answers, or rejects after about `attempts × intervalMs`. */
|
||||
export async function waitForApi(login: ApiLogin, attempts = 40, intervalMs = 250): Promise<void> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const res = await api(login, "/v3/info").catch(() => null);
|
||||
if (res?.ok) return;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new VideoBridgeError("MediaMTX API did not answer");
|
||||
}
|
||||
|
||||
/** Writes the config owner-only, atomically. */
|
||||
async function writeConfig(dir: string, text: string): Promise<string> {
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
await chmod(dir, 0o700);
|
||||
const file = path.join(dir, "mediamtx.yml");
|
||||
const tmp = `${file}.tmp`;
|
||||
await writeFile(tmp, text, { mode: 0o600 });
|
||||
await rename(tmp, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the bridge once per server process. Doesn't wait for MediaMTX to be ready, so
|
||||
* server startup isn't held up; status() reports progress.
|
||||
*/
|
||||
export async function startVideoBridge(
|
||||
makeSupervisor: (opts: ConstructorParameters<typeof MediamtxSupervisor>[0]) => MediamtxSupervisor = (o) =>
|
||||
new MediamtxSupervisor(o),
|
||||
): Promise<BridgeStatus> {
|
||||
if (g[KEY]) return g[KEY].supervisor.status();
|
||||
const login: ApiLogin = { user: "app", pass: randomBytes(24).toString("hex") };
|
||||
const dir = bridgeDir();
|
||||
const configPath = await writeConfig(dir, mediamtxConfig(login));
|
||||
const supervisor = makeSupervisor({
|
||||
binary: mediamtxBinary(),
|
||||
configPath,
|
||||
cwd: dir,
|
||||
waitReady: () => waitForApi(login),
|
||||
});
|
||||
g[KEY] = { supervisor, login, paths: new Map(), pathsEpoch: 0 };
|
||||
process.once("exit", () => supervisor.stop());
|
||||
supervisor.start();
|
||||
return supervisor.status();
|
||||
}
|
||||
|
||||
export function videoBridgeStatus(): BridgeStatus {
|
||||
return g[KEY]?.supervisor.status() ?? { state: "stopped", restarts: 0 };
|
||||
}
|
||||
|
||||
/** Test hook: forget the bridge (does not stop a real process). */
|
||||
export function resetVideoBridgeForTests() {
|
||||
delete g[KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure MediaMTX has a path for this camera stream, pointing at the camera with its
|
||||
* current login, and returns the path name. The camera is only pulled while someone
|
||||
* watches (sourceOnDemand).
|
||||
*/
|
||||
export async function ensureStreamPath(target: CameraTarget, stream: StreamKind): Promise<string> {
|
||||
const bridge = g[KEY];
|
||||
const status = bridge?.supervisor.status();
|
||||
if (!bridge || status?.state !== "running") throw new VideoBridgeError("Video bridge is not running");
|
||||
if (status.restarts !== bridge.pathsEpoch) {
|
||||
bridge.paths.clear();
|
||||
bridge.pathsEpoch = status.restarts;
|
||||
}
|
||||
const name = pathName(target.id, stream);
|
||||
const source = await rtspSourceWithLogin(target, stream);
|
||||
if (bridge.paths.get(name) === source) return name;
|
||||
|
||||
const body = JSON.stringify(pathConfig(source));
|
||||
const init = { method: "POST", headers: { "Content-Type": "application/json" }, body };
|
||||
let res = await api(bridge.login, `/v3/config/paths/replace/${name}`, init);
|
||||
if (res.status === 404) res = await api(bridge.login, `/v3/config/paths/add/${name}`, init);
|
||||
// MediaMTX's error text can echo the source URL, which holds the camera password.
|
||||
if (!res.ok) throw new VideoBridgeError(`MediaMTX refused the stream configuration (HTTP ${res.status})`);
|
||||
bridge.paths.set(name, source);
|
||||
return name;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user