SPIREでSPIFFEのFederationを試してみる

今年のAdvent Calendarの内容はSPIFFEが定義するFederation APIについて、SPIREを使ってどのようなものか試してみたいと思います。

SPIFFEについてやFederation APIについては[昨年の記事](https://qiita.com/hiyosi/items/6e8d56b257c55a709b4d#federation)を参考にしてください。

## 構成

今回の構成としては異なるトラストドメインを持つSPIRE Serverを別々のKubernetes Namespaceにデプロイします。また、それぞれのNamespaceのWorkloadは異なるNodeにデプロイされるようにし、最終的にフェデレーションの設定が上手くいけばお互いのWorkload間でSVIDが検証できるというものです。

※ 異なるNamespaceにSPIREをデプロイするような構成は一般的ではないと思います。今回はあくまでフェデレーションの機能を試してみることを目的として構成しています。

<img width="873" alt="スクリーンショット 2019-12-17 14.52.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15164/11ef4610-5afb-ca78-b080-59b8720c690f.png">


## インストールと事前準備

まずは kind などを使って適当にk8s クラスタを作成します。


    ❯ cat cluster.yaml
    kind: Cluster
    apiVersion: kind.sigs.k8s.io/v1alpha3
    kubeadmConfigPatches:
    - |
      apiVersion: kubeadm.k8s.io/v1beta2
      kind: ClusterConfiguration
      metadata:
        name: config
      apiServer:
        extraArgs:
          "service-account-key-file": "/etc/kubernetes/pki/sa.pub"
          "service-account-signing-key-file": "/etc/kubernetes/pki/sa.key"
          "service-account-issuer": "api"
          "service-account-api-audiences": "api,spire-server"
    nodes:
    - role: control-plane
    - role: worker
    - role: worker
    
    ❯ kind create cluster --image kindest/node:v1.17.0 --name spire-federation --config cluster.yaml
    Creating cluster "spire-federation" ...
     ✓ Ensuring node image (kindest/node:v1.17.0) 🖼
     ✓ Preparing nodes 📦📦📦📦
     ✓ Creating kubeadm config 📜
     ✓ Starting control-plane 🕹️
     ✓ Installing CNI 🔌
     ✓ Installing StorageClass 💾
     ✓ Joining worker nodes 🚜
    Cluster creation complete. You can now use the cluster with:
    
    export KUBECONFIG="$(kind get kubeconfig-path --name="spire-federation")"
    kubectl cluster-info
    
    ❯ kubectl get nodes
    NAME                             STATUS   ROLES    AGE   VERSION
    spire-federation-control-plane   Ready    master   88s   v1.17.0
    spire-federation-worker          Ready    <none>   57s   v1.17.0
    spire-federation-worker2         Ready    <none>   57s   v1.17.0

今回の検証用に一方のWorkerにはNamespace=foo用のPodをデプロイするためのTaintの設定を、もう一方にはNamespace=bar用の設定をします。


    ❯ kubectl taint nodes spire-federation-worker app=foo:NoSchedule
    node/spire-federation-worker tainted
    
    ❯ kubectl taint nodes spire-federation-worker2 app=bar:NoSchedule
    node/spire-federation-worker2 tainted

また、今回はSPIREのUpstreamCAとして静的なルートCA証明書を使う(`disk` pluginを利用する)ため、以下を参考にそれぞれのトラストドメイン用のCA証明書を発行します。
今回はNamecpace=fooにデプロイするSPIREには`foo.example.com`を、Namespace=barには`bar.example.com`をトラストドメインとして設定したいのでそれぞれ異なるルートCA証明書を発行します。


    ❯ cat <<EOF > foo-ca.json
    {
      "CN": "foo root",
      "key": {
        "algo": "ecdsa",
        "size": 256
      },
      "names": [
        {
          "C": "JP",
          "L": "Tokyo",
          "O": "SPIRE",
          "OU": "CA"
        }
      ]
    }
    
    ❯ cfssl gencert -initca foo-ca.csr | cfssljson -bare foo-ca
## SPIRE Serverのデプロイ

次にSPIRE Serverを以下のような設定ファイルでk8sクラスタにデプロイしていきます。設定ファイルについては`server.experimental`の部分がフェデレーションのキモになります。自身のbundleエンドポイントの設定と、フェデレーション先のbundleエンドポイントの情報を設定しています。

k8sにデプロイするためのマニフェストファイルやその他設定については [spire-examples](https://github.com/spiffe/spire-examples/) を参考にしてください。また、その際には先程設定したTaintsに対するTolerationsをPodのspecに設定します。

Namespace=fooにデプロイするSPIREのPodに設定するTolerationsの例

        spec:
            tolerations:
            - key: "app"
              operator: "Equal"
              value: "foo"
              effect: "NoSchedule"

Namespace=fooにデプロイするSPIREの設定ファイルの例

    server {
      bind_address = "0.0.0.0"
      bind_port = "8081"
      trust_domain = "foo.example.com"
      data_dir = "/run/spire/data"
      log_level = "DEBUG"
      svid_ttl = "1h"
      upstream_bundle = true
    
      experimental {
        bundle_endpoint_enabled = true
        bundle_endpoint_port = 8443
    
        federates_with "bar.example.com" {
          bundle_endpoint_address = "spire-server.bar.svc.cluster.local"
          bundle_endpoint_port = 8443
          bundle_endpoint_spiffe_id = "spiffe://bar.example.com/spire/server"
        }
      }
    }
    
    plugins {
     ...省略...
      UpstreamCA "disk" {
        plugin_data {
          ttl = "12h"
          key_file_path = "/run/spire/secrets/bootstrap.key" #先程発行したfoo用の秘密鍵
          cert_file_path = "/run/spire/config/bootstrap.crt" #先程発行したfoo用のCA証明書
        }
      }
    }
## フェデレーションの設定

フェデレーションするにあたって、現状は初回のみ相手のbundleエンドポイントにhttpsで接続するためのCA証明書を設定する必要があります。CA証明書はSPIRE ServerのUpstreamCAの設定によりますが、先程記載したSPIRE Serverの設定例のように事前に発行したCA証明書を設定している場合、設定するCA証明書はそれと同じ証明書となります。このとき、セットするのは相手のCA証明書(Namespace=fooのSPIREに設定するのはNamespace=barのSPIREに設定したCA証明書)であることに注意です。一度接続が成功すると以降は自動でローテーションしてくれるようです。


    ❯ kubectl exec -it -n foo spire-server-0 sh
    
    # export BUNDLE_FILE=$(mktemp)
    
    # cat <<EOF > $BUNDLE_FILE
    -----BEGIN CERTIFICATE-----
    ...省略...
    -----END CERTIFICATE-----
    EOF
    
    # /opt/spire/bin/spire-server bundle set --id spiffe://bar.example.com --path $BUNDLE_FILE

無事にフェデレーションが成功すると以下のコマンドでフェデレーション先の証明書バンドルが取得できます。

    # /opt/spire/bin/spire-server experimental bundle list
    ****************************************
    * spiffe://bar.example.com
    ****************************************
    {
        "keys": [
            {
                "use": "x509-svid",
                "kty": "EC",
                "crv": "P-256",
                "x": "f1uyyj6...省略...TyBlL6pxwg0k",
                "y": "uJXLJ58...省略...LcyiMOgjP4S4",
                "x5c": [
                    "MIIB3j...省略...ojQAtjC8EMUtPg="
                ]
            },
            {
                "use": "jwt-svid",
                "kty": "EC",
                "kid": "quGwcMt...省略...gJFZBaCkU9vL",
                "crv": "P-256",
                "x": "bY1k4N...省略...lsH13JTGzslJe00",
                "y": "TIMoI8...省略...UAqftiisZ6IISAQ"
            }
        ],
        "spiffe_refresh_hint": 15768000
    }

SPIRE Serverでは以下のようなログが出力されるようです。

    time="2019-12-17T00:29:32Z" level=info msg="Bundle refreshed" subsystem_name=bundle_client trust_domain=bar.example.com
    time="2019-12-17T00:29:32Z" level=debug msg="Scheduling next bundle refresh" at="2020-01-31T15:29:32Z" subsystem_name=bundle_client trust_domain=bar.example.com

Namespace=fooとbarの両方でフェデレーションの設定が正しくできたことを確認できたらServerの設定は一旦終わりです。

ここからはフェデレーションによって実際に異なるトラストドメイン間でSVIDの検証が行えることを確認していきます。

## Agentのデプロイ

SPIRE AgentのデプロイについてもServerと同様に spire-example を参考にして `foo`, `bar` それぞれのNamespaceにデプロイします。Tolerationsも忘れずに設定します。


    ❯ kubectl get pods -n foo -o wide
    NAME                READY   STATUS    RESTARTS   AGE    IP           NODE                      NOMINATED NODE   READINESS GATES
    spire-agent-64s95   1/1     Running   0          3m9s   172.17.0.3   spire-federation-worker   <none>           <none>
    spire-server-0      1/1     Running   0          67m    10.244.1.3   spire-federation-worker   <none>           <none>
    
    ❯ kubectl get pods -n bar -o wide
    NAME                READY   STATUS    RESTARTS   AGE     IP           NODE                       NOMINATED NODE   READINESS GATES
    spire-agent-xvlbj   1/1     Running   0          12s     172.17.0.2   spire-federation-worker2   <none>           <none>
    spire-server-0      1/1     Running   0          5m47s   10.244.2.4   spire-federation-worker2   <none>           <none>
## ワークロードのデプロイ

テスト用のワークロードとしてNamespace=fooには簡単なHTTP Serverをデプロイし、Namespace=barのPodからアクセスしてみたいと思います。Namespace=fooにはHTTP ServerのSidecar Proxyとしてghostunnelを利用します。ghostunnelは起動時にSPIREのWorkload APIと通信してPodに割り当てられたSVIDを取得します。Namespace=barのワークローも同様にclientモードのghostunnelをSidecar Proxyにした適当なPodを作成します。

フェデレーションを利用する場合、ワークロード用のRegistration Entryを作成する際のポイントとしては `federatesWith` でワークロードがフェデレーションを許可するトラストドメインを指定することが必要になります。

Namespace=fooのRegistration Entry登録例

    (foo spire)# /opt/spire/bin/spire-server entry create \
            --spiffeID spiffe://foo.example.com/spire-agent \
            --selector k8s_psat:agent_ns:foo \
            --node
     
    (foo spire)# /opt/spire/bin/spire-server entry create \
            --parentID spiffe://foo.example.com/spire-agent \
            --spiffeID spiffe://foo.example.com/http-server \
            --selector k8s:pod-label:app:http-server \
            --dns http-server \
            --dns http-server.foo.svc.cluster.local \
            --federatesWith spiffe://bar.example.com

Namespace=barのRegistration Entry登録例

    (bar spire)# /opt/spire/bin/spire-server entry create \
            --spiffeID spiffe://bar.example.com/spire-agent \
            --selector k8s_psat:agent_ns:bar \
            --node
    
    (bar spire)#  /opt/spire/bin/spire-server entry create \
            --parentID spiffe://bar.example.com/spire-agent \
            --spiffeID spiffe://bar.example.com/client \
            --selector k8s:pod-label:app:client \
            --federatesWith spiffe://foo.example.com

Namespace=fooにデプロイするマニフェスト例

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: http-server
      namespace: foo
      labels:
        app: http-servrer
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: http-server
      template:
        metadata:
          labels:
            app: http-server
        spec:
          tolerations:
          - key: "app"
            operator: "Equal"
            value: "foo"
            effect: "NoSchedule"
          containers:
          - name: ghostunnel
            image: squareup/ghostunnel:v1.5.2
            args:
            - "server"
            - "--listen=0.0.0.0:8443"
            - "--target=127.0.0.1:8080"
            - "--use-workload-api-addr=unix:///run/spire/sockets/agent.sock"
            - "--allow-uri=spiffe://bar.example.com/client"
            ports:
            - containerPort: 8443
            volumeMounts:
            - name: spire-agent-socket
              mountPath: /run/spire/sockets
              readOnly: false
          - name: http-server
            image: python:3.8.0-alpine
            args: ["python", "-m", "http.server", "8080"]
            ports:
            - containerPort: 8080
          volumes:
            - name: spire-agent-socket
              hostPath:
                path: /run/spire/sockets
                type: DirectoryOrCreate
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: http-server
      namespace: foo
    spec:
      type: NodePort
      ports:
        - name: https
          port: 18443
          targetPort: 8443
          protocol: TCP
      selector:
        app: http-server

Namespace=barにデプロイするマニフェスト例

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: client
      namespace: bar
      labels:
        app: client
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: client
      template:
        metadata:
          labels:
            app: client
        spec:
          tolerations:
          - key: "app"
            operator: "Equal"
            value: "bar"
            effect: "NoSchedule"
          containers:
          - name: ghostunnel
            image: squareup/ghostunnel:v1.5.2
            args:
            - "client"
            - "--listen=0.0.0.0:8080"
            - "--target=http-server.foo.svc.cluster.local:18443"
            - "--use-workload-api-addr=unix:///run/spire/sockets/agent.sock"
            - "--verify-uri=spiffe://foo.example.com/http-server"
            - "--unsafe-listen"
            volumeMounts:
            - name: spire-agent-socket
              mountPath: /run/spire/sockets
              readOnly: false
          - name: sleep
            image: alpine
            command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']
          volumes:
            - name: spire-agent-socket
              hostPath:
                path: /run/spire/sockets
                type: DirectoryOrCreate
## 異なるトラストドメイン間での認証

Namespace=barにデプロイしたPodからNamespace=fooのHTTP Serverにアクセスしてみます。今回の構成ではクライアントはghostunnelを経由してアクセスします。ghostunnelはお互いにmTLSで通信し、その際にSPIREから発行されたSVIDを使って認証しています。

Namespace=fooとbarは異なるトラストドメインのため、通常はお互いの証明書を検証できずに通信が失敗します。今回はフェデレーションを有効にしているため、お互いのトラストドメインのCA証明書バンドルを交換済みであり、ghostunnelはWorkload API経由でSVIDを取得する際にフェデレーションが許可されたトラストドメインの証明書バンドルを手にいれることができます。


    $ kubectl exec -it -n bar ${POD_NAME} sh
    
    # curl --silent http://localhost:8080/ | head
    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
    <html>
    <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Directory listing for /</title>
    </head>
    <body>
    <h1>Directory listing for /</h1>
    <hr>
    <ul>


## フェデレーションしていない場合

念のためフェデレーションしていない場合に通信が失敗することを確認してみます。
先程作成したRegistration Entryを一度削除し、以下のようにフェデレーションの設定をせずに再登録します。
登録された結果に `FederatesWith` の値が含まれていない点を確認してください。

    # /opt/spire/bin/spire-server entry show
    Found 2 entries
    Entry ID      : cdb445de-e14d-44d0-af7b-b9aecb677331
    SPIFFE ID     : spiffe://foo.example.com/http-server
    Parent ID     : spiffe://foo.example.com/spire-agent
    TTL           : 3600
    Selector      : k8s:pod-label:app:http-server
    DNS name      : http-server
    DNS name      : http-server.foo.svc.cluster.local
    
    Entry ID      : 06f327bd-cdc5-464c-b315-fef71dc5726b
    SPIFFE ID     : spiffe://foo.example.com/spire-agent
    Parent ID     : spiffe://foo.example.com/spire/server
    TTL           : 3600
    Selector      : k8s_psat:agent_ns:foo


    #  /opt/spire/bin/spire-server entry show
    Found 2 entries
    Entry ID      : 0b260337-da76-49b3-98bb-1c169a4e4711
    SPIFFE ID     : spiffe://bar.example.com/client
    Parent ID     : spiffe://bar.example.com/spire-agent
    TTL           : 3600
    Selector      : k8s:pod-label:app:client
    
    Entry ID      : ec192d5d-b6b5-4d7a-b368-934e4b7773f7
    SPIFFE ID     : spiffe://bar.example.com/spire-agent
    Parent ID     : spiffe://bar.example.com/spire/server
    TTL           : 3600
    Selector      : k8s_psat:agent_ns:bar

そのうえで先程と同じワークロードのマニフェストをそれぞれのNamespaceにデプロイし、クライアントから接続してみます。

    ❯ kubectl exec -it -n bar ${POD_NAME} sh
    # curl  http://localhost:8080/
    curl: (56) Recv failure: Connection reset by peer

このようにフェデレーションをしていない場合は接続が失敗するようになりました。
Namespace=barのghostunnelでは以下のように`foo.example.com`のCA証明書が見つからない旨のエラーがでています。

    ❯ kubectl logs -n bar ${POD_NAME} ghostunnel
    ...省略...
    [1] 2019/12/17 05:26:35.997469 error on dial: no roots for peer trust domain "spiffe://foo.example.com"

Namesapce=fooのghostunnelでは証明書エラーのログが出ています。

    ❯ kubectl logs -n foo ${POD_NAME} ghostunnel
    ...省略...
    [1] 2019/12/17 05:26:41.381518 error on TLS handshake from 10.244.2.15:33568: remote error: tls: bad certificate

## まとめ

今回の検証手順はちょっと長くなってしまいしたが、SPIREでフェデレーションする際にはServerの設定でフェデレーションを有効にすることと、Registraion Entryで受け入れるトラストドメインを指定することで、異なるトラストドメインであってもお互いのSVIDを検証することができるようになります。

今年は念願のSPIFFE Meetup Tokyoも開催できたり、SPIFFE/SPIREを少しは知ってもらえる機会を作れたのかなと思っています。Meetup開催に協力して頂いた方、ご参加頂いた方々には感謝しています。来年も引き続き盛り上げていきたいと思いますのでよろしくおねがいします。