虎の穴ラボ技術ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Session Managerを使ったEC2へのSSH接続をSlackへ通知 ※Terraformコードあり

こんにちは、とらのあなラボのはっとりです。

EC2インスタンスへのSSH接続は日常的な作業ですが、セキュリティと監査の観点から、誰がいつ接続したのかを把握することは非常に重要です。AWS Systems Manager Session Managerを使えば、セキュリティグループでSSHポート(22番)を開けることなく、安全にインスタンスへ接続できます。

今回は、Session ManagerでのSSH接続イベントをトリガーに、AWS ChatBot (※) 経由でSlackに通知する仕組みをTerraformで構築する方法をご紹介します。

※現在はAmazon Q Developer in chat applicationsという名前ですが、名前が長いため本記事ではChatBotと表記させていただきます。

構築する構成

  1. EC2インスタンスとIAM: Session Managerで接続される対象のEC2インスタンスと、接続に必要なIAMロールを作成します。
  2. CloudTrail (オプション): Session ManagerのAPI呼び出し履歴 (SSH接続開始イベント) を記録するために設定します。すでに設定済みの場合は不要です。
  3. 通知設定 (EventBridge, SNS): CloudTrailのログから特定のSSH接続イベントを検知し、SNSトピックに発行します。
  4. AWS ChatBot (手動設定): SNSトピックへの発行をトリガーに、指定したSlackチャンネルへ通知します。

Terraformで管理するのはSNSへの配信のみで、Slack通知部分(ChatBot)は手動設定が必要です。

前提条件

  • AWSアカウントを持っていること
  • Terraformの基本的な知識があること (インストール、init, applyなど)
  • Slackワークスペースと通知を受け取りたいチャンネルがあること

Terraformによる設定

今回はTerraformのコードを3つのファイルに分けて説明します。

  1. EC2インスタンスとIAM関連 ( ec2_iam.tf )
  2. CloudTrail関連 ( cloudtrail.tf ) - オプション
  3. 通知設定関連 ( notification.tf )

1. EC2インスタンスとIAM関連

provider "aws" {
}

# 以下のリソースは必要だが、ここでは省略
# - "aws_vpc"
# - "aws_subnet"
# - "aws_internet_gateway"
# - "aws_route_table"
# - "aws_route_table_association"

# EC2インスタンスのリソース
resource "aws_instance" "example" {
  ami           = data.aws_ssm_parameter.amazonlinux_2023.value
  instance_type = "t2.micro"
  subnet_id     = /* 省略 */

  # associate_public_ip_address = false
  associate_public_ip_address = true

  vpc_security_group_ids = [
    aws_security_group.example.id,
  ]

  iam_instance_profile = aws_iam_instance_profile.ec2_example.id

  tags = {
    Name = "example-instance"
  }

  lifecycle {
    ignore_changes = [
      ami,
    ]
  }
}

resource "aws_security_group" "example" {
  vpc_id = aws_vpc.example.id

  name = "example-security-group"

  # 必要に応じて ingress も設定
  # ingress {
  #   from_port   = 443
  #   to_port     = 443
  #   protocol    = "tcp"
  #   cidr_blocks = ["0.0.0/0"]
  # }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_iam_instance_profile" "ec2_example" {
  name = aws_iam_role.ec2_example.name
  role = aws_iam_role.ec2_example.name
}

resource "aws_iam_role" "ec2_example" {
  name        = "ExampleInstanceIamRole"
  description = "Role for EC2 instance"

  assume_role_policy = data.aws_iam_policy_document.ec2_example.json
}

data "aws_iam_policy_document" "ec2_example" {
  version = "2012-10-17"

  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "Service"

      identifiers = [
        "ec2.amazonaws.com",
      ]
    }
  }
}

resource "aws_iam_role_policy_attachment" "ec2_example_ssm_managed_instance_core" {
  role       = aws_iam_role.ec2_example.name
  policy_arn = data.aws_iam_policy.ssm_managed_instance_core.arn
}

data "aws_iam_policy" "ssm_managed_instance_core" {
  name = "AmazonSSMManagedInstanceCore"
}

# 最新の Amazon Linux 2023 の AMI ID を取得
data "aws_ssm_parameter" "amazonlinux_2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64"
}

ここでは、Session Managerで接続する対象のEC2インスタンスを作成します。重要なのはIAM関連の設定です。

ここでのポイントは

  • AmazonSSMManagedInstanceCore というAWS管理ポリシーをアタッチします。このポリシーにより、EC2インスタンスはSSMエージェントを通じてSystems Managerサービスと通信できるようになり、Session Managerでの接続が可能になります。
  • EC2インスタンスに適用するセキュリティグループは。SSHポート(22番)を開ける必要はありません。

また、このインスタンスに接続するためのIAMグループを作っておきます。

# SSHでインスタンスにアクセスするためのIAMグループ
resource "aws_iam_group" "ssh_instance_for_developer" {
  name = "SshInstanceForDeveloper"
}

resource "aws_iam_group_policy" "ssh_instance_for_developer" {
  name   = "SshInstanceForDeveloper"
  group  = aws_iam_group.ssh_instance_for_developer.name
  policy = data.aws_iam_policy_document.ssh_instance_for_developer.json
}

data "aws_iam_policy_document" "ssh_instance_for_developer" {
  version = "2012-10-17"

  statement {
    effect = "Allow"

    actions = [
      "ec2:DescribeInstances",
    ]

    resources = [
      "*",
    ]
  }

  statement {
    effect = "Allow"

    actions = [
      "ec2-instance-connect:SendSSHPublicKey",
      "ssm:StartSession",
      "ssm:DescribeSessions",
    ]

    resources = [
      aws_instance.example.arn,
    ]
  }

  statement {
    effect = "Allow"

    actions = [
      "ssm:StartSession",
    ]

    resources = [
      "arn:aws:ssm:*:*:document/AWS-StartPortForwardingSession",
      "arn:aws:ssm:*:*:document/AWS-StartSSHSession",
      "arn:aws:ssm:*:*:document/SSM-SessionManagerRunShell",
    ]
  }

  statement {
    effect = "Allow"

    actions = [
      "ssm:DescribeSessions",
      "ssm:TerminateSession",
    ]

    resources = [
      "*",
    ]
  }
}

このIAMグループに所属させたIAMユーザーで以下のようにSSHが可能になります。

Session Manager プラグイン のインストールが必要になります。

aws ssm start-session --target (i-から始まるEC2インスタンスのID)

2. CloudTrail関連

履歴通知のためにはCloudTrailの設定(管理イベント)が必要になります。Systems Managerを利用するだけならば不要です。

Session Managerの接続開始イベント (StartSession) はAPI呼び出しとして記録されます。このAPI呼び出し履歴を取得するためにCloudTrailが必要です。

もしTerraformで設定する場合は、以下のリソースを作成します。

もし、すでに対象リージョン・アカウントで管理イベントを記録するCloudTrailが有効になっている場合は、このTerraformコードは不要です。 Webコンソールなどで設定済みか確認してください。

data "aws_caller_identity" "current" {
}

data "aws_region" "current" {}

resource "aws_s3_bucket" "cloudtrail_logs" {
  bucket = "cloudtrail-logs-example-${random_string.cloudtrail_logs_bucket_suffix.result}"
}

resource "aws_s3_bucket_public_access_block" "cloudtrail_logs" {
  bucket = aws_s3_bucket.cloudtrail_logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "random_string" "cloudtrail_logs_bucket_suffix" {
  length  = 32
  special = false
  upper   = false
}

locals {
  cloudtrail_name = "management-event"
}

resource "aws_cloudtrail" "example" {
  depends_on = [
    aws_s3_bucket_policy.cloudtrail_logs_bucket_policy,
  ]

  name                          = local.cloudtrail_name
  s3_bucket_name                = aws_s3_bucket.cloudtrail_logs.id
  is_multi_region_trail         = true
  include_global_service_events = true

  advanced_event_selector {
    name = "Managed Event Selector"

    field_selector {
      field  = "eventCategory"
      equals = ["Management"]
    }
  }
}

resource "aws_s3_bucket_policy" "cloudtrail_logs_bucket_policy" {
  bucket = aws_s3_bucket.cloudtrail_logs.id
  policy = data.aws_iam_policy_document.cloudtrail_logs_bucket_policy.json
}

data "aws_iam_policy_document" "cloudtrail_logs_bucket_policy" {
  statement {
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }

    actions = [
      "s3:GetBucketAcl",
    ]

    resources = [
      aws_s3_bucket.cloudtrail_logs.arn,
    ]

    condition {
      test = "StringEquals"

      variable = "AWS:SourceArn"

      values = [
        "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}",
      ]
    }
  }

  statement {
    principals {
      type        = "Service"
      identifiers = ["cloudtrail.amazonaws.com"]
    }

    actions = [
      "s3:PutObject",
    ]

    resources = [
      "${aws_s3_bucket.cloudtrail_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*",
    ]

    condition {
      test = "StringEquals"

      variable = "AWS:SourceArn"

      values = [
        "arn:aws:cloudtrail:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:trail/${local.cloudtrail_name}",
      ]
    }

    condition {
      test = "StringEquals"

      variable = "s3:x-amz-acl"

      values = [
        "bucket-owner-full-control",
      ]
    }
  }
}

3. 通知設定関連

ここが今回のメイン設定です。CloudTrailに記録されたSession Managerの接続開始イベントを検知し、SNSトピックへ連携します。

# SSHの履歴を通知するためのEventBridgeルール
resource "aws_cloudwatch_event_rule" "ssh_notify_rule" {
  name = "SshNotifyRule"

  event_pattern = jsonencode({
    source = [
      "aws.ssm",
    ]
    detail-type = [
      "AWS API Call via CloudTrail",
    ]
    detail = {
      eventSource = [
        "ssm.amazonaws.com",
      ]
      eventName = [
        "StartSession",
      ]
      requestParameters = {
        target = [
          aws_instance.example.id,
        ],
      }
    }
  })
}

resource "aws_cloudwatch_event_target" "ssh_notify_target" {
  target_id = "SshNotify"
  rule      = aws_cloudwatch_event_rule.ssh_notify_rule.name
  arn       = aws_sns_topic.ssh_notify.arn
  role_arn  = aws_iam_role.ssh_notify_role.arn

  input_transformer {
    input_paths = {
      "userName"  = "$.detail.userIdentity.userName",
      "roleName"  = "$.detail.userIdentity.sessionContext.sessionIssuer.userName",
      "sessionId" = "$.detail.responseElements.sessionId",
    }
    input_template = <<-EOF
    {
      "version": "1.0",
      "source": "custom",
      "content": {
        "title": "インスタンスへのSSH通知",
        "description": "接続しました。\nユーザー: <userName> <roleName>\nセッションID: <sessionId>"
      }
    }
    EOF
  }
}

resource "aws_sns_topic" "ssh_notify" {
  name = "SshNotifyTopic"
}

resource "aws_iam_role" "ssh_notify_role" {
  name = "SshNotifyIamRole"

  assume_role_policy = data.aws_iam_policy_document.ssh_notify_assume_role.json
}

data "aws_iam_policy_document" "ssh_notify_assume_role" {
  version = "2012-10-17"

  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "Service"
      identifiers = [
        "events.amazonaws.com",
      ]
    }

    condition {
      test = "StringEquals"

      variable = "aws:SourceAccount"

      values = [
        data.aws_caller_identity.current.account_id,
      ]
    }

    condition {
      test = "StringEquals"

      variable = "aws:SourceArn"

      values = [
        aws_cloudwatch_event_rule.ssh_notify_rule.arn,
      ]
    }
  }
}

resource "aws_iam_role_policy" "ssh_notify_role" {
  name   = aws_iam_role.ssh_notify_role.name
  role   = aws_iam_role.ssh_notify_role.name
  policy = data.aws_iam_policy_document.ssh_notify_role.json
}

data "aws_iam_policy_document" "ssh_notify_role" {
  version = "2012-10-17"

  statement {
    effect = "Allow"

    actions = [
      "sns:Publish",
    ]

    resources = [
      aws_sns_topic.ssh_notify.arn,
    ]
  }
}
  • aws_cloudwatch_event_rule (ssh_notify_rule): 特定のイベントを検知するためのEventBridgeルールです。
    • event_pattern:
      • source: aws.ssm (Systems Managerからのイベント)
      • detail-type: AWS API Call via CloudTrail (CloudTrail経由のAPI呼び出し)
      • detail.eventSource: ssm.amazonaws.com
      • detail.eventName: StartSession (接続開始イベント)
      • detail.requestParameters.target: [aws_instance.example.id] (特定のEC2インスタンスIDを指定)
  • aws_sns_topic (ssh_notify): EventBridgeルールが検知したイベントを発行する先のSNSトピック。
  • aws_cloudwatch_event_target (ssh_notify_target): EventBridgeルールとSNSトピックを接続するターゲット設定です。
    • arn: 作成したSNSトピックのARNを指定します。
    • role_arn: EventBridgeがSNSトピックに発行するための権限を持つIAMロールのARNを指定します。
    • input_transformer: Slackに通知するメッセージの内容を整形します。
      • input_paths: イベントJSONから抽出したい情報を定義します (接続ユーザー名、引き受けたロール名、セッションID)。
      • input_template: 抽出した情報を使って、ChatBotが解釈できる形式 (今回はSlack向けのJSON) でメッセージテンプレートを作成します。
  • aws_iam_role (ssh_notify_role) と関連ポリシー: EventBridgeがSNSトピックへ発行 (sns:Publish) する権限を持つIAMロールです。信頼ポリシーでEventBridgeサービス (events.amazonaws.com) を信頼し、SourceArnSourceAccount で呼び出し元を制限しています。

Terraformの適用

上記3つのファイル (または ec2_iam.tf と notification.tf、必要なら cloudtrail.tf) を同じディレクトリに配置し、Terraformコマンドを実行します。

terraform init
terraform apply

これで、EC2インスタンス、IAMロール、(必要ならCloudTrail)、EventBridgeルール、SNSトピックが作成されます。

AWS ChatBotの手動設定

Terraformでのリソース作成後、AWS ChatBotの設定を行います。(ChatBot自体の設定はTerraformで完全に自動化するのが少し難しい部分があるため、ここでは手動設定とします。)

  1. AWSマネジメントコンソールを開き、Amazon Q Developer in chat applications(旧ChatBot)サービスに移動します。
  2. 「チャットクライアント」で「Slack」を選択し、「設定」ボタンをクリックします。
  3. Slackワークスペースへのアクセス許可を求められるので、指示に従って認証・承認します。(初回のみ)
  4. Webコンソール上で登録したSlackワークスペースを開き、「新しいチャネルを設定」をクリックします。
  5. 設定名: 任意の名前を入力します (例: ec2-ssh-notify)。
  6. ログ記録 (オプション): 必要であれば設定します。
  7. Slackチャネル:
    • 設定の詳細: わかりやすい名前を入力します。チャンネル名などが良いでしょう。
    • チャネルタイプ: 「パブリック」または「プライベート」を選択します。
    • パブリックチャネル名 or チャネルID: 通知を送りたいSlackチャンネルのIDを入力します。(チャンネル名を右クリックして「リンクをコピー」し、URLの末尾の方にある文字列を取得します。)
  8. IAMアクセス許可:
    • ロール設定」で「テンプレートを使用してIAMロールを作成する」を選択します。
    • ロール名: 任意の名前を入力します。
    • ポリシーテンプレート: デフォルトのままでよいです。
    • チャネルガードレールポリシー: デフォルトのままでよいです。
  9. 通知 - オプション:
    • リージョン」でTerraformリソースを作成したリージョンを選択します。
    • SNSトピック」で、Terraformで作成したSNSトピック SshNotifyTopic を選択します。
  10. 「設定」ボタンをクリックして保存します。

動作確認

AWSマネジメントコンソールまたはAWS CLIから、Terraformで作成したEC2インスタンスに対してSession Managerを使用して接続します。

aws ssm start-session --target (i-から始まるEC2インスタンスのID)

接続が成功すると、数秒〜数十秒後に設定したSlackチャンネルに以下のような通知が届けば成功です。

インスタンスへのSSH通知
接続しました。
ユーザー: <接続に使用したIAMユーザー名 or ロール名>
セッションID: <sm-xxxxxxxxxxxxxxxxx>

まとめ

今回は、Terraformを使い、Session ManagerによるEC2インスタンスへのSSH接続イベントを、Slackにリアルタイムで通知する仕組みを構築する方法をご紹介しました。

CloudTrailのログを直接監視する代わりにSlackへ通知することで、誰かがインスタンスに接続したことをより迅速かつ容易に把握できます。特に、Amazon Linux 2やAmazon Linux 2023など、SSM Agentがプリインストール済みのAMIを利用すれば、インスタンス側の設定も不要です。

この仕組みを導入することで、意図しない接続の早期発見や、チーム内での接続状況の可視化に繋がります。