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.
This commit is contained in:
Chris Lu
2026-07-29 14:02:33 -07:00
committed by GitHub
parent c4798979d8
commit a4692005e9
5 changed files with 87 additions and 40 deletions
@@ -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"
+5 -12
View File
@@ -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
+5 -12
View File
@@ -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
+5 -12
View File
@@ -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
+6 -4
View File
@@ -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)
}