From 480a34b0afe06c40a19c716613a46dd0b619c7ba Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sun, 26 Jul 2026 17:56:38 +0200 Subject: [PATCH] fix: automate RTMPS certificates through DNS --- CHANGELOG.md | 8 + companion/installer/Lumi.Companion.iss | 2 +- companion/scripts/publish-companion.ps1 | 2 +- .../Lumi.Companion.App.csproj | 4 +- docs/stream-testing.md | 14 +- knowledge/core/lumi-core.md | 32 ++- knowledge/plugins/lumi-transcription.md | 2 +- package-lock.json | 4 +- package.json | 2 +- plugins/lumi_transcription/CHANGELOG.md | 7 + .../companion_manifest.json | 16 +- plugins/lumi_transcription/plugin.json | 2 +- release-index.json | 32 +++ scripts/verify-release-metadata.js | 10 +- scripts/verify-stream-testing.js | 119 ++++++++- scripts/verify-update-system.js | 9 +- src/services/stream-test-certificates.js | 36 ++- src/services/stream-test-dns.js | 238 ++++++++++++++++++ src/web/public/stream-testing.css | 7 +- src/web/server.js | 27 +- src/web/views/admin-stream-testing.ejs | 39 +++ update-manifest.json | 16 +- 22 files changed, 585 insertions(+), 43 deletions(-) create mode 100644 src/services/stream-test-dns.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e8e20..5ac3ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 - 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. diff --git a/companion/installer/Lumi.Companion.iss b/companion/installer/Lumi.Companion.iss index dce3b8a..185ec44 100644 --- a/companion/installer/Lumi.Companion.iss +++ b/companion/installer/Lumi.Companion.iss @@ -1,5 +1,5 @@ #ifndef AppVersion - #define AppVersion "0.2.4" + #define AppVersion "0.2.5" #endif #ifndef SourceRoot #error SourceRoot must point at the self-contained Companion publish directory. diff --git a/companion/scripts/publish-companion.ps1 b/companion/scripts/publish-companion.ps1 index aee23cd..23633c1 100644 --- a/companion/scripts/publish-companion.ps1 +++ b/companion/scripts/publish-companion.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.2.4", + [string]$Version = "0.2.5", [string]$BridgeVersion = "0.2.5", [string]$ObsVersion = "31.1.1" ) diff --git a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj index 1738fd1..fb5d2c9 100644 --- a/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj +++ b/companion/src/Lumi.Companion.App/Lumi.Companion.App.csproj @@ -6,8 +6,8 @@ enable app.manifest Assets\Lumi.Companion.ico - 0.2.4 - 0.2.4.0 + 0.2.5 + 0.2.5.0 diff --git a/docs/stream-testing.md b/docs/stream-testing.md index 3473729..2549057 100644 --- a/docs/stream-testing.md +++ b/docs/stream-testing.md @@ -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. For the normal production path, Lumi automatically provisions and renews a -publicly trusted certificate for the paired hostname through ACME HTTP-01. The -temporary `/.well-known/acme-challenge/` response is public, narrowly scoped, -and available before WebUI authentication; all certificate keys stay under -Lumi's ignored data directory. The reverse proxy must forward that challenge -path to Lumi. The first production test may take up to two minutes while the +publicly trusted certificate for the paired hostname. It first supports ACME +HTTP-01 through the public, narrowly scoped +`/.well-known/acme-challenge/` route. When a reverse proxy owns that reserved +path, configure DNS automation in **Admin > Stream testing** instead. Lumi can +use encrypted Domeneshop credentials to create the short-lived DNS-01 TXT +record, wait for authoritative propagation, issue or renew the certificate, +and remove the record. No reverse-proxy changes, certificate paths, or external +ACME packages are required. All certificate keys stay under Lumi's ignored +data directory. The first production test may take up to two minutes while the certificate is issued; later tests reuse it. Every session receives an exact `lumi-test/` path and high-entropy diff --git a/knowledge/core/lumi-core.md b/knowledge/core/lumi-core.md index ae5986e..d519a02 100644 --- a/knowledge/core/lumi-core.md +++ b/knowledge/core/lumi-core.md @@ -14,8 +14,9 @@ editable: false Lumi is the core web UI and bot runtime. ## Runtime Package: lumi-bot -Version: 0.3.5 +Version: 0.3.6 ## Routes +- GET /.well-known/acme-challenge/:token - POST /api/diagnostics/v1/run - GET /api/events - POST /api/destructive-confirmations @@ -94,6 +95,8 @@ Version: 0.3.5 - POST /admin/theming - GET /admin/diagnostics - GET /admin/stream-testing +- POST /admin/stream-testing/tls/dns +- POST /admin/stream-testing/tls/dns/remove - GET /admin/stream-testing/status - POST /admin/stream-testing/stop - 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/import ## Route Reference +### GET /.well-known/acme-challenge/:token + +- Purpose: Handles well known acme challenge token. +- Inputs: path params: `token` +- Response format: HTML or data response; exact format was not detected statically. +- Access: No explicit access guard detected in the route handler; check surrounding router/mount middleware. +- Side effects: Usually read-only. +- Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. + ### POST /api/diagnostics/v1/run - Purpose: Provides api diagnostics v1 run data as JSON. @@ -927,6 +939,24 @@ Version: 0.3.5 - Side effects: Usually read-only. - Limits/notes: Generated from static route source analysis; confirm exact behavior in the handler before changing integrations. +### POST /admin/stream-testing/tls/dns + +- 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 - Purpose: Provides admin stream testing status data as JSON. diff --git a/knowledge/plugins/lumi-transcription.md b/knowledge/plugins/lumi-transcription.md index 416f281..fa45963 100644 --- a/knowledge/plugins/lumi-transcription.md +++ b/knowledge/plugins/lumi-transcription.md @@ -14,7 +14,7 @@ editable: false Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. ## Metadata Plugin ID: lumi_transcription -Version: 0.2.4 +Version: 0.2.5 Default state: enabled ## Web Routes - /plugins/lumi_transcription diff --git a/package-lock.json b/package-lock.json index 2676669..180dfdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lumi-bot", - "version": "0.3.5", + "version": "0.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lumi-bot", - "version": "0.3.5", + "version": "0.3.6", "dependencies": { "acme-client": "^5.4.0", "adm-zip": "^0.6.0", diff --git a/package.json b/package.json index b30e39e..2c1735b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lumi-bot", - "version": "0.3.5", + "version": "0.3.6", "private": true, "type": "commonjs", "scripts": { diff --git a/plugins/lumi_transcription/CHANGELOG.md b/plugins/lumi_transcription/CHANGELOG.md index db4d74a..2fba660 100644 --- a/plugins/lumi_transcription/CHANGELOG.md +++ b/plugins/lumi_transcription/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 - Reveal private-test captions progressively, wrap them across readable lines, and age stale caption words out without waiting for a complete sentence. diff --git a/plugins/lumi_transcription/companion_manifest.json b/plugins/lumi_transcription/companion_manifest.json index ebf07eb..c82e282 100644 --- a/plugins/lumi_transcription/companion_manifest.json +++ b/plugins/lumi_transcription/companion_manifest.json @@ -1,17 +1,17 @@ { "schema_version": 1, - "version": "0.2.4", + "version": "0.2.5", "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": { "id": "windows-x64-installer", "platform": "win32", "architecture": "x64", "label": "Windows x64 per-user installer", "filename": "Lumi.Companion-Setup.exe", - "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.4/Lumi.Companion-Setup.exe", - "sha256": "d94b86c5c2f9908471ecd86dd71c32d0a1e9c0e33400643a739779596f7d0e71", - "bytes": 51909250 + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-Setup.exe", + "sha256": "bfa2d6aeb79c7f366631ea33bbda1c9f313374c3fa11a18a76ef304fc3947474", + "bytes": 51919651 }, "artifacts": [ { @@ -20,9 +20,9 @@ "architecture": "x64", "label": "Windows x64 self-contained", "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", - "sha256": "b0304a733b86e4b6da21216d4458210443d0b099efb24a9f2e4756d4f08780eb", - "bytes": 68486263, + "url": "https://git.rolfsvaag.no/Rolfsvaag_Datateknikk/Lumi/releases/download/companion-v0.2.5/Lumi.Companion-win-x64.zip", + "sha256": "7b46c1562852c848176813bbe8fe25d04c69f49ccde5848cd36294b3c036500c", + "bytes": 68486344, "entrypoint": "Lumi.Companion.App.exe" } ] diff --git a/plugins/lumi_transcription/plugin.json b/plugins/lumi_transcription/plugin.json index 1e7e6ef..2a7ee95 100644 --- a/plugins/lumi_transcription/plugin.json +++ b/plugins/lumi_transcription/plugin.json @@ -1,7 +1,7 @@ { "id": "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.", "main": "index.js", "channel": "stable", diff --git a/release-index.json b/release-index.json index ef33995..161cbc6 100644 --- a/release-index.json +++ b/release-index.json @@ -2,6 +2,38 @@ "schema_version": 1, "channel": "stable", "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", "ref": "refs/tags/v0.3.5", diff --git a/scripts/verify-release-metadata.js b/scripts/verify-release-metadata.js index ca9263d..6a39be3 100644 --- a/scripts/verify-release-metadata.js +++ b/scripts/verify-release-metadata.js @@ -4,12 +4,12 @@ const path = require("path"); const { findSafeTarget } = require("../src/services/versioning"); const root = path.join(__dirname, ".."); -const releaseVersion = "0.3.5"; -const previousStableVersion = "0.3.4"; -const priorStableVersion = "0.3.3"; +const releaseVersion = "0.3.6"; +const previousStableVersion = "0.3.5"; +const priorStableVersion = "0.3.4"; const earliestCompatibleCoreVersion = "0.1.9"; 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" } }; @@ -82,4 +82,4 @@ assert.equal(webSearch.minimum_lumi_version, "0.2.0"); assert.equal(webSearch.minimum_lumi_ai_version, "0.8.2"); assert.equal(hasVersionHeading(readText("plugins/lumi_ai_web_search/CHANGELOG.md"), webSearch.version), true); -console.log("Release metadata verification passed: stable core 0.3.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."); diff --git a/scripts/verify-stream-testing.js b/scripts/verify-stream-testing.js index 4fba665..7f73e79 100644 --- a/scripts/verify-stream-testing.js +++ b/scripts/verify-stream-testing.js @@ -34,6 +34,10 @@ const { StreamTestCertificateManager, normalizeCertificateHostname } = require("../src/services/stream-test-certificates"); +const { + DomeneshopDnsProvider, + challengeHost +} = require("../src/services/stream-test-dns"); const protocol = require("../plugins/lumi_transcription/backend/companion/protocol"); 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_KEY; await verifyManagedCertificateProvisioning(tempRoot); + await verifyDomeneshopDnsAutomation(); } finally { for (const [key, value] of Object.entries({ 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, /const transport = "rtmps"/); 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, /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(!/shell:\s*true/.test(runtime)); assert.match(gateway, /await service\.create/); @@ -559,9 +567,10 @@ async function verifyManagedCertificateProvisioning(tempRoot) { Client: class { async auto(options) { issueCalls += 1; - await options.challengeCreateFn({}, { type: "http-01", token: "abcdefghijklmnopqrstuvwxyz012345" }, "key-authorization"); - assert.equal(manager.challenge("abcdefghijklmnopqrstuvwxyz012345"), "key-authorization"); - await options.challengeRemoveFn({}, { type: "http-01", token: "abcdefghijklmnopqrstuvwxyz012345" }); + 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"; @@ -591,6 +600,108 @@ async function verifyManagedCertificateProvisioning(tempRoot) { 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) => { diff --git a/scripts/verify-update-system.js b/scripts/verify-update-system.js index 32a989e..eac6284 100644 --- a/scripts/verify-update-system.js +++ b/scripts/verify-update-system.js @@ -24,7 +24,7 @@ function readJson(relativePath) { const releaseIndex = readJson("release-index.json"); const releaseVersions = releaseIndex.releases.map((release) => release.version); -assert.deepEqual(releaseVersions, ["0.3.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"); for (const release of releaseIndex.releases) { assert.equal(normalizeRepositoryRef(release.ref), release.ref); @@ -37,8 +37,8 @@ const packageVersion = readJson("package.json").version; const coreManifest = readJson("update-manifest.json"); assert.equal(packageVersion, coreManifest.version); assert.equal(coreManifest.channel, "stable"); -assert.equal(packageVersion, "0.3.5"); -assert.equal(currentRelease.version, "0.3.5"); +assert.equal(packageVersion, "0.3.6"); +assert.equal(currentRelease.version, "0.3.6"); assert.deepEqual(currentRelease.replaces_versions, ["1.2.0"]); for (const [pluginId, version] of Object.entries(currentRelease.plugins)) { assert.equal(readJson(`plugins/${pluginId}/plugin.json`).version, version, `${pluginId} release catalog version`); @@ -50,6 +50,7 @@ for (const [toolId, version] of Object.entries(currentRelease.tools)) { const baseTarget = { current_version: "0.2.4", available_versions: [ + { version: "0.3.6", ref: "refs/tags/v0.3.6", rollback_safe: true }, { version: "0.3.5", ref: "refs/tags/v0.3.5", rollback_safe: true }, { version: "0.3.4", ref: "refs/tags/v0.3.4", rollback_safe: true }, { version: "0.3.3", ref: "refs/tags/v0.3.3", rollback_safe: true }, @@ -154,7 +155,7 @@ const corrected = buildStatus({ channel: "stable" }); 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.blocked, false); diff --git a/src/services/stream-test-certificates.js b/src/services/stream-test-certificates.js index 68458b8..019e373 100644 --- a/src/services/stream-test-certificates.js +++ b/src/services/stream-test-certificates.js @@ -4,6 +4,7 @@ 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"), @@ -22,6 +23,7 @@ class StreamTestCertificateManager { 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" }); } @@ -84,6 +86,8 @@ class StreamTestCertificateManager { 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" }); @@ -108,19 +112,36 @@ class StreamTestCertificateManager { csr, email: operatorEmail(), termsOfServiceAgreed: true, - challengePriority: ["http-01"], + 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 valid HTTP-01 challenge."); + 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); @@ -143,13 +164,22 @@ class StreamTestCertificateManager { error }, { event: "rtmps_certificate_failed" }); 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 { 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() diff --git a/src/services/stream-test-dns.js b/src/services/stream-test-dns.js new file mode 100644 index 0000000..eef4227 --- /dev/null +++ b/src/services/stream-test-dns.js @@ -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 +}; diff --git a/src/web/public/stream-testing.css b/src/web/public/stream-testing.css index a0d59bf..86c4a2d 100644 --- a/src/web/public/stream-testing.css +++ b/src/web/public/stream-testing.css @@ -51,6 +51,11 @@ .stream-test-timeline li::marker { color: var(--lumi-primary); } .stream-test-warning { margin-top: var(--lumi-space-3); } .stream-runtime-actions { margin-top: var(--lumi-space-3); } +.stream-test-dns-form { display: grid; grid-template-columns: minmax(11rem, .7fr) repeat(2, minmax(12rem, 1fr)); gap: var(--lumi-space-3); margin-top: var(--lumi-space-4); align-items: end; } +.stream-test-dns-form .inline-actions { grid-column: 1 / -1; } @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; } +} } diff --git a/src/web/server.js b/src/web/server.js index 065dfcd..0ea1f9d 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -80,6 +80,11 @@ const { getClient: getTwitchClient } = require("../services/twitch"); const { twitchEventSubManager } = require("../services/twitch-eventsub"); const { eventHooksApi } = require("../services/overlay-event-hooks"); const { streamTestingService } = require("../services/stream-testing"); +const { + clearDnsAutomation, + dnsAutomationStatus, + saveDomeneshopDnsCredentials +} = require("../services/stream-test-dns"); const { streamTestCertificateManager } = require("../services/stream-test-certificates"); const { getClient: getYouTubeClient } = require("../services/youtube"); const { @@ -6219,10 +6224,30 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) { res.set("Cache-Control", "no-store"); res.render("admin-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) => { res.set("Cache-Control", "no-store"); res.json({ ok: true, ...streamTestingService.publicStatus() }); diff --git a/src/web/views/admin-stream-testing.ejs b/src/web/views/admin-stream-testing.ejs index 3236b47..100ca24 100644 --- a/src/web/views/admin-stream-testing.ejs +++ b/src/web/views/admin-stream-testing.ejs @@ -30,6 +30,45 @@ +
+
+
Secure production ingest

RTMPS certificate automation

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

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

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

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

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

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

+
+
Private receiver

Live output

Idle
diff --git a/update-manifest.json b/update-manifest.json index cd7a4b8..3d49160 100644 --- a/update-manifest.json +++ b/update-manifest.json @@ -1,6 +1,6 @@ { "name": "Lumi Core", - "version": "0.3.5", + "version": "0.3.6", "channel": "stable", "released_at": "2026-07-26", "compatible_from": "0.1.9", @@ -8,7 +8,7 @@ "replaces_versions": [ "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, "requirements": [ "Node.js 18 or newer" @@ -409,6 +409,18 @@ ], "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." + }, + { + "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." } ] }