#!/usr/bin/env python3
# Copyright (C) 2010-2016 Petter Reinholdtsen <pere@hungry.com>
#               2023 Guido Berhoerster <guido+freiesoftware@berhoerster.name>
#               2010 Morten Werner Forsbring <werner@debian.org>
#
# Licensed under the GNU General Public License Version 2
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
# 02110-1301, USA.

__author__ = "Petter Reinholdtsen <pere@hungry.com>"

#
# Create local user and redirected home directory.
# If the local user logging in have uid >= 1000, create primary group
# and user in /etc/passwd and /etc/group, and create a home directory
# under /home/ if none exist already.

import os
import sys
import pwd
import grp
import subprocess
import shutil
import tempfile
import syslog
from pathlib import Path


HOOK_PATH = Path("/etc/mklocaluser.d")
MINIMUM_UID = 1000 # FIXME read UID_MIN from login.defs?


def check_and_create_localuser(pamh, user):
    # Fetch current user and group info, possibly from LDAP or NIS.
    try:
        userinfo = pwd.getpwnam(user)
    except KeyError as err:
        syslog.syslog(f"Unknown username, should never happen: {err}")
        return pamh.PAM_USER_UNKNOWN

    # Ignore users belwo minimum UID
    if userinfo.pw_uid < MINIMUM_UID:
        return pamh.PAM_SUCCESS

    # Ignore users with existing entry in /etc/passwd
    try:
        subprocess.run(
            ["getent", "passwd", "-s", "compat", user],
            capture_output=True, text=True, check=True
        )
    except subprocess.CalledProcessError as err:
        if err.returncode != 2:
            syslog.syslog(f"{err} {err.stderr.strip()}")
            return pamh.PAM_SYSTEM_ERR
    else:
        return pamh.PAM_SUCCESS

    # Check whether home directory is set
    if userinfo.pw_dir is None:
        syslog.syslog(f"Home directory is not set for user {user}")
        return pamh.PAM_USER_UNKNOWN
    home = Path(userinfo.pw_dir)

    # Determine location of local home directory
    try:
        result = subprocess.run(
            ["useradd", "-D"], capture_output=True, text=True, check=True
        )
    except subprocess.CalledProcessError as err:
        syslog.syslog(f"{err} {err.stderr.strip()}")
        return pamh.PAM_SYSTEM_ERR
    useradd_defaults = dict(
        line.split("=", maxsplit=1) for line in result.stdout.split()
    )
    new_home = Path(useradd_defaults.get("HOME", "/home")) / user

    # Ensure neither old nor new home already exist
    if home.is_dir() or new_home.is_dir():
        return pamh.PAM_SUCCESS

    try:
        groupname = grp.getgrgid(userinfo.pw_gid).gr_name
    except KeyError:
        syslog.syslog(f"Unknown primary group with gid {userinfo.pw_gid}")
        groupname = "[unknown]"

    # Create local user
    syslog.syslog(
        f"Creating local passwd/shadow entry uid={userinfo.pw_uid}({user}) "
        f"gid={userinfo.pw_gid}({groupname}) gecos='{userinfo.pw_gecos}' "
        f"home={new_home}"
    )
    with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
        # Use alternative path to the root directory to trick useradd into
        # using files
        root = Path(tmpdir) / "root"
        root.symlink_to("/")
        try:
            # Use "--prefix" option in order to create a local user, do not set
            # a group since it will not be found
            subprocess.run(
                [
                    "useradd", "--prefix", root, "--uid", str(userinfo.pw_uid),
                    "--no-user-group", "--create-home", "--home-dir", new_home,
                    "--comment", userinfo.pw_gecos, user
                ],
                capture_output=True, text=True, check=True
            )
            # Set the correct group
            subprocess.run(
                ["usermod", "-g", str(userinfo.pw_gid), user],
                capture_output=True, text=True, check=True
            )
        except subprocess.CalledProcessError as err:
            syslog.syslog(f"{err} {err.stderr.strip()}")
            return pamh.PAM_SYSTEM_ERR

    # Flush nscd cache to get rid of original user entry
    nscd = shutil.which("nscd")
    if nscd:
        subprocess.run(
            [nscd, "-i", "passwd"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        )

    # Hook for adjusting the freshly created home directory
    if HOOK_PATH.is_dir:
        try:
            subprocess.run(
                ["run-parts", HOOK_PATH],
                env=os.environ | {"ORIGHOMEDIR": home, "USER": user},
                check=True
            )
        except subprocess.CalledProcessError as err:
            syslog.syslog(f"{err} {err.stderr.strip()}")

    # At this point, the HOME environment variable is still set to the
    # value (i.e. path) as provided by the LDAP database. With pam_mklocaluser,
    # we want a HOME path with the pattern /<topdir>/<user>. Luckily
    # the pam_python.so implementation provides an easy-to-use interface to
    # pam_getenv/pam_putenv:
    pamh.env['HOME'] = str(new_home)

    return pamh.PAM_SUCCESS


def pam_sm_setcred(pamh, flags, argv):
    return pamh.PAM_SUCCESS


def pam_sm_authenticate(pamh, flags, argv):
    return pamh.PAM_SUCCESS


def pam_sm_acct_mgmt(pamh, flags, argv):
    return pamh.PAM_SUCCESS


def pam_sm_open_session(pamh, flags, argv):
    syslog.openlog("pam_mklocaluser", syslog.LOG_PID, syslog.LOG_AUTH)
    try:
        user = pamh.get_user(None)
    except pamh.exception as exc:
        return exc.pam_result
    if user is None:
        syslog.syslog("No user, ignoring pam-python for mklocaluser")
        return pamh.PAM_USER_UNKNOWN

    # Only create local users for console logins
    try:
        if pamh.rhost is not None and len(pamh.rhost) != 0:
            syslog.syslog("Remote login, ignoring pam-python for mklocaluser")
            return pamh.PAM_SUCCESS
    except pamh.exception as exc:
        return exc.pam_result

    try:
        return check_and_create_localuser(pamh, user)
    except Exception as exc:
        syslog.syslog(f"Unexpected exception, should never happen: {exc}")
        return pamh.PAM_SYSTEM_ERR


def pam_sm_close_session(pamh, flags, argv):
    return pamh.PAM_SUCCESS


def pam_sm_chauthtok(pamh, flags, argv):
    return pamh.PAM_SUCCESS


# Test if the code work.    Argument is username to simulate login for.
if __name__ == '__main__':
    syslog.openlog("pam_mklocaluser", syslog.LOG_PID, syslog.LOG_AUTH)

    class pam_handler:
        PAM_SUCCESS = 1
        PAM_USER_UNKNOWN = 2
        PAM_SYSTEM_ERR = 3
        PAM_TRY_AGAIN = 4
        PAM_TEXT_INFO = 5

        def Message(self, tag, str):
            return str

        def conversation(self, msg):
            print("PAM conversation: " + msg)
            return

    pamh = pam_handler()
    user = sys.argv[1]
    check_and_create_localuser(pamh, user)
