- apply-wlan.sh: wlan_bands token was "6e" but this controller stores "6g" (verified live on Cascades Guest SSID) -> setting 6 GHz membership would have failed. Fixed band values + option names (5g6g/6g/all). - Cascades 2.4 runbook: folded in Phase 5 (5 GHz: width 80->40 on 76 radios; channel plan with the DFS decision flagged -- DFS empirically clean here, so including clean-DFS gives ~20 channels vs ~5 non-DFS-only for 77 APs) and Phase 6 (6 GHz: root cause = production SSID CSCNet not on 6 GHz [bands 2g,5g only]; add 6g + enable bss-transition; band-steering already on). Per Howard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
12 KiB
Bash
157 lines
12 KiB
Bash
#!/usr/bin/env bash
|
|
# apply-wlan.sh — WLAN-level (wlanconf) config changes that aren't on the radio: minimum data rates
|
|
# (and, extensibly, band-steering / roaming-assistant). These live on the WLAN object, so a change
|
|
# affects EVERY AP broadcasting that WLAN — bigger blast radius than apply-radio; target with --wlan.
|
|
#
|
|
# DRY-RUN by default; --apply gated behind infrastructure/uos-server-network-api-rw. Same controller
|
|
# REST write path as apply-radio (login -> GET/modify/PUT rest/wlanconf/<id>), rollback auto-saved.
|
|
#
|
|
# Actions:
|
|
# minrate <ng|na> auto|off|<Mbps> -> minrate_setting_preference + minrate_<band>_enabled/_data_rate_kbps
|
|
# kill 1-11Mbps legacy basic rates: set 2.4 floor to 12 or 24. e.g. minrate ng 12
|
|
# bandsteer <on|off> -> no2ghz_oui (keep 5GHz-capable client OUIs off 2.4 = band steering)
|
|
# bands <both|5g|5g6g|6g|all> -> wlan_bands (force the SSID onto specific bands; 5g = strongest steer; 6g = this controller's 6GHz token)
|
|
# steer <on|off> -> roaming_assistant_na_enabled (5GHz sticky-client kicker)
|
|
# bsstm <on|off> -> bss_transition (802.11v; assists band/AP steering + roaming)
|
|
# (modern UniFi has no classic 'bandsteering_mode' field; the above are its replacements, confirmed 2026-06-16)
|
|
#
|
|
# Usage:
|
|
# bash .../apply-wlan.sh <site> minrate ng 12 [--wlan "CSCNet"] [--apply]
|
|
# bash .../apply-wlan.sh <site> steer on --wlan "CSC ENT" --apply
|
|
set -uo pipefail
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh"
|
|
SITEARG="${1:?usage: apply-wlan.sh <site> <minrate|steer> ... [--wlan NAME] [--apply]}"
|
|
ACT="${2:?action: minrate|bandsteer|bands|steer|bsstm|wlan|dtim|mcast|bcfilter|rrm|ftroam|isolation|hidessid|macfilter|aps}"; shift 2
|
|
WLAN=""; APPLY=0
|
|
# resolve SITE now (the 'aps' action needs it to map AP names -> MACs)
|
|
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
|
|
SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi
|
|
[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; }
|
|
# action-specific positional args -> FIELDS (the wlanconf fields to set)
|
|
case "$ACT" in
|
|
minrate) RBAND="${1:?minrate <ng|na> <auto|off|Mbps>}"; RVAL="${2:?minrate <ng|na> <auto|off|Mbps>}"; shift 2
|
|
case "$RBAND" in ng|na) ;; *) echo "minrate band must be ng|na"; exit 1;; esac
|
|
# NOTE: a manual min rate is ONLY honored when minrate_setting_preference=manual (else the
|
|
# controller auto-manages and ignores the kbps). 'auto' hands it back to the controller.
|
|
if [ "$RVAL" = auto ]; then FIELDS="{\"minrate_setting_preference\":\"auto\"}";
|
|
elif [ "$RVAL" = off ]; then FIELDS="{\"minrate_setting_preference\":\"manual\",\"minrate_${RBAND}_enabled\":false}";
|
|
elif [[ "$RVAL" =~ ^[0-9]+$ ]]; then FIELDS="{\"minrate_setting_preference\":\"manual\",\"minrate_${RBAND}_enabled\":true,\"minrate_${RBAND}_data_rate_kbps\":$((RVAL*1000))}";
|
|
else echo "minrate value: auto|off|<Mbps>"; exit 1; fi ;;
|
|
steer) SVAL="${1:?steer <on|off>}"; shift # 5GHz sticky-client kicker (roaming assistant)
|
|
case "$SVAL" in on) FIELDS="{\"roaming_assistant_na_enabled\":true}";; off) FIELDS="{\"roaming_assistant_na_enabled\":false}";; *) echo "steer: on|off"; exit 1;; esac ;;
|
|
bandsteer) SVAL="${1:?bandsteer <on|off>}"; shift # keep 5GHz-capable client OUIs OFF 2.4 (the band-steering toggle)
|
|
case "$SVAL" in on) FIELDS="{\"no2ghz_oui\":true}";; off) FIELDS="{\"no2ghz_oui\":false}";; *) echo "bandsteer: on|off"; exit 1;; esac ;;
|
|
bands) SVAL="${1:?bands <both|5g|5g6g|all|6g>}"; shift # per-WLAN band membership (force SSID onto bands)
|
|
# NOTE: this controller stores 6 GHz as "6g" (verified live on Cascades Guest SSID), NOT "6e".
|
|
case "$SVAL" in
|
|
both) FIELDS="{\"wlan_bands\":[\"2g\",\"5g\"]}";;
|
|
5g) FIELDS="{\"wlan_bands\":[\"5g\"]}";;
|
|
5g6g) FIELDS="{\"wlan_bands\":[\"5g\",\"6g\"]}";;
|
|
6g) FIELDS="{\"wlan_bands\":[\"6g\"]}";;
|
|
all) FIELDS="{\"wlan_bands\":[\"2g\",\"5g\",\"6g\"]}";;
|
|
*) echo "bands: both|5g|5g6g|6g|all"; exit 1;; esac ;;
|
|
bsstm) SVAL="${1:?bsstm <on|off>}"; shift # 802.11v BSS Transition Management (assists steering/roaming)
|
|
case "$SVAL" in on) FIELDS="{\"bss_transition\":true}";; off) FIELDS="{\"bss_transition\":false}";; *) echo "bsstm: on|off"; exit 1;; esac ;;
|
|
wlan) SVAL="${1:?wlan <on|off>}"; shift # enable/disable the whole SSID
|
|
case "$SVAL" in on) FIELDS="{\"enabled\":true}";; off) FIELDS="{\"enabled\":false}";; *) echo "wlan: on|off"; exit 1;; esac ;;
|
|
dtim) DBAND="${1:?dtim <ng|na|6e> <1-255>}"; DV="${2:?dtim <ng|na|6e> <1-255>}"; shift 2
|
|
case "$DBAND" in ng|na|6e) ;; *) echo "dtim band: ng|na|6e"; exit 1;; esac
|
|
[[ "$DV" =~ ^[0-9]+$ ]] || { echo "dtim value: 1-255"; exit 1; }
|
|
FIELDS="{\"dtim_mode\":\"custom\",\"dtim_${DBAND}\":$DV}" ;; # per-band DTIM needs dtim_mode=custom
|
|
mcast) SVAL="${1:?mcast <on|off>}"; shift # multicast enhancement (convert mcast->unicast)
|
|
case "$SVAL" in on) FIELDS="{\"mcastenhance_enabled\":true}";; off) FIELDS="{\"mcastenhance_enabled\":false}";; *) echo "mcast: on|off"; exit 1;; esac ;;
|
|
bcfilter)SVAL="${1:?bcfilter <on|off>}"; shift # block broadcast (ARP/DHCP storms) on the WLAN
|
|
case "$SVAL" in on) FIELDS="{\"bc_filter_enabled\":true}";; off) FIELDS="{\"bc_filter_enabled\":false}";; *) echo "bcfilter: on|off"; exit 1;; esac ;;
|
|
rrm) SVAL="${1:?rrm <on|off>}"; shift # 802.11k neighbor reports (roaming assist)
|
|
case "$SVAL" in on) FIELDS="{\"rrm_enabled\":true}";; off) FIELDS="{\"rrm_enabled\":false}";; *) echo "rrm: on|off"; exit 1;; esac ;;
|
|
ftroam) SVAL="${1:?ftroam <on|off>}"; shift # 802.11r fast roaming
|
|
case "$SVAL" in on) FIELDS="{\"fast_roaming_enabled\":true}"; echo "[WARNING] 802.11r can break legacy/medical/IoT clients - test on a scoped SSID first.";; off) FIELDS="{\"fast_roaming_enabled\":false}";; *) echo "ftroam: on|off"; exit 1;; esac ;;
|
|
isolation) SVAL="${1:?isolation <on|off>}"; shift # L2 client isolation
|
|
case "$SVAL" in on) FIELDS="{\"l2_isolation\":true}";; off) FIELDS="{\"l2_isolation\":false}";; *) echo "isolation: on|off"; exit 1;; esac ;;
|
|
hidessid) SVAL="${1:?hidessid <on|off>}"; shift
|
|
case "$SVAL" in on) FIELDS="{\"hide_ssid\":true}";; off) FIELDS="{\"hide_ssid\":false}";; *) echo "hidessid: on|off"; exit 1;; esac ;;
|
|
macfilter) MV="${1:?macfilter <off|allow|deny> [mac,mac,...]}"; shift # per-WLAN MAC allow/deny list
|
|
case "$MV" in
|
|
off) FIELDS="{\"mac_filter_enabled\":false}";;
|
|
allow|deny) ML="${1:?macfilter $MV <mac,mac,...>}"; shift
|
|
macs=$(echo "$ML" | tr 'A-Z,' 'a-z\n' | sed '/^$/d; s/^/"/; s/$/"/' | paste -sd, -)
|
|
FIELDS="{\"mac_filter_enabled\":true,\"mac_filter_policy\":\"$MV\",\"mac_filter_list\":[$macs]}" ;;
|
|
*) echo "macfilter: off | allow <macs> | deny <macs>"; exit 1;; esac ;;
|
|
aps) AV="${1:?aps <ap1,ap2,...|all>}"; shift # restrict the WLAN to specific APs (broadcasting_aps) = closest thing to 'lock to AP'
|
|
if [ "$AV" = all ]; then FIELDS="{\"broadcasting_aps\":[]}";
|
|
else
|
|
macs=$(echo "$AV" | tr ',' '\n' | while IFS= read -r nm; do [ -z "$nm" ] && continue; printf 'db.device.find({site_id:"%s",type:"uap",name:"%s"},{mac:1}).forEach(function(d){print(d.mac);});\n' "$SITE" "$nm"; done | bash "$UOS" 2>/dev/null | grep -iE '^[0-9a-f:]{17}$' | sed 's/^/"/; s/$/"/' | paste -sd, -)
|
|
[ -n "$macs" ] || { echo "[ERROR] could not resolve any AP name in: $AV"; exit 1; }
|
|
FIELDS="{\"broadcasting_aps\":[$macs]}"
|
|
fi ;;
|
|
*) echo "action must be minrate|bandsteer|bands|steer|bsstm|wlan|dtim|mcast|bcfilter|rrm|ftroam|isolation|hidessid|macfilter|aps"; exit 1;;
|
|
esac
|
|
while [ $# -gt 0 ]; do case "$1" in --wlan) WLAN="$2"; shift 2;; --apply) APPLY=1; shift;; *) shift;; esac; done
|
|
|
|
echo "[INFO] site=$SITE set $FIELDS${WLAN:+ wlan='$WLAN'} mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
|
|
[ -z "$WLAN" ] && echo "[WARNING] no --wlan filter: this targets ALL WLANs at the site (incl. Guest). Consider --wlan."
|
|
|
|
# ---- DRY-RUN preview against current wlanconf (Mongo) ----
|
|
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
|
|
var SITE='$SITE',FIELDS=$FIELDS,WLAN='$WLAN';
|
|
var n=0,skip=0;
|
|
db.wlanconf.find({site_id:SITE}).forEach(function(w){
|
|
if(WLAN && w.name!==WLAN) return;
|
|
var change=false,roll={};
|
|
for(var f in FIELDS){ roll[f]=(w[f]!==undefined?w[f]:null); if(String(w[f])!==String(FIELDS[f])) change=true; }
|
|
if(!change){ skip++; return; }
|
|
n++; print("CHANGE wlan='"+w.name+"' "+JSON.stringify(roll)+" -> "+JSON.stringify(FIELDS));
|
|
});
|
|
print("\nSUMMARY: "+n+" WLAN(s) would change, "+skip+" already at target.");
|
|
JS
|
|
|
|
if [ "$APPLY" != "1" ]; then
|
|
echo; echo "[dry-run] no changes made. Add --apply to write (needs the RW admin vaulted)."
|
|
exit 0
|
|
fi
|
|
|
|
# ---- WRITE PATH (controller REST: login -> GET/modify/PUT rest/wlanconf/<id>, rollback) ----
|
|
RWP="infrastructure/uos-server-network-api-rw"
|
|
export RW_U="$(bash "$VAULT" get-field "$RWP" credentials.username 2>/dev/null || true)"
|
|
export RW_P="$(bash "$VAULT" get-field "$RWP" credentials.password 2>/dev/null || true)"
|
|
if [ -z "$RW_U" ] || [ -z "$RW_P" ]; then
|
|
echo "[BLOCKED] --apply needs the RW controller admin vaulted at: $RWP"; exit 2; fi
|
|
export AW_SITE="$SITE" AW_FIELDS="$FIELDS" AW_WLAN="$WLAN" REPO
|
|
python - <<'PY'
|
|
import os,sys,json,ssl,urllib.request,http.cookiejar
|
|
H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}"
|
|
ctx=ssl.create_default_context();ctx.check_hostname=False;ctx.verify_mode=ssl.CERT_NONE
|
|
cj=http.cookiejar.CookieJar();op=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj),urllib.request.HTTPSHandler(context=ctx))
|
|
def call(method,path,body=None,csrf=None,want_headers=False):
|
|
data=json.dumps(body).encode() if body is not None else None
|
|
r=urllib.request.Request(base+path,data=data,method=method); r.add_header('Content-Type','application/json')
|
|
if csrf:r.add_header('X-CSRF-Token',csrf)
|
|
resp=op.open(r,timeout=20);hdr=resp.headers;txt=resp.read().decode('utf-8','replace')
|
|
return (txt,hdr) if want_headers else txt
|
|
try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},want_headers=True)
|
|
except Exception as e:print("[ERROR] login failed:",e);sys.exit(1)
|
|
csrf=hd.get('X-CSRF-Token') or hd.get('X-Updated-Csrf-Token')
|
|
sites=json.loads(call('GET','/proxy/network/api/self/sites')).get('data',[])
|
|
short=next((s['name'] for s in sites if s.get('_id')==os.environ['AW_SITE']),None)
|
|
if not short:print("[ERROR] site resolve failed");sys.exit(1)
|
|
fields=json.loads(os.environ['AW_FIELDS']);wlan=os.environ['AW_WLAN']
|
|
wl=json.loads(call('GET',f'/proxy/network/api/s/{short}/rest/wlanconf')).get('data',[])
|
|
roll=[];done=0;fail=0
|
|
for w in wl:
|
|
if wlan and w.get('name')!=wlan:continue
|
|
if all(str(w.get(f))==str(v) for f,v in fields.items()):continue
|
|
old={f:w.get(f) for f in fields}
|
|
for f,v in fields.items():w[f]=v
|
|
try:
|
|
call('PUT',f"/proxy/network/api/s/{short}/rest/wlanconf/{w['_id']}",w,csrf=csrf)
|
|
roll.append({'id':w['_id'],'name':w.get('name'),'old':old});done+=1;print(f" [ok] wlan '{w.get('name')}' -> {fields}")
|
|
except Exception as e:
|
|
fail+=1;print(f" [FAIL] wlan '{w.get('name')}': {e}")
|
|
rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"apply-wlan-rollback-{short}.json")
|
|
try:
|
|
os.makedirs(os.path.dirname(rp),exist_ok=True);open(rp,'w').write(json.dumps(roll,indent=1))
|
|
print(f"\n[APPLY] {done} changed, {fail} failed. Rollback saved: {rp}")
|
|
except Exception as e:print("[APPLY] done; rollback save failed:",e)
|
|
PY
|