import { Effect, Context, Layer } from "effect"; import { execSync } from "child_process"; /** * Linux User Management Service * * Handles creation, management, and deletion of Linux system users * for multi-user isolation in MyAIDev Method web server. * * Security Features: * - No sudo/root access granted to created users * - Limited shell access (rbash or nologin) * - Home directory isolation * - Resource limits via ulimit * - User groups for permission management */ export interface LinuxUser { username: string; uid: number; gid: number; homeDir: string; shell: string; created: boolean; } export interface CreateLinuxUserOptions { username: string; shell?: "/bin/rbash" | "/usr/sbin/nologin" | "/bin/bash"; createHome?: boolean; groups?: string[]; } export interface LinuxUserError { readonly _tag: "LinuxUserError"; readonly message: string; readonly cause?: unknown; } const LinuxUserError = (message: string, cause?: unknown): LinuxUserError => ({ _tag: "LinuxUserError", message, cause, }); export class LinuxUserService extends Context.Tag("LinuxUserService")< LinuxUserService, { /** * Create a new Linux system user */ readonly createUser: ( options: CreateLinuxUserOptions ) => Effect.Effect; /** * Check if a Linux user exists */ readonly userExists: ( username: string ) => Effect.Effect; /** * Get Linux user information */ readonly getUserInfo: ( username: string ) => Effect.Effect; /** * Delete a Linux system user */ readonly deleteUser: ( username: string, removeHome?: boolean ) => Effect.Effect; /** * Set resource limits for a user */ readonly setResourceLimits: ( username: string, limits: ResourceLimits ) => Effect.Effect; /** * Sanitize username for Linux system use * Converts email-based usernames to valid Linux usernames */ readonly sanitizeUsername: ( username: string ) => Effect.Effect; } >() { static Live = Layer.succeed(this, { createUser: (options: CreateLinuxUserOptions) => Effect.gen(function* () { const { username, shell = "/bin/rbash", createHome = true, groups = [] } = options; // Validate username format (Linux username requirements) if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(username)) { return yield* Effect.fail( LinuxUserError( `Invalid Linux username format: ${username}. Must start with lowercase letter or underscore, contain only lowercase letters, digits, underscores, and hyphens, and be 1-32 characters long.` ) ); } // Check if user already exists const exists = yield* Effect.tryPromise({ try: async () => { try { execSync(`id -u ${username}`, { stdio: "ignore" }); return true; } catch { return false; } }, catch: (error) => LinuxUserError("Failed to check if user exists", error), }); if (exists) { return yield* Effect.fail( LinuxUserError(`Linux user ${username} already exists`) ); } // Create user command const createHomeFlag = createHome ? "--create-home" : "--no-create-home"; const groupsFlag = groups.length > 0 ? `--groups ${groups.join(",")}` : ""; const command = `sudo useradd ${createHomeFlag} --shell ${shell} ${groupsFlag} ${username}`; yield* Effect.tryPromise({ try: async () => { execSync(command, { stdio: "pipe" }); }, catch: (error) => LinuxUserError(`Failed to create Linux user ${username}`, error), }); // Get user info const userInfo = yield* Effect.tryPromise({ try: async () => { const uidOutput = execSync(`id -u ${username}`, { encoding: "utf-8", }).trim(); const gidOutput = execSync(`id -g ${username}`, { encoding: "utf-8", }).trim(); const homeDir = createHome ? `/home/${username}` : ""; return { username, uid: parseInt(uidOutput, 10), gid: parseInt(gidOutput, 10), homeDir, shell, created: true, }; }, catch: (error) => LinuxUserError(`Failed to get user info for ${username}`, error), }); return userInfo; }), userExists: (username: string) => Effect.tryPromise({ try: async () => { try { execSync(`id -u ${username}`, { stdio: "ignore" }); return true; } catch { return false; } }, catch: (error) => LinuxUserError("Failed to check if user exists", error), }), getUserInfo: (username: string) => Effect.gen(function* () { const exists = yield* Effect.tryPromise({ try: async () => { try { execSync(`id -u ${username}`, { stdio: "ignore" }); return true; } catch { return false; } }, catch: (error) => LinuxUserError("Failed to check if user exists", error), }); if (!exists) { return yield* Effect.fail( LinuxUserError(`Linux user ${username} does not exist`) ); } const userInfo = yield* Effect.tryPromise({ try: async () => { const uidOutput = execSync(`id -u ${username}`, { encoding: "utf-8", }).trim(); const gidOutput = execSync(`id -g ${username}`, { encoding: "utf-8", }).trim(); // Get shell from /etc/passwd const passwdLine = execSync(`getent passwd ${username}`, { encoding: "utf-8", }).trim(); const shell = passwdLine.split(":")[6] || "/bin/bash"; // Get home directory const homeDir = passwdLine.split(":")[5] || `/home/${username}`; return { username, uid: parseInt(uidOutput, 10), gid: parseInt(gidOutput, 10), homeDir, shell, created: true, }; }, catch: (error) => LinuxUserError(`Failed to get user info for ${username}`, error), }); return userInfo; }), deleteUser: (username: string, removeHome: boolean = true) => Effect.gen(function* () { // Check if user exists const exists = yield* Effect.tryPromise({ try: async () => { try { execSync(`id -u ${username}`, { stdio: "ignore" }); return true; } catch { return false; } }, catch: (error) => LinuxUserError("Failed to check if user exists", error), }); if (!exists) { return yield* Effect.fail( LinuxUserError(`Linux user ${username} does not exist`) ); } // Delete user const removeHomeFlag = removeHome ? "--remove" : ""; const command = `sudo userdel ${removeHomeFlag} ${username}`; yield* Effect.tryPromise({ try: async () => { execSync(command, { stdio: "pipe" }); }, catch: (error) => LinuxUserError(`Failed to delete Linux user ${username}`, error), }); }), setResourceLimits: (username: string, limits: ResourceLimits) => Effect.gen(function* () { // Create limits configuration file const limitsContent = ` # Resource limits for ${username} ${username} soft nofile ${limits.maxOpenFiles || 1024} ${username} hard nofile ${limits.maxOpenFiles || 2048} ${username} soft nproc ${limits.maxProcesses || 256} ${username} hard nproc ${limits.maxProcesses || 512} ${username} soft as ${limits.maxMemoryKB || 2097152} ${username} hard as ${limits.maxMemoryKB || 4194304} ${username} soft cpu ${limits.maxCPUTime || 60} ${username} hard cpu ${limits.maxCPUTime || 120} `.trim(); const limitsFilePath = `/etc/security/limits.d/${username}.conf`; yield* Effect.tryPromise({ try: async () => { // Write limits file (requires sudo) execSync(`echo '${limitsContent}' | sudo tee ${limitsFilePath} > /dev/null`, { stdio: "pipe", }); }, catch: (error) => LinuxUserError( `Failed to set resource limits for ${username}`, error ), }); }), sanitizeUsername: (username: string) => Effect.gen(function* () { // Convert to lowercase let sanitized = username.toLowerCase(); // Replace invalid characters with underscores sanitized = sanitized.replace(/[^a-z0-9_-]/g, "_"); // Ensure starts with letter or underscore if (!/^[a-z_]/.test(sanitized)) { sanitized = `u_${sanitized}`; } // Truncate to 32 characters sanitized = sanitized.substring(0, 32); // Remove trailing hyphens or underscores sanitized = sanitized.replace(/[-_]+$/, ""); if (sanitized.length === 0) { return yield* Effect.fail( LinuxUserError( `Could not sanitize username: ${username} results in empty string` ) ); } return sanitized; }), }); } export interface ResourceLimits { maxOpenFiles?: number; maxProcesses?: number; maxMemoryKB?: number; maxCPUTime?: number; }