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
8 changes: 8 additions & 0 deletions ansible/inventory/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ ssh_admin_pubkey_autodetect: true # read ~/.ssh (id_ed25519 > ecdsa > rsa) when
# - "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:
# 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
Expand Down
33 changes: 33 additions & 0 deletions ansible/molecule/default/converge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,38 @@
decdn_slash_judge_address: "0x3333333333333333333333333333333333333333"
decdn_cache_origin_kind: "http"
decdn_cache_origin_url: "https://origin.example.invalid/"
# Exercise baseline's named-sudo-users feature (see pre_tasks below). Throwaway
# 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.
baseline_sudo_users:
- name: molecule-operator
keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA/WM19rWc9jhe0qncc4EuoLzkpR28E+oq77lV2HNvaV molecule@test"
- name: alice.smith
keys:
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAiMn5YPa5+zTGH+dtmHTWBxw5H2/Z587gzTM44n0T9A alice-key1@test"
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID0mUUEyMOboViIle8vOOa3aGVXiH5NC2HnxLp+A5Gj+ alice-key2@test"
# baseline as a whole is real-host-only (firewall + DevSec hardening — see
# molecule.yml), but its named-sudo-users block is container-safe, so run just
# that task file here to give the feature real converge/idempotence/verify coverage.
pre_tasks:
# Pre-create alice.smith with a DISABLED-password sentinel ('*', not a real
# credential) so the role's `password_lock` has an unlocked field to convert
# (usermod -L prepends '!' -> '!*'). Without this the account would already be
# '!' from useradd and the lock assertion in verify.yml would pass vacuously.
# update_password: on_create keeps the field stable across the idempotence re-run.
- name: Seed alice.smith with a disabled password so the lock is verifiable
ansible.builtin.user:
name: alice.smith
password: "*"
update_password: on_create
shell: /bin/bash
create_home: true
- name: Exercise baseline's named-sudo-users block (container-safe subset)
ansible.builtin.include_role:
name: baseline
tasks_from: sudo_users
roles:
- role: decdn_node
78 changes: 78 additions & 0 deletions ansible/molecule/default/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,81 @@
'decdn-node.service' in ansible_facts.services
and ansible_facts.services['decdn-node.service'].state == 'running'
fail_msg: "decdn-node is not running — check: journalctl -u decdn-node -e"

# --- baseline_sudo_users: the two named sudo users created by converge ---------
# molecule-operator (simple name, one key) and alice.smith (a '.' in the name, so
# the sudoers.d filename-sanitize must map it to /etc/sudoers.d/alice_smith; two
# keys, and seeded with a '*' password so the lock is verifiable — see below).
- name: Read the whole passwd, shadow, and group databases
ansible.builtin.getent:
database: "{{ item }}"
loop:
- passwd
- shadow
- group

- name: Assert both named users exist, are in the sudo group, and are password-locked
ansible.builtin.assert:
that:
# Each clause self-guards (`is defined and …`) so a missing key routes to
# the fail_msg instead of raising AnsibleUndefinedVariable on the deref.
- getent_passwd['molecule-operator'] is defined
- getent_passwd['alice.smith'] is defined
# Exact membership (split on ',') — not a substring match against the raw
# comma-joined member field.
- >-
getent_group['sudo'] is defined
and 'molecule-operator' in getent_group['sudo'][2].split(',')
- >-
getent_group['sudo'] is defined
and 'alice.smith' in getent_group['sudo'][2].split(',')
# password_lock runs `usermod -L`, prepending '!'. molecule-operator was
# never given a password, so its field is a bare '!'. alice.smith was seeded
# with '*', so a correctly-run lock yields exactly '!*' — a bare '!' there
# would mean the seed or the lock silently didn't happen (a vacuous pass).
- >-
getent_shadow['molecule-operator'] is defined
and getent_shadow['molecule-operator'][0].startswith('!')
- >-
getent_shadow['alice.smith'] is defined
and getent_shadow['alice.smith'][0] == '!*'
fail_msg: "a named sudo user is missing, not in sudo, or not password-locked"

- name: Stat both named-user sudoers.d drop-ins (alice.smith at its SANITIZED path)
ansible.builtin.stat:
path: "{{ item }}"
register: sudoers_stats
loop:
- /etc/sudoers.d/molecule-operator
- /etc/sudoers.d/alice_smith

- name: Read both drop-ins and both authorized_keys files
ansible.builtin.slurp:
src: "{{ item }}"
register: sudo_user_files
loop:
- /etc/sudoers.d/molecule-operator
- /etc/sudoers.d/alice_smith
- /home/molecule-operator/.ssh/authorized_keys
- /home/alice.smith/.ssh/authorized_keys

- name: Assert both drop-ins are 0440 NOPASSWD and every key was installed
ansible.builtin.assert:
that:
- sudoers_stats.results[0].stat.exists and sudoers_stats.results[0].stat.mode == '0440'
- sudoers_stats.results[1].stat.exists and sudoers_stats.results[1].stat.mode == '0440'
- "'molecule-operator ALL=(ALL) NOPASSWD:ALL' in op_dropin"
# Sanitized filename, but the rule inside still names the real user.
- "'alice.smith ALL=(ALL) NOPASSWD:ALL' in alice_dropin"
- op_key in op_keys
- alice_key1 in alice_keys
- alice_key2 in alice_keys
fail_msg: "a sudoers drop-in has the wrong mode/content, or an authorized key is missing"
vars:
op_dropin: "{{ sudo_user_files.results[0].content | b64decode }}"
alice_dropin: "{{ sudo_user_files.results[1].content | b64decode }}"
op_keys: "{{ sudo_user_files.results[2].content | b64decode }}"
alice_keys: "{{ sudo_user_files.results[3].content | b64decode }}"
op_key: "AAAAC3NzaC1lZDI1NTE5AAAAIA/WM19rWc9jhe0qncc4EuoLzkpR28E+oq77lV2HNvaV"
alice_key1: "AAAAC3NzaC1lZDI1NTE5AAAAIAiMn5YPa5+zTGH+dtmHTWBxw5H2/Z587gzTM44n0T9A"
alice_key2: "AAAAC3NzaC1lZDI1NTE5AAAAID0mUUEyMOboViIle8vOOa3aGVXiH5NC2HnxLp+A5Gj+"
8 changes: 7 additions & 1 deletion ansible/roles/baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ In order — the ordering matters:
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`.
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 @@ -54,7 +59,8 @@ expect — set both explicitly in that case.
| `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. other operators. |
| `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. |
| `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. |
Expand Down
6 changes: 6 additions & 0 deletions ansible/roles/baseline/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ 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: []
# Additional named sudo users — DISTINCT accounts, not extra keys on the shared admin
# (that is ssh_admin_extra_pubkeys above). Each entry gets its OWN username, home, and
# key(s), created key-only exactly like the admin: member of `sudo`, a NOPASSWD
# sudoers.d drop-in, and a locked password (login by key only). Pubkeys only — no
# passwords. Shape: [{name: <login>, keys: ["<full pubkey string>", ...]}, ...].
baseline_sudo_users: []
# 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
Expand Down
8 changes: 8 additions & 0 deletions ansible/roles/baseline/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@
# fail_key:false stores a missing user as {user: None}, so test the value, not `in`.
when: not ansible_check_mode or (getent_passwd | default({})).get(ssh_admin_user_effective)

# --- Additional named sudo users (own account+home+key, key-only NOPASSWD) -----
# Separate from the admin above: these are DISTINCT teammate accounts, not extra
# keys on the shared admin account. Kept in their own file so molecule can exercise
# this container-safe block on its own (the rest of baseline is real-host-only).
- name: Create the additional named sudo users
ansible.builtin.include_tasks: sudo_users.yml
when: baseline_sudo_users | length > 0

# --- Firewall: default-deny inbound, SSH only ---------------------------------
- name: Install nftables ruleset
ansible.builtin.template:
Expand Down
127 changes: 127 additions & 0 deletions ansible/roles/baseline/tasks/sudo_users.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
# Additional named sudo users — each its OWN account (own username, home, key),
# created key-only EXACTLY like the admin: member of `sudo`, a NOPASSWD sudoers.d
# drop-in, and a locked password (login by key only). Distinct from
# ssh_admin_extra_pubkeys, which only adds keys to the SHARED admin account. Runs
# after the admin block and before the firewall / DevSec hardening, so a listed
# operator can log in immediately once hardening applies.
#
# These accounts are NOT lockout-critical — the play always connects as the admin
# user — so, unlike the admin path, this block needs no getent-probe or lockout
# assert: a plain `when: not ansible_check_mode` guard on the key install is enough
# to keep `make check` on a fresh host (where the account doesn't exist yet, so
# authorized_key would hard-fail) from blowing up.

# Refuse two operator-input shapes that would otherwise misconfigure an account
# SILENTLY (the role's ethos: fail loud, never a silent sudo lockout) — checked here,
# before any account is touched:
# * An entry with no `keys` (or keys: []): the tasks below would still create the
# account, join it to `sudo`, write its NOPASSWD drop-in, and lock its password —
# a dormant account with passwordless root and no way to log in (ssh_hardening
# disables password auth). A `key:`/`keys:` typo hits exactly this.
# * Two names that sanitize to the SAME sudoers.d filename (e.g. `deploy.bot` and
# `deploy_bot`, or a name colliding with the admin's own drop-in — this block runs
# AFTER the admin block): the copy loop would silently overwrite, leaving a user
# with `sudo` + a locked password but NO NOPASSWD grant. `visudo -cf` validates
# content, not a clobbered file, so it cannot catch this.
- name: Assert each named sudo user is well-formed and has a unique sudoers.d filename
ansible.builtin.assert:
that:
- unnamed_entries | length == 0
# `keys` as a scalar string (a common `- "ssh-..."` mistake) is truthy, so it
# slips past the keyless check below, but subelements() then mis-iterates it AFTER
# accounts are already changed. Reject it up front to keep this a pre-flight gate.
- stringy_keys_entries | length == 0
- keyless_named_users | length == 0
- sudoers_dropin_names | length == (sudoers_dropin_names | unique | length)
fail_msg: >-
baseline_sudo_users is misconfigured (validated before any account is created).
Entries with a missing/empty `name`: {{ unnamed_entries | length }}.
Entries whose `keys` is a string, not a list: {{ stringy_keys_entries }}.
Entries lacking a non-empty `keys` list: {{ keyless_named_users }}.
Sanitized /etc/sudoers.d/ filenames must be unique (including vs the admin
drop-in), else one grant silently overwrites another -> silent sudo lockout;
got {{ sudoers_dropin_names }}.
vars:
# `keys` must NOT be read with map(attribute='keys'): it collides with the dict
# .keys() METHOD, so an entry that omits `keys` (a `key:` typo) yields the bound
# method (truthy) and silently escapes the keyless check. `map('list')` yields each
# entry's real key NAMES instead, so `superset(['keys'])` tells us which entries
# actually carry a `keys` key; attribute access is then safe only on those.
names: "{{ baseline_sudo_users | map(attribute='name', default='') | list }}"
key_names_per_entry: "{{ baseline_sudo_users | map('list') | list }}"
with_keys: >-
{{ baseline_sudo_users | zip(key_names_per_entry)
| selectattr('1', 'superset', ['keys']) | map(attribute='0') | list }}
# `default=''` on the name deref so a missing `name` reports cleanly here rather
# than raising an undefined-attribute error while building fail_msg.
unnamed_entries: >-
{{ (baseline_sudo_users | rejectattr('name', 'defined') | list)
+ (baseline_sudo_users | selectattr('name', 'defined')
| rejectattr('name', 'truthy') | list) }}
stringy_keys_entries: >-
{{ with_keys | selectattr('keys', 'string')
| map(attribute='name', default='') | list }}
# Keyless = entries with no `keys` key at all (absent), plus entries whose `keys`
# is present but empty/null (falsy).
keyless_named_users: >-
{{ (names | zip(key_names_per_entry)
| rejectattr('1', 'superset', ['keys']) | map(attribute='0') | list)
+ (with_keys | selectattr('keys', 'falsy')
| map(attribute='name', default='') | list) }}
# ssh_admin_user_effective is resolved by the admin block in main.yml; on the
# molecule `tasks_from: sudo_users` path that block never runs, so default it out.
sudoers_dropin_names: >-
{{ (names | map('regex_replace', '[^A-Za-z0-9_-]', '_') | list)
+ ([ssh_admin_user_effective | regex_replace('[^A-Za-z0-9_-]', '_')]
if ssh_admin_user_effective is defined else []) }}

- name: Create the additional named sudo users
ansible.builtin.user:
name: "{{ item.name }}"
groups: [sudo]
append: true
shell: /bin/bash
create_home: true
loop: "{{ baseline_sudo_users }}"
loop_control:
label: "{{ item.name }}"

# Same sudoers.d filename hazard as the admin drop-in: sudo's #includedir SKIPS any
# file whose name contains a '.' or ends in '~' — which, with the password locked
# below, is a silent sudo lockout. Sanitize the username into the filename (the rule
# inside still names the real user); `visudo -cf` validates content, not the name.
- name: Grant each named sudo user passwordless sudo
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ item.name | regex_replace('[^A-Za-z0-9_-]', '_') }}"
owner: root
group: root
mode: "0440"
content: "{{ item.name }} ALL=(ALL) NOPASSWD:ALL\n"
validate: "visudo -cf %s"
loop: "{{ baseline_sudo_users }}"
loop_control:
label: "{{ item.name }}"

- name: Lock each named sudo user's password (key-only login; NOPASSWD sudo)
ansible.builtin.user:
name: "{{ item.name }}"
password_lock: true
loop: "{{ baseline_sudo_users }}"
loop_control:
label: "{{ item.name }}"
Comment thread
thiras marked this conversation as resolved.

# subelements flattens each (user, key) pair. The assert above already rejects a
# keyless entry, so skip_missing=true here is just defence-in-depth (it would emit
# no pairs rather than error). authorized_key writes into the account's ~/.ssh, so
# the user must exist first — under --check the create above is a no-op, so skip the
# install in check mode (see the not-lockout-critical note in the block header above).
- name: Install each named sudo user's authorized keys
ansible.posix.authorized_key:
user: "{{ item.0.name }}"
key: "{{ item.1 }}"
state: present
loop: "{{ baseline_sudo_users | subelements('keys', skip_missing=true) }}"
loop_control:
label: "{{ item.0.name }}"
when: not ansible_check_mode
Comment thread
thiras marked this conversation as resolved.
Loading