Featured image of post Receptor (3): 暗号化と認証、ファイアウォール、電子署名

Receptor (3): 暗号化と認証、ファイアウォール、電子署名

はじめに

これまでのエントリ(前々回、前回)では、Receptor の基本的な動きと、Kubernetes との連携を紹介しました。

本エントリでは、Receptor の持つ以下のセキュリティ関連の機能を取り扱います。

  • 接続できるピアの制限
  • TLS による暗号化と認証
  • ファイアウォール
  • ワークの電子署名

必要なファイルは、これまで通り GitHub のリポジトリに配置 しています。

構成例

今回は、前述のセキュリティ関連機能をさまざまなパタンで試すため、少し複雑ですが、図の 8 ノードで構成します。Controller が 4 ノード、Executor が 3 ノードです。

一連のセキュリティ関連の機能の確認のみを目的としているため、作りこみはせずにすべてコンテナ環境で構成します。Kubernetes ではなく Docker を利用しますが、Kubernetes を利用する場合も考え方はまったく一緒です。

なお、GitHub のリポジトリのファイル群 には、デモ用の構成をあらかじめすべて記述 してあります。このため、そもそもあるノードがメッシュネットワークに参加できなかったり、あるノードからあるノードに接続できなかったりしますが、デモのために意図的にそうしたものです。詳細はそれぞれのデモの中で紹介します。

準備: 証明書と鍵ペアの作成

暗号化と認証では証明書が、電子署名とその検証では鍵ペアがそれぞれ必要です。後続のデモに備えて、Receptor を起動する前にあらかじめ作成します。

証明書の作成

必要な証明書には、次のものがあります。

  • CA の証明書と秘密鍵
  • その CA に署名された各ノードのサーバ証明書(またはクライアント証明書)と秘密鍵

Receptor は組み込みで証明書の発行機能を持っているため、これを利用すると簡単に用意できます。いずれもがんばれば Receptor を使わずとも手作りはできますが、特にサーバ証明書(クライアント証明書)は Subject Alternative Name(SAN)に特殊なフィールドを持つ(後述)ため、ちょっと大変です。

CA 証明書と秘密鍵 は、次のコマンドで作成できます。デフォルトの有効期限は作成時点から 10 年ですが、必要に応じて notbefore と notafter を指定して任意の期限に設定できます(詳細は --help に記載があります)。

receptor --cert-init \
  commonname="<Common Name>" \
  bits=2048 \
  outcert=/path/to/ca.crt \
  outkey=/path/to/ca.key

CA 証明書が作成できたら、それを利用して次のコマンドで サーバ証明書兼クライアント証明書 を発行できます。ひとつめのコマンドで証明書署名要求(CSR)を作成し、ふたつめのコマンドでそれに署名しています。これもデフォルトの有効期限は 10 年です。その他のオプションは --help に記載があります。

receptor --cert-makereq \
  bits=2048 \
  commonname="<Common Name>" \
  dnsname=<DNS Name> \
  nodeid=<Node ID> \
  outreq=/path/to/server.csr \
  outkey=/path/to/server.key
receptor --cert-signreq \
  req=/path/to/server.csr \
  cacert=/path/to/ca.crt \
  cakey=/path/to/ca.key \
  outcert=/path/to/server.crt

今回のデモでは、全部で 8 ノード分の証明書の発行が必要です。コマンドを逐一組み立てるのも手間ですし、receptor のバイナリを用意するのも手間なので、次のようなコマンドを流して、全部を Docker 内で処理させます(コンテナ内のシェルをとって receptor を叩くのももちろんアリです)。これで、ホスト側の ./certs に生成されたファイル群が保存されます。

RECEPTOR_IMAGE="quay.io/ansible/receptor:v1.3.0"
NODES="
c01.example.internal:controller01
c02.example.internal:controller02
c03.example.internal:controller03
c04.example.internal:controller04
r01.example.internal:relayer01
e01.example.internal:executor01
e02.example.internal:executor02
e03.example.internal:executor03
"

# ルート CA 証明書の作成
docker run --rm --volume "${PWD}/certs:/tmp/certs" ${RECEPTOR_IMAGE} \
  receptor --cert-init commonname="Receptor Example CA" bits=2048 outcert=/tmp/certs/ca.crt outkey=/tmp/certs/ca.key

# サーバ証明書兼クライアント証明書の発行
for item in ${NODES}; do
  node=(${item//:/ })
  echo "Generating new certificate for ${node[0]} (${node[1]})"
  docker run --rm --volume "${PWD}/certs:/tmp/certs" ${RECEPTOR_IMAGE} \
    receptor --cert-makereq bits=2048 commonname="${node[1]} example cert" dnsname=${node[0]} nodeid=${node[1]} outreq=/tmp/certs/${node[0]}.csr outkey=/tmp/certs/${node[0]}.key
  docker run --rm --volume "${PWD}/certs:/tmp/certs" ${RECEPTOR_IMAGE} \
    receptor --cert-signreq req=/tmp/certs/${node[0]}.csr cacert=/tmp/certs/ca.crt cakey=/tmp/certs/ca.key outcert=/tmp/certs/${node[0]}.crt verify=true
  docker run --rm --volume "${PWD}/certs:/tmp/certs" ${RECEPTOR_IMAGE} \
    rm /tmp/certs/${node[0]}.csr
done

生成された証明書の中身は後述のデモで紹介します。

鍵ペアの作成

鍵ペアは、電子署名とその検証で必要です。これは一般的なお作法に従って openssl で作成できます。

次のコマンドで、./certs 下に鍵ペアが生成されます(証明書ではないので本当は ./certs 下に配置するのは不適切ですが、一か所にまとめたいのでそうしています)。

openssl genrsa -out certs/signworkprivate.pem 2048
openssl rsa -in certs/signworkprivate.pem -pubout -out certs/signworkpublic.pem

電子署名のデモに備えて、上記を別のファイル名でもう一度実行し、鍵ペアを合計で 2 つ作っておきます。

openssl genrsa -out certs/signworkprivate.c02.pem 2048
openssl rsa -in certs/signworkprivate.c02.pem -pubout -out certs/signworkpublic.c02.pem

ファイルの確認

./certs 下に次のファイル群が揃っていれば、準備は完了です。

$ ls -l certs/
total 80
-rw-------. 1 root root 1212 Dec  9 15:58 c01.example.internal.crt
-rw-------. 1 root root 1675 Dec  9 15:58 c01.example.internal.key
-rw-------. 1 root root 1212 Dec  9 15:58 c02.example.internal.crt
-rw-------. 1 root root 1675 Dec  9 15:58 c02.example.internal.key
-rw-------. 1 root root 1212 Dec  9 15:58 c03.example.internal.crt
-rw-------. 1 root root 1679 Dec  9 15:58 c03.example.internal.key
-rw-------. 1 root root 1212 Dec  9 15:58 c04.example.internal.crt
-rw-------. 1 root root 1679 Dec  9 15:58 c04.example.internal.key
-rw-------. 1 root root 1139 Dec  9 15:58 ca.crt
-rw-------. 1 root root 1679 Dec  9 15:58 ca.key
-rw-------. 1 root root 1208 Dec  9 15:58 e01.example.internal.crt
-rw-------. 1 root root 1679 Dec  9 15:58 e01.example.internal.key
-rw-------. 1 root root 1208 Dec  9 15:58 e02.example.internal.crt
-rw-------. 1 root root 1679 Dec  9 15:58 e02.example.internal.key
-rw-------. 1 root root 1208 Dec  9 15:58 e03.example.internal.crt
-rw-------. 1 root root 1679 Dec  9 15:58 e03.example.internal.key
-rw-------. 1 root root 1204 Dec  9 15:58 r01.example.internal.crt
-rw-------. 1 root root 1679 Dec  9 15:58 r01.example.internal.key
-rw-------. 1 kuro kuro 1675 Dec  9 16:05 signworkprivate.c02.pem
-rw-------. 1 kuro kuro 1675 Dec  9 16:05 signworkprivate.pem
-rw-rw-r--. 1 kuro kuro  451 Dec  9 16:05 signworkpublic.c02.pem
-rw-rw-r--. 1 kuro kuro  451 Dec  9 16:05 signworkpublic.pem

Docker Compose ファイルの確認

ホスト側の ./certs 下のファイルのうち、そのノードに必要なファイルのみがコンテナ内で /etc/receptor/certs 下にマウントされるよう、Docker Compose ファイルで volumes を構成しています。以下は c01 の例です。

...
  c01:
    ...
    volumes:
      - "./certs/ca.crt:/etc/receptor/certs/ca.crt"
      - "./certs/c01.example.internal.crt:/etc/receptor/certs/c01.example.internal.crt"
      - "./certs/c01.example.internal.key:/etc/receptor/certs/c01.example.internal.key"
      - "./certs/signworkprivate.pem:/etc/receptor/certs/signworkprivate.pem"
      - "./conf/c01.yml:/etc/receptor/receptor.conf"

デモ (1): 接続できるピアの制限

メッシュネットワークを構成する際、バックエンドでの接続方法を指定するため、隣接するノード間で *-listener と *-peer を定義することを 最初のエントリ で紹介しました。

このとき、*-listener 側は、他のノードから *-peer で指定されると、デフォルトでは制限なくすべての接続要求を受け入れます。これには、第三者により管理者の意図しないノードをメッシュネットワークに追加されうるリスクがあります。

この対策として、*-listener 側で、接続を受け入れるノードをホワイトリスト方式で制限 できます。

今回のデモでは、r01(relayer01)にこの設定を導入し、c04(controller04)はホワイトリストに含めない 構成で動作を確認します。

設定ファイルの作成

r01(relayer01)の設定ファイルの tcp-listener で、allowedpeers を設定します。

...
- tcp-listener:
    port: 7323
    ...
    allowedpeers:
      - controller01
      - controller02
      - controller03
      - executor01
      - executor02
      - executor03

これが、バックエンドでの接続を受け入れるノードのホワイトリスト です。記載の通り、controller04 はこのリストには含まれません。

実際の動き

コンテナを起動して、動きを確認します。

docker compose up -d

c04 のログを見ると、バックエンドの接続に失敗し、経路情報の交換ができていないことがわかります。

$ docker compose logs c04
c04  | DEBUG 2022/12/09 06:18:49 Running TCP peer connection r01.example.internal:7323
c04  | DEBUG 2022/12/09 06:18:49 Sending initial connection message
...
c04  | WARNING 2022/12/09 06:18:54 Backend connection exited (will retry)
...
c04  | DEBUG 2022/12/09 06:18:54 Re-calculating routing table
c04  | INFO 2022/12/09 06:18:54 Known Connections:
c04  | INFO 2022/12/09 06:18:54    controller04: 
c04  | INFO 2022/12/09 06:18:54    relayer01: 
c04  | INFO 2022/12/09 06:18:54 Routing Table:
...

ホワイトリストを定義した r01 側では、controller04 からの接続を拒否したログが確認できます。

$ docker compose logs r01
...
r01  | DEBUG 2022/12/09 06:18:49 Listening on TCP [::]:7323
...
r01  | ERROR 2022/12/09 06:18:49 Backend receiving error read tcp 172.21.0.4:7323->172.21.0.5:54814: use of closed network connection
r01  | ERROR 2022/12/09 06:18:49 Backend error: rejected connection with node controller04 because it is not in the allowed peers list
...

正常な他の Controller(例えば c01)でメッシュネットワークの状態を確認すると、controller04 がそもそも参加できていないことが確認できます。

[root@c01 /]# receptorctl status
...
Known Node   Known Connections
controller01 relayer01: 1 
controller02 relayer01: 1 
controller03 relayer01: 1 
executor01   relayer01: 1 
executor02   relayer01: 1 
executor03   relayer01: 1 
relayer01    controller01: 1 controller02: 1 controller03: 1 executor01: 1 executor02: 1 executor03: 1 
...

c04 は、自分自身しか認識できていません。

[root@c04 /]# receptorctl status
...
Known Node   Known Connections
controller04 
relayer01    

Node         Service   Type       Last Seen             Tags
controller04 control   Stream     2022-12-09 06:20:57   {'type': 'Control Service'}

allowedpeers の設定により、バックエンドで接続を許容するノードを制限できることが確認できました。

デモ (2): TLS による暗号化と相互認証

これまでのエントリで紹介してきた構成では、明示的な暗号化を行っていませんでした。ここでは、Receptor が持つ TLS 関連の機能、すなわち、サーバ証明書とクライアント証明書による 相互認証と暗号化 の動きを確認します。

前提: 二種類のネットワークと TLS

Receptor が扱うネットワークには、次の二種類があります。

  • バックエンドネットワーク(アンダレイネットワーク、Below-the-mesh)
    • メッシュの 下 のネットワーク
    • 隣接するノード間 の通信に利用する
    • 設定では *-listener と *-peer が該当する
  • オーバレイネットワーク(Above-the-mesh)
    • メッシュの 上 のネットワーク
    • メッシュネットワーク上で 任意のノード間 が通信するために利用する
    • 設定では control-service が該当する

少しややこしいですが、Receptor の TLS の設定はこの 二種類のそれぞれで設定がある ため、区別して考える必要があります。

前提: バックエンドネットワークの TLS 設定

バックエンドネットワーク の TLS 設定は、隣接ノード間の通信 に関連します。すなわち、バックエンドで接続するとき の *-listener をサーバ側、*-peer をクライアント側 として構成し、隣接ノード間の通信を保護します。

メッシュネットワーク上で発生する通信は、結局は下位のレイヤでバックエンドネットワークを介して対向のノードに届きます。したがって、バックエンドネットワークの TLS さえ設定 すれば、下位ネットワーク上の盗聴やなりすましからは、メッシュネットワーク上の通信も保全される といえます。

TLS を構成しない状態では、バックエンドの通信は暗号化されません。交換される経路の情報やオーバレイネットワーク上の通信の一部は、パケットを覗けば以下の通り平文で確認できます。

前提: オーバレイネットワークの TLS 設定

オーバレイネットワーク の TLS 設定は、主に ワークを実行するときの通信 に関連します。具体例は、ワークを実行した時に発生する、Controller ノードと Executor ノードの間での実行指示やパラメータ、ペイロード、実行結果などのやりとりです。オーバレイネットワークの TLS 設定では、Executor の Control Service をサーバ側、Controller をクライアント側 として構成します。

クライアントのイメージが少しつきにくいですが、雑に言えば、receptorctl で work submit を実行する Controller のことです。

こうしたオーバレイネットワーク上のノード間のやりとりは、送信元と送信先のノード ID やサービス名を示す ヘッダ と、実際にノード間でやりとりしたい データ本体(例えばワークに伴うパラメータや標準入出力の中身)が連結されたバイト列のメッセージとして授受されます。オーバレイネットワークの TLS 設定は、このうち データ本体 の保護に寄与するものです(ヘッダ部分は保護の範囲外です)。

前述の通り、バックエンドネットワークの TLS さえ設定すれば、下位ネットワーク上の盗聴やなりすましからはメッシュネットワーク上の通信も保全されるといえます。したがって、オーバレイネットワークの TLS 設定は、下位ネットワーク上よりはむしろ 経路のノード上での盗聴やなりすまし への対策と言えそうです。下位ネットワークの TLS 設定が危殆化したときの二重の備えとも位置付けられます。

なお、オーバレイネットワークには現在の実装では QUIC が利用されており、QUIC が TLS の設定を必須としていることから、TLS の設定を明示しない場合 でも 起動時に自己署名証明書が発行されて暗号化に利用 されます。このため、明示的に TLS の設定をしなくても、データ本体が平文で見えてしまうことは以下の通りありません。

したがって、最低限の暗号化だけを実施したい場合は、明示的な TLS の設定は必須ではありません。ただし、証明書の検証はスキップ されるため、ワークの 送信元と送信先の身元の正当性を保証したい 場合は、やはり 明示的な設定が必要 です。

前提: 証明書の中身

前述の手順で作成した証明書のうち、CA 証明書 は特に変わったところはありません。自前の証明書への置き換えも容易です。

$ sudo openssl x509 -text -in certs/ca.crt -noout
Certificate:
    Data:
        ...
        Issuer: CN = Receptor Example CA
        Validity
            Not Before: Dec  9 05:58:46 2022 GMT
            Not After : Dec  9 05:58:46 2032 GMT
        Subject: CN = Receptor Example CA
        ...

一方で、サーバ証明書(兼クライアント証明書) は、バックエンドネットワークとオーバレイネットワークの 両方 で利用されます。つまり、バックエンド用に ホストの正当性 を保証するだけでなく、オーバレイ用に ノードの正当性 も保証できる必要があります。

この目的で、前述の手順で Receptor により発行したサーバ証明書兼クライアント証明書には、Subject Alternative Name(SAN)に、バックエンド用の DNS のホスト名 だけでなく、オーバレイ用の ノード ID も含まれています。たとえば、c01(controller01)の証明書を確認します。

$ CERT="certs/c01.example.internal.crt"
$ sudo openssl x509 -text -in ${CERT} -noout
Certificate:
    Data:
        ...
        Issuer: CN = Receptor Example CA
        Validity
            Not Before: Dec  9 05:58:47 2022 GMT
            Not After : Dec  9 05:58:47 2023 GMT
        Subject: CN = controller01 example cert
        ...
        X509v3 extensions:
            X509v3 Subject Alternative Name: 
                DNS:c01.example.internal, othername:<unsupported>

SAN の DNS にホスト名が含まれるほか、othername の存在 も確認できます。このフィールドをパースすると、Receptor のノード ID 用に予約された OID 1.3.6.1.4.1.2312.19.1 と、実際に設定された ノード ID が見られます。

$ OFFSET=$(sudo openssl asn1parse -in ${CERT} | grep -A 1 "X509v3 Subject Alternative Name" | tail -n 1 | sed 's/:.*//g')
$ sudo openssl asn1parse -in ${CERT} -strparse ${OFFSET}
   ...
   26:d=2  hl=2 l=   9 prim: OBJECT            :1.3.6.1.4.1.2312.19.1
   ...
   39:d=3  hl=2 l=  12 prim: UTF8STRING        :controller01

これにより、ホストとノードの両方の正当性 を保証し、同時に そのホストがたしかにそのノードであることも保証 できるようになっています。

デモ (2-1): バックエンドネットワークの TLS

まずはバックエンドネットワークの TLS 設定と動作を確認します。

設定ファイルの作成

前述の通り、*-listener をサーバ側、*-peer をクライアント側 として構成します。今回は tcp-listener と tcp-peer です。

今回の構成では、tcp-listener は r01(relayer01)で定義されています。設定ファイルを抜粋します。

...
- tls-server:
    name: example-tls-server
    cert: /etc/receptor/certs/r01.example.internal.crt
    key: /etc/receptor/certs/r01.example.internal.key
    requireclientcert: true
    clientcas: /etc/receptor/certs/ca.crt

- tcp-listener:
    port: 7323
    tls: example-tls-server
    ...

tls-server で TLS のサーバ側の設定を行い、それを tcp-listener で呼び出す形で記述します。

tls-server には、利用するサーバ証明書と秘密鍵(cert と key)を指定します。ここでは、相手の正当性を双方向で検証させるため、クライアント証明書の要求を有効化(requireclientcert を true に)し、それに利用する CA の証明書を指定(clientcas)しています。一連の設定には name で名前を付けます。

tcp-listener の tls で tls-server の name を指定することで、この tcp-listener の待ち受けに TLS が利用されるようになります。

続けて、tcp-peer の定義の例として c01(controller01)の設定ファイルを抜粋します。サーバ側の設定と構造は似ていて、tls-client を定義して tcp-peer で呼び出す形です。

...
- tls-client:
    name: example-tls-client
    rootcas: /etc/receptor/certs/ca.crt
    cert: /etc/receptor/certs/c01.example.internal.crt
    key: /etc/receptor/certs/c01.example.internal.key

- tcp-peer:
    address: r01.example.internal:7323
    tls: example-tls-client
...

tls-client では、ここではサーバ証明書の検証に利用する CA の証明書を指定(rootcas)しています。また、サーバ側でクライアント証明書の検証を必須にしたため、クライアント証明書と秘密鍵(cert と key)も指定しています。一連の設定には name で名前を付けます。

tcp-peer の tls で tls-client の name を指定することで、この tcp-peer の接続に TLS が利用されるようになります。

実際の動き

コンテナを起動し、正常にメッシュネットワークが構成されていることを確認します。

docker compose up -d
[root@c01 /]# receptorctl status
...
Known Node   Known Connections
controller01 relayer01: 1 
controller02 relayer01: 1 
controller03 relayer01: 1 
executor01   relayer01: 1 
executor02   relayer01: 1 
executor03   relayer01: 1 
relayer01    controller01: 1 controller02: 1 controller03: 1 executor01: 1 executor02: 1 executor03: 1 

Route        Via
controller02 relayer01
controller03 relayer01
executor01   relayer01
executor02   relayer01
executor03   relayer01
relayer01    relayer01
...

バックエンド接続が正常に行われ、経路の交換ができているようです。

具体的な手順は割愛しますが、パケットを見ると、平文の通信がなくなっていることが確認できます。

何らかの原因でバックエンド接続ができなかった場合は、ログから原因を特定できます。設定例は割愛しますが、追加で例えば以下のようなことを試して動きを確認するのもよいでしょう。

  • クライアント側かサーバ側のどちらかで TLS の設定を無効にする
  • サーバ側の証明書内のホスト名と tcp-peer で指定されるホスト名が一致しない状態にする
  • 証明書内のノード名と実際のノード名が一致しない状態にする

いずれの場合も、バックエンドの接続に失敗するか、そもそも Receptor が起動できないかで、正常に動作しません。

デモ (2-2): オーバレイネットワークの TLS

続けて、オーバレイネットワークの TLS 設定と動作を確認します。

設定ファイルの作成

前述の通り、オーバレイネットワークの TLS 設定では、Executor の Control Service をサーバ側、Controller をクライアント側 として構成します。

今回は、サーバ側 の構成例として e03(executor03)を利用します。設定ファイルを抜粋します。

...
- tls-server:
    name: example-tls-server
    cert: /etc/receptor/certs/e03.example.internal.crt
    key: /etc/receptor/certs/e03.example.internal.key
    requireclientcert: true
    clientcas: /etc/receptor/certs/ca.crt

- control-service:
    service: control
    tls: example-tls-server
...

tls-server の記述内容は、前述のバックエンドネットワークの TLS 設定のそれとまったく同じです。オーバレイネットワークでこれを利用するには、それを control-service で呼び出し ます。

また、動作を確認するため、e01(executor01)と e03(executor03)で、以下のコマンドワークを定義しています。

...
- work-command:
    worktype: echo-reply
    command: bash
    params: "-c 'while read -r line; do echo Reply from $(cat /etc/hostname): ${line^^}; done'"
...

クライアント側 の Controller に必要な設定は、tls-client だけです。例として c01(controller01)の設定ファイルを抜粋しますが、前述のバックエンドネットワーク用の tls-client とまったく同じで、そのまま再利用できます。

...
- tls-client:
    name: example-tls-client
    rootcas: /etc/receptor/certs/ca.crt
    cert: /etc/receptor/certs/c01.example.internal.crt
    key: /etc/receptor/certs/c01.example.internal.key
...

ここで定義した name を、receptorctl の work submit の実行時に呼び出す ことになります。

実際の動き

コンテナを起動して、動きを確認します。

docker compose up -d

メッシュネットワークの状態を見ると、executor03 の Control Service のタイプが StreamTLS であることが確認できます。これが、オーバレイネットワークの Control Service で明示的に TLS の利用を構成した状態です。

[root@c01 /]# receptorctl status
...
Node         Service   Type       Last Seen             Tags
executor03   control   StreamTLS  2022-12-09 06:28:04   {'type': 'Control Service'}
controller01 control   Stream     2022-12-09 06:28:07   {'type': 'Control Service'}
controller02 control   Stream     2022-12-09 06:27:09   {'type': 'Control Service'}
executor01   control   Stream     2022-12-09 06:27:10   {'type': 'Control Service'}
controller03 control   Stream     2022-12-09 06:27:10   {'type': 'Control Service'}
executor02   control   Stream     2022-12-09 06:27:10   {'type': 'Control Service'}

まずは、TLS を 構成していない Executor に対してワークを送信します。例として executor01 に宛てます。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-reply
Reply from e01.example.internal: HELLO RECEPTOR!
(gCobCpTe, released)

コマンドは 以前のエントリ で紹介したものとまったく一緒です。正常に動作しています。

続けて、TLS を 構成した Executor(executor03)に宛てて同じコマンドを実行します。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor03 \
  --rm \
  --follow \
  --payload - \
  echo-reply
ERROR: Remote unit failed: TLS error connecting to remote service: CRYPTO_ERROR (0x12a): insecure connection to secure service
(fi4rBON8, released)

executor03 側の Control Service で TLS の設定が行われているため、このままでは接続を確立できず、ワークが送信できません。

ここで、オプション --tls-client を追加し、この Controller の設定ファイルで記述した tls-client の name を指定します。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor03 \
  --rm \
  --follow \
  --payload - \
  --tls-client example-tls-client \
  echo-reply
Reply from e03.example.internal: HELLO RECEPTOR!
(Pc9E1HSG, released)

正常に実行できるようになりました。これが、ワークの送信を行う Controller と対向の Control Service の間で TLS による相互認証と暗号化が行われた状態です。

デモ (3): ファイアウォール

続いて、組み込みのファイアウォール機能の構成と動作を確認します。

前提: ファイアウォールの考え方

Receptor に組み込まれたファイアウォールは、バックエンドネットワーク向けではなく オーバレイネットワーク向けの機能 です。メッシュネットワーク上の 任意のノードで有効化 でき、任意のノード間の通信を許可または拒否 できます。

本デモでは、Controller から Executor に通信するときに必ず経由する r01(relayer01)でファイアウォールを有効化し、特定の Controller のみ Executor との通信が許可された状態(ホワイトリスト)を構成します。また、併せて e02(executor02)で、特定の Controller からの接続を拒否する状態(ブラックリスト)を構成します。

前提: ルールの考え方

ひとつのファイアウォールルールは、次の 4 つの要素と、合致した場合の処理(action)を任意で組み合わせて定義します。詳細は ドキュメント に記載があります。

  • 送信元ノード ID(fromnode)
  • 送信先ノード ID(tonode)
  • 送信元サービス名(fromservice)
  • 送信先サービス名(toservice)

合致した場合の処理は、許可(accept)、破棄(drop)、拒否(reject)の三種です。

前提: ルールの評価

ルールは 記述した順に評価 され、最初に合致した条件で処理 されます。また、いずれのルールにも合致しない場合は許可 されます。

したがって、つまり ブラックリスト 的な考え方であり、いわゆる 暗黙の Deny はありません。ホワイトリスト 的な使い方をしたい場合は 最後にすべて拒否するルールを明示的に追加 する必要があります。

また、いわゆる ステートレス っぽい考え方なので、特に ホワイトリスト 的に使う場合は行きだけでなく 戻りも明示的に許可 が必要です。

例えば、あるワークを Controller から Executor に送信して実行させるとき、行き の通信は次のような状態です。戻り はそれぞれ逆になります。

項目 値
送信元ノード ID Controller のノード ID
送信先ノード ID Executor のノード ID
送信元サービス名 自動生成されるランダム文字列 (実行時に起動される一時的なサービスの名称)
送信先サービス名 Executor の Control Service の名称(通常は control)

設定ファイルの作成

今回は、r01(relayer01)で、ホワイトリスト 的な考え方で次の要件を実装します。

  • controller01 と controller02 から、executor01、02、03 への通信を許可(行き用)
  • executor01、02、03 から、controller01 と controller02 への通信を許可(戻り用)
  • それ以外は拒否

設定ファイルを抜粋します。

---
- node:
    id: relayer01
    firewallrules:
      - action: accept
        fromnode: /controller0[12]/
        tonode: /executor0[1-3]/
      - action: accept
        fromnode: /executor0[1-3]/
        tonode: /controller0[12]/
      - action: accept
        toservice: unreach
      - action: reject
...

行頭と末尾を / で囲むことで正規表現で記述できる(ただし完全一致が必要です)ため、ある程度まとめて全部で 4 つのルールを記述しています。

最初の 2 つのルールが、目的の Controller と Executor 間の通信を明示的に許可するものです。最初が行き、次が戻りです。

その次の 3 番目のルールでは、サービス名 unreach を宛先とするデータをすべて許可しています。これは、内部的に何らかの原因でメッセージが配送できなかった際に生成される 到達不可メッセージ の配送を、送信元や送信先のノードに関わらずすべて許可するためです。ファイアウォールによって拒否された旨を示すメッセージもこれに該当するため、許可しておくと動きがわかりやすくなります。

最後の 4 番目のルールで、これまでの 3 つのルールのいずれにも合致しない通信がすべて拒否されます。

3 番目のルール以外は、サービス名(fromservice や toservice)を指定していません。したがって、ワークのやりとりで発生する通信(サービス名 control との通信)だけでなく receptorctl ping で発生する Ping(サービス名 ping との通信)にも適用されます。

また、e02(executor02)では、ブラックリスト 的な考え方で次の要件を実装します。

  • controller02 からの通信は拒否

片方向だけ閉じれば通信は成立しなくなるため、戻り分は考慮しません。設定ファイルは次の通りです。

---
- node:
    id: executor02
    firewallrules:
      - action: reject
        fromnode: controller02
...

ここでは正規表現ではなくベタ書きしています。

加えて、executor01、02、03 のそれぞれで、動作確認用の次のコマンドワークを定義しています。

...
- work-command:
    worktype: echo-reply
    command: bash
    params: "-c 'while read -r line; do echo Reply from $(cat /etc/hostname): ${line^^}; done'"
...

実際の動き

コンテナを起動して、動きを確認します。

docker compose up -d

初めに、relayer01 のファイアウォールの動作を確認するため、controller01、02、03 のそれぞれから、executor01 に対してワークを送信します。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-reply
Reply from e01.example.internal: HELLO RECEPTOR!
(0eyPn5a8, released)
[root@c02 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-reply
Reply from e01.example.internal: HELLO RECEPTOR!
(p2qLHTyg, released)
[root@c03 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-reply
^C
(8B0hLBzU, released)

経路の relayer01 では、Executor との通信は controller01 と 02 のみを許可していました。これにより、controller03 からのワークの送信はできなくなっています(Ctrl+C で中断できます)。--rm と --follow を外してワークを送信してから work list を見ると、Pending で止まっている様子が確認できます。確認後はリリースしておきます。

[root@c03 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --payload - \
  echo-reply
^C

[root@c03 /]# receptorctl work list
{
    "gtAwdBBS": {
        "Detail": "Starting Worker",
        ...
        "State": 0,
        "StateName": "Pending",
        ...
    }
}

[root@c03 /]# receptorctl work release --all
Released:
(gtAwdBBS, released)

ワークだけでなく、Ping もファイアウォールで拒否されます。

[root@c01 /]# receptorctl ping executor01
Reply from executor01 in 455.761µs
Reply from executor01 in 609.2µs
Reply from executor01 in 414.827µs
Reply from executor01 in 751.812µs
[root@c02 /]# receptorctl ping executor01
Reply from executor01 in 406.311µs
Reply from executor01 in 640.188µs
Reply from executor01 in 590.659µs
Reply from executor01 in 1.32654ms
[root@c03 /]# receptorctl ping executor01
ERROR: blocked by firewall
ERROR: blocked by firewall
ERROR: blocked by firewall
ERROR: blocked by firewall

c02 のログでは、relayer01 から到達不可メッセージが返ってきたことが確認できます。

$ docker compose logs c03
...
c03  | WARNING 2022/12/09 06:32:11 Received unreachable message from relayer01
c03  | WARNING 2022/12/09 06:32:12 Received unreachable message from relayer01
c03  | WARNING 2022/12/09 06:32:13 Received unreachable message from relayer01
c03  | WARNING 2022/12/09 06:32:14 Received unreachable message from relayer01
...

続けて、executor02 のファイアウォールの動作を確認するため、controller01 と 02 から、executor01 と 02 に対してワークを送信します。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-reply
Reply from e01.example.internal: HELLO RECEPTOR!
(cXaxadUZ, released)

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor02 \
  --rm \
  --follow \
  --payload - \
  echo-reply
Reply from e02.example.internal: HELLO RECEPTOR!
(kdnrbl9s, released)
[root@c02 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-reply
Reply from e01.example.internal: HELLO RECEPTOR!
(R0kTKm5W, released)

[root@c02 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor02 \
  --rm \
  --follow \
  --payload - \
  echo-reply
^C

executor02 では、controller02 からの通信を拒否していました。これにより、controller02 は、executor01 とは通信できる一方で、executor02 には到達できません(Ctrl+C で中断できます)。

Ping も同様です。このパタンでは、到達できなかった旨が即座に返ってきます。

[root@c01 /]# receptorctl ping executor01
Reply from executor01 in 455.761µs
Reply from executor01 in 609.2µs
Reply from executor01 in 414.827µs
Reply from executor01 in 751.812µs

[root@c01 /]# receptorctl ping executor02
Reply from executor02 in 485.397µs
Reply from executor02 in 655.979µs
Reply from executor02 in 617.134µs
Reply from executor02 in 1.550298ms
[root@c02 /]# receptorctl ping executor01
Reply from executor01 in 406.311µs
Reply from executor01 in 640.188µs
Reply from executor01 in 590.659µs
Reply from executor01 in 1.32654ms

[root@c02 /]# receptorctl ping executor02
ERROR: blocked by firewall
ERROR: blocked by firewall
ERROR: blocked by firewall
ERROR: blocked by firewall

c02 のログでは、拒否されたログが確認できます。

$ docker compose logs c02
...
c02  | WARNING 2022/12/09 06:38:56 Received unreachable message from executor02
c02  | WARNING 2022/12/09 06:38:57 Received unreachable message from executor02
c02  | WARNING 2022/12/09 06:38:58 Received unreachable message from executor02
c02  | WARNING 2022/12/09 06:38:59 Received unreachable message from executor02
...

ここまでで、ファイアウォールの動作が確認できました。

不要なワークが残っていたらリリースします(ファイアウォールに阻害されるパタンでは、work submit 後の Ctrl+C でワークがリリースされるときとされないときがありそうです)。

[root@c02 /]# receptorctl work release --all
Released:
(xFD0u0uh, released)

デモ (4): ワークの電子署名

Receptor は、Executor に届いたワークが正当な Controller から送信されたものであることを検証する手段として、ワークへの署名 とその 署名の検証 を行う機能があります。あらかじめ作成した鍵ペアを利用して、Controller 側が秘密鍵で署名 し、Executor 側が公開鍵で検証 する形です。

本デモでは、この機能の構成と動作を確認します。

設定ファイルの作成

Controller 側の設定

前述の通り、署名は Controller 側が行います。例として c01(controller01)の設定ファイルを抜粋します。

...
- work-signing:
    privatekey: /etc/receptor/certs/signworkprivate.pem
    tokenexpiration: 5m

work-signing で署名に利用する 秘密鍵 を指定しています。tokenexpiration は署名の有効期間で、今回は 5 分としています。有効期間を超過すると、Executor 側でワークの検証が失敗します。短くする場合は Controller と Executor の間の時刻のズレに注意が必要です。

Controller 側で必要な設定はこれだけです。

今回は、署名の検証が正しく機能していることを確認するため、c02(controller02)には 別の秘密鍵 を使って署名する設定を追加しています。

...
- work-signing:
    privatekey: /etc/receptor/certs/signworkprivate.c02.pem
    tokenexpiration: 5m

Executor 側の設定

Executor は、署名を検証する側です。署名に利用された秘密鍵とペアになっている 公開鍵 を利用します。e01(executor01)の設定ファイルを抜粋します。

...
- work-verification:
    publickey: /etc/receptor/certs/signworkpublic.pem

- work-command:
    worktype: echo-verified-reply
    command: bash
    params: "-c 'while read -r line; do echo Reply from $(cat /etc/hostname) for verified work: ${line^^}; done'"
    verifysignature: true

work-verification で、検証に利用する公開鍵を指定します。

また、実行前に署名を検証させたいワーク(今回は echo-verified-reply)で、署名の検証(verifysignature)を有効(true)にします。今回は work-command でこれを有効化していますが、前回のエントリ で紹介した work-kubernetes でも同様に指定できます。

まったく同じ設定を、e03(executor03)にも追加しています。

実際の動き

コンテナを起動して、動きを確認します。

docker compose up -d

署名の検証が有効化されたワークの定義(ワークタイプ)は、通常のそれとは区別されて表示されます。

[root@c01 /]# receptorctl status
...
Node         Work Types
executor02   echo-reply
executor01   echo-reply
executor03   echo-reply

Node         Secure Work Types
executor01   echo-verified-reply
executor03   echo-verified-reply

executor01 と 03 の echo-verified-reply が、Secure Work Types として表示されており、署名の検証が有効化されていることがわかります。

c01(controller01)から、このワークタイプを実行します。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  echo-verified-reply
ERROR: Remote error: ERROR: could not parse response: ERROR: could not verify signature: signature is empty

これまで通りのコマンドでは、ワークへの署名は行われません。このため、Executor 側で署名を検証できずエラーになっています。

ワークに署名をするには、work submit に --signwork を追加します。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  --signwork \
  echo-verified-reply
Reply from e01.example.internal for verified work: HELLO RECEPTOR!
(PvvdSwrU, released)

正常に実行できました。これが、署名が正しく検証された状態です。

続けて、c02(controller02)からも同じワークタイプを署名付きで実行します。

[root@c02 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor01 \
  --rm \
  --follow \
  --payload - \
  --signwork \
  echo-verified-reply
ERROR: Remote error: ERROR: could not parse response: ERROR: could not verify signature: crypto/rsa: verification error

controller02 では、設定ファイルで意図的に 別の鍵ペアの秘密鍵 を署名に使うように構成していました。このため、Executor 側ではワークの署名が不正なものとして扱われ、実行が拒否されています。

署名とその検証により、正しい鍵を持っていなければ Executor にワークが送信できなくなることが確認できました。

なお、executor03 では、オーバレイネットワークの TLS 設定と署名検証の両方を有効にしていました。このような構成の Executor にワークを送信するには、work submit 時に --tls-client と --signwork の両方を追加する必要があります。

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor03 \
  --rm \
  --follow \
  --payload - \
  echo-verified-reply
ERROR: Remote unit failed: TLS error connecting to remote service: CRYPTO_ERROR (0x12a): insecure connection to secure service
(GWtzT1HE, released)

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor03 \
  --rm \
  --follow \
  --payload - \
  --tls-client example-tls-client \
  echo-verified-reply
ERROR: Remote error: ERROR: could not parse response: ERROR: could not verify signature: signature is empty

[root@c01 /]# echo "Hello Receptor!" | receptorctl work submit \
  --node executor03 \
  --rm \
  --follow \
  --payload - \
  --tls-client example-tls-client \
  --signwork \
  echo-verified-reply
Reply from e03.example.internal for verified work: HELLO RECEPTOR!
(PA6EyrXs, released)

まとめ

Receptor のセキュリティ関連の機能をいくつか紹介しました。相応に厳重なつくりになっているようですね。

Receptor 関連エントリ

Reactions and comments

Built with Hugo
Theme Stack designed by Jimmy