Refresh RackPeek and UniFi network inventory
This commit is contained in:
190
automation/bin/unifi_network.py
Normal file
190
automation/bin/unifi_network.py
Normal file
@@ -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/<site>/...`.
|
||||
|
||||
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())
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
106
docs/operations/UNIFI_NETWORK_ACCESS.md
Normal file
106
docs/operations/UNIFI_NETWORK_ACCESS.md
Normal file
@@ -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/<site>/...`
|
||||
- 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
|
||||
Reference in New Issue
Block a user