Compare commits

..

34 Commits

Author SHA1 Message Date
41ccf2a5b9 fix(configuration): allow guest-exec RPCs for EL golden cloning 2026-05-29 22:43:14 +02:00
8f9cfe3b2f fix(cleanup): force-stop then start the proxmox VM after install 2026-05-29 22:40:02 +02:00
6a75237197 fix(environment): pick the largest cdrom as the RHEL install DVD 2026-05-28 17:52:44 +02:00
b04aad12fb fix(satellite): drop auto-attach (mutually exclusive with activation key) 2026-05-26 01:08:09 +02:00
4fff9f8d80 fix(virtualization): create vmware target folder before vm deploy 2026-06-04 18:17:50 +02:00
7f12a0f3d8 feat: accept proxmox API-token auth alongside password 2026-05-31 12:40:31 +02:00
ceb2237bbb fix(encryption): add tpm2-tss dracut module explicitly for TPM2 LUKS 2026-05-31 12:39:24 +02:00
477c8379c4 fix(configuration): enable per-family time-sync and skip sudo-rs lecture 2026-05-31 12:30:26 +02:00
579c499c02 feat(configuration): multi-backend networking, bind by match not MAC 2026-05-31 12:25:53 +02:00
89e366d0f0 fix: EL10 PAM and crypto readiness via authselect profile and DEFAULT policy 2026-05-28 17:30:57 +02:00
6fe843355e fix(bootstrap): keep package cache off the 2 GiB CIS /var during install 2026-05-28 17:26:25 +02:00
441876fab9 refactor(global_defaults): single source of truth for family-default resolution 2026-05-28 17:25:23 +02:00
00acd4d200 refactor(configuration): consolidate firewall into one phase-aware path 2026-05-27 05:28:00 +02:00
d922efd2e4 feat: uniform system.content source schema across installers and repositories 2026-05-27 05:15:32 +02:00
939c5c741f feat: golden-image build support (cloud-init on EL, selinux relabel, SSH wait) 2026-05-27 05:05:55 +02:00
2c35409519 feat(cis): add selectable profile and per-rule hardening toggles 2026-05-25 04:37:33 +02:00
d2a19cfd5c feat(hardware): auto-detect audio, bluetooth, camera with declarative override 2026-05-25 04:36:21 +02:00
44f5adc682 feat(bootstrap): per-os desktop apps, KDE plasma-login-manager and DM resolution 2026-05-25 04:30:53 +02:00
0185797af9 fix(environment): co-upgrade soname closure when installing installer tools 2026-05-25 03:54:12 +02:00
e0ecf628cd fix(bootstrap): deploy all non-EOL core distros (keyrings, repos, versions) 2026-05-25 03:52:44 +02:00
37df881daa docs: refresh bootstrap examples and README 2026-05-30 18:05:14 +02:00
55b21eae5d fix: encryption, partitioning, cis and virtualization hardening 2026-05-30 18:05:14 +02:00
b1e938b7f0 fix(users): accept plaintext or pre-hashed passwords uniformly 2026-05-30 18:05:05 +02:00
c843f5289b feat: hardware/firmware/gpu/peripherals detection and packages 2026-05-30 18:05:05 +02:00
9757ed3785 feat: complete wayland desktop deployment (gnome/kde/sway/hyprland) 2026-05-30 18:05:05 +02:00
876e90ce2b refactor: trim bootstrap OS support to core three distro families 2026-05-30 18:04:00 +02:00
7c44cb1ff0 docs(bootstrap): fix users dict format in examples, sync schema defaults, document secure_boot/rhel_repo 2026-05-30 09:25:34 +02:00
5d0630a386 refactor(global_defaults): drop orphan luks.urandom/verify and aur feature, bump fedora to 45 2026-05-30 09:25:34 +02:00
3eaf918a53 fix(lint): convert sshd restart to handler, add pipefail to btrfs subvol set 2026-05-30 09:25:34 +02:00
382e82ff85 fix(configuration): tolerate missing units, gate Secure Boot to supported OSes, fix clevis install per family 2026-05-30 09:25:34 +02:00
db7dc53bd7 docs(bootstrap): document firmware/gpu/peripherals/hardware features 2026-05-30 09:25:34 +02:00
7d45f25a7e feat(bootstrap): install vendor-matched hardware packages 2026-05-30 09:25:34 +02:00
3880b8f41e feat(environment): detect cpu/gpu/wireless/fingerprint hardware 2026-05-30 09:25:34 +02:00
dc3c4a901f feat(global_defaults): firmware/gpu/peripherals/hardware schema 2026-05-30 09:25:34 +02:00
123 changed files with 3459 additions and 975 deletions

277
README.md
View File

@@ -13,7 +13,7 @@ Non-Arch targets require the appropriate package manager available from the ISO
- 4.1 [Core Variables](#41-core-variables)
- 4.2 [`system` Dictionary](#42-system-dictionary)
- 4.3 [`hypervisor` Dictionary](#43-hypervisor-dictionary)
- 4.4 [`cis` Dictionary](#44-cis-dictionary)
- 4.4 [CIS Hardening](#44-cis-hardening)
- 4.5 [VMware Guest Operations](#45-vmware-guest-operations)
- 4.6 [Multi-Disk Schema](#46-multi-disk-schema)
- 4.7 [Advanced Partitioning Overrides](#47-advanced-partitioning-overrides)
@@ -29,17 +29,14 @@ Non-Arch targets require the appropriate package manager available from the ISO
| `system.os` | Distribution | `system.version` |
| ------------ | ------------------------ | ------------------------------------- |
| `almalinux` | AlmaLinux | `8`, `9`, `10` |
| `alpine` | Alpine Linux | latest (rolling) |
| `almalinux` | AlmaLinux | `9`, `10` |
| `archlinux` | Arch Linux | latest (rolling) |
| `debian` | Debian | `10`-`13`, `unstable` |
| `fedora` | Fedora | `38`-`45` |
| `opensuse` | openSUSE Tumbleweed | latest (rolling) |
| `rhel` | Red Hat Enterprise Linux | `8`, `9`, `10` |
| `rocky` | Rocky Linux | `8`, `9`, `10` |
| `ubuntu` | Ubuntu (latest non-LTS) | optional (e.g. `24.04`) |
| `ubuntu-lts` | Ubuntu LTS | optional (e.g. `24.04`) |
| `void` | Void Linux | latest (rolling) |
| `debian` | Debian | `12`, `13`, `unstable` |
| `fedora` | Fedora | `43`, `44` |
| `rhel` | Red Hat Enterprise Linux | `9`, `10` |
| `rocky` | Rocky Linux | `9`, `10` |
| `ubuntu` | Ubuntu (latest non-LTS) | optional (tracks 25.10 `questing`) |
| `ubuntu-lts` | Ubuntu LTS | optional (tracks 26.04 `resolute`) |
### Hypervisors
@@ -62,12 +59,10 @@ Non-Arch targets require the appropriate package manager available from the ISO
Two dict-based variables drive the entire configuration:
- **`system`** -- host, network, users, disk layout, encryption, and feature toggles
- **`system`** -- host, network, users, disk layout, encryption, and feature toggles (including CIS hardening under `system.features.cis`)
- **`hypervisor`** -- virtualization backend credentials and targeting
An optional third dict **`cis`** overrides CIS hardening parameters when `system.features.cis.enabled: true`.
All three are standard Ansible variables. Place them in `group_vars/`, `host_vars/`, or inline inventory. With `hash_behaviour = merge`, dictionaries merge across scopes, so shared values go in group vars and host-specific overrides go per-host.
Both are standard Ansible variables. Place them in `group_vars/`, `host_vars/`, or inline inventory. With `hash_behaviour = merge`, dictionaries merge across scopes, so shared values go in group vars and host-specific overrides go per-host.
### Variable Placement
@@ -122,7 +117,7 @@ all:
path: /data
fstype: xfs
users:
- name: ops
ops:
password: !vault |
$ANSIBLE_VAULT...
keys:
@@ -151,7 +146,7 @@ all:
### 4.1 Core Variables
Top-level variables outside `system`/`hypervisor`/`cis`.
Top-level variables outside `system`/`hypervisor`.
| Variable | Type | Default | Description |
| ---------------- | ------ | -------------------------- | ---------------------------------------------------- |
@@ -167,7 +162,7 @@ Top-level variables outside `system`/`hypervisor`/`cis`.
| `type` | string | `virtual` | `virtual` or `physical` |
| `os` | string | -- | Target distribution (see [table](#distributions)) |
| `version` | string | -- | Version selector for versioned distros |
| `filesystem` | string | -- | `btrfs`, `ext4`, or `xfs` |
| `filesystem` | string | `ext4` | `btrfs`, `ext4`, or `xfs` |
| `name` | string | inventory hostname | Final hostname |
| `timezone` | string | `Europe/Vienna` | System timezone (tz database name) |
| `locale` | string | `en_US.UTF-8` | System locale |
@@ -176,15 +171,35 @@ Top-level variables outside `system`/`hypervisor`/`cis`.
| `cpus` | int | `0` | vCPU count (required for virtual) |
| `memory` | int | `0` | Memory in MiB (required for virtual) |
| `balloon` | int | `0` | Balloon memory in MiB (Proxmox) |
| `path` | string | -- | Hypervisor folder/path |
| `path` | string | -- | Hypervisor folder/path (falls back to `hypervisor.folder`) |
| `content` | dict | see below | Package content source (mirror/DVD/Satellite, family-resolved) |
| `packages` | list | `[]` | Additional packages installed post-reboot |
| `network` | dict | see below | Network configuration |
| `disks` | list | `[]` | Disk layout (see [Multi-Disk Schema](#46-multi-disk-schema)) |
| `users` | list | `[]` | User accounts |
| `users` | dict | `{}` | User accounts (keyed by username) |
| `root` | dict | see below | Root account settings |
| `luks` | dict | see below | Encryption settings |
| `features` | dict | see below | Feature toggles |
#### `system.content`
Uniform package content source, family-resolved. `source: ''` defaults to `dvd` on EL and `mirror` on Debian/Ubuntu/Arch. Satellite values come from inventory/vault only, never committed code.
| Key | Type | Default | Description |
| -------------------------- | ------ | -------------- | ----------------------------------------------------------------- |
| `source` | string | family default | `dvd`, `mirror`, `satellite`, or `none` |
| `url` | string | family default | Mirror URL / EL `.repo` baseurl |
| `proxy` | string | -- | `http://host:port` content proxy (dnf/apt/pacman) |
| `gpgcheck` | bool | `true` | Repository GPG checking |
| `satellite.host` | string | -- | EL Katello/Satellite hostname |
| `satellite.ip` | string | -- | Optional `/etc/hosts` entry when DNS does not resolve the host |
| `satellite.org` | string | -- | Organization label |
| `satellite.activation_key` | string | -- | Activation key |
| `satellite.ca_url` | string | derived | Katello CA RPM URL (default `https://<host>/pub/katello-ca-consumer-latest.noarch.rpm`) |
| `satellite.service_level` | string | -- | syspurpose service level |
| `satellite.environment` | string | -- | Lifecycle environment |
| `satellite.install` | bool | `false` | `false`: base from DVD/mirror then register; `true`: install from Satellite |
#### `system.network`
| Key | Type | Default | Description |
@@ -229,8 +244,9 @@ Users must be defined in inventory. The dict format enables additive merging acr
#### `system.root`
| Key | Type | Default | Description |
| ---------- | ------ | ------- | ------------- |
| ---------- | ------ | ----------- | ------------- |
| `password` | string | -- | Root password |
| `shell` | string | `/bin/bash` | Login shell |
#### `system.luks`
@@ -249,8 +265,6 @@ Users must be defined in inventory. The dict format enables additive merging acr
| `iter` | int | `4000` | PBKDF iteration time (ms) |
| `bits` | int | `512` | Key size (bits) |
| `pbkdf` | string | `argon2id` | PBKDF algorithm |
| `urandom` | bool | `true` | Use urandom during key generation |
| `verify` | bool | `true` | Verify passphrase during format |
#### `system.luks.tpm2`
@@ -271,7 +285,10 @@ The bootstrap auto-switches to dracut when `method: tpm2` is set. Override via `
| Key | Type | Default | Description |
| ------------------ | ------ | -------------- | ------------------------------------ |
| `cis.enabled` | bool | `false` | Enable CIS hardening (see [4.4](#44-cis-dictionary)) |
| `cis.enabled` | bool | `false` | Enable CIS hardening (see [4.4](#44-cis-hardening)) |
| `cis.profile` | string | `default` | CIS profile: `default`, `l1`, or `l2` (see [4.4](#44-cis-hardening)) |
| `cis.rules` | dict | `{}` | Per-rule CIS overrides |
| `cis.params` | dict | `{}` | CIS parameter overrides |
| `selinux.enabled` | bool | `true` | SELinux management |
| `firewall.enabled` | bool | `true` | Firewall setup |
| `firewall.backend` | string | `firewalld` | `firewalld` or `ufw` |
@@ -283,9 +300,15 @@ The bootstrap auto-switches to dracut when `method: tpm2` is set. Override via `
| `banner.sudo` | bool | `true` | Sudo banner |
| `chroot.tool` | string | `arch-chroot` | `arch-chroot`, `chroot`, or `systemd-nspawn` |
| `initramfs.generator` | string | auto-detected | Override initramfs generator (see below) |
| `secure_boot.enabled` | bool | `false` | Enable Secure Boot (Arch via sbctl, others via shim) |
| `secure_boot.method` | string | -- | Arch only: `sbctl` (default) or `uki` |
| `desktop.*` | dict | see below | Desktop environment settings (see [4.2.5](#425-systemfeaturesdesktop)) |
| `firmware.*` | dict | see below | Vendor firmware blobs and CPU microcode (see [4.2.6](#426-systemfeaturesfirmware)) |
| `gpu.*` | dict | see below | Mesa/Vulkan and per-vendor GPU userspace (see [4.2.7](#427-systemfeaturesgpu)) |
| `peripherals.*` | dict | see below | Fingerprint, camera, audio, bluetooth, DisplayLink (see [4.2.8](#428-systemfeaturesperipherals)) |
| `hardware.*` | dict | see below | Hardware-detection profile override (see [4.2.9](#429-systemfeatureshardware)) |
**Initramfs generator auto-detection:** RedHat dracut, Arch mkinitcpio, Debian/Ubuntu initramfs-tools.
**Initramfs generator auto-detection:** RedHat -> dracut, Arch -> mkinitcpio, Debian/Ubuntu -> initramfs-tools.
Override with `dracut`, `mkinitcpio`, or `initramfs-tools`. When LUKS TPM2 auto-unlock is enabled and the
native generator does not support `tpm2-device`, the generator is automatically upgraded to dracut.
On distros with older dracut (no `tpm2-tss` module), clevis is used as a fallback for TPM2 binding.
@@ -295,13 +318,147 @@ On distros with older dracut (no `tpm2-tss` module), clevis is used as a fallbac
| Key | Type | Default | Description |
| ----------------- | ------ | -------------- | ----------------------------------------- |
| `enabled` | bool | `false` | Install desktop environment |
| `environment` | string | -- | `gnome`, `kde`, `xfce`, `sway`, `hyprland`, `cinnamon`, `mate`, `lxqt`, `budgie` |
| `display_manager` | string | auto-detected | Override DM: `gdm`, `sddm`, `lightdm`, `ly`, `greetd` |
| `environment` | string | `""` | `gnome`, `kde`, `sway`, or `hyprland` |
| `display_manager` | string | auto-detected | Override DM: `gdm`, `sddm`, `plasma-login-manager`, `greetd`, or `ly` |
| `autologin` | bool \| string | `false` | `false` to disable, or a username from `system.users` to auto-login that user |
| `session` | string | auto-from-environment | Session to autologin into; overrides the per-environment default (sddm `.desktop` basename / greetd command) |
| `groups` | list | `[]` | Opt-in package groups installed on top of the base set (keys of `desktop_package_groups`, e.g. `dev`) |
All desktop environments are Wayland-only. `sway` and `hyprland` are available on Arch only;
`gnome` and `kde` are available on all three families. On enterprise Linux
(almalinux/rocky/rhel) the base desktop installs browser, PDF and image viewers but no
video player - none is packaged in the EL base repositories, and no third-party repo is
pulled in; add one from rpmfusion/flatpak if you need it.
When `enabled: true`, the bootstrap installs the desktop environment packages, enables the display manager
and bluetooth services, and sets the systemd default target to `graphical.target`.
Display manager auto-detection: gnomegdm, kde→sddm, xfce→lightdm, sway→greetd, hyprland→ly.
Display manager auto-detection: gnome to gdm; kde to plasma-login-manager on Arch and
Fedora 44+ (Plasma 6.6), else sddm; sway and hyprland to greetd.
`ly` is an explicit-only override (never auto-selected), available on Arch only,
and is desktop-agnostic - it can front any environment. It runs on `tty2` with
`getty@tty2` masked, and its autologin is written to `/etc/ly/config.ini`; set `session`
to the target session's `.desktop` basename (sway and hyprland resolve automatically).
When `autologin` names a user, the matching display manager is configured to log that user in without a
password prompt. `session` is resolved automatically per environment when left empty (gdm picks its default,
sddm uses `plasma.desktop` for kde, greetd runs the compositor command for sway/hyprland), so it only needs
setting to override that choice.
#### 4.2.6 `system.features.firmware`
| Key | Type | Default | Description |
| ----------- | --------------- | ------- | ----------------------------------------------------------------- |
| `enabled` | bool \| `auto` | `auto` | Install vendor firmware blobs. `auto` = on for `physical`, off for `virtual` |
| `microcode` | bool \| `auto` | `auto` | Install CPU microcode. `auto` follows `firmware.enabled` |
Defaults are designed so a baremetal install picks up firmware automatically with no inventory entry needed,
while VMs skip it (the hypervisor handles those). The environment role detects CPU/GPU/wireless vendors from
the live host (via `lscpu` and `lspci`) and the bootstrap role installs only the matching firmware packages.
On Arch, this uses the vendor splits (`linux-firmware-amdgpu`, `linux-firmware-realtek`, etc.) so the install
stays minimal. On Debian, it uses the equivalent `firmware-*` packages. Distros without firmware splits fall
back to a single meta package.
#### 4.2.7 `system.features.gpu`
| Key | Type | Default | Description |
| --------------- | ------ | ------- | ---------------------------------------------------- |
| `enabled` | bool | `false` | Install Mesa, Vulkan, and per-GPU userspace |
| `nvidia_driver` | string | `auto` | One of `auto`, `open`, `proprietary`, `nouveau` |
Pair with `desktop.enabled: true` for a working desktop. The package set is determined by the same hardware
profile as `firmware`. The `nvidia_driver: auto` default picks **`open`** (`nvidia-open` kernel modules) for
Turing or newer GPUs, falls back to **`proprietary`** for older cards on distros that ship the proprietary
driver, and falls back to **`nouveau`** elsewhere. Force a specific flavor by setting the value explicitly.
Proprietary and open Nvidia drivers on Fedora require RPMFusion non-free, which the bootstrap enables
automatically when needed. Debian uses `nvidia-driver` from the `non-free` component (already enabled in the
managed `sources.list`). Ubuntu uses `restricted`. Arch ships both `nvidia-open-dkms` and `nvidia-dkms` in
the `extra` repository - no third-party setup required.
> **Known limitation - Nvidia on Enterprise Linux (AlmaLinux/Rocky/RHEL):** the EL `akmod-nvidia*`
> packages live in RPMFusion non-free, and the bootstrap only enables RPMFusion automatically on
> **Fedora**, not on EL. So Nvidia on a bare EL desktop is best-effort: enable RPMFusion (or supply the
> driver repo) out of band, or it falls back to `nouveau`. EL desktops are not a primary target.
#### 4.2.8 `system.features.peripherals`
| Key | Type | Default | Description |
| ------------- | --------------- | ------- | ---------------------------------------------------------- |
| `enabled` | bool \| `auto` | `auto` | Master switch. `auto` follows `desktop.enabled` |
| `fingerprint` | bool \| `auto` | `auto` | `fprintd`/`libfprint`. `auto` = install when reader detected |
| `camera` | bool \| `auto` | `auto` | `v4l-utils` for UVC webcams. `auto` = install when a UVC/IPU6 camera is detected (IPU6 out-of-tree stack is logged, not auto-installed) |
| `audio` | bool \| `auto` | `auto` | SOF firmware + ALSA UCM. `auto` = install when an audio device is detected |
| `bluetooth` | bool \| `auto` | `auto` | `bluez`. `auto` = install when a Bluetooth controller is detected |
| `displaylink` | bool | `false` | DisplayLink dock support (explicit opt-in; see notes) |
Fingerprint detection scans `lsusb` for known reader vendor IDs (Synaptics, Validity, Goodix, Elan, Egis,
Broadcom, AuthenTec, Upek, Futronic). When `fingerprint: auto` and a reader is present, `fprintd` and the
PAM helper are installed. PAM enrollment must be done post-install (`fprintd-enroll`).
DisplayLink ships proprietary userspace that distros do not package consistently. The bootstrap installs the
in-tree `evdi-dkms` kernel module on Debian/Ubuntu and the `evdi` module on Fedora, but the userspace blob
must still be installed manually from DisplayLink's site after first boot. Arch users typically use AUR
(`displaylink`); this is not wired into the bootstrap.
#### 4.2.9 `system.features.hardware`
| Key | Type | Default | Description |
| --------- | ---- | ------- | -------------------------------------------------------------------- |
| `profile` | dict | `{}` | Full override: non-empty SKIPS detection (golden image); empty = autodetect |
| group fields | mixed | -- | `cpu`/`gpus`/`wireless`/`audio`/`camera`/`fingerprint`/`bluetooth`/`packages`/`disable`/`kernel_params` MERGE over autodetect (see below) |
When empty, hardware is detected at the start of the bootstrap. When set, detection is skipped and the
supplied profile drives package selection - this is the **golden-image** flow: bake an image with a fixed
profile, snapshot it, and reuse the same profile on every deploy of that hardware class.
Profile shape:
```yaml
system:
features:
hardware:
profile:
cpu: intel # intel | amd
gpus: [intel, nvidia] # any of: intel, amd, nvidia
nvidia_supports_open: true # set false to force proprietary/nouveau
wireless: [intel] # any of: intel, amd, atheros, broadcom,
# mediatek, marvell, realtek, qcom, cirrus
fingerprint: false # set true to force fprintd install
```
The same keys (minus `profile`) can also be set **directly under `hardware`** as a
declarative **hardware group** that MERGES over auto-detection (auto-detect = base; the
group supplements/overrides it). Unlike `profile`, which skips detection entirely, the
group keeps detection running and layers on top - use it to pin everything a known device
needs so nothing is ever under-set.
| Key | Type | Merge semantics |
| ------------------------- | ---- | -------------------------------------------------------- |
| `cpu` | str | pin the CPU vendor (overrides detection when non-empty) |
| `gpus`/`wireless`/`audio` | list | union with the detected vendor codes |
| `camera` | dict | `{uvc, ipu6}` booleans OR'd with detection |
| `fingerprint`/`bluetooth` | bool | OR'd with detection (force-on) |
| `packages` | dict | per-`os_family` extra packages, added to the install set (deduped; empty entries dropped) |
| `disable` | list | feature/vendor names force-off, applied last |
| `kernel_params` | list | extra kernel cmdline params, appended to the bootloader |
Example - a laptop with an Intel IPU6 camera (out-of-tree stack) and a Cirrus amp, pinned
in a group's `group_vars`:
```yaml
system:
features:
hardware:
bluetooth: true # force-on if detection misses the combo card
camera:
ipu6: true # force the IPU6 path
packages: # out-of-tree/AUR bits detection must not auto-install
Archlinux: [intel-ipu6-dkms, v4l2-relayd, linux-firmware-cirrus]
disable: [displaylink] # never pull DisplayLink on this device
kernel_params: ["i915.enable_psr=0"]
```
### 4.3 `hypervisor` Dictionary
@@ -315,47 +472,53 @@ Display manager auto-detection: gnome→gdm, kde→sddm, xfce→lightdm, sway→
| `storage` | string | -- | Storage identifier (Proxmox/VMware) |
| `datacenter` | string | -- | VMware datacenter |
| `cluster` | string | -- | VMware cluster |
| `certs` | bool | `true` | TLS certificate validation (VMware) |
| `certs` | bool | `false` | TLS certificate validation (VMware) |
| `ssh` | bool | `false` | Enable SSH on guest and switch connection (VMware) |
### 4.4 `cis` Dictionary
### 4.4 CIS Hardening
When `system.features.cis.enabled: true`, the CIS role applies hardening. All values have sensible defaults; override specific keys via the `cis` dict.
When `system.features.cis.enabled: true`, the CIS role applies hardening. The behaviour is driven by three keys under `system.features.cis`:
| Key | Type | Default | Description |
| -------------------- | ------ | ------- | ------------------------------------------------ |
| `modules_blacklist` | list | see below | Kernel modules to blacklist via modprobe |
| `sysctl` | dict | see below | Sysctl key/value pairs written to `10-cis.conf` |
| `sshd_options` | list | see below | SSHD options applied via lineinfile |
| `pwquality_minlen` | int | `14` | Minimum password length |
| `tmout` | int | `900` | Shell timeout (seconds) |
| `umask` | string | `077` | Default umask in bashrc |
| `umask_profile` | string | `027` | Default umask in /etc/profile |
| `faillock_deny` | int | `5` | Failed login attempts before lockout |
| `faillock_unlock_time` | int | `900` | Lockout duration (seconds) |
| `password_remember` | int | `5` | Password history depth |
| --------- | ------ | ----------- | ----------------------------------------------------------------- |
| `enabled` | bool | `false` | Apply CIS hardening at all |
| `profile` | string | `default` | `default` (house baseline), `l1` (clean CIS Level 1), or `l2` |
| `rules` | dict | `{}` | Per-rule on/off overrides on top of the profile |
| `params` | dict | `{}` | Parameter overrides (deep-merged; list values replace wholesale) |
**Default modules blacklist:** `freevxfs`, `jffs2`, `hfs`, `hfsplus`, `cramfs`, `udf`, `usb-storage`, `dccp`, `sctp`, `rds`, `tipc`, `firewire-core`, `firewire-sbp2`, `thunderbolt`. `squashfs` is added automatically except on Ubuntu (snap dependency).
**Profiles.** `default` is the established house baseline (CIS Level 1 plus the USB lockdown, full module blacklist, and IPv6-disable extras, minus the usability-hostile controls). `l1` is a clean CIS Level 1: it drops the L2 extras and adds password aging, AIDE, and warning banners. `l2` is `l1` plus auditd and the L2 extras.
**Default sysctl settings** include: `kernel.yama.ptrace_scope=2`, `kernel.kptr_restrict=2`, `kernel.perf_event_paranoid=3`, `kernel.unprivileged_bpf_disabled=1`, IPv4/IPv6 hardening, ARP protection, and IPv6 disabled by default. Override individual keys:
**Per-rule overrides.** Toggle an individual rule without changing profile, e.g. keep the default profile but allow USB and IPv6 on a desktop:
```yaml
cis:
sysctl:
net.ipv6.conf.all.disable_ipv6: 0 # re-enable IPv6
net.ipv4.ip_forward: 1 # enable for routers/containers
system:
features:
cis:
enabled: true
rules:
usb_lockdown: false
ipv6_disable: false
```
**Default SSHD options** enforce: `PermitRootLogin no`, `PasswordAuthentication no`, `X11Forwarding no`, `AllowTcpForwarding no`, `MaxAuthTries 4`, and post-quantum KEX (mlkem768x25519-sha256 on OpenSSH 9.9+). Override per-option:
Rule keys: `module_blacklist`, `usb_lockdown`, `sysctl_hardening`, `ipv6_disable`, `umask_default`, `empty_password_login`, `pwquality`, `core_dumps`, `shell_timeout`, `journald_persistent`, `sudo_logfile`, `su_restriction`, `faillock`, `password_history`, `tcp_wrappers`, `crypto_policy`, `mask_services`, `cron_at_access`, `file_permissions`, `sshd_hardening`, `password_expiry`, `aide`, `warning_banners`, `auditd`, and the opt-in `grub_password` (set `rules.grub_password: true` with `params.grub_password_hash`).
**Parameters.** Override baseline values under `params` (full list in `roles/cis/vars/main.yml`):
```yaml
cis:
sshd_options:
- { option: X11Forwarding, value: "yes" }
- { option: AllowTcpForwarding, value: "yes" }
system:
features:
cis:
enabled: true
profile: l1
params:
pwquality_minlen: 16
sysctl: # dict: deep-merged over the profile's set
net.ipv4.ip_forward: 1
sshd_options: # list: REPLACES the entire default list
- {option: X11Forwarding, value: "yes"}
```
Note: providing `sshd_options` replaces the entire list. Copy the defaults from `roles/cis/defaults/main.yml` and modify as needed.
Common params: `modules_blacklist` (list), `sysctl` (dict), `sshd_options` (list), `pwquality_minlen` (14), `tmout` (900), `umask` (077), `umask_profile` (027), `faillock_deny` (5), `faillock_unlock_time` (900), `password_remember` (5), `pass_max_days` (365), `aide_cron_hour`/`aide_cron_minute`, `banner_text`, `grub_password_hash`.
### 4.5 VMware Guest Operations
@@ -379,7 +542,7 @@ When `hypervisor.type: vmware` uses the `vmware_tools` connection:
| ------------- | ------ | ------------------------------------------------------ |
| `size` | number | Disk size in GB (required for virtual) |
| `device` | string | Block device path (required for physical data disks) |
| `partition` | string | Partition device path (required for physical data disks) |
| `partition` | string | Derived from `device` during normalization (not user input) |
| `mount.path` | string | Mount point (additional disks only) |
| `mount.fstype`| string | `btrfs`, `ext4`, or `xfs` |
| `mount.label` | string | Filesystem label |
@@ -427,9 +590,9 @@ Roles execute in this order:
1. **global_defaults** -- normalize inputs, validate, set OS flags
2. **system_check** -- detect installer environment, verify live/non-prod target
3. **virtualization** -- create VM (if virtual), attach disks, cloud-init
4. **environment** -- prepare installer: mount ISO, configure repos, setup pacman
4. **environment** -- prepare installer: mount ISO, configure repos, setup pacman, detect hardware
5. **partitioning** -- create partitions, LVM, LUKS, mount filesystems
6. **bootstrap** -- install base system and packages (OS-specific)
6. **bootstrap** -- install base system, packages, and vendor-matched hardware bits
7. **configuration** -- users, fstab, locales, bootloader, encryption enrollment, networking
8. **cis** -- CIS hardening (when `system.features.cis.enabled: true`)
9. **cleanup** -- unmount, shutdown installer, remove media, verify boot

View File

@@ -9,8 +9,11 @@ all:
baremetal01.example.com:
ansible_host: 10.0.0.162
ansible_user: root
ansible_password: "1234"
ansible_become_password: "1234"
ansible_password: "CHANGE_ME"
ansible_become_password: "CHANGE_ME"
# Required for physical installs: confirms the operator accepts that
# install_drive will be wiped. system_check refuses to run without it.
physical_install_confirmed: true
system:
type: "physical"
os: "archlinux"
@@ -18,3 +21,10 @@ all:
disks:
- device: "/dev/sda"
size: 120
users:
admin:
password: "CHANGE_ME"
keys:
- "ssh-ed25519 AAAA..."
root:
password: "CHANGE_ME"

View File

@@ -43,7 +43,7 @@ all:
label: DATA
opts: defaults
users:
- name: "ops"
ops:
password: "CHANGE_ME"
keys:
- "ssh-ed25519 AAAA..."
@@ -100,7 +100,7 @@ all:
path: /srv/data
fstype: ext4
users:
- name: "dbadmin"
dbadmin:
password: "CHANGE_ME"
keys:
- "ssh-ed25519 AAAA..."

View File

@@ -6,7 +6,6 @@ all:
url: "localhost"
username: ""
password: ""
host: ""
storage: "default"
boot_iso: "/var/lib/libvirt/images/archlinux-x86_64.iso"
children:
@@ -40,7 +39,7 @@ all:
path: /var/www
fstype: xfs
users:
- name: "web"
web:
password: "CHANGE_ME"
keys:
- "ssh-ed25519 AAAA..."
@@ -82,7 +81,7 @@ all:
path: /data
fstype: ext4
users:
- name: "db"
db:
password: "CHANGE_ME"
keys:
- "ssh-ed25519 AAAA..."
@@ -123,7 +122,7 @@ all:
path: /data
fstype: btrfs
users:
- name: "compute"
compute:
password: "CHANGE_ME"
keys:
- "ssh-ed25519 AAAA..."

View File

@@ -1,14 +1,4 @@
---
# Bootstrap pipeline — role execution order:
# 1. global_defaults — normalize + validate system/hypervisor/disk input
# 2. system_check — pre-flight hardware/environment safety checks
# 3. virtualization — create VM on hypervisor (libvirt/proxmox/vmware/xen)
# 4. environment — detect live ISO, configure installer network, install tools
# 5. partitioning — partition disk, create FS, LUKS, LVM, mount everything
# 6. bootstrap — debootstrap/pacstrap/dnf install the target OS into /mnt
# 7. configuration — users, network, encryption, fstab, bootloader, services
# 8. cis — CIS hardening (optional, per system.features.cis.enabled)
# 9. cleanup — unmount, remove cloud-init artifacts, reboot/shutdown
- name: Create and configure VMs
hosts: "{{ bootstrap_target | default('all') }}"
strategy: free # noqa: run-once[play]
@@ -62,6 +52,12 @@
name: configuration
public: true
# Past this point the OS is installed and configured; a CIS hardening or
# cleanup failure must not delete an otherwise-good VM.
- name: Mark base system complete
ansible.builtin.set_fact:
_bootstrap_base_complete: true
- name: Apply CIS hardening
when: system_cfg.features.cis.enabled | bool
ansible.builtin.include_role:
@@ -75,11 +71,16 @@
public: true
rescue:
- name: Decide whether to delete the half-built VM
ansible.builtin.set_fact:
_delete_vm_on_rescue: >-
{{ _vm_absent_before_bootstrap | default(false) | bool
and virtualization_vm_created_in_run | default(false) | bool
and system_cfg.type == "virtual"
and not (_bootstrap_base_complete | default(false) | bool) }}
- name: Delete VM on bootstrap failure
when:
- _vm_absent_before_bootstrap | default(false) | bool
- virtualization_vm_created_in_run | default(false) | bool
- system_cfg.type == "virtual"
when: _delete_vm_on_rescue | bool
ansible.builtin.include_role:
name: virtualization
tasks_from: delete
@@ -93,9 +94,8 @@
ansible.builtin.fail:
msg: >-
Bootstrap failed for {{ hostname }}.
{{ 'VM was deleted to allow clean retry.'
if (virtualization_vm_created_in_run | default(false))
else 'VM was not created in this run (kept).' }}
{{ 'VM was deleted to allow clean retry.' if (_delete_vm_on_rescue | bool)
else 'VM kept (base system installed or not created this run).' }}
post_tasks:
- name: Set post-reboot connection flags
@@ -131,6 +131,15 @@
ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3
- name: Wait for the rebooted host to accept SSH
when:
- post_reboot_can_connect | bool
ansible.builtin.wait_for_connection:
delay: 5
sleep: 5
# 600s: a selinux-enabled first boot relabels the filesystem and reboots once more.
timeout: 600
- name: Re-gather facts for target OS after reboot
when:
- post_reboot_can_connect | bool
@@ -140,6 +149,22 @@
- min
- pkg_mgr
- name: Register with the Satellite content source
when:
- post_reboot_can_connect | bool
- system_cfg.content.source == 'satellite'
- system_cfg.os | lower in os_family_rhel
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/configuration/tasks/satellite_register.yml"
- name: Activate the firewall on the rebooted host
when:
- post_reboot_can_connect | bool
- system_cfg.features.firewall.enabled | bool
- system_cfg.features.firewall.backend == 'ufw'
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/configuration/tasks/firewall.yml"
vars:
firewall_phase: postreboot
- name: Install post-reboot packages
when:
- post_reboot_can_connect | bool

View File

@@ -1,15 +1,12 @@
---
# OS task file mapping for bootstrap dispatch.
# OS -> task file mapping for bootstrap dispatch.
# Each key matches a supported `os` value; value is the task file to include.
bootstrap_os_task_map:
almalinux: _dnf_family.yml
alpine: alpine.yml
archlinux: archlinux.yml
debian: debian.yml
fedora: _dnf_family.yml
opensuse: opensuse.yml
rocky: _dnf_family.yml
rhel: rhel.yml
ubuntu: ubuntu.yml
ubuntu-lts: ubuntu.yml
void: void.yml

View File

@@ -8,14 +8,33 @@
_de: "{{ system_cfg.features.desktop.environment }}"
_family_pkgs: "{{ bootstrap_desktop_packages[os_family] | default({}) }}"
_de_config: "{{ _family_pkgs[_de] | default({}) }}"
_base: "{{ bootstrap_desktop_base_packages[os_family] | default([]) }}"
_dm: "{{ system_cfg.features.desktop.display_manager | default('') }}"
_dm_override_pkg: "{{ (bootstrap_dm_override_packages[_dm] | default({}))[os_family] | default('') }}"
_requested_groups: "{{ system_cfg.features.desktop.groups | default([]) }}"
_group_pkgs: >-
{{
_requested_groups
| select('in', desktop_package_groups)
| map('extract', desktop_package_groups)
| map(attribute=os_family, default=[])
| list
| sum(start=[])
}}
ansible.builtin.set_fact:
_desktop_groups: "{{ _de_config.groups | default([]) }}"
_desktop_packages: "{{ _de_config.packages | default([]) }}"
_desktop_packages: >-
{{
((_de_config.packages | default([])) + _base + _group_pkgs + [_dm_override_pkg])
| reject('equalto', '')
| unique
| list
}}
- name: Validate desktop environment is supported
ansible.builtin.assert:
that:
- (_desktop_groups | length > 0) or (_desktop_packages | length > 0)
- system_cfg.features.desktop.environment in (bootstrap_desktop_packages[os_family] | default({}))
fail_msg: >-
Desktop environment '{{ system_cfg.features.desktop.environment }}'
is not defined for os_family '{{ os_family }}'.
@@ -25,7 +44,7 @@
- name: Install desktop package groups
when: _desktop_groups | length > 0
ansible.builtin.command: >-
{{ chroot_command }} dnf --releasever={{ os_version }}
{{ chroot_command }} dnf --releasever={{ os_version_major }}
--setopt=install_weak_deps=False group install -y {{ _desktop_groups | join(' ') }}
register: _desktop_group_result
changed_when: _desktop_group_result.rc == 0
@@ -35,14 +54,13 @@
vars:
_install_commands:
RedHat: >-
{{ chroot_command }} dnf --releasever={{ os_version }}
{{ chroot_command }} dnf --releasever={{ os_version_major }}
--setopt=install_weak_deps=False install -y {{ _desktop_packages | join(' ') }}
Debian: >-
{{ chroot_command }} apt install -y {{ _desktop_packages | join(' ') }}
{{ chroot_command }} env DEBIAN_FRONTEND=noninteractive
apt install -y --install-recommends {{ _desktop_packages | join(' ') }}
Archlinux: >-
pacstrap /mnt {{ _desktop_packages | join(' ') }}
Suse: >-
{{ chroot_command }} zypper install -y {{ _desktop_packages | join(' ') }}
ansible.builtin.command: "{{ _install_commands[os_family] }}"
register: _desktop_pkg_result
changed_when: _desktop_pkg_result.rc == 0

View File

@@ -13,7 +13,7 @@
block:
- name: "Install base system for {{ os | capitalize }}"
ansible.builtin.command: >-
dnf --releasever={{ os_version }} --best {{ _dnf_repos }}
dnf --releasever={{ os_version_major }} --best {{ _dnf_repos }}
--installroot=/mnt --setopt=install_weak_deps=False
groupinstall -y {{ _dnf_groups }}
register: bootstrap_dnf_base_result
@@ -31,7 +31,7 @@
- name: Install extra packages
ansible.builtin.command: >-
{{ chroot_command }} dnf --releasever={{ os_version }} --setopt=install_weak_deps=False
{{ chroot_command }} dnf --releasever={{ os_version_major }} --setopt=install_weak_deps=False
install -y {{ _dnf_extra }}
register: bootstrap_dnf_extra_result
changed_when: bootstrap_dnf_extra_result.rc == 0

View File

@@ -0,0 +1,94 @@
---
- name: Load hardware package definitions
ansible.builtin.include_vars:
file: hardware.yml
- name: Validate hardware support for current os_family
ansible.builtin.assert:
that:
- os_family in bootstrap_hardware_packages
- hardware_profile_active is defined
fail_msg: >-
Hardware feature requested but no package map for os_family
'{{ os_family }}'. Extend roles/bootstrap/vars/hardware.yml.
quiet: true
# nvidia_driver: auto -> open (Turing+) -> proprietary (older, if family ships it)
# -> nouveau (fallback). Explicit value falls back to nouveau when
# the family lacks packages for it.
- name: Resolve Nvidia driver flavor
vars:
_family: "{{ bootstrap_hardware_packages[os_family] }}"
_user_driver: "{{ system_cfg.features.gpu.nvidia_driver | default('auto') }}"
_has_nvidia: "{{ 'nvidia' in (hardware_profile_active.gpus | default([]) | difference(_hardware_profile_disable | default([]))) }}"
_supports_open: "{{ hardware_profile_active.nvidia_supports_open | default(true) | bool }}"
_open_pkgs: "{{ _family.gpu_nvidia.open | default([]) }}"
_prop_pkgs: "{{ _family.gpu_nvidia.proprietary | default([]) }}"
_auto_choice: >-
{{
('open' if _supports_open and _open_pkgs | length > 0
else ('proprietary' if _prop_pkgs | length > 0
else 'nouveau'))
}}
_user_choice: >-
{{
_auto_choice if _user_driver == 'auto'
else (_user_driver
if (_family.gpu_nvidia[_user_driver] | default([]) | length > 0)
else 'nouveau')
}}
ansible.builtin.set_fact:
_nvidia_driver_resolved: "{{ _user_choice if _has_nvidia else 'nouveau' }}"
# Fedora's akmod-nvidia* packages live in RPMFusion non-free, which is not
# enabled out of the box; install the release RPM before the package step.
- name: Enable RPMFusion non-free for Fedora Nvidia install
when:
- os_family == 'RedHat'
- os == 'fedora'
- system_cfg.features.gpu.enabled | bool
- _nvidia_driver_resolved in ['open', 'proprietary']
ansible.builtin.command: >-
{{ chroot_command }} dnf install -y
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-{{ os_version_major }}.noarch.rpm
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-{{ os_version_major }}.noarch.rpm
register: _rpmfusion_result
changed_when: _rpmfusion_result.rc == 0
- name: Resolve hardware package set
ansible.builtin.include_tasks: _resolve_hardware_packages.yml
- name: Report hardware package selection
ansible.builtin.debug:
msg: >-
Hardware install ({{ os_family }}):
cpu={{ hardware_profile_active.cpu | default('-') }},
gpus={{ hardware_profile_active.gpus | default([]) | join(',') | default('-', true) }},
nvidia_driver={{ _nvidia_driver_resolved }},
wireless={{ hardware_profile_active.wireless | default([]) | join(',') | default('-', true) }},
fingerprint={{ hardware_profile_active.fingerprint | default(false) }}
-> {{ _hardware_packages | length }} package(s)
- name: Note Intel IPU6 camera (out-of-tree stack)
when: hardware_profile_active.camera.ipu6 | default(false) | bool
ansible.builtin.debug:
msg: >-
Intel IPU6 MIPI camera detected. Its driver stack (intel-ipu6 firmware,
DKMS module, v4l2-relayd, libcamera) is out-of-tree/AUR and is NOT auto-
installed. Pin the packages in a hardware group via
system.features.hardware.packages[{{ os_family }}].
- name: Install hardware packages
when: _hardware_packages | length > 0
vars:
_install_commands:
RedHat: >-
{{ chroot_command }} dnf --releasever={{ os_version_major }}
--setopt=install_weak_deps=False install -y {{ _hardware_packages | join(' ') }}
Debian: >-
{{ chroot_command }} apt install -y {{ _hardware_packages | join(' ') }}
Archlinux: >-
pacstrap /mnt {{ _hardware_packages | join(' ') }}
ansible.builtin.command: "{{ _install_commands[os_family] }}"
register: _hardware_install_result
changed_when: _hardware_install_result.rc == 0

View File

@@ -0,0 +1,125 @@
---
# Split out of _hardware.yml so fixtures can seed the inputs and assert the
# resolved _hardware_packages list with no chroot/install.
- name: Resolve hardware package set
vars:
_family: "{{ bootstrap_hardware_packages[os_family] }}"
_disable: "{{ _hardware_profile_disable | default([]) | list }}"
_profile_packages: "{{ (_hardware_profile_packages | default({}))[os_family] | default([]) | list }}"
_cpu: "{{ hardware_profile_active.cpu | default('') | string }}"
_gpus: "{{ hardware_profile_active.gpus | default([]) | difference(_disable) | list }}"
_wifi: "{{ hardware_profile_active.wireless | default([]) | difference(_disable) | list }}"
_fp_detected: "{{ hardware_profile_active.fingerprint | default(false) | bool }}"
_audio: "{{ hardware_profile_active.audio | default([]) | difference(_disable) | list }}"
_bt_detected: "{{ hardware_profile_active.bluetooth | default(false) | bool }}"
_firmware_on: "{{ system_cfg.features.firmware.enabled | bool }}"
_microcode_on: "{{ _firmware_on and (system_cfg.features.firmware.microcode | bool) }}"
_gpu_on: "{{ system_cfg.features.gpu.enabled | bool }}"
_peripherals_on: "{{ system_cfg.features.peripherals.enabled | bool }}"
_camera_pref: "{{ system_cfg.features.peripherals.camera | default('auto') }}"
_camera_uvc: "{{ hardware_profile_active.camera.uvc | default(false) | bool }}"
_camera_ipu6: "{{ hardware_profile_active.camera.ipu6 | default(false) | bool }}"
_fp_pref: "{{ system_cfg.features.peripherals.fingerprint | default('auto') }}"
_audio_pref: "{{ system_cfg.features.peripherals.audio | default('auto') }}"
_bt_pref: "{{ system_cfg.features.peripherals.bluetooth | default('auto') }}"
_dl_on: "{{ (system_cfg.features.peripherals.displaylink | bool) and ('displaylink' not in _disable) }}"
_camera_on: >-
{{
_peripherals_on
and ('camera' not in _disable)
and (_camera_pref == 'true' or (_camera_pref == 'auto' and (_camera_uvc or _camera_ipu6)))
}}
_fp_on: >-
{{
_peripherals_on
and ('fingerprint' not in _disable)
and (_fp_pref == 'true' or (_fp_pref == 'auto' and _fp_detected))
}}
_audio_on: >-
{{
_peripherals_on
and ('audio' not in _disable)
and (_audio_pref == 'true' or (_audio_pref == 'auto' and (_audio | length > 0)))
}}
_bt_on: >-
{{
_peripherals_on
and ('bluetooth' not in _disable)
and (_bt_pref == 'true' or (_bt_pref == 'auto' and _bt_detected))
}}
# Union of GPU/wireless/CPU vendors; CPU vendor is included so Intel-CPU
# systems pull i915/iwlwifi firmware via the same vendor split.
_cpu_vendor_list: "{{ ([_cpu] if (_cpu | length > 0 and _cpu not in _disable) else []) | list }}"
_firmware_vendors: >-
{{
(_firmware_on | ternary(
(_gpus + _wifi + _cpu_vendor_list)
| reject('equalto', '') | unique | list,
[]
))
}}
_microcode_pkgs: >-
{{
((_microcode_on and _cpu | length > 0 and _cpu not in _disable) | ternary(
_family.cpu_microcode[_cpu] | default([]),
[]
)) | list
}}
_firmware_pkgs: >-
{{
(_firmware_on | ternary(
(_family.firmware_base | default([]) | list)
+ (_firmware_vendors
| map('extract', _family.firmware | default({}))
| select('truthy')
| list
| sum(start=[])),
[]
)) | list
}}
_gpu_base_pkgs: "{{ (_gpu_on | ternary(_family.gpu_base | default([]), [])) | list }}"
_gpu_vendor_pkgs: >-
{{
(_gpu_on | ternary(
(_gpus | reject('equalto', 'nvidia') | list)
| map('extract', _family.gpu | default({}))
| select('truthy')
| list
| sum(start=[]),
[]
)) | list
}}
_gpu_nvidia_pkgs: >-
{{
((_gpu_on and ('nvidia' in _gpus)) | ternary(
_family.gpu_nvidia[_nvidia_driver_resolved] | default([]),
[]
)) | list
}}
_camera_base_pkgs: >-
{{
(_camera_on | ternary(_family.camera_base | default([]), [])) | list
}}
_peripherals_fingerprint_pkgs: >-
{{
(_fp_on | ternary(_family.peripherals_fingerprint | default([]), [])) | list
}}
_peripherals_displaylink_pkgs: >-
{{
(_dl_on | ternary(_family.peripherals_displaylink | default([]), [])) | list
}}
_audio_base_pkgs: "{{ (_audio_on | ternary(_family.audio_base | default([]), [])) | list }}"
_bluetooth_base_pkgs: "{{ (_bt_on | ternary(_family.bluetooth_base | default([]), [])) | list }}"
ansible.builtin.set_fact:
_hardware_packages: >-
{{
(_microcode_pkgs + _firmware_pkgs
+ _gpu_base_pkgs + _gpu_vendor_pkgs + _gpu_nvidia_pkgs
+ _audio_base_pkgs + _bluetooth_base_pkgs
+ _camera_base_pkgs + _peripherals_fingerprint_pkgs
+ _peripherals_displaylink_pkgs
+ _profile_packages)
| reject('equalto', '')
| unique
| list
}}

View File

@@ -1,30 +0,0 @@
---
- name: Bootstrap Alpine Linux
vars:
_config: "{{ lookup('vars', bootstrap_var_key) }}"
_base_packages: "{{ _config.base | join(' ') }}"
_extra_packages: >-
{{
((_config.extra | default([])) + (_config.conditional | default([])))
| reject('equalto', '')
| join(' ')
}}
block:
- name: Install Alpine Linux base
ansible.builtin.command: >
apk --root /mnt --no-cache add {{ _base_packages }}
register: bootstrap_alpine_bootstrap_result
changed_when: bootstrap_alpine_bootstrap_result.rc == 0
- name: Install extra packages
when: _extra_packages | trim | length > 0
ansible.builtin.command: >
apk --root /mnt add {{ _extra_packages }}
register: bootstrap_alpine_extra_result
changed_when: bootstrap_alpine_extra_result.rc == 0
- name: Install bootloader
ansible.builtin.command: >
apk --root /mnt add grub grub-efi efibootmgr
register: bootstrap_alpine_bootloader_result
changed_when: bootstrap_alpine_bootloader_result.rc == 0

View File

@@ -8,7 +8,44 @@
| reject('equalto', '')
| list
}}
block:
- name: Notify that mirror mode falls back to the public mirrorlist
when:
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length == 0
ansible.builtin.debug:
msg: >-
content.source is 'mirror' but content.url is empty: keeping the live
ISO public mirrorlist (refreshed by reflector). Set content.url to pin
a specific mirror.
- name: Point pacman at the content mirror
when: system_cfg.content.url | length > 0
ansible.builtin.copy:
dest: /etc/pacman.d/mirrorlist
content: "Server = {{ system_cfg.content.url }}/$repo/os/$arch\n"
mode: "0644"
- name: Refresh Arch keyring in the live environment
ansible.builtin.command: pacman -Sy --noconfirm archlinux-keyring
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_arch_keyring
changed_when: bootstrap_arch_keyring.rc == 0
- name: Install Arch base system
ansible.builtin.command: >-
pacstrap /mnt {{ bootstrap_archlinux_packages | join(' ') }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_result
changed_when: bootstrap_result.rc == 0
- name: Persist the content mirror in the installed system
when: system_cfg.content.url | length > 0
ansible.builtin.copy:
dest: /mnt/etc/pacman.d/mirrorlist
content: "Server = {{ system_cfg.content.url }}/$repo/os/$arch\n"
mode: "0644"

View File

@@ -3,9 +3,7 @@
vars:
bootstrap_debian_release: >-
{{
'buster' if (os_version | string) == '10'
else 'bullseye' if (os_version | string) == '11'
else 'bookworm' if (os_version | string) == '12'
'bookworm' if (os_version | string) == '12'
else 'trixie' if (os_version | string) == '13'
else 'sid' if (os_version | string) == 'unstable'
else 'trixie'
@@ -28,10 +26,27 @@
fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys."
quiet: true
- name: Check for a debootstrap script for the target release
ansible.builtin.stat:
path: "/usr/share/debootstrap/scripts/{{ bootstrap_debian_release }}"
register: bootstrap_debian_script
- name: Symlink a missing debootstrap script to the sid base
ansible.builtin.file:
src: sid
dest: "/usr/share/debootstrap/scripts/{{ bootstrap_debian_release }}"
state: link
when: not bootstrap_debian_script.stat.exists
- name: Install Debian base system
ansible.builtin.command: >-
debootstrap --include={{ bootstrap_debian_base_csv }}
{{ bootstrap_debian_release }} /mnt {{ system_cfg.mirror }}
debootstrap --keyring=/usr/share/keyrings/debian-archive-keyring.gpg
--include={{ bootstrap_debian_base_csv }}
{{ bootstrap_debian_release }} /mnt
{{ system_cfg.content.url }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_debian_base_result
changed_when: bootstrap_debian_base_result.rc == 0
@@ -48,6 +63,10 @@
Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false";
{% if system_cfg.content.proxy | length > 0 %}
Acquire::http::Proxy "{{ system_cfg.content.proxy }}";
Acquire::https::Proxy "{{ system_cfg.content.proxy }}";
{% endif %}
mode: "0644"
- name: Update package lists
@@ -66,7 +85,10 @@
register: bootstrap_debian_extra_result
changed_when: bootstrap_debian_extra_result.rc == 0
# Printing (libcups2) and mDNS (libavahi*) are needed by a desktop session,
# so keep them when a desktop is requested.
- name: Remove unnecessary packages
when: not (system_cfg.features.desktop.enabled | bool)
ansible.builtin.command: "{{ chroot_command }} apt remove -y libcups2 libavahi-common3 libavahi-common-data"
register: bootstrap_debian_remove_result
changed_when: bootstrap_debian_remove_result.rc == 0

View File

@@ -29,11 +29,42 @@
loop_control:
label: "{{ item.path }}"
# Installers write their cache inside the installroot; redirect it off the 2 GiB CIS /var LV.
- name: Create bootstrap package-cache directory
ansible.builtin.file:
path: /mnt/.bootstrap-cache
state: directory
mode: "0755"
- name: Redirect package cache off the CIS /var LV
ansible.posix.mount:
src: /mnt/.bootstrap-cache
path: /mnt/var/cache
fstype: none
opts: bind
state: ephemeral
- name: Run OS-specific bootstrap process
vars:
bootstrap_var_key: "{{ 'bootstrap_' + (os | replace('-lts', '') | replace('-', '_')) }}"
ansible.builtin.include_tasks: "{{ bootstrap_os_task_map[os] }}"
# dnf --installroot never runs anaconda, so no authselect profile is selected and
# /etc/pam.d/system-auth is missing, leaving the system unable to authenticate.
# local is the right profile: local-auth only, no pam_sss.so, still CIS-capable.
- name: Select default authselect profile for the PAM stack
when: is_authselect | bool
ansible.builtin.command: "{{ chroot_command }} authselect select local --force"
register: bootstrap_authselect_result
changed_when: bootstrap_authselect_result.rc == 0
- name: Install hardware-matched firmware/microcode/GPU/peripheral packages
when: >-
(system_cfg.features.firmware.enabled | bool)
or (system_cfg.features.gpu.enabled | bool)
or (system_cfg.features.peripherals.enabled | bool)
ansible.builtin.include_tasks: _hardware.yml
- name: Install desktop environment packages
when: system_cfg.features.desktop.enabled | bool
ansible.builtin.include_tasks: _desktop.yml

View File

@@ -1,30 +0,0 @@
---
- name: Bootstrap openSUSE
vars:
_config: "{{ lookup('vars', bootstrap_var_key) }}"
_base_patterns: "{{ _config.base | join(' ') }}"
_extra_packages: >-
{{
((_config.extra | default([])) + (_config.conditional | default([])))
| reject('equalto', '')
| join(' ')
}}
block:
- name: Install openSUSE base patterns
ansible.builtin.command: >
zypper --root /mnt --non-interactive install -t pattern {{ _base_patterns }}
register: bootstrap_opensuse_base_result
changed_when: bootstrap_opensuse_base_result.rc == 0
- name: Install extra packages
when: _extra_packages | trim | length > 0
ansible.builtin.command: >
zypper --root /mnt --non-interactive install {{ _extra_packages }}
register: bootstrap_opensuse_extra_result
changed_when: bootstrap_opensuse_extra_result.rc == 0
- name: Install bootloader
ansible.builtin.command: >
zypper --root /mnt --non-interactive install grub2 grub2-efi efibootmgr
register: bootstrap_opensuse_bootloader_result
changed_when: bootstrap_opensuse_bootloader_result.rc == 0

View File

@@ -24,12 +24,14 @@
- "'grub2-common' not in (bootstrap_result.stderr | default(''))"
- name: Ensure chroot RHEL DVD directory exists
when: system_cfg.content.source != 'mirror'
ansible.builtin.file:
path: /mnt/usr/local/install/redhat/dvd
state: directory
mode: "0755"
- name: Bind mount RHEL DVD into chroot
when: system_cfg.content.source != 'mirror'
ansible.posix.mount:
src: /usr/local/install/redhat/dvd
path: /mnt/usr/local/install/redhat/dvd

View File

@@ -4,8 +4,8 @@
# ubuntu = latest non-LTS, ubuntu-lts = latest LTS
bootstrap_ubuntu_release_map:
ubuntu: questing
ubuntu-lts: noble
bootstrap_ubuntu_release: "{{ bootstrap_ubuntu_release_map[os] | default('noble') }}"
ubuntu-lts: resolute
bootstrap_ubuntu_release: "{{ bootstrap_ubuntu_release_map[os] | default('resolute') }}"
_config: "{{ lookup('vars', bootstrap_var_key) }}"
bootstrap_ubuntu_base_csv: "{{ (['ca-certificates'] + _config.base) | unique | join(',') }}"
bootstrap_ubuntu_extra_args: >-
@@ -24,13 +24,28 @@
fail_msg: "{{ bootstrap_var_key }} must be a dict with base/extra/conditional keys."
quiet: true
- name: Check for a debootstrap script for the target release
ansible.builtin.stat:
path: "/usr/share/debootstrap/scripts/{{ bootstrap_ubuntu_release }}"
register: bootstrap_ubuntu_script
- name: Symlink a missing debootstrap script to the ubuntu base
ansible.builtin.file:
src: gutsy
dest: "/usr/share/debootstrap/scripts/{{ bootstrap_ubuntu_release }}"
state: link
when: not bootstrap_ubuntu_script.stat.exists
- name: Install Ubuntu base system
ansible.builtin.command: >-
debootstrap
--keyring=/usr/share/keyrings/ubuntu-archive-keyring.gpg
--include={{ bootstrap_ubuntu_base_csv }}
{{ bootstrap_ubuntu_release }} /mnt
{{ system_cfg.mirror }}
{{ system_cfg.content.url }}
environment:
http_proxy: "{{ system_cfg.content.proxy }}"
https_proxy: "{{ system_cfg.content.proxy }}"
register: bootstrap_ubuntu_base_result
changed_when: bootstrap_ubuntu_base_result.rc == 0
@@ -47,6 +62,10 @@
Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false";
{% if system_cfg.content.proxy | length > 0 %}
Acquire::http::Proxy "{{ system_cfg.content.proxy }}";
Acquire::https::Proxy "{{ system_cfg.content.proxy }}";
{% endif %}
mode: "0644"
- name: Update package lists

View File

@@ -1,30 +0,0 @@
---
- name: Bootstrap Void Linux
vars:
_config: "{{ lookup('vars', bootstrap_var_key) }}"
_base_packages: "{{ _config.base | join(' ') }}"
_extra_packages: >-
{{
((_config.extra | default([])) + (_config.conditional | default([])))
| reject('equalto', '')
| join(' ')
}}
block:
- name: Install Void Linux base
ansible.builtin.command: >
xbps-install -Sy -r /mnt -R https://repo-default.voidlinux.org/current {{ _base_packages }}
register: bootstrap_void_base_result
changed_when: bootstrap_void_base_result.rc == 0
- name: Install extra packages
when: _extra_packages | trim | length > 0
ansible.builtin.command: >
xbps-install -Su -r /mnt {{ _extra_packages }}
register: bootstrap_void_extra_result
changed_when: bootstrap_void_extra_result.rc == 0
- name: Install bootloader
ansible.builtin.command: >
xbps-install -Sy -r /mnt grub-x86_64-efi efibootmgr
register: bootstrap_void_bootloader_result
changed_when: bootstrap_void_bootloader_result.rc == 0

View File

@@ -1,7 +1,7 @@
# Managed by Ansible.
{% set release = bootstrap_debian_release %}
{% set mirror = system_cfg.mirror %}
{% set components = 'main contrib non-free' ~ (' non-free-firmware' if (os_version | string) not in ['10', '11'] else '') %}
{% set mirror = system_cfg.content.url | default('http://deb.debian.org/debian', true) %}
{% set components = 'main contrib non-free non-free-firmware' %}
deb {{ mirror }} {{ release }} {{ components }}
deb-src {{ mirror }} {{ release }} {{ components }}

View File

@@ -1,6 +1,6 @@
# Managed by Ansible.
{% set release = bootstrap_ubuntu_release %}
{% set mirror = system_cfg.mirror %}
{% set mirror = system_cfg.content.url %}
{% set components = 'main restricted universe multiverse' %}
deb {{ mirror }} {{ release }} {{ components }}

View File

@@ -1,13 +1,41 @@
---
# Per-family desktop environment package definitions.
# Keyed by os_family -> environment -> groups (dnf groupinstall) / packages.
# Kept intentionally minimal: base DE + essential tools, no full suites.
# Wayland only: gnome, kde, sway, hyprland. No X11/xorg-server, no X11-only DEs.
# plasma-login-manager on Arch/Fedora44+ (Plasma 6.6), else sddm.
bootstrap_kde_login_manager: >-
{{
'plasma-login-manager'
if (os == 'archlinux' or (os == 'fedora' and (os_version | int) >= 44))
else 'sddm'
}}
# Native DMs ride in each DE's package set; only explicit non-native overrides
# need a package here. ly is Arch-only (validation rejects it elsewhere first).
bootstrap_dm_override_packages:
ly:
Archlinux: ly
# EL = non-fedora RedHat.
bootstrap_os_is_el: "{{ os in ['almalinux', 'rocky', 'rhel'] }}"
bootstrap_os_is_el10: "{{ bootstrap_os_is_el | bool and (os_version | default('0') | int) >= 10 }}"
# EL10 renames (evince->papers, eog->loupe, ppd->tuned-ppd); fira-code + mpv absent on EL.
bootstrap_desktop_browser: "{{ 'firefox-esr' if os == 'debian' else 'firefox' }}"
bootstrap_desktop_pdf: "{{ 'papers' if bootstrap_os_is_el10 | bool else 'evince' }}"
bootstrap_desktop_image: "{{ 'loupe' if bootstrap_os_is_el10 | bool else 'eog' }}"
bootstrap_desktop_power: "{{ 'tuned-ppd' if bootstrap_os_is_el10 | bool else 'power-profiles-daemon' }}"
bootstrap_desktop_redhat_codefont: "{{ '' if bootstrap_os_is_el | bool else 'fira-code-fonts' }}"
bootstrap_desktop_redhat_video: "{{ '' if bootstrap_os_is_el | bool else 'mpv' }}"
bootstrap_desktop_packages:
RedHat:
gnome:
groups:
- workstation-product-environment
packages: []
groups: []
packages:
- gnome-shell
- gnome-control-center
- nautilus
- gnome-session
- gdm
kde:
groups: []
packages:
@@ -15,7 +43,7 @@ bootstrap_desktop_packages:
- plasma-nm
- plasma-pa
- plasma-systemmonitor
- sddm
- "{{ bootstrap_kde_login_manager }}"
- konsole
- dolphin
- kate
@@ -24,13 +52,6 @@ bootstrap_desktop_packages:
- xdg-user-dirs
- xdg-desktop-portal-kde
- bluez
- pipewire
- wireplumber
xfce:
groups:
- xfce-desktop-environment
packages:
- lightdm
Debian:
gnome:
groups: []
@@ -45,7 +66,7 @@ bootstrap_desktop_packages:
- plasma-desktop
- plasma-nm
- plasma-pa
- sddm
- "{{ bootstrap_kde_login_manager }}"
- konsole
- dolphin
- kate
@@ -53,15 +74,6 @@ bootstrap_desktop_packages:
- xdg-user-dirs
- xdg-desktop-portal-kde
- bluez
- pipewire
- wireplumber
xfce:
groups: []
packages:
- xfce4
- xfce4-goodies
- lightdm
- xdg-user-dirs
Archlinux:
gnome:
groups: []
@@ -75,7 +87,7 @@ bootstrap_desktop_packages:
- plasma-desktop
- plasma-nm
- plasma-pa
- sddm
- "{{ bootstrap_kde_login_manager }}"
- konsole
- dolphin
- kate
@@ -84,15 +96,6 @@ bootstrap_desktop_packages:
- xdg-user-dirs
- xdg-desktop-portal-kde
- bluez
- pipewire
- wireplumber
xfce:
groups: []
packages:
- xfce4
- xfce4-goodies
- lightdm
- xdg-user-dirs
sway:
groups: []
packages:
@@ -100,12 +103,13 @@ bootstrap_desktop_packages:
- waybar
- foot
- wofi
- nautilus
- greetd
- greetd-tuigreet
- xdg-user-dirs
- xdg-desktop-portal-wlr
- polkit-gnome
- bluez
- pipewire
- wireplumber
hyprland:
groups: []
packages:
@@ -113,37 +117,78 @@ bootstrap_desktop_packages:
- kitty
- wofi
- waybar
- ly
- nautilus
- greetd
- greetd-tuigreet
- xdg-user-dirs
- xdg-desktop-portal-hyprland
- polkit-kde-agent
- qt5-wayland
- qt6-wayland
- bluez
# Installed for EVERY DE whenever desktop.enabled. No file manager here: DE metas
# bundle their own and the wlroots sets above carry nautilus.
bootstrap_desktop_base_packages:
RedHat:
- google-noto-sans-fonts
- google-noto-emoji-fonts
- "{{ bootstrap_desktop_redhat_codefont }}"
- pipewire
- wireplumber
Suse:
gnome:
groups: []
packages:
- patterns-gnome-gnome_basic
- gdm
- xdg-user-dirs
kde:
groups: []
packages:
- patterns-kde-kde_plasma
- sddm
- xdg-user-dirs
- pipewire-pulseaudio
- xdg-desktop-portal
- "{{ bootstrap_desktop_power }}"
- bluez
- firefox
- "{{ bootstrap_desktop_pdf }}"
- "{{ bootstrap_desktop_image }}"
- "{{ bootstrap_desktop_redhat_video }}"
Debian:
- fonts-noto
- fonts-noto-color-emoji
- fonts-firacode
- pipewire
- wireplumber
- pipewire-pulse
- xdg-desktop-portal
- power-profiles-daemon
- bluez
- "{{ bootstrap_desktop_browser }}"
- evince
- eog
- mpv
Archlinux:
- noto-fonts
- noto-fonts-emoji
- ttf-nerd-fonts-symbols
- pipewire
- wireplumber
- pipewire-pulse
- xdg-desktop-portal
- power-profiles-daemon
- bluez
- firefox
- evince
- loupe
- mpv
# Display manager auto-detection from desktop environment.
bootstrap_desktop_dm_map:
gnome: gdm
kde: sddm
xfce: lightdm
sway: greetd
hyprland: ly@tty2
cinnamon: lightdm
mate: lightdm
lxqt: sddm
budgie: gdm
# Opt-in groups selected per host via features.desktop.groups; the union of the
# requested groups' packages is installed. Empty selection by default.
desktop_package_groups:
dev:
RedHat:
- git
- "@development-tools"
- neovim
- python3-pip
Debian:
- git
- build-essential
- neovim
- python3-pip
Archlinux:
- git
- base-devel
- neovim
- python-pip

View File

@@ -0,0 +1,103 @@
---
# Hardware-aware package definitions keyed by os_family, consumed by
# _resolve_hardware_packages.yml. Only packages matching detected hardware are
# installed; families without vendor splits collapse to one firmware meta package.
bootstrap_hardware_packages:
Archlinux:
cpu_microcode:
intel: [intel-ucode]
amd: [amd-ucode]
firmware_base: []
firmware:
intel: [linux-firmware-other] # iwlwifi + i915 firmware live here
amd: [linux-firmware-amdgpu]
nvidia: [linux-firmware-nvidia]
atheros: [linux-firmware-atheros]
broadcom: [linux-firmware-broadcom]
mediatek: [linux-firmware-mediatek]
marvell: [linux-firmware-marvell]
realtek: [linux-firmware-realtek]
qcom: [linux-firmware-qcom]
cirrus: [linux-firmware-cirrus]
other: [linux-firmware-other]
gpu_base: [mesa, vulkan-icd-loader]
gpu:
intel: [vulkan-intel, intel-media-driver]
amd: [vulkan-radeon, libva-mesa-driver]
gpu_nvidia:
open: [nvidia-open-dkms, nvidia-utils]
proprietary: [nvidia-dkms, nvidia-utils]
# Wayland-only: kernel nouveau module + mesa/gbm drive the display; no Xorg DDX.
nouveau: [vulkan-nouveau]
camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, libfprint]
peripherals_displaylink: [] # AUR only; user must wire in AUR helper
audio_base: [sof-firmware, alsa-ucm-conf]
bluetooth_base: [bluez, bluez-utils]
Debian:
cpu_microcode:
intel: [intel-microcode]
amd: [amd64-microcode]
firmware_base: [firmware-linux-free]
firmware:
intel: [firmware-iwlwifi, firmware-misc-nonfree]
amd: [firmware-amd-graphics, firmware-misc-nonfree]
nvidia: [firmware-misc-nonfree]
atheros: [firmware-atheros]
broadcom: [firmware-brcm80211]
mediatek: [firmware-misc-nonfree]
marvell: [firmware-misc-nonfree]
realtek: [firmware-realtek]
qcom: [firmware-misc-nonfree]
cirrus: [firmware-misc-nonfree]
other: [firmware-misc-nonfree]
gpu_base: [mesa-vulkan-drivers, libgl1-mesa-dri]
gpu:
intel: [intel-media-va-driver, i965-va-driver]
amd: [libva-glx2, mesa-va-drivers]
gpu_nvidia:
# Debian trixie+ ships nvidia-open-kernel-dkms; older releases only have
# the proprietary nvidia-driver. Both come from the non-free component.
open: [nvidia-open-kernel-dkms, nvidia-driver, nvidia-vulkan-icd]
proprietary: [nvidia-driver, nvidia-vulkan-icd]
# Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg.
nouveau: []
camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, libpam-fprintd]
peripherals_displaylink: [evdi-dkms] # userspace driver still needs vendor .run
audio_base: [firmware-sof-signed, alsa-ucm-conf]
bluetooth_base: [bluez]
RedHat:
cpu_microcode:
intel: [microcode_ctl]
amd: [microcode_ctl]
firmware_base: [linux-firmware]
firmware:
intel: []
amd: []
nvidia: []
atheros: []
broadcom: []
mediatek: []
marvell: []
realtek: []
qcom: []
cirrus: []
other: []
gpu_base: [mesa-dri-drivers, mesa-vulkan-drivers, vulkan-loader]
gpu:
intel: [intel-media-driver, libva-intel-driver]
amd: [mesa-va-drivers]
gpu_nvidia:
# akmod packages from RPMFusion non-free; repo enabled by _hardware.yml.
open: [akmod-nvidia-open, xorg-x11-drv-nvidia, xorg-x11-drv-nvidia-cuda]
proprietary: [akmod-nvidia, xorg-x11-drv-nvidia, xorg-x11-drv-nvidia-cuda]
# Wayland-only: kernel module + mesa (gpu_base) cover it; no Xorg DDX, no extra pkg.
nouveau: []
camera_base: [v4l-utils]
peripherals_fingerprint: [fprintd, fprintd-pam]
peripherals_displaylink: [evdi] # COPR-supplied; repo enablement deferred
audio_base: [alsa-sof-firmware, alsa-ucm]
bluetooth_base: [bluez]

View File

@@ -1,6 +1,6 @@
---
# Feature-gated packages shared across all distros.
# Arch has special nftables handling and composes this differently.
# Feature-gated packages shared across all distros. Arch strips nftables from
# this and composes it differently.
bootstrap_common_conditional: >-
{{
(
@@ -11,18 +11,37 @@ bootstrap_common_conditional: >-
+ (['cryptsetup', 'tpm2-tools'] if system_cfg.luks.enabled | bool else [])
+ (['qemu-guest-agent'] if hypervisor_type in ['libvirt', 'proxmox'] else [])
+ (['open-vm-tools'] if hypervisor_type == 'vmware' else [])
+ (['cloud-init'] if system_cfg.features.cloud_init | bool else [])
)
}}
# ---------------------------------------------------------------------------
# Per-OS package definitions: base (rootfs/group install), extra (post-base),
# conditional (feature/version-gated, appended by task files).
# DNF-based distros also carry repos (dnf --repo) and use base as group names.
# ---------------------------------------------------------------------------
# Native-installer parity backfill: anaconda and the d-i "standard" task leave
# these, but install_weak_deps=False / Recommends-off minimal installs drop them.
bootstrap_el_runtime:
- NetworkManager
- authselect
- authselect-libs
- chrony
- crypto-policies
- crypto-policies-scripts
- dbus
- polkit
bootstrap_deb_runtime:
- apparmor-utils
- chrony
- libpam-pwquality
- needrestart
- network-manager
- sudo
# Per-OS package definitions: base (rootfs/group install), extra (post-base),
# conditional (feature/version-gated, appended by task files). DNF distros also
# carry repos and use base as group names.
bootstrap_rhel:
repos:
- "rhel{{ os_version_major }}-baseos"
- "rhel{{ os_version_major }}-appstream"
base:
- core
- base
@@ -51,6 +70,7 @@ bootstrap_rhel:
+ (['python39'] if os_version_major | default('') == '8' else ['python'])
+ (['kernel'] if os_version_major | default('') == '10' else [])
+ (['zram-generator'] if os_version_major | default('') in ['9', '10'] else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional
}}
@@ -85,8 +105,8 @@ bootstrap_almalinux:
- zstd
conditional: >-
{{
(['dbus-daemon'] if (os_version_major | default('10') | int) >= 9 else [])
+ (['dhcp-client'] if (os_version_major | default('10') | int) < 10 else [])
(['dhcp-client'] if (os_version_major | default('10') | int) < 10 else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional
}}
@@ -125,6 +145,7 @@ bootstrap_rocky:
conditional: >-
{{
(['dhcp-client'] if (os_version_major | default('9') | int) < 10 else [])
+ bootstrap_el_runtime
+ bootstrap_common_conditional
}}
@@ -158,7 +179,6 @@ bootstrap_fedora:
- nc
- nfs-utils
- nfsv4-client-utils
- polkit
- ppp
- python3
- ripgrep
@@ -169,7 +189,7 @@ bootstrap_fedora:
- zoxide
- zram-generator
- zstd
conditional: "{{ bootstrap_common_conditional }}"
conditional: "{{ bootstrap_el_runtime + bootstrap_common_conditional }}"
bootstrap_debian:
base:
@@ -187,28 +207,22 @@ bootstrap_debian:
- python3
- xfsprogs
extra:
- apparmor-utils
- bat
- chrony
- curl
- entr
- fish
- fzf
- htop
- jq
- libpam-pwquality
- linux-image-amd64
- lrzsz
- mtr
- ncdu
- needrestart
- net-tools
- network-manager
- python-is-python3
- ripgrep
- rsync
- screen
- sudo
- syslog-ng
- tcpd
- vim
@@ -223,6 +237,7 @@ bootstrap_debian:
+ (['systemd-zram-generator'] if (os_version | string) not in ['10', '11'] else [])
+ (['tldr'] if (os_version | string) not in ['13', 'unstable'] else [])
+ (['shim-signed'] if system_cfg.features.secure_boot.enabled | bool else [])
+ bootstrap_deb_runtime
+ bootstrap_common_conditional
}}
@@ -244,10 +259,8 @@ bootstrap_ubuntu:
- python3
- xfsprogs
extra:
- apparmor-utils
- bash-completion
- bat
- chrony
- curl
- dnsutils
- duf
@@ -259,20 +272,16 @@ bootstrap_ubuntu:
- fzf
- htop
- jq
- libpam-pwquality
- lrzsz
- mtr
- ncdu
- ncurses-term
- needrestart
- net-tools
- network-manager
- python-is-python3
- ripgrep
- rsync
- screen
- software-properties-common
- sudo
- syslog-ng
- systemd-zram-generator
- tcpd
@@ -285,8 +294,8 @@ bootstrap_ubuntu:
- zstd
conditional: >-
{{
(['tldr'] if (os_version | default('') | string | length) > 0 else [])
+ (['shim-signed'] if system_cfg.features.secure_boot.enabled | bool else [])
(['shim-signed'] if system_cfg.features.secure_boot.enabled | bool else [])
+ bootstrap_deb_runtime
+ bootstrap_common_conditional
}}
@@ -313,7 +322,6 @@ bootstrap_archlinux:
- nfs-utils
- ppp
- python
- reflector
- rsync
- sudo
- tldr
@@ -326,75 +334,6 @@ bootstrap_archlinux:
(['openssh'] if system_cfg.features.ssh.enabled | bool else [])
+ (['iptables-nft'] if system_cfg.features.firewall.toolkit == 'nftables' and system_cfg.features.firewall.enabled | bool else [])
+ (['sbctl'] if system_cfg.features.secure_boot.enabled | bool else [])
+ (['reflector'] if system_cfg.content.url | length == 0 else [])
+ (bootstrap_common_conditional | reject('equalto', 'nftables') | list)
}}
bootstrap_alpine:
base:
- alpine-base
extra:
- btrfs-progs
- chrony
- curl
- e2fsprogs
- linux-lts
- logrotate
- lvm2
- python3
- rsync
- sudo
- util-linux
- vim
- xfsprogs
conditional: >-
{{
(['openssh'] if system_cfg.features.ssh.enabled | bool else [])
+ bootstrap_common_conditional
}}
bootstrap_opensuse:
base:
- patterns-base-base
extra:
- btrfs-progs
- chrony
- curl
- e2fsprogs
- glibc-locale
- kernel-default
- logrotate
- lvm2
- NetworkManager
- python3
- rsync
- sudo
- vim
- xfsprogs
conditional: >-
{{
(['openssh'] if system_cfg.features.ssh.enabled | bool else [])
+ bootstrap_common_conditional
}}
bootstrap_void:
base:
- base-system
- void-repo-nonfree
extra:
- btrfs-progs
- chrony
- curl
- dhcpcd
- e2fsprogs
- logrotate
- lvm2
- python3
- rsync
- sudo
- vim
- xfsprogs
conditional: >-
{{
(['openssh'] if system_cfg.features.ssh.enabled | bool else [])
+ bootstrap_common_conditional
}}

View File

@@ -1,100 +1,13 @@
---
# User-facing API: override via top-level `cis` dict in inventory.
# Merged with these defaults in _normalize.yml → cis_cfg.
cis_defaults:
modules_blacklist:
- freevxfs
- jffs2
- hfs
- hfsplus
- cramfs
- udf
- usb-storage
- dccp
- sctp
- rds
- tipc
- firewire-core
- firewire-sbp2
- thunderbolt
sysctl:
fs.suid_dumpable: 0
kernel.dmesg_restrict: 1
kernel.kptr_restrict: 2
kernel.perf_event_paranoid: 3
kernel.unprivileged_bpf_disabled: 1
kernel.yama.ptrace_scope: 2
kernel.randomize_va_space: 2
net.ipv4.ip_forward: 0
net.ipv4.tcp_syncookies: 1
net.ipv4.icmp_echo_ignore_broadcasts: 1
net.ipv4.icmp_ignore_bogus_error_responses: 1
net.ipv4.conf.all.log_martians: 1
net.ipv4.conf.all.rp_filter: 1
net.ipv4.conf.all.secure_redirects: 0
net.ipv4.conf.all.send_redirects: 0
net.ipv4.conf.all.accept_redirects: 0
net.ipv4.conf.all.accept_source_route: 0
net.ipv4.conf.all.arp_ignore: 1
net.ipv4.conf.all.arp_announce: 2
net.ipv4.conf.default.log_martians: 1
net.ipv4.conf.default.rp_filter: 1
net.ipv4.conf.default.secure_redirects: 0
net.ipv4.conf.default.send_redirects: 0
net.ipv4.conf.default.accept_redirects: 0
net.ipv6.conf.all.accept_redirects: 0
net.ipv6.conf.all.disable_ipv6: 1
net.ipv6.conf.default.accept_redirects: 0
net.ipv6.conf.default.disable_ipv6: 1
net.ipv6.conf.lo.disable_ipv6: 1
sshd_options:
- { option: LogLevel, value: VERBOSE }
- { option: LoginGraceTime, value: "60" }
- { option: PermitRootLogin, value: "no" }
- { option: StrictModes, value: "yes" }
- { option: MaxAuthTries, value: "4" }
- { option: MaxSessions, value: "10" }
- { option: MaxStartups, value: "10:30:60" }
- { option: PubkeyAuthentication, value: "yes" }
- { option: HostbasedAuthentication, value: "no" }
- { option: IgnoreRhosts, value: "yes" }
- { option: PasswordAuthentication, value: "no" }
- { option: PermitEmptyPasswords, value: "no" }
- { option: KerberosAuthentication, value: "no" }
- { option: GSSAPIAuthentication, value: "no" }
- { option: AllowAgentForwarding, value: "no" }
- { option: AllowTcpForwarding, value: "no" }
- { option: KbdInteractiveAuthentication, value: "no" }
- { option: GatewayPorts, value: "no" }
- { option: X11Forwarding, value: "no" }
- { option: PermitUserEnvironment, value: "no" }
- { option: ClientAliveInterval, value: "300" }
- { option: ClientAliveCountMax, value: "1" }
- { option: PermitTunnel, value: "no" }
- { option: Banner, value: /etc/issue.net }
pwquality_minlen: 14
tmout: 900
umask: "077"
umask_profile: "027"
faillock_deny: 5
faillock_unlock_time: 900
password_remember: 5
# Platform-specific binary names for CIS permission targets
cis_fusermount_binary: "{{ 'fusermount3' if is_rhel | default(false) | bool else 'fusermount' }}"
cis_write_binary: "{{ 'write' if is_rhel | default(false) | bool else 'wall' }}"
cis: {}
cis_permission_targets:
- { path: "/mnt/etc/ssh/sshd_config", mode: "0600" }
- { path: "/mnt/etc/cron.hourly", mode: "0700" }
- { path: "/mnt/etc/cron.daily", mode: "0700" }
- { path: "/mnt/etc/cron.weekly", mode: "0700" }
- { path: "/mnt/etc/cron.monthly", mode: "0700" }
- { path: "/mnt/etc/cron.d", mode: "0700" }
- { path: "/mnt/etc/crontab", mode: "0600" }
- { path: "/mnt/etc/logrotate.conf", mode: "0644" }
- { path: "/mnt/usr/sbin/pppd", mode: "0754" }
- { path: "/mnt/usr/bin/{{ cis_fusermount_binary }}", mode: "0755" }
- { path: "/mnt/usr/bin/{{ cis_write_binary }}", mode: "0755" }
- {path: "/mnt/etc/ssh/sshd_config", mode: "0600"}
- {path: "/mnt/etc/cron.hourly", mode: "0700"}
- {path: "/mnt/etc/cron.daily", mode: "0700"}
- {path: "/mnt/etc/cron.weekly", mode: "0700"}
- {path: "/mnt/etc/cron.monthly", mode: "0700"}
- {path: "/mnt/etc/cron.d", mode: "0700"}
- {path: "/mnt/etc/crontab", mode: "0600"}
- {path: "/mnt/etc/logrotate.conf", mode: "0644"}
- {path: "/mnt/usr/sbin/pppd", mode: "0754"}
- {path: "/mnt/usr/bin/{{ cis_fusermount_binary }}", mode: "0755"}
- {path: "/mnt/usr/bin/{{ cis_write_binary }}", mode: "0755"}

View File

@@ -1,10 +1,25 @@
---
- name: Normalize CIS input
- name: Determine CIS profile
ansible.builtin.set_fact:
cis_enabled: "{{ cis is defined and (cis is mapping or cis | bool) }}"
cis_input: "{{ cis if cis is mapping else {} }}"
cis_profile: "{{ system_cfg.features.cis.profile | default('default') }}"
- name: Normalize CIS configuration
when: cis_enabled and cis_cfg is not defined
- name: Validate CIS profile selection
ansible.builtin.assert:
that: cis_profile in cis_profiles
fail_msg: >-
system.features.cis.profile '{{ cis_profile }}' is unknown
(valid: {{ cis_profiles.keys() | list | join(', ') }}).
quiet: true
- name: Resolve CIS rules and parameters
vars:
_cis: "{{ system_cfg.features.cis | default({}) }}"
ansible.builtin.set_fact:
cis_cfg: "{{ cis_defaults | combine(cis_input, recursive=True) }}"
cis_effective_rules: "{{ cis_profiles[cis_profile] | combine(_cis.rules | default({})) }}"
cis_cfg: >-
{{ cis_param_defaults
| combine(cis_profile_params[cis_profile] | default({}), recursive=True)
| combine(_cis.params | default({}), recursive=True) }}
# l1/l2 add the stricter CIS-server controls on top of the legacy `default`
# baseline; gate those tasks on this so `default` stays byte-for-byte unchanged.
cis_strict: "{{ cis_profile in ['l1', 'l2'] }}"

42
roles/cis/tasks/aide.yml Normal file
View File

@@ -0,0 +1,42 @@
---
- name: Install AIDE
when: cis_effective_rules.aide | default(false)
# Debian's aideinit lives in aide-common (only Recommended, so absent under
# the installer's --no-install-recommends); pull it explicitly.
ansible.builtin.command: "{{ cis_pkg_install }} {{ 'aide aide-common' if is_debian | bool else 'aide' }}"
register: cis_aide_install
changed_when: cis_aide_install.rc == 0
- name: Initialize the AIDE database
when: cis_effective_rules.aide | default(false)
# Absolute path: arch-chroot's PATH omits /usr/sbin, so bare aide/aideinit is rc127.
# Debian's aideinit assembles its split config; RHEL/Arch run --init on /etc/aide.conf.
ansible.builtin.command: "{{ chroot_command }} {{ '/usr/sbin/aideinit -y -f' if is_debian | bool else '/usr/sbin/aide --init' }}"
register: cis_aide_init
changed_when: cis_aide_init.rc == 0
- name: Locate the freshly built AIDE database
when: cis_effective_rules.aide | default(false)
ansible.builtin.find:
paths: /mnt/var/lib/aide
patterns: "aide.db.new*"
register: cis_aide_newdb
- name: Activate the AIDE database
when:
- cis_effective_rules.aide | default(false)
- cis_aide_newdb.files | length > 0
ansible.builtin.copy:
src: "{{ cis_aide_newdb.files[0].path }}"
dest: "{{ cis_aide_newdb.files[0].path | regex_replace('\\.new', '') }}"
remote_src: true
mode: "0600"
- name: Schedule the daily AIDE integrity check
when: cis_effective_rules.aide | default(false)
ansible.builtin.copy:
dest: /mnt/etc/cron.d/cis-aide
mode: "0644"
content: |
PATH=/usr/sbin:/usr/bin:/sbin:/bin
{{ cis_cfg.aide_cron_minute }} {{ cis_cfg.aide_cron_hour }} * * * root aide --check

View File

@@ -0,0 +1,42 @@
---
- name: Install the audit daemon
when: cis_effective_rules.auditd | default(false)
ansible.builtin.command: "{{ cis_pkg_install }} {{ 'auditd' if is_debian | bool else 'audit' }}"
register: cis_auditd_install
changed_when: cis_auditd_install.rc == 0
- name: Deploy the CIS audit rule set
when: cis_effective_rules.auditd | default(false)
ansible.builtin.copy:
dest: /mnt/etc/audit/rules.d/cis.rules
mode: "0640"
content: |
## CIS baseline audit rules
-D
-b 8192
-f 1
-a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time-change
-w /etc/localtime -p wa -k time-change
-w /etc/group -p wa -k identity
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/security/opasswd -p wa -k identity
-a always,exit -F arch=b64 -S sethostname,setdomainname -k system-locale
-w /etc/hosts -p wa -k system-locale
-w /var/log/lastlog -p wa -k logins
-w /var/run/faillock -p wa -k logins
-w /var/run/utmp -p wa -k session
-w /var/log/wtmp -p wa -k session
-w /var/log/btmp -p wa -k session
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat,chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d -p wa -k scope
-a always,exit -F arch=b64 -S init_module,delete_module -k modules
-e 2
- name: Enable the audit daemon
when: cis_effective_rules.auditd | default(false)
ansible.builtin.command: "{{ chroot_command }} systemctl enable auditd"
register: cis_auditd_enable
changed_when: "'Created symlink' in cis_auditd_enable.stderr"

View File

@@ -1,12 +1,35 @@
---
- name: Ensure the Default UMASK is Set Correctly
when: cis_effective_rules.umask_default | default(false)
ansible.builtin.lineinfile:
path: "/mnt/etc/profile"
regexp: "^(\\s*)umask\\s+\\d+"
line: "umask {{ cis_cfg.umask_profile }}"
- name: Set the login.defs UMASK (CIS L1+)
when:
- cis_effective_rules.umask_default | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/login.defs
regexp: '^\s*#?\s*UMASK\b'
line: "UMASK\t\t{{ cis_cfg.umask_profile }}"
# authselect regenerates system-auth from the profile, so a direct edit is lost
# on the next apply; without-nullok is the supported way to drop nullok there.
- name: Prevent Login to Accounts With Empty Password (authselect)
when:
- cis_effective_rules.empty_password_login | default(false)
- is_authselect | bool
ansible.builtin.command: "{{ chroot_command }} authselect enable-feature without-nullok"
register: cis_nullok_result
changed_when: cis_nullok_result.rc == 0
# Non-RHEL/non-Debian distros: loop evaluates to [] (intentional skip)
- name: Prevent Login to Accounts With Empty Password
when:
- cis_effective_rules.empty_password_login | default(false)
- not is_authselect | bool
ansible.builtin.replace:
dest: "{{ item }}"
regexp: "\\s*nullok"

View File

@@ -1,13 +1,20 @@
---
# Fedora ships its own crypto-policies preset and update-crypto-policies
# behaves differently; applying DEFAULT:NO-SHA1 can break package signing.
# EL10 dropped the NO-SHA1 subpolicy module (DEFAULT already disables SHA-1
# signatures), so the modifier is set only on EL9 and below.
- name: Configure System Cryptography Policy
when: os in (os_family_rhel | difference(['fedora']))
ansible.builtin.command: "{{ chroot_command }} /usr/bin/update-crypto-policies --set DEFAULT:NO-SHA1"
vars:
_cis_crypto_policy: "{{ 'DEFAULT' if (os_version_major | int >= 10) else 'DEFAULT:NO-SHA1' }}"
when:
- cis_effective_rules.crypto_policy | default(false)
- os in (os_family_rhel | difference(['fedora']))
ansible.builtin.command: "{{ chroot_command }} /usr/bin/update-crypto-policies --set {{ _cis_crypto_policy }}"
register: cis_crypto_policy_result
changed_when: "'Setting system-wide crypto-policies to' in cis_crypto_policy_result.stdout"
- name: Mask Systemd Services
when: cis_effective_rules.mask_services | default(false)
ansible.builtin.command: >
{{ chroot_command }} systemctl mask {{ 'nftables' if system_cfg.features.firewall.toolkit == 'iptables' else 'iptables' }} bluetooth rpcbind
register: cis_mask_services_result

View File

@@ -1,5 +1,6 @@
---
- name: Ensure files exist
- name: Ensure cron and at access files exist
when: cis_effective_rules.cron_at_access | default(false)
ansible.builtin.file:
path: "{{ item }}"
state: touch
@@ -7,10 +8,19 @@
loop:
- /mnt/etc/at.allow
- /mnt/etc/cron.allow
- name: Ensure TCP wrapper files exist
when: cis_effective_rules.tcp_wrappers | default(false)
ansible.builtin.file:
path: "{{ item }}"
state: touch
mode: "0600"
loop:
- /mnt/etc/hosts.allow
- /mnt/etc/hosts.deny
- name: Ensure files do not exist
- name: Ensure cron and at deny files do not exist
when: cis_effective_rules.cron_at_access | default(false)
ansible.builtin.file:
path: "{{ item }}"
state: absent

View File

@@ -0,0 +1,31 @@
---
# Opt-in only: a GRUB superuser password blocks unattended menu edits; the default entry still boots.
- name: Assert a GRUB password hash is supplied
when: cis_effective_rules.grub_password | default(false)
ansible.builtin.assert:
that: cis_cfg.grub_password_hash | length > 0
fail_msg: >-
system.features.cis.rules.grub_password is enabled but
system.features.cis.params.grub_password_hash is empty. Generate one with
grub2-mkpasswd-pbkdf2 and set it there.
quiet: true
- name: Deploy the GRUB superuser password
when: cis_effective_rules.grub_password | default(false)
ansible.builtin.copy:
dest: /mnt/etc/grub.d/01_cis_password
mode: "0755"
content: |
#!/bin/sh
cat <<'EOF'
set superusers="root"
password_pbkdf2 root {{ cis_cfg.grub_password_hash }}
EOF
- name: Regenerate the GRUB configuration
when: cis_effective_rules.grub_password | default(false)
ansible.builtin.command: >-
{{ chroot_command }}
{{ 'grub2-mkconfig -o /boot/grub2/grub.cfg' if is_rhel | bool else 'grub-mkconfig -o /boot/grub/grub.cfg' }}
register: cis_grub_regen
changed_when: cis_grub_regen.rc == 0

View File

@@ -3,7 +3,6 @@
ansible.builtin.import_tasks: _normalize.yml
- name: Apply CIS hardening
when: cis_enabled
block:
- name: Include CIS hardening tasks
ansible.builtin.include_tasks: "{{ cis_task }}"
@@ -16,5 +15,11 @@
- security_lines.yml
- permissions.yml
- sshd.yml
- warning_banners.yml
- password_expiry.yml
- aide.yml
- auditd.yml
- packages.yml
- grub_password.yml
loop_control:
loop_var: cis_task

View File

@@ -1,7 +1,8 @@
---
- name: Disable Kernel Modules
when: cis_effective_rules.module_blacklist | default(false)
vars:
# Ubuntu uses squashfs for snap packages blacklisting it breaks snap entirely
# Ubuntu uses squashfs for snap packages - blacklisting it breaks snap entirely
cis_modules_squashfs: "{{ [] if os in ['ubuntu', 'ubuntu-lts'] else ['squashfs'] }}"
cis_modules_all: "{{ cis_cfg.modules_blacklist + cis_modules_squashfs }}"
ansible.builtin.copy:
@@ -14,11 +15,13 @@
{% endfor %}
- name: Remove old USB rules file
when: cis_effective_rules.usb_lockdown | default(false)
ansible.builtin.file:
path: /mnt/etc/udev/rules.d/10-cis_usb_devices.sh
state: absent
- name: Create USB rules
when: cis_effective_rules.usb_lockdown | default(false)
ansible.builtin.copy:
dest: /mnt/etc/udev/rules.d/10-cis_usb_devices.rules
mode: "0644"

View File

@@ -0,0 +1,29 @@
---
# CIS L1 names legacy cleartext clients (telnet) for removal. They are absent on
# a fresh minimal install; query first and remove only when present so the run
# stays idempotent (a chroot package-manager remove cannot use the package module).
- name: Check for insecure cleartext clients
when: cis_strict | default(false)
ansible.builtin.command: >-
{{ chroot_command }}
{{ 'dpkg -s' if is_debian | bool else 'pacman -Q' if os == 'archlinux' else 'rpm -q' }}
{{ item }}
loop: "{{ cis_cfg.insecure_packages }}"
register: cis_insecure_present
changed_when: false
failed_when: false
loop_control:
label: "{{ item }}"
- name: Remove insecure cleartext clients (CIS L1+)
when:
- cis_strict | default(false)
- item.rc == 0
ansible.builtin.command: >-
{{ chroot_command }}
{{ 'apt-get remove -y' if is_debian | bool else 'pacman -R --noconfirm' if os == 'archlinux' else 'dnf remove -y' }}
{{ item.item }}
loop: "{{ cis_insecure_present.results | default([]) }}"
changed_when: true
loop_control:
label: "{{ item.item }}"

View File

@@ -0,0 +1,22 @@
---
# login.defs sets policy for future accounts; existing service accounts are intentionally not chage-aged.
- name: Configure password aging defaults
when: cis_effective_rules.password_expiry | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/login.defs
regexp: '^#?\s*{{ item.key }}\b'
line: "{{ item.key }}\t{{ item.value }}"
loop:
- {key: PASS_MAX_DAYS, value: "{{ cis_cfg.pass_max_days }}"}
- {key: PASS_MIN_DAYS, value: "{{ cis_cfg.pass_min_days }}"}
- {key: PASS_WARN_AGE, value: "{{ cis_cfg.pass_warn_age }}"}
loop_control:
label: "{{ item.key }}"
# account_disable_post_pw_expiration: lock accounts INACTIVE days after expiry.
- name: Set the default account inactivity lock period
when: cis_effective_rules.password_expiry | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/default/useradd
regexp: '^\s*#?\s*INACTIVE\s*='
line: "INACTIVE={{ cis_cfg.pass_inactive }}"

View File

@@ -1,5 +1,6 @@
---
- name: Check CIS permission targets
when: cis_effective_rules.file_permissions | default(false)
ansible.builtin.stat:
path: "{{ item.path }}"
loop: "{{ cis_permission_targets }}"
@@ -9,12 +10,14 @@
changed_when: false
- name: Set permissions for existing targets
when:
- cis_effective_rules.file_permissions | default(false)
- item.stat.exists
ansible.builtin.file:
path: "{{ item.item.path }}"
owner: "{{ item.item.owner | default(omit) }}"
group: "{{ item.item.group | default(omit) }}"
mode: "{{ item.item.mode }}"
loop: "{{ cis_permission_stats.results }}"
loop: "{{ cis_permission_stats.results | default([]) }}"
loop_control:
label: "{{ item.item.path }}"
when: item.stat.exists

View File

@@ -1,62 +1,218 @@
---
- name: Add Security related lines into config files
- name: Restrict core dumps
when: cis_effective_rules.core_dumps | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/security/limits.conf
regexp: '^\*\s+hard\s+core\s+'
line: "* hard core 0"
- name: Ensure the systemd coredump drop-in directory exists (CIS L1+)
when:
- cis_effective_rules.core_dumps | default(false)
- cis_strict | default(false)
ansible.builtin.file:
path: /mnt/etc/systemd/coredump.conf.d
state: directory
mode: "0755"
- name: Disable systemd core dump storage and backtraces (CIS L1+)
when:
- cis_effective_rules.core_dumps | default(false)
- cis_strict | default(false)
ansible.builtin.copy:
dest: /mnt/etc/systemd/coredump.conf.d/10-cis.conf
mode: "0644"
content: |
[Coredump]
Storage=none
ProcessSizeMax=0
- name: Set password quality requirements
when: cis_effective_rules.pwquality | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/security/pwquality.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- {regexp: '^\s*#?\s*minlen\s*=', line: "minlen = {{ cis_cfg.pwquality_minlen }}"}
- {regexp: '^\s*#?\s*dcredit\s*=', line: "dcredit = -1"}
- {regexp: '^\s*#?\s*ucredit\s*=', line: "ucredit = -1"}
- {regexp: '^\s*#?\s*ocredit\s*=', line: "ocredit = -1"}
- {regexp: '^\s*#?\s*lcredit\s*=', line: "lcredit = -1"}
loop_control:
label: "{{ item.line }}"
# Stricter complexity SSG cis_server_l1 checks; affects only new-password changes.
- name: Set strict password quality requirements (CIS L1+)
when:
- cis_effective_rules.pwquality | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/security/pwquality.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- {regexp: '^\s*#?\s*difok\s*=', line: "difok = {{ cis_cfg.pwquality_difok }}"}
- {regexp: '^\s*#?\s*maxrepeat\s*=', line: "maxrepeat = {{ cis_cfg.pwquality_maxrepeat }}"}
- {regexp: '^\s*#?\s*maxsequence\s*=', line: "maxsequence = {{ cis_cfg.pwquality_maxsequence }}"}
- {regexp: '^\s*#?\s*minclass\s*=', line: "minclass = {{ cis_cfg.pwquality_minclass }}"}
- {regexp: '^\s*#?\s*dictcheck\s*=', line: "dictcheck = {{ cis_cfg.pwquality_dictcheck }}"}
- {regexp: '^\s*#?\s*enforce_for_root\b', line: "enforce_for_root"}
loop_control:
label: "{{ item.line }}"
- name: Set the default shell umask
when: cis_effective_rules.umask_default | default(false)
ansible.builtin.lineinfile:
path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*umask\s+\d+'
line: "umask {{ cis_cfg.umask }}"
- name: Set the shell idle timeout
when: cis_effective_rules.shell_timeout | default(false)
ansible.builtin.lineinfile:
path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*(export\s+)?TMOUT='
line: "export TMOUT={{ cis_cfg.tmout }}"
# A drop-in survives systemd upgrades; the RHEL vendor journald.conf does not.
- name: Ensure the journald drop-in directory exists
when: cis_effective_rules.journald_persistent | default(false)
ansible.builtin.file:
path: /mnt/etc/systemd/journald.conf.d
state: directory
mode: "0755"
- name: Enable persistent journald storage
when: cis_effective_rules.journald_persistent | default(false)
ansible.builtin.copy:
dest: /mnt/etc/systemd/journald.conf.d/10-cis.conf
mode: "0644"
content: |
[Journal]
Storage=persistent
- name: Compress large journald log files (CIS L1+)
when:
- cis_effective_rules.journald_persistent | default(false)
- cis_strict | default(false)
ansible.builtin.copy:
dest: /mnt/etc/systemd/journald.conf.d/20-cis-compress.conf
mode: "0644"
content: |
[Journal]
Compress=yes
- name: Log sudo commands
when: cis_effective_rules.sudo_logfile | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/sudoers
regexp: '^\s*Defaults\s+logfile='
line: 'Defaults logfile="/var/log/sudo.log"'
- name: Require a pty for sudo (CIS L1+)
when:
- cis_effective_rules.sudo_logfile | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/sudoers
regexp: '^\s*Defaults\s+use_pty\b'
line: "Defaults use_pty"
- name: Restrict su to the wheel group
when: cis_effective_rules.su_restriction | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/pam.d/su
regexp: '^\s*#?\s*auth\s+required\s+pam_wheel\.so'
line: auth required pam_wheel.so
# authselect wires the pam_faillock stack via the feature; deny/unlock_time live
# in faillock.conf, the supported place (pam_faillock(8) deprecates module args).
- name: Configure account lockout (authselect)
when:
- cis_effective_rules.faillock | default(false)
- is_authselect | bool
block:
- name: Enable the authselect faillock feature
ansible.builtin.command: "{{ chroot_command }} authselect enable-feature with-faillock"
register: cis_faillock_result
changed_when: cis_faillock_result.rc == 0
- name: Set faillock thresholds
ansible.builtin.lineinfile:
path: /mnt/etc/security/faillock.conf
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
create: true
mode: "0644"
loop:
- {regexp: '^\s*#?\s*deny\s*=', line: "deny = {{ cis_cfg.faillock_deny }}"}
- {regexp: '^\s*#?\s*unlock_time\s*=', line: "unlock_time = {{ cis_cfg.faillock_unlock_time }}"}
loop_control:
label: "{{ item.line }}"
- name: Configure account lockout
when:
- cis_effective_rules.faillock | default(false)
- not is_authselect | bool
ansible.builtin.lineinfile:
path: "{{ item.path }}"
regexp: "{{ item.regexp }}"
line: "{{ item.content }}"
line: "{{ item.line }}"
loop:
- { path: /mnt/etc/security/limits.conf, regexp: '^\*\s+hard\s+core\s+', content: "* hard core 0" }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*minlen\s*=', content: "minlen = {{ cis_cfg.pwquality_minlen }}" }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*dcredit\s*=', content: dcredit = -1 }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*ucredit\s*=', content: ucredit = -1 }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*ocredit\s*=', content: ocredit = -1 }
- { path: /mnt/etc/security/pwquality.conf, regexp: '^\s*#?\s*lcredit\s*=', content: lcredit = -1 }
- path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*umask\s+\d+'
content: "umask {{ cis_cfg.umask }}"
- path: '/mnt/etc/{{ "bashrc" if is_rhel else "bash.bashrc" }}'
regexp: '^\s*(export\s+)?TMOUT='
content: "export TMOUT={{ cis_cfg.tmout }}"
- path: '/mnt/{{ "usr/lib/systemd/journald.conf" if is_rhel | bool else "etc/systemd/journald.conf" }}'
regexp: '^\s*#?\s*Storage='
content: Storage=persistent
- path: /mnt/etc/sudoers
regexp: '^\s*Defaults\s+logfile='
content: Defaults logfile="/var/log/sudo.log"
- path: /mnt/etc/pam.d/su
regexp: '^\s*#?\s*auth\s+required\s+pam_wheel\.so'
content: auth required pam_wheel.so
- path: >-
/mnt/etc/{{
"pam.d/common-auth"
if is_debian | bool
else "authselect/system-auth"
if os == "fedora"
else "pam.d/system-auth"
}}
- path: '/mnt/etc/{{ "pam.d/common-auth" if is_debian | bool else "pam.d/system-auth" }}'
regexp: '^\s*auth\s+required\s+pam_faillock\.so'
content: >-
line: >-
auth required pam_faillock.so onerr=fail audit silent deny={{ cis_cfg.faillock_deny }} unlock_time={{ cis_cfg.faillock_unlock_time }}
- path: >-
/mnt/etc/{{
"pam.d/common-account"
if is_debian | bool
else "authselect/system-auth"
if os == "fedora"
else "pam.d/system-auth"
}}
- path: '/mnt/etc/{{ "pam.d/common-account" if is_debian | bool else "pam.d/system-auth" }}'
regexp: '^\s*account\s+required\s+pam_faillock\.so'
content: account required pam_faillock.so
- path: >-
line: account required pam_faillock.so
loop_control:
label: "{{ item.regexp }}"
- name: Enforce password history
when: cis_effective_rules.password_history | default(false)
ansible.builtin.lineinfile:
path: >-
/mnt/etc/pam.d/{{
"common-password"
if is_debian | bool
else "passwd"
}}
regexp: '^\s*password\s+\[success=1.*\]\s+pam_unix\.so'
content: >-
line: >-
password [success=1 default=ignore] pam_unix.so obscure sha512 remember={{ cis_cfg.password_remember }}
- { path: /mnt/etc/hosts.deny, regexp: '^ALL:\s*ALL', content: "ALL: ALL" }
- { path: /mnt/etc/hosts.allow, regexp: '^sshd:\s*ALL', content: "sshd: ALL" }
# SSG cis_server_l1 checks pam_pwhistory (not pam_unix remember) in the auth-stack
# files; affects only password changes, so no login-lockout risk. EL9 has no
# authselect path here (same direct-edit the faillock rule above uses).
- name: Enforce password reuse limit via pam_pwhistory (CIS L1+)
when:
- cis_effective_rules.password_history | default(false)
- cis_strict | default(false)
ansible.builtin.lineinfile:
path: "{{ item }}"
regexp: '^\s*password\s+(requisite|required)\s+pam_pwhistory\.so'
line: "password requisite pam_pwhistory.so use_authtok remember={{ cis_cfg.pwhistory_remember }} enforce_for_root"
insertbefore: '^\s*password\s+.*pam_unix\.so'
loop: >-
{{
['/mnt/etc/pam.d/system-auth', '/mnt/etc/pam.d/password-auth']
if is_rhel | bool
else (['/mnt/etc/pam.d/common-password'] if is_debian | bool else [])
}}
loop_control:
label: "{{ item.content }}"
label: "{{ item }}"
- name: Configure TCP wrappers
when: cis_effective_rules.tcp_wrappers | default(false)
ansible.builtin.lineinfile:
path: "{{ item.path }}"
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
loop:
- {path: /mnt/etc/hosts.deny, regexp: '^ALL:\s*ALL', line: "ALL: ALL"}
- {path: /mnt/etc/hosts.allow, regexp: '^sshd:\s*ALL', line: "sshd: ALL"}
loop_control:
label: "{{ item.path }}"

View File

@@ -1,5 +1,6 @@
---
- name: Adjust SSHD config
when: cis_effective_rules.sshd_hardening | default(false)
ansible.builtin.lineinfile:
path: /mnt/etc/ssh/sshd_config
regexp: ^\s*#?{{ item.option }}\s+.*$
@@ -9,6 +10,7 @@
label: "{{ item.option }}"
- name: Detect target OpenSSH version
when: cis_effective_rules.sshd_hardening | default(false)
ansible.builtin.shell: >-
set -o pipefail && {{ chroot_command }} ssh -V 2>&1 | grep -oP 'OpenSSH_\K[0-9]+\.[0-9]+'
args:
@@ -18,6 +20,7 @@
failed_when: false
- name: Append CIS specific configurations to sshd_config
when: cis_effective_rules.sshd_hardening | default(false)
vars:
cis_sshd_has_mlkem: "{{ (cis_sshd_openssh_version.stdout | default('0.0') is version('9.9', '>=')) }}"
cis_sshd_kex: >-

View File

@@ -1,10 +1,19 @@
---
- name: Create a consolidated sysctl configuration file
when: cis_effective_rules.sysctl_hardening | default(false)
vars:
# ipv6_disable is a separate rule: when off, drop the disable_ipv6 keys but keep the rest.
_cis_sysctl: >-
{{ cis_cfg.sysctl
if (cis_effective_rules.ipv6_disable | default(false))
else (cis_cfg.sysctl | dict2items | rejectattr('key', 'search', 'disable_ipv6') | items2dict) }}
ansible.builtin.copy:
dest: /mnt/etc/sysctl.d/10-cis.conf
# 99- so CIS wins: a 10- name loses to vendor /usr/lib/sysctl.d/10-default-yama-scope.conf
# (later basename applies last), which reset kernel.yama.ptrace_scope back to 0.
dest: /mnt/etc/sysctl.d/99-cis.conf
mode: "0644"
content: |
## CIS Sysctl configurations
{% for key, value in cis_cfg.sysctl | dictsort %}
{% for key, value in _cis_sysctl | dictsort %}
{{ key }}={{ value }}
{% endfor %}

View File

@@ -0,0 +1,11 @@
---
- name: Set login warning banners
when: cis_effective_rules.warning_banners | default(false)
ansible.builtin.copy:
dest: "/mnt/etc/{{ item }}"
content: "{{ cis_cfg.banner_text }}\n"
mode: "0644"
loop:
- issue
- issue.net
- motd

View File

@@ -1,6 +1,5 @@
---
# OS-specific binary names for CIS permission targets.
# fusermount3 is the modern name; older distros still use fusermount.
# fusermount3 is the modern name; older distros still ship fusermount.
cis_fusermount_binary: >-
{{
'fusermount3'
@@ -19,3 +18,235 @@ cis_write_binary: >-
if (os == 'debian' and (os_version | string) == '11')
else 'write'
}}
cis_pkg_install: >-
{{ chroot_command }} {{
'apt-get install -y'
if is_debian | bool
else 'pacman -S --noconfirm'
if os == 'archlinux'
else 'dnf install -y'
}}
# Rule catalog: control -> CIS level + whether a task implements it.
# `default` enables only implemented rules; `l1`/`l2` add the level-tagged ones.
cis_rule_catalog:
module_blacklist: {level: l1, implemented: true} # fs/net modprobe blacklist (list per profile)
usb_lockdown: {level: l2, implemented: true} # udev authorized_default=0 (aggressive)
sysctl_hardening: {level: l1, implemented: true}
ipv6_disable: {level: l2, implemented: true} # disable_ipv6 subset of the sysctl set
umask_default: {level: l1, implemented: true}
empty_password_login: {level: l1, implemented: true}
pwquality: {level: l1, implemented: true}
core_dumps: {level: l1, implemented: true}
shell_timeout: {level: l1, implemented: true}
journald_persistent: {level: l1, implemented: true}
sudo_logfile: {level: l1, implemented: true}
su_restriction: {level: l1, implemented: true}
faillock: {level: l1, implemented: true}
password_history: {level: l1, implemented: true}
tcp_wrappers: {level: l1, implemented: true}
crypto_policy: {level: l1, implemented: true} # RedHat non-Fedora only
mask_services: {level: l1, implemented: true}
cron_at_access: {level: l1, implemented: true}
file_permissions: {level: l1, implemented: true}
sshd_hardening: {level: l1, implemented: true}
password_expiry: {level: l1, implemented: true} # login.defs aging policy
aide: {level: l1, implemented: true} # file-integrity db + daily check
warning_banners: {level: l1, implemented: true} # /etc/issue, issue.net, motd
auditd: {level: l2, implemented: true} # audit daemon + CIS rule set
grub_password: {level: l1, implemented: true} # opt-in only; needs params.grub_password_hash
# Rules not listed are off. A per-host system.features.cis.rules map overlays this.
cis_profiles:
# default = established house behaviour, kept byte-for-byte unchanged.
default:
module_blacklist: true
usb_lockdown: true
sysctl_hardening: true
ipv6_disable: true
umask_default: true
empty_password_login: true
pwquality: true
core_dumps: true
shell_timeout: true
journald_persistent: true
sudo_logfile: true
su_restriction: true
faillock: true
password_history: true
tcp_wrappers: true
crypto_policy: true
mask_services: true
cron_at_access: true
file_permissions: true
sshd_hardening: true
# l1 = clean CIS Level 1: drops the L2 extras (usb_lockdown, ipv6_disable).
l1:
module_blacklist: true
sysctl_hardening: true
umask_default: true
empty_password_login: true
pwquality: true
core_dumps: true
shell_timeout: true
journald_persistent: true
sudo_logfile: true
su_restriction: true
faillock: true
password_history: true
tcp_wrappers: true
crypto_policy: true
mask_services: true
cron_at_access: true
file_permissions: true
sshd_hardening: true
password_expiry: true
aide: true
warning_banners: true
# l2 = l1 plus the defence-in-depth Level 2 controls.
l2:
module_blacklist: true
usb_lockdown: true
sysctl_hardening: true
ipv6_disable: true
umask_default: true
empty_password_login: true
pwquality: true
core_dumps: true
shell_timeout: true
journald_persistent: true
sudo_logfile: true
su_restriction: true
faillock: true
password_history: true
tcp_wrappers: true
crypto_policy: true
mask_services: true
cron_at_access: true
file_permissions: true
sshd_hardening: true
password_expiry: true
aide: true
warning_banners: true
auditd: true
# Override per host via system.features.cis.params: dicts deep-merge,
# list-valued keys (e.g. sshd_options) replace wholesale.
cis_param_defaults:
modules_blacklist:
- freevxfs
- jffs2
- hfs
- hfsplus
- cramfs
- udf
- usb-storage
- dccp
- sctp
- rds
- tipc
- firewire-core
- firewire-sbp2
- thunderbolt
sysctl:
fs.suid_dumpable: 0
kernel.dmesg_restrict: 1
kernel.kptr_restrict: 2
kernel.perf_event_paranoid: 3
kernel.unprivileged_bpf_disabled: 1
kernel.yama.ptrace_scope: 2
kernel.randomize_va_space: 2
net.ipv4.ip_forward: 0
net.ipv4.tcp_syncookies: 1
net.ipv4.icmp_echo_ignore_broadcasts: 1
net.ipv4.icmp_ignore_bogus_error_responses: 1
net.ipv4.conf.all.log_martians: 1
net.ipv4.conf.all.rp_filter: 1
net.ipv4.conf.all.secure_redirects: 0
net.ipv4.conf.all.send_redirects: 0
net.ipv4.conf.all.accept_redirects: 0
net.ipv4.conf.all.accept_source_route: 0
net.ipv4.conf.all.arp_ignore: 1
net.ipv4.conf.all.arp_announce: 2
net.ipv4.conf.default.log_martians: 1
net.ipv4.conf.default.rp_filter: 1
net.ipv4.conf.default.secure_redirects: 0
net.ipv4.conf.default.send_redirects: 0
net.ipv4.conf.default.accept_redirects: 0
net.ipv6.conf.all.accept_redirects: 0
net.ipv6.conf.all.disable_ipv6: 1
net.ipv6.conf.default.accept_redirects: 0
net.ipv6.conf.default.disable_ipv6: 1
net.ipv6.conf.lo.disable_ipv6: 1
sshd_options:
- {option: LogLevel, value: VERBOSE}
- {option: LoginGraceTime, value: "60"}
- {option: PermitRootLogin, value: "no"}
- {option: StrictModes, value: "yes"}
- {option: MaxAuthTries, value: "4"}
- {option: MaxSessions, value: "10"}
- {option: MaxStartups, value: "10:30:60"}
- {option: PubkeyAuthentication, value: "yes"}
- {option: HostbasedAuthentication, value: "no"}
- {option: IgnoreRhosts, value: "yes"}
- {option: PasswordAuthentication, value: "no"}
- {option: PermitEmptyPasswords, value: "no"}
- {option: KerberosAuthentication, value: "no"}
- {option: GSSAPIAuthentication, value: "no"}
- {option: AllowAgentForwarding, value: "no"}
- {option: AllowTcpForwarding, value: "no"}
- {option: KbdInteractiveAuthentication, value: "no"}
- {option: GatewayPorts, value: "no"}
- {option: X11Forwarding, value: "no"}
- {option: PermitUserEnvironment, value: "no"}
- {option: ClientAliveInterval, value: "300"}
- {option: ClientAliveCountMax, value: "1"}
- {option: PermitTunnel, value: "no"}
- {option: Banner, value: /etc/issue.net}
pwquality_minlen: 14
# pwquality strict set (l1/l2 only, cis_strict): SSG cis_server_l1 values.
pwquality_difok: 2
pwquality_maxrepeat: 3
pwquality_maxsequence: 3
pwquality_minclass: 4
pwquality_dictcheck: 1
tmout: 900
umask: "077"
umask_profile: "027"
faillock_deny: 5
faillock_unlock_time: 900
password_remember: 5
# pwhistory remember (l1/l2 only, cis_strict): SSG wants 24 via pam_pwhistory.
pwhistory_remember: 24
# password_expiry (l1/l2): /etc/login.defs aging.
pass_max_days: 365
pass_min_days: 1
pass_warn_age: 7
# account_disable_post_pw_expiration (l1/l2): days after expiry to lock (SSG=45).
pass_inactive: 45
# aide (l1/l2): daily integrity-check schedule.
aide_cron_hour: "5"
aide_cron_minute: "0"
# warning_banners (l1/l2): login/MOTD text.
banner_text: "Authorized access only. All activity may be monitored and reported."
# grub_password (opt-in only): a grub2 pbkdf2 hash; empty unless opted in.
grub_password_hash: ""
# insecure_packages (l1/l2 only, cis_strict): legacy cleartext clients to remove.
insecure_packages:
- telnet
# Only the module blacklist differs by profile: l1 trims to the L1 filesystem
# modules; default/l2 keep the full list.
cis_profile_params:
default: {}
l1:
modules_blacklist:
- cramfs
- freevxfs
- jffs2
- hfs
- hfsplus
- udf
- usb-storage
l2: {}

View File

@@ -16,13 +16,20 @@
loop: >-
{{
['ide0', 'ide2']
+ (['ide1'] if not (os == 'rhel' and system_cfg.features.rhel_repo.source == 'iso') else [])
+ (['ide1'] if not (os == 'rhel' and system_cfg.content.source == 'dvd') else [])
}}
failed_when: false
no_log: true
- name: Start the VM
- name: Ensure the installer environment is powered off
community.proxmox.proxmox_kvm:
vmid: "{{ system_cfg.id }}"
state: restarted
state: stopped
force: true
no_log: true
- name: Boot the installed OS
community.proxmox.proxmox_kvm:
vmid: "{{ system_cfg.id }}"
state: started
no_log: true

View File

@@ -2,6 +2,16 @@
- name: Unmount Disks
become: true
block:
- name: Unmount the bootstrap package cache
ansible.posix.mount:
path: /mnt/var/cache
state: unmounted
- name: Remove the bootstrap package cache so it is not sealed into the image
ansible.builtin.file:
path: /mnt/.bootstrap-cache
state: absent
- name: Disable Swap
ansible.builtin.command: swapoff -a
register: cleanup_swapoff_result

View File

@@ -35,7 +35,7 @@
}
]
if (rhel_iso is defined and rhel_iso | length > 0
and not (os == 'rhel' and system_cfg.features.rhel_repo.source == 'iso'))
and not (os == 'rhel' and system_cfg.content.source == 'dvd'))
else []
)
}}

View File

@@ -1,7 +1,3 @@
---
# Network configuration dispatch — maps OS name to the task file
# that writes network config. Default (NetworkManager) applies to
# all OSes not explicitly listed.
configuration_network_task_map:
alpine: network_alpine.yml
void: network_void.yml
# Network backend is detected per host from the target rootfs in network.yml;
# no static map needed.

View File

@@ -41,6 +41,18 @@
- name: Configure sudo banner
when: system_cfg.features.banner.sudo | bool
block:
- name: Detect the target sudo implementation
ansible.builtin.command: "{{ chroot_command }} /usr/bin/sudo --version"
register: configuration_sudo_version
changed_when: false
failed_when: false
# sudo-rs (Ubuntu 25.10+) implements neither `lecture` nor `lecture_file`
# and warns on every sudo call when they are set. It prints its version banner
# to stderr, not stdout, so match against both streams.
- name: Configure the sudo lecture
when: "'sudo-rs' not in (configuration_sudo_version.stdout ~ configuration_sudo_version.stderr)"
block:
- name: Create sudo lecture file
ansible.builtin.copy:

View File

@@ -155,5 +155,5 @@
ansible.builtin.include_tasks: encryption/dracut.yml
- name: Configure GRUB for LUKS
when: _initramfs_generator | default('') != 'dracut' or os_family != 'RedHat'
when: _initramfs_generator | default('') != 'dracut'
ansible.builtin.include_tasks: encryption/grub.yml

View File

@@ -14,11 +14,11 @@
install_items+=" {{ configuration_luks_keyfile_path }} "
{% endif %}
{% if configuration_luks_auto_method == 'tpm2' %}
add_dracutmodules+=" tpm2-tss "
install_items+=" {{ configuration_luks_tpm2_token_lib | default('') }} "
{% endif %}
mode: "0644"
# --- Kernel cmdline: write rd.luks.* args for dracut ---
- name: Ensure kernel cmdline directory exists
ansible.builtin.file:
path: /mnt/etc/kernel
@@ -58,7 +58,6 @@
mode: "0644"
content: "{{ _dracut_kernel_cmdline }}\n"
# --- BLS entries: RedHat-specific ---
- name: Update BLS entries with LUKS kernel cmdline
when: os_family == 'RedHat'
vars:

View File

@@ -8,8 +8,18 @@
when:
- configuration_luks_auto_method == 'tpm2'
- _tpm2_method | default('') == 'clevis'
ansible.builtin.command: >-
{{ chroot_command }} apt install -y clevis clevis-luks clevis-tpm2 clevis-initramfs tpm2-tools
vars:
_clevis_install_cmd:
Debian: >-
{{ chroot_command }} apt install -y
clevis clevis-luks clevis-tpm2 clevis-initramfs tpm2-tools
RedHat: >-
{{ chroot_command }} dnf install -y
clevis clevis-luks clevis-systemd tpm2-tools
Archlinux: >-
{{ chroot_command }} pacman -S --noconfirm --needed
clevis tpm2-tools
ansible.builtin.command: "{{ _clevis_install_cmd[os_family] }}"
register: _clevis_install_result
changed_when: _clevis_install_result.rc == 0

View File

@@ -3,7 +3,7 @@
# Sets _initramfs_generator and _tpm2_method facts.
#
# Generator detection: derived from the platform's initramfs_cmd
# (dracut dracut, mkinitcpio mkinitcpio, else initramfs-tools)
# (dracut -> dracut, mkinitcpio -> mkinitcpio, else -> initramfs-tools)
# TPM2 method: systemd-cryptenroll when generator supports tpm2-device,
# clevis fallback otherwise. Non-native dracut installed automatically.

View File

@@ -107,7 +107,7 @@
when: (configuration_luks_keyfile_unlock_test_after.rc | default(1)) != 0
ansible.builtin.debug:
msg: >-
LUKS keyfile enrollment failed falling back to manual unlock at boot.
LUKS keyfile enrollment failed - falling back to manual unlock at boot.
The system will prompt for the LUKS passphrase during startup.
- name: Fallback to manual LUKS unlock if keyfile enrollment failed

View File

@@ -1,7 +1,7 @@
---
# TPM2 enrollment via systemd-cryptenroll.
# Works with dracut and mkinitcpio (sd-encrypt). The user-set passphrase
# remains as a backup unlock method no auto-generated keyfiles.
# remains as a backup unlock method - no auto-generated keyfiles.
- name: Enroll TPM2 for LUKS
block:
- name: Create temporary passphrase file for TPM2 enrollment

View File

@@ -30,7 +30,6 @@
- name: Create zram config
when:
- (os != "debian" or (os_version | string) != "11") and os != "rhel"
- os not in ["alpine", "void"]
- system_cfg.features.swap.enabled | bool
ansible.builtin.copy:
dest: /mnt/etc/systemd/zram-generator.conf

View File

@@ -0,0 +1,34 @@
---
- name: Enable the firewall daemon in the install chroot
when:
- firewall_phase == 'install'
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.firewall.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ system_cfg.features.firewall.backend }}"
register: _firewall_enable
changed_when: _firewall_enable.rc == 0
failed_when: >-
_firewall_enable.rc != 0
and 'No such file or directory' not in (_firewall_enable.stderr | default(''))
and 'does not exist' not in (_firewall_enable.stderr | default(''))
# ufw's CLI needs a running kernel and is a no-op in the chroot (leaves ENABLED=no),
# so its activation and SSH rule are applied here, after reboot.
- name: Allow SSH through ufw before enabling
when:
- firewall_phase == 'postreboot'
- system_cfg.features.firewall.backend == 'ufw'
- system_cfg.features.firewall.enabled | bool
- system_cfg.features.ssh.enabled | bool
ansible.builtin.command: ufw allow 22/tcp
register: _ufw_allow
changed_when: "'added' in _ufw_allow.stdout or 'updated' in _ufw_allow.stdout"
- name: Activate ufw on the booted target
when:
- firewall_phase == 'postreboot'
- system_cfg.features.firewall.backend == 'ufw'
- system_cfg.features.firewall.enabled | bool
ansible.builtin.command: ufw --force enable
register: _ufw_enable
changed_when: "'active' in _ufw_enable.stdout"

View File

@@ -26,7 +26,7 @@
- name: Remove RHEL ISO fstab entry when not using local repo
when:
- os == "rhel"
- system_cfg.features.rhel_repo.source != "iso"
- system_cfg.content.source != "dvd"
ansible.builtin.lineinfile:
path: /mnt/etc/fstab
regexp: "^.*\\/dvd.*$"
@@ -35,7 +35,7 @@
- name: Replace ISO UUID entry with /dev/sr0 in fstab
when:
- os == "rhel"
- system_cfg.features.rhel_repo.source == "iso"
- system_cfg.content.source == "dvd"
vars:
configuration_fstab_dvd_line: >-
{{
@@ -53,7 +53,7 @@
when:
- os == "rhel"
- hypervisor_type == "vmware"
- system_cfg.features.rhel_repo.source == "iso"
- system_cfg.content.source == "dvd"
ansible.builtin.command:
argv:
- dd

View File

@@ -7,7 +7,7 @@
line: "{{ item.line }}"
loop:
- regexp: ^GRUB_CMDLINE_LINUX_DEFAULT=
line: GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3"
line: 'GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3{{ (" " ~ (_hardware_profile_kernel_params | join(" "))) if (_hardware_profile_kernel_params | default([]) | length > 0) else "" }}"'
- regexp: ^GRUB_TIMEOUT=
line: GRUB_TIMEOUT=1
loop_control:
@@ -43,19 +43,21 @@
}}
grub_root_flags: >-
{{ ['rootflags=subvol=@'] if system_cfg.filesystem == 'btrfs' else [] }}
# String-concat (not list-concat like grub_kernel_cmdline_base below): ansible-lint's
# jinja render trips on list+list when grub_lvm_args leads the expression here.
grub_cmdline_linux_base: >-
{{
(['crashkernel=auto'] + grub_lvm_args)
| join(' ')
((grub_lvm_args | join(' ')) ~ ' ' ~ (_hardware_profile_kernel_params | default([]) | join(' '))) | trim
}}
grub_kernel_cmdline_base: >-
{{
(
(['root=UUID=' + grub_root_uuid]
if grub_root_uuid | length > 0 else [])
+ ['ro', 'crashkernel=auto']
+ ['ro']
+ grub_lvm_args
+ grub_root_flags
+ (_hardware_profile_kernel_params | default([]))
)
| join(' ')
}}

View File

@@ -5,14 +5,16 @@
- name: Include configuration tasks
when: configuration_task.when | default(true)
ansible.builtin.include_tasks: "{{ configuration_task.file }}"
vars:
firewall_phase: install
loop:
- file: repositories.yml
when: "{{ os_family == 'Debian' }}"
- file: banner.yml
- file: fstab.yml
- file: locales.yml
- file: ssh.yml
- file: services.yml
- file: firewall.yml
- file: grub.yml
- file: encryption.yml
when: "{{ system_cfg.luks.enabled | bool }}"

View File

@@ -1,38 +1,51 @@
---
- name: Read network interfaces
ansible.builtin.command:
argv:
- ip
- -o
- link
- show
register: configuration_ip_link
changed_when: false
failed_when: false
- name: Detect available network interface names
vars:
configuration_detected_interfaces: >-
{{
configuration_ip_link.stdout
| default('')
| regex_findall('^[0-9]+: ([^:]+):', multiline=True)
| reject('equalto', 'lo')
| list
}}
ansible.builtin.set_fact:
configuration_detected_interfaces: "{{ configuration_detected_interfaces }}"
- name: Validate at least one network interface detected
ansible.builtin.assert:
that:
- configuration_detected_interfaces | length > 0
fail_msg: Failed to detect any network interfaces.
- name: Set DNS configuration facts
ansible.builtin.set_fact:
configuration_dns_list: "{{ system_cfg.network.dns.servers }}"
configuration_dns_search: "{{ system_cfg.network.dns.search }}"
- name: Configure networking
ansible.builtin.include_tasks: "{{ configuration_network_task_map[os] | default('network_nm.yml') }}"
# 2+ unnamed interfaces would all match the first-ethernet glob, leaving the rest unconfigured.
- name: Require an explicit name on every interface for multi-NIC
vars:
_unnamed: "{{ system_cfg.network.interfaces | map(attribute='name', default='') | map('string') | select('equalto', '') | list | length }}"
ansible.builtin.assert:
that:
- system_cfg.network.interfaces | length <= 1 or _unnamed == 0
fail_msg: >-
Multi-NIC (system.network.interfaces with 2+ entries) requires a name on
every interface; the first-adapter glob only binds a single NIC.
# Probe /mnt to detect the stack the installed rootfs will run (nothing runs in
# the chroot). NM is checked first and wins, since bootstrap installs it on every
# family; the rest are the fallback for a non-NM base image.
- name: Probe the installed network stack on the target rootfs
ansible.builtin.stat:
path: "{{ item }}"
register: configuration_net_probe
loop:
- /mnt/usr/bin/nmcli
- /mnt/usr/lib/systemd/system/NetworkManager.service
- /mnt/usr/sbin/netplan
- /mnt/etc/netplan
- /mnt/sbin/ifup
- /mnt/usr/sbin/ifup
- /mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service
- /mnt/etc/systemd/system/dbus-org.freedesktop.network1.service
loop_control:
label: "{{ item }}"
- name: Resolve the network backend from the probe
vars:
_found: "{{ configuration_net_probe.results | selectattr('stat.exists') | map(attribute='item') | list }}"
ansible.builtin.set_fact:
configuration_network_backend: >-
{{
'nm' if (['/mnt/usr/bin/nmcli', '/mnt/usr/lib/systemd/system/NetworkManager.service'] | intersect(_found))
else 'netplan' if (['/mnt/usr/sbin/netplan', '/mnt/etc/netplan'] | intersect(_found))
else 'eni' if (['/mnt/sbin/ifup', '/mnt/usr/sbin/ifup'] | intersect(_found))
else 'networkd' if (['/mnt/etc/systemd/system/multi-user.target.wants/systemd-networkd.service', '/mnt/etc/systemd/system/dbus-org.freedesktop.network1.service'] | intersect(_found))
else 'nm'
}}
- name: Configure networking for the detected backend {{ configuration_network_backend }}
ansible.builtin.include_tasks: "network_{{ configuration_network_backend }}.yml"

View File

@@ -1,36 +0,0 @@
---
- name: Write Alpine network interfaces
ansible.builtin.copy:
dest: /mnt/etc/network/interfaces
mode: "0644"
content: |
auto lo
iface lo inet loopback
{% for iface in system_cfg.network.interfaces %}
{% set inv_name = iface.name | default('') | string %}
{% set det_name = configuration_detected_interfaces[loop.index0] | default('eth' ~ loop.index0) %}
{% set iface_name = inv_name if inv_name | length > 0 else det_name %}
{% set has_static = (iface.ip | default('') | string | length) > 0 %}
auto {{ iface_name }}
iface {{ iface_name }} inet {{ 'static' if has_static else 'dhcp' }}
{% if has_static %}
address {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
gateway {{ iface.gateway }}
{% endif %}
{% endif %}
{% endfor %}
- name: Set Alpine DNS resolvers
when: configuration_dns_list | length > 0 or configuration_dns_search | length > 0
ansible.builtin.copy:
dest: /mnt/etc/resolv.conf
mode: "0644"
content: |
{% if configuration_dns_search | length > 0 %}
search {{ configuration_dns_search | join(' ') }}
{% endif %}
{% for resolver in configuration_dns_list %}
nameserver {{ resolver }}
{% endfor %}

View File

@@ -0,0 +1,35 @@
---
# ifupdown can't glob iface stanzas (no mapping on ifupdown2/Proxmox), so ENI binds
# a literal name detected here. The chroot only sees live-ISO names: on a real
# ifupdown base, set system.network.interfaces[].name to the installed name. Bootstrap
# installs NetworkManager, so this fires only on a non-NM base image.
- name: Detect ethernet interface names
ansible.builtin.command:
argv:
- ip
- -o
- link
- show
register: configuration_eni_link
changed_when: false
failed_when: false
- name: Resolve detected ethernet interface names
ansible.builtin.set_fact:
configuration_eni_detected: >-
{{
configuration_eni_link.stdout | default('')
| regex_findall('^[0-9]+: ([^:@]+)[@:].*?link/ether', multiline=True)
}}
- name: Ensure the network configuration directory exists
ansible.builtin.file:
path: /mnt/etc/network
state: directory
mode: "0755"
- name: Write the ifupdown interfaces file
ansible.builtin.template:
src: network_eni.j2
dest: /mnt/etc/network/interfaces
mode: "0644"

View File

@@ -0,0 +1,12 @@
---
- name: Ensure the netplan directory exists
ansible.builtin.file:
path: /mnt/etc/netplan
state: directory
mode: "0755"
- name: Write the netplan configuration
ansible.builtin.template:
src: network_netplan.j2
dest: /mnt/etc/netplan/10-sg.yaml
mode: "0600"

View File

@@ -0,0 +1,18 @@
---
- name: Ensure the systemd-networkd directory exists
ansible.builtin.file:
path: /mnt/etc/systemd/network
state: directory
mode: "0755"
- name: Write systemd-networkd configuration per interface
vars:
configuration_iface: "{{ item }}"
ansible.builtin.template:
src: network_networkd.j2
dest: "/mnt/etc/systemd/network/10-static-{{ idx }}.network"
mode: "0644"
loop: "{{ system_cfg.network.interfaces }}"
loop_control:
index_var: idx
label: "10-static-{{ idx }}"

View File

@@ -2,7 +2,6 @@
- name: Copy NetworkManager keyfile per interface
vars:
configuration_iface: "{{ item }}"
configuration_iface_name: "{{ item.name | default(configuration_detected_interfaces[idx] | default('')) }}"
configuration_net_uuid: "{{ ('LAN-' ~ idx ~ '-' ~ hostname) | ansible.builtin.to_uuid }}"
ansible.builtin.template:
src: network.j2

View File

@@ -1,26 +0,0 @@
---
- name: Write dhcpcd configuration
ansible.builtin.copy:
dest: /mnt/etc/dhcpcd.conf
mode: "0644"
content: |
{% for iface in system_cfg.network.interfaces %}
{% set inv_name = iface.name | default('') | string %}
{% set det_name = configuration_detected_interfaces[loop.index0] | default('eth' ~ loop.index0) %}
{% set iface_name = inv_name if inv_name | length > 0 else det_name %}
{% set has_static = (iface.ip | default('') | string | length) > 0 %}
{% if has_static %}
interface {{ iface_name }}
static ip_address={{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
static routers={{ iface.gateway }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_list | length > 0 %}
static domain_name_servers={{ configuration_dns_list | join(' ') }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_search | length > 0 %}
static domain_search={{ configuration_dns_search | join(' ') }}
{% endif %}
{% endif %}
{% endfor %}

View File

@@ -1,25 +1,84 @@
---
- name: Write final sources.list
# Config runs against the chroot, so these write /mnt directly via templates
# rather than apt_repository/yum_repository, which would touch the live host.
- name: Write the apt sources.list
when: os_family == 'Debian'
vars:
_debian_release_map:
"10": buster
"11": bullseye
"12": bookworm
"13": trixie
unstable: sid
_ubuntu_release_map:
ubuntu: questing
ubuntu-lts: noble
ubuntu-lts: resolute
ansible.builtin.template:
src: "{{ os | replace('-lts', '') }}.sources.list.j2"
dest: /mnt/etc/apt/sources.list
mode: "0644"
- name: Ensure apt performance configuration persists
- name: Ensure apt performance and content-proxy configuration
when: os_family == 'Debian'
ansible.builtin.copy:
dest: /mnt/etc/apt/apt.conf.d/99performance
content: |
Acquire::Retries "3";
Acquire::http::Pipeline-Depth "10";
APT::Install-Recommends "false";
{% if system_cfg.content.proxy | length > 0 %}
Acquire::http::Proxy "{{ system_cfg.content.proxy }}";
Acquire::https::Proxy "{{ system_cfg.content.proxy }}";
{% endif %}
mode: "0644"
- name: Drop the install-time DVD repo from the target on non-dvd sources
when:
- os_family == 'RedHat'
- system_cfg.content.source != 'dvd'
ansible.builtin.file:
path: /mnt/etc/yum.repos.d/redhat.repo
state: absent
- name: Write the EL mirror repo on the target
when:
- os_family == 'RedHat'
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length > 0
ansible.builtin.template:
src: el_mirror.repo.j2
dest: "/mnt/etc/yum.repos.d/{{ os }}.repo"
mode: "0644"
- name: Find the stock vendor repos shipped by the release package
when:
- os_family == 'RedHat'
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length > 0
ansible.builtin.find:
paths: /mnt/etc/yum.repos.d
patterns: "*.repo"
excludes: "{{ os }}.repo"
register: el_stock_repos
- name: Remove the stock vendor repos so only the custom mirror is reachable
when:
- os_family == 'RedHat'
- system_cfg.content.source == 'mirror'
- system_cfg.content.url | length > 0
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ el_stock_repos.files | default([]) }}"
loop_control:
label: "{{ item.path }}"
- name: Configure the dnf content proxy on the target
when:
- os_family == 'RedHat'
- system_cfg.content.proxy | length > 0
ansible.builtin.lineinfile:
path: /mnt/etc/dnf/dnf.conf
line: "proxy={{ system_cfg.content.proxy }}"
regexp: "^proxy="
create: true
mode: "0644"
state: present

View File

@@ -0,0 +1,45 @@
---
# Invoked post-reboot on the booted host, not in the chroot: subscription-manager
# needs a running systemd and the live network.
- name: Add the Satellite host to /etc/hosts
when: system_cfg.content.satellite.ip | length > 0
ansible.builtin.lineinfile:
path: /etc/hosts
line: "{{ system_cfg.content.satellite.ip }} {{ system_cfg.content.satellite.host }}"
regexp: "[[:space:]]{{ system_cfg.content.satellite.host | regex_escape }}([[:space:]]|$)"
state: present
- name: Fetch the Katello CA consumer RPM
ansible.builtin.get_url:
url: >-
{{ system_cfg.content.satellite.ca_url
if (system_cfg.content.satellite.ca_url | length > 0)
else 'https://' ~ system_cfg.content.satellite.host ~ '/pub/katello-ca-consumer-latest.noarch.rpm' }}
dest: /tmp/katello-ca-consumer-latest.noarch.rpm
validate_certs: false
mode: "0644"
- name: Install the Katello CA consumer RPM
ansible.builtin.dnf:
name: /tmp/katello-ca-consumer-latest.noarch.rpm
state: present
disable_gpg_check: true
- name: Clean any stale subscription identity
ansible.builtin.command: subscription-manager clean
changed_when: true
- name: Register with Satellite via activation key
no_log: true
community.general.redhat_subscription:
state: present
server_hostname: "{{ system_cfg.content.satellite.host }}"
org_id: "{{ system_cfg.content.satellite.org }}"
activationkey: "{{ system_cfg.content.satellite.activation_key }}"
environment: "{{ system_cfg.content.satellite.environment | default(omit, true) }}"
force_register: true
server_proxy_hostname: "{{ (system_cfg.content.proxy | urlsplit('hostname')) | default(omit, true) }}"
server_proxy_port: "{{ (system_cfg.content.proxy | urlsplit('port')) | default(omit, true) }}"
syspurpose:
service_level_agreement: "{{ system_cfg.content.satellite.service_level | default(omit, true) }}"
sync: true

View File

@@ -1,4 +1,15 @@
---
- name: Validate Secure Boot is supported on this OS
ansible.builtin.assert:
that:
- os in ['archlinux', 'debian', 'ubuntu', 'ubuntu-lts',
'rhel', 'rocky', 'almalinux', 'fedora']
fail_msg: >-
Secure Boot is not supported on {{ os }} in this bootstrap. Supported:
Arch (sbctl) and Debian/Ubuntu/RHEL/Rocky/Alma/Fedora (shim). Disable
system.features.secure_boot.enabled or pick a supported OS.
quiet: true
- name: Configure shim-based Secure Boot
when: os != 'archlinux'
ansible.builtin.include_tasks: secure_boot/shim.yml

View File

@@ -11,6 +11,16 @@
register: configuration_setfiles_result
changed_when: configuration_setfiles_result.rc == 0
# setfiles in the chroot misses paths created at first boot (e.g. /var/lib/sss),
# leaving unlabeled_t files that block services under enforcing SELinux. Force a
# complete relabel on first boot; fixfiles consumes and removes the flag.
- name: Force a complete SELinux relabel on first boot
when: os in ['almalinux', 'rocky', 'rhel'] and system_cfg.features.selinux.enabled | bool
ansible.builtin.file:
path: /mnt/.autorelabel
state: touch
mode: "0644"
# Fedora: setfiles segfaults during bootstrap chroot relabeling, so SELinux
# is left permissive and expected to relabel on first boot.
- name: Disable SELinux

View File

@@ -1,105 +1,248 @@
---
- name: Resolve desktop facts
when: system_cfg.features.desktop.enabled | bool
vars:
_autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}"
ansible.builtin.set_fact:
# KDE resolves to the plasmalogin unit on Arch/Fedora44+ (Plasma 6.6), else sddm.
_desktop_dm: >-
{{
('plasmalogin'
if system_cfg.features.desktop.display_manager == 'plasma-login-manager'
else system_cfg.features.desktop.display_manager)
if (system_cfg.features.desktop.display_manager | length > 0)
else (
('plasmalogin'
if (os == 'archlinux' or (os == 'fedora' and (os_version | int) >= 44))
else 'sddm')
if system_cfg.features.desktop.environment == 'kde'
else (configuration_desktop_dm_map[system_cfg.features.desktop.environment] | default(''))
)
}}
_desktop_session: "{{ system_cfg.features.desktop.session | default('') }}"
# Explicit session wins, else the per-environment command. Single source of
# truth for the greetd assert, the config gate, and the template.
_greetd_session: >-
{{
system_cfg.features.desktop.session
if (system_cfg.features.desktop.session | default('') | length > 0)
else (configuration_desktop_session_cmd_map[system_cfg.features.desktop.environment] | default(''))
}}
_desktop_autologin_user: >-
{{
_autologin
if (_autologin | string | lower not in ['', 'false'] and _autologin in system_cfg.users)
else ''
}}
- name: Enable systemd services
when: _configuration_platform.init_system == 'systemd'
vars:
_desktop_dm: >-
{{
system_cfg.features.desktop.display_manager
if (system_cfg.features.desktop.display_manager | length > 0)
else (configuration_desktop_dm_map[system_cfg.features.desktop.environment] | default(''))
}}
configuration_systemd_services: >-
{{
['NetworkManager']
+ (['firewalld'] if system_cfg.features.firewall.backend == 'firewalld' and system_cfg.features.firewall.enabled | bool else [])
+ (['ufw'] if system_cfg.features.firewall.backend == 'ufw' and system_cfg.features.firewall.enabled | bool else [])
['NetworkManager', _configuration_platform.time_sync_service]
+ ([_configuration_platform.ssh_service] if system_cfg.features.ssh.enabled | bool else [])
+ (['logrotate', 'systemd-timesyncd'] if os == 'archlinux' else [])
+ ([_desktop_dm] if system_cfg.features.desktop.enabled | bool and _desktop_dm | length > 0 else [])
+ (['logrotate'] if os == 'archlinux' else [])
+ (['bluetooth'] if system_cfg.features.desktop.enabled | bool else [])
}}
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ item }}"
loop: "{{ configuration_systemd_services }}"
register: configuration_enable_service_result
changed_when: configuration_enable_service_result.rc == 0
failed_when: >-
configuration_enable_service_result.rc != 0
and 'No such file or directory' not in (configuration_enable_service_result.stderr | default(''))
and 'does not exist' not in (configuration_enable_service_result.stderr | default(''))
- name: Activate UFW firewall
when:
- system_cfg.features.firewall.backend == 'ufw'
- system_cfg.features.firewall.enabled | bool
ansible.builtin.command: "{{ chroot_command }} ufw --force enable"
register: _ufw_enable_result
changed_when: _ufw_enable_result.rc == 0
failed_when: false
- name: Check for the EL qemu-guest-agent RPC allow-list
ansible.builtin.stat:
path: /mnt/etc/sysconfig/qemu-ga
register: configuration_qga_sysconfig
- name: Set default systemd target to graphical
- name: Allow clone-stamping RPCs in the EL qemu-guest-agent allow-list
when: configuration_qga_sysconfig.stat.exists
ansible.builtin.replace:
path: /mnt/etc/sysconfig/qemu-ga
regexp: '^(FILTER_RPC_ARGS="--allow-rpcs=(?:(?!guest-exec)[^"])*)"'
replace: '\1,guest-exec,guest-exec-status,guest-file-open,guest-file-close,guest-file-read,guest-file-write"'
- name: Enable display manager for selected desktop
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl set-default graphical.target"
register: _desktop_target_result
changed_when: _desktop_target_result.rc == 0
- _desktop_dm | length > 0
- _desktop_dm != 'ly'
ansible.builtin.command: "{{ chroot_command }} systemctl enable {{ _desktop_dm }}"
register: configuration_enable_dm_result
changed_when: configuration_enable_dm_result.rc == 0
# Unlike optional services above, a missing/unenabled DM is fatal: chroot
# systemctl can exit 0 while only warning on stderr, so check both.
failed_when: >-
configuration_enable_dm_result.rc != 0
or 'No such file or directory' in (configuration_enable_dm_result.stderr | default(''))
or 'does not exist' in (configuration_enable_dm_result.stderr | default(''))
- name: Enable OpenRC services
when: _configuration_platform.init_system == 'openrc'
- name: Enable ly on its tty
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'ly'
vars:
configuration_openrc_services: >-
{{
['networking']
+ (['sshd'] if system_cfg.features.ssh.enabled | bool else [])
+ ([system_cfg.features.firewall.backend] if system_cfg.features.firewall.enabled | bool else [])
}}
_ly_tty: tty2
block:
- name: Ensure OpenRC runlevel directory exists
- name: Enable ly display manager
ansible.builtin.command: "{{ chroot_command }} systemctl enable ly@{{ _ly_tty }}.service"
register: configuration_enable_ly_result
changed_when: configuration_enable_ly_result.rc == 0
failed_when: >-
configuration_enable_ly_result.rc != 0
or 'No such file or directory' in (configuration_enable_ly_result.stderr | default(''))
or 'does not exist' in (configuration_enable_ly_result.stderr | default(''))
# ly drives the VT itself; mask getty so logind never spawns a login on that tty.
- name: Mask getty on ly's tty
ansible.builtin.command: "{{ chroot_command }} systemctl mask getty@{{ _ly_tty }}.service"
register: configuration_mask_getty_result
changed_when: configuration_mask_getty_result.rc == 0
failed_when: >-
configuration_mask_getty_result.rc != 0
and 'No such file or directory' not in (configuration_mask_getty_result.stderr | default(''))
and 'does not exist' not in (configuration_mask_getty_result.stderr | default(''))
- name: Set default systemd target
when: _configuration_platform.init_system == 'systemd'
vars:
_default_target: "{{ 'graphical.target' if system_cfg.features.desktop.enabled | bool else 'multi-user.target' }}"
ansible.builtin.command: "{{ chroot_command }} systemctl set-default {{ _default_target }}"
register: _set_default_target_result
changed_when: _set_default_target_result.rc == 0
- name: Enable PipeWire user services globally
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
ansible.builtin.command: "{{ chroot_command }} systemctl --global enable {{ item }}"
loop: "{{ configuration_desktop_audio_units }}"
register: _desktop_audio_result
changed_when: _desktop_audio_result.rc == 0
failed_when: >-
_desktop_audio_result.rc != 0
and 'No such file or directory' not in (_desktop_audio_result.stderr | default(''))
and 'does not exist' not in (_desktop_audio_result.stderr | default(''))
- name: Assert greetd has a real session command to launch
when:
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'greetd'
ansible.builtin.assert:
that:
- _greetd_session | length > 0
- not (_greetd_session | trim | regex_search('\\.desktop$'))
fail_msg: >-
greetd needs an executable session command, but the resolved command for desktop
environment '{{ system_cfg.features.desktop.environment }}' is
'{{ _greetd_session }}'. greetd suits wlroots compositors (sway, hyprland) that
launch from a plain command; kde/gnome ship a '.desktop' session and should use
their own display manager (sddm, gdm). Set features.desktop.session to an
executable, or pick a different display manager.
- name: Generate greetd configuration
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'greetd'
- _greetd_session | length > 0
block:
- name: Ensure greetd config directory exists
ansible.builtin.file:
path: /mnt/etc/runlevels/default
path: /mnt/etc/greetd
state: directory
mode: "0755"
- name: Check OpenRC init scripts
ansible.builtin.stat:
path: "/mnt/etc/init.d/{{ item }}"
loop: "{{ configuration_openrc_services }}"
register: configuration_openrc_service_stats
- name: Write greetd config.toml
ansible.builtin.template:
src: greetd-config.toml.j2
dest: /mnt/etc/greetd/config.toml
mode: "0644"
- name: Enable OpenRC services
ansible.builtin.file:
src: "/mnt/etc/init.d/{{ item.item }}"
dest: "/mnt/etc/runlevels/default/{{ item.item }}"
state: link
loop: "{{ configuration_openrc_service_stats.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
- name: Enable runit services
when: _configuration_platform.init_system == 'runit'
- name: Configure GDM autologin
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'gdm'
- _desktop_autologin_user | length > 0
vars:
configuration_runit_services: >-
{{
['dhcpcd']
+ (['sshd'] if system_cfg.features.ssh.enabled | bool else [])
+ ([system_cfg.features.firewall.backend] if system_cfg.features.firewall.enabled | bool else [])
}}
# Debian gdm3 reads daemon.conf; RedHat/Arch gdm read custom.conf.
_gdm_dir: "/mnt/etc/{{ 'gdm3' if os_family == 'Debian' else 'gdm' }}"
_gdm_conf: "{{ 'daemon.conf' if os_family == 'Debian' else 'custom.conf' }}"
block:
- name: Ensure runit service directory exists
- name: Ensure GDM config directory exists
ansible.builtin.file:
path: /mnt/var/service
path: "{{ _gdm_dir }}"
state: directory
mode: "0755"
- name: Check runit service definitions
ansible.builtin.stat:
path: "/mnt/etc/sv/{{ item }}"
loop: "{{ configuration_runit_services }}"
register: configuration_runit_service_stats
- name: Write GDM autologin config
ansible.builtin.template:
src: gdm-custom.conf.j2
dest: "{{ _gdm_dir }}/{{ _gdm_conf }}"
mode: "0644"
- name: Enable runit services
# SDDM and plasma-login-manager share the [Autologin] format and the KDE Wayland
# session; only the config dir differs (sddm.conf.d vs plasmalogin.conf.d).
- name: Configure SDDM / plasma-login-manager autologin
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm in ['sddm', 'plasmalogin']
- _desktop_autologin_user | length > 0
vars:
_autologin_conf_dir: "/mnt/etc/{{ 'plasmalogin.conf.d' if _desktop_dm == 'plasmalogin' else 'sddm.conf.d' }}"
block:
- name: Ensure KDE login-manager config directory exists
ansible.builtin.file:
src: "/mnt/etc/sv/{{ item.item }}"
dest: "/mnt/var/service/{{ item.item }}"
state: link
loop: "{{ configuration_runit_service_stats.results }}"
loop_control:
label: "{{ item.item }}"
when: item.stat.exists
path: "{{ _autologin_conf_dir }}"
state: directory
mode: "0755"
# Plasma 6 ships the Wayland session as plasma.desktop; Plasma 5 ships it as
# plasmawayland.desktop (plasma.desktop is the X11 session there). Pick the
# installed Wayland session so autologin never lands on X11.
- name: Discover installed KDE Wayland sessions
ansible.builtin.find:
paths: /mnt/usr/share/wayland-sessions
patterns: "plasma.desktop,plasmawayland.desktop"
register: _kde_wayland_sessions
- name: Resolve the KDE Wayland session file
ansible.builtin.set_fact:
_sddm_session: >-
{%- set names = _kde_wayland_sessions.files | map(attribute='path') | map('basename') | list -%}
{{ 'plasma.desktop' if 'plasma.desktop' in names else (names | first | default('')) }}
- name: Write KDE login-manager autologin drop-in
ansible.builtin.template:
src: sddm-autologin.conf.j2
dest: "{{ _autologin_conf_dir }}/10-autologin.conf"
mode: "0644"
# ly ships a flat (sectionless) config.ini; edit it in place to keep upstream
# defaults. Both keys are required: an unresolved session writes 'null', which
# disables autologin rather than leaving it half-configured.
- name: Configure ly autologin
when:
- _configuration_platform.init_system == 'systemd'
- system_cfg.features.desktop.enabled | bool
- _desktop_dm == 'ly'
- _desktop_autologin_user | length > 0
community.general.ini_file:
path: /mnt/etc/ly/config.ini
option: "{{ item.key }}"
value: "{{ item.value }}"
create: false
mode: "0644"
loop:
- key: auto_login_user
value: "{{ _desktop_autologin_user }}"
- key: auto_login_session
value: "{{ _greetd_session if (_greetd_session | length > 0) else 'null' }}"

View File

@@ -15,7 +15,8 @@
validate: /usr/sbin/visudo --check --file=%s
- name: Deploy per-user sudoers rules
when: item.value.sudo is defined and (item.value.sudo | string | length > 0)
# Jinja truthiness: bool true / a rule string => deploy; false / '' / unset => skip.
when: item.value.sudo | default(false)
vars:
configuration_sudoers_rule: >-
{{ item.value.sudo if item.value.sudo is string else 'ALL=(ALL) NOPASSWD: ALL' }}

View File

@@ -3,7 +3,8 @@
when: (system_cfg.root.password | default('') | string | length) > 0
ansible.builtin.shell: >-
set -o pipefail &&
echo 'root:{{ system_cfg.root.password | password_hash("sha512") }}' | {{ chroot_command }} /usr/sbin/chpasswd -e
echo 'root:{{ system_cfg.root.password if (system_cfg.root.password | string)[:1] == "$" else system_cfg.root.password | password_hash("sha512") }}'
| {{ chroot_command }} /usr/sbin/chpasswd -e
args:
executable: /bin/bash
register: configuration_root_result
@@ -26,11 +27,15 @@
- name: Create user accounts
vars:
configuration_user_group: "{{ _configuration_platform.user_group }}"
# plaintext is hashed; a pre-computed crypt hash ($6$/$y$/...) passes through.
configuration_user_pw: >-
{{ item.value.password if (item.value.password | string)[:1] == '$'
else item.value.password | password_hash('sha512') }}
configuration_useradd_cmd: >-
{{ chroot_command }} /usr/sbin/useradd --create-home --user-group
--uid {{ 1000 + _idx }}
--groups {{ configuration_user_group }} {{ item.key }}
{{ ('--password ' ~ (item.value.password | password_hash('sha512'))) if (item.value.password | default('') | string | length > 0) else '' }}
{{ ('--password ' ~ configuration_user_pw) if (item.value.password | default('') | string | length > 0) else '' }}
--shell {{ item.value.shell | default('/bin/bash') }}
ansible.builtin.command: "{{ configuration_useradd_cmd }}"
loop: "{{ system_cfg.users | dict2items }}"

View File

@@ -1,7 +1,7 @@
# Managed by Ansible.
{% set release = _debian_release_map[os_version | string] | default('trixie') %}
{% set mirror = system_cfg.mirror %}
{% set components = 'main contrib non-free' ~ (' non-free-firmware' if (os_version | string) not in ['10', '11'] else '') %}
{% set mirror = system_cfg.content.url | default('http://deb.debian.org/debian', true) %}
{% set components = 'main contrib non-free non-free-firmware' %}
deb {{ mirror }} {{ release }} {{ components }}
deb-src {{ mirror }} {{ release }} {{ components }}

View File

@@ -0,0 +1,17 @@
[{{ os }}{{ os_version_major }}-baseos]
name={{ os }} {{ os_version_major }} BaseOS
baseurl={{ system_cfg.content.url }}/BaseOS
enabled=1
gpgcheck={{ 1 if system_cfg.content.gpgcheck | bool else 0 }}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}
[{{ os }}{{ os_version_major }}-appstream]
name={{ os }} {{ os_version_major }} AppStream
baseurl={{ system_cfg.content.url }}/AppStream
enabled=1
gpgcheck={{ 1 if system_cfg.content.gpgcheck | bool else 0 }}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}

View File

@@ -0,0 +1,4 @@
[daemon]
WaylandEnable=true
AutomaticLoginEnable=true
AutomaticLogin={{ _desktop_autologin_user }}

View File

@@ -0,0 +1,12 @@
[terminal]
vt = 1
[default_session]
command = "tuigreet --time --remember --cmd {{ _greetd_session }}"
user = "greeter"
{% if _desktop_autologin_user | length > 0 %}
[initial_session]
command = "{{ _greetd_session }}"
user = "{{ _desktop_autologin_user }}"
{% endif %}

View File

@@ -3,12 +3,18 @@ id=LAN-{{ idx }}
uuid={{ configuration_net_uuid }}
type=ethernet
autoconnect-priority=10
{% if configuration_iface_name | length > 0 %}
interface-name={{ configuration_iface_name }}
{% endif %}
[ipv4]
{% set iface = configuration_iface %}
{% if iface.name | default('') | string | length %}
interface-name={{ iface.name }}
{% else %}
{# Bind the first available ethernet by name glob, never a MAC: a clone with a new adapter/MAC stays networked (#12). #}
[match]
interface-name=en*;eth*;
{% endif %}
[ipv4]
{% set dns_list = configuration_dns_list %}
{% set search_list = configuration_dns_search %}
{% if iface.ip | default('') | string | length %}

View File

@@ -0,0 +1,23 @@
auto lo
iface lo inet loopback
{% for iface in system_cfg.network.interfaces %}
{% set ifname = iface.name if (iface.name | default('') | string | length) else (configuration_eni_detected[loop.index0] | default('eth' ~ loop.index0)) %}
auto {{ ifname }}
{% if iface.ip | default('') | string | length %}
iface {{ ifname }} inet static
address {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
gateway {{ iface.gateway }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_list %}
dns-nameservers {{ configuration_dns_list | join(' ') }}
{% endif %}
{% if loop.index0 == 0 and configuration_dns_search %}
dns-search {{ configuration_dns_search | join(' ') }}
{% endif %}
{% else %}
iface {{ ifname }} inet dhcp
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,29 @@
network:
version: 2
ethernets:
{% for iface in system_cfg.network.interfaces %}
lan{{ loop.index0 }}:
{# Unnamed binds the first ethernet by name glob (e* = en*/eth*, netplan match.name takes one glob), never a MAC (#12). #}
match:
name: "{{ iface.name if (iface.name | default('') | string | length) else 'e*' }}"
{% if iface.ip | default('') | string | length %}
addresses:
- {{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
routes:
- to: default
via: {{ iface.gateway }}
{% endif %}
{% else %}
dhcp4: true
{% endif %}
{% if loop.index0 == 0 and (configuration_dns_list or configuration_dns_search) %}
nameservers:
{% if configuration_dns_list %}
addresses: [{{ configuration_dns_list | join(', ') }}]
{% endif %}
{% if configuration_dns_search %}
search: [{{ configuration_dns_search | join(', ') }}]
{% endif %}
{% endif %}
{% endfor %}

View File

@@ -0,0 +1,27 @@
[Match]
{% set iface = configuration_iface %}
{% if iface.name | default('') | string | length %}
Name={{ iface.name }}
{% else %}
{# First available ethernet by name glob + device type, never a MAC (#12). #}
Name=en* eth*
Type=ether
{% endif %}
[Network]
{% if iface.ip | default('') | string | length %}
Address={{ iface.ip }}/{{ iface.prefix }}
{% if iface.gateway | default('') | string | length %}
Gateway={{ iface.gateway }}
{% endif %}
{% else %}
DHCP=yes
{% endif %}
{% if idx | int == 0 and configuration_dns_list %}
{% for dns in configuration_dns_list %}
DNS={{ dns }}
{% endfor %}
{% if configuration_dns_search %}
Domains={{ configuration_dns_search | join(' ') }}
{% endif %}
{% endif %}

View File

@@ -0,0 +1,6 @@
{% set _session = _desktop_session if (_desktop_session | length > 0) else _sddm_session %}
[Autologin]
User={{ _desktop_autologin_user }}
{% if _session | length > 0 %}
Session={{ _session }}
{% endif %}

View File

@@ -1,6 +1,6 @@
# Managed by Ansible.
{% set release = _ubuntu_release_map[os] | default('noble') %}
{% set mirror = system_cfg.mirror %}
{% set release = _ubuntu_release_map[os] | default('resolute') %}
{% set mirror = system_cfg.content.url %}
{% set components = 'main restricted universe multiverse' %}
deb {{ mirror }} {{ release }} {{ components }}

View File

@@ -1,12 +1,11 @@
---
# Platform-specific configuration values keyed by os_family.
# Consumed as _configuration_platform in tasks via:
# configuration_platform_config[os_family]
# Keyed by os_family; tasks read configuration_platform_config[os_family] as _configuration_platform.
configuration_platform_config:
RedHat:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
time_sync_service: chronyd
efi_loader: shimx64.efi
grub_install: false
initramfs_cmd: "/usr/bin/dracut --regenerate-all --force"
@@ -17,6 +16,7 @@ configuration_platform_config:
user_group: sudo
sudo_group: "%sudo"
ssh_service: ssh
time_sync_service: chrony
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: >-
@@ -29,51 +29,27 @@ configuration_platform_config:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
time_sync_service: systemd-timesyncd
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: "/usr/sbin/mkinitcpio -P"
grub_mkconfig_prefix: grub-mkconfig
locale_gen: true
init_system: systemd
Suse:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: "/usr/bin/dracut --regenerate-all --force"
grub_mkconfig_prefix: grub-mkconfig
locale_gen: true
init_system: systemd
Alpine:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: ""
grub_mkconfig_prefix: grub-mkconfig
locale_gen: false
init_system: openrc
Void:
user_group: wheel
sudo_group: "%wheel"
ssh_service: sshd
efi_loader: grubx64.efi
grub_install: true
initramfs_cmd: ""
grub_mkconfig_prefix: grub-mkconfig
locale_gen: false
init_system: runit
# Display manager auto-detection from desktop environment name.
configuration_desktop_dm_map:
gnome: gdm
kde: sddm
xfce: lightdm
sway: greetd
hyprland: ly@tty2
cinnamon: lightdm
mate: lightdm
lxqt: sddm
budgie: gdm
hyprland: greetd
# greetd session commands for sway/hyprland (gnome/kde use a DM instead).
configuration_desktop_session_cmd_map:
sway: sway
hyprland: Hyprland
# pipewire/pipewire-pulse are socket-activated; wireplumber ships no socket.
configuration_desktop_audio_units:
- pipewire.socket
- pipewire-pulse.socket
- wireplumber.service

View File

@@ -1,10 +1,60 @@
---
# Connection and timing
environment_wait_timeout: 180
environment_wait_delay: 5
# Pacman installer settings
environment_parallel_downloads: 20
environment_pacman_lock_timeout: 120
environment_pacman_retries: 4
environment_pacman_retry_delay: 15
# Installer-tool libraries whose soname may have bumped past the ISO. Each one's
# installed reverse-deps are co-upgraded so the install stays a consistent
# transaction. Extend if a future transition breaks the install.
environment_partial_upgrade_libs:
- nettle
- leancrypto
# PCI vendor ID -> vendor code. Only vendors that drive distinct
# firmware/driver packages are mapped.
environment_pci_vendor_map:
"8086": intel
"1002": amd
"1022": amd
"10de": nvidia
"14e4": broadcom
"10ec": realtek
"168c": atheros
"0cf3": atheros
"168d": atheros
"14c3": mediatek
"11ab": marvell
"1b4b": marvell
"17cb": qcom
"105b": qcom
"1cf3": cirrus
"13d7": cirrus
# USB vendor IDs of fingerprint readers supported by libfprint / fprintd,
# matched against `lsusb` output.
environment_fingerprint_vendor_ids:
- "06cb" # Synaptics (modern ThinkPad/Dell)
- "138a" # Validity Sensors (older ThinkPad)
- "1c7a" # LighTuning / Egis
- "27c6" # Goodix
- "04f3" # Elan
- "0a5c" # Broadcom
- "08ff" # AuthenTec (legacy)
- "147e" # Upek (legacy)
- "1491" # Futronic
# USB vendor IDs of common Bluetooth controllers. A fallback: detection also
# matches the literal "Bluetooth" string in `lsusb` for adapters that omit it.
environment_bluetooth_vendor_ids:
- "8087" # Intel (AX2xx combo cards)
- "0a12" # Cambridge Silicon Radio (CSR)
- "0bda" # Realtek
- "0cf3" # Qualcomm Atheros
- "13d3" # IMC / AzureWave
- "0489" # Foxconn / Lite-On
- "04ca" # Lite-On
- "0b05" # ASUS

View File

@@ -0,0 +1,5 @@
---
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted

View File

@@ -0,0 +1,84 @@
---
# A user-supplied override profile skips detection (golden-image flow: bake an
# image with a fixed profile).
- name: Resolve hardware detection requirement
ansible.builtin.set_fact:
_hardware_detection_needed: >-
{{
(system_cfg.features.firmware.enabled | bool)
or (system_cfg.features.gpu.enabled | bool)
or (system_cfg.features.peripherals.enabled | bool)
}}
_hardware_profile_override: "{{ system_cfg.features.hardware.profile | default({}) }}"
- name: Use supplied hardware profile (override)
when:
- _hardware_detection_needed | bool
- _hardware_profile_override | length > 0
ansible.builtin.set_fact:
hardware_profile_active:
cpu: "{{ _hardware_profile_override.cpu | default('') | string | lower }}"
gpus: "{{ _hardware_profile_override.gpus | default([]) | map('lower') | list }}"
nvidia_supports_open: "{{ _hardware_profile_override.nvidia_supports_open | default(true) | bool }}"
wireless: "{{ _hardware_profile_override.wireless | default([]) | map('lower') | list }}"
audio: "{{ _hardware_profile_override.audio | default([]) | map('lower') | list }}"
fingerprint: "{{ _hardware_profile_override.fingerprint | default(false) | bool }}"
bluetooth: "{{ _hardware_profile_override.bluetooth | default(false) | bool }}"
camera:
uvc: "{{ _hardware_profile_override.camera.uvc | default(false) | bool }}"
ipu6: "{{ _hardware_profile_override.camera.ipu6 | default(false) | bool }}"
- name: Detect hardware from live host
when:
- _hardware_detection_needed | bool
- _hardware_profile_override | length == 0
block:
- name: Read CPU vendor
ansible.builtin.command: lscpu
register: _hardware_lscpu
changed_when: false
- name: Read PCI device list
ansible.builtin.command: lspci -nn
register: _hardware_lspci
changed_when: false
- name: Read USB device list
ansible.builtin.command: lsusb
register: _hardware_lsusb
changed_when: false
failed_when: false
- name: Resolve detected hardware profile
ansible.builtin.include_tasks: _resolve_hardware_profile.yml
- name: Initialize empty hardware profile when detection skipped
when: not (_hardware_detection_needed | bool)
ansible.builtin.set_fact:
hardware_profile_active:
cpu: ""
gpus: []
nvidia_supports_open: true
wireless: []
audio: []
fingerprint: false
bluetooth: false
camera: { uvc: false, ipu6: false }
- name: Merge declarative hardware group over detection
when: _hardware_detection_needed | bool
ansible.builtin.include_tasks: _merge_hardware_profile.yml
- name: Report active hardware profile
when: _hardware_detection_needed | bool
ansible.builtin.debug:
msg: >-
Hardware profile {{ 'override' if _hardware_profile_override | length > 0 else 'detected' }}:
cpu={{ hardware_profile_active.cpu | default('-') }},
gpus={{ hardware_profile_active.gpus | default([]) | join(',') | default('-', true) }}
{{ '(open-supported)' if hardware_profile_active.nvidia_supports_open | bool else '(legacy)' }},
wireless={{ hardware_profile_active.wireless | default([]) | join(',') | default('-', true) }},
audio={{ hardware_profile_active.audio | default([]) | join(',') | default('-', true) }},
fingerprint={{ hardware_profile_active.fingerprint | default(false) }},
bluetooth={{ hardware_profile_active.bluetooth | default(false) }},
camera={{ 'uvc' if hardware_profile_active.camera.uvc | default(false) else '' }}{{ '+ipu6' if hardware_profile_active.camera.ipu6 | default(false) else '' }}

View File

@@ -77,13 +77,10 @@
MaxStartups 50:30:100
ClientAliveInterval 30
ClientAliveCountMax 10
register: _sshd_config_result
notify: Restart sshd
- name: Restart sshd immediately if config was changed
when: _sshd_config_result is changed
ansible.builtin.service:
name: sshd
state: restarted
- name: Apply pending sshd restart before continuing
ansible.builtin.meta: flush_handlers
- name: Abort if the host is not booted from the Arch install media
when:

View File

@@ -0,0 +1,22 @@
---
# Supplements whatever profile is active (detected or full-override) rather than
# replacing it: vendor lists union, booleans OR, cpu overrides when set.
- name: Merge declarative hardware group over detection
vars:
_hw: "{{ system_cfg.features.hardware }}"
_det: "{{ hardware_profile_active }}"
ansible.builtin.set_fact:
hardware_profile_active:
cpu: "{{ (_hw.cpu | default('') | string | lower) if (_hw.cpu | default('') | length > 0) else _det.cpu }}"
gpus: "{{ ((_det.gpus | default([])) + (_hw.gpus | default([]) | map('lower') | list)) | unique | list }}"
nvidia_supports_open: "{{ _det.nvidia_supports_open | default(true) | bool }}"
wireless: "{{ ((_det.wireless | default([])) + (_hw.wireless | default([]) | map('lower') | list)) | unique | list }}"
audio: "{{ ((_det.audio | default([])) + (_hw.audio | default([]) | map('lower') | list)) | unique | list }}"
fingerprint: "{{ (_det.fingerprint | default(false) | bool) or (_hw.fingerprint | default(false) | bool) }}"
bluetooth: "{{ (_det.bluetooth | default(false) | bool) or (_hw.bluetooth | default(false) | bool) }}"
camera:
uvc: "{{ (_det.camera.uvc | default(false) | bool) or (_hw.camera.uvc | default(false) | bool) }}"
ipu6: "{{ (_det.camera.ipu6 | default(false) | bool) or (_hw.camera.ipu6 | default(false) | bool) }}"
_hardware_profile_packages: "{{ _hw.packages | default({}) }}"
_hardware_profile_disable: "{{ _hw.disable | default([]) | list }}"
_hardware_profile_kernel_params: "{{ _hw.kernel_params | default([]) | list }}"

View File

@@ -14,24 +14,52 @@
timeout: "{{ environment_pacman_lock_timeout }}"
changed_when: false
- name: Setup Pacman
- name: Resolve installer tools for the target OS
when: not (custom_iso | bool)
ansible.builtin.set_fact:
environment_installer_tools: >-
{{
['glibc']
+ (['lua', 'dnf'] if os in ['almalinux', 'fedora', 'rhel', 'rocky'] else [])
+ (['debootstrap'] if os in ['debian', 'ubuntu', 'ubuntu-lts'] else [])
+ (['debian-archive-keyring'] if os == 'debian' else [])
+ (['ubuntu-keyring'] if os in ['ubuntu', 'ubuntu-lts'] else [])
}}
- name: Query reverse-dependencies of transition-sensitive libraries
when:
- not (custom_iso | bool)
- item.os is not defined or os in item.os
- environment_partial_upgrade_libs | length > 0
ansible.builtin.command: "pacman -Qi {{ item }}"
loop: "{{ environment_partial_upgrade_libs }}"
register: environment_revdep_query
changed_when: false
failed_when: false
# Co-upgrade each transition library with its installed reverse-deps so a soname
# bump moves the whole closure in one transaction, not a partial upgrade.
- name: Setup Pacman
when: not (custom_iso | bool)
vars:
environment_pacman_closure: >-
{{
(
environment_installer_tools
+ (environment_revdep_query.results | default([])
| selectattr('rc', 'equalto', 0) | map(attribute='item') | list)
+ (environment_revdep_query.results | default([])
| selectattr('rc', 'equalto', 0) | map(attribute='stdout')
| map('regex_search', 'Required By\s*:\s*(.+)', '\1')
| map('first') | map('split') | flatten)
)
| reject('equalto', 'None') | unique
}}
community.general.pacman:
update_cache: true
force: true
name: "{{ item.name }}"
name: "{{ environment_pacman_closure }}"
state: latest
loop:
- { name: glibc }
- { name: lua, os: [almalinux, fedora, rhel, rocky] }
- { name: dnf, os: [almalinux, fedora, rhel, rocky] }
- { name: debootstrap, os: [debian, ubuntu, ubuntu-lts] }
- { name: debian-archive-keyring, os: [debian] }
- { name: ubuntu-keyring, os: [ubuntu, ubuntu-lts] }
loop_control:
label: "{{ item.name }}"
register: environment_tool_install
until: environment_tool_install is succeeded
retries: "{{ environment_pacman_retries }}"
delay: "{{ environment_pacman_retry_delay }}"
@@ -45,28 +73,25 @@
mode: "0755"
- name: Detect RHEL ISO device
ansible.builtin.command: lsblk -rno NAME,TYPE
ansible.builtin.command: lsblk -rbno NAME,TYPE,SIZE
register: environment_lsblk_result
changed_when: false
- name: Select RHEL ISO device
vars:
_rom_devices: >-
{{
environment_lsblk_result.stdout_lines
| map('split', ' ')
| selectattr('1', 'equalto', 'rom')
| map('first')
| map('regex_replace', '^', '/dev/')
| list
}}
_roms: >-
{%- set out = [] -%}
{%- for line in environment_lsblk_result.stdout_lines -%}
{%- set p = line.split() -%}
{%- if (p | length) >= 3 and p[1] == 'rom' -%}
{%- set _ = out.append({'name': p[0], 'size': p[2] | int}) -%}
{%- endif -%}
{%- endfor -%}
{{ out }}
ansible.builtin.set_fact:
environment_rhel_iso_device: >-
{{
_rom_devices[-1]
if _rom_devices | length > 1
else (_rom_devices[0] | default('/dev/sr1'))
}}
{{ ('/dev/' ~ (_roms | sort(attribute='size') | last).name)
if (_roms | length) > 0 else '/dev/sr1' }}
- name: Mount RHEL ISO
ansible.posix.mount:
@@ -76,10 +101,8 @@
opts: "ro,loop"
state: mounted
# Security note: RPM Sequoia signature policy is relaxed to allow
# bootstrapping RHEL-family distros from the Arch ISO, where the
# host rpm/dnf does not trust target distro GPG keys. Package
# integrity is verified by the target system's own rpm after reboot.
# RPM Sequoia signature policy is relaxed because the Arch ISO host does not
# trust target-distro GPG keys; the target's own rpm re-verifies after reboot.
- name: Create RPM macros directory
when: is_rhel | bool
ansible.builtin.file:

View File

@@ -0,0 +1,57 @@
---
# Split out of _detect_hardware.yml so fixtures can seed the lscpu/lspci/lsusb
# registers and assert the result with no real hardware. Keep regex exprs
# double-quoted single-line: ansible-core 2.21 set_fact mangles backslash escapes
# inside folded (>-) scalars.
- name: Resolve detected hardware profile
vars:
_vendor_keys: "{{ environment_pci_vendor_map.keys() | list }}"
_cpu_vendor_raw: "{{ _hardware_lscpu.stdout | regex_findall('(?im)^Vendor ID:\\s*(\\S+)') | first | default('') }}"
_cpu_vendor: >-
{{
'intel' if _cpu_vendor_raw == 'GenuineIntel'
else ('amd' if _cpu_vendor_raw == 'AuthenticAMD' else '')
}}
# PCI classes: 0300 = VGA, 0302 = 3D, 0280 = wireless network controller.
_gpu_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[(0300|0302)\\]:') | list }}"
_gpu_pairs: "{{ (_gpu_lines | join('\n')) | regex_findall('\\[([0-9a-f]{4}):([0-9a-f]{4})\\]') | list }}"
_gpu_vendor_ids: "{{ _gpu_pairs | map('first') | select('in', _vendor_keys) | list }}"
_gpu_vendors: "{{ _gpu_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
_nvidia_device_ids: "{{ _gpu_pairs | selectattr('0', 'equalto', '10de') | map(attribute=1) | list }}"
_nvidia_min_id: >-
{{
(_nvidia_device_ids | map('int', base=16) | list | min)
if _nvidia_device_ids | length > 0 else 0
}}
# 0x1e00 = 7680 = first Turing device id; Turing+ supports nvidia-open.
_nvidia_supports_open: "{{ _nvidia_device_ids | length > 0 and (_nvidia_min_id | int) >= 7680 }}"
_wifi_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[0280\\]:') | list }}"
_wifi_vendor_ids: "{{ (_wifi_lines | join('\n')) | regex_findall('\\[([0-9a-f]{4}):[0-9a-f]{4}\\]') | select('in', _vendor_keys) | list }}"
_wifi_vendors: "{{ _wifi_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
# PCI class 0403 = audio device (HD-audio controller). Vendor drives SOF/firmware.
_audio_lines: "{{ _hardware_lspci.stdout_lines | select('search', '\\[0403\\]:') | list }}"
_audio_vendor_ids: "{{ (_audio_lines | join('\n')) | regex_findall('\\[([0-9a-f]{4}):[0-9a-f]{4}\\]') | select('in', _vendor_keys) | list }}"
_audio_vendors: "{{ _audio_vendor_ids | map('extract', environment_pci_vendor_map) | unique | list }}"
_fingerprint_present: "{{ (_hardware_lsusb.stdout | default('')) | regex_search('(?i)ID (' ~ (environment_fingerprint_vendor_ids | join('|')) ~ '):') is not none }}"
_camera_uvc_present: "{{ (_hardware_lsusb.stdout | default('')) is search('(?i)camera|webcam') }}"
# Intel IPU6 MIPI camera: PCI class 0480 (multimedia) under Intel 8086, or an ISP description. Out-of-tree userspace.
_camera_ipu6_desc: "{{ (_hardware_lspci.stdout | default('')) is search('(?i)image signal processor|IPU6') }}"
_camera_ipu6_pci: "{{ (_hardware_lspci.stdout_lines | select('search', '\\[0480\\]:') | select('search', '\\[8086:') | list) | length > 0 }}"
# No backslash escapes here, so a folded scalar is safe (unlike the \[..\] regexes above).
_bluetooth_present: >-
{{
((_hardware_lsusb.stdout | default('')) | regex_search('(?i)ID (' ~ (environment_bluetooth_vendor_ids | join('|')) ~ '):') is not none)
or ((_hardware_lsusb.stdout | default('')) is search('(?i)bluetooth'))
}}
ansible.builtin.set_fact:
hardware_profile_active:
cpu: "{{ _cpu_vendor }}"
gpus: "{{ _gpu_vendors }}"
nvidia_supports_open: "{{ _nvidia_supports_open | bool }}"
wireless: "{{ _wifi_vendors }}"
audio: "{{ _audio_vendors }}"
fingerprint: "{{ _fingerprint_present | bool }}"
bluetooth: "{{ _bluetooth_present | bool }}"
camera:
uvc: "{{ _camera_uvc_present | bool }}"
ipu6: "{{ (_camera_ipu6_desc | bool) or (_camera_ipu6_pci | bool) }}"

View File

@@ -11,5 +11,8 @@
- name: Prepare installer environment
ansible.builtin.include_tasks: _prepare_installer.yml
- name: Detect hardware for firmware/GPU package selection
ansible.builtin.include_tasks: _detect_hardware.yml
- name: Run third-party preparation tasks
ansible.builtin.include_tasks: _thirdparty.yml

View File

@@ -1,9 +1,10 @@
# gpgcheck off: bootstrap-time only; the Arch live env has no AlmaLinux key.
[appstream]
name=AlmaLinux $releasever - AppStream
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/appstream
# baseurl=https://repo.almalinux.org/almalinux/$releasever/AppStream/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400
@@ -14,7 +15,7 @@ name=AlmaLinux $releasever - BaseOS
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/baseos
# baseurl=https://repo.almalinux.org/almalinux/$releasever/BaseOS/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400
@@ -25,7 +26,7 @@ name=AlmaLinux $releasever - Extras
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/extras
# baseurl=https://repo.almalinux.org/almalinux/$releasever/extras/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400
@@ -36,7 +37,7 @@ name=AlmaLinux $releasever - HighAvailability
mirrorlist=https://mirrors.almalinux.org/mirrorlist/$releasever/highavailability
# baseurl=https://repo.almalinux.org/almalinux/$releasever/HighAvailability/$basearch/os/
enabled=1
gpgcheck=1
gpgcheck=0
countme=1
gpgkey=https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-$releasever
metadata_expire=86400

View File

@@ -1,13 +1,24 @@
{% set _baseurl = system_cfg.content.url if system_cfg.content.source == 'mirror' else 'file:///usr/local/install/redhat/dvd' %}
[rhel{{ os_version_major }}-baseos]
name=RHEL {{ os_version_major }} BaseOS
baseurl=file:///usr/local/install/redhat/dvd/BaseOS
baseurl={{ _baseurl }}/BaseOS
enabled=1
gpgcheck=0
{% if system_cfg.content.source != 'mirror' %}
gpgkey=file:///usr/local/install/redhat/dvd/RPM-GPG-KEY-redhat-release
{% endif %}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}
[rhel{{ os_version_major }}-appstream]
name=RHEL {{ os_version_major }} AppStream
baseurl=file:///usr/local/install/redhat/dvd/AppStream
baseurl={{ _baseurl }}/AppStream
enabled=1
gpgcheck=0
{% if system_cfg.content.source != 'mirror' %}
gpgkey=file:///usr/local/install/redhat/dvd/RPM-GPG-KEY-redhat-release
{% endif %}
{% if system_cfg.content.proxy | length > 0 %}
proxy={{ system_cfg.content.proxy }}
{% endif %}

View File

@@ -1,8 +1,9 @@
# gpgcheck off: bootstrap-time only; the Arch live env has no Rocky key.
[baseos]
name=Rocky Linux $releasever - BaseOS
mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=BaseOS-$releasever
#baseurl=http://dl.rockylinux.org/$contentdir/$releasever/BaseOS/$basearch/os/
gpgcheck=1
gpgcheck=0
enabled=1
countme=1
gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever
@@ -13,7 +14,7 @@ enabled_metadata=1
name=Rocky Linux $releasever - AppStream
mirrorlist=https://mirrors.rockylinux.org/mirrorlist?arch=$basearch&repo=AppStream-$releasever
#baseurl=http://dl.rockylinux.org/$contentdir/$releasever/AppStream/$basearch/os/
gpgcheck=1
gpgcheck=0
enabled=1
countme=1
gpgkey=https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-$releasever

View File

@@ -1,5 +1,4 @@
---
# OS family lists — single source of truth for platform detection and validation
os_family_rhel:
- almalinux
- fedora
@@ -10,33 +9,26 @@ os_family_debian:
- ubuntu
- ubuntu-lts
# OS family mapping — aligns with the main project's ansible_os_family pattern.
# Enables platform_config dict lookups per role instead of inline when: is_rhel chains.
# OS -> family, so roles do platform_config lookups instead of is_rhel when-chains.
os_family_map:
almalinux: RedHat
alpine: Alpine
archlinux: Archlinux
debian: Debian
fedora: RedHat
opensuse: Suse
rhel: RedHat
rocky: RedHat
ubuntu: Debian
ubuntu-lts: Debian
void: Void
os_supported:
- almalinux
- alpine
- archlinux
- debian
- fedora
- opensuse
- rhel
- rocky
- ubuntu
- ubuntu-lts
- void
# User input. Normalized into hypervisor_cfg + hypervisor_type.
hypervisor:
@@ -46,6 +38,8 @@ hypervisor_defaults:
url: ""
username: ""
password: ""
token_id: ""
token_secret: ""
node: ""
storage: ""
datacenter: ""
@@ -64,6 +58,8 @@ system_defaults:
version: ""
filesystem: "ext4"
name: ""
# consumed by the golden produce/deploy wrappers, not the bootstrap itself
source: ""
id: ""
cpus: 0
memory: 0 # MiB
@@ -82,7 +78,22 @@ system_defaults:
timezone: "Europe/Vienna"
locale: "en_US.UTF-8"
keymap: "us"
mirror: ""
# source: dvd|mirror|satellite|none ('' -> family default: EL=dvd, else mirror).
# satellite values come from inventory/vault only, never committed code.
content:
source: ""
url: ""
proxy: ""
gpgcheck: true
satellite:
host: ""
ip: "" # optional /etc/hosts entry when DNS does not resolve host
org: ""
activation_key: ""
ca_url: ""
service_level: ""
environment: ""
install: false
packages: []
disks: []
users: {}
@@ -106,16 +117,19 @@ system_defaults:
iter: 4000
bits: 512
pbkdf: "argon2id"
urandom: true
verify: true
features:
# On only for the clone-deploy golden path; off keeps ansible-direct + smaller image.
cloud_init: false
cis:
enabled: false
profile: default # default|l1|l2 (default = current house behaviour)
rules: {} # per-rule overrides, e.g. {usb_lockdown: false}
params: {} # parameter overrides, e.g. {pwquality_minlen: 16}
selinux:
enabled: true
firewall:
enabled: true
backend: "firewalld" # firewalld|ufw
backend: "" # '' -> family default (EL/arch=firewalld, debian/ubuntu=ufw); override: firewalld|ufw
toolkit: "nftables" # nftables|iptables
ssh:
enabled: true
@@ -126,30 +140,52 @@ system_defaults:
banner:
motd: false
sudo: true
rhel_repo:
source: "iso" # iso|satellite|none — how RHEL systems get packages post-install
url: "" # Satellite/custom repo URL when source=satellite
aur:
enabled: false
helper: "yay" # yay|paru
user: "_aur_builder"
chroot:
tool: "arch-chroot" # arch-chroot|chroot|systemd-nspawn
initramfs:
generator: "" # auto-detected; override: dracut|mkinitcpio|initramfs-tools
desktop:
enabled: false
environment: "" # gnome|kde|xfce|sway|hyprland|cinnamon|mate|lxqt|budgie
display_manager: "" # auto from environment when empty; override: gdm|sddm|lightdm|greetd
environment: "" # gnome|kde|sway|hyprland
display_manager: "" # auto from environment when empty; override: gdm|sddm|greetd|plasma-login-manager|ly
autologin: false # false | username from system.users
session: "" # session name/command for the autologin user
groups: [] # opt-in package groups (keys of desktop_package_groups)
secure_boot:
enabled: false
method: "" # arch only: sbctl (default) or uki; ignored for other distros
firmware:
enabled: "auto" # auto = on for physical, off for virtual
microcode: "auto"
gpu:
enabled: "auto" # auto = follows desktop.enabled
nvidia_driver: "auto" # auto | open | proprietary | nouveau
peripherals:
enabled: "auto" # auto = follows desktop.enabled
fingerprint: "auto" # auto|true|false (auto = install when detected)
camera: "auto" # v4l-utils when a UVC/IPU6 camera is detected
audio: "auto" # SOF firmware + ALSA UCM when an audio device is present
bluetooth: "auto" # bluez when a Bluetooth controller is present
displaylink: false
hardware:
profile: {} # full override: non-empty SKIPS detection (golden image)
# The keys below MERGE over detection: lists union, booleans OR, packages
# and kernel_params append, disable[] force-off applied last.
cpu: "" # pin a CPU vendor (intel|amd); empty = use detection
gpus: [] # extra GPU vendor codes to force
wireless: [] # extra wireless vendor codes to force
audio: [] # extra audio vendor codes to force
camera: {} # {uvc: true, ipu6: true} to force a camera kind
fingerprint: false # force-on a fingerprint reader detection missed
bluetooth: false # force-on a Bluetooth controller detection missed
packages: {} # per-os_family extra packages, e.g. {Archlinux: [intel-ipu6-dkms]}
disable: [] # feature/vendor names to force-off (audio|bluetooth|camera|fingerprint|displaylink|<vendor>)
kernel_params: [] # extra kernel cmdline params (quirks), e.g. ["i915.enable_psr=0"]
# Per-hypervisor required fields — drives data-driven validation.
# All virtual types additionally require network bridge or interfaces.
# Drives data-driven validation. Virtual types also require a network bridge or interfaces.
hypervisor_required_fields:
proxmox:
hypervisor: [url, username, password, node, storage]
hypervisor: [url, username, node, storage]
system: [id]
vmware:
hypervisor: [url, username, password, datacenter, storage]
@@ -161,15 +197,20 @@ hypervisor_required_fields:
hypervisor: []
system: []
# Hypervisor-to-disk device prefix mapping for virtual machines.
# Physical installs must set system.disks[].device explicitly.
# Used when content.url is empty.
content_mirror_defaults:
debian: "https://deb.debian.org/debian/"
ubuntu: "http://archive.ubuntu.com/ubuntu/"
ubuntu-lts: "http://archive.ubuntu.com/ubuntu/"
# Virtual-only; physical installs must set system.disks[].device explicitly.
hypervisor_disk_device_map:
libvirt: "/dev/vd"
xen: "/dev/xvd"
proxmox: "/dev/sd"
vmware: "/dev/sd"
# Mountpoints managed by the partitioning role forbidden for extra disks.
# Mountpoints managed by the partitioning role - forbidden for extra disks.
reserved_mounts:
- /boot
- /boot/efi

View File

@@ -0,0 +1,25 @@
---
# Shared by both the fresh-run path (_normalize_system.yml) and the pre-computed
# enrichment path (system.yml) so the family-default rules live in one place.
- name: Apply family defaults to system_cfg
vars:
_os: "{{ system_cfg.os | default('') | string | lower }}"
ansible.builtin.set_fact:
system_cfg: >-
{{
system_cfg | combine({
'content': {
'source': system_cfg.content.source
if (system_cfg.content.source | default('') | string | trim | length > 0)
else ('dvd' if _os == 'rhel' else 'mirror'),
'url': system_cfg.content.url
if (system_cfg.content.url | default('') | string | trim | length > 0)
else (content_mirror_defaults[_os] | default('')),
},
'features': {'firewall': {'backend':
system_cfg.features.firewall.backend
if (system_cfg.features.firewall.backend | default('') | string | trim | length > 0)
else ('ufw' if _os in os_family_debian else 'firewalld')
}},
}, recursive=True)
}}

View File

@@ -10,39 +10,49 @@
if (system_raw.name | default('') | string | trim | length) > 0
else inventory_hostname
}}
_mirror_defaults:
debian: "https://deb.debian.org/debian/"
ubuntu: "http://archive.ubuntu.com/ubuntu/"
ubuntu-lts: "http://archive.ubuntu.com/ubuntu/"
ansible.builtin.set_fact:
system_cfg:
# --- Identity & platform ---
type: "{{ system_type }}"
os: "{{ system_os_input if system_os_input | length > 0 else (physical_default_os if system_type == 'physical' else '') }}"
version: "{{ system_raw.version | default('') | string }}"
filesystem: "{{ system_raw.filesystem | default('') | string | lower }}"
name: "{{ system_name }}"
id: "{{ system_raw.id | default('') | string }}"
# --- VM sizing (ignored for physical) ---
cpus: "{{ [system_raw.cpus | default(0) | int, 0] | max }}"
memory: "{{ [system_raw.memory | default(0) | int, 0] | max }}"
balloon: "{{ [system_raw.balloon | default(0) | int, 0] | max }}"
# --- Network ---
# Flat fields (bridge, ip, etc.) and interfaces[] are mutually exclusive.
# When interfaces[] is set, flat fields are populated from the first
# interface in the "Populate primary network fields" task below.
# When only flat fields are set, a synthetic interfaces[] entry is built.
# Flat fields and interfaces[] describe the same primary NIC: each is
# backfilled from the other so consumers reading either form still work.
network:
bridge: "{{ system_raw.network.bridge | default('') | string }}"
bridge: >-
{{
(system_raw.network.bridge | default('') | string)
if (system_raw.network.bridge | default('') | string | length) > 0
else (system_raw.network.interfaces[0].bridge | default('') | string
if (system_raw.network.interfaces | default([]) | length) > 0 else '')
}}
vlan: "{{ system_raw.network.vlan | default('') | string }}"
ip: "{{ system_raw.network.ip | default('') | string }}"
ip: >-
{{
(system_raw.network.ip | default('') | string)
if (system_raw.network.ip | default('') | string | length) > 0
else (system_raw.network.interfaces[0].ip | default('') | string
if (system_raw.network.interfaces | default([]) | length) > 0 else '')
}}
prefix: >-
{{
(system_raw.network.prefix | int | string)
if (system_raw.network.prefix | default('') | string | length) > 0
else ''
else (system_raw.network.interfaces[0].prefix | default('') | string
if (system_raw.network.interfaces | default([]) | length) > 0 else '')
}}
gateway: >-
{{
(system_raw.network.gateway | default('') | string)
if (system_raw.network.gateway | default('') | string | length) > 0
else (system_raw.network.interfaces[0].gateway | default('') | string
if (system_raw.network.interfaces | default([]) | length) > 0 else '')
}}
gateway: "{{ system_raw.network.gateway | default('') | string }}"
dns:
servers: "{{ system_raw.network.dns.servers | default([]) }}"
search: "{{ system_raw.network.dns.search | default([]) }}"
@@ -67,16 +77,24 @@
else []
)
}}
# --- Locale & environment ---
timezone: "{{ system_raw.timezone | string }}"
locale: "{{ system_raw.locale | string }}"
keymap: "{{ system_raw.keymap | string }}"
mirror: >-
{{
system_raw.mirror | string | trim
if (system_raw.mirror | default('') | string | trim | length) > 0
else _mirror_defaults[system_raw.os | default('') | string | lower] | default('')
}}
content:
# Family defaults for empty source/url are applied by _apply_family_defaults.yml.
source: "{{ system_raw.content.source | default('') | string | lower | trim }}"
url: "{{ system_raw.content.url | default('') | string | trim }}"
proxy: "{{ system_raw.content.proxy | default('') | string | trim }}"
gpgcheck: "{{ system_raw.content.gpgcheck | default(true) | bool }}"
satellite:
host: "{{ system_raw.content.satellite.host | default('') | string | trim }}"
ip: "{{ system_raw.content.satellite.ip | default('') | string | trim }}"
org: "{{ system_raw.content.satellite.org | default('') | string }}"
activation_key: "{{ system_raw.content.satellite.activation_key | default('') | string }}"
ca_url: "{{ system_raw.content.satellite.ca_url | default('') | string | trim }}"
service_level: "{{ system_raw.content.satellite.service_level | default('') | string }}"
environment: "{{ system_raw.content.satellite.environment | default('') | string }}"
install: "{{ system_raw.content.satellite.install | default(false) | bool }}"
path: >-
{{
(system_raw.path | default('') | string)
@@ -94,13 +112,11 @@
| reject('equalto', '')
| list
}}
# --- Storage & accounts ---
disks: "{{ system_raw.disks | default([]) }}"
users: "{{ system_raw.users | default({}) }}"
root:
password: "{{ system_raw.root.password | string }}"
shell: "{{ system_raw.root.shell | default('/bin/bash') | string }}"
# --- LUKS disk encryption ---
luks:
enabled: "{{ system_raw.luks.enabled | bool }}"
passphrase: "{{ system_raw.luks.passphrase | string }}"
@@ -118,17 +134,19 @@
iter: "{{ system_raw.luks.iter | int }}"
bits: "{{ system_raw.luks.bits | int }}"
pbkdf: "{{ system_raw.luks.pbkdf | string }}"
urandom: "{{ system_raw.luks.urandom | bool }}"
verify: "{{ system_raw.luks.verify | bool }}"
# --- Feature flags ---
features:
cloud_init: "{{ system_raw.features.cloud_init | default(false) | bool }}"
cis:
enabled: "{{ system_raw.features.cis.enabled | bool }}"
profile: "{{ system_raw.features.cis.profile | default('default') | string }}"
rules: "{{ system_raw.features.cis.rules | default({}) }}"
params: "{{ system_raw.features.cis.params | default({}) }}"
selinux:
enabled: "{{ system_raw.features.selinux.enabled | bool }}"
firewall:
enabled: "{{ system_raw.features.firewall.enabled | bool }}"
backend: "{{ system_raw.features.firewall.backend | string | lower }}"
# Empty backend is family-resolved by _apply_family_defaults.yml.
backend: "{{ system_raw.features.firewall.backend | default('') | string | lower | trim }}"
toolkit: "{{ system_raw.features.firewall.toolkit | string | lower }}"
ssh:
enabled: "{{ system_raw.features.ssh.enabled | bool }}"
@@ -139,9 +157,6 @@
banner:
motd: "{{ system_raw.features.banner.motd | bool }}"
sudo: "{{ system_raw.features.banner.sudo | bool }}"
rhel_repo:
source: "{{ system_raw.features.rhel_repo.source | default('iso') | string | lower }}"
url: "{{ system_raw.features.rhel_repo.url | default('') | string }}"
chroot:
tool: "{{ system_raw.features.chroot.tool | string }}"
initramfs:
@@ -150,9 +165,82 @@
enabled: "{{ system_raw.features.desktop.enabled | bool }}"
environment: "{{ system_raw.features.desktop.environment | default('') | string | lower }}"
display_manager: "{{ system_raw.features.desktop.display_manager | default('') | string | lower }}"
autologin: "{{ system_raw.features.desktop.autologin | default(false) }}"
session: "{{ system_raw.features.desktop.session | default('') | string }}"
groups: "{{ system_raw.features.desktop.groups | default([]) }}"
secure_boot:
enabled: "{{ system_raw.features.secure_boot.enabled | bool }}"
method: "{{ system_raw.features.secure_boot.method | default('') | string | lower }}"
firmware:
enabled: >-
{{
(system_type == 'physical')
if (system_raw.features.firmware.enabled | string | lower) == 'auto'
else (system_raw.features.firmware.enabled | bool)
}}
microcode: >-
{{
(
(system_type == 'physical')
if (system_raw.features.firmware.enabled | string | lower) == 'auto'
else (system_raw.features.firmware.enabled | bool)
)
if (system_raw.features.firmware.microcode | string | lower) == 'auto'
else (system_raw.features.firmware.microcode | bool)
}}
gpu:
enabled: >-
{{
(system_raw.features.desktop.enabled | bool)
if (system_raw.features.gpu.enabled | string | lower) == 'auto'
else (system_raw.features.gpu.enabled | bool)
}}
nvidia_driver: "{{ system_raw.features.gpu.nvidia_driver | default('auto') | string | lower }}"
peripherals:
enabled: >-
{{
(system_raw.features.desktop.enabled | bool)
if (system_raw.features.peripherals.enabled | string | lower) == 'auto'
else (system_raw.features.peripherals.enabled | bool)
}}
# Kept tri-state ('auto'|'true'|'false'): 'auto' resolves at install time from detection.
fingerprint: >-
{{
'auto'
if (system_raw.features.peripherals.fingerprint | string | lower) == 'auto'
else (system_raw.features.peripherals.fingerprint | bool | string | lower)
}}
camera: >-
{{
'auto'
if (system_raw.features.peripherals.camera | string | lower) == 'auto'
else (system_raw.features.peripherals.camera | bool | string | lower)
}}
audio: >-
{{
'auto'
if (system_raw.features.peripherals.audio | string | lower) == 'auto'
else (system_raw.features.peripherals.audio | bool | string | lower)
}}
bluetooth: >-
{{
'auto'
if (system_raw.features.peripherals.bluetooth | string | lower) == 'auto'
else (system_raw.features.peripherals.bluetooth | bool | string | lower)
}}
displaylink: "{{ system_raw.features.peripherals.displaylink | bool }}"
hardware:
profile: "{{ system_raw.features.hardware.profile | default({}) }}"
cpu: "{{ system_raw.features.hardware.cpu | default('') | string }}"
gpus: "{{ system_raw.features.hardware.gpus | default([]) | list }}"
wireless: "{{ system_raw.features.hardware.wireless | default([]) | list }}"
audio: "{{ system_raw.features.hardware.audio | default([]) | list }}"
camera: "{{ system_raw.features.hardware.camera | default({}) }}"
fingerprint: "{{ system_raw.features.hardware.fingerprint | default(false) | bool }}"
bluetooth: "{{ system_raw.features.hardware.bluetooth | default(false) | bool }}"
packages: "{{ system_raw.features.hardware.packages | default({}) }}"
disable: "{{ system_raw.features.hardware.disable | default([]) | list }}"
kernel_params: "{{ system_raw.features.hardware.kernel_params | default([]) | list }}"
hostname: "{{ system_name }}"
os: "{{ system_os_input if system_os_input | length > 0 else (physical_default_os if system_type == 'physical' else '') }}"
os_version: "{{ system_raw.version | default('') | string }}"

View File

@@ -44,7 +44,7 @@
label: "system.features.{{ item }}"
ansible.builtin.assert:
that:
- (system.features[item] | default({})) is mapping
- (system_defaults.features[item] is not mapping) or ((system.features[item] | default({})) is mapping)
fail_msg: "system.features.{{ item }} must be a dictionary."
quiet: true

View File

@@ -1,8 +1,6 @@
---
# Centralized normalization — all input dicts (system, hypervisor, disks)
# are normalized here into system_cfg, hypervisor_cfg, etc.
# Downstream roles consume these computed facts directly and do NOT need
# per-role _normalize.yml (except CIS, which has its own input dict).
# Normalizes all input dicts into system_cfg/hypervisor_cfg/etc. here, so downstream
# roles consume the computed facts directly with no per-role _normalize (except CIS).
- name: Global defaults loaded
ansible.builtin.debug:
msg: Global defaults loaded.
@@ -27,11 +25,15 @@
_proxmox_auth:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
api_password: "{{ hypervisor_cfg.password }}"
api_password: "{{ hypervisor_cfg.password | default(omit, true) }}"
api_token_id: "{{ hypervisor_cfg.token_id | default(omit, true) }}"
api_token_secret: "{{ hypervisor_cfg.token_secret | default(omit, true) }}"
_proxmox_auth_node:
api_host: "{{ hypervisor_cfg.url }}"
api_user: "{{ hypervisor_cfg.username }}"
api_password: "{{ hypervisor_cfg.password }}"
api_password: "{{ hypervisor_cfg.password | default(omit, true) }}"
api_token_id: "{{ hypervisor_cfg.token_id | default(omit, true) }}"
api_token_secret: "{{ hypervisor_cfg.token_secret | default(omit, true) }}"
node: "{{ hypervisor_cfg.node }}"
no_log: true
@@ -61,6 +63,12 @@
ansible.builtin.set_fact:
os_version_major: "{{ (os_version | string).split('.')[0] }}"
# EL>=10 and Fedora dropped the static /etc/pam.d/system-auth shipped by pam;
# the PAM stack is generated by authselect and absent until a profile is selected.
- name: Flag authselect-managed PAM stacks
ansible.builtin.set_fact:
is_authselect: "{{ is_rhel | bool and (os_version_major | default('0') | int) >= 10 }}"
- name: Set chroot command wrapper
ansible.builtin.set_fact:
chroot_command: >-

View File

@@ -1,10 +1,7 @@
---
# Two code paths:
# 1. Fresh run (system_cfg undefined): normalize from raw `system` input.
# 2. Pre-computed (system_cfg already set, e.g. from main project's deploy_iac):
# merge with bootstrap system_defaults to fill missing fields (luks, features,
# etc.) that bootstrap expects but the main project doesn't set, then derive
# convenience facts (hostname, os, os_version).
# Fresh run normalizes raw `system` input. A pre-computed system_cfg (from the main
# project's deploy_iac) is instead merged with system_defaults to fill the fields
# bootstrap expects, then convenience facts are derived.
- name: Normalize system and disk configuration
when: system_cfg is not defined
block:
@@ -50,25 +47,6 @@
ansible.builtin.set_fact:
system_cfg: "{{ system_defaults | combine(system | default({}), recursive=True) | combine(system_cfg, recursive=True) }}"
- name: Apply mirror default for pre-computed system_cfg
when:
- system_cfg is defined
- _bootstrap_needs_enrichment | default(false) | bool
- system_cfg.mirror | default('') | string | trim | length == 0
vars:
# Same as _normalize_system.yml — kept in sync manually.
_mirror_defaults:
debian: "https://deb.debian.org/debian/"
ubuntu: "http://archive.ubuntu.com/ubuntu/"
ubuntu-lts: "http://archive.ubuntu.com/ubuntu/"
ansible.builtin.set_fact:
system_cfg: >-
{{
system_cfg | combine({
'mirror': _mirror_defaults[system_cfg.os | default('') | string | lower] | default('')
}, recursive=True)
}}
- name: Populate primary network fields from first interface (pre-computed)
when:
- system_cfg is defined
@@ -105,3 +83,8 @@
- system_cfg is defined
- install_drive is not defined
ansible.builtin.include_tasks: _normalize_disks.yml
# Runs on every path before validation, so an empty firewall.backend / content.source
# resolves to the family default even when system_cfg arrived pre-computed.
- name: Apply family defaults (content source, firewall backend)
ansible.builtin.include_tasks: _apply_family_defaults.yml

View File

@@ -96,7 +96,7 @@
quiet: true
- name: Validate system.features leaf schemas
loop: "{{ system_defaults.features | dict2items }}"
loop: "{{ system_defaults.features | dict2items | selectattr('value', 'mapping') }}"
loop_control:
label: "system.features.{{ item.key }}"
vars:
@@ -121,18 +121,18 @@
- >-
os_version is not defined or (os_version | string | length) == 0
or (
os == "debian" and (os_version | string) in ["10", "11", "12", "13", "unstable"]
os == "debian" and (os_version | string) in ["12", "13", "unstable"]
) or (
os == "fedora" and (os_version | int) >= 38 and (os_version | int) <= 43
os == "fedora" and (os_version | int) >= 43 and (os_version | int) <= 44
) or (
os in ["rocky", "almalinux"]
and (os_version | string) is match("^(8|9|10)(\\.\\d+)?$")
and (os_version | string) is match("^(9|10)(\\.\\d+)?$")
) or (
os == "rhel"
and (os_version | string) is match("^(8|9|10)(\\.\\d+)?$")
and (os_version | string) is match("^(9|10)(\\.\\d+)?$")
) or (
os == "ubuntu"
and (os_version | string) is match("^(2[0-9])\\.04$")
and (os_version | string) is match("^(2[0-9])\\.(04|10)$")
) or (
os == "ubuntu-lts"
and (os_version | string) is match("^(2[0-9])\\.04$")
@@ -140,7 +140,7 @@
os in ["ubuntu", "ubuntu-lts"]
and (os_version | default('') | string | length) == 0
) or (
os in ["alpine", "archlinux", "opensuse", "void"]
os == "archlinux"
)
fail_msg: "Invalid os/version specified. Please check README.md for supported values."
quiet: true
@@ -148,8 +148,8 @@
- name: Validate RHEL ISO requirement
ansible.builtin.assert:
that:
- os != "rhel" or (rhel_iso is defined and (rhel_iso | string | length) > 0)
fail_msg: "rhel_iso is required when os=rhel."
- os != "rhel" or system_cfg.content.source == "mirror" or (rhel_iso is defined and (rhel_iso | string | length) > 0)
fail_msg: "rhel_iso is required when os=rhel unless content.source is mirror."
quiet: true
- name: Validate hypervisor-specific required fields
@@ -166,6 +166,21 @@
label: "hypervisor.{{ item }}"
no_log: true
- name: Validate Proxmox authentication (password or API token)
when:
- system_cfg.type == "virtual"
- hypervisor_type == "proxmox"
ansible.builtin.assert:
that:
- >-
(hypervisor_cfg.password | default('') | string | length > 0)
or (hypervisor_cfg.token_id | default('') | string | length > 0
and hypervisor_cfg.token_secret | default('') | string | length > 0)
fail_msg: >-
Proxmox requires either hypervisor.password or
hypervisor.token_id + hypervisor.token_secret (API token).
quiet: true
- name: Validate VMware placement (cluster or node required, mutually exclusive)
when:
- system_cfg.type == "virtual"
@@ -232,6 +247,83 @@
fail_msg: Invalid feature flags were specified, please check your inventory/vars.
quiet: true
- name: Validate hardware feature flags
ansible.builtin.assert:
that:
- system_cfg.features.firmware.enabled is defined
- system_cfg.features.firmware.microcode is defined
- system_cfg.features.gpu.enabled is defined
- system_cfg.features.gpu.nvidia_driver in ["auto", "open", "proprietary", "nouveau"]
- system_cfg.features.peripherals.enabled is defined
- system_cfg.features.peripherals.fingerprint in ["auto", "true", "false"]
- system_cfg.features.peripherals.camera in ["auto", "true", "false"]
- system_cfg.features.peripherals.audio in ["auto", "true", "false"]
- system_cfg.features.peripherals.bluetooth in ["auto", "true", "false"]
- system_cfg.features.peripherals.displaylink is defined
- system_cfg.features.hardware.profile is mapping
- system_cfg.features.hardware.packages is mapping
- system_cfg.features.hardware.camera is mapping
- system_cfg.features.hardware.disable is sequence
- system_cfg.features.hardware.kernel_params is sequence
fail_msg: >-
Invalid hardware feature flags. firmware.enabled/microcode,
peripherals.enabled and peripherals.displaylink must be bool (or 'auto'
sentinel for firmware); gpu.nvidia_driver in
[auto|open|proprietary|nouveau]; peripherals.fingerprint/camera/audio/
bluetooth in [auto|true|false]; hardware.profile must be a dict.
quiet: true
- name: Validate desktop environment
when: system_cfg.features.desktop.enabled | bool
ansible.builtin.assert:
that:
- system_cfg.features.desktop.environment in ["gnome", "kde", "sway", "hyprland"]
- >-
system_cfg.features.desktop.environment not in ["sway", "hyprland"]
or os_family_map[os] | default('') == "Archlinux"
- >-
system_cfg.features.desktop.display_manager | default('') | length == 0
or system_cfg.features.desktop.display_manager in ["gdm", "sddm", "greetd", "plasma-login-manager", "ly"]
- >-
system_cfg.features.desktop.display_manager | default('') != "greetd"
or system_cfg.features.desktop.environment in ["sway", "hyprland"]
- >-
system_cfg.features.desktop.environment != "gnome"
or system_cfg.features.desktop.display_manager | default('') in ["", "gdm"]
- >-
system_cfg.features.desktop.environment != "kde"
or system_cfg.features.desktop.display_manager | default('') in ["", "sddm", "plasma-login-manager"]
- >-
system_cfg.features.desktop.display_manager | default('') != "plasma-login-manager"
or os == "archlinux" or (os == "fedora" and (os_version | int) >= 44)
- >-
system_cfg.features.desktop.display_manager | default('') != "ly"
or os == "archlinux"
fail_msg: >-
Invalid desktop config: environment '{{ system_cfg.features.desktop.environment }}'
for os_family '{{ os_family_map[os] | default('Unknown') }}',
display_manager '{{ system_cfg.features.desktop.display_manager | default('') }}'.
gnome and kde are available on all families; sway and hyprland are Archlinux only.
display_manager must be empty (auto) or match the environment's native DM:
gnome->gdm, kde->plasma-login-manager on Arch/Fedora44+ else sddm,
sway/hyprland->greetd. ly is an explicit override on Arch only and may
front any environment. Only that DM's package is installed, so a mismatched
override fails at enable time.
quiet: true
- name: Validate desktop autologin
when: system_cfg.features.desktop.enabled | bool
vars:
_autologin: "{{ system_cfg.features.desktop.autologin | default(false) }}"
ansible.builtin.assert:
that:
- _autologin is boolean and not _autologin or (_autologin is string and _autologin | length > 0 and _autologin in system_cfg.users)
fail_msg: >-
desktop.autologin must be false or a username string present in
system.users; got '{{ _autologin }}'. Bool true is not accepted - the
resolver matches the value against system.users by name.
quiet: true
- name: Validate virtual system sizing
when: system_cfg.type == "virtual"
ansible.builtin.assert:
@@ -242,7 +334,7 @@
- (system_cfg.disks[0].size | float) > 0
- (system_cfg.disks[0].size | float) >= 20
# Btrfs minimum disk: swap_size + 5.5 GiB overhead (subvolumes + metadata).
# Swap sizing: memory < 16 GiB max(memory_GiB, 2); memory >= 16 GiB memory/2.
# Swap sizing: memory < 16 GiB -> max(memory_GiB, 2); memory >= 16 GiB -> memory/2.
- >-
system_cfg.filesystem != "btrfs"
or (

Some files were not shown because too many files have changed in this diff Show More