Sync network redesign repo state and cutover artifacts

This commit is contained in:
Fizzlepoof
2026-05-22 20:16:21 +00:00
parent 4b1e3d4061
commit 9e6dde7695
59 changed files with 5389 additions and 244 deletions

View File

@@ -29,7 +29,7 @@ UNIFI_SITE=default
UNIFI_VERIFY_TLS=false
# Backup automation scaffolding
SERENITY_BACKUP_HOST=root@10.5.1.5
SERENITY_BACKUP_HOST=root@10.5.30.5
SERENITY_BACKUP_ROOT=/mnt/user/backups/plausible-deniability
PD_APPDATA_ROOT=/mnt/docker-ssd/docker/appdata
PD_DATABASES_ROOT=/mnt/docker-ssd/docker/databases

View File

@@ -44,7 +44,7 @@ Repo-side deployment prerequisites remain:
1. real values in `.env`
2. a writable dump destination on PD
3. SSH trust / key path for Serenity (`root@10.5.1.5` by default)
3. SSH trust / key path for Serenity (`root@10.5.30.5` by default)
- recommended known_hosts path on PD: `/home/truenas_admin/.ssh/known_hosts`
4. a real scheduler on the live host

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Stage low-risk UniFi objects for the network cutover.
Default mode is read-only dry-run. Pass --apply to actually create missing objects.
Currently stages:
- Servers network object
- Servers port profile
This is intentionally idempotent: if the objects already exist, it reports them and exits cleanly.
"""
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()
self.csrf_token: str | None = None
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-stage/1.0',
}
if method.upper() != 'GET' and self.csrf_token:
headers['X-Csrf-Token'] = self.csrf_token
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:
if method.upper() == 'POST':
self.csrf_token = resp.headers.get('X-Csrf-Token', self.csrf_token)
raw = resp.read().decode('utf-8', 'replace')
ctype = resp.headers.get('Content-Type', '')
payload = json.loads(raw) if 'json' in ctype and raw else raw
return resp.status, payload
except error.HTTPError as exc:
raw = exc.read().decode('utf-8', 'replace')
try:
payload = json.loads(raw) if raw else {}
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.get('data', payload) if isinstance(payload, dict) else payload
def post(self, path: str, body: dict[str, Any]) -> Any:
status, payload = self._json_request('POST', path, body)
if status >= 300:
raise SystemExit(f'UniFi POST {path} failed: HTTP {status} {payload!r}')
return payload.get('data', payload) if isinstance(payload, dict) else payload
def site_path(self, site: str, suffix: str) -> str:
suffix = suffix.lstrip('/')
return f'/proxy/network/api/s/{site}/{suffix}'
def find_by_name(items: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
return next((item for item in items if item.get('name') == name), None)
def servers_network_payload() -> dict[str, Any]:
return {
'name': 'Servers',
'purpose': 'corporate',
'enabled': True,
'networkgroup': 'LAN',
'setting_preference': 'manual',
'vlan_enabled': True,
'vlan': 30,
'ip_subnet': '10.5.30.1/24',
'dhcpd_enabled': True,
'dhcpd_start': '10.5.30.100',
'dhcpd_stop': '10.5.30.199',
'dhcpd_leasetime': 86400,
'dhcpd_gateway_enabled': False,
'dhcpd_dns_enabled': False,
'dhcpd_ntp_enabled': False,
'dhcpd_wins_enabled': False,
'dhcpd_time_offset_enabled': False,
'dhcpd_boot_enabled': False,
'dhcpd_conflict_checking': True,
'dhcp_relay_enabled': False,
'dhcp_relay_servers': [],
'domain_name': '',
'ipv6_interface_type': 'none',
'ipv6_setting_preference': 'manual',
'ipv6_aliases': [],
'ip_aliases': [],
'internet_access_enabled': True,
'network_isolation_enabled': False,
'gateway_type': 'default',
'nat_outbound_ip_addresses': [],
'mdns_enabled': True,
'lte_lan_enabled': True,
'auto_scale_enabled': True,
}
def build_servers_port_profile_payload(template: dict[str, Any], servers_network_id: str) -> dict[str, Any]:
payload = {
k: v for k, v in template.items()
if k not in {'_id', 'site_id', 'name', 'native_networkconf_id'}
}
payload['name'] = 'Servers'
payload['native_networkconf_id'] = servers_network_id
return payload
def main() -> int:
parser = argparse.ArgumentParser(description='Stage low-risk UniFi objects for the cutover.')
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('--apply', action='store_true', help='Actually create missing objects. Default is dry-run only.')
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()
networks = client.get(client.site_path(site, 'rest/networkconf'))
port_profiles = client.get(client.site_path(site, 'rest/portconf'))
result: dict[str, Any] = {
'mode': 'apply' if args.apply else 'dry-run',
'site': site,
'changes': [],
}
servers_network = find_by_name(networks, 'Servers')
if servers_network:
result['changes'].append({
'object': 'network',
'name': 'Servers',
'status': 'exists',
'id': servers_network.get('_id'),
'vlan': servers_network.get('vlan'),
'ip_subnet': servers_network.get('ip_subnet'),
})
else:
payload = servers_network_payload()
if args.apply:
created = client.post(client.site_path(site, 'rest/networkconf'), payload)
if isinstance(created, list):
created = created[0] if created else {}
servers_network = created
result['changes'].append({
'object': 'network',
'name': 'Servers',
'status': 'created',
'id': created.get('_id'),
'vlan': created.get('vlan'),
'ip_subnet': created.get('ip_subnet'),
})
else:
servers_network = {'_id': '<created-network-id>'}
result['changes'].append({
'object': 'network',
'name': 'Servers',
'status': 'would_create',
'payload': payload,
})
servers_port_profile = find_by_name(port_profiles, 'Servers')
if servers_port_profile:
result['changes'].append({
'object': 'port_profile',
'name': 'Servers',
'status': 'exists',
'id': servers_port_profile.get('_id'),
'native_networkconf_id': servers_port_profile.get('native_networkconf_id'),
})
else:
trusted_template = find_by_name(port_profiles, 'Trusted')
if not trusted_template:
raise SystemExit('Could not find existing Trusted port profile to clone.')
payload = build_servers_port_profile_payload(trusted_template, servers_network['_id'])
if args.apply:
created = client.post(client.site_path(site, 'rest/portconf'), payload)
if isinstance(created, list):
created = created[0] if created else {}
result['changes'].append({
'object': 'port_profile',
'name': 'Servers',
'status': 'created',
'id': created.get('_id'),
'native_networkconf_id': created.get('native_networkconf_id'),
})
else:
result['changes'].append({
'object': 'port_profile',
'name': 'Servers',
'status': 'would_create',
'payload': payload,
})
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())