181 lines
5.6 KiB
Elixir
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
|