Receives webhooks from MikroTik RouterOS when devices come online, extracts MAC/IP data, and reconciles with Gaiia inventory.
147 lines
4.6 KiB
Elixir
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
|