From bc7bff0eb929281bd3cd8321096a00ee1c26cf41 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 26 Jul 2026 18:36:30 +0200 Subject: [PATCH] release: publish Lumi 0.3.7 --- .env.example | 12 +- CHANGELOG.md | 6 + docs/stream-testing.md | 71 ++-- knowledge/core/lumi-core.md | 30 +- package-lock.json | 378 +------------------- package.json | 3 +- release-index.json | 32 ++ scripts/verify-release-metadata.js | 8 +- scripts/verify-stream-testing.js | 345 ++++++------------ scripts/verify-update-system.js | 9 +- src/services/mediamtx-runtime.js | 58 ++- src/services/settings.js | 2 + src/services/stream-test-certificates.js | 253 ------------- src/services/stream-test-dns.js | 238 ------------ src/services/stream-test-ingest-settings.js | 58 +++ src/services/stream-testing.js | 229 +++++++++--- src/web/public/stream-testing.css | 11 +- src/web/public/stream-testing.js | 32 ++ src/web/server.js | 43 +-- src/web/views/admin-stream-testing.ejs | 66 ++-- update-manifest.json | 16 +- 21 files changed, 593 insertions(+), 1307 deletions(-) delete mode 100644 src/services/stream-test-certificates.js delete mode 100644 src/services/stream-test-dns.js create mode 100644 src/services/stream-test-ingest-settings.js diff --git a/.env.example b/.env.example index b562c12..4f47402 100644 --- a/.env.example +++ b/.env.example @@ -51,15 +51,7 @@ LUMI_OPERATOR_PRIVACY_URL= # LUMI_HOST=127.0.0.1 # Private OBS stream testing (optional; see docs/stream-testing.md). -# Lumi uses the paired Companion hostname by default and manages MediaMTX itself. -# LUMI_STREAM_TEST_INGEST_HOST=lumi.example.com -# LUMI_STREAM_TEST_INGEST_PORT=19350 -# LUMI_STREAM_TEST_PUBLIC_PORT=19350 -# Production always uses RTMPS and Lumi manages its certificate automatically. -# Advanced certificate override only; set both or neither. -# LUMI_STREAM_TEST_TLS_CERT=/absolute/path/to/fullchain.pem -# LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.pem -# Advanced ACME directory override only. -# LUMI_STREAM_TEST_ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org/directory +# Public and local ingest ports are stored in Lumi settings and configured at +# Admin > Stream testing. Nginx Proxy Manager owns production TLS termination. # Advanced/manual runtime override only; normal installations do not need this. # LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac3ade..186b8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Lumi changelog +## 0.3.7 + +- Moved production Stream Testing TLS termination to Nginx Proxy Manager: Companion still receives RTMPS on the paired Lumi hostname while MediaMTX accepts only the decrypted local RTMP stream. +- Added DB-backed public and local ingest ports plus a full TLS and RTMP external-route check, while preserving direct loopback RTMP development and existing session authorization. +- Removed Lumi-owned ACME, DNS-provider, certificate-path, and challenge-route code without deleting any existing operator data. + ## 0.3.6 - Added encrypted Domeneshop DNS-01 automation for production RTMPS certificates when OpenResty, Nginx Proxy Manager, or another HTTPS proxy owns the reserved HTTP challenge path. diff --git a/docs/stream-testing.md b/docs/stream-testing.md index 2549057..d8477ac 100644 --- a/docs/stream-testing.md +++ b/docs/stream-testing.md @@ -64,53 +64,44 @@ the downloaded runtime and documented in Lumi derives the network policy from the authenticated Companion pairing origin. A Companion paired through `localhost`, `127.0.0.1`, or `::1` always -receives loopback RTMP; production ingest overrides are deliberately ignored -for that local development session. A non-local Companion must have paired -through HTTPS and always receives RTMPS. `LUMI_STREAM_TEST_INGEST_HOST` may -override the advertised hostname only for those non-local sessions. +receives direct loopback RTMP on the configured local listener port. A +non-local Companion must have paired through HTTPS and always receives RTMPS +at the same paired Lumi hostname. -For the normal production path, Lumi automatically provisions and renews a -publicly trusted certificate for the paired hostname. It first supports ACME -HTTP-01 through the public, narrowly scoped -`/.well-known/acme-challenge/` route. When a reverse proxy owns that reserved -path, configure DNS automation in **Admin > Stream testing** instead. Lumi can -use encrypted Domeneshop credentials to create the short-lived DNS-01 TXT -record, wait for authoritative propagation, issue or renew the certificate, -and remove the record. No reverse-proxy changes, certificate paths, or external -ACME packages are required. All certificate keys stay under Lumi's ignored -data directory. The first production test may take up to two minutes while the -certificate is issued; later tests reuse it. +In production, Nginx Proxy Manager owns the public certificate and terminates +TLS: + +```text +OBS → rtmps://:1936 + → Nginx Proxy Manager decrypts TLS + → plain RTMP to Lumi:19350 +``` + +MediaMTX binds plain RTMP on `0.0.0.0:19350` for a production session. It never +requests, loads, renews, or stores a TLS certificate. Configure an NPM TCP +stream that listens on public port `1936` with TLS enabled and forwards to the +Lumi host on port `19350`. The WebUI reverse-proxy settings can change both +ports; values are stored in Lumi's settings database and apply to the next +session. + +The collapsed **Reverse-proxy ingest** section in **Admin > Stream testing** +also provides **Check external access**. The check connects to the current +public Lumi hostname and port with TLS and SNI, validates the public +certificate, then completes an RTMP handshake through NPM to MediaMTX. A +server-side failure can still mean that the router does not support NAT +loopback/hairpinning, so confirm the path from an external network when the NPM +and firewall configuration otherwise look correct. Every session receives an exact `lumi-test/` path and high-entropy publisher credentials. Only that path can be published, and the generated MediaMTX configuration contains SHA-256 credential hashes rather than plaintext credentials. -RTMPS is required for every non-local pairing. A stale -`LUMI_STREAM_TEST_TRANSPORT=rtmp` value is ignored for production rather than -weakening transport or preventing the test from starting. Normal installations -need no certificate environment variables: - -```text -LUMI_STREAM_TEST_INGEST_PORT=19350 -LUMI_STREAM_TEST_PUBLIC_PORT=19350 -``` - -The public port is the value given to OBS and can differ when a firewall or -port-forward maps it to the MediaMTX listener. Use the existing paired Lumi -hostname; no extra Stream Testing domain is required. - -Operators that already manage a matching certificate can override Lumi's -managed certificate by setting both paths: - -```text -LUMI_STREAM_TEST_TLS_CERT=/absolute/path/to/fullchain.pem -LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.pem -``` - -Unencrypted RTMP is selected automatically only for an authenticated loopback -pairing. It cannot be enabled for a non-local pairing through an environment -override. Restrict the ingest port at the host firewall in either mode. +RTMPS is required for every non-local pairing. Unencrypted RTMP is advertised +only to an authenticated loopback pairing. Use the existing paired Lumi +hostname; no extra Stream Testing domain is required. Restrict direct access +to the local MediaMTX listener with the host firewall so production publishers +must enter through NPM. Optional session limits remain: diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index d519a02..c5737ee 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,9 +14,8 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.3.6 +Version: 0.3.7 ## Routes -- GET /.well-known/acme-challenge/:token - POST /api/diagnostics/v1/run - GET /api/events - POST /api/destructive-confirmations @@ -95,8 +94,8 @@ Version: 0.3.6 - POST /admin/theming - GET /admin/diagnostics - GET /admin/stream-testing -- POST /admin/stream-testing/tls/dns -- POST /admin/stream-testing/tls/dns/remove +- POST /admin/stream-testing/reverse-proxy +- POST /admin/stream-testing/reverse-proxy/check - GET /admin/stream-testing/status - POST /admin/stream-testing/stop - POST /admin/stream-testing/runtime/install @@ -228,15 +227,6 @@ Version: 0.3.6 - POST /api/admin/overlays/:id/obs/scene - POST /api/admin/overlays/:id/obs/import ## Route Reference -### GET /.well-known/acme-challenge/:token - -- Purpose: Handles well known acme challenge token. -- Inputs: path params: `token` -- Response format: HTML or data response; exact format was not detected statically. -- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. -- Side effects: Usually read-only. -- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - ### POST /api/diagnostics/v1/run - Purpose: Provides api diagnostics v1 run data as JSON. @@ -939,23 +929,23 @@ Version: 0.3.6 - Side effects: Usually read-only. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. -### POST /admin/stream-testing/tls/dns +### POST /admin/stream-testing/reverse-proxy -- Purpose: Processes the admin stream testing tls dns action and stores or applies submitted form data. +- Purpose: Processes the admin stream testing reverse proxy action and stores or applies submitted form data. - Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service - Response format: HTTP redirect after handling the request - Access: admin access expected - Side effects: writes or mutates server-side state - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. -### POST /admin/stream-testing/tls/dns/remove +### POST /admin/stream-testing/reverse-proxy/check -- Purpose: Processes the admin stream testing tls dns remove action and stores or applies submitted form data. +- Purpose: Provides admin stream testing reverse proxy check data as JSON. - Inputs: No request parameters detected by static analysis. -- Response format: HTTP redirect after handling the request +- Response format: JSON response - Access: admin access expected -- Side effects: writes or mutates server-side state -- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion. +- Side effects: Action route; side effects were not detected statically. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion. ### GET /admin/stream-testing/status diff --git a/package-lock.json b/package-lock.json index 180dfdd..9c54293 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,13 @@ { "name": "lumi-bot", - "version": "0.3.6", + "version": "0.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.3.6", + "version": "0.3.7", "dependencies": { - "acme-client": "^5.4.0", "adm-zip": "^0.6.0", "better-sqlite3": "^11.5.0", "better-sqlite3-session-store": "^0.1.0", @@ -88,163 +87,6 @@ "node": ">= 10" } }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", - "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", - "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", - "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pfx": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", - "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-rsa": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", - "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", - "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pfx": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-rsa": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", - "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", - "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", - "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", - "tslib": "^2.8.1", - "tsyringe": "^4.10.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@playwright/test": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", @@ -325,45 +167,6 @@ "node": ">= 0.6" } }, - "node_modules/acme-client": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", - "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", - "license": "MIT", - "dependencies": { - "@peculiar/x509": "^1.11.0", - "asn1js": "^3.0.5", - "axios": "^1.7.2", - "debug": "^4.3.5", - "node-forge": "^1.3.1" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/acme-client/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/acme-client/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/adm-zip": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", @@ -373,41 +176,6 @@ "node": ">=14.0" } }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -420,20 +188,6 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "license": "BSD-3-Clause", - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -456,18 +210,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1066,26 +808,6 @@ "node": ">= 0.8" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -1270,42 +992,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1572,15 +1258,6 @@ } } }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1760,15 +1437,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -1779,24 +1447,6 @@ "once": "^1.3.1" } }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -1875,12 +1525,6 @@ "node": ">= 6" } }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2190,24 +1834,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsyringe": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "license": "MIT", - "dependencies": { - "tslib": "^1.9.3" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 2c1735b..02f8b6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.3.6", + "version": "0.3.7", "private": true, "type": "commonjs", "scripts": { @@ -30,7 +30,6 @@ "node": ">=18" }, "dependencies": { - "acme-client": "^5.4.0", "adm-zip": "^0.6.0", "better-sqlite3": "^11.5.0", "better-sqlite3-session-store": "^0.1.0", diff --git a/release-index.json b/release-index.json index 161cbc6..02b06db 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,38 @@ "schema_version": 1, "channel": "stable", "releases": [ + { + "version": "0.3.7", + "ref": "refs/tags/v0.3.7", + "released_at": "2026-07-26", + "installable": true, + "rollback_safe": true, + "replaces_versions": [ + "1.2.0" + ], + "data_policy": "preserve", + "dependency_policy": "sync_on_restart", + "migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data.", + "plugins": { + "auto-vc": "0.1.6", + "birthday": "0.1.3", + "economy-framework": "0.2.10", + "economy-games": "0.1.7", + "expression-interaction": "0.2.1", + "lumi_ai": "0.8.5", + "lumi_transcription": "0.2.5", + "moderation": "0.1.5", + "now_playing": "0.1.3", + "okf": "0.1.2", + "quotes": "0.1.2", + "sample-plugin": "0.1.0", + "throne_wishlist": "0.1.2", + "welcome_messages": "0.1.1" + }, + "tools": { + "lumi_ai_web_search": "0.1.1" + } + }, { "version": "0.3.6", "ref": "refs/tags/v0.3.6", diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index 6a39be3..83dcf05 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,9 +4,9 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.3.6"; -const previousStableVersion = "0.3.5"; -const priorStableVersion = "0.3.4"; +const releaseVersion = "0.3.7"; +const previousStableVersion = "0.3.6"; +const priorStableVersion = "0.3.5"; const earliestCompatibleCoreVersion = "0.1.9"; const introducedPlugins = { lumi_transcription: { version: "0.2.5", knowledge: "lumi-transcription" }, @@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: stable core 0.3.6 after 0.3.5 with synchronized Companion plugin metadata."); +console.log("Release metadata verification passed: stable core 0.3.7 after 0.3.6 with synchronized Companion plugin metadata."); diff --git a/scripts/verify-stream-testing.js b/scripts/verify-stream-testing.js index 7f73e79..493b3e2 100644 --- a/scripts/verify-stream-testing.js +++ b/scripts/verify-stream-testing.js @@ -25,19 +25,17 @@ const { StreamTestingService, INACTIVITY_MS, isPrivateAddress, + probeExternalRtmps, resolveIngestConfiguration, rewriteManifest, sourceFor, validateHostname } = require("../src/services/stream-testing"); const { - StreamTestCertificateManager, - normalizeCertificateHostname -} = require("../src/services/stream-test-certificates"); -const { - DomeneshopDnsProvider, - challengeHost -} = require("../src/services/stream-test-dns"); + DEFAULT_LOCAL_RTMP_PORT, + DEFAULT_PUBLIC_RTMPS_PORT, + validateReverseProxyIngestSettings +} = require("../src/services/stream-test-ingest-settings"); const protocol = require("../plugins/lumi_transcription/backend/companion/protocol"); class FakeRuntime extends EventEmitter { @@ -63,6 +61,7 @@ class FakeRuntime extends EventEmitter { }; } async configureSession(session) { this.session = session; this.running = true; } + async prepareListener(listener) { this.listener = listener; this.running = true; } async clearSession() { this.session = null; this.cleared += 1; } async diagnostics(sessionPath) { return { @@ -77,6 +76,41 @@ class FakeRuntime extends EventEmitter { async close() { this.running = false; } } +function createSuccessfulTlsSocket(options) { + assert.equal(options.host, "lumi.example.test"); + assert.equal(options.port, 1936); + assert.equal(options.servername, "lumi.example.test"); + assert.equal(options.rejectUnauthorized, true); + const socket = new EventEmitter(); + socket.authorized = true; + socket.writes = []; + socket.write = (chunk, callback) => { + socket.writes.push(Buffer.from(chunk)); + callback?.(); + if (socket.writes.length === 1) { + assert.equal(chunk.length, 1537); + assert.equal(chunk[0], 3); + const handshake = Buffer.alloc(3073); + handshake[0] = 3; + crypto.randomFillSync(handshake, 1); + queueMicrotask(() => socket.emit("data", handshake)); + } else { + assert.equal(chunk.length, 1536); + } + return true; + }; + socket.getPeerCertificate = () => ({ + subject: { CN: "lumi.example.test" }, + issuer: { CN: "Test CA" }, + valid_to: "Jan 1 00:00:00 2030 GMT" + }); + socket.getProtocol = () => "TLSv1.3"; + socket.getCipher = () => ({ standardName: "TLS_AES_256_GCM_SHA384" }); + socket.destroy = () => {}; + queueMicrotask(() => socket.emit("secureConnect")); + return socket; +} + async function verifyArtifactManager(tempRoot) { assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/); assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/); @@ -164,8 +198,8 @@ async function verifyManagedRuntime(tempRoot) { path: "lumi-test/00000000-0000-4000-8000-000000000001", username: "private-user", password: "private-password", - transport: "rtmp", - ingestPort: 29350 + ingestPort: 29350, + bindHost: "0.0.0.0" }; await runtime.configureSession(session); const generated = fs.readFileSync(runtime.configPath, "utf8"); @@ -176,7 +210,9 @@ async function verifyManagedRuntime(tempRoot) { assert.match(generated, /srt: false/); assert.match(generated, /hlsVariant: lowLatency/); assert.match(generated, /maxReaders: 4/); - assert.match(generated, /rtmpAddress: "127\.0\.0\.1:29350"/); + assert.match(generated, /rtmpEncryption: "no"/); + assert.match(generated, /rtmpAddress: "0\.0\.0\.0:29350"/); + assert(!generated.includes("rtmpsAddress") && !generated.includes("rtmpServerCert") && !generated.includes("rtmpServerKey")); const diagnostic = await runtime.diagnostics(session.path); assert.equal(diagnostic.path.name, session.path); const playlist = await runtime.fetchHls(session.path, "index.m3u8"); @@ -191,18 +227,13 @@ async function verifyManagedRuntime(tempRoot) { } async function verifyStreamService() { - const old = { - host: process.env.LUMI_STREAM_TEST_INGEST_HOST, - transport: process.env.LUMI_STREAM_TEST_TRANSPORT, - cert: process.env.LUMI_STREAM_TEST_TLS_CERT, - key: process.env.LUMI_STREAM_TEST_TLS_KEY - }; - process.env.LUMI_STREAM_TEST_INGEST_HOST = "public.example.test"; - process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps"; - process.env.LUMI_STREAM_TEST_TLS_CERT = path.join(os.tmpdir(), "missing-local-cert.pem"); - process.env.LUMI_STREAM_TEST_TLS_KEY = path.join(os.tmpdir(), "missing-local-key.pem"); const runtime = new FakeRuntime(); - const service = new StreamTestingService({ runtime, timer: false }); + const service = new StreamTestingService({ + runtime, + timer: false, + ingestSettings: () => ({ publicPort: 1936, listenerPort: 19350 }), + tlsConnect: createSuccessfulTlsSocket + }); const sent = []; try { const created = await service.create( @@ -211,7 +242,7 @@ async function verifyStreamService() { (type, payload) => sent.push({ type, payload }) ); assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/); - assert.equal(runtime.session.transport, "rtmp", "localhost pairing must ignore production RTMPS overrides"); + assert.equal(runtime.session.ingestPort, 19350); assert.match(created.ingest.key, /^[0-9a-f-]{36}\?user=/); assert.equal(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder"); assert.equal(runtime.session.path, `lumi-test/${created.id}`); @@ -322,6 +353,9 @@ async function verifyStreamService() { assert.equal(endedSession.username, null); assert.equal(endedSession.password, null); assert.equal(sent.at(-1).type, "stream_test_ended"); + const external = await service.checkExternalAccess("lumi.example.test"); + assert.equal(external.rtmp.handshake_bytes, 3073); + assert.deepStrictEqual(runtime.listener, { bindHost: "0.0.0.0", ingestPort: 19350 }); await assert.rejects( () => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}), (error) => error.code === "STREAM_TEST_RATE_LIMIT" @@ -354,10 +388,6 @@ async function verifyStreamService() { assert([created.id, expiring.id, inactive.id, failed.id].every(Boolean)); } finally { await service.close(); - if (old.host === undefined) delete process.env.LUMI_STREAM_TEST_INGEST_HOST; else process.env.LUMI_STREAM_TEST_INGEST_HOST = old.host; - if (old.transport === undefined) delete process.env.LUMI_STREAM_TEST_TRANSPORT; else process.env.LUMI_STREAM_TEST_TRANSPORT = old.transport; - if (old.cert === undefined) delete process.env.LUMI_STREAM_TEST_TLS_CERT; else process.env.LUMI_STREAM_TEST_TLS_CERT = old.cert; - if (old.key === undefined) delete process.env.LUMI_STREAM_TEST_TLS_KEY; else process.env.LUMI_STREAM_TEST_TLS_KEY = old.key; } } @@ -367,9 +397,19 @@ async function main() { assert.equal(validateHostname("stream.example.com"), "stream.example.com"); assert.equal(validateHostname("https://stream.example.com"), ""); assert.equal(validateHostname("[::1]"), "[::1]"); - assert.equal(normalizeCertificateHostname("Stream.Example.com."), "stream.example.com"); - assert.equal(normalizeCertificateHostname("127.0.0.1"), ""); assert(isPrivateAddress("127.0.0.1") && isPrivateAddress("192.168.1.20") && !isPrivateAddress("8.8.8.8")); + assert.deepStrictEqual( + validateReverseProxyIngestSettings({ publicPort: "1936", listenerPort: "19350" }), + { publicPort: DEFAULT_PUBLIC_RTMPS_PORT, listenerPort: DEFAULT_LOCAL_RTMP_PORT } + ); + assert.throws( + () => validateReverseProxyIngestSettings({ publicPort: "0", listenerPort: "19350" }), + /1 to 65535/ + ); + assert.throws( + () => validateReverseProxyIngestSettings({ publicPort: "1936.5", listenerPort: "19350" }), + /whole number/ + ); assert.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]); assert.match(rewriteManifest( "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\nsegment0.mp4\n", @@ -399,49 +439,34 @@ async function main() { assert.match(config, /paths:\n "lumi-test\/id":/); assert(!/action: publish[\s\S]*path: all/.test(config)); - const oldNetwork = { - host: process.env.LUMI_STREAM_TEST_INGEST_HOST, - transport: process.env.LUMI_STREAM_TEST_TRANSPORT, - cert: process.env.LUMI_STREAM_TEST_TLS_CERT, - key: process.env.LUMI_STREAM_TEST_TLS_KEY - }; - try { - delete process.env.LUMI_STREAM_TEST_INGEST_HOST; - process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp"; - const forcedSecure = await resolveIngestConfiguration( - { pairing_host: "https://stream.example.test" }, - { certificateManager: { resolve: async () => ({ certificate: "managed-cert.pem", privateKey: "managed-key.pem" }) } } - ); - assert.equal(forcedSecure.transport, "rtmps", "a stale RTMP override must not weaken production transport"); - assert.equal(forcedSecure.tlsCert, "managed-cert.pem"); - const local = await resolveIngestConfiguration({ pairing_host: "http://localhost:3000" }); - assert.equal(local.transport, "rtmp"); - assert.equal(local.host, "localhost"); - const cert = path.join(tempRoot, "cert.pem"); - const key = path.join(tempRoot, "key.pem"); - fs.writeFileSync(cert, "test certificate"); - fs.writeFileSync(key, "test key"); - process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps"; - process.env.LUMI_STREAM_TEST_TLS_CERT = cert; - process.env.LUMI_STREAM_TEST_TLS_KEY = key; - const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" }); - assert.equal(secure.transport, "rtmps"); - assert.equal(secure.host, "lumi.example.test"); - - delete process.env.LUMI_STREAM_TEST_TLS_CERT; - delete process.env.LUMI_STREAM_TEST_TLS_KEY; - await verifyManagedCertificateProvisioning(tempRoot); - await verifyDomeneshopDnsAutomation(); - } finally { - for (const [key, value] of Object.entries({ - LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host, - LUMI_STREAM_TEST_TRANSPORT: oldNetwork.transport, - LUMI_STREAM_TEST_TLS_CERT: oldNetwork.cert, - LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key - })) { - if (value === undefined) delete process.env[key]; else process.env[key] = value; - } - } + const configuredPorts = { publicPort: 21936, listenerPort: 29350 }; + const secure = await resolveIngestConfiguration( + { pairing_host: "https://stream.example.test" }, + { settings: configuredPorts } + ); + assert.deepStrictEqual(secure, { + host: "stream.example.test", + transport: "rtmps", + bindHost: "0.0.0.0", + ingestPort: 29350, + publicPort: 21936 + }); + const local = await resolveIngestConfiguration( + { pairing_host: "http://localhost:3000" }, + { settings: configuredPorts } + ); + assert.equal(local.transport, "rtmp"); + assert.equal(local.host, "localhost"); + assert.equal(local.ingestPort, 29350); + assert.equal(local.publicPort, 29350, "loopback development must bypass the public RTMPS port"); + const probed = await probeExternalRtmps({ + hostname: "lumi.example.test", + publicPort: 1936, + listenerPort: 19350, + tlsConnect: createSuccessfulTlsSocket + }); + assert.equal(probed.tls.protocol, "TLSv1.3"); + assert.equal(probed.rtmp.version, 3); for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption_status", "stream_test_caption", "stream_test_stop"]) { const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null)))); @@ -462,11 +487,12 @@ async function main() { const companionWindow = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8"); const companionWindowCode = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8"); const companionStyles = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/App.axaml"), "utf8"); - const certificates = fs.readFileSync(path.join(root, "src/services/stream-test-certificates.js"), "utf8"); + const ingestSettings = fs.readFileSync(path.join(root, "src/services/stream-test-ingest-settings.js"), "utf8"); const transcriptionContribution = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/TranscriptionPluginContribution.cs"), "utf8"); const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8"); const webPlayer = fs.readFileSync(path.join(root, "src/web/public/stream-testing.js"), "utf8"); const webPlayerCss = fs.readFileSync(path.join(root, "src/web/public/stream-testing.css"), "utf8"); + const packageJson = fs.readFileSync(path.join(root, "package.json"), "utf8"); assert.match(server, /admin\/stream-testing\/runtime\/install/); assert.match(server, /admin\/stream-testing\/media\/:id\/\*/); assert.match(server, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/); @@ -475,13 +501,22 @@ async function main() { assert.match(service, /caption_delay/); assert.match(service, /updateCaptionStatus/); assert.match(service, /localDevelopment[\s\S]*transport: "rtmp"/); - assert.match(service, /const transport = "rtmps"/); - assert.match(service, /streamTestCertificateManager/); - assert.match(certificates, /challengePriority: \[dnsProvider \? "dns-01" : "http-01"\]/); - assert.match(server, /\.well-known\/acme-challenge\/:token/); - assert.match(server, /admin\/stream-testing\/tls\/dns/); - assert.match(webUi, /RTMPS certificate automation/); - assert.match(webUi, /Domeneshop \/ hyp\.net/); + assert.match(service, /transport: "rtmps"/); + assert.match(service, /probeExternalRtmps/); + assert.match(service, /servername: bareHost/); + assert.match(runtime, /rtmpEncryption: \\"no\\"/); + assert.match(runtime, /bindHost \|\| "0\.0\.0\.0"/); + assert.match(ingestSettings, /DEFAULT_PUBLIC_RTMPS_PORT = 1936/); + assert.match(ingestSettings, /DEFAULT_LOCAL_RTMP_PORT = 19350/); + assert.match(server, /admin\/stream-testing\/reverse-proxy\/check/); + assert.doesNotMatch(server, /acme-challenge|stream-test-dns|stream-test-certificates/); + assert(!fs.existsSync(path.join(root, "src/services/stream-test-certificates.js"))); + assert(!fs.existsSync(path.join(root, "src/services/stream-test-dns.js"))); + assert.doesNotMatch(packageJson, /acme-client/); + assert.match(webUi, /Reverse-proxy ingest/); + assert.match(webUi, /Check external access/); + assert.match(webUi, /
/); + assert.doesNotMatch(webUi, /certificate automation|Domeneshop/); assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service)); assert(!/shell:\s*true/.test(runtime)); assert.match(gateway, /await service\.create/); @@ -552,158 +587,6 @@ async function main() { console.log("Managed MediaMTX Stream Testing verification passed."); } -async function verifyManagedCertificateProvisioning(tempRoot) { - const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); - const privateKey = keyPair.privateKey.export({ type: "pkcs8", format: "pem" }); - let manager; - let issued = false; - let issueCalls = 0; - const fakeAcme = { - directory: { letsencrypt: { production: "https://acme.invalid/directory" } }, - crypto: { - createPrivateRsaKey: async () => Buffer.from(privateKey), - createCsr: async () => [Buffer.from(privateKey), Buffer.from("test csr")] - }, - Client: class { - async auto(options) { - issueCalls += 1; - const type = options.challengePriority[0]; - await options.challengeCreateFn({}, { type, token: "abcdefghijklmnopqrstuvwxyz012345" }, "key-authorization"); - if (type === "http-01") assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), "key-authorization"); - await options.challengeRemoveFn({}, { type, token: "abcdefghijklmnopqrstuvwxyz012345" }); - assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), null); - issued = true; - return "test certificate"; - } - } - }; - manager = new StreamTestCertificateManager({ - root: path.join(tempRoot, "managed-certificates"), - acme: fakeAcme, - directoryUrl: fakeAcme.directory.letsencrypt.production, - log: { info() {}, error() {} } - }); - manager.inspect = (host) => { - const locations = manager.locations(host); - const ready = issued && fs.existsSync(locations.certificate) && fs.existsSync(locations.privateKey); - return { - ready, - source: "managed", - certificate: locations.certificate, - privateKey: locations.privateKey, - validUntil: ready ? Date.now() + 60 * 24 * 60 * 60 * 1000 : null - }; - }; - const first = manager.resolve("stream.example.test"); - const second = manager.resolve("stream.example.test"); - const [result] = await Promise.all([first, second]); - assert.equal(issueCalls, 1, "concurrent certificate requests must share one operation"); - assert(result.ready && issued, "managed certificate provisioning did not complete"); - assert.equal(fs.statSync(result.privateKey).mode & 0o777, process.platform === "win32" ? fs.statSync(result.privateKey).mode & 0o777 : 0o600); - - let dnsCreated = 0; - let dnsRemoved = 0; - issued = false; - manager = new StreamTestCertificateManager({ - root: path.join(tempRoot, "managed-dns-certificates"), - acme: fakeAcme, - directoryUrl: fakeAcme.directory.letsencrypt.production, - dnsProviderFactory: async () => ({ - async createChallenge({ hostname, value }) { - dnsCreated += 1; - assert.equal(hostname, "stream.example.test"); - assert.equal(value, "key-authorization"); - return { recordId: 42 }; - }, - async removeChallenge(handle) { - dnsRemoved += 1; - assert.equal(handle.recordId, 42); - } - }), - log: { info() {}, error() {} } - }); - manager.inspect = (host) => { - const locations = manager.locations(host); - const ready = issued && fs.existsSync(locations.certificate) && fs.existsSync(locations.privateKey); - return { - ready, - source: "managed", - certificate: locations.certificate, - privateKey: locations.privateKey, - validUntil: ready ? Date.now() + 60 * 24 * 60 * 60 * 1000 : null - }; - }; - const dnsResult = await manager.resolve("stream.example.test"); - assert(dnsResult.ready, "DNS-managed certificate provisioning did not complete"); - assert.equal(dnsCreated, 1); - assert.equal(dnsRemoved, 1); -} - -async function verifyDomeneshopDnsAutomation() { - const requests = []; - let records = []; - const response = (status, body = null, headers = {}) => ({ - ok: status >= 200 && status < 300, - status, - headers: new Headers(headers), - json: async () => body, - text: async () => body ? JSON.stringify(body) : "" - }); - const provider = new DomeneshopDnsProvider({ - token: "test-token", - secret: "test-secret", - propagationTimeoutMs: 100, - wait: async () => {}, - resolveTxt: async (fqdn) => { - assert.equal(fqdn, "_acme-challenge.lumi.ookamikun.tv"); - return records.map((record) => record.data); - }, - fetch: async (url, options) => { - const parsed = new URL(url); - requests.push({ url: parsed.pathname + parsed.search, options }); - assert.match(options.headers.authorization, /^Basic /); - if (parsed.pathname.endsWith("/domains") && options.method === "GET") { - return response(200, [{ - id: 7, - domain: "ookamikun.tv", - services: { dns: true } - }]); - } - if (parsed.pathname.endsWith("/domains/7/dns") && options.method === "GET") { - return response(200, records); - } - if (parsed.pathname.endsWith("/domains/7/dns") && options.method === "POST") { - const payload = JSON.parse(options.body); - assert.deepEqual(payload, { - host: "_acme-challenge.lumi", - ttl: 60, - type: "TXT", - data: "dns-key-authorization" - }); - records.push({ id: 91, ...payload }); - return response(201, null, { location: "/v0/domains/7/dns/91" }); - } - if (parsed.pathname.endsWith("/domains/7/dns/91") && options.method === "DELETE") { - records = records.filter((record) => record.id !== 91); - return response(204); - } - return response(404); - } - }); - await provider.verify(); - const handle = await provider.createChallenge({ - hostname: "lumi.ookamikun.tv", - value: "dns-key-authorization" - }); - assert.equal(handle.recordId, 91); - await provider.removeChallenge(handle); - assert.equal(records.length, 0); - assert.equal(challengeHost("lumi.ookamikun.tv", "ookamikun.tv"), "_acme-challenge.lumi"); - assert.equal(challengeHost("ookamikun.tv", "ookamikun.tv"), "_acme-challenge"); - assert(requests.some((request) => request.options.method === "POST")); - assert(requests.some((request) => request.options.method === "DELETE")); -} - main().catch((error) => { console.error(error); process.exitCode = 1; diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index eac6284..2db6ddf 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -24,7 +24,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); +assert.deepEqual(releaseVersions, ["0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version; const coreManifest = readJson("update-manifest.json"); assert.equal(packageVersion, coreManifest.version); assert.equal(coreManifest.channel, "stable"); -assert.equal(packageVersion, "0.3.6"); -assert.equal(currentRelease.version, "0.3.6"); +assert.equal(packageVersion, "0.3.7"); +assert.equal(currentRelease.version, "0.3.7"); assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]); for (const [pluginId, version] of Object.entries(currentRelease.plugins)) { assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`); @@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true }, { version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true }, { version: "0.3.5", ref: "refs/tags/v0.3.5", rollback_safe: true }, { version: "0.3.4", ref: "refs/tags/v0.3.4", rollback_safe: true }, @@ -155,7 +156,7 @@ const corrected = buildStatus({ channel: "stable" }); assert.equal(corrected.version_correction, true); -assert.equal(corrected.safe_target_version, "0.3.6"); +assert.equal(corrected.safe_target_version, "0.3.7"); assert.equal(corrected.update_available, true); assert.equal(corrected.blocked, false); diff --git a/src/services/mediamtx-runtime.js b/src/services/mediamtx-runtime.js index b0fc600..32258b0 100644 --- a/src/services/mediamtx-runtime.js +++ b/src/services/mediamtx-runtime.js @@ -178,9 +178,13 @@ class MediaMtxRuntime extends EventEmitter { return this.install(options); } - async healthCheck() { + async healthCheck(listener = {}) { return this.queue(async () => { - await this.startWithConfig(null); + const listenerConfig = { + bindHost: listener.bindHost || "127.0.0.1", + ingestPort: listener.ingestPort || 19350 + }; + await this.startWithConfig(listenerConfig); const [api, metrics, hls] = await Promise.all([ this.fetchText(`http://127.0.0.1:${this.ports.api}/v3/paths/list`, 5000), this.fetchText(`http://127.0.0.1:${this.ports.metrics}/metrics`, 5000), @@ -192,7 +196,7 @@ class MediaMtxRuntime extends EventEmitter { hls: hls.response.status }; await this.stopProcess(); - await this.startWithConfig(null); + await this.startWithConfig(listenerConfig); return { ok: api.response.ok && metrics.response.ok && Boolean(hls.response), version: this.status({ refresh: true }).version, @@ -211,10 +215,7 @@ class MediaMtxRuntime extends EventEmitter { path: session.path, username: session.username, password: session.password, - transport: session.transport, ingestPort: session.ingestPort, - tlsCert: session.tlsCert, - tlsKey: session.tlsKey, bindHost: session.bindHost }; await this.startWithConfig(this.currentSession); @@ -222,12 +223,36 @@ class MediaMtxRuntime extends EventEmitter { }); } - async clearSession() { + async prepareListener(listener) { + return this.queue(async () => { + const requested = { + ingestPort: listener.ingestPort, + bindHost: listener.bindHost || "0.0.0.0" + }; + if (this.currentSession) { + if ( + this.currentSession.ingestPort !== requested.ingestPort + || this.currentSession.bindHost !== requested.bindHost + ) { + throw new Error("The active Stream Test is using different ingest settings."); + } + return this.processStatus(); + } + await this.startWithConfig(requested); + return this.processStatus(); + }); + } + + async clearSession(listener = {}) { return this.queue(async () => { this.logTail = redactRuntimeLog(this.logTail, this.currentSession); this.currentSession = null; - if (this.child && this.child.exitCode === null) await this.startWithConfig(null); - else this.writeConfig(null); + const listenerConfig = { + bindHost: listener.bindHost || "127.0.0.1", + ingestPort: listener.ingestPort || 19350 + }; + if (this.child && this.child.exitCode === null) await this.startWithConfig(listenerConfig); + else this.writeConfig(listenerConfig); return this.processStatus(); }); } @@ -437,8 +462,8 @@ class MediaMtxRuntime extends EventEmitter { } function renderConfig({ apiPort, metricsPort, hlsPort, session }) { - const transport = session?.transport === "rtmps" ? "rtmps" : "rtmp"; - const publisher = session ? [ + const publisherSession = Boolean(session?.path); + const publisher = publisherSession ? [ "- user: " + yamlString(sha256Credential(session.username)), " pass: " + yamlString(sha256Credential(session.password)), " ips: []", @@ -446,16 +471,11 @@ function renderConfig({ apiPort, metricsPort, hlsPort, session }) { " - action: publish", " path: " + yamlString(session.path) ] : []; - const rtmp = transport === "rtmps" ? [ - "rtmpEncryption: strict", - `rtmpsAddress: "0.0.0.0:${session.ingestPort}"`, - `rtmpServerKey: ${yamlString(session.tlsKey)}`, - `rtmpServerCert: ${yamlString(session.tlsCert)}` - ] : [ + const rtmp = [ "rtmpEncryption: \"no\"", `rtmpAddress: ${yamlString(formatSocketAddress(session?.bindHost || "127.0.0.1", session?.ingestPort || 19350))}` ]; - const paths = session ? [ + const paths = publisherSession ? [ "paths:", ` ${yamlString(session.path)}:`, " source: publisher", @@ -476,7 +496,7 @@ function renderConfig({ apiPort, metricsPort, hlsPort, session }) { " ips: [127.0.0.1, '::1']", " permissions:", " - action: read", - ...(session ? [` path: ${yamlString(session.path)}`] : []), + ...(publisherSession ? [` path: ${yamlString(session.path)}`] : []), " - action: api", " - action: metrics", "api: true", diff --git a/src/services/settings.js b/src/services/settings.js index 09a93cb..b25cad4 100644 --- a/src/services/settings.js +++ b/src/services/settings.js @@ -69,6 +69,8 @@ function ensureDefaults() { auto_update_enabled: envBoolean("AUTO_UPDATE_ENABLED", false), auto_update_interval_minutes: envNumber("AUTO_UPDATE_INTERVAL_MINUTES", 60), production_diagnostics_enabled: false, + stream_test_public_rtmps_port: 1936, + stream_test_local_rtmp_port: 19350, log_retention_days: 30, log_retention_max_entries: 100000, git_remote: envString("GIT_REMOTE", "origin"), diff --git a/src/services/stream-test-certificates.js b/src/services/stream-test-certificates.js deleted file mode 100644 index 019e373..0000000 --- a/src/services/stream-test-certificates.js +++ /dev/null @@ -1,253 +0,0 @@ -const acme = require("acme-client"); -const crypto = require("crypto"); -const fs = require("fs"); -const net = require("net"); -const path = require("path"); -const { createLogger } = require("./logger"); -const { configuredDnsProvider, dnsAutomationStatus } = require("./stream-test-dns"); - -const DATA_ROOT = path.join( - process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"), - "stream-testing", - "tls" -); -const RENEWAL_WINDOW_MS = 21 * 24 * 60 * 60 * 1000; -const CHALLENGE_TOKEN = /^[A-Za-z0-9_-]{20,300}$/; - -class StreamTestCertificateManager { - constructor(options = {}) { - this.root = options.root || DATA_ROOT; - this.acme = options.acme || acme; - this.directoryUrl = options.directoryUrl || process.env.LUMI_STREAM_TEST_ACME_DIRECTORY || acme.directory.letsencrypt.production; - this.now = options.now || (() => Date.now()); - this.challenges = new Map(); - this.operations = new Map(); - this.accountKeyOperation = null; - this.dnsProviderFactory = options.dnsProviderFactory || configuredDnsProvider; - this.log = options.log || createLogger("core:stream-testing", { category: "integration" }); - } - - challenge(token) { - if (!CHALLENGE_TOKEN.test(String(token || ""))) return null; - return this.challenges.get(String(token)) || null; - } - - async resolve(hostname) { - const host = normalizeCertificateHostname(hostname); - if (!host) { - throw certificateError("Automatic RTMPS certificates require a public DNS hostname; IP addresses and local hostnames cannot be issued a public certificate."); - } - const existing = this.inspect(host); - if (existing.ready && existing.validUntil - this.now() > RENEWAL_WINDOW_MS) return existing; - if (this.operations.has(host)) return this.operations.get(host); - const operation = this.issue(host, existing).finally(() => this.operations.delete(host)); - this.operations.set(host, operation); - return operation; - } - - inspect(hostname) { - const host = normalizeCertificateHostname(hostname); - if (!host) return { ready: false, source: "managed" }; - const locations = this.locations(host); - try { - const certificatePem = fs.readFileSync(locations.certificate); - const privateKeyPem = fs.readFileSync(locations.privateKey); - const certificate = new crypto.X509Certificate(certificatePem); - const privateKey = crypto.createPrivateKey(privateKeyPem); - const certificatePublicKey = certificate.publicKey.export({ type: "spki", format: "der" }); - const privatePublicKey = crypto.createPublicKey(privateKey).export({ type: "spki", format: "der" }); - const validFrom = Date.parse(certificate.validFrom); - const validUntil = Date.parse(certificate.validTo); - const hostnameMatches = Boolean(certificate.checkHost(host)); - const ready = hostnameMatches && - certificatePublicKey.equals(privatePublicKey) && - Number.isFinite(validFrom) && - Number.isFinite(validUntil) && - validFrom <= this.now() + 5 * 60 * 1000 && - validUntil > this.now(); - return { - ready, - source: "managed", - certificate: locations.certificate, - privateKey: locations.privateKey, - validUntil: ready ? validUntil : null - }; - } catch { - return { - ready: false, - source: "managed", - certificate: locations.certificate, - privateKey: locations.privateKey, - validUntil: null - }; - } - } - - async issue(host, existing) { - const locations = this.locations(host); - const issuedTokens = new Set(); - const dnsChallenges = new Map(); - const dnsProvider = await this.dnsProviderFactory(host); - fs.mkdirSync(locations.directory, { recursive: true, mode: 0o700 }); - fs.mkdirSync(this.root, { recursive: true, mode: 0o700 }); - this.log.info("Provisioning managed RTMPS certificate", { hostname: host }, { event: "rtmps_certificate_provisioning" }); - try { - const accountKey = await this.accountKey(); - let certificateKey; - try { - certificateKey = fs.readFileSync(locations.privateKey); - crypto.createPrivateKey(certificateKey); - } catch { - certificateKey = await this.acme.crypto.createPrivateRsaKey(2048); - } - const [, csr] = await this.acme.crypto.createCsr({ - commonName: host, - altNames: [host] - }, certificateKey); - const client = new this.acme.Client({ - directoryUrl: this.directoryUrl, - accountKey - }); - const certificate = await client.auto({ - csr, - email: operatorEmail(), - termsOfServiceAgreed: true, - challengePriority: [dnsProvider ? "dns-01" : "http-01"], - // Home-hosted Lumi installations frequently cannot hairpin through their - // public address. The ACME authority still performs the authoritative - // external HTTP-01 validation before issuing anything. - skipChallengeVerification: true, - challengeCreateFn: async (_authorization, challenge, keyAuthorization) => { - if (challenge.type === "dns-01" && dnsProvider) { - const handle = await dnsProvider.createChallenge({ - hostname: host, - token: challenge.token, - value: keyAuthorization - }); - dnsChallenges.set(challenge.token, handle); - return; - } - if (challenge.type !== "http-01" || !CHALLENGE_TOKEN.test(challenge.token)) { - throw new Error("The certificate authority did not provide a supported ACME challenge."); - } - this.challenges.set(challenge.token, keyAuthorization); - issuedTokens.add(challenge.token); - }, - challengeRemoveFn: async (_authorization, challenge) => { - if (challenge?.type === "dns-01" && dnsProvider) { - const handle = dnsChallenges.get(challenge.token); - if (handle) { - await dnsProvider.removeChallenge(handle); - dnsChallenges.delete(challenge.token); - } - return; - } - if (challenge?.token) { - this.challenges.delete(challenge.token); - issuedTokens.delete(challenge.token); - } - } - }); - writeAtomic(locations.privateKey, certificateKey, 0o600); - writeAtomic(locations.certificate, certificate, 0o644); - const issued = this.inspect(host); - if (!issued.ready) throw new Error("The issued certificate did not match the Lumi hostname and private key."); - this.log.info("Managed RTMPS certificate is ready", { - hostname: host, - valid_until: new Date(issued.validUntil).toISOString(), - renewed: Boolean(existing?.ready) - }, { event: "rtmps_certificate_ready" }); - return issued; - } catch (error) { - this.log.error("Managed RTMPS certificate provisioning failed", { - hostname: host, - error - }, { event: "rtmps_certificate_failed" }); - throw certificateError( - dnsProvider - ? `Lumi could not automatically prepare RTMPS for ${host} through DNS automation. ${error.message}` - : `Lumi could not automatically prepare RTMPS for ${host}. The HTTPS reverse proxy may own /.well-known/acme-challenge/ instead of forwarding it to Lumi. Configure DNS automation in Admin > Stream testing and retry. ${error.message}` - ); - } finally { - for (const token of issuedTokens) this.challenges.delete(token); - for (const handle of dnsChallenges.values()) { - try { await dnsProvider?.removeChallenge(handle); } catch {} - } - } - } - - status() { - return dnsAutomationStatus(); - } - - accountKey() { - if (this.accountKeyOperation) return this.accountKeyOperation; - this.accountKeyOperation = this.loadOrCreateAccountKey() - .finally(() => { this.accountKeyOperation = null; }); - return this.accountKeyOperation; - } - - async loadOrCreateAccountKey() { - const target = path.join(this.root, "acme-account.key"); - try { - const current = fs.readFileSync(target); - crypto.createPrivateKey(current); - return current; - } catch { - const created = await this.acme.crypto.createPrivateRsaKey(2048); - writeAtomic(target, created, 0o600); - return created; - } - } - - locations(host) { - const safe = host.replace(/[^a-z0-9.-]/gi, "_"); - const suffix = crypto.createHash("sha256").update(host).digest("hex").slice(0, 12); - const directory = path.join(this.root, `${safe}-${suffix}`); - return { - directory, - certificate: path.join(directory, "fullchain.pem"), - privateKey: path.join(directory, "private-key.pem") - }; - } -} - -function normalizeCertificateHostname(value) { - const host = String(value || "").trim().replace(/\.$/, "").toLowerCase(); - if (!host || net.isIP(host) || host === "localhost" || host.endsWith(".localhost")) return ""; - if (host.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) return ""; - return host; -} - -function operatorEmail() { - const value = String(process.env.LUMI_OPERATOR_CONTACT || "").trim().replace(/^mailto:/i, ""); - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? value : undefined; -} - -function writeAtomic(target, content, mode) { - fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); - const temporary = `${target}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`; - try { - fs.writeFileSync(temporary, content, { mode, flag: "wx" }); - fs.renameSync(temporary, target); - try { fs.chmodSync(target, mode); } catch {} - } finally { - try { fs.rmSync(temporary, { force: true }); } catch {} - } -} - -function certificateError(message) { - const error = new Error(message); - error.code = "STREAM_TEST_TLS_UNCONFIGURED"; - return error; -} - -const streamTestCertificateManager = new StreamTestCertificateManager(); - -module.exports = { - CHALLENGE_TOKEN, - RENEWAL_WINDOW_MS, - StreamTestCertificateManager, - normalizeCertificateHostname, - streamTestCertificateManager -}; diff --git a/src/services/stream-test-dns.js b/src/services/stream-test-dns.js deleted file mode 100644 index eef4227..0000000 --- a/src/services/stream-test-dns.js +++ /dev/null @@ -1,238 +0,0 @@ -const dns = require("dns"); -const { decryptSecret, encryptSecret } = require("./overlay-secrets"); -const { getSetting, setSetting } = require("./settings"); - -const PROVIDER_KEY = "stream_test_dns_provider"; -const TOKEN_KEY = "stream_test_dns_token_encrypted"; -const SECRET_KEY = "stream_test_dns_secret_encrypted"; -const DOMENESHOP_API = "https://api.domeneshop.no/v0"; -const REQUEST_TIMEOUT_MS = 15000; -const PROPAGATION_TIMEOUT_MS = 60000; - -class DomeneshopDnsProvider { - constructor(options = {}) { - this.token = String(options.token || "").trim(); - this.secret = String(options.secret || "").trim(); - this.fetch = options.fetch || global.fetch; - this.resolveTxt = options.resolveTxt || authoritativeTxtValues; - this.wait = options.wait || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); - this.propagationTimeoutMs = options.propagationTimeoutMs || PROPAGATION_TIMEOUT_MS; - } - - async verify() { - const domains = await this.domains(); - if (!domains.some((domain) => domain?.services?.dns)) { - throw new Error("The Domeneshop API credentials do not have access to an active DNS zone."); - } - return domains; - } - - async createChallenge({ hostname, value }) { - const domain = await this.domainFor(hostname); - const host = challengeHost(hostname, domain.domain); - const before = await this.records(domain.id, host); - const beforeIds = new Set(before.map((record) => Number(record.id))); - const created = await this.request(`/domains/${domain.id}/dns`, { - method: "POST", - body: { - host, - ttl: 60, - type: "TXT", - data: value - } - }); - let recordId = recordIdFromLocation(created.response.headers.get("location")); - if (!recordId) { - const after = await this.records(domain.id, host); - const match = after - .filter((record) => record.type === "TXT" && record.data === value && !beforeIds.has(Number(record.id))) - .sort((left, right) => Number(right.id) - Number(left.id))[0]; - recordId = Number(match?.id) || null; - } - if (!recordId) { - throw new Error("Domeneshop accepted the DNS challenge but Lumi could not identify the temporary record."); - } - const fqdn = `${host === "@" ? "" : `${host}.`}${domain.domain}`; - const handle = { domainId: domain.id, recordId, fqdn }; - try { - await this.waitForPropagation(fqdn, value); - return handle; - } catch (error) { - try { await this.removeChallenge(handle); } catch {} - throw error; - } - } - - async removeChallenge(handle) { - if (!Number.isInteger(Number(handle?.domainId)) || !Number.isInteger(Number(handle?.recordId))) return; - await this.request(`/domains/${Number(handle.domainId)}/dns/${Number(handle.recordId)}`, { - method: "DELETE", - allowNotFound: true - }); - } - - async domainFor(hostname) { - const host = String(hostname || "").toLowerCase(); - const domains = await this.domains(); - const match = domains - .filter((domain) => domain?.services?.dns && (host === domain.domain || host.endsWith(`.${domain.domain}`))) - .sort((left, right) => right.domain.length - left.domain.length)[0]; - if (!match) throw new Error(`The Domeneshop account does not contain the DNS zone for ${host}.`); - return match; - } - - async domains() { - const result = await this.request("/domains"); - return Array.isArray(result.body) ? result.body : []; - } - - async records(domainId, host) { - const query = new URLSearchParams({ host, type: "TXT" }); - const result = await this.request(`/domains/${domainId}/dns?${query}`); - return Array.isArray(result.body) ? result.body : []; - } - - async waitForPropagation(fqdn, expected) { - const deadline = Date.now() + this.propagationTimeoutMs; - let lastError = null; - while (Date.now() < deadline) { - try { - const values = await this.resolveTxt(fqdn); - if (values.includes(expected)) return; - } catch (error) { - lastError = error; - } - await this.wait(3000); - } - throw new Error(`The DNS challenge for ${fqdn} did not reach Domeneshop's authoritative nameservers in time.${lastError?.code ? ` (${lastError.code})` : ""}`); - } - - async request(relativePath, options = {}) { - if (!this.token || !this.secret) throw new Error("Domeneshop API token and secret are required."); - if (typeof this.fetch !== "function") throw new Error("This Node.js runtime cannot contact the Domeneshop API."); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const response = await this.fetch(`${DOMENESHOP_API}${relativePath}`, { - method: options.method || "GET", - headers: { - accept: "application/json", - authorization: `Basic ${Buffer.from(`${this.token}:${this.secret}`).toString("base64")}`, - ...(options.body ? { "content-type": "application/json" } : {}) - }, - body: options.body ? JSON.stringify(options.body) : undefined, - signal: controller.signal - }); - if (options.allowNotFound && response.status === 404) return { response, body: null }; - if (!response.ok) { - const detail = String(await response.text()).slice(0, 300); - const suffix = response.status === 401 || response.status === 403 - ? " Check the API token, secret, and DNS permission." - : detail ? ` ${detail}` : ""; - throw new Error(`Domeneshop DNS request failed (${response.status}).${suffix}`); - } - const body = response.status === 204 ? null : await response.json().catch(() => null); - return { response, body }; - } catch (error) { - if (error?.name === "AbortError") throw new Error("The Domeneshop DNS request timed out."); - throw error; - } finally { - clearTimeout(timeout); - } - } -} - -function dnsAutomationStatus() { - const provider = String(getSetting(PROVIDER_KEY, "") || ""); - const configured = provider === "domeneshop" && - Boolean(getSetting(TOKEN_KEY, "")) && - Boolean(getSetting(SECRET_KEY, "")); - return { - configured, - provider: configured ? provider : null, - providerLabel: configured ? "Domeneshop" : null, - challenge: configured ? "dns-01" : "http-01" - }; -} - -async function saveDomeneshopDnsCredentials({ token, secret }) { - const existing = dnsAutomationStatus(); - let resolvedToken = String(token || "").trim(); - let resolvedSecret = String(secret || "").trim(); - if (existing.configured) { - if (!resolvedToken) resolvedToken = decryptSecret(getSetting(TOKEN_KEY, "")); - if (!resolvedSecret) resolvedSecret = decryptSecret(getSetting(SECRET_KEY, "")); - } - if (!resolvedToken || resolvedToken.length > 512 || !resolvedSecret || resolvedSecret.length > 512) { - throw new Error("Enter a valid Domeneshop API token and secret."); - } - const provider = new DomeneshopDnsProvider({ token: resolvedToken, secret: resolvedSecret }); - await provider.verify(); - setSetting(PROVIDER_KEY, "domeneshop"); - setSetting(TOKEN_KEY, encryptSecret(resolvedToken)); - setSetting(SECRET_KEY, encryptSecret(resolvedSecret)); - return dnsAutomationStatus(); -} - -function configuredDnsProvider() { - const status = dnsAutomationStatus(); - if (!status.configured) return null; - try { - return new DomeneshopDnsProvider({ - token: decryptSecret(getSetting(TOKEN_KEY, "")), - secret: decryptSecret(getSetting(SECRET_KEY, "")) - }); - } catch { - return null; - } -} - -function clearDnsAutomation() { - setSetting(PROVIDER_KEY, ""); - setSetting(TOKEN_KEY, ""); - setSetting(SECRET_KEY, ""); - return dnsAutomationStatus(); -} - -async function authoritativeTxtValues(fqdn) { - const labels = String(fqdn || "").split(".").filter(Boolean); - let nameservers = []; - for (let index = 1; index < labels.length - 1 && !nameservers.length; index += 1) { - try { - nameservers = await dns.promises.resolveNs(labels.slice(index).join(".")); - } catch {} - } - if (!nameservers.length) throw Object.assign(new Error("Authoritative nameservers could not be resolved."), { code: "ENODATA" }); - const addresses = []; - for (const nameserver of nameservers) { - try { addresses.push(...await dns.promises.resolve4(nameserver)); } catch {} - try { addresses.push(...await dns.promises.resolve6(nameserver)); } catch {} - } - if (!addresses.length) throw Object.assign(new Error("Authoritative nameserver addresses could not be resolved."), { code: "ENODATA" }); - const resolver = new dns.promises.Resolver(); - resolver.setServers(addresses); - const records = await resolver.resolveTxt(fqdn); - return records.map((parts) => parts.join("")); -} - -function challengeHost(hostname, domain) { - const host = String(hostname || "").toLowerCase(); - const zone = String(domain || "").toLowerCase(); - const relative = host === zone ? "" : host.slice(0, -(zone.length + 1)); - return relative ? `_acme-challenge.${relative}` : "_acme-challenge"; -} - -function recordIdFromLocation(value) { - const match = String(value || "").match(/\/dns\/(\d+)\/?$/); - return match ? Number(match[1]) : null; -} - -module.exports = { - DomeneshopDnsProvider, - authoritativeTxtValues, - challengeHost, - clearDnsAutomation, - configuredDnsProvider, - dnsAutomationStatus, - saveDomeneshopDnsCredentials -}; diff --git a/src/services/stream-test-ingest-settings.js b/src/services/stream-test-ingest-settings.js new file mode 100644 index 0000000..a0075fa --- /dev/null +++ b/src/services/stream-test-ingest-settings.js @@ -0,0 +1,58 @@ +const { getSetting, setSetting } = require("./settings"); + +const DEFAULT_PUBLIC_RTMPS_PORT = 1936; +const DEFAULT_LOCAL_RTMP_PORT = 19350; +const PUBLIC_PORT_SETTING = "stream_test_public_rtmps_port"; +const LOCAL_PORT_SETTING = "stream_test_local_rtmp_port"; + +function validatePort(value, label) { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) { + throw new Error(`${label} must be a whole number from 1 to 65535.`); + } + const parsed = Number(text); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error(`${label} must be a whole number from 1 to 65535.`); + } + return parsed; +} + +function storedPort(key, fallback) { + try { + return validatePort(getSetting(key, fallback), "Saved port"); + } catch { + return fallback; + } +} + +function getReverseProxyIngestSettings() { + return { + publicPort: storedPort(PUBLIC_PORT_SETTING, DEFAULT_PUBLIC_RTMPS_PORT), + listenerPort: storedPort(LOCAL_PORT_SETTING, DEFAULT_LOCAL_RTMP_PORT) + }; +} + +function validateReverseProxyIngestSettings(input = {}) { + return { + publicPort: validatePort(input.publicPort, "Public RTMPS port"), + listenerPort: validatePort(input.listenerPort, "Local RTMP listener port") + }; +} + +function saveReverseProxyIngestSettings(input = {}) { + const settings = validateReverseProxyIngestSettings(input); + setSetting(PUBLIC_PORT_SETTING, settings.publicPort); + setSetting(LOCAL_PORT_SETTING, settings.listenerPort); + return settings; +} + +module.exports = { + DEFAULT_LOCAL_RTMP_PORT, + DEFAULT_PUBLIC_RTMPS_PORT, + LOCAL_PORT_SETTING, + PUBLIC_PORT_SETTING, + getReverseProxyIngestSettings, + saveReverseProxyIngestSettings, + validatePort, + validateReverseProxyIngestSettings +}; diff --git a/src/services/stream-testing.js b/src/services/stream-testing.js index 30682a5..7a4bb7f 100644 --- a/src/services/stream-testing.js +++ b/src/services/stream-testing.js @@ -3,10 +3,14 @@ const dns = require("dns"); const fs = require("fs"); const net = require("net"); const path = require("path"); +const tls = require("tls"); const { Readable } = require("stream"); const { pipeline } = require("stream/promises"); const { MediaMtxRuntime } = require("./mediamtx-runtime"); -const { streamTestCertificateManager } = require("./stream-test-certificates"); +const { + getReverseProxyIngestSettings, + validatePort +} = require("./stream-test-ingest-settings"); const { publishWebEvent } = require("./web-events"); const DATA_ROOT = path.join( @@ -15,8 +19,6 @@ const DATA_ROOT = path.join( ); const MAX_SESSION_MS = Math.min(60 * 60 * 1000, Math.max(5 * 60 * 1000, Number(process.env.LUMI_STREAM_TEST_MAX_MS) || 30 * 60 * 1000)); const INACTIVITY_MS = Math.min(5 * 60 * 1000, Math.max(20 * 1000, Number(process.env.LUMI_STREAM_TEST_INACTIVITY_MS) || 45 * 1000)); -const INGEST_PORT = port(process.env.LUMI_STREAM_TEST_INGEST_PORT, 19350); -const PUBLIC_INGEST_PORT = port(process.env.LUMI_STREAM_TEST_PUBLIC_PORT, INGEST_PORT); const MAX_MANIFEST_BYTES = 1024 * 1024; function finite(value, fallback, min, max) { @@ -60,6 +62,8 @@ function visibleCaptionCues(session) { class StreamTestingService { constructor(options = {}) { this.runtime = options.runtime || new MediaMtxRuntime(); + this.ingestSettings = options.ingestSettings || getReverseProxyIngestSettings; + this.tlsConnect = options.tlsConnect || tls.connect; this.active = null; this.timeline = []; this.lastCreateAt = new Map(); @@ -135,7 +139,9 @@ class StreamTestingService { "The Lumi private media router needs setup. Ask an administrator to open Admin > Stream testing and choose Install MediaMTX." ); } - const network = await resolveIngestConfiguration(device); + const network = await resolveIngestConfiguration(device, { + settings: this.ingestSettings() + }); const id = crypto.randomUUID(); const streamPath = `lumi-test/${id}`; const username = `lumi_${crypto.randomBytes(18).toString("base64url")}`; @@ -156,6 +162,7 @@ class StreamTestingService { transport: network.transport, ingestHost: network.host, ingestPort: network.ingestPort, + bindHost: network.bindHost, publicPort: network.publicPort, deviceId: device.id, deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120), @@ -191,16 +198,18 @@ class StreamTestingService { path: streamPath, username, password, - transport: network.transport, ingestPort: network.ingestPort, - tlsCert: network.tlsCert, - tlsKey: network.tlsKey, bindHost: network.bindHost }); this.event("receiver_ready", "Lumi's private media router is ready for OBS.", session); } catch (error) { if (this.active?.id === session.id) this.active = null; - try { await this.runtime.clearSession(); } catch {} + try { + await this.runtime.clearSession({ + bindHost: network.bindHost, + ingestPort: network.ingestPort + }); + } catch {} this.event("receiver_error", `Private media router startup failed: ${error.message}`, session); throw coded("STREAM_TEST_RECEIVER_UNAVAILABLE", `The private media router could not start: ${error.message}`); } @@ -218,6 +227,28 @@ class StreamTestingService { }; } + async checkExternalAccess(hostname) { + if (this.active) { + throw coded("STREAM_TEST_ACTIVE", "End the active Stream Test before checking external access."); + } + const host = validateHostname(String(hostname || "").trim()); + const bareHost = host.replace(/^\[|\]$/g, ""); + if (!host || net.isIP(bareHost) || isLoopbackHostname(host)) { + throw coded("STREAM_TEST_EXTERNAL_HOST_INVALID", "Open Lumi through its public DNS hostname before checking external RTMPS access."); + } + const settings = this.ingestSettings(); + await this.runtime.prepareListener({ + bindHost: "0.0.0.0", + ingestPort: settings.listenerPort + }); + return probeExternalRtmps({ + hostname: host, + publicPort: settings.publicPort, + listenerPort: settings.listenerPort, + tlsConnect: this.tlsConnect + }); + } + updateObs(deviceId, input = {}) { const session = this.requireDeviceSession(deviceId, input.session_id); session.metrics.obs = { @@ -435,7 +466,11 @@ class StreamTestingService { async healthCheck() { if (this.active) throw new Error("The active Stream Test is already exercising MediaMTX."); - const result = await this.runtime.healthCheck(); + const settings = this.ingestSettings(); + const result = await this.runtime.healthCheck({ + bindHost: "127.0.0.1", + ingestPort: settings.listenerPort + }); this.event("runtime_health", `MediaMTX ${result.version || ""} passed its listener and API health check.`.trim(), null); return result; } @@ -450,7 +485,10 @@ class StreamTestingService { if (this.active?.id === session.id) this.active = null; let runtimeError = null; try { - await this.runtime.clearSession(); + await this.runtime.clearSession({ + bindHost: session.bindHost, + ingestPort: session.ingestPort + }); } catch (error) { runtimeError = error; try { await this.runtime.close(); } catch {} @@ -595,6 +633,9 @@ class StreamTestingService { } async function resolveIngestConfiguration(device, options = {}) { + const settings = options.settings || getReverseProxyIngestSettings(); + const publicPort = validatePort(settings.publicPort, "Public RTMPS port"); + const listenerPort = validatePort(settings.listenerPort, "Local RTMP listener port"); const pairedOrigin = String(device?.pairing_host || "").trim(); let pairedUrl; try { @@ -602,8 +643,7 @@ async function resolveIngestConfiguration(device, options = {}) { } catch {} const pairedHost = validateHostname(pairedUrl?.hostname || ""); const localDevelopment = isLoopbackHostname(pairedHost); - const configuredHost = localDevelopment ? pairedHost : process.env.LUMI_STREAM_TEST_INGEST_HOST || pairedHost; - const host = validateHostname(String(configuredHost).trim()); + const host = validateHostname(String(pairedHost).trim()); if (!host) throw coded("STREAM_TEST_INGEST_UNCONFIGURED", "The paired Lumi hostname could not be used for private Stream Testing."); if (localDevelopment) { @@ -611,47 +651,147 @@ async function resolveIngestConfiguration(device, options = {}) { return { host, transport: "rtmp", - tlsCert: "", - tlsKey: "", bindHost: resolved[0] || "127.0.0.1", - ingestPort: INGEST_PORT, - publicPort: PUBLIC_INGEST_PORT + ingestPort: listenerPort, + publicPort: listenerPort }; } if (pairedUrl?.protocol !== "https:") { throw coded("STREAM_TEST_TLS_REQUIRED", "Non-local Companion sessions require an HTTPS pairing URL and RTMPS ingest."); } - const requested = String(process.env.LUMI_STREAM_TEST_TRANSPORT || "").trim().toLowerCase(); - let tlsCert = String(process.env.LUMI_STREAM_TEST_TLS_CERT || "").trim(); - let tlsKey = String(process.env.LUMI_STREAM_TEST_TLS_KEY || "").trim(); - if (requested && !["rtmps", "rtmp"].includes(requested)) throw coded("STREAM_TEST_TRANSPORT_INVALID", "LUMI_STREAM_TEST_TRANSPORT must be rtmps or rtmp."); - // Transport overrides never weaken the authenticated origin policy. This also - // makes an old development `rtmp` override harmless after moving Lumi behind - // its production HTTPS hostname. - const transport = "rtmps"; - if (tlsCert || tlsKey) { - for (const [label, target] of [["certificate", tlsCert], ["private key", tlsKey]]) { - if (!target || !fs.existsSync(target) || !fs.statSync(target).isFile()) { - throw coded("STREAM_TEST_TLS_UNCONFIGURED", `The advanced RTMPS override requires a readable ${label}. Configure both LUMI_STREAM_TEST_TLS_CERT and LUMI_STREAM_TEST_TLS_KEY, or remove both so Lumi can manage the certificate.`); - } - } - } else { - const managed = await (options.certificateManager || streamTestCertificateManager).resolve(host); - tlsCert = managed.certificate; - tlsKey = managed.privateKey; - } return { host, - transport, - tlsCert, - tlsKey, + transport: "rtmps", bindHost: "0.0.0.0", - ingestPort: INGEST_PORT, - publicPort: PUBLIC_INGEST_PORT + ingestPort: listenerPort, + publicPort }; } +function probeExternalRtmps(options = {}) { + const hostname = validateHostname(String(options.hostname || "").trim()); + const publicPort = validatePort(options.publicPort, "Public RTMPS port"); + const listenerPort = validatePort(options.listenerPort, "Local RTMP listener port"); + const connect = options.tlsConnect || tls.connect; + const timeoutMs = Math.max(1000, Math.min(30_000, Number(options.timeoutMs) || 12_000)); + const bareHost = hostname.replace(/^\[|\]$/g, ""); + if (!hostname || net.isIP(bareHost)) { + return Promise.reject(coded("STREAM_TEST_EXTERNAL_HOST_INVALID", "A public DNS hostname is required for the RTMPS certificate check.")); + } + + return new Promise((resolve, reject) => { + let socket; + let stage = "tls"; + let settled = false; + let received = Buffer.alloc(0); + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket?.removeAllListeners?.(); + socket?.destroy?.(); + if (error) reject(externalProbeError(error, stage, hostname, publicPort)); + else resolve(result); + }; + const timer = setTimeout(() => { + const error = new Error(`Timed out during the ${stage === "tls" ? "TLS connection" : "RTMP handshake"}.`); + error.code = "ETIMEDOUT"; + finish(error); + }, timeoutMs); + timer.unref?.(); + try { + socket = connect({ + host: bareHost, + port: publicPort, + servername: bareHost, + rejectUnauthorized: true + }); + } catch (error) { + finish(error); + return; + } + socket.once("error", (error) => finish(error)); + socket.once("secureConnect", () => { + if (socket.authorized === false) { + const error = new Error(socket.authorizationError || "The certificate was not authorized."); + error.code = "CERTIFICATE_UNAUTHORIZED"; + finish(error); + return; + } + stage = "rtmp"; + const c0c1 = Buffer.alloc(1537); + c0c1[0] = 3; + c0c1.writeUInt32BE(Math.floor(Date.now() / 1000) >>> 0, 1); + crypto.randomFillSync(c0c1, 9); + socket.write(c0c1, (error) => { + if (error) finish(error); + }); + }); + socket.on("data", (chunk) => { + if (stage !== "rtmp" || settled) return; + received = Buffer.concat([received, chunk], received.length + chunk.length); + if (received.length < 3073) return; + if (received[0] !== 3) { + const error = new Error(`The downstream service returned unsupported RTMP version ${received[0]}.`); + error.code = "RTMP_VERSION_INVALID"; + finish(error); + return; + } + const c2 = Buffer.from(received.subarray(1, 1537)); + socket.write(c2, (error) => { + if (error) return finish(error); + const peer = socket.getPeerCertificate?.() || {}; + const cipher = socket.getCipher?.() || {}; + finish(null, { + ok: true, + hostname, + public_port: publicPort, + local_port: listenerPort, + detail: `TLS certificate validation and the RTMP handshake both completed through ${hostname}:${publicPort}.`, + tls: { + protocol: socket.getProtocol?.() || null, + cipher: cipher.standardName || cipher.name || null, + subject: peer.subject?.CN || null, + issuer: peer.issuer?.CN || null, + valid_to: peer.valid_to || null + }, + rtmp: { + version: received[0], + handshake_bytes: 3073 + } + }); + }); + }); + socket.once("end", () => { + if (!settled) finish(new Error("The remote endpoint closed before the RTMP handshake completed.")); + }); + socket.once("close", () => { + if (!settled) finish(new Error("The remote endpoint closed before the RTMP handshake completed.")); + }); + }); +} + +function externalProbeError(error, stage, hostname, publicPort) { + const code = String(error?.code || ""); + const certificateFailure = stage === "tls" && ( + code.includes("CERT") + || code.includes("TLS") + || code.includes("UNABLE_TO_VERIFY") + || code === "DEPTH_ZERO_SELF_SIGNED_CERT" + ); + const detail = String(error?.message || "connection failed").replace(/\s+/g, " ").slice(0, 240); + const message = certificateFailure + ? `TLS reached ${hostname}:${publicPort}, but its certificate could not be validated: ${detail}` + : stage === "rtmp" + ? `TLS reached ${hostname}:${publicPort}, but the RTMP handshake did not complete through Nginx Proxy Manager to Lumi: ${detail}` + : `Lumi could not reach ${hostname}:${publicPort} over TLS: ${detail}`; + return coded( + "STREAM_TEST_EXTERNAL_CHECK_FAILED", + `${message} Check the NPM TCP stream, firewall, and port forwarding. A server-side check can also fail when the network does not support NAT loopback/hairpinning.` + ); +} + function isLoopbackHostname(value) { const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase(); return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1"; @@ -759,11 +899,6 @@ function formatFps(value) { return Number.isInteger(value) ? String(value) : Number(value).toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); } -function port(value, fallback) { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : fallback; -} - function publicRuntimeStatus(status) { return { available: Boolean(status.available), @@ -799,12 +934,12 @@ const streamTestingService = new StreamTestingService(); module.exports = { DATA_ROOT, INACTIVITY_MS, - INGEST_PORT, MAX_SESSION_MS, - PUBLIC_INGEST_PORT, StreamTestingService, + externalProbeError, isPrivateAddress, isPrivateHost, + probeExternalRtmps, publicRuntimeStatus, resolveIngestConfiguration, rewriteManifest, diff --git a/src/web/public/stream-testing.css b/src/web/public/stream-testing.css index 86c4a2d..7b3fbd3 100644 --- a/src/web/public/stream-testing.css +++ b/src/web/public/stream-testing.css @@ -51,11 +51,14 @@ .stream-test-timeline li::marker { color: var(--lumi-primary); } .stream-test-warning { margin-top: var(--lumi-space-3); } .stream-runtime-actions { margin-top: var(--lumi-space-3); } -.stream-test-dns-form { display: grid; grid-template-columns: minmax(11rem, .7fr) repeat(2, minmax(12rem, 1fr)); gap: var(--lumi-space-3); margin-top: var(--lumi-space-4); align-items: end; } -.stream-test-dns-form .inline-actions { grid-column: 1 / -1; } +.stream-test-ingest-settings > details { margin: 0; } +.stream-test-ingest-body { display: grid; gap: var(--lumi-space-3); padding-top: var(--lumi-space-3); } +.stream-test-ingest-body > :where(p, form, div) { margin-block: 0; } +.stream-test-ingest-form { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: var(--lumi-space-3); align-items: end; } +.stream-test-ingest-form .inline-actions { grid-column: 1 / -1; } @media (prefers-reduced-motion: reduce) { .stream-test-caption-word.is-new { animation: none; } } @media (max-width: 900px) { - .stream-test-layout, .stream-test-dns-form { grid-template-columns: 1fr; } - .stream-test-dns-form .inline-actions { grid-column: auto; } + .stream-test-layout, .stream-test-ingest-form { grid-template-columns: 1fr; } + .stream-test-ingest-form .inline-actions { grid-column: auto; } } } diff --git a/src/web/public/stream-testing.js b/src/web/public/stream-testing.js index c3d50a2..87540bb 100644 --- a/src/web/public/stream-testing.js +++ b/src/web/public/stream-testing.js @@ -21,6 +21,9 @@ const runtimeRepair = root.querySelector("[data-runtime-repair]"); const runtimeHealth = root.querySelector("[data-runtime-health]"); const runtimeResult = root.querySelector("[data-runtime-result]"); + const ingestCheck = root.querySelector("[data-ingest-check]"); + const ingestSave = root.querySelector("[data-ingest-save]"); + const ingestResult = root.querySelector("[data-ingest-result]"); let hls = null; let sessionId = null; let latestSession = null; @@ -256,6 +259,8 @@ runtimeInstall.disabled = Boolean(session); runtimeRepair.disabled = Boolean(session); runtimeHealth.disabled = Boolean(session) || !runtime.available; + if (ingestCheck) ingestCheck.disabled = Boolean(session); + if (ingestSave) ingestSave.disabled = Boolean(session); summary.innerHTML = [ metric("Media router", runtime.available ? `${runtime.version || "Ready"} · ${runtime.source || "managed"}` : "Needs setup"), metric("Session", session ? session.state : "No active test"), @@ -346,6 +351,32 @@ } } + async function checkExternalAccess() { + if (!ingestCheck || !ingestResult) return; + ingestCheck.disabled = true; + ingestResult.hidden = true; + try { + const response = await fetch("/admin/stream-testing/reverse-proxy/check", { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: "{}" + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "The external RTMPS check failed."); + const result = payload.result || {}; + const tlsDetail = [result.tls?.protocol, result.tls?.cipher].filter(Boolean).join(" · "); + ingestResult.className = "callout success"; + ingestResult.textContent = `${result.detail}${tlsDetail ? ` ${tlsDetail}.` : ""}`; + ingestResult.hidden = false; + } catch (error) { + ingestResult.className = "callout danger"; + ingestResult.textContent = error.message; + ingestResult.hidden = false; + } finally { + ingestCheck.disabled = false; + } + } + player.addEventListener("error", () => { playerErrors += 1; }); player.addEventListener("canplay", () => void attemptAutoplay()); player.textTracks?.addEventListener?.("change", () => { @@ -366,6 +397,7 @@ runtimeInstall.addEventListener("click", () => runtimeAction("install")); runtimeRepair.addEventListener("click", () => runtimeAction("repair")); runtimeHealth.addEventListener("click", () => runtimeAction("health")); + ingestCheck?.addEventListener("click", checkExternalAccess); const metricTimer = window.setInterval(() => { if (!sessionId) return; diff --git a/src/web/server.js b/src/web/server.js index 0ea1f9d..708ecdb 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -81,11 +81,9 @@ const { twitchEventSubManager } = require("../services/twitch-eventsub"); const { eventHooksApi } = require("../services/overlay-event-hooks"); const { streamTestingService } = require("../services/stream-testing"); const { - clearDnsAutomation, - dnsAutomationStatus, - saveDomeneshopDnsCredentials -} = require("../services/stream-test-dns"); -const { streamTestCertificateManager } = require("../services/stream-test-certificates"); + getReverseProxyIngestSettings, + saveReverseProxyIngestSettings +} = require("../services/stream-test-ingest-settings"); const { getClient: getYouTubeClient } = require("../services/youtube"); const { conditionalRepliesFromBody, @@ -3264,12 +3262,6 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { res.setHeader("X-Content-Type-Options", "nosniff"); next(); }); - app.get("/.well-known/acme-challenge/:token", (req, res) => { - const response = streamTestCertificateManager.challenge(req.params.token); - res.set("Cache-Control", "no-store"); - if (!response) return res.status(404).type("text/plain").send("Not found"); - return res.status(200).type("text/plain").send(response); - }); app.use(express.static(path.join(__dirname, "public"))); const uploadDir = path.join(__dirname, "..", "..", "data", "uploads"); @@ -6225,27 +6217,36 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { res.render("admin-stream-testing", { title: "Stream testing", streamTest: streamTestingService.publicStatus(), - streamTestDns: dnsAutomationStatus() + streamTestIngest: getReverseProxyIngestSettings() }); }); - app.post("/admin/stream-testing/tls/dns", requireRole("admin"), async (req, res) => { + app.post("/admin/stream-testing/reverse-proxy", requireRole("admin"), (req, res) => { try { - const status = await saveDomeneshopDnsCredentials({ - token: req.body?.token, - secret: req.body?.secret + if (streamTestingService.publicStatus().active) { + throw new Error("End the active Stream Test before changing reverse-proxy ingest ports."); + } + const settings = saveReverseProxyIngestSettings({ + publicPort: req.body?.public_port, + listenerPort: req.body?.listener_port }); - setFlash(req, "success", `${status.providerLabel} DNS automation is verified. Lumi can now issue and renew RTMPS certificates without using the reverse proxy challenge path.`); + setFlash(req, "success", `Reverse-proxy ingest saved. NPM should forward public port ${settings.publicPort} to Lumi port ${settings.listenerPort}.`); } catch (error) { setFlash(req, "error", error.message); } res.redirect("/admin/stream-testing"); }); - app.post("/admin/stream-testing/tls/dns/remove", requireRole("admin"), (req, res) => { - clearDnsAutomation(); - setFlash(req, "success", "Stored DNS automation credentials were removed. Lumi will use the public HTTP challenge route for future certificate issuance."); - res.redirect("/admin/stream-testing"); + app.post("/admin/stream-testing/reverse-proxy/check", requireRole("admin"), async (req, res) => { + try { + const result = await streamTestingService.checkExternalAccess(req.hostname); + res.json({ ok: true, result }); + } catch (error) { + res.status(error.code === "STREAM_TEST_ACTIVE" ? 409 : 502).json({ + ok: false, + error: error.message + }); + } }); app.get("/admin/stream-testing/status", requireRole("admin"), (_req, res) => { diff --git a/src/web/views/admin-stream-testing.ejs b/src/web/views/admin-stream-testing.ejs index 100ca24..61ddc63 100644 --- a/src/web/views/admin-stream-testing.ejs +++ b/src/web/views/admin-stream-testing.ejs @@ -30,43 +30,37 @@ -
-
-
Secure production ingest

RTMPS certificate automation

- "><%= streamTestDns.configured ? "DNS ready" : "HTTP challenge" %> -
- <% if (streamTestDns.configured) { %> -

Lumi uses encrypted <%= streamTestDns.providerLabel %> credentials to create and remove short-lived DNS challenges. Certificate issuance and renewal do not depend on the HTTPS reverse proxy.

- <% } else { %> -
- Using the public WebUI challenge path -

If OpenResty, Nginx Proxy Manager, or another HTTPS proxy reserves /.well-known/acme-challenge/, configure DNS automation here. Lumi will then issue RTMPS certificates without proxy changes or certificate file paths.

+
+
+ + Reverse-proxy ingestRTMPS at Nginx Proxy Manager → plain RTMP at Lumi + +
+

Nginx Proxy Manager must expose TLS on the public port and forward the decrypted TCP stream to Lumi on the local listener port. Lumi never requests or loads the certificate.

+
+ + +
+ + +
+
+ <% if (streamTest.active) { %> +

End the active Stream Test before changing ports or running the external check. Saved changes apply to the next session.

+ <% } else { %> +

Changes apply to the next Stream Test. The external check uses this page's public hostname, validates TLS with SNI, and completes an RTMP handshake through NPM to Lumi.

+ <% } %> +
- <% } %> -
- - - -
- - Create API credentials -
-
- <% if (streamTestDns.configured) { %> -
- -
- <% } %> -

Credentials are encrypted with this Lumi installation's secret and are never returned to the browser or written to logs.

+
diff --git a/update-manifest.json b/update-manifest.json index 3d49160..7ef7a56 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.3.6", + "version": "0.3.7", "channel": "stable", "released_at": "2026-07-26", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "1.2.0" ], - "migration_notes": "Adds encrypted Domeneshop DNS-01 automation for RTMPS certificate issuance and renewal when OpenResty or another HTTPS reverse proxy owns the HTTP challenge path. Lumi creates, verifies, and removes short-lived DNS records without external packages or certificate paths. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved.", + "migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data.", "rollback_safe": true, "requirements": [ "Node.js 18 or newer" @@ -421,6 +421,18 @@ ], "rollback_safe": true, "migration_notes": "Adds resilient Companion reconnect and caption controls, progressive private-test captions, improved Companion navigation and stream-viewer access, automatic Lumi-managed RTMPS certificates, refreshed Song Overlay delivery, and repository-wide durable redacted logging. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved." + }, + { + "version": "0.3.6", + "channel": "stable", + "released_at": "2026-07-26", + "compatible_from": "0.1.9", + "migration_kind": "patch", + "replaces_versions": [ + "1.2.0" + ], + "rollback_safe": true, + "migration_notes": "Adds encrypted Domeneshop DNS-01 automation for RTMPS certificate issuance and renewal when OpenResty or another HTTPS reverse proxy owns the HTTP challenge path. Lumi creates, verifies, and removes short-lived DNS records without external packages or certificate paths. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved." } ] }