🐰

AWS CLIやSDKからAWSへのAPIリクエストをGoでProxyしたい!

2024/12/03に公開

はじめに

re:InventのKeynoteが楽しみなガイです。

飲み会帰りの電車で家とは逆向きに向かい終点までついたり、その後正しい向きで目的の駅を乗り過ごしたり散々な帰路の中、AWSへのAPI CallをProxyしたいなと思ったらしい履歴がSlackに残ってたので試してみました 🐰🍺

AWSのAPIについて

まずはAWSのSDKやCLIコマンドからリクエストを受け取るにはどのようにSDKなどからリクエストが行われるかを知らなければなりません。

詳しいことは公式にもまとめられているとおりですが、基本的にはサービスエンドポイントとActionsによって問い合わせる内容が決まります。

各サービスごとにエンドポイントが定められており、そこに対してどのActionを利用したいのか?を主にhttpリクエストで送ることになります。

各種アクションは各サービスごとのドキュメントに書かれている通りです。

https://docs.aws.amazon.com/ja_jp/

レスポンスとしてはxmlが返ってくることもありますし、jsonが返ってくることもあります。

同じサービスの同じActionでもリクエスト方法によってどちらかが返ってくることもあります。

この辺は詳しくまとめられなかったのですがSmithyの AWS Protocols というドキュメントを読むと片鱗が見れるのではないでしょうか。

またリクエストは実際の環境では署名が検証され、正しいリクエストであることが求められます。

これは AWSSignature Version 4 (Sigv4) と呼ばれる署名プロトコルで、セキュリティ確保に役立ちます。

またこれはAWS SDKやAWS CLIを利用した際には自動で署名が行われるため気にしなくて良いでしょう。

API リクエストに対する AWS Signature Version 4 - AWS Identity and Access Management

curlでAPIを呼んでみる

ではここで実際にcurlを使ってAPI Callを試してみましょう。

今回は試しに原初のサービスであるSQSで実験してみます。

Localstackを使用してみますが、実際のAWS環境でも同じことは可能です。

1. localstackをDockerで起動する.

docker run -d --name localstack -p 4566:4566 -p 4571:4571 -e SERVICES=sqs -e DEBUG=1 localstack/localstack

起動したら別のタブでも開いてください。

2. CreateQueueのリクエストを送る

今回はCreateQueueのActionを使いたいのでCreateQueue - Amazon Simple Queue Serviceにしたがってリクエストを送ります。

export AWS_ACCESS_KEY_ID=dummykey
export AWS_SECRET_ACCESS_KEY=dummysecret
export AWS_SESSION_TOKEN=dummysession
curl -X POST localhost:4566 \
  --aws-sigv4 "aws:amz:ap-northeast-1:sqs" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  -H "X-Amz-Target: AmazonSQS.CreateQueue" \
  -d '{"QueueName": "test-curl-queue"}'

上記のコマンドを少し深ぼってみたいと思います。

まずは見慣れない文字列として --aws-sigv4 オプションがあります。
これはcurl 7.75.0から利用可能になったオプションでここに適当な値を入力すればよしなに署名してリクエストを送ってくれます。
Curl: RELEASE: curl 7.75.0

curl - How To Use

今回はフルで aws:amz:ap-northeast-1:sqsを指定してみましたが、ドキュメントに書いてあるとおりエンドポイントにリージョンとサービスがある場合は省略可能なため以下のようなリクエストも有効です.

# これは実際のap-northeast-1のsqsのエンドポイント。 リージョンとサービス名が含まれているため aws-sigv4の値は省略
curl -X Post https://sqs.ap-northeast-1.amazonaws.com/ \
  --aws-sigv4 "aws:amz::" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
  -H "X-Amz-Target: AmazonSQS.CreateQueue" \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  -H "Content-Type: application/x-amz-json-1.0" \
  -d '{"QueueName": "test-curl-queue"}'

また --aws-sigv4を有効にした際には、 --user オプションに対して ${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY} を設定する必要があります。

これはIAM Userを利用した場合の認証で、IAM Roleによる一時的な権限での接続を行う場合には X-Amz-Security-Token ヘッダーに対して AWS-SESSION-TOKEN を有効にする必要があります。

また Content-Type も設定する必要があります。これはどのActionに対して何が有効なのかは調べきれなかったので良かったら情報お待ちしております!

そして実際のリクエストパラメーターをBodyに含めました。

今回はQueueNameが必須だったので、それをJsonとして含めて送信しました。

このコマンドが無事通ればまずはcurlでSQSのQueueを作成することができます。

3. ListQueueのリクエストを送って中身を確認する

同様の手順でListQueueのActionのリクエストも送ってみます。

ドキュメントはこちらです。ListQueues - Amazon Simple Queue Service

curl -X POST localhost:4566 \
  -H "X-Amz-Target: AmazonSQS.ListQueues" \
  --aws-sigv4 "aws:amz:ap-northeast-1:sqs" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  -H "Content-Type: application/x-amz-json-1.0" \
  -d '{}'

結果は以下のようになりました。

 {"QueueUrls": ["http://sqs.ap-northeast-1.localhost.localstack.cloud:4566/000000000000/test-curl-queue"]

もちろんこれは aws cliを使用してもQueueが作成できていることが確認できるでしょう。

Proxyサーバーを実装してみる

さてなんとなくどんな形でリクエストが飛んでくるかが見えてきたと思います。

とはいえ全てを網羅するのはしんどいので一旦最小限で動くコードを作ってみました。

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/aws/aws-sdk-go-v2/service/sqs"
)

func main() {
	//endpointとしては単一のhandle
	http.HandleFunc("/", handler)

	port := ":8081"
	fmt.Printf("Starting server on %s\n", port)
	log.Fatal(http.ListenAndServe(port, nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
	if targetAction == "" {
		targetAction = r.Header.Get("X-Amz-Target")
	}

	switch targetAction {
	case "AmazonSQS.CreateQueue", "CreateQueue":
		handleCreateQueue(w, r)
	case "AmazonSQS.ListQueues", "ListQueues":
		handleListQueues(w, r)
		return
	default:
		http.Error(w, "Unsupported action", http.StatusBadRequest)
		return
	}
}

func handleCreateQueue(w http.ResponseWriter, r *http.Request) {
	var req sqs.CreateQueueInput
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, fmt.Sprintf("Invalid request body: %s", err), http.StatusBadRequest)
		return
	}

	response := sqs.CreateQueueOutput{
		QueueUrl: req.QueueName,
	}
	//本当はリクエストに応じてxmlだったりjsonを出し分ける
	w.Header().Set("Content-Type", "application/x-amz-json-1.0")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(response)
}

func handleListQueues(w http.ResponseWriter, r *http.Request) {
	var req sqs.ListQueuesInput
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, fmt.Sprintf("Invalid request body: %s", err), http.StatusBadRequest)
		return
	}

	response := sqs.ListQueuesOutput{
		QueueUrls: []string{"https://sqs.ap-northeast-1.amazonaws.com/123456789012/queue"},
	}
	//本当はリクエストに応じてxmlだったりjsonを出し分ける
	w.Header().Set("Content-Type", "application/x-amz-json-1.0")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(response)
}

このサーバーに対して以下のようなリクエストを投げてみます

curl -X POST localhost:8081 \
  -H "X-Amz-Target: AmazonSQS.ListQueues" \
  -H "Content-Type: application/x-amz-json-1.0" -d '{}'

or

curl -X POST localhost:8081/?Action=ListQueues \
  -H "Content-Type: application/x-amz-json-1.0" \
  -d '{}'

もちろんただのhttpサーバーを立て、特に検証もせずに投げているだけなのでこれでも意図したレスポンスは帰ってきます。

Sigv4の検証について

実は上記のコードでも無事に動くのですが、せっかくなのでsigv4の検証も含めてセキュアにやってみたいです。

ドキュメントはこちらにあるので、これに沿って実装を行ってみようと思います。

AWS の API を理解しよう !
中級編 ~ リクエストの署名や CLI/SDK の中身を覗いてみる

AWS API リクエスト署名の要素 - AWS Identity and Access Management

検証用の関数を以下のように作成しました。

ファイルは行数が多くなり分けたのでよしなにほかは埋めてください。 実装は取りあえず動くで作ったのでだいぶ汚いところがあるのはご容赦ください🐰

func verifySigV4(r *http.Request) error {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		return err
	}
	r.Body = io.NopCloser(bytes.NewReader(body))

	// 1. Authorizationヘッダーをパース
	authHeader := r.Header.Get("Authorization")
	if !strings.HasPrefix(authHeader, "AWS4-HMAC-SHA256") {
		return fmt.Errorf("Invalid Auth Header")
	}

	parts := strings.Split(authHeader, " ")
	if len(parts) != 4 {
		return fmt.Errorf("Invalid Auth Header")
	}

	// Credential部分を抽出
	params := make(map[string]string)
	for _, part := range parts {
		for _, param := range strings.Split(part, ",") {
			kv := strings.SplitN(param, "=", 2)
			if len(kv) == 2 {
				params[kv[0]] = kv[1]
			}
		}
	}

	// 必要な情報を抽出
	credential := params["Credential"]
	signedHeaders := params["SignedHeaders"]
	clientSignature := params["Signature"]

	// Credentialのフォーマット: ACCESS_KEY/DATE/REGION/SERVICE/aws4_request
	credParts := strings.Split(credential, "/")
	date := credParts[1]
	region := credParts[2]
	service := credParts[3]
	xAmzDate := r.Header.Get("X-Amz-Date")
	reqTime, err := time.Parse("20060102T150405Z", xAmzDate)
	if err != nil {
		return err
	}
	//X-Amz-Dateと現在地獄が5分ずれていたら失敗
	nowUtc := time.Now().UTC()
	timeDiff := nowUtc.Sub(reqTime)
	if timeDiff >= 5*time.Minute {
		return err
	}

	// 2. Canonical Request作成
	signedHeaderParts := strings.Split(signedHeaders, ";")
	canonicalRequest, err := createCanonicalRequest(r, signedHeaderParts, body)
	if err != nil {
		return err
	}
	fmt.Println("Canonical Request:", canonicalRequest)

	// 3. String to Sign作成
	scope := fmt.Sprintf("%s/%s/%s/aws4_request", date, region, service)
	hashedCanonicalRequest := hashSHA256([]byte(canonicalRequest))
	stringToSign := fmt.Sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s", xAmzDate, scope, hashedCanonicalRequest)
	fmt.Println("String to Sign:", stringToSign)

	// 4. サーバー署名を計算
	secretKey := "dummysecret" // 事前に設定してクライアント側と揃える。 今回はベタ
	signingKey := generateSigningKey(secretKey, date, region, service)
	serverSignature := calculateHMACSHA256(signingKey, stringToSign)

	if clientSignature != serverSignature {
		return fmt.Errorf("Signature mismatch")
	}
	return nil
}

func createCanonicalRequest(r *http.Request, signedHeaders []string, requesetBody []byte) (string, error) {
	httpMethod := r.Method

	canonicalURI := r.URL.Path
	if canonicalURI == "" {
		canonicalURI = "/"
	}

	canonicalQueryString := createCanonicalQueryString(r.URL.Query())
	headers := r.Header
	headers["Host"] = []string{r.Host} //なぜかHostヘッダーは取得できなかったので無理やり追加
	canonicalHeaders := createCanonicalHeaders(r.Header, signedHeaders)
	canonicalSignedHeaders := strings.Join(signedHeaders, ";")
	payloadHash, err := hashRequestPayload(requesetBody)
	if err != nil {
		return "", fmt.Errorf("failed to hash payload: %w", err)
	}

	canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s",
		httpMethod,
		canonicalURI,
		canonicalQueryString,
		canonicalHeaders,
		canonicalSignedHeaders,
		payloadHash,
	)
	return canonicalRequest, nil
}

func createCanonicalQueryString(query url.Values) string {
	keys := make([]string, 0, len(query))
	for key := range query {
		keys = append(keys, key)
	}
	sort.Strings(keys)

	var buffer bytes.Buffer
	for _, key := range keys {
		encodedKey := url.QueryEscape(key)
		encodedValue := url.QueryEscape(query.Get(key))
		buffer.WriteString(fmt.Sprintf("%s=%s&", encodedKey, encodedValue))
	}

	queryString := buffer.String()
	if len(queryString) > 0 {
		queryString = queryString[:len(queryString)-1]
	}
	return queryString
}

func createCanonicalHeaders(headers http.Header, signedHeaders []string) string {
	signedHeadersMap := make(map[string]struct{})
	for _, h := range signedHeaders {
		signedHeadersMap[strings.ToLower(h)] = struct{}{}
	}

	var headerNames []string
	for name := range headers {
		lowerName := strings.ToLower(name)
		if _, exists := signedHeadersMap[lowerName]; exists {
			headerNames = append(headerNames, lowerName)
		}
	}
	sort.Strings(headerNames)

	var buffer bytes.Buffer
	for _, name := range headerNames {
		values := strings.Join(headers.Values(name), ",")
		buffer.WriteString(fmt.Sprintf("%s:%s\n", name, strings.TrimSpace(values)))
	}
	return buffer.String()
}

func hashRequestPayload(requestBody []byte) (string, error) {
	hash := sha256.Sum256(requestBody)
	return hex.EncodeToString(hash[:]), nil
}

func hashSHA256(data []byte) string {
	hash := sha256.Sum256(data)
	return hex.EncodeToString(hash[:])
}

func generateSigningKey(secretKey, date, region, service string) []byte {
	kDate := hmacSHA256([]byte("AWS4"+secretKey), []byte(date))
	kRegion := hmacSHA256(kDate, []byte(region))
	kService := hmacSHA256(kRegion, []byte(service))
	kSigning := hmacSHA256(kService, []byte("aws4_request"))
	return kSigning
}

func calculateHMACSHA256(key []byte, data string) string {
	h := hmac.New(sha256.New, key)
	h.Write([]byte(data))
	return hex.EncodeToString(h.Sum(nil))
}

func hmacSHA256(key, data []byte) []byte {
	h := hmac.New(sha256.New, key)
	h.Write(data)
	return h.Sum(nil)
}

この関数を呼び出す実装も先程のサーバーのコードに追加します。

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/aws/aws-sdk-go-v2/service/sqs"
)

func main() {
	//endpointとしては単一のhandle
	http.HandleFunc("/", handler)

	port := ":8081"
	fmt.Printf("Starting server on %s\n", port)
	log.Fatal(http.ListenAndServe(port, nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
  //ここを追加 別ファイルに分けてみたのでここにはない
	err := verifySigV4(r)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	targetAction := r.URL.Query().Get("Action")
	if targetAction == "" {
		targetAction = r.Header.Get("X-Amz-Target")
	}

	switch targetAction {
	case "AmazonSQS.CreateQueue", "CreateQueue":
		handleCreateQueue(w, r)
	case "AmazonSQS.ListQueues", "ListQueues":
		handleListQueues(w, r)
		return
	default:
		http.Error(w, "Unsupported action", http.StatusBadRequest)
		return
	}
}

func handleCreateQueue(w http.ResponseWriter, r *http.Request) {
	var req sqs.CreateQueueInput
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, fmt.Sprintf("Invalid request body: %s", err), http.StatusBadRequest)
		return
	}

	response := sqs.CreateQueueOutput{
		QueueUrl: req.QueueName,
	}
	//本当はリクエストに応じてxmlだったりjsonを出し分ける
	w.Header().Set("Content-Type", "application/x-amz-json-1.0")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(response)
}

func handleListQueues(w http.ResponseWriter, r *http.Request) {
	var req sqs.ListQueuesInput
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, fmt.Sprintf("Invalid request body: %s", err), http.StatusBadRequest)
		return
	}

	response := sqs.ListQueuesOutput{
		QueueUrls: []string{"https://sqs.ap-northeast-1.amazonaws.com/123456789012/queue"},
	}
	//本当はリクエストに応じてxmlだったりjsonを出し分ける
	w.Header().Set("Content-Type", "application/x-amz-json-1.0")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(response)
}

この状況で以下のリクエストを投げると失敗することがわかると思います。

curl -X POST localhost:8081 \
  -H "X-Amz-Target: AmazonSQS.ListQueues" \
  -H "Content-Type: application/x-amz-json-1.0" -d '{}'

or

curl -X POST localhost:8081/?Action=ListQueues \
  -H "Content-Type: application/x-amz-json-1.0" \
  -d '{}'

ではここでsigv4のオプションを有効にしてみましょう。

export AWS_ACCESS_KEY_ID=dummykey
export AWS_SECRET_ACCESS_KEY=dummysecret
export AWS_SESSION_TOKEN=dummysession
curl -X POST localhost:8081 \
  -H "X-Amz-Target: AmazonSQS.ListQueues" \
  --aws-sigv4 "aws:amz:ap-northeast-1:sqs" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  -H "Content-Type: application/x-amz-json-1.0" \
  -d '{}'

or

export AWS_ACCESS_KEY_ID=dummykey
export AWS_SECRET_ACCESS_KEY=dummysecret
export AWS_SESSION_TOKEN=dummysession
curl -X POST localhost:8081/?Action=ListQueues \
  -H "X-Amz-Target: AmazonSQS.ListQueues" \
  --aws-sigv4 "aws:amz:ap-northeast-1:sqs" \
  --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
  -H "X-Amz-Security-Token: ${AWS_SESSION_TOKEN}" \
  -H "Content-Type: application/x-amz-json-1.0" \
  -d '{}'

また awsコマンドを使っても同様に結果が返ってくることが確認できます

aws --endpoint-url=http://localhost:8081 sqs list-queues

確認してないですがおそらくSDK経由で実行してもいけるんじゃないでしょうか。

今回はベタの値を返している実装ですが、ここで実際のSQSに対してSDK経由でリクエストを投げれば無事にProxyサーバーとして動くでしょう。

まとめ

いかがでしたでしょうか。

AWSのSDKやCLIから呼び出せるProxyを作成したので、メインのアプリケーションの処理を変えずになにか処理を差し込めるようなことがPoCとして示せたかなと思います。

例えばSQSのReciveMessageなどを中継するProxyとして動作せて、Atomic CountとECSのProtectTaskのToggle制御を行ったり、AWSのAPI Callに対する各タスクのmetrics等をexportしたりはユースケースとしてありえるかもしれませんね。

作りながらEnvoy等でも同じことができないかなーと思ったので暇を見つけて試してみたいと思います。

良いアイディアや間違っている点がありましたらご報告ください!

株式会社ログラス テックブログ

Discussion