1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
|
# dandokmang status console
Static status page for the dandokmang homelab server. A cron job runs
`render.sh`, which runs every check script in `checks/`, collects their
output, and writes a static `index.html` served by Caddy on the WireGuard
interface only.
## Layout
```
statuspage/
checks/ one script per metric or group of related metrics
lib/
common.sh shared helpers (svc_status, port_check, jail_status, json_line)
render.sh orchestrator + HTML renderer
README.md
```
## The check-script contract
This is the part to remember when you come back in six months to add a
metric: **every script in `checks/` is independent. `render.sh` never
changes to support a new check.**
A check script must:
- Be executable (`chmod +x`).
- Emit **one JSON object per line** (JSONL) to stdout, and nothing else.
- Emit no output to stdout on failure (send diagnostics to stderr if you
want them, but they're discarded by the renderer).
Each JSON object has exactly these fields:
| field | meaning |
|-----------|-------------------------------------------------------------------|
| `section` | groups rows under a heading: `hardware`, `host services`, `jails` (or a new section name - unrecognized sections are rendered after the known ones, in the order first seen) |
| `label` | display name for the row |
| `value` | display value; use `""` for a plain up/down check with no value |
| `status` | one of `ok`, `warn`, `down` - maps to a green/yellow/red square |
Example (`checks/wireguard.sh`):
```json
{"section": "host services", "label": "wireguard (wg0)", "value": "", "status": "ok"}
```
A script can emit multiple lines - `hw.sh` emits one line each for load
avg, uptime, free mem, zroot usage, and zpool health.
Use `json_line SECTION LABEL VALUE STATUS` from `lib/common.sh` instead of
hand-rolling the JSON - it escapes backslashes, quotes, and embedded
newlines (e.g. a multi-line `zpool status -x` when a pool is degraded) so
the JSONL stays one object per line no matter what a command prints.
### Adding a new check
1. Create `checks/<section>-<name>.sh` (or reuse an existing section
script if the metric belongs with it).
2. `. "$(dirname "$0")/../lib/common.sh"` and call `json_line` for each row.
3. `chmod +x` it.
That's it - no changes to `render.sh`, no template edits. It picks up any
executable `checks/*.sh` file automatically and renders whatever sections
it finds.
### Jails are auto-discovered, not one script per jail
`checks/jails.sh` is the exception to "one script per metric" - instead of
a `jail-<name>.sh` per jail, it calls `jls -n name` once and reports every
*currently running* jail it finds, resolving each one's IPv4 address (via
the `ip4.addr` jail parameter for classic jails, falling back to `jexec
<name> ifconfig` for VNET jails, which manage their own network stack and
usually don't have `ip4.addr` set) and probing port 80 on it.
Roll a new jail and it shows up on the next render automatically - no file
to add. The trade-off: a jail that's *supposed* to exist but isn't running
won't show up as a `down` row, it just won't appear at all, since `jls`
only lists running jails. If you need "this jail should exist and
doesn't" alerting, that needs an explicit expected-jails list, which isn't
implemented here.
## Failure isolation
`render.sh` runs each check script and handles three failure modes without
aborting the whole render:
- **Non-zero exit** -> a `down` row labeled with the script's filename under
a synthetic `checks` section.
- **No output** -> a `warn` row, same treatment.
- **Output that doesn't look like our JSONL** -> a `warn` row, same
treatment.
So a broken check shows up as a visible red/yellow row on the page instead
of silently disappearing or crashing the render.
## Dependencies
Parsing is done with plain `awk`/`sed`/`grep` against the constrained JSONL
shape our own `json_line` helper produces - no `jq` dependency. If checks
ever need to emit richer JSON (nested objects, arrays), the awk parser in
`render.sh` (`jsonval()`) will need to grow or be swapped for `jq`.
## Deployment
`/usr/local/etc/statuspage` is a symlink to this checkout
(`~/projects/console/statuspage` on the server). Deploying an update is
just `git pull` there - cron picks up the change on its next run.
Cron (as root):
```
* * * * * /usr/local/etc/statuspage/render.sh
```
Caddyfile - served over a real, browser-trusted cert via DNS-01 (Porkbun),
but bound only to the WireGuard interface so it's unreachable from the
public internet even though the hostname resolves via public DNS:
```
status.dandokmang.com {
bind 172.16.0.1
tls {
dns porkbun {
api_key {env.PORKBUN_API_KEY}
api_secret_key {env.PORKBUN_SECRET_KEY}
}
}
root * /usr/local/www/status
file_server
}
```
This requires:
- A Caddy build with the `github.com/caddy-dns/porkbun` module (the
pkg-installed FreeBSD binary doesn't include it - get one from
[caddyserver.com/download](https://caddyserver.com/download)).
- `status.dandokmang.com` set as an explicit A record -> `172.16.0.1` in
Porkbun DNS, overriding the `*.dandokmang.com` wildcard that otherwise
points to the public IP.
- `PORKBUN_API_KEY` / `PORKBUN_SECRET_KEY` set in the environment Caddy's
service reads (Porkbun account -> API Access, enabled per-domain).
Reload Caddy after editing: `service caddy reload`.
Access from a WireGuard peer: `https://status.dandokmang.com`.
`render.sh` writes to `/usr/local/www/status/index.html` by default;
override with the `STATUSPAGE_OUT` environment variable (useful for local
testing without root).
|