less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

Slackのスラッシュコマンドとダイアログを使ってEC2のセキュリティグループを編集する

経緯

フルリモート体制になっているので、sshの接続元などにオフィス以外の任意のIPアドレスを追加する必要が出てきた。

メンバーの自宅はIPが固定されていないため、IPアドレスが変わるたびにインフラ担当に作業を依頼する必要がある。

作業の手間とヒューマンエラーをなくすために、自動化を試みた。

要件

  • 許可したいIPアドレスと対象のセキュリティグループを入力するとインバウンドルールが追加される。
  • 追加された任意のIPアドレスを削除する機能も用意する。
  • 機能を使用できるユーザーと対象のセキュリティグループをあらかじめ制限する。
  • 誰がいつどんな操作を行ったのか履歴を残す。

使用技術

やっぱりこういうボットはSlackかなあと思ったのでスラッシュコマンドで作ることにしました。

バックエンドはLambda+API Gatewayです。

スラッシュコマンドはGASで作る人が多い気がするんですが、今回はAWSAPIを用いるのでこれ以外の選択肢はまあないですね。

f:id:bluepixel:20200424234524p:plain

f:id:bluepixel:20200424235631p:plain

1. Slackアプリの設定

以下からアプリを新しく作成する。
Slack API: Applications | Slack

[Slash Commands] を選択し、コマンドとURLを入力する。
この時点ではURLが決まっていないので適当に入れておく。
コマンドは/ipとする。

Usageにはパラメータのヒントなどを入れておくのだが、とりあえず空にしておく。

ワークスペースにインストールして一旦作業は終わり。
Verification TokenBot User OAuth Access Tokenがあとで必要になるのでコピーしておく。

2. Lambdaの作成

ロジックの作り込みはちょっと長くなるので、まずは疎通確認用にシンプルに200を返す関数を作る。

ランタイムはNode.js 12.x

exports.handler = async (event, context) => {
    console.log(event)
    return { statusCode: 200 } 
}

3. API Gatewayの作成

POSTリソースを作成する。ここではmenuというリソース名にする。
統合リクエストに先ほどの関数を指定して、マッピングテンプレートを追加する。

f:id:bluepixel:20200425002632p:plain

Slackから送られてくるリクエストはapplication/x-www-foorm-urlencodedなので、これを扱いやすいようにJSONに変換する処理を入れる。

このテンプレートのベストプラクティスについてはAWS Developer Forumでも議論されているようだが、下記の記事のものが良い感じの落とし所だと思う。
公式で提供してほしい。。。

qiita.com

これで適当にステージ作ってデプロイして、生成されたURLをさっき作ったSlackアプリの方に設定します。

これでSlackに/ipとポストすると{ statusCode: 200 }が返ります。

CloudWatch Logsにはパラメータのログが出るのでスキーマを確認しておきます。

{
  token: "**********",
  team_id: "**********",
  team_domain: "**********",
  channel_id: "**********",
  channel_name: "privategroup",
  user_id: "**********",
  user_name: "**********",
  command: "/ip",
  response_url: "https://hooks.slack.com/commands/**********",
  trigger_id: "**********"
}

tokenは最初にメモしたVerification Tokenに相当します。これが一致すると、正しくSlackからリクエストが送られてきているという証明になります。

user_idはコマンドを実行したユーザーです。ここを見れば権限が制御できそうですね。

4. 認証

最初に基本的な認証を入れておきます。
Verfication Tokenはすでに説明した通りです。ちなみにこれすでにDeprecatedになっている方法です。気になる人はsigned secretsを使ってください。

api.slack.com

あとはユーザーに制限をかけておきます。 許可するユーザーのSlackのIDを環境変数ALLOWED_USERSにカンマ区切りで持っておき判定することにします。

ユーザーIDの調べ方ですが、自分のアカウントは設定から、
ワークスペースの管理者であれば管理画面から一括でCSVダウンロードができます。

f:id:bluepixel:20200425010350p:plain

2つの認証をかけた状態です。

if (event["token"] != process.env.VERIFY_TOKEN) {
    return { "statusCode": 400, "body": "invalid verification token" }
}
    
if (!process.env.ALLOWED_USERS.split(",").includes(event['user_id'])) {
    return { "statusCode": 400, "body": "non-allowed user" }
}

5. dialog.open

中身の実装に移っていくわけですが、少しアーキテクチャを考えます。
スラッシュコマンドからパラメータを受け取って処理する場合、ユーザーに手順を覚えてもらう必要があります。引数の順番やフォーマットが正しくないとパースする側でしんどいという問題もあります。

application/x-www-form-urlencodedで送られてくるパラメータは、ユーザーが入力した部分は全てtextというキーに入れられ、スペースが+に置換された一つの文字列となるので、順番を間違えるともう終わりです。

やはりUIが必要になります。

今回はdialog.openというAPIを使って、ダイアログでユーザーの入力を補助することにします。 f:id:bluepixel:20200425011447p:plain

なので本命の追加・削除処理の前に、ダイアログを開くための関数を別で作ります。

6. 追加ダイアログの実装

対象となるセキュリティグループを取得します。
環境変数ALLOWED_SGSにカンマ区切りでグループIDを入れておきます。 取得したセキュリティグループをセレクトボックスで選択式にして、あとはIPアドレスとコメントの入力欄を用意したダイアログを返します。

const aws = require("aws-sdk")
const ec2 = new aws.EC2()
const axios = require("axios")

exports.handler = async (event, context) => {
    /*
     (中略)認証
    */
    
    const headers = {
      "Content-Type": "application/json; charset=utf-8",
      "Authorization": `Bearer ${process.env.SLACK_TOKEN}`
    }
    const body = await build_body(event["trigger_id"])

    const res = await axios.post('https://slack.com/api/dialog.open', body, {"headers":headers})
      .then(function (response) {
        console.log(response.data);
        return response.data;
      }).catch(function (err) {
        console.error(err)
        return
      })

    if(!res || !res["ok"]) return { statusCode: 400, body: "something went wrong." }
    return { statusCode: 200 }
};


async function getGroup() {
  return ec2.describeSecurityGroups({"GroupIds": process.env.ALLOWED_SGS.split(",")}).promise().then(function(data) {
    return data
  })
}

async function build_body(triggerId) {
  const groups = await getGroup()
  return {
    "trigger_id": triggerId,
    "dialog": {
      "callback_id": "add",
      "title": "ssh接続元許可IPアドレスの追加",
      "notify_on_cancel": false,
      "elements": [
        {
          "type": "select",
          "label": "Security Group",
          "name": "securityGroup",
          "options": groups["SecurityGroups"].map(group => {
            return {
              "value": group["GroupId"],
              "label": group["GroupName"]
            }
          })
        },  
        {
          "type": "text",
          "label": "IP Address",
          "name": "ipAddress"
        },
        {
          "type": "text",
          "label": "Comment",
          "name": "comment"
        }
      ]
    }
  }
}

ダイアログの出し方については dialog.openAPIのドキュメントを参照してください。
dialog.open method | Slack

ユーザーがスラッシュコマンドを実行した時のパラメータに含まれるtrigger_idを使ってダイアログを表示させます。このIDは有効期限が3秒しかないため、時間のかかる処理は組めません。

ダイアログの中身のelementsの仕様はドキュメントがまとまっていないので少しハマりました。BlockKitとも微妙に違うのでいろいろ試す必要があります。

f:id:bluepixel:20200425145016p:plain

セレクトボックス にはセキュリティグループの一覧が表示されます。
あらかじめ分かりやすいDescriptionをつけておく必要があります。
GroupNameにしていないのは、値が必須ではないからです。

f:id:bluepixel:20200425145028p:plain

あとは、SlackAPIの仕様としてヘッダーにcharsetを指定しないとWarningが出るのでつけています。また、AuthorizationヘッダーでBearerトークン形式で認証をします。Lambdaの環境変数トークンを追加するのを忘れずに。

const headers = {
  "Content-Type": "application/json; charset=utf-8",
  "Authorization": `Bearer ${process.env.SLACK_TOKEN}`
}

実装は終わりで、あとはセキュリティグループを取得するためにLambdaの実行ロールに以下のインラインポリシーを追加します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "0",
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeSecurityGroups"
            ],
            "Resource": "*"
        }
    ]
}

7. 追加APIの実装

ダイアログのsubmitをトリガーにして発火する関数を作ります。
リソースを/addとしてAPI Gatewayに登録し、URLを[Interactivity]の[Request Url]に設定します。

f:id:bluepixel:20200425200326p:plain

const aws = require("aws-sdk")
const ec2 = new aws.EC2()
const axios = require("axios")

exports.handler = async (event, context) => {
  /*
    (中略)認証
  */
    
  const res = await addGroup(
    event["submission"]["securityGroup"], 
    event["submission"]["ipAddress"], 
    event["submission"]["comment"]
  )

  const res2 = await postResult(event, res)

  return { }
}


async function addGroup(groupId, ipAddress, comment) {
  const params = {
    GroupId: groupId, 
    IpPermissions: [
      {
        FromPort: 22,
        ToPort: 22,
        IpProtocol: "tcp", 
        IpRanges: [
          {
            CidrIp: `${ipAddress}/32`, 
            Description: comment
          }
        ]
      }
    ]
  }
  return ec2.authorizeSecurityGroupIngress(params).promise()
    .then(function(data) {
      return true
    }).catch(function (err) {
      console.error(err)
      return
    })
}

async function getGroup(groupId) {
  return ec2.describeSecurityGroups({"GroupIds": [groupId]}).promise().then(function(data) {
    return data
  })
}

async function postResult(event, res) {
  const headers = {
    "Content-Type": "application/json; charset=utf-8",
    "Authorization": `Bearer ${process.env.SLACK_TOKEN}`
  }
  const groups = await getGroup(event["submission"]["securityGroup"])
  let body = {
    "channel": "#ip_address_changer",
    "icon_emoji": ":hammer:",
    "text": "",
    "attachments": [
      {
        "color": "#36a64f",
        "author_name": "IPアドレスの追加/削除",
        "fields": [
          {
            "title": "■ Executed By",
            "value": event["user"]["name"],
            "short": false
          },
          {
            "title": "■ Security Group",
            "value": groups["SecurityGroups"][0]["GroupName"],
            "short": false
          },
          {
            "title": "■ Ip Address",
            "value": event["submission"]["ipAddress"],
            "short": false
          },
          {
            "title": "■ Comment",
            "value": event["submission"]["comment"],
            "short": false
          }
        ]
      }
    ]
  }
  if (res) body["text"] = "Success."
  else body["text"] = "Something went wrong."
  
  return axios.post("https://slack.com/api/chat.postMessage", body, {"headers":headers})
    .then(function (response) {
      return response.data;
    }).catch(function (err) {
      console.error(err)
      return
    }) 
}

まずフォームの受信ですが、スラッシュコマンドの時とSlackから送られてくるペイロードの形式が異なります。同じapplication/x-www-form-urlencodedなんですが、中途半端にJSON文字列化されたフォームがパーセントエンコードされた状態になっています。

payload%3D%7B%22type%22%3A%22dialog_submission%22%2C%22token%22%3A%22xxxxxx%22%2C%22action_ts%22%3A%221587799384.191353%22%2C%22team%22%3A%7B%22id%22%3A%22xxxxxxx%22%2C%22domain%22%3A%22xxxxxx%22%7D%2C%22user%22%3A%7B%22id%22%3A%22xxxxxx%22%2C%22name%22%3A%22xxxxxx%22%7D%2C%22channel%22%3A%7B%22id%22%3A%22xxxxxx%22%2C%22name%22%3A%22xxxxxx%22%7D%2C%22submission%22%3A%7B%22securityGroup%22%3A%22xxxxxx%22%2C%22ipAddress%22%3A%221.1.1.1%22%2C%22comment%22%3A%22test%22%7D%2C%22callback_id%22%3A%22add%22%2C%22response_url%22%3A%22xxxxxx%22%2C%22state%22%3A%22%22%7D

デコードするとこんな感じです。

payload={"type":"dialog_submission","token":"xxxxxx","action_ts":"1587799384.191353","team":{"id":"xxxxxxx","domain":"xxxxxx"},"user":{"id":"xxxxxx","name":"xxxxxx"},"channel":{"id":"xxxxxx","name":"xxxxxx"},"submission":{"securityGroup":"xxxxxx","ipAddress":"1.1.1.1","comment":"test"},"callback_id":"add","response_url":"xxxxxx","state":""}

これをJSONマッピングするためにまたVTLを書きます。
あまりない形式っぽく、探してもネットに落ちてなかったので適当に自作します。

#set($raw = $input.body)
#set($payload = $raw.replace("payload=",""))
#set($jsonPayload = $util.urlDecode($payload))
$jsonPayload

これでLambda側で普通のJSONとして受け取れます。

ユーザーが選択・入力した値はsubmissionに入っているので、それを使ってAWSAPIを呼びます。試しにプロトコルtcpの22番ポートを許可してみます。
サブネットマスク/32に限定します。

Lambdaの実行ロールにec2:AuthorizeSecurityGroupIngressを追加しておいてください。

次にレスポンスです。

submit後にダイアログを閉じるためには、ボディが空のレスポンスを返す必要があります。{ statusCode: 200 }だとダイアログが残り続けます。

監査のために実行したコマンドと結果を投稿します。
ここで、リクエストに含まれるresponse_url は使いません。
なぜかというとこのresponse_urlに対してポストしたメッセージはephemeral、つまりそのユーザーにしか見えないメッセージになるので監査になりません。

普通にchat.postMessageAPIを使います。 Slackアプリの設定でスコープを追加してください。

これでひとまず完成です。

f:id:bluepixel:20200425222643p:plain

f:id:bluepixel:20200425202321p:plain

8. 削除用ダイアログの実装

次は削除の方を実装します。 ダイアログの返却に条件分岐を入れます。

スラッシュコマンドで引数を取るようにして/ip add または /ip remove で条件分岐します。ここの引数は空白スペースが+で置換されるので、余計なパラメータは取り除きます。

const command = event["text"] === undefined ? "" : event["text"].split("+")[0]
let res
if (command == "add") res = await openAddMenu(event)
else if (command == "remove") res = await openRemoveMenu(event)
else return { statusCode: 400, body: "type `/ip add` or `/ip remove`" }

削除のときはDescriptionがいらなくなるのでフィールドを削除します。
IPアドレスは依然、手入力してもらいます。
理想は、セキュリティグループが選択されたら、そのグループにあるルールのIPアドレスを列挙して動的にセレクトボックスを構築したいんですが、仕組みが結構面倒なのでさぼります。

9. 削除APIの実装

ダイアログを構築する際に callback_id を指定していましたが、これはsubmit時に送られてきます。ここの値を見て、追加のダイアログか削除のダイアログかを判定します。

[Interactivity] の [Request URL]って複数設定できないんですかね 🤔

if (event["callback_id"] == "add") {
 res = await addRule(
    event["submission"]["securityGroup"], 
    event["submission"]["ipAddress"], 
    event["submission"]["comment"]
  )
} else {
  res = await removeRule(
    event["submission"]["securityGroup"], 
    event["submission"]["ipAddress"]
  )
}

インバウンドルールの削除は revokeSecurityGroupIngressポリシーが必要です。例によって実行IAMロールに追加してください。

終わり

思いの外時間がかかってしまった。
ハマりどころはVTLのマッピングテンプレートやSlackAPIのドキュメントですね。
結構このドキュメントは足りないし嘘つきます。

コードは全部置いておくので使ってください。 github.com