diff --git a/roles/users/README.md b/roles/users/README.md new file mode 100644 index 0000000..90f8563 --- /dev/null +++ b/roles/users/README.md @@ -0,0 +1,55 @@ +ansible-users +========= + +Create user accounts according to a YAML manifest. + +Controls: + - username and UID + - groupname and GID + - homedir creation + - GECOS field (typically full user name) + - shell + - SSH public key deployment to `.ssh/authorized_keys` + - account expiration + - memory, swap and CPU allocation limits via user slices + +Check out [corvus-migratorius/ansible-disk-quotas](https://github.com/corvus-migratorius/ansible-disk-quotas) for controlling non-root filesystem quotas. + +Requirements +------------ + +- systemd +- openssh + +Role Variables +-------------- + +`manifest_path`: a YAML file containing user definitions (see `molecule/default/users.yml` for an example) + +`users`: a list of objects mirroring the YAML structure expected by `manifest_path`; takes precedence over it + +`common_memory_max`: e.g. `"500M"`, optional + +`common_swap_max`: e.g. `"2G"`, optional + +`common_cpu_quota`: e.g. `"100%"`, optional + +Dependencies +------------ + +Example Playbook +---------------- + +- Configuring users in a YAML manifest: [converge.yml](molecule/default/converge.yml) +- Configuring user list as a variable: [converge.yml](molecule/users_from_var/converge.yml) + +License +------- + +BSD + +Author Information +------------------ + +corvus-migratorius@proton.me +masayganova@gmail.com diff --git a/roles/users/defaults/main.yml b/roles/users/defaults/main.yml new file mode 100644 index 0000000..755fb15 --- /dev/null +++ b/roles/users/defaults/main.yml @@ -0,0 +1,2 @@ +--- +users_default_home_root: /home diff --git a/roles/users/handlers/main.yml b/roles/users/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/users/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/roles/users/meta/main.yml b/roles/users/meta/main.yml new file mode 100644 index 0000000..03efaab --- /dev/null +++ b/roles/users/meta/main.yml @@ -0,0 +1,16 @@ +galaxy_info: + role_name: users + namespace: genlab + author: Alexander Gorelyshev + company: Genlab, LLC + description: Configure user accounts + license: GPL-2.0-or-later + min_ansible_version: "2.1" + + platforms: + - name: Ubuntu + versions: ["jammy", "noble"] + + galaxy_tags: [] + +dependencies: [] diff --git a/roles/users/molecule/default/configuration/users.yml b/roles/users/molecule/default/configuration/users.yml new file mode 100644 index 0000000..39687a1 --- /dev/null +++ b/roles/users/molecule/default/configuration/users.yml @@ -0,0 +1,22 @@ +--- +users: + - name: "user0" + uid: 1001 + gid: 1004 + full_name: "User Zero" + state: "present" + groups: [sudo] + create_home: true + home_root: "/tmp/someplace" + shell: "/bin/bash" + pubkeys_file: "user0.keys" + + - name: "user1" + uid: 1002 + full_name: "User One" + state: "present" + groups: [] + create_home: true + shell: "/bin/sh" + expires: "2024-07-23 12:00:00" + generate_ssh_key: true diff --git a/roles/users/molecule/default/converge.yml b/roles/users/molecule/default/converge.yml new file mode 100644 index 0000000..0327060 --- /dev/null +++ b/roles/users/molecule/default/converge.yml @@ -0,0 +1,10 @@ +--- +- name: Converge + hosts: all + + roles: + - role: genlab.common.users + manifest_path: "configuration/users.yml" + common_memory_max: "500M" + common_swap_max: "2G" + common_cpu_quota: "100%" diff --git a/roles/users/molecule/default/molecule.yml b/roles/users/molecule/default/molecule.yml new file mode 100644 index 0000000..d82158e --- /dev/null +++ b/roles/users/molecule/default/molecule.yml @@ -0,0 +1,27 @@ +--- +dependency: + name: galaxy + +driver: + name: docker + +platforms: + - name: ubuntu + image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest + pre_build_image: true + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + +provisioner: + name: ansible + +verifier: + name: ansible + +lint: | + set -e + yamllint . + ansible-lint . diff --git a/roles/users/molecule/default/user0.keys b/roles/users/molecule/default/user0.keys new file mode 100644 index 0000000..5a478b1 --- /dev/null +++ b/roles/users/molecule/default/user0.keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMh9Y+wR4LH8lWJjJXqHn76kSoTRujkab+PYwD3IReFh user0@hostname diff --git a/roles/users/molecule/default/verify.yml b/roles/users/molecule/default/verify.yml new file mode 100644 index 0000000..da1af66 --- /dev/null +++ b/roles/users/molecule/default/verify.yml @@ -0,0 +1,64 @@ +--- +- name: Verify + hosts: all + gather_facts: true + any_errors_fatal: true + + tasks: + - name: "Get user info from /etc/passwd" + register: users_etc_passwd + changed_when: false + ansible.builtin.shell: + cmd: set -o pipefail; cat /etc/passwd | grep user + executable: /bin/bash + + - name: "Get user info from /etc/shadow" + register: users_etc_shadow + changed_when: false + ansible.builtin.shell: + cmd: set -o pipefail; cat /etc/shadow | grep user + executable: /bin/bash + + # using a hack since here the date of last password change == date of account creation + - name: "Verify expected account configuration" + vars: + expected_shadow: + - "user0:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7:::" + - "user1:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7::19927:" + expected_passwd: + - "user0:x:1001:1004:User Zero:/tmp/someplace/user0:/bin/bash" + - "user1:x:1002:1002:User One:/home/user1:/bin/sh" + ansible.builtin.assert: + that: + - users_etc_passwd.stdout_lines == expected_passwd + - users_etc_shadow.stdout_lines == expected_shadow + + - name: "Get SSH key contents for user1" + register: users_user1_ssh_key + changed_when: false + ansible.builtin.command: + cmd: cat /home/user1/.ssh/id_ed25519.pub + + - name: "Verify the SSH key contents for user1" + ansible.builtin.assert: + that: "'user1@ubuntu' in users_user1_ssh_key.stdout" + + - name: "Get the user slice drop-in for user0" + register: users_user0_slice_dropin + changed_when: false + ansible.builtin.command: + cmd: cat /etc/systemd/system/user-1001.slice.d/50-limits.conf + + - name: "Verify drop-in contens" + vars: + expected: + - "[Slice]" + - "MemoryAccounting=1" + - "MemoryMax=500M" + - "MemorySwapMax=2G" + - "CPUAccounting=1" + - "CPUQuota=100%" + ansible.builtin.assert: + that: "users_user0_slice_dropin.stdout_lines == expected" + success_msg: "Got the expected limits slice drop-in" + fail_msg: "Unexpected limits slice drop-in contents" diff --git a/roles/users/molecule/users_from_var/converge.yml b/roles/users/molecule/users_from_var/converge.yml new file mode 100644 index 0000000..4993887 --- /dev/null +++ b/roles/users/molecule/users_from_var/converge.yml @@ -0,0 +1,31 @@ +--- +- name: Converge + hosts: all + + roles: + - role: genlab.common.users + manifest_path: "some-none-existent-file" # the 'users' variable is supposed to take precedence + common_memory_max: "500M" + common_swap_max: "2G" + common_cpu_quota: "100%" + users: + - name: "user0" + uid: 1001 + gid: 1004 + full_name: "User Zero" + state: "present" + groups: [sudo] + create_home: true + home_root: "/tmp/someplace" + shell: "/bin/bash" + pubkeys_file: "user0.keys" + + - name: "user1" + uid: 1002 + full_name: "User One" + state: "present" + groups: [] + create_home: true + shell: "/bin/sh" + expires: "2024-07-23 12:00:00" + generate_ssh_key: true diff --git a/roles/users/molecule/users_from_var/molecule.yml b/roles/users/molecule/users_from_var/molecule.yml new file mode 100644 index 0000000..d82158e --- /dev/null +++ b/roles/users/molecule/users_from_var/molecule.yml @@ -0,0 +1,27 @@ +--- +dependency: + name: galaxy + +driver: + name: docker + +platforms: + - name: ubuntu + image: geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2404}-ansible:latest + pre_build_image: true + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + +provisioner: + name: ansible + +verifier: + name: ansible + +lint: | + set -e + yamllint . + ansible-lint . diff --git a/roles/users/molecule/users_from_var/user0.keys b/roles/users/molecule/users_from_var/user0.keys new file mode 100644 index 0000000..5a478b1 --- /dev/null +++ b/roles/users/molecule/users_from_var/user0.keys @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMh9Y+wR4LH8lWJjJXqHn76kSoTRujkab+PYwD3IReFh user0@hostname diff --git a/roles/users/molecule/users_from_var/verify.yml b/roles/users/molecule/users_from_var/verify.yml new file mode 100644 index 0000000..da1af66 --- /dev/null +++ b/roles/users/molecule/users_from_var/verify.yml @@ -0,0 +1,64 @@ +--- +- name: Verify + hosts: all + gather_facts: true + any_errors_fatal: true + + tasks: + - name: "Get user info from /etc/passwd" + register: users_etc_passwd + changed_when: false + ansible.builtin.shell: + cmd: set -o pipefail; cat /etc/passwd | grep user + executable: /bin/bash + + - name: "Get user info from /etc/shadow" + register: users_etc_shadow + changed_when: false + ansible.builtin.shell: + cmd: set -o pipefail; cat /etc/shadow | grep user + executable: /bin/bash + + # using a hack since here the date of last password change == date of account creation + - name: "Verify expected account configuration" + vars: + expected_shadow: + - "user0:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7:::" + - "user1:!:{{ (ansible_date_time.epoch | int) // 86400 }}:0:99999:7::19927:" + expected_passwd: + - "user0:x:1001:1004:User Zero:/tmp/someplace/user0:/bin/bash" + - "user1:x:1002:1002:User One:/home/user1:/bin/sh" + ansible.builtin.assert: + that: + - users_etc_passwd.stdout_lines == expected_passwd + - users_etc_shadow.stdout_lines == expected_shadow + + - name: "Get SSH key contents for user1" + register: users_user1_ssh_key + changed_when: false + ansible.builtin.command: + cmd: cat /home/user1/.ssh/id_ed25519.pub + + - name: "Verify the SSH key contents for user1" + ansible.builtin.assert: + that: "'user1@ubuntu' in users_user1_ssh_key.stdout" + + - name: "Get the user slice drop-in for user0" + register: users_user0_slice_dropin + changed_when: false + ansible.builtin.command: + cmd: cat /etc/systemd/system/user-1001.slice.d/50-limits.conf + + - name: "Verify drop-in contens" + vars: + expected: + - "[Slice]" + - "MemoryAccounting=1" + - "MemoryMax=500M" + - "MemorySwapMax=2G" + - "CPUAccounting=1" + - "CPUQuota=100%" + ansible.builtin.assert: + that: "users_user0_slice_dropin.stdout_lines == expected" + success_msg: "Got the expected limits slice drop-in" + fail_msg: "Unexpected limits slice drop-in contents" diff --git a/roles/users/tasks/create.yml b/roles/users/tasks/create.yml new file mode 100644 index 0000000..f952d08 --- /dev/null +++ b/roles/users/tasks/create.yml @@ -0,0 +1,106 @@ +--- +- name: "Create | Get current user's group entity info ({{ user.name }})" + failed_when: false + ansible.builtin.getent: + database: group + key: "{{ user.name }}" + split: ":" + +- name: "Create | Create user group ('{{ user.name }}')" # to handle cases where GID!=UID + when: + - getent_group[user.name] is not defined + ansible.builtin.group: + name: "{{ user.name }}" + gid: "{{ user.gid is defined | ternary(user.gid, user.uid) }}" + state: "{{ user.state }}" + +- name: "Create | Create user account ('{{ user.name }}')" + vars: + home_root: "{{ user.home_root is defined | ternary(user.home_root, users_default_home_root) }}" + ansible.builtin.user: + name: "{{ user.name }}" + state: "{{ user.state }}" + uid: "{{ user.uid }}" + group: "{{ user.name }}" + create_home: "{{ user.create_home | default(true) }}" + home: "{{ home_root }}/{{ user.name }}" + comment: "{{ user.full_name }}" + shell: "{{ user.shell | default('/bin/bash') }}" + groups: "{{ user.groups | default([]) }}" + append: false + generate_ssh_key: "{{ user.generate_ssh_key | default(false) }}" + ssh_key_type: "{{ user.ssh_key_type | default('ed25519') }}" + ssh_key_comment: "{{ user.name }}@{{ ansible_nodename }}" + # password_lock: "{{ user.password_lock | default('false') }}" + +- name: "Create | Set user account expiration date where defined ('{{ user.name }}')" + when: user.expires is defined + ansible.builtin.user: + name: "{{ user.name }}" + state: "{{ user.state }}" + expires: "{{ (user.expires | to_datetime).strftime('%s') }}" + +- name: "Create | Ensure no user account expiration date where undefined ('{{ user.name }}')" + when: user.expires is undefined + ansible.builtin.user: + name: "{{ user.name }}" + state: "{{ user.state }}" + expires: "-1" + +- name: "Create | Deploy SSH public key to 'authorized_keys' files ('{{ user.name }}')" + when: (user.pubkeys_file is defined) and (user.pubkeys_file != "") + ansible.posix.authorized_key: + user: "{{ user.name }}" + key: "{{ lookup('file', user.pubkeys_file) }}" + key_options: "{{ user.options | default('') }}" + exclusive: true + +- name: "Create | Create a systemd slice directory ('{{ user.name }}')" + ansible.builtin.file: + path: /etc/systemd/system/user-{{ user.uid }}.slice.d + state: directory + owner: root + group: root + mode: '0750' + +# Set user's maximum memory limit to 'memory_limit' defined in the user manifest_path. +# If 'memory_limit' is undefined there, use 'common_memory_max' as fallback. +# If 'common_memory_max' is also undefined, set to "", which should be ignored by the template. +# - 'memory_max:' may use postfix like K, M, G. +# - 'cpu_quota': "100%" for 1 full core. +# REF https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html +# REF https://serverfault.com/a/1092803 +- name: "Create | Create/update a systemd slice limits config ('{{ user.name }}')" + when: user.uid is defined + register: users_limit_state + vars: + memory_max: "{{ (user.memory_max is defined and user.memory_max != '') | ternary(user.memory_max, common_memory_max) | default('') }}" + swap_max: "{{ (user.swap_max is defined and user.swap_max != '') | ternary(user.swap_max, common_swap_max) | default('') }}" + cpu_quota: "{{ (user.cpu_quota is defined and user.cpu_quota != '') | ternary(user.cpu_quota, common_cpu_quota) | default('') }}" + ansible.builtin.template: + src: slice.j2 + dest: "/etc/systemd/system/user-{{ user.uid }}.slice.d/50-limits.conf" + owner: root + group: root + mode: '0750' + +- name: "Create | Enable user systemd service ('{{ user.name }}')" # noqa: no-handler + when: users_limit_state.changed + ansible.builtin.systemd_service: + name: user@{{ user.uid }}.service + enabled: true + +# User service cannot be restarted if: +# - the account is expired (we check for that) +# - password change was enforced +# Sometimes restarting fails with obscure 'status=219/CGROUP', but works fine after a retry +# Didn't debug this yet, sorry ;( +- name: "Restart user service (systemd limits) ('{{ user.name }}')" + when: + - users_limit_state.changed + - user.expires is undefined or (user.expires | to_datetime).strftime('%s') > now(fmt='%s') + retries: 3 + ansible.builtin.systemd_service: + name: user@{{ user.uid }}.service + state: restarted + daemon_reload: true diff --git a/roles/users/tasks/main.yml b/roles/users/tasks/main.yml new file mode 100644 index 0000000..6d3f166 --- /dev/null +++ b/roles/users/tasks/main.yml @@ -0,0 +1,14 @@ +--- +- name: "Include OpenSSH client installation tasks" + ansible.builtin.include_tasks: openssh.yml + +- name: "Include user definition vars" + when: (manifest_path is defined) and (manifest_path != "") and users is not defined + ansible.builtin.include_vars: + file: "{{ manifest_path }}" + +- name: "Create user accounts" + loop: "{{ users }}" + loop_control: + loop_var: user + ansible.builtin.include_tasks: create.yml diff --git a/roles/users/tasks/openssh.yml b/roles/users/tasks/openssh.yml new file mode 100644 index 0000000..753bf52 --- /dev/null +++ b/roles/users/tasks/openssh.yml @@ -0,0 +1,14 @@ +--- +- name: "OpenSSH | Ensure openssh client tools are installed" + when: ansible_os_family == "Debian" + ansible.builtin.apt: + name: openssh-client + state: present + cache_valid_time: 3000 + +- name: "OpenSSH | Ensure openssh client tools are installed" + when: ansible_os_family == "RedHat" + ansible.builtin.dnf: + name: openssh-clients + state: present + update_cache: true diff --git a/roles/users/templates/slice.j2 b/roles/users/templates/slice.j2 new file mode 100644 index 0000000..5a9b1f5 --- /dev/null +++ b/roles/users/templates/slice.j2 @@ -0,0 +1,12 @@ +[Slice] +{% if memory_max is defined and memory_max != "" %} +MemoryAccounting=1 +MemoryMax={{ memory_max }} +MemorySwapMax={{ swap_max }} +{% else %} +{% endif %} +{% if cpu_quota is defined and cpu_quota != "" %} +CPUAccounting=1 +CPUQuota={{ cpu_quota }} +{% else %} +{% endif %} diff --git a/roles/users/vars/main.yml b/roles/users/vars/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/roles/users/vars/main.yml @@ -0,0 +1 @@ +---