Vault ACME on minikube

## Vault ACME on minikube

今年、Vault 1.14がリリースされ、ACMEの機能が追加されました。

普段からVaultを使っているものの、ACMEが追加されたことに最近気づきました。
もっと大々的に機能紹介してよいものだと個人的には思っています。

ゼットラボ社では社内自己署名サーバ証明書の発行に、Smallstep Labsの[step-ca](https://smallstep.com/docs/step-ca/)でACMEを社内に提供しています。
主に社内KubernetesのIngressサーバ証明書の用途に利用されており、Ingressとの連携には、[cert-manager](https://cert-manager.io/)を導入しています。
また、社内SecretStore, PKIとしてVaultを利用しています。

今回VaultにACMEの機能が追加されたので、社内にもVaultがあることから、本記事を通して動作確認をします。

公式サイトには、VaultとCaddyをDocker上で動かすTutorialが公開されています。

https://developer.hashicorp.com/vault/tutorials/secrets-management/pki-acme-caddy

上記を参考に、本記事では、minikube上にVault, cert-managerを構築し、ACMEの動作確認をします。

## 検証環境

以下バージョンで検証しています。

- Mac: Ventura / Intel 
- minikube: v1.32.0 Hyperkit利用
- Ingress: minikube addons ingress(ingress-nginx/controller: v1.9.4)
- Kubernetes: v1.28.3
- Vault: v1.15.2
- cert-manager: v1.13.2

## 検証構成概要

```mermaid
flowchart LR

%%外部要素のUser
LO["Terminal (Local)"]

%%minikube
subgraph GC["Kubernetes (minikube)"]
    I{{"Ingress<br>nginx"}}
    SVC{{"Service<br>web"}}
    D{{"Deployment<br>web"}}
    S{{"Secret<br>tls-cert"}}
    CM("cert-manager")
    V[("PKI<br>Vault")]
end

%%Connection
LO --https--> I
I -.Refer.-> S
CM --ACME--> V
CM -.Create.-> S
V --ACME--> I
I --http--> SVC
SVC --> D
I --ACME--> CM
```

コンポーネントを様々省略しておりますが、簡易的には上記の通りとなります。
以下の手順で構築します。

- minikube起動・Ingress nginxのデプロイ
- Vaultのデプロイ
- CoreDNSの設定追加(HTTP-01 Challenge時に名前解決用途)
- cert-managerのデプロイ
- テスト用Webサーバのデプロイ
- 動作確認

## minikube起動・Ingress nginxのデプロイ

```bash
$ minikube start
$ minikube addons enable ingress
```

## Vault のデプロイ

今回は、VaultのエンドポイントをTLS化して検証したいと思います。
minikube上でVaultのTLS対応およびシークレットストアをセットアップする方法は以下公式チュートリアルがまとまっているので、こちらをコピペしてデプロイします。

https://developer.hashicorp.com/vault/tutorials/kubernetes/kubernetes-minikube-tls#kubernetes-minikube-tls

今回は、PKIでの利用ですので、チュートリアル中のシークレットストアの設定は不要です。もちろん、実施していただいても問題ありません。

公式手順の内、一点だけ修正します。
のちほどcert-managerがVault ACMEエンドポイントにhttpsでアクセスするのですが、この時、ワイルドカードのサーバ証明書をうまく検証しないようなので、直接サービスクラスタドメインをCSRに追加しておきます。
※ 実装上の問題なのかどうか原因は調査できてないのであしからず

```conf:vault-csr.conf
[req]
default_bits = 2048
prompt = no
encrypt_key = yes
default_md = sha256
distinguished_name = kubelet_serving
req_extensions = v3_req
[ kubelet_serving ]
O = system:nodes
CN = system:node:vault.vault.svc.cluster.local
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment, dataEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = *.vault-internal
DNS.2 = *.vault-internal.vault.svc.cluster.local
DNS.3 = *.vault
### 以下を追加
DNS.4 = vault.vault.svc.cluster.local
IP.1 = 127.0.0.1
```

チュートリアル通りにセットアップが終わると、以下のようなファイル構成・環境変数になっており、Vaultのサーバ証明書を検証する`vault.ca(kubeletのRoot CA)`が手に入っているはずです。

```bash
$ tree $WORKDIR
/some-workdir
├── cluster-keys.json
├── csr.yaml
├── overrides.yaml
├── vault-csr.conf
├── vault.ca
├── vault.crt
├── vault.csr
└── vault.key

0 directories, 8 files

$ env
...
VAULT_K8S_NAMESPACE=vault
VAULT_HELM_RELEASE_NAME=vault
VAULT_SERVICE_NAME=vault-internal
K8S_CLUSTER_NAME=cluster.local
WORKDIR=/some-workdir
CLUSTER_ROOT_TOKEN=<Secret>
```

ACMEでは、cert-managerがVaultにアクセスする際、このRoot CAを使い、サーバ証明書を検証します。
続いて、ACMEのセットアップを行います。
基本的には、以下公式チュートリアルをベースにセットアップします。

https://developer.hashicorp.com/vault/tutorials/secrets-management/pki-acme-caddy#enable-pki-secrets-engine

手順は以下の通りです。まずは、PKIのセットアップをし、ACMEを有効化します。
Vaultはminikube上にデプロイされているので、Terminalからport-forwardでVaultに接続してから、スクリプトを実行します。

```bash
# 別ターミナルで実施
$ kubectl -n vault port-forward service/vault 8200:8200

$ export VAULT_ADDR=https://127.0.0.1:8200
$ export VAULT_CACERT=${WORKDIR}/vault.ca

$ vault login $CLUSTER_ROOT_TOKEN
WARNING! The VAULT_TOKEN environment variable is set! The value of this
variable will take precedence; if this is unwanted please unset VAULT_TOKEN or
update its value accordingly.

Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                <secret>
token_accessor       Xg45I3MT7547YfNnuUUoXums
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]
```

チュートリアルの`enable_engines.sh`はDocker上のVaultのエンドポイントが指定されています。
今回のminikubeに合わせ、以下のように修正し、実行します。

```bash:enable_engins.sh
#!/bin/bash

set -euxo pipefail

VAULT_ENDPOINT="https://vault.vault.svc.cluster.local"
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki

# Root CA pki のセットアップ
vault write -field=certificate pki/root/generate/internal \
   common_name="learn.internal" \
   issuer_name="root-2023" \
   ttl=87600h > root_2023_ca.crt
vault write pki/config/cluster \
   path=${VAULT_ENDPOINT}:8200/v1/pki \
   aia_path=${VAULT_ENDPOINT}:8200/v1/pki
vault write pki/roles/2023-servers \
   allow_any_name=true \
   no_store=false
vault write pki/config/urls \
   issuing_certificates={{cluster_aia_path}}/issuer/{{issuer_id}}/der \
   crl_distribution_points={{cluster_aia_path}}/issuer/{{issuer_id}}/crl/der \
   ocsp_servers={{cluster_path}}/ocsp \
   enable_templating=true
# 以降、中間CA pki_intのセットアップ
vault secrets enable -path=pki_int pki
vault secrets tune -max-lease-ttl=43800h pki_int
vault write -format=json pki_int/intermediate/generate/internal \
   common_name="learn.internal Intermediate Authority" \
   issuer_name="learn-intermediate" \
   | jq -r '.data.csr' > pki_intermediate.csr
vault write -format=json pki/root/sign-intermediate \
   issuer_ref="root-2023" \
   csr=@pki_intermediate.csr \
   format=pem_bundle ttl="43800h" \
   | jq -r '.data.certificate' > intermediate.cert.pem
vault write pki_int/intermediate/set-signed certificate=@intermediate.cert.pem
# 中間CAのエンドポイント, aia_pathはOCSPのAIA処理用途
vault write pki_int/config/cluster \
   path=${VAULT_ENDPOINT}:8200/v1/pki_int \
   aia_path=${VAULT_ENDPOINT}:8200/v1/pki_int
# ACME用のRole作成。ACMEでは、no_store=falseが必須
vault write pki_int/roles/learn \
   issuer_ref="$(vault read -field=default pki_int/config/issuers)" \
   allow_any_name=true \
   max_ttl="720h" \
   no_store=false
# 証明書発行・閲覧するためのエンドポイントの設定
vault write pki_int/config/urls \
   issuing_certificates={{cluster_aia_path}}/issuer/{{issuer_id}}/der \
   crl_distribution_points={{cluster_aia_path}}/issuer/{{issuer_id}}/crl/der \
   ocsp_servers={{cluster_path}}/ocsp \
   enable_templating=true
```

上記設定の中で、Root CAは以下の名前で作成しています。
```
common_name="learn.internal"
issuer_name="root-2023"
```

```bash
# スクリプト内で利用
$ export VAULT_TOKEN=$CLUSTER_ROOT_TOKEN
$ ./enable_engines.sh
```

上記スクリプトによって、Root CA pkiおよび、中間CA pki_intが有効化されます。
今回のACMEは中間CAとして振る舞う設定となります。

```bash
$ vault secrets list
Path          Type         Accessor              Description
----          ----         --------              -----------
cubbyhole/    cubbyhole    cubbyhole_24e7edb4    per-token private secret storage
identity/     identity     identity_cb75ac73     identity store
pki/          pki          pki_af8d3cb7          n/a
pki_int/      pki          pki_d0a3c742          n/a
secret/       kv           kv_e5f7aa22           n/a
sys/          system       system_3b039b5e       system endpoints used for control, policy and debugging
```

成功すると以下のようなACMEエンドポイントが登録されます。

```bash
$ vault read pki_int/config/cluster
Key         Value
---         -----
aia_path    https://vault.vault.svc.cluster.local:8200/v1/pki_int
path        https://vault.vault.svc.cluster.local:8200/v1/pki_int

```

ACMEのリクエストヘッダー、レスポンスヘッダーを設定します。

```bash
$ vault secrets tune \
      -passthrough-request-headers=If-Modified-Since \
      -allowed-response-headers=Last-Modified \
      -allowed-response-headers=Location \
      -allowed-response-headers=Replay-Nonce \
      -allowed-response-headers=Link \
      pki_int
Success! Tuned the secrets engine at: pki_int/
```

ACMEを有効化します。

```bash
$ vault write pki_int/config/acme enabled=true
Key                         Value
---                         -----
allow_role_ext_key_usage    false
allowed_issuers             [*]
allowed_roles               [*]
default_directory_policy    sign-verbatim
dns_resolver                n/a
eab_policy                  not-required
enabled                     true
```

これでVaultのセットアップは完了です。

## CoreDNSの設定追加(HTTP-01 Challenge時に名前解決用途)
 
ACMEのHTTP-01 Challengeでは、指定したドメインでクラスタ内のcert-managerにACMEサーバがアクセスできる必要があります。
そのため、ACMEサーバがアクセスするDNSが名前解決できるようにする必要があります。

今回はローカルで検証しており、ACMEのサーバ証明書に利用するドメインは外部DNSには登録しないようにします。
`/etc/hosts` を書き換えるなどの対応方法は色々ありますが、簡単な方法として、K8s内のCoreDNSにルールを追加して対応します。

今回は、`test-vault-acme.k8s` というドメインを対象にします。
こちらに対してクラスタ内のPodがlookupした際、`ingress-nginx-controller`にアクセスするように以下変更します。

```bash
$ kubectl edit cm coredns --namespace kube-system
```
```yaml
apiVersion: v1
data:
  Corefile: |
    .:53 {
        log
        errors
        health {
           lameduck 5s
        }
        ready
        rewrite name test-vault-acme.k8s ingress-nginx-controller.ingress-nginx.svc.cluster.local
        kubernetes cluster.local in-addr.arpa ip6.arpa {
        ...
```

保存して反映します。

```bash
configmap/coredns edited
```

## cert-managerのデプロイ

cert-managerは証明書の管理をしてくれる、非常に便利なソフトウェアです。
[公式ドキュメント](https://cert-manager.io/docs/installation/)を参考にデプロイします。

```bash
$ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.2/cert-manager.yaml

$ kubectl get pod -A
NAMESPACE       NAME                                        READY   STATUS      RESTARTS      AGE
cert-manager    cert-manager-7d75f47cc5-5rbp7               1/1     Running     0             27s
cert-manager    cert-manager-cainjector-c778d44d8-llx4x     1/1     Running     0             27s
cert-manager    cert-manager-webhook-55d76f97bb-4wjwq       1/1     Running     0             27s
ingress-nginx   ingress-nginx-admission-create-nmmvz        0/1     Completed   0             46m
ingress-nginx   ingress-nginx-admission-patch-m8hkb         0/1     Completed   1             46m
ingress-nginx   ingress-nginx-controller-7c6974c4d8-fv4qm   1/1     Running     0             46m
kube-system     coredns-5dd5756b68-f69kr                    1/1     Running     0             48m
kube-system     etcd-minikube                               1/1     Running     0             49m
kube-system     kube-apiserver-minikube                     1/1     Running     0             49m
kube-system     kube-controller-manager-minikube            1/1     Running     0             49m
kube-system     kube-proxy-q4lm9                            1/1     Running     0             48m
kube-system     kube-scheduler-minikube                     1/1     Running     0             49m
kube-system     storage-provisioner                         1/1     Running     1 (48m ago)   49m
vault           vault-0                                     1/1     Running     0             36m
vault           vault-1                                     1/1     Running     0             36m
vault           vault-2                                     1/1     Running     0             36m
vault           vault-agent-injector-5477bfd7d8-xd4jb       1/1     Running     0             36m
```

続いて、CertManagerにClusterIssuerを設定し、Vault ACMEサーバとingress-nginx-controllerを介して、HTTP-01チャレンジを行うよう設定します。

caBundleにVault ACMEのエンドポイントを検証するRootCAを設定してください。
あえて設定しない場合は、サーバ証明書検証で失敗するため、`skipTLSVerify: true` を指定してください。

```yaml:acme.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: test-cluster-issuer
  namespace: default
spec:
  acme:
    caBundle: <cat vault.ca | base64 の結果>
    server: https://vault.vault.svc.cluster.local:8200/v1/pki_int/acme/directory
    privateKeySecretRef:
      name: test-cluster-issuer-account-key
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx
```

## テスト用Webサーバのデプロイ

テスト用のWebサーバはなんでもよいのですが、K8s公式ドキュメントで使われている動作確認用webサーバを起動します。
以下Deployment, Service, Ingressを適用します。

```yaml:web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
     app: web
  name: web
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - image: gcr.io/google-samples/hello-app:1.0
        imagePullPolicy: IfNotPresent
        name: hello-app
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: web
  name: web
  namespace: default
spec:
  ports:
  - name: https
    port: 443
    protocol: TCP
    targetPort: 8080
  selector:
    app: web
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$1
    cert-manager.io/cluster-issuer: "test-cluster-issuer"
spec:
  # minikube ingress-nginxの場合
  ingressClassName: nginx
  tls:
    - hosts:
      # 今回TLS化するドメインを指定
      - test-vault-acme.k8s
      # 証明書、秘密鍵を動的にcert-managerが以下のSecretに保存します
      secretName: selfsigning-cert
  rules:
    - host: test-vault-acme.k8s
      http:
        paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: web
              port:
                number: 443
```

Ingressリソースのannotationsで、前述した `cert-manager.io/cluster-issuer: "test-cluster-issuer"` を設定すれば、cert-managerが動的に80ポートでサーバを起動し、HTTP-01チャレンジのエンドポイントを生やしてくれます。
Webサーバをデプロイします。
```bash
$ kubectl apply -f web
```

## 動作確認

Terminalから中間CAでサーバ証明書を検証して、Ingressにアクセスしてみましょう。
名前解決できるように、`/etc/hosts`を書き換えます。

```bash
$ minikube ip
192.168.xx.xx

$ sudo vim /etc/hosts
...
# minikube IPの結果を追記
192.168.xx.xx test-vault-acme.k8s
```

実際にTerminalからアクセスしてみます。中間CAを使ってアクセスします。

```bash
$ curl --cacert $WORKDIR/root_2023_ca.crt -v https://test-vault-acme.k8s/
...

* Server certificate:
*  subject: [NONE]
*  start date: Dec 12 05:36:09 2023 GMT
*  expire date: Jan 13 05:36:39 2024 GMT
*  subjectAltName: host "test-vault-acme.k8s" matched cert's "test-vault-acme.k8s"
*  issuer: CN=learn.internal Intermediate Authority
*  SSL certificate verify ok.
* using HTTP/2

...

Hello, world!
Version: 1.0.0
Hostname: web-57f46db77f-nfvr8
* Connection #0 to host test-vault-acme.k8s left intact
```

無事に接続できました。

VaultのGUIからも発行されたサーバ証明書を確認してみましょう。
port-forwardしていれば、 https://127.0.0.1:8080/ でアクセスできるはずです。
Root Tokenでログインし、pki_intの証明書一覧を見ると登録されていることが確認できます。

![vault-ui.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100376/16413d1b-154d-f955-085a-fa2fac7c856f.png)

今回取得したサーバ証明書は、Secretに保存されています。
以下より確認すると、GUIで確認した証明書と一致することがわかります。

```
$ kubectl --namespace default get secrets selfsigning-cert \
-o=jsonpath="{.data.tls\.crt}" | base64 -D | openssl x509 -text -noout
...
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            3e:bd:23:69:67:ac:d3:a7:ef:6c:af:1d:f1:51:d9:68:64:82:30:f2
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN=learn.internal Intermediate Authority
        Validity
            Not Before: Dec 12 08:01:21 2023 GMT
            Not After : Jan 13 08:01:51 2024 GMT
...
```

## まとめ

今回、Vault ACMEをminikube上で動作確認しました。

Vaultに簡単な設定を追加するだけでACMEサーバにすることが可能ということを確認しました。
今回はACMEで広く使われているHTTP-01 チャレンジで検証しましたが、Vaultのドキュメントには、DNS-01, TLS-ALPN-01もサポートしていると記載があります。

Kubernetesでは、cert-managerと連携すると、Ingressのサーバ証明書管理が非常に楽になります。

すでにVault PKIを利用し、サーバ証明書をアプリケーション開発者に提供しているのであれば、本ACME機能は、アプリケーション開発者にって、とても魅力的な機能となりうるのではないでしょうか。

## 参考

- [Vault ACME公式ドキュメント](https://developer.hashicorp.com/vault/api-docs/secret/pki#acme-certificate-issuance)
- [cert-manager ACME](https://cert-manager.io/docs/configuration/acme/)
- [cert-manager Ingress Annotation](https://cert-manager.io/docs/usage/ingress/)