Archived
Compare commits
8
Commits
dev
..
7adf5c2999
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7adf5c2999 | ||
|
|
96f7876729 | ||
|
|
ef63609db9 | ||
|
|
c4a12727d4 | ||
|
|
52998de8f4 | ||
|
|
208c81899a | ||
|
|
8084c39e37 | ||
|
|
998d9f603d |
No files matched your search
@@ -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,2 +1,3 @@
|
|||||||
build/
|
build/
|
||||||
*.deb
|
*.deb
|
||||||
|
__pycache__/
|
||||||
+16
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+386
-15
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:])
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user