Compare commits

...

5 Commits

Author SHA1 Message Date
Nicolas Meienberger
685f0223f5 chore test 2025-11-18 18:53:06 +01:00
Nicolas Meienberger
70e4c782ff fix: undefined path in local repo 2025-11-17 21:09:46 +01:00
Nicolas Meienberger
c726c6fc72 feat: custom local repository path 2025-11-17 18:17:51 +01:00
Nicolas Meienberger
4d48d7be58 feat: add support for REST server 2025-11-16 18:24:09 +01:00
Nicolas Meienberger
df6b70c96f fix(create-volume): all port fields as number 2025-11-16 17:27:49 +01:00
8 changed files with 271 additions and 11 deletions

View File

@@ -287,3 +287,5 @@ Ironmount includes [Restic](https://github.com/restic/restic) for backup functio
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.
Test5

View File

@@ -741,12 +741,21 @@ export type ListRepositoriesResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -754,7 +763,7 @@ export type ListRepositoriesResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
}>;
};
@@ -799,12 +808,21 @@ export type CreateRepositoryData = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
@@ -919,12 +937,21 @@ export type GetRepositoryResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -932,7 +959,7 @@ export type GetRepositoryResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
};
@@ -1166,12 +1193,21 @@ export type ListBackupSchedulesResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1179,7 +1215,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
repositoryId: string;
@@ -1379,12 +1415,21 @@ export type GetBackupScheduleResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1392,7 +1437,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
repositoryId: string;
@@ -1573,12 +1618,21 @@ export type GetBackupScheduleForVolumeResponses = {
name: string;
customPassword?: string;
isExistingRepository?: boolean;
path?: string;
} | {
backend: 'rclone';
path: string;
remote: string;
customPassword?: string;
isExistingRepository?: boolean;
} | {
backend: 'rest';
url: string;
customPassword?: string;
isExistingRepository?: boolean;
password?: string;
path?: string;
username?: string;
};
createdAt: number;
id: string;
@@ -1586,7 +1640,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null;
name: string;
status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 's3';
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3';
updatedAt: number;
};
repositoryId: string;

View File

@@ -10,12 +10,23 @@ import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink } from "lucide-react";
import { ExternalLink, AlertTriangle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
import { Checkbox } from "./ui/checkbox";
import { DirectoryBrowser } from "./directory-browser";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
export const formSchema = type({
name: "2<=string<=32",
@@ -41,6 +52,7 @@ const defaultValuesForType = {
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
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 },
};
export const CreateRepositoryForm = ({
@@ -66,6 +78,8 @@ export const CreateRepositoryForm = ({
const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
const { capabilities } = useSystemInfo();
@@ -126,6 +140,7 @@ export const CreateRepositoryForm = ({
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rest">REST Server</SelectItem>
<Tooltip>
<TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone">
@@ -245,6 +260,87 @@ export const CreateRepositoryForm = ({
</>
)}
{watchedBackend === "local" && (
<>
<FormItem>
<FormLabel>Repository Directory</FormLabel>
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{form.watch("path") || "/var/lib/ironmount/repositories"}
</div>
<Button
type="button"
variant="outline"
onClick={() => setShowPathWarning(true)}
size="sm"
>
Change
</Button>
</div>
<FormDescription>
The directory where the repository will be stored.
</FormDescription>
</FormItem>
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Important: Host Mount Required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
When selecting a custom path, ensure it is mounted from the host machine into the
container.
</p>
<p className="font-medium">
If the path is not a host mount, you will lose your repository data when the container
restarts.
</p>
<p className="text-sm text-muted-foreground">
The default path <code className="bg-muted px-1 rounded">/var/lib/ironmount/repositories</code> is
already mounted from the host and is safe to use.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowPathBrowser(true);
setShowPathWarning(false);
}}
>
I Understand, Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
<AlertDialogDescription>
Choose a directory from the filesystem to store the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<DirectoryBrowser
onSelectPath={(path) => form.setValue("path", path)}
selectedPath={form.watch("path") || "/var/lib/ironmount/repositories"}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>Done</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{watchedBackend === "s3" && (
<>
<FormField
@@ -546,6 +642,67 @@ export const CreateRepositoryForm = ({
</>
))}
{watchedBackend === "rest" && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>REST Server URL</FormLabel>
<FormControl>
<Input placeholder="http://192.168.1.30:8000" {...field} />
</FormControl>
<FormDescription>URL of the REST server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="my-backup-repo" {...field} />
</FormControl>
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormDescription>Username for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes

View File

@@ -207,7 +207,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="2049" {...field} />
<Input
type="number"
placeholder="2049"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage />
@@ -332,7 +337,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="80" {...field} />
<Input
type="number"
placeholder="80"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
<FormMessage />

View File

@@ -1,4 +1,4 @@
import { Database, HardDrive, Cloud } from "lucide-react";
import { Database, HardDrive, Cloud, Server } from "lucide-react";
import type { RepositoryBackend } from "~/schemas/restic";
type Props = {
@@ -14,6 +14,8 @@ export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
return <Cloud className={className} />;
case "gcs":
return <Cloud className={className} />;
case "rest":
return <Server className={className} />;
default:
return <Database className={className} />;
}

View File

@@ -7,6 +7,7 @@ export const REPOSITORY_BACKENDS = {
gcs: "gcs",
azure: "azure",
rclone: "rclone",
rest: "rest",
} as const;
export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS;
@@ -36,6 +37,7 @@ export const r2RepositoryConfigSchema = type({
export const localRepositoryConfigSchema = type({
backend: "'local'",
name: "string",
path: "string?",
}).and(baseRepositoryConfigSchema);
export const gcsRepositoryConfigSchema = type({
@@ -59,12 +61,21 @@ export const rcloneRepositoryConfigSchema = type({
path: "string",
}).and(baseRepositoryConfigSchema);
export const restRepositoryConfigSchema = type({
backend: "'rest'",
url: "string",
username: "string?",
password: "string?",
path: "string?",
}).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema)
.or(rcloneRepositoryConfigSchema);
.or(rcloneRepositoryConfigSchema)
.or(restRepositoryConfigSchema);
export type RepositoryConfig = typeof repositoryConfigSchema.infer;

View File

@@ -33,6 +33,14 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
case "azure":
encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey);
break;
case "rest":
if (config.username) {
encryptedConfig.username = await cryptoUtils.encrypt(config.username);
}
if (config.password) {
encryptedConfig.password = await cryptoUtils.encrypt(config.password);
}
break;
}
return encryptedConfig as RepositoryConfig;

View File

@@ -71,7 +71,7 @@ const ensurePassfile = async () => {
const buildRepoUrl = (config: RepositoryConfig): string => {
switch (config.backend) {
case "local":
return `${REPOSITORY_BASE}/${config.name}`;
return config.path ? `${config.path}/${config.name}` : `${REPOSITORY_BASE}/${config.name}`;
case "s3":
return `s3:${config.endpoint}/${config.bucket}`;
case "r2": {
@@ -84,6 +84,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `azure:${config.container}:/`;
case "rclone":
return `rclone:${config.remote}:${config.path}`;
case "rest": {
const path = config.path ? `/${config.path}` : "";
return `rest:${config.url}${path}`;
}
default: {
throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`);
}
@@ -133,6 +137,15 @@ const buildEnv = async (config: RepositoryConfig) => {
}
break;
}
case "rest": {
if (config.username) {
env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username);
}
if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password);
}
break;
}
}
return env;
@@ -142,6 +155,9 @@ const init = async (config: RepositoryConfig) => {
await ensurePassfile();
const repoUrl = buildRepoUrl(config);
logger.info(`Initializing restic repository at ${repoUrl}...`);
const env = await buildEnv(config);
const res = await $`restic init --repo ${repoUrl} --json`.env(env).nothrow();