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 <> = 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 <> = 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