defmodule Integrations.TlsCert do @moduledoc """ TLS certificate expiry monitor. Connects to a TLS endpoint, retrieves the server certificate, and reports the number of days until expiry. Works with any TLS-speaking protocol — HTTPS, Postgres, LDAPS, SMTP/STARTTLS-capable ports with direct TLS, etc. The check intentionally uses `verify: :verify_none` so it can report on certificates that are already expired or have chain issues, rather than failing with a connection error before inspection. Collection only — see `Integrations.TlsCert.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts. ## Params * `:host` — Hostname or IP address. Required. * `:port` — Port number. Defaults to `443`. * `:warn_days` — Trigger `:degraded` when fewer than this many days remain. Defaults to `30`. * `:critical_days` — Trigger `:down` when fewer than this many days remain (including already-expired). Defaults to `7`. * `:timeout_ms` — Connection timeout in milliseconds. Defaults to `5000`. * `:server_name` — SNI hostname to send. Defaults to `:host`. Set explicitly when `:host` is an IP address. ## Health signal * `:up` — Certificate is valid and expires in > `:warn_days`. * `:degraded` — Certificate expires in ≤ `:warn_days` days. * `:down` — Certificate is expired or expires in ≤ `:critical_days` days, or the connection failed entirely. """ use CodeNameRaven.Monitor @default_port 443 @default_warn_days 30 @default_critical_days 7 @default_timeout_ms 5_000 @impl true def params_template do %{host: "", port: "443", warn_days: "30", critical_days: "7"} end @impl true def params_schema do [ host: [type: :string, required: true, doc: "Hostname or IP address"], port: [type: :non_neg_integer, default: 443, doc: "Port number"], warn_days: [type: :non_neg_integer, default: 30, doc: "Days remaining threshold for degraded"], critical_days: [type: :non_neg_integer, default: 7, doc: "Days remaining threshold for down"], timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "Connection timeout in milliseconds"], server_name: [type: :string, required: false, doc: "SNI hostname override (defaults to host)"] ] end @impl true def target_uri(params) do host = get_param(params, :host) port = parse_int(get_param(params, :port), @default_port) if host, do: {:ok, "tls://#{host}:#{port}"}, else: :none end @impl true def identity_params(params) do %{ host: get_param(params, :host), port: parse_int(get_param(params, :port), @default_port) } end # --------------------------------------------------------------------------- # Collect # --------------------------------------------------------------------------- @impl true def collect(params, state) do host = get_param(params, :host) if is_nil(host) do {:error, "missing required param :host", state} else port = parse_int(get_param(params, :port), @default_port) timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms) server_name = get_param(params, :server_name) || host ssl_opts = [ verify: :verify_none, server_name_indication: to_charlist(server_name) ] case :ssl.connect(to_charlist(host), port, ssl_opts, timeout_ms) do {:ok, socket} -> result = case :ssl.peercert(socket) do {:ok, der_cert} -> :ssl.close(socket) extract_cert_info(der_cert) {:error, reason} -> :ssl.close(socket) {:error, "could not retrieve certificate: #{inspect(reason)}"} end case result do {:ok, cert_info} -> {:ok, cert_info, state} {:error, reason} -> {:error, reason, state} end {:error, reason} -> {:error, format_ssl_error(reason), state} end end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(result) do warn_days = result[:warn_days] || @default_warn_days critical_days = result[:critical_days] || @default_critical_days days = result.days_remaining cond do days <= critical_days -> :down days <= warn_days -> :degraded true -> :up end end # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do not_after_unix = Date.diff(result.not_after, ~D[1970-01-01]) * 86_400 %{ days_remaining: result.days_remaining, not_after_unix: not_after_unix } end # --------------------------------------------------------------------------- # Certificate parsing # --------------------------------------------------------------------------- defp extract_cert_info(der_cert) do cert = :public_key.pkix_decode_cert(der_cert, :otp) tbs = elem(cert, 1) validity = elem(tbs, 5) not_after = decode_time(elem(validity, 2)) subject = tbs |> elem(6) |> decode_name() issuer = tbs |> elem(4) |> decode_name() today = Date.utc_today() days_remaining = Date.diff(not_after, today) {:ok, %{ not_after: not_after, days_remaining: days_remaining, subject: subject, issuer: issuer }} rescue e -> {:error, "certificate decode failed: #{Exception.message(e)}"} end # Handles both UTCTime ({:utcTime, charlist}) and GeneralizedTime ({:generalTime, charlist}) defp decode_time({:utcTime, time_charlist}) do s = to_string(time_charlist) # YYMMDDHHmmssZ <> = s year = if String.to_integer(yy) >= 50, do: "19#{yy}", else: "20#{yy}" decode_time({:generalTime, to_charlist("#{year}#{mm}#{dd}000000Z")}) end defp decode_time({:generalTime, time_charlist}) do s = to_string(time_charlist) # YYYYMMDDHHmmssZ <> = s Date.new!(String.to_integer(year), String.to_integer(month), String.to_integer(day)) end defp decode_name({:rdnSequence, rdns}) do rdns |> List.flatten() |> Enum.map(fn {:AttributeTypeAndValue, oid, value} -> label = oid_label(oid) v = case value do s when is_binary(s) -> s {:utf8String, s} -> to_string(s) {:printableString, s}-> to_string(s) {:ia5String, s} -> to_string(s) other -> inspect(other) end "#{label}=#{v}" end) |> Enum.join(", ") end defp decode_name(_), do: "unknown" defp oid_label({2, 5, 4, 3}), do: "CN" defp oid_label({2, 5, 4, 6}), do: "C" defp oid_label({2, 5, 4, 7}), do: "L" defp oid_label({2, 5, 4, 8}), do: "ST" defp oid_label({2, 5, 4, 10}), do: "O" defp oid_label({2, 5, 4, 11}), do: "OU" defp oid_label(oid), do: inspect(oid) defp format_ssl_error(:econnrefused), do: "connection refused" defp format_ssl_error(:timeout), do: "connection timed out" defp format_ssl_error(:nxdomain), do: "hostname not found" defp format_ssl_error(:ehostunreach), do: "host unreachable" defp format_ssl_error({:tls_alert, _} = e), do: "TLS error: #{inspect(e)}" defp format_ssl_error(reason), do: inspect(reason) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp get_param(params, key) when is_atom(key) do v = params[key] || params[to_string(key)] if is_binary(v) and String.trim(v) == "", do: nil, else: v end defp parse_int(nil, default), do: default defp parse_int(v, _) when is_integer(v), do: v defp parse_int(v, default) when is_binary(v) do case Integer.parse(v) do {n, _} -> n :error -> default end end defp parse_int(_, default), do: default end