From 8f8ce341ae827c3fed11a4766e0fe2f313850b63 Mon Sep 17 00:00:00 2001 From: Sandwich Date: Thu, 12 Feb 2026 22:52:15 +0100 Subject: [PATCH] refactor(users): migrate system.user to system.users[] for multi-user support --- README.md | 49 +++++++------- inventory_example.yml | 20 +++--- inventory_libvirt_example.yml | 30 ++++----- main.yml | 66 +++++++++++++------ roles/configuration/tasks/sudo.yml | 11 ++++ roles/configuration/tasks/users.yml | 55 ++++++++++------ roles/global_defaults/defaults/main.yml | 5 +- roles/global_defaults/tasks/main.yml | 6 +- roles/global_defaults/tasks/system.yml | 24 +++---- roles/global_defaults/tasks/validation.yml | 16 ----- roles/virtualization/tasks/proxmox.yml | 4 +- .../templates/cloud-user-data.yml.j2 | 19 ++++-- vars_baremetal_example.yml | 10 +-- vars_example.yml | 10 +-- 14 files changed, 186 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index f9bbb99..4f58e3e 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,11 @@ all: mount: path: /data fstype: xfs - user: - name: ops - password: CHANGE_ME - key: "ssh-ed25519 AAAA..." + users: + - name: ops + password: CHANGE_ME + keys: + - "ssh-ed25519 AAAA..." root: password: CHANGE_ME luks: @@ -173,32 +174,36 @@ Top-level host install/runtime settings. Use these keys under `system`. | `packages` | list | `[]` | Additional packages installed post-reboot | | `network` | dict | see below | Network configuration | | `disks` | list | `[]` | Disk layout (see [Multi-Disk Schema](#45-multi-disk-schema)) | -| `user` | dict | see below | User account settings | +| `users` | list | `[]` | User accounts (see below) | | `root` | dict | see below | Root account settings | | `luks` | dict | see below | Encryption settings | | `features` | dict | see below | Feature toggles | #### `system.network` -| Key | Type | Default | Description | -| -------------- | ----------- | ------- | --------------------------------------------------- | -| `bridge` | string | empty | Hypervisor network/bridge name | -| `vlan` | string/int | empty | VLAN tag | -| `ip` | string | empty | Static IP (omit for DHCP) | -| `prefix` | int | empty | CIDR prefix for static IP | -| `gateway` | string | empty | Default gateway (static only) | -| `dns.servers` | list/string | `[]` | DNS resolvers; comma-separated string is normalized | -| `dns.search` | list/string | `[]` | Search domains; comma-separated string is normalized | +| Key | Type | Default | Description | +| -------------- | ---------- | ------- | ---------------------------------------------------- | +| `bridge` | string | empty | Hypervisor network/bridge name | +| `vlan` | string/int | empty | VLAN tag | +| `ip` | string | empty | Static IP (omit for DHCP) | +| `prefix` | int | empty | CIDR prefix for static IP | +| `gateway` | string | empty | Default gateway (static only) | +| `dns.servers` | list | `[]` | DNS resolvers (must be a YAML list) | +| `dns.search` | list | `[]` | Search domains (must be a YAML list) | +| `interfaces` | list | `[]` | Multi-NIC config (overrides flat fields above) | -#### `system.user` +When `interfaces` is empty, the flat fields (`bridge`, `ip`, `prefix`, `gateway`, `vlan`) are auto-wrapped into a single-entry `interfaces[]` list. When `interfaces` is set, it takes precedence and the flat fields are back-populated from `interfaces[0]` for backward compatibility. Each `interfaces[]` entry supports: `name`, `bridge` (required), `vlan`, `ip`, `prefix`, `gateway`. -Credentials are prompted interactively by default via `vars_prompt` in `main.yml`, but can be supplied via inventory, vars files, or `-e` for non-interactive runs. +#### `system.users` -| Key | Type | Default | Description | -| ---------- | ------ | ------- | ------------------------------------- | -| `name` | string | empty | Username created on target | -| `password` | string | empty | User password (also used for sudo) | -| `key` | string | empty | SSH public key for `authorized_keys` | +A list of user account dictionaries. Credentials for the first user are prompted interactively by default via `vars_prompt` in `main.yml`, but can be supplied via inventory, vars files, or `-e` for non-interactive runs. + +| Key | Type | Default | Description | +| ---------- | ------ | ------- | -------------------------------------------- | +| `name` | string | empty | Username created on target (required) | +| `password` | string | empty | User password (also used for sudo) | +| `keys` | list | `[]` | SSH public keys for `authorized_keys` | +| `sudo` | string | empty | Custom sudoers rule (optional, per-user) | #### `system.root` @@ -387,7 +392,7 @@ To protect sensitive information such as passwords, API keys, and other confiden - For virtual installs, `system.cpus`, `system.memory`, and `system.disks[0].size` are required and validated. - For physical installs, sizing is derived from the detected install drive; set installer access (`ansible_user`/`ansible_password`) when the installer environment differs from the prompted user credentials. -- `system.network.dns.servers` and `system.network.dns.search` accept either YAML lists or comma-separated strings. +- `system.network.dns.servers` and `system.network.dns.search` must be YAML lists. - `hypervisor.type` selects backend-specific provisioning and cleanup behavior. - Guest tools are selected automatically by hypervisor: `qemu-guest-agent` for `libvirt`/`proxmox`, `open-vm-tools` for `vmware`. - With `system.luks.method: tpm2` on virtual installs, the virtualization role enables a TPM2 device where supported (libvirt/proxmox/vmware). diff --git a/inventory_example.yml b/inventory_example.yml index 533a966..c17a9d2 100644 --- a/inventory_example.yml +++ b/inventory_example.yml @@ -42,11 +42,11 @@ all: fstype: xfs label: DATA opts: defaults - user: - name: "ops" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "ops" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" packages: @@ -99,11 +99,11 @@ all: mount: path: /srv/data fstype: ext4 - user: - name: "dbadmin" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "dbadmin" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" luks: diff --git a/inventory_libvirt_example.yml b/inventory_libvirt_example.yml index 08c03c7..1520c34 100644 --- a/inventory_libvirt_example.yml +++ b/inventory_libvirt_example.yml @@ -39,11 +39,11 @@ all: mount: path: /var/www fstype: xfs - user: - name: "web" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "web" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" packages: @@ -81,11 +81,11 @@ all: mount: path: /data fstype: ext4 - user: - name: "db" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "db" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" luks: @@ -122,11 +122,11 @@ all: mount: path: /data fstype: btrfs - user: - name: "compute" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "compute" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" features: diff --git a/main.yml b/main.yml index f0fe352..670dca1 100644 --- a/main.yml +++ b/main.yml @@ -28,35 +28,59 @@ - name: Apply prompted authentication values to system input vars: system_input: "{{ system | default({}) }}" - system_user_input: "{{ (system_input.user | default({})) if (system_input.user is mapping) else {} }}" + system_users_input: "{{ system_input.users | default([]) }}" + system_first_user: >- + {{ + system_users_input[0] + if (system_users_input is iterable and system_users_input is not string + and system_users_input is not mapping and system_users_input | length > 0) + else {} + }} system_root_input: "{{ (system_input.root | default({})) if (system_input.root is mapping) else {} }}" prompt_user_name: "{{ user_name | default(system_user_name | default(''), true) | string }}" prompt_user_key: "{{ user_public_key | default(user_key | default(system_user_key | default(''), true), true) | string | trim }}" prompt_user_password: "{{ user_password | default(system_user_password | default(''), true) | string }}" prompt_root_password: "{{ root_password | default(system_root_password | default(''), true) | string }}" + resolved_user: + name: >- + {{ + system_first_user.name | string + if (system_first_user.name | default('') | string | length) > 0 + else prompt_user_name + }} + keys: >- + {{ + system_first_user['keys'] + if (system_first_user['keys'] is defined + and system_first_user['keys'] is iterable + and system_first_user['keys'] is not string + and system_first_user['keys'] | length > 0) + else ( + [prompt_user_key] + if (prompt_user_key | length > 0) + else [] + ) + }} + password: >- + {{ + system_first_user.password | string + if (system_first_user.password | default('') | string | length) > 0 + else prompt_user_password + }} ansible.builtin.set_fact: system: >- {{ system_input | combine( { - 'user': { - 'name': ( - (system_user_input.name | default('') | string | length) > 0 - ) | ternary(system_user_input.name | string, prompt_user_name), - 'keys': ( - system_user_input.keys - if (system_user_input.keys is iterable and system_user_input.keys is not string and system_user_input.keys | length > 0) - else ( - [prompt_user_key] - if (prompt_user_key | length > 0) - else [] - ) - ), - 'password': ( - (system_user_input.password | default('') | string | length) > 0 - ) | ternary(system_user_input.password | string, prompt_user_password) - }, + 'users': ( + [resolved_user] + + (system_users_input[1:] + if (system_users_input is sequence + and system_users_input is not string + and system_users_input | length > 1) + else []) + ), 'root': { 'password': ( (system_root_input.password | default('') | string | length) > 0 @@ -124,9 +148,9 @@ when: - post_reboot_can_connect | bool ansible.builtin.set_fact: - ansible_user: "{{ system_cfg.user.name }}" - ansible_password: "{{ system_cfg.user.password }}" - ansible_become_password: "{{ system_cfg.user.password }}" + ansible_user: "{{ system_cfg.users[0].name }}" + ansible_password: "{{ system_cfg.users[0].password }}" + ansible_become_password: "{{ system_cfg.users[0].password }}" ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" - name: Install post-reboot packages diff --git a/roles/configuration/tasks/sudo.yml b/roles/configuration/tasks/sudo.yml index 625b26d..aa6489e 100644 --- a/roles/configuration/tasks/sudo.yml +++ b/roles/configuration/tasks/sudo.yml @@ -5,3 +5,14 @@ dest: /mnt/etc/sudoers.d/01-wheel mode: "0440" validate: /usr/sbin/visudo --check --file=%s + +- name: Deploy per-user sudoers rules + when: item.sudo is defined and (item.sudo | string | length) > 0 + ansible.builtin.copy: + content: "{{ item.name }} {{ item.sudo }}\n" + dest: "/mnt/etc/sudoers.d/{{ item.name }}" + mode: "0440" + validate: /usr/sbin/visudo --check --file=%s + loop: "{{ system_cfg.users }}" + loop_control: + label: "{{ item.name }}" diff --git a/roles/configuration/tasks/users.yml b/roles/configuration/tasks/users.yml index e683743..b7b0e52 100644 --- a/roles/configuration/tasks/users.yml +++ b/roles/configuration/tasks/users.yml @@ -1,38 +1,53 @@ --- -- name: Create user account +- name: Set root password + vars: + configuration_root_cmd: >- + {{ chroot_command }} /usr/sbin/usermod --password + '{{ system_cfg.root.password | password_hash('sha512') }}' root --shell /bin/bash + ansible.builtin.command: "{{ configuration_root_cmd }}" + register: configuration_root_result + changed_when: configuration_root_result.rc == 0 + +- name: Create user accounts vars: configuration_user_group: >- {{ "sudo" if is_debian | bool else "wheel" }} configuration_useradd_cmd: >- {{ chroot_command }} /usr/sbin/useradd --create-home --user-group - --groups {{ configuration_user_group }} {{ system_cfg.user.name }} - --password {{ system_cfg.user.password | password_hash('sha512') }} --shell /bin/bash - configuration_root_cmd: >- - {{ chroot_command }} /usr/sbin/usermod --password - '{{ system_cfg.root.password | password_hash('sha512') }}' root --shell /bin/bash - ansible.builtin.command: "{{ item }}" - loop: - - "{{ configuration_useradd_cmd }}" - - "{{ configuration_root_cmd }}" + --uid {{ 1000 + ansible_loop.index0 }} + --groups {{ configuration_user_group }} {{ item.name }} + --password {{ item.password | password_hash('sha512') }} --shell /bin/bash + ansible.builtin.command: "{{ configuration_useradd_cmd }}" + loop: "{{ system_cfg.users }}" + loop_control: + extended: true + label: "{{ item.name }}" register: configuration_user_result changed_when: configuration_user_result.rc == 0 - name: Ensure .ssh directory exists - when: system_cfg.user.keys | length > 0 + when: item.keys | default([]) | length > 0 ansible.builtin.file: - path: /mnt/home/{{ system_cfg.user.name }}/.ssh + path: "/mnt/home/{{ item.name }}/.ssh" state: directory - owner: 1000 - group: 1000 + owner: "{{ 1000 + ansible_loop.index0 }}" + group: "{{ 1000 + ansible_loop.index0 }}" mode: "0700" + loop: "{{ system_cfg.users }}" + loop_control: + extended: true + label: "{{ item.name }}" - name: Add SSH public keys to authorized_keys - when: system_cfg.user.keys | length > 0 + vars: + _uid: "{{ 1000 + (system_cfg.users | map(attribute='name') | list).index(item.0.name) }}" ansible.builtin.lineinfile: - path: /mnt/home/{{ system_cfg.user.name }}/.ssh/authorized_keys - line: "{{ item }}" - owner: 1000 - group: 1000 + path: "/mnt/home/{{ item.0.name }}/.ssh/authorized_keys" + line: "{{ item.1 }}" + owner: "{{ _uid }}" + group: "{{ _uid }}" mode: "0600" create: true - loop: "{{ system_cfg.user.keys }}" + loop: "{{ system_cfg.users | subelements('keys', skip_missing=True) }}" + loop_control: + label: "{{ item.0.name }}: {{ item.1[:40] }}..." diff --git a/roles/global_defaults/defaults/main.yml b/roles/global_defaults/defaults/main.yml index cca462e..321524e 100644 --- a/roles/global_defaults/defaults/main.yml +++ b/roles/global_defaults/defaults/main.yml @@ -40,10 +40,7 @@ system_defaults: path: "" packages: [] disks: [] - user: - name: "" - password: "" - keys: [] + users: [] root: password: "" luks: diff --git a/roles/global_defaults/tasks/main.yml b/roles/global_defaults/tasks/main.yml index 6eab95c..6d67719 100644 --- a/roles/global_defaults/tasks/main.yml +++ b/roles/global_defaults/tasks/main.yml @@ -66,9 +66,9 @@ - system_cfg.type == "virtual" - hypervisor_type != "vmware" ansible.builtin.set_fact: - ansible_user: "{{ system_cfg.user.name }}" - ansible_password: "{{ system_cfg.user.password }}" - ansible_become_password: "{{ system_cfg.user.password }}" + ansible_user: "{{ system_cfg.users[0].name }}" + ansible_password: "{{ system_cfg.users[0].password }}" + ansible_become_password: "{{ system_cfg.users[0].password }}" ansible_ssh_extra_args: "-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" changed_when: false diff --git a/roles/global_defaults/tasks/system.yml b/roles/global_defaults/tasks/system.yml index d20d8c0..7ef2353 100644 --- a/roles/global_defaults/tasks/system.yml +++ b/roles/global_defaults/tasks/system.yml @@ -9,14 +9,14 @@ that: - system is mapping - system.network is not defined or system.network is mapping - - system.user is not defined or system.user is mapping + - system.users is not defined or (system.users is iterable and system.users is not string and system.users is not mapping) - system.root is not defined or system.root is mapping - system.luks is not defined or system.luks is mapping - system.features is not defined or system.features is mapping - fail_msg: "system and its nested keys (network, user, root, luks, features) must be dictionaries." + fail_msg: "system and its nested keys (network, root, luks, features) must be dictionaries; system.users must be a list." quiet: true -- name: Validate DNS and user.keys are lists (not strings) +- name: Validate DNS lists (not strings) when: system.network is defined and system.network.dns is defined ansible.builtin.assert: that: @@ -25,13 +25,18 @@ fail_msg: "system.network.dns.servers and system.network.dns.search must be lists, not strings." quiet: true -- name: Validate user.keys is a list - when: system.user is defined and system.user.keys is defined +- name: Validate system.users entries + when: system.users is defined and system.users | length > 0 ansible.builtin.assert: that: - - system.user.keys is iterable and system.user.keys is not string - fail_msg: "system.user.keys must be a list of SSH public key strings." + - item is mapping + - item.name is defined and (item.name | string | length) > 0 + - item.keys is not defined or (item.keys is iterable and item.keys is not string) + fail_msg: "Each system.users[] entry must be a dict with 'name'; 'keys' must be a list." quiet: true + loop: "{{ system.users }}" + loop_control: + label: "{{ item.name | default('(unnamed)') }}" - name: Validate system features input types when: system.features is defined @@ -122,10 +127,7 @@ | list }} disks: "{{ system_raw.disks | default([]) }}" - user: - name: "{{ system_raw.user.name | string }}" - password: "{{ system_raw.user.password | string }}" - keys: "{{ system_raw.user.keys | default([]) }}" + users: "{{ system_raw.users | default([]) }}" root: password: "{{ system_raw.root.password | string }}" luks: diff --git a/roles/global_defaults/tasks/validation.yml b/roles/global_defaults/tasks/validation.yml index 7f9f554..14eb5d0 100644 --- a/roles/global_defaults/tasks/validation.yml +++ b/roles/global_defaults/tasks/validation.yml @@ -48,25 +48,9 @@ fail_msg: "Unsupported system keys: {{ system_unknown_keys | join(', ') }}." quiet: true -- name: Validate nested system mappings - loop: - - network - - user - - root - - luks - - features - loop_control: - label: "{{ item }}" - ansible.builtin.assert: - that: - - system[item] is not defined or system[item] is mapping - fail_msg: "system.{{ item }} must be a dictionary." - quiet: true - - name: Validate system sub-dict schemas loop: - network - - user - root - luks loop_control: diff --git a/roles/virtualization/tasks/proxmox.yml b/roles/virtualization/tasks/proxmox.yml index 75834b6..b827c3e 100644 --- a/roles/virtualization/tasks/proxmox.yml +++ b/roles/virtualization/tasks/proxmox.yml @@ -34,8 +34,8 @@ api_host: "{{ hypervisor_cfg.url }}" api_user: "{{ hypervisor_cfg.username }}" api_password: "{{ hypervisor_cfg.password }}" - ciuser: "{{ system_cfg.user.name }}" - cipassword: "{{ system_cfg.user.password }}" + ciuser: "{{ system_cfg.users[0].name }}" + cipassword: "{{ system_cfg.users[0].password }}" ciupgrade: false node: "{{ hypervisor_cfg.host }}" vmid: "{{ system_cfg.id }}" diff --git a/roles/virtualization/templates/cloud-user-data.yml.j2 b/roles/virtualization/templates/cloud-user-data.yml.j2 index 977c3f3..da4b9ef 100644 --- a/roles/virtualization/templates/cloud-user-data.yml.j2 +++ b/roles/virtualization/templates/cloud-user-data.yml.j2 @@ -4,9 +4,18 @@ ssh_pwauth: true package_update: false package_upgrade: false users: - - name: "{{ system_cfg.user.name }}" - primary_group: "{{ system_cfg.user.name }}" +{% for user in system_cfg.users %} + - name: "{{ user.name }}" + primary_group: "{{ user.name }}" groups: users - sudo: ALL=(ALL) NOPASSWD:ALL - passwd: "{{ system_cfg.user.password | password_hash('sha512') }}" - lock_passwd: False + sudo: "{{ user.sudo | default('ALL=(ALL) NOPASSWD:ALL') }}" + passwd: "{{ user.password | password_hash('sha512') }}" + lock_passwd: false +{% set ssh_keys = user.keys | default([]) %} +{% if ssh_keys | length > 0 %} + ssh_authorized_keys: +{% for key in ssh_keys %} + - "{{ key }}" +{% endfor %} +{% endif %} +{% endfor %} diff --git a/vars_baremetal_example.yml b/vars_baremetal_example.yml index cf4c78d..b744f63 100644 --- a/vars_baremetal_example.yml +++ b/vars_baremetal_example.yml @@ -25,11 +25,11 @@ system: mount: path: /data fstype: ext4 - user: - name: "admin" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "admin" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" luks: diff --git a/vars_example.yml b/vars_example.yml index 3574771..59c3871 100644 --- a/vars_example.yml +++ b/vars_example.yml @@ -55,11 +55,11 @@ system: fstype: xfs label: DATA opts: defaults - user: - name: "ops" - password: "CHANGE_ME" - keys: - - "ssh-ed25519 AAAA..." + users: + - name: "ops" + password: "CHANGE_ME" + keys: + - "ssh-ed25519 AAAA..." root: password: "CHANGE_ME" luks: