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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ cd ansible
make deps # vendor pinned Galaxy collections into ./collections
cp inventory/hosts.yml.example inventory/hosts.yml
$EDITOR inventory/hosts.yml # set hosts for decdn_nodes
$EDITOR inventory/group_vars/all.yml # set ssh_admin_pubkey (REQUIRED — prevents lockout)
$EDITOR inventory/group_vars/all.yml # optional: add admins to baseline_sudo_users (runner is auto-detected)
cp inventory/host_vars/decdn-node-1/secret.yml.example inventory/host_vars/decdn-node-1/secret.yml
$EDITOR inventory/host_vars/decdn-node-1/secret.yml # set decdn_rpc_url (per-node config is in main.yml)
make check # dry run (--check --diff)
Expand Down
2 changes: 1 addition & 1 deletion ansible/.ansible-lint
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# skipped project-wide (removing the skip surfaces ~60 violations):
# - decdn_node uses a `decdn_` prefix, not the rule-expected `decdn_node_`
# (decdn_rpc_url, decdn_bind_port, decdn_user, …) — the bulk of the skip.
# - baseline surfaces a few global knobs in group_vars (ssh_admin_*, ssh_allow_cidrs).
# - baseline surfaces a global knob in group_vars (ssh_allow_cidrs).
# Conventional names read better for a public ops repo than role-prefixed ones;
# everything else is held to the 'production' profile.
skip_list:
Expand Down
21 changes: 11 additions & 10 deletions ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ $EDITOR inventory/host_vars/decdn-node-1/secret.yml # set decdn_rpc_url
Your `hosts.yml` is no longer force-ignored — commit it in your fork if you want, or keep
it local.

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 additional operators' 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).
By default baseline **deploys you as yourself**: the runner (your control-machine `$USER` +
its autodetected `~/.ssh` key, `id_ed25519` > `ecdsa` > `rsa`) is prepended as the head of
the one operator list, `baseline_sudo_users`. Add other admins there — list yourself (same
name) to override the auto-detected head with explicit keys. Set
`baseline_sudo_autodetect_runner: false` to skip the runner and provision only the explicit
Comment thread
thiras marked this conversation as resolved.
list (e.g. from CI). baseline **asserts a non-root account with 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 listed one).

---

Expand Down Expand Up @@ -148,8 +149,8 @@ the RPC URL). Highlights:

| Var | Default | Notes |
|-----|---------|-------|
| `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 (additional operators). |
| `baseline_sudo_users` | `[]` | The one operator list (`{name, keys, passwordless?}`); the auto-detected runner head (`$USER` + `~/.ssh` key) is prepended, all created before SSH hardening. |
| `baseline_sudo_autodetect_runner` / `baseline_sudo_passwordless` | `true` / `true` | Prepend the runner as head (`false` = explicit list only); key-only NOPASSWD + locked-password default. |
| `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. |
| `baseline_preserve_ipv6_autoconf` | `true` | Keep IPv6 RA/autoconf under hardening; set `false` for static-IPv6 hosts. |
| `baseline_rp_filter_loose` | `false` | `true` loosens reverse-path filtering (`rp_filter=2`) for multi-homed nodes. |
Expand Down
6 changes: 4 additions & 2 deletions ansible/galaxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ hardening), then the node:
roles:
- role: decdn.node.baseline
vars:
ssh_admin_user: deploy
ssh_admin_pubkey: "ssh-ed25519 AAAA... you@host" # REQUIRED — lockout guard
baseline_sudo_users: # REQUIRED — lockout guard
- name: deploy
keys: ["ssh-ed25519 AAAA... you@host"]
baseline_sudo_autodetect_runner: false # provision only the explicit admin above
baseline_extra_inbound:
- { proto: udp, port: 4433, comment: "deCDN QUIC" }
- role: decdn.node.decdn_node
Expand Down
40 changes: 17 additions & 23 deletions ansible/inventory/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,27 @@
# 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.

# --- 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: []

# Additional NAMED sudo users — DISTINCT accounts (own username, home, key), not extra
# keys on the shared admin above. Each is created key-only like the admin (sudo group,
# NOPASSWD drop-in, locked password). Uncomment and add real users to enable:
# --- Admin SSH access: ONE operator list (deploy as yourself by default) -----
# Admin/operator accounts + their keys are installed BEFORE ssh_hardening disables root
# + password login. There is no separate admin knob: the runner (this control box's
# $USER + its ~/.ssh key) is auto-detected and prepended as the head, so the person
# running the playbook is provisioned without being committed here. Add other admins
# below; list yourself (same name) to OVERRIDE the auto-detected head with explicit
# key(s). The baseline role aborts before hardening unless the resolved set has a
# NON-ROOT account with a key, so you can't lock yourself out.
# NOTE: under `sudo ansible-playbook`, cron, or CI the $USER/$HOME — and thus the
# autodetected key — may not be yours; set baseline_sudo_autodetect_runner: false and
# list admins explicitly here.
# baseline_sudo_users:
# - name: alice
# keys:
# - "ssh-ed25519 AAAA... alice@laptop"

# Key-only admin account: NOPASSWD sudo + locked password (default true). Set false to
# keep classic password sudo — you must then set a password on the account yourself.
ssh_admin_passwordless_sudo: true
# - name: bot # optional per-entry passwordless override
# keys: ["ssh-ed25519 AAAA... bot@ci"]
# passwordless: false
baseline_sudo_users: []
# baseline_sudo_autodetect_runner: true # false = provision ONLY the explicit list above
# baseline_sudo_passwordless: true # default sudo mode: NOPASSWD sudo + locked password

# Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source.
ssh_allow_cidrs: []
Expand Down
9 changes: 5 additions & 4 deletions ansible/inventory/hosts.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@
# it local. (Just never push real host IPs to the upstream template.)
#
# First run: connect as a user that can sudo (often root) to bootstrap the box.
# After the baseline role creates {{ ssh_admin_user }} and ssh_hardening disables
# root login, switch ansible_user to that admin account.
# After the baseline role creates your admin account (the auto-detected runner, or an
# entry in baseline_sudo_users) and ssh_hardening disables root login, switch
# ansible_user to that admin account.
#
# Ubuntu sudo-rs workaround: 25.10 (Questing) and 26.04 ship sudo-rs as the default
# /usr/bin/sudo. Ansible's sudo become plugin passes a custom `-p` prompt sentinel and
# waits for it before sending the become password; sudo-rs does not honor that custom
# `-p`, so `--ask-become-pass` / -K hangs with "Timeout waiting for privilege
# escalation prompt". This only bites when a become PASSWORD is actually sent: with the
# default `ssh_admin_passwordless_sudo: true` the admin user has NOPASSWD sudo, so
# default `baseline_sudo_passwordless: true` the admin user has NOPASSWD sudo, so
# Ansible sends no become password and the hang never occurs. It resurfaces only if you
# set `ssh_admin_passwordless_sudo: false` (classic password sudo). Fix: classic sudo is
# set `baseline_sudo_passwordless: false` (classic password sudo). Fix: classic sudo is
# still installed at /usr/bin/sudo.ws and honors the -p prompt Ansible expects — point
# become at it with the per-host `ansible_become_exe` line below. Only uncomment on an
# affected host: `sudo --version` reports "sudo-rs" and/or /usr/bin/sudo.ws exists. Leave
Expand Down
2 changes: 1 addition & 1 deletion ansible/molecule/default/converge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
# keys, generated for this test only — public keys, not credentials. Two users
# on purpose: `alice.smith` has a '.' (so the sudoers.d filename-sanitize maps it
# to /etc/sudoers.d/alice_smith — the one branch whose failure is silent) and two
# keys (exercises the subelements fan-out), while molecule-operator stays single.
# keys (exercises the multi-key authorized_key loop), while molecule-operator stays single.
baseline_sudo_users:
- name: molecule-operator
keys:
Expand Down
67 changes: 33 additions & 34 deletions ansible/roles/baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,22 @@ 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 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 operator keys
come from `ssh_admin_extra_pubkeys`. Explicit values always win. By default the
account is **key-only**: it gets a NOPASSWD sudoers drop-in and its password is
locked (`ssh_admin_passwordless_sudo`), so sudo / `make deploy` needs no become
password and no password can authenticate. Set the knob `false` for classic
password sudo (you must then set a password on the account yourself).
Additional **named** operator accounts come from `baseline_sudo_users` — each a
distinct login (own username, home, and key), created key-only exactly like the
admin (member of `sudo`, NOPASSWD drop-in, locked password). Use this to give
teammates their own accounts rather than sharing keys on the admin via
`ssh_admin_extra_pubkeys`.
2. **Sudo operators** — one list, `baseline_sudo_users`, installed **before** SSH is
hardened so you keep a way in. There is no separate admin knob: the **runner** (this
control box's `$USER` + its `~/.ssh` key, autodetected `id_ed25519` > `id_ecdsa` >
`id_rsa`) is prepended as the lockout-critical **head**, so the person running
`make deploy` is provisioned without being committed anywhere. Set
`baseline_sudo_autodetect_runner: false` to skip that and provision only the explicit
list (e.g. CI). Add other admins to `baseline_sudo_users` — each a distinct login (own
username, home, key(s)); listing yourself there (same name) **overrides** the
auto-detected head with explicit keys. Every operator is created **key-only**: member
of `sudo`, a NOPASSWD sudoers drop-in, and a locked password, so sudo / `make deploy`
needs no become password and no password can authenticate. `baseline_sudo_passwordless`
(default `true`) is the role-wide default; set it (or a per-entry `passwordless: false`)
`false` for classic password sudo (you must then set a password on the account
yourself). The head and every listed operator are rendered by a single primitive
(`tasks/sudo_account.yml`), so the sudoers.d sanitize / password-lock / check-mode
logic lives in exactly one place.
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 @@ -42,33 +44,30 @@ In order — the ordering matters:
## Lockout guard

`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:
assert** before any account creation or hardening that aborts the play unless the
resolved operator set (auto-detected runner head + `baseline_sudo_users`) contains at
least one **non-root** account **with a key**. By default the head comes 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
the whole set has no viable admin, e.g.:

- **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.
- **No runner head resolves** (no `~/.ssh/id_ed25519|ecdsa|rsa.pub`, `$USER` unset/`root`
under cron/CI/`sudo`, or `baseline_sudo_autodetect_runner: false`) **and**
`baseline_sudo_users` is empty — nothing to log in as.
- The only entries resolve to **`root`** (hardening forbids root login) or are
**keyless** — add a non-root, keyed entry to `baseline_sudo_users`.

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.
Because the head 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 `baseline_sudo_autodetect_runner: false` and list admins explicitly in that case.

## Key variables

| Var | Default | Notes |
|-----|---------|-------|
| `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) on the **shared** admin account — e.g. other operators. |
| `baseline_sudo_users` | `[]` | **Distinct** named sudo accounts, created key-only like the admin. Each item `{name, keys: [...]}` (pubkeys only). |
| `ssh_admin_passwordless_sudo` | `true` | Give the admin user NOPASSWD sudo and lock its password (key-only). Set `false` for classic password sudo. |
| `baseline_sudo_users` | `[]` | The one operator list. Each item `{name, keys: [...], passwordless?}` (pubkeys only), created key-only. The auto-detected runner head is prepended; a same-name entry here overrides it. |
| `baseline_sudo_autodetect_runner` | `true` | Auto-detect the runner (`$USER` + `~/.ssh` `id_ed25519`/`ecdsa`/`rsa`) and prepend it as the head. `false` = provision only the explicit list (e.g. CI). |
| `baseline_sudo_passwordless` | `true` | Role-wide default sudo mode: NOPASSWD drop-in + locked password (key-only). Per-entry `passwordless: false` overrides; `false` = classic password sudo. |
| `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_preserve_ipv6_autoconf` | `true` | Re-enable IPv6 RA/autoconf under `os_hardening` (which disables it, breaking SLAAC addresses). Set `false` for statically-addressed IPv6 hosts to keep full CIS IPv6 hardening. |
Expand Down
Loading
Loading