From b1715b210d7f3bb70019b2e1604ad77fc637ac2d Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Mon, 31 Aug 2026 16:58:11 +0200 Subject: [PATCH] fix(deploy): restore www-data ownership after deploy-code to prevent HTTP 500 --- deploy/xamxam-fix-permissions.sudoers | 13 ++++++ docs/deployment.md | 24 ++++++---- justfile | 45 ++++++++++++++++-- scripts/fix-permissions.sh | 66 +++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 deploy/xamxam-fix-permissions.sudoers create mode 100755 scripts/fix-permissions.sh diff --git a/deploy/xamxam-fix-permissions.sudoers b/deploy/xamxam-fix-permissions.sudoers new file mode 100644 index 0000000..57c30ba --- /dev/null +++ b/deploy/xamxam-fix-permissions.sudoers @@ -0,0 +1,13 @@ +# Scoped passwordless sudo for the deploy permission-restore step. +# +# `just deploy-permissions` (and thereby `just deploy-code`) resyncs the app +# tree as the deploy SSH user, then runs `fix-permissions.sh` as root to +# restore www-data:xamxam ownership. Using an interactive remote pty for that +# sudo is fragile: `ssh -t` silently drops the pseudo-terminal when the local +# process stdin is not a TTY, so sudo's password prompt prints but cannot +# receive keystrokes. Because the script is fixed-path, takes no arguments and +# only touches /var/www/xamxam, it is safe to whitelist with NOPASSWD. +# +# Install via `just deploy-sudoers` (must be run interactively once as a +# password-lessed sudoer, or with root). +theophile ALL=(root) NOPASSWD: /bin/bash /tmp/fix-permissions.sh diff --git a/docs/deployment.md b/docs/deployment.md index 62400d5..1eec043 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -104,6 +104,7 @@ If you ever rotate `APP_KEY`, re-encrypt the SMTP password with | `just deploy-migrate` | Run pending DB migrations on the server | | `just deploy-env` | Upload `app/.env` (only if the remote `.env` is absent — never overwrites a key) | | `just deploy-nginx` | Upload + apply + reload nginx config | +| `just deploy-permissions` | Restore `www-data:xamxam` ownership + 2775/664 perms (via `scripts/fix-permissions.sh`, needs sudo) | | `just deploy-db` | Push local `xamxam.db` → remote (**refuses** if a remote DB already exists) | | `just deploy-verify-permissions` | Check ownership / permissions on the server | @@ -182,14 +183,21 @@ Ownership and permissions are applied by `scripts/deploy-server.sh` (run via - `app/.env`: **640** `just deploy-code` only rsyncs code (as the SSH/deploy user); it does **not** -set ownership. Ownership is normalised afterwards by `deploy-server.sh` -(`chown -R www-data:xamxam /var/www/xamxam`, setgid dirs, locked DBs). On a -`xamxam` group-writable tree the rsync succeeds, then `deploy-nginx` (within -`just deploy`) fixes ownership/perms. If `deploy-code` reports `Permission -denied` on `storage/` (e.g. a fresh box where the dirs are not yet -`www-data:xamxam` group-writable), re-apply `scripts/setup-server.sh` / -`deploy-server.sh` first, or add `--chown=www-data:xamxam` to the rsync so it -normalises ownership while transferring. +set ownership itself. It therefore finishes by running `just deploy-permissions` +(`scripts/fix-permissions.sh` via sudo) so a standalone `deploy-code` can never +leave the site broken. Ownership is normalised by `deploy-server.sh` +(`chown -R www-data:xamxam /var/www/xamxam`, setgid dirs, locked DBs) too, via +`just deploy-nginx`. + +> **Why this matters (HTTP 500):** php-fpm runs as `www-data`. After a plain +> `rsync -az` (run as the deploy user, no `--chown`), synced files/dirs are +> owned by the deploy user, and top-level dirs such as `storage/` lose the +> `www-data` group-write bit. The app opens the SQLite DB in **WAL mode**, so +> www-data must be able to create `xamxam.db-wal` / `xamxam.db-shm` **in** +> `storage/`; without write access there the request fatals → **500**. If this +> ever recurs (e.g. a manual rsync that skipped `deploy-permissions`), run +> `just deploy-permissions` (`sudo chown -R www-data:xamxam` + 2775/664) to +> restore it. The nginx/`deploy-server.sh` step (`just deploy-nginx`, or `sudo DEPLOY_USER=$USER bash /tmp/deploy-server.sh` via `just deploy-script`) diff --git a/justfile b/justfile index 3cc0110..bfd2301 100644 --- a/justfile +++ b/justfile @@ -103,7 +103,7 @@ build-check: # ============================================================================ [group('deploy')] -deploy: build deploy-code deploy-nginx deploy-deps deploy-migrate +deploy: build deploy-code deploy-nginx deploy-deps deploy-migrate deploy-sudoers deploy-permissions @just deploy-env @just deploy-verify-permissions @echo "" @@ -114,9 +114,48 @@ deploy: build deploy-code deploy-nginx deploy-deps deploy-migrate deploy-code: # Sync application code only (no Composer deps, no migrations, no nginx config). # nginx + server-side setup are handled by `deploy-nginx` (via deploy). - rsync -az --info=progress2 --delete \ + # No -p/-t/-o/-g: the destination tree is owned by www-data:xamxam (setgid), + # so this SSH user can read/write it but cannot chmod/chown/settime files it + # doesn't own — preserving perms/times would fail every file with + # "Operation not permitted" and exit rsync 23. Ownership/perms are restored + # by `deploy-permissions` right after. Times are only used as a transfer + # heuristic here; --size-only keeps unchanged files from being re-uploaded + # since their remote mtimes are no longer preserved. + rsync -rlDz --size-only --info=progress2 --delete \ --exclude-from=.rsync-exclude \ - app/ xamxam:/var/www/xamxam/ + app/ xamxam:/var/www/xamxam/ + # Plain rsync (as this user) leaves newly-synced files owned by the caller, + # not www-data:xamxam — php-fpm then can't create SQLite journals in + # storage/ → HTTP 500. Restore ownership right after, so `just deploy-code` + # alone can never break the live site. + @just deploy-permissions + +[group('deploy')] +deploy-permissions: + # Fix app-tree ownership/permissions on the host so www-data (php-fpm) can + # read code and write storage (/sqlite wal+shm), cache/, tmp/, var/. Needs + # sudo. Run after any deploy-code resync and as a dep of `deploy`. + # + # sudo here is NOPASSWD-scoped to /tmp/fix-permissions.sh via the + # deploy/xamxam-fix-permissions.sudoers drop-in (installed once by `just + # deploy-sudoers`). That avoids relying on an interactive remote pty, which + # is fragile: `ssh -t` silently drops the pty when local stdin is not a TTY, + # so sudo's prompt prints but accepts no input. If you have not installed + # the drop-in yet, this step will prompt for your password interactively. + @echo "🔒 Fixing www-data ownership/permissions…" + rsync -v scripts/fix-permissions.sh xamxam:/tmp/fix-permissions.sh + ssh -t xamxam "sudo bash /tmp/fix-permissions.sh && rm -f /tmp/fix-permissions.sh" + +[group('deploy')] +deploy-sudoers: + # One-time install: scoped NOPASSWD sudo so `deploy-permissions` never needs + # an interactive remote pty (see deploy/xamxam-fix-permissions.sudoers for + # rationale). Privileged write to /etc/sudoers.d requires an interactive sudo + # password, so run this from a real terminal the first time. + @echo "🔒 Installing NOPASSWD sudo rule for fix-permissions.sh…" + rsync -v deploy/xamxam-fix-permissions.sudoers xamxam:/tmp/xamxam-fix-permissions.sudoers + ssh -t xamxam "sudo install -o root -g root -m 0440 /tmp/xamxam-fix-permissions.sudoers /etc/sudoers.d/xamxam-fix-permissions && sudo visudo -c -f /etc/sudoers.d/xamxam-fix-permissions && rm -f /tmp/xamxam-fix-permissions.sudoers" + @echo "✅ NOPASSWD rule installed. deploy-permissions will run without a password prompt." [group('deploy')] deploy-deps: diff --git a/scripts/fix-permissions.sh b/scripts/fix-permissions.sh new file mode 100755 index 0000000..792c0dc --- /dev/null +++ b/scripts/fix-permissions.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Fix XAMXAM file ownership + permissions on the production host. +# +# php-fpm runs as www-data, so the whole app tree must be group-owned by +# www-data:xamxam with setgid dirs (2775) and 664 files — otherwise www-data +# cannot create the SQLite WAL/journal side-cars in storage/ (→ HTTP 500) nor +# write cache/tmp/log dirs. +# +# This is the ownership half of scripts/deploy-server.sh, factored out so +# `just deploy-code` can restore correct ownership after an rsync resync +# (plain `rsync -az` as a non-root user preserves the calling user's owner, +# not www-data:xamxam). +# +# Usage: sudo bash /tmp/fix-permissions.sh +# +# Wired into the justfile as `deploy-permissions` (run at the end of +# `deploy-code`, and as a dependency of `deploy`). + +set -e + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +ok() { printf "${GREEN}✓${NC} %s\n" "$*"; } +ok "Fixing permissions" + +[ "$EUID" -eq 0 ] || { printf "${RED}✗${NC} Run as root (sudo)\n" >&2; exit 1; } + +APP_DIR="/var/www/xamxam" + +chown -R www-data:xamxam "$APP_DIR/" +ok "Ownership: www-data:xamxam" + +find "$APP_DIR" -type d -exec chmod 2775 {} \; +ok "Directories: 2775 (setgid)" + +find "$APP_DIR" -type f -exec chmod 664 {} \; +ok "Files: 664" + +if [ -d "$APP_DIR/storage" ]; then + chmod 2775 "$APP_DIR/storage" + # SQLite + WAL/SHM side-cars must be writable by www-data + find "$APP_DIR/storage" -name "*.db" -exec chmod 660 {} \; + find "$APP_DIR/storage" -name "*.db-wal" -exec chmod 660 {} \; + find "$APP_DIR/storage" -name "*.db-shm" -exec chmod 660 {} \; + ok "Storage: 2775, databases (+WAL/SHM): 660" +fi + +# .env config is a secret — extra-restrictive regardless of the 664 sweep +if [ -f "$APP_DIR/.env" ]; then + chmod 640 "$APP_DIR/.env" + ok ".env: 640" +fi + +# App var/ dirs (cache/logs/tmp) must stay writable by php-fpm +mkdir -p "$APP_DIR/var/{cache,logs,tmp}" +chown -R www-data:xamxam "$APP_DIR/var" +chmod -R 2775 "$APP_DIR/var" +ok "var/ dirs: www-data:xamxam (2775)" + +# Cache + PHP upload temp (create so first request doesn't race) +mkdir -p "$APP_DIR/storage/cache/rate_limit" +chown -R www-data:xamxam "$APP_DIR/storage/cache" +chmod -R 2775 "$APP_DIR/storage/cache" +mkdir -p "$APP_DIR/storage/tmp/php-uploads" +chown www-data:xamxam "$APP_DIR/storage/tmp/php-uploads" +chmod 2775 "$APP_DIR/storage/tmp/php-uploads" +ok "Cache + upload tmp dirs ready"