enhance config interpolation and validation for admin user setup

This commit is contained in:
Jakub Trávník 2025-12-02 10:07:14 +01:00
parent 34123becd0
commit a4d1cc0791

View file

@ -12,39 +12,45 @@ import { BackupExecutionJob } from "../../jobs/backup-execution";
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions"; import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
export const startup = async () => { export const startup = async () => {
let configFileVolumes = []; let configFileVolumes = [];
let configFileRepositories = []; let configFileRepositories = [];
let configFileBackupSchedules = []; let configFileBackupSchedules = [];
let configFileNotificationDestinations = []; let configFileNotificationDestinations = [];
let configFileAdmin = null; let configFileAdmin = null;
try { try {
const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json"; const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json";
const fs = await import("node:fs/promises"); const fs = await import("node:fs/promises");
const path = await import("node:path"); const path = await import("node:path");
const configFullPath = path.resolve(process.cwd(), configPath); const configFullPath = path.resolve(process.cwd(), configPath);
if (await fs.stat(configFullPath).then(() => true, () => false)) { if (await fs.stat(configFullPath).then(() => true, () => false)) {
const raw = await fs.readFile(configFullPath, "utf-8"); const raw = await fs.readFile(configFullPath, "utf-8");
const config = JSON.parse(raw); const config = JSON.parse(raw);
function interpolate(obj) { function interpolate(obj) {
if (typeof obj === "string") { if (typeof obj === "string") {
return obj.replace(/\$\{([^}]+)\}/g, (_, v) => process.env[v] || ""); return obj.replace(/\$\{([^}]+)\}/g, (_, v) => {
} else if (Array.isArray(obj)) { if (process.env[v] === undefined) {
return obj.map(interpolate); logger.warn(`Environment variable '${v}' is not defined. Replacing with empty string.`);
} else if (obj && typeof obj === "object") { return "";
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, interpolate(v)])); }
} return process.env[v];
return obj; });
} else if (Array.isArray(obj)) {
return obj.map(interpolate);
} else if (obj && typeof obj === "object") {
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, interpolate(v)]));
} }
configFileVolumes = interpolate(config.volumes || []); return obj;
configFileRepositories = interpolate(config.repositories || []);
configFileBackupSchedules = interpolate(config.backupSchedules || []);
configFileNotificationDestinations = interpolate(config.notificationDestinations || []);
configFileAdmin = interpolate(config.admin || null);
} }
} catch (e) { configFileVolumes = interpolate(config.volumes || []);
logger.warn(`No config file loaded or error parsing config: ${e.message}`); configFileRepositories = interpolate(config.repositories || []);
configFileBackupSchedules = interpolate(config.backupSchedules || []);
configFileNotificationDestinations = interpolate(config.notificationDestinations || []);
configFileAdmin = interpolate(config.admin || null);
} }
} catch (e) {
logger.warn(`No config file loaded or error parsing config: ${e.message}`);
}
await Scheduler.start(); await Scheduler.start();
await Scheduler.clear(); await Scheduler.clear();
@ -53,7 +59,15 @@ export const startup = async () => {
const fs = await import("node:fs/promises"); const fs = await import("node:fs/promises");
const { RESTIC_PASS_FILE } = await import("../../core/constants.js"); const { RESTIC_PASS_FILE } = await import("../../core/constants.js");
if (configFileAdmin && configFileAdmin.recoveryKey) { if (configFileAdmin && configFileAdmin.recoveryKey) {
await fs.writeFile(RESTIC_PASS_FILE, configFileAdmin.recoveryKey, { mode: 0o600 }); const recoveryKey = configFileAdmin.recoveryKey;
if (
typeof recoveryKey !== "string" ||
recoveryKey.length !== 64 ||
!/^[a-fA-F0-9]{64}$/.test(recoveryKey)
) {
throw new Error("Recovery key must be a 64-character hex string");
}
await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 });
logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`); logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`);
} }
} catch (err) { } catch (err) {
@ -177,6 +191,10 @@ export const startup = async () => {
try { try {
const { authService } = await import("../auth/auth.service"); const { authService } = await import("../auth/auth.service");
if (configFileAdmin && configFileAdmin.username && (configFileAdmin.password || configFileAdmin.passwordHash)) { if (configFileAdmin && configFileAdmin.username && (configFileAdmin.password || configFileAdmin.passwordHash)) {
if (configFileAdmin.password && configFileAdmin.passwordHash) {
logger.error("Config error: Both 'password' and 'passwordHash' provided for admin user. Use only one.");
throw new Error("Invalid admin configuration");
}
const hasUsers = await authService.hasUsers(); const hasUsers = await authService.hasUsers();
if (!hasUsers) { if (!hasUsers) {
let userId: number; let userId: number;
@ -198,7 +216,7 @@ export const startup = async () => {
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, userId)); await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, userId));
} }
} }
} else { } else if (configFileAdmin) {
logger.warn("Admin config missing required fields (username, password or passwordHash). Skipping automated admin setup."); logger.warn("Admin config missing required fields (username, password or passwordHash). Skipping automated admin setup.");
} }
} catch (err) { } catch (err) {