UNPKG

@bobfrankston/mountsmb

Version:

Cross-platform SMB mounting solution for Windows, WSL, and Linux

1,513 lines (1,323 loc) β€’ 79.1 kB
#!/usr/bin/env node /** * mountsmb - Cross-platform secure SMB mounting via SSH tunnels * Works in both Windows (cmd/PowerShell) and WSL environments * Provides automated Samba installation and secure mounting at C:\drives\smb\ * * DEVELOPMENT WORKFLOW: * 1. During development - accumulate changes: * releaseapp.ps1 -AddCommit "Fix junction creation bug" * releaseapp.ps1 -AddCommit "Add better error handling" * * 2. When ready to release: * releaseapp.ps1 # Patch release using commit.txt * releaseapp.ps1 -Minor # Minor release using commit.txt * releaseapp.ps1 -Major -Notes # Major release with additional notes * * 3. For immediate release without commit.txt: * releaseapp.ps1 -Commit "Quick bug fix" * * The commit.txt file is automatically cleared after successful release. * Use 'releaseapp.ps1 -Help' for complete usage information. */ import { execSync, spawn, ChildProcess, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as fp from 'fs/promises'; import { homedir, platform } from 'os'; import * as path from 'path'; import * as net from 'net'; import { createRequire } from 'module'; // Version info - not needed for core functionality // Detect if we're running in WSL const isWSL = (): boolean => { if (platform() !== 'linux') return false; try { return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); } catch { return false; } }; // Platform detection - simplified for Windows machines export const win32 = platform() === 'win32'; const IS_WINDOWS = win32; const IS_WSL = !win32; // If not Windows, it's WSL on Windows machines const IS_LINUX = false; // Not used on Windows machines // Debug message control let dbgMessages = false; export function setDbgMessages(enabled: boolean) { dbgMessages = enabled; } export function getDbgMessages(): boolean { return dbgMessages; } // Debug logging function function dbgLog(message: string) { if (dbgMessages) { console.log(`${getTimestamp()} DEBUG: ${message}`); } } /** * Remove outer quotes from a string if present * @param s - Array of string tokens to join and dequote * @returns String with outer quotes removed */ const dequote = (s: string[]): string => (s.slice(1).join(' ')).replace(/^(['"])(.*)\1$/, '$2'); /** * Hostname mapping configuration interface */ export interface HostnameMap { /** Mapping from short name to full hostname */ [shortName: string]: string; } /** * Host resolution result with both short and full names */ export interface HostResolution { /** Short name used for mount points and display */ shortName: string; /** Full hostname used for actual connections */ fullHostname: string; } // Default hostname mapping - empty, populated from config files const DEFAULT_HOSTNAME_MAP: HostnameMap = { // All mappings come from SSH config or JSON config files // Nothing hardcoded here }; /** * Get SSH config paths in priority order (Windows primary, WSL secondary) */ function getSSHConfigPaths(): string[] { const paths: string[] = []; if (IS_WSL) { // WSL: Windows config is primary, WSL config is secondary const windowsConfig = '/mnt/c/Users/' + (process.env.USER || 'Bob') + '/.ssh/config'; const wslConfig = path.join(homedir(), '.ssh', 'config'); paths.push(windowsConfig, wslConfig); } else if (IS_WINDOWS) { // Windows: Only Windows config paths.push(path.join(process.env.USERPROFILE || homedir(), '.ssh', 'config')); } else { // Linux: Only Linux config paths.push(path.join(homedir(), '.ssh', 'config')); } return paths.filter(path => fs.existsSync(path)); } /** * Parse SSH config files to build hostname mappings with validation * @returns Hostname mapping from SSH config (Host -> HostName) */ function parseSSHConfigMappings(): HostnameMap { const mappings: HostnameMap = { ...DEFAULT_HOSTNAME_MAP }; const configPaths = getSSHConfigPaths(); const localDomains = ['frankston.com', 'aaz.lt']; const seenHosts = new Set<string>(); const warnings: string[] = []; for (const configPath of configPaths) { try { const config = fs.readFileSync(configPath, 'utf8'); const lines = config.split('\n'); let currentHost: string | null = null; let lineNumber = 0; for (const line of lines) { lineNumber++; const trimmed = line.trim(); // Skip comment lines and empty lines if (trimmed.startsWith('#') || trimmed === '') { continue; } // Split line into tokens for processing const tokens = trimmed.split(/\s+/); if (tokens.length < 2) { continue; } const keyword = tokens[0].toLowerCase(); // Match Host entries (but not HostName) if (keyword === 'host' && tokens.length >= 2) { const hostValue = dequote(tokens); // Skip wildcards and complex patterns if (hostValue.includes('*') || hostValue.includes('?')) { currentHost = null; continue; } // Check for duplicate host entries if (seenHosts.has(hostValue)) { warnings.push(`πŸ”΄ DUPLICATE: Host "${hostValue}" appears multiple times in SSH config (line ${lineNumber})`); } else { seenHosts.add(hostValue); } // Check if Host entry uses FQDN (should use short name) if (hostValue.includes('.') && localDomains.some(domain => hostValue.endsWith(domain))) { const shortName = hostValue.split('.')[0]; warnings.push(`πŸ”΄ SSH CONFIG ERROR: Host entry "${hostValue}" should use short name "${shortName}" (line ${lineNumber})`); warnings.push(` Fix: Change "Host ${hostValue}" to "Host ${shortName}"`); } currentHost = hostValue; continue; } // Match HostName entries if (keyword === 'hostname' && tokens.length >= 2 && currentHost) { const hostname = dequote(tokens); mappings[currentHost] = hostname; } } } catch (err) { warn(`Could not parse SSH config at ${configPath}`); } } // Show all warnings if (warnings.length > 0) { errorAlways('\nπŸ”΄ SSH Configuration Issues Detected:'); warnings.forEach(warning => errorAlways(warning)); errorAlways('\n⚠️ These issues may cause SMB mounting problems.'); errorAlways(' Host entries should use short names, HostName should use FQDNs.'); errorAlways(' Example: Host mail1 β†’ HostName mail1.frankston.com\n'); } return mappings; } /** * Load hostname mapping from SSH config with JSON fallback * @returns Hostname mapping configuration */ export function loadHostnameConfig(): HostnameMap { // Primary: Generate from SSH config const sshMappings = parseSSHConfigMappings(); // Secondary: Load from JSON config files (for hosts not in SSH config) const jsonConfigPaths = [ path.join(homedir(), '.mountsmb', 'hostnames.json'), path.join(homedir(), '.config', 'mountsmb', 'hostnames.json'), IS_WSL ? '/mnt/c/Users/' + (process.env.USER || 'Bob') + '/.mountsmb/hostnames.json' : null ].filter(Boolean) as string[]; for (const configPath of jsonConfigPaths) { if (fs.existsSync(configPath)) { try { const jsonConfig = JSON.parse(fs.readFileSync(configPath, 'utf8')); // JSON config only adds entries not already in SSH config for (const [shortName, hostname] of Object.entries(jsonConfig)) { if (!sshMappings[shortName]) { sshMappings[shortName] = hostname as string; } } } catch (err) { warn(`Could not parse hostname config at ${configPath}`); } } } return sshMappings; } /** * Resolve hostname bidirectionally - normalize any input to short name and full hostname * @param input - Either a short name or full hostname * @returns Object with both short name and full hostname */ export function resolveHostname(input: string): HostResolution { const hostnameMap = loadHostnameConfig(); // Create reverse mapping: full hostname -> short name const reverseMap: Record<string, string> = {}; for (const [shortName, fullHostname] of Object.entries(hostnameMap)) { reverseMap[fullHostname] = shortName; } // Check if input is a short name (exists in forward map) if (hostnameMap[input]) { return { shortName: input, fullHostname: hostnameMap[input] }; } // Check if input is a full hostname (exists in reverse map) if (reverseMap[input]) { return { shortName: reverseMap[input], fullHostname: input }; } // Not found in mapping - try to extract short name from hostname const shortName = input.split('.')[0]; return { shortName, fullHostname: input }; } // Configuration - adjust paths based on platform const C_DRIVES = IS_WSL ? '/mnt/c/drives' : 'C:\\drives'; const getJunctionBase = (): string => { if (IS_WINDOWS) { return 'C:\\drives\\smb'; } else if (IS_WSL) { return '/mnt/c/drives/smb'; } else { return path.join(homedir(), 'mounts', 'smb'); } }; const JUNCTION_BASE = getJunctionBase(); // Windows path for junction creation - BUT only when running on Windows, not WSL! const JUNCTION_BASE_WIN = IS_WSL ? '/mnt/c/drives/smb' : 'C:\\drives\\smb'; const SSH_TUNNEL_DIR = path.join(homedir(), '.mountsmb'); const TUNNEL_PORT_START = 44500; // Not needed but kept for compatibility // No password needed - using SSH key authentication // Colors for output const colors = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', blue: '\x1b[34m' }; // Global verbose flag let isVerbose = false; // Get version from package.json function getVersion(): string { try { const packagePath = path.join(import.meta.dirname, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); return packageJson.version; } catch (err) { // Fallback: try current directory try { const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); return packageJson.version; } catch { return 'unknown'; } } } // Centralized timestamp function function getTimestamp(): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); const milliseconds = String(now.getMilliseconds()).padStart(3, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; } // Logging functions with timestamps const log = (msg: string) => { if (isVerbose) console.log(`${getTimestamp()} ${colors.green}βœ“${colors.reset} ${msg}`); }; const warn = (msg: string) => { if (isVerbose) console.log(`${getTimestamp()} ${colors.yellow}⚠${colors.reset} ${msg}`); }; const error = (msg: string) => { if (isVerbose) console.log(`${getTimestamp()} ${colors.red}βœ—${colors.reset} ${msg}`); }; const info = (msg: string) => { if (isVerbose) console.log(`${getTimestamp()} ${colors.blue}β„Ή${colors.reset} ${msg}`); }; const logAlways = (msg: string) => console.log(`${getTimestamp()} ${colors.green}βœ“${colors.reset} ${msg}`); // For truly critical errors that should always be shown const errorAlways = (msg: string) => console.log(`${getTimestamp()} ${colors.red}βœ—${colors.reset} ${msg}`); // Interface for connection info export interface ConnectionInfo { /** Username for SSH and SMB connections */ user: string; /** Full hostname for connections */ host: string; /** Short name used for mount points */ mountName: string; /** Remote path to mount */ remotePath: string; } // Interface for mount info interface MountInfo { pid: number; port: number; driveLetter: string; host: string; user: string; } /** * Parse SSH config to extract connection details for a specific site */ function parseSSHConfig(site: string): ConnectionInfo | null { const configPaths = getSSHConfigPaths(); if (configPaths.length === 0) { return null; } for (const configPath of configPaths) { try { const config = fs.readFileSync(configPath, 'utf8'); const lines = config.split('\n'); let inHost = false; let user = 'root'; let hostname = site; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Skip comment lines and empty lines if (line.startsWith('#') || line === '') { continue; } const tokens = line.split(/\s+/); if (tokens.length < 2) { continue; } const keyword = tokens[0].toLowerCase(); if (keyword === 'host' && tokens.length >= 2) { const hostValue = dequote(tokens); if (hostValue.toLowerCase() === site.toLowerCase()) { inHost = true; } else if (inHost) { break; // Next host section } continue; } if (inHost) { if (keyword === 'hostname') { hostname = dequote(tokens); } if (keyword === 'user') { user = dequote(tokens); } } } // If we found the host in this config file, return the result if (inHost) { return { user, host: hostname, mountName: site, remotePath: '/' }; } } catch (err) { // Continue to next config file continue; } } return null; } /** * Parse connection string (user@host or hostname) */ function parseConnection(site: string, remotePath: string = '/'): ConnectionInfo { // Check if it's user@host format if (site.includes('@')) { const [user, hostname] = site.split('@'); const resolved = resolveHostname(hostname); return { user, host: resolved.fullHostname, mountName: resolved.shortName, remotePath }; } // First try with the original site name let configInfo = parseSSHConfig(site); if (configInfo) { configInfo.remotePath = remotePath; const resolved = resolveHostname(configInfo.host); configInfo.host = resolved.fullHostname; configInfo.mountName = resolved.shortName; return configInfo; } // If not found, resolve hostname and try with both short and full forms const resolved = resolveHostname(site); // Try with short name if different from original if (resolved.shortName !== site) { configInfo = parseSSHConfig(resolved.shortName); if (configInfo) { configInfo.remotePath = remotePath; const finalResolved = resolveHostname(configInfo.host); configInfo.host = finalResolved.fullHostname; configInfo.mountName = finalResolved.shortName; return configInfo; } } // Try with full hostname if different from original if (resolved.fullHostname !== site) { configInfo = parseSSHConfig(resolved.fullHostname); if (configInfo) { configInfo.remotePath = remotePath; const finalResolved = resolveHostname(configInfo.host); configInfo.host = finalResolved.fullHostname; configInfo.mountName = finalResolved.shortName; return configInfo; } } // User MUST be specified in SSH config or as user@host throw new Error( `No user specified for ${site}. ` + `Please add a User entry in your SSH config for host '${resolved.shortName}' or '${resolved.fullHostname}' or use user@host format.\n` + `Example SSH config:\n` + ` Host ${resolved.shortName}\n` + ` HostName ${resolved.fullHostname}\n` + ` User <username>` ); } /** * Run SSH command using spawn for better quote handling * @param user SSH username * @param host SSH hostname * @param command Command to run remotely * @returns Promise that resolves when command completes */ async function runSSHCommand(user: string, host: string, command: string): Promise<void> { return new Promise((resolve, reject) => { const sshArgs = [ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'PasswordAuthentication=no', `${user}@${host}`, command ]; const sshProcess = spawn('ssh', sshArgs, { stdio: 'pipe' }); let output = ''; sshProcess.stdout?.on('data', (data) => { output += data.toString(); }); sshProcess.stderr?.on('data', (data) => { output += data.toString(); }); sshProcess.on('exit', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`SSH command failed (exit ${code}): ${output}`)); } }); }); } /** * Execute command using spawn for better argument handling * @param command Command to execute * @param args Array of arguments * @param options Execution options * @returns Command output */ function spawnx(command: string, args: string[], options: ExecOptions = {}): string { dbgLog(`Spawning: ${command} ${args.map(arg => arg.includes(' ') ? `"${arg}"` : arg).join(' ')}`); try { const result = spawnSync(command, args, { encoding: 'utf8', stdio: options.stdio || 'pipe', shell: false, // Don't use shell - safer argument handling windowsHide: true }); if (result.error) { throw result.error; } if (result.status !== 0 && !options.ignoreError) { const error = new Error(`Command failed with exit code ${result.status}: ${result.stderr || result.stdout}`); throw error; } return (result.stdout || '').trim(); } catch (err: unknown) { if (options.ignoreError) { return ''; } throw err; } } /** * Execute SSH command with proper options to prevent hanging * @param user SSH username * @param host SSH hostname * @param command Remote command to execute * @param options Execution options * @returns Command output */ function sshExec(user: string, host: string, command: string, options: ExecOptions = {}): string { // SSH options to prevent hanging on password/host key prompts const sshArgs = [ '-o', 'BatchMode=yes', // Fail instead of prompting for password '-o', 'ConnectTimeout=10', // 10 second connection timeout '-o', 'StrictHostKeyChecking=accept-new', // Accept new host keys automatically '-o', 'PasswordAuthentication=no', // Disable password auth to prevent prompts `${user}@${host}`, command // Command is passed as a single argument - SSH will handle it properly ]; dbgLog(`SSH command: ssh [options] ${user}@${host} "${command}"`); try { return spawnx('ssh', sshArgs, options); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); dbgLog(`SSH command failed for ${user}@${host}: ${message}`); dbgLog(`Command was: ${command}`); // Add debugger for fatal errors when not ignoring errors if (!options.ignoreError) { // Check for specific SSH connection issues only if (message.includes('Connection refused')) { debugger; // Debug connection issues throw new Error(`Cannot connect to ${host}:22. Please check if SSH service is running.`); } else if (message.includes('Connection timed out')) { debugger; // Debug timeout issues throw new Error(`Connection to ${host} timed out. Please check network connectivity.`); } else if (message.includes('Host key verification failed')) { debugger; // Debug host key issues throw new Error(`Host key verification failed for ${host}. The host key may have changed.`); } else if (message.includes('Permission denied (publickey')) { // Only for SSH key auth failures, not sudo permission denied debugger; // Debug SSH key auth issues throw new Error(`SSH key authentication failed for ${user}@${host}. Please ensure SSH keys are set up.`); } // For other errors, just pass them through with original message debugger; // Debug other SSH failures } throw err; } } /** * Execute command safely using spawnx - DEPRECATED, use spawnx() directly * This function is kept for backward compatibility but should be avoided for new code */ function exec(cmd: string, options: ExecOptions = {}): string { // Parse simple commands and delegate to spawnx for safety const parts = cmd.trim().split(/\s+/); if (parts.length === 0) { return ''; } const command = parts[0]; const args = parts.slice(1); // For simple commands without shell features, use spawnx if (!cmd.includes('|') && !cmd.includes('&&') && !cmd.includes('||') && !cmd.includes('>') && !cmd.includes('<')) { try { return spawnx(command, args, options); } catch (err) { if (options.ignoreError) { return ''; } throw err; } } // Fallback to shell execution for complex commands (NOT RECOMMENDED) warn(`DEPRECATED: Using shell execution for complex command: ${cmd}`); warn(`Consider replacing with direct spawnx() calls for better security`); try { let actualCmd = cmd; if (IS_WSL) { // In WSL, Windows commands need to be prefixed with cmd.exe /c if (cmd.startsWith('net use') || cmd.startsWith('mklink') || cmd.includes('if exist')) { actualCmd = `cmd.exe /c "${cmd}"`; } } return execSync(actualCmd, { encoding: 'utf8', stdio: 'pipe', shell: IS_WINDOWS ? 'cmd.exe' : '/bin/bash', ...options }).trim(); } catch (err: unknown) { if (options.ignoreError) { return ''; } throw err; } } /** * Execute specific Windows commands safely using spawnx */ function execWindowsSafe(command: string, args: string[], options: ExecOptions = {}): string { if (!IS_WINDOWS && !IS_WSL) { throw new Error('Windows commands not available on this platform'); } // Built-in cmd.exe commands that need shell execution const builtinCommands = ['mklink', 'dir', 'type', 'del', 'copy', 'move', 'md', 'rd']; const needsShell = builtinCommands.includes(command.toLowerCase()); if (IS_WINDOWS) { if (needsShell) { return spawnx('cmd.exe', ['/c', command, ...args], options); } else { return spawnx(command, args, options); } } else if (IS_WSL) { // WSL: Always use cmd.exe to execute Windows commands return spawnx('cmd.exe', ['/c', command, ...args], options); } throw new Error('Windows commands not available on this platform'); } /** * Execute Windows command from any platform - DEPRECATED * Use execWindowsSafe() or specific command functions instead */ function execWindows(cmd: string, options: ExecOptions = {}): string { if (!IS_WINDOWS && !IS_WSL) { throw new Error('Windows commands not available on this platform'); } warn(`DEPRECATED: execWindows() called with shell command: ${cmd}`); warn(`Consider using execWindowsSafe() or specific command functions for better security`); // Parse simple commands and try to use execWindowsSafe const parts = cmd.trim().split(/\s+/); if (parts.length > 0 && !cmd.includes('>') && !cmd.includes('|') && !cmd.includes('&&')) { const command = parts[0]; const args = parts.slice(1); try { return execWindowsSafe(command, args, options); } catch (err) { // Fallback to shell execution if parsing fails warn(`Falling back to shell execution for: ${cmd}`); } } // Fallback to shell execution for complex commands if (IS_WINDOWS) { return exec(cmd, options); // This will now show deprecation warning } else if (IS_WSL) { // WSL: Use cmd.exe to execute Windows commands return spawnx('cmd.exe', ['/c', cmd], options); } throw new Error('Windows commands not available on this platform'); } /** * Execute net use command safely with proper argument handling */ function netUse(uncPath: string, options: { delete?: boolean, password?: string, user?: string, persistent?: boolean } = {}): string { const args = ['use', uncPath]; if (options.delete) { args.push('/delete', '/y'); } else { if (options.password) { args.push(options.password); } if (options.user) { args.push('/user:' + options.user); } if (options.persistent) { args.push('/persistent:yes'); } } return execWindowsSafe('net', args, { ignoreError: true }); } /** * Create Windows junction using Node.js fs API with proper UNC path formatting */ async function createJunction(linkPath: string, targetPath: string): Promise<void> { if (!IS_WINDOWS && !IS_WSL) { throw new Error('Junction creation not available on this platform'); } try { // Fix the UNC path format - ensure it's properly formatted for Windows junctions let properUNCPath = targetPath.replaceAll(/\/\\/g, path.sep); // Normalize to platform // Remove trailing backslashes -- why would they exist? properUNCPath = properUNCPath.replace(/\\+$/, ''); dbgLog(`Creating junction: ${linkPath} β†’ ${properUNCPath}`); // Remove existing link/junction if it exists try { if (fs.existsSync(linkPath)) { dbgLog(`Removing existing link: ${linkPath}`); fs.unlinkSync(linkPath); } } catch (unlinkErr) { dbgLog(`Warning: Could not remove existing link: ${unlinkErr}`); } // For UNC paths, use symbolic links (/D) not junctions (/J) since junctions don't support UNC targets if (IS_WINDOWS) { const mklinkCmd = `mklink /D ${linkPath} ${properUNCPath}`; dbgLog(`Running: ${mklinkCmd}`); execSync(mklinkCmd); } else { // WSL: use cmd.exe (not PowerShell) to run mklink command const windowsLinkPath = linkPath.replace(/^\/mnt\/c/, 'C:'); const mklinkCmd = `mklink /D ${windowsLinkPath} ${properUNCPath}`; dbgLog(`Running from WSL: cmd.exe /c "${mklinkCmd}"`); execSync(`cmd.exe /c "${mklinkCmd}"`); } dbgLog(`Created junction successfully`); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to create junction: ${message}`); } } /** * Check if port is available */ async function isPortAvailable(port: number): Promise<boolean> { return new Promise((resolve) => { const server = net.createServer(); server.once('error', () => { resolve(false); }); server.once('listening', () => { server.close(); resolve(true); }); server.listen(port, '127.0.0.1'); }); } /** * Find available port for SSH tunnel */ async function findAvailablePort(): Promise<number> { let port = TUNNEL_PORT_START; while (port < TUNNEL_PORT_START + 100) { if (await isPortAvailable(port)) { return port; } port++; } throw new Error('No available ports for SSH tunnel'); } /** * Check if Samba is installed on remote host */ function checkSamba(conn: ConnectionInfo): 'running' | 'stopped' | 'missing' | 'unconfigured' { // First check if Samba is installed try { const whichResult = sshExec(conn.user, conn.host, 'which smbd', { ignoreError: true }); if (!whichResult || whichResult.trim() === '') { return 'missing'; } } catch { return 'missing'; } // Check if Samba service is active try { const result = sshExec(conn.user, conn.host, 'systemctl is-active smbd', { ignoreError: true }); if (result.trim() === 'active') { // Check if the specific share is configured try { sshExec(conn.user, conn.host, `grep -q '\\[${conn.mountName}\\]' /etc/samba/smb.conf`); return 'running'; } catch { return 'unconfigured'; // Samba is running but share not configured } } else { return 'stopped'; } } catch { return 'stopped'; } } /** * Install and configure Samba on remote host */ function installSamba(conn: ConnectionInfo): void { log(`Installing Samba on ${conn.host}...`); // Install Samba - try without update first, then with update if needed try { // First try to install without updating repositories log('Attempting to install Samba packages...'); sshExec(conn.user, conn.host, 'sudo apt-get install -y samba samba-common-bin'); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); log('Initial install failed, trying with repository update...'); try { // Only update repositories if the install failed sshExec(conn.user, conn.host, 'sudo apt-get update && sudo apt-get install -y samba samba-common-bin'); log('Samba installed successfully after repository update'); } catch (updateErr) { const updateErrorMsg = updateErr instanceof Error ? updateErr.message : String(updateErr); if (updateErrorMsg.includes('no longer has a Release file') || updateErrorMsg.includes('Release file')) { warn(`Repository update failed (possibly EOL Ubuntu version): ${updateErrorMsg}`); warn('Continuing anyway - Samba may already be installed or configuration may still work'); // Don't throw - let the configuration attempt proceed } else { error(`Failed to install Samba on ${conn.host}: ${updateErrorMsg}`); throw updateErr; } } } log(`Configuring Samba share for ${conn.remotePath}...`); // Configure Samba share const smbConfig = ` [${conn.mountName}] path = ${conn.remotePath} browsable = yes writable = yes guest ok = no valid users = ${conn.user} force user = ${conn.user} create mask = 0755 directory mask = 0755 encrypt passwords = yes smb encrypt = required server min protocol = SMB3 `; try { // Write config using here-document to avoid shell escaping issues sshExec(conn.user, conn.host, `sudo tee -a /etc/samba/smb.conf > /dev/null << 'SMBEOF' ${smbConfig}SMBEOF`); // Configure Samba user with a simple password that's based on the hostname // This is more secure than a fixed password and works automatically const autoPassword = `smb_${conn.host.split('.')[0]}`; sshExec(conn.user, conn.host, `echo -e '${autoPassword}\\n${autoPassword}' | sudo smbpasswd -a ${conn.user} -s`); log(`Samba user ${conn.user} configured with host-specific password`); // Enable and start Samba sshExec(conn.user, conn.host, 'sudo systemctl enable smbd nmbd && sudo systemctl restart smbd nmbd'); log(`Samba configured successfully on ${conn.host}`); } catch (err) { error(`Failed to configure Samba on ${conn.host}`); throw err; } } /** * Create SSH tunnel for SMB traffic */ async function createTunnel(conn: ConnectionInfo, port: number): Promise<ChildProcess> { return new Promise((resolve, reject) => { log(`Creating SSH tunnel: localhost:${port} -> ${conn.host}:445`); // Start SSH tunnel const tunnel = spawn('ssh', [ '-N', '-L', `${port}:localhost:445`, `${conn.user}@${conn.host}` ], { detached: true, stdio: 'ignore' }); tunnel.on('error', reject); // Give tunnel time to establish setTimeout(() => { // Verify tunnel is working isPortAvailable(port).then(available => { if (available) { reject(new Error('SSH tunnel failed to establish')); } else { log('SSH tunnel established successfully'); resolve(tunnel); } }); }, 2000); }); } /** * Check if we need a drive letter or can use UNC paths */ function needsDriveLetter(): boolean { // For now, we'll skip drive letters and use direct UNC mounting return false; } /** * Find available drive letter (only if needed) */ function findAvailableDrive(): string | null { if (!IS_WINDOWS && !IS_WSL) { // On pure Linux, we don't use drive letters return 'LINUX'; } // Skip drive letter assignment for now if (!needsDriveLetter()) { return 'UNC'; } const drives = ['Z:', 'Y:', 'X:', 'W:', 'V:', 'U:', 'T:', 'S:', 'R:', 'Q:', 'P:', 'O:', 'N:', 'M:']; for (const drive of drives) { try { // Use fs check instead of Windows "if exist" if (!directoryExists(`${drive}\\`)) { return drive; } } catch { // Drive exists, continue } } return null; } /** * Mount SMB share - direct connection (no tunnel needed for SMB) */ async function mountSMB(conn: ConnectionInfo): Promise<boolean> { // Check if already mounted AND connection is alive const existingMount = loadMountInfo(conn.mountName); if (existingMount) { // Verify the mount is actually accessible if (IS_WINDOWS || IS_WSL) { const junctionPath = `C:\\drives\\smb\\${conn.mountName}`; try { // Try to verify the directory is accessible using fs if (await isJunctionAccessible(junctionPath)) { logAlways(`${conn.mountName} is already mounted`); return true; } } catch { log(`${conn.mountName} mount info exists but connection is dead, remounting...`); // Clean up dead mount info const infoPath = path.join(SSH_TUNNEL_DIR, `${conn.mountName}.json`); try { fs.unlinkSync(infoPath); } catch { } // Continue to remount below } } } const autoPassword = `smb_${conn.host.split('.')[0]}`; // Platform-specific native mounting if (IS_WSL) { // WSL - mount natively with CIFS first, then create Windows junction log(`Setting up dual mount: WSL native + Windows junction`); const wslMountPoint = `/mnt/smb/${conn.mountName}`; try { // Create WSL native mount point using spawnx spawnx('sudo', ['mkdir', '-p', wslMountPoint], { ignoreError: true }); // Check if already mounted const mountCheck = spawnx('mount', [], { ignoreError: true }); if (!mountCheck.includes(wslMountPoint)) { // Use spawnx for safer argument handling spawnx('sudo', [ 'mount', '-t', 'cifs', `//${conn.host}/${conn.mountName}`, wslMountPoint, '-o', `user=${conn.user},password=${autoPassword},vers=3.0,_netdev` ]); log(`βœ“ WSL native mount: ${wslMountPoint}`); } else { log(`βœ“ WSL mount already active: ${wslMountPoint}`); } // Create convenient symlink const symlinkPath = path.join(homedir(), 'mounts', conn.mountName); spawnx('mkdir', ['-p', path.dirname(symlinkPath)], { ignoreError: true }); if (!fs.existsSync(symlinkPath)) { spawnx('ln', ['-sf', wslMountPoint, symlinkPath]); log(`βœ“ WSL symlink: ~/mounts/${conn.mountName}`); } } catch (err) { warn(`WSL native mount failed (continuing with Windows-only): ${err}`); } // Continue to Windows junction creation below... } else if (IS_LINUX) { // Pure Linux - use mount.cifs only log(`Mounting SMB share on Linux`); const mountPoint = path.join(homedir(), 'mounts', conn.mountName); fs.mkdirSync(mountPoint, { recursive: true }); try { // Use spawnx for safer argument handling spawnx('sudo', [ 'mount', '-t', 'cifs', `//${conn.host}/${conn.mountName}`, mountPoint, '-o', `user=${conn.user},password=${autoPassword},vers=3.0` ]); log(`SMB share mounted to ${mountPoint}`); return true; } catch (err) { error('Failed to mount SMB share on Linux (may need sudo or cifs-utils)'); throw err; } } // Windows or WSL - verify Samba is accessible and create persistent connection // For SMB connections to remote systems, ALWAYS use the FQDN from HostName // Remote systems need to be reached by their actual network name, not short aliases const smbHostname = conn.host; log(`Verifying SMB share is accessible: \\\\${smbHostname}\\${conn.mountName}`); try { // First, delete any existing connection to avoid conflicts netUse(`\\\\${smbHostname}\\${conn.mountName}`, { delete: true }); // Create persistent connection with authentication - let SMB handle hostname resolution const result = netUse(`\\\\${smbHostname}\\${conn.mountName}`, { password: autoPassword, user: conn.user, persistent: true }); log(`βœ“ Created persistent SMB connection with credentials`); log(` Connection will survive reboots and timeouts`); return true; } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); if (errorMsg.includes('System error 67')) { errorAlways(`Cannot reach SMB share at \\\\${conn.host}\\${conn.mountName}`); errorAlways(`Possible causes:`); errorAlways(` 1. Samba is not running on ${conn.host}`); errorAlways(` 2. Firewall is blocking SMB port 445`); errorAlways(` 3. The share name '${conn.mountName}' doesn't exist`); errorAlways(` 4. Network connectivity issue to ${conn.host}`); info(`Try: ssh ${conn.user}@${conn.host} "sudo systemctl status smbd"`); } else if (errorMsg.includes('System error 5')) { errorAlways(`Access denied to SMB share`); errorAlways(`The password or username may be incorrect`); errorAlways(`Expected: user=${conn.user}, password=${autoPassword}`); } else { errorAlways(`SMB share not accessible: ${err}`); errorAlways(`Make sure Samba is running and configured on ${conn.host}`); errorAlways(`Expected password: ${autoPassword}`); } return false; } } /** * Cross-platform directory existence check using fs */ function directoryExists(dirPath: string): boolean { try { // Use fs.existsSync first to avoid issues with stale symlinks if (!fs.existsSync(dirPath)) { return false; } const stats = fs.lstatSync(dirPath); // Use lstatSync to avoid following symlinks return stats.isDirectory(); } catch (err) { // If there's any error (including stale symlinks), still try basic existence check try { return fs.existsSync(dirPath); } catch { return false; } } } /** * Cross-platform check if junction/symlink is accessible using fs */ async function isJunctionAccessible(junctionPath: string): Promise<boolean> { try { // First check if it exists if (!fs.existsSync(junctionPath)) { return false; } // Try to read the directory - this will fail if junction is broken/inaccessible const stats = await fp.stat(junctionPath); if (!stats.isDirectory()) { return false; } // Try to list contents to verify it's really accessible await fp.readdir(junctionPath); return true; } catch { return false; } } /** * Remove junction/directory using fs calls instead of Windows commands */ async function removeJunction(junctionPath: string): Promise<void> { try { if (fs.existsSync(junctionPath)) { const stats = await fp.lstat(junctionPath); if (stats.isSymbolicLink()) { // It's a junction/symlink - just unlink it await fp.unlink(junctionPath); dbgLog(`Removed junction: ${junctionPath}`); } else if (stats.isDirectory()) { // It's a regular directory - remove recursively await fp.rmdir(junctionPath, { recursive: true }); dbgLog(`Removed directory: ${junctionPath}`); } } } catch (err) { dbgLog(`Failed to remove junction ${junctionPath}: ${err}`); } } /** * Create directory using fs instead of Windows mkdir */ async function ensureDirectoryExists(dirPath: string): Promise<void> { try { await fp.mkdir(dirPath, { recursive: true }); } catch (err: unknown) { if (err instanceof Error && 'code' in err && err.code !== 'EEXIST') { throw err; } } } /** * Verify that a Windows junction/path is accessible */ async function verifyJunctionAccessible(junctionPath: string, maxRetries: number = 3): Promise<boolean> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { // Use fs-based check instead of Windows dir command const isAccessible = await isJunctionAccessible(junctionPath); if (isAccessible) { return true; } if (attempt < maxRetries) { log(`Junction verification attempt ${attempt} failed, retrying in 2 seconds...`); // Wait 2 seconds before retry - use setTimeout instead of execSync await new Promise(resolve => setTimeout(resolve, 2000)); } } catch (err) { if (attempt < maxRetries) { log(`Junction verification attempt ${attempt} failed with error, retrying...`); // Wait 2 seconds before retry - use setTimeout instead of execSync await new Promise(resolve => setTimeout(resolve, 2000)); } } } return false; } /** * Verify if a junction points to the correct target using Node.js fs calls */ async function verifyJunctionTarget(junctionPath: string, expectedTarget: string): Promise<boolean> { try { const stats = await fp.lstat(junctionPath); if (!stats.isSymbolicLink()) { dbgLog(`Junction ${junctionPath} is not a symlink/junction`); return false; } const actualTarget = await fp.readlink(junctionPath); // Normalize paths for comparison (handle different slash styles) const normalizeUNC = (path: string) => path.replace(/\\/g, '/').toLowerCase(); const isCorrect = normalizeUNC(actualTarget) === normalizeUNC(expectedTarget); if (isCorrect) { dbgLog(`Junction ${junctionPath} points to correct target: ${actualTarget}`); } else { dbgLog(`Junction ${junctionPath} points to wrong target: ${actualTarget} (expected: ${expectedTarget})`); } return isCorrect; } catch (err) { dbgLog(`Could not verify junction target for ${junctionPath}: ${err}`); return false; } } /** * Create junction with retry logic and proper verification */ async function createJunctionWithRetry(junctionPath: string, uncPath: string, maxRetries: number = 3): Promise<boolean> { // First check if junction already exists try { if (fs.existsSync(junctionPath)) { // Verify it points to the correct target if (await verifyJunctionTarget(junctionPath, uncPath)) { // Also verify it's accessible if (await verifyJunctionAccessible(junctionPath, 1)) { logAlways(`βœ“ Junction ${junctionPath} already exists with correct target and is accessible`); return true; } else { dbgLog(`Junction ${junctionPath} has correct target but is not accessible, removing...`); } } else { dbgLog(`Junction ${junctionPath} exists but points to wrong target, removing...`); } // Remove junction only if it's wrong or not accessible await removeJunction(junctionPath); } } catch (err) { dbgLog(`Error checking existing junction: ${err}`); } for (let attempt = 1; attempt <= maxRetries; attempt++) { log(`Creating junction (attempt ${attempt}/${maxRetries}): ${junctionPath} β†’ ${uncPath}`); try { await createJunction(junctionPath, uncPath); log('Junction creation completed successfully'); // Wait a moment for the junction to be ready await new Promise(resolve => setTimeout(resolve, 1000)); // Verify the junction is actually accessible if (await verifyJunctionAccessible(junctionPath, 1)) { logAlways(`βœ“ Junction ${junctionPath} verified accessible`); return true; } else { warn(`Junction ${junctionPath} created but not accessible (attempt ${attempt})`); // Clean up failed junction await removeJunction(junctionPath); } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes('Cannot create a file when that file already exists') || errorMessage.includes('already exists')) { // Junction might have been created by another process, verify it works if (await verifyJunctionAccessible(junctionPath, 1)) { logAlways(`βœ“ Junction ${junctionPath} already exists and is accessible`); return true; } else { dbgLog(`Junction ${junctionPath} exists but not accessible, removing and retrying`); await removeJunction(junctionPath); } } else { warn(`Junction creation failed (attempt ${attempt}): ${err}`); } } if (attempt < maxRetries) { log('Waiting before retry...'); await new Promise(resolve => setTimeout(resolve, 2000)); } } return false; } /** * Create junction points and symlinks to UNC path */ async function createAccessPaths(mountName: string, hostName: string): Promise<void> { // Debug logging for UNC path logic dbgLog(`createAccessPaths called with mountName="${mountName}", hostName="${hostName}"`); if (IS_LINUX && !IS_WSL) { // Pure Linux - symlink already created by mount return; } // Ensure base directory exists (Windows-style path) log(`Creating base directory: ${JUNCTION_BASE_WIN}`); // Check if C:\drives exists - this is mandatory try { if (!directoryExists(C_DRIVES)) { errorAlways(`FATAL: ${C_DRIVES} directory does not exist. This is required for mountsmb to function.`); errorAlways(`Please ensure ${C_DRIVES} exists before running mountsmb.`); process.exit(1); } } catch (err) { errorAlways(`FATAL: Cannot check if ${C_DRIVES} exists: ${err}`); errorAlways(`mountsmb requires ${C_DRIVES} to exist. Exiting.`); process.exit(1); } try { // Create drives\smb directory if it doesn't exist using fs await ensureDirectoryExists(JUNCTION_BASE_WIN); } catch (err) { errorAlways(`FATAL: Could not create ${JUNCTION_BASE_WIN}: ${err}`); process.exit(1); } // WSL Path Validation: NEVER create Windows paths when in WSL context if (IS_WSL && JUNCTION_BASE_WIN.includes('C:\\')) { errorAlways('πŸ”΄ BUG DETECTED: Attempting to create Windows path in WSL context!'); errorAlways(`πŸ”΄ JUNCTION_BASE_WIN: ${JUNCTION_BASE_WIN}`); errorAlways(`πŸ”΄ This will create garbled paths like "CΓ―ΒΊΓ―drivesΓ―smb"`); errorAlways('πŸ”΄ WSL should use Unix paths like /mnt/c/drives/smb'); throw new Error('Windows path creation blocked in WSL context'); } // Create junction to UNC path with retry logic const junctionPath = IS_WSL ? `${JUNCTION_BASE_WIN}/${mountName}` : `${JUNCTION_BASE_WIN}\\${mountName}`; // For SMB connections to remote systems, ALWAYS use the FQDN from HostName // Short names in Host entries are just for SSH convenience, SMB needs real hostnames const uncHostname = hostName; const uncPath = `\\\\${uncHostname}\\${mountName}`.replace(/\\+$/, ''); // Remove trailing slashes dbgLog(`createAccessPaths UNC logic - mountName="${mountName}", hostName="${hostName}", uncHostname="${uncHostname}", uncPath="${uncPath}"`); const junctionSuccess = await createJunctionWithRetry(junctionPath, uncPath); if (!junctionSuccess) { errorAlways(`Failed to create accessible junction ${junctionPath} after multiple attempts`); errorAlways(`Mount failed - junction is required for Windows access`); errorAlways(`You can try accessing directly via: ${uncPath}`); throw new Error(`Junction creation failed for ${junctionPath}`); } // Verify paths were created successfully log(`Verifying paths created:`); const baseExists = directoryExists(JUNCTION_BASE_WIN); const junctionExists = await verifyJunctionAccessible(`${JUNCTION_BASE_WIN}\\${mountName}`, 1); info(` ${JUNCTION_BASE_WIN} exists: ${baseExists ? 'βœ“' : 'βœ—'}`); info(` Junction path exists: ${junctionExists ? 'βœ“' : 'βœ—'}`); if (!junctionExists) { warn(`Junction ${junctionPath} verification failed - mount may not be fully accessible from Windows`); info(`Try accessing directly: ${uncPath}`); } // Create local symlink for easy access const localMountDir = path.join(homedir(), 'mounts'); // Ensure the mounts directory exists with detailed error handling try { if (!fs.existsSync(localMountDir)) { fs.mkdirSync(localMountDir, { recursive: true }); log(`Created local mounts directory: ${localMountDir}`); } } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); warn(`Could not create mounts directory ${localMountDir}: ${message}`); warn(`Skipping local symlink creation`); return; } const symlinkPath = path.join(localMountDir, mountName); if (IS_WSL) { // WSL: Create symlink to Windows junction const targetPath = `/mnt/c/drives/smb/${mountName}`; // Remove existing symlink if it exists try { if (fs.existsSync(symlinkPath)) { fs.unlinkSync(symlinkPath); log(`Removed existing WSL symlink: ${symlinkPath}`); } } catch {} try { spawnx('ln', ['-sf', targetPath, symlinkPath]); log(`WSL symlink created: ${symlinkPath} β†’ ${targetPath}`); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); warn(`Could not create WSL symlink ${symlinkPath}: ${message}`); warn(`Target was: ${targetPath}`); } } else if (IS_WINDOWS) { // Windows: Create symlink to junction const windowsSymlinkPath = symlinkPath.replace(/\//g, '\\'); if (fs.existsSync(windowsSymlinkPath)) { log(`Windows symlink already exists: ${windowsSymlinkPath}`); } else { try { await createJunction(windowsSymlinkPath, junctionPath); log(`Windows symlink created: ${windowsSymlinkPath} β†’ ${junctionPath}`); } catch (err: unknown) { const errorMsg = err instanceof Error ? err.message : String(err); if (errorMsg.includes('already exists') || errorMsg.includes('EEXIST')