diff --git a/apps/dokploy/__test__/backups/redact-credentials.test.ts b/apps/dokploy/__test__/backups/redact-credentials.test.ts index 5fff508cc..ec723c547 100644 --- a/apps/dokploy/__test__/backups/redact-credentials.test.ts +++ b/apps/dokploy/__test__/backups/redact-credentials.test.ts @@ -1,4 +1,8 @@ -import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { + getSafeRcloneErrorMessage, + redactRcloneCredentials, +} from "@dokploy/server/utils/backups/redact"; +import { quote } from "shell-quote"; import { describe, expect, it } from "vitest"; describe("redactRcloneCredentials (#4621)", () => { @@ -27,6 +31,23 @@ describe("redactRcloneCredentials (#4621)", () => { expect(redacted).toContain('--s3-region="us-east-1"'); }); + it("should redact FTP and SFTP obscured password flags", () => { + for (const backend of ["ftp", "sftp"] as const) { + const cmd = `rclone lsf --${backend}-host=storage.example.com --${backend}-pass='obscured-secret' :${backend}:backups`; + const redacted = redactRcloneCredentials(cmd); + expect(redacted).not.toContain("obscured-secret"); + expect(redacted).toContain(`--${backend}-pass="[REDACTED]"`); + } + }); + + it("should redact unquoted FTP and SFTP password flags", () => { + const cmd = + "rclone lsf --ftp-pass=ftp-secret --sftp-pass=sftp-secret :ftp:backups"; + const redacted = redactRcloneCredentials(cmd); + expect(redacted).not.toContain("ftp-secret"); + expect(redacted).not.toContain("sftp-secret"); + }); + it("should not modify non-credential flags", () => { const cmd = 'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz'; @@ -47,4 +68,32 @@ describe("redactRcloneCredentials (#4621)", () => { expect(redacted).not.toContain("MYSECRET"); expect(redacted).toContain("[REDACTED]"); }); + it("should sanitize rclone credentials from propagated backup errors", () => { + const error = new Error( + "Command failed: rclone rcat --s3-access-key-id=ACCESS_VALUE_9X --s3-secret-access-key=S3_VALUE_LEAK_9X --ftp-pass=FTP_VALUE_LEAK_9X --sftp-pass=SFTP_VALUE_LEAK_9X --sftp-key-file-pass=KEY_VALUE_LEAK_9X :ftp:backups/file.gz", + ); + const safe = getSafeRcloneErrorMessage(error); + + for (const secret of [ + "ACCESS_VALUE_9X", + "S3_VALUE_LEAK_9X", + "FTP_VALUE_LEAK_9X", + "SFTP_VALUE_LEAK_9X", + "KEY_VALUE_LEAK_9X", + ]) { + expect(safe).not.toContain(secret); + } + expect(safe.match(/\[REDACTED\]/g)?.length).toBe(5); + }); + it("should fully redact shell-quote output with embedded quotes and whitespace", () => { + const secret = "PART_A' PART_B\" $PART_C;\\PART_D"; + const cmd = `rclone lsf --s3-secret-access-key=${quote([secret])} --s3-region=us-east-1 :s3:bucket`; + const redacted = redactRcloneCredentials(cmd); + + for (const fragment of ["PART_A", "PART_B", "PART_C", "PART_D"]) { + expect(redacted).not.toContain(fragment); + } + expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"'); + expect(redacted).toContain("--s3-region=us-east-1"); + }); }); diff --git a/apps/dokploy/__test__/utils/backups.test.ts b/apps/dokploy/__test__/utils/backups.test.ts index 2c1e5decc..192633f3d 100644 --- a/apps/dokploy/__test__/utils/backups.test.ts +++ b/apps/dokploy/__test__/utils/backups.test.ts @@ -1,4 +1,10 @@ -import { normalizeS3Path } from "@dokploy/server/utils/backups/utils"; +import { apiCreateDestination } from "@dokploy/server/db/schema/destination"; +import { RCLONE_DESTINATION_PROVIDERS } from "@dokploy/server/db/validations/destination"; +import { + getRclonePathAndFlags, + normalizeS3Path, +} from "@dokploy/server/utils/backups/utils"; +import { normalizeVolumeBackupFilePath } from "@dokploy/server/utils/volume-backups/restore"; import { describe, expect, test } from "vitest"; describe("normalizeS3Path", () => { @@ -59,3 +65,363 @@ describe("normalizeS3Path", () => { expect(normalizeS3Path("instance-backups")).toBe("instance-backups/"); }); }); + +describe("normalizeVolumeBackupFilePath", () => { + test("accepts a destination-relative backup path", () => { + expect(normalizeVolumeBackupFilePath("app/prefix/volume-2026.tar")).toBe( + "app/prefix/volume-2026.tar", + ); + }); + + test.each([ + "/absolute/backup.tar", + "../backup.tar", + "app/../backup.tar", + "backup.tar; touch /tmp/pwned", + "backup.tar$(touch /tmp/pwned)", + "backup.tar`touch /tmp/pwned`", + "backup.tar\nmalicious-command", + ])("rejects unsafe restore path %s", (value) => { + expect(() => normalizeVolumeBackupFilePath(value)).toThrow( + "Invalid volume backup file path", + ); + }); +}); + +const destination = (overrides: Record = {}) => + ({ + destinationId: "destination-id", + name: "Test", + provider: "AWS", + accessKey: "access", + secretAccessKey: "secret", + bucket: "bucket", + region: "us-east-1", + endpoint: "https://s3.example.com", + additionalFlags: [], + organizationId: "organization-id", + createdAt: new Date(0), + ...overrides, + }) as any; + +describe("FTP destination validation", () => { + const input = { + name: "FTP backups", + provider: RCLONE_DESTINATION_PROVIDERS.FTP, + accessKey: "backup-user", + secretAccessKey: "secret", + bucket: "backups", + region: "", + endpoint: "storage.example.com", + }; + + test("rejects plaintext FTP", () => { + expect( + apiCreateDestination.safeParse({ ...input, additionalFlags: [] }).success, + ).toBe(false); + }); + + test.each(["--ftp-explicit-tls", "--ftp-tls"])( + "accepts encrypted FTP with %s", + (flag) => { + const result = apiCreateDestination.safeParse({ + ...input, + additionalFlags: [flag], + }); + expect( + result.success, + result.success ? undefined : JSON.stringify(result.error.issues), + ).toBe(true); + }, + ); + + test("rejects conflicting implicit and explicit FTPS", () => { + expect( + apiCreateDestination.safeParse({ + ...input, + additionalFlags: ["--ftp-explicit-tls", "--ftp-tls"], + }).success, + ).toBe(false); + }); +}); + +describe("SFTP destination validation", () => { + const input = { + name: "SFTP backups", + provider: RCLONE_DESTINATION_PROVIDERS.SFTP, + accessKey: "backup-user", + secretAccessKey: "", + bucket: "backups", + region: "", + endpoint: "storage.example.com", + }; + + test("rejects SFTP without host-key verification", () => { + expect( + apiCreateDestination.safeParse({ ...input, additionalFlags: [] }).success, + ).toBe(false); + }); + + test("rejects explicitly disabled SFTP host-key verification", () => { + expect( + apiCreateDestination.safeParse({ + ...input, + additionalFlags: ["--sftp-known-hosts-file=none"], + }).success, + ).toBe(false); + }); + + test("accepts SFTP with a known-hosts file", () => { + const result = apiCreateDestination.safeParse({ + ...input, + additionalFlags: ["--sftp-known-hosts-file=/etc/ssh/ssh_known_hosts"], + }); + expect( + result.success, + result.success ? undefined : JSON.stringify(result.error.issues), + ).toBe(true); + }); +}); + +describe("getRclonePathAndFlags", () => { + test("preserves the existing S3 destination behavior", async () => { + const result = await getRclonePathAndFlags( + destination(), + "service/prefix/backup.sql.gz", + ); + + expect(result.path).toBe(":s3:bucket/service/prefix/backup.sql.gz"); + expect(result.flags).toContain("--s3-provider=AWS"); + expect(result.flags).toContain("--s3-access-key-id=access"); + expect(result.flags).toContain("--s3-secret-access-key=secret"); + }); + + test.each([ + RCLONE_DESTINATION_PROVIDERS.GOOGLE_DRIVE, + RCLONE_DESTINATION_PROVIDERS.ONEDRIVE, + RCLONE_DESTINATION_PROVIDERS.REMOTE, + ])("builds a safe named rclone remote for %s", async (provider) => { + const result = await getRclonePathAndFlags( + destination({ + provider, + endpoint: "team-drive", + bucket: "/dokploy/", + accessKey: "", + secretAccessKey: "", + region: "", + additionalFlags: ["--transfers=2"], + }), + "/service/backup.sql.gz", + ); + + expect(result.path).toBe("team-drive:dokploy/service/backup.sql.gz"); + expect(result.flags).toEqual(["--transfers=2"]); + }); + + test("rejects unsafe named remote names", async () => { + await expect( + getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.GOOGLE_DRIVE, + endpoint: "drive;touch /tmp/pwned", + bucket: "", + }), + ), + ).rejects.toThrow("Invalid rclone remote name"); + }); + + test("rejects unsafe stored additional flags at runtime", async () => { + await expect( + getRclonePathAndFlags( + destination({ additionalFlags: ["--transfers=2;touch /tmp/pwned"] }), + ), + ).rejects.toThrow("Invalid flag format"); + }); + + test.each([ + ["--ftp-explicit-tls", "21"], + ["--ftp-tls", "990"], + ] as const)( + "uses secure FTP mode %s with default port %s", + async (tlsFlag, defaultPort) => { + const result = await getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.FTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "/backups/", + additionalFlags: [tlsFlag], + }), + "service/backup.tar", + ); + + expect(result.path).toBe(":ftp:backups/service/backup.tar"); + expect(result.flags).toContain("--ftp-host=storage.example.com"); + expect(result.flags).toContain("--ftp-user=backup-user"); + expect(result.flags).toContain(`--ftp-port=${defaultPort}`); + expect(result.flags).toContain(tlsFlag); + }, + ); + + test("rejects plaintext FTP at runtime", async () => { + await expect( + getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.FTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "/backups/", + additionalFlags: [], + }), + ), + ).rejects.toThrow("FTP destinations must use TLS"); + }); + + test("builds SFTP flags with host-key verification and port 22", async () => { + const result = await getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.SFTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "/backups/", + additionalFlags: ["--sftp-known-hosts-file=/etc/ssh/ssh_known_hosts"], + }), + "service/backup.tar", + ); + + expect(result.path).toBe(":sftp:backups/service/backup.tar"); + expect(result.flags).toContain("--sftp-host=storage.example.com"); + expect(result.flags).toContain("--sftp-user=backup-user"); + expect(result.flags).toContain("--sftp-port=22"); + expect(result.flags).toContain( + "--sftp-known-hosts-file=/etc/ssh/ssh_known_hosts", + ); + }); + + test("rejects SFTP without host-key verification at runtime", async () => { + await expect( + getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.SFTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "/backups/", + additionalFlags: [], + }), + ), + ).rejects.toThrow("SFTP destinations must verify the server host key"); + }); +}); + +describe("FTP TLS certificate verification", () => { + const input = { + name: "FTP backups", + provider: RCLONE_DESTINATION_PROVIDERS.FTP, + accessKey: "backup-user", + secretAccessKey: "secret", + bucket: "backups", + region: "", + endpoint: "storage.example.com", + }; + + test.each([ + "--ftp-no-check-certificate", + "--ftp-no-check-certificate=true", + "--ftp-no-check-certificate=TRUE", + "--ftp-no-check-certificate=1", + "--ftp-no-check-certificate=t", + "--no-check-certificate", + "--no-check-certificate=true", + "--no-check-certificate=TRUE", + "--no-check-certificate=1", + "--no-check-certificate=t", + ])( + "rejects certificate-verification bypass %s at schema validation", + (flag) => { + expect( + apiCreateDestination.safeParse({ + ...input, + additionalFlags: ["--ftp-explicit-tls", flag], + }).success, + ).toBe(false); + }, + ); + + test.each([ + "--ftp-no-check-certificate", + "--ftp-no-check-certificate=TRUE", + "--ftp-no-check-certificate=1", + "--no-check-certificate", + "--no-check-certificate=TRUE", + "--no-check-certificate=1", + ])("rejects certificate-verification bypass %s at runtime", async (flag) => { + await expect( + getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.FTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "backups", + additionalFlags: ["--ftp-explicit-tls", flag], + }), + ), + ).rejects.toThrow("FTP TLS certificate verification cannot be disabled"); + }); + + test.each([ + "--ftp-no-check-certificate=false", + "--ftp-no-check-certificate=FALSE", + "--ftp-no-check-certificate=0", + "--ftp-no-check-certificate=f", + "--no-check-certificate=false", + "--no-check-certificate=FALSE", + "--no-check-certificate=0", + "--no-check-certificate=f", + ])("allows explicitly safe certificate flag %s", (flag) => { + expect( + apiCreateDestination.safeParse({ + ...input, + additionalFlags: ["--ftp-explicit-tls", flag], + }).success, + ).toBe(true); + }); + test.each([ + "--ftp-explicit-tls=TRUE", + "--ftp-explicit-tls=1", + "--ftp-explicit-tls=t", + ])("accepts pflag-compatible TLS true value %s", (flag) => { + expect( + apiCreateDestination.safeParse({ + ...input, + additionalFlags: [flag], + }).success, + ).toBe(true); + }); + test("forces certificate verification on after user flags", async () => { + const result = await getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.FTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "backups", + additionalFlags: ["--ftp-explicit-tls"], + }), + ); + expect(result.flags.slice(-2)).toEqual([ + "--ftp-no-check-certificate=false", + "--no-check-certificate=false", + ]); + }); +}); diff --git a/apps/dokploy/__test__/utils/issue-416-path-safety.test.ts b/apps/dokploy/__test__/utils/issue-416-path-safety.test.ts new file mode 100644 index 000000000..a8e53fb6c --- /dev/null +++ b/apps/dokploy/__test__/utils/issue-416-path-safety.test.ts @@ -0,0 +1,137 @@ +import { apiCreateDestination } from "@dokploy/server/db/schema/destination"; +import { RCLONE_DESTINATION_PROVIDERS } from "@dokploy/server/db/validations/destination"; +import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact"; +import { + assertSafeRclonePath, + getRclonePathAndFlags, +} from "@dokploy/server/utils/backups/utils"; +import { + normalizeDockerVolumeName, + normalizeVolumeBackupFilePath, +} from "@dokploy/server/utils/volume-backups/restore"; +import { describe, expect, test } from "vitest"; + +const destination = (overrides: Record = {}) => + ({ + destinationId: "destination-id", + name: "Test", + provider: RCLONE_DESTINATION_PROVIDERS.GOOGLE_DRIVE, + accessKey: "", + secretAccessKey: "", + bucket: "dokploy", + region: "", + endpoint: "team-drive", + additionalFlags: [], + organizationId: "organization-id", + createdAt: new Date(0), + ...overrides, + }) as any; + +describe("issue #416 rclone path safety", () => { + test.each([ + "../backup.sql.gz", + "app/../backup.sql.gz", + "app/./backup.sql.gz", + "..\\backup.sql.gz", + "app\\..\\backup.sql.gz", + "backup.sql.gz\nother", + "backup.sql.gz\rboom", + "backup.sql.gz\0boom", + ])("rejects unsafe destination-relative path %s", (value) => { + expect(() => assertSafeRclonePath(value)).toThrow("Invalid rclone path"); + }); + + test.each([ + "service/backup.sql.gz", + "/service/backup.sql.gz", + "service/backup..sql.gz", + "service/file name.sql.gz", + ])("keeps valid backup path %s", (value) => { + expect(() => assertSafeRclonePath(value)).not.toThrow(); + }); + + test.each([ + RCLONE_DESTINATION_PROVIDERS.GOOGLE_DRIVE, + RCLONE_DESTINATION_PROVIDERS.ONEDRIVE, + RCLONE_DESTINATION_PROVIDERS.REMOTE, + RCLONE_DESTINATION_PROVIDERS.FTP, + RCLONE_DESTINATION_PROVIDERS.SFTP, + "AWS", + ])("blocks traversal before building a %s target", async (provider) => { + await expect( + getRclonePathAndFlags(destination({ provider }), "app/../outside.sql.gz"), + ).rejects.toThrow("Invalid rclone path"); + }); +}); + +describe("issue #416 SFTP host-key safety", () => { + const conflictingKnownHostsFlags = [ + "--sftp-known-hosts-file=/etc/ssh/ssh_known_hosts", + "--sftp-known-hosts-file=none", + ]; + + test("rejects conflicting host-key flags during destination validation", () => { + const result = apiCreateDestination.safeParse({ + name: "SFTP backups", + provider: RCLONE_DESTINATION_PROVIDERS.SFTP, + accessKey: "backup-user", + secretAccessKey: "", + bucket: "backups", + region: "", + endpoint: "storage.example.com", + additionalFlags: conflictingKnownHostsFlags, + }); + + expect(result.success).toBe(false); + }); + + test("rejects conflicting host-key flags at runtime", async () => { + await expect( + getRclonePathAndFlags( + destination({ + provider: RCLONE_DESTINATION_PROVIDERS.SFTP, + endpoint: "storage.example.com", + accessKey: "backup-user", + secretAccessKey: "", + region: "", + bucket: "backups", + additionalFlags: conflictingKnownHostsFlags, + }), + ), + ).rejects.toThrow("SFTP destinations must verify the server host key"); + }); +}); + +describe("issue #416 credential redaction", () => { + test("redacts an SFTP private-key passphrase flag", () => { + const command = + "rclone lsf --sftp-key-file=/root/.ssh/id_rsa --sftp-key-file-pass=obscured-secret :sftp:backups"; + const redacted = redactRcloneCredentials(command); + + expect(redacted).not.toContain("obscured-secret"); + expect(redacted).toContain('--sftp-key-file-pass="[REDACTED]"'); + }); +}); + + +describe("issue #416 volume name and backup path shell safety", () => { + test("accepts valid docker volume names", () => { + expect(normalizeDockerVolumeName("app_data")).toBe("app_data"); + expect(normalizeDockerVolumeName("App.Data-1")).toBe("App.Data-1"); + }); + + test.each(["", "../evil", "vol;rm -rf /", "vol$(id)", "-sneaky"])( + "rejects unsafe docker volume name %s", + (value) => { + expect(() => normalizeDockerVolumeName(value)).toThrow( + "Invalid docker volume name", + ); + }, + ); + + test("normalizes generated backup file names", () => { + expect( + normalizeVolumeBackupFilePath("app_data-2026-01-01T00-00-00.tar"), + ).toBe("app_data-2026-01-01T00-00-00.tar"); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/destination/constants.ts b/apps/dokploy/components/dashboard/settings/destination/constants.ts index f43e47d1a..e3577302b 100644 --- a/apps/dokploy/components/dashboard/settings/destination/constants.ts +++ b/apps/dokploy/components/dashboard/settings/destination/constants.ts @@ -1,3 +1,5 @@ +import { RCLONE_DESTINATION_PROVIDERS } from "@dokploy/server/db/validations/destination"; + export const S3_PROVIDERS: Array<{ key: string; name: string; @@ -131,3 +133,30 @@ export const S3_PROVIDERS: Array<{ name: "Any other S3 compatible provider", }, ]; + +export const DESTINATION_PROVIDERS: Array<{ + key: string; + name: string; +}> = [ + { + key: RCLONE_DESTINATION_PROVIDERS.GOOGLE_DRIVE, + name: "Google Drive (configured rclone remote)", + }, + { + key: RCLONE_DESTINATION_PROVIDERS.ONEDRIVE, + name: "Microsoft OneDrive (configured rclone remote)", + }, + { + key: RCLONE_DESTINATION_PROVIDERS.SFTP, + name: "SFTP", + }, + { + key: RCLONE_DESTINATION_PROVIDERS.FTP, + name: "FTP", + }, + { + key: RCLONE_DESTINATION_PROVIDERS.REMOTE, + name: "Other configured rclone remote", + }, + ...S3_PROVIDERS, +]; diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx index d1542dc80..9c6648b07 100644 --- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx +++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx @@ -1,6 +1,11 @@ import { ADDITIONAL_FLAG_ERROR, ADDITIONAL_FLAG_REGEX, + getDestinationValidationIssues, + getFtpTlsState, + isNamedRcloneDestinationProvider, + isRcloneDestinationProvider, + RCLONE_DESTINATION_PROVIDERS, } from "@dokploy/server/db/validations/destination"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { PenBoxIcon, PlusIcon, Trash2 } from "lucide-react"; @@ -39,28 +44,62 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; -import { S3_PROVIDERS } from "./constants"; +import { DESTINATION_PROVIDERS } from "./constants"; -const addDestination = z.object({ - name: z.string().min(1, "Name is required"), - provider: z.string().min(1, "Provider is required"), - accessKeyId: z.string().min(1, "Access Key Id is required"), - secretAccessKey: z.string().min(1, "Secret Access Key is required"), - bucket: z.string().min(1, "Bucket is required"), - region: z.string(), - endpoint: z.string().min(1, "Endpoint is required"), - serverId: z.string().optional(), - additionalFlags: z - .array( - z.object({ - value: z - .string() - .min(1, "Flag cannot be empty") - .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR), - }), - ) - .optional(), -}); +const addDestination = z + .object({ + name: z.string().min(1, "Name is required"), + provider: z.string().min(1, "Provider is required"), + accessKeyId: z.string(), + secretAccessKey: z.string(), + bucket: z.string(), + region: z.string(), + endpoint: z.string(), + serverId: z.string().optional(), + additionalFlags: z + .array( + z.object({ + value: z + .string() + .min(1, "Flag cannot be empty") + .regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR), + }), + ) + .optional(), + }) + .superRefine((data, ctx) => { + const additionalFlags = data.additionalFlags?.map((flag) => flag.value) ?? []; + for (const issue of getDestinationValidationIssues({ + provider: data.provider, + accessKey: data.accessKeyId, + region: data.region, + endpoint: data.endpoint, + additionalFlags, + })) { + ctx.addIssue({ + code: "custom", + path: [issue.field === "accessKey" ? "accessKeyId" : issue.field], + message: issue.message, + }); + } + + if (isRcloneDestinationProvider(data.provider)) return; + + for (const [field, label] of [ + ["accessKeyId", "Access Key Id"], + ["secretAccessKey", "Secret Access Key"], + ["bucket", "Bucket"], + ["endpoint", "Endpoint"], + ] as const) { + if (!data[field].trim()) { + ctx.addIssue({ + code: "custom", + path: [field], + message: `${label} is required`, + }); + } + } + }); type AddDestination = z.infer; @@ -108,6 +147,19 @@ export const HandleDestinations = ({ destinationId }: Props) => { resolver: zodResolver(addDestination), }); + const currentProvider = form.watch("provider"); + const currentAdditionalFlags = + form.watch("additionalFlags")?.map((flag) => flag.value) ?? []; + const { implicitTlsEnabled: implicitFtpTlsEnabled } = getFtpTlsState( + currentAdditionalFlags, + ); + const isNamedRemote = isNamedRcloneDestinationProvider(currentProvider); + const isFileTransfer = + currentProvider === RCLONE_DESTINATION_PROVIDERS.FTP || + currentProvider === RCLONE_DESTINATION_PROVIDERS.SFTP; + const hasRemoteServers = (servers?.length ?? 0) > 0; + const showServerSelector = Boolean(isCloud) || hasRemoteServers; + const { fields, append, remove } = useFieldArray({ control: form.control, name: "additionalFlags", @@ -131,17 +183,25 @@ export const HandleDestinations = ({ destinationId }: Props) => { } }, [form, form.reset, form.formState.isSubmitSuccessful, destination]); + const getPayload = (data: AddDestination) => ({ + provider: data.provider, + accessKey: isNamedRcloneDestinationProvider(data.provider) + ? "" + : data.accessKeyId, + bucket: data.bucket, + endpoint: data.endpoint.trim(), + name: data.name, + region: isNamedRcloneDestinationProvider(data.provider) ? "" : data.region, + secretAccessKey: isNamedRcloneDestinationProvider(data.provider) + ? "" + : data.secretAccessKey, + additionalFlags: data.additionalFlags?.map((f) => f.value) ?? [], + }); + const onSubmit = async (data: AddDestination) => { await mutateAsync({ - provider: data.provider || "", - accessKey: data.accessKeyId, - bucket: data.bucket, - endpoint: data.endpoint, - name: data.name, - region: data.region, - secretAccessKey: data.secretAccessKey, + ...getPayload(data), destinationId: destinationId || "", - additionalFlags: data.additionalFlags?.map((f) => f.value) ?? [], }) .then(async () => { toast.success(`Destination ${destinationId ? "Updated" : "Created"}`); @@ -162,14 +222,7 @@ export const HandleDestinations = ({ destinationId }: Props) => { }; const handleTestConnection = async (serverId?: string) => { - const result = await form.trigger([ - "provider", - "accessKeyId", - "secretAccessKey", - "bucket", - "endpoint", - "additionalFlags", - ]); + const result = await form.trigger(); if (!result) { const errors = form.formState.errors; @@ -184,38 +237,23 @@ export const HandleDestinations = ({ destinationId }: Props) => { return; } - if (isCloud && !serverId) { + const selectedServerId = serverId === "none" ? undefined : serverId; + if (isCloud && !selectedServerId) { toast.error("Please select a server"); return; } - const provider = form.getValues("provider"); - const accessKey = form.getValues("accessKeyId"); - const secretKey = form.getValues("secretAccessKey"); - const bucket = form.getValues("bucket"); - const endpoint = form.getValues("endpoint"); - const region = form.getValues("region"); - - const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`; - await testConnection({ - provider, - accessKey, - bucket, - endpoint, + ...getPayload(form.getValues()), name: "Test", - region, - secretAccessKey: secretKey, - serverId, - additionalFlags: - form.getValues("additionalFlags")?.map((f) => f.value) ?? [], + serverId: selectedServerId, }) .then(() => { toast.success("Connection Success"); }) .catch((e) => { toast.error("Error connecting to provider", { - description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`, + description: e.message, }); }); }; @@ -244,9 +282,9 @@ export const HandleDestinations = ({ destinationId }: Props) => { {destinationId ? "Update" : "Add"} Destination - In this section, you can configure and add new destinations for your - backups. Please ensure that you provide the correct information to - guarantee secure and efficient storage. + Configure a backup destination. Google Drive, OneDrive, and generic + rclone destinations use a named rclone remote configured on the + machine that executes the backup. {(isError || isErrorConnection) && ( @@ -264,123 +302,113 @@ export const HandleDestinations = ({ destinationId }: Props) => { { - return ( - - Name - - - - - - ); - }} - /> - { - return ( - - Provider - - - - - - ); - }} - /> - - { - return ( - - Access Key Id - - - - - - ); - }} - /> - ( -
- Secret Access Key -
+ Name - +
)} /> + ( + + Provider + + + + + + )} + /> + + {!isNamedRemote && ( + ( + + + {isFileTransfer ? "Username" : "Access Key Id"} + + + + + + + )} + /> + )} + {!isNamedRemote && ( + ( + + + {isFileTransfer + ? "Password (Optional)" + : "Secret Access Key"} + + + + + + + )} + /> + )} ( -
- Bucket -
- - - - -
- )} - /> - ( - -
- Region -
- - - - -
- )} - /> - ( - - Endpoint + + {isNamedRemote + ? "Remote Base Path (Optional)" + : isFileTransfer + ? "Base Path / Directory (Optional)" + : "Bucket"} + @@ -388,9 +416,78 @@ export const HandleDestinations = ({ destinationId }: Props) => { )} /> + {!isNamedRemote && ( + ( + + {isFileTransfer ? "Port" : "Region"} + + + + + + )} + /> + )} + ( + + + {isNamedRemote + ? "Rclone Remote Name" + : isFileTransfer + ? "Host" + : "Endpoint"} + + + + + {isNamedRemote && ( +

+ Configure this remote with rclone on the machine that runs + the backup, then enter only its remote name here (without + a colon). +

+ )} + +
+ )} + />
- Additional Flags (Optional) + + {isFileTransfer + ? "Security / Additional Flags" + : "Additional Flags (Optional)"} +
+ {currentProvider === RCLONE_DESTINATION_PROVIDERS.FTP && ( +

+ Required: --ftp-explicit-tls for explicit FTPS (port 21) or + --ftp-tls for implicit FTPS (port 990). +

+ )} + {currentProvider === RCLONE_DESTINATION_PROVIDERS.SFTP && ( +

+ Required: --sftp-known-hosts-file=/path/to/known_hosts to + verify the server host key. +

+ )} {fields.map((field, index) => ( {
- +