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 && \
|
RUN apk update --no-cache && \
|
||||||
apk upgrade --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", "--"]
|
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ type Props = {
|
||||||
const defaultValuesForType = {
|
const defaultValuesForType = {
|
||||||
directory: { backend: "directory" as const, path: "/" },
|
directory: { backend: "directory" as const, path: "/" },
|
||||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
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" },
|
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
|
||||||
rclone: { backend: "rclone" as const, path: "/" },
|
rclone: { backend: "rclone" as const, path: "/" },
|
||||||
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
|
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,32 @@ export const SMBForm = ({ form }: Props) => {
|
||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="readOnly"
|
name="readOnly"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { v00002 } from "./migrations/00002-isolate-restic-passwords";
|
||||||
import { v00003 } from "./migrations/00003-assign-organization";
|
import { v00003 } from "./migrations/00003-assign-organization";
|
||||||
import { v00004 } from "./migrations/00004-concat-path-name";
|
import { v00004 } from "./migrations/00004-concat-path-name";
|
||||||
import { v00005 } from "./migrations/00005-split-backup-include-paths";
|
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 { sql } from "drizzle-orm";
|
||||||
import { appMetadataTable, usersTable } from "../../db/schema";
|
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
|
|
@ -38,7 +39,7 @@ type MigrationEntity = {
|
||||||
dependsOn?: string[];
|
dependsOn?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005];
|
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005, v00006];
|
||||||
|
|
||||||
export const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
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 fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Effect } from "effect";
|
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 { createRestic } from "@zerobyte/core/restic/server";
|
||||||
import { RCLONE_CONFIG_FILE } from "~/server/core/constants";
|
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 { configSchema, type IntegrationScenario } from "./runner/config";
|
||||||
import {
|
import {
|
||||||
formatError,
|
formatError,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
repositoryConfigSchema,
|
repositoryConfigSchema,
|
||||||
type RepositoryConfig,
|
type RepositoryConfig,
|
||||||
} from "@zerobyte/core/restic";
|
} from "@zerobyte/core/restic";
|
||||||
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
import { volumeConfigSchema, type BackendConfig } from "@zerobyte/contracts/volumes";
|
||||||
|
|
||||||
const modeSchema = z
|
const modeSchema = z
|
||||||
.union([z.number(), z.string().transform((value) => Number.parseInt(value, 8))])
|
.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 type { VolumeBackend } from "../types";
|
||||||
import { assertMounted, executeMount, executeUnmount } from "./utils";
|
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 checkHealth = async (mountPath: string) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
await assertMounted(mountPath, (fstype) => fstype === "cifs");
|
await assertMounted(mountPath, (fstype) => fstype === "cifs");
|
||||||
|
|
@ -79,8 +92,15 @@ const mount = async (config: BackendConfig, mountPath: string) => {
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
await fs.mkdir(mountPath, { recursive: true });
|
await fs.mkdir(mountPath, { recursive: true });
|
||||||
const { uid, gid } = os.userInfo();
|
const usingContainerMapping = config.mapToContainerUidGid ?? true;
|
||||||
const options = [`port=${config.port}`, `uid=${uid}`, `gid=${gid}`, "iocharset=utf8"];
|
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) {
|
if (config.guest) {
|
||||||
options.push("username=guest", "password=");
|
options.push("username=guest", "password=");
|
||||||
|
|
@ -102,8 +122,9 @@ const mount = async (config: BackendConfig, mountPath: string) => {
|
||||||
try {
|
try {
|
||||||
await executeMount(args);
|
await executeMount(args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`SMB mount failed: ${toMessage(error)}`);
|
const message = toSmbMountError(error, usingContainerMapping);
|
||||||
throw error;
|
logger.error(`SMB mount failed: ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`SMB volume at ${mountPath} mounted successfully.`);
|
logger.info(`SMB volume at ${mountPath} mounted successfully.`);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export const smbConfigSchema = z.object({
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
guest: z.boolean().optional(),
|
guest: z.boolean().optional(),
|
||||||
|
mapToContainerUidGid: z.boolean().default(false),
|
||||||
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
||||||
domain: z.string().optional(),
|
domain: z.string().optional(),
|
||||||
port: z
|
port: z
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue