@bobfrankston/mountsmb
Version:
Cross-platform SMB mounting solution for Windows, WSL, and Linux
540 lines (465 loc) • 15.2 kB
text/typescript
/**
* 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';