Current section
Files
Jump to
Current section
Files
lib/mikrotik_api.ex
defmodule MikrotikApi do
@moduledoc """
Public API for interacting with MikroTik RouterOS REST endpoints.
Usage pattern:
- Establish an %MikrotikApi.Auth{} once.
- Pass Auth and a target IP (IPv4/IPv6 literal) to each call.
Transport via OTP; JSON via Elixir's built-in JSON. Logging uses Logger only.
"""
require Logger
alias MikrotikApi.{Auth, Error}
@type method :: :get | :post | :put | :patch | :delete
@base_path "/rest"
@doc """
Generic call. See get/3, post/4, put/4, patch/4, delete/3.
opts: :body (map or list), :params (map), :headers (list), :scheme (:https | :http), :port (integer)
"""
@spec call(Auth.t(), String.t(), method(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def call(%Auth{} = auth, ip, method, path, opts \\ []) when is_binary(ip) and is_binary(path) do
started = System.monotonic_time()
default_scheme = Application.get_env(:mikrotik_api, :default_scheme, :http)
scheme = Keyword.get(opts, :scheme, default_scheme)
port = Keyword.get(opts, :port, default_port(scheme))
url = build_url(ip, port, scheme, path, Keyword.get(opts, :params, %{}))
{headers, body} =
build_request_parts(auth, method, Keyword.get(opts, :headers, []), Keyword.get(opts, :body))
http_opts = httpc_options(auth)
case transport_module().request(method, to_charlist(url), headers, body,
http_opts: http_opts,
owner_pid: Keyword.get(opts, :owner_pid, self())
) do
{:ok, {status, _resp_headers, resp_body}} ->
duration_ms = monotonic_ms_since(started)
Logger.debug(fn ->
"mikrotik_api #{method} #{path} status=#{status} duration_ms=#{duration_ms}"
end)
opts_with_path = Keyword.put(opts, :_path, path)
handle_response(status, resp_body, opts_with_path)
{:error, reason} ->
duration_ms = monotonic_ms_since(started)
Logger.error("mikrotik_api #{method} #{path} transport_error duration_ms=#{duration_ms}")
{:error, %Error{status: nil, reason: :transport_error, details: reason}}
end
end
@doc """
GET a path under /rest on the target.
"""
@spec get(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def get(auth, ip, path, opts \\ []) do
call(auth, ip, :get, path, opts)
end
@doc """
POST JSON to a path under /rest on the target.
"""
@spec post(Auth.t(), String.t(), String.t(), map() | list() | nil, Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def post(auth, ip, path, body \\ nil, opts \\ []) do
call(auth, ip, :post, path, Keyword.put(opts, :body, body))
end
@doc """
PUT JSON to a path under /rest on the target.
"""
@spec put(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def put(auth, ip, path, body, opts \\ []) do
call(auth, ip, :put, path, Keyword.put(opts, :body, body))
end
@doc """
PATCH JSON to a path under /rest on the target.
"""
@spec patch(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def patch(auth, ip, path, body, opts \\ []) do
call(auth, ip, :patch, path, Keyword.put(opts, :body, body))
end
@doc """
DELETE a path under /rest on the target.
"""
@spec delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def delete(auth, ip, path, opts \\ []) do
call(auth, ip, :delete, path, opts)
end
# -- resource helpers --
@doc """
GET /system/resource
"""
@spec system_resource(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def system_resource(auth, ip, opts \\ []) do
get(auth, ip, "/system/resource", opts)
end
@doc """
GET /interface
"""
@spec interface_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def interface_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface", opts)
end
@doc """
GET /ip/address
"""
@spec ip_address_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ip_address_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/address", opts)
end
@doc """
POST /ip/address
"""
@spec ip_address_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ip_address_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/ip/address", attrs, opts)
end
@doc """
Ensure an IP address exists on an interface.
Requires attrs to include at least "address" and optionally "interface"; matches existing entries by these keys.
Returns {:ok, address} when found or created.
"""
@spec ip_address_ensure(Auth.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def ip_address_ensure(auth, ip, attrs, opts \\ []) when is_map(attrs) do
with {:ok, list} <- ip_address_list(auth, ip, opts) do
addr = Map.get(attrs, "address")
if is_nil(addr) do
{:error, %Error{status: nil, reason: :invalid_argument, details: "address required"}}
else
iface = Map.get(attrs, "interface")
exists? =
Enum.any?(list, fn e ->
match_addr = e["address"] == addr
match_iface = is_nil(iface) or e["interface"] == iface
match_addr and match_iface
end)
if exists? do
{:ok, addr}
else
case ip_address_add(auth, ip, attrs, opts) do
{:ok, _} -> {:ok, addr}
{:error, _} = err -> err
end
end
end
end
end
@doc """
PATCH /ip/address/{id}
"""
@spec ip_address_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ip_address_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/ip/address/#{id}", attrs, opts)
end
@doc """
DELETE /ip/address/{id}
"""
@spec ip_address_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ip_address_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/ip/address/#{id}", opts)
end
@doc """
GET /ip/firewall/filter
"""
@spec firewall_filter_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_filter_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/firewall/filter", opts)
end
@doc """
POST /ip/firewall/filter
"""
@spec firewall_filter_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_filter_add(auth, ip, rule, opts \\ []) when is_map(rule) or is_list(rule) do
post(auth, ip, "/ip/firewall/filter", rule, opts)
end
@doc """
Ensure a firewall filter rule exists.
By default, matches existing by ["chain", "action"]. You can pass unique_keys: [..] in opts to control matching.
Returns {:ok, Map.t()} with the matched key-values when found or created.
"""
@spec firewall_filter_ensure(Auth.t(), String.t(), map(), Keyword.t()) ::
{:ok, map()} | {:error, Error.t()}
def firewall_filter_ensure(auth, ip, rule, opts \\ []) when is_map(rule) do
unique_keys = Keyword.get(opts, :unique_keys, ["chain", "action"])
with {:ok, list} <- firewall_filter_list(auth, ip, opts) do
found =
Enum.find(list, fn e ->
Enum.all?(unique_keys, fn k -> Map.get(e, k) == Map.get(rule, k) end)
end)
if found do
{:ok, Map.new(unique_keys, fn k -> {k, Map.get(found, k)} end)}
else
case firewall_filter_add(auth, ip, rule, opts) do
{:ok, _} -> {:ok, Map.new(unique_keys, fn k -> {k, Map.get(rule, k)} end)}
{:error, _} = err -> err
end
end
end
end
@doc """
DELETE /ip/firewall/filter/{id}
"""
@spec firewall_filter_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_filter_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/ip/firewall/filter/#{id}", opts)
end
# Firewall NAT
@doc """
GET /ip/firewall/nat
"""
@spec firewall_nat_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_nat_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/firewall/nat", opts)
end
@doc """
POST /ip/firewall/nat
"""
@spec firewall_nat_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_nat_add(auth, ip, rule, opts \\ []) when is_map(rule) or is_list(rule) do
post(auth, ip, "/ip/firewall/nat", rule, opts)
end
@doc """
DELETE /ip/firewall/nat/{id}
"""
@spec firewall_nat_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_nat_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/ip/firewall/nat/#{id}", opts)
end
@doc """
Ensure a firewall NAT rule exists. By default matches by ["chain", "action"]. You can pass unique_keys: [..] in opts.
Returns {:ok, map()} of matched keys when found or created.
"""
@spec firewall_nat_ensure(Auth.t(), String.t(), map(), Keyword.t()) ::
{:ok, map()} | {:error, Error.t()}
def firewall_nat_ensure(auth, ip, rule, opts \\ []) when is_map(rule) do
unique_keys = Keyword.get(opts, :unique_keys, ["chain", "action"])
with {:ok, list} <- firewall_nat_list(auth, ip, opts) do
found =
Enum.find(list, fn e ->
Enum.all?(unique_keys, fn k -> Map.get(e, k) == Map.get(rule, k) end)
end)
if found do
{:ok, Map.new(unique_keys, fn k -> {k, Map.get(found, k)} end)}
else
case firewall_nat_add(auth, ip, rule, opts) do
{:ok, _} -> {:ok, Map.new(unique_keys, fn k -> {k, Map.get(rule, k)} end)}
{:error, _} = err -> err
end
end
end
end
@doc """
PATCH /interface/{id} with attrs
"""
@spec interface_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def interface_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/#{id}", attrs, opts)
end
@doc """
Ensure interface settings. Ident can be an interface name or .id. Applies only differing keys from attrs.
Returns {:ok, %{id: id, name: name, changed: [keys]}} when up-to-date or updated; {:error, not_found} if interface not present.
"""
@spec interface_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, %{id: String.t(), name: String.t(), changed: [String.t()]}} | {:error, term()}
def interface_ensure(auth, ip, ident, attrs, opts \\ [])
when is_binary(ident) and is_map(attrs) do
with {:ok, list} <- interface_list(auth, ip, opts) do
case Enum.find(list, fn e -> e[".id"] == ident or e["name"] == ident end) do
nil ->
{:error, :not_found}
entry ->
id = entry[".id"]
name = entry["name"]
changed_map =
attrs
|> Enum.reduce(%{}, fn {k, v}, acc ->
existing = Map.get(entry, k)
if existing == v do
acc
else
Map.put(acc, k, v)
end
end)
changed_keys = Map.keys(changed_map)
case changed_keys do
[] ->
{:ok, %{id: id, name: name, changed: []}}
_ ->
case interface_update(auth, ip, id, changed_map, opts) do
{:ok, _} ->
{:ok, %{id: id, name: name, changed: Enum.map(changed_keys, &to_string/1)}}
{:error, _} = err ->
err
end
end
end
end
end
@doc """
Convenience: set disabled=no
"""
@spec interface_enable(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def interface_enable(auth, ip, id, opts \\ []) when is_binary(id) do
interface_update(auth, ip, id, %{"disabled" => "false"}, opts)
end
@doc """
Convenience: set disabled=yes
"""
@spec interface_disable(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def interface_disable(auth, ip, id, opts \\ []) when is_binary(id) do
interface_update(auth, ip, id, %{"disabled" => "true"}, opts)
end
@doc """
GET /ip/dhcp-server/lease
"""
@spec dhcp_lease_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def dhcp_lease_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/dhcp-server/lease", opts)
end
@doc """
POST /ip/dhcp-server/lease
"""
@spec dhcp_lease_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def dhcp_lease_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/ip/dhcp-server/lease", attrs, opts)
end
@doc """
Ensure a DHCP lease exists. Matches by ["address", "mac-address"]. Returns {:ok, %{address: ..., mac: ...}} if found or created.
"""
@spec dhcp_lease_ensure(Auth.t(), String.t(), map(), Keyword.t()) ::
{:ok, map()} | {:error, Error.t()}
def dhcp_lease_ensure(auth, ip, attrs, opts \\ []) when is_map(attrs) do
with {:ok, list} <- dhcp_lease_list(auth, ip, opts) do
addr = Map.get(attrs, "address")
mac = Map.get(attrs, "mac-address")
if is_nil(addr) or is_nil(mac) do
{:error,
%Error{
status: nil,
reason: :invalid_argument,
details: "address and mac-address required"
}}
else
found = Enum.find(list, fn e -> e["address"] == addr and e["mac-address"] == mac end)
if found do
{:ok, %{address: addr, mac: mac}}
else
case dhcp_lease_add(auth, ip, attrs, opts) do
{:ok, _} -> {:ok, %{address: addr, mac: mac}}
{:error, _} = err -> err
end
end
end
end
end
@doc """
PATCH /ip/dhcp-server/lease/{id}
"""
@spec dhcp_lease_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def dhcp_lease_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/ip/dhcp-server/lease/#{id}", attrs, opts)
end
@doc """
DELETE /ip/dhcp-server/lease/{id}
"""
@spec dhcp_lease_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def dhcp_lease_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/ip/dhcp-server/lease/#{id}", opts)
end
@doc """
GET /ip/route
"""
@spec route_list(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def route_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/route", opts)
end
@doc """
POST /ip/route
"""
@spec route_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def route_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/ip/route", attrs, opts)
end
@doc """
Ensure a route exists. Matches by ["dst-address", "gateway"]. Returns {:ok, %{dst: ..., gw: ...}} if found or created.
"""
@spec route_ensure(Auth.t(), String.t(), map(), Keyword.t()) ::
{:ok, map()} | {:error, Error.t()}
def route_ensure(auth, ip, attrs, opts \\ []) when is_map(attrs) do
with {:ok, list} <- route_list(auth, ip, opts) do
dst = Map.get(attrs, "dst-address")
gw = Map.get(attrs, "gateway")
if is_nil(dst) or is_nil(gw) do
{:error,
%Error{
status: nil,
reason: :invalid_argument,
details: "dst-address and gateway required"
}}
else
found = Enum.find(list, fn e -> e["dst-address"] == dst and e["gateway"] == gw end)
if found do
{:ok, %{dst: dst, gw: gw}}
else
case route_add(auth, ip, attrs, opts) do
{:ok, _} -> {:ok, %{dst: dst, gw: gw}}
{:error, _} = err -> err
end
end
end
end
end
@doc """
DELETE /ip/route/{id}
"""
@spec route_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def route_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/ip/route/#{id}", opts)
end
# Bridges
@doc """
GET /interface/bridge
"""
@spec bridge_list(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def bridge_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/bridge", opts)
end
@doc """
POST /interface/bridge
"""
@spec bridge_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/bridge", attrs, opts)
end
@doc """
Ensure a bridge exists by name. Returns {:ok, name} if found or created.
"""
@spec bridge_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def bridge_ensure(auth, ip, name, attrs \\ %{}, opts \\ []) when is_binary(name) do
with {:ok, list} <- bridge_list(auth, ip, opts) do
case Enum.find(list, &(&1["name"] == name)) do
nil ->
merged = Map.put(attrs, "name", name)
case bridge_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, name}
{:error, _} = err -> err
end
_found ->
{:ok, name}
end
end
end
@doc """
PATCH /interface/bridge/{id}
"""
@spec bridge_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/bridge/#{id}", attrs, opts)
end
@doc """
DELETE /interface/bridge/{id}
"""
@spec bridge_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/bridge/#{id}", opts)
end
# Bridge ports
@doc """
GET /interface/bridge/port
"""
@spec bridge_port_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_port_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/bridge/port", opts)
end
@doc """
POST /interface/bridge/port
"""
@spec bridge_port_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_port_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/bridge/port", attrs, opts)
end
@doc """
PATCH /interface/bridge/port/{id}
"""
@spec bridge_port_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_port_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/bridge/port/#{id}", attrs, opts)
end
@doc """
Ensure a bridge port exists for the given bridge and interface.
Returns {:ok, {bridge, interface}} when found or created.
"""
@spec bridge_port_ensure(Auth.t(), String.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, {String.t(), String.t()}} | {:error, Error.t()}
def bridge_port_ensure(auth, ip, bridge, interface, attrs \\ %{}, opts \\ [])
when is_binary(bridge) and is_binary(interface) do
with {:ok, list} <- bridge_port_list(auth, ip, opts) do
found = Enum.find(list, fn e -> e["bridge"] == bridge and e["interface"] == interface end)
if found do
{:ok, {bridge, interface}}
else
merged = attrs |> Map.put("bridge", bridge) |> Map.put("interface", interface)
case bridge_port_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, {bridge, interface}}
{:error, _} = err -> err
end
end
end
end
@doc """
DELETE /interface/bridge/port/{id}
"""
@spec bridge_port_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_port_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/bridge/port/#{id}", opts)
end
# Bridge VLANs
@doc """
GET /interface/bridge/vlan
"""
@spec bridge_vlan_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_vlan_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/bridge/vlan", opts)
end
@doc """
POST /interface/bridge/vlan
"""
@spec bridge_vlan_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_vlan_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/bridge/vlan", attrs, opts)
end
@doc """
Ensure a bridge VLAN entry exists for the given bridge and vlan-ids.
Returns {:ok, {bridge, vlan_ids}} when found or created.
"""
@spec bridge_vlan_ensure(Auth.t(), String.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, {String.t(), String.t()}} | {:error, Error.t()}
def bridge_vlan_ensure(auth, ip, bridge, vlan_ids, attrs \\ %{}, opts \\ [])
when is_binary(bridge) and is_binary(vlan_ids) do
case bridge_vlan_list(auth, ip, opts) do
{:ok, list} when is_list(list) ->
case Enum.find(list, &(&1["bridge"] == bridge and &1["vlan-ids"] == vlan_ids)) do
nil ->
merged = attrs |> Map.put("bridge", bridge) |> Map.put("vlan-ids", vlan_ids)
case bridge_vlan_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, {bridge, vlan_ids}}
{:error, _} = err -> err
end
_found ->
{:ok, {bridge, vlan_ids}}
end
{:error, _} = err ->
err
other ->
other
end
end
@doc """
PATCH /interface/bridge/vlan/{id}
"""
@spec bridge_vlan_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_vlan_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/bridge/vlan/#{id}", attrs, opts)
end
@doc """
DELETE /interface/bridge/vlan/{id}
"""
@spec bridge_vlan_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def bridge_vlan_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/bridge/vlan/#{id}", opts)
end
# Wireless (legacy wireless package)
@doc """
GET /interface/wireless
"""
@spec wireless_interface_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_interface_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/wireless", opts)
end
@doc """
POST /interface/wireless
"""
@spec wireless_interface_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_interface_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/wireless", attrs, opts)
end
@doc """
Ensure a legacy wireless interface exists by name.
"""
@spec wireless_interface_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def wireless_interface_ensure(auth, ip, name, attrs \\ %{}, opts \\ []) when is_binary(name) do
with {:ok, list} <- wireless_interface_list(auth, ip, opts) do
case Enum.find(list, &(&1["name"] == name)) do
nil ->
merged = Map.put(attrs, "name", name)
case wireless_interface_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, name}
{:error, _} = err -> err
end
_found ->
{:ok, name}
end
end
end
@doc """
PATCH /interface/wireless/{id}
"""
@spec wireless_interface_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_interface_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/wireless/#{id}", attrs, opts)
end
@doc """
DELETE /interface/wireless/{id}
"""
@spec wireless_interface_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_interface_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/wireless/#{id}", opts)
end
@doc """
GET /interface/wireless/registration-table
"""
@spec wireless_registration_table(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_registration_table(auth, ip, opts \\ []) do
get(auth, ip, "/interface/wireless/registration-table", opts)
end
@doc """
GET /interface/wireless/security-profiles
"""
@spec wireless_security_profile_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_security_profile_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/wireless/security-profiles", opts)
end
@doc """
Ensure a legacy wireless security profile exists by name.
"""
@spec wireless_security_profile_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def wireless_security_profile_ensure(auth, ip, name, attrs \\ %{}, opts \\ [])
when is_binary(name) do
with {:ok, list} <- wireless_security_profile_list(auth, ip, opts) do
case Enum.find(list, &(&1["name"] == name)) do
nil ->
merged = Map.put(attrs, "name", name)
case wireless_security_profile_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, name}
{:error, _} = err -> err
end
_found ->
{:ok, name}
end
end
end
@doc """
POST /interface/wireless/security-profiles
"""
@spec wireless_security_profile_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_security_profile_add(auth, ip, attrs, opts \\ [])
when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/wireless/security-profiles", attrs, opts)
end
@doc """
PATCH /interface/wireless/security-profiles/{id}
"""
@spec wireless_security_profile_update(
Auth.t(),
String.t(),
String.t(),
map() | list(),
Keyword.t()
) :: {:ok, any() | nil} | {:error, Error.t()}
def wireless_security_profile_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/wireless/security-profiles/#{id}", attrs, opts)
end
@doc """
DELETE /interface/wireless/security-profiles/{id}
"""
@spec wireless_security_profile_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wireless_security_profile_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/wireless/security-profiles/#{id}", opts)
end
# WiFi (wifiwave2 package)
@doc """
Ensure a WiFi security profile with the given name exists; returns the found or created entry name.
If the endpoint is unavailable, returns the underlying error.
"""
@spec wifi_security_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def wifi_security_ensure(auth, ip, name, attrs \\ %{}, opts \\ []) when is_binary(name) do
case wifi_security_list(auth, ip, opts) do
{:ok, list} when is_list(list) ->
case Enum.find(list, &(&1["name"] == name)) do
nil ->
merged = Map.put(attrs, "name", name)
case wifi_security_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, name}
{:error, _} = err -> err
end
_found ->
{:ok, name}
end
{:error, _} = err ->
err
other ->
other
end
end
@doc """
Ensure a WiFi SSID with the given name exists; returns the found or created entry name.
If the endpoint is unavailable (:wifi_ssid_unavailable), returns the underlying error.
"""
@spec wifi_ssid_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def wifi_ssid_ensure(auth, ip, name, attrs \\ %{}, opts \\ []) when is_binary(name) do
case wifi_ssid_list(auth, ip, opts) do
{:ok, list} when is_list(list) ->
case Enum.find(list, &(&1["name"] == name)) do
nil ->
merged = Map.put(attrs, "name", name)
case wifi_ssid_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, name}
{:error, _} = err -> err
end
_found ->
{:ok, name}
end
{:error, _} = err ->
err
other ->
other
end
end
# ARP and Neighbors
@doc """
GET /ip/arp
"""
@spec arp_list(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def arp_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/arp", opts)
end
@doc """
GET /ip/neighbor
"""
@spec neighbor_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def neighbor_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/neighbor", opts)
end
# CAPsMAN
@doc """
POST /caps-man/security
"""
@spec capsman_security_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def capsman_security_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/caps-man/security", attrs, opts)
end
@doc """
Ensure a CAPsMAN security profile with the given name exists.
"""
@spec capsman_security_ensure(Auth.t(), String.t(), String.t(), map(), Keyword.t()) ::
{:ok, String.t()} | {:error, Error.t()}
def capsman_security_ensure(auth, ip, name, attrs \\ %{}, opts \\ []) when is_binary(name) do
case capsman_security_list(auth, ip, opts) do
{:ok, list} when is_list(list) ->
case Enum.find(list, &(&1["name"] == name)) do
nil ->
merged = Map.put(attrs, "name", name)
case capsman_security_add(auth, ip, merged, opts) do
{:ok, _} -> {:ok, name}
{:error, _} = err -> err
end
_found ->
{:ok, name}
end
{:error, _} = err ->
err
other ->
other
end
end
@doc """
GET /caps-man/provisioning
"""
@spec capsman_provisioning_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def capsman_provisioning_list(auth, ip, opts \\ []) do
get(auth, ip, "/caps-man/provisioning", opts)
end
@doc """
POST /caps-man/provisioning
"""
@spec capsman_provisioning_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def capsman_provisioning_add(auth, ip, attrs, opts \\ [])
when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/caps-man/provisioning", attrs, opts)
end
@doc """
Ensure a CAPsMAN provisioning rule exists. By default matches by ["action", "master-configuration"].
Returns {:ok, map()} of matched keys when found or created.
"""
@spec capsman_provisioning_ensure(Auth.t(), String.t(), map(), Keyword.t()) ::
{:ok, map()} | {:error, Error.t()}
def capsman_provisioning_ensure(auth, ip, rule, opts \\ []) when is_map(rule) do
unique_keys = Keyword.get(opts, :unique_keys, ["action", "master-configuration"])
with {:ok, list} <- capsman_provisioning_list(auth, ip, opts) do
found =
Enum.find(list, fn e ->
Enum.all?(unique_keys, fn k -> Map.get(e, k) == Map.get(rule, k) end)
end)
if found do
{:ok, Map.new(unique_keys, fn k -> {k, Map.get(found, k)} end)}
else
case capsman_provisioning_add(auth, ip, rule, opts) do
{:ok, _} -> {:ok, Map.new(unique_keys, fn k -> {k, Map.get(rule, k)} end)}
{:error, _} = err -> err
end
end
end
end
@doc """
GET /caps-man/interface
"""
@spec capsman_interface_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def capsman_interface_list(auth, ip, opts \\ []) do
get(auth, ip, "/caps-man/interface", opts)
end
@doc """
GET /caps-man/registration-table
"""
@spec capsman_registration_table(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def capsman_registration_table(auth, ip, opts \\ []) do
get(auth, ip, "/caps-man/registration-table", opts)
end
@doc """
GET /caps-man/security
"""
@spec capsman_security_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def capsman_security_list(auth, ip, opts \\ []) do
get(auth, ip, "/caps-man/security", opts)
end
@doc """
GET /interface/wifi
"""
@spec wifi_interface_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_interface_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/wifi", opts)
end
@doc """
PATCH /interface/wifi/{id}
"""
@spec wifi_interface_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_interface_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/wifi/#{id}", attrs, opts)
end
@doc """
GET /interface/wifi/ssid
"""
@spec wifi_ssid_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_ssid_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/wifi/ssid", opts)
end
@doc """
POST /interface/wifi/ssid
"""
@spec wifi_ssid_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_ssid_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/wifi/ssid", attrs, opts)
end
@doc """
PATCH /interface/wifi/ssid/{id}
"""
@spec wifi_ssid_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_ssid_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/wifi/ssid/#{id}", attrs, opts)
end
@doc """
DELETE /interface/wifi/ssid/{id}
"""
@spec wifi_ssid_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_ssid_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/wifi/ssid/#{id}", opts)
end
@doc """
GET /interface/wifi/security
"""
@spec wifi_security_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_security_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/wifi/security", opts)
end
@doc """
POST /interface/wifi/security
"""
@spec wifi_security_add(Auth.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_security_add(auth, ip, attrs, opts \\ []) when is_map(attrs) or is_list(attrs) do
post(auth, ip, "/interface/wifi/security", attrs, opts)
end
@doc """
PATCH /interface/wifi/security/{id}
"""
@spec wifi_security_update(Auth.t(), String.t(), String.t(), map() | list(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_security_update(auth, ip, id, attrs, opts \\ []) when is_binary(id) do
patch(auth, ip, "/interface/wifi/security/#{id}", attrs, opts)
end
@doc """
DELETE /interface/wifi/security/{id}
"""
@spec wifi_security_delete(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def wifi_security_delete(auth, ip, id, opts \\ []) when is_binary(id) do
delete(auth, ip, "/interface/wifi/security/#{id}", opts)
end
# -- multi (concurrent batch) --
@doc """
Execute the same REST call concurrently across multiple IPs.
- ips: list of target IPs (IPv4/IPv6 as strings)
- method: :get | :post | :put | :patch | :delete
- path: string path under /rest (e.g., "/system/resource")
- opts: keyword options accepted by call/5 (e.g., scheme:, decode:, params:, headers:, body: for write methods)
- stream_opts:
- :max_concurrency (default System.schedulers_online())
- :timeout (default 15_000 ms)
Returns a list in input order: [%{ip: ip, result: {:ok, value} | {:error, %MikrotikApi.Error{}}}]
"""
@spec multi(Auth.t(), [String.t()], method(), String.t(), Keyword.t(), Keyword.t()) :: [
%{ip: String.t(), result: {:ok, any() | nil} | {:error, Error.t()}}
]
def multi(%Auth{} = auth, ips, method, path, opts \\ [], stream_opts \\ []) when is_list(ips) do
max_conc = Keyword.get(stream_opts, :max_concurrency, System.schedulers_online())
timeout = Keyword.get(stream_opts, :timeout, 15_000)
owner = self()
ips
|> Task.async_stream(
fn ip ->
{ip, call(auth, ip, method, path, Keyword.put(opts, :owner_pid, owner))}
end,
max_concurrency: max_conc,
timeout: timeout,
ordered: true
)
|> Enum.map(fn
{:ok, {ip, res}} ->
%{ip: ip, result: res}
{:exit, reason} ->
%{ip: nil, result: {:error, %Error{status: nil, reason: :task_exit, details: reason}}}
end)
end
# -- telemetry helpers (Phase 1: operations essentials) --
@doc """
GET /system/health
"""
@spec system_health(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def system_health(auth, ip, opts \\ []) do
get(auth, ip, "/system/health", opts)
end
@doc """
GET /system/package
"""
@spec system_packages(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def system_packages(auth, ip, opts \\ []) do
get(auth, ip, "/system/package", opts)
end
@doc """
GET /ip/firewall/connection
"""
@spec firewall_connection_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_connection_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/firewall/connection", opts)
end
@doc """
GET /ip/dns (config/stats)
"""
@spec dns_config(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def dns_config(auth, ip, opts \\ []) do
get(auth, ip, "/ip/dns", opts)
end
@doc """
GET /ip/dns/cache
"""
@spec dns_cache_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def dns_cache_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/dns/cache", opts)
end
@doc """
GET /ip/pool
"""
@spec ip_pool_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ip_pool_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/pool", opts)
end
@doc """
GET /ip/firewall/address-list
"""
@spec firewall_address_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def firewall_address_list(auth, ip, opts \\ []) do
get(auth, ip, "/ip/firewall/address-list", opts)
end
# -- IPv6 parity helpers (Phase 2) --
@doc """
GET /ipv6/route
"""
@spec ipv6_route_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ipv6_route_list(auth, ip, opts \\ []) do
get(auth, ip, "/ipv6/route", opts)
end
@doc """
GET /ipv6/pool
"""
@spec ipv6_pool_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ipv6_pool_list(auth, ip, opts \\ []) do
get(auth, ip, "/ipv6/pool", opts)
end
@doc """
GET /ipv6/firewall/filter
"""
@spec ipv6_firewall_filter_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ipv6_firewall_filter_list(auth, ip, opts \\ []) do
get(auth, ip, "/ipv6/firewall/filter", opts)
end
@doc """
GET /ipv6/neighbor
"""
@spec ipv6_neighbor_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ipv6_neighbor_list(auth, ip, opts \\ []) do
get(auth, ip, "/ipv6/neighbor", opts)
end
@doc """
GET /ipv6/firewall/address-list
"""
@spec ipv6_firewall_address_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ipv6_firewall_address_list(auth, ip, opts \\ []) do
get(auth, ip, "/ipv6/firewall/address-list", opts)
end
# -- extended telemetry helpers (Phase 4) --
@doc """
GET /interface/ethernet/poe
"""
@spec ethernet_poe_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ethernet_poe_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/ethernet/poe", opts)
end
@doc """
GET /interface/ethernet/monitor/{ident}
"""
@spec interface_ethernet_monitor(Auth.t(), String.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def interface_ethernet_monitor(auth, ip, ident, opts \\ []) when is_binary(ident) do
get(auth, ip, "/interface/ethernet/monitor/#{ident}", opts)
end
@doc """
GET /tool/netwatch
"""
@spec tool_netwatch_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def tool_netwatch_list(auth, ip, opts \\ []) do
get(auth, ip, "/tool/netwatch", opts)
end
@doc """
GET /ip/cloud
"""
@spec ip_cloud_info(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ip_cloud_info(auth, ip, opts \\ []) do
get(auth, ip, "/ip/cloud", opts)
end
@doc """
GET /interface/eoip
"""
@spec eoip_list(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def eoip_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/eoip", opts)
end
@doc """
GET /interface/gre
"""
@spec gre_list(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def gre_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/gre", opts)
end
@doc """
GET /interface/ipip
"""
@spec ipip_list(Auth.t(), String.t(), Keyword.t()) :: {:ok, any() | nil} | {:error, Error.t()}
def ipip_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/ipip", opts)
end
@doc """
GET /interface/ethernet/switch/port
"""
@spec ethernet_switch_port_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def ethernet_switch_port_list(auth, ip, opts \\ []) do
get(auth, ip, "/interface/ethernet/switch/port", opts)
end
@doc """
GET /user/active
"""
@spec user_active_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def user_active_list(auth, ip, opts \\ []) do
get(auth, ip, "/user/active", opts)
end
@doc """
GET /queue/simple
"""
@spec queue_simple_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def queue_simple_list(auth, ip, opts \\ []) do
get(auth, ip, "/queue/simple", opts)
end
@doc """
GET /queue/tree
"""
@spec queue_tree_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def queue_tree_list(auth, ip, opts \\ []) do
get(auth, ip, "/queue/tree", opts)
end
@doc """
GET /routing/bfd/session
"""
@spec routing_bfd_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def routing_bfd_list(auth, ip, opts \\ []) do
get(auth, ip, "/routing/bfd/session", opts)
end
@doc """
GET /routing/bgp/session
"""
@spec routing_bgp_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def routing_bgp_list(auth, ip, opts \\ []) do
get(auth, ip, "/routing/bgp/session", opts)
end
@doc """
GET /routing/stats
"""
@spec routing_stats(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def routing_stats(auth, ip, opts \\ []) do
get(auth, ip, "/routing/stats", opts)
end
@doc """
GET /certificate
"""
@spec certificate_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def certificate_list(auth, ip, opts \\ []) do
get(auth, ip, "/certificate", opts)
end
@doc """
GET /container
"""
@spec container_list(Auth.t(), String.t(), Keyword.t()) ::
{:ok, any() | nil} | {:error, Error.t()}
def container_list(auth, ip, opts \\ []) do
get(auth, ip, "/container", opts)
end
# -- probe helpers --
@doc """
Probe core device info and summarize.
Returns a map with keys:
- system: {:ok, map()} | {:error, error_summary}
- counts: %{interfaces: n | nil, ip_addresses: n | nil, arp: n | nil, neighbors: n | nil}
Accepts same opts as other calls (e.g., scheme: :http | :https).
"""
@spec probe_device(Auth.t(), String.t(), Keyword.t()) :: {:ok, map()} | {:error, Error.t()}
def probe_device(%Auth{} = auth, ip, opts \\ []) when is_binary(ip) do
sys = system_resource(auth, ip, opts)
ifs = interface_list(auth, ip, opts)
ips = ip_address_list(auth, ip, opts)
arp = arp_list(auth, ip, opts)
nbr = neighbor_list(auth, ip, opts)
{:ok,
%{
system:
case sys do
{:ok, m} when is_map(m) ->
{:ok,
%{
"board-name" => Map.get(m, "board-name"),
"version" => Map.get(m, "version"),
"platform" => Map.get(m, "platform")
}}
{:ok, other} ->
{:ok, other}
{:error, %Error{} = e} ->
error_summary(e)
end,
counts: %{
interfaces: list_count(ifs),
ip_addresses: list_count(ips),
arp: list_count(arp),
neighbors: list_count(nbr)
}
}}
end
defp list_count({:ok, list}) when is_list(list), do: length(list)
defp list_count({:ok, _}), do: nil
defp list_count({:error, _}), do: nil
defp error_summary(%Error{status: code, reason: reason}),
do: {:error, %{status_code: code, reason: reason}}
@doc """
Probe wireless (legacy) and wifi (wifiwave2) endpoint availability and summarize.
Returns a map with sections :wireless and :wifi, each containing endpoint keys
and status maps like %{status: :ok | :unavailable | :error, count: non_neg_integer | nil, reason: term() | nil, status_code: integer | nil}.
Accepts the same opts as other calls (e.g., scheme: :http | :https).
"""
@spec probe_wireless(Auth.t(), String.t(), Keyword.t()) :: {:ok, map()} | {:error, Error.t()}
def probe_wireless(%Auth{} = auth, ip, opts \\ []) when is_binary(ip) do
w_int = wireless_interface_list(auth, ip, opts)
w_reg = wireless_registration_table(auth, ip, opts)
w_sec = wireless_security_profile_list(auth, ip, opts)
wf_int = wifi_interface_list(auth, ip, opts)
wf_ssid = wifi_ssid_list(auth, ip, opts)
wf_sec = wifi_security_list(auth, ip, opts)
{:ok,
%{
wireless: %{
interfaces: probe_status(w_int),
registration_table: probe_status(w_reg),
security_profiles: probe_status(w_sec)
},
wifi: %{
interfaces: probe_status(wf_int),
ssid: probe_status(wf_ssid),
security: probe_status(wf_sec)
}
}}
end
defp probe_status({:ok, list}) when is_list(list), do: %{status: :ok, count: length(list)}
defp probe_status({:ok, _other}), do: %{status: :ok, count: nil}
defp probe_status({:error, %Error{reason: :wifi_ssid_unavailable} = _e}),
do: %{status: :unavailable, reason: :wifi_ssid_unavailable}
defp probe_status({:error, %Error{status: code, reason: reason}}),
do: %{status: :error, status_code: code, reason: reason}
# -- internal helpers --
defp transport_module do
Application.get_env(:mikrotik_api, :transport, MikrotikApi.Transport.Httpc)
end
defp default_port(:https), do: 443
defp default_port(:http), do: 80
defp build_url(ip, port, scheme, path, params) do
host = if String.contains?(ip, ":"), do: "[#{ip}]", else: ip
base = "#{scheme}://#{host}:#{port}#{@base_path}"
qs = encode_query(params)
base <> path <> qs
end
defp encode_query(%{} = params) when map_size(params) == 0, do: ""
defp encode_query(%{} = params) do
encoded =
params
|> Enum.flat_map(fn {k, v} ->
case v do
nil -> []
_ -> [URI.encode_www_form(to_string(k)) <> "=" <> URI.encode_www_form(to_string(v))]
end
end)
|> Enum.join("&")
if encoded == "", do: "", else: "?" <> encoded
end
defp build_request_parts(%Auth{} = auth, method, extra_headers, body_term) do
auth_header = basic_auth_header(auth)
headers =
auth.default_headers
|> Kernel.++(extra_headers)
|> Kernel.++([{"authorization", auth_header}])
|> Enum.map(fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
case method do
m when m in [:post, :put, :patch] ->
json =
case body_term do
nil -> "null"
_ -> JSON.encode!(body_term)
end
{headers_with_ct(headers), to_charlist(json)}
_ ->
{headers, :undefined}
end
end
defp headers_with_ct(headers) do
[{~c"content-type", ~c"application/json"} | headers]
end
defp basic_auth_header(%Auth{username: u, password: p}) do
"Basic " <> Base.encode64("#{u}:#{p}")
end
defp httpc_options(%Auth{} = auth) do
base_ssl =
case auth.verify do
:verify_none ->
[verify: :verify_none]
_ ->
cacerts = default_cacerts()
if has_user_cacert_option?(auth.ssl_opts) do
[verify: :verify_peer]
else
[verify: :verify_peer, cacerts: cacerts]
end
end
ssl_opts = Keyword.merge(base_ssl, auth.ssl_opts)
[
ssl: ssl_opts,
connect_timeout: auth.connect_timeout,
timeout: auth.recv_timeout
]
end
defp handle_response(status, body, _opts)
when status in 200..299 and (body == "" or status == 204) do
{:ok, nil}
end
defp handle_response(status, body, opts) when status in 200..299 do
if Keyword.get(opts, :decode, true) do
case JSON.decode(body) do
{:ok, data} ->
{:ok, data}
{:error, reason} ->
{:error, %Error{status: status, reason: :decode_error, details: reason}}
end
else
{:ok, body}
end
end
defp handle_response(status, body, opts) do
path = Keyword.get(opts, :_path)
cond do
status == 500 and is_binary(path) and String.starts_with?(path, "/interface/wifi/ssid") ->
{:error, %Error{status: status, reason: :wifi_ssid_unavailable, details: truncate(body)}}
true ->
{:error, %Error{status: status, reason: :http_error, details: truncate(body)}}
end
end
defp has_user_cacert_option?(opts) do
Keyword.has_key?(opts, :cacerts) or Keyword.has_key?(opts, :cacertfile)
end
defp default_cacerts do
try do
:public_key.cacerts_get()
rescue
_ -> []
end
end
defp truncate(bin) when is_binary(bin) and byte_size(bin) > 4096, do: binary_part(bin, 0, 4096)
defp truncate(bin), do: bin
defp monotonic_ms_since(started) do
System.convert_time_unit(System.monotonic_time() - started, :native, :millisecond)
end
end