diff --git a/docs/ssm-session-manager.md b/docs/ssm-session-manager.md index 158e1cac..7fa5de15 100644 --- a/docs/ssm-session-manager.md +++ b/docs/ssm-session-manager.md @@ -7,7 +7,7 @@ kitchen-ec2 now supports AWS Systems Manager (SSM) Session Manager as an alterna - **No SSH/WinRM network access required**: Connect to instances in private subnets without VPN or bastion hosts - **Enhanced security**: No need to open SSH/RDP ports in security groups - **Centralized audit logging**: All session activity is logged to CloudTrail -- **No SSH key management**: Eliminate the complexity of managing SSH key pairs for testing +- **Automatic SSH key injection**: SSH keys are automatically injected into instances via SSM before each connection - **Zero-trust compliance**: Access instances through AWS IAM authentication instead of network-based access ## Requirements @@ -61,9 +61,20 @@ suites: | Option | Default | Description | |--------|---------|-------------| | `use_ssm_session_manager` | `false` | Enable SSM Session Manager transport | -| `ssm_session_manager_document_name` | `nil` | Optional custom SSM document name | +| `ssm_session_manager_document_name` | `nil` | Optional custom SSM document name eg. AWS-StartSSHSession | | `iam_profile_name` | `nil` | IAM instance profile (required for SSM) | +## How It Works + +When SSM Session Manager is enabled, kitchen-ec2: + +1. Waits for the SSM agent to become available on the instance +2. Automatically injects SSH public keys into the instance via SSM before each connection +3. Configures SSH to use SSM Session Manager as a proxy command +4. Connects to the instance through SSM, eliminating the need for direct network access + +SSH keys are automatically extracted from your configured key files and injected into the instance's `~/.ssh/authorized_keys` file using SSM Run Command. This happens transparently before each connection attempt. + ## Additional Resources - [AWS Systems Manager Session Manager Documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html) diff --git a/lib/kitchen/driver/ec2.rb b/lib/kitchen/driver/ec2.rb index 9dd2511e..777d297c 100644 --- a/lib/kitchen/driver/ec2.rb +++ b/lib/kitchen/driver/ec2.rb @@ -1282,6 +1282,76 @@ def ssm_session_manager @ssm_session_manager ||= Aws::SsmSessionManager.new(config, instance.logger) end + def ssm_session_manager_client + @ssm_session_manager_client ||= ::Aws::SSM::Client.new( + region: config[:region], + profile: config[:shared_credentials_profile] + ) + end + + def ssm_session_manager_build_proxy_command(state) + # Build SSM start-session command + cmd = [ + "aws", "ssm", "start-session", + "--target", state[:server_id], + "--region", config[:region] + ] + + # Add document name if specified + if config[:ssm_session_manager_document_name] + cmd += ["--document-name", config[:ssm_session_manager_document_name]] + end + + # Add AWS profile if specified + if config[:shared_credentials_profile] + cmd += ["--profile", config[:shared_credentials_profile]] + end + + cmd.join(" ") + end + + def ssm_session_manager_execute_command(state, commands, max_wait: 30) + # Execute SSM command and wait for completion + ssm_client = ssm_session_manager_client + + resp = ssm_client.send_command( + instance_ids: [state[:server_id]], + document_name: "AWS-RunShellScript", + parameters: { + "commands" => commands, + } + ) + + command_id = resp.command.command_id + debug("[AWS SSM Session Manager] Command sent, ID: #{command_id}") + + # Wait for command to complete (poll every 2 seconds) + waited = 0 + loop do + sleep 2 + waited += 2 + + status_resp = ssm_client.get_command_invocation( + command_id: command_id, + instance_id: state[:server_id] + ) + + if status_resp.status == "Success" + return true + elsif status_resp.status == "Failed" || status_resp.status == "Cancelled" + warn("[AWS SSM Session Manager] Command failed: #{status_resp.status}") + warn("[AWS SSM Session Manager] Error: #{status_resp.error_message}") if status_resp.error_message + return false + elsif waited >= max_wait + warn("[AWS SSM Session Manager] Timeout waiting for command to complete") + return false + end + end + rescue => e + warn("[AWS SSM Session Manager] Error executing command: #{e.message}") + false + end + def ssm_session_manager_setup_ready(state) info("[AWS SSM Session Manager] Setting up SSM Session Manager connection") @@ -1312,6 +1382,53 @@ def ssm_session_manager_setup_ready(state) info("[AWS SSM Session Manager] Waiting for SSM agent to be available (attempt #{retries}/#{max_retries})...") sleep retry_delay end + + # Inject SSH public key into instance via SSM if key exists (one-time setup) + if state[:ssh_key] && File.exist?(state[:ssh_key]) + ssm_session_manager_inject_ssh_key(state) + end + end + + def ssm_session_manager_inject_ssh_key(state) + # Extract public key using existing helper method + key_path = state[:ssh_key] || instance.transport[:ssh_key] + return unless key_path && File.exist?(key_path) + + begin + public_key = instance_connect_extract_public_key(key_path) + rescue => e + warn("[AWS SSM Session Manager] Could not extract public key: #{e.message}") + return + end + + return unless public_key + + # Use same username resolution pattern as Instance Connect + username = state[:username] || actual_platform&.username + home_dir = username == "root" ? "/root" : "/home/#{username}" + authorized_keys_path = "#{home_dir}/.ssh/authorized_keys" + + info("[AWS SSM Session Manager] Injecting SSH public key into instance #{state[:server_id]} for user #{username}") + + # Escape the public key for shell + escaped_key = Shellwords.escape(public_key) + + # Build SSM command to add key (check if key already exists to avoid duplicates) + # Use test -f to check if file exists before grep to avoid errors + commands = [ + "mkdir -p #{home_dir}/.ssh", + "chmod 700 #{home_dir}/.ssh", + "test -f #{authorized_keys_path} && grep -qxF #{escaped_key} #{authorized_keys_path} || echo #{escaped_key} >> #{authorized_keys_path}", + "chmod 600 #{authorized_keys_path}", + "chown #{username}:#{username} #{home_dir}/.ssh -R", + ] + + # Use reusable SSM command execution helper + if ssm_session_manager_execute_command(state, commands) + info("[AWS SSM Session Manager] SSH public key injected successfully") + else + warn("[AWS SSM Session Manager] Failed to inject SSH key") + end end def ssm_session_manager_setup_override(instance) @@ -1327,28 +1444,20 @@ def ssm_session_manager_setup_override(instance) instance.transport.define_singleton_method(:connection) do |state, &block| if use_ssm && state[:server_id] - # Build SSM start-session command - cmd = [ - "aws", "ssm", "start-session", - "--target", state[:server_id], - "--region", driver_instance.config[:region] - ] - - # Add document name if specified - if driver_instance.config[:ssm_session_manager_document_name] - cmd += ["--document-name", driver_instance.config[:ssm_session_manager_document_name]] - end + # Inject/refresh SSH key before connection (like Instance Connect) + driver_instance.send(:ssm_session_manager_inject_ssh_key, state) - # Add AWS profile if specified - if driver_instance.config[:shared_credentials_profile] - cmd += ["--profile", driver_instance.config[:shared_credentials_profile]] - end - - proxy_command = cmd.join(" ") + # Build and set proxy command + proxy_command = driver_instance.send(:ssm_session_manager_build_proxy_command, state) driver_instance.info("[AWS SSM Session Manager] Using proxy command: #{proxy_command}") # Set proxy command for SSH transport state[:ssh_proxy_command] = proxy_command + + # Override hostname to localhost - SSM proxy command handles routing + # SSH should connect to localhost, not the instance IP + driver_instance.debug("[AWS SSM Session Manager] Overriding hostname to localhost (was: #{state[:hostname]})") + state[:hostname] = "localhost" end # Call original connection method @@ -1388,25 +1497,12 @@ def ssm_session_manager_setup_inspec_override(instance) # Inject SSM Session Manager configuration if enabled if use_ssm && config_data[:server_id] - # Build SSM start-session command - cmd = [ - "aws", "ssm", "start-session", - "--target", config_data[:server_id], - "--region", driver_instance.config[:region] - ] - - # Add document name if specified - if driver_instance.config[:ssm_session_manager_document_name] - cmd += ["--document-name", driver_instance.config[:ssm_session_manager_document_name]] - end - - # Add AWS profile if specified - if driver_instance.config[:shared_credentials_profile] - cmd += ["--profile", driver_instance.config[:shared_credentials_profile]] - end + # Inject/refresh SSH key before connection (like Instance Connect) + driver_instance.send(:ssm_session_manager_inject_ssh_key, config_data) - opts["proxy_command"] = cmd.join(" ") - driver_instance.info("[AWS SSM Session Manager] InSpec using proxy command: #{opts["proxy_command"]}") + proxy_command = driver_instance.send(:ssm_session_manager_build_proxy_command, config_data) + opts["proxy_command"] = proxy_command + driver_instance.info("[AWS SSM Session Manager] InSpec using proxy command: #{proxy_command}") end opts diff --git a/spec/kitchen/driver/ec2_spec.rb b/spec/kitchen/driver/ec2_spec.rb index 60c258ad..d6d529be 100644 --- a/spec/kitchen/driver/ec2_spec.rb +++ b/spec/kitchen/driver/ec2_spec.rb @@ -1400,12 +1400,39 @@ expect(state[:ssh_proxy_command]).to include("--profile my-profile") end + it "injects SSH key before connection" do + allow(driver).to receive(:debug) + driver.send(:ssm_session_manager_setup_override, instance) + + original_method = instance.transport.method(:connection) + allow(original_method).to receive(:call) + + expect(driver).to receive(:ssm_session_manager_inject_ssh_key).with(state) + instance.transport.connection(state) + end + + it "sets hostname to localhost" do + state[:hostname] = "10.74.130.5" + allow(driver).to receive(:ssm_session_manager_inject_ssh_key) + allow(driver).to receive(:debug) + driver.send(:ssm_session_manager_setup_override, instance) + + original_method = instance.transport.method(:connection) + allow(original_method).to receive(:call) + + instance.transport.connection(state) + + expect(state[:hostname]).to eq("localhost") + end + it "includes document name when specified" do config[:ssm_session_manager_document_name] = "MyCustomDocument" + allow(driver).to receive(:ssm_session_manager_inject_ssh_key) + allow(driver).to receive(:debug) driver.send(:ssm_session_manager_setup_override, instance) instance.transport.connection(state) - + expect(state[:ssh_proxy_command]).to include("--document-name MyCustomDocument") end @@ -1418,5 +1445,201 @@ driver.send(:ssm_session_manager_setup_override, instance) end end + + describe "#ssm_session_manager_inject_ssh_key" do + let(:state) do + { + server_id: "i-12345", + ssh_key: "/path/to/key.pem", + username: "ec2-user", + } + end + let(:config) do + { + use_ssm_session_manager: true, + region: "us-west-2", + shared_credentials_profile: "my-profile", + } + end + let(:ssm_client) { instance_double(::Aws::SSM::Client) } + let(:command_resp) { double("command_resp", command: double("command", command_id: "cmd-123")) } + let(:status_resp) { double("status_resp", status: "Success") } + + before do + allow(driver).to receive(:ssm_session_manager_client).and_return(ssm_client) + allow(driver).to receive(:instance_connect_extract_public_key).with("/path/to/key.pem").and_return("ssh-rsa AAAAB3... test-key") + allow(driver).to receive(:info) + allow(driver).to receive(:warn) + allow(driver).to receive(:actual_platform).and_return(nil) + allow(File).to receive(:exist?).with("/path/to/key.pem").and_return(true) + end + + it "injects SSH key via SSM command" do + expect(ssm_client).to receive(:send_command).with( + instance_ids: ["i-12345"], + document_name: "AWS-RunShellScript", + parameters: hash_including("commands" => array_including(/mkdir -p/)) + ).and_return(command_resp) + + expect(ssm_client).to receive(:get_command_invocation).with( + command_id: "cmd-123", + instance_id: "i-12345" + ).and_return(status_resp) + + allow(driver).to receive(:sleep) + expect(driver).to receive(:info).with("[AWS SSM Session Manager] SSH public key injected successfully") + + driver.send(:ssm_session_manager_inject_ssh_key, state) + end + + it "returns early if no key path is provided" do + state.delete(:ssh_key) + allow(instance.transport).to receive(:[]).with(:ssh_key).and_return(nil) + expect(driver).not_to receive(:instance_connect_extract_public_key) + expect(ssm_client).not_to receive(:send_command) + driver.send(:ssm_session_manager_inject_ssh_key, state) + end + + it "returns early if key file does not exist" do + allow(File).to receive(:exist?).with("/path/to/key.pem").and_return(false) + expect(driver).not_to receive(:instance_connect_extract_public_key) + expect(ssm_client).not_to receive(:send_command) + driver.send(:ssm_session_manager_inject_ssh_key, state) + end + + it "handles key extraction failure gracefully" do + allow(driver).to receive(:instance_connect_extract_public_key).and_raise(StandardError.new("Key extraction failed")) + expect(driver).to receive(:warn).with(/Could not extract public key/) + expect(ssm_client).not_to receive(:send_command) + driver.send(:ssm_session_manager_inject_ssh_key, state) + end + + it "handles SSM command failure" do + expect(ssm_client).to receive(:send_command).and_return(command_resp) + failed_status = double("status_resp", status: "Failed", error_message: "Command failed") + expect(ssm_client).to receive(:get_command_invocation).and_return(failed_status) + allow(driver).to receive(:sleep) + expect(driver).to receive(:warn).with(/Failed to inject SSH key/) + + driver.send(:ssm_session_manager_inject_ssh_key, state) + end + end + + describe "#ssm_session_manager_execute_command" do + let(:state) { { server_id: "i-12345" } } + let(:config) do + { + use_ssm_session_manager: true, + region: "us-west-2", + shared_credentials_profile: "my-profile", + } + end + let(:ssm_client) { instance_double(::Aws::SSM::Client) } + let(:command_resp) { double("command_resp", command: double("command", command_id: "cmd-123")) } + let(:commands) { ["echo 'test'"] } + + before do + allow(driver).to receive(:ssm_session_manager_client).and_return(ssm_client) + allow(driver).to receive(:debug) + allow(driver).to receive(:warn) + end + + it "executes SSM command and returns true on success" do + expect(ssm_client).to receive(:send_command).with( + instance_ids: ["i-12345"], + document_name: "AWS-RunShellScript", + parameters: { + "commands" => commands, + } + ).and_return(command_resp) + + success_status = double("status_resp", status: "Success") + expect(ssm_client).to receive(:get_command_invocation).with( + command_id: "cmd-123", + instance_id: "i-12345" + ).and_return(success_status) + + allow(driver).to receive(:sleep) + + result = driver.send(:ssm_session_manager_execute_command, state, commands) + expect(result).to be true + end + + it "returns false on command failure" do + expect(ssm_client).to receive(:send_command).and_return(command_resp) + failed_status = double("status_resp", status: "Failed", error_message: "Command failed") + expect(ssm_client).to receive(:get_command_invocation).and_return(failed_status) + allow(driver).to receive(:sleep) + expect(driver).to receive(:warn).with(/Command failed/) + + result = driver.send(:ssm_session_manager_execute_command, state, commands) + expect(result).to be false + end + + it "returns false on timeout" do + expect(ssm_client).to receive(:send_command).and_return(command_resp) + in_progress_status = double("status_resp", status: "InProgress") + expect(ssm_client).to receive(:get_command_invocation).and_return(in_progress_status).at_least(:once) + allow(driver).to receive(:sleep) + expect(driver).to receive(:warn).with(/Timeout waiting/) + + result = driver.send(:ssm_session_manager_execute_command, state, commands, max_wait: 2) + expect(result).to be false + end + + it "handles exceptions gracefully" do + expect(ssm_client).to receive(:send_command).and_raise(StandardError.new("Network error")) + expect(driver).to receive(:warn).with(/Error executing command/) + + result = driver.send(:ssm_session_manager_execute_command, state, commands) + expect(result).to be false + end + end + + describe "#ssm_session_manager_setup_inspec_override" do + let(:state) { { server_id: "i-12345" } } + let(:config) do + { + use_ssm_session_manager: true, + region: "us-west-2", + shared_credentials_profile: "my-profile", + } + end + let(:inspec_verifier) do + verifier = double("InspecVerifier") + allow(verifier).to receive(:name).and_return("inspec") + verifier + end + + before do + allow(instance).to receive(:verifier).and_return(inspec_verifier) + allow(driver).to receive(:info) + allow(driver).to receive(:debug) + end + + it "overrides InSpec verifier call method" do + original_call = ->(state) {} + allow(inspec_verifier).to receive(:method).with(:call).and_return(original_call) + allow(inspec_verifier).to receive(:respond_to?).with(:ssm_session_manager_inspec_override_applied).and_return(false) + + expect(inspec_verifier).to receive(:define_singleton_method).with(:call) + expect(inspec_verifier).to receive(:define_singleton_method).with(:ssm_session_manager_inspec_override_applied) + + driver.send(:ssm_session_manager_setup_inspec_override, instance) + end + + it "does not apply override twice" do + allow(inspec_verifier).to receive(:respond_to?).with(:ssm_session_manager_inspec_override_applied).and_return(true) + + expect(inspec_verifier).not_to receive(:define_singleton_method) + driver.send(:ssm_session_manager_setup_inspec_override, instance) + end + + it "only applies to InSpec verifier" do + allow(inspec_verifier).to receive(:name).and_return("serverspec") + expect(inspec_verifier).not_to receive(:define_singleton_method) + driver.send(:ssm_session_manager_setup_inspec_override, instance) + end + end end end