downloadCompleteLogFileをbashで実装する

EC2インスタンス上のOSからRDSのログをダウンロードするには、[awsのcli](https://aws.amazon.com/jp/cli/)の[aws rds download-db-log-file-portion](https://docs.aws.amazon.com/cli/latest/reference/rds/download-db-log-file-portion.html)が便利かつ簡単であるが、ダウンロードしたログが途切れる現象（仕様？不具合？）が発生する（もともと"portion"という名の通りといえばその通りである）。ログ全体をダウンロードするのに、`downloadCompleteLogFile`というAPIが提供されているものの、残念ながら`aws rds`には実装されていない。
そこでOSコマンドを駆使してRDSからログを取ってくるシェルスクリプトを作ってみる。
 正直なところ、サンプルコードが公開されているpythonを使うほうが楽なのだけれど、現場ではインフラ保守SEはpythonコードのメンテナンスなんてできません、などという状況がままあるので、「***普通の***シェルスクリプトです」というと通りがよいのだ、、、

# 前提環境
[REST APIのサンプル](https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html)では、アクセスキーとシークレットをハードコーディングする例が記載されているが、AWSではEC2インスタンスにAPI実行を許可するポリシーを持ったロールを付与しておくことがベストプラクティスとされている。ここでもその前提とする。

具体的には以下のような構成となる。

+ EC2インスタンス（Amazon Linux）
+ RDS for PostgreSQL11
+ EC2インスタンスにアタッチしたロール名は`testec2`

`testec2`ロールにアタッチされているポリシーは以下の通り。

```json:ec2_rds_ctl_policy
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "rds:DownloadDBLogFilePortion",
                "rds:DescribeDBLogFiles",
                "rds:StopDBInstance",
                "rds:StartDBInstance"
            ],
            "Resource": "*"
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "rds:DescribeDBInstances",
                "rds:DownloadCompleteDBLogFile"
            ],
            "Resource": "*"
        }
    ]
}
```

今回のシェルスクリプトに必要なのは、`rds:DownloadCompleteDBLogFile`のみ。

# `downloadCompleteDBLogFile`の概要
[Amazon RDS データベースログファイル](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_LogAccess.html)に記載してある通り、以下のようなリクエストをRDSのAPIエンドポイントに投げてやればよい。

```text:公式サイトから引用
GET /v13/downloadCompleteLogFile/DBInstanceIdentifier/LogFileName HTTP/1.1
Content-type: application/json
host: rds.region.amazonaws.com
```

これだけ見れば、なんだ`curl`とか`wget`で楽勝と思いきや、実際には認証関連のリクエストヘッダを生成して付与してやる必要がある。公式サイトにも上記の例に続いて、次のようなリクエスト例が記載されていて、見るものを絶望の淵に追いやってくれる。

```text:公式サイトから引用
GET /v13/downloadCompleteLogFile/sample-sql/log/ERROR.6 HTTP/1.1
host: rds.us-west-2.amazonaws.com
X-Amz-Security-Token: AQoDYXdzEIH//////////wEa0AIXLhngC5zp9CyB1R6abwKrXHVR5efnAVN3XvR7IwqKYalFSn6UyJuEFTft9nObglx4QJ+GXV9cpACkETq=
X-Amz-Date: 20140903T233749Z
X-Amz-Algorithm: AWS4-HMAC-SHA256
X-Amz-Credential: AKIADQKE4SARGYLE/20140903/us-west-2/rds/aws4_request
X-Amz-SignedHeaders: host
X-Amz-Content-SHA256: e3b0c44298fc1c229afbf4c8996fb92427ae41e4649b934de495991b7852b855
X-Amz-Expires: 86400
X-Amz-Signature: 353a4f14b3f250142d9afc34f9f9948154d46ce7d4ec091d0cdabbcf8b40c558
```

というわけで、これらの値は何なのか、そしてどうやって生成するのか、というのが問題の核心ということになる。
また、仕様なのか不具合なのか不明だが、この通りヘッダを設定しても認証情報が見つからないというエラー（403 Forbidden）になる。以下の情報は`Authorization`ヘッダにまとめて渡す必要がある。

+ `X-Amz-Signature`
+ `X-Amz-Algorithm`
+ `X-Amz-Credential`
+ `X-Amz-SignedHeaders`
+ `X-Amz-Content-SHA256`

# 各種セキュリティ情報
前述の通り、以下のセキュリティ情報をHTTPリクエストヘッダに付与してやる必要がある。

## X-Amz-Security-Token
今回はEC2インスタンスにロールを付与しているので、これはメタデータから取得可能。

```bash
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/$iam_role
```

```text:実行結果抜粋
{
  "AccessKeyId" : "XXXXXXXXXXXXXXXXX",
  "SecretAccessKey" : "YYYYYYYYYYYYYYYYYYY",
  "Token" : "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ",
}
```

`Token`の値を`X-Amz-Security-Token`ヘッダとして渡してやればよい。また、のちに`AccessKeyId`と`SecretAccessKey`も使うので、これらも取得する必要がある。`perl`か`awk`あたりで正規表現を使えば簡単に取り出せる。その他、以降で使用する定数も含めると以下のようなコードになる。

```bash
# ========== Step 0: 定数設定 ==========
LF="__LF__"
## IAMロールからアクセスキー・シークレット・トークンを取得
a=$(curl http://169.254.169.254/latest/meta-data/iam/security-credentials/${iam_role} | grep -e AccessKeyId -e SecretAccessKey -e Token)
access_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$1/')
secret_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$2/')
token=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$3/')
## サービス関連パラメータ設定
ALGORITHM=AWS4-HMAC-SHA256
region=ap-northeast-1
service=rds
host=${service}.${region}.amazonaws.com
endpoint="https://${host}"
request_path=/v13/downloadCompleteLogFile/${RDS_ID}/${logfilename}
amzdate=$(date -u +%Y%m%dT%H%M%SZ)
datestamp=$(date -u +%Y%m%d)
```

日付・時刻データは2種類、それぞれUTC時間で必要になる。0時（日本時間9時）をまたいだ場合、つまり`$amzdate`と`$datestamp`とで日付が異なるとどうなるかは不明。心配なら一回で日付取得して文字列を切り出すか、値を比較して異なるなら再生成するよう実装するほうがセーフかもしれない。

## `Authorization`
これがこの記事の核心である。リクエストを認証してもらうのに、署名（HMACのハッシュ値計算）という手続きが必要で、これをリクエストヘッダ（`Authorization`）として渡す必要がある。

[python](https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html)での実装例を見ればどうやればよいかがわかる。大雑把にいって以下のようになる。

1. リクエスト情報をまとめる
2. リクエスト情報と時刻、固定文字列から署名対象データを生成する
3. 署名対象データに署名する
4. リクエストを発行する

以降ではそれぞれについて、`bash`での実装例を記載していく。特に改行文字の扱いには注意が必要で、ダイジェスト値を求める際に`echo "hogehoge" | openssl dgst`などとしてしまうと、末尾の改行コードも含めたダイジェスト値となってしまう点に留意する。また、意味のある文字として改行コード（LF）が随所に埋め込まれるので、`IFS`環境変数の設定によってはスペース文字に変換されてしまう可能性がある。このため、以下では文字列定数`LF=__LF__`を定義して、ダイジェスト値を求める際に文字変換するように実装している。

### 1. リクエスト情報をまとめる
ドキュメントには「正規リクエスト（canonical request）」と表現されているが、以下の要素を改行文字（LF）で連結した文字列である。

+ HTTPリクエストメソッド：今回は`GET`
+ HTTPリクエストパス：今回は`/v13/downloadCompleteLogFile/${RDS_ID}/${logfilename}`
+ HTTPリクエストクエリ文字列：今回はNULL
+ 署名対象HTTPリクエストヘッダ：今回は`host:$RDS_API_ENDPOINT`と`x-amz-date:<リクエスト発行時刻GMT>`。複数ある場合は改行文字（LF）で区切る
+ 署名対象ヘッダ情報：今回は`host;x-amz-date`。こちらは複数ある場合セミコロン（;）で区切る
+ POSTデータのペイロードハッシュ：今回はリクエストメソッドが`GET`なのでNULLのSHA-256ハッシュ値

ほぼ固定値なので、`bash`で簡単に生成できる。ペイロードは空なので常に固定値になるが、`openssl`コマンドで動的に生成すると以下のようになる。

```bash
# ========== Step 1: 正規リクエストの生成 ==========
header="host:${host}${LF}x-amz-date:${amzdate}${LF}"
signed_headers="host;x-amz-date"
request_param=""
payload_hash=$(printf "" | openssl dgst -sha256 | awk '{print$2}')
canonical_request="GET${LF}${request_path}${LF}${request_param}${LF}${header}${LF}${signed_headers}${LF}${payload_hash}"
```

繰返しになるが、この後ダイジェスト値を求めることになるので、たとえば`header`変数の中で`host: ${host}`というように余分な空白を入れたりすると、まったく異なった署名値ができあがり、無事に（？）`403 Forbidden`が返されることになる。

### 2. リクエスト情報と時刻、固定文字列から署名対象データを生成する
署名対象の文字列は以下の要素をこれまた改行文字（LF）で連結して生成する。

+ 署名アルゴリズム：`AWS4-HMAC-SHA256`固定。ダイジェストアルゴリズムは変更できそうだがわざわざビット数が低いアルゴリズムを選択するメリットはないだろう
+ 資格情報の有効範囲（スコープ）：`YYYYMMDD/<AWSリージョン名>/<AWSサービス名>/aws4_request`
+ 正規リクエストのダイジェスト値

実際のコードは以下のようになる。

```bash
# ========== Step 2: 署名対象文字列生成 =========
scope="${datestamp}/${region}/${service}/aws4_request"
dgst=$(printf $canonical_request | perl -pe 's/__LF__/\n/g' | openssl dgst -sha256 | awk '{print$2}')
str_2B_signed="${ALGORITHM}${LF}${amzdate}${LF}${scope}${LF}${dgst}"
```

正規リクエスト文字列（`$canonical_request}`）のダイジェスト（`$dgst`）は、改行文字のマーク（`${LF}`）をパイプで`perl`につないで改行文字（LF）に変換し、あとは`openssl dgst -sha256`に渡して取得できる。
署名対象文字列（`str_2B_signed`）はこれらをやはり改行文字（LF）で連結したものになる。

### 3. 署名対象データに署名する
上記で生成した署名対処文字列に署名する。使用するアルゴリズム（HMAC）は同じなので、コマンドとしては`openssl dgst`で生成可能だが、そのステップは以下のようになる。

| # | 署名対象データ | 鍵データ |
|:-:|:-:|:-:|
| 1  | 日付（`$datestamp`）  | `AWS4${secret_key}`  |
| 2  | リージョン名（`$region`）  |  前のステップで生成したダイジェスト値 |
| 3  | サービス名(`$service`)  | 前のステップで生成したダイジェスト値  |
| 4  | `aws4_request`  | 前のステップで生成したダイジェスト値  |
| 5  | 署名対象データ（`$str_2B_signed`）  | 前のステップで生成したダイジェスト値  |

やっていることは同じだがその意味合いは異なる。1～4までで署名に使用する鍵となる値を生成し、そのダイジェスト値を使用して署名対象データのダイジェスト値（HMACハッシュ値）を計算することになる。

```bash
# ========== Step3: 署名生成 ==========
## 署名用鍵生成
kDate=$(printf $datestamp | openssl dgst -sha256 -mac HMAC -macopt key:AWS4${secret_key} | awk '{print$2}')
kRegion=$(printf $region  | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kDate} | awk '{print$2}')
kService=$(printf $service| openssl dgst -sha256 -mac HMAC -macopt hexkey:${kRegion} | awk '{print$2}')
sigkey=$(printf "aws4_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kService} | awk '{print$2}')
## 署名
signature=$(printf $str_2B_signed | perl -pe 's/__LF__/\n/g' | openssl dgst -mac HMAC -macopt hexkey:${sigkey} | awk '{print$2}')
```

ところどころに固定文字列が使用されるので若干複雑に感じるものの、いくつかの情報のハッシュチェーンである。
`openssl dgst -mac HMAC`では鍵を渡すときのオプション（`-macopt`）として文字列（`key:<key_str>`）とhex文字列（`hexkey:<hex_str>`）が利用可能で、一つ目だけは文字列で鍵を渡すので、前者のオプションを使用している。出力はhexdumpなので、以降では`hexkey`として渡す必要がある。
繰返しになるが、署名を生成するのにハッシュチェーンを使用していて、鍵生成の最初のステップと最後のステップ（#1と#4）で固定文字列（それぞれ`AWS4`と`aws4_request`）を使用している。`$secret_key`をそのまま使わずにソルト文字列の役割を持つ`AWS4`を挿入している点が心憎い。

### 4. リクエストを発行する
あとはリクエストヘッダを付与して実際に`GET`するだけ。`curl`の`-H`オプションを使用すれば、リクエストヘッダを追加できる。`Authorization`ヘッダ値のフォーマットは以下の通り。

```text
<署名アルゴリズム> Credential=<アクセスキー>/<スコープ>, SignedHeaders=<署名対象HTTPリクエストヘッダ>, Signature=<署名値>
```

アクセスキーはここで使う。

```bash
# ========== Step 4: 署名をリクエストに付与してリクエスト発行 ==========
auth_header="${ALGORITHM} Credential=${access_key}/${scope}, SignedHeaders=${signed_headers}, Signature=${signature}"
curl \
-H "host: ${host}" \
-H "Accept: text/plain" \
-H "x-amz-security-token: ${token}" \
-H "x-amz-date: ${amzdate}" \
-H "Authorization: ${auth_header}" \
"${endpoint}${request_path}"
```

`Accept`に`text/plain`を指定したので、標準出力をリダイレクトするなりすれば、そのままログファイルとして出力できる（指定しなくてもデフォルトは`text/plain`で返ってきていたが明示しておいて損はないだろう）。

# 参照資料
+ [Amazon RDS データベースログファイル](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_LogAccess.html)
+ [署名バージョン 4 署名プロセス](https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-version-4.html)
+ [完全なバージョン 4 署名プロセスの例 (Python)](https://docs.aws.amazon.com/ja_jp/general/latest/gr/sigv4-signed-request-examples.html)

# 付録
引数でIAMロール名、RDSインスタンスID、ログファイルパターンおよび出力先を指定し、マッチするログを全て出力する例。IAM認証情報がいつ期限切れになるかわからないので、ダウンロードするたびに動的に取得するよう実装している。というよりも、上記コードを関数化して繰り返し呼出しするようにしただけなので、あまりきれいにまとまっていない、、、

```bash:downloadCompleteLogFile.sh
#!/bin/bash
# https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_LogAccess.html
# https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-version-4.html
# ========== 初期化 ==========
while [ $# -gt 0 ]; do
    case $1 in
        "-region")
            shift
            region=$1
            shift;;
        "-rds")
            shift 
            rds_id=$1
            shift;;
        "-iam")
            shift
            iam_role=$1
            shift;;
        "-out")
            shift
            outputfile=$1
            shift;;
        "-file")
            shift
            logfilename=$1
            shift;;
        *)
            echo "Usage: $(basename $0) -region AWS_REGION -rds RDS_IDENTIFIER -iam IAM_ROLE_NAME -file FILENAME_PATTERN_TO_BE_DOWNLOADED -out OUTPUT_FILE" >&2
            exit 1;;
    esac
done
# assume defaults
iam_role=${iam_role:=$(uname -n)}
rds_id=${rds_id:=database-1}
logfilename=${logfilename:=postgresql.log}
outputfile=${outputfile:=/tmp/postgresql.log}
region=${region:=$AWS_DEFAULT_REGION}
region=${region:=ap-northeast-1}

# set constants
service=rds
metadata_url=http://169.254.169.254/latest/meta-data/iam/security-credentials/${iam_role}
LF="__LF__"
ALGORITHM=AWS4-HMAC-SHA256
host=${service}.${region}.amazonaws.com
endpoint="https://${host}"

# ========== 関数 =========
function downloadRDSLog() {
    local logfilename=$1
    # ========== Step 0: 定数設定 ==========
    ## IAMロールからアクセスキー・シークレット・トークンを取得
    a=$(curl $metadata_url | grep -e AccessKeyId -e SecretAccessKey -e Token)
    access_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$1/')
    secret_key=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$2/')
    token=$(echo $a | perl -pe 's/"AccessKeyId" : "(.*)", "SecretAccessKey" : "(.*)", "Token" : "(.*)",*$/$3/')
    if [ -z "$access_key" -o -z "$secret_key" -o -z "$token" ]; then
        echo "security credentials not available" >&2
        curl $metadata_url >&2
        exit 1
    fi
    ## リクエスト関連パラメータ設定
    request_path=/v13/downloadCompleteLogFile/${rds_id}/${logfilename}
    amzdate=$(date -u +%Y%m%dT%H%M%SZ)
    datestamp=$(date -u +%Y%m%d)

    # ========== Step 1: 正規リクエストの生成 ==========
    header="host:${host}${LF}x-amz-date:${amzdate}${LF}"
    signed_headers="host;x-amz-date"
    request_param=""
    payload_hash=$(printf "" | openssl dgst -sha256 | awk '{print$2}')
    canonical_request="GET${LF}${request_path}${LF}${request_param}${LF}${header}${LF}${signed_headers}${LF}${payload_hash}"

    # ========== Step 2: 署名対象文字列生成 =========
    scope="${datestamp}/${region}/${service}/aws4_request"
    dgst=$(printf $canonical_request | perl -pe 's/__LF__/\n/g' | openssl dgst -sha256 | awk '{print$2}')
    str_2B_signed="${ALGORITHM}${LF}${amzdate}${LF}${scope}${LF}${dgst}"

    # ========== Step3: 署名生成 ==========
    ## 署名用鍵生成
    kDate=$(printf $datestamp | openssl dgst -sha256 -mac HMAC -macopt key:AWS4${secret_key} | awk '{print$2}')
    kRegion=$(printf $region  | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kDate} | awk '{print$2}')
    kService=$(printf $service| openssl dgst -sha256 -mac HMAC -macopt hexkey:${kRegion} | awk '{print$2}')
    sigkey=$(printf "aws4_request" | openssl dgst -sha256 -mac HMAC -macopt hexkey:${kService} | awk '{print$2}')
    ## 署名
    signature=$(printf $str_2B_signed | perl -pe 's/__LF__/\n/g' | openssl dgst -mac HMAC -macopt hexkey:${sigkey} | awk '{print$2}')

    # ========== Step 4: 署名をリクエストに付与してリクエスト発行 ==========
    auth_header="${ALGORITHM} Credential=${access_key}/${scope}, SignedHeaders=${signed_headers}, Signature=${signature}"
    curl \
    -H "host: ${host}" \
    -H "Accept: text/plain" \
    -H "x-amz-security-token: ${token}" \
    -H "x-amz-date: ${amzdate}" \
    -H "Authorization: ${auth_header}" \
    "${endpoint}${request_path}"
    if [ $? -ne 0 ]; then
        echo "download error" >&2
        exit 1
    fi
}

# ========== 主処理 ==========
## ファイル初期化
> $outputfile
if [ $? -ne 0 ]; then
    echo "file access error(${outputfile})" >&2
    exit 1
fi

## ログファイルリストを取得
logfiles=$(
    aws rds describe-db-log-files --db-instance-identifier $rds_id --filename-contains $logfilename \
    --query "DescribeDBLogFiles[*].[LogFileName]" --output text | sort
)
if [ $? -ne 0 ]; then
    echo "log list not available" >&2
    exit 1
fi

## ログをダウンロード
for logfile in $logfiles; do
    echo "downloading $logfile"
    downloadRDSLog $logfile >>$outputfile
done


```