#! /usr/bin/env python3

# <revno> becomes the revision number and <catList> the multiline command list by category.
"""tt - TeamTalk Linux server administration utility revision <revno>
Requires Python 3.6 or later and a working systemd setup for most operations.

Usage:
tt by itself, or tt help, for this help
tt help command for help on a command; e.g., tt help new
tt help category for help on a command category; e.g., tt help general.
   The category name can be a prefix; e.g., tt help gen.
   Use all upper case to get all help for each command; e.g., tt help GEN.
tt help all - for a description of every command at once.
   Use ALL (upper case) to get absolutely all help for all commands.
tt command [args...] to run a command.

<catList>

Author: Doug Lee, based initially on shell and systemd scripts by Daniel Nash
License: BSD; run "tt license" for details.
"""

revno = 185

"""Design notes:

This utility is in one file for exchange convenience among its users.
Also, it is modeled after a 2021 shell script by Daniel Nash and tries to be as easy to install as that.
Major sections of this file are heralded by ==== walls that give a short section description.

See the CommandProcessorBase class doc string for how to add new commands.
Also see the do_* methods in the CommandProcessor class for examples; that's where commands go.

Pro servers are handled by naming tt5prosrv tt5srv, so that all else is the same for both server types.
"""

import os, sys, shlex, shutil, re, tarfile, signal, time, glob, filecmp
from pwd import getpwnam
from grp import getgrnam
from urllib.request import urlopen
from subprocess import run, check_call, PIPE, STDOUT, CalledProcessError
from collections import OrderedDict, defaultdict
# This allows reference to the program's main doc string even if this code is in a submodule.
import __main__

#========== Utility Code ==========

def printHelp(txt):
	"""Printer for help text, to fix a few formatting oddities.
	"""
	for line in txt.splitlines():
		line = line.rstrip()
		# Remove first two tabs, which come from indented class method doc strings.
		if line.startswith("\t"): line = line[1:]
		if line.startswith("\t"): line = line[1:]
		# Replace any more with three spaces.
		line = line.replace("\t", "   ")
		print(line)

def confirm(prompt):
	"""Get permission for an action with a y/n prompt.
	Returns True if "y" is typed and False if "n" is typed.
	Repeats request until one or the other is provided.
	KeyboardInterrupt signals equate to "n"
	EOF exits the calling program.
	A " (y/n)? " suffix is fairly well enforced on the prompt.
	"""
	prompt = prompt.replace("?", "")
	if not prompt.endswith(" "): prompt += " "
	if "(y/n)" not in prompt.lower(): prompt += "(y/n) "
	if "?" not in prompt: prompt = prompt.rstrip() +"? "
	l = ""
	while not l:
		try: l = input(prompt)
		except KeyboardInterrupt:
			l = "n"
			print(l)
		except EOFError: sys.exit("\nEOF encountered")
		l = l.strip()
		l = l.lower()
		if l in ["n", "no"]: return False
		elif l in ["y", "yes"]: return True
		print("Please enter y or n.")
		l = ""

def getChoice(prompt, choices):
	"""Let the user choose numerically among a set of choices.
	Returns the 1-based choice index, or 0 if no choice is made.
	Repeats request until a response is provided.
	KeyboardInterrupt signals equate to 0.
	EOF exits the calling program.
	"""
	l = ""
	while not l:
		print(prompt)
		for i,choice in enumerate(choices):
			print(f"   {i+1:2d} {choice}")
		try: l = input("Selection or 0 to abort: ")
		except KeyboardInterrupt:
			l = "0"
			print(l)
		except EOFError: sys.exit("\nEOF encountered")
		l = l.strip().lower()
		if l.isdigit() and int(l) >= 0 and int(l) <= len(choices):
			return int(l)
		print(f"Invalid selection; please enter a number between 0 and {len(choices):d}")
		l = ""

def errExit(e):
	"""Print error/exception e and exit.
	"""
	txt = str(e)
	txt = "Error: " +txt
	sys.exit(txt)

def hrsize(size):
	"""Return a 10-character string representing the given size succinctly in human-readable form:
		0-1023 bytes: Size suffixed with " bytes.
		0-1023 kb, meg, gig, tb: Size with two decimal places with the indicated suffix.
	All values are aligned so that the first four characters are always thousands through ones for any unit.
	In the unlikely event that a size exceeds 1024 of the largest unit in here, the result will exceed 10 characters.
	This currently requires a size whose decimal value is at least 28 digits long.
	As of this writing (March 20, 2023), there seems to be disagreement over what unit will come after yottabyte.
	"""
	if isinstance(size, str): size = int(size)
	# byte, kilobyte, megabyte, gigabyte, terabyte, petabyte, exabyte, zettabyte, yottabyte.
	units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
	uidx = 0
	val = size
	while val > 1024 and uidx < len(units) - 1:
		val /= 1024.0
		uidx += 1
	if uidx == 0:
		val = f"{val:4d} {units[uidx]}"
	else:
		val = f"{val:7.2f} {units[uidx]}"
	return val

# For on-the-fly structures that are syntactically prettier than dicts.
class Struct: pass

class Issues(defaultdict):
	"""Text issues with lists of places they are found.
	Used by do_analyze().
	"""
	def __init__(self, *args, **kwargs):
		super().__init__(list)

	def add(self, issue, where):
		self[issue].append(where)

#========== Support Code For RList Command ==========

class TTInstance:
	"""A single TeamTalk server instance:
		pid: The process id of the instance.
		name: The name of the server as drawn from the <server-name> element in the XML configFile.
		shortname: The name minus trailing announcement material like "Root is silent," as determined heuristically.
		exe, cwd, cmdline: The full executable path/name, working directory, and full command line including exe path.
			Under restrictive-permission conditions, exe may just be the command name.
		isDaemon: True if this instance is running as a daemon and False if not.
		isVerbose: True if -verbose was given (log output goes to stdout) and False if not.
		isLogging: True if this instance is actually logging and False if not.
		configFile, logFile: Full pathnames to config and log files.
		ipAddr, tcpPort, udpPort: The listening IP address and TCP and UDP ports. ipAddr will be "*" for any.
		launchArgs: A full set of subprocess-compatible args that, if launched, would recreate this server instance.
		launchLine: A shell script line that would recreate this server instance.
			NOTE: These do not necessarily recreate the same command line; for example, these may be reordered and/or include more precise paths.
		connections: A list of connection addr:port strings, one per attached TeamTalk client.
	"""
	# Slots used here mostly to catch programming errors like inconsistent use of instance vars.
	__slots__ = (
	'pid', 'name', 'shortname',
	'exe', 'cwd', 'cmdline',
	'isDaemon', 'isVerbose', 'isLogging',
	'configFile', 'logFile',
	'ipAddr', 'tcpPort', 'udpPort',
	'launchArgs', 'launchLine',
	'clients',
	)

	def __init__(self, pid):
		self.pid = int(pid)
		self.name = self.shortname = ""
		self.exe = self.cwd = self.cmdline = ""
		self.isDaemon = self.isVerbose = self.isLogging = None
		self.configFile = self.logFile = ""
		self.ipAddr = ""
		self.tcpPort = self.udpPort = 0
		self.clients = {}
		self.launchArgs = []
		self.launchLine = ""

	def applyCommandLine(self):
		"""Apply cmdline to this object.
		cwd should be defined first for best results.
		"""
		args = shlex.split(self.cmdline)
		# Remove prog path.
		exe = args.pop(0)
		if not self.exe or ("/" not in self.exe and "\\" not in self.exe):
			self.exe = exe
		base = os.path.splitext(os.path.basename(exe))[0]
		if not base: base = "tt5srv"
		self.isDaemon = False
		self.isVerbose = False
		if self.cwd:
			if not self.configFile: self.configFile = os.path.join(self.cwd, base+".xml")
			if not self.logFile: self.logFile = os.path.join(self.cwd, base+".log")
		while args:
			arg = args.pop(0)
			if not arg.startswith("-"):
				raise ValueError("Unexpected TeamTalk command-line option: " +arg)
			if arg == "-d":
				self.isDaemon = True
			elif arg == "-nd":
				self.isDaemon = False
			elif arg == "-c":
				if self.cwd:
					self.configFile = os.path.join(self.cwd, args.pop(0))
				else:
					# ToDo: This may be a relative path or have no path at all.
					self.configFile = args.pop(0)
			elif arg == "-l":
				if self.cwd:
					self.logFile = os.path.join(self.cwd, args.pop(0))
				else:
					# ToDo: This may be a relative path or have no path at all.
					self.logFile = args.pop(0)
			elif arg == "-verbose":
				self.isVerbose = True
			elif arg == "-tcpport":
				p = int(args.pop(0))
				if not self.tcpPort: self.tcpPort = p
				del p
			elif arg == "-udpport":
				p = int(args.pop(0))
				if not self.udpPort: self.udpPort = p
				del p
			elif arg == "-ip":
				ip = args.pop(0)
				if not self.ipAddr: self.ipAddr = ip
				del ip
			elif arg == "-wd":
				cwd = args.pop(0)
				if not self.cwd: self.cwd = cwd
				del cwd
			elif arg == "-pid-file":
				# We have the pid so we don't need this file path.
				args.pop(0)
			elif arg in ["-daemon-pid", "-wizard", "--help", "--version"]: continue
			else: raise ValueError("Unrecognized TeamTalk option: " +arg)
		# A couple of final touch-ups.
		if not os.path.isabs(self.configFile) and self.cwd:
			self.configFile = os.path.join(self.cwd, self.configFile)
		if self.configFile: self.configFile = os.path.normpath(self.configFile)
		if not os.path.isabs(self.logFile) and self.cwd:
			self.logFile = os.path.join(self.cwd, self.logFile)
		if self.logFile: self.logFile = os.path.normpath(self.logFile)
		self._setLaunchers()

	def _setLaunchers(self):
		"""Set launchArgs and launchLine from existing data.
		"""
		if not self.exe or not self.cwd: return
		# ToDo: Verify configFile exists and has the right server-name. But deletion could break this.
		# ToDo: If isLogging is True, verify that the logFile exists.
		cmd = [self.exe]
		cmd.append("-d" if self.isDaemon else "-nd")
		if self.isVerbose: cmd.append("-verbose")
		if self.ipAddr and self.ipAddr != "*":
			cmd.extend(["-ip", self.ipAddr])
		if self.tcpPort: cmd.extend(["-tcpport", str(self.tcpPort)])
		if self.udpPort: cmd.extend(["-udpport", str(self.udpPort)])
		cmd.extend(["-wd", self.cwd])
		# ToDo: Handle configFile and logFile, then make self.launchArgs, then self.launchLine.

	def clientInfo(self, details=False):
		"""Return information about attached clients.
		details==False: unique+duplicates.
		details==True:  List of ips, each with a list of ports for that ip.
		"""
		clients = self.clients
		if not details:
			cnt1 = len(clients)
			cnt2 = sum([len(ports) for ports in clients.values()])
			return f"{cnt1}+{cnt2-cnt1}"
		return ", ".join([
			f"{ip}({', '.join(sorted(ports, key=int))})"
		for ip,ports in sorted(clients.items())])

class TTInstances(OrderedDict):
	"""A dictionary (by pid) of TeamTalk server instances running on one host.
	The list (actually a pid-to-TTInstance dictionary) can be filtered.
	"""
	# List here the specific names of TeamTalk server executables, without path.
	# The right sides of these are (configFile,logFile) tuples indicating standard file names for the respective TT versions.
	_ttNames = {
		"tt5srv": ("tt5srv.xml", "tt5srv.log"),
		"tt5prosrv": ("tt5prosrv.xml", "tt5prosrv.log"),
	}
	# Temporary var.
	# ToDo: Eliminate this, but this will probably involve adding a "comm" slot to this class for the command name, sans extension.
	_ttProgNames = list(_ttNames.keys())
	def __init__(self, filters=None, countFilters=None):
		if not filters: filters = []
		if not countFilters: countFilters = {}
		super().__init__()
		try: self._getLSOFInfo()
		except OSError:
			print("Warning: lsof is not available.")
		method1 = self._fillByProcFS()
		method2 = self._fillByPS()
		if not method1 and not method2:
			print("Warning: Command lines are not available")
		[self._getFromXML(tt) for tt in self.values()]
		self.totcount = len(self)
		self.matchcount = self.totcount
		if not filters and not countFilters: return
		for k in list(self.keys()):
			tt = self[k]
			if not all(self._match(tt, match) for match in filters):
				del self[k]
			if not countFilters: continue
			c1 = len(tt.clients)
			c2 = sum([len(ports) for ports in tt.clients.values()])
			for ctype,lim in countFilters.items():
				if ctype == "i": cnt = c1
				elif ctype == "d": cnt = c2 - c1
				else: cnt = c2
				if cnt <= lim:
					del self[k]
		self.matchcount = len(self)

	@staticmethod
	def _match(tt, fexp):
		"""Return True if the TTInstance tt matches the filter expression fexp.
		This method defines filter syntax.
		"""
		# Numeric port match.
		if fexp.isdigit():
			port = int(fexp)
			return port in (tt.tcpPort, tt.udpPort)
		# Case-insensitive match against executable, config, and log file pathnames, and current working directory path.
		targets = [tt.exe, tt.cwd, tt.configFile, tt.logFile, tt.name]
		targets = [target.lower() for target in targets]
		if any(fexp.lower() in val for val in targets):
			return True
		return False

	def _instance(self, pid):
		"""Return or create a TTInstance object for pid, which can be a string or an int.
		"""
		pid = int(pid)
		try: return self[pid]
		except KeyError:
			tt = TTInstance(pid)
			self[pid] = tt
			return tt

	def _fillByProcFS(self):
		"""List running TeamTalk server instances by reading from the procfs file system where implemented.
		Returns True on success and False if procfs is not found.
		Note that an empty or incomplete server list can still result if this user's permissions do not permit access to things like /proc/*/cwd.
		"""
		procfsPath = "/proc"
		try: pids = os.listdir(procfsPath)
		except OSError: return False
		for pid in pids:
			# Omit fd and other non-pid entries.
			if not pid.isdigit(): continue
			# Omit non-TT processes. Some processes may not have the exe link, hence the error catching.
			try: exe = os.readlink(os.path.join(procfsPath, pid, "exe"))
			except OSError: continue
			sinfo = self._ttNames.get(os.path.basename(exe))
			if sinfo is None: continue
			xmlname,logname = sinfo
			try: cwd = os.readlink(os.path.join(procfsPath, pid, "cwd"))
			except OSError: cwd = None
			with open(f"/proc/{pid}/cmdline") as f: cmdline = f.read()
			if cmdline.endswith('\0'):
				cmdline = cmdline[:-1]
				args = cmdline.split('\0')
				cmdline = " ".join(['"'+arg+'"' if " " in arg or "\t" in arg else arg for arg in args])
			tt = self._instance(pid)
			tt.exe = exe
			if cwd: tt.cwd = cwd
			tt.cmdline = cmdline
			tt.applyCommandLine()
		return True

	def _fillByPS(self):
		"""List running TeamTalk server instances by using ps to collect process information.
		This may be slower than using procfs and is thus used as a fallback.
		MacOS requires this by default, though procfs implementations for MacOS exist.
		"""
		# We use two ps invocations to avoid spacing ambiguities in results:
		# First pid and the unalterable command names to filter out non-TeamTalk processes,
		# then pid and command lines (args) to collect info.
		# Header rows on output are omitted, and unlimited width is requested though this is probably unnecessary.
		lines = self._proc(["ps", "-e", "-ww", "-opid=", "-ocomm="])
		if not lines: return False
		for line in lines:
			line = line.strip()
			try: pid,comm = line.split(None, 1)
			except ValueError as e:
				# This happens for a process with no accounting name.
				# Seen on a Debian 4.19 server 2022-12-30.
				# We simply assume this line is irrelevant.
				continue
			if os.path.basename(comm) not in self._ttProgNames: continue
			pid = int(pid)
			tt = self._instance(pid)
			if not tt.exe: tt.exe = comm
		lines = self._proc(["ps", "-e", "-ww", "-opid=", "-oargs="])
		if not lines: return False
		for line in lines:
			line = line.strip()
			try: pid,cmdline = line.split(None, 1)
			except ValueError as e:
				# Same story as above.
				continue
			pid = int(pid)
			if pid not in self: continue
			tt = self._instance(pid)
			# This fixes file names that contain spaces.
			cmdline = re.sub(r'(/home/tt/.*?\.(xml|log))', r'"\1"', cmdline)
			tt.cmdline = cmdline
			tt.applyCommandLine()
		return True

	def _getLSOFInfo(self):
		"""Attempt to collect relevant info for all running TeamTalk servers from lsof.
		"""
		cmd = ["lsof"]
		[cmd.extend(["-c", name]) for name in self._ttProgNames]
		cmd.extend([
			# No DNS or port name lookups, for speed.
			"-nP",
			# Only state (ST=) info on T lines (only matters if T appears below in the value for -F).
			"-Ts",
			# p pid, c command name, f file descriptor/cwd/rtd etc., t type, P protocol name, n file/comment/ipaddr:port.
			# T (TCP info) would say things like ESTABLISHED or LISTEN; but we figure * means listen and addr means established here.
			"-F", "pcftPn",
		])
		lines = self._proc(cmd)
		# Append a terminator.
		lines.append("----")
		tt = None
		info = None
		for line in lines:
			line = line.strip()
			lt,val = line[0],line[1:]
			# One case for each -F-value letter from the above cmd.
			if lt in ["p", "-"]:
				# Process id, start of new process block (which will contain file blocks), or end of data.
				self._processLSOFInfo(tt, info)
				if lt == "-": break
				tt = self._instance(val)
				info = None
			elif lt == "c":
				# Command name for this pid.
				pass
			elif lt == "f":
				# File descriptor, start of new file block within the current process block.
				# Examples: 0, 1, 2, 11u, cwd, rtd (root directory), txt (one running program file reference).
				self._processLSOFInfo(tt, info)
				info = Struct()
				info.fd = val
			elif lt == "t":
				# Type of this file entry; e.g., REG, DIR, IPv4, IPv6.
				info.type = val
			elif lt == "P":
				# Protocol; e.g., TCP, UDP.
				info.protocol = val
			elif lt == "n":
				# Name, comment, TCP info, error message, etc.
				info.name = val
			else:
				raise ValueError("Unexpected lsof line type: " +str(lt))

	def _processLSOFInfo(self, tt, info):
		"""Apply collected lsof info to tt.
		Fields expected in info: fd, type, protocol, name.
		"""
		if not tt or not info: return
		try: info.type
		except AttributeError: info.type = ""
		try: info.protocol
		except AttributeError: info.protocol = ""
		try: info.name
		except AttributeError: info.name = ""
		if info.fd == "cwd":
			if info.type == "DIR" and not tt.cwd:
				tt.cwd = info.name
		elif info.fd == "txt":
			if os.path.basename(info.name) in self._ttProgNames:
				tt.exe = info.name
		elif info.fd.isdigit() and info.type == "REG" and os.path.splitext(info.name)[1].lower() == ".log":
			tt.logFile = info.name
			tt.isLogging = True
		elif info.protocol in ["TCP", "UDP"]:
			if "->" not in info.name:
				# A listening port.
				addr,port = info.name.rsplit(":", 1)
				if info.protocol == "TCP":
					tt.ipAddr = addr
					# ToDo: We assume here that the IP address for TCP and UDP listening will be equal if set at all.
					tt.tcpPort = int(port)
				else:  # UDP
					tt.ipAddr = addr
					tt.udpPort = int(port)
			else:
				# A TeamTalk client connection. Only TCP shows up here apparently.
				# info.name contains localAddr:localPort->clientAddr:clientPort.
				local,client = info.name.split("->", 1)
				ip,port = client.rsplit(":", 1)
				tt.clients.setdefault(ip, [])
				tt.clients[ip].append(port)

	@staticmethod
	def _proc(cmd):
		"""Run a process and return stdout as a set of lines or raise an error if the command fails.
		cmd is a list of arguments comprising a command-line.
		On first failure, tries the command with a sequence of explicit paths before giving up.
		"""
		paths = ["", "/bin", "/usr/bin", "/sbin", "/usr/sbin", "/usr/local/bin", "/usr/local/sbin", "/opt/local/bin", "/opt/local/sbin"]
		if "/" in cmd[0]: paths = [""]
		prog = cmd[0]
		while paths:
			path = paths.pop(0)
			if path:
				cmd.pop(0)
				cmd.insert(0, os.path.join(path, prog))
			try:
				proc = run(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, universal_newlines=True)
				break
			except OSError as e:
				if not paths: raise
		if proc.stderr and not proc.stdout: return []
		return proc.stdout.splitlines()

	@staticmethod
	def _getFromXML(tt):
		"""Set tt fields from this server's .xml file if possible.
		"""
		if not tt.configFile: return
		try: f = open(tt.configFile, "r")
		except IOError: return
		txt = f.read()
		f.close()
		del f
		try: name = re.findall(r'<server-name>(.*?)</server-name>', txt)[0]
		except IndexError: name = ""
		name = name.replace('&apos;', "'").replace('&amp;', "&")
		tt.name = name
		name = re.sub(r'\s*[:(].*', '', name)
		tt.shortname = name

	def valuesByPort(self):
		"""Like values() but returns a TTInstance sequence ordered by TCP port, lowest first.
		This ordering has the advantage of resulting in a list that is stable across server relaunches.
		"""
		return sorted(list(self.values()), key=lambda tt: f"{tt.ipAddr}{tt.tcpPort:5d}")

#========== Support Code For logsum Command ==========

class LineCollector:
	"""Collector of a single type of log line.
	"""
	def __init__(self, name, searcher, nonterminal=False):
		"""
		name: The printable name of the line type.
		searcher: A lambda expression returning True for a match.
		nonterminal: True if matches allow further matching to occur.
		"""
		self.name = name
		self.searcher = searcher
		self.count = 0
		self.nonterminal = nonterminal

	def apply(self, line):
		"""Apply this object to the given line.
		Returns True if the object should be the last to apply to the line and False if not.
		"""
		if self.searcher(line):
			self.count += 1
			return not self.nonterminal
		return False

	def report(self):
		"""Report the results collected for this line type.
		The report is returned as a list of lines.
		The caller must prepend any indenting to be applied to this line type.
		"""
		lines = []
		if self.count == 0: return lines
		lines.append("%9d  %s" % (self.count, self.name))
		return lines

class UnknownLineCollector:
	"""Collector of unknown line types.
	"""
	# Format of unrecognized line "categories."
	Fmt_Unknown = "{%s}"
	# A matcher for an IPV4 or IPV6 address with possible trailing port number.
	hhhh = r'[0-9a-fA-F]+'
	re_addr = r'((?:::ffff:)\d+\.\d+\.\d+\.\d+|' +((hhhh+":")*7)+hhhh +r')(:\d+)?'
	del hhhh

	# Parts of a line that can be "unified" (changed to constants)
	# to reduce the dynamic category count.
	unifications = (
		# User and channel ids.
		(re.compile(r' #\d+ '), " #<number> "),
		# SSL process numbers and other data up to an error description.
		(re.compile(r'\(\d+\|\d+\).*?SSL routines:'), "... "),
		(re.compile(r'(\s*)nickname: ".*?" '), "\\1<nickSpec> "),
		(re.compile(r'(\s*)nickname: ".*?"\.?$'), "\\1<nickSpec> "),
		(re.compile(r'(\s*)username: ".*?" '), "\\1<userSpec> "),
		(re.compile(r'(\s*)username: ".*?"\.?$'), "\\1<userSpec> "),
		(re.compile(r'(\s*)account: ".*?" '), "\\1<accountSpec> "),
		(re.compile(r'(\s*)account: ".*?"\.?$'), "\\1<accountSpec> "),
		(re.compile(r'(\s*)file: \S*'), "\\1<fileSpec>"),
		(re.compile(r' IP address: '+re_addr+r' '), " <IPAddress> "),
		(re.compile(r' TCP address: '+re_addr+r' '), " <TCPAddress> "),
		(re.compile(r' UDP address: '+re_addr+r' '), " <UDPAddress> "),
		(re.compile(r' port \d+'), " port <port>"),
		(re.compile(r' content: ".*"(\s*)'), " <contentSpec>\\1"),
		(re.compile(r' in file .*? at line \d+'), " <fileAndLineInfo>"),
	)

	def __init__(self):
		self.data = {}

	# To split off the date/time stamp from a log line.
	# The first timestamp format is for TT4 and the second for TT5.
	re_dt = re.compile(r'^((?:... ... .. ....|\d+-\d+-\d+) [\d:.]+) (.*)')
	def apply(self, line):
		"""Apply this object to the given line.
		Returns True if the object should be the last to apply to the line and False if not.
		This object will always return True for this method.
		"""
		# First split off the date/time.
		try: dt,line = self.re_dt.match(line).groups()
		except AttributeError:
			# No timestamp.  This actually happens.
			# The next two line types have no further data to print after the name.
			# Empty lines often follow assertion failure messages.
			if not line: return self.addUnknown("empty lines" +line)
			if not line.strip(): return self.addUnknown("blank lines" +line)
			# But the next one probably does.
			return self.addUnknown("<no timestamp> " +line)
		# All lines I've seen next say "User ," so complain if not.
		if not line.startswith("User "):
			return self.addUnknown("<no user number> " +line)
		if line.startswith("User message from "):
			self.data.setdefault(self.K_Message, 0)
			self.data[self.K_Message] += 1
			return True
		if not line.startswith("User #"):
			return self.addUnknown("<no user number> " +line)
		# Get the user number.
		user,line = re.match(r'^User #(\d+) (.*)', line).groups()
		# TODO: Could keep a set of user numbers for a count.
		# Could do the same for nicknames and channel names.
		return self.addUnknown(line)

	def addUnknown(self, line):
		"""Tally a line of unknown format.
		"""
		line = self.unify(line)
		line = self.Fmt_Unknown % (line)
		self.data.setdefault(line, 0)
		self.data[line] += 1
		return True

	def unify(self, line):
		"""Replace variable content with constants in the line.
		"""
		for lhs,rhs in self.unifications:
			line = lhs.sub(rhs, line)
		return line

	def report(self):
		"""Report the results collected for these line types.
		The report is returned as a list of lines sorted by decreasing frequency.
		The caller must prepend any required indenting.
		"""
		lines = []
		for k,v in sorted(list(self.data.items()), key=lambda kv: kv[1], reverse=True):
			lines.append("%9d  %s" % (v, k))
		return lines

class LineTotaler:
	"""LineCollector-style class for totaling all lines.
	"""
	def __init__(self, name):
		"""
		name: The printable name of the line type.
		"""
		self.name = name
		self.count = 0

	def apply(self, line):
		"""Apply this object to the given line.
		Returns True if the object should be the last to apply to the line and False if not.
		"""
		self.count += 1
		return False

	def report(self):
		"""Report the results collected by this object.
		The report is returned as a list of lines.
		The caller must prepend any indenting to be applied.
		"""
		lines = []
		lines.append("%9d  %s" % (self.count, self.name))
		return lines

class TTLogSum:
	"""Summarizer of log files.
	"""

	# Line types reported, in reporting order.
	# At this writing, this is also the search order.
	ltypes = [
		# Do totals first so no other object cuts them off.
		LineTotaler("total log lines"),
		LineCollector("server starts", lambda l:
			" Started TeamTalk " in l),
		LineCollector("server stops", lambda l:
			" Stopped TeamTalk " in l),
		LineCollector("connects", lambda l:
			l.endswith( ' connected.'), True),
		LineCollector("disconnects", lambda l:
			l.endswith(' disconnected.')),
		LineCollector("connects via IPV4on6", lambda l:
			l.endswith( ' connected.') and "::ffff:" in l.rsplit("address:", 1)[1]),
		LineCollector("connects via IPV6", lambda l:
			l.endswith( ' connected.') and l.rsplit("address:", 1)[1].count(":") > 4),
		LineCollector("connects via IPV4", lambda l:
			l.endswith( ' connected.')),
		LineCollector("drops for inactivity", lambda l:
			" dropped after " in l and l.endswith(" sec for inactivity.")),
		LineCollector("logins", lambda l:
			l.endswith(' logged in.'), True),
		LineCollector("logouts", lambda l:
			l.endswith(' logged out.')),
		LineCollector("logins from text clients", lambda l:
			l.endswith(' UDP address: :::0 logged in.'), True),
		LineCollector("logins via IPV4on6", lambda l:
			l.endswith(' logged in.') and "::ffff:" in l.rsplit("address:", 1)[1]),
		LineCollector("logins via IPV6", lambda l:
			l.endswith(' logged in.') and l.rsplit("address:", 1)[1].count(":") > 4),
		# This one comes from process of elimination.
		LineCollector("logins via IPV4", lambda l:
			l.endswith(' logged in.')),
		LineCollector("messages to users", lambda l:
			' User message from ' in l),
		LineCollector("messages to channels", lambda l:
			' Channel message from ' in l),
		LineCollector("broadcast messages", lambda l:
			' Broadcast message from ' in l),
		LineCollector("custom messages", lambda l:
			# Not sure if this one happens yet. (2022)
			' Custom message from ' in l),
		LineCollector("user status updates", lambda l:
			l.endswith(' updated.')),
		LineCollector("channel joins", lambda l:
			' joined channel: ' in l),
		LineCollector("channel leaves", lambda l:
			' left channel: ' in l),
		LineCollector("user moves", lambda l:
			" moved user #" in l),
		LineCollector("solo transmitter updates by a user", lambda l:
			"Channel #" in l and "updated by" in l and "transmitter: #" in l),
		LineCollector("other solo transmitter updates", lambda l:
			"Channel #" in l and "updated" in l and "transmitter: #" in l),
		LineCollector("subscription changes that include intercepting", lambda l:
			# Sub/intercept types list as two letters each, intercepts in upper case.
			# Intercepts come last and a dot ends the line; therefore,
			# if someone is intercepting, the letter before the final dot is upper case.
			# NOTE: Any sub/intercept change lists all flags set at the time;
			# so this count does not equal number of times someone was intercepted.
			" changed subscription to #" in l and l[-2].isupper(), True),
		LineCollector("subscription changes", lambda l:
			" changed subscription to #" in l),
		LineCollector("kicks from channel", lambda l:
			" kicked by " in l and " from channel: " in l),
		LineCollector("kicks from channel with no kicker ID", lambda l:
			" kicked from channel: " in l),
		LineCollector("kicks from server", lambda l:
			" kicked by " in l and " from server" in l),
		# This happens when someone logs in while already there on a server that doesn't allow this.
		LineCollector("kicks from server for login replacement", lambda l:
			" kick from server." in l),
		LineCollector("bans from channel", lambda l:
			" from channel" in l and " banned " in l),
		LineCollector("bans from server", lambda l:
			" from server" in l and " banned " in l),
		LineCollector("unbans from channel", lambda l:
			" from channel" in l and " unbanned " in l),
		LineCollector("unbans from server", lambda l:
			" unbanned " in l),
		LineCollector("logins denied due to IP ban", lambda l:
			l.endswith(' denied login due to banned IP-address.')),
		LineCollector("user authentication failures", lambda l:
			" failed to authenticate for account " in l),
		LineCollector("server property updates", lambda l:
			" updated server properties." in l),
		# Create and update look the same, unfortunately.
		LineCollector("user creates/updates", lambda l:
			" created user " in l),
		LineCollector("user deletes", lambda l:
			" deleted user " in l),
		LineCollector("channel creates", lambda l:
			" Channel #" in l and " created by " in l),
		LineCollector("channel updates", lambda l:
			" Channel #" in l and " updated by " in l),
		LineCollector("channel removals", lambda l:
			" Channel #" in l and " removed by " in l),
		LineCollector("file uploads", lambda l:
			" uploaded " in l and " to channel: " in l),
		LineCollector("file deletes", lambda l:
			" deleted " in l and " from channel: " in l),
		# 2022: Current servers add "by user", older added "due to user," still older added nothing.
		LineCollector("configuration saves", lambda l:
			"Server configuration saved" in l),
		LineCollector("configuration reloads", lambda l:
			"Reloaded settings file " in l),
		LineCollector("data transfer stat entries", lambda l:
			'Data transferred - ' in l),
		# Two line types that appear in more detail in the dynamic entries later.
		LineCollector("total failed assertions", lambda l:
			" Failed assertion " in l, True),
		LineCollector("total SSL errors", lambda l:
			l.startswith("ACE_SSL "), True),
	# And the final catch-all...
		UnknownLineCollector()
	]

	def __init__(self):
		self.fname = None
		self.linecount = 0
		self.filecount = 0
		self.startTime = self.endTime = None
		self.units = 0
		self.unitsize = 1000000
		self.unitname = "million"
		self.ftype = None

	def startfile(self, fname):
		"""Start a new file.
		"""
		if not self.startTime:
			# perf_counter_ns requires py3.7 but we allow 3.6.
			self.startTime = time.perf_counter()
			self.endTime = self.startTime
		if self.fname is not None: self.stopfile()
		self.fname = fname
		self.filecount += 1
		self.ftype = None

	def stopfile(self):
		"""Stop the current file.
		"""
		self.fname = None
		self.endTime = time.perf_counter()

	def addLine(self, line):
		"""Add a log line to the data.
		"""
		self.linecount += 1
		if self.linecount - (self.unitsize * self.units) > self.unitsize:
			self.units += 1
			print("%d %s lines..." % (self.units, self.unitname), file=sys.stderr)
			sys.stderr.flush()
		line = line.rstrip()
		line = line.decode("utf-8", errors="ignore")
		if self.ftype is None:
			isDelta = re.match(r'^[\d,]+[a-z][\d,]+$', line)
			if line.startswith("===") or isDelta:
				self.ftype = "d"
				return
			self.ftype = "l"
		if self.ftype == "d":
			# < and - are old lines that are changed.
			# @ and = are heading lines (= comes from bzr/brz).
			# 0-9 announce changed line ranges, and space is an unchanged line in a unified diff.
			if line and line[0] in "<-= @0123456789": return
			# +++ is a heading also.
			elif line.startswith("+++"): return
			# These are new versions of changed lines in old and unified diff formats.
			elif line and line[0] in ">+": line = line[1:]
			# diff(1) can say this when avoiding binary files.
			elif line.startswith("Binary files "): return
			# Stop anything else not recognized.
			else: return
			line = line.lstrip()
		line = self._cleanLine(line)
		for obj in self.ltypes:
			if obj.apply(line): break

	@staticmethod
	def _cleanLine(line):
		"""Clean up a line so it doesn't match falsely on user-generated material.
		"""
		l1 = ""
		while '"' in line:
			l2,line = line.split('"', 1)
			l1 += l2
			try: la,lb = line.split('"', 1)
			except ValueError:
				# ToDo: Count these as skipped lines.
				# As it is, they should count as empty lines.
				#print("Not able to clean " +line)
				return ""
			if not la or la[-1] != '\\':
				line = lb
				l1 += '"string"'
			else:
				ln = len(line)
				i = 0
				while i < ln:
					ch = line[i]
					if ch == '\\': i += 1
					elif ch == '"': break
					i += 1
				l1 += '"string"'
				line = line[i+1:]
		l1 += line
		return l1

	def output(self):
		"""Produce the summary as a returned text string.
		"""
		lines = []
		lines.append("Line frequencies and types:")
		for obj in self.ltypes:
			[lines.append("  "+l) for l in obj.report()]
		return "\n".join(lines)

	def stats(self):
		"""Return a possibly multiline string of stats on what was summarized.
		"""
		buf = []
		try: secs = self.endTime - self.startTime
		except TypeError:
			return ["Nothing processed"]
		buf.append(f"{self.linecount} lines from {self.filecount} files in {int(secs)} seconds.")
		buf.append(f"{int(self.linecount/secs)} lines per second.")
		return "\n".join(buf)

def isTTLog(f, isGiven=False):
	"""Return True if f is a TeamTalk log file to consider.
	If isGiven is True, any file (not folder) is considered valid.
	"""
	if not os.path.isfile(f): return False
	if isGiven: return True
	name = os.path.split(f)[1]
	ext = os.path.splitext(name)[1]
	# This prevents the next test from grabbing most archive types, which are binary and thus messy as logs.  :)
	if ext.lower() in [".arc", ".bz", ".bz2", ".cab", ".gz", ".lzh", ".tar", ".tgz", ".zip", ".zoo"]: return False
	# These two avoid TeamSpeak logs, for those folks (like me!) who have placed TS3 under a /home/tt folder.
	if name.lower().startswith("ts3"): return False
	if name.lower().startswith("accept_license"): return False
	# Then whatever .log made it here is allowed.
	if ".log" in name.lower(): return True
	return False

def fileGen(files):
	"""Generator that returns files until they run out.
	Folders in the given list are converted recursively into their contents.
	Files for which isTTLog() returns False are ignored,
	unless they are specified directly in files.
	"-" becomes sys.stdin.
	"""
	for f in files:
		if f == "-":
			yield f
		if isTTLog(f, True):
			yield f
		for dirpath,dirnames,filenames in os.walk(f):
			# Ignore dirnames because os.walk will visit them.
			for f1 in filenames:
				path = os.path.join(dirpath, f1)
				if isTTLog(path):
					yield path

def processFile(logsum, f):
	"""Process file f, which is assumed to be a TT log.
	"""
	print("File " +f, file=sys.stderr)
	sys.stderr.flush()
	logsum.startfile(f)
	if f == "-": stream = sys.stdin
	else: stream = open(f, "rb")
	for line in stream:
		logsum.addLine(line)
	logsum.stopfile()

#========== Support Code For fln Command ==========

class FileData:
	"""Representation of one file.
	"""
	def __init__(self, path):
		self.path = path
		self.stat = os.stat(self.path)
		self.fkey = self._fkey()

	def _fkey(self):
		"""Get the dict key to use for a file from its path and stat information.
		"""
		return f"{self.stat.st_dev}/{self.stat.st_ino}"

class FLN:
	"""Manage a dict of link sets by fileSystem/inodeNumber.
	This class implements the meat of the fln command.
	Usage, where the [True] optional argument causes actions and/or progress and/or findings to be output.:
		fln = FLN(folders, pats)
			# Folders are the folders to scan. pats are the glob-style file patterns to match.
		fln.catalog([True]) to build the list of files to manage.
		fln.analyze([True]) to determine what file sets can be merged (this can take time).
		fln.commit([True]) to commit link changes to disk.
	Types of "bags" used by this class:
		* A link bag (lbag) is a bag of file paths that all point to a single storage of data (dev and inode match).
		* A size bag (sbag) is a bag of link bags all of which have data stores of the same size (stat.st_size match).
		* A match bag (mbag) is a bag of link bags in which all files are equal in content (filecmp.cmp returns True).
	Members (not all created by __init__ so mis-ordered calls will cause errors rather than bad actions):
		folders, pats: Folders and patterns passed to __init__.
		lbags: Dict by dev+inode of lists of file paths pointing to the same inode. Built by catalog().
		extraLinks: Dict of dev+inode to count of extra links (stat.st_nlink - len(lbag)).
		mbags: List of lists of lbags full of file paths with identical contents. Built by analyze().
	"""
	def __init__(self, folders, pats):
		self.folders = folders
		self.pats = pats
		# Other instance vars are created by analyze() so report() or commit() first will generate an error.

	def _printState(self):
		"""Print file and link counts at time of last catalog.
		Aborts with messages if any link bag indicates more links than the OS does for any file.
		Helper for catalog().
		"""
		nfiles = sum([len(v) for v in self.lbags.values()])
		if not nfiles:
			print("No user uploads found")
			return
		nbags = len(self.lbags)
		nlinks = nfiles - nbags
		nsets = len([bag for bag in self.lbags.values() if len(bag) > 1])
		nsingles = nbags - nsets
		size1 = sum([bag[0].stat.st_size * len(bag) for bag in self.lbags.values()])
		size2 = sum([bag[0].stat.st_size for bag in self.lbags.values()])
		print(f"{nfiles:d} user uploads stored as {nbags:d} files using {nlinks:d} links in {nsets:d} sets")
		if size1 == size2:
			print(f"{hrsize(size1)} used by user uploads, no space currently saved by links")
		else:
			print(f"Now saving {hrsize(size1-size2)} (using {hrsize(size2)} to store {hrsize(size1)} of uploads)")
			print(f"{nsingles} uploads without extra links")
		# If any files are linked outside the scanned area, warn of this.
		extras = []
		missings = []
		outsize = 0
		for lbag in self.lbags.values():
			nextra = self.extraLinks[lbag[0].fkey]
			if nextra == 0: continue
			if nextra < 0:
				missings.append(0 - nextra)
				continue
			extras.append(nextra)
			outsize += lbag[0].stat.st_size
		if extras:
			print(f"Warning: {sum(extras)} links to {len(extras)} files ({hrsize(outsize)} of storage) go outside this area.")
		if missings:
			# This should never happen.
			print(f"Error: {sum(missings)} links to {len(missings)} files are not accounted for by os.stat!")
			print("This is not expected and could represent problems or concurrent file changes.")
			sys.exit("File analysis aborted.")

	def catalog(self, verbose=False):
		"""Catalog files matching the given folders and patterns.
		This finds all files and links that already exist among them, and builds self.lbags.
		This also builds self.extraLinks to indicate any files with links pointing outside this area.
		"""
		# Clear any analysis results as we're about to invalidate them anyway.
		try: del self.mbags
		except Exception: pass
		self.lbags = {}
		self.extraLinks = {}
		[self._catalogFolder(f) for f in self.folders]
		for lbag in self.lbags.values():
			nlinks = lbag[0].stat.st_nlink
			nextra = nlinks - len(lbag)
			# Ignore this on platforms that don't give link counts on a stat call.
			if nlinks == 0 and nextra < 0: nextra = 0
			# Still store 0 in that case though so lookups don't generate KeyErrors.
			self.extraLinks[lbag[0].fkey] = nextra
		if verbose:
			self._printState()

	def _catalogFolder(self, folder):
		"""Include the matching contents of one folder and its descendants.
		This updates self.lbags and self.extraLinks.
		"""
		for dirpath,dirs,files in os.walk(folder):
			# Get the set of files that match the given patterns.
			flist = []
			[flist.extend(glob.fnmatch.filter(files, pat)) for pat in self.pats]
			# Eliminate duplicates though there probably shouldn't be any.
			flist = set(flist)
			files = flist
			[self._record(os.path.join(dirpath, fname)) for fname in files]

	def _record(self, path):
		"""Record a file given its full path.
		"""
		fdata = FileData(path)
		k = fdata.fkey
		self.lbags.setdefault(k, [])
		self.lbags[k].append(fdata)

	def analyze(self, verbose=False):
		"""Analyze files for common contents.
		Also runs through results and prints what would be done but without writing to disk.
		Call only after catalog() has been called.
		"""
		# Make size bags of link bags.
		# A link bag is a bag of file paths that all point to a single storage of data.
		# A size bag is a bag of link bags all of which have data stores of the same size.
		# (See also the class doc block for a description of the three bag types used here.)
		sbags = {}
		for lbag in self.lbags.values():
			sz = lbag[0].stat.st_size
			sbags.setdefault(sz, [])
			sbags[sz].append(lbag)
		# We only need the size bags with more than one member.
		# This also makes a list instead of a dict.
		sbags = [sbag for sbag in sbags.values() if len(sbag) > 1]
		nfiles = sum([len(sbag) for sbag in sbags])
		if verbose:
			if nfiles:
				print(f"{len(sbags)} file sets (by size) to compare, representing {nfiles:d} user uploads")
			else:
				# A "no user uploads" message should have already printed.
				pass
		# Now to make match bags of link bags, using size bags to decide what files to compare.
		# A match bag is a bag of link bags in which all files are equal in content.
		# This is a list instead of a dict because there's no key to determine what goes with what,
		# though they could easily be sorted/categorized by size.
		mbags = []
		self.ncomps = 0
		[mbags.extend(self._splitSizeBag(sbag, verbose)) for sbag in sbags]
		self.mbags = mbags
		if verbose:
			print(f"{self.ncomps:d} file comparisons were required")
			self._process(verbose, False)

	def _splitSizeBag(self, sbag, verbose):
		"""Return a list of match bags for file sets of one size based on content comparisons.
		Helper for analyze().
		"""
		mbags = []
		for lbag in sbag:
			f1 = lbag[0].path
			foundMatch = False
			for mbag in mbags:
				# Compare only against the first file path in the first link bag in this match bag.
				f2 = mbag[0][0].path
				self.ncomps += 1
				matched = filecmp.cmp(f1, f2, shallow=False)
				if matched:
					mbag.append(lbag)
					foundMatch = True
					break
			if not foundMatch:
				mbag = [lbag, ]
				mbags.append(mbag)
		# Remove match bags with only one link bag since these can't be linked to anything else.
		mbags = [mbag for mbag in mbags if len(mbag) > 1]
		return mbags

	def _process(self, verbose, commit, keepBackups=False):
		"""Run through self.mbags and self.extraLinks and report what would be done.
		If commit is True, also do it.
		If keepBackups is True, every newly linked file will leave its original as the same name plus ".bk"
		Helper for analyze() and commit().
		Call catalog() and then analyze() first.
		"""
		# Removed and held are lists of file sizes for inodes removed or held by external links, respectively.
		# A "held" file is still merged, but the external link(s) prevent the inode from going away.
		removed = []
		held = []
		for mbag in self.mbags:
			# mbag is a set of two or more link bags of files whose sizes and contents all match exactly.
			# Select which link bag (inode) to consider the target for all others in this set.
			# The target link path used will be that of the first file in the link bag chosen.
			# Priorities used here:
			#	* The first inode/lbag with an external link, because deleting links to it wouldn't save any space.  OR
			#	* The oldest mod time (using best available precision on the platform).
			# First copy mbag so modifying it (by deleting the target) won't alter the original structure.
			mbag = mbag.copy()
			target = None
			for lbag in mbag:
				# Assert: There are no negative extraLinks counts; those should have aborted the program already.
				# This means that .get() will return None (shouldn't), 0, or a positive integer.
				# We convert None to 0 and check values carefully just for rigor though.
				nextra = self.extraLinks.get(lbag[0].fkey) or 0
				if nextra > 0:
					target = lbag
					break
				if not target or target[0].stat.st_mtime_ns > lbag[0].stat.st_mtime_ns:
					target = lbag
			# Remove the target from the (copied) list, so the list becomes just sources.
			mbag.remove(target)
			targetPath = target[0].path
			if verbose and not commit:
				print(f"Link {sum([len(lbag) for lbag in mbag])} user uploads in {len(mbag)} sets to {targetPath}")
			for lbag in mbag:
				nextra = self.extraLinks.get(lbag[0].fkey) or 0
				if nextra > 0:
					held.append(lbag)
				else:
					removed.append(lbag)
				for fdata in lbag:
					sourcePath = fdata.path
					if commit:
						# This rename-before-link stunt has three benefits:
						#	* A crash before link leaves a way to restore state.
						#	* Simultaneous runs of this process have less chance of making a mess.
						#	* Implementation of the -b backup flag was trivial from here.
						tempPath = f"{sourcePath}.bk"
						os.rename(sourcePath, tempPath)
						# If things die just before this line, manually rename the .tmp file to lose its .tmp suffix.
						os.link(targetPath, sourcePath)
						if not keepBackups:
							os.unlink(tempPath)
		if not verbose: return
		cmt = "Saved" if commit else "Would save"
		if removed:
			print(f"{cmt} {hrsize(sum([lbag[0].stat.st_size for lbag in removed]))} by replacing {sum([len(lbag) for lbag in removed])} files in {len(removed)} sets with links")
		else:
			print("No new links to make")
		if held:
			print(f"Note that {hrsize(sum([lbag[0].stat.st_size for lbag in held]))} are held by {sum([len(lbag) for lbag in held])} external links in {len(held)} sets")

	def commit(self, verbose=False, keepBackups=False):
		"""Commit link changes to disk to save file space.
		If keepBackups is True, every newly linked file will leave its original as the same name plus ".bk"
		Call catalog() and then analyze() first.
		"""
		self._process(verbose, True, keepBackups)

#========== Box and System classes ==========

class Box:
	"""A TeamTalk server box (machine or VM).
	Some methods in this class may prompt the user.
	This is a singleton class; the box object will be created just after this class definition.
	See __init__ for defaults; override them in tt.conf in this utility's folder.
	Class members:
		serverName: The name used for all managed server binaries (normal and pro).
			This is also the name used in TeamTalk distributions for non-pro servers.
		proServerName: The name used in TeamTalk distributions for a pro server.
		update_url: The base URL for downloading server updates.
		update_url_pro: The same for pro server updates.
	Instance members:
		bindir: The directory to contain server binaries.
		binfilter: The filter to help select server binary update downloads.
		ttdir: The folder containing server configs.
		user, group: The user and group used by servers.
	"""
	serverName = "tt5srv"
	proServerName = "tt5prosrv"
	update_url = "https://bearware.dk/?page_id=353"
	update_url_pro = "https://bearware.dk/?page_id=976"
	update_url_beta = "https://bearware.dk/beta"
	update_url_rawfmt = "https://bearware.dk/teamtalk/v{ver}"

	def __init__(self):
		# ToDo: This folder typically requires root; but so do some of this utility's operations.
		self.bindir = "/usr/bin"
		self.binfilter= ""
		self.ttdir = "/home/tt"
		self.user = "tt"
		self.group = "tt"
		self.configure()

	def configure(self):
		"""Allow local overrides of values that already exist as attributes without leading underscores.
		"""
		fname = "tt.conf"
		fpath = os.path.join(os.path.dirname(sys.argv[0]), fname)
		if not os.path.exists(fpath): return
		try:
			with open(fpath) as f: lines = f.read().splitlines()
		except OSError: return
		for line in lines:
			line = line.strip()
			if not line or line[0] in ";#": continue
			kw,val = line.split("=", 1)
			kw = kw.strip()
			val = val.strip()
			if not kw[0].isalpha() or not hasattr(self, kw):
				print(f"Warning: Ignoring {kw} in config file {fpath}")
				continue
			setattr(self, kw, val)

	def path(self):
		"""The path to the managed server binary. Not required to exist.
		None if the folder path doesn't exist.
		"""
		if not os.path.exists(self.bindir):
			return None
		path = os.path.join(self.bindir, self.serverName)
		return path

	def version(self, path=None):
		"""The version of the server, as reported by tt5srv --version, and its numeric part.
		Internally, path may be passed to test a specific server instance.
		Returns a (fullString, verString) tuple.
		None if the server is not found. Null strings if it is found but reports no recognized version (not expected).
		"""
		if path is None: path = self.path()
		if not path or not os.path.exists(path):
			return None
		try:
			proc = run([path, "--version"], stdout=PIPE, check=False, universal_newlines=True)
		except Exception as e:
			print(str(e), file=sys.stderr)
			return None
		full = proc.stdout.strip()
		# This leaves the build number included, whereas the build number tends not to appear in versions on the website.
		try: ver = full.rsplit(None, 1)[1]
		except Exception: ver = ""
		return (full, ver)

	def binaryChoices(self, wantPro, wantBeta, filters=None, verFolder=None):
		"""Return a set of zero or more URLs for TT server downloads.
		If passed, filters is an iterable of strings, each of which must be found in each choice returned.
		This has two intended uses: Restrict by OS, and restrict by file type.
		At this time, however, a .tgz file extension restriction is hard-coded here.
		"""
		if not filters: filters = []
		url = self.update_url_pro if wantPro else self.update_url
		if wantBeta: url = self.update_url_beta
		# Safety.
		if verFolder and not re.match(r'^\d+\.[\d.]+$', verFolder):
			verFolder = None
		if verFolder:
			print(f"Restricting to v{verFolder}")
			url = self.update_url_rawfmt.replace("{ver}", verFolder)
		try:
			with urlopen(url) as req: txt = req.read()
		except IOError as e:
			print(str(e))
			return []
		# Start with all href= values with the right file extension.
		if wantBeta or verFolder:
			choices = re.findall(rb'href="([^"]*?\.tgz)"', txt)
			if wantPro:
				choices = [ch for ch in choices if b"teamtalkpro" in ch]
			else:
				choices = [ch for ch in choices if b"teamtalkpro" not in ch]
		else:
			choices = re.findall(rb'href="(http[^"]*?\.tgz)"', txt)
		# Convert from bytes to str for convenience.
		# Doing it here instead of sooner allows any encoding issues outside of href= values to be ignored.
		choices = [c.decode("utf-8") for c in choices]
		if verFolder:
			prefix = self.update_url_rawfmt.replace("{ver}", verFolder)
			choices = [f"{prefix}/{ch}" for ch in choices]
		elif wantBeta:
			choices = [f"{self.update_url_beta}/{ch}" for ch in choices]
		# Filter further if asked to do so.
		# This returns all if filters is empty.
		choices = [c for c in choices if all(f.lower() in c.lower() for f in filters)]
		return choices

	def binupdate(self, mode="k", verFolder=None):
		"""Install or update a server.
		Uses bindir and binfilter, which can be set in tt.conf.
		On update, if the local server matches the current download, this is reported and nothing is changed.
		Mode: k (default) to keep current type, n for normal, p for professional.
		k means n when there is no server installed yet.
		Mode can also include b for beta versions.
		If verFolder is specified, it should be a version number as appears as a folder name on Bjoern's site.
		Example: "5.11." This causes downloads to come from only that folder.
		NOTE: If your server is a symlink, the destination of the link will be updated.
		"""
		path = self.path()
		bindir = self.bindir
		if path and os.path.islink(path):
			path = os.readlink(path)
			bindir = os.path.dirname(path)
		if not path:
			# None means the folder doesn't exist, not just the binary.
			errExit(f"Path {bindir} does not exist; please create first or fix the path in tt.conf")
		verInfo = self.version()
		full,ver = ("","") if verInfo is None else verInfo
		isPro = False
		wantPro = False
		wantBeta = False
		if "b" in mode:
			mode = mode.replace("b", "")
			wantBeta = True
		if verInfo is None:
			if not confirm(f"Server {path} not yet installed. Install now?"):
				return
			if mode == "p": wantPro = True
		elif ver == "":
			if not confirm(f"Server {path} not recognized. Replace now?"):
				return
			if mode == "p": wantPro = True
		else:
			isPro = "professional" in full.lower()
			if not confirm(f"{full} is currently installed as {path}.  Update?"):
				return
			if mode == "k": wantPro = isPro
			elif mode == "p": wantPro = True
		isRoot = (os.geteuid() == 0)
		# An update requires that the file can be written, and also its directory.
		# Design note: This is an advisory check, not a replacement for install-time error handling;
		# we look for obvious problems before downloading a whole tar file but still handle errors at install time.
		# This saves time when things are not set up locally as they should be.
		if not (isRoot or os.access(bindir, os.W_OK)):
			errExit(f"You do not have sufficient permissions to install or update {path}")
		if isPro != wantPro:
			newType = "professional" if wantPro else "non-professional"
			if not confirm(f"Warning: You are about to change your server type to a {newType} server.  Proceed?"):
				return
		choices = self.binaryChoices(wantPro, wantBeta, self.binfilter, verFolder)
		if not choices:
			print("No choices found")
			return
		# A full URL does not a friendly option make.
		prompts = []
		for choice in choices:
			prompt = os.path.basename(choice)
			prompt = os.path.splitext(prompt)[0]
			prompt = prompt.replace("teamtalk-v", "", 1).replace("-", " ", 1)
			# That's the version number, a space, and the OS name and bitness info.
			prompts.append(prompt)
		ch = getChoice("Select desired version (often the first one shown):", prompts)
		if not ch: return
		# prompts and choices are parallel lists; indices in one work in the other.
		# That was 1-based though, and Python lists are 0-based.
		ch -= 1
		url = choices[ch]
		print("Downloading and installing...")
		# This reads the server binary into memory - 3 meg or so in 2021.
		try:
			with urlopen(url) as f: srv = self._getSrv(f, wantPro)
		except IOError as e:
			print(str(e))
			return
		# Read the current version for comparison.
		try:
			with open(path, "rb") as f: csrv = f.read()
		except IOError: csrv = bytes()
		if srv == csrv:
			print("No change; the current server version is already installed")
			return
		# First write to a temp name for testing before installation.
		# ToDo: Two people simultaneously updating this might cause interesting results; not tested carefully.
		ptemp = f"{path}.tmp"
		try: os.unlink(ptemp)
		except Exception: pass
		with open(ptemp, "wb") as f: f.write(srv)
		os.chmod(ptemp, 0o755)
		vtemp = self.version(ptemp)
		if not vtemp or not vtemp[0]:
			try: os.unlink(ptemp)
			except Exception: pass
			errExit("New server failed to run; update aborted")
		# Update the local server instance.
		oldpath = path +".old"
		try: os.unlink(oldpath)
		except Exception: pass
		try:
			os.rename(path, oldpath)
			print(f"Original server left for backup in {oldpath}")
		except Exception: pass
		os.rename(ptemp, path)
		print("Done.")

	def _getSrv(self, f, wantPro):
		"""Given f as an open tar stream, extract and return as bytes the server binary from within it.
		"""
		# Python 3 allows tars to be uncompressed or compressed as gzip, bzip, or lzma (gz/bz2/xz).
		# The * below causes transparent handling of all that.
		try:
			with tarfile.open(fileobj=f, mode="r|*") as tf:
				for ti in tf:
					if not ti.name.endswith(f"/{self.proServerName}" if wantPro else f"/{self.serverName}"): continue
					with tf.extractfile(ti) as xf: srv = xf.read()
					# Stop reading the tar stream as soon as we get what we want.
					# In practice this cuts the time by an estimated factor of at least 10. [DGL, 2021-12-21]
					# The time savings is of course governed by the order of files in the tar itself.
					break
		except (tarfile.ReadError, tarfile.CompressionError) as e: errExit(e)
		return srv

	@staticmethod
	def ugStatus():
		"""Return a string containing "u" if the user exists and "g" if the group exists.
		u comes before g when both are present.
		"""
		stat = "ug"
		try: getpwnam(box.user)
		except KeyError: stat = stat.replace("u", "")
		try: getgrnam(box.group)
		except KeyError: stat = stat.replace("g", "")
		return stat

	def checkIds(self):
		"""Check and offer to install user and group ids if necessary.
		"""
		ugStat = self.ugStatus()
		if ugStat == "ug": return
		uPrompt = "" if "u" in ugStat else f"user {box.user}"
		gPrompt = "" if "g" in ugStat else f"group {box.group}"
		prompt = f"{uPrompt} and {gPrompt}" if uPrompt and gPrompt else f"{uPrompt}{gPrompt}"
		if not confirm(f"Need to set up {prompt}. Proceed?"):
			sys.exit("Aborted")
		pth1 = shutil.which("useradd")
		pth2 = shutil.which("groupadd")
		if pth1 and pth2:
			# Should work on most if not all Linux distros.
			cmd = [pth2,
				"--system", # also -r
				box.group
			]
			if "g" not in ugStat:
				try: run(cmd, check=True)
				except Exception as e: errExit(str(e))
			cmd = [pth1,
				"--comment", "TeamTalk server user",
				"-d", f"/home/{self.user}",
				"--create-home", # also -m
				"--system", # also -r
				"-N", "-g", box.group,
				"-s", "/bin/false",
				box.user
			]
			if "u" not in ugStat:
				try: run(cmd, check=True)
				except Exception as e: errExit(str(e))
			return
		# Not finding useradd and groupadd.
		pth = shutil.which("dscl")
		if pth:
			# Specific to MacOS.
			# ToDo: dscl or alternate means not implemented here.
			pass
		errExit(f"Please make user {box.user} and/or group {box.group} and retry this operation")

box = Box()

# This systemd service file was provided by Daniel Nash (2021-22).
# Note from Sun Apr 6 2025: Batuhan Demir in my Telegram discussion group suggests this:
# After=network-online.target
# Wants=network-online.target
systemd_service = (
"""[Unit]
Description=TeamTalk 5 server for %I
After=network.target

[Service]
ExecStart=/usr/bin/tt5srv -nd -c /home/tt/%I.xml -l /home/tt/%I.log
Type=simple
User=tt
Group=tt
Restart=always
[Install]
WantedBy=multi-user.target
""").replace("\r\n", "\n")

class Systemd:
	"""Configuration manager for systemd.
	This is a singleton class; the systemd object will be created just after this class definition.
	"""
	ss_text = systemd_service
	ss_name = "tt5@.service"
	ss_dir = "/usr/lib/systemd/system"
	ss_path = os.path.join(ss_dir, ss_name)
	def __init__(self):
		self.status = ""
		self.setStatus()

	def setStatus(self):
		"""Set self.status:
			n: Not installed.
			e: Error reading installed version but there is one.
			u: Needs update (installed but does not match current version).
			i: Installed and matches current version.
		"""
		if not os.path.exists(self.ss_path):
			self.status = "n"
			return
		try:
			with open(self.ss_path) as f: curText = f.read()
		except OSError as e:
			self.status = "e"
			return
		if curText != self.ss_text:
			self.status = "u"
			return
		self.status = "i"

	def install(self):
		prompts = {
			"n": "Installing systemd service.",
			"e": "Error verifying system service; trying to update.",
			"u": "Updating systemd service.",
			"i": "The systemd service is already installed and current.",
		}
		prompt = prompts[self.status]
		if self.status == "i":
			print(prompt)
			return
		if not confirm(f"{prompt} Proceed?"):
			sys.exit("Aborted")
		try:
			with open(self.ss_path, "w") as f: f.write(self.ss_text)
			return True
		except OSError as e: errExit(str(e))
		# These may fail but shouldn't; but the work is mostly done, so they cause only a warning.
		try:
			os.chmod(self.ss_path, 0o644)
			shutil.chown(self.ss_path, user=box.user, group=box.group)
		except Exception as e: print(f"Warning: {str(e)}")
systemd = Systemd()

#========== User Command Handling Classes ==========

class Command:
	"""One command from a command processor.
	Members: name, func, help, cat, aliases.
	Supports sorting and making sets. str uses the command name.
	"""
	def __init__(self, f):
		self.func = f
		self.name = f.__name__[3:]
		self.help = f.__doc__
		self.cat = f.cat
		self.aliases = f.aliases if hasattr(f, "aliases") else []

	def __str__(self):
		"To simplify printing of command entries."
		return self.name

	def __hash__(self):
		"To support sets."
		return hash(self.name)

	def __lt__(self, other):
		"Permits sorting by name."
		return self.name < other.name

class CommandProcessorBase:
	"""Base class for command processor classes.
	Make a subclass of this and do the following to make an actual command handler:
		* In __init__(), add any required categories, then call this class's init.
		* For each command, define do_<commandName>>(self, args).
		* After each, set do_<commandName>.cat = "general" or appropriate category.
		* In each do_* method's doc block, the first line is the one-line help (command description).
		* Any following lines are the full help for the command.
	"""
	cats = [
		# Format: (internalName, displayName, description).
		("general", "General", "General commands"),
	]

	# Anything in this list may not be a command or category regardless of case.
	# All elements in this list must be in lower case.
	forbidden = ["all"]

	def __init__(self):
		"""Build the command and alias list and do various error checks.
		"""
		self.cmds = {}
		# Make sure all do_* methods have categories and that they are valid.
		# This includes categories and methods defined in subclasses.
		# Also make sure any command aliases don't collide with actual commands.
		catnames = [c[0] for c in self.cats]
		bad = [c for c in catnames if c.lower() in self.forbidden]
		if len(bad):
			errExit(f"Disallowed category name(s): {' '.join(bad)}")
		for attr in dir(self):
			if not attr.startswith("do_"): continue
			f = getattr(self, attr)
			if not hasattr(f, "cat"): errExit(f"{attr} is not assigned a category")
			if not hasattr(f, "__doc__"): errExit(f"{attr} has no help doc block")
			if not f.__doc__ or not f.__doc__.strip(): errExit(f"{attr} has an empty help doc block")
			v = f.cat
			if v not in catnames: errExit(f"{attr} is assigned category {v} but that category is not listed in cats")
			attr = attr[3:]
			if [c for c in self.cmds if c.lower() == attr.lower()]:
				errExit(f"{attr} is both a command and an alias or appears in multiple cases")
			fc = Command(f)
			self.cmds[attr] = fc
			if hasattr(f, "aliases"):
				for al in f.aliases:
					if [c for c in self.cmds if c.lower() == al.lower()]:
						errExit(f"{al} is both a command and an alias or appears in multiple cases")
					self.cmds[al] = fc
		bad = [c for c in self.cmds if c.lower() in self.forbidden]
		if len(bad):
			errExit(f"Disallowed command(s): {' '.join(bad)}")
		bad = [c for c in self.cmds if c.lower() in catnames]
		if len(bad):
			errExit(f"Command/category name collision(s): {' '.join(bad)}")

	def commandMatches(self, kw=""):
		"""Return as many unique Command objects as match the given keyword.
		If kw is not given, all unique Command objects are returned.
		If kw exactly (ignoring case) matches one command, that command alone is returned.
		"""
		s = set(c for k,c in self.cmds.items() if kw and k.lower() == kw.lower())
		if len(s) != 1:
			s = set(c for k,c in self.cmds.items() if not kw or k.lower().startswith(kw.lower()))
		return sorted(s, key=lambda c: c.name)

	def process(self, argv):
		"""Process one command. Includes command lookup and dispatch.
		"""
		progname = argv.pop(0)
		if not argv: argv.append("help")
		cmd = argv.pop(0)
		matches = self.commandMatches(cmd)
		if not matches: errExit(f'"{cmd}" does not match a valid command. Type help for help.')
		if len(matches) != 1:
			# Make an exact (case-insensitive) match work even if there are less precise ones.
			# This allows "log" and "logcat" to be valid commands, for example.
			ms = [m for m in matches if m.name.lower() == cmd]
			if len(ms) == 1: matches = ms
		if len(matches) != 1:
			errExit(f'"{cmd}" matches {len(matches)} commands ({", ".join([str(m) for m in matches])}). Type help for help.')
		cmd = matches[0]
		func = cmd.func
		if not self.precmd(cmd.name, argv): return
		func(argv)

	def precmd(self, cmd, argv):
		"""Override in subclasses if you need to stop some commands from running or perform actions before them.
		Of course this idea comes from the built-in Python cmd module.
		"""
		return True

	def do_help(self, args):
		"""Help on this program itself or on a specific command or category.
		Help by itself shows general help for this program.
		help followed by a command name shows help for that command.
		help followed by a category shows help for that category and its commands.
		help all shows help for all categories and commands.
		"""
		if len(args) > 1:
			errExit(f"The help command can be typed alone or with one argument, not {len(args)}")
		if not args:
			# Basic program help.
			txt = __main__.__doc__.replace("<revno>", str(revno)).replace("<catList>", self._catList())
			printHelp(txt)
			return
		# Exactly one argument after help.
		kw = args.pop()
		if kw.lower() == "all":
			# All categories with one-line help for each.
			# If ALL is in all upper case, all help for all commands is included.
			printHelp(self._catList(expand=2 if kw=="ALL" else 1))
			return
		# A command or category, or initial part of one.
		cmds = self.commandMatches(kw)
		catnames = [c[0] for c in self.cats if c[1].lower().startswith(kw.lower())]
		if len(cmds) + len(catnames) == 0:
			errExit(f"{kw} does not match any command or category")
		elif len(cmds) + len(catnames) > 1:
			errExit(f"{kw} is ambiguous")
		if len(catnames):
			# Help for a category.
			printHelp(self._catList(catname=catnames[0], expand=2 if kw==kw.upper() else 1))
			return
		# Help for a speciffic command.
		cmd = cmds[0]
		f = cmd.func
		# The command name is included in case the user typed only a prefix of it.
		printHelp(f"{cmd.name} - {cmd.help.strip()}")
		if cmd.aliases:
			printHelp(f"Aliases: {', '.join(cmd.aliases)}")
		printHelp(f"Category: {cmd.cat}")
	do_help.cat = "general"

	@classmethod
	def launchURL(cls, url):
		"""Launch the given URL in a browser.  May not be supported on all platforms."
		Provides some protection against calls with non-URL strings.
		Raises a CalledProcessError with a returncode attribute if unsuccessful.
		Raises a RuntimeError if not supported on this platform.
		Returns True on success for compatibility with older code that expected a True/False result instead of exceptions.
		Adapted for this utility from mycmd.py, 2022-09-01.
		"""
		try: urltype = url.split(":", 1)[0].lower()
		except Exception: urltype = ""
		if len(urltype) < 1 or not re.match(r'^[a-z0-9_]+$', urltype):
			raise ValueError("URL type not recognized")
		url = url.replace(" ", "%20").replace('"', "%22").replace("'", "%27")
		browser = None
		plat = sys.platform
		if browser:
			cmds = ([browser, url],)
		elif plat == "cygwin" or plat.startswith("win"):
			# On Cygwin, `cygstart' works but prevents Windows auto-login from working.
			# Running Explorer directly fixes that, at least on Windows XP.
			# [DGL, 2009-03-17]
			# It also works on ActivePython.
			cmds = (["explorer", url],)
		elif "linux" in plat.lower():
			# wslview enables use of default Windows browser on WSL.
			cmds = (["wslview", url], ["xdg-open", url], ["firefox", url], ["lynx", url], ["links", url], ["w3m", url],)
		elif plat == "darwin":  # MacOS
			cmds = (["open", url],)
		else:
			raise RuntimeError("LaunchURL not supported on this platform")
		print("Web page launching.")
		for i,cmd in enumerate(cmds):
			try:
				check_call(cmd)
				# Quit trying alternatives if that did not throw an exception.
				break
			except Exception as e:
				# Windows Explorer returns 1 on success! [DGL, 2017-09-30, Windows 10]
				if (isinstance(e, CalledProcessError)
				and (plat == "cygwin" or plat.startswith("win"))
				and e.returncode == 1):
					# Call that a success by exiting the loop.
					break
				if i+1 == len(cmds):
					# Fail if this is the last command to try.
					raise
				# Otherwise just try the next command quietly.
		# One of the commands succeeded.
		return True

	def _catList(self, expand=0, catname=""):
		"""Return a category and/or command list according to arguments.
		Helper for do_help().
		Used to put the category list in the main program help if requested by the <catList> (case-insensitive) marker.
		Also used with expand=1 or 2 to make category lists with short or long help per commands.
		Used with a passed category name to produce a command list for a category.
		"""
		txt = []
		started = False
		# This loops through categories.
		for name,display,desc in self.cats:
			if catname and name != catname: continue
			cmds = []
			# This loops through commands to find all commands in the current category.
			for cmd in self.commandMatches():
				if cmd.cat != name: continue
				cmds.append(cmd)
			if not cmds: continue
			if started and expand > 0:
				# Blank line between categories but not before the ffirst one.
				txt.append("")
			started = True
			txt.append(f"{display} - {desc}:")
			cmds.sort()
			if expand == 0:
				# Comma-separated one-line command list.
				txt.append(f"   {', '.join([str(c) for c in cmds])}")
			else:
				# One or more lines per command in each category.
				for cmd in cmds:
					f = cmd.func
					hlp = cmd.help.strip().splitlines()
					txt.append(f"{cmd} - {hlp.pop(0).strip()}")
					if expand > 1:
						for line in hlp:
							line = line.strip()
							txt.append(f"   {line}")
						if cmd.aliases:
							txt.append(f"   Aliases: {', '.join(cmd.aliases)}")
		return "\n".join(txt)

class CommandProcessor(CommandProcessorBase):
	"""Processor of one user command.
	"""
	def __init__(self):
		self.cats.extend([
			("setup", "Setup", "Commands for setting up and removing servers"),
			("running", "Running", "Commands for starting, stopping, and setting boot-time behavior for a server"),
			("misc", "Miscellaneous", "Server-related commands not in another category"),
		])
		super().__init__()

	def precmd(self, cmd, argv):
		"""Stop commands if their prerequisites are missing.
		"""
		if not box.path():
			errExit(f"Path {box.bindir} does not exist; please create first or fix the path in tt.conf")
		return True

	def do_license(self, args):
		"""Print the license for this utility.
		"""
		print("""
Copyright (c) 2018-2024 Doug Lee, with original contributions by Daniel Nash, 2021-22

All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice,
  this list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
  this list of conditions and the following disclaimer in the documentation
  and/or other materials provided with the distribution.

* The names of the copyright holders and contributors may not be used to
  endorse or promote products derived from this software without specific
  prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
		""".lstrip().replace("\r\n", "\n"))
	do_license.cat = "general"

	def do_man(self, args):
		"""Go to the online tt manual.
		-b goes to the beta version's manual.
		"""
		beta = ("-b" in args)
		url = "updates" if beta else "teamtalk/tt"
		url = f"https://www.dlee.org/{url}/tt.htm"
		return self.launchURL(url)
	do_man.cat = "general"

	def do_rel(self, args):
		"""Go to the online tt manual's Revision History (release notes) section.
		-b uses the beta version's documentation.
		"""
		beta = ("-b" in args)
		url = "updates" if beta else "teamtalk/tt"
		url = f"https://www.dlee.org/{url}/tt.htm#hist"
		return self.launchURL(url)
	do_rel.cat = "general"

	def do_remote(self, args):
		"""Print information on how to execute this utility against a remote server.
		"""
		print("""
Remote command execution:
WARNING: This only works for commands that don't prompt for answers.
This is also likely to fail against servers with an unusual tt.conf file.

tt @host or user@host command [args...] to run a command on another server via ssh.
Examples:
tt @host1 list (assumes host1 is defined in ~/.ssh/config)
tt tt@host2.mydomain.com list
These may be chained - e.g., tt me@host1 me2@host2 list
tt assumes that public key authentication, not passwords, are in use with such ssh hosts.

This facility is intended for remote statistics collection
and for use when a remote server has not installed this utility.
		""".lstrip().replace("\r\n", "\n"))
	do_remote.cat = "general"

	def do_TTConf(self, args):
		"""Print information on how to configure this utility for unusual circumstances.
		"""
		print("""
The tt.conf file:

This file is normally not necessary but may be used to handle unusual circumstances.
tt.conf must reside in this utility's folder.
Lines beginning with # or ; are ignored, as are blank lines.
Lines consist of a key word, an = sign, and a value.
Leading and trailing spaces and spaces around the = sign are ignored.

Key words and values understood:
bindir: The full path to the TeamTalk server binary folder. Default /usr/bin
binfilter: A string used to select viable choices for server binary downloads. Default empty.
   Example: binfilter=centos
ttdir: The full path to all TeamTalk configuration files. Default /home/tt
user: The user to use for all operations. Default tt
group: The group to use for all operations. Default tt
		""".lstrip().replace("\r\n", "\n"))
	do_TTConf.cat = "general"

	def do_version(self, args):
		"""Print the path and version of the TeamTalk server installed on this system and managed by this utility.
		"""
		if args: errExit("Unrecognized arguments")
		verInfo = box.version()
		if verInfo:
			print(f"{box.path()}: {verInfo[0]}")
		else:
			print("Server not found or not recognized")
	do_version.cat = "general"

	def do_init(self, args):
		"""Initialize this system for TeamTalk server management.
		Tasks included:
			* User and group setup.
			* systemd service installation or update.
			* TeamTalk server binary installation if necessary.
		"""
		box.checkIds()
		print(f"User {box.user} and group {box.group} are set up")
		systemd.install()
		if not os.path.exists(box.bindir):
			errExit(f"Path {box.bindir} does not exist; please create first or fix the path in tt.conf")
		spath = os.path.join(box.bindir, box.serverName)
		if not os.path.exists(spath):
			print(f"TeamTalk server not yet installed in {box.bindir}")
			stype = "-n"
			if confirm("Is this a professional server with a license and SSL certificates?"):
				stype = "-p"
			self.do_binupdate([stype])
	do_init.cat = "setup"

	def do_binupdate(self, args):
		"""Attempt to update or install the TeamTalk server.
		Use -p to force a professional server install or update.
		Note that professional servers require a license and some extra SSL certificate setup.
		Use -n to force a non-professional (normal) install or update.
		If neither is specified, the installed type, or normal for a new install, is assumed.
		Use -b to update to a beta version (normal or pro).
		For unadvertised versions, specify a version like 5.11.
		On update, if the local server matches the current download, this is reported and nothing is changed.
		NOTE: If your server is a symlink, the destination of the link will be updated.
		This is intended as a convenience for testing servers under Windows+WSL without having to be root.
		"""
		if "-p" in args:
			mode = "p"
			args.remove("-p")
		elif "-n" in args:
			mode = "n"
			args.remove("-n")
		else:
			mode = "k"
		if "-b" in args:
			mode += "b"
			args.remove("-b")
		verFolder = None
		if args and re.match(r'^\d+\.[\d.]+$', args[0]):
			verFolder = args.pop(0)
		if args: errExit("Unrecognized arguments")
		box.binupdate(mode, verFolder)
	do_binupdate.cat = "setup"

	def do_selfupdate(self, args):
		"""Update this utility from its Internet home.
		-b causes an update to the latest published beta version instead of the latest formal release.
		-y updates without prompting for permission.
		-n or -c checks for an update without updating. -n and -c override -y.
		"""
		beta = ("-b" in args)
		force = ("-y" in args)
		check = ("-n" in args or "-c" in args)
		url = "updates" if beta else "teamtalk/tt"
		url = f"https://www.dlee.org/{url}/tt.tgz"
		progPath = sys.argv[0]
		if not os.path.dirname(progPath) or not os.path.basename(progPath):
			errExit("Unable to update this utility without knowing its file path")
		with open(progPath) as f: progNow = f.read()
		try:
			with urlopen(url) as f: progNext = self._getTT(f)
		except IOError as e:
			errExit(str(e))
		revNow = progNow.split("revno = ", 1)[1].split(None, 1)[0]
		revNext = progNext.split("revno = ", 1)[1].split(None, 1)[0]
		updText = f"to revision {revNext}" if revNext == revNow else f"from revision {revNow} to revision {revNext}"
		if progNext == progNow:
			# That is a full-text program comparison, not just a revno check.
			sys.exit(f"You are already running revision {revNext}")
		if check:
			sys.exit(f"tt needs an update {updText}")
		if not (force or confirm(f"Update tt {updText}?")):
			sys.exit("Aborted")
		with open(progPath, "w") as f: f.write(progNext)
		sys.exit(f"Updated {updText}")
	do_selfupdate.cat = "general"

	@staticmethod
	def _getTT(f):
		"""Given f as an open tar stream, extract and return as text the tt utility source code from within it.
		"""
		try:
			with tarfile.open(fileobj=f, mode="r|*") as tf:
				for ti in tf:
					if ti.name != "tt": continue
					with tf.extractfile(ti) as xf: txt = xf.read()
					break
		except (tarfile.ReadError, tarfile.CompressionError) as e: errExit(e)
		return txt.decode("utf-8")

	@staticmethod
	def serverList():
		"""Return a list of server file names, without .xml extensions.
		"""
		try:
			fnames = [os.path.splitext(f)[0] for f in os.listdir(box.ttdir) if
				f.endswith(".xml")
				and os.path.isfile(os.path.join(box.ttdir, f))
			]
		except FileNotFoundError: fnames = []
		return fnames

	def pickServers(self, argv, onlyOne=False, allowNoSystemd=False):
		"""Pick one or more servers based on argv, removing the server specifications from argv in the process.
		This method also arbitrates how to handle no server spec being given.
		Also requires the systemd setup to have been completed,
		unless allowNoSystemd is True.
		Returns a list the unique server names picked.
		The list will not be empty; this will exit first if that happens.
		onlyOne may be set True to allow only one server.
		This is a convenience function for all commands that require valid servers on which to operate.
		"""
		if systemd.status != "i" and not allowNoSystemd:
			self.do_init(args=[])
		if not argv: errExit("Please specify a server name")
		# All server xml file names without extension.
		fnames = self.serverList()
		if len(argv) == 1 and argv[0].lower() == "all":
			# Pretend the user typed all exact names.
			argv.clear()
			argv.extend(fnames)
		snames = []
		while argv:
			sname = argv.pop(0)
			# For when the user does something like prefix*.xml; get rid of the .xml part.
			# This avoids accidental server names like mine.xml.
			if os.path.splitext(sname)[1].lower() == ".xml":
				sname = os.path.splitext(sname)[0]
			if os.path.exists(os.path.join(box.ttdir, f"{sname}.xml")):
				# Exact matches are not challenged so that another server containing this one's name doesn't interfere.
				snames.append(sname)
				continue
			matches = [f for f in fnames if
				sname.lower() in os.path.splitext(f)[0].lower()
			]
			if len(matches) != 1:
				errExit(f"{sname} matched {len(matches)} servers")
			sname0 = sname
			sname = matches[0]
			print(f"Assuming {sname0} means {sname}")
			snames.append(sname)
		# Remove duplicates.
		snames = list(set(snames))
		ns = len(snames)
		if ns == 0: errExit("No servers matched")
		if ns > 1 and onlyOne: errExit("This command allows only one server")
		if ns > 1:
			print(f"Applying to {len(snames)} servers")
		return snames

	def verifyClearServer(self, argv, allowMoreArgs=False):
		"""Ensure that the given server specification represents a server that does not already exist.
		Removes the server spec from argv in the process.
		Unless allowMoreArgs is True, also produces an error if extra args are given.
		Also requires the systemd setup to have been completed.
		Does not allow creation of a server called "all."
		"""
		if systemd.status != "i":
			self.do_init(args=[])
		if not argv: errExit("Please specify a new server name")
		sname = argv.pop(0)
		if argv and not allowMoreArgs: errExit("Only one argument, a new server name, is accepted")
		# For when the user does something like prefix*.xml; get rid of the .xml part.
		# This avoids accidental server names like mine.xml.
		if os.path.splitext(sname)[1].lower() == ".xml":
			sname = os.path.splitext(sname)[0]
		if sname.lower() == "all":
			errExit('This utility does not support creation of a server called "all"')
		if os.path.exists(os.path.join(box.ttdir, f"{sname}.xml")):
			errExit(f"Server {sname} already exists")
		matches = [f for f in self.serverList() if f.lower() == sname.lower()]
		if len(matches) == 0:
			return sname
		errExit(f"{len(matches)} servers matched {sname}")

	def do_new(self, args):
		"""Create a new server.
		Specify the exact name of the server to create.
		This utility will not create servers whose names differ only in case.
		"""
		spath = box.path()
		if not spath: errExit(f"TeamTalk server {spath} not found")
		sname = self.verifyClearServer(args)
		try: os.mkdir(os.path.join(box.ttdir, f"files.{sname}"))
		except OSError as e: print(str(e))
		xmlpath = os.path.join(box.ttdir, f"{sname}.xml")
		cmd = [
			spath,
			"-c", xmlpath,
			"-wizard"
		]
		try: run(cmd, check=False)
		except KeyboardInterrupt:
			# New line; aborting this wizard often ends on a prompt.
			print()
		# ToDo: os.chown and shutil.chown are not recursive but we want that.
		# This is redundant in chowning the whole tree but is safe.
		run(["chown", "-R", f"{box.user}:{box.group}", box.ttdir], check=False)
		# Quick sanity check on the xml file.
		try:
			with open(xmlpath) as f: xmltext = f.read().splitlines()
		except Exception as e: xmltext = "".splitlines()
		if len(xmltext) < 4:
			print(f"The new {sname}.xml file is not complete and is being removed; better luck next time")
			try: os.unlink(xmlpath)
			except OSError as e: print(str(e))
			try: os.rmdir(os.path.join(box.ttdir, f"files.{sname}"))
			except OSError as e: print(str(e))
			return
		if confirm("Start this server on boot?"):
			# Permit failure but not quietly.
			try: run(["systemctl", "enable", f"tt5@{sname}"], check=True)
			except Exception as e: print(str(e))
		if confirm("Start this server now?"):
			# ToDo: This won't work if start-on-boot was not done.
			try: run(["systemctl", "--no-block", "start", f"tt5@{sname}"], check=True)
			except Exception as e: print(str(e))
	do_new.cat = "setup"
	do_new.aliases = ["add", "create"]

	def do_reconfigure(self, args):
		"""Reconfigure and restart or reload an existing server.
		Specify the name or partial name of the server to reconfigure.
		"""
		spath = box.path()
		if not spath: errExit(f"TeamTalk server {spath} not found")
		sname = self.pickServers(args, onlyOne=True)[0]
		xmlpath = os.path.join(box.ttdir, f"{sname}.xml")
		cmd = [
			spath,
			"-c", xmlpath,
			"-wizard"
		]
		with open(xmlpath) as f: xmlOrg = f.read()
		try: run(cmd, check=False)
		except KeyboardInterrupt:
			# The newline jumps off a prompt line from the wizard if necessary.
			print("\nWarning: TeamTalk reconfiguration aborted.")
		with open(xmlpath) as f: xmlNew = f.read()
		if xmlNew == xmlOrg:
			print("No changes to apply")
			return
		choice = getChoice("How do you want to update the running server now?", [
			"Reload: Updates accounts but not channels or general settings.",
			"Restart: Restarts the TeamTalk server for a full update, forcing users to reconnect.",
			"Delay: Let changes take effect when the server next starts or restarts.",
		])
		if choice < 1 or choice > 2:
			print("Server not restarted or reloaded; changes will not take effect at this time.")
			return
		if choice == 1:
			self.do_reload([sname])
		elif choice == 2:
			self.do_restart([sname])
	do_reconfigure.cat = "setup"

	def do_remove(self, args):
		"""Remove one or more servers.
		Specify the name or partial name of each server to remove, or type "all" for all.
		"""
		snames = self.pickServers(args, allowNoSystemd=True)
		logs = []
		for sname in snames:
			if systemd.status != "n":
				# Permit these to fail in case they are not needed.
				run(["systemctl", "--no-block", "stop", f"tt5@{sname}"], check=False)
				run(["systemctl", "disable", f"tt5@{sname}"], check=False)
			shutil.rmtree(os.path.join(box.ttdir, f"files.{sname}"), ignore_errors=True)
			try: os.unlink(f"{box.ttdir}/{sname}.xml")
			except OSError as e: print(str(e))
			log = f"{box.ttdir}/{sname}.log"
			if os.path.exists(log): logs.append(log)
		if logs:
			if confirm(f"Remove {len(logs)} related server log files?"):
				for log in logs:
					try: os.remove(log)
					except OSError as e: print(str(e))
	do_remove.cat = "setup"
	do_remove.aliases = ["delete", "rm"]

	def do_enable(self, args):
		"""Enable one or more servers to start on system boot.
		Specify the name or partial name of each server to enable, or type "all" for all.
		"""
		snames = self.pickServers(args)
		[run(["systemctl", "enable", f"tt5@{sname}"], check=False) for sname in snames]
	do_enable.cat = "running"

	def do_disable(self, args):
		"""Disable one or more servers from starting on system boot.
		Specify the name or partial name of each server to disable, or type "all" for all.
		"""
		if systemd.status == "n":
			print("systemd is not set up")
			return
		snames = self.pickServers(args)
		[run(["systemctl", "disable", f"tt5@{sname}"], check=False) for sname in snames]
	do_disable.cat = "running"

	def do_restart(self, args):
		"""Restart one or more servers.
		Specify the name or partial name of each server to restart, or type "all" for all.
		"""
		if systemd.status == "n":
			print("systemd is not set up")
			return
		snames = self.pickServers(args)
		# No check because systemd should print a suitable error message.
		[run(["systemctl", "--no-block", "restart", f"tt5@{sname}"], check=False) for sname in snames]
	do_restart.cat = "running"

	def do_reload(self, args):
		"""Reload one or more servers to update some settings without a server restart.
		Specify the name or partial name of each server to reload, or type "all" for all.
		Note that a reload only updates accounts, bans, and logging settings from the xml file.
		It does not update server name or other general settings, channels, or file sharing.
		"""
		if systemd.status == "n":
			print("systemd is not set up")
			return
		snames = self.pickServers(args)
		[run(["systemctl", "--no-block", "kill", "--signal=HUP", f"tt5@{sname}"], check=False) for sname in snames]
	do_reload.cat = "running"

	def do_start(self, args):
		"""Start one or more servers.
		Specify the name or partial name of each server to start, or type "all" for all.
		"""
		snames = self.pickServers(args)
		[run(["systemctl", "--no-block", "start", f"tt5@{sname}"], check=False) for sname in snames]
	do_start.cat = "running"

	def do_stop(self, args):
		"""Stop one or more servers.
		Specify the name or partial name of each server to stop, or type "all" for all.
		"""
		if systemd.status == "n":
			print("systemd is not set up")
			return
		snames = self.pickServers(args)
		[run(["systemctl", "--no-block", "stop", f"tt5@{sname}"], check=False) for sname in snames]
	do_stop.cat = "running"

	def do_log(self, args):
		"""Show Linux journal entries for a server in journalctl's default format.
		Specify the name or partial name of the server to query.
		"""
		sname = self.pickServers(args, onlyOne=True, allowNoSystemd=True)[0]
		run(["journalctl", "--full", "--no-pager", "-r", "-u", f"tt5@{sname}.service"], check=False)
	do_log.cat = "misc"

	def do_logcat(self, args):
		"""Show Linux journal entries for a server in a terse format.
		Specify the name or partial name of the server to query.
		"""
		sname = self.pickServers(args, onlyOne=True, allowNoSystemd=True)[0]
		run(["journalctl", "--full", "--no-pager", "-o", "cat", "-r", "-u", f"tt5@{sname}.service"], check=False)
	do_logcat.cat = "misc"

	def do_logsum(self, args):
		"""Print a summary of one or more log files.
		"""
		files = args
		logsum = TTLogSum()
		for f in fileGen(files):
			processFile(logsum, f)
		print(logsum.stats())
		print(logsum.output())
		sys.stdout.flush()
		print(logsum.stats(), file=sys.stderr)
		sys.stderr.flush()
	do_logsum.cat = "misc"

	def do_analyze(self, args):
		"""Analyze servers and report issues or possible issues as appropriate.
		Finds servers just as the list command does by default.
		Alternatively, files and/or folders may be given on the command line.
		Folders are searched recursively, so that .xml files in subfolders are included.
		Files not ending in .xml and folders starting with . are quietly ignored.
		Items reported:
			* Number of servers found.
			* Incompletely configured servers, which can interfere with default-port servers.
			* Servers sharing the same TCP and/or UDP ports.
			* Servers whose TCP and UDP ports differ. This is irregular but not necessarily a problem.
		"""
		boxpath = box.ttdir
		if not args:
			snames = self.serverList()
		else:
			boxpath = ""
			snames = []
			cands = list(args)
			for sname in cands:
				if sname.lower().endswith(".xml") and os.path.isfile(sname):
					snames.append(os.path.splitext(sname)[0])
				elif os.path.isdir(sname):
					for path,dirs,files in os.walk(sname, topdown=True):
						snames.extend(os.path.join(path, os.path.splitext(f)[0]) for f in files if f.endswith(".xml"))
						[dirs.remove(d) for d in dirs if d.startswith(".")]
		print(f"{len(snames)} servers found")
		servers = []
		issues = Issues()
		for fname in snames:
			sinfo = Struct()
			fname += ".xml"
			sinfo.fname = os.path.splitext(fname)[0]
			with open(os.path.join(boxpath, fname)) as f: xmltext = f.read()
			try:
				sname = re.search(r'<server-name>(.*?)</server-name>', xmltext).groups()[0]
				sname = sname.replace("&apos;", "'")
				sinfo.sname = sname
				tcpport = re.search(r'<tcpport>(.*?)</tcpport>', xmltext).groups()[0]
				sinfo.tcpport = tcpport
				udpport = re.search(r'<udpport>(.*?)</udpport>', xmltext).groups()[0]
				sinfo.udpport = udpport
			except AttributeError:
				issues.add("Incomplete configuration", sinfo.fname)
				continue
			servers.append(sinfo)
			# These two are only printed if server list length exceeds 1.
			issues.add(f"Servers trying to use TCP port {sinfo.tcpport}", sinfo.fname)
			issues.add(f"Servers trying to use UDP port {sinfo.udpport}", sinfo.fname)
			if sinfo.tcpport != sinfo.udpport:
				issues.add("TCP and UDP ports differ", sinfo.fname)
		found = False
		for issue,lst in sorted(issues.items(), key=lambda i: i[0]):
			if "trying to use " in issue and len(lst) == 1: continue
			print(f"{issue} ({len(lst)}): {', '.join(lst)}")
			found = True
		if not found:
			print("No issues found")
	do_analyze.cat = "misc"

	def do_list(self, args):
		"""Show a list of all defined servers or servers matching a filter.
		This command refers to defined servers (all those with xml files), not all of which may currently be running.
		See the rlist command for how to list running servers.
		Each server's file name base, ports, and root channel name are listed.
		To filter, specify one or more strings to be found in server names or ports.
		All given strings must match for a server to be listed.
		Specified numbers are matched only against ports and must match exactly.
		Other strings match if they are contained in server file names or root channel names.
		Case is ignored.
		A warning also prints for any incomplete server configuration found.
		"""
		snames = self.serverList()
		totcount = len(snames)
		servers = []
		for fname in snames:
			fname += ".xml"
			with open(os.path.join(box.ttdir, fname)) as f: xmltext = f.read()
			try:
				sname = re.search(r'<server-name>(.*?)</server-name>', xmltext).groups()[0]
				sname = sname.replace("&apos;", "'")
				tcpport = re.search(r'<tcpport>(.*?)</tcpport>', xmltext).groups()[0]
				udpport = re.search(r'<udpport>(.*?)</udpport>', xmltext).groups()[0]
			except AttributeError:
				print(f"Warning: Incomplete server configuration for {os.path.splitext(fname)[0]}")
				continue
			sinfo = Struct()
			sinfo.fname = os.path.splitext(fname)[0]
			sinfo.sname = sname
			sinfo.tcpport = tcpport
			sinfo.udpport = udpport
			servers.append(sinfo)
		if args:
			s0 = servers
			servers = []
			for sinfo in s0:
				block = False
				for arg in args:
					if arg.isdigit():
						if arg not in [sinfo.tcpport, sinfo.udpport]:
							block = True
					else:
						if arg.lower() not in sinfo.fname.lower() and arg.lower() not in sinfo.sname.lower():
							block = True
				if block: continue
				servers.append(sinfo)
			del s0
		dispcount = len(servers)
		servers.sort(key=lambda sinfo: sinfo.fname.lower())
		for sinfo in servers:
			ports = sinfo.tcpport if sinfo.tcpport == sinfo.udpport else f"{sinfo.tcpport}/{sinfo.udpport}"
			print(f"{sinfo.fname} {ports} {sinfo.sname}")
		if dispcount == totcount:
			print(f"{totcount:d} servers defined")
		else:
			print(f"{dispcount:d} of {totcount:d} defined servers matched")
	do_list.cat = "misc"
	do_list.aliases = ["ls"]

	def do_rlist(self, args):
		"""List all or selected running TeamTalk servers. Can also optionally kill or reload them.
		This command refers to running servers, not defined servers (all those with xml files).
		See the list command for how to list defined servers.
		Include one or more words to limit matches to those whose working directory, config file path,
		log file path, or server name contains all words, case insensitive.
		Include a port number to match any server running on that TCP or UDP port.
		-0, -1, etc.: Ignore servers with the given number or fewer connections.
		Add "i" to filter by IP count, "d" to filter by dup count, or "t" (default) to filter by total connections.
		Examples: -0, -1i, -3d.
		Multiple of these filters may be specified, but only the highest of each type is applied.
		-i: Include IP summaries in output, normally omitted.
		-l: Long (multiline rather than tabular) listing.
		-k, -r: Kill or reload the shown servers.
		WARNING: This command does not reference systemd, so killing a server may cause it to restart.
		Without -l, each server appears on one line. Characters that can appear in the Flags column of this table:
			d, n: Daemon or non-daemon mode.
			l: Logging is enabled.
			p: Server is a pro server (tt5prosrv).
			u: UDP port is not the same as the shown TCP port (use -l to get it).
			v: Verbose (server log data goes to stdout).
		    x: Command line not available.
		If a server has a name, it will appear on the next line.
		If -i is given, any connected clients for a server will be summarized on another extra line.
		"""
		isLong = doIPs = False
		opflags = set()
		matchers = args
		if "-i" in matchers:
			doIPs = True
			matchers.remove("-i")
		if "-l" in matchers:
			isLong = True
			matchers.remove("-l")
		if "-k" in matchers:
			opflags.add("k")
			matchers.remove("-k")
		if "-r" in matchers:
			opflags.add("r")
			matchers.remove("-r")
		if "k" in opflags and "r" in opflags:
			sys.exit("Cannot specify -r and -k at the same time")
		# Collect any filters on connection counts.
		countFilters = {}
		for arg in matchers.copy():
			if not arg.startswith("-"): continue
			if not arg[1].isdigit():
				sys.exit(f"Unrecognized option: {arg}")
			# Only -<digit>... args get this far.
			matchers.remove(arg)
			arg = arg[1:]
			ctype = "t"
			arg0 = arg
			if not arg[-1].isdigit():
				ctype = arg[-1]
				arg = arg[:-1]
				if ctype not in "dit":
					sys.exit(f"Unknown count type: {ctype}")
			if not arg.isdigit():
				sys.exit(f"Unrecognized count format: {arg0}")
			# Remember the highest cutoff point requested for this filter type.
			countFilters.setdefault(ctype, -1)
			countFilters[ctype] = max(countFilters[ctype], int(arg))
		tts = TTInstances(matchers, countFilters)
		if not tts:
			if matchers or countFilters: print("No servers matched the filter.")
			else: print("No servers found.")
			sys.exit(0)
		if not isLong:
			print("Clients  Ports  Flags      Pid  Path/Name")
		ips = set()
		conns = 0
		for tt in tts.valuesByPort():
			isPro = ("pro" in os.path.basename(tt.exe))
			if isLong:
				print(("Pid {tt.pid} ({tt.exe}):"))
				indent = "    "
				print((indent +"Name:  " +tt.name))
				if tt.shortname and tt.shortname != tt.name:
					print((indent +"Short: " +tt.shortname))
				flags = []
				if isPro: flags.append("pro")
				if not tt.cmdline: flags.append("noCommandLine")
				if not tt.name: flags.append("noName")
				if tt.cmdline: flags.append("daemon" if tt.isDaemon else "noDaemon")
				if tt.cmdline and tt.isVerbose: flags.append("verbose")
				flags.append("log" if tt.isLogging else "noLog")
				if tt.udpPort != tt.tcpPort: flags.append("differentPorts")
				print((indent +"Flags: " +", ".join(flags)))
				print((indent +f"Clients ({tt.clientInfo(False)}): {tt.clientInfo(True)}"))
				print((indent +"Command line: " +tt.cmdline))
				print((indent +"Local IP Address: " +tt.ipAddr))
				print((indent +f"Ports: TCP {tt.tcpPort}, UDP {tt.udpPort}"))
				print((indent +"Working Directory: " +tt.cwd))
				print((indent +"Config File: " +tt.configFile))
				print((indent +"Log File: " +tt.logFile))
			else:
				base = os.path.dirname(tt.configFile)
				if not base: base = tt.cwd
				path = os.path.join(base, os.path.splitext(tt.configFile)[0])
				port = tt.tcpPort
				if not port: port = 0
				flags = ""
				if isPro: flags += "p"
				if not tt.cmdline: flags += "x"
				if "x" not in flags: flags += "d" if tt.isDaemon else "n"
				if tt.isLogging: flags += "l"
				if "x" not in flags and tt.isVerbose: flags += "v"
				if tt.udpPort != tt.tcpPort: flags += "u"
				sclients = tt.clientInfo(False)
				cnt1,cnt2 = 0,0
				if sclients:
					# Split into the two component subfields and right-justify each.
					cnt1,cnt2 = sclients.split("+", 1)
					sclients = f"{int(cnt1):3d}+{int(cnt2):3d}"
				print((f"{sclients:7s}  {port:5d}  {flags:5s}  {tt.pid:7d}  {path}"))
				spacer = " "
				if tt.shortname:
					print(f"{spacer:7s}  {spacer:5s}  {spacer:5s}  {spacer:7s}  {tt.shortname}")
				if doIPs and int(cnt1) +int(cnt2) > 0:
					print(f"{spacer:7s}  {spacer:5s}  {spacer:5s}  {spacer:7s}  {tt.clientInfo(True)}")
			[ips.add(ip) for ip in tt.clients.keys()]
			conns += sum([len(plist) for plist in tt.clients.values()])
		if tts.matchcount == tts.totcount:
			buf = f"{tts.totcount:d} servers running"
		else:
			buf = f"{tts.matchcount:d} of {tts.totcount:d} running servers matched"
		buf += f", {len(ips)} + {conns - len(ips)} connections"
		print(buf)
		if "k" in opflags:
			os.kill(int(tt.pid), signal.SIGTERM)
		elif "r" in opflags:
			os.kill(int(tt.pid), signal.SIGHUP)
	do_rlist.cat = "misc"

	def do_fln(self, args):
		"""Examine all uploaded files on all TeamTalk servers and offer to save disk space by hard-linking duplicates.
		this trick works best when all TeamTalk servers are hosted on a single file system (common case).
		File names are not considered; a "duplicate" is a file with the same size and contents.
		TeamTalk does not allow modification of an uploaded file, which is why this is safe.
		If a user deletes a file with links, that instance goes away without affecting others, as expected.
		Linking duplicates is a one-way operation; there is no command for unlinking duplicates (but see -b).
		Usage: fln [-n|-y|-q|-Q] [-b] [folder ...]
		-n: Only report what is found and could be done; don't prompt for or perform action.
		-y: Auto confirm permission to proceed.
		-q: -y but only print any changes made, not a leading summary of links found.
		-Q: -y but print nothing.
		-b: Keep backups of all original files before converting them to links. They end with .bk.
		Note that this negates all space savings and is therefore not generally useful except during experimentation.
		folder ...: One or more folders to scan, including their subfolders.
		If no folders are given (common case), the box's TeamTalk server data folder tree is examined.
		* -y, -q, and -Q are included with cron jobs in mind; e.g., fln -q saves space and only reports changes.
		* If more than one of -y, -q, and -Q are included, -Q takes precedence followed by -q.
		* -n overrides them all and avoids prompt or action.
		"ln" is the name of the Linux (and other flavors) command for making hard file links.
		Note for those who use rsync to back up TeamTalk servers, including uploaded files:
		If you use fln, you should also include -H on your rsync commands, to check for hard links.
		Failure to do this will cause the backup to contain duplicates that are not linked to save space.
		"""
		# 0 print all and prompt for changes, 1 print all and change, 2 change and print only changes, 3 change quietly.
		lvl = 0
		if self.opt(args, "-y"): lvl = 1
		if self.opt(args, "-q"): lvl = 2
		if self.opt(args, "-Q"): lvl = 3
		noPrompt = False
		if self.opt(args, "-n"):
			lvl = 0
			noPrompt = True
		backup = False
		if self.opt(args, "-b"): backup = True
		# Scan TT data folder and all subfolders unless the user lists what folders to scan.
		# This assumes all files are under there and nothing else matches the file pattern used.
		folders = [box.ttdir, ]
		if args: folders = args
		pats = ["data_*.dat", ]
		fln = FLN(folders, pats)
		fln.catalog(lvl <= 1)
		fln.analyze(lvl <= 1)
		if not fln.mbags: return
		if noPrompt: return
		if lvl == 0 and not confirm("Link files?"): return
		fln.commit(lvl <= 2, backup)
	do_fln.cat = "misc"

	@staticmethod
	def opt(args, arg):
		"""Return True, and remove arg from args, if arg is found in args.
		"""
		if arg in args:
			args.remove(arg)
			return True
		return False

	def do_uninstall(self, args):
		"""Uninstall components of this TeamTalk management system.
		Removes as needed, and upon separate confirmations:
			* All TeamTalk servers, including logs if desired.
			* The TeamTalk server root folder.
			* The user and group used for servers (only if both are tt).
			* The systemd service used to start TeamTalk servers.
			* The TeamTalk server binary.
			* This utility itself, and its configuration file if present.
		"""
		if os.path.exists(box.ttdir):
			fnames = self.serverList()
			if fnames and confirm(f"Remove all {len(fnames)} TeamTalk servers?"):
				self.do_remove(["all"])
			# Ignore dot files here because the system often populates user folders with them.
			fnames = [f for f in os.listdir(box.ttdir) if not f.startswith(".")]
			if len(fnames) == 0:
				if confirm(f"Remove empty {box.ttdir} folder?"):
					# rmtree because of those possible dot files.
					try: shutil.rmtree(box.ttdir)
					except OSError as e: print(str(e))
			else:
				print(f"{box.ttdir} is not empty; leaving alone.")
		ugStat = box.ugStatus()
		if (not os.path.exists(box.ttdir)
		and box.user == "tt" and box.group == "tt"):
			if "u" in ugStat and confirm(f"Remove {box.user} user?"):
				try:
					run(["userdel", box.user], check=True)
					ugStat = box.ugStatus()
				except Exception as e: print(str(e))
			if "g" in ugStat and "u" not in ugStat and confirm(f"Remove {box.group} group?"):
				try:
					run(["groupdel", box.group], check=True)
				except Exception as e: print(str(e))
		ugStat = box.ugStatus()
		uPrompt = "" if "u" not in ugStat else f"user {box.user}"
		gPrompt = "" if "g" not in ugStat else f"group {box.group}"
		prompt = f"{uPrompt} and {gPrompt}" if uPrompt and gPrompt else f"{uPrompt}{gPrompt}"
		if prompt:
			print(f"Leaving {prompt} alone in case used by existing files or folders.")
		sstat = systemd.status
		if sstat != "n" and confirm("Remove systemd service file?"):
			try:
				os.unlink(systemd.ss_path)
				sstat = "n"
			except Exception as e: print(str(e))
		spath = os.path.join(box.bindir, box.serverName)
		if os.path.exists(spath):
			if sstat != "n":
				print(f"Leaving TeamTalk server binary {spath} because systemd may still use it.")
			elif confirm(f"Remove TeamTalk server binary {spath}?"):
				try: os.unlink(spath)
				except Exception as e: print(str(e))
		me = sys.argv[0]
		mypath = os.path.dirname(me)
		cfgpath = os.path.join(mypath, "tt.conf")
		isCfg = os.path.exists(cfgpath)
		prompt = "this utility and its configuration (tt.conf) file" if isCfg else "this utility itself"
		if confirm(f"Remove {prompt}?"):
			if isCfg:
				try: os.unlink(cfgpath)
				except Exception as e: print(str(e))
			try: os.unlink(me)
			except Exception as e: print(str(e))
	do_uninstall.cat = "setup"

#========== Main Module and Support Functions ==========

def handleRemoteCommand():
	"""Handle remote command execution.
	Returns False if this is not a remote command and True if it is, regardless of its exit status.
	"""
	if len(sys.argv) <= 1 or "@" not in sys.argv[1]:
		return False
	hosts = []
	while "@" in sys.argv[1]:
		host = sys.argv.pop(1)
		if host.startswith("@"): host = host[1:]
		hosts.append(host)
	cmd = []
	# StrictHostKeyChecking=no avoids "fingerprint has changed" prompts and auto-updates the local known_hosts file.
	# BatchMode avoids password prompts and fails if passwords are needed.
	# The upshot is to maximize successes while eliminating multi-thread user prompts, which can create terminal messes.
	# If the first of these options causes a known_hosts file update, the user will see a warning of this.
	[cmd.extend(["ssh", "-oStrictHostKeyChecking=no", "-oBatchMode=yes", host]) for host in hosts]
	cmd.extend(["python3", "-"])
	cmd.extend(sys.argv[1:])
	with open(__file__, "rb") as f:
		run(cmd, stdin=f)
		return True

if __name__ == "__main__":
	if handleRemoteCommand(): sys.exit()
	cp = CommandProcessor()
	sys.exit(cp.process(sys.argv.copy()))
