@bobfrankston/mountsmb
Version:
Cross-platform SMB mounting solution for Windows, WSL, and Linux
1,513 lines (1,323 loc) β’ 79.1 kB
text/typescript
#!/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')