diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex index 1c808184..e59719b6 100644 --- a/lib/towerops/monitoring.ex +++ b/lib/towerops/monitoring.ex @@ -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 diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index c946c224..e451cc3c 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -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 diff --git a/lib/towerops/monitoring/executors/ssl_executor.ex b/lib/towerops/monitoring/executors/ssl_executor.ex new file mode 100644 index 00000000..4087ebae --- /dev/null +++ b/lib/towerops/monitoring/executors/ssl_executor.ex @@ -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 + <> = 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 + <> = 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 diff --git a/lib/towerops/proto/agent.pb.ex b/lib/towerops/proto/agent.pb.ex index 5c8cafd6..6dbcb11a 100644 --- a/lib/towerops/proto/agent.pb.ex +++ b/lib/towerops/proto/agent.pb.ex @@ -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 diff --git a/lib/towerops/workers/check_executor_worker.ex b/lib/towerops/workers/check_executor_worker.ex index 0cfcb547..8aa9c30a 100644 --- a/lib/towerops/workers/check_executor_worker.ex +++ b/lib/towerops/workers/check_executor_worker.ex @@ -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 diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 82b36462..cdc7edb5 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -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()] diff --git a/lib/towerops_web/live/check_live/form_component.ex b/lib/towerops_web/live/check_live/form_component.ex index 7fb617a9..32655caf 100644 --- a/lib/towerops_web/live/check_live/form_component.ex +++ b/lib/towerops_web/live/check_live/form_component.ex @@ -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)} <% _ -> %>
Select a check type to configure @@ -249,6 +252,33 @@ defmodule ToweropsWeb.CheckLive.FormComponent do """ end + defp render_ssl_fields(assigns) do + ~H""" +
+
+
+ <.input field={@form[:host]} type="text" label="Host" placeholder="example.com" /> +
+
+ <.input field={@form[:port]} type="number" label="Port" placeholder="443" /> +
+
+ +
+ <.input + field={@form[:warning_days]} + type="number" + label="Warning Days" + placeholder="30" + /> +

+ Alert when certificate expires within this many days +

+
+
+ """ + 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) diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index e3c87762..6eca708f 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -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" diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index c495e2d3..3d0f9718 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -2731,7 +2731,7 @@ > {t("Graph")} - <%= if check.check_type in ["http", "tcp", "dns"] do %> + <%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %>