mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d78b4adfd9 | ||
|
|
4d3ec524e2 | ||
|
|
681cf5dff1 | ||
|
|
31da747c2d | ||
|
|
b86081b2e8 | ||
|
|
3622fd57ef | ||
|
|
5b1d7eff17 | ||
|
|
2b3d8dffc5 | ||
|
|
f517438a8e | ||
|
|
1ddd4d701b | ||
|
|
9a1797b8b2 | ||
|
|
52046c88cc | ||
|
|
951d9d970c | ||
|
|
ffc821af2b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@
|
||||
.env
|
||||
.turbo
|
||||
CLAUDE.md
|
||||
|
||||
mutagen.yml.lock
|
||||
notes.md
|
||||
|
||||
16
README.md
16
README.md
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
proj_Nwis7nYU1DiPGTtNlwRKBVtdgo5cOWPsnwbtxj2Urg0
|
||||
Reference in New Issue
Block a user