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"))