#!/usr/bin/python3 from datetime import datetime, timedelta from glob import glob from os import path, mkdir, symlink, system, unlink import re from shutil import rmtree import socket from sys import argv from time import sleep # Exit codes EXIT_OK = 0 EXIT_INVOCATION = 1 # Incorrect usage (syntax error, invalid command, etc.) EXIT_DOWNLOAD = 8 # Error downloading a remote file EXIT_NOSYS = -1 # Unspecified error # File locations ABS_DIR = "/srv/minecraft" SERVER_PROPS = "server.properties" BACKUP_DIR = "backups" LOG_DIR = "logs" WARP_DIR = "plugins/Essentials/warps" DIMENSIONS = {"", "_nether", "_the_end"} # tmux TMUX_SESSION = "minecraft" STOP_TIMEOUT = timedelta(seconds=20) # Properties PROP_WORLD = "level-name" PROP_SEED = "level-seed" PROP_PORT = "port" # Utility functions def warp_files(world: str) -> str: """ Get the filename selector for a world's warps. """ return f"{WARP_DIR}/{world}_*.y*ml" def get_json(url: str) -> str: """ Download JSON from a URL and parse it. """ from json import loads from urllib.request import Request, urlopen request = Request(url, headers={"User-Agent": "Mozilla/5.0"}) json = urlopen(request).read().decode() return loads(json) def get_prop(key: str) -> str: """ Read a configuration variable from server.properties. """ from configparser import ConfigParser # Read from disk parser = ConfigParser() with open(SERVER_PROPS) as f: parser.read_string("[DEFAULT]\n" + f.read()) # Get requested property value = parser["DEFAULT"].get(key) if value: # Remove quotes return value.strip('"') else: return None def set_prop(key: str, value): """ Write a configuration variable in server.properties. """ # Read from disk with open(SERVER_PROPS) as f: props = f.read() # Quote strings with special characters if re.match(r"[?{}|&~!()^]", value): value = f'"{value}"' # Update property props = re.sub(f"^{key}=(.*)$", f'{key}={value}', props, flags=re.MULTILINE) # Write to disk with open(SERVER_PROPS, "w") as f: f.write(props) def send_cmd(cmd: str) -> bool: """ Send a command to the running server. """ return system(f"tmux send-keys -t {TMUX_SESSION}.0 '{cmd}' ENTER 1> /dev/null") == 0 def is_running() -> bool: """ Check whether the server session exists. """ return system(f"tmux has-session -t {TMUX_SESSION} 1> /dev/null") == 0 def is_up() -> bool: """ Check whether the server is listening on its assigned port. """ port = int(get_prop(PROP_PORT) or "25565") s = socket.socket() try: s.connect(("localhost", port)) return True except socket.error: return False finally: s.close() # User-facing commands def start(args: [str] = [], flags: [str] = []) -> int: """ Start running the server. """ if is_running(): print("Server is already running.") return EXIT_NOSYS java_cmd = "java -Xms2G -Xmx4G -Djava.net.preferIPv4Stack=true -DIReallyKnowWhatIAmDoingISwear=true -jar server.jar nogui" return system(f'/usr/bin/tmux new-session -d -s {TMUX_SESSION} "cd {ABS_DIR} && {java_cmd}; echo STOPPED; read"') def stop(args: [str] = [], flags: [str] = []) -> int: """ Stop the running server. """ if not is_running(): print("Server is not running.") return EXIT_INVOCATION send_cmd("stop") # Wait for graceful shutdown deadline = datetime.now() + STOP_TIMEOUT while datetime.now() < deadline and is_up(): sleep(1) # Force shutdown if is_up(): system(f"tmux kill-session -t {TMUX_SESSION} 1> /dev/null") # Wait for complete stop sleep(1) send_cmd("") # Continue past 'read' return int(not is_running()) def restart(args: [str] = [], flags: [str] = []) -> int: """ Stop the server (if it is running) and then start it. """ if is_running(): stop() return start() def reload(args: [str] = [], flags: [str] = []) -> int: """ Reload the running server. """ first_ok = send_cmd("reload") if not first_ok: return EXIT_NOSYS confirm_ok = send_cmd("reload confirm") if not confirm_ok: return EXIT_NOSYS return EXIT_OK def promote(args: [str], flags: [str] = []) -> int: """ Promote a player to the next role in the hierarchy. """ if len(args) != 1: print("Username of player to promote must be specified.") return EXIT_INVOCATION return not send_cmd(f"lp user {args[0]} promote default") def update(args: [str] = [], flags: [str] = []): """ Download a Paper server JAR. If no version is specified, the latest version is used. NOTE: This restarts the server. To prevent this, use -q. """ if len(args) == 1: version = args[0] print(f"Using specified version '{version}'") else: # Get latest Paper version version = get_json("https://papermc.io/api/v1/paper")["versions"][0] print(f"Using latest version '{version}'") if not "q" in flags: stop() # Get latest build of the selected Paper version build = get_json(f"https://papermc.io/api/v1/paper/{version}")["builds"]["latest"] print(f"Using build #{build}") # Download server JAR download_url = f"https://papermc.io/api/v1/paper/{version}/{build}/download" if system(f"wget {download_url} -O paper-{version}-{build}.jar") != 0: return EXIT_DOWNLOAD # Update link unlink("server.jar") symlink(f"paper-{version}-{build}.jar", "server.jar") if not "q" in flags: start() def attach(args: [str] = [], flags: [str] = []): """ Open the server console. """ return system("tmux attach -t minecraft") def clean(args: [str] = [], flags: [str] = []): """ Delete old files to free up space. """ # Delete old log files for log_file in glob(f"{LOG_DIR}/*.log.gz"): unlink(log_file) def backup(args: [str] = [], flags: [str] = []) -> int: """ Create a backup of one or more worlds. If no world names are specified, the currently loaded world name is used. """ # Find world(s) to back up if len(args) >= 1: worlds = args print(f"Backing up selected worlds: {', '.join(worlds)}") else: worlds = [get_prop(PROP_WORLD)] print(f"Backing up active world '{worlds[0]}") # Back up selected world(s) failures = 0 for world in worlds: dir_list = [] # Figure out which dimensions to back up for dim in DIMENSIONS: if path.isdir(f"{world}{dim}/"): dir_list.append(f"{world}{dim}/") # Add warps if present if path.isdir(WARP_DIR): # Remove old warps directory if path.isdir("warps"): rmtree("warps") # Copy warps to temporary directory mkdir("warps") current_world = get_prop(PROP_WORLD) if glob(warp_files(current_world)): system(f"cp {warp_files(current_world)} warps") dir_list.append("warps/") # Create archive failures += system(f"tar -czvf {BACKUP_DIR}/{world}.tar.gz {' '.join(dir_list)}") # Remove temporary warps directory rmtree("warps") return failures def restore(args: [str] = []) -> int: """ Restore a world. If no world name is specified, the currently loaded world name is used. """ # Find world to restore if len(args) == 1: world = args[0] print(f"Restoring selected world '{world}'") else: world = get_prop(PROP_WORLD) print(f"Restoring active world '{world}'") # Find backup file name archive = glob(f"{BACKUP_DIR}/{world}[._]*") if not archive: print("Could not find archive to restore") return EXIT_NOSYS # Remove current version for dim in DIMENSIONS | {"warps"}: folder = f"{world}{dim}" if path.isdir(folder): rmtree(folder) # Extract archive system(f"tar -xvzf {archive[0]}") if path.isdir("warps"): if glob("warps/*.y*ml"): # Copy warps out of temporary directory system(f"cp warps/*.y*ml {WARP_DIR}/") # Remove temporary directory rmtree("warps") def switch(args: [str] = [], flags: [str] = []) -> int: """ Switch to a different world. Requires the name of a world and optionally a seed for new worlds. This stops and restarts the server. To skip, use -q. This backs up the currently loaded world first. To skip, use -k. """ if len(args) < 1: print("A world name must be specified") return EXIT_INVOCATION if "q" not in flags: print("[switch] Stopping server") stop() if "k" not in flags: print("[switch] Backing up current world") backup() try: current_world = get_prop(PROP_WORLD) print(f"[switch] Removing warps for '{current_world}'") for f in glob(warp_files(current_world)): unlink(f) except FileNotFoundError: pass print("[switch] Updating server properties") set_prop(PROP_WORLD, args[0]) if len(args) == 2: set_prop(PROP_SEED, args[1]) print("[switch] Attempting to restore archive") restore() if "q" not in flags: print("[switch] Starting server") start() COMMANDS = { "update": update, "start": start, "stop": stop, "restart": restart, "reload": reload, "promote": promote, "attach": attach, "clean": clean, "backup": backup, "restore": restore, "switch": switch, } def help(args: [str] = [], flags: [str] = []): """ View a list of commands. """ for command, function in COMMANDS.items(): print(command) if function.__doc__: print(function.__doc__) if __name__ == "__main__": COMMANDS["help"] = help if len(argv) == 1 or argv[1] not in COMMANDS: print(f"Valid commands are: {', '.join(COMMANDS.keys())}") exit(EXIT_INVOCATION) # Find arguments args = list(filter(lambda x: not x.startswith("-"), argv[2:])) # Find flags flags = [] for f in filter(lambda x: x.startswith("-"), argv[2:]): f = f[1:] if len(f) > 1: flags.extend(list(f)) else: flags.append(f) # Run command status = COMMANDS[argv[1]](args, flags) exit(status if status is not None else 0)