diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 80f3efc4..58f79eab 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -175,6 +175,8 @@ export type ListVolumesResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -236,6 +238,8 @@ export type CreateVolumeData = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -294,6 +298,8 @@ export type CreateVolumeResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -355,6 +361,8 @@ export type TestConnectionData = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -466,6 +474,8 @@ export type GetVolumeResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -529,6 +539,8 @@ export type UpdateVolumeData = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -596,6 +608,8 @@ export type UpdateVolumeResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -836,8 +850,10 @@ export type ListRepositoriesResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -913,8 +929,10 @@ export type CreateRepositoryData = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; name: string; compressionMode?: 'auto' | 'max' | 'off'; @@ -1052,8 +1070,10 @@ export type GetRepositoryResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1156,8 +1176,10 @@ export type UpdateRepositoryResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1430,8 +1452,10 @@ export type ListBackupSchedulesResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1479,6 +1503,8 @@ export type ListBackupSchedulesResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -1688,8 +1714,10 @@ export type GetBackupScheduleResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1737,6 +1765,8 @@ export type GetBackupScheduleResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -1927,8 +1957,10 @@ export type GetBackupScheduleForVolumeResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -1976,6 +2008,8 @@ export type GetBackupScheduleForVolumeResponses = { path: string; username: string; port?: number; + skipHostKeyCheck?: boolean; + knownHosts?: string; password?: string; privateKey?: string; readOnly?: boolean; @@ -2333,8 +2367,10 @@ export type GetScheduleMirrorsResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; @@ -2437,8 +2473,10 @@ export type UpdateScheduleMirrorsResponses = { privateKey: string; user: string; port?: number; + skipHostKeyCheck?: boolean; customPassword?: string; isExistingRepository?: boolean; + knownHosts?: string; }; createdAt: number; id: string; diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index dce3ff52..79760ca2 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -58,7 +58,7 @@ const defaultValuesForType = { azure: { backend: "azure" as const, compressionMode: "auto" as const }, rclone: { backend: "rclone" as const, compressionMode: "auto" as const }, rest: { backend: "rest" as const, compressionMode: "auto" as const }, - sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 }, + sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: true }, }; export const CreateRepositoryForm = ({ diff --git a/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx index 321d3ba5..8ed55217 100644 --- a/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/sftp-repository-form.tsx @@ -9,6 +9,7 @@ import { } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; import { Textarea } from "../../../../components/ui/textarea"; +import { Switch } from "../../../../components/ui/switch"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -72,7 +73,7 @@ export const SftpRepositoryForm = ({ form }: Props) => { Path - + Repository path on the SFTP server. @@ -96,6 +97,48 @@ export const SftpRepositoryForm = ({ form }: Props) => { )} /> + ( + + + Skip Host Key Verification + + Disable SSH host key checking. Useful for servers with dynamic IPs or self-signed keys. + + + + + + + )} + /> + {!form.watch("skipHostKeyCheck") && ( + ( + + Known Hosts + + + + + The contents of the known_hosts file for this server. + + + + )} + /> + )} > ); }; diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx index dfa8a50d..764a3c6a 100644 --- a/app/client/modules/volumes/components/create-volume-form.tsx +++ b/app/client/modules/volumes/components/create-volume-form.tsx @@ -46,7 +46,7 @@ const defaultValuesForType = { smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" }, rclone: { backend: "rclone" as const, path: "/" }, - sftp: { backend: "sftp" as const, port: 22, path: "/" }, + sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: true }, }; export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { diff --git a/app/client/modules/volumes/components/volume-forms/sftp-form.tsx b/app/client/modules/volumes/components/volume-forms/sftp-form.tsx index 4b0c166d..1d9fa8d6 100644 --- a/app/client/modules/volumes/components/volume-forms/sftp-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/sftp-form.tsx @@ -11,6 +11,7 @@ import { import { Input } from "../../../../components/ui/input"; import { SecretInput } from "../../../../components/ui/secret-input"; import { Textarea } from "../../../../components/ui/textarea"; +import { Switch } from "../../../../components/ui/switch"; type Props = { form: UseFormReturn; @@ -36,7 +37,6 @@ export const SFTPForm = ({ form }: Props) => { ( Port @@ -115,6 +115,48 @@ export const SFTPForm = ({ form }: Props) => { )} /> + ( + + + Skip Host Key Verification + + Disable SSH host key checking. Useful for servers with dynamic IPs or self-signed keys. + + + + + + + )} + /> + {!form.watch("skipHostKeyCheck") && ( + ( + + Known Hosts + + + + + The contents of the known_hosts file for this server. + + + + )} + /> + )} > ); }; diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index 071b0b08..0ae46710 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -77,6 +77,8 @@ export const sftpRepositoryConfigSchema = type({ user: "string", path: "string", privateKey: "string", + skipHostKeyCheck: "boolean = true", + knownHosts: "string?", }).and(baseRepositoryConfigSchema); export const repositoryConfigSchemaBase = s3RepositoryConfigSchema diff --git a/app/schemas/volumes.ts b/app/schemas/volumes.ts index b885c2c7..5589935a 100644 --- a/app/schemas/volumes.ts +++ b/app/schemas/volumes.ts @@ -65,6 +65,8 @@ export const sftpConfigSchema = type({ privateKey: "string?", path: "string", readOnly: "boolean?", + skipHostKeyCheck: "boolean = true", + knownHosts: "string?", }); export const volumeConfigSchemaBase = nfsConfigSchema diff --git a/app/server/modules/backends/sftp/sftp-backend.ts b/app/server/modules/backends/sftp/sftp-backend.ts index 553b04fb..a5eb1de0 100644 --- a/app/server/modules/backends/sftp/sftp-backend.ts +++ b/app/server/modules/backends/sftp/sftp-backend.ts @@ -19,6 +19,11 @@ const getPrivateKeyPath = (mountPath: string) => { return path.join(SSH_KEYS_DIR, `${name}.key`); }; +const getKnownHostsPath = (mountPath: string) => { + const name = path.basename(mountPath); + return path.join(SSH_KEYS_DIR, `${name}.known_hosts`); +}; + const mount = async (config: BackendConfig, mountPath: string) => { logger.debug(`Mounting SFTP volume ${mountPath}...`); @@ -51,12 +56,18 @@ const mount = async (config: BackendConfig, mountPath: string) => { "ServerAliveInterval=15", "ServerAliveCountMax=3", "allow_other", - "StrictHostKeyChecking=no", - "UserKnownHostsFile=/dev/null", "uid=1000", "gid=1000", ]; + if (config.skipHostKeyCheck) { + options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null"); + } else if (config.knownHosts) { + const knownHostsPath = getKnownHostsPath(mountPath); + await fs.writeFile(knownHostsPath, config.knownHosts, { mode: 0o600 }); + options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes"); + } + if (config.readOnly) { options.push("ro"); } @@ -126,6 +137,10 @@ const unmount = async (mountPath: string) => { const keyPath = getPrivateKeyPath(mountPath); await fs.unlink(keyPath).catch(() => {}); + + const knownHostsPath = getKnownHostsPath(mountPath); + await fs.unlink(knownHostsPath).catch(() => {}); + await fs.rmdir(mountPath).catch(() => {}); logger.info(`SFTP volume at ${mountPath} unmounted successfully.`); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index fdf705cb..a7773cf3 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -159,7 +159,7 @@ export const buildEnv = async (config: RepositoryConfig) => { } case "sftp": { const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); - const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`); + const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`); let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); if (!normalizedKey.endsWith("\n")) { @@ -176,10 +176,6 @@ export const buildEnv = async (config: RepositoryConfig) => { env._SFTP_KEY_PATH = keyPath; const sshArgs = [ - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", "-o", "LogLevel=VERBOSE", "-o", @@ -190,6 +186,15 @@ export const buildEnv = async (config: RepositoryConfig) => { keyPath, ]; + if (config.skipHostKeyCheck) { + sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); + } else if (config.knownHosts) { + const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`); + await fs.writeFile(knownHostsPath, config.knownHosts, { mode: 0o600 }); + env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; + sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); + } + if (config.port && config.port !== 22) { sshArgs.push("-p", String(config.port)); } @@ -806,8 +811,13 @@ const copy = async ( }; export const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record) => { - if (config.backend === "sftp" && env._SFTP_KEY_PATH) { - await fs.unlink(env._SFTP_KEY_PATH).catch(() => {}); + if (config.backend === "sftp") { + if (env._SFTP_KEY_PATH) { + await fs.unlink(env._SFTP_KEY_PATH).catch(() => {}); + } + if (env._SFTP_KNOWN_HOSTS_PATH) { + await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {}); + } } else if (config.isExistingRepository && config.customPassword && env.RESTIC_PASSWORD_FILE) { await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {}); } else if (config.backend === "gcs" && env.GOOGLE_APPLICATION_CREDENTIALS) {
known_hosts