- packaging/rpm/terraria-server-manager.spec.in: installs via make install, plain scriptlets (no Fedora-only macros) mirroring the deb maintainer scripts; removal stops servers, disables boot/backups/ daemon and removes only terractl's own drop-ins. - make rpm (rpmbuild --nodeps, works on Debian/Ubuntu); make check parses the spec when rpmspec is installed. - Release workflow builds deb and rpm, attaches both, publishes the rpm to Gitea's RPM registry. Job renamed to 'packages'. - pkg_install: install hints use dnf or apt with the right package names (doctor, fetch, python3/openssl checks). - firewalld: new prints the firewall-cmd command for its port; doctor lists instance and daemon ports firewalld doesn't allow. - Docs, README, AGENTS.md and changelog; VERSION 0.0.13. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2480 lines
102 KiB
Bash
Executable File
2480 lines
102 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# terractl - helper for terraria@.service
|
|
#
|
|
# Layout:
|
|
# /var/lib/terraria/dist/ server zips (read-only source)
|
|
# terraria-server-*.zip vanilla dedicated server zips from terraria.org
|
|
# tModLoader*.zip tModLoader release zips (GitHub)
|
|
# /var/lib/terraria/instances/<instance>/
|
|
# instance.conf TYPE (vanilla|tmodloader), ZIP pin, EXTRA_ARGS
|
|
# serverconfig.txt normal Terraria server config (port, world, ...)
|
|
# server/ unpacked server binaries (replaceable)
|
|
# data/ worlds, mods, player data (kept across reinstalls)
|
|
# /var/lib/terraria/backups/<instance>/ backups ('backup', and 'upgrade' before switching)
|
|
#
|
|
# Managing other hosts (--host, remote, daemon, copy, move) is done by
|
|
# terractl-remote, in Python; see run_remote_helper.
|
|
set -euo pipefail
|
|
|
|
# Work from / so nothing depends on the caller's directory (which the 'terraria'
|
|
# user may not be allowed into, e.g. /root). Paths given on the command line are
|
|
# resolved against where terractl was called from, via abspath().
|
|
CALLER_PWD=$PWD
|
|
SELF=$(readlink -f "$0") # before the cd, in case $0 is a relative path
|
|
cd /
|
|
|
|
abspath() {
|
|
if [[ $1 == /* ]]; then printf '%s\n' "$1"; else printf '%s\n' "$CALLER_PWD/$1"; fi
|
|
}
|
|
|
|
# Local overrides (see the comments in that file)
|
|
# shellcheck disable=SC1091
|
|
if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi
|
|
|
|
BASE_DIR="${TERRARIA_BASE:-/var/lib/terraria/instances}"
|
|
DIST_DIR="${TERRARIA_DIST:-/var/lib/terraria/dist}"
|
|
BACKUP_DIR="${TERRARIA_BACKUPS:-/var/lib/terraria/backups}"
|
|
RUN_DIR="/run/terraria"
|
|
SVC_USER="${TERRARIA_USER:-terraria}"
|
|
STOP_TIMEOUT="${TERRARIA_STOP_TIMEOUT:-80}" # keep below TimeoutStopSec
|
|
JOURNAL_NS=terraria # LogNamespace= in terraria@.service
|
|
TERRACTL_VERSION="@VERSION@" # filled in by 'make install' / 'make deb'
|
|
REMOTE_HELPER="@PKGLIBDIR@/terractl-remote" # likewise
|
|
|
|
die() { echo "terractl: $*" >&2; exit 1; }
|
|
# join_by <separator> <item>...: the items with the separator between them
|
|
join_by() {
|
|
local sep=$1 first
|
|
shift
|
|
if [[ $# -eq 0 ]]; then echo; return 0; fi
|
|
first=$1; shift
|
|
printf '%s' "$first" "${@/#/$sep}"
|
|
echo
|
|
}
|
|
log() { echo "terractl: $*" >&2; }
|
|
|
|
# The command that installs <what> (unzip, curl, python3, openssl, icu, polkit)
|
|
# with this system's package manager: pkg_install <sudo prefix> <what>
|
|
pkg_install() {
|
|
local sudo=$1 what=$2
|
|
if command -v apt-get >/dev/null; then
|
|
case $what in icu) what=libicu-dev ;; polkit) what=polkitd ;; esac
|
|
echo "${sudo}apt install $what"
|
|
elif command -v dnf >/dev/null; then
|
|
case $what in icu) what=libicu ;; esac
|
|
echo "${sudo}dnf install $what"
|
|
else
|
|
case $what in icu) what=ICU ;; esac
|
|
echo "install $what with your package manager"
|
|
fi
|
|
}
|
|
|
|
# firewalld is on by default on Fedora and RHEL, and blocks new ports until
|
|
# they're opened. Both need root (or polkit's permission) to ask.
|
|
firewalld_running() { command -v firewall-cmd >/dev/null && firewall-cmd --state >/dev/null 2>&1; }
|
|
firewalld_allows() { firewall-cmd --query-port="$1/tcp" >/dev/null 2>&1; }
|
|
firewalld_open_cmd() { # firewalld_open_cmd <sudo prefix> <port>
|
|
echo "${1}firewall-cmd --permanent --add-port=$2/tcp && ${1}firewall-cmd --reload"
|
|
}
|
|
|
|
# Is <user> (default: whoever runs this) in the terraria group?
|
|
in_group() {
|
|
[[ " $(id -nG "${1:-$(id -un)}" 2>/dev/null) " == *" $SVC_USER "* ]]
|
|
}
|
|
|
|
# A terractl command to suggest: hint root|group <command> [args...]. 'root'
|
|
# commands need sudo; 'group' ones only for people outside the terraria group
|
|
# (start/stop/restart are allowed to the group by the polkit rule). Under sudo,
|
|
# it advises the person who ran sudo. When terractld runs a command for another
|
|
# host, TERRACTL_VIA says how that host reaches this one (e.g. "--host nas").
|
|
hint() {
|
|
local need=$1 who=${SUDO_USER:-$(id -un)}; shift
|
|
if [[ -n ${TERRACTL_VIA:-} ]]; then
|
|
echo "terractl $TERRACTL_VIA $*"
|
|
elif [[ $who == root ]] || { [[ $need == group ]] && in_group "$who"; }; then
|
|
echo "terractl $*"
|
|
else
|
|
echo "sudo terractl $*"
|
|
fi
|
|
}
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: terractl <command> <instance> [args]
|
|
terractl --version
|
|
|
|
Admin commands:
|
|
new <name> [vanilla|tmodloader] [port] create an instance (run as root)
|
|
list show instances, type, port, boot, state
|
|
info [<name>] paths and zips of this host, or details of an instance
|
|
start|stop|restart <name> start, stop (saves the world) or restart a server
|
|
enable <name> [--now] start at boot (--now: also start it now)
|
|
disable <name> [--now] don't start at boot (--now: also stop it now)
|
|
attach <name> interactive console (Ctrl-D to detach)
|
|
watch <name> [-n <lines>] [-q] follow the server's output, read-only (Ctrl-C to quit)
|
|
cmd <name> <console command...> send a console command (e.g. save, say hi)
|
|
upgrade <name> show the server zips it can use, and which it runs
|
|
upgrade <name> <zip>|latest [--downgrade] [--no-backup]
|
|
change its server version now (backs up worlds first)
|
|
reinstall <name> [--clean] re-unpack the same server version on next start
|
|
(--clean also re-downloads tModLoader's .NET)
|
|
fetch vanilla|tmodloader [<version>] [--upgrade <name>]
|
|
download a server zip into dist/ (default: latest)
|
|
fetch vanilla|tmodloader --list versions available to download
|
|
backup enable <name> [--every <when>] [--keep <n>]
|
|
back up on a schedule (default: daily, keep 14)
|
|
backup disable <name> stop scheduled backups (keeps existing ones)
|
|
backup now <name> back up now
|
|
backup list [<name>] show schedules and backups
|
|
doctor [<name>] [--fix] check the setup and explain problems
|
|
(--fix: fix what can be fixed safely)
|
|
fix-perms <name> give all of an instance's files back to the
|
|
'$SVC_USER' user, with the usual modes (run as root)
|
|
mods <name> [list] show installed mods and whether they're enabled
|
|
mods <name> add <file.tmod|folder>... install mods (and ModConfigs) and enable them
|
|
mods <name> enable|disable <mod>... change which installed mods load
|
|
mods <name> remove <mod>... uninstall mods
|
|
delete <name> [-y] [--no-backup] stop, disable and remove an instance
|
|
rename <name> <new name> rename an instance (restarted if it was running)
|
|
export <name> [file.tar.gz] [--with-server]
|
|
archive an instance (configs, worlds, mods)
|
|
import <name> <file.tar.gz> create instance <name> from an archive
|
|
(worlds/configs archived to $BASE_DIR/.deleted/)
|
|
|
|
Other hosts:
|
|
--host <remote> <command> [args] run a command on another host (e.g. --host nas list)
|
|
copy [<remote>:]<name> [<remote>:][<new name>] [--with-server]
|
|
copy an instance, on this host or between hosts
|
|
move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
|
|
move (or rename) an instance; stops it while moving
|
|
remote add [<name>] <token> [--address <host>[:<port>]]
|
|
add a host to manage (token from its 'daemon enable')
|
|
remote list | remote remove <name> show or forget remotes
|
|
daemon enable|disable|status let other hosts manage this one (terractld.service)
|
|
daemon token a one-time token for another computer to add this host
|
|
daemon revoke <name> take a computer's access away
|
|
|
|
Used by terraria@.service:
|
|
journal-access let the terraria group read the servers' journal
|
|
prepare <name> create dirs/configs, unpack server if needed
|
|
run <name> exec the server
|
|
service-stop <name> <mainpid> graceful shutdown via console "exit" (service only;
|
|
to stop a server use: terractl stop <name>)
|
|
EOF
|
|
exit "${1:-1}"
|
|
}
|
|
|
|
valid_name() {
|
|
# Must not start with '.', so '.', '..' and hidden dirs can never be targeted
|
|
[[ ${1:-} =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "invalid instance name '${1:-}'"
|
|
}
|
|
|
|
inst_dir() { echo "$BASE_DIR/$1"; }
|
|
|
|
load_conf() {
|
|
local dir; dir=$(inst_dir "$1")
|
|
TYPE=vanilla
|
|
ZIP=""
|
|
EXTRA_ARGS=()
|
|
# shellcheck disable=SC1091
|
|
[[ -f $dir/instance.conf ]] && source "$dir/instance.conf"
|
|
case $TYPE in
|
|
vanilla|tmodloader) ;;
|
|
*) die "TYPE in $dir/instance.conf must be 'vanilla' or 'tmodloader' (got '$TYPE')" ;;
|
|
esac
|
|
}
|
|
|
|
write_instance_conf() {
|
|
local name=$1 type=$2 dir; dir=$(inst_dir "$name")
|
|
cat > "$dir/instance.conf" <<EOF
|
|
# Settings for terraria@$name
|
|
# Changing TYPE or ZIP makes the next start re-unpack server/ (data/ is kept).
|
|
# To change version, use: terractl upgrade $name <zip>
|
|
|
|
TYPE=$type # vanilla | tmodloader
|
|
|
|
# Pin a specific zip from $DIST_DIR (filename or absolute path).
|
|
# Blank = newest matching zip at install time; it is NOT auto-upgraded later.
|
|
ZIP=
|
|
|
|
# Extra arguments passed to the server binary, as a bash array, e.g.
|
|
# EXTRA_ARGS=(-disableannouncementbox)
|
|
EXTRA_ARGS=()
|
|
EOF
|
|
}
|
|
|
|
next_free_port() {
|
|
local max=7776 p f
|
|
for f in "$BASE_DIR"/*/serverconfig.txt; do
|
|
[[ -f $f ]] || continue
|
|
p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n1)
|
|
if [[ -n $p && $p -gt $max ]]; then max=$p; fi
|
|
done
|
|
echo $((max + 1))
|
|
}
|
|
|
|
write_server_config() {
|
|
local name=$1 port=${2:-} dir; dir=$(inst_dir "$name")
|
|
[[ -n $port ]] || port=$(next_free_port)
|
|
cat > "$dir/serverconfig.txt" <<EOF
|
|
# Terraria server config for instance '$name'
|
|
# Edit freely, then: systemctl restart terraria@$name
|
|
|
|
world=$dir/data/Worlds/$name.wld
|
|
worldpath=$dir/data/Worlds
|
|
# Created automatically if the world file does not exist. 1=small 2=medium 3=large
|
|
autocreate=2
|
|
worldname=$name
|
|
# 0=classic 1=expert 2=master 3=journey
|
|
difficulty=0
|
|
#seed=
|
|
|
|
port=$port
|
|
maxplayers=8
|
|
password=
|
|
motd=Welcome to $name
|
|
secure=1
|
|
upnp=0
|
|
language=en-US
|
|
banlist=$dir/data/banlist.txt
|
|
EOF
|
|
log "created $dir/serverconfig.txt (port $port)"
|
|
}
|
|
|
|
pick_zip() {
|
|
local pattern f
|
|
if [[ -n $ZIP ]]; then
|
|
if [[ $ZIP == /* ]]; then echo "$ZIP"; else echo "$DIST_DIR/$ZIP"; fi
|
|
return
|
|
fi
|
|
case $TYPE in
|
|
vanilla) pattern='terraria-server-*.zip' ;;
|
|
tmodloader) pattern='tModLoader*.zip' ;;
|
|
esac
|
|
f=$(find "$DIST_DIR" -maxdepth 1 -type f -name "$pattern" -printf '%f\n' | sort -V | tail -n1)
|
|
[[ -n $f ]] || die "no '$pattern' found in $DIST_DIR"
|
|
echo "$DIST_DIR/$f"
|
|
}
|
|
|
|
# Global (not local) so the EXIT trap can still see them after functions return
|
|
EXTRACT_TMP=""
|
|
ATTACH_PID=""
|
|
cleanup() {
|
|
if [[ -n $EXTRACT_TMP && -d $EXTRACT_TMP ]]; then rm -rf "$EXTRACT_TMP"; fi
|
|
if [[ -n $ATTACH_PID ]]; then kill "$ATTACH_PID" 2>/dev/null || true; fi
|
|
return 0
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
install_server() {
|
|
local name=$1 dir marker zip src have_type="" have_zip=""
|
|
dir=$(inst_dir "$name")
|
|
marker="$dir/server/.installed"
|
|
|
|
# Already installed with the right type (and pinned zip, if any), and not
|
|
# asked to unpack it again ('reinstall')? Nothing to do.
|
|
if [[ -f $marker && ! -e $dir/server/.reinstall ]]; then
|
|
read -r have_type have_zip < "$marker" || true
|
|
if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
command -v unzip >/dev/null || die "unzip is not installed"
|
|
zip=$(pick_zip)
|
|
[[ -e $zip ]] || die "$zip not found"
|
|
[[ -r $zip ]] || die "the '$SVC_USER' user can't read $zip; fix it with: sudo chmod 644 $zip"
|
|
log "installing $TYPE from $(basename "$zip") into $dir/server"
|
|
|
|
EXTRACT_TMP=$(mktemp -d "$dir/.extract.XXXXXX")
|
|
unzip -o -q "$zip" -d "$EXTRACT_TMP"
|
|
|
|
case $TYPE in
|
|
vanilla)
|
|
# terraria.org zips contain <version>/{Linux,Windows,Mac}; we only want Linux
|
|
src=$(find "$EXTRACT_TMP" -type f -name TerrariaServer.bin.x86_64 -path '*/Linux/*' -printf '%h\n' -quit)
|
|
[[ -n $src ]] || die "Linux/TerrariaServer.bin.x86_64 not found in $zip"
|
|
chmod +x "$src/TerrariaServer.bin.x86_64"
|
|
[[ -f $src/TerrariaServer ]] && chmod +x "$src/TerrariaServer"
|
|
;;
|
|
tmodloader)
|
|
src=$(find "$EXTRACT_TMP" -type f -name start-tModLoaderServer.sh -printf '%h\n' -quit)
|
|
[[ -n $src ]] || die "start-tModLoaderServer.sh not found in $zip"
|
|
find "$src" -type f -name '*.sh' -exec chmod +x {} +
|
|
;;
|
|
esac
|
|
|
|
# tModLoader releases don't include .NET; it downloads it into server/dotnet on
|
|
# first start. Carry that (and the logs) over so an upgrade or reinstall doesn't
|
|
# download it again. tModLoader checks the .NET version itself on every start
|
|
# and installs a different one if the new release needs it.
|
|
if [[ $TYPE == tmodloader && -d $dir/server ]]; then
|
|
local keep
|
|
for keep in dotnet tModLoader-Logs; do
|
|
if [[ -d $dir/server/$keep && ! -e $src/$keep ]]; then
|
|
mv "$dir/server/$keep" "$src/$keep"
|
|
log "kept existing server/$keep"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
rm -rf "$dir/server"
|
|
mv "$src" "$dir/server"
|
|
echo "$TYPE $(basename "$zip")" > "$dir/server/.installed"
|
|
cleanup
|
|
log "install complete"
|
|
}
|
|
|
|
# One server (or prepare) per instance at a time. The lock is held on file
|
|
# descriptor 9. 'run' passes it on to the server process, so it's held for the
|
|
# server's whole lifetime and released by the kernel when the server exits,
|
|
# however it exits; it can never go stale.
|
|
take_lock() {
|
|
local name=$1 dir state
|
|
dir=$(inst_dir "$name")
|
|
exec 9>"$dir/.lock"
|
|
if ! flock -n 9; then
|
|
state=$(systemctl show -p ActiveState --value "terraria@$name" 2>/dev/null || true)
|
|
case $state in
|
|
active|activating|deactivating|reloading)
|
|
die "terraria@$name is $state; stop it first: $(hint group stop "$name")" ;;
|
|
*)
|
|
die "instance '$name' is already running outside systemd (e.g. 'terractl run' in another terminal); stop that first" ;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
# True while a server (or prepare) holds the instance lock. Only tests the lock:
|
|
# never creates or changes the file, so it's safe for root and for members of
|
|
# the 'terraria' group. If the lock can't be read, assumes nothing is running.
|
|
instance_running() {
|
|
local lock rc=0
|
|
lock="$(inst_dir "$1")/.lock"
|
|
[[ -r $lock ]] || return 1
|
|
flock -n -E 75 "$lock" true 2>/dev/null || rc=$?
|
|
[[ $rc -eq 75 ]]
|
|
}
|
|
|
|
cmd_prepare() {
|
|
local name=$1 dir err; dir=$(inst_dir "$name")
|
|
if ! err=$(mkdir -p "$dir/data/Worlds" 2>&1); then
|
|
location_warnings
|
|
die "can't create $dir/data/Worlds: ${err##*: }"
|
|
fi
|
|
take_lock "$name" # never re-unpack server/ under a running server
|
|
[[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla
|
|
load_conf "$name"
|
|
if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
|
|
[[ -f $dir/serverconfig.txt ]] || write_server_config "$name"
|
|
install_server "$name"
|
|
}
|
|
|
|
cmd_run() {
|
|
local name=$1 dir; dir=$(inst_dir "$name")
|
|
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
|
take_lock "$name" # held by the server process until it exits
|
|
load_conf "$name"
|
|
[[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first"
|
|
# Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet
|
|
export HOME="$dir"
|
|
cd "$dir/server"
|
|
case $TYPE in
|
|
vanilla)
|
|
exec ./TerrariaServer.bin.x86_64 -config "$dir/serverconfig.txt" "${EXTRA_ARGS[@]}"
|
|
;;
|
|
tmodloader)
|
|
# -nosteam skips the interactive "use steam server?" prompt.
|
|
# -tmlsavedirectory keeps worlds, Mods/ and configs under data/.
|
|
# First start downloads a matching .NET runtime into server/dotnet.
|
|
exec ./start-tModLoaderServer.sh -nosteam \
|
|
-config "$dir/serverconfig.txt" \
|
|
-tmlsavedirectory "$dir/data" \
|
|
"${EXTRA_ARGS[@]}"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ExecStop of terraria@.service
|
|
cmd_service_stop() {
|
|
local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i
|
|
# systemd sets INVOCATION_ID for every process it runs for a service
|
|
if [[ -z ${INVOCATION_ID:-} ]]; then
|
|
die "'service-stop' is only called by terraria@.service. To stop the server: $(hint group stop "$name")"
|
|
fi
|
|
if [[ -n $pid && ! $pid =~ ^[0-9]+$ ]]; then die "invalid process ID '$pid'"; fi
|
|
if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi
|
|
if [[ -p $fifo ]]; then
|
|
echo exit > "$fifo"
|
|
else
|
|
log "console FIFO missing; systemd will send SIGTERM"
|
|
exit 0
|
|
fi
|
|
for ((i = 0; i < STOP_TIMEOUT; i++)); do
|
|
kill -0 "$pid" 2>/dev/null || exit 0
|
|
sleep 1
|
|
done
|
|
log "server did not exit within ${STOP_TIMEOUT}s; leaving it to systemd"
|
|
}
|
|
|
|
# start/stop/restart through systemd: root, or the terraria group through the
|
|
# polkit rule (50-terractl.rules)
|
|
cmd_svc() {
|
|
local action=$1 name=$2 unit="terraria@$2" state; shift 2
|
|
[[ $# -eq 0 ]] || die "usage: terractl $action <name>"
|
|
# Only checkable with read access to the instances (root or the group)
|
|
if [[ -x $BASE_DIR && ! -f $(inst_dir "$name")/instance.conf ]]; then
|
|
die "no instance '$name' (create it with: $(hint root new "$name"))"
|
|
fi
|
|
if [[ $EUID -ne 0 ]] && ! in_group; then
|
|
die "'$action' needs root or the '$SVC_USER' group: sudo terractl $action $name"
|
|
fi
|
|
state=$(systemctl is-active "$unit" 2>/dev/null || true)
|
|
case $action in
|
|
start)
|
|
if [[ $state == active ]]; then echo "$unit is already running"; return 0; fi
|
|
systemctl start "$unit"
|
|
echo "$unit started" ;;
|
|
stop)
|
|
if [[ $state == inactive || $state == failed ]]; then
|
|
if instance_running "$name"; then
|
|
die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); type 'exit' in its console"
|
|
fi
|
|
echo "$unit isn't running"
|
|
return 0
|
|
fi
|
|
echo "stopping $unit (saves the world first)..."
|
|
systemctl stop "$unit"
|
|
echo "$unit stopped" ;;
|
|
restart)
|
|
echo "restarting $unit (saves the world first)..."
|
|
systemctl restart "$unit"
|
|
echo "$unit restarted" ;;
|
|
esac
|
|
}
|
|
|
|
# The servers log to their own journal (LogNamespace=terraria), which the
|
|
# 'terraria' group can read; see cmd_journal_access. It lives in
|
|
# <machine-id>.terraria under /var/log/journal (or /run/log/journal).
|
|
journal_dirs() {
|
|
local mid base
|
|
mid=$(cat /etc/machine-id 2>/dev/null) || return 0
|
|
[[ -n $mid ]] || return 0
|
|
for base in /var/log/journal /run/log/journal; do
|
|
if [[ -d $base/$mid.$JOURNAL_NS ]]; then printf '%s\n' "$base/$mid.$JOURNAL_NS"; fi
|
|
done
|
|
}
|
|
|
|
# Checks the actual permissions rather than guessing from group names. The
|
|
# folder itself is world-readable, so the journal file inside is what counts.
|
|
# No folder or file yet means nothing has been logged, so nothing to hide.
|
|
can_read_journal() {
|
|
if [[ $EUID -eq 0 ]]; then return 0; fi
|
|
local d
|
|
while IFS= read -r d; do
|
|
if [[ ! -r $d || ! -x $d ]]; then return 1; fi
|
|
if [[ -e $d/system.journal && ! -r $d/system.journal ]]; then return 1; fi
|
|
done < <(journal_dirs)
|
|
return 0
|
|
}
|
|
|
|
journal_hint() {
|
|
{
|
|
echo "$1"
|
|
if [[ " $(id -Gn) " == *" $SVC_USER "* ]]; then
|
|
echo "You're in the '$SVC_USER' group, but the servers' journal isn't set up for it"
|
|
echo "yet. That happens automatically when a server (re)starts, or run once:"
|
|
echo " sudo terractl journal-access"
|
|
else
|
|
echo "Reading it needs root or the '$SVC_USER' group. If you were just added to it,"
|
|
echo "log out and back in. Or use sudo."
|
|
fi
|
|
} >&2
|
|
}
|
|
|
|
# Run as root before every server start (ExecStartPre=+ in terraria@.service).
|
|
# Creates the servers' journal folder so it can be given to the terraria group
|
|
# before journald writes to it, then applies the access rules from tmpfiles.d
|
|
# (which also repair files journald created earlier). Only creates the folder
|
|
# where journald already keeps journals: creating /var/log/journal itself would
|
|
# switch the whole system to persistent logging. Never blocks a server start.
|
|
cmd_journal_access() {
|
|
[[ $EUID -eq 0 ]] || die "journal-access must run as root"
|
|
local mid base
|
|
mid=$(cat /etc/machine-id 2>/dev/null) || return 0
|
|
[[ -n $mid ]] || return 0
|
|
for base in /var/log/journal /run/log/journal; do
|
|
if [[ -d $base && ! -d $base/$mid.$JOURNAL_NS ]]; then
|
|
# inherits journald's group (systemd-journal) through the setgid parent
|
|
mkdir -m 0755 "$base/$mid.$JOURNAL_NS" 2>/dev/null ||
|
|
log "warning: could not create $base/$mid.$JOURNAL_NS"
|
|
fi
|
|
done
|
|
systemd-tmpfiles --create --prefix=/var/log/journal --prefix=/run/log/journal \
|
|
terractl.conf 2>/dev/null || log "warning: could not set journal access for group '$SVC_USER'"
|
|
}
|
|
|
|
cmd_cmd() {
|
|
local name=$1; shift
|
|
[[ $# -gt 0 ]] || die "no console command given"
|
|
local fifo="$RUN_DIR/$name.stdin" unit="terraria@$name" cursor="" out="" prev=-1 quiet=0 i
|
|
[[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
|
|
[[ -w $fifo ]] || die "no write access to $fifo (run with sudo or join group '$SVC_USER')"
|
|
|
|
if ! can_read_journal; then
|
|
printf '%s\n' "$*" > "$fifo"
|
|
echo "sent."
|
|
journal_hint "Note: commands are sent, but you can't see the server's output."
|
|
return 0
|
|
fi
|
|
|
|
# Note the journal position first, so only the reply to this command is shown
|
|
cursor=$(journalctl --namespace="$JOURNAL_NS" -u "$unit" -n 1 --show-cursor -q -o cat 2>/dev/null |
|
|
sed -n 's/^-- cursor: //p') || true
|
|
local -a after=(--since=-2s)
|
|
if [[ -n $cursor ]]; then after=(--after-cursor="$cursor"); fi
|
|
printf '%s\n' "$*" > "$fifo"
|
|
|
|
# Show the server's output until it has been quiet for 1s (at most 5s)
|
|
for ((i = 0; i < 50; i++)); do
|
|
sleep 0.1
|
|
out=$(journalctl --namespace="$JOURNAL_NS" -u "$unit" "${after[@]}" -q -o cat 2>/dev/null) || true
|
|
if [[ -n $out && ${#out} -eq $prev ]]; then
|
|
quiet=$((quiet + 1))
|
|
if [[ $quiet -ge 10 ]]; then break; fi
|
|
else
|
|
quiet=0
|
|
fi
|
|
prev=${#out}
|
|
done
|
|
if [[ -n $out ]]; then
|
|
printf '%s\n' "$out"
|
|
else
|
|
echo "sent; no output from the server within 5s (it may be busy or still starting)."
|
|
echo "Watch it with: $(hint group attach "$name")"
|
|
fi
|
|
}
|
|
|
|
cmd_attach() {
|
|
local name=$1 fifo="$RUN_DIR/$1.stdin" line
|
|
[[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
|
|
[[ -w $fifo ]] || die "no write access to $fifo (run with sudo or join group '$SVC_USER')"
|
|
|
|
echo "Attached to terraria@$name. Ctrl-D or Ctrl-C detaches; the server keeps running."
|
|
if can_read_journal; then
|
|
# Server output comes from the journal; -o cat drops the timestamp prefixes.
|
|
journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n 30 -o cat &
|
|
ATTACH_PID=$! # killed by cleanup() however attach ends
|
|
else
|
|
journal_hint "Note: commands are sent, but you can't see the server's output."
|
|
fi
|
|
trap 'echo; exit 0' INT
|
|
|
|
# -e gives line editing and history while typing commands
|
|
while IFS= read -r -e line; do
|
|
[[ -z $line ]] && continue
|
|
if [[ $line == exit ]]; then
|
|
echo "(not sent: 'exit' would shut the server down. Use Ctrl-D to detach," \
|
|
"or '$(hint group stop "$name")' to stop it.)"
|
|
continue
|
|
fi
|
|
printf '%s\n' "$line" > "$fifo"
|
|
done
|
|
echo
|
|
}
|
|
|
|
# Like attach, but only shows output: nothing typed is sent to the server, so
|
|
# it's safe to leave running. Works whether or not the server is running.
|
|
cmd_watch() {
|
|
local name=$1 lines=30 quiet=0; shift
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-n|--lines)
|
|
[[ ${2:-} =~ ^[0-9]+$ ]] || die "$1 needs a number of lines"
|
|
lines=$2; shift ;;
|
|
-q|--quiet) quiet=1 ;;
|
|
*) die "unknown option '$1'" ;;
|
|
esac
|
|
shift
|
|
done
|
|
# Only checkable with read access to the instances (root or the group)
|
|
if [[ -x $BASE_DIR && ! -f $(inst_dir "$name")/instance.conf ]]; then
|
|
die "no instance '$name'"
|
|
fi
|
|
if ! can_read_journal; then
|
|
journal_hint "Can't show the server's output: no read access to the servers' journal."
|
|
exit 1
|
|
fi
|
|
if [[ $quiet -eq 0 ]]; then
|
|
if ! systemctl is-active --quiet "terraria@$name" 2>/dev/null; then
|
|
echo "terraria@$name isn't running; output will appear here once it starts."
|
|
fi
|
|
echo "Watching terraria@$name (read-only). Ctrl-C quits; the server keeps running."
|
|
fi
|
|
exec journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n "$lines" -o cat
|
|
}
|
|
|
|
cmd_delete() {
|
|
local name=$1; shift
|
|
local assume_yes=0 backup=1 dir archive answer
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-y|--yes) assume_yes=1 ;;
|
|
--no-backup) backup=0 ;;
|
|
*) die "unknown option '$1'" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
[[ $EUID -eq 0 ]] || die "delete must be run as root"
|
|
dir=$(inst_dir "$name")
|
|
[[ -d $dir ]] || die "$dir does not exist"
|
|
# Refuse to remove anything that doesn't look like one of our instances
|
|
[[ -f $dir/instance.conf ]] || die "$dir has no instance.conf; not deleting it"
|
|
|
|
if [[ $assume_yes -ne 1 ]]; then
|
|
[[ -t 0 ]] || die "not a terminal; use -y to delete without confirmation"
|
|
echo "This will stop terraria@$name and remove $dir."
|
|
if [[ $backup -eq 1 ]]; then
|
|
echo "Worlds and configs will be archived to $BASE_DIR/.deleted/ first."
|
|
else
|
|
echo "NO BACKUP will be made. Worlds will be lost."
|
|
fi
|
|
read -r -p "Type the instance name to confirm: " answer
|
|
[[ $answer == "$name" ]] || die "confirmation did not match; nothing deleted"
|
|
fi
|
|
|
|
if command -v systemctl >/dev/null; then
|
|
systemctl disable --now "terraria@$name.service" 2>/dev/null || true
|
|
systemctl stop "terraria@$name.socket" 2>/dev/null || true
|
|
systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
|
|
if [[ -n $(backup_schedule "$name") ]]; then
|
|
backup_disable "$name" >/dev/null
|
|
echo "turned off scheduled backups of '$name'"
|
|
fi
|
|
fi
|
|
if instance_running "$name"; then
|
|
die "'$name' is still running outside systemd (e.g. 'terractl run' in another terminal); stop it, then run delete again"
|
|
fi
|
|
|
|
if [[ $backup -eq 1 ]]; then
|
|
mkdir -p "$BASE_DIR/.deleted"
|
|
chmod 700 "$BASE_DIR/.deleted"
|
|
archive="$BASE_DIR/.deleted/$name-$(date +%Y%m%d-%H%M%S).tar.gz"
|
|
# server/ is excluded: it's just the unpacked zip and can be recreated
|
|
tar -czf "$archive" -C "$BASE_DIR" \
|
|
--exclude="$name/server" --exclude="$name/.extract.*" "$name"
|
|
echo "backup: $archive"
|
|
fi
|
|
|
|
rm -rf -- "$dir"
|
|
echo "deleted instance '$name'"
|
|
}
|
|
|
|
# Gives an instance a new name: its folder, the paths in serverconfig.txt, and
|
|
# its service (stopped and started again, and still starting at boot if it did).
|
|
cmd_rename() {
|
|
local name=$1 new=${2:-} dir newdir state enabled sched every keep
|
|
[[ $# -eq 2 ]] || die "usage: terractl rename <name> <new name>"
|
|
valid_name "$new"
|
|
[[ $EUID -eq 0 ]] || die "rename must be run as root: sudo terractl rename $name $new"
|
|
dir=$(inst_dir "$name"); newdir=$(inst_dir "$new")
|
|
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
|
[[ ! -e $newdir ]] || die "instance '$new' already exists"
|
|
state=$(systemctl is-active "terraria@$name" 2>/dev/null || true)
|
|
enabled=$(systemctl is-enabled "terraria@$name" 2>/dev/null || true)
|
|
if [[ $state == inactive || $state == failed ]] && instance_running "$name"; then
|
|
die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); stop it first"
|
|
fi
|
|
if [[ $state == active || $state == activating || $state == reloading ]]; then
|
|
echo "stopping terraria@$name (saves the world first)..."
|
|
systemctl stop "terraria@$name"
|
|
fi
|
|
if [[ $enabled == enabled ]]; then systemctl disable -q "terraria@$name"; fi
|
|
systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
|
|
sched=$(backup_schedule "$name")
|
|
if [[ -n $sched ]]; then backup_disable "$name" >/dev/null; fi
|
|
mv "$dir" "$newdir"
|
|
if [[ -f $newdir/serverconfig.txt ]]; then
|
|
sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$newdir/data/#" "$newdir/serverconfig.txt"
|
|
fi
|
|
echo "renamed '$name' to '$new'"
|
|
if [[ -n $sched ]]; then
|
|
IFS=$'\t' read -r every keep <<< "$sched"
|
|
backup_enable "$new" --every "$every" --keep "$keep" >/dev/null
|
|
echo "scheduled backups now run for '$new'"
|
|
if [[ -d $BACKUP_DIR/$name ]]; then echo "backups made before the rename stay in $BACKUP_DIR/$name"; fi
|
|
fi
|
|
if [[ $enabled == enabled ]]; then systemctl enable -q "terraria@$new"; echo "terraria@$new starts at boot"; fi
|
|
if [[ $state == active || $state == activating || $state == reloading ]]; then
|
|
systemctl start "terraria@$new"
|
|
echo "terraria@$new started"
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------- export/import ----
|
|
# Archive layout: terractl-export.txt (info about the export; optional)
|
|
# <instance>/... (instance.conf, serverconfig.txt, data/, [server/])
|
|
# Backups made by 'delete' have the same layout without the info file, so they
|
|
# can be imported too.
|
|
EXPORT_INFO=terractl-export.txt
|
|
|
|
# Ask a server running under systemd to save, then wait until its world files
|
|
# have been written and stopped changing. Best effort: carries on after 60s.
|
|
save_and_wait() {
|
|
local name=$1 dir marker newest last="" stable=0 i
|
|
dir=$(inst_dir "$name")
|
|
marker=$(mktemp)
|
|
printf 'save\n' > "$RUN_DIR/$name.stdin"
|
|
log "'$name' is running: asked it to save, waiting for the world files..."
|
|
for ((i = 0; i < 60; i++)); do
|
|
sleep 1
|
|
newest=$(find "$dir/data/Worlds" -maxdepth 1 -type f -newer "$marker" -printf '%T@\n' 2>/dev/null |
|
|
sort -n | tail -n 1) || true
|
|
[[ -n $newest ]] || continue
|
|
if [[ $newest == "$last" ]]; then
|
|
stable=$((stable + 1))
|
|
if [[ $stable -ge 3 ]]; then rm -f "$marker"; log "save finished"; return 0; fi
|
|
else
|
|
stable=0; last=$newest
|
|
fi
|
|
done
|
|
rm -f "$marker"
|
|
log "warning: no completed save seen within 60s; exporting the last saved state"
|
|
}
|
|
|
|
cmd_export() {
|
|
local name=$1; shift
|
|
local out="" with_server=0 arg dir installed_zip="" ver rc=0
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
--with-server) with_server=1 ;;
|
|
-*) die "unknown option '$arg'" ;;
|
|
*) [[ -z $out ]] || die "more than one output file given"; out=$arg ;;
|
|
esac
|
|
done
|
|
dir=$(inst_dir "$name")
|
|
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
|
load_conf "$name"
|
|
|
|
[[ -n $out ]] || out="$name-$(date +%Y%m%d-%H%M%S).tar.gz"
|
|
out=$(abspath "$out")
|
|
[[ $out == *.tar.gz || $out == *.tgz ]] || out="$out.tar.gz"
|
|
[[ ! -e $out ]] || die "$out already exists"
|
|
[[ -d $(dirname "$out") ]] || die "folder $(dirname "$out") does not exist"
|
|
|
|
if instance_running "$name"; then
|
|
if [[ -p $RUN_DIR/$name.stdin ]]; then
|
|
save_and_wait "$name"
|
|
else
|
|
die "'$name' is running outside systemd, so terractl can't ask it to save; stop it first (type 'exit' in its console)"
|
|
fi
|
|
fi
|
|
|
|
if [[ -r $dir/server/.installed ]]; then read -r _ installed_zip < "$dir/server/.installed" || true; fi
|
|
ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi
|
|
EXTRACT_TMP=$(mktemp -d) # removed by cleanup() on exit
|
|
{
|
|
echo "# Created by 'terractl export'. Import with: terractl import <name> <this file>"
|
|
echo "format=1"
|
|
echo "terractl=$ver"
|
|
echo "instance=$name"
|
|
echo "type=$TYPE"
|
|
echo "server_zip=$installed_zip"
|
|
echo "includes_server=$(if [[ $with_server -eq 1 ]]; then echo yes; else echo no; fi)"
|
|
echo "exported=$(date -Iseconds)"
|
|
echo "host=$(hostname 2>/dev/null || echo unknown)"
|
|
} > "$EXTRACT_TMP/$EXPORT_INFO"
|
|
|
|
local -a excludes=(--exclude="$name/.lock" --exclude="$name/.extract.*"
|
|
--exclude="$name/data/Mods/.enabled.json.tmp")
|
|
if [[ $with_server -eq 0 ]]; then excludes+=(--exclude="$name/server"); fi
|
|
|
|
# serverconfig.txt can hold the server password, so the archive is private (0600)
|
|
(
|
|
umask 077
|
|
tar -czf "$out" --owner=0 --group=0 --numeric-owner "${excludes[@]}" \
|
|
-C "$EXTRACT_TMP" "$EXPORT_INFO" -C "$BASE_DIR" "$name"
|
|
) || rc=$?
|
|
if [[ $rc -ge 2 ]]; then rm -f "$out"; die "export failed (tar exit status $rc)"; fi
|
|
if [[ $rc -eq 1 ]]; then
|
|
log "warning: some files changed while being archived (an autosave?); consider exporting again"
|
|
fi
|
|
echo "exported '$name' to $out ($(du -h "$out" | cut -f1), readable only by $(id -un))"
|
|
if [[ $with_server -eq 0 ]]; then
|
|
echo "server/ not included; it's unpacked again from ${installed_zip:-the server zip} on first start after import"
|
|
fi
|
|
}
|
|
|
|
cmd_import() {
|
|
local name=$1; shift
|
|
[[ $# -eq 1 ]] || die "usage: terractl import <new instance name> <file.tar.gz>"
|
|
local file dir listing types m top="" meta_zip="" meta_from="" port newport p f clash=0 zip=""
|
|
file=$(abspath "$1")
|
|
[[ -f $file && -r $file ]] || die "cannot read $file"
|
|
dir=$(inst_dir "$name")
|
|
[[ ! -e $dir ]] || die "instance '$name' already exists; choose another name, or delete it first"
|
|
|
|
# Check the archive before extracting anything
|
|
listing=$(tar -tzf "$file" 2>/dev/null) || die "$file is not a readable .tar.gz archive"
|
|
types=$(tar -tvzf "$file" 2>/dev/null | cut -c1 | sort -u | tr -d '\n')
|
|
[[ $types =~ ^[-d]+$ ]] || die "archive contains links or special files; refusing to import it"
|
|
local -A tops=()
|
|
while IFS= read -r m; do
|
|
case $m in
|
|
/*|..|../*|*/../*|*/..) die "archive contains an unsafe path '$m'; refusing to import it" ;;
|
|
esac
|
|
m=${m#./}
|
|
if [[ -z $m || $m == . || $m == "$EXPORT_INFO" ]]; then continue; fi
|
|
tops[${m%%/*}]=1
|
|
done <<< "$listing"
|
|
if [[ ${#tops[@]} -ne 1 ]]; then
|
|
local found="nothing"
|
|
if [[ ${#tops[@]} -gt 0 ]]; then found="${!tops[*]}"; fi
|
|
die "expected one instance folder in the archive (as 'terractl export' creates); found: $found"
|
|
fi
|
|
top=${!tops[*]}
|
|
|
|
mkdir -p "$BASE_DIR"
|
|
EXTRACT_TMP=$(mktemp -d "$BASE_DIR/.import.XXXXXX") # removed by cleanup() on exit
|
|
( umask 027; tar -xzf "$file" -C "$EXTRACT_TMP" --no-same-owner --no-same-permissions ) ||
|
|
die "extracting $file failed"
|
|
[[ -f $EXTRACT_TMP/$top/instance.conf ]] || die "no instance.conf in '$top/'; this isn't a terractl instance archive"
|
|
if [[ -f $EXTRACT_TMP/$EXPORT_INFO ]]; then
|
|
meta_zip=$(sed -n 's/^server_zip=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1)
|
|
meta_from=$(sed -n 's/^instance=//p' "$EXTRACT_TMP/$EXPORT_INFO" | tail -n 1)
|
|
fi
|
|
mv "$EXTRACT_TMP/$top" "$dir"
|
|
rm -rf "$EXTRACT_TMP"; EXTRACT_TMP=""
|
|
|
|
# serverconfig.txt holds absolute paths from where it came from; keep only
|
|
# the part after /data/ (this also converts old /opt/terraria instances)
|
|
if [[ -f $dir/serverconfig.txt ]]; then
|
|
sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$dir/data/#" "$dir/serverconfig.txt"
|
|
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" | tail -n 1)
|
|
if [[ -n $port ]]; then
|
|
for f in "$BASE_DIR"/*/serverconfig.txt; do
|
|
[[ -f $f && $f != "$dir/serverconfig.txt" ]] || continue
|
|
p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$f" | tail -n 1)
|
|
if [[ $p == "$port" ]]; then clash=1; fi
|
|
done
|
|
if [[ $clash -eq 1 ]]; then
|
|
newport=$(next_free_port)
|
|
sed -i "s/^port=.*/port=$newport/" "$dir/serverconfig.txt"
|
|
log "port $port is already used by another instance; '$name' now uses port $newport"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Keep the server version the world was exported with, unless one is pinned already
|
|
load_conf "$name"
|
|
# A pin to an absolute path from another host usually doesn't exist here: use
|
|
# the zip of that name in this host's dist folder instead
|
|
if [[ $ZIP == /* && ! -r $ZIP && $(basename "$ZIP") =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then
|
|
log "ZIP=$ZIP isn't on this host; now ZIP=$(basename "$ZIP") (from $DIST_DIR)"
|
|
ZIP=$(basename "$ZIP")
|
|
sed -i "s/^ZIP=.*/ZIP=$ZIP/" "$dir/instance.conf"
|
|
fi
|
|
if [[ -z $ZIP && $meta_zip =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then
|
|
sed -i "s/^ZIP=.*/ZIP=$meta_zip/" "$dir/instance.conf"
|
|
ZIP=$meta_zip
|
|
log "pinned ZIP=$meta_zip in instance.conf (the version it was exported with)"
|
|
fi
|
|
if [[ ! -f $dir/server/.installed ]]; then
|
|
zip=$(pick_zip 2>/dev/null) || true
|
|
if [[ -z $zip || ! -r $zip ]]; then
|
|
log "warning: before starting, put ${ZIP:-a $TYPE server zip} in $DIST_DIR"
|
|
fi
|
|
fi
|
|
|
|
if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi
|
|
if [[ -n $meta_from ]]; then
|
|
echo "imported '$meta_from' as '$name' into $dir"
|
|
else
|
|
echo "imported '$name' into $dir"
|
|
fi
|
|
echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)"
|
|
location_warnings
|
|
}
|
|
|
|
# ---------------------------------------------------------------- mods ----
|
|
# enabled.json is a plain JSON array of mod names, e.g. ["CalamityMod","MagicStorage"].
|
|
# Mod names are identifiers, so simple text handling is enough (no jq needed).
|
|
|
|
fix_owner() { if [[ $EUID -eq 0 ]]; then chown "$SVC_USER:$SVC_USER" "$@"; fi; }
|
|
|
|
read_enabled() {
|
|
local f="$1/enabled.json"
|
|
[[ -f $f ]] || return 0
|
|
grep -o '"[^"]*"' "$f" | tr -d '"' || true
|
|
}
|
|
|
|
write_enabled() { # write_enabled <mods dir> <name>...
|
|
local mdir=$1; shift
|
|
local out="[" sep="" n
|
|
for n in "$@"; do out+="$sep"$'\n'" \"$n\""; sep=","; done
|
|
if [[ $# -gt 0 ]]; then out+=$'\n'; fi
|
|
out+="]"
|
|
printf '%s\n' "$out" > "$mdir/.enabled.json.tmp"
|
|
mv "$mdir/.enabled.json.tmp" "$mdir/enabled.json"
|
|
fix_owner "$mdir/enabled.json"
|
|
}
|
|
|
|
mods_restart_hint() {
|
|
if systemctl is-active --quiet "terraria@$name" 2>/dev/null; then
|
|
echo "Restart to apply: $(hint group restart "$name")"
|
|
fi
|
|
}
|
|
|
|
mods_list() {
|
|
local enabled f n st
|
|
enabled=$(read_enabled "$mdir")
|
|
local -a files=()
|
|
for f in "$mdir"/*.tmod; do
|
|
if [[ -f $f ]]; then files+=("$f"); fi
|
|
done
|
|
if [[ ${#files[@]} -eq 0 ]]; then
|
|
echo "No mods installed in $mdir"
|
|
echo "Add some with: $(hint root mods "$name" add "<folder or .tmod files>")"
|
|
return 0
|
|
fi
|
|
printf '%-32s %s\n' MOD STATE
|
|
for f in "${files[@]}"; do
|
|
n=$(basename "$f" .tmod)
|
|
if grep -qxF "$n" <<<"$enabled"; then st=enabled; else st=disabled; fi
|
|
printf '%-32s %s\n' "$n" "$st"
|
|
done
|
|
for n in $enabled; do
|
|
if [[ ! -f $mdir/$n.tmod ]]; then printf '%-32s %s\n' "$n" "enabled but MISSING (server will fail to load mods)"; fi
|
|
done
|
|
}
|
|
|
|
mods_add() {
|
|
[[ $# -gt 0 ]] || die "usage: terractl mods $name add <file.tmod|folder>..."
|
|
local src f n found
|
|
local -a files=() cfgs=()
|
|
for src in "$@"; do
|
|
src=$(abspath "$src")
|
|
if [[ -f $src ]]; then
|
|
[[ $src == *.tmod ]] || die "$src is not a .tmod file"
|
|
files+=("$src")
|
|
elif [[ -d $src ]]; then
|
|
# Accepts a folder of .tmod files, or an export folder containing Mods/ and ModConfigs/
|
|
found=0
|
|
for f in "$src"/*.tmod "$src"/Mods/*.tmod; do
|
|
if [[ -f $f ]]; then files+=("$f"); found=1; fi
|
|
done
|
|
[[ $found -eq 1 ]] || die "no .tmod files in $src or $src/Mods"
|
|
for f in "$src"/ModConfigs/*.json; do
|
|
if [[ -f $f ]]; then cfgs+=("$f"); fi
|
|
done
|
|
else
|
|
die "$src not found"
|
|
fi
|
|
done
|
|
|
|
local -A pick=()
|
|
for f in "${files[@]}"; do
|
|
n=$(basename "$f" .tmod)
|
|
[[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "unexpected mod file name '$n.tmod'"
|
|
if [[ -n ${pick[$n]:-} ]]; then log "warning: $n given more than once; using $f"; fi
|
|
pick[$n]=$f
|
|
done
|
|
|
|
mkdir -p "$mdir"
|
|
fix_owner "$dir/data" "$mdir"
|
|
for n in "${!pick[@]}"; do
|
|
install -m 0640 "${pick[$n]}" "$mdir/$n.tmod"
|
|
fix_owner "$mdir/$n.tmod"
|
|
echo "installed $n"
|
|
done
|
|
|
|
if [[ ${#cfgs[@]} -gt 0 ]]; then
|
|
mkdir -p "$dir/data/ModConfigs"
|
|
fix_owner "$dir/data/ModConfigs"
|
|
for f in "${cfgs[@]}"; do
|
|
install -m 0640 "$f" "$dir/data/ModConfigs/$(basename "$f")"
|
|
fix_owner "$dir/data/ModConfigs/$(basename "$f")"
|
|
done
|
|
echo "installed ${#cfgs[@]} mod config file(s)"
|
|
fi
|
|
|
|
local -a current=()
|
|
mapfile -t current < <(read_enabled "$mdir")
|
|
# shellcheck disable=SC2046
|
|
write_enabled "$mdir" $(printf '%s\n' "${current[@]}" "${!pick[@]}" | sort -u)
|
|
echo "enabled: ${!pick[*]}"
|
|
echo "Note: dependencies are not checked here; if one is missing, the server log will say which."
|
|
mods_restart_hint
|
|
}
|
|
|
|
mods_set() { # mods_set 1|0 <name>...
|
|
local on=$1; shift
|
|
[[ $# -gt 0 ]] || die "no mod names given"
|
|
local n list
|
|
local -a current=()
|
|
mapfile -t current < <(read_enabled "$mdir")
|
|
if [[ $on -eq 1 ]]; then
|
|
for n in "$@"; do
|
|
[[ -f $mdir/$n.tmod ]] || die "$n is not installed (no $mdir/$n.tmod)"
|
|
done
|
|
list=$(printf '%s\n' "${current[@]}" "$@" | sort -u)
|
|
else
|
|
list=$(printf '%s\n' "${current[@]}" | grep -vxF -f <(printf '%s\n' "$@") || true)
|
|
fi
|
|
mkdir -p "$mdir"
|
|
# shellcheck disable=SC2086
|
|
write_enabled "$mdir" $list
|
|
}
|
|
|
|
mods_remove() {
|
|
[[ $# -gt 0 ]] || die "no mod names given"
|
|
local n
|
|
for n in "$@"; do
|
|
[[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "invalid mod name '$n'"
|
|
if [[ -f $mdir/$n.tmod ]]; then rm -f "$mdir/$n.tmod"; echo "removed $n"; else log "$n was not installed"; fi
|
|
done
|
|
mods_set 0 "$@"
|
|
}
|
|
|
|
cmd_mods() {
|
|
local name=$1; shift
|
|
local action=${1:-list}
|
|
if [[ $# -gt 0 ]]; then shift; fi
|
|
local dir mdir
|
|
dir=$(inst_dir "$name")
|
|
[[ -f $dir/instance.conf ]] || die "no instance '$name' (create it with: $(hint root new "$name" tmodloader))"
|
|
load_conf "$name"
|
|
[[ $TYPE == tmodloader ]] || die "'$name' is a $TYPE instance; mods need TYPE=tmodloader"
|
|
mdir="$dir/data/Mods"
|
|
case $action in
|
|
list) mods_list ;;
|
|
add) mods_add "$@" ;;
|
|
enable) mods_set 1 "$@"; echo "enabled: $*"; mods_restart_hint ;;
|
|
disable) mods_set 0 "$@"; echo "disabled: $*"; mods_restart_hint ;;
|
|
remove) mods_remove "$@"; mods_restart_hint ;;
|
|
*) die "unknown mods action '$action' (list, add, enable, disable, remove)" ;;
|
|
esac
|
|
}
|
|
|
|
cmd_new() {
|
|
local name=$1 type=${2:-vanilla} port=${3:-} dir
|
|
[[ $type == vanilla || $type == tmodloader ]] || die "type must be vanilla or tmodloader"
|
|
[[ -z $port || $port =~ ^[0-9]+$ ]] || die "port must be a number"
|
|
dir=$(inst_dir "$name")
|
|
[[ -e $dir ]] && die "$dir already exists"
|
|
mkdir -p "$dir/data/Worlds"
|
|
if [[ $type == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
|
|
write_instance_conf "$name" "$type"
|
|
write_server_config "$name" "$port"
|
|
if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi
|
|
cat <<EOF
|
|
Created $type instance '$name' in $dir
|
|
Review $dir/serverconfig.txt, then:
|
|
EOF
|
|
if [[ $type == tmodloader ]]; then
|
|
echo " $(hint root mods "$name" add "<folder or .tmod files>") (before first start)"
|
|
fi
|
|
cat <<EOF
|
|
$(hint root enable "$name" --now)
|
|
$(hint group attach "$name")
|
|
EOF
|
|
if firewalld_running && ! firewalld_allows "$(inst_port "$name")"; then
|
|
echo "firewalld is on; to let players connect, open its port:"
|
|
echo " $(firewalld_open_cmd "$(if [[ $EUID -ne 0 ]]; then echo "sudo "; fi)" "$(inst_port "$name")")"
|
|
fi
|
|
location_warnings
|
|
}
|
|
|
|
# Whether the server starts at boot. Needs root: systemd doesn't let polkit
|
|
# rules limit enabling to our units, so the terraria group can't be allowed it.
|
|
cmd_enable() {
|
|
local action=$1 name=$2 now=0 unit="terraria@$2"; shift 2
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--now) now=1 ;;
|
|
*) die "unknown option '$1'" ;;
|
|
esac
|
|
shift
|
|
done
|
|
[[ $EUID -eq 0 ]] || die "'$action' needs root: sudo terractl $action $name$(if [[ $now -eq 1 ]]; then echo " --now"; fi)"
|
|
if [[ $action == enable ]]; then
|
|
# Enabling a name without an instance would create a default one at boot
|
|
[[ -f $(inst_dir "$name")/instance.conf ]] ||
|
|
die "no instance '$name' (create it with: $(hint root new "$name"))"
|
|
location_warnings
|
|
systemctl enable -q "$unit"
|
|
if [[ $now -eq 1 ]]; then
|
|
systemctl start "$unit"
|
|
echo "$unit started and will start at boot"
|
|
elif systemctl is-active --quiet "$unit"; then
|
|
echo "$unit will start at boot"
|
|
else
|
|
echo "$unit will start at boot. To start it now: $(hint group start "$name")"
|
|
fi
|
|
else
|
|
systemctl disable -q "$unit"
|
|
if [[ $now -eq 1 ]]; then
|
|
echo "stopping $unit (saves the world first)..."
|
|
systemctl stop "$unit"
|
|
echo "$unit stopped and won't start at boot"
|
|
elif systemctl is-active --quiet "$unit"; then
|
|
echo "$unit won't start at boot; it is still running. To stop it: $(hint group stop "$name")"
|
|
else
|
|
echo "$unit won't start at boot"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Can the service user read <file>? Worked out from the file's owner, group and
|
|
# mode, because whoever runs terractl may be someone else.
|
|
svc_can_read() {
|
|
local owner group mode
|
|
read -r owner group mode < <(stat -c '%U %G %a' "$1" 2>/dev/null) || return 1
|
|
if [[ $owner == "$SVC_USER" ]]; then (( 8#$mode & 0400 ))
|
|
elif [[ $group == "$SVC_USER" ]]; then (( 8#$mode & 0040 ))
|
|
else (( 8#$mode & 0004 )); fi
|
|
}
|
|
|
|
# Check that the service user can run this script, which prepare/run (as root)
|
|
# and upgrade switch to. Not so for a copy in a private folder, such as a
|
|
# development copy under /root.
|
|
need_svc_can_run_self() {
|
|
command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead"
|
|
runuser -u "$SVC_USER" -- test -x "$SELF" 2>/dev/null ||
|
|
die "$1 runs $SELF as the '$SVC_USER' user, who can't open it (it's in a folder only root can open, e.g. /root). Use the installed terractl, or a copy somewhere '$SVC_USER' can read."
|
|
}
|
|
|
|
# Zips in dist/ the servers can't unpack, e.g. copied in with 'scp -p' from a
|
|
# private file. Instances fail on their first start with such a zip.
|
|
unreadable_zips() {
|
|
local f
|
|
for f in "$DIST_DIR"/*.zip; do
|
|
if [[ -f $f ]] && ! svc_can_read "$f"; then echo "$f"; fi
|
|
done
|
|
}
|
|
|
|
zip_warning() {
|
|
local where=""
|
|
local -a bad=()
|
|
mapfile -t bad < <(unreadable_zips)
|
|
[[ ${#bad[@]} -gt 0 ]] || return 0
|
|
if [[ -n ${TERRACTL_VIA:-} ]]; then where=" (on $(hostname 2>/dev/null || echo that host))"; fi
|
|
log "warning: the '$SVC_USER' user can't read these zips, so servers can't be installed from them:"
|
|
printf ' %s\n' "${bad[@]}" >&2
|
|
log "fix it with$where: sudo chmod 644 ${bad[*]@Q}"
|
|
}
|
|
|
|
# ------------------------------------------------------------ locations ----
|
|
# The services' sandboxes (ProtectSystem=, ProtectHome=, ReadWritePaths=) are
|
|
# set in their unit files, which can't follow /etc/default/terractl. If
|
|
# TERRARIA_BASE or TERRARIA_DIST is moved somewhere a unit can't reach, it needs
|
|
# a drop-in; these checks say so, with the drop-in to add.
|
|
|
|
# Is <path> <dir>, or inside it?
|
|
path_in() { [[ $1 == "$2" || $1 == "$2"/* ]]; }
|
|
|
|
# Why <unit> can't use <path> (need: rw or ro): 'home' (ProtectHome hides it),
|
|
# 'home-ro' (ProtectHome=read-only, not writable) or 'system' (ProtectSystem
|
|
# makes it read-only). Prints nothing if the unit can use it, or if systemd or
|
|
# the unit isn't there to ask.
|
|
unit_path_problem() {
|
|
local unit=$1 path=$2 need=$3 key value load="" ps=no ph=no p covered=0
|
|
local -a rw=()
|
|
command -v systemctl >/dev/null || return 0
|
|
while IFS='=' read -r key value; do
|
|
case $key in
|
|
LoadState) load=$value ;;
|
|
ProtectSystem) ps=$value ;;
|
|
ProtectHome) ph=$value ;;
|
|
ReadWritePaths) read -ra rw <<< "$value" ;;
|
|
esac
|
|
done < <(systemctl show -p LoadState,ProtectSystem,ProtectHome,ReadWritePaths "$unit" 2>/dev/null)
|
|
[[ $load == loaded ]] || return 0
|
|
path=$(readlink -m "$path") # where it really is, as systemd sees it
|
|
for p in "${rw[@]}"; do
|
|
p=${p#-} # '-': ignored if missing
|
|
if path_in "$path" "$(readlink -m "$p")"; then covered=1; fi
|
|
done
|
|
if path_in "$path" /home || path_in "$path" /root || path_in "$path" /run/user; then
|
|
case $ph in
|
|
no) ;;
|
|
read-only) if [[ $need == rw && $covered -eq 0 ]]; then echo home-ro; fi ;;
|
|
*) echo home ;;
|
|
esac
|
|
return 0
|
|
fi
|
|
[[ $need == rw && $covered -eq 0 ]] || return 0
|
|
case $ps in
|
|
strict) echo system ;;
|
|
yes|full)
|
|
if path_in "$path" /usr || path_in "$path" /boot || path_in "$path" /efi ||
|
|
{ [[ $ps == full ]] && path_in "$path" /etc; }; then
|
|
echo system
|
|
fi ;;
|
|
esac
|
|
}
|
|
|
|
# What's wrong for one unit: location_problems <unit> <name to edit> <what
|
|
# breaks> <dist access needed>. Fills LOC_PROBLEMS (messages), LOC_RW (paths
|
|
# for ReadWritePaths=) and LOC_HOME (1 if ProtectHome= must change).
|
|
location_problems() {
|
|
local unit=$1 edit=$2 effect=$3 dist_need=$4 what path need problem
|
|
LOC_PROBLEMS=() LOC_RW=() LOC_HOME=0
|
|
for what in TERRARIA_BASE TERRARIA_DIST; do
|
|
if [[ $what == TERRARIA_BASE ]]; then path=$BASE_DIR need=rw; else path=$DIST_DIR need=$dist_need; fi
|
|
problem=$(unit_path_problem "$unit" "$path" "$need")
|
|
case $problem in
|
|
home)
|
|
LOC_PROBLEMS+=("$edit hides $path ($what) under ProtectHome=, so $effect")
|
|
LOC_HOME=1
|
|
if [[ $need == rw ]]; then LOC_RW+=("$path"); fi ;;
|
|
home-ro|system)
|
|
LOC_PROBLEMS+=("$edit can't write to $path ($what), so $effect")
|
|
if [[ $problem == home-ro ]]; then LOC_HOME=1; fi
|
|
LOC_RW+=("$path") ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# The drop-in that fixes what location_problems found
|
|
location_dropin() {
|
|
local path
|
|
echo "[Service]"
|
|
if [[ $LOC_HOME -eq 1 ]]; then echo "ProtectHome=read-only"; fi
|
|
for path in "${LOC_RW[@]}"; do echo "ReadWritePaths=$path"; done
|
|
}
|
|
|
|
# Warns about one unit, with the drop-in to add; arguments as location_problems
|
|
location_warning() {
|
|
local edit=$2 where=""
|
|
location_problems "$@"
|
|
[[ ${#LOC_PROBLEMS[@]} -gt 0 ]] || return 0
|
|
if [[ -n ${TERRACTL_VIA:-} ]]; then where=" (on $(hostname 2>/dev/null || echo that host))"; fi
|
|
printf 'terractl: warning: %s\n' "${LOC_PROBLEMS[@]}" >&2
|
|
{
|
|
echo " Fix it with a drop-in$where: sudo systemctl edit $edit"
|
|
location_dropin | sed 's/^/ /'
|
|
if [[ $LOC_HOME -eq 1 ]]; then
|
|
echo " The '$SVC_USER' user also needs access to every folder above it; see"
|
|
echo " \"Instances or zips under /home\" in the guide."
|
|
else
|
|
echo " See \"Changing locations\" in the guide, or run: $(hint root doctor --fix)"
|
|
fi
|
|
} >&2
|
|
}
|
|
|
|
# The servers always; the daemon only if it's in use. The daemon writes zips
|
|
# into dist/ when instances are copied to this host, so it needs both writable.
|
|
location_warnings() {
|
|
location_warning terraria@terractl-check.service terraria@.service \
|
|
"servers can't start" ro
|
|
if systemctl is-enabled --quiet terractld.service 2>/dev/null ||
|
|
systemctl is-active --quiet terractld.service 2>/dev/null; then
|
|
location_warning terractld.service terractld.service \
|
|
"other hosts can't copy, move or import instances here" rw
|
|
fi
|
|
}
|
|
|
|
cmd_list() {
|
|
local d n port boot state
|
|
printf '%-20s %-11s %-6s %-9s %s\n' INSTANCE TYPE PORT BOOT STATE
|
|
for d in "$BASE_DIR"/*/; do
|
|
[[ -f $d/instance.conf ]] || continue
|
|
n=$(basename "$d")
|
|
(
|
|
load_conf "$n"
|
|
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$d/serverconfig.txt" 2>/dev/null | tail -n1)
|
|
# enabled, disabled, or e.g. masked
|
|
boot=$(systemctl is-enabled "terraria@$n" 2>/dev/null || true)
|
|
state=$(systemctl is-active "terraria@$n" 2>/dev/null || true)
|
|
if [[ $state != active ]] && instance_running "$n"; then state="running (outside systemd)"; fi
|
|
printf '%-20s %-11s %-6s %-9s %s\n' "$n" "$TYPE" "${port:--}" "${boot:--}" "$state"
|
|
) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
|
|
done
|
|
zip_warning
|
|
location_warnings
|
|
}
|
|
|
|
# ------------------------------------------------------------ versions ----
|
|
# The zip an instance runs is recorded in server/.installed when it's unpacked.
|
|
# A pin (ZIP= in instance.conf) that differs from it makes the next start (the
|
|
# 'prepare' step) unpack the pinned zip instead.
|
|
|
|
# Checks that <file> is a complete server zip of <type>; dies saying what's wrong
|
|
check_server_zip() {
|
|
local f=$1 type=$2 listing
|
|
command -v unzip >/dev/null || die "unzip is not installed"
|
|
listing=$(unzip -Z1 "$f" 2>/dev/null) || die "$f isn't a readable zip (damaged, or not fully downloaded?)"
|
|
case $type in
|
|
vanilla) grep -q '/Linux/TerrariaServer\.bin\.x86_64$' <<< "$listing" ||
|
|
die "$f has no Linux/TerrariaServer.bin.x86_64; is it the dedicated server zip?" ;;
|
|
tmodloader) grep -qE '(^|/)start-tModLoaderServer\.sh$' <<< "$listing" ||
|
|
die "$f has no start-tModLoaderServer.sh; is it a tModLoader release zip?" ;;
|
|
esac
|
|
log "checking $(basename "$f")..."
|
|
unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again"
|
|
}
|
|
|
|
# The version in a zip's name: tModLoader-v2026.07.3.0.zip -> 2026.07.3.0,
|
|
# terraria-server-1458.zip -> 1458
|
|
zip_version() {
|
|
local v=${1%.zip}
|
|
v=${v#terraria-server-}; v=${v#tModLoader}; v=${v#-}; v=${v#v}
|
|
echo "$v"
|
|
}
|
|
|
|
zip_pattern() {
|
|
case $TYPE in
|
|
vanilla) echo 'terraria-server-*.zip' ;;
|
|
tmodloader) echo 'tModLoader*.zip' ;;
|
|
esac
|
|
}
|
|
|
|
# The zips in dist/ for this instance's TYPE, oldest first
|
|
type_zips() {
|
|
find "$DIST_DIR" -maxdepth 1 -type f -name "$(zip_pattern)" -printf '%f\n' | sort -V
|
|
}
|
|
|
|
# The zip server/ was unpacked from (file name only), or nothing
|
|
installed_zip() {
|
|
local marker type="" zip=""
|
|
marker="$(inst_dir "$1")/server/.installed"
|
|
if [[ -r $marker ]]; then read -r type zip < "$marker" || true; fi
|
|
echo "$zip"
|
|
}
|
|
|
|
# instance.conf is sourced by bash, so only plain file names go into it
|
|
safe_zip_name() { [[ $1 =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; }
|
|
|
|
set_zip_pin() {
|
|
local conf; conf="$(inst_dir "$1")/instance.conf"
|
|
if grep -q '^ZIP=' "$conf"; then
|
|
sed -i "s|^ZIP=.*|ZIP=$2|" "$conf"
|
|
else
|
|
printf 'ZIP=%s\n' "$2" >> "$conf"
|
|
fi
|
|
fix_owner "$conf"
|
|
}
|
|
|
|
# Unpacks the same zip again on the next start. An unpinned instance is pinned
|
|
# to the zip it runs first, as otherwise it would pick the newest zip in dist/
|
|
# and so change version; changing version is what 'upgrade' is for.
|
|
cmd_reinstall() {
|
|
local name=$1 clean=0 dir installed; dir=$(inst_dir "$name")
|
|
shift
|
|
case ${1:-} in
|
|
--clean) clean=1 ;;
|
|
"") ;;
|
|
*) die "unknown option '$1'" ;;
|
|
esac
|
|
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
|
if [[ $clean -eq 1 ]] && instance_running "$name"; then
|
|
die "'$name' is running; --clean would delete .NET under it. Stop it first: $(hint group stop "$name")"
|
|
fi
|
|
load_conf "$name"
|
|
installed=$(installed_zip "$name")
|
|
if [[ -z $ZIP && -n $installed ]]; then
|
|
[[ -f $DIST_DIR/$installed ]] ||
|
|
die "'$name' runs $installed, which is no longer in $DIST_DIR. Put it back to unpack it again, or change version: $(hint root upgrade "$name")"
|
|
safe_zip_name "$installed" || die "can't pin '$installed' in instance.conf; rename it in $DIST_DIR and use: $(hint root upgrade "$name" "<zip>")"
|
|
set_zip_pin "$name" "$installed"
|
|
log "pinned ZIP=$installed in instance.conf, so the same version is unpacked again"
|
|
elif [[ -n $ZIP && -n $installed && $(basename "$ZIP") != "$installed" ]]; then
|
|
log "note: instance.conf pins ZIP=$ZIP, but $installed is installed; the next start unpacks $(basename "$ZIP")"
|
|
fi
|
|
# Keeps .installed, so what it runs is still known (e.g. to 'upgrade')
|
|
if [[ -d $dir/server ]]; then touch "$dir/server/.reinstall"; fix_owner "$dir/server/.reinstall"; fi
|
|
if [[ $clean -eq 1 ]]; then
|
|
rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
|
|
echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again"
|
|
fi
|
|
echo "server/ will be re-unpacked on next start: $(hint group restart "$name")"
|
|
}
|
|
|
|
upgrade_list() {
|
|
local z joined newest=""
|
|
local -a zips=() note=()
|
|
mapfile -t zips < <(type_zips)
|
|
if [[ ${#zips[@]} -gt 0 ]]; then newest=${zips[-1]}; fi
|
|
echo "'$name' ($TYPE) runs: ${installed:-nothing yet (unpacked on its first start)}"
|
|
if [[ ${#zips[@]} -eq 0 ]]; then
|
|
echo "No '$(zip_pattern)' zips in $DIST_DIR"
|
|
return 0
|
|
fi
|
|
printf '%-40s %s\n' ZIP NOTE
|
|
for z in "${zips[@]}"; do
|
|
note=()
|
|
if [[ $z == "$installed" ]]; then note+=(installed); fi
|
|
if [[ -n $ZIP && $z == "${ZIP##*/}" ]]; then note+=(pinned); fi
|
|
if [[ $z == "$newest" ]]; then note+=(newest); fi
|
|
if ! svc_can_read "$DIST_DIR/$z"; then note+=("unreadable by $SVC_USER"); fi
|
|
joined=$(printf '%s, ' "${note[@]}")
|
|
printf '%-40s %s\n' "$z" "${joined%, }" | sed 's/ *$//'
|
|
done
|
|
if [[ -n $installed && ! -f $DIST_DIR/$installed ]]; then
|
|
printf '%-40s %s\n' "$installed" "installed, no longer in $DIST_DIR"
|
|
fi
|
|
echo "Change version with: $(hint root upgrade "$name" "<zip>|latest")"
|
|
}
|
|
|
|
# Changes the server version (zip) of an instance, up or down, and applies it
|
|
# right away: checks the zip, stops the server, backs up its worlds and
|
|
# configs, pins ZIP=, unpacks the zip and starts the server again. Anything
|
|
# that fails before the new server/ is in place leaves the instance as it was.
|
|
cmd_upgrade() {
|
|
local name=$1; shift
|
|
local target="" downgrade=0 backup=1 arg dir installed zip f lower verb state
|
|
local was_running=0 installed_name old_pin backup_file="" unit="terraria@$name"
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
--downgrade) downgrade=1 ;;
|
|
--no-backup) backup=0 ;;
|
|
-*) die "unknown option '$arg'" ;;
|
|
*) [[ -z $target ]] || die "more than one zip given"; target=$arg ;;
|
|
esac
|
|
done
|
|
dir=$(inst_dir "$name")
|
|
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
|
|
load_conf "$name"
|
|
installed=$(installed_zip "$name")
|
|
if [[ -z $target ]]; then upgrade_list; return 0; fi
|
|
[[ $EUID -eq 0 ]] || die "changing version needs root: sudo terractl upgrade $name $*"
|
|
|
|
# Check the zip before changing anything
|
|
if [[ $target == latest ]]; then
|
|
zip=$(type_zips | tail -n 1)
|
|
[[ -n $zip ]] || die "no '$(zip_pattern)' zips in $DIST_DIR"
|
|
elif [[ $target == */* ]]; then
|
|
[[ $(dirname "$(abspath "$target")") -ef $DIST_DIR ]] ||
|
|
die "$target isn't in $DIST_DIR; copy it there first"
|
|
zip=$(basename "$target")
|
|
else
|
|
zip=$target
|
|
fi
|
|
# shellcheck disable=SC2053 # the pattern is a glob on purpose
|
|
[[ $zip == $(zip_pattern) ]] || die "$zip isn't a $TYPE zip (expected $(zip_pattern))"
|
|
safe_zip_name "$zip" || die "rename $zip to use only letters, digits and ._+- (it's written into instance.conf)"
|
|
f="$DIST_DIR/$zip"
|
|
[[ -f $f ]] || die "$f not found. Available: $(hint group upgrade "$name")"
|
|
svc_can_read "$f" || die "the '$SVC_USER' user can't read $f; fix it with: sudo chmod 644 $f"
|
|
|
|
if [[ $zip == "$installed" ]]; then
|
|
if [[ ${ZIP##*/} != "$zip" ]]; then set_zip_pin "$name" "$zip"; log "pinned ZIP=$zip in instance.conf"; fi
|
|
echo "'$name' already runs $zip"
|
|
return 0
|
|
fi
|
|
if [[ -n $installed ]]; then
|
|
lower=$(printf '%s\n' "$installed" "$zip" | sort -V | head -n 1)
|
|
if [[ $lower == "$zip" ]]; then
|
|
verb=downgraded
|
|
if [[ $downgrade -eq 0 ]]; then
|
|
die "$zip is older than $installed, which '$name' runs now. Worlds saved by a newer version" \
|
|
"can't be opened by an older one, and mods may not load. To go ahead anyway: $(hint root upgrade "$name" "$zip" --downgrade)"
|
|
fi
|
|
else
|
|
verb=upgraded
|
|
fi
|
|
else
|
|
verb=switched
|
|
fi
|
|
|
|
need_svc_can_run_self "upgrade"
|
|
check_server_zip "$f" "$TYPE"
|
|
|
|
state=$(systemctl is-active "$unit" 2>/dev/null || true)
|
|
if [[ $state == inactive || $state == failed ]] && instance_running "$name"; then
|
|
die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); stop it first"
|
|
fi
|
|
if [[ $state == active || $state == activating || $state == reloading ]]; then
|
|
was_running=1
|
|
echo "stopping $unit (saves the world first)..."
|
|
systemctl stop "$unit"
|
|
fi
|
|
|
|
if [[ $backup -eq 1 ]]; then
|
|
installed_name=${installed%.zip}
|
|
if ! backup_file=$(make_backup "$name" "before-${installed_name:-upgrade}"); then
|
|
if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; echo "$unit started again"; fi
|
|
die "backing up '$name' failed, so its version wasn't changed"
|
|
fi
|
|
echo "backup of worlds and configs: $backup_file"
|
|
fi
|
|
|
|
old_pin=$ZIP
|
|
set_zip_pin "$name" "$zip"
|
|
# Unpack now, as the service would, so any problem shows up here. Until the
|
|
# new server/ is complete, the old one is left in place.
|
|
if ! runuser -u "$SVC_USER" -- env HOME="$dir" "$SELF" prepare "$name"; then
|
|
set_zip_pin "$name" "$old_pin"
|
|
if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; echo "$unit started again"; fi
|
|
die "unpacking $zip failed (see above); '$name' still runs ${installed:-its old version}"
|
|
fi
|
|
|
|
echo "$verb '$name' from ${installed:-nothing} to $zip"
|
|
if [[ $was_running -eq 1 ]]; then
|
|
systemctl start "$unit"
|
|
echo "$unit started again"
|
|
else
|
|
echo "Start it with: $(hint group start "$name")"
|
|
fi
|
|
echo "Players need the version in $zip to join."
|
|
if [[ $TYPE == tmodloader ]]; then
|
|
echo "Check that its mods still load: $(hint group watch "$name")"
|
|
fi
|
|
if [[ -n $backup_file && -n $installed ]]; then
|
|
echo "If its worlds need to go back to $installed, see 'Undoing an upgrade' in the guide."
|
|
fi
|
|
}
|
|
|
|
# Gives every file of an instance back to the service user, with the modes the
|
|
# service creates files with (UMask=0027): the terraria group can read but not
|
|
# change them, others get nothing. For files copied in as root, or with odd
|
|
# modes (e.g. 'cp -a' or 'scp -p'). Symlinks are left alone, never followed.
|
|
cmd_fix_perms() {
|
|
local name=$1 dir; shift
|
|
[[ $# -eq 0 ]] || die "usage: terractl fix-perms <name>"
|
|
[[ $EUID -eq 0 ]] || die "fix-perms must be run as root: sudo terractl fix-perms $name"
|
|
dir=$(inst_dir "$name")
|
|
[[ -f $dir/instance.conf && ! -L $dir ]] || die "no instance '$name'"
|
|
chown -R -h "$SVC_USER:$SVC_USER" "$dir"
|
|
# X: directories, and files that were already executable for someone
|
|
chmod -R u+rwX,u-s,g=rX,o= "$dir"
|
|
# The server binaries must stay executable, whatever happened to them
|
|
if [[ -d $dir/server ]]; then
|
|
find "$dir/server" -maxdepth 1 -type f \
|
|
\( -name TerrariaServer.bin.x86_64 -o -name TerrariaServer -o -name '*.sh' \) \
|
|
-exec chmod u+x,g+x {} +
|
|
fi
|
|
echo "'$name': all files owned by $SVC_USER:$SVC_USER, readable by the '$SVC_USER' group, private to everyone else"
|
|
}
|
|
|
|
# ------------------------------------------------------------- backups ----
|
|
# Backups go in $BACKUP_DIR/<name>/, readable by the terraria group, like the
|
|
# instances themselves. Each is an export without server/, so 'import' restores
|
|
# it. Scheduled ones are named <name>-<date>-<time>.tar.gz, and only those are
|
|
# pruned; the ones 'upgrade' makes have a label after the time and are kept.
|
|
#
|
|
# A schedule is a systemd timer per instance (terraria-backup@<name>.timer). Its
|
|
# settings are in two drop-ins that 'backup enable' writes: when (OnCalendar=)
|
|
# and how many to keep (TERRACTL_BACKUP_KEEP).
|
|
SYSTEMD_ETC=/etc/systemd/system
|
|
|
|
backup_timer_conf() { echo "$SYSTEMD_ETC/terraria-backup@$1.timer.d/terractl.conf"; }
|
|
backup_service_conf() { echo "$SYSTEMD_ETC/terraria-backup@$1.service.d/terractl.conf"; }
|
|
|
|
# The schedule of <name> as "<when><tab><keep>", or nothing if it has none
|
|
backup_schedule() {
|
|
local conf every keep
|
|
conf=$(backup_timer_conf "$1")
|
|
[[ -r $conf ]] || return 0
|
|
every=$(sed -n 's/^OnCalendar=\(..*\)$/\1/p' "$conf" | tail -n 1)
|
|
keep=$(sed -n 's/^Environment=TERRACTL_BACKUP_KEEP=\([0-9]*\)$/\1/p' "$(backup_service_conf "$1")" 2>/dev/null | tail -n 1)
|
|
if [[ -n $every ]]; then printf '%s\t%s\n' "$every" "${keep:-0}"; fi
|
|
}
|
|
|
|
# Backs up <name> into $BACKUP_DIR/<name>/ and prints the file's path. An
|
|
# optional label goes after the time. Fails (rather than exiting) if it can't.
|
|
make_backup() {
|
|
local name=$1 label=${2:-} folder file d
|
|
folder="$BACKUP_DIR/$name"
|
|
for d in "$BACKUP_DIR" "$folder"; do
|
|
if [[ ! -d $d ]]; then install -d -m 2750 -g "$SVC_USER" "$d" || return 1; fi
|
|
done
|
|
file="$folder/$name-$(date +%Y%m%d-%H%M%S)"
|
|
if [[ -n $label ]]; then file+="-$label"; fi
|
|
while [[ -e $file.tar.gz ]]; do file+=_; done
|
|
file+=.tar.gz
|
|
# In a subshell, so a failure comes back here instead of exiting
|
|
( trap cleanup EXIT; cmd_export "$name" "$file" ) >/dev/null || return 1
|
|
chgrp "$SVC_USER" "$file" && chmod 640 "$file"
|
|
echo "$file"
|
|
}
|
|
|
|
# Deletes all but the newest <keep> scheduled backups of <name> (0: keeps all)
|
|
prune_backups() {
|
|
local name=$1 keep=$2 re i n
|
|
local -a files=()
|
|
[[ $keep -gt 0 && -d $BACKUP_DIR/$name ]] || return 0
|
|
re="${name//./\\.}-[0-9]{8}-[0-9]{6}_*\.tar\.gz"
|
|
mapfile -t files < <(find "$BACKUP_DIR/$name" -maxdepth 1 -type f -printf '%f\n' | grep -xE "$re" | sort)
|
|
n=$(( ${#files[@]} - keep ))
|
|
for ((i = 0; i < n; i++)); do
|
|
rm -f -- "$BACKUP_DIR/$name/${files[i]}"
|
|
echo "removed old backup ${files[i]}"
|
|
done
|
|
}
|
|
|
|
backup_instance() {
|
|
valid_name "${1:-}"
|
|
[[ -f $(inst_dir "$1")/instance.conf ]] || die "no instance '$1'"
|
|
}
|
|
|
|
backup_enable() {
|
|
local name=${1:-} every=daily keep=14 next unit
|
|
if [[ $# -gt 0 ]]; then shift; fi
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--every) [[ -n ${2:-} ]] || die "--every needs a schedule, e.g. daily"; every=$2; shift ;;
|
|
--keep) [[ ${2:-} =~ ^[0-9]+$ ]] || die "--keep needs a number (0 keeps them all)"; keep=$2; shift ;;
|
|
*) die "unknown option '$1'" ;;
|
|
esac
|
|
shift
|
|
done
|
|
[[ -n $name ]] || die "usage: terractl backup enable <name> [--every <when>] [--keep <number>]"
|
|
backup_instance "$name"
|
|
[[ $EUID -eq 0 ]] || die "'backup enable' needs root: sudo terractl backup enable $name --every '$every' --keep $keep"
|
|
# Goes into a unit file, where % and line breaks mean something
|
|
[[ $every =~ ^[A-Za-z0-9\ :*,./~+-]+$ ]] || die "'$every' isn't a schedule (e.g. daily, weekly, hourly, 04:30, 'Sun 03:00')"
|
|
if command -v systemd-analyze >/dev/null && ! systemd-analyze calendar "$every" >/dev/null 2>&1; then
|
|
die "'$every' isn't a schedule systemd understands; e.g. daily, weekly, hourly, 04:30 or 'Sun 03:00' (see: man systemd.time)"
|
|
fi
|
|
unit="terraria-backup@$name"
|
|
mkdir -p "$(dirname "$(backup_timer_conf "$name")")" "$(dirname "$(backup_service_conf "$name")")"
|
|
printf '# Written by terractl. Change it with: terractl backup enable %s --every <when>\n[Timer]\nOnCalendar=\nOnCalendar=%s\n' \
|
|
"$name" "$every" > "$(backup_timer_conf "$name")"
|
|
printf '# Written by terractl. Change it with: terractl backup enable %s --keep <number>\n[Service]\nEnvironment=TERRACTL_BACKUP_KEEP=%s\n' \
|
|
"$name" "$keep" > "$(backup_service_conf "$name")"
|
|
systemctl daemon-reload
|
|
systemctl enable -q "$unit.timer"
|
|
systemctl restart "$unit.timer" # so a changed schedule applies now
|
|
if [[ $keep -eq 0 ]]; then keep="all of them"; else keep="the newest $keep"; fi
|
|
echo "backing up '$name' $every, keeping $keep, in $BACKUP_DIR/$name"
|
|
next=$(systemctl show -p NextElapseUSecRealtime --value "$unit.timer" 2>/dev/null || true)
|
|
if [[ -n $next && $next != n/a ]]; then echo "next backup: $next"; fi
|
|
echo "To make one now: $(hint root backup now "$name")"
|
|
}
|
|
|
|
backup_disable() {
|
|
local name=${1:-} f
|
|
[[ $# -eq 1 ]] || die "usage: terractl backup disable <name>"
|
|
valid_name "$name"
|
|
[[ $EUID -eq 0 ]] || die "'backup disable' needs root: sudo terractl backup disable $name"
|
|
systemctl disable --now -q "terraria-backup@$name.timer" 2>/dev/null || true
|
|
for f in "$(backup_timer_conf "$name")" "$(backup_service_conf "$name")"; do
|
|
rm -f "$f"
|
|
rmdir "$(dirname "$f")" 2>/dev/null || true # unless someone added more there
|
|
done
|
|
systemctl daemon-reload
|
|
echo "no more scheduled backups of '$name'"
|
|
if [[ -d $BACKUP_DIR/$name ]]; then echo "its backups are kept in $BACKUP_DIR/$name"; fi
|
|
}
|
|
|
|
# Also what terraria-backup@.service runs
|
|
backup_now() {
|
|
local name=${1:-} file every keep=0
|
|
[[ $# -eq 1 ]] || die "usage: terractl backup now <name>"
|
|
backup_instance "$name"
|
|
[[ $EUID -eq 0 ]] || die "'backup now' needs root: sudo terractl backup now $name"
|
|
file=$(make_backup "$name") || die "backing up '$name' failed"
|
|
echo "backup: $file ($(du -h "$file" | cut -f1))"
|
|
IFS=$'\t' read -r every keep < <(backup_schedule "$name") || true
|
|
prune_backups "$name" "${keep:-0}"
|
|
}
|
|
|
|
backup_list() {
|
|
local only=${1:-} n sched every keep last result next f size when
|
|
local -a names=()
|
|
[[ $# -le 1 ]] || die "usage: terractl backup list [<name>]"
|
|
if [[ -n $only ]]; then
|
|
valid_name "$only"; names=("$only")
|
|
else
|
|
# Instances, and backups of instances that no longer exist
|
|
for f in "$BASE_DIR"/*/instance.conf "$BACKUP_DIR"/*/; do
|
|
if [[ $f == */instance.conf && -f $f ]]; then names+=("$(basename "$(dirname "$f")")")
|
|
elif [[ $f == */ && -d $f ]]; then names+=("$(basename "$f")"); fi
|
|
done
|
|
mapfile -t names < <(printf '%s\n' "${names[@]}" | sort -u | grep -v '^$' || true)
|
|
fi
|
|
if [[ -d $BACKUP_DIR && ! ( -r $BACKUP_DIR && -x $BACKUP_DIR ) ]]; then
|
|
die "can't read $BACKUP_DIR: needs root or the '$SVC_USER' group"
|
|
fi
|
|
if [[ ${#names[@]} -eq 0 ]]; then echo "No instances or backups yet"; return 0; fi
|
|
for n in "${names[@]}"; do
|
|
sched=$(backup_schedule "$n")
|
|
if [[ -n $sched ]]; then
|
|
IFS=$'\t' read -r every keep <<< "$sched"
|
|
if [[ $keep -eq 0 ]]; then keep="all"; else keep="newest $keep"; fi
|
|
echo "$n: backed up $every, keeping $keep"
|
|
last=$(systemctl show -p LastTriggerUSec --value "terraria-backup@$n.timer" 2>/dev/null || true)
|
|
next=$(systemctl show -p NextElapseUSecRealtime --value "terraria-backup@$n.timer" 2>/dev/null || true)
|
|
result=$(systemctl show -p Result --value "terraria-backup@$n.service" 2>/dev/null || true)
|
|
if [[ -z $last || $last == n/a ]]; then last="not yet"; fi
|
|
if [[ -n $result && $result != success ]]; then last+=" (FAILED; see: journalctl -u terraria-backup@$n)"; fi
|
|
echo " last: $last; next: ${next:-?}"
|
|
else
|
|
echo "$n: no scheduled backups"
|
|
fi
|
|
local -a rows=()
|
|
if [[ -d $BACKUP_DIR/$n ]]; then
|
|
mapfile -t rows < <(find "$BACKUP_DIR/$n" -maxdepth 1 -type f -name '*.tar.gz' \
|
|
-printf '%f\t%s\t%TY-%Tm-%Td %TH:%TM\n' 2>/dev/null | sort)
|
|
fi
|
|
if [[ ${#rows[@]} -eq 0 ]]; then echo " no backups"; continue; fi
|
|
for f in "${rows[@]}"; do
|
|
IFS=$'\t' read -r f size when <<< "$f"
|
|
printf ' %-58s %6s %s\n' "$f" "$(numfmt --to=iec "$size")" "$when"
|
|
done
|
|
done
|
|
echo "Backups are in $BACKUP_DIR/<name>/. Restore one with: $(hint root import "<name>" "<file>")"
|
|
}
|
|
|
|
cmd_backup() {
|
|
local action=${1:-list}
|
|
if [[ $# -gt 0 ]]; then shift; fi
|
|
case $action in
|
|
enable) backup_enable "$@" ;;
|
|
disable) backup_disable "$@" ;;
|
|
now) backup_now "$@" ;;
|
|
list) backup_list "$@" ;;
|
|
*) die "unknown backup action '$action' (enable, disable, now, list)" ;;
|
|
esac
|
|
}
|
|
|
|
# --------------------------------------------------------------- doctor ----
|
|
DOC_FIX=0 DOC_FAILS=0 DOC_WARNS=0 DOC_FIXABLE=0 DOC_FIXED=0 DOC_SUDO=""
|
|
|
|
doc_line() { # doc_line ok|warn|FAIL|fixed <text>
|
|
local tag=$1 colour="" reset=""; shift
|
|
if [[ -t 1 && ${TERM:-dumb} != dumb ]]; then
|
|
case $tag in
|
|
ok|fixed) colour=$'\e[32m' ;;
|
|
warn) colour=$'\e[33m' ;;
|
|
FAIL) colour=$'\e[31m' ;;
|
|
esac
|
|
reset=$'\e[0m'
|
|
fi
|
|
printf ' %s%-5s%s %s\n' "$colour" "$tag" "$reset" "$*"
|
|
}
|
|
|
|
doc_ok() { doc_line ok "$*"; }
|
|
|
|
# doc_problem warn|FAIL <what's wrong> <how to fix it> [<command that fixes it>...]
|
|
# With --fix, runs the command (if there is one) instead of only reporting it.
|
|
doc_problem() {
|
|
local level=$1 msg=$2 fix=$3 err; shift 3
|
|
if [[ $# -gt 0 && $DOC_FIX -eq 1 ]]; then
|
|
if err=$( ( "$@" ) 2>&1 >/dev/null ); then
|
|
doc_line fixed "$msg"
|
|
DOC_FIXED=$((DOC_FIXED + 1))
|
|
return 0
|
|
fi
|
|
doc_line "$level" "$msg"
|
|
printf ' fixing it failed: %s\n' "$(tail -n 1 <<< "$err")"
|
|
else
|
|
doc_line "$level" "$msg"
|
|
fi
|
|
if [[ -n $fix ]]; then printf ' fix: %s\n' "${fix//$'\n'/$'\n' }"; fi
|
|
if [[ $level == FAIL ]]; then DOC_FAILS=$((DOC_FAILS + 1)); else DOC_WARNS=$((DOC_WARNS + 1)); fi
|
|
if [[ $# -gt 0 ]]; then DOC_FIXABLE=$((DOC_FIXABLE + 1)); fi
|
|
}
|
|
|
|
doc_problems_so_far() { echo $((DOC_FAILS + DOC_WARNS + DOC_FIXED)); }
|
|
|
|
# Can the service user do <test args> on a path? Only checkable as root.
|
|
svc_test() { runuser -u "$SVC_USER" -- test "$@" 2>/dev/null; }
|
|
|
|
# Fixes for --fix
|
|
doc_fix_location() { # <unit to edit> <unit to check> <what breaks> <dist access>; uses LOC_*
|
|
local d="$SYSTEMD_ETC/$1.d"
|
|
mkdir -p "$d"
|
|
{
|
|
echo "# Written by 'terractl doctor --fix', for the locations in /etc/default/terractl"
|
|
location_dropin
|
|
} > "$d/terractl-locations.conf"
|
|
systemctl daemon-reload
|
|
location_problems "$2" "$1" "$3" "$4"
|
|
[[ ${#LOC_PROBLEMS[@]} -eq 0 ]] || die "wrote $d/terractl-locations.conf, but systemd still doesn't allow it"
|
|
}
|
|
doc_fix_journal() {
|
|
local d
|
|
cmd_journal_access
|
|
while IFS= read -r d; do
|
|
if ! svc_test -r "$d" -a -x "$d" || { [[ -e $d/system.journal ]] && ! svc_test -r "$d/system.journal"; }; then
|
|
die "ran journal-access, but $d still isn't readable by the group"
|
|
fi
|
|
done < <(journal_dirs)
|
|
}
|
|
doc_fix_base() { install -d -o "$SVC_USER" -g "$SVC_USER" -m 0750 "$BASE_DIR"; chown "$SVC_USER:$SVC_USER" "$BASE_DIR"; }
|
|
doc_fix_dist() { install -d -o root -g "$SVC_USER" -m 2775 "$DIST_DIR"; chgrp "$SVC_USER" "$DIST_DIR"; chmod 2775 "$DIST_DIR"; }
|
|
doc_fix_backup_dir() { chgrp -R "$SVC_USER" "$BACKUP_DIR"; chmod 2750 "$BACKUP_DIR"; find "$BACKUP_DIR" -mindepth 1 -type d -exec chmod 2750 {} +; }
|
|
doc_fix_paths() {
|
|
local sc; sc="$(inst_dir "$1")/serverconfig.txt"
|
|
sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$(inst_dir "$1")/data/#" "$sc"
|
|
fix_owner "$sc"
|
|
}
|
|
|
|
doctor_units() {
|
|
local spec unit label key value load frag dropins before
|
|
before=$(doc_problems_so_far)
|
|
for spec in terraria@terractl-check.service:terraria@.service terraria@terractl-check.socket:terraria@.socket \
|
|
terraria-backup@terractl-check.service:terraria-backup@.service \
|
|
terraria-backup@terractl-check.timer:terraria-backup@.timer terractld.service:terractld.service; do
|
|
unit=${spec%%:*} label=${spec#*:} load="" frag="" dropins=""
|
|
while IFS='=' read -r key value; do
|
|
case $key in
|
|
LoadState) load=$value ;;
|
|
FragmentPath) frag=$value ;;
|
|
DropInPaths) dropins=$value ;;
|
|
esac
|
|
done < <(systemctl show -p LoadState,FragmentPath,DropInPaths "$unit" 2>/dev/null)
|
|
if [[ $load != loaded ]]; then
|
|
doc_problem FAIL "$label isn't installed (or systemd can't load it)" "reinstall terraria-server-manager"
|
|
elif [[ $frag == /etc/* ]]; then
|
|
doc_problem FAIL "$frag replaces the package's $label (left over from a manual setup?)" \
|
|
"${DOC_SUDO}rm $frag && ${DOC_SUDO}systemctl daemon-reload"
|
|
elif [[ -n $dropins ]]; then
|
|
doc_ok "$label has drop-ins: $dropins"
|
|
fi
|
|
done
|
|
if [[ $(doc_problems_so_far) -eq $before ]]; then doc_ok "systemd units installed"; fi
|
|
}
|
|
|
|
doctor_locations() {
|
|
local before
|
|
before=$(doc_problems_so_far)
|
|
doctor_location_result terraria@.service terraria@terractl-check.service "servers can't start" ro
|
|
if systemctl is-enabled --quiet terractld.service 2>/dev/null; then
|
|
doctor_location_result terractld.service terractld.service "other hosts can't copy, move or import instances here" rw
|
|
fi
|
|
if [[ $(doc_problems_so_far) -eq $before ]]; then doc_ok "the services can use $BASE_DIR and $DIST_DIR"; fi
|
|
}
|
|
|
|
# doctor_location_result <unit to edit> <unit to check> <what breaks> <dist access>
|
|
doctor_location_result() {
|
|
local edit=$1 msg fix
|
|
location_problems "$2" "$1" "$3" "$4"
|
|
[[ ${#LOC_PROBLEMS[@]} -gt 0 ]] || return 0
|
|
msg=$(join_by "; " "${LOC_PROBLEMS[@]}")
|
|
fix="${DOC_SUDO}systemctl edit $edit, and add:"$'\n'"$(location_dropin | sed 's/^/ /')"
|
|
if [[ $LOC_HOME -eq 1 ]]; then
|
|
# Opens up home folders, so it's left to a person to decide
|
|
doc_problem FAIL "$msg" "$fix"$'\n'"see \"Instances or zips under /home\" in the guide"
|
|
else
|
|
doc_problem FAIL "$msg" "$fix" doc_fix_location "$edit" "$2" "$3" "$4"
|
|
fi
|
|
}
|
|
|
|
doctor_system() {
|
|
local ldconfig tml=0 before d mode group f
|
|
local -a bad=() tml_confs=()
|
|
|
|
if getent passwd "$SVC_USER" >/dev/null && getent group "$SVC_USER" >/dev/null; then
|
|
doc_ok "user and group '$SVC_USER'"
|
|
else
|
|
doc_problem FAIL "the '$SVC_USER' user or group is missing" "${DOC_SUDO}systemd-sysusers terractl.conf" \
|
|
systemd-sysusers terractl.conf
|
|
fi
|
|
|
|
# Programs
|
|
before=$(doc_problems_so_far)
|
|
command -v unzip >/dev/null || doc_problem FAIL "unzip isn't installed, so servers can't be unpacked" "$(pkg_install "$DOC_SUDO" unzip)"
|
|
mapfile -t tml_confs < <(grep -l '^TYPE=tmodloader' "$BASE_DIR"/*/instance.conf 2>/dev/null || true)
|
|
tml=${#tml_confs[@]}
|
|
if [[ $tml -gt 0 ]]; then
|
|
ldconfig=$(command -v ldconfig || echo /sbin/ldconfig)
|
|
if ! "$ldconfig" -p 2>/dev/null | grep -q 'libicuuc\.so'; then
|
|
doc_problem FAIL "ICU isn't installed; tModLoader servers stop with 'Aborted' without it" "$(pkg_install "$DOC_SUDO" icu)"
|
|
fi
|
|
command -v curl >/dev/null ||
|
|
doc_problem FAIL "curl isn't installed; tModLoader servers need it to download .NET on their first start" "$(pkg_install "$DOC_SUDO" curl)"
|
|
fi
|
|
if [[ $(doc_problems_so_far) -eq $before ]]; then
|
|
doc_ok "programs$(if [[ $tml -gt 0 ]]; then echo " (including ICU and curl for tModLoader)"; fi)"
|
|
fi
|
|
|
|
# Folders
|
|
before=$(doc_problems_so_far)
|
|
if [[ ! -d $BASE_DIR ]]; then
|
|
doc_problem FAIL "$BASE_DIR (instances) doesn't exist" \
|
|
"${DOC_SUDO}install -d -o $SVC_USER -g $SVC_USER -m 0750 $BASE_DIR" doc_fix_base
|
|
else
|
|
read -r d group mode < <(stat -c '%U %G %a' "$BASE_DIR")
|
|
if [[ $d != "$SVC_USER" ]]; then
|
|
doc_problem warn "$BASE_DIR is owned by $d, not $SVC_USER; starting a server with a new name can't create its folder" \
|
|
"${DOC_SUDO}chown $SVC_USER:$SVC_USER $BASE_DIR" doc_fix_base
|
|
fi
|
|
if [[ $EUID -eq 0 ]] && ! svc_test -r "$BASE_DIR" -a -x "$BASE_DIR"; then
|
|
doc_problem FAIL "the '$SVC_USER' user can't get into $BASE_DIR (a folder above it is private?)" \
|
|
"see \"Instances or zips under /home\" in the guide"
|
|
fi
|
|
fi
|
|
if [[ ! -d $DIST_DIR ]]; then
|
|
doc_problem FAIL "$DIST_DIR (server zips) doesn't exist" \
|
|
"${DOC_SUDO}install -d -o root -g $SVC_USER -m 2775 $DIST_DIR" doc_fix_dist
|
|
else
|
|
read -r d group mode < <(stat -c '%U %G %a' "$DIST_DIR")
|
|
if [[ $EUID -eq 0 ]] && ! svc_test -r "$DIST_DIR" -a -x "$DIST_DIR"; then
|
|
doc_problem FAIL "the '$SVC_USER' user can't get into $DIST_DIR (a folder above it is private?)" \
|
|
"see \"Instances or zips under /home\" in the guide"
|
|
fi
|
|
if [[ $group != "$SVC_USER" ]] || (( (8#$mode & 8#070) != 8#070 )); then
|
|
doc_problem warn "members of the '$SVC_USER' group can't add zips to $DIST_DIR" \
|
|
"${DOC_SUDO}chgrp $SVC_USER $DIST_DIR && ${DOC_SUDO}chmod 2775 $DIST_DIR" doc_fix_dist
|
|
fi
|
|
fi
|
|
if [[ -d $BACKUP_DIR ]]; then
|
|
read -r d group mode < <(stat -c '%U %G %a' "$BACKUP_DIR")
|
|
if [[ $group != "$SVC_USER" ]] || (( (8#$mode & 8#050) != 8#050 )); then
|
|
doc_problem warn "members of the '$SVC_USER' group can't read the backups in $BACKUP_DIR" \
|
|
"${DOC_SUDO}chgrp -R $SVC_USER $BACKUP_DIR && ${DOC_SUDO}chmod 2750 $BACKUP_DIR" doc_fix_backup_dir
|
|
fi
|
|
fi
|
|
if [[ $(doc_problems_so_far) -eq $before ]]; then doc_ok "folders $BASE_DIR and $DIST_DIR"; fi
|
|
|
|
# Zips
|
|
mapfile -t bad < <(unreadable_zips)
|
|
if [[ ${#bad[@]} -gt 0 ]]; then
|
|
doc_problem FAIL "the '$SVC_USER' user can't read: $(printf '%s ' "${bad[@]##*/}")" \
|
|
"${DOC_SUDO}chmod 644 ${bad[*]@Q}" chmod 644 "${bad[@]}"
|
|
elif ! compgen -G "$DIST_DIR/*.zip" >/dev/null; then
|
|
doc_problem warn "no server zips in $DIST_DIR" "$(hint group fetch vanilla) (or tmodloader)"
|
|
else
|
|
doc_ok "server zips: $(find "$DIST_DIR" -maxdepth 1 -type f -name '*.zip' | wc -l) in $DIST_DIR"
|
|
fi
|
|
|
|
if ! command -v systemctl >/dev/null || [[ ! -d /run/systemd/system ]]; then
|
|
doc_problem FAIL "systemd isn't running here, so the servers can't run as services" ""
|
|
return 0
|
|
fi
|
|
doctor_units
|
|
doctor_locations
|
|
|
|
# The servers' journal
|
|
if [[ -d /var/log/journal || -d /run/log/journal ]]; then
|
|
local -a jdirs=()
|
|
mapfile -t jdirs < <(journal_dirs)
|
|
before=$(doc_problems_so_far)
|
|
if [[ ${#jdirs[@]} -eq 0 ]]; then
|
|
doc_problem warn "the servers' journal isn't set up yet, so the '$SVC_USER' group can't see server output" \
|
|
"${DOC_SUDO}terractl journal-access (also happens when a server starts)" doc_fix_journal
|
|
else
|
|
for d in "${jdirs[@]}"; do
|
|
f=$d/system.journal
|
|
if [[ $EUID -eq 0 ]]; then
|
|
if ! svc_test -r "$d" -a -x "$d" || { [[ -e $f ]] && ! svc_test -r "$f"; }; then
|
|
doc_problem FAIL "the '$SVC_USER' group can't read the servers' journal in $d" \
|
|
"${DOC_SUDO}terractl journal-access" doc_fix_journal
|
|
break
|
|
fi
|
|
elif in_group && ! can_read_journal; then
|
|
doc_problem FAIL "you can't read the servers' journal in $d" "${DOC_SUDO}terractl journal-access" ; break
|
|
fi
|
|
done
|
|
if [[ $(doc_problems_so_far) -eq $before ]]; then doc_ok "the '$SVC_USER' group can read the servers' journal"; fi
|
|
fi
|
|
fi
|
|
|
|
if [[ $(systemctl show -p LoadState --value polkit.service 2>/dev/null) != loaded ]]; then
|
|
doc_problem warn "polkit isn't installed, so members of the '$SVC_USER' group need sudo to start and stop servers" \
|
|
"$(pkg_install "$DOC_SUDO" polkit)"
|
|
fi
|
|
|
|
if systemctl is-enabled --quiet terractld.service 2>/dev/null; then
|
|
if systemctl is-active --quiet terractld.service; then
|
|
doc_ok "the daemon for other hosts (terractld) is running"
|
|
d=${TERRACTL_LISTEN:-7776}; d=${d##*:}
|
|
if [[ $EUID -eq 0 ]] && firewalld_running && ! firewalld_allows "$d"; then
|
|
doc_problem warn "firewalld doesn't allow port $d/tcp, so other hosts can't reach the daemon" \
|
|
"$(firewalld_open_cmd "$DOC_SUDO" "$d") (or allow it only from the hosts that manage this one)"
|
|
fi
|
|
else
|
|
doc_problem FAIL "the daemon for other hosts (terractld) is enabled but not running" \
|
|
"${DOC_SUDO}systemctl restart terractld; if it stops again: journalctl -u terractld" \
|
|
systemctl restart terractld.service
|
|
fi
|
|
fi
|
|
|
|
# Leftovers of instances that are gone
|
|
for f in "$SYSTEMD_ETC"/*.wants/terraria@*.service; do
|
|
[[ -L $f ]] || continue
|
|
d=${f##*/terraria@}; d=${d%.service}
|
|
if [[ ! -f $(inst_dir "$d")/instance.conf ]]; then
|
|
doc_problem warn "terraria@$d starts at boot, but there's no instance '$d'; at boot, an empty one would be created" \
|
|
"$(hint root disable "$d")" systemctl disable -q "terraria@$d.service"
|
|
fi
|
|
done
|
|
for f in "$SYSTEMD_ETC"/terraria-backup@*.timer.d; do
|
|
[[ -d $f ]] || continue
|
|
d=${f##*/terraria-backup@}; d=${d%.timer.d}
|
|
if [[ ! -f $(inst_dir "$d")/instance.conf ]]; then
|
|
doc_problem warn "scheduled backups are set up for '$d', which no longer exists" \
|
|
"$(hint root backup disable "$d")" backup_disable "$d"
|
|
fi
|
|
done
|
|
}
|
|
|
|
inst_port() {
|
|
local p
|
|
p=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$(inst_dir "$1")/serverconfig.txt" 2>/dev/null | tail -n 1) || true
|
|
echo "${p:-7777}" # Terraria's default
|
|
}
|
|
|
|
doctor_instance() {
|
|
local n=$1 dir err before installed pinned zf key val wrong open pattern sc result
|
|
local -a missing=()
|
|
dir=$(inst_dir "$n")
|
|
before=$(doc_problems_so_far)
|
|
if ! err=$( (load_conf "$n") 2>&1 ); then
|
|
doc_problem FAIL "$n: ${err#terractl: }" "edit $dir/instance.conf"
|
|
return 0
|
|
fi
|
|
load_conf "$n"
|
|
installed=$(installed_zip "$n")
|
|
pattern=$(zip_pattern)
|
|
|
|
# Files the server can't change, or that others can read
|
|
wrong=$(find "$dir" ! -type l \( ! -user "$SVC_USER" -o ! -group "$SVC_USER" \) -printf . 2>/dev/null | wc -c)
|
|
open=$(find "$dir/data" "$dir/instance.conf" "$dir/serverconfig.txt" ! -type l \
|
|
\( -perm /o=rwx -o -perm /g=w \) -printf . 2>/dev/null | wc -c)
|
|
if [[ $wrong -gt 0 ]]; then
|
|
doc_problem FAIL "$n: $wrong files aren't owned by $SVC_USER, so the server can't change them (saving can fail)" \
|
|
"$(hint root fix-perms "$n")" cmd_fix_perms "$n"
|
|
elif [[ $open -gt 0 ]]; then
|
|
doc_problem warn "$n: $open of its files can be read by other users, or changed by the '$SVC_USER' group" \
|
|
"$(hint root fix-perms "$n")" cmd_fix_perms "$n"
|
|
fi
|
|
|
|
# Its server version
|
|
if [[ -n $ZIP ]]; then
|
|
pinned=${ZIP##*/}
|
|
if [[ $ZIP == /* ]]; then zf=$ZIP; else zf=$DIST_DIR/$ZIP; fi
|
|
# shellcheck disable=SC2053 # the pattern is a glob on purpose
|
|
if [[ $pinned != $pattern ]]; then
|
|
doc_problem FAIL "$n: ZIP=$pinned isn't a $TYPE zip" "$(hint root upgrade "$n") to pick one"
|
|
elif [[ ! -f $zf ]]; then
|
|
if [[ $pinned == "$installed" && ! -e $dir/server/.reinstall ]]; then
|
|
doc_problem warn "$n: runs $pinned, which is no longer in $DIST_DIR (fine until it needs unpacking again)" \
|
|
"$(hint group fetch "$TYPE" "$(zip_version "$pinned")")"
|
|
else
|
|
doc_problem FAIL "$n: ZIP=$pinned isn't in $DIST_DIR, so it can't start" \
|
|
"$(hint group fetch "$TYPE" "$(zip_version "$pinned")")"
|
|
fi
|
|
elif [[ -n $installed && $pinned != "$installed" ]]; then
|
|
doc_problem warn "$n: runs $installed, but ZIP= in instance.conf says $pinned; its next start switches without a backup" \
|
|
"$(hint root upgrade "$n" "$pinned") (checks the zip and backs up first)"
|
|
fi
|
|
elif [[ -z $installed ]] && ! compgen -G "$DIST_DIR/$pattern" >/dev/null; then
|
|
doc_problem FAIL "$n: there's no $pattern in $DIST_DIR, so it can't start" "$(hint group fetch "$TYPE")"
|
|
fi
|
|
|
|
# serverconfig.txt
|
|
sc=$dir/serverconfig.txt
|
|
if [[ ! -f $sc ]]; then
|
|
doc_problem warn "$n: has no serverconfig.txt; a default one is made when it starts" ""
|
|
else
|
|
for key in world worldpath banlist; do
|
|
val=$(sed -n "s/^$key=//p" "$sc" | tail -n 1)
|
|
if [[ -n $val && $val != "$dir/data" && $val != "$dir/data/"* ]]; then
|
|
if [[ $val == */data/* ]]; then
|
|
doc_problem warn "$n: $key= in serverconfig.txt points outside its folder ($val)" \
|
|
"set it to $dir/data/${val#*/data/}" doc_fix_paths "$n"
|
|
else
|
|
doc_problem warn "$n: $key= in serverconfig.txt points outside its folder ($val)" "check $sc"
|
|
fi
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Mods
|
|
if [[ $TYPE == tmodloader && -f $dir/data/Mods/enabled.json ]]; then
|
|
while IFS= read -r key; do
|
|
if [[ -n $key && ! -f $dir/data/Mods/$key.tmod ]]; then missing+=("$key"); fi
|
|
done < <(read_enabled "$dir/data/Mods")
|
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
doc_problem warn "$n: enabled mods that aren't installed: ${missing[*]} (it fails to load mods)" \
|
|
"add them with $(hint root mods "$n" add "<files>"), or: $(hint root mods "$n" disable "${missing[@]}")"
|
|
fi
|
|
fi
|
|
|
|
if systemctl is-failed --quiet "terraria@$n.service" 2>/dev/null; then
|
|
doc_problem warn "$n: its last start failed" "see why with: $(hint group watch "$n")"
|
|
fi
|
|
if [[ -n $(backup_schedule "$n") ]]; then
|
|
result=$(systemctl show -p Result --value "terraria-backup@$n.service" 2>/dev/null || true)
|
|
if [[ -n $result && $result != success ]]; then
|
|
doc_problem warn "$n: its last scheduled backup failed" "journalctl -u terraria-backup@$n"
|
|
fi
|
|
fi
|
|
|
|
if [[ $(doc_problems_so_far) -eq $before ]]; then
|
|
doc_ok "$n ($TYPE, port $(inst_port "$n"), ${installed:-not unpacked yet})"
|
|
fi
|
|
}
|
|
|
|
doctor_instances() {
|
|
local only=$1 d n p list
|
|
local -a names=() clash=()
|
|
local -A by_port=()
|
|
if [[ ! -r $BASE_DIR || ! -x $BASE_DIR ]]; then
|
|
doc_problem warn "can't read $BASE_DIR, so instances weren't checked" "run it with sudo, or join the '$SVC_USER' group"
|
|
return 0
|
|
fi
|
|
for d in "$BASE_DIR"/*/; do
|
|
if [[ -f $d/instance.conf ]]; then names+=("$(basename "$d")"); fi
|
|
done
|
|
for n in "${names[@]}"; do
|
|
p=$(inst_port "$n")
|
|
by_port[$p]+="${by_port[$p]:+ }$n"
|
|
done
|
|
if [[ -n $only ]]; then names=("$only"); fi
|
|
if [[ ${#names[@]} -eq 0 ]]; then doc_ok "no instances yet (create one with: $(hint root new "<name>"))"; return 0; fi
|
|
for n in "${names[@]}"; do ( doctor_instance "$n"; doctor_counts ) ; doctor_merge_counts; done
|
|
for p in "${!by_port[@]}"; do
|
|
list=${by_port[$p]}
|
|
[[ $list == *" "* ]] || continue
|
|
if [[ -n $only && " $list " != *" $only "* ]]; then continue; fi
|
|
read -ra clash <<< "$list"
|
|
if [[ ${#clash[@]} -eq 2 ]]; then
|
|
list="${clash[0]} and ${clash[1]} both"
|
|
else
|
|
list=$(printf '%s, ' "${clash[@]:0:${#clash[@]}-1}")
|
|
list="${list%, } and ${clash[-1]} all"
|
|
fi
|
|
doc_problem warn "$list use port $p, so only one of them can run at a time" \
|
|
"change port= in the serverconfig.txt of all but one"
|
|
done
|
|
# Opening ports is left to a person: it's a decision about who can connect
|
|
if [[ $EUID -eq 0 ]] && firewalld_running; then
|
|
for p in "${!by_port[@]}"; do
|
|
list=${by_port[$p]}
|
|
if [[ -n $only && " $list " != *" $only "* ]]; then continue; fi
|
|
if ! firewalld_allows "$p"; then
|
|
doc_problem warn "firewalld doesn't allow port $p/tcp, so players can't connect to ${list// /, }" \
|
|
"$(firewalld_open_cmd "$DOC_SUDO" "$p")"
|
|
fi
|
|
done
|
|
fi
|
|
}
|
|
|
|
# Each instance is checked in a subshell (load_conf sets globals), so its
|
|
# counts come back through a file
|
|
DOC_COUNTS=""
|
|
doctor_counts() { echo "$DOC_FAILS $DOC_WARNS $DOC_FIXABLE $DOC_FIXED" > "$DOC_COUNTS"; }
|
|
doctor_merge_counts() { read -r DOC_FAILS DOC_WARNS DOC_FIXABLE DOC_FIXED < "$DOC_COUNTS"; }
|
|
|
|
cmd_doctor() {
|
|
local only="" arg ver
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
--fix) DOC_FIX=1 ;;
|
|
-*) die "unknown option '$arg'" ;;
|
|
*) [[ -z $only ]] || die "usage: terractl doctor [<name>] [--fix]"; valid_name "$arg"; only=$arg ;;
|
|
esac
|
|
done
|
|
if [[ $DOC_FIX -eq 1 && $EUID -ne 0 ]]; then die "'doctor --fix' needs root: sudo terractl doctor${only:+ $only} --fix"; fi
|
|
if [[ -n $only && -x $BASE_DIR && ! -f $(inst_dir "$only")/instance.conf ]]; then die "no instance '$only'"; fi
|
|
if [[ $EUID -ne 0 && -z ${TERRACTL_VIA:-} ]]; then DOC_SUDO="sudo "; fi
|
|
DOC_COUNTS=$(mktemp)
|
|
ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi
|
|
echo "terractl $ver on $(hostname 2>/dev/null || echo this host)"
|
|
if [[ $EUID -ne 0 ]]; then echo "(Not root, so some checks are skipped. For all of them: sudo terractl doctor)"; fi
|
|
echo
|
|
echo "System"
|
|
doctor_system
|
|
echo
|
|
echo "Instances"
|
|
doctor_instances "$only"
|
|
rm -f "$DOC_COUNTS"
|
|
echo
|
|
if [[ $((DOC_FAILS + DOC_WARNS)) -eq 0 ]]; then
|
|
if [[ $DOC_FIXED -gt 0 ]]; then echo "Fixed $DOC_FIXED problem(s); everything else looks fine."
|
|
else echo "Everything looks fine."; fi
|
|
else
|
|
echo "$DOC_FAILS problem(s), $DOC_WARNS warning(s)$(if [[ $DOC_FIXED -gt 0 ]]; then echo "; fixed $DOC_FIXED"; fi)."
|
|
if [[ $DOC_FIX -eq 0 && $DOC_FIXABLE -gt 0 ]]; then
|
|
echo "$(hint root doctor${only:+ "$only"} --fix) fixes $DOC_FIXABLE of them; the others need the steps shown."
|
|
fi
|
|
fi
|
|
[[ $DOC_FAILS -eq 0 ]]
|
|
}
|
|
|
|
# ---------------------------------------------------------------- fetch ----
|
|
# Downloads official server zips into dist/, named with their version:
|
|
# tModLoader from its GitHub releases, vanilla from terraria.org.
|
|
TML_API="https://api.github.com/repos/tModLoader/tModLoader"
|
|
TML_PAGE="https://github.com/tModLoader/tModLoader/releases"
|
|
VANILLA_LIST="https://terraria.org/api/get/dedicated-servers-names"
|
|
VANILLA_DOWNLOAD="https://terraria.org/api/download/pc-dedicated-server"
|
|
VANILLA_PAGE="https://terraria.org (the 'PC Dedicated Server' link at the bottom)"
|
|
|
|
fetch_get() {
|
|
curl -fsL --connect-timeout 15 --max-time 60 -A "terractl/$TERRACTL_VERSION" "$@"
|
|
}
|
|
|
|
# Dies explaining a failed download: fetch_failed <curl status> <what> <where to get it by hand>
|
|
fetch_failed() {
|
|
if [[ $1 -eq 22 ]]; then
|
|
die "$2 isn't available there (not found, or the site refused the request). Download it by hand from $3, into $DIST_DIR"
|
|
fi
|
|
die "couldn't download $2 (curl error $1; no internet access?). Download it by hand from $3, into $DIST_DIR"
|
|
}
|
|
|
|
# tModLoader releases with a tModLoader.zip, newest first, one per line:
|
|
# <version> stable|preview <url> <sha256, or ->. tml_releases latest|list|<version>
|
|
tml_releases() {
|
|
local url json rc=0
|
|
case $1 in
|
|
latest) url="$TML_API/releases/latest" ;;
|
|
list) url="$TML_API/releases?per_page=50" ;;
|
|
*) url="$TML_API/releases/tags/v$1" ;;
|
|
esac
|
|
json=$(fetch_get -H 'Accept: application/vnd.github+json' "$url") || rc=$?
|
|
[[ $rc -eq 0 ]] || return "$rc"
|
|
python3 -c '
|
|
import json, sys
|
|
data = json.load(sys.stdin)
|
|
for r in data if isinstance(data, list) else [data]:
|
|
if r.get("draft"):
|
|
continue
|
|
for a in r.get("assets", []):
|
|
if a.get("name") == "tModLoader.zip":
|
|
digest = a.get("digest") or ""
|
|
digest = digest[7:] if digest.startswith("sha256:") else "-"
|
|
print(r["tag_name"].lstrip("v"), "preview" if r.get("prerelease") else "stable",
|
|
a["browser_download_url"], digest)
|
|
' <<< "$json" | sort -rV -k1,1
|
|
}
|
|
|
|
# The newest vanilla server terraria.org offers, e.g. terraria-server-1458.zip
|
|
vanilla_latest() {
|
|
local json rc=0
|
|
json=$(fetch_get "$VANILLA_LIST") || rc=$?
|
|
[[ $rc -eq 0 ]] || return "$rc"
|
|
python3 -c 'import json, sys; print("\n".join(json.load(sys.stdin)))' <<< "$json" |
|
|
grep -xE 'terraria-server-[0-9]+\.zip' | sort -V | tail -n 1
|
|
}
|
|
|
|
fetch_list() {
|
|
local type=$1 out rc=0 version kind url sum file note latest="" shown=0
|
|
if [[ $type == tmodloader ]]; then
|
|
out=$(tml_releases list) || rc=$?
|
|
[[ $rc -eq 0 ]] || fetch_failed "$rc" "the list of tModLoader releases" "$TML_PAGE"
|
|
printf '%-16s %-32s %s\n' VERSION FILE NOTE
|
|
while read -r version kind url sum; do
|
|
file="tModLoader-v$version.zip"
|
|
note=()
|
|
if [[ $kind == preview ]]; then note+=(preview)
|
|
elif [[ -z $latest ]]; then latest=$version; note+=("latest stable"); fi
|
|
if [[ -f $DIST_DIR/$file ]]; then note+=(downloaded); fi
|
|
printf '%-16s %-32s %s\n' "$version" "$file" "$(join_by ", " "${note[@]}")"
|
|
shown=$((shown + 1))
|
|
if [[ $shown -ge 15 ]]; then break; fi
|
|
done <<< "$out"
|
|
echo "Get one with: $(hint group fetch tmodloader "<version>") (no version: the latest stable)"
|
|
else
|
|
file=$(vanilla_latest) || rc=$?
|
|
[[ $rc -eq 0 ]] || fetch_failed "$rc" "the list of Terraria server versions" "$VANILLA_PAGE"
|
|
printf '%-16s %-32s %s\n' VERSION FILE NOTE
|
|
latest=$file
|
|
if [[ -n $file ]]; then
|
|
note=(latest); if [[ -f $DIST_DIR/$file ]]; then note+=(downloaded); fi
|
|
printf '%-16s %-32s %s\n' "$(zip_version "$file")" "$file" "$(join_by ", " "${note[@]}")"
|
|
fi
|
|
for file in "$DIST_DIR"/terraria-server-*.zip; do
|
|
[[ -f $file && ${file##*/} != "$latest" ]] || continue
|
|
printf '%-16s %-32s %s\n' "$(zip_version "${file##*/}")" "${file##*/}" downloaded
|
|
done
|
|
echo "terraria.org only lists its newest version, but older ones can be fetched by number,"
|
|
echo "e.g. $(hint group fetch vanilla 1449)"
|
|
fi
|
|
}
|
|
|
|
cmd_fetch() {
|
|
local type="" version="" list=0 upgrade="" file url sum=- kind=stable out line rc=0 page progress
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--list) list=1 ;;
|
|
--upgrade) [[ -n ${2:-} ]] || die "--upgrade needs an instance name"; upgrade=$2; shift ;;
|
|
-*) die "unknown option '$1'" ;;
|
|
*)
|
|
if [[ -z $type ]]; then type=$1
|
|
elif [[ -z $version ]]; then version=$1
|
|
else die "too many arguments"; fi ;;
|
|
esac
|
|
shift
|
|
done
|
|
case ${type,,} in
|
|
vanilla|terraria) type=vanilla page=$VANILLA_PAGE ;;
|
|
tmodloader|tml) type=tmodloader page=$TML_PAGE ;;
|
|
"") die "usage: terractl fetch vanilla|tmodloader [<version>|latest] [--upgrade <name>], or: terractl fetch vanilla|tmodloader --list" ;;
|
|
*) die "unknown server type '$type' (vanilla or tmodloader)" ;;
|
|
esac
|
|
command -v curl >/dev/null || die "fetch needs curl: $(pkg_install "sudo " curl)"
|
|
command -v python3 >/dev/null || die "fetch needs python3: $(pkg_install "sudo " python3)"
|
|
if [[ $list -eq 1 ]]; then
|
|
[[ -z $version && -z $upgrade ]] || die "--list doesn't take a version or --upgrade"
|
|
fetch_list "$type"
|
|
return 0
|
|
fi
|
|
if [[ -n $upgrade ]]; then
|
|
valid_name "$upgrade"
|
|
[[ $EUID -eq 0 ]] || die "--upgrade needs root: sudo terractl fetch $type ${version:-latest} --upgrade $upgrade"
|
|
[[ -f $(inst_dir "$upgrade")/instance.conf ]] || die "no instance '$upgrade'"
|
|
out=$(load_conf "$upgrade"; echo "$TYPE")
|
|
[[ $out == "$type" ]] || die "'$upgrade' is a $out instance, not $type"
|
|
fi
|
|
[[ -d $DIST_DIR && -w $DIST_DIR ]] || die "can't write to $DIST_DIR: fetch needs root or the '$SVC_USER' group"
|
|
|
|
version=${version:-latest}
|
|
if [[ $type == tmodloader ]]; then
|
|
version=${version#v}
|
|
[[ $version == latest || $version =~ ^[0-9][0-9.]*$ ]] || die "a tModLoader version looks like 2026.07.3.0"
|
|
out=$(tml_releases "$version") || rc=$?
|
|
[[ $rc -eq 0 ]] || fetch_failed "$rc" "tModLoader $version" "$TML_PAGE (see: terractl fetch tmodloader --list)"
|
|
line=$(head -n 1 <<< "$out")
|
|
[[ -n $line ]] || die "tModLoader $version has no tModLoader.zip; see: terractl fetch tmodloader --list"
|
|
read -r version kind url sum <<< "$line"
|
|
file="tModLoader-v$version.zip"
|
|
else
|
|
if [[ $version == latest ]]; then
|
|
file=$(vanilla_latest) || rc=$?
|
|
[[ $rc -eq 0 ]] || fetch_failed "$rc" "the list of Terraria server versions" "$VANILLA_PAGE"
|
|
[[ -n $file ]] || die "terraria.org didn't list any server versions. Download it by hand from $VANILLA_PAGE"
|
|
else
|
|
version=${version//./}
|
|
[[ $version =~ ^[0-9]+$ ]] || die "a vanilla version looks like 1458 or 1.4.5.8"
|
|
file="terraria-server-$version.zip"
|
|
fi
|
|
url="$VANILLA_DOWNLOAD/$file"
|
|
fi
|
|
|
|
if [[ -f $DIST_DIR/$file ]]; then
|
|
echo "$file is already in $DIST_DIR"
|
|
else
|
|
EXTRACT_TMP=$(mktemp -d "$DIST_DIR/.fetch.XXXXXX") # removed by cleanup() on exit
|
|
log "downloading $file"
|
|
progress=(-sS); if [[ -t 2 ]]; then progress=(--progress-bar); fi
|
|
curl -fL --connect-timeout 15 --retry 2 "${progress[@]}" -A "terractl/$TERRACTL_VERSION" \
|
|
-o "$EXTRACT_TMP/$file" "$url" || fetch_failed $? "$file" "$page"
|
|
if [[ $sum != - && $(sha256sum "$EXTRACT_TMP/$file" | cut -d ' ' -f 1) != "$sum" ]]; then
|
|
die "$file doesn't match the checksum GitHub publishes for it; try again"
|
|
fi
|
|
check_server_zip "$EXTRACT_TMP/$file" "$type"
|
|
chmod 644 "$EXTRACT_TMP/$file"
|
|
mv -n "$EXTRACT_TMP/$file" "$DIST_DIR/$file"
|
|
cleanup; EXTRACT_TMP=""
|
|
echo "downloaded $file to $DIST_DIR ($(du -h "$DIST_DIR/$file" | cut -f1))"
|
|
fi
|
|
if [[ $kind == preview ]]; then
|
|
echo "Note: this is a preview release. Players need tModLoader's preview branch to join."
|
|
fi
|
|
if [[ -n $upgrade ]]; then
|
|
cmd_upgrade "$upgrade" "$file"
|
|
else
|
|
echo "Switch an instance to it with: $(hint root upgrade "<name>" "$file")"
|
|
fi
|
|
}
|
|
|
|
# key=value facts, one per line, for people and for terractl-remote (copy, move,
|
|
# tab completion). Without a name: this host. Repeated keys are lists.
|
|
cmd_info() {
|
|
local name=${1:-} dir f ver installed="" port
|
|
if [[ -z $name ]]; then
|
|
ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi
|
|
echo "version=$ver"
|
|
echo "host=$(hostname 2>/dev/null || echo unknown)"
|
|
echo "base_dir=$BASE_DIR"
|
|
echo "dist_dir=$DIST_DIR"
|
|
echo "backup_dir=$BACKUP_DIR"
|
|
echo "run_dir=$RUN_DIR"
|
|
for f in "$DIST_DIR"/*.zip; do
|
|
if [[ -f $f ]]; then echo "dist_zip=$(basename "$f")"; fi
|
|
done
|
|
while IFS= read -r f; do echo "unreadable_zip=$(basename "$f")"; done < <(unreadable_zips)
|
|
for f in "$BASE_DIR"/*/instance.conf; do
|
|
if [[ -f $f ]]; then echo "instance=$(basename "$(dirname "$f")")"; fi
|
|
done
|
|
return 0
|
|
fi
|
|
dir=$(inst_dir "$name")
|
|
echo "instance=$name"
|
|
if [[ ! -f $dir/instance.conf ]]; then echo "exists=no"; return 0; fi
|
|
load_conf "$name"
|
|
if [[ -r $dir/server/.installed ]]; then read -r _ installed < "$dir/server/.installed" || true; fi
|
|
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" 2>/dev/null | tail -n1) || true
|
|
echo "exists=yes"
|
|
echo "dir=$dir"
|
|
echo "type=$TYPE"
|
|
echo "port=$port"
|
|
echo "pinned_zip=$ZIP"
|
|
echo "installed_zip=$installed"
|
|
echo "state=$(systemctl is-active "terraria@$name" 2>/dev/null || true)"
|
|
echo "boot=$(systemctl is-enabled "terraria@$name" 2>/dev/null || true)"
|
|
echo "running=$(if instance_running "$name"; then echo yes; else echo no; fi)"
|
|
}
|
|
|
|
# Other hosts are handled by terractl-remote (Python). It runs this script for
|
|
# anything it does on this host, and passes on where it was called from.
|
|
run_remote_helper() {
|
|
local helper=$REMOTE_HELPER ver=$TERRACTL_VERSION self
|
|
self=$SELF
|
|
# A development copy uses the helper from its source tree
|
|
if [[ $helper == *@* ]]; then helper="$(dirname "$self")/../lib/terractl-remote"; fi
|
|
if [[ $ver == *@* ]]; then ver=development; fi
|
|
command -v python3 >/dev/null || die "this needs python3: $(pkg_install "sudo " python3)"
|
|
TERRACTL_BIN=$self TERRACTL_VERSION=$ver TERRACTL_CALLER_PWD=$CALLER_PWD \
|
|
TERRACTL_LISTEN=${TERRACTL_LISTEN:-} exec python3 "$helper" "$@"
|
|
}
|
|
|
|
[[ $# -ge 1 ]] || usage
|
|
case $1 in
|
|
--host|--host=*|-H|remote|daemon|copy|move) run_remote_helper "$@" ;;
|
|
esac
|
|
command=$1; shift
|
|
case $command in
|
|
-h|--help|help) usage 0 ;;
|
|
-V|--version|version)
|
|
# The build replaces the placeholder with a version number, which never contains '@'
|
|
if [[ $TERRACTL_VERSION == *@* ]]; then echo "terractl (development copy)"
|
|
else echo "terractl $TERRACTL_VERSION"; fi
|
|
exit 0 ;;
|
|
list) cmd_list; exit ;;
|
|
info) if [[ $# -eq 0 ]]; then cmd_info; exit; fi ;;
|
|
journal-access) cmd_journal_access; exit ;;
|
|
doctor) cmd_doctor "$@"; exit ;;
|
|
backup) cmd_backup "$@"; exit ;;
|
|
fetch) cmd_fetch "$@"; exit ;;
|
|
esac
|
|
[[ $# -ge 1 ]] || usage
|
|
valid_name "$1"
|
|
|
|
# prepare/run create and modify instance files. If invoked as root (e.g. while
|
|
# debugging), switch to the service user so nothing ends up owned by root.
|
|
if [[ $command == prepare || $command == run ]]; then
|
|
if [[ $EUID -eq 0 ]]; then
|
|
need_svc_can_run_self "'$command'"
|
|
exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \
|
|
"$SELF" "$command" "$@"
|
|
elif [[ $(id -un) != "$SVC_USER" ]]; then
|
|
# Being in the group isn't enough: files would end up owned by the caller
|
|
die "'$command' needs root (it switches to the '$SVC_USER' user by itself): sudo terractl $command $1"
|
|
fi
|
|
fi
|
|
|
|
case $command in
|
|
prepare) cmd_prepare "$1" ;;
|
|
run) cmd_run "$1" ;;
|
|
service-stop) cmd_service_stop "$@" ;;
|
|
stop)
|
|
# Before 0.0.11 the service stopped servers with 'terractl stop <name> <pid>';
|
|
# a unit loaded before an upgrade may still do that
|
|
if [[ -n ${INVOCATION_ID:-} && $# -eq 2 ]]; then cmd_service_stop "$@"; else cmd_svc stop "$@"; fi ;;
|
|
start|restart) cmd_svc "$command" "$@" ;;
|
|
info) cmd_info "$1" ;;
|
|
cmd) cmd_cmd "$@" ;;
|
|
attach) cmd_attach "$1" ;;
|
|
watch) cmd_watch "$@" ;;
|
|
enable|disable) cmd_enable "$command" "$@" ;;
|
|
mods) cmd_mods "$@" ;;
|
|
new) cmd_new "$@" ;;
|
|
reinstall) cmd_reinstall "$@" ;;
|
|
upgrade) cmd_upgrade "$@" ;;
|
|
fix-perms) cmd_fix_perms "$@" ;;
|
|
delete) cmd_delete "$@" ;;
|
|
rename) cmd_rename "$@" ;;
|
|
export) cmd_export "$@" ;;
|
|
import) cmd_import "$@" ;;
|
|
*) usage ;;
|
|
esac
|