fix: automate RTMPS certificates through DNS

This commit is contained in:
Franz Rolfsvaag 2026-07-26 17:56:38 +02:00
parent dc87b7e8e2
commit 480a34b0af
22 changed files with 585 additions and 43 deletions

View File

@ -1,5 +1,13 @@
# Lumi changelog # Lumi changelog
## 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 a Lumi-styled Stream Testing setup surface that verifies DNS credentials once, creates and removes only short-lived challenge records, and supports timed destructive credential removal.
- Kept HTTP-01 for installations that forward it correctly, improved proxy-specific failure guidance, and bounded authoritative DNS propagation within Companion's startup window.
- Released Companion 0.2.5 as the matching Transcription compatibility release while retaining OBS Bridge 0.2.5.
- Preserved all server, plugin, pairing, OBS, media, and existing certificate data; no external ACME package or certificate-path environment variable is required.
## 0.3.5 ## 0.3.5
- Released Companion 0.2.4 with silent Lumi reconnect, a direct transcription on/off control, consistent collapsed plugin navigation, and a one-click route from an active Stream Test to its Lumi viewer. - Released Companion 0.2.4 with silent Lumi reconnect, a direct transcription on/off control, consistent collapsed plugin navigation, and a one-click route from an active Stream Test to its Lumi viewer.

View File

@ -1,5 +1,5 @@
#ifndef AppVersion #ifndef AppVersion
#define AppVersion "0.2.4" #define AppVersion "0.2.5"
#endif #endif
#ifndef SourceRoot #ifndef SourceRoot
#error SourceRoot must point at the self-contained Companion publish directory. #error SourceRoot must point at the self-contained Companion publish directory.

View File

@ -1,5 +1,5 @@
param( param(
[string]$Version = "0.2.4", [string]$Version = "0.2.5",
[string]$BridgeVersion = "0.2.5", [string]$BridgeVersion = "0.2.5",
[string]$ObsVersion = "31.1.1" [string]$ObsVersion = "31.1.1"
) )

View File

@ -6,8 +6,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon> <ApplicationIcon>Assets\Lumi.Companion.ico</ApplicationIcon>
<Version>0.2.4</Version> <Version>0.2.5</Version>
<AssemblyVersion>0.2.4.0</AssemblyVersion> <AssemblyVersion>0.2.5.0</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\Lumi.Companion.ico" /> <AvaloniaResource Include="Assets\Lumi.Companion.ico" />

View File

@ -70,11 +70,15 @@ through HTTPS and always receives RTMPS. `LUMI_STREAM_TEST_INGEST_HOST` may
override the advertised hostname only for those non-local sessions. override the advertised hostname only for those non-local sessions.
For the normal production path, Lumi automatically provisions and renews a For the normal production path, Lumi automatically provisions and renews a
publicly trusted certificate for the paired hostname through ACME HTTP-01. The publicly trusted certificate for the paired hostname. It first supports ACME
temporary `/.well-known/acme-challenge/` response is public, narrowly scoped, HTTP-01 through the public, narrowly scoped
and available before WebUI authentication; all certificate keys stay under `/.well-known/acme-challenge/` route. When a reverse proxy owns that reserved
Lumi's ignored data directory. The reverse proxy must forward that challenge path, configure DNS automation in **Admin > Stream testing** instead. Lumi can
path to Lumi. The first production test may take up to two minutes while the use encrypted Domeneshop credentials to create the short-lived DNS-01 TXT
record, wait for authoritative propagation, issue or renew the certificate,
and remove the record. No reverse-proxy changes, certificate paths, or external
ACME packages are required. All certificate keys stay under Lumi's ignored
data directory. The first production test may take up to two minutes while the
certificate is issued; later tests reuse it. certificate is issued; later tests reuse it.
Every session receives an exact `lumi-test/<uuid>` path and high-entropy Every session receives an exact `lumi-test/<uuid>` path and high-entropy

View File

@ -14,8 +14,9 @@ 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.5 Version: 0.3.6
## 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
@ -94,6 +95,8 @@ Version: 0.3.5
- 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/tls/dns/remove
- 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
@ -225,6 +228,15 @@ Version: 0.3.5
- 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.
@ -927,6 +939,24 @@ Version: 0.3.5
- 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
- Purpose: Processes the admin stream testing tls dns action and stores or applies submitted form data.
- Inputs: body: full submitted body is passed to a helper; exact fields are defined by the matching form/service
- Response format: HTTP redirect after handling the request
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Errors are caught and usually returned as a flash message, JSON error, or error page. Most non-API POST routes are browser form submissions and usually redirect after completion.
### POST /admin/stream-testing/tls/dns/remove
- Purpose: Processes the admin stream testing tls dns remove action and stores or applies submitted form data.
- Inputs: No request parameters detected by static analysis.
- Response format: HTTP redirect after handling the request
- Access: admin access expected
- Side effects: writes or mutates server-side state
- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. Most non-API POST routes are browser form submissions and usually redirect after completion.
### GET /admin/stream-testing/status ### GET /admin/stream-testing/status
- Purpose: Provides admin stream testing status data as JSON. - Purpose: Provides admin stream testing status data as JSON.

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata ## Metadata
Plugin ID: lumi_transcription Plugin ID: lumi_transcription
Version: 0.2.4 Version: 0.2.5
Default state: enabled Default state: enabled
## Web Routes ## Web Routes
- /plugins/lumi_transcription - /plugins/lumi_transcription

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.5", "version": "0.3.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.5", "version": "0.3.6",
"dependencies": { "dependencies": {
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.5", "version": "0.3.6",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {

View File

@ -1,5 +1,12 @@
# Lumi Transcription changelog # Lumi Transcription changelog
## 0.2.5
- Add encrypted Domeneshop DNS-01 automation for RTMPS certificate issuance and renewal when the production HTTPS proxy reserves the HTTP challenge route.
- Verify credentials in the Stream Testing WebUI, wait against authoritative DNS, and clean up temporary TXT records after success or failure.
- Retain HTTP-01 where it is forwarded correctly and provide actionable proxy-specific failure guidance otherwise.
- Release matching Companion 0.2.5 while retaining the current OBS Bridge 0.2.5.
## 0.2.4 ## 0.2.4
- Reveal private-test captions progressively, wrap them across readable lines, and age stale caption words out without waiting for a complete sentence. - Reveal private-test captions progressively, wrap them across readable lines, and age stale caption words out without waiting for a complete sentence.

View File

@ -1,17 +1,17 @@
{ {
"schema_version": 1, "schema_version": 1,
"version": "0.2.4", "version": "0.2.5",
"signed": false, "signed": false,
"release_notes": "Adds silent Lumi reconnect, a transcription toggle, consistent collapsed plugin navigation, a direct Stream Testing viewer shortcut, progressive private captions, and OBS Bridge 0.2.5.", "release_notes": "Matches Lumi Transcription 0.2.5 and its managed DNS-01 RTMPS certificate workflow while retaining silent reconnect, caption controls, progressive private captions, and OBS Bridge 0.2.5.",
"installer": { "installer": {
"id": "windows-x64-installer", "id": "windows-x64-installer",
"platform": "win32", "platform": "win32",
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 per-user installer", "label": "Windows x64 per-user installer",
"filename": "Lumi.Companion-Setup.exe", "filename": "Lumi.Companion-Setup.exe",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.4/Lumi.Companion-Setup.exe", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-Setup.exe",
"sha256": "d94b86c5c2f9908471ecd86dd71c32d0a1e9c0e33400643a739779596f7d0e71", "sha256": "bfa2d6aeb79c7f366631ea33bbda1c9f313374c3fa11a18a76ef304fc3947474",
"bytes": 51909250 "bytes": 51919651
}, },
"artifacts": [ "artifacts": [
{ {
@ -20,9 +20,9 @@
"architecture": "x64", "architecture": "x64",
"label": "Windows x64 self-contained", "label": "Windows x64 self-contained",
"filename": "Lumi.Companion-win-x64.zip", "filename": "Lumi.Companion-win-x64.zip",
"url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.4/Lumi.Companion-win-x64.zip", "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-win-x64.zip",
"sha256": "b0304a733b86e4b6da21216d4458210443d0b099efb24a9f2e4756d4f08780eb", "sha256": "7b46c1562852c848176813bbe8fe25d04c69f49ccde5848cd36294b3c036500c",
"bytes": 68486263, "bytes": 68486344,
"entrypoint": "Lumi.Companion.App.exe" "entrypoint": "Lumi.Companion.App.exe"
} }
] ]

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_transcription", "id": "lumi_transcription",
"name": "Lumi Transcription", "name": "Lumi Transcription",
"version": "0.2.4", "version": "0.2.5",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js", "main": "index.js",
"channel": "stable", "channel": "stable",

View File

@ -2,6 +2,38 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.3.6",
"ref": "refs/tags/v0.3.6",
"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": "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 local and plugin data remains preserved.",
"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.5", "version": "0.3.5",
"ref": "refs/tags/v0.3.5", "ref": "refs/tags/v0.3.5",

View File

@ -4,12 +4,12 @@ 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.5"; const releaseVersion = "0.3.6";
const previousStableVersion = "0.3.4"; const previousStableVersion = "0.3.5";
const priorStableVersion = "0.3.3"; const priorStableVersion = "0.3.4";
const earliestCompatibleCoreVersion = "0.1.9"; const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = { const introducedPlugins = {
lumi_transcription: { version: "0.2.4", knowledge: "lumi-transcription" }, lumi_transcription: { version: "0.2.5", knowledge: "lumi-transcription" },
now_playing: { version: "0.1.3", knowledge: "now-playing" } now_playing: { version: "0.1.3", knowledge: "now-playing" }
}; };
@ -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.5 after 0.3.4 with synchronized Companion plugin metadata."); console.log("Release metadata verification passed: stable core 0.3.6 after 0.3.5 with synchronized Companion plugin metadata.");

View File

@ -34,6 +34,10 @@ const {
StreamTestCertificateManager, StreamTestCertificateManager,
normalizeCertificateHostname normalizeCertificateHostname
} = require("../src/services/stream-test-certificates"); } = require("../src/services/stream-test-certificates");
const {
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 {
@ -427,6 +431,7 @@ async function main() {
delete process.env.LUMI_STREAM_TEST_TLS_CERT; delete process.env.LUMI_STREAM_TEST_TLS_CERT;
delete process.env.LUMI_STREAM_TEST_TLS_KEY; delete process.env.LUMI_STREAM_TEST_TLS_KEY;
await verifyManagedCertificateProvisioning(tempRoot); await verifyManagedCertificateProvisioning(tempRoot);
await verifyDomeneshopDnsAutomation();
} finally { } finally {
for (const [key, value] of Object.entries({ for (const [key, value] of Object.entries({
LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host, LUMI_STREAM_TEST_INGEST_HOST: oldNetwork.host,
@ -472,8 +477,11 @@ async function main() {
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, /const transport = "rtmps"/);
assert.match(service, /streamTestCertificateManager/); assert.match(service, /streamTestCertificateManager/);
assert.match(certificates, /challengePriority: \["http-01"\]/); assert.match(certificates, /challengePriority: \[dnsProvider \? "dns-01" : "http-01"\]/);
assert.match(server, /\.well-known\/acme-challenge\/:token/); assert.match(server, /\.well-known\/acme-challenge\/:token/);
assert.match(server, /admin\/stream-testing\/tls\/dns/);
assert.match(webUi, /RTMPS certificate automation/);
assert.match(webUi, /Domeneshop \/ hyp\.net/);
assert(!/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/);
@ -559,9 +567,10 @@ async function verifyManagedCertificateProvisioning(tempRoot) {
Client: class { Client: class {
async auto(options) { async auto(options) {
issueCalls += 1; issueCalls += 1;
await options.challengeCreateFn({}, { type: "http-01", token: "abcdefghijklmnopqrstuvwxyz012345" }, "key-authorization"); const type = options.challengePriority[0];
assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), "key-authorization"); await options.challengeCreateFn({}, { type, token: "abcdefghijklmnopqrstuvwxyz012345" }, "key-authorization");
await options.challengeRemoveFn({}, { type: "http-01", token: "abcdefghijklmnopqrstuvwxyz012345" }); if (type === "http-01") assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), "key-authorization");
await options.challengeRemoveFn({}, { type, token: "abcdefghijklmnopqrstuvwxyz012345" });
assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), null); assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), null);
issued = true; issued = true;
return "test certificate"; return "test certificate";
@ -591,6 +600,108 @@ async function verifyManagedCertificateProvisioning(tempRoot) {
assert.equal(issueCalls, 1, "concurrent certificate requests must share one operation"); assert.equal(issueCalls, 1, "concurrent certificate requests must share one operation");
assert(result.ready && issued, "managed certificate provisioning did not complete"); 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); 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) => {

View File

@ -24,7 +24,7 @@ function readJson(relativePath) {
const releaseIndex = readJson("release-index.json"); const releaseIndex = readJson("release-index.json");
const releaseVersions = releaseIndex.releases.map((release) => release.version); const releaseVersions = releaseIndex.releases.map((release) => release.version);
assert.deepEqual(releaseVersions, ["0.3.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.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.5"); assert.equal(packageVersion, "0.3.6");
assert.equal(currentRelease.version, "0.3.5"); assert.equal(currentRelease.version, "0.3.6");
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.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 },
{ version: "0.3.3", ref: "refs/tags/v0.3.3", rollback_safe: true }, { version: "0.3.3", ref: "refs/tags/v0.3.3", rollback_safe: true },
@ -154,7 +155,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.5"); assert.equal(corrected.safe_target_version, "0.3.6");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -4,6 +4,7 @@ const fs = require("fs");
const net = require("net"); const net = require("net");
const path = require("path"); const path = require("path");
const { createLogger } = require("./logger"); const { createLogger } = require("./logger");
const { configuredDnsProvider, dnsAutomationStatus } = require("./stream-test-dns");
const DATA_ROOT = path.join( const DATA_ROOT = path.join(
process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"), process.env.LUMI_DATA_DIR ? path.resolve(process.env.LUMI_DATA_DIR) : path.join(__dirname, "..", "..", "data"),
@ -22,6 +23,7 @@ class StreamTestCertificateManager {
this.challenges = new Map(); this.challenges = new Map();
this.operations = new Map(); this.operations = new Map();
this.accountKeyOperation = null; this.accountKeyOperation = null;
this.dnsProviderFactory = options.dnsProviderFactory || configuredDnsProvider;
this.log = options.log || createLogger("core:stream-testing", { category: "integration" }); this.log = options.log || createLogger("core:stream-testing", { category: "integration" });
} }
@ -84,6 +86,8 @@ class StreamTestCertificateManager {
async issue(host, existing) { async issue(host, existing) {
const locations = this.locations(host); const locations = this.locations(host);
const issuedTokens = new Set(); const issuedTokens = new Set();
const dnsChallenges = new Map();
const dnsProvider = await this.dnsProviderFactory(host);
fs.mkdirSync(locations.directory, { recursive: true, mode: 0o700 }); fs.mkdirSync(locations.directory, { recursive: true, mode: 0o700 });
fs.mkdirSync(this.root, { 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" }); this.log.info("Provisioning managed RTMPS certificate", { hostname: host }, { event: "rtmps_certificate_provisioning" });
@ -108,19 +112,36 @@ class StreamTestCertificateManager {
csr, csr,
email: operatorEmail(), email: operatorEmail(),
termsOfServiceAgreed: true, termsOfServiceAgreed: true,
challengePriority: ["http-01"], challengePriority: [dnsProvider ? "dns-01" : "http-01"],
// Home-hosted Lumi installations frequently cannot hairpin through their // Home-hosted Lumi installations frequently cannot hairpin through their
// public address. The ACME authority still performs the authoritative // public address. The ACME authority still performs the authoritative
// external HTTP-01 validation before issuing anything. // external HTTP-01 validation before issuing anything.
skipChallengeVerification: true, skipChallengeVerification: true,
challengeCreateFn: async (_authorization, challenge, keyAuthorization) => { 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)) { if (challenge.type !== "http-01" || !CHALLENGE_TOKEN.test(challenge.token)) {
throw new Error("The certificate authority did not provide a valid HTTP-01 challenge."); throw new Error("The certificate authority did not provide a supported ACME challenge.");
} }
this.challenges.set(challenge.token, keyAuthorization); this.challenges.set(challenge.token, keyAuthorization);
issuedTokens.add(challenge.token); issuedTokens.add(challenge.token);
}, },
challengeRemoveFn: async (_authorization, challenge) => { 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) { if (challenge?.token) {
this.challenges.delete(challenge.token); this.challenges.delete(challenge.token);
issuedTokens.delete(challenge.token); issuedTokens.delete(challenge.token);
@ -143,12 +164,21 @@ class StreamTestCertificateManager {
error error
}, { event: "rtmps_certificate_failed" }); }, { event: "rtmps_certificate_failed" });
throw certificateError( throw certificateError(
`Lumi could not automatically prepare RTMPS for ${host}. Ensure the public hostname reaches this Lumi installation at /.well-known/acme-challenge/ and retry. ${error.message}` 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 { } finally {
for (const token of issuedTokens) this.challenges.delete(token); 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() { accountKey() {
if (this.accountKeyOperation) return this.accountKeyOperation; if (this.accountKeyOperation) return this.accountKeyOperation;

View File

@ -0,0 +1,238 @@
const dns = require("dns");
const { decryptSecret, encryptSecret } = require("./overlay-secrets");
const { getSetting, setSetting } = require("./settings");
const PROVIDER_KEY = "stream_test_dns_provider";
const TOKEN_KEY = "stream_test_dns_token_encrypted";
const SECRET_KEY = "stream_test_dns_secret_encrypted";
const DOMENESHOP_API = "https://api.domeneshop.no/v0";
const REQUEST_TIMEOUT_MS = 15000;
const PROPAGATION_TIMEOUT_MS = 60000;
class DomeneshopDnsProvider {
constructor(options = {}) {
this.token = String(options.token || "").trim();
this.secret = String(options.secret || "").trim();
this.fetch = options.fetch || global.fetch;
this.resolveTxt = options.resolveTxt || authoritativeTxtValues;
this.wait = options.wait || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
this.propagationTimeoutMs = options.propagationTimeoutMs || PROPAGATION_TIMEOUT_MS;
}
async verify() {
const domains = await this.domains();
if (!domains.some((domain) => domain?.services?.dns)) {
throw new Error("The Domeneshop API credentials do not have access to an active DNS zone.");
}
return domains;
}
async createChallenge({ hostname, value }) {
const domain = await this.domainFor(hostname);
const host = challengeHost(hostname, domain.domain);
const before = await this.records(domain.id, host);
const beforeIds = new Set(before.map((record) => Number(record.id)));
const created = await this.request(`/domains/${domain.id}/dns`, {
method: "POST",
body: {
host,
ttl: 60,
type: "TXT",
data: value
}
});
let recordId = recordIdFromLocation(created.response.headers.get("location"));
if (!recordId) {
const after = await this.records(domain.id, host);
const match = after
.filter((record) => record.type === "TXT" && record.data === value && !beforeIds.has(Number(record.id)))
.sort((left, right) => Number(right.id) - Number(left.id))[0];
recordId = Number(match?.id) || null;
}
if (!recordId) {
throw new Error("Domeneshop accepted the DNS challenge but Lumi could not identify the temporary record.");
}
const fqdn = `${host === "@" ? "" : `${host}.`}${domain.domain}`;
const handle = { domainId: domain.id, recordId, fqdn };
try {
await this.waitForPropagation(fqdn, value);
return handle;
} catch (error) {
try { await this.removeChallenge(handle); } catch {}
throw error;
}
}
async removeChallenge(handle) {
if (!Number.isInteger(Number(handle?.domainId)) || !Number.isInteger(Number(handle?.recordId))) return;
await this.request(`/domains/${Number(handle.domainId)}/dns/${Number(handle.recordId)}`, {
method: "DELETE",
allowNotFound: true
});
}
async domainFor(hostname) {
const host = String(hostname || "").toLowerCase();
const domains = await this.domains();
const match = domains
.filter((domain) => domain?.services?.dns && (host === domain.domain || host.endsWith(`.${domain.domain}`)))
.sort((left, right) => right.domain.length - left.domain.length)[0];
if (!match) throw new Error(`The Domeneshop account does not contain the DNS zone for ${host}.`);
return match;
}
async domains() {
const result = await this.request("/domains");
return Array.isArray(result.body) ? result.body : [];
}
async records(domainId, host) {
const query = new URLSearchParams({ host, type: "TXT" });
const result = await this.request(`/domains/${domainId}/dns?${query}`);
return Array.isArray(result.body) ? result.body : [];
}
async waitForPropagation(fqdn, expected) {
const deadline = Date.now() + this.propagationTimeoutMs;
let lastError = null;
while (Date.now() < deadline) {
try {
const values = await this.resolveTxt(fqdn);
if (values.includes(expected)) return;
} catch (error) {
lastError = error;
}
await this.wait(3000);
}
throw new Error(`The DNS challenge for ${fqdn} did not reach Domeneshop's authoritative nameservers in time.${lastError?.code ? ` (${lastError.code})` : ""}`);
}
async request(relativePath, options = {}) {
if (!this.token || !this.secret) throw new Error("Domeneshop API token and secret are required.");
if (typeof this.fetch !== "function") throw new Error("This Node.js runtime cannot contact the Domeneshop API.");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await this.fetch(`${DOMENESHOP_API}${relativePath}`, {
method: options.method || "GET",
headers: {
accept: "application/json",
authorization: `Basic ${Buffer.from(`${this.token}:${this.secret}`).toString("base64")}`,
...(options.body ? { "content-type": "application/json" } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal
});
if (options.allowNotFound && response.status === 404) return { response, body: null };
if (!response.ok) {
const detail = String(await response.text()).slice(0, 300);
const suffix = response.status === 401 || response.status === 403
? " Check the API token, secret, and DNS permission."
: detail ? ` ${detail}` : "";
throw new Error(`Domeneshop DNS request failed (${response.status}).${suffix}`);
}
const body = response.status === 204 ? null : await response.json().catch(() => null);
return { response, body };
} catch (error) {
if (error?.name === "AbortError") throw new Error("The Domeneshop DNS request timed out.");
throw error;
} finally {
clearTimeout(timeout);
}
}
}
function dnsAutomationStatus() {
const provider = String(getSetting(PROVIDER_KEY, "") || "");
const configured = provider === "domeneshop" &&
Boolean(getSetting(TOKEN_KEY, "")) &&
Boolean(getSetting(SECRET_KEY, ""));
return {
configured,
provider: configured ? provider : null,
providerLabel: configured ? "Domeneshop" : null,
challenge: configured ? "dns-01" : "http-01"
};
}
async function saveDomeneshopDnsCredentials({ token, secret }) {
const existing = dnsAutomationStatus();
let resolvedToken = String(token || "").trim();
let resolvedSecret = String(secret || "").trim();
if (existing.configured) {
if (!resolvedToken) resolvedToken = decryptSecret(getSetting(TOKEN_KEY, ""));
if (!resolvedSecret) resolvedSecret = decryptSecret(getSetting(SECRET_KEY, ""));
}
if (!resolvedToken || resolvedToken.length > 512 || !resolvedSecret || resolvedSecret.length > 512) {
throw new Error("Enter a valid Domeneshop API token and secret.");
}
const provider = new DomeneshopDnsProvider({ token: resolvedToken, secret: resolvedSecret });
await provider.verify();
setSetting(PROVIDER_KEY, "domeneshop");
setSetting(TOKEN_KEY, encryptSecret(resolvedToken));
setSetting(SECRET_KEY, encryptSecret(resolvedSecret));
return dnsAutomationStatus();
}
function configuredDnsProvider() {
const status = dnsAutomationStatus();
if (!status.configured) return null;
try {
return new DomeneshopDnsProvider({
token: decryptSecret(getSetting(TOKEN_KEY, "")),
secret: decryptSecret(getSetting(SECRET_KEY, ""))
});
} catch {
return null;
}
}
function clearDnsAutomation() {
setSetting(PROVIDER_KEY, "");
setSetting(TOKEN_KEY, "");
setSetting(SECRET_KEY, "");
return dnsAutomationStatus();
}
async function authoritativeTxtValues(fqdn) {
const labels = String(fqdn || "").split(".").filter(Boolean);
let nameservers = [];
for (let index = 1; index < labels.length - 1 && !nameservers.length; index += 1) {
try {
nameservers = await dns.promises.resolveNs(labels.slice(index).join("."));
} catch {}
}
if (!nameservers.length) throw Object.assign(new Error("Authoritative nameservers could not be resolved."), { code: "ENODATA" });
const addresses = [];
for (const nameserver of nameservers) {
try { addresses.push(...await dns.promises.resolve4(nameserver)); } catch {}
try { addresses.push(...await dns.promises.resolve6(nameserver)); } catch {}
}
if (!addresses.length) throw Object.assign(new Error("Authoritative nameserver addresses could not be resolved."), { code: "ENODATA" });
const resolver = new dns.promises.Resolver();
resolver.setServers(addresses);
const records = await resolver.resolveTxt(fqdn);
return records.map((parts) => parts.join(""));
}
function challengeHost(hostname, domain) {
const host = String(hostname || "").toLowerCase();
const zone = String(domain || "").toLowerCase();
const relative = host === zone ? "" : host.slice(0, -(zone.length + 1));
return relative ? `_acme-challenge.${relative}` : "_acme-challenge";
}
function recordIdFromLocation(value) {
const match = String(value || "").match(/\/dns\/(\d+)\/?$/);
return match ? Number(match[1]) : null;
}
module.exports = {
DomeneshopDnsProvider,
authoritativeTxtValues,
challengeHost,
clearDnsAutomation,
configuredDnsProvider,
dnsAutomationStatus,
saveDomeneshopDnsCredentials
};

View File

@ -51,6 +51,11 @@
.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-dns-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) { .stream-test-layout { grid-template-columns: 1fr; } } @media (max-width: 900px) {
.stream-test-layout, .stream-test-dns-form { grid-template-columns: 1fr; }
.stream-test-dns-form .inline-actions { grid-column: auto; }
}
} }

View File

@ -80,6 +80,11 @@ const { getClient: getTwitchClient } = require("../services/twitch");
const { twitchEventSubManager } = require("../services/twitch-eventsub"); 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 {
clearDnsAutomation,
dnsAutomationStatus,
saveDomeneshopDnsCredentials
} = require("../services/stream-test-dns");
const { streamTestCertificateManager } = require("../services/stream-test-certificates"); const { streamTestCertificateManager } = require("../services/stream-test-certificates");
const { getClient: getYouTubeClient } = require("../services/youtube"); const { getClient: getYouTubeClient } = require("../services/youtube");
const { const {
@ -6219,10 +6224,30 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.render("admin-stream-testing", { res.render("admin-stream-testing", {
title: "Stream testing", title: "Stream testing",
streamTest: streamTestingService.publicStatus() streamTest: streamTestingService.publicStatus(),
streamTestDns: dnsAutomationStatus()
}); });
}); });
app.post("/admin/stream-testing/tls/dns", requireRole("admin"), async (req, res) => {
try {
const status = await saveDomeneshopDnsCredentials({
token: req.body?.token,
secret: req.body?.secret
});
setFlash(req, "success", `${status.providerLabel} DNS automation is verified. Lumi can now issue and renew RTMPS certificates without using the reverse proxy challenge path.`);
} catch (error) {
setFlash(req, "error", error.message);
}
res.redirect("/admin/stream-testing");
});
app.post("/admin/stream-testing/tls/dns/remove", requireRole("admin"), (req, res) => {
clearDnsAutomation();
setFlash(req, "success", "Stored DNS automation credentials were removed. Lumi will use the public HTTP challenge route for future certificate issuance.");
res.redirect("/admin/stream-testing");
});
app.get("/admin/stream-testing/status", requireRole("admin"), (_req, res) => { app.get("/admin/stream-testing/status", requireRole("admin"), (_req, res) => {
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.json({ ok: true, ...streamTestingService.publicStatus() }); res.json({ ok: true, ...streamTestingService.publicStatus() });

View File

@ -30,6 +30,45 @@
<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">
<div class="section-heading">
<div><span class="eyebrow">Secure production ingest</span><h2>RTMPS certificate automation</h2></div>
<span class="status-pill <%= streamTestDns.configured ? "success" : "warning" %>"><%= streamTestDns.configured ? "DNS ready" : "HTTP challenge" %></span>
</div>
<% if (streamTestDns.configured) { %>
<p class="hint">Lumi uses encrypted <%= streamTestDns.providerLabel %> credentials to create and remove short-lived DNS challenges. Certificate issuance and renewal do not depend on the HTTPS reverse proxy.</p>
<% } else { %>
<div class="callout warning">
<strong>Using the public WebUI challenge path</strong>
<p>If OpenResty, Nginx Proxy Manager, or another HTTPS proxy reserves <code>/.well-known/acme-challenge/</code>, configure DNS automation here. Lumi will then issue RTMPS certificates without proxy changes or certificate file paths.</p>
</div>
<% } %>
<form method="post" action="/admin/stream-testing/tls/dns" class="stream-test-dns-form">
<label class="field">
<span>DNS provider</span>
<select name="provider" aria-label="DNS provider"><option value="domeneshop" selected>Domeneshop / hyp.net</option></select>
</label>
<label class="field">
<span>API token</span>
<input type="password" name="token" autocomplete="off" maxlength="512" placeholder="<%= streamTestDns.configured ? "Leave blank to keep the saved token" : "Domeneshop API token" %>" />
</label>
<label class="field">
<span>API secret</span>
<input type="password" name="secret" autocomplete="new-password" maxlength="512" placeholder="<%= streamTestDns.configured ? "Leave blank to keep the saved secret" : "Domeneshop API secret" %>" />
</label>
<div class="inline-actions">
<button class="button" type="submit"><%= streamTestDns.configured ? "Verify saved credentials" : "Save and verify" %></button>
<a class="button subtle" href="https://www.domeneshop.no/admin?view=api" target="_blank" rel="noopener noreferrer">Create API credentials</a>
</div>
</form>
<% if (streamTestDns.configured) { %>
<form method="post" action="/admin/stream-testing/tls/dns/remove" data-confirm-mode="modal" data-confirm-title="Remove DNS automation?" data-confirm-text="Lumi will no longer be able to renew its RTMPS certificate through DNS until credentials are configured again." data-confirm-label="Remove credentials">
<button class="button danger" type="submit">Remove DNS credentials</button>
</form>
<% } %>
<p class="hint">Credentials are encrypted with this Lumi installation's secret and are never returned to the browser or written to logs.</p>
</section>
<section class="stream-test-layout"> <section class="stream-test-layout">
<div class="card stream-test-player-card"> <div class="card stream-test-player-card">
<div class="section-heading"><div><span class="eyebrow">Private receiver</span><h2>Live output</h2></div><span class="status-pill neutral" data-stream-state>Idle</span></div> <div class="section-heading"><div><span class="eyebrow">Private receiver</span><h2>Live output</h2></div><span class="status-pill neutral" data-stream-state>Idle</span></div>

View File

@ -1,6 +1,6 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.3.5", "version": "0.3.6",
"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 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 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.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -409,6 +409,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Fixes Companion package generation behind Lumi's private HTTPS reverse proxy, applies the same transport policy to HTTP and WebSocket device traffic, and standardizes durable redacted operational logging. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved." "migration_notes": "Fixes Companion package generation behind Lumi's private HTTPS reverse proxy, applies the same transport policy to HTTP and WebSocket device traffic, and standardizes durable redacted operational logging. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
},
{
"version": "0.3.5",
"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 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."
} }
] ]
} }