Files
terraria-server-manager/bin/terractl
T
abrown38andClaude Opus 5.5 96f7876729 add upgrade command; reinstall keeps the same version
- terractl upgrade <name> lists the zips an instance can use; with a zip
  or 'latest' it checks the zip, stops the server, backs up worlds and
  configs to instances/.backups/, pins ZIP=, unpacks right away and
  starts the server again. Downgrades need --downgrade.
- reinstall pins the installed zip when ZIP= is blank, so it never
  changes version, and keeps server/.installed (uses a .reinstall flag).
- Resolve terractl's own path before cd /, so a relative $0 works.
- Docs, completion and the daemon's command list; version 0.0.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:37:51 +00:00

1461 lines
57 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
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}"
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)
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)
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; 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
}
# ------------------------------------------------------------ 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.
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 listing 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
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 $zip..."
unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again"
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
mkdir -p "$BASE_DIR/.backups"
chmod 700 "$BASE_DIR/.backups"
installed_name=${installed%.zip}
backup_file="$BASE_DIR/.backups/$name-$(date +%Y%m%d-%H%M%S)-${installed_name:-new}"
while [[ -e $backup_file.tar.gz ]]; do backup_file+=_; done
backup_file+=.tar.gz
# In a subshell, so a failure comes back here instead of exiting
if ! ( trap cleanup EXIT; cmd_export "$name" "$backup_file" ) >/dev/null; then
if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; 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"; 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 ]]; 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"
}
# 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=$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 (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")" \
"$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