From eb87710b9a614055aa5caa29042ba409fcc89e63 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Tue, 16 Jun 2026 07:35:55 -0700 Subject: [PATCH] =?UTF-8?q?unifi-wifi:=20add=20gw-control.sh=20=E2=80=94?= =?UTF-8?q?=20gateway=20router=20actions=20(port-forward=20+=20WAN=20firew?= =?UTF-8?q?all)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write companion to gw-audit. Closes/scopes internet-facing port-forwards and toggles WAN firewall rules at the USG/UXG/UDM via the RW controller REST admin. Actions: pf-list / pf-disable / pf-enable / pf-delete / pf-set-ports / pf-set-src, fw-list / fw-disable / fw-enable, block-ips (WAN address-group + WAN_IN drop rule). Reads via Mongo (no cred); writes via login->CSRF->REST (rest/portforward, rest/firewallrule, rest/firewallgroup). DRY-RUN default, --apply gated on infrastructure/uos-server-network-api-rw, rollback saved to .claude/tmp. Dry-run validated on Grabb & Durando (USG-3P): identifies the live "VPN" forward (80,443,1723 -> 192.168.242.200) + the "GRE" WAN_IN accept that back an internet-exposed, brute-forced PPTP. Closes the ROADMAP firewall/port-forward item. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/unifi-wifi/SKILL.md | 24 +- .../skills/unifi-wifi/references/ROADMAP.md | 7 +- .../skills/unifi-wifi/scripts/gw-control.sh | 228 ++++++++++++++++++ errorlog.md | 2 + 4 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/unifi-wifi/scripts/gw-control.sh diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index da75340..7fa5b9d 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -33,8 +33,12 @@ path is Cascades — override with the script's vault-path arg per client. uplink, internet latency/drops/speedtest, gateway CPU/mem/uptime, and the adoption/health rollup (APs/switches adopted vs disconnected vs pending, client counts, firmware-upgradable) with flags. Handles third-party-firewall sites (num_gw=0). Controller-side, any site. -- **[WIP] Deeper firewall/VPN policy, client DHCP/DNS, adoption *remediation*** — read/health is covered - by gw-audit; deeper config + remediation actions are future. Access layer reaches them already. +- **[WORKING] Gateway router actions — port-forwards + WAN firewall** — `scripts/gw-control.sh `: + list/disable/enable/delete/re-scope port-forwards (`pf-*`), toggle WAN firewall rules (`fw-disable`/ + `fw-enable`), and drop attacker IPs at the edge (`block-ips`). The write companion to gw-audit; closes + an internet-facing exposure (e.g. a brute-forced PPTP). Gated/DRY-RUN, rollback saved. Controller-side. +- **[WIP] Client DHCP/DNS policy, deeper VPN (server) config, adoption *remediation* depth** — port-forward + + WAN firewall is now covered (gw-control); remaining gateway config (VPN server stand-up, DHCP/DNS) is future. - **Per-client requirement:** `watch-ap`/`neighbor-collect`/`survey-collect`/`dfs-check` default the AP device-auth SSH cred to `clients/cascades-tucson/unifi-ap-ssh`; for another client, vault its own `clients//unifi-ap-ssh` and pass it as the script's vault-path arg. @@ -176,6 +180,22 @@ client-control.sh block|unblock|kick [--apply] # ban a MAC / un-b ```bash device-control.sh adopt|restart|provision|locate|unlocate|upgrade [--apply] ``` +**Gateway router actions — `scripts/gw-control.sh`** (port-forwards + WAN firewall; the write side of +gw-audit; gated/DRY-RUN, rollback saved): +```bash +gw-control.sh pf-list # list port-forwards (id/on/proto/ports/dst/src) +gw-control.sh pf-set-ports [fwd] # change forwarded ports (drop one, e.g. 1723) +gw-control.sh pf-disable|pf-enable|pf-delete +gw-control.sh pf-set-src # restrict a forward to a known source +gw-control.sh fw-list # list firewall rules +gw-control.sh fw-disable|fw-enable # toggle a WAN rule (e.g. a "GRE" accept) +gw-control.sh block-ips [--group N] # WAN address-group + WAN_IN drop rule +``` +Closing an internet-facing PPTP usually = `pf-set-ports VPN 80,443` (drop tcp 1723) **+** `fw-disable GRE` +(PPTP needs both the 1723 forward and the GRE WAN_IN rule). Reads via Mongo (no cred); writes via the RW +admin REST (`rest/portforward|firewallrule|firewallgroup`). `block-ips` clones an existing WAN_IN rule's +schema for firmware compatibility — verify the new rule's precedence in the UI. Dry-run validated 2026-06-16 +on Grabb & Durando (USG-3P): identified the live `VPN` forward (80,443,1723→.200) + `GRE` WAN_IN accept. **Channel plan — `scripts/channel-plan.sh`** (computes + applies a co-channel-minimizing plan): ```bash NEIGHBOR_JSON=...nbr.json SURVEY_JSON=...survey.json \ diff --git a/.claude/skills/unifi-wifi/references/ROADMAP.md b/.claude/skills/unifi-wifi/references/ROADMAP.md index dedcd97..5db958c 100644 --- a/.claude/skills/unifi-wifi/references/ROADMAP.md +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -61,7 +61,12 @@ side, multi-client enablement, and non-WiFi scope. Build/validate new apply acti with flags). Handles third-party-firewall sites. Validated on a USG site + the pfSense Cascades site. - [x] **adoption / device remediation** — DONE: `device-control.sh` adopt|restart|provision|locate| unlocate|upgrade a device (cmd/devmgr, gated; validated locate/unlocate on AP 622). Pairs with gw-audit. -- [ ] **Deeper firewall/VPN policy** — config beyond health read; future. Access layer reaches it. +- [x] **Port-forwards + WAN firewall control** — DONE: `gw-control.sh` pf-list/disable/enable/delete/ + set-ports/set-src, fw-disable/enable, block-ips (RW REST: rest/portforward|firewallrule|firewallgroup, + gated/DRY-RUN, rollback). Dry-run validated 2026-06-16 on Grabb & Durando USG-3P (closes the brute-forced + PPTP forward 1723→GND-SERVER + its GRE WAN_IN rule). First production apply pending. +- [ ] **Deeper VPN policy** — gateway-hosted VPN *server* stand-up (USG L2TP/IPsec remote-user, RADIUS), + client DHCP/DNS policy. Config beyond port-forward/WAN-firewall; future. Access layer reaches it. ## D. Robustness / ops - [ ] **VPN-flap resilience** in the AP-side loops (resume/retry so a mid-run tunnel drop doesn't waste diff --git a/.claude/skills/unifi-wifi/scripts/gw-control.sh b/.claude/skills/unifi-wifi/scripts/gw-control.sh new file mode 100644 index 0000000..d881b15 --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/gw-control.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# gw-control.sh — gateway (USG/UXG/UDM) ROUTER actions: port-forwards + WAN firewall rules. +# The write-side companion to gw-audit.sh (which surfaces WAN exposure / open forwards). Lets you +# close or scope an internet-facing port-forward (e.g. a brute-forced PPTP/RDP/SMB), toggle a WAN +# firewall rule (e.g. a GRE allow that backs PPTP), and drop a list of attacker IPs at the edge. +# +# pf-list list all port-forwards (id, on/off, proto, ports, dst, src) +# pf-disable disable a port-forward (keeps it, stops the exposure) +# pf-enable re-enable a port-forward +# pf-delete delete a port-forward +# pf-set-ports [] change dst_port (and fwd_port; default fwd=dst). e.g. drop +# 1723 from "80,443,1723": pf-set-ports VPN 80,443 +# pf-set-src restrict the SOURCE of a port-forward (e.g. an office /32) +# fw-list list firewall rules (ruleset #index id action name on/off) +# fw-disable disable a firewall rule (e.g. a "GRE" WAN_IN accept) +# fw-enable +# block-ips [--group N] create/append a WAN address-group + a WAN_IN drop rule +# +# DRY-RUN by default; --apply gated behind infrastructure/uos-server-network-api-rw. Reads via Mongo +# (no cred); writes via the controller REST (login -> GET/modify -> PUT|POST|DELETE on +# rest/portforward | rest/firewallrule | rest/firewallgroup). Rollback auto-saved to .claude/tmp. +# Controller-side -> any UOS site. PPTP needs BOTH tcp 1723 AND the GRE WAN_IN rule, so closing PPTP +# usually = pf-set-ports (drop 1723) + fw-disable the GRE rule. +# +# Usage: bash .claude/skills/unifi-wifi/scripts/gw-control.sh [args] [--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: gw-control.sh [args] [--apply]}" +ACT="${2:?action: pf-list|pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src|fw-list|fw-disable|fw-enable|block-ips}" +shift 2 + +APPLY=0; GROUP="gw-control blocklist"; POS=() +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1; shift;; + --group) GROUP="${2:?--group needs a name}"; shift 2;; + *) POS+=("$1"); shift;; + esac +done + +# resolve SITE (24-hex id, or fuzzy name via --sites) +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: $SITEARG"; exit 1; } +MONGO_CLEAN='grep -viE pq.html|post-quantum|store now|server may need' + +# ---------- READ-ONLY listers (Mongo, no cred) ---------- +pf_list() { +cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' +db.portforward.find({site_id:"$SITE"}).forEach(function(p){ + print((p.enabled?"[on] ":"[off] ")+"id="+p._id+" name='"+(p.name||"")+"' "+(p.proto||"")+ + " dst_port="+(p.dst_port||"")+" -> "+(p.fwd||"")+":"+(p.fwd_port||"")+" src="+(p.src||"any")); +}); +JS +} +fw_list() { +cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' +db.firewallrule.find({site_id:"$SITE"}).sort({ruleset:1,rule_index:1}).forEach(function(r){ + var src=(r.src_firewallgroup_ids&&r.src_firewallgroup_ids.length)?(" src_grp="+r.src_firewallgroup_ids.join(",")):""; + print((r.enabled?"[on] ":"[off] ")+r.ruleset+" #"+r.rule_index+" id="+r._id+" "+r.action+ + " '"+(r.name||"")+"' proto="+(r.protocol||"")+(r.dst_port?(" dst_port="+r.dst_port):"")+src); +}); +JS +} + +case "$ACT" in + pf-list) echo "[INFO] site=$SITE port-forwards:"; pf_list; exit 0;; + fw-list) echo "[INFO] site=$SITE firewall rules:"; fw_list; exit 0;; +esac + +# ---------- DRY-RUN preview for write actions (Mongo lookup of the target) ---------- +TARGET="${POS[0]:-}" +echo "[INFO] site=$SITE action=$ACT target='${TARGET:-}' mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)" +case "$ACT" in + pf-disable|pf-enable|pf-delete|pf-set-ports|pf-set-src) + [ -n "$TARGET" ] || { echo "[ERROR] $ACT needs "; exit 1; } + echo " --- current matching port-forward(s) ---" +cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' +var T="$TARGET"; +db.portforward.find({site_id:"$SITE"}).forEach(function(p){ + if(p._id===T || (p.name&&p.name.toLowerCase()===T.toLowerCase())){ + print(" [on?"+p.enabled+"] id="+p._id+" name='"+(p.name||"")+"' "+(p.proto||"")+" dst_port="+(p.dst_port||"")+" -> "+(p.fwd||"")+":"+(p.fwd_port||"")+" src="+(p.src||"any")); + } +}); +JS + ;; + fw-disable|fw-enable) + [ -n "$TARGET" ] || { echo "[ERROR] $ACT needs "; exit 1; } + echo " --- current matching firewall rule(s) ---" +cat <&1 | grep -viE 'pq.html|post-quantum|store now|server may need' +var T="$TARGET"; +db.firewallrule.find({site_id:"$SITE"}).forEach(function(r){ + if(r._id===T || (r.name&&r.name.toLowerCase()===T.toLowerCase())){ + print(" ["+r.ruleset+" #"+r.rule_index+" on?"+r.enabled+"] id="+r._id+" "+r.action+" '"+(r.name||"")+"'"); + } +}); +JS + ;; + block-ips) + [ -n "$TARGET" ] || { echo "[ERROR] block-ips needs "; exit 1; } + echo " would create/append WAN address-group '$GROUP' with: $TARGET" + echo " and ensure a WAN_IN drop rule referencing it (high priority)." + ;; + *) echo "[ERROR] unknown action: $ACT"; exit 1;; +esac + +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, gated) ---------- +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 the RW controller admin vaulted at: $RWP"; exit 2; } + +export GC_SITE="$SITE" GC_ACT="$ACT" GC_GROUP="$GROUP" REPO +export GC_ARG0="${POS[0]:-}" GC_ARG1="${POS[1]:-}" GC_ARG2="${POS[2]:-}" +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=25);hdr=resp.headers;txt=resp.read().decode('utf-8','replace') + return (txt,hdr) if wh else txt +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['GC_SITE']),None) +if not short:print("[ERROR] site resolve failed");sys.exit(1) +def S(p):return f"/proxy/network/api/s/{short}{p}" +ACT=os.environ['GC_ACT'];A0=os.environ['GC_ARG0'];A1=os.environ['GC_ARG1'];A2=os.environ['GC_ARG2'] +def find(items,key): + for it in items: + if it.get('_id')==key or (it.get('name','').lower()==key.lower()):return it + return None +def save_rollback(tag,obj): + rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"gw-control-rollback-{short}-{tag}.json") + try: + os.makedirs(os.path.dirname(rp),exist_ok=True);open(rp,'w').write(json.dumps(obj,indent=1)) + print(f"[rollback] saved {rp}") + except Exception as e:print("[rollback] save failed:",e) + +try: + if ACT.startswith('pf-'): + pfs=json.loads(call('GET',S('/rest/portforward'))).get('data',[]) + pf=find(pfs,A0) + if not pf:print(f"[FAIL] port-forward '{A0}' not found");sys.exit(1) + before={k:pf.get(k) for k in ('enabled','dst_port','fwd_port','src')} + save_rollback(f"pf-{pf['_id']}",pf) + if ACT=='pf-delete': + call('DELETE',S(f"/rest/portforward/{pf['_id']}"),csrf=csrf) + print(f"[ok] deleted port-forward '{pf.get('name')}' (was: {before})");sys.exit(0) + if ACT=='pf-disable':pf['enabled']=False + elif ACT=='pf-enable':pf['enabled']=True + elif ACT=='pf-set-ports': + if not A1:print("[FAIL] pf-set-ports needs []");sys.exit(1) + pf['dst_port']=A1; pf['fwd_port']=(A2 or A1) + elif ACT=='pf-set-src': + pf['src']=('any' if A0 and A1.lower()=='any' else A1) if A1 else 'any' + call('PUT',S(f"/rest/portforward/{pf['_id']}"),pf,csrf=csrf) + after={k:pf.get(k) for k in ('enabled','dst_port','fwd_port','src')} + print(f"[ok] {ACT} '{pf.get('name')}' {before} -> {after}");sys.exit(0) + + if ACT in ('fw-disable','fw-enable'): + rules=json.loads(call('GET',S('/rest/firewallrule'))).get('data',[]) + r=find(rules,A0) + if not r:print(f"[FAIL] firewall rule '{A0}' not found");sys.exit(1) + save_rollback(f"fw-{r['_id']}",r) + r['enabled']=(ACT=='fw-enable') + call('PUT',S(f"/rest/firewallrule/{r['_id']}"),r,csrf=csrf) + print(f"[ok] {ACT} '{r.get('name')}' [{r.get('ruleset')} #{r.get('rule_index')}] enabled={r['enabled']}");sys.exit(0) + + if ACT=='block-ips': + ips=[x.strip() for x in A0.split(',') if x.strip()] + if not ips:print("[FAIL] no IPs");sys.exit(1) + gname=os.environ['GC_GROUP'] + groups=json.loads(call('GET',S('/rest/firewallgroup'))).get('data',[]) + grp=next((g for g in groups if g.get('name')==gname and g.get('group_type')=='address-group'),None) + if grp: + before=list(grp.get('group_members',[])) + grp['group_members']=sorted(set(before)|set(ips)) + save_rollback(f"grp-{grp['_id']}",{'_id':grp['_id'],'group_members':before}) + call('PUT',S(f"/rest/firewallgroup/{grp['_id']}"),grp,csrf=csrf) + print(f"[ok] appended {len(ips)} IP(s) to existing group '{gname}' ({len(before)}->{len(grp['group_members'])})") + gid=grp['_id'] + else: + ng={'name':gname,'group_type':'address-group','group_members':sorted(set(ips))} + res=json.loads(call('POST',S('/rest/firewallgroup'),ng,csrf=csrf)) + gid=res.get('data',[{}])[0].get('_id') + print(f"[ok] created address-group '{gname}' with {len(ips)} IP(s) id={gid}") + # ensure a WAN_IN drop rule referencing the group exists; clone an existing WAN_IN rule's schema + rules=json.loads(call('GET',S('/rest/firewallrule'))).get('data',[]) + existing=next((r for r in rules if r.get('name')==f"Block {gname}" and r.get('ruleset')=='WAN_IN'),None) + if existing: + print(f"[ok] WAN_IN drop rule 'Block {gname}' already present (#{existing.get('rule_index')}) -> group updated, nothing else to do") + sys.exit(0) + wanin=[r for r in rules if r.get('ruleset')=='WAN_IN'] + idxs=[int(r.get('rule_index',2000)) for r in wanin if str(r.get('rule_index','')).isdigit()] + nidx=max(idxs)+1 if idxs else 2000 + tmpl=wanin[0] if wanin else None + if tmpl: + nr={k:v for k,v in tmpl.items() if k not in ('_id','rule_index','name','src_firewallgroup_ids','dst_firewallgroup_ids','src_address','dst_address')} + else: + nr={'ruleset':'WAN_IN','protocol':'all','protocol_match_excepted':False,'logging':False, + 'state_new':False,'state_established':False,'state_invalid':False,'state_related':False, + 'ipsec':'','src_networkconf_type':'NETv4','dst_networkconf_type':'NETv4','icmp_typename':''} + nr.update({'ruleset':'WAN_IN','rule_index':nidx,'name':f"Block {gname}",'enabled':True,'action':'drop', + 'src_firewallgroup_ids':[gid],'dst_firewallgroup_ids':[],'src_address':'','dst_address':''}) + res=call('POST',S('/rest/firewallrule'),nr,csrf=csrf) + ok=json.loads(res).get('meta',{}).get('rc')=='ok' + print(f"[{'ok' if ok else 'FAIL'}] WAN_IN drop rule 'Block {gname}' #{nidx} -> group {gid} ({json.loads(res).get('meta')})") + print("[note] verify the new rule's position/precedence in the UI; firewallrule schema is firmware-sensitive.") + sys.exit(0) + print("[ERROR] unhandled action",ACT);sys.exit(1) +except urllib.error.HTTPError as e: + print(f"[FAIL] HTTP {e.code}: {e.read().decode('utf-8','replace')[:300]}");sys.exit(1) +except Exception as e: + print("[FAIL]",e);sys.exit(1) +PY diff --git a/errorlog.md b/errorlog.md index ac40408..283363b 100644 --- a/errorlog.md +++ b/errorlog.md @@ -19,6 +19,8 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure · 2026-06-16 | Howard-Home | bash/curl.exe-on-windows | [friction] PowerShell-invoked curl.exe strips embedded double-quotes from --data-urlencode args (CommandLineToArgvW), silently mangling POST bodies; pfSense PHP became 'echo PHPRUNS-OK' -> 'Undefined constant'. Fix: write payloads with single-quotes only, build $ via [char]36, keep one line. [ctx: ref=pfsense diag_command.php php-exec; cost=4 wasted RMM round-trips] +2026-06-16 | GURU-5070 | remediation-tool/get-token | [friction] get-token.sh reads vault_path from ~/.claude/identity.json (home), which lacks the field on this machine; repo identity.json (.claude/identity.json) has it. Fix: export VAULT_ROOT_ENV=$(jq -r .vault_path .claude/identity.json) before calling get-token [ctx: ref=remediation-tool;machine=GURU-5070] + 2026-06-15 | GURU-5070 | rmm/quickbooks-folderbrowser | [correction] assumed F:FolderRedirection was a dead/missing drive (Test-Path F: = False under SYSTEM); correct: F: is a per-user NETWORK-mapped redirected folder, invisible to the SYSTEM context RMM runs in - must diagnose mapped-drive/redirect issues in user_session 2026-06-15 | GURU-5070 | rmm | ProfWiz Pro silent-install command returned 'Execution error: Failed to execute command' (status failed, no stdout) on SP-SharonW11 [ctx: agent=86de13d7 host=SP-SharonW11 task=upw-install]