import "server-only"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import { lstat, mkdir, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { serverEnv } from "@/lib/server/env"; import { logServerAction } from "@/lib/server/logging/logger"; import { FilesPathError, resolvePathWithinFilesRoot, } from "@/lib/server/modules/files/path-resolver"; import { deleteNetworkShareFromDb, getNetworkShareByMountPathFromDb, getNetworkShareFromDb, insertNetworkShareInDb, listNetworkSharesFromDb, touchNetworkShareInDb, type NetworkShareRecord, } from "@/lib/server/modules/files/network-shares-repository"; import { decryptSecret, encryptSecret } from "@/lib/server/modules/files/secrets"; import type { CreateNetworkShareRequest, DiscoverServersResponse, DiscoverSharesResponse, FileServiceErrorCode, NetworkShare, NetworkShareStatus, } from "@/lib/shared/contracts/files"; const execFileAsync = promisify(execFile); const WATCH_INTERVAL_MS = 60_000; let watcherTimer: NodeJS.Timeout | null = null; let watcherRunning = false; let watcherTickInFlight = false; export class NetworkStorageError extends Error { readonly code: FileServiceErrorCode; readonly statusCode: number; constructor( message: string, options?: { code?: FileServiceErrorCode; statusCode?: number; cause?: unknown; }, ) { super(message, { cause: options?.cause, }); this.name = "NetworkStorageError"; this.code = options?.code ?? "internal_error"; this.statusCode = options?.statusCode ?? 500; } } type ResolvedMountPath = { rootPath: string; relativePath: string; absolutePath: string; }; function sanitizeSegment(input: string, fallback: string) { const sanitized = input .trim() .replace(/[^a-zA-Z0-9\-\.\' \(\)_]/g, "") .replace(/\s+/g, " ") .trim(); return sanitized.length > 0 ? sanitized : fallback; } async function resolveMountPath(mountPath: string): Promise { try { const resolved = await resolvePathWithinFilesRoot({ inputPath: mountPath, requiredPrefix: "Network", allowHiddenSegments: false, allowMissingLeaf: true, }); return { rootPath: resolved.rootPath, relativePath: resolved.relativePath, absolutePath: resolved.absolutePath, }; } catch (error) { if (error instanceof FilesPathError) { throw new NetworkStorageError(error.message, { code: error.code, statusCode: error.statusCode, cause: error, }); } throw error; } } function mountPathForShare(host: string, share: string) { const hostSegment = sanitizeSegment(host, "host"); const shareSegment = sanitizeSegment(share, "share"); return path.posix.join("Network", hostSegment, shareSegment); } function resolveMountIdentity() { const processWithIds = process as NodeJS.Process & { geteuid?: () => number; getegid?: () => number; }; const uid = serverEnv.FILES_NETWORK_MOUNT_UID ?? (typeof processWithIds.geteuid === "function" ? processWithIds.geteuid() : 0); const gid = serverEnv.FILES_NETWORK_MOUNT_GID ?? (typeof processWithIds.getegid === "function" ? processWithIds.getegid() : 0); return { uid, gid, }; } function mapCommandError( error: unknown, fallbackMessage: string, code: FileServiceErrorCode, ) { if (error instanceof NetworkStorageError) return error; return new NetworkStorageError(fallbackMessage, { code, statusCode: code === "share_not_found" ? 404 : 500, cause: error, }); } function isCommandUnavailable(error: unknown) { if (!error || typeof error !== "object") return false; const err = error as { code?: string; message?: string; stderr?: string; }; if (err.code === "ENOENT") return true; const combined = `${err.message ?? ""}\n${err.stderr ?? ""}`.toLowerCase(); return ( combined.includes("not found") || combined.includes("no such file or directory") ); } function errorText(error: unknown) { if (!error || typeof error !== "object") return ""; const err = error as { message?: string; stderr?: string | Buffer; }; const stderr = typeof err.stderr === "string" ? err.stderr : Buffer.isBuffer(err.stderr) ? err.stderr.toString("utf8") : ""; return `${err.message ?? ""}\n${stderr}`.toLowerCase(); } function isUnmountMissingError(error: unknown) { const text = errorText(error); return ( text.includes("not mounted") || text.includes("is not mounted") || text.includes("not found") ); } async function runCommand(command: string, args: string[]) { const result = await execFileAsync(command, args, { timeout: 15_000, maxBuffer: 1024 * 1024, }); if (typeof result === "string" || Buffer.isBuffer(result)) { return { stdout: result, stderr: "", }; } if (result && typeof result === "object" && "stdout" in result) { const castResult = result as { stdout?: string | Buffer; stderr?: string | Buffer }; return { stdout: castResult.stdout ?? "", stderr: castResult.stderr ?? "", }; } return { stdout: "", stderr: "", }; } async function isMounted(mountPath: string) { const resolved = await resolveMountPath(mountPath); try { await runCommand("mountpoint", ["-q", resolved.absolutePath]); return true; } catch { return false; } } async function isShareActive(record: NetworkShareRecord) { try { return await isMounted(record.mountPath); } catch (error) { const mappedError = error instanceof NetworkStorageError ? error : mapCommandError(error, "Failed to resolve network share state", "internal_error"); if ( mappedError.code === "not_found" || mappedError.code === "invalid_path" || mappedError.code === "path_outside_root" ) { return false; } logServerAction({ level: "warn", layer: "service", action: "files.network.shares.remove.state", status: "error", message: "Unable to verify whether network share is still mounted; continuing cleanup", meta: { shareId: record.id, mountPath: record.mountPath, }, error, }); return false; } } function toPublicShare(record: NetworkShareRecord): NetworkShare { return { id: record.id, host: record.host, share: record.share, username: record.username, mountPath: record.mountPath, }; } function toShareStatus(record: NetworkShareRecord, mounted: boolean): NetworkShareStatus { return { ...toPublicShare(record), isMounted: mounted, }; } async function mountShareRecord(record: NetworkShareRecord) { const resolved = await resolveMountPath(record.mountPath); await mkdir(resolved.absolutePath, { recursive: true, }); const alreadyMounted = await isMounted(record.mountPath); if (alreadyMounted) { return; } const password = decryptSecret({ ciphertext: record.passwordCiphertext, iv: record.passwordIv, tag: record.passwordTag, }); const smbPath = `//${record.host}/${record.share}`; const identity = resolveMountIdentity(); // Write credentials to a temporary file (mode 600) instead of embedding // them in the mount command line. On Linux, /proc/PID/cmdline is readable // by all local users, so passing password= as a CLI arg leaks the secret. const credsPath = `/tmp/homeio-mount-${randomUUID()}.creds`; try { await writeFile( credsPath, `username=${record.username}\npassword=${password}\n`, { mode: 0o600 }, ); const mountOptions = [ `credentials=${credsPath}`, `uid=${identity.uid}`, `gid=${identity.gid}`, "iocharset=utf8", ].join(","); await runCommand("mount", [ "-t", "cifs", smbPath, resolved.absolutePath, "-o", mountOptions, ]); } finally { // Always remove the credentials file — whether mount succeeded or failed. await rm(credsPath, { force: true }).catch(() => undefined); } } async function unmountShareRecord( record: NetworkShareRecord, options?: { tolerateMissing?: boolean; }, ) { const tolerateMissing = options?.tolerateMissing ?? false; let resolved: ResolvedMountPath; try { resolved = await resolveMountPath(record.mountPath); } catch (error) { const mappedError = error instanceof NetworkStorageError ? error : mapCommandError(error, "Failed to unmount network share", "unmount_failed"); if ( tolerateMissing && (mappedError.code === "not_found" || mappedError.code === "invalid_path" || mappedError.code === "path_outside_root") ) { return; } throw mappedError; } const mounted = await isMounted(record.mountPath); if (mounted) { try { await runCommand("umount", [resolved.absolutePath]); } catch (error) { if (!isUnmountMissingError(error)) { throw error; } } } try { await rm(resolved.absolutePath, { recursive: true, force: true, }); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if ( !tolerateMissing || (nodeError?.code !== "ENOENT" && nodeError?.code !== "ENOTDIR") ) { throw error; } } const hostDirectory = path.dirname(resolved.absolutePath); try { const entries = await readdir(hostDirectory); if (entries.length === 0) { await rm(hostDirectory, { recursive: false, force: true, }); } } catch { // ignored } } async function runWatcherTick() { if (watcherTickInFlight) return; watcherTickInFlight = true; try { const shares = await listNetworkSharesFromDb(); await Promise.all( shares.map(async (share) => { try { const mounted = await isMounted(share.mountPath); if (!mounted) { await mountShareRecord(share); await touchNetworkShareInDb(share.id); } } catch (error) { logServerAction({ level: "warn", layer: "service", action: "files.network.watch.mount", status: "error", message: "Failed to mount network share during watcher tick", meta: { shareId: share.id, mountPath: share.mountPath, }, error, }); } }), ); } finally { watcherTickInFlight = false; } } export function startNetworkStorageWatcher() { if (watcherRunning) return; watcherRunning = true; watcherTimer = setInterval(() => { void runWatcherTick(); }, WATCH_INTERVAL_MS); watcherTimer.unref?.(); void runWatcherTick(); } export async function stopNetworkStorageWatcher() { watcherRunning = false; if (watcherTimer) { clearInterval(watcherTimer); watcherTimer = null; } } export async function getShareInfo(): Promise { const shares = await listNetworkSharesFromDb(); const statuses = await Promise.all( shares.map(async (share) => { let mounted = false; try { mounted = await isMounted(share.mountPath); } catch (error) { logServerAction({ level: "warn", layer: "service", action: "files.network.shares.get.status", status: "error", message: "Failed to resolve network share mount status", meta: { shareId: share.id, mountPath: share.mountPath, }, error, }); } return toShareStatus(share, mounted); }), ); return statuses; } export async function addShare(input: CreateNetworkShareRequest) { const host = input.host.trim(); const share = input.share.trim(); const username = input.username.trim(); const password = input.password; if (!host || !share || !username || !password) { throw new NetworkStorageError("Invalid share payload", { code: "invalid_path", statusCode: 400, }); } const mountPath = mountPathForShare(host, share); const existing = await getNetworkShareByMountPathFromDb(mountPath); if (existing) { throw new NetworkStorageError("Network share already exists", { code: "share_exists", statusCode: 409, }); } const encrypted = encryptSecret(password); const created = await insertNetworkShareInDb({ id: randomUUID(), host, share, username, mountPath, passwordCiphertext: encrypted.ciphertext, passwordIv: encrypted.iv, passwordTag: encrypted.tag, }); try { await mountShareRecord(created); await touchNetworkShareInDb(created.id); return { ...toPublicShare(created), isMounted: true, } satisfies NetworkShareStatus; } catch (error) { try { await deleteNetworkShareFromDb(created.id); } catch (rollbackError) { throw new NetworkStorageError( "Failed to mount network share and rollback reservation", { code: "internal_error", statusCode: 500, cause: { error, rollbackError, }, }, ); } throw mapCommandError(error, "Failed to mount network share", "mount_failed"); } } export async function removeShare(shareId: string) { const share = await getNetworkShareFromDb(shareId); if (!share) { throw new NetworkStorageError("Network share not found", { code: "share_not_found", statusCode: 404, }); } try { await unmountShareRecord(share, { tolerateMissing: true, }); } catch (error) { const mappedError = mapCommandError( error, "Failed to unmount network share", "unmount_failed", ); const tolerateCleanupError = mappedError.code === "not_found" || mappedError.code === "invalid_path" || mappedError.code === "path_outside_root"; if (!tolerateCleanupError) { const stillMounted = await isShareActive(share); if (stillMounted) { throw mappedError; } } logServerAction({ level: "warn", layer: "service", action: "files.network.shares.remove.cleanup", status: "error", message: "Network share cleanup was already complete; removing stale DB record", meta: { shareId: share.id, mountPath: share.mountPath, }, error, }); } await deleteNetworkShareFromDb(share.id); return { removed: true, id: share.id, }; } export async function mountShare(shareId: string) { const share = await getNetworkShareFromDb(shareId); if (!share) { throw new NetworkStorageError("Network share not found", { code: "share_not_found", statusCode: 404, }); } try { await mountShareRecord(share); const updated = (await touchNetworkShareInDb(share.id)) ?? share; return toShareStatus(updated, true); } catch (error) { throw mapCommandError(error, "Failed to mount network share", "mount_failed"); } } export async function unmountShare(shareId: string) { const share = await getNetworkShareFromDb(shareId); if (!share) { throw new NetworkStorageError("Network share not found", { code: "share_not_found", statusCode: 404, }); } try { await unmountShareRecord(share); const updated = (await touchNetworkShareInDb(share.id)) ?? share; return toShareStatus(updated, false); } catch (error) { throw mapCommandError(error, "Failed to unmount network share", "unmount_failed"); } } export async function discoverServers(): Promise { try { const { stdout } = await runCommand("avahi-browse", [ "--resolve", "--terminate", "_smb._tcp", "--parsable", ]); const output = typeof stdout === "string" ? stdout : Buffer.isBuffer(stdout) ? stdout.toString("utf8") : ""; const servers = output .split("\n") .map((line) => line.split(";")[6]?.trim() ?? "") .filter((value) => value.length > 0); return { servers: Array.from(new Set(servers)).sort((a, b) => a.localeCompare(b), ), }; } catch (error) { if (isCommandUnavailable(error)) { logServerAction({ level: "warn", layer: "service", action: "files.network.discover.servers.unavailable", status: "error", message: "SMB server discovery command is unavailable", error, }); return { servers: [] }; } throw mapCommandError(error, "Failed to discover SMB servers", "internal_error"); } } export async function discoverShares(input: { host: string; username: string; password: string; }): Promise { const host = input.host.trim(); const username = input.username.trim(); const password = input.password; if (!host || !username || !password) { throw new NetworkStorageError("Invalid share discovery payload", { code: "invalid_path", statusCode: 400, }); } try { const { stdout } = await runCommand("smbclient", [ "--list", `//${host}`, "--user", username, "--password", password, "--grepable", ]); const output = typeof stdout === "string" ? stdout : Buffer.isBuffer(stdout) ? stdout.toString("utf8") : ""; const shares = output .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .filter((line) => line.split("|").length === 3) .map((line) => line.split("|")[1]?.trim() ?? "") .filter((shareName) => shareName.length > 0 && shareName !== "IPC$"); return { shares: Array.from(new Set(shares)).sort((a, b) => a.localeCompare(b), ), }; } catch (error) { if (isCommandUnavailable(error)) { logServerAction({ level: "warn", layer: "service", action: "files.network.discover.shares.unavailable", status: "error", message: "SMB share discovery command is unavailable", meta: { host }, error, }); return { shares: [] }; } throw mapCommandError( error, "Failed to discover SMB shares for host", "internal_error", ); } } export async function isShareMounted(shareId: string) { const share = await getNetworkShareFromDb(shareId); if (!share) { throw new NetworkStorageError("Network share not found", { code: "share_not_found", statusCode: 404, }); } const mounted = await isMounted(share.mountPath); return toShareStatus(share, mounted); } export async function assertMountPathIsSafe(mountPath: string) { const resolved = await resolveMountPath(mountPath); const info = await lstat(resolved.absolutePath).catch(() => null); if (info?.isSymbolicLink()) { throw new NetworkStorageError("Symlinks are not allowed", { code: "symlink_blocked", statusCode: 403, }); } return resolved; }