From a4692005e982a0d28fd9efd12e4500d4d34285ba Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 29 Jul 2026 14:02:33 -0700 Subject: [PATCH] ci: harden the fusermount3 repair (#10485) * ci: move the fusermount3 repair into a composite action Three copies of the same block were already drifting apart, and the target comes from PATH: only ever add setuid root to a root-owned, non-symlink binary under the system bin paths, and say why otherwise. * test: say that the process exited in the wait errors "process exit status 1 before ... accepted connections" is missing its verb. Also mark the SIGTERM return discarded - it fails with os.ErrProcessDone exactly when the select below already handles it. * ci: prefer the distro fusermount3 over escalating a shadow copy The shadowing /usr/local/bin/fusermount3 is not root-owned either, so setting its setuid bit would have handed root to a binary the runner user owns - the repair now symlinks the distro one earlier in PATH and touches nothing, keeping the in-place chmod for a root-owned binary with no distro alternative. A setuid bit only grants root when root owns the file, so accept an existing one only then. * ci: run the FUSE workflows when the shared action changes Their paths filters listed each workflow file but not the composite action all three now call. --- .../actions/fix-fusermount-setuid/action.yml | 66 +++++++++++++++++++ .github/workflows/fuse-dlm-integration.yml | 17 ++--- .github/workflows/fuse-integration.yml | 17 ++--- .github/workflows/fuse-p2p-integration.yml | 17 ++--- test/fuse_integration/framework_test.go | 10 +-- 5 files changed, 87 insertions(+), 40 deletions(-) create mode 100644 .github/actions/fix-fusermount-setuid/action.yml diff --git a/.github/actions/fix-fusermount-setuid/action.yml b/.github/actions/fix-fusermount-setuid/action.yml new file mode 100644 index 000000000..7aad5bf48 --- /dev/null +++ b/.github/actions/fix-fusermount-setuid/action.yml @@ -0,0 +1,66 @@ +name: Fix fusermount3 setuid +description: > + Make sure the fusermount3 an unprivileged mount will find can actually mount. + Some runner images carry a second, source-built fusermount3 in /usr/local/bin + that shadows the distro one in PATH; it is neither setuid nor root-owned, so + every mount fails with "mount failed: Operation not permitted". Both the Go + mount and sw-fuse resolve the helper through PATH. + Run it after the step that apt-installs fuse3. + +runs: + using: composite + steps: + - name: Point PATH at a fusermount3 that can mount + shell: bash + run: | + set -euo pipefail + + # setuid only grants root when root owns the file, and exec follows + # symlinks, so judge the target. + can_mount() { + [ -u "$1" ] && [ "$(stat -Lc %u "$1")" = 0 ] + } + + bin=$(command -v fusermount3 || true) + if [ -z "$bin" ]; then + echo "no fusermount3 in PATH" >&2 + exit 1 + fi + if can_mount "$bin"; then + ls -l "$bin" + exit 0 + fi + echo "$bin cannot mount unprivileged:" + ls -l "$bin" + + # The distro fusermount3 is setuid root and is the one meant to be used. + # Reach it through a symlink earlier in PATH: exec resolves the link, so + # the target keeps its setuid bit, and nothing on the image is modified. + for distro in /usr/bin/fusermount3 /bin/fusermount3; do + if [ "$distro" != "$bin" ] && can_mount "$distro"; then + mkdir -p "$RUNNER_TEMP/fuse-bin" + ln -sf "$distro" "$RUNNER_TEMP/fuse-bin/fusermount3" + echo "$RUNNER_TEMP/fuse-bin" >> "$GITHUB_PATH" + echo "using $distro instead" + ls -l "$distro" + exit 0 + fi + done + + # No usable distro binary, so setting the bit is the only way out - but + # the target came from PATH: do that only for a root-owned system binary, + # never for anything else that happens to sit there. + case "$bin" in + /bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/usr/local/bin/*|/usr/local/sbin/*) ;; + *) echo "refusing to setuid $bin: outside the system bin paths" >&2; exit 1 ;; + esac + if [ -L "$bin" ] || [ ! -f "$bin" ] || [ ! -x "$bin" ]; then + echo "refusing to setuid $bin: not a regular executable file" >&2 + exit 1 + fi + if [ "$(stat -c %u "$bin")" != 0 ] || [ "$(stat -c %g "$bin")" != 0 ]; then + echo "refusing to setuid $bin: not owned by root:root" >&2 + exit 1 + fi + sudo chmod u+s "$bin" + ls -l "$bin" diff --git a/.github/workflows/fuse-dlm-integration.yml b/.github/workflows/fuse-dlm-integration.yml index 18026be60..6ce3f0724 100644 --- a/.github/workflows/fuse-dlm-integration.yml +++ b/.github/workflows/fuse-dlm-integration.yml @@ -8,6 +8,7 @@ on: - 'weed/cluster/**' - 'test/fuse_dlm/**' - '.github/workflows/fuse-dlm-integration.yml' + - '.github/actions/fix-fusermount-setuid/**' push: branches: [master] paths: @@ -15,6 +16,7 @@ on: - 'weed/mount/**' - 'weed/cluster/**' - 'test/fuse_dlm/**' + - '.github/actions/fix-fusermount-setuid/**' concurrency: group: ${{ github.head_ref || github.ref }}/fuse-dlm-integration @@ -44,18 +46,9 @@ jobs: sudo apt-get install -y libfuse3-dev echo 'user_allow_other' | sudo tee -a /etc/fuse.conf sudo chmod 644 /etc/fuse.conf - # Some runner images carry a second, source-built fusermount3 in - # /usr/local/bin that shadows the distro one in PATH without its setuid - # bit, and every unprivileged mount then fails with EPERM. go-fuse takes - # the first fusermount3 in PATH, so repair that one. - fusermount_bin=$(command -v fusermount3 || true) - if [ -n "$fusermount_bin" ]; then - if [ ! -u "$fusermount_bin" ]; then - sudo chown root:root "$fusermount_bin" - sudo chmod u+s "$fusermount_bin" - fi - ls -l "$fusermount_bin" - fi + + - name: Repair the fusermount3 setuid bit + uses: ./.github/actions/fix-fusermount-setuid - name: Build SeaweedFS run: go build -o weed/weed -buildvcs=false ./weed diff --git a/.github/workflows/fuse-integration.yml b/.github/workflows/fuse-integration.yml index c1dbe5d05..72bb23ad5 100644 --- a/.github/workflows/fuse-integration.yml +++ b/.github/workflows/fuse-integration.yml @@ -7,12 +7,14 @@ on: - 'weed/**' - 'test/fuse_integration/**' - '.github/workflows/fuse-integration.yml' + - '.github/actions/fix-fusermount-setuid/**' pull_request: branches: [ master, main ] paths: - 'weed/**' - 'test/fuse_integration/**' - '.github/workflows/fuse-integration.yml' + - '.github/actions/fix-fusermount-setuid/**' concurrency: group: ${{ github.head_ref }}/fuse-integration @@ -45,22 +47,13 @@ jobs: # Allow non-root FUSE mounts with allow_other echo 'user_allow_other' | sudo tee -a /etc/fuse.conf sudo chmod 644 /etc/fuse.conf - # Some runner images carry a second, source-built fusermount3 in - # /usr/local/bin that shadows the distro one in PATH without its setuid - # bit, and every unprivileged mount then fails with EPERM. go-fuse takes - # the first fusermount3 in PATH, so repair that one. - fusermount_bin=$(command -v fusermount3 || true) - if [ -n "$fusermount_bin" ]; then - if [ ! -u "$fusermount_bin" ]; then - sudo chown root:root "$fusermount_bin" - sudo chmod u+s "$fusermount_bin" - fi - ls -l "$fusermount_bin" - fi # Verify FUSE installation fusermount3 --version || fusermount --version || true ls -la /dev/fuse + - name: Repair the fusermount3 setuid bit + uses: ./.github/actions/fix-fusermount-setuid + - name: Build SeaweedFS run: | cd weed diff --git a/.github/workflows/fuse-p2p-integration.yml b/.github/workflows/fuse-p2p-integration.yml index b289c65bc..8cdc18147 100644 --- a/.github/workflows/fuse-p2p-integration.yml +++ b/.github/workflows/fuse-p2p-integration.yml @@ -11,6 +11,7 @@ on: - 'weed/pb/filer.proto' - 'test/fuse_p2p/**' - '.github/workflows/fuse-p2p-integration.yml' + - '.github/actions/fix-fusermount-setuid/**' push: branches: [master] paths: @@ -21,6 +22,7 @@ on: - 'weed/pb/mount_peer.proto' - 'weed/pb/filer.proto' - 'test/fuse_p2p/**' + - '.github/actions/fix-fusermount-setuid/**' concurrency: group: ${{ github.head_ref || github.ref }}/fuse-p2p-integration @@ -50,18 +52,9 @@ jobs: sudo apt-get install -y libfuse3-dev echo 'user_allow_other' | sudo tee -a /etc/fuse.conf sudo chmod 644 /etc/fuse.conf - # Some runner images carry a second, source-built fusermount3 in - # /usr/local/bin that shadows the distro one in PATH without its setuid - # bit, and every unprivileged mount then fails with EPERM. go-fuse takes - # the first fusermount3 in PATH, so repair that one. - fusermount_bin=$(command -v fusermount3 || true) - if [ -n "$fusermount_bin" ]; then - if [ ! -u "$fusermount_bin" ]; then - sudo chown root:root "$fusermount_bin" - sudo chmod u+s "$fusermount_bin" - fi - ls -l "$fusermount_bin" - fi + + - name: Repair the fusermount3 setuid bit + uses: ./.github/actions/fix-fusermount-setuid - name: Build SeaweedFS run: go build -o weed/weed -buildvcs=false ./weed diff --git a/test/fuse_integration/framework_test.go b/test/fuse_integration/framework_test.go index 90cbb980f..ec0fc19b5 100644 --- a/test/fuse_integration/framework_test.go +++ b/test/fuse_integration/framework_test.go @@ -219,7 +219,7 @@ func (p *managedProcess) exited() error { if p.err != nil { return p.err } - return fmt.Errorf("exited with status 0") + return fmt.Errorf("exit status 0") default: return nil } @@ -227,7 +227,9 @@ func (p *managedProcess) exited() error { // stop asks the process to terminate and waits for it to go away. func (p *managedProcess) stop() { - p.cmd.Process.Signal(syscall.SIGTERM) + // Signal fails with os.ErrProcessDone when the child is already gone, which + // is exactly the case the select below handles. + _ = p.cmd.Process.Signal(syscall.SIGTERM) select { case <-p.done: case <-time.After(10 * time.Second): @@ -379,7 +381,7 @@ func (f *FuseTestFramework) waitForService(proc *managedProcess, addr string, ti return nil } if exitErr := proc.exited(); exitErr != nil { - return fmt.Errorf("process %v before %s accepted connections", exitErr, addr) + return fmt.Errorf("process exited (%v) before %s accepted connections", exitErr, addr) } time.Sleep(100 * time.Millisecond) } @@ -406,7 +408,7 @@ func (f *FuseTestFramework) waitForMount(timeout time.Duration) error { // A mount that cannot mount at all (no /dev/fuse, fusermount not setuid) // dies within a second; reporting that beats waiting out the timeout. if exitErr := f.mountProcess.exited(); exitErr != nil { - return fmt.Errorf("mount process %v", exitErr) + return fmt.Errorf("mount process exited (%v)", exitErr) } time.Sleep(100 * time.Millisecond) }