Slackのスラッシュコマンドとダイアログを使ってEC2のセキュリティグループを編集する
経緯
フルリモート体制になっているので、sshの接続元などにオフィス以外の任意のIPアドレスを追加する必要が出てきた。
メンバーの自宅はIPが固定されていないため、IPアドレスが変わるたびにインフラ担当に作業を依頼する必要がある。
作業の手間とヒューマンエラーをなくすために、自動化を試みた。
要件
- 許可したいIPアドレスと対象のセキュリティグループを入力するとインバウンドルールが追加される。
- 追加された任意のIPアドレスを削除する機能も用意する。
- 機能を使用できるユーザーと対象のセキュリティグループをあらかじめ制限する。
- 誰がいつどんな操作を行ったのか履歴を残す。
使用技術
やっぱりこういうボットはSlackかなあと思ったのでスラッシュコマンドで作ることにしました。
スラッシュコマンドはGASで作る人が多い気がするんですが、今回はAWSのAPIを用いるのでこれ以外の選択肢はまあないですね。
1. Slackアプリの設定
以下からアプリを新しく作成する。
Slack API: Applications | Slack
[Slash Commands] を選択し、コマンドとURLを入力する。
この時点ではURLが決まっていないので適当に入れておく。
コマンドは/ip
とする。
Usageにはパラメータのヒントなどを入れておくのだが、とりあえず空にしておく。
ワークスペースにインストールして一旦作業は終わり。
Verification Token
とBot 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
というリソース名にする。
統合リクエストに先ほどの関数を指定して、マッピングテンプレートを追加する。
Slackから送られてくるリクエストはapplication/x-www-foorm-urlencoded
なので、これを扱いやすいようにJSONに変換する処理を入れる。
このテンプレートのベストプラクティスについてはAWS Developer Forumでも議論されているようだが、下記の記事のものが良い感じの落とし所だと思う。
公式で提供してほしい。。。
これで適当にステージ作ってデプロイして、生成された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
を使ってください。
あとはユーザーに制限をかけておきます。
許可するユーザーのSlackのIDを環境変数ALLOWED_USERS
にカンマ区切りで持っておき判定することにします。
ユーザーIDの調べ方ですが、自分のアカウントは設定から、
ワークスペースの管理者であれば管理画面から一括でCSVダウンロードができます。
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を使って、ダイアログでユーザーの入力を補助することにします。
なので本命の追加・削除処理の前に、ダイアログを開くための関数を別で作ります。
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.open
APIのドキュメントを参照してください。
dialog.open method | Slack
ユーザーがスラッシュコマンドを実行した時のパラメータに含まれるtrigger_id
を使ってダイアログを表示させます。このIDは有効期限が3秒しかないため、時間のかかる処理は組めません。
ダイアログの中身のelements
の仕様はドキュメントがまとまっていないので少しハマりました。BlockKitとも微妙に違うのでいろいろ試す必要があります。
セレクトボックス にはセキュリティグループの一覧が表示されます。
あらかじめ分かりやすいDescription
をつけておく必要があります。
GroupName
にしていないのは、値が必須ではないからです。
あとは、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]に設定します。
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
に入っているので、それを使ってAWSのAPIを呼びます。試しにプロトコルtcp
の22番ポートを許可してみます。
サブネットマスクも/32
に限定します。
Lambdaの実行ロールにec2:AuthorizeSecurityGroupIngress
を追加しておいてください。
次にレスポンスです。
submit後にダイアログを閉じるためには、ボディが空のレスポンスを返す必要があります。{ statusCode: 200 }
だとダイアログが残り続けます。
監査のために実行したコマンドと結果を投稿します。
ここで、リクエストに含まれるresponse_url
は使いません。
なぜかというとこのresponse_url
に対してポストしたメッセージはephemeral
、つまりそのユーザーにしか見えないメッセージになるので監査になりません。
普通にchat.postMessage
APIを使います。
Slackアプリの設定でスコープを追加してください。
これでひとまず完成です。
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