Presets
A preset is a YAML file in presets/ that describes an infrastructure recipe
(hardening baseline, monitoring stack, web app). The API loads them at boot,
validates them against the Zod schema in apps/api/src/lib/presets/schema.ts,
and renders them to idempotent bash on demand.
Top-level shape
name: server-light # kebab-case, must match the filename
version: 0.1.0
description: >
Multi-line description. Surfaces in the catalogue card and the rendered
bash header.
tags: [server, security, baseline] # free-form labels
targets: [debian, ubuntu] # distros the renderer's guard accepts
requires: [sudo, apt] # documented assumptions (not enforced)
risk_level: CONFIRM # see "The Fix contract" below
variables:
ssh_port:
description: TCP port for sshd. Keep 22 unless you're moving SSH off the default.
default: 22
hostname:
description: Set the system hostname. Leave empty to keep the current one.
default: ""
steps:
- name: Install baseline security packages
type: package
packages: [ufw, fail2ban, unattended-upgrades]
# ... more steps
verify: # optional but recommended for Fixes
- name: sshd is active
type: command
run: systemctl is-active --quiet ssh
Required fields
name— must be[a-z0-9-]{2,40}AND match the filename without extension. Mismatch is a hard error at load time.description— 1 to 280 chars.steps— at least one. Order matters; steps run sequentially.
Optional but recommended
tags— used to filter the catalogue UI.targets— populates the runtime distro guard. Anything outside the list refuses to run unlessHYPRBOX_FORCE=1.variables— gives the user form fields in the dashboard and CLI-Vflags.
Variables
Each variable is { description?, default?, required? }. Reference them in
strings via {{ var_name }}. The renderer substitutes at render time —
not at runtime — so the resolved values get baked into the script.
variables:
domain:
description: Domain name pointing at this server (required for HTTPS).
required: true
steps:
- name: Write Caddyfile
type: config_file
path: /opt/hyprbox/stacks/web/Caddyfile
content: |
{{ domain }} {
reverse_proxy app:80
}
If required: true and the user didn't provide a value, the API returns
400 Render failed. If required: false (default) and no default is set,
the variable resolves to an empty string.
Step types
All steps accept two common fields:
name— label shown in the CLI / UI / rendered bash header.when— runtime guard. The renderer emits anifcheck; if the substituted value is empty,"0", or"false", the step printsskipped: when-guard fell throughand continues.
package
- type: package
packages: [ufw, fail2ban, curl]
Emits apt-get update -qq && apt-get install -y -qq .... Apt only — the
distro guard ensures we're on Debian/Ubuntu before this can fire.
firewall
- type: firewall
reset: true # default-deny baseline before applying rules
allow:
- port: 22
proto: tcp
comment: SSH
- port: 80
proto: tcp
from: 10.0.0.0/8 # optional CIDR allowlist
comment: HTTP from VPN only
- "8080/udp" # shorthand string also works
Emits ufw --force reset (if reset: true), then ufw default deny incoming / allow outgoing, then each rule, then ufw --force enable.
ssh_harden
- type: ssh_harden
port: 22 # accepts variables like "{{ ssh_port }}"
disable_root_login: true
disable_password_auth: true
allow_users: [hyprbox-ops] # optional AllowUsers directive
Backs up /etc/ssh/sshd_config, applies the changes via sed, validates
with sshd -t before restarting the service. You're responsible for having
an authorized SSH key set up before applying this — if password auth is the
only way in, this preset will lock you out.
automatic_updates
- type: automatic_updates
scope: security # 'security' (default) or 'all'
reboot: false # auto-reboot after upgrades?
reboot_time: "04:00"
Configures unattended-upgrades. Safe to apply more than once.
config_file
- type: config_file
path: /etc/hyprbox/agent.conf
mode: "0644"
owner: root
group: root
content: |
api_url={{ api_url }}
interval=5m
The content is dropped via a tee heredoc with a quoted sentinel, so it's
shell-literal (no $ expansion at runtime). Variables ARE substituted at
render time.
command
- type: command
run: systemctl reload nginx
fail_on_error: true # default true — non-zero exit aborts the run
Escape hatch for anything not covered by a typed step. Avoid when possible — typed steps are easier to audit and test.
docker_compose
- type: docker_compose
target_dir: /opt/hyprbox/stacks/web
env:
DOMAIN: "{{ domain }}"
POSTGRES_PASSWORD: "{{ postgres_password }}"
compose: |
services:
caddy:
image: caddy:2.8
ports: ["80:80", "443:443"]
# ...
Writes compose.yml and .env to target_dir, then runs docker compose pull && up -d in that directory. The env file is rebuilt every apply, so
remove a key from env: to drop it from the stack.
hostname
- type: hostname
hostname: "{{ hostname }}"
Calls hostnamectl set-hostname. Combine with a when: guard if you want
to skip when the variable is empty:
- when: "{{ hostname }}"
type: hostname
hostname: "{{ hostname }}"
Rendered bash anatomy
Every script starts with:
#!/usr/bin/env bash+set -euo pipefail.- A comment header with preset name, version, description, and the resolved variable map.
- Helpers —
log,ok,warn,run_step <i/N> <label> <fn>,step_skip. - A
guard_distrofunction that reads/etc/os-release, refuses unknown distros, and respectsHYPRBOX_FORCE=1for override. step_NN()functions in declaration order.- The actual runner:
run_step "1/N" "<label>" step_01lines.
Output looks like:
[12:34:56] ─── [1/5] Install baseline security packages
[12:35:02] ✓ [1/5] Install baseline security packages
[12:35:02] ─── [2/5] Default-deny firewall, allow SSH + web
...
[12:35:30] >>> Done — preset 'server-light' applied successfully.
A failed step aborts the run (set -e) and prints [i/N] <label> — FAILED.
Writing your own preset
- Drop a
<name>.yamlinpresets/. The filename must match thename:key. - Restart the API so the loader picks it up:
pnpm dev:apiordocker compose -f docker-compose.prod.yml restart api. - Verify it loads:
curl /api/presetsshould list it. - Preview the rendered bash without applying:
hyprbox preset preview my-preset -V foo=bar -V port=2222 - Test on a throwaway VM. The
guard_distrostep will refuse to run on the wrong OS — that's intentional. UseHYPRBOX_FORCE=1to bypass when you're sure.
Best practices
- Idempotent steps: a preset should be safe to re-apply.
ufw allow Xis idempotent;echo line >> fileis not (usetee -awith a content check or stick to the typed steps). - One concern per step: easier to read in the activity log and easier to rerun a single failed step manually.
- Variables for anything that varies per host: hostname, ports, allowed IPs. Hard-coded values mean a new preset per environment.
- Document
required: truevariables: the description shows up in the dashboard form and in the API response — it's the only hint the user gets. - Don't bake secrets into the YAML: pass them as variables at queue time; they end up in the job row but at least not in the repo.
The Fix contract
A preset that's the target of a Recommendation is a Fix. Fixes
conform to a stricter shape than vanilla presets, so the operator can
trust them:
| Field | Purpose | Default |
|---|---|---|
risk_level |
UI gating for Apply | CONFIRM |
steps |
The actual change (existing field) | required |
verify |
Post-apply assertions. Failure → job FAILED, even if steps passed. | [] |
risk_level
| Value | What it means | UI behaviour |
|---|---|---|
SAFE |
Read-only or strictly idempotent reversible no-op. | Apply with no confirm |
CONFIRM |
Default. State change, easy to undo. | Modal confirm + show plan |
DANGEROUS |
Can lock you out or destroy data. | Modal + type the hostname to confirm |
MANUAL |
We describe the fix but won't run it. | No Apply button, instructions only |
When in doubt: pick CONFIRM. Promote to SAFE only for changes that
are genuinely no-ops if applied twice (e.g. caddy reload triggering a
no-op renewal). Promote to DANGEROUS for anything that touches sshd,
firewall, or persistent data.
verify steps
verify: accepts the same step types as steps: (typically command:).
They run after steps: and decide the job's terminal status:
- Every verify step exits 0 → job is
SUCCEEDED. - Any verify step exits non-zero → job is
FAILED(with exit code 2 from the script — the renderer'srun_verifyhelper translates the failure into a clearly-labelled "the fix ran but did not take effect" message in the output stream).
This separation matters. Without verify, "succeeded" means "no command
errored". With verify, it means "no command errored AND the change
landed". That's the difference between a deploy script and a Fix.
Good verify steps assert the goal, not the mechanism:
# good — asserts the post-condition
verify:
- name: Port 443 is listening
type: command
run: ss -tln | grep -q ':443'
# bad — restates a step that already happened
verify:
- name: Caddy was reloaded
type: command
run: echo "we reloaded caddy"
A reasonable cadence: verify: should take less than 30 seconds total.
For "wait for the service to come back" use sleep N in the last steps:
entry, not in verify:.
What about rollback?
Not yet in the schema. When apply succeeds but verify fails, the operator
sees a clear error and decides; we don't automatically roll back. The
explicit rollback: block lands when we have a fix complex enough to
benefit from it (e.g. an SSH hardening preset with multiple sshd_config
patches). For now, idempotent forward-fixes (caddy reload, restic backup) don't need rollback because re-running them converges anyway.
Built-in presets
server-light— Debian/Ubuntu hardening: UFW (default deny + SSH/HTTP/HTTPS), fail2ban, unattended-upgrades (security only),sshd_configlockdown.monitoring-only— Prometheus + Grafana + Alertmanager + node-exporter via docker compose. Opens ports 3001/9090/9093.pme-web— Caddy reverse-proxy with automatic Let's Encrypt + Postgres- an app container of your choice (
{{ app_image }}). Requiresdomain.
- an app container of your choice (
hypervault-restic— installed by the HyprVault page; wires Restic- a per-policy cron + start/complete reporting to the API.
caddy-tls-renew— Fix recipe (risk_level: SAFE). Recommended by thetls.expiringfinding. Reloads Caddy then verifies the certificate has at leastmin_days_after_renewaldays of validity.