First Commit

This commit is contained in:
Carl Manzi 2021-01-02 23:30:39 -05:00
commit 8ffa4dacbd
16 changed files with 1383 additions and 0 deletions

31
usr/local/bin/t2bouncer Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env -S python3 -B
import yaml
from datetime import datetime
from os import system
from t2support import *
# Set default configuration
config_defaults = {
'RestartTime' : False,
'RestartDay' : 'Mon',
}
# 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}
now=datetime.now()
if not config['RestartTime']:
print("RestartTime is disabled.")
elif config['RestartTime'] > 0 and config['RestartTime'] < 25:
if config['RestartTime'] == int(now.strftime('%H')) and config['RestartDay'][:3].upper() == now.strftime('%a').upper():
print("RestartTime and RestartDay match current time and day. Restarting t2server.service.")
system("/usr/bin/systemctl try-restart t2server.service")
else:
print("RestartTime and RestartDay do not match current time and day.")

103
usr/local/bin/t2fixer Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env -S python3 -B
from os import geteuid, chmod, walk
from os.path import isdir, islink, join
from shutil import chown
from sys import exit as bail
from t2support import *
if geteuid() != 0:
bail("This script must be run with sudo or as root.\n")
def setperm(file):
if islink(file):
pass
elif isdir(file):
log.write(f"Setting mode 775 on directory {file}")
try:
chmod(file,0o775)
log.write(f"\n")
except:
pwarn(f"Failed to set permissions on {file}")
log.write(f"... FAILED!\n")
pass
elif file.endswith("t2support.py"):
log.write(f"Setting mode 664 on file {file}")
try:
chmod(file,0o664)
log.write(f"\n")
except:
pwarn(f"Failed to set permissions on {file}")
log.write(f"... FAILED!\n")
pass
elif file.startswith(bin_dir):
log.write(f"Setting mode 775 on script {file}")
try:
chmod(file,0o775)
log.write(f"\n")
except:
pwarn(f"Failed to set permissions on {file}")
log.write(f"... FAILED!\n")
pass
elif file.endswith(".exe"):
log.write(f"Setting mode 775 on exe {file}")
try:
chmod(file,0o775)
log.write(f"\n")
except:
pwarn(f"Failed to set permissions on {file}")
log.write(f"... FAILED!\n")
pass
else:
log.write(f"Setting mode 664 on file {file}")
try:
chmod(file,0o664)
log.write(f"\n")
except:
pwarn(f"Failed to set permissions on {file}")
log.write(f"... FAILED!\n")
pass
if islink(file):
pass
else:
try:
log.write(f"Setting owner/group on {file}")
chown(file, "t2server", "t2server")
log.write(f"\n")
except:
pwarn(f"Failed to set owner/group on {file}")
log.write(f"... FAILED!\n")
pass
with open(f'{log_dir}/t2fixer.log', 'w') as log:
setperm(install_dir)
setperm(etc_dir)
for r, d, f in walk(f"{install_dir}/GameData"):
for file in d:
setperm(join(r, file))
for file in f:
setperm(join(r, file))
for r, d, f in walk(etc_dir):
for file in d:
setperm(join(r, file))
for file in f:
setperm(join(r, file))
for r, d, f in walk(log_dir):
for file in d:
setperm(join(r, file))
for file in f:
setperm(join(r, file))
setperm(f"{unit_dir}/t2server.service")
setperm(f"{unit_dir}/t2bouncer.service")
setperm(f"{unit_dir}/t2bouncer.timer")
setperm(f"{bin_dir}/t2server")
setperm(f"{bin_dir}/t2bouncer")
setperm(f"{bin_dir}/t2remove")
setperm(f"{bin_dir}/t2fixer")
setperm(f"{bin_dir}/t2help")
setperm(f"{bin_dir}/t2support.py")

22
usr/local/bin/t2help Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env -S python3 -B
from t2support import color
print(f"\n{color.DC}The follow commands can be used to manage your Tribes 2 server:")
print(f"""\n{color.BW}systemctl <action> t2server{color.DC}: The t2server service can be managed with the
standard Linux systemctl command.""")
print(f"""\n{color.BW}t2fixer{color.DC}: This command resets the owner and permissions on all files associated
with t2server. It's recommended to run this command any time you make changes
or add files (such as mods or map packs) to your server to ensure they can be
properly read and accessed. In the future, this command may also resolve other
common issues.""")
print(f"""\n{color.BW}t2remove{color.DC}: This command uninstalls t2server and removes most associated files.
Configuration and serverprefs files will be left alone.""")
print(f"\n{color.DC}Tribes 2 is installed in {color.BW}/opt/t2server{color.DC}.")
print(f"{color.DC}The {color.BW}/etc/t2server/config.yaml{color.DC} file holds basic service configuration values.")
print(f"{color.DC}The {color.BW}/etc/t2server/serverprefs{color.DC} directory holds your T2 serverprefs .cs files.")
print(color.X)

64
usr/local/bin/t2remove Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env -S python3 -B
from os import system, unlink, geteuid
from sys import argv
from t2support import *
if geteuid() != 0:
bail(f"This script must be run with sudo or as root.\n")
if menu(["[Y]es, remove Tribes 2", "~~[N]o, exit t2remove"],header=f"This script will remove Tribes 2, service files, and utilities. {etc_dir} will be left as it is.") == "N": bail()
system("/usr/bin/systemctl stop t2server.service")
system("/usr/sbin/userdel -fr t2server > /dev/null 2>&1")
system("/usr/sbin/groupdel t2server > /dev/null 2>&1")
systemd_delete_failed=0
try:
unlink(f"{unit_dir}/t2bouncer.service")
except:
pwarn(f"Failed to delete {unit_dir}/t2bouncer.service")
systemd_delete_failed+=1
try:
unlink(f"{unit_dir}/t2bouncer.timer")
except:
pwarn(f"Failed to delete {unit_dir}/t2bouncer.timer")
systemd_delete_failed+=1
try:
unlink(f"{unit_dir}/t2server.service")
except:
pwarn(f"Failed to delete {unit_dir}/t2server.service")
systemd_delete_failed+=1
if systemd_delete_failed > 0:
pinfo("After manually removing the files above, run 'systemctl daemon-reload' to refresh systemd.")
else:
system("/usr/bin/systemctl daemon-reload")
try:
unlink(f"{bin_dir}/t2bouncer")
except:
pwarn(f"Failed to delete {bin_dir}/t2bouncer")
try:
unlink(f"{bin_dir}/t2fixer")
except:
pwarn(f"Failed to delete {bin_dir}/t2fixer")
try:
unlink(f"{bin_dir}/t2server")
except:
pwarn(f"Failed to delete {bin_dir}/t2server")
try:
unlink(f"{bin_dir}/t2support.py")
except:
pwarn(f"Failed to delete {bin_dir}/t2support.py")
try:
if argv[0].startswith(bin_dir): unlink(argv[0])
except:
pwarn(f"Failed to delete {argv[0]}")

192
usr/local/bin/t2server Executable file
View file

@ -0,0 +1,192 @@
#!/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/wineconsole", "--backend=curses"]
argbase=["Tribes2.exe","-dedicated"]
basecmd=winecmd+argbase
parent=getppid()
def dso_cleanup():
"""
This function finds all .dso files (compiled .cs scripts) and deletes them
if they are older than their associated .cs script so that Tribes 2 will
recompile them at startup.
"""
for dso_file in iglob(f"{install_dir}/**/*.dso", recursive=True):
cs_file=dso_file[:-4]
if isfile(cs_file):
cs_mtime=getmtime(cs_file)
dso_mtime=getmtime(dso_file)
if cs_mtime > dso_mtime:
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:
if isfile(f"{install_dir}/GameData/base/prefs/missions.txt"):
print(f"Purging {install_dir}/GameData/base/prefs/missions.txt")
# missions.txt needs to exist or the missioncycle.cs script will hang, so overwrite with an empty file
open(f"{install_dir}/GameData/base/prefs/missions.txt", 'w').close()
def runaway_control():
"""
When run in the background, wine will spawn a 'wineconsole --use-event=52'
process that will consume all available CPU. This function finds that
process and uses cpulimit to keep it under control.
"""
for x in range(20):
sleep(15)
print("Checking for runaway wineconsole process...")
runaway_pid=run(["/usr/bin/pgrep","-f","wineconsole --use-event=52"],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.
"""
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))
def override_mitm():
"""
Tribes 2 servers try to detect descrepencies between their own IP and the IP
that the client believes it's connecting to as possible man-in-the-middle
attacks, however this often interfers with connections when the server is
NATed or multi-homed. This function gets the public-facing IP of the host
and writes an autoexec script to effectively disable this detection.
"""
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.")
print(f"Overriding Man-in-the-Middle attack detection.")
with open(f'{install_dir}/GameData/base/scripts/autoexec/noMITM.cs', 'w') as nomitm_script:
nomitm_script.write(f'$IPv4::InetAddress = "{r.text}";\n')
if __name__ == "__main__":
# 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(['[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")
# Set default configuration
config_defaults = {
'ServerPrefs' : 'Classic_CTF.cs',
'Mod' : 'Classic',
'Public' : False,
'OverrideMITM': True,
'MissionType' : 'CTF',
'MapList' : False
}
# 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}
print(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 noMITM script/dso. It will be recreated below, if needed.
if isfile(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs"): unlink(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs")
if isfile(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs.dso"): unlink(f"{install_dir}/GameData/base/scripts/autoexec/noMITM.cs.dso")
# Create serverprefs symlink and missions.txt (if appropriate), clean out stale dso files, then assemble the command line arguments to launch the server
server_files(config)
dso_cleanup()
server_command=build_args(basecmd,config)
# If this is a public server, start a hearbeat thread. Also write the MITM override file if configured.
if config['Public']:
print("Starting heartbeat...")
if config['OverrideMITM']: override_mitm()
heartbeat=Thread(target=master_heartbeat)
heartbeat.daemon=True
heartbeat.start()
# Cap the CPU of the runaway wineconsole process
wcpid_limit=Thread(target=runaway_control)
wcpid_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)

132
usr/local/bin/t2support.py Normal file
View file

@ -0,0 +1,132 @@
from tqdm import tqdm
from re import search
from requests import get
from hashlib import md5
from os import walk
from os.path import join, islink
from shutil import chown
from sys import exit
from textwrap import wrap
from itertools import chain
user = "t2server"
install_parent = "/opt"
install_dir = f"{install_parent}/t2server"
etc_dir = "/etc/t2server"
log_dir = "/var/log/t2server"
unit_dir = "/etc/systemd/system"
bin_dir = "/usr/local/bin"
class color:
X = '\033[m' # Reset
DR = '\033[31m' # Dark Red
DG = '\033[32m' # Dark Green
DY = '\033[33m' # Dark Yellow (Brown)
DU = '\033[34m' # Dark Blue
DP = '\033[35m' # Dark Purple
DC = '\033[36m' # Dark Cyan
DW = '\033[37m' # Dark White (light grey)
BK = '\033[90m' # Bright Black (dark grey)
BR = '\033[91m' # Bright Red
BG = '\033[92m' # Bright Green
BY = '\033[93m' # Bright Yellow
BU = '\033[94m' # Bright Blue
BP = '\033[95m' # Bright Purple
BC = '\033[96m' # Bright Cyan
BW = '\033[97m' # Bright White
class box:
TR = u'\u2510'
TL = u'\u250c'
BR = u'\u2518'
BL = u'\u2514'
H = u'\u2500'
V = u'\u2502'
def menu(option_list,header="",footer=""):
"""
This function takes a list of options and make a BBS-style menu out of them.
Each item should have a unique alphanum in square brackets (eg. "[Q]uit" or "[1] Quit")
that represents the letter the user will type to select that option. Additionally, one
option may start with ~~ to indicate that it is the default option. This option will be
selected if the user presses Enter without typing a letter. ~~ will not be displayed on
screen. The function returns the selected letter in uppercase.
"""
default=None
keys=[]
line_list=list(option_list)
if header:
header_list = wrap(header,width=76)
line_list.extend(header_list)
else:
header_list = None
if footer:
footer_list = wrap(footer,width=76)
line_list.extend(footer_list)
else:
footer_list = None
longest = max(list(chain((len(ele) for ele in line_list),[40])))
print(f"{color.BG}{box.TL}{box.H}{''.ljust(longest,box.H)}{box.H}{box.TR}{color.X}")
print(f"{color.BG}{box.V} {''.ljust(longest)} {color.DG}{box.V}{color.X}")
if header_list:
for line in header_list:
print(f"{color.BG}{box.V} {color.DG}{line.ljust(longest)} {box.V}{color.X}")
print(f"{color.BG}{box.V} {''.ljust(longest)} {color.DG}{box.V}{color.X}")
for option in option_list:
try:
key=search(r'\[([0-9a-zA-Z])\]', option).group(1)
except AttributeError:
pass
if option.startswith("~~"):
default = str(key)
keys.append(key.upper())
option=option[2:]
else:
keys.append(str(key).lower())
option=option.ljust(longest)
option=option.replace("[", color.BG + "[" + color.BW)
option=option.replace("]", color.BG + "]" + color.DG)
option=color.DG + option + color.X
print(f"{color.BG}{box.V} {option.ljust(longest)} {color.DG}{box.V}{color.X}")
if footer_list:
print(f"{color.BG}{box.V} {''.ljust(longest)} {color.DG}{box.V}{color.X}")
for line in footer_list:
print(f"{color.BG}{box.V} {color.DG}{line.ljust(longest)} {box.V}{color.X}")
print(f"{color.BG}{box.V} {''.ljust(longest)} {color.DG}{box.V}{color.X}")
print(f"{color.BG}{box.BL}{color.DG}{box.H}{''.ljust(longest,box.H)}{box.H}{box.BR}{color.X}")
menu_choice=" "
if default:
prompt=f"{color.DG}Choice {color.BK}({color.BG}{default}{color.BK}){color.DG}: {color.X}"
else:
prompt=f"{color.DG}Choice: {color.X}"
while menu_choice.upper() not in [x.upper() for x in keys]:
menu_choice = str(input(prompt))
if default and menu_choice == "": menu_choice = default
return menu_choice.upper()
def chowner(path, owner):
""" Recursively chown path with owner as both user and group """
for dirpath, ignore, filenames in walk(path):
chown(dirpath, owner, owner)
for filename in filenames:
if not islink(join(dirpath, filename)):
chown(join(dirpath, filename), owner, owner)
def pinfo(message):
print(color.BU + message + color.X)
def pwarn(message):
print(color.BY + message + color.X)
def perror(message):
print(color.BR + message + color.X)
def bail(message=""):
exit(color.BR + message + color.X)
if __name__ == "__main__":
bail("This file contains only shared functions and variables for the other scripts and is not meant to be run interactively.")