From a497d8ddb259044ef2f92c4de658578d9790c8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matteo=20=E2=84=B1an?= Date: Sun, 2 Feb 2020 22:10:24 +0100 Subject: [PATCH] Added daemonization option improved options parsing --- .gitignore | 4 + py-kms/Etrigan.py | 610 ++++++++++++++++++++++++++++++++++++++++ py-kms/pykms_Client.py | 9 +- py-kms/pykms_Format.py | 2 +- py-kms/pykms_GuiBase.py | 15 +- py-kms/pykms_Misc.py | 25 ++ py-kms/pykms_Server.py | 233 +++++++++++---- 7 files changed, 831 insertions(+), 67 deletions(-) create mode 100644 py-kms/Etrigan.py diff --git a/.gitignore b/.gitignore index feffe32..621d21f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # App files pykms_logserver.log* +pykms_logclient.log* +pykms_newlines.txt* +pykms_config.pickle* +etrigan.log* # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/py-kms/Etrigan.py b/py-kms/Etrigan.py new file mode 100644 index 0000000..1d90a53 --- /dev/null +++ b/py-kms/Etrigan.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import atexit +import errno +import os +import sys +import time +import signal +import logging +import argparse +from collections import Sequence + +__version__ = "0.1" +__license__ = "MIT License" +__author__ = u"Matteo ℱan " +__copyright__ = "© Copyright 2020" +__url__ = "https://github.com/SystemRage/Etrigan" +__description__ = "Etrigan: a python daemonizer that rocks." + +path = os.path.dirname(os.path.abspath(__file__)) + +class Etrigan(object): + """ + Daemonizer based on double-fork method + -------------------------------------- + Each option can be passed as a keyword argument or modified by assigning + to an attribute on the instance: + + jasonblood = Etrigan(pidfile, + argument_example_1 = foo, + argument_example_2 = bar) + + that is equivalent to: + + jasonblood = Etrigan(pidfile) + jasonblood.argument_example_1 = foo + jasonblood.argument_example_2 = bar + + Object constructor expects always `pidfile` argument. + `pidfile` + Path to the pidfile. + + The following other options are defined: + `stdin` + `stdout` + `stderr` + :Default: `os.devnull` + File objects used as the new file for the standard I/O streams + `sys.stdin`, `sys.stdout`, and `sys.stderr` respectively. + + `funcs_to_daemonize` + :Default: `[]` + Define a list of your custom functions + which will be executed after daemonization. + If None, you have to subclass Etrigan `run` method. + Note that these functions can return elements that will be + added to Etrigan object (`etrigan_add` list) so the other subsequent + ones can reuse them for further processing. + You only have to provide indexes of `etrigan_add` list, + (an int (example: 2) for single index or a string (example: '1:4') for slices) + as first returning element. + + `want_quit` + :Default: `False` + If `True`, runs Etrigan `quit_on_start` or `quit_on_stop` + lists of your custom functions at the end of `start` or `stop` operations. + These can return elements as `funcs_to_daemonize`. + + `logfile` + :Default: `None` + Path to the output log file. + + `loglevel` + :Default: `None` + Set the log level of logging messages. + + `mute` + :Default: `False` + Disable all stdout and stderr messages (before double forking). + + `pause_loop` + :Default: `None` + Seconds of pause between the calling, in an infinite loop, + of every function in `funcs_to_daemonize` list. + If `-1`, no pause between the calling, in an infinite loop, + of every function in `funcs_to_daemonize` list. + If `None`, only one run (no infinite loop) of functions in + `funcs_to_daemonize` list, without pause. + """ + + def __init__(self, pidfile, + stdin = os.devnull, stdout = os.devnull, stderr = os.devnull, + funcs_to_daemonize = [], want_quit = False, + logfile = None, loglevel = None, + mute = False, pause_loop = None): + + self.pidfile = pidfile + self.funcs_to_daemonize = funcs_to_daemonize + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + self.logfile = logfile + self.loglevel = loglevel + self.mute = mute + self.want_quit = want_quit + self.pause_loop = pause_loop + # internal only. + self.homedir = '/' + self.umask = 0o22 + self.etrigan_restart, self.etrigan_reload = (False for _ in range(2)) + self.etrigan_alive = True + self.etrigan_add = [] + self.etrigan_index = None + # seconds of pause between stop and start during the restart of the daemon. + self.pause_restart = 5 + # when terminate a process, seconds to wait until kill the process with signal. + # self.pause_kill = 3 + + # create logfile. + self.setup_files() + + def handle_terminate(self, signum, frame): + if os.path.exists(self.pidfile): + self.etrigan_alive = False + # eventually run quit (on stop) function/s. + if self.want_quit: + if not isinstance(self.quit_on_stop, (list, tuple)): + self.quit_on_stop = [self.quit_on_stop] + self.execute(self.quit_on_stop) + # then always run quit standard. + self.quit_standard() + else: + self.view(self.logdaemon.error, self.emit_error, "Failed to stop the daemon process: can't find PIDFILE '%s'" %self.pidfile) + sys.exit(0) + + def handle_reload(self, signum, frame): + self.etrigan_reload = True + + def setup_files(self): + self.pidfile = os.path.abspath(self.pidfile) + + if self.logfile is not None: + self.logdaemon = logging.getLogger('logdaemon') + self.logdaemon.setLevel(self.loglevel) + + filehandler = logging.FileHandler(self.logfile) + filehandler.setLevel(self.loglevel) + formatter = logging.Formatter(fmt = '[%(asctime)s] [%(levelname)8s] --- %(message)s', + datefmt = '%Y-%m-%d %H:%M:%S') + filehandler.setFormatter(formatter) + self.logdaemon.addHandler(filehandler) + else: + nullhandler = logging.NullHandler() + self.logdaemon.addHandler(nullhandler) + + def emit_error(self, message, to_exit = True): + """ Print an error message to STDERR. """ + if not self.mute: + sys.stderr.write(message + '\n') + sys.stderr.flush() + if to_exit: + sys.exit(1) + + def emit_message(self, message, to_exit = False): + """ Print a message to STDOUT. """ + if not self.mute: + sys.stdout.write(message + '\n') + sys.stdout.flush() + if to_exit: + sys.exit(0) + + def view(self, logobj, emitobj, msg, **kwargs): + options = {'to_exit' : False, + 'silent' : False + } + options.update(kwargs) + + if logobj: + logobj(msg) + if emitobj: + if not options['silent']: + emitobj(msg, to_exit = options['to_exit']) + + def daemonize(self): + """ + Double-forks the process to daemonize the script. + see Stevens' "Advanced Programming in the UNIX Environment" for details (ISBN 0201563177) + http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 + """ + self.view(self.logdaemon.debug, None, "Attempting to daemonize the process...") + + # First fork. + self.fork(msg = "First fork") + # Decouple from parent environment. + self.detach() + # Second fork. + self.fork(msg = "Second fork") + # Write the PID file. + self.create_pidfile() + self.view(self.logdaemon.info, self.emit_message, "The daemon process has started.") + # Redirect standard file descriptors. + sys.stdout.flush() + sys.stderr.flush() + self.attach('stdin', mode = 'r') + self.attach('stdout', mode = 'a+') + + try: + self.attach('stderr', mode = 'a+', buffering = 0) + except ValueError: + # Python 3 can't have unbuffered text I/O. + self.attach('stderr', mode = 'a+', buffering = 1) + + # Handle signals. + signal.signal(signal.SIGINT, self.handle_terminate) + signal.signal(signal.SIGTERM, self.handle_terminate) + signal.signal(signal.SIGHUP, self.handle_reload) + #signal.signal(signal.SIGKILL....) + + def fork(self, msg): + try: + pid = os.fork() + if pid > 0: + self.view(self.logdaemon.debug, None, msg + " success with PID %d." %pid) + # Exit from parent. + sys.exit(0) + except Exception as e: + msg += " failed: %s." %str(e) + self.view(self.logdaemon.error, self.emit_error, msg) + + def detach(self): + # cd to root for a guarenteed working dir. + try: + os.chdir(self.homedir) + except Exception as e: + msg = "Unable to change working directory: %s." %str(e) + self.view(self.logdaemon.error, self.emit_error, msg) + + # clear the session id to clear the controlling tty. + pid = os.setsid() + if pid == -1: + sys.exit(1) + + # set the umask so we have access to all files created by the daemon. + try: + os.umask(self.umask) + except Exception as e: + msg = "Unable to change file creation mask: %s." %str(e) + self.view(self.logdaemon.error, self.emit_error, msg) + + def attach(self, name, mode, buffering = -1): + with open(getattr(self, name), mode, buffering) as stream: + os.dup2(stream.fileno(), getattr(sys, name).fileno()) + + def checkfile(self, path, typearg, typefile): + filename = os.path.basename(path) + pathname = os.path.dirname(path) + if not os.path.isdir(pathname): + msg = "argument %s: invalid directory: '%s'. Exiting..." %(typearg, pathname) + self.view(self.logdaemon.error, self.emit_error, msg) + elif not filename.lower().endswith(typefile): + msg = "argument %s: not a %s file, invalid extension: '%s'. Exiting..." %(typearg, typefile, filename) + self.view(self.logdaemon.error, self.emit_error, msg) + + def create_pidfile(self): + atexit.register(self.delete_pidfile) + pid = os.getpid() + try: + with open(self.pidfile, 'w+') as pf: + pf.write("%s\n" %pid) + self.view(self.logdaemon.debug, None, "PID %d written to '%s'." %(pid, self.pidfile)) + except Exception as e: + msg = "Unable to write PID to PIDFILE '%s': %s" %(self.pidfile, str(e)) + self.view(self.logdaemon.error, self.emit_error, msg) + + def delete_pidfile(self, pid): + # Remove the PID file. + try: + os.remove(self.pidfile) + self.view(self.logdaemon.debug, None, "Removing PIDFILE '%s' with PID %d." %(self.pidfile, pid)) + except Exception as e: + if e.errno != errno.ENOENT: + self.view(self.logdaemon.error, self.emit_error, str(e)) + + def get_pidfile(self): + # Get the PID from the PID file. + if self.pidfile is None: + return None + if not os.path.isfile(self.pidfile): + return None + + try: + with open(self.pidfile, 'r') as pf: + pid = int(pf.read().strip()) + self.view(self.logdaemon.debug, None, "Found PID %d in PIDFILE '%s'" %(pid, self.pidfile)) + except Exception as e: + self.view(self.logdaemon.warning, None, "Empty or broken PIDFILE") + pid = None + + def pid_exists(pid): + # psutil _psposix.py. + if pid == 0: + return True + try: + os.kill(pid, 0) + except OSError as e: + if e.errno == errno.ESRCH: + return False + elif e.errno == errno.EPERM: + return True + else: + self.view(self.logdaemon.error, self.emit_error, str(e)) + else: + return True + + if pid is not None and pid_exists(pid): + return pid + else: + # Remove the stale PID file. + self.delete_pidfile(pid) + return None + + def start(self): + """ Start the daemon. """ + self.view(self.logdaemon.info, self.emit_message, "Starting the daemon process...", silent = self.etrigan_restart) + + # Check for a PID file to see if the Daemon is already running. + pid = self.get_pidfile() + if pid is not None: + msg = "A previous daemon process with PIDFILE '%s' already exists. Daemon already running ?" %self.pidfile + self.view(self.logdaemon.warning, self.emit_error, msg, to_exit = False) + return + + # Daemonize the main process. + self.daemonize() + # Start a infinitive loop that periodically runs `funcs_to_daemonize`. + self.loop() + # eventualy run quit (on start) function/s. + if self.want_quit: + if not isinstance(self.quit_on_start, (list, tuple)): + self.quit_on_start = [self.quit_on_start] + self.execute(self.quit_on_start) + + def stop(self): + """ Stop the daemon. """ + self.view(None, self.emit_message, "Stopping the daemon process...", silent = self.etrigan_restart) + + self.logdaemon.disabled = True + pid = self.get_pidfile() + self.logdaemon.disabled = False + if not pid: + # Just to be sure. A ValueError might occur + # if the PIDFILE is empty but does actually exist. + if os.path.exists(self.pidfile): + self.delete_pidfile(pid) + + msg = "Can't find the daemon process with PIDFILE '%s'. Daemon not running ?" %self.pidfile + self.view(self.logdaemon.warning, self.emit_error, msg, to_exit = False) + return + + # Try to kill the daemon process. + try: + while True: + os.kill(pid, signal.SIGTERM) + time.sleep(0.1) + except Exception as e: + if (e.errno != errno.ESRCH): + self.view(self.logdaemon.error, self.emit_error, "Failed to stop the daemon process: %s" %str(e)) + else: + self.view(None, self.emit_message, "The daemon process has ended correctly.", silent = self.etrigan_restart) + + def restart(self): + """ Restart the daemon. """ + self.view(self.logdaemon.info, self.emit_message, "Restarting the daemon process...") + self.etrigan_restart = True + self.stop() + if self.pause_restart: + time.sleep(self.pause_restart) + self.etrigan_alive = True + self.start() + + def reload(self): + pass + + def status(self): + """ Get status of the daemon. """ + self.view(self.logdaemon.info, self.emit_message, "Viewing the daemon process status...") + + if self.pidfile is None: + self.view(self.logdaemon.error, self.emit_error, "Cannot get the status of daemon without PIDFILE.") + + pid = self.get_pidfile() + if pid is None: + self.view(self.logdaemon.info, self.emit_message, "The daemon process is not running.", to_exit = True) + else: + try: + with open("/proc/%d/status" %pid, 'r') as pf: + pass + self.view(self.logdaemon.info, self.emit_message, "The daemon process is running.", to_exit = True) + except Exception as e: + msg = "There is not a process with the PIDFILE '%s': %s" %(self.pidfile, str(e)) + self.view(self.logdaemon.error, self.emit_error, msg) + + def flatten(self, alistoflists, ltypes = Sequence): + # https://stackoverflow.com/questions/2158395/flatten-an-irregular-list-of-lists/2158532#2158532 + alistoflists = list(alistoflists) + while alistoflists: + while alistoflists and isinstance(alistoflists[0], ltypes): + alistoflists[0:1] = alistoflists[0] + if alistoflists: yield alistoflists.pop(0) + + def exclude(self, func): + from inspect import getargspec + args = getargspec(func) + if callable(func): + try: + args[0].pop(0) + except IndexError: + pass + return args + else: + self.view(self.logdaemon.error, self.emit_error, "Not a function.") + return + + def execute(self, some_functions): + returned = None + if isinstance(some_functions, (list, tuple)): + for func in some_functions: + l_req = len(self.exclude(func)[0]) + + if l_req == 0: + returned = func() + else: + l_add = len(self.etrigan_add) + if l_req > l_add: + self.view(self.logdaemon.error, self.emit_error, + "Can't evaluate function: given %s, required %s." %(l_add, l_req)) + return + else: + arguments = self.etrigan_add[self.etrigan_index] + l_args = (len(arguments) if isinstance(arguments, list) else 1) + if (l_args > l_req) or (l_args < l_req): + self.view(self.logdaemon.error, self.emit_error, + "Can't evaluate function: given %s, required %s." %(l_args, l_req)) + return + else: + if isinstance(arguments, list): + returned = func(*arguments) + else: + returned = func(arguments) + + if returned: + if isinstance(returned, (list, tuple)): + if isinstance(returned[0], int): + self.etrigan_index = returned[0] + else: + self.etrigan_index = slice(*map(int, returned[0].split(':'))) + if returned[1:] != []: + self.etrigan_add.append(returned[1:]) + self.etrigan_add = list(self.flatten(self.etrigan_add)) + else: + self.view(self.logdaemon.error, self.emit_error, "Function should return list or tuple.") + returned = None + else: + if some_functions is None: + self.run() + + def loop(self): + try: + if self.pause_loop is None: + # one-shot. + self.execute(self.funcs_to_daemonize) + else: + if self.pause_loop >= 0: + # infinite with pause. + time.sleep(self.pause_loop) + while self.etrigan_alive: + self.execute(self.funcs_to_daemonize) + time.sleep(self.pause_loop) + elif self.pause_loop == -1: + # infinite without pause. + while self.etrigan_alive: + self.execute(self.funcs_to_daemonize) + except Exception as e: + msg = "The daemon process start method failed: %s" %str(e) + self.view(self.logdaemon.error, self.emit_error, msg) + + def quit_standard(self): + self.view(self.logdaemon.info, None, "Stopping the daemon process...") + self.delete_pidfile(self.get_pidfile()) + self.view(self.logdaemon.info, None, "The daemon process has ended correctly.") + + def quit_on_start(self): + """ + Override this method when you subclass Daemon. + """ + self.quit_standard() + + def quit_on_stop(self): + """ + Override this method when you subclass Daemon. + """ + pass + + def run(self): + """ + Override this method when you subclass Daemon. + It will be called after the process has been + daemonized by start() or restart(). + """ + pass + +#----------------------------------------------------------------------------------------------------------------------------------------------------------- + +class JasonBlood(Etrigan): + def run(self): + jasonblood_func() + +def jasonblood_func(): + with open(os.path.join(path, 'etrigan_test.txt'), 'a') as file: + file.write("Yarva Demonicus Etrigan " + time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) + '\n') + +def Etrigan_parser(parser = None): + if parser is None: + # create a new parser. + parser = argparse.ArgumentParser(description = __description__, epilog = __version__) + if not parser.add_help: + # create help argument. + parser.add_argument("-h", "--help", action = "help", help = "show this help message and exit") + + # attach to an existent parser. + parser.add_argument("operation", action = "store", choices = ["start", "stop", "restart", "status", "reload"], + help = "Select an operation for daemon.", type = str) + parser.add_argument("--etrigan-pid", + action = "store", dest = "etriganpid", default = "/tmp/etrigan.pid", + help = "Choose a pidfile path. Default is \"/tmp/etrigan.pid\".", type = str) #'/var/run/etrigan.pid' + parser.add_argument("--etrigan-log", + action = "store", dest = "etriganlog", default = os.path.join(path, "etrigan.log"), + help = "Use this option to choose an output log file; for not logging don't select it. Default is \"etrigan.log\".", type = str) + parser.add_argument("--etrigan-lev", + action = "store", dest = "etriganlev", default = "DEBUG", + choices = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], + help = "Use this option to set a log level. Default is \"DEBUG\".", type = str) + parser.add_argument("--etrigan-mute", + action = "store_const", dest = 'etriganmute', const = True, default = False, + help = "Disable all stdout and stderr messages.") + return parser + +class Etrigan_check(object): + def emit_opt_err(self, msg): + print(msg) + sys.exit(1) + + def checkfile(self, path, typearg, typefile): + filename, extension = os.path.splitext(path) + pathname = os.path.dirname(path) + if not os.path.isdir(pathname): + msg = "argument `%s`: invalid directory: '%s'. Exiting..." %(typearg, pathname) + self.emit_opt_err(msg) + elif not extension == typefile: + msg = "argument `%s`: not a %s file, invalid extension: '%s'. Exiting..." %(typearg, typefile, extension) + self.emit_opt_err(msg) + + def checkfunction(self, funcs, booleans): + if not isinstance(funcs, (list, tuple)): + if funcs is not None: + msg = "argument `funcs_to_daemonize`: provide list, tuple or None" + self.emit_opt_err(msg) + + for elem in booleans: + if not type(elem) == bool: + msg = "argument `want_quit`: not a boolean." + self.emit_opt_err(msg) + +def Etrigan_job(type_oper, daemon_obj): + Etrigan_check().checkfunction(daemon_obj.funcs_to_daemonize, + [daemon_obj.want_quit]) + if type_oper == "start": + daemon_obj.start() + elif type_oper == "stop": + daemon_obj.stop() + elif type_oper == "restart": + daemon_obj.restart() + elif type_oper == "status": + daemon_obj.status() + elif type_oper == "reload": + daemon_obj.reload() + sys.exit(0) + +def main(): + # Parse arguments. + parser = Etrigan_parser() + args = vars(parser.parse_args()) + # Check arguments. + Etrigan_check().checkfile(args['etriganpid'], 'pidfile', '.pid') + Etrigan_check().checkfile(args['etriganlog'], 'pidfile', '.log') + + # Setup daemon. + jasonblood_1 = Etrigan(pidfile = args['etriganpid'], logfile = args['etriganlog'], loglevel = args['etriganlev'], + mute = args['etriganmute'], + funcs_to_daemonize = [jasonblood_func], pause_loop = 5) + +## jasonblood_2 = JasonBlood(pidfile = args['etriganpid'], logfile = args['etriganlog'], loglevel = args['etriganlev'], +## mute = args['etriganmute'], +## funcs_to_daemonize = None, pause_loop = 5) + # Do job. + Etrigan_job(args['operation'], jasonblood_1) + +if __name__ == '__main__': + main() diff --git a/py-kms/pykms_Client.py b/py-kms/pykms_Client.py index 6ed5a27..01ab761 100644 --- a/py-kms/pykms_Client.py +++ b/py-kms/pykms_Client.py @@ -25,8 +25,11 @@ from pykms_Misc import logger_create, check_logfile from pykms_Misc import KmsParser, KmsException from pykms_Format import justify, byterize, enco, deco, ShellMessage, pretty_printer -clt_description = 'KMS Client Emulator written in Python' -clt_version = 'py-kms_2019-05-15' +clt_version = "py-kms_2020-02-02" +__license__ = "The Unlicense" +__author__ = u"Matteo ℱan " +__url__ = "https://github.com/SystemRage/py-kms" +clt_description = "py-kms: KMS Client Emulator written in Python" clt_config = {} #--------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -58,7 +61,7 @@ will be generated.', 'def' : None, 'des' : "machineName"}, 'choi' : ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "MINI"]}, 'lfile' : {'help' : 'Use this option to set an output log file. The default is \"pykms_logclient.log\". Type \"STDOUT\" to view \ log info on stdout. Type \"FILESTDOUT\" to combine previous actions.', - 'def' : os.path.dirname(os.path.abspath( __file__ )) + "/pykms_logclient.log", 'des' : "logfile"}, + 'def' : os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pykms_logclient.log'), 'des' : "logfile"}, 'lsize' : {'help' : 'Use this flag to set a maximum size (in MB) to the output log file. Desactivated by default.', 'def' : 0, 'des': "logsize"}, } diff --git a/py-kms/pykms_Format.py b/py-kms/pykms_Format.py index f7a8a75..080609b 100644 --- a/py-kms/pykms_Format.py +++ b/py-kms/pykms_Format.py @@ -215,7 +215,7 @@ class ShellMessage(object): self.put_text = put_text self.where = where self.plaintext = [] - self.path = os.path.dirname(os.path.abspath( __file__ )) + '/newlines.txt' + self.path = os.path.dirname(os.path.abspath( __file__ )) + '/pykms_newlines.txt' self.print_queue = Queue.Queue() def formatter(self, msgtofrmt): diff --git a/py-kms/pykms_GuiBase.py b/py-kms/pykms_GuiBase.py index 85c413d..2430602 100644 --- a/py-kms/pykms_GuiBase.py +++ b/py-kms/pykms_GuiBase.py @@ -20,12 +20,15 @@ except ImportError: from tkinter import filedialog import tkinter.font as tkFont -from pykms_Server import srv_options, srv_version, srv_config, srv_terminate, serverqueue, serverthread +from pykms_Server import srv_options, srv_version, srv_config, server_terminate, serverqueue, serverthread from pykms_GuiMisc import ToolTip, TextDoubleScroll, TextRedirect, custom_background from pykms_Client import clt_options, clt_version, clt_config, client_thread -gui_description = 'py-kms GUI' -gui_version = 'v1.0' +gui_version = "py-kms_gui_v2.0" +__license__ = "The Unlicense" +__author__ = u"Matteo ℱan " +__url__ = "https://github.com/SystemRage/py-kms" +gui_description = "A GUI for py-kms." ##--------------------------------------------------------------------------------------------------------------------------------------------------------- def get_ip_address(): @@ -452,7 +455,7 @@ class KmsGui(tk.Tk): def srv_actions_stop(self): if serverthread.is_running_server: if serverthread.server is not None: - srv_terminate(exit_server = True) + server_terminate(serverthread, exit_server = True) # wait for switch. while serverthread.is_running_server: pass @@ -522,10 +525,10 @@ class KmsGui(tk.Tk): def on_exit(self): if serverthread.is_running_server: if serverthread.server is not None: - srv_terminate(exit_server = True) + server_terminate(serverthread, exit_server = True) else: serverthread.is_running_server = False - srv_terminate(exit_thread = True) + server_terminate(serverthread, exit_thread = True) self.destroy() def on_clear(self, widgetlist): diff --git a/py-kms/pykms_Misc.py b/py-kms/pykms_Misc.py index d6cb7b2..4dabb67 100644 --- a/py-kms/pykms_Misc.py +++ b/py-kms/pykms_Misc.py @@ -221,6 +221,31 @@ class KmsParser(argparse.ArgumentParser): def error(self, message): raise KmsException(message) +class KmsHelper(object): + def replace(self, parser): + text = parser.format_help().splitlines() + help_list = [] + for line in text: + if line == parser.description: + continue + if line == parser.epilog: + line = 80 * '*' + '\n' + help_list.append(line) + return help_list + + def print(self, parsers): + parser_base, parser_adj, parser_sub = parsers + print('\n' + parser_base.description) + print(len(parser_base.description) * '-' + '\n') + for line in self.replace(parser_base): + print(line) + print(parser_adj.description + '\n') + for line in self.replace(parser_sub): + print(line) + print('\n' + len(parser_base.epilog) * '-') + print(parser_base.epilog + '\n') + parser_base.exit() + #---------------------------------------------------------------------------------------------------------------------------------------------------------- # http://joshpoley.blogspot.com/2011/09/hresults-user-0x004.html (slerror.h) diff --git a/py-kms/pykms_Server.py b/py-kms/pykms_Server.py index 7176ac7..27b3387 100755 --- a/py-kms/pykms_Server.py +++ b/py-kms/pykms_Server.py @@ -9,6 +9,7 @@ import uuid import logging import os import threading +import pickle try: # Python 2 import. @@ -27,11 +28,15 @@ import pykms_RpcBind, pykms_RpcRequest from pykms_RpcBase import rpcBase from pykms_Dcerpc import MSRPCHeader from pykms_Misc import logger_create, check_logfile, check_lcid -from pykms_Misc import KmsParser, KmsException +from pykms_Misc import KmsParser, KmsException, KmsHelper from pykms_Format import enco, deco, ShellMessage, pretty_printer +from Etrigan import Etrigan, Etrigan_parser, Etrigan_check, Etrigan_job -srv_description = 'KMS Server Emulator written in Python' -srv_version = 'py-kms_2019-05-15' +srv_version = "py-kms_2020-02-02" +__license__ = "The Unlicense" +__author__ = u"Matteo ℱan " +__url__ = "https://github.com/SystemRage/py-kms" +srv_description = "py-kms: KMS Server Emulator written in Python" srv_config = {} ##--------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -110,12 +115,14 @@ class server_thread(threading.Thread): self.name = name self.queue = queue self.server = None - self.is_running_server, self.with_gui = [False for _ in range(2)] + self.is_running_server, self.with_gui, self.checked = [False for _ in range(3)] self.is_running_thread = threading.Event() def terminate_serve(self): self.server.shutdown() self.server.server_close() + self.server = None + self.is_running_server = False def terminate_thread(self): self.is_running_thread.set() @@ -136,15 +143,11 @@ class server_thread(threading.Thread): self.eject = False self.is_running_server = True # Check options. - server_check() + if not self.checked: + server_check() # Create and run server. self.server = server_create() self.server.pykms_serve() - elif item == 'stop': - self.server = None - self.is_running_server = False - elif item == 'exit': - self.terminate_thread() except SystemExit as e: self.eject = True if not self.with_gui: @@ -179,46 +182,126 @@ The default is \"364F463A8863D35F\" or type \"RANDOM\" to auto generate the HWID 'choi' : ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "MINI"]}, 'lfile' : {'help' : 'Use this option to set an output log file. The default is \"pykms_logserver.log\". Type \"STDOUT\" to view \ log info on stdout. Type \"FILESTDOUT\" to combine previous actions.', - 'def' : os.path.dirname(os.path.abspath( __file__ )) + "/pykms_logserver.log", 'des' : "logfile"}, + 'def' : os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pykms_logserver.log'), 'des' : "logfile"}, 'lsize' : {'help' : 'Use this flag to set a maximum size (in MB) to the output log file. Desactivated by default.', 'def' : 0, 'des': "logsize"}, } def server_options(): - parser = KmsParser(description = srv_description, epilog = 'version: ' + srv_version) - parser.add_argument("ip", nargs = "?", action = "store", default = srv_options['ip']['def'], help = srv_options['ip']['help'], type = str) - parser.add_argument("port", nargs = "?", action = "store", default = srv_options['port']['def'], help = srv_options['port']['help'], type = int) - parser.add_argument("-e", "--epid", dest = srv_options['epid']['des'], default = srv_options['epid']['def'], help = srv_options['epid']['help'], type = str) - parser.add_argument("-l", "--lcid", dest = srv_options['lcid']['des'], default = srv_options['lcid']['def'], help = srv_options['lcid']['help'], type = int) - parser.add_argument("-c", "--client-count", dest = srv_options['count']['des'] , default = srv_options['count']['def'], - help = srv_options['count']['help'], type = int) - parser.add_argument("-a", "--activation-interval", dest = srv_options['activation']['des'], default = srv_options['activation']['def'], - help = srv_options['activation']['help'], type = int) - parser.add_argument("-r", "--renewal-interval", dest = srv_options['renewal']['des'], default = srv_options['renewal']['def'], - help = srv_options['renewal']['help'], type = int) - parser.add_argument("-s", "--sqlite", dest = srv_options['sql']['des'], action = "store_const", const = True, default = srv_options['sql']['def'], - help = srv_options['sql']['help']) - parser.add_argument("-w", "--hwid", dest = srv_options['hwid']['des'], action = "store", default = srv_options['hwid']['def'], - help = srv_options['hwid']['help'], type = str) - parser.add_argument("-t", "--timeout", dest = srv_options['time']['des'], action = "store", default = srv_options['time']['def'], - help = srv_options['time']['help'], type = int) - parser.add_argument("-V", "--loglevel", dest = srv_options['llevel']['des'], action = "store", choices = srv_options['llevel']['choi'], - default = srv_options['llevel']['def'], help = srv_options['llevel']['help'], type = str) - parser.add_argument("-F", "--logfile", nargs = "+", action = "store", dest = srv_options['lfile']['des'], default = srv_options['lfile']['def'], - help = srv_options['lfile']['help'], type = str) - parser.add_argument("-S", "--logsize", dest = srv_options['lsize']['des'], action = "store", default = srv_options['lsize']['def'], - help = srv_options['lsize']['help'], type = float) + main_parser = KmsParser(description = srv_description, epilog = 'version: ' + srv_version, add_help = False, allow_abbrev = False) + main_parser.add_argument("ip", nargs = "?", action = "store", default = srv_options['ip']['def'], help = srv_options['ip']['help'], type = str) + main_parser.add_argument("port", nargs = "?", action = "store", default = srv_options['port']['def'], help = srv_options['port']['help'], type = int) + main_parser.add_argument("-e", "--epid", action = "store", dest = srv_options['epid']['des'], default = srv_options['epid']['def'], + help = srv_options['epid']['help'], type = str) + main_parser.add_argument("-l", "--lcid", action = "store", dest = srv_options['lcid']['des'], default = srv_options['lcid']['def'], + help = srv_options['lcid']['help'], type = int) + main_parser.add_argument("-c", "--client-count", action = "store", dest = srv_options['count']['des'] , default = srv_options['count']['def'], + help = srv_options['count']['help'], type = int) + main_parser.add_argument("-a", "--activation-interval", action = "store", dest = srv_options['activation']['des'], + default = srv_options['activation']['def'], help = srv_options['activation']['help'], type = int) + main_parser.add_argument("-r", "--renewal-interval", action = "store", dest = srv_options['renewal']['des'], default = srv_options['renewal']['def'], + help = srv_options['renewal']['help'], type = int) + main_parser.add_argument("-s", "--sqlite", action = "store_const", dest = srv_options['sql']['des'], const = True, default = srv_options['sql']['def'], + help = srv_options['sql']['help']) + main_parser.add_argument("-w", "--hwid", action = "store", dest = srv_options['hwid']['des'], default = srv_options['hwid']['def'], + help = srv_options['hwid']['help'], type = str) + main_parser.add_argument("-t", "--timeout", action = "store", dest = srv_options['time']['des'], default = srv_options['time']['def'], + help = srv_options['time']['help'], type = int) + main_parser.add_argument("-V", "--loglevel", action = "store", dest = srv_options['llevel']['des'], choices = srv_options['llevel']['choi'], + default = srv_options['llevel']['def'], help = srv_options['llevel']['help'], type = str) + main_parser.add_argument("-F", "--logfile", nargs = "+", action = "store", dest = srv_options['lfile']['des'], default = srv_options['lfile']['def'], + help = srv_options['lfile']['help'], type = str) + main_parser.add_argument("-S", "--logsize", action = "store", dest = srv_options['lsize']['des'], default = srv_options['lsize']['def'], + help = srv_options['lsize']['help'], type = float) + main_parser.add_argument("-h", "--help", action = "help", help = "show this help message and exit") + + daemon_parser = KmsParser(description = "daemon options inherited from Etrigan", add_help = False, allow_abbrev = False) + daemon_subparser = daemon_parser.add_subparsers(dest = "mode") + etrigan_parser = daemon_subparser.add_parser("etrigan", add_help = False, allow_abbrev = False) + etrigan_parser.add_argument("-g", "--gui", action = "store_const", dest = 'gui', const = True, default = False, + help = "Enable py-kms GUI usage.") + etrigan_parser = Etrigan_parser(parser = etrigan_parser) try: - srv_config.update(vars(parser.parse_args())) - # Check logfile. - srv_config['logfile'] = check_logfile(srv_config['logfile'], srv_options['lfile']['def'], where = "srv") + if "-h" in sys.argv[1:]: + KmsHelper().print(parsers = [main_parser, daemon_parser, etrigan_parser]) + + # Set defaults for config. + # case: python3 pykms_Server.py + srv_config.update(vars(main_parser.parse_args([]))) + # Eventually set daemon values for config. + if 'etrigan' in sys.argv[1:]: + if 'etrigan' == sys.argv[1]: + # case: python3 pykms_Server.py etrigan start --daemon_optionals + srv_config.update(vars(daemon_parser.parse_args(sys.argv[1:]))) + elif 'etrigan' == sys.argv[2]: + # case: python3 pykms_Server.py 1.2.3.4 etrigan start --daemon_optionals + srv_config['ip'] = sys.argv[1] + srv_config.update(vars(daemon_parser.parse_args(sys.argv[2:]))) + else: + # case: python3 pykms_Server.py 1.2.3.4 1234 --main_optionals etrigan start --daemon_optionals + knw_args, knw_extras = main_parser.parse_known_args() + # fix for logfile option (at the end) that catchs etrigan parser options. + if 'etrigan' in knw_args.logfile: + indx = knw_args.logfile.index('etrigan') + for num, elem in enumerate(knw_args.logfile[indx:]): + knw_extras.insert(num, elem) + knw_args.logfile = knw_args.logfile[:indx] + + # continue parsing. + if len(knw_extras) > 0 and knw_extras[0] in ['etrigan']: + daemon_parser.parse_args(knw_extras, namespace = knw_args) + srv_config.update(vars(knw_args)) + else: + # Update dict config. + # case: python3 pykms_Server.py 1.2.3.4 1234 --main_optionals + knw_args, knw_extras = main_parser.parse_known_args() + if knw_extras != []: + raise KmsException("unrecognized arguments: %s" %' '.join(knw_extras)) + else: + srv_config.update(vars(knw_args)) + except KmsException as e: pretty_printer(put_text = "{reverse}{red}{bold}%s. Exiting...{end}" %str(e), to_exit = True) + +def server_daemon(): + if 'etrigan' in srv_config.values(): + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pykms_config.pickle') + + if srv_config['operation'] in ['stop', 'restart', 'status'] and len(sys.argv[1:]) > 2: + pretty_printer(put_text = "{reverse}{red}{bold}too much arguments. Exiting...{end}", to_exit = True) + + if srv_config['gui']: + pass + else: + if srv_config['operation'] == 'start': + with open(path, 'wb') as file: + pickle.dump(srv_config, file, protocol = pickle.HIGHEST_PROTOCOL) + elif srv_config['operation'] in ['stop', 'status', 'restart']: + with open(path, 'rb') as file: + old_srv_config = pickle.load(file) + old_srv_config = {x: old_srv_config[x] for x in old_srv_config if x not in ['operation']} + srv_config.update(old_srv_config) + + serverdaemon = Etrigan(srv_config['etriganpid'], + logfile = srv_config['etriganlog'], loglevel = srv_config['etriganlev'], + mute = srv_config['etriganmute'], pause_loop = None) + + if srv_config['operation'] == 'start': + serverdaemon.want_quit = True + if srv_config['gui']: + serverdaemon.funcs_to_daemonize = [server_with_gui] + else: + server_without_gui = ServerWithoutGui() + serverdaemon.funcs_to_daemonize = [server_without_gui.start, server_without_gui.join] + indx_for_clean = lambda: (0, ) + serverdaemon.quit_on_stop = [indx_for_clean, server_without_gui.clean] + + Etrigan_job(srv_config['operation'], serverdaemon) + def server_check(): - # Check logfile (only for GUI). - if serverthread.with_gui: - srv_config['logfile'] = check_logfile(srv_config['logfile'], srv_options['lfile']['def'], where = "srv") + # Check logfile. + srv_config['logfile'] = check_logfile(srv_config['logfile'], srv_options['lfile']['def'], where = "srv") # Setup hidden or not messages. ShellMessage.view = ( False if any(i in ['STDOUT', 'FILESTDOUT'] for i in srv_config['logfile']) else True ) @@ -279,28 +362,59 @@ def server_create(): loggersrv.info("HWID: %s" % deco(binascii.b2a_hex(srv_config['hwid']), 'utf-8').upper()) return server -def srv_terminate(exit_server = False, exit_thread = False): +def server_terminate(generic_srv, exit_server = False, exit_thread = False): if exit_server: - serverthread.terminate_serve() - serverqueue.put('stop') + generic_srv.terminate_serve() if exit_thread: - serverqueue.put('exit') + generic_srv.terminate_thread() -def srv_main_without_gui(): +class ServerWithoutGui(object): + def start(self): + import queue as Queue + daemon_queue = Queue.Queue(maxsize = 0) + daemon_serverthread = server_thread(daemon_queue, name = "Thread-Srv-Daemon") + daemon_serverthread.setDaemon(True) + # options already checked in `server_main_terminal`. + daemon_serverthread.checked = True + daemon_serverthread.start() + daemon_queue.put('start') + return 0, daemon_serverthread + + def join(self, daemon_serverthread): + while daemon_serverthread.is_alive(): + daemon_serverthread.join(timeout = 0.5) + + def clean(self, daemon_serverthread): + server_terminate(daemon_serverthread, exit_server = True, exit_thread = True) + +def server_main_terminal(): # Parse options. server_options() - # Run threaded server. - serverqueue.put('start') - # Wait to finish. - try: - while serverthread.is_alive(): - serverthread.join(timeout = 0.5) - except (KeyboardInterrupt, SystemExit): - srv_terminate(exit_server = True, exit_thread = True) + # Check options. + server_check() + serverthread.checked = True -def srv_main_with_gui(width = 950, height = 660): + if 'etrigan' not in srv_config.values(): + # (without GUI) and (without daemon). + # Run threaded server. + serverqueue.put('start') + # Wait to finish. + try: + while serverthread.is_alive(): + serverthread.join(timeout = 0.5) + except (KeyboardInterrupt, SystemExit): + server_terminate(serverthread, exit_server = True, exit_thread = True) + else: + # (with or without GUI) and (with daemon) + # Setup daemon (eventually). + server_daemon() + +def server_with_gui(): import pykms_GuiBase + width = 950 + height = 660 + root = pykms_GuiBase.KmsGui() root.title(pykms_GuiBase.gui_description + ' ' + pykms_GuiBase.gui_version) # Main window initial position. @@ -314,6 +428,11 @@ def srv_main_with_gui(width = 950, height = 660): root.resizable(0, 0) root.mainloop() +def server_main_no_terminal(): + # Run tkinter GUI. + # (with GUI) and (without daemon). + server_with_gui() + class kmsServerHandler(socketserver.BaseRequestHandler): def setup(self): loggersrv.info("Connection accepted: %s:%d" % (self.client_address[0], self.client_address[1])) @@ -376,9 +495,9 @@ serverthread.start() if __name__ == "__main__": if sys.stdout.isatty(): - srv_main_without_gui() + server_main_terminal() else: try: - srv_main_with_gui() + server_main_no_terminal() except: - srv_main_without_gui() + server_main_terminal()