Compare commits

...

10 commits

Author SHA1 Message Date
Nicolas Meienberger
8c6fc6cfed
test: fix webhook mocks
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-29 20:06:44 +02:00
Nicolas Meienberger
6434068976
e2e: fix 2fa leakage in next tests
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-27 22:19:59 +02:00
Nicolas Meienberger
2add15c8c0
refactor(sftp): mixed options style 2026-05-27 21:45:32 +02:00
Nico
581e0b007b
fix(core): enforce webhook timeout (#920) 2026-05-27 21:27:55 +02:00
Nico
cb8668e95d
test(e2e): add test suite to test webhooks (#922) 2026-05-27 21:27:41 +02:00
Nicolas Meienberger
c9063a963b
e2e: add missing host-gateway mapping 2026-05-27 21:27:34 +02:00
Nicolas Meienberger
df834dd416
deps: bump rclone & shoutrrr to latest versions 2026-05-27 21:27:03 +02:00
Nico
9274af5079
fix(sftp): allow legacy ssh rsa to add support for older servers (#921)
* refactor(e2e): use more stable assertion

* feat(sftp): add legacy ssh-rsa option
2026-05-27 21:26:53 +02:00
Nicolas Meienberger
f269751014
refactor(e2e): use more stable assertion
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-22 20:19:56 +02:00
Nico
c752e2d52d
fix(2fa): add missing 2fa column (#917) 2026-05-22 20:10:36 +02:00
29 changed files with 3846 additions and 260 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input"; import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea"; import { Textarea } from "../../../../components/ui/textarea";
import { Switch } from "../../../../components/ui/switch"; import { Switch } from "../../../../components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
type Props = { type Props = {
form: UseFormReturn<FormValues>; form: UseFormReturn<FormValues>;
@ -78,7 +79,9 @@ export const SFTPForm = ({ form }: Props) => {
<FormControl> <FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} /> <SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl> </FormControl>
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription> <FormDescription>
Password for SFTP authentication (optional if using private key).
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -98,7 +101,9 @@ export const SFTPForm = ({ form }: Props) => {
value={field.value ?? ""} value={field.value ?? ""}
/> />
</FormControl> </FormControl>
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription> <FormDescription>
SSH private key for authentication (optional if using password).
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -158,6 +163,29 @@ export const SFTPForm = ({ form }: Props) => {
)} )}
/> />
)} )}
<Collapsible>
<CollapsibleTrigger>Advanced Settings</CollapsibleTrigger>
<CollapsibleContent className="pb-4 pt-4">
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</> </>
); );
}; };

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
}
});

View 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();
}
});

View 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()));
});
},
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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