fix: restore proxied Companion downloads

This commit is contained in:
Franz Rolfsvaag 2026-07-26 14:22:51 +02:00
parent eb3d0baf43
commit 2368c01eb0
26 changed files with 417 additions and 91 deletions

2
.gitignore vendored
View File

@ -33,3 +33,5 @@ twitch-credentials-lumi.png
.secrets-* .secrets-*
DEVNOTES.md DEVNOTES.md
.QWEN.md .QWEN.md
_*_changes.md
lumi_current.zip

View File

@ -1,5 +1,12 @@
# Lumi changelog # Lumi changelog
## 0.3.4
- Fixed production Companion pairing and download generation behind Lumi's private HTTPS reverse proxy, while continuing to reject forged forwarding headers from public clients.
- Reused the same guarded request-origin policy for Companion HTTP, pairing, settings, and WebSocket traffic, with explicit regression coverage for private proxy HTTPS, public spoofing, and malformed hosts.
- Standardized operational failures on Lumi's durable redacting logger, removed a placeholder metrics API that silently discarded events, and documented the actual logging and feature-owned metrics boundaries.
- Included the existing Lumi favicon and Qwen workspace housekeeping from the branch without changing preserved server or plugin data.
## 0.3.3 ## 0.3.3
- Isolated private tests in a dedicated libobs output, bypassed account OAuth stream-key mutation, retained crash-safe exact service restoration, and corrected OBS encoder ownership so failed outputs cannot corrupt a later retry. - Isolated private tests in a dedicated libobs output, bypassed account OAuth stream-key mutation, retained crash-safe exact service restoration, and corrected OBS encoder ownership so failed outputs cannot corrupt a later retry.

View File

68
docs/LOGGING_STANDARD.md Normal file
View File

@ -0,0 +1,68 @@
# Logging standard
Lumi uses the structured logger in `src/services/logger.js` for operational
events. It stores searchable entries for the admin UI, attaches request context,
redacts common credential fields, and publishes live admin updates.
## Operational logging
Core services create a named logger:
```js
const { createLogger } = require("./logger");
const logger = createLogger("updater");
logger.info("Update check completed", { version }, {
category: "updates",
event: "update_check_completed"
});
```
Plugins receive a plugin-scoped `logger` in their `init()` dependencies. Use
that injected logger when practical. A plugin module that must log outside
`init()` may create a logger named `plugin:<plugin-id>`.
Use:
- `debug` for detailed, low-value troubleshooting information.
- `info` for meaningful lifecycle and administrative actions.
- `warn` for degraded behavior that Lumi can continue through.
- `error` for a failed operation that may require attention.
Pass an `Error` as the details argument when one is available. The logger
preserves its stack trace while applying credential redaction:
```js
logger.error("Plugin refresh failed", error, {
event: "plugin_refresh_failed"
});
```
Do not include passwords, tokens, cookies, pairing secrets, authorization
headers, or full request bodies in messages or metadata. Redaction is a safety
net, not a reason to collect secrets.
## Metrics and high-frequency events
Do not write high-frequency counters or per-frame events to the operational log.
There is no global metrics API.
A feature may retain its own bounded metrics only when it also owns the storage,
retention, and inspection UI. Lumi AI is one example:
`plugins/lumi_ai/backend/metrics.js` aggregates AI timings and retains bounded
work history. Operational failures in such a feature still belong in the core
logger.
## Console output
Direct `console.*` calls are captured after `hookConsole()` starts, but they lose
the useful source and event metadata of a named logger. Prefer a named logger in
runtime code. Console output remains reasonable in standalone verification and
build scripts where the terminal is the intended consumer.
## Retention and context
Operational logs default to 30 days and at most 100,000 entries. Request
correlation is inherited through `withLogContext()` and logger `run()` scopes.
The logger redacts common sensitive keys and credential-looking text before an
entry is stored or published.

View File

@ -36,6 +36,10 @@ Production device HTTP and WebSocket traffic requires HTTPS/WSS. Plain
HTTP/WebSocket is allowed only for a device paired from the exact matching HTTP/WebSocket is allowed only for a device paired from the exact matching
loopback Lumi origin and only while both sides remain on loopback. loopback Lumi origin and only while both sides remain on loopback.
Lumi recognizes HTTPS terminated by a reverse proxy on loopback or an isolated
private proxy network. Forwarded protocol headers received directly from public
addresses are ignored, so only a trusted proxy can mark a request as HTTPS.
## Models and workers ## Models and workers
The Admin page installs checksum-pinned model and worker artifacts only after The Admin page installs checksum-pinned model and worker artifacts only after

View File

@ -14,7 +14,7 @@ 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.3 Version: 0.3.4
## Routes ## Routes
- POST /api/diagnostics/v1/run - POST /api/diagnostics/v1/run
- GET /api/events - GET /api/events

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.3", "version": "0.3.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lumi-bot", "name": "lumi-bot",
"version": "0.3.3", "version": "0.3.4",
"dependencies": { "dependencies": {
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",

View File

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

View File

@ -3,6 +3,9 @@ const fs = require("fs");
const ejs = require("ejs"); const ejs = require("ejs");
const { Permissions } = require("discord.js"); const { Permissions } = require("discord.js");
const { ensureUserForIdentity } = require("../../src/services/users"); const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("auto-vc");
const PLUGIN_ID = "auto-vc"; const PLUGIN_ID = "auto-vc";
const DEFAULT_TEMPLATE = "[username]'s room"; const DEFAULT_TEMPLATE = "[username]'s room";
@ -127,7 +130,7 @@ module.exports = {
const attach = () => { const attach = () => {
bootstrapRooms(discordClient, db, state, settings).catch((error) => { bootstrapRooms(discordClient, db, state, settings).catch((error) => {
console.error("Auto VC bootstrap failed", error); logger.error("Auto VC bootstrap failed", error);
}); });
discordClient.on("voiceStateUpdate", (oldState, newState) => { discordClient.on("voiceStateUpdate", (oldState, newState) => {
handleVoiceStateUpdate(oldState, newState, db, settings, state); handleVoiceStateUpdate(oldState, newState, db, settings, state);
@ -580,7 +583,7 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
if (lobby && newState.channelId !== oldState.channelId) { if (lobby && newState.channelId !== oldState.channelId) {
createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => { createRoomFromLobby(newState, lobby, db, settings, state, config).catch((error) => {
console.error("Auto VC creation failed", error); logger.error("Auto VC creation failed", error);
}); });
} }
@ -923,7 +926,7 @@ async function deleteChannel(channel) {
try { try {
await channel.delete("Auto VC cleanup"); await channel.delete("Auto VC cleanup");
} catch (error) { } catch (error) {
console.error("Failed to delete Auto VC channel", error); logger.error("Failed to delete Auto VC channel", error);
} }
} }
@ -1361,12 +1364,12 @@ async function moveMemberToChannel(member, channel) {
await member.voice.setChannel(channel); await member.voice.setChannel(channel);
return true; return true;
} catch (error) { } catch (error) {
console.error("Failed to move member", error); logger.error("Failed to move member", error);
try { try {
await member.edit({ channel: channel.id }); await member.edit({ channel: channel.id });
return true; return true;
} catch (fallbackError) { } catch (fallbackError) {
console.error("Fallback move failed", fallbackError); logger.error("Fallback move failed", fallbackError);
return false; return false;
} }
} }
@ -1378,7 +1381,7 @@ function startNameRefreshTimer(discordClient, db, settings, state) {
} }
const runSweep = () => { const runSweep = () => {
refreshRoomNames(discordClient, db, settings, state).catch((error) => { refreshRoomNames(discordClient, db, settings, state).catch((error) => {
console.error("Auto VC name refresh failed", error); logger.error("Auto VC name refresh failed", error);
}); });
}; };
runSweep(); runSweep();
@ -1408,7 +1411,7 @@ async function refreshRoomNames(discordClient, db, settings, state) {
const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName); const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName);
if (desiredName && desiredName !== channel.name) { if (desiredName && desiredName !== channel.name) {
await channel.setName(desiredName).catch((error) => { await channel.setName(desiredName).catch((error) => {
console.error("Failed to update Auto VC name", error); logger.error("Failed to update Auto VC name", error);
}); });
} }
} }
@ -1420,7 +1423,7 @@ function startSweepTimer(discordClient, db, settings, state) {
} }
const runSweep = () => { const runSweep = () => {
sweepRooms(discordClient, db, state).catch((error) => { sweepRooms(discordClient, db, state).catch((error) => {
console.error("Auto VC sweep failed", error); logger.error("Auto VC sweep failed", error);
}); });
}; };
runSweep(); runSweep();

View File

@ -1,5 +1,8 @@
const crypto = require("crypto"); const crypto = require("crypto");
const path = require("path"); const path = require("path");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("birthday");
const PLUGIN_ID = "birthday"; const PLUGIN_ID = "birthday";
const TIMER_KEY = Symbol.for("lumi.birthday.interval"); const TIMER_KEY = Symbol.for("lumi.birthday.interval");
@ -539,9 +542,9 @@ function restartScheduler({ db, discordClient }) {
if (!config.enabled) { if (!config.enabled) {
return; return;
} }
checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); checkBirthdays({ db, discordClient }).catch((error) => logger.error("Birthday check failed", error));
global[TIMER_KEY] = setInterval(() => { global[TIMER_KEY] = setInterval(() => {
checkBirthdays({ db, discordClient }).catch((error) => console.error("Birthday check failed", error)); checkBirthdays({ db, discordClient }).catch((error) => logger.error("Birthday check failed", error));
}, config.birthday_check_interval_minutes * 60 * 1000); }, config.birthday_check_interval_minutes * 60 * 1000);
} }

View File

@ -6,11 +6,14 @@ const express = require("express");
const multer = require("multer"); const multer = require("multer");
const EventEmitter = require("events"); const EventEmitter = require("events");
const { ensureUserForIdentity } = require("../../src/services/users"); const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const { const {
cleanupUploadedFiles, cleanupUploadedFiles,
validateUploadedFile validateUploadedFile
} = require("../../src/services/upload-security"); } = require("../../src/services/upload-security");
const logger = createLogger("economy-framework");
const PLUGIN_ID = "economy-framework"; const PLUGIN_ID = "economy-framework";
const LEGACY_STEM = ["echo", "nomy"].join(""); const LEGACY_STEM = ["echo", "nomy"].join("");
const LEGACY_PLUGIN_ID = `${LEGACY_STEM}-framework`; const LEGACY_PLUGIN_ID = `${LEGACY_STEM}-framework`;
@ -1423,7 +1426,7 @@ function startActivityRewardFlusher(db) {
try { try {
flushActivityRewards(db); flushActivityRewards(db);
} catch (error) { } catch (error) {
console.error("Activity reward flush failed", error); logger.error("Activity reward flush failed", error);
} }
}, 60 * 1000); }, 60 * 1000);
} }
@ -1489,7 +1492,7 @@ function flushActivityRewards(db) {
"DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?" "DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?"
).run(group.userId, group.hourStart); ).run(group.userId, group.hourStart);
} catch (error) { } catch (error) {
console.error("Failed to apply queued activity reward", error); logger.error("Failed to apply queued activity reward", error);
} }
} }
} }

View File

@ -1,5 +1,8 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("expression-interaction");
const DEFAULT_ACTIONS = [ const DEFAULT_ACTIONS = [
{ id: "hug", verb: "hugs", past: "hugged" }, { id: "hug", verb: "hugs", past: "hugged" },
@ -672,7 +675,7 @@ function writeCommandsManifest(config) {
const target = path.join(pluginMeta.dir, "cmds.json"); const target = path.join(pluginMeta.dir, "cmds.json");
fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8"); fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8");
} catch (error) { } catch (error) {
console.error("Failed to write expression command manifest", error); logger.error("Failed to write expression command manifest", error);
} }
} }

View File

@ -2,6 +2,7 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const express = require("express"); const express = require("express");
const { writeJsonAtomicSync } = require("../../src/services/safe-files"); const { writeJsonAtomicSync } = require("../../src/services/safe-files");
const { createLogger } = require("../../src/services/logger");
const { ensureDataDirs, resolveData } = require("./backend/paths"); const { ensureDataDirs, resolveData } = require("./backend/paths");
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager"); const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware"); const { detectHardware, estimateAllocation, performanceTuningHints } = require("./backend/hardware");
@ -42,6 +43,7 @@ const { formatBytes, bytesFromMb, sanityCheckSize } = require("./backend/size_ut
const { SOURCE_DEFAULTS } = require("./backend/controller"); const { SOURCE_DEFAULTS } = require("./backend/controller");
const PLUGIN_ID = "lumi_ai"; const PLUGIN_ID = "lumi_ai";
const logger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" });
const TOKEN_PRESETS = Object.freeze([ const TOKEN_PRESETS = Object.freeze([
{ label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." }, { label: "Tiny (256)", value: 256, description: "Small helper replies and minimal context." },
{ label: "Very small (512)", value: 512, description: "Short replies and low memory usage." }, { label: "Very small (512)", value: 512, description: "Short replies and low memory usage." },
@ -63,7 +65,7 @@ module.exports = {
ensureDataDirs(); ensureDataDirs();
if (!repoIndexer.loadIndex()) { if (!repoIndexer.loadIndex()) {
try { repoIndexer.refreshIndex(); } try { repoIndexer.refreshIndex(); }
catch (error) { console.warn("Lumi AI repository index initialization failed", error.message); } catch (error) { logger.warn("Lumi AI repository index initialization failed", error); }
} }
let config = getConfig(); let config = getConfig();
metrics.configureRetention(config.work_history_retention); metrics.configureRetention(config.work_history_retention);
@ -374,7 +376,7 @@ module.exports = {
sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3), sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3),
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3) sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
].filter((check) => !check.valid); ].filter((check) => !check.valid);
for (const diagnostic of sizeDiagnostics) console.warn(`Lumi AI size diagnostic: ${diagnostic.message}`); for (const diagnostic of sizeDiagnostics) logger.warn(`Lumi AI size diagnostic: ${diagnostic.message}`);
const models = modelManifest.models.map((model) => ({ const models = modelManifest.models.map((model) => ({
...model, ...model,
downloaded: fs.existsSync(resolveData("models", model.filename)), downloaded: fs.existsSync(resolveData("models", model.filename)),
@ -1651,7 +1653,7 @@ module.exports = {
locals: { endpoint: `/plugins/${PLUGIN_ID}` } locals: { endpoint: `/plugins/${PLUGIN_ID}` }
}); });
} else { } else {
console.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible."); logger.warn("Lumi AI assistant panel hook is unavailable; settings remain accessible.");
} }
ensureSidebarNavItem(settings); ensureSidebarNavItem(settings);
registerAssistantCommands({ registerAssistantCommands({
@ -1666,17 +1668,17 @@ module.exports = {
}); });
writeCommandsManifest(plugin?.dir || __dirname, config); writeCommandsManifest(plugin?.dir || __dirname, config);
setImmediate(() => toolManager.loadEnabled().catch((error) => setImmediate(() => toolManager.loadEnabled().catch((error) =>
console.error("Lumi AI tool loader failed", error) logger.error("Lumi AI tool loader failed", error)
)); ));
if (config.enabled) { if (config.enabled) {
setImmediate(() => ensureGateRuntime().catch((error) => setImmediate(() => ensureGateRuntime().catch((error) =>
console.error("Lumi AI gate runtime start failed", error) logger.error("Lumi AI gate runtime start failed", error)
)); ));
} }
const state = getRuntimeState(); const state = getRuntimeState();
if (shouldAutoResume(config, state)) { if (shouldAutoResume(config, state)) {
setImmediate(() => startRuntimes({ resume: true }).catch((error) => console.error("Lumi AI runtime resume failed", error))); setImmediate(() => startRuntimes({ resume: true }).catch((error) => logger.error("Lumi AI runtime resume failed", error)));
} }
return async () => { return async () => {
@ -1799,7 +1801,7 @@ function deniedImprovement(req, res) {
return sendImprovementError(req, res, 403, "Improvement Center access is not enabled for this account."); return sendImprovementError(req, res, 403, "Improvement Center access is not enabled for this account.");
} }
function logImprovementActionError(req, error) { function logImprovementActionError(req, error) {
console.error("Lumi AI feedback action failed", { logger.error("Lumi AI feedback action failed", {
review_id: cleanText(req.params?.id, 100), review_id: cleanText(req.params?.id, 100),
action: cleanText(req.body?.action, 30), action: cleanText(req.body?.action, 30),
user_id: cleanText(req.session?.user?.id, 100), user_id: cleanText(req.session?.user?.id, 100),

View File

@ -1,6 +1,10 @@
const { WebSocketServer, WebSocket } = require("ws"); const { WebSocketServer, WebSocket } = require("ws");
const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol"); const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol");
const { insecureDeviceAllowed } = require("./device_store"); const { insecureDeviceAllowed } = require("./device_store");
const {
isSecureRequest,
requestOrigin
} = require("../../../../src/services/proxy-security");
const MAX_CONTROL_MESSAGES_PER_SECOND = 120; const MAX_CONTROL_MESSAGES_PER_SECOND = 120;
const MAX_AUDIO_MESSAGES_PER_SECOND = 200; const MAX_AUDIO_MESSAGES_PER_SECOND = 200;
@ -15,15 +19,18 @@ class CompanionGateway {
this.wss.on("connection", (socket, request, device) => this.connection(socket, request, device)); this.wss.on("connection", (socket, request, device) => this.connection(socket, request, device));
} }
upgrade(request, socket, head) { upgrade(request, socket, head) {
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim(); const secure = isSecureRequest(request);
const proxyIsLocal = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress);
const secure = Boolean(request.socket.encrypted) || (proxyIsLocal && forwardedProto === "https");
const auth = this.devices.authenticate(request.headers.authorization, "transcription.capture.v1"); const auth = this.devices.authenticate(request.headers.authorization, "transcription.capture.v1");
if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason); if (!auth.allowed) return reject(socket, auth.reason === "capability_revoked" ? 403 : 401, auth.reason);
const requestOrigin = `http://${request.headers.host || "invalid"}`; let requestOriginValue;
if (!secure && !insecureDeviceAllowed(auth.device, requestOrigin, request.socket.remoteAddress)) return reject(socket, 426, "tls_required"); try {
const origin = request.headers.origin; requestOriginValue = requestOrigin(request);
if (origin && !sameHostOrigin(origin, request.headers.host)) return reject(socket, 403, "origin_rejected"); } catch {
return reject(socket, 400, "invalid_host");
}
if (!secure && !insecureDeviceAllowed(auth.device, requestOriginValue, request.socket.remoteAddress)) return reject(socket, 426, "tls_required");
const browserOrigin = request.headers.origin;
if (browserOrigin && !sameHostOrigin(browserOrigin, request.headers.host)) return reject(socket, 403, "origin_rejected");
this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device)); this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit("connection", ws, request, auth.device));
} }
connection(socket, _request, device) { connection(socket, _request, device) {

View File

@ -2,6 +2,10 @@ const express = require("express");
const ejs = require("ejs"); const ejs = require("ejs");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const {
isSecureRequest,
requestOrigin
} = require("../../src/services/proxy-security");
const { DeviceStore } = require("./backend/companion/device_store"); const { DeviceStore } = require("./backend/companion/device_store");
const { insecureDeviceAllowed, sameLocalhostOrigin } = require("./backend/companion/device_store"); const { insecureDeviceAllowed, sameLocalhostOrigin } = require("./backend/companion/device_store");
const { CompanionGateway } = require("./backend/companion/gateway"); const { CompanionGateway } = require("./backend/companion/gateway");
@ -98,25 +102,62 @@ module.exports = {
runtime: runtimeManifest runtime: runtimeManifest
})); }));
router.post("/api/pairing-package", requireAdmin, (req, res) => { router.post("/api/pairing-package", requireAdmin, (req, res) => {
let host = "";
try { try {
const host = requestHost(req); host = requestHost(req);
diagnosticLog.append?.({ kind: "companion_pairing", action: "request_start", scheme: host.startsWith("https") ? "https" : "http", host });
if (!host.startsWith("https")) {
logger.warn?.(
"Companion pairing package requested over HTTP — check X-Forwarded-Proto proxy header",
{ plugin: PLUGIN_ID, host },
{ event: "companion_http_pairing_request" }
);
}
const pairing = devices.issuePairing({ userId: req.session.user.id, host }); const pairing = devices.issuePairing({ userId: req.session.user.id, host });
diagnosticLog.append?.({ kind: "companion_pairing", action: "pairing_issued", pairing_id: pairing.pairing_id, scheme: host.startsWith("https") ? "https" : "http" });
const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair`, ...companionOperatorMetadata(host) }; const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair`, ...companionOperatorMetadata(host) };
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.attachment(`lumi-companion-${pairing.pairing_id}.lumi-pairing.json`); res.attachment(`lumi-companion-${pairing.pairing_id}.lumi-pairing.json`);
res.send(`${JSON.stringify(bootstrap, null, 2)}\n`); res.send(`${JSON.stringify(bootstrap, null, 2)}\n`);
} catch (error) { res.status(400).json({ ok: false, error: error.message }); } } catch (error) {
diagnosticLog.append?.({ kind: "companion_pairing", action: "pairing_failed", error: error.message, scheme: host ? (host.startsWith("https") ? "https" : "http") : "invalid" });
logger.error?.(
"Failed to generate companion pairing package",
{ plugin: PLUGIN_ID, error: error.message },
{ event: "companion_pairing_failed" }
);
res.status(400).json({ ok: false, error: error.message });
}
}); });
router.post("/api/companion/download", requireAdmin, async (req, res) => { router.post("/api/companion/download", requireAdmin, async (req, res) => {
let host = "";
try { try {
const host = requestHost(req); host = requestHost(req);
diagnosticLog.append?.({ kind: "companion_download", action: "request_start", scheme: host.startsWith("https") ? "https" : "http", host });
if (!host.startsWith("https")) {
logger.warn?.(
"Companion download requested over HTTP — check X-Forwarded-Proto proxy header",
{ plugin: PLUGIN_ID, host },
{ event: "companion_http_download_request" }
);
}
const pairing = devices.issuePairing({ userId: req.session.user.id, host }); const pairing = devices.issuePairing({ userId: req.session.user.id, host });
diagnosticLog.append?.({ kind: "companion_download", action: "pairing_issued_for_download", pairing_id: pairing.pairing_id });
const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair`, ...companionOperatorMetadata(host) }; const bootstrap = { format: "lumi-companion-bootstrap-v1", ...pairing, exchange_url: `${host}/plugins/${PLUGIN_ID}/api/pair`, ...companionOperatorMetadata(host) };
const bundle = await companionPackages.build({ ...pairing, bootstrap }); const bundle = await companionPackages.build({ ...pairing, bootstrap });
diagnosticLog.append?.({ kind: "companion_download", action: "bundle_built", filename: bundle.filename, bytes: bundle.buffer?.length || 0 });
res.set("Cache-Control", "no-store"); res.set("Cache-Control", "no-store");
res.attachment(bundle.filename); res.attachment(bundle.filename);
res.send(bundle.buffer); res.send(bundle.buffer);
} catch (error) { res.status(503).json({ ok: false, error: error.message }); } } catch (error) {
diagnosticLog.append?.({ kind: "companion_download", action: "download_failed", error: error.message });
logger.error?.(
"Failed to build Companion download bundle",
{ plugin: PLUGIN_ID, error: error.message },
{ event: "companion_download_failed" }
);
res.status(503).json({ ok: false, error: error.message });
}
}); });
const companionUpdateHandler = async (req, res) => { const companionUpdateHandler = async (req, res) => {
try { try {
@ -342,7 +383,7 @@ function companionOperatorMetadata(host) {
}; };
} }
function requireDeviceAccess(devices) { return (req, res, next) => { const auth = devices.authenticate(req.headers.authorization); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!req.secure && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Companion requests require HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; } function requireDeviceAccess(devices) { return (req, res, next) => { const auth = devices.authenticate(req.headers.authorization); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!isSecureRequest(req) && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Companion requests require HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; }
function compareVersions(left, right) { function compareVersions(left, right) {
const parse = (value) => { const parse = (value) => {
const [version] = String(value || "0").split("+", 1); const [version] = String(value || "0").split("+", 1);
@ -405,6 +446,6 @@ async function renderLumiPage(locals) {
]); ]);
return `${top}${body}${bottom}`; return `${top}${body}${bottom}`;
} }
function requireSettingsAccess(devices) { return (req, res, next) => { if (req.session?.user?.isAdmin) return next(); const auth = devices.authenticate(req.headers.authorization, "transcription.settings.v1"); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!req.secure && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Device settings synchronization requires HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; } function requireSettingsAccess(devices) { return (req, res, next) => { if (req.session?.user?.isAdmin) return next(); const auth = devices.authenticate(req.headers.authorization, "transcription.settings.v1"); if (!auth.allowed) return res.status(401).json({ error: "A paired device credential is required." }); if (!isSecureRequest(req) && !insecureDeviceAllowed(auth.device, requestHost(req), req.socket.remoteAddress)) return res.status(426).json({ error: "Device settings synchronization requires HTTPS unless this device was paired from the same localhost origin." }); req.lumiDevice = auth.device; next(); }; }
function requestHost(req) { return `${req.protocol === "https" ? "https" : "http"}://${req.get("host")}`; } function requestHost(req) { return requestOrigin(req); }
function requirePairingTransport(devices) { return (req, res, next) => { if (req.secure) return next(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (local && devices.pairingAllowsHttp(req.body?.token, requestHost(req))) return next(); return res.status(426).json({ error: "Lumi Companion pairing requires HTTPS unless the package was generated from this exact localhost URL." }); }; } function requirePairingTransport(devices) { return (req, res, next) => { if (isSecureRequest(req)) return next(); const local = ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress); if (local && devices.pairingAllowsHttp(req.body?.token, requestHost(req))) return next(); return res.status(426).json({ error: "Lumi Companion pairing requires HTTPS unless the package was generated from this exact localhost URL." }); }; }

View File

@ -24,6 +24,10 @@ const { CaptionStabilizer, LatestCaptionGate } = require("../backend/transcripti
const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("../backend/transcription/provider"); const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("../backend/transcription/provider");
const { BenchmarkStore, metricStats } = require("../backend/tests/benchmark_store"); const { BenchmarkStore, metricStats } = require("../backend/tests/benchmark_store");
const plugin = require("../index"); const plugin = require("../index");
const {
isSecureRequest,
requestOrigin
} = require("../../../src/services/proxy-security");
const { createWebUpgradeRegistry } = require("../../../src/services/web-upgrades"); const { createWebUpgradeRegistry } = require("../../../src/services/web-upgrades");
async function run() { async function run() {
@ -32,6 +36,7 @@ async function run() {
verifyProtocol(); verifyProtocol();
verifyPairingAndRevocation(); verifyPairingAndRevocation();
verifyLocalhostTransportPolicy(); verifyLocalhostTransportPolicy();
verifyProxiedHttpsTransport();
verifyCompanionVersionOrdering(); verifyCompanionVersionOrdering();
verifyWorkerResolution(temp); verifyWorkerResolution(temp);
verifyRevisions(); verifyRevisions();
@ -53,6 +58,43 @@ async function run() {
} finally { fs.rmSync(temp, { recursive: true, force: true }); } } finally { fs.rmSync(temp, { recursive: true, force: true }); }
} }
function verifyProxiedHttpsTransport() {
const request = {
protocol: "http",
secure: false,
socket: { remoteAddress: "::ffff:172.20.0.3" },
get(name) {
if (name === "x-forwarded-proto") return "https";
if (name === "host") return "lumi.example.test";
return "";
}
};
const origin = requestOrigin(request);
assert.equal(origin, "https://lumi.example.test");
assert.equal(isSecureRequest(request), true);
assert.doesNotThrow(() => normalizeHost(origin), "a Companion package generated behind the production HTTPS proxy must retain its HTTPS origin");
const devices = new DeviceStore(new Database(":memory:"));
const pairing = devices.issuePairing({ userId: "admin", host: origin });
assert.equal(pairing.host, "https://lumi.example.test", "the issued package must contain the public HTTPS origin");
assert.throws(
() => normalizeHost(requestOrigin({ ...request, socket: { remoteAddress: "203.0.113.20" } })),
/HTTPS/,
"an untrusted client cannot turn an HTTP request into an HTTPS pairing package with forwarding headers"
);
assert.throws(
() => requestOrigin({
...request,
get(name) {
if (name === "x-forwarded-proto") return "https";
if (name === "host") return "lumi.example.test/forged";
return "";
}
}),
/host is invalid/,
"pairing origins must reject malformed Host headers instead of silently normalizing them"
);
}
function verifyAdminDeviceRevocationUx() { function verifyAdminDeviceRevocationUx() {
const view = fs.readFileSync(path.join(__dirname, "../views/settings.ejs"), "utf8"); const view = fs.readFileSync(path.join(__dirname, "../views/settings.ejs"), "utf8");
const client = fs.readFileSync(path.join(__dirname, "../public/transcription.js"), "utf8"); const client = fs.readFileSync(path.join(__dirname, "../public/transcription.js"), "utf8");

View File

@ -2,6 +2,38 @@
"schema_version": 1, "schema_version": 1,
"channel": "stable", "channel": "stable",
"releases": [ "releases": [
{
"version": "0.3.4",
"ref": "refs/tags/v0.3.4",
"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": "Fixes Companion package generation behind Lumi's private HTTPS reverse proxy, applies one guarded transport policy to HTTP and WebSocket device traffic, and standardizes durable redacted operational logging. 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.3",
"moderation": "0.1.5",
"now_playing": "0.1.2",
"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.3", "version": "0.3.3",
"ref": "refs/tags/v0.3.3", "ref": "refs/tags/v0.3.3",

View File

@ -5,6 +5,7 @@ const path = require("path");
const root = path.join(__dirname, ".."); const root = path.join(__dirname, "..");
require("../src/services/db").migrate(); require("../src/services/db").migrate();
const diagnostics = require("../src/services/production-diagnostics"); const diagnostics = require("../src/services/production-diagnostics");
const proxySecurity = require("../src/services/proxy-security");
assert.deepEqual(Object.keys(diagnostics.CHECKS), [ assert.deepEqual(Object.keys(diagnostics.CHECKS), [
"system_health", "system_health",
@ -35,6 +36,28 @@ assert.equal(diagnostics.isTrustedPrivateProxyAddress("203.0.113.10"), false);
assert.equal(diagnostics.diagnosticsRequestProtocol(proxiedRequest), "https"); assert.equal(diagnostics.diagnosticsRequestProtocol(proxiedRequest), "https");
assert.equal(diagnostics.isSecureDiagnosticRequest(proxiedRequest), true); assert.equal(diagnostics.isSecureDiagnosticRequest(proxiedRequest), true);
assert.equal(diagnostics.diagnosticsRequestProtocol({ ...proxiedRequest, socket: { remoteAddress: "203.0.113.10" } }), "http"); assert.equal(diagnostics.diagnosticsRequestProtocol({ ...proxiedRequest, socket: { remoteAddress: "203.0.113.10" } }), "http");
const companionDownloadRequest = {
protocol: "http",
secure: false,
socket: { remoteAddress: "::ffff:172.19.0.4" },
get(name) {
if (name === "x-forwarded-proto") return "https";
if (name === "host") return "lumi.example.test";
return "";
}
};
assert.equal(proxySecurity.requestOrigin(companionDownloadRequest), "https://lumi.example.test");
assert.equal(proxySecurity.isSecureRequest(companionDownloadRequest), true);
assert.equal(proxySecurity.isTrustedProxyAddress("172.19.0.4"), true);
assert.equal(proxySecurity.isTrustedProxyAddress("203.0.113.10"), false);
assert.equal(proxySecurity.requestOrigin({
...companionDownloadRequest,
socket: { remoteAddress: "203.0.113.10" }
}), "http://lumi.example.test", "a public client must not be able to forge HTTPS");
assert.equal(proxySecurity.requestProtocol({
...companionDownloadRequest,
get(name) { return name === "x-forwarded-proto" ? "javascript" : "lumi.example.test"; }
}), "http", "unsupported forwarding schemes must be rejected");
const redacted = diagnostics.redactDiagnosticValue({ const redacted = diagnostics.redactDiagnosticValue({
token: "top-secret", token: "top-secret",
@ -82,7 +105,8 @@ const endpointIndex = serverSource.indexOf('app.post("/api/diagnostics/v1/run"')
const configuredIndex = serverSource.indexOf("app.use(requireConfigured)"); const configuredIndex = serverSource.indexOf("app.use(requireConfigured)");
assert(endpointIndex > 0 && endpointIndex < configuredIndex, "diagnostics endpoint must remain available for production recovery"); assert(endpointIndex > 0 && endpointIndex < configuredIndex, "diagnostics endpoint must remain available for production recovery");
assert.match(serverSource, /authenticateDiagnosticsRequest\(req\)/); assert.match(serverSource, /authenticateDiagnosticsRequest\(req\)/);
assert.match(serverSource, /app\.set\("trust proxy", "loopback"\)/); assert.match(serverSource, /app\.set\("trust proxy", isTrustedProxyAddress\)/);
assert.doesNotMatch(serverSource, /LUMI_LOCALHOST/, "proxy trust must not depend on an unrelated undocumented environment flag");
assert.match(serverSource, /app\.get\("\/admin\/diagnostics", requireRole\("admin"\)/); assert.match(serverSource, /app\.get\("\/admin\/diagnostics", requireRole\("admin"\)/);
assert.match(serverSource, /app\.post\("\/admin\/diagnostics\/access\/renew", requireRole\("admin"\)/); assert.match(serverSource, /app\.post\("\/admin\/diagnostics\/access\/renew", requireRole\("admin"\)/);
assert.match(serverSource, /app\.post\("\/admin\/diagnostics\/access\/revoke", requireRole\("admin"\)/); assert.match(serverSource, /app\.post\("\/admin\/diagnostics\/access\/revoke", requireRole\("admin"\)/);

View File

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

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.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.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.3"); assert.equal(packageVersion, "0.3.4");
assert.equal(currentRelease.version, "0.3.3"); assert.equal(currentRelease.version, "0.3.4");
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.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 },
{ version: "0.3.2", ref: "refs/tags/v0.3.2", rollback_safe: true }, { version: "0.3.2", ref: "refs/tags/v0.3.2", rollback_safe: true },
{ version: "0.3.1", ref: "refs/tags/v0.3.1", rollback_safe: true }, { version: "0.3.1", ref: "refs/tags/v0.3.1", rollback_safe: true },
@ -152,7 +153,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.3"); assert.equal(corrected.safe_target_version, "0.3.4");
assert.equal(corrected.update_available, true); assert.equal(corrected.update_available, true);
assert.equal(corrected.blocked, false); assert.equal(corrected.blocked, false);

View File

@ -2,6 +2,9 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const { db } = require("./db"); const { db } = require("./db");
const { getPlugins } = require("./plugins"); const { getPlugins } = require("./plugins");
const { createLogger } = require("./logger");
const logger = createLogger("plugin-stats");
function readJsonSafe(filePath) { function readJsonSafe(filePath) {
try { try {
@ -35,7 +38,7 @@ function loadStatProviders() {
try { try {
provider = require(providerPath); provider = require(providerPath);
} catch (error) { } catch (error) {
console.error("Failed to load plugin stats provider", error); logger.error("Failed to load plugin stats provider", error);
continue; continue;
} }
providers.push({ plugin, manifest, provider }); providers.push({ plugin, manifest, provider });
@ -51,7 +54,7 @@ function buildProfileSection({ plugin, manifest, provider, userId }) {
try { try {
result = provider.getProfileStats({ db, userId, plugin, manifest }); result = provider.getProfileStats({ db, userId, plugin, manifest });
} catch (error) { } catch (error) {
console.error("Failed to load plugin profile stats", error); logger.error("Failed to load plugin profile stats", error);
return null; return null;
} }
const stats = Array.isArray(result?.stats) ? result.stats : []; const stats = Array.isArray(result?.stats) ? result.stats : [];
@ -72,7 +75,7 @@ function buildLeaderboardSection({ plugin, manifest, provider, limit }) {
try { try {
result = provider.getLeaderboards({ db, limit, plugin, manifest }); result = provider.getLeaderboards({ db, limit, plugin, manifest });
} catch (error) { } catch (error) {
console.error("Failed to load plugin leaderboards", error); logger.error("Failed to load plugin leaderboards", error);
return null; return null;
} }
const boards = Array.isArray(result?.boards) ? result.boards : []; const boards = Array.isArray(result?.boards) ? result.boards : [];
@ -112,7 +115,7 @@ async function getAdminDashboardSections() {
actions: normalizeDashboardActions(result.actions) actions: normalizeDashboardActions(result.actions)
}; };
} catch (error) { } catch (error) {
console.error(`Failed to load ${plugin.id} admin dashboard stats`, error); logger.error(`Failed to load ${plugin.id} admin dashboard stats`, error);
return { return {
id: plugin.id, id: plugin.id,
eyebrow: "Companion service", eyebrow: "Companion service",

View File

@ -8,6 +8,11 @@ const { createLogger, listLogs } = require("./logger");
const { getPlugins, scanPluginDirectories } = require("./plugins"); const { getPlugins, scanPluginDirectories } = require("./plugins");
const { readRecoveryMarker } = require("./recovery-mode"); const { readRecoveryMarker } = require("./recovery-mode");
const { getSetting, setSetting } = require("./settings"); const { getSetting, setSetting } = require("./settings");
const {
isLoopbackAddress,
isTrustedProxyAddress,
requestProtocol
} = require("./proxy-security");
const { readUpdateState } = require("./update-repository"); const { readUpdateState } = require("./update-repository");
const repoRoot = path.join(__dirname, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
@ -279,33 +284,11 @@ function isSecureDiagnosticRequest(req) {
} }
function diagnosticsRequestProtocol(req) { function diagnosticsRequestProtocol(req) {
if (req.secure === true || String(req.protocol || "").toLowerCase() === "https") return "https"; return requestProtocol(req);
const forwarded = String(req.get?.("x-forwarded-proto") || "")
.split(",")[0]
.trim()
.toLowerCase();
const proxyAddress = String(req.socket?.remoteAddress || "");
if (forwarded === "https" && isTrustedPrivateProxyAddress(proxyAddress)) return "https";
return "http";
} }
function isTrustedPrivateProxyAddress(value) { function isTrustedPrivateProxyAddress(value) {
const address = String(value || "").trim().toLowerCase().replace(/^::ffff:/, ""); return isTrustedProxyAddress(value);
if (isLoopbackAddress(address)) return true;
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) {
const parts = address.split(".").map(Number);
if (parts.some((part) => part < 0 || part > 255)) return false;
return parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254);
}
return address.startsWith("fc") || address.startsWith("fd") || /^fe[89ab]/.test(address);
}
function isLoopbackAddress(value) {
const address = String(value || "").trim().toLowerCase();
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
} }
function summarizeRecovery(marker) { function summarizeRecovery(marker) {

View File

@ -0,0 +1,83 @@
const net = require("net");
function normalizeAddress(value) {
return String(value || "").trim().toLowerCase().replace(/^::ffff:/, "");
}
function isLoopbackAddress(value) {
const address = normalizeAddress(value);
if (address === "::1") return true;
if (net.isIPv4(address)) return address.startsWith("127.");
return false;
}
function isTrustedProxyAddress(value) {
const address = normalizeAddress(value);
if (isLoopbackAddress(address)) return true;
if (net.isIPv4(address)) {
const parts = address.split(".").map(Number);
return parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254);
}
return address.startsWith("fc") ||
address.startsWith("fd") ||
/^fe[89ab]/.test(address);
}
function header(req, name) {
if (typeof req?.get === "function") return req.get(name);
return req?.headers?.[String(name).toLowerCase()];
}
function forwardedProtocol(req) {
if (!isTrustedProxyAddress(req?.socket?.remoteAddress)) return null;
const value = String(header(req, "x-forwarded-proto") || "")
.split(",")[0]
.trim()
.toLowerCase();
return value === "https" || value === "http" ? value : null;
}
function requestProtocol(req) {
if (
req?.secure === true ||
req?.socket?.encrypted === true ||
String(req?.protocol || "").toLowerCase() === "https"
) {
return "https";
}
return forwardedProtocol(req) || "http";
}
function isSecureRequest(req) {
return requestProtocol(req) === "https";
}
function requestOrigin(req) {
const host = String(header(req, "host") || "").trim();
if (!host || /[\r\n\0]/.test(host)) throw new Error("The request host is invalid.");
const url = new URL(`${requestProtocol(req)}://${host}`);
if (
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash ||
url.host.toLowerCase() !== host.toLowerCase()
) {
throw new Error("The request host is invalid.");
}
return url.origin;
}
module.exports = {
forwardedProtocol,
isLoopbackAddress,
isSecureRequest,
isTrustedProxyAddress,
normalizeAddress,
requestOrigin,
requestProtocol
};

View File

@ -9,6 +9,9 @@ const {
readUpdateState, readUpdateState,
resolveSourceBranch resolveSourceBranch
} = require("./update-repository"); } = require("./update-repository");
const { createLogger } = require("./logger");
const logger = createLogger("updater");
let restartHandler = null; let restartHandler = null;
@ -102,7 +105,7 @@ function requestRestart(options = {}) {
return; return;
} }
Promise.resolve(restartHandler(10)).catch((error) => { Promise.resolve(restartHandler(10)).catch((error) => {
console.error("Graceful restart failed; forcing wrapper restart.", error); logger.error("Graceful restart failed; forcing wrapper restart.", error);
process.exit(10); process.exit(10);
}); });
}, delayMs); }, delayMs);

View File

@ -156,6 +156,7 @@ const {
revokeDiagnosticsAccess, revokeDiagnosticsAccess,
runDiagnosticCheck runDiagnosticCheck
} = require("../services/production-diagnostics"); } = require("../services/production-diagnostics");
const { isTrustedProxyAddress } = require("../services/proxy-security");
const { const {
generateCommandPreview, generateCommandPreview,
previewParts previewParts
@ -3105,10 +3106,9 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
const app = express(); const app = express();
const upgradeRegistry = createWebUpgradeRegistry(); const upgradeRegistry = createWebUpgradeRegistry();
app.locals.lumiUpgradeRegistry = upgradeRegistry; app.locals.lumiUpgradeRegistry = upgradeRegistry;
// Only trust forwarding headers from a reverse proxy on this machine. This // Lumi's TLS terminator can run on the host or an isolated private proxy
// lets the diagnostics endpoint recognize HTTPS without trusting arbitrary // network. Public clients remain unable to forge forwarding headers.
// client-supplied X-Forwarded-Proto headers. app.set("trust proxy", isTrustedProxyAddress);
app.set("trust proxy", "loopback");
const webhooks = createWebhookService(); const webhooks = createWebhookService();
placeholders.registerCorePlaceholders(); placeholders.registerCorePlaceholders();
placeholders.registerPlatformPlaceholders({ placeholders.registerPlatformPlaceholders({
@ -3811,7 +3811,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
: true : true
}; };
} catch (error) { } catch (error) {
console.error(`Assistant panel ${panel.id} availability check failed`, error); webLog.error(`Assistant panel ${panel.id} availability check failed`, error);
panels.push({ ...unavailableAssistantPanel(panel, "availability_check_failed"), debug: panelDebug }); panels.push({ ...unavailableAssistantPanel(panel, "availability_check_failed"), debug: panelDebug });
continue; continue;
} }
@ -3855,7 +3855,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
} }
}); });
} catch (error) { } catch (error) {
console.error(`Assistant panel ${panel.id} render failed`, error); webLog.error(`Assistant panel ${panel.id} render failed`, error);
panel.onRenderDiagnostic?.({ panel.onRenderDiagnostic?.({
panel_endpoint_status: 500, panel_endpoint_status: 500,
panel_html_length: 0, panel_html_length: 0,
@ -4285,7 +4285,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
setFlash(req, "success", "Logged in."); setFlash(req, "success", "Logged in.");
res.redirect("/"); res.redirect("/");
} catch (error) { } catch (error) {
console.error(error); webLog.error(error);
res.status(500).render("error", { res.status(500).render("error", {
title: "Login failed", title: "Login failed",
message: "Discord authentication failed." message: "Discord authentication failed."
@ -4507,7 +4507,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
res.redirect("/profile"); res.redirect("/profile");
} }
} catch (error) { } catch (error) {
console.error(error); webLog.error(error);
res.status(500).render("error", { res.status(500).render("error", {
title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed", title: isLogin ? "Login failed" : isEvent ? "Event connection failed" : "Link failed",
message: isLogin message: isLogin
@ -4670,7 +4670,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
setFlash(req, "success", "YouTube account linked."); setFlash(req, "success", "YouTube account linked.");
res.redirect("/profile"); res.redirect("/profile");
} catch (error) { } catch (error) {
console.error(error); webLog.error(error);
res.status(500).render("error", { res.status(500).render("error", {
title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed", title: isBot ? "Bot connect failed" : isLogin ? "Login failed" : "Link failed",
message: isBot message: isBot
@ -7223,7 +7223,7 @@ function createWebServer({ loadPlugins, discordClient, commandRouter }) {
try { try {
fs.rmSync(plugin.path, { recursive: true, force: true }); fs.rmSync(plugin.path, { recursive: true, force: true });
} catch (error) { } catch (error) {
console.error(error); webLog.error(error);
} }
} }
removePlugin(req.params.id); removePlugin(req.params.id);

View File

@ -1,14 +1,14 @@
{ {
"name": "Lumi Core", "name": "Lumi Core",
"version": "0.3.3", "version": "0.3.4",
"channel": "stable", "channel": "stable",
"released_at": "2026-07-25", "released_at": "2026-07-26",
"compatible_from": "0.1.9", "compatible_from": "0.1.9",
"migration_kind": "patch", "migration_kind": "patch",
"replaces_versions": [ "replaces_versions": [
"1.2.0" "1.2.0"
], ],
"migration_notes": "Replaces the FFmpeg Stream Testing receiver and transcoder with Lumi-managed checksum-pinned MediaMTX source remuxing, authenticated same-origin playback, and factual receiver diagnostics. 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.",
"rollback_safe": true, "rollback_safe": true,
"requirements": [ "requirements": [
"Node.js 18 or newer" "Node.js 18 or newer"
@ -385,6 +385,18 @@
], ],
"rollback_safe": true, "rollback_safe": true,
"migration_notes": "Fixes the managed OBS Bridge streaming-service ownership crash and requires the corrected bridge before Stream Testing. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved." "migration_notes": "Fixes the managed OBS Bridge streaming-service ownership crash and requires the corrected bridge before Stream Testing. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
},
{
"version": "0.3.3",
"channel": "stable",
"released_at": "2026-07-25",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"1.2.0"
],
"rollback_safe": true,
"migration_notes": "Replaces the FFmpeg Stream Testing receiver and transcoder with Lumi-managed checksum-pinned MediaMTX source remuxing, authenticated same-origin playback, and factual receiver diagnostics. Existing settings, databases, pairing records, OBS settings, credentials, overlays, uploads, models, secrets, plugin data, and local-only plugins are preserved."
} }
] ]
} }