Files
claudetools/.claude/skills/unifi-wifi/scripts/client-control.sh
Howard Enos 6557cdb5bb sync: auto-sync from HOWARD-HOME at 2026-06-16 01:09:44
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-16 01:09:44
2026-06-16 01:09:53 -07:00

54 lines
3.7 KiB
Bash

#!/usr/bin/env bash
# client-control.sh — operational per-client wifi controls via the controller (cmd/stamgr).
# block = ban a MAC from the wifi entirely (persists until unblock)
# unblock = remove the ban
# kick = force-deauth/reconnect a client now (one-shot; e.g. to re-steer it after a change)
# These are the device-level "lock"/eviction tools (UniFi has no per-client AP pin; to constrain a
# device to an AP use apply-wlan 'aps' (broadcasting_aps) ± 'macfilter' on a dedicated SSID).
#
# DRY-RUN default; --apply gated behind infrastructure/uos-server-network-api-rw. Controller-side
# (no AP cred / VPN needed) -> works for any site.
#
# Usage: bash .../client-control.sh <site> <block|unblock|kick> <mac> [--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: client-control.sh <site> <block|unblock|kick> <mac> [--apply]}"
ACT="${2:?action: block|unblock|kick}"; MAC="$(echo "${3:?mac required}" | tr 'A-Z' 'a-z')"; APPLY=0
shift 3; while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; *) shift;; esac; done
case "$ACT" in block) CMD="block-sta";; unblock) CMD="unblock-sta";; kick) CMD="kick-sta";; *) echo "action: block|unblock|kick"; 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
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; }
echo "[INFO] site=$SITE $ACT ($CMD) mac=$MAC mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"
if [ "$APPLY" != "1" ]; then echo "[dry-run] would POST cmd/stamgr {cmd:'$CMD', mac:'$MAC'}. Add --apply."; exit 0; fi
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)"
[ -n "$RW_U" ] && [ -n "$RW_P" ] || { echo "[BLOCKED] --apply needs RW admin vaulted at $RWP"; exit 2; }
export CC_SITE="$SITE" CC_CMD="$CMD" CC_MAC="$MAC"
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,wh=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);return (resp.read().decode('utf-8','replace'),resp.headers) if wh else resp.read().decode('utf-8','replace')
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)
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['CC_SITE']),None)
if not short:print("[ERROR] site resolve failed");sys.exit(1)
try:
r=call('POST',f"/proxy/network/api/s/{short}/cmd/stamgr",{'cmd':os.environ['CC_CMD'],'mac':os.environ['CC_MAC']},csrf=csrf)
meta=json.loads(r).get('meta',{})
print(f" [{'ok' if meta.get('rc')=='ok' else 'FAIL'}] {os.environ['CC_CMD']} {os.environ['CC_MAC']} -> {meta}")
except Exception as e:print(" [FAIL]",e)
PY