Some changes.

This commit is contained in:
2026-05-17 21:14:40 +08:00
parent 2d751f6f19
commit 07276f6dd7
3 changed files with 756 additions and 96 deletions
+314 -41
View File
@@ -1,12 +1,16 @@
import argparse import argparse
import asyncio import asyncio
import base64
import binascii
import queue import queue
import shutil
import ssl import ssl
import sys import sys
import threading import threading
import socket import socket
import time import time
import traceback import traceback
from pathlib import Path
from prompt_toolkit import Application from prompt_toolkit import Application
from prompt_toolkit.layout import Layout, HSplit from prompt_toolkit.layout import Layout, HSplit
from prompt_toolkit.widgets import TextArea 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 from prompt_toolkit.shortcuts import clear as ptk_clear
version = "Beta-1" 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): class ServerJarClient(Application):
@@ -68,6 +175,7 @@ class ServerJarClient(Application):
self.client_thread = threading.Thread(target=self.client, daemon=True) self.client_thread = threading.Thread(target=self.client, daemon=True)
self.connect_event = threading.Event() self.connect_event = threading.Event()
self.disconnect_event = threading.Event() self.disconnect_event = threading.Event()
self.auth_required = False
# Queue # Queue
self.incoming = queue.Queue() self.incoming = queue.Queue()
@@ -75,7 +183,7 @@ class ServerJarClient(Application):
# Command History # Command History
self.cmds = [] self.cmds = []
self.current_index = None self.current_index = None
self.start_history_flag = False self.history_draft = ""
@self.kb.add("c-c") @self.kb.add("c-c")
def closing_kb(event): def closing_kb(event):
@@ -84,23 +192,15 @@ class ServerJarClient(Application):
@self.kb.add("up", filter=has_focus(self.input_area)) @self.kb.add("up", filter=has_focus(self.input_area))
def get_old_cmd(event): def get_old_cmd(event):
# check if there's no old command available in the command history list # check if there's no old command available in the command history list
if len(self.cmds) < 2: if not self.cmds:
return return
# Save entered command to history list # Save the current input so Down can restore it after browsing history.
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
if self.current_index is None: if self.current_index is None:
self.history_draft = self.get_input_area_output()
self.current_index = 0 self.current_index = 0
elif self.current_index < len(self.cmds) - 1:
# Avoid IndexError when it's the last one command self.current_index += 1
if self.current_index >= len(self.cmds)-1:
return
self.current_index += 1
old_cmd = self.cmds[self.current_index] old_cmd = self.cmds[self.current_index]
@@ -116,8 +216,9 @@ class ServerJarClient(Application):
if self.current_index is None: if self.current_index is None:
return return
# Avoid IndexError when it's the last one command if self.current_index == 0:
if self.current_index-1 < 0: self.current_index = None
self.set_input_area_text(self.history_draft)
return return
self.current_index -= 1 self.current_index -= 1
@@ -128,19 +229,36 @@ class ServerJarClient(Application):
@self.kb.add("enter", filter=has_focus(self.input_area)) @self.kb.add("enter", filter=has_focus(self.input_area))
def enter_kb(event): def enter_kb(event):
# Disable history flag
self.start_history_flag = False
cmd = self.input_area.text cmd = self.input_area.text
self.input_area.text = "" self.input_area.text = ""
self.current_index = None
# Save command self.history_draft = ""
self.insert_new_cmd_to_history(cmd)
if cmd == "_exit": if cmd == "_exit":
self.shutdown("_exit command detected") self.shutdown("_exit command detected")
return 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): # if re.match(r"^_[A-Za-z0-9]+(?:$|_.*)", cmd):
exit_flag = self.command_parser(cmd) exit_flag = self.command_parser(cmd)
@@ -204,6 +322,10 @@ class ServerJarClient(Application):
s.close() s.close()
except Exception as e: except Exception as e:
self._err("An error occurred while closing the socket: {}".format(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: else:
self._err("The remote server is not connected yet.") self._err("The remote server is not connected yet.")
@@ -244,18 +366,70 @@ class ServerJarClient(Application):
self._log("ServerJar Client Version {}".format(version)) self._log("ServerJar Client Version {}".format(version))
return True 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 = { cmd_map = {
"_exit": _shutdown, "_exit": {
"_c": connect_to_server_parser, "func": _shutdown,
"_d": disconnect_from_server, "description": "Exit the shell",
"_top": _top, },
"_bottom": _bottom, "_c": {
"_version": _version, "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(): for cmd in cmd_map.keys():
if command.startswith(cmd): if cmd == header:
return_flag = cmd_map[cmd](command) func = cmd_map.get(cmd).get("func")
return_flag = func(command)
return return_flag return return_flag
# self._err("Unknown command '%s'" % command) # self._err("Unknown command '%s'" % command)
@@ -270,8 +444,13 @@ class ServerJarClient(Application):
def set_input_area_text(self, text): def set_input_area_text(self, text):
self.input_area.text = text self.input_area.text = text
self.input_area.buffer.cursor_position = len(text)
def insert_new_cmd_to_history(self, cmd): 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) self.cmds.insert(0, cmd)
class ServerInfoInvalidException(Exception): class ServerInfoInvalidException(Exception):
@@ -285,14 +464,21 @@ class ServerJarClient(Application):
def arguments_parser(self): def arguments_parser(self):
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("-p", "--port", type=int, help="Port number", required=True) parser.add_argument("-p", "--port", type=int, help="Port number", required=False)
parser.add_argument('-host', '--host', type=str, help="Hostname", required=True) parser.add_argument('-host', '--host', type=str, help="Hostname", required=False)
parser.add_argument('-no-tls', '--no-tls', type="store_true", help="Enable TLS support") 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() args = parser.parse_args()
return args return args
def get_tls_context(self):
return create_client_tls_context(log=self._log, warn=self._warn)
def shutdown(self, reason=""): def shutdown(self, reason=""):
if self.closing_event.is_set(): if self.closing_event.is_set():
return return
@@ -310,6 +496,9 @@ class ServerJarClient(Application):
self._err(f"Unable to close socket: {e}") self._err(f"Unable to close socket: {e}")
pass pass
# Save history
save_history(self.cmds, self._log, self._warn)
# Exit ui event loop # Exit ui event loop
self.full_exit() self.full_exit()
@@ -366,12 +555,18 @@ class ServerJarClient(Application):
while not self.closing_event.is_set(): 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(): if self.closing_event.is_set():
break 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: if not self.host or not self.port:
self._err("No host/port set. Usage: _c host:port") self._err("No host/port set. Usage: _c host:port")
self.connect_event.clear() self.connect_event.clear()
@@ -386,17 +581,19 @@ class ServerJarClient(Application):
s.connect((self.host, self.port)) s.connect((self.host, self.port))
else: else:
raw = socket.create_connection((self.host, self.port)) 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 = context.wrap_socket(raw, server_hostname=self.host)
s.connect((self.host, self.port))
with self.sock_lock: with self.sock_lock:
self.sock = s self.sock = s
self.auth_required = False
self._log("Remote socket server connected [HOST: {}, PORT: {}]".format(self.host, self.port)) self._log("Remote socket server connected [HOST: {}, PORT: {}]".format(self.host, self.port))
buffer = "" buffer = ""
downloading_log = False
download_path = None
download_file = None
while True: while True:
# Receive remote server broadcast message and display it on log area # Receive remote server broadcast message and display it on log area
data = s.recv(4096) data = s.recv(4096)
@@ -405,12 +602,79 @@ class ServerJarClient(Application):
buffer += data.decode("utf-8", errors="replace") buffer += data.decode("utf-8", errors="replace")
while "\n" in buffer: while "\n" in buffer:
line, buffer = buffer.split("\n", 1) 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 ### # ### Use normal log method ###
self.log(line) self.log(line)
except (ConnectionError, OSError) as e: except (ConnectionError, OSError) as e:
if not self.closing_event.is_set(): 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) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
self._log("Exiting...") self._log("Exiting...")
@@ -419,6 +683,9 @@ class ServerJarClient(Application):
self._err(f"Unhandled exception: {e}") self._err(f"Unhandled exception: {e}")
self._err(f"{traceback.format_exc()}") self._err(f"{traceback.format_exc()}")
finally: finally:
if "download_file" in locals() and download_file:
download_file.close()
with self.sock_lock: with self.sock_lock:
try: try:
if self.sock: if self.sock:
@@ -428,20 +695,26 @@ class ServerJarClient(Application):
self._err(f"Unable to close socket: {e}") self._err(f"Unable to close socket: {e}")
pass pass
self.sock = None self.sock = None
self.auth_required = False
# reset flags # reset flags
self.disconnect_event.clear() self.disconnect_event.clear()
self.connect_event.clear() if not self.args.retry:
self.connect_event.clear()
def startup(self): def startup(self):
self.args = self.arguments_parser() self.args = self.arguments_parser()
self.layout.focus(self.input_area) self.layout.focus(self.input_area)
self.cmds = get_history(self._log, self._warn)
asyncio.create_task(self.consume_incoming()) asyncio.create_task(self.consume_incoming())
self.client_thread.start() self.client_thread.start()
if __name__ == "__main__": if __name__ == "__main__":
if run_cli_action():
sys.exit(0)
app = ServerJarClient() app = ServerJarClient()
app.run(pre_run=app.startup) app.run(pre_run=app.startup)
+359 -47
View File
@@ -16,11 +16,13 @@ import subprocess
import threading import threading
import time import time
import datetime import datetime
import base64
import hmac
from pathlib import Path from pathlib import Path
import click import click
import yaml import yaml
from utils.common import download_latest_paper_jar, get_latest_version_minecraft, get_specific_version_paper_builds, \ 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 FileSettings
from utils.file_settings import required_list, required_value from utils.file_settings import required_list, required_value
from cryptography import x509 from cryptography import x509
@@ -32,6 +34,9 @@ from cryptography.hazmat.primitives import serialization
ROOT_DIR = Path(os.getcwd()) ROOT_DIR = Path(os.getcwd())
SERVER_CONFIG_PATH = ROOT_DIR / "config" / "server.yml" SERVER_CONFIG_PATH = ROOT_DIR / "config" / "server.yml"
VERSION = "1.0" VERSION = "1.0"
LOG_DIR_NAME = "logs"
SERVERJAR_LOG_FILE = "serverjar.log"
LOG_DOWNLOAD_CHUNK_SIZE = 4096
def exit(message): def exit(message):
click.echo(click.style(message, fg='green')) click.echo(click.style(message, fg='green'))
@@ -50,11 +55,13 @@ def load_settings():
{ {
"servers": [], "servers": [],
"socketServerHostname": "127.0.0.1", "socketServerHostname": "127.0.0.1",
"socketServerPort": 25560 "socketServerPort": 25560,
"socketServerPassword": ""
}, },
{ {
"socketServerHostname": required_value("127.0.0.1"), "socketServerHostname": required_value("127.0.0.1"),
"socketServerPort": required_value(25560), "socketServerPort": required_value(25560),
"socketServerPassword": required_value(""),
"enableTLSSupport": required_value(True), "enableTLSSupport": required_value(True),
"socketServerCertfile": required_value("data/server-public.pem"), "socketServerCertfile": required_value("data/server-public.pem"),
"socketServerKeyfile": required_value("data/server-private.pem"), "socketServerKeyfile": required_value("data/server-private.pem"),
@@ -92,9 +99,14 @@ def load_settings():
required=False) required=False)
@click.option("--build", "-b", default=None, @click.option("--build", "-b", default=None,
help="Specify paper build to download (Use latest Minecraft version if not specified)") 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, @click.option("--snapshot", is_flag=True,
help="Download snapshot version Minecraft (Use it if the current mc-version type is snapshot)") 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("--list-builds", is_flag=True, help="List available paper build versions")
@click.option("--filename", default=None, help="Custom SERVER.jar file name") @click.option("--filename", default=None, help="Custom SERVER.jar file name")
@click.option("--extra-args", "-e", @click.option("--extra-args", "-e",
@@ -116,26 +128,43 @@ def load_settings():
help="Hostname of the server", required=True) help="Hostname of the server", required=True)
@click.option("--server-port", "-srp", @click.option("--server-port", "-srp",
help="Port of the server", required=True) 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): x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host):
server_dir = Path("servers", name) server_dir = Path("servers", name)
server_type = server_type.lower()
if server_dir.exists(): def prepare_server_dir():
result = str(input("Found existing server dir. Do you want to overwrite it and continue? [y/N] ")) 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": if not result.lower() == "y":
exit("User aborted.") exit("User aborted.")
server_dir.mkdir(parents=True, exist_ok=True) server_dir.mkdir(parents=True, exist_ok=True)
latest_ver = None latest_ver = None
try: try:
release = True if not snapshot else False 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...") click.echo("Fetching latest Mojang release version...")
latest_ver = get_latest_paper_version(release=release) latest_ver = get_latest_paper_version(release=release)
builds = get_specific_version_paper_builds(latest_ver) builds = get_specific_version_paper_builds(latest_ver)
prepare_server_dir()
out = download_server_jar(latest_ver, builds[-1], server_dir) out = download_server_jar(latest_ver, builds[-1], server_dir)
else: else:
if mc_version is None: if mc_version is None:
@@ -154,10 +183,12 @@ def create_server(name, mc_version, build, snapshot, latest, list_builds, filena
if build: if build:
click.echo(f"Downloading Paper {mc_version} build {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) out = download_server_jar(mc_version, str(build), server_dir, filename=filename)
click.echo(f"Done: {out}") click.echo(f"Done: {out}")
else: else:
click.echo(f"Downloading latest Paper build for {mc_version} ...") 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) out = download_latest_build_paper_jar(mc_version, server_dir, filename=filename)
click.echo(f"Done: {out}") 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 "" extra_args += "nogui" if nogui else ""
args = [ args = [
java_exec_path, java_exec_path,
"--Xms{}".format(x_memory_initial), "-Xms{}".format(x_memory_initial),
"--Xmx{}".format(x_memory_maximum), "-Xmx{}".format(x_memory_maximum),
"-jar", "-jar",
out.absolute().as_posix(), out.absolute().as_posix(),
extra_args, 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.") print("Will use custom commands as replacement.")
args = custom_args args = custom_args
print(f"Server command: {" ".join(args)}") print(f"Server command: {' '.join(args)}")
with settings.edit() as s: with settings.edit() as s:
print("Saving...") print("Saving...")
@@ -213,10 +244,13 @@ def create_server(name, mc_version, build, snapshot, latest, list_builds, filena
print("Done") print("Done")
class SocketServer: 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 = logging.getLogger("SocketServer")
self.logger.setLevel(logging.INFO)
self.stdout_handler = logging.StreamHandler(sys.stdout) 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 # flags
self.stop_event = threading.Event() self.stop_event = threading.Event()
@@ -235,15 +269,24 @@ class SocketServer:
self.enable_tls = enable_tls self.enable_tls = enable_tls
self.certfile = certfile self.certfile = certfile
self.keyfile = keyfile self.keyfile = keyfile
self.password = "" if password is None else str(password)
self._ssl_context = None
if not self.certfile.exists(): if self.password and not self.enable_tls:
raise FileNotFoundError("Certfile not found") self.logger.warning(
"[SECURITY] socketServerPassword is enabled while TLS is disabled. "
"Client passwords will be sent in plaintext."
)
if not self.keyfile.exists(): if self.enable_tls:
raise FileNotFoundError("Keyfile not found") if not self.certfile.exists():
raise FileNotFoundError("Certfile not found")
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) if not self.keyfile.exists():
context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) 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 # Socket Server
@@ -271,18 +314,129 @@ class SocketServer:
with self._sub_lock: with self._sub_lock:
self._log_subscribers.discard(q) 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 <server name>": "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",
"<minecraft command>": "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 <server name> 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): def handler_command(self, command: str):
self.logger.info("On...no co", command) 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): def _build_tcp_server(self):
manager = 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): class TCPServer(socketserver.ThreadingTCPServer):
allow_reuse_address = True allow_reuse_address = True
daemon_threads = True daemon_threads = True
@@ -291,13 +445,19 @@ class SocketServer:
super().__init__(server_address, RequestHandlerClass) super().__init__(server_address, RequestHandlerClass)
self.manager = manager self.manager = manager
if self.manager.enable_tls: def get_request(self):
def get_request(): sock, addr = super().get_request()
sock, addr = super().get_request()
tls_sock = context.wrap_socket(sock, server_side=True)
return tls_sock, addr
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): class Handler(socketserver.BaseRequestHandler):
current_server_record = { current_server_record = {
@@ -310,7 +470,8 @@ class SocketServer:
def handle(self): def handle(self):
mgr: Server = self.server.manager mgr: Server = self.server.manager
log_q = mgr.subscribe_logs() authenticated = not mgr.requires_password()
log_q = None
stop_evt = threading.Event() stop_evt = threading.Event()
def push_logs(): def push_logs():
@@ -324,11 +485,17 @@ class SocketServer:
except OSError: except OSError:
break break
t = threading.Thread(target=push_logs, daemon=True) t = None
t.start()
try: 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"" buf = b""
while True: while True:
@@ -344,6 +511,32 @@ class SocketServer:
if not cmd: if not cmd:
continue 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( current_server = self.current_server_record.get(
f"{self.client_address[0]}:{self.client_address[1]}", f"{self.client_address[0]}:{self.client_address[1]}",
None) None)
@@ -354,16 +547,28 @@ class SocketServer:
message = None message = None
if cmd.startswith("__"): 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 # Exit socket
self.request.sendall(b"[SYS] bye\n") self.request.sendall(b"[SYS] bye\n")
return return
if cmd == "__stop_all": elif cmd == "__stop_all":
self.request.sendall( self.request.sendall(
f"[SYS] Stopping all servers...bye\n".encode("utf-8") f"[SYS] Stopping all servers...bye\n".encode("utf-8")
) )
mgr.stop_event.set() mgr.stop_event.set()
return 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"): elif cmd.startswith("__c"):
match = re.match(r"^__c\s+(.+)$", cmd) match = re.match(r"^__c\s+(.+)$", cmd)
if match: if match:
@@ -430,12 +635,13 @@ class SocketServer:
if message is not None: if message is not None:
msg = msg + message + "\n" msg = msg + message + "\n"
self.request.sendall(msg.encode("utf-8")) self.request.sendall(msg.encode("utf-8"))
except ConnectionResetError: except (ConnectionResetError, OSError):
mgr.logger.info( mgr.logger.info(
"[SYS] Client disconnected. From {}:{}".format(self.client_address[0], self.client_address[1])) "[SYS] Client disconnected. From {}:{}".format(self.client_address[0], self.client_address[1]))
finally: finally:
stop_evt.set() 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) return TCPServer((self.host, self.port), Handler)
@@ -464,13 +670,14 @@ class SocketServer:
self._tcp_thread.join(timeout=2) self._tcp_thread.join(timeout=2)
self._tcp_thread = None 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(): if server_name in self.command_receivers.keys():
self.logger.warning(f"[SYS] Command receiver name \"{server_name}\" already registered") self.logger.warning(f"[SYS] Command receiver name \"{server_name}\" already registered")
else: else:
self.command_receivers[server_name] = { self.command_receivers[server_name] = {
"receiver": receiver, "receiver": receiver,
"processReceiver": process_receiver, "processReceiver": process_receiver,
"logReader": log_reader,
} }
def get_command_receiver(self, server_name): def get_command_receiver(self, server_name):
@@ -514,11 +721,93 @@ class Server:
self.port = port self.port = port
self.host = host self.host = host
self.enable = enable 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.log_queue = queue.Queue() # stdout lines
self._threads: list[threading.Thread] = [] self._threads: list[threading.Thread] = []
self.broadcaster = None 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): def start_process(self):
self.logger.info("Starting process...") self.logger.info("Starting process...")
@@ -574,6 +863,10 @@ class Server:
line = line.rstrip("\n") line = line.rstrip("\n")
self.logger.info("[PROC] %s", line) 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) self.publish_log(line)
def send_command(self, command: str) -> bool: def send_command(self, command: str) -> bool:
@@ -631,12 +924,24 @@ class Server:
self.stop_process() self.stop_process()
def restart(self):
if self.running:
self.stop()
self.start()
def is_process_alive(self) -> bool: def is_process_alive(self) -> bool:
with self.proc_lock: with self.proc_lock:
return self.proc is not None and self.proc.poll() is None return self.proc is not None and self.proc.poll() is None
def command_receiver(self, command): 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"
"<minecraft command>: Send command to the target Minecraft server process")
elif command == "__status":
with self.proc_lock: with self.proc_lock:
pid = self.proc.pid if self.proc else None pid = self.proc.pid if self.proc else None
return True, (f"processAlive: {self.is_process_alive()}\n" return True, (f"processAlive: {self.is_process_alive()}\n"
@@ -648,6 +953,9 @@ class Server:
elif command == "__start": elif command == "__start":
self.start() self.start()
return True, f"Server \"{self.name}\" process started" return True, f"Server \"{self.name}\" process started"
elif command == "__restart":
self.restart()
return True, f"Server \"{self.name}\" process restarted"
else: else:
return False, f"Unknown command: {command}" return False, f"Unknown command: {command}"
@@ -681,7 +989,9 @@ def load_all_server_from_settings(settings: FileSettings):
@main.command() @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__) logger = logging.getLogger(__name__)
formatter = logging.Formatter('[%(asctime)s:%(levelname)s:runServer]: %(message)s') formatter = logging.Formatter('[%(asctime)s:%(levelname)s:runServer]: %(message)s')
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@@ -699,7 +1009,8 @@ def runserver():
settings.get("socketServerPort", 25560), settings.get("socketServerPort", 25560),
settings.get("enableTLSSupport", True), settings.get("enableTLSSupport", True),
Path(settings.get("socketServerCertfile", "data/server.crt")), 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 # Flags
stop_once = False stop_once = False
@@ -739,7 +1050,8 @@ def runserver():
if server.enable: if server.enable:
server.register_broadcaster(socket_server.publish_log) server.register_broadcaster(socket_server.publish_log)
socket_server.register_command_receiver(server.name, server.command_receiver, socket_server.register_command_receiver(server.name, server.command_receiver,
server.process_command_receiver) server.process_command_receiver,
server.log_reader)
try: try:
server.start() server.start()
except Exception as e: except Exception as e:
@@ -763,7 +1075,7 @@ def runserver():
logger.info(f"Server {server.name} stopped.") logger.info(f"Server {server.name} stopped.")
server.running = False 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() cleanup()
stop = True stop = True
continue continue
+83 -8
View File
@@ -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" 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): def download_file(url: str, destination: Path, chunk_size: int = 1024 * 512):
destination.parent.mkdir(parents=True, exist_ok=True) 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)) "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): def get_latest_version_minecraft(release=True):
version_list = get_version_list(release=release) 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: if ver is None:
raise Exception("Unable to find latest version in version list.\n") 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) url = PAPER_SERVER_JAR_API.format(minecraft_version, build_version, minecraft_version, build_version)
if filename: jar_name = jar_filename(filename, os.path.basename(url))
jar_name = filename
if not jar_name.endswith(".jar"):
jar_name += ".jar"
else:
jar_name = os.path.basename(url)
destination.parent.mkdir(parents=True, exist_ok=True) destination.parent.mkdir(parents=True, exist_ok=True)
destination = Path(destination, jar_name) 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] latest_mc = vers[0]
return download_latest_build_paper_jar(latest_mc, destination_dir, filename=filename) 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,
))