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

446 lines
13 KiB
TypeScript
Raw Normal View History

2026-03-05 00:04:26 -06:00
import { existsSync, readFileSync, writeFileSync } from "fs";
import { writeFile, mkdir } from "fs/promises";
import { 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;
};
notifications: {
keywords: string[];
minIntervalMs: number;
spamWindowMs: number;
spamWarnThreshold: number;
historyLimit: number;
webhookEnabled: boolean;
webhookUrl: string;
telegramEnabled: boolean;
telegramBotToken: string;
telegramChatId: string;
};
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-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-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: "",
},
notifications: {
keywords: ["bank", "password", "admin"],
minIntervalMs: 8000,
spamWindowMs: 60000,
spamWarnThreshold: 5,
historyLimit: 200,
webhookEnabled: false,
webhookUrl: "",
telegramEnabled: false,
telegramBotToken: "",
telegramChatId: "",
},
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-03-05 00:04:26 -06:00
};
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;
export function loadConfig(): Config {
if (configCache) {
return configCache;
}
let fileConfig: Partial<Config> = {};
const dataDir = ensureDataDir();
const savePath = resolve(dataDir, "save.json");
const savedSecrets = loadSaveSecrets(savePath);
let saveChanged = false;
const configPath = resolve(process.cwd(), "config.json");
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(content);
logger.info("Loaded configuration from config.json");
} catch (error) {
logger.warn("Failed to parse config.json, using defaults:", error);
}
} else {
logger.info(
"No config.json found, using defaults and environment variables",
);
}
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,
},
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-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-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,
};
const configPath = resolve(process.cwd(), "config.json");
let fileConfig: any = {};
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(content) || {};
} catch {
fileConfig = {};
}
}
fileConfig.notifications = next;
try {
await mkdir(resolve(process.cwd()), { recursive: true });
} catch {}
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
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);
configCache = {
...current,
security: next,
};
const configPath = resolve(process.cwd(), "config.json");
let fileConfig: any = {};
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(content) || {};
} catch {
fileConfig = {};
}
}
fileConfig.security = next;
try {
await mkdir(resolve(process.cwd()), { recursive: true });
} catch {}
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
return next;
}