feat: smb expose real ACLs when available
This commit is contained in:
parent
60464bc21a
commit
2cf95685f7
9 changed files with 108 additions and 15 deletions
|
|
@ -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", "--"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))])
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue