Hey there, Crystal community!
This is my first time posting something here, and before everything else, I would like to thank all the core team members and members of the community for all the work put into building this amazing language!
When it comes to Crystal, I think people tend to gravitate towards web development, but one area that I think the language doesn’t get enough credit for is tooling: it can produce small, performant, and, with very little effort, statically compiled binaries, which are super portable and easy to distribute. This is, of course, until you include something that depends on OpenSSL. Since each system has its own configuration for it, binaries compiled somewhere else usually won’t work out of the box for programs that depend on it.
This issue is something that was discussed before, and with the pointers given in another discussion (thanks, @straight-shoota!) I was able to assemble a little hack to search for certificates in the current system, which allowed me to build binaries that can perform HTTP requests on multiple systems. This was something very useful at the company where I work, and now that we have been using it successfully for almost a year, I feel confident enough to share it here.
We successfully deployed binaries with this code in a variety of environments: Alpines, Ubuntus, OpenSUSEs, Amazon Linuxes, and some others that I can’t remember, so this should be pretty exhaustive:
require "http/client"
require "openssl"
lib LibSSL
fun X509_get_default_cert_dir : Char*
fun X509_get_default_cert_file : Char*
end
private CERTS_DIRS = %w[
/var/ssl
/usr/share/ssl
/usr/local/ssl
/usr/local/openssl
/usr/local/etc/openssl
/usr/local/share
/usr/lib/ssl
/usr/ssl
/etc/openssl
/etc/pki/ca-trust/extracted/pem
/etc/pki/tls
/etc/ssl
/etc/certs
/opt/etc/ssl
/data/data/com.termux/files/usr/etc/tls
/boot/system/data/ssl
]
private CERT_FILENAMES = %w[
cert.pem
certs.pem
ca-bundle.pem
cacert.pem
ca-certificates.crt
certs/ca-certificates.crt
certs/ca-root-nss.crt
certs/ca-bundle.crt
CARootCertificates.pem
tls-ca-bundle.pem
]
CERTS_LOCATION = begin
cert_file = ENV["SSL_CERT_FILE"]?
certs_dir = ENV["SSL_CERT_DIR"]?
CERTS_DIRS.each do |dir|
cert_file ||= CERT_FILENAMES
.map { |file_name| Path[dir, file_name] }
.find { |path| File.exists?(path) }
if certs_dir.nil?
possible_certs_dir = Path[dir, "certs"]
certs_dir = possible_certs_dir if File.exists?(possible_certs_dir)
end
break if cert_file && certs_dir
end
{
ca_bundle_pem: cert_file.to_s || String.new(LibSSL.X509_get_default_cert_dir),
certs_dir: certs_dir.to_s || String.new(LibSSL.X509_get_default_cert_file),
}
end
class HTTP::Client
# Creates a new HTTP client with the given *host*, *port* and *tls*
# configurations. If no port is given, the default one will
# be used depending on the *tls* arguments: 80 for if *tls* is `false`,
# 443 if *tls* is truthy. If *tls* is `true` a new `OpenSSL::SSL::Context::Client` will
# be used, else the given one. In any case the active context can be accessed through `tls`.
def initialize(@host : String, port = nil, tls : TLSContext = nil)
check_host_only(@host)
{% if flag?(:without_openssl) %}
if tls
raise "HTTP::Client TLS is disabled because `-D without_openssl` was passed at compile time"
end
@tls = nil
{% else %}
@tls = case tls
when true
context = OpenSSL::SSL::Context::Client.new
context.ca_certificates = CERTS_LOCATION[:ca_bundle_pem]
context.ca_certificates_path = CERTS_LOCATION[:certs_dir]
context
when OpenSSL::SSL::Context::Client
tls
when false, nil
nil
end
{% end %}
@port = (port || (@tls ? 443 : 80)).to_i
end
end
Now, this clearly involves reopening methods from the stdlib, which isn’t the best approach here. With that said, I would like to ask: would it make sense to include this functionality, maybe behind a compiler flag, in the stdlib itself? And if it doesn’t, is there a better way to write it so it can seamlessly be shared through a shard?