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
@@ -0,0 +1,143 @@
---
# First-contact provisioning: bring a fresh host under Steward management, then
# install the host agent — all in one idempotent run.
#
# Run this ONCE per host with bootstrap credentials (an existing login + sudo,
# typically password auth via the "Provision host" button). It:
# 1. creates a dedicated `steward` login account,
# 2. installs Steward's managed public key into its authorized_keys,
# 3. grants it passwordless sudo,
# 4. installs/updates the host agent.
# After this, every future run (agent updates, maintenance) connects as
# `steward` with the managed key — no operator credentials needed.
#
# Extra-vars (injected by the Provision card):
# steward_url, steward_token — agent → Steward auth (per-host)
# steward_pubkey — managed public key to authorize
# steward_user (default steward), agent_interval (default 30)
- name: Provision host for Steward + install agent
hosts: all
become: true
gather_facts: false
vars:
steward_url: ""
steward_token: ""
steward_pubkey: ""
steward_user: steward
agent_interval: 30
tasks:
- name: Require provisioning vars
ansible.builtin.assert:
that:
- steward_url | length > 0
- steward_token | length > 0
- steward_pubkey | length > 0
fail_msg: "Pass steward_url, steward_token and steward_pubkey as extra-vars."
# ── Managed login account ────────────────────────────────────────────────
- name: Create the steward management account
ansible.builtin.user:
name: "{{ steward_user }}"
shell: /bin/sh
create_home: true
state: present
- name: Ensure ~/.ssh exists
ansible.builtin.file:
path: "/home/{{ steward_user }}/.ssh"
state: directory
owner: "{{ steward_user }}"
group: "{{ steward_user }}"
mode: "0700"
- name: Authorize the managed public key
ansible.builtin.lineinfile:
path: "/home/{{ steward_user }}/.ssh/authorized_keys"
line: "{{ steward_pubkey }}"
create: true
owner: "{{ steward_user }}"
group: "{{ steward_user }}"
mode: "0600"
- name: Grant passwordless sudo to the steward account
ansible.builtin.copy:
dest: "/etc/sudoers.d/steward"
content: "{{ steward_user }} ALL=(ALL) NOPASSWD:ALL\n"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
# ── Host agent (same as install.yml) ─────────────────────────────────────
- name: Create steward-agent system user
ansible.builtin.user:
name: steward-agent
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Create agent directory
ansible.builtin.file:
path: /usr/local/lib/steward-agent
state: directory
mode: "0755"
- name: Download agent.py
ansible.builtin.get_url:
url: "{{ steward_url }}/plugins/host_agent/agent.py"
dest: /usr/local/lib/steward-agent/agent.py
mode: "0755"
force: true
notify: restart steward-agent
- name: Write agent config
ansible.builtin.copy:
dest: /etc/steward-agent.conf
owner: root
group: steward-agent
mode: "0640"
content: |
url = {{ steward_url }}
token = {{ steward_token }}
interval_seconds = {{ agent_interval }}
notify: restart steward-agent
- name: Install systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/steward-agent.service
mode: "0644"
content: |
[Unit]
Description=Steward host agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=steward-agent
Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf
ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
notify: restart steward-agent
- name: Enable and start the agent
ansible.builtin.systemd:
name: steward-agent
enabled: true
state: started
daemon_reload: true
handlers:
- name: restart steward-agent
ansible.builtin.systemd:
name: steward-agent
state: restarted
daemon_reload: true
+56 -3
View File
@@ -155,6 +155,37 @@ def build_credentials(creds: dict | None, tmpdir: str) -> tuple[list[str], list[
return args, files
def build_bootstrap(connection: dict | None, tmpdir: str) -> tuple[list[str], list[tuple[str, str]]]:
"""Per-run connection override for first-contact provisioning.
Pure: returns (extra argv, files to write). The bootstrap user/password are
written to a temp vars file (``-e @file``) — never argv, never the DB — so
they don't leak via the process list or persist on the AnsibleRun row.
A bare connection password doubles as the become password unless one is
given explicitly. connection keys: user, password, become_password.
"""
args: list[str] = []
files: list[tuple[str, str]] = []
connection = connection or {}
user = (connection.get("user") or "").strip()
password = connection.get("password") or ""
if not user and not password:
return args, files
lines: dict[str, str] = {}
if user:
lines["ansible_user"] = user
if password:
lines["ansible_password"] = password
lines["ansible_become_password"] = connection.get("become_password") or password
# JSON values are valid YAML and keep arbitrary characters safe.
content = "".join(f"{k}: {json.dumps(v)}\n" for k, v in lines.items())
path = os.path.join(tmpdir, "bootstrap.yml")
files.append((path, content))
args += ["-e", "@" + path]
return args, files
def ansible_env(creds: dict | None, base_env) -> dict:
"""Return a copy of base_env with ANSIBLE_HOST_KEY_CHECKING set from creds."""
env = dict(base_env)
@@ -250,12 +281,17 @@ async def start_run(
source_path: str,
params: dict | None = None,
inventory_content: str | None = None,
connection: dict | None = None,
) -> None:
"""Execute ansible-playbook as a subprocess and update the DB run row.
Respects a global concurrency cap (app._ansible_semaphore): if no slot is
free the run shows as 'queued' until one frees. Supports cancellation, a
persistent full-log artifact, and a parsed per-host result summary.
connection is an optional per-run SSH override (user/password) for
first-contact provisioning. It is applied to the subprocess only — it is
never persisted on the AnsibleRun row, never placed on argv, and not logged.
"""
from steward.models.ansible import AnsibleRunStatus
@@ -328,14 +364,31 @@ async def start_run(
inv_target = inventory_path
cmd = build_ansible_command(playbook_path, inv_target, params)
cred_args, cred_files = build_credentials(creds, tmpdir)
for cred_path, content in cred_files:
# First-contact provisioning supplies a password and a bootstrap
# user; the managed key isn't on the host yet, so don't offer it
# (avoids a failed key attempt before the password fallback).
conn = connection or {}
effective_creds = dict(creds)
if conn.get("password"):
effective_creds.pop("ssh_private_key", None)
cred_args, cred_files = build_credentials(effective_creds, tmpdir)
boot_args, boot_files = build_bootstrap(conn, tmpdir)
for cred_path, content in cred_files + boot_files:
with open(cred_path, "w", encoding="utf-8") as cf:
cf.write(content)
os.chmod(cred_path, 0o600)
# Steady-state floor: connect as the managed account unless a target
# var or the bootstrap override (extra-vars, higher precedence) wins.
user_args: list[str] = []
if not conn.get("user"):
ssh_user = (creds.get("ssh_user") or "").strip()
if ssh_user:
user_args += ["--user", ssh_user]
proc = await asyncio.create_subprocess_exec(
*cmd, *cred_args,
*cmd, *cred_args, *boot_args, *user_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
cwd=cwd,
+5
View File
@@ -25,6 +25,7 @@ async def trigger_run(
params: dict | None = None,
triggered_by: str | None = None,
inventory_content: str | None = None,
connection: dict | None = None,
):
"""Resolve inventory for the scope, persist an AnsibleRun, and launch it.
@@ -32,6 +33,9 @@ async def trigger_run(
If inventory_content is provided, it is used verbatim and scope resolution
is skipped (the caller built a bespoke inventory — e.g. host_agent deploy
injecting per-host tokens); inventory_scope is still recorded for display.
connection is an optional per-run SSH override (user/password) for
first-contact provisioning — passed to the executor but deliberately NOT
stored on the AnsibleRun row (params), so the password never lands in the DB.
Returns (run, source, error): on success error is None; on failure run is
None and error is a short human-readable reason.
"""
@@ -73,6 +77,7 @@ async def trigger_run(
executor.start_run(
app, run_id, playbook_path, inventory_path or "",
source["path"], params or None, inventory_content,
connection=connection,
)
)
task.add_done_callback(