Commit Graph

33 Commits

Author SHA1 Message Date
vh 4f0c5f1f66 feat(inventory): bring nh3-dev under infra-ops management
Adds the NH3 dev box (10.100.10.50) as a managed host: README, first
system-details snapshot, ssh-target. Adds it to the fleet bootstrap's
Tier 1 and the CLAUDE.md servers table. infra-ops identity bootstrapped
there (operator-run) — NOPASSWD sudo + key, so root-level infra work on
the box (it runs the egress proxy, ttyd seat, mead-hall, volva, and dev
checkouts) no longer needs a per-task password.

First use: installed Playwright headless-Chromium system deps + binary
for bloom_music's OSMD browser-geometry test harness; headless launch +
real SVG geometry verified.
2026-06-04 14:03:52 -07:00
vh 8c32a0540f feat(infra-ops): commission a dedicated NOPASSWD-sudo agent identity for PFI boxes
Adds a host-agnostic elway play + fleet driver that stand up an
`infra-ops` system user (dedicated ed25519 key, NOPASSWD sudo with
log_output audit, docker group) so the infra-ops agent completes
DevOps work end-to-end instead of handing sudo steps back to the
operator. Scoped to PFI-owned Linux boxes; tiered (compute/app/
sensitive-infra) with SureFire/corviduo/esh/Synology explicitly
excluded. Validated live on irv-ml1.
2026-06-03 14:49:46 -07:00
vh f27ee47fac feat(nh3-egress-proxy): durable SOCKS5 egress on nh3-dev for datacenter-IP-gated colo services
YouTube (and a growing set of services) hard-flag datacenter IPs, bot-gating
even public content regardless of cookies/PO-tokens. Origin case: yt-voice-clipper
on irv-ml1 (Irvine colo) — every yt-dlp fetch returned LOGIN_REQUIRED. Confirmed
pure IP reputation: the same public video fetches cleanly (no cookies) once routed
through nh3-dev's residential egress (70.230.226.88).

- scripts/setup-nh3-egress-proxy.sh: idempotent dante (SOCKS5) install + config.
  Internal-only ACL (10.100.0.0/16), bound to the WG interface, systemd-managed.
- docs/runbooks/nh3-egress-proxy.md: purpose, usage, security model, caveats.

Reusable fleet egress, not yt-voice-clipper-specific.
2026-06-02 22:37:08 -07:00
vh 9e9bdf9810 deploy-stack: add -n to ssh calls to prevent stdin slurp in loops
Without -n, ssh inherits the surrounding loop's stdin and consumes
the heredoc that feeds $changed / $deleted, silently truncating the
diff output to the first file only.
2026-05-17 22:46:41 -07:00
vh c5ab99e74f kokoro: persist custom voices across container recreate
Wrapper only enumerates one voice directory (settings.voices_dir,
default /app/api/src/voices/v1_0 — inside the container's writable
layer, not bind-mounted). Override via VOICES_DIR=/app/user_voices
(host bind mount) and add a command shim that cp -r's built-ins from
the in-image v1_0 into user_voices on every start. Built-ins re-seed
fresh from the image (so upgrades that add voices propagate); custom
.pt files in user_voices are preserved (cp -r is additive).

Also adds scripts/blend_kokoro_voice.py + a playbook around it that
mirrors the wrapper's request-time voice="a(w)+b(w)" math but writes
the result as a named .pt to user_voices, making it discoverable via
GET /v1/audio/voices and persistent across recreate. Defaults to
athena = af_bella(2)+af_aoede(1) normalized.
2026-05-10 17:48:24 -07:00
vh 48aaa53c9d extract news-digest into its own repo (vh/nevermore)
The applet outgrew "stack alongside the infra-management workspace" —
it has its own pyproject, multi-tenant deploy story, separate
release cadence, and isn't actually about managing infrastructure.
Lives at https://gitea.phasefinal.com/vh/nevermore now, with
provenance noted in its initial commit.

This commit removes:
  stacks/news-digest/             (full stack tree)
  playbooks/deploy-news-digest.yaml
  scripts/add-digest-user.sh

The existing ana-docker deployment continues running on its baked
local/news-digest:v5 image — nothing changes for the live install
until you choose to redeploy from the new repo. Migration steps
(rename data dir, redeploy, retire old compose dir) are in
nevermore's README.

Updated:
  README.md     — Current stacks listing now points at the new repo
  STATUS.md     — milestones entry for the extraction
2026-04-29 17:18:14 -07:00
vh 765d89aed3 scripts: add fork-fleet.sh for bootstrapping a new fleet repo
Mirrors the reusable tooling (scripts/, generic playbook templates,
.gitignore, conventions section of CLAUDE.md) into a new directory
and strips everything fleet-specific: servers/, stacks/, configs/,
fleet-named playbooks (deploy-*, decouple-*), runbooks, status,
host-pinned scripts.

Output is an empty conventionally-organized workspace with fresh git
history, ready to populate with a different fleet. Skeleton
CLAUDE.md / README.md / STATUS.md are written with the new fleet
name baked in but no server table or placement rules pre-populated.

Deliberately does NOT create a remote or push — the user picks the
namespace + name explicitly via tea / git remote add.

  scripts/fork-fleet.sh ~/development/acme-prod-management
  scripts/fork-fleet.sh /tmp/test-fork test-fleet
2026-04-28 15:58:22 -07:00
vh d552e289cb news-digest: per-instance cron schedule via env
Hardcoded crontab → render at container start from
DIGEST_CRON_AM + DIGEST_CRON_PM. Defaults match the original
0800 / 2000 so existing deploys are no-ops.

scripts/add-digest-user.sh learns --am and --pm flags so each
teammate's stack can fire on their hours:

  scripts/add-digest-user.sh bob --am "0 6 * * *" --pm "0 17 * * *"
  scripts/add-digest-user.sh carol --pm "30 18 * * 1-5"   # weekdays only

Standard 5-field cron syntax; busybox crond honors the container's
\$TZ. Removed the now-unused stacks/news-digest/crontab file and
the matching COPY in the Dockerfile.
2026-04-28 14:29:55 -07:00
vh aeb5c18ca3 news-digest: multi-tenant deploy + masthead overlap fix
Two pieces:

1) Multi-tenant onboarding via scripts/add-digest-user.sh

   Shared miniflux + per-user digest stack. Onboarding a teammate
   takes one command (plus a one-time sudo for dir creation):

     scripts/add-digest-user.sh <username>

   What the script does:
     - Reads miniflux admin creds from ana-docker
     - Allocates next free port (scans existing digest-*/.env)
     - Generates a random password (or accepts one as 2nd arg)
     - Creates the miniflux user via the admin API
     - Materializes a per-user .env at /opt/docker/compose/digest-<user>/
       (inherits NEWS_DIGEST_TAG from the canonical stack so all
       tenants run the same image)
     - Brings up `docker compose -p digest-<user> up -d`
     - Seeds default world/local feeds in the new user's miniflux
     - Triggers a first digest run

   compose.yaml now uses ${DIGEST_PROJECT:-news-digest} to namespace
   container_name + homepage labels. Default keeps backward-compat
   for the singleton install — existing stacks unaffected.

2) Masthead overlap on phone widths

   Desktop CSS pinned .masthead-edition to grid-row 1, which collided
   with .masthead-brand once the mobile media query collapsed both
   to grid-column 1. Result: "MORNING EDITION" badge stacked on top
   of the "DAILY DIGEST" hero. Reset grid-row to `auto` for all
   three masthead children in the ≤720 px breakpoint so they
   auto-flow vertically.
2026-04-28 13:53:26 -07:00
vh 6f7bb4885e stacks/ is canonical; stacks-mirror/ is drift snapshot — stop confusing the two
Decision recorded in CLAUDE.md ("Stack tree convention") and memory
(convention_stacks_vs_mirror.md):

  stacks/<stack>/                    canonical / intent. git-tracked.
                                     deploy-stack.sh reads from here.
  stacks-mirror/<host>/<stack>/      snapshot / reality. gitignored.
                                     sync-stacks.sh writes here. Used
                                     for drift inspection only — never
                                     a deploy source.

Bug this fixes: deploy-stack.sh was reading from the mirror, so edits
to stacks/llama-swap/config.yaml never reached ana-ml2. Today's
two new model entries (qwen3.6-35-a3b-heretic + qwen3.6-27b) lived
in the canonical for hours but the deploy reported "in sync" because
the script only diffed mirror vs server.

Changes:
* deploy-stack.sh: source switched from MIRROR_DIR/$HOST/$STACK to
  STACKS_DIR/$STACK. Header comment + error message updated.
* sync-stacks.sh: header explicitly identifies its role as drift
  detection; documents the diff command for comparing canonical vs
  mirror.
* stacks/llama-swap/{config.yaml → conf/config.yaml}: matches the
  deploy mapping (conf/ in canonical → /opt/docker/conf/ on host).
* CLAUDE.md: "Stack mirror (pull / push)" section rewritten as
  "Stack tree convention (canonical vs mirror)" with the role table
  + workflow rules + diff recipe. Layout diagram updated.
2026-04-26 22:07:30 -07:00
vh c9c4262bdb scripts/upgrade-irv-ml1-cuda: drop pipe-to-head (SIGPIPE under pipefail)
Phase 1 silently aborted right after the 'cuda-drivers candidate' step
— before snapshotting, stopping containers, installing the new driver,
or rebooting. Symptom: phase2 showed driver still at 570.124.06 and
containers with multi-hour uptimes (proof they were never stopped).

Cause: `apt-cache policy cuda-drivers | head -10`. head reads its 10
lines and closes the pipe early; that delivers SIGPIPE to apt-cache,
which under `set -o pipefail` propagates as a non-zero exit, and
`set -e` immediately terminates the remote shell. The same hazard
existed in phase2's `docker ps ... | head -15`.

Fix is one-line per call site: drop the head. apt-cache policy output
for cuda-drivers is short (~10 lines), and docker ps's container list
on irv-ml1 is small enough to print fully.
2026-04-25 16:51:28 -07:00
vh 8beb858239 scripts/upgrade-irv-ml1-cuda: detect existing cuda repo, clean up orphan
Phase 1 was unconditionally adding /etc/apt/sources.list.d/nvidia-cuda.list
pointing at /etc/apt/keyrings/nvidia-cuda.gpg. But irv-ml1 already had
the upstream-style /etc/apt/sources.list.d/cuda-debian12-x86_64.list
with /usr/share/keyrings/cuda-archive-keyring.gpg — that's how the
existing 570 driver got installed in the first place. APT then refused
both: 'Conflicting values set for option Signed-By regarding source ...
debian12/x86_64/'.

Now the script:
  * removes any orphan nvidia-cuda.{list,gpg} files from a previous
    failed run
  * greps existing /etc/apt/sources.list.d/*.list for a debian12 cuda
    repo entry; if found, trusts and uses it
  * only writes a new repo entry if no existing one is found, and
    when it does, uses the same upstream paths (cuda-archive-keyring.gpg,
    cuda-debian12-x86_64.list) so reruns are stable

Re-run safe — phase1 can be invoked any number of times without
accumulating conflicting configs.
2026-04-25 16:43:59 -07:00
vh e4a809cfc0 scripts/upgrade-irv-ml1-cuda: stage remote script via scp, exec via ssh -t
The previous version piped a heredoc to `ssh -t host bash -s` —
which can't work because `-t` requires a real TTY and heredoc puts
stdin in pipe mode. They're mutually exclusive (orientation.md
gotcha, tripped over once already). The remote sudo therefore aborted
with 'a terminal is required to read the password' before doing
anything.

Restructured: the remote half of each phase is built as a tempfile
locally, scp'd to /tmp on irv-ml1, made executable, and invoked via
`ssh -t host /tmp/upgrade-cuda-remote.sh phase1`. Now stdin is the
real TTY (no pipe), sudo prompts work, and the rest of the script
runs as before.

Same three phases, same rollback behavior, same idempotency story.
Just a transport fix for the local→remote half.
2026-04-25 16:40:00 -07:00
vh 5a5ab94939 scripts: upgrade-irv-ml1-cuda.sh — driver bump 570 → 580 (CUDA 12.8 → 12.9+)
Two-phase script for bumping irv-ml1's NVIDIA driver to support
CUDA 12.9 — currently blocked at 12.8 per driver 570.124.06, which
is why the Kokoro GPU image (built against CUDA 12.9) wouldn't
start.

Phase 1 (interactive — sudo prompted once via ssh -t):
  * snapshot current nvidia packages to /tmp/nvidia-pre-upgrade.txt
    (rollback reference)
  * add NVIDIA's CUDA APT repo for Debian 12 with signed key
  * stop GPU containers (comfyui, cosyvoice, qwen3-tts, index-tts,
    parakeet, kokoro)
  * apt install cuda-drivers (currently pulls 580 series)
  * reboot

Phase 2 (re-run after host comes back):
  * poll for SSH availability
  * verify new driver + CUDA version
  * restart GPU containers via docker compose up -d
  * print next steps (flip Kokoro .env to gpu variant + redeploy)

Includes a rollback subcommand that reinstalls the snapshotted
packages with --allow-downgrades and reboots — for the case where
the new driver regresses on this kernel.

Driver source switched from Debian's nvidia-driver metapackage to
NVIDIA's official cuda-drivers metapackage; bookworm doesn't carry
575+ in its main or backports repos as of 2026-04-25.
2026-04-25 16:35:27 -07:00
vh 1f14c6d959 scripts: restic-prune.sh — quarterly forget + prune ceremony (closes #9)
Toggles --append-only off on the rest-server via a temporary
docker-compose.override.yaml (canonical compose untouched), runs
resticprofile forget --prune --verbose on each client of that
rest-server, then restores --append-only. The restore is wrapped in
a trap so a partial-failure prune still leaves the rest-server in
its safe configuration.

ANA side is fully automated against ana-docker (5 clients:
ana-docker, ana-ml2, esh-docker-vm, vm-esh-nas, esh-vm-db).

NH3 side currently prints a manual DSM ceremony — Synology Container
Manager doesn't expose docker on the expected paths and syncuser
sudo isn't NOPASSWD, so the toggle isn't safely scriptable from
this workstation. The instructions cover the same flow in DSM web
UI + interactive ssh on each NH3 client (nh3-docker, nh3-dev,
irv-ml1).

Usage:
  scripts/restic-prune.sh ana    # ANA only (auto)
  scripts/restic-prune.sh nh3    # NH3 instructions
  scripts/restic-prune.sh all    # both
  scripts/restic-prune.sh -h     # help
  scripts/restic-prune.sh --dry-run ana   # show every command
2026-04-24 22:01:37 -07:00
vh 4f7bf3b0b6 elway: tee every run to /tmp/elway-last.log + ~/.cache/elway/runs/
Avoids the "paste the full output" friction. Every elway run now
writes its full streamed output to two files in addition to the
terminal:

  /tmp/elway-last.log
      always overwritten — the easy "what just happened" target

  ~/.cache/elway/runs/<ts>-<host>-<playbook>.log
      timestamped permanent record; accumulates across runs

Implementation: small _Tee class wraps sys.stdout for the duration
of main() so all `print(...)` calls fan out to the real terminal +
both file handles. Subprocess output already goes through print()
via _stream_process, so the build/healthz/etc. text is captured.
ANSI color codes are kept in the file so colors are preserved in
log viewers that handle them (less -R, modern tail). Strip with
`sed 's/\x1b\[[0-9;]*m//g'` for paste-elsewhere.

New flags:
  --log <path>   override path; replaces both default destinations
  --no-log       terminal-only, skip both files

Path of the permanent log is printed at the top of every run so
you know where it landed without remembering the timestamp pattern.
2026-04-24 17:02:26 -07:00
vh 42fbc4b4c4 elway: lazy sudo probe — don't prompt when every sudo step will skip
Previously the startup logic ran `probe_sudo()` if any step in the
playbook declared `sudo: true`, regardless of whether that step's
creates:/when: gates would actually let it fire. The result on the
task-board deploy rerun was a spurious password prompt followed by
six SKIPPED lines — the prompt served no purpose.

New flow:
  - Remove the upfront probe in main().
  - SSHContext.sudo_password defaults to None; new sudo_probed flag
    tracks whether we've already prompted this session.
  - run_shell_step + run_upload_step call ensure_sudo(ctx) only at
    the point a sudo step is actually executing — i.e. after its
    skip conditions have been evaluated and passed. Idempotent:
    probes at most once per playbook run.

Tradeoff accepted: if the user fat-fingers the password, they see it
mid-run on the first sudo step rather than upfront. `stop_on_fail`
(default true) halts cleanly; they rerun. Lower friction for the
common idempotent-rerun case, same recoverability.

Verified against playbooks/deploy-task-board.yaml — prior run
prompted + completed in 1.7s; new run completes in 1.7s with no
prompt because every sudo step skip-gated.
2026-04-24 15:04:36 -07:00
vh f115c982bc elway: add tier 1 + tier 2 idempotency
Tier 1 — pre-step skip conditions:
  when:    <remote shell expr>   skip unless expr exits 0
  creates: <remote path>         skip if path already exists
  removes: <remote path>         skip if path is already absent
Any of the three saying "skip" marks the step `skipped` and moves on.
Evaluated under bash -c on the remote so `!`, `[[`, pipes etc. behave
consistently regardless of the default remote shell.

Tier 2 — post-step change detection:
  changed_when: <remote shell expr>
Evaluated after a successful step. Exit 0 → step counts as `changed`
(default). Exit != 0 → `ok` (ran, nothing actually different).
Without this field, successful steps default to `changed`, matching
Ansible's shell/command defaults. Useful on verify steps:
`changed_when: "false"` reports them as `ok` since they only attest.

Status model moved from pass/fail to four states:
  ok / changed / failed / skipped
Summary reports each count; overall outcome is CHANGED if any step
changed, OK if none did, FAILED on any non-skipped failure.

Rerunnable smoke: playbooks/elway-smoke.yaml now proves it. On a
clean target the cold run reports 4 changed, 3 ok. Rerunning with
the same vars reports 2 skipped / 2 changed (upload + log-record
have no idempotency hooks and are always `changed`). Overriding
--var greeting=... re-runs the gated step exactly as intended.

Doc block at the top of the script updated with the new schema
fields and state machine.
2026-04-24 10:39:48 -07:00
vh dea95bf526 elway: add mini playbook runner + smoke playbook
`scripts/elway` is a ~600-line Python tool (stdlib + python3-yaml) for
driving one-off ssh commands, ad-hoc file uploads, and YAML playbooks
against a single host. Fills the gap between "single ssh one-liner"
and "reach for Ansible."

Highlights:
  - Three invocation modes: --shell, --upload (LOCAL:REMOTE[:MODE]),
    and --playbook <path>
  - Playbook schema: inline vars, list of steps, optional verify block.
    Template via {{ var }}; CLI --var overrides inline defaults
  - stop_on_fail global (default on), per-step override. Verify phase
    always runs, even after a halt — you see end-state regardless
  - Sudo handled once: probes NOPASSWD; if not, prompts locally via
    getpass, validates up-front, then feeds via `sudo -S` per step.
    Password never written to disk/logs. Upload-with-sudo stages to
    /tmp then sudo-mv + sudo-chmod
  - SSH connection reuse via ControlMaster (60s persist) keeps
    multi-step playbooks responsive (~30ms/step reuse vs ~550ms cold)
  - Live interleaved stdout/stderr with per-step prefix and colored
    pass/fail summary. --dry-run prints the plan without executing
  - Shebang pinned to /usr/bin/python3 to bypass venv-shadowing
    when python3-yaml lives in the system site-packages

Smoke test (playbooks/elway-smoke.yaml) covers vars + upload + verify;
drove out a YAML-scalar-coercion bug before first commit (`shell: false`
parsed to Python bool, crashed the templater — now coerced to string
at load time with a clear error on nulls).
2026-04-24 10:09:16 -07:00
vh fd1c9287cd scripts/refresh-*: StrictHostKeyChecking=accept-new on first connect
BatchMode=yes (which the scripts set) implies strict host key checking
and refuses to prompt — so first-time SSH to a host that isn't in
known_hosts fails with "Host key verification failed". Every new host
we register needs a manual `ssh <host>` round-trip first to store the
key before the refresh scripts can reach it.

accept-new fixes that: unknown hosts are auto-accepted into
known_hosts on first connect; subsequent key CHANGES still fail loudly
(as they should — that'd be a MITM signal).

Matches the pattern already used by deploy-stack.sh.

Affects only refresh-server-info.sh and refresh-proxmox-info.sh;
deploy-stack.sh + sync-stacks.sh use their own targets that may or may
not want the same treatment (leaving alone for now).
2026-04-21 16:53:56 -07:00
vh b33439499b scripts/discover-*: four QoL upgrades from first live gap-analysis run
First real run surfaced 31 gap rows, ~20 of which were noise. These
changes reduce the output to actionable signal.

1. discover-unifi: /ea/devices now filters out
     - IPs outside the fleet LAN range (UDM's WAN IP appearing as a
       "device", ISP uplink records with public IPs)
     - UDM self-records (isConsole=true, or IP matches wans[].ipv4)
     - UCI records (UniFi Cable Internet = ISP modem tracking)
   LAN filter regex defaults to ^10\. (matches 10.0.0.0/8); override
   via UNIFI_LAN_FILTER env var if you run other private ranges.

2. discover-gaps: new --ignore-unifi flag drops rows where the final
   SOURCE column starts with "unifi:". Useful for "show me servery
   things to manage, not the fleet's network hardware."

3. discover-gaps: known-IP set now pulls IPs from
   servers/*/proxmox-details.txt AND servers/*/system-details.txt in
   addition to README.md and ssh-target. Consequence: VMs tracked by
   proxmox_inspect.sh are automatically counted as known without
   needing a separate servers/<vmname>/ dir. Also strips meaningless
   addresses (127.*, 0.0.0.0, 169.254.*) so they can't false-positive
   a "known" match.

4. MAC normalization: both discover-fortigate and discover-unifi now
   emit xx:xx:xx:xx:xx:xx lowercase. Previously FortiGate used colon
   format, UniFi used no-separator uppercase — same MAC looked
   different per source. Fortigate does tolower() in awk; UniFi uses
   a shared jq `norm_mac` function.
2026-04-21 14:19:12 -07:00
vh 57c944ad5f scripts/discover-unifi: correct field selectors for real API response shape
Raw dumps of /ea/hosts and /ea/devices surfaced the actual JSON:

- /ea/hosts: LAN IP isn't at top-level ipAddress (that's WAN public);
  it's buried in reportedState.ipAddrs[] mixed with WAN + link-local.
  Have to pick the first RFC1918 entry that ISN'T also a WAN interface
  IP (reportedState.wans[].ipv4). Name/mac/model all live under
  reportedState.{hostname,mac,hardware.shortname}.

- /ea/devices: outer records are per-host wrappers; real AP/switch
  records are in the nested `devices` array with top-level `ip`, `mac`,
  `name`, `model` fields. Previous parser was reading the wrapper and
  getting all `-`.

Reorder all TSV outputs so IP is column 1 — makes discover-gaps.sh
work uniformly against both FortiGate and UniFi sources. Sites TSV
dropped its IP slot since sites have no meaningful IP (metadata only).

Verified against the real payloads the user captured: ESH-UDMPM now
surfaces as 10.0.0.1 (LAN) instead of 192.168.200.111 (WAN2, RFC1918
but excluded via the wans cross-check). A sample device record
(E7-ESH-Media at 10.0.250.176) flattens correctly into a single TSV row.
2026-04-21 14:12:42 -07:00
vh ddc45dc3bd scripts/discover-unifi: add 'raw <path>' mode for debugging selectors 2026-04-21 14:07:28 -07:00
vh 3bdbfc3424 scripts/discover-unifi: switch to Site Manager API (developer.ui.com)
Rewrite to use Ubiquiti's public cloud API at api.ui.com instead of
logging into individual controllers via session cookies. Benefits:

  - One API key covers every UniFi OS device on the account (no
    per-controller login logic, no cookie jar lifecycle).
  - Read-only by design (auth keys are scoped).
  - Works across sites transparently.

Three endpoints wired up: hosts (controllers / Cloud Keys), sites,
and devices (APs / switches). Each emits a distinct TSV shape so the
output can be concatenated and still parsed.

`all` mode runs all three and prints section markers on stderr so
the stdout stream stays clean TSV suitable for discover-gaps.sh.

Pagination handled via nextToken. Rate limit not enforced locally;
Ubiquiti documents generous defaults for read endpoints.

Note: Site Manager API (early access) doesn't appear to expose a
connected-client list directly. For endpoint discovery (IP + MAC of
connected clients like laptops, IoT, etc.) we'd still need to hit
each local controller's REST API — follow-up if the infrastructure-
level data isn't enough.

Requires: curl (present), jq (apt install jq).
2026-04-21 14:04:50 -07:00
vh 8f1a2789a9 scripts/discover-fortigate: validate by IP presence, not error-string match
Bug: FortiOS 7.x ana-gw replied to 'execute dhcp lease-list all' with
"Interface name 'all' does not exist." — my error-pattern grep didn't
include that phrase, so the script thought it got valid data, bailed
out of the retry loop, and handed empty/garbage to the parser, which
produced zero output with no error.

Fix: try the plain `execute dhcp lease-list` form first (works across
versions we've seen), fall back to the `all` variant only if the plain
form returns nothing. Validate acceptance by grepping for an actual
IP-shaped token — the parser needs IPs anyway, so "got real data"
and "has at least one IP" are equivalent conditions.
2026-04-21 13:51:10 -07:00
vh 6f998a83c3 fleet: retire NH3-Firewall (Fortigate 101F @ 10.100.250.1)
Device has been removed from the NH3 site. Drop the homepage card
and the corresponding example in discover-fortigate.sh.

Note left in services.yaml so whoever adds the replacement edge
device knows where the old entry lived.
2026-04-21 13:48:26 -07:00
vh 6a72408c5e scripts/discover-fortigate: handle real FortiOS output + user@host arg
First live run against ana-fw.phasefinal.com surfaced two bugs:

1. Script double-prefixed user@ when the arg already contained it
   (e.g. `admin@10.250.250.1` became `admin@admin@10.250.250.1` →
   auth prompt loop). Accept either "host" or "user@host" and only
   prepend the default user if missing.

2. Parser assumed the wrong output format. Real FortiOS (tested on
   7.x) emits:

     <prompt> # <iface>
       IP          MAC-Address         Hostname    VCI  SSID  AP  SERVER-ID  Expiry
       10.x.x.x    ...
     <next-iface>
       IP          MAC-Address ...

   - Interface names are flush-left (no "Interface:" prefix)
   - First line has the shell prompt embedded before the iface
   - Hostnames don't contain spaces in practice
   - 8 columns, not 4; VCI can contain "udhcp 1.32.1" etc.

   Rewrote awk to detect interfaces via indentation (flush-left = iface,
   indented = header or lease) and extract IP/MAC/Hostname from the
   first three tokens of each lease row.

Verified against a captured sample; emits clean TSV.
2026-04-21 13:46:39 -07:00
vh 83eddc872a scripts/discover-fortigate: stop hiding SSH errors
First real run returned empty and we had no idea why — the script was
silently swallowing stderr via `2>/dev/null`. Remove the suppression
and try both `execute dhcp lease-list all` and the no-arg form, keeping
whichever returns non-error output.

Also emit a clearer diagnostic when both fail, pointing the user at
an interactive SSH to poke at command syntax.
2026-04-21 13:35:53 -07:00
vh 921891b27a scripts: network-discovery tooling for FortiGate + UniFi
Three scripts that surface hosts on the fleet's networks that aren't
already tracked under servers/*/. Goal: spot servers that need management
coverage (inventory, backup, monitoring) without wandering the LAN by
hand.

  discover-fortigate.sh  SSH to a FortiGate admin, run
                         `execute dhcp lease-list all`, emit TSV
                         (IP, MAC, hostname, vdom, source).

                         SSH was picked over the REST API for now
                         because it needs no API-token plumbing. The
                         parser is defensive about FortiOS output
                         format drift (multiple VDOM sections,
                         optional hostname). API variant can replace
                         it when the extra robustness is worth the
                         token setup.

  discover-unifi.sh      Cookie-auth REST call against a UniFi
                         Controller. Tries /api/auth/login (UniFi OS
                         / UDM / Cloud Key Gen2+) first; falls back
                         to legacy /api/login for self-hosted
                         controllers. Output is the same TSV shape
                         as the FortiGate script so the two mix.

                         Needs UNIFI_USER / UNIFI_PASS env and jq.

  discover-gaps.sh       Consumes one or more TSVs from the sources
                         above. Builds the set of managed IPs from
                         servers/*/ssh-target (plus a grep of README
                         files for documented IPs) and prints any
                         discovered IPs not in that set.

                         Exit code is 1 if gaps found — suitable for
                         cron alerting.

Common pipeline:
  scripts/discover-fortigate.sh ana-fw.phasefinal.com > /tmp/ana.tsv
  scripts/discover-fortigate.sh nh3-gw.phasefinal.com > /tmp/nh3.tsv
  UNIFI_USER=admin UNIFI_PASS=… scripts/discover-unifi.sh esh-uc.esteban.net > /tmp/esh.tsv
  scripts/discover-gaps.sh /tmp/ana.tsv /tmp/nh3.tsv /tmp/esh.tsv

First-time use probably needs:
  - SSH access configured to each FortiGate (admin login, key preferred)
  - UniFi user with read access (the built-in API read-only role works)
  - `jq` installed on the runner (for UniFi script)
2026-04-21 11:51:00 -07:00
vh 1431768596 scripts: refresh-proxmox-info.sh wrapper for PVE snapshots
Parallel to refresh-server-info.sh but pipes proxmox_inspect.sh and writes
to servers/<host>/proxmox-details.txt. Same discovery / ssh-target /
validate-only / dry-run behavior.

Fleet-wide `all` matches dir names containing `-pve` (covers *-pve and
*-pve-* so esh-pve-nas is included alongside pfi-pve / nh3-pve / esh-pve).
Explicit names are never filtered — useful for one-off PVE hosts with
non-matching names.

Validation checks the captured snapshot for: truncation, missing PVE
version line, non-Proxmox target, and surfaces backup-coverage "NO"
verdict counts so gaps show up in the validate-only output.

Initial fleet snapshot refreshed.
2026-04-20 23:01:48 -07:00
vh 852896120a scripts: proxmox_inspect.sh for VM/LXC/storage/backup discovery
Read-only companion to server_inspect.sh. Runs over ssh via stdin the
same way:

  ssh root@pfi-pve 'bash -s' < scripts/proxmox_inspect.sh \\
      > servers/pfi-pve/proxmox-details.txt

Captures what the generic inspect misses on PVE nodes:

- Cluster + node resources (pvesh get /cluster/resources)
- VM list + per-vmid config highlights (qm list, qm config)
- LXC list + per-ctid config highlights (pct list, pct config)
- Storage pools (pvesm status, zpool, storage.cfg, NFS mounts)
- Backup jobs table parsed from /etc/pve/jobs.cfg (no jq needed)
- Per-guest backup coverage verdict: YES/NO with reason
- Most-recent actual backup per VMID on each local dump storage
- PBS targets, replication jobs, listening ports, subscription status

Coverage analysis surfaces the real gap: on pfi-pve, VMIDs 106, 107,
109, 112, 113 have no scheduled backup; VMID 106 (PFI-Tailscale) has
not been backed up since 2024-05-31. nh3-pve uses "all" job and covers
everything. esh-pve + esh-pve-nas share the tank-vmbu dump storage via
NFS; coverage splits cleanly across the two nodes.

First snapshots committed for each PVE host.
2026-04-20 22:14:57 -07:00
vh 8c15ec2d5f Add the four Proxmox hypervisors to the inventory
pfi-pve (Anaheim), nh3-pve (NH), esh-pve + esh-pve-nas (ESH home lab) now
have servers/<host>/ entries with ssh-target files, README stubs, and
first snapshots. Register all four via add-host.sh + ssh-copy-id; SSH
auth is root@<name> with key-based access.

Fix a resolver bug in the three workspace scripts: previously, when a
hostname resolved via /etc/hosts, resolve_target returned the bare name
and ssh dropped the user prefix stored in ssh-target (connecting as the
wrong user). Now ssh-target wins whenever it's present, since it can
carry user@ or port info that /etc/hosts + ssh_config cannot. Falls back
to the dir name only when no ssh-target file exists.

CLAUDE.md and README.md split the fleet table into Docker hosts vs
hypervisors with an explicit placement rule: don't deploy Docker stacks
on PVE hosts; capture host-level detail with server_inspect.sh, use
Proxmox-native tooling (qm list, pvesh, zpool) for VM/storage audits.
2026-04-20 14:58:17 -07:00
vh e376d0aec9 Initial commit: PFI fleet inventory, stacks, tooling, and backup pipeline
Captures the full workspace state built up to this point:

  - CLAUDE.md + README.md describing conventions and the four-host fleet
    (ana-ml2, ana-docker, nh3-docker, esh-docker-vm).
  - Per-host notes under servers/<host>/ with ssh-target fallback files
    and latest system-details snapshots (two in-compose credential leaks
    scrubbed; the upstream compose files still need to move those to .env).
  - scripts/: server_inspect.sh (read-only remote diagnostic),
    refresh-server-info.sh (dir-driven discovery + snapshot capture with
    validation warnings), add-host.sh, sync-stacks.sh (pull
    compose/conf trees), deploy-stack.sh (push with per-file diff + prompt).
  - stacks/: canonical compose for backrest, beszel, dozzle, llama-swap,
    rest-server-ana, rest-server-nh3, vllm-qwen3, plus the retired
    infinity reference. All use the .env-driven + traefik-net + homepage
    label pattern.
  - configs/restic/ana-docker/: first resticprofile config + pre-backup
    hook (Synapse pg_dump, Seafile mysqldump, Vaultwarden SQLite); templates
    for the other three hosts to come.
  - docs/pfi/: general infrastructure reference carried over.
  - .gitignore excludes .env, stacks-mirror/, and assorted secret/state
    filenames to prevent re-leaks on later commits.
2026-04-20 14:29:48 -07:00