Skip to content

Work around vendor kernels whose syscall 452 is not fchmodat2 (RRD-2830) - #31

Merged
decriptor merged 1 commit into
mainfrom
shaw/qnap-fchmodat2
Aug 13, 2026
Merged

decriptor merged 1 commit into
mainfrom
shaw/qnap-fchmodat2

Conversation

@decriptor

Copy link
Copy Markdown
Collaborator

RoonServer 2.71 cannot install or self-update in a container on QNAP. tar aborts on the three .roonhost alias symlinks and the updater reports update installer exited with 1:

tar: RoonServer/Appliance/.roonhost/RAATServer: Cannot change mode to rwxr-xr-x: Bad address
tar: RoonServer/Appliance/.roonhost/RoonAppliance: Cannot change mode to rwxr-xr-x: Bad address
tar: RoonServer/Server/.roonhost/RoonServer: Cannot change mode to rwxr-xr-x: Bad address
Error: [appupdater] While installing update: System.Exception: update installer exited with 1

Root cause

Confirmed on a TS-453Be running QTS 5.2.9 / kernel 5.10.60-qnap:

fchmodat2(4, "…/.roonhost/RAATServer", 0755, AT_SYMLINK_NOFOLLOW) = -1 EFAULT (Bad address)

tar restores a symlink's mode for every symlink in an archive, whatever its target. glibc 2.39+ implements that via fchmodat2 (syscall 452, Linux 6.6+). That kernel is 5.10 and has no fchmodat2 — but QNAP put six vendor syscalls on 450-455 before upstream claimed those numbers. From their GPL source, GPL_QTS/src/linux-5.10/arch/x86/entry/syscalls/syscall_64.tbl:

450 qnap_rmdir   451 qnap_unlink   452 qnap_find_filename
453 qsendfile    454 qsendfile64   455 qtier_anchor

452 is qnap_find_filename, declared in that tree's fs/namei.c as

SYSCALL_DEFINE2(qnap_find_filename, const char __user *, path,
                const char __user *, file)

so it reads glibc's argument 0 — a dirfd — as a userspace string pointer and faults in getname(). glibc only falls back to its pre-2.39 emulation on ENOSYS, so EFAULT propagates. tar ignores EOPNOTSUPP here (a symlink's mode is meaningless on Linux) but treats EFAULT as fatal.

The table jumps 440 → 450, matching a syscall sweep on the running kernel: 441-449 and 456+ correctly return ENOSYS, 450-455 do not. So this is a collision with vendor syscalls, not a missing feature.

Ruled out on hardware: not seccomp (--security-opt seccomp=unconfined behaves identically) and not the filesystem (reproduces on overlayfs and on an ext4 bind mount alike). Also not Roon-specific — any archive containing any symlink fails the same way, which is why unrelated images break on the same NAS.

The fix

Restores glibc 2.36's behaviour for this one call via a small LD_PRELOAD, scoped three ways so unaffected hosts are untouched:

  • Not installed unless a probe proves the kernel is affected. The probe extracts a one-symlink archive with the real tar and looks for the actual error — no syscall numbers, no kernel-version or vendor matching. So it also covers any other vendor kernel with the same collision, and stops engaging by itself if a host is ever fixed.
  • Scoped to tar through a PATH shim, so RoonServer never loads it. RoonServer/start.sh invokes tar unqualified, so this covers self-update as well as first install.
  • Inside tar, the real fchmodat runs first and its result is returned untouched unless it failed with exactly EFAULT on a no-follow request.

Every filesystem step is best-effort: a full /tmp, a --read-only rootfs or a non-root --user degrades to "no shim" rather than stopping the container from starting.

Verification

healthy kernel (6.12) TS-453Be (5.10.60-qnap)
probe fires no yes
/usr/local/bin/tar absent present
command -v tar /usr/bin/tar /usr/local/bin/tar
install of 2.71 b1683 normal succeeds (was failing)

On the NAS the entrypoint logs Shim verified: extracting a symlink now succeeds, and a restart reports shim already active. Locally: 63/63 smoke, 9/9 healthcheck.

Tests pin the blast radius, not just the fix — smoke.sh asserts the library ships, loads as a preload, that nothing overrides tar in the image, and that the builder stage's toolchain did not leak; runtime.sh asserts the shim stays dormant on CI's healthy kernel, since a misfiring probe would put an LD_PRELOAD on tar for every user on every platform.

Lifespan

This works around a third-party kernel defect and should not outlive it. QNAP cannot fix it cheaply — those syscall numbers are ABI for their own userspace, so renumbering breaks their binaries and third-party QPKGs, and a rebase to ≥6.6 is not happening for this hardware generation. Retirement is gated on affected models aging out, observable by the detection line disappearing from support logs.

The second commit addresses review feedback (set -e safety, verifying the shim actually loads before claiming it, LD_PRELOAD prepending, glibc's ENOENT→EOPNOTSUPP mapping, and two tests that could pass without verifying anything). Happy to squash on merge.

Copilot AI lite review requested due to automatic review settings August 12, 2026 21:38
@decriptor
decriptor requested a review from pargue August 12, 2026 21:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a narrowly scoped workaround for vendor kernels where syscall 452 collides with fchmodat2, causing GNU tar to abort when extracting archives that contain symlinks (notably impacting RoonServer 2.71 container install/self-update on certain QNAP kernels).

Changes:

  • Add an LD_PRELOAD shim that intercepts fchmodat() and emulates the pre-glibc-2.39 behavior only for the specific EFAULT + AT_SYMLINK_NOFOLLOW failure mode.
  • Add an entrypoint probe that detects the failure by extracting a one-symlink archive and installs a PATH-scoped tar wrapper only when needed.
  • Add smoke/runtime tests to ensure the shim ships, loads, and stays dormant on healthy kernels; compile the shim in a builder stage to avoid toolchain leakage.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/smoke.sh Adds smoke assertions that the shim is present/loadable and no tar override/toolchain is baked into the image.
tests/runtime.sh Adds runtime assertions that the shim does not activate on CI’s healthy kernel.
fchmodat-compat.c Introduces the LD_PRELOAD shim implementing the targeted fchmodat() fallback behavior.
entrypoint.sh Adds runtime probing and best-effort installation of a tar PATH shim that injects the preload only when the kernel is affected.
Dockerfile Adds a builder stage to compile the shim and copies only the .so into the runtime image.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread fchmodat-compat.c Outdated
Comment thread tests/smoke.sh Outdated
@decriptor
decriptor force-pushed the shaw/qnap-fchmodat2 branch 2 times, most recently from 7987386 to 4e4b7d4 Compare August 12, 2026 22:28
RoonServer 2.71 cannot install or self-update in a container on QNAP: tar
aborts with "Cannot change mode to rwxr-xr-x: Bad address" (RRD-2830).

Root cause, confirmed on a TS-453Be running QTS 5.2.9 / kernel 5.10.60-qnap:

  fchmodat2(..., AT_SYMLINK_NOFOLLOW) = -1 EFAULT (Bad address)

glibc 2.39+ implements fchmodat with flags ≠ 0 via the fchmodat2 syscall
(452, Linux 6.6+). GNU tar uses AT_SYMLINK_NOFOLLOW when restoring modes of
directories, files, and symlinks. That kernel is 5.10 and has no fchmodat2 —
but QNAP put six vendor syscalls on 450-455 before upstream claimed those
numbers. Per their GPL source, GPL_QTS/src/linux-5.10/arch/x86/entry/syscalls/syscall_64.tbl:

  450 qnap_rmdir   451 qnap_unlink   452 qnap_find_filename
  453 qsendfile    454 qsendfile64   455 qtier_anchor

452 is qnap_find_filename, declared in that tree's fs/namei.c as

  SYSCALL_DEFINE2(qnap_find_filename, const char __user *, path,
                  const char __user *, file)

so it reads glibc's argument 0 — a dirfd — as a userspace string pointer and
faults in getname(). glibc only falls back to its pre-2.39 emulation on ENOSYS,
so EFAULT propagates; tar ignores EOPNOTSUPP for SYMTYPE but treats EFAULT as
fatal. Public failures are mostly directory mode restores.

The table jumps 440 -> 450, matching a syscall sweep on the running kernel:
441-449 and 456+ correctly return ENOSYS, 450-455 do not. So this is a
collision with vendor syscalls, not a missing feature. It is not seccomp
(--security-opt seccomp=unconfined behaves identically) and not the filesystem
(it reproduces on overlayfs and on an ext4 bind mount alike). It is also not
specific to Roon: any archive whose members need a flagged fchmodat fails the
same way, which is why unrelated images break on the same hardware.

The fix restores glibc 2.36's behaviour for this one call via a small
LD_PRELOAD, scoped so unaffected hosts are untouched:

  - Installed only after a probe extracts a tiny archive with /usr/bin/tar
    and sees "Bad address" — no syscall numbers or vendor matching. The
    wrapper lives in the container's writable layer, so a restart keeps it
    (a no-op once 452 is really fchmodat2). Recreating the container re-probes.
  - Preload is scoped to tar through a PATH wrapper, so RoonServer never
    loads it. RoonServer/start.sh invokes `tar` unqualified, so this covers
    self-update as well as first install.
  - The real fchmodat runs first; its result is returned untouched unless it
    failed with exactly EFAULT on a no-follow request.

hash -r after installing the wrapper is load-bearing: unqualified tar must
resolve through PATH so the wrapper we just wrote is what the verify extract
and later install use.

The wrapper is best-effort: a full /tmp, a --read-only rootfs, or --user
without write access to /usr/local degrades to "no shim" rather than aborting
the entrypoint. LD_PRELOAD is prepended so an operator-set preload survives.
A missing /proc/self/fd/N is mapped ENOENT → EOPNOTSUPP. On a successful
emulation the caller's entry errno is restored, because chmod/close leave
errno unchanged on success.

After install, the probe archive is re-extracted through the PATH wrapper to
verify the preload actually took (ld.so ignores an unusable LD_PRELOAD). A
failed verify removes the wrapper so the next start retries instead of
reporting "already active". Restart only treats our wrapper plus the .so as
active; a foreign /usr/local/bin/tar is left alone.

Verified on the affected NAS: detection fires, "Shim verified", 2.71 build
1683 installs, and a restart reports the shim already active. Healthy 6.12
kernel: probe stays dormant.

Tests: smoke.sh asserts the library ships and loads, that a stub making
fchmodat(..., AT_SYMLINK_NOFOLLOW) return EFAULT still extracts a dir+symlink
archive through the shim, and that restart does not call a foreign
/usr/local/bin/tar the shim. runtime.sh asserts the probe stays dormant on
CI's healthy kernel.

This is a workaround for a third-party kernel defect and should not outlive it.
QNAP cannot fix it cheaply — those syscall numbers are ABI for their own
userspace — so retirement is gated on affected models aging out, observable by
the detection line disappearing from support logs.
@decriptor
decriptor force-pushed the shaw/qnap-fchmodat2 branch from 4e4b7d4 to ba94d6d Compare August 12, 2026 22:53

@pargue pargue left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no actionable correctness, security, or operational issues.

@decriptor
decriptor merged commit 9374a7e into main Aug 13, 2026
2 checks passed
@decriptor
decriptor deleted the shaw/qnap-fchmodat2 branch August 13, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants