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
This commit is contained in:
Nico
2025-12-28 18:30:49 +01:00
committed by GitHub
parent ae5233d9fb
commit c05aa8d5bf
18 changed files with 611 additions and 42 deletions

View File

@@ -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 <20> service <20> 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 <20> service <20> 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

View File

@@ -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
# ------------------------------

View File

@@ -31,7 +31,7 @@ Zerobyte is a backup automation tool that helps you save your data across multip
- &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic
- &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected
- &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories
- &nbsp; **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.

View File

@@ -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;

View File

@@ -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 (
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>

View File

@@ -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 = ({

View File

@@ -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) => {
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/ironmount" {...field} />
<Input placeholder="backups/zerobyte" {...field} />
</FormControl>
<FormDescription>Repository path on the SFTP server. </FormDescription>
<FormMessage />
@@ -96,6 +97,47 @@ export const SftpRepositoryForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="skipHostKeyCheck"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Skip Host Key Verification</FormLabel>
<FormDescription>
Disable SSH host key checking. Useful for servers with dynamic IPs or self-signed keys.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{!form.watch("skipHostKeyCheck") && (
<FormField
control={form.control}
name="knownHosts"
render={({ field }) => (
<FormItem>
<FormLabel>Known Hosts</FormLabel>
<FormControl>
<Textarea
placeholder="example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ..."
className="font-mono text-xs"
rows={3}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
The contents of the <code>known_hosts</code> file for this server.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
);
};

View File

@@ -22,7 +22,7 @@ import { volumeConfigSchemaBase } from "~/schemas/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volume-forms";
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
export const formSchema = type({
name: "2<=string<=32",
@@ -46,6 +46,7 @@ const defaultValuesForType = {
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
rclone: { backend: "rclone" as const, path: "/" },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@@ -96,7 +97,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const handleTestConnection = async () => {
const formValues = getValues();
if (formValues.backend === "nfs" || formValues.backend === "smb" || formValues.backend === "webdav") {
if (
formValues.backend === "nfs" ||
formValues.backend === "smb" ||
formValues.backend === "webdav" ||
formValues.backend === "sftp"
) {
testBackendConnection.mutate({
body: { config: formValues },
});
@@ -177,6 +183,18 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.sysAdmin} value="sftp">
SFTP
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>
@@ -204,6 +222,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
{watchedBackend === "webdav" && <WebDAVForm form={form} />}
{watchedBackend === "smb" && <SMBForm form={form} />}
{watchedBackend === "rclone" && <RcloneForm form={form} />}
{watchedBackend === "sftp" && <SFTPForm form={form} />}
{watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && (
<div className="space-y-3">
<div className="flex items-center gap-2">

View File

@@ -3,3 +3,4 @@ export { NFSForm } from "./nfs-form";
export { SMBForm } from "./smb-form";
export { WebDAVForm } from "./webdav-form";
export { RcloneForm } from "./rclone-form";
export { SFTPForm } from "./sftp-form";

View File

@@ -0,0 +1,161 @@
import type { UseFormReturn } from "react-hook-form";
import type { FormValues } from "../create-volume-form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea";
import { Switch } from "../../../../components/ui/switch";
type Props = {
form: UseFormReturn<FormValues>;
};
export const SFTPForm = ({ form }: Props) => {
return (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>SFTP server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="22"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>SFTP server port (default: 22).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormDescription>Username for SFTP authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>Private Key (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="font-mono text-xs"
rows={5}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/backups" {...field} />
</FormControl>
<FormDescription>Path to the directory on the SFTP server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="skipHostKeyCheck"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Skip Host Key Verification</FormLabel>
<FormDescription>
Disable SSH host key checking. Useful for servers with dynamic IPs or self-signed keys.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{!form.watch("skipHostKeyCheck") && (
<FormField
control={form.control}
name="knownHosts"
render={({ field }) => (
<FormItem>
<FormLabel>Known Hosts</FormLabel>
<FormControl>
<Textarea
placeholder="example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ..."
className="font-mono text-xs"
rows={3}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
The contents of the <code>known_hosts</code> file for this server.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
);
};

View File

@@ -77,6 +77,8 @@ export const sftpRepositoryConfigSchema = type({
user: "string",
path: "string",
privateKey: "string",
skipHostKeyCheck: "boolean = true",
knownHosts: "string?",
}).and(baseRepositoryConfigSchema);
export const repositoryConfigSchemaBase = s3RepositoryConfigSchema

View File

@@ -6,6 +6,7 @@ export const BACKEND_TYPES = {
directory: "directory",
webdav: "webdav",
rclone: "rclone",
sftp: "sftp",
} as const;
export type BackendType = keyof typeof BACKEND_TYPES;
@@ -55,11 +56,25 @@ export const rcloneConfigSchema = type({
readOnly: "boolean?",
});
export const sftpConfigSchema = type({
backend: "'sftp'",
host: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
username: "string",
password: "string?",
privateKey: "string?",
path: "string",
readOnly: "boolean?",
skipHostKeyCheck: "boolean = true",
knownHosts: "string?",
});
export const volumeConfigSchemaBase = nfsConfigSchema
.or(smbConfigSchema)
.or(webdavConfigSchema)
.or(directoryConfigSchema)
.or(rcloneConfigSchema);
.or(rcloneConfigSchema)
.or(sftpConfigSchema);
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete");

View File

@@ -6,6 +6,7 @@ import { makeNfsBackend } from "./nfs/nfs-backend";
import { makeRcloneBackend } from "./rclone/rclone-backend";
import { makeSmbBackend } from "./smb/smb-backend";
import { makeWebdavBackend } from "./webdav/webdav-backend";
import { makeSftpBackend } from "./sftp/sftp-backend";
type OperationResult = {
error?: string;
@@ -37,5 +38,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
case "rclone": {
return makeRcloneBackend(volume.config, path);
}
case "sftp": {
return makeSftpBackend(volume.config, path);
}
}
};

View File

@@ -0,0 +1,180 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend";
import { executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const SSH_KEYS_DIR = "/var/lib/zerobyte/ssh";
const getPrivateKeyPath = (mountPath: string) => {
const name = path.basename(mountPath);
return path.join(SSH_KEYS_DIR, `${name}.key`);
};
const getKnownHostsPath = (mountPath: string) => {
const name = path.basename(mountPath);
return path.join(SSH_KEYS_DIR, `${name}.known_hosts`);
};
const mount = async (config: BackendConfig, mountPath: string) => {
logger.debug(`Mounting SFTP volume ${mountPath}...`);
if (config.backend !== "sftp") {
logger.error("Provided config is not for SFTP backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for SFTP backend" };
}
if (os.platform() !== "linux") {
logger.error("SFTP mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "SFTP mounting is only supported on Linux hosts." };
}
const { status } = await checkHealth(mountPath);
if (status === "mounted") {
return { status: BACKEND_STATUS.mounted };
}
if (status === "error") {
logger.debug(`Trying to unmount any existing mounts at ${mountPath} before mounting...`);
await unmount(mountPath);
}
const run = async () => {
await fs.mkdir(mountPath, { recursive: true });
await fs.mkdir(SSH_KEYS_DIR, { recursive: true });
const { uid, gid } = os.userInfo();
const options = [
"reconnect",
"ServerAliveInterval=15",
"ServerAliveCountMax=3",
"allow_other",
`uid=${uid}`,
`gid=${gid}`,
];
if (config.skipHostKeyCheck || !config.knownHosts) {
options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) {
const knownHostsPath = getKnownHostsPath(mountPath);
await fs.writeFile(knownHostsPath, config.knownHosts, { mode: 0o600 });
options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes");
}
if (config.readOnly) {
options.push("ro");
}
if (config.port) {
options.push(`port=${config.port}`);
}
const keyPath = getPrivateKeyPath(mountPath);
if (config.privateKey) {
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
if (!normalizedKey.endsWith("\n")) {
normalizedKey += "\n";
}
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
options.push(`IdentityFile=${keyPath}`);
}
const source = `${config.username}@${config.host}:${config.path || ""}`;
const args = [source, mountPath, "-o", options.join(",")];
logger.debug(`Mounting SFTP volume ${mountPath}...`);
let result: $.ShellOutput;
if (config.password) {
const password = await cryptoUtils.resolveSecret(config.password);
args.push("-o", "password_stdin");
logger.info(`Executing sshfs: echo "******" | sshfs ${args.join(" ")}`);
result = await $`echo ${password} | sshfs ${args}`.nothrow();
} else {
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
result = await $`sshfs ${args}`.nothrow();
}
if (result.exitCode !== 0) {
const errorMsg = result.stderr.toString() || result.stdout.toString() || "Unknown error";
throw new Error(`Failed to mount SFTP volume: ${errorMsg}`);
}
logger.info(`SFTP volume at ${mountPath} mounted successfully.`);
return { status: BACKEND_STATUS.mounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT * 2, "SFTP mount");
} catch (error) {
const errorMsg = toMessage(error);
logger.error("Error mounting SFTP volume", { error: errorMsg });
return { status: BACKEND_STATUS.error, error: errorMsg };
}
};
const unmount = async (mountPath: string) => {
if (os.platform() !== "linux") {
logger.error("SFTP unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "SFTP unmounting is only supported on Linux hosts." };
}
const run = async () => {
const mount = await getMountForPath(mountPath);
if (!mount || mount.mountPoint !== mountPath) {
logger.debug(`Path ${mountPath} is not a mount point. Skipping unmount.`);
} else {
await executeUnmount(mountPath);
}
const keyPath = getPrivateKeyPath(mountPath);
await fs.unlink(keyPath).catch(() => {});
const knownHostsPath = getKnownHostsPath(mountPath);
await fs.unlink(knownHostsPath).catch(() => {});
await fs.rmdir(mountPath).catch(() => {});
logger.info(`SFTP volume at ${mountPath} unmounted successfully.`);
return { status: BACKEND_STATUS.unmounted };
};
try {
return await withTimeout(run(), OPERATION_TIMEOUT, "SFTP unmount");
} catch (error) {
logger.error("Error unmounting SFTP volume", { mountPath, error: toMessage(error) });
return { status: BACKEND_STATUS.error, error: toMessage(error) };
}
};
const checkHealth = async (mountPath: string) => {
const mount = await getMountForPath(mountPath);
if (!mount || mount.mountPoint !== mountPath) {
return { status: BACKEND_STATUS.unmounted };
}
if (mount.fstype !== "fuse.sshfs") {
return {
status: BACKEND_STATUS.error,
error: `Invalid filesystem type: ${mount.fstype} (expected fuse.sshfs)`,
};
}
return { status: BACKEND_STATUS.mounted };
};
export const makeSftpBackend = (config: BackendConfig, mountPath: string): VolumeBackend => ({
mount: () => mount(config, mountPath),
unmount: () => unmount(mountPath),
checkHealth: () => checkHealth(mountPath),
});

View File

@@ -39,13 +39,14 @@ const mount = async (config: BackendConfig, path: string) => {
const password = await cryptoUtils.resolveSecret(config.password);
const source = `//${config.server}/${config.share}`;
const { uid, gid } = os.userInfo();
const options = [
`user=${config.username}`,
`pass=${password}`,
`vers=${config.vers}`,
`port=${config.port}`,
"uid=1000",
"gid=1000",
`uid=${uid}`,
`gid=${gid}`,
];
if (config.domain) {

View File

@@ -43,9 +43,10 @@ const mount = async (config: BackendConfig, path: string) => {
const port = config.port !== defaultPort ? `:${config.port}` : "";
const source = `${protocol}://${config.server}${port}${config.path}`;
const { uid, gid } = os.userInfo();
const options = config.readOnly
? ["uid=1000", "gid=1000", "file_mode=0444", "dir_mode=0555", "ro"]
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
? [`uid=${uid}`, `gid=${gid}`, "file_mode=0444", "dir_mode=0555", "ro"]
: [`uid=${uid}`, `gid=${gid}`, "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) {
const password = await cryptoUtils.resolveSecret(config.password);

View File

@@ -31,6 +31,12 @@ async function encryptSensitiveFields(config: BackendConfig): Promise<BackendCon
...config,
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
};
case "sftp":
return {
...config,
password: config.password ? await cryptoUtils.sealSecret(config.password) : undefined,
privateKey: config.privateKey ? await cryptoUtils.sealSecret(config.privateKey) : undefined,
};
default:
return config;
}

View File

@@ -159,7 +159,7 @@ export const buildEnv = async (config: RepositoryConfig) => {
}
case "sftp": {
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`);
const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
if (!normalizedKey.endsWith("\n")) {
@@ -176,10 +176,6 @@ export const buildEnv = async (config: RepositoryConfig) => {
env._SFTP_KEY_PATH = keyPath;
const sshArgs = [
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=VERBOSE",
"-o",
@@ -190,6 +186,15 @@ export const buildEnv = async (config: RepositoryConfig) => {
keyPath,
];
if (config.skipHostKeyCheck || !config.knownHosts) {
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) {
const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`);
await fs.writeFile(knownHostsPath, config.knownHosts, { mode: 0o600 });
env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath;
sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`);
}
if (config.port && config.port !== 22) {
sshArgs.push("-p", String(config.port));
}
@@ -806,8 +811,13 @@ const copy = async (
};
export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
if (config.backend === "sftp" && env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
if (config.backend === "sftp") {
if (env._SFTP_KEY_PATH) {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {});
}
if (env._SFTP_KNOWN_HOSTS_PATH) {
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {});
}
} else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) {
await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {});
} else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {