Open6

AWS Step Functionsで何かしたい、Discordのgatewayイベントで何かする仕組みを作ってみる

ピン留めされたアイテム
azechiazechi
import {
  Stack,
  StackProps,
  aws_iam as iam,
  aws_stepfunctions as sfn,
  aws_stepfunctions_tasks as tasks,
  aws_apigatewayv2 as apigw,
  ArnFormat,
  CfnOutput,
} from "aws-cdk-lib";
import { Construct } from "constructs";


export class StepStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const httpApi_inner = new apigw.CfnApi(this, "innerapi", {
      name: "discord_webhook_stf",
      protocolType: "HTTP",
    });

    //stage
    new apigw.CfnStage(this, "innerstage", {
      apiId: httpApi_inner.ref,
      stageName: "$default",
      autoDeploy: true,
    });

    const integration_inner = new apigw.CfnIntegration(
      this,
      "sfnIntegrationinner",
      {
        apiId: httpApi_inner.ref,
        integrationType: "HTTP_PROXY",
        integrationMethod: "ANY",
        integrationUri: "<<ここにDiscord Webhook URL>>",
        payloadFormatVersion: "1.0",
        requestParameters: {
          "overwrite:header.User-Agent":
            "DiscordBot (app://my-bot, 0.0.1)",
          "overwrite:header.Content-Type": "application/json",
        },
      }
    );

    new apigw.CfnRoute(this, "routeinner", {
      apiId: httpApi_inner.ref,
      routeKey: "ANY /",
      authorizationType: "AWS_IAM",
      target: `integrations/${integration_inner.ref}`,
    });

    const stateMachine = new sfn.StateMachine(this, "MyStateMachine", {
      definition: new MyCallApiGatewayHttpApiEndpoint(this, "InvokeAPIGW", {
        apiId: httpApi_inner.ref,
        apiStack: this,
        authType: tasks.AuthType.IAM_ROLE,
        method: tasks.HttpMethod.POST,
        requestBody: sfn.TaskInput.fromJsonPathAt("$"),
      }).next(new sfn.Succeed(this, "SUCCEED")),
    });

    const invokeStepFunctionRole = new iam.Role(this, "MyAPIRole", {
      assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
    });

    stateMachine.grantStartExecution(invokeStepFunctionRole);

    const httpApi = new apigw.CfnApi(this, "MyCfnApi", {
      name: "step-MyApi",
      protocolType: "HTTP",
    });

    const integration = new apigw.CfnIntegration(this, "sfnIntegration", {
      apiId: httpApi.ref,
      credentialsArn: invokeStepFunctionRole.roleArn,
      integrationType: "AWS_PROXY",
      integrationSubtype: "StepFunctions-StartExecution",
      payloadFormatVersion: "1.0",
      requestParameters: {
        StateMachineArn: stateMachine.stateMachineArn,
        Input: "$request.body",
      },
    });

    new apigw.CfnRoute(this, "route", {
      apiId: httpApi.ref,
      routeKey: "$default",
      authorizationType: "AWS_IAM",
      target: `integrations/${integration.ref}`,
    });

    new apigw.CfnStage(this, "DefaultStage", {
      apiId: httpApi.ref,
      stageName: "$default",
      autoDeploy: true,
    });

    const policy = new iam.PolicyStatement({
      actions: ["execute-api:Invoke"],
      resources: [
        this.formatArn({
          service: "execute-api",
          resource: httpApi.ref,
          arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
          resourceName: "$default/POST/",
        }),
      ],
    });

    const user = iam.User.fromUserName(this, "api-user", "<<IAMユーザー名>>");
    user.addToPrincipalPolicy(policy);

    new CfnOutput(this, "endpoint", {
      value: httpApi.attrApiEndpoint,
    });
  }
}
azechiazechi

作るもの

Discordのボイスチャンネルへユーザーが接続・切断したらテキストチャンネルに投稿する

  • ボットはVoiceStateUpdateイベントを受信する
  • ボットはAPI Gatewayへイベントを転送する
  • API Gatewayはワークフローの実行を開始する
  • ワークフローはAPI Gatewayを実行する
  • API GatewayはDiscordのWebhookでメッセージを投稿する

関連するもの

  • Amazon API Gateway
    • HTTP API
    • IAM認証
      • IAMユーザーの署名付きリクエストのみを受け付ける
    • 統合
      • Step FunctionsのExecutionWorkflowを呼びだす
  • AWS Step Functions
    • ワークフロー
    • API Gateway統合
      • タスクとしてAPI Gatewayを実行できる
      • 指定できるMethodは1つだけ
  • Discordボット
  • Discord Webhook
    • Amazon API GatewayのHTTP API HTTP_ProxyでDiscordにメッセージを投稿する
  • 構成
    • AWS CDK
      • AWS上のリソースの構成をコードにする
      • 構成図を作る
    • Githubリポジトリにする
      • Github Actionsでデプロイできるようにする
  • 監視
    • 課金対象
      • Amazon API Gateway
      • AWS Step Functions
azechiazechi

RustでAmazon API GatewayのIAM認証付きHTTP APIを呼ぶ

とりあえず

  • rustls
  • tokio
  • reqwest

execute-apiできるユーザーのアクセスキー
アクセスキーはキーIDとシークレットキーの組み

credentials
署名を作る

https://github.com/awslabs/smithy-rs
aws_sig_auth
Signature Version 4 (SigV4)

use aws_types::{Credentials, SigningService, region::SigningRegion};
use aws_smithy_http::body::SdkBody;
use aws_sig_auth::signer::{OperationSigningConfig, RequestConfig, SigV4Signer};
use http::Request;

use std::time::SystemTime;
use std::env;

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {

    let payload = "{\"IsHelloWorldExample\": true}";

    let mut request = Request::builder()
        .uri("https://w0kphty5fk.execute-api.us-east-1.amazonaws.com/")
        .method("POST")
        .header("Content-Type", "application/json")
        .body(SdkBody::from(payload))
        .unwrap();

    let key_id = env::var("key_id").expect("key_id");
    let secret = env::var("secret_key").expect("secret_key");

    let creds = Credentials::from_keys(key_id, secret, None);

    let now = SystemTime::now();
    let signer = SigV4Signer::new();

    let request_config = RequestConfig {
        request_ts: now,
        region: &SigningRegion::from_static("us-east-1"),
        service: &SigningService::from_static("execute-api"),
        payload_override: None,
    };

    signer.sign(
        &OperationSigningConfig::default_config(),
        &request_config,
        &creds,
        &mut request
    ).unwrap();

    let (parts, _) = request.into_parts();
    let request = Request::from_parts(parts, payload);


    let request: reqwest::Request = request.try_into().unwrap();

    let client = reqwest::Client::new();

    let res = client.execute(request).await?;

    eprintln!("Response: {:?} {}", res.version(), res.status());
    eprintln!("Headers: {:#?}\n", res.headers());

    let body = res.text().await?;

    println!("{}", body);
    Ok(())
}
azechiazechi

Amazon API GatewayのIAM認証付きHTTP APIを呼ぶためのHTTPリクエスト

どういうリクエストを作るのかPythonでやってみた。

Dateヘッダーでは面倒なので、x-amz-dateを使った

参考:
https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html

#以下のコードは、
#https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html
#においてapache2.0ライセンスで配布される
#"Copyright 2010-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved"
#なコードをapi gateway呼び出し用に改変したものです

method = 'POST'
endpoint = 'https://w0kphty5fk.execute-api.us-east-1.amazonaws.com/'
host = 'w0kphty5fk.execute-api.us-east-1.amazonaws.com'
region = 'us-east-1'
service = 'execute-api'

content_type = 'application/json'

payload =  '{'
payload += '"IsHelloWorldExample": true'
payload +=  '}'

def sign(key, msg):
    return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()

def getSignatureKey(key, date_stamp, regionName, serviceName):
    kDate = sign(('AWS4' + key).encode('utf-8'), date_stamp)
    kRegion = sign(kDate, regionName)
    kService = sign(kRegion, serviceName)
    kSigning = sign(kService, 'aws4_request')
    return kSigning

access_key_id = 'AAAAAAAAAAAAAAAAAAAA'
secret_key = 'secret_key'

# Create a date for headers and the credential string
t = datetime.datetime.utcnow()
amz_date = t.strftime('%Y%m%dT%H%M%SZ')
date_stamp = t.strftime('%Y%m%d') # Date w/o time, used in credential scope

# ************* TASK 1: CREATE A CANONICAL REQUEST *************
canonical_uri = '/'
canonical_querystring = ''

canonical_headers = f'content-type:{content_type}\nhost:{host}\nx-amz-date:{amz_date}\n'
signed_headers = 'content-type;host;x-amz-date'

payload_hash = hashlib.sha256(payload.encode('utf-8')).hexdigest()

canonical_request = method + '\n'
canonical_request += canonical_uri + '\n' 
canonical_request += canonical_querystring + '\n' 
canonical_request += canonical_headers + '\n' 
canonical_request += signed_headers + '\n' 
canonical_request += payload_hash

hashed_canonical_request = hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()

# ************* TASK 2: CREATE THE STRING TO SIGN*************
ALGORITHM = 'AWS4-HMAC-SHA256'

credential_scope = f'{date_stamp}/{region}/{service}/aws4_request'

string_to_sign = f'{ALGORITHM}\n{amz_date}\n{credential_scope}\n{hashed_canonical_request}'

signing_key = getSignatureKey(secret_key, date_stamp, region, service)
signature = hmac.new(signing_key, (string_to_sign).encode('utf-8'), hashlib.sha256).hexdigest()

authorization_header = ALGORITHM + ' '
authorization_header += 'Credential=' + access_key_id + '/' + credential_scope + ', ' 
authorization_header += 'SignedHeaders=' + signed_headers + ', '
authorization_header += 'Signature=' + signature

headers = {'Content-Type':content_type,
           'X-Amz-Date':amz_date,
           'Authorization':authorization_header}

# ************* SEND THE REQUEST *************
print('\nBEGIN REQUEST++++++++++++++++++++++++++++++++++++')
print('Request URL = ' + endpoint)

r = requests.post(endpoint, data=payload, headers=headers)

#print(f'{endpoint}\n[{payload}]\n{headers}')

print('\nRESPONSE++++++++++++++++++++++++++++++++++++')
print('Response code: %d\n' % r.status_code)
print(r.text)
azechiazechi

API Gatewayの quick create

  • API作成のときにTargetを指定すれば、$defaultのルートと$defaultのステージが生成される。
  • 統合タイプ HTTP ProxyとLambda Proxyにのみ対応している。
    • when calling the CreateApi operation: Target only supports HTTP Proxy or Lambda Proxy

WebのマネジメントコンソールでHTTP統合を選択したときに作られるルートのRouteKeyの既定値はANY /

ANY /$default

  • integrationUriが https://.../path/xyzのときGET /すると
    • $defaultだと、 GET .../xyz/ のリクエストになる
    • ANY / だと、GET .../xyz のリクエストになる
  • パススルーするなら ANY /{proxy+}
azechiazechi

CDK aws_stepfunctions_tasks.CallApiGatewayHttpApiEndpointが無効なPolicyを生成する

aws-cdk-lib@2.34.2

Step FunctionsのAPI Gateway統合で、IAM認証付きのHTTP APIを呼ぶとき

  • TaskのAuthTypeはIAM_ROLE
  • apiPath、stageを指定せずにデフォルト値を使用する

認証タイプをIAM_ROLEにするならば、apiPathとstageを設定しないとexecute-api:Invokeポリシーのリソース指定でundefinedという文字列になってアクセス許可にならい

  • 問題のあるPolicy.Resource
arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${apiId}/undefined/POSTunefined
  • 期待する値 apiPath: "/", stageName: "$default"
arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${apiId}/$default/POST/

とりあえずの対応をした

  • ちゃんと対応するにはパラメーターマッピングとかちゃんと考えないとならない
  • 今回は、apiPathは/、stageNameは$defaultで動作すればいい
class MyCallApiGatewayHttpApiEndpoint extends tasks.CallApiGatewayHttpApiEndpoint {

  protected readonly taskPolicies?: iam.PolicyStatement[] | undefined;
  protected readonly arnForExecuteApi: string;

  constructor(scope:Construct, id: string, props: CallApiGatewayHttpApiEndpointProps) {
    super(scope, id, props);

    const {apiStack, apiId, stageName, method, apiPath} = props;

    this.arnForExecuteApi = apiStack.formatArn({
      service: 'execute-api',
      resource: apiId,
      arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
      resourceName: `${stageName??"$default"}/${method}${apiPath??"/"}`
    });
    this.taskPolicies = super.createPolicyStatements();
  }
}

CallApiGatewayHttpApiEndpointのコンストラクタでtaskPoliciesが設定される。継承したコンストラクタでarnForExecuteApiを設定しなおしてcreatePolicyStatementsを再実行する。

参考
https://github.com/aws/aws-cdk/blob/e82ba52ac5c27863cc30309502ecd45810f96803/packages/%40aws-cdk/aws-stepfunctions-tasks/lib/apigateway/call-http-api.ts#L57-L66

https://github.com/aws/aws-cdk/blob/e82ba52ac5c27863cc30309502ecd45810f96803/packages/%40aws-cdk/aws-stepfunctions-tasks/lib/apigateway/base.ts#L57-L68

https://github.com/aws/aws-cdk/blob/e82ba52ac5c27863cc30309502ecd45810f96803/packages/%40aws-cdk/aws-stepfunctions/lib/states/task-base.ts#L261-L266

https://github.com/aws/aws-cdk/blob/cea1039e3664fdfa89c6f00cdaeb1a0185a12678/packages/%40aws-cdk/aws-stepfunctions/lib/states/state.ts#L233-L248