From f5339d3708a19b6364e4bbcd4ba8dd45e5f38ec1 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 8 Nov 2025 10:01:54 +0100 Subject: [PATCH] feat(volumes): read only mount mode --- apps/client/app/api-client/sdk.gen.ts | 9 +-- apps/client/app/api-client/types.gen.ts | 25 ------- .../app/components/create-volume-form.tsx | 75 +++++++++++++++++++ .../components/snapshot-file-browser.tsx | 35 +++++++-- .../modules/backups/routes/backup-details.tsx | 1 + .../src/modules/backends/nfs/nfs-backend.ts | 13 +++- .../src/modules/backends/smb/smb-backend.ts | 14 +++- .../modules/backends/webdav/webdav-backend.ts | 14 ++-- packages/schemas/src/index.ts | 2 + 9 files changed, 137 insertions(+), 51 deletions(-) diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index 874933c..81d8ca7 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -4,10 +4,8 @@ import type { Options as ClientOptions, TDataShape, Client } from "./client"; import type { RegisterData, RegisterResponses, - RegisterErrors, LoginData, LoginResponses, - LoginErrors, LogoutData, LogoutResponses, GetMeData, @@ -16,7 +14,6 @@ import type { GetStatusResponses, ChangePasswordData, ChangePasswordResponses, - ChangePasswordErrors, ListVolumesData, ListVolumesResponses, CreateVolumeData, @@ -97,7 +94,7 @@ export type Options(options?: Options) => { - return (options?.client ?? _heyApiClient).post({ + return (options?.client ?? _heyApiClient).post({ url: "/api/v1/auth/register", ...options, headers: { @@ -111,7 +108,7 @@ export const register = (options?: Options * Login with username and password */ export const login = (options?: Options) => { - return (options?.client ?? _heyApiClient).post({ + return (options?.client ?? _heyApiClient).post({ url: "/api/v1/auth/login", ...options, headers: { @@ -157,7 +154,7 @@ export const getStatus = (options?: Option export const changePassword = ( options?: Options, ) => { - return (options?.client ?? _heyApiClient).post({ + return (options?.client ?? _heyApiClient).post({ url: "/api/v1/auth/change-password", ...options, headers: { diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index a768a31..fecda6b 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -10,13 +10,6 @@ export type RegisterData = { url: "/api/v1/auth/register"; }; -export type RegisterErrors = { - /** - * Invalid request or username already exists - */ - 400: unknown; -}; - export type RegisterResponses = { /** * User created successfully @@ -43,13 +36,6 @@ export type LoginData = { url: "/api/v1/auth/login"; }; -export type LoginErrors = { - /** - * Invalid credentials - */ - 401: unknown; -}; - export type LoginResponses = { /** * Login successful @@ -135,17 +121,6 @@ export type ChangePasswordData = { url: "/api/v1/auth/change-password"; }; -export type ChangePasswordErrors = { - /** - * Invalid current password or validation error - */ - 400: unknown; - /** - * Not authenticated - */ - 401: unknown; -}; - export type ChangePasswordResponses = { /** * Password changed successfully diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx index deb6606..e6227d3 100644 --- a/apps/client/app/components/create-volume-form.tsx +++ b/apps/client/app/components/create-volume-form.tsx @@ -202,6 +202,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for )} /> + ( + + 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. + + +
+ )} + /> )} @@ -301,6 +326,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for )} /> + ( + + 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. + + +
+ )} + /> )} @@ -422,6 +472,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for )} /> + ( + + 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. + + +
+ )} + /> )} diff --git a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx index 59cd363..672ac6e 100644 --- a/apps/client/app/modules/backups/components/snapshot-file-browser.tsx +++ b/apps/client/app/modules/backups/components/snapshot-file-browser.tsx @@ -17,16 +17,20 @@ import { AlertDialogHeader, AlertDialogTitle, } from "~/components/ui/alert-dialog"; -import type { Snapshot } from "~/lib/types"; +import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip"; +import type { Snapshot, Volume } from "~/lib/types"; import { toast } from "sonner"; interface Props { snapshot: Snapshot; repositoryName: string; + volume?: Volume; } export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryName } = props; + const { snapshot, repositoryName, volume } = props; + + const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true; const queryClient = useQueryClient(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); @@ -195,11 +199,28 @@ export const SnapshotFileBrowser = (props: Props) => { {`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`} {selectedPaths.size > 0 && ( - + + + + + + + {isReadOnly && ( + +

Volume is mounted as read-only.

+

Please remount with read-only disabled to restore files.

+
+ )} +
)} diff --git a/apps/client/app/modules/backups/routes/backup-details.tsx b/apps/client/app/modules/backups/routes/backup-details.tsx index 589852e..61ba243 100644 --- a/apps/client/app/modules/backups/routes/backup-details.tsx +++ b/apps/client/app/modules/backups/routes/backup-details.tsx @@ -181,6 +181,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon key={selectedSnapshot?.short_id} snapshot={selectedSnapshot} repositoryName={schedule.repository.name} + volume={schedule.volume} /> )} diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index 3d3b209..6c0354d 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => { return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." }; } - const { status } = await checkHealth(path); + const { status } = await checkHealth(path, config.readOnly); if (status === "mounted") { return { status: BACKEND_STATUS.mounted }; } @@ -35,6 +35,9 @@ const mount = async (config: BackendConfig, path: string) => { const source = `${config.server}:${config.exportPath}`; const options = [`vers=${config.version}`, `port=${config.port}`]; + if (config.readOnly) { + options.push("ro"); + } const args = ["-t", "nfs", "-o", options.join(","), source, path]; logger.debug(`Mounting volume ${path}...`); @@ -84,7 +87,7 @@ const unmount = async (path: string) => { } }; -const checkHealth = async (path: string) => { +const checkHealth = async (path: string, readOnly: boolean) => { const run = async () => { logger.debug(`Checking health of NFS volume at ${path}...`); await fs.access(path); @@ -95,7 +98,9 @@ const checkHealth = async (path: string) => { throw new Error(`Path ${path} is not mounted as NFS.`); } - await createTestFile(path); + if (!readOnly) { + await createTestFile(path); + } logger.debug(`NFS volume at ${path} is healthy and mounted.`); return { status: BACKEND_STATUS.mounted }; @@ -112,5 +117,5 @@ const checkHealth = async (path: string) => { export const makeNfsBackend = (config: BackendConfig, path: string): VolumeBackend => ({ mount: () => mount(config, path), unmount: () => unmount(path), - checkHealth: () => checkHealth(path), + checkHealth: () => checkHealth(path, config.readOnly), }); diff --git a/apps/server/src/modules/backends/smb/smb-backend.ts b/apps/server/src/modules/backends/smb/smb-backend.ts index b4a7b54..17870cd 100644 --- a/apps/server/src/modules/backends/smb/smb-backend.ts +++ b/apps/server/src/modules/backends/smb/smb-backend.ts @@ -22,7 +22,7 @@ const mount = async (config: BackendConfig, path: string) => { return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." }; } - const { status } = await checkHealth(path); + const { status } = await checkHealth(path, config.readOnly); if (status === "mounted") { return { status: BACKEND_STATUS.mounted }; } @@ -47,6 +47,10 @@ const mount = async (config: BackendConfig, path: string) => { options.push(`domain=${config.domain}`); } + if (config.readOnly) { + options.push("ro"); + } + const args = ["-t", "cifs", "-o", options.join(","), source, path]; logger.debug(`Mounting SMB volume ${path}...`); @@ -96,7 +100,7 @@ const unmount = async (path: string) => { } }; -const checkHealth = async (path: string) => { +const checkHealth = async (path: string, readOnly: boolean) => { const run = async () => { logger.debug(`Checking health of SMB volume at ${path}...`); await fs.access(path); @@ -107,7 +111,9 @@ const checkHealth = async (path: string) => { throw new Error(`Path ${path} is not mounted as CIFS/SMB.`); } - await createTestFile(path); + if (!readOnly) { + await createTestFile(path); + } logger.debug(`SMB volume at ${path} is healthy and mounted.`); return { status: BACKEND_STATUS.mounted }; @@ -124,5 +130,5 @@ const checkHealth = async (path: string) => { export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({ mount: () => mount(config, path), unmount: () => unmount(path), - checkHealth: () => checkHealth(path), + checkHealth: () => checkHealth(path, config.readOnly), }); diff --git a/apps/server/src/modules/backends/webdav/webdav-backend.ts b/apps/server/src/modules/backends/webdav/webdav-backend.ts index 50ee458..5fa41ae 100644 --- a/apps/server/src/modules/backends/webdav/webdav-backend.ts +++ b/apps/server/src/modules/backends/webdav/webdav-backend.ts @@ -26,7 +26,7 @@ const mount = async (config: BackendConfig, path: string) => { return { status: BACKEND_STATUS.error, error: "WebDAV mounting is only supported on Linux hosts." }; } - const { status } = await checkHealth(path); + const { status } = await checkHealth(path, config.readOnly); if (status === "mounted") { return { status: BACKEND_STATUS.mounted }; } @@ -44,7 +44,9 @@ const mount = async (config: BackendConfig, path: string) => { const port = config.port !== defaultPort ? `:${config.port}` : ""; const source = `${protocol}://${config.server}${port}${config.path}`; - const options = ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; + const options = config.readOnly + ? ["uid=1000", "gid=1000", "file_mode=0444", "dir_mode=0555", "ro"] + : ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; if (config.username && config.password) { const secretsFile = "/etc/davfs2/secrets"; @@ -132,7 +134,7 @@ const unmount = async (path: string) => { } }; -const checkHealth = async (path: string) => { +const checkHealth = async (path: string, readOnly: boolean) => { const run = async () => { logger.debug(`Checking health of WebDAV volume at ${path}...`); await fs.access(path); @@ -143,7 +145,9 @@ const checkHealth = async (path: string) => { throw new Error(`Path ${path} is not mounted as WebDAV.`); } - await createTestFile(path); + if (!readOnly) { + await createTestFile(path); + } logger.debug(`WebDAV volume at ${path} is healthy and mounted.`); return { status: BACKEND_STATUS.mounted }; @@ -160,5 +164,5 @@ const checkHealth = async (path: string) => { export const makeWebdavBackend = (config: BackendConfig, path: string): VolumeBackend => ({ mount: () => mount(config, path), unmount: () => unmount(path), - checkHealth: () => checkHealth(path), + checkHealth: () => checkHealth(path, config.readOnly), }); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 28d8604..edc778e 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -15,6 +15,7 @@ export const nfsConfigSchema = type({ exportPath: "string", port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049), version: "'3' | '4' | '4.1'", + readOnly: "boolean?", }); export const smbConfigSchema = type({ @@ -26,6 +27,7 @@ export const smbConfigSchema = type({ vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"), domain: "string?", port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445), + readOnly: "boolean?", }); export const directoryConfigSchema = type({