t2server/usr/local/bin/t2server

189 lines
8.8 KiB
Python
Executable file

#!/usr/bin/env -S python3 -B
import yaml
from os import unlink, symlink, chdir, getppid, makedirs
from os.path import isfile, islink, isdir, getmtime
from re import match
from glob import iglob
from subprocess import run, PIPE
from time import sleep
from requests import get
from threading import Thread
from t2support import *
winecmd = ["/usr/bin/wine"]
argbase = ["Tribes2.exe","-dedicated"]
basecmd = winecmd + argbase
parent = getppid()
def dso_cleanup():
"""
This function finds all .dso files (compiled .cs scripts) and deletes them
so that Tribes 2 will recompile them at startup.
"""
for dso_file in iglob(f"{install_dir}/**/*.dso", recursive = True):
print(f"Deleting {dso_file} so it can be rebuilt.")
unlink(dso_file)
def build_args(basecmd,config):
"""
This function assembles the command line to launch the server based on the
config.yaml file.
"""
server_command = basecmd
if config['Public']:
server_command.append("-online")
else:
server_command.append("-nologin")
if config['Mod'] != "base":
server_command.extend(["-mod", config['Mod']])
if config['ServerPrefs']:
server_command.extend(["-serverprefs","prefs\\ServerPrefs.cs"])
return server_command
def server_files(config):
"""
This function creates a symlink to the specified /etc/t2server/serverprefs
file in the GameData/<mod>/prefs directory where Tribes 2 expects to find it.
If MapList and MissionType are also configured, this funtion writes
GameData/base/prefs/missions.txt with the appropriate values for the
GameData/base/scripts/autoexec/missioncycle.cs script. If either MapList or
MissionType is False, an empty missions.txt is written.
"""
if config['ServerPrefs']:
if isfile(f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs") or islink(f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs"):
print(f"Deleting {install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs")
unlink(f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs")
print(f"Linking {install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs -> {etc_dir}/serverprefs/{config['ServerPrefs']}")
makedirs(f"{install_dir}/GameData/{config['Mod']}/prefs", mode = 0o755, exist_ok = True)
symlink(f"{etc_dir}/serverprefs/{config['ServerPrefs']}", f"{install_dir}/GameData/{config['Mod']}/prefs/ServerPrefs.cs")
if config["MapList"] and config["MissionType"]:
print(f"Writing {install_dir}/GameData/base/prefs/missions.txt")
makedirs(f"{install_dir}/GameData/base/prefs", mode = 0o755, exist_ok = True)
with open(f"{install_dir}/GameData/base/prefs/missions.txt", 'w') as mlist:
for mission in config["MapList"]:
mlist.write(f"{config['MissionType']} {mission}\n")
else:
# missions.txt needs to exist or the missioncycle.cs script will hang, so create/overwrite with an empty file
print(f"Purging {install_dir}/GameData/base/prefs/missions.txt")
with open(f"{install_dir}/GameData/base/prefs/missions.txt", 'w') as mlist:
mlist.write("")
def runaway_control(runaway_proc_cmd):
"""
When run in the background, wine will spawn two 'wineconsole'
processes that will consume all available CPU. This function finds these
processes and uses cpulimit to keep them under control.
"""
for x in range(20):
sleep(15)
print(f"Checking for runaway '{runaway_proc_cmd}' process...")
runaway_pid = run(["/usr/bin/pgrep","-f", runaway_proc_cmd],stdout = PIPE).stdout
if runaway_pid:
runaway_pid = str(int(runaway_pid))
print(f"Limiting runaway wineconsole process: {runaway_pid}")
run(["/usr/bin/cpulimit","-bp", runaway_pid,"-l2"])
break
def master_heartbeat():
"""
A public Tribes 2 server should send a regular heartbeat to the TribexNext
master server so that it appears in the list, however this seems
inconsistent, so this function takes over that responsibility if enabled.
"""
print("Starting TribesNext heartbeat thread...")
while True:
get('http://master.tribesnext.com/add/28000')
sleep(240)
def is_valid_ip(ip):
"""Check if an ip looks like a valid IP address."""
return bool(match(r"^(\d{1,3}\.){3}\d{1,3}$", ip))
if __name__ == "__main__":
# Get the version from the release file and print it.
if isfile(release_file):
with open(release_file, 'r') as rf:
version = rf.read().rstrip()
else:
version = None
if version: print(f"t2server version {version}")
# If run interactively, warn user that they probably don't want to do this unless troubleshooting.
if parent == 1:
print(f"Started by init")
else:
interactive_run = menu([f'[Y]es, run t2server interactively.',"~~[N]o, abort."], header = "Running t2server directly can be helpful for troubleshooting but generally it's best to manage your server with 'systemctl'. Do you still want to run t2server?", footer = "Choose [N] and run 't2help' if you're unsure of what to do.")
if interactive_run == 'N': bail()
chdir(f"{install_dir}/GameData")
# Read configuration from config.yaml
with open(f'{etc_dir}/config.yaml', 'r') as f:
loaded_config = yaml.full_load(f)
# Merge config_defaults and loaded_config, with loaded_config taking precedence where there are conflicts.
# This ensures there are no undefined values in the case of a user removing one from config.yaml.
config = {**config_defaults, **loaded_config}
# Validate the mod directory and serverprefs file
if not isdir(f"{install_dir}/GameData/{config['Mod']}"):
bail(f"Invalid Mod directory: {config['Mod']}")
if not isfile(f"{etc_dir}/serverprefs/{config['ServerPrefs']}"):
bail(f"Invalid ServerPrefs file: {config['ServerPrefs']}")
# Delete any pre-existing 00_t2server_opts script. It will be recreated below, if needed.
if isfile(f"{install_dir}/GameData/base/scripts/autoexec/00_t2server_opts.cs"): unlink(f"{install_dir}/GameData/base/scripts/autoexec/00_t2server_opts.cs")
# Create serverprefs symlink and missions.txt (if appropriate), clean out dso files, then assemble the command line arguments to launch the server
server_files(config)
if config['DSOCleanup']: dso_cleanup()
server_command = build_args(basecmd,config)
# Start the opts script with a comment at the top indicating this is auto-generated and shouldn't be edited, and also disable PureServer.
opts_script_content = "// This file is updated automatically by t2server based on /etc/t2server/config.yaml.\n// Do not edit this file directly.\n$Host::PureServer = 0;\n"
# If this is a public server...
if config['Public']:
# Start a heartbeat thread, if configured.
if config['Heartbeat']:
heartbeat = Thread(target = master_heartbeat)
heartbeat.daemon = True
heartbeat.start()
# Capture the public IP for MITM override, if configured.
if config['OverrideMITM']:
for ip_service in ["http://api.ipify.org","http://ifconfig.me","http://ipinfo.io/ip"]:
r = get(ip_service)
if r.status_code == 200 and is_valid_ip(r.text):
print(f"Got public IP address {r.text}")
break
if r.status_code != 200: bail("Could not get this server's public IP address.")
opts_script_content += f'$IPv4::InetAddress = "{r.text}";\n'
print(f"Overriding Man-in-the-Middle attack detection.")
# If MissionType and MapList are defined, write the first map into 00_t2server_opts.cs
if config['MissionType'] and config['MapList']:
opts_script_content += f'$Host::MissionType = "' + config['MissionType'] + '";\n$Host::Map = "' + config['MapList'][0] + '";\n'
if config['MissionType'] and config['MissionType'].lower() != 'teamrabbit':
opts_script_content += "$Host::LoadTR2Gametype = 0;\n"
# Write all options to the 00_t2server_opts file
with open(f'{install_dir}/GameData/base/scripts/autoexec/00_t2server_opts.cs', 'w') as opts_script:
opts_script.write(opts_script_content)
# Cap the CPU of the runaway wineconsole processes
wcpid1_limit = Thread(target = runaway_control, args = ("wineconsole --use-event=52",))
wcpid1_limit.start()
wcpid2_limit = Thread(target = runaway_control, args = ("wineconsole --use-event=188",))
wcpid2_limit.start()
# Open the console log file if running as service and start the Tribes 2 server
print(f"Starting Tribes 2 server: " + " ".join(server_command))
if parent == 1:
with open(f"{log_dir}/console.log", 'w') as consolelog:
run(server_command,stdout = consolelog)
else:
run(server_command)