grafana restored and then died with "attempt to write a readonly database",
repeatedly. Its database is recorded in the snapshot as 231543:1002 and landed
as 1002:1002 — the owner was lost, so grafana, running as 231543, could not
write it at mode 0640.
Restore runs as the backup user with no CAP_CHOWN, so it reinstates ownership
inside a user namespace. The prefix was
unshare --map-root-user --map-users=SUB:SUB:N --map-groups=SUB:SUB:N
and unshare accepts ONE range per option, so the backup user's own GID was never
mapped — while app data is written as <container-uid>:<backup-user>. The group
half of every such chown referred to an unmapped id, lchown returned EINVAL, and
the file kept the restoring user's ownership. restic reports those as "ignoring
error ..." and still exits 0, so nothing failed: 1626 of one 13-app restore's
2086 failed chowns were grafana's, under a restore that reported success.
restic-userns-exec uses newuidmap/newgidmap, which write the multi-range maps
unshare cannot express:
uid: 0 <- caller inner root, or caps are dropped at exec
SUB.. <- SUB.. identity, so restic can name the stored uid
gid: caller <- caller identity: the group half of app-data chowns
SUB.. <- SUB.. identity
The caller's own UID is deliberately not identity-mapped — that slot is spent on
inner root — and a file stored as <caller>:<caller> lands owned by the caller
anyway, because that is who inner root is outside. So the one case this cannot
map is the one case needing no mapping. `unshare --map-auto --map-current-user`
is not a shortcut: it maps the subuid range to low inner ids while restic needs
identity. Tested.
Measured live, restoring grafana: failed chowns 1626 -> 12 (the 12 being the
caller's own files, correct), grafana.db back to 231543:1002, grafana up and
writing. Falls back to running the command plainly when there is no subuid
range, no newuidmap, or the namespace will not start.
scripts/dev/lp-userns-ownership-test pins all three ownership cases; verified
the old prefix fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
4.2 KiB
Bash
Executable File
106 lines
4.2 KiB
Bash
Executable File
#!/bin/bash
|
|
# Run a command in a user namespace whose id maps let it restore file ownership.
|
|
#
|
|
# restic-userns-exec restic restore <id> --target …
|
|
#
|
|
# Restore runs as the backup user, which has no CAP_CHOWN, so it cannot put back
|
|
# the uid a file had when it was backed up. A user namespace solves that — but
|
|
# only if every id involved is mapped, and LibrePortal needs three mappings at
|
|
# once while `unshare` accepts exactly ONE range per option.
|
|
#
|
|
# That limit is why the previous one-line prefix silently did not work:
|
|
#
|
|
# unshare --map-root-user --map-users=SUB:SUB:N --map-groups=SUB:SUB:N
|
|
#
|
|
# mapped root and the subuid range, but never the backup user's own GID. App
|
|
# data is written as <container-uid>:<backup-user>, so the group half of every
|
|
# chown referred to an unmapped id, lchown returned EINVAL, and the file kept
|
|
# the restoring user's ownership. Grafana's database came back 1002:1002 instead
|
|
# of 231543:1002 and the app died with "attempt to write a readonly database" —
|
|
# under a restore that reported success.
|
|
#
|
|
# newuidmap/newgidmap can write multi-range maps, so the maps become:
|
|
#
|
|
# uid: 0 <- caller (inner root: without this, capabilities are
|
|
# dropped at exec and chown is not permitted)
|
|
# SUB.. <- SUB.. (identity, so restic can ask for the stored
|
|
# container uid by its real number)
|
|
# gid: caller <- caller (identity: the group half of app-data chowns)
|
|
# SUB.. <- SUB.. (identity)
|
|
#
|
|
# The caller's own UID is deliberately NOT identity-mapped: it is spent on inner
|
|
# root. A file stored as <caller>:<caller> therefore fails its chown — and lands
|
|
# owned by the caller anyway, because that is who inner root is outside. So the
|
|
# one case this cannot map is the one case that needs no mapping.
|
|
#
|
|
# Anything unexpected — no subuid range, no newuidmap, a namespace that will not
|
|
# start — falls back to running the command plainly, which is what happened
|
|
# before this existed.
|
|
set -u
|
|
|
|
(( $# )) || { echo "restic-userns-exec: no command given" >&2; exit 2; }
|
|
|
|
TMPDIR_MADE=""
|
|
_plain() { [[ -n "$TMPDIR_MADE" ]] && rm -rf "$TMPDIR_MADE"; exec "$@"; }
|
|
|
|
for _bin in unshare newuidmap newgidmap; do
|
|
command -v "$_bin" >/dev/null 2>&1 || _plain "$@"
|
|
done
|
|
|
|
_usr=$(id -un 2>/dev/null) || _plain "$@"
|
|
_u=$(id -u); _g=$(id -g)
|
|
|
|
_uline=$(grep "^${_usr}:" /etc/subuid 2>/dev/null | head -1)
|
|
_gline=$(grep "^${_usr}:" /etc/subgid 2>/dev/null | head -1)
|
|
[[ -n "$_uline" && -n "$_gline" ]] || _plain "$@"
|
|
|
|
_ustart="${_uline#*:}"; _ustart="${_ustart%%:*}"; _ucount="${_uline##*:}"
|
|
_gstart="${_gline#*:}"; _gstart="${_gstart%%:*}"; _gcount="${_gline##*:}"
|
|
[[ "$_ustart" =~ ^[0-9]+$ && "$_ucount" =~ ^[0-9]+$ ]] || _plain "$@"
|
|
[[ "$_gstart" =~ ^[0-9]+$ && "$_gcount" =~ ^[0-9]+$ ]] || _plain "$@"
|
|
(( _ucount > 0 && _gcount > 0 )) || _plain "$@"
|
|
|
|
# The caller's own id has to sit outside its sub-range, or the two entries would
|
|
# overlap on the outer side and the kernel rejects the whole map.
|
|
(( _u < _ustart || _u >= _ustart + _ucount )) || _plain "$@"
|
|
(( _g < _gstart || _g >= _gstart + _gcount )) || _plain "$@"
|
|
|
|
TMPDIR_MADE=$(mktemp -d "${TMPDIR:-/tmp}/lp-userns.XXXXXX") || { TMPDIR_MADE=""; _plain "$@"; }
|
|
_fifo="$TMPDIR_MADE/gate"
|
|
mkfifo -m 600 "$_fifo" 2>/dev/null || _plain "$@"
|
|
|
|
# Opened here and inherited there, rather than opened by path in the child:
|
|
# until the map is written the child's uid is unmapped, so to the filesystem it
|
|
# is nobody and could not open its own gate.
|
|
exec 9<>"$_fifo"
|
|
|
|
unshare --user bash -c 'read -r _ <&9; exec "$@"' _ "$@" &
|
|
_child=$!
|
|
|
|
# The namespace exists before unshare execs, but only just — retry briefly
|
|
# rather than assume. A write that succeeds cannot be repeated, so stop there.
|
|
_mapped=0
|
|
for _ in $(seq 1 100); do
|
|
kill -0 "$_child" 2>/dev/null || break
|
|
if newuidmap "$_child" 0 "$_u" 1 "$_ustart" "$_ustart" "$_ucount" 2>/dev/null; then
|
|
_mapped=1; break
|
|
fi
|
|
sleep 0.02
|
|
done
|
|
|
|
if (( _mapped )); then
|
|
newgidmap "$_child" "$_g" "$_g" 1 "$_gstart" "$_gstart" "$_gcount" 2>/dev/null || _mapped=0
|
|
fi
|
|
|
|
if (( ! _mapped )); then
|
|
kill "$_child" 2>/dev/null
|
|
wait "$_child" 2>/dev/null
|
|
_plain "$@"
|
|
fi
|
|
|
|
printf 'go\n' >&9
|
|
wait "$_child"
|
|
_rc=$?
|
|
rm -rf "$TMPDIR_MADE"
|
|
exit "$_rc"
|