import { EventEmitter } from 'events';
import { v4 as uuidv4 } from 'uuid';
import { WebSocket } from 'ws';
// AWS SDK v3 types and imports - made optional to handle missing dependencies
interface SSMClientType {
send(command: any): Promise<any>;
}
interface EC2ClientType {
send(command: any): Promise<any>;
}
interface STSClientType {
send(command: any): Promise<any>;
}
interface S3ClientType {
send(command: any): Promise<any>;
}
interface CloudWatchLogsClientType {
send(command: any): Promise<any>;
}
interface StartSessionCommandInput {
Target: string;
DocumentName?: string;
Parameters?: Record<string, string[]>;
}
interface StartSessionCommandOutput {
SessionId?: string;
Token?: string;
StreamUrl?: string;
}
interface SendCommandCommandInput {
DocumentName: string;
Parameters?: Record<string, string[]>;
Comment?: string;
TimeoutSeconds?: number;
MaxConcurrency?: string;
MaxErrors?: string;
InstanceIds?: string[];
Targets?: Array<{
key: string;
values: string[];
}>;
OutputS3BucketName?: string;
OutputS3KeyPrefix?: string;
CloudWatchOutputConfig?: {
cloudWatchLogGroupName: string;
cloudWatchOutputEnabled: boolean;
};
}
interface LogEvent {
timestamp: number;
message: string;
}
type AwsCredentialProvider = () => Promise<{
accessKeyId: string;
secretAccessKey: string;
sessionToken?: string;
}>;
enum CommandStatus {
Success = 'Success',
Failed = 'Failed',
Cancelled = 'Cancelled',
TimedOut = 'TimedOut',
InProgress = 'InProgress',
Pending = 'Pending'
}
enum SessionStatus {
Connected = 'Connected',
Connecting = 'Connecting',
Disconnected = 'Disconnected',
Failed = 'Failed',
Terminated = 'Terminated',
Terminating = 'Terminating'
}
enum SessionState {
Active = 'Active',
History = 'History'
}
let SSMClient: any, StartSessionCommand: any, TerminateSessionCommand: any, DescribeSessionsCommand: any,
ResumeSessionCommand: any, SendCommandCommand: any, ListCommandInvocationsCommand: any, GetCommandInvocationCommand: any,
DescribeInstanceInformationCommand: any, GetParametersCommand: any, PutParameterCommand: any, ListDocumentsCommand: any,
DescribeDocumentCommand: any, CreateDocumentCommand: any, UpdateDocumentCommand: any, DeleteDocumentCommand: any,
SessionStatusValues: any, SessionStateValues: any, DocumentStatusValues: any, CommandStatusValues: any;
try {
const ssmModule = require('@aws-sdk/client-ssm');
SSMClient = ssmModule.SSMClient;
StartSessionCommand = ssmModule.StartSessionCommand;
TerminateSessionCommand = ssmModule.TerminateSessionCommand;
DescribeSessionsCommand = ssmModule.DescribeSessionsCommand;
ResumeSessionCommand = ssmModule.ResumeSessionCommand;
SendCommandCommand = ssmModule.SendCommandCommand;
ListCommandInvocationsCommand = ssmModule.ListCommandInvocationsCommand;
GetCommandInvocationCommand = ssmModule.GetCommandInvocationCommand;
DescribeInstanceInformationCommand = ssmModule.DescribeInstanceInformationCommand;
GetParametersCommand = ssmModule.GetParametersCommand;
PutParameterCommand = ssmModule.PutParameterCommand;
ListDocumentsCommand = ssmModule.ListDocumentsCommand;
DescribeDocumentCommand = ssmModule.DescribeDocumentCommand;
CreateDocumentCommand = ssmModule.CreateDocumentCommand;
UpdateDocumentCommand = ssmModule.UpdateDocumentCommand;
DeleteDocumentCommand = ssmModule.DeleteDocumentCommand;
SessionStatusValues = ssmModule.SessionStatus || {};
SessionStateValues = ssmModule.SessionState || {};
DocumentStatusValues = ssmModule.DocumentStatus || {};
CommandStatusValues = ssmModule.CommandStatus || {};
} catch (error) {
console.warn('@aws-sdk/client-ssm not available, AWS SSM functionality will be disabled');
}
// EC2 SDK imports - made optional
let EC2Client: any, DescribeInstancesCommand: any, DescribeTagsCommand: any;
try {
const ec2Module = require('@aws-sdk/client-ec2');
EC2Client = ec2Module.EC2Client;
DescribeInstancesCommand = ec2Module.DescribeInstancesCommand;
DescribeTagsCommand = ec2Module.DescribeTagsCommand;
} catch (error) {
console.warn('@aws-sdk/client-ec2 not available, EC2 functionality will be disabled');
}
// STS SDK imports - made optional
let STSClient: any, AssumeRoleCommand: any, GetCallerIdentityCommand: any, GetSessionTokenCommand: any;
try {
const stsModule = require('@aws-sdk/client-sts');
STSClient = stsModule.STSClient;
AssumeRoleCommand = stsModule.AssumeRoleCommand;
GetCallerIdentityCommand = stsModule.GetCallerIdentityCommand;
GetSessionTokenCommand = stsModule.GetSessionTokenCommand;
} catch (error) {
console.warn('@aws-sdk/client-sts not available, STS functionality will be disabled');
}
// S3 SDK imports - made optional
let S3Client: any, HeadBucketCommand: any, PutObjectCommand: any, GetObjectCommand: any;
try {
const s3Module = require('@aws-sdk/client-s3');
S3Client = s3Module.S3Client;
HeadBucketCommand = s3Module.HeadBucketCommand;
PutObjectCommand = s3Module.PutObjectCommand;
GetObjectCommand = s3Module.GetObjectCommand;
} catch (error) {
console.warn('@aws-sdk/client-s3 not available, S3 functionality will be disabled');
}
// CloudWatch Logs SDK imports - made optional
let CloudWatchLogsClient: any, CreateLogGroupCommand: any, CreateLogStreamCommand: any,
PutLogEventsCommand: any, DescribeLogGroupsCommand: any;
try {
const logsModule = require('@aws-sdk/client-cloudwatch-logs');
CloudWatchLogsClient = logsModule.CloudWatchLogsClient;
CreateLogGroupCommand = logsModule.CreateLogGroupCommand;
CreateLogStreamCommand = logsModule.CreateLogStreamCommand;
PutLogEventsCommand = logsModule.PutLogEventsCommand;
DescribeLogGroupsCommand = logsModule.DescribeLogGroupsCommand;
} catch (error) {
console.warn('@aws-sdk/client-cloudwatch-logs not available, CloudWatch Logs functionality will be disabled');
}
// AWS Credential Providers - made optional
let fromEnv: any, fromIni: any, fromInstanceMetadata: any, fromContainerMetadata: any,
fromNodeProviderChain: any, fromWebToken: any;
try {
const credModule = require('@aws-sdk/credential-providers');
fromEnv = credModule.fromEnv;
fromIni = credModule.fromIni;
fromInstanceMetadata = credModule.fromInstanceMetadata;
fromContainerMetadata = credModule.fromContainerMetadata;
fromNodeProviderChain = credModule.fromNodeProviderChain;
fromWebToken = credModule.fromWebToken;
} catch (error) {
console.warn('@aws-sdk/credential-providers not available, credential provider functionality will be disabled');
}
import {
AWSSSMConnectionOptions,
AWSSSMSession,
AWSSSMTarget,
AWSSSMDocument,
AWSSSMCommandExecution,
AWSSSMPortForwardingSession,
AWSSSMError,
AWSSSMSessionLog,
AWSSSMSessionManagerConfig,
AWSCredentials,
AWSSTSAssumedRole,
AWSSSMRetryConfig,
ConsoleOutput,
ConsoleSession,
ConsoleEvent
} from '../types/index.js';
import { Logger } from '../utils/logger.js';
import { RetryManager } from '../core/RetryManager.js';
import { ErrorRecovery } from '../core/ErrorRecovery.js';
/**
* AWS Systems Manager Session Manager Protocol Implementation
*
* This class provides comprehensive support for AWS SSM Session Manager including:
* - Interactive shell sessions
* - Port forwarding tunnels
* - Command execution
* - Session logging to S3/CloudWatch
* - Multi-region support
* - IAM role assumption and credential management
* - Advanced retry logic with exponential backoff
* - Session recording and audit capabilities
*/
export class AWSSSMProtocol extends EventEmitter {
private ssmClient!: SSMClientType;
private ec2Client!: EC2ClientType;
private stsClient!: STSClientType;
private s3Client?: S3ClientType;
private cloudWatchLogsClient?: CloudWatchLogsClientType;
private logger: Logger;
private retryManager: RetryManager;
private errorRecovery: ErrorRecovery;
private sessions: Map<string, AWSSSMSession> = new Map();
private portForwardingSessions: Map<string, AWSSSMPortForwardingSession> = new Map();
private webSockets: Map<string, WebSocket> = new Map();
private config: AWSSSMSessionManagerConfig;
private connectionOptions: AWSSSMConnectionOptions;
private credentials?: AWSCredentials;
private assumedRole?: AWSSTSAssumedRole;
private sessionLogs: Map<string, AWSSSMSessionLog[]> = new Map();
private activeCommands: Map<string, AWSSSMCommandExecution> = new Map();
// Health monitoring
private healthCheckInterval?: NodeJS.Timeout;
private connectionHealthy: boolean = false;
private lastHealthCheck: Date = new Date();
private reconnectAttempts: number = 0;
constructor(options: AWSSSMConnectionOptions, config?: Partial<AWSSSMSessionManagerConfig>) {
super();
this.connectionOptions = options;
this.logger = new Logger(`AWSSSMProtocol-${options.region}`);
this.retryManager = new RetryManager();
this.errorRecovery = new ErrorRecovery();
// Initialize configuration with defaults
this.config = this.initializeConfig(config);
// Initialize AWS clients with configuration
this.initializeAWSClients();
// Setup error recovery handlers
this.setupErrorRecoveryHandlers();
// Start health monitoring
this.startHealthMonitoring();
}
/**
* Initialize configuration with sensible defaults
*/
private initializeConfig(config?: Partial<AWSSSMSessionManagerConfig>): AWSSSMSessionManagerConfig {
return {
regionConfig: {
defaultRegion: this.connectionOptions.region,
allowedRegions: [this.connectionOptions.region],
regionPriority: [this.connectionOptions.region]
},
authConfig: {
credentialChain: ['environment', 'profile', 'iam-role', 'instance-profile', 'ecs-task-role'],
assumeRoleConfig: this.connectionOptions.roleArn ? {
roleArn: this.connectionOptions.roleArn,
roleSessionName: this.connectionOptions.roleSessionName || `ssm-session-${Date.now()}`,
externalId: this.connectionOptions.externalId,
durationSeconds: this.connectionOptions.durationSeconds || 3600
} : undefined,
mfaConfig: this.connectionOptions.mfaSerial ? {
mfaSerial: this.connectionOptions.mfaSerial,
tokenCodeCallback: async () => this.connectionOptions.mfaTokenCode || ''
} : undefined
},
sessionConfig: {
defaultDocumentName: 'SSM-SessionManagerRunShell',
defaultShellProfile: this.connectionOptions.shellProfile || 'bash',
defaultWorkingDirectory: this.connectionOptions.workingDirectory,
defaultEnvironmentVariables: this.connectionOptions.environmentVariables || {},
sessionTimeout: this.connectionOptions.sessionTimeout || 1800000, // 30 minutes
maxSessionDuration: this.connectionOptions.maxSessionDuration || 7200000, // 2 hours
keepAliveInterval: this.connectionOptions.keepAliveInterval || 30000, // 30 seconds
maxConcurrentSessions: 10
},
loggingConfig: {
enabled: !!(this.connectionOptions.s3BucketName || this.connectionOptions.cloudWatchLogGroupName),
s3Config: this.connectionOptions.s3BucketName ? {
bucketName: this.connectionOptions.s3BucketName,
keyPrefix: this.connectionOptions.s3KeyPrefix || 'ssm-session-logs',
encryptionEnabled: this.connectionOptions.s3EncryptionEnabled !== false
} : undefined,
cloudWatchConfig: this.connectionOptions.cloudWatchLogGroupName ? {
logGroupName: this.connectionOptions.cloudWatchLogGroupName,
encryptionEnabled: this.connectionOptions.cloudWatchEncryptionEnabled !== false,
streamingEnabled: this.connectionOptions.cloudWatchStreamingEnabled !== false
} : undefined,
localLogging: {
enabled: true,
logLevel: 'INFO',
maxFileSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5
}
},
connectionConfig: {
connectionTimeout: this.connectionOptions.connectionTimeout || 30000,
retryAttempts: this.connectionOptions.retryAttempts || 3,
backoffMultiplier: this.connectionOptions.backoffMultiplier || 2,
jitterEnabled: this.connectionOptions.jitterEnabled !== false,
customEndpoints: this.connectionOptions.customEndpoint ? {
ssm: this.connectionOptions.customEndpoint
} : undefined,
useIMDSv2: this.connectionOptions.useIMDSv2 !== false
},
securityConfig: {
sessionRecordingEnabled: true,
complianceMode: false,
encryptionInTransit: true,
encryptionAtRest: true
},
monitoringConfig: {
metricsEnabled: true,
cloudWatchMetrics: true,
customMetrics: false,
healthCheckInterval: 60000, // 1 minute
alertingEnabled: false
},
...config
};
}
/**
* Initialize AWS SDK clients with appropriate configuration
*/
private initializeAWSClients(): void {
const clientConfig = {
region: this.connectionOptions.region,
maxAttempts: this.config.connectionConfig.retryAttempts,
retryMode: 'adaptive' as const,
credentials: this.getCredentialProvider(),
endpoint: this.config.connectionConfig.customEndpoints?.ssm,
requestHandler: {
connectionTimeout: this.config.connectionConfig.connectionTimeout,
socketTimeout: this.config.connectionConfig.connectionTimeout * 2
}
};
if (!SSMClient) {
throw new Error('@aws-sdk/client-ssm is required but not available');
}
this.ssmClient = new SSMClient(clientConfig);
this.ec2Client = new EC2Client(clientConfig);
this.stsClient = new STSClient(clientConfig);
if (this.config.loggingConfig.s3Config && S3Client) {
this.s3Client = new S3Client(clientConfig);
}
if (this.config.loggingConfig.cloudWatchConfig && CloudWatchLogsClient) {
this.cloudWatchLogsClient = new CloudWatchLogsClient(clientConfig);
}
}
/**
* Convert environment variables to SSM parameters format
*/
private convertEnvironmentToParameters(env: Record<string, string>): Record<string, string[]> {
const parameters: Record<string, string[]> = {};
for (const [key, value] of Object.entries(env)) {
parameters[key] = [value];
}
return parameters;
}
/**
* Convert SSM parameters to environment variables format
*/
private convertParametersToEnvironment(params: Record<string, string[]>): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, values] of Object.entries(params)) {
env[key] = values[0] || '';
}
return env;
}
/**
* Get appropriate credential provider based on configuration
*/
private getCredentialProvider(): AwsCredentialProvider {
// If explicit credentials provided
if (this.connectionOptions.accessKeyId && this.connectionOptions.secretAccessKey) {
return async () => ({
accessKeyId: this.connectionOptions.accessKeyId!,
secretAccessKey: this.connectionOptions.secretAccessKey!,
sessionToken: this.connectionOptions.sessionToken
});
}
// If profile specified
if (this.connectionOptions.profile) {
return fromIni({ profile: this.connectionOptions.profile });
}
// Use credential chain based on configuration
const providers: AwsCredentialProvider[] = [];
for (const providerType of this.config.authConfig.credentialChain) {
switch (providerType) {
case 'environment':
providers.push(fromEnv());
break;
case 'profile':
providers.push(fromIni());
break;
case 'instance-profile':
providers.push(fromInstanceMetadata());
break;
case 'ecs-task-role':
providers.push(fromContainerMetadata());
break;
case 'web-identity':
providers.push(fromWebToken());
break;
}
}
return fromNodeProviderChain({
providers
});
}
/**
* Setup error recovery event handlers
*/
private setupErrorRecoveryHandlers(): void {
this.errorRecovery.on('recovery-attempted', (data) => {
this.logger.info(`SSM error recovery attempted: ${data.strategy}`);
this.emit('recovery-attempted', data);
});
this.errorRecovery.on('degradation-enabled', (data) => {
this.logger.warn(`SSM degraded mode enabled: ${data.reason}`);
this.emit('degradation-enabled', data);
});
this.errorRecovery.on('require-reauth', async (data) => {
this.logger.warn('SSM re-authentication required');
await this.refreshCredentials();
this.emit('require-reauth', data);
});
}
/**
* Start health monitoring for connections and sessions
*/
private startHealthMonitoring(): void {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
}
this.healthCheckInterval = setInterval(async () => {
await this.performHealthCheck();
}, this.config.monitoringConfig.healthCheckInterval);
}
/**
* Perform comprehensive health check
*/
private async performHealthCheck(): Promise<void> {
try {
// Test basic connectivity
const identity = await this.stsClient.send(new GetCallerIdentityCommand({}));
this.connectionHealthy = true;
this.lastHealthCheck = new Date();
this.reconnectAttempts = 0;
this.logger.debug(`Health check passed - Account: ${identity.Account}, User: ${identity.Arn}`);
// Check active sessions
for (const [sessionId, session] of Array.from(this.sessions.entries())) {
if (session.status === 'Connected' || session.status === 'Connecting') {
await this.checkSessionHealth(sessionId);
}
}
this.emit('health-check', { status: 'healthy', timestamp: this.lastHealthCheck });
} catch (error) {
this.connectionHealthy = false;
this.reconnectAttempts++;
this.logger.error(`Health check failed (attempt ${this.reconnectAttempts}):`, error);
this.emit('health-check', {
status: 'unhealthy',
error,
timestamp: new Date(),
reconnectAttempts: this.reconnectAttempts
});
// Attempt recovery if configured
if (this.reconnectAttempts <= this.config.connectionConfig.retryAttempts) {
await this.attemptReconnection();
}
}
}
/**
* Check health of a specific session
*/
private async checkSessionHealth(sessionId: string): Promise<void> {
try {
const response = await this.ssmClient.send(new DescribeSessionsCommand({
State: SessionStateValues.Active || 'Active',
Filters: [{
key: 'SessionId', // Use string instead of enum since AWS SDK is optional
value: sessionId
}]
}));
const sessionInfo = response.Sessions?.find((s: any) => s.SessionId === sessionId);
if (!sessionInfo || sessionInfo.Status !== (SessionStatusValues.Connected || 'Connected')) {
await this.handleSessionDisconnect(sessionId, 'Health check failed');
}
} catch (error) {
this.logger.error(`Session health check failed for ${sessionId}:`, error);
await this.handleSessionDisconnect(sessionId, `Health check error: ${error}`);
}
}
/**
* Attempt to reconnect and recover sessions
*/
private async attemptReconnection(): Promise<void> {
try {
this.logger.info(`Attempting reconnection (${this.reconnectAttempts}/${this.config.connectionConfig.retryAttempts})`);
// Refresh credentials if needed
await this.refreshCredentials();
// Re-initialize clients
this.initializeAWSClients();
// Attempt to reconnect active sessions
for (const [sessionId, session] of Array.from(this.sessions.entries())) {
if (session.status === 'Disconnected' || session.status === 'Failed') {
await this.attemptSessionRecovery(sessionId);
}
}
} catch (error) {
this.logger.error('Reconnection attempt failed:', error);
}
}
/**
* Refresh AWS credentials (for assume role scenarios)
*/
private async refreshCredentials(): Promise<void> {
if (this.config.authConfig.assumeRoleConfig) {
try {
const assumeRoleResponse = await this.stsClient.send(new AssumeRoleCommand({
RoleArn: this.config.authConfig.assumeRoleConfig.roleArn,
RoleSessionName: this.config.authConfig.assumeRoleConfig.roleSessionName,
ExternalId: this.config.authConfig.assumeRoleConfig.externalId,
DurationSeconds: this.config.authConfig.assumeRoleConfig.durationSeconds,
Policy: this.config.authConfig.assumeRoleConfig.policy,
PolicyArns: this.config.authConfig.assumeRoleConfig.policyArns?.map(arn => ({ arn })),
SerialNumber: this.config.authConfig.mfaConfig?.mfaSerial,
TokenCode: this.config.authConfig.mfaConfig ?
await this.config.authConfig.mfaConfig.tokenCodeCallback() : undefined
}));
if (assumeRoleResponse.Credentials) {
this.credentials = {
accessKeyId: assumeRoleResponse.Credentials.AccessKeyId!,
secretAccessKey: assumeRoleResponse.Credentials.SecretAccessKey!,
sessionToken: assumeRoleResponse.Credentials.SessionToken,
expiration: assumeRoleResponse.Credentials.Expiration
};
this.assumedRole = {
credentials: this.credentials,
assumedRoleUser: assumeRoleResponse.AssumedRoleUser!,
packedPolicySize: assumeRoleResponse.PackedPolicySize,
sourceIdentity: assumeRoleResponse.SourceIdentity
};
this.logger.info('Credentials refreshed successfully');
}
} catch (error) {
this.logger.error('Failed to refresh credentials:', error);
throw error;
}
}
}
/**
* Start an interactive SSM session
*/
async startSession(options: Partial<AWSSSMConnectionOptions> = {}): Promise<string> {
const sessionOptions = { ...this.connectionOptions, ...options };
if (!sessionOptions.instanceId) {
throw new Error('Instance ID is required for SSM sessions');
}
const sessionId = uuidv4();
try {
// Validate instance accessibility
await this.validateTarget(sessionOptions.instanceId, sessionOptions.targetType || 'instance');
// Prepare session parameters
const sessionParams: StartSessionCommandInput = {
Target: sessionOptions.instanceId,
DocumentName: sessionOptions.documentName || this.config.sessionConfig.defaultDocumentName,
Parameters: this.convertEnvironmentToParameters({
...this.config.sessionConfig.defaultEnvironmentVariables,
...sessionOptions.environmentVariables
})
};
// Start the session
const response = await this.retryManager.executeWithRetry(
async () => await this.ssmClient.send(new StartSessionCommand(sessionParams)),
{
sessionId,
operationName: 'start_ssm_session',
strategyName: 'ssm',
context: { instanceId: sessionOptions.instanceId, documentName: sessionParams.DocumentName }
}
) as StartSessionCommandOutput;
// Create session object
const session: AWSSSMSession = {
sessionId: response.SessionId!,
sessionToken: response.Token!,
tokenValue: response.Token!,
streamUrl: response.StreamUrl!,
instanceId: sessionOptions.instanceId,
targetType: sessionOptions.targetType || 'instance',
documentName: sessionParams.DocumentName!,
parameters: sessionParams.Parameters || {},
status: 'Connecting',
creationDate: new Date(),
lastAccessedDate: new Date(),
owner: 'session-manager',
sessionTimeout: this.config.sessionConfig.sessionTimeout,
maxSessionDuration: this.config.sessionConfig.maxSessionDuration,
shellProfile: sessionOptions.shellProfile,
workingDirectory: sessionOptions.workingDirectory,
environmentVariables: this.convertParametersToEnvironment(sessionParams.Parameters || {}),
region: this.connectionOptions.region,
accountId: await this.getAccountId(),
tags: sessionOptions.tags || {},
complianceInfo: {
recordingEnabled: this.config.securityConfig.sessionRecordingEnabled,
encryptionEnabled: this.config.securityConfig.encryptionInTransit,
retentionPolicy: 'default',
auditLogEnabled: this.config.loggingConfig.enabled
}
};
// Add S3 logging if configured
if (this.config.loggingConfig.s3Config) {
session.s3OutputLocation = {
outputS3BucketName: this.config.loggingConfig.s3Config.bucketName,
outputS3KeyPrefix: this.config.loggingConfig.s3Config.keyPrefix,
outputS3Region: this.connectionOptions.region
};
}
// Add CloudWatch logging if configured
if (this.config.loggingConfig.cloudWatchConfig) {
session.cloudWatchOutputConfig = {
cloudWatchLogGroupName: this.config.loggingConfig.cloudWatchConfig.logGroupName,
cloudWatchEncryptionEnabled: this.config.loggingConfig.cloudWatchConfig.encryptionEnabled
};
}
this.sessions.set(sessionId, session);
// Initialize WebSocket connection
await this.initializeWebSocketConnection(sessionId, response.StreamUrl!);
// Setup session logging
await this.setupSessionLogging(sessionId);
// Start session monitoring
this.startSessionMonitoring(sessionId);
this.logger.info(`SSM session started: ${sessionId} for instance ${sessionOptions.instanceId}`);
this.emit('session-started', { sessionId, instanceId: sessionOptions.instanceId });
return sessionId;
} catch (error) {
this.logger.error(`Failed to start SSM session:`, error);
await this.handleSessionError(sessionId, error as Error);
throw this.createSSMError(error as Error, 'StartSessionFailed', true);
}
}
/**
* Start a port forwarding session
*/
async startPortForwardingSession(targetId: string, portNumber: number, localPortNumber?: number): Promise<string> {
const sessionId = uuidv4();
try {
// Validate target
await this.validateTarget(targetId, 'instance');
const actualLocalPort = localPortNumber || portNumber;
// Start port forwarding session
const sessionParams: StartSessionCommandInput = {
Target: targetId,
DocumentName: 'AWS-StartPortForwardingSession',
Parameters: {
portNumber: [portNumber.toString()],
localPortNumber: [actualLocalPort.toString()]
}
};
const response = await this.retryManager.executeWithRetry(
async () => await this.ssmClient.send(new StartSessionCommand(sessionParams)),
{
sessionId,
operationName: 'start_port_forwarding',
strategyName: 'ssm',
context: { targetId, portNumber, localPortNumber: actualLocalPort }
}
) as StartSessionCommandOutput;
// Create port forwarding session object
const portForwardingSession: AWSSSMPortForwardingSession = {
sessionId: response.SessionId!,
targetId,
targetType: 'instance',
portNumber,
localPortNumber: actualLocalPort,
protocol: 'TCP',
status: 'Connecting',
creationDate: new Date(),
lastAccessedDate: new Date(),
owner: 'session-manager',
sessionToken: response.Token!,
streamUrl: response.StreamUrl!,
region: this.connectionOptions.region,
tags: this.connectionOptions.tags || {}
};
this.portForwardingSessions.set(sessionId, portForwardingSession);
// Initialize WebSocket connection for port forwarding
await this.initializeWebSocketConnection(sessionId, response.StreamUrl!, true);
this.logger.info(`Port forwarding session started: ${sessionId} (${targetId}:${portNumber} -> localhost:${actualLocalPort})`);
this.emit('port-forwarding-started', { sessionId, targetId, portNumber, localPortNumber: actualLocalPort });
return sessionId;
} catch (error) {
this.logger.error(`Failed to start port forwarding session:`, error);
throw this.createSSMError(error as Error, 'StartPortForwardingFailed', true);
}
}
/**
* Send command to multiple targets
*/
async sendCommand(documentName: string, parameters: Record<string, string[]>, targets?: AWSSSMTarget[]): Promise<string> {
const commandId = uuidv4();
try {
const commandInput: SendCommandCommandInput = {
DocumentName: documentName,
Parameters: parameters,
Comment: `Command executed via SSM Protocol - ${new Date().toISOString()}`,
TimeoutSeconds: this.connectionOptions.sessionTimeout ? Math.floor(this.connectionOptions.sessionTimeout / 1000) : 3600,
MaxConcurrency: '10',
MaxErrors: '1'
};
// Set targets
if (targets && targets.length > 0) {
if (targets.every(t => t.type === 'instance')) {
commandInput.InstanceIds = targets.map(t => t.id);
} else {
commandInput.Targets = targets.map(t => ({
key: t.type === 'tag' ? `tag:${t.name}` : t.type,
values: [t.id]
}));
}
} else if (this.connectionOptions.instanceId) {
commandInput.InstanceIds = [this.connectionOptions.instanceId];
}
// Add logging configuration
if (this.config.loggingConfig.s3Config) {
commandInput.OutputS3BucketName = this.config.loggingConfig.s3Config.bucketName;
commandInput.OutputS3KeyPrefix = this.config.loggingConfig.s3Config.keyPrefix;
}
if (this.config.loggingConfig.cloudWatchConfig) {
commandInput.CloudWatchOutputConfig = {
cloudWatchLogGroupName: this.config.loggingConfig.cloudWatchConfig.logGroupName,
cloudWatchOutputEnabled: true
};
}
const response = await this.retryManager.executeWithRetry(
async () => await this.ssmClient.send(new SendCommandCommand(commandInput)),
{
sessionId: commandId,
operationName: 'send_command',
strategyName: 'ssm',
context: { documentName, parametersCount: Object.keys(parameters).length }
}
);
// Create command execution object
const commandExecution: AWSSSMCommandExecution = {
commandId: response.Command!.CommandId!,
documentName,
parameters,
instanceIds: commandInput.InstanceIds,
targets: commandInput.Targets,
requestedDateTime: new Date(),
status: 'Pending',
statusDetails: 'Command sent successfully',
outputS3Region: this.connectionOptions.region,
outputS3BucketName: commandInput.OutputS3BucketName,
outputS3KeyPrefix: commandInput.OutputS3KeyPrefix,
maxConcurrency: commandInput.MaxConcurrency,
maxErrors: commandInput.MaxErrors,
timeoutSeconds: commandInput.TimeoutSeconds,
cloudWatchOutputConfig: commandInput.CloudWatchOutputConfig
};
this.activeCommands.set(commandId, commandExecution);
// Start monitoring command execution
this.monitorCommandExecution(commandId);
this.logger.info(`Command sent: ${commandId} (${documentName})`);
this.emit('command-sent', { commandId, documentName });
return commandId;
} catch (error) {
this.logger.error(`Failed to send command:`, error);
throw this.createSSMError(error as Error, 'SendCommandFailed', true);
}
}
/**
* Initialize WebSocket connection for session
*/
private async initializeWebSocketConnection(sessionId: string, streamUrl: string, isPortForwarding: boolean = false): Promise<void> {
try {
const ws = new WebSocket(streamUrl);
ws.on('open', () => {
this.logger.debug(`WebSocket connection opened for session: ${sessionId}`);
if (isPortForwarding) {
const session = this.portForwardingSessions.get(sessionId);
if (session) {
session.status = 'Connected';
this.portForwardingSessions.set(sessionId, session);
}
} else {
const session = this.sessions.get(sessionId);
if (session) {
session.status = 'Connected';
this.sessions.set(sessionId, session);
}
}
this.emit('websocket-connected', { sessionId, isPortForwarding });
});
ws.on('message', (data: Buffer) => {
this.handleWebSocketMessage(sessionId, data, isPortForwarding);
});
ws.on('close', (code: number, reason: Buffer) => {
this.logger.debug(`WebSocket closed for session ${sessionId}: ${code} - ${reason.toString()}`);
this.handleWebSocketClose(sessionId, code, reason.toString(), isPortForwarding);
});
ws.on('error', (error: Error) => {
this.logger.error(`WebSocket error for session ${sessionId}:`, error);
this.handleWebSocketError(sessionId, error, isPortForwarding);
});
this.webSockets.set(sessionId, ws);
} catch (error) {
this.logger.error(`Failed to initialize WebSocket for session ${sessionId}:`, error);
throw error;
}
}
/**
* Handle WebSocket messages
*/
private async handleWebSocketMessage(sessionId: string, data: Buffer, isPortForwarding: boolean): Promise<void> {
try {
const message = data.toString('utf8');
// Create console output event
const output: ConsoleOutput = {
sessionId,
type: 'stdout',
data: message,
timestamp: new Date(),
raw: data.toString('base64')
};
// Log session data
await this.logSessionData(sessionId, message, 'stdout');
// Update session statistics
this.updateSessionStatistics(sessionId, data.length, 'in');
// Emit output event
this.emit('output', output);
this.emit('data', { sessionId, data: message, type: 'stdout' });
} catch (error) {
this.logger.error(`Error handling WebSocket message for session ${sessionId}:`, error);
}
}
/**
* Handle WebSocket close events
*/
private async handleWebSocketClose(sessionId: string, code: number, reason: string, isPortForwarding: boolean): Promise<void> {
if (isPortForwarding) {
const session = this.portForwardingSessions.get(sessionId);
if (session) {
session.status = 'Disconnected';
this.portForwardingSessions.set(sessionId, session);
}
} else {
await this.handleSessionDisconnect(sessionId, `WebSocket closed: ${code} - ${reason}`);
}
this.webSockets.delete(sessionId);
this.emit('websocket-closed', { sessionId, code, reason, isPortForwarding });
}
/**
* Handle WebSocket errors
*/
private async handleWebSocketError(sessionId: string, error: Error, isPortForwarding: boolean): Promise<void> {
if (isPortForwarding) {
const session = this.portForwardingSessions.get(sessionId);
if (session) {
session.status = 'Failed';
session.reason = error.message;
this.portForwardingSessions.set(sessionId, session);
}
} else {
await this.handleSessionError(sessionId, error);
}
this.emit('websocket-error', { sessionId, error, isPortForwarding });
}
/**
* Send input to a session
*/
async sendInput(sessionId: string, input: string): Promise<void> {
const ws = this.webSockets.get(sessionId);
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error(`Session ${sessionId} is not connected`);
}
try {
// Send input to WebSocket
ws.send(Buffer.from(input, 'utf8'));
// Log session data
await this.logSessionData(sessionId, input, 'stdin');
// Update session statistics
this.updateSessionStatistics(sessionId, Buffer.byteLength(input), 'out');
this.emit('input-sent', { sessionId, input });
} catch (error) {
this.logger.error(`Failed to send input to session ${sessionId}:`, error);
throw error;
}
}
/**
* Terminate a session
*/
async terminateSession(sessionId: string): Promise<void> {
try {
const session = this.sessions.get(sessionId) || this.portForwardingSessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
// Terminate the session via AWS API
await this.ssmClient.send(new TerminateSessionCommand({
SessionId: sessionId
}));
// Close WebSocket connection
const ws = this.webSockets.get(sessionId);
if (ws) {
ws.close();
this.webSockets.delete(sessionId);
}
// Update session status
if (this.sessions.has(sessionId)) {
const ssmSession = this.sessions.get(sessionId)!;
ssmSession.status = 'Terminated';
this.sessions.set(sessionId, ssmSession);
} else {
const portSession = this.portForwardingSessions.get(sessionId)!;
portSession.status = 'Terminated';
this.portForwardingSessions.set(sessionId, portSession);
}
// Finalize session logging
await this.finalizeSessionLogging(sessionId);
this.logger.info(`Session terminated: ${sessionId}`);
this.emit('session-terminated', { sessionId });
} catch (error) {
this.logger.error(`Failed to terminate session ${sessionId}:`, error);
throw error;
}
}
/**
* Validate target accessibility
*/
private async validateTarget(targetId: string, targetType: string): Promise<void> {
try {
if (targetType === 'instance') {
// Check if instance exists and is SSM-managed
const instanceInfo = await this.ssmClient.send(new DescribeInstanceInformationCommand({
InstanceInformationFilterList: [{
key: 'InstanceIds',
valueSet: [targetId]
}]
}));
if (!instanceInfo.InstanceInformationList || instanceInfo.InstanceInformationList.length === 0) {
throw new Error(`Instance ${targetId} is not managed by SSM or does not exist`);
}
const instance = instanceInfo.InstanceInformationList[0];
if (instance.PingStatus !== 'Online') {
throw new Error(`Instance ${targetId} is not online (status: ${instance.PingStatus})`);
}
}
} catch (error) {
this.logger.error(`Target validation failed for ${targetId}:`, error);
throw error;
}
}
/**
* Setup session logging
*/
private async setupSessionLogging(sessionId: string): Promise<void> {
if (!this.config.loggingConfig.enabled) {
return;
}
try {
// Initialize session log array
this.sessionLogs.set(sessionId, []);
// Create CloudWatch log stream if configured
if (this.config.loggingConfig.cloudWatchConfig && this.cloudWatchLogsClient) {
const logStreamName = `ssm-session-${sessionId}`;
try {
await this.cloudWatchLogsClient.send(new CreateLogStreamCommand({
logGroupName: this.config.loggingConfig.cloudWatchConfig.logGroupName,
logStreamName
}));
} catch (error: any) {
// Log stream might already exist, which is fine
if (error.name !== 'ResourceAlreadyExistsException') {
throw error;
}
}
}
// Log session start event
await this.logSessionEvent(sessionId, 'SessionStart', 'system', 'Session started');
} catch (error) {
this.logger.error(`Failed to setup session logging for ${sessionId}:`, error);
}
}
/**
* Log session data
*/
private async logSessionData(sessionId: string, data: string, dataType: 'stdin' | 'stdout' | 'stderr'): Promise<void> {
if (!this.config.loggingConfig.enabled) {
return;
}
const sessionLog: AWSSSMSessionLog = {
sessionId,
timestamp: new Date(),
eventType: 'DataStreamEvent',
source: dataType === 'stdin' ? 'client' : 'target',
data,
dataType,
byteCount: Buffer.byteLength(data),
sequenceNumber: this.getNextSequenceNumber(sessionId),
accountId: await this.getAccountId(),
region: this.connectionOptions.region,
sessionOwner: 'session-manager',
targetId: this.sessions.get(sessionId)?.instanceId,
targetType: this.sessions.get(sessionId)?.targetType
};
// Add to in-memory log
const logs = this.sessionLogs.get(sessionId) || [];
logs.push(sessionLog);
this.sessionLogs.set(sessionId, logs);
// Send to CloudWatch Logs if configured
if (this.config.loggingConfig.cloudWatchConfig?.streamingEnabled && this.cloudWatchLogsClient) {
await this.sendToCloudWatchLogs(sessionId, sessionLog);
}
}
/**
* Log session events
*/
private async logSessionEvent(sessionId: string, eventType: AWSSSMSessionLog['eventType'], source: AWSSSMSessionLog['source'], data: string): Promise<void> {
if (!this.config.loggingConfig.enabled) {
return;
}
const sessionLog: AWSSSMSessionLog = {
sessionId,
timestamp: new Date(),
eventType,
source,
data,
dataType: 'system',
byteCount: Buffer.byteLength(data),
sequenceNumber: this.getNextSequenceNumber(sessionId),
accountId: await this.getAccountId(),
region: this.connectionOptions.region,
sessionOwner: 'session-manager',
targetId: this.sessions.get(sessionId)?.instanceId,
targetType: this.sessions.get(sessionId)?.targetType
};
// Add to in-memory log
const logs = this.sessionLogs.get(sessionId) || [];
logs.push(sessionLog);
this.sessionLogs.set(sessionId, logs);
// Send to CloudWatch Logs if configured
if (this.config.loggingConfig.cloudWatchConfig && this.cloudWatchLogsClient) {
await this.sendToCloudWatchLogs(sessionId, sessionLog);
}
}
/**
* Send log entry to CloudWatch Logs
*/
private async sendToCloudWatchLogs(sessionId: string, logEntry: AWSSSMSessionLog): Promise<void> {
if (!this.cloudWatchLogsClient || !this.config.loggingConfig.cloudWatchConfig) {
return;
}
try {
const logEvent: LogEvent = {
timestamp: logEntry.timestamp.getTime(),
message: JSON.stringify(logEntry)
};
await this.cloudWatchLogsClient.send(new PutLogEventsCommand({
logGroupName: this.config.loggingConfig.cloudWatchConfig.logGroupName,
logStreamName: `ssm-session-${sessionId}`,
logEvents: [logEvent]
}));
} catch (error) {
this.logger.error(`Failed to send log to CloudWatch for session ${sessionId}:`, error);
}
}
/**
* Finalize session logging
*/
private async finalizeSessionLogging(sessionId: string): Promise<void> {
if (!this.config.loggingConfig.enabled) {
return;
}
try {
// Log session end event
await this.logSessionEvent(sessionId, 'SessionEnd', 'system', 'Session ended');
// Upload to S3 if configured
if (this.config.loggingConfig.s3Config && this.s3Client) {
const logs = this.sessionLogs.get(sessionId) || [];
const logData = JSON.stringify(logs, null, 2);
const key = `${this.config.loggingConfig.s3Config.keyPrefix}/${sessionId}/${new Date().toISOString()}.json`;
await this.s3Client.send(new PutObjectCommand({
Bucket: this.config.loggingConfig.s3Config.bucketName,
Key: key,
Body: logData,
ContentType: 'application/json',
ServerSideEncryption: this.config.loggingConfig.s3Config.encryptionEnabled ? 'AES256' : undefined,
Metadata: {
sessionId,
region: this.connectionOptions.region,
timestamp: new Date().toISOString()
}
}));
this.logger.info(`Session logs uploaded to S3: s3://${this.config.loggingConfig.s3Config.bucketName}/${key}`);
}
// Clean up in-memory logs
this.sessionLogs.delete(sessionId);
} catch (error) {
this.logger.error(`Failed to finalize session logging for ${sessionId}:`, error);
}
}
/**
* Get next sequence number for session logs
*/
private getNextSequenceNumber(sessionId: string): number {
const logs = this.sessionLogs.get(sessionId) || [];
return logs.length + 1;
}
/**
* Update session statistics
*/
private updateSessionStatistics(sessionId: string, bytes: number, direction: 'in' | 'out'): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (!session.statistics) {
session.statistics = {
bytesIn: 0,
bytesOut: 0,
packetsIn: 0,
packetsOut: 0,
commandsExecuted: 0,
errorsCount: 0,
lastActivityTime: new Date()
};
}
if (direction === 'in') {
session.statistics.bytesIn += bytes;
session.statistics.packetsIn += 1;
} else {
session.statistics.bytesOut += bytes;
session.statistics.packetsOut += 1;
}
session.statistics.lastActivityTime = new Date();
session.lastAccessedDate = new Date();
this.sessions.set(sessionId, session);
}
/**
* Handle session disconnect
*/
private async handleSessionDisconnect(sessionId: string, reason: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
session.status = 'Disconnected';
session.reason = reason;
this.sessions.set(sessionId, session);
// Close WebSocket if still open
const ws = this.webSockets.get(sessionId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
this.logger.warn(`Session ${sessionId} disconnected: ${reason}`);
this.emit('session-disconnected', { sessionId, reason });
}
/**
* Handle session errors
*/
private async handleSessionError(sessionId: string, error: Error): Promise<void> {
const session = this.sessions.get(sessionId);
if (session) {
session.status = 'Failed';
session.reason = error.message;
this.sessions.set(sessionId, session);
}
// Log error event
await this.logSessionEvent(sessionId, 'ErrorEvent', 'system', `Error: ${error.message}`);
this.logger.error(`Session ${sessionId} error:`, error);
this.emit('session-error', { sessionId, error });
}
/**
* Attempt session recovery
*/
private async attemptSessionRecovery(sessionId: string): Promise<void> {
try {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.info(`Attempting recovery for session: ${sessionId}`);
// Try to resume the session
await this.ssmClient.send(new ResumeSessionCommand({
SessionId: sessionId
}));
session.status = 'Connected';
this.sessions.set(sessionId, session);
this.logger.info(`Session recovery successful: ${sessionId}`);
this.emit('session-recovered', { sessionId });
} catch (error) {
this.logger.error(`Session recovery failed for ${sessionId}:`, error);
await this.handleSessionError(sessionId, error as Error);
}
}
/**
* Start session monitoring
*/
private startSessionMonitoring(sessionId: string): void {
// Check session status periodically
const monitorInterval = setInterval(async () => {
const session = this.sessions.get(sessionId);
if (!session || session.status === 'Terminated') {
clearInterval(monitorInterval);
return;
}
try {
await this.checkSessionHealth(sessionId);
} catch (error) {
this.logger.error(`Session monitoring error for ${sessionId}:`, error);
}
}, this.config.sessionConfig.keepAliveInterval);
}
/**
* Monitor command execution
*/
private monitorCommandExecution(commandId: string): void {
const checkStatus = async () => {
try {
const response = await this.ssmClient.send(new ListCommandInvocationsCommand({
CommandId: commandId
}));
const command = this.activeCommands.get(commandId);
if (!command || !response.CommandInvocations) return;
// Update command status based on invocations
const invocations = response.CommandInvocations;
const statuses = invocations.map((inv: any) => inv.Status as keyof typeof CommandStatus);
if (statuses.every((status: any) => status === CommandStatus.Success)) {
command.status = 'Success';
} else if (statuses.some((status: any) => status === CommandStatus.Failed)) {
command.status = 'Failed';
} else if (statuses.some((status: any) => status === CommandStatus.Cancelled)) {
command.status = 'Cancelled';
} else if (statuses.some((status: any) => status === CommandStatus.TimedOut)) {
command.status = 'TimedOut';
} else if (statuses.some((status: any) => status === CommandStatus.InProgress)) {
command.status = 'InProgress';
}
this.activeCommands.set(commandId, command);
if (['Success', 'Failed', 'Cancelled', 'TimedOut'].includes(command.status)) {
this.emit('command-completed', { commandId, status: command.status });
// Stop monitoring
return;
}
// Continue monitoring
setTimeout(checkStatus, 5000);
} catch (error) {
this.logger.error(`Command monitoring error for ${commandId}:`, error);
}
};
// Start monitoring after a short delay
setTimeout(checkStatus, 2000);
}
/**
* Get AWS account ID
*/
private async getAccountId(): Promise<string> {
try {
const identity = await this.stsClient.send(new GetCallerIdentityCommand({}));
return identity.Account!;
} catch (error) {
this.logger.error('Failed to get account ID:', error);
return 'unknown';
}
}
/**
* Create standardized SSM error
*/
private createSSMError(originalError: Error, code: string, retryable: boolean): AWSSSMError {
return {
code,
message: originalError.message,
retryable,
originalError,
region: this.connectionOptions.region,
time: new Date()
};
}
/**
* Get session information
*/
getSession(sessionId: string): AWSSSMSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Get port forwarding session information
*/
getPortForwardingSession(sessionId: string): AWSSSMPortForwardingSession | undefined {
return this.portForwardingSessions.get(sessionId);
}
/**
* List all active sessions
*/
listSessions(): AWSSSMSession[] {
return Array.from(this.sessions.values());
}
/**
* List all active port forwarding sessions
*/
listPortForwardingSessions(): AWSSSMPortForwardingSession[] {
return Array.from(this.portForwardingSessions.values());
}
/**
* Get session logs
*/
getSessionLogs(sessionId: string): AWSSSMSessionLog[] {
return this.sessionLogs.get(sessionId) || [];
}
/**
* Get command execution status
*/
getCommandExecution(commandId: string): AWSSSMCommandExecution | undefined {
return this.activeCommands.get(commandId);
}
/**
* Check if protocol is healthy
*/
isHealthy(): boolean {
return this.connectionHealthy;
}
/**
* Get protocol configuration
*/
getConfig(): AWSSSMSessionManagerConfig {
return { ...this.config };
}
/**
* Update protocol configuration
*/
updateConfig(newConfig: Partial<AWSSSMSessionManagerConfig>): void {
this.config = { ...this.config, ...newConfig };
// Update health check interval if changed
if (newConfig.monitoringConfig?.healthCheckInterval) {
this.startHealthMonitoring();
}
this.emit('config-updated', this.config);
}
/**
* Clean up and destroy the protocol instance
*/
async destroy(): Promise<void> {
try {
// Stop health monitoring
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = undefined;
}
// Terminate all active sessions
const terminationPromises: Promise<void>[] = [];
for (const sessionId of Array.from(this.sessions.keys())) {
terminationPromises.push(this.terminateSession(sessionId).catch(err =>
this.logger.error(`Failed to terminate session ${sessionId} during cleanup:`, err)
));
}
for (const sessionId of Array.from(this.portForwardingSessions.keys())) {
terminationPromises.push(this.terminateSession(sessionId).catch(err =>
this.logger.error(`Failed to terminate port forwarding session ${sessionId} during cleanup:`, err)
));
}
await Promise.allSettled(terminationPromises);
// Close all WebSocket connections
for (const [sessionId, ws] of Array.from(this.webSockets.entries())) {
try {
ws.close();
} catch (error) {
this.logger.error(`Failed to close WebSocket for session ${sessionId}:`, error);
}
}
// Clear all collections
this.sessions.clear();
this.portForwardingSessions.clear();
this.webSockets.clear();
this.sessionLogs.clear();
this.activeCommands.clear();
this.logger.info('AWS SSM Protocol destroyed successfully');
this.emit('destroyed');
} catch (error) {
this.logger.error('Error during protocol destruction:', error);
throw error;
}
}
}