#!/bin/bash # Single source of truth for aligning volume ownership with the runtime user. # # Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH [PATH...] # # --dry-run report what would change, touch nothing # --sentinel skip entirely when FILE already records "SCHEMA:UID:GID"; # write it after a successful run (used by the boot path so # multi-TB volumes are swept once per UID/schema change, not # on every boot) # --mode append STRING to the sentinel, so changing it re-sweeps once # # Only files whose uid OR gid differs are touched, so re-runs are cheap. # lost+found is skipped: fsck fills it with root-only recovered fragments. # Top-level /config additionally grants group frigate-data TRAVERSE ONLY # (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file # on hosts where /config is mounted 0700. Never g+w: directory write means # unlink rights over frigate.db/config.yml, and would let a compromised # go2rtc plant /config/go2rtc, which the go2rtc run script executes # preferentially, as root under the escape hatch. set -o errexit -o nounset -o pipefail # Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade # (e.g. when the privilege-drop release must capture files created as root # since the previous sweep). schema=2 dry_run=0 sentinel="" mode="" while [[ "${1:-}" == --* ]]; do case "$1" in --dry-run) dry_run=1; shift ;; --sentinel) if [[ -z "${2:-}" ]]; then echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2 exit 2 fi sentinel="$2"; shift 2 ;; --mode) if [[ -z "${2:-}" ]]; then echo "[ERROR] fix-ownership: --mode requires a value" >&2 exit 2 fi mode="$2"; shift 2 ;; *) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;; esac done if [[ $# -lt 3 ]]; then echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH..." >&2 exit 2 fi target_uid="$1" target_gid="$2" shift 2 if [[ "$(id -u)" -ne 0 ]]; then echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)" exit 0 fi # The list folds into the sentinel so entering or leaving a granular root mode # re-sweeps once, catching whatever the other ownership mechanisms missed. sentinel_content="${schema}:${target_uid}:${target_gid}" if [[ -n "$mode" ]]; then sentinel_content="${sentinel_content}:${mode}" fi # safe-sentinel reports only a root-owned regular file, so a forged or # symlinked sentinel in the runtime-user-owned /config can't suppress the sweep if [[ "$dry_run" -eq 0 && -n "$sentinel" ]]; then if existing=$(/usr/local/bin/safe-sentinel read "$sentinel" 2>/dev/null) && \ [[ "$existing" == "$sentinel_content" ]]; then echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping" exit 0 fi fi # A sweep that could not chown everything must not be recorded as complete: # the sentinel would make every later boot skip it and the entries would stay # unreachable once services run unprivileged. swept_clean=1 # Entries another mechanism deliberately owns. Chowning them undoes that work # and leaves the same "mismatch" waiting for the next boot, so /config could # never report itself clean: /config is chgrp'd to frigate-data below so go2rtc # can traverse it, and the HomeKit file is handed to the go2rtc user by the # go2rtc service. Only the GROUP on /config is exempt; a root-owned /config # must still be chowned or the runtime user cannot write there at all. # Shared by the counting and the chowning walk so the two cannot disagree. mismatch_expr=( "(" -not -uid "$target_uid" -o "(" -not -gid "$target_gid" -a ! -path /config ")" ")" -a ! -path /config/go2rtc_homekit.yml ) if [[ -n "$sentinel" ]]; then # safe-sentinel keeps the sentinel root-owned on purpose and rejects one # owned by anybody else, so chowning it here would suppress the skip and # make every boot re-sweep. Only the trailing write puts it back today. mismatch_expr+=(-a ! -path "$sentinel") fi for path in "$@"; do # An absent root is an incomplete sweep, not a finished one: /media/frigate # is not in the image, so a boot before the volume is mounted would # otherwise record success and the volume would never be swept once added. if [[ ! -d "$path" ]]; then swept_clean=0 echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot" continue fi echo "[INFO] fix-ownership: scanning ${path} for ownership mismatches; this may take a while on large filesystems" # find may fail mid-walk on a live volume (file deleted under it) or on a # stale mount. Tolerate it rather than aborting under errexit, but never # read a failed scan as "nothing to do": that would record the sweep as # complete without having looked. if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then swept_clean=0 echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot" continue fi if [[ "$count" -eq 0 ]]; then echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do" continue fi # find does not descend symlinks and chown -h retargets the link itself, so # anything behind a symlinked directory is outside this sweep. Following # them is not an option: a link could walk the chown out of the volume. if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand" fi echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}" if [[ "$dry_run" -eq 1 ]]; then echo "[INFO] fix-ownership: dry run, not changing ${path}" continue fi # -execdir chowns from the entry's own directory, so a parent swapped for a # symlink mid-walk can't redirect the chown out of the volume started=$SECONDS if find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" \ -print -execdir chown -h "${target_uid}:${target_gid}" {} + \ | awk -v total="$count" -v path="$path" ' BEGIN { next_pct = 5 } { pct = int(NR * 100 / total) if (pct > 100) pct = 100 if (pct >= next_pct) { printf "[INFO] fix-ownership: %s %d%% (%d/%d entries)\n", path, pct, NR, total # mawk block-buffers to a pipe; without fflush the whole # progress log arrives at once fflush() while (next_pct <= pct) next_pct += 5 } }'; then elapsed=$((SECONDS - started)) if [[ "$elapsed" -ge 60 ]]; then elapsed="$((elapsed / 60))m $((elapsed % 60))s" else elapsed="${elapsed}s" fi echo "[INFO] fix-ownership: finished ${path} in ${elapsed}" else swept_clean=0 echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch" fi done # go2rtc (separate user) must be able to REACH its HomeKit state in /config. # Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites # the first -config file via os.WriteFile (in-place truncate, no rename, # verified against go2rtc v1.9.14 internal/app/config.go), and the file is # always pre-created by setup_homekit_config before go2rtc starts, so # O_CREATE never needs directory write. See header comment for why g+w is # forbidden here. if [[ "$dry_run" -eq 0 && -d /config ]]; then chgrp frigate-data /config 2>/dev/null || true chmod g+rx /config 2>/dev/null || true fi if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then /usr/local/bin/safe-sentinel write "$sentinel" "$sentinel_content" || \ echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot" fi