UNPKG

@bobfrankston/mountsmb

Version:

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

540 lines (465 loc) 15.2 kB
#!/usr/bin/env node /** * apisupport.ts - API functions for mountsmb * Provides programmatic access to SMB mounting functionality */ import { execSync, spawn, ChildProcess } 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'; /** * 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'); // Platform detection const IS_WINDOWS = platform() === 'win32'; const IS_LINUX = platform() === 'linux'; const IS_WSL = (): boolean => { if (platform() !== 'linux') return false; try { return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); } catch { return false; } }; // Constants const SSH_TUNNEL_DIR = path.join(homedir(), '.ssh', 'tunnels'); /** * 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; } /** * Connection information for a host */ export interface ConnectionInfo { /** Short hostname for mount points */ shortName: string; /** Full hostname for connections */ fullHostname: string; /** Remote path being mounted */ remotePath: string; /** SSH username (from config or default 'root') */ user: string; /** Local Windows path (C:\drives\smb\hostname) */ windowsPath?: string; /** Local WSL path (/mnt/c/drives/smb/hostname) */ wslPath?: string; /** WSL symlink path (~/mounts/hostname) */ symlinkPath?: string; /** Generated SMB password */ smbPassword: string; } /** * Mount options interface */ export interface MountOptions { /** Force Samba installation even if already installed */ installSamba?: boolean; /** Enable verbose logging output */ verbose?: boolean; /** Remote path to mount (defaults to '/') */ remotePath?: string; /** Enable debug messages for troubleshooting */ debug?: boolean; } /** * Mount information stored in JSON files */ export interface MountInfo { pid: number; port: number; driveLetter?: string; host: string; remotePath: string; localPath: string; timestamp: 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[] = []; const isWSL = IS_WSL(); if (isWSL) { // 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(configPath => fs.existsSync(configPath)); } /** * Parse SSH config files to build hostname mappings * @returns Hostname mapping from SSH config (Host -> HostName) */ function parseSSHConfig(): HostnameMap { const hostMap: HostnameMap = {}; const configPaths = getSSHConfigPaths(); for (const configPath of configPaths) { try { const configContent = fs.readFileSync(configPath, 'utf8'); const lines = configContent.split('\n'); let currentHost: string | null = null; for (const line of lines) { 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(); if (keyword === 'host' && tokens.length >= 2) { const hostValue = dequote(tokens); // Skip wildcards and complex patterns if (hostValue.includes('*') || hostValue.includes('?')) { currentHost = null; } else { currentHost = hostValue; } } else if (currentHost && keyword === 'hostname') { const hostName = dequote(tokens); hostMap[currentHost] = hostName; currentHost = null; } } } catch (error) { // Skip files that can't be read console.warn(`Warning: Could not read SSH config at ${configPath}`); } } return hostMap; } /** * Load hostname configuration from SSH config and defaults * @returns Combined hostname mapping */ export function loadHostnameConfig(): HostnameMap { const sshConfig = parseSSHConfig(); return { ...DEFAULT_HOSTNAME_MAP, ...sshConfig }; } /** * Resolve hostname to both short and full forms * @param input - Short name or full hostname * @returns Resolution with both short and full names */ export function resolveHostname(input: string): HostResolution { const hostMap = loadHostnameConfig(); // Check if input is a short name if (hostMap[input]) { return { shortName: input, fullHostname: hostMap[input] }; } // Check if input is a full hostname for (const [shortName, fullHostname] of Object.entries(hostMap)) { if (fullHostname === input) { return { shortName, fullHostname: input }; } } // Not in config, use as-is return { shortName: input, fullHostname: input }; } /** * Load mount information from JSON file */ function loadMountInfo(mountName: string): MountInfo | null { const infoPath = path.join(SSH_TUNNEL_DIR, `${mountName}.json`); try { return JSON.parse(fs.readFileSync(infoPath, 'utf8')); } catch { return null; } } /** * Get list of active mounts with their information * @returns Array of active mount information */ export function getActiveMounts(): Array<MountInfo & { mountName: string }> { if (!fs.existsSync(SSH_TUNNEL_DIR)) { return []; } const jsonFiles = fs.readdirSync(SSH_TUNNEL_DIR) .filter(f => f.endsWith('.json')) .map(f => ({ file: f, stat: fs.statSync(path.join(SSH_TUNNEL_DIR, f)) })) .sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime()) .map(item => item.file); const activeMounts: Array<MountInfo & { mountName: string }> = []; for (const file of jsonFiles) { const mountName = path.basename(file, '.json'); const mountInfo = loadMountInfo(mountName); if (mountInfo) { try { // Check if process is still alive process.kill(mountInfo.pid, 0); activeMounts.push({ ...mountInfo, mountName }); } catch { // Process is dead, skip it } } } return activeMounts; } /** * Clean up dead mount processes and their info files * @returns Number of dead mounts cleaned up */ export function cleanupDeadMounts(): number { if (!fs.existsSync(SSH_TUNNEL_DIR)) { return 0; } const jsonFiles = fs.readdirSync(SSH_TUNNEL_DIR).filter(f => f.endsWith('.json')); let cleanedCount = 0; for (const file of jsonFiles) { const mountName = path.basename(file, '.json'); const mountInfo = loadMountInfo(mountName); if (mountInfo) { try { // Check if process is still alive process.kill(mountInfo.pid, 0); } catch { // Process is dead, clean it up try { fs.unlinkSync(path.join(SSH_TUNNEL_DIR, file)); cleanedCount++; } catch { // File might already be deleted } } } } return cleanedCount; } /** * Parse SSH config to get user information for a specific host */ function parseSSHConfigUser(site: string): string | null { const configPaths = getSSHConfigPaths(); for (const configPath of configPaths) { try { const config = fs.readFileSync(configPath, 'utf8'); const lines = config.split('\n'); let inHost = false; for (const line of lines) { const trimmed = line.trim(); // Skip comment lines and empty lines if (trimmed.startsWith('#') || trimmed === '') { continue; } const tokens = trimmed.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 && keyword === 'user') { return dequote(tokens); } } } catch { continue; } } return null; } /** * Get connection information for a host and path * @param host - Hostname (short or full) * @param remotePath - Remote path to mount (default: '/') * @returns Connection information */ /** * Test SSH connection without password (key-based auth) * @param user SSH username * @param host SSH hostname * @returns true if SSH key authentication works */ function testSSHKeyAuth(user: string, host: string): boolean { try { const command = IS_WINDOWS ? `wsl -e bash -c "ssh -o BatchMode=yes -o ConnectTimeout=5 ${user}@${host} exit"` : `ssh -o BatchMode=yes -o ConnectTimeout=5 ${user}@${host} exit`; execSync(command, { stdio: 'pipe' }); return true; } catch { return false; } } /** * Ensure SSH keys are set up for a host * @param user SSH username * @param host SSH hostname * @returns true if SSH keys are working or were set up successfully */ export async function ensureSSHKeys(user: string, host: string): Promise<boolean> { // First test if SSH key auth already works if (testSSHKeyAuth(user, host)) { return true; } console.log(`⚠ SSH key authentication not working for ${user}@${host}`); console.log('Setting up SSH keys automatically...'); try { // Run setupkeys from the linuxbin directory const setupKeysPath = path.join(__dirname, '../../../linuxbin/setupkeys.ts'); const setupProcess = spawn('node', [setupKeysPath, host, user], { stdio: 'inherit', cwd: path.dirname(setupKeysPath) }); return new Promise((resolve) => { setupProcess.on('exit', (code) => { if (code === 0) { console.log('✓ SSH keys set up successfully'); resolve(true); } else { console.log('✗ SSH key setup failed'); resolve(false); } }); }); } catch (error) { console.error('Failed to run setupkeys:', error); console.log(`Fallback: Please run manually: setupkeys ${host} ${user}`); return false; } } export function getConnectionInfo(host: string, remotePath: string = '/'): ConnectionInfo { const resolution = resolveHostname(host); const smbPassword = `smb_${resolution.shortName}`; // Try to get user from SSH config, default to 'root' const sshUser = parseSSHConfigUser(resolution.shortName) || parseSSHConfigUser(host); const user = sshUser || 'root'; const info: ConnectionInfo = { shortName: resolution.shortName, fullHostname: resolution.fullHostname, remotePath, user, smbPassword, }; if (IS_WINDOWS || IS_WSL()) { info.windowsPath = `C:\\drives\\smb\\${resolution.shortName}`; info.wslPath = `/mnt/c/drives/smb/${resolution.shortName}`; } if (IS_WSL()) { info.symlinkPath = path.join(homedir(), 'mounts', resolution.shortName); } return info; } /** * Get hostname mappings from configuration * @returns Current hostname mappings */ export function getHostnameMappings(): HostnameMap { return loadHostnameConfig(); } /** * Get SSH config file information * @returns Array of SSH config file paths and their status */ export function getSSHConfigInfo(): Array<{ path: string; exists: boolean; primary: boolean }> { const isWSL = IS_WSL(); const results: Array<{ path: string; exists: boolean; primary: boolean }> = []; if (isWSL) { const windowsConfig = '/mnt/c/Users/' + (process.env.USER || 'Bob') + '/.ssh/config'; const wslConfig = path.join(homedir(), '.ssh', 'config'); results.push( { path: windowsConfig, exists: fs.existsSync(windowsConfig), primary: true }, { path: wslConfig, exists: fs.existsSync(wslConfig), primary: false } ); } else if (IS_WINDOWS) { const windowsConfig = path.join(process.env.USERPROFILE || homedir(), '.ssh', 'config'); results.push({ path: windowsConfig, exists: fs.existsSync(windowsConfig), primary: true }); } else { const linuxConfig = path.join(homedir(), '.ssh', 'config'); results.push({ path: linuxConfig, exists: fs.existsSync(linuxConfig), primary: true }); } return results; } /** * Sync hostname configurations between Windows and WSL * @returns Operation result with success status and message */ export function syncHostnameConfigs(): { success: boolean; message: string; paths?: { wsl: string; windows: string } } { if (!IS_WSL()) { return { success: false, message: 'Hostname config sync only available in WSL environment' }; } const windowsConfigPath = '/mnt/c/Users/' + (process.env.USER || 'Bob') + '/.ssh/config'; const wslConfigPath = path.join(homedir(), '.ssh', 'config'); try { if (fs.existsSync(windowsConfigPath)) { const windowsConfig = fs.readFileSync(windowsConfigPath, 'utf8'); // Ensure WSL .ssh directory exists const wslSshDir = path.dirname(wslConfigPath); if (!fs.existsSync(wslSshDir)) { fs.mkdirSync(wslSshDir, { recursive: true, mode: 0o700 }); } fs.writeFileSync(wslConfigPath, windowsConfig, { mode: 0o600 }); return { success: true, message: 'Successfully synced Windows SSH config to WSL', paths: { wsl: wslConfigPath, windows: windowsConfigPath } }; } else { return { success: false, message: `Windows SSH config not found at ${windowsConfigPath}` }; } } catch (error) { return { success: false, message: `Failed to sync configs: ${error instanceof Error ? error.message : String(error)}` }; } } // Re-export from main module for backwards compatibility export { mountSMBHost, unmountSMBHost, setDbgMessages, getDbgMessages } from './smbcmd.js';