UNPKG

@fiftyten/db-connect

Version:

CLI tool for database connections and DynamoDB operations via AWS Session Manager

635 lines (531 loc) 23.8 kB
import { EC2Client, DescribeInstancesCommand } from '@aws-sdk/client-ec2'; import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'; import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; import { spawn } from 'child_process'; import { createConnection } from 'net'; import chalk from 'chalk'; import { MfaAuthenticator } from './mfa-auth'; export interface ConnectionInfo { instanceId: string; sessionManagerEnabled: boolean; accessMethod: string; region: string; securityGroupId: string; sessionCommand: string; portForwardCommand: string; cliToolCommand: string; sshEnabled?: boolean; keyName?: string; note?: string; } export interface DatabaseInfo { DATABASE_HOST: string; DATABASE_PORT: string; DATABASE_NAME: string; DATABASE_USER: string; DATABASE_SECRET_ARN: string; } export class DatabaseConnector { private ec2Client: EC2Client; private ssmClient: SSMClient; private secretsClient: SecretsManagerClient; private mfaAuth: MfaAuthenticator; private region: string; private mfaAuthenticated: boolean = false; constructor(region: string = 'us-west-1') { this.region = region; this.ec2Client = new EC2Client({ region }); this.ssmClient = new SSMClient({ region }); this.secretsClient = new SecretsManagerClient({ region }); this.mfaAuth = new MfaAuthenticator(region); } /** * Check if a local port is available */ private async isPortAvailable(port: number): Promise<boolean> { return new Promise((resolve) => { const connection = createConnection({ port, host: 'localhost' }); connection.on('connect', () => { connection.destroy(); resolve(false); // Port is in use }); connection.on('error', () => { resolve(true); // Port is available }); }); } /** * Find an available port starting from the given port */ private async findAvailablePort(startPort: number): Promise<number> { for (let port = startPort; port <= startPort + 10; port++) { if (await this.isPortAvailable(port)) { return port; } } throw new Error(`No available ports found in range ${startPort}-${startPort + 10}`); } /** * Handle AWS API calls with automatic MFA authentication */ private async callWithMfaRetry<T>(operation: () => Promise<T>): Promise<T> { try { return await operation(); } catch (error) { // Check if this is an MFA-related error and we haven't already authenticated if (this.mfaAuth.isMfaRequired(error) && !this.mfaAuthenticated) { console.log(chalk.yellow('⚠️ MFA authentication required for AWS access')); // Attempt MFA authentication const credentials = await this.mfaAuth.authenticateWithMfa(); this.mfaAuth.applyCredentials(credentials); // Recreate clients with new credentials const clientConfig = { region: this.region, credentials: { accessKeyId: credentials.accessKeyId, secretAccessKey: credentials.secretAccessKey, sessionToken: credentials.sessionToken } }; this.ec2Client = new EC2Client(clientConfig); this.ssmClient = new SSMClient(clientConfig); this.secretsClient = new SecretsManagerClient(clientConfig); // Mark as authenticated to prevent re-prompting this.mfaAuthenticated = true; // Retry the operation return await operation(); } // Re-throw if not MFA-related or already authenticated throw error; } } /** * Get bastion host instance ID by environment */ private async getBastionInstanceId(environment: string): Promise<string> { try { // First try to get from SSM parameter const connectionInfo = await this.callWithMfaRetry(() => this.getConnectionInfo(environment)); if (connectionInfo.instanceId) { return connectionInfo.instanceId; } } catch (error) { console.log(chalk.yellow('Could not get instance ID from SSM, searching EC2...')); } // Fallback: search EC2 instances by tag const response = await this.callWithMfaRetry(async () => { const command = new DescribeInstancesCommand({ Filters: [ { Name: 'tag:Name', Values: [`indicator-bastion-${environment}-host`] }, { Name: 'instance-state-name', Values: ['running', 'stopped'] } ] }); return await this.ec2Client.send(command); }); if (!response.Reservations || response.Reservations.length === 0) { throw new Error(`No bastion host found for environment: ${environment}`); } const instance = response.Reservations[0].Instances?.[0]; if (!instance || !instance.InstanceId) { throw new Error(`Invalid bastion host instance for environment: ${environment}`); } return instance.InstanceId; } /** * Get connection information from SSM */ private async getConnectionInfo(environment: string): Promise<ConnectionInfo> { const command = new GetParameterCommand({ Name: `/indicator/bastion/${environment}/connection-info` }); const response = await this.ssmClient.send(command); if (!response.Parameter || !response.Parameter.Value) { throw new Error(`Connection info not found for environment: ${environment}`); } return JSON.parse(response.Parameter.Value); } /** * Get database information from SSM */ private async getDatabaseInfo(environment: string, database: string = 'platform'): Promise<DatabaseInfo> { const parameterName = database === 'platform' ? `/indicator/platform-api/${environment}/database-environment-variables` : `/indicator/${database}-api/${environment}/database-environment-variables`; const response = await this.callWithMfaRetry(async () => { const command = new GetParameterCommand({ Name: parameterName }); return await this.ssmClient.send(command); }); if (!response.Parameter || !response.Parameter.Value) { throw new Error(`Database info not found for ${database} in environment: ${environment}`); } return JSON.parse(response.Parameter.Value); } /** * Get database password from Secrets Manager */ async getDatabasePassword(environment: string, database: string = 'platform'): Promise<string> { const dbInfo = await this.getDatabaseInfo(environment, database); const command = new GetSecretValueCommand({ SecretId: dbInfo.DATABASE_SECRET_ARN }); const response = await this.callWithMfaRetry(async () => { return await this.secretsClient.send(command); }); if (!response.SecretString) { throw new Error(`Database password not found in secret: ${dbInfo.DATABASE_SECRET_ARN}`); } const secretValue = JSON.parse(response.SecretString); return secretValue.password || secretValue.PASSWORD; } /** * Create SSH tunnel to database via Session Manager */ async createTunnel(environment: string, database: string = 'platform', localPort: number = 5433): Promise<void> { console.log(chalk.blue('🔗 Creating database tunnel via Session Manager...')); const instanceId = await this.getBastionInstanceId(environment); const dbInfo = await this.getDatabaseInfo(environment, database); console.log(chalk.green('✅ Connection details:')); console.log(` Environment: ${chalk.yellow(environment)}`); console.log(` Database: ${chalk.yellow(database)}`); console.log(` Local port: ${chalk.yellow(localPort)}`); console.log(` Remote database: ${chalk.yellow(dbInfo.DATABASE_HOST + ':' + dbInfo.DATABASE_PORT)}`); console.log(` Database: ${chalk.yellow(dbInfo.DATABASE_NAME)}`); console.log(''); // Check if local port is available console.log(chalk.blue('🔍 Checking local port availability...')); const isAvailable = await this.isPortAvailable(localPort); if (!isAvailable) { console.log(chalk.red(`❌ Port ${localPort} is already in use`)); console.log(''); console.log(chalk.yellow('💡 Solutions:')); console.log(` 1. Use a different port: ${chalk.cyan(`fiftyten-db tunnel ${environment} -d ${database} -p 5433`)}`); console.log(` 2. Find what's using port ${localPort}: ${chalk.gray(`lsof -i :${localPort}`)}`); console.log(` 3. Stop local PostgreSQL if running: ${chalk.gray('brew services stop postgresql')}`); // Try to suggest an available port try { const availablePort = await this.findAvailablePort(localPort + 1); console.log(` 4. Suggested available port: ${chalk.cyan(`fiftyten-db tunnel ${environment} -d ${database} -p ${availablePort}`)}`); } catch { // Ignore if we can't find an available port } throw new Error(`Port ${localPort} is in use. Please use a different port with -p option.`); } console.log(chalk.green('🚀 Starting tunnel...')); console.log(chalk.gray(' Once tunnel is established, connect with:')); console.log(chalk.cyan(` psql -h localhost -p ${localPort} -d ${dbInfo.DATABASE_NAME} -U ${dbInfo.DATABASE_USER}`)); console.log(''); console.log(chalk.gray(' Press Ctrl+C to close the tunnel')); console.log(''); // Start Session Manager port forwarding const args = [ 'ssm', 'start-session', '--target', instanceId, '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost', '--parameters', `host=${dbInfo.DATABASE_HOST},portNumber=${dbInfo.DATABASE_PORT},localPortNumber=${localPort}` ]; const child = spawn('aws', args, { stdio: 'inherit' }); child.on('error', (error) => { console.error(chalk.red('Error starting tunnel:'), error.message); console.log(chalk.yellow('Make sure AWS CLI and Session Manager plugin are installed')); }); child.on('exit', (code) => { if (code !== 0) { console.log(chalk.red(`Tunnel exited with code ${code}`)); } else { console.log(chalk.green('Tunnel closed')); } }); } /** * Connect directly to database via Session Manager */ async connectDatabase(environment: string, database: string = 'platform'): Promise<void> { console.log(chalk.blue('🔗 Connecting to database via Session Manager...')); const instanceId = await this.getBastionInstanceId(environment); const dbInfo = await this.getDatabaseInfo(environment, database); console.log(chalk.green('✅ Connection details:')); console.log(` Environment: ${chalk.yellow(environment)}`); console.log(` Database: ${chalk.yellow(database)}`); console.log(` Database: ${chalk.yellow(dbInfo.DATABASE_HOST + ':' + dbInfo.DATABASE_PORT + '/' + dbInfo.DATABASE_NAME)}`); console.log(` Username: ${chalk.yellow(dbInfo.DATABASE_USER)}`); console.log(''); // Start Session Manager session with database connection const args = [ 'ssm', 'start-session', '--target', instanceId ]; console.log(chalk.green('🚀 Starting Session Manager connection...')); console.log(chalk.gray(' Once connected, run:')); console.log(chalk.cyan(` psql -h ${dbInfo.DATABASE_HOST} -p ${dbInfo.DATABASE_PORT} -d ${dbInfo.DATABASE_NAME} -U ${dbInfo.DATABASE_USER}`)); console.log(''); const child = spawn('aws', args, { stdio: 'inherit' }); child.on('error', (error) => { console.error(chalk.red('Error connecting:'), error.message); console.log(chalk.yellow('Make sure AWS CLI and Session Manager plugin are installed')); }); } /** * SSH into bastion host via Session Manager */ async sshBastion(environment: string): Promise<void> { console.log(chalk.blue('🔗 Connecting to bastion host via Session Manager...')); const instanceId = await this.getBastionInstanceId(environment); console.log(chalk.green('✅ Connection details:')); console.log(` Environment: ${chalk.yellow(environment)}`); console.log(` Instance ID: ${chalk.yellow(instanceId)}`); console.log(''); // Start Session Manager session const args = [ 'ssm', 'start-session', '--target', instanceId ]; console.log(chalk.green('🚀 Starting Session Manager connection...')); console.log(chalk.gray(' No SSH keys required!')); console.log(''); const child = spawn('aws', args, { stdio: 'inherit' }); child.on('error', (error) => { console.error(chalk.red('Error connecting:'), error.message); console.log(chalk.yellow('Make sure AWS CLI and Session Manager plugin are installed')); }); } /** * Show connection information */ async showInfo(environment: string): Promise<void> { console.log(chalk.blue(`📋 Connection Information - ${environment.toUpperCase()}`)); console.log(''); try { const connectionInfo = await this.getConnectionInfo(environment); console.log(chalk.green('🖥️ Bastion Host:')); console.log(` Instance ID: ${chalk.yellow(connectionInfo.instanceId)}`); console.log(` Access Method: ${chalk.yellow(connectionInfo.accessMethod)}`); console.log(` Region: ${chalk.yellow(connectionInfo.region)}`); console.log(''); console.log(chalk.green('🔧 Session Manager Commands:')); console.log(` Connect: ${chalk.cyan(connectionInfo.sessionCommand)}`); console.log(` Port Forward: ${chalk.cyan(connectionInfo.portForwardCommand.replace('DATABASE_ENDPOINT', '<DATABASE_ENDPOINT>'))}`); console.log(''); console.log(chalk.green('⚡ CLI Tool Commands:')); console.log(` Tunnel: ${chalk.cyan(`5010-db tunnel ${environment}`)}`); console.log(` Connect: ${chalk.cyan(`5010-db connect ${environment}`)}`); console.log(` SSH: ${chalk.cyan(`5010-db ssh ${environment}`)}`); console.log(''); if (connectionInfo.sshEnabled) { console.log(chalk.yellow('⚠️ SSH access is enabled (legacy mode)')); console.log(` Key Name: ${connectionInfo.keyName || 'N/A'}`); } else { console.log(chalk.green('✅ Session Manager only (no SSH keys required)')); } if (connectionInfo.note) { console.log(chalk.gray(` Note: ${connectionInfo.note}`)); } } catch (error) { console.error(chalk.red('Error fetching connection info:'), error instanceof Error ? error.message : String(error)); // Try to get basic instance info try { const instanceId = await this.getBastionInstanceId(environment); console.log(chalk.yellow('⚠️ Basic connection info:')); console.log(` Instance ID: ${chalk.yellow(instanceId)}`); console.log(` Manual command: ${chalk.cyan(`aws ssm start-session --target ${instanceId}`)}`); } catch (fallbackError) { console.error(chalk.red('Could not find bastion host for environment:'), environment); } } } /** * List available environments */ async listEnvironments(): Promise<void> { console.log(chalk.blue('📋 Available Environments')); console.log(''); const environments = ['dev', 'main']; for (const env of environments) { try { const instanceId = await this.getBastionInstanceId(env); console.log(chalk.green(`✅ ${env.toUpperCase()}`)); console.log(` Instance: ${chalk.yellow(instanceId)}`); // Check if we can get connection info try { const connectionInfo = await this.getConnectionInfo(env); console.log(` Access: ${chalk.cyan(connectionInfo.accessMethod)}`); } catch { console.log(` Access: ${chalk.cyan('Session Manager')}`); } console.log(` Commands: ${chalk.gray(`5010-db tunnel ${env}, 5010-db connect ${env}`)}`); console.log(''); } catch (error) { console.log(chalk.red(`❌ ${env.toUpperCase()}`)); console.log(` Status: ${chalk.red('Not available')}`); console.log(` Error: ${chalk.gray(error instanceof Error ? error.message : String(error))}`); console.log(''); } } console.log(chalk.gray('Usage examples:')); console.log(chalk.cyan(' fiftyten-db tunnel dev -d platform # Create tunnel to platform database')); console.log(chalk.cyan(' fiftyten-db connect main -d copytrading # Connect to copytrading database')); console.log(chalk.cyan(' fiftyten-db ssh dev # SSH into dev bastion host')); console.log(chalk.cyan(' fiftyten-db psql dev -d platform # Connect with automatic password')); } /** * Discover available databases for an environment */ async discoverDatabases(environment: string): Promise<string[]> { console.log(chalk.blue(`🔍 Discovering available databases for ${environment.toUpperCase()}...`)); console.log(''); const databases = ['platform', 'copytrading']; // Common database types const available: string[] = []; for (const database of databases) { try { await this.getDatabaseInfo(environment, database); available.push(database); console.log(chalk.green(`✅ ${database}`)); console.log(` Command: ${chalk.cyan(`fiftyten-db psql ${environment} -d ${database}`)}`); } catch (error) { console.log(chalk.gray(`⚪ ${database} (not configured)`)); } } console.log(''); if (available.length === 0) { console.log(chalk.yellow('⚠️ No databases found for this environment')); } else { console.log(chalk.green(`Found ${available.length} available database(s)`)); } return available; } /** * Connect to database with automatic tunnel and password retrieval */ async connectWithPassword(environment: string, database: string = 'platform', localPort: number = 5433): Promise<void> { console.log(chalk.blue('🔗 Setting up complete database connection...')); try { // Get database info first, then password (to avoid duplicate MFA) const dbInfo = await this.getDatabaseInfo(environment, database); const password = await this.getDatabasePassword(environment, database); console.log(chalk.green('✅ Retrieved database credentials')); console.log(` Environment: ${chalk.yellow(environment)}`); console.log(` Application: ${chalk.yellow(database)}`); console.log(` Database: ${chalk.yellow(dbInfo.DATABASE_NAME)}`); console.log(` User: ${chalk.yellow(dbInfo.DATABASE_USER)}`); console.log(` Password: ${chalk.yellow(password)}`); console.log(''); console.log(chalk.gray('💡 DATABASE_URL for manual configuration:')); console.log(chalk.cyan(`DATABASE_URL=postgres://${dbInfo.DATABASE_USER}:${password}@localhost:${localPort}/${dbInfo.DATABASE_NAME}`)); console.log(''); // Check if local port is available console.log(chalk.blue('🔍 Checking local port availability...')); const isAvailable = await this.isPortAvailable(localPort); if (!isAvailable) { console.log(chalk.red(`❌ Port ${localPort} is already in use`)); console.log(''); console.log(chalk.yellow('💡 Solutions:')); console.log(` 1. Use a different port: ${chalk.cyan(`fiftyten-db psql ${environment} -d ${database} -p 5433`)}`); console.log(` 2. Find what's using port ${localPort}: ${chalk.gray(`lsof -i :${localPort}`)}`); console.log(` 3. Stop local PostgreSQL if running: ${chalk.gray('brew services stop postgresql')}`); // Try to suggest an available port try { const availablePort = await this.findAvailablePort(localPort + 1); console.log(` 4. Suggested available port: ${chalk.cyan(`fiftyten-db psql ${environment} -d ${database} -p ${availablePort}`)}`); } catch { // Ignore if we can't find an available port } throw new Error(`Port ${localPort} is in use. Please use a different port with -p option.`); } // Create tunnel in background console.log(chalk.blue('🚀 Creating database tunnel...')); const instanceId = await this.getBastionInstanceId(environment); // Start Session Manager port forwarding const args = [ 'ssm', 'start-session', '--target', instanceId, '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost', '--parameters', `host=${dbInfo.DATABASE_HOST},portNumber=${dbInfo.DATABASE_PORT},localPortNumber=${localPort}` ]; const child = spawn('aws', args, { stdio: ['ignore', 'pipe', 'pipe'] }); // Wait for tunnel to establish console.log(chalk.gray(' Waiting for tunnel to establish...')); return new Promise((resolve, reject) => { let tunnelReady = false; const checkTunnel = () => { setTimeout(() => { if (!tunnelReady) { // Set password as environment variable for psql process.env.PGPASSWORD = password; console.log(chalk.green('✅ Tunnel established! Connecting to database...')); console.log(''); // Launch psql with direct connection to the specific database const psqlArgs = [ '-h', 'localhost', '-p', localPort.toString(), '-d', dbInfo.DATABASE_NAME, '-U', dbInfo.DATABASE_USER ]; const psql = spawn('psql', psqlArgs, { stdio: 'inherit' }); psql.on('exit', (code) => { // Clean up password from environment delete process.env.PGPASSWORD; // Terminate the tunnel child.kill(); if (code === 0) { console.log(chalk.green('Database session ended')); resolve(); } else { reject(new Error(`psql exited with code ${code}`)); } }); psql.on('error', (error) => { delete process.env.PGPASSWORD; child.kill(); if (error.message.includes('ENOENT')) { reject(new Error('psql command not found. Please install PostgreSQL client.')); } else { reject(error); } }); tunnelReady = true; } }, 3000); // Wait 3 seconds for tunnel to establish }; child.stdout?.on('data', (data) => { const output = data.toString(); if (output.includes('Waiting for connections') || output.includes('Port forwarding session started')) { if (!tunnelReady) { checkTunnel(); } } }); child.on('error', (error) => { delete process.env.PGPASSWORD; console.error(chalk.red('Error starting tunnel:'), error.message); reject(error); }); // Fallback - if no specific output detected, try after 5 seconds setTimeout(() => { if (!tunnelReady) { checkTunnel(); } }, 5000); }); } catch (error) { console.error(chalk.red('Error setting up database connection:'), error instanceof Error ? error.message : String(error)); throw error; } } }