#!/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, missing_required_lib
from ansible.module_utils.basic import jsonify
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()
