release: publish Lumi 0.3.7

This commit is contained in:
Franz Rolfsvaag 2026-07-26 18:36:30 +02:00
parent 480a34b0af
commit bc7bff0eb9
21 changed files with 593 additions and 1307 deletions

View File

@ -51,15 +51,7 @@ LUMI_OPERATOR_PRIVACY_URL=
# LUMI_HOST=127.0.0.1 # LUMI_HOST=127.0.0.1
# Private OBS stream testing (optional; see docs/stream-testing.md). # Private OBS stream testing (optional; see docs/stream-testing.md).
# Lumi uses the paired Companion hostname by default and manages MediaMTX itself. # Public and local ingest ports are stored in Lumi settings and configured at
# LUMI_STREAM_TEST_INGEST_HOST=lumi.example.com # Admin > Stream testing. Nginx Proxy Manager owns production TLS termination.
# 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
# Advanced/manual runtime override only; normal installations do not need this. # Advanced/manual runtime override only; normal installations do not need this.
# LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx # LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx

View File

@ -1,5 +1,11 @@
# Lumi changelog # 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 ## 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. - 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.

View File

@ -64,53 +64,44 @@ the downloaded runtime and documented in
Lumi derives the network policy from the authenticated Companion pairing Lumi derives the network policy from the authenticated Companion pairing
origin. A Companion paired through `localhost`, `127.0.0.1`, or `::1` always origin. A Companion paired through `localhost`, `127.0.0.1`, or `::1` always
receives loopback RTMP; production ingest overrides are deliberately ignored receives direct loopback RTMP on the configured local listener port. A
for that local development session. A non-local Companion must have paired non-local Companion must have paired through HTTPS and always receives RTMPS
through HTTPS and always receives RTMPS. `LUMI_STREAM_TEST_INGEST_HOST` may at the same paired Lumi hostname.
override the advertised hostname only for those non-local sessions.
For the normal production path, Lumi automatically provisions and renews a In production, Nginx Proxy Manager owns the public certificate and terminates
publicly trusted certificate for the paired hostname. It first supports ACME TLS:
HTTP-01 through the public, narrowly scoped
`/.well-known/acme-challenge/` route. When a reverse proxy owns that reserved ```text
path, configure DNS automation in **Admin > Stream testing** instead. Lumi can OBS → rtmps://<paired Lumi hostname>:1936
use encrypted Domeneshop credentials to create the short-lived DNS-01 TXT → Nginx Proxy Manager decrypts TLS
record, wait for authoritative propagation, issue or renew the certificate, → plain RTMP to Lumi:19350
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 MediaMTX binds plain RTMP on `0.0.0.0:19350` for a production session. It never
certificate is issued; later tests reuse it. 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/<uuid>` path and high-entropy Every session receives an exact `lumi-test/<uuid>` path and high-entropy
publisher credentials. Only that path can be published, and the generated publisher credentials. Only that path can be published, and the generated
MediaMTX configuration contains SHA-256 credential hashes rather than MediaMTX configuration contains SHA-256 credential hashes rather than
plaintext credentials. plaintext credentials.
RTMPS is required for every non-local pairing. A stale RTMPS is required for every non-local pairing. Unencrypted RTMP is advertised
`LUMI_STREAM_TEST_TRANSPORT=rtmp` value is ignored for production rather than only to an authenticated loopback pairing. Use the existing paired Lumi
weakening transport or preventing the test from starting. Normal installations hostname; no extra Stream Testing domain is required. Restrict direct access
need no certificate environment variables: to the local MediaMTX listener with the host firewall so production publishers
must enter through NPM.
```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.
Optional session limits remain: Optional session limits remain:

View File

@ -14,9 +14,8 @@ editable: false
Lumi is the core web UI and bot runtime. Lumi is the core web UI and bot runtime.
## Runtime ## Runtime
Package: lumi-bot Package: lumi-bot
Version: 0.3.6 Version: 0.3.7
## Routes ## Routes
- GET /.well-known/acme-challenge/:token
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events
- POST /api/destructive-confirmations - POST /api/destructive-confirmations
@ -95,8 +94,8 @@ Version: 0.3.6
- POST /admin/theming - POST /admin/theming
- GET /admin/diagnostics - GET /admin/diagnostics
- GET /admin/stream-testing - GET /admin/stream-testing
- POST /admin/stream-testing/tls/dns - POST /admin/stream-testing/reverse-proxy
- POST /admin/stream-testing/tls/dns/remove - POST /admin/stream-testing/reverse-proxy/check
- GET /admin/stream-testing/status - GET /admin/stream-testing/status
- POST /admin/stream-testing/stop - POST /admin/stream-testing/stop
- POST /admin/stream-testing/runtime/install - 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/scene
- POST /api/admin/overlays/:id/obs/import - POST /api/admin/overlays/:id/obs/import
## Route Reference ## 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 ### POST /api/diagnostics/v1/run
- Purpose: Provides api diagnostics v1 run data as JSON. - Purpose: Provides api diagnostics v1 run data as JSON.
@ -939,23 +929,23 @@ Version: 0.3.6
- Side effects: Usually read-only. - Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. - 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 - 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 - Response format: HTTP redirect after handling the request
- Access: admin access expected - Access: admin access expected
- Side effects: writes or mutates server-side state - 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. - 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. - Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request - Response format: JSON response
- Access: admin access expected - Access: admin access expected
- Side effects: writes or mutates server-side state - 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. Most non-API POST routes are browser form submissions and usually redirect after completion. - 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 ### GET /admin/stream-testing/status

378
package-lock.json generated
View File

@ -1,14 +1,13 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.6", "version": "0.3.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.6", "version": "0.3.7",
"dependencies": { "dependencies": {
"acme-client": "^5.4.0",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"better-sqlite3-session-store": "^0.1.0", "better-sqlite3-session-store": "^0.1.0",
@ -88,163 +87,6 @@
"node": ">= 10" "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": { "node_modules/@playwright/test": {
"version": "1.61.1", "version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
@ -325,45 +167,6 @@
"node": ">= 0.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": { "node_modules/adm-zip": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
@ -373,41 +176,6 @@
"node": ">=14.0" "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": { "node_modules/append-field": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@ -420,20 +188,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "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": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -456,18 +210,6 @@
"node": ">=4" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1066,26 +808,6 @@
"node": ">= 0.8" "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": { "node_modules/form-data": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@ -1270,42 +992,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1760,15 +1437,6 @@
"node": ">= 0.10" "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": { "node_modules/pump": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@ -1779,24 +1447,6 @@
"once": "^1.3.1" "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": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@ -1875,12 +1525,6 @@
"node": ">= 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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -2190,24 +1834,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.6", "version": "0.3.7",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
@ -30,7 +30,6 @@
"node": ">=18" "node": ">=18"
}, },
"dependencies": { "dependencies": {
"acme-client": "^5.4.0",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"better-sqlite3-session-store": "^0.1.0", "better-sqlite3-session-store": "^0.1.0",

View File

@ -2,6 +2,38 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "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", "version": "0.3.6",
"ref": "refs/tags/v0.3.6", "ref": "refs/tags/v0.3.6",

View File

@ -4,9 +4,9 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning"); const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
const releaseVersion = "0.3.6"; const releaseVersion = "0.3.7";
const previousStableVersion = "0.3.5"; const previousStableVersion = "0.3.6";
const priorStableVersion = "0.3.4"; const priorStableVersion = "0.3.5";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = { const introducedPlugins = {
lumi_transcription: { version: "0.2.5", knowledge: "lumi-transcription" }, 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(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); 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.");

View File

@ -25,19 +25,17 @@ const {
StreamTestingService, StreamTestingService,
INACTIVITY_MS, INACTIVITY_MS,
isPrivateAddress, isPrivateAddress,
probeExternalRtmps,
resolveIngestConfiguration, resolveIngestConfiguration,
rewriteManifest, rewriteManifest,
sourceFor, sourceFor,
validateHostname validateHostname
} = require("../src/services/stream-testing"); } = require("../src/services/stream-testing");
const { const {
StreamTestCertificateManager, DEFAULT_LOCAL_RTMP_PORT,
normalizeCertificateHostname DEFAULT_PUBLIC_RTMPS_PORT,
} = require("../src/services/stream-test-certificates"); validateReverseProxyIngestSettings
const { } = require("../src/services/stream-test-ingest-settings");
DomeneshopDnsProvider,
challengeHost
} = require("../src/services/stream-test-dns");
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol"); const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
class FakeRuntime extends EventEmitter { class FakeRuntime extends EventEmitter {
@ -63,6 +61,7 @@ class FakeRuntime extends EventEmitter {
}; };
} }
async configureSession(session) { this.session = session; this.running = true; } 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 clearSession() { this.session = null; this.cleared += 1; }
async diagnostics(sessionPath) { async diagnostics(sessionPath) {
return { return {
@ -77,6 +76,41 @@ class FakeRuntime extends EventEmitter {
async close() { this.running = false; } 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) { async function verifyArtifactManager(tempRoot) {
assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/); assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/);
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/); assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/);
@ -164,8 +198,8 @@ async function verifyManagedRuntime(tempRoot) {
path: "lumi-test/00000000-0000-4000-8000-000000000001", path: "lumi-test/00000000-0000-4000-8000-000000000001",
username: "private-user", username: "private-user",
password: "private-password", password: "private-password",
transport: "rtmp", ingestPort: 29350,
ingestPort: 29350 bindHost: "0.0.0.0"
}; };
await runtime.configureSession(session); await runtime.configureSession(session);
const generated = fs.readFileSync(runtime.configPath, "utf8"); const generated = fs.readFileSync(runtime.configPath, "utf8");
@ -176,7 +210,9 @@ async function verifyManagedRuntime(tempRoot) {
assert.match(generated, /srt: false/); assert.match(generated, /srt: false/);
assert.match(generated, /hlsVariant: lowLatency/); assert.match(generated, /hlsVariant: lowLatency/);
assert.match(generated, /maxReaders: 4/); 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); const diagnostic = await runtime.diagnostics(session.path);
assert.equal(diagnostic.path.name, session.path); assert.equal(diagnostic.path.name, session.path);
const playlist = await runtime.fetchHls(session.path, "index.m3u8"); const playlist = await runtime.fetchHls(session.path, "index.m3u8");
@ -191,18 +227,13 @@ async function verifyManagedRuntime(tempRoot) {
} }
async function verifyStreamService() { 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 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 = []; const sent = [];
try { try {
const created = await service.create( const created = await service.create(
@ -211,7 +242,7 @@ async function verifyStreamService() {
(type, payload) => sent.push({ type, payload }) (type, payload) => sent.push({ type, payload })
); );
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/); 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.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(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder");
assert.equal(runtime.session.path, `lumi-test/${created.id}`); assert.equal(runtime.session.path, `lumi-test/${created.id}`);
@ -322,6 +353,9 @@ async function verifyStreamService() {
assert.equal(endedSession.username, null); assert.equal(endedSession.username, null);
assert.equal(endedSession.password, null); assert.equal(endedSession.password, null);
assert.equal(sent.at(-1).type, "stream_test_ended"); 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( await assert.rejects(
() => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}), () => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}),
(error) => error.code === "STREAM_TEST_RATE_LIMIT" (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)); assert([created.id, expiring.id, inactive.id, failed.id].every(Boolean));
} finally { } finally {
await service.close(); 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("stream.example.com"), "stream.example.com");
assert.equal(validateHostname("https://stream.example.com"), ""); assert.equal(validateHostname("https://stream.example.com"), "");
assert.equal(validateHostname("[::1]"), "[::1]"); 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(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.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]);
assert.match(rewriteManifest( assert.match(rewriteManifest(
"#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\nsegment0.mp4\n", "#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.match(config, /paths:\n "lumi-test\/id":/);
assert(!/action: publish[\s\S]*path: all/.test(config)); assert(!/action: publish[\s\S]*path: all/.test(config));
const oldNetwork = { const configuredPorts = { publicPort: 21936, listenerPort: 29350 };
host: process.env.LUMI_STREAM_TEST_INGEST_HOST, const secure = await resolveIngestConfiguration(
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" }, { pairing_host: "https://stream.example.test" },
{ certificateManager: { resolve: async () => ({ certificate: "managed-cert.pem", privateKey: "managed-key.pem" }) } } { 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(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.transport, "rtmp");
assert.equal(local.host, "localhost"); assert.equal(local.host, "localhost");
const cert = path.join(tempRoot, "cert.pem"); assert.equal(local.ingestPort, 29350);
const key = path.join(tempRoot, "key.pem"); assert.equal(local.publicPort, 29350, "loopback development must bypass the public RTMPS port");
fs.writeFileSync(cert, "test certificate"); const probed = await probeExternalRtmps({
fs.writeFileSync(key, "test key"); hostname: "lumi.example.test",
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps"; publicPort: 1936,
process.env.LUMI_STREAM_TEST_TLS_CERT = cert; listenerPort: 19350,
process.env.LUMI_STREAM_TEST_TLS_KEY = key; tlsConnect: createSuccessfulTlsSocket
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" }); });
assert.equal(secure.transport, "rtmps"); assert.equal(probed.tls.protocol, "TLSv1.3");
assert.equal(secure.host, "lumi.example.test"); assert.equal(probed.rtmp.version, 3);
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;
}
}
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption_status", "stream_test_caption", "stream_test_stop"]) { 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)))); 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 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 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 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 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 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 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 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\/runtime\/install/);
assert.match(server, /admin\/stream-testing\/media\/:id\/\*/); assert.match(server, /admin\/stream-testing\/media\/:id\/\*/);
assert.match(server, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/); 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, /caption_delay/);
assert.match(service, /updateCaptionStatus/); assert.match(service, /updateCaptionStatus/);
assert.match(service, /localDevelopment[\s\S]*transport: "rtmp"/); assert.match(service, /localDevelopment[\s\S]*transport: "rtmp"/);
assert.match(service, /const transport = "rtmps"/); assert.match(service, /transport: "rtmps"/);
assert.match(service, /streamTestCertificateManager/); assert.match(service, /probeExternalRtmps/);
assert.match(certificates, /challengePriority: \[dnsProvider \? "dns-01" : "http-01"\]/); assert.match(service, /servername: bareHost/);
assert.match(server, /\.well-known\/acme-challenge\/:token/); assert.match(runtime, /rtmpEncryption: \\"no\\"/);
assert.match(server, /admin\/stream-testing\/tls\/dns/); assert.match(runtime, /bindHost \|\| "0\.0\.0\.0"/);
assert.match(webUi, /RTMPS certificate automation/); assert.match(ingestSettings, /DEFAULT_PUBLIC_RTMPS_PORT = 1936/);
assert.match(webUi, /Domeneshop \/ hyp\.net/); 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, /<details class="lumi-expandable-settings">/);
assert.doesNotMatch(webUi, /certificate automation|Domeneshop/);
assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service)); assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
assert(!/shell:\s*true/.test(runtime)); assert(!/shell:\s*true/.test(runtime));
assert.match(gateway, /await service\.create/); assert.match(gateway, /await service\.create/);
@ -552,158 +587,6 @@ async function main() {
console.log("Managed MediaMTX Stream Testing verification passed."); 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) => { main().catch((error) => {
console.error(error); console.error(error);
process.exitCode = 1; process.exitCode = 1;

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); 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"); assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) { for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref); assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json"); const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version); assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable"); assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.3.6"); assert.equal(packageVersion, "0.3.7");
assert.equal(currentRelease.version, "0.3.6"); assert.equal(currentRelease.version, "0.3.7");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]); assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) { for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`); 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 = { const baseTarget = {
current_version: "0.2.4", current_version: "0.2.4",
available_versions: [ 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.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.5", ref: "refs/tags/v0.3.5", rollback_safe: true },
{ version: "0.3.4", ref: "refs/tags/v0.3.4", 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" channel: "stable"
}); });
assert.equal(corrected.version_correction, true); 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.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -178,9 +178,13 @@ class MediaMtxRuntime extends EventEmitter {
return this.install(options); return this.install(options);
} }
async healthCheck() { async healthCheck(listener = {}) {
return this.queue(async () => { 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([ 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.api}/v3/paths/list`, 5000),
this.fetchText(`http://127.0.0.1:${this.ports.metrics}/metrics`, 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 hls: hls.response.status
}; };
await this.stopProcess(); await this.stopProcess();
await this.startWithConfig(null); await this.startWithConfig(listenerConfig);
return { return {
ok: api.response.ok && metrics.response.ok && Boolean(hls.response), ok: api.response.ok && metrics.response.ok && Boolean(hls.response),
version: this.status({ refresh: true }).version, version: this.status({ refresh: true }).version,
@ -211,10 +215,7 @@ class MediaMtxRuntime extends EventEmitter {
path: session.path, path: session.path,
username: session.username, username: session.username,
password: session.password, password: session.password,
transport: session.transport,
ingestPort: session.ingestPort, ingestPort: session.ingestPort,
tlsCert: session.tlsCert,
tlsKey: session.tlsKey,
bindHost: session.bindHost bindHost: session.bindHost
}; };
await this.startWithConfig(this.currentSession); 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 () => { return this.queue(async () => {
this.logTail = redactRuntimeLog(this.logTail, this.currentSession); this.logTail = redactRuntimeLog(this.logTail, this.currentSession);
this.currentSession = null; this.currentSession = null;
if (this.child && this.child.exitCode === null) await this.startWithConfig(null); const listenerConfig = {
else this.writeConfig(null); 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(); return this.processStatus();
}); });
} }
@ -437,8 +462,8 @@ class MediaMtxRuntime extends EventEmitter {
} }
function renderConfig({ apiPort, metricsPort, hlsPort, session }) { function renderConfig({ apiPort, metricsPort, hlsPort, session }) {
const transport = session?.transport === "rtmps" ? "rtmps" : "rtmp"; const publisherSession = Boolean(session?.path);
const publisher = session ? [ const publisher = publisherSession ? [
"- user: " + yamlString(sha256Credential(session.username)), "- user: " + yamlString(sha256Credential(session.username)),
" pass: " + yamlString(sha256Credential(session.password)), " pass: " + yamlString(sha256Credential(session.password)),
" ips: []", " ips: []",
@ -446,16 +471,11 @@ function renderConfig({ apiPort, metricsPort, hlsPort, session }) {
" - action: publish", " - action: publish",
" path: " + yamlString(session.path) " path: " + yamlString(session.path)
] : []; ] : [];
const rtmp = transport === "rtmps" ? [ const rtmp = [
"rtmpEncryption: strict",
`rtmpsAddress: "0.0.0.0:${session.ingestPort}"`,
`rtmpServerKey: ${yamlString(session.tlsKey)}`,
`rtmpServerCert: ${yamlString(session.tlsCert)}`
] : [
"rtmpEncryption: \"no\"", "rtmpEncryption: \"no\"",
`rtmpAddress: ${yamlString(formatSocketAddress(session?.bindHost || "127.0.0.1", session?.ingestPort || 19350))}` `rtmpAddress: ${yamlString(formatSocketAddress(session?.bindHost || "127.0.0.1", session?.ingestPort || 19350))}`
]; ];
const paths = session ? [ const paths = publisherSession ? [
"paths:", "paths:",
` ${yamlString(session.path)}:`, ` ${yamlString(session.path)}:`,
" source: publisher", " source: publisher",
@ -476,7 +496,7 @@ function renderConfig({ apiPort, metricsPort, hlsPort, session }) {
" ips: [127.0.0.1, '::1']", " ips: [127.0.0.1, '::1']",
" permissions:", " permissions:",
" - action: read", " - action: read",
...(session ? [` path: ${yamlString(session.path)}`] : []), ...(publisherSession ? [` path: ${yamlString(session.path)}`] : []),
" - action: api", " - action: api",
" - action: metrics", " - action: metrics",
"api: true", "api: true",

View File

@ -69,6 +69,8 @@ function ensureDefaults() {
auto_update_enabled: envBoolean("AUTO_UPDATE_ENABLED", false), auto_update_enabled: envBoolean("AUTO_UPDATE_ENABLED", false),
auto_update_interval_minutes: envNumber("AUTO_UPDATE_INTERVAL_MINUTES", 60), auto_update_interval_minutes: envNumber("AUTO_UPDATE_INTERVAL_MINUTES", 60),
production_diagnostics_enabled: false, production_diagnostics_enabled: false,
stream_test_public_rtmps_port: 1936,
stream_test_local_rtmp_port: 19350,
log_retention_days: 30, log_retention_days: 30,
log_retention_max_entries: 100000, log_retention_max_entries: 100000,
git_remote: envString("GIT_REMOTE", "origin"), git_remote: envString("GIT_REMOTE", "origin"),

View File

@ -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
};

View File

@ -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
};

View File

@ -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
};

View File

@ -3,10 +3,14 @@ const dns = require("dns");
const fs = require("fs"); const fs = require("fs");
const net = require("net"); const net = require("net");
const path = require("path"); const path = require("path");
const tls = require("tls");
const { Readable } = require("stream"); const { Readable } = require("stream");
const { pipeline } = require("stream/promises"); const { pipeline } = require("stream/promises");
const { MediaMtxRuntime } = require("./mediamtx-runtime"); 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 { publishWebEvent } = require("./web-events");
const DATA_ROOT = path.join( 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 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 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; const MAX_MANIFEST_BYTES = 1024 * 1024;
function finite(value, fallback, min, max) { function finite(value, fallback, min, max) {
@ -60,6 +62,8 @@ function visibleCaptionCues(session) {
class StreamTestingService { class StreamTestingService {
constructor(options = {}) { constructor(options = {}) {
this.runtime = options.runtime || new MediaMtxRuntime(); this.runtime = options.runtime || new MediaMtxRuntime();
this.ingestSettings = options.ingestSettings || getReverseProxyIngestSettings;
this.tlsConnect = options.tlsConnect || tls.connect;
this.active = null; this.active = null;
this.timeline = []; this.timeline = [];
this.lastCreateAt = new Map(); 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." "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 id = crypto.randomUUID();
const streamPath = `lumi-test/${id}`; const streamPath = `lumi-test/${id}`;
const username = `lumi_${crypto.randomBytes(18).toString("base64url")}`; const username = `lumi_${crypto.randomBytes(18).toString("base64url")}`;
@ -156,6 +162,7 @@ class StreamTestingService {
transport: network.transport, transport: network.transport,
ingestHost: network.host, ingestHost: network.host,
ingestPort: network.ingestPort, ingestPort: network.ingestPort,
bindHost: network.bindHost,
publicPort: network.publicPort, publicPort: network.publicPort,
deviceId: device.id, deviceId: device.id,
deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120), deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
@ -191,16 +198,18 @@ class StreamTestingService {
path: streamPath, path: streamPath,
username, username,
password, password,
transport: network.transport,
ingestPort: network.ingestPort, ingestPort: network.ingestPort,
tlsCert: network.tlsCert,
tlsKey: network.tlsKey,
bindHost: network.bindHost bindHost: network.bindHost
}); });
this.event("receiver_ready", "Lumi's private media router is ready for OBS.", session); this.event("receiver_ready", "Lumi's private media router is ready for OBS.", session);
} catch (error) { } catch (error) {
if (this.active?.id === session.id) this.active = null; 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); 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}`); 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 = {}) { updateObs(deviceId, input = {}) {
const session = this.requireDeviceSession(deviceId, input.session_id); const session = this.requireDeviceSession(deviceId, input.session_id);
session.metrics.obs = { session.metrics.obs = {
@ -435,7 +466,11 @@ class StreamTestingService {
async healthCheck() { async healthCheck() {
if (this.active) throw new Error("The active Stream Test is already exercising MediaMTX."); 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); this.event("runtime_health", `MediaMTX ${result.version || ""} passed its listener and API health check.`.trim(), null);
return result; return result;
} }
@ -450,7 +485,10 @@ class StreamTestingService {
if (this.active?.id === session.id) this.active = null; if (this.active?.id === session.id) this.active = null;
let runtimeError = null; let runtimeError = null;
try { try {
await this.runtime.clearSession(); await this.runtime.clearSession({
bindHost: session.bindHost,
ingestPort: session.ingestPort
});
} catch (error) { } catch (error) {
runtimeError = error; runtimeError = error;
try { await this.runtime.close(); } catch {} try { await this.runtime.close(); } catch {}
@ -595,6 +633,9 @@ class StreamTestingService {
} }
async function resolveIngestConfiguration(device, options = {}) { 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(); const pairedOrigin = String(device?.pairing_host || "").trim();
let pairedUrl; let pairedUrl;
try { try {
@ -602,8 +643,7 @@ async function resolveIngestConfiguration(device, options = {}) {
} catch {} } catch {}
const pairedHost = validateHostname(pairedUrl?.hostname || ""); const pairedHost = validateHostname(pairedUrl?.hostname || "");
const localDevelopment = isLoopbackHostname(pairedHost); const localDevelopment = isLoopbackHostname(pairedHost);
const configuredHost = localDevelopment ? pairedHost : process.env.LUMI_STREAM_TEST_INGEST_HOST || pairedHost; const host = validateHostname(String(pairedHost).trim());
const host = validateHostname(String(configuredHost).trim());
if (!host) throw coded("STREAM_TEST_INGEST_UNCONFIGURED", "The paired Lumi hostname could not be used for private Stream Testing."); if (!host) throw coded("STREAM_TEST_INGEST_UNCONFIGURED", "The paired Lumi hostname could not be used for private Stream Testing.");
if (localDevelopment) { if (localDevelopment) {
@ -611,47 +651,147 @@ async function resolveIngestConfiguration(device, options = {}) {
return { return {
host, host,
transport: "rtmp", transport: "rtmp",
tlsCert: "",
tlsKey: "",
bindHost: resolved[0] || "127.0.0.1", bindHost: resolved[0] || "127.0.0.1",
ingestPort: INGEST_PORT, ingestPort: listenerPort,
publicPort: PUBLIC_INGEST_PORT publicPort: listenerPort
}; };
} }
if (pairedUrl?.protocol !== "https:") { if (pairedUrl?.protocol !== "https:") {
throw coded("STREAM_TEST_TLS_REQUIRED", "Non-local Companion sessions require an HTTPS pairing URL and RTMPS ingest."); 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 { return {
host, host,
transport, transport: "rtmps",
tlsCert,
tlsKey,
bindHost: "0.0.0.0", bindHost: "0.0.0.0",
ingestPort: INGEST_PORT, ingestPort: listenerPort,
publicPort: PUBLIC_INGEST_PORT 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) { function isLoopbackHostname(value) {
const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase(); const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1"; 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(/\.$/, ""); 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) { function publicRuntimeStatus(status) {
return { return {
available: Boolean(status.available), available: Boolean(status.available),
@ -799,12 +934,12 @@ const streamTestingService = new StreamTestingService();
module.exports = { module.exports = {
DATA_ROOT, DATA_ROOT,
INACTIVITY_MS, INACTIVITY_MS,
INGEST_PORT,
MAX_SESSION_MS, MAX_SESSION_MS,
PUBLIC_INGEST_PORT,
StreamTestingService, StreamTestingService,
externalProbeError,
isPrivateAddress, isPrivateAddress,
isPrivateHost, isPrivateHost,
probeExternalRtmps,
publicRuntimeStatus, publicRuntimeStatus,
resolveIngestConfiguration, resolveIngestConfiguration,
rewriteManifest, rewriteManifest,

View File

@ -51,11 +51,14 @@
.stream-test-timeline li::marker { color: var(--lumi-primary); } .stream-test-timeline li::marker { color: var(--lumi-primary); }
.stream-test-warning { margin-top: var(--lumi-space-3); } .stream-test-warning { margin-top: var(--lumi-space-3); }
.stream-runtime-actions { 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-ingest-settings > details { margin: 0; }
.stream-test-dns-form .inline-actions { grid-column: 1 / -1; } .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 (prefers-reduced-motion: reduce) { .stream-test-caption-word.is-new { animation: none; } }
@media (max-width: 900px) { @media (max-width: 900px) {
.stream-test-layout, .stream-test-dns-form { grid-template-columns: 1fr; } .stream-test-layout, .stream-test-ingest-form { grid-template-columns: 1fr; }
.stream-test-dns-form .inline-actions { grid-column: auto; } .stream-test-ingest-form .inline-actions { grid-column: auto; }
} }
} }

View File

@ -21,6 +21,9 @@
const runtimeRepair = root.querySelector("[data-runtime-repair]"); const runtimeRepair = root.querySelector("[data-runtime-repair]");
const runtimeHealth = root.querySelector("[data-runtime-health]"); const runtimeHealth = root.querySelector("[data-runtime-health]");
const runtimeResult = root.querySelector("[data-runtime-result]"); 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 hls = null;
let sessionId = null; let sessionId = null;
let latestSession = null; let latestSession = null;
@ -256,6 +259,8 @@
runtimeInstall.disabled = Boolean(session); runtimeInstall.disabled = Boolean(session);
runtimeRepair.disabled = Boolean(session); runtimeRepair.disabled = Boolean(session);
runtimeHealth.disabled = Boolean(session) || !runtime.available; runtimeHealth.disabled = Boolean(session) || !runtime.available;
if (ingestCheck) ingestCheck.disabled = Boolean(session);
if (ingestSave) ingestSave.disabled = Boolean(session);
summary.innerHTML = [ summary.innerHTML = [
metric("Media router", runtime.available ? `${runtime.version || "Ready"} · ${runtime.source || "managed"}` : "Needs setup"), metric("Media router", runtime.available ? `${runtime.version || "Ready"} · ${runtime.source || "managed"}` : "Needs setup"),
metric("Session", session ? session.state : "No active test"), 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("error", () => { playerErrors += 1; });
player.addEventListener("canplay", () => void attemptAutoplay()); player.addEventListener("canplay", () => void attemptAutoplay());
player.textTracks?.addEventListener?.("change", () => { player.textTracks?.addEventListener?.("change", () => {
@ -366,6 +397,7 @@
runtimeInstall.addEventListener("click", () => runtimeAction("install")); runtimeInstall.addEventListener("click", () => runtimeAction("install"));
runtimeRepair.addEventListener("click", () => runtimeAction("repair")); runtimeRepair.addEventListener("click", () => runtimeAction("repair"));
runtimeHealth.addEventListener("click", () => runtimeAction("health")); runtimeHealth.addEventListener("click", () => runtimeAction("health"));
ingestCheck?.addEventListener("click", checkExternalAccess);
const metricTimer = window.setInterval(() => { const metricTimer = window.setInterval(() => {
if (!sessionId) return; if (!sessionId) return;

View File

@ -81,11 +81,9 @@ const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks"); const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing"); const { streamTestingService } = require("../services/stream-testing");
const { const {
clearDnsAutomation, getReverseProxyIngestSettings,
dnsAutomationStatus, saveReverseProxyIngestSettings
saveDomeneshopDnsCredentials } = require("../services/stream-test-ingest-settings");
} = require("../services/stream-test-dns");
const { streamTestCertificateManager } = require("../services/stream-test-certificates");
const { getClient: getYouTubeClient } = require("../services/youtube"); const { getClient: getYouTubeClient } = require("../services/youtube");
const { const {
conditionalRepliesFromBody, conditionalRepliesFromBody,
@ -3264,12 +3262,6 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
next(); 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"))); app.use(express.static(path.join(__dirname, "public")));
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads"); const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
@ -6225,27 +6217,36 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.render("admin-stream-testing", { res.render("admin-stream-testing", {
title: "Stream testing", title: "Stream testing",
streamTest: streamTestingService.publicStatus(), 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 { try {
const status = await saveDomeneshopDnsCredentials({ if (streamTestingService.publicStatus().active) {
token: req.body?.token, throw new Error("End the active Stream Test before changing reverse-proxy ingest ports.");
secret: req.body?.secret }
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) { } catch (error) {
setFlash(req, "error", error.message); setFlash(req, "error", error.message);
} }
res.redirect("/admin/stream-testing"); res.redirect("/admin/stream-testing");
}); });
app.post("/admin/stream-testing/tls/dns/remove", requireRole("admin"), (req, res) => { app.post("/admin/stream-testing/reverse-proxy/check", requireRole("admin"), async (req, res) => {
clearDnsAutomation(); try {
setFlash(req, "success", "Stored DNS automation credentials were removed. Lumi will use the public HTTP challenge route for future certificate issuance."); const result = await streamTestingService.checkExternalAccess(req.hostname);
res.redirect("/admin/stream-testing"); 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) => { app.get("/admin/stream-testing/status", requireRole("admin"), (_req, res) => {

View File

@ -30,43 +30,37 @@
<div class="callout" data-runtime-result hidden aria-live="polite"></div> <div class="callout" data-runtime-result hidden aria-live="polite"></div>
</section> </section>
<section class="card"> <section class="card stream-test-ingest-settings">
<div class="section-heading"> <details class="lumi-expandable-settings">
<div><span class="eyebrow">Secure production ingest</span><h2>RTMPS certificate automation</h2></div> <summary>
<span class="status-pill <%= streamTestDns.configured ? "success" : "warning" %>"><%= streamTestDns.configured ? "DNS ready" : "HTTP challenge" %></span> <span><strong>Reverse-proxy ingest</strong><span class="hint">RTMPS at Nginx Proxy Manager → plain RTMP at Lumi</span></span>
</div> </summary>
<% if (streamTestDns.configured) { %> <div class="stream-test-ingest-body">
<p class="hint">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.</p> <p>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.</p>
<% } else { %> <form method="post" action="/admin/stream-testing/reverse-proxy" class="stream-test-ingest-form">
<div class="callout warning">
<strong>Using the public WebUI challenge path</strong>
<p>If OpenResty, Nginx Proxy Manager, or another HTTPS proxy reserves <code>/.well-known/acme-challenge/</code>, configure DNS automation here. Lumi will then issue RTMPS certificates without proxy changes or certificate file paths.</p>
</div>
<% } %>
<form method="post" action="/admin/stream-testing/tls/dns" class="stream-test-dns-form">
<label class="field"> <label class="field">
<span>DNS provider</span> <span>Public RTMPS port</span>
<select name="provider" aria-label="DNS provider"><option value="domeneshop" selected>Domeneshop / hyp.net</option></select> <input type="number" name="public_port" min="1" max="65535" step="1" required value="<%= streamTestIngest.publicPort %>" />
<small>Advertised to Companion and OBS. Default: 1936.</small>
</label> </label>
<label class="field"> <label class="field">
<span>API token</span> <span>Local RTMP listener port</span>
<input type="password" name="token" autocomplete="off" maxlength="512" placeholder="<%= streamTestDns.configured ? "Leave blank to keep the saved token" : "Domeneshop API token" %>" /> <input type="number" name="listener_port" min="1" max="65535" step="1" required value="<%= streamTestIngest.listenerPort %>" />
</label> <small>NPM forwards to this Lumi host and port. Default: 19350.</small>
<label class="field">
<span>API secret</span>
<input type="password" name="secret" autocomplete="new-password" maxlength="512" placeholder="<%= streamTestDns.configured ? "Leave blank to keep the saved secret" : "Domeneshop API secret" %>" />
</label> </label>
<div class="inline-actions"> <div class="inline-actions">
<button class="button" type="submit"><%= streamTestDns.configured ? "Verify saved credentials" : "Save and verify" %></button> <button class="button" type="submit" data-ingest-save <%= streamTest.active ? "disabled" : "" %>>Save ports</button>
<a class="button subtle" href="https://www.domeneshop.no/admin?view=api" target="_blank" rel="noopener noreferrer">Create API credentials</a> <button class="button subtle" type="button" data-ingest-check <%= streamTest.active ? "disabled" : "" %>>Check external access</button>
</div> </div>
</form> </form>
<% if (streamTestDns.configured) { %> <% if (streamTest.active) { %>
<form method="post" action="/admin/stream-testing/tls/dns/remove" data-confirm-mode="modal" data-confirm-title="Remove DNS automation?" data-confirm-text="Lumi will no longer be able to renew its RTMPS certificate through DNS until credentials are configured again." data-confirm-label="Remove credentials"> <p class="hint">End the active Stream Test before changing ports or running the external check. Saved changes apply to the next session.</p>
<button class="button danger" type="submit">Remove DNS credentials</button> <% } else { %>
</form> <p class="hint">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.</p>
<% } %> <% } %>
<p class="hint">Credentials are encrypted with this Lumi installation's secret and are never returned to the browser or written to logs.</p> <div class="callout" data-ingest-result hidden aria-live="polite"></div>
</div>
</details>
</section> </section>
<section class="stream-test-layout"> <section class="stream-test-layout">

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.3.6", "version": "0.3.7",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-26", "released_at": "2026-07-26",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
@ -8,7 +8,7 @@
"replaces_versions": [ "replaces_versions": [
"1.2.0" "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, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -421,6 +421,18 @@
], ],
"rollback_safe": true, "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." "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."
} }
] ]
} }