mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-21 07:22:47 -04:00
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:
@@ -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;
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
161
app/client/modules/volumes/components/volume-forms/sftp-form.tsx
Normal file
161
app/client/modules/volumes/components/volume-forms/sftp-form.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
180
app/server/modules/backends/sftp/sftp-backend.ts
Normal file
180
app/server/modules/backends/sftp/sftp-backend.ts
Normal 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),
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user