add import and export
This commit is contained in:
1 parent
d4bd93bbe1
commit
35f541458c
7 files changed
+369
-38
No files matched your search
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
- **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership.
|
||||
- **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.
|
||||
- **Helper for Windows.** A PowerShell script exports your enabled tModLoader mods for the server.
|
||||
|
||||
|
||||
+234
-3
@@ -12,10 +12,16 @@
|
||||
# data/ worlds, mods, player data (kept across reinstalls)
|
||||
set -euo pipefail
|
||||
|
||||
# All paths used are absolute. Start from / so nothing depends on the caller's
|
||||
# directory (which the 'terraria' user may not be allowed into, e.g. /root).
|
||||
# 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
|
||||
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
|
||||
@@ -47,12 +53,16 @@ Admin commands:
|
||||
mods <name> enable|disable <mod>... change which installed mods load
|
||||
mods <name> remove <mod>... uninstall mods
|
||||
delete <name> [-y] [--no-backup] stop, disable and remove an instance
|
||||
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/)
|
||||
|
||||
Used by terraria@.service:
|
||||
prepare <name> create dirs/configs, unpack server if needed
|
||||
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
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -215,9 +225,40 @@ install_server() {
|
||||
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() {
|
||||
local name=$1 dir; dir=$(inst_dir "$name")
|
||||
mkdir -p "$dir/data/Worlds"
|
||||
take_lock "$name" # never re-unpack server/ under a running server
|
||||
[[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla
|
||||
load_conf "$name"
|
||||
if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi
|
||||
@@ -227,6 +268,8 @@ cmd_prepare() {
|
||||
|
||||
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
|
||||
@@ -250,6 +293,11 @@ cmd_run() {
|
||||
|
||||
cmd_stop() {
|
||||
local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i
|
||||
# systemd sets INVOCATION_ID for every process it runs for a service
|
||||
if [[ -z ${INVOCATION_ID:-} ]]; then
|
||||
die "'stop' is only for terraria@.service. To stop the server: systemctl stop terraria@$name"
|
||||
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"
|
||||
@@ -332,6 +380,9 @@ cmd_delete() {
|
||||
systemctl stop "terraria@$name.socket" 2>/dev/null || true
|
||||
systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true
|
||||
fi
|
||||
if instance_running "$name"; then
|
||||
die "'$name' is still running outside systemd (e.g. 'terractl run' in another terminal); stop it, then run delete again"
|
||||
fi
|
||||
|
||||
if [[ $backup -eq 1 ]]; then
|
||||
mkdir -p "$BASE_DIR/.deleted"
|
||||
@@ -347,6 +398,179 @@ cmd_delete() {
|
||||
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 ----
|
||||
# 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).
|
||||
@@ -404,6 +628,7 @@ mods_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")
|
||||
@@ -540,6 +765,7 @@ cmd_list() {
|
||||
load_conf "$n"
|
||||
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)
|
||||
if [[ $state != active ]] && instance_running "$n"; then state="running (outside systemd)"; fi
|
||||
printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state"
|
||||
) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
|
||||
done
|
||||
@@ -554,6 +780,9 @@ cmd_reinstall() {
|
||||
*) 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: systemctl stop terraria@$name"
|
||||
fi
|
||||
rm -f "$dir/server/.installed"
|
||||
if [[ $clean -eq 1 ]]; then
|
||||
rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
|
||||
@@ -598,5 +827,7 @@ case $command in
|
||||
new) cmd_new "$@" ;;
|
||||
reinstall) cmd_reinstall "$@" ;;
|
||||
delete) cmd_delete "$@" ;;
|
||||
export) cmd_export "$@" ;;
|
||||
import) cmd_import "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -46,6 +46,20 @@ _terractl_mods() {
|
||||
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
|
||||
_terractl_reply() {
|
||||
mapfile -t COMPREPLY < <(compgen -W "$1" -- "$cur")
|
||||
@@ -63,7 +77,7 @@ _terractl() {
|
||||
cword=$COMP_CWORD
|
||||
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"
|
||||
|
||||
if (( cword == 1 )); then
|
||||
@@ -79,6 +93,10 @@ _terractl() {
|
||||
# new <new name> [vanilla|tmodloader] [port]: the name is new, so nothing to offer
|
||||
if (( cword == 3 )); then _terractl_reply "vanilla tmodloader"; fi
|
||||
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
|
||||
|
||||
if (( cword == 2 )); then
|
||||
@@ -94,6 +112,8 @@ _terractl() {
|
||||
if (( cword == 3 )); then _terractl_reply "--clean"; fi ;;
|
||||
delete)
|
||||
_terractl_reply "-y --yes --no-backup" ;;
|
||||
export)
|
||||
if [[ $cur == -* ]]; then _terractl_reply "--with-server"; else _terractl_files tar.gz; fi ;;
|
||||
mods)
|
||||
if (( cword == 3 )); then
|
||||
_terractl_reply "list add enable disable remove"
|
||||
@@ -102,12 +122,7 @@ _terractl() {
|
||||
case ${words[3]} in
|
||||
add)
|
||||
# .tmod files and folders (e.g. the Windows export folder)
|
||||
if declare -F _filedir >/dev/null 2>&1; then
|
||||
_filedir tmod
|
||||
else
|
||||
compopt -o filenames 2>/dev/null
|
||||
mapfile -t COMPREPLY < <(compgen -d -- "$cur"; compgen -f -X '!*.tmod' -- "$cur")
|
||||
fi ;;
|
||||
_terractl_files tmod ;;
|
||||
enable|remove)
|
||||
_terractl_reply "$(_terractl_mods "$instance" installed)" ;;
|
||||
disable)
|
||||
|
||||
+96
-26
@@ -17,12 +17,13 @@ Run any number of vanilla Terraria and tModLoader dedicated servers on one Linux
|
||||
11. [Mods (tModLoader)](#mods-tmodloader)
|
||||
12. [Versions and upgrades](#versions-and-upgrades)
|
||||
13. [Deleting and restoring](#deleting-and-restoring)
|
||||
14. [Backups](#backups)
|
||||
15. [Permissions](#permissions)
|
||||
16. [Running by hand for debugging](#running-by-hand-for-debugging)
|
||||
17. [Troubleshooting](#troubleshooting)
|
||||
18. [Tab completion](#tab-completion)
|
||||
19. [Command reference](#command-reference)
|
||||
14. [Exporting and importing](#exporting-and-importing)
|
||||
15. [Backups](#backups)
|
||||
16. [Permissions](#permissions)
|
||||
17. [Running by hand for debugging](#running-by-hand-for-debugging)
|
||||
18. [Troubleshooting](#troubleshooting)
|
||||
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.
|
||||
|
||||
```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.
|
||||
@@ -137,10 +138,10 @@ sudo rm -rf /var/lib/terraria && sudo userdel terraria
|
||||
|
||||
### 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
|
||||
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:
|
||||
@@ -203,7 +204,7 @@ sudo rm /etc/systemd/system/terraria@.service /etc/systemd/system/terraria@.sock
|
||||
sudo rm /usr/local/bin/terraria-instance
|
||||
|
||||
# 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
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
To restore an instance:
|
||||
To restore an instance, import its backup. You can give it back its old name, or a new one:
|
||||
|
||||
```bash
|
||||
sudo tar -xzf /var/lib/terraria/instances/.deleted/calamity-20260928-134422.tar.gz -C /var/lib/terraria/instances
|
||||
sudo chown -R terraria:terraria /var/lib/terraria/instances/calamity
|
||||
sudo terractl import calamity /var/lib/terraria/instances/.deleted/calamity-20260928-134422.tar.gz
|
||||
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`.
|
||||
|
||||
To back up a running server, save it first so the world file on disk is current:
|
||||
`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.
|
||||
|
||||
```bash
|
||||
sudo terractl cmd calamity save
|
||||
sleep 10
|
||||
sudo tar -czf /root/calamity-$(date +%F).tar.gz -C /var/lib/terraria/instances \
|
||||
--exclude=calamity/server calamity
|
||||
sudo terractl export calamity # ./calamity-<date>-<time>.tar.gz
|
||||
sudo terractl export calamity /root/backups/cal.tar.gz # choose the file
|
||||
sudo terractl export calamity --with-server # also include server/ (see below)
|
||||
|
||||
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 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. |
|
||||
| `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. |
|
||||
| `stop` | Only the service | Needs the server's process ID from systemd. |
|
||||
| `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
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -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 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.**
|
||||
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;
|
||||
- `vanilla`/`tmodloader` for `new`;
|
||||
- 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.
|
||||
|
||||
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> remove <Mod>... Uninstall mods
|
||||
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
|
||||
|
||||
Used by the service:
|
||||
terractl prepare <name> Create dirs/configs, unpack zip if needed
|
||||
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
@@ -10,7 +10,9 @@ case "$1" in
|
||||
units=$(systemctl list-units --plain --no-legend --all 'terraria@*.service' | awk '{print $1}')
|
||||
if [ -n "$units" ]; then
|
||||
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
|
||||
;;
|
||||
|
||||
Reference in new issue
Block a user