diff --git a/client.py b/client.py index e69c8e5..708a82b 100644 --- a/client.py +++ b/client.py @@ -26,7 +26,9 @@ 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_CONNECTION_HISTORY_FILE = Path.home() / ".serverjar" / "history" / "connection_history.txt" CLIENT_LOG_DIR = Path.home() / ".serverjar" / "client" / "logs" +DEFAULT_CONNECT_TIMEOUT = 10.0 def add_client_cert(cert_path): source = Path(cert_path).expanduser() @@ -94,6 +96,68 @@ def create_client_tls_context(log=None, warn=None): return context +def get_connection_history(log=None, warn=None): + connection_history = [] + + if not CLIENT_CONNECTION_HISTORY_FILE.exists(): + return [] + + if callable(log): + log("Searching existing connections history...") + + try: + with CLIENT_CONNECTION_HISTORY_FILE.open(mode="r", encoding="utf-8") as f: + for line in f: + try: + line = line.strip() + if line.count(":") < 2: + continue + + server_name, host, port = line.rsplit(":", 2) + + # Ignore unknown format line record + if len(server_name) == 0 or len(host) == 0 or len(port) == 0: + continue + + int(port) + + connection_history.append({ + "serverName": server_name, + "host": host, + "port": port, + }) + except ValueError: + continue + + except Exception as e: + if callable(warn): + warn(f"Unable to load connection history file: {e}") + + return connection_history + + +def save_connection_history(connection_history, log=None, warn=None): + if not connection_history: + return + + CLIENT_CONNECTION_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + + if callable(log): + log("Saving connection history file...") + + try: + with CLIENT_CONNECTION_HISTORY_FILE.open(mode="w", encoding="utf-8") as f: + for server in connection_history: + server_name = server.get("serverName") + host = server.get("host") + port = server.get("port") + if not server_name or not host or not port: + continue + f.write(f"{server_name}:{host}:{port}\n") + except Exception as e: + if callable(warn): + warn(f"Unable to save connection history file: {e}") + def get_history(log=None, warn=None): if not CLIENT_HISTORY_FILE.exists(): @@ -227,6 +291,7 @@ class ServerJarClient(Application): self.cmds = [] self.current_index = None self.history_draft = "" + self.connection_history = [] @self.kb.add("c-c") def closing_kb(event): @@ -426,6 +491,43 @@ class ServerJarClient(Application): self._log("History cleared") return True + def _enable_retry_mode(cmd): + self.args.retry = True + self._log("Auto-retry mode enabled") + return True + + def _list_connection_history(cmd): + if len(self.connection_history) == 0: + self._log("No connection history exists.") + return True + + for index, server in enumerate(self.connection_history): + self._log("{}: {}".format(index, server.get("serverName"))) + + return True + + def _connect_to_record_server(cmd): + parts = cmd.split() + if len(parts) != 2: + self._err("Usage: _hc ${index}") + return True + + try: + index = int(parts[1]) + server = self.connection_history[index] + host = server.get("host") + port = int(server.get("port")) + if not host: + raise ValueError("empty host") + except (ValueError, TypeError, IndexError): + self._err("Invalid index") + return True + + connect_to_server(host, port) + + return True + + cmd_map = { "_exit": { "func": _shutdown, @@ -459,10 +561,23 @@ class ServerJarClient(Application): "func": _clear, "description": "Clear the log area", }, + "_retry": { + "func": _enable_retry_mode, + "description": "Reconnect to the remote server if connection fails", + }, "_help": { "func": _help, "description": "Display the help message", }, + "_lhc": { + "func": _list_connection_history, + "description": "List the connection history (Only list the server that have one connection is successful)", + } + , + "_hc": { + "func": _connect_to_record_server, + "description": "Connect to the server listed in the connection history. (Usage: _hc ${index})", + } } if not command.strip(): @@ -513,6 +628,8 @@ class ServerJarClient(Application): required=False) parser.add_argument('-r', '--retry', help="Retry when disconnect", action='store_true', default=False, required=False) + parser.add_argument('--connect-timeout', type=float, default=DEFAULT_CONNECT_TIMEOUT, + help="Socket connection timeout in seconds", required=False) parser.add_argument('--add-cert', help="Add server certificate", type=str, default=None, required=False) args = parser.parse_args() @@ -541,6 +658,7 @@ class ServerJarClient(Application): # Save history save_history(self.cmds, self._log, self._warn) + save_connection_history(self.connection_history, self._log, self._warn) # Exit ui event loop self.full_exit() @@ -600,6 +718,17 @@ class ServerJarClient(Application): self.invalidate() + def save_server(self, host, port): + for server in self.connection_history: + if server.get("host") == host and str(server.get("port")) == str(port): + return + + self.connection_history.append({ + "serverName": f"{host}:{port}", + "host": host, + "port": port, + }) + def client(self): ptk_clear() @@ -627,10 +756,9 @@ class ServerJarClient(Application): # Create connect if self.args.no_tls: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect((self.host, self.port)) + s = socket.create_connection((self.host, self.port), timeout=self.args.connect_timeout) else: - raw = socket.create_connection((self.host, self.port)) + raw = socket.create_connection((self.host, self.port), timeout=self.args.connect_timeout) context = self.get_tls_context() s = context.wrap_socket(raw, server_hostname=self.host) @@ -649,6 +777,10 @@ class ServerJarClient(Application): data = s.recv(4096) if not data: raise ConnectionError("Server closed") + + # Only save the server that have at least one connection is successful + self.save_server(self.host, self.port) + buffer += data.decode("utf-8", errors="replace") while "\n" in buffer: line, buffer = buffer.split("\n", 1) @@ -721,7 +853,7 @@ class ServerJarClient(Application): except (ConnectionError, OSError) as e: if not self.closing_event.is_set(): - if self.args.retry: + if self.args.retry and self.host is not None and self.port is not None: self._warn(f"Disconnected: {e}, retrying...") else: self._err(f"Disconnected: {e}") @@ -757,6 +889,7 @@ class ServerJarClient(Application): self.args = self.arguments_parser() self.layout.focus(self.input_area) self.cmds = get_history(self._log, self._warn) + self.connection_history = get_connection_history(self._log, self._warn) asyncio.create_task(self.consume_incoming()) self.client_thread.start() diff --git a/main.py b/main.py index 5fcb345..fd2627a 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ ServerJar Wei - 2026 """ +import platform import re import signal import socketserver @@ -17,11 +18,14 @@ import time import datetime import base64 import hmac +import ipaddress +import shlex from pathlib import Path import click import yaml from utils.common import get_latest_version_minecraft, get_specific_version_paper_builds, \ - download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar + download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar, \ + read_server_properties, save_server_properties, activate_eula_file from utils.file_settings import FileSettings from utils.file_settings import required_list, required_value from cryptography import x509 @@ -89,6 +93,86 @@ def load_settings(): return s +def run_initial_server_start(args, server_dir: Path, timeout: float = 120.0): + click.echo("Starting server once to generate initial files...") + proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=server_dir, + encoding="utf-8", + errors="replace", + ) + + try: + output, _ = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + click.echo("Initial start is still running. Sending stop command...") + try: + if proc.stdin: + proc.stdin.write("stop\n") + proc.stdin.flush() + except OSError: + pass + + try: + output, _ = proc.communicate(timeout=30) + except subprocess.TimeoutExpired: + click.echo("Server did not stop in time. Killing initial start process...") + proc.kill() + output, _ = proc.communicate() + + if output: + for line in output.splitlines(): + click.echo(f"[BOOT] {line}") + + return proc.returncode + + +def configure_initial_server_files(server_dir: Path, server_name: str, server_host: str, server_port: str, + property_overrides=None): + try: + properties = read_server_properties(server_dir) + except FileNotFoundError as e: + click.echo(click.style(f"Unable to read server.properties: {e}", fg="yellow")) + else: + property_values = { + "server-name": str(server_name), + "server-ip": str(server_host), + "server-port": str(server_port), + } + if property_overrides: + property_values.update(property_overrides) + + for key, value in property_values.items(): + properties[key] = (value, {}) + + save_server_properties(properties, server_dir) + click.echo("server.properties updated.") + + try: + if activate_eula_file(server_dir): + click.echo("EULA activated.") + else: + click.echo(click.style("EULA was not activated.", fg="yellow")) + except FileNotFoundError as e: + click.echo(click.style(f"Unable to activate EULA: {e}", fg="yellow")) + + +def parse_server_property_overrides(values): + overrides = {} + for value in values: + key, separator, property_value = value.partition("=") + key = key.strip() + if separator == "" or key == "": + raise click.ClickException(f"Invalid server property override: {value}. Use key=value.") + overrides[key] = property_value.strip() + return overrides + + @main.command() @click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name") @click.option("--mc-version", "-m", @@ -126,10 +210,13 @@ def load_settings(): help="Hostname of the server", required=True) @click.option("--server-port", "-srp", help="Port of the server", required=True) +@click.option("--server-property", "-sp", "server_properties", multiple=True, + help="server.properties override written after the first startup. Use key=value; can be repeated.") 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): + x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host, server_properties): server_dir = Path("servers", name) server_type = server_type.lower() + property_overrides = parse_server_property_overrides(server_properties) def prepare_server_dir(): if server_dir.exists(): @@ -210,22 +297,32 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b exit("User aborted.") return - extra_args += "nogui" if nogui else "" args = [ java_exec_path, "-Xms{}".format(x_memory_initial), "-Xmx{}".format(x_memory_maximum), "-jar", out.absolute().as_posix(), - extra_args, ] + if extra_args: + args.extend(shlex.split(extra_args)) + if nogui: + args.append("nogui") if custom_args: print("Will use custom commands as replacement.") - args = custom_args + args = shlex.split(custom_args) print(f"Server command: {' '.join(args)}") + try: + return_code = run_initial_server_start(args, server_dir) + if return_code not in (0, None): + click.echo(click.style(f"Initial server start exited with code {return_code}.", fg="yellow")) + configure_initial_server_files(server_dir, name, server_host, server_port, property_overrides) + except Exception as e: + raise click.ClickException(f"Unable to bootstrap server files: {e}") from e + with settings.edit() as s: print("Saving...") s["servers"].append({ @@ -292,6 +389,8 @@ class SocketServer: self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) + self.commands = {} + # ------------------------- # Socket Server # ------------------------- @@ -339,11 +438,12 @@ class SocketServer: "__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", + "__stop_all": "Stop all servers and close the socket server (TLS Support must be enabled)", "__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", + "__system_info": "Show system information", } attached_commands = { "__status": "Show target server process status", @@ -597,12 +697,18 @@ class SocketServer: self.request.sendall(b"[SYS] bye\n") return elif cmd == "__stop_all": - self.request.sendall( - f"[SYS] Stopping all servers...bye\n".encode("utf-8") - ) - mgr.broadcast_all(mgr.tcp_server.clients, "[SYS] Server stopping... (Stop by remote client)") - mgr.stop_event.set() - return + if not mgr.enable_tls: + self.request.sendall( + f"[SYS] Command \"__stop_all\" only works when tls support is enabled.\n".encode("utf-8") + ) + else: + self.request.sendall( + f"[SYS] Stopping all servers...bye\n".encode("utf-8") + ) + mgr.broadcast_all(mgr.tcp_server.clients, + "[SYS] Server stopping... (Stop by remote client)") + mgr.stop_event.set() + return elif cmd.startswith("__sync_log"): mgr.send_sync_log(self.request, current_server, cmd) elif cmd == "__download_log": @@ -633,6 +739,14 @@ class SocketServer: f"[SYS] Disconnected from current server \"{current_server}\"'s shell.\n".encode( "utf-8") ) + elif cmd == "__system_info": + self.request.sendall( + f"[SYS] Connected clients number: {len(self.current_server_record)}\n" + f"[SYS] Hostname: {mgr.host}\n" + f"[SYS] Port: {mgr.port}\n" + f"[SYS] Working Directory: {os.getcwd()}\n" + f"[SYS] Platform: {platform.system()} ({platform.architecture()[0]})\n" + f"[SYS] Version: {VERSION}\n".encode("utf-8")) else: if current_server is not None: receiver = mgr.get_command_receiver(current_server) @@ -887,12 +1001,12 @@ class Server: def _stdout_reader_loop(self): self.logger.info("[PROC] stdout reader started") - while self.running: + while True: with self.proc_lock: proc = self.proc out = proc.stdout if proc else None - if not proc or proc.poll() is not None or not out: + if not proc or not out: self.logger.info("[PROC] process ended / stdout closed") break @@ -915,8 +1029,11 @@ class Server: if not self.proc.stdin: return False - self.proc.stdin.write(command + "\n") - self.proc.stdin.flush() + try: + self.proc.stdin.write(command + "\n") + self.proc.stdin.flush() + except OSError: + return False return True def stop_process(self, timeout: float = 10.0): @@ -959,9 +1076,9 @@ class Server: return self.stopping = True - self.running = False self.stop_process() + self.running = False def restart(self): if self.running: @@ -995,7 +1112,7 @@ class Server: return True, f"Server \"{self.name}\" process started" elif command == "__info": return True, (f"serverName: {self.name}\n" - f"serverPID: {self.proc.pid}\n" + f"serverPID: {self.proc.pid if self.proc else None}\n" f"description: {self.description}\n" f"arguments: {self.args}\n" f"host: {self.host}\n" @@ -1012,8 +1129,11 @@ class Server: if not self.proc.stdin: return False, "Process standard input are not available." - self.proc.stdin.write(command + "\n") - self.proc.stdin.flush() + try: + self.proc.stdin.write(command + "\n") + self.proc.stdin.flush() + except OSError as e: + return False, f"Unable to write process standard input: {e}" return True, "Command sent." def load_all_server_from_settings(settings: FileSettings): @@ -1138,7 +1258,8 @@ def runserver(keep_running: bool): logger.info("Stopped!") @main.command() -def generate_tls_key(): +@click.option("--hostname", default="localhost", show_default=True, help="Hostname or IP address of the server.") +def generate_tls_key(hostname: str): print("Generating TLS key...") key = rsa.generate_private_key( public_exponent=65537, @@ -1150,9 +1271,23 @@ def generate_tls_key(): x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"My Dev Org"), - x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"), + x509.NameAttribute(NameOID.COMMON_NAME, u"{}".format(hostname)), ]) + alt_names = [x509.DNSName(u"localhost")] + + def add_alt_name(name): + if name not in alt_names: + alt_names.append(name) + + try: + add_alt_name(x509.IPAddress(ipaddress.ip_address(hostname))) + except ValueError: + add_alt_name(x509.DNSName(hostname)) + + for loopback in ("127.0.0.1", "::1"): + add_alt_name(x509.IPAddress(ipaddress.ip_address(loopback))) + cert = x509.CertificateBuilder().subject_name( subject ).issuer_name( @@ -1167,7 +1302,7 @@ def generate_tls_key(): # Valid for 1 year datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365) ).add_extension( - x509.SubjectAlternativeName([x509.DNSName(u"localhost")]), + x509.SubjectAlternativeName(alt_names), critical=False, ).sign(key, hashes.SHA256()) diff --git a/requirements.txt b/requirements.txt index ea968a8..7235b4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ prompt-toolkit==3.0.52 -cryptography==48.0.0 \ No newline at end of file +cryptography==48.0.0 +jproperties==2.1.2 \ No newline at end of file diff --git a/utils/common.py b/utils/common.py index 495db55..d7641d8 100644 --- a/utils/common.py +++ b/utils/common.py @@ -2,11 +2,56 @@ from pathlib import Path import requests import click import os +from jproperties import Properties PAPER_VERSION_API = "https://api.papermc.io/v2/projects/paper/versions/{}" PAPER_SERVER_JAR_API = "https://api.papermc.io/v2/projects/paper/versions/{}/builds/{}/downloads/paper-{}-{}.jar" MOJANG_VERSION_MANIFEST_V2 = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" +def read_server_properties(server_dir: Path) -> Properties: + if not server_dir.exists(): + raise FileNotFoundError("Server directory does not exist") + + props = Properties() + + with open(server_dir / 'server.properties', 'rb') as config_file: + props.load(config_file, encoding='utf-8') + return props + + +def save_server_properties(properties: Properties, server_dir: Path) -> None: + path = server_dir / 'server.properties' + + with path.open("wb") as config_file: + properties.store(config_file, encoding='utf-8') + +def activate_eula_file(server_dir: Path) -> bool: + path = server_dir / 'eula.txt' + + if not path.exists(): + raise FileNotFoundError("EULA file does not exist. Did you run the server?") + + with open(path, 'r', encoding='utf-8') as file: + lines = file.readlines() + + for line in lines: + print(f"{line}\n") + + print("To activate eula file please read above text. If you agree this license, enter Y [not agree enter N]") + result = str(input("Agree? :")) + + if result.strip().lower() != "y": + return False + + with open(path, 'w', encoding='utf-8') as file: + for line in lines: + if line.strip() == "eula=false": + file.write("eula=true\n") + continue + file.write(line) + + + return True def jar_filename(filename: str | None, default: str) -> str: if filename: diff --git a/utils/explorer_library.py b/utils/explorer_library.py new file mode 100644 index 0000000..2d2e881 --- /dev/null +++ b/utils/explorer_library.py @@ -0,0 +1,102 @@ +""" +Source are from KiteeLauncher. +""" +import os +import re +import subprocess +import tempfile + + +def check_java_executable_and_major_version(java_executable_path): + # test java runtimes are executable + try: + subprocess.run([java_executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return True, None + except PermissionError: + return False, "Could not execute the Java executable. Permission denied." + except Exception as e: + return False, "Unknown error occurred while executing the Java executable. {}".format(e) + + +def get_java_version_by_execute(java_executable_path): + # test java runtimes are executable + + # executable it + try: + result = subprocess.run([java_executable_path, '-version'], stderr=subprocess.PIPE, stdout=subprocess.PIPE, + text=True) + except PermissionError: + return False, None, "Could not execute the Java executable. Permission denied." + except Exception as e: + return False, None, "Unknown error occurred while executing the Java executable. {}".format(e) + + # Get output + output = result.stderr + + # Get major version (e.g., "21.0.3") and full version in the output + match = re.search(r'java version "(\d+)(?:\.(\d+))?', output) + + # Is for installation by launcher runtimes (Because is openjdk not oracle java....) + if not match: + match = re.search(r'openjdk version "(\d+)(?:\.(\d+))?', output) + + if not match: + return False, None, "Unsupported Java runtime build or current path is not a valid java executable." + + try: + major_version = match.group(1) + # Special case for Java 8 where we need to use the second part (8) instead of 1 + if major_version == "1" and match.group(2): + major_version = match.group(2) + return True, major_version, None + except IndexError: + return False, None, "The target Java runtime version is too old or is not supported by this method." + +def convert_java_version_tuple_to_major_version(java_version_tuple): + try: + _, major_version, *_ = java_version_tuple.split(".") + return True, major_version + except ValueError: + return False, None + + +def get_java_version_by_checkmyduke(java_executable_path): + checkmyduke_jar = os.path.join("jar_files", "CheckMyDuke.jar") + + if not os.path.exists(checkmyduke_jar): + return False, None, "CheckMyDuke.jar not found" + + # executable it + try: + result = subprocess.run([java_executable_path, '-jar', checkmyduke_jar], stderr=subprocess.PIPE, stdout=subprocess.PIPE, + text=True) + except PermissionError: + return False, None, "Could not execute the Java executable. Permission denied." + except Exception as e: + return False, None, "Unknown error occurred while executing the Java executable. {}".format(e)\ + + + status, major_version = convert_java_version_tuple_to_major_version(result.stdout.strip()) + + if status: + return True, major_version, None + else: + return False, None, "Unsupported Java runtime build or current path is not a valid java executable." + +def search_available_java_runtimes_in_directory(target_directory, java_executable_name="java.exe" if os.name == "nt" else "java"): + """ + Using this function may take ~1min to search for available Java executable files. + """ + found_java_runtimes = [] + + for root, dirs, files in os.walk(target_directory): + for file in files: + if file == java_executable_name: + found_java_runtimes.append(os.path.join(root, file)) + + + return found_java_runtimes + + + +