add import and export

This commit is contained in:
abrown38 committed 2026-09-29 16:40:56 +00:00
1 parent d4bd93bbe1
commit 35f541458c
7 files changed
+369 -38

No files matched your search

+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 0.0.9 - 2026-09-28
- New `export` command: `terractl export <name> [file.tar.gz] [--with-server]` archives an instance's `instance.conf`, `serverconfig.txt` and `data/` (worlds, mods, mod configs). If the server is running under systemd, it is asked to save first. Archives are created readable only by their owner, since `serverconfig.txt` can contain the server password. `--with-server` also includes the unpacked server software (and tModLoader's .NET), for hosts without internet access.
- New `import` command: `terractl import <name> <file.tar.gz>` creates an instance from an export, a `delete` backup, or an archive of an old `/opt/terraria` instance. Archives are checked before extracting (no paths outside the instance folder, no links or special files). Paths in `serverconfig.txt` are updated, a port already used by another instance is replaced with a free one, the server version the world was exported with is pinned, and ownership is set to the `terraria` user.
- One server per instance: `run` holds a lock (`<instance>/.lock`) for the server's whole lifetime, released automatically when it exits or crashes. A second `run`, or starting the service while a copy started by hand is running, is refused.
- `prepare` and `reinstall --clean` refuse to run while the instance's server is running, so `server/` and .NET are never replaced or deleted under it.
- `delete` refuses to continue if the server is still running outside systemd.
- `list` shows servers started by hand as `running (outside systemd)`.
- `stop` typed by hand now explains to use `systemctl stop` instead of silently doing nothing.
- Fixed relative paths given on the command line (e.g. `terractl mods <name> add tmod-export`) being looked up from `/` instead of the current directory.
- Bash completion for `export` and `import`.
## 0.0.8 - 2026-09-28 ## 0.0.8 - 2026-09-28
- Bash completion for `terractl`: commands, instance names, `new` types, console commands for `cmd`, `reinstall`/`delete` options, and `mods` actions and mod names. Installed to `share/bash-completion/completions/terractl` by both `make install` and the package. - Bash completion for `terractl`: commands, instance names, `new` types, console commands for `cmd`, `reinstall`/`delete` options, and `mods` actions and mod names. Installed to `share/bash-completion/completions/terractl` by both `make install` and the package.
+1
View File
@@ -18,6 +18,7 @@ sudo terractl attach calamity
- **A live console.** `terractl attach` shows the server's output and lets you type commands into it. - **A live console.** `terractl attach` shows the server's output and lets you type commands into it.
- **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership. - **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership.
- **Tab completion** for commands, instance names, mods and console commands. - **Tab completion** for commands, instance names, mods and console commands.
- **Export and import.** Pack an instance into one `.tar.gz` for backups or moving to another machine, and import it anywhere, with paths, ports and ownership adjusted.
- **Hardened.** Servers run as an unprivileged `terraria` user and can only write to their own data. - **Hardened.** Servers run as an unprivileged `terraria` user and can only write to their own data.
- **Helper for Windows.** A PowerShell script exports your enabled tModLoader mods for the server. - **Helper for Windows.** A PowerShell script exports your enabled tModLoader mods for the server.
+1 -1
View File
@@ -1 +1 @@
0.0.8 0.0.9
+234 -3
View File
@@ -12,10 +12,16 @@
# data/ worlds, mods, player data (kept across reinstalls) # data/ worlds, mods, player data (kept across reinstalls)
set -euo pipefail set -euo pipefail
# All paths used are absolute. Start from / so nothing depends on the caller's # Work from / so nothing depends on the caller's directory (which the 'terraria'
# directory (which the 'terraria' user may not be allowed into, e.g. /root). # 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
cd / 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) # Local overrides (see the comments in that file)
# shellcheck disable=SC1091 # shellcheck disable=SC1091
if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi
@@ -47,12 +53,16 @@ Admin commands:
mods <name> enable|disable <mod>... change which installed mods load mods <name> enable|disable <mod>... change which installed mods load
mods <name> remove <mod>... uninstall mods mods <name> remove <mod>... uninstall mods
delete <name> [-y] [--no-backup] stop, disable and remove an instance delete <name> [-y] [--no-backup] stop, disable and remove an instance
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/) (worlds/configs archived to $BASE_DIR/.deleted/)
Used by terraria@.service: Used by terraria@.service:
prepare <name> create dirs/configs, unpack server if needed prepare <name> create dirs/configs, unpack server if needed
run <name> exec the server run <name> exec the server
stop <name> <mainpid> graceful shutdown via console "exit" stop <name> <mainpid> graceful shutdown via console "exit" (service only;
to stop a server use: systemctl stop terraria@<name>)
EOF EOF
exit "${1:-1}" exit "${1:-1}"
} }
@@ -215,9 +225,40 @@ install_server() {
log "install complete" 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: systemctl stop terraria@$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() { cmd_prepare() {
local name=$1 dir; dir=$(inst_dir "$name") local name=$1 dir; dir=$(inst_dir "$name")
mkdir -p "$dir/data/Worlds" 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 [[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla
load_conf "$name" load_conf "$name"
if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
@@ -227,6 +268,8 @@ cmd_prepare() {
cmd_run() { cmd_run() {
local name=$1 dir; dir=$(inst_dir "$name") 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" load_conf "$name"
[[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first" [[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first"
# Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet # Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet
@@ -250,6 +293,11 @@ cmd_run() {
cmd_stop() { cmd_stop() {
local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i 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"
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 [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi
if [[ -p $fifo ]]; then if [[ -p $fifo ]]; then
echo exit > "$fifo" echo exit > "$fifo"
@@ -332,6 +380,9 @@ cmd_delete() {
systemctl stop "terraria@$name.socket" 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 systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
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 if [[ $backup -eq 1 ]]; then
mkdir -p "$BASE_DIR/.deleted" mkdir -p "$BASE_DIR/.deleted"
@@ -347,6 +398,179 @@ cmd_delete() {
echo "deleted instance '$name'" echo "deleted instance '$name'"
} }
# ------------------------------------------------------- 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"
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
echo "imported${meta_from:+ '$meta_from' as} '$name' into $dir"
echo "Review $dir/serverconfig.txt, then: systemctl enable --now terraria@$name"
}
# ---------------------------------------------------------------- mods ---- # ---------------------------------------------------------------- mods ----
# enabled.json is a plain JSON array of mod names, e.g. ["CalamityMod","MagicStorage"]. # 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). # Mod names are identifiers, so simple text handling is enough (no jq needed).
@@ -404,6 +628,7 @@ mods_add() {
local src f n found local src f n found
local -a files=() cfgs=() local -a files=() cfgs=()
for src in "$@"; do for src in "$@"; do
src=$(abspath "$src")
if [[ -f $src ]]; then if [[ -f $src ]]; then
[[ $src == *.tmod ]] || die "$src is not a .tmod file" [[ $src == *.tmod ]] || die "$src is not a .tmod file"
files+=("$src") files+=("$src")
@@ -540,6 +765,7 @@ cmd_list() {
load_conf "$n" load_conf "$n"
port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$d/serverconfig.txt" 2>/dev/null | tail -n1) port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$d/serverconfig.txt" 2>/dev/null | tail -n1)
state=$(systemctl is-active "terraria@$n" 2>/dev/null || true) 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 %s\n' "$n" "$TYPE" "${port:--}" "$state"
) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)" ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
done done
@@ -554,6 +780,9 @@ cmd_reinstall() {
*) die "unknown option '$1'" ;; *) die "unknown option '$1'" ;;
esac esac
[[ -f $dir/instance.conf ]] || die "no instance '$name'" [[ -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"
fi
rm -f "$dir/server/.installed" rm -f "$dir/server/.installed"
if [[ $clean -eq 1 ]]; then if [[ $clean -eq 1 ]]; then
rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs" rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
@@ -598,5 +827,7 @@ case $command in
new) cmd_new "$@" ;; new) cmd_new "$@" ;;
reinstall) cmd_reinstall "$@" ;; reinstall) cmd_reinstall "$@" ;;
delete) cmd_delete "$@" ;; delete) cmd_delete "$@" ;;
export) cmd_export "$@" ;;
import) cmd_import "$@" ;;
*) usage ;; *) usage ;;
esac esac
+22 -7
View File
@@ -46,6 +46,20 @@ _terractl_mods() {
fi fi
} }
# File names, optionally only those ending in .<extension> (folders always offered)
_terractl_files() {
if declare -F _filedir >/dev/null 2>&1; then
_filedir "$@"
else
compopt -o filenames 2>/dev/null
if [[ -n ${1:-} ]]; then
mapfile -t COMPREPLY < <(compgen -d -- "$cur"; compgen -f -X "!*.$1" -- "$cur")
else
mapfile -t COMPREPLY < <(compgen -f -- "$cur")
fi
fi
}
# Set COMPREPLY to the words matching what's typed so far # Set COMPREPLY to the words matching what's typed so far
_terractl_reply() { _terractl_reply() {
mapfile -t COMPREPLY < <(compgen -W "$1" -- "$cur") mapfile -t COMPREPLY < <(compgen -W "$1" -- "$cur")
@@ -63,7 +77,7 @@ _terractl() {
cword=$COMP_CWORD cword=$COMP_CWORD
fi fi
local commands="new list attach cmd mods reinstall delete prepare run --help --version" local commands="new list attach cmd mods reinstall delete export import prepare run --help --version"
local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help" local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help"
if (( cword == 1 )); then if (( cword == 1 )); then
@@ -79,6 +93,10 @@ _terractl() {
# new <new name> [vanilla|tmodloader] [port]: the name is new, so nothing to offer # new <new name> [vanilla|tmodloader] [port]: the name is new, so nothing to offer
if (( cword == 3 )); then _terractl_reply "vanilla tmodloader"; fi if (( cword == 3 )); then _terractl_reply "vanilla tmodloader"; fi
return ;; return ;;
import)
# import <new name> <file.tar.gz>: the name is new, so nothing to offer
if (( cword == 3 )); then _terractl_files tar.gz; fi
return ;;
esac esac
if (( cword == 2 )); then if (( cword == 2 )); then
@@ -94,6 +112,8 @@ _terractl() {
if (( cword == 3 )); then _terractl_reply "--clean"; fi ;; if (( cword == 3 )); then _terractl_reply "--clean"; fi ;;
delete) delete)
_terractl_reply "-y --yes --no-backup" ;; _terractl_reply "-y --yes --no-backup" ;;
export)
if [[ $cur == -* ]]; then _terractl_reply "--with-server"; else _terractl_files tar.gz; fi ;;
mods) mods)
if (( cword == 3 )); then if (( cword == 3 )); then
_terractl_reply "list add enable disable remove" _terractl_reply "list add enable disable remove"
@@ -102,12 +122,7 @@ _terractl() {
case ${words[3]} in case ${words[3]} in
add) add)
# .tmod files and folders (e.g. the Windows export folder) # .tmod files and folders (e.g. the Windows export folder)
if declare -F _filedir >/dev/null 2>&1; then _terractl_files tmod ;;
_filedir tmod
else
compopt -o filenames 2>/dev/null
mapfile -t COMPREPLY < <(compgen -d -- "$cur"; compgen -f -X '!*.tmod' -- "$cur")
fi ;;
enable|remove) enable|remove)
_terractl_reply "$(_terractl_mods "$instance" installed)" ;; _terractl_reply "$(_terractl_mods "$instance" installed)" ;;
disable) disable)
+96 -26
View File
@@ -17,12 +17,13 @@ Run any number of vanilla Terraria and tModLoader dedicated servers on one Linux
11. [Mods (tModLoader)](#mods-tmodloader) 11. [Mods (tModLoader)](#mods-tmodloader)
12. [Versions and upgrades](#versions-and-upgrades) 12. [Versions and upgrades](#versions-and-upgrades)
13. [Deleting and restoring](#deleting-and-restoring) 13. [Deleting and restoring](#deleting-and-restoring)
14. [Backups](#backups) 14. [Exporting and importing](#exporting-and-importing)
15. [Permissions](#permissions) 15. [Backups](#backups)
16. [Running by hand for debugging](#running-by-hand-for-debugging) 16. [Permissions](#permissions)
17. [Troubleshooting](#troubleshooting) 17. [Running by hand for debugging](#running-by-hand-for-debugging)
18. [Tab completion](#tab-completion) 18. [Troubleshooting](#troubleshooting)
19. [Command reference](#command-reference) 19. [Tab completion](#tab-completion)
20. [Command reference](#command-reference)
--- ---
@@ -95,7 +96,7 @@ The server can write only inside `instances/`. It reads `dist/` but can never ch
The package works on Debian 12 or newer and Ubuntu 22.04 or newer. The package works on Debian 12 or newer and Ubuntu 22.04 or newer.
```bash ```bash
sudo apt install ./terraria-server-manager_2.0.0_all.deb sudo apt install ./terraria-server-manager_0.0.9_all.deb
``` ```
Using `apt install ./file.deb` (rather than `dpkg -i`) installs the dependencies automatically. `unzip` is required. `curl` and the ICU library are "recommended", which apt installs by default; they're only needed for tModLoader instances, which crash with `Aborted` without ICU. Using `apt install ./file.deb` (rather than `dpkg -i`) installs the dependencies automatically. `unzip` is required. `curl` and the ICU library are "recommended", which apt installs by default; they're only needed for tModLoader instances, which crash with `Aborted` without ICU.
@@ -137,10 +138,10 @@ sudo rm -rf /var/lib/terraria && sudo userdel terraria
### Upgrading from the `terraria-instance` package ### Upgrading from the `terraria-instance` package
Versions before 2.0.0 were packaged as `terraria-instance`, and the command had the same name. The new package declares that it replaces the old one, so installing it removes `terraria-instance` automatically: Versions before 0.0.6 were packaged as `terraria-instance`, and the command had the same name. The new package declares that it replaces the old one, so installing it removes `terraria-instance` automatically:
```bash ```bash
sudo apt install ./terraria-server-manager_2.0.0_all.deb sudo apt install ./terraria-server-manager_0.0.9_all.deb
``` ```
Things to know: Things to know:
@@ -203,7 +204,7 @@ sudo rm /etc/systemd/system/terraria@.service /etc/systemd/system/terraria@.sock
sudo rm /usr/local/bin/terraria-instance sudo rm /usr/local/bin/terraria-instance
# 3. Install the package (keeps the existing 'terraria' user) # 3. Install the package (keeps the existing 'terraria' user)
sudo apt install ./terraria-server-manager_2.0.0_all.deb sudo apt install ./terraria-server-manager_0.0.9_all.deb
# 4. Move instances and zips # 4. Move instances and zips
for d in /opt/terraria/*/; do for d in /opt/terraria/*/; do
@@ -222,6 +223,8 @@ sudo systemctl reenable terraria@calamity # repeat for each instance from
sudo systemctl start terraria@calamity sudo systemctl start terraria@calamity
``` ```
Alternatively, you can move instances one at a time with `import`, which updates the paths in `serverconfig.txt` and sets ownership for you: `sudo tar -czf /tmp/calamity.tar.gz -C /opt/terraria calamity`, then `sudo terractl import calamity /tmp/calamity.tar.gz`.
Once everything works, remove the old directories: `sudo rm -rf /terraria /opt/terraria`. If you set `ZIP=` in an `instance.conf` to an absolute path under `/terraria`, change it to just the file name. Once everything works, remove the old directories: `sudo rm -rf /terraria /opt/terraria`. If you set `ZIP=` in an `instance.conf` to an absolute path under `/terraria`, change it to just the file name.
The existing `terraria` user keeps `/opt/terraria` as its home directory. That's harmless, because the script sets a per-instance home itself. To tidy it anyway, run `sudo usermod -d /var/lib/terraria terraria` after deleting `/opt/terraria`. The existing `terraria` user keeps `/opt/terraria` as its home directory. That's harmless, because the script sets a per-instance home itself. To tidy it anyway, run `sudo usermod -d /var/lib/terraria terraria` after deleting `/opt/terraria`.
@@ -533,11 +536,10 @@ sudo terractl delete calamity -y --no-backup # permanently, no backup
This stops and disables the service, then removes the whole instance directory. Unless you pass `--no-backup`, it first archives the configs and `data/` to `/var/lib/terraria/instances/.deleted/<name>-<timestamp>.tar.gz`. `server/` is left out of the archive because it can be recreated from the zip. This stops and disables the service, then removes the whole instance directory. Unless you pass `--no-backup`, it first archives the configs and `data/` to `/var/lib/terraria/instances/.deleted/<name>-<timestamp>.tar.gz`. `server/` is left out of the archive because it can be recreated from the zip.
To restore an instance: To restore an instance, import its backup. You can give it back its old name, or a new one:
```bash ```bash
sudo tar -xzf /var/lib/terraria/instances/.deleted/calamity-20260928-134422.tar.gz -C /var/lib/terraria/instances sudo terractl import calamity /var/lib/terraria/instances/.deleted/calamity-20260928-134422.tar.gz
sudo chown -R terraria:terraria /var/lib/terraria/instances/calamity
sudo systemctl enable --now terraria@calamity # server/ is unpacked again on start sudo systemctl enable --now terraria@calamity # server/ is unpacked again on start
``` ```
@@ -545,20 +547,67 @@ sudo systemctl enable --now terraria@calamity # server/ is unpacked ag
--- ---
## Backups ## Exporting and importing
Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`. `export` packs an instance into a single `.tar.gz` file, and `import` turns such a file back into an instance. Use them for backups, for moving a server to another machine, or for making a copy to experiment on.
To back up a running server, save it first so the world file on disk is current:
```bash ```bash
sudo terractl cmd calamity save sudo terractl export calamity # ./calamity-<date>-<time>.tar.gz
sleep 10 sudo terractl export calamity /root/backups/cal.tar.gz # choose the file
sudo tar -czf /root/calamity-$(date +%F).tar.gz -C /var/lib/terraria/instances \ sudo terractl export calamity --with-server # also include server/ (see below)
--exclude=calamity/server calamity
sudo terractl import calamity2 calamity-20260928-1830.tar.gz
``` ```
Stopping the server first is the safest option. Terraria also keeps a `.bak` copy of each world alongside the world file. ### What an export contains
It contains `instance.conf`, `serverconfig.txt` and all of `data/`: worlds (`.wld` and `.twld`), their `.bak` copies, mods, `enabled.json`, mod configs and the ban list. It also includes a small `terractl-export.txt`, which records where the export came from, the server type, and the server zip it was running.
`server/` is left out by default, because it can be recreated from the zip in `dist/`. `--with-server` includes it, together with the .NET runtime tModLoader downloaded. That makes the archive much larger, but an import can then start without a zip and without internet access.
**If the server is running**, `export` sends it `save` and waits until the world files have been written, which usually takes a few seconds. A server started by hand with `terractl run` can't be asked to save that way, so `export` refuses; stop it first.
**The archive is readable only by you** (mode 600), because `serverconfig.txt` can contain the server password. Keep that in mind before sharing an export.
### What an import does
`import <name> <file>` creates a new instance called `<name>`. It never overwrites an existing one, so pick a free name or delete the old instance first. Before extracting anything, it checks the archive: it must hold one instance folder, and nothing that could land outside it (no absolute or `../` paths, no links or special files).
After extracting, it adjusts the instance for its new home:
- **Paths.** The paths in `serverconfig.txt` (`world=`, `worldpath=`, `banlist=`) are updated for the new name and location.
- **Port.** If the port is already used by another instance, a free port is assigned and printed.
- **Server version.** If `ZIP=` in `instance.conf` is blank, it's set to the zip the world was exported with, so the server runs the same Terraria or tModLoader version as before. That matters for mods. Make sure that zip is in `/var/lib/terraria/dist`; `import` warns you if it isn't.
- **Ownership.** All files are given to the `terraria` user.
`import` also accepts:
- the backups that `delete` makes (see [Deleting and restoring](#deleting-and-restoring));
- a plain archive of an instance folder, including one from the old `/opt/terraria` manual setup, e.g. `tar -czf old.tar.gz -C /opt/terraria calamity`.
### Moving a server to another machine
```bash
# old machine
sudo terractl export calamity /tmp/calamity.tar.gz
scp /tmp/calamity.tar.gz newhost:/tmp/
# new machine (with terraria-server-manager installed, and the same zip in dist/)
sudo terractl import calamity /tmp/calamity.tar.gz
sudo systemctl enable --now terraria@calamity
```
---
## Backups
Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`, which is exactly what `export` saves. A nightly backup kept for 14 days, as a root cron job (`sudo crontab -e`):
```cron
30 4 * * * /usr/bin/terractl export calamity /var/backups/terraria/calamity-$(date +\%F).tar.gz && find /var/backups/terraria -name 'calamity-*.tar.gz' -mtime +14 -delete
```
Create the folder first with `sudo mkdir -p /var/backups/terraria`. In a crontab, `%` has a special meaning and must be written as `\%`, as above. Terraria also keeps a `.bak` copy of each world next to the world file, which is useful if the latest save is damaged.
--- ---
@@ -577,6 +626,8 @@ The servers always run as the `terraria` user. Administration is simplest with `
| `mods <name>` (list) | root, or members of the `terraria` group | | | `mods <name>` (list) | root, or members of the `terraria` group | |
| `mods add/enable/disable/remove` | root (or `terraria`) | Run as root, it sets ownership of the files it installs to `terraria`. | | `mods add/enable/disable/remove` | root (or `terraria`) | Run as root, it sets ownership of the files it installs to `terraria`. |
| `delete` | root only | Checked by the script. | | `delete` | root only | Checked by the script. |
| `export` | root, or members of the `terraria` group | Reads the instance, and asks a running server to save. |
| `import` | root (or `terraria`) | Run as root, it gives the new instance to `terraria`. |
| `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. | | `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. |
| `stop` | Only the service | Needs the server's process ID from systemd. | | `stop` | Only the service | Needs the server's process ID from systemd. |
| `systemctl start/stop/enable` | root | | | `systemctl start/stop/enable` | root | |
@@ -610,7 +661,7 @@ Editing an existing file with `sudo nano` keeps its owner, so it's fine.
## Running by hand for debugging ## Running by hand for debugging
Stop the service first, so two copies of the same server don't run at once on the same port and world: Stop the service first. `terractl` won't let two copies of the same server run at once: if the service is running, `run` refuses and tells you to stop it.
```bash ```bash
sudo systemctl stop terraria@calamity sudo systemctl stop terraria@calamity
@@ -623,6 +674,18 @@ The server runs in your terminal and you can type commands into it directly. Typ
sudo systemctl start terraria@calamity sudo systemctl start terraria@calamity
``` ```
### How running servers are protected
While a server runs, whether systemd started it or you did, it holds a lock on the file `<instance>/.lock`. The lock is released automatically when the server exits, even if it crashes, so it can never be left behind. While the lock is held:
- **A second `run` is refused**, and so is starting the service while a copy started by hand is running. In that case the service keeps retrying every 10 seconds and starts by itself once the other copy exits.
- **`prepare` is refused**, so `server/` can't be replaced under the running server.
- **`reinstall --clean` is refused**, so `.NET` can't be deleted under it. Plain `reinstall` is allowed, because it only takes effect at the next start.
- **`delete` stops the service**, but refuses to go on if a copy started by hand is still running.
- **`list` shows a copy started by hand** as `running (outside systemd)`.
`terractl stop` is only for the service's own use. Typed by hand, it tells you to use `systemctl stop terraria@<name>` instead.
--- ---
## Troubleshooting ## Troubleshooting
@@ -657,7 +720,10 @@ Run `terractl mods <name>` to check for mods that are enabled but missing. Then
- the files are owned by root. - the files are owned by root.
**The server fails with "address already in use".** **The server fails with "address already in use".**
Two instances share a port, or a copy you started by hand is still running. Check with `sudo terractl list` and `pgrep -af 'tModLoader.dll|TerrariaServer'`. Two instances share a port. Check the ports with `sudo terractl list`. A second copy of the *same* instance can't be the cause, because `terractl` prevents it.
**"already running outside systemd" or "is active; stop it first".**
The instance's server is already running, so the command was refused to protect it. Stop the server first. If it was started with `terractl run` in another terminal, type `exit` there; otherwise use `sudo systemctl stop terraria@<name>`.
**The service starts but you can't connect.** **The service starts but you can't connect.**
Check the port in `serverconfig.txt`, your firewall (TCP), and router port forwarding. Check the port in `serverconfig.txt`, your firewall (TCP), and router port forwarding.
@@ -678,7 +744,8 @@ It was generated before the mods were added. Delete the world files in `data/Wor
- instance names; - instance names;
- `vanilla`/`tmodloader` for `new`; - `vanilla`/`tmodloader` for `new`;
- common server console commands for `cmd` (`save`, `say`, `playing`, `kick`...); - common server console commands for `cmd` (`save`, `say`, `playing`, `kick`...);
- the options of `reinstall` and `delete`; - the options of `reinstall`, `delete` and `export`;
- `.tar.gz` files for `export` and `import`;
- `mods` actions. For `mods add` it completes `.tmod` files and folders; for `mods enable` and `mods remove`, the installed mods; for `mods disable`, the enabled ones. - `mods` actions. For `mods add` it completes `.tmod` files and folders; for `mods enable` and `mods remove`, the installed mods; for `mods disable`, the enabled ones.
It works with `sudo terractl` too. It needs the `bash-completion` package, which most Debian and Ubuntu systems already have (`sudo apt install bash-completion`). It takes effect in new terminal sessions. It works with `sudo terractl` too. It needs the `bash-completion` package, which most Debian and Ubuntu systems already have (`sudo apt install bash-completion`). It takes effect in new terminal sessions.
@@ -701,12 +768,15 @@ terractl mods <name> add <folder|file.tmod>... Install and enable mods (and M
terractl mods <name> enable|disable <Mod>... Change which mods load terractl mods <name> enable|disable <Mod>... Change which mods load
terractl mods <name> remove <Mod>... Uninstall mods terractl mods <name> remove <Mod>... Uninstall mods
terractl delete <name> [-y] [--no-backup] Stop, disable, back up and remove terractl delete <name> [-y] [--no-backup] Stop, disable, back up and remove
terractl export <name> [file.tar.gz] [--with-server]
Archive an instance (configs, worlds, mods)
terractl import <name> <file.tar.gz> Create an instance from an archive
terractl --version Show the installed version terractl --version Show the installed version
Used by the service: Used by the service:
terractl prepare <name> Create dirs/configs, unpack zip if needed terractl prepare <name> Create dirs/configs, unpack zip if needed
terractl run <name> Run the server terractl run <name> Run the server
terractl stop <name> <pid> Save and shut down via the console terractl stop <name> <pid> Save and shut down via the console (service only)
``` ```
``` ```
+3 -1
View File
@@ -10,7 +10,9 @@ case "$1" in
units=$(systemctl list-units --plain --no-legend --all 'terraria@*.service' | awk '{print $1}') units=$(systemctl list-units --plain --no-legend --all 'terraria@*.service' | awk '{print $1}')
if [ -n "$units" ]; then if [ -n "$units" ]; then
echo "Stopping Terraria instances: $units" echo "Stopping Terraria instances: $units"
systemctl stop "$units" || true # Unquoted on purpose: each unit name must be a separate argument
# shellcheck disable=SC2086
systemctl stop $units || true
fi fi
fi fi
;; ;;