diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index 1582ca2..647d95b 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -243,6 +243,27 @@ async function loadCurrentUser() { usersLink?.classList.remove("hidden"); } + if (currentUser.role === "admin" && !localStorage.getItem("overlord_settings_exported")) { + localStorage.setItem("overlord_settings_exported", "1"); + try { + const expRes = await fetch("/api/settings/export", { credentials: "include" }); + if (expRes.ok) { + const blob = await expRes.blob(); + const disposition = expRes.headers.get("Content-Disposition") || ""; + const match = disposition.match(/filename="?([^"]+)"?/); + const filename = match ? match[1] : "overlord-settings.json"; + const dlUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = dlUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(dlUrl); + } + } catch {} + } + if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) { buildLink?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index 0665993..8974d3a 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -61,6 +61,11 @@ const appearancePermissionNote = document.getElementById("appearance-permission- const appearanceSaveBtn = document.getElementById("appearance-save-btn"); const appearanceCustomCssInput = document.getElementById("appearance-custom-css"); +const exportImportSection = document.getElementById("export-import-section"); +const exportSettingsBtn = document.getElementById("export-settings-btn"); +const importSettingsFile = document.getElementById("import-settings-file"); +const exportImportMessage = document.getElementById("export-import-message"); + let currentUser = null; let securityConfig = null; let tlsConfig = null; @@ -665,10 +670,119 @@ async function saveAppearanceSettings(event) { showMessage("Custom CSS saved. Reload the page to apply the new styles."); } +function showExportImportMessage(text, type = "ok") { + if (!exportImportMessage) return; + exportImportMessage.textContent = text; + exportImportMessage.classList.remove( + "hidden", + "text-emerald-200", "border-emerald-700", "bg-emerald-900/30", + "text-rose-200", "border-rose-700", "bg-rose-900/30", + "text-amber-200", "border-amber-700", "bg-amber-900/30", + ); + + if (type === "error") { + exportImportMessage.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30"); + } else if (type === "warning") { + exportImportMessage.classList.add("text-amber-200", "border-amber-700", "bg-amber-900/30"); + } else { + exportImportMessage.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30"); + } +} + +async function exportSettings() { + try { + const res = await fetch("/api/settings/export", { credentials: "include" }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + showExportImportMessage(data.error || "Failed to export settings.", "error"); + return; + } + + const blob = await res.blob(); + const disposition = res.headers.get("Content-Disposition") || ""; + const filenameMatch = disposition.match(/filename="?([^"]+)"?/); + const filename = filenameMatch ? filenameMatch[1] : "overlord-settings.json"; + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showExportImportMessage("Settings exported successfully."); + } catch (error) { + showExportImportMessage(`Export failed: ${String(error?.message || error)}`, "error"); + } +} + +async function importSettings(event) { + const file = event.target.files?.[0]; + if (!file) return; + + event.target.value = ""; + + if (file.size > 512 * 1024) { + showExportImportMessage("File too large (max 512 KB).", "error"); + return; + } + + let data; + try { + const text = await file.text(); + data = JSON.parse(text); + } catch { + showExportImportMessage("Invalid JSON file.", "error"); + return; + } + + if (!data || typeof data !== "object") { + showExportImportMessage("File does not contain a valid settings object.", "error"); + return; + } + + if (!confirm("Import settings from this file? This will overwrite your current configuration.")) { + return; + } + + try { + const res = await fetch("/api/settings/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(data), + }); + + const result = await res.json().catch(() => ({})); + if (!res.ok) { + showExportImportMessage(result.error || "Import failed.", "error"); + return; + } + + const appliedStr = result.applied?.length ? result.applied.join(", ") : "nothing"; + const warningStr = result.warnings?.length ? " \u26A0 " + result.warnings.join(" ") : ""; + const msgType = result.warnings?.length ? "warning" : "ok"; + showExportImportMessage(`Imported: ${appliedStr}.${warningStr}`, msgType); + + await loadSecurityPolicy(); + await loadTlsSettings(); + await loadAppearanceSettings(); + } catch (error) { + showExportImportMessage(`Import failed: ${String(error?.message || error)}`, "error"); + } +} + async function init() { try { await loadCurrentUser(); loadPrefs(); + + if (isAdmin(currentUser?.role) && exportImportSection) { + exportImportSection.classList.remove("hidden"); + } + await loadSecurityPolicy(); await loadTlsSettings(); await loadAppearanceSettings(); @@ -680,6 +794,8 @@ async function init() { tlsForm.addEventListener("submit", saveTlsSettings); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings); + if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings); + if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings); refreshBansBtn.addEventListener("click", loadBannedIps); bansTableBody.addEventListener("click", handleUnbanClick); } catch (error) { diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 6c8df14..4af3e57 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -54,6 +54,53 @@ + +
diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts index c8f7de0..2b10265 100644 --- a/Overlord-Server/src/config.ts +++ b/Overlord-Server/src/config.ts @@ -615,3 +615,102 @@ export async function updateAppearanceConfig(customCSS: string): Promise { + const config = getConfig(); + 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, + }; +} + +export async function importFullConfig(data: Record): Promise<{ applied: string[]; warnings: string[] }> { + const applied: string[] = []; + const warnings: string[] = []; + + const envOverrides: Record = { + 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."); + } + } + + 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."); + } + } + } + + return { applied, warnings }; +} diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index 4194298..8c58936 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -1,6 +1,6 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; -import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig } from "../../config"; +import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfig, getExportableConfig, importFullConfig } from "../../config"; import { getClientMetricsSummary } from "../../db"; import { metrics } from "../../metrics"; import { requirePermission } from "../../rbac"; @@ -397,6 +397,84 @@ export async function handleMiscRoutes( ); } + if (req.method === "GET" && url.pathname === "/api/settings/export") { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + const exportData = getExportableConfig(deps.SERVER_VERSION); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: "Exported settings", + success: true, + }); + + const dateStr = new Date().toISOString().slice(0, 10); + return new Response(JSON.stringify(exportData, null, 2), { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="overlord-settings-${dateStr}.json"`, + ...deps.CORS_HEADERS, + }, + }); + } + + if (req.method === "POST" && url.pathname === "/api/settings/import") { + const user = await authenticateRequest(req); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + if (user.role !== "admin") { + return new Response("Forbidden: Admin access required", { status: 403 }); + } + + let body: any = {}; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body || typeof body !== "object") { + return Response.json({ error: "Expected a JSON object" }, { status: 400 }); + } + + try { + const result = await importFullConfig(body); + + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: `Imported settings: ${result.applied.join(", ") || "none"}`, + success: true, + }); + + return Response.json({ ok: true, applied: result.applied, warnings: result.warnings }, { headers: deps.CORS_HEADERS }); + } catch (error: any) { + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: "Settings import failed", + success: false, + errorMessage: String(error?.message || error), + }); + + return Response.json({ ok: false, error: String(error?.message || "Import failed") }, { status: 400, headers: deps.CORS_HEADERS }); + } + } + if (url.pathname === "/api/settings/appearance") { const user = await authenticateRequest(req); if (!user) {