towerops/lib/towerops/monitoring/executors/ssl_executor.ex

181 lines
5.6 KiB
Elixir

defmodule Towerops.Monitoring.Executors.SslExecutor do
@moduledoc """
Executes SSL/TLS certificate expiration checks.
Connects to a host via TLS, reads the peer certificate, and checks
how many days until it expires. Returns OK/WARNING/CRITICAL based on
the configured warning_days threshold.
Config format:
%{
"host" => "example.com",
"port" => 443,
"warning_days" => 30
}
"""
require Logger
@default_timeout 10_000
@default_port 443
@default_warning_days 30
@doc """
Executes an SSL certificate expiration check.
Returns:
- {:ok, status, response_time_ms, output} on successful connection
- status 0 (OK): cert expires in > warning_days
- status 1 (WARNING): cert expires within warning_days
- status 2 (CRITICAL): cert already expired
- {:error, reason} on connection failure
"""
def execute(config, timeout_ms \\ @default_timeout) do
host = Map.fetch!(config, "host")
port = config["port"] || @default_port
warning_days = config["warning_days"] || @default_warning_days
timeout = timeout_ms || @default_timeout
host_charlist = String.to_charlist(host)
start_time = System.monotonic_time(:millisecond)
ssl_opts = [
verify: :verify_none,
depth: 3,
server_name_indication: host_charlist
]
case :ssl.connect(host_charlist, port, ssl_opts, timeout) do
{:ok, ssl_socket} ->
result =
try do
response_time = System.monotonic_time(:millisecond) - start_time
check_certificate(ssl_socket, host, port, warning_days, response_time)
after
:ssl.close(ssl_socket)
end
result
{:error, :timeout} ->
{:error, "Connection timeout after #{timeout}ms"}
{:error, {:tls_alert, {reason, _}}} ->
{:error, "TLS error: #{reason}"}
{:error, reason} ->
{:error, "Connection failed: #{inspect(reason)}"}
end
rescue
e ->
Logger.error("SSL check exception: #{inspect(e)}")
{:error, "Exception: #{Exception.message(e)}"}
end
defp check_certificate(ssl_socket, host, port, warning_days, response_time) do
case :ssl.peercert(ssl_socket) do
{:ok, der_cert} ->
otp_cert = :public_key.pkix_decode_cert(der_cert, :otp)
case extract_not_after(otp_cert) do
{:error, reason} ->
{:error, "Failed to parse certificate expiry date: #{inspect(reason)}"}
not_after ->
days_remaining = days_until(not_after)
{status, message} = evaluate_expiry(days_remaining, warning_days, host, port, not_after)
{:ok, status, response_time, message}
end
{:error, reason} ->
{:error, "Failed to get peer certificate: #{inspect(reason)}"}
end
end
defp extract_not_after(otp_cert) do
# Navigate OTP certificate structure:
# {:OTPCertificate, tbs_certificate, ...}
# tbs_certificate: {:OTPTBSCertificate, :v3, serial, sig_alg, issuer, validity, ...}
# validity at index 5: {:Validity, not_before, not_after}
tbs = elem(otp_cert, 1)
validity = elem(tbs, 5)
not_after = elem(validity, 2)
parse_asn1_time(not_after)
end
defp parse_asn1_time({:utcTime, time_charlist}) do
time_str = List.to_string(time_charlist)
# Format: YYMMDDHHMMSSZ - validate length before pattern matching
if byte_size(time_str) < 12 do
Logger.warning("Invalid UTCTime format: too short (#{byte_size(time_str)} bytes): #{time_str}")
{:error, :invalid_utc_time_format}
else
<<yy::binary-size(2), mm::binary-size(2), dd::binary-size(2), hh::binary-size(2), min::binary-size(2),
ss::binary-size(2), _rest::binary>> = time_str
year = String.to_integer(yy)
# UTCTime: 00-49 → 2000-2049, 50-99 → 1950-1999
year = if year < 50, do: 2000 + year, else: 1900 + year
{:ok, datetime} =
NaiveDateTime.new(
year,
String.to_integer(mm),
String.to_integer(dd),
String.to_integer(hh),
String.to_integer(min),
String.to_integer(ss)
)
DateTime.from_naive!(datetime, "Etc/UTC")
end
end
defp parse_asn1_time({:generalTime, time_charlist}) do
time_str = List.to_string(time_charlist)
# Format: YYYYMMDDHHMMSSZ - validate length before pattern matching
if byte_size(time_str) < 14 do
Logger.warning("Invalid GeneralTime format: too short (#{byte_size(time_str)} bytes): #{time_str}")
{:error, :invalid_general_time_format}
else
<<yyyy::binary-size(4), mm::binary-size(2), dd::binary-size(2), hh::binary-size(2), min::binary-size(2),
ss::binary-size(2), _rest::binary>> = time_str
{:ok, datetime} =
NaiveDateTime.new(
String.to_integer(yyyy),
String.to_integer(mm),
String.to_integer(dd),
String.to_integer(hh),
String.to_integer(min),
String.to_integer(ss)
)
DateTime.from_naive!(datetime, "Etc/UTC")
end
end
defp days_until(expiry_datetime) do
now = DateTime.utc_now()
DateTime.diff(expiry_datetime, now, :day)
end
defp evaluate_expiry(days_remaining, warning_days, host, port, not_after) do
expires_str = Calendar.strftime(not_after, "%Y-%m-%d")
cond do
days_remaining < 0 ->
{2, "CRITICAL: Certificate for #{host}:#{port} expired #{abs(days_remaining)} days ago (#{expires_str})"}
days_remaining <= warning_days ->
{1, "WARNING: Certificate for #{host}:#{port} expires in #{days_remaining} days (#{expires_str})"}
true ->
{0, "OK: Certificate for #{host}:#{port} valid for #{days_remaining} days (#{expires_str})"}
end
end
end