#!/opt/cloudlinux/venv/bin/python3 -bb
# -*- coding: utf-8 -*-
#
# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2022 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

# pylint: disable=no-absolute-import

import os
import time
import subprocess
import signal
from typing import Optional, List, Dict
import traceback
import fcntl
import secureio
from pathlib import Path
import glob

import psutil

from clcommon.lib import cledition
from clcommon.utils import run_command, ExternalProgramFailed, get_file_lines, write_file_lines
from cldetectlib import is_da
from clsentry import CLLIB_DSN, init_sentry_client
from clsentry.utils import get_pkg_version
from secureio import write_file_via_tempfile, logging


_CAGEFS_MOUNT_BIN_FILE = '/usr/sbin/cagefs-mount'
_LSNS_BIN_FILE = '/usr/bin/lsns'
_MOUNT_BIN_FILE = '/bin/mount'
_UMOUNT_BIN_FILE = '/bin/umount'
_NSENTER_BIN = '/bin/nsenter'
_GREP_BIN = '/bin/grep'

_LOCK_FILE_NAME_PATTERN = '/var/cagefs/%s/%s.lock'
_CAGEFS_SKELETON_DIR = '/usr/share/cagefs-skeleton'

# /var/cagefs.uid/$PREFIX/$UID/ns.mnt
_NS_MNT_FILE_NAME_PATTERN = '/var/cagefs.uid/%s/%d/ns.mnt'
# /var/cagefs.uid/$PREFIX/$UID/ns.id
_NS_ID_FILE_NAME_PATTERN = '/var/cagefs.uid/%s/%d/ns.id'

sentry_client = init_sentry_client(
    'cagefs',
    release=get_pkg_version('cagefs'),
    dsn=CLLIB_DSN,
    handle=False,
)


class LockFailedException(Exception):
    pass


class NsNotFoundException(Exception):
    pass


class MountCommandFailedException(Exception):
    pass


class CagefsMountInvalidUserException(Exception):
    pass


class CagefsMountPIDWriteFailed(Exception):
    pass


class CagefsMountJailCallFailed(Exception):
    pass


_EXIT_VALIDATION_ERROR = 1
_EXIT_JAIL_MOUNT_ERROR = 2
_EXIT_PID_WRITE_ERROR = 3


class CagefsMountNotStartedException(Exception):
    def __init__(self, msg=''):
        self.msg = f'{msg}'
        if cledition.is_container():
            self.msg += '\nThe Virtuozzo host mounting limit may have been reached.\n' \
                'Check for the presence of the kernel error "reached the limit on mounts" on VZ host.\n' \
                'More info at https://docs.cloudlinux.com/cloudlinux_installation/#known-restrictions-and-issues'

    def __str__(self):
        return self.msg


def _find_command_in_ns(ns_id: str, cmd_to_search: str) -> bool:
    """
    Find proposed command in proposed NS id
    :param ns_id: NS id to search command
    :param cmd_to_search: Command to search
    :return: True - command found in NS, False - not found
    """
    # Example:
    # # lsns --type mnt --list --output command <ns_id>
    #  COMMAND
    #  /bin/sh /usr/bin/mysqld_safe --basedir=/usr
    #  /usr/libexec/mysqld --basedir=/usr --datadir=/var/lib/mysql ....
    try:
        cmd = [_LSNS_BIN_FILE, '--type', 'mnt', '--list', '--output', 'command', ns_id]
        stdout = run_command(cmd)
        for line in stdout.split('\n'):
            line = line.strip()
            if line == '' or 'COMMAND' in line:
                # Skip header line and empty line
                continue
            # line example: '/bin/sh /usr/bin/mysqld_safe --basedir=/usr'
            if line == cmd_to_search:
                return True
    except (ExternalProgramFailed, ):
        pass
    return False


def _find_save_ns_id_for_user(username: str, filename_to_write: str):
    """
    Find user's NS id and write it to file
    :param username: User name
    :param filename_to_write: File name to write
    """
    cmd_to_search = f'{_CAGEFS_MOUNT_BIN_FILE} {username}'
    # 1. Get all user's NS id
    # lsns --type mnt --list --output ns,command
    cmd = [_LSNS_BIN_FILE, '--type', 'mnt', '--list', '--output', 'ns,command']
    stdout = run_command(cmd)
    ns_id_list = []
    for line in stdout.split('\n'):
        line = line.strip()
        if line == '' or 'NS' in line:
            # Skip header line and empty line
            continue
        # line example:
        # '4026532195 /usr/sbin/pdns_server --socket-dir=/run/pdns --guardian=no --daemon=no ...'
        ns_id, ns_command = line.split(' ', 1)
        if ns_command.strip() == cmd_to_search:
            # Command found, write NS id
            write_file_via_tempfile(ns_id, filename_to_write, 0o600)
            return
        ns_id_list.append(ns_id)
    # Command not found in lsns output, search it in each NS
    for ns_id in ns_id_list:
        # try to find user's NS by process '/usr/sbin/cagefs-mount <username>'
        if _find_command_in_ns(ns_id, cmd_to_search):
            # Command found, write NS id
            write_file_via_tempfile(ns_id, filename_to_write, 0x600)
            return
    raise NsNotFoundException(f"NS not found for user {username} when save NS id to file")


def _get_all_ns() -> Dict[str, str]:
    """
    Get all NS with processes in system
    :return: Dict: {'some_ns_id', 'some_pid_from_ns'}
    """
    ns_dict = {}
    try:
        cmd = [_LSNS_BIN_FILE, '--type', 'mnt', '--list', '--output', 'ns,pid']
        stdout = run_command(cmd)
        for line in stdout.split('\n'):
            line = line.strip()
            if line == '' or 'PID' in line:
                # Skip header line and empty line
                continue
            # line example: '4026532195  1582'
            line_parts = line.split()
            ns_dict[line_parts[0].strip()] = line_parts[1].strip()
    except (ExternalProgramFailed, ):
        pass
    return ns_dict


def _get_pid_list_by_ns_id(ns_id: str, user_homedir: str) -> List[int]:
    """
    Retrieves PID list for user in proposed NS id
    :param ns_id: NS id to retrieve PID list
    :param user_homedir: User homedir
    :return: list PIDs in NS, [] - NS not found/has no processes
    """
    # Get all NS id in system with some PID in NS as dict {ns_id: pid}
    all_ns_id_dict = _get_all_ns()
    if ns_id not in all_ns_id_dict:
        return []
    ns_pid = all_ns_id_dict[ns_id]
    try:
        # Check that NS with ns_id owned by user with proposed homedir
        # /bin/nsenter -m -t PID /bin/grep /usr/share/cagefs-skeleton/$USERHOME /proc/mounts
        user_home_is_skeleton = os.path.join(_CAGEFS_SKELETON_DIR, user_homedir)
        proc = subprocess.run([_NSENTER_BIN, '-m', '-t', ns_pid, _GREP_BIN, user_home_is_skeleton, '/proc/mounts'],
                              stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False)
        if proc.returncode != 0:
            # user home mount not found in NS
            return []
        pid_list = []
        # NS valid, get PID list from it
        cmd = [_LSNS_BIN_FILE, '--type', 'mnt', '--list', '--output', 'pid', ns_id]
        stdout = run_command(cmd)
        for line in stdout.split('\n'):
            line = line.strip()
            if line == '' or 'PID' in line:
                # Skip header line and empty line
                continue
            pid_list.append(int(line))
        return pid_list
    except (ExternalProgramFailed, OSError, IOError, ):
        pass
    return []


def _get_pid_list_for_user(user_uid: int, cagefs_user_prefix: str, user_homedir: str) -> Optional[List[int]]:
    """
    Retrieve pid list from user's NS
    :param user_uid: User's uid
    :param cagefs_user_prefix: User's cagefs prefix
    :param user_homedir: User's homedir
    :return: List of user's PIDs or None if user has no NS
    """
    try:
        # Read NS id from file
        lines = get_file_lines(_NS_ID_FILE_NAME_PATTERN % (cagefs_user_prefix, user_uid))
        ns_id = lines[0].strip()
        return _get_pid_list_by_ns_id(ns_id, user_homedir)
    except (OSError, IOError, IndexError, ):
        # No NS for this user
        pass
    return None


def _kill_processes_in_list(pid_list: List[int]):
    """
    Kill processes from list
    :param pid_list: PID list to kill
    """
    for pid in pid_list:
        try:
            parent = psutil.Process(pid)
        except psutil.NoSuchProcess:
            logging(f'Process with PID {pid} is already dead, nothing to kill')
            continue

        children = parent.children(recursive=True)
        children.append(parent)
        for p in children:
            try:
                p.terminate()
            except psutil.NoSuchProcess:
                pass

        _, alive = psutil.wait_procs(children, timeout=3)
        if not alive:
            continue

        for p in alive:
            try:
                p.kill()
            except psutil.NoSuchProcess:
                pass

        _, still_alive = psutil.wait_procs(alive, timeout=3)
        if still_alive:
            logging("Some processes are still alive after sending "
                    f"the SIGKILL signal: {still_alive}")


def _acquire_lock(filename: str) -> int:
    """
    Creates a lock file and acquire lock on it
    :return: File descriptor of created file
    """
    try:
        os.makedirs(os.path.dirname(filename), mode=0o700, exist_ok=True)
        lock_fd = os.open(filename, os.O_CREAT, 0o600)
        fcntl.flock(lock_fd, fcntl.LOCK_EX)
        return lock_fd
    except IOError:
        raise LockFailedException("IO error happened while getting lock")


def _release_lock(lock_fd: int) -> None:
    """
    Release lock and close lock file
    :param lock_fd: Lock file descriptor
    """
    fcntl.flock(lock_fd, fcntl.LOCK_UN)
    os.close(lock_fd)


def _is_pid_file_created_successfully(
    pid_filename: str,
    process: subprocess.Popen
) -> bool:
    """
    Waits for cagefs-mount pid file appears up to 60 seconds
    :param pid_filename: PID filename
    :param process: subprocess that creates the PID file
    :return: True - appears; False - Not
    """
    for i in range(60000):
        try:
            os.stat(pid_filename)
            return True
        except (OSError, IOError):
            # Error, PID file absent
            # Check if subprocess is still alive
            if process.poll() is not None:
                return False
            time.sleep(0.001)

    return False


def _create_namespace_user(username: str) -> bool:
    """
    Create namespace for single user
    :param username: User name to create namespace
    """
    from cagefsctl import get_user_prefix
    error = False
    background_processes = []
    pid_file_created = False
    cagefs_mount_time_taken = 0
    cagefs_mount_timeout = 20  # seconds
    try:
        cagefs_user_prefix = get_user_prefix(username)
        user_uid = secureio.clpwd.get_uid(username)
        # To create namespace for USER, use:
        # if /var/cagefs.uid/$PREFIX/$UID/ns.mnt bind mount exists already - do nothing
        # mkdir -p /var/cagefs.uid/$PREFIX/$UID
        # touch /var/cagefs.uid/$PREFIX/$UID/ns.mnt
        # /usr/sbin/cagefs-mount $USER
        # mount --bind /proc/$PID/ns/mnt /var/cagefs.uid/$PREFIX/$UID/ns.mnt
        # release lockfile /var/cagefs/$PREFIX/$USER.lock
        # _NS_MNT_FILE_NAME_PATTERN = '/var/cagefs.uid/$PREFIX/$UID/ns.mnt'
        # kill $PID (where $PID = pid of cagefs-mount process)
        ns_mnt_filename = _NS_MNT_FILE_NAME_PATTERN % (cagefs_user_prefix, user_uid)
        if os.path.exists(ns_mnt_filename):
            return False
        os.makedirs(os.path.dirname(ns_mnt_filename), mode=0o700, exist_ok=True)
        # touch /var/cagefs.uid/$PREFIX/$UID/ns.mnt
        Path(ns_mnt_filename).touch()
        cagefs_pid_file = f'/var/cagefs.uid/{cagefs_user_prefix}/{user_uid}/cagefs-mount.pid'
        try:
            os.unlink(cagefs_pid_file)
        except (OSError, IOError,):
            pass

        proc = subprocess.Popen(
            [_CAGEFS_MOUNT_BIN_FILE, username],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            shell=False,
            text=True,
        )
        cagefs_mount_pid = proc.pid
        background_processes.append(cagefs_mount_pid)

        # We should wait while binary creates cagefs mounts
        start_time = time.time()
        pid_file_created = _is_pid_file_created_successfully(cagefs_pid_file, proc)
        cagefs_mount_time_taken = time.time() - start_time
        if not pid_file_created:
            _kill_processes_in_list(background_processes)
            stdout, stderr = proc.communicate()
            stdout, stderr = stdout.strip(), stderr.strip()

            if proc.returncode == _EXIT_VALIDATION_ERROR:
                raise CagefsMountInvalidUserException(
                    f"Input arguments validation failed in cagefs-mount for user={username}.\n"
                    f'STDOUT: "{stdout}"\nSTDERR: "{stderr}"'
                )
            elif proc.returncode == _EXIT_JAIL_MOUNT_ERROR:
                raise CagefsMountJailCallFailed(
                    f"Can't create namespace {ns_mnt_filename} for user {username}.\n"
                    f'STDOUT: "{stdout}"\nSTDERR: "{stderr}"'
                )
            elif proc.returncode == _EXIT_PID_WRITE_ERROR:
                raise CagefsMountPIDWriteFailed(
                    f"cagefs-mount created namespace for user={username}, but was unable to write pidfile.\n"
                    f'STDOUT: "{stdout}"\nSTDERR: "{stderr}"'
                )
            raise CagefsMountNotStartedException(
                f'{cagefs_pid_file} was not created within the expected time frame.\n'
                f'STDOUT: "{stdout}"\nSTDERR: "{stderr}"'
            )

        # /bin/mount --bind /proc/$PID/ns/mnt /var/cagefs.uid/$PREFIX/$UID/ns.mnt
        res = subprocess.run(
            [_MOUNT_BIN_FILE, '--bind', f'/proc/{cagefs_mount_pid}/ns/mnt', ns_mnt_filename],
            capture_output=True,
            text=True,
            shell=False,
        )
        if res.returncode != 0:
            raise MountCommandFailedException(
                f"Can't mount {ns_mnt_filename} for user {username}.\n"
                f'STDOUT: "{res.stdout}"\nSTDERR: "{res.stderr}"'
            )

        # Find user's NS id and save it to file /var/cagefs.uid/$PREFIX/$UID/ns.id
        ns_id_filename = _NS_ID_FILE_NAME_PATTERN % (cagefs_user_prefix, user_uid)
        _find_save_ns_id_for_user(username, ns_id_filename)
    except (
            CagefsMountNotStartedException,
            MountCommandFailedException,
            CagefsMountInvalidUserException,
            CagefsMountJailCallFailed,
            CagefsMountPIDWriteFailed,
    ) as e:
        error = True
        logging(str(e))
        sentry_client.captureException()
    except Exception:
        error = True
        msg = traceback.format_exc()
        logging(f"General error while attempting to create a namespace for user {username}. Error is: {msg}")
        sentry_client.captureException()
    finally:
        # Kill cagefs-mount process
        _kill_processes_in_list(background_processes)
        if pid_file_created and cagefs_mount_time_taken > cagefs_mount_timeout:
            stdout, stderr = proc.communicate()
            sentry_client.captureMessage(
                f'{cagefs_pid_file} file was created after {cagefs_mount_time_taken:.2f}.\n'
                f'STDOUT: "{stdout}"\nSTDERR: "{stderr}"'
            )

    return error


def _delete_namespace_user(username: str) -> bool:
    """
    Delete namespace for single user
    :param username: User name to delete namespace
    """
    from cagefsctl import get_user_prefix
    lock_obj = None
    error = False
    try:
        cagefs_user_prefix = get_user_prefix(username)
        user_uid = secureio.clpwd.get_uid(username)
        user_homedir = secureio.clpwd.get_homedir(username)
        ns_mnt_filename = _NS_MNT_FILE_NAME_PATTERN % (cagefs_user_prefix, user_uid)
        if not os.path.exists(ns_mnt_filename):
            # User has no NS
            return False
        lock_obj = _acquire_lock(_LOCK_FILE_NAME_PATTERN % (cagefs_user_prefix, username))
        if not os.path.exists(ns_mnt_filename):
            # Check if User has no NS again after acquiring lock
            return False
        user_pids = _get_pid_list_for_user(user_uid, cagefs_user_prefix, user_homedir)
        if user_pids:
            _kill_processes_in_list(user_pids)

        # umount /var/cagefs.uid/$PREFIX/$UID/ns.mnt
        res = subprocess.run(
            [_UMOUNT_BIN_FILE, ns_mnt_filename],
            capture_output=True,
            text=True,
            shell=False,
            env={**os.environ, **{'LC_ALL': 'C'}},
        )
        if res.returncode != 0 and 'not mounted' not in res.stderr:
            raise MountCommandFailedException(
                f"Can't umount {ns_mnt_filename} for user {username}.\n"
                f'STDOUT: "{res.stdout}"\nSTDERR: "{res.stderr}"'
            )

        # rf -f /var/cagefs.uid/$PREFIX/$UID/ns.mnt
        os.unlink(ns_mnt_filename)
    except MountCommandFailedException as e:
        # NOTE: Don't set `error` to `True` to preserve the previous behavior,
        # but send errors to Sentry to determine if there are any actual issues
        # with this command and decide if something needs to be fixed
        logging(str(e))
        sentry_client.captureException()
    except (LockFailedException, ) as e:
        logging(f"Can't acqure lock for user {username}. Error is: {str(e)}")
        error = True
        sentry_client.captureException()
    except Exception:
        msg = traceback.format_exc()
        logging(f"Error during delete namespace for user {username}. Error is:\n{msg}")
        error = True
        sentry_client.captureException()
    if lock_obj:
        _release_lock(lock_obj)
    return error


def create_namespace_user_list(username_list: list[str], verbose = False) -> int:
    """
    Create namespace for users from list
    :param username_list: username list for prosess
    :param verbose: prints log in stdout if set
    """
    errors = 0
    for username in username_list:
        if verbose:
            print("Creating NS for user:", username)
        if _create_namespace_user(username):
            errors += 1
    return errors


def delete_namespace_user_list(username_list: list[str], verbose = False) -> bool:
    """
    Delete namespace for users from list
    :param username_list: username list for prosess
    :param verbose: If True, print messages to stdout
    """
    error = False
    for username in username_list:
        if verbose:
            print("Deleting NS for user:", username)
        if _delete_namespace_user(username):
            error = True
    return error


def _get_httpd_php_fpm_service_override_files() -> Dict[str, str]:
    """
    Get list of all php-fpm services override files on server
    :return Dict. Example:
        {'ea-php74-php-fpm.service': '/etc/systemd/system/ea-php74-php-fpm.service.d/override.conf',
         'ea-php56-php-fpm.service': '/etc/systemd/system/ea-php56-php-fpm.service.d/override.conf'
        }
    """
    systemd_dir = '/usr/lib/systemd/system'
    # Scan available ea-php fpm services
    mask_to_search = os.path.join(systemd_dir, 'ea-php*-fpm.service')
    service_names = [os.path.basename(x) for x in glob.glob(mask_to_search)]
    # Scan available alt-php fpm services
    mask_to_search = os.path.join(systemd_dir, 'alt-php*-fpm.service')
    service_names.extend([os.path.basename(x) for x in glob.glob(mask_to_search)])
    # Add additional services - native php-fpm and httpd
    # DA doesn't register service in /usr/lib/systemd/service, so we need to check /etc/systemd/system
    add_services_names = ['php-fpm.service', 'httpd.service']
    for service_name in add_services_names:
        if os.path.exists(os.path.join(systemd_dir, service_name)) or (
            is_da() and os.path.exists(os.path.join('/etc/systemd/system', service_name))
        ):
            service_names.append(service_name)
    # Create override configs list
    override_file_dict = {service_name: '/etc/systemd/system/%s.d/zzz-cagefs.conf' % service_name
                          for service_name in service_names}
    return override_file_dict


def fix_httpd_php_fpm_services():
    """
    Reconfigure httpd and ea-php-fpm services to work in without LVE
    Write to each systemd service file directives:
            PrivateDevices=false
            PrivateMounts=false
            PrivateTmp=false
    """
    try:
        override_files_dict = _get_httpd_php_fpm_service_override_files()
        lines_to_write = ['[Service]\n', 'PrivateDevices=false\n', 'PrivateMounts=false\n', 'PrivateTmp=false\n',
                          'ProtectSystem=false\n', 'ReadOnlyDirectories=\n', 'ReadWriteDirectories=\n',
                          'InaccessibleDirectories=\n', 'ProtectHome=false\n'
                          ]
        for override_file in override_files_dict.values():
            # Create /etc/systemd/system/%s.d directory if need
            os.makedirs(os.path.dirname(override_file), mode=0o700, exist_ok=True)
            write_file_lines(override_file, lines_to_write, 'w')
        os.system('/bin/systemctl daemon-reload 2> /dev/null')
        # Restart all need services
        for service_name in override_files_dict.keys():
            os.system(f'/sbin/service {service_name} restart 2> /dev/null')
    except (OSError, IOError,):
        pass


def restore_httpd_php_fpm_services():
    try:
        override_files_dict = _get_httpd_php_fpm_service_override_files()
        # override_files_dict example:
        # {'ea-php74-php-fpm.service': '/etc/systemd/system/ea-php74-php-fpm.service.d/zzz-cagefs.conf',
        #  'ea-php56-php-fpm.service': '/etc/systemd/system/ea-php56-php-fpm.service.d/zzz-cagefs.conf'}
        for override_file in override_files_dict.values():
            # Remove override file
            try:
                os.unlink(override_file)
            except (OSError, IOError, ):
                pass
            # Remove override dir if it empty
            try:
                os.rmdir(os.path.dirname(override_file))
            except (OSError, IOError, ):
                pass
        os.system('/bin/systemctl daemon-reload 2> /dev/null')
        # Restart all need services
        for service_name in override_files_dict.keys():
            os.system(f'/sbin/service {service_name} restart 2> /dev/null')
    except (OSError, IOError,):
        pass
