add ssl/tls certificate expiration check type (#64)

add ssl check executor that connects via TLS, reads peer certificate
expiry, and returns OK/WARNING/CRITICAL based on configurable
warning_days threshold. includes protobuf config, liveview form fields,
agent channel encoding, oban worker dispatch, and unit tests.

Reviewed-on: graham/towerops-web#64
This commit is contained in:
Graham McIntire 2026-03-17 16:14:22 -05:00 committed by graham
parent 150ffb421d
commit 76b3dd6a93
11 changed files with 490 additions and 64 deletions

View file

@ -57,7 +57,7 @@ defmodule Towerops.Monitoring do
query =
from c in Check,
where: c.agent_token_id == ^agent_token_id,
where: c.check_type in ["http", "tcp", "dns"],
where: c.check_type in ["http", "tcp", "dns", "ssl"],
order_by: [asc: c.name]
query

View file

@ -23,7 +23,7 @@ defmodule Towerops.Monitoring.Check do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@check_types ~w(http tcp dns passive ping snmp_sensor snmp_interface snmp_processor snmp_storage)
@check_types ~w(http tcp dns ssl passive ping snmp_sensor snmp_interface snmp_processor snmp_storage)
schema "checks" do
field :name, :string
@ -119,6 +119,7 @@ defmodule Towerops.Monitoring.Check do
"http" -> validate_http_config(changeset, config)
"tcp" -> validate_tcp_config(changeset, config)
"dns" -> validate_dns_config(changeset, config)
"ssl" -> validate_ssl_config(changeset, config)
"ping" -> validate_ping_config(changeset, config)
_ -> changeset
end
@ -153,6 +154,14 @@ defmodule Towerops.Monitoring.Check do
end
end
defp validate_ssl_config(changeset, config) do
if Map.has_key?(config, "host") do
changeset
else
add_error(changeset, :config, "SSL check requires 'host' in config")
end
end
defp validate_ping_config(changeset, config) do
if Map.has_key?(config, "host") do
changeset

View file

@ -0,0 +1,163 @@
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)
not_after = extract_not_after(otp_cert)
days_remaining = days_until(not_after)
{status, message} = evaluate_expiry(days_remaining, warning_days, host, port, not_after)
{:ok, status, response_time, message}
{: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
<<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
defp parse_asn1_time({:generalTime, time_charlist}) do
time_str = List.to_string(time_charlist)
# Format: YYYYMMDDHHMMSSZ
<<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
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

View file

@ -246,6 +246,7 @@ defmodule Towerops.Agent.Check do
field :http, 5, type: Towerops.Agent.HttpCheckConfig, oneof: 0
field :tcp, 6, type: Towerops.Agent.TcpCheckConfig, oneof: 0
field :dns, 7, type: Towerops.Agent.DnsCheckConfig, oneof: 0
field :ssl, 8, type: Towerops.Agent.SslCheckConfig, oneof: 0
end
defmodule Towerops.Agent.HttpCheckConfig.HeadersEntry do
@ -307,6 +308,19 @@ defmodule Towerops.Agent.DnsCheckConfig do
field :expected, 4, type: :string
end
defmodule Towerops.Agent.SslCheckConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SslCheckConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :host, 1, type: :string
field :port, 2, type: :uint32
field :warning_days, 3, type: :uint32, json_name: "warningDays"
end
defmodule Towerops.Agent.CheckResult do
@moduledoc false

View file

@ -33,6 +33,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
alias Towerops.Monitoring.Executors.SnmpProcessorExecutor
alias Towerops.Monitoring.Executors.SnmpSensorExecutor
alias Towerops.Monitoring.Executors.SnmpStorageExecutor
alias Towerops.Monitoring.Executors.SslExecutor
alias Towerops.Monitoring.Executors.TcpExecutor
alias Towerops.Snmp.Client
alias Towerops.Workers.PollingOffset
@ -40,7 +41,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
require Logger
@snmp_check_types ~w(snmp_sensor snmp_interface snmp_processor snmp_storage)
@service_check_types ~w(http tcp dns)
@service_check_types ~w(http tcp dns ssl)
@impl Oban.Worker
def perform(%Oban.Job{args: %{"check_id" => check_id}}) do
@ -99,6 +100,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
defp dispatch_executor(%{check_type: "http"} = check), do: execute_http_check(check)
defp dispatch_executor(%{check_type: "tcp"} = check), do: execute_tcp_check(check)
defp dispatch_executor(%{check_type: "dns"} = check), do: execute_dns_check(check)
defp dispatch_executor(%{check_type: "ssl"} = check), do: execute_ssl_check(check)
defp dispatch_executor(%{check_type: "ping"} = check), do: execute_ping_check(check)
defp dispatch_executor(%{check_type: type}), do: {:error, "Unknown check type: #{type}"}
@ -184,6 +186,23 @@ defmodule Towerops.Workers.CheckExecutorWorker do
end
end
# Adapter for SSL executor - returns status directly (OK/WARNING/CRITICAL)
defp execute_ssl_check(check) do
case SslExecutor.execute(check.config, check.timeout_ms) do
{:ok, status, response_time, output} ->
{:ok,
%{
value: nil,
status: status,
output: output,
response_time_ms: response_time
}}
{:error, reason} ->
{:error, reason}
end
end
# Adapter for Ping executor which returns different format
defp execute_ping_check(check) do
case PingExecutor.execute(check.config, check.timeout_ms) do

View file

@ -34,6 +34,7 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SslCheckConfig
alias Towerops.Agent.TcpCheckConfig
alias Towerops.Agent.Validator
alias Towerops.Agents
@ -939,6 +940,16 @@ defmodule ToweropsWeb.AgentChannel do
]
end
defp check_type_config("ssl", config) do
[
ssl: %SslCheckConfig{
host: config["host"] || "",
port: config["port"] || 443,
warning_days: config["warning_days"] || 30
}
]
end
defp check_type_config(_, _config), do: []
@spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()]

View file

@ -49,7 +49,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
options={[
{"HTTP/HTTPS Check", "http"},
{"TCP Port Check", "tcp"},
{"DNS Check", "dns"}
{"DNS Check", "dns"},
{"SSL Certificate Check", "ssl"}
]}
phx-change="change_type"
phx-target={@myself}
@ -75,6 +76,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
{render_tcp_fields(assigns)}
<% "dns" -> %>
{render_dns_fields(assigns)}
<% "ssl" -> %>
{render_ssl_fields(assigns)}
<% _ -> %>
<div class="text-sm text-gray-500 dark:text-gray-400">
Select a check type to configure
@ -249,6 +252,33 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
"""
end
defp render_ssl_fields(assigns) do
~H"""
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<.input field={@form[:host]} type="text" label="Host" placeholder="example.com" />
</div>
<div>
<.input field={@form[:port]} type="number" label="Port" placeholder="443" />
</div>
</div>
<div>
<.input
field={@form[:warning_days]}
type="number"
label="Warning Days"
placeholder="30"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Alert when certificate expires within this many days
</p>
</div>
</div>
"""
end
@impl true
def mount(socket) do
{:ok, socket}
@ -297,9 +327,9 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|> assign(:selected_type, type)
|> assign_default_config(type)
# Auto-fill TCP host from device IP
# Auto-fill host from device IP for TCP and SSL checks
socket =
if type == "tcp" && socket.assigns[:device] do
if type in ["tcp", "ssl"] && socket.assigns[:device] do
device = socket.assigns.device
if device.ip_address && device.ip_address != "" do
@ -417,76 +447,97 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
}
end
defp config_to_form_fields(config, "ssl") do
%{
"host" => config["host"] || "",
"port" => to_string(config["port"] || 443),
"warning_days" => to_string(config["warning_days"] || 30)
}
end
defp config_to_form_fields(_config, _type), do: %{}
defp assign_default_config(socket, type) do
config =
case type do
"http" ->
%{
"url" => "",
"method" => "GET",
"expected_status" => "200",
"verify_ssl" => "true",
"follow_redirects" => "true"
}
assign(socket, :default_config, default_config_for_type(socket, type))
end
"tcp" ->
device = socket.assigns[:device]
host = if device && device.ip_address, do: to_string(device.ip_address), else: ""
%{"host" => host, "port" => ""}
defp default_config_for_type(_socket, "http") do
%{
"url" => "",
"method" => "GET",
"expected_status" => "200",
"verify_ssl" => "true",
"follow_redirects" => "true"
}
end
"dns" ->
%{"hostname" => "", "record_type" => "A"}
defp default_config_for_type(socket, "tcp") do
%{"host" => device_host(socket), "port" => ""}
end
_ ->
%{}
end
defp default_config_for_type(_socket, "dns") do
%{"hostname" => "", "record_type" => "A"}
end
assign(socket, :default_config, config)
defp default_config_for_type(socket, "ssl") do
%{"host" => device_host(socket), "port" => "443", "warning_days" => "30"}
end
defp default_config_for_type(_socket, _type), do: %{}
defp device_host(socket) do
device = socket.assigns[:device]
if device && device.ip_address, do: to_string(device.ip_address), else: ""
end
defp merge_config_params(check_params, type) do
config =
case type do
"http" ->
maybe_add(
%{
"url" => check_params["url"],
"method" => check_params["method"] || "GET",
"expected_status" => String.to_integer(check_params["expected_status"] || "200"),
"verify_ssl" => check_params["verify_ssl"] == "true",
"follow_redirects" => check_params["follow_redirects"] == "true"
},
"regex",
check_params["content_match"]
)
"tcp" ->
%{
"host" => check_params["host"],
"port" => String.to_integer(check_params["port"] || "0")
}
|> maybe_add("send", check_params["send_string"])
|> maybe_add("expect", check_params["expect_string"])
"dns" ->
%{
"hostname" => check_params["hostname"],
"record_type" => check_params["record_type"] || "A"
}
|> maybe_add("server", check_params["dns_server"])
|> maybe_add("expected", check_params["expected_result"])
_ ->
%{}
end
check_params
|> Map.put("config", config)
|> Map.put("config", config_from_params(check_params, type))
|> Map.put("check_type", type)
end
defp config_from_params(check_params, "http") do
maybe_add(
%{
"url" => check_params["url"],
"method" => check_params["method"] || "GET",
"expected_status" => String.to_integer(check_params["expected_status"] || "200"),
"verify_ssl" => check_params["verify_ssl"] == "true",
"follow_redirects" => check_params["follow_redirects"] == "true"
},
"regex",
check_params["content_match"]
)
end
defp config_from_params(check_params, "tcp") do
%{
"host" => check_params["host"],
"port" => String.to_integer(check_params["port"] || "0")
}
|> maybe_add("send", check_params["send_string"])
|> maybe_add("expect", check_params["expect_string"])
end
defp config_from_params(check_params, "dns") do
%{
"hostname" => check_params["hostname"],
"record_type" => check_params["record_type"] || "A"
}
|> maybe_add("server", check_params["dns_server"])
|> maybe_add("expected", check_params["expected_result"])
end
defp config_from_params(check_params, "ssl") do
%{
"host" => check_params["host"],
"port" => String.to_integer(check_params["port"] || "443"),
"warning_days" => String.to_integer(check_params["warning_days"] || "30")
}
end
defp config_from_params(_check_params, _type), do: %{}
defp maybe_add(map, _key, nil), do: map
defp maybe_add(map, _key, ""), do: map
defp maybe_add(map, key, value), do: Map.put(map, key, value)

View file

@ -52,6 +52,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
"http" => :http_checks,
"tcp" => :tcp_checks,
"dns" => :dns_checks,
"ssl" => :ssl_checks,
"ping" => :ping_checks
}
@ -1735,6 +1736,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp group_title(:http_checks), do: "HTTP Checks"
defp group_title(:tcp_checks), do: "TCP Checks"
defp group_title(:dns_checks), do: "DNS Checks"
defp group_title(:ssl_checks), do: "SSL Certificate Checks"
defp group_title(:ping_checks), do: "Ping Checks"
defp group_title(:other_checks), do: "Other Checks"

View file

@ -2731,7 +2731,7 @@
>
{t("Graph")}
</.link>
<%= if check.check_type in ["http", "tcp", "dns"] do %>
<%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %>
<button
type="button"
phx-click="edit_check"

View file

@ -105,7 +105,7 @@ message MonitoringCheck {
message Check {
string id = 1;
string check_type = 2; // "http", "tcp", "dns"
string check_type = 2; // "http", "tcp", "dns", "ssl"
uint32 interval_seconds = 3;
uint32 timeout_ms = 4;
@ -114,6 +114,7 @@ message Check {
HttpCheckConfig http = 5;
TcpCheckConfig tcp = 6;
DnsCheckConfig dns = 7;
SslCheckConfig ssl = 8;
}
}
@ -142,6 +143,12 @@ message DnsCheckConfig {
string expected = 4; // Expected result (optional)
}
message SslCheckConfig {
string host = 1;
uint32 port = 2; // default 443
uint32 warning_days = 3; // alert if expires within this many days
}
message CheckResult {
string check_id = 1;
uint32 status = 2; // 0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN

View file

@ -0,0 +1,150 @@
defmodule Towerops.Monitoring.Executors.SslExecutorTest do
use ExUnit.Case, async: true
alias Towerops.Monitoring.Executors.SslExecutor
describe "execute/2" do
@tag :integration
test "returns OK for a valid certificate with many days remaining" do
config = %{"host" => "www.google.com", "port" => 443, "warning_days" => 7}
assert {:ok, 0, response_time, output} = SslExecutor.execute(config, 10_000)
assert response_time > 0
assert output =~ "OK: Certificate for www.google.com:443"
assert output =~ "valid for"
end
test "returns error for connection failure to non-routable address" do
config = %{"host" => "192.0.2.1", "port" => 443, "warning_days" => 30}
assert {:error, reason} = SslExecutor.execute(config, 1_000)
assert is_binary(reason)
end
test "returns error for connection refused on closed port" do
# Bind and immediately close to get a port that refuses
{:ok, listen} = :gen_tcp.listen(0, [])
{:ok, port} = :inet.port(listen)
:gen_tcp.close(listen)
config = %{"host" => "127.0.0.1", "port" => port, "warning_days" => 30}
assert {:error, reason} = SslExecutor.execute(config, 2_000)
assert is_binary(reason)
end
test "returns error for missing host key" do
config = %{"port" => 443, "warning_days" => 30}
assert {:error, "Exception:" <> _} = SslExecutor.execute(config, 5_000)
end
test "defaults port to 443 when not specified" do
# We can't easily test the default port without network access,
# but verify the function doesn't crash
config = %{"host" => "192.0.2.1", "warning_days" => 30}
assert {:error, _reason} = SslExecutor.execute(config, 500)
end
test "defaults warning_days to 30 when not specified" do
config = %{"host" => "192.0.2.1", "port" => 443}
assert {:error, _reason} = SslExecutor.execute(config, 500)
end
end
describe "execute/2 with local TLS server" do
setup do
# Use :x509 to generate a cert that expires in 10 days
# Since :x509 is not available, we'll use Erlang's :public_key
# with a simpler approach using :ssl.listen with auto-generated certs
certfile = Path.join(System.tmp_dir!(), "towerops_test_cert_#{System.unique_integer([:positive])}.pem")
keyfile = Path.join(System.tmp_dir!(), "towerops_test_key_#{System.unique_integer([:positive])}.pem")
# Generate self-signed cert using openssl command
{_, 0} =
System.cmd(
"openssl",
[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
keyfile,
"-out",
certfile,
"-days",
"10",
"-nodes",
"-subj",
"/CN=localhost"
],
stderr_to_stdout: true
)
{:ok, listen_socket} =
:ssl.listen(0,
certfile: String.to_charlist(certfile),
keyfile: String.to_charlist(keyfile),
reuseaddr: true
)
{:ok, {_addr, port}} = :ssl.sockname(listen_socket)
# Accept connections in background
pid =
spawn_link(fn ->
accept_loop(listen_socket)
end)
on_exit(fn ->
Process.exit(pid, :kill)
:ssl.close(listen_socket)
File.rm(certfile)
File.rm(keyfile)
end)
%{port: port}
end
test "returns WARNING when certificate expires within warning_days", %{port: port} do
# Certificate is valid for 10 days, warning_days=30 → WARNING
config = %{"host" => "127.0.0.1", "port" => port, "warning_days" => 30}
assert {:ok, status, _response_time, output} = SslExecutor.execute(config, 5_000)
assert status == 1
assert output =~ "WARNING"
assert output =~ "expires in"
end
test "returns OK when certificate has plenty of time", %{port: port} do
# Certificate is valid for 10 days, warning_days=5 → OK
config = %{"host" => "127.0.0.1", "port" => port, "warning_days" => 5}
assert {:ok, status, _response_time, output} = SslExecutor.execute(config, 5_000)
assert status == 0
assert output =~ "OK"
assert output =~ "valid for"
end
test "measures positive response time", %{port: port} do
config = %{"host" => "127.0.0.1", "port" => port, "warning_days" => 30}
assert {:ok, _status, response_time, _output} = SslExecutor.execute(config, 5_000)
assert response_time >= 0
end
end
defp accept_loop(listen_socket) do
case :ssl.transport_accept(listen_socket, 5_000) do
{:ok, socket} ->
_ = :ssl.handshake(socket)
accept_loop(listen_socket)
{:error, _} ->
:ok
end
end
end