From 8e9e04c3d06f0f17a8288539531b58219ef2a780 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 22:48:05 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(baseline):=20key-only=20admin=20?= =?UTF-8?q?=E2=80=94=20passwordless=20sudo=20+=20locked=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the operator's admin account (ssh_admin_user, defaults to the local $USER; not the decdn service user) fully key-only, behind a new ssh_admin_passwordless_sudo knob (default true): - write a /etc/sudoers.d/ NOPASSWD drop-in (0440 root:root, visudo-validated), and lock the account password (password_lock) - remove the drop-in and warn the operator to set a password when the knob is disabled This closes a latent gap: the account was in the sudo group (password required) but created with no password, so `make deploy` as that user could not escalate after ssh_hardening disables root + password login. The sudoers.d filename is sanitized because sudo's #includedir silently ignores names containing '.' or ending in '~' (e.g. deploy.bot), which combined with the password lock would be a silent sudo lockout. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/inventory/group_vars/all.yml | 4 ++ ansible/inventory/hosts.yml.example | 14 +++--- ansible/roles/baseline/README.md | 7 ++- ansible/roles/baseline/defaults/main.yml | 7 +++ ansible/roles/baseline/tasks/main.yml | 57 ++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml index 46ab8f0..629449e 100644 --- a/ansible/inventory/group_vars/all.yml +++ b/ansible/inventory/group_vars/all.yml @@ -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: [] diff --git a/ansible/inventory/hosts.yml.example b/ansible/inventory/hosts.yml.example index 7586042..25e21b9 100644 --- a/ansible/inventory/hosts.yml.example +++ b/ansible/inventory/hosts.yml.example @@ -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 diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index ab938c6..e8661ec 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -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. @@ -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. | diff --git a/ansible/roles/baseline/defaults/main.yml b/ansible/roles/baseline/defaults/main.yml index 6e3e8b7..944096a 100644 --- a/ansible/roles/baseline/defaults/main.yml +++ b/ansible/roles/baseline/defaults/main.yml @@ -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 `, to be able to sudo). +ssh_admin_passwordless_sudo: true # 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 a1b6386..72632c7 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -95,6 +95,63 @@ 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 + +# 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 ` step. +- 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) + +# 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. +- 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. + 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 From dd932aa0e371ee3cf9579fe2bcfb14bba7c35c4c Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 22:55:22 +0300 Subject: [PATCH 2/2] fix(baseline): guard destructive sudoers removal + clarify downgrade warning Address PR review: - Add `ssh_admin_user_effective | length > 0` guard to the drop-in removal task. The lockout-guard assert already blocks an empty user, but defence-in-depth: an empty user would collapse the path to the bare /etc/sudoers.d/ directory and state:absent would recursively delete it, breaking sudo host-wide. - Extend the disabled-path warning to note that flipping true->false on an already-hardened host (connected as the admin user) breaks the current run once the drop-in is removed; set a password first and re-run with --ask-become-pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/baseline/tasks/main.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index 72632c7..f7d9aa2 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -134,15 +134,24 @@ # 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 ` 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) + 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. +# 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: >- @@ -150,6 +159,8 @@ 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