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
4 changes: 4 additions & 0 deletions ansible/inventory/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ ssh_admin_pubkey_autodetect: true # read ~/.ssh (id_ed25519 > ecdsa > rsa) when
# - "ssh-ed25519 AAAA... bob@laptop"
ssh_admin_extra_pubkeys: []

# 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

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

Expand Down
14 changes: 8 additions & 6 deletions ansible/inventory/hosts.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
# /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". 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. Password sudo is preserved (no NOPASSWD, no host
# change). Only uncomment on an affected host: `sudo --version` reports "sudo-rs"
# and/or /usr/bin/sudo.ws exists. Leave it commented on Debian bookworm / Ubuntu
# noble — that path does not exist there.
# 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
# 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
# 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
# it commented on Debian bookworm / Ubuntu noble — that path does not exist there.

# PRIMARY: public deCDN node(s) — provisioned by playbooks/site.yml.
# Per-node config (contract addresses, region, version, …) goes in the committed
Expand Down
7 changes: 6 additions & 1 deletion ansible/roles/baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ In order — the ordering matters:
**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.
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).
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 Down Expand Up @@ -51,6 +55,7 @@ expect — set both explicitly in that case.
| `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. other operators. |
| `ssh_admin_passwordless_sudo` | `true` | Give the admin user NOPASSWD sudo and lock its password (key-only). Set `false` for 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_packages` | see `defaults/main.yml` | Base package set. |
Expand Down
7 changes: 7 additions & 0 deletions ansible/roles/baseline/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ ssh_admin_pubkey: ""
ssh_admin_pubkey_autodetect: true
# Additional authorized keys for the admin user (full pubkey strings, e.g. teammates).
ssh_admin_extra_pubkeys: []
# Passwordless, key-only admin account. ssh_hardening disables SSH password auth, so
# the admin user logs in by key only; with this on (default) the account gets a
# NOPASSWD sudoers drop-in AND its password is locked — non-interactive sudo / `make
# deploy` works with no become password, and no password can authenticate at all.
# Set false to keep the classic password-sudo model (you must then set a password on
# the account yourself, e.g. `passwd <user>`, to be able to sudo).
ssh_admin_passwordless_sudo: true
# Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source.
ssh_allow_cidrs: []

Expand Down
68 changes: 68 additions & 0 deletions ansible/roles/baseline/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,74 @@
shell: /bin/bash
create_home: true

# --- Passwordless, key-only admin (ssh_hardening disables SSH password auth) -----
# The account logs in by key only, so it needs no password at all: grant NOPASSWD
# sudo (so non-interactive sudo / `make deploy` works with no become password) and
# lock the password (so nothing can authenticate by password). Both are gated on the
# same knob; turning it off removes the grant and falls back to classic sudo-group
# password sudo — which needs a real password, so we must NOT lock it in that case
# (and the operator must set one — see the disabled-path warning below).

# sudo's #includedir SKIPS any file in sudoers.d whose name contains a '.' or ends
# in '~'. A username like `deploy.bot` would produce a valid-but-ignored drop-in, so
# NOPASSWD would silently not apply — and with the password locked below that is a
# sudo lockout. `visudo -cf` validates content, not the filename, so it can't catch
# this. Sanitize the name to sudo's accepted charset; the rule inside still names the
# real user, so the grant is unaffected.
- name: Resolve the admin sudoers.d drop-in path (sudo ignores '.'/'~' in filenames)
ansible.builtin.set_fact:
ssh_admin_sudoers_file: >-
/etc/sudoers.d/{{ ssh_admin_user_effective | regex_replace('[^A-Za-z0-9_-]', '_') }}

- name: Grant the admin user passwordless sudo
ansible.builtin.copy:
dest: "{{ ssh_admin_sudoers_file }}"
owner: root
group: root
mode: "0440"
content: "{{ ssh_admin_user_effective }} ALL=(ALL) NOPASSWD:ALL\n"
validate: "visudo -cf %s"
when: ssh_admin_passwordless_sudo | bool

- name: Lock the admin user's password (key-only login; NOPASSWD sudo)
ansible.builtin.user:
name: "{{ ssh_admin_user_effective }}"
password_lock: true
when: ssh_admin_passwordless_sudo | bool
Comment thread
thiras marked this conversation as resolved.

# Reverse the NOPASSWD grant if the knob is later turned off. The password lock is
# intentionally NOT auto-reverted: unlocking a never-set password is unsafe/undefined
# — older shadow-utils blank it (passwordless account), newer ones (Debian bookworm /
# current Ubuntu) refuse. Regaining password sudo is a manual `passwd <user>` step.
# The `length > 0` guard is defence-in-depth: the lockout-guard assert above already
# aborts on an empty user, but if it were ever bypassed the path would collapse to the
# bare `/etc/sudoers.d/` directory and state:absent would recursively delete it,
# breaking sudo host-wide. Never run this destructive task on an empty username.
- name: Remove the passwordless-sudo drop-in when disabled
ansible.builtin.file:
path: "{{ ssh_admin_sudoers_file }}"
state: absent
when:
- not (ssh_admin_passwordless_sudo | bool)
- ssh_admin_user_effective | length > 0

# Loud fallback warning: with the knob off there is no NOPASSWD grant, and a fresh
# account (or one locked by a prior passwordless run) has no usable password — so it
# cannot sudo after ssh_hardening unless the operator sets one on the host. Flipping
# this true->false on an ALREADY-hardened host while connected as that admin user also
# breaks the current run once the drop-in is removed (later tasks can no longer
# `sudo -n`) — set a password first and re-run with `--ask-become-pass`.
- name: Warn that classic password sudo needs a password set on the host
ansible.builtin.debug:
msg: >-
ssh_admin_passwordless_sudo is false: admin user '{{ ssh_admin_user_effective }}'
uses classic sudo-group password sudo. If its password is unset or locked (fresh
account, or a prior passwordless run), run `passwd {{ ssh_admin_user_effective }}`
on the host or it will be unable to sudo once SSH hardening disables root login.
Switching from passwordless to password sudo on a live hardened host needs that
password set first, then a re-run with `--ask-become-pass`.
when: not (ssh_admin_passwordless_sudo | bool)

# authorized_key resolves the user's ~/.ssh to write the key, so the account must
# already exist. Under --check the create task above is a no-op, so on a not-yet-
# provisioned host the user is absent and the module hard-fails ("Either user must
Expand Down
Loading