defmodule ToweropsWeb.Api.V1.MikrotikWebhookController do @moduledoc """ Webhook endpoints for MikroTik routers. Routers POST DHCP lease-script and PPPoE on-up/on-down events here. Auth is a per-org shared secret stored on a `mikrotik` integration (header `X-Towerops-Token`). The handler enqueues an Oban job and ACKs immediately so the router's `/tool fetch` doesn't block the lease/PPP state machine. Routes: POST /api/v1/webhooks/mikrotik/:organization_id/dhcp POST /api/v1/webhooks/mikrotik/:organization_id/pppoe """ use ToweropsWeb, :controller alias Towerops.Integrations alias Towerops.Workers.MikrotikWebhookWorker require Logger @token_header "x-towerops-token" def dhcp(conn, params), do: handle(conn, params, "dhcp") def pppoe(conn, params), do: handle(conn, params, "pppoe") defp handle(conn, %{"organization_id" => organization_id} = params, source) do payload = Map.delete(params, "organization_id") with {:ok, integration} <- get_integration(organization_id), :ok <- verify_token(conn, integration), {:ok, event_type} <- parse_event_type(payload) do args = %{ "organization_id" => organization_id, "source" => source, "event_type" => event_type, "payload" => payload, "received_at" => DateTime.to_iso8601(DateTime.utc_now()) } case Oban.insert(MikrotikWebhookWorker.new(args)) do {:ok, _job} -> json(conn, %{status: "ok"}) {:error, changeset} -> Logger.error("MikroTik webhook enqueue failed: #{inspect(changeset.errors)}") conn |> put_status(:internal_server_error) |> json(%{error: "Failed to enqueue"}) end else {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "MikroTik integration not found"}) {:error, :no_token_configured} -> conn |> put_status(:forbidden) |> json(%{error: "Webhook token not configured"}) {:error, :invalid_token} -> conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook token"}) {:error, :missing_bound} -> conn |> put_status(:bad_request) |> json(%{error: "Missing or invalid 'bound' field"}) end end defp get_integration(organization_id) do case Integrations.get_integration(organization_id, "mikrotik") do {:ok, _integration} = ok -> ok {:error, :not_found} -> {:error, :not_found} end end defp verify_token(conn, integration) do expected = get_in(integration.credentials, ["webhook_token"]) presented = conn |> Plug.Conn.get_req_header(@token_header) |> List.first() cond do is_nil(expected) or expected == "" -> Logger.warning("Mikrotik webhook: rejected - no webhook_token configured for org") {:error, :no_token_configured} is_nil(presented) -> {:error, :invalid_token} Plug.Crypto.secure_compare(expected, presented) -> :ok true -> {:error, :invalid_token} end end # MikroTik scripts send bound as a string ("1" / "0"). Map to assign/release. defp parse_event_type(%{"bound" => bound}) do case to_string(bound) do "1" -> {:ok, "assign"} "0" -> {:ok, "release"} _ -> {:error, :missing_bound} end end defp parse_event_type(_), do: {:error, :missing_bound} end