config import via json

This commit is contained in:
Jakub Trávník 2025-11-30 18:53:32 +01:00
parent 999850dab8
commit ebaebe0aad
8 changed files with 822 additions and 21 deletions

345
README.md
View file

@ -37,6 +37,350 @@ Zerobyte is a backup automation tool that helps you save your data across multip
In order to run Zerobyte, you need to have Docker and Docker Compose installed on your server. Then, you can use the provided `docker-compose.yml` file to start the application. In order to run Zerobyte, you need to have Docker and Docker Compose installed on your server. Then, you can use the provided `docker-compose.yml` file to start the application.
### 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`).
Secrets/credentials in the config file can reference environment variables using `${VAR_NAME}` syntax for secure injection.
> ** Config File Behavior**
>
> The config file is applied on startup using a **create-only** approach:
> - Resources defined in the config are only created if they don't already exist in the database
> - Existing resources with the same name are **not overwritten** - a warning is logged and the config entry is skipped
> - Changes made via the UI are preserved across container restarts
> - To update a resource from config, either modify it via the UI or delete it first
>
> This means the config file serves as "initial setup" rather than "desired state sync".
#### zerobyte.config.json Structure
```json
{
"volumes": [
// Array of volume objects. Each must have a unique "name" and a "config" matching one of the types below.
],
"repositories": [
// Array of repository objects. Each must have a unique "name" and a "config" matching one of the types below.
// Optionally, "compressionMode" ("auto", "off", "max")
],
"backupSchedules": [
// Array of backup schedule objects as described below.
],
"notificationDestinations": [
// Array of notification destination objects as described below.
]
}
```
##### Volume Types
- **Local Directory**
```json
{
"name": "local-volume",
"config": {
"backend": "directory",
"path": "/data",
"readOnly": true
}
}
```
- **NFS**
```json
{
"name": "nfs-volume",
"config": {
"backend": "nfs",
"server": "nfs.example.com",
"exportPath": "/data",
"port": 2049,
"version": "4",
"readOnly": false
}
}
```
- **SMB**
```json
{
"name": "smb-volume",
"config": {
"backend": "smb",
"server": "smb.example.com",
"share": "shared",
"username": "user",
"password": "${SMB_PASSWORD}",
"vers": "3.0",
"domain": "WORKGROUP",
"port": 445,
"readOnly": false
}
}
```
- **WebDAV**
```json
{
"name": "webdav-volume",
"config": {
"backend": "webdav",
"server": "webdav.example.com",
"path": "/remote.php/webdav",
"username": "user",
"password": "${WEBDAV_PASSWORD}",
"port": 80,
"readOnly": false,
"ssl": true
}
}
```
##### Repository Types
- **Local Directory**
```json
{
"name": "local-repo",
"config": {
"backend": "local",
"path": "/var/lib/zerobyte/repositories"
},
"compressionMode": "auto"
}
```
> **Note for importing existing local repositories:** If you're importing an existing repository (e.g., from a backup or migration), include the `name` field in `config` with the original subfolder name. The actual restic repo is stored at `{path}/{name}`. You can find this value in an exported config under `repositories[].config.name`.
- **S3-Compatible**
```json
{
"name": "backup-repo",
"config": {
"backend": "s3",
"bucket": "mybucket",
"accessKeyId": "${ACCESS_KEY_ID}",
"secretAccessKey": "${SECRET_ACCESS_KEY}"
},
"compressionMode": "auto"
}
```
- **Google Cloud Storage**
```json
{
"name": "gcs-repo",
"config": {
"backend": "gcs",
"bucket": "mybucket",
"projectId": "my-gcp-project",
"credentialsJson": "${GCS_CREDENTIALS}"
}
}
```
- **Azure Blob Storage**
```json
{
"name": "azure-repo",
"config": {
"backend": "azure",
"container": "mycontainer",
"accountName": "myaccount",
"accountKey": "${AZURE_KEY}"
}
}
```
- **WebDAV, rclone, SFTP, REST, etc.**
(See documentation for required fields; all support env variable secrets.)
##### Backup Schedules
- **Example:**
```json
{
"volume": "local-volume",
"repository": "local-repo",
"cronExpression": "0 2 * * *",
"retentionPolicy": { "keepLast": 7, "keepDaily": 7 },
"includePatterns": ["important-folder"],
"excludePatterns": ["*.tmp", "*.log"],
"enabled": true,
"notifications": ["slack-alerts", "email-admin"]
}
```
- **Fields:**
- `volume`: Name of the source volume
- `repository`: Name of the destination repository
- `cronExpression`: Cron string for schedule
- `retentionPolicy`: Object with retention rules (e.g., keepLast, keepDaily)
- `includePatterns`/`excludePatterns`: Arrays of patterns
- `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}]`
##### Notification Destinations
- **Examples:**
- **Slack**
```json
{
"name": "slack-alerts",
"type": "slack",
"config": {
"webhookUrl": "${SLACK_WEBHOOK_URL}",
"channel": "#backups",
"username": "zerobyte",
"iconEmoji": ":floppy_disk:"
}
}
```
- **Email**
```json
{
"name": "email-admin",
"type": "email",
"config": {
"smtpHost": "smtp.example.com",
"smtpPort": 587,
"username": "admin@example.com",
"password": "${EMAIL_PASSWORD}",
"from": "zerobyte@example.com",
"to": ["admin@example.com"],
"useTLS": true
}
}
```
- **Discord**
```json
{
"name": "discord-backups",
"type": "discord",
"config": {
"webhookUrl": "${DISCORD_WEBHOOK_URL}",
"username": "zerobyte",
"avatarUrl": "https://example.com/avatar.png",
"threadId": "1234567890"
}
}
```
- **Gotify**
```json
{
"name": "gotify-notify",
"type": "gotify",
"config": {
"serverUrl": "https://gotify.example.com",
"token": "${GOTIFY_TOKEN}",
"path": "/message",
"priority": 5
}
}
```
- **ntfy**
```json
{
"name": "ntfy-notify",
"type": "ntfy",
"config": {
"serverUrl": "https://ntfy.example.com",
"topic": "zerobyte-backups",
"priority": "high",
"username": "ntfyuser",
"password": "${NTFY_PASSWORD}"
}
}
```
- **Pushover**
```json
{
"name": "pushover-notify",
"type": "pushover",
"config": {
"userKey": "${PUSHOVER_USER_KEY}",
"apiToken": "${PUSHOVER_API_TOKEN}",
"devices": "phone,tablet",
"priority": 1
}
}
```
- **Telegram**
```json
{
"name": "telegram-notify",
"type": "telegram",
"config": {
"botToken": "${TELEGRAM_BOT_TOKEN}",
"chatId": "123456789"
}
}
```
- **Custom (shoutrrr)**
```json
{
"name": "custom-shoutrrr",
"type": "custom",
"config": {
"shoutrrrUrl": "${SHOUTRRR_URL}"
}
}
```
- **Fields:**
- `name`: Unique name for the notification config
- `type`: Notification type (email, slack, discord, gotify, ntfy, pushover, telegram, custom)
- `config`: Type-specific config, secrets via `${ENV_VAR}`
##### Admin Setup (Automated)
- **Example:**
```json
{
"admin": {
"username": "admin",
"password": "${ADMIN_PASSWORD}",
"recoveryKey": "${RECOVERY_KEY}"
}
}
```
- **Fields:**
- `username`: Admin username to create on first startup
- `password`: Admin password (can use `${ENV_VAR}`)
- `recoveryKey`: Optional recovery key (can use `${ENV_VAR}`) - if provided, the UI prompt to download recovery key will be skipped
**On first startup, Zerobyte will automatically create the admin user from the config file.**
> **⚠️ About the Recovery Key**
>
> The recovery key is a 64-character hex string that serves two critical purposes:
> 1. **Restic repository password** - Used to encrypt all your backup data
> 2. **Database encryption key** - Used to encrypt credentials stored in Zerobyte's database
>
> **If you lose this key, you will lose access to all your backups and stored credentials.**
>
> **Generating a recovery key ahead of time:**
> ```bash
> # Using OpenSSL (Linux/macOS)
> openssl rand -hex 32
>
> # Using Python
> python3 -c "import secrets; print(secrets.token_hex(32))"
> ```
>
> **Retrieving from an existing instance:**
> - Download via UI: Settings → Download Recovery Key
> - Or read directly from the container: `docker exec zerobyte cat /var/lib/zerobyte/data/restic.pass`
---
**Notes:**
- All secrets (passwords, keys) can use `${ENV_VAR}` syntax to inject from environment variables.
- All paths must be accessible inside the container (mount host paths as needed).
- `readOnly` is supported for all volume types that allow it, including local directories.
```yaml ```yaml
services: services:
zerobyte: zerobyte:
@ -54,6 +398,7 @@ services:
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
- ./zerobyte.config.json:/app/zerobyte.config.json:ro # Mount your config file
``` ```
> [!WARNING] > [!WARNING]

View file

@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import cron from "node-cron"; import cron from "node-cron";
import { CronExpressionParser } from "cron-parser"; import { CronExpressionParser } from "cron-parser";
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
@ -77,6 +77,17 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
throw new NotFoundError("Repository not found"); 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 nextBackupAt = calculateNextRun(data.cronExpression);
const [newSchedule] = await db const [newSchedule] = await db

View file

@ -1,7 +1,7 @@
import { Scheduler } from "../../core/scheduler"; import { Scheduler } from "../../core/scheduler";
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable, usersTable, repositoriesTable, notificationDestinationsTable } from "../../db/schema";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { volumeService } from "../volumes/volume.service"; import { volumeService } from "../volumes/volume.service";
@ -12,13 +12,191 @@ 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 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) => 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.start();
await Scheduler.clear(); await Scheduler.clear();
try {
const fs = await import("node:fs/promises");
const { RESTIC_PASS_FILE } = await import("../../core/constants.js");
if (configFileAdmin && configFileAdmin.recoveryKey) {
await fs.writeFile(RESTIC_PASS_FILE, configFileAdmin.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}`);
}
await restic.ensurePassfile().catch((err) => { await restic.ensurePassfile().catch((err) => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`); 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) {
const hasUsers = await authService.hasUsers();
if (!hasUsers) {
const { user } = await authService.register(configFileAdmin.username, configFileAdmin.password);
logger.info(`Admin user '${configFileAdmin.username}' created from config.`);
if (configFileAdmin.recoveryKey) {
await db.update(usersTable).set({ hasDownloadedResticPassword: true }).where(eq(usersTable.id, user.id));
}
}
} else {
logger.warn("Admin config missing required fields (username, password). 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({ const volumes = await db.query.volumesTable.findMany({
where: or( where: or(
eq(volumesTable.status, "mounted"), eq(volumesTable.status, "mounted"),

View file

@ -38,42 +38,42 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "email": case "email":
return { return {
...config, ...config,
password: await cryptoUtils.encrypt(config.password), password: cryptoUtils.isEncrypted(config.password) ? config.password : await cryptoUtils.encrypt(config.password),
}; };
case "slack": case "slack":
return { return {
...config, ...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl), webhookUrl: cryptoUtils.isEncrypted(config.webhookUrl) ? config.webhookUrl : await cryptoUtils.encrypt(config.webhookUrl),
}; };
case "discord": case "discord":
return { return {
...config, ...config,
webhookUrl: await cryptoUtils.encrypt(config.webhookUrl), webhookUrl: cryptoUtils.isEncrypted(config.webhookUrl) ? config.webhookUrl : await cryptoUtils.encrypt(config.webhookUrl),
}; };
case "gotify": case "gotify":
return { return {
...config, ...config,
token: await cryptoUtils.encrypt(config.token), token: cryptoUtils.isEncrypted(config.token) ? config.token : await cryptoUtils.encrypt(config.token),
}; };
case "ntfy": case "ntfy":
return { return {
...config, ...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined, password: config.password ? (cryptoUtils.isEncrypted(config.password) ? config.password : await cryptoUtils.encrypt(config.password)) : undefined,
}; };
case "pushover": case "pushover":
return { return {
...config, ...config,
apiToken: await cryptoUtils.encrypt(config.apiToken), apiToken: cryptoUtils.isEncrypted(config.apiToken) ? config.apiToken : await cryptoUtils.encrypt(config.apiToken),
}; };
case "telegram": case "telegram":
return { return {
...config, ...config,
botToken: await cryptoUtils.encrypt(config.botToken), botToken: cryptoUtils.isEncrypted(config.botToken) ? config.botToken : await cryptoUtils.encrypt(config.botToken),
}; };
case "custom": case "custom":
return { return {
...config, ...config,
shoutrrrUrl: await cryptoUtils.encrypt(config.shoutrrrUrl), shoutrrrUrl: cryptoUtils.isEncrypted(config.shoutrrrUrl) ? config.shoutrrrUrl : await cryptoUtils.encrypt(config.shoutrrrUrl),
}; };
default: default:
return config; return config;

View file

@ -18,32 +18,42 @@ const listRepositories = async () => {
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => { const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const encryptedConfig: Record<string, string | boolean | number> = { ...config }; const encryptedConfig: Record<string, string | boolean | number> = { ...config };
if (config.customPassword) { if (config.customPassword && !cryptoUtils.isEncrypted(config.customPassword)) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
} }
switch (config.backend) { switch (config.backend) {
case "s3": case "s3":
case "r2": case "r2":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId); if (!cryptoUtils.isEncrypted(config.accessKeyId)) {
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey); encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
}
if (!cryptoUtils.isEncrypted(config.secretAccessKey)) {
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
}
break; break;
case "gcs": case "gcs":
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson); if (!cryptoUtils.isEncrypted(config.credentialsJson)) {
encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson);
}
break; break;
case "azure": case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey); if (!cryptoUtils.isEncrypted(config.accountKey)) {
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
}
break; break;
case "rest": case "rest":
if (config.username) { if (config.username && !cryptoUtils.isEncrypted(config.username)) {
encryptedConfig.username = await cryptoUtils.encrypt(config.username); encryptedConfig.username = await cryptoUtils.encrypt(config.username);
} }
if (config.password) { if (config.password && !cryptoUtils.isEncrypted(config.password)) {
encryptedConfig.password = await cryptoUtils.encrypt(config.password); encryptedConfig.password = await cryptoUtils.encrypt(config.password);
} }
break; break;
case "sftp": case "sftp":
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey); if (!cryptoUtils.isEncrypted(config.privateKey)) {
encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey);
}
break; break;
} }
@ -62,7 +72,8 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const shortId = generateShortId();
const shortId = (config.backend === "local" && config.name) ? config.name : generateShortId();
let processedConfig = config; let processedConfig = config;
if (config.backend === "local") { if (config.backend === "local") {
@ -94,12 +105,23 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
const result = await restic const result = await restic
.snapshots(encryptedConfig) .snapshots(encryptedConfig)
.then(() => ({ error: null })) .then(() => ({ error: null }))
.catch((error) => ({ error })); .catch((err) => ({ error: err }));
error = result.error; error = result.error;
} else { } else {
const initResult = await restic.init(encryptedConfig); const initResult = await restic.init(encryptedConfig);
error = initResult.error; error = initResult.error;
if (error) {
const errorStr = typeof error === "string" ? error : (error as Error)?.message || "";
if (errorStr.includes("config file already exists")) {
const verifyResult = await restic
.snapshots(encryptedConfig)
.then(() => ({ error: null }))
.catch((err) => ({ error: err }));
error = verifyResult.error;
}
}
} }
if (!error) { if (!error) {

View file

@ -5,6 +5,10 @@ const algorithm = "aes-256-gcm" as const;
const keyLength = 32; const keyLength = 32;
const encryptionPrefix = "encv1"; const encryptionPrefix = "encv1";
const isEncrypted = (val?: string): boolean => {
return typeof val === "string" && val.startsWith(encryptionPrefix);
};
/** /**
* Given a string, encrypts it using a randomly generated salt * Given a string, encrypts it using a randomly generated salt
*/ */
@ -56,6 +60,7 @@ const decrypt = async (encryptedData: string) => {
}; };
export const cryptoUtils = { export const cryptoUtils = {
isEncrypted,
encrypt, encrypt,
decrypt, decrypt,
}; };

View file

@ -17,7 +17,7 @@ services:
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
- ./app:/app/app - ./app:/app/app
- ~/.config/rclone:/root/.config/rclone - ~/.config/rclone:/root/.config/rclone
# - /run/docker/plugins:/run/docker/plugins # - /run/docker/plugins:/run/docker/plugins
@ -36,8 +36,17 @@ services:
- SYS_ADMIN - SYS_ADMIN
ports: ports:
- "4096:4096" - "4096:4096"
environment:
- ACCESS_KEY_ID=your-access-key-id
- SECRET_ACCESS_KEY=your-secret-access-key
- SMB_PASSWORD=your-smb-password
- WEBDAV_PASSWORD=your-webdav-password
- GCS_CREDENTIALS=your-gcs-credentials-json
- AZURE_KEY=your-azure-key
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte:rshared - /var/lib/zerobyte:/var/lib/zerobyte:rshared
- ./zerobyte.config.json:/app/zerobyte.config.json:ro
- ./mydata:/mydata:ro
- /run/docker/plugins:/run/docker/plugins - /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock

231
zerobyte.config.json Normal file
View file

@ -0,0 +1,231 @@
{
"volumes": [
{
"name": "local-volume",
"config": {
"backend": "directory",
"path": "/mydata",
"readOnly": true
}
},
{
"name": "nfs-volume",
"config": {
"backend": "nfs",
"server": "nfs.example.com",
"exportPath": "/data",
"port": 2049,
"version": "4",
"readOnly": false
}
},
{
"name": "smb-volume",
"config": {
"backend": "smb",
"server": "smb.example.com",
"share": "shared",
"username": "user",
"password": "${SMB_PASSWORD}",
"vers": "3.0",
"domain": "WORKGROUP",
"port": 445,
"readOnly": false
}
},
{
"name": "webdav-volume",
"config": {
"backend": "webdav",
"server": "webdav.example.com",
"path": "/remote.php/webdav",
"username": "user",
"password": "${WEBDAV_PASSWORD}",
"port": 80,
"readOnly": false,
"ssl": true
}
},
{
"name": "sftp-volume",
"config": {
"backend": "sftp",
"host": "sftp.example.com",
"port": 22,
"username": "user",
"password": "${SFTP_PASSWORD}",
"privateKey": "${SFTP_PRIVATE_KEY}",
"path": "/data",
"readOnly": false
}
}
],
"repositories": [
{
"name": "local-repo",
"config": {
"backend": "local",
"path": "/var/lib/zerobyte/repositories"
},
"compressionMode": "auto"
},
{
"name": "s3-repo",
"config": {
"backend": "s3",
"bucket": "mybucket",
"accessKeyId": "${ACCESS_KEY_ID}",
"secretAccessKey": "${SECRET_ACCESS_KEY}"
},
"compressionMode": "auto"
},
{
"name": "gcs-repo",
"config": {
"backend": "gcs",
"bucket": "mybucket",
"projectId": "my-gcp-project",
"credentialsJson": "${GCS_CREDENTIALS}"
}
},
{
"name": "azure-repo",
"config": {
"backend": "azure",
"container": "mycontainer",
"accountName": "myaccount",
"accountKey": "${AZURE_KEY}"
}
},
{
"name": "rclone-repo",
"config": {
"backend": "rclone",
"remote": "myremote",
"path": "backups/zerobyte"
}
},
{
"name": "webdav-repo",
"config": {
"backend": "webdav",
"server": "webdav.example.com",
"path": "/remote.php/webdav",
"username": "user",
"password": "${WEBDAV_PASSWORD}",
"port": 80,
"ssl": true
}
},
{
"name": "sftp-repo",
"config": {
"backend": "sftp",
"host": "sftp.example.com",
"port": 22,
"username": "user",
"password": "${SFTP_PASSWORD}",
"privateKey": "${SFTP_PRIVATE_KEY}",
"path": "/backups"
}
}
],
"backupSchedules": [
{
"volume": "local-volume",
"repository": "local-repo",
"cronExpression": "0 2 * * *",
"retentionPolicy": { "keepLast": 7, "keepDaily": 7 },
"includePatterns": ["important-folder"],
"excludePatterns": ["*.tmp", "*.log"],
"enabled": true,
"notifications": ["slack-alerts", "email-admin", "discord-backups", "gotify-notify", "ntfy-notify", "pushover-notify", "telegram-notify", "custom-shoutrrr"]
}
],
"notificationDestinations": [
{
"name": "slack-alerts",
"type": "slack",
"config": {
"webhookUrl": "${SLACK_WEBHOOK_URL}",
"channel": "#backups",
"username": "zerobyte",
"iconEmoji": ":floppy_disk:"
}
},
{
"name": "email-admin",
"type": "email",
"config": {
"smtpHost": "smtp.example.com",
"smtpPort": 587,
"username": "admin@example.com",
"password": "${EMAIL_PASSWORD}",
"from": "zerobyte@example.com",
"to": ["admin@example.com"],
"useTLS": true
}
},
{
"name": "discord-backups",
"type": "discord",
"config": {
"webhookUrl": "${DISCORD_WEBHOOK_URL}",
"username": "zerobyte",
"avatarUrl": "https://example.com/avatar.png",
"threadId": "1234567890"
}
},
{
"name": "gotify-notify",
"type": "gotify",
"config": {
"serverUrl": "https://gotify.example.com",
"token": "${GOTIFY_TOKEN}",
"path": "/message",
"priority": 5
}
},
{
"name": "ntfy-notify",
"type": "ntfy",
"config": {
"serverUrl": "https://ntfy.example.com",
"topic": "zerobyte-backups",
"priority": "high",
"username": "ntfyuser",
"password": "${NTFY_PASSWORD}"
}
},
{
"name": "pushover-notify",
"type": "pushover",
"config": {
"userKey": "${PUSHOVER_USER_KEY}",
"apiToken": "${PUSHOVER_API_TOKEN}",
"devices": "phone,tablet",
"priority": 1
}
},
{
"name": "telegram-notify",
"type": "telegram",
"config": {
"botToken": "${TELEGRAM_BOT_TOKEN}",
"chatId": "123456789"
}
},
{
"name": "custom-shoutrrr",
"type": "custom",
"config": {
"shoutrrrUrl": "${SHOUTRRR_URL}"
}
}
],
"admin": {
"username": "admin",
"password": "${ADMIN_PASSWORD}",
"recoveryKey": "${RECOVERY_KEY}"
}
}