import + export settings and always export settings if never done before.

This commit is contained in:
KingKDot
2026-03-25 10:18:22 -05:00
parent 63e86be231
commit c815d7174d
5 changed files with 362 additions and 1 deletions
+21
View File
@@ -243,6 +243,27 @@ async function loadCurrentUser() {
usersLink?.classList.remove("hidden"); 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) { if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) {
buildLink?.classList.remove("hidden"); buildLink?.classList.remove("hidden");
} }
+116
View File
@@ -61,6 +61,11 @@ const appearancePermissionNote = document.getElementById("appearance-permission-
const appearanceSaveBtn = document.getElementById("appearance-save-btn"); const appearanceSaveBtn = document.getElementById("appearance-save-btn");
const appearanceCustomCssInput = document.getElementById("appearance-custom-css"); 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 currentUser = null;
let securityConfig = null; let securityConfig = null;
let tlsConfig = null; let tlsConfig = null;
@@ -665,10 +670,119 @@ async function saveAppearanceSettings(event) {
showMessage("Custom CSS saved. Reload the page to apply the new styles."); 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() { async function init() {
try { try {
await loadCurrentUser(); await loadCurrentUser();
loadPrefs(); loadPrefs();
if (isAdmin(currentUser?.role) && exportImportSection) {
exportImportSection.classList.remove("hidden");
}
await loadSecurityPolicy(); await loadSecurityPolicy();
await loadTlsSettings(); await loadTlsSettings();
await loadAppearanceSettings(); await loadAppearanceSettings();
@@ -680,6 +794,8 @@ async function init() {
tlsForm.addEventListener("submit", saveTlsSettings); tlsForm.addEventListener("submit", saveTlsSettings);
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup); tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings); if (appearanceForm) appearanceForm.addEventListener("submit", saveAppearanceSettings);
if (exportSettingsBtn) exportSettingsBtn.addEventListener("click", exportSettings);
if (importSettingsFile) importSettingsFile.addEventListener("change", importSettings);
refreshBansBtn.addEventListener("click", loadBannedIps); refreshBansBtn.addEventListener("click", loadBannedIps);
bansTableBody.addEventListener("click", handleUnbanClick); bansTableBody.addEventListener("click", handleUnbanClick);
} catch (error) { } catch (error) {
+47
View File
@@ -54,6 +54,53 @@
</div> </div>
</section> </section>
<section id="export-import-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
<i class="fa-solid fa-file-export text-amber-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Export &amp; Import Settings</h2>
<p class="text-sm text-slate-400">Backup or restore your Overlord configuration (notifications, security, TLS, appearance, auth tokens).</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Export</strong> &mdash; Downloads a JSON file with all current settings and auth tokens.
</p>
<p class="text-xs text-slate-500">Keep this file secure &mdash; it contains sensitive tokens.</p>
</div>
<button
id="export-settings-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-600 hover:bg-amber-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-download"></i>
Export Settings
</button>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Import</strong> &mdash; Restore settings from a previously exported JSON file.
</p>
<p class="text-xs text-slate-500">Auth token changes require a server restart. Environment variables will still take priority.</p>
</div>
<label
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white text-sm font-medium whitespace-nowrap cursor-pointer"
>
<i class="fa-solid fa-upload"></i>
Import Settings
<input id="import-settings-file" type="file" accept=".json,application/json" class="hidden" />
</label>
</div>
<p id="export-import-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6"> <section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4"> <div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div> <div>
+99
View File
@@ -615,3 +615,102 @@ export async function updateAppearanceConfig(customCSS: string): Promise<Config[
await writePersistentFileConfig(fileConfig); await writePersistentFileConfig(fileConfig);
return next; return next;
} }
export function getExportableConfig(serverVersion: string): Record<string, unknown> {
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<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.");
}
}
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 };
}
@@ -1,6 +1,6 @@
import { authenticateRequest } from "../../auth"; import { authenticateRequest } from "../../auth";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; 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 { getClientMetricsSummary } from "../../db";
import { metrics } from "../../metrics"; import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac"; 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") { if (url.pathname === "/api/settings/appearance") {
const user = await authenticateRequest(req); const user = await authenticateRequest(req);
if (!user) { if (!user) {