mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: smb support
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
<SelectContent>
|
||||
<SelectItem value="directory">Directory</SelectItem>
|
||||
<SelectItem value="nfs">NFS</SelectItem>
|
||||
<SelectItem value="smb">SMB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Choose the storage backend for this volume.</FormDescription>
|
||||
@@ -186,6 +187,158 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "smb" && (
|
||||
<>
|
||||
<FormField
|
||||
name="server"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>SMB server IP address or hostname.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="share"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Share</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="myshare" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>SMB share name on the server.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="admin" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Username for SMB authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Password for SMB authentication.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="vers"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SMB Version</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value || "3.0"}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select SMB version" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="1.0">SMB v1.0</SelectItem>
|
||||
<SelectItem value="2.0">SMB v2.0</SelectItem>
|
||||
<SelectItem value="2.1">SMB v2.1</SelectItem>
|
||||
<SelectItem value="3.0">SMB v3.0</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>SMB protocol version to use (default: 3.0).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Domain (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="WORKGROUP" value={field.value ?? ""} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>Domain or workgroup for authentication (optional).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="445"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>SMB server port (default: 445).</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchedBackend === "smb" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={
|
||||
testStatus === "loading" ||
|
||||
!form.watch("server") ||
|
||||
!form.watch("share") ||
|
||||
!form.watch("username") ||
|
||||
!form.watch("password")
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{testStatus === "loading" && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{testStatus === "success" && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
|
||||
{testStatus === "error" && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
|
||||
{testStatus === "idle" && "Test Connection"}
|
||||
{testStatus === "loading" && "Testing..."}
|
||||
{testStatus === "success" && "Connection Successful"}
|
||||
{testStatus === "error" && "Test Failed"}
|
||||
</Button>
|
||||
</div>
|
||||
{testMessage && (
|
||||
<div
|
||||
className={`text-sm p-2 rounded-md ${
|
||||
testStatus === "success"
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: testStatus === "error"
|
||||
? "bg-red-50 text-red-700 border border-red-200"
|
||||
: "bg-gray-50 text-gray-700 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{testMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{watchedBackend === "nfs" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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}`);
|
||||
})();
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
128
apps/server/src/modules/backends/smb/smb-backend.ts
Normal file
128
apps/server/src/modules/backends/smb/smb-backend.ts
Normal file
@@ -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),
|
||||
});
|
||||
37
apps/server/src/modules/backends/utils/backend-utils.ts
Normal file
37
apps/server/src/modules/backends/utils/backend-utils.ts
Normal file
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
const testFilePath = npath.join(path, `.healthcheck-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||
|
||||
await fs.writeFile(testFilePath, "healthcheck");
|
||||
await fs.unlink(testFilePath);
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user