feat(sftp): add legacy ssh-rsa option

This commit is contained in:
Nicolas Meienberger 2026-05-26 08:21:49 +02:00
parent f269751014
commit de6ead9345
No known key found for this signature in database
14 changed files with 337 additions and 91 deletions

View file

@ -337,14 +337,7 @@ export type ListVolumesResponses = {
200: Array<{
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -359,6 +352,7 @@ export type ListVolumesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -392,7 +386,15 @@ export type ListVolumesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
}>;
};
@ -416,6 +418,7 @@ export type CreateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -449,6 +452,7 @@ export type CreateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -463,14 +467,7 @@ export type CreateVolumeResponses = {
201: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -485,6 +482,7 @@ export type CreateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -518,7 +516,15 @@ export type CreateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
};
@ -541,6 +547,7 @@ export type TestConnectionData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -574,6 +581,7 @@ export type TestConnectionData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -637,14 +645,7 @@ export type GetVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -659,6 +660,7 @@ export type GetVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -692,13 +694,21 @@ export type GetVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
statfs: {
total: number;
used: number;
free: number;
total?: number;
used?: number;
free?: number;
};
};
};
@ -723,6 +733,7 @@ export type UpdateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -756,6 +767,7 @@ export type UpdateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path: {
@ -779,14 +791,7 @@ export type UpdateVolumeResponses = {
200: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -801,6 +806,7 @@ export type UpdateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -834,7 +840,15 @@ export type UpdateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
};
@ -855,8 +869,8 @@ export type MountVolumeResponses = {
* Volume mounted successfully
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -876,8 +890,8 @@ export type UnmountVolumeResponses = {
* Volume unmounted successfully
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -904,8 +918,8 @@ export type HealthCheckVolumeResponses = {
* Volume health check result
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -932,7 +946,7 @@ export type ListFilesResponses = {
files: Array<{
name: string;
path: string;
type: 'file' | 'directory';
type: 'directory' | 'file';
size?: number;
modifiedAt?: number;
}>;
@ -966,8 +980,8 @@ export type BrowseFilesystemResponses = {
directories: Array<{
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
type: 'directory';
size?: unknown;
modifiedAt?: number;
}>;
path: string;
@ -1136,6 +1150,7 @@ export type ListRepositoriesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1319,6 +1334,7 @@ export type CreateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1557,6 +1573,7 @@ export type GetRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1740,6 +1757,7 @@ export type UpdateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1931,6 +1949,7 @@ export type UpdateRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2457,14 +2476,7 @@ export type ListBackupSchedulesResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2479,6 +2491,7 @@ export type ListBackupSchedulesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2512,7 +2525,15 @@ export type ListBackupSchedulesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -2664,6 +2685,7 @@ export type ListBackupSchedulesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2875,14 +2897,7 @@ export type GetBackupScheduleResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2897,6 +2912,7 @@ export type GetBackupScheduleResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2930,7 +2946,15 @@ export type GetBackupScheduleResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -3082,6 +3106,7 @@ export type GetBackupScheduleResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3274,14 +3299,7 @@ export type GetBackupScheduleForVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -3296,6 +3314,7 @@ export type GetBackupScheduleForVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -3329,7 +3348,15 @@ export type GetBackupScheduleForVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -3481,6 +3508,7 @@ export type GetBackupScheduleForVolumeResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3955,6 +3983,7 @@ export type GetScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -4167,6 +4196,7 @@ export type UpdateScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;

View file

@ -92,7 +92,13 @@ const defaultValuesForType = (repoBase: string) => ({
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, skipHostKeyCheck: false },
sftp: {
backend: "sftp" as const,
compressionMode: "auto" as const,
port: 22,
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
});
export const CreateRepositoryForm = ({
@ -251,7 +257,9 @@ export const CreateRepositoryForm = ({
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
<FormDescription>
Check this if the repository already exists at the specified location
</FormDescription>
</div>
</FormItem>
)}
@ -281,8 +289,8 @@ export const CreateRepositoryForm = ({
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
a custom password for the existing repository.
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
account) or enter a custom password for the existing repository.
</FormDescription>
</FormItem>

View file

@ -26,6 +26,7 @@ export const AdvancedForm = ({ form }: Props) => {
const cacert = useWatch({ control: form.control, name: "cacert" });
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
const backend = useWatch({ control: form.control, name: "backend" });
return (
<Collapsible>
@ -44,7 +45,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable upload speed limit</FormLabel>
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription>
<FormDescription className="text-xs">
Limit upload speed to the repository
</FormDescription>
</div>
</FormItem>
)}
@ -66,7 +69,9 @@ export const AdvancedForm = ({ form }: Props) => {
placeholder="10"
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -120,7 +125,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable download speed limit</FormLabel>
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription>
<FormDescription className="text-xs">
Limit download speed from the repository
</FormDescription>
</div>
</FormItem>
)}
@ -144,7 +151,9 @@ export const AdvancedForm = ({ form }: Props) => {
disabled={!downloadLimitEnabled}
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -208,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
TLS validation instead.
This option is disabled because a CA certificate is provided. Remove the CA
certificate to skip TLS validation instead.
</p>
</TooltipContent>
</Tooltip>
@ -217,8 +226,8 @@ export const AdvancedForm = ({ form }: Props) => {
<div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription>
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
insecure and should only be used for testing.
Disable TLS certificate verification for HTTPS connections with self-signed
certificates. This is insecure and should only be used for testing.
</FormDescription>
</div>
</FormItem>
@ -235,7 +244,9 @@ export const AdvancedForm = ({ form }: Props) => {
<TooltipTrigger asChild>
<div>
<Textarea
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={6}
disabled={insecureTls}
{...field}
@ -244,8 +255,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
Verification" to provide a custom CA certificate.
CA certificate is disabled because TLS validation is being skipped. Uncheck
"Skip TLS Certificate Verification" to provide a custom CA certificate.
</p>
</TooltipContent>
</Tooltip>
@ -266,6 +277,26 @@ export const AdvancedForm = ({ form }: Props) => {
</FormItem>
)}
/>
{backend === "sftp" && (
<FormField
control={form.control}
name="allowLegacySshRsa"
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>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Checkbox checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</CollapsibleContent>
</Collapsible>
);

View file

@ -69,7 +69,7 @@ const defaultValuesForType = {
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
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 },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false, allowLegacySshRsa: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@ -230,7 +230,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
<SelectItem
disabled={!capabilities.rclone || !capabilities.sysAdmin}
value="rclone"
>
rclone
</SelectItem>
</div>
@ -238,7 +241,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}>
<TooltipContent
className={cn({
hidden: !capabilities.sysAdmin || capabilities.rclone,
})}
>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>

View file

@ -12,6 +12,7 @@ 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";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
type Props = {
form: UseFormReturn<FormValues>;
@ -78,7 +79,9 @@ export const SFTPForm = ({ form }: Props) => {
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
<FormDescription>
Password for SFTP authentication (optional if using private key).
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -98,7 +101,9 @@ export const SFTPForm = ({ form }: Props) => {
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
<FormDescription>
SSH private key for authentication (optional if using password).
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -158,6 +163,29 @@ export const SFTPForm = ({ form }: Props) => {
)}
/>
)}
<Collapsible>
<CollapsibleTrigger>Advanced Settings</CollapsibleTrigger>
<CollapsibleContent className="pb-4 pt-4">
<FormField
control={form.control}
name="allowLegacySshRsa"
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>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
);
};

View file

@ -33,7 +33,9 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>
{value}
</span>
</div>
);
}
@ -43,12 +45,19 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
switch (config.backend) {
case "directory":
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
return (
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
);
case "nfs":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
<ConfigRow
icon={<FolderOpen className="h-4 w-4" />}
label="Export Path"
value={config.exportPath}
mono
/>
</>
);
case "smb":
@ -84,6 +93,8 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
}
function DonutChart({ statfs }: { statfs: StatFs }) {
const { used = 0, total = 0 } = statfs;
const chartData = useMemo(
() => [
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
@ -93,8 +104,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
);
const usagePercentage = useMemo(() => {
return Math.round((statfs.used / statfs.total) * 100);
}, [statfs]);
return Math.round((used / total) * 100);
}, [used, total]);
return (
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
@ -114,10 +125,18 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-2xl font-bold"
>
{usagePercentage}%
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 20}
className="fill-muted-foreground text-xs"
>
Used
</tspan>
</text>
@ -132,7 +151,9 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
}
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const hasStorage = statfs.total > 0;
const { total = 0, used = 0, free = 0 } = statfs;
const hasStorage = total > 0;
return (
<Card className="px-6 py-6 @container/inner">
@ -144,7 +165,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Name" value={volume.name} />
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
<ConfigRow
icon={<HardDrive className="h-4 w-4" />}
label="Backend"
value={backendLabels[volume.type]}
/>
<BackendConfigRows volume={volume} />
</div>
</div>
@ -162,21 +187,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Total</span>
</div>
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
<ByteSize bytes={total} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-strong-accent" />
<span className="text-sm font-medium">Used</span>
</div>
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
<ByteSize bytes={used} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-primary" />
<span className="text-sm font-medium">Free</span>
</div>
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
<ByteSize bytes={free} className="font-mono text-sm" />
</div>
</div>
</div>

View file

@ -279,6 +279,7 @@ describe("backup execution - validation failures", () => {
privateKey: "key",
path: "/data",
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
});
const repository = await createTestRepository();

View file

@ -57,6 +57,7 @@ public_key="$(printf '%s' "$7" | base64 -d)"
repo_path="/srv/zerobyte-backend-integration/restic-repo"
repo_password_fingerprint_path="$repo_path/.zerobyte-password-sha256"
repo_password_fingerprint="$(printf '%s' "$restic_password" | sha256sum | cut -d' ' -f1)"
legacy_sshd_dir="/etc/ssh/zerobyte-backend-integration-legacy"
export DEBIAN_FRONTEND=noninteractive
@ -158,6 +159,11 @@ EOF
a2ensite zerobyte-backend-integration-dav >/dev/null
apache2ctl configtest
install -d -m 0700 "$legacy_sshd_dir"
if [[ ! -f "$legacy_sshd_dir/ssh_host_rsa_key" ]]; then
ssh-keygen -q -t rsa -b 2048 -N "" -f "$legacy_sshd_dir/ssh_host_rsa_key"
fi
install -d -m 0755 /etc/ssh/sshd_config.d
write_file /etc/ssh/sshd_config.d/zerobyte-backend-integration.conf <<'EOF'
Match User zerobyte-sftp
@ -170,17 +176,68 @@ Match User zerobyte-sftp
EOF
sshd -t
write_file "$legacy_sshd_dir/sshd_config" <<EOF
Port 2222
ListenAddress 0.0.0.0
PidFile /run/zerobyte-backend-integration-legacy-sshd.pid
HostKey $legacy_sshd_dir/ssh_host_rsa_key
HostKeyAlgorithms ssh-rsa
PasswordAuthentication yes
PubkeyAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PermitTTY no
X11Forwarding no
AllowTcpForwarding no
Subsystem sftp internal-sftp
Match User zerobyte-sftp
ForceCommand internal-sftp
EOF
sshd -t -f "$legacy_sshd_dir/sshd_config"
write_file /etc/systemd/system/zerobyte-backend-integration-legacy-sshd.service <<EOF
[Unit]
Description=Zerobyte Backend Integration Legacy SFTP
After=network.target
[Service]
Type=simple
ExecStart=/usr/sbin/sshd -D -f $legacy_sshd_dir/sshd_config
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now zerobyte-backend-integration-legacy-sshd.service
systemctl restart apache2
systemctl restart smbd
systemctl restart ssh
systemctl restart zerobyte-backend-integration-legacy-sshd.service
systemctl is-active --quiet zerobyte-backend-integration-legacy-sshd.service
for _ in 1 2 3 4 5; do
ss -ltn | grep -q ':2222' && break
sleep 1
done
ss -ltn | grep -q ':2222'
REMOTE
ssh-keyscan "$TARGET_HOST" >"$KNOWN_HOSTS_PATH" 2>/dev/null
if ! ssh-keyscan -T 5 -p 2222 "$TARGET_HOST" >>"$KNOWN_HOSTS_PATH" 2>/dev/null; then
echo "Failed to scan legacy SFTP host key from $TARGET_HOST:2222" >&2
echo "Check the target service with:" >&2
echo " ssh $TARGET systemctl status zerobyte-backend-integration-legacy-sshd.service" >&2
exit 1
fi
INTEGRATION_HOST="$TARGET_HOST" \
FIXTURE_UID="$FIXTURE_UID" \
FIXTURE_GID="$FIXTURE_GID" \
SMB_PASSWORD="$SMB_PASSWORD" \
SFTP_PASSWORD="$SFTP_PASSWORD" \
WEBDAV_PASSWORD="$WEBDAV_PASSWORD" \
RESTIC_PASSWORD="$RESTIC_PASSWORD" \
SFTP_KEY_PATH="$KEY_PATH" \

View file

@ -12,6 +12,7 @@ const host = getRequiredEnv("INTEGRATION_HOST");
const fixtureUid = getRequiredNumberEnv("FIXTURE_UID");
const fixtureGid = getRequiredNumberEnv("FIXTURE_GID");
const smbPassword = getRequiredEnv("SMB_PASSWORD");
const sftpPassword = getRequiredEnv("SFTP_PASSWORD");
const webdavPassword = getRequiredEnv("WEBDAV_PASSWORD");
const resticPassword = getRequiredEnv("RESTIC_PASSWORD");
const privateKey = fs.readFileSync(getRequiredEnv("SFTP_KEY_PATH"), "utf8");
@ -29,7 +30,7 @@ const nfsEntries = [
const smbEntries = [
{ path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText },
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "0755" },
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "1755" },
{ path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText },
];
@ -90,6 +91,41 @@ const config = {
fixtureRoot: "case-a",
expectedEntries: contentOnlyEntries,
},
{
id: "sftp-password-local-repo",
volume: {
backend: "sftp",
host,
port: 22,
username: "zerobyte-sftp",
password: sftpPassword,
path: "/srv/zerobyte-backend-integration/fixtures",
readOnly: true,
skipHostKeyCheck: false,
knownHosts,
},
repository: { backend: "local", path: "repo-sftp-password-volume" },
fixtureRoot: "case-a",
expectedEntries: contentOnlyEntries,
},
{
id: "sftp-legacy-rsa-hostkey-local-repo",
volume: {
backend: "sftp",
host,
port: 2222,
username: "zerobyte-sftp",
password: sftpPassword,
path: "/srv/zerobyte-backend-integration/fixtures",
readOnly: true,
skipHostKeyCheck: false,
knownHosts,
allowLegacySshRsa: true,
},
repository: { backend: "local", path: "repo-sftp-legacy-rsa-hostkey-volume" },
fixtureRoot: "case-a",
expectedEntries: contentOnlyEntries,
},
{
id: "webdav-local-repo",
volume: {

View file

@ -142,6 +142,8 @@ const mount = async (config: BackendConfig, mountPath: string) => {
`gid=${gid}`,
];
if (config.allowLegacySshRsa) options.push("HostKeyAlgorithms=+ssh-rsa");
if (config.skipHostKeyCheck) {
options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) {

View file

@ -85,6 +85,7 @@ export const sftpConfigSchema = z.object({
readOnly: z.boolean().optional(),
skipHostKeyCheck: z.boolean().default(false),
knownHosts: z.string().optional(),
allowLegacySshRsa: z.boolean().default(false),
});
export const volumeConfigSchema = z.discriminatedUnion("backend", [

View file

@ -318,6 +318,7 @@ describe("buildEnv", () => {
path: "/backups",
privateKey: PLAIN_PRIVATE_KEY,
skipHostKeyCheck: true as const,
allowLegacySshRsa: false as const,
});
test("throws for passphrase-protected private keys", async () => {
@ -349,6 +350,20 @@ describe("buildEnv", () => {
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null");
});
test("does not allow legacy RSA/SHA1 SSH algorithms by default", async () => {
const env = await buildEnvForTest(baseSftpConfig, "org-1");
expect(env._SFTP_SSH_ARGS).not.toContain("HostKeyAlgorithms=+ssh-rsa");
expect(env._SFTP_SSH_ARGS).not.toContain("PubkeyAcceptedAlgorithms=+ssh-rsa");
});
test("allows legacy RSA/SHA1 SSH algorithms after modern defaults when enabled", async () => {
const env = await buildEnvForTest({ ...baseSftpConfig, allowLegacySshRsa: true }, "org-1");
expect(env._SFTP_SSH_ARGS).toContain("HostKeyAlgorithms=+ssh-rsa");
expect(env._SFTP_SSH_ARGS).toContain("PubkeyAcceptedAlgorithms=+ssh-rsa");
});
test("uses StrictHostKeyChecking=yes when knownHosts is absent", async () => {
const env = await buildEnvForTest(
{ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined },

View file

@ -153,6 +153,10 @@ export const buildEnv = async (
keyPath,
];
if (config.allowLegacySshRsa) {
sshArgs.push("-o", "HostKeyAlgorithms=+ssh-rsa", "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa");
}
if (config.skipHostKeyCheck) {
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) {

View file

@ -118,6 +118,7 @@ export const sftpRepositoryConfigSchema = z
privateKey: z.string().min(1),
skipHostKeyCheck: z.boolean().default(false),
knownHosts: z.string().optional(),
allowLegacySshRsa: z.boolean().default(false),
})
.extend(baseRepositoryConfigSchema.shape);