From ee9a2cfcee723a684b02c10954528ef3514286f5 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Thu, 21 May 2026 23:16:11 +0000 Subject: [PATCH] Refresh RackPeek and UniFi network inventory --- automation/bin/unifi_network.py | 190 ++++++++++++++++++++++++ dev/rackpeek/README.md | 4 + dev/rackpeek/config.yaml | 166 +++++++++++++++++++++ docs/architecture/NETWORKING_MODEL.md | 10 +- docs/operations/UNIFI_NETWORK_ACCESS.md | 106 +++++++++++++ 5 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 automation/bin/unifi_network.py create mode 100644 docs/operations/UNIFI_NETWORK_ACCESS.md diff --git a/automation/bin/unifi_network.py b/automation/bin/unifi_network.py new file mode 100644 index 0000000..60aa922 --- /dev/null +++ b/automation/bin/unifi_network.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Minimal Doris helper for UniFi OS / Network inventory reads. + +Reads credentials from automation/.env by default and performs a cookie-based +login against UniFi OS, then calls Network app endpoints through +`/proxy/network/api/s//...`. + +Default action prints a compact JSON summary suitable for RackPeek refresh work. +No secrets are printed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import ssl +import sys +from http.cookiejar import CookieJar +from pathlib import Path +from typing import Any +from urllib import error, request + +DEFAULT_TIMEOUT = 20 +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent +DEFAULT_ENV_PATH = REPO_ROOT / 'automation' / '.env' + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + for raw_line in path.read_text(encoding='utf-8').splitlines(): + line = raw_line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + key, value = line.split('=', 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def env_bool(name: str, default: bool) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + + +class UniFiClient: + def __init__(self, base_url: str, username: str, password: str, *, verify_tls: bool = True, timeout: int = DEFAULT_TIMEOUT) -> None: + self.base_url = base_url.rstrip('/') + self.username = username + self.password = password + self.timeout = timeout + self.cookie_jar = CookieJar() + handlers: list[Any] = [request.HTTPCookieProcessor(self.cookie_jar)] + if not verify_tls: + handlers.append(request.HTTPSHandler(context=ssl._create_unverified_context())) + self.opener = request.build_opener(*handlers) + + def _json_request(self, method: str, path: str, body: dict[str, Any] | None = None) -> tuple[int, Any]: + url = self.base_url + path + data = None + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'doris-unifi-helper/1.0', + } + if body is not None: + data = json.dumps(body).encode('utf-8') + req = request.Request(url, data=data, method=method.upper(), headers=headers) + try: + with self.opener.open(req, timeout=self.timeout) as resp: + raw = resp.read().decode('utf-8', 'replace') + ctype = resp.headers.get('Content-Type', '') + payload = json.loads(raw) if 'json' in ctype else raw + return resp.status, payload + except error.HTTPError as exc: + raw = exc.read().decode('utf-8', 'replace') + try: + payload = json.loads(raw) + except Exception: + payload = raw + return exc.code, payload + + def login(self) -> None: + status, payload = self._json_request('POST', '/api/auth/login', { + 'username': self.username, + 'password': self.password, + 'rememberMe': True, + }) + if status >= 300: + raise SystemExit(f'UniFi login failed: HTTP {status} {payload!r}') + + def get(self, path: str) -> Any: + status, payload = self._json_request('GET', path) + if status >= 300: + raise SystemExit(f'UniFi GET {path} failed: HTTP {status} {payload!r}') + return payload + + def site_path(self, site: str, suffix: str) -> str: + suffix = suffix.lstrip('/') + return f'/proxy/network/api/s/{site}/{suffix}' + + +def response_data(payload: Any) -> Any: + if isinstance(payload, dict) and 'data' in payload: + return payload['data'] + return payload + + +def device_summary(devices: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for dev in devices: + out.append({ + 'name': dev.get('name') or dev.get('hostname') or dev.get('mac'), + 'model': dev.get('model'), + 'type': dev.get('type') or dev.get('device_type'), + 'state': dev.get('state'), + 'ip': dev.get('ip'), + 'mac': dev.get('mac'), + 'version': dev.get('version'), + 'uplink': ((dev.get('uplink') or {}).get('name') if isinstance(dev.get('uplink'), dict) else None), + }) + return out + + +def client_summary(clients: list[dict[str, Any]]) -> list[dict[str, Any]]: + out = [] + for client in clients: + out.append({ + 'name': client.get('name') or client.get('hostname') or client.get('mac'), + 'ip': client.get('ip'), + 'mac': client.get('mac'), + 'network': client.get('network'), + 'ap_mac': client.get('ap_mac'), + 'oui': client.get('oui'), + }) + return out + + +def build_summary(client: UniFiClient, site: str) -> dict[str, Any]: + devices = response_data(client.get(client.site_path(site, 'stat/device'))) + health = response_data(client.get(client.site_path(site, 'stat/health'))) + clients = response_data(client.get(client.site_path(site, 'stat/sta'))) + return { + 'site': site, + 'device_count': len(devices), + 'client_count': len(clients), + 'devices': device_summary(devices), + 'health': health, + 'clients': client_summary(clients), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description='Read UniFi Network inventory for Doris.') + parser.add_argument('--env-file', default=str(DEFAULT_ENV_PATH), help='Path to .env containing UNIFI_* vars') + parser.add_argument('--site', help='UniFi site name; defaults to UNIFI_SITE or default') + parser.add_argument('--raw', action='store_true', help='Print raw device/client/health payloads instead of compact summary') + parser.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT) + args = parser.parse_args() + + load_env_file(Path(args.env_file)) + base_url = os.environ.get('UNIFI_BASE_URL', 'https://10.5.0.1') + username = os.environ.get('UNIFI_USERNAME') + password = os.environ.get('UNIFI_PASSWORD') + site = args.site or os.environ.get('UNIFI_SITE', 'default') + verify_tls = env_bool('UNIFI_VERIFY_TLS', False) + + if not username or not password: + raise SystemExit('Missing UNIFI_USERNAME or UNIFI_PASSWORD in env file/environment.') + + client = UniFiClient(base_url, username, password, verify_tls=verify_tls, timeout=args.timeout) + client.login() + + if args.raw: + payload = { + 'devices': response_data(client.get(client.site_path(site, 'stat/device'))), + 'health': response_data(client.get(client.site_path(site, 'stat/health'))), + 'clients': response_data(client.get(client.site_path(site, 'stat/sta'))), + } + else: + payload = build_summary(client, site) + + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/dev/rackpeek/README.md b/dev/rackpeek/README.md index 9b35356..33981dd 100644 --- a/dev/rackpeek/README.md +++ b/dev/rackpeek/README.md @@ -9,11 +9,15 @@ This directory is the git-tracked RackPeek inventory for the homelab. Update this inventory from the canonical repo docs first: - `docs/architecture/ARCHITECTURE_OVERVIEW.md` - `docs/architecture/SERVICES_DIRECTORY.md` +- `docs/architecture/NETWORKING_MODEL.md` - `docs/servers/PLAUSIBLEDENABILITY.md` - `docs/servers/SERENITY.md` - `docs/servers/NOMAD.md` - `docs/servers/ROCINANTE.md` +For UniFi-managed network gear, refresh from the validated read-only helper after reconciling the docs: +- `automation/bin/unifi_network.py` + ## Runtime wiring The `dev/docker-compose.yaml` RackPeek service mounts this repo directory directly at `/app/config` on PD: - host path: `/mnt/docker-ssd/docker/compose/dev/rackpeek` diff --git a/dev/rackpeek/config.yaml b/dev/rackpeek/config.yaml index 516d4cf..e66d7d6 100644 --- a/dev/rackpeek/config.yaml +++ b/dev/rackpeek/config.yaml @@ -126,6 +126,118 @@ resources: Current operational note from 2026-05-21: - Unreachable from N.O.M.A.D. on ports 11434 and 8787 during Doris validation. + - kind: Server + name: unifi-udm-pro + ports: + - type: rj45 + speed: 10 + count: 8 + - type: sfp+ + speed: 10 + count: 2 + tags: + - prod + - network + - unifi + - gateway + labels: + ip: 10.5.0.1 + management-ip: 10.5.0.1 + wan-ip: 69.166.182.157 + os: UniFi OS 5.1.11.33023 + mac: d0:21:f9:75:8a:ff + serial: D021F9758AFF + notes: |- + UniFi gateway and controller. + UniFi device name: UDM Pro - Old. + WAN uplink currently active on eth9. + + - kind: Server + name: unifi-usw-24-poe + ports: + - type: rj45 + speed: 1 + count: 24 + tags: + - prod + - network + - unifi + - switch + - poe + labels: + ip: 10.5.0.10 + management-ip: 10.5.0.10 + os: UniFi Switch 7.4.1.16850 + mac: ac:8b:a9:ad:ab:86 + serial: AC8BA9ADAB86 + notes: |- + UniFi device name: USW-24-PoE. + Active in UniFi; management uplink reported on eth0. + + - kind: Server + name: unifi-usw-pro-hd-24 + ports: + - type: rj45 + speed: 1 + count: 24 + tags: + - prod + - network + - unifi + - switch + labels: + ip: 10.5.0.135 + management-ip: 10.5.0.135 + os: UniFi Switch 7.4.1.16850 + mac: 8c:30:66:d0:9f:48 + serial: 8C3066D09F48 + notes: |- + UniFi device name: USW Pro HD 24. + Active in UniFi; management uplink reported on eth0. + + - kind: Server + name: unifi-u7-pro + ports: + - type: rj45 + speed: 1 + count: 1 + tags: + - prod + - network + - unifi + - ap + labels: + ip: 10.5.0.21 + management-ip: 10.5.0.21 + os: UniFi AP 8.5.21.18681 + mac: 8c:ed:e1:8a:58:54 + serial: 8CEDE18A5854 + notes: |- + UniFi device name: U7 Pro. + Active in UniFi; management uplink reported on eth0. + + - kind: Server + name: unifi-u6-lr + ports: + - type: rj45 + speed: 1 + count: 1 + tags: + - prod + - network + - unifi + - ap + - disconnected + labels: + ip: 10.5.0.20 + management-ip: 10.5.0.20 + os: UniFi AP 6.7.41.15623 + mac: d0:21:f9:69:a5:f1 + serial: D021F969A5F1 + notes: |- + UniFi device name: U6 LR. + Currently disconnected in UniFi as of 2026-05-21. + - kind: System type: Baremetal name: pd-truenas @@ -164,6 +276,46 @@ resources: runsOn: - rocinante + - kind: System + type: Embedded + name: unifi-gateway + os: unifi-os-5.1.11.33023 + ip: 10.5.0.1 + runsOn: + - unifi-udm-pro + + - kind: System + type: Embedded + name: unifi-usw-24-poe-firmware + os: unifi-switch-7.4.1.16850 + ip: 10.5.0.10 + runsOn: + - unifi-usw-24-poe + + - kind: System + type: Embedded + name: unifi-usw-pro-hd-24-firmware + os: unifi-switch-7.4.1.16850 + ip: 10.5.0.135 + runsOn: + - unifi-usw-pro-hd-24 + + - kind: System + type: Embedded + name: unifi-u7-pro-firmware + os: unifi-ap-8.5.21.18681 + ip: 10.5.0.21 + runsOn: + - unifi-u7-pro + + - kind: System + type: Embedded + name: unifi-u6-lr-firmware + os: unifi-ap-6.7.41.15623 + ip: 10.5.0.20 + runsOn: + - unifi-u6-lr + - kind: Service name: homepage network: @@ -256,6 +408,20 @@ resources: runsOn: - pd-truenas + - kind: Service + name: unifi-network-controller + network: + ip: 10.5.0.1 + port: 443 + protocol: TCP + url: https://10.5.0.1 + tags: + - internal + - network + - unifi + runsOn: + - unifi-gateway + - kind: Service name: authelia network: diff --git a/docs/architecture/NETWORKING_MODEL.md b/docs/architecture/NETWORKING_MODEL.md index 4cd7330..7217f38 100644 --- a/docs/architecture/NETWORKING_MODEL.md +++ b/docs/architecture/NETWORKING_MODEL.md @@ -2,8 +2,16 @@ ## Physical Network - **Gear:** Unifi throughout +- **Gateway / controller:** UDM Pro (`10.5.0.1`, WAN `69.166.182.157`) +- **Core / access switching:** + - USW-24-PoE (`10.5.0.10`) + - USW Pro HD 24 (`10.5.0.135`) +- **Wi-Fi access points:** + - U7 Pro (`10.5.0.21`) — active + - U6 LR (`10.5.0.20`) — currently disconnected in UniFi as of 2026-05-21 - **Main LAN:** 10.5.1.x (Trusted VLAN) -- **Other VLANs:** Cameras, Guest, IoT +- **Management VLAN:** 10.5.0.x +- **Other VLANs:** Cameras, Guest, IoT / Old IoT - **Serenity IP:** 10.5.1.5 - **N.O.M.A.D. IP:** 10.5.1.16 - **PlausibleDeniability:** 10.5.1.6 (static on LAN) diff --git a/docs/operations/UNIFI_NETWORK_ACCESS.md b/docs/operations/UNIFI_NETWORK_ACCESS.md new file mode 100644 index 0000000..ba949e4 --- /dev/null +++ b/docs/operations/UNIFI_NETWORK_ACCESS.md @@ -0,0 +1,106 @@ +# UniFi network operator access for Doris + +## Goal + +Give Doris safe, durable read access to the UniFi Network controller at `https://10.5.0.1` so she can: + +- verify gateway / switch / AP inventory +- refresh RackPeek network device data +- check client/device state during troubleshooting +- avoid using your full owner account for routine reads + +## Preferred access model + +Use a **dedicated local Doris account** on UniFi OS with the **least privilege that still allows Network inventory reads**. + +Target shape: + +- Account name: `doris` or `doris-unifi` +- Scope: **Network application only** +- Permission: **view-only / read-only** if UniFi exposes that role cleanly +- Local account: yes +- Cloud account sharing: no + +If UniFi's UI forces a broader role for API readability, use the narrowest app-scoped permission available and document the final role here. + +## Secrets placement + +Do **not** commit credentials to git. + +When the Doris UniFi account exists, store the live credential in: + +- PD live env: `/mnt/docker-ssd/docker/compose/automation/.env` +- synced encrypted backup: `/mnt/docker-ssd/docker/secrets/env/automation.env` + +Expected vars: + +```bash +UNIFI_BASE_URL=https://10.5.0.1 +UNIFI_USERNAME=doris +UNIFI_PASSWORD=REPLACE_ME +UNIFI_SITE=default +UNIFI_VERIFY_TLS=false +``` + +After editing the live `.env` on PD, run the normal secrets sync: + +```bash +export PATH="/mnt/docker-ssd/bin:$PATH" +bash /mnt/docker-ssd/docker/compose/scripts/sync-envs-to-secrets.sh +``` + +## Doris helper + +Repo helper: + +- `automation/bin/unifi_network.py` + +Default behavior: + +- loads `automation/.env` +- logs into UniFi OS with cookie auth +- queries Network app endpoints through `/proxy/network/api/s//...` +- prints a JSON inventory summary without exposing the password + +Examples: + +```bash +cd /mnt/docker-ssd/docker/compose +python3 automation/bin/unifi_network.py +python3 automation/bin/unifi_network.py --raw +``` + +## Validation checklist + +1. Confirm `https://10.5.0.1` is reachable from Doris's host. +2. Create the dedicated UniFi operator account. +3. Add the `UNIFI_*` vars to live `automation/.env` on PD. +4. Sync secrets repo. +5. Run: + +```bash +cd /mnt/docker-ssd/docker/compose +python3 automation/bin/unifi_network.py +``` + +Success means Doris can read: + +- device inventory +- controller health summary +- connected client list + +## Future integration + +Once access is verified, use the helper as an input for: + +- RackPeek network device refreshes +- dashboard network summaries +- targeted checks like "what AP is this client on" or "is switch X offline" + +## Current status + +- UniFi OS front door verified reachable at `https://10.5.0.1` +- unauthenticated API requests correctly return `401 Unauthorized` +- Doris helper scaffold exists in the repo +- dedicated Doris account validated end-to-end against the `default` site from N.O.M.A.D. +- current verified read scope is sufficient to read device inventory, controller health, and connected clients