feat(frontend): rclone repositories config

This commit is contained in:
Nicolas Meienberger
2025-11-11 21:31:10 +01:00
parent 8f9873148a
commit 36b0282d18
10 changed files with 265 additions and 43 deletions

View File

@@ -94,11 +94,69 @@ Now, when adding a new volume in the Ironmount web interface, you can select "Di
## Creating a repository ## Creating a repository
A repository is where your backups will be securely stored encrypted. Ironmount currently supports S3-compatible storage backends and local directories for storing 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>`
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage
- **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.
To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings. If you choose a local directory as the repository type, your backups will be stored at `/var/lib/ironmount/repositories/<repository-name>`. To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings.
### Using rclone for cloud storage
Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service.
**Setup instructions:**
1. **Install rclone on your host system** (if not already installed):
```bash
curl https://rclone.org/install.sh | sudo bash
```
2. **Configure your cloud storage remote** using rclone's interactive config:
```bash
rclone config
```
Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow.
3. **Verify your remote is configured**:
```bash
rclone listremotes
```
4. **Mount the rclone config into the Ironmount container** by updating your `docker-compose.yml`:
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
container_name: ironmount
restart: unless-stopped
privileged: true
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
volumes:
- /var/lib/ironmount:/var/lib/ironmount
+ - ~/.config/rclone:/root/.config/rclone
```
5. **Restart the Ironmount container**:
```bash
docker compose down
docker compose up -d
```
6. **Create a repository** in Ironmount:
- Select "rclone" as the repository type
- Choose your configured remote from the dropdown
- Specify the path within your remote (e.g., `backups/ironmount`)
For a complete list of supported providers, see the [rclone documentation](https://rclone.org/).
## Your first backup job ## Your first backup job

View File

@@ -29,6 +29,7 @@ import {
listSnapshotFiles, listSnapshotFiles,
restoreSnapshot, restoreSnapshot,
doctorRepository, doctorRepository,
listRcloneRemotes,
listBackupSchedules, listBackupSchedules,
createBackupSchedule, createBackupSchedule,
deleteBackupSchedule, deleteBackupSchedule,
@@ -84,6 +85,7 @@ import type {
RestoreSnapshotResponse, RestoreSnapshotResponse,
DoctorRepositoryData, DoctorRepositoryData,
DoctorRepositoryResponse, DoctorRepositoryResponse,
ListRcloneRemotesData,
ListBackupSchedulesData, ListBackupSchedulesData,
CreateBackupScheduleData, CreateBackupScheduleData,
CreateBackupScheduleResponse, CreateBackupScheduleResponse,
@@ -918,6 +920,27 @@ export const doctorRepositoryMutation = (
return mutationOptions; return mutationOptions;
}; };
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) =>
createQueryKey("listRcloneRemotes", options);
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotesOptions = (options?: Options<ListRcloneRemotesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listRcloneRemotes({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: listRcloneRemotesQueryKey(options),
});
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
createQueryKey("listBackupSchedules", options); createQueryKey("listBackupSchedules", options);

View File

@@ -60,6 +60,8 @@ import type {
RestoreSnapshotResponses, RestoreSnapshotResponses,
DoctorRepositoryData, DoctorRepositoryData,
DoctorRepositoryResponses, DoctorRepositoryResponses,
ListRcloneRemotesData,
ListRcloneRemotesResponses,
ListBackupSchedulesData, ListBackupSchedulesData,
ListBackupSchedulesResponses, ListBackupSchedulesResponses,
CreateBackupScheduleData, CreateBackupScheduleData,
@@ -443,6 +445,18 @@ export const doctorRepository = <ThrowOnError extends boolean = false>(
}); });
}; };
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(
options?: Options<ListRcloneRemotesData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/rclone-remotes",
...options,
});
};
/** /**
* List all backup schedules * List all backup schedules
*/ */

View File

@@ -754,6 +754,11 @@ export type ListRepositoriesResponses = {
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -761,7 +766,7 @@ export type ListRepositoriesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "azure" | "gcs" | "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}>; }>;
}; };
@@ -794,6 +799,11 @@ export type CreateRepositoryData = {
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
name: string; name: string;
compressionMode?: "auto" | "better" | "fastest" | "max" | "off"; compressionMode?: "auto" | "better" | "fastest" | "max" | "off";
@@ -877,6 +887,11 @@ export type GetRepositoryResponses = {
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -884,7 +899,7 @@ export type GetRepositoryResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "azure" | "gcs" | "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
}; };
@@ -1037,6 +1052,25 @@ export type DoctorRepositoryResponses = {
export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses]; export type DoctorRepositoryResponse = DoctorRepositoryResponses[keyof DoctorRepositoryResponses];
export type ListRcloneRemotesData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/repositories/rclone-remotes";
};
export type ListRcloneRemotesResponses = {
/**
* List of rclone remotes
*/
200: Array<{
name: string;
type: string;
}>;
};
export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcloneRemotesResponses];
export type ListBackupSchedulesData = { export type ListBackupSchedulesData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -1085,6 +1119,11 @@ export type ListBackupSchedulesResponses = {
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1092,7 +1131,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "azure" | "gcs" | "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1284,6 +1323,11 @@ export type GetBackupScheduleResponses = {
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1291,7 +1335,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "azure" | "gcs" | "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;
@@ -1464,6 +1508,11 @@ export type GetBackupScheduleForVolumeResponses = {
| { | {
backend: "local"; backend: "local";
name: string; name: string;
}
| {
backend: "rclone";
path: string;
remote: string;
}; };
createdAt: number; createdAt: number;
id: string; id: string;
@@ -1471,7 +1520,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null; lastError: string | null;
name: string; name: string;
status: "error" | "healthy" | "unknown" | null; status: "error" | "healthy" | "unknown" | null;
type: "azure" | "gcs" | "local" | "s3"; type: "azure" | "gcs" | "local" | "rclone" | "s3";
updatedAt: number; updatedAt: number;
}; };
repositoryId: string; repositoryId: string;

View File

@@ -9,6 +9,10 @@ import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { listRcloneRemotesOptions } from "~/api-client/@tanstack/react-query.gen";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink } from "lucide-react";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
@@ -32,6 +36,7 @@ const defaultValuesForType = {
s3: { backend: "s3" as const, compressionMode: "auto" as const }, s3: { backend: "s3" as const, compressionMode: "auto" as const },
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 },
}; };
export const CreateRepositoryForm = ({ export const CreateRepositoryForm = ({
@@ -56,6 +61,10 @@ export const CreateRepositoryForm = ({
const watchedBackend = watch("backend"); const watchedBackend = watch("backend");
const watchedName = watch("name"); const watchedName = watch("name");
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
...listRcloneRemotesOptions(),
});
useEffect(() => { useEffect(() => {
if (watchedBackend && watchedBackend in defaultValuesForType) { if (watchedBackend && watchedBackend in defaultValuesForType) {
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] }); form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
@@ -104,6 +113,7 @@ export const CreateRepositoryForm = ({
<SelectItem value="s3">S3</SelectItem> <SelectItem value="s3">S3</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="rclone">rclone (40+ cloud providers)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription> <FormDescription>Choose the storage backend for this repository.</FormDescription>
@@ -307,6 +317,75 @@ export const CreateRepositoryForm = ({
</> </>
)} )}
{watchedBackend === "rclone" &&
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
) : (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoadingRemotes ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes.map((remote: { name: string; type: string }) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/ironmount" {...field} />
</FormControl>
<FormDescription>Path within the remote 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

@@ -41,7 +41,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const [showRestoreDialog, setShowRestoreDialog] = useState(false); const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false); const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || ""; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const { data: filesData, isLoading: filesLoading } = useQuery({ const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({ ...listSnapshotFilesOptions({

View File

@@ -39,6 +39,21 @@ export const repositoriesController = new Hono()
return c.json({ message: "Repository created", repository: res.repository }, 201); return c.json({ message: "Repository created", repository: res.repository }, 201);
}) })
.get("/rclone-remotes", listRcloneRemotesDto, async (c) => {
const remoteNames = await listRcloneRemotes();
const remotes = await Promise.all(
remoteNames.map(async (name) => {
const info = await getRcloneRemoteInfo(name);
return {
name,
type: info?.type ?? "unknown",
};
}),
);
return c.json(remotes);
})
.get("/:name", getRepositoryDto, async (c) => { .get("/:name", getRepositoryDto, async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
const res = await repositoriesService.getRepository(name); const res = await repositoriesService.getRepository(name);
@@ -127,19 +142,4 @@ export const repositoriesController = new Hono()
const result = await repositoriesService.doctorRepository(name); const result = await repositoriesService.doctorRepository(name);
return c.json<DoctorRepositoryDto>(result, 200); return c.json<DoctorRepositoryDto>(result, 200);
})
.get("/rclone-remotes", listRcloneRemotesDto, async (c) => {
const remoteNames = await listRcloneRemotes();
const remotes = await Promise.all(
remoteNames.map(async (name) => {
const info = await getRcloneRemoteInfo(name);
return {
name,
type: info?.type ?? "unknown",
};
}),
);
return c.json(remotes);
}); });

View File

@@ -6,27 +6,22 @@ import { logger } from "./logger";
* @returns Array of remote names * @returns Array of remote names
*/ */
export async function listRcloneRemotes(): Promise<string[]> { export async function listRcloneRemotes(): Promise<string[]> {
try { const result = await $`rclone listremotes`.nothrow();
const result = await $`rclone listremotes`.quiet();
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
logger.error(`Failed to list rclone remotes: ${result.stderr}`); logger.error(`Failed to list rclone remotes: ${result.stderr}`);
return [];
}
// Parse output - each line is a remote name ending with ":"
const remotes = result.stdout
.toString()
.split("\n")
.map((line) => line.trim())
.filter((line) => line.endsWith(":"))
.map((line) => line.slice(0, -1)); // Remove trailing ":"
return remotes;
} catch (error) {
logger.error(`Error listing rclone remotes: ${error}`);
return []; return [];
} }
// Parse output - each line is a remote name ending with ":"
const remotes = result.stdout
.toString()
.split("\n")
.map((line) => line.trim())
.filter((line) => line.endsWith(":"))
.map((line) => line.slice(0, -1)); // Remove trailing ":"
return remotes;
} }
/** /**

View File

@@ -90,6 +90,7 @@ const buildEnv = async (config: RepositoryConfig) => {
const env: Record<string, string> = { const env: Record<string, string> = {
RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache", RESTIC_CACHE_DIR: "/var/lib/ironmount/restic/cache",
RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE, RESTIC_PASSWORD_FILE: RESTIC_PASS_FILE,
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
}; };
switch (config.backend) { switch (config.backend) {
@@ -234,6 +235,8 @@ const backup = async (
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`); logger.error(`Restic backup failed: ${res.stderr}`);
logger.error(`Command executed: restic ${args.join(" ")}`);
throw new Error(`Restic backup failed: ${res.stderr}`); throw new Error(`Restic backup failed: ${res.stderr}`);
} }
@@ -361,7 +364,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
args.push("--json"); args.push("--json");
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow().quiet();
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`); logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
@@ -456,7 +459,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
args.push(path); args.push(path);
} }
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow().quiet();
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`); logger.error(`Restic ls failed: ${res.stderr}`);

View File

@@ -19,6 +19,7 @@ services:
- ./apps/client/app:/app/apps/client/app - ./apps/client/app:/app/apps/client/app
- ./apps/server/src:/app/apps/server/src - ./apps/server/src:/app/apps/server/src
- ~/.config/rclone:/root/.config/rclone
ironmount-prod: ironmount-prod:
build: build: