Add connection history and some automatic server create process.

This commit is contained in:
2026-05-19 23:30:00 +08:00
parent 8bd09688bb
commit c50603a47f
5 changed files with 444 additions and 28 deletions
+158 -23
View File
@@ -3,6 +3,7 @@ ServerJar
Wei - 2026
"""
import platform
import re
import signal
import socketserver
@@ -17,11 +18,14 @@ import time
import datetime
import base64
import hmac
import ipaddress
import shlex
from pathlib import Path
import click
import yaml
from utils.common import get_latest_version_minecraft, get_specific_version_paper_builds, \
download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar
download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar, \
read_server_properties, save_server_properties, activate_eula_file
from utils.file_settings import FileSettings
from utils.file_settings import required_list, required_value
from cryptography import x509
@@ -89,6 +93,86 @@ def load_settings():
return s
def run_initial_server_start(args, server_dir: Path, timeout: float = 120.0):
click.echo("Starting server once to generate initial files...")
proc = subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
cwd=server_dir,
encoding="utf-8",
errors="replace",
)
try:
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
click.echo("Initial start is still running. Sending stop command...")
try:
if proc.stdin:
proc.stdin.write("stop\n")
proc.stdin.flush()
except OSError:
pass
try:
output, _ = proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
click.echo("Server did not stop in time. Killing initial start process...")
proc.kill()
output, _ = proc.communicate()
if output:
for line in output.splitlines():
click.echo(f"[BOOT] {line}")
return proc.returncode
def configure_initial_server_files(server_dir: Path, server_name: str, server_host: str, server_port: str,
property_overrides=None):
try:
properties = read_server_properties(server_dir)
except FileNotFoundError as e:
click.echo(click.style(f"Unable to read server.properties: {e}", fg="yellow"))
else:
property_values = {
"server-name": str(server_name),
"server-ip": str(server_host),
"server-port": str(server_port),
}
if property_overrides:
property_values.update(property_overrides)
for key, value in property_values.items():
properties[key] = (value, {})
save_server_properties(properties, server_dir)
click.echo("server.properties updated.")
try:
if activate_eula_file(server_dir):
click.echo("EULA activated.")
else:
click.echo(click.style("EULA was not activated.", fg="yellow"))
except FileNotFoundError as e:
click.echo(click.style(f"Unable to activate EULA: {e}", fg="yellow"))
def parse_server_property_overrides(values):
overrides = {}
for value in values:
key, separator, property_value = value.partition("=")
key = key.strip()
if separator == "" or key == "":
raise click.ClickException(f"Invalid server property override: {value}. Use key=value.")
overrides[key] = property_value.strip()
return overrides
@main.command()
@click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name")
@click.option("--mc-version", "-m",
@@ -126,10 +210,13 @@ def load_settings():
help="Hostname of the server", required=True)
@click.option("--server-port", "-srp",
help="Port of the server", required=True)
@click.option("--server-property", "-sp", "server_properties", multiple=True,
help="server.properties override written after the first startup. Use key=value; can be repeated.")
def create_server(name, mc_version, build, server_type, snapshot, latest, list_builds, filename, extra_args, java_exec_path,
x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host):
x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host, server_properties):
server_dir = Path("servers", name)
server_type = server_type.lower()
property_overrides = parse_server_property_overrides(server_properties)
def prepare_server_dir():
if server_dir.exists():
@@ -210,22 +297,32 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b
exit("User aborted.")
return
extra_args += "nogui" if nogui else ""
args = [
java_exec_path,
"-Xms{}".format(x_memory_initial),
"-Xmx{}".format(x_memory_maximum),
"-jar",
out.absolute().as_posix(),
extra_args,
]
if extra_args:
args.extend(shlex.split(extra_args))
if nogui:
args.append("nogui")
if custom_args:
print("Will use custom commands as replacement.")
args = custom_args
args = shlex.split(custom_args)
print(f"Server command: {' '.join(args)}")
try:
return_code = run_initial_server_start(args, server_dir)
if return_code not in (0, None):
click.echo(click.style(f"Initial server start exited with code {return_code}.", fg="yellow"))
configure_initial_server_files(server_dir, name, server_host, server_port, property_overrides)
except Exception as e:
raise click.ClickException(f"Unable to bootstrap server files: {e}") from e
with settings.edit() as s:
print("Saving...")
s["servers"].append({
@@ -292,6 +389,8 @@ class SocketServer:
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
self.commands = {}
# -------------------------
# Socket Server
# -------------------------
@@ -339,11 +438,12 @@ class SocketServer:
"__help": "Display this help message",
"__list": "List available server shells",
"__exit": "Close the current socket connection",
"__stop_all": "Stop all servers and close the socket server",
"__stop_all": "Stop all servers and close the socket server (TLS Support must be enabled)",
"__c <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",
"__system_info": "Show system information",
}
attached_commands = {
"__status": "Show target server process status",
@@ -597,12 +697,18 @@ class SocketServer:
self.request.sendall(b"[SYS] bye\n")
return
elif cmd == "__stop_all":
self.request.sendall(
f"[SYS] Stopping all servers...bye\n".encode("utf-8")
)
mgr.broadcast_all(mgr.tcp_server.clients, "[SYS] Server stopping... (Stop by remote client)")
mgr.stop_event.set()
return
if not mgr.enable_tls:
self.request.sendall(
f"[SYS] Command \"__stop_all\" only works when tls support is enabled.\n".encode("utf-8")
)
else:
self.request.sendall(
f"[SYS] Stopping all servers...bye\n".encode("utf-8")
)
mgr.broadcast_all(mgr.tcp_server.clients,
"[SYS] Server stopping... (Stop by remote client)")
mgr.stop_event.set()
return
elif cmd.startswith("__sync_log"):
mgr.send_sync_log(self.request, current_server, cmd)
elif cmd == "__download_log":
@@ -633,6 +739,14 @@ class SocketServer:
f"[SYS] Disconnected from current server \"{current_server}\"'s shell.\n".encode(
"utf-8")
)
elif cmd == "__system_info":
self.request.sendall(
f"[SYS] Connected clients number: {len(self.current_server_record)}\n"
f"[SYS] Hostname: {mgr.host}\n"
f"[SYS] Port: {mgr.port}\n"
f"[SYS] Working Directory: {os.getcwd()}\n"
f"[SYS] Platform: {platform.system()} ({platform.architecture()[0]})\n"
f"[SYS] Version: {VERSION}\n".encode("utf-8"))
else:
if current_server is not None:
receiver = mgr.get_command_receiver(current_server)
@@ -887,12 +1001,12 @@ class Server:
def _stdout_reader_loop(self):
self.logger.info("[PROC] stdout reader started")
while self.running:
while True:
with self.proc_lock:
proc = self.proc
out = proc.stdout if proc else None
if not proc or proc.poll() is not None or not out:
if not proc or not out:
self.logger.info("[PROC] process ended / stdout closed")
break
@@ -915,8 +1029,11 @@ class Server:
if not self.proc.stdin:
return False
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
try:
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
except OSError:
return False
return True
def stop_process(self, timeout: float = 10.0):
@@ -959,9 +1076,9 @@ class Server:
return
self.stopping = True
self.running = False
self.stop_process()
self.running = False
def restart(self):
if self.running:
@@ -995,7 +1112,7 @@ class Server:
return True, f"Server \"{self.name}\" process started"
elif command == "__info":
return True, (f"serverName: {self.name}\n"
f"serverPID: {self.proc.pid}\n"
f"serverPID: {self.proc.pid if self.proc else None}\n"
f"description: {self.description}\n"
f"arguments: {self.args}\n"
f"host: {self.host}\n"
@@ -1012,8 +1129,11 @@ class Server:
if not self.proc.stdin:
return False, "Process standard input are not available."
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
try:
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
except OSError as e:
return False, f"Unable to write process standard input: {e}"
return True, "Command sent."
def load_all_server_from_settings(settings: FileSettings):
@@ -1138,7 +1258,8 @@ def runserver(keep_running: bool):
logger.info("Stopped!")
@main.command()
def generate_tls_key():
@click.option("--hostname", default="localhost", show_default=True, help="Hostname or IP address of the server.")
def generate_tls_key(hostname: str):
print("Generating TLS key...")
key = rsa.generate_private_key(
public_exponent=65537,
@@ -1150,9 +1271,23 @@ def generate_tls_key():
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"),
x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"My Dev Org"),
x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"),
x509.NameAttribute(NameOID.COMMON_NAME, u"{}".format(hostname)),
])
alt_names = [x509.DNSName(u"localhost")]
def add_alt_name(name):
if name not in alt_names:
alt_names.append(name)
try:
add_alt_name(x509.IPAddress(ipaddress.ip_address(hostname)))
except ValueError:
add_alt_name(x509.DNSName(hostname))
for loopback in ("127.0.0.1", "::1"):
add_alt_name(x509.IPAddress(ipaddress.ip_address(loopback)))
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
@@ -1167,7 +1302,7 @@ def generate_tls_key():
# Valid for 1 year
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([x509.DNSName(u"localhost")]),
x509.SubjectAlternativeName(alt_names),
critical=False,
).sign(key, hashes.SHA256())