#!/opt/cloudlinux/venv/bin/python3 -bb
#coding:utf-8

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import grp
import os
import pwd
import re
import sys
import json
import shlex
import shutil
import argparse
import subprocess
import configparser
import cldetectlib as detect
from clcommon.utils import get_rhn_systemid_value
from clcommon.lib.cledition import is_cl_solo_edition
from clcommon.ui_config import UIConfig

SOURCE_PATH_BASE = "/usr/share/lvemanager-xray/"
SOURCE_PATH_ADMIN = "/usr/share/lvemanager-xray/xray-admin/"
SOURCE_PATH_USER = "/usr/share/lvemanager-xray/xray-user/"
LVEMANAGER_SOURCE_PATH = "/usr/share/l.v.e-manager/"
XRAY_MANAGER_UTILITY = "/usr/sbin/cloudlinux-xray-manager"
DYNAMICUI_SYNC_CONFIG_COMMAND = "/usr/share/l.v.e-manager/utils/dynamicui.py --sync-conf=all --silent"
CLOUDLINUX_CONFIG_COMMAND = "/usr/sbin/cloudlinux-config set --data '{\"options\": {\"uiSettings\": {\"hideXrayApp\": true}}}' --json"
PANEL_INTEGRATION_CONFIG = '/opt/cpvendor/etc/integration.ini'
CLOUDLINUX_TRANSLATIONS_DIR = '/usr/share/cloudlinux-translations/'
CHECK_PLUGIN_CRON = {
    "name":  "xray-plugin-check-cron",
    "schedule": "*/10 * * * *",
    "executor": "root",
    "command": "/usr/bin/flock -n /var/run/cloudlinux-xray-plugin-check.cronlock /usr/share/lvemanager-xray/plugins/install-xray-plugin.py --check > /dev/null 2>&1"
}

class Base:
    def __init__(self):
        pass

    def add_cron(self, file_name, schedule, executor, command):
        """
        Create cron job
        :param file_name: cron name
        :param schedule: schedule of execution like "* * * * *"
        :param executor: user to run cron
        :param command: command to execute
        :return: None
        """
        try:
            f = open('/etc/cron.d/' + file_name, 'w')
            f.write("{} {} {}\n".format(schedule, executor, command))
            f.close()
        except Exception as e:
            print('Cannot add cron job. {}'.format(e))

    def remove_cron(self, file_name):
        """
        Remove cron
        :param file_name: cron name
        :return: None
        """
        try:
            if file_name:
                os.remove('/etc/cron.d/' + file_name)
        except Exception as e:
            print('Cannot remove cron job. {}'.format(e))

    def parse_command(self, command):
        """
        Parses a command string into a list of arguments.
        """
        if isinstance(command, str):
            if command.strip() == "":
                return []
            return shlex.split(command)
        elif isinstance(command, list):
            return command
        else:
            return []

    def exec_command(self, command, env=None):
        """
        This function will run passed command
        in command line and returns result
        :param command:
        :param env:
        :return:
        """
        result = []
        try:
            args = self.parse_command(command)
            if not args:
                raise ValueError(f"The provided command is not valid: {command}")
            p = subprocess.Popen(args, stdout=subprocess.PIPE, env=env, text=True)
            while 1:
                output = p.stdout.readline()
                if not output:
                    break
                if output.strip() != "":
                    result.append(output.strip())
        except Exception as e:
            print ("Call process error: " + str(e))
        return result

    def disable_agent(self):
        system_id = self.get_system_id()
        if system_id is None:
            print("Warning: Cannot disable user-agent because system_id is None.")
            return
        self.exec_command("{} {} --system_id {}"
                          .format(XRAY_MANAGER_UTILITY, "disable-user-agent", system_id))

    def enable_agent(self):
        system_id = self.get_system_id()
        if system_id is None:
            print("Warning: Cannot enable user-agent because system_id is None.")
            return
        self.exec_command("{} {} --system_id {}"
                          .format(XRAY_MANAGER_UTILITY, "enable-user-agent", system_id))

    def is_agent_enabled_in_config(self):
        return UIConfig().get_param('hideXrayApp', 'uiSettings') is False

    def sync_ui_config(self):
        self.exec_command(DYNAMICUI_SYNC_CONFIG_COMMAND)

    def configure_plugin(self):
        self.sync_ui_config()
        if self.is_agent_enabled_in_config():
            self.enable_agent()
        # Automatic disable was turned off because we still need
        # xray-user-agent to work in background for smart advice
        # previously this caused issues when this script was triggered in upcp
        # we may revisit this in the future
        # else:
        #     self.disable_agent()

    def get_system_id(self):
        try:
            system_id = get_rhn_systemid_value('system_id').split('-')[1]
            return system_id
        except Exception:
            print("Warning: Cannot get system id. It looks like your system is not registered.")
            return

    def reset_ui_config(self):
        if not is_cl_solo_edition(skip_jwt_check=True):
            self.exec_command(CLOUDLINUX_CONFIG_COMMAND)
        self.exec_command(DYNAMICUI_SYNC_CONFIG_COMMAND)

    def remove_file(self, filename):
        try:
            os.remove(filename)
        except OSError:
            pass

    def is_agent_running(self):
        """
        Check if user agent running
        :return: Boolean
        """
        try:
            system_id = self.get_system_id()
            result = json.loads(self.exec_command("{} {} --system_id {}"
                                                  .format(XRAY_MANAGER_UTILITY, "user-agent-status", system_id))[0])
            return result["status"] == "enabled"
        except Exception as e:
            print('Cannot get agent status. {}'.format(e))
            return False

    def check_and_repair_plugin(self):
        """
        Check if there is misconfiguration between UiConfig and agent service and reconfigure plugin
        :return: None
        """
        if self.is_agent_enabled_in_config() and not self.is_agent_running():
            self.configure_plugin()

    def copy_file_or_dir(self, src, dst):
        """
        Copy file or directory to specified location
        """
        try:
            if os.path.isfile(src):
                self.remove_file_or_dir(dst)
                shutil.copy(src, dst)
            elif os.path.isdir(src):
                self.remove_file_or_dir(dst)
                shutil.copytree(src, dst)
        except Exception as e:
            print("An error occurred while copying: {}".format(e))

    def remove_file_or_dir(self, path):
        """
        Remove file or directory
        """
        try:
            if os.path.isfile(path):
                os.remove(path)
            elif os.path.isdir(path):
                shutil.rmtree(path)
        except Exception as e:
            print("An error occurred while copying: {}".format(e))

    def safe_recursive_chown(self, path, user, group):
        """
        Recursively change ownership without following symlinks.

        Uses openat/fwalk semantics via dir-fd so path resolution is bound to
        a specific directory inode for the whole walk — closes the TOCTOU
        window between realpath() and os.walk() that a plain realpath+walk
        approach leaves open (os.walk follows its top argument even when
        followlinks=False, and os.lchown only protects the final component).
        """
        try:
            # pwd.getpwnam / grp.getgrnam raise KeyError on missing entries;
            # catch Exception (same semantic as the replaced exec_command
            # helper) so a missing user/group logs and continues rather
            # than aborting the whole %posttrans install flow.
            uid = pwd.getpwnam(user).pw_uid
            gid = grp.getgrnam(group).gr_gid
            # Strip trailing slashes: O_NOFOLLOW is silently ignored when the
            # path ends with `/` because the kernel resolves the trailing slash
            # as a directory dereference of the final component, following a
            # symlink substituted at the basename instead of rejecting it.
            path = path.rstrip("/")
            # O_NOFOLLOW blocks a symlink substituted at `path` itself.
            # O_DIRECTORY ensures we only accept a real directory.
            top_fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
        except Exception as e:
            print("An error occurred while opening path for chown: {}".format(e))
            return
        try:
            # Chown the top directory itself via its fd — immune to rename.
            os.fchown(top_fd, uid, gid)
            # Walk via file descriptors. os.fwalk uses openat internally, so
            # descents are relative to a live dir fd and cannot be redirected
            # by an intermediate symlink swap mid-walk.
            for _dirpath, dirnames, filenames, sub_fd in os.fwalk(
                    top=".", dir_fd=top_fd, follow_symlinks=False):
                for name in dirnames + filenames:
                    # Per-entry try/except so a single ENOENT / EPERM on a
                    # mid-walk race doesn't abort the whole traversal —
                    # matches the prior `chown -R` shell resilience.
                    try:
                        os.chown(name, uid, gid,
                                 dir_fd=sub_fd, follow_symlinks=False)
                    except OSError as e:
                        print("An error occurred while chowning {}: {}".format(name, e))
        except Exception as e:
            print("An error occurred while changing ownership: {}".format(e))
        finally:
            try:
                os.close(top_fd)
            except OSError:
                pass

    def safe_chmod_nofollow(self, path, mode, dir_fd=None):
        """
        Change permissions on a path without following symlinks.

        When called with a dir_fd, path is resolved relative to the directory
        fd via openat() — used by safe_recursive_chmod to bind each leaf
        operation to the live dir fd produced by os.fwalk. O_NONBLOCK guards
        against a racing FIFO at that entry: without it, os.open(O_RDONLY)
        on a FIFO blocks forever waiting for a writer and hangs the
        root-privileged installer (the replaced `chmod -R` used fchmodat
        semantics that never opened the file).
        """
        try:
            fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
                         dir_fd=dir_fd)
            try:
                os.fchmod(fd, mode)
            finally:
                os.close(fd)
        except Exception as e:
            print("An error occurred while changing permissions: {}".format(e))

    def safe_recursive_chmod(self, path, mode):
        """
        Recursively change permissions without following symlinks.

        Same openat/fwalk discipline as safe_recursive_chown — path
        resolution is bound to a dir fd so a concurrent directory->symlink
        swap between the initial open and the recursive traversal cannot
        redirect us to an attacker-chosen target.
        """
        try:
            # Strip trailing slashes: see safe_recursive_chown for why
            # O_NOFOLLOW alone is not enough on a path ending with `/`.
            path = path.rstrip("/")
            top_fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
        except Exception as e:
            print("An error occurred while opening path for chmod: {}".format(e))
            return
        try:
            os.fchmod(top_fd, mode)
            for _dirpath, dirnames, filenames, sub_fd in os.fwalk(
                    top=".", dir_fd=top_fd, follow_symlinks=False):
                for name in dirnames + filenames:
                    self.safe_chmod_nofollow(name, mode, dir_fd=sub_fd)
        except Exception as e:
            print("An error occurred while changing permissions: {}".format(e))
        finally:
            try:
                os.close(top_fd)
            except OSError:
                pass

    def generate_translate_templates(self):
        """
        Prepare translate templates using english as a base.
        """
        source_path = f"{SOURCE_PATH_USER}i18n/en-en.json"
        template_path = f"{CLOUDLINUX_TRANSLATIONS_DIR}xray-user-ui.json"
        self.copy_file_or_dir(source_path, template_path)


class CpanelPluginInstaller(Base):
    def __init__(self):
        self.ROOT_CPANEL_DIR = "/usr/local/cpanel/whostmgr/docroot/"
        self.CPANEL_THEMES_BASE_DIR = "/usr/local/cpanel/base/frontend/"
        self.template_src = SOURCE_PATH_BASE + "plugins/cpanel/xray.live.pl"
        # template_dst should be format with theme name and placed for each theme
        self.template_dst = self.CPANEL_THEMES_BASE_DIR + "{}/lveversion/xray.live.pl"
        self.plugin_tar = SOURCE_PATH_BASE + "plugins/cpanel/cpanel-xray-plugin.tar.bz2"
        self.plugin_installer = "/usr/local/cpanel/scripts/install_plugin"
        self.plugin_uninstaller = "/usr/local/cpanel/scripts/uninstall_plugin"
        self.xray_feature_file = "/usr/local/cpanel/whostmgr/addonfeatures/lvexray"
        self.install_config = SOURCE_PATH_BASE + "plugins/cpanel/plugin/install.json"
        self.destination_admin = "/usr/local/cpanel/whostmgr/docroot/3rdparty/cloudlinux/assets/xray-admin"
        self.destination_user = "/usr/local/cpanel/whostmgr/docroot/3rdparty/cloudlinux/assets/xray-user"

    def cpanel_fix_feature_manager(self):
        if os.path.exists(self.xray_feature_file) and os.path.exists(self.install_config):
            with open(self.install_config) as install_config:
                feature_config = json.load(install_config)
                feature = feature_config[0]  # We have only one item
                try:
                    feature_name_fixed = re.search("\$LANG{'(.*)'}", feature['name']).group(1)
                except AttributeError:
                    feature_name_fixed = ''
                feature_file = open(self.xray_feature_file, 'w')
                feature_file.write(feature['feature'] + ':' + feature_name_fixed)
                feature_file.close()

    def install_plugin(self):
        self.copy_file_or_dir(SOURCE_PATH_ADMIN, self.destination_admin)
        self.copy_file_or_dir(SOURCE_PATH_USER, self.destination_user)
        for theme in self.get_theme_list():
            self.copy_file_or_dir(self.template_src, self.template_dst.format(theme))
            self.exec_command("{} {}  --theme {}".format(self.plugin_installer, self.plugin_tar, theme))
        self.cpanel_fix_feature_manager()

    def uninstall_plugin(self):
        self.remove_file_or_dir(self.destination_admin)
        self.remove_file_or_dir(self.destination_user)
        for theme in self.get_theme_list():
            self.remove_file_or_dir(self.template_dst.format(theme))
            self.exec_command("{} {}  --theme {}".format(self.plugin_uninstaller, self.plugin_tar, theme))

    def get_theme_list(self):
        if os.path.isdir(self.CPANEL_THEMES_BASE_DIR):
            return next(os.walk(self.CPANEL_THEMES_BASE_DIR), (None, None, []))[1]


class PleskPluginInstaller(Base):
    def __init__(self):
        self.ROOT_PLESK_DIR = "/usr/local/psa/admin/"
        self.controller_src = SOURCE_PATH_BASE + "plugins/plesk/XrayController.php"
        self.controller_dst = self.ROOT_PLESK_DIR + "plib/modules/plesk-lvemanager/controllers/XrayController.php"
        self.send_request_controller_src = SOURCE_PATH_BASE + "plugins/plesk/XraySendRequestController.php"
        self.send_request_controller_dst = self.ROOT_PLESK_DIR + "plib/modules/plesk-lvemanager/controllers/XraySendRequestController.php"
        self.destination_admin = "/usr/local/psa/admin/htdocs/modules/plesk-lvemanager/xray-admin"
        self.destination_user = "/usr/local/psa/admin/htdocs/modules/plesk-lvemanager/xray-user"
        self.icon_src = SOURCE_PATH_BASE + "plugins/plesk/xray.svg"
        self.icon_dst = self.ROOT_PLESK_DIR + "htdocs/modules/plesk-lvemanager/images/xray.svg"
        self.views_dir = self.ROOT_PLESK_DIR + "plib/modules/plesk-lvemanager/views/scripts/xray/"
        self.template_src = SOURCE_PATH_BASE + "plugins/plesk/index.phtml"
        self.template_dst = self.ROOT_PLESK_DIR + "plib/modules/plesk-lvemanager/views/scripts/xray/index.phtml"

    def install_plugin(self):
        self.copy_file_or_dir(SOURCE_PATH_ADMIN, self.destination_admin)
        self.copy_file_or_dir(SOURCE_PATH_USER, self.destination_user)
        self.copy_file_or_dir(self.controller_src, self.controller_dst)
        self.copy_file_or_dir(self.send_request_controller_src, self.send_request_controller_dst)
        self.copy_file_or_dir(self.icon_src, self.icon_dst)
        if not os.path.isdir(self.views_dir):
            os.mkdir(self.views_dir)
        self.copy_file_or_dir(self.template_src, self.template_dst)

    def uninstall_plugin(self):
        self.remove_file_or_dir(self.destination_admin)
        self.remove_file_or_dir(self.destination_user)
        self.remove_file_or_dir(self.controller_dst)
        self.remove_file_or_dir(self.send_request_controller_dst)
        self.remove_file_or_dir(self.icon_dst)
        self.remove_file_or_dir(self.views_dir)


class DirectAdminPluginInstaller(Base):
    def __init__(self):
        # Plugin files
        self.source_user_plugin = SOURCE_PATH_BASE + "plugins/directadmin/xray/"
        self.destination_user_plugin = "/usr/local/directadmin/plugins/xray/"
        self.source_index_file = SOURCE_PATH_BASE + "plugins/directadmin/xrayIndex.php"
        self.destination_index_file = "/usr/local/directadmin/plugins/lvemanager_spa/app/View/Spa/index/xrayIndex.php"
        self.plugin_conf_file = self.destination_user_plugin + "/plugin.conf"
        self.destination_admin_spa = "/usr/local/directadmin/plugins/lvemanager_spa/images/assets/xray-admin"
        self.destination_user_spa = "/usr/local/directadmin/plugins/xray/images/xray-user"

    def install_plugin(self):
        self.copy_file_or_dir(SOURCE_PATH_ADMIN, self.destination_admin_spa)
        self.copy_file_or_dir(self.source_user_plugin, self.destination_user_plugin)
        self.copy_file_or_dir(self.source_index_file, self.destination_index_file)
        self.copy_file_or_dir(SOURCE_PATH_USER, self.destination_user_spa)
        self.safe_recursive_chown(self.destination_user_plugin, "diradmin", "diradmin")
        self.safe_recursive_chown(self.destination_admin_spa, "diradmin", "diradmin")
        self.safe_recursive_chmod(self.destination_user_plugin, 0o755)
        self.safe_chmod_nofollow(self.plugin_conf_file, 0o644)

    def uninstall_plugin(self):
        self.remove_file_or_dir(self.destination_user_plugin)
        self.remove_file_or_dir(self.destination_admin_spa)


class PanelIntegrationPluginInstaller(Base):
    def __init__(self):
        self.destination_admin_py_plugin = "/usr/share/l.v.e-manager/commons/spa-resources/static/xray-admin"
        self.destination_user_py_plugin = "/usr/share/l.v.e-manager/commons/spa-resources/static/xray-user"

    def get_panel_base_path(self):
        try:
            parser = configparser.ConfigParser(interpolation=None, strict=False)
            parser.read(PANEL_INTEGRATION_CONFIG)
            base_path = parser.get("lvemanager_config", "base_path")
            return base_path
        except Exception as e:
            print('Cannot copy files for no panel version. {}'.format(e))
            sys.exit(1)

    def install_plugin(self):
        base_path = self.get_panel_base_path().rstrip('/')
        self.copy_file_or_dir(SOURCE_PATH_ADMIN, self.destination_admin_py_plugin)
        self.copy_file_or_dir(SOURCE_PATH_USER, self.destination_user_py_plugin)
        self.copy_file_or_dir(SOURCE_PATH_ADMIN, base_path + '/assets/xray-admin')
        self.copy_file_or_dir(SOURCE_PATH_USER, base_path + '/assets/xray-user')

    def uninstall_plugin(self):
        base_path = self.get_panel_base_path().rstrip('/')
        self.remove_file_or_dir(self.destination_admin_py_plugin)
        self.remove_file_or_dir(self.destination_user_py_plugin)
        self.remove_file_or_dir(base_path + '/assets/xray-admin')
        self.remove_file_or_dir(base_path + '/assets/xray-user')


class Main:
    def __init__(self):
        pass

    def make_parser(self):
        parser = argparse.ArgumentParser(description="Script to install|uninstall Xray App for user")
        parser.add_argument("--install", "-i", action="store_true",
                            help="Install Xray App")
        parser.add_argument("--uninstall", "-u", action="store_true",
                            help="Uninstall Xray App")
        parser.add_argument("--check", "-c", action="store_true",
                            help="Check and repair plugin")
        return parser

    def run(self):
        parser = self.make_parser()
        args = parser.parse_args()

        if detect.is_cpanel():
            # Cpanel
            installer = CpanelPluginInstaller()
        elif detect.is_da():
            # DirectAdmin
            installer = DirectAdminPluginInstaller()
        elif detect.is_plesk():
            # Plesk
            installer = PleskPluginInstaller()
        elif os.path.isfile(PANEL_INTEGRATION_CONFIG):
             # Custom panel with integration.ini
             installer = PanelIntegrationPluginInstaller()
        else:
            print("X-Ray plugin cannot be installed on your environment")
            sys.exit(0)

        if args.install:
            installer.install_plugin()
            installer.generate_translate_templates()
            installer.configure_plugin()
            installer.add_cron(CHECK_PLUGIN_CRON["name"],
                               CHECK_PLUGIN_CRON["schedule"],
                               CHECK_PLUGIN_CRON["executor"],
                               CHECK_PLUGIN_CRON["command"])
        elif args.uninstall:
            installer.remove_cron(CHECK_PLUGIN_CRON["name"])
            installer.uninstall_plugin()
            installer.reset_ui_config()
        elif args.check:
            installer.check_and_repair_plugin()
        else:
            parser.print_help()


if __name__ == "__main__":
    main = Main()
    main.run()
