From fc2c08d47b03bbf9e4ea9dce5351c5f413dac4d9 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 2 Oct 2026 22:26:56 +0000 Subject: [PATCH] add doctor, scheduled backups and fetch; release 0.0.12 - doctor [] [--fix]: checks the system (user, programs, folders, zips, units and leftover copies in /etc, sandbox access to moved folders, journal, polkit, daemon, orphaned boot links and schedules) and each instance (ownership, server zip, config paths, mods, ports, failed starts/backups), with a fix for each. --fix applies the safe ones and re-checks them. - backup enable|disable|now|list: a systemd timer per instance (terraria-backup@.timer, Persistent=), saving the world first, into /var/lib/terraria/backups// (group-readable), pruning only scheduled backups. rename carries the schedule, delete removes it. upgrade's backups move there too. - fetch vanilla|tmodloader [] [--list] [--upgrade ]: downloads from terraria.org / tModLoader's GitHub releases, checks GitHub's sha256 and the zip, names it with its version. - terractld may write /etc/systemd/system, for backup enable and doctor --fix over --host. - Units, tmpfiles, packaging, completion, docs and changelog. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +- Makefile | 9 +- README.md | 4 +- bin/terractl | 936 +++++++++++++++++++++++++++++-- completions/bash/terractl | 39 +- config/default/terractl | 3 + config/tmpfiles.d/terractl.conf | 3 + docs/guide.md | 120 +++- lib/terractl-remote | 2 +- packaging/deb/postrm | 3 + packaging/deb/prerm | 1 + systemd/terractld.service | 2 + systemd/terraria-backup@.service | 22 + systemd/terraria-backup@.timer | 13 + 14 files changed, 1118 insertions(+), 56 deletions(-) create mode 100644 systemd/terraria-backup@.service create mode 100644 systemd/terraria-backup@.timer diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c924c7..54c79fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,23 @@ # Changelog -## 0.0.12 - unreleased +## 0.0.12 - 2026-10-02 +- New `fetch` command to download server zips into `dist/`, named with their version: + - `terractl fetch tmodloader []` downloads from tModLoader's GitHub releases (default: the latest stable) and checks it against the SHA-256 checksum GitHub publishes. `terractl fetch vanilla []` downloads from terraria.org (default: its newest; older versions by number, `1449` or `1.4.4.9`). + - `--list` shows the versions available (tModLoader: stable and preview), and which are already downloaded. `--upgrade ` switches an instance to the zip with `upgrade` right after. + - Every zip is checked (complete, and the right kind) before it's put in `dist/`; zips already there aren't downloaded again. If a site doesn't have the version or can't be reached, it says so and where to download it by hand. + - Members of the `terraria` group can use it without `sudo` (except `--upgrade`). Needs `curl`. +- Scheduled backups, with a systemd timer per instance (`terraria-backup@.timer`): + - `sudo terractl backup enable [--every ] [--keep ]` (default: daily, keeping the newest 14). `--every` takes `hourly`, `daily`, `weekly`, a time such as `04:30`, or any systemd calendar expression. Missed backups are made after the machine starts again. + - `backup now ` makes one immediately, `backup disable ` stops the schedule (existing backups are kept), and `backup list []` shows schedules, the last and next run, and the backups. + - Each backup saves the world first if the server is running (as `export` does) and goes in `/var/lib/terraria/backups//` (`TERRARIA_BACKUPS` in `/etc/default/terractl`), readable by the `terraria` group. Only scheduled backups are pruned. + - `rename` takes the schedule along, `delete` turns it off, and purging the package removes the schedules (not the backups). +- New `doctor` command: `terractl doctor [] [--fix]` checks the system (user, programs including ICU and curl, folders, zips, the systemd units and old copies in `/etc` that replace them, sandbox access to moved folders, journal access, polkit, the daemon, leftovers of deleted instances) and each instance (file ownership and modes, its server zip, paths in `serverconfig.txt`, missing mods, shared ports, failed starts and backups). Each problem comes with the command that fixes it. `--fix` applies the safe fixes: `fix-perms`, folder and zip permissions, journal access, the `ReadWritePaths=` drop-in for moved folders, and boot links or backup schedules of instances that are gone. It exits with status 1 if a problem remains. +- The daemon (`terractld.service`) may now write to `/etc/systemd/system`, so `backup enable` and `doctor --fix` also work through `--host`. +- Warnings about moved folders now also suggest `terractl doctor --fix`. - New `upgrade` command to change an instance's server version, up or down: - `terractl upgrade ` lists the zips in `dist/` for its type, marking the installed, pinned and newest ones. The `terraria` group can use it without `sudo`. - - `sudo terractl upgrade |latest` checks the zip first (right type, readable by `terraria`, contains the server, passes `unzip -t`), stops the server (saving the world), backs up its worlds and configs to `instances/.backups/`, pins `ZIP=`, unpacks the zip right away and starts the server again if it was running. If unpacking fails, the instance is left as it was. `--no-backup` skips the backup. + - `sudo terractl upgrade |latest` checks the zip first (right type, readable by `terraria`, contains the server, passes `unzip -t`), stops the server (saving the world), backs up its worlds and configs to `/var/lib/terraria/backups//`, pins `ZIP=`, unpacks the zip right away and starts the server again if it was running. If unpacking fails, the instance is left as it was. `--no-backup` skips the backup. - Going to an older version needs `--downgrade`, because worlds saved by a newer version can't be opened by an older one. - Bash completion offers the instance's zips. Works with `--host`. - `reinstall` now always unpacks the **same** version again. Before, an instance with a blank `ZIP=` picked the newest zip in `dist/`, so `reinstall` could silently upgrade it. It now pins the zip the instance runs first, and refuses if that zip is gone from `dist/`. It also keeps the record of the installed version (`server/.installed`) until the new unpack is done. diff --git a/Makefile b/Makefile index 9ac0ca7..f23047b 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,10 @@ install: install -m 755 lib/terractl-remote $(DESTDIR)$(PKGLIBDIR)/terractl-remote $(SUBST) systemd/terraria@.service > $(DESTDIR)$(UNITDIR)/terraria@.service $(SUBST) systemd/terractld.service > $(DESTDIR)$(UNITDIR)/terractld.service - chmod 644 $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terractld.service + $(SUBST) systemd/terraria-backup@.service > $(DESTDIR)$(UNITDIR)/terraria-backup@.service + $(SUBST) systemd/terraria-backup@.timer > $(DESTDIR)$(UNITDIR)/terraria-backup@.timer + chmod 644 $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terractld.service \ + $(DESTDIR)$(UNITDIR)/terraria-backup@.service $(DESTDIR)$(UNITDIR)/terraria-backup@.timer 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)/ @@ -97,10 +100,14 @@ install: uninstall: @if [ -z "$(DESTDIR)" ] && [ -d /run/systemd/system ]; then \ systemctl disable --now terractld.service 2>/dev/null || true; \ + for t in $$(systemctl list-unit-files --plain --no-legend 'terraria-backup@*.timer' | awk '{print $$1}'); do \ + systemctl disable --now "$$t" 2>/dev/null || true; \ + done; \ fi rm -f $(DESTDIR)$(BINDIR)/terractl \ $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terraria@.socket \ $(DESTDIR)$(UNITDIR)/terractld.service \ + $(DESTDIR)$(UNITDIR)/terraria-backup@.service $(DESTDIR)$(UNITDIR)/terraria-backup@.timer \ $(DESTDIR)$(SYSUSERSDIR)/terractl.conf $(DESTDIR)$(TMPFILESDIR)/terractl.conf \ $(DESTDIR)$(COMPLETIONDIR)/terractl $(DESTDIR)$(POLKITDIR)/50-terractl.rules rm -rf $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) $(DESTDIR)$(PKGLIBDIR) diff --git a/README.md b/README.md index ae12737..070dd44 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,14 @@ sudo terractl attach calamity - **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. `terractl upgrade` checks a new zip, backs up the worlds and switches version in one step. +- **No manual installs.** `terractl fetch` downloads official Terraria and tModLoader server zips (or drop them into `/var/lib/terraria/dist` yourself); they're unpacked on demand, and each instance can be pinned to a specific version. `terractl upgrade` checks a new zip, backs up the worlds and switches version in one step. - **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; `terractl watch` just shows the output. - **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. +- **Scheduled backups.** `terractl backup enable ` backs an instance up daily (or on any schedule) with a systemd timer, saving the world first and keeping the newest 14. +- **Self-check.** `terractl doctor` checks the whole setup, explains what's wrong with the command to fix it, and `--fix` fixes what it safely can. - **Manage other hosts.** Run any command on another host with `terractl --host nas ...`, and copy or move instances between hosts with `terractl copy` and `terractl move`, like LXD/Incus remotes. Hosts opt in by running the `terractld` daemon; access is per computer, over pinned TLS. - **Hardened.** Servers run as an unprivileged `terraria` user and can only write to their own data. - **Access for moderators.** Members of the `terraria` group can start, stop and restart servers, use the console and read the servers' own logs, without `sudo` and without seeing the rest of the system's logs. diff --git a/bin/terractl b/bin/terractl index 1812790..729a26a 100755 --- a/bin/terractl +++ b/bin/terractl @@ -10,6 +10,7 @@ # serverconfig.txt normal Terraria server config (port, world, ...) # server/ unpacked server binaries (replaceable) # data/ worlds, mods, player data (kept across reinstalls) +# /var/lib/terraria/backups// backups ('backup', and 'upgrade' before switching) # # Managing other hosts (--host, remote, daemon, copy, move) is done by # terractl-remote, in Python; see run_remote_helper. @@ -32,6 +33,7 @@ if [[ -r /etc/default/terractl ]]; then . /etc/default/terractl; fi BASE_DIR="${TERRARIA_BASE:-/var/lib/terraria/instances}" DIST_DIR="${TERRARIA_DIST:-/var/lib/terraria/dist}" +BACKUP_DIR="${TERRARIA_BACKUPS:-/var/lib/terraria/backups}" RUN_DIR="/run/terraria" SVC_USER="${TERRARIA_USER:-terraria}" STOP_TIMEOUT="${TERRARIA_STOP_TIMEOUT:-80}" # keep below TimeoutStopSec @@ -40,6 +42,15 @@ TERRACTL_VERSION="@VERSION@" # filled in by 'make install' / 'm REMOTE_HELPER="@PKGLIBDIR@/terractl-remote" # likewise die() { echo "terractl: $*" >&2; exit 1; } +# join_by ...: the items with the separator between them +join_by() { + local sep=$1 first + shift + if [[ $# -eq 0 ]]; then echo; return 0; fi + first=$1; shift + printf '%s' "$first" "${@/#/$sep}" + echo +} log() { echo "terractl: $*" >&2; } # Is (default: whoever runs this) in the terraria group? @@ -83,6 +94,16 @@ Admin commands: change its server version now (backs up worlds first) reinstall [--clean] re-unpack the same server version on next start (--clean also re-downloads tModLoader's .NET) + fetch vanilla|tmodloader [] [--upgrade ] + download a server zip into dist/ (default: latest) + fetch vanilla|tmodloader --list versions available to download + backup enable [--every ] [--keep ] + back up on a schedule (default: daily, keep 14) + backup disable stop scheduled backups (keeps existing ones) + backup now back up now + backup list [] show schedules and backups + doctor [] [--fix] check the setup and explain problems + (--fix: fix what can be fixed safely) fix-perms give all of an instance's files back to the '$SVC_USER' user, with the usual modes (run as root) mods [list] show installed mods and whether they're enabled @@ -602,6 +623,10 @@ cmd_delete() { systemctl disable --now "terraria@$name.service" 2>/dev/null || true systemctl stop "terraria@$name.socket" 2>/dev/null || true systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true + if [[ -n $(backup_schedule "$name") ]]; then + backup_disable "$name" >/dev/null + echo "turned off scheduled backups of '$name'" + fi fi if instance_running "$name"; then die "'$name' is still running outside systemd (e.g. 'terractl run' in another terminal); stop it, then run delete again" @@ -624,7 +649,7 @@ cmd_delete() { # Gives an instance a new name: its folder, the paths in serverconfig.txt, and # its service (stopped and started again, and still starting at boot if it did). cmd_rename() { - local name=$1 new=${2:-} dir newdir state enabled + local name=$1 new=${2:-} dir newdir state enabled sched every keep [[ $# -eq 2 ]] || die "usage: terractl rename " valid_name "$new" [[ $EUID -eq 0 ]] || die "rename must be run as root: sudo terractl rename $name $new" @@ -642,11 +667,19 @@ cmd_rename() { fi if [[ $enabled == enabled ]]; then systemctl disable -q "terraria@$name"; fi systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true + sched=$(backup_schedule "$name") + if [[ -n $sched ]]; then backup_disable "$name" >/dev/null; fi mv "$dir" "$newdir" if [[ -f $newdir/serverconfig.txt ]]; then sed -i -E "s#^(world|worldpath|banlist)=.*/data/#\1=$newdir/data/#" "$newdir/serverconfig.txt" fi echo "renamed '$name' to '$new'" + if [[ -n $sched ]]; then + IFS=$'\t' read -r every keep <<< "$sched" + backup_enable "$new" --every "$every" --keep "$keep" >/dev/null + echo "scheduled backups now run for '$new'" + if [[ -d $BACKUP_DIR/$name ]]; then echo "backups made before the rename stay in $BACKUP_DIR/$name"; fi + fi if [[ $enabled == enabled ]]; then systemctl enable -q "terraria@$new"; echo "terraria@$new starts at boot"; fi if [[ $state == active || $state == activating || $state == reloading ]]; then systemctl start "terraria@$new" @@ -1153,38 +1186,51 @@ unit_path_problem() { esac } -# Warns about one unit: location_warning -# -location_warning() { - local unit=$1 edit=$2 effect=$3 dist_need=$4 what path need problem home=0 where="" - local -a lines=() rw=() +# What's wrong for one unit: location_problems . Fills LOC_PROBLEMS (messages), LOC_RW (paths +# for ReadWritePaths=) and LOC_HOME (1 if ProtectHome= must change). +location_problems() { + local unit=$1 edit=$2 effect=$3 dist_need=$4 what path need problem + LOC_PROBLEMS=() LOC_RW=() LOC_HOME=0 for what in TERRARIA_BASE TERRARIA_DIST; do if [[ $what == TERRARIA_BASE ]]; then path=$BASE_DIR need=rw; else path=$DIST_DIR need=$dist_need; fi problem=$(unit_path_problem "$unit" "$path" "$need") case $problem in home) - lines+=("$edit hides $path ($what) under ProtectHome=, so $effect") - home=1 - if [[ $need == rw ]]; then rw+=("$path"); fi ;; + LOC_PROBLEMS+=("$edit hides $path ($what) under ProtectHome=, so $effect") + LOC_HOME=1 + if [[ $need == rw ]]; then LOC_RW+=("$path"); fi ;; home-ro|system) - lines+=("$edit can't write to $path ($what), so $effect") - if [[ $problem == home-ro ]]; then home=1; fi - rw+=("$path") ;; + LOC_PROBLEMS+=("$edit can't write to $path ($what), so $effect") + if [[ $problem == home-ro ]]; then LOC_HOME=1; fi + LOC_RW+=("$path") ;; esac done - [[ ${#lines[@]} -gt 0 ]] || return 0 +} + +# The drop-in that fixes what location_problems found +location_dropin() { + local path + echo "[Service]" + if [[ $LOC_HOME -eq 1 ]]; then echo "ProtectHome=read-only"; fi + for path in "${LOC_RW[@]}"; do echo "ReadWritePaths=$path"; done +} + +# Warns about one unit, with the drop-in to add; arguments as location_problems +location_warning() { + local edit=$2 where="" + location_problems "$@" + [[ ${#LOC_PROBLEMS[@]} -gt 0 ]] || return 0 if [[ -n ${TERRACTL_VIA:-} ]]; then where=" (on $(hostname 2>/dev/null || echo that host))"; fi - printf 'terractl: warning: %s\n' "${lines[@]}" >&2 + printf 'terractl: warning: %s\n' "${LOC_PROBLEMS[@]}" >&2 { echo " Fix it with a drop-in$where: sudo systemctl edit $edit" - echo " [Service]" - if [[ $home -eq 1 ]]; then echo " ProtectHome=read-only"; fi - for path in "${rw[@]}"; do echo " ReadWritePaths=$path"; done - if [[ $home -eq 1 ]]; then + location_dropin | sed 's/^/ /' + if [[ $LOC_HOME -eq 1 ]]; then echo " The '$SVC_USER' user also needs access to every folder above it; see" echo " \"Instances or zips under /home\" in the guide." else - echo " See \"Changing locations\" in the guide." + echo " See \"Changing locations\" in the guide, or run: $(hint root doctor --fix)" fi } >&2 } @@ -1226,6 +1272,29 @@ cmd_list() { # A pin (ZIP= in instance.conf) that differs from it makes the next start (the # 'prepare' step) unpack the pinned zip instead. +# Checks that is a complete server zip of ; dies saying what's wrong +check_server_zip() { + local f=$1 type=$2 listing + command -v unzip >/dev/null || die "unzip is not installed" + listing=$(unzip -Z1 "$f" 2>/dev/null) || die "$f isn't a readable zip (damaged, or not fully downloaded?)" + case $type in + vanilla) grep -q '/Linux/TerrariaServer\.bin\.x86_64$' <<< "$listing" || + die "$f has no Linux/TerrariaServer.bin.x86_64; is it the dedicated server zip?" ;; + tmodloader) grep -qE '(^|/)start-tModLoaderServer\.sh$' <<< "$listing" || + die "$f has no start-tModLoaderServer.sh; is it a tModLoader release zip?" ;; + esac + log "checking $(basename "$f")..." + unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again" +} + +# The version in a zip's name: tModLoader-v2026.07.3.0.zip -> 2026.07.3.0, +# terraria-server-1458.zip -> 1458 +zip_version() { + local v=${1%.zip} + v=${v#terraria-server-}; v=${v#tModLoader}; v=${v#-}; v=${v#v} + echo "$v" +} + zip_pattern() { case $TYPE in vanilla) echo 'terraria-server-*.zip' ;; @@ -1326,7 +1395,7 @@ upgrade_list() { # that fails before the new server/ is in place leaves the instance as it was. cmd_upgrade() { local name=$1; shift - local target="" downgrade=0 backup=1 arg dir installed zip f listing lower verb state + local target="" downgrade=0 backup=1 arg dir installed zip f lower verb state local was_running=0 installed_name old_pin backup_file="" unit="terraria@$name" for arg in "$@"; do case $arg in @@ -1381,17 +1450,8 @@ cmd_upgrade() { verb=switched fi - command -v unzip >/dev/null || die "unzip is not installed" need_svc_can_run_self "upgrade" - listing=$(unzip -Z1 "$f" 2>/dev/null) || die "$f isn't a readable zip (damaged, or not fully downloaded?)" - case $TYPE in - vanilla) grep -q '/Linux/TerrariaServer\.bin\.x86_64$' <<< "$listing" || - die "$f has no Linux/TerrariaServer.bin.x86_64; is it the dedicated server zip?" ;; - tmodloader) grep -qE '(^|/)start-tModLoaderServer\.sh$' <<< "$listing" || - die "$f has no start-tModLoaderServer.sh; is it a tModLoader release zip?" ;; - esac - log "checking $zip..." - unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again" + check_server_zip "$f" "$TYPE" state=$(systemctl is-active "$unit" 2>/dev/null || true) if [[ $state == inactive || $state == failed ]] && instance_running "$name"; then @@ -1404,14 +1464,8 @@ cmd_upgrade() { fi if [[ $backup -eq 1 ]]; then - mkdir -p "$BASE_DIR/.backups" - chmod 700 "$BASE_DIR/.backups" installed_name=${installed%.zip} - backup_file="$BASE_DIR/.backups/$name-$(date +%Y%m%d-%H%M%S)-${installed_name:-new}" - while [[ -e $backup_file.tar.gz ]]; do backup_file+=_; done - backup_file+=.tar.gz - # In a subshell, so a failure comes back here instead of exiting - if ! ( trap cleanup EXIT; cmd_export "$name" "$backup_file" ) >/dev/null; then + if ! backup_file=$(make_backup "$name" "before-${installed_name:-upgrade}"); then if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; echo "$unit started again"; fi die "backing up '$name' failed, so its version wasn't changed" fi @@ -1439,7 +1493,7 @@ cmd_upgrade() { if [[ $TYPE == tmodloader ]]; then echo "Check that its mods still load: $(hint group watch "$name")" fi - if [[ -n $backup_file ]]; then + if [[ -n $backup_file && -n $installed ]]; then echo "If its worlds need to go back to $installed, see 'Undoing an upgrade' in the guide." fi } @@ -1466,6 +1520,808 @@ cmd_fix_perms() { echo "'$name': all files owned by $SVC_USER:$SVC_USER, readable by the '$SVC_USER' group, private to everyone else" } +# ------------------------------------------------------------- backups ---- +# Backups go in $BACKUP_DIR//, readable by the terraria group, like the +# instances themselves. Each is an export without server/, so 'import' restores +# it. Scheduled ones are named --