From 07276f6dd7746cf8c95ad564ad1583284ff67957 Mon Sep 17 00:00:00 2001 From: Wei Hong Date: Sun, 17 May 2026 21:14:40 +0800 Subject: [PATCH] Some changes. --- client.py | 355 +++++++++++++++++++++++++++++++++++++----- main.py | 406 ++++++++++++++++++++++++++++++++++++++++++------ utils/common.py | 91 ++++++++++- 3 files changed, 756 insertions(+), 96 deletions(-) diff --git a/client.py b/client.py index 70fc213..e0c05ed 100644 --- a/client.py +++ b/client.py @@ -1,12 +1,16 @@ import argparse import asyncio +import base64 +import binascii import queue +import shutil import ssl import sys import threading import socket import time import traceback +from pathlib import Path from prompt_toolkit import Application from prompt_toolkit.layout import Layout, HSplit from prompt_toolkit.widgets import TextArea @@ -16,6 +20,109 @@ from prompt_toolkit.filters import has_focus from prompt_toolkit.shortcuts import clear as ptk_clear version = "Beta-1" +SERVER_JAR_DIR = Path.home() / ".serverjar" +CLIENT_CERT_DIR = Path.home() / ".serverjar" / "client" / "cert" +CLIENT_CERT_SUFFIXES = {".pem", ".crt", ".cer"} +CLIENT_HISTORY_FILE = Path.home() / ".serverjar" / "history" / "history.txt" +CLIENT_LOG_DIR = Path.home() / ".serverjar" / "client" / "logs" + +def add_client_cert(cert_path): + source = Path(cert_path).expanduser() + + if not source.exists(): + raise FileNotFoundError(f"{source} does not exist") + if not source.is_file(): + raise IsADirectoryError(f"{source} is not a file") + if source.suffix.lower() not in CLIENT_CERT_SUFFIXES: + allowed = ", ".join(sorted(CLIENT_CERT_SUFFIXES)) + raise ValueError(f"Unsupported certificate suffix '{source.suffix}'. Allowed: {allowed}") + + CLIENT_CERT_DIR.mkdir(parents=True, exist_ok=True) + target = CLIENT_CERT_DIR / source.name + + if source.resolve() == target.resolve(): + return target + + shutil.copy2(source, target) + return target + + +def run_cli_action(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv or argv[0] != "--add-cert": + return False + + parser = argparse.ArgumentParser(prog=f"{Path(sys.argv[0]).name} --add-cert") + parser.add_argument("cert_path", help="Path to a PEM/CRT/CER certificate file") + args = parser.parse_args(argv[1:]) + + try: + target = add_client_cert(args.cert_path) + except Exception as e: + print(f"Unable to add certificate: {e}", file=sys.stderr) + sys.exit(1) + + print(f"Certificate added: {target}") + return True + + +def create_client_tls_context(log=None, warn=None): + CLIENT_CERT_DIR.mkdir(parents=True, exist_ok=True) + context = ssl.create_default_context() + loaded_certs = [] + + for cert_path in sorted(CLIENT_CERT_DIR.iterdir()): + if not cert_path.is_file() or cert_path.suffix.lower() not in CLIENT_CERT_SUFFIXES: + continue + + try: + context.load_verify_locations(cafile=cert_path) + except ssl.SSLError as e: + if callable(warn): + warn(f"Unable to load TLS certificate {cert_path}: {e}") + continue + + loaded_certs.append(cert_path.name) + + if callable(log): + if loaded_certs: + log("Loaded TLS certificate(s): {}".format(", ".join(loaded_certs))) + else: + log(f"No custom TLS certificates found in {CLIENT_CERT_DIR}") + + return context + + +def get_history(log=None, warn=None): + if not CLIENT_HISTORY_FILE.exists(): + return [] + + CLIENT_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + + if callable(log): + log("Restoring command history...") + + try: + with CLIENT_HISTORY_FILE.open("r", encoding="utf-8") as f: + return [line for line in f.read().splitlines() if line] + except Exception as e: + if callable(warn): + warn(f"Unable to restore command history: {e}") + + return [] + +def save_history(history, log=None, warn=None): + CLIENT_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + + if callable(log): + log("Saving command history...") + + try: + with CLIENT_HISTORY_FILE.open("w", encoding="utf-8") as f: + f.write("\n".join(history)) + except Exception as e: + if callable(warn): + warn(f"Unable to save command history: {e}") class ServerJarClient(Application): @@ -68,6 +175,7 @@ class ServerJarClient(Application): self.client_thread = threading.Thread(target=self.client, daemon=True) self.connect_event = threading.Event() self.disconnect_event = threading.Event() + self.auth_required = False # Queue self.incoming = queue.Queue() @@ -75,7 +183,7 @@ class ServerJarClient(Application): # Command History self.cmds = [] self.current_index = None - self.start_history_flag = False + self.history_draft = "" @self.kb.add("c-c") def closing_kb(event): @@ -84,23 +192,15 @@ class ServerJarClient(Application): @self.kb.add("up", filter=has_focus(self.input_area)) def get_old_cmd(event): # check if there's no old command available in the command history list - if len(self.cmds) < 2: + if not self.cmds: return - # Save entered command to history list - if not self.start_history_flag: - self.insert_new_cmd_to_history(self.get_input_area_output()) - self.start_history_flag = True - - # Set current_index to 1 if it's not initiated yet + # Save the current input so Down can restore it after browsing history. if self.current_index is None: + self.history_draft = self.get_input_area_output() self.current_index = 0 - - # Avoid IndexError when it's the last one command - if self.current_index >= len(self.cmds)-1: - return - - self.current_index += 1 + elif self.current_index < len(self.cmds) - 1: + self.current_index += 1 old_cmd = self.cmds[self.current_index] @@ -116,8 +216,9 @@ class ServerJarClient(Application): if self.current_index is None: return - # Avoid IndexError when it's the last one command - if self.current_index-1 < 0: + if self.current_index == 0: + self.current_index = None + self.set_input_area_text(self.history_draft) return self.current_index -= 1 @@ -128,19 +229,36 @@ class ServerJarClient(Application): @self.kb.add("enter", filter=has_focus(self.input_area)) def enter_kb(event): - # Disable history flag - self.start_history_flag = False - cmd = self.input_area.text self.input_area.text = "" - - # Save command - self.insert_new_cmd_to_history(cmd) + self.current_index = None + self.history_draft = "" if cmd == "_exit": self.shutdown("_exit command detected") return + if self.auth_required: + if cmd == "_d": + self.command_parser(cmd) + return + + with self.sock_lock: + s = self.sock + + if s: + try: + s.sendall(("__auth " + cmd + "\n").encode("utf-8")) + self.display_message("Password sent, waiting for server...") + except OSError as e: + self._err(f"Send failed: {e}\n") + else: + self._err("The remote server is not connected yet.") + return + + # Save command + self.insert_new_cmd_to_history(cmd) + # if re.match(r"^_[A-Za-z0-9]+(?:$|_.*)", cmd): exit_flag = self.command_parser(cmd) @@ -204,6 +322,10 @@ class ServerJarClient(Application): s.close() except Exception as e: self._err("An error occurred while closing the socket: {}".format(e)) + elif self.connect_event.is_set(): + self.host = None + self.port = None + self._log("Auto-reconnect stopped.") else: self._err("The remote server is not connected yet.") @@ -244,18 +366,70 @@ class ServerJarClient(Application): self._log("ServerJar Client Version {}".format(version)) return True + def _help(cmd): + for key, value in cmd_map.items(): + self._log(f"{key}: {value.get("description")}") + return True + + def _clear(cmd): + self.log_area.text = "" + self._log("Log cleared") + return True + + def _clear_history(cmd): + self.cmds = [] + self.current_index = None + self.history_draft = "" + self._log("History cleared") + return True + cmd_map = { - "_exit": _shutdown, - "_c": connect_to_server_parser, - "_d": disconnect_from_server, - "_top": _top, - "_bottom": _bottom, - "_version": _version, + "_exit": { + "func": _shutdown, + "description": "Exit the shell", + }, + "_c": { + "func": connect_to_server_parser, + "description": "Connect to the remote server (Usage: _c host:port)", + }, + "_d": { + "func": disconnect_from_server, + "description": "Disconnect from the remote server", + }, + "_top": { + "func": _top, + "description": "Go to the top of the log area", + }, + "_bottom": { + "func": _bottom, + "description": "Go to the bottom of the log area", + }, + "_version": { + "func": _version, + "description": "Display the version of the client", + }, + "_clear_history": { + "func": _clear_history, + "description": "Clear the command history", + }, + "_clear": { + "func": _clear, + "description": "Clear the log area", + }, + "_help": { + "func": _help, + "description": "Display the help message", + }, } + if not command.strip(): + return True + + header = command.split()[0] for cmd in cmd_map.keys(): - if command.startswith(cmd): - return_flag = cmd_map[cmd](command) + if cmd == header: + func = cmd_map.get(cmd).get("func") + return_flag = func(command) return return_flag # self._err("Unknown command '%s'" % command) @@ -270,8 +444,13 @@ class ServerJarClient(Application): def set_input_area_text(self, text): self.input_area.text = text + self.input_area.buffer.cursor_position = len(text) def insert_new_cmd_to_history(self, cmd): + if not cmd: + return + if self.cmds and self.cmds[0] == cmd: + return self.cmds.insert(0, cmd) class ServerInfoInvalidException(Exception): @@ -285,14 +464,21 @@ class ServerJarClient(Application): def arguments_parser(self): parser = argparse.ArgumentParser() - parser.add_argument("-p", "--port", type=int, help="Port number", required=True) - parser.add_argument('-host', '--host', type=str, help="Hostname", required=True) - parser.add_argument('-no-tls', '--no-tls', type="store_true", help="Enable TLS support") + parser.add_argument("-p", "--port", type=int, help="Port number", required=False) + parser.add_argument('-host', '--host', type=str, help="Hostname", required=False) + parser.add_argument('-no-tls', '--no-tls', help="Enable TLS support", action='store_true', default=False, + required=False) + parser.add_argument('-r', '--retry', help="Retry when disconnect", action='store_true', default=False, + required=False) + parser.add_argument('--add-cert', help="Add server certificate", type=str, default=None, required=False) args = parser.parse_args() return args + def get_tls_context(self): + return create_client_tls_context(log=self._log, warn=self._warn) + def shutdown(self, reason=""): if self.closing_event.is_set(): return @@ -310,6 +496,9 @@ class ServerJarClient(Application): self._err(f"Unable to close socket: {e}") pass + # Save history + save_history(self.cmds, self._log, self._warn) + # Exit ui event loop self.full_exit() @@ -366,12 +555,18 @@ class ServerJarClient(Application): while not self.closing_event.is_set(): - self._log("Type _c host:port to connect. (_d to disconnect)") - self.connect_event.wait() - if self.closing_event.is_set(): break + if not self.connect_event.is_set() and (self.args.port is None or self.args.host is None): + self._log("Type _c host:port to connect. (_d to disconnect)") + self.connect_event.wait() + elif self.connect_event.is_set(): + self._log(f"Reconnecting...") + else: + self._log(f"Connecting to remote server from {self.args.host}:{self.args.port} (Value from sys.argv)...") + self.host, self.port = self.args.host, self.args.port + if not self.host or not self.port: self._err("No host/port set. Usage: _c host:port") self.connect_event.clear() @@ -386,17 +581,19 @@ class ServerJarClient(Application): s.connect((self.host, self.port)) else: raw = socket.create_connection((self.host, self.port)) - context = ssl.create_default_context() + context = self.get_tls_context() s = context.wrap_socket(raw, server_hostname=self.host) - s.connect((self.host, self.port)) - with self.sock_lock: self.sock = s + self.auth_required = False self._log("Remote socket server connected [HOST: {}, PORT: {}]".format(self.host, self.port)) buffer = "" + downloading_log = False + download_path = None + download_file = None while True: # Receive remote server broadcast message and display it on log area data = s.recv(4096) @@ -405,12 +602,79 @@ class ServerJarClient(Application): buffer += data.decode("utf-8", errors="replace") while "\n" in buffer: line, buffer = buffer.split("\n", 1) + if line.startswith("[AUTH_REQUIRED]"): + self.auth_required = True + self.display_message("Password required. Type the server password to continue.") + self._warn("Server requires a password before continuing.") + if self.args.no_tls: + self._warn("TLS is disabled. The password will be sent without encryption.") + continue + + if line.startswith("[AUTH_OK]"): + self.auth_required = False + self.display_message("Authenticated.") + self._log("Server password accepted.") + continue + + if line.startswith("[AUTH_ERR]"): + self.auth_required = True + self.display_message("Invalid password. Try again, or use _d to disconnect.") + self._err(line) + continue + + if line.startswith("[DOWNLOAD_LOG_BEGIN]"): + if download_file: + download_file.close() + + file_name = line[len("[DOWNLOAD_LOG_BEGIN]"):].strip() + file_name = Path(file_name).name or "serverjar.log" + CLIENT_LOG_DIR.mkdir(parents=True, exist_ok=True) + download_path = CLIENT_LOG_DIR / file_name + download_file = download_path.open("wb") + downloading_log = True + self._log(f"Downloading log to {download_path}") + continue + + if line == "[DOWNLOAD_LOG_END]": + if download_file: + download_file.close() + download_file = None + downloading_log = False + self._log(f"Log downloaded: {download_path}") + download_path = None + continue + + if downloading_log: + if line.startswith("["): + if line.startswith("[SYS:ERR]"): + if download_file: + download_file.close() + download_file = None + downloading_log = False + download_path = None + self.log(line) + continue + + try: + if download_file: + download_file.write(base64.b64decode(line.encode("ascii"))) + except (binascii.Error, OSError) as e: + self._err(f"Unable to write downloaded log: {e}") + if download_file: + download_file.close() + download_file = None + downloading_log = False + continue + # ### Use normal log method ### self.log(line) except (ConnectionError, OSError) as e: if not self.closing_event.is_set(): - self._warn(f"Disconnected: {e}, retrying...") + if self.args.retry: + self._warn(f"Disconnected: {e}, retrying...") + else: + self._err(f"Disconnected: {e}") time.sleep(1) except KeyboardInterrupt: self._log("Exiting...") @@ -419,6 +683,9 @@ class ServerJarClient(Application): self._err(f"Unhandled exception: {e}") self._err(f"{traceback.format_exc()}") finally: + if "download_file" in locals() and download_file: + download_file.close() + with self.sock_lock: try: if self.sock: @@ -428,20 +695,26 @@ class ServerJarClient(Application): self._err(f"Unable to close socket: {e}") pass self.sock = None + self.auth_required = False # reset flags self.disconnect_event.clear() - self.connect_event.clear() + if not self.args.retry: + self.connect_event.clear() def startup(self): self.args = self.arguments_parser() self.layout.focus(self.input_area) + self.cmds = get_history(self._log, self._warn) asyncio.create_task(self.consume_incoming()) self.client_thread.start() if __name__ == "__main__": + if run_cli_action(): + sys.exit(0) + app = ServerJarClient() app.run(pre_run=app.startup) diff --git a/main.py b/main.py index 00307ec..dee1f42 100644 --- a/main.py +++ b/main.py @@ -16,11 +16,13 @@ import subprocess import threading import time import datetime +import base64 +import hmac from pathlib import Path import click import yaml from utils.common import download_latest_paper_jar, get_latest_version_minecraft, get_specific_version_paper_builds, \ - download_server_jar, download_latest_build_paper_jar, get_latest_paper_version + download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar from utils.file_settings import FileSettings from utils.file_settings import required_list, required_value from cryptography import x509 @@ -32,6 +34,9 @@ from cryptography.hazmat.primitives import serialization ROOT_DIR = Path(os.getcwd()) SERVER_CONFIG_PATH = ROOT_DIR / "config" / "server.yml" VERSION = "1.0" +LOG_DIR_NAME = "logs" +SERVERJAR_LOG_FILE = "serverjar.log" +LOG_DOWNLOAD_CHUNK_SIZE = 4096 def exit(message): click.echo(click.style(message, fg='green')) @@ -50,11 +55,13 @@ def load_settings(): { "servers": [], "socketServerHostname": "127.0.0.1", - "socketServerPort": 25560 + "socketServerPort": 25560, + "socketServerPassword": "" }, { "socketServerHostname": required_value("127.0.0.1"), "socketServerPort": required_value(25560), + "socketServerPassword": required_value(""), "enableTLSSupport": required_value(True), "socketServerCertfile": required_value("data/server-public.pem"), "socketServerKeyfile": required_value("data/server-private.pem"), @@ -92,9 +99,14 @@ def load_settings(): required=False) @click.option("--build", "-b", default=None, help="Specify paper build to download (Use latest Minecraft version if not specified)") +@click.option("--server-type", "-t", + type=click.Choice(["paper", "vanilla"], case_sensitive=False), + default="paper", + show_default=True, + help="Server jar type to download") @click.option("--snapshot", is_flag=True, help="Download snapshot version Minecraft (Use it if the current mc-version type is snapshot)") -@click.option("--latest", is_flag=True, help="Download latest Minecraft version (With latest build paper)") +@click.option("--latest", is_flag=True, help="Download latest Minecraft version") @click.option("--list-builds", is_flag=True, help="List available paper build versions") @click.option("--filename", default=None, help="Custom SERVER.jar file name") @click.option("--extra-args", "-e", @@ -116,26 +128,43 @@ def load_settings(): help="Hostname of the server", required=True) @click.option("--server-port", "-srp", help="Port of the server", required=True) -def create_server(name, mc_version, build, snapshot, latest, list_builds, filename, extra_args, java_exec_path, +def create_server(name, mc_version, build, server_type, snapshot, latest, list_builds, filename, extra_args, java_exec_path, x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host): server_dir = Path("servers", name) + server_type = server_type.lower() - if server_dir.exists(): - result = str(input("Found existing server dir. Do you want to overwrite it and continue? [y/N] ")) + def prepare_server_dir(): + if server_dir.exists(): + result = str(input("Found existing server dir. Do you want to overwrite it and continue? [y/N] ")) - if not result.lower() == "y": - exit("User aborted.") + if not result.lower() == "y": + exit("User aborted.") - server_dir.mkdir(parents=True, exist_ok=True) + server_dir.mkdir(parents=True, exist_ok=True) latest_ver = None try: release = True if not snapshot else False - if latest: + if server_type == "vanilla": + if build: + raise click.ClickException("--build is only available when --server-type paper is used.") + if list_builds: + raise click.ClickException("--list-builds is only available when --server-type paper is used.") + + if latest or mc_version is None: + click.echo("Fetching latest Minecraft version...") + mc_version = get_latest_version_minecraft(release=release) + + prepare_server_dir() + click.echo(f"Downloading Vanilla Minecraft server {mc_version} ...") + out = download_vanilla_server_jar(mc_version, server_dir, filename=filename) + click.echo(f"Done: {out}") + elif latest: click.echo("Fetching latest Mojang release version...") latest_ver = get_latest_paper_version(release=release) builds = get_specific_version_paper_builds(latest_ver) + prepare_server_dir() out = download_server_jar(latest_ver, builds[-1], server_dir) else: if mc_version is None: @@ -154,10 +183,12 @@ def create_server(name, mc_version, build, snapshot, latest, list_builds, filena if build: click.echo(f"Downloading Paper {mc_version} build {build} ...") + prepare_server_dir() out = download_server_jar(mc_version, str(build), server_dir, filename=filename) click.echo(f"Done: {out}") else: click.echo(f"Downloading latest Paper build for {mc_version} ...") + prepare_server_dir() out = download_latest_build_paper_jar(mc_version, server_dir, filename=filename) click.echo(f"Done: {out}") @@ -184,8 +215,8 @@ def create_server(name, mc_version, build, snapshot, latest, list_builds, filena extra_args += "nogui" if nogui else "" args = [ java_exec_path, - "--Xms{}".format(x_memory_initial), - "--Xmx{}".format(x_memory_maximum), + "-Xms{}".format(x_memory_initial), + "-Xmx{}".format(x_memory_maximum), "-jar", out.absolute().as_posix(), extra_args, @@ -195,7 +226,7 @@ def create_server(name, mc_version, build, snapshot, latest, list_builds, filena print("Will use custom commands as replacement.") args = custom_args - print(f"Server command: {" ".join(args)}") + print(f"Server command: {' '.join(args)}") with settings.edit() as s: print("Saving...") @@ -213,10 +244,13 @@ def create_server(name, mc_version, build, snapshot, latest, list_builds, filena print("Done") class SocketServer: - def __init__(self, host, port, enable_tls, certfile: Path, keyfile: Path): + def __init__(self, host, port, enable_tls, certfile: Path, keyfile: Path, password: str = ""): self.logger = logging.getLogger("SocketServer") + self.logger.setLevel(logging.INFO) self.stdout_handler = logging.StreamHandler(sys.stdout) - self.stdout_handler.setFormatter(logging.Formatter("%(level)s:%(message)s")) + self.stdout_handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s")) + if not self.logger.handlers: + self.logger.addHandler(self.stdout_handler) # flags self.stop_event = threading.Event() @@ -235,15 +269,24 @@ class SocketServer: self.enable_tls = enable_tls self.certfile = certfile self.keyfile = keyfile + self.password = "" if password is None else str(password) + self._ssl_context = None - if not self.certfile.exists(): - raise FileNotFoundError("Certfile not found") + if self.password and not self.enable_tls: + self.logger.warning( + "[SECURITY] socketServerPassword is enabled while TLS is disabled. " + "Client passwords will be sent in plaintext." + ) - if not self.keyfile.exists(): - raise FileNotFoundError("Keyfile not found") + if self.enable_tls: + if not self.certfile.exists(): + raise FileNotFoundError("Certfile not found") - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) + if not self.keyfile.exists(): + raise FileNotFoundError("Keyfile not found") + + self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) # ------------------------- # Socket Server @@ -271,18 +314,129 @@ class SocketServer: with self._sub_lock: self._log_subscribers.discard(q) + def _format_command_help(self, command_map): + return "\n".join( + f"{command}: {description}" + for command, description in command_map.items() + ) + + def get_socket_help_message(self, current_server=None): + socket_commands = { + "__help": "Display this help message", + "__list": "List available server shells", + "__exit": "Close the current socket connection", + "__stop_all": "Stop all servers and close the socket server", + "__c ": "Connect to a server shell", + "__d": "Disconnect from the current server shell", + "__sync_log [fromDate:endDate]": "Sync saved log lines from the attached server. Empty syncs the latest 300 lines", + "__download_log": "Download the attached server's saved log file", + } + attached_commands = { + "__status": "Show target server process status", + "__stop": "Stop the target server process", + "__start": "Start the target server process", + "": "Send command to the target Minecraft server process", + } + + message = "[SYS] ServerJar (Server-side):\n" + self._format_command_help(socket_commands) + if current_server is not None: + message += ( + f"\n[SYS] Attached server commands for \"{current_server}\":\n" + + self._format_command_help(attached_commands) + ) + else: + message += "\n[SYS] Use __c before attached server commands." + + return message + + def get_server_list_message(self): + if not self.command_receivers: + return "[SYS] No server shells are available." + + server_names = sorted(self.command_receivers.keys()) + return "[SYS] Available server shells:\n" + "\n".join( + f"- {server_name}" for server_name in server_names + ) + def handler_command(self, command: str): self.logger.info("On...no co", command) + @staticmethod + def _send_text(sock, message: str): + sock.sendall((message + "\n").encode("utf-8")) + + def requires_password(self): + return bool(self.password) + + def check_password(self, candidate: str): + return hmac.compare_digest(self.password, candidate) + + def _get_current_server_log_reader(self, current_server): + if current_server is None: + return None, "[SYS:ERR] You are not connected to any target server." + + receiver = self.get_command_receiver(current_server) + if receiver is None: + return None, f"[SYS:ERR] Target server \"{current_server}\" does not exist." + + log_reader = receiver.get("logReader") + if not callable(log_reader): + return None, "[SYS:ERR] Target server's logReader is not callable." + + return log_reader, None + + def send_sync_log(self, sock, current_server, command: str): + log_reader, error = self._get_current_server_log_reader(current_server) + if error: + self._send_text(sock, error) + return + + date_range = command[len("__sync_log"):].strip() + try: + lines = log_reader("sync", date_range) + except ValueError as e: + self._send_text(sock, f"[SYS:ERR] {e}") + return + except OSError as e: + self._send_text(sock, f"[SYS:ERR] Unable to read log: {e}") + return + + if not lines: + self._send_text(sock, "[SYS] No saved log lines matched.") + return + + self._send_text(sock, f"[SYS] Syncing {len(lines)} saved log line(s).") + for line in lines: + self._send_text(sock, f"[LOG] {line}") + + def send_download_log(self, sock, current_server): + log_reader, error = self._get_current_server_log_reader(current_server) + if error: + self._send_text(sock, error) + return + + try: + log_path = log_reader("path") + if not log_path.exists(): + self._send_text(sock, "[SYS:ERR] No saved log file exists yet.") + return + + safe_server_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", current_server).strip("._") or "server" + file_name = f"{safe_server_name}-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}.log" + self._send_text(sock, f"[DOWNLOAD_LOG_BEGIN] {file_name}") + with log_path.open("rb") as f: + while True: + chunk = f.read(LOG_DOWNLOAD_CHUNK_SIZE) + if not chunk: + break + self._send_text(sock, base64.b64encode(chunk).decode("ascii")) + self._send_text(sock, "[DOWNLOAD_LOG_END]") + except OSError as e: + self._send_text(sock, f"[SYS:ERR] Unable to download log: {e}") + def _build_tcp_server(self): manager = self - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - try: - context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) - except ssl.SSLError as e: - self.logger.fatal("SSL error", exc_info=e) - class TCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True daemon_threads = True @@ -291,13 +445,19 @@ class SocketServer: super().__init__(server_address, RequestHandlerClass) self.manager = manager - if self.manager.enable_tls: - def get_request(): - sock, addr = super().get_request() - tls_sock = context.wrap_socket(sock, server_side=True) - return tls_sock, addr + def get_request(self): + sock, addr = super().get_request() - self.get_request = get_request + if not self.manager.enable_tls: + return sock, addr + + try: + tls_sock = self.manager._ssl_context.wrap_socket(sock, server_side=True) + except ssl.SSLError: + sock.close() + raise + + return tls_sock, addr class Handler(socketserver.BaseRequestHandler): current_server_record = { @@ -310,7 +470,8 @@ class SocketServer: def handle(self): mgr: Server = self.server.manager - log_q = mgr.subscribe_logs() + authenticated = not mgr.requires_password() + log_q = None stop_evt = threading.Event() def push_logs(): @@ -324,11 +485,17 @@ class SocketServer: except OSError: break - t = threading.Thread(target=push_logs, daemon=True) - t.start() + t = None try: - self.request.sendall(b"[SYS] connected\n") + if authenticated: + self.request.sendall(b"[SYS] connected\n") + log_q = mgr.subscribe_logs() + t = threading.Thread(target=push_logs, daemon=True) + t.start() + else: + self.request.sendall(b"[AUTH_REQUIRED] Password required before continuing.\n") + buf = b"" while True: @@ -344,6 +511,32 @@ class SocketServer: if not cmd: continue + if not authenticated: + if cmd == "__exit": + self.request.sendall(b"[SYS] bye\n") + return + + if cmd.startswith("__auth "): + password = cmd[len("__auth "):] + if mgr.check_password(password): + authenticated = True + self.request.sendall(b"[AUTH_OK] authenticated\n") + self.request.sendall(b"[SYS] connected\n") + log_q = mgr.subscribe_logs() + t = threading.Thread(target=push_logs, daemon=True) + t.start() + else: + mgr.logger.warning( + "[SECURITY] Authentication failed from %s:%s", + self.client_address[0], + self.client_address[1], + ) + self.request.sendall(b"[AUTH_ERR] Invalid password.\n") + continue + + self.request.sendall(b"[AUTH_REQUIRED] Password required before continuing.\n") + continue + current_server = self.current_server_record.get( f"{self.client_address[0]}:{self.client_address[1]}", None) @@ -354,16 +547,28 @@ class SocketServer: message = None if cmd.startswith("__"): - if cmd == "__exit": + if cmd == "__help": + self.request.sendall( + (mgr.get_socket_help_message(current_server) + "\n").encode("utf-8") + ) + elif cmd == "__list": + self.request.sendall( + (mgr.get_server_list_message() + "\n").encode("utf-8") + ) + elif cmd == "__exit": # Exit socket self.request.sendall(b"[SYS] bye\n") return - if cmd == "__stop_all": + elif cmd == "__stop_all": self.request.sendall( f"[SYS] Stopping all servers...bye\n".encode("utf-8") ) mgr.stop_event.set() return + elif cmd.startswith("__sync_log"): + mgr.send_sync_log(self.request, current_server, cmd) + elif cmd == "__download_log": + mgr.send_download_log(self.request, current_server) elif cmd.startswith("__c"): match = re.match(r"^__c\s+(.+)$", cmd) if match: @@ -430,12 +635,13 @@ class SocketServer: if message is not None: msg = msg + message + "\n" self.request.sendall(msg.encode("utf-8")) - except ConnectionResetError: + except (ConnectionResetError, OSError): mgr.logger.info( "[SYS] Client disconnected. From {}:{}".format(self.client_address[0], self.client_address[1])) finally: stop_evt.set() - mgr.unsubscribe_logs(log_q) + if log_q is not None: + mgr.unsubscribe_logs(log_q) return TCPServer((self.host, self.port), Handler) @@ -464,13 +670,14 @@ class SocketServer: self._tcp_thread.join(timeout=2) self._tcp_thread = None - def register_command_receiver(self, server_name, receiver, process_receiver): + def register_command_receiver(self, server_name, receiver, process_receiver, log_reader=None): if server_name in self.command_receivers.keys(): self.logger.warning(f"[SYS] Command receiver name \"{server_name}\" already registered") else: self.command_receivers[server_name] = { "receiver": receiver, "processReceiver": process_receiver, + "logReader": log_reader, } def get_command_receiver(self, server_name): @@ -514,11 +721,93 @@ class Server: self.port = port self.host = host self.enable = enable + self.log_dir = Path(self.work_dir) / LOG_DIR_NAME + self.log_path = self.log_dir / SERVERJAR_LOG_FILE self.log_queue = queue.Queue() # stdout lines self._threads: list[threading.Thread] = [] self.broadcaster = None + def _append_saved_log(self, line: str): + self.log_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.datetime.now().isoformat(timespec="seconds") + with self.log_path.open("a", encoding="utf-8") as f: + f.write(f"{timestamp}\t{line}\n") + + @staticmethod + def _parse_log_date(value: str, *, is_end=False): + value = value.strip() + if not value: + return None + + date_only = re.fullmatch(r"\d{4}-\d{2}-\d{2}", value) is not None + try: + parsed = datetime.datetime.fromisoformat(value) + except ValueError as e: + raise ValueError(f"Invalid date '{value}'. Use ISO date format, for example 2026-05-17.") from e + + if date_only and is_end: + return datetime.datetime.combine(parsed.date(), datetime.time.max) + return parsed + + @staticmethod + def _split_saved_log_line(raw_line: str): + raw_line = raw_line.rstrip("\n") + timestamp, sep, message = raw_line.partition("\t") + if not sep: + return None, raw_line + + try: + parsed = datetime.datetime.fromisoformat(timestamp) + except ValueError: + return None, raw_line + + return parsed, f"{timestamp} {message}" + + def read_saved_logs(self, date_range: str = ""): + if not self.log_path.exists(): + return [] + + start = None + end = None + date_range = date_range.strip() + limit = 300 if not date_range else None + + if date_range: + if ":" not in date_range: + raise ValueError("Usage: __sync_log fromDate:endDate") + start_raw, end_raw = date_range.split(":", 1) + start = self._parse_log_date(start_raw) + end = self._parse_log_date(end_raw, is_end=True) + if start and end and start > end: + raise ValueError("fromDate must be earlier than endDate.") + + matched = [] + with self.log_path.open("r", encoding="utf-8") as f: + for raw_line in f: + timestamp, display_line = self._split_saved_log_line(raw_line) + if start or end: + if timestamp is None: + continue + if start and timestamp < start: + continue + if end and timestamp > end: + continue + matched.append(display_line) + + if limit is not None: + return matched[-limit:] + + return matched + + def log_reader(self, action, date_range=""): + if action == "sync": + return self.read_saved_logs(date_range) + if action == "path": + return self.log_path + + raise ValueError(f"Unknown log action: {action}") + def start_process(self): self.logger.info("Starting process...") @@ -574,6 +863,10 @@ class Server: line = line.rstrip("\n") self.logger.info("[PROC] %s", line) + try: + self._append_saved_log(line) + except OSError as e: + self.logger.warning("[PROC] unable to save log: %s", e) self.publish_log(line) def send_command(self, command: str) -> bool: @@ -631,12 +924,24 @@ class Server: self.stop_process() + def restart(self): + if self.running: + self.stop() + + self.start() + def is_process_alive(self) -> bool: with self.proc_lock: return self.proc is not None and self.proc.poll() is None def command_receiver(self, command): - if command == "__status": + if command == "__help": + return True, ("__status: Show target server process status\n" + "__stop: Stop the target server process\n" + "__start: Start the target server process\n" + "__restart: Restart the target server process\n" + ": Send command to the target Minecraft server process") + elif command == "__status": with self.proc_lock: pid = self.proc.pid if self.proc else None return True, (f"processAlive: {self.is_process_alive()}\n" @@ -648,6 +953,9 @@ class Server: elif command == "__start": self.start() return True, f"Server \"{self.name}\" process started" + elif command == "__restart": + self.restart() + return True, f"Server \"{self.name}\" process restarted" else: return False, f"Unknown command: {command}" @@ -681,7 +989,9 @@ def load_all_server_from_settings(settings: FileSettings): @main.command() -def runserver(): +@click.option("--keep-running", required=False, help="Keep server running while all Minecraft servers are not running.", + default=True, show_default=True, flag_value=True) +def runserver(keep_running: bool): logger = logging.getLogger(__name__) formatter = logging.Formatter('[%(asctime)s:%(levelname)s:runServer]: %(message)s') logger.setLevel(logging.INFO) @@ -699,7 +1009,8 @@ def runserver(): settings.get("socketServerPort", 25560), settings.get("enableTLSSupport", True), Path(settings.get("socketServerCertfile", "data/server.crt")), - Path(settings.get( "socketServerKeyfile", "data/server.key"))) + Path(settings.get( "socketServerKeyfile", "data/server.key")), + settings.get("socketServerPassword", "")) # Flags stop_once = False @@ -739,7 +1050,8 @@ def runserver(): if server.enable: server.register_broadcaster(socket_server.publish_log) socket_server.register_command_receiver(server.name, server.command_receiver, - server.process_command_receiver) + server.process_command_receiver, + server.log_reader) try: server.start() except Exception as e: @@ -763,7 +1075,7 @@ def runserver(): logger.info(f"Server {server.name} stopped.") server.running = False - if servers and not any(server.running for server in servers): + if servers and not any(server.running for server in servers) and not keep_running: cleanup() stop = True continue diff --git a/utils/common.py b/utils/common.py index ffc9431..495db55 100644 --- a/utils/common.py +++ b/utils/common.py @@ -8,6 +8,16 @@ PAPER_SERVER_JAR_API = "https://api.papermc.io/v2/projects/paper/versions/{}/bui MOJANG_VERSION_MANIFEST_V2 = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" +def jar_filename(filename: str | None, default: str) -> str: + if filename: + name = filename + if not name.endswith(".jar"): + name += ".jar" + return name + + return default + + def download_file(url: str, destination: Path, chunk_size: int = 1024 * 512): destination.parent.mkdir(parents=True, exist_ok=True) @@ -70,9 +80,53 @@ def get_version_list(release=True): "Error: {}".format(MOJANG_VERSION_MANIFEST_V2, e)) +def get_version_manifest(): + try: + r = requests.get(MOJANG_VERSION_MANIFEST_V2) + + if r.status_code == 200: + return r.json() + + raise Exception("Unable to fetch version manifest.\n" + "Response: {}".format(r.text)) + except requests.exceptions.RequestException as e: + raise Exception("Unable to get version manifest from server.\n" + "URL: {}\n" + "Error: {}".format(MOJANG_VERSION_MANIFEST_V2, e)) + + +def get_minecraft_version_metadata(minecraft_version: str): + manifest = get_version_manifest() + version_info = next( + (version for version in manifest.get("versions", []) if version.get("id") == minecraft_version), + None, + ) + + if version_info is None: + raise Exception(f"Minecraft version {minecraft_version} was not found in Mojang version manifest.") + + try: + r = requests.get(version_info["url"]) + + if r.status_code == 200: + return r.json() + + raise Exception("Unable to fetch Minecraft version metadata for {}.\n" + "Response: {}".format(minecraft_version, r.text)) + except requests.exceptions.RequestException as e: + raise Exception("Unable to get Minecraft version metadata from server.\n" + "URL: {}\n" + "Error: {}".format(version_info.get("url"), e)) + + def get_latest_version_minecraft(release=True): version_list = get_version_list(release=release) - ver = version_list[0] if version_list else None + if not version_list: + ver = None + elif release: + ver = version_list[0] + else: + ver = version_list[0].get("id") if ver is None: raise Exception("Unable to find latest version in version list.\n") @@ -86,12 +140,7 @@ def download_server_jar(minecraft_version: str, build_version: str, destination: """ url = PAPER_SERVER_JAR_API.format(minecraft_version, build_version, minecraft_version, build_version) - if filename: - jar_name = filename - if not jar_name.endswith(".jar"): - jar_name += ".jar" - else: - jar_name = os.path.basename(url) + jar_name = jar_filename(filename, os.path.basename(url)) destination.parent.mkdir(parents=True, exist_ok=True) destination = Path(destination, jar_name) @@ -152,4 +201,30 @@ def download_latest_paper_jar(destination_dir: Path, filename: str | None = None latest_mc = vers[0] - return download_latest_build_paper_jar(latest_mc, destination_dir, filename=filename) \ No newline at end of file + return download_latest_build_paper_jar(latest_mc, destination_dir, filename=filename) + + +def download_vanilla_server_jar(minecraft_version: str, destination: Path, filename: str | None = None): + """ + Download a vanilla Minecraft server jar from Mojang's version manifest. + """ + metadata = get_minecraft_version_metadata(minecraft_version) + server_download = metadata.get("downloads", {}).get("server") + + if not server_download or not server_download.get("url"): + raise Exception(f"Minecraft version {minecraft_version} does not provide a vanilla server jar.") + + url = server_download["url"] + jar_name = jar_filename(filename, f"minecraft_server.{minecraft_version}.jar") + destination.parent.mkdir(parents=True, exist_ok=True) + destination = Path(destination, jar_name) + + try: + download_file(url, destination) + return destination + except Exception as e: + raise Exception("Unable to download vanilla server jar for version {}\nURL: {}\nError: {}".format( + minecraft_version, + url, + e, + ))