Edited at

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直下に作成されているユーザーディレクトリの一覧を取得するようにしました。

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


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

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

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


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

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

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で接続している場合は、コネクションを強制切断

#!/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のテンプレートを作ってみましたので、良ければ使ってください。

{

"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" }
]
}
}
}
}