The guest network blocks private ranges twice: a zone default on the gateway, and the guest hotspot's Post-Authorization Restrictions, which the access points enforce on their own. With only a firewall allow, a phone on Waters Guest worked for about two minutes and then timed out. docs/examples/waters-church-na/ gains the three scripts used to fix it: zone-allow-policy.py (allow Hotspot -> 10.1.1.4 on 80/443), guest-restrictions.py (drop 10.0.0.0/8 from the restriction list via the legacy guest_access setting, since system policies cannot be edited through the Integration API) and wifi-broadcast-devices.py (put the Waters Guest SSID on the Auditorium, Main Lobby and Offices APs). The runbook gets a section on all three, and PUBLIC-ACCESS.md points at it from the troubleshooting notes. Confirmed on site: translate.watersmedia.org loads on Waters Guest and stays up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
6.0 KiB
Python
Executable File
141 lines
6.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Change which access points broadcast a WiFi network (SSID) on one UniFi console.
|
|
|
|
Usage:
|
|
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --list
|
|
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" \
|
|
--add "Auditorium Left,Auditorium Right,Main Lobby Entrance" # dry run
|
|
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" \
|
|
--add "Auditorium Left,Auditorium Right,Main Lobby Entrance" --apply # write it
|
|
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --remove "The Vault" --apply
|
|
docs/examples/waters-church-na/wifi-broadcast-devices.py north-attleboro "Waters Guest" --all --apply # every AP
|
|
|
|
Names are matched exactly against the console's device names (--list prints
|
|
them). Without --apply nothing is written: the script prints the APs the SSID
|
|
is on now and the list it would send. The whole broadcast object is PUT back
|
|
with only broadcastingDeviceFilter changed.
|
|
|
|
Needs the per-console Network API key at ~/.config/unifi/<site>-network-api-key
|
|
(see unifi-dns-record.py). The key is only ever sent in the X-API-KEY header.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import ssl
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
SITES = {
|
|
"apollo-beach": "10.3.0.1",
|
|
"north-attleboro": "10.1.0.1",
|
|
"woonsocket": "10.2.0.1",
|
|
"norwood": "10.4.0.1",
|
|
"southcoast": "10.5.0.1",
|
|
}
|
|
KEY_DIR = os.path.expanduser("~/.config/unifi")
|
|
TIMEOUT = 20
|
|
|
|
|
|
class Console:
|
|
def __init__(self, name: str):
|
|
self.name = name
|
|
self.gateway = SITES[name]
|
|
with open(os.path.join(KEY_DIR, f"{name}-network-api-key")) as f:
|
|
self.key = f.read().strip()
|
|
self.ctx = ssl.create_default_context()
|
|
self.ctx.check_hostname = False
|
|
self.ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
def request(self, method: str, path: str, body: dict | None = None):
|
|
url = f"https://{self.gateway}/proxy/network/integration/v1{path}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(url, data=data, method=method, headers={
|
|
"X-API-KEY": self.key, "Accept": "application/json",
|
|
"Content-Type": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=TIMEOUT, context=self.ctx) as r:
|
|
raw = r.read().decode()
|
|
except urllib.error.HTTPError as e:
|
|
raise SystemExit(f"{method} {path} -> HTTP {e.code}: {e.read().decode(errors='replace')[:600]}")
|
|
return json.loads(raw) if raw else None
|
|
|
|
def site_id(self) -> str:
|
|
return self.request("GET", "/sites")["data"][0]["id"]
|
|
|
|
def access_points(self, sid: str) -> dict[str, str]:
|
|
devs = self.request("GET", f"/sites/{sid}/devices?limit=200")["data"]
|
|
return {d["name"]: d["id"] for d in devs if "accessPoint" in (d.get("features") or [])}
|
|
|
|
def broadcast(self, sid: str, ssid: str) -> dict:
|
|
for b in self.request("GET", f"/sites/{sid}/wifi/broadcasts?limit=200")["data"]:
|
|
if b.get("name") == ssid:
|
|
return b
|
|
raise SystemExit(f"no WiFi network named {ssid!r} on {self.name}")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("site", choices=sorted(SITES))
|
|
ap.add_argument("ssid", help="WiFi network name as shown in the console")
|
|
ap.add_argument("--add", default="", help="comma-separated AP names to add")
|
|
ap.add_argument("--remove", default="", help="comma-separated AP names to remove")
|
|
ap.add_argument("--all", action="store_true", help="broadcast on every AP (clears the device filter)")
|
|
ap.add_argument("--list", action="store_true", help="print the APs and where the SSID broadcasts, then exit")
|
|
ap.add_argument("--apply", action="store_true", help="actually write the change")
|
|
a = ap.parse_args()
|
|
|
|
c = Console(a.site)
|
|
sid = c.site_id()
|
|
aps = c.access_points(sid)
|
|
by_id = {v: k for k, v in aps.items()}
|
|
b = c.broadcast(sid, a.ssid)
|
|
filt = b.get("broadcastingDeviceFilter") or {}
|
|
current = list(filt.get("deviceIds", [])) if filt.get("type") == "DEVICES" else None
|
|
|
|
def names(ids):
|
|
return ", ".join(by_id.get(i, i) for i in ids)
|
|
|
|
print(f"{a.site}: {a.ssid!r} broadcasts on " + ("ALL access points" if current is None else f"{len(current)} AP(s): {names(current)}"))
|
|
if a.list:
|
|
print("\nAccess points on this console:")
|
|
for n in sorted(aps):
|
|
print(f" {'*' if current is None or aps[n] in current else ' '} {n}")
|
|
return 0
|
|
|
|
add = [s.strip() for s in a.add.split(",") if s.strip()]
|
|
rem = [s.strip() for s in a.remove.split(",") if s.strip()]
|
|
unknown = [n for n in add + rem if n not in aps]
|
|
if unknown:
|
|
raise SystemExit(f"unknown AP name(s): {', '.join(unknown)}; use --list")
|
|
if not (add or rem or a.all):
|
|
ap.error("nothing to change: give --add, --remove or --all")
|
|
|
|
new_b = {k: v for k, v in b.items() if k not in ("id", "metadata")}
|
|
if a.all:
|
|
new_b.pop("broadcastingDeviceFilter", None)
|
|
summary = "ALL access points"
|
|
else:
|
|
ids = list(current or [])
|
|
for n in add:
|
|
if aps[n] not in ids:
|
|
ids.append(aps[n])
|
|
ids = [i for i in ids if i not in {aps[n] for n in rem}]
|
|
new_b["broadcastingDeviceFilter"] = {"type": "DEVICES", "deviceIds": ids}
|
|
summary = f"{len(ids)} AP(s): {names(ids)}"
|
|
print(f"would broadcast on {summary}")
|
|
if not a.apply:
|
|
print("DRY RUN, add --apply to write it")
|
|
return 0
|
|
out = c.request("PUT", f"/sites/{sid}/wifi/broadcasts/{b['id']}", new_b)
|
|
f2 = (out or {}).get("broadcastingDeviceFilter") or {}
|
|
print("UPDATED: now on " + ("ALL access points" if f2.get("type") != "DEVICES" else names(f2.get("deviceIds", []))))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|