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-*
DEVNOTES.md
.QWEN.md
_*_changes.md
lumi_current.zip

View File

@ -1,5 +1,12 @@
# 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
- 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
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
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.
## Runtime
Package: lumi-bot
Version: 0.3.3
Version: 0.3.4
## Routes
- POST /api/diagnostics/v1/run
- GET /api/events

4
package-lock.json generated
View File

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

View File

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

View File

@ -3,6 +3,9 @@ const fs = require("fs");
const ejs = require("ejs");
const { Permissions } = require("discord.js");
const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("auto-vc");
const PLUGIN_ID = "auto-vc";
const DEFAULT_TEMPLATE = "[username]'s room";
@ -127,7 +130,7 @@ module.exports = {
const attach = () => {
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) => {
handleVoiceStateUpdate(oldState, newState, db, settings, state);
@ -580,7 +583,7 @@ function handleVoiceStateUpdate(oldState, newState, db, settings, state) {
if (lobby && newState.channelId !== oldState.channelId) {
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 {
await channel.delete("Auto VC cleanup");
} 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);
return true;
} catch (error) {
console.error("Failed to move member", error);
logger.error("Failed to move member", error);
try {
await member.edit({ channel: channel.id });
return true;
} catch (fallbackError) {
console.error("Fallback move failed", fallbackError);
logger.error("Fallback move failed", fallbackError);
return false;
}
}
@ -1378,7 +1381,7 @@ function startNameRefreshTimer(discordClient, db, settings, state) {
}
const runSweep = () => {
refreshRoomNames(discordClient, db, settings, state).catch((error) => {
console.error("Auto VC name refresh failed", error);
logger.error("Auto VC name refresh failed", error);
});
};
runSweep();
@ -1408,7 +1411,7 @@ async function refreshRoomNames(discordClient, db, settings, state) {
const desiredName = buildRoomName(room.name_template, member, room.room_number, gameName);
if (desiredName && desiredName !== channel.name) {
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 = () => {
sweepRooms(discordClient, db, state).catch((error) => {
console.error("Auto VC sweep failed", error);
logger.error("Auto VC sweep failed", error);
});
};
runSweep();

View File

@ -1,5 +1,8 @@
const crypto = require("crypto");
const path = require("path");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("birthday");
const PLUGIN_ID = "birthday";
const TIMER_KEY = Symbol.for("lumi.birthday.interval");
@ -539,9 +542,9 @@ function restartScheduler({ db, discordClient }) {
if (!config.enabled) {
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(() => {
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);
}

View File

@ -6,11 +6,14 @@ const express = require("express");
const multer = require("multer");
const EventEmitter = require("events");
const { ensureUserForIdentity } = require("../../src/services/users");
const { createLogger } = require("../../src/services/logger");
const {
cleanupUploadedFiles,
validateUploadedFile
} = require("../../src/services/upload-security");
const logger = createLogger("economy-framework");
const PLUGIN_ID = "economy-framework";
const LEGACY_STEM = ["echo", "nomy"].join("");
const LEGACY_PLUGIN_ID = `${LEGACY_STEM}-framework`;
@ -1423,7 +1426,7 @@ function startActivityRewardFlusher(db) {
try {
flushActivityRewards(db);
} catch (error) {
console.error("Activity reward flush failed", error);
logger.error("Activity reward flush failed", error);
}
}, 60 * 1000);
}
@ -1489,7 +1492,7 @@ function flushActivityRewards(db) {
"DELETE FROM economy_activity_reward_hourly WHERE user_id = ? AND hour_start = ?"
).run(group.userId, group.hourStart);
} 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 path = require("path");
const { createLogger } = require("../../src/services/logger");
const logger = createLogger("expression-interaction");
const DEFAULT_ACTIONS = [
{ id: "hug", verb: "hugs", past: "hugged" },
@ -672,7 +675,7 @@ function writeCommandsManifest(config) {
const target = path.join(pluginMeta.dir, "cmds.json");
fs.writeFileSync(target, JSON.stringify(manifest, null, 2), "utf8");
} 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 express = require("express");
const { writeJsonAtomicSync } = require("../../src/services/safe-files");
const { createLogger } = require("../../src/services/logger");
const { ensureDataDirs, resolveData } = require("./backend/paths");
const { getConfig, saveConfig, getRuntimeState } = require("./backend/config_manager");
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 PLUGIN_ID = "lumi_ai";
const logger = createLogger(`plugin:${PLUGIN_ID}`, { category: "plugin" });
const TOKEN_PRESETS = Object.freeze([
{ 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." },
@ -63,7 +65,7 @@ module.exports = {
ensureDataDirs();
if (!repoIndexer.loadIndex()) {
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();
metrics.configureRetention(config.work_history_retention);
@ -374,7 +376,7 @@ module.exports = {
sanityCheckSize("Installed model", modelFileSize, 100 * 1024 ** 3),
sanityCheckSize("Estimated GPU memory", bytesFromMb(gpuAllocation.estimated_gpu_memory_mb), 100 * 1024 ** 3)
].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) => ({
...model,
downloaded: fs.existsSync(resolveData("models", model.filename)),
@ -1651,7 +1653,7 @@ module.exports = {
locals: { endpoint: `/plugins/${PLUGIN_ID}` }
});
} 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);
registerAssistantCommands({
@ -1666,17 +1668,17 @@ module.exports = {
});
writeCommandsManifest(plugin?.dir || __dirname, config);
setImmediate(() => toolManager.loadEnabled().catch((error) =>
console.error("Lumi AI tool loader failed", error)
logger.error("Lumi AI tool loader failed", error)
));
if (config.enabled) {
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();
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 () => {
@ -1799,7 +1801,7 @@ function deniedImprovement(req, res) {
return sendImprovementError(req, res, 403, "Improvement Center access is not enabled for this account.");
}
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),
action: cleanText(req.body?.action, 30),
user_id: cleanText(req.session?.user?.id, 100),

View File

@ -1,6 +1,10 @@
const { WebSocketServer, WebSocket } = require("ws");
const { MAX_JSON_BYTES, MAX_AUDIO_PAYLOAD_BYTES, AUDIO_HEADER_BYTES, parseEnvelope, validateHello, parseAudioFrame, envelope } = require("./protocol");
const { insecureDeviceAllowed } = require("./device_store");
const {
isSecureRequest,
requestOrigin
} = require("../../../../src/services/proxy-security");
const MAX_CONTROL_MESSAGES_PER_SECOND = 120;
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));
}
upgrade(request, socket, head) {
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
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 secure = isSecureRequest(request);
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);
const requestOrigin = `http://${request.headers.host || "invalid"}`;
if (!secure && !insecureDeviceAllowed(auth.device, requestOrigin, request.socket.remoteAddress)) return reject(socket, 426, "tls_required");
const origin = request.headers.origin;
if (origin && !sameHostOrigin(origin, request.headers.host)) return reject(socket, 403, "origin_rejected");
let requestOriginValue;
try {
requestOriginValue = requestOrigin(request);
} 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));
}
connection(socket, _request, device) {

View File

@ -2,6 +2,10 @@ const express = require("express");
const ejs = require("ejs");
const fs = require("fs");
const path = require("path");
const {
isSecureRequest,
requestOrigin
} = require("../../src/services/proxy-security");
const { DeviceStore } = require("./backend/companion/device_store");
const { insecureDeviceAllowed, sameLocalhostOrigin } = require("./backend/companion/device_store");
const { CompanionGateway } = require("./backend/companion/gateway");
@ -98,25 +102,62 @@ module.exports = {
runtime: runtimeManifest
}));
router.post("/api/pairing-package", requireAdmin, (req, res) => {
let host = "";
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 });
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) };
res.set("Cache-Control", "no-store");
res.attachment(`lumi-companion-${pairing.pairing_id}.lumi-pairing.json`);
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) => {
let host = "";
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 });
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 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.attachment(bundle.filename);
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) => {
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) {
const parse = (value) => {
const [version] = String(value || "0").split("+", 1);
@ -405,6 +446,6 @@ async function renderLumiPage(locals) {
]);
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 requestHost(req) { return `${req.protocol === "https" ? "https" : "http"}://${req.get("host")}`; }
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 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 requestOrigin(req); }
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 { BenchmarkStore, metricStats } = require("../backend/tests/benchmark_store");
const plugin = require("../index");
const {
isSecureRequest,
requestOrigin
} = require("../../../src/services/proxy-security");
const { createWebUpgradeRegistry } = require("../../../src/services/web-upgrades");
async function run() {
@ -32,6 +36,7 @@ async function run() {
verifyProtocol();
verifyPairingAndRevocation();
verifyLocalhostTransportPolicy();
verifyProxiedHttpsTransport();
verifyCompanionVersionOrdering();
verifyWorkerResolution(temp);
verifyRevisions();
@ -53,6 +58,43 @@ async function run() {
} 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() {
const view = fs.readFileSync(path.join(__dirname, "../views/settings.ejs"), "utf8");
const client = fs.readFileSync(path.join(__dirname, "../public/transcription.js"), "utf8");

View File

@ -2,6 +2,38 @@
"schema_version": 1,
"channel": "stable",
"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",
"ref": "refs/tags/v0.3.3",

View File

@ -5,6 +5,7 @@ const path = require("path");
const root = path.join(__dirname, "..");
require("../src/services/db").migrate();
const diagnostics = require("../src/services/production-diagnostics");
const proxySecurity = require("../src/services/proxy-security");
assert.deepEqual(Object.keys(diagnostics.CHECKS), [
"system_health",
@ -35,6 +36,28 @@ assert.equal(diagnostics.isTrustedPrivateProxyAddress("203.0.113.10"), false);
assert.equal(diagnostics.diagnosticsRequestProtocol(proxiedRequest), "https");
assert.equal(diagnostics.isSecureDiagnosticRequest(proxiedRequest), true);
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({
token: "top-secret",
@ -82,7 +105,8 @@ const endpointIndex = serverSource.indexOf('app.post("/api/diagnostics/v1/run"')
const configuredIndex = serverSource.indexOf("app.use(requireConfigured)");
assert(endpointIndex > 0 && endpointIndex < configuredIndex, "diagnostics endpoint must remain available for production recovery");
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\.post\("\/admin\/diagnostics\/access\/renew", 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 root = path.join(__dirname, "..");
const releaseVersion = "0.3.3";
const previousStableVersion = "0.3.2";
const priorStableVersion = "0.3.1";
const releaseVersion = "0.3.4";
const previousStableVersion = "0.3.3";
const priorStableVersion = "0.3.2";
const earliestCompatibleCoreVersion = "0.1.9";
const introducedPlugins = {
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(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 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");
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.3");
assert.equal(currentRelease.version, "0.3.3");
assert.equal(packageVersion, "0.3.4");
assert.equal(currentRelease.version, "0.3.4");
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.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.2", ref: "refs/tags/v0.3.2", 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"
});
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.blocked, false);

View File

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

View File

@ -8,6 +8,11 @@ const { createLogger, listLogs } = require("./logger");
const { getPlugins, scanPluginDirectories } = require("./plugins");
const { readRecoveryMarker } = require("./recovery-mode");
const { getSetting, setSetting } = require("./settings");
const {
isLoopbackAddress,
isTrustedProxyAddress,
requestProtocol
} = require("./proxy-security");
const { readUpdateState } = require("./update-repository");
const repoRoot = path.join(__dirname, "..", "..");
@ -279,33 +284,11 @@ function isSecureDiagnosticRequest(req) {
}
function diagnosticsRequestProtocol(req) {
if (req.secure === true || String(req.protocol || "").toLowerCase() === "https") return "https";
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";
return requestProtocol(req);
}
function isTrustedPrivateProxyAddress(value) {
const address = String(value || "").trim().toLowerCase().replace(/^::ffff:/, "");
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";
return isTrustedProxyAddress(value);
}
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,
resolveSourceBranch
} = require("./update-repository");
const { createLogger } = require("./logger");
const logger = createLogger("updater");
let restartHandler = null;
@ -102,7 +105,7 @@ function requestRestart(options = {}) {
return;
}
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);
});
}, delayMs);

View File

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

View File

@ -1,14 +1,14 @@
{
"name": "Lumi Core",
"version": "0.3.3",
"version": "0.3.4",
"channel": "stable",
"released_at": "2026-07-25",
"released_at": "2026-07-26",
"compatible_from": "0.1.9",
"migration_kind": "patch",
"replaces_versions": [
"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,
"requirements": [
"Node.js 18 or newer"
@ -385,6 +385,18 @@
],
"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."
},
{
"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."
}
]
}