2026-05-17 17:16:58 +08:00
|
|
|
"""
|
|
|
|
|
ServerJar
|
|
|
|
|
|
|
|
|
|
Wei - 2026
|
|
|
|
|
"""
|
2026-05-19 23:30:00 +08:00
|
|
|
import platform
|
2026-05-17 17:16:58 +08:00
|
|
|
import re
|
|
|
|
|
import signal
|
|
|
|
|
import socketserver
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import queue
|
2026-05-17 19:16:00 +08:00
|
|
|
import ssl
|
2026-05-17 17:16:58 +08:00
|
|
|
import sys
|
|
|
|
|
import subprocess
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
2026-05-17 19:16:00 +08:00
|
|
|
import datetime
|
2026-05-17 21:14:40 +08:00
|
|
|
import base64
|
|
|
|
|
import hmac
|
2026-05-19 23:30:00 +08:00
|
|
|
import ipaddress
|
|
|
|
|
import shlex
|
2026-06-06 17:18:25 +08:00
|
|
|
import shutil
|
2026-05-17 17:16:58 +08:00
|
|
|
from pathlib import Path
|
|
|
|
|
import click
|
|
|
|
|
import yaml
|
2026-05-18 23:29:58 +08:00
|
|
|
from utils.common import get_latest_version_minecraft, get_specific_version_paper_builds, \
|
2026-05-19 23:30:00 +08:00
|
|
|
download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar, \
|
2026-05-19 23:51:55 +08:00
|
|
|
read_server_properties, save_server_properties, activate_eula_file, \
|
|
|
|
|
get_specific_version_minecraft_require_java_version, find_system_java_executables
|
|
|
|
|
from utils.explorer_library import get_java_version_by_execute, search_available_java_runtimes_in_directory
|
2026-05-17 17:16:58 +08:00
|
|
|
from utils.file_settings import FileSettings
|
|
|
|
|
from utils.file_settings import required_list, required_value
|
2026-05-19 23:51:55 +08:00
|
|
|
from utils.java.java_info import create_java_version_info
|
|
|
|
|
from utils.java.jvm_installer import install_azul_build_version_jvm
|
2026-05-17 19:16:00 +08:00
|
|
|
from cryptography import x509
|
|
|
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
|
from cryptography.hazmat.primitives import hashes
|
|
|
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
ROOT_DIR = Path(os.getcwd())
|
|
|
|
|
SERVER_CONFIG_PATH = ROOT_DIR / "config" / "server.yml"
|
2026-05-18 23:29:58 +08:00
|
|
|
VERSION = "1.1"
|
2026-05-17 21:14:40 +08:00
|
|
|
LOG_DIR_NAME = "logs"
|
|
|
|
|
SERVERJAR_LOG_FILE = "serverjar.log"
|
|
|
|
|
LOG_DOWNLOAD_CHUNK_SIZE = 4096
|
2026-05-19 23:51:55 +08:00
|
|
|
JVM_RUNTIME_DIR = ROOT_DIR / "data" / "jvm"
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
def exit(message):
|
|
|
|
|
click.echo(click.style(message, fg='green'))
|
2026-05-17 19:16:00 +08:00
|
|
|
sys.exit(0)
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
|
def main():
|
2026-05-17 19:16:00 +08:00
|
|
|
print(f"ServerJar v{VERSION}"
|
|
|
|
|
f"\nWorkDir: {ROOT_DIR}")
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_settings():
|
|
|
|
|
s = FileSettings(
|
|
|
|
|
SERVER_CONFIG_PATH,
|
|
|
|
|
{
|
|
|
|
|
"servers": [],
|
|
|
|
|
"socketServerHostname": "127.0.0.1",
|
2026-05-17 21:14:40 +08:00
|
|
|
"socketServerPort": 25560,
|
|
|
|
|
"socketServerPassword": ""
|
2026-05-17 17:16:58 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"socketServerHostname": required_value("127.0.0.1"),
|
|
|
|
|
"socketServerPort": required_value(25560),
|
2026-05-17 21:14:40 +08:00
|
|
|
"socketServerPassword": required_value(""),
|
2026-05-17 19:16:00 +08:00
|
|
|
"enableTLSSupport": required_value(True),
|
|
|
|
|
"socketServerCertfile": required_value("data/server-public.pem"),
|
|
|
|
|
"socketServerKeyfile": required_value("data/server-private.pem"),
|
2026-05-17 17:16:58 +08:00
|
|
|
"servers": required_list(
|
|
|
|
|
{
|
|
|
|
|
"name": "Unnamed Server",
|
|
|
|
|
"version": "unknown",
|
|
|
|
|
"description": "",
|
2026-05-17 19:16:00 +08:00
|
|
|
"args": [],
|
2026-05-17 17:16:58 +08:00
|
|
|
"workDir": "",
|
|
|
|
|
"port": 25565,
|
|
|
|
|
"host": "127.0.0.1",
|
|
|
|
|
"enable": True
|
|
|
|
|
},
|
|
|
|
|
use_same_form=True,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
dumps_func=yaml.safe_dump,
|
|
|
|
|
load_func=yaml.safe_load,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not s.exists():
|
|
|
|
|
s.create()
|
|
|
|
|
|
|
|
|
|
s.read_from_exist()
|
|
|
|
|
|
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 23:51:55 +08:00
|
|
|
def normalize_machine_arch(machine=None):
|
|
|
|
|
machine = (machine or platform.machine()).lower()
|
|
|
|
|
if machine in ("amd64", "x86_64", "x64"):
|
|
|
|
|
return "amd64"
|
|
|
|
|
if machine in ("arm64", "aarch64"):
|
|
|
|
|
return "arm64"
|
|
|
|
|
if machine in ("x86", "i386", "i686"):
|
|
|
|
|
return "i686"
|
|
|
|
|
return machine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def java_executable_names():
|
|
|
|
|
return ["java.exe", "javaw.exe"] if os.name == "nt" else ["java"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def java_runtime_search_roots():
|
|
|
|
|
roots = [JVM_RUNTIME_DIR]
|
|
|
|
|
java_home = os.environ.get("JAVA_HOME")
|
|
|
|
|
if java_home:
|
|
|
|
|
roots.append(Path(java_home))
|
|
|
|
|
return roots
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def collect_java_executable_candidates():
|
|
|
|
|
candidates = []
|
|
|
|
|
|
|
|
|
|
for root in java_runtime_search_roots():
|
|
|
|
|
if root.exists():
|
|
|
|
|
candidates.extend(search_available_java_runtimes_in_directory(str(root)))
|
|
|
|
|
|
|
|
|
|
candidates.extend(find_system_java_executables(java_executable_names()))
|
|
|
|
|
|
|
|
|
|
for java_name in java_executable_names():
|
|
|
|
|
candidates.append(java_name)
|
|
|
|
|
|
|
|
|
|
unique_candidates = []
|
|
|
|
|
seen = set()
|
|
|
|
|
for candidate in candidates:
|
|
|
|
|
candidate_key = str(candidate).lower()
|
|
|
|
|
if candidate_key in seen:
|
|
|
|
|
continue
|
|
|
|
|
seen.add(candidate_key)
|
|
|
|
|
unique_candidates.append(str(candidate))
|
|
|
|
|
|
|
|
|
|
return unique_candidates
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
def resolve_executable_path(executable_path):
|
|
|
|
|
resolved = shutil.which(str(executable_path))
|
|
|
|
|
if resolved:
|
|
|
|
|
return Path(resolved)
|
|
|
|
|
return Path(executable_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_x86_directory_java(executable_path):
|
|
|
|
|
path = resolve_executable_path(executable_path)
|
|
|
|
|
return any("(x86)" in part.lower() for part in path.parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_memory_size_to_mb(value):
|
|
|
|
|
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([kmgtKMGT]?)\s*", str(value))
|
|
|
|
|
if not match:
|
|
|
|
|
raise click.ClickException(f"Invalid memory size: {value}")
|
|
|
|
|
|
|
|
|
|
amount = float(match.group(1))
|
|
|
|
|
unit = match.group(2).upper() or "M"
|
|
|
|
|
multiplier = {
|
|
|
|
|
"K": 1 / 1024,
|
|
|
|
|
"M": 1,
|
|
|
|
|
"G": 1024,
|
|
|
|
|
"T": 1024 * 1024,
|
|
|
|
|
}[unit]
|
|
|
|
|
return amount * multiplier
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 23:51:55 +08:00
|
|
|
def inspect_java_candidate(java_executable_path):
|
|
|
|
|
status, major_version, error = get_java_version_by_execute(java_executable_path)
|
|
|
|
|
if not status:
|
2026-06-06 17:18:25 +08:00
|
|
|
return None, error, is_x86_directory_java(java_executable_path)
|
2026-05-19 23:51:55 +08:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
major_version = int(major_version)
|
|
|
|
|
except (TypeError, ValueError):
|
2026-06-06 17:18:25 +08:00
|
|
|
return None, f"Invalid Java major version: {major_version}", is_x86_directory_java(java_executable_path)
|
2026-05-19 23:51:55 +08:00
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
return major_version, None, is_x86_directory_java(java_executable_path)
|
2026-05-19 23:51:55 +08:00
|
|
|
|
|
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
def find_compatible_java(required_major_version, max_memory_mb):
|
2026-05-19 23:51:55 +08:00
|
|
|
required_major_version = int(required_major_version)
|
|
|
|
|
compatible = []
|
|
|
|
|
checked = []
|
2026-06-06 17:18:25 +08:00
|
|
|
avoid_32bit = max_memory_mb >= 4096
|
2026-05-19 23:51:55 +08:00
|
|
|
|
|
|
|
|
for candidate in collect_java_executable_candidates():
|
2026-06-06 17:18:25 +08:00
|
|
|
major_version, error, is_32bit = inspect_java_candidate(candidate)
|
2026-05-19 23:51:55 +08:00
|
|
|
if major_version is None:
|
2026-06-06 17:18:25 +08:00
|
|
|
checked.append((candidate, None, is_32bit, error))
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if is_32bit and avoid_32bit:
|
|
|
|
|
checked.append((candidate, major_version, is_32bit, "Skipped 32-bit Java because Xmx is 4G or higher."))
|
2026-05-19 23:51:55 +08:00
|
|
|
continue
|
|
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
checked.append((candidate, major_version, is_32bit, None))
|
2026-05-19 23:51:55 +08:00
|
|
|
if major_version >= required_major_version:
|
2026-06-06 17:18:25 +08:00
|
|
|
compatible.append((candidate, major_version, is_32bit))
|
2026-05-19 23:51:55 +08:00
|
|
|
|
|
|
|
|
compatible.sort(key=lambda item: (item[1], item[0]))
|
|
|
|
|
if compatible:
|
2026-06-06 17:18:25 +08:00
|
|
|
return compatible[0][0], compatible[0][1], compatible[0][2], checked
|
2026-05-19 23:51:55 +08:00
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
return None, None, None, checked
|
2026-05-19 23:51:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_compatible_java(required_major_version):
|
|
|
|
|
arch = normalize_machine_arch()
|
|
|
|
|
platform_name = platform.system().lower()
|
|
|
|
|
install_dir = JVM_RUNTIME_DIR / f"java_{required_major_version}_{arch}"
|
|
|
|
|
|
|
|
|
|
click.echo(f"Installing Azul Java {required_major_version} into {install_dir} ...")
|
|
|
|
|
status, error = install_azul_build_version_jvm(
|
|
|
|
|
str(required_major_version),
|
|
|
|
|
str(install_dir),
|
|
|
|
|
arch,
|
|
|
|
|
platform_name,
|
|
|
|
|
)
|
|
|
|
|
if not status:
|
|
|
|
|
raise click.ClickException(f"Unable to install Azul Java {required_major_version}: {error}")
|
|
|
|
|
|
|
|
|
|
create_java_version_info(str(required_major_version), arch, str(install_dir))
|
|
|
|
|
java_executable = install_dir / "bin" / ("java.exe" if os.name == "nt" else "java")
|
|
|
|
|
if not java_executable.exists():
|
|
|
|
|
raise click.ClickException(f"Installed JVM does not contain a Java executable: {java_executable}")
|
|
|
|
|
|
|
|
|
|
return java_executable.absolute().as_posix()
|
|
|
|
|
|
|
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
def resolve_java_executable(java_exec_path, minecraft_version, release, auto_install_java, x_memory_maximum):
|
|
|
|
|
max_memory_mb = parse_memory_size_to_mb(x_memory_maximum)
|
2026-05-19 23:51:55 +08:00
|
|
|
required_major_version = get_specific_version_minecraft_require_java_version(minecraft_version, release=release)
|
|
|
|
|
if required_major_version is None:
|
|
|
|
|
click.echo(click.style("Unable to determine required Java version. Using configured Java executable.", fg="yellow"))
|
|
|
|
|
return java_exec_path or "java"
|
|
|
|
|
|
|
|
|
|
click.echo(f"Minecraft {minecraft_version} requires Java {required_major_version}.")
|
|
|
|
|
|
|
|
|
|
if java_exec_path:
|
2026-06-06 17:18:25 +08:00
|
|
|
major_version, error, is_32bit = inspect_java_candidate(java_exec_path)
|
2026-05-19 23:51:55 +08:00
|
|
|
if major_version is None:
|
|
|
|
|
raise click.ClickException(f"Unable to use Java executable '{java_exec_path}': {error}")
|
2026-06-06 17:18:25 +08:00
|
|
|
if is_32bit and max_memory_mb >= 4096:
|
|
|
|
|
raise click.ClickException(
|
|
|
|
|
f"Java executable '{java_exec_path}' is in an (x86) directory and is treated as 32-bit. "
|
|
|
|
|
f"It cannot be used with -Xmx{x_memory_maximum}; use less than 4G, or choose a 64-bit Java."
|
|
|
|
|
)
|
2026-05-19 23:51:55 +08:00
|
|
|
if major_version < int(required_major_version):
|
|
|
|
|
raise click.ClickException(
|
|
|
|
|
f"Java executable '{java_exec_path}' is Java {major_version}, "
|
|
|
|
|
f"but Minecraft {minecraft_version} requires Java {required_major_version}."
|
|
|
|
|
)
|
2026-06-06 17:18:25 +08:00
|
|
|
bitness = ", 32-bit path" if is_32bit else ""
|
|
|
|
|
click.echo(f"Using configured Java executable: {java_exec_path} (Java {major_version}{bitness})")
|
2026-05-19 23:51:55 +08:00
|
|
|
return java_exec_path
|
|
|
|
|
|
2026-06-06 17:18:25 +08:00
|
|
|
java_path, major_version, is_32bit, checked = find_compatible_java(required_major_version, max_memory_mb)
|
2026-05-19 23:51:55 +08:00
|
|
|
if java_path:
|
2026-06-06 17:18:25 +08:00
|
|
|
bitness = ", 32-bit path" if is_32bit else ""
|
|
|
|
|
click.echo(f"Using compatible Java executable: {java_path} (Java {major_version}{bitness})")
|
2026-05-19 23:51:55 +08:00
|
|
|
return java_path
|
|
|
|
|
|
|
|
|
|
click.echo(click.style(f"No compatible Java runtime found for Java {required_major_version}.", fg="yellow"))
|
|
|
|
|
if checked:
|
|
|
|
|
click.echo("Checked Java candidates:")
|
2026-06-06 17:18:25 +08:00
|
|
|
for candidate, candidate_major, is_32bit, error in checked:
|
|
|
|
|
bitness = ", 32-bit path" if is_32bit else ""
|
2026-05-19 23:51:55 +08:00
|
|
|
if candidate_major is None:
|
2026-06-06 17:18:25 +08:00
|
|
|
click.echo(f"- {candidate}: unavailable{bitness} ({error})")
|
2026-05-19 23:51:55 +08:00
|
|
|
else:
|
2026-06-06 17:18:25 +08:00
|
|
|
suffix = f" ({error})" if error else ""
|
|
|
|
|
click.echo(f"- {candidate}: Java {candidate_major}{bitness}{suffix}")
|
2026-05-19 23:51:55 +08:00
|
|
|
|
|
|
|
|
should_install = auto_install_java
|
|
|
|
|
if not should_install:
|
|
|
|
|
result = input("Install a compatible Azul JVM now? [y/N] ")
|
|
|
|
|
should_install = result.strip().lower() == "y"
|
|
|
|
|
|
|
|
|
|
if should_install:
|
|
|
|
|
return install_compatible_java(required_major_version)
|
|
|
|
|
|
|
|
|
|
raise click.ClickException(
|
|
|
|
|
f"No compatible Java runtime is available. Install Java {required_major_version}+ "
|
|
|
|
|
"or rerun with --install-java."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
@main.command()
|
2026-05-17 19:16:00 +08:00
|
|
|
@click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name")
|
|
|
|
|
@click.option("--mc-version", "-m",
|
|
|
|
|
default=None,
|
|
|
|
|
help="Specify Minecraft version to download (If not specified, download latest Minecraft version)",
|
|
|
|
|
required=False)
|
|
|
|
|
@click.option("--build", "-b", default=None,
|
|
|
|
|
help="Specify paper build to download (Use latest Minecraft version if not specified)")
|
2026-05-17 21:14:40 +08:00
|
|
|
@click.option("--server-type", "-t",
|
|
|
|
|
type=click.Choice(["paper", "vanilla"], case_sensitive=False),
|
|
|
|
|
default="paper",
|
|
|
|
|
show_default=True,
|
|
|
|
|
help="Server jar type to download")
|
2026-05-17 19:16:00 +08:00
|
|
|
@click.option("--snapshot", is_flag=True,
|
|
|
|
|
help="Download snapshot version Minecraft (Use it if the current mc-version type is snapshot)")
|
2026-05-17 21:14:40 +08:00
|
|
|
@click.option("--latest", is_flag=True, help="Download latest Minecraft version")
|
2026-05-17 19:16:00 +08:00
|
|
|
@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",
|
|
|
|
|
help="Extra java arguments", type=str, default="")
|
|
|
|
|
@click.option("--custom-args", "-ce",
|
|
|
|
|
help="Custom arguments (command)", type=str, default="")
|
2026-05-17 17:16:58 +08:00
|
|
|
@click.option("--java-exec-path", "-p", show_default=True,
|
2026-05-19 23:51:55 +08:00
|
|
|
help="The destination of the java executable. If omitted, ServerJar searches for a compatible Java.",
|
|
|
|
|
default=None)
|
|
|
|
|
@click.option("--install-java", is_flag=True,
|
|
|
|
|
help="Install a compatible Azul JVM automatically when no local compatible Java is found.")
|
2026-05-17 17:16:58 +08:00
|
|
|
@click.option("--x-memory-initial", "-xms", show_default=True,
|
|
|
|
|
help="Initial allocation size of the memory for server",
|
|
|
|
|
type=str, default="1G")
|
|
|
|
|
@click.option("--x-memory-maximum", "-xmx", show_default=True,
|
|
|
|
|
help="Maximum allocation size of the memory for server",
|
|
|
|
|
type=str, default="4G")
|
|
|
|
|
@click.option("--nogui", "-ng",
|
|
|
|
|
help="Disable server window",
|
|
|
|
|
is_flag=True)
|
2026-05-17 19:16:00 +08:00
|
|
|
@click.option("--server-host", "-srh",
|
|
|
|
|
help="Hostname of the server", required=True)
|
|
|
|
|
@click.option("--server-port", "-srp",
|
|
|
|
|
help="Port of the server", required=True)
|
2026-05-19 23:30:00 +08:00
|
|
|
@click.option("--server-property", "-sp", "server_properties", multiple=True,
|
|
|
|
|
help="server.properties override written after the first startup. Use key=value; can be repeated.")
|
2026-05-17 21:14:40 +08:00
|
|
|
def create_server(name, mc_version, build, server_type, snapshot, latest, list_builds, filename, extra_args, java_exec_path,
|
2026-05-19 23:51:55 +08:00
|
|
|
install_java, x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host,
|
|
|
|
|
server_properties):
|
2026-05-17 19:16:00 +08:00
|
|
|
server_dir = Path("servers", name)
|
2026-05-17 21:14:40 +08:00
|
|
|
server_type = server_type.lower()
|
2026-05-19 23:30:00 +08:00
|
|
|
property_overrides = parse_server_property_overrides(server_properties)
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
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] "))
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
if not result.lower() == "y":
|
|
|
|
|
exit("User aborted.")
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
server_dir.mkdir(parents=True, exist_ok=True)
|
2026-05-17 19:16:00 +08:00
|
|
|
|
|
|
|
|
latest_ver = None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
release = True if not snapshot else False
|
2026-05-17 21:14:40 +08:00
|
|
|
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:
|
2026-05-17 19:16:00 +08:00
|
|
|
click.echo("Fetching latest Mojang release version...")
|
|
|
|
|
latest_ver = get_latest_paper_version(release=release)
|
|
|
|
|
builds = get_specific_version_paper_builds(latest_ver)
|
2026-05-17 21:14:40 +08:00
|
|
|
prepare_server_dir()
|
2026-05-17 19:16:00 +08:00
|
|
|
out = download_server_jar(latest_ver, builds[-1], server_dir)
|
|
|
|
|
else:
|
|
|
|
|
if mc_version is None:
|
|
|
|
|
click.echo("The mc-version is not specified. Fetching latest Minecraft release version...")
|
2026-06-06 17:18:25 +08:00
|
|
|
mc_version = get_latest_paper_version(release=release)
|
2026-05-17 19:16:00 +08:00
|
|
|
|
|
|
|
|
if list_builds:
|
|
|
|
|
builds = get_specific_version_paper_builds(mc_version)
|
|
|
|
|
if not builds:
|
|
|
|
|
click.echo(f"No builds found for Paper {mc_version}")
|
|
|
|
|
return
|
|
|
|
|
click.echo(f"Paper {mc_version} builds:")
|
|
|
|
|
click.echo(", ".join(map(str, builds[-20:])))
|
|
|
|
|
click.echo("(Only list latest 20 builds)")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if build:
|
|
|
|
|
click.echo(f"Downloading Paper {mc_version} build {build} ...")
|
2026-05-17 21:14:40 +08:00
|
|
|
prepare_server_dir()
|
2026-05-17 19:16:00 +08:00
|
|
|
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} ...")
|
2026-05-17 21:14:40 +08:00
|
|
|
prepare_server_dir()
|
2026-05-17 19:16:00 +08:00
|
|
|
out = download_latest_build_paper_jar(mc_version, server_dir, filename=filename)
|
|
|
|
|
click.echo(f"Done: {out}")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise click.ClickException(str(e))
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
settings = load_settings()
|
|
|
|
|
|
|
|
|
|
print("There's some information you need to fill for server config.")
|
2026-05-17 19:16:00 +08:00
|
|
|
name = str(input("New server name: ")) if name is None else name
|
2026-05-17 17:16:58 +08:00
|
|
|
desc = str(input("Server description: "))
|
|
|
|
|
|
|
|
|
|
found_exist = False
|
|
|
|
|
for srv in settings["servers"]:
|
|
|
|
|
if name == srv["name"]:
|
|
|
|
|
found_exist = True
|
|
|
|
|
|
|
|
|
|
if found_exist:
|
|
|
|
|
result = str(input("WARNING: Found duplicate server name. Would you like to continue? [y/N] "))
|
|
|
|
|
if not result.lower() == "y":
|
|
|
|
|
exit("User aborted.")
|
2026-05-17 19:16:00 +08:00
|
|
|
return
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-19 23:51:55 +08:00
|
|
|
selected_mc_version = latest_ver if latest_ver is not None else mc_version
|
|
|
|
|
if not custom_args:
|
2026-06-06 17:18:25 +08:00
|
|
|
java_exec_path = resolve_java_executable(
|
|
|
|
|
java_exec_path,
|
|
|
|
|
selected_mc_version,
|
|
|
|
|
release,
|
|
|
|
|
install_java,
|
|
|
|
|
x_memory_maximum,
|
|
|
|
|
)
|
2026-05-19 23:51:55 +08:00
|
|
|
|
2026-05-17 19:16:00 +08:00
|
|
|
args = [
|
2026-05-19 23:51:55 +08:00
|
|
|
java_exec_path or "java",
|
2026-05-17 21:14:40 +08:00
|
|
|
"-Xms{}".format(x_memory_initial),
|
|
|
|
|
"-Xmx{}".format(x_memory_maximum),
|
2026-05-17 19:16:00 +08:00
|
|
|
"-jar",
|
|
|
|
|
out.absolute().as_posix(),
|
|
|
|
|
]
|
2026-05-19 23:30:00 +08:00
|
|
|
if extra_args:
|
|
|
|
|
args.extend(shlex.split(extra_args))
|
|
|
|
|
if nogui:
|
|
|
|
|
args.append("nogui")
|
2026-05-17 19:16:00 +08:00
|
|
|
|
|
|
|
|
if custom_args:
|
2026-05-17 17:16:58 +08:00
|
|
|
print("Will use custom commands as replacement.")
|
2026-05-19 23:30:00 +08:00
|
|
|
args = shlex.split(custom_args)
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
print(f"Server command: {' '.join(args)}")
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
with settings.edit() as s:
|
|
|
|
|
print("Saving...")
|
|
|
|
|
s["servers"].append({
|
|
|
|
|
"name": name,
|
2026-05-17 19:16:00 +08:00
|
|
|
"version": latest_ver if latest_ver is not None else mc_version,
|
2026-05-17 17:16:58 +08:00
|
|
|
"description": desc,
|
2026-05-17 19:16:00 +08:00
|
|
|
"args": args,
|
|
|
|
|
"workDir": server_dir.absolute().as_posix(),
|
|
|
|
|
"port": server_port,
|
|
|
|
|
"host": server_host,
|
2026-05-17 17:16:58 +08:00
|
|
|
"enable": True,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
print("Done")
|
|
|
|
|
|
2026-05-18 23:29:58 +08:00
|
|
|
class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
self.clients = []
|
|
|
|
|
self.clients_lock = threading.Lock()
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
class SocketServer:
|
2026-05-17 21:14:40 +08:00
|
|
|
def __init__(self, host, port, enable_tls, certfile: Path, keyfile: Path, password: str = ""):
|
2026-05-17 17:16:58 +08:00
|
|
|
self.logger = logging.getLogger("SocketServer")
|
2026-05-17 21:14:40 +08:00
|
|
|
self.logger.setLevel(logging.INFO)
|
2026-05-17 17:16:58 +08:00
|
|
|
self.stdout_handler = logging.StreamHandler(sys.stdout)
|
2026-05-17 21:14:40 +08:00
|
|
|
self.stdout_handler.setFormatter(logging.Formatter("%(levelname)s:%(message)s"))
|
|
|
|
|
if not self.logger.handlers:
|
|
|
|
|
self.logger.addHandler(self.stdout_handler)
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
# flags
|
|
|
|
|
self.stop_event = threading.Event()
|
|
|
|
|
|
|
|
|
|
# Server
|
|
|
|
|
self.host = host
|
|
|
|
|
self.port = port
|
|
|
|
|
|
2026-05-18 23:29:58 +08:00
|
|
|
self.tcp_server: ThreadedTCPServer | None = None
|
2026-05-17 17:16:58 +08:00
|
|
|
self._tcp_thread: threading.Thread | None = None
|
|
|
|
|
|
|
|
|
|
self._log_subscribers: set[queue.Queue] = set()
|
|
|
|
|
self._sub_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
self.command_receivers = {}
|
2026-05-17 19:16:00 +08:00
|
|
|
self.enable_tls = enable_tls
|
|
|
|
|
self.certfile = certfile
|
|
|
|
|
self.keyfile = keyfile
|
2026-05-17 21:14:40 +08:00
|
|
|
self.password = "" if password is None else str(password)
|
|
|
|
|
self._ssl_context = None
|
|
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
)
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
if self.enable_tls:
|
|
|
|
|
if not self.certfile.exists():
|
|
|
|
|
raise FileNotFoundError("Certfile not found")
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
if not self.keyfile.exists():
|
|
|
|
|
raise FileNotFoundError("Keyfile not found")
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
|
|
|
self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
self.commands = {}
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
# -------------------------
|
|
|
|
|
# Socket Server
|
|
|
|
|
# -------------------------
|
|
|
|
|
def publish_log(self, server_name: str, line: str | None = None):
|
|
|
|
|
if line is None:
|
2026-05-18 23:29:58 +08:00
|
|
|
log_server = None
|
2026-05-17 17:16:58 +08:00
|
|
|
message = server_name
|
|
|
|
|
else:
|
2026-05-18 23:29:58 +08:00
|
|
|
log_server = server_name
|
2026-05-17 17:16:58 +08:00
|
|
|
message = f"[{server_name}] {line}"
|
|
|
|
|
|
|
|
|
|
with self._sub_lock:
|
|
|
|
|
for q in list(self._log_subscribers):
|
|
|
|
|
try:
|
2026-05-18 23:29:58 +08:00
|
|
|
q.put_nowait((log_server, message))
|
2026-05-17 17:16:58 +08:00
|
|
|
except queue.Full:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def subscribe_logs(self) -> queue.Queue:
|
|
|
|
|
q = queue.Queue(maxsize=2000)
|
|
|
|
|
with self._sub_lock:
|
|
|
|
|
self._log_subscribers.add(q)
|
|
|
|
|
return q
|
|
|
|
|
|
|
|
|
|
def unsubscribe_logs(self, q: queue.Queue):
|
|
|
|
|
with self._sub_lock:
|
|
|
|
|
self._log_subscribers.discard(q)
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
def _format_command_help(self, command_map):
|
|
|
|
|
return "\n".join(
|
2026-05-18 23:29:58 +08:00
|
|
|
f"[SYS] {command}: {description}"
|
2026-05-17 21:14:40 +08:00
|
|
|
for command, description in command_map.items()
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-18 23:29:58 +08:00
|
|
|
@staticmethod
|
|
|
|
|
def broadcast_all(socks, message):
|
|
|
|
|
for sock in list(socks):
|
|
|
|
|
try:
|
|
|
|
|
sock.sendall((message + "\n").encode("utf-8"))
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
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",
|
2026-05-19 23:30:00 +08:00
|
|
|
"__stop_all": "Stop all servers and close the socket server (TLS Support must be enabled)",
|
2026-05-17 21:14:40 +08:00
|
|
|
"__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",
|
2026-05-19 23:30:00 +08:00
|
|
|
"__system_info": "Show system information",
|
2026-05-17 21:14:40 +08:00
|
|
|
}
|
|
|
|
|
attached_commands = {
|
|
|
|
|
"__status": "Show target server process status",
|
|
|
|
|
"__stop": "Stop the target server process",
|
|
|
|
|
"__start": "Start the target server process",
|
2026-05-18 23:29:58 +08:00
|
|
|
"__restart": "Restart the target server process",
|
|
|
|
|
"__info": "Show server information",
|
2026-05-17 21:14:40 +08:00
|
|
|
"<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(
|
2026-05-18 23:29:58 +08:00
|
|
|
f"[SYS] - {server_name}" for server_name in server_names
|
2026-05-17 21:14:40 +08:00
|
|
|
)
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
def handler_command(self, command: str):
|
|
|
|
|
self.logger.info("On...no co", command)
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
@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
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-17 19:16:00 +08:00
|
|
|
try:
|
2026-05-17 21:14:40 +08:00
|
|
|
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
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-18 23:29:58 +08:00
|
|
|
class TCPServer(ThreadedTCPServer):
|
2026-05-17 17:16:58 +08:00
|
|
|
allow_reuse_address = True
|
|
|
|
|
daemon_threads = True
|
|
|
|
|
|
|
|
|
|
def __init__(self, server_address, RequestHandlerClass):
|
|
|
|
|
super().__init__(server_address, RequestHandlerClass)
|
|
|
|
|
self.manager = manager
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
def get_request(self):
|
|
|
|
|
sock, addr = super().get_request()
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
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
|
2026-05-17 19:16:00 +08:00
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
class Handler(socketserver.BaseRequestHandler):
|
|
|
|
|
current_server_record = {
|
|
|
|
|
}
|
|
|
|
|
def setup(self):
|
|
|
|
|
mgr: Server = self.server.manager
|
|
|
|
|
|
|
|
|
|
mgr.logger.info(f"[SYS] Client from {self.client_address[0]}:{self.client_address[1]} connected,")
|
2026-05-18 23:29:58 +08:00
|
|
|
with self.server.clients_lock:
|
|
|
|
|
self.server.clients.append(self.request)
|
|
|
|
|
|
|
|
|
|
def finish(self):
|
|
|
|
|
with self.server.clients_lock:
|
|
|
|
|
try:
|
|
|
|
|
self.server.clients.remove(self.request)
|
|
|
|
|
except ValueError:
|
|
|
|
|
pass
|
|
|
|
|
self.current_server_record.pop(
|
|
|
|
|
f"{self.client_address[0]}:{self.client_address[1]}",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
super().finish()
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
def handle(self):
|
|
|
|
|
mgr: Server = self.server.manager
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
authenticated = not mgr.requires_password()
|
|
|
|
|
log_q = None
|
2026-05-17 17:16:58 +08:00
|
|
|
stop_evt = threading.Event()
|
|
|
|
|
|
|
|
|
|
def push_logs():
|
|
|
|
|
while not stop_evt.is_set():
|
|
|
|
|
try:
|
2026-05-18 23:29:58 +08:00
|
|
|
log_server, line = log_q.get(timeout=0.5)
|
2026-05-17 17:16:58 +08:00
|
|
|
except Exception:
|
|
|
|
|
continue
|
2026-05-18 23:29:58 +08:00
|
|
|
current_server = self.current_server_record.get(
|
|
|
|
|
f"{self.client_address[0]}:{self.client_address[1]}",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
# Only push client connected server's log
|
|
|
|
|
if log_server is not None and log_server != current_server:
|
|
|
|
|
continue
|
2026-05-17 17:16:58 +08:00
|
|
|
try:
|
|
|
|
|
self.request.sendall(f"[LOG] {line}\n".encode("utf-8"))
|
|
|
|
|
except OSError:
|
|
|
|
|
break
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
t = None
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
try:
|
2026-05-17 21:14:40 +08:00
|
|
|
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")
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
buf = b""
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
data = self.request.recv(4096)
|
|
|
|
|
if not data:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
buf += data
|
|
|
|
|
while b"\n" in buf:
|
|
|
|
|
raw, buf = buf.split(b"\n", 1)
|
|
|
|
|
cmd = raw.decode("utf-8", errors="replace").strip()
|
|
|
|
|
|
|
|
|
|
if not cmd:
|
|
|
|
|
continue
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
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
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
current_server = self.current_server_record.get(
|
|
|
|
|
f"{self.client_address[0]}:{self.client_address[1]}",
|
|
|
|
|
None)
|
|
|
|
|
|
|
|
|
|
mgr.logger.info(f"[SYS] Client from {self.client_address[0]}:{self.client_address[1]} send command \"{cmd}\".")
|
|
|
|
|
|
|
|
|
|
ok = None
|
|
|
|
|
message = None
|
|
|
|
|
|
|
|
|
|
if cmd.startswith("__"):
|
2026-05-17 21:14:40 +08:00
|
|
|
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":
|
2026-05-17 17:16:58 +08:00
|
|
|
# Exit socket
|
|
|
|
|
self.request.sendall(b"[SYS] bye\n")
|
|
|
|
|
return
|
2026-05-17 21:14:40 +08:00
|
|
|
elif cmd == "__stop_all":
|
2026-05-19 23:30:00 +08:00
|
|
|
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
|
2026-05-17 21:14:40 +08:00
|
|
|
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)
|
2026-05-17 17:16:58 +08:00
|
|
|
elif cmd.startswith("__c"):
|
|
|
|
|
match = re.match(r"^__c\s+(.+)$", cmd)
|
|
|
|
|
if match:
|
|
|
|
|
server_name = match.group(1).strip()
|
|
|
|
|
receiver = mgr.get_command_receiver(server_name)
|
|
|
|
|
else:
|
|
|
|
|
server_name = None
|
|
|
|
|
receiver = None
|
|
|
|
|
|
|
|
|
|
if receiver is not None:
|
|
|
|
|
self.current_server_record[f"{self.client_address[0]}:{self.client_address[1]}"] = server_name
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
f"[SYS] Connected to server \"{server_name}\" shell.\n".encode(
|
|
|
|
|
"utf-8")
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
f"[SYS:ERR] Target server \"{server_name}\" does not exist.\n".encode(
|
|
|
|
|
"utf-8")
|
|
|
|
|
)
|
|
|
|
|
elif cmd == "__d":
|
|
|
|
|
self.current_server_record[f"{self.client_address[0]}:{self.client_address[1]}"] = None
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
f"[SYS] Disconnected from current server \"{current_server}\"'s shell.\n".encode(
|
|
|
|
|
"utf-8")
|
|
|
|
|
)
|
2026-05-19 23:30:00 +08:00
|
|
|
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"))
|
2026-05-17 17:16:58 +08:00
|
|
|
else:
|
|
|
|
|
if current_server is not None:
|
|
|
|
|
receiver = mgr.get_command_receiver(current_server)
|
|
|
|
|
func = receiver.get("receiver") if receiver else None
|
|
|
|
|
|
|
|
|
|
if callable(func):
|
|
|
|
|
ok, message = func(cmd)
|
|
|
|
|
else:
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
"[SYS:ERR] Target server's receiver are not callable.\n".encode(
|
|
|
|
|
"utf-8")
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# "target server" is Minecraft server
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
"[SYS:ERR] You are not connected to any target server.\n".encode("utf-8")
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
if current_server is not None:
|
|
|
|
|
receiver = mgr.get_command_receiver(current_server)
|
|
|
|
|
func = receiver.get("processReceiver") if receiver else None
|
|
|
|
|
|
|
|
|
|
if callable(func):
|
|
|
|
|
ok, message = func(cmd)
|
|
|
|
|
else:
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
"[SYS:ERR] Target server's processReceiver are not callable.\n".encode(
|
|
|
|
|
"utf-8")
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# "target server" is Minecraft server
|
|
|
|
|
self.request.sendall(
|
|
|
|
|
"[SYS:ERR] You are not connected to any target server.\n".encode("utf-8")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if ok is not None:
|
|
|
|
|
msg = f"[OK] Command received. {cmd}\n" if ok else "[ERR] An error occurred\n"
|
|
|
|
|
if message is not None:
|
|
|
|
|
msg = msg + message + "\n"
|
|
|
|
|
self.request.sendall(msg.encode("utf-8"))
|
2026-05-17 21:14:40 +08:00
|
|
|
except (ConnectionResetError, OSError):
|
2026-05-17 17:16:58 +08:00
|
|
|
mgr.logger.info(
|
|
|
|
|
"[SYS] Client disconnected. From {}:{}".format(self.client_address[0], self.client_address[1]))
|
|
|
|
|
finally:
|
|
|
|
|
stop_evt.set()
|
2026-05-17 21:14:40 +08:00
|
|
|
if log_q is not None:
|
|
|
|
|
mgr.unsubscribe_logs(log_q)
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
return TCPServer((self.host, self.port), Handler)
|
|
|
|
|
|
|
|
|
|
def start_socket_server(self):
|
2026-05-18 23:29:58 +08:00
|
|
|
if self.tcp_server:
|
2026-05-17 17:16:58 +08:00
|
|
|
print("[SOCK] already running")
|
|
|
|
|
return
|
|
|
|
|
|
2026-05-18 23:29:58 +08:00
|
|
|
self.tcp_server = self._build_tcp_server()
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
def loop():
|
|
|
|
|
self.logger.info(f"[SOCK] listening on {self.host}:{self.port}")
|
2026-05-18 23:29:58 +08:00
|
|
|
self.tcp_server.serve_forever(poll_interval=0.5)
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
self._tcp_thread = threading.Thread(target=loop, daemon=True)
|
|
|
|
|
self._tcp_thread.start()
|
|
|
|
|
|
|
|
|
|
def stop_socket_server(self):
|
2026-05-18 23:29:58 +08:00
|
|
|
if not self.tcp_server:
|
2026-05-17 17:16:58 +08:00
|
|
|
return
|
|
|
|
|
self.logger.info("[SOCK] shutting down")
|
2026-05-18 23:29:58 +08:00
|
|
|
self.broadcast_all(self.tcp_server.clients, "[SYS] Server stopping...")
|
|
|
|
|
self.tcp_server.shutdown()
|
|
|
|
|
self.tcp_server.server_close()
|
|
|
|
|
self.tcp_server = None
|
2026-05-17 17:16:58 +08:00
|
|
|
if self._tcp_thread and self._tcp_thread.is_alive():
|
|
|
|
|
self._tcp_thread.join(timeout=2)
|
|
|
|
|
self._tcp_thread = None
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
def register_command_receiver(self, server_name, receiver, process_receiver, log_reader=None):
|
2026-05-17 17:16:58 +08:00
|
|
|
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,
|
2026-05-17 21:14:40 +08:00
|
|
|
"logReader": log_reader,
|
2026-05-17 17:16:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def get_command_receiver(self, server_name):
|
|
|
|
|
if server_name in self.command_receivers.keys():
|
|
|
|
|
return self.command_receivers[server_name]
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
class Server:
|
2026-05-17 19:16:00 +08:00
|
|
|
def __init__(self, name, version, description, args, work_dir, port, host, enable):
|
2026-05-17 17:16:58 +08:00
|
|
|
self._stdout_thread = None
|
|
|
|
|
|
|
|
|
|
# Process
|
|
|
|
|
self.proc: subprocess.Popen | None = None
|
|
|
|
|
self.proc_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
self.running = False
|
|
|
|
|
self.stopping = False
|
|
|
|
|
|
|
|
|
|
# logger
|
|
|
|
|
self.logger = None
|
|
|
|
|
# Ensure all servers name are not duplicated
|
|
|
|
|
if f"Server.{name}" in logging.root.manager.loggerDict:
|
|
|
|
|
index = 1
|
|
|
|
|
name = f"Server.{name}_1"
|
|
|
|
|
while name not in logging.root.manager.loggerDict:
|
|
|
|
|
name = f"Server.{name}_{index}"
|
|
|
|
|
|
|
|
|
|
self.logger = logging.getLogger(name)
|
|
|
|
|
self.logger.setLevel(logging.INFO)
|
|
|
|
|
self.stdout_handler = logging.StreamHandler(sys.stdout)
|
2026-05-17 19:16:00 +08:00
|
|
|
self.stdout_handler.setFormatter(logging.Formatter(f"[%(asctime)s:%(levelname)s:{name}]: %(message)s"))
|
2026-05-17 17:16:58 +08:00
|
|
|
self.logger.addHandler(self.stdout_handler)
|
|
|
|
|
|
|
|
|
|
# Values from config
|
|
|
|
|
self.name = name
|
|
|
|
|
self.version = version
|
|
|
|
|
self.description = description
|
2026-05-17 19:16:00 +08:00
|
|
|
self.args = args
|
2026-05-17 17:16:58 +08:00
|
|
|
self.work_dir = work_dir
|
|
|
|
|
self.port = port
|
|
|
|
|
self.host = host
|
|
|
|
|
self.enable = enable
|
2026-05-17 21:14:40 +08:00
|
|
|
self.log_dir = Path(self.work_dir) / LOG_DIR_NAME
|
|
|
|
|
self.log_path = self.log_dir / SERVERJAR_LOG_FILE
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
self.log_queue = queue.Queue() # stdout lines
|
|
|
|
|
self._threads: list[threading.Thread] = []
|
|
|
|
|
self.broadcaster = None
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
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}")
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
def start_process(self):
|
|
|
|
|
self.logger.info("Starting process...")
|
|
|
|
|
|
|
|
|
|
with self.proc_lock:
|
|
|
|
|
if self.proc and self.proc.poll() is None:
|
|
|
|
|
self.logger.warning("[PROC] already running, skip")
|
|
|
|
|
return
|
|
|
|
|
|
2026-05-17 19:16:00 +08:00
|
|
|
if len(self.args) == 0:
|
|
|
|
|
raise Exception("[SYS] No arguments provided")
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-17 19:16:00 +08:00
|
|
|
self.logger.info("[PROC] spawning: %s", " ".join(self.args))
|
2026-05-17 17:16:58 +08:00
|
|
|
self.proc = subprocess.Popen(
|
2026-05-17 19:16:00 +08:00
|
|
|
self.args,
|
2026-05-17 17:16:58 +08:00
|
|
|
stdin=subprocess.PIPE,
|
|
|
|
|
stdout=subprocess.PIPE,
|
|
|
|
|
stderr=subprocess.STDOUT,
|
|
|
|
|
text=True,
|
|
|
|
|
bufsize=1,
|
|
|
|
|
cwd=self.work_dir,
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
errors="replace"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.logger.info(f"[PROC] Process spawned. PID = {self.proc.pid}")
|
|
|
|
|
|
|
|
|
|
self._stdout_thread = threading.Thread(target=self._stdout_reader_loop, daemon=True)
|
|
|
|
|
self._stdout_thread.start()
|
|
|
|
|
|
|
|
|
|
def publish_log(self, line):
|
|
|
|
|
if self.broadcaster is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self.broadcaster(self.name, line)
|
|
|
|
|
|
|
|
|
|
def register_broadcaster(self, broadcaster):
|
|
|
|
|
self.broadcaster = broadcaster
|
|
|
|
|
|
|
|
|
|
def _stdout_reader_loop(self):
|
|
|
|
|
self.logger.info("[PROC] stdout reader started")
|
2026-05-19 23:30:00 +08:00
|
|
|
while True:
|
2026-05-17 17:16:58 +08:00
|
|
|
with self.proc_lock:
|
|
|
|
|
proc = self.proc
|
|
|
|
|
out = proc.stdout if proc else None
|
|
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
if not proc or not out:
|
2026-05-17 17:16:58 +08:00
|
|
|
self.logger.info("[PROC] process ended / stdout closed")
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
line = out.readline()
|
|
|
|
|
if not line:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
line = line.rstrip("\n")
|
|
|
|
|
self.logger.info("[PROC] %s", line)
|
2026-05-17 21:14:40 +08:00
|
|
|
try:
|
|
|
|
|
self._append_saved_log(line)
|
|
|
|
|
except OSError as e:
|
|
|
|
|
self.logger.warning("[PROC] unable to save log: %s", e)
|
2026-05-17 17:16:58 +08:00
|
|
|
self.publish_log(line)
|
|
|
|
|
|
|
|
|
|
def send_command(self, command: str) -> bool:
|
|
|
|
|
with self.proc_lock:
|
|
|
|
|
if not self.proc or self.proc.poll() is not None:
|
|
|
|
|
return False
|
|
|
|
|
if not self.proc.stdin:
|
|
|
|
|
return False
|
|
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
try:
|
|
|
|
|
self.proc.stdin.write(command + "\n")
|
|
|
|
|
self.proc.stdin.flush()
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
2026-05-17 17:16:58 +08:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def stop_process(self, timeout: float = 10.0):
|
|
|
|
|
with self.proc_lock:
|
|
|
|
|
proc = self.proc
|
|
|
|
|
|
|
|
|
|
if not proc:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Minecraft only
|
|
|
|
|
self.send_command("stop")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
proc.wait(timeout=timeout)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
proc.kill()
|
|
|
|
|
proc.wait()
|
|
|
|
|
|
|
|
|
|
with self.proc_lock:
|
|
|
|
|
self.proc = None
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# Manager lifecycle
|
|
|
|
|
# -------------------------
|
|
|
|
|
def start(self):
|
|
|
|
|
if self.running:
|
|
|
|
|
return
|
|
|
|
|
self.running = True
|
|
|
|
|
self.stopping = False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self.start_process()
|
|
|
|
|
except Exception:
|
|
|
|
|
self.running = False
|
|
|
|
|
self.stopping = False
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
if not self.running:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self.stopping = True
|
|
|
|
|
|
|
|
|
|
self.stop_process()
|
2026-05-19 23:30:00 +08:00
|
|
|
self.running = False
|
2026-05-17 17:16:58 +08:00
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
def restart(self):
|
|
|
|
|
if self.running:
|
|
|
|
|
self.stop()
|
|
|
|
|
|
|
|
|
|
self.start()
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
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):
|
2026-05-17 21:14:40 +08:00
|
|
|
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"
|
2026-05-18 23:29:58 +08:00
|
|
|
"__info: Show target server process status\n"
|
2026-05-17 21:14:40 +08:00
|
|
|
"<minecraft command>: Send command to the target Minecraft server process")
|
|
|
|
|
elif command == "__status":
|
2026-05-17 17:16:58 +08:00
|
|
|
with self.proc_lock:
|
|
|
|
|
pid = self.proc.pid if self.proc else None
|
|
|
|
|
return True, (f"processAlive: {self.is_process_alive()}\n"
|
|
|
|
|
f"processPID: {pid}\n"
|
|
|
|
|
f"workdir: {self.work_dir}")
|
|
|
|
|
elif command == "__stop":
|
|
|
|
|
self.stop()
|
|
|
|
|
return True, f"Server \"{self.name}\" process stopped"
|
|
|
|
|
elif command == "__start":
|
|
|
|
|
self.start()
|
|
|
|
|
return True, f"Server \"{self.name}\" process started"
|
2026-05-18 23:29:58 +08:00
|
|
|
elif command == "__info":
|
|
|
|
|
return True, (f"serverName: {self.name}\n"
|
2026-05-19 23:30:00 +08:00
|
|
|
f"serverPID: {self.proc.pid if self.proc else None}\n"
|
2026-05-18 23:29:58 +08:00
|
|
|
f"description: {self.description}\n"
|
|
|
|
|
f"arguments: {self.args}\n"
|
|
|
|
|
f"host: {self.host}\n"
|
|
|
|
|
f"port: {self.port}\n"
|
|
|
|
|
f"version: {self.version}\n"
|
|
|
|
|
f"status: {"Running" if self.is_process_alive() else "Died"}")
|
2026-05-17 17:16:58 +08:00
|
|
|
else:
|
|
|
|
|
return False, f"Unknown command: {command}"
|
|
|
|
|
|
|
|
|
|
def process_command_receiver(self, command):
|
|
|
|
|
with self.proc_lock:
|
|
|
|
|
if not self.proc or self.proc.poll() is not None:
|
|
|
|
|
return False, "Process is not running."
|
|
|
|
|
if not self.proc.stdin:
|
|
|
|
|
return False, "Process standard input are not available."
|
|
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
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}"
|
2026-05-17 17:16:58 +08:00
|
|
|
return True, "Command sent."
|
|
|
|
|
|
|
|
|
|
def load_all_server_from_settings(settings: FileSettings):
|
|
|
|
|
servers = []
|
|
|
|
|
with settings.edit() as s:
|
|
|
|
|
for server_conf in s.get("servers", []):
|
|
|
|
|
servers.append(Server(
|
|
|
|
|
name=server_conf.get("name"),
|
|
|
|
|
version=server_conf.get("version"),
|
|
|
|
|
description=server_conf.get("description"),
|
2026-05-17 19:16:00 +08:00
|
|
|
args=server_conf.get("args",[]),
|
2026-05-17 17:16:58 +08:00
|
|
|
work_dir=server_conf.get("workDir"),
|
|
|
|
|
port=server_conf.get("port"),
|
|
|
|
|
host=server_conf.get("host"),
|
|
|
|
|
enable=server_conf.get("enable")
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
return servers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
2026-05-17 21:14:40 +08:00
|
|
|
@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):
|
2026-05-17 17:16:58 +08:00
|
|
|
logger = logging.getLogger(__name__)
|
2026-05-17 19:16:00 +08:00
|
|
|
formatter = logging.Formatter('[%(asctime)s:%(levelname)s:runServer]: %(message)s')
|
2026-05-17 17:16:58 +08:00
|
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
|
|
|
stdout_handler.setFormatter(formatter)
|
|
|
|
|
logger.addHandler(stdout_handler)
|
|
|
|
|
|
|
|
|
|
# Server
|
|
|
|
|
settings = load_settings()
|
|
|
|
|
servers = load_all_server_from_settings(settings)
|
|
|
|
|
logger.info("{} servers available".format(len(servers)))
|
|
|
|
|
|
|
|
|
|
# Socket
|
|
|
|
|
socket_server = SocketServer(settings.get("socketServerHostname", "127.0.0.1"),
|
2026-05-17 19:16:00 +08:00
|
|
|
settings.get("socketServerPort", 25560),
|
|
|
|
|
settings.get("enableTLSSupport", True),
|
|
|
|
|
Path(settings.get("socketServerCertfile", "data/server.crt")),
|
2026-05-17 21:14:40 +08:00
|
|
|
Path(settings.get( "socketServerKeyfile", "data/server.key")),
|
|
|
|
|
settings.get("socketServerPassword", ""))
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
# Flags
|
|
|
|
|
stop_once = False
|
|
|
|
|
|
|
|
|
|
def cleanup():
|
|
|
|
|
nonlocal stop_once
|
|
|
|
|
if stop_once:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
stop_once = True
|
|
|
|
|
|
|
|
|
|
for s in servers:
|
|
|
|
|
logger.info("Stopping server {}...".format(s.name))
|
|
|
|
|
s.stop()
|
|
|
|
|
|
|
|
|
|
logger.info("Closing socket server...")
|
|
|
|
|
socket_server.stop_socket_server()
|
|
|
|
|
logger.info("Done")
|
|
|
|
|
|
|
|
|
|
def sigint_handler(signum, frame):
|
|
|
|
|
logger.info("Caught SIGINT, exiting...")
|
|
|
|
|
cleanup()
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
signal.signal(
|
|
|
|
|
signal.SIGINT,
|
|
|
|
|
sigint_handler
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Boot server
|
2026-05-17 19:16:00 +08:00
|
|
|
if len(servers) != 0:
|
|
|
|
|
logger.info("Starting socket server")
|
|
|
|
|
socket_server.start_socket_server()
|
|
|
|
|
|
|
|
|
|
logger.info("Starting server")
|
|
|
|
|
for server in servers:
|
|
|
|
|
if server.enable:
|
|
|
|
|
server.register_broadcaster(socket_server.publish_log)
|
|
|
|
|
socket_server.register_command_receiver(server.name, server.command_receiver,
|
2026-05-17 21:14:40 +08:00
|
|
|
server.process_command_receiver,
|
|
|
|
|
server.log_reader)
|
2026-05-17 19:16:00 +08:00
|
|
|
try:
|
|
|
|
|
server.start()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Server {server.name} failed to start: {e}")
|
|
|
|
|
else:
|
|
|
|
|
logger.info(f"Server {server.name} started.")
|
2026-05-17 17:16:58 +08:00
|
|
|
else:
|
2026-05-17 19:16:00 +08:00
|
|
|
logger.info(f"Server {server.name} is disabled.")
|
|
|
|
|
try:
|
|
|
|
|
stop = False
|
|
|
|
|
|
|
|
|
|
while not stop:
|
|
|
|
|
if socket_server.stop_event.is_set():
|
|
|
|
|
logger.info("Remote stop event triggered. Stopping...")
|
|
|
|
|
cleanup()
|
|
|
|
|
stop = True
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for server in list(servers):
|
|
|
|
|
if server.running and not server.is_process_alive():
|
|
|
|
|
logger.info(f"Server {server.name} stopped.")
|
|
|
|
|
server.running = False
|
|
|
|
|
|
2026-05-17 21:14:40 +08:00
|
|
|
if servers and not any(server.running for server in servers) and not keep_running:
|
2026-05-17 19:16:00 +08:00
|
|
|
cleanup()
|
|
|
|
|
stop = True
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
time.sleep(0.5)
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
logger.info("Stopping server...")
|
|
|
|
|
cleanup()
|
|
|
|
|
else:
|
|
|
|
|
logger.info("No work to do.")
|
|
|
|
|
|
|
|
|
|
settings.save()
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
logger.info("Stopped!")
|
|
|
|
|
|
2026-05-17 19:16:00 +08:00
|
|
|
@main.command()
|
2026-05-19 23:30:00 +08:00
|
|
|
@click.option("--hostname", default="localhost", show_default=True, help="Hostname or IP address of the server.")
|
|
|
|
|
def generate_tls_key(hostname: str):
|
2026-05-17 19:16:00 +08:00
|
|
|
print("Generating TLS key...")
|
|
|
|
|
key = rsa.generate_private_key(
|
|
|
|
|
public_exponent=65537,
|
|
|
|
|
key_size=2048,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
subject = issuer = x509.Name([
|
|
|
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"),
|
|
|
|
|
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"),
|
2026-05-19 23:30:00 +08:00
|
|
|
x509.NameAttribute(NameOID.COMMON_NAME, u"{}".format(hostname)),
|
2026-05-17 19:16:00 +08:00
|
|
|
])
|
|
|
|
|
|
2026-05-19 23:30:00 +08:00
|
|
|
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)))
|
|
|
|
|
|
2026-05-17 19:16:00 +08:00
|
|
|
cert = x509.CertificateBuilder().subject_name(
|
|
|
|
|
subject
|
|
|
|
|
).issuer_name(
|
|
|
|
|
issuer
|
|
|
|
|
).public_key(
|
|
|
|
|
key.public_key()
|
|
|
|
|
).serial_number(
|
|
|
|
|
x509.random_serial_number()
|
|
|
|
|
).not_valid_before(
|
|
|
|
|
datetime.datetime.now(datetime.UTC)
|
|
|
|
|
).not_valid_after(
|
|
|
|
|
# Valid for 1 year
|
|
|
|
|
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
|
|
|
|
|
).add_extension(
|
2026-05-19 23:30:00 +08:00
|
|
|
x509.SubjectAlternativeName(alt_names),
|
2026-05-17 19:16:00 +08:00
|
|
|
critical=False,
|
|
|
|
|
).sign(key, hashes.SHA256())
|
|
|
|
|
|
|
|
|
|
private_key = Path("data/server-private.pem")
|
|
|
|
|
public_key = Path("data/server-public.pem")
|
|
|
|
|
private_key.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
public_key.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
with private_key.open("wb") as f:
|
|
|
|
|
f.write(key.private_bytes(
|
|
|
|
|
encoding=serialization.Encoding.PEM,
|
|
|
|
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
|
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
with public_key.open("wb") as f:
|
|
|
|
|
f.write(cert.public_bytes(serialization.Encoding.PEM))
|
|
|
|
|
|
|
|
|
|
print("Private key saved to {}".format(private_key))
|
|
|
|
|
print("Public key saved to {}".format(public_key))
|
|
|
|
|
print("Done.")
|
|
|
|
|
|
2026-05-17 17:16:58 +08:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|