From 28803d4baa7012734f7ae5162db32d263f464860 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Tue, 20 Oct 2026 18:40:20 +0800 Subject: [PATCH 1/2] fix: collect Host disk data in the correct mount namespace --- .github/workflows/codeql-verification.yml | 6 +- .github/workflows/security-release-gate.yml | 22 +-- .github/workflows/validate.yml | 65 +++++-- README.md | 14 +- core/hostinfo/disk.go | 14 +- core/hostinfo/disk_info.go | 50 ++--- core/hostinfo/disk_info_linux.go | 51 +++++ core/hostinfo/disk_info_linux_test.go | 203 ++++++++++++++++++++ core/hostinfo/disk_info_other.go | 7 + core/ping/local_storage_test.go | 25 +++ core/ping/ping_common.go | 6 +- scripts/version | 2 +- 12 files changed, 399 insertions(+), 66 deletions(-) create mode 100644 core/hostinfo/disk_info_linux.go create mode 100644 core/hostinfo/disk_info_linux_test.go create mode 100644 core/hostinfo/disk_info_other.go create mode 100644 core/ping/local_storage_test.go diff --git a/.github/workflows/codeql-verification.yml b/.github/workflows/codeql-verification.yml index a619b88f..f2a01393 100644 --- a/.github/workflows/codeql-verification.yml +++ b/.github/workflows/codeql-verification.yml @@ -38,10 +38,10 @@ jobs: set -euo pipefail test -z "$(git status --porcelain)" git merge-base --is-ancestor c8663d12dd253ef13258750dca056d4b1219fc10 HEAD - grep -Fq 'VERSION_OVERRIDE: v0.13.28' .github/workflows/validate.yml + grep -Fq 'VERSION_OVERRIDE: v0.13.29' .github/workflows/validate.yml grep -Fq 'SHA256SUMS' scripts/package grep -Fq 'bash scripts/verify-package "$linux_asset"' .github/workflows/validate.yml - grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.28.tar.gz' .github/workflows/security-release-gate.yml + grep -Fq 'bash scripts/verify-package dist/artifacts/node-agent-0.13.29.tar.gz' .github/workflows/security-release-gate.yml - name: Install the release Go toolchain uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -68,7 +68,7 @@ jobs: export GOFLAGS=-mod=vendor mkdir -p "$GITHUB_WORKSPACE/bin" CGO_ENABLED=0 go build -trimpath -tags 'netgo osusergo' \ - -ldflags='-w -s -X main.VERSION=v0.13.28' \ + -ldflags='-w -s -X main.VERSION=v0.13.29' \ -o "$GITHUB_WORKSPACE/bin/node-agent" ./ - name: Analyze without publishing temporary alerts diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index d51e45be..ba2c85e9 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -19,9 +19,9 @@ jobs: timeout-minutes: 120 env: DAPPER_IMAGE: pasturestack/node-agent-dapper:${{ github.sha }} - RUNTIME_IMAGE: pasturestack/node-agent:v0.13.28 + RUNTIME_IMAGE: pasturestack/node-agent:v0.13.29 TRIVY_IMAGE: aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 - VERSION_OVERRIDE: v0.13.28 + VERSION_OVERRIDE: v0.13.29 steps: - name: Check out candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -50,20 +50,20 @@ jobs: set -euo pipefail CROSS=1 VERSION_OVERRIDE="$VERSION_OVERRIDE" make DAPPER_IMAGE="$DAPPER_IMAGE" ci make DAPPER_IMAGE="$DAPPER_IMAGE" IMAGE_NAME=pasturestack/node-agent \ - TAG=v0.13.28 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image - test -s dist/artifacts/node-agent-0.13.28.tar.gz - test -s dist/artifacts/node-agent-0.13.28-windows-amd64.zip + TAG=v0.13.29 VERSION_OVERRIDE="$VERSION_OVERRIDE" package-image + test -s dist/artifacts/node-agent-0.13.29.tar.gz + test -s dist/artifacts/node-agent-0.13.29-windows-amd64.zip test "$(cat dist/image)" = "$RUNTIME_IMAGE" - gzip -t dist/artifacts/node-agent-0.13.28.tar.gz - bash scripts/verify-package dist/artifacts/node-agent-0.13.28.tar.gz - unzip -t dist/artifacts/node-agent-0.13.28-windows-amd64.zip - sha256sum dist/artifacts/node-agent-0.13.28.tar.gz \ - dist/artifacts/node-agent-0.13.28-windows-amd64.zip \ + gzip -t dist/artifacts/node-agent-0.13.29.tar.gz + bash scripts/verify-package dist/artifacts/node-agent-0.13.29.tar.gz + unzip -t dist/artifacts/node-agent-0.13.29-windows-amd64.zip + sha256sum dist/artifacts/node-agent-0.13.29.tar.gz \ + dist/artifacts/node-agent-0.13.29-windows-amd64.zip \ bin/node-agent > evidence/product-artifacts.sha256 docker image inspect "$RUNTIME_IMAGE" > evidence/runtime-image-inspect.json docker run --rm --entrypoint /usr/bin/node-agent "$RUNTIME_IMAGE" --version \ > evidence/runtime-version.txt - grep -Fxq 'node-agent version v0.13.28' evidence/runtime-version.txt + grep -Fxq 'node-agent version v0.13.29' evidence/runtime-version.txt - name: Verify reproducible Linux binary shell: bash diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3e86d9a9..808e8647 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -5,6 +5,15 @@ on: branches: - 'verification/node-agent-*' workflow_dispatch: + inputs: + validation_scope: + description: 'Full CI or focused host-disk tests and native cross-platform packages' + type: choice + required: true + default: full + options: + - full + - host-disk permissions: contents: read @@ -30,37 +39,71 @@ jobs: test "$(git rev-parse HEAD)" = "$SOURCE_SHA" test -z "$(git status --short)" + - name: Install the release Go toolchain for host-disk validation + if: github.event_name == 'workflow_dispatch' && inputs.validation_scope == 'host-disk' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.27.2 + check-latest: false + cache: false + - name: Test and package immutable artifacts id: package env: CROSS: "1" - VERSION_OVERRIDE: v0.13.28 + VERSION_OVERRIDE: v0.13.29 + VALIDATION_SCOPE: ${{ inputs.validation_scope || 'full' }} run: | set -euo pipefail - make ci - linux_asset="dist/artifacts/node-agent-0.13.28.tar.gz" - windows_asset="dist/artifacts/node-agent-0.13.28-windows-amd64.zip" + case "$VALIDATION_SCOPE" in + full) + make ci + ;; + host-disk) + export GOTOOLCHAIN=local GOTELEMETRY=off GOWORK=off + export GOFLAGS=-mod=vendor GOPROXY=off GOSUMDB=off + export GOOS=linux GOARCH=amd64 GOAMD64=v1 CGO_ENABLED=0 + test "$(go version)" = 'go version go1.27.2 linux/amd64' + go test -count=1 -v ./core/hostinfo ./core/ping + bash scripts/build + bash scripts/package + ;; + *) + printf 'Unknown validation scope: %s\n' "$VALIDATION_SCOPE" >&2 + exit 1 + ;; + esac + linux_asset="dist/artifacts/node-agent-0.13.29.tar.gz" + windows_asset="dist/artifacts/node-agent-0.13.29-windows-amd64.zip" test -s "$linux_asset" test -s "$windows_asset" gzip -t "$linux_asset" bash scripts/verify-package "$linux_asset" unzip -t "$windows_asset" + if [ "$VALIDATION_SCOPE" = host-disk ]; then + package_id=$(git rev-parse --short=32 HEAD) + tar -xOzf "$linux_asset" "$package_id/bin/node-agent" | cmp bin/linux/amd64/node-agent - + unzip -p "$windows_asset" pasturestack/node-agent.exe | cmp bin/windows/amd64/node-agent.exe - + unzip -p "$windows_asset" pasturestack/startup_node-agent.ps1 | cmp package/startup_agent.ps1 - + sha256sum bin/linux/amd64/node-agent bin/windows/amd64/node-agent.exe package/startup_agent.ps1 + fi ( cd dist/artifacts sha256sum \ - node-agent-0.13.28.tar.gz \ - node-agent-0.13.28-windows-amd64.zip >SHA256SUMS + node-agent-0.13.29.tar.gz \ + node-agent-0.13.29-windows-amd64.zip >SHA256SUMS + sha256sum -c SHA256SUMS ) - printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s artifacts=linux,windows\n' \ - "$GITHUB_SHA" "$VERSION_OVERRIDE" + printf 'NODE_AGENT_VALIDATION_OK source=%s version=%s scope=%s artifacts=linux,windows\n' \ + "$GITHUB_SHA" "$VERSION_OVERRIDE" "$VALIDATION_SCOPE" - name: Retain reviewed release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: node-agent-0.13.28 + name: node-agent-0.13.29 path: | - dist/artifacts/node-agent-0.13.28.tar.gz - dist/artifacts/node-agent-0.13.28-windows-amd64.zip + dist/artifacts/node-agent-0.13.29.tar.gz + dist/artifacts/node-agent-0.13.29-windows-amd64.zip dist/artifacts/SHA256SUMS if-no-files-found: error retention-days: 30 diff --git a/README.md b/README.md index 046c11a9..fc9e58a8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ From a Docker-capable Linux host: make test make build make package -make package-image IMAGE_NAME=pasturestack/node-agent TAG=poc +make package-image IMAGE_NAME=pasturestack/node-agent TAG=v0.13.29 ``` Packaging is local only and does not push an image. See [COMPATIBILITY.md](COMPATIBILITY.md), [SECURITY.md](SECURITY.md), and [ORIGIN.md](ORIGIN.md). @@ -33,14 +33,22 @@ client 0.5, gopsutil v4.26, mapstructure v2.5, netlink v1.3, and netns v0.0.5. The retired AWS SDK v1, Aliyungo, root Docker module, GOPATH/Godeps, and Trash dependency paths are not part of the build. -`VERSION_OVERRIDE=v0.13.28 make package` produces the deterministic Linux asset `node-agent-0.13.28.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel. +`VERSION_OVERRIDE=v0.13.29 make package` produces the deterministic Linux asset `node-agent-0.13.29.tar.gz`. The archive carries the legacy SHA-1 and current SHA-256 manifests required by the authenticated configcontent installer. The producer release is published by this repository; Server can embed that verified archive and distribute it through the existing configuration channel. On Linux, Node supervises the separately maintained `host-api` producer at `${PASTURESTACK_HOME:-${CATTLE_HOME:-/var/lib/pasturestack}}/bin/host-api`. Host API `0.38.5` must be installed before the Node package by the existing `pyagent` package sequence. Node verifies its exact version, forwards the current agent/host identity only through process environment, restarts an exited child, and binds its lifetime to Node. Missing or incompatible producers never fall back to the embedded legacy Host API. Stream authorization, target binding, and private durable terminal evidence remain owned by the Host API producer. -Windows retains the existing embedded compatibility path; this release does not provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server-distributed Windows artifact remains independently pinned to `0.13.27`; the Linux `0.13.28` candidate and CI cross-builds do not establish new Windows runtime acceptance. +Windows retains the existing embedded compatibility path; this release does not provide Windows stream delegation or durable terminal evidence support. A replacement Windows bootstrap image and upgrade/rollback tests are still required before Windows hosts are supported. The Server-distributed Windows artifact remains independently pinned to `0.13.27`; Linux `0.13.29` and CI cross-builds do not establish new Windows runtime acceptance. The `host.port.check` event performs a read-only host-port preflight through the existing agent event channel. It reports Docker bindings from running and stopped containers and, on Linux, listening TCP/UDP sockets visible through the existing host `/proc` mount. Incomplete host socket inspection is reported as unknown; it is never presented as an available port. +Linux disk information reads filesystem usage through the root of the process +whose mounts are enumerated, including when the agent has a separate mount +namespace. Direct host execution retains direct paths. Read-only Snap filesystem +images are excluded from disk capacity; an unreadable mount reports a collection +warning while preserving other successfully collected filesystems and mount points. +Host `localStorageMb` reports available space in whole MiB, not total disk capacity; +`CATTLE_LOCAL_STORAGE_MB_OVERRIDE` retains priority over the measured value. + The test harness starts a disposable inner Docker daemon and generates its BusyBox image, build contexts, and Git fixtures locally. It does not mount the host Docker socket or depend on mutable registry images and retired external test endpoints. ## License and attribution diff --git a/core/hostinfo/disk.go b/core/hostinfo/disk.go index 23e4452f..19cf23bc 100644 --- a/core/hostinfo/disk.go +++ b/core/hostinfo/disk.go @@ -21,20 +21,12 @@ func (d DiskCollector) GetData() (map[string]interface{}, error) { "dockerStorageDriver": infoData.Info.Driver, } - mfs, err := d.getMachineFilesystems(infoData) + fileSystems, mountPoints, err := d.collectDiskInfo(infoData) + data["fileSystems"] = fileSystems + data["mountPoints"] = mountPoints if err != nil { return data, errors.Wrap(err, constants.DiskGetDataError+"failed get filesystem info") } - for key, value := range mfs { - data["fileSystems"].(map[string]interface{})[key] = value - } - mp, err := d.getMountPoints() - if err != nil { - return data, errors.Wrap(err, constants.DiskGetDataError+"failed get mountpoint info") - } - for key, value := range mp { - data["mountPoints"].(map[string]interface{})[key] = value - } return data, nil } diff --git a/core/hostinfo/disk_info.go b/core/hostinfo/disk_info.go index 089edf32..a10025e3 100755 --- a/core/hostinfo/disk_info.go +++ b/core/hostinfo/disk_info.go @@ -1,11 +1,14 @@ package hostinfo import ( + "errors" + "math" + "path/filepath" + "strings" + "github.com/PastureStack/node-agent/model" "github.com/PastureStack/node-agent/utilities/utils" "github.com/shirou/gopsutil/v4/disk" - "math" - "strings" ) func (d DiskCollector) convertUnits(number uint64) float64 { @@ -42,43 +45,40 @@ func (d DiskCollector) includeInFilesystem(infoData model.InfoData, device strin return include } -func (d DiskCollector) getMountPoints() (map[string]interface{}, error) { - data := map[string]interface{}{} +func (d DiskCollector) collectDiskInfo(infoData model.InfoData) (map[string]interface{}, map[string]interface{}, error) { + fileSystems := map[string]interface{}{} + mountPoints := map[string]interface{}{} partitions, err := disk.Partitions(false) if err != nil { - return data, err + return fileSystems, mountPoints, err } + root := diskUsageRoot() + var usageErrors []error for _, partition := range partitions { - usage, err := disk.Usage(partition.Mountpoint) + // Snap packages are read-only filesystem images, not host disk capacity. + if partition.Fstype == "squashfs" { + continue + } + mountpoint := partition.Mountpoint + if root != "" { + mountpoint = filepath.Join(root, mountpoint) + } + usage, err := disk.Usage(mountpoint) if err != nil { - return map[string]interface{}{}, err + usageErrors = append(usageErrors, err) + continue } - data[partition.Device] = map[string]interface{}{ + mountPoints[partition.Device] = map[string]interface{}{ "free": d.convertUnits(usage.Free), "total": d.convertUnits(usage.Total), "used": d.convertUnits(usage.Used), "percentage": usage.UsedPercent, } - } - return data, nil -} - -func (d DiskCollector) getMachineFilesystems(infoData model.InfoData) (map[string]interface{}, error) { - data := map[string]interface{}{} - partitions, err := disk.Partitions(false) - if err != nil { - return data, err - } - for _, partition := range partitions { if d.includeInFilesystem(infoData, partition.Device) { - usage, err := disk.Usage(partition.Mountpoint) - if err != nil { - return map[string]interface{}{}, err - } - data[utils.InterfaceToString(partition.Device)] = map[string]interface{}{ + fileSystems[partition.Device] = map[string]interface{}{ "capacity": d.convertUnits(usage.Total), } } } - return data, nil + return fileSystems, mountPoints, errors.Join(usageErrors...) } diff --git a/core/hostinfo/disk_info_linux.go b/core/hostinfo/disk_info_linux.go new file mode 100644 index 00000000..eb29fb93 --- /dev/null +++ b/core/hostinfo/disk_info_linux.go @@ -0,0 +1,51 @@ +//go:build linux + +package hostinfo + +import ( + "errors" + "os" + "path/filepath" +) + +// gopsutil enumerates /proc/1's mounts by default, even when the agent has +// another mount namespace. Resolve usage through that same process's root. +func diskUsageRoot() string { + proc := os.Getenv("HOST_PROC") + if proc == "" { + proc = "/proc" + } + process := filepath.Join(proc, "1") + if override := os.Getenv("HOST_PROC_MOUNTINFO"); override != "" { + process = filepath.Dir(override) + } else if !hasDiskMountTable(process) { + // Match gopsutil's fallback when PID 1's mount table is unavailable. + process = filepath.Join(proc, "self") + } + + hostNamespace, hostErr := os.Stat(filepath.Join(process, "ns", "mnt")) + selfNamespace, selfErr := os.Stat(filepath.Join(proc, "self", "ns", "mnt")) + if hostErr == nil && selfErr == nil && os.SameFile(hostNamespace, selfNamespace) { + // Preserve direct host operation without requiring access to PID 1's root. + return "" + } + return filepath.Join(process, "root") +} + +func hasDiskMountTable(process string) bool { + return hasDiskMountTableWithOpen(process, os.Open) +} + +func hasDiskMountTableWithOpen(process string, openFile func(string) (*os.File, error)) bool { + file, err := openFile(filepath.Join(process, "mountinfo")) + var pathErr *os.PathError + if errors.As(err, &pathErr) { + // Match gopsutil for every open path error, including permission errors. + file, err = openFile(filepath.Join(process, "mounts")) + } + if err != nil { + return false + } + file.Close() + return true +} diff --git a/core/hostinfo/disk_info_linux_test.go b/core/hostinfo/disk_info_linux_test.go new file mode 100644 index 00000000..321a3434 --- /dev/null +++ b/core/hostinfo/disk_info_linux_test.go @@ -0,0 +1,203 @@ +//go:build linux + +package hostinfo + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "syscall" + "testing" +) + +func diskFixture(t *testing.T, sameNamespace bool) (string, string) { + t.Helper() + root := t.TempDir() + proc := filepath.Join(root, "proc") + hostRoot := filepath.Join(root, "host-root") + for _, dir := range []string{hostRoot, filepath.Join(proc, "1", "ns"), filepath.Join(proc, "self", "ns")} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + writeDiskFixture(t, filepath.Join(proc, "filesystems"), "ext4\nsquashfs\n") + selfNamespace := filepath.Join(proc, "self", "ns", "mnt") + writeDiskFixture(t, selfNamespace, "self") + if sameNamespace { + if err := os.Link(selfNamespace, filepath.Join(proc, "1", "ns", "mnt")); err != nil { + t.Fatal(err) + } + } else { + writeDiskFixture(t, filepath.Join(proc, "1", "ns", "mnt"), "host") + } + if err := os.Symlink(hostRoot, filepath.Join(proc, "1", "root")); err != nil { + t.Fatal(err) + } + t.Setenv("HOST_PROC", proc) + t.Setenv("HOST_PROC_MOUNTINFO", "") + return proc, hostRoot +} + +func writeDiskFixture(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } +} + +func TestDiskCollectorUsesEnumeratedHostRoot(t *testing.T) { + proc, hostRoot := diskFixture(t, false) + mountpoint := "/pasturestack-host-only-" + filepath.Base(hostRoot) + if err := os.Mkdir(filepath.Join(hostRoot, mountpoint), 0700); err != nil { + t.Fatal(err) + } + writeDiskFixture(t, filepath.Join(proc, "1", "mountinfo"), + fmt.Sprintf("1 0 8:1 / %s rw - ext4 /dev/disk-a rw\n", mountpoint)) + + data, err := (DiskCollector{Unit: 1048576}).GetData() + if err != nil { + t.Fatal(err) + } + filesystems := data["fileSystems"].(map[string]interface{}) + if len(filesystems) != 1 || filesystems["/dev/disk-a"].(map[string]interface{})["capacity"].(float64) <= 0 { + t.Fatalf("host-only mount capacity missing: %v", filesystems) + } + if got := diskUsageRoot(); got != filepath.Join(proc, "1", "root") { + t.Fatalf("wrong host root: %s", got) + } +} + +func TestDiskCollectorPreservesPartialDataAndSkipsSnapCapacity(t *testing.T) { + proc, hostRoot := diskFixture(t, false) + if err := os.MkdirAll(filepath.Join(hostRoot, "snap", "core24", "1643"), 0700); err != nil { + t.Fatal(err) + } + writeDiskFixture(t, filepath.Join(proc, "1", "mountinfo"), + "1 0 7:0 / /snap/core24/1643 ro - squashfs /dev/loop0 ro\n"+ + "2 0 8:2 / /missing rw - ext4 /dev/disk-missing rw\n"+ + "3 0 8:1 / / rw - ext4 /dev/disk-a rw\n") + + data, err := (DiskCollector{Unit: 1048576}).GetData() + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("partial collection must report the unreadable mount: %v", err) + } + for _, field := range []string{"fileSystems", "mountPoints"} { + entries := data[field].(map[string]interface{}) + if len(entries) != 1 || entries["/dev/disk-a"] == nil { + t.Fatalf("%s lost valid data or counted Snap: %v", field, entries) + } + } +} + +func TestDiskCollectorPreservesDirectHostMode(t *testing.T) { + proc, _ := diskFixture(t, true) + mountpoint := t.TempDir() + writeDiskFixture(t, filepath.Join(proc, "1", "mountinfo"), + fmt.Sprintf("1 0 8:1 / %s rw - ext4 /dev/disk-a rw\n", mountpoint)) + if err := os.Remove(filepath.Join(proc, "1", "root")); err != nil { + t.Fatal(err) + } + + data, err := (DiskCollector{Unit: 1048576}).GetData() + if err != nil || len(data["fileSystems"].(map[string]interface{})) != 1 { + t.Fatalf("direct host mode must not need PID 1 root access: %v %v", data, err) + } + if got := diskUsageRoot(); got != "" { + t.Fatalf("direct host mode unexpectedly rebased: %s", got) + } +} + +func TestDiskUsageRootHonorsMountInfoOverrideAndSelfFallback(t *testing.T) { + proc, _ := diskFixture(t, false) + writeDiskFixture(t, filepath.Join(proc, "self", "mountinfo"), "") + if got := diskUsageRoot(); got != "" { + t.Fatalf("missing PID 1 table must use self: %s", got) + } + + custom := filepath.Join(proc, "custom") + if err := os.MkdirAll(filepath.Join(custom, "ns"), 0700); err != nil { + t.Fatal(err) + } + writeDiskFixture(t, filepath.Join(custom, "ns", "mnt"), "custom") + t.Setenv("HOST_PROC_MOUNTINFO", filepath.Join(custom, "mountinfo")) + if got := diskUsageRoot(); got != filepath.Join(custom, "root") { + t.Fatalf("override root differs from enumerated process: %s", got) + } +} + +func TestDiskMountTableFallbackMatchesGopsutilPathErrors(t *testing.T) { + table := filepath.Join(t.TempDir(), "table") + writeDiskFixture(t, table, "") + for _, test := range []struct { + name string + mountInfoErr error + mountsErr error + want bool + wantFiles []string + }{ + { + name: "permission denied falls back to mounts", + mountInfoErr: &os.PathError{Op: "open", Path: "mountinfo", Err: syscall.EACCES}, + want: true, + wantFiles: []string{"mountinfo", "mounts"}, + }, + { + name: "both tables unreadable stay unavailable", + mountInfoErr: &os.PathError{Op: "open", Path: "mountinfo", Err: syscall.EACCES}, + mountsErr: &os.PathError{Op: "open", Path: "mounts", Err: syscall.EACCES}, + wantFiles: []string{"mountinfo", "mounts"}, + }, + { + name: "non path error does not read mounts", + mountInfoErr: errors.New("read failed"), + wantFiles: []string{"mountinfo"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + var files []string + openFile := func(path string) (*os.File, error) { + name := filepath.Base(path) + files = append(files, name) + err := test.mountsErr + if name == "mountinfo" { + err = test.mountInfoErr + } + if err != nil { + return nil, err + } + return os.Open(table) + } + if got := hasDiskMountTableWithOpen("/proc/1", openFile); got != test.want { + t.Fatalf("mount table availability = %v, want %v", got, test.want) + } + if !reflect.DeepEqual(files, test.wantFiles) { + t.Fatalf("read tables %v, want %v", files, test.wantFiles) + } + }) + } +} + +func TestDiskCollectorUsesHostMountsAfterMountInfoPathError(t *testing.T) { + proc, hostRoot := diskFixture(t, false) + notDirectory := filepath.Join(proc, "not-a-directory") + writeDiskFixture(t, notDirectory, "") + if err := os.Symlink(filepath.Join(notDirectory, "mountinfo"), filepath.Join(proc, "1", "mountinfo")); err != nil { + t.Fatal(err) + } + mountpoint := "/pasturestack-host-mounts-only" + if err := os.Mkdir(filepath.Join(hostRoot, mountpoint), 0700); err != nil { + t.Fatal(err) + } + writeDiskFixture(t, filepath.Join(proc, "1", "mounts"), + fmt.Sprintf("/dev/disk-a %s ext4 rw 0 0\n", mountpoint)) + + data, err := (DiskCollector{Unit: 1048576}).GetData() + if err != nil || len(data["fileSystems"].(map[string]interface{})) != 1 { + t.Fatalf("mounts fallback lost the host root view: %v %v", data, err) + } + if got := diskUsageRoot(); got != filepath.Join(proc, "1", "root") { + t.Fatalf("mounts fallback selected a different namespace: %s", got) + } +} diff --git a/core/hostinfo/disk_info_other.go b/core/hostinfo/disk_info_other.go new file mode 100644 index 00000000..bcd774dc --- /dev/null +++ b/core/hostinfo/disk_info_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package hostinfo + +func diskUsageRoot() string { + return "" +} diff --git a/core/ping/local_storage_test.go b/core/ping/local_storage_test.go new file mode 100644 index 00000000..63686dee --- /dev/null +++ b/core/ping/local_storage_test.go @@ -0,0 +1,25 @@ +package ping + +import "testing" + +func TestLocalStorageMiB(t *testing.T) { + const mib = uint64(1024 * 1024) + for _, test := range []struct { + name string + freeBytes uint64 + want uint64 + }{ + {name: "zero"}, + {name: "less than one MiB", freeBytes: mib - 1}, + {name: "one MiB", freeBytes: mib, want: 1}, + {name: "one GiB", freeBytes: 1024 * mib, want: 1024}, + {name: "fraction floors", freeBytes: 3*mib + mib/2, want: 3}, + {name: "maximum uint64", freeBytes: ^uint64(0), want: (uint64(1) << 44) - 1}, + } { + t.Run(test.name, func(t *testing.T) { + if got := localStorageMiB(test.freeBytes); got != test.want { + t.Fatalf("%d free bytes reported as %d MiB, want %d", test.freeBytes, got, test.want) + } + }) + } +} diff --git a/core/ping/ping_common.go b/core/ping/ping_common.go index ad78bf7e..1fa91493 100755 --- a/core/ping/ping_common.go +++ b/core/ping/ping_common.go @@ -100,7 +100,7 @@ func addResource(ping *revents.Event, pong *model.PingResponse, dockerClient *cl if err != nil { log.Errorf("Error getting local storage usage: %v", err) } else { - compute.LocalStorageMb = uint64(usage.Free) / 1000 + compute.LocalStorageMb = localStorageMiB(usage.Free) } } @@ -129,6 +129,10 @@ func addResource(ping *revents.Event, pong *model.PingResponse, dockerClient *cl return nil } +func localStorageMiB(freeBytes uint64) uint64 { + return freeBytes / (1024 * 1024) +} + func getResourceOverride(envVar string) uint64 { var resource uint64 var err error diff --git a/scripts/version b/scripts/version index 8e66482f..7673a8bd 100755 --- a/scripts/version +++ b/scripts/version @@ -5,4 +5,4 @@ if [ -n "$VERSION_OVERRIDE" ]; then return 0 fi -VERSION=v0.13.28 +VERSION=v0.13.29 From 514a3c1605809628693430007b30a33f4b1efbb4 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Tue, 20 Oct 2026 18:56:06 +0800 Subject: [PATCH 2/2] fix: read confined namespace identifiers without following magic links --- .github/workflows/validate.yml | 1 + core/hostinfo/disk_info_linux.go | 37 ++++++++++++- core/hostinfo/disk_info_linux_test.go | 80 ++++++++++++++++++++++++--- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 808e8647..7a596330 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -64,6 +64,7 @@ jobs: export GOFLAGS=-mod=vendor GOPROXY=off GOSUMDB=off export GOOS=linux GOARCH=amd64 GOAMD64=v1 CGO_ENABLED=0 test "$(go version)" = 'go version go1.27.2 linux/amd64' + test -z "$(gofmt -l core/hostinfo/disk_info_linux.go core/hostinfo/disk_info_linux_test.go)" go test -count=1 -v ./core/hostinfo ./core/ping bash scripts/build bash scripts/package diff --git a/core/hostinfo/disk_info_linux.go b/core/hostinfo/disk_info_linux.go index eb29fb93..50befe12 100644 --- a/core/hostinfo/disk_info_linux.go +++ b/core/hostinfo/disk_info_linux.go @@ -6,6 +6,8 @@ import ( "errors" "os" "path/filepath" + "strconv" + "strings" ) // gopsutil enumerates /proc/1's mounts by default, even when the agent has @@ -23,15 +25,44 @@ func diskUsageRoot() string { process = filepath.Join(proc, "self") } - hostNamespace, hostErr := os.Stat(filepath.Join(process, "ns", "mnt")) - selfNamespace, selfErr := os.Stat(filepath.Join(proc, "self", "ns", "mnt")) - if hostErr == nil && selfErr == nil && os.SameFile(hostNamespace, selfNamespace) { + hostNamespace, hostErr := diskMountNamespace(process) + selfNamespace, selfErr := diskMountNamespace(filepath.Join(proc, "self")) + if hostErr == nil && selfErr == nil && hostNamespace == selfNamespace { // Preserve direct host operation without requiring access to PID 1's root. return "" } return filepath.Join(process, "root") } +// The configured proc directory is operator-owned, like gopsutil's mount-table +// source. Read only the fixed namespace link within it; its target is an +// identifier, never a filesystem path to follow (proc namespace links are magic). +func diskMountNamespace(process string) (uint64, error) { + directory, err := os.OpenRoot(process) + if err != nil { + return 0, err + } + defer directory.Close() + identifier, err := directory.Readlink("ns/mnt") + if err != nil { + return 0, err + } + if !strings.HasPrefix(identifier, "mnt:[") || !strings.HasSuffix(identifier, "]") { + return 0, os.ErrInvalid + } + value := identifier[len("mnt:[") : len(identifier)-1] + for _, digit := range value { + if digit < '0' || digit > '9' { + return 0, os.ErrInvalid + } + } + namespace, err := strconv.ParseUint(value, 10, 64) + if err != nil || namespace == 0 { + return 0, os.ErrInvalid + } + return namespace, nil +} + func hasDiskMountTable(process string) bool { return hasDiskMountTableWithOpen(process, os.Open) } diff --git a/core/hostinfo/disk_info_linux_test.go b/core/hostinfo/disk_info_linux_test.go index 321a3434..655b14f3 100644 --- a/core/hostinfo/disk_info_linux_test.go +++ b/core/hostinfo/disk_info_linux_test.go @@ -24,13 +24,15 @@ func diskFixture(t *testing.T, sameNamespace bool) (string, string) { } writeDiskFixture(t, filepath.Join(proc, "filesystems"), "ext4\nsquashfs\n") selfNamespace := filepath.Join(proc, "self", "ns", "mnt") - writeDiskFixture(t, selfNamespace, "self") + if err := os.Symlink("mnt:[1]", selfNamespace); err != nil { + t.Fatal(err) + } + hostNamespace := "mnt:[2]" if sameNamespace { - if err := os.Link(selfNamespace, filepath.Join(proc, "1", "ns", "mnt")); err != nil { - t.Fatal(err) - } - } else { - writeDiskFixture(t, filepath.Join(proc, "1", "ns", "mnt"), "host") + hostNamespace = "mnt:[1]" + } + if err := os.Symlink(hostNamespace, filepath.Join(proc, "1", "ns", "mnt")); err != nil { + t.Fatal(err) } if err := os.Symlink(hostRoot, filepath.Join(proc, "1", "root")); err != nil { t.Fatal(err) @@ -120,13 +122,77 @@ func TestDiskUsageRootHonorsMountInfoOverrideAndSelfFallback(t *testing.T) { if err := os.MkdirAll(filepath.Join(custom, "ns"), 0700); err != nil { t.Fatal(err) } - writeDiskFixture(t, filepath.Join(custom, "ns", "mnt"), "custom") + if err := os.Symlink("mnt:[3]", filepath.Join(custom, "ns", "mnt")); err != nil { + t.Fatal(err) + } t.Setenv("HOST_PROC_MOUNTINFO", filepath.Join(custom, "mountinfo")) if got := diskUsageRoot(); got != filepath.Join(custom, "root") { t.Fatalf("override root differs from enumerated process: %s", got) } } +func TestDiskNamespaceReadsOnlyAnIdentifier(t *testing.T) { + for _, target := range []string{"/etc/passwd", "../other/ns/mnt", "mnt:[1]/../../other", "mnt:[+1]", "mnt:[-1]", "mnt:[]", "mnt:[0]", "mnt:[18446744073709551616]", "mnt:[1]\n"} { + t.Run(target, func(t *testing.T) { + process := t.TempDir() + if err := os.Mkdir(filepath.Join(process, "ns"), 0700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(process, "ns", "mnt")); err != nil { + t.Fatal(err) + } + if _, err := diskMountNamespace(process); !errors.Is(err, os.ErrInvalid) { + t.Fatalf("non-namespace link must be rejected, got %v", err) + } + }) + } +} + +func TestDiskNamespaceReadFailureDoesNotAssumeSameNamespace(t *testing.T) { + proc, _ := diskFixture(t, false) + writeDiskFixture(t, filepath.Join(proc, "1", "mountinfo"), "") + if err := os.Remove(filepath.Join(proc, "1", "ns", "mnt")); err != nil { + t.Fatal(err) + } + if got := diskUsageRoot(); got != filepath.Join(proc, "1", "root") { + t.Fatalf("unavailable namespace must keep the enumerated process root: %s", got) + } +} + +func TestDiskNamespaceSupportsRelativeProcSelfAlias(t *testing.T) { + proc, _ := diskFixture(t, true) + writeDiskFixture(t, filepath.Join(proc, "1", "mountinfo"), "") + if err := os.Rename(filepath.Join(proc, "self"), filepath.Join(proc, "123")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("123", filepath.Join(proc, "self")); err != nil { + t.Fatal(err) + } + if got := diskUsageRoot(); got != "" { + t.Fatalf("a proc self alias in the same namespace must retain direct paths: %s", got) + } +} + +func TestDiskNamespaceSupportsRealProcMagicLinks(t *testing.T) { + if _, err := diskMountNamespace("/proc/self"); err != nil { + t.Fatalf("the current process namespace must be readable without following its magic target: %v", err) + } +} + +func TestDiskNamespaceRejectsParentSymlinkEscape(t *testing.T) { + process := t.TempDir() + outside := t.TempDir() + if err := os.Symlink("mnt:[1]", filepath.Join(outside, "mnt")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(process, "ns")); err != nil { + t.Fatal(err) + } + if _, err := diskMountNamespace(process); err == nil { + t.Fatal("a namespace directory outside the selected process root must not be read") + } +} + func TestDiskMountTableFallbackMatchesGopsutilPathErrors(t *testing.T) { table := filepath.Join(t.TempDir(), "table") writeDiskFixture(t, table, "")