feat(ansible): host provisioning via steward managed SSH identity
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:
@@ -558,6 +558,9 @@ async def settings_list():
|
||||
if new_token and new_host_id:
|
||||
install_url = f"{public_base_url(request)}/plugins/host_agent/install.sh?token={new_token}"
|
||||
|
||||
managed_key_set = bool(
|
||||
(current_app.config.get("ANSIBLE", {}).get("ssh_public_key") or "").strip())
|
||||
|
||||
return await render_template(
|
||||
"settings_list.html",
|
||||
registrations=[
|
||||
@@ -568,6 +571,7 @@ async def settings_list():
|
||||
ansible_available=ansible_available,
|
||||
deploy_targets=targets,
|
||||
deploy_groups=groups,
|
||||
managed_key_set=managed_key_set,
|
||||
)
|
||||
|
||||
|
||||
@@ -725,3 +729,83 @@ async def deploy_via_ansible():
|
||||
if err:
|
||||
return _error(400, "deploy_failed", err)
|
||||
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||
|
||||
|
||||
@host_agent_bp.post("/provision")
|
||||
@require_role(UserRole.admin)
|
||||
async def provision_via_ansible():
|
||||
"""First-contact provisioning: create the steward account + install the
|
||||
managed key + NOPASSWD sudo, then install the agent — over bootstrap
|
||||
(password) auth. After this, hosts are reachable with the managed key.
|
||||
|
||||
The bootstrap password is passed to the runner as a connection override and
|
||||
is NOT persisted on the run row.
|
||||
"""
|
||||
import json as _json
|
||||
from steward.core.capabilities import has_capability, invoke_capability
|
||||
from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory
|
||||
from steward.ansible.sources import BUILTIN_SOURCE_NAME
|
||||
|
||||
if not has_capability("ansible.run_playbook"):
|
||||
return _error(400, "ansible_unavailable", "Ansible is not available")
|
||||
|
||||
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
||||
pubkey = (ansible_cfg.get("ssh_public_key") or "").strip()
|
||||
if not pubkey:
|
||||
return _error(400, "no_managed_key",
|
||||
"Generate a managed SSH key in Settings → Ansible first")
|
||||
steward_user = (ansible_cfg.get("ssh_user") or "steward").strip() or "steward"
|
||||
|
||||
form = await request.form
|
||||
scope = (form.get("inventory_scope", "") or "").strip()
|
||||
boot_user = (form.get("bootstrap_user", "") or "").strip()
|
||||
boot_password = form.get("bootstrap_password", "") or ""
|
||||
try:
|
||||
interval = max(5, int(form.get("agent_interval", "30")))
|
||||
except (TypeError, ValueError):
|
||||
interval = 30
|
||||
if not (scope.startswith("steward:target:")
|
||||
or scope.startswith("steward:group:")
|
||||
or scope == "steward:all"):
|
||||
return _error(400, "bad_scope", "Choose a target or group")
|
||||
if not boot_user or not boot_password:
|
||||
return _error(400, "missing_bootstrap",
|
||||
"Bootstrap user and password are required for first contact")
|
||||
|
||||
url = public_base_url(request)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
targets = await fetch_scope_targets(db, scope)
|
||||
if not targets:
|
||||
return _error(400, "no_targets", "No Ansible targets in that scope")
|
||||
tokens: dict[str, str] = {}
|
||||
async with db.begin():
|
||||
for t in targets:
|
||||
host = await _ensure_host_for_target(db, t)
|
||||
tokens[t.name] = await _mint_registration_token(db, host)
|
||||
inv = generate_inventory(targets)
|
||||
for name, tok in tokens.items():
|
||||
hv = inv["_meta"]["hostvars"].setdefault(name, {})
|
||||
hv["steward_url"] = url
|
||||
hv["steward_token"] = tok
|
||||
# Pubkey carries a space (the comment) and steward_user is free text —
|
||||
# injected as JSON hostvars (space-safe), NOT -e k=v strings which
|
||||
# Ansible would shlex-split on whitespace.
|
||||
hv["steward_pubkey"] = pubkey
|
||||
hv["steward_user"] = steward_user
|
||||
inventory_content = _json.dumps(inv)
|
||||
|
||||
actor_role = UserRole(session.get("user_role", "viewer"))
|
||||
run, _source, err = await invoke_capability(
|
||||
"ansible.run_playbook", actor_role,
|
||||
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||
source_name=BUILTIN_SOURCE_NAME,
|
||||
playbook_path="host_agent/provision.yml",
|
||||
inventory_content=inventory_content,
|
||||
inventory_scope=scope,
|
||||
params={"extra_vars": [f"agent_interval={interval}"]},
|
||||
triggered_by=session.get("user_id"),
|
||||
connection={"user": boot_user, "password": boot_password},
|
||||
)
|
||||
if err:
|
||||
return _error(400, "provision_failed", err)
|
||||
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in New Issue
Block a user