|
|
|
@@ -1,57 +1,77 @@
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
# device-control.sh — adoption remediation + device ops via the controller (cmd/devmgr).
|
|
|
|
# device-control.sh — adoption + device ops via the controller (cmd/devmgr). Pairs with gw-audit.
|
|
|
|
# Pairs with gw-audit.sh, which flags pending/disconnected/upgradable devices; this remediates them.
|
|
|
|
# adopt <mac> adopt a pending device
|
|
|
|
# adopt <mac> adopt a pending device
|
|
|
|
# restart <mac> reboot a device (clean; drops its clients ~1 min)
|
|
|
|
# restart <mac> reboot a device
|
|
|
|
# locate <mac> flash the device LED
|
|
|
|
# provision<mac> force re-provision (push config) — fixes "stuck"/out-of-sync devices
|
|
|
|
# unlocate <mac> stop flashing
|
|
|
|
# locate <mac> flash the device LED (find it physically)
|
|
|
|
# upgrade <mac> upgrade firmware to the controller-recommended version
|
|
|
|
# unlocate <mac> stop flashing
|
|
|
|
# poe-cycle <ap-name|mac> RECOVERY: power-cycle the PoE switch port feeding an AP (the remote
|
|
|
|
# upgrade <mac> upgrade firmware to the controller-recommended version
|
|
|
|
# equivalent of physically re-seating the cable). Use when an AP is hung/
|
|
|
|
# DRY-RUN default; --apply gated behind infrastructure/uos-server-network-api-rw. Controller-side
|
|
|
|
# offline after a change. Resolves the AP -> its uplink switch + port.
|
|
|
|
# (no AP cred / VPN) -> any UOS site. Find MACs with: gw-audit.sh / sites.sh / stat/device.
|
|
|
|
|
|
|
|
#
|
|
|
|
#
|
|
|
|
# Usage: bash .claude/skills/unifi-wifi/scripts/device-control.sh <site> <adopt|restart|provision|locate|unlocate|upgrade> <mac> [--apply]
|
|
|
|
# REMOVED: 'provision' / force-provision — on U7-Pro it knocked AP 445 fully OFFLINE (required a
|
|
|
|
|
|
|
|
# physical port power-cycle to recover, 2026-06-16). Do NOT force-provision these APs. To recover a
|
|
|
|
|
|
|
|
# hung AP use `poe-cycle`; to push config cleanly use `restart` or just let the controller converge.
|
|
|
|
|
|
|
|
#
|
|
|
|
|
|
|
|
# DRY-RUN default; --apply gated behind infrastructure/uos-server-network-api-rw. Controller-side.
|
|
|
|
|
|
|
|
# Usage: bash .../device-control.sh <site> <adopt|restart|locate|unlocate|upgrade|poe-cycle> <target> [--apply]
|
|
|
|
set -uo pipefail
|
|
|
|
set -uo pipefail
|
|
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
|
|
UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh"
|
|
|
|
UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh"
|
|
|
|
SITEARG="${1:?usage: device-control.sh <site> <action> <mac> [--apply]}"
|
|
|
|
SITEARG="${1:?usage: device-control.sh <site> <action> <target> [--apply]}"
|
|
|
|
ACT="${2:?action: adopt|restart|provision|locate|unlocate|upgrade}"; MAC="$(echo "${3:?mac required}" | tr 'A-Z' 'a-z')"; APPLY=0
|
|
|
|
ACT="${2:?action: adopt|restart|locate|unlocate|upgrade|poe-cycle}"; TGT="${3:?target (mac, or AP name for poe-cycle)}"; APPLY=0
|
|
|
|
shift 3; while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; *) shift;; esac; done
|
|
|
|
shift 3; while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; *) shift;; esac; done
|
|
|
|
case "$ACT" in
|
|
|
|
case "$ACT" in
|
|
|
|
adopt) CMD="adopt";; restart) CMD="restart";; provision) CMD="force-provision";;
|
|
|
|
adopt) CMD="adopt";; restart) CMD="restart";; locate) CMD="set-locate";; unlocate) CMD="unset-locate";; upgrade) CMD="upgrade";; poe-cycle) CMD="power-cycle";;
|
|
|
|
locate) CMD="set-locate";; unlocate) CMD="unset-locate";; upgrade) CMD="upgrade";;
|
|
|
|
provision|force-provision) echo "[REFUSED] force-provision is unsafe on these APs (took 445 offline 2026-06-16). Use 'poe-cycle' to recover a hung AP, or 'restart'."; exit 1;;
|
|
|
|
*) echo "action: adopt|restart|provision|locate|unlocate|upgrade"; exit 1;; esac
|
|
|
|
*) echo "action: adopt|restart|locate|unlocate|upgrade|poe-cycle"; exit 1;; esac
|
|
|
|
[[ "$MAC" =~ ^[0-9a-f:]{17}$ ]] || { echo "[ERROR] mac must be aa:bb:cc:dd:ee:ff"; exit 1; }
|
|
|
|
|
|
|
|
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
|
|
|
|
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
|
|
|
|
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; }
|
|
|
|
[ -n "$SITE" ] || { echo "[ERROR] site not found"; exit 1; }
|
|
|
|
echo "[INFO] site=$SITE $ACT ($CMD) mac=$MAC mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
|
|
|
|
# mac-based verbs need a mac; poe-cycle accepts an AP name OR mac
|
|
|
|
if [ "$APPLY" != "1" ]; then echo "[dry-run] would POST cmd/devmgr {cmd:'$CMD', mac:'$MAC'}. Add --apply."; exit 0; fi
|
|
|
|
if [ "$ACT" != "poe-cycle" ]; then
|
|
|
|
|
|
|
|
MAC="$(echo "$TGT" | tr 'A-Z' 'a-z')"; [[ "$MAC" =~ ^[0-9a-f:]{17}$ ]] || { echo "[ERROR] $ACT needs a mac aa:bb:cc:dd:ee:ff"; exit 1; }
|
|
|
|
|
|
|
|
else MAC="$TGT"; fi
|
|
|
|
|
|
|
|
echo "[INFO] site=$SITE $ACT ($CMD) target=$TGT mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
|
|
|
|
|
|
|
|
|
|
|
|
RWP="infrastructure/uos-server-network-api-rw"
|
|
|
|
RWP="infrastructure/uos-server-network-api-rw"
|
|
|
|
export RW_U="$(bash "$VAULT" get-field "$RWP" credentials.username 2>/dev/null || true)"
|
|
|
|
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)"
|
|
|
|
export RW_P="$(bash "$VAULT" get-field "$RWP" credentials.password 2>/dev/null || true)"
|
|
|
|
[ -n "$RW_U" ] && [ -n "$RW_P" ] || { echo "[BLOCKED] --apply needs RW admin vaulted at $RWP"; exit 2; }
|
|
|
|
[ -n "$RW_U" ] && [ -n "$RW_P" ] || { echo "[BLOCKED] needs RW admin vaulted at $RWP"; exit 2; }
|
|
|
|
export DC_SITE="$SITE" DC_CMD="$CMD" DC_MAC="$MAC"
|
|
|
|
export DC_SITE="$SITE" DC_CMD="$CMD" DC_TGT="$MAC" DC_ACT="$ACT" DC_APPLY="$APPLY"
|
|
|
|
python - <<'PY'
|
|
|
|
python - <<'PY'
|
|
|
|
import os,sys,json,ssl,urllib.request,http.cookiejar
|
|
|
|
import os,sys,json,ssl,urllib.request,http.cookiejar
|
|
|
|
H="172.16.3.29";PORT=11443;base=f"https://{H}:{PORT}"
|
|
|
|
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
|
|
|
|
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))
|
|
|
|
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,wh=False):
|
|
|
|
def call(m,p,b=None,csrf=None,wh=False):
|
|
|
|
data=json.dumps(body).encode() if body is not None else None
|
|
|
|
d=json.dumps(b).encode() if b is not None else None
|
|
|
|
r=urllib.request.Request(base+path,data=data,method=method);r.add_header('Content-Type','application/json')
|
|
|
|
r=urllib.request.Request(base+p,data=d,method=m);r.add_header('Content-Type','application/json')
|
|
|
|
if csrf:r.add_header('X-CSRF-Token',csrf)
|
|
|
|
if csrf:r.add_header('X-CSRF-Token',csrf)
|
|
|
|
resp=op.open(r,timeout=30);return (resp.read().decode('utf-8','replace'),resp.headers) if wh else resp.read().decode('utf-8','replace')
|
|
|
|
x=op.open(r,timeout=30);return (x.read().decode('utf-8','replace'),x.headers) if wh else x.read().decode('utf-8','replace')
|
|
|
|
try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},wh=True)
|
|
|
|
try:_,hd=call('POST','/api/auth/login',{'username':os.environ['RW_U'],'password':os.environ['RW_P']},wh=True)
|
|
|
|
except Exception as e:print("[ERROR] login failed:",e);sys.exit(1)
|
|
|
|
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')
|
|
|
|
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',[])
|
|
|
|
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['DC_SITE']),None)
|
|
|
|
short=next((s['name'] for s in sites if s.get('_id')==os.environ['DC_SITE']),None)
|
|
|
|
if not short:print("[ERROR] site resolve failed");sys.exit(1)
|
|
|
|
act=os.environ['DC_ACT']; tgt=os.environ['DC_TGT']; apply=os.environ['DC_APPLY']=='1'
|
|
|
|
try:
|
|
|
|
if act=='poe-cycle':
|
|
|
|
r=call('POST',f"/proxy/network/api/s/{short}/cmd/devmgr",{'cmd':os.environ['DC_CMD'],'mac':os.environ['DC_MAC']},csrf=csrf)
|
|
|
|
devs=json.loads(call('GET',f'/proxy/network/api/s/{short}/stat/device')).get('data',[])
|
|
|
|
meta=json.loads(r).get('meta',{})
|
|
|
|
ap=next((d for d in devs if d.get('name')==tgt or d.get('mac')==tgt.lower()),None)
|
|
|
|
print(f" [{'ok' if meta.get('rc')=='ok' else 'FAIL'}] {os.environ['DC_CMD']} {os.environ['DC_MAC']} -> {meta}")
|
|
|
|
if not ap:print(f"[ERROR] AP '{tgt}' not found");sys.exit(1)
|
|
|
|
except Exception as e:print(" [FAIL]",e)
|
|
|
|
u=ap.get('uplink',{}); swmac=u.get('uplink_mac'); port=u.get('uplink_remote_port')
|
|
|
|
|
|
|
|
sw=next((d for d in devs if d.get('mac')==swmac),None)
|
|
|
|
|
|
|
|
if not (swmac and port):print(f"[ERROR] no wired uplink for {tgt} (mesh? can't PoE-cycle)");sys.exit(1)
|
|
|
|
|
|
|
|
print(f" {tgt} uplinks to {sw.get('name') if sw else swmac} port {port} -> power-cycle that PoE port")
|
|
|
|
|
|
|
|
if not apply:print(" [dry-run] add --apply to power-cycle the port (drops the AP ~30s, then it boots).");sys.exit(0)
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
r=call('POST',f"/proxy/network/api/s/{short}/cmd/devmgr",{'cmd':'power-cycle','mac':swmac,'port_idx':int(port)},csrf=csrf)
|
|
|
|
|
|
|
|
meta=json.loads(r).get('meta',{}); print(f" [{'ok' if meta.get('rc')=='ok' else 'FAIL'}] power-cycle {sw.get('name') if sw else swmac}:p{port} -> {meta}")
|
|
|
|
|
|
|
|
except Exception as e:print(" [FAIL]",e)
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
if not apply:print(f" [dry-run] would POST cmd/devmgr {{cmd:'{os.environ['DC_CMD']}', mac:'{tgt}'}}. Add --apply.");sys.exit(0)
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
r=call('POST',f"/proxy/network/api/s/{short}/cmd/devmgr",{'cmd':os.environ['DC_CMD'],'mac':tgt},csrf=csrf)
|
|
|
|
|
|
|
|
meta=json.loads(r).get('meta',{}); print(f" [{'ok' if meta.get('rc')=='ok' else 'FAIL'}] {os.environ['DC_CMD']} {tgt} -> {meta}")
|
|
|
|
|
|
|
|
except Exception as e:print(" [FAIL]",e)
|
|
|
|
PY
|
|
|
|
PY
|
|
|
|
|