#!/usr/bin/env python2.6
#
# Copyright (c) 2011 Duo Security
# All Rights Reserved
#

# This import _must_ be first
import _fipscustomize # noqa: F401, isort:skip

import abc
import argparse
import os
import os.path
import signal
import subprocess
import sys
import time

from duoauthproxy import proxy
from duoauthproxy.lib import log_observation, util

if util.is_windows_os():
    import win32service
    import win32serviceutil
    import winerror

    # Windows error codes
    ERROR_NONE = winerror.ERROR_SUCCESS
    ERROR_GENERIC = winerror.ERROR_GEN_FAILURE
    ERROR_ARGS = winerror.ERROR_BAD_ARGUMENTS
    ERROR_UNIMPLEMENTED = winerror.ERROR_BAD_COMMAND
else:
    # linux standard error codes for other initscript commands
    ERROR_NONE = 0
    ERROR_GENERIC = 1
    ERROR_ARGS = 2
    ERROR_UNIMPLEMENTED = 3


class AuthproxyController(abc.ABC):
    """Auth Proxy Controller base class"""

    def __init__(self, duo_root: str):
        """
        Args:
            duo_root: Path to the auth proxy installation root
        """
        self.duo_root = duo_root

    @property
    def conn_tool_path(self):
        return os.path.join(self.duo_root, "bin", "authproxy_connectivity_tool")

    @property
    def conn_tool_logs(self):
        return os.path.join(self.duo_root, "log", "connectivity_tool.log")

    @abc.abstractmethod
    def start(self):
        raise NotImplementedError()

    @abc.abstractmethod
    def stop(self):
        raise NotImplementedError()

    @abc.abstractmethod
    def restart(self):
        raise NotImplementedError()

    @abc.abstractmethod
    def status(self):
        raise NotImplementedError()

    @staticmethod
    def version():
        version = util.get_version()
        if not version:
            version = "- Unknown Version"

        print("Duo Security Authentication Proxy {}".format(version), file=sys.stderr)

    def run_connectivity_tool(self):
        try:
            return subprocess.call(
                [self.conn_tool_path, "--no-explicit-connectivity-check", "--startup"]
            )
        except OSError as e:
            print("Failed to run authproxy connectivity tool: %s" % e, file=sys.stderr)
            return 0


class LinuxAuthproxyController(AuthproxyController):

    # --- install script will customize this ---
    USER_DEFAULT = None
    # ------------------------------------------

    # linux standard status codes for 'status' initscript command
    STATUS_RUNNING = 0
    STATUS_STALE_PIDFILE = 1
    STATUS_STOPPED = 3
    STATUS_UNKNOWN = 4

    def __init__(self, python_root: str = None, duo_root: str = None, user: str = None):
        # Follow symlinks and get the root of the bundled python and the authproxy
        script_abspath = os.path.abspath(os.path.realpath(__file__))
        if python_root is None:
            root_relative = os.path.join(os.path.dirname(script_abspath), "..")
            python_root = os.path.abspath(root_relative)

        if duo_root is None:
            root_relative = os.path.join(os.path.dirname(script_abspath), *[".."] * 3)
            duo_root = os.path.abspath(root_relative)

        super(LinuxAuthproxyController, self).__init__(duo_root)
        self.python_root = python_root

        # determine uid to which twistd should drop privileges
        # if we were passed an argument, we use that
        if user is None:
            # otherwise fall back on the hardcoded default in this script
            user = self.USER_DEFAULT

        # if we have one, parse the user into a uid
        self.uid = None
        if user is not None:
            self.uid, self.gid = log_observation._parse_user(user)

    @property
    def pidfile(self):
        return os.path.join(self.duo_root, "run", "duoauthproxy.pid")

    @property
    def tapfile(self):
        return os.path.join(self.duo_root, "bin", "duoauthproxy.tap")

    @property
    def twistd_path(self):
        return os.path.join(self.python_root, "bin", "twistd")

    @property
    def python_executable_path(self):
        return os.path.join(self.python_root, "bin", "python3")

    def _read_pidfile(self):
        try:
            with open(self.pidfile, "r") as pidfile_fp:
                pid = int(pidfile_fp.read())
        except IOError:
            # pidfile doesn't exist; return sentinel
            return None
        except ValueError:
            raise ValueError("Invalid data in pidfile!")
        return pid

    def _is_running(self, pid):
        # attempt to send a null signal to the process this won't
        # actually send a signal, but will error out if the process is
        # not running
        try:
            os.kill(pid, 0)
            return True
        except OSError:
            return False

    def _status(self):
        try:
            pid = self._read_pidfile()
        except ValueError as e:
            print(repr(e), file=sys.stderr)
            return self.STATUS_UNKNOWN

        if pid is None:
            return self.STATUS_STOPPED
        elif self._is_running(pid):
            return self.STATUS_RUNNING
        else:
            return self.STATUS_STALE_PIDFILE

    def start(self):
        # do nothing if already running
        if self._status() == self.STATUS_RUNNING:
            return ERROR_NONE

        return_code = self.run_connectivity_tool()
        if return_code != 0:
            # print >>sys.stderr, "Your configuration is not valid. The Duo Authentication Proxy failed to start"
            # return ERROR_GENERIC

            # We're choosing not to prevent auth proxy startup if there are
            # config errors for now, but once the configuration validation
            # proves to be stable we can go back to preventing startup.
            print(
                "Your configuration is not valid. Check the validation output for errors.",
                file=sys.stderr,
            )

        # build up twistd args
        twistd_args = [
            self.python_executable_path,
            "-s",
            self.twistd_path,
            "--pidfile=%s" % self.pidfile,
            "--python=%s" % self.tapfile,
        ]
        if self.uid is not None and self.gid is not None:
            twistd_args.extend(["--uid=%s" % self.uid, "--gid=%s" % self.gid])
        # run twistd
        tool_message = (
            "If the duoauthproxy failed to start for connectivity reasons, "
            "the connectivity tool located at %s can be run independently to test for "
            "connectivity problems." % self.conn_tool_path
        )
        try:
            exit_status = subprocess.call(twistd_args)
        except OSError as e:
            print("Failed to start duoauthproxy: %s" % e, file=sys.stderr)
            print(tool_message, file=sys.stderr)
            return ERROR_GENERIC
        if exit_status:
            print(
                "Failed to start duoauthproxy: exit status %d" % (exit_status),
                file=sys.stderr,
            )
            print(tool_message, file=sys.stderr)
            return ERROR_GENERIC

    def stop(self):
        if self._status() == self.STATUS_RUNNING:
            pid = self._read_pidfile()

            # if pidfile exists, attempt to kill the process
            try:
                os.kill(pid, signal.SIGTERM)
            except OSError as e:
                print("Failed to stop duoauthproxy: %s" % e, file=sys.stderr)
                return ERROR_GENERIC
        return 0

    def restart(self):
        status = self.stop()
        if status:
            return status
        time.sleep(1)
        return self.start()

    def status(self):
        status = self._status()
        if status == self.STATUS_RUNNING:
            pid = self._read_pidfile()
            print("duoauthproxy (pid %d) is running" % pid, file=sys.stderr)
        elif status == self.STATUS_STALE_PIDFILE:
            print("duoauthproxy is not running", file=sys.stderr)
        elif status == self.STATUS_STOPPED:
            print("duoauthproxy is not running", file=sys.stderr)
        elif status == self.STATUS_UNKNOWN:
            print("duoauthproxy status unknown!", file=sys.stderr)
        return status


class WindowsAuthproxyController(AuthproxyController):

    SERVICE_NAME = "duoauthproxy"
    SERVICE_LABEL = "Duo Security Authentication Proxy Service"

    def __init__(self):
        # Try to get the installation's bin directory dynamically. If that doesn't work,
        # just use the hardcoded path. We don't allow customizable Windows installation
        # directories anyway.
        if hasattr(sys, "frozen") and sys.frozen in ("windows_exe", "console_exe"):
            exe = os.path.abspath(sys.executable)
            duo_root = os.path.dirname(os.path.dirname(exe))
        else:
            duo_root = os.path.join(
                "C:\\", "Program Files", "Duo Security Authentication Proxy"
            )

        super(WindowsAuthproxyController, self).__init__(duo_root)

    def _is_running(self):
        status_code = self._status_code()
        return status_code in [
            win32service.SERVICE_RUNNING,
            win32service.SERVICE_START_PENDING,
            win32service.SERVICE_PAUSED,
            win32service.SERVICE_CONTINUE_PENDING,
        ]

    def _status(self):
        """
        Returns the full SERVICE_STATUS for the auth proxy service (packed into
        a dict for more readable code) as defined here:
        https://docs.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_status?redirectedfrom=MSDN
        """
        status = win32serviceutil.QueryServiceStatus(self.SERVICE_NAME)
        return {
            "service_type": status[0],
            "current_state": status[1],
            "controls_accepted": status[2],
            "win32_exit_code": status[3],
            "service_exit_code": status[4],
            "checkpoint": status[5],
            "wait_hint": status[6],
        }

    def _status_code(self):
        status = self._status()
        return status["current_state"]

    def _print_service_message(self, message):
        print("The {} {}".format(self.SERVICE_LABEL, message))

    def start(self):
        # do nothing if the service is running
        if self._is_running():
            print("The Duo Security Authentication Proxy Service is already running.")
            return ERROR_NONE

        return_code = self.run_connectivity_tool()
        if return_code != 0:
            # print >>sys.stderr, "Your configuration is not valid. The Duo Authentication Proxy failed to start"
            # return ERROR_GENERIC

            # We're choosing not to prevent auth proxy startup if there are
            # config errors for now, but once the configuration validation
            # proves to be stable we can go back to preventing startup.
            print(
                "Your configuration is not valid. Check the validation output for errors."
            )

        try:
            self._print_service_message("is starting")
            win32serviceutil.StartService(self.SERVICE_NAME)
            return self._wait_for_start()
        except Exception as e:
            self._print_service_message(
                "could not be started. Exception: {}".format(str(e))
            )
            return ERROR_GENERIC

    def _wait_for_start(self, timeout=20):
        """
        Waits for the auth proxy service to be in a running state, timing
        out after the specified timeout. If the service reports an error code,
        the waiting terminates and this method returns.

        This method should be called after requesting for the service to be
        started via win32serviceutil.StartService().
        """
        wait_count = 0
        while True:
            status = self._status()
            if status["win32_exit_code"] != ERROR_NONE:
                # Something went wrong during startup. Stop waiting.
                message = (
                    "could not be started.\n\nThis is likely a configuration "
                    "error. Check the configuration validation output above or "
                    "in the logs located at {}. After fixing the errors you "
                    "can run a test of your configuration using our validator "
                    "located at {}"
                ).format(self.conn_tool_logs, self.conn_tool_path)
                self._print_service_message(message)
                return ERROR_GENERIC

            if status["current_state"] == win32service.SERVICE_RUNNING:
                self._print_service_message("was started successfully")
                return ERROR_NONE

            wait_count += 1
            if wait_count < timeout:
                # Nothing went wrong but it's still not started. Wait.
                time.sleep(1)
            else:
                # We timed out
                break

        # The auth proxy isn't reporting that it's running but it also isn't
        # reporting an error. Bail because something weird is happening.
        status = self._status()
        self._print_service_message(
            "hasn't successfully started after {} seconds and appears to be "
            "unresponsive (status code {}). \n\n Run 'sc query duoauthproxy' "
            "for more details on the current status of the service.".format(
                timeout, status["current_state"]
            )
        )
        return ERROR_GENERIC

    def stop(self):
        # do nothing if the service is already stopped
        if not self._is_running():
            print("The Duo Security Authentication Proxy Service is already stopped.")
            return ERROR_NONE

        self._print_service_message("is stopping")
        try:
            win32serviceutil.StopService(self.SERVICE_NAME)
            win32serviceutil.WaitForServiceStatus(
                self.SERVICE_NAME, win32service.SERVICE_STOPPED, 30
            )
            self._print_service_message("was stopped successfully")
            exit_code = ERROR_NONE
        except Exception as e:
            self._print_service_message(
                "could not be stopped. Exception: {}".format(str(e))
            )
            exit_code = ERROR_GENERIC

        return exit_code

    def restart(self):
        # Do a stop and start instead of calling win32serviceutil.RestartService
        # so we re-run the connectivity tool on startup
        exit_code = self.stop()
        if exit_code == ERROR_NONE:
            exit_code = self.start()

        return exit_code

    def status(self):
        status_code = self._status_code()
        if status_code == win32service.SERVICE_START_PENDING:
            self._print_service_message("is starting")
        if status_code == win32service.SERVICE_RUNNING:
            self._print_service_message("is running")
        elif status_code == win32service.SERVICE_PAUSED:
            self._print_service_message("is paused")
        elif status_code == win32service.SERVICE_CONTINUE_PENDING:
            self._print_service_message("is pending continue")
        elif status_code == win32service.SERVICE_STOP_PENDING:
            self._print_service_message("is stopping")
        elif status_code == win32service.SERVICE_STOPPED:
            self._print_service_message("is not running")
        else:
            print("Unknown status. Status code: {}".format(status_code))
        return status_code


def usage(argv):
    return "Usage: %s {start|stop|restart|status|version}" % argv[0]


def main(argv):
    parser = argparse.ArgumentParser(usage=usage(argv))
    parser.add_argument("-p", "--pause", action="store_true")
    parser.add_argument("command")
    args = parser.parse_args()

    if util.is_windows_os():
        proxy_ctl = WindowsAuthproxyController()
    else:
        proxy_ctl = LinuxAuthproxyController()

    command = args.command
    exit_code = -1
    if command == "start":
        exit_code = proxy_ctl.start()
    elif command == "stop":
        exit_code = proxy_ctl.stop()
    elif command == "restart":
        exit_code = proxy_ctl.restart()
    elif command == "status":
        exit_code = proxy_ctl.status()
    elif command == "version":
        exit_code = proxy_ctl.version()
    else:
        parser.print_help()
        exit_code = ERROR_UNIMPLEMENTED

    if args.pause:
        input("Press Enter to Continue...")

    return exit_code


if __name__ == "__main__":
    sys.exit(main(sys.argv))
