From 7adf5c2999488dd3b01444861bc82d3292d60322 Mon Sep 17 00:00:00 2001 From: adam Date: Fri, 2 Oct 2026 21:51:29 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 3 ++ bin/terractl | 109 +++++++++++++++++++++++++++++++++++++- completions/bash/terractl | 4 +- config/default/terractl | 9 ++-- docs/guide.md | 79 ++++++++++++++++++++++++++- 5 files changed, 195 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb4719..fd64662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - Going to an older version needs `--downgrade`, because worlds saved by a newer version can't be opened by an older one. - Bash completion offers the instance's zips. Works with `--host`. - `reinstall` now always unpacks the **same** version again. Before, an instance with a blank `ZIP=` picked the newest zip in `dist/`, so `reinstall` could silently upgrade it. It now pins the zip the instance runs first, and refuses if that zip is gone from `dist/`. It also keeps the record of the installed version (`server/.installed`) until the new unpack is done. +- `list`, `new`, `import` and `enable` warn when `TERRARIA_BASE` or `TERRARIA_DIST` is somewhere the services' sandbox doesn't allow (the instances folder not in `ReadWritePaths=`, or either folder under `/home`), and print the exact drop-in to add. The daemon (`terractld.service`) is checked too when it's on. A server that can't create its folder now says why in its log, instead of only "Read-only file system". +- The guide's "Changing locations" now covers bind mounts (the simplest way to use another disk) as well as drop-ins, and a new section explains keeping instances or zips under `/home`. +- `journal-access` is offered by tab completion and listed in the guide's command reference. - `terractl` run by a relative path (e.g. `sudo ./bin/terractl prepare ` from a source tree) no longer fails to switch to the `terraria` user. - New `fix-perms` command: `sudo terractl fix-perms ` 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 `) instead of just "cannot read", and a missing zip is reported as not found. diff --git a/bin/terractl b/bin/terractl index b5c4109..41907c3 100755 --- a/bin/terractl +++ b/bin/terractl @@ -311,8 +311,11 @@ instance_running() { } cmd_prepare() { - local name=$1 dir; dir=$(inst_dir "$name") - mkdir -p "$dir/data/Worlds" + local name=$1 dir err; dir=$(inst_dir "$name") + if ! err=$(mkdir -p "$dir/data/Worlds" 2>&1); then + location_warnings + die "can't create $dir/data/Worlds: ${err##*: }" + fi take_lock "$name" # never re-unpack server/ under a running server [[ -f $dir/instance.conf ]] || write_instance_conf "$name" vanilla load_conf "$name" @@ -833,6 +836,7 @@ cmd_import() { echo "imported '$name' into $dir" fi echo "Review $dir/serverconfig.txt, then: $(hint root enable "$name" --now)" + location_warnings } # ---------------------------------------------------------------- mods ---- @@ -1017,6 +1021,7 @@ EOF $(hint root enable "$name" --now) $(hint group attach "$name") EOF + location_warnings } # Whether the server starts at boot. Needs root: systemd doesn't let polkit @@ -1035,6 +1040,7 @@ cmd_enable() { # Enabling a name without an instance would create a default one at boot [[ -f $(inst_dir "$name")/instance.conf ]] || die "no instance '$name' (create it with: $(hint root new "$name"))" + location_warnings systemctl enable -q "$unit" if [[ $now -eq 1 ]]; then systemctl start "$unit" @@ -1088,6 +1094,104 @@ zip_warning() { log "fix it with$where: sudo chmod 644 ${bad[*]@Q}" } +# ------------------------------------------------------------ locations ---- +# The services' sandboxes (ProtectSystem=, ProtectHome=, ReadWritePaths=) are +# set in their unit files, which can't follow /etc/default/terractl. If +# TERRARIA_BASE or TERRARIA_DIST is moved somewhere a unit can't reach, it needs +# a drop-in; these checks say so, with the drop-in to add. + +# Is , or inside it? +path_in() { [[ $1 == "$2" || $1 == "$2"/* ]]; } + +# Why can't use (need: rw or ro): 'home' (ProtectHome hides it), +# 'home-ro' (ProtectHome=read-only, not writable) or 'system' (ProtectSystem +# makes it read-only). Prints nothing if the unit can use it, or if systemd or +# the unit isn't there to ask. +unit_path_problem() { + local unit=$1 path=$2 need=$3 key value load="" ps=no ph=no p covered=0 + local -a rw=() + command -v systemctl >/dev/null || return 0 + while IFS='=' read -r key value; do + case $key in + LoadState) load=$value ;; + ProtectSystem) ps=$value ;; + ProtectHome) ph=$value ;; + ReadWritePaths) read -ra rw <<< "$value" ;; + esac + done < <(systemctl show -p LoadState,ProtectSystem,ProtectHome,ReadWritePaths "$unit" 2>/dev/null) + [[ $load == loaded ]] || return 0 + path=$(readlink -m "$path") # where it really is, as systemd sees it + for p in "${rw[@]}"; do + p=${p#-} # '-': ignored if missing + if path_in "$path" "$(readlink -m "$p")"; then covered=1; fi + done + if path_in "$path" /home || path_in "$path" /root || path_in "$path" /run/user; then + case $ph in + no) ;; + read-only) if [[ $need == rw && $covered -eq 0 ]]; then echo home-ro; fi ;; + *) echo home ;; + esac + return 0 + fi + [[ $need == rw && $covered -eq 0 ]] || return 0 + case $ps in + strict) echo system ;; + yes|full) + if path_in "$path" /usr || path_in "$path" /boot || path_in "$path" /efi || + { [[ $ps == full ]] && path_in "$path" /etc; }; then + echo system + fi ;; + esac +} + +# Warns about one unit: location_warning +# +location_warning() { + local unit=$1 edit=$2 effect=$3 dist_need=$4 what path need problem home=0 where="" + local -a lines=() rw=() + for what in TERRARIA_BASE TERRARIA_DIST; do + if [[ $what == TERRARIA_BASE ]]; then path=$BASE_DIR need=rw; else path=$DIST_DIR need=$dist_need; fi + problem=$(unit_path_problem "$unit" "$path" "$need") + case $problem in + home) + lines+=("$edit hides $path ($what) under ProtectHome=, so $effect") + home=1 + if [[ $need == rw ]]; then rw+=("$path"); fi ;; + home-ro|system) + lines+=("$edit can't write to $path ($what), so $effect") + if [[ $problem == home-ro ]]; then home=1; fi + rw+=("$path") ;; + esac + done + [[ ${#lines[@]} -gt 0 ]] || return 0 + if [[ -n ${TERRACTL_VIA:-} ]]; then where=" (on $(hostname 2>/dev/null || echo that host))"; fi + printf 'terractl: warning: %s\n' "${lines[@]}" >&2 + { + echo " Fix it with a drop-in$where: sudo systemctl edit $edit" + echo " [Service]" + if [[ $home -eq 1 ]]; then echo " ProtectHome=read-only"; fi + for path in "${rw[@]}"; do echo " ReadWritePaths=$path"; done + if [[ $home -eq 1 ]]; then + echo " The '$SVC_USER' user also needs access to every folder above it; see" + echo " \"Instances or zips under /home\" in the guide." + else + echo " See \"Changing locations\" in the guide." + fi + } >&2 +} + +# The servers always; the daemon only if it's in use. The daemon writes zips +# into dist/ when instances are copied to this host, so it needs both writable. +location_warnings() { + location_warning terraria@terractl-check.service terraria@.service \ + "servers can't start" ro + if systemctl is-enabled --quiet terractld.service 2>/dev/null || + systemctl is-active --quiet terractld.service 2>/dev/null; then + location_warning terractld.service terractld.service \ + "other hosts can't copy, move or import instances here" rw + fi +} + cmd_list() { local d n port boot state printf '%-20s %-11s %-6s %-9s %s\n' INSTANCE TYPE PORT BOOT STATE @@ -1105,6 +1209,7 @@ cmd_list() { ) 2>/dev/null || printf '%-20s %s\n' "$n" "(cannot read config - try sudo)" done zip_warning + location_warnings } # ------------------------------------------------------------ versions ---- diff --git a/completions/bash/terractl b/completions/bash/terractl index 6b31827..e053cb1 100644 --- a/completions/bash/terractl +++ b/completions/bash/terractl @@ -141,7 +141,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 @@ -151,7 +151,7 @@ _terractl() { local command=${words[1]} case $command in - list|help|-h|--help|version|-V|--version) + list|journal-access|help|-h|--help|version|-V|--version) return ;; new) # new [vanilla|tmodloader] [port]: the name is new, so nothing to offer diff --git a/config/default/terractl b/config/default/terractl index ebcd0f5..c8f7901 100644 --- a/config/default/terractl +++ b/config/default/terractl @@ -1,13 +1,16 @@ # 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 # Seconds to wait for a server to save and exit on stop (keep below 90) diff --git a/docs/guide.md b/docs/guide.md index b7b273e..547a2ed 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -199,16 +199,86 @@ The version comes from the `VERSION` file. Set `Maintainer:` in `packaging/deb/c ### 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 `. 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 @@ -948,6 +1018,9 @@ sudo cp -a /var/lib/terraria/instances/other/server/dotnet /var/lib/terraria/ins sudo systemctl start terraria@ ``` +**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, or have the wrong modes. Run `sudo terractl fix-perms `. @@ -1056,6 +1129,8 @@ Used by the service: terractl prepare Create dirs/configs, unpack zip if needed terractl run Run the server terractl service-stop 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) ```