IAMで踏み台ホストのユーザーとSSH公開鍵を一元管理する

公式でAWS EC2 Connectという機能が入ったので、そちらを使いましょう。
https://dev.classmethod.jp/cloud/aws/ec2-instance-connect/

−−−−−−−−−−−−

AWSの踏み台ホストのユーザー管理ってどうしていますか？IAMユーザーとホストユーザーで管理が2重になり、大変‥なんとかならないかなぁと思っていたら、IAMユーザーごとにCodeCommit用のSSH公開鍵が登録できるのを見つけたので、今回はこれを使ってホストユーザーを管理をする機構を作ってみました。

# やりたいこと

1. 自動でIAMに登録されているユーザーと同じユーザーをホストに作成する
2. IAMユーザーごとに登録しているCodeCommitのSSH公開鍵を取得し，`~/.ssh/authorized_keys`にセットする

# 主な部品の検証

## ホストに登録されているユーザー一覧を取得する

直接ホストに作成されているユーザーの取得方法がわからなかったので、sedを駆使して`/home`直下に作成されているユーザーディレクトリの一覧を取得するようにしました。

```bash
find /home -maxdepth 1 -type d | grep '^/home/' | sed -e 's/^\/home\/\(.\)/\1/'
```

## IAMユーザーの一覧を取得する

aws-cliで，IAMユーザーの名前一覧を取得できました。

```bash
aws iam list-users | jq -r '.Users[].UserName'
```

## IAMユーザーのCodeCommit用SSH公開鍵を取得する

ユーザーごとに鍵のID一覧を取得して、IDから鍵の実体が取れました。

```bash
key_ids=$(aws iam list-ssh-public-keys --user-name $iam_user | jq -r 'select(.SSHPublicKeys[].Status == "Active") | .SSHPublicKeys[].SSHPublicKeyId')
for key_id in $key_ids; do
  aws iam get-ssh-public-key --user-name $iam_user --ssh-public-key-id $key_id --encoding SSH | jq -r '.SSHPublicKey.SSHPublicKeyBody' >> authorized_keys
done
```

# 完成版のスクリプト

最終的に以下のようなフローを実行させるようにしました。

1. ホストにIAMユーザーと同じ名前でユーザーを作成
2. IAMユーザーごとに登録しているCodeCommitのSSH公開鍵を取得し、更新がある場合は`~/.ssh/authorized_keys`にセット
3. IAMユーザーから削除されたユーザーはホストからも削除
4. 鍵の更新やIAMから削除があったユーザーが現在SSHで接続している場合は、コネクションを強制切断

```bash
#!/bin/bash
PATH=$PATH:/sbin:/bin:/usr/sbin:/usr/bin:/opt/aws/bin
function contains() {
  for row in $1; do
    if [ "$row" == "$2" ]; then
      echo "y"
      return 0
    fi
  done
  echo "n"
  return 1
}
# current users in host.
host_users=$(find /home -maxdepth 1 -type d | grep '^/home/' | sed -e 's/^\/home\/\(.\)/\1/')
# create iam users
iam_users=$(aws iam list-users | jq -r '.Users[].UserName')
for iam_user in $iam_users; do
  user_home="/home/$iam_user"
  user_ssh_dir="$user_home/.ssh"
  useradd "$iam_user"
  # setup ssh directory
  mkdir -p "$user_ssh_dir"
  chown -R "$iam_user:$iam_user" "$user_home"
  chmod -R 500 "$user_home"
  # setup ssh key
  touch "$user_ssh_dir/authorized_keys"
  touch "$user_ssh_dir/new_authorized_keys"
  key_ids=$(aws iam list-ssh-public-keys --user-name $iam_user | jq -r 'select(.SSHPublicKeys[].Status == "Active") | .SSHPublicKeys[].SSHPublicKeyId')
  for key_id in $key_ids; do
    aws iam get-ssh-public-key --user-name $iam_user --ssh-public-key-id $key_id --encoding SSH | jq -r '.SSHPublicKey.SSHPublicKeyBody' >> "$user_ssh_dir/new_authorized_keys"
  done
  keys_diff=$(diff "$user_ssh_dir/authorized_keys" "$user_ssh_dir/new_authorized_keys")
  if [ "$keys_diff" == "" ]; then
    rm -f "$user_ssh_dir/new_authorized_keys"
    continue
  fi
  rm -f "$user_ssh_dir/authorized_keys"
  mv "$user_ssh_dir/new_authorized_keys" "$user_ssh_dir/authorized_keys"
  chown "$iam_user:$iam_user" "$user_ssh_dir/authorized_keys"
  chmod 500 "$user_ssh_dir/authorized_keys"
  ps aux | grep "sshd: $iam_user@pts/" | grep -v grep | awk '{ print "kill -9", $2 }' | sh
done
# delete iam user deleted by aws from host
for host_user in $host_users; do
  # if ec2-user, not action
  if [ "$host_user" = "ec2-user" ]; then
    continue
  fi
  if [[ "$(contains "${iam_users[@]}" "$host_user")" == "y" ]]; then
    continue
  fi
  ps aux | grep "sshd: $host_user@pts/" | grep -v grep | awk '{ print "kill -9", $2 }' | sh
  userdel -r "$host_user"
  rm -rf "/home/$host_user"
  echo "delete $host_user from host."
done
```

あとは、cronでこれを適当な間隔でまわしてあげれば完成です。
お手物の環境に直ぐにデプロイできるようにCloudFromationのテンプレートを作ってみましたので、良ければ使ってください。

```json
{
  "AWSTemplateFormatVersion": "2010-09-09",

  "Description" : "Bastion Instance",

  "Parameters" : {
    "KeyName" : {
      "Description"           : "Name of an existing EC2 KeyPair to enable SSH access to the instances",
      "Type"                  : "AWS::EC2::KeyPair::KeyName",
      "ConstraintDescription" : "can contain only alphanumeric characters, spaces, dashes and underscores."
    },
    "InstanceType" : {
      "Description"           : "Instance type for node.",
      "Type"                  : "String",
      "Default"               : "t2.nano",
      "AllowedValues"         : [ "t2.nano", "t2.micro","t2.small","t2.medium","m3.medium","m3.large","m3.xlarge","m3.2xlarge","c3.large","c3.xlarge","c3.2xlarge","c3.4xlarge","c3.8xlarge" ],
      "ConstraintDescription" : "must be a valid T2, M3 or C3 instance type."
    },
    "Subnets" : {
      "Description"           : "ID of your existing subnet for launching Bastion",
      "Type"                  : "List<AWS::EC2::Subnet::Id>"
    },
    "SSHSecurityGroup" : {
      "Description"           : "SecurityGroup for accessing with SSH",
      "Type"                  : "AWS::EC2::SecurityGroup::Id"
    },
    "ElasticIpId": {
      "Description"           : "ID of ElasticIp for launching Bastion",
      "Type"                  : "String",
      "AllowedPattern"        : "eipalloc-[a-z0-9]{8}",
      "ConstraintDescription" : "Input format is eipalloc-xxxxxxxx"
    },
    "Recurrence" : {
      "Type"                  : "String",
      "Description"           : "schedule for updating keys. crontab style syntax. ex) 0 0 * * *",
      "AllowedPattern"        : "(((([*])|(((([0-5])?[0-9])((-(([0-5])?[0-9])))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))(,(((([*])|(((([0-5])?[0-9])((-(([0-5])?[0-9])))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?)))* (((([*])|(((((([0-1])?[0-9]))|(([2][0-3])))((-(((([0-1])?[0-9]))|(([2][0-3])))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))(,(((([*])|(((((([0-1])?[0-9]))|(([2][0-3])))((-(((([0-1])?[0-9]))|(([2][0-3])))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?)))* (((((((([*])|(((((([1-2])?[0-9]))|(([3][0-1]))|(([1-9])))((-(((([1-2])?[0-9]))|(([3][0-1]))|(([1-9])))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))|(L)|(((((([1-2])?[0-9]))|(([3][0-1]))|(([1-9])))W))))(,(((((([*])|(((((([1-2])?[0-9]))|(([3][0-1]))|(([1-9])))((-(((([1-2])?[0-9]))|(([3][0-1]))|(([1-9])))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))|(L)|(((((([1-2])?[0-9]))|(([3][0-1]))|(([1-9])))W)))))*)|([?])) (((([*])|((((([1-9]))|(([1][0-2])))((-((([1-9]))|(([1][0-2])))))?))|((((JAN)|(FEB)|(MAR)|(APR)|(MAY)|(JUN)|(JUL)|(AUG)|(SEP)|(OKT)|(NOV)|(DEC))((-((JAN)|(FEB)|(MAR)|(APR)|(MAY)|(JUN)|(JUL)|(AUG)|(SEP)|(OKT)|(NOV)|(DEC))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))(,(((([*])|((((([1-9]))|(([1][0-2])))((-((([1-9]))|(([1][0-2])))))?))|((((JAN)|(FEB)|(MAR)|(APR)|(MAY)|(JUN)|(JUL)|(AUG)|(SEP)|(OKT)|(NOV)|(DEC))((-((JAN)|(FEB)|(MAR)|(APR)|(MAY)|(JUN)|(JUL)|(AUG)|(SEP)|(OKT)|(NOV)|(DEC))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?)))* (((((((([*])|((([0-6])((-([0-6])))?))|((((SUN)|(MON)|(TUE)|(WED)|(THU)|(FRI)|(SAT))((-((SUN)|(MON)|(TUE)|(WED)|(THU)|(FRI)|(SAT))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))|((([0-6])L))|(W)|(([#][1-5]))))(,(((((([*])|((([0-6])((-([0-6])))?))|((((SUN)|(MON)|(TUE)|(WED)|(THU)|(FRI)|(SAT))((-((SUN)|(MON)|(TUE)|(WED)|(THU)|(FRI)|(SAT))))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))|((([0-6])L))|(W)|(([#][1-5])))))*)|([?]))((( (((([*])|((([1-2][0-9][0-9][0-9])((-([1-2][0-9][0-9][0-9])))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?))(,(((([*])|((([1-2][0-9][0-9][0-9])((-([1-2][0-9][0-9][0-9])))?)))((/(([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?([0-9])?[0-9])))?)))*))?)",
      "Default"               : "*/10 * * * *"
    }
  },

  "Mappings": {
     "AWSBastionAMI" : {
      "ap-northeast-1" : { "AMI" : "ami-383c1956" }
    }
  },

  "Resources" : {

    "BastionRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "AssumeRolePolicyDocument": {
           "Statement": [ {
              "Effect": "Allow",
              "Principal": {
                 "Service": [ "ec2.amazonaws.com" ]
              },
              "Action": [ "sts:AssumeRole" ]
           } ]
        },
        "Path": "/",
        "Policies": [ {
          "PolicyName": "Bastion_Takeover",
          "PolicyDocument": {
            "Statement": [ {
               "Effect": "Allow",
               "Action": [
                  "iam:ListUsers",
                  "iam:ListSshPublicKeys",
                  "iam:GetSshPublicKey",
                  "ec2:AssociateAddress"
               ],
               "Resource": "*"
            } ]
          }
        } ]
      }
    },

    "BastionRoleProfile": {
       "Type": "AWS::IAM::InstanceProfile",
       "Properties": {
          "Path": "/",
          "Roles": [ {
             "Ref": "BastionRole"
          } ]
       }
    },

    "BastionLaunchConfig" : {
      "Type" : "AWS::AutoScaling::LaunchConfiguration",
      "Metadata" : {
        "AWS::CloudFormation::Init" : {
          "config" : {
            "packages" : {
              "yum" : {
                "jq" : []
              }
            },
            "files" : {
              "/etc/sshkey-update/cron.sh" : {
                "content" : { "Fn::Join" : [ "\n", [
                  "#!/bin/bash",
                  "PATH=$PATH:/sbin:/bin:/usr/sbin:/usr/bin:/opt/aws/bin",
                  "function contains() {",
                  "  for row in $1; do",
                  "    if [ \"$row\" == \"$2\" ]; then",
                  "      echo \"y\"",
                  "      return 0",
                  "    fi",
                  "  done",
                  "  echo \"n\"",
                  "  return 1",
                  "}",
                  "# current users in host.",
                  "host_users=$(find /home -maxdepth 1 -type d | grep '^/home/' | sed -e 's/^\\/home\\/\\(.\\)/\\1/')",
                  "# create iam users",
                  "iam_users=$(aws iam list-users | jq -r '.Users[].UserName')",
                  "for iam_user in $iam_users; do",
                  "  user_home=\"/home/$iam_user\"",
                  "  user_ssh_dir=\"$user_home/.ssh\"",
                  "  useradd \"$iam_user\"",
                  "  # setup ssh directory",
                  "  mkdir -p \"$user_ssh_dir\"",
                  "  chown -R \"$iam_user:$iam_user\" \"$user_home\"",
                  "  chmod -R 500 \"$user_home\"",
                  "  # setup ssh key",
                  "  touch \"$user_ssh_dir/authorized_keys\"",
                  "  touch \"$user_ssh_dir/new_authorized_keys\"",
                  "  key_ids=$(aws iam list-ssh-public-keys --user-name $iam_user | jq -r 'select(.SSHPublicKeys[].Status == \"Active\") | .SSHPublicKeys[].SSHPublicKeyId')",
                  "  for key_id in $key_ids; do",
                  "    aws iam get-ssh-public-key --user-name $iam_user --ssh-public-key-id $key_id --encoding SSH | jq -r '.SSHPublicKey.SSHPublicKeyBody' >> \"$user_ssh_dir/new_authorized_keys\"",
                  "  done",
                  "  keys_diff=$(diff \"$user_ssh_dir/authorized_keys\" \"$user_ssh_dir/new_authorized_keys\")",
                  "  if [ \"$keys_diff\" == \"\" ]; then",
                  "    rm -f \"$user_ssh_dir/new_authorized_keys\"",
                  "    continue",
                  "  fi",
                  "  rm -f \"$user_ssh_dir/authorized_keys\"",
                  "  mv \"$user_ssh_dir/new_authorized_keys\" \"$user_ssh_dir/authorized_keys\"",
                  "  chown \"$iam_user:$iam_user\" \"$user_ssh_dir/authorized_keys\"",
                  "  chmod 500 \"$user_ssh_dir/authorized_keys\"",
                  "  ps aux | grep \"sshd: $iam_user@pts/\" | grep -v grep | awk '{ print \"kill -9\", $2 }' | sh",
                  "done",
                  "# delete iam user deleted by aws management console from host",
                  "for host_user in $host_users; do",
                  "  # if ec2-user, not action",
                  "  if [ \"$host_user\" = \"ec2-user\" ]; then",
                  "    continue",
                  "  fi",
                  "  if [[ \"$(contains \"${iam_users[@]}\" \"$host_user\")\" == \"y\" ]]; then",
                  "    continue",
                  "  fi",
                  "  ps aux | grep \"sshd: $host_user@pts/\" | grep -v grep | awk '{ print \"kill -9\", $2 }' | sh",
                  "  userdel -r \"$host_user\"",
                  "  rm -rf \"/home/$host_user\"",
                  "  echo \"delete $host_user from host.\"",
                  "done"
                ]]},
                "mode"  : "0755",
                "owner" : "root",
                "group" : "root"
              }
            }
          }
        }
      },
      "Properties" : {
          "InstanceType"             : { "Ref" : "InstanceType" },
          "KeyName"                  : { "Ref" : "KeyName" },
          "ImageId"                  : { "Fn::FindInMap" : [ "AWSBastionAMI", { "Ref" : "AWS::Region" }, "AMI"] },
          "IamInstanceProfile"       : { "Ref" : "BastionRoleProfile" },
          "AssociatePublicIpAddress" : "true",
          "SecurityGroups"           : [ { "Ref" : "SSHSecurityGroup" } ],
          "UserData"                 : { "Fn::Base64" : { "Fn::Join" : [ "", [
            "#!/bin/bash\n",
            "yum update -y\n",
            "export AWS_DEFAULT_REGION=", { "Ref" : "AWS::Region" }, "\n",
            "/opt/aws/bin/cfn-init -s ", { "Ref" : "AWS::StackId" }, " -r BastionLaunchConfig --region ", { "Ref" : "AWS::Region" }, "\n",
            "echo \"", { "Ref" : "Recurrence" }, " /bin/bash /etc/sshkey-update/cron.sh\" >> /tmp/$$.tmp\n",
            "crontab /tmp/$$.tmp && rm -rf /tmp/$$.tmp\n",
            "/bin/bash /etc/sshkey-update/cron.sh\n",
            "instanceId=$(curl http://169.254.169.254/latest/meta-data/instance-id)\n",
            "aws ec2 associate-address --instance-id $instanceId --allocation-id ", { "Ref" : "ElasticIpId" }, "\n"
          ]]}
        }
      }
    },
    "BastionAutoScalingGroup" : {
      "Type"                      : "AWS::AutoScaling::AutoScalingGroup",
      "Properties" : {
        "LaunchConfigurationName" : { "Ref" : "BastionLaunchConfig" },
        "MaxSize"                 : "1",
        "MinSize"                 : "1",
        "VPCZoneIdentifier"       : { "Ref" : "Subnets" },
        "Tags"                    : [
          { "Key" : "Name", "Value" : "bastion", "PropagateAtLaunch" : "true" }
        ]
      }
    }
  }
}
```