diff --git a/Dockerfile b/Dockerfile index 7129ae2a..2e3d8c4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \ RUN apk update --no-cache && \ apk upgrade --no-cache && \ - apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata + apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata ENTRYPOINT ["/sbin/tini", "-s", "--"] diff --git a/app/client/modules/volumes/components/create-volume-form.tsx b/app/client/modules/volumes/components/create-volume-form.tsx index 367c6b81..86ecec24 100644 --- a/app/client/modules/volumes/components/create-volume-form.tsx +++ b/app/client/modules/volumes/components/create-volume-form.tsx @@ -66,7 +66,7 @@ type Props = { const defaultValuesForType = { directory: { backend: "directory" as const, path: "/" }, nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const }, - smb: { backend: "smb" as const, port: 445, vers: "3.0" as const }, + smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false }, webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" }, rclone: { backend: "rclone" as const, path: "/" }, sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false }, diff --git a/app/client/modules/volumes/components/volume-forms/smb-form.tsx b/app/client/modules/volumes/components/volume-forms/smb-form.tsx index 89e64b52..34130e9c 100644 --- a/app/client/modules/volumes/components/volume-forms/smb-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/smb-form.tsx @@ -168,6 +168,32 @@ export const SMBForm = ({ form }: Props) => { )} /> + ( + + Ownership Mapping + +
+ field.onChange(e.target.checked)} + className="rounded border-gray-300" + /> + Map all files to container user/group +
+
+ + Keep the old behavior by forcing the SMB mount to present every file and directory as owned by the + container user and group instead of using server reported ownership. + + +
+ )} + /> { const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); diff --git a/app/server/modules/lifecycle/migrations/00006-map-smb-files-to-container-uid-gid.ts b/app/server/modules/lifecycle/migrations/00006-map-smb-files-to-container-uid-gid.ts new file mode 100644 index 00000000..cdb0d5b9 --- /dev/null +++ b/app/server/modules/lifecycle/migrations/00006-map-smb-files-to-container-uid-gid.ts @@ -0,0 +1,44 @@ +import { eq } from "drizzle-orm"; +import { logger } from "@zerobyte/core/node"; +import { db } from "../../../db/db"; +import { volumesTable } from "../../../db/schema"; +import { toMessage } from "~/server/utils/errors"; + +const execute = async () => { + const errors: Array<{ name: string; error: string }> = []; + const volumes = await db.query.volumesTable.findMany(); + let migratedCount = 0; + + for (const volume of volumes) { + if (volume.type !== "smb" || volume.config.backend !== "smb" || volume.config.mapToContainerUidGid !== undefined) { + continue; + } + + try { + await db + .update(volumesTable) + .set({ + config: { ...volume.config, mapToContainerUidGid: true }, + updatedAt: Date.now(), + }) + .where(eq(volumesTable.id, volume.id)); + + migratedCount += 1; + } catch (error) { + errors.push({ + name: `volume:${volume.id}`, + error: toMessage(error), + }); + } + } + + logger.info(`Migration 00006-map-smb-files-to-container-uid-gid updated ${migratedCount} SMB volumes.`); + + return { success: errors.length === 0, errors }; +}; + +export const v00006 = { + execute, + id: "00006-map-smb-files-to-container-uid-gid", + type: "maintenance" as const, +}; diff --git a/app/test/backend-integration/runner.ts b/app/test/backend-integration/runner.ts index d253a31f..aa608c2c 100644 --- a/app/test/backend-integration/runner.ts +++ b/app/test/backend-integration/runner.ts @@ -2,15 +2,15 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { Effect } from "effect"; +import type { VolumeBackend } from "../../../apps/agent/src/volume-host/types"; +import { makeDirectoryBackend } from "../../../apps/agent/src/volume-host/backends/directory"; +import { makeNfsBackend } from "../../../apps/agent/src/volume-host/backends/nfs"; +import { makeRcloneBackend } from "../../../apps/agent/src/volume-host/backends/rclone"; +import { makeSftpBackend } from "../../../apps/agent/src/volume-host/backends/sftp"; +import { makeSmbBackend } from "../../../apps/agent/src/volume-host/backends/smb"; +import { makeWebdavBackend } from "../../../apps/agent/src/volume-host/backends/webdav"; import { createRestic } from "@zerobyte/core/restic/server"; import { RCLONE_CONFIG_FILE } from "~/server/core/constants"; -import type { VolumeBackend } from "~/server/modules/backends/backend"; -import { makeDirectoryBackend } from "~/server/modules/backends/directory/directory-backend"; -import { makeNfsBackend } from "~/server/modules/backends/nfs/nfs-backend"; -import { makeRcloneBackend } from "~/server/modules/backends/rclone/rclone-backend"; -import { makeSftpBackend } from "~/server/modules/backends/sftp/sftp-backend"; -import { makeSmbBackend } from "~/server/modules/backends/smb/smb-backend"; -import { makeWebdavBackend } from "~/server/modules/backends/webdav/webdav-backend"; import { configSchema, type IntegrationScenario } from "./runner/config"; import { formatError, diff --git a/app/test/backend-integration/runner/config.ts b/app/test/backend-integration/runner/config.ts index 60bd3556..ef2a0c5d 100644 --- a/app/test/backend-integration/runner/config.ts +++ b/app/test/backend-integration/runner/config.ts @@ -5,7 +5,7 @@ import { repositoryConfigSchema, type RepositoryConfig, } from "@zerobyte/core/restic"; -import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes"; +import { volumeConfigSchema, type BackendConfig } from "@zerobyte/contracts/volumes"; const modeSchema = z .union([z.number(), z.string().transform((value) => Number.parseInt(value, 8))]) diff --git a/apps/agent/src/volume-host/backends/smb.ts b/apps/agent/src/volume-host/backends/smb.ts index 3d7ff5da..faf1018e 100644 --- a/apps/agent/src/volume-host/backends/smb.ts +++ b/apps/agent/src/volume-host/backends/smb.ts @@ -9,6 +9,19 @@ import { getMountForPath } from "../fs"; import type { VolumeBackend } from "../types"; import { assertMounted, executeMount, executeUnmount } from "./utils"; +const isUnsupportedAclMountOptionError = (message: string) => + /invalid argument|unknown mount option|unrecognized mount option|bad option/i.test(message); + +const toSmbMountError = (error: unknown, usingContainerMapping: boolean) => { + const message = toMessage(error); + + if (usingContainerMapping || !isUnsupportedAclMountOptionError(message)) { + return message; + } + + return `${message} Your host/kernel may not support cifsacl, idsfromsid, or modefromsid. Enable "Map all files to container user/group" to fall back to the old uid/gid mapping behavior.`; +}; + const checkHealth = async (mountPath: string) => { const run = async () => { await assertMounted(mountPath, (fstype) => fstype === "cifs"); @@ -79,8 +92,15 @@ const mount = async (config: BackendConfig, mountPath: string) => { const run = async () => { await fs.mkdir(mountPath, { recursive: true }); - const { uid, gid } = os.userInfo(); - const options = [`port=${config.port}`, `uid=${uid}`, `gid=${gid}`, "iocharset=utf8"]; + const usingContainerMapping = config.mapToContainerUidGid ?? true; + const options = [`port=${config.port}`, "iocharset=utf8"]; + + if (usingContainerMapping) { + const { uid, gid } = os.userInfo(); + options.push(`uid=${uid}`, `gid=${gid}`); + } else { + options.push("cifsacl", "idsfromsid", "modefromsid"); + } if (config.guest) { options.push("username=guest", "password="); @@ -102,8 +122,9 @@ const mount = async (config: BackendConfig, mountPath: string) => { try { await executeMount(args); } catch (error) { - logger.error(`SMB mount failed: ${toMessage(error)}`); - throw error; + const message = toSmbMountError(error, usingContainerMapping); + logger.error(`SMB mount failed: ${message}`); + throw new Error(message); } logger.info(`SMB volume at ${mountPath} mounted successfully.`); diff --git a/packages/contracts/src/volumes.ts b/packages/contracts/src/volumes.ts index 1dd69660..669d8c77 100644 --- a/packages/contracts/src/volumes.ts +++ b/packages/contracts/src/volumes.ts @@ -31,6 +31,7 @@ export const smbConfigSchema = z.object({ username: z.string().optional(), password: z.string().optional(), guest: z.boolean().optional(), + mapToContainerUidGid: z.boolean().default(false), vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"), domain: z.string().optional(), port: z