#!/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) # /var/lib/terraria/backups// backups ('backup', and 'upgrade' before switching) # # 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}" BACKUP_DIR="${TERRARIA_BACKUPS:-/var/lib/terraria/backups}" 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; } # join_by ...: the items with the separator between them join_by() { local sep=$1 first shift if [[ $# -eq 0 ]]; then echo; return 0; fi first=$1; shift printf '%s' "$first" "${@/#/$sep}" echo } log() { echo "terractl: $*" >&2; } # The command that installs (unzip, curl, python3, openssl, icu, polkit) # with this system's package manager: pkg_install pkg_install() { local sudo=$1 what=$2 if command -v apt-get >/dev/null; then case $what in icu) what=libicu-dev ;; polkit) what=polkitd ;; esac echo "${sudo}apt install $what" elif command -v dnf >/dev/null; then case $what in icu) what=libicu ;; esac echo "${sudo}dnf install $what" else case $what in icu) what=ICU ;; esac echo "install $what with your package manager" fi } # firewalld is on by default on Fedora and RHEL, and blocks new ports until # they're opened. Both need root (or polkit's permission) to ask. firewalld_running() { command -v firewall-cmd >/dev/null && firewall-cmd --state >/dev/null 2>&1; } firewalld_allows() { firewall-cmd --query-port="$1/tcp" >/dev/null 2>&1; } firewalld_open_cmd() { # firewalld_open_cmd echo "${1}firewall-cmd --permanent --add-port=$2/tcp && ${1}firewall-cmd --reload" } # Is (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 [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 < [args] terractl --version Admin commands: new [vanilla|tmodloader] [port] create an instance (run as root) list show instances, type, port, boot, state info [] paths and zips of this host, or details of an instance start|stop|restart start, stop (saves the world) or restart a server enable [--now] start at boot (--now: also start it now) disable [--now] don't start at boot (--now: also stop it now) attach interactive console (Ctrl-D to detach) watch [-n ] [-q] follow the server's output, read-only (Ctrl-C to quit) cmd send a console command (e.g. save, say hi) upgrade show the server zips it can use, and which it runs upgrade |latest [--downgrade] [--no-backup] change its server version now (backs up worlds first) reinstall [--clean] re-unpack the same server version on next start (--clean also re-downloads tModLoader's .NET) fetch vanilla|tmodloader [] [--upgrade ] download a server zip into dist/ (default: latest) fetch vanilla|tmodloader --list versions available to download backup enable [--every ] [--keep ] back up on a schedule (default: daily, keep 14) backup disable stop scheduled backups (keeps existing ones) backup now back up now backup list [] show schedules and backups doctor [] [--fix] check the setup and explain problems (--fix: fix what can be fixed safely) fix-perms give all of an instance's files back to the '$SVC_USER' user, with the usual modes (run as root) 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 rename rename an instance (restarted if it was running) export [file.tar.gz] [--with-server] archive an instance (configs, worlds, mods) import create instance from an archive (worlds/configs archived to $BASE_DIR/.deleted/) Other hosts: --host [args] run a command on another host (e.g. --host nas list) copy [:] [:][] [--with-server] copy an instance, on this host or between hosts move [:] [:][] [--with-server] move (or rename) an instance; stops it while moving remote add [] [--address [:]] add a host to manage (token from its 'daemon enable') remote list | remote remove 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 take a computer's access away Used by terraria@.service: journal-access let the terraria group read the servers' journal prepare create dirs/configs, unpack server if needed run exec the server service-stop graceful shutdown via console "exit" (service only; to stop a server use: terractl stop ) 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" < 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" </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 /{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 err; dir=$(inst_dir "$name") if ! err=$(mkdir -p "$dir/data/Worlds" 2>&1); then location_warnings die "can't create $dir/data/Worlds: ${err##*: }" fi 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 " # 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 # .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 if [[ -n $(backup_schedule "$name") ]]; then backup_disable "$name" >/dev/null echo "turned off scheduled backups of '$name'" fi 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 sched every keep [[ $# -eq 2 ]] || die "usage: terractl rename " 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 sched=$(backup_schedule "$name") if [[ -n $sched ]]; then backup_disable "$name" >/dev/null; fi 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 [[ -n $sched ]]; then IFS=$'\t' read -r every keep <<< "$sched" backup_enable "$new" --every "$every" --keep "$keep" >/dev/null echo "scheduled backups now run for '$new'" if [[ -d $BACKUP_DIR/$name ]]; then echo "backups made before the rename stay in $BACKUP_DIR/$name"; fi fi 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.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 " 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 " 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)" location_warnings } # ---------------------------------------------------------------- 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: $(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 "")" 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 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 ... 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 <") (before first start)" fi cat <? 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 } # Check that the service user can run this script, which prepare/run (as root) # and upgrade switch to. Not so for a copy in a private folder, such as a # development copy under /root. need_svc_can_run_self() { command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead" runuser -u "$SVC_USER" -- test -x "$SELF" 2>/dev/null || die "$1 runs $SELF as the '$SVC_USER' user, who can't open it (it's in a folder only root can open, e.g. /root). Use the installed terractl, or a copy somewhere '$SVC_USER' can read." } # 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}" } # ------------------------------------------------------------ locations ---- # The services' sandboxes (ProtectSystem=, ProtectHome=, ReadWritePaths=) are # set in their unit files, which can't follow /etc/default/terractl. If # TERRARIA_BASE or TERRARIA_DIST is moved somewhere a unit can't reach, it needs # a drop-in; these checks say so, with the drop-in to add. # Is , or inside it? path_in() { [[ $1 == "$2" || $1 == "$2"/* ]]; } # Why can't use (need: rw or ro): 'home' (ProtectHome hides it), # 'home-ro' (ProtectHome=read-only, not writable) or 'system' (ProtectSystem # makes it read-only). Prints nothing if the unit can use it, or if systemd or # the unit isn't there to ask. unit_path_problem() { local unit=$1 path=$2 need=$3 key value load="" ps=no ph=no p covered=0 local -a rw=() command -v systemctl >/dev/null || return 0 while IFS='=' read -r key value; do case $key in LoadState) load=$value ;; ProtectSystem) ps=$value ;; ProtectHome) ph=$value ;; ReadWritePaths) read -ra rw <<< "$value" ;; esac done < <(systemctl show -p LoadState,ProtectSystem,ProtectHome,ReadWritePaths "$unit" 2>/dev/null) [[ $load == loaded ]] || return 0 path=$(readlink -m "$path") # where it really is, as systemd sees it for p in "${rw[@]}"; do p=${p#-} # '-': ignored if missing if path_in "$path" "$(readlink -m "$p")"; then covered=1; fi done if path_in "$path" /home || path_in "$path" /root || path_in "$path" /run/user; then case $ph in no) ;; read-only) if [[ $need == rw && $covered -eq 0 ]]; then echo home-ro; fi ;; *) echo home ;; esac return 0 fi [[ $need == rw && $covered -eq 0 ]] || return 0 case $ps in strict) echo system ;; yes|full) if path_in "$path" /usr || path_in "$path" /boot || path_in "$path" /efi || { [[ $ps == full ]] && path_in "$path" /etc; }; then echo system fi ;; esac } # What's wrong for one unit: location_problems . Fills LOC_PROBLEMS (messages), LOC_RW (paths # for ReadWritePaths=) and LOC_HOME (1 if ProtectHome= must change). location_problems() { local unit=$1 edit=$2 effect=$3 dist_need=$4 what path need problem LOC_PROBLEMS=() LOC_RW=() LOC_HOME=0 for what in TERRARIA_BASE TERRARIA_DIST; do if [[ $what == TERRARIA_BASE ]]; then path=$BASE_DIR need=rw; else path=$DIST_DIR need=$dist_need; fi problem=$(unit_path_problem "$unit" "$path" "$need") case $problem in home) LOC_PROBLEMS+=("$edit hides $path ($what) under ProtectHome=, so $effect") LOC_HOME=1 if [[ $need == rw ]]; then LOC_RW+=("$path"); fi ;; home-ro|system) LOC_PROBLEMS+=("$edit can't write to $path ($what), so $effect") if [[ $problem == home-ro ]]; then LOC_HOME=1; fi LOC_RW+=("$path") ;; esac done } # The drop-in that fixes what location_problems found location_dropin() { local path echo "[Service]" if [[ $LOC_HOME -eq 1 ]]; then echo "ProtectHome=read-only"; fi for path in "${LOC_RW[@]}"; do echo "ReadWritePaths=$path"; done } # Warns about one unit, with the drop-in to add; arguments as location_problems location_warning() { local edit=$2 where="" location_problems "$@" [[ ${#LOC_PROBLEMS[@]} -gt 0 ]] || return 0 if [[ -n ${TERRACTL_VIA:-} ]]; then where=" (on $(hostname 2>/dev/null || echo that host))"; fi printf 'terractl: warning: %s\n' "${LOC_PROBLEMS[@]}" >&2 { echo " Fix it with a drop-in$where: sudo systemctl edit $edit" location_dropin | sed 's/^/ /' if [[ $LOC_HOME -eq 1 ]]; then echo " The '$SVC_USER' user also needs access to every folder above it; see" echo " \"Instances or zips under /home\" in the guide." else echo " See \"Changing locations\" in the guide, or run: $(hint root doctor --fix)" fi } >&2 } # The servers always; the daemon only if it's in use. The daemon writes zips # into dist/ when instances are copied to this host, so it needs both writable. location_warnings() { location_warning terraria@terractl-check.service terraria@.service \ "servers can't start" ro if systemctl is-enabled --quiet terractld.service 2>/dev/null || systemctl is-active --quiet terractld.service 2>/dev/null; then location_warning terractld.service terractld.service \ "other hosts can't copy, move or import instances here" rw fi } 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 location_warnings } # ------------------------------------------------------------ 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. # Checks that is a complete server zip of ; dies saying what's wrong check_server_zip() { local f=$1 type=$2 listing 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 $(basename "$f")..." unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again" } # The version in a zip's name: tModLoader-v2026.07.3.0.zip -> 2026.07.3.0, # terraria-server-1458.zip -> 1458 zip_version() { local v=${1%.zip} v=${v#terraria-server-}; v=${v#tModLoader}; v=${v#-}; v=${v#v} echo "$v" } 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" "")" 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" "|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 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 need_svc_can_run_self "upgrade" check_server_zip "$f" "$TYPE" 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 installed_name=${installed%.zip} if ! backup_file=$(make_backup "$name" "before-${installed_name:-upgrade}"); then if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; echo "$unit started again"; 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"; echo "$unit started again"; 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 && -n $installed ]]; 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 " [[ $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" } # ------------------------------------------------------------- backups ---- # Backups go in $BACKUP_DIR//, readable by the terraria group, like the # instances themselves. Each is an export without server/, so 'import' restores # it. Scheduled ones are named --