# AGENTS.md Guidance for AI coding agents working in this repository. ## What this repo is `statuspage/` generates a static status console for a FreeBSD homelab server (`dandokmang.com`). Modular POSIX `/bin/sh` check scripts each emit JSONL rows; an orchestrator (`render.sh`) runs them all, groups the rows by section, and writes a static `index.html` (dark/monospace, stoplight-grid style) that Caddy serves over WireGuard. **Start with `statuspage/README.md`** for the full JSONL contract between check scripts and the renderer, the jails-are-auto-discovered-not-scripted design, failure-isolation behavior, and the current deployment recipe (Caddy + DNS-01 cert via Porkbun). This file only covers what that one doesn't: workflow and environment gotchas specific to developing here. ## Homelab topology Context that isn't tied to any one check script but explains why they're shaped the way they are: - FreeBSD 15.0-RELEASE, single physical homelab server (`dandokmang.com`). - WireGuard interface `wg0`, network `172.16.0.0/24` - the status page is reachable only from this network (see `statuspage/README.md`'s Caddy config). - Caddy is the host's reverse proxy, fronting the jails below and (per its own Caddyfile, not in this repo) the status page's `status.dandokmang.com` vhost. - ZFS pool `zroot`. - VNET jails on `bridge0` / `192.168.100.0/24`: `cgit` (`192.168.100.10`) and `www` (`192.168.100.20`), each running nginx on port 80. - Host services outside the jails: `wg0`, `caddy`, `ddclient` (dynamic DNS, since the box is on a residential/dynamic IP - this is also why `checks/wg-watcher.sh` exists, to catch the WAN-change-reaction daemon dying silently). `checks/ddclient.sh` currently only checks the process is running (`svc_status`), not that updates are actually succeeding - **ddclient logging isn't enabled on this box**, so a "did the last update actually succeed" check can't be built by tailing a log file that doesn't exist. It'd need to read ddclient's cache file (records the last IP it set) or compare against the box's actual public IP instead - neither has been explored yet. ## Where code runs vs. where it's edited This checkout is on Windows. None of the runtime commands the check scripts depend on (`sysctl`, `jls`, `jexec`, `pfctl`, `zpool`, `wg`, `service`, `ntpq`) exist here - they only exist on the target FreeBSD box. - **Validate here**: `sh -n path/to/script.sh` for syntax, and dry-run logic by putting small shell stubs for the real commands in a temp dir and prepending it to `PATH` (e.g. a fake `pfctl` that echoes canned output). This is the only way to exercise check-script logic without the real box. - **Deploy for real**: this repo's `origin` remote is `ssh://cgit-jail/srv/git/console.git`. The FreeBSD server has its own clone at `~/projects/console`, with `/usr/local/etc/statuspage` symlinked to `~/projects/console/statuspage`. `render.sh` runs there via root cron every minute, writing to `/usr/local/www/status/index.html`. Changes only take effect after: commit here -> push -> `git pull` on the server. There is no way to verify a change actually works against real system state from this machine - say so rather than claiming success. ## Committing shell scripts `core.fileMode` is `false` on this checkout, because this Windows filesystem doesn't reliably preserve the executable bit - `chmod +x` followed by a plain `git add` still stages a `.sh` file as `100644`. `git config core.hooksPath .githooks` (already set in this checkout, but **not** carried by a fresh clone - re-run it once after cloning elsewhere) activates a pre-commit hook that force-sets `+x` on every staged `*.sh` file automatically. A non-`.sh` executable (rare - the hook only globs `*.sh`) needs `git update-index --chmod=+x ` by hand, and so does the hook file itself if it's ever edited (it can't fix its own bit). ## Gotchas already hit once - don't re-discover these - **`ln -s` on a directory, tested on this Windows box, silently falls back to a real copy instead of a symlink.** Don't trust symlink behavior verified here; it needs confirming on the real FreeBSD box. - **Greedy regex substring traps**: `usec` contains `sec`, so `sed 's/.*sec = \(...\).*/.../'` will match the *last* occurrence (`usec`), not the first. Anchor tightly (e.g. match the literal `{ sec = ` prefix) instead of relying on `.*` to stop at the right spot. - **`ntpq`'s `rv 0 offset` prefixes non-negative values with a literal `+`.** A sed capture class of `[-0-9.]` silently drops the `+` and matches empty rather than failing loudly - always include `+` alongside `-` in numeric-capture character classes. - **`grep -c pattern` exits `1` when the count is `0`**, even though it prints `0`. Guard command substitutions that end in `grep -c` with `|| var=0`, or `set -e` will kill the whole script on a legitimate zero-count case. - **Most check commands (`pfctl`, `jls`, `wg`, `jexec`) need root.** `render.sh` runs as root via cron, so this isn't an issue in production, but manual testing on the box needs `doas`/`sudo`. ## Cosmetic conventions (render.sh) - Status values are `ok` / `warn` / `down` (green/yellow/red) or `info` (gray, for rows that aren't a health signal - e.g. `pf.sh`'s rule listing). Don't repurpose the health colors for non-health rows. - The font (`statuspage/fonts/scientifica.ttf`, a bitmap-style font) only ships Arrows, Geometric Shapes, Box Drawings, Mathematical Operators/Symbols-A, Misc Technical/Symbols, and PUA/Powerline glyphs - notably **not** Dingbats (✓/✗). Check glyph coverage against that list before adding new symbols to check-script output. ## Design preference: rows should fit on one line The user has repeatedly pushed back on wrapped/overflowing row text - this is a standing preference, not a one-off request. When a check emits a label or value that could be long or variable-length (rule dumps, peer lists, anything sourced from a system command's verbose output), assume it needs to be compressed to fit a ~380px column at 16px, not just left to wrap. `td.label`'s hanging indent (`text-indent`/`padding-left` in `render.sh`) exists as a fallback for when wrapping is unavoidable, not as the primary solution - reach for compression first. `checks/pf.sh`'s `abbreviate_rule()` is the reference example for how this compression was actually developed, worth following the same approach for any future long-text row: 1. First attempt was the raw `pfctl -sr`/`-sn` output verbatim - overflowed badly, multi-line wraps looked messy even with the hanging indent. 2. Stripped tokens that are boilerplate *for this specific ruleset* (`quick`, `flags S/SA`) rather than guessing generically - checked against the user's real `/etc/pf.conf` first rather than assuming. 3. Swapped `in`/`out` for `→`/`←`, generalized `from X to Y` to `X → Y`, and replaced pfctl's own ASCII `->` with the same unicode arrow for visual consistency - all using only the confirmed-supported Arrows block (see the font gotcha above). 4. Re-sorted the rule list by (interface, direction) for readability. Display-only - doesn't touch pf's real evaluation order or `quick`/first-match semantics. 5. For the state-tracking flag (`no state`/`keep state`/`modulate state`/`synproxy state` - four modes with real, different security properties), the instinct was to find clever unicode from Mathematical Operators/Geometric Shapes, but landed on plain bracket-letter tags (`[N]`/`[K]`/`[M]`/`[S]`) instead: a distinction with real operational meaning needs to be legible without a legend, and a "clever" symbol that requires memorizing isn't actually more compact once you factor in "what does this mean." 6. Every step was verified against a rendered preview (Artifact tool with mocked check output), not just eyeballed character counts - monospace- ish bitmap font wrapping doesn't line up with raw string length in an obvious way. **Known gap**: `abbreviate_rule()`'s regexes were developed and tested against filter-rule (`-sr`) syntax only. It also runs on `-sn` (nat/rdr) output, but no nat/rdr rule shape has actually been exercised against it - a new nat rule may pass through unshortened rather than compressing as tightly as a filter rule. Check the rendered page after adding one. ## When cron + static HTML stops being enough This project started as a refactor of a single monolithic cron script (one `check.sh` that shelled out to various commands and heredoc'd a static page) into the independently-addable checks/render.sh split described in `statuspage/README.md`. The goal was always "modular static page," not "monitoring platform" - if a future ask starts pushing on one of these, say so explicitly rather than bolting a workaround onto the current design: - **Alerting / notification on state change.** The current design is pull-only - a human has to load the page to see anything. There's no mechanism to push "X just went down" anywhere. That needs a real daemon (or at minimum a separate cron job comparing renders and calling out). - **History or trends** (e.g. "graph load average over the last day"). Each render overwrites the last; nothing persists prior states. That needs a time-series store, which is a different project. - **Faster-than-cron or push-based updates.** Cron's practical floor is about a minute, and the page currently self-refreshes via `` (a full reload), not a live push. If sub-minute latency or in-place updates without a full reload actually matter, that means WebSockets/SSE and a long-running process, not this architecture. - **Check execution time approaching the 1-minute cron interval.** This one's an operational red flag rather than a feature request - if `render.sh` starts taking close to 60s to run, cron ticks will begin overlapping/queuing. Worth watching as more checks get added.