2026-03-05 00:04:26 -06:00
|
|
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
|
|
|
import { writeFile, mkdir } from "fs/promises";
|
2026-03-13 15:28:58 -05:00
|
|
|
import { dirname, resolve } from "path";
|
2026-03-05 00:04:26 -06:00
|
|
|
import logger from "./logger";
|
|
|
|
|
import { ensureDataDir } from "./paths";
|
|
|
|
|
|
|
|
|
|
export interface Config {
|
|
|
|
|
auth: {
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
2026-05-08 14:30:39 -05:00
|
|
|
passwordIsUserSupplied: boolean;
|
2026-03-05 00:04:26 -06:00
|
|
|
jwtSecret: string;
|
|
|
|
|
agentToken: string;
|
|
|
|
|
};
|
|
|
|
|
server: {
|
|
|
|
|
port: number;
|
|
|
|
|
host: string;
|
|
|
|
|
};
|
|
|
|
|
tls: {
|
|
|
|
|
certPath: string;
|
|
|
|
|
keyPath: string;
|
|
|
|
|
caPath: string;
|
2026-03-07 15:14:00 -06:00
|
|
|
certbot: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
livePath: string;
|
|
|
|
|
domain: string;
|
|
|
|
|
certFileName: string;
|
|
|
|
|
keyFileName: string;
|
|
|
|
|
caFileName: string;
|
|
|
|
|
};
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
notifications: {
|
|
|
|
|
keywords: string[];
|
|
|
|
|
minIntervalMs: number;
|
|
|
|
|
spamWindowMs: number;
|
|
|
|
|
spamWarnThreshold: number;
|
|
|
|
|
historyLimit: number;
|
|
|
|
|
webhookEnabled: boolean;
|
|
|
|
|
webhookUrl: string;
|
|
|
|
|
telegramEnabled: boolean;
|
|
|
|
|
telegramBotToken: string;
|
|
|
|
|
telegramChatId: string;
|
2026-03-18 23:46:44 -05:00
|
|
|
clipboardEnabled: boolean;
|
2026-05-16 23:19:46 -05:00
|
|
|
antiSpamMaxHits: number;
|
|
|
|
|
antiSpamWindowMs: number;
|
|
|
|
|
antiSpamCooldownMs: number;
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
2026-03-06 20:50:02 -06:00
|
|
|
security: {
|
|
|
|
|
sessionTtlHours: number;
|
|
|
|
|
loginMaxAttempts: number;
|
|
|
|
|
loginWindowMinutes: number;
|
|
|
|
|
loginLockoutMinutes: number;
|
|
|
|
|
passwordMinLength: number;
|
|
|
|
|
passwordRequireUppercase: boolean;
|
|
|
|
|
passwordRequireLowercase: boolean;
|
|
|
|
|
passwordRequireNumber: boolean;
|
|
|
|
|
passwordRequireSymbol: boolean;
|
2026-06-01 16:37:23 -05:00
|
|
|
mfaRequiredForAdmins: boolean;
|
|
|
|
|
mfaRequiredForNonAdmins: boolean;
|
2026-03-06 20:50:02 -06:00
|
|
|
};
|
2026-03-20 22:38:17 -05:00
|
|
|
enrollment: {
|
|
|
|
|
requireApproval: boolean;
|
2026-05-22 10:12:40 -05:00
|
|
|
autoApproveUnlessSuspicious: boolean;
|
2026-03-20 22:38:17 -05:00
|
|
|
};
|
2026-03-25 09:50:41 -05:00
|
|
|
appearance: {
|
|
|
|
|
customCSS: string;
|
|
|
|
|
};
|
2026-04-14 09:56:20 -05:00
|
|
|
plugins: {
|
|
|
|
|
trustedKeys: string[];
|
|
|
|
|
};
|
2026-04-14 21:56:18 -05:00
|
|
|
chat: {
|
|
|
|
|
retentionDays: number;
|
|
|
|
|
};
|
2026-04-19 18:24:50 -05:00
|
|
|
registration: {
|
|
|
|
|
mode: "off" | "open" | "key" | "approval";
|
|
|
|
|
defaultRole: "operator" | "viewer";
|
|
|
|
|
maxUsersTotal: number;
|
2026-05-19 00:46:30 -05:00
|
|
|
defaultGroupIds: number[];
|
2026-04-19 18:24:50 -05:00
|
|
|
};
|
|
|
|
|
buildRateLimit: {
|
|
|
|
|
maxBuildsPerHour: number;
|
|
|
|
|
maxConcurrentPerUser: number;
|
|
|
|
|
globalMaxConcurrent: number;
|
|
|
|
|
};
|
2026-05-09 18:31:53 -05:00
|
|
|
buildSigning: {
|
|
|
|
|
banlist: string[];
|
|
|
|
|
};
|
2026-05-17 22:02:23 -05:00
|
|
|
thumbnails: {
|
|
|
|
|
dashboardEnabled: boolean;
|
|
|
|
|
wallEnabled: boolean;
|
|
|
|
|
};
|
2026-06-10 19:54:18 -05:00
|
|
|
inputArchive: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
retentionDays: number;
|
|
|
|
|
maxFileBytes: number;
|
|
|
|
|
pollIntervalSeconds: number;
|
|
|
|
|
};
|
2026-03-05 00:04:26 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEFAULT_CONFIG: Config = {
|
|
|
|
|
auth: {
|
|
|
|
|
username: "admin",
|
2026-03-06 14:45:15 -06:00
|
|
|
password: "admin",
|
2026-05-08 14:30:39 -05:00
|
|
|
passwordIsUserSupplied: false,
|
2026-03-05 00:04:26 -06:00
|
|
|
jwtSecret: "",
|
|
|
|
|
agentToken: "",
|
|
|
|
|
},
|
|
|
|
|
server: {
|
|
|
|
|
port: 5173,
|
|
|
|
|
host: "0.0.0.0",
|
|
|
|
|
},
|
|
|
|
|
tls: {
|
|
|
|
|
certPath: "./certs/server.crt",
|
|
|
|
|
keyPath: "./certs/server.key",
|
|
|
|
|
caPath: "",
|
2026-03-07 15:14:00 -06:00
|
|
|
certbot: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
livePath: "/etc/letsencrypt/live",
|
|
|
|
|
domain: "",
|
|
|
|
|
certFileName: "fullchain.pem",
|
|
|
|
|
keyFileName: "privkey.pem",
|
|
|
|
|
caFileName: "chain.pem",
|
|
|
|
|
},
|
2026-03-05 00:04:26 -06:00
|
|
|
},
|
|
|
|
|
notifications: {
|
|
|
|
|
keywords: ["bank", "password", "admin"],
|
|
|
|
|
minIntervalMs: 8000,
|
|
|
|
|
spamWindowMs: 60000,
|
|
|
|
|
spamWarnThreshold: 5,
|
|
|
|
|
historyLimit: 200,
|
|
|
|
|
webhookEnabled: false,
|
|
|
|
|
webhookUrl: "",
|
|
|
|
|
telegramEnabled: false,
|
|
|
|
|
telegramBotToken: "",
|
|
|
|
|
telegramChatId: "",
|
2026-03-18 23:46:44 -05:00
|
|
|
clipboardEnabled: false,
|
2026-05-16 23:19:46 -05:00
|
|
|
antiSpamMaxHits: 15,
|
|
|
|
|
antiSpamWindowMs: 600000,
|
|
|
|
|
antiSpamCooldownMs: 600000,
|
2026-03-05 00:04:26 -06:00
|
|
|
},
|
2026-03-06 20:50:02 -06:00
|
|
|
security: {
|
|
|
|
|
sessionTtlHours: 168,
|
|
|
|
|
loginMaxAttempts: 5,
|
|
|
|
|
loginWindowMinutes: 15,
|
|
|
|
|
loginLockoutMinutes: 30,
|
|
|
|
|
passwordMinLength: 6,
|
|
|
|
|
passwordRequireUppercase: false,
|
|
|
|
|
passwordRequireLowercase: false,
|
|
|
|
|
passwordRequireNumber: false,
|
|
|
|
|
passwordRequireSymbol: false,
|
2026-06-01 16:37:23 -05:00
|
|
|
mfaRequiredForAdmins: false,
|
|
|
|
|
mfaRequiredForNonAdmins: false,
|
2026-03-06 20:50:02 -06:00
|
|
|
},
|
2026-03-20 22:38:17 -05:00
|
|
|
enrollment: {
|
|
|
|
|
requireApproval: true,
|
2026-05-22 10:12:40 -05:00
|
|
|
autoApproveUnlessSuspicious: false,
|
2026-03-20 22:38:17 -05:00
|
|
|
},
|
2026-03-25 09:50:41 -05:00
|
|
|
appearance: {
|
|
|
|
|
customCSS: "",
|
|
|
|
|
},
|
2026-04-14 09:56:20 -05:00
|
|
|
plugins: {
|
|
|
|
|
trustedKeys: [],
|
|
|
|
|
},
|
2026-04-14 21:56:18 -05:00
|
|
|
chat: {
|
|
|
|
|
retentionDays: 30,
|
|
|
|
|
},
|
2026-04-19 18:24:50 -05:00
|
|
|
registration: {
|
|
|
|
|
mode: "off" as const,
|
|
|
|
|
defaultRole: "operator" as const,
|
|
|
|
|
maxUsersTotal: 0,
|
2026-05-19 00:46:30 -05:00
|
|
|
defaultGroupIds: [] as number[],
|
2026-04-19 18:24:50 -05:00
|
|
|
},
|
|
|
|
|
buildRateLimit: {
|
|
|
|
|
maxBuildsPerHour: 5,
|
|
|
|
|
maxConcurrentPerUser: 1,
|
|
|
|
|
globalMaxConcurrent: 3,
|
|
|
|
|
},
|
2026-05-09 18:31:53 -05:00
|
|
|
buildSigning: {
|
|
|
|
|
banlist: [],
|
|
|
|
|
},
|
2026-05-17 22:02:23 -05:00
|
|
|
thumbnails: {
|
|
|
|
|
dashboardEnabled: true,
|
|
|
|
|
wallEnabled: true,
|
|
|
|
|
},
|
2026-06-10 19:54:18 -05:00
|
|
|
inputArchive: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
retentionDays: 7,
|
|
|
|
|
maxFileBytes: 5 * 1024 * 1024,
|
|
|
|
|
pollIntervalSeconds: 300,
|
|
|
|
|
},
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
|
2026-06-08 22:33:34 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
type SaveSecrets = {
|
|
|
|
|
auth?: {
|
|
|
|
|
jwtSecret?: string;
|
|
|
|
|
agentToken?: string;
|
|
|
|
|
bootstrapPassword?: string;
|
|
|
|
|
};
|
2026-05-09 18:31:53 -05:00
|
|
|
buildSigning?: {
|
|
|
|
|
privateKey?: string;
|
|
|
|
|
publicKey?: string;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BuildSigningSecrets = {
|
|
|
|
|
privateKey: string;
|
|
|
|
|
publicKey: string;
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 00:04:26 -06:00
|
|
|
export function loadConfig(): Config {
|
|
|
|
|
if (configCache) {
|
|
|
|
|
return configCache;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
const fileConfig = readFileConfigForLoad();
|
2026-03-05 00:04:26 -06:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:18:40 -05:00
|
|
|
const agentTokenFromEnv = process.env.OVERLORD_AGENT_TOKEN?.trim();
|
|
|
|
|
const agentTokenFromConfig = fileConfig.auth?.agentToken?.trim();
|
2026-03-05 00:04:26 -06:00
|
|
|
let finalAgentToken =
|
|
|
|
|
agentTokenFromEnv ||
|
|
|
|
|
agentTokenFromConfig ||
|
2026-04-05 18:18:40 -05:00
|
|
|
savedSecrets.auth?.agentToken?.trim() ||
|
2026-03-05 00:04:26 -06:00
|
|
|
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;
|
2026-05-08 14:30:39 -05:00
|
|
|
const passwordFromSaved = savedSecrets.auth?.bootstrapPassword;
|
2026-03-05 00:04:26 -06:00
|
|
|
let finalBootstrapPassword =
|
|
|
|
|
passwordFromEnv ||
|
|
|
|
|
passwordFromConfig ||
|
2026-05-08 14:30:39 -05:00
|
|
|
passwordFromSaved ||
|
2026-03-05 00:04:26 -06:00
|
|
|
DEFAULT_CONFIG.auth.password;
|
2026-05-08 14:30:39 -05:00
|
|
|
const passwordIsUserSupplied =
|
|
|
|
|
Boolean(passwordFromEnv) ||
|
|
|
|
|
Boolean(passwordFromConfig);
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
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,
|
2026-05-08 14:30:39 -05:00
|
|
|
passwordIsUserSupplied,
|
2026-03-05 00:04:26 -06:00
|
|
|
jwtSecret: finalJwtSecret,
|
|
|
|
|
agentToken: finalAgentToken,
|
|
|
|
|
},
|
|
|
|
|
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,
|
2026-03-07 15:14:00 -06:00
|
|
|
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,
|
|
|
|
|
},
|
2026-03-05 00:04:26 -06:00
|
|
|
},
|
|
|
|
|
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,
|
2026-03-18 23:46:44 -05:00
|
|
|
clipboardEnabled:
|
|
|
|
|
String(process.env.OVERLORD_NOTIFICATION_CLIPBOARD_ENABLED || "").toLowerCase() === "true" ||
|
|
|
|
|
fileConfig.notifications?.clipboardEnabled ||
|
|
|
|
|
DEFAULT_CONFIG.notifications.clipboardEnabled,
|
2026-05-16 23:19:46 -05:00
|
|
|
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,
|
2026-03-05 00:04:26 -06:00
|
|
|
},
|
2026-03-06 20:50:02 -06:00
|
|
|
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,
|
2026-06-01 16:37:23 -05:00
|
|
|
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,
|
2026-03-06 20:50:02 -06:00
|
|
|
},
|
2026-03-20 22:38:17 -05:00
|
|
|
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),
|
2026-05-22 10:12:40 -05:00
|
|
|
autoApproveUnlessSuspicious:
|
|
|
|
|
fileConfig.enrollment?.autoApproveUnlessSuspicious ?? DEFAULT_CONFIG.enrollment.autoApproveUnlessSuspicious,
|
2026-03-20 22:38:17 -05:00
|
|
|
},
|
2026-03-25 09:50:41 -05:00
|
|
|
appearance: {
|
|
|
|
|
customCSS: fileConfig.appearance?.customCSS || DEFAULT_CONFIG.appearance.customCSS,
|
|
|
|
|
},
|
2026-04-14 09:56:20 -05:00
|
|
|
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;
|
|
|
|
|
})(),
|
|
|
|
|
},
|
2026-04-14 21:56:18 -05:00
|
|
|
chat: {
|
|
|
|
|
retentionDays:
|
|
|
|
|
Number(process.env.OVERLORD_CHAT_RETENTION_DAYS) ||
|
|
|
|
|
fileConfig.chat?.retentionDays ||
|
|
|
|
|
DEFAULT_CONFIG.chat.retentionDays,
|
|
|
|
|
},
|
2026-04-19 18:24:50 -05:00
|
|
|
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,
|
2026-05-19 00:46:30 -05:00
|
|
|
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],
|
2026-04-19 18:24:50 -05:00
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
},
|
2026-05-09 18:31:53 -05:00
|
|
|
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],
|
|
|
|
|
},
|
2026-05-17 22:02:23 -05:00
|
|
|
thumbnails: {
|
|
|
|
|
dashboardEnabled:
|
2026-06-08 22:33:34 -05:00
|
|
|
envBoolOverride("OVERLORD_THUMBNAILS_DASHBOARD_ENABLED") ??
|
|
|
|
|
(fileConfig.thumbnails?.dashboardEnabled !== undefined
|
2026-05-17 22:02:23 -05:00
|
|
|
? Boolean(fileConfig.thumbnails.dashboardEnabled)
|
2026-06-08 22:33:34 -05:00
|
|
|
: DEFAULT_CONFIG.thumbnails.dashboardEnabled),
|
2026-05-17 22:02:23 -05:00
|
|
|
wallEnabled:
|
2026-06-08 22:33:34 -05:00
|
|
|
envBoolOverride("OVERLORD_THUMBNAILS_WALL_ENABLED") ??
|
|
|
|
|
(fileConfig.thumbnails?.wallEnabled !== undefined
|
2026-05-17 22:02:23 -05:00
|
|
|
? Boolean(fileConfig.thumbnails.wallEnabled)
|
2026-06-08 22:33:34 -05:00
|
|
|
: DEFAULT_CONFIG.thumbnails.wallEnabled),
|
2026-05-17 22:02:23 -05:00
|
|
|
},
|
2026-06-10 19:54:18 -05:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
},
|
2026-03-05 00:04:26 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
const fileConfig = readFileConfigForUpdate();
|
2026-03-05 00:04:26 -06:00
|
|
|
|
|
|
|
|
fileConfig.notifications = next;
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
await writePersistentFileConfig(fileConfig);
|
2026-03-05 00:04:26 -06:00
|
|
|
return next;
|
|
|
|
|
}
|
2026-03-06 20:50:02 -06:00
|
|
|
|
|
|
|
|
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);
|
2026-06-01 16:37:23 -05:00
|
|
|
next.mfaRequiredForAdmins = Boolean(next.mfaRequiredForAdmins);
|
|
|
|
|
next.mfaRequiredForNonAdmins = Boolean(next.mfaRequiredForNonAdmins);
|
2026-03-06 20:50:02 -06:00
|
|
|
|
|
|
|
|
configCache = {
|
|
|
|
|
...current,
|
|
|
|
|
security: next,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
const fileConfig = readFileConfigForUpdate();
|
2026-03-06 20:50:02 -06:00
|
|
|
|
|
|
|
|
fileConfig.security = next;
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
await writePersistentFileConfig(fileConfig);
|
2026-03-06 20:50:02 -06:00
|
|
|
return next;
|
|
|
|
|
}
|
2026-03-07 15:14:00 -06:00
|
|
|
|
2026-03-20 22:38:17 -05:00
|
|
|
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,
|
2026-05-22 10:12:40 -05:00
|
|
|
autoApproveUnlessSuspicious: updates.autoApproveUnlessSuspicious !== undefined ? Boolean(updates.autoApproveUnlessSuspicious) : current.enrollment.autoApproveUnlessSuspicious,
|
2026-03-20 22:38:17 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
configCache = {
|
|
|
|
|
...current,
|
|
|
|
|
enrollment: next,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fileConfig = readFileConfigForUpdate();
|
|
|
|
|
fileConfig.enrollment = next;
|
|
|
|
|
await writePersistentFileConfig(fileConfig);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 15:14:00 -06:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
const fileConfig = readFileConfigForUpdate();
|
2026-03-07 15:14:00 -06:00
|
|
|
|
|
|
|
|
fileConfig.tls = next;
|
|
|
|
|
|
2026-03-13 15:28:58 -05:00
|
|
|
await writePersistentFileConfig(fileConfig);
|
2026-03-07 15:14:00 -06:00
|
|
|
return next;
|
|
|
|
|
}
|
2026-03-25 09:50:41 -05:00
|
|
|
|
|
|
|
|
export async function updateAppearanceConfig(customCSS: string): Promise<Config["appearance"]> {
|
|
|
|
|
const current = getConfig();
|
|
|
|
|
const next = { customCSS: String(customCSS || "").slice(0, 51200) };
|
|
|
|
|
|
|
|
|
|
configCache = {
|
|
|
|
|
...current,
|
|
|
|
|
appearance: next,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fileConfig = readFileConfigForUpdate();
|
|
|
|
|
fileConfig.appearance = next;
|
|
|
|
|
await writePersistentFileConfig(fileConfig);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
2026-03-25 10:18:22 -05:00
|
|
|
|
2026-04-14 09:56:20 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 21:56:18 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:54:18 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 18:24:50 -05:00
|
|
|
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));
|
2026-05-19 00:46:30 -05:00
|
|
|
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];
|
2026-04-19 18:24:50 -05:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 18:31:53 -05:00
|
|
|
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 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 10:18:22 -05:00
|
|
|
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
|
|
|
|
|
const config = getConfig();
|
2026-05-09 18:31:53 -05:00
|
|
|
const buildSigningSecrets = getBuildSigningSecrets();
|
2026-03-25 10:18:22 -05:00
|
|
|
return {
|
|
|
|
|
_meta: {
|
|
|
|
|
exportedAt: new Date().toISOString(),
|
|
|
|
|
version: serverVersion,
|
|
|
|
|
},
|
|
|
|
|
auth: {
|
|
|
|
|
jwtSecret: config.auth.jwtSecret,
|
|
|
|
|
agentToken: config.auth.agentToken,
|
|
|
|
|
},
|
|
|
|
|
notifications: config.notifications,
|
|
|
|
|
security: config.security,
|
|
|
|
|
tls: config.tls,
|
|
|
|
|
enrollment: config.enrollment,
|
|
|
|
|
appearance: config.appearance,
|
2026-04-14 09:56:20 -05:00
|
|
|
plugins: config.plugins,
|
2026-04-14 21:56:18 -05:00
|
|
|
chat: config.chat,
|
2026-04-19 18:24:50 -05:00
|
|
|
registration: config.registration,
|
|
|
|
|
buildRateLimit: config.buildRateLimit,
|
2026-05-09 18:31:53 -05:00
|
|
|
buildSigning: {
|
|
|
|
|
privateKey: buildSigningSecrets?.privateKey || "",
|
|
|
|
|
publicKey: buildSigningSecrets?.publicKey || "",
|
|
|
|
|
banlist: config.buildSigning.banlist,
|
|
|
|
|
},
|
2026-05-17 22:02:23 -05:00
|
|
|
thumbnails: config.thumbnails,
|
2026-06-10 19:54:18 -05:00
|
|
|
inputArchive: config.inputArchive,
|
2026-03-25 10:18:22 -05:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
await updateAppearanceConfig(css);
|
|
|
|
|
applied.push("appearance");
|
|
|
|
|
} else {
|
|
|
|
|
warnings.push("Custom CSS exceeds 50 KB limit and was skipped.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 09:56:20 -05:00
|
|
|
if (data.plugins && typeof data.plugins === "object") {
|
|
|
|
|
await updatePluginsConfig(data.plugins);
|
|
|
|
|
applied.push("plugins");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 21:56:18 -05:00
|
|
|
if (data.chat && typeof data.chat === "object") {
|
|
|
|
|
await updateChatConfig(data.chat);
|
|
|
|
|
applied.push("chat");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 18:24:50 -05:00
|
|
|
if (data.registration && typeof data.registration === "object") {
|
|
|
|
|
await updateRegistrationConfig(data.registration);
|
|
|
|
|
applied.push("registration");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.buildRateLimit && typeof data.buildRateLimit === "object") {
|
|
|
|
|
await updateBuildRateLimitConfig(data.buildRateLimit);
|
|
|
|
|
applied.push("buildRateLimit");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:02:23 -05:00
|
|
|
if (data.thumbnails && typeof data.thumbnails === "object") {
|
|
|
|
|
await updateThumbnailsConfig(data.thumbnails);
|
|
|
|
|
applied.push("thumbnails");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:54:18 -05:00
|
|
|
if (data.inputArchive && typeof data.inputArchive === "object") {
|
|
|
|
|
await updateInputArchiveConfig(data.inputArchive);
|
|
|
|
|
applied.push("inputArchive");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 10:18:22 -05:00
|
|
|
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.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 18:31:53 -05:00
|
|
|
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)",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 10:18:22 -05:00
|
|
|
return { applied, warnings };
|
|
|
|
|
}
|