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