add restic sftp support

This commit is contained in:
Renan Bernordi 2025-11-17 00:38:41 -03:00
parent c0bef7f65e
commit ae1d822260
4 changed files with 102 additions and 2 deletions

View file

@ -41,6 +41,7 @@ const defaultValuesForType = {
gcs: { backend: "gcs" as const, compressionMode: "auto" as const }, gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
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 },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
}; };
export const CreateRepositoryForm = ({ export const CreateRepositoryForm = ({
@ -126,6 +127,7 @@ export const CreateRepositoryForm = ({
<SelectItem value="r2">Cloudflare R2</SelectItem> <SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem> <SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem> <SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone"> <SelectItem disabled={!capabilities.rclone} value="rclone">
@ -546,6 +548,81 @@ export const CreateRepositoryForm = ({
</> </>
))} ))}
{watchedBackend === "sftp" && (
<>
<FormField
control={form.control}
name="server"
render={({ field }) => (
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="sftp.example.com" {...field} />
</FormControl>
<FormDescription>SFTP/SSH server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="22" {...field} />
</FormControl>
<FormDescription>SSH server port (default: 22).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="sftpuser" {...field} />
</FormControl>
<FormDescription>SSH account username.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>SSH account password.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/home/user/backups/repository" {...field} />
</FormControl>
<FormDescription>Remote path on the SFTP server where backups will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && ( {mode === "update" && (
<Button type="submit" className="w-full" loading={loading}> <Button type="submit" className="w-full" loading={loading}>
Save Changes Save Changes

View file

@ -7,6 +7,7 @@ export const REPOSITORY_BACKENDS = {
gcs: "gcs", gcs: "gcs",
azure: "azure", azure: "azure",
rclone: "rclone", rclone: "rclone",
sftp: "sftp",
} as const; } as const;
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
@ -59,12 +60,22 @@ export const rcloneRepositoryConfigSchema = type({
path: "string", path: "string",
}).and(baseRepositoryConfigSchema); }).and(baseRepositoryConfigSchema);
export const sftpRepositoryConfigSchema = type({
backend: "'sftp'",
server: "string",
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(22),
username: "string",
password: "string",
path: "string",
}).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema) .or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema) .or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema) .or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema) .or(azureRepositoryConfigSchema)
.or(rcloneRepositoryConfigSchema); .or(rcloneRepositoryConfigSchema)
.or(sftpRepositoryConfigSchema);
export type RepositoryConfig = typeof repositoryConfigSchema.infer; export type RepositoryConfig = typeof repositoryConfigSchema.infer;

View file

@ -15,7 +15,7 @@ const listRepositories = async () => {
}; };
const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => { const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig> => {
const encryptedConfig: Record<string, string | boolean> = { ...config }; const encryptedConfig: Record<string, string | boolean | number> = { ...config };
if (config.customPassword) { if (config.customPassword) {
encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword);
@ -33,6 +33,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
case "azure": case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey); encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
break; break;
case "sftp":
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
break;
} }
return encryptedConfig as RepositoryConfig; return encryptedConfig as RepositoryConfig;

View file

@ -84,6 +84,8 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `azure:${config.container}:/`; return `azure:${config.container}:/`;
case "rclone": case "rclone":
return `rclone:${config.remote}:${config.path}`; return `rclone:${config.remote}:${config.path}`;
case "sftp":
return `sftp:${config.username}@${config.server}:${config.port}${config.path}`;
default: { default: {
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`); throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
} }
@ -133,6 +135,13 @@ const buildEnv = async (config: RepositoryConfig) => {
} }
break; break;
} }
case "sftp": {
const decryptedPassword = await cryptoUtils.decrypt(config.password);
const passwordFilePath = path.join("/tmp", `ironmount-sftp-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
env.RESTIC_SFTP_PASSWORD_FILE = passwordFilePath;
break;
}
} }
return env; return env;