diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index db8ba4b4..a0069b52 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -63,11 +63,14 @@ path is Cascades — override with the script's vault-path arg per client. `pf-set-src ` (port-forwards + associated filter rule) — built, **live-verify pending** (no box with forwards available yet). Filter rules match by `tracker` (the `id` field is empty on 25.07) or exact `descr`. Each write backs up `config.xml` first; writes drive `scripts/pfsense-gwc.php`. - - **Dispatch:** `gw-audit.sh`/`gw-control.sh` auto-route the SAME verbs to this SSH backend when a - `clients//pfsense-firewall` cred is vaulted (dispatch runs before UOS site resolution, so a - pfSense-only slug works; pass `--pfsense ` if the UOS site name differs). The REST - `pfsense-backend.sh` (`clients//pfsense-api`) remains a **dormant fallback** only. Design/verb-map + - pfSense PHP gotchas: `references/ROADMAP.md` §E. + - **Dispatch + auto-select:** `gw-audit.sh`/`gw-control.sh` route the SAME verbs to this SSH backend three + ways: (1) explicit `--pfsense `; (2) the site arg is itself a client slug with a vaulted + cred; (3) **auto-select** — the resolved UOS site is bound in `references/site-gateways.tsv`, so no + `--pfsense` is needed (`gw-control Cascades fw-list` just works). Manage the map with + `scripts/gateway-map.sh` (`lookup`/`list`/`validate`/`suggest`); `suggest` lists no-UniFi-gateway sites + not yet bound + vaulted creds not yet referenced. The REST `pfsense-backend.sh` + (`clients//pfsense-api`) remains a **dormant fallback** only. Design/verb-map + pfSense PHP gotchas: + `references/ROADMAP.md` §E. - **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. diff --git a/.claude/skills/unifi-wifi/references/ROADMAP.md b/.claude/skills/unifi-wifi/references/ROADMAP.md index 00bb0915..a93a9451 100644 --- a/.claude/skills/unifi-wifi/references/ROADMAP.md +++ b/.claude/skills/unifi-wifi/references/ROADMAP.md @@ -159,8 +159,12 @@ as a dormant alternative (works if a site ever installs the pkg) but is no longe classified UniFi-gateway (model) vs no-UniFi-gateway (pfSense/third-party candidate), plus the list of vaulted `clients/*/pfsense-firewall` creds with resolved host:port (SSH-backend-ready). Generated live (always current), not a static file. Today: 12 UniFi-gw / 36 no-UniFi-gw sites; 1 pfSense cred (Cascades). - - [ ] **Auto-select from the map** (driver picks the pfSense cred for a site WITHOUT `--pfsense`): cred-path - convention now settled (option A, above) — remaining blocker is just a persisted UOS-site-name→cred binding. + - [x] **Auto-select from the map** (2026-06-21): `gw-audit`/`gw-control` now route a site to its pfSense + cred automatically (no `--pfsense`) when it's bound in `references/site-gateways.tsv` (keyed on the resolved + 24-hex UOS site_id; carries an optional port). Manage with `scripts/gateway-map.sh` + (`lookup`/`list`/`validate`/`suggest` — suggest cross-refs no-UniFi-gw controller sites + vaulted creds to + find unmapped bindings). Validated: `gw-control Cascades fw-list` and `gw-audit Cascades` both auto-dispatch + to the pfSense SSH backend. Seeded with Cascades; add rows via `gateway-map.sh suggest`. - [ ] **VPN convergence:** the "Deeper VPN — gateway-hosted VPN server" item (C) is *easier and better* on pfSense (WireGuard/OpenVPN) than on a USG — fold the Grabb-style "retire Windows RRAS PPTP → gateway VPN" play into the pfSense driver from the start. diff --git a/.claude/skills/unifi-wifi/scripts/gateway-map.sh b/.claude/skills/unifi-wifi/scripts/gateway-map.sh new file mode 100644 index 00000000..3460cb47 --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/gateway-map.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# gateway-map.sh — manage + query the persisted UOS-site -> pfSense-cred map (references/site-gateways.tsv). +# This is the "auto-select" half of ROADMAP §E: gw-audit/gw-control call `lookup` to pick the pfSense SSH +# backend for a site automatically (no --pfsense). Humans use list/validate/suggest to keep the map honest. +# +# Usage: +# gateway-map.sh lookup # print "\t" for a mapped site (exit 1 if none) +# gateway-map.sh list # show the map (with cred-resolves check) +# gateway-map.sh validate # check every row: 24-hex site_id + cred resolves in vault +# gateway-map.sh suggest # no-UniFi-gw UOS sites NOT yet mapped + unbound pfSense creds +set -uo pipefail +REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)" +HERE="$(cd "$(dirname "$0")" && pwd)" +UOS="$REPO/.claude/scripts/uos-mongo.sh"; VAULT="$REPO/.claude/scripts/vault.sh" +MAP="$HERE/../references/site-gateways.tsv" +# Mandatory skill error logging (skill-creator rule): log GENUINE functional failures only. +logerr(){ bash "$REPO/.claude/scripts/log-skill-error.sh" "unifi-wifi/gateway-map" "$1" --context "${2:-}" >/dev/null 2>&1 || true; } +VROOT="${VAULT_ROOT:-$(jq -r '.vault_path // empty' "$REPO/.claude/identity.json" 2>/dev/null)}" +[ -n "$VROOT" ] || VROOT="$REPO/../vault" + +ACT="${1:-list}"; ARG="${2:-}" +[ -f "$MAP" ] || { echo "[ERROR] map not found: $MAP"; exit 2; } +is_hex24(){ printf '%s' "$1" | grep -qE '^[0-9a-f]{24}$'; } +cred_ok(){ [ -n "$(bash "$VAULT" get-field "$1" credentials.password 2>/dev/null || bash "$VAULT" get-field "$1" password 2>/dev/null)" ]; } + +# iterate data rows -> calls `row ` +each_row(){ while IFS=$'\t' read -r sid cred port name; do case "${sid:-}" in ''|'#'*) continue;; esac; "$1" "$sid" "$cred" "${port:-}" "${name:-}"; done < "$MAP"; } + +case "$ACT" in + lookup) + [ -n "$ARG" ] || { echo "[ERROR] lookup needs " >&2; exit 2; } + found="" + while IFS=$'\t' read -r sid cred port name; do + case "${sid:-}" in ''|'#'*) continue;; esac + if [ "$sid" = "$ARG" ]; then found="$cred ${port:-}"; break; fi + if ! is_hex24 "$ARG"; then + ln=$(printf '%s' "${name:-}" | tr 'A-Z' 'a-z'); lk=$(printf '%s' "$ARG" | tr 'A-Z' 'a-z') + [ -n "$ln" ] && case "$ln" in *"$lk"*) found="$cred ${port:-}"; break;; esac + fi + done < "$MAP" + [ -n "$found" ] && { printf '%s\n' "$found"; exit 0; } || exit 1 + ;; + + list) + echo "[INFO] gateway map ($MAP):" + printf " %-26s %-42s %-5s %s\n" "site_id" "cred_path" "port" "site_name / cred-check" + printf " %-26s %-42s %-5s %s\n" "-------" "---------" "----" "---------" + _row(){ local sid="$1" cred="$2" port="$3" name="$4" tag + if cred_ok "$cred"; then tag="[ok]"; else tag="[MISSING CRED]"; fi + printf " %-26s %-42s %-5s %s %s\n" "$sid" "$cred" "${port:--}" "$name" "$tag"; } + each_row _row + ;; + + validate) + rc=0 + _row(){ local sid="$1" cred="$2" port="$3" name="$4" + is_hex24 "$sid" || { echo "[BAD] site_id not 24-hex: '$sid' ($name)"; rc=1; return; } + if cred_ok "$cred"; then echo "[ok] $name ($sid) -> $cred"; else echo "[MISSING CRED] $name ($sid) -> $cred"; rc=1; fi; } + each_row _row + [ $rc -eq 0 ] && echo "[OK] all rows valid" || echo "[WARNING] some rows need attention" + exit $rc + ;; + + suggest) + echo "[INFO] Cross-referencing controller (no-UniFi-gateway sites) + vault (pfSense creds) vs the map..." + # mapped site_ids + mapped="$(grep -vE '^\s*#|^\s*$' "$MAP" | cut -f1 | tr '\n' ' ')" + # no-UniFi-gateway sites from the controller (site_idname) + third="$(cat <<'JS' | bash "$UOS" 2>/dev/null | grep -viE 'WARNING|post-quantum|store now|upgraded|openssh' +var s={}; db.site.find({},{name:1,desc:1}).forEach(function(x){ s[x._id.str]={n:(x.desc||x.name),gw:0}; }); +db.device.find({type:{$in:['ugw','uxg','udm','ucg']}},{site_id:1}).forEach(function(d){ if(s[d.site_id])s[d.site_id].gw++; }); +Object.keys(s).forEach(function(id){ if(s[id].gw===0) print(id+"\t"+s[id].n); }); +JS +)" + if [ -z "$third" ]; then echo "[WARNING] no controller data (UOS unreachable?)"; logerr "UOS query returned no sites (suggest)" "uos=$UOS"; fi + echo + echo " No-UniFi-gateway sites NOT yet in the map (candidates to bind):" + printf '%s\n' "$third" | while IFS=$'\t' read -r id nm; do + [ -n "$id" ] || continue + case " $mapped " in *" $id "*) ;; *) echo " [unmapped] $id $nm";; esac + done + echo + echo " Vaulted pfSense creds (clients/*/pfsense-firewall) and whether a map row references them:" + if [ -d "$VROOT" ]; then + find "$VROOT/clients" -name 'pfsense-firewall.sops.yaml' 2>/dev/null | sort | while IFS= read -r f; do + slug=$(printf '%s' "$f" | sed -E 's#.*/clients/([^/]+)/.*#\1#'); cp="clients/$slug/pfsense-firewall" + if grep -qF " $cp " "$MAP" 2>/dev/null || grep -qF " $cp" "$MAP" 2>/dev/null; then echo " [bound] $cp"; else echo " [UNBOUND] $cp (add a row binding a site_id to it)"; fi + done + fi + echo + echo " To bind: add a TAB-separated row to $MAP: \\t\\t\\t" + ;; + + *) echo "usage: gateway-map.sh [arg]"; exit 1;; +esac diff --git a/.claude/skills/unifi-wifi/scripts/gw-audit.sh b/.claude/skills/unifi-wifi/scripts/gw-audit.sh index 79723c91..674d5c55 100644 --- a/.claude/skills/unifi-wifi/scripts/gw-audit.sh +++ b/.claude/skills/unifi-wifi/scripts/gw-audit.sh @@ -105,20 +105,29 @@ NGW="$(cat "$TMP/ngw" 2>/dev/null || echo 1)" if [ "$NGW" = "0" ]; then # PREFERRED: SSH backend (Mike 2026-06-16). Target = full vault path if --pfsense contains '/' # (option A, Mike 2026-06-21), else a client slug -> clients//pfsense-firewall. - ssh_target="" + ssh_target=""; ssh_port="" + # 1. explicit --pfsense full vault path (option A) 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 + # 2. explicit --pfsense client slug + if [ -z "$ssh_target" ] && [ -n "$PFARG" ]; then case "$PFARG" in + */*) ;; *) [ -n "$(bash "$VAULT" get-field "clients/$PFARG/pfsense-firewall" credentials.password 2>/dev/null)" ] && ssh_target="$PFARG";; esac; fi + # 3. AUTO-SELECT from the gateway map (no --pfsense needed): bind by resolved site name/id + if [ -z "$ssh_target" ] && mapres="$(bash "$REPO/.claude/skills/unifi-wifi/scripts/gateway-map.sh" lookup "$SITEARG" 2>/dev/null)"; then + IFS=$'\t' read -r ssh_target ssh_port <<< "$mapres" + [ -n "$ssh_target" ] && echo; [ -n "$ssh_target" ] && echo "[INFO] site in gateway map -> auto-selected pfSense cred '$ssh_target'" fi + # 4. SITEARG itself a client slug + if [ -z "$ssh_target" ]; then case "$SITEARG" in + */*) ;; *) [ -n "$(bash "$VAULT" get-field "clients/$SITEARG/pfsense-firewall" credentials.password 2>/dev/null)" ] && ssh_target="$SITEARG";; esac; fi if [ -n "$ssh_target" ]; then echo; echo "[INFO] pfSense gateway (SSH backend, target '$ssh_target') -> pfSense gateway audit:" - bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "$ssh_target" audit || true + if [ -n "$ssh_port" ] && [ "$ssh_port" != "-" ]; then + bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "$ssh_target" audit --port "$ssh_port" || true + else + bash "$REPO/.claude/skills/unifi-wifi/scripts/pfsense-ssh.sh" "$ssh_target" audit || true + fi else # REST fallback (dormant): only if a pfSense-api cred is vaulted. cands=() diff --git a/.claude/skills/unifi-wifi/scripts/gw-control.sh b/.claude/skills/unifi-wifi/scripts/gw-control.sh index 5f52c48d..cef7fcac 100644 --- a/.claude/skills/unifi-wifi/scripts/gw-control.sh +++ b/.claude/skills/unifi-wifi/scripts/gw-control.sh @@ -86,6 +86,17 @@ if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else [ -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' diff --git a/.claude/skills/unifi-wifi/scripts/sites.sh b/.claude/skills/unifi-wifi/scripts/sites.sh index 16de5fd0..be990ee0 100644 --- a/.claude/skills/unifi-wifi/scripts/sites.sh +++ b/.claude/skills/unifi-wifi/scripts/sites.sh @@ -96,5 +96,7 @@ if [ -d "$VROOT" ]; then else echo " (vault not found at $VROOT - set VAULT_ROOT)" fi -echo " Bind a no-UniFi-gateway site to its pfSense cred via --pfsense , e.g.:" -echo " bash .claude/skills/unifi-wifi/scripts/gw-audit.sh '' --pfsense " +echo " One-off: bind via --pfsense , e.g. gw-audit '' --pfsense ." +echo " Persistent AUTO-SELECT (no --pfsense): add a row to references/site-gateways.tsv —" +echo " bash .claude/skills/unifi-wifi/scripts/gateway-map.sh suggest # shows unmapped sites + creds" +echo " bash .claude/skills/unifi-wifi/scripts/gateway-map.sh list|validate"