diff --git a/app/classes/minecraft/controller.py b/app/classes/minecraft/controller.py new file mode 100644 index 00000000..44b55472 --- /dev/null +++ b/app/classes/minecraft/controller.py @@ -0,0 +1,138 @@ +import os +import time +import logging +import sys +import yaml + +from app.classes.shared.helpers import helper +from app.classes.shared.console import console + +from app.classes.shared.models import db_helper + +from app.classes.minecraft.server import Server +from app.classes.minecraft.server_props import ServerProps + +logger = logging.getLogger(__name__) + + +class Controller: + + def __init__(self): + self.servers_list = [] + + def init_all_servers(self): + + # if we have servers defined, let's destroy it and start over + if len(self.servers_list) > 0: + self.servers_list = [] + + servers = db_helper.get_all_defined_servers() + + for s in servers: + settings_file = os.path.join(s['path'], 'server.properties') + settings = ServerProps(settings_file) + + temp_server_dict = { + 'server_id': s.get('server_id'), + 'server_data_obj': s, + 'server_obj': Server(), + 'server_settings': settings.props + } + + # setup the server, do the auto start and all that jazz + temp_server_dict['server_obj'].do_server_setup(s) + + # add this temp object to the list of init servers + self.servers_list.append(temp_server_dict) + + console.info("Loaded Server: ID {} | Name: {} | Autostart: {} | Delay: {} ".format( + s['server_id'], + s['server_name'], + s['auto_start'], + s['auto_start_delay'] + )) + + def get_server_obj(self, server_id): + + for s in self.servers_list: + if int(s['server_id']) == int(server_id): + return s['server_obj'] + + logger.warning("Unable to find server object for server id {}".format(server_id)) + return False + + @staticmethod + def list_defined_servers(): + servers = db_helper.get_all_defined_servers() + + def list_running_servers(self): + running_servers = [] + + if len(self.servers_list) > 0: + + # for each server + for s in self.servers_list: + + # is the server running? + srv_obj = s['server_obj'] + running = srv_obj.check_running() + # if so, let's add a dictionary to the list of running servers + if running: + running_servers.append({ + 'id': srv_obj.server_id, + 'name': srv_obj.name + }) + + return running_servers + + def stop_all_servers(self): + servers = self.list_running_servers() + logger.info("Found {} running server(s)".format(len(servers))) + console.info("Found {} running server(s)".format(len(servers))) + + logger.info("Stopping All Servers") + console.info("Stopping All Servers") + + for s in servers: + logger.info("Stopping Server ID {} - {}".format(s['id'], s['name'])) + console.info("Stopping Server ID {} - {}".format(s['id'], s['name'])) + + # get object + svr_obj = self.get_server_obj(s['id']) + running = svr_obj.check_running(True) + + # issue the stop command + svr_obj.stop_threaded_server() + + # while it's running, we wait + x = 0 + while running: + logger.info("Server {} is still running - waiting 2s to see if it stops".format(s['name'])) + console.info("Server {} is still running - waiting 2s to see if it stops".format(s['name'])) + running = svr_obj.check_running() + + # let's keep track of how long this is going on... + x = x + 1 + + # if we have been waiting more than 120 seconds. let's just kill the pid + if x >= 60: + logger.error("Server {} is taking way too long to stop. Killing this process".format(s['name'])) + console.error("Server {} is taking way too long to stop. Killing this process".format(s['name'])) + + svr_obj.killpid(svr_obj.PID) + running = False + + # if we killed the server, let's clean up the object + if not running: + svr_obj.cleanup_server_object() + + time.sleep(2) + + # let's wait 2 seconds to let everything flush out + time.sleep(2) + + logger.info("All Servers Stopped") + console.info("All Servers Stopped") + + +controller = Controller() diff --git a/app/classes/minecraft/server.py b/app/classes/minecraft/server.py new file mode 100644 index 00000000..ff76817b --- /dev/null +++ b/app/classes/minecraft/server.py @@ -0,0 +1,270 @@ +import os +import re +import json +import time +import psutil +import pexpect +import datetime +import threading +import schedule +import logging.config + +from pexpect.popen_spawn import PopenSpawn + +from app.classes.shared.helpers import helper +from app.classes.shared.console import console + +logger = logging.getLogger(__name__) + + +class Server: + + def __init__(self): + # holders for our process + self.process = None + self.line = False + self.PID = None + self.start_time = None + self.server_command = None + self.server_path = None + self.server_thread = None + self.settings = None + self.updating = False + self.server_id = None + self.name = None + self.is_crashed = False + self.restart_count = 0 + + def do_server_setup(self, server_data_obj): + logger.info('Creating Server object: {} | Server Name: {} | Auto Start: {}'.format( + server_data_obj['server_id'], + server_data_obj['server_name'], + server_data_obj['auto_start'] + )) + self.server_id = server_data_obj['server_id'] + self.name = server_data_obj['server_name'] + self.settings = server_data_obj + + # build our server run command + self.setup_server_run_command() + + if server_data_obj['auto_start']: + delay = int(self.settings['auto_start_delay']) + + logger.info("Scheduling server {} to start in {} seconds".format(self.name, delay)) + console.info("Scheduling server {} to start in {} seconds".format(self.name, delay)) + + schedule.every(delay).seconds.do(self.run_scheduled_server) + + def run_scheduled_server(self): + console.info("Starting Minecraft server ID: {} - {}".format(self.server_id, self.name)) + logger.info("Starting Minecraft server {}".format(self.server_id, self.name)) + self.run_threaded_server() + + # remove the scheduled job since it's ran + return schedule.CancelJob + + def run_threaded_server(self): + # start the server + self.server_thread = threading.Thread(target=self.start_server, daemon=True) + self.server_thread.start() + + def setup_server_run_command(self): + # configure the server + server_exec_path = self.settings['executable'] + self.server_command = self.settings['execution_command'] + self.server_path = self.settings['path'] + + # let's do some quick checking to make sure things actually exists + full_path = os.path.join(self.server_path, server_exec_path) + if not helper.check_file_exists(full_path): + logger.critical("Server executable path: {} does not seem to exist".format(full_path)) + console.critical("Server executable path: {} does not seem to exist".format(full_path)) + helper.do_exit() + + if not helper.check_path_exits(self.server_path): + logger.critical("Server path: {} does not seem to exits".format(self.server_path)) + console.critical("Server path: {} does not seem to exits".format(self.server_path)) + helper.do_exit() + + if not helper.check_writeable(self.server_path): + logger.critical("Unable to write/access {}".format(self.server_path)) + console.warning("Unable to write/access {}".format(self.server_path)) + helper.do_exit() + + def start_server(self): + # fail safe in case we try to start something already running + if self.check_running(): + logger.error("Server is already running - Cancelling Startup") + console.error("Server is already running - Cancelling Startup") + return False + + logger.info("Launching Server {} with command {}".format(self.name, self.server_command)) + console.info("Launching Server {} with command {}".format(self.name, self.server_command)) + + if os.name == "nt": + logger.info("Windows Detected - launching cmd") + self.server_command = self.server_command.replace('\\', '/') + logging.info("Opening CMD prompt") + self.process = pexpect.popen_spawn.PopenSpawn('cmd \r\n', timeout=None, encoding=None) + + drive_letter = self.server_path[:1] + + if drive_letter.lower() != "c": + logger.info("Server is not on the C drive, changing drive letter to {}:".format(drive_letter)) + self.process.send("{}:\r\n".format(drive_letter)) + + logging.info("changing directories to {}".format(self.server_path.replace('\\', '/'))) + self.process.send('cd {} \r\n'.format(self.server_path.replace('\\', '/'))) + logging.info("Sending command {} to CMD".format(self.server_command)) + self.process.send(self.server_command + "\r\n") + + self.is_crashed = False + else: + logger.info("Linux Detected - launching Bash") + self.process = pexpect.popen_spawn.PopenSpawn('/bin/bash \n', timeout=None, encoding=None) + + logger.info("Changing directory to {}".format(self.server_path)) + self.process.send('cd {} \n'.format(self.server_path)) + + logger.info("Sending server start command: {} to shell".format(self.server_command)) + self.process.send(self.server_command + '\n') + self.is_crashed = False + + ts = time.time() + self.start_time = str(datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')) + + if psutil.pid_exists(self.process.pid): + parent = psutil.Process(self.process.pid) + time.sleep(.5) + children = parent.children(recursive=True) + for c in children: + self.PID = c.pid + logger.info("Server {} running with PID {}".format(self.name, self.PID)) + console.info("Server {} running with PID {}".format(self.name, self.PID)) + self.is_crashed = False + else: + logger.warning("Server PID {} died right after starting - is this a server config issue?".format(self.PID)) + console.warning("Server PID {} died right after starting - is this a server config issue?".format(self.PID)) + + if self.settings['crash_detection']: + logger.info("Server {} has crash detection enabled - starting watcher task".format(self.name)) + console.info("Server {} has crash detection enabled - starting watcher task".format(self.name)) + + # TODO: create crash detection watcher and such + # schedule.every(30).seconds.do(self.check_running).tag(self.name) + + def stop_threaded_server(self): + self.stop_server() + + if self.server_thread: + self.server_thread.join() + + def stop_server(self): + if self.settings['stop_command_needed']: + self.send_command(self.settings['stop_command']) + else: + self.killpid(self.PID) + + def cleanup_server_object(self): + self.process = None + self.PID = None + self.start_time = None + self.name = None + + def check_running(self, shutting_down=False): + # if process is None, we never tried to start + if self.PID is None: + return False + + try: + running = psutil.pid_exists(self.PID) + + except Exception as e: + logger.error("Unable to find if server PID exists: {}".format(self.PID)) + running = False + pass + + if not running: + + # did the server crash? + if not shutting_down: + + # do we have crash detection turned on? + if self.settings['crash_detection']: + + # if we haven't tried to restart more 3 or more times + if self.restart_count <= 3: + + # start the server if needed + server_restarted = self.crash_detected(self.name) + + if server_restarted: + # add to the restart count + self.restart_count = self.restart_count + 1 + return False + + # we have tried to restart 4 times... + elif self.restart_count == 4: + logger.warning("Server {} has been restarted {} times. It has crashed, not restarting.".format( + self.name, self.restart_count)) + + # set to 99 restart attempts so this elif is skipped next time. (no double logging) + self.restart_count = 99 + self.is_crashed = True + return False + else: + self.is_crashed = True + return False + + self.process = None + self.PID = None + self.name = None + return False + + return True + + def send_command(self, command): + + if not self.check_running() and command.lower() != 'start': + logger.warning("Server not running, unable to send command \"{}\"".format(command)) + return False + + logger.debug("Sending command {} to server via pexpect".format(command)) + + # send it + self.process.send(command + '\n') + + def crash_detected(self, name): + + # the server crashed, or isn't found - so let's reset things. + logger.warning("The server {} seems to have vanished unexpectedly, did it crash?".format(name)) + + if self.settings['crash_detection']: + logger.info("The server {} has crashed and will be restarted. Restarting server".format(name)) + self.run_threaded_server() + return True + else: + logger.info("The server {} has crashed, crash detection is disabled and it will not be restarted".format(name)) + return False + + def killpid(self, pid): + logger.info("Terminating PID {} and all child processes".format(pid)) + process = psutil.Process(pid) + + # for every sub process... + for proc in process.children(recursive=True): + # kill all the child processes - it sounds too wrong saying kill all the children (kevdagoat: lol!) + logger.info("Sending SIGKILL to PID {}".format(proc.name)) + proc.kill() + # kill the main process we are after + logger.info('Sending SIGKILL to parent') + process.kill() + + def get_start_time(self): + if self.check_running(): + return self.start_time + else: + return False + + diff --git a/app/classes/minecraft/server_props.py b/app/classes/minecraft/server_props.py new file mode 100644 index 00000000..cd4ffd89 --- /dev/null +++ b/app/classes/minecraft/server_props.py @@ -0,0 +1,66 @@ +import pprint +import os + +class ServerProps: + + def __init__(self, filepath): + self.filepath = filepath + self.props = self._parse() + + def _parse(self): + """Loads and parses the file speified in self.filepath""" + with open(self.filepath) as fp: + line = fp.readline() + d = {} + if os.path.exists(".header"): + os.remove(".header") + while line: + if '#' != line[0]: + s = line + s1 = s[:s.find('=')] + if '\n' in s: + s2 = s[s.find('=')+1:s.find('\\')] + else: + s2 = s[s.find('=')+1:] + d[s1] = s2 + else: + with open(".header", "a+") as h: + h.write(line) + line = fp.readline() + return d + + def print(self): + """Prints the properties dictionary (using pprint)""" + pprint.pprint(self.props) + + def get(self): + """Returns the properties dictionary""" + return self.props + + def update(self, key, val): + """Updates property in the properties dictionary [ update("pvp", "true") ] and returns boolean condition""" + if key in self.props.keys(): + self.props[key] = val + return True + else: + return False + + def save(self): + """Writes to the new file""" + with open(self.filepath, "a+") as f: + f.truncate(0) + with open(".header") as header: + line = header.readline() + while line: + f.write(line) + line = header.readline() + header.close() + for key, value in self.props.items(): + f.write(key + "=" + value + "\n") + if os.path.exists(".header"): + os.remove(".header") + + @staticmethod + def cleanup(): + if os.path.exists(".header"): + os.remove(".header") diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index 43d24452..b2b4e006 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -32,7 +32,7 @@ class Helpers: self.session_file = os.path.join(self.root_dir, 'session.lock') self.settings_file = os.path.join(self.root_dir, 'config.ini') self.webroot = os.path.join(self.root_dir, 'app', 'frontend') - self.db_path = os.path.join(self.root_dir, 'commander.sqlite') + self.db_path = os.path.join(self.root_dir, 'crafty.sqlite') self.passhasher = PasswordHasher() self.exiting = False @@ -74,6 +74,15 @@ class Helpers: return version_data + def get_version_string(self): + + version_data = self.get_version() + # set some defaults if we don't get version_data from our helper + version = "{}.{}.{}".format(version_data.get('major', '?'), + version_data.get('minor', '?'), + version_data.get('sub', '?')) + return str(version) + def do_exit(self): exit_file = os.path.join(self.root_dir, 'exit.txt') try: @@ -111,6 +120,7 @@ class Helpers: def ensure_logging_setup(self): log_file = os.path.join(os.path.curdir, 'logs', 'commander.log') + session_log_file = os.path.join(os.path.curdir, 'logs', 'session.log') logger.info("Checking app directory writable") @@ -136,7 +146,7 @@ class Helpers: # del any old session.lock file as this is a new session try: - os.remove(self.session_file) + os.remove(session_log_file) except: pass @@ -326,5 +336,11 @@ class Helpers: """ return ''.join(random.choice(chars) for x in range(size)) + @staticmethod + def is_os_windows(): + if os.name == 'nt': + return True + else: + return False helper = Helpers() diff --git a/app/classes/shared/models.py b/app/classes/shared/models.py index 3f843520..5fec08d8 100644 --- a/app/classes/shared/models.py +++ b/app/classes/shared/models.py @@ -1,15 +1,18 @@ +import os import sys import logging import datetime from app.classes.shared.helpers import helper from app.classes.shared.console import console +from app.classes.minecraft.server_props import ServerProps logger = logging.getLogger(__name__) try: from peewee import * from playhouse.shortcuts import model_to_dict + import yaml except ModuleNotFoundError as e: logger.critical("Import Error: Unable to load {} module".format(e, e.name)) @@ -59,6 +62,23 @@ class Host_Stats(BaseModel): table_name = "host_stats" +class Servers(BaseModel): + server_id = AutoField() + created = DateTimeField(default=datetime.datetime.now) + server_name = CharField(default="Server") + path = CharField(default="") + executable = CharField(default="") + log_path = CharField(default="") + execution_command = CharField(default="") + auto_start = BooleanField(default=0) + auto_start_delay = IntegerField(default=10) + crash_detection = BooleanField(default=0) + stop_command = CharField(default="stop") + + class Meta: + table_name = "servers" + + class Webhooks(BaseModel): id = AutoField() name = CharField(max_length=64, unique=True) @@ -80,6 +100,7 @@ class Backups(BaseModel): class Meta: table_name = 'backups' + class db_builder: @staticmethod @@ -89,7 +110,8 @@ class db_builder: Backups, Users, Host_Stats, - Webhooks + Webhooks, + Servers ]) @staticmethod @@ -105,7 +127,25 @@ class db_builder: def is_fresh_install(): if helper.check_file_exists(helper.db_path): return False - return True + +class db_shortcuts: + + def return_rows(self, query): + rows = [] + + if query: + for s in query: + rows.append(model_to_dict(s)) + else: + rows.append({}) + return rows + + def get_all_defined_servers(self): + query = Servers.select() + return self.return_rows(query) + + installer = db_builder() +db_helper = db_shortcuts() \ No newline at end of file diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index 67951655..9ed7c7d7 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -5,13 +5,20 @@ import time import logging import threading - from app.classes.shared.helpers import helper from app.classes.shared.console import console from app.classes.web.tornado import webserver +from app.classes.minecraft import server_props logger = logging.getLogger(__name__) +try: + import schedule + +except ModuleNotFoundError as e: + logger.critical("Import Error: Unable to load {} module".format(e, e.name)) + console.critical("Import Error: Unable to load {} module".format(e, e.name)) + sys.exit(1) class TasksManager: @@ -22,6 +29,8 @@ class TasksManager: self.main_kill_switch_thread = threading.Thread(target=self.main_kill_switch, daemon=True, name="main_loop") self.main_thread_exiting = False + self.schedule_thread = threading.Thread(target=self.scheduler_thread, daemon=True, name="scheduler") + def get_main_thread_run_status(self): return self.main_thread_exiting @@ -39,9 +48,12 @@ class TasksManager: # commander.stop_all_servers() logger.info("***** Crafty Shutting Down *****\n\n") console.info("***** Crafty Shutting Down *****\n\n") - os.remove(helper.session_file) - os.remove(os.path.join(helper.root_dir, 'exit.txt')) - # ServerProps.cleanup() + try: + os.remove(helper.session_file) + os.remove(os.path.join(helper.root_dir, 'exit.txt')) + os.remove(os.path.join(helper.root_dir, '.header')) + except: + pass self.main_thread_exiting = True def start_webserver(self): @@ -57,6 +69,16 @@ class TasksManager: def stop_webserver(self): self.tornado.stop_web_server() + def start_scheduler(self): + logger.info("Launching Scheduler Thread...") + console.info("Launching Scheduler Thread...") + self.schedule_thread.start() + + @staticmethod + def scheduler_thread(): + while True: + schedule.run_pending() + time.sleep(1) diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 39e3f48b..b778e91a 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -5,12 +5,13 @@ import tornado.escape import bleach from app.classes.shared.console import console -from app.classes.shared.models import Users +from app.classes.shared.models import Users, installer from app.classes.web.base_handler import BaseHandler - +from app.classes.minecraft.controller import controller logger = logging.getLogger(__name__) + class PanelHandler(BaseHandler): @tornado.web.authenticated @@ -25,6 +26,8 @@ class PanelHandler(BaseHandler): 'user_data': user_data } + servers = controller.list_defined_servers() + if page == 'unauthorized': template = "panel/denied.html" diff --git a/app/classes/web/public_handler.py b/app/classes/web/public_handler.py index 50064372..4fd01360 100644 --- a/app/classes/web/public_handler.py +++ b/app/classes/web/public_handler.py @@ -40,7 +40,9 @@ class PublicHandler(BaseHandler): self.clear_cookie("user") self.clear_cookie("user_data") - # print(page) + page_data = { + 'version': helper.get_version_string() + } error = bleach.clean(self.get_argument('error', "")) @@ -51,19 +53,14 @@ class PublicHandler(BaseHandler): # sensible defaults template = "public/404.html" - page_data = "{}" - - # if we have no page, let's go to login - if page is None: - self.redirect("public/login") if page == "login": template = "public/login.html" - page_data = {'error': error_msg} + page_data['error'] = error_msg - # our default 404 template + # if we have no page, let's go to login else: - page_data = {'error': error_msg} + self.redirect("public/login") self.render(template, data=page_data) diff --git a/app/config/version.json b/app/config/version.json index 843bc029..71791532 100644 --- a/app/config/version.json +++ b/app/config/version.json @@ -1,5 +1,5 @@ { - "major": 0.1, - "minor": 1, + "major": 4, + "minor": 0, "sub": "Alpha" } \ No newline at end of file diff --git a/app/frontend/templates/blank_base.html b/app/frontend/templates/blank_base.html new file mode 100644 index 00000000..ace4b7dd --- /dev/null +++ b/app/frontend/templates/blank_base.html @@ -0,0 +1,51 @@ + + + + + + + {% block title %}{{ _('Default') }}{% end %} + + + + + + + + + + + + + + + +
+
+
+
+
+ + {% block content %} + {% end %} + +
+
+
+ +
+ +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/frontend/templates/main_menu.html b/app/frontend/templates/main_menu.html index 70db4707..39e52440 100644 --- a/app/frontend/templates/main_menu.html +++ b/app/frontend/templates/main_menu.html @@ -6,28 +6,13 @@ - - +