Some changes.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+82
-7
@@ -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)
|
||||||
@@ -153,3 +202,29 @@ 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,
|
||||||
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user