207 lines
7.6 KiB
Python
207 lines
7.6 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BlockResult:
|
|
success: bool
|
|
message: str
|
|
|
|
|
|
class RouterOSClient:
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
username: str,
|
|
password: str,
|
|
verify_tls: bool,
|
|
address_list: str,
|
|
timeout: int = 5,
|
|
) -> None:
|
|
self.base_url = base_url.rstrip("/")
|
|
self.username = username
|
|
self.password = password
|
|
self.verify_tls = verify_tls
|
|
self.address_list = address_list
|
|
self.timeout = timeout
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(
|
|
self.base_url
|
|
and self.username
|
|
and self.password
|
|
and self.password != "CHANGE_ME"
|
|
)
|
|
|
|
def block_ip(self, address: str, timeout_value: str, comment: str) -> BlockResult:
|
|
if not self.configured:
|
|
return BlockResult(False, "RouterOS credentials are not configured")
|
|
try:
|
|
existing = self._request(
|
|
"GET",
|
|
"/rest/ip/firewall/address-list",
|
|
query={"list": self.address_list, "address": address},
|
|
)
|
|
if isinstance(existing, list) and existing:
|
|
return BlockResult(True, "address already present in RouterOS address-list")
|
|
|
|
body = {
|
|
"list": self.address_list,
|
|
"address": address,
|
|
"timeout": timeout_value,
|
|
"comment": comment[:220],
|
|
}
|
|
self._request("PUT", "/rest/ip/firewall/address-list", body=body)
|
|
return BlockResult(True, "address added to RouterOS address-list")
|
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
|
return BlockResult(False, f"RouterOS REST error: {exc}")
|
|
|
|
|
|
def list_blocks(self) -> list[dict]:
|
|
if not self.configured:
|
|
return []
|
|
try:
|
|
result = self._request(
|
|
"GET",
|
|
"/rest/ip/firewall/address-list",
|
|
query={"list": self.address_list},
|
|
)
|
|
if not isinstance(result, list):
|
|
return []
|
|
rows = []
|
|
for item in result:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
rows.append({
|
|
"id": item.get(".id") or item.get("id"),
|
|
"address": item.get("address"),
|
|
"list": item.get("list"),
|
|
"timeout": item.get("timeout"),
|
|
"creation_time": item.get("creation-time") or item.get("creation_time"),
|
|
"comment": item.get("comment", ""),
|
|
"dynamic": str(item.get("dynamic", "false")).lower() == "true",
|
|
})
|
|
return rows
|
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
|
|
return []
|
|
|
|
|
|
def list_arp(self) -> list[dict]:
|
|
"""Return RouterOS ARP observations for passive asset enrichment."""
|
|
if not self.configured:
|
|
return []
|
|
try:
|
|
result = self._request("GET", "/rest/ip/arp")
|
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
|
|
return []
|
|
if not isinstance(result, list):
|
|
return []
|
|
rows = []
|
|
for item in result:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
address = str(item.get("address") or "").strip()
|
|
if not address:
|
|
continue
|
|
rows.append({
|
|
"address": address,
|
|
"mac": str(item.get("mac-address") or item.get("mac_address") or "").strip(),
|
|
"interface": str(item.get("interface") or "").strip(),
|
|
"dynamic": str(item.get("dynamic", "false")).lower() == "true",
|
|
"complete": str(item.get("complete", "true")).lower() != "false",
|
|
})
|
|
return rows
|
|
|
|
def list_dhcp_leases(self) -> list[dict]:
|
|
"""Return DHCP lease identity data when the router exposes a DHCP server table."""
|
|
if not self.configured:
|
|
return []
|
|
try:
|
|
result = self._request("GET", "/rest/ip/dhcp-server/lease")
|
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError):
|
|
return []
|
|
if not isinstance(result, list):
|
|
return []
|
|
rows = []
|
|
for item in result:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
address = str(item.get("active-address") or item.get("address") or "").strip()
|
|
if not address:
|
|
continue
|
|
rows.append({
|
|
"address": address,
|
|
"mac": str(item.get("active-mac-address") or item.get("mac-address") or "").strip(),
|
|
"hostname": str(item.get("host-name") or "").strip(),
|
|
"status": str(item.get("status") or "").strip(),
|
|
"server": str(item.get("server") or "").strip(),
|
|
"expires_after": str(item.get("expires-after") or "").strip(),
|
|
"last_seen": str(item.get("last-seen") or "").strip(),
|
|
})
|
|
return rows
|
|
|
|
def unblock_ip(self, address: str) -> BlockResult:
|
|
if not self.configured:
|
|
return BlockResult(False, "RouterOS credentials are not configured")
|
|
try:
|
|
existing = self._request(
|
|
"GET",
|
|
"/rest/ip/firewall/address-list",
|
|
query={"list": self.address_list, "address": address},
|
|
)
|
|
if not isinstance(existing, list) or not existing:
|
|
return BlockResult(True, "address is not present in RouterOS address-list")
|
|
removed = 0
|
|
for item in existing:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
item_id = item.get(".id") or item.get("id")
|
|
if not item_id:
|
|
continue
|
|
self._request(
|
|
"DELETE",
|
|
"/rest/ip/firewall/address-list/" + urllib.parse.quote(str(item_id), safe="*"),
|
|
)
|
|
removed += 1
|
|
return BlockResult(True, f"removed {removed} RouterOS address-list entr{'y' if removed == 1 else 'ies'}")
|
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ValueError) as exc:
|
|
return BlockResult(False, f"RouterOS REST error: {exc}")
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
body: dict | None = None,
|
|
query: dict | None = None,
|
|
):
|
|
url = self.base_url + path
|
|
if query:
|
|
url += "?" + urllib.parse.urlencode(query)
|
|
data = None
|
|
headers = {"Accept": "application/json"}
|
|
if body is not None:
|
|
data = json.dumps(body).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
|
|
token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
|
|
headers["Authorization"] = f"Basic {token}"
|
|
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
|
|
context = None
|
|
if url.lower().startswith("https://") and not self.verify_tls:
|
|
context = ssl._create_unverified_context()
|
|
|
|
with urllib.request.urlopen(request, timeout=self.timeout, context=context) as response:
|
|
raw = response.read()
|
|
if not raw:
|
|
return None
|
|
return json.loads(raw.decode("utf-8"))
|