8 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 7adf5c2999 warn when the services can't reach moved folders
- list, new, import and enable check TERRARIA_BASE and TERRARIA_DIST
  against the units' ProtectSystem/ProtectHome/ReadWritePaths
  (terraria@ always, terractld when it's on) and print the drop-in
  to add. prepare explains why it can't create an instance folder.
- Guide: bind mounts and drop-ins under "Changing locations", and a
  new section for instances or zips under /home.
- journal-access in tab completion and the command reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:51:29 +00:00
abrown38andClaude Opus 5.5 96f7876729 add upgrade command; reinstall keeps the same version
- terractl upgrade <name> lists the zips an instance can use; with a zip
  or 'latest' it checks the zip, stops the server, backs up worlds and
  configs to instances/.backups/, pins ZIP=, unpacks right away and
  starts the server again. Downgrades need --downgrade.
- reinstall pins the installed zip when ZIP= is blank, so it never
  changes version, and keeps server/.installed (uses a .reinstall flag).
- Resolve terractl's own path before cd /, so a relative $0 works.
- Docs, completion and the daemon's command list; version 0.0.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:37:51 +00:00
abrown38 ef63609db9 fix permissions subcommand 2026-10-02 21:21:59 +00:00
abrown38 c4a12727d4 better permission warnings
release / deb (push) Successful in 14s
2026-09-30 21:34:05 +00:00
abrown38andClaude Opus 5.5 52998de8f4 set maintainer
release / deb (push) Successful in 14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:14:52 +00:00
abrown38 208c81899a debian repo
release / deb (push) Successful in 14s
2026-09-30 20:01:10 +00:00
abrown38 8084c39e37 release build workflow 2026-09-30 19:57:43 +00:00
abrown38 998d9f603d simplify remotes 2026-09-30 17:47:41 +00:00
13 changed files with 923 additions and 181 deletions

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
# Builds the .deb when a version tag is pushed and publishes it as a release,
# with that version's CHANGELOG.md section as the release notes. To release,
# set VERSION and CHANGELOG.md, commit, then:
# git tag v$(cat VERSION) && git push origin v$(cat VERSION)
name: release
on:
push:
tags: ['v*']
jobs:
deb:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check that the tag matches VERSION
run: |
if [ "$GITHUB_REF_NAME" != "v$(cat VERSION)" ]; then
echo "tag $GITHUB_REF_NAME doesn't match VERSION ($(cat VERSION))"
exit 1
fi
- name: Install build tools
env:
DEBIAN_FRONTEND: noninteractive
run: |
SUDO=$(command -v sudo || true)
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq make python3 dpkg curl shellcheck python3-pyflakes
- name: Build (make check runs first)
run: make deb
- name: Publish the release
env:
GITEA_URL: ${{ gitea.server_url }}
GITEA_REPO: ${{ gitea.repository }}
# The job's own token; set a RELEASE_TOKEN secret instead if it isn't allowed to write releases
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
run: python3 packaging/gitea-release.py "$GITHUB_REF_NAME" build/*.deb
# Gitea's Debian registry, as an apt repository:
# deb <server>/api/packages/<owner>/debian stable main
- name: Publish to the apt repository
env:
POOL: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/debian/pool/stable/main
# The job's own token; set a PACKAGE_TOKEN secret (scope write:package) if it isn't allowed to
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.GITEA_TOKEN }}
run: |
deb=$(echo build/*.deb)
upload() {
curl -sS -o response.txt -w '%{http_code}' --user "$GITHUB_ACTOR:$TOKEN" \
--upload-file "$deb" "$POOL/upload"
}
status=$(upload)
if [ "$status" = 409 ]; then
# This version is there already (the tag was pushed again): replace it
curl -sS -f --user "$GITHUB_ACTOR:$TOKEN" -X DELETE \
"$POOL/terraria-server-manager/$(cat VERSION)/all"
status=$(upload)
fi
if [ "$status" != 201 ]; then
echo "upload failed: HTTP $status"; cat response.txt; echo; exit 1
fi
echo "published $(basename "$deb") to $POOL"
+1
View File
@@ -1,2 +1,3 @@
build/ build/
*.deb *.deb
__pycache__/
+16 -1
View File
@@ -1,5 +1,20 @@
# Changelog # Changelog
## 0.0.12 - unreleased
- New `upgrade` command to change an instance's server version, up or down:
- `terractl upgrade <name>` 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 <name> <zip>|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.
- 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.
- `list`, `new`, `import` and `enable` warn when `TERRARIA_BASE` or `TERRARIA_DIST` is somewhere the services' sandbox doesn't allow (the instances folder not in `ReadWritePaths=`, or either folder under `/home`), and print the exact drop-in to add. The daemon (`terractld.service`) is checked too when it's on. A server that can't create its folder now says why in its log, instead of only "Read-only file system".
- The guide's "Changing locations" now covers bind mounts (the simplest way to use another disk) as well as drop-ins, and a new section explains keeping instances or zips under `/home`.
- `journal-access` is offered by tab completion and listed in the guide's command reference.
- `terractl` run by a relative path (e.g. `sudo ./bin/terractl prepare <name>` from a source tree) no longer fails to switch to the `terraria` user.
- New `fix-perms` command: `sudo terractl fix-perms <name>` gives every file of an instance back to the `terraria` user and reapplies the usual modes (group can read, others nothing; server binaries executable), e.g. after copying worlds in as root. Symlinks are not followed. Works with `--host` too.
- `list` warns about zips in `dist/` that the `terraria` user can't read (e.g. copied in with `scp -p` from a private file), and `info` lists them as `unreadable_zip=`. A server that fails to install because of one now says how to fix it (`sudo chmod 644 <zip>`) instead of just "cannot read", and a missing zip is reported as not found.
## 0.0.11 - 2026-09-30 ## 0.0.11 - 2026-09-30
- The servers now log to their own journal (systemd log namespace `terraria`), which members of the `terraria` group can read. `attach` and `cmd` therefore show output to `terraria` group members without `sudo` or the `systemd-journal` group, and without access to the rest of the system's logs. Read it directly with `journalctl --namespace=terraria -u terraria@<name>`. - The servers now log to their own journal (systemd log namespace `terraria`), which members of the `terraria` group can read. `attach` and `cmd` therefore show output to `terraria` group members without `sudo` or the `systemd-journal` group, and without access to the rest of the system's logs. Read it directly with `journalctl --namespace=terraria -u terraria@<name>`.
@@ -11,7 +26,7 @@
- `list` has a new `BOOT` column showing whether each server starts at boot (`enabled`, `disabled`, or e.g. `masked`). - `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: - 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 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 <name>` creates a one-time token for a computer, valid for 24 hours; `daemon trust list` and `daemon trust remove <name>` show and revoke access. On that computer, `terractl remote add <remote> <token>` adds the host; `remote list` and `remote remove` manage remotes, which are stored per user. - `daemon enable` prints a command with a one-time token (valid for 24 hours); running it on another computer, `terractl remote add <token>`, adds the host as a remote named after its hostname (or `remote add <name> <token>`). `sudo terractl daemon token` makes tokens for more computers, `daemon status` lists the trusted ones, and `daemon revoke <name>` removes one. Adding a host again replaces the old remote, e.g. after a revoke. `remote list` and `remote remove` manage remotes, which are stored per user.
- `terractl --host <remote> <command>` 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 <remote> ...`. - `terractl --host <remote> <command>` 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 <remote> ...`.
- `terractl copy` and `terractl move` copy, move or rename instances, on one host or between hosts (`[<remote>:]<name>`). 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. - `terractl copy` and `terractl move` copy, move or rename instances, on one host or between hosts (`[<remote>:]<name>`). 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. - 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.
+21 -3
View File
@@ -13,7 +13,7 @@ sudo terractl attach calamity
- **Many servers, one host.** Each instance is `terraria@<name>.service`, with its own directory, port, world and mods. - **Many servers, one host.** Each instance is `terraria@<name>.service`, with its own directory, port, world and mods.
- **Vanilla and tModLoader side by side**, chosen per instance. - **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. - **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.
- **Safe shutdowns.** Stopping a server saves the world first. - **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. - **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. - **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership.
@@ -32,7 +32,19 @@ sudo terractl attach calamity
## Install ## Install
Download the `.deb` from the releases page, then: From the apt repository, which also brings updates with `sudo apt upgrade`:
```bash
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/debian/repository.key \
-o /etc/apt/keyrings/terraria-server-manager.asc
echo "deb [signed-by=/etc/apt/keyrings/terraria-server-manager.asc] https://gitea.adbraun.lol/api/packages/adam/debian stable main" |
sudo tee /etc/apt/sources.list.d/terraria-server-manager.list
sudo apt update
sudo apt install terraria-server-manager
```
Or download the `.deb` from the releases page, then:
```bash ```bash
sudo apt install ./terraria-server-manager_<version>_all.deb sudo apt install ./terraria-server-manager_<version>_all.deb
@@ -69,7 +81,13 @@ make check # bash -n on all scripts, plus shellcheck if installed
make deb # build/terraria-server-manager_<version>_all.deb make deb # build/terraria-server-manager_<version>_all.deb
``` ```
To release: update `VERSION` and `CHANGELOG.md`, run `make deb`, and attach the `.deb` to the release. To release, update `VERSION` and `CHANGELOG.md`, commit, and push a matching tag:
```bash
git tag v$(cat VERSION) && git push origin v$(cat VERSION)
```
The `release` workflow (`.gitea/workflows/release.yaml`, Gitea Actions) then builds the `.deb`, publishes it as a release with that version's changelog section as the notes, and adds it to the apt repository. It needs a runner with the `ubuntu-latest` label. To build a package by hand instead, run `make deb`.
## Disclaimer ## Disclaimer
+1 -1
View File
@@ -1 +1 @@
0.0.11 0.0.12
+386 -15
View File
@@ -19,6 +19,7 @@ set -euo pipefail
# user may not be allowed into, e.g. /root). Paths given on the command line are # user may not be allowed into, e.g. /root). Paths given on the command line are
# resolved against where terractl was called from, via abspath(). # resolved against where terractl was called from, via abspath().
CALLER_PWD=$PWD CALLER_PWD=$PWD
SELF=$(readlink -f "$0") # before the cd, in case $0 is a relative path
cd / cd /
abspath() { abspath() {
@@ -77,8 +78,13 @@ Admin commands:
attach <name> interactive console (Ctrl-D to detach) attach <name> interactive console (Ctrl-D to detach)
watch <name> [-n <lines>] [-q] follow the server's output, read-only (Ctrl-C to quit) watch <name> [-n <lines>] [-q] follow the server's output, read-only (Ctrl-C to quit)
cmd <name> <console command...> send a console command (e.g. save, say hi) cmd <name> <console command...> send a console command (e.g. save, say hi)
reinstall <name> [--clean] re-unpack server on next start upgrade <name> show the server zips it can use, and which it runs
upgrade <name> <zip>|latest [--downgrade] [--no-backup]
change its server version now (backs up worlds first)
reinstall <name> [--clean] re-unpack the same server version on next start
(--clean also re-downloads tModLoader's .NET) (--clean also re-downloads tModLoader's .NET)
fix-perms <name> give all of an instance's files back to the
'$SVC_USER' user, with the usual modes (run as root)
mods <name> [list] show installed mods and whether they're enabled mods <name> [list] show installed mods and whether they're enabled
mods <name> add <file.tmod|folder>... install mods (and ModConfigs) and enable them mods <name> add <file.tmod|folder>... install mods (and ModConfigs) and enable them
mods <name> enable|disable <mod>... change which installed mods load mods <name> enable|disable <mod>... change which installed mods load
@@ -96,13 +102,12 @@ Other hosts:
copy an instance, on this host or between hosts copy an instance, on this host or between hosts
move [<remote>:]<name> [<remote>:][<new name>] [--with-server] move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
move (or rename) an instance; stops it while moving move (or rename) an instance; stops it while moving
remote add <name> <token> add a host to manage (token from 'daemon trust add') remote add [<name>] <token> [--address <host>[:<port>]]
remote add <name> <address> [<token>] ... when the token's addresses don't work from here add a host to manage (token from its 'daemon enable')
remote list | remote remove <name> show or forget remotes remote list | remote remove <name> show or forget remotes
daemon enable|disable|status let other hosts manage this one (terractld.service) daemon enable|disable|status let other hosts manage this one (terractld.service)
daemon trust add <client> create a one-time token for a computer to join daemon token a one-time token for another computer to add this host
daemon trust list | trust remove <client> daemon revoke <name> take a computer's access away
show or revoke computers that may manage this host
Used by terraria@.service: Used by terraria@.service:
journal-access let the terraria group read the servers' journal journal-access let the terraria group read the servers' journal
@@ -139,6 +144,7 @@ write_instance_conf() {
cat > "$dir/instance.conf" <<EOF cat > "$dir/instance.conf" <<EOF
# Settings for terraria@$name # Settings for terraria@$name
# Changing TYPE or ZIP makes the next start re-unpack server/ (data/ is kept). # Changing TYPE or ZIP makes the next start re-unpack server/ (data/ is kept).
# To change version, use: terractl upgrade $name <zip>
TYPE=$type # vanilla | tmodloader TYPE=$type # vanilla | tmodloader
@@ -220,8 +226,9 @@ install_server() {
dir=$(inst_dir "$name") dir=$(inst_dir "$name")
marker="$dir/server/.installed" marker="$dir/server/.installed"
# Already installed with the right type (and pinned zip, if any)? Nothing to do. # Already installed with the right type (and pinned zip, if any), and not
if [[ -f $marker ]]; then # asked to unpack it again ('reinstall')? Nothing to do.
if [[ -f $marker && ! -e $dir/server/.reinstall ]]; then
read -r have_type have_zip < "$marker" || true read -r have_type have_zip < "$marker" || true
if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then if [[ $have_type == "$TYPE" ]] && [[ -z $ZIP || $have_zip == "$(basename "$ZIP")" ]]; then
return 0 return 0
@@ -230,7 +237,8 @@ install_server() {
command -v unzip >/dev/null || die "unzip is not installed" command -v unzip >/dev/null || die "unzip is not installed"
zip=$(pick_zip) zip=$(pick_zip)
[[ -r $zip ]] || die "cannot read $zip" [[ -e $zip ]] || die "$zip not found"
[[ -r $zip ]] || die "the '$SVC_USER' user can't read $zip; fix it with: sudo chmod 644 $zip"
log "installing $TYPE from $(basename "$zip") into $dir/server" log "installing $TYPE from $(basename "$zip") into $dir/server"
EXTRACT_TMP=$(mktemp -d "$dir/.extract.XXXXXX") EXTRACT_TMP=$(mktemp -d "$dir/.extract.XXXXXX")
@@ -303,8 +311,11 @@ instance_running() {
} }
cmd_prepare() { cmd_prepare() {
local name=$1 dir; dir=$(inst_dir "$name") local name=$1 dir err; dir=$(inst_dir "$name")
mkdir -p "$dir/data/Worlds" if ! err=$(mkdir -p "$dir/data/Worlds" 2>&1); then
location_warnings
die "can't create $dir/data/Worlds: ${err##*: }"
fi
take_lock "$name" # never re-unpack server/ under a running server take_lock "$name" # never re-unpack server/ under a running server
[[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla [[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla
load_conf "$name" load_conf "$name"
@@ -825,6 +836,7 @@ cmd_import() {
echo "imported '$name' into $dir" echo "imported '$name' into $dir"
fi fi
echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)" echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)"
location_warnings
} }
# ---------------------------------------------------------------- mods ---- # ---------------------------------------------------------------- mods ----
@@ -1009,6 +1021,7 @@ EOF
$(hint root enable "$name" --now) $(hint root enable "$name" --now)
$(hint group attach "$name") $(hint group attach "$name")
EOF EOF
location_warnings
} }
# Whether the server starts at boot. Needs root: systemd doesn't let polkit # Whether the server starts at boot. Needs root: systemd doesn't let polkit
@@ -1027,6 +1040,7 @@ cmd_enable() {
# Enabling a name without an instance would create a default one at boot # Enabling a name without an instance would create a default one at boot
[[ -f $(inst_dir "$name")/instance.conf ]] || [[ -f $(inst_dir "$name")/instance.conf ]] ||
die "no instance '$name' (create it with: $(hint root new "$name"))" die "no instance '$name' (create it with: $(hint root new "$name"))"
location_warnings
systemctl enable -q "$unit" systemctl enable -q "$unit"
if [[ $now -eq 1 ]]; then if [[ $now -eq 1 ]]; then
systemctl start "$unit" systemctl start "$unit"
@@ -1050,6 +1064,134 @@ cmd_enable() {
fi fi
} }
# Can the service user read <file>? Worked out from the file's owner, group and
# mode, because whoever runs terractl may be someone else.
svc_can_read() {
local owner group mode
read -r owner group mode < <(stat -c '%U %G %a' "$1" 2>/dev/null) || return 1
if [[ $owner == "$SVC_USER" ]]; then (( 8#$mode & 0400 ))
elif [[ $group == "$SVC_USER" ]]; then (( 8#$mode & 0040 ))
else (( 8#$mode & 0004 )); fi
}
# Zips in dist/ the servers can't unpack, e.g. copied in with 'scp -p' from a
# private file. Instances fail on their first start with such a zip.
unreadable_zips() {
local f
for f in "$DIST_DIR"/*.zip; do
if [[ -f $f ]] && ! svc_can_read "$f"; then echo "$f"; fi
done
}
zip_warning() {
local where=""
local -a bad=()
mapfile -t bad < <(unreadable_zips)
[[ ${#bad[@]} -gt 0 ]] || return 0
if [[ -n ${TERRACTL_VIA:-} ]]; then where=" (on $(hostname 2>/dev/null || echo that host))"; fi
log "warning: the '$SVC_USER' user can't read these zips, so servers can't be installed from them:"
printf ' %s\n' "${bad[@]}" >&2
log "fix it with$where: sudo chmod 644 ${bad[*]@Q}"
}
# ------------------------------------------------------------ locations ----
# The services' sandboxes (ProtectSystem=, ProtectHome=, ReadWritePaths=) are
# set in their unit files, which can't follow /etc/default/terractl. If
# TERRARIA_BASE or TERRARIA_DIST is moved somewhere a unit can't reach, it needs
# a drop-in; these checks say so, with the drop-in to add.
# Is <path> <dir>, or inside it?
path_in() { [[ $1 == "$2" || $1 == "$2"/* ]]; }
# Why <unit> can't use <path> (need: rw or ro): 'home' (ProtectHome hides it),
# 'home-ro' (ProtectHome=read-only, not writable) or 'system' (ProtectSystem
# makes it read-only). Prints nothing if the unit can use it, or if systemd or
# the unit isn't there to ask.
unit_path_problem() {
local unit=$1 path=$2 need=$3 key value load="" ps=no ph=no p covered=0
local -a rw=()
command -v systemctl >/dev/null || return 0
while IFS='=' read -r key value; do
case $key in
LoadState) load=$value ;;
ProtectSystem) ps=$value ;;
ProtectHome) ph=$value ;;
ReadWritePaths) read -ra rw <<< "$value" ;;
esac
done < <(systemctl show -p LoadState,ProtectSystem,ProtectHome,ReadWritePaths "$unit" 2>/dev/null)
[[ $load == loaded ]] || return 0
path=$(readlink -m "$path") # where it really is, as systemd sees it
for p in "${rw[@]}"; do
p=${p#-} # '-': ignored if missing
if path_in "$path" "$(readlink -m "$p")"; then covered=1; fi
done
if path_in "$path" /home || path_in "$path" /root || path_in "$path" /run/user; then
case $ph in
no) ;;
read-only) if [[ $need == rw && $covered -eq 0 ]]; then echo home-ro; fi ;;
*) echo home ;;
esac
return 0
fi
[[ $need == rw && $covered -eq 0 ]] || return 0
case $ps in
strict) echo system ;;
yes|full)
if path_in "$path" /usr || path_in "$path" /boot || path_in "$path" /efi ||
{ [[ $ps == full ]] && path_in "$path" /etc; }; then
echo system
fi ;;
esac
}
# Warns about one unit: location_warning <unit> <name to edit> <who it breaks>
# <dist access needed>
location_warning() {
local unit=$1 edit=$2 effect=$3 dist_need=$4 what path need problem home=0 where=""
local -a lines=() rw=()
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 ;;
home-ro|system)
lines+=("$edit can't write to $path ($what), so $effect")
if [[ $problem == home-ro ]]; then home=1; fi
rw+=("$path") ;;
esac
done
[[ ${#lines[@]} -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
{
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
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."
fi
} >&2
}
# The servers always; the daemon only if it's in use. The daemon writes zips
# into dist/ when instances are copied to this host, so it needs both writable.
location_warnings() {
location_warning terraria@terractl-check.service terraria@.service \
"servers can't start" ro
if systemctl is-enabled --quiet terractld.service 2>/dev/null ||
systemctl is-active --quiet terractld.service 2>/dev/null; then
location_warning terractld.service terractld.service \
"other hosts can't copy, move or import instances here" rw
fi
}
cmd_list() { cmd_list() {
local d n port boot state local d n port boot state
printf '%-20s %-11s %-6s %-9s %s\n' INSTANCE TYPE PORT BOOT STATE printf '%-20s %-11s %-6s %-9s %s\n' INSTANCE TYPE PORT BOOT STATE
@@ -1066,10 +1208,53 @@ cmd_list() {
printf '%-20s %-11s %-6s %-9s %s\n' "$n" "$TYPE" "${port:--}" "${boot:--}" "$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)" ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)"
done done
zip_warning
location_warnings
} }
# ------------------------------------------------------------ versions ----
# The zip an instance runs is recorded in server/.installed when it's unpacked.
# A pin (ZIP= in instance.conf) that differs from it makes the next start (the
# 'prepare' step) unpack the pinned zip instead.
zip_pattern() {
case $TYPE in
vanilla) echo 'terraria-server-*.zip' ;;
tmodloader) echo 'tModLoader*.zip' ;;
esac
}
# The zips in dist/ for this instance's TYPE, oldest first
type_zips() {
find "$DIST_DIR" -maxdepth 1 -type f -name "$(zip_pattern)" -printf '%f\n' | sort -V
}
# The zip server/ was unpacked from (file name only), or nothing
installed_zip() {
local marker type="" zip=""
marker="$(inst_dir "$1")/server/.installed"
if [[ -r $marker ]]; then read -r type zip < "$marker" || true; fi
echo "$zip"
}
# instance.conf is sourced by bash, so only plain file names go into it
safe_zip_name() { [[ $1 =~ ^[A-Za-z0-9._+-]+\.zip$ ]]; }
set_zip_pin() {
local conf; conf="$(inst_dir "$1")/instance.conf"
if grep -q '^ZIP=' "$conf"; then
sed -i "s|^ZIP=.*|ZIP=$2|" "$conf"
else
printf 'ZIP=%s\n' "$2" >> "$conf"
fi
fix_owner "$conf"
}
# Unpacks the same zip again on the next start. An unpinned instance is pinned
# to the zip it runs first, as otherwise it would pick the newest zip in dist/
# and so change version; changing version is what 'upgrade' is for.
cmd_reinstall() { cmd_reinstall() {
local name=$1 clean=0 dir; dir=$(inst_dir "$name") local name=$1 clean=0 dir installed; dir=$(inst_dir "$name")
shift shift
case ${1:-} in case ${1:-} in
--clean) clean=1 ;; --clean) clean=1 ;;
@@ -1080,7 +1265,19 @@ cmd_reinstall() {
if [[ $clean -eq 1 ]] && instance_running "$name"; then if [[ $clean -eq 1 ]] && instance_running "$name"; then
die "'$name' is running; --clean would delete .NET under it. Stop it first: $(hint group stop "$name")" die "'$name' is running; --clean would delete .NET under it. Stop it first: $(hint group stop "$name")"
fi fi
rm -f "$dir/server/.installed" load_conf "$name"
installed=$(installed_zip "$name")
if [[ -z $ZIP && -n $installed ]]; then
[[ -f $DIST_DIR/$installed ]] ||
die "'$name' runs $installed, which is no longer in $DIST_DIR. Put it back to unpack it again, or change version: $(hint root upgrade "$name")"
safe_zip_name "$installed" || die "can't pin '$installed' in instance.conf; rename it in $DIST_DIR and use: $(hint root upgrade "$name" "<zip>")"
set_zip_pin "$name" "$installed"
log "pinned ZIP=$installed in instance.conf, so the same version is unpacked again"
elif [[ -n $ZIP && -n $installed && $(basename "$ZIP") != "$installed" ]]; then
log "note: instance.conf pins ZIP=$ZIP, but $installed is installed; the next start unpacks $(basename "$ZIP")"
fi
# Keeps .installed, so what it runs is still known (e.g. to 'upgrade')
if [[ -d $dir/server ]]; then touch "$dir/server/.reinstall"; fix_owner "$dir/server/.reinstall"; fi
if [[ $clean -eq 1 ]]; then if [[ $clean -eq 1 ]]; then
rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs" rm -rf "$dir/server/dotnet" "$dir/server/tModLoader-Logs"
echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again" echo "removed the downloaded .NET runtime and logs; .NET will be downloaded again"
@@ -1088,6 +1285,177 @@ cmd_reinstall() {
echo "server/ will be re-unpacked on next start: $(hint group restart "$name")" echo "server/ will be re-unpacked on next start: $(hint group restart "$name")"
} }
upgrade_list() {
local z joined newest=""
local -a zips=() note=()
mapfile -t zips < <(type_zips)
if [[ ${#zips[@]} -gt 0 ]]; then newest=${zips[-1]}; fi
echo "'$name' ($TYPE) runs: ${installed:-nothing yet (unpacked on its first start)}"
if [[ ${#zips[@]} -eq 0 ]]; then
echo "No '$(zip_pattern)' zips in $DIST_DIR"
return 0
fi
printf '%-40s %s\n' ZIP NOTE
for z in "${zips[@]}"; do
note=()
if [[ $z == "$installed" ]]; then note+=(installed); fi
if [[ -n $ZIP && $z == "${ZIP##*/}" ]]; then note+=(pinned); fi
if [[ $z == "$newest" ]]; then note+=(newest); fi
if ! svc_can_read "$DIST_DIR/$z"; then note+=("unreadable by $SVC_USER"); fi
joined=$(printf '%s, ' "${note[@]}")
printf '%-40s %s\n' "$z" "${joined%, }" | sed 's/ *$//'
done
if [[ -n $installed && ! -f $DIST_DIR/$installed ]]; then
printf '%-40s %s\n' "$installed" "installed, no longer in $DIST_DIR"
fi
echo "Change version with: $(hint root upgrade "$name" "<zip>|latest")"
}
# Changes the server version (zip) of an instance, up or down, and applies it
# right away: checks the zip, stops the server, backs up its worlds and
# configs, pins ZIP=, unpacks the zip and starts the server again. Anything
# 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 was_running=0 installed_name old_pin backup_file="" unit="terraria@$name"
for arg in "$@"; do
case $arg in
--downgrade) downgrade=1 ;;
--no-backup) backup=0 ;;
-*) die "unknown option '$arg'" ;;
*) [[ -z $target ]] || die "more than one zip given"; target=$arg ;;
esac
done
dir=$(inst_dir "$name")
[[ -f $dir/instance.conf ]] || die "no instance '$name'"
load_conf "$name"
installed=$(installed_zip "$name")
if [[ -z $target ]]; then upgrade_list; return 0; fi
[[ $EUID -eq 0 ]] || die "changing version needs root: sudo terractl upgrade $name $*"
# Check the zip before changing anything
if [[ $target == latest ]]; then
zip=$(type_zips | tail -n 1)
[[ -n $zip ]] || die "no '$(zip_pattern)' zips in $DIST_DIR"
elif [[ $target == */* ]]; then
[[ $(dirname "$(abspath "$target")") -ef $DIST_DIR ]] ||
die "$target isn't in $DIST_DIR; copy it there first"
zip=$(basename "$target")
else
zip=$target
fi
# shellcheck disable=SC2053 # the pattern is a glob on purpose
[[ $zip == $(zip_pattern) ]] || die "$zip isn't a $TYPE zip (expected $(zip_pattern))"
safe_zip_name "$zip" || die "rename $zip to use only letters, digits and ._+- (it's written into instance.conf)"
f="$DIST_DIR/$zip"
[[ -f $f ]] || die "$f not found. Available: $(hint group upgrade "$name")"
svc_can_read "$f" || die "the '$SVC_USER' user can't read $f; fix it with: sudo chmod 644 $f"
if [[ $zip == "$installed" ]]; then
if [[ ${ZIP##*/} != "$zip" ]]; then set_zip_pin "$name" "$zip"; log "pinned ZIP=$zip in instance.conf"; fi
echo "'$name' already runs $zip"
return 0
fi
if [[ -n $installed ]]; then
lower=$(printf '%s\n' "$installed" "$zip" | sort -V | head -n 1)
if [[ $lower == "$zip" ]]; then
verb=downgraded
if [[ $downgrade -eq 0 ]]; then
die "$zip is older than $installed, which '$name' runs now. Worlds saved by a newer version" \
"can't be opened by an older one, and mods may not load. To go ahead anyway: $(hint root upgrade "$name" "$zip" --downgrade)"
fi
else
verb=upgraded
fi
else
verb=switched
fi
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 $zip..."
unzip -tqq "$f" >/dev/null 2>&1 || die "$f is damaged (unzip -t found errors); download it again"
state=$(systemctl is-active "$unit" 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
was_running=1
echo "stopping $unit (saves the world first)..."
systemctl stop "$unit"
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 [[ $was_running -eq 1 ]]; then systemctl start "$unit"; fi
die "backing up '$name' failed, so its version wasn't changed"
fi
echo "backup of worlds and configs: $backup_file"
fi
old_pin=$ZIP
set_zip_pin "$name" "$zip"
# Unpack now, as the service would, so any problem shows up here. Until the
# new server/ is complete, the old one is left in place.
if ! runuser -u "$SVC_USER" -- env HOME="$dir" "$SELF" prepare "$name"; then
set_zip_pin "$name" "$old_pin"
if [[ $was_running -eq 1 ]]; then systemctl start "$unit"; fi
die "unpacking $zip failed (see above); '$name' still runs ${installed:-its old version}"
fi
echo "$verb '$name' from ${installed:-nothing} to $zip"
if [[ $was_running -eq 1 ]]; then
systemctl start "$unit"
echo "$unit started again"
else
echo "Start it with: $(hint group start "$name")"
fi
echo "Players need the version in $zip to join."
if [[ $TYPE == tmodloader ]]; then
echo "Check that its mods still load: $(hint group watch "$name")"
fi
if [[ -n $backup_file ]]; then
echo "If its worlds need to go back to $installed, see 'Undoing an upgrade' in the guide."
fi
}
# Gives every file of an instance back to the service user, with the modes the
# service creates files with (UMask=0027): the terraria group can read but not
# change them, others get nothing. For files copied in as root, or with odd
# modes (e.g. 'cp -a' or 'scp -p'). Symlinks are left alone, never followed.
cmd_fix_perms() {
local name=$1 dir; shift
[[ $# -eq 0 ]] || die "usage: terractl fix-perms <name>"
[[ $EUID -eq 0 ]] || die "fix-perms must be run as root: sudo terractl fix-perms $name"
dir=$(inst_dir "$name")
[[ -f $dir/instance.conf && ! -L $dir ]] || die "no instance '$name'"
chown -R -h "$SVC_USER:$SVC_USER" "$dir"
# X: directories, and files that were already executable for someone
chmod -R u+rwX,u-s,g=rX,o= "$dir"
# The server binaries must stay executable, whatever happened to them
if [[ -d $dir/server ]]; then
find "$dir/server" -maxdepth 1 -type f \
\( -name TerrariaServer.bin.x86_64 -o -name TerrariaServer -o -name '*.sh' \) \
-exec chmod u+x,g+x {} +
fi
echo "'$name': all files owned by $SVC_USER:$SVC_USER, readable by the '$SVC_USER' group, private to everyone else"
}
# key=value facts, one per line, for people and for terractl-remote (copy, move, # 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. # tab completion). Without a name: this host. Repeated keys are lists.
cmd_info() { cmd_info() {
@@ -1102,6 +1470,7 @@ cmd_info() {
for f in "$DIST_DIR"/*.zip; do for f in "$DIST_DIR"/*.zip; do
if [[ -f $f ]]; then echo "dist_zip=$(basename "$f")"; fi if [[ -f $f ]]; then echo "dist_zip=$(basename "$f")"; fi
done done
while IFS= read -r f; do echo "unreadable_zip=$(basename "$f")"; done < <(unreadable_zips)
for f in "$BASE_DIR"/*/instance.conf; do for f in "$BASE_DIR"/*/instance.conf; do
if [[ -f $f ]]; then echo "instance=$(basename "$(dirname "$f")")"; fi if [[ -f $f ]]; then echo "instance=$(basename "$(dirname "$f")")"; fi
done done
@@ -1128,7 +1497,7 @@ cmd_info() {
# anything it does on this host, and passes on where it was called from. # anything it does on this host, and passes on where it was called from.
run_remote_helper() { run_remote_helper() {
local helper=$REMOTE_HELPER ver=$TERRACTL_VERSION self local helper=$REMOTE_HELPER ver=$TERRACTL_VERSION self
self=$(readlink -f "$0") self=$SELF
# A development copy uses the helper from its source tree # A development copy uses the helper from its source tree
if [[ $helper == *@* ]]; then helper="$(dirname "$self")/../lib/terractl-remote"; fi if [[ $helper == *@* ]]; then helper="$(dirname "$self")/../lib/terractl-remote"; fi
if [[ $ver == *@* ]]; then ver=development; fi if [[ $ver == *@* ]]; then ver=development; fi
@@ -1162,7 +1531,7 @@ if [[ $command == prepare || $command == run ]]; then
if [[ $EUID -eq 0 ]]; then if [[ $EUID -eq 0 ]]; then
command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead" command -v runuser >/dev/null || die "runuser not found; run as '$SVC_USER' instead"
exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \ exec runuser -u "$SVC_USER" -- env HOME="$(inst_dir "$1")" \
"$(readlink -f "$0")" "$command" "$@" "$SELF" "$command" "$@"
elif [[ $(id -un) != "$SVC_USER" ]]; then elif [[ $(id -un) != "$SVC_USER" ]]; then
# Being in the group isn't enough: files would end up owned by the caller # 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" die "'$command' needs root (it switches to the '$SVC_USER' user by itself): sudo terractl $command $1"
@@ -1186,6 +1555,8 @@ case $command in
mods) cmd_mods "$@" ;; mods) cmd_mods "$@" ;;
new) cmd_new "$@" ;; new) cmd_new "$@" ;;
reinstall) cmd_reinstall "$@" ;; reinstall) cmd_reinstall "$@" ;;
upgrade) cmd_upgrade "$@" ;;
fix-perms) cmd_fix_perms "$@" ;;
delete) cmd_delete "$@" ;; delete) cmd_delete "$@" ;;
rename) cmd_rename "$@" ;; rename) cmd_rename "$@" ;;
export) cmd_export "$@" ;; export) cmd_export "$@" ;;
+36 -12
View File
@@ -4,16 +4,36 @@
# Installed to <prefix>/share/bash-completion/completions/terractl, where the # Installed to <prefix>/share/bash-completion/completions/terractl, where the
# bash-completion package loads it automatically. Also works with 'sudo terractl'. # bash-completion package loads it automatically. Also works with 'sudo terractl'.
# Instances directory: default, or TERRARIA_BASE from /etc/default/terractl. # A setting from /etc/default/terractl, or its default: _terractl_setting <name> <default>.
# The file is parsed rather than sourced, so nothing runs in your shell. # The file is parsed rather than sourced, so nothing runs in your shell.
_terractl_base() { _terractl_setting() {
local base=/var/lib/terraria/instances value local result=$2 value
if [[ -r /etc/default/terractl ]]; then if [[ -r /etc/default/terractl ]]; then
value=$(sed -n 's/^[[:space:]]*TERRARIA_BASE=//p' /etc/default/terractl | tail -n 1) value=$(sed -n "s/^[[:space:]]*$1=//p" /etc/default/terractl | tail -n 1)
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'} value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
if [[ -n $value ]]; then base=$value; fi if [[ -n $value ]]; then result=$value; fi
fi fi
printf '%s\n' "$base" printf '%s\n' "$result"
}
# Instances directory
_terractl_base() { _terractl_setting TERRARIA_BASE /var/lib/terraria/instances; }
# Server zips an instance can use, by its TYPE in instance.conf (all zips if
# that can't be read). On a remote, all of its zips, from its daemon.
_terractl_zips() {
if [[ -n $2 ]]; then
timeout 5 terractl --host "$2" info 2>/dev/null </dev/null | sed -n 's/^dist_zip=//p'
return
fi
local dist type pattern='*.zip' f
dist=$(_terractl_setting TERRARIA_DIST /var/lib/terraria/dist)
type=$(sed -n 's/^TYPE=\([a-z]*\).*/\1/p' "$(_terractl_base)/$1/instance.conf" 2>/dev/null | tail -n 1)
case $type in
vanilla) pattern='terraria-server-*.zip' ;;
tmodloader) pattern='tModLoader*.zip' ;;
esac
while IFS= read -r f; do printf '%s\n' "${f##*/}"; done < <(compgen -G "$dist/$pattern")
} }
# Instance names. Reading the directory needs root or the 'terraria' group; if # Instance names. Reading the directory needs root or the 'terraria' group; if
@@ -105,7 +125,7 @@ _terractl() {
cword=$COMP_CWORD cword=$COMP_CWORD
fi fi
local commands="new list info start stop restart enable disable attach watch cmd mods reinstall delete rename export import" local commands="new list info start stop restart enable disable attach watch cmd mods upgrade reinstall fix-perms 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" local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help"
# terractl --host <remote> <command> ...: the same as on this host, except # terractl --host <remote> <command> ...: the same as on this host, except
@@ -121,7 +141,7 @@ _terractl() {
cword=$((cword - 2)) cword=$((cword - 2))
commands+=" --help --version" commands+=" --help --version"
else else
commands+=" copy move remote daemon prepare run --host --help --version" commands+=" copy move remote daemon prepare run journal-access --host --help --version"
fi fi
if (( cword == 1 )); then if (( cword == 1 )); then
@@ -131,7 +151,7 @@ _terractl() {
local command=${words[1]} local command=${words[1]}
case $command in case $command in
list|help|-h|--help|version|-V|--version) list|journal-access|help|-h|--help|version|-V|--version)
return ;; return ;;
new) new)
# new <new name> [vanilla|tmodloader] [port]: the name is new, so nothing to offer # new <new name> [vanilla|tmodloader] [port]: the name is new, so nothing to offer
@@ -153,9 +173,7 @@ _terractl() {
return ;; return ;;
daemon) daemon)
if (( cword == 2 )); then if (( cword == 2 )); then
_terractl_reply "enable disable status trust" _terractl_reply "enable disable status token revoke"
elif (( cword == 3 )) && [[ ${words[2]} == trust ]]; then
_terractl_reply "add list remove"
fi fi
return ;; return ;;
esac esac
@@ -175,6 +193,12 @@ _terractl() {
if (( cword == 3 )); then _terractl_reply "$console"; fi ;; if (( cword == 3 )); then _terractl_reply "$console"; fi ;;
reinstall) reinstall)
if (( cword == 3 )); then _terractl_reply "--clean"; fi ;; if (( cword == 3 )); then _terractl_reply "--clean"; fi ;;
upgrade)
if [[ $cur == -* ]]; then
_terractl_reply "--downgrade --no-backup"
else
_terractl_reply "latest $(_terractl_zips "$instance" "$host")"
fi ;;
enable|disable) enable|disable)
if (( cword == 3 )); then _terractl_reply "--now"; fi ;; if (( cword == 3 )); then _terractl_reply "--now"; fi ;;
watch) watch)
+6 -3
View File
@@ -1,13 +1,16 @@
# Overrides for terractl. Uncomment to change. # Overrides for terractl. Uncomment to change.
# Changes apply to new commands and to each instance's next start. # 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 # Where instances live. To use another disk, it's simplest to mount it at the
# to write there: sudo systemctl edit terraria@.service # default location instead. If you change this, the service must also be
# allowed to write there: sudo systemctl edit terraria@.service
# [Service] # [Service]
# ReadWritePaths=/your/new/path # ReadWritePaths=/your/new/path
# Under /home it needs more than that. 'terractl list' warns about what's
# missing; see "Changing locations" in the guide.
#TERRARIA_BASE=/var/lib/terraria/instances #TERRARIA_BASE=/var/lib/terraria/instances
# Where server zips are read from # Where server zips are read from. Outside /home, no drop-in is needed.
#TERRARIA_DIST=/var/lib/terraria/dist #TERRARIA_DIST=/var/lib/terraria/dist
# Seconds to wait for a server to save and exit on stop (keep below 90) # Seconds to wait for a server to save and exit on stop (keep below 90)
+192 -44
View File
@@ -87,6 +87,7 @@ On stop, `terractl service-stop` sends `exit` through the pipe so the world is s
│ ├── Mods/ tModLoader mods + enabled.json │ ├── Mods/ tModLoader mods + enabled.json
│ ├── ModConfigs/ tModLoader mod settings │ ├── ModConfigs/ tModLoader mod settings
│ └── banlist.txt │ └── banlist.txt
├── .backups/ Backups made by 'terractl upgrade'
└── .deleted/ Backups made by 'terractl delete' └── .deleted/ Backups made by 'terractl delete'
/run/terraria/<name>.stdin Console pipe (exists only while running) /run/terraria/<name>.stdin Console pipe (exists only while running)
@@ -100,13 +101,25 @@ The server can write only inside `instances/`. It reads `dist/` but can never ch
## Installation ## Installation
The package works on Debian 12 or newer and Ubuntu 22.04 or newer. The package works on Debian 12 or newer and Ubuntu 22.04 or newer. The easiest way is the apt repository, since `sudo apt upgrade` then keeps it up to date:
```bash
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/debian/repository.key \
-o /etc/apt/keyrings/terraria-server-manager.asc
echo "deb [signed-by=/etc/apt/keyrings/terraria-server-manager.asc] https://gitea.adbraun.lol/api/packages/adam/debian stable main" |
sudo tee /etc/apt/sources.list.d/terraria-server-manager.list
sudo apt update
sudo apt install terraria-server-manager
```
The repository is signed; the first two commands fetch its key and let apt trust it for this repository only. Alternatively, download a `.deb` from the releases page and install it directly:
```bash ```bash
sudo apt install ./terraria-server-manager_0.0.9_all.deb sudo apt install ./terraria-server-manager_0.0.9_all.deb
``` ```
Using `apt install ./file.deb` (rather than `dpkg -i`) installs the dependencies automatically. `unzip` is required. `curl` and the ICU library are "recommended", which apt installs by default; they're only needed for tModLoader instances, which crash with `Aborted` without ICU. `polkitd` is also recommended: it lets members of the `terraria` group start and stop servers without `sudo` (see [Permissions](#permissions)). Using `apt install` (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: Installing the package:
@@ -125,7 +138,8 @@ sudo ufw allow 7777/tcp # example with ufw
### Upgrading the package ### Upgrading the package
```bash ```bash
sudo apt install ./terraria-server-manager_<new-version>_all.deb sudo apt update && sudo apt upgrade # from the apt repository
sudo apt install ./terraria-server-manager_<new-version>_all.deb # or from a downloaded .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. 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.
@@ -185,16 +199,86 @@ The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/c
### Changing locations ### 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: The servers run in a systemd sandbox: they can write only to `/var/lib/terraria/instances`, they can read but not change the rest of the system, and they can't see `/home` at all. These rules are in the unit files, which can't read `/etc/default/terractl`. So if the instances or zips go somewhere else, the services must be told as well.
**The simplest way: mount the storage at the usual place.** To keep instances on another disk, mount it (or a folder on it) at `/var/lib/terraria/instances`, and change no settings at all. For a folder on a disk that's already mounted, add a bind mount to `/etc/fstab`:
```
/srv/bigdisk/terraria /var/lib/terraria/instances none bind 0 0
```
```bash
sudo systemctl stop 'terraria@*' # if servers are running
sudo cp -a /var/lib/terraria/instances/. /srv/bigdisk/terraria/ # copy existing instances over
sudo mount /var/lib/terraria/instances
sudo chown terraria:terraria /var/lib/terraria/instances
sudo chmod 0750 /var/lib/terraria/instances
```
The old copies stay hidden underneath the mount. Once everything works, you can free that space: `sudo umount /var/lib/terraria/instances`, delete the old instance folders, and `sudo mount /var/lib/terraria/instances` again.
The same works for `dist/`. Use a mount, not a symbolic link: systemd follows a link to where it really points, and that place isn't covered by the sandbox rules.
**Or change the setting, and add a drop-in.** Set `TERRARIA_BASE` and/or `TERRARIA_DIST` in `/etc/default/terractl`, create the folders with the same owners and modes as the default ones, and let the servers write to the new instances folder:
```bash
sudo install -d -o terraria -g terraria -m 0750 /srv/terraria/instances
sudo install -d -o root -g terraria -m 2775 /srv/terraria/dist
sudo systemctl edit terraria@.service
```
```ini
[Service]
ReadWritePaths=/srv/terraria/instances
```
A zips folder outside `/home` needs no drop-in, because the servers only read it.
`terractl list`, `new`, `import` and `enable` check that the services can reach both folders, and print the exact drop-in to add if they can't. A server that can't create its folder says so in its log, with the same advice. If the daemon for other hosts (`terractld.service`) is on, it's checked too; it only needs a drop-in for folders under `/home` (see below).
### Instances or zips under `/home`
This works, but needs more changes, because the services are set up to never touch home folders (`ProtectHome=yes`). That hides `/home`, `/root` and `/run/user` from them completely. Prefer a folder outside `/home`, or a bind mount (above), if you can.
If they must be under `/home`:
1. **Let the services see `/home`, read-only, and write to the instances folder.** For the servers:
```bash ```bash
sudo systemctl edit terraria@.service sudo systemctl edit terraria@.service
``` ```
```ini ```ini
[Service] [Service]
ReadWritePaths=/your/new/path ProtectHome=read-only
ReadWritePaths=/home/you/terraria/instances
``` ```
If the daemon for other hosts is on, it also needs to write to the zips folder, because copying an instance here can bring its server zip along:
```bash
sudo systemctl edit terractld.service
```
```ini
[Service]
ProtectHome=read-only
ReadWritePaths=/home/you/terraria/instances /home/you/terraria/dist
```
`ProtectHome=read-only` lets the servers see all home folders. Normal file permissions still apply, so as the `terraria` user they can only read what other users can.
2. **Let the `terraria` user into the folders above.** On Ubuntu (21.04 and later) and some other systems, home folders are private (mode `750`), so the `terraria` user can't even pass through `/home/you`. Give it just that, without letting it list or read anything there:
```bash
sudo setfacl -m u:terraria:x /home/you
```
Do the same for every folder between `/home/you` and the instances or zips folder that isn't open to others. `sudo -u terraria ls /home/you/terraria/instances` tells you whether it worked.
3. **Create the folders** with the right owners and modes, and set `TERRARIA_BASE` and/or `TERRARIA_DIST` in `/etc/default/terractl`, as above.
4. **Restart running servers** so they pick up the drop-in: `sudo terractl restart <name>`. If the daemon is on: `sudo systemctl restart terractld`.
`terractl list` stops warning once the drop-ins match. Members of the `terraria` group also need to get through `/home/you` to use `list`, `info` and tab completion without `sudo`.
--- ---
## Migrating from the manual install ## Migrating from the manual install
@@ -255,6 +339,8 @@ 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. **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.
**The `terraria` user must be able to read the zips.** Files copied in normally are fine, but `scp -p` or `cp -p` of a private file keeps its `600` mode, and a server can't be installed from that. `terractl list` warns about such zips, and a server that fails to start because of one says so in its log. Fix them with `sudo chmod 644 /var/lib/terraria/dist/*.zip`.
Members of the `terraria` group can copy zips into `dist/` without `sudo`: Members of the `terraria` group can copy zips into `dist/` without `sudo`:
```bash ```bash
@@ -453,7 +539,7 @@ EXTRA_ARGS=() # extra server arguments, as a bash array
| Setting | Effect | | 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. | | `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**. | | `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; `terractl upgrade` sets it for you (see [Upgrading](#upgrading-or-downgrading-an-instance)). 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)`. | | `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. This file also marks the directory as an instance: `list` ignores directories without one, and `delete` refuses to remove them.
@@ -548,17 +634,59 @@ Stable tModLoader and the latest vanilla Terraria can be based on different Terr
### Upgrading (or downgrading) an instance ### Upgrading (or downgrading) an instance
1. Put the new zip in `/var/lib/terraria/dist`, with the version in its name. Put the new zip in `/var/lib/terraria/dist`, with the version in its name (see [Server zips](#server-zips)). To see which zips an instance can use and which one it runs:
2. Set `ZIP=` to that file name in the instance's `instance.conf`.
3. Restart the instance:
```bash ```
sudo systemctl restart terraria@calamity $ terractl upgrade calamity
'calamity' (tmodloader) runs: tModLoader-v2026.06.1.0.zip
ZIP NOTE
tModLoader-v2026.06.1.0.zip installed, pinned
tModLoader-v2026.07.3.0.zip newest
Change version with: sudo terractl upgrade calamity <zip>|latest
``` ```
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. Then switch to it, by name or with `latest` (the newest zip of the instance's type):
**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. ```bash
sudo terractl upgrade calamity tModLoader-v2026.07.3.0.zip
sudo terractl upgrade calamity latest
```
`upgrade` applies the change right away, in this order:
1. **Checks the zip** before changing anything: it must be in `dist/`, match the instance's type, be readable by the `terraria` user, contain the server, and pass `unzip -t`. A damaged or half-downloaded zip is refused here, not on the next start.
2. **Stops the server** if it's running. Stopping saves the world.
3. **Backs up** the worlds and configs (not `server/`) to `/var/lib/terraria/instances/.backups/<name>-<date>-<old zip>.tar.gz`, readable only by root. Use `--no-backup` to skip this.
4. **Pins `ZIP=`** in `instance.conf` to the new zip, so the version can't change by accident afterwards.
5. **Unpacks the new zip** into `server/`. For tModLoader, the .NET runtime and logs are kept, as always. If this fails, the old pin is put back and the old `server/` is still in place, so the instance is exactly as it was.
6. **Starts the server again** if it was running.
Nothing in `data/` is changed by `upgrade` itself. But once the new version saves a world, older versions can't open it.
**Downgrades need `--downgrade`.** `upgrade` compares the version in the zip names, and refuses to go to an older one without it:
```bash
sudo terractl upgrade calamity tModLoader-v2026.06.1.0.zip --downgrade
```
Downgrading only changes the server software. If worlds have already been saved by the newer version, restore them from the backup as well (see [Undoing an upgrade](#undoing-an-upgrade)).
**Pin `ZIP=` on modded instances.** `upgrade` always pins, and pinned instances never change version by accident. New tModLoader versions can break older mods, so only upgrade a modded server once its mods support the new version. After upgrading, check that the mods load with `terractl watch calamity`.
You can still change version by hand: set `ZIP=` in `instance.conf` and restart. That skips the checks and the backup, and a bad zip only shows up when the server fails to start.
### Undoing an upgrade
To go back to the old version *and* the worlds as they were before the upgrade, replace the instance with the backup `upgrade` made. The backup is in the same format as `export`, so `import` restores it, pinned to the version it was taken with:
```bash
sudo ls /var/lib/terraria/instances/.backups/
sudo terractl delete calamity -y # keeps its own backup in .deleted/, just in case
sudo terractl import calamity /var/lib/terraria/instances/.backups/calamity-20261002-213553-tModLoader-v2026.06.1.0.tar.gz
sudo terractl enable calamity --now
```
Anything played since the upgrade is lost this way. The backups are never deleted automatically; remove old ones with `sudo rm`.
### Re-unpacking the same version ### Re-unpacking the same version
@@ -566,14 +694,16 @@ If `server/` gets damaged or you want a clean copy:
```bash ```bash
sudo terractl reinstall calamity sudo terractl reinstall calamity
sudo systemctl restart terraria@calamity sudo terractl restart calamity
``` ```
`reinstall` unpacks the **same** zip again on the next start; it never changes version. If `ZIP=` is blank, it first pins the zip the instance runs, because otherwise the next start would pick the newest zip in `dist/`. If that zip is no longer in `dist/`, it says so and changes nothing: put the zip back, or use `upgrade`.
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: 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 ```bash
sudo terractl reinstall calamity --clean sudo terractl reinstall calamity --clean
sudo systemctl restart terraria@calamity sudo terractl restart calamity
``` ```
--- ---
@@ -670,7 +800,7 @@ sudo terractl move calamity nas: # this host → nas
terractl copy nas:calamity pi:test # nas → pi, as 'test' terractl copy nas:calamity pi:test # nas → pi, as 'test'
``` ```
### Turning on the daemon ### Setting it up
On the host to be managed: On the host to be managed:
@@ -678,29 +808,31 @@ On the host to be managed:
sudo terractl daemon enable 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`. It prints a command with a one-time token. Run that command on the computer that should manage the host:
`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. ```bash
terractl remote add tctl1_eyJ2IjoxLCJmcCI6...
```
That's all: the host is now a remote called by its hostname (say `nas`), and `terractl --host nas list` works. To call it something else, put a name before the token: `terractl remote add nas tctl1_...`. The host, in turn, knows the computer by the computer's hostname.
For each further computer, run `sudo terractl daemon token` on the host for a new token. A token works **once**, **within 24 hours**.
### The daemon
`daemon enable` 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. `sudo terractl daemon revoke <name>` takes a computer's access away.
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. 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 ### Remotes
Each computer that should manage the host needs a one-time token. On the host with the daemon: The token contains the host's addresses, so usually `remote add` needs nothing else. If none of them works from the computer (behind NAT, for example), add one that does: `terractl remote add tctl1_... --address 203.0.113.5`, or `--address nas.example.com:7800` for another port.
```bash Adding a host that's already a remote replaces that remote, keeping its name unless you give a new one. So if a remote stops working because its access was revoked, just get a new token and add it again.
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: 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 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 ```bash
terractl remote list # your remotes terractl remote list # your remotes
@@ -757,7 +889,7 @@ On this host, copying *from* it works for members of the `terraria` group. Copyi
- **Encrypted and pinned.** Traffic is HTTPS (TLS 1.2 or newer). The daemon's certificate is self-signed, the token carries its fingerprint, and every connection checks it, so nothing in between can pose as the daemon. If the fingerprint ever changes, `terractl` refuses to connect and says so. - **Encrypted and pinned.** Traffic is HTTPS (TLS 1.2 or newer). The daemon's certificate is self-signed, the token carries its fingerprint, and every connection checks it, so nothing in between can pose as the daemon. If the fingerprint ever changes, `terractl` refuses to connect and says so.
- **Keys, not passwords.** Joining trades the token for a random key for that computer. The daemon keeps only hashes of keys and tokens, in `/var/lib/terractl/trust.json` (root only). - **Keys, not passwords.** Joining trades the token for a random key for that computer. The daemon keeps only hashes of keys and tokens, in `/var/lib/terractl/trust.json` (root only).
- **A trusted computer is an admin of the host's servers.** It can do what `sudo terractl` can: create, delete, import, start and stop instances, and install mods or server files, which run as the `terraria` user. It can't change the daemon or run anything other than `terractl`. Give tokens only to computers you'd trust with `sudo terractl`. - **A trusted computer is an admin of the host's servers.** It can do what `sudo terractl` can: create, delete, import, start and stop instances, and install mods or server files, which run as the `terraria` user. It can't change the daemon or run anything other than `terractl`. Give tokens only to computers you'd trust with `sudo terractl`.
- **Revoking.** `sudo terractl daemon trust list` shows the trusted computers, and `sudo terractl daemon trust remove laptop` takes one's access away immediately. - **Revoking.** `sudo terractl daemon status` shows the trusted computers, and `sudo terractl daemon revoke laptop` takes one's access away immediately.
- **Firewall.** The daemon doesn't need to be reachable from the internet. Limit port 7776 to your LAN or VPN, or set `TERRACTL_LISTEN` to a LAN or VPN address. - **Firewall.** The daemon doesn't need to be reachable from the internet. Limit port 7776 to your LAN or VPN, or set `TERRACTL_LISTEN` to a LAN or VPN address.
The daemon runs as root, because it creates and removes instances and starts services, but with systemd's protections it can't write to `/usr`, `/boot`, `/etc` or home folders. Its certificate, key and trust list are in `/var/lib/terractl/`. Files in transit are kept in `/var/lib/terractl/transfers/`, and removed once used, after 6 hours unused, or when the daemon restarts. The daemon runs as root, because it creates and removes instances and starts services, but with systemd's protections it can't write to `/usr`, `/boot`, `/etc` or home folders. Its certificate, key and trust list are in `/var/lib/terractl/`. Files in transit are kept in `/var/lib/terractl/transfers/`, and removed once used, after 6 hours unused, or when the daemon restarts.
@@ -772,7 +904,7 @@ Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`,
30 4 * * * /usr/bin/terractl export calamity /var/backups/terraria/calamity-$(date +\%F).tar.gz && find /var/backups/terraria -name 'calamity-*.tar.gz' -mtime +14 -delete 30 4 * * * /usr/bin/terractl export calamity /var/backups/terraria/calamity-$(date +\%F).tar.gz && find /var/backups/terraria -name 'calamity-*.tar.gz' -mtime +14 -delete
``` ```
Create the folder first with `sudo mkdir -p /var/backups/terraria`. In a crontab, `%` has a special meaning and must be written as `\%`, as above. Terraria also keeps a `.bak` copy of each world next to the world file, which is useful if the latest save is damaged. Create the folder first with `sudo mkdir -p /var/backups/terraria`. Separately, `upgrade` backs up an instance's worlds and configs before every version change (see [Undoing an upgrade](#undoing-an-upgrade)). In a crontab, `%` has a special meaning and must be written as `\%`, as above. Terraria also keeps a `.bak` copy of each world next to the world file, which is useful if the latest save is damaged.
--- ---
@@ -790,7 +922,10 @@ The servers always run as the `terraria` user. Administration is simplest with `
| `attach` | root, or members of the `terraria` group | Writes to the console pipe and reads the servers' journal. | | `attach` | root, or members of the `terraria` group | Writes to the console pipe and reads the servers' journal. |
| `cmd` | root, or members of the `terraria` group | Same as `attach`. | | `cmd` | root, or members of the `terraria` group | Same as `attach`. |
| `watch` | root, or members of the `terraria` group | Reads the servers' journal only. | | `watch` | root, or members of the `terraria` group | Reads the servers' journal only. |
| `upgrade <name>` (list) | root, or members of the `terraria` group | |
| `upgrade <name> <zip>` | root only | Checked by the script. Stops and starts the server, and unpacks the zip as `terraria`. |
| `reinstall` | root (or `terraria`) | | | `reinstall` | root (or `terraria`) | |
| `fix-perms` | root only | Checked by the script. |
| `mods <name>` (list) | root, or members of the `terraria` group | | | `mods <name>` (list) | root, or members of the `terraria` group | |
| `mods add/enable/disable/remove` | root (or `terraria`) | Run as root, it sets ownership of the files it installs to `terraria`. | | `mods add/enable/disable/remove` | root (or `terraria`) | Run as root, it sets ownership of the files it installs to `terraria`. |
| `delete`, `rename` | root only | Checked by the script. | | `delete`, `rename` | root only | Checked by the script. |
@@ -798,7 +933,7 @@ The servers always run as the `terraria` user. Administration is simplest with `
| `import` | root (or `terraria`) | Run as root, it gives the new instance to `terraria`. | | `import` | root (or `terraria`) | Run as root, it gives the new instance to `terraria`. |
| `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. | | `prepare`, `run` | Normally only the service | Run as root, they automatically switch to the `terraria` user, so no files end up owned by root. Any other user is refused. |
| `service-stop` | Only the service | Needs the server's process ID from systemd. | | `service-stop` | Only the service | Needs the server's process ID from systemd. |
| `daemon enable`, `daemon disable`, `daemon trust ...` | root only | | | `daemon enable`, `disable`, `token`, `revoke` | root only | |
| `daemon status` | anyone | Shows the trusted computers only with `sudo`. | | `daemon status` | anyone | Shows the trusted computers only with `sudo`. |
| `remote ...`, `--host ...` | anyone | Uses your own remotes. On the remote, the daemon runs the command as root. | | `remote ...`, `--host ...` | anyone | Uses your own remotes. On the remote, the daemon runs the command as root. |
| `copy`, `move` | depends on this host's part | Copying from this host: the group is enough. Copying to it, or moving away from it: root. | | `copy`, `move` | depends on this host's part | Copying from this host: the group is enough. Copying to it, or moving away from it: root. |
@@ -820,9 +955,11 @@ Members of the `terraria` group can read server files (useful for backups) but c
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: 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 ```bash
sudo chown -R terraria:terraria /var/lib/terraria/instances/<name> sudo terractl fix-perms <name>
``` ```
This gives every file of the instance back to the `terraria` user and resets the modes to what the service creates: the `terraria` group can read everything but change nothing, and other users get no access. Files that were executable stay executable (and the server binaries are made executable again); setuid bits are removed. Symbolic links are left alone, so nothing outside the instance is changed. It's safe while the server is running.
Alternatively, copy files as the `terraria` user to begin with: Alternatively, copy files as the `terraria` user to begin with:
```bash ```bash
@@ -855,6 +992,7 @@ While a server runs, whether systemd started it or you did, it holds a lock on t
- **A second `run` is refused**, and so is starting the service while a copy started by hand is running. In that case the service keeps retrying every 10 seconds and starts by itself once the other copy exits. - **A second `run` is refused**, and so is starting the service while a copy started by hand is running. In that case the service keeps retrying every 10 seconds and starts by itself once the other copy exits.
- **`prepare` is refused**, so `server/` can't be replaced under the running server. - **`prepare` is refused**, so `server/` can't be replaced under the running server.
- **`reinstall --clean` is refused**, so `.NET` can't be deleted under it. Plain `reinstall` is allowed, because it only takes effect at the next start. - **`reinstall --clean` is refused**, so `.NET` can't be deleted under it. Plain `reinstall` is allowed, because it only takes effect at the next start.
- **`upgrade` stops the service first**, and refuses if a copy started by hand is running.
- **`delete` stops the service**, but refuses to go on if a copy started by hand is still running. - **`delete` stops the service**, but refuses to go on if a copy started by hand is still running.
- **`list` shows a copy started by hand** as `running (outside systemd)`. - **`list` shows a copy started by hand** as `running (outside systemd)`.
@@ -880,8 +1018,11 @@ sudo cp -a /var/lib/terraria/instances/other/server/dotnet /var/lib/terraria/ins
sudo systemctl start terraria@<name> sudo systemctl start terraria@<name>
``` ```
**The server won't start: "Read-only file system" or "No such file or directory" for its folder.**
The instances folder was moved (`TERRARIA_BASE`) to where the service isn't allowed to write, or under `/home`. The log names the drop-in to add, and so does `terractl list`. See [Changing locations](#changing-locations).
**The server can't save, or you see "Permission denied" errors.** **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/<name>`. Some files are owned by root, or have the wrong modes. Run `sudo terractl fix-perms <name>`.
**Players can't join a tModLoader server with a version-mismatch message.** **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). The player's tModLoader version differs from the server's. See [Versions and upgrades](#versions-and-upgrades).
@@ -918,7 +1059,7 @@ The daemon isn't running on that host (check with `terractl daemon status` there
The daemon has a different certificate than when you added it. That's expected if `/var/lib/terractl` was deleted there, or the package was purged and installed again; remove the remote and add it again. If nothing like that happened, don't work around it: something may be intercepting the connection. The daemon has a different certificate than when you added it. That's expected if `/var/lib/terractl` was deleted there, or the package was purged and installed again; remove the remote and add it again. If nothing like that happened, don't work around it: something may be intercepting the connection.
**`doesn't trust this computer (any more)`.** **`doesn't trust this computer (any more)`.**
Its access was revoked with `daemon trust remove`, or that computer was added again under the same name elsewhere. Get a new token. Its access was revoked with `daemon revoke`, or the host's `/var/lib/terractl` was reset. Run `sudo terractl daemon token` there and `terractl remote add <token>` here; that replaces the old remote.
--- ---
@@ -930,7 +1071,8 @@ Its access was revoked with `daemon trust remove`, or that computer was added ag
- instance names; - instance names;
- `vanilla`/`tmodloader` for `new`; - `vanilla`/`tmodloader` for `new`;
- common server console commands for `cmd` (`save`, `say`, `playing`, `kick`...); - common server console commands for `cmd` (`save`, `say`, `playing`, `kick`...);
- the options of `enable`, `disable`, `watch`, `reinstall`, `delete`, `export`, `copy` and `move`; - the options of `enable`, `disable`, `watch`, `upgrade`, `reinstall`, `delete`, `export`, `copy` and `move`;
- server zips for `upgrade`, only those of the instance's type (on a remote, all of its zips);
- remote names after `--host`, and `<remote>:<name>` for `copy` and `move`. Instance names on a remote are asked from its daemon, so they can take a moment; - remote names after `--host`, and `<remote>:<name>` for `copy` and `move`. Instance names on a remote are asked from its daemon, so they can take a moment;
- `.tar.gz` files for `export` and `import`; - `.tar.gz` files for `export` and `import`;
- `mods` actions. For `mods add` it completes `.tmod` files and folders; for `mods enable` and `mods remove`, the installed mods; for `mods disable`, the enabled ones. - `mods` actions. For `mods add` it completes `.tmod` files and folders; for `mods enable` and `mods remove`, the installed mods; for `mods disable`, the enabled ones.
@@ -953,8 +1095,12 @@ terractl disable <name> [--now] Don't start at boot (--now: an
terractl attach <name> Interactive console (Ctrl-D detaches) terractl attach <name> Interactive console (Ctrl-D detaches)
terractl watch <name> [-n <lines>] [-q] Follow the output, read-only (Ctrl-C quits) terractl watch <name> [-n <lines>] [-q] Follow the output, read-only (Ctrl-C quits)
terractl cmd <name> <command...> Send one console command terractl cmd <name> <command...> Send one console command
terractl reinstall <name> [--clean] Re-unpack server/ on next start terractl upgrade <name> Show the zips it can use, and which it runs
terractl upgrade <name> <zip>|latest [--downgrade] [--no-backup]
Change server version now (backs up first)
terractl reinstall <name> [--clean] Re-unpack the same version on next start
(--clean also re-downloads .NET) (--clean also re-downloads .NET)
terractl fix-perms <name> Reset the owner and modes of all its files
terractl mods <name> [list] Show installed mods terractl mods <name> [list] Show installed mods
terractl mods <name> add <folder|file.tmod>... Install and enable mods (and ModConfigs) terractl mods <name> add <folder|file.tmod>... Install and enable mods (and ModConfigs)
terractl mods <name> enable|disable <Mod>... Change which mods load terractl mods <name> enable|disable <Mod>... Change which mods load
@@ -972,17 +1118,19 @@ terractl copy [<remote>:]<name> [<remote>:][<new name>] [--with-server]
Copy an instance, here or between hosts Copy an instance, here or between hosts
terractl move [<remote>:]<name> [<remote>:][<new name>] [--with-server] terractl move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
Move or rename an instance Move or rename an instance
terractl remote add <name> <token> Add a remote (token from 'daemon trust add') terractl remote add [<name>] <token> [--address <host>[:<port>]]
terractl remote add <name> <address> [<token>] ... at an address the token doesn't list Add (or replace) a remote
terractl remote list | remove <name> Show or forget remotes terractl remote list | remove <name> Show or forget remotes
terractl daemon enable|disable|status Let other hosts manage this one terractl daemon enable|disable|status Let other hosts manage this one
terractl daemon trust add|remove <name> Create a token for a computer, or revoke one terractl daemon token A one-time token for another computer
terractl daemon trust list Show trusted computers and unused tokens terractl daemon revoke <name> Take a computer's access away
Used by the service: Used by the service:
terractl prepare <name> Create dirs/configs, unpack zip if needed terractl prepare <name> Create dirs/configs, unpack zip if needed
terractl run <name> Run the server terractl run <name> Run the server
terractl service-stop <name> <pid> Save and shut down via the console (service only) terractl service-stop <name> <pid> Save and shut down via the console (service only)
terractl journal-access Let the terraria group read the servers' journal
(runs before every start; safe to run by hand)
terractl daemon serve Run the daemon (terractld.service) terractl daemon serve Run the daemon (terractld.service)
``` ```
+105 -100
View File
@@ -14,7 +14,7 @@
# the instance to the destination's folders and free ports. # the instance to the destination's folders and free ports.
# #
# Security: the daemon's certificate is self-signed. A computer joins with a # Security: the daemon's certificate is self-signed. A computer joins with a
# one-time token from 'terractl daemon trust add', which holds the certificate's # one-time token from 'terractl daemon enable' or 'daemon token', which holds the certificate's
# fingerprint and a secret. The client checks that fingerprint on every # fingerprint and a secret. The client checks that fingerprint on every
# connection, so nothing in between can pose as the daemon, and trades the # connection, so nothing in between can pose as the daemon, and trades the
# secret for a random key of its own, sent with each request. The daemon stores # secret for a random key of its own, sent with each request. The daemon stores
@@ -27,7 +27,6 @@ import base64
import codecs import codecs
import errno import errno
import fcntl import fcntl
import getpass
import hashlib import hashlib
import hmac import hmac
import http.client import http.client
@@ -212,7 +211,7 @@ def open_state(cmdline):
class StateLock: class StateLock:
"""Serialises changes to trust.json between the daemon and 'terractl daemon trust'.""" """Serialises changes to trust.json between the daemon and 'terractl daemon token/revoke'."""
def __enter__(self): def __enter__(self):
self.fd = os.open(state_path(".lock"), os.O_RDWR | os.O_CREAT, 0o600) self.fd = os.open(state_path(".lock"), os.O_RDWR | os.O_CREAT, 0o600)
@@ -326,7 +325,7 @@ def host_addresses(listen_host, port):
# --------------------------------------------------------------- daemon ---- # --------------------------------------------------------------- daemon ----
EXEC_COMMANDS = {"list", "info", "new", "start", "stop", "restart", "enable", "disable", "cmd", "watch", EXEC_COMMANDS = {"list", "info", "new", "start", "stop", "restart", "enable", "disable", "cmd", "watch",
"mods", "reinstall", "delete", "rename", "export", "import", "mods", "reinstall", "upgrade", "fix-perms", "delete", "rename", "export", "import",
"-V", "--version", "version", "-h", "--help", "help"} "-V", "--version", "version", "-h", "--help", "help"}
@@ -427,6 +426,15 @@ def extract_upload(tar_path, dest):
raise HttpError(400, f"the files sent aren't a valid archive ({e})") raise HttpError(400, f"the files sent aren't a valid archive ({e})")
def unique_client_name(wanted, clients):
base = wanted[:60] if isinstance(wanted, str) and CLIENT_RE.fullmatch(wanted[:60]) else "computer"
taken = {c.get("name") for c in clients}
name, n = base, 2
while name in taken:
name, n = f"{base}-{n}", n + 1
return name
class Handler(http.server.BaseHTTPRequestHandler): class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
server_version = "terractld" server_version = "terractld"
@@ -579,7 +587,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
# ---- endpoints # ---- endpoints
def join(self): def join(self):
secret = self.read_json().get("secret") req = self.read_json()
secret, wanted, old_key = req.get("secret"), req.get("name"), req.get("old_key")
if not isinstance(secret, str): if not isinstance(secret, str):
raise HttpError(400, "bad request") raise HttpError(400, "bad request")
digest = sha256(secret) digest = sha256(secret)
@@ -593,11 +602,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
save_trust(trust) save_trust(trust)
dlog(f"{self.client_address[0]}: refused a join (unknown, used or expired token)") dlog(f"{self.client_address[0]}: refused a join (unknown, used or expired token)")
raise HttpError(403, "this token is not valid (it may have been used already, or " raise HttpError(403, "this token is not valid (it may have been used already, or "
"expired); create a new one there: sudo terractl daemon trust add <name>") "expired); create a new one there: sudo terractl daemon token")
trust["tokens"].remove(token) trust["tokens"].remove(token)
key = secrets.token_urlsafe(32) key = secrets.token_urlsafe(32)
name = token["name"] # A computer that's trusted already (it sends its old key) keeps its
trust["clients"] = [c for c in trust["clients"] if c.get("name") != name] # entry and name; a new one is named after its hostname
old = None
if isinstance(old_key, str):
old = next((c for c in trust["clients"]
if hmac.compare_digest(c.get("key_sha256", ""), sha256(old_key))), None)
if old:
name = old["name"]
trust["clients"].remove(old)
else:
name = unique_client_name(wanted, trust["clients"])
trust["clients"].append({"name": name, "key_sha256": sha256(key), trust["clients"].append({"name": name, "key_sha256": sha256(key),
"added": time.strftime("%Y-%m-%d %H:%M"), "added": time.strftime("%Y-%m-%d %H:%M"),
"from": self.client_address[0]}) "from": self.client_address[0]})
@@ -969,11 +987,12 @@ def daemon_enable():
if not wait_listening(host, port, 10): if not wait_listening(host, port, 10):
die(f"terractld isn't listening on {describe_listen(host, port)}; see: journalctl -u terractld") die(f"terractld isn't listening on {describe_listen(host, port)}; see: journalctl -u terractld")
print(f"terractld is running and starts at boot, listening on {describe_listen(host, port)}.") print(f"terractld is running and starts at boot, listening on {describe_listen(host, port)}.")
print(f"Certificate fingerprint (SHA-256): {cert_fingerprint()}")
print()
print("To let a computer manage this host, create a token for it:")
print(" sudo terractl daemon trust add <name for that computer>")
print(f"If a firewall is active, allow TCP port {port} (e.g. sudo ufw allow {port}/tcp).") print(f"If a firewall is active, allow TCP port {port} (e.g. sudo ufw allow {port}/tcp).")
print()
if load_trust()["clients"]:
print("To add another computer: sudo terractl daemon token")
else:
print_token()
return 0 return 0
@@ -983,7 +1002,7 @@ def daemon_disable():
if proc.returncode != 0: if proc.returncode != 0:
die(f"stopping terractld failed: {proc.stderr.strip()}") die(f"stopping terractld failed: {proc.stderr.strip()}")
print("terractld is stopped and won't start at boot, so other hosts can't manage this one.") print("terractld is stopped and won't start at boot, so other hosts can't manage this one.")
print("The computers it trusts are kept for next time (terractl daemon trust list).") print("The computers it trusts are kept for next time (sudo terractl daemon status).")
return 0 return 0
@@ -1001,76 +1020,54 @@ def daemon_status():
except PermissionError: except PermissionError:
print("trusted: (run with sudo to see which computers)") print("trusted: (run with sudo to see which computers)")
trust = None trust = None
if trust is not None: if trust is not None and not trust["clients"]:
names = ", ".join(c["name"] for c in trust["clients"]) or "none yet" print("trusted: none yet (add one: sudo terractl daemon token)")
print(f"trusted: {names}") elif trust is not None:
waiting = [t["name"] for t in trust["tokens"] if t["expires"] > time.time()] print()
if waiting: print(f"{'TRUSTED':<24} {'ADDED':<17} FROM")
print(f"tokens for: {', '.join(waiting)} (not used yet)") for c in trust["clients"]:
print(f"{c['name']:<24} {c.get('added', '-'):<17} {c.get('from', '-')}")
unused = [t for t in trust["tokens"] if t["expires"] > time.time()]
if unused:
print(f"(and {len(unused)} unused token{'s' if len(unused) > 1 else ''})")
if active != "active": if active != "active":
print("Start it with: sudo terractl daemon enable") print("Start it with: sudo terractl daemon enable")
return 0 return 0
def trust_add(name): def print_token():
if not CLIENT_RE.fullmatch(name): """Creates a one-time token and prints the command that uses it."""
die(f"invalid name '{name}' (letters, digits and _ . - @, up to 64)") open_state("daemon token")
open_state(f"daemon trust add {name}")
ensure_cert() ensure_cert()
secret = secrets.token_hex(32) secret = secrets.token_hex(32)
with StateLock(): with StateLock():
trust = load_trust() trust = load_trust()
now = time.time() now = time.time()
trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now and t["name"] != name] trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now]
trust["tokens"].append({"name": name, "secret_sha256": sha256(secret), trust["tokens"].append({"secret_sha256": sha256(secret), "expires": int(now + TOKEN_HOURS * 3600)})
"expires": int(now + TOKEN_HOURS * 3600)})
save_trust(trust) save_trust(trust)
known = any(c["name"] == name for c in trust["clients"])
host, port = listen_setting() host, port = listen_setting()
token = make_token({"v": 1, "fp": cert_fingerprint(), "s": secret, "a": host_addresses(host, port)}) fields = {"v": 1, "fp": cert_fingerprint(), "s": secret, "a": host_addresses(host, port)}
here = (socket.gethostname() or "").split(".")[0] here = (socket.gethostname() or "").split(".")[0]
if not NAME_RE.fullmatch(here): if NAME_RE.fullmatch(here):
here = "<name>" fields["n"] = here # the remote's name, unless the other computer picks one
print(f"On '{name}', run this to add this host as a remote called '{here}':") print(f"To manage this host from another computer, run this there (works once, within {TOKEN_HOURS} hours):")
print() print()
print(f" terractl remote add {here} {token}") print(f" terractl remote add {make_token(fields)}")
print() print()
print(f"The token works once, within {TOKEN_HOURS} hours.")
if known:
print(f"'{name}' is trusted already; joining again gives it a new key and the old one stops working.")
if systemctl("is-active", DAEMON_UNIT).stdout.strip() != "active": if systemctl("is-active", DAEMON_UNIT).stdout.strip() != "active":
print("The daemon isn't running yet; start it with: sudo terractl daemon enable") print("The daemon isn't running yet; start it with: sudo terractl daemon enable")
return 0 return 0
def trust_list(): def daemon_revoke(name):
try: open_state(f"daemon revoke {name}")
trust = load_trust()
except PermissionError:
die("this needs root: sudo terractl daemon trust list")
if not trust["clients"]:
print("No computers are trusted yet. Add one with: sudo terractl daemon trust add <name>")
else:
print(f"{'NAME':<24} {'ADDED':<17} FROM")
for c in trust["clients"]:
print(f"{c['name']:<24} {c.get('added', '-'):<17} {c.get('from', '-')}")
now = time.time()
for t in trust["tokens"]:
if t["expires"] > now:
hours = max(1, round((t["expires"] - now) / 3600))
print(f"Token for '{t['name']}' not used yet (expires in {hours} h)")
return 0
def trust_remove(name):
open_state(f"daemon trust remove {name}")
with StateLock(): with StateLock():
trust = load_trust() trust = load_trust()
clients = [c for c in trust["clients"] if c["name"] != name] clients = [c for c in trust["clients"] if c["name"] != name]
tokens = [t for t in trust["tokens"] if t["name"] != name] if len(clients) == len(trust["clients"]):
if len(clients) == len(trust["clients"]) and len(tokens) == len(trust["tokens"]): die(f"no computer called '{name}' is trusted (see: sudo terractl daemon status)")
die(f"'{name}' isn't trusted (see: terractl daemon trust list)") trust["clients"] = clients
trust["clients"], trust["tokens"] = clients, tokens
save_trust(trust) save_trust(trust)
print(f"'{name}' can't manage this host any more.") print(f"'{name}' can't manage this host any more.")
return 0 return 0
@@ -1078,20 +1075,13 @@ def trust_remove(name):
def daemon_main(argv): def daemon_main(argv):
sub, rest = (argv[0], argv[1:]) if argv else ("", []) sub, rest = (argv[0], argv[1:]) if argv else ("", [])
if sub == "trust": if sub == "revoke" and len(rest) == 1:
what = rest[0] if rest else "list" return daemon_revoke(rest[0])
if what == "add" and len(rest) == 2:
return trust_add(rest[1])
if what == "list" and len(rest) <= 1:
return trust_list()
if what == "remove" and len(rest) == 2:
return trust_remove(rest[1])
die("usage: terractl daemon trust add <name> | trust list | trust remove <name>")
commands = {"enable": daemon_enable, "disable": daemon_disable, "status": daemon_status, commands = {"enable": daemon_enable, "disable": daemon_disable, "status": daemon_status,
"serve": daemon_serve} "token": print_token, "serve": daemon_serve}
if sub in commands and not rest: if sub in commands and not rest:
return commands[sub]() return commands[sub]()
die("usage: terractl daemon enable | disable | status | trust ...") die("usage: terractl daemon enable | disable | status | token | revoke <name>")
# --------------------------------------------------------------- client ---- # --------------------------------------------------------------- client ----
@@ -1244,9 +1234,8 @@ class Remote:
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
message = f"HTTP error {resp.status}" message = f"HTTP error {resp.status}"
if resp.status == 401: if resp.status == 401:
die(f"{self.label} doesn't trust this computer (any more). On {self.label}, run " die(f"{self.label} doesn't trust this computer (any more). Run 'sudo terractl daemon "
f"'sudo terractl daemon trust add <name for this computer>', then here: terractl " f"token' there, then here: terractl remote add <token>")
f"remote remove {self.name} && terractl remote add {self.name} {self.addr} <token>")
die(f"{self.label}: {message}") die(f"{self.label}: {message}")
return resp return resp
@@ -1384,40 +1373,50 @@ def find_remote(spec):
if host and conf["address"].lower() == host.lower() and int(conf["port"]) == port: if host and conf["address"].lower() == host.lower() and int(conf["port"]) == port:
return Remote(name, conf, via=f"--host {spec}") return Remote(name, conf, via=f"--host {spec}")
die(f"'{spec}' isn't one of your remotes (see: terractl remote list). To add it, run " die(f"'{spec}' isn't one of your remotes (see: terractl remote list). To add it, run "
f"'sudo terractl daemon trust add <name for this computer>' there, then here: " f"'sudo terractl daemon token' there, then here: terractl remote add <token>")
f"terractl remote add <name> <token>")
def remote_add(args): def remote_add(args):
if len(args) not in (2, 3): usage = "usage: terractl remote add [<name>] <token> [--address <host>[:<port>]]"
die("usage: terractl remote add <name> <token>, or: terractl remote add <name> <address> [<token>]") address, rest = None, []
name = args[0] args = iter(args)
for arg in args:
if arg == "--address":
address = next(args, None) or die(usage)
elif arg.startswith("--address="):
address = arg.split("=", 1)[1]
elif arg.startswith("-"):
die(f"unknown option '{arg}'")
else:
rest.append(arg)
if len(rest) not in (1, 2):
die(usage)
name, token = (None, rest[0]) if len(rest) == 1 else rest
fields = read_token(token)
remotes = load_remotes()
# Adding a host that's a remote already (same certificate) replaces that
# remote: after its access was revoked, say, or to rename it
old = next((n for n, c in sorted(remotes.items()) if c["fingerprint"] == fields["fp"]), None)
name = name or old or fields.get("n")
if not name:
die("give the remote a name: terractl remote add <name> <token>")
if not NAME_RE.fullmatch(name): if not NAME_RE.fullmatch(name):
die(f"invalid remote name '{name}' (letters, digits and _ . -, not starting with . or -)") die(f"invalid remote name '{name}' (letters, digits and _ . -, not starting with . or -)")
remotes = load_remotes() if name in remotes and name != old:
if name in remotes: die(f"'{name}' is already the name of another remote; choose another: terractl remote add <name> <token>")
die(f"there's a remote called '{name}' already (to replace it: terractl remote remove {name})")
if len(args) == 3:
address, token = args[1], args[2]
elif args[1].startswith(TOKEN_PREFIX):
address, token = None, args[1]
else:
address = args[1]
if not sys.stdin.isatty():
die("no token given")
token = getpass.getpass("Token (from 'sudo terractl daemon trust add' on that host): ")
fields = read_token(token)
candidates = [split_hostport(address)] if address else [split_hostport(a) for a in fields.get("a", [])] candidates = [split_hostport(address)] if address else [split_hostport(a) for a in fields.get("a", [])]
if not candidates: if not candidates:
die("the token doesn't say where the host is; give its address: " die("the token doesn't say where the host is; add --address <host>")
"terractl remote add <name> <address> <token>") join = {"secret": fields["s"], "name": (socket.gethostname() or "").split(".")[0]}
if old:
join["old_key"] = remotes[old].get("key", "")
problems = [] problems = []
for host, port in candidates: for host, port in candidates:
remote = Remote(name, {"address": host, "port": port, "fingerprint": fields["fp"]}) remote = Remote(name, {"address": host, "port": port, "fingerprint": fields["fp"]})
remote.joining = True remote.joining = True
remote.label = remote.addr remote.label = remote.addr
try: try:
reply = remote.call("POST", "/1.0/trust/join", {"secret": fields["s"]}, auth=False, timeout=5) reply = remote.call("POST", "/1.0/trust/join", join, auth=False, timeout=5)
break break
except Unreachable as e: except Unreachable as e:
problems.append(str(e)) problems.append(str(e))
@@ -1425,7 +1424,9 @@ def remote_add(args):
if len(problems) == 1: if len(problems) == 1:
die(problems[0]) die(problems[0])
die("couldn't reach the host at any address in the token:\n " + "\n ".join(problems) + die("couldn't reach the host at any address in the token:\n " + "\n ".join(problems) +
"\nGive an address that works from here: terractl remote add <name> <address> <token>") "\nGive an address that works from here: terractl remote add <token> --address <host>")
if old:
del remotes[old]
remotes[name] = {"address": host, "port": port, "fingerprint": fields["fp"], "key": reply["key"], remotes[name] = {"address": host, "port": port, "fingerprint": fields["fp"], "key": reply["key"],
"client_name": reply["name"], "added": time.strftime("%Y-%m-%d %H:%M")} "client_name": reply["name"], "added": time.strftime("%Y-%m-%d %H:%M")}
save_remotes(remotes) save_remotes(remotes)
@@ -1437,7 +1438,7 @@ def remote_add(args):
about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}" about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}"
except Fail: except Fail:
about = remote.addr about = remote.addr
print(f"added remote '{name}' ({about}); it knows this computer as '{reply['name']}'") print(f"{'updated' if old else 'added'} remote '{name}' ({about}); it knows this computer as '{reply['name']}'")
print(f"Try: terractl --host {name} list") print(f"Try: terractl --host {name} list")
return 0 return 0
@@ -1447,7 +1448,7 @@ def remote_list(args):
die("usage: terractl remote list") die("usage: terractl remote list")
remotes = load_remotes() remotes = load_remotes()
if not remotes: if not remotes:
print("No remotes yet. Add one with: terractl remote add <name> <token>") print("No remotes yet. Run 'sudo terractl daemon token' on a host, then here: terractl remote add <token>")
return 0 return 0
print(f"{'NAME':<20} {'ADDRESS':<30} FINGERPRINT") print(f"{'NAME':<20} {'ADDRESS':<30} FINGERPRINT")
for name, conf in sorted(remotes.items()): for name, conf in sorted(remotes.items()):
@@ -1463,8 +1464,8 @@ def remote_remove(args):
if conf is None: if conf is None:
die(f"there's no remote called '{args[0]}' (see: terractl remote list)") die(f"there's no remote called '{args[0]}' (see: terractl remote list)")
save_remotes(remotes) save_remotes(remotes)
print(f"removed remote '{args[0]}'. It still lists this computer as '{conf.get('client_name', '?')}'; " print(f"removed remote '{args[0]}'. It still trusts this computer; to revoke that too, run there: "
f"to revoke that too, run there: sudo terractl daemon trust remove {conf.get('client_name', '<name>')}") f"sudo terractl daemon revoke {conf.get('client_name', '<name>')}")
return 0 return 0
@@ -1940,6 +1941,10 @@ if __name__ == "__main__":
except Fail as e: except Fail as e:
print(f"terractl: {e}", file=sys.stderr) print(f"terractl: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
except BrokenPipeError:
# Output went into something that stopped reading (e.g. head)
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
sys.exit(1)
except OSError as e: except OSError as e:
where = f": {e.filename}" if e.filename else "" where = f": {e.filename}" if e.filename else ""
print(f"terractl: {e.strerror or e}{where}", file=sys.stderr) print(f"terractl: {e.strerror or e}{where}", file=sys.stderr)
+1 -1
View File
@@ -4,7 +4,7 @@ Section: games
Priority: optional Priority: optional
Architecture: all Architecture: all
Installed-Size: @INSTALLED_SIZE@ Installed-Size: @INSTALLED_SIZE@
Maintainer: Your Name <you@example.com> Maintainer: Adam Brown <adam@adbraun.lol>
Depends: unzip, systemd, python3, openssl Depends: unzip, systemd, python3, openssl
Recommends: curl, libicu76 | libicu74 | libicu72 | libicu70 | libicu-dev, polkitd Recommends: curl, libicu76 | libicu74 | libicu72 | libicu70 | libicu-dev, polkitd
Conflicts: terraria-instance Conflicts: terraria-instance
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Creates or updates the Gitea release for a tag and attaches files to it. Its
# notes are that version's section of CHANGELOG.md. Run again for the same tag,
# it updates the notes and replaces files of the same name.
#
# gitea-release.py <tag> <file>...
#
# Needs GITEA_URL (https://gitea.example.com), GITEA_REPO (owner/name) and
# GITEA_TOKEN. Used by .gitea/workflows/release.yaml.
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
def api(method, path, body=None, content_type="application/json"):
url = f"{os.environ['GITEA_URL'].rstrip('/')}/api/v1/repos/{os.environ['GITEA_REPO']}{path}"
if isinstance(body, dict):
body = json.dumps(body).encode()
headers = {"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = content_type
request = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=300) as response:
data = response.read()
return json.loads(data) if data else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == "GET":
return None
sys.exit(f"{method} {url}: HTTP {e.code}: {e.read().decode(errors='replace')}")
def changelog_section(version):
"""The lines under '## <version>' in CHANGELOG.md, up to the next '## '."""
lines, found = [], False
with open("CHANGELOG.md") as f:
for line in f:
if line.startswith("## "):
if found:
break
found = line[3:].split()[0] == version
continue
if found:
lines.append(line)
if not found:
sys.exit(f"CHANGELOG.md has no '## {version}' section")
return "".join(lines).strip() + "\n"
def upload(release_id, path):
name = os.path.basename(path)
boundary = uuid.uuid4().hex
with open(path, "rb") as f:
content = f.read()
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n").encode() + content + f"\r\n--{boundary}--\r\n".encode()
api("POST", f"/releases/{release_id}/assets?name={urllib.parse.quote(name)}", body,
f"multipart/form-data; boundary={boundary}")
print(f"attached {name}")
def main(tag, files):
notes = changelog_section(tag[1:] if tag.startswith("v") else tag)
release = api("GET", f"/releases/tags/{urllib.parse.quote(tag)}")
if release is None:
release = api("POST", "/releases", {"tag_name": tag, "name": tag, "body": notes})
print(f"created release {tag}")
else:
release = api("PATCH", f"/releases/{release['id']}", {"body": notes})
print(f"updated release {tag}")
names = {os.path.basename(p) for p in files}
for asset in release.get("assets") or []:
if asset["name"] in names:
api("DELETE", f"/releases/{release['id']}/assets/{asset['id']}")
for path in files:
upload(release["id"], path)
print(release.get("html_url", ""))
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit("usage: gitea-release.py <tag> <file>...")
for var in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN"):
if not os.environ.get(var):
sys.exit(f"{var} is not set")
main(sys.argv[1], sys.argv[2:])
+1 -1
View File
@@ -8,7 +8,7 @@ Wants=network-online.target
Type=simple Type=simple
# Listens on TERRACTL_LISTEN from /etc/default/terractl (default: all addresses, # Listens on TERRACTL_LISTEN from /etc/default/terractl (default: all addresses,
# port 7776). Its certificate and the list of trusted computers are kept in # port 7776). Its certificate and the list of trusted computers are kept in
# /var/lib/terractl. Manage it with: terractl daemon enable|disable|status|trust # /var/lib/terractl. Manage it with: terractl daemon enable|disable|status|token|revoke
ExecStart=@BINDIR@/terractl daemon serve ExecStart=@BINDIR@/terractl daemon serve
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5