config import via json
This commit is contained in:
parent
999850dab8
commit
ebaebe0aad
8 changed files with 822 additions and 21 deletions
345
README.md
345
README.md
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
231
zerobyte.config.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue