#!/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" # Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only. logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "unifi-wifi/gw-control" "$1" --context "${2:-}" >/dev/null 2>&1 || true; } 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"; PFARG=""; POS=() while [ $# -gt 0 ]; do case "$1" in --apply) APPLY=1; shift;; --group) GROUP="${2:?--group needs a name}"; shift 2;; --pfsense) PFARG="${2:?--pfsense needs a slug/vault-path}"; shift 2;; # route to pfSense backend (§E) *) POS+=("$1"); shift;; esac done # ---------- pfSense dispatch (ROADMAP §E): gateway is pfSense -> same verbs, pfSense backend ---------- # Runs BEFORE UOS site resolution: a pfSense-only site is keyed by client slug (not a UOS site name), # so the SAME verb (pf-*/fw-*/block-ips) routes to a pfSense backend here. PREFERRED = SSH backend # (Mike's 2026-06-16 decision: no REST package needed), keyed on a vaulted clients//pfsense-firewall # cred. The REST backend (pfsense-backend.sh, clients//pfsense-api) is kept only as a dormant fallback. ssh_target="" # option A (Mike 2026-06-21): --pfsense may be a FULL vault path (contains '/') for an infra-vaulted # device, else a client slug -> clients//pfsense-firewall. pfsense-ssh.sh resolves both. case "$PFARG" in */*) if [ -n "$(bash "$VAULT" get-field "$PFARG" credentials.password 2>/dev/null || bash "$VAULT" get-field "$PFARG" password 2>/dev/null)" ]; then ssh_target="$PFARG"; fi ;; esac if [ -z "$ssh_target" ]; then for s in "$PFARG" "$SITEARG"; do [ -n "$s" ] || continue case "$s" in */*) continue;; esac if [ -n "$(bash "$VAULT" get-field "clients/$s/pfsense-firewall" credentials.password 2>/dev/null)" ]; then ssh_target="$s"; break; fi done fi if [ -n "$ssh_target" ]; then echo "[INFO] pfSense gateway (SSH backend, target '$ssh_target') -> dispatching '$ACT' to pfsense-ssh.sh" args=("$ssh_target" "$ACT"); [ ${#POS[@]} -gt 0 ] && args+=("${POS[@]}") [ "$APPLY" = "1" ] && args+=(--apply) exec bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "${args[@]}" fi # REST fallback (dormant): only if a pfSense-api cred is vaulted and no SSH cred was found. pf_cands=() [ -n "$PFARG" ] && { case "$PFARG" in */*) pf_cands+=("$PFARG");; *) pf_cands+=("clients/$PFARG/pfsense-api");; esac; } pf_cands+=("clients/$SITEARG/pfsense-api") for cand in "${pf_cands[@]}"; do if [ -n "$(bash "$VAULT" get-field "$cand" credentials.apikey 2>/dev/null || bash "$VAULT" get-field "$cand" apikey 2>/dev/null)" ]; then echo "[INFO] pfSense gateway (REST cred vault:$cand) -> dispatching '$ACT' to pfsense-backend.sh (dormant REST path)" args=("$cand" "$ACT"); [ ${#POS[@]} -gt 0 ] && args+=("${POS[@]}") [ "$ACT" = "block-ips" ] && args+=(--alias "${GROUP// /_}") [ "$APPLY" = "1" ] && args+=(--apply) exec bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-backend.sh" "${args[@]}" fi done # resolve SITE (24-hex id, or fuzzy name via --sites) — UniFi gateway path below 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' # ---------- AUTO-SELECT (ROADMAP §E): resolved site is in the gateway map -> pfSense SSH backend ---------- # No --pfsense needed: if site-gateways.tsv binds this site_id to a pfSense cred, route the verb there. if mapres="$(bash "$REPO/.claude/skills/unifi-wifi/scripts/gateway-map.sh" lookup "$SITE" 2>/dev/null)"; then IFS=$'\t' read -r mcred mport <<< "$mapres" echo "[INFO] site '$SITEARG' is in the gateway map -> pfSense SSH backend (cred:$mcred) for '$ACT'" args=("$mcred" "$ACT"); [ ${#POS[@]} -gt 0 ] && args+=("${POS[@]}") [ -n "$mport" ] && [ "$mport" != "-" ] && args+=(--port "$mport") [ "$APPLY" = "1" ] && args+=(--apply) exec bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "${args[@]}" fi # ---------- 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 rc=$? [ "$rc" -ne 0 ] && logerr "gw-control REST write failed" "site=$SITE act=$ACT rc=$rc" exit $rc