Compare commits
10 commits
main
...
v0.37.3-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6fc6cfed | ||
|
|
6434068976 | ||
|
|
2add15c8c0 | ||
|
|
581e0b007b | ||
|
|
cb8668e95d | ||
|
|
c9063a963b | ||
|
|
df834dd416 | ||
|
|
9274af5079 | ||
|
|
f269751014 | ||
|
|
c752e2d52d |
29 changed files with 3846 additions and 260 deletions
|
|
@ -1,8 +1,8 @@
|
|||
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
|
||||
|
||||
ARG RESTIC_VERSION="0.18.1"
|
||||
ARG RCLONE_VERSION="1.74.1"
|
||||
ARG SHOUTRRR_VERSION="0.15.0"
|
||||
ARG RCLONE_VERSION="1.74.2"
|
||||
ARG SHOUTRRR_VERSION="0.15.1"
|
||||
|
||||
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
||||
|
|
|
|||
|
|
@ -337,14 +337,7 @@ export type ListVolumesResponses = {
|
|||
200: Array<{
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -359,6 +352,7 @@ export type ListVolumesResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -392,7 +386,15 @@ export type ListVolumesResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
}>;
|
||||
};
|
||||
|
|
@ -416,6 +418,7 @@ export type CreateVolumeData = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -449,6 +452,7 @@ export type CreateVolumeData = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
};
|
||||
path?: never;
|
||||
|
|
@ -463,14 +467,7 @@ export type CreateVolumeResponses = {
|
|||
201: {
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -485,6 +482,7 @@ export type CreateVolumeResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -518,7 +516,15 @@ export type CreateVolumeResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
};
|
||||
};
|
||||
|
|
@ -541,6 +547,7 @@ export type TestConnectionData = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -574,6 +581,7 @@ export type TestConnectionData = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
};
|
||||
path?: never;
|
||||
|
|
@ -637,14 +645,7 @@ export type GetVolumeResponses = {
|
|||
volume: {
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -659,6 +660,7 @@ export type GetVolumeResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -692,13 +694,21 @@ export type GetVolumeResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
};
|
||||
statfs: {
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
total?: number;
|
||||
used?: number;
|
||||
free?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -723,6 +733,7 @@ export type UpdateVolumeData = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -756,6 +767,7 @@ export type UpdateVolumeData = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
};
|
||||
path: {
|
||||
|
|
@ -779,14 +791,7 @@ export type UpdateVolumeResponses = {
|
|||
200: {
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -801,6 +806,7 @@ export type UpdateVolumeResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -834,7 +840,15 @@ export type UpdateVolumeResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
};
|
||||
};
|
||||
|
|
@ -855,8 +869,8 @@ export type MountVolumeResponses = {
|
|||
* Volume mounted successfully
|
||||
*/
|
||||
200: {
|
||||
error?: string;
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -876,8 +890,8 @@ export type UnmountVolumeResponses = {
|
|||
* Volume unmounted successfully
|
||||
*/
|
||||
200: {
|
||||
error?: string;
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -904,8 +918,8 @@ export type HealthCheckVolumeResponses = {
|
|||
* Volume health check result
|
||||
*/
|
||||
200: {
|
||||
error?: string;
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -932,7 +946,7 @@ export type ListFilesResponses = {
|
|||
files: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
type: 'directory' | 'file';
|
||||
size?: number;
|
||||
modifiedAt?: number;
|
||||
}>;
|
||||
|
|
@ -966,8 +980,8 @@ export type BrowseFilesystemResponses = {
|
|||
directories: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
type: 'directory';
|
||||
size?: unknown;
|
||||
modifiedAt?: number;
|
||||
}>;
|
||||
path: string;
|
||||
|
|
@ -1136,6 +1150,7 @@ export type ListRepositoriesResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -1319,6 +1334,7 @@ export type CreateRepositoryData = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -1557,6 +1573,7 @@ export type GetRepositoryResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -1740,6 +1757,7 @@ export type UpdateRepositoryData = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -1931,6 +1949,7 @@ export type UpdateRepositoryResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -2457,14 +2476,7 @@ export type ListBackupSchedulesResponses = {
|
|||
volume: {
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -2479,6 +2491,7 @@ export type ListBackupSchedulesResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -2512,7 +2525,15 @@ export type ListBackupSchedulesResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
};
|
||||
repository: {
|
||||
|
|
@ -2664,6 +2685,7 @@ export type ListBackupSchedulesResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -2875,14 +2897,7 @@ export type GetBackupScheduleResponses = {
|
|||
volume: {
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -2897,6 +2912,7 @@ export type GetBackupScheduleResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -2930,7 +2946,15 @@ export type GetBackupScheduleResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
};
|
||||
repository: {
|
||||
|
|
@ -3082,6 +3106,7 @@ export type GetBackupScheduleResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -3274,14 +3299,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
volume: {
|
||||
id: number;
|
||||
shortId: string;
|
||||
provisioningId: string | null;
|
||||
name: string;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
config: {
|
||||
backend: 'nfs';
|
||||
server: string;
|
||||
|
|
@ -3296,6 +3314,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
username?: string;
|
||||
password?: string;
|
||||
guest?: boolean;
|
||||
mapToContainerUidGid?: boolean;
|
||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||
domain?: string;
|
||||
port?: string | number;
|
||||
|
|
@ -3329,7 +3348,15 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
readOnly?: boolean;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
};
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastHealthCheck: number;
|
||||
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||
status: 'mounted' | 'unmounted' | 'error';
|
||||
lastError: string | null;
|
||||
provisioningId?: string | null;
|
||||
autoRemount: boolean;
|
||||
};
|
||||
repository: {
|
||||
|
|
@ -3481,6 +3508,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -3955,6 +3983,7 @@ export type GetScheduleMirrorsResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
@ -4167,6 +4196,7 @@ export type UpdateScheduleMirrorsResponses = {
|
|||
privateKey: string;
|
||||
skipHostKeyCheck?: boolean;
|
||||
knownHosts?: string;
|
||||
allowLegacySshRsa?: boolean;
|
||||
isExistingRepository?: boolean;
|
||||
customPassword?: string;
|
||||
cacert?: string;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,13 @@ const defaultValuesForType = (repoBase: string) => ({
|
|||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false },
|
||||
sftp: {
|
||||
backend: "sftp" as const,
|
||||
compressionMode: "auto" as const,
|
||||
port: 22,
|
||||
skipHostKeyCheck: false,
|
||||
allowLegacySshRsa: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const CreateRepositoryForm = ({
|
||||
|
|
@ -251,7 +257,9 @@ export const CreateRepositoryForm = ({
|
|||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Import existing repository</FormLabel>
|
||||
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
|
||||
<FormDescription>
|
||||
Check this if the repository already exists at the specified location
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -281,8 +289,8 @@ export const CreateRepositoryForm = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
|
||||
a custom password for the existing repository.
|
||||
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
|
||||
account) or enter a custom password for the existing repository.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
const cacert = useWatch({ control: form.control, name: "cacert" });
|
||||
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
|
||||
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
|
||||
const backend = useWatch({ control: form.control, name: "backend" });
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
|
|
@ -44,7 +45,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Enable upload speed limit</FormLabel>
|
||||
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription>
|
||||
<FormDescription className="text-xs">
|
||||
Limit upload speed to the repository
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -66,7 +69,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
placeholder="10"
|
||||
className="pr-12"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
|
@ -120,7 +125,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<FormLabel>Enable download speed limit</FormLabel>
|
||||
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription>
|
||||
<FormDescription className="text-xs">
|
||||
Limit download speed from the repository
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -144,7 +151,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
disabled={!downloadLimitEnabled}
|
||||
className="pr-12"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
|
@ -208,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: !cacert })}>
|
||||
<p className="max-w-xs">
|
||||
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
|
||||
TLS validation instead.
|
||||
This option is disabled because a CA certificate is provided. Remove the CA
|
||||
certificate to skip TLS validation instead.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -217,8 +226,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>Skip TLS certificate verification</FormLabel>
|
||||
<FormDescription>
|
||||
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
|
||||
insecure and should only be used for testing.
|
||||
Disable TLS certificate verification for HTTPS connections with self-signed
|
||||
certificates. This is insecure and should only be used for testing.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
|
@ -235,7 +244,9 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Textarea
|
||||
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
rows={6}
|
||||
disabled={insecureTls}
|
||||
{...field}
|
||||
|
|
@ -244,8 +255,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent className={cn({ hidden: !insecureTls })}>
|
||||
<p className="max-w-xs">
|
||||
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
|
||||
Verification" to provide a custom CA certificate.
|
||||
CA certificate is disabled because TLS validation is being skipped. Uncheck
|
||||
"Skip TLS Certificate Verification" to provide a custom CA certificate.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -266,6 +277,26 @@ export const AdvancedForm = ({ form }: Props) => {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{backend === "sftp" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowLegacySshRsa"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
|
||||
<FormDescription>
|
||||
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
|
||||
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Checkbox checked={field.value ?? false} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ const defaultValuesForType = {
|
|||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
|
||||
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
|
||||
rclone: { backend: "rclone" as const, path: "/" },
|
||||
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
|
||||
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false, allowLegacySshRsa: false },
|
||||
};
|
||||
|
||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||
|
|
@ -230,7 +230,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
|
||||
<SelectItem
|
||||
disabled={!capabilities.rclone || !capabilities.sysAdmin}
|
||||
value="rclone"
|
||||
>
|
||||
rclone
|
||||
</SelectItem>
|
||||
</div>
|
||||
|
|
@ -238,7 +241,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
|
||||
<p>Remote mounts require SYS_ADMIN capability</p>
|
||||
</TooltipContent>
|
||||
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}>
|
||||
<TooltipContent
|
||||
className={cn({
|
||||
hidden: !capabilities.sysAdmin || capabilities.rclone,
|
||||
})}
|
||||
>
|
||||
<p>Setup rclone to use this backend</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Input } from "../../../../components/ui/input";
|
|||
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||
import { Textarea } from "../../../../components/ui/textarea";
|
||||
import { Switch } from "../../../../components/ui/switch";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<FormValues>;
|
||||
|
|
@ -78,7 +79,9 @@ export const SFTPForm = ({ form }: Props) => {
|
|||
<FormControl>
|
||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
|
||||
<FormDescription>
|
||||
Password for SFTP authentication (optional if using private key).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -98,7 +101,9 @@ export const SFTPForm = ({ form }: Props) => {
|
|||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
|
||||
<FormDescription>
|
||||
SSH private key for authentication (optional if using password).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
|
@ -158,6 +163,29 @@ export const SFTPForm = ({ form }: Props) => {
|
|||
)}
|
||||
/>
|
||||
)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Advanced Settings</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pb-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowLegacySshRsa"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
|
||||
<FormDescription>
|
||||
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
|
||||
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
|
|||
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</span>
|
||||
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
|
||||
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,12 +45,19 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
|
|||
|
||||
switch (config.backend) {
|
||||
case "directory":
|
||||
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
|
||||
return (
|
||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
|
||||
);
|
||||
case "nfs":
|
||||
return (
|
||||
<>
|
||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
|
||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
|
||||
<ConfigRow
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label="Export Path"
|
||||
value={config.exportPath}
|
||||
mono
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "smb":
|
||||
|
|
@ -84,6 +93,8 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
|
|||
}
|
||||
|
||||
function DonutChart({ statfs }: { statfs: StatFs }) {
|
||||
const { used = 0, total = 0 } = statfs;
|
||||
|
||||
const chartData = useMemo(
|
||||
() => [
|
||||
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
|
||||
|
|
@ -93,8 +104,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
|||
);
|
||||
|
||||
const usagePercentage = useMemo(() => {
|
||||
return Math.round((statfs.used / statfs.total) * 100);
|
||||
}, [statfs]);
|
||||
return Math.round((used / total) * 100);
|
||||
}, [used, total]);
|
||||
|
||||
return (
|
||||
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
|
||||
|
|
@ -114,10 +125,18 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
|||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-2xl font-bold"
|
||||
>
|
||||
{usagePercentage}%
|
||||
</tspan>
|
||||
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 20}
|
||||
className="fill-muted-foreground text-xs"
|
||||
>
|
||||
Used
|
||||
</tspan>
|
||||
</text>
|
||||
|
|
@ -132,7 +151,9 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
|||
}
|
||||
|
||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||
const hasStorage = statfs.total > 0;
|
||||
const { total = 0, used = 0, free = 0 } = statfs;
|
||||
|
||||
const hasStorage = total > 0;
|
||||
|
||||
return (
|
||||
<Card className="px-6 py-6 @container/inner">
|
||||
|
|
@ -144,7 +165,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
</CardTitle>
|
||||
<div className="space-y-0 divide-y divide-border/50">
|
||||
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Name" value={volume.name} />
|
||||
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
|
||||
<ConfigRow
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
label="Backend"
|
||||
value={backendLabels[volume.type]}
|
||||
/>
|
||||
<BackendConfigRows volume={volume} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -162,21 +187,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
|||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Total</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
||||
<ByteSize bytes={total} className="font-mono text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-strong-accent" />
|
||||
<span className="text-sm font-medium">Used</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
|
||||
<ByteSize bytes={used} className="font-mono text-sm" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-primary" />
|
||||
<span className="text-sm font-medium">Free</span>
|
||||
</div>
|
||||
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
|
||||
<ByteSize bytes={free} className="font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
1
app/drizzle/20260522174225_organic_nehzno/migration.sql
Normal file
1
app/drizzle/20260522174225_organic_nehzno/migration.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `two_factor` ADD `verified` integer DEFAULT true NOT NULL;
|
||||
2821
app/drizzle/20260522174225_organic_nehzno/snapshot.json
Normal file
2821
app/drizzle/20260522174225_organic_nehzno/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -505,6 +505,7 @@ export const twoFactor = sqliteTable(
|
|||
userId: text("user_id")
|
||||
.notNull()
|
||||
.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)],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ describe("backup execution - validation failures", () => {
|
|||
privateKey: "key",
|
||||
path: "/data",
|
||||
skipHostKeyCheck: false,
|
||||
allowLegacySshRsa: false,
|
||||
},
|
||||
});
|
||||
const repository = await createTestRepository();
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ public_key="$(printf '%s' "$7" | base64 -d)"
|
|||
repo_path="/srv/zerobyte-backend-integration/restic-repo"
|
||||
repo_password_fingerprint_path="$repo_path/.zerobyte-password-sha256"
|
||||
repo_password_fingerprint="$(printf '%s' "$restic_password" | sha256sum | cut -d' ' -f1)"
|
||||
legacy_sshd_dir="/etc/ssh/zerobyte-backend-integration-legacy"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
|
|
@ -158,6 +159,11 @@ EOF
|
|||
a2ensite zerobyte-backend-integration-dav >/dev/null
|
||||
apache2ctl configtest
|
||||
|
||||
install -d -m 0700 "$legacy_sshd_dir"
|
||||
if [[ ! -f "$legacy_sshd_dir/ssh_host_rsa_key" ]]; then
|
||||
ssh-keygen -q -t rsa -b 2048 -N "" -f "$legacy_sshd_dir/ssh_host_rsa_key"
|
||||
fi
|
||||
|
||||
install -d -m 0755 /etc/ssh/sshd_config.d
|
||||
write_file /etc/ssh/sshd_config.d/zerobyte-backend-integration.conf <<'EOF'
|
||||
Match User zerobyte-sftp
|
||||
|
|
@ -170,17 +176,68 @@ Match User zerobyte-sftp
|
|||
EOF
|
||||
sshd -t
|
||||
|
||||
write_file "$legacy_sshd_dir/sshd_config" <<EOF
|
||||
Port 2222
|
||||
ListenAddress 0.0.0.0
|
||||
PidFile /run/zerobyte-backend-integration-legacy-sshd.pid
|
||||
HostKey $legacy_sshd_dir/ssh_host_rsa_key
|
||||
HostKeyAlgorithms ssh-rsa
|
||||
PasswordAuthentication yes
|
||||
PubkeyAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PermitRootLogin no
|
||||
PermitTTY no
|
||||
X11Forwarding no
|
||||
AllowTcpForwarding no
|
||||
Subsystem sftp internal-sftp
|
||||
|
||||
Match User zerobyte-sftp
|
||||
ForceCommand internal-sftp
|
||||
EOF
|
||||
sshd -t -f "$legacy_sshd_dir/sshd_config"
|
||||
|
||||
write_file /etc/systemd/system/zerobyte-backend-integration-legacy-sshd.service <<EOF
|
||||
[Unit]
|
||||
Description=Zerobyte Backend Integration Legacy SFTP
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/sbin/sshd -D -f $legacy_sshd_dir/sshd_config
|
||||
ExecReload=/bin/kill -HUP \$MAINPID
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now zerobyte-backend-integration-legacy-sshd.service
|
||||
|
||||
systemctl restart apache2
|
||||
systemctl restart smbd
|
||||
systemctl restart ssh
|
||||
systemctl restart zerobyte-backend-integration-legacy-sshd.service
|
||||
systemctl is-active --quiet zerobyte-backend-integration-legacy-sshd.service
|
||||
for _ in 1 2 3 4 5; do
|
||||
ss -ltn | grep -q ':2222' && break
|
||||
sleep 1
|
||||
done
|
||||
ss -ltn | grep -q ':2222'
|
||||
REMOTE
|
||||
|
||||
ssh-keyscan "$TARGET_HOST" >"$KNOWN_HOSTS_PATH" 2>/dev/null
|
||||
if ! ssh-keyscan -T 5 -p 2222 "$TARGET_HOST" >>"$KNOWN_HOSTS_PATH" 2>/dev/null; then
|
||||
echo "Failed to scan legacy SFTP host key from $TARGET_HOST:2222" >&2
|
||||
echo "Check the target service with:" >&2
|
||||
echo " ssh $TARGET systemctl status zerobyte-backend-integration-legacy-sshd.service" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INTEGRATION_HOST="$TARGET_HOST" \
|
||||
FIXTURE_UID="$FIXTURE_UID" \
|
||||
FIXTURE_GID="$FIXTURE_GID" \
|
||||
SMB_PASSWORD="$SMB_PASSWORD" \
|
||||
SFTP_PASSWORD="$SFTP_PASSWORD" \
|
||||
WEBDAV_PASSWORD="$WEBDAV_PASSWORD" \
|
||||
RESTIC_PASSWORD="$RESTIC_PASSWORD" \
|
||||
SFTP_KEY_PATH="$KEY_PATH" \
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const host = getRequiredEnv("INTEGRATION_HOST");
|
|||
const fixtureUid = getRequiredNumberEnv("FIXTURE_UID");
|
||||
const fixtureGid = getRequiredNumberEnv("FIXTURE_GID");
|
||||
const smbPassword = getRequiredEnv("SMB_PASSWORD");
|
||||
const sftpPassword = getRequiredEnv("SFTP_PASSWORD");
|
||||
const webdavPassword = getRequiredEnv("WEBDAV_PASSWORD");
|
||||
const resticPassword = getRequiredEnv("RESTIC_PASSWORD");
|
||||
const privateKey = fs.readFileSync(getRequiredEnv("SFTP_KEY_PATH"), "utf8");
|
||||
|
|
@ -29,7 +30,7 @@ const nfsEntries = [
|
|||
|
||||
const smbEntries = [
|
||||
{ path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText },
|
||||
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "0755" },
|
||||
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "1755" },
|
||||
{ path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText },
|
||||
];
|
||||
|
||||
|
|
@ -90,6 +91,41 @@ const config = {
|
|||
fixtureRoot: "case-a",
|
||||
expectedEntries: contentOnlyEntries,
|
||||
},
|
||||
{
|
||||
id: "sftp-password-local-repo",
|
||||
volume: {
|
||||
backend: "sftp",
|
||||
host,
|
||||
port: 22,
|
||||
username: "zerobyte-sftp",
|
||||
password: sftpPassword,
|
||||
path: "/srv/zerobyte-backend-integration/fixtures",
|
||||
readOnly: true,
|
||||
skipHostKeyCheck: false,
|
||||
knownHosts,
|
||||
},
|
||||
repository: { backend: "local", path: "repo-sftp-password-volume" },
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: contentOnlyEntries,
|
||||
},
|
||||
{
|
||||
id: "sftp-legacy-rsa-hostkey-local-repo",
|
||||
volume: {
|
||||
backend: "sftp",
|
||||
host,
|
||||
port: 2222,
|
||||
username: "zerobyte-sftp",
|
||||
password: sftpPassword,
|
||||
path: "/srv/zerobyte-backend-integration/fixtures",
|
||||
readOnly: true,
|
||||
skipHostKeyCheck: false,
|
||||
knownHosts,
|
||||
allowLegacySshRsa: true,
|
||||
},
|
||||
repository: { backend: "local", path: "repo-sftp-legacy-rsa-hostkey-volume" },
|
||||
fixtureRoot: "case-a",
|
||||
expectedEntries: contentOnlyEntries,
|
||||
},
|
||||
{
|
||||
id: "webdav-local-repo",
|
||||
volume: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { Effect } from "effect";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import waitForExpect from "wait-for-expect";
|
||||
import { fromPartial } from "@total-typescript/shoehorn";
|
||||
import { parseAgentMessage, type BackupCancelPayload, type BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
||||
|
|
@ -10,10 +9,54 @@ import { handleBackupCancelCommand } from "../backup-cancel";
|
|||
import { handleBackupRunCommand } from "../backup-run";
|
||||
import type { ControllerCommandContext, RunningJob } from "../../context";
|
||||
|
||||
const server = setupServer();
|
||||
type WebhookHandler = (context: {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
body: string;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
let webhookServer: Server;
|
||||
let webhookOrigin = "";
|
||||
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>() => {
|
||||
|
|
@ -25,13 +68,14 @@ const createDeferred = <T>() => {
|
|||
return { promise, resolve };
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
webhookServer.closeAllConnections?.();
|
||||
if (webhookServer.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webhookServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
||||
|
|
@ -71,7 +115,7 @@ const createRunPayload = (overrides: Partial<BackupRunPayload> = {}) =>
|
|||
password: "password",
|
||||
},
|
||||
webhooks: { pre: null, post: null },
|
||||
webhookAllowedOrigins: ["http://localhost:8080"],
|
||||
webhookAllowedOrigins: [webhookOrigin],
|
||||
webhookTimeoutMs: 60_000,
|
||||
...overrides,
|
||||
});
|
||||
|
|
@ -114,16 +158,14 @@ const runBackupCommand = async (payload: BackupRunPayload) => {
|
|||
test("runs pre and post backup webhooks around restic", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||
const body = (await request.json()) as { event: string };
|
||||
events.push(body.event);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/pre", ({ body, response }) => {
|
||||
events.push((JSON.parse(body) as { event: string }).event);
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
const body = (await request.json()) as { event: string };
|
||||
events.push(body.event);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
webhookRoute("/post", ({ body, response }) => {
|
||||
events.push((JSON.parse(body) as { event: string }).event);
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -140,8 +182,8 @@ test("runs pre and post backup webhooks around restic", async () => {
|
|||
const messages = await runBackupCommand(
|
||||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -153,22 +195,22 @@ test("runs pre and post backup webhooks around restic", async () => {
|
|||
test("sends configured webhook headers and body without changing them", async () => {
|
||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/pre", ({ request, response, body }) => {
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
url: webhookUrl("/pre"),
|
||||
headers: new Headers(request.headers as Record<string, string>),
|
||||
body,
|
||||
});
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
webhookRoute("/post", ({ request, response, body }) => {
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
url: webhookUrl("/post"),
|
||||
headers: new Headers(request.headers as Record<string, string>),
|
||||
body,
|
||||
});
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -182,12 +224,12 @@ test("sends configured webhook headers and body without changing them", async ()
|
|||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: {
|
||||
url: "http://localhost:8080/pre",
|
||||
url: webhookUrl("/pre"),
|
||||
headers: ["authorization: Bearer pre-token", "content-type: application/json"],
|
||||
body: '{"action":"stop"}',
|
||||
},
|
||||
post: {
|
||||
url: "http://localhost:8080/post",
|
||||
url: webhookUrl("/post"),
|
||||
headers: ["authorization: Bearer post-token"],
|
||||
body: "start-container",
|
||||
},
|
||||
|
|
@ -196,20 +238,20 @@ test("sends configured webhook headers and body without changing them", async ()
|
|||
);
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]?.url).toBe("http://localhost:8080/pre");
|
||||
expect(requests[0]?.url).toBe(webhookUrl("/pre"));
|
||||
expect(requests[0]?.headers.get("authorization")).toBe("Bearer pre-token");
|
||||
expect(requests[0]?.headers.get("content-type")).toBe("application/json");
|
||||
expect(requests[0]?.body).toBe('{"action":"stop"}');
|
||||
expect(requests[1]?.url).toBe("http://localhost:8080/post");
|
||||
expect(requests[1]?.url).toBe(webhookUrl("/post"));
|
||||
expect(requests[1]?.headers.get("authorization")).toBe("Bearer post-token");
|
||||
expect(requests[1]?.body).toBe("start-container");
|
||||
});
|
||||
|
||||
test("fails without running restic when the pre-backup webhook fails", async () => {
|
||||
const backupMock = vi.fn();
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
return new HttpResponse("stop failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/pre", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "stop failed");
|
||||
}),
|
||||
);
|
||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||
|
|
@ -221,7 +263,7 @@ test("fails without running restic when the pre-backup webhook fails", async ()
|
|||
const messages = await runBackupCommand(
|
||||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: null,
|
||||
},
|
||||
}),
|
||||
|
|
@ -236,9 +278,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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", () => {
|
||||
return new HttpResponse("start failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/post", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "start failed");
|
||||
}),
|
||||
);
|
||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||
|
|
@ -251,7 +293,7 @@ test("reports a post-backup webhook failure as completed warning details", async
|
|||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -264,9 +306,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 () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", () => {
|
||||
return new HttpResponse("start failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
webhookRoute("/post", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "start failed");
|
||||
}),
|
||||
);
|
||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||
|
|
@ -284,7 +326,7 @@ test("includes post-backup webhook failure details when a backup is cancelled",
|
|||
createRunPayload({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -142,6 +142,10 @@ const mount = async (config: BackendConfig, mountPath: string) => {
|
|||
`gid=${gid}`,
|
||||
];
|
||||
|
||||
if (config.allowLegacySshRsa) {
|
||||
options.push("ssh_command=ssh -oHostKeyAlgorithms=+ssh-rsa -oPubkeyAcceptedAlgorithms=+ssh-rsa");
|
||||
}
|
||||
|
||||
if (config.skipHostKeyCheck) {
|
||||
options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null");
|
||||
} else if (config.knownHosts) {
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -70,6 +70,7 @@
|
|||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"@effect/language-service": "^0.86.1",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hey-api/openapi-ts": "^0.97.1",
|
||||
|
|
|
|||
|
|
@ -62,8 +62,11 @@ services:
|
|||
- APP_SECRET=94bad4678ce84a60b9789bd2114a6bf780aeb38df426f7352c941c66e25d5c2b
|
||||
- BASE_URL=http://localhost:4096
|
||||
- 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
|
||||
- SSL_CERT_FILE=/tinyauth-ca/caddy/pki/authorities/local/root.crt
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse
|
||||
cap_add:
|
||||
|
|
|
|||
|
|
@ -576,15 +576,16 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page
|
|||
await volumeLink.click();
|
||||
await expect(page).toHaveURL(/\/volumes\/[^/?#]+/);
|
||||
await expect(page.getByRole("button", { name: "Actions" })).toBeVisible();
|
||||
const deleteVolumeMenuItem = page.getByRole("menuitem", { name: "Delete" });
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "Actions" }).click();
|
||||
const deleteVolumeMenuItem = page.getByRole("menuitem", { name: "Delete" });
|
||||
await expect(deleteVolumeMenuItem).toBeVisible();
|
||||
await deleteVolumeMenuItem.click();
|
||||
await expect(page.getByRole("heading", { name: "Delete volume?" })).toBeVisible();
|
||||
}).toPass({ timeout: 10000 });
|
||||
await deleteVolumeMenuItem.click({ force: true });
|
||||
await expect(page.getByRole("heading", { name: "Delete volume?" })).toBeVisible();
|
||||
await expect(page.getByText("All backup schedules associated with this volume will also be removed.")).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 expect(page.getByText("Volume deleted successfully")).toBeVisible();
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ async function getRepositoryShortId(page: Page, name: string) {
|
|||
}
|
||||
|
||||
async function createRepository(page: Page, name: string) {
|
||||
await gotoAndWaitForAppReady(page, "/repositories");
|
||||
await page.getByRole("button", { name: "Create repository" }).click();
|
||||
await gotoAndWaitForAppReady(page, "/repositories/create");
|
||||
await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "Name" }).fill(name);
|
||||
await page.getByRole("combobox", { name: "Backend" }).click();
|
||||
await page.getByRole("option", { name: "Local" }).click();
|
||||
|
|
|
|||
84
e2e/0005-two-factor.spec.ts
Normal file
84
e2e/0005-two-factor.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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();
|
||||
}
|
||||
});
|
||||
172
e2e/0006-notifications-webhooks.spec.ts
Normal file
172
e2e/0006-notifications-webhooks.spec.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
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();
|
||||
}
|
||||
});
|
||||
57
e2e/helpers/webhook-receiver.ts
Normal file
57
e2e/helpers/webhook-receiver.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -101,6 +101,7 @@
|
|||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"@effect/language-service": "^0.86.1",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hey-api/openapi-ts": "^0.97.1",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const sftpConfigSchema = z.object({
|
|||
readOnly: z.boolean().optional(),
|
||||
skipHostKeyCheck: z.boolean().default(false),
|
||||
knownHosts: z.string().optional(),
|
||||
allowLegacySshRsa: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const volumeConfigSchema = z.discriminatedUnion("backend", [
|
||||
|
|
|
|||
|
|
@ -1,21 +1,66 @@
|
|||
import nodeHttp, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { Effect } from "effect";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js";
|
||||
|
||||
const server = setupServer();
|
||||
type WebhookHandler = (context: {
|
||||
request: IncomingMessage;
|
||||
response: ServerResponse;
|
||||
body: string;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
let webhookServer: Server;
|
||||
let webhookOrigin = "";
|
||||
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(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
afterEach(async () => {
|
||||
webhookServer.closeAllConnections?.();
|
||||
if (webhookServer.listening) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
webhookServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metadata = {
|
||||
|
|
@ -44,7 +89,7 @@ const runWithHooks = <TResult>(
|
|||
repositoryConfig: { backend: "local", path: "/tmp/repository" },
|
||||
options: {},
|
||||
webhooks: { pre: null, post: null },
|
||||
webhookAllowedOrigins: ["http://localhost:8080"],
|
||||
webhookAllowedOrigins: [webhookOrigin],
|
||||
webhookTimeoutMs: 60_000,
|
||||
signal: defaultSignal(),
|
||||
...options,
|
||||
|
|
@ -68,23 +113,23 @@ test("runs pre and post webhooks around a successful backup", async () => {
|
|||
let preBody: WebhookBody | undefined;
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", async ({ request }) => {
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ body, response }) => {
|
||||
events.push("pre");
|
||||
preBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
preBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
events.push("post");
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () =>
|
||||
Effect.sync(() => {
|
||||
|
|
@ -102,17 +147,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 () => {
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () => completedBackup("snapshot-1", 3, "some files could not be read"),
|
||||
});
|
||||
|
|
@ -129,17 +174,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 () => {
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () => completedBackup("snapshot-1", 0, "Backup was stopped by the user"),
|
||||
});
|
||||
|
|
@ -156,17 +201,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 () => {
|
||||
let postBody: WebhookBody | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as WebhookBody;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as WebhookBody;
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () => Effect.fail(new Error("restic failed")),
|
||||
});
|
||||
|
|
@ -179,20 +224,20 @@ test("fails without running the backup or post webhook when the pre-backup webho
|
|||
let backupRan = false;
|
||||
let postRan = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
return new HttpResponse("stop failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
sendWebhookResponse(response, 500, "stop failed");
|
||||
}),
|
||||
http.post("http://localhost:8080/post", () => {
|
||||
postWebhook("/post", ({ response }) => {
|
||||
postRan = true;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
runBackup: () =>
|
||||
Effect.sync(() => {
|
||||
|
|
@ -215,12 +260,16 @@ test("sends configured webhook headers and body without replacing them", async (
|
|||
let authorization: string | null | undefined;
|
||||
let contentType: string | null | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
body = await request.text();
|
||||
authorization = request.headers.get("authorization");
|
||||
contentType = request.headers.get("content-type");
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ request, response, body: requestBody }) => {
|
||||
body = requestBody;
|
||||
authorization = Array.isArray(request.headers.authorization)
|
||||
? request.headers.authorization.join(", ")
|
||||
: request.headers.authorization;
|
||||
contentType = Array.isArray(request.headers["content-type"])
|
||||
? request.headers["content-type"].join(", ")
|
||||
: (request.headers["content-type"] ?? null);
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -228,7 +277,7 @@ test("sends configured webhook headers and body without replacing them", async (
|
|||
webhooks: {
|
||||
pre: null,
|
||||
post: {
|
||||
url: "http://localhost:8080/post",
|
||||
url: webhookUrl("/post"),
|
||||
headers: ["authorization: Bearer post-token"],
|
||||
body: "start-container",
|
||||
},
|
||||
|
|
@ -245,17 +294,17 @@ test("runs the post-backup webhook after cancellation without using the cancelle
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -273,17 +322,17 @@ test("runs the post-backup webhook when cancellation returns a completed backup
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -301,17 +350,17 @@ test("includes post-backup webhook failure details after cancellation", async ()
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse("start failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response, 500, "start failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -334,17 +383,17 @@ test("includes post-backup webhook failure details after completed cancellation"
|
|||
const abortController = new AbortController();
|
||||
let postBody: { status?: string; error?: string } | undefined;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/post", async ({ request }) => {
|
||||
postBody = (await request.json()) as { status?: string; error?: string };
|
||||
return new HttpResponse("cleanup failed", { status: 500 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/post", ({ body, response }) => {
|
||||
postBody = JSON.parse(body) as { status?: string; error?: string };
|
||||
sendWebhookResponse(response, 500, "cleanup failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: null,
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -368,7 +417,7 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
|
|||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://127.0.0.1:8080/pre" },
|
||||
pre: { url: "http://127.0.0.1:9/pre" },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () =>
|
||||
|
|
@ -381,23 +430,23 @@ test("rejects webhook URLs outside the configured allowed origins", async () =>
|
|||
expect(backupRan).toBe(false);
|
||||
expect(result).toEqual({
|
||||
status: "failed",
|
||||
error: "pre webhook URL origin is not allowed. Add http://127.0.0.1:8080 to WEBHOOK_ALLOWED_ORIGINS.",
|
||||
error: "pre webhook URL origin is not allowed. Add http://127.0.0.1:9 to WEBHOOK_ALLOWED_ORIGINS.",
|
||||
});
|
||||
});
|
||||
|
||||
test("matches configured webhook origins with trailing slashes or paths", async () => {
|
||||
let backupRan = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhookAllowedOrigins: ["http://localhost:8080/", "http://example.com/webhook"],
|
||||
webhookAllowedOrigins: [`${webhookOrigin}/`, "http://example.com/webhook"],
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () =>
|
||||
|
|
@ -414,19 +463,20 @@ test("matches configured webhook origins with trailing slashes or paths", async
|
|||
test("does not follow webhook redirects", async () => {
|
||||
let redirectedTargetCalled = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/redirect", () => {
|
||||
return new HttpResponse(null, { status: 302, headers: { location: "http://localhost:8080/target" } });
|
||||
useWebhookHandlers(
|
||||
postWebhook("/redirect", ({ response }) => {
|
||||
response.writeHead(302, { location: webhookUrl("/target") });
|
||||
response.end();
|
||||
}),
|
||||
http.post("http://localhost:8080/target", () => {
|
||||
postWebhook("/target", ({ response }) => {
|
||||
redirectedTargetCalled = true;
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/redirect" },
|
||||
pre: { url: webhookUrl("/redirect") },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () => completedBackup(null),
|
||||
|
|
@ -436,10 +486,37 @@ test("does not follow webhook redirects", async () => {
|
|||
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 () => {
|
||||
const bodyResult = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre", body: "a".repeat(64 * 1024 + 1) },
|
||||
pre: { url: webhookUrl("/pre"), body: "a".repeat(64 * 1024 + 1) },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () => completedBackup(null),
|
||||
|
|
@ -449,7 +526,7 @@ test("rejects oversized webhook request bodies and headers", async () => {
|
|||
|
||||
const headersResult = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre", headers: [`x-large: ${"a".repeat(8 * 1024)}`] },
|
||||
pre: { url: webhookUrl("/pre"), headers: [`x-large: ${"a".repeat(8 * 1024)}`] },
|
||||
post: null,
|
||||
},
|
||||
runBackup: () => completedBackup(null),
|
||||
|
|
@ -459,13 +536,13 @@ test("rejects oversized webhook request bodies and headers", async () => {
|
|||
});
|
||||
|
||||
test("rejects malformed webhook header lines", () => {
|
||||
expect(() => backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Malformed"] })).toThrow(
|
||||
expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/pre"), headers: ["Malformed"] })).toThrow(
|
||||
"Headers must use non-empty Key: Value format with valid header names",
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Bad Header: value"] }),
|
||||
).toThrow("Headers must use non-empty Key: Value format with valid header names");
|
||||
expect(() => backupWebhookConfigSchema.parse({ url: webhookUrl("/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 () => {
|
||||
|
|
@ -476,8 +553,8 @@ test("cancels before the pre-backup webhook without running the backup", async (
|
|||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
post: { url: "http://localhost:8080/post" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: { url: webhookUrl("/post") },
|
||||
},
|
||||
signal: abortController.signal,
|
||||
runBackup: () =>
|
||||
|
|
@ -495,16 +572,16 @@ test("cancels after the pre-backup webhook without running the backup", async ()
|
|||
const abortController = new AbortController();
|
||||
let backupRan = false;
|
||||
|
||||
server.use(
|
||||
http.post("http://localhost:8080/pre", () => {
|
||||
useWebhookHandlers(
|
||||
postWebhook("/pre", ({ response }) => {
|
||||
abortController.abort(new Error("Backup was cancelled"));
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
sendWebhookResponse(response);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runWithHooks({
|
||||
webhooks: {
|
||||
pre: { url: "http://localhost:8080/pre" },
|
||||
pre: { url: webhookUrl("/pre") },
|
||||
post: null,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { Data, Effect } from "effect";
|
||||
import { z } from "zod";
|
||||
import type { CompressionMode, RepositoryConfig, ResticBackupProgressDto } from "../restic/index.js";
|
||||
|
|
@ -24,7 +26,11 @@ export const isAllowedWebhookUrl = (url: string, allowedOrigins: readonly string
|
|||
export const backupWebhookConfigSchema = z.object({
|
||||
url: z.url(),
|
||||
headers: z
|
||||
.array(z.string().refine(isValidHeaderLine, "Headers must use non-empty Key: Value format with valid header names"))
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.refine(isValidHeaderLine, "Headers must use non-empty Key: Value format with valid header names"),
|
||||
)
|
||||
.optional(),
|
||||
body: z.string().optional(),
|
||||
});
|
||||
|
|
@ -141,7 +147,7 @@ const createAbortController = (timeoutMs: number, signal?: AbortSignal) => {
|
|||
};
|
||||
};
|
||||
|
||||
const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookContext): RequestInit => {
|
||||
const createWebhookRequest = (config: BackupWebhookConfig, context: BackupWebhookContext) => {
|
||||
const headers = new Headers();
|
||||
const body = config.body ?? JSON.stringify(context);
|
||||
|
||||
|
|
@ -184,13 +190,87 @@ const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookCo
|
|||
}
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
headers,
|
||||
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 = (
|
||||
config: BackupWebhookConfig | null,
|
||||
context: BackupWebhookContext,
|
||||
|
|
@ -220,14 +300,10 @@ const runBackupWebhook = (
|
|||
});
|
||||
}
|
||||
|
||||
const response = await fetch(config.url, { ...createRequestInit(config, context), signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BackupWebhookError({
|
||||
cause: new Error(`${context.phase} webhook returned HTTP ${response.status}`),
|
||||
message: `${context.phase} webhook returned HTTP ${response.status}`,
|
||||
});
|
||||
}
|
||||
await sendWebhookRequest(config, context, {
|
||||
signal: controller.signal,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
},
|
||||
catch: (error) => {
|
||||
if (error instanceof BackupWebhookError) {
|
||||
|
|
@ -327,7 +403,8 @@ export const runBackupLifecycle = <TResult>({
|
|||
if (signal.aborted) {
|
||||
return {
|
||||
status: "cancelled",
|
||||
message: appendDetails(formatError(signal.reason || backupResult.hookError), postHookError) || undefined,
|
||||
message:
|
||||
appendDetails(formatError(signal.reason || backupResult.hookError), postHookError) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ describe("buildEnv", () => {
|
|||
path: "/backups",
|
||||
privateKey: PLAIN_PRIVATE_KEY,
|
||||
skipHostKeyCheck: true as const,
|
||||
allowLegacySshRsa: false as const,
|
||||
});
|
||||
|
||||
test("throws for passphrase-protected private keys", async () => {
|
||||
|
|
@ -349,6 +350,20 @@ describe("buildEnv", () => {
|
|||
expect(env._SFTP_SSH_ARGS).toContain("UserKnownHostsFile=/dev/null");
|
||||
});
|
||||
|
||||
test("does not allow legacy RSA/SHA1 SSH algorithms by default", async () => {
|
||||
const env = await buildEnvForTest(baseSftpConfig, "org-1");
|
||||
|
||||
expect(env._SFTP_SSH_ARGS).not.toContain("HostKeyAlgorithms=+ssh-rsa");
|
||||
expect(env._SFTP_SSH_ARGS).not.toContain("PubkeyAcceptedAlgorithms=+ssh-rsa");
|
||||
});
|
||||
|
||||
test("allows legacy RSA/SHA1 SSH algorithms after modern defaults when enabled", async () => {
|
||||
const env = await buildEnvForTest({ ...baseSftpConfig, allowLegacySshRsa: true }, "org-1");
|
||||
|
||||
expect(env._SFTP_SSH_ARGS).toContain("HostKeyAlgorithms=+ssh-rsa");
|
||||
expect(env._SFTP_SSH_ARGS).toContain("PubkeyAcceptedAlgorithms=+ssh-rsa");
|
||||
});
|
||||
|
||||
test("uses StrictHostKeyChecking=yes when knownHosts is absent", async () => {
|
||||
const env = await buildEnvForTest(
|
||||
{ ...baseSftpConfig, skipHostKeyCheck: false, knownHosts: undefined },
|
||||
|
|
|
|||
|
|
@ -153,6 +153,10 @@ export const buildEnv = async (
|
|||
keyPath,
|
||||
];
|
||||
|
||||
if (config.allowLegacySshRsa) {
|
||||
sshArgs.push("-o", "HostKeyAlgorithms=+ssh-rsa", "-o", "PubkeyAcceptedAlgorithms=+ssh-rsa");
|
||||
}
|
||||
|
||||
if (config.skipHostKeyCheck) {
|
||||
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
|
||||
} else if (config.knownHosts) {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export const sftpRepositoryConfigSchema = z
|
|||
privateKey: z.string().min(1),
|
||||
skipHostKeyCheck: z.boolean().default(false),
|
||||
knownHosts: z.string().optional(),
|
||||
allowLegacySshRsa: z.boolean().default(false),
|
||||
})
|
||||
.extend(baseRepositoryConfigSchema.shape);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue