Files
terraria-server-manager/bin/terractl
T
2026-09-28 16:52:30 +00:00

602 lines
20 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)
set -euo pipefail
# All paths used are absolute. Start from / so nothing depends on the caller's
# directory (which the 'terraria' user may not be allowed into, e.g. /root).
cd /
# 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
TERRACTL_VERSION="@VERSION@" # filled in by 'make install' / 'make deb'
die() { echo "terractl: $*" >&2; exit 1; }
log() { echo "terractl: $*" >&2; }
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, state
attach <name> interactive console (Ctrl-D to detach)
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)
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
(worlds/configs archived to $BASE_DIR/.deleted/)
Used by terraria@.service:
prepare <name> create dirs/configs, unpack server if needed
run <name> exec the server
stop <name> <mainpid> graceful shutdown via console "exit"
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)
[[ -r $zip ]] || die "cannot read $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"
}
cmd_prepare() {
local name=$1 dir; dir=$(inst_dir "$name")
mkdir -p "$dir/data/Worlds"
[[ -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")
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
}
cmd_stop() {
local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i
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"
}
cmd_cmd() {
local name=$1; shift
[[ $# -gt 0 ]] || die "no console command given"
local fifo="$RUN_DIR/$name.stdin"
[[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?"
printf '%s\n' "$*" > "$fifo"
echo "sent. Output: journalctl -u terraria@$name -n 20"
}
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."
# Server output comes from the journal; -o cat drops the timestamp prefixes.
journalctl -u "terraria@$name" -f -n 30 -o cat &
ATTACH_PID=$! # killed by cleanup() however attach ends
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 'systemctl stop terraria@$name' to stop it.)"
continue
fi
printf '%s\n' "$line" > "$fifo"
done
echo
}
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 [[ $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'"
}
# ---------------------------------------------------------------- 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: sudo systemctl restart terraria@$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: terractl 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
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: terractl 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 " terractl mods $name add <folder or .tmod files> (before first start)"
fi
cat <<EOF
systemctl enable --now terraria@$name
terractl attach $name
EOF
}
cmd_list() {
local d n port state
printf '%-20s %-11s %-6s %s\n' INSTANCE TYPE PORT 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)
state=$(systemctl is-active "terraria@$n" 2>/dev/null || true)
printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state"
) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
done
}
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'"
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: systemctl restart terraria@$name"
}
[[ $# -ge 1 ]] || usage
command=$1; shift
case $command in
-h|--help|help) usage 0 ;;
-V|--version|version)
if [[ $TERRACTL_VERSION == "@"VERSION"@" ]]; then echo "terractl (development copy)"
else echo "terractl $TERRACTL_VERSION"; fi
exit 0 ;;
list) cmd_list; 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
die "$command must run as root or '$SVC_USER'"
fi
fi
case $command in
prepare) cmd_prepare "$1" ;;
run) cmd_run "$1" ;;
stop) cmd_stop "$@" ;;
cmd) cmd_cmd "$@" ;;
attach) cmd_attach "$1" ;;
mods) cmd_mods "$@" ;;
new) cmd_new "$@" ;;
reinstall) cmd_reinstall "$@" ;;
delete) cmd_delete "$@" ;;
*) usage ;;
esac