diff --git a/README.md b/README.md index a4f3bd4..62256ff 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,97 @@ # unifi_respondd -This queries the API of a UniFi controller to get the current status of the Accesspoints and sends the information via the respondd protocol. Thus it can be picked up by `yanic` and other respondd queriers. +This tool queries controller APIs (UniFi, Omada, UISP, etc.) to get the current status of Access Points and sends the information via the respondd protocol. Thus it can be picked up by `yanic` and other respondd queriers. ## Overview ```mermaid graph TD; - A{"*respondd_main*"} -->| | B("*unifi_client*") + A{"*respondd_main*"} -->| | B("*provider*") A -->| | C("*respondd_client*") - B -->|"RestFul API"| D("unifi_controller") + B -->|"RestFul API"| D("unifi_controller / omada / uisp") C -->|"Subscribe"| E("multicast") C -->|"Send per interval / On multicast request"| F("unicast") G{"yanic"} -->|"Request metrics"| E F -->|"Receive"| G ``` -## Config File: +## Multi-Provider Support + +The tool now supports multiple controller providers in a single configuration. You can connect to multiple UniFi controllers, Omada controllers, or UISP systems simultaneously. + +### Multi-Provider Config File (New Format): +```yaml +providers: + # UniFi Controller 1 + - type: unifi + config: + controller_url: unifi1.lan + controller_port: 8443 + username: ubnt + password: ubnt + ssid_regex: .*freifunk.* + offloader_mac: + SiteName: 00:00:00:00:00:00 + nodelist: https://MAPURL/data/meshviewer.json + version: v5 + ssl_verify: True + fallback_domain: "unifi_provider1" + + # UniFi Controller 2 + - type: unifi + config: + controller_url: unifi2.lan + controller_port: 8443 + username: admin + password: admin123 + ssid_regex: .*freifunk.* + offloader_mac: + SiteA: 11:11:11:11:11:11 + nodelist: https://MAPURL/data/meshviewer.json + version: v5 + ssl_verify: True + fallback_domain: "unifi_provider2" + + # Future: TP-Link Omada support + # - type: omada + # config: + # controller_url: omada.lan + # ... + + # Future: Ubiquiti UISP support + # - type: uisp + # config: + # controller_url: uisp.lan + # ... + +# Respondd settings +multicast_enabled: false +multicast_address: ff05::2:1001 +multicast_port: 1001 +unicast_address: fe80::68ff:94ff:fe00:1504 +unicast_port: 10001 +interface: eth0 +verbose: true +logging_config: + formatters: + standard: + format: '%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d] %(message)s' + handlers: + console: + class: logging.StreamHandler + formatter: standard + root: + handlers: + - console + level: DEBUG + version: 1 +``` + +See `unifi_respondd.multi-provider.yaml.example` for a complete example. + +### Legacy Config File (Single UniFi Controller): +The legacy single-controller format is still supported for backward compatibility: + ```yaml controller_url: unifi.lan controller_port: 8443 diff --git a/unifi_respondd.multi-provider.yaml.example b/unifi_respondd.multi-provider.yaml.example new file mode 100644 index 0000000..6fe0277 --- /dev/null +++ b/unifi_respondd.multi-provider.yaml.example @@ -0,0 +1,74 @@ +# Multi-Provider Configuration Example +# This configuration format allows you to connect to multiple controllers/providers + +providers: + # UniFi Controller 1 + - type: unifi + config: + controller_url: unifi1.lan + controller_port: 8443 + username: ubnt + password: ubnt + ssid_regex: .*freifunk.* + offloader_mac: + SiteName: 00:00:00:00:00:00 + SiteName2: 00:00:00:00:00:00 + nodelist: https://MAPURL/data/meshviewer.json + version: v5 + ssl_verify: True + fallback_domain: "unifi_provider1" + + # UniFi Controller 2 (optional - example of multiple UniFi controllers) + - type: unifi + config: + controller_url: unifi2.lan + controller_port: 8443 + username: admin + password: admin123 + ssid_regex: .*freifunk.* + offloader_mac: + SiteA: 11:11:11:11:11:11 + nodelist: https://MAPURL/data/meshviewer.json + version: v5 + ssl_verify: True + fallback_domain: "unifi_provider2" + + # TP-Link Omada (future implementation) + # - type: omada + # config: + # controller_url: omada.lan + # controller_port: 8043 + # username: admin + # password: password + # ssid_regex: .*freifunk.* + # nodelist: https://MAPURL/data/meshviewer.json + + # Ubiquiti UISP (future implementation) + # - type: uisp + # config: + # controller_url: uisp.lan + # api_token: your_api_token_here + # ssid_regex: .*freifunk.* + # nodelist: https://MAPURL/data/meshviewer.json + +# Respondd settings (same for all providers) +multicast_enabled: false +multicast_address: ff05::2:1001 +multicast_port: 1001 +unicast_address: fe80::68ff:94ff:fe00:1504 +unicast_port: 10001 +interface: eth0 +verbose: true +logging_config: + formatters: + standard: + format: '%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d] %(message)s' + handlers: + console: + class: logging.StreamHandler + formatter: standard + root: + handlers: + - console + level: DEBUG + version: 1 diff --git a/unifi_respondd/config.py b/unifi_respondd/config.py index 5801568..7b12b1d 100644 --- a/unifi_respondd/config.py +++ b/unifi_respondd/config.py @@ -18,25 +18,44 @@ class ConfigFileNotFoundError(Error): """File could not be found on disk.""" +@dataclasses.dataclass +class ProviderConfig: + """Configuration for a single provider. + Attributes: + type: The type of provider (e.g., 'unifi', 'omada', 'uisp'). + config: Provider-specific configuration dictionary. + """ + + type: str + config: Dict[str, Any] + + @dataclasses.dataclass class Config: """A representation of the configuration file. Attributes: - controller_url: The unifi controller URL. - controller_port: The unifi Controller port. - username: The username for unifi controller. - password: The password for unifi controller. + providers: List of provider configurations (new format). + multicast_address: The multicast address for respondd. + multicast_port: The multicast port for respondd. + unicast_address: The unicast address for respondd. + unicast_port: The unicast port for respondd. + interface: The network interface to use. + verbose: Enable verbose logging. + multicast_enabled: Enable multicast support. + + # Legacy fields for backward compatibility + controller_url: The unifi controller URL (deprecated). + controller_port: The unifi Controller port (deprecated). + username: The username for unifi controller (deprecated). + password: The password for unifi controller (deprecated). + ssid_regex: SSID regex pattern (deprecated). + offloader_mac: Offloader MAC addresses (deprecated). + nodelist: Nodelist URL (deprecated). + fallback_domain: Fallback domain (deprecated). + version: UniFi version (deprecated). + ssl_verify: SSL verification (deprecated). """ - controller_url: str - controller_port: int - username: str - password: str - ssid_regex: str - offloader_mac: Dict[str, str] - nodelist: str - fallback_domain: str - multicast_address: str multicast_port: int unicast_address: str @@ -45,29 +64,61 @@ class Config: verbose: bool = False multicast_enabled: bool = True - version: str = "v5" - ssl_verify: bool = True + # New multi-provider support + providers: Optional[List[ProviderConfig]] = None + + # Legacy single-controller fields (for backward compatibility) + controller_url: Optional[str] = None + controller_port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + ssid_regex: Optional[str] = None + offloader_mac: Optional[Dict[str, str]] = None + nodelist: Optional[str] = None + fallback_domain: Optional[str] = None + version: Optional[str] = None + ssl_verify: Optional[bool] = None @classmethod - def from_dict(cls, cfg: Dict[str, str]) -> "Config": + def from_dict(cls, cfg: Dict[str, Any]) -> "Config": """Creates a Config object from a configuration file. + + Supports both legacy format (single UniFi controller) and new format (multiple providers). + Arguments: cfg: The configuration file as a dict. Returns: A Config object. """ + # Check if this is the new multi-provider format + providers = None + if "providers" in cfg: + providers = [ + ProviderConfig(type=p["type"], config=p["config"]) + for p in cfg["providers"] + ] + + # Handle legacy format - if no providers but has controller_url, create a provider + if providers is None and "controller_url" in cfg: + # Legacy format detected - create a single UniFi provider from root config + provider_config = { + "controller_url": cfg["controller_url"], + "controller_port": cfg["controller_port"], + "username": cfg["username"], + "password": cfg["password"], + "ssid_regex": cfg["ssid_regex"], + "offloader_mac": cfg["offloader_mac"], + "nodelist": cfg["nodelist"], + "fallback_domain": cfg.get( + "fallback_domain", "unifi_respondd_fallback" + ), + "version": cfg.get("version", "v5"), + "ssl_verify": cfg.get("ssl_verify", True), + } + providers = [ProviderConfig(type="unifi", config=provider_config)] return cls( - controller_url=cfg["controller_url"], - controller_port=cfg["controller_port"], - username=cfg["username"], - password=cfg["password"], - ssid_regex=cfg["ssid_regex"], - offloader_mac=cfg["offloader_mac"], - nodelist=cfg["nodelist"], - fallback_domain=cfg.get("fallback_domain", "unifi_respondd_fallback"), - version=cfg["version"], - ssl_verify=cfg["ssl_verify"], + providers=providers, multicast_enabled=cfg["multicast_enabled"], multicast_address=cfg["multicast_address"], multicast_port=cfg["multicast_port"], @@ -75,6 +126,17 @@ def from_dict(cls, cfg: Dict[str, str]) -> "Config": unicast_port=cfg["unicast_port"], interface=cfg["interface"], verbose=cfg["verbose"], + # Legacy fields (optional, only populated in legacy format) + controller_url=cfg.get("controller_url"), + controller_port=cfg.get("controller_port"), + username=cfg.get("username"), + password=cfg.get("password"), + ssid_regex=cfg.get("ssid_regex"), + offloader_mac=cfg.get("offloader_mac"), + nodelist=cfg.get("nodelist"), + fallback_domain=cfg.get("fallback_domain", "unifi_respondd_fallback"), + version=cfg.get("version", "v5"), + ssl_verify=cfg.get("ssl_verify", True), ) @@ -105,6 +167,9 @@ def load_config() -> Dict[str, str]: return config except (KeyError, TypeError) as e: print("Failed to lint file: %s", e) + print( + "Make sure your config has either 'providers' list or legacy UniFi controller fields" + ) sys.exit(2) diff --git a/unifi_respondd/provider.py b/unifi_respondd/provider.py new file mode 100644 index 0000000..ceeb2d1 --- /dev/null +++ b/unifi_respondd/provider.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 + +from abc import ABC, abstractmethod +from typing import List, Dict, Any +from unifi_respondd import unifi_client + + +class Provider(ABC): + """Base class for all providers that can supply access point information.""" + + @abstractmethod + def get_accesspoints(self) -> unifi_client.Accesspoints: + """Fetches access point information from the provider. + + Returns: + Accesspoints object containing a list of access points. + """ + pass + + @abstractmethod + def get_provider_type(self) -> str: + """Returns the type of provider (e.g., 'unifi', 'omada', 'uisp').""" + pass + + +class UnifiProvider(Provider): + """Provider implementation for UniFi controllers.""" + + def __init__(self, config: Dict[str, Any]): + """Initializes the UniFi provider with configuration. + + Args: + config: Dictionary containing UniFi-specific configuration: + - controller_url: The UniFi controller URL + - controller_port: The UniFi controller port + - username: Username for authentication + - password: Password for authentication + - version: UniFi version (default: "v5") + - ssl_verify: Whether to verify SSL certificates (default: True) + - ssid_regex: Regex pattern to filter SSIDs + - offloader_mac: Dictionary mapping site names to offloader MACs + - nodelist: URL to the nodelist/meshviewer JSON + - fallback_domain: Fallback domain name + """ + self.controller_url = config["controller_url"] + self.controller_port = config["controller_port"] + self.username = config["username"] + self.password = config["password"] + self.version = config.get("version", "v5") + self.ssl_verify = config.get("ssl_verify", True) + self.ssid_regex = config["ssid_regex"] + self.offloader_mac = config["offloader_mac"] + self.nodelist = config["nodelist"] + self.fallback_domain = config.get("fallback_domain", "unifi_respondd_fallback") + + def get_accesspoints(self) -> unifi_client.Accesspoints: + """Fetches access point information from UniFi controller.""" + # Use the existing unifi_client logic but with this provider's config + return unifi_client.get_infos_from_config( + controller_url=self.controller_url, + controller_port=self.controller_port, + username=self.username, + password=self.password, + version=self.version, + ssl_verify=self.ssl_verify, + ssid_regex=self.ssid_regex, + offloader_mac=self.offloader_mac, + nodelist=self.nodelist, + fallback_domain=self.fallback_domain, + ) + + def get_provider_type(self) -> str: + """Returns 'unifi' as the provider type.""" + return "unifi" + + +def create_provider(provider_type: str, config: Dict[str, Any]) -> Provider: + """Factory function to create a provider instance. + + Args: + provider_type: Type of provider to create (e.g., 'unifi') + config: Configuration dictionary for the provider + + Returns: + Provider instance + + Raises: + ValueError: If provider_type is not supported + """ + if provider_type == "unifi": + return UnifiProvider(config) + else: + raise ValueError(f"Unsupported provider type: {provider_type}") diff --git a/unifi_respondd/respondd_client.py b/unifi_respondd/respondd_client.py index bb2d666..2e355de 100644 --- a/unifi_respondd/respondd_client.py +++ b/unifi_respondd/respondd_client.py @@ -9,6 +9,7 @@ import dataclasses from dataclasses_json import dataclass_json from unifi_respondd import unifi_client +from unifi_respondd import provider from unifi_respondd import logger from typing import List, Dict @@ -243,6 +244,19 @@ def __init__(self, config): self._timeStop = time.time() self._sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + # Initialize providers based on config + self._providers = [] + if config.providers: + for provider_cfg in config.providers: + try: + p = provider.create_provider(provider_cfg.type, provider_cfg.config) + self._providers.append(p) + logger.info(f"Initialized provider: {provider_cfg.type}") + except Exception as e: + logger.error( + f"Failed to initialize provider {provider_cfg.type}: {e}" + ) + @property def _nodeinfos(self): return self.getNodeInfos() @@ -413,8 +427,28 @@ def start(self): else: self.sendUnicast() self._timeStart = time.time() - self._aps = unifi_client.get_infos() - if self._aps is None: + + # Fetch access points from all configured providers + all_aps = unifi_client.Accesspoints(accesspoints=[]) + if self._providers: + # New multi-provider mode + for prov in self._providers: + try: + provider_aps = prov.get_accesspoints() + if provider_aps: + all_aps.accesspoints.extend(provider_aps.accesspoints) + except Exception as e: + logger.error( + f"Error fetching from provider {prov.get_provider_type()}: {e}" + ) + else: + # Legacy mode - use backward compatibility function + provider_aps = unifi_client.get_infos() + if provider_aps: + all_aps = provider_aps + + self._aps = all_aps + if self._aps is None or not self._aps.accesspoints: continue if msgSplit[0] == "GET": # multi_request for request in msgSplit[1:]: diff --git a/unifi_respondd/unifi_client.py b/unifi_respondd/unifi_client.py index 9c47442..46b7dee 100644 --- a/unifi_respondd/unifi_client.py +++ b/unifi_respondd/unifi_client.py @@ -78,12 +78,12 @@ class Accesspoints: accesspoints: List[Accesspoint] -def get_client_count_for_ap(ap_mac, clients, cfg): +def get_client_count_for_ap(ap_mac, clients, ssid_regex): """This function returns the number total clients, 2,4Ghz clients and 5Ghz clients connected to an AP.""" client5_count = 0 client24_count = 0 for client in clients: - if re.search(cfg.ssid_regex, client.get("essid", ""), re.IGNORECASE): + if re.search(ssid_regex, client.get("essid", ""), re.IGNORECASE): if client.get("ap_mac", "No mac") == ap_mac: if client.get("channel", 0) > 14: client5_count += 1 @@ -92,7 +92,7 @@ def get_client_count_for_ap(ap_mac, clients, cfg): return client24_count + client5_count, client24_count, client5_count -def get_ap_channel_usage(ssids, cfg): +def get_ap_channel_usage(ssids, ssid_regex): """This function returns the channels used for the Freifunk SSIDs""" channel5 = None rx_bytes5 = None @@ -101,7 +101,7 @@ def get_ap_channel_usage(ssids, cfg): rx_bytes24 = None tx_bytes24 = None for ssid in ssids: - if re.search(cfg.ssid_regex, ssid.get("essid", ""), re.IGNORECASE): + if re.search(ssid_regex, ssid.get("essid", ""), re.IGNORECASE): channel = ssid.get("channel", 0) rx_bytes = ssid.get("rx_bytes", 0) tx_bytes = ssid.get("tx_bytes", 0) @@ -139,18 +139,44 @@ def scrape(url): logger.error("Error: %s" % (ex)) -def get_infos(): - """This function gathers all the information and returns a list of Accesspoint objects.""" - cfg = config.Config.from_dict(config.load_config()) - ffnodes = scrape(cfg.nodelist) +def get_infos_from_config( + controller_url, + controller_port, + username, + password, + version, + ssl_verify, + ssid_regex, + offloader_mac, + nodelist, + fallback_domain, +): + """This function gathers all the information from a UniFi controller and returns a list of Accesspoint objects. + + Args: + controller_url: The UniFi controller URL + controller_port: The UniFi controller port + username: Username for authentication + password: Password for authentication + version: UniFi version + ssl_verify: Whether to verify SSL certificates + ssid_regex: Regex pattern to filter SSIDs + offloader_mac: Dictionary mapping site names to offloader MACs + nodelist: URL to the nodelist/meshviewer JSON + fallback_domain: Fallback domain name + + Returns: + Accesspoints object containing list of Accesspoint objects + """ + ffnodes = scrape(nodelist) try: c = Controller( - host=cfg.controller_url, - username=cfg.username, - password=cfg.password, - port=cfg.controller_port, - version=cfg.version, - ssl_verify=cfg.ssl_verify, + host=controller_url, + username=username, + password=password, + port=controller_port, + version=version, + ssl_verify=ssl_verify, ) except Exception as ex: logger.error("Error: %s" % (ex)) @@ -158,15 +184,15 @@ def get_infos(): geolookup = Nominatim(user_agent="ffmuc_respondd") aps = Accesspoints(accesspoints=[]) for site in c.get_sites(): - if cfg.version == "UDMP-unifiOS": + if version == "UDMP-unifiOS": c = Controller( - host=cfg.controller_url, - username=cfg.username, - password=cfg.password, - port=cfg.controller_port, - version=cfg.version, + host=controller_url, + username=username, + password=password, + port=controller_port, + version=version, site_id=site["name"], - ssl_verify=cfg.ssl_verify, + ssl_verify=ssl_verify, ) else: try: @@ -189,9 +215,7 @@ def get_infos(): rx = 0 if ssids is not None: for ssid in ssids: - if re.search( - cfg.ssid_regex, ssid.get("essid", ""), re.IGNORECASE - ): + if re.search(ssid_regex, ssid.get("essid", ""), re.IGNORECASE): containsSSID = True tx = tx + ssid.get("tx_bytes", 0) rx = rx + ssid.get("rx_bytes", 0) @@ -200,7 +224,9 @@ def get_infos(): client_count, client_count24, client_count5, - ) = get_client_count_for_ap(ap.get("mac", None), clients, cfg) + ) = get_client_count_for_ap( + ap.get("mac", None), clients, ssid_regex + ) ( channel5, @@ -209,7 +235,7 @@ def get_infos(): channel24, rx_bytes24, tx_bytes24, - ) = get_ap_channel_usage(ssids, cfg) + ) = get_ap_channel_usage(ssids, ssid_regex) lat, lon = 0, 0 neighbour_macs = [] @@ -221,14 +247,14 @@ def get_infos(): except: pass try: - neighbour_macs.append(cfg.offloader_mac.get(site["desc"], None)) - offloader_id = cfg.offloader_mac.get(site["desc"], "").replace( + neighbour_macs.append(offloader_mac.get(site["desc"], None)) + offloader_id = offloader_mac.get(site["desc"], "").replace( ":", "" ) offloader = list( filter( lambda x: x["mac"] - == cfg.offloader_mac.get(site["desc"], ""), + == offloader_mac.get(site["desc"], ""), ffnodes["nodes"], ) )[0] @@ -276,12 +302,44 @@ def get_infos(): gateway6=offloader.get("gateway6", None), gateway_nexthop=offloader_id, neighbour_macs=neighbour_macs, - domain_code=offloader.get("domain", cfg.fallback_domain), + domain_code=offloader.get("domain", fallback_domain), ) ) return aps +def get_infos(): + """This function gathers all the information and returns a list of Accesspoint objects. + + This is a wrapper function that maintains backward compatibility with the existing API. + It loads configuration from the default config file and calls get_infos_from_config. + + Note: This function only works with legacy single-controller configuration format. + For multi-provider configurations, use the ResponddClient class directly. + """ + cfg = config.Config.from_dict(config.load_config()) + + # Check if this is a multi-provider config + if cfg.providers and not cfg.controller_url: + logger.error( + "get_infos() called with multi-provider config. Use ResponddClient instead." + ) + return Accesspoints(accesspoints=[]) + + return get_infos_from_config( + controller_url=cfg.controller_url, + controller_port=cfg.controller_port, + username=cfg.username, + password=cfg.password, + version=cfg.version, + ssl_verify=cfg.ssl_verify, + ssid_regex=cfg.ssid_regex, + offloader_mac=cfg.offloader_mac, + nodelist=cfg.nodelist, + fallback_domain=cfg.fallback_domain, + ) + + def main(): """This function is the main function, it's only executed if we aren't imported.""" print(get_infos())