From ca77cdc40244416ed801a2aedcd30739bc50f801 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 6 Jun 2026 23:26:02 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(baseline):=20deploy=20as=20yourself=20?= =?UTF-8?q?=E2=80=94=20auto-resolve=20admin=20user/key,=20add=20extra=20ke?= =?UTF-8?q?ys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the baseline role zero-config by default instead of requiring a manually set ssh_admin_pubkey: - ssh_admin_user "" -> control-machine local $USER - ssh_admin_pubkey "" -> autodetected ~/.ssh key (id_ed25519 > ecdsa > rsa) - new ssh_admin_extra_pubkeys list authorizes additional team keys The lockout guard is now an unconditional assert that runs before account creation and hardening, aborting the play unless a NON-ROOT admin user and at least one key resolve. This closes two footguns: resolving to "root" (which ssh_hardening then locks out) and an unset $USER silently skipping hardening with a green exit. A debug task echoes the resolved user + key count for visibility into what is trusted. Docs (ansible/README.md, baseline README, group_vars/all.yml, defaults) updated to drop the "pubkey REQUIRED" framing and document the control-node $USER/$HOME resolution caveat. molecule.yml drops the now-inert ssh_admin_* skip vars (converge runs only anvil + caddy; baseline is never exercised). Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/README.md | 16 ++-- ansible/inventory/group_vars/all.yml | 20 +++-- ansible/molecule/default/molecule.yml | 5 +- ansible/roles/baseline/README.md | 35 +++++++-- ansible/roles/baseline/defaults/main.yml | 14 +++- ansible/roles/baseline/tasks/main.yml | 94 ++++++++++++++++++------ 6 files changed, 140 insertions(+), 44 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index cdf1174..16e4d98 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -43,12 +43,17 @@ cd ansible make deps # vendor pinned collections into ./collections cp inventory/hosts.yml.example inventory/hosts.yml $EDITOR inventory/hosts.yml # set hosts for decdn_nodes and/or anvil_devnet -$EDITOR inventory/group_vars/all.yml # set ssh_admin_pubkey (REQUIRED) +$EDITOR inventory/group_vars/all.yml # optional: override admin user/keys, allowlists ``` -`ssh_admin_pubkey` is **mandatory** — baseline refuses to run `ssh_hardening` (which -disables root + password login) without it, so you can't lock yourself out. After the first -deploy, switch each host's `ansible_user` to your `ssh_admin_user` (default `deploy`). +By default baseline **deploys you as yourself**: an empty `ssh_admin_user` resolves to your +control-machine `$USER`, and an empty `ssh_admin_pubkey` is autodetected from `~/.ssh` +(`id_ed25519` > `ecdsa` > `rsa`). Add teammates' keys via `ssh_admin_extra_pubkeys`. Set +`ssh_admin_user`/`ssh_admin_pubkey` explicitly to override (e.g. a shared `deploy` account, +or when deploying from CI). baseline **asserts a key resolves** before `ssh_hardening` +disables root + password login, so you can't lock yourself out. After the first deploy, +switch each host's `ansible_user` to that admin account (your local username unless you set +one). --- @@ -122,7 +127,8 @@ Defaults live in each role (`roles/*/defaults/main.yml`); override in `group_var | Var | Default | Notes | |-----|---------|-------| -| `ssh_admin_user` / `ssh_admin_pubkey` | `deploy` / `""` | **pubkey required**; admin created before SSH hardening. | +| `ssh_admin_user` / `ssh_admin_pubkey` | `""` / `""` | Empty = local `$USER` + autodetected `~/.ssh` key; admin created before SSH hardening. | +| `ssh_admin_extra_pubkeys` | `[]` | Extra authorized keys for the admin user (teammates). | | `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. | | `decdn_node_version` | `""` | **required**; a `v` release must exist. | | `decdn_rpc_url` + 3 contract addresses | `""` | **required** per node (host_vars); sourced from an ADR/deployment. | diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml index 0331567..1b613c8 100644 --- a/ansible/inventory/group_vars/all.yml +++ b/ansible/inventory/group_vars/all.yml @@ -5,11 +5,21 @@ # the roles are self-contained. Set anything here to override per host/group. # Secrets are generated ON THE HOST by the roles and never live here. -# --- REQUIRED --------------------------------------------------------------- -# Public key for the admin sudo user, installed BEFORE ssh_hardening disables -# root + password login (the baseline role asserts this to prevent lockout). -ssh_admin_user: deploy -ssh_admin_pubkey: "" # e.g. "ssh-ed25519 AAAA... you@laptop" +# --- Admin SSH access (deploy as yourself by default) ----------------------- +# The admin sudo user + its key are installed BEFORE ssh_hardening disables root +# + password login. Left empty they resolve from the control machine of whoever +# runs ansible-playbook (NOTE: under `sudo ansible-playbook`, cron, or CI the +# $USER/$HOME — and thus the autodetected key — may not be yours; set both +# explicitly there). Explicit values always win. The baseline role aborts before +# hardening unless a NON-ROOT user + a key resolve, so you can't lock yourself out. +ssh_admin_user: "" # "" -> your local $USER (e.g. "deploy" to share one account; never "root") +ssh_admin_pubkey: "" # "" -> autodetected ~/.ssh key; or "ssh-ed25519 AAAA... you@laptop" +ssh_admin_pubkey_autodetect: true # read ~/.ssh (id_ed25519 > ecdsa > rsa) when pubkey is empty +# Additional authorized keys for the admin user (e.g. teammates): +# ssh_admin_extra_pubkeys: +# - "ssh-ed25519 AAAA... alice@laptop" +# - "ssh-ed25519 AAAA... bob@laptop" +ssh_admin_extra_pubkeys: [] # Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source. ssh_allow_cidrs: [] diff --git a/ansible/molecule/default/molecule.yml b/ansible/molecule/default/molecule.yml index 72103dc..54097f9 100644 --- a/ansible/molecule/default/molecule.yml +++ b/ansible/molecule/default/molecule.yml @@ -26,9 +26,8 @@ provisioner: inventory: group_vars: all: - # Skip SSH hardening / admin-key gating inside the container. - ssh_admin_user: "" - ssh_admin_pubkey: "" + # converge.yml runs only anvil + caddy — baseline (and its admin-user / + # ssh_hardening logic) is never exercised here, so no ssh_admin_* is needed. foundry_version: latest verifier: name: ansible diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index 33b6cd0..7d54b74 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -10,8 +10,11 @@ In order — the ordering matters: 1. **Base packages** — `curl`, `git`, `jq`, `openssl`, `nftables`, `fail2ban`, `unattended-upgrades`, `chrony`, … (override `baseline_packages`). -2. **Admin sudo user** — creates `ssh_admin_user` and installs `ssh_admin_pubkey` - **before** SSH is hardened, so you keep a way in. +2. **Admin sudo user** — creates `ssh_admin_user` and installs its key(s) + **before** SSH is hardened, so you keep a way in. Both resolve from the control + machine when left empty: the user falls back to the local `$USER`, and the key is + autodetected from `~/.ssh` (`id_ed25519` > `id_ecdsa` > `id_rsa`). Extra team keys + come from `ssh_admin_extra_pubkeys`. Explicit values always win. 3. **Firewall** — nftables **default-deny inbound**; SSH is the only universally-open port. Extra public listeners are declared explicitly via `baseline_extra_inbound`. 4. **Auto-patching** — `unattended-upgrades` for security updates. @@ -23,17 +26,33 @@ In order — the ordering matters: ## Lockout guard -`ssh_hardening` disables root and password auth. The role **asserts** that -`ssh_admin_user` and `ssh_admin_pubkey` are set before it runs — set both for any -real deploy, or you will lock yourself out. (Set `ssh_admin_user: ""` to skip the -admin account + SSH hardening entirely, as the Molecule container does.) +`ssh_hardening` disables root and password auth. The role runs an **unconditional +assert** before any account creation or hardening that aborts the play unless a +**non-root** admin user *and* at least one key resolve. By default these come from +the control machine (local `$USER` + `~/.ssh` key), so a stock interactive run "just +works". The assert fires — by design, so you can fix it rather than lock yourself +out — when any of these hold: + +- **No key resolves**: no `~/.ssh/id_ed25519|ecdsa|rsa.pub` and no explicit + `ssh_admin_pubkey`/`ssh_admin_extra_pubkeys` (the most common real-world trigger). +- **The user is unresolvable**: `ssh_admin_user` empty *and* `$USER` unset (cron, CI, + or `sudo` with `env_reset`). +- **The user resolves to `root`**: hardening forbids root login, so this would be a + guaranteed lockout — set `ssh_admin_user` to a non-root account. + +Because resolution reads the **control node's** `$USER`/`$HOME` of whoever invokes +`ansible-playbook`, a `sudo`/CI run can autodetect a different user/key than you +expect — set both explicitly in that case. (Molecule never runs `baseline`, so the +assert never fires there.) ## Key variables | Var | Default | Notes | |-----|---------|-------| -| `ssh_admin_user` | `deploy` | Admin sudo account; created before SSH hardening. `""` skips it. | -| `ssh_admin_pubkey` | `""` | **Required** for a real deploy — the lockout guard asserts it. | +| `ssh_admin_user` | `""` | Admin sudo account; created before SSH hardening. Empty = the control machine's local `$USER`. | +| `ssh_admin_pubkey` | `""` | Admin key. Empty = autodetected from `~/.ssh` (`id_ed25519`/`ecdsa`/`rsa`). Set to override. | +| `ssh_admin_pubkey_autodetect` | `true` | When `ssh_admin_pubkey` is empty, read the operator's default local public key. | +| `ssh_admin_extra_pubkeys` | `[]` | Additional authorized keys (full pubkey strings) — e.g. teammates. | | `ssh_allow_cidrs` | `[]` | Optional inbound-SSH source allowlist (CIDRs). Empty = any source. | | `baseline_extra_inbound` | `[]` | Extra public inbound ports. Each item `{proto, port, comment}`. Loopback services need nothing here; the deCDN node opens udp/4433. | | `baseline_packages` | see `defaults/main.yml` | Base package set. | diff --git a/ansible/roles/baseline/defaults/main.yml b/ansible/roles/baseline/defaults/main.yml index 0232a73..6e3e8b7 100644 --- a/ansible/roles/baseline/defaults/main.yml +++ b/ansible/roles/baseline/defaults/main.yml @@ -2,9 +2,19 @@ # baseline role defaults. Override in inventory/group_vars for a real deployment. # Admin sudo account, created + keyed BEFORE ssh_hardening disables root login. -# ssh_admin_pubkey MUST be set for a real deploy (lockout guard asserts it). -ssh_admin_user: deploy +# "Deploy as yourself" by default — both knobs resolve from the control machine +# when left empty, and explicit values always win: +# ssh_admin_user "" -> the control-machine local $USER (of whoever runs +# ansible-playbook; under sudo/CI this may not be you) +# ssh_admin_pubkey "" -> autodetected from that same ~/.ssh (id_ed25519 > ecdsa > rsa) +# The lockout-guard assert in tasks/main.yml aborts the run (before any hardening) +# unless a NON-ROOT admin user and >=1 key resolve — so hardening never runs keyless. +ssh_admin_user: "" ssh_admin_pubkey: "" +# When ssh_admin_pubkey is empty, read the operator's default local public key. +ssh_admin_pubkey_autodetect: true +# Additional authorized keys for the admin user (full pubkey strings, e.g. teammates). +ssh_admin_extra_pubkeys: [] # Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source. ssh_allow_cidrs: [] diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index e7d8316..ff14032 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -10,35 +10,85 @@ cache_valid_time: 3600 # --- Admin account (must exist + have a key BEFORE ssh_hardening locks root) --- +# Resolve the user and key set from the control machine when left empty; explicit +# values always win. Lookups run on the control node — that's the operator's box. +- name: Resolve the admin sudo username (local $USER if unset) + ansible.builtin.set_fact: + ssh_admin_user_effective: >- + {{ ssh_admin_user if ssh_admin_user | length > 0 + else lookup('ansible.builtin.env', 'USER') }} + +- name: Autodetect the operator's local SSH public key (control machine) + ansible.builtin.set_fact: + ssh_admin_detected_pubkey: "{{ lookup('ansible.builtin.file', found) | trim }}" + vars: + found: >- + {{ query('ansible.builtin.first_found', + {'files': candidates, 'skip': true}) | first | default('') }} + candidates: + - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ed25519.pub" + - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ecdsa.pub" + - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_rsa.pub" + when: + - ssh_admin_pubkey | length == 0 + - ssh_admin_pubkey_autodetect | bool + - found | length > 0 + +- name: Resolve the set of admin authorized keys + ansible.builtin.set_fact: + ssh_admin_keys: >- + {{ (([ssh_admin_pubkey] if ssh_admin_pubkey | length > 0 + else [ssh_admin_detected_pubkey | default('')]) + + ssh_admin_extra_pubkeys) + | map('trim') | reject('equalto', '') | unique | list }} + +# UNCONDITIONAL lockout guard — runs BEFORE the account is created and hardening +# applied. It is the single gate that makes a keyless/root-only harden impossible; +# downstream tasks therefore need no `when`. ssh_hardening disables root login, so a +# resolved admin user of "root" (or an empty one, e.g. $USER unset under cron/CI) +# would lock the host out — both are refused here rather than skipped silently. +- name: Refuse to harden SSH without a non-root admin user and a key (lockout guard) + ansible.builtin.assert: + that: + - ssh_admin_user_effective | length > 0 + - ssh_admin_user_effective != 'root' + - ssh_admin_keys | length > 0 + fail_msg: >- + Cannot establish a working admin login before devsec.hardening.ssh_hardening + disables root + password auth. Resolved user='{{ ssh_admin_user_effective }}', + keys={{ ssh_admin_keys | length }}. Causes: ssh_admin_user is empty and the + control-machine $USER is unset (cron/CI/sudo with env_reset) -> set + ssh_admin_user; the resolved user is 'root' (hardening forbids root login) -> + set ssh_admin_user to a non-root account; or no key was found -> set + ssh_admin_pubkey, add ssh_admin_extra_pubkeys, or place a default key + (~/.ssh/id_ed25519.pub, id_ecdsa.pub, or id_rsa.pub) on the control machine. + +# Surface WHAT will be trusted: autodetect installs whatever key sits in the +# control box's ~/.ssh, so echo the resolved user + key count (a silently dropped +# malformed entry, or a wrong control machine, shows up as an unexpected count). +- name: Report the resolved admin user and key count + ansible.builtin.debug: + msg: >- + Admin user '{{ ssh_admin_user_effective }}' will be authorized with + {{ ssh_admin_keys | length }} key(s) + ({{ 'explicit ssh_admin_pubkey' if ssh_admin_pubkey | length > 0 + else 'autodetected ~/.ssh key' }} + + {{ ssh_admin_extra_pubkeys | length }} extra). + - name: Create admin sudo user ansible.builtin.user: - name: "{{ ssh_admin_user }}" + name: "{{ ssh_admin_user_effective }}" groups: [sudo] append: true shell: /bin/bash create_home: true - when: ssh_admin_user | length > 0 -- name: Install admin authorized key +- name: Install admin authorized keys ansible.posix.authorized_key: - user: "{{ ssh_admin_user }}" - key: "{{ ssh_admin_pubkey }}" + user: "{{ ssh_admin_user_effective }}" + key: "{{ item }}" state: present - when: - - ssh_admin_user | length > 0 - - ssh_admin_pubkey | length > 0 - -- name: Refuse to harden SSH without a working admin key (lockout guard) - ansible.builtin.assert: - that: - - ssh_admin_user | length > 0 - - ssh_admin_pubkey | length > 0 - fail_msg: >- - ssh_admin_user and ssh_admin_pubkey must be set so the admin account can log - in BEFORE devsec.hardening.ssh_hardening disables root and password auth. - Set them in inventory/group_vars/all.yml. (Molecule sets both to "" to skip - SSH hardening inside the throwaway container.) - when: ssh_admin_user | length > 0 + loop: "{{ ssh_admin_keys }}" # --- Firewall: default-deny inbound, SSH only --------------------------------- - name: Install nftables ruleset @@ -99,7 +149,9 @@ ansible.builtin.include_role: name: devsec.hardening.os_hardening +# No `when` guard here by design: the unconditional lockout assert above has already +# proven a non-root admin user with >=1 key exists (or aborted the play), so hardening +# can never run keyless. Re-adding a gate here would risk drifting out of sync with it. - name: Apply DevSec SSH hardening ansible.builtin.include_role: name: devsec.hardening.ssh_hardening - when: ssh_admin_user | length > 0 From 818959f43c1c678a55308888019ffc3341c6eb58 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 6 Jun 2026 23:33:31 +0300 Subject: [PATCH 2/2] fix(baseline): accurate key-source label + tilde autodetect paths Address PR review: - Copilot: the debug task labeled the primary key source "autodetected" whenever ssh_admin_pubkey was empty, even when autodetect found nothing and all keys came from ssh_admin_extra_pubkeys. Now distinguishes explicit / autodetected / none (extras only) by checking the resolved detected key. - gemini: use ~/.ssh tilde paths for the autodetect candidates instead of an env('HOME') lookup. expanduser falls back to the passwd DB when $HOME is unset (cron/CI), so it's more robust; verified first_found expands ~. Declined two suggestions: a /dev/null fallback for lookup('file') (the existing `when: found | length > 0` guard already prevents an empty-path lookup) and initializing ssh_admin_detected_pubkey in defaults (it's an internal computed fact already handled by `| default('')`). Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/baseline/tasks/main.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index ff14032..2af4d8e 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -25,10 +25,12 @@ found: >- {{ query('ansible.builtin.first_found', {'files': candidates, 'skip': true}) | first | default('') }} + # ~ expands via os.path.expanduser, which falls back to the passwd DB when + # $HOME is unset (cron/CI) — more robust than an env lookup that returns ''. candidates: - - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ed25519.pub" - - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ecdsa.pub" - - "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_rsa.pub" + - "~/.ssh/id_ed25519.pub" + - "~/.ssh/id_ecdsa.pub" + - "~/.ssh/id_rsa.pub" when: - ssh_admin_pubkey | length == 0 - ssh_admin_pubkey_autodetect | bool @@ -70,10 +72,12 @@ ansible.builtin.debug: msg: >- Admin user '{{ ssh_admin_user_effective }}' will be authorized with - {{ ssh_admin_keys | length }} key(s) - ({{ 'explicit ssh_admin_pubkey' if ssh_admin_pubkey | length > 0 - else 'autodetected ~/.ssh key' }} - + {{ ssh_admin_extra_pubkeys | length }} extra). + {{ ssh_admin_keys | length }} key(s); primary source = + {{ 'explicit ssh_admin_pubkey' if ssh_admin_pubkey | length > 0 + else ('autodetected ~/.ssh key' + if (ssh_admin_detected_pubkey | default('')) | length > 0 + else 'none (extra keys only)') }}, + plus {{ ssh_admin_extra_pubkeys | length }} extra. - name: Create admin sudo user ansible.builtin.user: