diff --git a/Dockerfile b/Dockerfile index c74d42d..e74ceca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update \ gosu \ # openssh-client — ansible-playbook reaches remote hosts over ssh openssh-client \ + # sshpass — Ansible shells out to it for first-contact password SSH + # (provisioning a fresh host before the managed key is installed) + sshpass \ && rm -rf /var/lib/apt/lists/* # Upgrade pip to suppress the version notice diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 07e672b..269296b 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -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)) diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 03b6958..77fdf71 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -31,6 +31,51 @@ {% if ansible_available %} +
+

Provision a fresh host

+

+ First contact for a host Steward can't yet reach by key. Creates a + steward 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 Deploy below for updates. +

+ {% if not managed_key_set %} +

+ No managed key yet. Generate one under Settings → Ansible first. +

+ {% elif not (deploy_targets or deploy_groups) %} +

+ No Ansible inventory targets yet. Add some under Ansible → Inventory. +

+ {% else %} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {% endif %} +
+

Deploy via Ansible

diff --git a/steward/ansible/bundled/host_agent/provision.yml b/steward/ansible/bundled/host_agent/provision.yml new file mode 100644 index 0000000..db35589 --- /dev/null +++ b/steward/ansible/bundled/host_agent/provision.yml @@ -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 diff --git a/steward/ansible/executor.py b/steward/ansible/executor.py index dcfa9b6..f8c7ec8 100644 --- a/steward/ansible/executor.py +++ b/steward/ansible/executor.py @@ -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, diff --git a/steward/ansible/runner.py b/steward/ansible/runner.py index a8ea6cd..5b031ef 100644 --- a/steward/ansible/runner.py +++ b/steward/ansible/runner.py @@ -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( diff --git a/steward/core/crypto.py b/steward/core/crypto.py index 2d5a1fb..44595a7 100644 --- a/steward/core/crypto.py +++ b/steward/core/crypto.py @@ -19,6 +19,8 @@ import os from pathlib import Path from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey logger = logging.getLogger(__name__) @@ -91,3 +93,26 @@ def decrypt_secret(value: str) -> str: except InvalidToken: logger.error("Could not decrypt a stored secret (wrong/rotated key?)") return value + + +def generate_ssh_keypair(comment: str = "steward-managed") -> tuple[str, str]: + """Mint a fresh ed25519 keypair for Steward's managed Ansible identity. + + Returns (private_openssh_pem, public_openssh_line). ed25519 is small, fast, + and universally supported by modern OpenSSH. The private key is unencrypted + OpenSSH PEM (Steward stores it encrypted-at-rest itself); the public key is + the single-line ``ssh-ed25519 AAAA… comment`` form for authorized_keys. + """ + key = Ed25519PrivateKey.generate() + private_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + public_line = key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ).decode("ascii") + if comment: + public_line = f"{public_line} {comment}" + return private_pem, public_line diff --git a/steward/core/settings.py b/steward/core/settings.py index 465d563..e08b03d 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -52,10 +52,17 @@ DEFAULTS: dict[str, Any] = { "webhook.template": _DEFAULT_WEBHOOK_TEMPLATE, "ansible.sources": [], # Ansible credentials — global, used by every run (manual + alert-triggered). - # Plaintext at rest, masked in the UI (encryption-at-rest tracked separately). + # ssh_private_key/become/vault are encrypted at rest (see SECRET_KEYS). "ansible.ssh_private_key": "", "ansible.become_password": "", "ansible.vault_password": "", + # Public half of the managed keypair — non-secret, displayed so it can be + # sprayed onto hosts (provisioning installs it into ~steward/.ssh). + "ansible.ssh_public_key": "", + # Default SSH login for steady-state runs — the dedicated account + # provisioning creates. Acts as a floor (--user); a target's ansible_user + # or a per-run bootstrap override still wins. + "ansible.ssh_user": "steward", "ansible.host_key_checking": False, # Max simultaneous playbook runs; extra runs queue. Applied at app start. "ansible.max_concurrent_runs": 3, @@ -198,6 +205,8 @@ def to_ansible_cfg(settings: dict[str, Any]) -> dict: "ssh_private_key": settings.get("ansible.ssh_private_key", ""), "become_password": settings.get("ansible.become_password", ""), "vault_password": settings.get("ansible.vault_password", ""), + "ssh_public_key": settings.get("ansible.ssh_public_key", ""), + "ssh_user": settings.get("ansible.ssh_user", "steward"), "host_key_checking": settings.get("ansible.host_key_checking", False), "max_concurrent_runs": settings.get("ansible.max_concurrent_runs", 3), } diff --git a/steward/settings/routes.py b/steward/settings/routes.py index fcba65b..8944f58 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -178,11 +178,36 @@ async def ansible(): ssh_key_set=bool(settings.get("ansible.ssh_private_key")), become_set=bool(settings.get("ansible.become_password")), vault_set=bool(settings.get("ansible.vault_password")), + ssh_public_key=settings.get("ansible.ssh_public_key", ""), + ssh_user=settings.get("ansible.ssh_user", "steward"), host_key_checking=settings.get("ansible.host_key_checking", False), max_concurrent_runs=settings.get("ansible.max_concurrent_runs", 3), ) +@settings_bp.post("/ansible/generate-key") +@require_role(UserRole.admin) +async def ansible_generate_key(): + """Mint a fresh managed ed25519 keypair (private encrypted, public shown). + + This is the reusable Steward identity: provisioning installs the public half + on every host, and steady-state runs authenticate with the private half. + Regenerating invalidates access to already-provisioned hosts until they are + re-provisioned, so the UI confirms before calling this. + """ + from steward.core.crypto import generate_ssh_keypair + + private_pem, public_line = generate_ssh_keypair() + async with current_app.db_sessionmaker() as db: + async with db.begin(): + await set_setting(db, "ansible.ssh_private_key", private_pem) + await set_setting(db, "ansible.ssh_public_key", public_line) + await _reload_app_config() + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "settings.saved", detail={"section": "ansible.managed_key"}) + return redirect(url_for("settings.ansible")) + + @settings_bp.post("/ansible/credentials") @require_role(UserRole.admin) async def ansible_save_credentials(): @@ -203,6 +228,9 @@ async def ansible_save_credentials(): val = raw.strip("\n") if field == "ssh_private_key" else raw.strip() if val: await set_setting(db, key, val) + ssh_user = (form.get("ssh_user", "") or "").strip() + if ssh_user: + await set_setting(db, "ansible.ssh_user", ssh_user) await set_setting(db, "ansible.host_key_checking", "host_key_checking" in form) try: mcr = max(1, min(50, int(form.get("max_concurrent_runs", 3)))) diff --git a/steward/templates/settings/ansible.html b/steward/templates/settings/ansible.html index 27d0602..2bf1611 100644 --- a/steward/templates/settings/ansible.html +++ b/steward/templates/settings/ansible.html @@ -21,6 +21,35 @@ {# ── Credentials ──────────────────────────────────────────────────────────── #} {% set cfg_badge = '(configured)' %} {% set not_set_badge = '(not set)' %} + + {# ── Managed identity (provisioning) ───────────────────────────────────────── #} +

+
Managed SSH identity
+

+ Steward's reusable Ansible identity. Provisioning installs the public + key into each host's {{ ssh_user }} account; steady-state runs authenticate + with the private half (stored encrypted). Generate it once, then provision hosts from + Host Agents. +

+ {% if ssh_public_key %} +
+ + + Click to select — add to a host's authorized_keys to authorize Steward manually. +
+
+ +
+ {% else %} +
+ + ed25519 — created server-side, nothing to paste. +
+ {% endif %} +
+
Credentials

@@ -56,6 +85,12 @@ Clear {% endif %}

+
+ + +