Current section
Files
Jump to
Current section
Files
lib/integrations/tls_cert.ex
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
<<yy::binary-2, mm::binary-2, dd::binary-2, _rest::binary>> = 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
<<year::binary-4, month::binary-2, day::binary-2, _rest::binary>> = 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