Compare commits

...

14 Commits

Author SHA1 Message Date
Nicolas Meienberger
d78b4adfd9 chore: update readme 2025-11-15 10:37:35 +01:00
Nicolas Meienberger
4d3ec524e2 chore: add all caps for dev container 2025-11-15 10:23:15 +01:00
Nicolas Meienberger
681cf5dff1 fix: hide test-connection button for directories 2025-11-15 10:15:25 +01:00
Nicolas Meienberger
31da747c2d fix: mount and unmount command not properly throwing errors 2025-11-15 10:08:16 +01:00
Nicolas Meienberger
b86081b2e8 Merge branch 'altendorfme-backup-file-path' 2025-11-15 09:51:05 +01:00
Nicolas Meienberger
3622fd57ef refactor(repository): keep the error if repo is already init 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
5b1d7eff17 chore: update .gitignore 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
2b3d8dffc5 Merge branch 'altendorfme-main' 2025-11-15 09:42:50 +01:00
Nicolas Meienberger
f517438a8e refactor(repository): keep the error if repo is already init 2025-11-15 09:42:29 +01:00
Nicolas Meienberger
1ddd4d701b chore: update .gitignore 2025-11-15 09:39:49 +01:00
Renan Bernordi
9a1797b8b2 backup file and folders 2025-11-14 23:21:13 -03:00
Renan Bernordi
52046c88cc support cloudflare r2 2025-11-14 22:37:27 -03:00
Nicolas Meienberger
951d9d970c chore: update readme 2025-11-14 22:44:58 +01:00
Nicolas Meienberger
ffc821af2b chore: update readme 2025-11-14 21:36:33 +01:00
13 changed files with 151 additions and 50 deletions

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@
.env
.turbo
CLAUDE.md
mutagen.yml.lock
notes.md

View File

@@ -36,10 +36,11 @@ In order to run Ironmount, you need to have Docker and Docker Compose installed
```yaml
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount
restart: unless-stopped
privileged: true
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
@@ -67,7 +68,7 @@ If you want to track a local directory on the same server where Ironmount is run
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount
restart: unless-stopped
cap_add:
@@ -132,10 +133,11 @@ Ironmount can use [rclone](https://rclone.org/) to support 40+ cloud storage pro
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount
restart: unless-stopped
privileged: true
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
@@ -187,7 +189,7 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ir
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount
restart: unless-stopped
ports:
@@ -215,7 +217,7 @@ In order to enable this feature, you need to run Ironmount with several items sh
```diff
services:
ironmount:
image: ghcr.io/nicotsx/ironmount:v0.6
image: ghcr.io/nicotsx/ironmount:v0.8
container_name: ironmount
restart: unless-stopped
cap_add:

View File

@@ -36,6 +36,7 @@ type Props = {
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" as const, compressionMode: "auto" as const },
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 },
@@ -115,6 +116,7 @@ export const CreateRepositoryForm = ({
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<Tooltip>
@@ -222,6 +224,67 @@ export const CreateRepositoryForm = ({
</>
)}
{watchedBackend === "r2" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
</FormControl>
<FormDescription>R2 endpoint (without https://). Find in R2 dashboard under bucket settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>R2 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
</FormControl>
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "gcs" && (
<>
<FormField

View File

@@ -536,42 +536,44 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</>
)}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
)}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button>
</div>
{testMessage && (
<div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage.message}
{watchedBackend !== "directory" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
)}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button>
</div>
)}
</div>
{testMessage && (
<div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage.message}
</div>
)}
</div>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
Save Changes

View File

@@ -254,7 +254,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
<CardHeader>
<CardTitle>Backup paths</CardTitle>
<CardDescription>
Select which folders to include in the backup. If no paths are selected, the entire volume will be
Select which folders or files to include in the backup. If no paths are selected, the entire volume will be
backed up.
</CardDescription>
</CardHeader>
@@ -264,7 +264,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={true}
foldersOnly={false}
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
/>
{selectedPaths.size > 0 && (

View File

@@ -3,6 +3,7 @@ import { type } from "arktype";
export const REPOSITORY_BACKENDS = {
local: "local",
s3: "s3",
r2: "r2",
gcs: "gcs",
azure: "azure",
rclone: "rclone",
@@ -18,6 +19,14 @@ export const s3RepositoryConfigSchema = type({
secretAccessKey: "string",
});
export const r2RepositoryConfigSchema = type({
backend: "'r2'",
endpoint: "string",
bucket: "string",
accessKeyId: "string",
secretAccessKey: "string",
});
export const localRepositoryConfigSchema = type({
backend: "'local'",
name: "string",
@@ -45,6 +54,7 @@ export const rcloneRepositoryConfigSchema = type({
});
export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema)
.or(azureRepositoryConfigSchema)

View File

@@ -19,7 +19,9 @@ export class CleanupDanglingMountsJob extends Job {
const matchingVolume = allVolumes.find((v) => getVolumePath(v) === mount.mountPoint);
if (!matchingVolume) {
logger.info(`Found dangling mount at ${mount.mountPoint}, attempting to unmount...`);
await executeUnmount(mount.mountPoint);
await executeUnmount(mount.mountPoint).catch((err) => {
logger.warn(`Failed to unmount dangling mount ${mount.mountPoint}: ${toMessage(err)}`);
});
await fs.rmdir(path.dirname(mount.mountPoint)).catch((err) => {
logger.warn(

View File

@@ -13,6 +13,10 @@ export const executeMount = async (args: string[]): Promise<void> => {
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
if (result.exitCode !== 0) {
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
}
};
export const executeUnmount = async (path: string): Promise<void> => {
@@ -24,6 +28,10 @@ export const executeUnmount = async (path: string): Promise<void> => {
if (stderr?.trim()) {
logger.warn(stderr.trim());
}
if (result.exitCode !== 0) {
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
}
};
export const createTestFile = async (path: string): Promise<void> => {

View File

@@ -7,6 +7,7 @@ import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => {
@@ -19,6 +20,7 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
switch (config.backend) {
case "s3":
case "r2":
encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId);
encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey);
break;
@@ -80,6 +82,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
}
const errorMessage = toMessage(error);
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, id));
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);

View File

@@ -74,6 +74,10 @@ const buildRepoUrl = (config: RepositoryConfig): string => {
return `${REPOSITORY_BASE}/${config.name}`;
case "s3":
return `s3:${config.endpoint}/${config.bucket}`;
case "r2": {
const endpoint = config.endpoint.replace(/^https?:\/\//, '');
return `s3:${endpoint}/${config.bucket}`;
}
case "gcs":
return `gs:${config.bucket}:/`;
case "azure":
@@ -98,6 +102,12 @@ const buildEnv = async (config: RepositoryConfig) => {
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
break;
case "r2":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId);
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey);
env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true";
break;
case "gcs": {
const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson);
const credentialsPath = path.join("/tmp", `gcs-credentials-${crypto.randomBytes(8).toString("hex")}.json`);

View File

@@ -19,6 +19,9 @@ services:
- ./app:/app/app
- ~/.config/rclone:/root/.config/rclone
- /var/lib/ironmount:/var/lib/ironmount:rshared
- /run/docker/plugins:/run/docker/plugins
- /var/run/docker.sock:/var/run/docker.sock
ironmount-prod:
build:

View File

@@ -1 +0,0 @@
proj_Nwis7nYU1DiPGTtNlwRKBVtdgo5cOWPsnwbtxj2Urg0

View File

@@ -1,4 +0,0 @@
docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi'
mount -t davfs http://192.168.2.42 /mnt/webdav