Refresh RackPeek and UniFi network inventory

This commit is contained in:
Fizzlepoof
2026-05-21 23:16:11 +00:00
parent 92f88178e7
commit ee9a2cfcee
5 changed files with 475 additions and 1 deletions

View 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())

View File

@@ -9,11 +9,15 @@ This directory is the git-tracked RackPeek inventory for the homelab.
Update this inventory from the canonical repo docs first: Update this inventory from the canonical repo docs first:
- `docs/architecture/ARCHITECTURE_OVERVIEW.md` - `docs/architecture/ARCHITECTURE_OVERVIEW.md`
- `docs/architecture/SERVICES_DIRECTORY.md` - `docs/architecture/SERVICES_DIRECTORY.md`
- `docs/architecture/NETWORKING_MODEL.md`
- `docs/servers/PLAUSIBLEDENABILITY.md` - `docs/servers/PLAUSIBLEDENABILITY.md`
- `docs/servers/SERENITY.md` - `docs/servers/SERENITY.md`
- `docs/servers/NOMAD.md` - `docs/servers/NOMAD.md`
- `docs/servers/ROCINANTE.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 ## Runtime wiring
The `dev/docker-compose.yaml` RackPeek service mounts this repo directory directly at `/app/config` on PD: 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` - host path: `/mnt/docker-ssd/docker/compose/dev/rackpeek`

View File

@@ -126,6 +126,118 @@ resources:
Current operational note from 2026-05-21: Current operational note from 2026-05-21:
- Unreachable from N.O.M.A.D. on ports 11434 and 8787 during Doris validation. - 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 - kind: System
type: Baremetal type: Baremetal
name: pd-truenas name: pd-truenas
@@ -164,6 +276,46 @@ resources:
runsOn: runsOn:
- rocinante - 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 - kind: Service
name: homepage name: homepage
network: network:
@@ -256,6 +408,20 @@ resources:
runsOn: runsOn:
- pd-truenas - 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 - kind: Service
name: authelia name: authelia
network: network:

View File

@@ -2,8 +2,16 @@
## Physical Network ## Physical Network
- **Gear:** Unifi throughout - **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) - **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 - **Serenity IP:** 10.5.1.5
- **N.O.M.A.D. IP:** 10.5.1.16 - **N.O.M.A.D. IP:** 10.5.1.16
- **PlausibleDeniability:** 10.5.1.6 (static on LAN) - **PlausibleDeniability:** 10.5.1.6 (static on LAN)

View 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