feat: smb expose real ACLs when available

This commit is contained in:
Nicolas Meienberger 2026-04-19 12:16:34 +02:00
parent 60464bc21a
commit 2cf95685f7
No known key found for this signature in database
9 changed files with 108 additions and 15 deletions

View file

@ -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", "--"]

View file

@ -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 },

View file

@ -168,6 +168,32 @@ export const SMBForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="mapToContainerUidGid"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Ownership Mapping</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Map all files to container user/group</span>
</div>
</FormControl>
<FormDescription>
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.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"

View file

@ -4,6 +4,7 @@ import { v00002 } from "./migrations/00002-isolate-restic-passwords";
import { v00003 } from "./migrations/00003-assign-organization";
import { v00004 } from "./migrations/00004-concat-path-name";
import { v00005 } from "./migrations/00005-split-backup-include-paths";
import { v00006 } from "./migrations/00006-map-smb-files-to-container-uid-gid";
import { sql } from "drizzle-orm";
import { appMetadataTable, usersTable } from "../../db/schema";
import { db } from "../../db/db";
@ -38,7 +39,7 @@ type MigrationEntity = {
dependsOn?: string[];
};
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005];
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005, v00006];
export const runMigrations = async () => {
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);

View file

@ -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,
};

View file

@ -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,

View file

@ -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))])

View file

@ -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.`);

View file

@ -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