mazyu36の日記

某SIer所属のクラウドエンジニアのブログ

【CDK】Application Load BalancerのmTLSをL2 Construct化した+コントリビュート時に困ったこと

CDK v2.154.0でALBのmTLS対応がL2 Constructに対応しました(わしが作った)

Pull Requestはこちらです。 github.com

特定のクライアント証明書を持つクライアントのみ接続できるやつですね。昨日の詳しい内容は安定のクラメソさんの記事が参考になります。

のんピ(non____97)さんの記事はコントリビュート時にも参考にさせていただきました(感謝)。

dev.classmethod.jp

dev.classmethod.jp

ざっとL2 Constructの使い方をまとめるとともに、いくつかコントリビュート時に詰まった点があるのでまとめておきます。

目次

L2 Constructの使い方

ALBのmTLSには「トラストストアモード(ALBがクライアント証明書を検証)」と「パススルーモード(ALBの背後のバックエンドサーバーがクライアント証明書を検証)」の2つがありますが、おそらく使われることがより多い(と思う)前者を扱います。

コントリビュート時のinteg testで実際に使っているので、手っ取り早く実装を知りたい場合はこれをみていただくのが良いかと思います。

github.com

なお前提として、クライアント証明書など必要なものは事前準備済みとします。この辺りは上記integ testの冒頭にもコマンドを用意してあります。

mTLSで登場する証明書の種類を詳しく知りたい方は以下の記事が参考になります。

dev.classmethod.jp

以下ざっと流れを説明します(説明のしやすさなどを踏まえて、integ testのリソース定義と順序を一部入れ替えています)

1. VPC、ホストゾーン、ACM、ALBの用意

この辺りは通常通りなのでざっと流します。

// VPC作成
const vpc = new ec2.Vpc(this, 'Stack');

// ホストゾーン作成。IDやゾーン名はpropsで渡す。
const hostedZone = route53.PublicHostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
  hostedZoneId: props.hostedZoneId,
  zoneName: props.hostedZoneName,
});

// ACMで証明書生成。ドメイン名はpropsで渡す。
const certificate = new acm.Certificate(this, 'Certificate', {
  domainName: props.domainName,
  validation: acm.CertificateValidation.fromDns(hostedZone),
});

// ALB生成
const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', {
  vpc,  
  internetFacing: true,  
});

// ALBをターゲットとするAレコードを生成
new route53.ARecord(this, 'ARecord', {
  target: route53.RecordTarget.fromAlias(new route53targets.LoadBalancerTarget(lb)),
  zone: hostedZone,
});

2. CA証明書をS3バケットに配置し、トラストストアを作成

メインの箇所1つ目です。S3バケットにあらかじめ用意したCA証明書を配置し、それを参照する形でトラストストアを作成します。

以下の例ではBucketDeploymentを使用して、cdk deployの過程でアップロードしています。

この場合デプロイとトラストストアの依存関係を定義しておかないと、CA証明書の格納が終わる前にトラストストアの作成が走る→CA証明書が見つからない、となり失敗してしまう点に注意が必要です。

// バケットを作成
const bucket = new s3.Bucket(this, 'Bucket', {
  autoDeleteObjects: true,
  removalPolicy: RemovalPolicy.DESTROY,
});

// バケットにCA証明書を配置
const deploy = new s3deploy.BucketDeployment(this, 'DeployCaCert', {
  sources: [s3deploy.Source.asset(path.join(__dirname, 'mtls'))],
  destinationBucket: bucket,
});

// トラストストアを作成
const trustStore = new elbv2.TrustStore(this, 'Store', {
  bucket,
  key: 'rootCA_cert.pem',
});

// S3へのCA証明書配置が終わってからトラストストアを作成するよう指定
trustStore.node.addDependency(deploy);

3. トラストストアをALBのリスナーに紐付け

リスナーのPropertyのmutualAuthenticationにおいてmTLSの設定を行なっていきます。それ以外は通常のHTTPSを使用するときと同様です。

以下はALBに対してaddListenerでリスナーを追加していますが、もちろんリスナーをnewする形でも定義可能です。

// リスナーを追加
lb.addListener('Listener', {
  port: 443,
  protocol: elbv2.ApplicationProtocol.HTTPS,
  certificates: [certificate],
  // mTLSの設定
  mutualAuthentication: {
    ignoreClientCertificateExpiry: false,  // 期限切れのクライアント証明書を無視するか。セキュリティを強固にするのであればtrue推奨
    mutualAuthenticationMode: elbv2.MutualAuthenticationMode.VERIFY,  // トラストストアモードの場合はVERIFYを指定
    trustStore,  // トラストストアを指定
  },
  defaultAction: elbv2.ListenerAction.fixedResponse(200,
    { contentType: 'text/plain', messageBody: 'Success mTLS' }),
});

// リスナーをnewする場合
new elbv2.ApplicationListener(this, 'Listener', {
  loadBalancer: lb,
  port: 443,
  protocol: elbv2.ApplicationProtocol.HTTPS,
  certificates: [certificate],
  mutualAuthentication: {
    ignoreClientCertificateExpiry: false,
    mutualAuthenticationMode: elbv2.MutualAuthenticationMode.VERIFY,
    trustStore,
  },
  defaultAction: elbv2.ListenerAction.fixedResponse(200,
    { contentType: 'text/plain', messageBody: 'Success mTLS' }),
});

4. CRL (Certificate Revocation List)を設定(任意)

クライアント証明書のCRL(証明書失効リスト)を作成して、トラストストアに追加することが可能です。

トラストストアとほぼ同様で、.pemを作成してS3バケットに配置しておき、それを参照させる形で定義します。

// CRLを設定
const trustStoreRevocation = new elbv2.TrustStoreRevocation(this, 'Revocation', {
  trustStore,  // トラストストアを指定
  revocationContents: [
    {
      bucket,
      key: 'crl.pem',
    },
  ],
});

// BucketDeploymentを使用する場合は依存関係を定義
trustStoreRevocation.node.addDependency(deploy);

以上がALBのmTLS設定(トラストストアモード)の全量です。

コントリビュート時に困ったこと

特殊な点がいくつかあり、手こずった点をまとめておきます。

ドメインを使ったinteg testが必要

HTTPSの設定が必要なので、テスト用のドメインを持っていないとコントリビュート自体ができません(integ testが実行できない)。私は物好きなのでお名前.comでテスト用のドメインを取得してコントリビュートしました。

ドメインを取得した上でドメイン名は実装やテスト結果に残さない形で実装します。詳細はframework-integのドキュメントにも書いてあります。

github.com

以下実装方法などを記載します。※ドキュメントや実装を調査した内容をもとに記載していますが、若干自信ないところもあるため間違ってるところあれば教えてください。

まず、ホストゾーンID、ホストゾーン名、ドメイン名は以下のように環境変数で渡す形にします。以下は今回使用した実装ですが、他のinteg testのものからコピペで持ってきています。

CDK_INTEG_...環境変数runner-baseで定義されている値です。最終的にこの値がsynthで生成されるCFnテンプレートに残ります(自前のドメイン名等はinteg testの結果に残らない形になります)

/**
 * ホストゾーンID、ホストゾーン名、ドメイン名は以下のように環境変数で渡す
 *
*/
const hostedZoneId = process.env.CDK_INTEG_HOSTED_ZONE_ID ?? process.env.HOSTED_ZONE_ID;
if (!hostedZoneId) throw new Error('For this test you must provide your own HostedZoneId as an env var "HOSTED_ZONE_ID". See framework-integ/README.md for details.');
const hostedZoneName = process.env.CDK_INTEG_HOSTED_ZONE_NAME ?? process.env.HOSTED_ZONE_NAME;
if (!hostedZoneName) throw new Error('For this test you must provide your own HostedZoneName as an env var "HOSTED_ZONE_NAME". See framework-integ/README.md for details.');
const domainName = process.env.CDK_INTEG_DOMAIN_NAME ?? process.env.DOMAIN_NAME;
if (!domainName) throw new Error('For this test you must provide your own DomainName as an env var "DOMAIN_NAME". See framework-integ/README.md for details.');

const app = new App();
const stack = new MutualTls(app, 'alb-mtls-test-stack', {
  hostedZoneId, // propsで渡す
  hostedZoneName,
  domainName,
});

その上でinteg testにおいて、enableLookupstrueに、stackUpdateWorkflowfalseにすることでLookupを有効にしつつ、CFnテンプレートにはダミーの値が埋め込まれるようになります(自前のドメイン名などは残らず、process.env.CDK_INTEG_DOMAIN_NAMEに定義されたexample.comがテンプレートに残るなど)。

new IntegTest(app, 'alb-mtls-integ', {
  testCases: [stack],
  enableLookups: true,
  stackUpdateWorkflow: false,
});

あとはinteg test実行前に、自前のホストゾーンやドメイン名を環境変数を設定して走行させるだけです。

export HOSTED_ZONE_ID=your_hosted_zone_id
export HOSTED_ZONE_NAME=your_hosted_zone_name
export DOMAIN_NAME=your_domain_name

integ testに必要なCA証明書などはコミットしたくない

証明書などはセキュリティ面を踏まえると、できればリポジトリに入れたく無いところです。しかしinteg testを走行するためには必要なので、再走行用が必要になった時のための手段は残す必要があります。

今回はinteg testの冒頭にCA証明書などの発行含めて、integ testを走行するための手順を全て記載することで対応しました。

github.com

リソースにおいてnameに相当するプロパティが無い場合の対処

CRLの設定をするためTrustStoreRevocationのCloudFormationにおけるプロパティを見てみます。

以下の通りリソース名を表すプロパティ(...nameのような)が存在しません。

Type: AWS::ElasticLoadBalancingV2::TrustStoreRevocation
Properties:
  RevocationContents: 
    - RevocationContent
  TrustStoreArn: String

じゃあL2 Construct化する時も定義しなくてええか、ということで正直に実装するとビルドで以下のエラーを吐きます。

error: [awslint:props-physical-name:aws-cdk-lib.aws_elasticloadbalancingv2.TrustStoreRevocationProps] Every Resource must have a single physical name construction property, with a name that is an ending substring of <cfnResource>Name 

props-physical-nameのリンターで、nameに相当するプロパティが存在しないため弾かれています。しかし今回は該当するプロパティがCloudFormationテンプレートに存在しないためどうしようもありません。

その場合は aws-lint.jsonにチェック除外とするように設定します。

今回はprops-physical-nameのチェックを、TrustStoreRevocationのプロパティであるTrustStoreRevocationPropsに対しては適用しないよう、除外設定をしています。

{
  "exclude": [
    //omit
    "props-physical-name:aws-cdk-lib.aws_elasticloadbalancingv2.TrustStoreRevocationProps",
     // omit
  ]
}

おわりに

今回のL2対応により、ALBのmTLS対応はかなり使いやすくなったかと思います。ぜひ使ってみてください!