add sftp support

This commit is contained in:
Renan Bernordi 2025-11-17 01:00:00 -03:00
parent 4d48d7be58
commit 122f351555
5 changed files with 103 additions and 3 deletions

View file

@ -103,9 +103,10 @@ Now, when adding a new volume in the Ironmount web interface, you can select "Di
A repository is where your backups will be securely stored encrypted. Ironmount supports multiple storage backends for your backup repositories: A repository is where your backups will be securely stored encrypted. Ironmount supports multiple storage backends for your backup repositories:
- **Local directories** - Store backups on local disk at `/var/lib/ironmount/repositories/<repository-name>` - **Local directories** - Store backups on local disk at `/var/lib/ironmount/repositories/<repository-name>`
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc. - **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, Cloudflare R2, etc.
- **Google Cloud Storage** - Google's cloud storage service - **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage - **Azure Blob Storage** - Microsoft Azure storage
- **SFTP** - Secure File Transfer Protocol
- **rclone remotes** - 40+ cloud storage providers via rclone (see below) - **rclone remotes** - 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features. Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.

View file

@ -42,6 +42,7 @@ const defaultValuesForType = {
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 },
}; };
export const CreateRepositoryForm = ({ export const CreateRepositoryForm = ({
@ -128,6 +129,7 @@ export const CreateRepositoryForm = ({
<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="rest">REST Server</SelectItem> <SelectItem value="rest">REST Server</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone"> <SelectItem disabled={!capabilities.rclone} value="rclone">
@ -609,6 +611,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

@ -68,13 +68,23 @@ export const restRepositoryConfigSchema = 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(restRepositoryConfigSchema); .or(restRepositoryConfigSchema)
.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);
@ -41,6 +41,9 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
encryptedConfig.password = await cryptoUtils.encrypt(config.password); encryptedConfig.password = await cryptoUtils.encrypt(config.password);
} }
break; break;
case "sftp":
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
break;
} }
return encryptedConfig as RepositoryConfig; return encryptedConfig as RepositoryConfig;

View file

@ -88,6 +88,8 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
const path = config.path ? `/${config.path}` : ""; const path = config.path ? `/${config.path}` : "";
return `rest:${config.url}${path}`; return `rest:${config.url}${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)}`);
} }
@ -146,6 +148,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;