1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-02-04 16:01:55 +00:00
community.general/plugins/modules/homectl.py
patchback[bot] b769b0bc01
[PR #11400/236b9c0e backport][stable-12] Sort imports with ruff check --fix (#11409)
Sort imports with ruff check --fix (#11400)

Sort imports with ruff check --fix.

(cherry picked from commit 236b9c0e04)

Co-authored-by: Felix Fontein <felix@fontein.de>
2026-01-09 19:36:52 +01:00

688 lines
25 KiB
Python

#!/usr/bin/python
# Copyright (c) 2022, James Livulpi
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import annotations
DOCUMENTATION = r"""
module: homectl
author:
- "James Livulpi (@jameslivulpi)"
short_description: Manage user accounts with systemd-homed
version_added: 4.4.0
description:
- Manages a user's home directory managed by systemd-homed.
notes:
- This module requires the deprecated L(crypt Python module, https://docs.python.org/3.12/library/crypt.html) library which
was removed from Python 3.13. For Python 3.13 or newer, you need to install L(legacycrypt, https://pypi.org/project/legacycrypt/).
requirements:
- legacycrypt (on Python 3.13 or newer)
extends_documentation_fragment:
- community.general.attributes
attributes:
check_mode:
support: full
diff_mode:
support: none
options:
name:
description:
- The user name to create, remove, or update.
required: true
aliases: ['user', 'username']
type: str
password:
description:
- Set the user's password to this.
- Homed requires this value to be in cleartext on user creation and updating a user.
- The module takes the password and generates a password hash in SHA-512 with 10000 rounds of salt generation using
crypt.
- See U(https://systemd.io/USER_RECORD/).
- This is required for O(state=present). When an existing user is updated this is checked against the stored hash in
homed.
type: str
state:
description:
- The operation to take on the user.
choices: ['absent', 'present']
default: present
type: str
storage:
description:
- Indicates the storage mechanism for the user's home directory.
- If the storage type is not specified, C(homed.conf(5\)) defines which default storage to use.
- Only used when a user is first created.
choices: ['classic', 'luks', 'directory', 'subvolume', 'fscrypt', 'cifs']
type: str
disksize:
description:
- The intended home directory disk space.
- Human readable value such as V(10G), V(10M), or V(10B).
type: str
resize:
description:
- When used with O(disksize) this attempts to resize the home directory immediately.
default: false
type: bool
realname:
description:
- The user's real ('human') name.
- This can also be used to add a comment to maintain compatibility with C(useradd).
aliases: ['comment']
type: str
realm:
description:
- The 'realm' a user is defined in.
type: str
email:
description:
- The email address of the user.
type: str
location:
description:
- A free-form location string describing the location of the user.
type: str
iconname:
description:
- The name of an icon picked by the user, for example for the purpose of an avatar.
- Should follow the semantics defined in the Icon Naming Specification.
- See U(https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html) for specifics.
type: str
homedir:
description:
- Path to use as home directory for the user.
- This is the directory the user's home directory is mounted to while the user is logged in.
- This is not where the user's data is actually stored, see O(imagepath) for that.
- Only used when a user is first created.
type: path
imagepath:
description:
- Path to place the user's home directory.
- See U(https://www.freedesktop.org/software/systemd/man/homectl.html#--image-path=PATH) for more information.
- Only used when a user is first created.
type: path
uid:
description:
- Sets the UID of the user.
- If using O(gid) homed requires the value to be the same.
- Only used when a user is first created.
type: int
gid:
description:
- Sets the gid of the user.
- If using O(uid) homed requires the value to be the same.
- Only used when a user is first created.
type: int
mountopts:
description:
- String separated by comma each indicating mount options for a users home directory.
- Valid options are V(nosuid), V(nodev) or V(noexec).
- Homed by default uses V(nodev) and V(nosuid) while V(noexec) is off.
type: str
umask:
description:
- Sets the umask for the user's login sessions.
- Value from V(0000) to V(0777).
type: int
memberof:
description:
- String separated by comma each indicating a UNIX group this user shall be a member of.
- Groups the user should be a member of should be supplied as comma separated list.
aliases: ['groups']
type: str
skeleton:
description:
- The absolute path to the skeleton directory to populate a new home directory from.
- This is only used when a home directory is first created.
- If not specified homed by default uses V(/etc/skel).
aliases: ['skel']
type: path
shell:
description:
- Shell binary to use for terminal logins of given user.
- If not specified homed by default uses V(/bin/bash).
type: str
environment:
description:
- String separated by comma each containing an environment variable and its value to set for the user's login session,
in a format compatible with C(putenv(\)).
- Any environment variable listed here is automatically set by pam_systemd for all login sessions of the user.
aliases: ['setenv']
type: str
timezone:
description:
- Preferred timezone to use for the user.
- Should be a tzdata compatible location string such as V(America/New_York).
type: str
locked:
description:
- Whether the user account should be locked or not.
type: bool
language:
description:
- The preferred language/locale for the user.
- This should be in a format compatible with the E(LANG) environment variable.
type: str
passwordhint:
description:
- Password hint for the given user.
type: str
sshkeys:
description:
- String separated by comma each listing a SSH public key that is authorized to access the account.
- The keys should follow the same format as the lines in a traditional C(~/.ssh/authorized_key) file.
type: str
notbefore:
description:
- A time since the UNIX epoch before which the record should be considered invalid for the purpose of logging in.
type: int
notafter:
description:
- A time since the UNIX epoch after which the record should be considered invalid for the purpose of logging in.
type: int
"""
EXAMPLES = r"""
- name: Add the user 'james'
community.general.homectl:
name: johnd
password: myreallysecurepassword1!
state: present
- name: Add the user 'alice' with a zsh shell, uid of 1000, and gid of 2000
community.general.homectl:
name: alice
password: myreallysecurepassword1!
state: present
shell: /bin/zsh
uid: 1000
gid: 1000
- name: Modify an existing user 'frank' to have 10G of diskspace and resize usage now
community.general.homectl:
name: frank
password: myreallysecurepassword1!
state: present
disksize: 10G
resize: true
- name: Remove an existing user 'janet'
community.general.homectl:
name: janet
state: absent
"""
RETURN = r"""
data:
description: Dictionary returned from C(homectl inspect -j).
returned: success
type: dict
sample:
{
"data": {
"binding": {
"e9ed2a5b0033427286b228e97c1e8343": {
"fileSystemType": "btrfs",
"fileSystemUuid": "7bd59491-2812-4642-a492-220c3f0c6c0b",
"gid": 60268,
"imagePath": "/home/james.home",
"luksCipher": "aes",
"luksCipherMode": "xts-plain64",
"luksUuid": "7f05825a-2c38-47b4-90e1-f21540a35a81",
"luksVolumeKeySize": 32,
"partitionUuid": "5a906126-d3c8-4234-b230-8f6e9b427b2f",
"storage": "luks",
"uid": 60268
}
},
"diskSize": 3221225472,
"disposition": "regular",
"lastChangeUSec": 1641941238208691,
"lastPasswordChangeUSec": 1641941238208691,
"privileged": {
"hashedPassword": [
"$6$ov9AKni.trf76inT$tTtfSyHgbPTdUsG0CvSSQZXGqFGdHKQ9Pb6e0BTZhDmlgrL/vA5BxrXduBi8u/PCBiYUffGLIkGhApjKMK3bV."
]
},
"signature": [
{
"data": "o6zVFbymcmk4YTVaY6KPQK23YCp+VkXdGEeniZeV1pzIbFzoaZBvVLPkNKMoPAQbodY5BYfBtuy41prNL78qAg==",
"key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAbs7ELeiEYBxkUQhxZ+5NGyu6J7gTtZtZ5vmIw3jowcY=\n-----END PUBLIC KEY-----\n"
}
],
"status": {
"e9ed2a5b0033427286b228e97c1e8343": {
"diskCeiling": 21845405696,
"diskFloor": 268435456,
"diskSize": 3221225472,
"service": "io.systemd.Home",
"signedLocally": true,
"state": "inactive"
}
},
"userName": "james"
}
}
"""
import json
import traceback
from ansible.module_utils.basic import AnsibleModule, jsonify, missing_required_lib
from ansible.module_utils.common.text.formatters import human_to_bytes
CRYPT_IMPORT_ERROR: str | None
try:
import crypt
except ImportError:
HAS_CRYPT = False
CRYPT_IMPORT_ERROR = traceback.format_exc()
else:
HAS_CRYPT = True
CRYPT_IMPORT_ERROR = None
LEGACYCRYPT_IMPORT_ERROR: str | None
try:
import legacycrypt
if not HAS_CRYPT:
crypt = legacycrypt
except ImportError:
HAS_LEGACYCRYPT = False
LEGACYCRYPT_IMPORT_ERROR = traceback.format_exc()
else:
HAS_LEGACYCRYPT = True
LEGACYCRYPT_IMPORT_ERROR = None
class Homectl:
def __init__(self, module):
self.module = module
self.state = module.params["state"]
self.name = module.params["name"]
self.password = module.params["password"]
self.storage = module.params["storage"]
self.disksize = module.params["disksize"]
self.resize = module.params["resize"]
self.realname = module.params["realname"]
self.realm = module.params["realm"]
self.email = module.params["email"]
self.location = module.params["location"]
self.iconname = module.params["iconname"]
self.homedir = module.params["homedir"]
self.imagepath = module.params["imagepath"]
self.uid = module.params["uid"]
self.gid = module.params["gid"]
self.umask = module.params["umask"]
self.memberof = module.params["memberof"]
self.skeleton = module.params["skeleton"]
self.shell = module.params["shell"]
self.environment = module.params["environment"]
self.timezone = module.params["timezone"]
self.locked = module.params["locked"]
self.passwordhint = module.params["passwordhint"]
self.sshkeys = module.params["sshkeys"]
self.language = module.params["language"]
self.notbefore = module.params["notbefore"]
self.notafter = module.params["notafter"]
self.mountopts = module.params["mountopts"]
self.result = {}
# Cannot run homectl commands if service is not active
def homed_service_active(self):
is_active = True
cmd = ["systemctl", "show", "systemd-homed.service", "-p", "ActiveState"]
rc, show_service_stdout, stderr = self.module.run_command(cmd)
if rc == 0:
state = show_service_stdout.rsplit("=")[1]
if state.strip() != "active":
is_active = False
return is_active
def user_exists(self):
exists = False
valid_pw = False
# Get user properties if they exist in json
rc, stdout, stderr = self.get_user_metadata()
if rc == 0:
exists = True
# User exists now compare password given with current hashed password stored in the user metadata.
if self.state != "absent": # Don't need checking on remove user
stored_pwhash = json.loads(stdout)["privileged"]["hashedPassword"][0]
if self._check_password(stored_pwhash):
valid_pw = True
return exists, valid_pw
def create_user(self):
record = self.create_json_record(create=True)
cmd = [self.module.get_bin_path("homectl", True)]
cmd.append("create")
cmd.append("--identity=-") # Read the user record from standard input.
return self.module.run_command(cmd, data=record)
def _hash_password(self, password):
method = crypt.METHOD_SHA512
salt = crypt.mksalt(method, rounds=10000)
pw_hash = crypt.crypt(password, salt)
return pw_hash
def _check_password(self, pwhash):
hash = crypt.crypt(self.password, pwhash)
return pwhash == hash
def remove_user(self):
cmd = [self.module.get_bin_path("homectl", True)]
cmd.append("remove")
cmd.append(self.name)
return self.module.run_command(cmd)
def prepare_modify_user_command(self):
record = self.create_json_record()
cmd = [self.module.get_bin_path("homectl", True)]
cmd.append("update")
cmd.append(self.name)
cmd.append("--identity=-") # Read the user record from standard input.
# Resize disksize now resize = true
# This is not valid in user record (json) and requires it to be passed on command.
if self.disksize and self.resize:
cmd.append("--and-resize")
cmd.append("true")
self.result["changed"] = True
return cmd, record
def get_user_metadata(self):
cmd = [self.module.get_bin_path("homectl", True)]
cmd.append("inspect")
cmd.append(self.name)
cmd.append("-j")
cmd.append("--no-pager")
rc, stdout, stderr = self.module.run_command(cmd)
return rc, stdout, stderr
# Build up dictionary to jsonify for homectl commands.
def create_json_record(self, create=False):
record = {}
user_metadata = {}
self.result["changed"] = False
# Get the current user record if not creating a new user record.
if not create:
rc, user_metadata, stderr = self.get_user_metadata()
user_metadata = json.loads(user_metadata)
# Remove elements that are not meant to be updated from record.
# These are always part of the record when a user exists.
user_metadata.pop("signature", None)
user_metadata.pop("binding", None)
user_metadata.pop("status", None)
# Let last change Usec be updated by homed when command runs.
user_metadata.pop("lastChangeUSec", None)
# Now only change fields that are called on leaving what's currently in the record intact.
record = user_metadata
record["userName"] = self.name
record["secret"] = {"password": [self.password]}
if create:
password_hash = self._hash_password(self.password)
record["privileged"] = {"hashedPassword": [password_hash]}
self.result["changed"] = True
if self.uid and self.gid and create:
record["uid"] = self.uid
record["gid"] = self.gid
self.result["changed"] = True
if self.memberof:
member_list = list(self.memberof.split(","))
if member_list != record.get("memberOf", [None]):
record["memberOf"] = member_list
self.result["changed"] = True
if self.realname:
if self.realname != record.get("realName"):
record["realName"] = self.realname
self.result["changed"] = True
# Cannot update storage unless were creating a new user.
# See 'Fields in the binding section' at https://systemd.io/USER_RECORD/
if self.storage and create:
record["storage"] = self.storage
self.result["changed"] = True
# Cannot update homedir unless were creating a new user.
# See 'Fields in the binding section' at https://systemd.io/USER_RECORD/
if self.homedir and create:
record["homeDirectory"] = self.homedir
self.result["changed"] = True
# Cannot update imagepath unless were creating a new user.
# See 'Fields in the binding section' at https://systemd.io/USER_RECORD/
if self.imagepath and create:
record["imagePath"] = self.imagepath
self.result["changed"] = True
if self.disksize:
# convert human readable to bytes
if self.disksize != record.get("diskSize"):
record["diskSize"] = human_to_bytes(self.disksize)
self.result["changed"] = True
if self.realm:
if self.realm != record.get("realm"):
record["realm"] = self.realm
self.result["changed"] = True
if self.email:
if self.email != record.get("emailAddress"):
record["emailAddress"] = self.email
self.result["changed"] = True
if self.location:
if self.location != record.get("location"):
record["location"] = self.location
self.result["changed"] = True
if self.iconname:
if self.iconname != record.get("iconName"):
record["iconName"] = self.iconname
self.result["changed"] = True
if self.skeleton:
if self.skeleton != record.get("skeletonDirectory"):
record["skeletonDirectory"] = self.skeleton
self.result["changed"] = True
if self.shell:
if self.shell != record.get("shell"):
record["shell"] = self.shell
self.result["changed"] = True
if self.umask:
if self.umask != record.get("umask"):
record["umask"] = self.umask
self.result["changed"] = True
if self.environment:
if self.environment != record.get("environment", [None]):
record["environment"] = list(self.environment.split(","))
self.result["changed"] = True
if self.timezone:
if self.timezone != record.get("timeZone"):
record["timeZone"] = self.timezone
self.result["changed"] = True
if self.locked:
if self.locked != record.get("locked"):
record["locked"] = self.locked
self.result["changed"] = True
if self.passwordhint:
if self.passwordhint != record.get("privileged", {}).get("passwordHint"):
record["privileged"]["passwordHint"] = self.passwordhint
self.result["changed"] = True
if self.sshkeys:
if self.sshkeys != record.get("privileged", {}).get("sshAuthorizedKeys"):
record["privileged"]["sshAuthorizedKeys"] = list(self.sshkeys.split(","))
self.result["changed"] = True
if self.language:
if self.locked != record.get("preferredLanguage"):
record["preferredLanguage"] = self.language
self.result["changed"] = True
if self.notbefore:
if self.locked != record.get("notBeforeUSec"):
record["notBeforeUSec"] = self.notbefore
self.result["changed"] = True
if self.notafter:
if self.locked != record.get("notAfterUSec"):
record["notAfterUSec"] = self.notafter
self.result["changed"] = True
if self.mountopts:
opts = list(self.mountopts.split(","))
if "nosuid" in opts:
if record.get("mountNoSuid") is not True:
record["mountNoSuid"] = True
self.result["changed"] = True
else:
if record.get("mountNoSuid") is not False:
record["mountNoSuid"] = False
self.result["changed"] = True
if "nodev" in opts:
if record.get("mountNoDevices") is not True:
record["mountNoDevices"] = True
self.result["changed"] = True
else:
if record.get("mountNoDevices") is not False:
record["mountNoDevices"] = False
self.result["changed"] = True
if "noexec" in opts:
if record.get("mountNoExecute") is not True:
record["mountNoExecute"] = True
self.result["changed"] = True
else:
if record.get("mountNoExecute") is not False:
record["mountNoExecute"] = False
self.result["changed"] = True
return jsonify(record)
def main():
module = AnsibleModule(
argument_spec=dict(
state=dict(type="str", default="present", choices=["absent", "present"]),
name=dict(type="str", required=True, aliases=["user", "username"]),
password=dict(type="str", no_log=True),
storage=dict(type="str", choices=["classic", "luks", "directory", "subvolume", "fscrypt", "cifs"]),
disksize=dict(type="str"),
resize=dict(type="bool", default=False),
realname=dict(type="str", aliases=["comment"]),
realm=dict(type="str"),
email=dict(type="str"),
location=dict(type="str"),
iconname=dict(type="str"),
homedir=dict(type="path"),
imagepath=dict(type="path"),
uid=dict(type="int"),
gid=dict(type="int"),
umask=dict(type="int"),
environment=dict(type="str", aliases=["setenv"]),
timezone=dict(type="str"),
memberof=dict(type="str", aliases=["groups"]),
skeleton=dict(type="path", aliases=["skel"]),
shell=dict(type="str"),
locked=dict(type="bool"),
passwordhint=dict(type="str", no_log=True),
sshkeys=dict(type="str", no_log=True),
language=dict(type="str"),
notbefore=dict(type="int"),
notafter=dict(type="int"),
mountopts=dict(type="str"),
),
supports_check_mode=True,
required_if=[
("state", "present", ["password"]),
("resize", True, ["disksize"]),
],
)
if not HAS_CRYPT and not HAS_LEGACYCRYPT:
module.fail_json(
msg=missing_required_lib("crypt (part of standard library up to Python 3.12) or legacycrypt (PyPI)"),
exception=CRYPT_IMPORT_ERROR,
)
homectl = Homectl(module)
homectl.result["state"] = homectl.state
# First we need to make sure homed service is active
if not homectl.homed_service_active():
module.fail_json(msg="systemd-homed.service is not active")
# handle removing user
if homectl.state == "absent":
user_exists, valid_pwhash = homectl.user_exists()
if user_exists:
if module.check_mode:
module.exit_json(changed=True)
rc, stdout, stderr = homectl.remove_user()
if rc != 0:
module.fail_json(name=homectl.name, msg=stderr, rc=rc)
homectl.result["changed"] = True
homectl.result["rc"] = rc
homectl.result["msg"] = f"User {homectl.name} removed!"
else:
homectl.result["changed"] = False
homectl.result["msg"] = "User does not exist!"
# Handle adding a user
if homectl.state == "present":
user_exists, valid_pwhash = homectl.user_exists()
if not user_exists:
if module.check_mode:
module.exit_json(changed=True)
rc, stdout, stderr = homectl.create_user()
if rc != 0:
module.fail_json(name=homectl.name, msg=stderr, rc=rc)
rc, user_metadata, stderr = homectl.get_user_metadata()
homectl.result["data"] = json.loads(user_metadata)
homectl.result["rc"] = rc
homectl.result["msg"] = f"User {homectl.name} created!"
else:
if valid_pwhash:
# Run this to see if changed would be True or False which is useful for check_mode
cmd, record = homectl.prepare_modify_user_command()
else:
# User gave wrong password fail with message
homectl.result["changed"] = False
homectl.result["msg"] = "User exists but password is incorrect!"
module.fail_json(**homectl.result)
if module.check_mode:
module.exit_json(**homectl.result)
# Now actually modify the user if changed was set to true at any point.
if homectl.result["changed"]:
rc, stdout, stderr = module.run_command(cmd, data=record)
if rc != 0:
module.fail_json(name=homectl.name, msg=stderr, rc=rc, changed=False)
rc, user_metadata, stderr = homectl.get_user_metadata()
homectl.result["data"] = json.loads(user_metadata)
homectl.result["rc"] = rc
if homectl.result["changed"]:
homectl.result["msg"] = f"User {homectl.name} modified"
module.exit_json(**homectl.result)
if __name__ == "__main__":
main()