towerops/lib/towerops_web/controllers/api/v1/mikrotik_webhook_controller.ex
Graham McIntire ecec8ba845 feat(mikrotik): webhook receiver for MikroTik RouterOS events
Receives webhooks from MikroTik RouterOS when devices come online,
extracts MAC/IP data, and reconciles with Gaiia inventory.
2026-05-11 18:02:21 -05:00

113 lines
3.4 KiB
Elixir

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