diff --git a/CHANGELOG.md b/CHANGELOG.md index 041e97a..ed4e81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ - New `/etc/systemd/journald@terraria.conf` for the servers' journal size and retention (default: up to 256 MB). - New internal `terractl journal-access`, run as root before each server start: creates the servers' journal folder (never `/var/log/journal` itself) and gives the `terraria` group read access, repairing existing journal files too. - The note shown by `attach` and `cmd` when output can't be read now distinguishes "not in the `terraria` group" from "in it, but the journal isn't set up yet". +- New polkit rule (`/usr/share/polkit-1/rules.d/50-terractl.rules`): members of the `terraria` group can `systemctl start`, `stop` and `restart` `terraria@` without `sudo`. `enable`, `disable` and all other units still need root. Needs `polkitd` 0.106 or newer, now a recommended package; Ubuntu 22.04's polkit can't run the rule. +- New `enable` and `disable` commands: `terractl enable [--now]` makes a server start at boot (`--now` also starts it), `terractl disable [--now]` stops that (`--now` also stops it, saving the world). They need root, like `systemctl enable`. `enable` refuses names without an instance, so a typo can't create a new instance at boot. `new` and `import` now suggest `terractl enable --now`. +- `list` has a new `BOOT` column showing whether each server starts at boot (`enabled`, `disabled`, or e.g. `masked`). +- Manage other hosts, like LXD/Incus remotes: + - `sudo terractl daemon enable` starts `terractld.service`, a daemon other hosts connect to (HTTPS, TCP port 7776; `TERRACTL_LISTEN` in `/etc/default/terractl` changes it). It's off until enabled. `daemon disable` and `daemon status` turn it off and show its state. + - `sudo terractl daemon trust add ` creates a one-time token for a computer, valid for 24 hours; `daemon trust list` and `daemon trust remove ` show and revoke access. On that computer, `terractl remote add ` adds the host; `remote list` and `remote remove` manage remotes, which are stored per user. + - `terractl --host ` runs any command on a remote, including `attach`, `watch` and `export`/`import`/`mods add` with files from your computer. The daemon runs it as root there, with messages adjusted to `terractl --host ...`. + - `terractl copy` and `terractl move` copy, move or rename instances, on one host or between hosts (`[:]`). Paths and ports follow the destination's settings, and a missing server zip is copied along. `move` stops the source first, starts it again if the transfer fails, and starts the moved instance where it was running before. + - Security: the token carries the daemon's certificate fingerprint, which the client checks on every connection; each computer gets its own key, and the daemon stores only hashes. The daemon runs as root with systemd sandboxing. + - Written in Python (standard library only), in `/usr/lib/terraria-server-manager/terractl-remote`. The package now depends on `python3` and `openssl`. +- New `start`, `stop` and `restart` commands, the same as `systemctl start|stop|restart terraria@`, for this host and for `--host`. `start` refuses names that have no instance. The service's own stop step is now `terractl service-stop`; units that still call `terractl stop ` keep working. +- New `rename` command: `terractl rename ` renames the folder, updates `serverconfig.txt`, and carries over the service (stopped and started again if it was running, and still starting at boot). The port and unpacked server are kept. `move` within one host uses it. +- New `info` command: this host's folders, zips and instances, or an instance's type, port, server zip and state, as `key=value` lines. +- `import` replaces a `ZIP=` pin that's an absolute path not present on this host with the zip of the same name in this host's `dist/`. +- Messages that suggest a command now suggest `terractl` commands (`terractl stop ` rather than `systemctl stop terraria@`), with `sudo` only when it's needed: not in a root shell, and not for `terraria` group members starting, stopping or restarting a server. +- New `watch` command: `terractl watch [-n ] [-q]` follows the server's output like `attach`, but never sends input, so it's safe to leave open. Works for the `terraria` group without `sudo`, and while the server is stopped. `-q` leaves out the lines about what it's doing. +- Bash completion for the new commands, remote names after `--host`, and `:` for `copy` and `move`. +- Clearer messages when `run` or `prepare` is typed without `sudo` (the old one, "must run as root or 'terraria'", read as if the `terraria` group were enough), and when `stop` is typed by hand. - Upgrading: running servers keep logging to the system journal until restarted; the package lists the ones to restart. ## 0.0.10 - 2026-09-30 diff --git a/Makefile b/Makefile index d266b3c..9ac0ca7 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,18 @@ SYSUSERSDIR := $(PREFIX)/lib/sysusers.d TMPFILESDIR := $(PREFIX)/lib/tmpfiles.d DOCDIR := $(PREFIX)/share/doc/$(NAME) DATADIR := $(PREFIX)/share/$(NAME) +PKGLIBDIR := $(PREFIX)/lib/$(NAME) COMPLETIONDIR := $(PREFIX)/share/bash-completion/completions +# polkit only reads /usr/share/polkit-1/rules.d and /etc/polkit-1/rules.d +ifeq ($(PREFIX),/usr) +POLKITDIR ?= /usr/share/polkit-1/rules.d +else +POLKITDIR ?= $(SYSCONFDIR)/polkit-1/rules.d +endif # Fill in version and install paths -SUBST = sed -e 's|@VERSION@|$(VERSION)|g' -e 's|@BINDIR@|$(BINDIR)|g' -e 's|@DOCDIR@|$(DOCDIR)|g' +SUBST = sed -e 's|@VERSION@|$(VERSION)|g' -e 's|@BINDIR@|$(BINDIR)|g' -e 's|@DOCDIR@|$(DOCDIR)|g' \ + -e 's|@PKGLIBDIR@|$(PKGLIBDIR)|g' MAINT_SCRIPTS := packaging/deb/postinst packaging/deb/prerm packaging/deb/postrm STAGE := build/deb-root @@ -34,22 +42,33 @@ check: bash -n bin/terractl bash -n completions/bash/terractl for f in $(MAINT_SCRIPTS); do sh -n $$f; done + python3 -c 'import ast, sys; ast.parse(open(sys.argv[1]).read(), sys.argv[1])' lib/terractl-remote @if command -v shellcheck >/dev/null; then \ shellcheck bin/terractl completions/bash/terractl $(MAINT_SCRIPTS); \ else \ echo "shellcheck not installed; skipped (apt install shellcheck)"; \ fi + @if python3 -m pyflakes --version >/dev/null 2>&1; then \ + python3 -m pyflakes lib/terractl-remote; \ + else \ + echo "pyflakes not installed; skipped (apt install python3-pyflakes)"; \ + fi install: install -d $(DESTDIR)$(BINDIR) $(DESTDIR)$(UNITDIR) $(DESTDIR)$(SYSUSERSDIR) \ - $(DESTDIR)$(TMPFILESDIR) $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) \ - $(DESTDIR)$(COMPLETIONDIR) $(DESTDIR)$(SYSCONFDIR)/default $(DESTDIR)$(SYSCONFDIR)/systemd + $(DESTDIR)$(TMPFILESDIR) $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) $(DESTDIR)$(PKGLIBDIR) \ + $(DESTDIR)$(COMPLETIONDIR) $(DESTDIR)$(SYSCONFDIR)/default $(DESTDIR)$(SYSCONFDIR)/systemd \ + $(DESTDIR)$(POLKITDIR) $(SUBST) bin/terractl > $(DESTDIR)$(BINDIR)/terractl chmod 755 $(DESTDIR)$(BINDIR)/terractl + 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 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 config/polkit/terractl.rules $(DESTDIR)$(POLKITDIR)/50-terractl.rules install -m 644 docs/guide.md $(DESTDIR)$(DOCDIR)/README.md install -m 644 tools/windows/export-tmod-mods.ps1 $(DESTDIR)$(DATADIR)/ install -m 644 completions/bash/terractl $(DESTDIR)$(COMPLETIONDIR)/terractl @@ -68,20 +87,27 @@ install: @if [ -z "$(DESTDIR)" ]; then \ systemd-sysusers terractl.conf && \ systemd-tmpfiles --create terractl.conf && \ - if [ -d /run/systemd/system ]; then systemctl daemon-reload; fi && \ + if [ -d /run/systemd/system ]; then \ + systemctl daemon-reload && systemctl try-restart terractld.service; \ + fi && \ $(BINDIR)/terractl journal-access && \ echo "Installed terractl $(VERSION). See $(DOCDIR)/README.md"; \ fi uninstall: + @if [ -z "$(DESTDIR)" ] && [ -d /run/systemd/system ]; then \ + systemctl disable --now terractld.service 2>/dev/null || true; \ + fi rm -f $(DESTDIR)$(BINDIR)/terractl \ $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terraria@.socket \ + $(DESTDIR)$(UNITDIR)/terractld.service \ $(DESTDIR)$(SYSUSERSDIR)/terractl.conf $(DESTDIR)$(TMPFILESDIR)/terractl.conf \ - $(DESTDIR)$(COMPLETIONDIR)/terractl - rm -rf $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) + $(DESTDIR)$(COMPLETIONDIR)/terractl $(DESTDIR)$(POLKITDIR)/50-terractl.rules + rm -rf $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) $(DESTDIR)$(PKGLIBDIR) @if [ -z "$(DESTDIR)" ] && [ -d /run/systemd/system ]; then systemctl daemon-reload; fi @echo "Removed. Kept: $(SYSCONFDIR)/default/terractl, $(SYSCONFDIR)/systemd/journald@terraria.conf," - @echo "/var/lib/terraria, the servers' journal and the terraria user." + @echo "/var/lib/terraria, /var/lib/terractl (the daemon's certificate and trusted" + @echo "computers), the servers' journal and the terraria user." deb: check rm -rf $(STAGE) diff --git a/README.md b/README.md index 8dffeaf..9e1ff73 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Run any number of vanilla **Terraria** and **tModLoader** dedicated servers on o ```bash sudo terractl new calamity tmodloader sudo terractl mods calamity add ~/tmod-export -sudo systemctl enable --now terraria@calamity +sudo terractl enable calamity --now sudo terractl attach calamity ``` @@ -15,18 +15,20 @@ sudo terractl attach calamity - **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. +- **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. +- **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 use the console and read the servers' own logs, without `sudo` and without seeing the rest of the system's logs. +- **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. - **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`) +- `python3` and `openssl`, for managing other hosts ## Install diff --git a/bin/terractl b/bin/terractl index d775c2c..62c7582 100755 --- a/bin/terractl +++ b/bin/terractl @@ -10,6 +10,9 @@ # serverconfig.txt normal Terraria server config (port, world, ...) # server/ unpacked server binaries (replaceable) # data/ worlds, mods, player data (kept across reinstalls) +# +# Managing other hosts (--host, remote, daemon, copy, move) is done by +# terractl-remote, in Python; see run_remote_helper. set -euo pipefail # Work from / so nothing depends on the caller's directory (which the 'terraria' @@ -33,10 +36,32 @@ SVC_USER="${TERRARIA_USER:-terraria}" STOP_TIMEOUT="${TERRARIA_STOP_TIMEOUT:-80}" # keep below TimeoutStopSec JOURNAL_NS=terraria # LogNamespace= in terraria@.service TERRACTL_VERSION="@VERSION@" # filled in by 'make install' / 'make deb' +REMOTE_HELPER="@PKGLIBDIR@/terractl-remote" # likewise die() { echo "terractl: $*" >&2; exit 1; } log() { echo "terractl: $*" >&2; } +# Is (default: whoever runs this) in the terraria group? +in_group() { + [[ " $(id -nG "${1:-$(id -un)}" 2>/dev/null) " == *" $SVC_USER "* ]] +} + +# A terractl command to suggest: hint root|group [args...]. 'root' +# commands need sudo; 'group' ones only for people outside the terraria group +# (start/stop/restart are allowed to the group by the polkit rule). Under sudo, +# it advises the person who ran sudo. When terractld runs a command for another +# host, TERRACTL_VIA says how that host reaches this one (e.g. "--host nas"). +hint() { + local need=$1 who=${SUDO_USER:-$(id -un)}; shift + if [[ -n ${TERRACTL_VIA:-} ]]; then + echo "terractl $TERRACTL_VIA $*" + elif [[ $who == root ]] || { [[ $need == group ]] && in_group "$who"; }; then + echo "terractl $*" + else + echo "sudo terractl $*" + fi +} + usage() { cat < [args] @@ -44,8 +69,13 @@ Usage: terractl [args] Admin commands: new [vanilla|tmodloader] [port] create an instance (run as root) - list show instances, type, port, state + list show instances, type, port, boot, state + info [] paths and zips of this host, or details of an instance + start|stop|restart start, stop (saves the world) or restart a server + enable [--now] start at boot (--now: also start it now) + disable [--now] don't start at boot (--now: also stop it now) attach interactive console (Ctrl-D to detach) + watch [-n ] [-q] follow the server's output, read-only (Ctrl-C to quit) cmd send a console command (e.g. save, say hi) reinstall [--clean] re-unpack server on next start (--clean also re-downloads tModLoader's .NET) @@ -54,17 +84,32 @@ Admin commands: mods enable|disable ... change which installed mods load mods remove ... uninstall mods delete [-y] [--no-backup] stop, disable and remove an instance + rename rename an instance (restarted if it was running) export [file.tar.gz] [--with-server] archive an instance (configs, worlds, mods) import create instance from an archive (worlds/configs archived to $BASE_DIR/.deleted/) +Other hosts: + --host [args] run a command on another host (e.g. --host nas list) + copy [:] [:][] [--with-server] + copy an instance, on this host or between hosts + move [:] [:][] [--with-server] + move (or rename) an instance; stops it while moving + remote add add a host to manage (token from 'daemon trust add') + remote add
[] ... when the token's addresses don't work from here + remote list | remote remove show or forget remotes + daemon enable|disable|status let other hosts manage this one (terractld.service) + daemon trust add create a one-time token for a computer to join + daemon trust list | trust remove + show or revoke computers that may manage this host + Used by terraria@.service: journal-access let the terraria group read the servers' journal prepare create dirs/configs, unpack server if needed run exec the server - stop graceful shutdown via console "exit" (service only; - to stop a server use: systemctl stop terraria@) + service-stop graceful shutdown via console "exit" (service only; + to stop a server use: terractl stop ) EOF exit "${1:-1}" } @@ -239,7 +284,7 @@ take_lock() { 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 "terraria@$name is $state; stop it first: $(hint group stop "$name")" ;; *) die "instance '$name' is already running outside systemd (e.g. 'terractl run' in another terminal); stop that first" ;; esac @@ -293,11 +338,12 @@ cmd_run() { esac } -cmd_stop() { +# ExecStop of terraria@.service +cmd_service_stop() { local name=$1 pid=${2:-} fifo="$RUN_DIR/$1.stdin" i # systemd sets INVOCATION_ID for every process it runs for a service if [[ -z ${INVOCATION_ID:-} ]]; then - die "'stop' is only for terraria@.service. To stop the server: systemctl stop terraria@$name" + die "'service-stop' is only called by terraria@.service. To stop the server: $(hint group stop "$name")" fi if [[ -n $pid && ! $pid =~ ^[0-9]+$ ]]; then die "invalid process ID '$pid'"; fi if [[ -z $pid ]] || ! kill -0 "$pid" 2>/dev/null; then exit 0; fi @@ -314,6 +360,42 @@ cmd_stop() { log "server did not exit within ${STOP_TIMEOUT}s; leaving it to systemd" } +# start/stop/restart through systemd: root, or the terraria group through the +# polkit rule (50-terractl.rules) +cmd_svc() { + local action=$1 name=$2 unit="terraria@$2" state; shift 2 + [[ $# -eq 0 ]] || die "usage: terractl $action " + # Only checkable with read access to the instances (root or the group) + if [[ -x $BASE_DIR && ! -f $(inst_dir "$name")/instance.conf ]]; then + die "no instance '$name' (create it with: $(hint root new "$name"))" + fi + if [[ $EUID -ne 0 ]] && ! in_group; then + die "'$action' needs root or the '$SVC_USER' group: sudo terractl $action $name" + fi + state=$(systemctl is-active "$unit" 2>/dev/null || true) + case $action in + start) + if [[ $state == active ]]; then echo "$unit is already running"; return 0; fi + systemctl start "$unit" + echo "$unit started" ;; + stop) + if [[ $state == inactive || $state == failed ]]; then + if instance_running "$name"; then + die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); type 'exit' in its console" + fi + echo "$unit isn't running" + return 0 + fi + echo "stopping $unit (saves the world first)..." + systemctl stop "$unit" + echo "$unit stopped" ;; + restart) + echo "restarting $unit (saves the world first)..." + systemctl restart "$unit" + echo "$unit restarted" ;; + esac +} + # The servers log to their own journal (LogNamespace=terraria), which the # 'terraria' group can read; see cmd_journal_access. It lives in # .terraria under /var/log/journal (or /run/log/journal). @@ -341,7 +423,7 @@ can_read_journal() { journal_hint() { { - echo "Note: commands are sent, but you can't see the server's output." + echo "$1" if [[ " $(id -Gn) " == *" $SVC_USER "* ]]; then echo "You're in the '$SVC_USER' group, but the servers' journal isn't set up for it" echo "yet. That happens automatically when a server (re)starts, or run once:" @@ -385,7 +467,7 @@ cmd_cmd() { if ! can_read_journal; then printf '%s\n' "$*" > "$fifo" echo "sent." - journal_hint + journal_hint "Note: commands are sent, but you can't see the server's output." return 0 fi @@ -412,7 +494,7 @@ cmd_cmd() { printf '%s\n' "$out" else echo "sent; no output from the server within 5s (it may be busy or still starting)." - echo "Watch it with: terractl attach $name" + echo "Watch it with: $(hint group attach "$name")" fi } @@ -427,7 +509,7 @@ cmd_attach() { journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n 30 -o cat & ATTACH_PID=$! # killed by cleanup() however attach ends else - journal_hint + journal_hint "Note: commands are sent, but you can't see the server's output." fi trap 'echo; exit 0' INT @@ -436,7 +518,7 @@ cmd_attach() { [[ -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.)" + "or '$(hint group stop "$name")' to stop it.)" continue fi printf '%s\n' "$line" > "$fifo" @@ -444,6 +526,37 @@ cmd_attach() { echo } +# Like attach, but only shows output: nothing typed is sent to the server, so +# it's safe to leave running. Works whether or not the server is running. +cmd_watch() { + local name=$1 lines=30 quiet=0; shift + while [[ $# -gt 0 ]]; do + case $1 in + -n|--lines) + [[ ${2:-} =~ ^[0-9]+$ ]] || die "$1 needs a number of lines" + lines=$2; shift ;; + -q|--quiet) quiet=1 ;; + *) die "unknown option '$1'" ;; + esac + shift + done + # Only checkable with read access to the instances (root or the group) + if [[ -x $BASE_DIR && ! -f $(inst_dir "$name")/instance.conf ]]; then + die "no instance '$name'" + fi + if ! can_read_journal; then + journal_hint "Can't show the server's output: no read access to the servers' journal." + exit 1 + fi + if [[ $quiet -eq 0 ]]; then + if ! systemctl is-active --quiet "terraria@$name" 2>/dev/null; then + echo "terraria@$name isn't running; output will appear here once it starts." + fi + echo "Watching terraria@$name (read-only). Ctrl-C quits; the server keeps running." + fi + exec journalctl --namespace="$JOURNAL_NS" -u "terraria@$name" -f -n "$lines" -o cat +} + cmd_delete() { local name=$1; shift local assume_yes=0 backup=1 dir archive answer @@ -497,6 +610,39 @@ cmd_delete() { echo "deleted instance '$name'" } +# Gives an instance a new name: its folder, the paths in serverconfig.txt, and +# its service (stopped and started again, and still starting at boot if it did). +cmd_rename() { + local name=$1 new=${2:-} dir newdir state enabled + [[ $# -eq 2 ]] || die "usage: terractl rename " + valid_name "$new" + [[ $EUID -eq 0 ]] || die "rename must be run as root: sudo terractl rename $name $new" + dir=$(inst_dir "$name"); newdir=$(inst_dir "$new") + [[ -f $dir/instance.conf ]] || die "no instance '$name'" + [[ ! -e $newdir ]] || die "instance '$new' already exists" + state=$(systemctl is-active "terraria@$name" 2>/dev/null || true) + enabled=$(systemctl is-enabled "terraria@$name" 2>/dev/null || true) + if [[ $state == inactive || $state == failed ]] && instance_running "$name"; then + die "'$name' is running outside systemd (e.g. 'terractl run' in another terminal); stop it first" + fi + if [[ $state == active || $state == activating || $state == reloading ]]; then + echo "stopping terraria@$name (saves the world first)..." + systemctl stop "terraria@$name" + fi + if [[ $enabled == enabled ]]; then systemctl disable -q "terraria@$name"; fi + systemctl reset-failed "terraria@$name.service" "terraria@$name.socket" 2>/dev/null || true + 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 [[ $enabled == enabled ]]; then systemctl enable -q "terraria@$new"; echo "terraria@$new starts at boot"; fi + if [[ $state == active || $state == activating || $state == reloading ]]; then + systemctl start "terraria@$new" + echo "terraria@$new started" + fi +} + # ------------------------------------------------------- export/import ---- # Archive layout: terractl-export.txt (info about the export; optional) # /... (instance.conf, serverconfig.txt, data/, [server/]) @@ -653,6 +799,13 @@ cmd_import() { # Keep the server version the world was exported with, unless one is pinned already load_conf "$name" + # A pin to an absolute path from another host usually doesn't exist here: use + # the zip of that name in this host's dist folder instead + if [[ $ZIP == /* && ! -r $ZIP && $(basename "$ZIP") =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then + log "ZIP=$ZIP isn't on this host; now ZIP=$(basename "$ZIP") (from $DIST_DIR)" + ZIP=$(basename "$ZIP") + sed -i "s/^ZIP=.*/ZIP=$ZIP/" "$dir/instance.conf" + fi if [[ -z $ZIP && $meta_zip =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; then sed -i "s/^ZIP=.*/ZIP=$meta_zip/" "$dir/instance.conf" ZIP=$meta_zip @@ -671,7 +824,7 @@ cmd_import() { else echo "imported '$name' into $dir" fi - echo "Review $dir/serverconfig.txt, then: systemctl enable --now terraria@$name" + echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)" } # ---------------------------------------------------------------- mods ---- @@ -699,7 +852,7 @@ write_enabled() { # write_enabled ... mods_restart_hint() { if systemctl is-active --quiet "terraria@$name" 2>/dev/null; then - echo "Restart to apply: sudo systemctl restart terraria@$name" + echo "Restart to apply: $(hint group restart "$name")" fi } @@ -712,7 +865,7 @@ mods_list() { done if [[ ${#files[@]} -eq 0 ]]; then echo "No mods installed in $mdir" - echo "Add some with: terractl mods $name add " + echo "Add some with: $(hint root mods "$name" add "")" return 0 fi printf '%-32s %s\n' MOD STATE @@ -820,7 +973,7 @@ cmd_mods() { 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)" + [[ -f $dir/instance.conf ]] || die "no instance '$name' (create it with: $(hint root new "$name" tmodloader))" load_conf "$name" [[ $TYPE == tmodloader ]] || die "'$name' is a $TYPE instance; mods need TYPE=tmodloader" mdir="$dir/data/Mods" @@ -850,26 +1003,67 @@ Created $type instance '$name' in $dir Review $dir/serverconfig.txt, then: EOF if [[ $type == tmodloader ]]; then - echo " terractl mods $name add (before first start)" + echo " $(hint root mods "$name" add "") (before first start)" fi cat </dev/null | tail -n1) + # enabled, disabled, or e.g. masked + boot=$(systemctl is-enabled "terraria@$n" 2>/dev/null || true) state=$(systemctl is-active "terraria@$n" 2>/dev/null || true) if [[ $state != active ]] && instance_running "$n"; then state="running (outside systemd)"; fi - printf '%-20s %-11s %-6s %s\n' "$n" "$TYPE" "${port:--}" "$state" + printf '%-20s %-11s %-6s %-9s %s\n' "$n" "$TYPE" "${port:--}" "${boot:--}" "$state" ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)" done } @@ -884,17 +1078,69 @@ cmd_reinstall() { 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" + die "'$name' is running; --clean would delete .NET under it. Stop it first: $(hint group stop "$name")" fi 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" + echo "server/ will be re-unpacked on next start: $(hint group restart "$name")" +} + +# key=value facts, one per line, for people and for terractl-remote (copy, move, +# tab completion). Without a name: this host. Repeated keys are lists. +cmd_info() { + local name=${1:-} dir f ver installed="" port + if [[ -z $name ]]; then + ver=$TERRACTL_VERSION; if [[ $ver == *@* ]]; then ver=development; fi + echo "version=$ver" + echo "host=$(hostname 2>/dev/null || echo unknown)" + echo "base_dir=$BASE_DIR" + echo "dist_dir=$DIST_DIR" + echo "run_dir=$RUN_DIR" + for f in "$DIST_DIR"/*.zip; do + if [[ -f $f ]]; then echo "dist_zip=$(basename "$f")"; fi + done + for f in "$BASE_DIR"/*/instance.conf; do + if [[ -f $f ]]; then echo "instance=$(basename "$(dirname "$f")")"; fi + done + return 0 + fi + dir=$(inst_dir "$name") + echo "instance=$name" + if [[ ! -f $dir/instance.conf ]]; then echo "exists=no"; return 0; fi + load_conf "$name" + if [[ -r $dir/server/.installed ]]; then read -r _ installed < "$dir/server/.installed" || true; fi + port=$(sed -n 's/^port=\([0-9][0-9]*\).*/\1/p' "$dir/serverconfig.txt" 2>/dev/null | tail -n1) || true + echo "exists=yes" + echo "dir=$dir" + echo "type=$TYPE" + echo "port=$port" + echo "pinned_zip=$ZIP" + echo "installed_zip=$installed" + echo "state=$(systemctl is-active "terraria@$name" 2>/dev/null || true)" + echo "boot=$(systemctl is-enabled "terraria@$name" 2>/dev/null || true)" + echo "running=$(if instance_running "$name"; then echo yes; else echo no; fi)" +} + +# Other hosts are handled by terractl-remote (Python). It runs this script for +# anything it does on this host, and passes on where it was called from. +run_remote_helper() { + local helper=$REMOTE_HELPER ver=$TERRACTL_VERSION self + self=$(readlink -f "$0") + # A development copy uses the helper from its source tree + if [[ $helper == *@* ]]; then helper="$(dirname "$self")/../lib/terractl-remote"; fi + if [[ $ver == *@* ]]; then ver=development; fi + command -v python3 >/dev/null || die "this needs python3 (sudo apt install python3)" + TERRACTL_BIN=$self TERRACTL_VERSION=$ver TERRACTL_CALLER_PWD=$CALLER_PWD \ + TERRACTL_LISTEN=${TERRACTL_LISTEN:-} exec python3 "$helper" "$@" } [[ $# -ge 1 ]] || usage +case $1 in + --host|--host=*|-H|remote|daemon|copy|move) run_remote_helper "$@" ;; +esac command=$1; shift case $command in -h|--help|help) usage 0 ;; @@ -904,6 +1150,7 @@ case $command in else echo "terractl $TERRACTL_VERSION"; fi exit 0 ;; list) cmd_list; exit ;; + info) if [[ $# -eq 0 ]]; then cmd_info; exit; fi ;; journal-access) cmd_journal_access; exit ;; esac [[ $# -ge 1 ]] || usage @@ -917,20 +1164,30 @@ if [[ $command == prepare || $command == run ]]; then 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'" + # Being in the group isn't enough: files would end up owned by the caller + die "'$command' needs root (it switches to the '$SVC_USER' user by itself): sudo terractl $command $1" fi fi case $command in prepare) cmd_prepare "$1" ;; run) cmd_run "$1" ;; - stop) cmd_stop "$@" ;; + service-stop) cmd_service_stop "$@" ;; + stop) + # Before 0.0.11 the service stopped servers with 'terractl stop '; + # a unit loaded before an upgrade may still do that + if [[ -n ${INVOCATION_ID:-} && $# -eq 2 ]]; then cmd_service_stop "$@"; else cmd_svc stop "$@"; fi ;; + start|restart) cmd_svc "$command" "$@" ;; + info) cmd_info "$1" ;; cmd) cmd_cmd "$@" ;; attach) cmd_attach "$1" ;; + watch) cmd_watch "$@" ;; + enable|disable) cmd_enable "$command" "$@" ;; mods) cmd_mods "$@" ;; new) cmd_new "$@" ;; reinstall) cmd_reinstall "$@" ;; delete) cmd_delete "$@" ;; + rename) cmd_rename "$@" ;; export) cmd_export "$@" ;; import) cmd_import "$@" ;; *) usage ;; diff --git a/completions/bash/terractl b/completions/bash/terractl index d7e5392..02fcfbf 100644 --- a/completions/bash/terractl +++ b/completions/bash/terractl @@ -32,6 +32,33 @@ _terractl_instances() { fi } +# Remotes this user added with 'terractl remote add' +_terractl_remotes() { + local f=${TERRACTL_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/terractl}/remotes.json + [[ -r $f ]] || return 0 + python3 -c 'import json, sys; print("\n".join(json.load(open(sys.argv[1]))["remotes"]))' "$f" 2>/dev/null +} + +# Instance names on a remote. This asks its daemon, so it can take a moment. +_terractl_remote_instances() { + timeout 5 terractl --host "$1" info 2>/dev/null :] for copy and move +_terractl_places() { + if [[ $cur == -* ]]; then + _terractl_reply "--with-server" + elif [[ $cur == *:* ]]; then + mapfile -t COMPREPLY < <(compgen -P "${cur%:*}:" -W "$(_terractl_remote_instances "${cur%:*}")" -- "${cur##*:}") + else + mapfile -t COMPREPLY < <(compgen -W "$(_terractl_instances) $(_terractl_remotes | sed 's/$/:/')" -- "$cur") + # After ":" comes an instance name, not a space + if [[ ${#COMPREPLY[@]} -eq 1 && ${COMPREPLY[0]} == *: ]]; then compopt -o nospace 2>/dev/null; fi + fi + # Readline sees only the part after the last ':' + if declare -F __ltrim_colon_completions >/dev/null 2>&1; then __ltrim_colon_completions "$cur"; fi +} + # Mods installed in an instance (*.tmod files), or enabled ones (enabled.json) _terractl_mods() { local mdir f @@ -70,16 +97,33 @@ _terractl() { if declare -F _init_completion >/dev/null 2>&1; then # shellcheck disable=SC2034 # prev is set by _init_completion but not needed here local prev - _init_completion || return + # -n : keeps ":" (copy, move) in one word + _init_completion -n : || return else cur=${COMP_WORDS[COMP_CWORD]} words=("${COMP_WORDS[@]}") cword=$COMP_CWORD fi - local commands="new list attach cmd mods reinstall delete export import prepare run --help --version" + local commands="new list info start stop restart enable disable attach watch cmd mods reinstall delete rename export import" local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help" + # terractl --host ...: the same as on this host, except + # that instance names come from the remote and mod names aren't offered + local host="" + if [[ ${words[1]:-} == --host || ${words[1]:-} == -H ]]; then + if (( cword == 2 )); then + _terractl_reply "$(_terractl_remotes)" + return + fi + host=${words[2]} + words=("${words[0]}" "${words[@]:3}") + cword=$((cword - 2)) + commands+=" --help --version" + else + commands+=" copy move remote daemon prepare run --host --help --version" + fi + if (( cword == 1 )); then _terractl_reply "$commands" return @@ -97,10 +141,31 @@ _terractl() { # import : the name is new, so nothing to offer if (( cword == 3 )); then _terractl_files tar.gz; fi return ;; + copy|move) + _terractl_places + return ;; + remote) + if (( cword == 2 )); then + _terractl_reply "add list remove" + elif (( cword == 3 )) && [[ ${words[2]} == remove ]]; then + _terractl_reply "$(_terractl_remotes)" + fi + return ;; + daemon) + if (( cword == 2 )); then + _terractl_reply "enable disable status trust" + elif (( cword == 3 )) && [[ ${words[2]} == trust ]]; then + _terractl_reply "add list remove" + fi + return ;; esac if (( cword == 2 )); then - _terractl_reply "$(_terractl_instances)" + if [[ -n $host ]]; then + _terractl_reply "$(_terractl_remote_instances "$host")" + else + _terractl_reply "$(_terractl_instances)" + fi return fi @@ -110,6 +175,11 @@ _terractl() { if (( cword == 3 )); then _terractl_reply "$console"; fi ;; reinstall) if (( cword == 3 )); then _terractl_reply "--clean"; fi ;; + enable|disable) + if (( cword == 3 )); then _terractl_reply "--now"; fi ;; + watch) + # the number after -n is free-form + if [[ $cur == -* ]]; then _terractl_reply "-n -q"; fi ;; delete) _terractl_reply "-y --yes --no-backup" ;; export) @@ -124,9 +194,9 @@ _terractl() { # .tmod files and folders (e.g. the Windows export folder) _terractl_files tmod ;; enable|remove) - _terractl_reply "$(_terractl_mods "$instance" installed)" ;; + if [[ -z $host ]]; then _terractl_reply "$(_terractl_mods "$instance" installed)"; fi ;; disable) - _terractl_reply "$(_terractl_mods "$instance" enabled)" ;; + if [[ -z $host ]]; then _terractl_reply "$(_terractl_mods "$instance" enabled)"; fi ;; esac ;; esac } diff --git a/config/default/terractl b/config/default/terractl index a6041e4..ebcd0f5 100644 --- a/config/default/terractl +++ b/config/default/terractl @@ -12,3 +12,8 @@ # Seconds to wait for a server to save and exit on stop (keep below 90) #TERRARIA_STOP_TIMEOUT=80 + +# Where terractld (terractl daemon) listens for other hosts: an address and/or +# port. Default: all addresses, port 7776. After changing it: +# sudo systemctl restart terractld +#TERRACTL_LISTEN=10.0.10.77:7776 diff --git a/config/polkit/terractl.rules b/config/polkit/terractl.rules new file mode 100644 index 0000000..dbcf62b --- /dev/null +++ b/config/polkit/terractl.rules @@ -0,0 +1,23 @@ +// Members of the 'terraria' group may start, stop and restart Terraria servers +// (terraria@.service) without sudo. Nothing else: enable/disable and all +// other units still need root. +// +// Starting a name that has no instance yet creates a vanilla instance with +// default settings, as it does for root. +// +// Needs polkit 0.106 or newer (JavaScript rules); Ubuntu 22.04's 0.105 ignores it. +polkit.addRule(function(action, subject) { + if (action.id != "org.freedesktop.systemd1.manage-units" || + !subject.isInGroup("terraria")) { + return polkit.Result.NOT_HANDLED; + } + // Same names terractl accepts (valid_name) + var unit = action.lookup("unit") || ""; + var verb = action.lookup("verb") || ""; + var verbs = ["start", "stop", "restart", "try-restart", "reset-failed"]; + if (/^terraria@[A-Za-z0-9_][A-Za-z0-9_.-]*\.service$/.test(unit) && + verbs.indexOf(verb) >= 0) { + return polkit.Result.YES; + } + return polkit.Result.NOT_HANDLED; +}); diff --git a/docs/guide.md b/docs/guide.md index 9a896fc..1ccab34 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -16,14 +16,15 @@ Run any number of vanilla Terraria and tModLoader dedicated servers on one Linux 10. [Configuration](#configuration) 11. [Mods (tModLoader)](#mods-tmodloader) 12. [Versions and upgrades](#versions-and-upgrades) -13. [Deleting and restoring](#deleting-and-restoring) +13. [Deleting, renaming and restoring](#deleting-renaming-and-restoring) 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) +15. [Managing other hosts](#managing-other-hosts) +16. [Backups](#backups) +17. [Permissions](#permissions) +18. [Running by hand for debugging](#running-by-hand-for-debugging) +19. [Troubleshooting](#troubleshooting) +20. [Tab completion](#tab-completion) +21. [Command reference](#command-reference) --- @@ -49,10 +50,13 @@ Everything is installed by the `terraria-server-manager` package: | `/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/terraria-server-manager/terractl-remote` | The part of `terractl` that manages other hosts (`--host`, `remote`, `daemon`, `copy`, `move`), in Python. | +| `/usr/lib/systemd/system/terractld.service` | The daemon that lets other hosts manage this one. Off until you turn it on (see [Managing other hosts](#managing-other-hosts)). | +| `/etc/default/terractl` | Optional overrides (locations, stop timeout, the daemon's address). | | `/etc/systemd/journald@terraria.conf` | Size and retention of the servers' own journal (see [Logs](#logs)). | | `/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/polkit-1/rules.d/50-terractl.rules` | Lets members of the `terraria` group start, stop and restart servers without `sudo`. | | `/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: @@ -61,7 +65,7 @@ What happens when an instance starts: 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 servers' own journal (see [Logs](#logs)). -On stop, `terractl stop` sends `exit` through the pipe so the world is saved before the server shuts down. +On stop, `terractl service-stop` sends `exit` through the pipe so the world is saved before the server shuts down. --- @@ -86,6 +90,8 @@ On stop, `terractl stop` sends `exit` through the pipe so the world is saved bef └── .deleted/ Backups made by 'terractl delete' /run/terraria/.stdin Console pipe (exists only while running) + +/var/lib/terractl/ The daemon's certificate and trusted computers (root only) ``` The server can write only inside `instances/`. It reads `dist/` but can never change it. @@ -100,15 +106,15 @@ The package works on Debian 12 or newer and Ubuntu 22.04 or newer. sudo apt install ./terraria-server-manager_0.0.9_all.deb ``` -Using `apt install ./file.deb` (rather than `dpkg -i`) installs the dependencies automatically. `unzip` is required. `curl` and the ICU library are "recommended", which apt installs by default; they're only needed for tModLoader instances, which crash with `Aborted` without ICU. +Using `apt install ./file.deb` (rather than `dpkg -i`) installs the dependencies automatically. `unzip` is required. `curl` and the ICU library are "recommended", which apt installs by default; they're only needed for tModLoader instances, which crash with `Aborted` without ICU. `polkitd` is also recommended: it lets members of the `terraria` group start and stop servers without `sudo` (see [Permissions](#permissions)). 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. +- installs the units, the helper script and the polkit rule. -It doesn't start or enable anything. +It doesn't start or enable anything, including the daemon for managing the host from elsewhere. If you use a firewall, open each instance's port for **TCP**. Terraria uses TCP only. @@ -173,6 +179,8 @@ sudo make install # or install directly without a package (PREFIX=/usr/ sudo make uninstall # removes a 'make install' (keeps settings and data) ``` +`make install` with a prefix other than `/usr` puts the polkit rule in `/etc/polkit-1/rules.d/`, because polkit doesn't read `/usr/local`. + The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/control` to yourself before publishing packages. ### Changing locations @@ -281,7 +289,7 @@ Instance names may contain letters, digits, `_`, `-` and `.`, and must not start 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 enable calamity --now sudo terractl attach calamity # watch it load and generate the world ``` @@ -291,24 +299,41 @@ You can also skip `new` and just run `sudo systemctl start terraria@`. Tha ## Starting, stopping and boot -Instances are ordinary systemd services: +Instances are ordinary systemd services, which `terractl` starts and stops for you: ```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 +sudo terractl start calamity +sudo terractl stop calamity # saves the world, then exits +sudo terractl restart calamity ``` -To see all instances with their type, port and state: +These are the same as `systemctl start|stop|restart terraria@`, which work too; `systemctl status terraria@` shows the details. `terractl start` refuses names that have no instance, whereas `systemctl start` creates one. + +Whether a server starts at boot is set with `terractl`: + +```bash +sudo terractl enable calamity # start at boot +sudo terractl enable calamity --now # start at boot, and start it now +sudo terractl disable calamity # don't start at boot (a running server keeps running) +sudo terractl disable calamity --now # don't start at boot, and stop it now (saves the world) +``` + +These do the same as `systemctl enable`/`disable terraria@`, which also still work. `terractl enable` refuses names that have no instance, so a typo can't create a new instance at the next boot. + +Members of the `terraria` group can `start`, `stop` and `restart` servers without `sudo` (see [Permissions](#permissions)). `enable` and `disable` always need `sudo`. + +To see all instances with their type, port, whether they start at boot, and state: ```bash sudo terractl list ``` +``` +INSTANCE TYPE PORT BOOT STATE +calamity tmodloader 7778 enabled active +survival vanilla 7777 disabled inactive +``` + Behaviour to be aware of: - **Crashes restart automatically** after 10 seconds. @@ -330,10 +355,20 @@ 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. +`attach` will not send `exit`, since that would stop the server. Use `terractl stop` to stop it. The one visible difference from a real terminal: the server's `:` prompt only appears once a newline follows it. +### Read-only: `watch` + +```bash +terractl watch calamity # last 30 lines, then follow +terractl watch calamity -n 200 # start with the last 200 lines +terractl watch calamity -q # without the lines about what it's doing +``` + +`watch` shows the server's output like `attach` does, but never sends anything to the server. That makes it safe to leave open, for instance on a second screen, with no risk of typing into the console. **Ctrl-C** quits and the server keeps running. It also works while the server is stopped: it shows the last output and picks up again when the server starts. + ### One-off commands: `cmd` ```bash @@ -343,7 +378,7 @@ sudo terractl cmd calamity say Restarting in 5 minutes `cmd` prints the server's reply, then returns once the server has been quiet for a second (at most 5 seconds). For example, `terractl cmd calamity playing` lists who's online. That makes it handy in scripts and cron jobs too. If the server doesn't answer within 5 seconds (for instance while it's still loading), `cmd` says so; the command has still been sent. -Members of the `terraria` group can use `attach` and `cmd` fully, output included, without `sudo`. See [Logs](#logs) for how that's arranged. +Members of the `terraria` group can use `attach`, `watch` and `cmd` fully, output included, without `sudo`. See [Logs](#logs) for how that's arranged. ### Logs @@ -543,7 +578,7 @@ sudo systemctl restart terraria@calamity --- -## Deleting and restoring +## Deleting, renaming and restoring ```bash sudo terractl delete calamity # asks you to type the name @@ -557,11 +592,17 @@ To restore an instance, import its backup. You can give it back its old name, or ```bash 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 +sudo terractl enable calamity --now # server/ is unpacked again on start ``` `.deleted/` is never cleaned up automatically. Remove old archives yourself. +To give an instance another name, use `rename`. It stops the server if it's running, renames the folder, updates the paths in `serverconfig.txt`, and starts it again under the new name. The port, world, mods and unpacked server stay as they are, and if it started at boot, it still does: + +```bash +sudo terractl rename calamity calamity-old +``` + --- ## Exporting and importing @@ -599,11 +640,13 @@ After extracting, it adjusts the instance for its new home: `import` also accepts: -- the backups that `delete` makes (see [Deleting and restoring](#deleting-and-restoring)); +- the backups that `delete` makes (see [Deleting, renaming and restoring](#deleting-renaming-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 +If the new machine runs the daemon, `sudo terractl move calamity newhost:` does all of this in one step; see [Managing other hosts](#managing-other-hosts). By hand: + ```bash # old machine sudo terractl export calamity /tmp/calamity.tar.gz @@ -611,11 +654,116 @@ 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 +sudo terractl enable calamity --now ``` --- +## Managing other hosts + +One computer can manage the Terraria servers of several hosts. A host that should be managed runs **the daemon**, `terractld`. The computers that manage it add it as a **remote**, as with LXD or Incus remotes. Then any command can be run there with `--host`, and instances can be copied and moved between hosts: + +```bash +terractl --host nas list +terractl --host nas attach calamity +sudo terractl move calamity nas: # this host → nas +terractl copy nas:calamity pi:test # nas → pi, as 'test' +``` + +### Turning on the daemon + +On the host to be managed: + +```bash +sudo terractl daemon enable +``` + +This starts `terractld.service` and makes it start at boot. It listens on **TCP port 7776**, on all addresses. If a firewall is active, allow that port (for example `sudo ufw allow 7776/tcp`), ideally only from the computers that will manage the host. To listen on one address or on another port, set `TERRACTL_LISTEN` in `/etc/default/terractl` (e.g. `TERRACTL_LISTEN=10.0.10.77:7776`) and run `sudo systemctl restart terractld`. + +`sudo terractl daemon disable` turns it off again. `terractl daemon status` shows whether it's running, where it listens and its certificate's fingerprint, and with `sudo`, which computers it trusts. + +The daemon is an ordinary systemd service: systemd starts it at boot, restarts it if it crashes, and keeps its log. `journalctl -u terractld` lists every command it ran and for which computer. + +### Adding a remote + +Each computer that should manage the host needs a one-time token. On the host with the daemon: + +```bash +sudo terractl daemon trust add laptop +``` + +`laptop` is a name for the computer, used in the host's log and to revoke it later. The command prints what to run on that computer: + +```bash +terractl remote add nas tctl1_eyJ2IjoxLCJmcCI6... +``` + +`nas` is the name you'll use for the host there; choose any. The token contains the host's addresses, so usually nothing else is needed. If none of them works from the computer (behind NAT, for example), add the address: `terractl remote add nas 203.0.113.5 tctl1_...`, or `nas.example.com:7800` for another port. + +A token works **once**, **within 24 hours**. Remotes are kept per user in `~/.config/terractl/remotes.json`, readable only by you. Under `sudo`, the remotes of the person who ran sudo are used. + +```bash +terractl remote list # your remotes +terractl remote remove nas # forget one +``` + +### Running commands on a remote: `--host` + +Put `--host ` in front of any command: + +```bash +terractl --host nas list +terractl --host nas new calamity tmodloader +terractl --host nas mods calamity add ~/tmod-export # sends the files +terractl --host nas enable calamity --now +terractl --host nas attach calamity +terractl --host nas export calamity # fetches ./calamity--