commit 910b4386861537db3a4336a85627ae20cab9be95 Author: adam Date: Mon Sep 28 16:52:30 2026 +0000 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcaffed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts break with Windows line endings; keep everything LF. +* text=auto eol=lf +*.deb binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93a22b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +*.deb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92db403 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 2.0.0 - 2026-09-28 + +- Renamed the command from `terraria-instance` to `terractl`, and the package from `terraria-instance` to `terraria-server-manager`. The new package replaces the old one automatically; see "Upgrading from the terraria-instance package" in the guide. +- Settings file moved from `/etc/default/terraria-instance` to `/etc/default/terractl`. +- New `terractl --version`. +- New Makefile: `make check`, `make deb`, `make install`, `make uninstall`. +- Restructured the source into a git repository layout. + +## 1.0.4 - 2026-09-28 + +- Fixed `attach`: detaching with Ctrl-D printed "jpid: unbound variable" and left a `journalctl` process running. + +## 1.0.3 - 2026-09-28 + +- The .NET runtime that tModLoader downloads on first start (and its logs) is now kept when an instance is upgraded or re-unpacked. +- New `reinstall --clean` to discard .NET as well and download it again. + +## 1.0.2 - 2026-09-28 + +- New `mods` command: `list`, `add`, `enable`, `disable`, `remove`. Accepts the Windows export folder directly and sets file ownership. +- `new` and `prepare` now create `data/Mods` for tModLoader instances. + +## 1.0.1 - 2026-09-28 + +- Fixed `prepare` failing for tModLoader zips when run as root from a directory the `terraria` user can't enter (e.g. `/root`). + +## 1.0.0 - 2026-09-28 + +- First Debian package. Uses `/var/lib/terraria` instead of `/opt/terraria` and `/terraria`, creates the `terraria` user and directories with sysusers.d and tmpfiles.d, and stops instances cleanly when removed. +- `prepare` and `run` switch to the `terraria` user when started as root, so no files end up owned by root. +- New `delete` command, with an automatic backup. +- `list` no longer aborts when an instance's config can't be read. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0198b23 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +# terraria-server-manager +# +# make check syntax-check the scripts (and shellcheck, if installed) +# make deb build build/terraria-server-manager__all.deb +# sudo make install install directly (default PREFIX=/usr/local), no package +# sudo make uninstall remove what 'make install' installed (keeps your data) +# make clean remove build/ + +NAME := terraria-server-manager +VERSION := $(shell cat VERSION) + +PREFIX ?= /usr/local +SYSCONFDIR ?= /etc +BINDIR := $(PREFIX)/bin +UNITDIR := $(PREFIX)/lib/systemd/system +SYSUSERSDIR := $(PREFIX)/lib/sysusers.d +TMPFILESDIR := $(PREFIX)/lib/tmpfiles.d +DOCDIR := $(PREFIX)/share/doc/$(NAME) +DATADIR := $(PREFIX)/share/$(NAME) + +# Fill in version and install paths +SUBST = sed -e 's|@VERSION@|$(VERSION)|g' -e 's|@BINDIR@|$(BINDIR)|g' -e 's|@DOCDIR@|$(DOCDIR)|g' + +MAINT_SCRIPTS := packaging/deb/postinst packaging/deb/prerm packaging/deb/postrm +STAGE := build/deb-root +DEB := build/$(NAME)_$(VERSION)_all.deb + +.PHONY: all check install uninstall deb clean + +all: check + +check: + bash -n bin/terractl + for f in $(MAINT_SCRIPTS); do sh -n $$f; done + @if command -v shellcheck >/dev/null; then \ + shellcheck bin/terractl $(MAINT_SCRIPTS); \ + else \ + echo "shellcheck not installed; skipped (apt install shellcheck)"; \ + fi + +install: + install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(UNITDIR) $(DESTDIR)$(SYSUSERSDIR) \ + $(DESTDIR)$(TMPFILESDIR) $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) \ + $(DESTDIR)$(SYSCONFDIR)/default + $(SUBST) bin/terractl > $(DESTDIR)$(BINDIR)/terractl + chmod 755 $(DESTDIR)$(BINDIR)/terractl + $(SUBST) systemd/terraria@.service > $(DESTDIR)$(UNITDIR)/terraria@.service + install -m 644 systemd/terraria@.socket $(DESTDIR)$(UNITDIR)/ + install -m 644 config/sysusers.d/terractl.conf $(DESTDIR)$(SYSUSERSDIR)/ + install -m 644 config/tmpfiles.d/terractl.conf $(DESTDIR)$(TMPFILESDIR)/ + install -m 644 docs/guide.md $(DESTDIR)$(DOCDIR)/README.md + install -m 644 tools/windows/export-tmod-mods.ps1 $(DESTDIR)$(DATADIR)/ + @# Never overwrite local settings + @if [ -e $(DESTDIR)$(SYSCONFDIR)/default/terractl ]; then \ + echo "keeping existing $(DESTDIR)$(SYSCONFDIR)/default/terractl"; \ + else \ + install -m 644 config/default/terractl $(DESTDIR)$(SYSCONFDIR)/default/terractl; \ + fi + @# On a real install (not staging for a package), set up the user and directories + @if [ -z "$(DESTDIR)" ]; then \ + systemd-sysusers terractl.conf && \ + systemd-tmpfiles --create terractl.conf && \ + if [ -d /run/systemd/system ]; then systemctl daemon-reload; fi && \ + echo "Installed terractl $(VERSION). See $(DOCDIR)/README.md"; \ + fi + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/terractl \ + $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terraria@.socket \ + $(DESTDIR)$(SYSUSERSDIR)/terractl.conf $(DESTDIR)$(TMPFILESDIR)/terractl.conf + rm -rf $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) + @if [ -z "$(DESTDIR)" ] && [ -d /run/systemd/system ]; then systemctl daemon-reload; fi + @echo "Removed. Kept: $(SYSCONFDIR)/default/terractl, /var/lib/terraria and the terraria user." + +deb: check + rm -rf $(STAGE) + $(MAKE) --no-print-directory install DESTDIR=$(CURDIR)/$(STAGE) PREFIX=/usr + install -d -m 755 $(STAGE)/DEBIAN + install -m 644 packaging/deb/conffiles $(STAGE)/DEBIAN/ + install -m 755 $(MAINT_SCRIPTS) $(STAGE)/DEBIAN/ + sed -e 's|@VERSION@|$(VERSION)|' \ + -e "s|@INSTALLED_SIZE@|$$(du -sk --exclude=DEBIAN $(STAGE) | cut -f1)|" \ + packaging/deb/control > $(STAGE)/DEBIAN/control + find $(STAGE) -type d -exec chmod 755 {} + + dpkg-deb --build --root-owner-group $(STAGE) $(DEB) + +clean: + rm -rf build diff --git a/README.md b/README.md new file mode 100644 index 0000000..c388385 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# terraria-server-manager + +Run any number of vanilla **Terraria** and **tModLoader** dedicated servers on one Linux host. Each server is its own systemd service, and they're all managed with the `terractl` command. + +```bash +sudo terractl new calamity tmodloader +sudo terractl mods calamity add ~/tmod-export +sudo systemctl enable --now terraria@calamity +sudo terractl attach calamity +``` + +## Features + +- **Many servers, one host.** Each instance is `terraria@.service`, with its own directory, port, world and mods. +- **Vanilla and tModLoader side by side**, chosen per instance. +- **No manual installs.** Drop the official server zips into `/var/lib/terraria/dist`; they're unpacked on demand, and each instance can be pinned to a specific version. +- **Safe shutdowns.** Stopping a server saves the world first. +- **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. +- **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. + +## Requirements + +- Debian 12+ or Ubuntu 22.04+ (other systemd-based distributions should work with `make install`) +- `unzip`, `systemd`; for tModLoader, also `curl` and ICU (`libicu`) + +## Install + +Download the `.deb` from the releases page, then: + +```bash +sudo apt install ./terraria-server-manager__all.deb +``` + +Or install from source, without a package: + +```bash +sudo make install +``` + +## Documentation + +See [docs/guide.md](docs/guide.md). It covers installation, creating instances, the console, configuration, mods, versions and upgrades, permissions and troubleshooting. Installed systems also have it at `/usr/share/doc/terraria-server-manager/README.md`. + +## Repository layout + +``` +bin/terractl The command-line tool +systemd/ terraria@.service and terraria@.socket +config/ /etc/default settings, sysusers.d and tmpfiles.d entries +packaging/deb/ Debian package metadata and install/remove scripts +docs/guide.md Full documentation +tools/windows/ export-tmod-mods.ps1 for exporting mods from Windows +VERSION Current version, used by the build +Makefile make check | deb | install | uninstall | clean +``` + +## Building + +```bash +make check # bash -n on all scripts, plus shellcheck if installed +make deb # build/terraria-server-manager__all.deb +``` + +To release: update `VERSION` and `CHANGELOG.md`, run `make deb`, and attach the `.deb` to the release. + +## Disclaimer + +This is an unofficial community tool. It is not affiliated with or endorsed by Re-Logic or the tModLoader team. It does not include any Terraria or tModLoader software; you supply the official server downloads. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..227cea2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/bin/terractl b/bin/terractl new file mode 100755 index 0000000..eccfa68 --- /dev/null +++ b/bin/terractl @@ -0,0 +1,601 @@ +#!/usr/bin/env bash +# terractl - helper for terraria@.service +# +# Layout: +# /var/lib/terraria/dist/ server zips (read-only source) +# terraria-server-*.zip vanilla dedicated server zips from terraria.org +# tModLoader*.zip tModLoader release zips (GitHub) +# /var/lib/terraria/instances// +# instance.conf TYPE (vanilla|tmodloader), ZIP pin, EXTRA_ARGS +# serverconfig.txt normal Terraria server config (port, world, ...) +# server/ unpacked server binaries (replaceable) +# data/ worlds, mods, player data (kept across reinstalls) +set -euo pipefail + +# All paths used are absolute. Start from / so nothing depends on the caller's +# directory (which the 'terraria' user may not be allowed into, e.g. /root). +cd / + +# Local overrides (see the comments in that file) +# shellcheck disable=SC1091 +if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi + +BASE_DIR="${TERRARIA_BASE:-/var/lib/terraria/instances}" +DIST_DIR="${TERRARIA_DIST:-/var/lib/terraria/dist}" +RUN_DIR="/run/terraria" +SVC_USER="${TERRARIA_USER:-terraria}" +STOP_TIMEOUT="${TERRARIA_STOP_TIMEOUT:-80}" # keep below TimeoutStopSec +TERRACTL_VERSION="@VERSION@" # filled in by 'make install' / 'make deb' + +die() { echo "terractl: $*" >&2; exit 1; } +log() { echo "terractl: $*" >&2; } + +usage() { + cat < [args] + terractl --version + +Admin commands: + new [vanilla|tmodloader] [port] create an instance (run as root) + list show instances, type, port, state + attach interactive console (Ctrl-D to detach) + cmd send a console command (e.g. save, say hi) + reinstall [--clean] re-unpack server on next start + (--clean also re-downloads tModLoader's .NET) + mods [list] show installed mods and whether they're enabled + mods add ... install mods (and ModConfigs) and enable them + mods enable|disable ... change which installed mods load + mods remove ... uninstall mods + delete [-y] [--no-backup] stop, disable and remove an instance + (worlds/configs archived to $BASE_DIR/.deleted/) + +Used by terraria@.service: + prepare create dirs/configs, unpack server if needed + run exec the server + stop graceful shutdown via console "exit" +EOF + exit "${1:-1}" +} + +valid_name() { + # Must not start with '.', so '.', '..' and hidden dirs can never be targeted + [[ ${1:-} =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "invalid instance name '${1:-}'" +} + +inst_dir() { echo "$BASE_DIR/$1"; } + +load_conf() { + local dir; dir=$(inst_dir "$1") + TYPE=vanilla + ZIP="" + EXTRA_ARGS=() + # shellcheck disable=SC1091 + [[ -f $dir/instance.conf ]] && source "$dir/instance.conf" + case $TYPE in + vanilla|tmodloader) ;; + *) die "TYPE in $dir/instance.conf must be 'vanilla' or 'tmodloader' (got '$TYPE')" ;; + esac +} + +write_instance_conf() { + local name=$1 type=$2 dir; dir=$(inst_dir "$name") + cat > "$dir/instance.conf" < "$dir/serverconfig.txt" </dev/null || true; fi + return 0 +} +trap cleanup EXIT + +install_server() { + local name=$1 dir marker zip src have_type="" have_zip="" + dir=$(inst_dir "$name") + marker="$dir/server/.installed" + + # Already installed with the right type (and pinned zip, if any)? Nothing to do. + if [[ -f $marker ]]; then + read -r have_type have_zip < "$marker" || true + if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then + return 0 + fi + fi + + command -v unzip >/dev/null || die "unzip is not installed" + zip=$(pick_zip) + [[ -r $zip ]] || die "cannot read $zip" + log "installing $TYPE from $(basename "$zip") into $dir/server" + + EXTRACT_TMP=$(mktemp -d "$dir/.extract.XXXXXX") + unzip -o -q "$zip" -d "$EXTRACT_TMP" + + case $TYPE in + vanilla) + # terraria.org zips contain /{Linux,Windows,Mac}; we only want Linux + src=$(find "$EXTRACT_TMP" -type f -name TerrariaServer.bin.x86_64 -path '*/Linux/*' -printf '%h\n' -quit) + [[ -n $src ]] || die "Linux/TerrariaServer.bin.x86_64 not found in $zip" + chmod +x "$src/TerrariaServer.bin.x86_64" + [[ -f $src/TerrariaServer ]] && chmod +x "$src/TerrariaServer" + ;; + tmodloader) + src=$(find "$EXTRACT_TMP" -type f -name start-tModLoaderServer.sh -printf '%h\n' -quit) + [[ -n $src ]] || die "start-tModLoaderServer.sh not found in $zip" + find "$src" -type f -name '*.sh' -exec chmod +x {} + + ;; + esac + + # tModLoader releases don't include .NET; it downloads it into server/dotnet on + # first start. Carry that (and the logs) over so an upgrade or reinstall doesn't + # download it again. tModLoader checks the .NET version itself on every start + # and installs a different one if the new release needs it. + if [[ $TYPE == tmodloader && -d $dir/server ]]; then + local keep + for keep in dotnet tModLoader-Logs; do + if [[ -d $dir/server/$keep && ! -e $src/$keep ]]; then + mv "$dir/server/$keep" "$src/$keep" + log "kept existing server/$keep" + fi + done + fi + + rm -rf "$dir/server" + mv "$src" "$dir/server" + echo "$TYPE $(basename "$zip")" > "$dir/server/.installed" + cleanup + log "install complete" +} + +cmd_prepare() { + local name=$1 dir; dir=$(inst_dir "$name") + mkdir -p "$dir/data/Worlds" + [[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla + load_conf "$name" + if [[ $TYPE == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi + [[ -f $dir/serverconfig.txt ]] || write_server_config "$name" + install_server "$name" +} + +cmd_run() { + local name=$1 dir; dir=$(inst_dir "$name") + load_conf "$name" + [[ -f $dir/server/.installed ]] || die "server not installed; run 'prepare' first" + # Per-instance HOME so nothing is shared through ~/.local or ~/.dotnet + export HOME="$dir" + cd "$dir/server" + case $TYPE in + vanilla) + exec ./TerrariaServer.bin.x86_64 -config "$dir/serverconfig.txt" "${EXTRA_ARGS[@]}" + ;; + tmodloader) + # -nosteam skips the interactive "use steam server?" prompt. + # -tmlsavedirectory keeps worlds, Mods/ and configs under data/. + # First start downloads a matching .NET runtime into server/dotnet. + exec ./start-tModLoaderServer.sh -nosteam \ + -config "$dir/serverconfig.txt" \ + -tmlsavedirectory "$dir/data" \ + "${EXTRA_ARGS[@]}" + ;; + esac +} + +cmd_stop() { + local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i + if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi + if [[ -p $fifo ]]; then + echo exit > "$fifo" + else + log "console FIFO missing; systemd will send SIGTERM" + exit 0 + fi + for ((i = 0; i < STOP_TIMEOUT; i++)); do + kill -0 "$pid" 2>/dev/null || exit 0 + sleep 1 + done + log "server did not exit within ${STOP_TIMEOUT}s; leaving it to systemd" +} + +cmd_cmd() { + local name=$1; shift + [[ $# -gt 0 ]] || die "no console command given" + local fifo="$RUN_DIR/$name.stdin" + [[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?" + printf '%s\n' "$*" > "$fifo" + echo "sent. Output: journalctl -u terraria@$name -n 20" +} + +cmd_attach() { + local name=$1 fifo="$RUN_DIR/$1.stdin" line + [[ -p $fifo ]] || die "$fifo not found - is terraria@$name running?" + [[ -w $fifo ]] || die "no write access to $fifo (run with sudo or join group '$SVC_USER')" + + echo "Attached to terraria@$name. Ctrl-D or Ctrl-C detaches; the server keeps running." + # Server output comes from the journal; -o cat drops the timestamp prefixes. + journalctl -u "terraria@$name" -f -n 30 -o cat & + ATTACH_PID=$! # killed by cleanup() however attach ends + trap 'echo; exit 0' INT + + # -e gives line editing and history while typing commands + while IFS= read -r -e line; do + [[ -z $line ]] && continue + if [[ $line == exit ]]; then + echo "(not sent: 'exit' would shut the server down. Use Ctrl-D to detach," \ + "or 'systemctl stop terraria@$name' to stop it.)" + continue + fi + printf '%s\n' "$line" > "$fifo" + done + echo +} + +cmd_delete() { + local name=$1; shift + local assume_yes=0 backup=1 dir archive answer + while [[ $# -gt 0 ]]; do + case $1 in + -y|--yes) assume_yes=1 ;; + --no-backup) backup=0 ;; + *) die "unknown option '$1'" ;; + esac + shift + done + + [[ $EUID -eq 0 ]] || die "delete must be run as root" + dir=$(inst_dir "$name") + [[ -d $dir ]] || die "$dir does not exist" + # Refuse to remove anything that doesn't look like one of our instances + [[ -f $dir/instance.conf ]] || die "$dir has no instance.conf; not deleting it" + + if [[ $assume_yes -ne 1 ]]; then + [[ -t 0 ]] || die "not a terminal; use -y to delete without confirmation" + echo "This will stop terraria@$name and remove $dir." + if [[ $backup -eq 1 ]]; then + echo "Worlds and configs will be archived to $BASE_DIR/.deleted/ first." + else + echo "NO BACKUP will be made. Worlds will be lost." + fi + read -r -p "Type the instance name to confirm: " answer + [[ $answer == "$name" ]] || die "confirmation did not match; nothing deleted" + fi + + if command -v systemctl >/dev/null; then + systemctl disable --now "terraria@$name.service" 2>/dev/null || true + systemctl stop "terraria@$name.socket" 2>/dev/null || true + systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true + fi + + if [[ $backup -eq 1 ]]; then + mkdir -p "$BASE_DIR/.deleted" + chmod 700 "$BASE_DIR/.deleted" + archive="$BASE_DIR/.deleted/$name-$(date +%Y%m%d-%H%M%S).tar.gz" + # server/ is excluded: it's just the unpacked zip and can be recreated + tar -czf "$archive" -C "$BASE_DIR" \ + --exclude="$name/server" --exclude="$name/.extract.*" "$name" + echo "backup: $archive" + fi + + rm -rf -- "$dir" + echo "deleted instance '$name'" +} + +# ---------------------------------------------------------------- mods ---- +# enabled.json is a plain JSON array of mod names, e.g. ["CalamityMod","MagicStorage"]. +# Mod names are identifiers, so simple text handling is enough (no jq needed). + +fix_owner() { if [[ $EUID -eq 0 ]]; then chown "$SVC_USER:$SVC_USER" "$@"; fi; } + +read_enabled() { + local f="$1/enabled.json" + [[ -f $f ]] || return 0 + grep -o '"[^"]*"' "$f" | tr -d '"' || true +} + +write_enabled() { # write_enabled ... + local mdir=$1; shift + local out="[" sep="" n + for n in "$@"; do out+="$sep"$'\n'" \"$n\""; sep=","; done + if [[ $# -gt 0 ]]; then out+=$'\n'; fi + out+="]" + printf '%s\n' "$out" > "$mdir/.enabled.json.tmp" + mv "$mdir/.enabled.json.tmp" "$mdir/enabled.json" + fix_owner "$mdir/enabled.json" +} + +mods_restart_hint() { + if systemctl is-active --quiet "terraria@$name" 2>/dev/null; then + echo "Restart to apply: sudo systemctl restart terraria@$name" + fi +} + +mods_list() { + local enabled f n st + enabled=$(read_enabled "$mdir") + local -a files=() + for f in "$mdir"/*.tmod; do + if [[ -f $f ]]; then files+=("$f"); fi + done + if [[ ${#files[@]} -eq 0 ]]; then + echo "No mods installed in $mdir" + echo "Add some with: terractl mods $name add " + return 0 + fi + printf '%-32s %s\n' MOD STATE + for f in "${files[@]}"; do + n=$(basename "$f" .tmod) + if grep -qxF "$n" <<<"$enabled"; then st=enabled; else st=disabled; fi + printf '%-32s %s\n' "$n" "$st" + done + for n in $enabled; do + if [[ ! -f $mdir/$n.tmod ]]; then printf '%-32s %s\n' "$n" "enabled but MISSING (server will fail to load mods)"; fi + done +} + +mods_add() { + [[ $# -gt 0 ]] || die "usage: terractl mods $name add ..." + local src f n found + local -a files=() cfgs=() + for src in "$@"; do + if [[ -f $src ]]; then + [[ $src == *.tmod ]] || die "$src is not a .tmod file" + files+=("$src") + elif [[ -d $src ]]; then + # Accepts a folder of .tmod files, or an export folder containing Mods/ and ModConfigs/ + found=0 + for f in "$src"/*.tmod "$src"/Mods/*.tmod; do + if [[ -f $f ]]; then files+=("$f"); found=1; fi + done + [[ $found -eq 1 ]] || die "no .tmod files in $src or $src/Mods" + for f in "$src"/ModConfigs/*.json; do + if [[ -f $f ]]; then cfgs+=("$f"); fi + done + else + die "$src not found" + fi + done + + local -A pick=() + for f in "${files[@]}"; do + n=$(basename "$f" .tmod) + [[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "unexpected mod file name '$n.tmod'" + if [[ -n ${pick[$n]:-} ]]; then log "warning: $n given more than once; using $f"; fi + pick[$n]=$f + done + + mkdir -p "$mdir" + fix_owner "$dir/data" "$mdir" + for n in "${!pick[@]}"; do + install -m 0640 "${pick[$n]}" "$mdir/$n.tmod" + fix_owner "$mdir/$n.tmod" + echo "installed $n" + done + + if [[ ${#cfgs[@]} -gt 0 ]]; then + mkdir -p "$dir/data/ModConfigs" + fix_owner "$dir/data/ModConfigs" + for f in "${cfgs[@]}"; do + install -m 0640 "$f" "$dir/data/ModConfigs/$(basename "$f")" + fix_owner "$dir/data/ModConfigs/$(basename "$f")" + done + echo "installed ${#cfgs[@]} mod config file(s)" + fi + + local -a current=() + mapfile -t current < <(read_enabled "$mdir") + # shellcheck disable=SC2046 + write_enabled "$mdir" $(printf '%s\n' "${current[@]}" "${!pick[@]}" | sort -u) + echo "enabled: ${!pick[*]}" + echo "Note: dependencies are not checked here; if one is missing, the server log will say which." + mods_restart_hint +} + +mods_set() { # mods_set 1|0 ... + local on=$1; shift + [[ $# -gt 0 ]] || die "no mod names given" + local n list + local -a current=() + mapfile -t current < <(read_enabled "$mdir") + if [[ $on -eq 1 ]]; then + for n in "$@"; do + [[ -f $mdir/$n.tmod ]] || die "$n is not installed (no $mdir/$n.tmod)" + done + list=$(printf '%s\n' "${current[@]}" "$@" | sort -u) + else + list=$(printf '%s\n' "${current[@]}" | grep -vxF -f <(printf '%s\n' "$@") || true) + fi + mkdir -p "$mdir" + # shellcheck disable=SC2086 + write_enabled "$mdir" $list +} + +mods_remove() { + [[ $# -gt 0 ]] || die "no mod names given" + local n + for n in "$@"; do + [[ $n =~ ^[A-Za-z0-9_]+$ ]] || die "invalid mod name '$n'" + if [[ -f $mdir/$n.tmod ]]; then rm -f "$mdir/$n.tmod"; echo "removed $n"; else log "$n was not installed"; fi + done + mods_set 0 "$@" +} + +cmd_mods() { + local name=$1; shift + local action=${1:-list} + if [[ $# -gt 0 ]]; then shift; fi + local dir mdir + dir=$(inst_dir "$name") + [[ -f $dir/instance.conf ]] || die "no instance '$name' (create it with: terractl new $name tmodloader)" + load_conf "$name" + [[ $TYPE == tmodloader ]] || die "'$name' is a $TYPE instance; mods need TYPE=tmodloader" + mdir="$dir/data/Mods" + case $action in + list) mods_list ;; + add) mods_add "$@" ;; + enable) mods_set 1 "$@"; echo "enabled: $*"; mods_restart_hint ;; + disable) mods_set 0 "$@"; echo "disabled: $*"; mods_restart_hint ;; + remove) mods_remove "$@"; mods_restart_hint ;; + *) die "unknown mods action '$action' (list, add, enable, disable, remove)" ;; + esac +} + +cmd_new() { + local name=$1 type=${2:-vanilla} port=${3:-} dir + [[ $type == vanilla || $type == tmodloader ]] || die "type must be vanilla or tmodloader" + [[ -z $port || $port =~ ^[0-9]+$ ]] || die "port must be a number" + dir=$(inst_dir "$name") + [[ -e $dir ]] && die "$dir already exists" + mkdir -p "$dir/data/Worlds" + if [[ $type == tmodloader ]]; then mkdir -p "$dir/data/Mods"; fi + write_instance_conf "$name" "$type" + write_server_config "$name" "$port" + if [[ $EUID -eq 0 ]]; then chown -R "$SVC_USER:$SVC_USER" "$dir"; fi + cat < (before first start)" + fi + cat </dev/null | tail -n1) + state=$(systemctl is-active "terraria@$n" 2>/dev/null || true) + printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state" + ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)" + done +} + +cmd_reinstall() { + local name=$1 clean=0 dir; dir=$(inst_dir "$name") + shift + case ${1:-} in + --clean) clean=1 ;; + "") ;; + *) die "unknown option '$1'" ;; + esac + [[ -f $dir/instance.conf ]] || die "no instance '$name'" + rm -f "$dir/server/.installed" + if [[ $clean -eq 1 ]]; then + rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs" + echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again" + fi + echo "server/ will be re-unpacked on next start: systemctl restart terraria@$name" +} + +[[ $# -ge 1 ]] || usage +command=$1; shift +case $command in + -h|--help|help) usage 0 ;; + -V|--version|version) + if [[ $TERRACTL_VERSION == "@"VERSION"@" ]]; then echo "terractl (development copy)" + else echo "terractl $TERRACTL_VERSION"; fi + exit 0 ;; + list) cmd_list; exit ;; +esac +[[ $# -ge 1 ]] || usage +valid_name "$1" + +# prepare/run create and modify instance files. If invoked as root (e.g. while +# debugging), switch to the service user so nothing ends up owned by root. +if [[ $command == prepare || $command == run ]]; then + if [[ $EUID -eq 0 ]]; then + command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead" + exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \ + "$(readlink -f "$0")" "$command" "$@" + elif [[ $(id -un) != "$SVC_USER" ]]; then + die "$command must run as root or '$SVC_USER'" + fi +fi + +case $command in + prepare) cmd_prepare "$1" ;; + run) cmd_run "$1" ;; + stop) cmd_stop "$@" ;; + cmd) cmd_cmd "$@" ;; + attach) cmd_attach "$1" ;; + mods) cmd_mods "$@" ;; + new) cmd_new "$@" ;; + reinstall) cmd_reinstall "$@" ;; + delete) cmd_delete "$@" ;; + *) usage ;; +esac diff --git a/config/default/terractl b/config/default/terractl new file mode 100644 index 0000000..a6041e4 --- /dev/null +++ b/config/default/terractl @@ -0,0 +1,14 @@ +# Overrides for terractl. Uncomment to change. +# Changes apply to new commands and to each instance's next start. + +# Where instances live. If you change this, the service must also be allowed +# to write there: sudo systemctl edit terraria@.service +# [Service] +# ReadWritePaths=/your/new/path +#TERRARIA_BASE=/var/lib/terraria/instances + +# Where server zips are read from +#TERRARIA_DIST=/var/lib/terraria/dist + +# Seconds to wait for a server to save and exit on stop (keep below 90) +#TERRARIA_STOP_TIMEOUT=80 diff --git a/config/sysusers.d/terractl.conf b/config/sysusers.d/terractl.conf new file mode 100644 index 0000000..3c0b7f7 --- /dev/null +++ b/config/sysusers.d/terractl.conf @@ -0,0 +1,2 @@ +# System user the Terraria servers run as +u terraria - "Terraria game servers" /var/lib/terraria /usr/sbin/nologin diff --git a/config/tmpfiles.d/terractl.conf b/config/tmpfiles.d/terractl.conf new file mode 100644 index 0000000..9969798 --- /dev/null +++ b/config/tmpfiles.d/terractl.conf @@ -0,0 +1,5 @@ +# No age field: nothing here is ever cleaned up automatically. +d /var/lib/terraria 0750 terraria terraria - +d /var/lib/terraria/instances 0750 terraria terraria - +# setgid + group-writable: members of the 'terraria' group can upload zips +d /var/lib/terraria/dist 2775 root terraria - diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..b5cf466 --- /dev/null +++ b/docs/guide.md @@ -0,0 +1,698 @@ +# terraria-server-manager + +Run any number of vanilla Terraria and tModLoader dedicated servers on one Linux host, each managed as its own systemd service, with the `terractl` command. + +## Contents + +1. [Overview](#overview) +2. [Components](#components) +3. [Directory layout](#directory-layout) +4. [Installation](#installation) +5. [Migrating from the manual install](#migrating-from-the-manual-install) +6. [Server zips](#server-zips) +7. [Creating an instance](#creating-an-instance) +8. [Starting, stopping and boot](#starting-stopping-and-boot) +9. [The server console](#the-server-console) +10. [Configuration](#configuration) +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. [Command reference](#command-reference) + +--- + +## Overview + +Each server is an **instance** with a short name such as `survival` or `calamity`. An instance is: + +- a directory, `/var/lib/terraria/instances//`, holding everything about that server; +- a systemd service, `terraria@.service`, that runs it as the unprivileged `terraria` user. + +Server software is never installed by hand. You drop the official zips into `/var/lib/terraria/dist`, and the first time an instance starts, the right zip is unpacked into that instance's `server/` folder. Worlds, mods and settings live separately in `data/`, so server software can be replaced or upgraded without touching them. + +Each instance chooses between **vanilla** and **tModLoader** independently, so both kinds can run side by side on different ports. + +--- + +## Components + +Everything is installed by the `terraria-server-manager` package: + +| File | Purpose | +|---|---| +| `/usr/lib/systemd/system/terraria@.service` | Template service. One copy runs per instance. | +| `/usr/lib/systemd/system/terraria@.socket` | Creates the console pipe the server reads commands from. | +| `/usr/bin/terractl` | Helper script. The service calls it to set up, run and stop servers; you use it for everyday management. | +| `/etc/default/terractl` | Optional overrides (locations, stop timeout). | +| `/usr/lib/sysusers.d/terractl.conf` | Defines the `terraria` system user. | +| `/usr/lib/tmpfiles.d/terractl.conf` | Creates `/var/lib/terraria` and its subdirectories. | +| `/usr/share/terraria-server-manager/export-tmod-mods.ps1` | Optional. Copy to your Windows PC to collect your enabled tModLoader mods. | + +What happens when an instance starts: + +1. systemd creates the console pipe `/run/terraria/.stdin` (from the socket unit). +2. `terractl prepare ` creates the instance directory and default configs if they're missing, and unpacks the server zip if it hasn't been unpacked yet. +3. `terractl run ` starts the vanilla or tModLoader server, reading commands from the pipe and logging to the journal. + +On stop, `terractl stop` sends `exit` through the pipe so the world is saved before the server shuts down. + +--- + +## Directory layout + +``` +/var/lib/terraria/ Home of the 'terraria' user +├── dist/ Server zips (you add these; never modified) +│ ├── terraria-server-1458.zip +│ └── tModLoader-v2026.07.3.0.zip +└── instances/ + ├── / One directory per instance + │ ├── instance.conf Which server type and zip to use + │ ├── serverconfig.txt Terraria's own settings (port, world, password...) + │ ├── server/ Unpacked server software (replaceable) + │ │ └── .installed Records what was unpacked + │ └── data/ Your data (never touched by upgrades) + │ ├── Worlds/ World files + │ ├── Mods/ tModLoader mods + enabled.json + │ ├── ModConfigs/ tModLoader mod settings + │ └── banlist.txt + └── .deleted/ Backups made by 'terractl delete' + +/run/terraria/.stdin Console pipe (exists only while running) +``` + +The server can write only inside `instances/`. It reads `dist/` but can never change it. + +--- + +## Installation + +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 +``` + +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. + +Installing the package: + +- creates the `terraria` system user and group; +- creates `/var/lib/terraria/`, `/var/lib/terraria/instances/` and `/var/lib/terraria/dist/`; +- installs the units and helper script. + +It doesn't start or enable anything. + +If you use a firewall, open each instance's port for **TCP**. Terraria uses TCP only. + +```bash +sudo ufw allow 7777/tcp # example with ufw +``` + +### Upgrading the package + +```bash +sudo apt install ./terraria-server-manager__all.deb +``` + +Running servers are not restarted. Each one picks up the new version the next time it starts. Your changes to `/etc/default/terractl` are kept; if the new package also changes that file, dpkg asks which version to keep. + +### Removing the package + +```bash +sudo apt remove terraria-server-manager # stops all instances (worlds are saved first) +sudo apt purge terraria-server-manager # also removes config and boot-time enablement +``` + +Neither command deletes `/var/lib/terraria` or the `terraria` user, so your worlds survive even a purge. To delete everything: + +```bash +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: + +```bash +sudo apt install ./terraria-server-manager_2.0.0_all.deb +``` + +Things to know: + +- **Instances are stopped (and saved) during the switch**, because removing the old package stops them. Start them again afterwards with `sudo systemctl start terraria@`. Instances, worlds, mods and the `terraria` user all carry over unchanged. +- **The command is now `terractl`.** Update any scripts or cron jobs that call `terraria-instance`. + +**Clean up the old package straight after upgrading.** The old package is removed, but dpkg still keeps a record of it because its settings file is left behind. Purging that record, including with a general cleanup like `apt purge ~c` or `apt autoremove --purge`, runs the old package's purge step. Because the instances have the same unit names, that step deletes their start-at-boot links. So purge it deliberately, then re-enable the instances: + +```bash +# 1. If you customised it, copy the old settings file across (purging deletes it) +sudo cp /etc/default/terraria-instance /etc/default/terractl + +# 2. Note which instances start at boot, purge the old package, re-enable them +enabled=$(ls /etc/systemd/system/multi-user.target.wants/ | grep '^terraria@') +sudo apt purge terraria-instance +for unit in $enabled; do sudo systemctl enable "$unit"; done +``` + +### Building from source + +From the git repository: + +```bash +make check # syntax-check the scripts (and shellcheck, if installed) +make deb # builds build/terraria-server-manager__all.deb +sudo make install # or install directly without a package (PREFIX=/usr/local) +sudo make uninstall # removes a 'make install' (keeps settings and data) +``` + +The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/control` to yourself before publishing packages. + +### Changing locations + +`/etc/default/terractl` can move the instances or zips directory. If you move the instances directory, the service also needs permission to write to the new location: + +```bash +sudo systemctl edit terraria@.service +``` +```ini +[Service] +ReadWritePaths=/your/new/path +``` + +--- + +## Migrating from the manual install + +The earlier manual setup used `/opt/terraria`, `/terraria`, and a script called `terraria-instance` in `/usr/local/bin`. To move to the package: + +```bash +# 1. Note which instances start at boot, then stop everything +ls /etc/systemd/system/multi-user.target.wants/ | grep terraria@ +sudo systemctl stop 'terraria@*' + +# 2. Remove the old files. These would override the package's versions: +# /etc/systemd/system takes precedence over /usr/lib/systemd/system, and +# /usr/local/bin comes before /usr/bin in PATH. +sudo rm /etc/systemd/system/terraria@.service /etc/systemd/system/terraria@.socket +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 + +# 4. Move instances and zips +for d in /opt/terraria/*/; do + [ -f "$d/instance.conf" ] && sudo mv "$d" /var/lib/terraria/instances/ +done +[ -d /opt/terraria/.deleted ] && sudo mv /opt/terraria/.deleted /var/lib/terraria/instances/ +sudo mv /terraria/*.zip /var/lib/terraria/dist/ + +# 5. serverconfig.txt contains absolute paths; update them +sudo sed -i 's#/opt/terraria/#/var/lib/terraria/instances/#g' \ + /var/lib/terraria/instances/*/serverconfig.txt +sudo chown -R terraria:terraria /var/lib/terraria/instances + +# 6. Re-enable instances (the old boot links point at the deleted unit file) +sudo systemctl reenable terraria@calamity # repeat for each instance from step 1 +sudo systemctl start terraria@calamity +``` + +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`. + +--- + +## Server zips + +Put the official downloads in `/var/lib/terraria/dist`: + +| Type | File name pattern | Where to get it | +|---|---|---| +| Vanilla | `terraria-server-*.zip` | terraria.org (the "PC Dedicated Server" download link) | +| tModLoader | `tModLoader*.zip` | tModLoader's GitHub releases page (`tModLoader.zip`) | + +**Rename tModLoader zips to include the version**, for example `tModLoader-v2026.07.3.0.zip`. GitHub always calls the file `tModLoader.zip`, so renaming it lets you keep several versions side by side and pin instances to specific ones. + +Zips are only read, never changed, and one zip can serve any number of instances. + +**tModLoader zips don't include .NET.** Each tModLoader instance downloads the .NET runtime it needs into `server/dotnet/` on its first start, so that start needs internet access and `curl`. The download is kept when you upgrade or re-unpack the instance. tModLoader checks the .NET version on every start and fetches a different one itself if a new release needs it. + +Members of the `terraria` group can copy zips into `dist/` without `sudo`: + +```bash +scp tModLoader-v2026.07.3.0.zip you@yourserver:/var/lib/terraria/dist/ +``` + +--- + +## Creating an instance + +```bash +sudo terractl new [vanilla|tmodloader] [port] +``` + +Examples: + +```bash +sudo terractl new survival # vanilla, next free port +sudo terractl new calamity tmodloader # tModLoader, next free port +sudo terractl new pvp vanilla 7800 # vanilla on port 7800 +``` + +If you don't give a port, the instance gets the next number after the highest port already used, starting at 7777. + +This creates `/var/lib/terraria/instances//` with `instance.conf` and `serverconfig.txt`, but **does not start the server**. Before the first start, review `serverconfig.txt` (world size, difficulty, password). For a tModLoader instance, also add your mods first (see [Mods](#mods-tmodloader)): the world is generated on first start, and it should be generated with the mods loaded so their world content is created. + +Instance names may contain letters, digits, `_`, `-` and `.`, and must not start with `.`. + +### Quick start: a new modded server + +```bash +sudo terractl new calamity tmodloader +sudo terractl mods calamity add ~/tmod-export # your mods, before the first start +sudo nano /var/lib/terraria/instances/calamity/serverconfig.txt # optional: world size, difficulty +sudo systemctl enable --now terraria@calamity +sudo terractl attach calamity # watch it load and generate the world +``` + +You can also skip `new` and just run `sudo systemctl start terraria@`. That creates a **vanilla** instance with default settings; to switch it to tModLoader, change `TYPE` in its `instance.conf` and restart. + +--- + +## Starting, stopping and boot + +Instances are ordinary systemd services: + +```bash +sudo systemctl start terraria@calamity +sudo systemctl stop terraria@calamity # saves the world, then exits +sudo systemctl restart terraria@calamity +sudo systemctl enable terraria@calamity # start at boot +sudo systemctl disable terraria@calamity # don't start at boot +sudo systemctl enable --now terraria@calamity # both at once +systemctl status terraria@calamity +``` + +To see all instances with their type, port and state: + +```bash +sudo terractl list +``` + +Behaviour to be aware of: + +- **Crashes restart automatically** after 10 seconds. +- **A clean exit does not restart.** If you type `exit` in the server console, the server saves and stops, and stays stopped until you start it again. +- **The first start of a tModLoader instance is slow.** It downloads .NET, loads the mods and generates the world, which can take several minutes. +- **Stopping waits for the save.** The server gets up to 80 seconds to save and exit before systemd forces it to stop. + +--- + +## The server console + +Each server reads console commands (`save`, `say`, `kick`, `playing`, and so on) from its pipe, and its output goes to the systemd journal. + +### Interactive: `attach` + +```bash +sudo terractl attach calamity +``` + +This shows the last 30 lines of output and then follows it live. Anything you type is sent to the server, with arrow-key editing and history. **Ctrl-D** or **Ctrl-C** detaches, and the server keeps running. + +`attach` will not send `exit`, since that would stop the server. Use `systemctl stop` to stop it. + +The one visible difference from a real terminal: the server's `:` prompt only appears once a newline follows it. + +### One-off commands: `cmd` + +```bash +sudo terractl cmd calamity save +sudo terractl cmd calamity say Restarting in 5 minutes +``` + +This is useful in scripts and cron jobs. The command's output appears in the journal. + +### Logs only + +```bash +journalctl -u terraria@calamity -f # follow +journalctl -u terraria@calamity -n 100 # last 100 lines +journalctl -u terraria@calamity --since today +``` + +tModLoader also writes its own logs to `/var/lib/terraria/instances//server/tModLoader-Logs/`: + +- `Launch.log` covers the .NET download and startup. +- `server.log` covers mod loading and gameplay. + +--- + +## Configuration + +Each instance has two config files for two different purposes. + +### `serverconfig.txt`: Terraria's settings + +This is Terraria's standard server config, read by the game itself. The generated default: + +```ini +world=/var/lib/terraria/instances//data/Worlds/.wld +worldpath=/var/lib/terraria/instances//data/Worlds +autocreate=2 # create world if missing: 1=small 2=medium 3=large +worldname= +difficulty=0 # 0=classic 1=expert 2=master 3=journey +#seed= +port=7777 +maxplayers=8 +password= +motd=Welcome to +secure=1 +upnp=0 +language=en-US +banlist=/var/lib/terraria/instances//data/banlist.txt +``` + +`autocreate`, `worldname`, `difficulty` and `seed` only matter when the world file doesn't exist yet. Once the world has been created, changing them has no effect. + +Changes take effect on restart. Every instance must use a different `port`. + +To use an existing world, copy the `.wld` file (plus `.twld` for tModLoader worlds) into `data/Worlds/` and point `world=` at it. + +### `instance.conf`: how the instance runs + +The game never reads this file. The helper script reads it at every start. + +```bash +TYPE=tmodloader # vanilla | tmodloader +ZIP= # zip to use; blank = newest matching zip at first install +EXTRA_ARGS=() # extra server arguments, as a bash array +``` + +| Setting | Effect | +|---|---| +| `TYPE` | Which server software runs. Changing it re-unpacks `server/` from the other kind of zip on the next start. `data/` and `serverconfig.txt` are kept. | +| `ZIP` | Pins a specific zip from `/var/lib/terraria/dist` (a file name, or an absolute path). Changing it re-unpacks `server/` on the next start. If left blank, the newest matching zip is used at first install and **never upgraded automatically**. | +| `EXTRA_ARGS` | Extra command-line arguments, e.g. `EXTRA_ARGS=(-disableannouncementbox)`. | + +This file also marks the directory as an instance: `list` ignores directories without one, and `delete` refuses to remove them. + +--- + +## Mods (tModLoader) + +Mods only work on tModLoader instances. Each instance has its own separate set of mods: + +``` +/var/lib/terraria/instances//data/Mods/ +├── enabled.json ["CalamityMod", "MagicStorage"] +├── CalamityMod.tmod +└── MagicStorage.tmod +``` + +`enabled.json` lists which mods to load, using each file's name without `.tmod`. Every mod's dependencies must also be present and listed. Mod settings live in `data/ModConfigs/`. + +You don't need to edit these by hand: `terractl mods` manages them and sets the file ownership correctly (see [Managing mods](#managing-mods)). The `Mods` folder is created automatically for tModLoader instances. + +Players don't need to install mods themselves: tModLoader offers to download the server's mods when they join. + +### Exporting mods from Windows + +tModLoader on Windows keeps its files in these places: + +| What | Where | +|---|---| +| Which mods are enabled | `Documents\My Games\Terraria\tModLoader\Mods\enabled.json` | +| Workshop mods | `\steamapps\workshop\content\1281930\\` | +| Manually installed mods | `Documents\My Games\Terraria\tModLoader\Mods\` | +| Mod settings | `Documents\My Games\Terraria\tModLoader\ModConfigs\` | + +Workshop mods often contain **several builds**, in folders named after the tModLoader version they target (`2024.3`, `2025.3`, ...). The game loads the build from the highest folder that isn't newer than the running tModLoader. + +`export-tmod-mods.ps1` applies the same rule. It's installed on the server at `/usr/share/terraria-server-manager/`; copy it to your PC first. It collects every enabled mod, a matching `enabled.json` and your mod settings into `Desktop\tmod-export`, laid out like the server's `data/` folder. Run it in PowerShell: + +```powershell +powershell -ExecutionPolicy Bypass -File .\export-tmod-mods.ps1 -MaxVersion 2026.7 +``` + +Set `-MaxVersion` to your tModLoader version as year.month. The full version is shown at the bottom-left of tModLoader's main menu, e.g. `v2026.07.3.0` → `2026.7`. For the preview branch, add `-TmlDir "$HOME\Documents\My Games\Terraria\tModLoader-preview"`. + +The script prints which file it chose for each mod, and warns about any it couldn't find. It leaves missing mods out of `enabled.json`, because one missing file would stop the server loading any mods. + +### Copying mods to the server + +Copy the export folder to the server. `scp` is built into Windows 10 and 11: + +```powershell +scp -r "$HOME\Desktop\tmod-export" you@yourserver: +``` + +This puts it in your home directory on the server. Then install the mods into the instance: + +```bash +sudo terractl mods calamity add ~/tmod-export +sudo systemctl restart terraria@calamity # only if it's already running +``` + +`add` copies the `.tmod` files and any `ModConfigs`, gives them to the `terraria` user, and adds them to `enabled.json`. For a new instance, do this **before the first start** so the world is generated with the mods loaded. + +### Managing mods + +```bash +terractl mods # list installed mods and whether each is enabled +terractl mods add ... # install and enable +terractl mods disable ... # keep installed, but don't load +terractl mods enable ... # load again +terractl mods remove ... # uninstall +``` + +`add` accepts `.tmod` files, a folder containing them, or an export folder with `Mods/` and `ModConfigs/` subfolders. Mod names are the file names without `.tmod`, e.g. `CalamityModMusic`. + +Adding a mod that's already installed replaces it, which is how you update a mod. Changes take effect when the server restarts; the command reminds you if it's running. + +The list also flags mods that are **enabled but missing**, because the server won't load any mods while one is missing. + +`add` doesn't check dependencies, so remember to add them too. If one is missing, `server/tModLoader-Logs/server.log` says which. + +--- + +## Versions and upgrades + +### Players must match the server's version + +- **Vanilla:** players need the same Terraria version as the server's zip. For example, `terraria-server-1458.zip` is Terraria 1.4.5.8. +- **tModLoader:** players need exactly the same tModLoader version, including the same branch (stable or preview). Steam updates players' tModLoader automatically, usually monthly, so a server that was matching can become mismatched after an update. + +Stable tModLoader and the latest vanilla Terraria can be based on different Terraria versions. That's fine, because every instance runs independently. + +### Upgrading (or downgrading) an instance + +1. Put the new zip in `/var/lib/terraria/dist`, with the version in its name. +2. Set `ZIP=` to that file name in the instance's `instance.conf`. +3. Restart the instance: + +```bash +sudo systemctl restart terraria@calamity +``` + +The helper sees that the zip has changed and re-unpacks `server/`. Nothing in `data/` is touched. To move back, set `ZIP=` to the old file. + +**Pin `ZIP=` on modded instances.** Pinned instances never change version by accident, and you can see at a glance which version each one is on. New tModLoader versions can break older mods, so only upgrade a modded server once its mods support the new version. + +### Re-unpacking the same version + +If `server/` gets damaged or you want a clean copy: + +```bash +sudo terractl reinstall calamity +sudo systemctl restart terraria@calamity +``` + +For tModLoader, the downloaded .NET runtime and `tModLoader-Logs/` are kept. If you suspect .NET itself is damaged, use `--clean` to delete them too, so .NET is downloaded again on the next start: + +```bash +sudo terractl reinstall calamity --clean +sudo systemctl restart terraria@calamity +``` + +--- + +## Deleting and restoring + +```bash +sudo terractl delete calamity # asks you to type the name +sudo terractl delete calamity -y # no prompt +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/-.tar.gz`. `server/` is left out of the archive because it can be recreated from the zip. + +To restore an instance: + +```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 systemctl enable --now terraria@calamity # server/ is unpacked again on start +``` + +`.deleted/` is never cleaned up automatically. Remove old archives yourself. + +--- + +## Backups + +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: + +```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 +``` + +Stopping the server first is the safest option. Terraria also keeps a `.bak` copy of each world alongside the world file. + +--- + +## Permissions + +The servers always run as the `terraria` user. Administration is simplest with `sudo`. + +| Command | Who can run it | Notes | +|---|---|---| +| `new` | root (or `terraria`) | Run as root, it sets ownership of the new instance to `terraria` itself. | +| `list` | root, or members of the `terraria` group | `/var/lib/terraria` is not readable by other users. | +| Adding zips to `dist/` | root, or members of the `terraria` group | | +| `attach` | root, or members of **both** `terraria` and `systemd-journal` | Needs to write to the console pipe and read the journal. | +| `cmd` | root, or members of the `terraria` group | Writes to the console pipe. | +| `reinstall` | root (or `terraria`) | | +| `mods ` (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. | +| `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 | | +| `journalctl -u terraria@...` | root, or members of `systemd-journal` or `adm` | | + +To run the everyday commands without `sudo`: + +```bash +sudo usermod -aG terraria,systemd-journal youruser # then log out and back in +``` + +Members of the `terraria` group can read server files (useful for backups) but cannot change them. Files the service creates are readable, not writable, by the group. + +### Avoiding root-owned files + +The helper commands don't leave root-owned files behind, and `mods add` handles mods and ModConfigs. The remaining risk is **copying or creating other files in an instance directory as root**, such as worlds. The server can't write to root-owned files, so saving fails or tModLoader errors. After copying anything in, run: + +```bash +sudo chown -R terraria:terraria /var/lib/terraria/instances/ +``` + +Alternatively, copy files as the `terraria` user to begin with: + +```bash +sudo -u terraria cp MyWorld.wld /var/lib/terraria/instances/calamity/data/Worlds/ +``` + +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: + +```bash +sudo systemctl stop terraria@calamity +sudo terractl run calamity # automatically runs as 'terraria' +``` + +The server runs in your terminal and you can type commands into it directly. Type `exit` to stop it, then start the service again: + +```bash +sudo systemctl start terraria@calamity +``` + +--- + +## Troubleshooting + +**tModLoader stops with `Aborted` right after "Launched Using Local Dotnet".** +The ICU library is missing, so .NET aborts at startup. Fix it with `sudo apt install libicu-dev`, then restart the instance. To confirm, check that `ldconfig -p | grep -c libicu` prints more than 0. + +**`ls: cannot access '.../dotnet/shared/Microsoft.NETCore.App'` on the first start.** +Harmless. tModLoader looks for old .NET versions to clean up before any have been installed. + +**The first tModLoader start fails while downloading .NET.** +The server needs internet access and `curl` on its first start. See `server/tModLoader-Logs/Launch.log` for the exact error. + +If the host has no internet access, you can copy the `dotnet` folder from another tModLoader instance that runs the same tModLoader version. Unpack the new instance first, then copy the folder in before starting it: +```bash +sudo terractl prepare # unpacks server/ without starting +sudo cp -a /var/lib/terraria/instances/other/server/dotnet /var/lib/terraria/instances//server/ +sudo systemctl start terraria@ +``` + +**The server can't save, or you see "Permission denied" errors.** +Some files are owned by root. Run `sudo chown -R terraria:terraria /var/lib/terraria/instances/`. + +**Players can't join a tModLoader server with a version-mismatch message.** +The player's tModLoader version differs from the server's. See [Versions and upgrades](#versions-and-upgrades). + +**Mods don't load.** +Run `terractl mods ` to check for mods that are enabled but missing. Then check `server/tModLoader-Logs/server.log` for the cause. Common ones: +- a dependency is missing from `data/Mods/`; +- a mod name in `enabled.json` doesn't match its `.tmod` file name; +- a mod build was made for a newer tModLoader than the server (re-export with `-MaxVersion`); +- 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'`. + +**The service starts but you can't connect.** +Check the port in `serverconfig.txt`, your firewall (TCP), and router port forwarding. + +**The server isn't restarting after it stopped.** +It exited cleanly, probably via `exit` in the console, so systemd leaves it stopped. Start it again with `systemctl start`. + +**The world was created without mod content.** +It was generated before the mods were added. Delete the world files in `data/Worlds/` (or point `world=` at a new name) and restart to generate a new one. + +--- + +## Command reference + +``` +terractl new [vanilla|tmodloader] [port] Create an instance (not started) +terractl list Show instances, type, port, state +terractl attach Interactive console (Ctrl-D detaches) +terractl cmd Send one console command +terractl reinstall [--clean] Re-unpack server/ on next start + (--clean also re-downloads .NET) +terractl mods [list] Show installed mods +terractl mods add ... Install and enable mods (and ModConfigs) +terractl mods enable|disable ... Change which mods load +terractl mods remove ... Uninstall mods +terractl delete [-y] [--no-backup] Stop, disable, back up and remove +terractl --version Show the installed version + +Used by the service: +terractl prepare Create dirs/configs, unpack zip if needed +terractl run Run the server +terractl stop Save and shut down via the console +``` + +``` +systemctl start|stop|restart|status terraria@ +systemctl enable|disable [--now] terraria@ +journalctl -u terraria@ [-f] [-n N] +``` diff --git a/packaging/deb/conffiles b/packaging/deb/conffiles new file mode 100644 index 0000000..041992a --- /dev/null +++ b/packaging/deb/conffiles @@ -0,0 +1 @@ +/etc/default/terractl diff --git a/packaging/deb/control b/packaging/deb/control new file mode 100644 index 0000000..fd60ec6 --- /dev/null +++ b/packaging/deb/control @@ -0,0 +1,22 @@ +Package: terraria-server-manager +Version: @VERSION@ +Section: games +Priority: optional +Architecture: all +Installed-Size: @INSTALLED_SIZE@ +Maintainer: Your Name +Depends: unzip, systemd +Recommends: curl, libicu76 | libicu74 | libicu72 | libicu70 | libicu-dev +Conflicts: terraria-instance +Replaces: terraria-instance +Description: run multiple Terraria and tModLoader dedicated servers with systemd + Provides the terraria@.service template and the terractl command for + running any number of vanilla Terraria and tModLoader dedicated servers, each + as its own systemd service under an unprivileged terraria user. + . + Server software is not included. Place the official server zips in + /var/lib/terraria/dist and they are unpacked into each instance on demand. + . + curl and ICU are only needed for tModLoader instances. + . + This package replaces terraria-instance (the same tool under its old name). diff --git a/packaging/deb/postinst b/packaging/deb/postinst new file mode 100755 index 0000000..2fa5477 --- /dev/null +++ b/packaging/deb/postinst @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +case "$1" in + configure) + systemd-sysusers terractl.conf + systemd-tmpfiles --create terractl.conf + if [ -d /run/systemd/system ]; then + systemctl daemon-reload || true + fi + # Running instances are not restarted; they pick up changes on their next start. + ;; +esac + +exit 0 diff --git a/packaging/deb/postrm b/packaging/deb/postrm new file mode 100755 index 0000000..220b20b --- /dev/null +++ b/packaging/deb/postrm @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +case "$1" in + purge) + # Forget which instances were enabled at boot + rm -f /etc/systemd/system/multi-user.target.wants/terraria@*.service + rm -rf /etc/systemd/system/terraria@.service.d + cat >&2 <. +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/var/lib/terraria/instances +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/systemd/terraria@.socket b/systemd/terraria@.socket new file mode 100644 index 0000000..4da3d90 --- /dev/null +++ b/systemd/terraria@.socket @@ -0,0 +1,12 @@ +[Unit] +Description=Terraria server console FIFO (%i) +# Stopping the service also stops (and removes) its console FIFO +PartOf=terraria@%i.service + +[Socket] +ListenFIFO=%t/terraria/%i.stdin +SocketUser=terraria +SocketGroup=terraria +SocketMode=0660 +DirectoryMode=0755 +RemoveOnStop=true diff --git a/tools/windows/export-tmod-mods.ps1 b/tools/windows/export-tmod-mods.ps1 new file mode 100644 index 0000000..03be111 --- /dev/null +++ b/tools/windows/export-tmod-mods.ps1 @@ -0,0 +1,130 @@ +<# +.SYNOPSIS + Collects every mod currently enabled in tModLoader (Windows) into one folder, + ready to copy into /opt/terraria//data/ on the Linux server. + +.DESCRIPTION + Output layout (mirrors the server's data/ folder): + \Mods\*.tmod + \Mods\enabled.json (only lists mods that were actually found) + \ModConfigs\*.json + + .tmod files are taken from Steam's Workshop folder (all Steam libraries are + searched), falling back to Documents\My Games\Terraria\tModLoader\Mods for + mods you installed manually or built yourself. + +.PARAMETER Out + Where to put the export. Default: Desktop\tmod-export + +.PARAMETER TmlDir + tModLoader's save folder. Default: Documents\My Games\Terraria\tModLoader + For the preview branch use ...\My Games\Terraria\tModLoader-preview + +.PARAMETER MaxVersion + Optional. Your tModLoader version as year.month (e.g. 2025.3), shown at the + bottom-left of the tModLoader main menu. Workshop builds made for a newer + tModLoader than this are skipped. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File .\export-tmod-mods.ps1 + powershell -ExecutionPolicy Bypass -File .\export-tmod-mods.ps1 -MaxVersion 2025.3 +#> +param( + [string]$Out = (Join-Path ([Environment]::GetFolderPath('Desktop')) 'tmod-export'), + [string]$TmlDir = (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'My Games\Terraria\tModLoader'), + [version]$MaxVersion +) +$ErrorActionPreference = 'Stop' + +# --- Which mods are enabled? ------------------------------------------------ +$enabledJson = Join-Path $TmlDir 'Mods\enabled.json' +if (-not (Test-Path $enabledJson)) { + throw "Can't find $enabledJson. Has tModLoader been run? For the preview branch pass -TmlDir ...\tModLoader-preview" +} +$enabled = @(Get-Content $enabledJson -Raw | ConvertFrom-Json) +if ($enabled.Count -eq 0) { throw "No mods are enabled in $enabledJson" } + +# --- Find Steam Workshop folders for tModLoader (app 1281930) ---------------- +$steamPath = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath +if (-not $steamPath) { $steamPath = 'C:\Program Files (x86)\Steam' } +$libraries = @($steamPath) +$vdf = Join-Path $steamPath 'steamapps\libraryfolders.vdf' +if (Test-Path $vdf) { + $libraries += Select-String -Path $vdf -Pattern '"path"\s+"([^"]+)"' | + ForEach-Object { $_.Matches[0].Groups[1].Value -replace '\\\\', '\' } +} +$workshopDirs = @($libraries | + ForEach-Object { Join-Path $_ 'steamapps\workshop\content\1281930' } | + Where-Object { Test-Path $_ } | + ForEach-Object { (Resolve-Path $_).Path } | + Sort-Object -Unique) +if ($workshopDirs.Count -eq 0) { Write-Warning "No Steam Workshop folder for tModLoader found; only local mods will be exported." } + +function Get-FolderVersion([string]$dir) { + # Workshop items keep builds in folders named after the tModLoader version, e.g. 2024.3 + $v = $null + if ([version]::TryParse((Split-Path $dir -Leaf), [ref]$v)) { return $v } + return $null +} + +# --- Collect ------------------------------------------------------------------ +$modsOut = Join-Path $Out 'Mods' +New-Item -ItemType Directory -Force -Path $modsOut | Out-Null +$exported = @() +$missing = @() + +foreach ($mod in $enabled) { + $candidates = @() + foreach ($w in $workshopDirs) { + $candidates += @(Get-ChildItem -Path $w -Recurse -File -Filter "$mod.tmod" -ErrorAction SilentlyContinue) + } + + $pick = $null + if ($candidates.Count -gt 0) { + $withVer = $candidates | ForEach-Object { + [pscustomobject]@{ File = $_; Version = (Get-FolderVersion $_.DirectoryName) } + } + # Newest version folder that isn't newer than -MaxVersion + $best = @($withVer | + Where-Object { $_.Version -and (-not $MaxVersion -or $_.Version -le $MaxVersion) } | + Sort-Object Version -Descending) + if ($best.Count -gt 0) { + $pick = $best[0].File + } else { + # Build stored at the item's top level (no version folder) + $root = @($withVer | Where-Object { -not $_.Version } | + Sort-Object { $_.File.LastWriteTime } -Descending) + if ($root.Count -gt 0) { $pick = $root[0].File } + } + } + + if (-not $pick) { + $local = Join-Path $TmlDir "Mods\$mod.tmod" + if (Test-Path $local) { $pick = Get-Item $local } + } + + if ($pick) { + Copy-Item $pick.FullName $modsOut -Force + $exported += $mod + Write-Host (" {0,-28} {1}" -f $mod, $pick.FullName) + } else { + $missing += $mod + Write-Warning "$mod is enabled but no .tmod file was found; skipped." + } +} + +# enabled.json listing only what we exported (a missing mod would stop the server loading) +$json = ConvertTo-Json -InputObject @($exported) +[IO.File]::WriteAllText((Join-Path $modsOut 'enabled.json'), $json, (New-Object Text.UTF8Encoding $false)) + +# Mod settings (server-side configs matter; client-side ones are harmless) +$cfgSrc = Join-Path $TmlDir 'ModConfigs' +if (Test-Path $cfgSrc) { + $cfgOut = Join-Path $Out 'ModConfigs' + New-Item -ItemType Directory -Force -Path $cfgOut | Out-Null + Copy-Item (Join-Path $cfgSrc '*') $cfgOut -Recurse -Force +} + +Write-Host "" +Write-Host "Exported $($exported.Count) mod(s) to $Out" +if ($missing.Count -gt 0) { Write-Warning "Missing: $($missing -join ', ')" }