#!/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.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 < [args] terractl --version Admin commands: new [vanilla|tmodloader] [port] create an instance (run as root) list show instances, type, port, state attach interactive console (Ctrl-D to detach) cmd send a console command (e.g. save, say hi) reinstall [--clean] re-unpack server on next start (--clean also re-downloads tModLoader's .NET) mods [list] show installed mods and whether they're enabled mods add ... install mods (and ModConfigs) and enable them mods enable|disable ... change which installed mods load mods remove ... uninstall mods delete [-y] [--no-backup] stop, disable and remove an instance (worlds/configs archived to $BASE_DIR/.deleted/) Used by terraria@.service: prepare create dirs/configs, unpack server if needed run exec the server stop 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" < "$dir/serverconfig.txt" </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 /{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 ... 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 " 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 ..." 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 ... 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 < (before first start)" fi cat </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