diff --git a/Dockerfile b/Dockerfile index b9c01db1..206d294b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.3" FROM oven/bun:${BUN_VERSION}-alpine AS base -RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client +RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 # ------------------------------ diff --git a/app/client/components/create-volume-form.tsx b/app/client/components/create-volume-form.tsx index a497526f..7f2af9a1 100644 --- a/app/client/components/create-volume-form.tsx +++ b/app/client/components/create-volume-form.tsx @@ -1,7 +1,7 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { type } from "arktype"; -import { CheckCircle, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react"; +import { CheckCircle, ExternalLink, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { cn, slugify } from "~/client/lib/utils"; @@ -12,7 +12,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For import { Input } from "./ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { volumeConfigSchema } from "~/schemas/volumes"; -import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen"; +import { listRcloneRemotesOptions, testConnectionMutation } from "../api-client/@tanstack/react-query.gen"; +import { Alert, AlertDescription } from "./ui/alert"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { useSystemInfo } from "~/client/hooks/use-system-info"; export const formSchema = type({ name: "2<=string<=32", @@ -35,6 +38,7 @@ const defaultValuesForType = { nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const }, smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, webdav: { backend: "webdav" as const, port: 80, ssl: false }, + rclone: { backend: "rclone" as const, remote: "", path: "" }, }; export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => { @@ -49,6 +53,13 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for const { watch, getValues } = form; + const { capabilities } = useSystemInfo(); + + const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({ + ...listRcloneRemotesOptions(), + enabled: capabilities.rclone, + }); + const watchedBackend = watch("backend"); useEffect(() => { @@ -122,14 +133,24 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for - - - Directory - NFS - SMB - WebDAV - - + + + Directory + NFS + SMB + WebDAV + + + + rclone (40+ cloud providers) + + + + Setup rclone to use this backend + + + + Choose the storage backend for this volume. @@ -545,7 +566,99 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for > )} - {watchedBackend && watchedBackend !== "directory" && ( + {watchedBackend === "rclone" && + (!rcloneRemotes || rcloneRemotes.length === 0 ? ( + + + No rclone remotes configured + + To use rclone, you need to configure remotes on your host system + + + View rclone documentation + + + + + ) : ( + <> + ( + + Remote + field.onChange(v)} defaultValue={field.value} value={field.value}> + + + + + + + {isLoadingRemotes ? ( + + Loading remotes... + + ) : ( + rcloneRemotes.map((remote: { name: string; type: string }) => ( + + {remote.name} ({remote.type}) + + )) + )} + + + Select the rclone remote configured on your host system. + + + )} + /> + ( + + Path + + + + Path on the remote to mount. Use "/" for the root. + + + )} + /> + ( + + Read-only Mode + + + field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Mount volume as read-only + + + + Prevent any modifications to the volume. Recommended for backup sources and sensitive data. + + + + )} + /> + > + ))} {watchedBackend && watchedBackend !== "directory" && watchedBackend !== "rclone" && ( { color: "text-green-600 dark:text-green-400", label: "WebDAV", }; + case "rclone": + return { + icon: Cloud, + color: "text-cyan-600 dark:text-cyan-400", + label: "Rclone", + }; default: return { icon: Folder, diff --git a/app/schemas/volumes.ts b/app/schemas/volumes.ts index dedc75f9..d0306075 100644 --- a/app/schemas/volumes.ts +++ b/app/schemas/volumes.ts @@ -5,6 +5,7 @@ export const BACKEND_TYPES = { smb: "smb", directory: "directory", webdav: "webdav", + rclone: "rclone", } as const; export type BackendType = keyof typeof BACKEND_TYPES; @@ -47,7 +48,14 @@ export const webdavConfigSchema = type({ ssl: "boolean?", }); -export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema); +export const rcloneConfigSchema = type({ + backend: "'rclone'", + remote: "string", + path: "string", + readOnly: "boolean?", +}); + +export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema); export type BackendConfig = typeof volumeConfigSchema.infer; diff --git a/app/server/modules/backends/backend.ts b/app/server/modules/backends/backend.ts index c3431d5f..c19388d3 100644 --- a/app/server/modules/backends/backend.ts +++ b/app/server/modules/backends/backend.ts @@ -3,6 +3,7 @@ import type { Volume } from "../../db/schema"; import { getVolumePath } from "../volumes/helpers"; import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeNfsBackend } from "./nfs/nfs-backend"; +import { makeRcloneBackend } from "./rclone/rclone-backend"; import { makeSmbBackend } from "./smb/smb-backend"; import { makeWebdavBackend } from "./webdav/webdav-backend"; @@ -33,5 +34,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => { case "webdav": { return makeWebdavBackend(volume.config, path); } + case "rclone": { + return makeRcloneBackend(volume.config, path); + } } }; diff --git a/app/server/modules/backends/rclone/rclone-backend.ts b/app/server/modules/backends/rclone/rclone-backend.ts new file mode 100644 index 00000000..0fcffba8 --- /dev/null +++ b/app/server/modules/backends/rclone/rclone-backend.ts @@ -0,0 +1,128 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import { $ } from "bun"; +import { OPERATION_TIMEOUT } from "../../../core/constants"; +import { toMessage } from "../../../utils/errors"; +import { logger } from "../../../utils/logger"; +import { getMountForPath } from "../../../utils/mountinfo"; +import { withTimeout } from "../../../utils/timeout"; +import type { VolumeBackend } from "../backend"; +import { executeUnmount } from "../utils/backend-utils"; +import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes"; + +const mount = async (config: BackendConfig, path: string) => { + logger.debug(`Mounting rclone volume ${path}...`); + + if (config.backend !== "rclone") { + logger.error("Provided config is not for rclone backend"); + return { status: BACKEND_STATUS.error, error: "Provided config is not for rclone backend" }; + } + + if (os.platform() !== "linux") { + logger.error("Rclone mounting is only supported on Linux hosts."); + return { status: BACKEND_STATUS.error, error: "Rclone mounting is only supported on Linux hosts." }; + } + + const { status } = await checkHealth(path); + if (status === "mounted") { + return { status: BACKEND_STATUS.mounted }; + } + + logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`); + await unmount(path); + + const run = async () => { + await fs.mkdir(path, { recursive: true }); + + const remotePath = `${config.remote}:${config.path}`; + const args = ["mount", remotePath, path, "--daemon"]; + + if (config.readOnly) { + args.push("--read-only"); + } + + args.push("--vfs-cache-mode", "writes"); + args.push("--allow-non-empty"); + args.push("--allow-other"); + + logger.debug(`Mounting rclone volume ${path}...`); + logger.info(`Executing rclone: rclone ${args.join(" ")}`); + + const result = await $`rclone ${args}`.nothrow(); + + if (result.exitCode !== 0) { + const errorMsg = result.stderr.toString() || result.stdout.toString() || "Unknown error"; + throw new Error(`Failed to mount rclone volume: ${errorMsg}`); + } + + logger.info(`Rclone volume at ${path} mounted successfully.`); + return { status: BACKEND_STATUS.mounted }; + }; + + try { + return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone mount"); + } catch (error) { + const errorMsg = toMessage(error); + + logger.error("Error mounting rclone volume", { error: errorMsg }); + return { status: BACKEND_STATUS.error, error: errorMsg }; + } +}; + +const unmount = async (path: string) => { + if (os.platform() !== "linux") { + logger.error("Rclone unmounting is only supported on Linux hosts."); + return { status: BACKEND_STATUS.error, error: "Rclone unmounting is only supported on Linux hosts." }; + } + + const run = async () => { + try { + await fs.access(path); + } catch (e) { + logger.warn(`Path ${path} does not exist. Skipping unmount.`, e); + return { status: BACKEND_STATUS.unmounted }; + } + + await executeUnmount(path); + await fs.rmdir(path); + + logger.info(`Rclone volume at ${path} unmounted successfully.`); + return { status: BACKEND_STATUS.unmounted }; + }; + + try { + return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone unmount"); + } catch (error) { + logger.error("Error unmounting rclone volume", { path, error: toMessage(error) }); + return { status: BACKEND_STATUS.error, error: toMessage(error) }; + } +}; + +const checkHealth = async (path: string) => { + const run = async () => { + logger.debug(`Checking health of rclone volume at ${path}...`); + await fs.access(path); + + const mount = await getMountForPath(path); + + if (!mount || mount.fstype !== "fuse.rclone") { + throw new Error(`Path ${path} is not mounted as rclone.`); + } + + logger.debug(`Rclone volume at ${path} is healthy and mounted.`); + return { status: BACKEND_STATUS.mounted }; + }; + + try { + return await withTimeout(run(), OPERATION_TIMEOUT, "Rclone health check"); + } catch (error) { + logger.error("Rclone volume health check failed:", toMessage(error)); + return { status: BACKEND_STATUS.error, error: toMessage(error) }; + } +}; + +export const makeRcloneBackend = (config: BackendConfig, path: string): VolumeBackend => ({ + mount: () => mount(config, path), + unmount: () => unmount(path), + checkHealth: () => checkHealth(path), +});
Setup rclone to use this backend
No rclone remotes configured
+ To use rclone, you need to configure remotes on your host system +