defmodule Integrations.Xmpp do @moduledoc """ XMPP server monitor. Opens a TCP connection to an XMPP server and sends an XML stream opening. A healthy server responds with its own `` header and advertises its supported features. The check confirms the server is alive and speaking the XMPP protocol without performing authentication. Uses raw `:gen_tcp` — no external dependencies required. Collection only — see `Integrations.Xmpp.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts. ## Params * `:host` — XMPP server hostname or IP. Required. * `:port` — Port number. Defaults to `5222` (client-to-server). Use `5269` for server-to-server federation checks. * `:domain` — XMPP domain to use in the stream `to` attribute. Defaults to `:host`. Set this when the domain differs from the server address (virtual hosting). * `:timeout_ms` — TCP connect and receive timeout in milliseconds. Defaults to `5000`. * `:latency_degraded_ms` — Time-to-first-byte threshold for `:degraded`. Defaults to `1000`. ## Health signal * `:up` — Server responded with a valid XMPP stream header and latency is within threshold. * `:degraded` — Server responded but latency exceeded threshold, or the response was not a well-formed stream header. * `:down` — Connection refused, timeout, or no response. """ use CodeNameRaven.Monitor @default_port 5222 @default_timeout_ms 5_000 @default_lat_degraded_ms 1_000 @stream_ns "jabber:client" @impl true def params_template do %{host: "", port: "5222", domain: "", timeout_ms: "5000"} end @impl true def params_schema do [ host: [type: :string, required: true, doc: "XMPP server hostname or IP"], port: [type: :non_neg_integer, default: 5222, doc: "Port (5222 client, 5269 server federation)"], domain: [type: :string, required: false, doc: "XMPP domain for stream header (defaults to host)"], timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "TCP connect and receive timeout in milliseconds"], latency_degraded_ms: [type: :non_neg_integer, default: 1_000, doc: "TTFB threshold for degraded"] ] 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, "xmpp://#{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) domain = get_param(params, :domain) || host tcp_opts = [:binary, packet: :raw, active: false, send_timeout: timeout_ms] started_at = System.monotonic_time(:millisecond) case :gen_tcp.connect(to_charlist(host), port, tcp_opts, timeout_ms) do {:ok, socket} -> result = do_xmpp_check(socket, domain, timeout_ms, started_at) :gen_tcp.close(socket) case result do {:ok, data} -> {:ok, data, state} {:error, reason} -> {:error, reason, state} end {:error, :econnrefused} -> {:error, "connection refused on port #{port}", state} {:error, :timeout} -> {:error, "connection timed out", state} {:error, :nxdomain} -> {:error, "hostname not found: #{host}", state} {:error, reason} -> {:error, inspect(reason), state} end end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(result) do lat_deg = result[:latency_degraded_ms] || @default_lat_degraded_ms cond do not result.stream_ok -> :degraded result.latency_ms >= lat_deg -> :degraded true -> :up end end # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do %{latency_ms: result.latency_ms} end # --------------------------------------------------------------------------- # XMPP protocol # --------------------------------------------------------------------------- defp do_xmpp_check(socket, domain, timeout_ms, started_at) do stream_open = "" :gen_tcp.send(socket, stream_open) case recv_until_stream_header(socket, timeout_ms, "") do {:ok, data} -> ttfb_ms = System.monotonic_time(:millisecond) - started_at stream_ok = String.contains?(data, " {:error, reason} end end # Read until we have seen the stream:stream opening tag close # (it's not a complete XML doc, so we just collect until we see '>') defp recv_until_stream_header(socket, timeout_ms, acc) do case :gen_tcp.recv(socket, 0, timeout_ms) do {:ok, data} -> acc = acc <> data # Stream header is complete once we see the closing '>' of the opening tag # We also accept partial data that has the stream:stream element if String.contains?(acc, "") do {:ok, acc} else recv_until_stream_header(socket, timeout_ms, acc) end {:error, :timeout} -> {:error, "timed out waiting for stream header"} {:error, reason} -> {:error, inspect(reason)} end end defp extract_attribute(xml, attr) do case Regex.run(~r/\b#{attr}="([^"]*)"/, xml) do [_, value] -> value _ -> nil end end defp extract_features(xml) do # Extract feature names from ... case Regex.run(~r|(.*?)|s, xml) do [_, features_xml] -> Regex.scan(~r/<(\w+[:\w]*)[^>]*\/>|<(\w+[:\w]*)[^>]*>/, features_xml) |> Enum.flat_map(fn [_, feat, ""] -> [feat] [_, "", feat] -> [feat] _ -> [] end) |> Enum.reject(&(&1 in ["stream:features", ""])) |> Enum.uniq() _ -> [] end end # --------------------------------------------------------------------------- # 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