らくがきちょう

なんとなく ~所属組織/団体とは無関係であり、個人の見解です~

Linux と IAM ユーザ情報を同期し、SSH 鍵でログイン出来るようにする

aws-ec2-ssh を使うと EC2 上にある Linux ユーザ情報を IAM と同期させることが出来ます。 ログイン時に必要な SSH 公開鍵も IAM と同期出来る為、ユーザの追加・削除を (Linux では無く) IAM 上だけで完結出来るようになります。

ゴール

今回、「EC2 上の Linux」「IAM Role」「IAM Policy」の関係は以下のようにします。

f:id:sig9:20200429193218p:plain

制限 / 注意点

GitHub には以下の制限が記載されていました。

  • your EC2 instances need access to the AWS API either via an Internet Gateway + public IP or a Nat Gatetway / instance.
  • it can take up to 10 minutes until a new IAM user can log in
  • if you delete the IAM user / ssh public key and the user is already logged in, the SSH session will not be closed
  • uid's and gid's across multiple servers might not line up correctly (due to when a server was booted, and what users existed at that time). Could affect NFS mounts or Amazon EFS.
  • this solution will work for ~100 IAM users and ~100 EC2 instances. If your setup is much larger (e.g. 10 times more users or 10 times more EC2 instances) you may run into two issues:
    • IAM API limitations
    • Disk space issues
  • not all IAM user names are allowed in Linux user names (e.g. if you use email addresses as IAM user names). See section IAM user names and Linux user names for further details.

また、以下の点について注意する必要があります。

  1. aws-ec2-sshAWS CLI に依存している為、予め AWS CLI がインストールされていること
  2. SELinux が適切に設定されていること (または SELinux が無効化されていること)
  3. IAM には ED25519 形式の鍵は登録出来ないこと

Step.1

IAM で以下の内容を持った新規ポリシーを作成します。 ポリシー名は AmazonEC2SshSyncIAM にしました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:ListSSHPublicKeys",
                "iam:GetSSHPublicKey",
                "iam:GetGroup"
            ],
            "Resource": [
                "arn:aws:iam::*:user/*",
                "arn:aws:iam::*:group/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": "iam:ListUsers",
            "Resource": "*"
        }
    ]
}

Step.2

前のステップで作成した IAM Policy を参照する形で IAM Role を作成します。 今回は SshRole という名前にしました。

Step.3

次は以下のようにグループとユーザを作成します。

f:id:sig9:20200429194710p:plain

ふたつグループを作成しますが、これらは以下のように使い分けます。 今回は作成したグループを aws-ec2-ssh の制御にしか使わない為、グループには何もポリシーを紐付けませんでした。

グループ名 sudo 可否
SshAdministrators 可能
SshMembers 不可能

ユーザも作成します。 所属グループは以下の方針とします。 IAM の仕様上、ユーザ作成時には必ず「プログラムによるアクセス」または「AWS マネジメントコンソールへのアクセス」のいずれか、または両方を選択する必要があります。 ここは任意に設計すると良いと思います。

  • adminX ユーザは SshAdministratorsSshMembers グループの両方に参加させる
  • memberX ユーザは SshMembers グループにのみ参加させる
No. ユーザ名 SshAdministratos SshMembers
1 admin1
2 admin2
3 member1
4 member2

Step.4

EC2 で Linux を 3 台、作成しました。 3 台とも、同じ SshRole という IAM Role を関連付けています。

No. 名前 OS IAM Role AMI ID
1 Server-1 AmazonLinux2 SshRole ami-0f310fced6141e627
2 Server-2 CentOS7 SshRole ami-06a46da680048c8ae
3 Server-3 Ubuntu20 SshRole ami-0c1ac8728ef7f87a4

Step.5

OS 毎にインストールしていきます。

AmazonLinux2

RPM パッケージから簡単にインストール出来ます。

rpm -i https://s3-eu-west-1.amazonaws.com/widdix-aws-ec2-ssh-releases-eu-west-1/aws-ec2-ssh-1.9.2-1.el7.centos.noarch.rpm

CentOS7

標準状態で aws コマンドが存在しない為、先に AWS CLI v2 をインストールします。

yum -y install unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
./aws/install

後は AmazonLinux2 同様、RPM パッケージからインストールします。

rpm -i https://s3-eu-west-1.amazonaws.com/widdix-aws-ec2-ssh-releases-eu-west-1/aws-ec2-ssh-1.9.2-1.el7.centos.noarch.rpm

Ubuntu20.04 LTS

CentOS7 同様、先に AWS CLi v2 をインストールします。

apt -y install unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
./aws/install

Ubuntu では RPM パッケージからのインストールが出来ない為、ソースコードからインストールします。 ただ、ソースコードからインストールすると、インストールしただけで (まだ設定していないにも関わらず) IAM とユーザ情報が同期されてしまいました…??

git clone --depth 1 https://github.com/widdix/aws-ec2-ssh.git
cd aws-ec2-ssh/
./install.sh

Step.6

設定ファイルは /etc/aws-ec2-ssh.conf に存在します。 これを以下のように書き換えます。

cat << EOF > /etc/aws-ec2-ssh.conf
IAM_AUTHORIZED_GROUPS="SshMembers"
LOCAL_MARKER_GROUP="iam-users"
SUDOERS_GROUPS="SshAdministratos"
DONOTSYNC=0
EOF

Step.7

手動で同期スクリプトを実行し、意図した通りに IAM とユーザ情報が同期されることを確認します。

インストール方法 同期スクリプトのファイルパス
RPM パッケージから /usr/bin/import_users.sh
ソースコードから /opt/import_users.sh

以下は手動同期が正常終了した後にユーザ情報を確認した実行例です。

# id admin1
uid=1001(admin1) gid=1002(admin1) groups=1002(admin1),1001(iam-users)
# id admin2
uid=1002(admin2) gid=1003(admin2) groups=1003(admin2),1001(iam-users)
# id member1
uid=1003(member1) gid=1004(member1) groups=1004(member1),1001(iam-users)
# id member2
uid=1004(member2) gid=1005(member2) groups=1005(member2),1001(iam-users)

Step.8

IAM 上で以下のようにユーザを削除・作成します。

  • admin2 を削除する
  • member3 を追加する

結果としてユーザは以下となりました。

f:id:sig9:20200429194713p:plain

ユーザ情報は /etc/cron.d/import_users の cron 設定により、デフォルトでは 10 分間隔で IAM と同期します。 最長 10 分待ってユーザ情報が IAM と同期することを確認します。

参考

各ファイルの初期状態 (メモ作成時点)

/etc/cron.d/import_users

*/10 * * * * root /usr/bin/import_users.sh

/etc/aws-ec2-ssh.conf

IAM_AUTHORIZED_GROUPS=""
LOCAL_MARKER_GROUP="iam-synced-users"
LOCAL_GROUPS=""
SUDOERS_GROUPS=""
ASSUMEROLE=""

# Remove or set to 0 if you are done with configuration
# To change the interval of the sync change the file
# /etc/cron.d/import_users
DONOTSYNC=1

/usr/bin/import_users.sh

#!/bin/bash -e

function log() {
    /usr/bin/logger -i -p auth.info -t aws-ec2-ssh "$@"
}

# check if AWS CLI exists
if ! [ -x "$(which aws)" ]; then
    log "aws executable not found - exiting!"
    exit 1
fi

# source configuration if it exists
[ -f /etc/aws-ec2-ssh.conf ] && . /etc/aws-ec2-ssh.conf

# Should we actually do something?
: ${DONOTSYNC:=0}

if [ ${DONOTSYNC} -eq 1 ]
then
    log "Please configure aws-ec2-ssh by editing /etc/aws-ec2-ssh.conf"
    exit 1
fi

# Which IAM groups have access to this instance
# Comma seperated list of IAM groups. Leave empty for all available IAM users
: ${IAM_AUTHORIZED_GROUPS:=""}

# Special group to mark users as being synced by our script
: ${LOCAL_MARKER_GROUP:="iam-synced-users"}

# Give the users these local UNIX groups
: ${LOCAL_GROUPS:=""}

# Specify an IAM group for users who should be given sudo privileges, or leave
# empty to not change sudo access, or give it the value '##ALL##' to have all
# users be given sudo rights.
# DEPRECATED! Use SUDOERS_GROUPS
: ${SUDOERSGROUP:=""}

# Specify a comma seperated list of IAM groups for users who should be given sudo privileges.
# Leave empty to not change sudo access, or give the value '##ALL## to have all users
# be given sudo rights.
: ${SUDOERS_GROUPS:="${SUDOERSGROUP}"}

# Assume a role before contacting AWS IAM to get users and keys.
# This can be used if you define your users in one AWS account, while the EC2
# instance you use this script runs in another.
: ${ASSUMEROLE:=""}

# Possibility to provide a custom useradd program
: ${USERADD_PROGRAM:="/usr/sbin/useradd"}

# Possibility to provide custom useradd arguments
: ${USERADD_ARGS:="--user-group --create-home --shell /bin/bash"}

# Initizalize INSTANCE variable
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
REGION=$(curl -s http://169.254.169.254/latest/dynamic/instance-identity/document | grep region | awk -F\" '{print $4}')

function setup_aws_credentials() {
    local stscredentials
    if [[ ! -z "${ASSUMEROLE}" ]]
    then
        stscredentials=$(aws sts assume-role \
            --role-arn "${ASSUMEROLE}" \
            --role-session-name something \
            --query '[Credentials.SessionToken,Credentials.AccessKeyId,Credentials.SecretAccessKey]' \
            --output text)

        AWS_ACCESS_KEY_ID=$(echo "${stscredentials}" | awk '{print $2}')
        AWS_SECRET_ACCESS_KEY=$(echo "${stscredentials}" | awk '{print $3}')
        AWS_SESSION_TOKEN=$(echo "${stscredentials}" | awk '{print $1}')
        AWS_SECURITY_TOKEN=$(echo "${stscredentials}" | awk '{print $1}')
        export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_SECURITY_TOKEN
    fi
}

# Get list of iam groups from tag
function get_iam_groups_from_tag() {
    if [ "${IAM_AUTHORIZED_GROUPS_TAG}" ]
    then
        IAM_AUTHORIZED_GROUPS=$(\
            aws --region $REGION ec2 describe-tags \
            --filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=$IAM_AUTHORIZED_GROUPS_TAG" \
            --query "Tags[0].Value" --output text \
        )
    fi
}

# Get all IAM users (optionally limited by IAM groups)
function get_iam_users() {
    local group
    if [ -z "${IAM_AUTHORIZED_GROUPS}" ]
    then
        aws iam list-users \
            --query "Users[].[UserName]" \
            --output text \
        | sed "s/\r//g"
    else
        for group in $(echo ${IAM_AUTHORIZED_GROUPS} | tr "," " "); do
            aws iam get-group \
                --group-name "${group}" \
                --query "Users[].[UserName]" \
                --output text \
            | sed "s/\r//g"
        done
    fi
}

# Run all found iam users through clean_iam_username
function get_clean_iam_users() {
    local raw_username

    for raw_username in $(get_iam_users); do
        clean_iam_username "${raw_username}" | sed "s/\r//g"
    done
}

# Get previously synced users
function get_local_users() {
    /usr/bin/getent group ${LOCAL_MARKER_GROUP} \
        | cut -d : -f4- \
        | sed "s/,/ /g"
}

# Get list of IAM groups marked with sudo access from tag
function get_sudoers_groups_from_tag() {
    if [ "${SUDOERS_GROUPS_TAG}" ]
    then
        SUDOERS_GROUPS=$(\
            aws --region $REGION ec2 describe-tags \
            --filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=$SUDOERS_GROUPS_TAG" \
            --query "Tags[0].Value" --output text \
        )
    fi
}

# Get IAM users of the groups marked with sudo access
function get_sudoers_users() {
    local group

    [[ -z "${SUDOERS_GROUPS}" ]] || [[ "${SUDOERS_GROUPS}" == "##ALL##" ]] ||
        for group in $(echo "${SUDOERS_GROUPS}" | tr "," " "); do
            aws iam get-group \
                --group-name "${group}" \
                --query "Users[].[UserName]" \
                --output text
        done
}

# Get the unix usernames of the IAM users within the sudo group
function get_clean_sudoers_users() {
    local raw_username

    for raw_username in $(get_sudoers_users); do
        clean_iam_username "${raw_username}"
    done
}

# Create or update a local user based on info from the IAM group
function create_or_update_local_user() {
    local username
    local sudousers
    local localusergroups

    username="${1}"
    sudousers="${2}"
    localusergroups="${LOCAL_MARKER_GROUP}"

    # check that username contains only alphanumeric, period (.), underscore (_), and hyphen (-) for a safe eval
    if [[ ! "${username}" =~ ^[0-9a-zA-Z\._\-]{1,32}$ ]]
    then
        log "Local user name ${username} contains illegal characters"
        exit 1
    fi

    if [ ! -z "${LOCAL_GROUPS}" ]
    then
        localusergroups="${LOCAL_GROUPS},${LOCAL_MARKER_GROUP}"
    fi

    if ! id "${username}" >/dev/null 2>&1; then
        ${USERADD_PROGRAM} ${USERADD_ARGS} "${username}"
        /bin/chown -R "${username}:${username}" "$(eval echo ~$username)"
        log "Created new user ${username}"
    fi
    /usr/sbin/usermod -a -G "${localusergroups}" "${username}"

    # Should we add this user to sudo ?
    if [[ ! -z "${SUDOERS_GROUPS}" ]]
    then
        SaveUserFileName=$(echo "${username}" | tr "." " ")
        SaveUserSudoFilePath="/etc/sudoers.d/$SaveUserFileName"
        if [[ "${SUDOERS_GROUPS}" == "##ALL##" ]] || echo "${sudousers}" | grep "^${username}\$" > /dev/null
        then
            echo "${username} ALL=(ALL) NOPASSWD:ALL" > "${SaveUserSudoFilePath}"
        else
            [[ ! -f "${SaveUserSudoFilePath}" ]] || rm "${SaveUserSudoFilePath}"
        fi
    fi
}

function delete_local_user() {
    # First, make sure no new sessions can be started
    /usr/sbin/usermod -L -s /sbin/nologin "${1}" || true
    # ask nicely and give them some time to shutdown
    /usr/bin/pkill -15 -u "${1}" || true
    sleep 5
    # Dont want to close nicely? DIE!
    /usr/bin/pkill -9 -u "${1}" || true
    sleep 1
    # Remove account now that all processes for the user are gone
    /usr/sbin/userdel -f -r "${1}"
    log "Deleted user ${1}"
}

function clean_iam_username() {
    local clean_username="${1}"
    clean_username=${clean_username//"+"/".plus."}
    clean_username=${clean_username//"="/".equal."}
    clean_username=${clean_username//","/".comma."}
    clean_username=${clean_username//"@"/".at."}
    echo "${clean_username}"
}

function sync_accounts() {
    if [ -z "${LOCAL_MARKER_GROUP}" ]
    then
        log "Please specify a local group to mark imported users. eg iam-synced-users"
        exit 1
    fi

    # Check if local marker group exists, if not, create it
    /usr/bin/getent group "${LOCAL_MARKER_GROUP}" >/dev/null 2>&1 || /usr/sbin/groupadd "${LOCAL_MARKER_GROUP}"

    # declare and set some variables
    local iam_users
    local sudo_users
    local local_users
    local intersection
    local removed_users
    local user

    # init group and sudoers from tags
    get_iam_groups_from_tag
    get_sudoers_groups_from_tag

    # setup the aws credentials if needed
    setup_aws_credentials

    iam_users=$(get_clean_iam_users | sort | uniq)
    if [[ -z "${iam_users}" ]]
    then
      log "we just got back an empty iam_users user list which is likely caused by an IAM outage!"
      exit 1
    fi

    sudo_users=$(get_clean_sudoers_users | sort | uniq)
    if [[ ! -z "${SUDOERS_GROUPS}" ]] && [[ ! "${SUDOERS_GROUPS}" == "##ALL##" ]] && [[ -z "${sudo_users}" ]]
    then
      log "we just got back an empty sudo_users user list which is likely caused by an IAM outage!"
      exit 1
    fi

    local_users=$(get_local_users | sort | uniq)

    intersection=$(echo ${local_users} ${iam_users} | tr " " "\n" | sort | uniq -D | uniq)
    removed_users=$(echo ${local_users} ${intersection} | tr " " "\n" | sort | uniq -u)

    # Add or update the users found in IAM
    for user in ${iam_users}; do
        if [ "${#user}" -le "32" ]
        then
            create_or_update_local_user "${user}" "$sudo_users"
        else
            log "Can not import IAM user ${user}. User name is longer than 32 characters."
        fi
    done

    # Remove users no longer in the IAM group(s)
    for user in ${removed_users}; do
        delete_local_user "${user}"
    done
}

sync_accounts