diff --git a/README.md b/README.md index 9e537ef..b19c332 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ # ironmount -mutagen sync create ~/Developer/dir/ironmount nicolas@192.168.2.42:/home/nicolas/ironmount - docker run --rm -it -v nicolas:/data alpine sh -lc 'echo hello > /data/hi && cat /data/hi' diff --git a/apps/client/app/components/create-volume-form.tsx b/apps/client/app/components/create-volume-form.tsx index f343ac1..7ab51a5 100644 --- a/apps/client/app/components/create-volume-form.tsx +++ b/apps/client/app/components/create-volume-form.tsx @@ -62,7 +62,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for const handleTestConnection = async () => { const formValues = getValues(); - if (formValues.backend === "nfs") { + if (formValues.backend === "nfs" || formValues.backend === "smb") { testBackendConnection.mutate({ body: { config: formValues }, }); @@ -107,6 +107,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for Directory NFS + SMB Choose the storage backend for this volume. @@ -186,6 +187,158 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for )} + {watchedBackend === "smb" && ( + <> + ( + + Server + + + + SMB server IP address or hostname. + + + )} + /> + ( + + Share + + + + SMB share name on the server. + + + )} + /> + ( + + Username + + + + Username for SMB authentication. + + + )} + /> + ( + + Password + + + + Password for SMB authentication. + + + )} + /> + ( + + SMB Version + + SMB protocol version to use (default: 3.0). + + + )} + /> + ( + + Domain (Optional) + + + + Domain or workgroup for authentication (optional). + + + )} + /> + ( + + Port + + field.onChange(parseInt(e.target.value, 10) || undefined)} + /> + + SMB server port (default: 445). + + + )} + /> + + )} + + {watchedBackend === "smb" && ( +
+
+ +
+ {testMessage && ( +
+ {testMessage} +
+ )} +
+ )} + {watchedBackend === "nfs" && (
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b98e299..5a0199f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -71,7 +71,7 @@ const socketPath = "/run/docker/plugins/ironmount.sock"; fetch: app.fetch, }); - await startup(); + startup(); logger.info(`Server is running at http://localhost:8080 and unix socket at ${socketPath}`); })(); diff --git a/apps/server/src/modules/backends/backend.ts b/apps/server/src/modules/backends/backend.ts index 9283d4c..4442ea5 100644 --- a/apps/server/src/modules/backends/backend.ts +++ b/apps/server/src/modules/backends/backend.ts @@ -1,8 +1,9 @@ import type { BackendStatus } from "@ironmount/schemas"; +import { VOLUME_MOUNT_BASE } from "../../core/constants"; import type { Volume } from "../../db/schema"; import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeNfsBackend } from "./nfs/nfs-backend"; -import { VOLUME_MOUNT_BASE } from "../../core/constants"; +import { makeSmbBackend } from "./smb/smb-backend"; type OperationResult = { error?: string; @@ -22,11 +23,11 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => { case "nfs": { return makeNfsBackend(volume.config, path); } + case "smb": { + return makeSmbBackend(volume.config, path); + } case "directory": { return makeDirectoryBackend(volume.config, path); } - default: { - throw new Error(`Backend ${volume.config.backend} not implemented`); - } } }; diff --git a/apps/server/src/modules/backends/nfs/nfs-backend.ts b/apps/server/src/modules/backends/nfs/nfs-backend.ts index 3d18669..ebfd66e 100644 --- a/apps/server/src/modules/backends/nfs/nfs-backend.ts +++ b/apps/server/src/modules/backends/nfs/nfs-backend.ts @@ -1,17 +1,13 @@ -import { execFile as execFileCb } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; -import * as npath from "node:path"; import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas"; import type { VolumeBackend } from "../backend"; import { logger } from "../../../utils/logger"; -import { promisify } from "node:util"; import { withTimeout } from "../../../utils/timeout"; import { OPERATION_TIMEOUT } from "../../../core/constants"; import { toMessage } from "../../../utils/errors"; import { getMountForPath } from "../../../utils/mountinfo"; - -const execFile = promisify(execFileCb); +import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils"; const mount = async (config: BackendConfig, path: string) => { logger.debug(`Mounting volume ${path}...`); @@ -44,14 +40,7 @@ const mount = async (config: BackendConfig, path: string) => { logger.debug(`Mounting volume ${path}...`); logger.info(`Executing mount: mount ${args.join(" ")}`); - const { stderr } = await execFile("mount", args, { - timeout: OPERATION_TIMEOUT, - maxBuffer: 1024 * 1024, - }); - - if (stderr?.trim()) { - logger.warn(stderr.trim()); - } + await executeMount(args); logger.info(`NFS volume at ${path} mounted successfully.`); return { status: BACKEND_STATUS.mounted }; @@ -59,11 +48,9 @@ const mount = async (config: BackendConfig, path: string) => { try { return await withTimeout(run(), OPERATION_TIMEOUT, "NFS mount"); - } catch (err: any) { - const msg = err.stderr?.toString().trim() || err.message; - - logger.error("Error mounting NFS volume", { error: msg }); - return { status: BACKEND_STATUS.error, error: msg }; + } catch (err) { + logger.error("Error mounting NFS volume", { error: toMessage(err) }); + return { status: BACKEND_STATUS.error, error: toMessage(err) }; } }; @@ -81,14 +68,7 @@ const unmount = async (path: string) => { return { status: BACKEND_STATUS.unmounted }; } - const { stderr } = await execFile("umount", ["-l", "-f", path], { - timeout: OPERATION_TIMEOUT, - maxBuffer: 1024 * 1024, - }); - - if (stderr?.trim()) { - logger.warn(stderr.trim()); - } + await executeUnmount(path); await fs.rmdir(path); @@ -101,7 +81,6 @@ const unmount = async (path: string) => { } catch (err: any) { const msg = err.stderr?.toString().trim() || err.message; logger.error("Error unmounting NFS volume", { path, error: msg }); - return { status: BACKEND_STATUS.error, error: msg }; } }; @@ -117,10 +96,7 @@ const checkHealth = async (path: string) => { throw new Error(`Path ${path} is not mounted as NFS.`); } - const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); - - await fs.writeFile(testFilePath, "healthcheck"); - await fs.unlink(testFilePath); + await createTestFile(path); logger.debug(`NFS volume at ${path} is healthy and mounted.`); return { status: BACKEND_STATUS.mounted }; diff --git a/apps/server/src/modules/backends/smb/smb-backend.ts b/apps/server/src/modules/backends/smb/smb-backend.ts new file mode 100644 index 0000000..7dbc7ce --- /dev/null +++ b/apps/server/src/modules/backends/smb/smb-backend.ts @@ -0,0 +1,128 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import { BACKEND_STATUS, type BackendConfig } from "@ironmount/schemas"; +import type { VolumeBackend } from "../backend"; +import { logger } from "../../../utils/logger"; +import { withTimeout } from "../../../utils/timeout"; +import { OPERATION_TIMEOUT } from "../../../core/constants"; +import { toMessage } from "../../../utils/errors"; +import { getMountForPath } from "../../../utils/mountinfo"; +import { createTestFile, executeMount, executeUnmount } from "../utils/backend-utils"; + +const mount = async (config: BackendConfig, path: string) => { + logger.debug(`Mounting SMB volume ${path}...`); + + if (config.backend !== "smb") { + logger.error("Provided config is not for SMB backend"); + return { status: BACKEND_STATUS.error, error: "Provided config is not for SMB backend" }; + } + + if (os.platform() !== "linux") { + logger.error("SMB mounting is only supported on Linux hosts."); + return { status: BACKEND_STATUS.error, error: "SMB mounting is only supported on Linux hosts." }; + } + + const { status } = await checkHealth(path); + if (status === "mounted") { + return { status: BACKEND_STATUS.mounted }; + } + + logger.debug(`Trying to unmount any existing mounts at ${path} before mounting...`); + await unmount(path); + + const run = async () => { + await fs.mkdir(path, { recursive: true }); + + const source = `//${config.server}/${config.share}`; + const options = [ + `user=${config.username}`, + `pass=${config.password}`, + `vers=${config.vers}`, + `port=${config.port}`, + "uid=1000", + "gid=1000", + ]; + + if (config.domain) { + options.push(`domain=${config.domain}`); + } + + const args = ["-t", "cifs", "-o", options.join(","), source, path]; + + logger.debug(`Mounting SMB volume ${path}...`); + logger.info(`Executing mount: mount ${args.join(" ")}`); + + await executeMount(args); + + logger.info(`SMB volume at ${path} mounted successfully.`); + return { status: BACKEND_STATUS.mounted }; + }; + + try { + return await withTimeout(run(), OPERATION_TIMEOUT, "SMB mount"); + } catch (error) { + logger.error("Error mounting SMB volume", { error: toMessage(error) }); + return { status: BACKEND_STATUS.error, error: toMessage(error) }; + } +}; + +const unmount = async (path: string) => { + if (os.platform() !== "linux") { + logger.error("SMB unmounting is only supported on Linux hosts."); + return { status: BACKEND_STATUS.error, error: "SMB unmounting is only supported on Linux hosts." }; + } + + const run = async () => { + try { + await fs.access(path); + } catch { + logger.warn(`Path ${path} does not exist. Skipping unmount.`); + return { status: BACKEND_STATUS.unmounted }; + } + + await executeUnmount(path); + + await fs.rmdir(path); + + logger.info(`SMB volume at ${path} unmounted successfully.`); + return { status: BACKEND_STATUS.unmounted }; + }; + + try { + return await withTimeout(run(), OPERATION_TIMEOUT, "SMB unmount"); + } catch (error) { + logger.error("Error unmounting SMB volume", { path, error: toMessage(error) }); + return { status: BACKEND_STATUS.error, error: toMessage(error) }; + } +}; + +const checkHealth = async (path: string) => { + const run = async () => { + logger.debug(`Checking health of SMB volume at ${path}...`); + await fs.access(path); + + const mount = await getMountForPath(path); + + if (!mount || mount.fstype !== "cifs") { + throw new Error(`Path ${path} is not mounted as CIFS/SMB.`); + } + + await createTestFile(path); + + logger.debug(`SMB volume at ${path} is healthy and mounted.`); + return { status: BACKEND_STATUS.mounted }; + }; + + try { + return await withTimeout(run(), OPERATION_TIMEOUT, "SMB health check"); + } catch (error) { + logger.error("SMB volume health check failed:", toMessage(error)); + return { status: BACKEND_STATUS.error, error: toMessage(error) }; + } +}; + +export const makeSmbBackend = (config: BackendConfig, path: string): VolumeBackend => ({ + mount: () => mount(config, path), + unmount: () => unmount(path), + checkHealth: () => checkHealth(path), +}); diff --git a/apps/server/src/modules/backends/utils/backend-utils.ts b/apps/server/src/modules/backends/utils/backend-utils.ts new file mode 100644 index 0000000..b96d643 --- /dev/null +++ b/apps/server/src/modules/backends/utils/backend-utils.ts @@ -0,0 +1,37 @@ +import * as npath from "node:path"; +import * as fs from "node:fs/promises"; +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; +import { OPERATION_TIMEOUT } from "../../../core/constants"; +import { logger } from "../../../utils/logger"; + +const execFile = promisify(execFileCb); + +export const executeMount = async (args: string[]): Promise => { + const { stderr } = await execFile("mount", args, { + timeout: OPERATION_TIMEOUT, + maxBuffer: 1024 * 1024, + }); + + if (stderr?.trim()) { + logger.warn(stderr.trim()); + } +}; + +export const executeUnmount = async (path: string): Promise => { + const { stderr } = await execFile("umount", ["-l", "-f", path], { + timeout: OPERATION_TIMEOUT, + maxBuffer: 1024 * 1024, + }); + + if (stderr?.trim()) { + logger.warn(stderr.trim()); + } +}; + +export const createTestFile = async (path: string): Promise => { + const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); + + await fs.writeFile(testFilePath, "healthcheck"); + await fs.unlink(testFilePath); +}; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 39ee174..12142aa 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -18,6 +18,13 @@ export const nfsConfigSchema = type({ export const smbConfigSchema = type({ backend: "'smb'", + server: "string", + share: "string", + username: "string", + password: "string", + vers: type("'1.0' | '2.0' | '2.1' | '3.0'").default("3.0"), + domain: "string?", + port: type("string.integer.parse").or(type("number")).to("1 <= number <= 65535").default(445), }); export const directoryConfigSchema = type({