Files
agent-skills/skills/bastille-jail-provisioning/SKILL.md
T
MalinandClaude Sonnet 5 251498ad58 bastille-jail-provisioning: add FreeBSD-adapted script safety patterns
Merge in a checklist-style "provisioning script safety" section, adapted
from wshobson/agents' bash-defensive-patterns skill (fetched directly
from GitHub, MIT licensed) rather than trusted from its catalog summary.

Covers: shell-choice caution (#!/bin/bash isn't guaranteed on FreeBSD;
bash is a package at /usr/local/bin/bash, not base), a contextual
error-handling philosophy instead of a blanket `set -Eeuo pipefail`,
trap-based cleanup/logging for scripts interrupted mid-jail-creation
(without auto-rollback, which can worsen the known destroy/create IP
churn issue), jail-name/IP input validation before destructive `bastille`
commands, and FreeBSD `mktemp`/`sed -i`/`date` syntax differences from
GNU. Deliberately dropped the upstream's interactive `rm -rI` cleanup
pattern (wrong for unattended SSH automation) and did not import
retry/locking/ShellCheck-gate advice that wasn't actually present in the
real upstream files. Cross-references remote-shell-quoting-safety instead
of duplicating its nested-shell-quoting content.

Also documents the /usr/local/bastille vs /www/bastille bastille_prefix
split already present across this fleet's hosts (gringo/staging vs
granja), which the new script-safety guidance assumes readers already
know not to hardcode.

Full worked patterns and code examples live in the new
references/script-safety.md; SKILL.md keeps the load-bearing summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:28:05 +02:00

328 lines
18 KiB
Markdown

---
name: bastille-jail-provisioning
description: Use when creating a new FreeBSD bastille jail (a new site/service on a shared-LAN jail host) via clone-from-known-good-base. Covers the IP-conflict gotcha that has caused real, time-costly incidents twice.
---
# Bastille Jail Provisioning
Pattern for standing up a new jail on a FreeBSD host running Bastille,
by cloning a known-working base jail (already has nginx+php-fpm+packages
configured) rather than building from scratch.
## CRITICAL FIRST STEP: arp-scan before picking an IP
**Confirmed real incident, twice, on two different hosts on the same
shared LAN**: an IP address that looked free by every host-local check
(`bastille list all`, `ifconfig` aliases, a manually-tracked "already
used" list) turned out to already belong to a **different physical
device, or a jail on a different host sharing the same LAN segment**.
Symptom: the new jail's DNS resolution and outbound TCP connections
silently fail or time out with no useful error — this looks exactly like
a jail networking/pf config bug and can cost real time chasing the wrong
theory before the actual cause (an IP conflict) is found.
**Before assigning ANY candidate IP, always:**
```bash
pkg install -y arp-scan # if not already present
arp-scan --interface=vtnet0 <candidate-ip>
```
Zero responses = genuinely free. **Any** response (even from a device
with no recognizable vendor match) means it's taken — pick a different
IP and re-check. Do not rely on ping (misses devices with ICMP
disabled/filtered — a real false-negative that happened once already) or
on a per-host "what's already in use" list (each host only knows about
its own jails, not other physical devices or other hosts' jails on the
same shared subnet).
If you inherit a jail already provisioned on a conflicting IP: stop the
jail, remove the bad alias, update `jail.conf`'s `ip4.addr`, start the
jail again (this re-adds the alias on the new IP), then propagate the IP
change everywhere else it's referenced (DB user grants scoped by IP,
host-level nginx `proxy_pass` target, etc.) — an IP conflict fixed in
`jail.conf` alone but not in the DB grant/nginx config leaves the site
broken even though the jail itself is now healthy.
## Provisioning steps (once the IP is confirmed free)
1. **Clone from a known-good base jail** rather than building from
scratch — inherits working nginx/php-fpm/package configuration:
```bash
bastille clone -a <known-good-base-jail> <new-jail-name> <new-ip>
```
2. **Wipe cloned content, do a fresh install** if this is a brand-new
site (not a migration) — the cloned base's site files are a *template*
for the stack config, not content you want to keep.
3. **Create the database** on the host's local MariaDB, with the grant
scoped to the **jail's own IP**, not the host's IP:
```sql
CREATE DATABASE `dbname` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'dbuser'@'<jail-ip>' IDENTIFIED BY '<random-password>';
GRANT ALL PRIVILEGES ON `dbname`.* TO 'dbuser'@'<jail-ip>';
```
The application's `DB_HOST` config value is the **host's** own LAN IP
(where MariaDB actually listens), NOT the jail's IP — the jail IP is
only used for the GRANT's scope. Confirmed gotcha across multiple
real migrations: mixing these two up is an easy, non-obvious mistake.
4. **Wire the host-level nginx reverse-proxy vhost**, `proxy_pass`ing to
the jail's IP.
5. **Do NOT touch DNS or the reverse-proxy manager's public routing** as
part of automated provisioning — that's a deliberate, manual cutover
step for a human to do once the site is verified working over a
direct IP/Host-header test. Build and verify fully "dark" first.
6. **Smoke test** both directly to the jail IP and via the host nginx
proxy (with the right `Host` header and, if the site checks for HTTPS
via `X-Forwarded-Proto`, that header too) before considering
provisioning done.
## Persist credentials immediately
Write the generated DB password (and any other generated secrets) to a
durable location the moment they're generated — if a later provisioning
step fails, an unrecorded generated password is otherwise lost with no
way to recover it short of resetting it.
## `bastille create` interface gotcha: don't assume `bastille0` exists
Some hosts' `bastille.conf` still has the default
`bastille_network_loopback="bastille0"` / `bastille_network_vnet_type="if_bridge"`
values even though the host actually uses plain shared-IP aliasing on the
physical interface (`vtnet0`) for every real jail on it — confirmed on the
`staging` host (100.104.61.54) 2026-08-02. A bare `bastille create <name>
<release> <ip>` fails with `[ERROR]: bastille0 interface does not exist`.
**Check an existing working jail's `jail.conf`** (`ip4.addr = vtnet0|<ip>;`)
to see which interface this host actually uses, then pass it explicitly:
```bash
bastille create <name> <release> <ip> vtnet0
```
## After create/destroy churn, verify the IP alias actually attached
A `bastille create` immediately following a `bastille destroy` of a jail
that held the *same* IP can silently fail to (re-)add the alias — the jail
comes up "running" but has zero network connectivity (DNS and raw-IP fetch
both time out, no error surfaced anywhere). Confirmed live 2026-08-02: `pkg
install` inside the new jail failed with "Non-recoverable resolver failure"
on the first attempt, which looks exactly like a DNS/resolv.conf problem
but wasn't (resolv.conf was correct and identical to a working jail).
**Verify directly** before assuming jail networking is up:
```bash
ifconfig <interface> | grep <jail-ip> # must show the alias
bastille cmd <jail> fetch -o /dev/null http://1.1.1.1 # raw IP, bypasses DNS entirely
```
If the alias is missing, `bastille restart <jail>` (not just `start`) or a
manual `ifconfig <interface> inet <ip> netmask 255.255.255.255 alias`
reapplies it.
## Testing a jail before its public vhost exists: hit the jail IP, not the host
If the site's domain isn't wired into the host's reverse-proxy nginx yet
(no `sites-enabled/<jail>.conf`), curling the **host's** own `127.0.0.1`
with a `Host:` header does NOT reach the new jail — it falls through to
whichever `server_name` on the host's nginx matches first (often a
different site's `default_server`), returning a normal-looking `200 OK`
for entirely the wrong site's content. This produced a long, wrong-track
debugging session on 2026-08-02 (chasing a "cache never writes" theory
against a site that was never actually being hit). **Always curl the
jail's own IP directly** until the real vhost is wired:
```bash
curl -H 'Host: <domain>' http://<jail-ip>/
```
Only switch to testing via the host's `127.0.0.1` (or the public domain)
once the `sites-enabled` vhost for this specific site actually exists.
## Default-deny outbound is the standard for every new jail
**This is no longer an incident workaround — it is the default posture for
any jail created on any Bastille host in this fleet.** Most jails are
reverse-proxied inbound-only services and do not need outbound internet
access. Denying it by default limits blast radius if a jail is compromised
(fewer callback/exfil paths, no unexpected update pings).
When you finish provisioning a new jail, it must land in the `block out`
ruleset, not the `pass out` ruleset. Only add it to the allowed list if its
actual function genuinely requires external reach, and document the reason
in `pf.conf` next to the IP:
```
# Jails that need real outbound internet access (function confirmed):
table <outbound_allowed> { 10.20.0.14, 10.20.0.22, ... }
nat on $ext_if from <outbound_allowed> to any -> ($ext_if)
pass out quick on $ext_if from <outbound_allowed> to any keep state
block out quick on $ext_if from 10.20.0.0/24 to any
```
The live reference implementation is gringo's `/etc/pf.conf` (tracked in
`infra/gringo/pf/pf.conf`). Before considering any new jail complete, verify
its IP is either in the deny block or explicitly listed with a comment
explaining why it needs outbound access. "Might need it later" is not a
reason.
## Every jail needs a dedicated NPM-facing port, not just `listen 80`
**Confirmed real gap, 2026-08-07**: on jail hosts fronted by a shared
Nginx Proxy Manager (NPM) instance, every existing jail's host-level
nginx vhost has **two** `listen` directives — the shared port 80 (for
same-host/Host-header testing) **and a dedicated port unique to that
jail**, following the convention `8000 + <jail's last IP octet>` (e.g. a
jail at `10.20.0.43` gets `listen 8043;`, one at `.50` gets `listen
8050;`). This dedicated port is what NPM's own proxy-host config actually
targets when routing the public domain to this jail host — **NPM does
not rely on Host-header-based routing through the shared port 80 for
this fleet**, it connects to a specific port per site.
This was missed migrating 5 Laravel apps from a Linux host to a FreeBSD
jail host (gringo) in one session: all 5 new vhosts were created with
only `listen 80;`, matching the *shared* port but missing the dedicated
one entirely. Every functional test the delegate ran (raw jail IP, and
via the shared port 80 with the right `Host:` header) passed cleanly —
the gap was invisible until specifically checked against sibling jails'
configs, at which point every other jail on the host turned out to
follow the two-port pattern without exception. **Before considering any
new jail's nginx vhost complete, diff its `listen` directives against at
least one working sibling jail's config on the same host** — don't just
confirm the site loads via a manual test, confirm the *routing surface*
matches the established convention, since NPM (external, not scriptable
from this sandbox) is what will actually determine reachability once
cut over, and a working manual test doesn't prove NPM's real path works.
```bash
# find the convention on any host that uses it
for f in /usr/local/etc/nginx/sites-available/*.conf; do
echo "=== $(basename $f) ==="; grep -E '^\s*listen' "$f"
done
```
If a host doesn't show this pattern on ANY existing jail, it likely
doesn't use per-jail dedicated ports — don't assume the convention
applies fleet-wide without checking the specific host first.
## pf `nat on $ext_if from <subnet> to any -> ($ext_if)` silently fails to translate the source IP when that IP is already aliased on the same interface
**Confirmed real incident, 2026-08-07**, on a host using the shared-IP-
aliasing jail pattern (jail IPs are `/32` aliases directly on the host's
own external interface, e.g. `192.168.0.185` aliased onto `vtnet0`
alongside the interface's real address `192.168.168.64` -- not the VNET/
bridge pattern where jails get a genuinely separate subnet). A `nat on
$ext_if from <jail-subnet> to any -> ($ext_if)` rule -- the standard,
seemingly-correct pf idiom for masquerading outbound jail traffic --
**did not rewrite the source address at all** for these jails. Outbound
packets left with the jail's own 192.168.0.x source IP untouched (only
the source *port* got NAT'd), which the upstream network silently
dropped (source IP outside the VM's assigned subnet, likely anti-spoof
filtering) -- symptom was a clean connection timeout, not a pf/rule
error, with a real `SYN_SENT` state visible in `pfctl -s states` showing
the untranslated source IP in parens. This broke every outbound call any
jail on that subnet tried to make (external API calls, WordPress core's
own update-check pings, etc.) -- surfaced as WordPress admin pages
hanging/timing out with no PHP error logged anywhere, since the PHP-FPM
worker was just sleeping on a TCP connect that would never complete.
**Root cause**: the `($ext_if)` interface-macro NAT target apparently
skips/no-ops translation when the connection's source address is *already*
one of the interface's own configured addresses -- plausible as "this
traffic looks locally-originated already, no need to rewrite" logic in
pf's NAT implementation, but wrong for this topology where those aliased
addresses are jail IPs that specifically need translating to look like
the host's *primary* address before leaving.
**Fix: use an explicit IP as the NAT target, not the interface macro.**
```
ext_if = "vtnet0"
ext_ip = "192.168.168.64" # the interface's real, non-aliased address
lan_net = "192.168.0.0/24" # the jail-alias subnet
nat on $ext_if from $lan_net to any -> $ext_ip # works
# nat on $ext_if from $lan_net to any -> ($ext_if) # looked identical, silently no-ops
```
**Verify a NAT fix actually rewrites the source**, don't just check
`pfctl -s nat` shows the rule (a no-op-translating rule still shows up
there looking correct) -- watch `pfctl -s states` during a live outbound
attempt from the jail and confirm the parenthetical translated
address/port shown is genuinely different from the jail's own IP, not
just a port change on the same source IP.
This is a distinct topology from the VNET/bridge pattern documented
elsewhere in this skill and in `jail-dedicated-subnet-migration` (where
jails get a real separate subnet on a bridge interface, e.g.
`10.20.0.0/24`) -- that pattern's NAT rules were unaffected by this bug
in the same test. Check which pattern a given host actually uses
(`ifconfig` -- are jail IPs aliased directly on the external interface,
or on a separate bridge?) before assuming either NAT idiom is safe.
## Stock stack includes Valkey, not just nginx/php/MariaDB
The fleet's standard per-host stack (see `docs/server-funky.md`) is
nginx + php-fpm + MariaDB (host-local) + **Valkey** (host-local, ACL-auth'd,
`aclfile`-based, admin password at `/root/.valkey_admin_pw`). A host that's
never hosted a Redis/object-cache-dependent site before may be missing
Valkey entirely — install and configure it the same way (`bind 127.0.0.1
<host-lan-ip>`, `aclfile`, admin password file) rather than treating it as
optional, so any plugin/site expecting a persistent object cache gets a
real one to test against, not silently falls back to a weaker default.
Inside a jail that needs to reach it, install the PHP Redis extension —
**package name is `php85-pecl-redis`** (not `php85-redis`, which doesn't
exist under that name in the FreeBSD ports tree) — and remember Valkey's
ACL means any code connecting to it needs an explicit `AUTH` step, not just
`connect()`; a `connect()`-only implementation will silently fail every
subsequent command with `NOAUTH` against this fleet's standard Valkey setup.
## Bastille filesystem prefix varies by host — check before hardcoding a path
Don't assume `/usr/local/bastille/jails/<name>/root/...` — that's only
the convention where `bastille.conf` uses the default `bastille_prefix`
(confirmed on gringo and staging). A host with a custom
`bastille_prefix` (e.g. `granja`, where storage was repointed at a
dedicated `wwwpool` with `bastille_prefix=/www/bastille`) puts jails
under `/www/bastille/jails/<name>/root/...` instead — writing to the
`/usr/local/bastille/...` path on that host silently succeeds (creates a
new, wrong directory) rather than erroring, since nothing stops you from
writing to an arbitrary path. **Check the host's actual
`bastille_prefix`** (`grep bastille_prefix /etc/rc.conf` or
`bastille.conf`) before hardcoding either path in a script.
## Provisioning script safety
Applies to any shell script written to automate part of this
provisioning flow (not the one-off interactive commands above). Full
worked patterns, including FreeBSD-specific `mktemp`/`sed`/`date` syntax,
a trap-based cleanup template, and jail-name/IP input validation, live in
`references/script-safety.md` — read that before writing a new
provisioning script. Summary of the load-bearing points:
- **Don't assume `#!/bin/bash` exists.** FreeBSD base ships `/bin/sh`
(POSIX-ish ash), not bash — bash is a package that installs to
`/usr/local/bin/bash`, never `/bin/bash`, and many freshly cloned jails
won't have it installed at all. Default to `#!/bin/sh` and POSIX
constructs unless bash-only features are genuinely needed and you've
confirmed bash is present on the target.
- **Error handling is contextual, not a blanket `set -Eeuo pipefail`.**
Turn on `set -eu` for the whole script, but explicitly tolerate steps
that are allowed to fail (e.g. an already-installed package, an
optional service restart) with a commented `|| true` rather than either
aborting the whole provisioning run on a non-fatal hiccup or suppressing
errors wholesale. `-o pipefail`/`-E` are bash-only and unavailable under
`/bin/sh`.
- **Trap cleanup, don't trap auto-rollback.** A script that dies partway
through jail creation should log exactly which step it reached (jail
created? DB granted? vhost written?) so a human can clean up — see the
create/destroy IP-alias churn gotcha above for why an automatic
`bastille destroy` inside a failure-path trap can make things worse, not
better.
- **Validate jail names and IPs before they reach a destructive `bastille`
command.** `bastille destroy <name>` has no confirmation prompt.
Validate with plain POSIX `case`/`[` pattern matching (works under both
`/bin/sh` and bash) before any value — especially one derived from
outside the script — is interpolated into a command.
For quoting content through nested shell layers (`ssh` → `bastille cmd`
→ `su`), use the separate `remote-shell-quoting-safety` skill — that
problem is not duplicated here.
Provenance: the script-safety patterns above are adapted from
wshobson/agents' `bash-defensive-patterns` skill
(https://github.com/wshobson/agents,
`plugins/shell-scripting/skills/bash-defensive-patterns`, MIT licensed,
fetched 2026-08-15) — rewritten for FreeBSD/Bastille where upstream
assumed GNU/Linux; see `references/script-safety.md` for exactly what was
kept, rewritten, or dropped.