はじめに
定期的にKubernetesのCronjobから外部のWeb APIにアクセスしてデータを収集するコンテナを稼動させています。
一応エラーメッセージは通知されるようにPrometheus等を設定しているのですが、外部のAPIサーバーにHTTPSで接続ができず、次のようなメッセージでジョブが異常終了(Abend)していました。
/cronjob/lib/ruby/3.3.0/gems/httpclient-2.8.3/lib/httpclient/ssl_socket.rb:103:in `connect': SSL_connect returned=1 errno=0 peeraddr=xxx.xxx.xxx.xxx:443 state=error: certificate verify failed (unable to get local issuer certificate) (OpenSSL::SSL::SSLError)
HTTPClientは今年更新されているのと、Ruby-3.4がリリースされていることから、コンテナを更新する機会にこれを修正することにしました。
状況
openssl s_client --connect somewhere.com:443のようなコードを実行すると、問題なく接続できています。
表示されるCertificatesをファイルとして保存して、openssl verify certs.pemのようなコードを実行しても問題はありません。
RubyのコードからHTTPClientを利用した場合に問題が発生します。
標準のNet::HTTP + URIライブラリを利用したような場合にはエラーは発生しません。
後述していますが標準のNet::HTTPライブラリを利用した場合には、schemeがhttpsであればシステムのCA局証明書が自動的に読み込まれるため、今回のような問題は発生しません。
原因
普通に考えるとCA局情報が古いか、中間証明局の情報がないのかと思われたのですが、Alpineコンテナにはca-certificatesパッケージを導入していて、自組織のWebページへのアクセスには問題がなかったのでパッケージに由来することは考えにくいと思っていました。
そこで開発環境で、ruby:3.4-alpineベースのコンテナに更新しても以前のDockerfileの構成では動作しなくなっていることが確認できました。
ChatGPTにエラーメッセージといくつかのワークアラウンドを提示するように指示したところ、次のような検証用コードを提示してきました。
require 'httpclient'
require 'openssl'
client = HTTPClient.new
# Alpine のデフォルトパスを拾わせる
client.ssl_config.set_default_paths
# あるいは明示
client.ssl_config.add_trust_ca('/usr/lib/ssl/cert.pem')
# (接続例)
resp = client.get('https://www.mozilla.org/')
puts resp.status
ruby:3.4-alpineコンテナを起動して、このコードを動作させると問題なく動作しました。
ポイントは、明示的にcert.pemファイルを指定しない場合、client.ssl_config.set_default_pathsをコメントアウトすることによってエラーが再現することが分かりました。
ということで元のコードに、URLが"https"で始まっていればこのコードを実行するように自前のライブラリを修正して解決しました。
def initialize(client, url, param, header={})
@httpclient = client
@httpclient.ssl_config.set_default_paths if url =~ /^https:/ ## workaround for TLS certificate verification issue
@queryurl = url
@queryparam = param
@headerparam = header
end
ただ以前は問題なかったように記憶しているのですが、少し前からエラーが記録されAPIサーバーから一部の情報が取得されていませんでした。
状況からは元々問題はあった不具合が、接続先のサーバーで使用されているTLSサーバー証明書がLet's Encryptに変更された等、変更のあったタイミングで発現したのだと思われます。
対策
似たようなコードは他でも利用しているのですが、今のところ問題になっているのは特定のサイトにアクセスするコードだけでした。
このサイトの特徴として、Let's Encryptを利用している点が共通しています。
現時点での解決策はclient.ssl_config.set_default_pathsを追加することだけです。
ssl_default_pathsは何をしているか
コードをみるとX509::Store.newを呼び出して、set_default_pathsを呼び出しています。
def set_default_paths
@cacerts_loaded = true # avoid lazy override
@cert_store = X509::Store.new
@cert_store.set_default_paths
change_notify
end
OpenSSL::X509::Store自体はC言語ライブラリをコールしているだけなので、実際には X509_STORE_set_default_paths を呼び出しているだけです。
これを呼ばないとLet's Encyptが配布しているような証明書は少なくとも確認した範囲では、HTTPClientでアクセスしようとするとエラーになります。
Net::HTTPを利用した場合には、lib/open-uri.rbから自動的に set_default_paths が呼ばれるためエラーになることはありませんでした。
Let's Encrypt を利用しているサイトのCA局証明書に失敗する理由
straceコマンドを使ってログを確認すると、失敗する(明示的にset_default_paths呼ばない)場合、lib/ruby/3.4.0/gems/httpclient-2.9.0/lib/httpclient/cacert.pemを読みこんでいることが分かりました。
明示的にset_default_pathsを呼んだ場合には、/usr/lib/ssl/certs.pemから/etc/ssl/certs/ca-certificates.crtを参照しています。
このライブラリの元データはMozillaが配布する builtins/certdata.txt です。
このhttpclientライブラリが持っているCA局証明書のリストを確認すると、CNの一致するCA局証明書を含んでいました。
しかし問題が発生した証明書の情報とはCNは一致するものの他の内容は現在配布され、/etc/ssl/certs/に格納されている情報とは一致しないようでした。
openssl s_client -connect www.mozilla.org:443 -servername www.mozilla.org -showcerts -CAfile lib/ruby/3.4.0/gems/httpclient-2.9.0/lib/httpclient/cacert.pem < /dev/null
メッセージの後半にエラーが記録されています。
...
---
SSL handshake has read 3117 bytes and written 381 bytes
Verification error: unable to get local issuer certificate
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Server public key is 2048 bit
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 20 (unable to get local issuer certificate
...
問題がないサイトを対象にすると問題なく検証に成功していることが分かります。
原因の追求
この見通しが正しければ、./lib以下のcacert.pemを正しいものに置き換えれば正しく動作するはずです。
サンプルコードを元に失敗するURLを埋め込み、set_default_pathsをコメントアウトしてエラーが返るようにしたfailed.rbを準備しておきます。
#!/usr/bin/env ruby
require "bundler/setup"
Bundler.require
client = HTTPClient.new
puts "access to www.mozilla.org"
resp = client.get('https://www.mozilla.org')
puts resp.status
このコードは$ bundle exec ruby failed.rbのように実行すると接続時にOpenSSLがエラーを送出します。
$ bundle exec ruby failed.rb
access to www.mozilla.org
.../lib/ruby/3.4.0/gems/httpclient-2.9.0/lib/httpclient/ssl_socket.rb:103:in 'OpenSSL::SSL::SSLSocket#connect': SSL_connect returned=1 errno=0 peeraddr=151.101.111.19:443 state=error: certificate verify failed
(unable to get local issuer certificate) (OpenSSL::SSL::SSLError)
...
エラーを確認したので、cacert.pemを変更して問題が再現するか確認します。
$ cp /usr/lib/ssl/cert.pem ./lib/ruby/3.4.0/gems/httpclient-2.9.0/lib/httpclient/cacert.pem
$ bundle exec run failed.rb
access to www.mozilla.org
200
straceの結果から/usr/lib/ssl/cert.pemはset_default_pathsを実行した時に読み込まれるファイルです。
問題なく実行されるようになり、原因が分かりました。
まとめ
中間CA局の情報を/etc/ssl/certs/に配置してupdate-ca-certificatesなどを呼んで適切に処理をしても、HTTPClientでは明示的にset_default_pathsを呼び出す必要がありました。
HTTPClientを利用する時には必ずset_default_pathsを呼び出しましょう。
また元々この不具合はコードに存在していましたが、CA局情報がHTTPClientに含まれているCA局情報と整合している限り、問題が表面化することはありません。
この問題はLet's Encryptだけに限定されるわけではありませんが、中間局を含めてCA局の証明書は通常であれば比較的長い間利用される想定でいるはずです。
今回は通常の期限が到来する前に証明書を変更したことで、想定よりも早く情報を更新しなければいけなかったことが十分に周知できなかったのかもしれません。
多くのユーザーに利用されて影響力が大きいCA局だけに全体の構成を変更するといった大規模な変更の反映にタイムラグが入ったことで、コードの不具合が表面化したものと思われます。