14 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 164bfc72db release 0.0.13
release / packages (push) Successful in 20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:42 +00:00
adamandClaude Opus 5.5 d2320a8ab4 license under MIT
LICENSE (MIT), installed as /usr/share/doc/terraria-server-manager/copyright
in both packages and with %license in the RPM. README gains a License
section and an up-to-date repository layout and build section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:59:52 +00:00
adamandClaude Opus 5.5 85da15a26f add rpm packaging; dnf and firewalld support
- packaging/rpm/terraria-server-manager.spec.in: installs via make
  install, plain scriptlets (no Fedora-only macros) mirroring the deb
  maintainer scripts; removal stops servers, disables boot/backups/
  daemon and removes only terractl's own drop-ins.
- make rpm (rpmbuild --nodeps, works on Debian/Ubuntu); make check
  parses the spec when rpmspec is installed.
- Release workflow builds deb and rpm, attaches both, publishes the rpm
  to Gitea's RPM registry. Job renamed to 'packages'.
- pkg_install: install hints use dnf or apt with the right package
  names (doctor, fetch, python3/openssl checks).
- firewalld: new prints the firewall-cmd command for its port; doctor
  lists instance and daemon ports firewalld doesn't allow.
- Docs, README, AGENTS.md and changelog; VERSION 0.0.13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:54:39 +00:00
adamandClaude Opus 5.5 3a3b4b8eb9 add AGENTS.md
Guidance for agents working on the repo: layout, build and checks, the
invariants (ownership, permissions, sandbox, data safety), code
conventions, a checklist for adding commands, testing without root,
docs style, the changelog, releases and git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:41:31 +00:00
adamandClaude Opus 5.5 fc2c08d47b add doctor, scheduled backups and fetch; release 0.0.12
release / deb (push) Successful in 17s
- doctor [<name>] [--fix]: checks the system (user, programs, folders,
  zips, units and leftover copies in /etc, sandbox access to moved
  folders, journal, polkit, daemon, orphaned boot links and schedules)
  and each instance (ownership, server zip, config paths, mods, ports,
  failed starts/backups), with a fix for each. --fix applies the safe
  ones and re-checks them.
- backup enable|disable|now|list: a systemd timer per instance
  (terraria-backup@.timer, Persistent=), saving the world first, into
  /var/lib/terraria/backups/<name>/ (group-readable), pruning only
  scheduled backups. rename carries the schedule, delete removes it.
  upgrade's backups move there too.
- fetch vanilla|tmodloader [<version>] [--list] [--upgrade <name>]:
  downloads from terraria.org / tModLoader's GitHub releases, checks
  GitHub's sha256 and the zip, names it with its version.
- terractld may write /etc/systemd/system, for backup enable and
  doctor --fix over --host.
- Units, tmpfiles, packaging, completion, docs and changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:26:56 +00:00
adamandClaude Opus 5.5 8a509f1398 check the terraria user can run terractl before switching to it
upgrade (before stopping anything) and prepare/run started as root now
refuse with an explanation when terractl is somewhere the terraria user
can't open, such as a copy under /root, instead of failing with
"env: Permission denied". upgrade also says when it started the server
again after a failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:03:43 +00:00
adamandClaude Opus 5.5 7adf5c2999 warn when the services can't reach moved folders
- list, new, import and enable check TERRARIA_BASE and TERRARIA_DIST
  against the units' ProtectSystem/ProtectHome/ReadWritePaths
  (terraria@ always, terractld when it's on) and print the drop-in
  to add. prepare explains why it can't create an instance folder.
- Guide: bind mounts and drop-ins under "Changing locations", and a
  new section for instances or zips under /home.
- journal-access in tab completion and the command reference.

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

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

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
# Builds the .deb and the .rpm when a version tag is pushed and publishes them
# as a release, with that version's CHANGELOG.md section as the release notes,
# and to Gitea's apt and RPM repositories. 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:
packages:
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 rpm file
- name: Build (make check runs first)
run: make deb rpm
- 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 build/*.rpm
# 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"
# Gitea's RPM registry, as a dnf/yum repository:
# <server>/api/packages/<owner>/rpm.repo
- name: Publish to the RPM repository
env:
RPMREPO: ${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/rpm
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.GITEA_TOKEN }}
run: |
rpm=$(echo build/*.rpm)
upload() {
curl -sS -o response.txt -w '%{http_code}' --user "$GITHUB_ACTOR:$TOKEN" \
--upload-file "$rpm" "$RPMREPO/upload"
}
status=$(upload)
if [ "$status" = 409 ]; then
# This version is there already (the tag was pushed again): replace it.
# Gitea names the version either <version>-<release> or <version>.
for v in "$(cat VERSION)-1" "$(cat VERSION)"; do
curl -sS -f --user "$GITHUB_ACTOR:$TOKEN" -X DELETE \
"$RPMREPO/package/terraria-server-manager/$v/noarch" && break
done
status=$(upload)
fi
if [ "$status" != 201 ]; then
echo "upload failed: HTTP $status"; cat response.txt; echo; exit 1
fi
echo "published $(basename "$rpm") to $RPMREPO"
+1
View File
@@ -1,2 +1,3 @@
build/
*.deb
__pycache__/
+272
View File
@@ -0,0 +1,272 @@
# AGENTS.md
Guidance for AI agents (and people) working on this repository.
## What this is
`terraria-server-manager` runs any number of vanilla Terraria and tModLoader
dedicated servers on one Linux host, each as its own systemd service
(`terraria@<name>.service`), managed with the `terractl` command. It ships as a
Debian package and an RPM (Fedora, RHEL 9+), both built from the same
`make install`. Server software is never bundled: official zips go in
`/var/lib/terraria/dist` and are unpacked into each instance on demand.
The user-facing documentation is `docs/guide.md` (installed as the package's
README). Read the sections relevant to a change before making it; the guide is
the specification of how things behave.
## Layout
| Path | What it is |
|---|---|
| `bin/terractl` | The main tool, in bash. Everything on the local host. |
| `lib/terractl-remote` | Python (standard library only, 3.9+): `--host`, `remote`, `daemon`, `copy`, `move`. Its daemon does everything by running `terractl` on its own host. |
| `completions/bash/terractl` | Bash completion. |
| `systemd/` | `terraria@.service`/`.socket` (one per instance), `terractld.service` (daemon for other hosts), `terraria-backup@.service`/`.timer` (scheduled backups). |
| `config/` | `default/terractl` (user overrides, a conffile), `tmpfiles.d`, `sysusers.d`, `polkit` rule, `journald@terraria.conf` (a conffile). |
| `packaging/deb/` | `control`, `conffiles`, maintainer scripts (`postinst`, `prerm`, `postrm`). |
| `packaging/rpm/` | `terraria-server-manager.spec.in`: the RPM spec (`@VERSION@` filled in by `make rpm`). Plain scriptlets, no Fedora-only macros, so it builds anywhere `rpmbuild` runs. |
| `packaging/gitea-release.py` | Publishes a release with that version's CHANGELOG section as notes. |
| `.gitea/workflows/release.yaml` | On a `v*` tag: `make deb rpm` (on Ubuntu), publish the release and the apt and RPM repositories. |
| `tools/windows/export-tmod-mods.ps1` | Helper for players to export their enabled tModLoader mods. |
| `docs/guide.md`, `README.md`, `CHANGELOG.md`, `VERSION` | Docs and release metadata. |
| `LICENSE` | MIT. Installed as `/usr/share/doc/terraria-server-manager/copyright` (and `%license` in the RPM). |
Placeholders `@VERSION@`, `@BINDIR@`, `@DOCDIR@` and `@PKGLIBDIR@` in
`bin/terractl` and the unit files are filled in by `make install`/`make deb`.
A copy run straight from the repo is a "development copy": it detects the
unfilled placeholders and uses `lib/terractl-remote` from the source tree.
## Build and check
```bash
make check # bash -n, sh -n on maintainer scripts, Python syntax, shellcheck, pyflakes
make deb # runs check, builds build/terraria-server-manager_<version>_all.deb
make rpm # runs check, builds build/terraria-server-manager-<version>-1.noarch.rpm
```
`make rpm` needs `rpmbuild` (`apt install rpm` works; it runs with `--nodeps`
because Debian has no RPM database). `make check` parses the spec with `rpmspec`
when it's installed.
**`make check` must pass with shellcheck installed** (`apt install shellcheck
python3-pyflakes`). The release workflow runs it, so a shellcheck warning blocks
a release. Fix warnings rather than disabling them; a `# shellcheck disable=`
needs a comment saying why (see the existing ones).
There is no automated test suite. See [Testing](#testing).
## How the system fits together
These invariants run through the whole code base. Keep them.
- **Servers run as the unprivileged `terraria` user**, sandboxed by
`terraria@.service` (`ProtectSystem=strict`, `ProtectHome=yes`, writable only
`ReadWritePaths=/var/lib/terraria/instances`, `UMask=0027`).
- **Nothing in an instance may end up owned by root.** Commands run as root
`chown` what they create (`fix_owner`, `chown -R` after `new`/`import`).
`prepare` and `run` started as root switch to `terraria` with `runuser`
(re-running `$SELF`, which is resolved before the script's `cd /`).
- **Permission model:** root can do everything; members of the `terraria` group
can read instances, start/stop/restart servers (polkit rule), use the console
(`attach`, `cmd`, `watch`), read the servers' own journal (log namespace
`terraria`), add zips to `dist/` and read backups. Group members can read but
never change instance files. Every new command must fit this model and be
added to the permissions table in the guide.
- **`instance.conf` is sourced by bash.** Anything written into it (e.g. `ZIP=`)
must be validated first (`safe_zip_name`). Instance names are validated by
`valid_name` and can't start with `.`.
- **One server per instance at a time:** the lock on `<instance>/.lock` (fd 9)
is held for the server's lifetime. Commands that replace `server/` or delete
things under a running server must check `instance_running`.
- **`server/` is replaceable, `data/` is precious.** Unpacking happens in a temp
folder and only replaces `server/` once complete; `server/.installed` records
`<type> <zip>`. Nothing may delete or rewrite worlds without a backup.
- **Changes that can lose data back up first** and leave things as they were on
failure (see `cmd_upgrade`: validate everything, stop, back up, change, roll
back the pin and restart on failure).
- **Files outside `/var/lib/terraria`:** only the documented drop-ins in
`/etc/systemd/system` (`terraria-backup@<name>.{timer,service}.d/terractl.conf`
from `backup enable`, `<unit>.d/terractl-locations.conf` from `doctor --fix`).
`postrm` purge removes them. Never overwrite admin files or unit files.
- **The daemon (`terractld`) runs `terractl` as root** with `TERRACTL_VIA` set.
Only commands listed in `EXEC_COMMANDS` in `lib/terractl-remote` can be run
remotely. It runs with `ProtectSystem=full` and `ProtectHome=true`, plus write
access to `/etc/systemd/system` only.
- **Locations are configurable** (`TERRARIA_BASE`, `TERRARIA_DIST`,
`TERRARIA_BACKUPS` in `/etc/default/terractl`), but unit files can't follow
them; `location_warnings` and `doctor` detect mismatches. Never hard-code
`/var/lib/terraria/...` in `bin/terractl`: use `$BASE_DIR`, `$DIST_DIR`,
`$BACKUP_DIR`.
## Code conventions
### bash (`bin/terractl`)
- `set -euo pipefail`. Be careful with pipelines that can legitimately fail
(`grep` with no match, `grep -q` closing a pipe early): capture to a variable
first, or add `|| true`.
- Errors: `die "message"` (exits 1); notes: `log "message"` (stderr). Both
prefix `terractl:`. Normal output goes to stdout.
- **Messages say what to do next.** When suggesting a command, use
`hint root|group <command> [args]`, which adds `sudo` only when needed and
shows `terractl --host <remote> ...` when run through the daemon.
- Commands are `cmd_<name>` functions; sub-actions are `<command>_<action>`
(e.g. `backup_enable`). Helpers are lower-case with underscores. Sections are
separated by `# ---- name ----` banner comments.
- Every non-obvious function gets a short comment above it saying what it does
and why; match the existing density (comments explain intent and edge cases,
not syntax).
- Option parsing: a `while`/`case` loop, `die "unknown option '$1'"` for
anything unexpected, and a `usage: terractl ...` message for wrong arguments.
- Root checks: `[[ $EUID -eq 0 ]] || die "'<command>' needs root: sudo terractl ..."`.
- `local` for all function variables. Globals are upper-case.
- `EXTRACT_TMP` is the single temp-folder slot that `cleanup` (the EXIT trap)
removes. Code that must survive a `die` in a helper runs it in a subshell
with its own `trap cleanup EXIT` (see `make_backup`).
### Python (`lib/terractl-remote`)
- Standard library only, Python 3.9+. `make check` runs pyflakes on it.
- It never touches instances directly: it runs `terractl` (locally, or via the
daemon on the other host) so checks and ownership stay in one place.
### Shell in packaging
- Maintainer scripts and RPM scriptlets are POSIX `sh`, idempotent, and must
never fail an install or removal because systemd isn't running
(`[ -d /run/systemd/system ]` guards, `|| true`/`|| :` on best-effort steps).
- **The two packages must behave the same.** A change to `packaging/deb/`
scripts needs the matching change in the spec's `%post`/`%preun`/`%postun`,
and the other way round. RPM scriptlets get `$1` as a count (`%post`: 1 =
install, 2+ = upgrade; `%preun`/`%postun`: 0 = removal) where dpkg gets
words. rpm has no purge: `%postun` on removal does a careful subset of the
Debian purge (never deleting admin files or data).
### Portability
- Debian/Ubuntu and Fedora/RHEL are both supported. Don't hard-code `apt`:
suggest installs with `pkg_install "<sudo prefix>" <what>` (maps package
names per system), and mention both in the docs where commands differ.
- Fedora and RHEL run firewalld by default; features that open a port should
say how to allow it (`firewalld_open_cmd`), never open it automatically.
- SELinux (enforcing on Fedora/RHEL) hasn't been looked at yet; note anything
that might be affected (new executables, sockets, paths) in the PR.
## Adding or changing a command: checklist
1. `bin/terractl`: the `cmd_*` function, the `usage` text, and the dispatch at
the bottom. Commands that don't take an instance name first (like `list`,
`doctor`, `backup`, `fetch`) are dispatched in the first `case`, before
`valid_name "$1"`.
2. `lib/terractl-remote`: add it to `EXEC_COMMANDS` if it should work with
`--host` (and a handler in `run_on_remote` if it moves files to or from the
caller's computer, like `export`, `import` and `mods add`).
3. `completions/bash/terractl`: the command list (`commands=`), and its
arguments/options. Local-only commands go in the `else` branch's list.
Instance names on a remote come from `_terractl_remote_instances`.
4. `docs/guide.md`:
- the section describing it (with examples using the `calamity` instance);
- the **Permissions** table;
- the **Tab completion** list, if completion changed;
- the **Command reference**;
- **Troubleshooting** and the `doctor` description, if relevant;
- **Components** and **Directory layout** for new files or folders.
5. `README.md` features list, for user-visible features.
6. `CHANGELOG.md`: an entry in the newest section (see [Changelog](#changelog)).
7. New installed files: `Makefile` `install` **and** `uninstall`, unit files
through `$(SUBST)` if they contain placeholders, the RPM spec's `%files`
(a file missing there fails `make rpm`), `packaging/deb/postrm` (purge) and
the spec's `%postun` for anything created at runtime, and `conffiles` plus
`%config(noreplace)` for files in `/etc` that users edit. New dependencies go
in both `packaging/deb/control` and the spec (package names can differ).
8. If it affects what can go wrong on a host, consider a check in `doctor`
(`doc_problem warn|FAIL "<what>" "<fix command>" [<function that fixes it>]`;
only pass a fix function for fixes that are safe to apply unattended).
## Testing
Agents usually can't run as root, use systemd, or install the package. The
pattern used so far:
- Make a scratch folder (outside the repo) with `inst/`, `dist/`, `backups/`
and point `TERRARIA_BASE`, `TERRARIA_DIST`, `TERRARIA_BACKUPS` at them, and
`TERRARIA_USER` at your own user.
- Fake root in a **copy** of the script:
`sed -e 's/\$EUID/${FAKE_EUID:-$EUID}/g' bin/terractl > scratch/terractl`
with `FAKE_EUID=0` exported, and a `runuser` stub on `PATH` that drops to the
"service user" with `exec env -u FAKE_EUID "$@"` after the `--`. (Replacing
`$EUID` with a literal `0` makes `prepare` re-run itself forever.)
- Stub `systemctl` on `PATH` that keeps unit state in files (`is-active`,
`is-enabled`, `is-failed`, `start`/`stop`/`enable`/`disable`, and `show -p`
for the properties used). Point `SYSTEMD_ETC` at a scratch folder in the copy.
- Build test zips with Python's `zipfile` (`zip` may not be installed): vanilla
needs `<ver>/Linux/TerrariaServer.bin.x86_64`, tModLoader needs
`start-tModLoaderServer.sh`.
- `fetch` can be tested against the real sites; downloads are 40-60 MB.
- Don't `pkill -f` a pattern that appears in your own command line.
- Always run `make check`, and `make deb` when packaging changed. For unit
files, run `systemd-analyze verify` on the **built** copies under
`build/deb-root` (the sources contain placeholders).
Say clearly what was tested with stubs and what wasn't (real root, real
systemd, real servers); the maintainer tests on a real host before releasing.
## Documentation style
- Plain, direct English; short sentences; British spelling where it differs.
- Explain what happens and why, then show the command. Examples use the
instance `calamity` and the remote `nas`.
- Bold lead-in phrases for key points (`**Downgrades need --downgrade.**`).
- Command reference and usage text are aligned two-column blocks; keep the
columns aligned when adding lines.
- Documented behaviour must match the code; update both in the same change.
## Changelog
`CHANGELOG.md` is for users, and each version's section is published as-is as
its release notes on the Gitea release page (`gitea-release.py`). Write it so it
reads well there.
- **Headings:** `## <version> - unreleased` while in progress,
`## <version> - <YYYY-MM-DD>` once released. `gitea-release.py` takes the
first word after `## ` as the version, so keep exactly that shape.
- **Where entries go:** in the newest section if it's unreleased. If the newest
section is already released (dated), start `## <next version> - unreleased`
at the top **and** bump `VERSION` to match, in the same commit.
- **Released sections are history:** don't edit them, except to fix a typo.
- **Unreleased changes are edited, not appended to.** If something in the open
section changes before release (renamed, moved, fixed), update its existing
entry so the section describes the final state, rather than adding a
"fixed X" line about something users never had.
- **What an entry says:** the command and options as the user types them
(`` `sudo terractl backup enable <name> [--every <when>]` ``), what it does
and what they'll see, defaults, and anything they must do themselves
(restart servers, run a command after upgrading, a new dependency). Full
sentences; say "now" for changed behaviour and "before, ..." when the old
behaviour could have caught people out.
- **Order:** new features first, then changed behaviour, then fixes; the most
important first within each. A large feature gets one bullet with
sub-bullets for its parts (see `upgrade` and `fetch` in 0.0.12).
- **Leave out** refactors, test-only changes, code style and doc rewording,
unless the docs gained something users should know about (e.g. a new guide
section on a setup that didn't work before).
- Every user-visible change gets an entry in the same commit as the change.
## Releases
- `VERSION` holds the version (`0.0.N`) and must match the newest changelog
section (see [Changelog](#changelog)).
- To release: set the date, commit, then tag **`v<version>`** (annotated,
message `terraria-server-manager <version>`). The workflow refuses a tag that
doesn't match `VERSION`. Pushing the tag builds and publishes the release and
the apt and RPM repositories, so only push when asked.
## Git
- Commit subjects are short, lower-case and imperative-ish
(`add upgrade command; reinstall keeps the same version`), with a body
listing the changes for anything non-trivial.
- Everything is LF (`.gitattributes`); `build/`, `*.deb` and `__pycache__/` are
ignored.
- Commit, tag or push only when asked.
+36 -1
View File
@@ -1,5 +1,40 @@
# Changelog
## 0.0.13 - 2026-10-02
- terraria-server-manager is now under the MIT license (`LICENSE`). Both packages include it: `/usr/share/doc/terraria-server-manager/copyright`, and on RPM systems also `/usr/share/licenses/terraria-server-manager/LICENSE`.
- RPM package for Fedora, RHEL 9+ and compatible systems (AlmaLinux, Rocky Linux), built alongside the `.deb` and published to an RPM repository: add `https://gitea.adbraun.lol/api/packages/adam/rpm.repo` to `/etc/yum.repos.d/` and `sudo dnf install terraria-server-manager` (see the guide's "Installation"). It installs the same files and works the same way as the Debian package. `sudo dnf remove` stops all instances (saving their worlds) and turns off starting at boot, scheduled backups and the daemon; worlds, backups and settings are kept. Build it with `make rpm` (needs `rpmbuild`, also on Debian and Ubuntu).
- `doctor` checks the firewall when firewalld is running (the default on Fedora and RHEL): it lists instance ports, and the daemon's, that firewalld doesn't allow, with the `firewall-cmd` command to open them. `new` shows that command too. Opening ports is never done automatically.
- Messages that suggest installing something (`doctor`, `fetch`, and when `python3` or `openssl` is missing) now give the `dnf install` command on Fedora and RHEL instead of `apt install`, with the right package names (`libicu`, `polkit`).
## 0.0.12 - 2026-10-02
- New `fetch` command to download server zips into `dist/`, named with their version:
- `terractl fetch tmodloader [<version>]` downloads from tModLoader's GitHub releases (default: the latest stable) and checks it against the SHA-256 checksum GitHub publishes. `terractl fetch vanilla [<version>]` downloads from terraria.org (default: its newest; older versions by number, `1449` or `1.4.4.9`).
- `--list` shows the versions available (tModLoader: stable and preview), and which are already downloaded. `--upgrade <name>` switches an instance to the zip with `upgrade` right after.
- Every zip is checked (complete, and the right kind) before it's put in `dist/`; zips already there aren't downloaded again. If a site doesn't have the version or can't be reached, it says so and where to download it by hand.
- Members of the `terraria` group can use it without `sudo` (except `--upgrade`). Needs `curl`.
- Scheduled backups, with a systemd timer per instance (`terraria-backup@<name>.timer`):
- `sudo terractl backup enable <name> [--every <when>] [--keep <number>]` (default: daily, keeping the newest 14). `--every` takes `hourly`, `daily`, `weekly`, a time such as `04:30`, or any systemd calendar expression. Missed backups are made after the machine starts again.
- `backup now <name>` makes one immediately, `backup disable <name>` stops the schedule (existing backups are kept), and `backup list [<name>]` shows schedules, the last and next run, and the backups.
- Each backup saves the world first if the server is running (as `export` does) and goes in `/var/lib/terraria/backups/<name>/` (`TERRARIA_BACKUPS` in `/etc/default/terractl`), readable by the `terraria` group. Only scheduled backups are pruned.
- `rename` takes the schedule along, `delete` turns it off, and purging the package removes the schedules (not the backups).
- New `doctor` command: `terractl doctor [<name>] [--fix]` checks the system (user, programs including ICU and curl, folders, zips, the systemd units and old copies in `/etc` that replace them, sandbox access to moved folders, journal access, polkit, the daemon, leftovers of deleted instances) and each instance (file ownership and modes, its server zip, paths in `serverconfig.txt`, missing mods, shared ports, failed starts and backups). Each problem comes with the command that fixes it. `--fix` applies the safe fixes: `fix-perms`, folder and zip permissions, journal access, the `ReadWritePaths=` drop-in for moved folders, and boot links or backup schedules of instances that are gone. It exits with status 1 if a problem remains.
- The daemon (`terractld.service`) may now write to `/etc/systemd/system`, so `backup enable` and `doctor --fix` also work through `--host`.
- Warnings about moved folders now also suggest `terractl doctor --fix`.
- New `upgrade` command to change an instance's server version, up or down:
- `terractl upgrade <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 `/var/lib/terraria/backups/<name>/`, 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.
- `upgrade`, and `prepare`/`run` started as root, check first that the `terraria` user can run the terractl being used. A copy under `/root` is refused with an explanation, before `upgrade` stops the server, instead of failing with `env: Permission denied`.
- `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
- 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 +46,7 @@
- `list` has a new `BOOT` column showing whether each server starts at boot (`enabled`, `disabled`, or e.g. `masked`).
- Manage other hosts, like LXD/Incus remotes:
- `sudo terractl daemon enable` starts `terractld.service`, a daemon other hosts connect to (HTTPS, TCP port 7776; `TERRACTL_LISTEN` in `/etc/default/terractl` changes it). It's off until enabled. `daemon disable` and `daemon status` turn it off and show its state.
- `sudo terractl daemon trust add <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 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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Adam Brown
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35 -2
View File
@@ -2,6 +2,7 @@
#
# make check syntax-check the scripts (and shellcheck, if installed)
# make deb build build/terraria-server-manager_<version>_all.deb
# make rpm build build/terraria-server-manager-<version>-1.noarch.rpm
# sudo make install install directly (default PREFIX=/usr/local), no package
# sudo make uninstall remove what 'make install' installed (keeps your data)
# make clean remove build/
@@ -33,8 +34,13 @@ SUBST = sed -e 's|@VERSION@|$(VERSION)|g' -e 's|@BINDIR@|$(BINDIR)|g' -e 's|@DOC
MAINT_SCRIPTS := packaging/deb/postinst packaging/deb/prerm packaging/deb/postrm
STAGE := build/deb-root
DEB := build/$(NAME)_$(VERSION)_all.deb
RPMTOP := build/rpm
RPM := build/$(NAME)-$(VERSION)-1.noarch.rpm
# What goes in the source tarball the RPM is built from
RPM_SOURCES := Makefile VERSION LICENSE README.md CHANGELOG.md bin lib completions config docs \
packaging systemd tools
.PHONY: all check install uninstall deb clean
.PHONY: all check install uninstall deb rpm clean
all: check
@@ -48,6 +54,13 @@ check:
else \
echo "shellcheck not installed; skipped (apt install shellcheck)"; \
fi
@if command -v rpmspec >/dev/null; then \
spec=$$(mktemp --suffix=.spec); \
sed 's|@VERSION@|$(VERSION)|' packaging/rpm/$(NAME).spec.in > $$spec; \
rpmspec -P $$spec >/dev/null; rc=$$?; rm -f $$spec; [ $$rc -eq 0 ]; \
else \
echo "rpmspec not installed; RPM spec not checked (apt install rpm, or dnf install rpm-build)"; \
fi
@if python3 -m pyflakes --version >/dev/null 2>&1; then \
python3 -m pyflakes lib/terractl-remote; \
else \
@@ -64,12 +77,16 @@ install:
install -m 755 lib/terractl-remote $(DESTDIR)$(PKGLIBDIR)/terractl-remote
$(SUBST) systemd/terraria@.service > $(DESTDIR)$(UNITDIR)/terraria@.service
$(SUBST) systemd/terractld.service > $(DESTDIR)$(UNITDIR)/terractld.service
chmod 644 $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terractld.service
$(SUBST) systemd/terraria-backup@.service > $(DESTDIR)$(UNITDIR)/terraria-backup@.service
$(SUBST) systemd/terraria-backup@.timer > $(DESTDIR)$(UNITDIR)/terraria-backup@.timer
chmod 644 $(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terractld.service \
$(DESTDIR)$(UNITDIR)/terraria-backup@.service $(DESTDIR)$(UNITDIR)/terraria-backup@.timer
install -m 644 systemd/terraria@.socket $(DESTDIR)$(UNITDIR)/
install -m 644 config/sysusers.d/terractl.conf $(DESTDIR)$(SYSUSERSDIR)/
install -m 644 config/tmpfiles.d/terractl.conf $(DESTDIR)$(TMPFILESDIR)/
install -m 644 config/polkit/terractl.rules $(DESTDIR)$(POLKITDIR)/50-terractl.rules
install -m 644 docs/guide.md $(DESTDIR)$(DOCDIR)/README.md
install -m 644 LICENSE $(DESTDIR)$(DOCDIR)/copyright
install -m 644 tools/windows/export-tmod-mods.ps1 $(DESTDIR)$(DATADIR)/
install -m 644 completions/bash/terractl $(DESTDIR)$(COMPLETIONDIR)/terractl
@# Never overwrite local settings
@@ -97,10 +114,14 @@ install:
uninstall:
@if [ -z "$(DESTDIR)" ] && [ -d /run/systemd/system ]; then \
systemctl disable --now terractld.service 2>/dev/null || true; \
for t in $$(systemctl list-unit-files --plain --no-legend 'terraria-backup@*.timer' | awk '{print $$1}'); do \
systemctl disable --now "$$t" 2>/dev/null || true; \
done; \
fi
rm -f $(DESTDIR)$(BINDIR)/terractl \
$(DESTDIR)$(UNITDIR)/terraria@.service $(DESTDIR)$(UNITDIR)/terraria@.socket \
$(DESTDIR)$(UNITDIR)/terractld.service \
$(DESTDIR)$(UNITDIR)/terraria-backup@.service $(DESTDIR)$(UNITDIR)/terraria-backup@.timer \
$(DESTDIR)$(SYSUSERSDIR)/terractl.conf $(DESTDIR)$(TMPFILESDIR)/terractl.conf \
$(DESTDIR)$(COMPLETIONDIR)/terractl $(DESTDIR)$(POLKITDIR)/50-terractl.rules
rm -rf $(DESTDIR)$(DOCDIR) $(DESTDIR)$(DATADIR) $(DESTDIR)$(PKGLIBDIR)
@@ -121,5 +142,17 @@ deb: check
find $(STAGE) -type d -exec chmod 755 {} +
dpkg-deb --build --root-owner-group $(STAGE) $(DEB)
# Builds with plain rpmbuild (no Fedora-specific macros), so it works on Debian
# and Ubuntu too, with: apt install rpm
rpm: check
rm -rf $(RPMTOP)
install -d $(RPMTOP)/SOURCES $(RPMTOP)/SPECS
tar -czf $(RPMTOP)/SOURCES/$(NAME)-$(VERSION).tar.gz --exclude=__pycache__ \
--transform 's|^|$(NAME)-$(VERSION)/|' $(RPM_SOURCES)
sed 's|@VERSION@|$(VERSION)|' packaging/rpm/$(NAME).spec.in > $(RPMTOP)/SPECS/$(NAME).spec
@# --nodeps: on Debian and Ubuntu there is no RPM database to check BuildRequires against
rpmbuild -bb --nodeps --define "_topdir $(CURDIR)/$(RPMTOP)" $(RPMTOP)/SPECS/$(NAME).spec
cp $(RPMTOP)/RPMS/noarch/$(NAME)-$(VERSION)-1.noarch.rpm $(RPM)
clean:
rm -rf build
+45 -9
View File
@@ -13,12 +13,14 @@ sudo terractl attach calamity
- **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.
- **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.** `terractl fetch` downloads official Terraria and tModLoader server zips (or drop them into `/var/lib/terraria/dist` yourself); they're unpacked on demand, and each instance can be pinned to a specific version. `terractl upgrade` checks a new zip, backs up the worlds and switches version in one step.
- **Safe shutdowns.** Stopping a server saves the world first.
- **A live console.** `terractl attach` shows the server's output and lets you type commands into it; `terractl watch` just shows the output.
- **Mod management.** `terractl mods` installs, enables and removes mods, with correct file ownership.
- **Tab completion** for commands, instance names, mods and console commands.
- **Export and import.** Pack an instance into one `.tar.gz` for backups or moving to another machine, and import it anywhere, with paths, ports and ownership adjusted.
- **Scheduled backups.** `terractl backup enable <name>` backs an instance up daily (or on any schedule) with a systemd timer, saving the world first and keeping the newest 14.
- **Self-check.** `terractl doctor` checks the whole setup, explains what's wrong with the command to fix it, and `--fix` fixes what it safely can.
- **Manage other hosts.** Run any command on another host with `terractl --host nas ...`, and copy or move instances between hosts with `terractl copy` and `terractl move`, like LXD/Incus remotes. Hosts opt in by running the `terractld` daemon; access is per computer, over pinned TLS.
- **Hardened.** Servers run as an unprivileged `terraria` user and can only write to their own data.
- **Access for moderators.** Members of the `terraria` group can start, stop and restart servers, use the console and read the servers' own logs, without `sudo` and without seeing the rest of the system's logs.
@@ -26,16 +28,37 @@ sudo terractl attach calamity
## Requirements
- Debian 12+ or Ubuntu 22.04+ (other systemd-based distributions should work with `make install`)
- Debian 12+ or Ubuntu 22.04+ (`.deb`), or Fedora, RHEL 9+ and compatible (`.rpm`); other systemd-based distributions should work with `make install`
- `unzip`, `systemd`; for tModLoader, also `curl` and ICU (`libicu`)
- `python3` and `openssl`, for managing other hosts
## Install
Download the `.deb` from the releases page, then:
From the apt repository, which also brings updates with `sudo apt upgrade`:
```bash
sudo apt install ./terraria-server-manager_<version>_all.deb
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
```
On Fedora, RHEL and compatible, from the RPM repository:
```bash
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/rpm.repo \
-o /etc/yum.repos.d/terraria-server-manager.repo
sudo dnf install terraria-server-manager
```
Or download the `.deb` or `.rpm` from the releases page, then:
```bash
sudo apt install ./terraria-server-manager_<version>_all.deb # Debian, Ubuntu
sudo dnf install ./terraria-server-manager-<version>-1.noarch.rpm # Fedora, RHEL
```
Or install from source, without a package:
@@ -52,24 +75,37 @@ See [docs/guide.md](docs/guide.md). It covers installation, creating instances,
```
bin/terractl The command-line tool
systemd/ terraria@.service and terraria@.socket
lib/terractl-remote Managing other hosts (--host, remote, daemon, copy, move), in Python
systemd/ terraria@ service and socket, terractld, terraria-backup@ service and timer
completions/bash/ Bash completion for terractl
config/ /etc/default settings, sysusers.d and tmpfiles.d entries
config/ /etc/default settings, sysusers.d, tmpfiles.d, polkit and journald files
packaging/deb/ Debian package metadata and install/remove scripts
packaging/rpm/ RPM spec (Fedora, RHEL)
docs/guide.md Full documentation
tools/windows/ export-tmod-mods.ps1 for exporting mods from Windows
VERSION Current version, used by the build
Makefile make check | deb | install | uninstall | clean
Makefile make check | deb | rpm | install | uninstall | clean
```
## Building
```bash
make check # bash -n on all scripts, plus shellcheck if installed
make check # syntax checks, plus shellcheck, pyflakes and rpmspec if installed
make deb # build/terraria-server-manager_<version>_all.deb
make rpm # build/terraria-server-manager-<version>-1.noarch.rpm (needs rpmbuild)
```
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` and the `.rpm`, publishes them as a release with that version's changelog section as the notes, and adds them to the apt and RPM repositories. It needs a runner with the `ubuntu-latest` label. To build a package by hand instead, run `make deb` or `make rpm`.
## License
MIT; see [LICENSE](LICENSE).
## Disclaimer
+1 -1
View File
@@ -1 +1 @@
0.0.11
0.0.13
+1303 -18
View File
File diff suppressed because it is too large. Load diff
+72 -13
View File
@@ -4,16 +4,36 @@
# Installed to <prefix>/share/bash-completion/completions/terractl, where the
# 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.
_terractl_base() {
local base=/var/lib/terraria/instances value
_terractl_setting() {
local result=$2 value
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#\'}
if [[ -n $value ]]; then base=$value; fi
if [[ -n $value ]]; then result=$value; 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
@@ -32,6 +52,11 @@ _terractl_instances() {
fi
}
# Instance names here, or on remote <host> if one is given
_terractl_any_instances() {
if [[ -n $1 ]]; then _terractl_remote_instances "$1"; else _terractl_instances; fi
}
# Remotes this user added with 'terractl remote add'
_terractl_remotes() {
local f=${TERRACTL_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/terractl}/remotes.json
@@ -105,7 +130,7 @@ _terractl() {
cword=$COMP_CWORD
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 fetch backup doctor delete rename export import"
local console="save say playing kick ban motd password time dawn noon dusk midnight settle seed version maxplayers port clear help"
# terractl --host <remote> <command> ...: the same as on this host, except
@@ -121,7 +146,7 @@ _terractl() {
cword=$((cword - 2))
commands+=" --help --version"
else
commands+=" copy move remote daemon prepare run --host --help --version"
commands+=" copy move remote daemon prepare run journal-access --host --help --version"
fi
if (( cword == 1 )); then
@@ -129,9 +154,9 @@ _terractl() {
return
fi
local command=${words[1]}
local command=${words[1]} prev=${words[cword-1]}
case $command in
list|help|-h|--help|version|-V|--version)
list|journal-access|help|-h|--help|version|-V|--version)
return ;;
new)
# new <new name> [vanilla|tmodloader] [port]: the name is new, so nothing to offer
@@ -153,9 +178,37 @@ _terractl() {
return ;;
daemon)
if (( cword == 2 )); then
_terractl_reply "enable disable status trust"
elif (( cword == 3 )) && [[ ${words[2]} == trust ]]; then
_terractl_reply "add list remove"
_terractl_reply "enable disable status token revoke"
fi
return ;;
doctor)
# doctor [<name>] [--fix]
if [[ $cur == -* ]]; then _terractl_reply "--fix"; else _terractl_reply "$(_terractl_any_instances "$host")"; fi
return ;;
fetch)
# fetch vanilla|tmodloader [<version>|latest] [--list] [--upgrade <name>]
if (( cword == 2 )); then
_terractl_reply "vanilla tmodloader"
elif [[ $prev == --upgrade ]]; then
_terractl_reply "$(_terractl_any_instances "$host")"
elif [[ $cur == -* ]]; then
_terractl_reply "--list --upgrade"
else
_terractl_reply "latest"
fi
return ;;
backup)
# backup enable|disable|now|list <name> [--every <when>] [--keep <number>]
if (( cword == 2 )); then
_terractl_reply "enable disable now list"
elif (( cword == 3 )); then
_terractl_reply "$(_terractl_any_instances "$host")"
elif [[ ${words[2]} == enable ]]; then
case $prev in
--every) _terractl_reply "daily weekly hourly monthly" ;;
--keep) ;;
*) _terractl_reply "--every --keep" ;;
esac
fi
return ;;
esac
@@ -175,6 +228,12 @@ _terractl() {
if (( cword == 3 )); then _terractl_reply "$console"; fi ;;
reinstall)
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)
if (( cword == 3 )); then _terractl_reply "--now"; fi ;;
watch)
+9 -3
View File
@@ -1,15 +1,21 @@
# Overrides for terractl. Uncomment to change.
# Changes apply to new commands and to each instance's next start.
# Where instances live. If you change this, the service must also be allowed
# to write there: sudo systemctl edit terraria@.service
# Where instances live. To use another disk, it's simplest to mount it at the
# default location instead. If you change this, the service must also be
# allowed to write there: sudo systemctl edit terraria@.service
# [Service]
# 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
# 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
# Where 'terractl backup' (and 'upgrade', before switching) puts backups
#TERRARIA_BACKUPS=/var/lib/terraria/backups
# Seconds to wait for a server to save and exit on stop (keep below 90)
#TERRARIA_STOP_TIMEOUT=80
+3
View File
@@ -3,6 +3,9 @@ d /var/lib/terraria 0750 terraria terraria -
d /var/lib/terraria/instances 0750 terraria terraria -
# setgid + group-writable: members of the 'terraria' group can upload zips
d /var/lib/terraria/dist 2775 root terraria -
# Backups ('terractl backup', and 'upgrade' before switching): readable by the
# group, like the instances themselves
d /var/lib/terraria/backups 2750 root terraria -
# The servers' journal (LogNamespace=terraria): readable by the terraria group,
# including files journald creates later (default ACL). 'A+' only acts on paths
+337 -57
View File
@@ -52,7 +52,8 @@ Everything is installed by the `terraria-server-manager` package:
| `/usr/bin/terractl` | Helper script. The service calls it to set up, run and stop servers; you use it for everyday management. |
| `/usr/lib/terraria-server-manager/terractl-remote` | The part of `terractl` that manages other hosts (`--host`, `remote`, `daemon`, `copy`, `move`), in Python. |
| `/usr/lib/systemd/system/terractld.service` | The daemon that lets other hosts manage this one. Off until you turn it on (see [Managing other hosts](#managing-other-hosts)). |
| `/etc/default/terractl` | Optional overrides (locations, stop timeout, the daemon's address). |
| `/usr/lib/systemd/system/terraria-backup@.timer`, `terraria-backup@.service` | Scheduled backups of an instance. Off until you turn them on with `terractl backup enable` (see [Backups](#backups)). |
| `/etc/default/terractl` | Optional overrides (locations of instances, zips and backups, stop timeout, the daemon's address). |
| `/etc/systemd/journald@terraria.conf` | Size and retention of the servers' own journal (see [Logs](#logs)). |
| `/usr/lib/sysusers.d/terractl.conf` | Defines the `terraria` system user. |
| `/usr/lib/tmpfiles.d/terractl.conf` | Creates `/var/lib/terraria` and its subdirectories. |
@@ -76,6 +77,7 @@ On stop, `terractl service-stop` sends `exit` through the pipe so the world is s
├── dist/ Server zips (you add these; never modified)
│ ├── terraria-server-1458.zip
│ └── tModLoader-v2026.07.3.0.zip
├── backups/<name>/ Backups from 'terractl backup' and 'upgrade' (group-readable)
└── instances/
├── <name>/ One directory per instance
│ ├── instance.conf Which server type and zip to use
@@ -100,13 +102,51 @@ The server can write only inside `instances/`. It reads `dist/` but can never ch
## Installation
The package works on Debian 12 or newer and Ubuntu 22.04 or newer.
There are packages for Debian 12 or newer and Ubuntu 22.04 or newer (`.deb`), and for Fedora, RHEL 9 or newer and compatible systems such as AlmaLinux and Rocky Linux (`.rpm`). Both install the same files and work the same way.
### Debian and Ubuntu
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
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)).
### Fedora, RHEL and compatible
From the RPM repository, which `sudo dnf upgrade` then keeps up to date:
```bash
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/rpm.repo \
-o /etc/yum.repos.d/terraria-server-manager.repo
sudo dnf install terraria-server-manager
```
The repository is signed; the first time, dnf asks you to accept its key. Alternatively, download the `.rpm` from the releases page and install it directly:
```bash
sudo dnf install ./terraria-server-manager-<version>-1.noarch.rpm
```
dnf installs the "recommended" packages by default too: `curl` and `libicu` for tModLoader, and `polkit`. On RHEL and its rebuilds, ICU and curl are in the base repositories; nothing from EPEL is needed.
The RPM package is newer than the Debian one and has been tested less. With SELinux enforcing (the default on these systems), the servers run as an unconfined service; if SELinux blocks anything, `sudo ausearch -m avc -ts recent` shows what, and a report is welcome.
### What installing does
Installing the package:
@@ -116,19 +156,22 @@ Installing the package:
It doesn't start or enable anything, including the daemon for managing the host from elsewhere.
If you use a firewall, open each instance's port for **TCP**. Terraria uses TCP only.
If you use a firewall, open each instance's port for **TCP**. Terraria uses TCP only. Fedora and RHEL have firewalld on by default, so there this is needed before anyone can connect; `terractl new` reminds you, and `terractl doctor` lists ports firewalld still blocks.
```bash
sudo ufw allow 7777/tcp # example with ufw
sudo firewall-cmd --permanent --add-port=7777/tcp && sudo firewall-cmd --reload # firewalld (Fedora, RHEL)
sudo ufw allow 7777/tcp # ufw (if you use it on Ubuntu)
```
### Upgrading the package
```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
sudo dnf upgrade terraria-server-manager # Fedora, RHEL: from the RPM repository
```
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; rpm keeps yours and saves the new one next to it as `/etc/default/terractl.rpmnew`.
### Removing the package
@@ -137,10 +180,12 @@ sudo apt remove terraria-server-manager # stops all instances (worlds are s
sudo apt purge terraria-server-manager # also removes config and boot-time enablement
```
Neither command deletes `/var/lib/terraria`, the servers' journal or the `terraria` user, so your worlds survive even a purge. To delete everything:
On Fedora and RHEL there's only `sudo dnf remove terraria-server-manager`, since rpm has no separate purge. It sits between the two: it stops all instances (saving their worlds), turns off starting at boot, scheduled backups and the daemon, and removes the drop-ins terractl wrote itself (backup schedules, `doctor --fix`). It keeps drop-ins you added with `systemctl edit`, the daemon's keys in `/var/lib/terractl`, and a changed `/etc/default/terractl` (as `.rpmsave`).
None of these commands deletes `/var/lib/terraria`, the servers' journal or the `terraria` user, so your worlds survive. To delete everything:
```bash
sudo rm -rf /var/lib/terraria && sudo userdel terraria
sudo rm -rf /var/lib/terraria /var/lib/terractl && sudo userdel terraria
```
### Upgrading from the `terraria-instance` package
@@ -175,26 +220,99 @@ From the git repository:
```bash
make check # syntax-check the scripts (and shellcheck, if installed)
make deb # builds build/terraria-server-manager_<version>_all.deb
make rpm # builds build/terraria-server-manager-<version>-1.noarch.rpm
sudo make install # or install directly without a package (PREFIX=/usr/local)
sudo make uninstall # removes a 'make install' (keeps settings and data)
```
`make install` with a prefix other than `/usr` puts the polkit rule in `/etc/polkit-1/rules.d/`, because polkit doesn't read `/usr/local`.
The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/control` to yourself before publishing packages.
`make rpm` needs `rpmbuild`: `sudo dnf install rpm-build` on Fedora and RHEL, or `sudo apt install rpm` on Debian and Ubuntu (the spec uses no Fedora-only macros, so the package is the same wherever it's built).
The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/control` and `Packager:` in `packaging/rpm/terraria-server-manager.spec.in` to yourself before publishing packages.
### Changing locations
`/etc/default/terractl` can move the instances or zips directory. If you move the instances directory, the service also needs permission to write to the new location:
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=/your/new/path
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
sudo systemctl edit terraria@.service
```
```ini
[Service]
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
@@ -242,6 +360,28 @@ The existing `terraria` user keeps `/opt/terraria` as its home directory. That's
## Server zips
Servers are unpacked from the official zips in `/var/lib/terraria/dist`. The easiest way to get them there is `fetch`, which downloads them and names them with their version:
```bash
terractl fetch tmodloader # the latest stable tModLoader
terractl fetch tmodloader 2026.07.3.0 # a specific version, e.g. the one your players have
terractl fetch vanilla # the latest Terraria server
terractl fetch vanilla 1449 # a specific version (1449 or 1.4.4.9)
terractl fetch tmodloader --list # recent tModLoader versions, stable and preview
```
tModLoader comes from its GitHub releases, and its download is checked against the checksum GitHub publishes. Vanilla comes from terraria.org, which only lists its newest version, but older ones can be fetched by number. Every zip is checked (complete, and the right kind) before it's put in `dist/`. Zips already there aren't downloaded again.
`--upgrade <name>` also switches an instance to the zip it just fetched, with `upgrade` (see [Upgrading](#upgrading-or-downgrading-an-instance)):
```bash
sudo terractl fetch tmodloader --upgrade calamity
```
`fetch` needs `curl` and internet access. Members of the `terraria` group can use it without `sudo` (except `--upgrade`). If a site changes its download address and `fetch` stops working, it says so; download the zip by hand as below.
### Adding zips by hand
Put the official downloads in `/var/lib/terraria/dist`:
| Type | File name pattern | Where to get it |
@@ -255,6 +395,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.
**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`:
```bash
@@ -453,7 +595,7 @@ EXTRA_ARGS=() # extra server arguments, as a bash array
| Setting | Effect |
|---|---|
| `TYPE` | Which server software runs. Changing it re-unpacks `server/` from the other kind of zip on the next start. `data/` and `serverconfig.txt` are kept. |
| `ZIP` | Pins a specific zip from `/var/lib/terraria/dist` (a file name, or an absolute path). Changing it re-unpacks `server/` on the next start. If left blank, the newest matching zip is used at first install and **never upgraded automatically**. |
| `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)`. |
This file also marks the directory as an instance: `list` ignores directories without one, and `delete` refuses to remove them.
@@ -548,17 +690,59 @@ Stable tModLoader and the latest vanilla Terraria can be based on different Terr
### Upgrading (or downgrading) an instance
1. Put the new zip in `/var/lib/terraria/dist`, with the version in its name.
2. Set `ZIP=` to that file name in the instance's `instance.conf`.
3. Restart the instance:
Get the new zip with `terractl fetch` (or put it in `/var/lib/terraria/dist` by hand, with the version in its name; see [Server zips](#server-zips)). To see which zips an instance can use and which one it runs:
```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/backups/<name>/<name>-<date>-<time>-before-<old zip>.tar.gz`. Use `--no-backup` to skip this. These backups are never deleted automatically, not even by scheduled backups' `--keep`.
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
terractl backup list calamity
sudo terractl delete calamity -y # keeps its own backup in .deleted/, just in case
sudo terractl import calamity /var/lib/terraria/backups/calamity/calamity-20261002-213553-before-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
@@ -566,14 +750,16 @@ If `server/` gets damaged or you want a clean copy:
```bash
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:
```bash
sudo terractl reinstall calamity --clean
sudo systemctl restart terraria@calamity
sudo terractl restart calamity
```
---
@@ -670,7 +856,7 @@ sudo terractl move calamity nas: # this host → nas
terractl copy nas:calamity pi:test # nas → pi, as 'test'
```
### Turning on the daemon
### Setting it up
On the host to be managed:
@@ -678,29 +864,31 @@ On the host to be managed:
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`, or with firewalld `sudo firewall-cmd --permanent --add-port=7776/tcp && sudo firewall-cmd --reload`), 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.
### 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
sudo terractl daemon trust add laptop
```
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.
`laptop` is a name for the computer, used in the host's log and to revoke it later. The command prints what to run on that computer:
```bash
terractl remote add nas tctl1_eyJ2IjoxLCJmcCI6...
```
`nas` is the name you'll use for the host there; choose any. The token contains the host's addresses, so usually nothing else is needed. If none of them works from the computer (behind NAT, for example), add the address: `terractl remote add nas 203.0.113.5 tctl1_...`, or `nas.example.com:7800` for another port.
A token works **once**, **within 24 hours**. Remotes are kept per user in `~/.config/terractl/remotes.json`, readable only by you. Under `sudo`, the remotes of the person who ran sudo are used.
Remotes are kept per user in `~/.config/terractl/remotes.json`, readable only by you. Under `sudo`, the remotes of the person who ran sudo are used.
```bash
terractl remote list # your remotes
@@ -757,22 +945,45 @@ 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.
- **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`.
- **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.
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. The one exception is `/etc/systemd/system`, where `backup enable` and `doctor --fix` write their drop-ins. 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.
---
## Backups
Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`, which is exactly what `export` saves. A nightly backup kept for 14 days, as a root cron job (`sudo crontab -e`):
Everything worth keeping is in `data/`, `instance.conf` and `serverconfig.txt`, which is exactly what a backup saves. `terractl backup` makes them on a schedule, and keeps a set number:
```cron
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
```bash
sudo terractl backup enable calamity # daily, keeping the newest 14
sudo terractl backup enable calamity --every 04:30 --keep 30 # every day at 4:30, keep 30
sudo terractl backup enable calamity --every weekly --keep 0 # weekly, keep them all
terractl backup list # schedules and backups of all instances
sudo terractl backup now calamity # one now
sudo terractl backup disable calamity # stop (keeps existing backups)
```
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.
`--every` takes `hourly`, `daily`, `weekly`, `monthly`, a time such as `04:30`, or anything else systemd's `OnCalendar=` accepts (`'Sun 03:00'`, `'*-*-* 00/6:00'`; see `man systemd.time`). Running `backup enable` again changes the schedule.
How it works:
- **One systemd timer per instance**, `terraria-backup@<name>.timer`. A backup missed while the machine was off is made soon after it starts again. Backups start up to 10 minutes after the scheduled time, so several instances don't all start at once.
- **The world is saved first** if the server is running, as `export` does, so the backup has the current state. The server keeps running.
- **Backups go in `/var/lib/terraria/backups/<name>/`**, as `<name>-<date>-<time>.tar.gz`. Members of the `terraria` group can read them, as they can the instances themselves (`serverconfig.txt` can contain the server password). Change the folder with `TERRARIA_BACKUPS` in `/etc/default/terractl`.
- **Only scheduled backups are deleted** when there are more than `--keep`. The ones `upgrade` makes before changing version (`...-before-<zip>.tar.gz`) and ones you put there yourself are kept.
- **`rename` takes the schedule along**, and `delete` turns it off. Existing backups stay where they are in both cases.
To restore one, import it under a new name, or replace the instance with it:
```bash
sudo terractl import calamity-restored /var/lib/terraria/backups/calamity/calamity-20261002-040512.tar.gz
```
If a scheduled backup fails, `terractl backup list` and `terractl doctor` say so; the details are in `journalctl -u terraria-backup@<name>`.
Backups on the same disk don't protect against the disk failing. Copy `/var/lib/terraria/backups` elsewhere now and then, or put it on another disk (with `TERRARIA_BACKUPS`, or a mount). 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 +1001,14 @@ 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. |
| `cmd` | root, or members of the `terraria` group | Same as `attach`. |
| `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`) | |
| `fix-perms` | root only | Checked by the script. |
| `fetch` | root, or members of the `terraria` group | Writes to `dist/`, which the group may. `--upgrade` needs root. |
| `backup list` | root, or members of the `terraria` group | |
| `backup enable`, `disable`, `now` | root only | Checked by the script. |
| `doctor` | anyone | Without root, the checks that need it are skipped. `--fix` needs root. |
| `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`. |
| `delete`, `rename` | root only | Checked by the script. |
@@ -798,7 +1016,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`. |
| `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. |
| `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`. |
| `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. |
@@ -820,9 +1038,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:
```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:
```bash
@@ -855,6 +1075,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.
- **`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.
- **`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.
- **`list` shows a copy started by hand** as `running (outside systemd)`.
@@ -864,8 +1085,47 @@ While a server runs, whether systemd started it or you did, it holds a lock on t
## Troubleshooting
### Start with `doctor`
`terractl doctor` checks the whole setup and explains anything that's wrong, with the command that fixes it:
```
$ sudo terractl doctor
terractl 0.0.12 on nas
System
ok user and group 'terraria'
ok programs (including ICU and curl for tModLoader)
ok folders /var/lib/terraria/instances and /var/lib/terraria/dist
ok server zips: 3 in /var/lib/terraria/dist
FAIL /etc/systemd/system/terraria@.service replaces the package's terraria@.service (left over from a manual setup?)
fix: rm /etc/systemd/system/terraria@.service && systemctl daemon-reload
ok the services can use /var/lib/terraria/instances and /var/lib/terraria/dist
ok the 'terraria' group can read the servers' journal
Instances
FAIL calamity: 3 files aren't owned by terraria, so the server can't change them (saving can fail)
fix: terractl fix-perms calamity
ok pvp (vanilla, port 7778, terraria-server-1458.zip)
2 problem(s), 0 warning(s).
terractl doctor --fix fixes 1 of them; the others need the steps shown.
```
It checks:
- **The system:** the `terraria` user and group; `unzip`, and ICU and `curl` if there are tModLoader instances; the instances, zips and backups folders; unreadable zips; the systemd units, including old copies in `/etc/systemd/system` that replace the package's; whether the services may use the folders set in `/etc/default/terractl` (see [Changing locations](#changing-locations)); the servers' journal; polkit; the daemon, if it's on; and servers or backups that are still scheduled for instances that no longer exist.
- **The firewall:** if firewalld is running, the instances' ports (and the daemon's) that it doesn't allow. Opening them is left to you, since it decides who can connect.
- **Each instance:** files the server can't change, or that others can read; its server zip (missing, wrong type, or a `ZIP=` changed by hand that hasn't been applied yet); paths in `serverconfig.txt` that point somewhere else (e.g. after copying it by hand); enabled mods that aren't installed; instances sharing a port; and failed starts or backups.
`--fix` fixes what's safe to fix by itself, and says what it did: file ownership and modes (`fix-perms`), folder and zip permissions, the servers' journal access, the drop-in for moved folders, and boot links or backup schedules for instances that are gone. Everything else, such as deleting unit files or opening up home folders, is left for you, with the steps shown. `terractl doctor <name>` checks only that instance (and the system).
Without `sudo`, `doctor` skips the checks that need root and says so. It exits with status 1 if it finds a problem, so it can be used in scripts and monitoring.
### Common problems
**tModLoader stops with `Aborted` right after "Launched Using Local Dotnet".**
The ICU library is missing, so .NET aborts at startup. Fix it with `sudo apt install libicu-dev`, then restart the instance. To confirm, check that `ldconfig -p | grep -c libicu` prints more than 0.
The ICU library is missing, so .NET aborts at startup. Fix it with `sudo apt install libicu-dev` (Fedora, RHEL: `sudo dnf install libicu`), then restart the instance. To confirm, check that `ldconfig -p | grep -c libicu` prints more than 0.
**`ls: cannot access '.../dotnet/shared/Microsoft.NETCore.App'` on the first start.**
Harmless. tModLoader looks for old .NET versions to clean up before any have been installed.
@@ -880,8 +1140,11 @@ sudo cp -a /var/lib/terraria/instances/other/server/dotnet /var/lib/terraria/ins
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.**
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.**
The player's tModLoader version differs from the server's. See [Versions and upgrades](#versions-and-upgrades).
@@ -918,7 +1181,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.
**`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,12 +1193,14 @@ Its access was revoked with `daemon trust remove`, or that computer was added ag
- instance names;
- `vanilla`/`tmodloader` for `new`;
- 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);
- `vanilla`/`tmodloader` and the options for `fetch`, `backup` actions and schedules (`--every daily` etc.), and `--fix` for `doctor`;
- 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`;
- `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.
It works with `sudo terractl` too. It needs the `bash-completion` package, which most Debian and Ubuntu systems already have (`sudo apt install bash-completion`). It takes effect in new terminal sessions.
It works with `sudo terractl` too. It needs the `bash-completion` package, which most systems already have (`sudo apt install bash-completion`, or `sudo dnf install bash-completion`). It takes effect in new terminal sessions.
Completion runs as *you*, even when you type `sudo`. Instance and mod names therefore only complete if you can read `/var/lib/terraria`, which means being root or a member of the `terraria` group (see [Permissions](#permissions)). Otherwise, it falls back to the names of servers systemd currently knows about, and mod names don't complete.
@@ -953,8 +1218,21 @@ terractl disable <name> [--now] Don't start at boot (--now: an
terractl attach <name> Interactive console (Ctrl-D detaches)
terractl watch <name> [-n <lines>] [-q] Follow the output, read-only (Ctrl-C quits)
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)
terractl fix-perms <name> Reset the owner and modes of all its files
terractl fetch vanilla|tmodloader [<version>] [--upgrade <name>]
Download a server zip (default: the latest)
terractl fetch vanilla|tmodloader --list Versions available to download
terractl backup enable <name> [--every <when>] [--keep <n>]
Back up on a schedule (default: daily, keep 14)
terractl backup disable <name> Stop scheduled backups (keeps existing ones)
terractl backup now <name> Back up now
terractl backup list [<name>] Schedules and backups
terractl doctor [<name>] [--fix] Check the setup; --fix fixes what it safely can
terractl mods <name> [list] Show installed mods
terractl mods <name> add <folder|file.tmod>... Install and enable mods (and ModConfigs)
terractl mods <name> enable|disable <Mod>... Change which mods load
@@ -972,17 +1250,19 @@ terractl copy [<remote>:]<name> [<remote>:][<new name>] [--with-server]
Copy an instance, here or between hosts
terractl move [<remote>:]<name> [<remote>:][<new name>] [--with-server]
Move or rename an instance
terractl remote add <name> <token> Add a remote (token from 'daemon trust add')
terractl remote add <name> <address> [<token>] ... at an address the token doesn't list
terractl remote add [<name>] <token> [--address <host>[:<port>]]
Add (or replace) a remote
terractl remote list | remove <name> Show or forget remotes
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 trust list Show trusted computers and unused tokens
terractl daemon token A one-time token for another computer
terractl daemon revoke <name> Take a computer's access away
Used by the service:
terractl prepare <name> Create dirs/configs, unpack zip if needed
terractl run <name> Run the server
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)
```
+107 -101
View File
@@ -14,7 +14,7 @@
# the instance to the destination's folders and free ports.
#
# 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
# 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
@@ -27,7 +27,6 @@ import base64
import codecs
import errno
import fcntl
import getpass
import hashlib
import hmac
import http.client
@@ -212,7 +211,7 @@ def open_state(cmdline):
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):
self.fd = os.open(state_path(".lock"), os.O_RDWR | os.O_CREAT, 0o600)
@@ -239,7 +238,8 @@ def ensure_cert():
"-keyout", new_key, "-out", new_crt],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
except OSError:
die("openssl is not installed (sudo apt install openssl)")
manager = "dnf" if shutil.which("dnf") and not shutil.which("apt-get") else "apt"
die(f"openssl is not installed (sudo {manager} install openssl)")
finally:
os.umask(old_umask)
if proc.returncode != 0:
@@ -326,7 +326,7 @@ def host_addresses(listen_host, port):
# --------------------------------------------------------------- daemon ----
EXEC_COMMANDS = {"list", "info", "new", "start", "stop", "restart", "enable", "disable", "cmd", "watch",
"mods", "reinstall", "delete", "rename", "export", "import",
"mods", "reinstall", "upgrade", "fix-perms", "doctor", "backup", "fetch", "delete", "rename", "export", "import",
"-V", "--version", "version", "-h", "--help", "help"}
@@ -427,6 +427,15 @@ def extract_upload(tar_path, dest):
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):
protocol_version = "HTTP/1.1"
server_version = "terractld"
@@ -579,7 +588,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
# ---- endpoints
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):
raise HttpError(400, "bad request")
digest = sha256(secret)
@@ -593,11 +603,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
save_trust(trust)
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 "
"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)
key = secrets.token_urlsafe(32)
name = token["name"]
trust["clients"] = [c for c in trust["clients"] if c.get("name") != name]
# A computer that's trusted already (it sends its old key) keeps its
# 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),
"added": time.strftime("%Y-%m-%d %H:%M"),
"from": self.client_address[0]})
@@ -969,11 +988,12 @@ def daemon_enable():
if not wait_listening(host, port, 10):
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"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()
if load_trust()["clients"]:
print("To add another computer: sudo terractl daemon token")
else:
print_token()
return 0
@@ -983,7 +1003,7 @@ def daemon_disable():
if proc.returncode != 0:
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("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
@@ -1001,76 +1021,54 @@ def daemon_status():
except PermissionError:
print("trusted: (run with sudo to see which computers)")
trust = None
if trust is not None:
names = ", ".join(c["name"] for c in trust["clients"]) or "none yet"
print(f"trusted: {names}")
waiting = [t["name"] for t in trust["tokens"] if t["expires"] > time.time()]
if waiting:
print(f"tokens for: {', '.join(waiting)} (not used yet)")
if trust is not None and not trust["clients"]:
print("trusted: none yet (add one: sudo terractl daemon token)")
elif trust is not None:
print()
print(f"{'TRUSTED':<24} {'ADDED':<17} FROM")
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":
print("Start it with: sudo terractl daemon enable")
return 0
def trust_add(name):
if not CLIENT_RE.fullmatch(name):
die(f"invalid name '{name}' (letters, digits and _ . - @, up to 64)")
open_state(f"daemon trust add {name}")
def print_token():
"""Creates a one-time token and prints the command that uses it."""
open_state("daemon token")
ensure_cert()
secret = secrets.token_hex(32)
with StateLock():
trust = load_trust()
now = time.time()
trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now and t["name"] != name]
trust["tokens"].append({"name": name, "secret_sha256": sha256(secret),
"expires": int(now + TOKEN_HOURS * 3600)})
trust["tokens"] = [t for t in trust["tokens"] if t["expires"] > now]
trust["tokens"].append({"secret_sha256": sha256(secret), "expires": int(now + TOKEN_HOURS * 3600)})
save_trust(trust)
known = any(c["name"] == name for c in trust["clients"])
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]
if not NAME_RE.fullmatch(here):
here = "<name>"
print(f"On '{name}', run this to add this host as a remote called '{here}':")
if NAME_RE.fullmatch(here):
fields["n"] = here # the remote's name, unless the other computer picks one
print(f"To manage this host from another computer, run this there (works once, within {TOKEN_HOURS} hours):")
print()
print(f" terractl remote add {here} {token}")
print(f" terractl remote add {make_token(fields)}")
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":
print("The daemon isn't running yet; start it with: sudo terractl daemon enable")
return 0
def trust_list():
try:
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}")
def daemon_revoke(name):
open_state(f"daemon revoke {name}")
with StateLock():
trust = load_trust()
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"]) and len(tokens) == len(trust["tokens"]):
die(f"'{name}' isn't trusted (see: terractl daemon trust list)")
trust["clients"], trust["tokens"] = clients, tokens
if len(clients) == len(trust["clients"]):
die(f"no computer called '{name}' is trusted (see: sudo terractl daemon status)")
trust["clients"] = clients
save_trust(trust)
print(f"'{name}' can't manage this host any more.")
return 0
@@ -1078,20 +1076,13 @@ def trust_remove(name):
def daemon_main(argv):
sub, rest = (argv[0], argv[1:]) if argv else ("", [])
if sub == "trust":
what = rest[0] if rest else "list"
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>")
if sub == "revoke" and len(rest) == 1:
return daemon_revoke(rest[0])
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:
return commands[sub]()
die("usage: terractl daemon enable | disable | status | trust ...")
die("usage: terractl daemon enable | disable | status | token | revoke <name>")
# --------------------------------------------------------------- client ----
@@ -1244,9 +1235,8 @@ class Remote:
except (ValueError, KeyError, TypeError):
message = f"HTTP error {resp.status}"
if resp.status == 401:
die(f"{self.label} doesn't trust this computer (any more). On {self.label}, run "
f"'sudo terractl daemon trust add <name for this computer>', then here: terractl "
f"remote remove {self.name} && terractl remote add {self.name} {self.addr} <token>")
die(f"{self.label} doesn't trust this computer (any more). Run 'sudo terractl daemon "
f"token' there, then here: terractl remote add <token>")
die(f"{self.label}: {message}")
return resp
@@ -1384,40 +1374,50 @@ def find_remote(spec):
if host and conf["address"].lower() == host.lower() and int(conf["port"]) == port:
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 "
f"'sudo terractl daemon trust add <name for this computer>' there, then here: "
f"terractl remote add <name> <token>")
f"'sudo terractl daemon token' there, then here: terractl remote add <token>")
def remote_add(args):
if len(args) not in (2, 3):
die("usage: terractl remote add <name> <token>, or: terractl remote add <name> <address> [<token>]")
name = args[0]
usage = "usage: terractl remote add [<name>] <token> [--address <host>[:<port>]]"
address, rest = None, []
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):
die(f"invalid remote name '{name}' (letters, digits and _ . -, not starting with . or -)")
remotes = load_remotes()
if name in remotes:
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)
if name in remotes and name != old:
die(f"'{name}' is already the name of another remote; choose another: terractl remote add <name> <token>")
candidates = [split_hostport(address)] if address else [split_hostport(a) for a in fields.get("a", [])]
if not candidates:
die("the token doesn't say where the host is; give its address: "
"terractl remote add <name> <address> <token>")
die("the token doesn't say where the host is; add --address <host>")
join = {"secret": fields["s"], "name": (socket.gethostname() or "").split(".")[0]}
if old:
join["old_key"] = remotes[old].get("key", "")
problems = []
for host, port in candidates:
remote = Remote(name, {"address": host, "port": port, "fingerprint": fields["fp"]})
remote.joining = True
remote.label = remote.addr
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
except Unreachable as e:
problems.append(str(e))
@@ -1425,7 +1425,9 @@ def remote_add(args):
if len(problems) == 1:
die(problems[0])
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"],
"client_name": reply["name"], "added": time.strftime("%Y-%m-%d %H:%M")}
save_remotes(remotes)
@@ -1437,7 +1439,7 @@ def remote_add(args):
about = f"{info['hostname']} at {remote.addr}, terractl {info['version']}"
except Fail:
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")
return 0
@@ -1447,7 +1449,7 @@ def remote_list(args):
die("usage: terractl remote list")
remotes = load_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
print(f"{'NAME':<20} {'ADDRESS':<30} FINGERPRINT")
for name, conf in sorted(remotes.items()):
@@ -1463,8 +1465,8 @@ def remote_remove(args):
if conf is None:
die(f"there's no remote called '{args[0]}' (see: terractl remote list)")
save_remotes(remotes)
print(f"removed remote '{args[0]}'. It still lists this computer as '{conf.get('client_name', '?')}'; "
f"to revoke that too, run there: sudo terractl daemon trust remove {conf.get('client_name', '<name>')}")
print(f"removed remote '{args[0]}'. It still trusts this computer; to revoke that too, run there: "
f"sudo terractl daemon revoke {conf.get('client_name', '<name>')}")
return 0
@@ -1940,6 +1942,10 @@ if __name__ == "__main__":
except Fail as e:
print(f"terractl: {e}", file=sys.stderr)
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:
where = f": {e.filename}" if e.filename else ""
print(f"terractl: {e.strerror or e}{where}", file=sys.stderr)
+1 -1
View File
@@ -4,7 +4,7 @@ Section: games
Priority: optional
Architecture: all
Installed-Size: @INSTALLED_SIZE@
Maintainer: Your Name <you@example.com>
Maintainer: Adam Brown <adam@adbraun.lol>
Depends: unzip, systemd, python3, openssl
Recommends: curl, libicu76 | libicu74 | libicu72 | libicu70 | libicu-dev, polkitd
Conflicts: terraria-instance
+3
View File
@@ -6,6 +6,9 @@ case "$1" in
# Forget which instances were enabled at boot
rm -f /etc/systemd/system/multi-user.target.wants/terraria@*.service
rm -rf /etc/systemd/system/terraria@.service.d
# Scheduled backups (the backups themselves stay in /var/lib/terraria/backups)
rm -f /etc/systemd/system/timers.target.wants/terraria-backup@*.timer
rm -rf /etc/systemd/system/terraria-backup@*.timer.d /etc/systemd/system/terraria-backup@*.service.d
# The daemon: whether it's on, its certificate and the computers it trusts
rm -f /etc/systemd/system/multi-user.target.wants/terractld.service
rm -rf /etc/systemd/system/terractld.service.d /var/lib/terractl
+1
View File
@@ -8,6 +8,7 @@ case "$1" in
remove|deconfigure)
if [ -d /run/systemd/system ]; then
systemctl stop terractld.service || true
systemctl stop 'terraria-backup@*.timer' 'terraria-backup@*.service' || true
units=$(systemctl list-units --plain --no-legend --all 'terraria@*.service' | awk '{print $1}')
if [ -n "$units" ]; then
echo "Stopping Terraria instances: $units"
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# Creates or updates the Gitea release for a tag and attaches files to it. Its
# notes are that version's section of CHANGELOG.md. Run again for the same tag,
# it updates the notes and replaces files of the same name.
#
# gitea-release.py <tag> <file>...
#
# Needs GITEA_URL (https://gitea.example.com), GITEA_REPO (owner/name) and
# GITEA_TOKEN. Used by .gitea/workflows/release.yaml.
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
def api(method, path, body=None, content_type="application/json"):
url = f"{os.environ['GITEA_URL'].rstrip('/')}/api/v1/repos/{os.environ['GITEA_REPO']}{path}"
if isinstance(body, dict):
body = json.dumps(body).encode()
headers = {"Authorization": f"token {os.environ['GITEA_TOKEN']}", "Accept": "application/json"}
if body is not None:
headers["Content-Type"] = content_type
request = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=300) as response:
data = response.read()
return json.loads(data) if data else None
except urllib.error.HTTPError as e:
if e.code == 404 and method == "GET":
return None
sys.exit(f"{method} {url}: HTTP {e.code}: {e.read().decode(errors='replace')}")
def changelog_section(version):
"""The lines under '## <version>' in CHANGELOG.md, up to the next '## '."""
lines, found = [], False
with open("CHANGELOG.md") as f:
for line in f:
if line.startswith("## "):
if found:
break
found = line[3:].split()[0] == version
continue
if found:
lines.append(line)
if not found:
sys.exit(f"CHANGELOG.md has no '## {version}' section")
return "".join(lines).strip() + "\n"
def upload(release_id, path):
name = os.path.basename(path)
boundary = uuid.uuid4().hex
with open(path, "rb") as f:
content = f.read()
body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n").encode() + content + f"\r\n--{boundary}--\r\n".encode()
api("POST", f"/releases/{release_id}/assets?name={urllib.parse.quote(name)}", body,
f"multipart/form-data; boundary={boundary}")
print(f"attached {name}")
def main(tag, files):
notes = changelog_section(tag[1:] if tag.startswith("v") else tag)
release = api("GET", f"/releases/tags/{urllib.parse.quote(tag)}")
if release is None:
release = api("POST", "/releases", {"tag_name": tag, "name": tag, "body": notes})
print(f"created release {tag}")
else:
release = api("PATCH", f"/releases/{release['id']}", {"body": notes})
print(f"updated release {tag}")
names = {os.path.basename(p) for p in files}
for asset in release.get("assets") or []:
if asset["name"] in names:
api("DELETE", f"/releases/{release['id']}/assets/{asset['id']}")
for path in files:
upload(release["id"], path)
print(release.get("html_url", ""))
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit("usage: gitea-release.py <tag> <file>...")
for var in ("GITEA_URL", "GITEA_REPO", "GITEA_TOKEN"):
if not os.environ.get(var):
sys.exit(f"{var} is not set")
main(sys.argv[1], sys.argv[2:])
@@ -0,0 +1,126 @@
# RPM package for Fedora, RHEL and similar. Built by 'make rpm', which fills in
# @VERSION@ and makes the source tarball. The scriptlets use plain systemctl,
# systemd-sysusers and systemd-tmpfiles rather than Fedora's macros, so the
# package can be built anywhere rpmbuild runs (the release workflow builds it on
# Ubuntu). They do what packaging/deb/ does for Debian; see the comments there.
# The license goes in /usr/share/licenses/<name>, as on Fedora, wherever it's built
%global _docdir_fmt %{name}
Name: terraria-server-manager
Version: @VERSION@
Release: 1
Summary: Run multiple Terraria and tModLoader dedicated servers with systemd
License: MIT
URL: https://gitea.adbraun.lol/adam/terraria-server-manager
Packager: Adam Brown <adam@adbraun.lol>
Source0: %{name}-%{version}.tar.gz
BuildArch: noarch
BuildRequires: make
Requires: bash
Requires: coreutils
Requires: util-linux
Requires: unzip
Requires: systemd
Requires: python3
Requires: openssl
# Only needed for tModLoader instances (ICU: they stop with 'Aborted' without it)
Recommends: curl
Recommends: libicu
# Lets the terraria group start, stop and restart servers without sudo
Recommends: polkit
Suggests: bash-completion
%description
Provides the terraria@.service template and the terractl command for running
any number of vanilla Terraria and tModLoader dedicated servers, each as its
own systemd service under an unprivileged terraria user.
Server software is not included. 'terractl fetch' downloads the official server
zips into /var/lib/terraria/dist, and they are unpacked into each instance on
demand.
Hosts can also be managed from other hosts (terractl --host, copy, move)
through the optional terractld daemon.
%prep
%setup -q
%build
# Nothing to build: shell and Python scripts
%install
make install DESTDIR=%{buildroot} PREFIX=/usr SYSCONFDIR=/etc
%post
systemd-sysusers terractl.conf >/dev/null 2>&1 || :
systemd-tmpfiles --create terractl.conf >/dev/null 2>&1 || :
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || :
# On upgrades the daemon (if it's on) runs the new version right away. It
# only serves other hosts; the game servers keep running.
if [ "$1" -ge 2 ]; then
systemctl try-restart terractld.service >/dev/null 2>&1 || :
fi
fi
# Let the terraria group read the servers' journal (LogNamespace=terraria)
/usr/bin/terractl journal-access >/dev/null 2>&1 || :
%preun
# On removal (not upgrade), stop everything while terractl still exists, so each
# server is sent "exit" and saves its world, and take back what was enabled:
# rpm, unlike dpkg, has no separate purge.
if [ "$1" -eq 0 ] && [ -d /run/systemd/system ]; then
systemctl disable --now terractld.service >/dev/null 2>&1 || :
for f in /etc/systemd/system/*.wants/terraria-backup@*.timer \
/etc/systemd/system/*.wants/terraria@*.service; do
[ -L "$f" ] && systemctl disable "${f##*/}" >/dev/null 2>&1 || :
done
systemctl stop 'terraria-backup@*.timer' 'terraria-backup@*.service' >/dev/null 2>&1 || :
units=$(systemctl list-units --plain --no-legend --all 'terraria@*.service' | awk '{print $1}')
if [ -n "$units" ]; then
echo "Stopping Terraria instances: $units"
# Unquoted on purpose: each unit name must be a separate argument
# shellcheck disable=SC2086
systemctl stop $units || :
fi
fi
%postun
if [ "$1" -eq 0 ]; then
# Drop-ins terractl wrote itself (backup schedules, 'doctor --fix'). Ones
# you added with 'systemctl edit' are kept.
rm -f /etc/systemd/system/terraria-backup@*.timer.d/terractl.conf \
/etc/systemd/system/terraria-backup@*.service.d/terractl.conf \
/etc/systemd/system/*.d/terractl-locations.conf
rmdir /etc/systemd/system/terraria-backup@*.d /etc/systemd/system/terraria@.service.d \
/etc/systemd/system/terractld.service.d 2>/dev/null || :
cat >&2 <<MSG
terraria-server-manager removed. Worlds, instances and backups in /var/lib/terraria,
the daemon's keys in /var/lib/terractl and the 'terraria' user were kept. Remove
them manually if they are no longer needed:
sudo rm -rf /var/lib/terraria /var/lib/terractl && sudo userdel terraria
MSG
fi
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || :
fi
%files
%license LICENSE
/usr/bin/terractl
/usr/lib/terraria-server-manager/
/usr/lib/systemd/system/terraria@.service
/usr/lib/systemd/system/terraria@.socket
/usr/lib/systemd/system/terractld.service
/usr/lib/systemd/system/terraria-backup@.service
/usr/lib/systemd/system/terraria-backup@.timer
/usr/lib/sysusers.d/terractl.conf
/usr/lib/tmpfiles.d/terractl.conf
/usr/share/polkit-1/rules.d/50-terractl.rules
/usr/share/bash-completion/completions/terractl
/usr/share/terraria-server-manager/
%doc /usr/share/doc/terraria-server-manager/
%config(noreplace) /etc/default/terractl
%config(noreplace) /etc/systemd/journald@terraria.conf
+3 -1
View File
@@ -8,7 +8,7 @@ Wants=network-online.target
Type=simple
# Listens on TERRACTL_LISTEN from /etc/default/terractl (default: all addresses,
# 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
Restart=on-failure
RestartSec=5
@@ -19,6 +19,8 @@ StateDirectoryMode=0755
# computers, like sudo terractl does. Everything else is locked down.
NoNewPrivileges=true
ProtectSystem=full
# 'backup enable' and 'doctor --fix' write drop-ins for the Terraria units here
ReadWritePaths=/etc/systemd/system
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Back up Terraria server (%i)
Documentation=file://@DOCDIR@/README.md
[Service]
Type=oneshot
# Saves the world first if the server is running, then archives the instance
# (without server/) into /var/lib/terraria/backups/%i/ and removes the oldest
# scheduled backups beyond the number to keep. Set up with:
# terractl backup enable %i [--every <when>] [--keep <number>]
ExecStart=@BINDIR@/terractl backup now %i
Nice=10
IOSchedulingClass=idle
# Runs as root, like 'sudo terractl backup now'
NoNewPrivileges=true
ProtectSystem=full
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Scheduled backups of Terraria server (%i)
Documentation=file://@DOCDIR@/README.md
[Timer]
# 'terractl backup enable %i --every <when>' sets the schedule in a drop-in
OnCalendar=daily
# Catch up on a backup missed while the machine was off
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target