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
# Private OBS stream testing (optional; see docs/stream-testing.md).
# Lumi uses the paired Companion hostname by default and manages MediaMTX itself.
# LUMI_STREAM_TEST_INGEST_HOST=lumi.example.com
# LUMI_STREAM_TEST_INGEST_PORT=19350
# LUMI_STREAM_TEST_PUBLIC_PORT=19350
# Production always uses RTMPS and Lumi manages its certificate automatically.
# Advanced certificate override only; set both or neither.
# LUMI_STREAM_TEST_TLS_CERT=/absolute/path/to/fullchain.pem
# LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.pem
# Advanced ACME directory override only.
# LUMI_STREAM_TEST_ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org/directory
# Public and local ingest ports are stored in Lumi settings and configured at
# Admin > Stream testing. Nginx Proxy Manager owns production TLS termination.
# Advanced/manual runtime override only; normal installations do not need this.
# LUMI_MEDIAMTX_PATH=/absolute/path/to/mediamtx

View File

@ -1,5 +1,11 @@
# Lumi changelog
## 0.3.7
- Moved production Stream Testing TLS termination to Nginx Proxy Manager: Companion still receives RTMPS on the paired Lumi hostname while MediaMTX accepts only the decrypted local RTMP stream.
- Added DB-backed public and local ingest ports plus a full TLS and RTMP external-route check, while preserving direct loopback RTMP development and existing session authorization.
- Removed Lumi-owned ACME, DNS-provider, certificate-path, and challenge-route code without deleting any existing operator data.
## 0.3.6
- Added encrypted Domeneshop DNS-01 automation for production RTMPS certificates when OpenResty, Nginx Proxy Manager, or another HTTPS proxy owns the reserved HTTP challenge path.

View File

@ -64,53 +64,44 @@ the downloaded runtime and documented in
Lumi derives the network policy from the authenticated Companion pairing
origin. A Companion paired through `localhost`, `127.0.0.1`, or `::1` always
receives loopback RTMP; production ingest overrides are deliberately ignored
for that local development session. A non-local Companion must have paired
through HTTPS and always receives RTMPS. `LUMI_STREAM_TEST_INGEST_HOST` may
override the advertised hostname only for those non-local sessions.
receives direct loopback RTMP on the configured local listener port. A
non-local Companion must have paired through HTTPS and always receives RTMPS
at the same paired Lumi hostname.
For the normal production path, Lumi automatically provisions and renews a
publicly trusted certificate for the paired hostname. It first supports ACME
HTTP-01 through the public, narrowly scoped
`/.well-known/acme-challenge/` route. When a reverse proxy owns that reserved
path, configure DNS automation in **Admin > Stream testing** instead. Lumi can
use encrypted Domeneshop credentials to create the short-lived DNS-01 TXT
record, wait for authoritative propagation, issue or renew the certificate,
and remove the record. No reverse-proxy changes, certificate paths, or external
ACME packages are required. All certificate keys stay under Lumi's ignored
data directory. The first production test may take up to two minutes while the
certificate is issued; later tests reuse it.
In production, Nginx Proxy Manager owns the public certificate and terminates
TLS:
```text
OBS → rtmps://<paired Lumi hostname>:1936
→ Nginx Proxy Manager decrypts TLS
→ plain RTMP to Lumi:19350
```
MediaMTX binds plain RTMP on `0.0.0.0:19350` for a production session. It never
requests, loads, renews, or stores a TLS certificate. Configure an NPM TCP
stream that listens on public port `1936` with TLS enabled and forwards to the
Lumi host on port `19350`. The WebUI reverse-proxy settings can change both
ports; values are stored in Lumi's settings database and apply to the next
session.
The collapsed **Reverse-proxy ingest** section in **Admin > Stream testing**
also provides **Check external access**. The check connects to the current
public Lumi hostname and port with TLS and SNI, validates the public
certificate, then completes an RTMP handshake through NPM to MediaMTX. A
server-side failure can still mean that the router does not support NAT
loopback/hairpinning, so confirm the path from an external network when the NPM
and firewall configuration otherwise look correct.
Every session receives an exact `lumi-test/<uuid>` path and high-entropy
publisher credentials. Only that path can be published, and the generated
MediaMTX configuration contains SHA-256 credential hashes rather than
plaintext credentials.
RTMPS is required for every non-local pairing. A stale
`LUMI_STREAM_TEST_TRANSPORT=rtmp` value is ignored for production rather than
weakening transport or preventing the test from starting. Normal installations
need no certificate environment variables:
```text
LUMI_STREAM_TEST_INGEST_PORT=19350
LUMI_STREAM_TEST_PUBLIC_PORT=19350
```
The public port is the value given to OBS and can differ when a firewall or
port-forward maps it to the MediaMTX listener. Use the existing paired Lumi
hostname; no extra Stream Testing domain is required.
Operators that already manage a matching certificate can override Lumi's
managed certificate by setting both paths:
```text
LUMI_STREAM_TEST_TLS_CERT=/absolute/path/to/fullchain.pem
LUMI_STREAM_TEST_TLS_KEY=/absolute/path/to/private-key.pem
```
Unencrypted RTMP is selected automatically only for an authenticated loopback
pairing. It cannot be enabled for a non-local pairing through an environment
override. Restrict the ingest port at the host firewall in either mode.
RTMPS is required for every non-local pairing. Unencrypted RTMP is advertised
only to an authenticated loopback pairing. Use the existing paired Lumi
hostname; no extra Stream Testing domain is required. Restrict direct access
to the local MediaMTX listener with the host firewall so production publishers
must enter through NPM.
Optional session limits remain:

View File

@ -14,9 +14,8 @@ editable: false
Lumi is the core web UI and bot runtime.
## Runtime
Package: lumi-bot
Version: 0.3.6
Version: 0.3.7
## Routes
- GET /.well-known/acme-challenge/:token
- POST /api/diagnostics/v1/run
- GET /api/events
- POST /api/destructive-confirmations
@ -95,8 +94,8 @@ Version: 0.3.6
- POST /admin/theming
- GET /admin/diagnostics
- GET /admin/stream-testing
- POST /admin/stream-testing/tls/dns
- POST /admin/stream-testing/tls/dns/remove
- POST /admin/stream-testing/reverse-proxy
- POST /admin/stream-testing/reverse-proxy/check
- GET /admin/stream-testing/status
- POST /admin/stream-testing/stop
- POST /admin/stream-testing/runtime/install
@ -228,15 +227,6 @@ Version: 0.3.6
- POST /api/admin/overlays/:id/obs/scene
- POST /api/admin/overlays/:id/obs/import
## Route Reference
### GET /.well-known/acme-challenge/:token
- Purpose: Handles well known acme challenge token.
- Inputs: path params: `token`
- Response format: HTML or data response; exact format was not detected statically.
- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware.
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### POST /api/diagnostics/v1/run
- Purpose: Provides api diagnostics v1 run data as JSON.
@ -939,23 +929,23 @@ Version: 0.3.6
- Side effects: Usually read-only.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations.
### POST /admin/stream-testing/tls/dns
### POST /admin/stream-testing/reverse-proxy
- Purpose: Processes the admin stream testing tls dns action and stores or applies submitted form data.
- Purpose: Processes the admin stream testing reverse proxy action and stores or applies submitted form data.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: HTTP redirect after handling the request
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/stream-testing/tls/dns/remove
### POST /admin/stream-testing/reverse-proxy/check
- Purpose: Processes the admin stream testing tls dns remove action and stores or applies submitted form data.
- Purpose: Provides admin stream testing reverse proxy check data as JSON.
- Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request
- Response format: JSON response
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
- Side effects: Action route; side effects were not detected statically.
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### GET /admin/stream-testing/status

378
package-lock.json generated
View File

@ -1,14 +1,13 @@
{
"name": "lumi-bot",
"version": "0.3.6",
"version": "0.3.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lumi-bot",
"version": "0.3.6",
"version": "0.3.7",
"dependencies": {
"acme-client": "^5.4.0",
"adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0",
"better-sqlite3-session-store": "^0.1.0",
@ -88,163 +87,6 @@
"node": ">= 10"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
@ -325,45 +167,6 @@
"node": ">= 0.6"
}
},
"node_modules/acme-client": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz",
"integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==",
"license": "MIT",
"dependencies": {
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.7.2",
"debug": "^4.3.5",
"node-forge": "^1.3.1"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/acme-client/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/acme-client/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/adm-zip": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
@ -373,41 +176,6 @@
"node": ">=14.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agent-base/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/agent-base/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@ -420,20 +188,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -456,18 +210,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1066,26 +808,6 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@ -1270,42 +992,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -1572,15 +1258,6 @@
}
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1760,15 +1437,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@ -1779,24 +1447,6 @@
"once": "^1.3.1"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@ -1875,12 +1525,6 @@
"node": ">= 6"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -2190,24 +1834,6 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

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

View File

@ -2,6 +2,38 @@
"schema_version": 1,
"channel": "stable",
"releases": [
{
"version": "0.3.7",
"ref": "refs/tags/v0.3.7",
"released_at": "2026-07-26",
"installable": true,
"rollback_safe": true,
"replaces_versions": [
"1.2.0"
],
"data_policy": "preserve",
"dependency_policy": "sync_on_restart",
"migration_notes": "Moves production Stream Testing TLS termination to Nginx Proxy Manager while preserving RTMPS Companion destinations, plain local MediaMTX ingest, localhost development, session credentials, and all existing operator data. Adds DB-backed public and local ports plus external TLS and RTMP route validation, and removes unused Lumi ACME and DNS automation without deleting stored data.",
"plugins": {
"auto-vc": "0.1.6",
"birthday": "0.1.3",
"economy-framework": "0.2.10",
"economy-games": "0.1.7",
"expression-interaction": "0.2.1",
"lumi_ai": "0.8.5",
"lumi_transcription": "0.2.5",
"moderation": "0.1.5",
"now_playing": "0.1.3",
"okf": "0.1.2",
"quotes": "0.1.2",
"sample-plugin": "0.1.0",
"throne_wishlist": "0.1.2",
"welcome_messages": "0.1.1"
},
"tools": {
"lumi_ai_web_search": "0.1.1"
}
},
{
"version": "0.3.6",
"ref": "refs/tags/v0.3.6",

View File

@ -4,9 +4,9 @@ const path = require("path");
const { findSafeTarget } = require("../src/services/versioning");
const root = path.join(__dirname, "..");
const releaseVersion = "0.3.6";
const previousStableVersion = "0.3.5";
const priorStableVersion = "0.3.4";
const releaseVersion = "0.3.7";
const previousStableVersion = "0.3.6";
const priorStableVersion = "0.3.5";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
lumi_transcription: { version: "0.2.5", knowledge: "lumi-transcription" },
@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0");
assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2");
assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true);
console.log("Release metadata verification passed: stable core 0.3.6 after 0.3.5 with synchronized Companion plugin metadata.");
console.log("Release metadata verification passed: stable core 0.3.7 after 0.3.6 with synchronized Companion plugin metadata.");

View File

@ -25,19 +25,17 @@ const {
StreamTestingService,
INACTIVITY_MS,
isPrivateAddress,
probeExternalRtmps,
resolveIngestConfiguration,
rewriteManifest,
sourceFor,
validateHostname
} = require("../src/services/stream-testing");
const {
StreamTestCertificateManager,
normalizeCertificateHostname
} = require("../src/services/stream-test-certificates");
const {
DomeneshopDnsProvider,
challengeHost
} = require("../src/services/stream-test-dns");
DEFAULT_LOCAL_RTMP_PORT,
DEFAULT_PUBLIC_RTMPS_PORT,
validateReverseProxyIngestSettings
} = require("../src/services/stream-test-ingest-settings");
const protocol = require("../plugins/lumi_transcription/backend/companion/protocol");
class FakeRuntime extends EventEmitter {
@ -63,6 +61,7 @@ class FakeRuntime extends EventEmitter {
};
}
async configureSession(session) { this.session = session; this.running = true; }
async prepareListener(listener) { this.listener = listener; this.running = true; }
async clearSession() { this.session = null; this.cleared += 1; }
async diagnostics(sessionPath) {
return {
@ -77,6 +76,41 @@ class FakeRuntime extends EventEmitter {
async close() { this.running = false; }
}
function createSuccessfulTlsSocket(options) {
assert.equal(options.host, "lumi.example.test");
assert.equal(options.port, 1936);
assert.equal(options.servername, "lumi.example.test");
assert.equal(options.rejectUnauthorized, true);
const socket = new EventEmitter();
socket.authorized = true;
socket.writes = [];
socket.write = (chunk, callback) => {
socket.writes.push(Buffer.from(chunk));
callback?.();
if (socket.writes.length === 1) {
assert.equal(chunk.length, 1537);
assert.equal(chunk[0], 3);
const handshake = Buffer.alloc(3073);
handshake[0] = 3;
crypto.randomFillSync(handshake, 1);
queueMicrotask(() => socket.emit("data", handshake));
} else {
assert.equal(chunk.length, 1536);
}
return true;
};
socket.getPeerCertificate = () => ({
subject: { CN: "lumi.example.test" },
issuer: { CN: "Test CA" },
valid_to: "Jan 1 00:00:00 2030 GMT"
});
socket.getProtocol = () => "TLSv1.3";
socket.getCipher = () => ({ standardName: "TLS_AES_256_GCM_SHA384" });
socket.destroy = () => {};
queueMicrotask(() => socket.emit("secureConnect"));
return socket;
}
async function verifyArtifactManager(tempRoot) {
assert.throws(() => validateManifestEntry({ id: "bad", url: "http://example.invalid/a.zip", sha256: "0".repeat(64) }), /invalid/);
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/);
@ -164,8 +198,8 @@ async function verifyManagedRuntime(tempRoot) {
path: "lumi-test/00000000-0000-4000-8000-000000000001",
username: "private-user",
password: "private-password",
transport: "rtmp",
ingestPort: 29350
ingestPort: 29350,
bindHost: "0.0.0.0"
};
await runtime.configureSession(session);
const generated = fs.readFileSync(runtime.configPath, "utf8");
@ -176,7 +210,9 @@ async function verifyManagedRuntime(tempRoot) {
assert.match(generated, /srt: false/);
assert.match(generated, /hlsVariant: lowLatency/);
assert.match(generated, /maxReaders: 4/);
assert.match(generated, /rtmpAddress: "127\.0\.0\.1:29350"/);
assert.match(generated, /rtmpEncryption: "no"/);
assert.match(generated, /rtmpAddress: "0\.0\.0\.0:29350"/);
assert(!generated.includes("rtmpsAddress") && !generated.includes("rtmpServerCert") && !generated.includes("rtmpServerKey"));
const diagnostic = await runtime.diagnostics(session.path);
assert.equal(diagnostic.path.name, session.path);
const playlist = await runtime.fetchHls(session.path, "index.m3u8");
@ -191,18 +227,13 @@ async function verifyManagedRuntime(tempRoot) {
}
async function verifyStreamService() {
const old = {
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
key: process.env.LUMI_STREAM_TEST_TLS_KEY
};
process.env.LUMI_STREAM_TEST_INGEST_HOST = "public.example.test";
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps";
process.env.LUMI_STREAM_TEST_TLS_CERT = path.join(os.tmpdir(), "missing-local-cert.pem");
process.env.LUMI_STREAM_TEST_TLS_KEY = path.join(os.tmpdir(), "missing-local-key.pem");
const runtime = new FakeRuntime();
const service = new StreamTestingService({ runtime, timer: false });
const service = new StreamTestingService({
runtime,
timer: false,
ingestSettings: () => ({ publicPort: 1936, listenerPort: 19350 }),
tlsConnect: createSuccessfulTlsSocket
});
const sent = [];
try {
const created = await service.create(
@ -211,7 +242,7 @@ async function verifyStreamService() {
(type, payload) => sent.push({ type, payload })
);
assert.match(created.ingest.server, /^rtmp:\/\/localhost:19350\/lumi-test$/);
assert.equal(runtime.session.transport, "rtmp", "localhost pairing must ignore production RTMPS overrides");
assert.equal(runtime.session.ingestPort, 19350);
assert.match(created.ingest.key, /^[0-9a-f-]{36}\?user=/);
assert.equal(created.source.variants.length, 1, "MediaMTX handoff must preserve source quality without a synthetic ladder");
assert.equal(runtime.session.path, `lumi-test/${created.id}`);
@ -322,6 +353,9 @@ async function verifyStreamService() {
assert.equal(endedSession.username, null);
assert.equal(endedSession.password, null);
assert.equal(sent.at(-1).type, "stream_test_ended");
const external = await service.checkExternalAccess("lumi.example.test");
assert.equal(external.rtmp.handshake_bytes, 3073);
assert.deepStrictEqual(runtime.listener, { bindHost: "0.0.0.0", ingestPort: 19350 });
await assert.rejects(
() => service.create({ id: "device-1", pairing_host: "http://localhost:3000" }, {}),
(error) => error.code === "STREAM_TEST_RATE_LIMIT"
@ -354,10 +388,6 @@ async function verifyStreamService() {
assert([created.id, expiring.id, inactive.id, failed.id].every(Boolean));
} finally {
await service.close();
if (old.host === undefined) delete process.env.LUMI_STREAM_TEST_INGEST_HOST; else process.env.LUMI_STREAM_TEST_INGEST_HOST = old.host;
if (old.transport === undefined) delete process.env.LUMI_STREAM_TEST_TRANSPORT; else process.env.LUMI_STREAM_TEST_TRANSPORT = old.transport;
if (old.cert === undefined) delete process.env.LUMI_STREAM_TEST_TLS_CERT; else process.env.LUMI_STREAM_TEST_TLS_CERT = old.cert;
if (old.key === undefined) delete process.env.LUMI_STREAM_TEST_TLS_KEY; else process.env.LUMI_STREAM_TEST_TLS_KEY = old.key;
}
}
@ -367,9 +397,19 @@ async function main() {
assert.equal(validateHostname("stream.example.com"), "stream.example.com");
assert.equal(validateHostname("https://stream.example.com"), "");
assert.equal(validateHostname("[::1]"), "[::1]");
assert.equal(normalizeCertificateHostname("Stream.Example.com."), "stream.example.com");
assert.equal(normalizeCertificateHostname("127.0.0.1"), "");
assert(isPrivateAddress("127.0.0.1") && isPrivateAddress("192.168.1.20") && !isPrivateAddress("8.8.8.8"));
assert.deepStrictEqual(
validateReverseProxyIngestSettings({ publicPort: "1936", listenerPort: "19350" }),
{ publicPort: DEFAULT_PUBLIC_RTMPS_PORT, listenerPort: DEFAULT_LOCAL_RTMP_PORT }
);
assert.throws(
() => validateReverseProxyIngestSettings({ publicPort: "0", listenerPort: "19350" }),
/1 to 65535/
);
assert.throws(
() => validateReverseProxyIngestSettings({ publicPort: "1936.5", listenerPort: "19350" }),
/whole number/
);
assert.deepStrictEqual(sourceFor({ width: 1920, height: 1080, fps: 60 }).variants.map((item) => item.name), ["source"]);
assert.match(rewriteManifest(
"#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\"\nsegment0.mp4\n",
@ -399,49 +439,34 @@ async function main() {
assert.match(config, /paths:\n "lumi-test\/id":/);
assert(!/action: publish[\s\S]*path: all/.test(config));
const oldNetwork = {
host: process.env.LUMI_STREAM_TEST_INGEST_HOST,
transport: process.env.LUMI_STREAM_TEST_TRANSPORT,
cert: process.env.LUMI_STREAM_TEST_TLS_CERT,
key: process.env.LUMI_STREAM_TEST_TLS_KEY
};
try {
delete process.env.LUMI_STREAM_TEST_INGEST_HOST;
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmp";
const forcedSecure = await resolveIngestConfiguration(
{ pairing_host: "https://stream.example.test" },
{ certificateManager: { resolve: async () => ({ certificate: "managed-cert.pem", privateKey: "managed-key.pem" }) } }
);
assert.equal(forcedSecure.transport, "rtmps", "a stale RTMP override must not weaken production transport");
assert.equal(forcedSecure.tlsCert, "managed-cert.pem");
const local = await resolveIngestConfiguration({ pairing_host: "http://localhost:3000" });
assert.equal(local.transport, "rtmp");
assert.equal(local.host, "localhost");
const cert = path.join(tempRoot, "cert.pem");
const key = path.join(tempRoot, "key.pem");
fs.writeFileSync(cert, "test certificate");
fs.writeFileSync(key, "test key");
process.env.LUMI_STREAM_TEST_TRANSPORT = "rtmps";
process.env.LUMI_STREAM_TEST_TLS_CERT = cert;
process.env.LUMI_STREAM_TEST_TLS_KEY = key;
const secure = await resolveIngestConfiguration({ pairing_host: "https://lumi.example.test" });
assert.equal(secure.transport, "rtmps");
assert.equal(secure.host, "lumi.example.test");
delete process.env.LUMI_STREAM_TEST_TLS_CERT;
delete process.env.LUMI_STREAM_TEST_TLS_KEY;
await verifyManagedCertificateProvisioning(tempRoot);
await verifyDomeneshopDnsAutomation();
} finally {
for (const [key, value] of Object.entries({
LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host,
LUMI_STREAM_TEST_TRANSPORT: oldNetwork.transport,
LUMI_STREAM_TEST_TLS_CERT: oldNetwork.cert,
LUMI_STREAM_TEST_TLS_KEY: oldNetwork.key
})) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
const configuredPorts = { publicPort: 21936, listenerPort: 29350 };
const secure = await resolveIngestConfiguration(
{ pairing_host: "https://stream.example.test" },
{ settings: configuredPorts }
);
assert.deepStrictEqual(secure, {
host: "stream.example.test",
transport: "rtmps",
bindHost: "0.0.0.0",
ingestPort: 29350,
publicPort: 21936
});
const local = await resolveIngestConfiguration(
{ pairing_host: "http://localhost:3000" },
{ settings: configuredPorts }
);
assert.equal(local.transport, "rtmp");
assert.equal(local.host, "localhost");
assert.equal(local.ingestPort, 29350);
assert.equal(local.publicPort, 29350, "loopback development must bypass the public RTMPS port");
const probed = await probeExternalRtmps({
hostname: "lumi.example.test",
publicPort: 1936,
listenerPort: 19350,
tlsConnect: createSuccessfulTlsSocket
});
assert.equal(probed.tls.protocol, "TLSv1.3");
assert.equal(probed.rtmp.version, 3);
for (const type of ["stream_test_create", "stream_test_obs_metrics", "stream_test_caption_status", "stream_test_caption", "stream_test_stop"]) {
const parsed = protocol.parseEnvelope(Buffer.from(JSON.stringify(protocol.envelope(type, {}, null))));
@ -462,11 +487,12 @@ async function main() {
const companionWindow = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml"), "utf8");
const companionWindowCode = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/MainWindow.axaml.cs"), "utf8");
const companionStyles = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/App.axaml"), "utf8");
const certificates = fs.readFileSync(path.join(root, "src/services/stream-test-certificates.js"), "utf8");
const ingestSettings = fs.readFileSync(path.join(root, "src/services/stream-test-ingest-settings.js"), "utf8");
const transcriptionContribution = fs.readFileSync(path.join(root, "companion/src/Lumi.Companion.App/TranscriptionPluginContribution.cs"), "utf8");
const webUi = fs.readFileSync(path.join(root, "src/web/views/admin-stream-testing.ejs"), "utf8");
const webPlayer = fs.readFileSync(path.join(root, "src/web/public/stream-testing.js"), "utf8");
const webPlayerCss = fs.readFileSync(path.join(root, "src/web/public/stream-testing.css"), "utf8");
const packageJson = fs.readFileSync(path.join(root, "package.json"), "utf8");
assert.match(server, /admin\/stream-testing\/runtime\/install/);
assert.match(server, /admin\/stream-testing\/media\/:id\/\*/);
assert.match(server, /admin\/stream-testing\/media\/:id\/\*[\s\S]{0,500}requireRole\("admin"\)/);
@ -475,13 +501,22 @@ async function main() {
assert.match(service, /caption_delay/);
assert.match(service, /updateCaptionStatus/);
assert.match(service, /localDevelopment[\s\S]*transport: "rtmp"/);
assert.match(service, /const transport = "rtmps"/);
assert.match(service, /streamTestCertificateManager/);
assert.match(certificates, /challengePriority: \[dnsProvider \? "dns-01" : "http-01"\]/);
assert.match(server, /\.well-known\/acme-challenge\/:token/);
assert.match(server, /admin\/stream-testing\/tls\/dns/);
assert.match(webUi, /RTMPS certificate automation/);
assert.match(webUi, /Domeneshop \/ hyp\.net/);
assert.match(service, /transport: "rtmps"/);
assert.match(service, /probeExternalRtmps/);
assert.match(service, /servername: bareHost/);
assert.match(runtime, /rtmpEncryption: \\"no\\"/);
assert.match(runtime, /bindHost \|\| "0\.0\.0\.0"/);
assert.match(ingestSettings, /DEFAULT_PUBLIC_RTMPS_PORT = 1936/);
assert.match(ingestSettings, /DEFAULT_LOCAL_RTMP_PORT = 19350/);
assert.match(server, /admin\/stream-testing\/reverse-proxy\/check/);
assert.doesNotMatch(server, /acme-challenge|stream-test-dns|stream-test-certificates/);
assert(!fs.existsSync(path.join(root, "src/services/stream-test-certificates.js")));
assert(!fs.existsSync(path.join(root, "src/services/stream-test-dns.js")));
assert.doesNotMatch(packageJson, /acme-client/);
assert.match(webUi, /Reverse-proxy ingest/);
assert.match(webUi, /Check external access/);
assert.match(webUi, /<details class="lumi-expandable-settings">/);
assert.doesNotMatch(webUi, /certificate automation|Domeneshop/);
assert(!/FFMPEG|ffmpegArgs|h264_nvenc|libx264/.test(service));
assert(!/shell:\s*true/.test(runtime));
assert.match(gateway, /await service\.create/);
@ -552,158 +587,6 @@ async function main() {
console.log("Managed MediaMTX Stream Testing verification passed.");
}
async function verifyManagedCertificateProvisioning(tempRoot) {
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const privateKey = keyPair.privateKey.export({ type: "pkcs8", format: "pem" });
let manager;
let issued = false;
let issueCalls = 0;
const fakeAcme = {
directory: { letsencrypt: { production: "https://acme.invalid/directory" } },
crypto: {
createPrivateRsaKey: async () => Buffer.from(privateKey),
createCsr: async () => [Buffer.from(privateKey), Buffer.from("test csr")]
},
Client: class {
async auto(options) {
issueCalls += 1;
const type = options.challengePriority[0];
await options.challengeCreateFn({}, { type, token: "abcdefghijklmnopqrstuvwxyz012345" }, "key-authorization");
if (type === "http-01") assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), "key-authorization");
await options.challengeRemoveFn({}, { type, token: "abcdefghijklmnopqrstuvwxyz012345" });
assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), null);
issued = true;
return "test certificate";
}
}
};
manager = new StreamTestCertificateManager({
root: path.join(tempRoot, "managed-certificates"),
acme: fakeAcme,
directoryUrl: fakeAcme.directory.letsencrypt.production,
log: { info() {}, error() {} }
});
manager.inspect = (host) => {
const locations = manager.locations(host);
const ready = issued && fs.existsSync(locations.certificate) && fs.existsSync(locations.privateKey);
return {
ready,
source: "managed",
certificate: locations.certificate,
privateKey: locations.privateKey,
validUntil: ready ? Date.now() + 60 * 24 * 60 * 60 * 1000 : null
};
};
const first = manager.resolve("stream.example.test");
const second = manager.resolve("stream.example.test");
const [result] = await Promise.all([first, second]);
assert.equal(issueCalls, 1, "concurrent certificate requests must share one operation");
assert(result.ready && issued, "managed certificate provisioning did not complete");
assert.equal(fs.statSync(result.privateKey).mode & 0o777, process.platform === "win32" ? fs.statSync(result.privateKey).mode & 0o777 : 0o600);
let dnsCreated = 0;
let dnsRemoved = 0;
issued = false;
manager = new StreamTestCertificateManager({
root: path.join(tempRoot, "managed-dns-certificates"),
acme: fakeAcme,
directoryUrl: fakeAcme.directory.letsencrypt.production,
dnsProviderFactory: async () => ({
async createChallenge({ hostname, value }) {
dnsCreated += 1;
assert.equal(hostname, "stream.example.test");
assert.equal(value, "key-authorization");
return { recordId: 42 };
},
async removeChallenge(handle) {
dnsRemoved += 1;
assert.equal(handle.recordId, 42);
}
}),
log: { info() {}, error() {} }
});
manager.inspect = (host) => {
const locations = manager.locations(host);
const ready = issued && fs.existsSync(locations.certificate) && fs.existsSync(locations.privateKey);
return {
ready,
source: "managed",
certificate: locations.certificate,
privateKey: locations.privateKey,
validUntil: ready ? Date.now() + 60 * 24 * 60 * 60 * 1000 : null
};
};
const dnsResult = await manager.resolve("stream.example.test");
assert(dnsResult.ready, "DNS-managed certificate provisioning did not complete");
assert.equal(dnsCreated, 1);
assert.equal(dnsRemoved, 1);
}
async function verifyDomeneshopDnsAutomation() {
const requests = [];
let records = [];
const response = (status, body = null, headers = {}) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers(headers),
json: async () => body,
text: async () => body ? JSON.stringify(body) : ""
});
const provider = new DomeneshopDnsProvider({
token: "test-token",
secret: "test-secret",
propagationTimeoutMs: 100,
wait: async () => {},
resolveTxt: async (fqdn) => {
assert.equal(fqdn, "_acme-challenge.lumi.ookamikun.tv");
return records.map((record) => record.data);
},
fetch: async (url, options) => {
const parsed = new URL(url);
requests.push({ url: parsed.pathname + parsed.search, options });
assert.match(options.headers.authorization, /^Basic /);
if (parsed.pathname.endsWith("/domains") && options.method === "GET") {
return response(200, [{
id: 7,
domain: "ookamikun.tv",
services: { dns: true }
}]);
}
if (parsed.pathname.endsWith("/domains/7/dns") && options.method === "GET") {
return response(200, records);
}
if (parsed.pathname.endsWith("/domains/7/dns") && options.method === "POST") {
const payload = JSON.parse(options.body);
assert.deepEqual(payload, {
host: "_acme-challenge.lumi",
ttl: 60,
type: "TXT",
data: "dns-key-authorization"
});
records.push({ id: 91, ...payload });
return response(201, null, { location: "/v0/domains/7/dns/91" });
}
if (parsed.pathname.endsWith("/domains/7/dns/91") && options.method === "DELETE") {
records = records.filter((record) => record.id !== 91);
return response(204);
}
return response(404);
}
});
await provider.verify();
const handle = await provider.createChallenge({
hostname: "lumi.ookamikun.tv",
value: "dns-key-authorization"
});
assert.equal(handle.recordId, 91);
await provider.removeChallenge(handle);
assert.equal(records.length, 0);
assert.equal(challengeHost("lumi.ookamikun.tv", "ookamikun.tv"), "_acme-challenge.lumi");
assert.equal(challengeHost("ookamikun.tv", "ookamikun.tv"), "_acme-challenge");
assert(requests.some((request) => request.options.method === "POST"));
assert(requests.some((request) => request.options.method === "DELETE"));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.deepEqual(releaseVersions, ["0.3.7", "0.3.6", "0.3.5", "0.3.4", "0.3.3", "0.3.2", "0.3.1", "0.3.0", "0.2.27", "0.2.26", "0.2.25", "0.2.24", "0.2.23", "0.2.22", "0.2.21", "0.2.20", "0.2.19", "0.2.18", "0.2.17", "0.2.16", "0.2.15", "0.2.14", "0.2.13", "0.2.12", "0.2.11", "0.2.10", "0.2.9", "0.2.8", "0.2.7", "0.2.6", "0.2.5", "0.2.4", "0.2.3", "0.2.2", "0.2.1", "0.2.0", "0.1.9"]);
assert.equal(new Set(releaseVersions).size, releaseVersions.length, "release versions must be unique");
for (const release of releaseIndex.releases) {
assert.equal(normalizeRepositoryRef(release.ref), release.ref);
@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version;
const coreManifest = readJson("update-manifest.json");
assert.equal(packageVersion, coreManifest.version);
assert.equal(coreManifest.channel, "stable");
assert.equal(packageVersion, "0.3.6");
assert.equal(currentRelease.version, "0.3.6");
assert.equal(packageVersion, "0.3.7");
assert.equal(currentRelease.version, "0.3.7");
assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]);
for (const [pluginId, version] of Object.entries(currentRelease.plugins)) {
assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`);
@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) {
const baseTarget = {
current_version: "0.2.4",
available_versions: [
{ version: "0.3.7", ref: "refs/tags/v0.3.7", rollback_safe: true },
{ version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true },
{ version: "0.3.5", ref: "refs/tags/v0.3.5", rollback_safe: true },
{ version: "0.3.4", ref: "refs/tags/v0.3.4", rollback_safe: true },
@ -155,7 +156,7 @@ const corrected = buildStatus({
channel: "stable"
});
assert.equal(corrected.version_correction, true);
assert.equal(corrected.safe_target_version, "0.3.6");
assert.equal(corrected.safe_target_version, "0.3.7");
assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false);

View File

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

View File

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

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 net = require("net");
const path = require("path");
const tls = require("tls");
const { Readable } = require("stream");
const { pipeline } = require("stream/promises");
const { MediaMtxRuntime } = require("./mediamtx-runtime");
const { streamTestCertificateManager } = require("./stream-test-certificates");
const {
getReverseProxyIngestSettings,
validatePort
} = require("./stream-test-ingest-settings");
const { publishWebEvent } = require("./web-events");
const DATA_ROOT = path.join(
@ -15,8 +19,6 @@ const DATA_ROOT = path.join(
);
const MAX_SESSION_MS = Math.min(60 * 60 * 1000, Math.max(5 * 60 * 1000, Number(process.env.LUMI_STREAM_TEST_MAX_MS) || 30 * 60 * 1000));
const INACTIVITY_MS = Math.min(5 * 60 * 1000, Math.max(20 * 1000, Number(process.env.LUMI_STREAM_TEST_INACTIVITY_MS) || 45 * 1000));
const INGEST_PORT = port(process.env.LUMI_STREAM_TEST_INGEST_PORT, 19350);
const PUBLIC_INGEST_PORT = port(process.env.LUMI_STREAM_TEST_PUBLIC_PORT, INGEST_PORT);
const MAX_MANIFEST_BYTES = 1024 * 1024;
function finite(value, fallback, min, max) {
@ -60,6 +62,8 @@ function visibleCaptionCues(session) {
class StreamTestingService {
constructor(options = {}) {
this.runtime = options.runtime || new MediaMtxRuntime();
this.ingestSettings = options.ingestSettings || getReverseProxyIngestSettings;
this.tlsConnect = options.tlsConnect || tls.connect;
this.active = null;
this.timeline = [];
this.lastCreateAt = new Map();
@ -135,7 +139,9 @@ class StreamTestingService {
"The Lumi private media router needs setup. Ask an administrator to open Admin > Stream testing and choose Install MediaMTX."
);
}
const network = await resolveIngestConfiguration(device);
const network = await resolveIngestConfiguration(device, {
settings: this.ingestSettings()
});
const id = crypto.randomUUID();
const streamPath = `lumi-test/${id}`;
const username = `lumi_${crypto.randomBytes(18).toString("base64url")}`;
@ -156,6 +162,7 @@ class StreamTestingService {
transport: network.transport,
ingestHost: network.host,
ingestPort: network.ingestPort,
bindHost: network.bindHost,
publicPort: network.publicPort,
deviceId: device.id,
deviceName: String(device.name || device.metadata?.name || "Paired Companion").slice(0, 120),
@ -191,16 +198,18 @@ class StreamTestingService {
path: streamPath,
username,
password,
transport: network.transport,
ingestPort: network.ingestPort,
tlsCert: network.tlsCert,
tlsKey: network.tlsKey,
bindHost: network.bindHost
});
this.event("receiver_ready", "Lumi's private media router is ready for OBS.", session);
} catch (error) {
if (this.active?.id === session.id) this.active = null;
try { await this.runtime.clearSession(); } catch {}
try {
await this.runtime.clearSession({
bindHost: network.bindHost,
ingestPort: network.ingestPort
});
} catch {}
this.event("receiver_error", `Private media router startup failed: ${error.message}`, session);
throw coded("STREAM_TEST_RECEIVER_UNAVAILABLE", `The private media router could not start: ${error.message}`);
}
@ -218,6 +227,28 @@ class StreamTestingService {
};
}
async checkExternalAccess(hostname) {
if (this.active) {
throw coded("STREAM_TEST_ACTIVE", "End the active Stream Test before checking external access.");
}
const host = validateHostname(String(hostname || "").trim());
const bareHost = host.replace(/^\[|\]$/g, "");
if (!host || net.isIP(bareHost) || isLoopbackHostname(host)) {
throw coded("STREAM_TEST_EXTERNAL_HOST_INVALID", "Open Lumi through its public DNS hostname before checking external RTMPS access.");
}
const settings = this.ingestSettings();
await this.runtime.prepareListener({
bindHost: "0.0.0.0",
ingestPort: settings.listenerPort
});
return probeExternalRtmps({
hostname: host,
publicPort: settings.publicPort,
listenerPort: settings.listenerPort,
tlsConnect: this.tlsConnect
});
}
updateObs(deviceId, input = {}) {
const session = this.requireDeviceSession(deviceId, input.session_id);
session.metrics.obs = {
@ -435,7 +466,11 @@ class StreamTestingService {
async healthCheck() {
if (this.active) throw new Error("The active Stream Test is already exercising MediaMTX.");
const result = await this.runtime.healthCheck();
const settings = this.ingestSettings();
const result = await this.runtime.healthCheck({
bindHost: "127.0.0.1",
ingestPort: settings.listenerPort
});
this.event("runtime_health", `MediaMTX ${result.version || ""} passed its listener and API health check.`.trim(), null);
return result;
}
@ -450,7 +485,10 @@ class StreamTestingService {
if (this.active?.id === session.id) this.active = null;
let runtimeError = null;
try {
await this.runtime.clearSession();
await this.runtime.clearSession({
bindHost: session.bindHost,
ingestPort: session.ingestPort
});
} catch (error) {
runtimeError = error;
try { await this.runtime.close(); } catch {}
@ -595,6 +633,9 @@ class StreamTestingService {
}
async function resolveIngestConfiguration(device, options = {}) {
const settings = options.settings || getReverseProxyIngestSettings();
const publicPort = validatePort(settings.publicPort, "Public RTMPS port");
const listenerPort = validatePort(settings.listenerPort, "Local RTMP listener port");
const pairedOrigin = String(device?.pairing_host || "").trim();
let pairedUrl;
try {
@ -602,8 +643,7 @@ async function resolveIngestConfiguration(device, options = {}) {
} catch {}
const pairedHost = validateHostname(pairedUrl?.hostname || "");
const localDevelopment = isLoopbackHostname(pairedHost);
const configuredHost = localDevelopment ? pairedHost : process.env.LUMI_STREAM_TEST_INGEST_HOST || pairedHost;
const host = validateHostname(String(configuredHost).trim());
const host = validateHostname(String(pairedHost).trim());
if (!host) throw coded("STREAM_TEST_INGEST_UNCONFIGURED", "The paired Lumi hostname could not be used for private Stream Testing.");
if (localDevelopment) {
@ -611,47 +651,147 @@ async function resolveIngestConfiguration(device, options = {}) {
return {
host,
transport: "rtmp",
tlsCert: "",
tlsKey: "",
bindHost: resolved[0] || "127.0.0.1",
ingestPort: INGEST_PORT,
publicPort: PUBLIC_INGEST_PORT
ingestPort: listenerPort,
publicPort: listenerPort
};
}
if (pairedUrl?.protocol !== "https:") {
throw coded("STREAM_TEST_TLS_REQUIRED", "Non-local Companion sessions require an HTTPS pairing URL and RTMPS ingest.");
}
const requested = String(process.env.LUMI_STREAM_TEST_TRANSPORT || "").trim().toLowerCase();
let tlsCert = String(process.env.LUMI_STREAM_TEST_TLS_CERT || "").trim();
let tlsKey = String(process.env.LUMI_STREAM_TEST_TLS_KEY || "").trim();
if (requested && !["rtmps", "rtmp"].includes(requested)) throw coded("STREAM_TEST_TRANSPORT_INVALID", "LUMI_STREAM_TEST_TRANSPORT must be rtmps or rtmp.");
// Transport overrides never weaken the authenticated origin policy. This also
// makes an old development `rtmp` override harmless after moving Lumi behind
// its production HTTPS hostname.
const transport = "rtmps";
if (tlsCert || tlsKey) {
for (const [label, target] of [["certificate", tlsCert], ["private key", tlsKey]]) {
if (!target || !fs.existsSync(target) || !fs.statSync(target).isFile()) {
throw coded("STREAM_TEST_TLS_UNCONFIGURED", `The advanced RTMPS override requires a readable ${label}. Configure both LUMI_STREAM_TEST_TLS_CERT and LUMI_STREAM_TEST_TLS_KEY, or remove both so Lumi can manage the certificate.`);
}
}
} else {
const managed = await (options.certificateManager || streamTestCertificateManager).resolve(host);
tlsCert = managed.certificate;
tlsKey = managed.privateKey;
}
return {
host,
transport,
tlsCert,
tlsKey,
transport: "rtmps",
bindHost: "0.0.0.0",
ingestPort: INGEST_PORT,
publicPort: PUBLIC_INGEST_PORT
ingestPort: listenerPort,
publicPort
};
}
function probeExternalRtmps(options = {}) {
const hostname = validateHostname(String(options.hostname || "").trim());
const publicPort = validatePort(options.publicPort, "Public RTMPS port");
const listenerPort = validatePort(options.listenerPort, "Local RTMP listener port");
const connect = options.tlsConnect || tls.connect;
const timeoutMs = Math.max(1000, Math.min(30_000, Number(options.timeoutMs) || 12_000));
const bareHost = hostname.replace(/^\[|\]$/g, "");
if (!hostname || net.isIP(bareHost)) {
return Promise.reject(coded("STREAM_TEST_EXTERNAL_HOST_INVALID", "A public DNS hostname is required for the RTMPS certificate check."));
}
return new Promise((resolve, reject) => {
let socket;
let stage = "tls";
let settled = false;
let received = Buffer.alloc(0);
const finish = (error, result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket?.removeAllListeners?.();
socket?.destroy?.();
if (error) reject(externalProbeError(error, stage, hostname, publicPort));
else resolve(result);
};
const timer = setTimeout(() => {
const error = new Error(`Timed out during the ${stage === "tls" ? "TLS connection" : "RTMP handshake"}.`);
error.code = "ETIMEDOUT";
finish(error);
}, timeoutMs);
timer.unref?.();
try {
socket = connect({
host: bareHost,
port: publicPort,
servername: bareHost,
rejectUnauthorized: true
});
} catch (error) {
finish(error);
return;
}
socket.once("error", (error) => finish(error));
socket.once("secureConnect", () => {
if (socket.authorized === false) {
const error = new Error(socket.authorizationError || "The certificate was not authorized.");
error.code = "CERTIFICATE_UNAUTHORIZED";
finish(error);
return;
}
stage = "rtmp";
const c0c1 = Buffer.alloc(1537);
c0c1[0] = 3;
c0c1.writeUInt32BE(Math.floor(Date.now() / 1000) >>> 0, 1);
crypto.randomFillSync(c0c1, 9);
socket.write(c0c1, (error) => {
if (error) finish(error);
});
});
socket.on("data", (chunk) => {
if (stage !== "rtmp" || settled) return;
received = Buffer.concat([received, chunk], received.length + chunk.length);
if (received.length < 3073) return;
if (received[0] !== 3) {
const error = new Error(`The downstream service returned unsupported RTMP version ${received[0]}.`);
error.code = "RTMP_VERSION_INVALID";
finish(error);
return;
}
const c2 = Buffer.from(received.subarray(1, 1537));
socket.write(c2, (error) => {
if (error) return finish(error);
const peer = socket.getPeerCertificate?.() || {};
const cipher = socket.getCipher?.() || {};
finish(null, {
ok: true,
hostname,
public_port: publicPort,
local_port: listenerPort,
detail: `TLS certificate validation and the RTMP handshake both completed through ${hostname}:${publicPort}.`,
tls: {
protocol: socket.getProtocol?.() || null,
cipher: cipher.standardName || cipher.name || null,
subject: peer.subject?.CN || null,
issuer: peer.issuer?.CN || null,
valid_to: peer.valid_to || null
},
rtmp: {
version: received[0],
handshake_bytes: 3073
}
});
});
});
socket.once("end", () => {
if (!settled) finish(new Error("The remote endpoint closed before the RTMP handshake completed."));
});
socket.once("close", () => {
if (!settled) finish(new Error("The remote endpoint closed before the RTMP handshake completed."));
});
});
}
function externalProbeError(error, stage, hostname, publicPort) {
const code = String(error?.code || "");
const certificateFailure = stage === "tls" && (
code.includes("CERT")
|| code.includes("TLS")
|| code.includes("UNABLE_TO_VERIFY")
|| code === "DEPTH_ZERO_SELF_SIGNED_CERT"
);
const detail = String(error?.message || "connection failed").replace(/\s+/g, " ").slice(0, 240);
const message = certificateFailure
? `TLS reached ${hostname}:${publicPort}, but its certificate could not be validated: ${detail}`
: stage === "rtmp"
? `TLS reached ${hostname}:${publicPort}, but the RTMP handshake did not complete through Nginx Proxy Manager to Lumi: ${detail}`
: `Lumi could not reach ${hostname}:${publicPort} over TLS: ${detail}`;
return coded(
"STREAM_TEST_EXTERNAL_CHECK_FAILED",
`${message} Check the NPM TCP stream, firewall, and port forwarding. A server-side check can also fail when the network does not support NAT loopback/hairpinning.`
);
}
function isLoopbackHostname(value) {
const host = String(value || "").replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1";
@ -759,11 +899,6 @@ function formatFps(value) {
return Number.isInteger(value) ? String(value) : Number(value).toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}
function port(value, fallback) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 65535 ? parsed : fallback;
}
function publicRuntimeStatus(status) {
return {
available: Boolean(status.available),
@ -799,12 +934,12 @@ const streamTestingService = new StreamTestingService();
module.exports = {
DATA_ROOT,
INACTIVITY_MS,
INGEST_PORT,
MAX_SESSION_MS,
PUBLIC_INGEST_PORT,
StreamTestingService,
externalProbeError,
isPrivateAddress,
isPrivateHost,
probeExternalRtmps,
publicRuntimeStatus,
resolveIngestConfiguration,
rewriteManifest,

View File

@ -51,11 +51,14 @@
.stream-test-timeline li::marker { color: var(--lumi-primary); }
.stream-test-warning { margin-top: var(--lumi-space-3); }
.stream-runtime-actions { margin-top: var(--lumi-space-3); }
.stream-test-dns-form { display: grid; grid-template-columns: minmax(11rem, .7fr) repeat(2, minmax(12rem, 1fr)); gap: var(--lumi-space-3); margin-top: var(--lumi-space-4); align-items: end; }
.stream-test-dns-form .inline-actions { grid-column: 1 / -1; }
.stream-test-ingest-settings > details { margin: 0; }
.stream-test-ingest-body { display: grid; gap: var(--lumi-space-3); padding-top: var(--lumi-space-3); }
.stream-test-ingest-body > :where(p, form, div) { margin-block: 0; }
.stream-test-ingest-form { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: var(--lumi-space-3); align-items: end; }
.stream-test-ingest-form .inline-actions { grid-column: 1 / -1; }
@media (prefers-reduced-motion: reduce) { .stream-test-caption-word.is-new { animation: none; } }
@media (max-width: 900px) {
.stream-test-layout, .stream-test-dns-form { grid-template-columns: 1fr; }
.stream-test-dns-form .inline-actions { grid-column: auto; }
.stream-test-layout, .stream-test-ingest-form { grid-template-columns: 1fr; }
.stream-test-ingest-form .inline-actions { grid-column: auto; }
}
}

View File

@ -21,6 +21,9 @@
const runtimeRepair = root.querySelector("[data-runtime-repair]");
const runtimeHealth = root.querySelector("[data-runtime-health]");
const runtimeResult = root.querySelector("[data-runtime-result]");
const ingestCheck = root.querySelector("[data-ingest-check]");
const ingestSave = root.querySelector("[data-ingest-save]");
const ingestResult = root.querySelector("[data-ingest-result]");
let hls = null;
let sessionId = null;
let latestSession = null;
@ -256,6 +259,8 @@
runtimeInstall.disabled = Boolean(session);
runtimeRepair.disabled = Boolean(session);
runtimeHealth.disabled = Boolean(session) || !runtime.available;
if (ingestCheck) ingestCheck.disabled = Boolean(session);
if (ingestSave) ingestSave.disabled = Boolean(session);
summary.innerHTML = [
metric("Media router", runtime.available ? `${runtime.version || "Ready"} · ${runtime.source || "managed"}` : "Needs setup"),
metric("Session", session ? session.state : "No active test"),
@ -346,6 +351,32 @@
}
}
async function checkExternalAccess() {
if (!ingestCheck || !ingestResult) return;
ingestCheck.disabled = true;
ingestResult.hidden = true;
try {
const response = await fetch("/admin/stream-testing/reverse-proxy/check", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: "{}"
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "The external RTMPS check failed.");
const result = payload.result || {};
const tlsDetail = [result.tls?.protocol, result.tls?.cipher].filter(Boolean).join(" · ");
ingestResult.className = "callout success";
ingestResult.textContent = `${result.detail}${tlsDetail ? ` ${tlsDetail}.` : ""}`;
ingestResult.hidden = false;
} catch (error) {
ingestResult.className = "callout danger";
ingestResult.textContent = error.message;
ingestResult.hidden = false;
} finally {
ingestCheck.disabled = false;
}
}
player.addEventListener("error", () => { playerErrors += 1; });
player.addEventListener("canplay", () => void attemptAutoplay());
player.textTracks?.addEventListener?.("change", () => {
@ -366,6 +397,7 @@
runtimeInstall.addEventListener("click", () => runtimeAction("install"));
runtimeRepair.addEventListener("click", () => runtimeAction("repair"));
runtimeHealth.addEventListener("click", () => runtimeAction("health"));
ingestCheck?.addEventListener("click", checkExternalAccess);
const metricTimer = window.setInterval(() => {
if (!sessionId) return;

View File

@ -81,11 +81,9 @@ const { twitchEventSubManager } = require("../services/twitch-eventsub");
const { eventHooksApi } = require("../services/overlay-event-hooks");
const { streamTestingService } = require("../services/stream-testing");
const {
clearDnsAutomation,
dnsAutomationStatus,
saveDomeneshopDnsCredentials
} = require("../services/stream-test-dns");
const { streamTestCertificateManager } = require("../services/stream-test-certificates");
getReverseProxyIngestSettings,
saveReverseProxyIngestSettings
} = require("../services/stream-test-ingest-settings");
const { getClient: getYouTubeClient } = require("../services/youtube");
const {
conditionalRepliesFromBody,
@ -3264,12 +3262,6 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.setHeader("X-Content-Type-Options", "nosniff");
next();
});
app.get("/.well-known/acme-challenge/:token", (req, res) => {
const response = streamTestCertificateManager.challenge(req.params.token);
res.set("Cache-Control", "no-store");
if (!response) return res.status(404).type("text/plain").send("Not found");
return res.status(200).type("text/plain").send(response);
});
app.use(express.static(path.join(__dirname, "public")));
const uploadDir = path.join(__dirname, "..", "..", "data", "uploads");
@ -6225,27 +6217,36 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.render("admin-stream-testing", {
title: "Stream testing",
streamTest: streamTestingService.publicStatus(),
streamTestDns: dnsAutomationStatus()
streamTestIngest: getReverseProxyIngestSettings()
});
});
app.post("/admin/stream-testing/tls/dns", requireRole("admin"), async (req, res) => {
app.post("/admin/stream-testing/reverse-proxy", requireRole("admin"), (req, res) => {
try {
const status = await saveDomeneshopDnsCredentials({
token: req.body?.token,
secret: req.body?.secret
if (streamTestingService.publicStatus().active) {
throw new Error("End the active Stream Test before changing reverse-proxy ingest ports.");
}
const settings = saveReverseProxyIngestSettings({
publicPort: req.body?.public_port,
listenerPort: req.body?.listener_port
});
setFlash(req, "success", `${status.providerLabel} DNS automation is verified. Lumi can now issue and renew RTMPS certificates without using the reverse proxy challenge path.`);
setFlash(req, "success", `Reverse-proxy ingest saved. NPM should forward public port ${settings.publicPort} to Lumi port ${settings.listenerPort}.`);
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect("/admin/stream-testing");
});
app.post("/admin/stream-testing/tls/dns/remove", requireRole("admin"), (req, res) => {
clearDnsAutomation();
setFlash(req, "success", "Stored DNS automation credentials were removed. Lumi will use the public HTTP challenge route for future certificate issuance.");
res.redirect("/admin/stream-testing");
app.post("/admin/stream-testing/reverse-proxy/check", requireRole("admin"), async (req, res) => {
try {
const result = await streamTestingService.checkExternalAccess(req.hostname);
res.json({ ok: true, result });
} catch (error) {
res.status(error.code === "STREAM_TEST_ACTIVE" ? 409 : 502).json({
ok: false,
error: error.message
});
}
});
app.get("/admin/stream-testing/status", requireRole("admin"), (_req, res) => {

View File

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

View File

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