Compare commits

..

No commits in common. "8c6fc6cfed048ed38cd553686d9c60ca5f4db4c1" and "273408cdb84aa270bcfef15c3a5baa25669e4f1d" have entirely different histories.

29 changed files with 260 additions and 3846 deletions

View file

@ -1,8 +1,8 @@
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
ARG RESTIC_VERSION="0.18.1" ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.2" ARG RCLONE_VERSION="1.74.1"
ARG SHOUTRRR_VERSION="0.15.1" ARG SHOUTRRR_VERSION="0.15.0"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,6 @@ import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input"; import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea"; import { Textarea } from "../../../../components/ui/textarea";
import { Switch } from "../../../../components/ui/switch"; import { Switch } from "../../../../components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
type Props = { type Props = {
form: UseFormReturn<FormValues>; form: UseFormReturn<FormValues>;
@ -79,9 +78,7 @@ export const SFTPForm = ({ form }: Props) => {
<FormControl> <FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} /> <SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
Password for SFTP authentication (optional if using private key).
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -101,9 +98,7 @@ export const SFTPForm = ({ form }: Props) => {
value={field.value ?? ""} value={field.value ?? ""}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
SSH private key for authentication (optional if using password).
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -163,29 +158,6 @@ 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,9 +33,7 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0"> <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-muted-foreground shrink-0">{icon}</span>
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</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 })}> <span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
{value}
</span>
</div> </div>
); );
} }
@ -45,19 +43,12 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
switch (config.backend) { switch (config.backend) {
case "directory": case "directory":
return ( return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
);
case "nfs": case "nfs":
return ( return (
<> <>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono /> <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
icon={<FolderOpen className="h-4 w-4" />}
label="Export Path"
value={config.exportPath}
mono
/>
</> </>
); );
case "smb": case "smb":
@ -93,8 +84,6 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
} }
function DonutChart({ statfs }: { statfs: StatFs }) { function DonutChart({ statfs }: { statfs: StatFs }) {
const { used = 0, total = 0 } = statfs;
const chartData = useMemo( const chartData = useMemo(
() => [ () => [
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" }, { name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
@ -104,8 +93,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
); );
const usagePercentage = useMemo(() => { const usagePercentage = useMemo(() => {
return Math.round((used / total) * 100); return Math.round((statfs.used / statfs.total) * 100);
}, [used, total]); }, [statfs]);
return ( return (
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]"> <ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
@ -125,18 +114,10 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
if (viewBox && "cx" in viewBox && "cy" in viewBox) { if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return ( return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle"> <text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan <tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-2xl font-bold"
>
{usagePercentage}% {usagePercentage}%
</tspan> </tspan>
<tspan <tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
x={viewBox.cx}
y={(viewBox.cy || 0) + 20}
className="fill-muted-foreground text-xs"
>
Used Used
</tspan> </tspan>
</text> </text>
@ -151,9 +132,7 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
} }
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => { export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const { total = 0, used = 0, free = 0 } = statfs; const hasStorage = statfs.total > 0;
const hasStorage = total > 0;
return ( return (
<Card className="px-6 py-6 @container/inner"> <Card className="px-6 py-6 @container/inner">
@ -165,11 +144,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
</CardTitle> </CardTitle>
<div className="space-y-0 divide-y divide-border/50"> <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="Name" value={volume.name} />
<ConfigRow <ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
icon={<HardDrive className="h-4 w-4" />}
label="Backend"
value={backendLabels[volume.type]}
/>
<BackendConfigRows volume={volume} /> <BackendConfigRows volume={volume} />
</div> </div>
</div> </div>
@ -187,21 +162,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<HardDrive className="h-4 w-4 text-muted-foreground" /> <HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Total</span> <span className="text-sm font-medium">Total</span>
</div> </div>
<ByteSize bytes={total} className="font-mono text-sm" /> <ByteSize bytes={statfs.total} className="font-mono text-sm" />
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-strong-accent" /> <div className="h-3 w-3 rounded-full bg-strong-accent" />
<span className="text-sm font-medium">Used</span> <span className="text-sm font-medium">Used</span>
</div> </div>
<ByteSize bytes={used} className="font-mono text-sm" /> <ByteSize bytes={statfs.used} className="font-mono text-sm" />
</div> </div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50"> <div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-primary" /> <div className="h-3 w-3 rounded-full bg-primary" />
<span className="text-sm font-medium">Free</span> <span className="text-sm font-medium">Free</span>
</div> </div>
<ByteSize bytes={free} className="font-mono text-sm" /> <ByteSize bytes={statfs.free} className="font-mono text-sm" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -1 +0,0 @@
ALTER TABLE `two_factor` ADD `verified` integer DEFAULT true NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -505,7 +505,6 @@ export const twoFactor = sqliteTable(
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => usersTable.id, { onDelete: "cascade" }), .references(() => usersTable.id, { onDelete: "cascade" }),
verified: integer("verified", { mode: "boolean" }).notNull().default(true),
}, },
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)], (table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
); );

View file

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

View file

@ -57,7 +57,6 @@ public_key="$(printf '%s' "$7" | base64 -d)"
repo_path="/srv/zerobyte-backend-integration/restic-repo" repo_path="/srv/zerobyte-backend-integration/restic-repo"
repo_password_fingerprint_path="$repo_path/.zerobyte-password-sha256" repo_password_fingerprint_path="$repo_path/.zerobyte-password-sha256"
repo_password_fingerprint="$(printf '%s' "$restic_password" | sha256sum | cut -d' ' -f1)" repo_password_fingerprint="$(printf '%s' "$restic_password" | sha256sum | cut -d' ' -f1)"
legacy_sshd_dir="/etc/ssh/zerobyte-backend-integration-legacy"
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
@ -159,11 +158,6 @@ EOF
a2ensite zerobyte-backend-integration-dav >/dev/null a2ensite zerobyte-backend-integration-dav >/dev/null
apache2ctl configtest 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 install -d -m 0755 /etc/ssh/sshd_config.d
write_file /etc/ssh/sshd_config.d/zerobyte-backend-integration.conf <<'EOF' write_file /etc/ssh/sshd_config.d/zerobyte-backend-integration.conf <<'EOF'
Match User zerobyte-sftp Match User zerobyte-sftp
@ -176,68 +170,17 @@ Match User zerobyte-sftp
EOF EOF
sshd -t 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 apache2
systemctl restart smbd systemctl restart smbd
systemctl restart ssh 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 REMOTE
ssh-keyscan "$TARGET_HOST" >"$KNOWN_HOSTS_PATH" 2>/dev/null 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" \ INTEGRATION_HOST="$TARGET_HOST" \
FIXTURE_UID="$FIXTURE_UID" \ FIXTURE_UID="$FIXTURE_UID" \
FIXTURE_GID="$FIXTURE_GID" \ FIXTURE_GID="$FIXTURE_GID" \
SMB_PASSWORD="$SMB_PASSWORD" \ SMB_PASSWORD="$SMB_PASSWORD" \
SFTP_PASSWORD="$SFTP_PASSWORD" \
WEBDAV_PASSWORD="$WEBDAV_PASSWORD" \ WEBDAV_PASSWORD="$WEBDAV_PASSWORD" \
RESTIC_PASSWORD="$RESTIC_PASSWORD" \ RESTIC_PASSWORD="$RESTIC_PASSWORD" \
SFTP_KEY_PATH="$KEY_PATH" \ SFTP_KEY_PATH="$KEY_PATH" \

View file

@ -12,7 +12,6 @@ const host = getRequiredEnv("INTEGRATION_HOST");
const fixtureUid = getRequiredNumberEnv("FIXTURE_UID"); const fixtureUid = getRequiredNumberEnv("FIXTURE_UID");
const fixtureGid = getRequiredNumberEnv("FIXTURE_GID"); const fixtureGid = getRequiredNumberEnv("FIXTURE_GID");
const smbPassword = getRequiredEnv("SMB_PASSWORD"); const smbPassword = getRequiredEnv("SMB_PASSWORD");
const sftpPassword = getRequiredEnv("SFTP_PASSWORD");
const webdavPassword = getRequiredEnv("WEBDAV_PASSWORD"); const webdavPassword = getRequiredEnv("WEBDAV_PASSWORD");
const resticPassword = getRequiredEnv("RESTIC_PASSWORD"); const resticPassword = getRequiredEnv("RESTIC_PASSWORD");
const privateKey = fs.readFileSync(getRequiredEnv("SFTP_KEY_PATH"), "utf8"); const privateKey = fs.readFileSync(getRequiredEnv("SFTP_KEY_PATH"), "utf8");
@ -30,7 +29,7 @@ const nfsEntries = [
const smbEntries = [ const smbEntries = [
{ path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText }, { path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText },
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "1755" }, { path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "0755" },
{ path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText }, { path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText },
]; ];
@ -91,41 +90,6 @@ const config = {
fixtureRoot: "case-a", fixtureRoot: "case-a",
expectedEntries: contentOnlyEntries, 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", id: "webdav-local-repo",
volume: { volume: {

View file

@ -1,6 +1,7 @@
import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Effect } from "effect"; import { Effect } from "effect";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect"; import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn"; import { fromPartial } from "@total-typescript/shoehorn";
import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol"; import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
@ -9,54 +10,10 @@ import { handleBackupCancelCommand } from "../backup-cancel";
import { handleBackupRunCommand } from "../backup-run"; import { handleBackupRunCommand } from "../backup-run";
import type { ControllerCommandContext, RunningJob } from "../../context"; import type { ControllerCommandContext, RunningJob } from "../../context";
type WebhookHandler = (context: { const server = setupServer();
request: IncomingMessage;
response: ServerResponse;
body: string;
}) => void | Promise<void>;
let webhookServer: Server; beforeAll(() => {
let webhookOrigin = ""; server.listen({ onUnhandledRequest: "error" });
let webhookHandlers = new Map<string, WebhookHandler>();
const webhookUrl = (path: string) => `${webhookOrigin}${path}`;
const webhookRoute = (path: string, handler: WebhookHandler) => [`POST ${path}`, handler] as const;
const useWebhookHandlers = (...handlers: ReturnType<typeof webhookRoute>[]) => {
webhookHandlers = new Map(handlers);
};
const sendWebhookResponse = (response: ServerResponse, status = 204, body = "") => {
response.writeHead(status);
response.end(body);
};
beforeEach(async () => {
webhookHandlers = new Map();
webhookServer = nodeHttp.createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks).toString("utf8");
const pathname = new URL(request.url ?? "/", webhookOrigin).pathname;
const handler = webhookHandlers.get(`${request.method ?? ""} ${pathname}`);
if (!handler) {
sendWebhookResponse(response, 404);
return;
}
await handler({ request, response, body });
});
await new Promise<void>((resolve) => {
webhookServer.listen(0, "127.0.0.1", resolve);
});
const address = webhookServer.address();
if (!address || typeof address === "string") {
throw new Error("Failed to bind test webhook server");
}
webhookOrigin = `http://127.0.0.1:${address.port}`;
}); });
const createDeferred = <T>() => { const createDeferred = <T>() => {
@ -68,14 +25,13 @@ const createDeferred = <T>() => {
return { promise, resolve }; return { promise, resolve };
}; };
afterEach(async () => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
webhookServer.closeAllConnections?.(); server.resetHandlers();
if (webhookServer.listening) { });
await new Promise<void>((resolve, reject) => {
webhookServer.close((error) => (error ? reject(error) : resolve())); afterAll(() => {
}); server.close();
}
}); });
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) => const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
@ -115,7 +71,7 @@ const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
password: "password", password: "password",
}, },
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: [webhookOrigin], webhookAllowedOrigins: ["http://localhost:8080"],
webhookTimeoutMs: 60_000, webhookTimeoutMs: 60_000,
...overrides, ...overrides,
}); });
@ -158,14 +114,16 @@ const runBackupCommand = async (payload: BackupRunPayload) => {
test("runs pre and post backup webhooks around restic", async () => { test("runs pre and post backup webhooks around restic", async () => {
const events: string[] = []; const events: string[] = [];
useWebhookHandlers( server.use(
webhookRoute("/pre", ({ body, response }) => { http.post("http://localhost:8080/pre", async ({ request }) => {
events.push((JSON.parse(body) as { event: string }).event); const body = (await request.json()) as { event: string };
sendWebhookResponse(response); events.push(body.event);
return new HttpResponse(null, { status: 204 });
}), }),
webhookRoute("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
events.push((JSON.parse(body) as { event: string }).event); const body = (await request.json()) as { event: string };
sendWebhookResponse(response); events.push(body.event);
return new HttpResponse(null, { status: 204 });
}), }),
); );
@ -182,8 +140,8 @@ test("runs pre and post backup webhooks around restic", async () => {
const messages = await runBackupCommand( const messages = await runBackupCommand(
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
}), }),
); );
@ -195,22 +153,22 @@ test("runs pre and post backup webhooks around restic", async () => {
test("sends configured webhook headers and body without changing them", async () => { test("sends configured webhook headers and body without changing them", async () => {
const requests: Array<{ url: string; headers: Headers; body: string }> = []; const requests: Array<{ url: string; headers: Headers; body: string }> = [];
useWebhookHandlers( server.use(
webhookRoute("/pre", ({ request, response, body }) => { http.post("http://localhost:8080/pre", async ({ request }) => {
requests.push({ requests.push({
url: webhookUrl("/pre"), url: request.url,
headers: new Headers(request.headers as Record<string, string>), headers: request.headers,
body, body: await request.text(),
}); });
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
webhookRoute("/post", ({ request, response, body }) => { http.post("http://localhost:8080/post", async ({ request }) => {
requests.push({ requests.push({
url: webhookUrl("/post"), url: request.url,
headers: new Headers(request.headers as Record<string, string>), headers: request.headers,
body, body: await request.text(),
}); });
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
@ -224,12 +182,12 @@ test("sends configured webhook headers and body without changing them", async ()
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: { pre: {
url: webhookUrl("/pre"), url: "http://localhost:8080/pre",
headers: ["authorization: Bearer pre-token", "content-type: application/json"], headers: ["authorization: Bearer pre-token", "content-type: application/json"],
body: '{"action":"stop"}', body: '{"action":"stop"}',
}, },
post: { post: {
url: webhookUrl("/post"), url: "http://localhost:8080/post",
headers: ["authorization: Bearer post-token"], headers: ["authorization: Bearer post-token"],
body: "start-container", body: "start-container",
}, },
@ -238,20 +196,20 @@ test("sends configured webhook headers and body without changing them", async ()
); );
expect(requests).toHaveLength(2); expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(webhookUrl("/pre")); expect(requests[0]?.url).toBe("http://localhost:8080/pre");
expect(requests[0]?.headers.get("authorization")).toBe("Bearer pre-token"); expect(requests[0]?.headers.get("authorization")).toBe("Bearer pre-token");
expect(requests[0]?.headers.get("content-type")).toBe("application/json"); expect(requests[0]?.headers.get("content-type")).toBe("application/json");
expect(requests[0]?.body).toBe('{"action":"stop"}'); expect(requests[0]?.body).toBe('{"action":"stop"}');
expect(requests[1]?.url).toBe(webhookUrl("/post")); expect(requests[1]?.url).toBe("http://localhost:8080/post");
expect(requests[1]?.headers.get("authorization")).toBe("Bearer post-token"); expect(requests[1]?.headers.get("authorization")).toBe("Bearer post-token");
expect(requests[1]?.body).toBe("start-container"); expect(requests[1]?.body).toBe("start-container");
}); });
test("fails without running restic when the pre-backup webhook fails", async () => { test("fails without running restic when the pre-backup webhook fails", async () => {
const backupMock = vi.fn(); const backupMock = vi.fn();
useWebhookHandlers( server.use(
webhookRoute("/pre", ({ response }) => { http.post("http://localhost:8080/pre", () => {
sendWebhookResponse(response, 500, "stop failed"); return new HttpResponse("stop failed", { status: 500 });
}), }),
); );
vi.spyOn(resticServer, "createRestic").mockReturnValue( vi.spyOn(resticServer, "createRestic").mockReturnValue(
@ -263,7 +221,7 @@ test("fails without running restic when the pre-backup webhook fails", async ()
const messages = await runBackupCommand( const messages = await runBackupCommand(
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: null, post: null,
}, },
}), }),
@ -278,9 +236,9 @@ test("fails without running restic when the pre-backup webhook fails", async ()
}); });
test("reports a post-backup webhook failure as completed warning details", async () => { test("reports a post-backup webhook failure as completed warning details", async () => {
useWebhookHandlers( server.use(
webhookRoute("/post", ({ response }) => { http.post("http://localhost:8080/post", () => {
sendWebhookResponse(response, 500, "start failed"); return new HttpResponse("start failed", { status: 500 });
}), }),
); );
vi.spyOn(resticServer, "createRestic").mockReturnValue( vi.spyOn(resticServer, "createRestic").mockReturnValue(
@ -293,7 +251,7 @@ test("reports a post-backup webhook failure as completed warning details", async
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
}), }),
); );
@ -306,9 +264,9 @@ test("reports a post-backup webhook failure as completed warning details", async
}); });
test("includes post-backup webhook failure details when a backup is cancelled", async () => { test("includes post-backup webhook failure details when a backup is cancelled", async () => {
useWebhookHandlers( server.use(
webhookRoute("/post", ({ response }) => { http.post("http://localhost:8080/post", () => {
sendWebhookResponse(response, 500, "start failed"); return new HttpResponse("start failed", { status: 500 });
}), }),
); );
vi.spyOn(resticServer, "createRestic").mockReturnValue( vi.spyOn(resticServer, "createRestic").mockReturnValue(
@ -326,7 +284,7 @@ test("includes post-backup webhook failure details when a backup is cancelled",
createRunPayload({ createRunPayload({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
}), }),
); );

View file

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

View file

@ -70,7 +70,6 @@
"zod": "^4.4.3", "zod": "^4.4.3",
}, },
"devDependencies": { "devDependencies": {
"@better-auth/utils": "0.4.0",
"@effect/language-service": "^0.86.1", "@effect/language-service": "^0.86.1",
"@faker-js/faker": "^10.4.0", "@faker-js/faker": "^10.4.0",
"@hey-api/openapi-ts": "^0.97.1", "@hey-api/openapi-ts": "^0.97.1",

View file

@ -62,11 +62,8 @@ services:
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b - APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
- BASE_URL=http://localhost:4096 - BASE_URL=http://localhost:4096
- TRUSTED_ORIGINS=https://tinyauth.example.com:5557,https://localhost:5557 - TRUSTED_ORIGINS=https://tinyauth.example.com:5557,https://localhost:5557
- WEBHOOK_ALLOWED_ORIGINS=http://host.docker.internal:18080
- NODE_EXTRA_CA_CERTS=/tinyauth-ca/caddy/pki/authorities/local/root.crt - NODE_EXTRA_CA_CERTS=/tinyauth-ca/caddy/pki/authorities/local/root.crt
- SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt - SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt
extra_hosts:
- "host.docker.internal:host-gateway"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
cap_add: cap_add:

View file

@ -576,16 +576,15 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
await volumeLink.click(); await volumeLink.click();
await expect(page).toHaveURL(/\/volumes\/[^/?#]+/); await expect(page).toHaveURL(/\/volumes\/[^/?#]+/);
await expect(page.getByRole("button", { name: "Actions" })).toBeVisible(); await expect(page.getByRole("button", { name: "Actions" })).toBeVisible();
const deleteVolumeMenuItem = page.getByRole("menuitem", { name: "Delete" });
await expect(async () => { await expect(async () => {
await page.getByRole("button", { name: "Actions" }).click(); await page.getByRole("button", { name: "Actions" }).click();
const deleteVolumeMenuItem = page.getByRole("menuitem", { name: "Delete" });
await expect(deleteVolumeMenuItem).toBeVisible(); await expect(deleteVolumeMenuItem).toBeVisible();
await deleteVolumeMenuItem.click();
await expect(page.getByRole("heading", { name: "Delete volume?" })).toBeVisible();
}).toPass({ timeout: 10000 }); }).toPass({ timeout: 10000 });
await expect( await deleteVolumeMenuItem.click({ force: true });
page.getByText("All backup schedules associated with this volume will also be removed."), await expect(page.getByRole("heading", { name: "Delete volume?" })).toBeVisible();
).toBeVisible(); await expect(page.getByText("All backup schedules associated with this volume will also be removed.")).toBeVisible();
await page.getByRole("button", { name: "Delete volume" }).click(); await page.getByRole("button", { name: "Delete volume" }).click();
await expect(page.getByText("Volume deleted successfully")).toBeVisible(); await expect(page.getByText("Volume deleted successfully")).toBeVisible();

View file

@ -52,8 +52,8 @@ async function getRepositoryShortId(page: Page, name: string) {
} }
async function createRepository(page: Page, name: string) { async function createRepository(page: Page, name: string) {
await gotoAndWaitForAppReady(page, "/repositories/create"); await gotoAndWaitForAppReady(page, "/repositories");
await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible(); await page.getByRole("button", { name: "Create repository" }).click();
await page.getByRole("textbox", { name: "Name" }).fill(name); await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("combobox", { name: "Backend" }).click(); await page.getByRole("combobox", { name: "Backend" }).click();
await page.getByRole("option", { name: "Local" }).click(); await page.getByRole("option", { name: "Local" }).click();

View file

@ -1,84 +0,0 @@
import type { Browser, Page } from "@playwright/test";
import { base32 } from "@better-auth/utils/base32";
import { createOTP } from "@better-auth/utils/otp";
import path from "node:path";
import { expect, test } from "./test";
import { gotoAndWaitForAppReady } from "./helpers/page";
const twoFactorPassword = "password123";
async function createTwoFactorUser(browser: Browser, username: string) {
const context = await browser.newContext({
baseURL: `http://${process.env.SERVER_IP}:4096`,
storageState: { cookies: [], origins: [] },
});
const page = await context.newPage();
await gotoAndWaitForAppReady(page, "/onboarding");
await page.getByRole("textbox", { name: "Email" }).fill(`${username}@example.com`);
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(twoFactorPassword);
await page.getByRole("textbox", { name: "Confirm Password" }).fill(twoFactorPassword);
await page.getByRole("button", { name: "Create admin user" }).click();
await expect(page.getByText("Download Your Recovery Key")).toBeVisible();
await page.getByRole("textbox", { name: "Confirm Your Password" }).fill(twoFactorPassword);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Download Recovery Key" }).click();
const download = await downloadPromise;
await download.saveAs(path.join(process.cwd(), "playwright", `restic-${username}.pass`));
await expect(page).toHaveURL("/volumes");
return { context, page };
}
async function generateTotp(secret: string) {
const decodedSecret = new TextDecoder().decode(base32.decode(secret));
return createOTP(decodedSecret).totp();
}
async function fillOtp(page: Page, code: string) {
await page.locator('[data-slot="input-otp"]').click();
await page.keyboard.type(code);
}
test("user can enable 2FA and sign in with a TOTP code", async ({ browser }, testInfo) => {
const username = `e2e-2fa-${testInfo.parallelIndex}-${testInfo.retry}`;
const { context, page: twoFactorPage } = await createTwoFactorUser(browser, username);
try {
await gotoAndWaitForAppReady(twoFactorPage, "/settings");
await twoFactorPage.getByRole("button", { name: "Enable 2FA" }).click();
await twoFactorPage.getByRole("textbox", { name: "Password" }).fill(twoFactorPassword);
await twoFactorPage.getByRole("button", { name: "Continue" }).click();
await expect(twoFactorPage.getByRole("heading", { name: "Scan QR Code" })).toBeVisible();
const secret = await twoFactorPage.locator("input[readonly]").inputValue();
await twoFactorPage.getByRole("button", { name: "Continue" }).click();
await expect(twoFactorPage.getByRole("heading", { name: "Verify setup" })).toBeVisible();
await fillOtp(twoFactorPage, await generateTotp(secret));
await expect(twoFactorPage.getByText(/Status:\s*Enabled/)).toBeVisible();
await context.clearCookies();
await gotoAndWaitForAppReady(twoFactorPage, "/login");
const usernameInput = twoFactorPage.getByRole("textbox", { name: "Username" });
await usernameInput.fill(username);
await expect(usernameInput).toHaveValue(username);
await twoFactorPage.getByRole("textbox", { name: "Password" }).fill(twoFactorPassword);
await twoFactorPage.getByRole("button", { name: "Login" }).click();
await expect(twoFactorPage.getByRole("heading", { name: "Two-Factor Authentication" })).toBeVisible();
await fillOtp(twoFactorPage, await generateTotp(secret));
await expect(twoFactorPage).toHaveURL("/volumes");
await expect(twoFactorPage.getByRole("button", { name: "Create Volume" })).toBeVisible();
} finally {
await context.close();
}
});

View file

@ -1,172 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { expect, test } from "./test";
import { gotoAndWaitForAppReady } from "./helpers/page";
import { startWebhookReceiver } from "./helpers/webhook-receiver";
const receiverPort = 18080;
const receiverOrigin = `http://host.docker.internal:${receiverPort}`;
const testDataPath = path.join(process.cwd(), "playwright", "temp");
type BackupSchedule = {
shortId: string;
lastBackupStatus: "success" | "error" | "in_progress" | "warning" | null;
lastBackupError: string | null;
};
function prepareTestFile(runId: string) {
const runPath = path.join(testDataPath, runId);
fs.mkdirSync(runPath, { recursive: true });
fs.writeFileSync(path.join(runPath, "notification-webhook-test.json"), JSON.stringify({ runId }));
return `/test-data/${runId}`;
}
test("delivers notification destinations and backup lifecycle webhooks", async ({ page }, testInfo) => {
const receiver = await startWebhookReceiver(receiverPort);
try {
const runId = `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`;
const sourcePath = prepareTestFile(runId);
const repositoryPath = `/var/lib/zerobyte/data/repos/${runId}`;
await gotoAndWaitForAppReady(page, "/");
await expect(page).toHaveURL("/volumes");
const volumeResponse = await page.request.post("/api/v1/volumes", {
data: {
name: `Volume-${runId}`,
config: { backend: "directory", path: sourcePath, readOnly: false },
},
});
expect(volumeResponse.ok()).toBe(true);
const volume = (await volumeResponse.json()) as { shortId: string };
const repositoryResponse = await page.request.post("/api/v1/repositories", {
data: {
name: `Repo-${runId}`,
config: { backend: "local", path: repositoryPath, isExistingRepository: false },
},
});
expect(repositoryResponse.ok()).toBe(true);
const repository = (await repositoryResponse.json()) as { repository: { shortId: string } };
const notificationResponse = await page.request.post("/api/v1/notifications/destinations", {
data: {
name: `Notify-${runId}`,
config: {
type: "generic",
url: `${receiverOrigin}/notifications`,
method: "POST",
contentType: "application/json",
headers: ["X-Zerobyte-E2E: notifications"],
useJson: true,
titleKey: "title",
messageKey: "message",
},
},
});
expect(notificationResponse.ok()).toBe(true);
const notification = (await notificationResponse.json()) as { id: number };
const testNotificationResponse = await page.request.post(
`/api/v1/notifications/destinations/${notification.id}/test`,
);
expect(testNotificationResponse.ok()).toBe(true);
await receiver.waitFor(
(request) => request.path === "/notifications" && request.body.includes("Zerobyte Test Notification"),
);
const scheduleResponse = await page.request.post("/api/v1/backups", {
data: {
name: `Backup-${runId}`,
volumeId: volume.shortId,
repositoryId: repository.repository.shortId,
enabled: false,
cronExpression: "",
includePaths: [],
excludePatterns: [],
excludeIfPresent: [],
includePatterns: [],
oneFileSystem: false,
backupWebhooks: {
pre: {
url: `${receiverOrigin}/backup/pre`,
headers: ["X-Zerobyte-E2E: pre"],
},
post: {
url: `${receiverOrigin}/backup/post`,
headers: ["X-Zerobyte-E2E: post"],
},
},
maxRetries: 0,
retryDelay: 1,
},
});
expect(scheduleResponse.ok()).toBe(true);
const schedule = (await scheduleResponse.json()) as BackupSchedule;
const assignmentResponse = await page.request.put(`/api/v1/backups/${schedule.shortId}/notifications`, {
data: {
assignments: [
{
destinationId: notification.id,
notifyOnStart: true,
notifyOnSuccess: true,
notifyOnWarning: true,
notifyOnFailure: true,
},
],
},
});
expect(assignmentResponse.ok()).toBe(true);
const runResponse = await page.request.post(`/api/v1/backups/${schedule.shortId}/run`);
expect(runResponse.ok()).toBe(true);
await receiver.waitFor(
(request) =>
request.path === "/notifications" &&
request.body.includes(`Zerobyte Backup-${runId} started`) &&
request.body.includes(`Volume-${runId}`),
);
const preWebhook = await receiver.waitFor((request) => request.path === "/backup/pre");
expect(preWebhook.method).toBe("POST");
expect(preWebhook.headers["x-zerobyte-e2e"]).toBe("pre");
expect(preWebhook.json).toMatchObject({
phase: "pre",
event: "backup.pre",
scheduleId: schedule.shortId,
sourcePath,
});
const postWebhook = await receiver.waitFor((request) => request.path === "/backup/post");
expect(postWebhook.method).toBe("POST");
expect(postWebhook.headers["x-zerobyte-e2e"]).toBe("post");
expect(postWebhook.json).toMatchObject({
phase: "post",
event: "backup.post",
scheduleId: schedule.shortId,
sourcePath,
status: "success",
});
await receiver.waitFor(
(request) =>
request.path === "/notifications" &&
request.body.includes(`Zerobyte Backup-${runId} completed successfully`) &&
request.body.includes(`Repo-${runId}`),
);
await expect(async () => {
const latestScheduleResponse = await page.request.get(`/api/v1/backups/${schedule.shortId}`);
expect(latestScheduleResponse.ok()).toBe(true);
const latestSchedule = (await latestScheduleResponse.json()) as BackupSchedule;
expect(latestSchedule.lastBackupStatus).toBe("success");
expect(latestSchedule.lastBackupError).toBeNull();
}).toPass({ timeout: 30000 });
} finally {
await receiver.close();
}
});

View file

@ -1,57 +0,0 @@
import { createServer, type IncomingHttpHeaders } from "node:http";
import { expect } from "@playwright/test";
export type WebhookReceiverRequest = {
method: string;
path: string;
headers: IncomingHttpHeaders;
body: string;
json: unknown;
};
export async function startWebhookReceiver(port: number) {
const requests: WebhookReceiverRequest[] = [];
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(chunk);
const body = Buffer.concat(chunks).toString("utf8");
requests.push({
method: request.method ?? "",
path: new URL(request.url ?? "/", "http://receiver.test").pathname,
headers: request.headers,
body,
json: JSON.parse(body),
});
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "0.0.0.0", () => {
server.off("error", reject);
resolve();
});
});
return {
waitFor: async (predicate: (request: WebhookReceiverRequest) => boolean) => {
await expect
.poll(() => requests.some(predicate), {
timeout: 30000,
message: `Received webhook requests: ${JSON.stringify(requests, null, 2)}`,
})
.toBe(true);
return requests.find(predicate)!;
},
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}

View file

@ -101,7 +101,6 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@better-auth/utils": "0.4.0",
"@effect/language-service": "^0.86.1", "@effect/language-service": "^0.86.1",
"@faker-js/faker": "^10.4.0", "@faker-js/faker": "^10.4.0",
"@hey-api/openapi-ts": "^0.97.1", "@hey-api/openapi-ts": "^0.97.1",

View file

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

View file

@ -1,66 +1,21 @@
import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { Effect } from "effect"; import { Effect } from "effect";
import { afterEach, beforeEach, expect, test } from "vitest"; import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js"; import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js";
type WebhookHandler = (context: { const server = setupServer();
request: IncomingMessage;
response: ServerResponse;
body: string;
}) => void | Promise<void>;
let webhookServer: Server; beforeAll(() => {
let webhookOrigin = ""; server.listen({ onUnhandledRequest: "error" });
let webhookHandlers = new Map<string, WebhookHandler>();
const routeKey = (method: string, path: string) => `${method} ${path}`;
const webhookUrl = (path: string) => `${webhookOrigin}${path}`;
const postWebhook = (path: string, handler: WebhookHandler) => ({ key: routeKey("POST", path), handler });
const useWebhookHandlers = (...handlers: ReturnType<typeof postWebhook>[]) => {
webhookHandlers = new Map(handlers.map(({ key, handler }) => [key, handler]));
};
const sendWebhookResponse = (response: ServerResponse, status = 204, body = "") => {
response.writeHead(status);
response.end(body);
};
beforeEach(async () => {
webhookHandlers = new Map();
webhookServer = nodeHttp.createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks).toString("utf8");
const pathname = new URL(request.url ?? "/", webhookOrigin).pathname;
const handler = webhookHandlers.get(routeKey(request.method ?? "", pathname));
if (!handler) {
sendWebhookResponse(response, 404);
return;
}
await handler({ request, response, body });
});
await new Promise<void>((resolve) => {
webhookServer.listen(0, "127.0.0.1", resolve);
});
const address = webhookServer.address();
if (!address || typeof address === "string") {
throw new Error("Failed to bind test webhook server");
}
webhookOrigin = `http://127.0.0.1:${address.port}`;
}); });
afterEach(async () => { afterEach(() => {
webhookServer.closeAllConnections?.(); server.resetHandlers();
if (webhookServer.listening) { });
await new Promise<void>((resolve, reject) => {
webhookServer.close((error) => (error ? reject(error) : resolve())); afterAll(() => {
}); server.close();
}
}); });
const metadata = { const metadata = {
@ -89,7 +44,7 @@ const runWithHooks = <TResult>(
repositoryConfig: { backend: "local", path: "/tmp/repository" }, repositoryConfig: { backend: "local", path: "/tmp/repository" },
options: {}, options: {},
webhooks: { pre: null, post: null }, webhooks: { pre: null, post: null },
webhookAllowedOrigins: [webhookOrigin], webhookAllowedOrigins: ["http://localhost:8080"],
webhookTimeoutMs: 60_000, webhookTimeoutMs: 60_000,
signal: defaultSignal(), signal: defaultSignal(),
...options, ...options,
@ -113,23 +68,23 @@ test("runs pre and post webhooks around a successful backup", async () => {
let preBody: WebhookBody | undefined; let preBody: WebhookBody | undefined;
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
useWebhookHandlers( server.use(
postWebhook("/pre", ({ body, response }) => { http.post("http://localhost:8080/pre", async ({ request }) => {
events.push("pre"); events.push("pre");
preBody = JSON.parse(body) as WebhookBody; preBody = (await request.json()) as WebhookBody;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
events.push("post"); events.push("post");
postBody = JSON.parse(body) as WebhookBody; postBody = (await request.json()) as WebhookBody;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
runBackup: () => runBackup: () =>
Effect.sync(() => { Effect.sync(() => {
@ -147,17 +102,17 @@ test("runs pre and post webhooks around a successful backup", async () => {
test("sends warning details to the post-backup webhook for a non-zero completed backup", async () => { test("sends warning details to the post-backup webhook for a non-zero completed backup", async () => {
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as WebhookBody; postBody = (await request.json()) as WebhookBody;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"), runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"),
}); });
@ -174,17 +129,17 @@ test("sends warning details to the post-backup webhook for a non-zero completed
test("sends warning details to the post-backup webhook for a zero-exit completed backup with warnings", async () => { test("sends warning details to the post-backup webhook for a zero-exit completed backup with warnings", async () => {
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as WebhookBody; postBody = (await request.json()) as WebhookBody;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
runBackup: () => completedBackup("snapshot-1", 0, "Backup was stopped by the user"), runBackup: () => completedBackup("snapshot-1", 0, "Backup was stopped by the user"),
}); });
@ -201,17 +156,17 @@ test("sends warning details to the post-backup webhook for a zero-exit completed
test("sends error details to the post-backup webhook when the backup fails", async () => { test("sends error details to the post-backup webhook when the backup fails", async () => {
let postBody: WebhookBody | undefined; let postBody: WebhookBody | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as WebhookBody; postBody = (await request.json()) as WebhookBody;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
runBackup: () => Effect.fail(new Error("restic failed")), runBackup: () => Effect.fail(new Error("restic failed")),
}); });
@ -224,20 +179,20 @@ test("fails without running the backup or post webhook when the pre-backup webho
let backupRan = false; let backupRan = false;
let postRan = false; let postRan = false;
useWebhookHandlers( server.use(
postWebhook("/pre", ({ response }) => { http.post("http://localhost:8080/pre", () => {
sendWebhookResponse(response, 500, "stop failed"); return new HttpResponse("stop failed", { status: 500 });
}), }),
postWebhook("/post", ({ response }) => { http.post("http://localhost:8080/post", () => {
postRan = true; postRan = true;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
runBackup: () => runBackup: () =>
Effect.sync(() => { Effect.sync(() => {
@ -260,16 +215,12 @@ test("sends configured webhook headers and body without replacing them", async (
let authorization: string | null | undefined; let authorization: string | null | undefined;
let contentType: string | null | undefined; let contentType: string | null | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ request, response, body: requestBody }) => { http.post("http://localhost:8080/post", async ({ request }) => {
body = requestBody; body = await request.text();
authorization = Array.isArray(request.headers.authorization) authorization = request.headers.get("authorization");
? request.headers.authorization.join(", ") contentType = request.headers.get("content-type");
: request.headers.authorization; return new HttpResponse(null, { status: 204 });
contentType = Array.isArray(request.headers["content-type"])
? request.headers["content-type"].join(", ")
: (request.headers["content-type"] ?? null);
sendWebhookResponse(response);
}), }),
); );
@ -277,7 +228,7 @@ test("sends configured webhook headers and body without replacing them", async (
webhooks: { webhooks: {
pre: null, pre: null,
post: { post: {
url: webhookUrl("/post"), url: "http://localhost:8080/post",
headers: ["authorization: Bearer post-token"], headers: ["authorization: Bearer post-token"],
body: "start-container", body: "start-container",
}, },
@ -294,17 +245,17 @@ test("runs the post-backup webhook after cancellation without using the cancelle
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as { status?: string; error?: string }; postBody = (await request.json()) as { status?: string; error?: string };
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -322,17 +273,17 @@ test("runs the post-backup webhook when cancellation returns a completed backup
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as { status?: string; error?: string }; postBody = (await request.json()) as { status?: string; error?: string };
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -350,17 +301,17 @@ test("includes post-backup webhook failure details after cancellation", async ()
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as { status?: string; error?: string }; postBody = (await request.json()) as { status?: string; error?: string };
sendWebhookResponse(response, 500, "start failed"); return new HttpResponse("start failed", { status: 500 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -383,17 +334,17 @@ test("includes post-backup webhook failure details after completed cancellation"
const abortController = new AbortController(); const abortController = new AbortController();
let postBody: { status?: string; error?: string } | undefined; let postBody: { status?: string; error?: string } | undefined;
useWebhookHandlers( server.use(
postWebhook("/post", ({ body, response }) => { http.post("http://localhost:8080/post", async ({ request }) => {
postBody = JSON.parse(body) as { status?: string; error?: string }; postBody = (await request.json()) as { status?: string; error?: string };
sendWebhookResponse(response, 500, "cleanup failed"); return new HttpResponse("cleanup failed", { status: 500 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: null, pre: null,
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -417,7 +368,7 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: "http://127.0.0.1:9/pre" }, pre: { url: "http://127.0.0.1:8080/pre" },
post: null, post: null,
}, },
runBackup: () => runBackup: () =>
@ -430,23 +381,23 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
expect(backupRan).toBe(false); expect(backupRan).toBe(false);
expect(result).toEqual({ expect(result).toEqual({
status: "failed", status: "failed",
error: "pre webhook URL origin is not allowed. Add http://127.0.0.1:9 to WEBHOOK_ALLOWED_ORIGINS.", error: "pre webhook URL origin is not allowed. Add http://127.0.0.1:8080 to WEBHOOK_ALLOWED_ORIGINS.",
}); });
}); });
test("matches configured webhook origins with trailing slashes or paths", async () => { test("matches configured webhook origins with trailing slashes or paths", async () => {
let backupRan = false; let backupRan = false;
useWebhookHandlers( server.use(
postWebhook("/pre", ({ response }) => { http.post("http://localhost:8080/pre", () => {
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhookAllowedOrigins: [`${webhookOrigin}/`, "http://example.com/webhook"], webhookAllowedOrigins: ["http://localhost:8080/", "http://example.com/webhook"],
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: null, post: null,
}, },
runBackup: () => runBackup: () =>
@ -463,20 +414,19 @@ test("matches configured webhook origins with trailing slashes or paths", async
test("does not follow webhook redirects", async () => { test("does not follow webhook redirects", async () => {
let redirectedTargetCalled = false; let redirectedTargetCalled = false;
useWebhookHandlers( server.use(
postWebhook("/redirect", ({ response }) => { http.post("http://localhost:8080/redirect", () => {
response.writeHead(302, { location: webhookUrl("/target") }); return new HttpResponse(null, { status: 302, headers: { location: "http://localhost:8080/target" } });
response.end();
}), }),
postWebhook("/target", ({ response }) => { http.post("http://localhost:8080/target", () => {
redirectedTargetCalled = true; redirectedTargetCalled = true;
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/redirect") }, pre: { url: "http://localhost:8080/redirect" },
post: null, post: null,
}, },
runBackup: () => completedBackup(null), runBackup: () => completedBackup(null),
@ -486,37 +436,10 @@ test("does not follow webhook redirects", async () => {
expect(result).toEqual({ status: "failed", error: "pre webhook returned HTTP 302" }); expect(result).toEqual({ status: "failed", error: "pre webhook returned HTTP 302" });
}); });
test("uses the configured webhook timeout for the request", async () => {
useWebhookHandlers(
postWebhook("/pre", ({ response }) => {
setTimeout(() => {
if (response.destroyed) return;
response.writeHead(204);
response.end();
}, 300);
}),
);
const result = await runWithHooks({
webhookTimeoutMs: 50,
webhooks: {
pre: { url: webhookUrl("/pre") },
post: null,
},
runBackup: () => completedBackup(null),
});
expect(result.status).toBe("failed");
if (result.status === "failed") {
expect(result.error).toContain("pre webhook failed: Webhook timed out");
}
});
test("rejects oversized webhook request bodies and headers", async () => { test("rejects oversized webhook request bodies and headers", async () => {
const bodyResult = await runWithHooks({ const bodyResult = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre"), body: "a".repeat(64 * 1024 + 1) }, pre: { url: "http://localhost:8080/pre", body: "a".repeat(64 * 1024 + 1) },
post: null, post: null,
}, },
runBackup: () => completedBackup(null), runBackup: () => completedBackup(null),
@ -526,7 +449,7 @@ test("rejects oversized webhook request bodies and headers", async () => {
const headersResult = await runWithHooks({ const headersResult = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre"), headers: [`x-large: ${"a".repeat(8 * 1024)}`] }, pre: { url: "http://localhost:8080/pre", headers: [`x-large: ${"a".repeat(8 * 1024)}`] },
post: null, post: null,
}, },
runBackup: () => completedBackup(null), runBackup: () => completedBackup(null),
@ -536,13 +459,13 @@ test("rejects oversized webhook request bodies and headers", async () => {
}); });
test("rejects malformed webhook header lines", () => { test("rejects malformed webhook header lines", () => {
expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/pre"), headers: ["Malformed"] })).toThrow( expect(() => backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Malformed"] })).toThrow(
"Headers must use non-empty Key: Value format with valid header names", "Headers must use non-empty Key: Value format with valid header names",
); );
expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/pre"), headers: ["Bad Header: value"] })).toThrow( expect(() =>
"Headers must use non-empty Key: Value format with valid header names", backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Bad Header: value"] }),
); ).toThrow("Headers must use non-empty Key: Value format with valid header names");
}); });
test("cancels before the pre-backup webhook without running the backup", async () => { test("cancels before the pre-backup webhook without running the backup", async () => {
@ -553,8 +476,8 @@ test("cancels before the pre-backup webhook without running the backup", async (
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: { url: webhookUrl("/post") }, post: { url: "http://localhost:8080/post" },
}, },
signal: abortController.signal, signal: abortController.signal,
runBackup: () => runBackup: () =>
@ -572,16 +495,16 @@ test("cancels after the pre-backup webhook without running the backup", async ()
const abortController = new AbortController(); const abortController = new AbortController();
let backupRan = false; let backupRan = false;
useWebhookHandlers( server.use(
postWebhook("/pre", ({ response }) => { http.post("http://localhost:8080/pre", () => {
abortController.abort(new Error("Backup was cancelled")); abortController.abort(new Error("Backup was cancelled"));
sendWebhookResponse(response); return new HttpResponse(null, { status: 204 });
}), }),
); );
const result = await runWithHooks({ const result = await runWithHooks({
webhooks: { webhooks: {
pre: { url: webhookUrl("/pre") }, pre: { url: "http://localhost:8080/pre" },
post: null, post: null,
}, },
signal: abortController.signal, signal: abortController.signal,

View file

@ -1,5 +1,3 @@
import http from "node:http";
import https from "node:https";
import { Data, Effect } from "effect"; import { Data, Effect } from "effect";
import { z } from "zod"; import { z } from "zod";
import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js"; import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js";
@ -26,11 +24,7 @@ export const isAllowedWebhookUrl = (url: string, allowedOrigins: readonly string
export const backupWebhookConfigSchema = z.object({ export const backupWebhookConfigSchema = z.object({
url: z.url(), url: z.url(),
headers: z headers: z
.array( .array(z.string().refine(isValidHeaderLine, "Headers must use non-empty Key: Value format with valid header names"))
z
.string()
.refine(isValidHeaderLine, "Headers must use non-empty Key: Value format with valid header names"),
)
.optional(), .optional(),
body: z.string().optional(), body: z.string().optional(),
}); });
@ -147,7 +141,7 @@ const createAbortController = (timeoutMs: number, signal?: AbortSignal) => {
}; };
}; };
const createWebhookRequest = (config: BackupWebhookConfig, context: BackupWebhookContext) => { const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookContext): RequestInit => {
const headers = new Headers(); const headers = new Headers();
const body = config.body ?? JSON.stringify(context); const body = config.body ?? JSON.stringify(context);
@ -190,87 +184,13 @@ const createWebhookRequest = (config: BackupWebhookConfig, context: BackupWebhoo
} }
return { return {
method: "POST",
headers, headers,
body: config.body === undefined ? body : new TextEncoder().encode(body), body: config.body === undefined ? body : new TextEncoder().encode(body),
redirect: "manual",
}; };
}; };
const sendWebhookRequest = (
config: BackupWebhookConfig,
context: BackupWebhookContext,
{ signal, timeoutMs }: { signal: AbortSignal; timeoutMs: number },
) =>
new Promise<void>((resolve, reject) => {
const url = new URL(config.url);
const client = url.protocol === "http:" ? http : url.protocol === "https:" ? https : null;
if (!client) {
reject(new Error(`Unsupported webhook URL protocol ${url.protocol}`));
return;
}
const request = createWebhookRequest(config, context);
const headers = Object.fromEntries(request.headers.entries());
if (!request.headers.has("content-length")) {
headers["content-length"] = String(
getByteLength(typeof request.body === "string" ? request.body : (config.body ?? "")),
);
}
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", abortRequest);
callback();
};
const req = client.request(url, { method: "POST", headers }, (res) => {
res.resume();
res.on("error", (error) => settle(() => reject(error)));
res.on("end", () => {
const statusCode = res.statusCode ?? 0;
if (statusCode >= 200 && statusCode < 300) {
settle(resolve);
return;
}
settle(() =>
reject(
new BackupWebhookError({
cause: new Error(`${context.phase} webhook returned HTTP ${statusCode}`),
message: `${context.phase} webhook returned HTTP ${statusCode}`,
}),
),
);
});
});
const abortRequest = () => {
const error = signal.reason || new Error("Operation aborted");
settle(() => reject(error));
req.destroy(error);
};
req.setTimeout(timeoutMs, () => {
const error = new Error(`Webhook timed out after ${Math.round(timeoutMs / 1000)} seconds`);
settle(() => reject(error));
req.destroy(error);
});
req.on("error", (error) => settle(() => reject(error)));
if (signal.aborted) {
abortRequest();
return;
}
signal.addEventListener("abort", abortRequest, { once: true });
req.end(request.body);
});
const runBackupWebhook = ( const runBackupWebhook = (
config: BackupWebhookConfig | null, config: BackupWebhookConfig | null,
context: BackupWebhookContext, context: BackupWebhookContext,
@ -300,10 +220,14 @@ const runBackupWebhook = (
}); });
} }
await sendWebhookRequest(config, context, { const response = await fetch(config.url, { ...createRequestInit(config, context), signal: controller.signal });
signal: controller.signal,
timeoutMs: options.timeoutMs, if (!response.ok) {
}); throw new BackupWebhookError({
cause: new Error(`${context.phase} webhook returned HTTP ${response.status}`),
message: `${context.phase} webhook returned HTTP ${response.status}`,
});
}
}, },
catch: (error) => { catch: (error) => {
if (error instanceof BackupWebhookError) { if (error instanceof BackupWebhookError) {
@ -403,8 +327,7 @@ export const runBackupLifecycle = <TResult>({
if (signal.aborted) { if (signal.aborted) {
return { return {
status: "cancelled", status: "cancelled",
message: message: appendDetails(formatError(signal.reason || backupResult.hookError), postHookError) || undefined,
appendDetails(formatError(signal.reason || backupResult.hookError), postHookError) || undefined,
}; };
} }

View file

@ -318,7 +318,6 @@ describe("buildEnv", () => {
path: "/backups", path: "/backups",
privateKey: PLAIN_PRIVATE_KEY, privateKey: PLAIN_PRIVATE_KEY,
skipHostKeyCheck: true as const, skipHostKeyCheck: true as const,
allowLegacySshRsa: false as const,
}); });
test("throws for passphrase-protected private keys", async () => { test("throws for passphrase-protected private keys", async () => {
@ -350,20 +349,6 @@ describe("buildEnv", () => {
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null"); 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 () => { test("uses StrictHostKeyChecking=yes when knownHosts is absent", async () => {
const env = await buildEnvForTest( const env = await buildEnvForTest(
{ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined }, { ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined },

View file

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

View file

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