diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 2fe32ab0..7e1b939b 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -253,6 +253,24 @@ defmodule Towerops.Alerts do end end + @doc """ + Resolves an alert without notifying PagerDuty (used when PagerDuty is the source). + """ + def resolve_alert_silent(alert) do + alert + |> Alert.changeset(%{resolved_at: DateTime.truncate(DateTime.utc_now(), :second)}) + |> Repo.update() + end + + @doc """ + Acknowledges an alert without notifying PagerDuty (used when PagerDuty is the source). + """ + def acknowledge_alert_silent(alert) do + alert + |> Alert.changeset(%{acknowledged_at: DateTime.truncate(DateTime.utc_now(), :second)}) + |> Repo.update() + end + @doc """ Stores Gaiia impact analysis data on an alert record. """ diff --git a/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex new file mode 100644 index 00000000..d5883abb --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/pagerduty_webhook_controller.ex @@ -0,0 +1,155 @@ +defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do + @moduledoc """ + Handles inbound PagerDuty V3 webhook events. + + When an incident is resolved or acknowledged in PagerDuty, this controller + updates the corresponding TowerOps alert so we don't re-alert on it. + + PagerDuty signs webhooks with HMAC-SHA256 using a per-integration secret. + The signature is in the `X-PagerDuty-Signature` header as `v1=`. + """ + use ToweropsWeb, :controller + + alias Towerops.Alerts + alias Towerops.Integrations + + require Logger + + def create(conn, %{"organization_id" => organization_id} = params) do + with {:ok, raw_body} <- get_raw_body(conn), + {:ok, integration} <- get_pagerduty_integration(organization_id), + :ok <- verify_signature(conn, raw_body, integration) do + # params already parsed by Phoenix JSON parser + process_webhook(params, organization_id) + json(conn, %{status: "accepted"}) + else + {:error, :not_configured} -> + conn |> put_status(404) |> json(%{error: "Integration not found"}) + + {:error, :invalid_signature} -> + conn |> put_status(401) |> json(%{error: "Invalid signature"}) + + {:error, _reason} -> + conn |> put_status(500) |> json(%{error: "Internal error"}) + end + end + + defp get_raw_body(conn) do + case conn.private[:raw_body] do + body when is_binary(body) and body != "" -> {:ok, body} + _ -> {:error, :no_body} + end + end + + defp get_pagerduty_integration(organization_id) do + case Integrations.get_integration(organization_id, "pagerduty") do + {:ok, %{enabled: true} = integration} -> {:ok, integration} + _ -> {:error, :not_configured} + end + end + + defp verify_signature(conn, raw_body, integration) do + webhook_secret = get_in(integration.credentials, ["webhook_secret"]) + + if is_nil(webhook_secret) or webhook_secret == "" do + # No webhook secret configured — accept without verification + # (user hasn't set up the V3 webhook extension yet) + :ok + else + signatures = + conn + |> Plug.Conn.get_req_header("x-pagerduty-signature") + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.map(&String.trim/1) + + expected = + :hmac + |> :crypto.mac(:sha256, webhook_secret, raw_body) + |> Base.encode16(case: :lower) + + if Enum.any?(signatures, fn sig -> + case sig do + "v1=" <> hex -> Plug.Crypto.secure_compare(hex, expected) + _ -> false + end + end) do + :ok + else + Logger.warning("PagerDuty webhook signature mismatch for org #{integration.organization_id}") + + {:error, :invalid_signature} + end + end + end + + defp process_webhook(%{"event" => %{"event_type" => event_type, "data" => data}}, organization_id) do + case event_type do + "incident.resolved" -> + resolve_from_pagerduty(data, organization_id) + + "incident.acknowledged" -> + acknowledge_from_pagerduty(data, organization_id) + + other -> + Logger.debug("Ignoring PagerDuty webhook event: #{other}") + :ok + end + end + + defp process_webhook(_payload, _organization_id), do: :ok + + defp resolve_from_pagerduty(data, _organization_id) do + case extract_alert_id(data) do + nil -> + Logger.debug("PagerDuty resolve: no TowerOps alert ID found in incident") + :ok + + alert_id -> + case Alerts.get_alert(alert_id) do + nil -> + Logger.warning("PagerDuty resolve: alert #{alert_id} not found") + + %{resolved_at: resolved_at} when not is_nil(resolved_at) -> + Logger.debug("PagerDuty resolve: alert #{alert_id} already resolved") + + alert -> + Logger.info("Resolving alert #{alert_id} from PagerDuty") + Alerts.resolve_alert_silent(alert) + end + end + end + + defp acknowledge_from_pagerduty(data, _organization_id) do + case extract_alert_id(data) do + nil -> + :ok + + alert_id -> + case Alerts.get_alert(alert_id) do + nil -> + Logger.warning("PagerDuty acknowledge: alert #{alert_id} not found") + + %{acknowledged_at: ack} when not is_nil(ack) -> + Logger.debug("PagerDuty acknowledge: alert #{alert_id} already acknowledged") + + alert -> + Logger.info("Acknowledging alert #{alert_id} from PagerDuty") + Alerts.acknowledge_alert_silent(alert) + end + end + end + + # PagerDuty includes our dedup_key as the alert key. + # Our dedup_key format is "towerops-alert-" + defp extract_alert_id(data) do + # Try the first alert's dedup key + alerts = data["alerts"] || [] + + Enum.find_value(alerts, fn alert -> + case alert["alert_key"] do + "towerops-alert-" <> id -> id + _ -> nil + end + end) + end +end diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 6981c7fd..aec61e1f 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -507,13 +507,20 @@ defmodule ToweropsWeb.Org.SettingsLive do api_key = Map.get(params, "api_key", "") sync_interval = Map.get(params, "sync_interval_minutes") + # Use form value if provided, otherwise preserve existing webhook_secret = - case existing_integration do - %Integration{credentials: %{"webhook_secret" => secret}} when is_binary(secret) -> + case Map.get(params, "webhook_secret") do + secret when is_binary(secret) and secret != "" -> secret _ -> - "" + case existing_integration do + %Integration{credentials: %{"webhook_secret" => secret}} when is_binary(secret) -> + secret + + _ -> + "" + end end attrs = %{credentials: %{"api_key" => api_key, "webhook_secret" => webhook_secret}} diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 1700df20..9d79fb85 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -1502,6 +1502,61 @@ When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.

+ +
+
+ <.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" /> +

+ Two-Way Sync (Webhooks) +

+
+

+ When someone resolves or acknowledges an incident directly in PagerDuty, + TowerOps will automatically update the corresponding alert. +

+
+ +
+ + {ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id} + +
+
+ <.input + name="integration[webhook_secret]" + type="password" + label="Webhook Signing Secret" + value={ + if(@integrations[provider.id], + do: + get_in(@integrations[provider.id].credentials, ["webhook_secret"]) || + "", + else: "" + ) + } + placeholder="Optional — paste from PagerDuty webhook extension" + /> +
+
    +
  1. + In PagerDuty, go to Integrations + → Generic Webhooks (v3) +
  2. +
  3. + Add a subscription with the Webhook URL above +
  4. +
  5. + Select events: incident.resolved + and incident.acknowledged +
  6. +
  7. + Copy the Signing Secret and paste it above +
  8. +
+
+
<% end %> <%= if provider.id != "pagerduty" do %> diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 3e456d2b..eb98cc95 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -175,6 +175,7 @@ defmodule ToweropsWeb.Router do pipe_through [:api, :rate_limit_api] post "/gaiia/:organization_id", GaiiaWebhookController, :create + post "/pagerduty/:organization_id", PagerdutyWebhookController, :create end # Admin API routes (requires superuser API token)