Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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<version>` release must exist. |
| `decdn_rpc_url` + 3 contract addresses | `""` | **required** per node (host_vars); sourced from an ADR/deployment. |
Expand Down
20 changes: 15 additions & 5 deletions ansible/inventory/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down
5 changes: 2 additions & 3 deletions ansible/molecule/default/molecule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 27 additions & 8 deletions ansible/roles/baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. |
Expand Down
14 changes: 12 additions & 2 deletions ansible/roles/baseline/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
thiras marked this conversation as resolved.
# 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: []

Expand Down
98 changes: 77 additions & 21 deletions ansible/roles/baseline/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,35 +10,89 @@
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('') }}
# ~ 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:
- "~/.ssh/id_ed25519.pub"
- "~/.ssh/id_ecdsa.pub"
- "~/.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); 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:
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
Expand Down Expand Up @@ -99,7 +153,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
Loading