diff --git a/.env.example b/.env.example index 70b4db6..cb4fc2d 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,15 @@ FILES_DATA_DIR=/srv/ulicraft-files # value is substituted straight into the volume string. FILES_MOUNT_MODE=rw +# Runtime uid:gid of the filestash process (docker `user:`). Leave at the image +# default 1000:1000 UNLESS you want files it writes into the share owned by a +# host account. To own the share as `oier` on oxps: set both to 1002 and run +# tooling/filestash-set-run-user.sh (it chowns the state volume + share to match +# — a bare override without the chown makes filestash fail to write its sqlite). +# The compose keeps group_add 1000 so any uid can still exec the image binary. +FILES_UID=1000 +FILES_GID=1000 + # Auth middleware (rendered into config.json by render-config.sh): # htpasswd one shared user/pass — the only internet-safe value # passthrough NO LOGIN. Anyone reaching the vhost is in. diff --git a/docker-compose.yml b/docker-compose.yml index e35a79a..2b1d5ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -257,6 +257,18 @@ services: image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1 container_name: filestash restart: unless-stopped + # Runtime uid/gid of the filestash process. Default 1000:1000 = the image's + # own `filestash` user (cochi runs this — files in the share end up 1000). + # A host can override so share files are owned by a host account instead + # (oxps: FILES_UID/FILES_GID=1002 → files owned by `oier`). When overriding + # to a non-1000 uid you MUST also chown the filestash_state volume + the + # FILES_DATA_DIR share to that uid — see plan/20-files.md. group_add keeps + # the image binary (/app/filestash is 0730, group filestash) executable and + # /app/data (0770) traversable no matter which uid runs; on cochi it is the + # process's own gid, harmlessly redundant. + user: "${FILES_UID:-1000}:${FILES_GID:-1000}" + group_add: + - "1000" environment: # The `local` storage backend is admin-gated in filestash: it refuses to # init unless the connection params carry this secret (or the admin diff --git a/plan/20-files.md b/plan/20-files.md index 3dc2e29..f245fe9 100644 --- a/plan/20-files.md +++ b/plan/20-files.md @@ -194,6 +194,57 @@ The SPA sends it; anything else (curl, a naive monitor) trips intrusion detection and gets a 403 that looks like an auth bug. Only matters when probing by hand — the Kuma monitor hits `/`, which is fine. +### Share dir must be owned by uid 1000 or writes silently 403 +`filestash` runs as **uid/gid 1000**. `FILES_MOUNT_MODE=rw` and `FILES_AUTH=none` +(or a valid login) are **not enough** — the kernel still enforces the share +directory's own perms. If `${FILES_DATA_DIR}` is owned by a different uid with +mode `0775`, uid 1000 lands in *other* (`r-x`) and every upload/mkdir/delete dies +with `Permission denied`, while the UI, `curl /`, and the Kuma monitor all stay +green (same false-green trap as the SPA gotchas). Hit on `oxps` 2026-07-24: the +share (`/home/oier/mc/distribution-ulicraft/files`) was `1002:1002 0775`. + +Fix — give the share to filestash's uid: +```sh +sudo chown -R 1000:1000 "$FILES_DATA_DIR" +sudo chmod g+s "$FILES_DATA_DIR" # new files inherit the group +docker exec -u 1000 filestash sh -c 'touch /data/files/.wtest && echo OK && rm /data/files/.wtest' +``` +Reversible (`chown -R "$FILES_DATA_DIR"`). If another account also writes +the share directly, use a shared group + `g+rwx` instead of chowning it away. + +#### Cleaner: run filestash *as* the owning uid (share files become `oier:oier`) +Chowning the share to filestash's 1000 works, but then everything filestash +writes is owned by an anonymous `1000` nobody logs in as. To make the share +files owned by a **real host account** (oxps `oier` = uid 1002) so you can manage +them over ssh, run the container as that uid. Compose exposes it: +```yaml +user: "${FILES_UID:-1000}:${FILES_GID:-1000}" +group_add: ["1000"] +``` +Two things make a bare uid override fail, both handled above: +- The image binary `/app/filestash` is `0730 filestash:filestash` and `/app/data` + is `0770` — a non-1000 uid is *other* and can neither exec nor traverse. The + `group_add: ["1000"]` restores that via the filestash group. +- The `filestash_state` volume (config, sqlite, search index) is owned `1000:1000`. + Running as another uid without chowning it → filestash can't write its own + state and crash-loops. **Chown the state volume too.** + +Do all of it with **`tooling/filestash-set-run-user.sh`** (reads `FILES_UID`/ +`FILES_GID` from `.env`, chowns the state volume + `FILES_DATA_DIR`, recreates, +write-tests). oxps: set `FILES_UID=FILES_GID=1002` in `.env`, run it. Default +`1000:1000` leaves cochi untouched. Per-host, host-local — nothing but the +compose knob and this script is committed. + +### Accessing the share files directly (bypassing the web UI) +`${FILES_DATA_DIR}` is a plain host directory — the web UI is just a view of it. +- **On the host:** `ls "$FILES_DATA_DIR"` (oxps: `/home/oier/mc/distribution-ulicraft/files`). + Write as uid 1000 to match filestash ownership: `sudo -u '#1000' cp foo "$FILES_DATA_DIR"/`. +- **From your machine (oxps is LAN-only):** `scp file oxps:/home/oier/mc/distribution-ulicraft/files/` + then `ssh oxps 'sudo chown 1000:1000 /home/oier/mc/distribution-ulicraft/files/file'`, + or mount over sshfs: `sshfs oxps:/home/oier/mc/distribution-ulicraft/files ~/mnt/ulifiles`. +- **Via the container:** `docker exec -u 1000 filestash ls /data/files`. +- **Web (LAN):** `http://files.${BASE_DOMAIN}` — no login on oxps (auth=none). + ### Disk is the shared failure domain `FILES_DATA_DIR` grows without a quota, driven by whoever has the password. A full disk on `cochi` does not just break `files.` — it takes **Minecraft, Drasl, diff --git a/tooling/filestash-set-run-user.sh b/tooling/filestash-set-run-user.sh new file mode 100755 index 0000000..19466b4 --- /dev/null +++ b/tooling/filestash-set-run-user.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Recreate the filestash container running as a chosen uid/gid so that every +# file it writes into the share is owned by a host account (e.g. oier). +# +# Reads FILES_UID / FILES_GID from .env, chowns the filestash_state volume and +# the FILES_DATA_DIR share to that uid, then force-recreates the container. +# Run it FROM THE REPO ROOT on the target host (needs sudo + docker). +# +# Idempotent and cautious: halts on any ambiguity rather than guessing. +# Background + rationale: plan/20-files.md ("Share dir must be owned by uid …"). +set -euo pipefail + +die() { echo "!! $*" >&2; exit 1; } +info() { echo ">> $*"; } + +# --- preconditions ----------------------------------------------------------- +[ -f docker-compose.yml ] || die "run this from the repo root (no docker-compose.yml here)" +[ -f .env ] || die ".env not found — this is a host-local file; run on the target host" + +# The compose file must carry the env-driven `user:` directive. If it doesn't, +# this checkout predates the change — pull before running so the recreate below +# actually applies the uid. +grep -q 'user: "${FILES_UID' docker-compose.yml \ + || die "docker-compose.yml has no FILES_UID user directive — 'git pull' first" + +# --- resolve target uid/gid from .env --------------------------------------- +FILES_UID="$(grep -E '^FILES_UID=' .env | tail -1 | cut -d= -f2- | tr -d '\047"' )" +FILES_GID="$(grep -E '^FILES_GID=' .env | tail -1 | cut -d= -f2- | tr -d '\047"' )" +[ -n "${FILES_UID:-}" ] || die "FILES_UID unset in .env — add e.g. 'FILES_UID=1002' (oier)" +[ -n "${FILES_GID:-}" ] || die "FILES_GID unset in .env — add e.g. 'FILES_GID=1002' (oier)" +case "$FILES_UID$FILES_GID" in *[!0-9]*) die "FILES_UID/FILES_GID must be numeric (got $FILES_UID:$FILES_GID)";; esac +info "target run user = ${FILES_UID}:${FILES_GID}" + +# --- resolve the share dir + the state volume host path ---------------------- +FILES_DATA_DIR="$(grep -E '^FILES_DATA_DIR=' .env | tail -1 | cut -d= -f2- | tr -d '\047"' )" +[ -n "${FILES_DATA_DIR:-}" ] || die "FILES_DATA_DIR unset in .env" +[ -d "$FILES_DATA_DIR" ] || die "share dir does not exist: $FILES_DATA_DIR" + +PROJECT="$(basename "$PWD")" # compose project name = dir name +VOL="${PROJECT}_filestash_state" +docker volume inspect "$VOL" >/dev/null 2>&1 || die "docker volume '$VOL' not found" +STATE_DIR="$(docker volume inspect "$VOL" --format '{{ .Mountpoint }}')" +[ -n "$STATE_DIR" ] && [ -d "$STATE_DIR" ] || die "could not resolve state volume mountpoint" + +info "share dir = $FILES_DATA_DIR" +info "state dir = $STATE_DIR (volume $VOL)" + +# --- confirm (destructive-ish: chown -R) ------------------------------------- +echo +echo "About to:" +echo " sudo chown -R ${FILES_UID}:${FILES_GID} $STATE_DIR" +echo " sudo chown -R ${FILES_UID}:${FILES_GID} $FILES_DATA_DIR" +echo " docker compose up -d --force-recreate filestash (brief downtime)" +read -r -p "Proceed? [y/N] " ans +[ "$ans" = "y" ] || [ "$ans" = "Y" ] || die "aborted by user" + +# --- apply ------------------------------------------------------------------- +info "stopping filestash" +docker compose stop filestash + +info "chowning state volume -> ${FILES_UID}:${FILES_GID}" +sudo chown -R "${FILES_UID}:${FILES_GID}" "$STATE_DIR" + +info "chowning share -> ${FILES_UID}:${FILES_GID}" +sudo chown -R "${FILES_UID}:${FILES_GID}" "$FILES_DATA_DIR" + +info "recreating filestash as ${FILES_UID}:${FILES_GID}" +docker compose up -d --force-recreate filestash + +# --- verify ------------------------------------------------------------------ +sleep 3 +running="$(docker inspect -f '{{.State.Running}}' filestash 2>/dev/null || echo false)" +[ "$running" = "true" ] || { docker compose logs --tail=40 filestash; die "filestash is not running — see logs above"; } + +info "write test as uid ${FILES_UID}" +if docker exec -u "$FILES_UID" filestash sh -c 'touch /data/files/.wtest && rm /data/files/.wtest'; then + info "OK — filestash writes the share as ${FILES_UID}:${FILES_GID}; new files owned by that user" +else + docker compose logs --tail=40 filestash + die "write test FAILED — check share perms + logs above" +fi