From c05aa8d5bfa052d5ce028204a9a5f1cb3555985d Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:30:49 +0100 Subject: [PATCH] feat: add support for SFTP volume backend (#249) * feat: add support for SFTP volume backend * feat: allow more secure SFTP connection with known hosts * refactor: make default value for skip host key check to false * refactor: use os.userInfo when setting uid/gid in mount commands --- AGENTS.md | 8 +- Dockerfile | 2 +- README.md | 6 +- app/client/api-client/types.gen.ts | 142 +++++++++++++- app/client/components/volume-icon.tsx | 21 +- .../components/create-repository-form.tsx | 2 +- .../repository-forms/sftp-repository-form.tsx | 44 ++++- .../volumes/components/create-volume-form.tsx | 23 ++- .../volumes/components/volume-forms/index.ts | 1 + .../components/volume-forms/sftp-form.tsx | 161 ++++++++++++++++ app/schemas/restic.ts | 2 + app/schemas/volumes.ts | 17 +- app/server/modules/backends/backend.ts | 4 + .../modules/backends/sftp/sftp-backend.ts | 180 ++++++++++++++++++ .../modules/backends/smb/smb-backend.ts | 5 +- .../modules/backends/webdav/webdav-backend.ts | 5 +- app/server/modules/volumes/volume.service.ts | 6 + app/server/utils/restic.ts | 24 ++- 18 files changed, 611 insertions(+), 42 deletions(-) create mode 100644 app/client/modules/volumes/components/volume-forms/sftp-form.tsx create mode 100644 app/server/modules/backends/sftp/sftp-backend.ts diff --git a/AGENTS.md b/AGENTS.md index 92f7ff18..9d09ac2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ ## Project Overview -Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). +Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). ## Technology Stack @@ -99,7 +99,7 @@ The server follows a modular service-oriented architecture: Each module follows a controller � service � database pattern: - `auth/` - User authentication and session management -- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, directories) +- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, SFTP, directories) - `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone) - `backups/` - Backup schedule management and execution - `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover) @@ -109,7 +109,7 @@ Each module follows a controller � service � database pattern: - `lifecycle/` - Application startup/shutdown hooks **Backends** (`app/server/modules/backends/`): -Each volume backend (NFS, SMB, WebDAV, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2). +Each volume backend (NFS, SMB, WebDAV, SFTP, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2, sshfs). **Jobs** (`app/server/jobs/`): Cron-based background jobs managed by the Scheduler: @@ -158,7 +158,7 @@ Routes are organized in feature modules at `app/client/modules/*/routes/`. `app/schemas/` contains ArkType schemas used by both client and server: -- Volume configurations (NFS, SMB, WebDAV, directory) +- Volume configurations (NFS, SMB, WebDAV, SFTP, directory) - Repository configurations (S3, Azure, GCS, local, rclone) - Restic command output parsing types - Backend status types diff --git a/Dockerfile b/Dockerfile index c95dab9f..3ec7d898 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG BUN_VERSION="1.3.5" FROM oven/bun:${BUN_VERSION}-alpine AS base RUN apk upgrade --no-cache && \ - apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 + apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs # ------------------------------ diff --git a/README.md b/README.md index 8000055a..b77f57ec 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Zerobyte is a backup automation tool that helps you save your data across multip -   **Automated backups** with encryption, compression and retention policies powered by Restic -   **Flexible scheduling** For automated backup jobs with fine-grained retention policies -   **End-to-end encryption** ensuring your data is always protected --   **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories +-   **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories ## Installation @@ -94,7 +94,7 @@ services: - ✅ Improved security by reducing container capabilities - ✅ Support for local directories - ✅ Keep support all repository types (local, S3, GCS, Azure, rclone) -- ❌ Cannot mount NFS, SMB, or WebDAV shares directly from Zerobyte +- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`. @@ -104,7 +104,7 @@ See [examples/README.md](examples/README.md) for runnable, copy/paste-friendly e ## Adding your first volume -Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor. +Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and local directories. A volume represents the source data you want to back up and monitor. To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings. diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index d85c5eee..58f79eab 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -169,6 +169,17 @@ export type ListVolumesResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -196,7 +207,7 @@ export type ListVolumesResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }>; }; @@ -221,6 +232,17 @@ export type CreateVolumeData = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -270,6 +292,17 @@ export type CreateVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -297,7 +330,7 @@ export type CreateVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -322,6 +355,17 @@ export type TestConnectionData = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -424,6 +468,17 @@ export type GetVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -451,7 +506,7 @@ export type GetVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -478,6 +533,17 @@ export type UpdateVolumeData = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -536,6 +602,17 @@ export type UpdateVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -563,7 +640,7 @@ export type UpdateVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; }; @@ -773,8 +850,10 @@ export type ListRepositoriesResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -850,8 +929,10 @@ export type CreateRepositoryData = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; name: string; compressionMode?: 'auto' | 'max' | 'off'; @@ -989,8 +1070,10 @@ export type GetRepositoryResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1093,8 +1176,10 @@ export type UpdateRepositoryResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1367,8 +1452,10 @@ export type ListBackupSchedulesResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1410,6 +1497,17 @@ export type ListBackupSchedulesResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1437,7 +1535,7 @@ export type ListBackupSchedulesResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1616,8 +1714,10 @@ export type GetBackupScheduleResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1659,6 +1759,17 @@ export type GetBackupScheduleResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1686,7 +1797,7 @@ export type GetBackupScheduleResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -1846,8 +1957,10 @@ export type GetBackupScheduleForVolumeResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1889,6 +2002,17 @@ export type GetBackupScheduleForVolumeResponses = { path: string; remote: string; readOnly?: boolean; + } | { + backend: 'sftp'; + host: string; + path: string; + username: string; + port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; + password?: string; + privateKey?: string; + readOnly?: boolean; } | { backend: 'smb'; password: string; @@ -1916,7 +2040,7 @@ export type GetBackupScheduleForVolumeResponses = { name: string; shortId: string; status: 'error' | 'mounted' | 'unmounted'; - type: 'directory' | 'nfs' | 'rclone' | 'smb' | 'webdav'; + type: 'directory' | 'nfs' | 'rclone' | 'sftp' | 'smb' | 'webdav'; updatedAt: number; }; volumeId: number; @@ -2243,8 +2367,10 @@ export type GetScheduleMirrorsResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -2347,8 +2473,10 @@ export type UpdateScheduleMirrorsResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; diff --git a/app/client/components/volume-icon.tsx b/app/client/components/volume-icon.tsx index 3094d86a..c21b3a04 100644 --- a/app/client/components/volume-icon.tsx +++ b/app/client/components/volume-icon.tsx @@ -1,53 +1,52 @@ -import { Cloud, Folder, Server, Share2 } from "lucide-react"; +import { Cloud, Folder, Server } from "lucide-react"; import type { BackendType } from "~/schemas/volumes"; type VolumeIconProps = { backend: BackendType; }; -const getIconAndColor = (backend: BackendType) => { +const getIconAndLabel = (backend: BackendType) => { switch (backend) { case "directory": return { icon: Folder, - color: "text-blue-600 dark:text-blue-400", label: "Directory", }; case "nfs": return { icon: Server, - color: "text-orange-600 dark:text-orange-400", label: "NFS", }; case "smb": return { - icon: Share2, - color: "text-purple-600 dark:text-purple-400", + icon: Server, label: "SMB", }; case "webdav": return { - icon: Cloud, - color: "text-green-600 dark:text-green-400", + icon: Server, label: "WebDAV", }; case "rclone": return { icon: Cloud, - color: "text-cyan-600 dark:text-cyan-400", label: "Rclone", }; + case "sftp": + return { + icon: Server, + label: "SFTP", + }; default: return { icon: Folder, - color: "text-gray-600 dark:text-gray-400", label: "Unknown", }; } }; export const VolumeIcon = ({ backend }: VolumeIconProps) => { - const { icon: Icon, label } = getIconAndColor(backend); + const { icon: Icon, label } = getIconAndLabel(backend); return ( diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index dce3ff52..08fa6e0b 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -58,7 +58,7 @@ const defaultValuesForType = { azure: { backend: "azure" as const, compressionMode: "auto" as const }, rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, rest: { backend: "rest" as const, compressionMode: "auto" as const }, - sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 }, + sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false }, }; export const CreateRepositoryForm = ({ diff --git a/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx index 321d3ba5..c5286a60 100644 --- a/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx @@ -9,6 +9,7 @@ import { } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; import { Textarea } from "../../../../components/ui/textarea"; +import { Switch } from "../../../../components/ui/switch"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -72,7 +73,7 @@ export const SftpRepositoryForm = ({ form }: Props) => { Path - + Repository path on the SFTP server. @@ -96,6 +97,47 @@ export const SftpRepositoryForm = ({ form }: Props) => { )} /> + ( + +
+ Skip Host Key Verification + + Disable SSH host key checking. Useful for servers with dynamic IPs or self-signed keys. + +
+ + + +
+ )} + /> + {!form.watch("skipHostKeyCheck") && ( + ( + + Known Hosts + +