#!/usr/bin/env bash
# pfsense-backend.sh — pfSense gateway driver (REST API package backend) for the unifi-wifi skill.
# This is the pfSense half of the "gateway compatibility layer" (ROADMAP §E): the SAME gateway verbs
# that gw-audit.sh/gw-control.sh expose for UniFi gateways, implemented against a pfSense firewall.
# gw-audit.sh / gw-control.sh auto-DISPATCH here when a site has no UniFi gateway (num_gw=0) AND a
# pfSense API cred is vaulted — callers/SKILL docs don't fork (Mike's §E "dispatch inside" lean).
#
# BACKEND = pfSense REST API package (pfSense-pkg-RESTAPI v2, jaredhendrickson13/pfsense-api).
#   Auth: header  X-API-Key: <key>   Base: <url>/api/v2/
#   One-time per-site setup (see `setup` action below): install the pkg, mint a key, vault it.
# NOTE: endpoint paths follow the v2 schema; on the FIRST live run VERIFY them against the installed
# API version (the pkg evolves). Writes are gated behind --apply and auto-save a rollback, exactly
# like the UniFi side. Use a READ-ONLY key for audit; writes need a key with write privilege.
#
# Usage:
#   bash pfsense-backend.sh <vault-path> audit
#   bash pfsense-backend.sh <vault-path> pf-list
#   bash pfsense-backend.sh <vault-path> pf-disable|pf-enable|pf-delete <descr|id> [--apply]
#   bash pfsense-backend.sh <vault-path> pf-set-ports <descr|id> <dstport> [--apply]
#   bash pfsense-backend.sh <vault-path> fw-list
#   bash pfsense-backend.sh <vault-path> fw-disable|fw-enable <descr|id> [--apply]
#   bash pfsense-backend.sh <vault-path> block-ips <ip[,ip,...]> [--alias NAME] [--apply]
#   bash pfsense-backend.sh <vault-path> setup        # print the one-time install+vault steps
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
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/pfsense-backend" "$1" --context "${2:-}" >/dev/null 2>&1 || true; }
VP="${1:?usage: pfsense-backend.sh <vault-path> <action> [args] [--apply]}"
ACT="${2:?action: audit|pf-list|pf-disable|pf-enable|pf-delete|pf-set-ports|fw-list|fw-disable|fw-enable|block-ips|setup}"
shift 2
APPLY=0; ALIAS="gw_control_blocklist"; POS=()
while [ $# -gt 0 ]; do case "$1" in
  --apply) APPLY=1; shift;;
  --alias) ALIAS="${2:?--alias needs a name}"; shift 2;;
  *) POS+=("$1"); shift;; esac; done

setup_msg(){ cat <<EOF
[SETUP] pfSense REST API backend - one-time per client:
  1) pfSense GUI -> System > Package Manager > Available Packages: install 'RESTAPI'
  2) System > REST API > Settings: enable; Auth Method = API Key
  3) System > REST API > Keys: create a key (READ-ONLY for audit; a write-capable key for pf-/fw-/block-ips)
  4) vault it (url = https://<pfsense-ip-or-host>, apikey = the key):
     bash $REPO/.claude/skills/vault/scripts/vault-helper.sh new $VP \\
       --kind generic --name '<Client> pfSense REST API' --tag pfsense \\
       --set url=https://<pfsense> --set apikey=<key>
  (No package / can't install? SSH 'easyrule' + config.xml fallback is the planned alt backend - ROADMAP section E.)
EOF
}
[ "$ACT" = "setup" ] && { setup_msg; exit 0; }

URL="$(bash "$VAULT" get-field "$VP" credentials.url 2>/dev/null || bash "$VAULT" get-field "$VP" url 2>/dev/null || true)"
KEY="$(bash "$VAULT" get-field "$VP" credentials.apikey 2>/dev/null || bash "$VAULT" get-field "$VP" apikey 2>/dev/null || true)"
if [ -z "$URL" ] || [ -z "$KEY" ]; then
  echo "[BLOCKED] no pfSense REST API cred at vault:$VP"; echo; setup_msg; exit 2
fi
echo "[INFO] pfSense $ACT @ $URL  mode=$([ $APPLY = 1 ] && echo APPLY || echo DRY-RUN)"

export PF_URL="$URL" PF_KEY="$KEY" PF_ACT="$ACT" PF_APPLY="$APPLY" PF_ALIAS="$ALIAS" REPO
export PF_A0="${POS[0]:-}" PF_A1="${POS[1]:-}"
python - <<'PY'
import os,sys,json,ssl,urllib.request,urllib.error
URL=os.environ['PF_URL'].rstrip('/'); KEY=os.environ['PF_KEY']
ACT=os.environ['PF_ACT']; APPLY=os.environ['PF_APPLY']=='1'
A0=os.environ['PF_A0']; A1=os.environ['PF_A1']; ALIAS=os.environ['PF_ALIAS']
ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE
def call(method,path,body=None):
    data=json.dumps(body).encode() if body is not None else None
    r=urllib.request.Request(f"{URL}/api/v2{path}",data=data,method=method)
    r.add_header('X-API-Key',KEY); r.add_header('Content-Type','application/json')
    resp=urllib.request.urlopen(r,timeout=20,context=ctx)
    txt=resp.read().decode('utf-8','replace')
    try: j=json.loads(txt)
    except Exception: return txt
    return j
def data(j): return j.get('data',j) if isinstance(j,dict) else j
def err(e):
    body=''
    try: body=e.read().decode('utf-8','replace')[:300]
    except Exception: pass
    print(f"[FAIL] HTTP {getattr(e,'code','?')}: {body or e}")
    print("[hint] if 404/endpoint-not-found, the installed REST API version uses a different path - "
          "check System > REST API > Documentation and adjust pfsense-backend.sh.")
    sys.exit(1)
def save_rollback(tag,obj):
    rp=os.path.join(os.environ.get('REPO','.'),'.claude','tmp',f"pfsense-rollback-{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 ex: print("[rollback] save failed:",ex)
def apply_fw():
    if APPLY:
        try: call('POST','/firewall/apply',{}); print("[ok] firewall changes applied")
        except urllib.error.HTTPError as e: err(e)

def find(items,key,namekeys=('descr','name','desc,')):
    for i,it in enumerate(items):
        if str(it.get('id',i))==str(key): return it
        for nk in namekeys:
            if it.get(nk) and str(it[nk]).lower()==key.lower(): return it
    return None

try:
    if ACT=='audit':
        flags=[]
        print("\n== WAN / Gateways ==")
        try:
            gws=data(call('GET','/status/gateways'))
            for g in (gws or []):
                nm=g.get('name'); st=g.get('status'); loss=g.get('loss'); rtt=g.get('delay')
                print(f"  {nm}: status={st} rtt={rtt} loss={loss} monitor={g.get('monitorip')}")
                if st and str(st).lower() not in ('online','up','none',''): flags.append(f"WAN {nm} status={st}")
                try:
                    if loss and float(str(loss).rstrip('% ').strip() or 0)>5: flags.append(f"WAN {nm} loss={loss}")
                except Exception: pass
            if not gws: print("  (no gateway data)")
        except urllib.error.HTTPError as e: print(f"  [skip] status/gateways: HTTP {e.code} (verify endpoint)")
        print("\n== System ==")
        for ep in ('/status/system','/system/version'):
            try:
                s=data(call('GET',ep))
                if isinstance(s,dict): print("  "+", ".join(f"{k}={s.get(k)}" for k in list(s)[:8])); break
            except urllib.error.HTTPError: continue
        else: print("  (system endpoint not found - verify path)")
        print("\n== DHCP scopes (pool pressure) ==")
        try:
            ds=data(call('GET','/services/dhcp_server'))
            for d in (ds or []):
                if not d.get('enable',True): continue
                print(f"  {d.get('interface')}: pool {d.get('range_from')}-{d.get('range_to')}")
            if not ds: print("  (none / disabled)")
        except urllib.error.HTTPError as e: print(f"  [skip] services/dhcp_server: HTTP {e.code} (verify endpoint)")
        print("\n== WAN exposure (port-forwards) ==")
        try:
            pfs=data(call('GET','/firewall/nat/port_forwards'))
            for p in (pfs or []):
                dis='off' if p.get('disabled') else 'on '
                print(f"  [{dis}] id={p.get('id')} '{p.get('descr','')}' {p.get('protocol')} "
                      f"{p.get('interface')} dst_port={p.get('destination_port')} -> {p.get('target')}:{p.get('local_port')} src={p.get('source','any')}")
                if not p.get('disabled') and str(p.get('source','any')).lower() in ('any','wanaddress',''):
                    flags.append(f"port-forward '{p.get('descr')}' open to ANY source")
            if not pfs: print("  (none)")
        except urllib.error.HTTPError as e: print(f"  [skip] firewall/nat/port_forwards: HTTP {e.code} (verify endpoint)")
        print("\n== FLAGS ==")
        print("\n".join("  [!] "+f for f in flags) if flags else "  [OK] (or endpoints need version verification)")
        sys.exit(0)

    if ACT=='pf-list':
        for p in (data(call('GET','/firewall/nat/port_forwards')) or []):
            dis='off' if p.get('disabled') else 'on '
            print(f"  [{dis}] id={p.get('id')} '{p.get('descr','')}' {p.get('protocol')} {p.get('interface')} "
                  f"dst_port={p.get('destination_port')} -> {p.get('target')}:{p.get('local_port')} src={p.get('source','any')}")
        sys.exit(0)
    if ACT=='fw-list':
        for r in (data(call('GET','/firewall/rules')) or []):
            dis='off' if r.get('disabled') else 'on '
            print(f"  [{dis}] id={r.get('id')} {r.get('interface')} {r.get('type')} '{r.get('descr','')}' "
                  f"proto={r.get('protocol')} dst_port={r.get('destination_port')} src={r.get('source','any')}")
        sys.exit(0)

    if ACT in ('pf-disable','pf-enable','pf-delete','pf-set-ports'):
        pfs=data(call('GET','/firewall/nat/port_forwards')); 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 ('disabled','destination_port','local_port','source')}
        print(f"  target: id={pf.get('id')} '{pf.get('descr')}' {before}")
        if not APPLY: print("  [dry-run] add --apply to write."); sys.exit(0)
        save_rollback(f"pf-{pf.get('id')}",pf)
        if ACT=='pf-delete':
            call('DELETE','/firewall/nat/port_forward',{'id':pf['id']}); apply_fw()
            print(f"[ok] deleted port-forward '{pf.get('descr')}'"); sys.exit(0)
        body={'id':pf['id']}
        if ACT=='pf-disable': body['disabled']=True
        elif ACT=='pf-enable': body['disabled']=False
        elif ACT=='pf-set-ports':
            if not A1: print("[FAIL] pf-set-ports needs <dstport>"); sys.exit(1)
            body['destination_port']=A1
        call('PATCH','/firewall/nat/port_forward',body); apply_fw()
        print(f"[ok] {ACT} '{pf.get('descr')}' {before} -> {body}"); sys.exit(0)

    if ACT in ('fw-disable','fw-enable'):
        rules=data(call('GET','/firewall/rules')); r=find(rules,A0)
        if not r: print(f"[FAIL] firewall rule '{A0}' not found"); sys.exit(1)
        print(f"  target: id={r.get('id')} '{r.get('descr')}' disabled={r.get('disabled')}")
        if not APPLY: print("  [dry-run] add --apply to write."); sys.exit(0)
        save_rollback(f"fw-{r.get('id')}",r)
        call('PATCH','/firewall/rule',{'id':r['id'],'disabled':(ACT=='fw-disable')}); apply_fw()
        print(f"[ok] {ACT} '{r.get('descr')}'"); sys.exit(0)

    if ACT=='block-ips':
        ips=[x.strip() for x in A0.split(',') if x.strip()]
        if not ips: print("[FAIL] block-ips needs <ip[,ip,...]>"); sys.exit(1)
        aliases=data(call('GET','/firewall/aliases')); al=find(aliases,ALIAS)
        print(f"  would ensure alias '{ALIAS}' contains: {ips}  + a WAN block rule referencing it")
        if not APPLY: print("  [dry-run] add --apply to write."); sys.exit(0)
        if al:
            before=list(al.get('address',[]) or [])
            save_rollback(f"alias-{al.get('id')}",{'id':al.get('id'),'address':before})
            al['address']=sorted(set(before)|set(ips))
            call('PATCH','/firewall/alias',{'id':al['id'],'address':al['address']})
            print(f"[ok] alias '{ALIAS}' {len(before)}->{len(al['address'])} entries")
        else:
            call('POST','/firewall/alias',{'name':ALIAS,'type':'host','address':sorted(set(ips)),'descr':'gw-control blocklist'})
            print(f"[ok] created alias '{ALIAS}' with {len(ips)} entries")
        rules=data(call('GET','/firewall/rules'))
        existing=next((r for r in rules if r.get('descr')==f"Block {ALIAS}" and r.get('interface') in ('wan','WAN')),None)
        if existing:
            print(f"[ok] WAN block rule 'Block {ALIAS}' already present -> alias updated")
        else:
            call('POST','/firewall/rule',{'type':'block','interface':'wan','ipprotocol':'inet','protocol':'any',
                 'source':ALIAS,'destination':'any','descr':f"Block {ALIAS}",'disabled':False})
            print(f"[ok] created WAN block rule 'Block {ALIAS}' (source alias {ALIAS})")
        apply_fw()
        print("[note] verify rule order/precedence in the pfSense GUI; place the block above permissive WAN rules.")
        sys.exit(0)

    print("[ERROR] unhandled action",ACT); sys.exit(1)
except urllib.error.HTTPError as e: err(e)
except Exception as e: print("[FAIL]",e); sys.exit(1)
PY
rc=$?
[ "$rc" -ne 0 ] && logerr "pfsense-backend REST call failed" "vp=$VP act=$ACT rc=$rc"
exit $rc
