towerops/lib/towerops/mikrotik.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

147 lines
4.6 KiB
Elixir

defmodule Towerops.Mikrotik do
@moduledoc """
Context for MikroTik router-sourced DHCP / PPPoE webhook events.
Routers POST lease-script and PPP on-up/on-down events to
`POST /api/v1/webhooks/mikrotik/:organization_id/{dhcp,pppoe}`.
The controller authenticates with a shared secret, enqueues an Oban job,
and ACKs immediately. The worker (`Towerops.Workers.MikrotikWebhookWorker`)
resolves the MAC / username to a Gaiia account, writes one row to
`mikrotik_events`, and upserts the current state in `mikrotik_ip_assignments`.
Authoritative resolution path:
1. caller-MAC (PPPoE) / lease-MAC (DHCP) -> Gaiia InventoryItem.mac
custom field -> Account assignment.
2. Fallback: PPPoE username / DHCP host-name fuzzy match against
Account.displayName.
3. If neither resolves, the event is still recorded with
`resolved_account_id=NULL` so later backfill can pick it up.
"""
import Ecto.Query
alias Towerops.Mikrotik.Event
alias Towerops.Mikrotik.IpAssignment
alias Towerops.Repo
@doc """
Inserts a single MikroTik event row. Returns `{:ok, event}` or `{:error, changeset}`.
"""
def record_event(attrs) do
%Event{}
|> Event.changeset(attrs)
|> Repo.insert()
end
@doc """
Upserts the current-state row for a given (organization_id, ip).
- On `assign` events, sets/refreshes mac, account, last_seen_at, is_active=true.
- On `release` events, flips is_active=false but preserves the most-recent
resolution so we keep the audit trail.
`attrs` must include at minimum: organization_id, ip, source, event_type.
Optional: mac, router, username, host, gaiia_account_id,
gaiia_inventory_item_id, account_name, resolved_via.
"""
def upsert_assignment(attrs) do
now = DateTime.utc_now()
event_type = Map.fetch!(attrs, :event_type)
org_id = Map.fetch!(attrs, :organization_id)
ip = Map.fetch!(attrs, :ip)
base_attrs =
attrs
|> Map.delete(:event_type)
|> Map.put(:is_active, event_type == "assign")
|> Map.put(:last_seen_at, now)
|> Map.put(:first_seen_at, now)
case Repo.get_by(IpAssignment, organization_id: org_id, ip: ip) do
nil ->
%IpAssignment{}
|> IpAssignment.changeset(base_attrs)
|> Repo.insert()
existing ->
update_attrs =
base_attrs
|> Map.put(:first_seen_at, existing.first_seen_at || now)
|> maybe_keep_resolution(existing, event_type)
existing
|> IpAssignment.changeset(update_attrs)
|> Repo.update()
end
end
# On release events, only flip is_active; keep prior resolution fields so the
# audit trail isn't lost when a customer disconnects.
defp maybe_keep_resolution(attrs, existing, "release") do
Map.merge(
%{
mac: existing.mac,
username: existing.username,
host: existing.host,
router: existing.router,
source: existing.source,
gaiia_account_id: existing.gaiia_account_id,
gaiia_inventory_item_id: existing.gaiia_inventory_item_id,
account_name: existing.account_name,
resolved_via: existing.resolved_via
},
attrs
)
end
defp maybe_keep_resolution(attrs, _existing, _event_type), do: attrs
@doc "List recent events for an organization (most recent first)."
def list_recent_events(organization_id, limit \\ 100) do
Event
|> where(organization_id: ^organization_id)
|> order_by([e], desc: e.received_at)
|> limit(^limit)
|> Repo.all()
end
@doc "List currently-active IP assignments for an organization."
def list_active_assignments(organization_id) do
IpAssignment
|> where(organization_id: ^organization_id, is_active: true)
|> order_by(:ip)
|> Repo.all()
end
@doc "Get the current assignment for a specific IP (if any)."
def get_assignment(organization_id, ip) do
Repo.get_by(IpAssignment, organization_id: organization_id, ip: ip)
end
@doc "Normalize a MAC string to lowercase 12-char hex (no separators)."
def normalize_mac(nil), do: nil
def normalize_mac(""), do: nil
def normalize_mac(mac) when is_binary(mac) do
cleaned =
mac
|> String.downcase()
|> String.replace(~r/[^0-9a-f]/, "")
case cleaned do
<<_::binary-size(12)>> -> cleaned
_ -> nil
end
end
@doc "Format a normalized MAC back to `XX:XX:XX:XX:XX:XX` (uppercase)."
def format_mac(nil), do: nil
def format_mac(<<a::2-bytes, b::2-bytes, c::2-bytes, d::2-bytes, e::2-bytes, f::2-bytes>>) do
[a, b, c, d, e, f] |> Enum.join(":") |> String.upcase()
end
def format_mac(other), do: other
end