1252 lines
48 KiB
Bash
Executable File
1252 lines
48 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)
|
|
#
|
|
# 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
|
|
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}"
|
|
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; }
|
|
log() { echo "terractl: $*" >&2; }
|
|
|
|
# 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)
|
|
reinstall <name> [--clean] re-unpack server on next start
|
|
(--clean also re-downloads tModLoader's .NET)
|
|
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).
|
|
|
|
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)? Nothing to do.
|
|
if [[ -f $marker ]]; 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; dir=$(inst_dir "$name")
|
|
mkdir -p "$dir/data/Worlds"
|
|
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
|
|
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
|
|
[[ $# -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
|
|
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 [[ $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)"
|
|
}
|
|
|
|
# ---------------------------------------------------------------- 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
|
|
}
|
|
|
|
# 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"))"
|
|
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
|
|
}
|
|
|
|
# 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}"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
cmd_reinstall() {
|
|
local name=$1 clean=0 dir; 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
|
|
rm -f "$dir/server/.installed"
|
|
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")"
|
|
}
|
|
|
|
# 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"
|
|
}
|
|
|
|
# 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 "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=$(readlink -f "$0")
|
|
# 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 (sudo apt install 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 ;;
|
|
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
|
|
command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead"
|
|
exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \
|
|
"$(readlink -f "$0")" "$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 "$@" ;;
|
|
fix-perms) cmd_fix_perms "$@" ;;
|
|
delete) cmd_delete "$@" ;;
|
|
rename) cmd_rename "$@" ;;
|
|
export) cmd_export "$@" ;;
|
|
import) cmd_import "$@" ;;
|
|
*) usage ;;
|
|
esac
|