diff --git a/.gitlab/windows-build.yml b/.gitlab/windows-build.yml index 2af1acc2..dd8dc796 100644 --- a/.gitlab/windows-build.yml +++ b/.gitlab/windows-build.yml @@ -49,6 +49,7 @@ win-prod-build: paths: - .venv/ rules: + - if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH" - if: $CI_COMMIT_TAG environment: name: production diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ea631bc..5b1d9960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,47 +1,53 @@ # Changelog -## [4.0.3] - 2022/06/18 +## --- [4.0.4] - 2022/06/21 ### New features -- Integrate Wiki iframe into panel instead of link ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/367)) +- Add shutdown on backup feature ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/373)) +- Add detection and dropdown of java versions ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/375)) +- Add file-editor size toggle ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/378)) +### Bug fixes +- Backup/Config.json rework for API key hardening ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/369)) +- Fix stack on ping result being falsy ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/371)) +- Fix sec bug with server creation roles ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/376)) +### Tweaks +- Spelling mistake fixed in German lang file ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/370)) +- Backup failure warning (Tab text goes red) ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/373)) +- - ([Merge Request 2](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/377)) +- Rework server list on dashboard display for use on small screens ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/372)) +- File handling enhancements ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/362)) +

+## --- [4.0.3] - 2022/06/18 +### New features +- Integrate Wiki iframe into panel instead of link ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/367)) ### Bug fixes - Amend Java system variable fix to be more specfic since they only affect Oracle. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/364)) - API Token authentication hardening ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/364)) ### Tweaks - Add better error logging for statistic collection ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/359)) +

-## [4.0.2-hotfix1] - 2022/06/17 - +## --- [4.0.2-hotfix1] - 2022/06/17 ### Crit Bug fixes - Fix blank server_detail page for general users ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/358)) +

-## [4.0.2] - 2022/06/16 - +## --- [4.0.2] - 2022/06/16 ### New features None - ### Bug fixes - Fix winreg import pass on non-NT systems ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/344)) - Make the WebSocket automatically reconnect. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/345)) -- Fix an error when there are no servers ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/346)) -- Use relative paths for the jarfile and logs ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/347)) -- Flatten all instances of username creation or editing, usernames should be lower case. -- - ([Merge Request 1](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/342)) - - ([Merge Request 2](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/351)) - Add version inheretence & config check ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/353)) - Fix support log temp file deletion issue/hang ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/354)) +

-## [4.0.1] - 2022/06/15 - +## --- [4.0.1] - 2022/06/15 ### New features None - ### Bug fixes - - Remove session.lock warning ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/338)) - Correct Dutch Spacing Issue ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/340)) - Remove no-else-* pylint exemptions and tidy code. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/342)) -- Make unRAID more readable, and flatten path to lower, to fit standard practice. ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/337)) -- Fix Java Pathing issues on windows ([Commit](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/343/diffs?commit_id=cda2120579083d447db5dbeb5489822880f4cae7)) - diff --git a/README.md b/README.md index 19e8fca6..ced866fc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Supported Python Versions](https://shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20-blue)](https://www.python.org) -[![Version(temp-hardcoded)](https://img.shields.io/badge/release-v4.0.3--beta-orange)](https://gitlab.com/crafty-controller/crafty-4/-/releases) +[![Version(temp-hardcoded)](https://img.shields.io/badge/release-v4.0.4--beta-orange)](https://gitlab.com/crafty-controller/crafty-4/-/releases) [![Code Quality(temp-hardcoded)](https://img.shields.io/badge/code%20quality-10-brightgreen)](https://gitlab.com/crafty-controller/crafty-4) [![Build Status](https://gitlab.com/crafty-controller/crafty-4/badges/master/pipeline.svg)](https://gitlab.com/crafty-controller/crafty-4/-/commits/master) -# Crafty Controller 4.0.3-beta +# Crafty Controller 4.0.4-beta > Python based Control Panel for your Minecraft Server ## What is Crafty Controller? diff --git a/app/classes/controllers/management_controller.py b/app/classes/controllers/management_controller.py index 099dbf0d..b0b1f10a 100644 --- a/app/classes/controllers/management_controller.py +++ b/app/classes/controllers/management_controller.py @@ -17,6 +17,14 @@ class ManagementController: def get_latest_hosts_stats(): return HelpersManagement.get_latest_hosts_stats() + @staticmethod + def set_crafty_api_key(key): + HelpersManagement.set_secret_api_key(key) + + @staticmethod + def get_crafty_api_key(): + return HelpersManagement.get_secret_api_key() + # ********************************************************************************** # Commands Methods # ********************************************************************************** @@ -128,9 +136,10 @@ class ManagementController: max_backups: int = None, excluded_dirs: list = None, compress: bool = False, + shutdown: bool = False, ): return self.management_helper.set_backup_config( - server_id, backup_path, max_backups, excluded_dirs, compress + server_id, backup_path, max_backups, excluded_dirs, compress, shutdown ) @staticmethod diff --git a/app/classes/controllers/servers_controller.py b/app/classes/controllers/servers_controller.py index fc8ff3e8..ca59fbdc 100644 --- a/app/classes/controllers/servers_controller.py +++ b/app/classes/controllers/servers_controller.py @@ -5,6 +5,7 @@ import json import typing as t from app.classes.controllers.roles_controller import RolesController +from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.singleton import Singleton from app.classes.shared.server import ServerInstance @@ -28,8 +29,9 @@ logger = logging.getLogger(__name__) class ServersController(metaclass=Singleton): servers_list: ServerInstance - def __init__(self, helper, servers_helper, management_helper): + def __init__(self, helper, servers_helper, management_helper, file_helper): self.helper: Helpers = helper + self.file_helper: FileHelpers = file_helper self.servers_helper: HelperServers = servers_helper self.management_helper = management_helper self.servers_list = [] @@ -189,6 +191,7 @@ class ServersController(metaclass=Singleton): self.helper, self.management_helper, self.stats, + self.file_helper, ), "server_settings": settings.props, } diff --git a/app/classes/minecraft/stats.py b/app/classes/minecraft/stats.py index 4b699717..3cc52f88 100644 --- a/app/classes/minecraft/stats.py +++ b/app/classes/minecraft/stats.py @@ -218,7 +218,7 @@ class Stats: return level_total_size - def get_server_players(self, server_id): # pylint: disable=no-self-use + def get_server_players(self, server_id): server = HelperServers.get_server_data_by_id(server_id) @@ -265,15 +265,24 @@ class Stats: logger.info( "Unable to read the server icon due to the following error:", exc_info=e ) - - ping_data = { - "online": online_stats.get("online", 0), - "max": online_stats.get("max", 0), - "players": online_stats.get("players", 0), - "server_description": ping_obj.description, - "server_version": ping_obj.version, - "server_icon": server_icon, - } + if ping_obj: + ping_data = { + "online": online_stats.get("online", 0), + "max": online_stats.get("max", 0), + "players": online_stats.get("players", 0), + "server_description": ping_obj.description, + "server_version": ping_obj.version, + "server_icon": server_icon, + } + else: + ping_data = { + "online": online_stats.get("online", 0), + "max": online_stats.get("max", 0), + "players": online_stats.get("players", 0), + "server_description": "", + "server_version": "", + "server_icon": server_icon, + } return ping_data diff --git a/app/classes/models/management.py b/app/classes/models/management.py index 6cdf7a8a..085bdd37 100644 --- a/app/classes/models/management.py +++ b/app/classes/models/management.py @@ -38,6 +38,16 @@ class AuditLog(BaseModel): table_name = "audit_log" +# ********************************************************************************** +# Crafty Settings Class +# ********************************************************************************** +class CraftySettings(BaseModel): + secret_api_key = CharField(default="") + + class Meta: + table_name = "crafty_settings" + + # ********************************************************************************** # Host_Stats Class # ********************************************************************************** @@ -118,6 +128,7 @@ class Backups(BaseModel): max_backups = IntegerField() server_id = ForeignKeyField(Servers, backref="backups_server") compress = BooleanField(default=False) + shutdown = BooleanField(default=False) class Meta: table_name = "backups" @@ -231,6 +242,17 @@ class HelpersManagement: else: return + @staticmethod + def set_secret_api_key(key): + CraftySettings.insert(secret_api_key=key).execute() + + @staticmethod + def get_secret_api_key(): + settings = CraftySettings.select(CraftySettings.secret_api_key).where( + CraftySettings.id == 1 + ) + return settings[0].secret_api_key + # ********************************************************************************** # Schedules Methods # ********************************************************************************** @@ -330,6 +352,7 @@ class HelpersManagement: "max_backups": row.max_backups, "server_id": row.server_id_id, "compress": row.compress, + "shutdown": row.shutdown, } except IndexError: conf = { @@ -338,6 +361,7 @@ class HelpersManagement: "max_backups": 0, "server_id": server_id, "compress": False, + "shutdown": False, } return conf @@ -348,6 +372,7 @@ class HelpersManagement: max_backups: int = None, excluded_dirs: list = None, compress: bool = False, + shutdown: bool = False, ): logger.debug(f"Updating server {server_id} backup config with {locals()}") if Backups.select().where(Backups.server_id == server_id).exists(): @@ -359,6 +384,7 @@ class HelpersManagement: "max_backups": 0, "server_id": server_id, "compress": False, + "shutdown": False, } new_row = True if max_backups is not None: @@ -367,6 +393,7 @@ class HelpersManagement: dirs_to_exclude = ",".join(excluded_dirs) conf["excluded_dirs"] = dirs_to_exclude conf["compress"] = compress + conf["shutdown"] = shutdown if not new_row: with self.database.atomic(): if backup_path is not None: diff --git a/app/classes/shared/authentication.py b/app/classes/shared/authentication.py index 330a8883..d9b2c053 100644 --- a/app/classes/shared/authentication.py +++ b/app/classes/shared/authentication.py @@ -5,6 +5,7 @@ import jwt from jwt import PyJWTError from app.classes.models.users import HelperUsers, ApiKeys +from app.classes.controllers.management_controller import ManagementController logger = logging.getLogger(__name__) @@ -13,11 +14,14 @@ class Authentication: def __init__(self, helper): self.helper = helper self.secret = "my secret" - self.secret = self.helper.get_setting("apikey_secret", None) - - if self.secret is None or self.secret == "random": + try: + self.secret = ManagementController.get_crafty_api_key() + if self.secret == "": + self.secret = self.helper.random_string_generator(64) + ManagementController.set_crafty_api_key(str(self.secret)) + except: self.secret = self.helper.random_string_generator(64) - self.helper.set_setting("apikey_secret", self.secret) + ManagementController.set_crafty_api_key(str(self.secret)) def generate(self, user_id, extra=None): if extra is None: diff --git a/app/classes/shared/file_helpers.py b/app/classes/shared/file_helpers.py index dad6ddef..620b34f8 100644 --- a/app/classes/shared/file_helpers.py +++ b/app/classes/shared/file_helpers.py @@ -2,14 +2,22 @@ import os import shutil import logging import pathlib +import tempfile +import zipfile from zipfile import ZipFile, ZIP_DEFLATED +from app.classes.shared.helpers import Helpers +from app.classes.shared.console import Console + logger = logging.getLogger(__name__) class FileHelpers: allowed_quotes = ['"', "'", "`"] + def __init__(self, helper): + self.helper: Helpers = helper + @staticmethod def del_dirs(path): path = pathlib.Path(path) @@ -82,7 +90,6 @@ class FileHelpers: f"Error backing up: {os.path.join(root, file)}!" f" - Error was: {e}" ) - return True @staticmethod @@ -113,3 +120,173 @@ class FileHelpers: ) return True + + def make_compressed_backup( + self, path_to_destination, path_to_zip, excluded_dirs, server_id + ): + # create a ZipFile object + path_to_destination += ".zip" + ex_replace = [p.replace("\\", "/") for p in excluded_dirs] + total_bytes = 0 + dir_bytes = Helpers.get_dir_size(path_to_zip) + results = { + "percent": 0, + "total_files": self.helper.human_readable_file_size(dir_bytes), + } + self.helper.websocket_helper.broadcast_page_params( + "/panel/server_detail", + {"id": str(server_id)}, + "backup_status", + results, + ) + with ZipFile(path_to_destination, "w", ZIP_DEFLATED) as zip_file: + for root, dirs, files in os.walk(path_to_zip, topdown=True): + for l_dir in dirs: + if str(os.path.join(root, l_dir)).replace("\\", "/") in ex_replace: + dirs.remove(l_dir) + ziproot = path_to_zip + for file in files: + if ( + str(os.path.join(root, file)).replace("\\", "/") + not in ex_replace + and file != "crafty.sqlite" + ): + try: + logger.info(f"backing up: {os.path.join(root, file)}") + if os.name == "nt": + zip_file.write( + os.path.join(root, file), + os.path.join(root.replace(ziproot, ""), file), + ) + else: + zip_file.write( + os.path.join(root, file), + os.path.join(root.replace(ziproot, "/"), file), + ) + + except Exception as e: + logger.warning( + f"Error backing up: {os.path.join(root, file)}!" + f" - Error was: {e}" + ) + total_bytes += os.path.getsize(os.path.join(root, file)) + percent = round((total_bytes / dir_bytes) * 100, 2) + results = { + "percent": percent, + "total_files": self.helper.human_readable_file_size(dir_bytes), + } + self.helper.websocket_helper.broadcast_page_params( + "/panel/server_detail", + {"id": str(server_id)}, + "backup_status", + results, + ) + + return True + + def make_backup(self, path_to_destination, path_to_zip, excluded_dirs, server_id): + # create a ZipFile object + path_to_destination += ".zip" + ex_replace = [p.replace("\\", "/") for p in excluded_dirs] + total_bytes = 0 + dir_bytes = Helpers.get_dir_size(path_to_zip) + results = { + "percent": 0, + "total_files": self.helper.human_readable_file_size(dir_bytes), + } + self.helper.websocket_helper.broadcast_page_params( + "/panel/server_detail", + {"id": str(server_id)}, + "backup_status", + results, + ) + with ZipFile(path_to_destination, "w") as zip_file: + for root, dirs, files in os.walk(path_to_zip, topdown=True): + for l_dir in dirs: + if str(os.path.join(root, l_dir)).replace("\\", "/") in ex_replace: + dirs.remove(l_dir) + ziproot = path_to_zip + for file in files: + if ( + str(os.path.join(root, file)).replace("\\", "/") + not in ex_replace + and file != "crafty.sqlite" + ): + try: + logger.info(f"backing up: {os.path.join(root, file)}") + if os.name == "nt": + zip_file.write( + os.path.join(root, file), + os.path.join(root.replace(ziproot, ""), file), + ) + else: + zip_file.write( + os.path.join(root, file), + os.path.join(root.replace(ziproot, "/"), file), + ) + + except Exception as e: + logger.warning( + f"Error backing up: {os.path.join(root, file)}!" + f" - Error was: {e}" + ) + total_bytes += os.path.getsize(os.path.join(root, file)) + percent = round((total_bytes / dir_bytes) * 100, 2) + results = { + "percent": percent, + "total_files": self.helper.human_readable_file_size(dir_bytes), + } + self.helper.websocket_helper.broadcast_page_params( + "/panel/server_detail", + {"id": str(server_id)}, + "backup_status", + results, + ) + return True + + @staticmethod + def unzip_file(zip_path): + new_dir_list = zip_path.split("/") + new_dir = "" + for i in range(len(new_dir_list) - 1): + if i == 0: + new_dir += new_dir_list[i] + else: + new_dir += "/" + new_dir_list[i] + + if Helpers.check_file_perms(zip_path) and os.path.isfile(zip_path): + Helpers.ensure_dir_exists(new_dir) + temp_dir = tempfile.mkdtemp() + try: + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(temp_dir) + for i in enumerate(zip_ref.filelist): + if len(zip_ref.filelist) > 1 or not zip_ref.filelist[ + i + ].filename.endswith("/"): + break + + full_root_path = temp_dir + + for item in os.listdir(full_root_path): + if os.path.isdir(os.path.join(full_root_path, item)): + try: + FileHelpers.move_dir( + os.path.join(full_root_path, item), + os.path.join(new_dir, item), + ) + except Exception as ex: + logger.error(f"ERROR IN ZIP IMPORT: {ex}") + else: + try: + FileHelpers.move_file( + os.path.join(full_root_path, item), + os.path.join(new_dir, item), + ) + except Exception as ex: + logger.error(f"ERROR IN ZIP IMPORT: {ex}") + except Exception as ex: + Console.error(ex) + else: + return "false" + return diff --git a/app/classes/shared/helpers.py b/app/classes/shared/helpers.py index 31273a60..4273c38f 100644 --- a/app/classes/shared/helpers.py +++ b/app/classes/shared/helpers.py @@ -15,6 +15,8 @@ import html import zipfile import pathlib import ctypes +import subprocess +import itertools from datetime import datetime from socket import gethostname from contextlib import redirect_stderr, suppress @@ -22,7 +24,6 @@ from contextlib import redirect_stderr, suppress from app.classes.shared.null_writer import NullWriter from app.classes.shared.console import Console from app.classes.shared.installer import installer -from app.classes.shared.file_helpers import FileHelpers from app.classes.shared.translation import Translation from app.classes.web.websocket_helper import WebSocketHelper @@ -81,6 +82,60 @@ class Helpers: print(f"Import Error: Unable to load {ex.name} module") installer.do_install() + @staticmethod + def find_java_installs(): + # If we're windows return oracle java versions, + # otherwise java vers need to be manual. + if os.name == "nt": + # Adapted from LeeKamentsky >>> + # https://github.com/LeeKamentsky/python-javabridge/blob/master/javabridge/locate.py + jdk_key_paths = ( + "SOFTWARE\\JavaSoft\\JDK", + "SOFTWARE\\JavaSoft\\Java Development Kit", + ) + java_paths = [] + for jdk_key_path in jdk_key_paths: + try: + with suppress(OSError), winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, jdk_key_path + ) as kjdk: + for i in itertools.count(): + version = winreg.EnumKey(kjdk, i) + kjdk_current = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + jdk_key_path, + ) + kjdk_current = winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + jdk_key_path + "\\" + version, + ) + kjdk_current_values = dict( # pylint: disable=consider-using-dict-comprehension + [ + winreg.EnumValue(kjdk_current, i)[:2] + for i in range(winreg.QueryInfoKey(kjdk_current)[1]) + ] + ) + java_paths.append(kjdk_current_values["JavaHome"]) + except OSError as e: + if e.errno == 2: + continue + raise + return java_paths + + # If we get here we're linux so we will use 'update-alternatives' + # (If distro does not have update-alternatives then manual input.) + try: + paths = subprocess.check_output( + ["/usr/bin/update-alternatives", "--list", "java"], encoding="utf8" + ) + + if re.match("^(/[^/ ]*)+/?$", paths): + return paths.split("\n") + + except Exception as e: + print("Java Detect Error: ", e) + logger.error(f"Java Detect Error: {e}") + @staticmethod def float_to_string(gbs: float): s = str(float(gbs) * 1000).rstrip("0").rstrip(".") @@ -89,7 +144,8 @@ class Helpers: @staticmethod def check_file_perms(path): try: - open(path, "r", encoding="utf-8").close() + with open(path, "r", encoding="utf-8"): + pass logger.info(f"{path} is readable") return True except PermissionError: @@ -425,7 +481,8 @@ class Helpers: def check_writeable(path: str): filename = os.path.join(path, "tempfile.txt") try: - open(filename, "w", encoding="utf-8").close() + with open(filename, "w", encoding="utf-8"): + pass os.remove(filename) logger.info(f"{filename} is writable") @@ -441,53 +498,6 @@ class Helpers: return ctypes.windll.shell32.IsUserAnAdmin() == 1 return os.geteuid() == 0 - @staticmethod - def unzip_file(zip_path): - new_dir_list = zip_path.split("/") - new_dir = "" - for i in range(len(new_dir_list) - 1): - if i == 0: - new_dir += new_dir_list[i] - else: - new_dir += "/" + new_dir_list[i] - - if Helpers.check_file_perms(zip_path) and os.path.isfile(zip_path): - Helpers.ensure_dir_exists(new_dir) - temp_dir = tempfile.mkdtemp() - try: - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(temp_dir) - for i in enumerate(zip_ref.filelist): - if len(zip_ref.filelist) > 1 or not zip_ref.filelist[ - i - ].filename.endswith("/"): - break - - full_root_path = temp_dir - - for item in os.listdir(full_root_path): - if os.path.isdir(os.path.join(full_root_path, item)): - try: - FileHelpers.move_dir( - os.path.join(full_root_path, item), - os.path.join(new_dir, item), - ) - except Exception as ex: - logger.error(f"ERROR IN ZIP IMPORT: {ex}") - else: - try: - FileHelpers.move_file( - os.path.join(full_root_path, item), - os.path.join(new_dir, item), - ) - except Exception as ex: - logger.error(f"ERROR IN ZIP IMPORT: {ex}") - except Exception as ex: - Console.error(ex) - else: - return "false" - return - 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") @@ -510,7 +520,8 @@ class Helpers: # ensure the log file is there try: - open(log_file, "a", encoding="utf-8").close() + with open(log_file, "a", encoding="utf-8"): + pass except Exception as e: Console.critical(f"Unable to open log file! {e}") sys.exit(1) @@ -640,7 +651,7 @@ class Helpers: session_data = {"pid": pid, "started": now.strftime("%d-%m-%Y, %H:%M:%S")} with open(self.session_file, "w", encoding="utf-8") as f: - json.dump(session_data, f, indent=True) + json.dump(session_data, f, indent=4) # because this is a recursive function, we will return bytes, # and set human readable later @@ -774,13 +785,15 @@ class Helpers: cert.set_version(2) cert.sign(k, "sha256") - f = open(cert_file, "w", encoding="utf-8") - f.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()) - f.close() + with open(cert_file, "w", encoding="utf-8") as cert_file_handle: + cert_file_handle.write( + crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode() + ) - f = open(key_file, "w", encoding="utf-8") - f.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode()) - f.close() + with open(key_file, "w", encoding="utf-8") as key_file_handle: + key_file_handle.write( + crypto.dump_privatekey(crypto.FILETYPE_PEM, k).decode() + ) @staticmethod def random_string_generator(size=6, chars=string.ascii_uppercase + string.digits): @@ -832,7 +845,7 @@ class Helpers: for item in file_list: if os.path.isdir(os.path.join(folder, item)): dir_list.append(item) - else: + elif str(item) != "crafty.sqlite": unsorted_files.append(item) file_list = sorted(dir_list, key=str.casefold) + sorted( unsorted_files, key=str.casefold @@ -863,13 +876,14 @@ class Helpers: @staticmethod def generate_dir(folder, output=""): + dir_list = [] unsorted_files = [] file_list = os.listdir(folder) for item in file_list: if os.path.isdir(os.path.join(folder, item)): dir_list.append(item) - else: + elif str(item) != "crafty.sqlite": unsorted_files.append(item) file_list = sorted(dir_list, key=str.casefold) + sorted( unsorted_files, key=str.casefold @@ -986,14 +1000,6 @@ class Helpers: [parent_path, child_path] ) - @staticmethod - def copy_files(source, dest): - if os.path.isfile(source): - FileHelpers.copy_file(source, dest) - logger.info("Copying jar %s to %s", source, dest) - else: - logger.info("Source jar does not exist.") - @staticmethod def download_file(executable_url, jar_path): try: @@ -1006,7 +1012,8 @@ class Helpers: return False try: - open(jar_path, "wb").write(response.content) + with open(jar_path, "wb") as jar_file: + jar_file.write(response.content) except Exception as e: logger.error("Unable to finish executable download. Error: %s", e) return False diff --git a/app/classes/shared/main_controller.py b/app/classes/shared/main_controller.py index 39db11cd..ffe8ee0c 100644 --- a/app/classes/shared/main_controller.py +++ b/app/classes/shared/main_controller.py @@ -34,8 +34,9 @@ logger = logging.getLogger(__name__) class Controller: - def __init__(self, database, helper): + def __init__(self, database, helper, file_helper): self.helper: Helpers = helper + self.file_helper: FileHelpers = file_helper self.server_jars: ServerJars = ServerJars(helper) self.users_helper: HelperUsers = HelperUsers(database, self.helper) self.roles_helper: HelperRoles = HelperRoles(database) @@ -53,7 +54,7 @@ class Controller: ) self.server_perms: ServerPermsController = ServerPermsController() self.servers: ServersController = ServersController( - self.helper, self.servers_helper, self.management_helper + self.helper, self.servers_helper, self.management_helper, self.file_helper ) self.users: UsersController = UsersController( self.helper, self.users_helper, self.authentication diff --git a/app/classes/shared/main_models.py b/app/classes/shared/main_models.py index 5e809c48..ae4636c2 100644 --- a/app/classes/shared/main_models.py +++ b/app/classes/shared/main_models.py @@ -17,8 +17,6 @@ class DatabaseBuilder: logger.info("Fresh Install Detected - Creating Default Settings") Console.info("Fresh Install Detected - Creating Default Settings") default_data = self.helper.find_default_password() - # Reset this value if the DB has been dumped - self.helper.set_setting("apikey_secret", "random") username = default_data.get("username", "admin") password = default_data.get("password", "crafty") diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py index eb43ff47..07317ff2 100644 --- a/app/classes/shared/server.py +++ b/app/classes/shared/server.py @@ -9,7 +9,6 @@ import threading import logging.config import subprocess import html -import tempfile # TZLocal is set as a hidden import on win pipeline from tzlocal import get_localzone @@ -102,12 +101,14 @@ class ServerOutBuf: class ServerInstance: server_object: Servers helper: Helpers + file_helper: FileHelpers management_helper: HelpersManagement stats: Stats stats_helper: HelperServerStats - def __init__(self, server_id, helper, management_helper, stats): + def __init__(self, server_id, helper, management_helper, stats, file_helper): self.helper = helper + self.file_helper = file_helper self.management_helper = management_helper # holders for our process self.process = None @@ -126,6 +127,7 @@ class ServerInstance: self.stats = stats self.server_object = HelperServers.get_server_obj(self.server_id) self.stats_helper = HelperServerStats(self.server_id) + self.last_backup_failed = False try: tz = get_localzone() except ZoneInfoNotFoundError: @@ -800,10 +802,9 @@ class ServerInstance: self.server_scheduler.remove_job("c_" + str(self.server_id)) def agree_eula(self, user_id): - file = os.path.join(self.server_path, "eula.txt") - f = open(file, "w", encoding="utf-8") - f.write("eula=true") - f.close() + eula_file = os.path.join(self.server_path, "eula.txt") + with open(eula_file, "w", encoding="utf-8") as f: + f.write("eula=true") self.run_threaded_server(user_id) def backup_server(self): @@ -846,6 +847,7 @@ class ServerInstance: "backup_reload", {"percent": 0, "total_files": 0}, ) + was_server_running = None logger.info(f"Starting server {self.name} (ID {self.server_id}) backup") server_users = PermissionsServers.get_server_user_list(self.server_id) for user in server_users: @@ -858,6 +860,15 @@ class ServerInstance: ) time.sleep(3) conf = HelpersManagement.get_backup_config(self.server_id) + if conf["shutdown"]: + logger.info( + "Found shutdown preference. Delaying" + + "backup start. Shutting down server." + ) + if self.check_running(): + self.stop_server() + was_server_running = True + self.helper.ensure_dir_exists(self.settings["backup_path"]) try: backup_filename = ( @@ -869,62 +880,27 @@ class ServerInstance: f" (ID#{self.server_id}, path={self.server_path}) " f"at '{backup_filename}'" ) - - temp_dir = tempfile.mkdtemp() - self.server_scheduler.add_job( - self.backup_status, - "interval", - seconds=1, - id="backup_" + str(self.server_id), - args=[temp_dir + "/", backup_filename + ".zip"], - ) - # pylint: disable=unexpected-keyword-arg - try: - FileHelpers.copy_dir(self.server_path, temp_dir, dirs_exist_ok=True) - except shutil.Error as e: - logger.error(f"Failed to fully complete backup due to shutil error {e}") excluded_dirs = HelpersManagement.get_excluded_backup_dirs(self.server_id) server_dir = Helpers.get_os_understandable_path(self.settings["path"]) - - for my_dir in excluded_dirs: - # Take the full path of the excluded dir and replace the - # server path with the temp path, this is so that we're - # only deleting excluded dirs from the temp path - # and not the server path - excluded_dir = Helpers.get_os_understandable_path(my_dir).replace( - server_dir, Helpers.get_os_understandable_path(temp_dir) - ) - # Next, check to see if it is a directory - if os.path.isdir(excluded_dir): - # If it is a directory, - # recursively delete the entire directory from the backup - try: - FileHelpers.del_dirs(excluded_dir) - except FileNotFoundError: - Console.error( - f"Excluded dir {excluded_dir} not found. Moving on..." - ) - else: - # If not, just remove the file - try: - os.remove(excluded_dir) - except: - Console.error( - f"Excluded dir {excluded_dir} not found. Moving on..." - ) if conf["compress"]: logger.debug( "Found compress backup to be true. Calling compressed archive" ) - FileHelpers.make_compressed_archive( - Helpers.get_os_understandable_path(backup_filename), temp_dir + self.file_helper.make_compressed_backup( + Helpers.get_os_understandable_path(backup_filename), + server_dir, + excluded_dirs, + self.server_id, ) else: logger.debug( "Found compress backup to be false. Calling NON-compressed archive" ) - FileHelpers.make_archive( - Helpers.get_os_understandable_path(backup_filename), temp_dir + self.file_helper.make_backup( + Helpers.get_os_understandable_path(backup_filename), + server_dir, + excluded_dirs, + self.server_id, ) while ( @@ -939,7 +915,6 @@ class ServerInstance: self.is_backingup = False logger.info(f"Backup of server: {self.name} completed") - self.server_scheduler.remove_job("backup_" + str(self.server_id)) results = {"percent": 100, "total_files": 0, "current_file": 0} if len(self.helper.websocket_helper.clients) > 0: self.helper.websocket_helper.broadcast_page_params( @@ -959,12 +934,17 @@ class ServerInstance: HelperUsers.get_user_lang_by_id(user), ).format(self.name), ) + if was_server_running: + logger.info( + "Backup complete. User had shutdown preference. Starting server." + ) + self.start_server(HelperUsers.get_user_id_by_name("system")) time.sleep(3) + self.last_backup_failed = False except: logger.exception( f"Failed to create backup of server {self.name} (ID {self.server_id})" ) - self.server_scheduler.remove_job("backup_" + str(self.server_id)) results = {"percent": 100, "total_files": 0, "current_file": 0} if len(self.helper.websocket_helper.clients) > 0: self.helper.websocket_helper.broadcast_page_params( @@ -974,8 +954,12 @@ class ServerInstance: results, ) self.is_backingup = False - finally: - FileHelpers.del_dirs(temp_dir) + if was_server_running: + logger.info( + "Backup complete. User had shutdown preference. Starting server." + ) + self.start_server(HelperUsers.get_user_id_by_name("system")) + self.last_backup_failed = True def backup_status(self, source_path, dest_path): results = Helpers.calc_percent(source_path, dest_path) @@ -988,6 +972,9 @@ class ServerInstance: results, ) + def last_backup_status(self): + return self.last_backup_failed + def send_backup_status(self): try: return self.backup_stats @@ -1093,7 +1080,7 @@ class ServerInstance: ) # copies to backup dir - Helpers.copy_files(current_executable, backup_executable) + FileHelpers.copy_file(current_executable, backup_executable) # boolean returns true for false for success downloaded = Helpers.download_file( diff --git a/app/classes/web/file_handler.py b/app/classes/web/file_handler.py index 886441ed..e1131c3c 100644 --- a/app/classes/web/file_handler.py +++ b/app/classes/web/file_handler.py @@ -220,7 +220,7 @@ class FileHandler(BaseHandler): path = Helpers.get_os_understandable_path(self.get_argument("path", None)) if Helpers.is_os_windows(): path = Helpers.wtol_path(path) - Helpers.unzip_file(path) + FileHelpers.unzip_file(path) self.redirect(f"/panel/server_detail?id={server_id}&subpage=files") return diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py index 9dfbb17a..6927bca4 100644 --- a/app/classes/web/panel_handler.py +++ b/app/classes/web/panel_handler.py @@ -6,6 +6,7 @@ import typing as t import json import logging import threading +import shlex import bleach import libgravatar import requests @@ -482,6 +483,14 @@ class PanelHandler(BaseHandler): if str(server_id) not in server_ids[:]: user_order.remove(server_id) page_data["servers"] = page_servers + for server in page_data["servers"]: + server_obj = self.controller.servers.get_server_instance_by_id( + server["server_data"]["server_id"] + ) + alert = False + if server_obj.last_backup_status(): + alert = True + server["alert"] = alert # num players is set to zero here. If we poll all servers while # dashboard is loading it takes FOREVER. We leave this to the @@ -497,6 +506,10 @@ class PanelHandler(BaseHandler): if server_id is None: return + server_obj = self.controller.servers.get_server_instance_by_id(server_id) + page_data["backup_failed"] = server_obj.last_backup_status() + server_obj = None + valid_subpages = [ "term", "logs", @@ -627,6 +640,18 @@ class PanelHandler(BaseHandler): "/panel/error?error=Unauthorized access Server Config" ) return + page_data["java_versions"] = Helpers.find_java_installs() + server_obj: Servers = self.controller.servers.get_server_obj(server_id) + page_java = [] + page_data["java_versions"].append("java") + for version in page_data["java_versions"]: + if os.name == "nt": + page_java.append(version) + else: + if len(version) > 0: + page_java.append(version) + + page_data["java_versions"] = page_java if subpage == "files": if ( @@ -1342,6 +1367,8 @@ class PanelHandler(BaseHandler): if Helpers.is_os_windows(): log_path.replace(" ", "^ ") log_path = Helpers.wtol_path(log_path) + if not self.helper.validate_traversal(server_obj.path, log_path): + log_path = "" executable = self.get_argument("executable", None) execution_command = self.get_argument("execution_command", None) server_ip = self.get_argument("server_ip", None) @@ -1355,11 +1382,50 @@ class PanelHandler(BaseHandler): auto_start = int(float(self.get_argument("auto_start", "0"))) crash_detection = int(float(self.get_argument("crash_detection", "0"))) logs_delete_after = int(float(self.get_argument("logs_delete_after", "0"))) + java_selection = self.get_argument("java_selection", None) # subpage = self.get_argument('subpage', None) server_id = self.check_server_id() if server_id is None: return + if java_selection: + try: + execution_list = shlex.split(execution_command) + except ValueError: + self.redirect( + "/panel/error?error=Invalid execution command. Java path" + " must be surrounded by quotes." + " (Are you missing a closing quote?)" + ) + if ( + not any( + java_selection in path for path in Helpers.find_java_installs() + ) + and java_selection != "java" + ): + self.redirect( + "/panel/error?error=Attack attempted." + + " A copy of this report is being sent to server owner." + ) + self.controller.management.add_to_audit_log_raw( + exec_user["username"], + exec_user["user_id"], + server_id, + f"Attempted to send bad java path for {server_id}." + + " Possible attack. Act accordingly.", + self.get_remote_ip(), + ) + return + if java_selection != "java": + if self.helper.is_os_windows(): + execution_list[0] = '"' + java_selection + '/bin/java"' + else: + execution_list[0] = '"' + java_selection + '"' + else: + execution_list[0] = "java" + execution_command = "" + for item in execution_list: + execution_command += item + " " server_obj: Servers = self.controller.servers.get_server_obj(server_id) stale_executable = server_obj.executable @@ -1389,7 +1455,7 @@ class PanelHandler(BaseHandler): server_obj.path = server_obj.path server_obj.log_path = server_obj.log_path server_obj.executable = server_obj.executable - server_obj.execution_command = server_obj.execution_command + server_obj.execution_command = execution_command server_obj.server_ip = server_obj.server_ip server_obj.server_port = server_obj.server_port server_obj.executable_update_url = server_obj.executable_update_url @@ -1433,6 +1499,7 @@ class PanelHandler(BaseHandler): server_obj = self.controller.servers.get_server_obj(server_id) compress = self.get_argument("compress", False) + shutdown = self.get_argument("shutdown", False) check_changed = self.get_argument("changed") if str(check_changed) == str(1): checked = self.get_body_arguments("root_path") @@ -1448,6 +1515,18 @@ class PanelHandler(BaseHandler): max_backups = bleach.clean(self.get_argument("max_backups", None)) server_obj = self.controller.servers.get_server_obj(server_id) + if ( + not backup_path + == self.helper.wtol_path( + os.path.join(self.helper.backup_path, server_obj.server_uuid) + ) + and self.helper.wtol_path(self.controller.project_root) in backup_path + ): + self.redirect( + "/panel/error?error=Nefarious activities detected." + " User attempted to make backup path within Crafty's root." + ) + return server_obj.backup_path = backup_path self.controller.servers.update_server(server_obj) self.controller.management.set_backup_config( @@ -1455,6 +1534,7 @@ class PanelHandler(BaseHandler): max_backups=max_backups, excluded_dirs=checked, compress=bool(compress), + shutdown=bool(shutdown), ) self.controller.management.add_to_audit_log( @@ -1941,7 +2021,10 @@ class PanelHandler(BaseHandler): self.redirect("/panel/error?error=Invalid Key ID") return - if key.user_id != exec_user["user_id"]: + if ( + str(key.user_id) != str(exec_user["user_id"]) + and not exec_user["superuser"] + ): self.redirect( "/panel/error?error=You are not authorized to access this key." ) diff --git a/app/classes/web/server_handler.py b/app/classes/web/server_handler.py index bf4e930d..3505e1de 100644 --- a/app/classes/web/server_handler.py +++ b/app/classes/web/server_handler.py @@ -17,6 +17,15 @@ logger = logging.getLogger(__name__) class ServerHandler(BaseHandler): + def get_user_roles(self): + user_roles = {} + for user_id in self.controller.users.get_all_user_ids(): + user_roles_list = self.controller.users.get_user_roles_names(user_id) + # user_servers = + # self.controller.servers.get_authorized_servers(user.user_id) + user_roles[user_id] = user_roles_list + return user_roles + @tornado.web.authenticated def get(self, page): ( @@ -271,11 +280,19 @@ class ServerHandler(BaseHandler): ) if page == "step1": + if not superuser and not self.controller.crafty_perms.can_create_server( + exec_user["user_id"] + ): + self.redirect( + "/panel/error?error=Unauthorized access: " + "not a server creator or server limit reached" + ) + return if not superuser: user_roles = self.controller.roles.get_all_roles() else: - user_roles = self.controller.roles.get_all_roles() + user_roles = self.get_user_roles() server = bleach.clean(self.get_argument("server", "")) server_name = bleach.clean(self.get_argument("server_name", "")) min_mem = bleach.clean(self.get_argument("min_memory", "")) @@ -396,6 +413,14 @@ class ServerHandler(BaseHandler): self.redirect("/panel/dashboard") if page == "bedrock_step1": + if not superuser and not self.controller.crafty_perms.can_create_server( + exec_user["user_id"] + ): + self.redirect( + "/panel/error?error=Unauthorized access: " + "not a server creator or server limit reached" + ) + return if not superuser: user_roles = self.controller.roles.get_all_roles() else: diff --git a/app/config/version.json b/app/config/version.json index 0f1b738a..4ae669ee 100644 --- a/app/config/version.json +++ b/app/config/version.json @@ -1,6 +1,6 @@ { "major": 4, "minor": 0, - "sub": 3, + "sub": 4, "meta": "beta" } diff --git a/app/frontend/static/assets/css/crafty.css b/app/frontend/static/assets/css/crafty.css index f0fdda00..a4bfe57c 100644 --- a/app/frontend/static/assets/css/crafty.css +++ b/app/frontend/static/assets/css/crafty.css @@ -9,7 +9,7 @@ } -.sidebar > .nav > .nav-item:not(.nav-profile) > .nav-link:before { +.sidebar>.nav>.nav-item:not(.nav-profile)>.nav-link:before { content: none; position: absolute; left: 30px; @@ -21,7 +21,7 @@ display: block; } -.sidebar > .nav > .nav-item:not(.nav-profile) > .nav-link:before { +.sidebar>.nav>.nav-item:not(.nav-profile)>.nav-link:before { content: none; position: absolute; left: 30px; @@ -33,43 +33,48 @@ display: block; } -.sidebar > .nav .nav-item .nav-link, .collapsed{ +.sidebar>.nav .nav-item .nav-link, +.collapsed { padding: 15px 30px; } -.mc-log-time{ - color:#19d895; +.mc-log-time { + color: #19d895; } -.mc-log-info{ - color:#8862e0; +.mc-log-info { + color: #8862e0; } -.mc-log-warn{ - color:#ffaf00; +.mc-log-warn { + color: #ffaf00; } -.mc-log-error{ - color:#af463f; +.mc-log-error { + color: #af463f; } -.mc-log-fatal{ - color:#da0f00; +.mc-log-fatal { + color: #da0f00; } -.mc-log-keyword{ - color:#2196f3; +.mc-log-keyword { + color: #2196f3; } .scrollable-element { - scrollbar-color: red yellow; + scrollbar-color: red yellow; } + .term-nav-item { padding: 1%; } /* Fix body scrollbar color */ -body { background-color: var(--dark) !important; /* Firefox */ } +body { + background-color: var(--dark) !important; + /* Firefox */ +} /* Webkit */ /* Didn't really work out @@ -81,11 +86,20 @@ body { background-color: var(--dark) !important; /* Firefox */ } ::-webkit-scrollbar-track { background-color: #202538; } ::-webkit-scrollbar-corner { background-color: #202538; }*/ -.actions_serverlist > a > i { - cursor: pointer; +.actions_serverlist>a>i { + cursor: pointer; } + +.actions_serveritem { + cursor: pointer; +} + .corner { position: absolute; margin-top: 0; margin-left: 0; +} + +.accordion .card { + margin-bottom: 0px; } \ No newline at end of file diff --git a/app/frontend/templates/panel/dashboard.html b/app/frontend/templates/panel/dashboard.html index 8c215a77..b6eb09a2 100644 --- a/app/frontend/templates/panel/dashboard.html +++ b/app/frontend/templates/panel/dashboard.html @@ -25,21 +25,21 @@ {% if data['first_log'] %} -{% end %} +{% end %} \ No newline at end of file diff --git a/app/frontend/templates/panel/parts/m_server_controls_list.html b/app/frontend/templates/panel/parts/m_server_controls_list.html index a82d02ff..4a114b52 100644 --- a/app/frontend/templates/panel/parts/m_server_controls_list.html +++ b/app/frontend/templates/panel/parts/m_server_controls_list.html @@ -15,8 +15,12 @@ {{ translate('serverDetails', 'schedule', data['lang']) }} {% end %} {% if data['permissions']['Backup'] in data['user_permissions'] %} + {% if data['backup_failed'] %} + {{ translate('serverDetails', 'backup', data['lang']) }}  + {% else %} {{ translate('serverDetails', 'backup', data['lang']) }} {% end %} + {% end %} {% if data['permissions']['Files'] in data['user_permissions'] %} {{ translate('serverDetails', 'files', data['lang']) }} {% end %} diff --git a/app/frontend/templates/panel/parts/server_controls_list.html b/app/frontend/templates/panel/parts/server_controls_list.html index a232f022..8e40e7ec 100644 --- a/app/frontend/templates/panel/parts/server_controls_list.html +++ b/app/frontend/templates/panel/parts/server_controls_list.html @@ -19,11 +19,18 @@ {% end %} {% if data['permissions']['Backup'] in data['user_permissions'] %} + {% if data['backup_failed'] %} + + {% else %} {% end %} + {% end %} {% if data['permissions']['Files'] in data['user_permissions'] %}