Skip to content

Commit ca77cdc

Browse files
thirasclaude
andcommitted
feat(baseline): deploy as yourself — auto-resolve admin user/key, add extra keys
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) <noreply@anthropic.com>
1 parent 6f1b7de commit ca77cdc

6 files changed

Lines changed: 140 additions & 44 deletions

File tree

‎ansible/README.md‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,17 @@ cd ansible
4343
make deps # vendor pinned collections into ./collections
4444
cp inventory/hosts.yml.example inventory/hosts.yml
4545
$EDITOR inventory/hosts.yml # set hosts for decdn_nodes and/or anvil_devnet
46-
$EDITOR inventory/group_vars/all.yml # set ssh_admin_pubkey (REQUIRED)
46+
$EDITOR inventory/group_vars/all.yml # optional: override admin user/keys, allowlists
4747
```
4848

49-
`ssh_admin_pubkey` is **mandatory** — baseline refuses to run `ssh_hardening` (which
50-
disables root + password login) without it, so you can't lock yourself out. After the first
51-
deploy, switch each host's `ansible_user` to your `ssh_admin_user` (default `deploy`).
49+
By default baseline **deploys you as yourself**: an empty `ssh_admin_user` resolves to your
50+
control-machine `$USER`, and an empty `ssh_admin_pubkey` is autodetected from `~/.ssh`
51+
(`id_ed25519` > `ecdsa` > `rsa`). Add teammates' keys via `ssh_admin_extra_pubkeys`. Set
52+
`ssh_admin_user`/`ssh_admin_pubkey` explicitly to override (e.g. a shared `deploy` account,
53+
or when deploying from CI). baseline **asserts a key resolves** before `ssh_hardening`
54+
disables root + password login, so you can't lock yourself out. After the first deploy,
55+
switch each host's `ansible_user` to that admin account (your local username unless you set
56+
one).
5257

5358
---
5459

@@ -122,7 +127,8 @@ Defaults live in each role (`roles/*/defaults/main.yml`); override in `group_var
122127

123128
| Var | Default | Notes |
124129
|-----|---------|-------|
125-
| `ssh_admin_user` / `ssh_admin_pubkey` | `deploy` / `""` | **pubkey required**; admin created before SSH hardening. |
130+
| `ssh_admin_user` / `ssh_admin_pubkey` | `""` / `""` | Empty = local `$USER` + autodetected `~/.ssh` key; admin created before SSH hardening. |
131+
| `ssh_admin_extra_pubkeys` | `[]` | Extra authorized keys for the admin user (teammates). |
126132
| `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. |
127133
| `decdn_node_version` | `""` | **required**; a `v<version>` release must exist. |
128134
| `decdn_rpc_url` + 3 contract addresses | `""` | **required** per node (host_vars); sourced from an ADR/deployment. |

‎ansible/inventory/group_vars/all.yml‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,21 @@
55
# the roles are self-contained. Set anything here to override per host/group.
66
# Secrets are generated ON THE HOST by the roles and never live here.
77

8-
# --- REQUIRED ---------------------------------------------------------------
9-
# Public key for the admin sudo user, installed BEFORE ssh_hardening disables
10-
# root + password login (the baseline role asserts this to prevent lockout).
11-
ssh_admin_user: deploy
12-
ssh_admin_pubkey: "" # e.g. "ssh-ed25519 AAAA... you@laptop"
8+
# --- Admin SSH access (deploy as yourself by default) -----------------------
9+
# The admin sudo user + its key are installed BEFORE ssh_hardening disables root
10+
# + password login. Left empty they resolve from the control machine of whoever
11+
# runs ansible-playbook (NOTE: under `sudo ansible-playbook`, cron, or CI the
12+
# $USER/$HOME — and thus the autodetected key — may not be yours; set both
13+
# explicitly there). Explicit values always win. The baseline role aborts before
14+
# hardening unless a NON-ROOT user + a key resolve, so you can't lock yourself out.
15+
ssh_admin_user: "" # "" -> your local $USER (e.g. "deploy" to share one account; never "root")
16+
ssh_admin_pubkey: "" # "" -> autodetected ~/.ssh key; or "ssh-ed25519 AAAA... you@laptop"
17+
ssh_admin_pubkey_autodetect: true # read ~/.ssh (id_ed25519 > ecdsa > rsa) when pubkey is empty
18+
# Additional authorized keys for the admin user (e.g. teammates):
19+
# ssh_admin_extra_pubkeys:
20+
# - "ssh-ed25519 AAAA... alice@laptop"
21+
# - "ssh-ed25519 AAAA... bob@laptop"
22+
ssh_admin_extra_pubkeys: []
1323

1424
# Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source.
1525
ssh_allow_cidrs: []

‎ansible/molecule/default/molecule.yml‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@ provisioner:
2626
inventory:
2727
group_vars:
2828
all:
29-
# Skip SSH hardening / admin-key gating inside the container.
30-
ssh_admin_user: ""
31-
ssh_admin_pubkey: ""
29+
# converge.yml runs only anvil + caddy — baseline (and its admin-user /
30+
# ssh_hardening logic) is never exercised here, so no ssh_admin_* is needed.
3231
foundry_version: latest
3332
verifier:
3433
name: ansible

‎ansible/roles/baseline/README.md‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ In order — the ordering matters:
1010

1111
1. **Base packages** — `curl`, `git`, `jq`, `openssl`, `nftables`, `fail2ban`,
1212
`unattended-upgrades`, `chrony`, … (override `baseline_packages`).
13-
2. **Admin sudo user** — creates `ssh_admin_user` and installs `ssh_admin_pubkey`
14-
**before** SSH is hardened, so you keep a way in.
13+
2. **Admin sudo user** — creates `ssh_admin_user` and installs its key(s)
14+
**before** SSH is hardened, so you keep a way in. Both resolve from the control
15+
machine when left empty: the user falls back to the local `$USER`, and the key is
16+
autodetected from `~/.ssh` (`id_ed25519` > `id_ecdsa` > `id_rsa`). Extra team keys
17+
come from `ssh_admin_extra_pubkeys`. Explicit values always win.
1518
3. **Firewall** — nftables **default-deny inbound**; SSH is the only universally-open
1619
port. Extra public listeners are declared explicitly via `baseline_extra_inbound`.
1720
4. **Auto-patching** — `unattended-upgrades` for security updates.
@@ -23,17 +26,33 @@ In order — the ordering matters:
2326

2427
## Lockout guard
2528

26-
`ssh_hardening` disables root and password auth. The role **asserts** that
27-
`ssh_admin_user` and `ssh_admin_pubkey` are set before it runs — set both for any
28-
real deploy, or you will lock yourself out. (Set `ssh_admin_user: ""` to skip the
29-
admin account + SSH hardening entirely, as the Molecule container does.)
29+
`ssh_hardening` disables root and password auth. The role runs an **unconditional
30+
assert** before any account creation or hardening that aborts the play unless a
31+
**non-root** admin user *and* at least one key resolve. By default these come from
32+
the control machine (local `$USER` + `~/.ssh` key), so a stock interactive run "just
33+
works". The assert fires — by design, so you can fix it rather than lock yourself
34+
out — when any of these hold:
35+
36+
- **No key resolves**: no `~/.ssh/id_ed25519|ecdsa|rsa.pub` and no explicit
37+
`ssh_admin_pubkey`/`ssh_admin_extra_pubkeys` (the most common real-world trigger).
38+
- **The user is unresolvable**: `ssh_admin_user` empty *and* `$USER` unset (cron, CI,
39+
or `sudo` with `env_reset`).
40+
- **The user resolves to `root`**: hardening forbids root login, so this would be a
41+
guaranteed lockout — set `ssh_admin_user` to a non-root account.
42+
43+
Because resolution reads the **control node's** `$USER`/`$HOME` of whoever invokes
44+
`ansible-playbook`, a `sudo`/CI run can autodetect a different user/key than you
45+
expect — set both explicitly in that case. (Molecule never runs `baseline`, so the
46+
assert never fires there.)
3047

3148
## Key variables
3249

3350
| Var | Default | Notes |
3451
|-----|---------|-------|
35-
| `ssh_admin_user` | `deploy` | Admin sudo account; created before SSH hardening. `""` skips it. |
36-
| `ssh_admin_pubkey` | `""` | **Required** for a real deploy — the lockout guard asserts it. |
52+
| `ssh_admin_user` | `""` | Admin sudo account; created before SSH hardening. Empty = the control machine's local `$USER`. |
53+
| `ssh_admin_pubkey` | `""` | Admin key. Empty = autodetected from `~/.ssh` (`id_ed25519`/`ecdsa`/`rsa`). Set to override. |
54+
| `ssh_admin_pubkey_autodetect` | `true` | When `ssh_admin_pubkey` is empty, read the operator's default local public key. |
55+
| `ssh_admin_extra_pubkeys` | `[]` | Additional authorized keys (full pubkey strings) — e.g. teammates. |
3756
| `ssh_allow_cidrs` | `[]` | Optional inbound-SSH source allowlist (CIDRs). Empty = any source. |
3857
| `baseline_extra_inbound` | `[]` | Extra public inbound ports. Each item `{proto, port, comment}`. Loopback services need nothing here; the deCDN node opens udp/4433. |
3958
| `baseline_packages` | see `defaults/main.yml` | Base package set. |

‎ansible/roles/baseline/defaults/main.yml‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,19 @@
22
# baseline role defaults. Override in inventory/group_vars for a real deployment.
33

44
# Admin sudo account, created + keyed BEFORE ssh_hardening disables root login.
5-
# ssh_admin_pubkey MUST be set for a real deploy (lockout guard asserts it).
6-
ssh_admin_user: deploy
5+
# "Deploy as yourself" by default — both knobs resolve from the control machine
6+
# when left empty, and explicit values always win:
7+
# ssh_admin_user "" -> the control-machine local $USER (of whoever runs
8+
# ansible-playbook; under sudo/CI this may not be you)
9+
# ssh_admin_pubkey "" -> autodetected from that same ~/.ssh (id_ed25519 > ecdsa > rsa)
10+
# The lockout-guard assert in tasks/main.yml aborts the run (before any hardening)
11+
# unless a NON-ROOT admin user and >=1 key resolve — so hardening never runs keyless.
12+
ssh_admin_user: ""
713
ssh_admin_pubkey: ""
14+
# When ssh_admin_pubkey is empty, read the operator's default local public key.
15+
ssh_admin_pubkey_autodetect: true
16+
# Additional authorized keys for the admin user (full pubkey strings, e.g. teammates).
17+
ssh_admin_extra_pubkeys: []
818
# Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source.
919
ssh_allow_cidrs: []
1020

‎ansible/roles/baseline/tasks/main.yml‎

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,85 @@
1010
cache_valid_time: 3600
1111

1212
# --- Admin account (must exist + have a key BEFORE ssh_hardening locks root) ---
13+
# Resolve the user and key set from the control machine when left empty; explicit
14+
# values always win. Lookups run on the control node — that's the operator's box.
15+
- name: Resolve the admin sudo username (local $USER if unset)
16+
ansible.builtin.set_fact:
17+
ssh_admin_user_effective: >-
18+
{{ ssh_admin_user if ssh_admin_user | length > 0
19+
else lookup('ansible.builtin.env', 'USER') }}
20+
21+
- name: Autodetect the operator's local SSH public key (control machine)
22+
ansible.builtin.set_fact:
23+
ssh_admin_detected_pubkey: "{{ lookup('ansible.builtin.file', found) | trim }}"
24+
vars:
25+
found: >-
26+
{{ query('ansible.builtin.first_found',
27+
{'files': candidates, 'skip': true}) | first | default('') }}
28+
candidates:
29+
- "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ed25519.pub"
30+
- "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_ecdsa.pub"
31+
- "{{ lookup('ansible.builtin.env', 'HOME') }}/.ssh/id_rsa.pub"
32+
when:
33+
- ssh_admin_pubkey | length == 0
34+
- ssh_admin_pubkey_autodetect | bool
35+
- found | length > 0
36+
37+
- name: Resolve the set of admin authorized keys
38+
ansible.builtin.set_fact:
39+
ssh_admin_keys: >-
40+
{{ (([ssh_admin_pubkey] if ssh_admin_pubkey | length > 0
41+
else [ssh_admin_detected_pubkey | default('')])
42+
+ ssh_admin_extra_pubkeys)
43+
| map('trim') | reject('equalto', '') | unique | list }}
44+
45+
# UNCONDITIONAL lockout guard — runs BEFORE the account is created and hardening
46+
# applied. It is the single gate that makes a keyless/root-only harden impossible;
47+
# downstream tasks therefore need no `when`. ssh_hardening disables root login, so a
48+
# resolved admin user of "root" (or an empty one, e.g. $USER unset under cron/CI)
49+
# would lock the host out — both are refused here rather than skipped silently.
50+
- name: Refuse to harden SSH without a non-root admin user and a key (lockout guard)
51+
ansible.builtin.assert:
52+
that:
53+
- ssh_admin_user_effective | length > 0
54+
- ssh_admin_user_effective != 'root'
55+
- ssh_admin_keys | length > 0
56+
fail_msg: >-
57+
Cannot establish a working admin login before devsec.hardening.ssh_hardening
58+
disables root + password auth. Resolved user='{{ ssh_admin_user_effective }}',
59+
keys={{ ssh_admin_keys | length }}. Causes: ssh_admin_user is empty and the
60+
control-machine $USER is unset (cron/CI/sudo with env_reset) -> set
61+
ssh_admin_user; the resolved user is 'root' (hardening forbids root login) ->
62+
set ssh_admin_user to a non-root account; or no key was found -> set
63+
ssh_admin_pubkey, add ssh_admin_extra_pubkeys, or place a default key
64+
(~/.ssh/id_ed25519.pub, id_ecdsa.pub, or id_rsa.pub) on the control machine.
65+
66+
# Surface WHAT will be trusted: autodetect installs whatever key sits in the
67+
# control box's ~/.ssh, so echo the resolved user + key count (a silently dropped
68+
# malformed entry, or a wrong control machine, shows up as an unexpected count).
69+
- name: Report the resolved admin user and key count
70+
ansible.builtin.debug:
71+
msg: >-
72+
Admin user '{{ ssh_admin_user_effective }}' will be authorized with
73+
{{ ssh_admin_keys | length }} key(s)
74+
({{ 'explicit ssh_admin_pubkey' if ssh_admin_pubkey | length > 0
75+
else 'autodetected ~/.ssh key' }}
76+
+ {{ ssh_admin_extra_pubkeys | length }} extra).
77+
1378
- name: Create admin sudo user
1479
ansible.builtin.user:
15-
name: "{{ ssh_admin_user }}"
80+
name: "{{ ssh_admin_user_effective }}"
1681
groups: [sudo]
1782
append: true
1883
shell: /bin/bash
1984
create_home: true
20-
when: ssh_admin_user | length > 0
2185

22-
- name: Install admin authorized key
86+
- name: Install admin authorized keys
2387
ansible.posix.authorized_key:
24-
user: "{{ ssh_admin_user }}"
25-
key: "{{ ssh_admin_pubkey }}"
88+
user: "{{ ssh_admin_user_effective }}"
89+
key: "{{ item }}"
2690
state: present
27-
when:
28-
- ssh_admin_user | length > 0
29-
- ssh_admin_pubkey | length > 0
30-
31-
- name: Refuse to harden SSH without a working admin key (lockout guard)
32-
ansible.builtin.assert:
33-
that:
34-
- ssh_admin_user | length > 0
35-
- ssh_admin_pubkey | length > 0
36-
fail_msg: >-
37-
ssh_admin_user and ssh_admin_pubkey must be set so the admin account can log
38-
in BEFORE devsec.hardening.ssh_hardening disables root and password auth.
39-
Set them in inventory/group_vars/all.yml. (Molecule sets both to "" to skip
40-
SSH hardening inside the throwaway container.)
41-
when: ssh_admin_user | length > 0
91+
loop: "{{ ssh_admin_keys }}"
4292

4393
# --- Firewall: default-deny inbound, SSH only ---------------------------------
4494
- name: Install nftables ruleset
@@ -99,7 +149,9 @@
99149
ansible.builtin.include_role:
100150
name: devsec.hardening.os_hardening
101151

152+
# No `when` guard here by design: the unconditional lockout assert above has already
153+
# proven a non-root admin user with >=1 key exists (or aborted the play), so hardening
154+
# can never run keyless. Re-adding a gate here would risk drifting out of sync with it.
102155
- name: Apply DevSec SSH hardening
103156
ansible.builtin.include_role:
104157
name: devsec.hardening.ssh_hardening
105-
when: ssh_admin_user | length > 0

0 commit comments

Comments
 (0)