Postfix から SMTP endpoint を使わずに Amazon SES 経由でメールを送る

Postfix から Amazon SES 経由でメールを送る場合、通常であれば SMTP credentials を作成したうえで Postfix から Amazon SES の SMTP endpoint へリレーを設定します。

が、やんごとなき理由によりこの方法が使えなかったときのために Postfix から SES API の SendRawEmail でメールを送信してみました。

master.cfses トランスポートを定義する

master.cf に次のように追記して sqs トランスポートを定義します。

sqs  unix  -       n       n       -       -       pipe
  null_sender=MAILER-DAEMON@example.com
  user=nobody argv=/etc/postfix/ses.sh ${sender} ${recipient}

null_sender=MAILER-DAEMON@example.com は稀に postfix でメールがバウンスしたときに sender が MAILER-DAEMON のようなドメイン無しの名前でスクリプトが呼ばれて「error occurred (InvalidParameterValue) when calling the SendRawEmail operation: Missing final '@domain'」などとエラーになることがあるのでその対策です。ドメイン部分は SES で検証済 ID である必要があります。

mail.cf でデフォルトトランスポートを sqs に変更します。

default_transport = sqs

ses.sh を次の内容で作成します。

#!/bin/bash

sender=$1
shift
jq -Rs '{Data: .}' | aws \
    --region ap-northeast-1 \
    ses send-raw-email \
    --source "$sender" \
    --destinations "$@" \
    --raw-message file:///dev/stdin

簡単化のためにシェルスクリプトから AWS CLI を実行していますが、お好みのプログラミング言語&AWS SDKでも良いと思います。

さいごに

AWS 上の Webアプリケーションでメールを送信するような要件があるなら Postfix のような MTA はかまさずに直接アプリケーションから AWS SDK でメールを送ればよいので通常は Amazon SES の SMTP endpoint は使わないだろうと思います。

ただ、サードパーティのツールやライブラリで、メール送信の方法として「SMTP か sendmail か」ぐらいしか選択肢が無いことは往々にあって、そのような場合は Amazon SES の SMTP endpoint を使わざるを得ません。

SMTP endpoint に必要な SMTP credentials は実のところ IAM User のアクセスキーです(そのままではないけれどもアクセスキーから SMTP credentials が導出できる)。

ので SMTP endpoint を使うためにはそれようの IAM User をアクセスキー付きで作成する必要があるのですが・・メールの送信元サーバが EC2 インスタンスなのであれば IAM User のアクセスキーなんて使わなくても、インスタンスプロファイルに設定した IAM Role のポリシーで ses:SendRawEmail を許可するだけにしたいところです。

この記事の方法で可能です。

CloudFront ~ S3 で CORS を設定する

CloudFront 経由で公開している S3 に Web フォントを置いて、それをクロスオリジンで利用できるようにするために Access-Control-Allow-Origin ヘッダーを返すように設定したときのメモ。

以下の 2 通りの方法が考えられるでしょうか

  • CloudFront のレスポンスヘッダーポリシーで設定
  • S3 で CORS を設定して CloudFront から S3 へ Origin を渡す

CloudFront のレスポンスヘッダーポリシーで設定

CloudFront のビヘイビアで次のようなレスポンスヘッダーポリシーを設定します。

resource "aws_cloudfront_response_headers_policy" "cors" {
  name    = "example-cors"
  comment = "example-cors"
  cors_config {
    access_control_allow_methods {
      items = ["GET"]
    }
    access_control_allow_origins {
      items = ["example.com"]
    }
    access_control_allow_headers {
      items = ["*"]
    }
    access_control_allow_credentials = false
    origin_override                  = true
  }
}

resource "aws_cloudfront_distribution" "main" {
  // ...snip....

    response_headers_policy_id = aws_cloudfront_response_headers_policy.cors.id

  // ...snip....
}

次のように origin ヘッダーを渡してやれば access-control-allow-origin が返ってきます。

curl -I -XGET https://xxx.cloudfront.net/example.woff -H origin:example.com | grep access-control-allow-origin
#=> access-control-allow-origin: example.com

任意の origin を許可して OK なら、カスタムレスポンスヘッダーポリシーを用意しなくても SimpleCORS などのマネージドポリシーでも良いと思います。

data "aws_cloudfront_response_headers_policy" "cors" {
  name = "Managed-SimpleCORS"
}

S3 で CORS を設定して CloudFront から S3 へ Origin を渡す

S3 で次のように CORS を設定します。

resource "aws_s3_bucket_cors_configuration" "main" {
  bucket = aws_s3_bucket.main.id
  cors_rule {
    allowed_methods = ["GET"]
    allowed_origins = ["example.com"]
  }
}

そのうえで CloudFront のキャッシュポリシーで origin をキャッシュキーに含めます

resource "aws_cloudfront_cache_policy" "origin" {
  name    = "example-origin"
  comment = "example-origin"

  min_ttl     = 1
  max_ttl     = 31536000
  default_ttl = 86400

  parameters_in_cache_key_and_forwarded_to_origin {
    headers_config {
      header_behavior = "whitelist"
      headers {
        items = ["origin"]
      }
    }
    cookies_config {
      cookie_behavior = "none"
    }
    query_strings_config {
      query_string_behavior = "none"
    }
  }
}

resource "aws_cloudfront_distribution" "main" {
  // ...snip....

    cache_policy_id = aws_cloudfront_cache_policy.origin.id

  // ...snip....
}

次のように origin ヘッダーを渡してやれば access-control-allow-origin が返ってきます。

curl -I -XGET https://xxx.cloudfront.net/example.woff -H origin:example.com | grep access-control-allow-origin
#=> access-control-allow-origin: example.com

なお、キャッシュポリシーのキャッシュキーに origin を含めなくても、オリジンリクエストポリシーに origin を含めれば origin 付きのリクエストに対して access-control-allow-origin が返るようになります。ただその場合、リクエストの origin が違っていてもキャッシュが共通になるため、origin が無かったり違ったりするリクエストを元に access-control-allow-origin が無いレスポンスがキャッシュされてしまうと、正しい origin を送ったとしてもキャッシュから access-control-allow-origin が無いレスポンスが返されてしまうため、ダメです。

さいごに

CloudFront のレスポンスヘッダーポリシーで設定する方が、CloudFront がリクエストの origin を見て共通のキャッシュから access-control-allow-origin を出しわけてくれるので、こちらの方が良いですね。

S3 で CORS を使うのは CloudFront でレガシーキャッシュ設定しか使えなかった頃の名残で、もう使うことは無さそうです(CloudFront を噛まさない裸の S3 なら別ですけど)。

PHP の LDAP 関数でCA証明書を指定する

LDAP のサーバ証明書がプライベートCAで発行されているため、素のままだと ldaps で証明書の検証で失敗してアクセス出来ず、PHP 側で信頼するCA証明書を設定しようと試行錯誤したメモ。

簡単な解決方法・・ダメ

LDAP_OPT_X_TLS_CACERTFILE というオプションがあるのでこれで指定すれば OK です、完。

<?php
$ldap = ldap_connect('ldaps://ldap.local:636');
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_X_TLS_CACERTFILE, '/mnt/ldap.crt');
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

というわけにはいきませんでした。なにやらバグ?により機能していないようです。

サーバの設定ファイルでどうにかする

サーバの証明書ストアに追加したり、

cp ldap.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust

ldap.conf に追記したり。

echo TLS_CACERT /mnt/ldap.crt >> /etc/openldap/ldap.conf

ユーザーごとの .ldaprc でも良いようです。

echo TLS_CACERT /mnt/ldap.crt > ~/.ldaprc

PHP 側でどうにかする

環境変数 LDAPTLS_CACERT でサーバ証明書を指定すれば大丈夫です。

<?php
putenv('LDAPTLS_CACERT=/mnt/ldap.crt');
$ldap = ldap_connect('ldaps://ldap.local:636');
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

あるいは環境変数で LDAPTLS_REQCERT=never を指定すれば証明書関係のエラーを無視できます。

<?php
putenv('LDAPTLS_REQCERT=never');
$ldap = ldap_connect('ldaps://ldap.local:636');
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

もしくは・・・ ldap_set_optionLDAP_OPT_X_TLS_CACERTFILE を指定するときに第1引数に null を指定して libldap のグローバルなオプションにすれば良いようです。

<?php
ldap_set_option(null, LDAP_OPT_X_TLS_CACERTFILE, '/mnt/ldap.crt');
$ldap = ldap_connect('ldaps://ldap.local:1636', 1636);
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_bind($ldap, 'cn=admin,dc=example,dc=org', 'password');

null ではなく LDAP リソースを渡すとダメなのですが・・次のような C のコードでも同様だったので PHP の LDAP 拡張の問題ではないようです。

#include <stdio.h>
#include <stdlib.h>
#include <ldap.h>

void check(int rc)
{
    if (rc != LDAP_SUCCESS) {
        printf("%d: %s\n", rc, ldap_err2string(rc));
        exit(1);
    }
}

int main()
{
    LDAP *ldap = NULL;
    check(ldap_initialize(&ldap, "ldaps://ldap.local:1636"));

    check(ldap_set_option(NULL, LDAP_OPT_X_TLS_CACERTFILE, "/mnt/ldap.crt"));

    int v = 3;
    check(ldap_set_option(ldap, LDAP_OPT_PROTOCOL_VERSION, &v));

    char pw[] = "password";
    struct berval cred = {strlen(pw), pw};
    check(ldap_sasl_bind_s(ldap, "cn=admin,dc=example,dc=org", LDAP_SASL_SIMPLE, &cred, NULL, NULL, NULL));
    return 0;
}

最初に PHP の LDAP 拡張のバグかと思ったものの、実は libldap のバグあるいは仕様のようです(どっちなのかはわからない)。

おまけ:DNS SRV RR

通常、Active Directory のドメインコントローラーは複数台設け、DNS サーバが SRV リソースレコードを複数返すことで冗長化が図られています。

ので、Active Directory の LDAP サーバに問い合わせるなら、直接ドメインコントローラーの FQDN を名前解決して問い合わせるよりは、ドメインの SRV レコードを検索してドメインコントローラーの FQDN を特定したいものですが・・実は openldap の CLI も SRV レコードをサポートしています。

例えば次のように指定すると _ldap._tcp.example.com の SRV レコードが検索され、その結果を元に LDAP 問い合わせが行われます。

ldapwhoami -H ldap:///dc%3Dexample%2Cdc%3Dcom -x -D ore@example.com -W

-H オプションで指定した値は URL デコードすると ldap:///dc=example,dc=com です。

なお ldaps:///dc%3Dexample%2Cdc%3Dcom のように指定しても LDAPS ではアクセスできません。このように指定しても SRV レコードの問い合わせは _ldap._tcp.example.com のままです。SRV レコードにはホスト名だけでなくポート番号 389 も含まれています。そのため LDAPS で 389 ポートにアクセスして失敗します。

http/https で言うところの https://example.com:80/ のようなものと考えてもらえればわかりやすいと思います。

下記によると openldap の CLI では SRV レコードを解決しつつの LDAPS アクセスは出来ないようです。

次のように -ZZ を使えば STARTTLS には出来ます。

ldapwhoami -H ldap:///dc%3Dexample%2Cdc%3Dcom -x -D ore@example.com -W -ZZ

なお、この機能、そもそものところ PHP の LDAP 拡張では実装されていません、ちーん。

次のように SRV レコードを問い合わせても良いのかも?

$records = dns_get_record('_ldap._tcp.example.com', DNS_SRV);
usort($records, function ($a, $b) {
    $cmp = $a['pri'] <=> $b['pri'];
    if ($cmp != 0) {
        return $cmp;
    }
    $cmp = random_int(0, $a['weight']) <=> random_int(0, $b['weight']);
    return -$cmp;
});
foreach ($records as $rec) {
    $host = $rec['target'];
    $port = $rec['port'];
    try {
        // なにかする
        break;
    } catch (Throwable) {
        continue;
    }
}

クロスアカウントの VPC ピアリング接続を経由してカスタム CNAME で RDS に接続

クロスアカウントの VPC ピアリング接続を経由して RDS に接続する検証。

要するにアカウント A と B があって、それぞれに VPC があり、ピアリング接続されている状態で、A の RDS に B の EC2 インスタンスから接続します。

ただし RDS の元々の DNS 名ではなく、RDS 側のアカウントの Route53 プライベートホストゾーンに登録された CNAME で接続します。

プライベートホストゾーンをクロスアカウントで VPC に関連付け

RDS の DNS 名は VPC とか関係なしにグローバルに解決できるので、ピアリング接続・ルーティング・セキュリティグループなどの設定が正しければ、普通にクロスアカウントのピアリング接続を経由して接続できます。

ただ、RDS の DNS 名をそのまま用いずに Route53 プライベートゾーンにそれっぽい CNAME を登録している場合、その CNAME を引くためにはプライベートゾーンを VPC に関連付ける必要があります。

下記の通り手順でクロスアカウントでも関連付けることができます。

マネジメントコンソールからはできないもよう・・ Terraform なら下記を使えばできます。

aws_route53_zonelifecycle.ignore_changes = [vpc] が必要なのがイケてない・・次のような段階で処理されるものの、

  1. A の aws_route53_zone でプライベートゾーン作成&VPC を関連付け
  2. A の aws_route53_vpc_association_authorization でクロスアカウントの VPC への関連を許可
  3. B の aws_route53_zone_association で VPC を追加で関連付け

これの 1. と 3. が競合するため ignore_changes が必要、とのこと。

最初のプライベートゾーンの作成時に VPC を関連付けずに作成し、その後 VPC の関連付けはすべて aws_route53_zone_association だけでやれば解決な気もするけれども、 プライベートゾーンは作成時点で必ずひとつは VPC を関連付けておく必要がるためダメでした。

Route53 Resolver アウトバウンドから Route53 Resolver インバウンドに転送

プライベートホストゾーンをクロスアカウントに関連付けなくても、ピアリング接続がつながっているなら次の方法でも A のプライベートゾーンの CNAME を B の VPC から解決できます。

  • A に Route53 Resolver インバウンドエンドポイントを作成
  • B の Route53 Resolver アウトバウンドエンドポイントを作成&転送ルールで A のドメインを A の Route53 Resolver に転送

ただ Route53 Resolver の分の余計な費用がかかるし、無駄に複雑なので最初の方法のが良いですね。

プライベートホストゾーンの上位ドメインを Route53 Resolver の転送ルールに設定

本題の RDS の件とはぜんぜん関係無いですけど、要する次のように設定したときにどうなるか。

  1. プライベートホストゾーン aaa.example.com を作成して VPC に割当
  2. Route53 Resolver アウトバウンドを作成し example.com を転送するルールを VPC に割当

これだと 2. による転送のルールが 1. での名前解決を阻害してしまうのではないかと思ったのです・・・特にそんなことはなく aaa.example.com の名前解決は 1. のプライベートホストゾーンで、それ以外の example.com の名前解決は 2. の Route53 Resolver の転送が行われました。

下記の記述によるとプライベートホストゾーンを VPC に関連つけると暗黙的に Resolver にルールが作成されるようです。

https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/resolver.html#resolver-overview-forward-vpc-to-network-domain-name-matches VPC に関連付けるプライベートホストゾーンごとに、Resolver はルールを作成して、その VPC に関連付けます。プライベートホストゾーンを複数の VPC に関連付けている場合、Resolver はルールを、それらと同じ複数の VPC に関連付けます。

そして以下の記述の通り、元も具体的なルールが優先されます。

クエリが (example.com と acme.example.com など) 複数のルールと一致する場合、Resolver では最も具体的なルール (acme.example.com) が選択され、そのルールで指定している IP アドレスに対しクエリが転送されます。

なお、下記記事によるとプライベートホストゾーンと Route53 Resolver のルールとで同じドメイン名が登録されている場合は Route53 Resolver の方が優先されるようです。

その場合も Route53 Resolver で System ルールを定義することで対応できるようです。

さいごに

これ別に RDS は全然関係ないですね。

auditd のメモ

とある事情でファイル改竄検知や root によるコマンド実行の検知のために auditd を使う必要があったのでそのメモ。

試した環境は次の通りです。

  • centos-release-7-9.2009.0.el7.centos.x86_64
  • audit-2.8.5-4.el7.x86_64
  • kernel-3.10.0-1160.83.1.el7.x86_64

参考

リスト

auditd は「リスト」にルールを定義します。リストには以下のものがあります。

  • task
  • exit
  • user
  • filesystem
  • exclude

システムで監査イベントが発生すると最初に exclude を除くいずれかのリストのルールが順番に適用され、そこで除外されなければ最後に exclude リストのルールが適用され、そこでも除外されなければログなどに出力されます。

リストへのルールの追加は次のコマンドで行います。exit がリスト名、always はアクションです。

auditctl -a always,exit -F perm=x -F euid=0

アクションは always と never のいずれかを指定します。いわゆる許可と拒否です。なお、リストとアクションは逆に指定しても OK です(e.g. exit,always)。

-F で監査イベントのフィールドで条件を指定できます。リストごとに指定可能なフィールドは異なります。例えば exclude は msgtype や SELinux 関係の一部のフィールドしか指定できません。他にも -S でシステムコール名、-w でファイル名やディレクトリ名、-p でファイルやディレクトリに対する操作、などが指定できます。

task リスト

task リストは fork や clone で新しくプロセスが作成されたときに適用されます。このリストで除外されなければ新しく生まれたプロセスで監査コンテキストが初期化されます。要するに、それ以後そのプロセスではシステムコールがフックされて監査イベントが発生し、exit リストのフィルタが適用されるようになります。

task リストで除外された場合、そのプロセスではシステムコールがフックされないため、exit リストがどうであれシステムコールの監査イベントは発生しなくなります。

# すべてのシステムコールの監査を無効にする
auditctl -a never,task

システムコール監査が不要なとき、exit リストを空のままにしておけば何も記録はされませんが、task リストはデフォルトで always なので、ルールが未定義でもすべてのプロセスでシステムコールがフックされます。ので、システムコール監査が不要なら明示的に task リストで除外しておいた方がパフォーマンスは良いです。そのため、大抵のディストリでは配布物に含まれるデフォルトのルールで -a never,task となっているようです。

また、設定例では次のように SELinux のラベルで crond から実行されたときはシステムコールの監査を無効にしたりしていました。

auditctl -A never,task -F subj_type=system_cronjob_t
auditctl -A never,task -F subj_type=crond_t

プロセス単位で監査を除外したいときは exit リストよりも task リストが良いでしょう。

exit リスト

システムコールの終了時に適用されるリストです。

# root によるすべてのコマンド実行を記録
auditctl -a always,exit -F perm=x -F euid=0

システムコールの監査では1回のシステムコールで複数行のログが出力されます。例えば下記はカレントディレクトリ /etc/sshcat ssh_config したログです。

type=SYSCALL msg=audit(1686548854.861:788133): arch=c000003e syscall=59 success=yes exit=0 a0=af0470 a1=aed550 a2=a172a0 a3=7fffb20ca420 items=2 ppid=26926 pid=26969 auid=1003 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=pts0 ses=144027 comm="cat" exe="/usr/bin/cat" key="exec"
type=EXECVE msg=audit(1686548854.861:788133): argc=2 a0="cat" a1="ssh_config"
type=CWD msg=audit(1686548854.861:788133):  cwd="/etc/ssh"
type=PATH msg=audit(1686548854.861:788133): item=0 name="/bin/cat" inode=8410545 dev=ca:01 mode=0100755 ouid=0 ogid=0 rdev=00:00 objtype=NORMAL cap_fp=0000000000000000 cap_fi=0000000000000000 cap_fe=0 cap_fver=0
type=PATH msg=audit(1686548854.861:788133): item=1 name="/lib64/ld-linux-x86-64.so.2" inode=12583884 dev=ca:01 mode=0100755 ouid=0 ogid=0 rdev=00:00 objtype=NORMAL cap_fp=0000000000000000 cap_fi=0000000000000000 cap_fe=0 cap_fver=0
type=PROCTITLE msg=audit(1686548854.861:788133): proctitle=636174007373685F636F6E666967

type=SYSCALL が実際にシステムコールの内容です。このログでは引数(a0~a3)に文字列が渡されていてもポインタ値しか記録されないためにその内容は全然わかりません。

type=EXECVE はシステムコールが execve だったときに記録されます。このログでは引数のうち文字列部分が何であったかも表示されています。

type=CWD はシステムコールの実行時のカレントディレクトリです。execve の引数に相対パスがある場合、カレントディレクトリによって実際のファイルパスが変わるため、カレントディレクトリも監査ログに記録されています。

type=PATH はシステムコールの引数で指定されたパスの絶対パスが記録されます。引数に相対パスが指定されていてもこのログには絶対パスが記録されます。

type=PROCTITLE は所謂 proctitle で、システムコールの元のプロセスのコマンドラインが記録されます。この例の値を16進デコードすると cat\nssh_config です。

user リスト

ユーザー空間で発生した監査イベントをフィルタリングするためのリストです。ユーザー空間で発生する監査イベントには次のようなものがあります。

  • CRYPTO_*
    • 暗号化関係
  • CRED_*
    • 認証情報関係
  • USER_*
    • ユーザー認証関係
  • USER
    • auditctl -m "message"
  • SERVICE_START SERVICE_STOP
    • サービスの開始・停止
  • SOFTWARE_UPDATE
    • yum や dnf によるソフトウェアのインストール・アップデート
  • その他たくさん

要するにシステムコール関係の監査イベント以外はすべてこちらに属します(後述の filesystem リストなどもあるので正確ではない)。

man によると CAP_AUDIT_WRITE ケーパビリティを持つプロセスのユーザー空間のイベントは何もしなくても記録されます。ので、ルールを追加していない状態でもユーザー空間の監査イベントはログには記録されます。never でルールを追加すれば特定のイベントを記録しないようにできます。また、プロセスから CAP_AUDIT_WRITE を落としてやるとなにも記録されなくなります。

capsh --drop='cap_audit_write' -- -c 'auditctl -m "this is test"'
# 監査イベントが記録されない

ユーザー空間の監査イベントがすべて不要な場合、never,user だけを条件無しで指定してもダメでした。

auditctl -a never,user
# すべて除外・・と思いきや効果なし

なにかしら条件を指定する必要があります。

auditctl -a never,user -F uid=0
auditctl -a never,user -F uid!=0
# すべて除外される

filesystem リスト

man によると tracefs や debugfs などのファイルシステム全体のイベントを除外するためのものらしいのですが・・どちらもよくわかりません。

いわゆるファイルシステムの監査 -w とは無関係です。xfs とかの普通のファイルシステムを指定するものでもありません。

auditctl -a never,filesystem -F fstype=tracefs
# ok
auditctl -a never,filesystem -F fstype=debugfs
# ok
auditctl -a never,filesystem -F fstype=xfs
# file system type is unknown for field: fstype

auditctl -a never,filesystem
auditctl -w /root -p wa
# filesystem リストと -w の監査は無関係なので /root 以下に書き込むと監査イベントは発生する

exclude リスト

他のリストのルールが適用された後、除外されなかった監査イベントに対して exclude リストが適用されます。 このリストではイベントを除外することだけ可能なのでアクションの指定は無視され、追加されたルールは常に never となります。

auditctl -a always,exclude -F msgtype=USER
# エラーにはならないものの never,exclude と同じです

perm オプション

exit リストで指定する perm はパーミッションのことですがいわゆるファイルのパーミッションとはあまり関係無く、相当するシステムコールと一致する条件として扱われます。

例えば perm=x なら実行なので execve とかです。ただ、システムコールで指定する場合は CPU アーキも指定する必要があるため。

# 以下の2つは概ね同じ意味
auditctl -a always,exit -F euid=0 -F perm=x
auditctl -a always,exit -F euid=0 -F arch=b64 -S execve

他にも perm=r なら読込なので openat が読込可能なフラグ付きで呼ばれたとき、などです。詳細はよくわからないけれども次のコードでイメージが掴めるでしょうか。

LOGIN イベント

ssh やシリアルコンソールなどからのログイン時には user リストが適用される USER_*** イベントがたくさん発生するのですが、同じようにログイン成功のタイミングで発生する LOGIN イベントはなぜか user リストの never では除外できませんでした。

exclude リストでなら除外できます。理由は不明。

auditctl -a never,user -F msgtype=LOGIN
# LOGIN が記録される

auditctl -a never,exclude -F msgtype=LOGIN
# LOGIN が記録されなくなる

ファイルやディレクトリの監査

-w でファイルやディレクトリに対する操作の監査が可能です。-p で操作内容も指定できます。

# /root 以下に対する書き込み
auditctl -w /root -p w

これは実際のところシステムコールの監査なので、仕組み的には exit リストで -F path-F dir などを指定するのと変わりありません。

なお、ルール定義時点の i-node 番号が追跡されるため、i-node 番号が変わると監査されなくなります。

mkdir /test
auditctl -w /test -p w

touch  /test/a #=> 監査イベントが発生する
rm -fr /test   #=> 監査イベントが発生する
mkdir  /test   #=> 監査イベントが発生しない
touch  /test/a #=> 監査イベントが発生しない

auid

リストのフィルタで指定可能なフィールドに auid というものがあります。これは Audit User ID のことで、ログイン時にユーザーに割り当てられた後、su や sudo でユーザーが切り換えられた後も継承されます。

例えば次のように一般ユーザーでログイン後に su や sudo などで特権ユーザーでのコマンド実行のみを記録することができます。

auditctl -a always,exit -F perm=x -F euid=0 -F 'auid>0' -F 'auid!=unset'

なお、CAP_AUDIT_CONTROL ケーパビリティがあれば auid は改竄可能らしいです。root に su した後に改竄されると意味が無いので、次の設定で最初に auid が割り当てられた後不変にできます。ただし、これを設定すると特定の種類のコンテナ(certain kinds of containers)で問題が発生する可能性があるとのことです。

auditctl --loginuid-immutable

とことで何故か task リストだと auid=0 での除外設定が効きませんでした。auid でのフィルタが効かないわけでは内容なのですが・・理由は不明

# なぜか機能しない
auditctl -a never,task -F auid=0

さいごに

1監査イベント=1ログ、ではないので取り扱いしづらいです。

terraform でリソースのリージョン変更時の嵌りどころ

AWS の SES でオレゴンリージョンを使っていたものを、東京リージョンに切り換えるために terraform で変更しようとして嵌ったときのメモ。

この記事を書いたときの terraform のバージョンは 1.4.6 です。書くだけ書いて寝かせていたらいつの間にか最新は 1.6.2 とかになってました。ので最新版で同じ結果になるかは判りません(tfstate のバージョンが変わらない限りこの結果も変わらないだろうのでたぶん変わってないと思います)。

プロバイダのリージョンだけ変更すると旧リソースが取り残される

例えば次のように東京リージョンに SSM パラメータを作成しているとします(SSM パラメータなのに特に意味はありません。サッとすぐ作って試せるリソースなら何でもよかった)。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge"
}

resource "aws_ssm_parameter" "a" {
  provider = aws.hoge
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}

この状態でリージョンだけ ap-northeast-3 に変更して plan すると、期待する結果としては a の replace (あるいは古いリージョンでの delete と新しいリージョンでの create)ですが、実際はそうはならずに新しいリージョンでの create のみになります。

aws_ssm_parameter.a: Refreshing state... [id=/test/a]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_ssm_parameter.a will be created
  + resource "aws_ssm_parameter" "a" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = (known after apply)
      + name           = "/test/a"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "String"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

ので、apply すると古いリージョンのリソースは terraform 管理外となって残ったまま、新しいリージョンのリソースが作成されます。

原因

terraform は tfstate の中でリソースごとにプロバイダ名は記録されているものの、そのプロバイダのリージョンなどのパラメータまでは保持していません。

$ cat terraform.tfstate
      // ...snip...
      "mode": "managed",
      "type": "aws_ssm_parameter",
      "name": "a",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"].hoge",
      // ...snip...

そのためプロバイダのリージョンだけ書き換えて plan すると、tfstate に記録されている作成済リソースは新しいリージョンから探されて、見つからないために terraform 外で削除されたものとして tfstate からスルッと抜け落ちて(plan だけなら tfstate 自体は更新されない)、そして新しいリージョンで作成されようとします。

plan の結果だけだとそこまで読み取ることできず、単に新しいリソースが作成されるだけに見えるので、知らずのうちに古いリソースが取り残されてしまうことがありそうです。

apply する前に plan -refresh-only すれば tfstate から削除されることはわかる=何かがおかしいと気づくことはできます。

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the last "terraform apply" which may have affected this plan:

  # aws_ssm_parameter.a has been deleted
  - resource "aws_ssm_parameter" "a" {
      - arn       = "arn:aws:ssm:ap-northeast-1:096446238848:parameter/test/a" -> null
      - data_type = "text" -> null
      - id        = "/test/a" -> null
      - name      = "/test/a" -> null
      - tags_all  = {} -> null
      - tier      = "Standard" -> null
      - type      = "String" -> null
      - value     = (sensitive value) -> null
      - version   = 1 -> null
    }

-refresh-only 未指定のときにも同じ内容を表示してほしい気もしますが・・refresh による tfstate の差分と実際に apply される差分が一緒に表示されるとそれはそれで判りにくいでしょうか・・うーん。

リソース識別子はそのままで新旧プロバイダを両方記載 → ダメ

ではどうすればよいかというと、tfstate にプロバイダ名は記録されているわけなので、そのプロバイダ名は元のリージョンのまま残しつつ、新しくプロバイダを追記してリソースに適用すればよいかと思いましたが・・・ダメでした、結果は↑と変わりありません。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge" // そのまま残す
}

provider "aws" {
  region = "ap-northeast-3"
  alias  = "fuga" // 新しいリージョンは別名で追加
}

resource "aws_ssm_parameter" "a" {
  provider = aws.fuga // 新しいリージョンのプロバイダを指定
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}

次のような流れで delete/create されるかと思いましたが、そんなことはありません。

  1. プロバイダの指定が異なるため delete/create が必要
  2. 既存リソースの検索と削除は tfstate に記録されている aws.hoge で行われる
  3. 新規リソースの作成は tf で指定されている aws.fuga で行われる

よく考えてみれば、これで delete/create になってしまうと、例えばリージョンは変わらないのだけれども何かしらの事情(リファクタリングとか)でプロバイダのエイリアス名を変更したときまで不必要に replace されてしまうのでダメですね・・うーん。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge1"
}

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge2"
}

resource "aws_ssm_parameter" "a" {
  provider = aws.hoge2 // aws.hoge1 から aws.hoge2 に変更しただけで replace して欲しくはない
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}

リソース識別子を変える

次のようにリソース識別子を変えれば期待通り delete/create されます。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge" // そのまま残す
}

provider "aws" {
  region = "ap-northeast-3"
  alias  = "fuga" // 新しいリージョンは別名で追加
}

resource "aws_ssm_parameter" "a_new" { // リソース識別子を変える
  provider = aws.fuga  // 新しいリージョンのプロバイダを指定
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
  - destroy

Terraform will perform the following actions:

  # aws_ssm_parameter.a will be destroyed
  # (because aws_ssm_parameter.a is not in configuration)
  - resource "aws_ssm_parameter" "a" {
      - arn       = "arn:aws:ssm:ap-northeast-1:096446238848:parameter/test/a" -> null
      - data_type = "text" -> null
      - id        = "/test/a" -> null
      - name      = "/test/a" -> null
      - tags      = {} -> null
      - tags_all  = {} -> null
      - tier      = "Standard" -> null
      - type      = "String" -> null
      - value     = (sensitive value) -> null
      - version   = 1 -> null
    }

  # aws_ssm_parameter.a_new will be created
  + resource "aws_ssm_parameter" "a_new" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = (known after apply)
      + name           = "/test/a"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "String"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

リソース識別子を戻したければ、一度この内容で apply した後に moved を追記のうえで apply -refresh-only ですね。

moved {
  from = aws_ssm_parameter.a_new
  to   = aws_ssm_parameter.a
}

リソースを削除して作り直す

泥臭いですが、リージョン変更の対象となるリソースを削除したうえでプロバイダのリージョンを書き換えて作り直す、とかでも良いです。

# リージョンを変えたいリソースを削除
terraform destroy --target=module.ses

# プロバイダのリージョンを書き換え
vim provider.tf

# tfstate からしれっと消えるリソースが存在しないことを確認
terraform plan -refresh-only

# リソースを作成
terraform apply

検証系などでリージョン移行→戻しを繰り返して試行錯誤するときなどはこの方が簡単で良さそうですね。

SNS トピックが含まれるとエラー

最初に記載した、プロバイダ定義のリージョンを変更して plan/apply するケースで、対象のリソースに SNS トピックが含まれていると、delete とか create とか以前に plan の時点で terraform がエラーでコケます。

Error: reading SNS Topic (arn:aws:sns:us-west-2:999999999999:test): InvalidParameter: Invalid parameter: TopicArn

sns:GetTopicAttributes の TopicArn で、実際に対象としているリージョン(AWS_REGION 環境変数や --region オプションなど)と、指定したトピックの ARN に含まれるリージョンが異なっている場合に、NotFound ではなく InvalidParameter エラーとなるために、terraform はリソースが見つからなかったのではなく、API 呼び出しそのものが失敗したと判断されるため、です。

例えば次のように --region オプションのリージョンと arn のリージョンとが一致していればトピック名が適当でも NotFound となりますが、

aws --region us-west-2 sns get-topic-attributes --topic-arn arn:aws:sns:us-west-2:999999999999:xxx
#=> An error occurred (NotFound) when calling the GetTopicAttributes operation: Topic does not exist

リージョンの部分が異なっていると InvalidParameter となります。

aws --region ap-northeast-1 sns get-topic-attributes --topic-arn arn:aws:sns:us-west-2:999999999999:xxx
#=> An error occurred (InvalidParameter) when calling the GetTopicAttributes operation: Invalid parameter: TopicArn

他は確認していませんがリソースの arn を指定する API は基本的にそうなっていると思います。

変に NotFound になってしまうと terraform 管理外のリソースが取り残されて面倒なことになるので、InvalidParameter でエラーになるのはむしろ親切ですね。実際に SES のリージョンを変更しようとしたときは関連する SNS トピックでこのエラーが発生したために、terraform 管理外のリソースが取り残されるという面倒は避けることができました。

さいごに

本番系で切り替えるときはオレゴンリージョンと東京リージョンの両方で SES を設定して両方を利用可能としたうえで、段階を経て順次切り替えたためこのような問題は起こりえませんが、検証系で何度か切り替えたり戻したりして検証したいときに terraform apply の一発だけでやろうとするときは注意が必要そうです。

git でメインラインのブランチが複数あるときに特定のブランチから分岐しているブランチを得る

Git でメインラインのブランチが複数あるとき(例えば main とか develop とか、あるいは v1.x とか v2.x とか)、特定のメインラインのブランチから分岐しているブランチを一覧表示する方法。

git branch --merged の反対をやるイメージですが、分岐元のメインラインのブランチが別の機能ブランチのマージにより進んでいることも考慮する必要があるため、単純にはできません。

手順

メインラインのブランチが A と B の 2 つあるときに、B から分岐しているブランチを一覧表示させます。

まず、A には含まれるものの B には含まれないコミットをすべてリストアップします。

git log A..B --format=%H

そしてそれらのコミットのいずれかを含むブランチをすべて表示します。

| xargs -i git br -r --contains {}

重複を除外すれば、目的とするブランチの一覧になります。

| sort | uniq

コマンドを繋げると次の通りです。

git log A..B --format=%H | xargs -i git br -r --contains {} | sort | uniq

メインラインが3つ以上あるときは次のようにすれば OK です。

# A にも B にもなくて C にだけあるコミットのいずれかを含むブランチのリスト
git log ^A ^B C --format=%H | xargs -i git br -r --contains {} | sort | uniq

# これでも OK です
git log C --not A B --format=%H | xargs -i git br -r --contains {} | sort | uniq

さいごに

Git flow とかで develop からたくさん機能ブランチが生えてる状態で、本番にデプロイ済の main/master ブランチからもホットフィックスブランチをたくさん生え、かつ、ホットフィックスブランチに命名規則的なものが無いために develop から生えた機能ブランチと区別がつかなくなったときに、この方法でどちらに属するブランチなのかを調べられます。