What it is
vpscfgfarm is contract-based configuration and deployment safety for a fleet of Linux servers reached over SSH (Docker, docker compose, k3s). It has three doors: vpscfg-control.py for contracted deploys, vpsexec for gated ad-hoc commands, and vpsrun for running a reviewed sequence a human approves once. Every door reads one governor file, refuses by default, and journals what it allowed and what it refused. Use it when you, a small team or an agent working under a human's approval want every production command to pass through reviewed, tested, journaled code.
What it is not. It is not a configuration-management system (it can drive Ansible behind the same gates), not a multi-tenant service, not a secrets store, and not a sandbox against a local user who can already edit the doors. It protects production from mistakes made through the door. It cannot stop someone who goes around it with raw SSH; fleet changes exists to notice when that happens. A semantically disastrous but ordinary command, approved by a human, is indistinguishable from intent: the doors make it slow, visible, bounded and recorded, not impossible.
Requirements
- Python 3.9 or newer, with PyYAML (hash-pinned in
requirements.txt). ssh,rsyncandtaron the operator machine.- Managed hosts: Linux servers reachable over SSH, each one a
Hostalias in~/.ssh/config. - A real terminal for approvals. Mutating approvals and grants refuse without a TTY.
Install
git clone https://github.com/afterdarksys/vpscfgfarm.git
cd vpscfgfarm
python3 -m pip install --require-hashes -r requirements.txtPin the interpreter
The three doors carry one shebang naming a real interpreter with -I (isolated mode). The committed shebang is the maintainer's machine, so point it at the interpreter that has PyYAML:
tools/pin-interpreter.sh "$(python3 -c 'import os,sys;print(os.path.realpath(sys.executable))')"It rewrites line 1 of vpscfg-control.py, vpsexec and vpsrun, and refuses unless the target is an absolute, real, executable binary that is not group- or world-writable and reports isolation under -I. Never use #!/usr/bin/env: it lets PATH choose the program that reads the governor. Start the doors by path (./vpsexec, or a symlink such as ~/bin/vpsexec), not as python3 vpsexec.
Verify the install
./vpscfg-control.py status
python3 -m unittest discover -s testsThe repository ships no governor, so on a fresh clone status refuses with control file missing — nothing runs without the governor. That refusal is the expected first result: until you configure an estate, every door refuses. The test suite proves the refusals; CI treats a skipped test as a failure.
Quick start with the example estate
The repository includes a fictional three-host estate in contrib/example-estate/:
| Host | Address | Posture | Role |
|---|---|---|---|
web1 | 192.0.2.10 | frozen | Serves www.example.com, no failover. Also reachable as web1.example.internal (an alias_of). |
db1 | 192.0.2.20 | lockdown | The database. |
edge1 | 198.51.100.5 | test | Reverse proxy; where it is safe to be wrong first. |
Point the tool at a copy of it with a pointer file at the root of the tool checkout:
cp -R contrib/example-estate ~/vpscfg-estate
printf '%s\n' "$(cd ~/vpscfg-estate && pwd -P)" > estate.path
chmod 600 estate.path
./vpscfg-control.py status # the example's three hosts and their posturesMerge the stanzas from ssh_config.example into ~/.ssh/config: every governed host must be an SSH alias, and a policy group naming a host you cannot reach refuses to load. Then use the introspection verbs, which change nothing:
./vpsexec hosts # postures, as the governor sees them
./vpsexec groups # @web, @db, @edge, @docker, @shop, @test
./vpsexec checks # the named read plays
./vpsexec edge1 'touch /tmp/hello' # a mutation without --run: prints the plan and stopsReads such as ./vpsexec @all 'uptime' connect to the hosts over SSH. The example addresses are documentation ranges and will not answer. Replace the example hosts, addresses and keys with your own, start every host at frozen or test, and change postures only in reviewed commits.
On your own estate, a first read session looks like this:
./vpsexec @all 'uptime'
./vpsexec @docker 'docker ps' --compare
./vpsexec check failed
./vpscfg-control.py web1 containers
./vpscfg-control.py edge1 detailsDelete estate.path to return to the default estate, the tool checkout itself. There is deliberately no flag or environment variable for choosing the estate.
Key concepts
The governor
vpscfg-control.json at the estate root is the one file that grants anything. Nothing in policies/ can loosen it.
| Posture | Reads | Mutations through vpsexec |
|---|---|---|
released | Yes | Yes, with --run, --reason and approval. |
test | Yes | The same. Use it where a mistake is cheap. |
lockdown | Yes | Only with --break-glass and a committed runbook naming the exact command. |
frozen | Yes | The same as lockdown. |
A host missing from hosts is treated as frozen. mode: lockdown or mutation_enabled: false refuses every mutation, break-glass included. allowed_verbs must contain exec for vpsexec to work at all. alias_of declares that two names are one machine. scopes holds the scopes released for contracted deploys.
The deny-always floor
Volume, system, image and network prunes, volume rm, compose down -v, DROP TABLE|DATABASE|SCHEMA, TRUNCATE, rm -rf /..., kubectl delete ns, mkfs, dd if= and terraform destroy are compiled into all three doors. The governor's deny_always list may add patterns; a governor that omits a floor entry is treated as tampered and refuses everything. No policy file, runbook, --break-glass or approval reaches the floor.
Policies and runbooks
Policy files in policies/*.yaml (not recursive, so policies/examples/ is inert) can only make the doors stricter:
| A policy file can | A policy file cannot |
|---|---|
| Name host groups | Change any host's posture |
Add deny patterns (deny_extra) | Remove a deny pattern |
Add risky patterns (risky_extra) | Widen the read-only allowlist |
| Authorise an exact command on exact hosts | Lift the deny-always floor |
A runbook is a policy file with runbooks:. Each entry authorises one exact command on exact hosts. A file with runbooks needs expires: "YYYY-MM-DD" (after that day its runbooks are disarmed and every run warns) and a shape declaring its step count, hosts and break-glass mix. Entries can carry break_glass: true, a verify read that must exit non-zero on failure, timeout, container and rollback: true:
version: 1
expires: "2026-12-31"
shape: {steps: 1, hosts: [web1, edge1], break_glass: true}
reason: "Outage runbooks"
runbooks:
- hosts: [web1, edge1]
cmd: "systemctl restart nginx"
where: host
break_glass: true
why: "wedged nginx worker pool"Unknown keys and duplicate keys refuse the whole load. Retire a finished runbook with git mv policies/<file>.yaml policies/examples/superseded/ in a reviewed commit.
Contracts
The contracted-deploy door works from one YAML contract per service in contracts/. A contract records what to sync, what must never be overwritten, what healthy means and what else is in the blast radius:
service: shop-api
host: web1 # must resolve in ~/.ssh/config
deploy_dir: /srv/shop-api
sync:
include: ["server.js", "routes/", "middleware/", "migrations/"]
never: [".env.production", "Dockerfile", "package.json", "package-lock.json"]
build: "docker compose up -d --build --force-recreate"
freeze: false
pre:
- backup: "tar czf /root/{service}-pre-{ts}.tar.gz {deploy_dir}"
verify:
- http: { url: "https://api.example.com/health", expect: 200, timeout: 10 }
- container: { name: "shop-api", state: "healthy", within: "90s" }
blast_radius:
shares_host_with: "every other service on web1"
volumes: []sync.never is required. freeze: true is a hard lock; unfreezing is a git commit. Every operation runs the same pipeline: contract, plan, gate, apply, verify. Reads stop after the plan, and mutations need --run.
The journal
Every invocation, allowed or refused, lands in a locked, fsynced, hash-chained JSONL journal in the tool checkout's journal/ directory (control.jsonl, executor.jsonl, vpsexec.jsonl). If the journal cannot be written, nothing changes. Secret values are masked in remote output before it is shown, compared or journaled; if the masker fails, the output is withheld.
The release ratchet
The contracted-deploy path ships maximally locked: stage 0 allows reads, status and plans only, and the executor refuses even when every gate is green. Controls come off one scope per commit, smallest first (container, then pod, then service, then host), each tested and recorded in the governor's scopes.tested before it enters scopes.unlocked. docs/STABILITY.md lists the stages and what stays locked forever.
The contracted-deploy door
vpscfg-control.py validates the governor, judges the invocation, journals it and hands it to the vpscfg package, which re-validates independently. The upstream docs write its grammar as vpscfg ...; you run it as ./vpscfg-control.py ....
./vpscfg-control.py status # governor state: mode, postures, scopes
./vpscfg-control.py web1 containers # live docker ps (read-only)
./vpscfg-control.py db1 overlays # k3s namespaces
./vpscfg-control.py edge1 details # host identity, OS, uptime
./vpscfg-control.py web1 drift shop-api # git, contract and live state, three-way
./vpscfg-control.py web1 verify shop-api # run the contract's post-conditions
./vpscfg-control.py lint # contract quality floor
./vpscfg-control.py fleet watch # every contract's verify block
./vpscfg-control.py fleet changes # live state vs baseline; flags unjournaled changes
./vpscfg-control.py index --all # cached record of what lives on each host
./vpscfg-control.py journal verify # check the journal's hash chainDeploying: preflight, then run
./vpscfg-control.py edge1 deploy shop-proxy --preflight # GO=0 / warnings=1 / NO-GO=2
./vpscfg-control.py edge1 deploy shop-proxy --preflight --json
./vpscfg-control.py edge1 deploy shop-proxy --run --reason "why" # only within 30 min of a GOPreflight is read-only. --run refuses unless the latest preflight for the same plan (contract, sync arguments and source bytes) was GO or GO-WITH-WARNINGS in the last 30 minutes. Mutations also need --reason, journaled forever, and the scope must have graduated in the governor. Refusals exit 2 with the reason on standard error.
vpsexec: gated ad-hoc commands
For what a contract cannot express, such as restarting a wedged process on several machines during an outage or reading five machines' logs at once.
vpsexec <hosts> '<command>' [flags] # the command is ONE argument
vpsexec <hosts> push <file> --sha256 <hex> [flags] # content-addressed file pushHosts are edge1, a list such as web1,edge1 (run in the order named), a group such as @web, @all, or a mix. One machine reached by two names runs once.
The three tiers
| Tier | Examples | What it takes |
|---|---|---|
| read | df -h, docker ps, systemctl status x, journalctl | Nothing. Any host, no --run. Reads fan out in parallel. |
| mutating | ./deploy.sh, make install, anything unrecognised | --run, --reason, a posture that allows it, and approval. Mutations run serially and continue past a failure. |
| risky | systemctl restart, docker rm, reboot, iptables | All of the above, plus a runbook or override naming the exact command for every host. |
| floor | Prunes, volume rm, DROP, rm -rf /..., mkfs | Never. |
The read tier is an allowlist with per-command option grammars; anything not on it is a mutation, and command substitution is refused outright. For example, curl is a read only when -q comes first, so a health check is written curl -qfsS URL.
Common flags
| Flag | Meaning |
|---|---|
--run | Actually execute. Without it you get the plan and stop. |
--reason "why" | Required for anything that is not a read; journaled. |
--in NAME, --in-match GLOB, --in-all | Run inside live containers. |
--verify 'CMD' | A read post-condition per host; its failure fails that host. |
--canary [N] | The first N hosts must pass before the rest are touched. |
--compare | Reads only: group hosts by identical output and flag the odd one out. |
--break-glass | Reach frozen or lockdown hosts, backed by a committed runbook. |
--approve=HASH | Non-interactive approval of one exact plan hash (not enough on its own for break-glass). |
--grant FILE, --grant auto | Use an approval contract instead of typing the break-glass phrase. |
--timeout N | Per-host timeout in seconds. |
Checks and read-back
vpsexec check # list the named read plays
vpsexec check failed # what is broken, everywhere
vpsexec check disk @docker # override the default host set
vpsexec last web1 -n 3 # the last runs in full: command, reason, approval, output, verify
vpsexec log --since 2h # the journal, filtered
vpsexec runbook lint policies/web-release-example.yamlChecks are reads with a name, so they need no --run or approval. runbook lint checks a runbook at authoring time (shape, expiry, timeouts, pushed artifacts, STOP steps) and ends in GO, GO-WITH-WARNINGS or NO-GO.
Approvals, break-glass and grants
Nothing mutating runs without explicit, per-plan approval, typed at a real terminal. The phrase scales with what is at stake:
| Situation | Type |
|---|---|
| One host | The host name |
| Several hosts | FLEET:<n> |
--in-all | ALL:<hosts> |
| Break-glass | BREAK-GLASS:<n> |
There is no --yes. Without a TTY, a plan can be approved only with --approve=<plan-hash> from an earlier dry run, which authorises one byte-identical plan and never a break-glass plan on its own.
Break-glass elevates posture per host, only for hosts that need it, and only with --break-glass plus a committed runbook with break_glass: true naming the exact command for each elevated host. The floor, the master switch and mode: lockdown still refuse. An ad-hoc --override file can never grant break-glass.
Approval contracts ("grants") replace the typed phrase for one reviewed runbook's break-glass steps, for a bounded time, and nothing else. Every other gate runs unchanged on every use.
vpsexec grant new web-release-example --ttl 30m # preview; writes nothing
vpsexec grant new web-release-example --ttl 30m --run # mint: TTY only, type GRANT:web-release-example:30m
vpsexec grant list
vpsexec grant revoke <session_id>Short grants bind one runbook file by the hash of its bytes, so editing the runbook invalidates them. Reusable grants (--kind reusable) bind pushed policy and a maximum number of uses. Both need a TTY when minted and at every use.
Plays and the runbook walker
When a fix is several approved mutations, an agent can write plays/<name>.yaml, and a human runs vpsrun plays/<name>.yaml in a real terminal, reads every step's plan and types one phrase. Each step still goes through the door twice, once for its plan hash and once to run it, so a plan that changed after it was shown halts the play. The agent reads the result with vpsexec last.
The example estate's runbook walker runs a runbook's steps in order through vpsexec:
contrib/runbook-walker/walk.sh --runbook web-release-example --list
contrib/runbook-walker/deploy.sh web-release-example --ttl 30m \
--reason "shop 1.4.0 release" --artifacts contrib/example-estate/artifactsdeploy.sh mints one grant, then runs walk.sh, which checks pushed artifacts by sha256 before the first step, records progress so you can resume with --from N, skips rollback steps and pauses after "Stop if" steps.
Files and locations
| Path | What it is |
|---|---|
estate.path | Optional pointer at the tool checkout root: one line, an absolute real path to your estate. Must be yours, mode 0600 or 0644, and never committed. |
vpscfg-control.json | The governor, at the estate root. Not shipped. |
policies/*.yaml | Groups, checks, deny additions, credential paths and runbooks (estate). |
contracts/*.yaml | One contract per service (estate). |
plays/ | Queued plays for vpsrun (estate). |
journal/ | The hash-chained journals and the repair database, in the tool checkout (gitignored). |
~/.ssh/config | The host registry. Include is followed as OpenSSH follows it. |
~/.vpscfg/grants/ | Minted approval contracts (0600 files in a 0700 directory). |
Optional integrations
Each is off until named in the governor's integrations block. No environment variable or flag turns them on.
- Telemetry: an
https://endpoint that receives a copy of each journal record. The local journal stays authoritative; push failures never block a mutation. The key comes from$VPSCFG_TELEMETRY_KEYor~/.config/vpscfgfarm/telemetry-key. - Traefik file provider: read a host's dynamic config to suggest
http:verify checks. - Firewall manager: the desired state that
fleet firewalljudges live firewall state against. - Maintenance windows: a JSON export of active windows;
--require-blackoutrefuses hosts outside one. - badfarm IaC scan: a separately installed
badfarmonPATH(or$VPSCFG_BADFARM).fleet iac PATHscans a local tree, and deploy preflight'siaccheck scans the contract's repository. - Secret source: the command
secret-put --from-piperuns, without a shell, to read one secret.
How it fits with the other tools
- badfarm discovers problems read-only and can scan a contract's infrastructure code for vpscfgfarm's preflight.
- goodfarm plans repairs and drives them through vpscfgfarm's repair protocol:
./vpscfg-control.py HOST deploy SERVICE --repair-action describe|approve|apply|status. Onlyservice-deploy/v1executes, through the normal deploy gates; the distro-upgrade and reboot recipes can be approved but their apply records a refusal. - vpscfgfarm executes through its gates, and holds all the authority.
- farmops is planned to schedule routine work under standing grants that vpscfgfarm checks on every call. Today farmops has only its catalog format, loader and journal.
Links
- Full README on GitHub
- The example estate walkthrough
- vpsexec reference, deploy door how-to, design, release ratchet, policy files, preflight, recovery
- Tagged versions (v0.1.0; no packaged release downloads)
- Apache 2.0 license
- Issue tracker
- Security policy: report a way past a door privately.
Built from the public vpscfgfarm repository (README.md, contrib/README.md, the example estate, docs/HOWTO.md, docs/VPSEXEC.md, docs/DESIGN.md, docs/STABILITY.md, docs/REPAIR-PROTOCOL.md and the vpscfg CLI help text) at commit e899b93.