This commit is contained in:
abrown38 committed 2026-09-30 17:11:22 +00:00
1 parent 4f0886b6cd
commit 02017d470f
16 files changed
+2665 -84

No files matched your search

+282 -25
View File
@@ -10,6 +10,9 @@
# 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'
@@ -33,10 +36,32 @@ 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]
@@ -44,8 +69,13 @@ Usage: terractl <command> <instance> [args]
Admin commands:
new <name> [vanilla|tmodloader] [port] create an instance (run as root)
list show instances, type, port, state
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)
@@ -54,17 +84,32 @@ Admin commands:
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> add a host to manage (token from 'daemon trust add')
remote add <name> <address> [<token>] ... when the token's addresses don't work from here
remote list | remote remove <name> show or forget remotes
daemon enable|disable|status let other hosts manage this one (terractld.service)
daemon trust add <client> create a one-time token for a computer to join
daemon trust list | trust remove <client>
show or revoke computers that may manage this host
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
stop <name> <mainpid> graceful shutdown via console "exit" (service only;
to stop a server use: systemctl stop terraria@<name>)
service-stop <name> <mainpid> graceful shutdown via console "exit" (service only;
to stop a server use: terractl stop <name>)
EOF
exit "${1:-1}"
}
@@ -239,7 +284,7 @@ take_lock() {
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: systemctl stop terraria@$name" ;;
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
@@ -293,11 +338,12 @@ cmd_run() {
esac
}
cmd_stop() {
# 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 "'stop' is only for terraria@.service. To stop the server: systemctl stop terraria@$name"
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
@@ -314,6 +360,42 @@ cmd_stop() {
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).
@@ -341,7 +423,7 @@ can_read_journal() {
journal_hint() {
{
echo "Note: commands are sent, but you can't see the server's output."
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:"
@@ -385,7 +467,7 @@ cmd_cmd() {
if ! can_read_journal; then
printf '%s\n' "$*" > "$fifo"
echo "sent."
journal_hint
journal_hint "Note: commands are sent, but you can't see the server's output."
return 0
fi
@@ -412,7 +494,7 @@ cmd_cmd() {
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: terractl attach $name"
echo "Watch it with: $(hint group attach "$name")"
fi
}
@@ -427,7 +509,7 @@ cmd_attach() {
journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n 30 -o cat &
ATTACH_PID=$! # killed by cleanup() however attach ends
else
journal_hint
journal_hint "Note: commands are sent, but you can't see the server's output."
fi
trap 'echo; exit 0' INT
@@ -436,7 +518,7 @@ cmd_attach() {
[[ -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.)"
"or '$(hint group stop "$name")' to stop it.)"
continue
fi
printf '%s\n' "$line" > "$fifo"
@@ -444,6 +526,37 @@ cmd_attach() {
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
@@ -497,6 +610,39 @@ cmd_delete() {
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/])
@@ -653,6 +799,13 @@ cmd_import() {
# 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
@@ -671,7 +824,7 @@ cmd_import() {
else
echo "imported '$name' into $dir"
fi
echo "Review $dir/serverconfig.txt, then: systemctl enable --now terraria@$name"
echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)"
}
# ---------------------------------------------------------------- mods ----
@@ -699,7 +852,7 @@ write_enabled() { # write_enabled <mods dir> <name>...
mods_restart_hint() {
if systemctl is-active --quiet "terraria@$name" 2>/dev/null; then
echo "Restart to apply: sudo systemctl restart terraria@$name"
echo "Restart to apply: $(hint group restart "$name")"
fi
}
@@ -712,7 +865,7 @@ mods_list() {
done
if [[ ${#files[@]} -eq 0 ]]; then
echo "No mods installed in $mdir"
echo "Add some with: terractl mods $name add <folder or .tmod files>"
echo "Add some with: $(hint root mods "$name" add "<folder or .tmod files>")"
return 0
fi
printf '%-32s %s\n' MOD STATE
@@ -820,7 +973,7 @@ cmd_mods() {
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)"
[[ -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"
@@ -850,26 +1003,67 @@ 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)"
echo " $(hint root mods "$name" add "<folder or .tmod files>") (before first start)"
fi
cat <<EOF
systemctl enable --now terraria@$name
terractl attach $name
$(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
}
cmd_list() {
local d n port state
printf '%-20s %-11s %-6s %s\n' INSTANCE TYPE PORT STATE
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 %s\n' "$n" "$TYPE" "${port:--}" "$state"
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
}
@@ -884,17 +1078,69 @@ cmd_reinstall() {
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: systemctl stop terraria@$name"
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: systemctl restart terraria@$name"
echo "server/ will be re-unpacked on next start: $(hint group restart "$name")"
}
# 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
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 ;;
@@ -904,6 +1150,7 @@ case $command in
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
@@ -917,20 +1164,30 @@ if [[ $command == prepare || $command == run ]]; then
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'"
# 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" ;;
stop) cmd_stop "$@" ;;
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 "$@" ;;
delete) cmd_delete "$@" ;;
rename) cmd_rename "$@" ;;
export) cmd_export "$@" ;;
import) cmd_import "$@" ;;
*) usage ;;