From 94a55d137461fec8d077634c563011c0c57fdffb Mon Sep 17 00:00:00 2001 From: Julien D <150942337+jdaln@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:41:17 +0200 Subject: [PATCH 1/2] docs: improve bootstrap doc --- README.md | 160 +++++++++++++++++++++++++++++++++++++-------- docs/inventory.md | 16 ++++- setup-playbook.yml | 11 ++++ 3 files changed, 159 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index b966f98..af4ac1e 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,43 @@ This repository provides a collection of Ansible playbooks and roles designed to ## Prerequisites -### Debian-based Linux +You run these playbooks from a **control machine** (your laptop) against remote +servers over SSH. The only hard requirements are `git`, Python 3, and Ansible +(installed in the next section). VirtualBox + Vagrant are **only** needed if you +want to run the local VM test suite — skip them if you're provisioning real hosts. + +### To provision servers (required) + +#### Debian-based Linux ```bash sudo apt update -sudo apt install virtualbox vagrant python3-pip +sudo apt install git python3 python3-venv ``` -### macOS +#### macOS ```bash -brew install python -python3 -m ensurepip --upgrade -# Vagrant (HashiCorp tap) -brew install hashicorp/tap/hashicorp-vagrant +brew install git python +``` + +> Note: On modern macOS (Homebrew Python) and Debian 12+/13, Python is +> "externally managed" (PEP 668), so a global `pip install` is blocked. The +> Installation section below uses a virtual environment, which is the supported +> path on every platform. Ansible itself is installed there — not via the OS +> package manager. + +### To run local VM tests (optional) + +Only needed for the [Testing](#testing) section. +#### Debian-based Linux +```bash +sudo apt install virtualbox vagrant +``` + +#### macOS +```bash brew install --cask virtualbox +brew install hashicorp/tap/hashicorp-vagrant ``` ## Installation @@ -45,16 +68,24 @@ brew install --cask virtualbox cd linux-server-management ``` -2. **Install Python dependencies**: +2. **Create a virtual environment and install Python dependencies** + (installs Ansible, ansible-lint, passlib, jmespath): ```bash + python3 -m venv .venv + source .venv/bin/activate # run this in every new shell before using ansible pip install -r requirements.txt ``` -3. **Install Ansible dependencies**: +3. **Install Ansible dependencies** (collections; the playbook also pulls the + hardening role automatically at runtime): ```bash ansible-galaxy install -r requirements.yml ``` +> The `.venv` must be active (`source .venv/bin/activate`) whenever you run +> `ansible`, `ansible-playbook`, or `ansible-galaxy`. On macOS use `pip` / +> `python3` from inside the venv (a bare `pip` may not exist system-wide). + ## Available Playbooks ### setup-playbook.yml @@ -87,7 +118,7 @@ Manages user accounts with the following features: - `AUDITD_ACTION_MAIL_ACCT`: Email address for audit system alerts - `MANAGE_UFW`: Enable/disable UFW firewall management (true/false) - `UFW_OUTGOING_TRAFFIC`: List of allowed outbound traffic rules -- `REBOOT_UBUNTU`: Allow automatic reboots for updates (true/false) +- `AUTO_UPDATES_OPTIONS`: Automatic-updates policy; its `reboot` key (bool) is required by pre-flight - `SSHD_ADMIN_NET`: List of networks allowed for SSH admin access **Example Configuration**: @@ -105,11 +136,20 @@ UFW_OUTGOING_TRAFFIC: - "80/tcp" # HTTP - "443/tcp" # HTTPS - "53" # DNS -REBOOT_UBUNTU: false +AUTO_UPDATES_OPTIONS: + enabled: true + only_security: true + reboot: false # required by pre-flight (must be a boolean) + reboot_from_time: "02:00" + reboot_time_margin_mins: 20 SSHD_ADMIN_NET: - "192.168.1.0/24" - "10.0.0.0/8" +# Disable client-side DNSSEC if your network breaks resolution after hardening +# (see Troubleshooting). Validation still happens at the upstream resolvers. +# DNSSEC: false + # Optional security overrides (defaults shown) # SSHD_MAX_AUTH_TRIES: 3 # CIS compliant (default) # SSHD_LOGIN_GRACE_TIME: 60 # Extended for password complexity @@ -119,29 +159,68 @@ SSHD_ADMIN_NET: ## Quick Start ### 1. Set up your inventory -Create or modify inventory files in the `inventories/` directory based on your environment. +Create your own inventory under `inventories//` — an `inventory` file +plus `host_vars/` (and optionally `group_vars/`). + +> **Inventories are gitignored by design** (see the top of `.gitignore`): they +> contain environment-specific IPs, usernames and secrets, so the repository +> ships **no** inventories — a fresh clone has none. Maintain yours locally or in +> a private repo. Use `docs/inventory.md` and the example configuration above +> (the `users_add` variables block) as your template. ### 2. Configure variables -Edit the appropriate files under `inventories//group_vars/` and `inventories//host_vars/` for your environment-specific settings. -See the example configurations in `docs/inventory.md` for detailed guidance on setting up your environment. +Define your host/group variables under `inventories//host_vars/` and +`inventories//group_vars/`. See `docs/inventory.md` for a full walkthrough. + +`setup-playbook.yml` runs a pre-flight check and **fails immediately** unless all +of these are defined for the target host: `SSH_USERLIST`, +`AUDITD_ACTION_MAIL_ACCT`, `MANAGE_UFW`, `UFW_OUTGOING_TRAFFIC`, +`AUTO_UPDATES_OPTIONS.reboot`, `SSHD_ADMIN_NET`, and `ENABLE_2FA` (required even +when `false`). Define every one of them for each host you target. + +### 3. Provision a brand-new server (first run, as `root`) + +A freshly created server usually only has the `root` account — and this playbook +is what *creates* your unprivileged users. So the **first** run must connect as +`root`. The same run also hardens the box and, when `DISABLE_ROOT_ACCOUNT: true`, +**disables root SSH and password authentication at the end** — so this is a +one-time bootstrap. Order matters. -### 3. Run a playbook ```bash -# Set your SSH username -ANSIBLE_USER="your_username" +source .venv/bin/activate # if not already active -# The below assumes that you ran ssh-add so that you won't be prompted countless time for your ssh key password. +# 1. Accept the new host's SSH fingerprint (host key checking is enforced). +# A brand-new host has never been seen, so connect once manually first: +ssh -o StrictHostKeyChecking=accept-new root@ true -# Run the setup playbook -ansible-playbook -i inventories/your-inventory/inventory \ - -l target_group_or_host \ - -u $ANSIBLE_USER \ +# 2. (Optional) Smoke-test connectivity through Ansible: +ansible -i inventories//inventory -u root -m ping + +# 3. First run — connect as root. No -K needed: root has no sudo password. +ansible-playbook -i inventories//inventory \ + -l \ + -u root \ setup-playbook.yml +``` + +This creates the users in `SSH_USERLIST`, applies CIS hardening, and locks down +SSH. New users are forced to change their password on first login. -# Add -K flag if sudo password is required -ansible-playbook -i inventories/your-inventory/inventory \ - -l target_group_or_host \ - -u $ANSIBLE_USER \ +> If `root` only accepts a password (not your key), copy your key up first with +> `ssh-copy-id root@`, or add `--ask-pass` (requires `sshpass`). + +### 4. Subsequent runs (as your admin user) + +After the bootstrap, `root` login is gone. Connect as one of the admin users you +just created, and add `-K` so Ansible can prompt for the sudo password: + +```bash +ANSIBLE_USER="your_admin_user" + +# ssh-add your key first to avoid repeated passphrase prompts. +ansible-playbook -i inventories//inventory \ + -l \ + -u "$ANSIBLE_USER" \ -K \ setup-playbook.yml ``` @@ -206,6 +285,12 @@ The Docker rootless integration test is intentionally end-to-end: it assumes the To verify: run `docker info | grep -i cgroup` as the rootless Docker user. If you need cgroup enforcement in rootless mode and your system supports it, remove that override and restart the user service (`systemctl --user restart docker`). For example arm64 Ubuntu 26.04. +- **systemd-resolved DNSSEC**: the hardening role defaults to `DNSSEC=allow-downgrade`. + On networks where upstream answers fail validation (`no-signature`, common with + CDN/CNAME-backed mirrors such as `deb.debian.org`), this breaks DNS and `apt` + partway through a run. Override per host/group with `DNSSEC: false` — see the + Troubleshooting entry above. + ### Using Ansible Vault ```bash # Create encrypted variable file @@ -232,6 +317,29 @@ ansible-playbook -i inventory playbook.yml --ask-vault-pass - Verify user has sudo permissions on target system - Check `/etc/sudoers.d/` for user-specific rules +**`root@host: Permission denied (publickey)` after a successful run** +- Expected. With `DISABLE_ROOT_ACCOUNT: true`, the first run disables root SSH. + Reconnect as one of the admin users from `SSH_USERLIST` (see Quick Start step 4). + +**Pre-flight fails: `Missing required variables` / `ENABLE_2FA is defined` evaluated to false** +- The target host is missing one of the mandatory variables. Define all of them + (see Quick Start step 2); `ENABLE_2FA` is required even when set to `false`. + +**`externally-managed-environment` when running `pip install`** +- You're installing into the system Python (PEP 668). Use the virtual environment + from the Installation section: `python3 -m venv .venv && source .venv/bin/activate`. + +**DNS / `apt` breaks midway through hardening (`Temporary failure resolving ...`)** +- The hardening role configures `systemd-resolved` with `DNSSEC=allow-downgrade`, + which on some networks rejects valid answers (`DNSSEC validation failed: + no-signature`) and breaks all name resolution — failing later `apt` tasks. +- Diagnose on the host: `resolvectl query deb.debian.org` (look for a DNSSEC error) + and `resolvectl status | grep -i dnssec`. +- Fix: set `DNSSEC: false` in your host/group vars. + `setup-playbook.yml` exposes `DNSSEC` and `DNS_OVER_TLS` as overridable variables. + Validation is still performed by the upstream resolvers (`1.1.1.2`/`9.9.9.9`) and + the channel is protected by DNS-over-TLS, so this is a safe, common posture. + **Ansible Galaxy Dependencies** Run `ansible-galaxy install -r requirements.yml --force` to update roles diff --git a/docs/inventory.md b/docs/inventory.md index d13439c..142860d 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -33,12 +33,24 @@ inventories/ │ ├── inventory │ ├── group_vars/ │ └── host_vars/ -└── testing/ +├── testing/ +│ ├── inventory +│ ├── group_vars/ +│ └── host_vars/ +└── testmachine/ # Minimal single-host example (gitignored, local) ├── inventory - ├── group_vars/ └── host_vars/ + └── testmachine-node/vars.yml ``` +> **Note:** the contents of `inventories//` are gitignored by design (they +> hold IPs, usernames and secrets), so none of these directories are committed — +> a fresh clone contains only this guide and the inline examples below. Create +> your own environment directory locally or in a private repo. `testmachine/` above +> is shown as the shape of a minimal, single-host example: one `inventory` file +> and a single `host_vars//vars.yml` defining every variable +> `setup-playbook.yml` requires. + ## Inventory Organization ### Basic Inventory Format diff --git a/setup-playbook.yml b/setup-playbook.yml index 1a5aa39..5b957ff 100644 --- a/setup-playbook.yml +++ b/setup-playbook.yml @@ -168,6 +168,17 @@ manage_ufw: "{{ MANAGE_UFW }}" # Enable/disable UFW management disable_wireless: "{{ DISABLE_WIRELESS | default(true) }}" # Disable wireless interfaces + # === DNS RESOLVER (systemd-resolved) === + # Exposed so operators on networks where DNSSEC validation fails + # ("DNSSEC validation failed: no-signature", common with CDN/CNAME + # mirrors like deb.debian.org) can override per inventory. + # The hardening role's arg spec accepts only: the string 'allow-downgrade' + # or a YAML boolean (true/false) -- NOT the string "false". So set e.g. + # DNSSEC: false + # in host_vars/group_vars. Defaults match the konstruktoid.hardening role. + dnssec: "{{ DNSSEC | default('allow-downgrade') }}" # allow-downgrade | true | false + dns_over_tls: "{{ DNS_OVER_TLS | default('opportunistic') }}" # opportunistic | true | false + # === SSH CONFIGURATION === manage_ssh: "{{ MANAGE_SSH | default(true) }}" # Enable SSH hardening sshd_admin_net: "{{ SSHD_ADMIN_NET }}" # Networks allowed for SSH admin access From 911d60cdd1af011440bb4534eb2cc94e3ed3704a Mon Sep 17 00:00:00 2001 From: Julien D <150942337+jdaln@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:47:15 +0200 Subject: [PATCH 2/2] fix: small fix in the install logic of docker rootless --- install-docker-rootless.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/install-docker-rootless.yml b/install-docker-rootless.yml index 9594be3..df23526 100644 --- a/install-docker-rootless.yml +++ b/install-docker-rootless.yml @@ -180,6 +180,10 @@ notify: Restart rootless docker tags: [docker-config] + - name: Apply daemon.json before the smoke test (restart rootless docker now) + ansible.builtin.meta: flush_handlers + tags: [docker-config] + - name: Check Docker version for rootless user ansible.builtin.shell: "export PATH=$HOME/bin:$HOME/.local/bin:$PATH && export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock && docker --version" register: docker_version_check