feat(sftp): add legacy ssh-rsa option
This commit is contained in:
parent
f269751014
commit
de6ead9345
14 changed files with 337 additions and 91 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ describe("backup execution - validation failures", () => {
|
|||
privateKey: "key",
|
||||
path: "/data",
|
||||
skipHostKeyCheck: false,
|
||||
allowLegacySshRsa: false,
|
||||
},
|
||||
});
|
||||
const repository = await createTestRepository();
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue