From bc2487e3df965a35addd20948ea5d4fa70c3ebcc Mon Sep 17 00:00:00 2001 From: davi_xavier <47609623+davirxavier@users.noreply.github.com> Date: Thu, 17 Aug 2023 20:18:13 -0300 Subject: [PATCH 1/3] Add backend support for server hibernation feature --- .../controllers/dummy_servers_controller.py | 84 ++++ app/classes/controllers/servers_controller.py | 85 +++- app/classes/models/servers.py | 12 + app/classes/shared/dummy_servers.py | 72 +++ app/classes/shared/helpers.py | 1 + app/classes/shared/server.py | 451 +++++++++--------- app/classes/shared/tasks.py | 14 + .../20230814130904_close_when_inactive_col.py | 22 + main.py | 1 + 9 files changed, 518 insertions(+), 224 deletions(-) create mode 100644 app/classes/controllers/dummy_servers_controller.py create mode 100644 app/classes/shared/dummy_servers.py create mode 100644 app/migrations/20230814130904_close_when_inactive_col.py diff --git a/app/classes/controllers/dummy_servers_controller.py b/app/classes/controllers/dummy_servers_controller.py new file mode 100644 index 000000000..c4ccfb002 --- /dev/null +++ b/app/classes/controllers/dummy_servers_controller.py @@ -0,0 +1,84 @@ +import asyncio +import logging +from typing import Dict, Callable + +from twisted.internet.defer import Deferred +from twisted.internet.tcp import Port + +from app.classes.shared.dummy_servers import DummyServers, DummyServerFactory +from app.classes.shared.singleton import Singleton + +logger = logging.getLogger(__name__) + + +class DummyServersController(metaclass=Singleton): + """Controller for dummy servers.""" + + def __init__(self): + self.dummy_servers = DummyServers() + self.dummy_by_id: Dict[int, Port] = {} + + def create_dummy_server( + self, + server_id: int, + server_port: int, + hibernation_motd: str, + wake_up_message: str, + online_mode: bool = True, + max_players: int = 1, + player_connected_callback: Callable = None, + ): + """ Starts a new dummy server with the defined configuration. + Parameters: + server_id: The real server id. + server_port: The port to bind the dummy server. + hibernation_motd: The server description in the multiplayer menu. + wake_up_message: The message shown to the player when they connect. + online_mode: Enable online mode for the dummy server. + max_players: The max players allowed. + player_connected_callback: The callback for when a player connects to the server. Will be called + immediately after the "Connecting to server..." message appears to a player that is joining. + """ + + async def do_create(): + factory = DummyServerFactory() + + factory.motd = hibernation_motd + factory.close_message = wake_up_message + factory.max_players = max_players + factory.online_mode = online_mode + factory.player_connected_callback = player_connected_callback + + self.dummy_by_id[int(server_id)] = self.dummy_servers.reactor.listenTCP( + server_port, + factory, + interface="0.0.0.0" + ) + logger.info(f"Created dummy server in port {server_port}.") + + asyncio.run_coroutine_threadsafe(do_create(), self.dummy_servers.loop) + + def stop_dummy_server(self, server_id, port_freed_callback: Callable = None): + """ Stops a dummy server for the server_id specified and frees up the used port. + Parameters: + server_id: The real server id. + port_freed_callback: The function that will be called when the port used by the dummy server + is completely freed. + """ + + async def do_stop(): + port: Port = self.dummy_by_id.pop(int(server_id), None) + if port: + deferred: Deferred = port.loseConnection() + if deferred and port_freed_callback: + deferred.addCallback(lambda ret: port_freed_callback()) + deferred.addErrback(lambda err: logger.error("Error while trying to stop dummy server: ", err)) + elif port_freed_callback: + port_freed_callback() + elif port_freed_callback: + port_freed_callback() + + asyncio.run_coroutine_threadsafe(do_stop(), self.dummy_servers.loop) + + def is_dummy_server_running(self, server_id): + return int(server_id) in self.dummy_by_id diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index ca6c8d222..eceb74b07 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -1,3 +1,4 @@ +import datetime import os import logging import time @@ -5,6 +6,7 @@ import json import pathlib import typing as t +from app.classes.controllers.dummy_servers_controller import DummyServersController from app.classes.controllers.roles_controller import RolesController from app.classes.shared.file_helpers import FileHelpers @@ -36,6 +38,10 @@ class ServersController(metaclass=Singleton): self.management_helper = management_helper self.servers_list = [] self.stats = Stats(self.helper, self) + self.dummy_servers = DummyServersController() + self.inactive_server_timeout = self.helper.get_setting( + "inactive_server_timeout", 10 + ) # ********************************************************************************** # Generic Servers Methods @@ -98,11 +104,28 @@ class ServersController(metaclass=Singleton): @staticmethod def update_server(server_obj): ret = HelperServers.update_server(server_obj) - server_instance: ServerInstance = ServersController().get_server_instance_by_id( + servers_controller = ServersController() + server_instance: ServerInstance = servers_controller.get_server_instance_by_id( server_obj.server_id ) server_instance.update_server_instance() + # Check if there's the need to open/close a dummy server when updating + is_running = bool(server_instance.stats_helper.get_server_stats()["running"]) + if ( + not is_running + and not servers_controller.dummy_servers.is_dummy_server_running( + server_obj.server_id + ) + ): + servers_controller.check_inactive_servers() + # Restart dummy server in case the server port was updated while it's running + elif not is_running: + servers_controller.dummy_servers.stop_dummy_server( + server_obj.server_id, + lambda: servers_controller.run_dummy_server(server_instance), + ) + return ret def get_history_stats(self, server_id, days): @@ -136,6 +159,8 @@ class ServersController(metaclass=Singleton): return server.stats_helper.get_import_status() def remove_server(self, server_id): + self.dummy_servers.stop_dummy_server(server_id) + roles_list = PermissionsServers.get_roles_from_server(server_id) for role in roles_list: role_id = role.role_id @@ -579,3 +604,61 @@ class ServersController(metaclass=Singleton): log_file_path, logs_delete_after ): os.remove(log_file_path) + + def run_dummy_server(self, server_obj: ServerInstance): + self.dummy_servers.create_dummy_server( + server_obj.server_id, + server_obj.server_object.server_port, + hibernation_motd=server_obj.server_object.hibernation_motd or "", + wake_up_message=server_obj.server_object.wake_up_message or "", + online_mode=False, + max_players=1, + player_connected_callback=lambda: server_obj.start_server(None), + ) + + def check_inactive_servers(self): + logger.info("Checking inactive servers.") + + servers = self.servers_list + for server in servers: + server_obj: ServerInstance = server["server_obj"] + stats = server_obj.get_servers_stats() + + is_running = bool(stats["running"]) + close_when_inactive = server_obj.server_object.close_when_inactive + + if is_running and close_when_inactive and int(stats["online"]) == 0: + running_diff = datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None + ) - datetime.datetime.strptime(stats["started"], "%Y-%m-%d %H:%M:%S") + + if (running_diff.total_seconds() / 60) > self.inactive_server_timeout: + logger.info( + "No players running for server {0} after {1} minute timeout, stopping and running " + "dummy server on its port ({2}).".format( + server_obj.server_id, + self.inactive_server_timeout, + server_obj.server_object.server_port, + ) + ) + + self.stop_server(server_obj.server_id) + self.run_dummy_server(server_obj) + pass + elif ( + not is_running + and close_when_inactive + and not self.dummy_servers.is_dummy_server_running(server_obj.server_id) + ): + logger.info( + "Server {0} has close_when_inactive activated, running new dummy server on its port ({1}).".format( + server_obj.server_id, server_obj.server_object.server_port + ) + ) + self.run_dummy_server(server_obj) + elif ( + not is_running + and not close_when_inactive + and self.dummy_servers.is_dummy_server_running(server_obj.server_id) + ): + self.dummy_servers.stop_dummy_server(server_obj.server_id) diff --git a/app/classes/models/servers.py b/app/classes/models/servers.py index a83fd0a2a..ec21751c6 100644 --- a/app/classes/models/servers.py +++ b/app/classes/models/servers.py @@ -42,6 +42,9 @@ class Servers(BaseModel): created_by = IntegerField(default=-100) shutdown_timeout = IntegerField(default=60) ignored_exits = CharField(default="0") + close_when_inactive = BooleanField(default=False) + hibernation_motd = CharField(default=None, null=True) + wake_up_message = CharField(default=None, null=True) class Meta: table_name = "servers" @@ -71,6 +74,9 @@ class HelperServers: created_by: int, server_port: int = 25565, server_host: str = "127.0.0.1", + close_when_inactive: bool = False, + hibernation_motd: str = None, + wake_up_message: str = None, ) -> int: """Create a server in the database @@ -87,6 +93,9 @@ class HelperServers: server_port: The port the server will be monitored on, defaults to 25565 server_host: The host the server will be monitored on, defaults to 127.0.0.1 show_status: Should Crafty show this server on the public status page + close_when_inactive: Should Crafty close this server when inactive and run it again when someone tries to connect + hibernation_motd: Motd that should be shown in dummy server when hibernating + wake_up_message: Message sent to the player when they try to connect to a dummy server when hibernating Returns: int: The new server's id @@ -111,6 +120,9 @@ class HelperServers: Servers.backup_path: backup_path, Servers.type: server_type, Servers.created_by: created_by, + Servers.close_when_inactive: close_when_inactive, + Servers.hibernation_motd: hibernation_motd, + Servers.wake_up_message: wake_up_message, } ).execute() diff --git a/app/classes/shared/dummy_servers.py b/app/classes/shared/dummy_servers.py new file mode 100644 index 000000000..439b63078 --- /dev/null +++ b/app/classes/shared/dummy_servers.py @@ -0,0 +1,72 @@ +import logging +import sys +import threading +import asyncio +from typing import Callable + +from quarry.net.server import ServerFactory, ServerProtocol +from twisted.internet import asyncioreactor + +from app.classes.shared.singleton import Singleton + +logger = logging.getLogger(__name__) + + +class DummyServers(metaclass=Singleton): + """Manager for the dummy server thread""" + + def __init__(self): + self.thread = threading.Thread( + daemon=True, + target=self.run_thread, + name="dummy_servers_thread", + ) + self.reactor: asyncioreactor.AsyncioSelectorReactor | None = None + self.loop: asyncio.AbstractEventLoop | None = None + + def init_servers(self): + self.thread.start() + + def run_thread(self): + if "twisted.internet.reactor" in sys.modules: + del sys.modules["twisted.internet.reactor"] + + self.loop = asyncio.new_event_loop() + asyncioreactor.install(self.loop) + + from twisted.internet import reactor + self.reactor = reactor + self.reactor.run(installSignalHandlers=False) + + +class DummyServerProtocol(ServerProtocol): + """Class that implements what to do after client connects to dummy server.""" + + def player_joined(self): + logger.info("Player connected in server. Stopping dummy server.") + ServerProtocol.player_joined(self) + self.close(self.factory.close_message) + + if self.factory.player_connected_callback: + self.factory.player_connected_callback() + + +class DummyServerFactory(ServerFactory): + """Factory class for a dummy server.""" + + def __init__( + self, + motd: str = "", + close_message: str = "", + online_mode: bool = False, + max_players: int = 20, + player_connected_callback: Callable = None, + ): + super().__init__() + self.protocol = DummyServerProtocol + self.motd = motd + self.close_message = close_message + self.online_mode = online_mode + self.max_players = max_players + self.player_connected_callback = player_connected_callback + diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index 489115ae9..c9efff83e 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -481,6 +481,7 @@ class Helpers: "monitored_mounts": mounts, "dir_size_poll_freq_minutes": 5, "crafty_logs_delete_after_days": 0, + "inactive_server_timeout": 10, } def get_all_settings(self): diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index c1a11158a..c44c59abb 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -21,6 +21,7 @@ from tzlocal.utils import ZoneInfoNotFoundError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.jobstores.base import JobLookupError +from app.classes.controllers.dummy_servers_controller import DummyServersController from app.classes.minecraft.stats import Stats from app.classes.minecraft.mc_ping import ping, ping_bedrock from app.classes.models.servers import HelperServers, Servers @@ -133,6 +134,7 @@ class ServerInstance: self.server_object = HelperServers.get_server_obj(self.server_id) self.stats_helper = HelperServerStats(self.server_id) self.last_backup_failed = False + self.dummy_servers = DummyServersController() try: with open( os.path.join(self.server_object.path, "db_stats", "players_cache.json"), @@ -314,280 +316,283 @@ class ServerInstance: Console.critical(f"Unable to write/access {self.server_path}") def start_server(self, user_id, forge_install=False): - if not user_id: - user_lang = self.helper.get_setting("language") - else: - user_lang = HelperUsers.get_user_lang_by_id(user_id) - - # Checks if user is currently attempting to move global server - # dir - if self.helper.dir_migration: - self.helper.websocket_helper.broadcast_user( - user_id, - "send_start_error", - { - "error": self.helper.translation.translate( - "error", - "migration", - user_lang, - ) - }, - ) - return False + def do_start(): + if not user_id: + user_lang = self.helper.get_setting("language") + else: + user_lang = HelperUsers.get_user_lang_by_id(user_id) - if self.stats_helper.get_import_status() and not forge_install: - if user_id: + # Checks if user is currently attempting to move global server + # dir + if self.helper.dir_migration: self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "not-downloaded", user_lang + "error", + "migration", + user_lang, ) }, ) - return False + return False - logger.info( - f"Start command detected. Reloading settings from DB for server {self.name}" - ) - self.setup_server_run_command() - # 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 - if self.check_update(): - logger.error("Server is updating. Terminating startup.") - return False + if self.stats_helper.get_import_status() and not forge_install: + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, + "send_start_error", + { + "error": self.helper.translation.translate( + "error", "not-downloaded", user_lang + ) + }, + ) + return False - logger.info(f"Launching Server {self.name} with command {self.server_command}") - Console.info(f"Launching Server {self.name} with command {self.server_command}") + logger.info( + f"Start command detected. Reloading settings from DB for server {self.name}" + ) + self.setup_server_run_command() + # 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 + if self.check_update(): + logger.error("Server is updating. Terminating startup.") + return False - # Checks for eula. Creates one if none detected. - # If EULA is detected and not set to true we offer to set it true. - e_flag = False - if Helpers.check_file_exists(os.path.join(self.settings["path"], "eula.txt")): - with open( - os.path.join(self.settings["path"], "eula.txt"), "r", encoding="utf-8" - ) as f: - line = f.readline().lower() - e_flag = line in [ - "eula=true", - "eula = true", - "eula= true", - "eula =true", - ] - # If this is a forge installer we're running we can bypass the eula checks. - if forge_install is True: - e_flag = True - if not e_flag and self.settings["type"] == "minecraft-java": - if user_id: - self.helper.websocket_helper.broadcast_user( - user_id, "send_eula_bootbox", {"id": self.server_id} - ) + logger.info(f"Launching Server {self.name} with command {self.server_command}") + Console.info(f"Launching Server {self.name} with command {self.server_command}") + + # Checks for eula. Creates one if none detected. + # If EULA is detected and not set to true we offer to set it true. + e_flag = False + if Helpers.check_file_exists(os.path.join(self.settings["path"], "eula.txt")): + with open( + os.path.join(self.settings["path"], "eula.txt"), "r", encoding="utf-8" + ) as f: + line = f.readline().lower() + e_flag = line in [ + "eula=true", + "eula = true", + "eula= true", + "eula =true", + ] + # If this is a forge installer we're running we can bypass the eula checks. + if forge_install is True: + e_flag = True + if not e_flag and self.settings["type"] == "minecraft-java": + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, "send_eula_bootbox", {"id": self.server_id} + ) + else: + logger.error( + "Autostart failed due to EULA being false. " + "Agree not sent due to auto start." + ) + return False + if Helpers.is_os_windows(): + logger.info("Windows Detected") else: - logger.error( - "Autostart failed due to EULA being false. " - "Agree not sent due to auto start." - ) - return False - if Helpers.is_os_windows(): - logger.info("Windows Detected") - else: - logger.info("Unix Detected") - - logger.info( - f"Starting server in {self.server_path} with command: {self.server_command}" - ) - - # checks to make sure file is openable (downloaded) and exists. - try: - with open( - os.path.join( - self.server_path, - HelperServers.get_server_data_by_id(self.server_id)["executable"], - ), - "r", - encoding="utf-8", - ): - # Can open the file - pass + logger.info("Unix Detected") - except: - if user_id: - self.helper.websocket_helper.broadcast_user( - user_id, - "send_start_error", - { - "error": self.helper.translation.translate( - "error", "not-downloaded", user_lang - ) - }, - ) - return - - if ( - not Helpers.is_os_windows() - and HelperServers.get_server_type_by_id(self.server_id) - == "minecraft-bedrock" - ): logger.info( - f"Bedrock and Unix detected for server {self.name}. " - f"Switching to appropriate execution string" + f"Starting server in {self.server_path} with command: {self.server_command}" ) - my_env = os.environ - my_env["LD_LIBRARY_PATH"] = self.server_path + + # checks to make sure file is openable (downloaded) and exists. try: - self.process = subprocess.Popen( - self.server_command, - cwd=self.server_path, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=my_env, - ) - except Exception as ex: - logger.error( - f"Server {self.name} failed to start with error code: {ex}" - ) + with open( + os.path.join( + self.server_path, + HelperServers.get_server_data_by_id(self.server_id)["executable"], + ), + "r", + encoding="utf-8", + ): + # Can open the file + pass + + except: if user_id: self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "start-error", user_lang - ).format(self.name, ex) + "error", "not-downloaded", user_lang + ) }, ) - if forge_install: - # Reset import status if failed while forge installing - self.stats_helper.finish_import() - return False + return - else: - try: - self.process = subprocess.Popen( - self.server_command, - cwd=self.server_path, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + if ( + not Helpers.is_os_windows() + and HelperServers.get_server_type_by_id(self.server_id) + == "minecraft-bedrock" + ): + logger.info( + f"Bedrock and Unix detected for server {self.name}. " + f"Switching to appropriate execution string" ) - except Exception as ex: - # Checks for java on initial fail - if not self.helper.detect_java(): + my_env = os.environ + my_env["LD_LIBRARY_PATH"] = self.server_path + try: + self.process = subprocess.Popen( + self.server_command, + cwd=self.server_path, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=my_env, + ) + except Exception as ex: + logger.error( + f"Server {self.name} failed to start with error code: {ex}" + ) if user_id: self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "noJava", user_lang - ).format(self.name) + "error", "start-error", user_lang + ).format(self.name, ex) }, ) + if forge_install: + # Reset import status if failed while forge installing + self.stats_helper.finish_import() return False - logger.error( - f"Server {self.name} failed to start with error code: {ex}" + + else: + try: + self.process = subprocess.Popen( + self.server_command, + cwd=self.server_path, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except Exception as ex: + # Checks for java on initial fail + if not self.helper.detect_java(): + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, + "send_start_error", + { + "error": self.helper.translation.translate( + "error", "noJava", user_lang + ).format(self.name) + }, + ) + return False + logger.error( + f"Server {self.name} failed to start with error code: {ex}" + ) + if user_id: + self.helper.websocket_helper.broadcast_user( + user_id, + "send_start_error", + { + "error": self.helper.translation.translate( + "error", "start-error", user_lang + ).format(self.name, ex) + }, + ) + if forge_install: + # Reset import status if failed while forge installing + self.stats_helper.finish_import() + return False + + out_buf = ServerOutBuf(self.helper, self.process, self.server_id) + + logger.debug(f"Starting virtual terminal listener for server {self.name}") + threading.Thread( + target=out_buf.check, daemon=True, name=f"{self.server_id}_virtual_terminal" + ).start() + + self.is_crashed = False + self.stats_helper.server_crash_reset() + + self.start_time = str(datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")) + + if self.process.poll() is None: + logger.info(f"Server {self.name} running with PID {self.process.pid}") + Console.info(f"Server {self.name} running with PID {self.process.pid}") + self.is_crashed = False + self.stats_helper.server_crash_reset() + self.record_server_stats() + check_internet_thread = threading.Thread( + target=self.check_internet_thread, + daemon=True, + args=( + user_id, + user_lang, + ), + name=f"{self.name}_Internet", ) - if user_id: + check_internet_thread.start() + # Checks if this is the servers first run. + if self.stats_helper.get_first_run(): + self.stats_helper.set_first_run() + loc_server_port = self.stats_helper.get_server_stats()["server_port"] + # Sends port reminder message. self.helper.websocket_helper.broadcast_user( user_id, "send_start_error", { "error": self.helper.translation.translate( - "error", "start-error", user_lang - ).format(self.name, ex) + "error", "portReminder", user_lang + ).format(self.name, loc_server_port) }, ) - if forge_install: - # Reset import status if failed while forge installing - self.stats_helper.finish_import() - return False - - out_buf = ServerOutBuf(self.helper, self.process, self.server_id) - - logger.debug(f"Starting virtual terminal listener for server {self.name}") - threading.Thread( - target=out_buf.check, daemon=True, name=f"{self.server_id}_virtual_terminal" - ).start() - - self.is_crashed = False - self.stats_helper.server_crash_reset() - - self.start_time = str(datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")) - - if self.process.poll() is None: - logger.info(f"Server {self.name} running with PID {self.process.pid}") - Console.info(f"Server {self.name} running with PID {self.process.pid}") - self.is_crashed = False - self.stats_helper.server_crash_reset() - self.record_server_stats() - check_internet_thread = threading.Thread( - target=self.check_internet_thread, - daemon=True, - args=( - user_id, - user_lang, - ), - name=f"{self.name}_Internet", - ) - check_internet_thread.start() - # Checks if this is the servers first run. - if self.stats_helper.get_first_run(): - self.stats_helper.set_first_run() - loc_server_port = self.stats_helper.get_server_stats()["server_port"] - # Sends port reminder message. - self.helper.websocket_helper.broadcast_user( - user_id, - "send_start_error", - { - "error": self.helper.translation.translate( - "error", "portReminder", user_lang - ).format(self.name, loc_server_port) - }, - ) - server_users = PermissionsServers.get_server_user_list(self.server_id) - for user in server_users: - if user != user_id: + server_users = PermissionsServers.get_server_user_list(self.server_id) + for user in server_users: + if user != user_id: + self.helper.websocket_helper.broadcast_user( + user, "send_start_reload", {} + ) + else: + server_users = PermissionsServers.get_server_user_list(self.server_id) + for user in server_users: self.helper.websocket_helper.broadcast_user( user, "send_start_reload", {} ) else: - server_users = PermissionsServers.get_server_user_list(self.server_id) - for user in server_users: - self.helper.websocket_helper.broadcast_user( - user, "send_start_reload", {} - ) - else: - logger.warning( - f"Server PID {self.process.pid} died right after starting " - f"- is this a server config issue?" - ) - Console.critical( - f"Server PID {self.process.pid} died right after starting " - f"- is this a server config issue?" - ) + logger.warning( + f"Server PID {self.process.pid} died right after starting " + f"- is this a server config issue?" + ) + Console.critical( + f"Server PID {self.process.pid} died right after starting " + f"- is this a server config issue?" + ) - if self.settings["crash_detection"]: - logger.info( - f"Server {self.name} has crash detection enabled " - f"- starting watcher task" - ) - Console.info( - f"Server {self.name} has crash detection enabled " - f"- starting watcher task" - ) + if self.settings["crash_detection"]: + logger.info( + f"Server {self.name} has crash detection enabled " + f"- starting watcher task" + ) + Console.info( + f"Server {self.name} has crash detection enabled " + f"- starting watcher task" + ) - self.server_scheduler.add_job( - self.detect_crash, "interval", seconds=30, id=f"c_{self.server_id}" - ) + self.server_scheduler.add_job( + self.detect_crash, "interval", seconds=30, id=f"c_{self.server_id}" + ) + + # If this is a forge install we'll call the watcher to do the things + if forge_install: + self.forge_install_watcher() - # If this is a forge install we'll call the watcher to do the things - if forge_install: - self.forge_install_watcher() + self.dummy_servers.stop_dummy_server(self.server_id, port_freed_callback=do_start) def check_internet_thread(self, user_id, user_lang): if user_id: diff --git a/app/classes/shared/tasks.py b/app/classes/shared/tasks.py index acdc1cac6..773bd7d26 100644 --- a/app/classes/shared/tasks.py +++ b/app/classes/shared/tasks.py @@ -16,6 +16,7 @@ from app.classes.models.management import HelpersManagement from app.classes.models.users import HelperUsers from app.classes.controllers.users_controller import UsersController from app.classes.shared.console import Console +from app.classes.shared.dummy_servers import DummyServers from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.helpers import Helpers from app.classes.shared.main_controller import Controller @@ -57,6 +58,8 @@ class TasksManager: self.users_controller: UsersController = self.controller.users + self.dummy_server_manager = DummyServers() + self.webserver_thread = threading.Thread( target=self.tornado.run_tornado, daemon=True, name="tornado_thread" ) @@ -81,6 +84,9 @@ class TasksManager: self.reload_schedule_from_db() + def start_dummy_servers(self): + self.dummy_server_manager.init_servers() + def get_main_thread_run_status(self): return self.main_thread_exiting @@ -203,6 +209,14 @@ class TasksManager: # self.scheduler.add_job( # self.scheduler.print_jobs, "interval", seconds=10, id="-1" # ) + self.controller.servers.check_inactive_servers() + self.scheduler.add_job( + self.controller.servers.check_inactive_servers, + "interval", + minutes=1, + id="check_inactive_servers", + start_date=datetime.datetime.now(), + ) # load schedules from DB for schedule in schedules: diff --git a/app/migrations/20230814130904_close_when_inactive_col.py b/app/migrations/20230814130904_close_when_inactive_col.py new file mode 100644 index 000000000..795a1fe39 --- /dev/null +++ b/app/migrations/20230814130904_close_when_inactive_col.py @@ -0,0 +1,22 @@ +# Generated by database migrator +import peewee + +from app.classes.models.servers import Servers + + +def migrate(migrator, db): + migrator.add_columns( + Servers, close_when_inactive=peewee.BooleanField(default=False) + ) + migrator.add_columns( + Servers, hibernation_motd=peewee.CharField(default=None, null=True) + ) + migrator.add_columns( + Servers, wake_up_message=peewee.CharField(default=None, null=True) + ) + + +def rollback(migrator, db): + migrator.drop_columns( + Servers, ["close_when_inactive", "hibernation_motd", "wake_up_message"] + ) diff --git a/main.py b/main.py index 2338517b0..b2573aec8 100644 --- a/main.py +++ b/main.py @@ -173,6 +173,7 @@ if __name__ == "__main__": import3 = Import3(helper, controller) tasks_manager = TasksManager(helper, controller) tasks_manager.start_webserver() + tasks_manager.start_dummy_servers() def signal_handler(signum, _frame): if not args.daemon: -- GitLab From f24ded31934121e610907f853f220da1908eaf61 Mon Sep 17 00:00:00 2001 From: davi_xavier <47609623+davirxavier@users.noreply.github.com> Date: Thu, 17 Aug 2023 21:04:59 -0300 Subject: [PATCH 2/3] Add frontend support for server hibernation feature --- app/classes/controllers/servers_controller.py | 6 ++- app/classes/web/panel_handler.py | 3 ++ .../web/routes/api/servers/server/index.py | 6 +++ .../templates/panel/server_config.html | 50 ++++++++++++++++++- app/translations/en_EN.json | 7 ++- app/translations/pt_BR.json | 7 ++- requirements.txt | 1 + 7 files changed, 76 insertions(+), 4 deletions(-) diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index eceb74b07..53a11718f 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -103,6 +103,10 @@ class ServersController(metaclass=Singleton): @staticmethod def update_server(server_obj): + if not server_obj.close_when_inactive: + server_obj.hibernation_motd = None + server_obj.wake_up_message = None + ret = HelperServers.update_server(server_obj) servers_controller = ServersController() server_instance: ServerInstance = servers_controller.get_server_instance_by_id( @@ -123,7 +127,7 @@ class ServersController(metaclass=Singleton): elif not is_running: servers_controller.dummy_servers.stop_dummy_server( server_obj.server_id, - lambda: servers_controller.run_dummy_server(server_instance), + port_freed_callback=servers_controller.check_inactive_servers, ) return ret diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 20c76c1a9..d4a810012 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -536,6 +536,9 @@ class PanelHandler(BaseHandler): "auto_start": server_temp_obj["auto_start"], "crash_detection": server_temp_obj["crash_detection"], "show_status": server_temp_obj["show_status"], + "close_when_inactive": server_temp_obj["close_when_inactive"], + "hibernation_motd": server_temp_obj["hibernation_motd"], + "wake_up_message": server_temp_obj["wake_up_message"], "ignored_exits": server_temp_obj["ignored_exits"], }, "running": False, diff --git a/app/classes/web/routes/api/servers/server/index.py b/app/classes/web/routes/api/servers/server/index.py index afe02a0b7..2570c1af2 100644 --- a/app/classes/web/routes/api/servers/server/index.py +++ b/app/classes/web/routes/api/servers/server/index.py @@ -21,6 +21,9 @@ server_patch_schema = { "auto_start": {"type": "boolean"}, "auto_start_delay": {"type": "integer", "minimum": 0}, "crash_detection": {"type": "boolean"}, + "close_when_inactive": {"type": "boolean"}, + "hibernation_motd": {"type": "string"}, + "wake_up_message": {"type": "string"}, "stop_command": {"type": "string"}, "executable_update_url": {"type": "string"}, "server_ip": {"type": "string", "minLength": 1}, @@ -42,6 +45,9 @@ basic_server_patch_schema = { "auto_start": {"type": "boolean"}, "auto_start_delay": {"type": "integer", "minimum": 0}, "crash_detection": {"type": "boolean"}, + "close_when_inactive": {"type": "boolean"}, + "hibernation_motd": {"type": "string"}, + "wake_up_message": {"type": "string"}, "stop_command": {"type": "string"}, "shutdown_timeout": {"type": "integer"}, "logs_delete_after": {"type": "integer", "minimum": 0}, diff --git a/app/frontend/templates/panel/server_config.html b/app/frontend/templates/panel/server_config.html index 913a20003..54c464ab7 100644 --- a/app/frontend/templates/panel/server_config.html +++ b/app/frontend/templates/panel/server_config.html @@ -166,6 +166,35 @@ +
+
+ {% if data['server_stats']['server_id']['close_when_inactive'] %} + + + - {{ translate('serverConfig', 'serverCloseWhenInactiveExplain1', data['lang']) }} + {% else %} + + + - {{ translate('serverConfig', 'serverCloseWhenInactiveExplain1', data['lang']) }} + {% end %} +
+
+ +
+
+ + +
+
+ + +
+
+
{% if data['server_stats']['server_id']['auto_start'] %} @@ -547,6 +576,20 @@ webSocket.on('remove_spinner', function () { document.getElementById("update-spinner").style.visibility = "hidden"; }); + + let checkInactiveInputs = function() { + if ($("#close_when_inactive").prop('checked')) { + $("#close_when_inactive_inputs").removeClass("d-none"); + $("#close_when_inactive_inputs input").attr("required", ""); + } else { + $("#close_when_inactive_inputs").addClass("d-none"); + $("#close_when_inactive_inputs input").removeAttr("required"); + } + }; + + checkInactiveInputs(); + $("#close_when_inactive").change(checkInactiveInputs); + $("#config_form").on("submit", async function (e) { e.preventDefault(); var token = getCookie("_xsrf") @@ -559,6 +602,11 @@ formDataObject.show_status = $("#show_status").prop('checked'); formDataObject.crash_detection = $("#crash_detection").prop('checked'); formDataObject.auto_start = $("#auto_start").prop('checked'); + + formDataObject.close_when_inactive = $("#close_when_inactive").prop('checked'); + formDataObject['hibernation_motd'] = formDataObject['hibernation_motd'] ? formDataObject['hibernation_motd'] : undefined; + formDataObject['wake_up_message'] = formDataObject['wake_up_message'] ? formDataObject['wake_up_message'] : undefined; + console.log(formDataObject); // Format the plain form data as JSON let formDataJsonString = JSON.stringify(formDataObject, replacer); @@ -588,4 +636,4 @@ }); -{% end %} \ No newline at end of file +{% end %} diff --git a/app/translations/en_EN.json b/app/translations/en_EN.json index 5da22d2a3..aa3749246 100644 --- a/app/translations/en_EN.json +++ b/app/translations/en_EN.json @@ -335,6 +335,7 @@ "deleteServerQuestionMessage": "Are you sure you want to delete this server? After this there is no going back...", "exeUpdateURL": "Server Executable Update URL", "exeUpdateURLDesc": "Direct Download URL for updates.", + "hibernationMotd": "Server description while in hibernation", "javaNoChange": "Do Not Override", "javaVersion": "Override current Java Version", "javaVersionDesc": "If you're going to override Java, make sure your current Java path in 'execution command' is wrapped in quotes (default 'java' variable excluded)", @@ -348,6 +349,8 @@ "serverAutoStart": "Server Auto Start", "serverAutostartDelay": "Server Autostart Delay", "serverAutostartDelayDesc": "Delay before auto starting (If enabled below)", + "serverCloseWhenInactive": "Close server when inactive", + "serverCloseWhenInactiveExplain1": "Close server when there are no online players and reopen it when a someone tries to join", "serverCrashDetection": "Server Crash Detection", "serverExecutable": "Server Executable", "serverExecutableDesc": "The server's executable file", @@ -368,6 +371,8 @@ "showStatus": "Show On Public Status Page", "stopBeforeDeleting": "Please stop the server before deleting it", "update": "Update Executable", + "wakeUpMessage": "Server wake up message", + "wakeUpMessageExplain": "What should be shown to a player when they try to join the server while it is in hibernation", "yesDelete": "Yes, delete", "yesDeleteFiles": "Yes, delete files", "shutdownTimeout": "Shutdown Timeout", @@ -628,4 +633,4 @@ "manager": "Manager", "selectManager": "Select Manager for User" } -} \ No newline at end of file +} diff --git a/app/translations/pt_BR.json b/app/translations/pt_BR.json index af23d2c99..ce82858b5 100644 --- a/app/translations/pt_BR.json +++ b/app/translations/pt_BR.json @@ -290,6 +290,7 @@ "deleteServerQuestionMessage": "Tem certeza de que deseja deletar esta servidor? Esta ação não pode ser desfeita.", "exeUpdateURL": "URL de Atualização do Executável do Servidor", "exeUpdateURLDesc": "URL de Download Direto para atualizações.", + "hibernationMotd": "Descrição do servidor quando estiver em suspensão", "javaNoChange": "Não sobrepor", "javaVersion": "Sobrepor versão atual do Java", "javaVersionDesc": "Se for sobrepor o Java, certifique-se de que seu Java atual em 'Comando de Execução do Servidor' está envolto em aspas simples(' ') (variável 'java' padrão excluída)", @@ -303,6 +304,8 @@ "serverAutoStart": "Início Automático do Servidor", "serverAutostartDelay": "Atraso de Início Automático de Servidor", "serverAutostartDelayDesc": "Atraso antes do início automático (Se habilitado abaixo)", + "serverCloseWhenInactive": "Suspender servidor quando inativo", + "serverCloseWhenInactiveExplain1": "Desliga o servidor quando não há jogadores online e o abre novamente caso alguém tente se conectar", "serverCrashDetection": "Detecção de Crash do Servidor", "serverExecutable": "Executável do Servidor", "serverExecutableDesc": "O arquivo executável do servidor", @@ -322,6 +325,8 @@ "serverStopCommandDesc": "Comando enviado ao programa para pará-lo", "stopBeforeDeleting": "Por favor pare o servidor antes de deletá-lo", "update": "Atualizar Executável", + "wakeUpMessage": "Mensagem de despertar do servidor", + "wakeUpMessageExplain": "A mensagem que será mostrada ao jogador quando ele acorda o servidor que está em modo de suspensão", "yesDelete": "Sim, delete.", "yesDeleteFiles": "Sim, delete os arquivos." }, @@ -537,4 +542,4 @@ "userSettings": "Configurações do Usuário", "uses": "Número de Usos Permitidos (-1==Sem Limite)" } -} \ No newline at end of file +} diff --git a/requirements.txt b/requirements.txt index 98e095f17..f229f6734 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ tornado==6.3.2 tzlocal==4.0 jsonschema==4.5.1 orjson==3.8.12 +quarry==1.9.4 -- GitLab From f915b6a3efac205cc23a89c6c562059b7f7f047c Mon Sep 17 00:00:00 2001 From: davi_xavier <47609623+davirxavier@users.noreply.github.com> Date: Thu, 17 Aug 2023 21:23:33 -0300 Subject: [PATCH 3/3] Fix lint issues in new server hibernation code --- .../controllers/dummy_servers_controller.py | 40 +++++++------ app/classes/controllers/servers_controller.py | 12 ++-- app/classes/shared/dummy_servers.py | 2 +- app/classes/shared/server.py | 60 +++++++++++++------ 4 files changed, 69 insertions(+), 45 deletions(-) diff --git a/app/classes/controllers/dummy_servers_controller.py b/app/classes/controllers/dummy_servers_controller.py index c4ccfb002..5c156d908 100644 --- a/app/classes/controllers/dummy_servers_controller.py +++ b/app/classes/controllers/dummy_servers_controller.py @@ -28,16 +28,16 @@ class DummyServersController(metaclass=Singleton): max_players: int = 1, player_connected_callback: Callable = None, ): - """ Starts a new dummy server with the defined configuration. - Parameters: - server_id: The real server id. - server_port: The port to bind the dummy server. - hibernation_motd: The server description in the multiplayer menu. - wake_up_message: The message shown to the player when they connect. - online_mode: Enable online mode for the dummy server. - max_players: The max players allowed. - player_connected_callback: The callback for when a player connects to the server. Will be called - immediately after the "Connecting to server..." message appears to a player that is joining. + """Starts a new dummy server with the defined configuration. + Parameters: + server_id: The real server id. + server_port: The port to bind the dummy server. + hibernation_motd: The server description in the multiplayer menu. + wake_up_message: The message shown to the player when they connect. + online_mode: Enable online mode for the dummy server. + max_players: The max players allowed. + player_connected_callback: The callback for when a player connects to the server. Will be called + immediately after the "Connecting to server..." message appears to a player that is joining. """ async def do_create(): @@ -50,20 +50,18 @@ class DummyServersController(metaclass=Singleton): factory.player_connected_callback = player_connected_callback self.dummy_by_id[int(server_id)] = self.dummy_servers.reactor.listenTCP( - server_port, - factory, - interface="0.0.0.0" + server_port, factory, interface="0.0.0.0" ) logger.info(f"Created dummy server in port {server_port}.") asyncio.run_coroutine_threadsafe(do_create(), self.dummy_servers.loop) def stop_dummy_server(self, server_id, port_freed_callback: Callable = None): - """ Stops a dummy server for the server_id specified and frees up the used port. - Parameters: - server_id: The real server id. - port_freed_callback: The function that will be called when the port used by the dummy server - is completely freed. + """Stops a dummy server for the server_id specified and frees up the used port. + Parameters: + server_id: The real server id. + port_freed_callback: The function that will be called when the port used by the dummy server + is completely freed. """ async def do_stop(): @@ -72,7 +70,11 @@ class DummyServersController(metaclass=Singleton): deferred: Deferred = port.loseConnection() if deferred and port_freed_callback: deferred.addCallback(lambda ret: port_freed_callback()) - deferred.addErrback(lambda err: logger.error("Error while trying to stop dummy server: ", err)) + deferred.addErrback( + lambda err: logger.error( + "Error while trying to stop dummy server: ", err + ) + ) elif port_freed_callback: port_freed_callback() elif port_freed_callback: diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index 53a11718f..278d7967b 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -650,9 +650,9 @@ class ServersController(metaclass=Singleton): self.run_dummy_server(server_obj) pass elif ( - not is_running - and close_when_inactive - and not self.dummy_servers.is_dummy_server_running(server_obj.server_id) + not is_running + and close_when_inactive + and not self.dummy_servers.is_dummy_server_running(server_obj.server_id) ): logger.info( "Server {0} has close_when_inactive activated, running new dummy server on its port ({1}).".format( @@ -661,8 +661,8 @@ class ServersController(metaclass=Singleton): ) self.run_dummy_server(server_obj) elif ( - not is_running - and not close_when_inactive - and self.dummy_servers.is_dummy_server_running(server_obj.server_id) + not is_running + and not close_when_inactive + and self.dummy_servers.is_dummy_server_running(server_obj.server_id) ): self.dummy_servers.stop_dummy_server(server_obj.server_id) diff --git a/app/classes/shared/dummy_servers.py b/app/classes/shared/dummy_servers.py index 439b63078..d5e047fda 100644 --- a/app/classes/shared/dummy_servers.py +++ b/app/classes/shared/dummy_servers.py @@ -35,6 +35,7 @@ class DummyServers(metaclass=Singleton): asyncioreactor.install(self.loop) from twisted.internet import reactor + self.reactor = reactor self.reactor.run(installSignalHandlers=False) @@ -69,4 +70,3 @@ class DummyServerFactory(ServerFactory): self.online_mode = online_mode self.max_players = max_players self.player_connected_callback = player_connected_callback - diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index c44c59abb..3c298c533 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -364,15 +364,23 @@ class ServerInstance: logger.error("Server is updating. Terminating startup.") return False - logger.info(f"Launching Server {self.name} with command {self.server_command}") - Console.info(f"Launching Server {self.name} with command {self.server_command}") + logger.info( + f"Launching Server {self.name} with command {self.server_command}" + ) + Console.info( + f"Launching Server {self.name} with command {self.server_command}" + ) # Checks for eula. Creates one if none detected. # If EULA is detected and not set to true we offer to set it true. e_flag = False - if Helpers.check_file_exists(os.path.join(self.settings["path"], "eula.txt")): + if Helpers.check_file_exists( + os.path.join(self.settings["path"], "eula.txt") + ): with open( - os.path.join(self.settings["path"], "eula.txt"), "r", encoding="utf-8" + os.path.join(self.settings["path"], "eula.txt"), + "r", + encoding="utf-8", ) as f: line = f.readline().lower() e_flag = line in [ @@ -407,12 +415,14 @@ class ServerInstance: # checks to make sure file is openable (downloaded) and exists. try: with open( - os.path.join( - self.server_path, - HelperServers.get_server_data_by_id(self.server_id)["executable"], - ), - "r", - encoding="utf-8", + os.path.join( + self.server_path, + HelperServers.get_server_data_by_id(self.server_id)[ + "executable" + ], + ), + "r", + encoding="utf-8", ): # Can open the file pass @@ -431,9 +441,9 @@ class ServerInstance: return if ( - not Helpers.is_os_windows() - and HelperServers.get_server_type_by_id(self.server_id) - == "minecraft-bedrock" + not Helpers.is_os_windows() + and HelperServers.get_server_type_by_id(self.server_id) + == "minecraft-bedrock" ): logger.info( f"Bedrock and Unix detected for server {self.name}. " @@ -514,13 +524,17 @@ class ServerInstance: logger.debug(f"Starting virtual terminal listener for server {self.name}") threading.Thread( - target=out_buf.check, daemon=True, name=f"{self.server_id}_virtual_terminal" + target=out_buf.check, + daemon=True, + name=f"{self.server_id}_virtual_terminal", ).start() self.is_crashed = False self.stats_helper.server_crash_reset() - self.start_time = str(datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")) + self.start_time = str( + datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + ) if self.process.poll() is None: logger.info(f"Server {self.name} running with PID {self.process.pid}") @@ -541,7 +555,9 @@ class ServerInstance: # Checks if this is the servers first run. if self.stats_helper.get_first_run(): self.stats_helper.set_first_run() - loc_server_port = self.stats_helper.get_server_stats()["server_port"] + loc_server_port = self.stats_helper.get_server_stats()[ + "server_port" + ] # Sends port reminder message. self.helper.websocket_helper.broadcast_user( user_id, @@ -552,14 +568,18 @@ class ServerInstance: ).format(self.name, loc_server_port) }, ) - server_users = PermissionsServers.get_server_user_list(self.server_id) + server_users = PermissionsServers.get_server_user_list( + self.server_id + ) for user in server_users: if user != user_id: self.helper.websocket_helper.broadcast_user( user, "send_start_reload", {} ) else: - server_users = PermissionsServers.get_server_user_list(self.server_id) + server_users = PermissionsServers.get_server_user_list( + self.server_id + ) for user in server_users: self.helper.websocket_helper.broadcast_user( user, "send_start_reload", {} @@ -592,7 +612,9 @@ class ServerInstance: if forge_install: self.forge_install_watcher() - self.dummy_servers.stop_dummy_server(self.server_id, port_freed_callback=do_start) + self.dummy_servers.stop_dummy_server( + self.server_id, port_freed_callback=do_start + ) def check_internet_thread(self, user_id, user_lang): if user_id: -- GitLab