From 7f124f81cbb8230211789da528ac8f5749976839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Tr=C3=A1vn=C3=ADk?= Date: Thu, 18 Dec 2025 15:31:27 +0100 Subject: [PATCH] refactor the import logic - Updated README to reflect changes in config file structure and usage. - Modified error messages for user registration to be more generic. - Cleaned up startup logic to utilize the new config import functionality when requested. - Adjusted JSON config structure to support user definitions. --- README.md | 51 ++- app/server/modules/auth/auth.service.ts | 2 +- app/server/modules/backups/backups.service.ts | 11 - app/server/modules/lifecycle/config-import.ts | 404 ++++++++++++++++++ app/server/modules/lifecycle/startup.ts | 215 +--------- docker-compose.yml | 2 +- zerobyte.config.json | 13 +- 7 files changed, 455 insertions(+), 243 deletions(-) create mode 100644 app/server/modules/lifecycle/config-import.ts diff --git a/README.md b/README.md index 55bf3cdf..e07807d0 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o ### Configure Zerobyte via Config File -You can pre-configure backup sources (volumes), destinations (repositories), backup schedules, notification destinations and admin user using a config file (`zerobyte.config.json` by default (mounted in /app dir), or set `ZEROBYTE_CONFIG_PATH`). +You can pre-configure backup sources (volumes), destinations (repositories), backup schedules, notification destinations and initial users using a config file (`zerobyte.config.json` by default (mounted in /app dir), or set `ZEROBYTE_CONFIG_PATH`). + +Config import is opt-in. Enable it by setting `ZEROBYTE_CONFIG_IMPORT=true`. Secrets/credentials in the config file can reference environment variables using `${VAR_NAME}` syntax for secure injection. @@ -58,6 +60,7 @@ Secrets/credentials in the config file can reference environment variables using ```json { + "recoveryKey": "${RECOVERY_KEY}", "volumes": [ // Array of volume objects. Each must have a unique "name" and a "config" matching one of the types below. ], @@ -70,6 +73,10 @@ Secrets/credentials in the config file can reference environment variables using ], "notificationDestinations": [ // Array of notification destination objects as described below. + ], + "users": [ + // Array of user objects. Each must have a unique "username". + // Note: Zerobyte currently supports a single user; only the first entry is applied. ] } ``` @@ -215,6 +222,7 @@ Secrets/credentials in the config file can reference environment variables using - **Example:** ```json { + "name": "local-volume-local-repo", "volume": "local-volume", "repository": "local-repo", "cronExpression": "0 2 * * *", @@ -226,6 +234,7 @@ Secrets/credentials in the config file can reference environment variables using } ``` - **Fields:** + - `name`: Unique name of the schedule - `volume`: Name of the source volume - `repository`: Name of the destination repository - `cronExpression`: Cron string for schedule @@ -234,7 +243,7 @@ Secrets/credentials in the config file can reference environment variables using - `enabled`: Boolean - `notifications`: Array of notification destination names (strings) or detailed objects: - Simple: `["slack-alerts", "email-admin"]` - - Detailed: `[{"name": "slack-alerts", "notifyOnStart": false, "notifyOnSuccess": true, "notifyOnFailure": true}]` + - Detailed: `[{"name": "slack-alerts", "notifyOnStart": false, "notifyOnSuccess": true, "notifyOnWarning": true, "notifyOnFailure": true}]` ##### Notification Destinations @@ -348,39 +357,47 @@ Secrets/credentials in the config file can reference environment variables using - `config.type`: Notification type (email, slack, discord, gotify, ntfy, pushover, telegram, custom) - `config`: Type-specific config with `type` field, secrets via `${ENV_VAR}` -##### Admin Setup (Automated) +##### User Setup (Automated) + +Zerobyte currently supports a **single user**. If multiple entries are provided in `users[]`, only the first one will be applied. - **Example (new instance):** ```json { - "admin": { - "username": "admin", - "password": "${ADMIN_PASSWORD}", - "recoveryKey": "${RECOVERY_KEY}" - } + "recoveryKey": "${RECOVERY_KEY}", + "users": [ + { + "username": "my-user", + "password": "${ADMIN_PASSWORD}" + } + ] } ``` - **Example (migration from another instance):** ```json { - "admin": { - "username": "admin", - "passwordHash": "$argon2id$v=19$m=19456,t=2,p=1$...", - "recoveryKey": "${RECOVERY_KEY}" - } + "recoveryKey": "${RECOVERY_KEY}", + "users": [ + { + "username": "my-user", + "passwordHash": "$argon2id$v=19$m=19456,t=2,p=1$..." + } + ] } ``` - **Fields:** - - `username`: Admin username to create on first startup - - `password`: Admin password for new instances (can use `${ENV_VAR}`) - - `passwordHash`: Pre-hashed password for migration (exported from another instance) - `recoveryKey`: Optional recovery key (can use `${ENV_VAR}`) - if provided, the UI prompt to download recovery key will be skipped + - `users[]`: List of users to create on first startup (create-only). Only the first user is applied. + - `users[].username`: Username + - `users[].password`: Plaintext password for new instances (can use `${ENV_VAR}`) + - `users[].passwordHash`: Pre-hashed password for migration (exported from another instance) + - `users[].hasDownloadedResticPassword`: Optional boolean; defaults to `true` when `recoveryKey` is provided > **Note:** Use either `password` OR `passwordHash`, not both. The `passwordHash` option is useful when migrating from another Zerobyte instance using an exported config with `includePasswordHash=true`. -**On first startup, Zerobyte will automatically create the admin user from the config file.** +**On first startup, Zerobyte will automatically create users from the config file.** > **⚠️ About the Recovery Key** > diff --git a/app/server/modules/auth/auth.service.ts b/app/server/modules/auth/auth.service.ts index 4a97c0df..19cadff3 100644 --- a/app/server/modules/auth/auth.service.ts +++ b/app/server/modules/auth/auth.service.ts @@ -13,7 +13,7 @@ export class AuthService { const [existingUser] = await db.select().from(usersTable); if (existingUser) { - throw new Error("Admin user already exists"); + throw new Error("A user already exists"); } const passwordHash = await Bun.password.hash(password, { diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 6a7e5952..584fada7 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -88,17 +88,6 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { throw new NotFoundError("Repository not found"); } - const existingSchedule = await db.query.backupSchedulesTable.findFirst({ - where: and( - eq(backupSchedulesTable.volumeId, volume.id), - eq(backupSchedulesTable.repositoryId, repository.id) - ), - }); - - if (existingSchedule) { - throw new ConflictError(`A backup schedule for volume '${volume.name}' and repository '${repository.name}' already exists`); - } - const nextBackupAt = calculateNextRun(data.cronExpression); const [newSchedule] = await db diff --git a/app/server/modules/lifecycle/config-import.ts b/app/server/modules/lifecycle/config-import.ts new file mode 100644 index 00000000..d5e9ae09 --- /dev/null +++ b/app/server/modules/lifecycle/config-import.ts @@ -0,0 +1,404 @@ +import { eq } from "drizzle-orm"; +import slugify from "slugify"; +import { db } from "../../db/db"; +import { usersTable } from "../../db/schema"; +import { logger } from "../../utils/logger"; +import { volumeService } from "../volumes/volume.service"; +import type { NotificationConfig } from "~/schemas/notifications"; +import type { RepositoryConfig } from "~/schemas/restic"; +import type { BackendConfig } from "~/schemas/volumes"; + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; + +const asStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +}; + +type RetentionPolicy = { + keepLast?: number; + keepHourly?: number; + keepDaily?: number; + keepWeekly?: number; + keepMonthly?: number; + keepYearly?: number; + keepWithinDuration?: string; +}; + +type ImportConfig = { + volumes: unknown[]; + repositories: unknown[]; + backupSchedules: unknown[]; + notificationDestinations: unknown[]; + users: unknown[]; + recoveryKey: string | null; +}; + +function interpolateEnvVars(value: unknown): unknown { + if (typeof value === "string") { + return value.replace(/\$\{([^}]+)\}/g, (_, v) => { + if (process.env[v] === undefined) { + logger.warn(`Environment variable '${v}' is not defined. Replacing with empty string.`); + return ""; + } + return process.env[v]; + }); + } + if (Array.isArray(value)) { + return value.map(interpolateEnvVars); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolateEnvVars(v)])); + } + return value; +} + +async function loadConfigFromFile(): Promise { + try { + const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json"; + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const configFullPath = path.resolve(process.cwd(), configPath); + try { + const raw = await fs.readFile(configFullPath, "utf-8"); + return JSON.parse(raw); + } catch (error) { + if (isRecord(error) && error.code === "ENOENT") return null; + throw error; + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn(`No config file loaded or error parsing config: ${err.message}`); + return null; + } +} + +function parseImportConfig(configRaw: unknown): ImportConfig { + const root = isRecord(configRaw) ? configRaw : {}; + const config = isRecord(root.config) ? (root.config as Record) : root; + + const volumes = interpolateEnvVars(config.volumes || []); + const repositories = interpolateEnvVars(config.repositories || []); + const backupSchedules = interpolateEnvVars(config.backupSchedules || []); + const notificationDestinations = interpolateEnvVars(config.notificationDestinations || []); + const users = interpolateEnvVars(config.users || []); + const recoveryKeyRaw = interpolateEnvVars(config.recoveryKey || null); + + return { + volumes: Array.isArray(volumes) ? volumes : [], + repositories: Array.isArray(repositories) ? repositories : [], + backupSchedules: Array.isArray(backupSchedules) ? backupSchedules : [], + notificationDestinations: Array.isArray(notificationDestinations) ? notificationDestinations : [], + users: Array.isArray(users) ? users : [], + recoveryKey: typeof recoveryKeyRaw === "string" ? recoveryKeyRaw : null, + }; +} + +async function writeRecoveryKeyFromConfig(recoveryKey: string | null): Promise { + try { + const fs = await import("node:fs/promises"); + const { RESTIC_PASS_FILE } = await import("../../core/constants.js"); + if (!recoveryKey) return; + + 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"); + } + const passFileExists = await fs.stat(RESTIC_PASS_FILE).then( + () => true, + () => false, + ); + if (passFileExists) { + logger.info(`Restic passfile already exists at ${RESTIC_PASS_FILE}; skipping config recovery key write`); + return; + } + await fs.writeFile(RESTIC_PASS_FILE, recoveryKey, { mode: 0o600 }); + logger.info(`Recovery key written from config to ${RESTIC_PASS_FILE}`); + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + logger.error(`Failed to write recovery key from config: ${e.message}`); + } +} + +async function importVolumes(volumes: unknown[]): Promise { + for (const v of volumes) { + try { + if (!isRecord(v) || typeof v.name !== "string" || !isRecord(v.config) || typeof v.config.backend !== "string") { + throw new Error("Invalid volume entry"); + } + await volumeService.createVolume(v.name, v.config as BackendConfig); + logger.info(`Initialized volume from config: ${v.name}`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Volume not created: ${err.message}`); + } + } +} + +async function importRepositories(repositories: unknown[]): Promise { + const repoServiceModule = await import("../repositories/repositories.service"); + for (const r of repositories) { + try { + if (!isRecord(r) || typeof r.name !== "string" || !isRecord(r.config) || typeof r.config.backend !== "string") { + throw new Error("Invalid repository entry"); + } + const compressionMode = + r.compressionMode === "auto" || r.compressionMode === "off" || r.compressionMode === "max" + ? r.compressionMode + : undefined; + await repoServiceModule.repositoriesService.createRepository( + r.name, + r.config as RepositoryConfig, + compressionMode, + ); + logger.info(`Initialized repository from config: ${r.name}`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Repository not created: ${err.message}`); + } + } +} + +async function importNotificationDestinations(notificationDestinations: unknown[]): Promise { + const notificationsServiceModule = await import("../notifications/notifications.service"); + for (const n of notificationDestinations) { + try { + if (!isRecord(n) || typeof n.name !== "string" || !isRecord(n.config) || typeof n.config.type !== "string") { + throw new Error("Invalid notification destination entry"); + } + await notificationsServiceModule.notificationsService.createDestination(n.name, n.config as NotificationConfig); + logger.info(`Initialized notification destination from config: ${n.name}`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Notification destination not created: ${err.message}`); + } + } +} + +function getScheduleVolumeName(schedule: Record): string | null { + return typeof schedule.volume === "string" ? schedule.volume : typeof schedule.volumeName === "string" ? schedule.volumeName : null; +} + +function getScheduleRepositoryName(schedule: Record): string | null { + return typeof schedule.repository === "string" + ? schedule.repository + : typeof schedule.repositoryName === "string" + ? schedule.repositoryName + : null; +} + +type ScheduleNotificationAssignment = { + destinationId: number; + notifyOnStart: boolean; + notifyOnSuccess: boolean; + notifyOnWarning: boolean; + notifyOnFailure: boolean; +}; + +function buildScheduleNotificationAssignments( + notifications: unknown[], + destinationBySlug: Map, +): ScheduleNotificationAssignment[] { + const assignments: ScheduleNotificationAssignment[] = []; + + for (const notif of notifications) { + const destName = typeof notif === "string" ? notif : isRecord(notif) ? notif.name : null; + if (typeof destName !== "string" || destName.length === 0) { + logger.warn("Notification destination missing name for schedule"); + continue; + } + const destSlug = slugify(destName, { lower: true, strict: true }); + const dest = destinationBySlug.get(destSlug); + if (!dest) { + logger.warn(`Notification destination '${destName}' not found for schedule`); + continue; + } + assignments.push({ + destinationId: dest.id, + notifyOnStart: isRecord(notif) && typeof notif.notifyOnStart === "boolean" ? notif.notifyOnStart : true, + notifyOnSuccess: isRecord(notif) && typeof notif.notifyOnSuccess === "boolean" ? notif.notifyOnSuccess : true, + notifyOnWarning: isRecord(notif) && typeof notif.notifyOnWarning === "boolean" ? notif.notifyOnWarning : true, + notifyOnFailure: isRecord(notif) && typeof notif.notifyOnFailure === "boolean" ? notif.notifyOnFailure : true, + }); + } + + return assignments; +} + +async function attachScheduleNotifications( + scheduleId: number, + notifications: unknown[], + destinationBySlug: Map, + notificationsServiceModule: typeof import("../notifications/notifications.service"), +): Promise { + try { + const assignments = buildScheduleNotificationAssignments(notifications, destinationBySlug); + if (assignments.length === 0) return; + + await notificationsServiceModule.notificationsService.updateScheduleNotifications(scheduleId, assignments); + logger.info(`Assigned ${assignments.length} notification(s) to backup schedule`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Failed to assign notifications to schedule: ${err.message}`); + } +} + +async function importBackupSchedules(backupSchedules: unknown[]): Promise { + const backupServiceModule = await import("../backups/backups.service"); + const notificationsServiceModule = await import("../notifications/notifications.service"); + if (!Array.isArray(backupSchedules) || backupSchedules.length === 0) return; + + const volumes = await db.query.volumesTable.findMany(); + const repositories = await db.query.repositoriesTable.findMany(); + const destinations = await db.query.notificationDestinationsTable.findMany(); + + const volumeByName = new Map(volumes.map((v) => [v.name, v] as const)); + const repoByName = new Map(repositories.map((r) => [r.name, r] as const)); + const destinationBySlug = new Map(destinations.map((d) => [d.name, d] as const)); + + for (const s of backupSchedules) { + if (!isRecord(s)) { + continue; + } + const volumeName = getScheduleVolumeName(s); + if (typeof volumeName !== "string" || volumeName.length === 0) { + logger.warn("Backup schedule not created: Missing volume name"); + continue; + } + const volume = volumeByName.get(volumeName); + if (!volume) { + logger.warn(`Backup schedule not created: Volume '${volumeName}' not found`); + continue; + } + + const repositoryName = getScheduleRepositoryName(s); + if (typeof repositoryName !== "string" || repositoryName.length === 0) { + logger.warn("Backup schedule not created: Missing repository name"); + continue; + } + const repository = repoByName.get(repositoryName); + if (!repository) { + logger.warn(`Backup schedule not created: Repository '${repositoryName}' not found`); + continue; + } + + const scheduleName = typeof s.name === "string" && s.name.length > 0 ? s.name : `${volumeName}-${repositoryName}`; + if (typeof s.cronExpression !== "string" || s.cronExpression.length === 0) { + logger.warn(`Backup schedule not created: Missing cronExpression for '${scheduleName}'`); + continue; + } + + if (volume.status !== "mounted") { + try { + await volumeService.mountVolume(volume.name); + volumeByName.set(volume.name, { ...volume, status: "mounted" }); + logger.info(`Mounted volume ${volume.name} for backup schedule`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Could not mount volume ${volume.name}: ${err.message}`); + continue; + } + } + + let createdSchedule: { id: number } | null = null; + try { + const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined; + createdSchedule = await backupServiceModule.backupsService.createSchedule({ + name: scheduleName, + volumeId: volume.id, + repositoryId: repository.id, + enabled: typeof s.enabled === "boolean" ? s.enabled : true, + cronExpression: s.cronExpression, + retentionPolicy, + excludePatterns: asStringArray(s.excludePatterns), + excludeIfPresent: asStringArray(s.excludeIfPresent), + includePatterns: asStringArray(s.includePatterns), + }); + logger.info(`Initialized backup schedule from config: ${scheduleName}`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn(`Backup schedule not created: ${err.message}`); + continue; + } + + if (createdSchedule && Array.isArray(s.notifications) && s.notifications.length > 0) { + await attachScheduleNotifications(createdSchedule.id, s.notifications, destinationBySlug, notificationsServiceModule); + } + } +} + +async function setupInitialUser(users: unknown[], recoveryKey: string | null): Promise { + try { + const { authService } = await import("../auth/auth.service"); + const hasUsers = await authService.hasUsers(); + if (hasUsers) return; + if (!Array.isArray(users) || users.length === 0) return; + + if (users.length > 1) { + logger.warn( + "Multiple users provided in config. Zerobyte currently supports a single initial user; extra entries will be ignored.", + ); + } + + for (const u of users) { + if (!isRecord(u)) continue; + if (typeof u.username !== "string" || u.username.length === 0) continue; + + if (typeof u.passwordHash === "string" && u.passwordHash.length > 0) { + try { + await db.insert(usersTable).values({ + username: u.username, + passwordHash: u.passwordHash, + hasDownloadedResticPassword: + typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey), + }); + logger.info(`User '${u.username}' imported with password hash from config.`); + break; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn(`User '${u.username}' not imported: ${err.message}`); + } + continue; + } + + if (typeof u.password === "string" && u.password.length > 0) { + try { + const { user } = await authService.register(u.username, u.password); + const hasDownloadedResticPassword = + typeof u.hasDownloadedResticPassword === "boolean" ? u.hasDownloadedResticPassword : Boolean(recoveryKey); + if (hasDownloadedResticPassword) { + await db.update(usersTable).set({ hasDownloadedResticPassword }).where(eq(usersTable.id, user.id)); + } + logger.info(`User '${u.username}' created from config.`); + break; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn(`User '${u.username}' not created: ${err.message}`); + } + continue; + } + + logger.warn(`User '${u.username}' missing passwordHash/password; skipping`); + } + } catch (err) { + const e = err instanceof Error ? err : new Error(String(err)); + logger.error(`Automated user setup failed: ${e.message}`); + } +} + +export async function applyConfigImportFromFile(): Promise { + const configRaw = await loadConfigFromFile(); + const config = parseImportConfig(configRaw); + + await writeRecoveryKeyFromConfig(config.recoveryKey); + + try { + await importVolumes(config.volumes); + await importRepositories(config.repositories); + await importNotificationDestinations(config.notificationDestinations); + await importBackupSchedules(config.backupSchedules); + await setupInitialUser(config.users, config.recoveryKey); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.error(`Failed to initialize from config: ${err.message}`); + } +} diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index bb0a7ae6..fd6a96f5 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -1,7 +1,7 @@ import { Scheduler } from "../../core/scheduler"; import { and, eq, or } from "drizzle-orm"; import { db } from "../../db/db"; -import { volumesTable, usersTable, repositoriesTable, notificationDestinationsTable } from "../../db/schema"; +import { volumesTable } from "../../db/schema"; import { logger } from "../../utils/logger"; import { restic } from "../../utils/restic"; import { volumeService } from "../volumes/volume.service"; @@ -10,224 +10,23 @@ import { VolumeHealthCheckJob } from "../../jobs/healthchecks"; import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks"; import { BackupExecutionJob } from "../../jobs/backup-execution"; import { CleanupSessionsJob } from "../../jobs/cleanup-sessions"; +import { applyConfigImportFromFile } from "./config-import"; export const startup = async () => { - let configFileVolumes = []; - let configFileRepositories = []; - let configFileBackupSchedules = []; - let configFileNotificationDestinations = []; - let configFileAdmin = null; - try { - const configPath = process.env.ZEROBYTE_CONFIG_PATH || "zerobyte.config.json"; - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const configFullPath = path.resolve(process.cwd(), configPath); - if (await fs.stat(configFullPath).then(() => true, () => false)) { - const raw = await fs.readFile(configFullPath, "utf-8"); - const config = JSON.parse(raw); - - function interpolate(obj) { - if (typeof obj === "string") { - return obj.replace(/\$\{([^}]+)\}/g, (_, v) => { - if (process.env[v] === undefined) { - logger.warn(`Environment variable '${v}' is not defined. Replacing with empty string.`); - return ""; - } - return process.env[v]; - }); - } 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)])); - } - return obj; - } - configFileVolumes = interpolate(config.volumes || []); - 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.clear(); - try { - const fs = await import("node:fs/promises"); - const { RESTIC_PASS_FILE } = await import("../../core/constants.js"); - if (configFileAdmin && configFileAdmin.recoveryKey) { - 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}`); - } - } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)); - logger.error(`Failed to write recovery key from config: ${e.message}`); + if (process.env.ZEROBYTE_CONFIG_IMPORT === "true") { + logger.info("Config import enabled (ZEROBYTE_CONFIG_IMPORT=true)"); + await applyConfigImportFromFile(); + } else { + logger.info("Config import skipped (set ZEROBYTE_CONFIG_IMPORT=true to enable)"); } await restic.ensurePassfile().catch((err) => { logger.error(`Error ensuring restic passfile exists: ${err.message}`); }); - try { - for (const v of configFileVolumes) { - try { - await volumeService.createVolume(v.name, v.config); - logger.info(`Initialized volume from config: ${v.name}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Volume ${v.name} not created: ${err.message}`); - } - } - const repoServiceModule = await import("../repositories/repositories.service"); - for (const r of configFileRepositories) { - try { - await repoServiceModule.repositoriesService.createRepository(r.name, r.config, r.compressionMode); - logger.info(`Initialized repository from config: ${r.name}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Repository ${r.name} not created: ${err.message}`); - } - } - const notificationsServiceModule = await import("../notifications/notifications.service"); - for (const n of configFileNotificationDestinations) { - try { - await notificationsServiceModule.notificationsService.createDestination(n.name, n.config); - logger.info(`Initialized notification destination from config: ${n.name}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Notification destination ${n.name} not created: ${err.message}`); - } - } - - const backupServiceModule = await import("../backups/backups.service"); - for (const s of configFileBackupSchedules) { - const volumeName = s.volume || s.volumeName; - const volume = await db.query.volumesTable.findFirst({ - where: eq(volumesTable.name, volumeName), - }); - if (!volume) { - logger.warn(`Backup schedule not created: Volume '${volumeName}' not found`); - continue; - } - const repositoryName = s.repository || s.repositoryName; - const repository = await db.query.repositoriesTable.findFirst({ - where: eq(repositoriesTable.name, repositoryName), - }); - if (!repository) { - logger.warn(`Backup schedule not created: Repository '${repositoryName}' not found`); - continue; - } - if (volume.status !== "mounted") { - try { - await volumeService.mountVolume(volume.name); - logger.info(`Mounted volume ${volume.name} for backup schedule`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Could not mount volume ${volume.name}: ${err.message}`); - continue; - } - } - let createdSchedule; - try { - createdSchedule = await backupServiceModule.backupsService.createSchedule({ - ...s, - volumeId: volume.id, - repositoryId: repository.id, - }); - logger.info(`Initialized backup schedule from config: ${s.cronExpression || s.name}`); - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Backup schedule not created: ${err.message}`); - continue; - } - - if (createdSchedule && s.notifications && Array.isArray(s.notifications) && s.notifications.length > 0) { - try { - const assignments: Array<{ - destinationId: number; - notifyOnStart: boolean; - notifyOnSuccess: boolean; - notifyOnFailure: boolean; - }> = []; - for (const notif of s.notifications) { - const destName = typeof notif === 'string' ? notif : notif.name; - const dest = await db.query.notificationDestinationsTable.findFirst({ - where: eq(notificationDestinationsTable.name, destName), - }); - if (dest) { - assignments.push({ - destinationId: dest.id, - notifyOnStart: typeof notif === 'object' ? (notif.notifyOnStart ?? true) : true, - notifyOnSuccess: typeof notif === 'object' ? (notif.notifyOnSuccess ?? true) : true, - notifyOnFailure: typeof notif === 'object' ? (notif.notifyOnFailure ?? true) : true, - }); - } else { - logger.warn(`Notification destination '${destName}' not found for schedule`); - } - } - if (assignments.length > 0) { - await notificationsServiceModule.notificationsService.updateScheduleNotifications(createdSchedule.id, assignments); - logger.info(`Assigned ${assignments.length} notification(s) to backup schedule`); - } - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn(`Failed to assign notifications to schedule: ${err.message}`); - } - } - - } - - try { - const { authService } = await import("../auth/auth.service"); - 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(); - if (!hasUsers) { - let userId: number; - if (configFileAdmin.passwordHash) { - // Import with existing password hash (migration from another instance) - const [user] = await db.insert(usersTable).values({ - username: configFileAdmin.username, - passwordHash: configFileAdmin.passwordHash, - }).returning(); - userId = user.id; - logger.info(`Admin user '${configFileAdmin.username}' imported with password hash from config.`); - } else { - // Create new user with plaintext password - const { user } = await authService.register(configFileAdmin.username, configFileAdmin.password); - userId = user.id; - logger.info(`Admin user '${configFileAdmin.username}' created from config.`); - } - if (configFileAdmin.recoveryKey) { - await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, userId)); - } - } - } else if (configFileAdmin) { - logger.warn("Admin config missing required fields (username, password or passwordHash). Skipping automated admin setup."); - } - } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)); - logger.error(`Automated admin setup failed: ${e.message}`); - } - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.error(`Failed to initialize from config: ${err.message}`); - } - const volumes = await db.query.volumesTable.findMany({ where: or( eq(volumesTable.status, "mounted"), diff --git a/docker-compose.yml b/docker-compose.yml index acdbd17e..cd132c35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte - + - ./app:/app/app - ~/.config/rclone:/root/.config/rclone # - /run/docker/plugins:/run/docker/plugins diff --git a/zerobyte.config.json b/zerobyte.config.json index 1323b939..6e30e340 100644 --- a/zerobyte.config.json +++ b/zerobyte.config.json @@ -118,6 +118,7 @@ ], "backupSchedules": [ { + "name": "local-volume-local-repo", "volume": "local-volume", "repository": "local-repo", "cronExpression": "0 2 * * *", @@ -209,9 +210,11 @@ } } ], - "admin": { - "username": "admin", - "password": "${ADMIN_PASSWORD}", - "recoveryKey": "${RECOVERY_KEY}" - } + "recoveryKey": "${RECOVERY_KEY}", + "users": [ + { + "username": "admin", + "password": "${ADMIN_PASSWORD}" + } + ] }