M4 plan: the guided wizard (v0.4.0)
Status: done. Everything below shipped in v0.4.0 (PRs #70 to #73, #78, #80 and #82, and the security fixes #79 and #81). The plan is kept as the design record. Where the code differs from this plan:
modules <ID>does not list the files and services the module touches. There is no static source for them (they depend on the host’s facts), so it would need a plan against a host.- The flow lives in
src/cli/wizard.rs;src/tuiholds only the prompts (thePrompttrait and itsdialoguerimplementation), not the flow.
M4 makes sudo vps-ready with no arguments a short guided setup for someone who has never hardened a server: it looks at the host, recommends a profile, asks three or four questions, shows the plan, applies it with the safety timer, and ends by printing the command that does the same thing without questions. It also adds vps-ready modules, which explains each module.
The wizard is a front end, not a new path: it builds a profile and hands it to the same plan and apply code the commands use. Anything it does, apply --profile <file> can repeat. The engine, the modules and the safety timer do not change.
Debian 12/13 and Ubuntu 22.04/24.04 with systemd, as in M3.
Decisions
Section titled “Decisions”Entry point
Section titled “Entry point”Cli.command becomes Option<Command>. With no subcommand, vps-ready starts the wizard when stdin and stdout are both terminals. Otherwise it behaves as today (help on stderr, exit code 2), so scripts and pipes see no change. There is no wizard subcommand: the bare command is the one the README and the site teach.
Before the first question, in this order, the wizard stops with the same message the commands print:
- Not root:
Run it with sudo: sudo vps-ready.vps-ready planstays the way to look without root. - The binary cannot be trusted to run from the safety timer (the existing binary-path check, for example
./vps-readyin a home directory). - A transaction is pending or did not finish: the engine’s own message (
rollback --txn ID, or wait for its deadline). This takes the lock, asapplydoes.
So nobody answers every question and then fails at apply for a reason that was known at the start.
The flow
Section titled “The flow”$ sudo vps-readyvps-ready 0.4.0 on Ubuntu 24.04 (KVM)Audit: 25 passed, 3 failed (2 medium, 1 low). vps-ready can fix 2 of them.
? What will this server run? > Web apps or Docker: opens 80 and 443 (recommended: Docker is installed) Something else: SSH only? Admin user: alice (existing account, the one you logged in as)? Open ports besides SSH (22): [x] 80/tcp [x] 443/tcp [x] 443/udp [ ] 5432/tcp (postgres)? Change the module list? (y/N)
<the plan, exactly as `apply` prints it>Apply 9 changes? [y/N] y...<the safety timer countdown and the confirm line, as today>
Next time, without questions: sudo vps-ready apply --profile web-
Header and audit line. The OS line from
detectand the audit summary, so the questions have a reason. One line each, no list. -
What will this server run? Two choices that map to the built-in profiles,
webandminimal. The recommended one is first and says why:webwhen Docker is installed or something listens publicly on 80 or 443, elseminimal. -
Admin user. The default follows the rule
ssh.serveralready uses: the user this SSH session logged in as when that is not root, elseadmin. For a root login the wizard offers the first ofadminandsysadminthatuser.adminaccepts (Ubuntu’s cloud images have anadmingroup and no such user), or no default when neither is, and a name other thanadminis saved asuser.admin’sname. Naming the session user is always safe:user.adminonly adds (keys are appended, an existing password is never touched, existing sudo rules are kept). The line under the question says what will happen to the name typed, checked with the same lookupsuser.adminuses:- a new account:
created, with N keys from <path>; - an existing login account:
existing account: gets sudo and N keys from <path>, so granting sudo to an existing user is never silent; - a name
user.adminrefuses (root, a group without a user, a system or nologin account): the refusal text, and the question is asked again.
Keys come from where
user.admintakes them today: the session user’sauthorized_keys, else root’s, skipping lines with options (the wizard calls the module’s own key reader, madepub(crate), rather than a copy). When that finds no key, the wizard asks for a public key to paste, checks it with the same line check, and puts it inauthorized_keys; it never continues without a key. - a new account:
-
Open ports. A multi-select. The profile’s ports are checked. Ports something listens on publicly are listed unchecked with the process name, under the line “Unchecked ports will be blocked.” Public means a wildcard address (
*,0.0.0.0,::) or a specific non-loopback one, with any%zonestripped; loopback (127.0.0.0/8,::1,::ffff:127.x), the SSH ports and ports already in the list are left out.docker-proxylisteners are left out too: Docker’s published ports are forwarded past this firewall (see theFW-002control), so the line would be false for them. Under socket activation the process may show assystemd; it is listed as such. Another port can be typed as<port>/tcpor<port>/udp, with the same parser the profile uses, asking again on a bad value. The SSH ports are shown as always open. Asked only whenfirewall.inboundapplies; otherwise the wizard says why it is skipped (for example, ufw is active). -
Change the module list? Default no, matching decisions.md (“recommended by default, with Customize one keypress away”). Yes shows a multi-select of every module with its title; modules that arm the safety timer say so, and modules that do not apply are shown greyed out with the reason. Removing
user.adminwhile keepingssh.serverworks only when the host already has a keyed sudo user, which is the existingssh.serverrule; if not, its refusal is shown and the list is asked again. -
Plan and apply.
cli/change.rsis split so the loading of--profileis separate from the work:plan_profileandapply_profiletake anOption<Profile>, and the commands become a load plus a call. The wizard callsapply_profilewith the profile it built,yes = falseand the terminal as input, so the plan, theApply N changes? [y/N]question, the progress lines, the countdown and the confirm instructions are the onesapplyalready prints. The wizard locks stdin for that answer only after its lastdialoguerprompt, since both read the same terminal. The safety window is the default 5 minutes. “No” ends with “Nothing was changed.” An empty plan ends with “Nothing to do: this server already matches.” (applycollects the facts again; that costs about a second and keeps one code path.) -
Next time. When the answers equal a built-in profile, the line is
sudo vps-ready apply --profile web(orminimal). Otherwise the wizard writes the profile it built tovps-ready.tomlin the current directory (it asks first, default yes, and never overwrites without asking) and prints the command with the file’s absolute path, sincesudooften runs in/root. The saved fileextendsthe built-in profile and holds only what differs, withenabled = falsefor a module taken out.
Ctrl-C at any question exits with code 130 and “Nothing was changed.” Ctrl-C during apply behaves as it does today.
Prompt library: dialoguer, not inquire
Section titled “Prompt library: dialoguer, not inquire”The architecture doc names inquire. M4 uses dialoguer instead, with default features off and only Select, MultiSelect, Input and Confirm:
- Its terminal layer is
console, which is small and already reviewed (it is inCargo.locktoday as a test dependency ofinsta, and becomes a runtime one).inquirebringscrosstermand a larger tree, which matters for a static binary people pipe intosudo sh. - It is from console-rs (the
instaandindicatifmaintainers) and widely used. - The wizard needs four plain widgets.
The version added is one released at least 7 days before, as for every dependency. All prompts go through a small Prompt trait (select, multi_select, input, confirm), with a dialoguer implementation and a scripted one for tests. Nothing outside src/tui/ touches dialoguer.
--no-color and NO_COLOR turn the theme’s colors off, as they do for the rest of the output.
Writing profiles back as TOML
Section titled “Writing profiles back as TOML”Profile keeps neither extends nor disabled modules, so a ProfileBuilder holds the base name, the params set and the modules taken out. It builds the Profile the wizard applies and writes the TOML (schema, extends, only the sections that differ, enabled = false for removed modules). It needs the toml crate’s display feature; params are already toml::Table, so no params struct needs Serialize. Merging is key by key, one level deep, so a section holding only the changed keys gives the same result. A round-trip test holds the rule: for every wizard outcome in the tests, Profile::parse(builder.to_toml()) == builder.build().
vps-ready modules [ID]
Section titled “vps-ready modules [ID]”Replaces modules --explain ID from the architecture doc, since one positional argument is simpler.
vps-ready moduleslists every module: ID, title, whether it applies on this host (with the reason when not), and whether it arms the safety timer.vps-ready modules ssh.serveradds thewhy()text, the params with their defaults and a one-line help each, the audit controls whosefixnames the module, and the files and services it touches.--jsonfor both. No root needed; applicability uses the same facts asdetect.
Each module gains params_doc() -> &'static [ParamDoc] (name, default, help); modules without params return an empty list. A test checks that every documented param name is a field the module’s params accept, so the help cannot drift from the code.
Not in M4
Section titled “Not in M4”| Planned before | M4 | When |
|---|---|---|
ratatui plan/diff view |
The plan text apply prints |
If the plain plan proves hard to read |
JSON Schema for profiles (schemars) |
Strict parsing with field names in errors | With /etc/vps-ready/config.toml and drift (M6), when there is an editor-facing file to complete |
Export as cloud-init user-data |
The “next time” command and saved profile | M5, with --deadman=off for unattended runs |
| Changing the safety window in the wizard | Default 5 minutes; apply --deadman for other values |
If asked for |
| More questions (SSH port, sysctl choices) | Three or four questions | Only with a module that needs them |
Testing
Section titled “Testing”Layer 1:
- The wizard flow on
FakeFs/FakeRunnerfacts with the scriptedPrompt, asinstasnapshots of the full transcript:- a Docker host (recommends
web; adocker-proxylistener is not offered); - a host with nothing listening (recommends
minimal); - a non-root session user as admin (existing account: the plan has no
user.adminaccount changes beyond sudo and keys it lacks); - root login, with an existing
adminlogin account (says so), then with a groupadminand no user (refused, asked again); - root with no keys (asks for a key; rejects a bad line, then accepts a good one);
- a public listener on 5432 left unchecked; a bad typed port, then a good one;
- a customized module list (writes
vps-ready.toml, prints its absolute path); - not root, an untrusted binary path, a pending transaction (each stops before the first question);
- firewall not applicable (skips the ports question).
- a Docker host (recommends
- For every scenario that applies: the profile the wizard built, saved and parsed again, gives the same plan as the wizard’s (the “anything it does,
apply --profilecan repeat” rule). ProfileBuilderandto_tomlround trips;params_docmatches the params structs.modulesandmodules <ID>snapshots, text and JSON.- Bare
vps-readywithout a terminal: help on stderr, exit code 2 (the existing test, kept).
Layer 2 (containers): none new; the wizard adds no system change of its own.
Layer 3 (VMs): cycle 7 drives sudo vps-ready over a real terminal with expect (installed in the test VM only) on all four images, then checks that vps-ready apply --profile <printed> plans no changes:
- logged in as the image’s cloud user: accept the defaults (the session user becomes the admin), apply, confirm from a second SSH session;
- logged in as root with a key (the seed already adds one): accept the defaults (creates
adminwith root’s key), apply, confirm asadminfrom a second session.
PR sequence
Section titled “PR sequence”docs: plan M4(this document).feat(cli): modules:modulesandmodules <ID>,params_docon every module. Parallel with 3.feat(config): profile builder, TOML output and apply_profile:ProfileBuilder,to_toml, thetomldisplayfeature, round-trip tests;plan_profile/apply_profilesplit out ofcli/change.rswith no change in behavior;user.admin’s key reader and line check madepub(crate). Parallel with 2.feat(tui): the wizard(needs 3):src/tui/with thePrompttrait and itsdialoguerimplementation,Option<Command>, the start checks, the flow above, the “next time” line andvps-ready.toml, the layer 1 scenarios.test(vm): wizard cycle(needs 4): cycle 7.docs(site): the wizard(needs 4): README quick start leads withsudo vps-ready; the site’s quick start and install pages; the landing page’s wizard preview (now “Design preview”) replaced by real output;architecture.md(dialoguer,modules <ID>,src/tui),decisions.md(the default-profile question decided).chore: release v0.4.0: version bump, milestone, release notes.
Waves: {2, 3} → 4 → {5, 6} → 7.
Decided by the owner (2026-10-31)
Section titled “Decided by the owner (2026-10-31)”All eight recommendations were accepted:
dialoguerinstead ofinquire. Recommended for the smaller dependency tree (see above).- Bare
vps-readyis the wizard on a terminal and help otherwise, with nowizardsubcommand. - Root, the binary check and a pending transaction are checked before the first question, rather than failing at apply.
- Customized answers are saved to
vps-ready.toml(asked, default yes), rather than only printed. - Paste a key when none is found, rather than stopping with instructions.
- Public listeners are offered unchecked, with “Unchecked ports will be blocked” (Docker’s published ports left out, since this firewall does not filter them), rather than checked. Checked would keep a running service reachable but could expose a database.
modules <ID>instead ofmodules --explain <ID>.- JSON Schema and
ratatuiwait (see Not in M4), although the architecture doc tied the schema to M4.
Feedback
Questions, ideas or problems with vps-ready.