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:
parent
150ffb421d
commit
76b3dd6a93
11 changed files with 490 additions and 64 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
lib/towerops/monitoring/executors/ssl_executor.ex
Normal file
163
lib/towerops/monitoring/executors/ssl_executor.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
150
test/towerops/monitoring/executors/ssl_executor_test.exs
Normal file
150
test/towerops/monitoring/executors/ssl_executor_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue