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

555 lines
17 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;
jwtSecret: string;
agentToken: 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;
};
security: {
sessionTtlHours: number;
loginMaxAttempts: number;
loginWindowMinutes: number;
loginLockoutMinutes: number;
passwordMinLength: number;
passwordRequireUppercase: boolean;
passwordRequireLowercase: boolean;
passwordRequireNumber: boolean;
passwordRequireSymbol: boolean;
};
}
const DEFAULT_CONFIG: Config = {
auth: {
username: "admin",
password: "admin",
jwtSecret: "",
agentToken: "",
},
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: "",
},
security: {
sessionTtlHours: 168,
loginMaxAttempts: 5,
loginWindowMinutes: 15,
loginLockoutMinutes: 30,
passwordMinLength: 6,
passwordRequireUppercase: false,
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
},
};
type SaveSecrets = {
auth?: {
jwtSecret?: string;
agentToken?: string;
bootstrapPassword?: 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;
const agentTokenFromConfig = fileConfig.auth?.agentToken;
let finalAgentToken =
agentTokenFromEnv ||
agentTokenFromConfig ||
savedSecrets.auth?.agentToken ||
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;
let finalBootstrapPassword =
passwordFromEnv ||
passwordFromConfig ||
savedSecrets.auth?.bootstrapPassword ||
DEFAULT_CONFIG.auth.password;
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,
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,
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,
},
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,
},
};
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);
configCache = {
...current,
security: next,
};
const fileConfig = readFileConfigForUpdate();
fileConfig.security = 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;
}