feat(ansible): host provisioning via steward managed SSH identity
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 1m3s

Turn the agent-install playbook into a full provisioning + maintenance
path. Solves the bootstrap chicken-and-egg: first contact uses an
operator-supplied password (one run, never stored), which creates a
dedicated `steward` login account with NOPASSWD sudo + Steward's managed
public key. Every run thereafter connects as `steward` with the managed
key — fully unattended (scheduled prune, agent updates).

- core/crypto: generate_ssh_keypair() — ed25519, OpenSSH formats.
- settings: ansible.ssh_public_key (non-secret, displayed) + ansible.ssh_user
  (default steward); to_ansible_cfg extended.
- settings UI + route: "Generate managed key" (private encrypted, public
  shown to copy) + SSH-user field.
- executor: build_bootstrap() writes a 0600 vars file (-e @file) for the
  per-run user/password — never argv, never DB, never logged; drops the
  managed key when a bootstrap password is given; --user floor from the
  global ssh_user when no override.
- runner.trigger_run: pass-through `connection` kwarg, deliberately NOT
  persisted on AnsibleRun.params (password stays out of the DB).
- bundled/host_agent/provision.yml: create steward user + authorized_keys
  + /etc/sudoers.d/steward (visudo-validated) + agent install.
- host_agent: /provision route + "Provision a fresh host" card (bootstrap
  user/password; injects pubkey + user + token as space-safe JSON hostvars).
- Dockerfile: add sshpass (Ansible shells out to it for password SSH).
- tests: keypair generation + build_bootstrap (secret stays off argv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:17:38 -04:00
parent 6e91bdc82b
commit 0318f6423f
12 changed files with 492 additions and 5 deletions
@@ -31,6 +31,51 @@
</div>
{% if ansible_available %}
<div class="card">
<h3 style="margin-bottom:0.4rem;">Provision a fresh host</h3>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.75rem;">
First contact for a host Steward can't yet reach by key. Creates a
<code>steward</code> login account with passwordless sudo, installs the managed
key, then installs the agent — over a one-time bootstrap password (used for this
run only, never stored). After this, use <strong>Deploy</strong> below for updates.
</p>
{% if not managed_key_set %}
<p style="font-size:0.85rem;color:var(--text-dim);">
No managed key yet. Generate one under <a href="/settings/ansible/">Settings → Ansible</a> first.
</p>
{% elif not (deploy_targets or deploy_groups) %}
<p style="font-size:0.85rem;color:var(--text-dim);">
No Ansible inventory targets yet. Add some under <a href="/ansible/inventory/targets">Ansible → Inventory</a>.
</p>
{% else %}
<form method="post" action="/plugins/host_agent/provision"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">
<div class="form-group" style="margin-bottom:0;">
<label>Target</label>
<select name="inventory_scope" required>
{% for g in deploy_groups %}<option value="steward:group:{{ g.id }}">Group: {{ g.name }}</option>{% endfor %}
{% for t in deploy_targets %}<option value="steward:target:{{ t.id }}">Target: {{ t.name }}</option>{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Bootstrap user</label>
<input type="text" name="bootstrap_user" required placeholder="root" style="width:9rem;"
autocomplete="off">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Bootstrap password</label>
<input type="password" name="bootstrap_password" required style="width:11rem;"
autocomplete="new-password">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Report interval (s)</label>
<input type="number" name="agent_interval" value="30" min="5" style="width:7rem;">
</div>
<button type="submit" class="btn">Provision</button>
</form>
{% endif %}
</div>
<div class="card">
<h3 style="margin-bottom:0.4rem;">Deploy via Ansible</h3>
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.75rem;">