From 7cf956afbf9fa5c8fc95b06d88a308c7da91b934 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 16 Jul 2026 19:27:40 -0400 Subject: [PATCH 01/10] Implement Darwin (macOS) support Split Linux-only syscalls into _linux.go/_darwin.go file pairs: - ufs: openat2 (ENOSYS stub on Darwin), fdPath via F_GETPATH fcntl, Chtimesat without UTIME_OMIT, Getdirentries-based getdents, and path-based dirent Info/Open (Getdirentries corrupts the stored dirfd) - config: UseOpenat2 probe moved to a Linux file with a Darwin stub - server/filesystem: Darwin CTime; non-Linux stub for the quotas package - system: report macOS without reading os-release - environment: never set BlkioWeight on non-Linux hosts Resolve symlinks in UnixFS base paths (/var -> /private/var on macOS) and use AT_FDCWD instead of AT_EMPTY_PATH as the safePath dirfd. Also fixes a latent bug where root-level dirents got the parent directory's name as their path; harmless on Linux (which stats via dirfd+name) but exposed by Darwin's path-based dirent methods. --- config/config.go | 51 ++-- config/config_openat_darwin.go | 7 + config/config_openat_linux.go | 41 ++++ config/config_openat_linux_test.go | 26 ++ config/config_openat_test.go | 29 +++ environment/settings.go | 6 + internal/ufs/file.go | 4 +- internal/ufs/file_darwin.go | 4 + internal/ufs/file_linux.go | 5 + internal/ufs/fs_darwin.go | 61 +++++ internal/ufs/fs_linux.go | 72 ++++++ internal/ufs/fs_platform_test.go | 142 +++++++++++ internal/ufs/fs_unix.go | 90 ++----- internal/ufs/fs_unix_test.go | 5 +- internal/ufs/readdirmap_test.go | 255 ++++++++++++++++++++ internal/ufs/walk_darwin.go | 18 ++ internal/ufs/walk_dirent_darwin.go | 21 ++ internal/ufs/walk_dirent_linux.go | 15 ++ internal/ufs/walk_linux.go | 33 +++ internal/ufs/walk_unix.go | 53 +--- server/filesystem/filesystem_test.go | 4 + server/filesystem/quotas/functions_other.go | 36 +++ server/filesystem/stat_darwin.go | 22 ++ server/filesystem/stat_test.go | 72 ++++++ system/system.go | 26 +- 25 files changed, 934 insertions(+), 164 deletions(-) create mode 100644 config/config_openat_darwin.go create mode 100644 config/config_openat_linux.go create mode 100644 config/config_openat_linux_test.go create mode 100644 config/config_openat_test.go create mode 100644 internal/ufs/file_darwin.go create mode 100644 internal/ufs/file_linux.go create mode 100644 internal/ufs/fs_darwin.go create mode 100644 internal/ufs/fs_linux.go create mode 100644 internal/ufs/fs_platform_test.go create mode 100644 internal/ufs/readdirmap_test.go create mode 100644 internal/ufs/walk_darwin.go create mode 100644 internal/ufs/walk_dirent_darwin.go create mode 100644 internal/ufs/walk_dirent_linux.go create mode 100644 internal/ufs/walk_linux.go create mode 100644 server/filesystem/quotas/functions_other.go create mode 100644 server/filesystem/stat_darwin.go create mode 100644 server/filesystem/stat_test.go diff --git a/config/config.go b/config/config.go index c1e936ac..904095a0 100644 --- a/config/config.go +++ b/config/config.go @@ -11,9 +11,9 @@ import ( "path" "path/filepath" "regexp" + "runtime" "strings" "sync" - "sync/atomic" "text/template" "time" @@ -22,7 +22,6 @@ import ( "github.com/apex/log" "github.com/creasty/defaults" "github.com/gbrlsnchs/jwt/v3" - "golang.org/x/sys/unix" "gopkg.in/yaml.v2" "github.com/pelican-dev/wings/system" @@ -523,6 +522,18 @@ func EnsurePelicanUser() error { return err } + // macOS doesn't have useradd, use the current user like rootless mode. + if sysName == "darwin" { + u, err := user.Current() + if err != nil { + return err + } + _config.System.Username = u.Username + _config.System.User.Uid = system.MustInt(u.Uid) + _config.System.User.Gid = system.MustInt(u.Gid) + return nil + } + // Our way of detecting if wings is running inside of Docker. if sysName == "distroless" { _config.System.Username = system.FirstNotEmpty(os.Getenv("WINGS_USERNAME"), "pelican") @@ -811,6 +822,9 @@ func ConfigureTimezone() error { // Gets the system release name. func getSystemName() (string, error) { + if runtime.GOOS == "darwin" { + return "darwin", nil + } // use osrelease to get release version and ID release, err := osrelease.Read() if err != nil { @@ -819,39 +833,6 @@ func getSystemName() (string, error) { return release["ID"], nil } -var ( - openat2 atomic.Bool - openat2Set atomic.Bool -) - -func UseOpenat2() bool { - if openat2Set.Load() { - return openat2.Load() - } - defer openat2Set.Store(true) - - c := Get() - openatMode := c.System.OpenatMode - switch openatMode { - case "openat2": - openat2.Store(true) - return true - case "openat": - openat2.Store(false) - return false - default: - fd, err := unix.Openat2(unix.AT_FDCWD, "/", &unix.OpenHow{}) - if err != nil { - log.WithError(err).Warn("error occurred while checking for openat2 support, falling back to openat") - openat2.Store(false) - return false - } - _ = unix.Close(fd) - openat2.Store(true) - return true - } -} - // Expand expands an input string by calling [os.ExpandEnv] to expand all // environment variables, then checks if the value is prefixed with `file://` // to support reading the value from a file. diff --git a/config/config_openat_darwin.go b/config/config_openat_darwin.go new file mode 100644 index 00000000..6d2b2d35 --- /dev/null +++ b/config/config_openat_darwin.go @@ -0,0 +1,7 @@ +package config + +// UseOpenat2 always returns false on Darwin as the openat2 syscall is +// Linux-specific (kernel 5.6+). +func UseOpenat2() bool { + return false +} diff --git a/config/config_openat_linux.go b/config/config_openat_linux.go new file mode 100644 index 00000000..e99642ce --- /dev/null +++ b/config/config_openat_linux.go @@ -0,0 +1,41 @@ +package config + +import ( + "sync/atomic" + + "github.com/apex/log" + "golang.org/x/sys/unix" +) + +var ( + openat2 atomic.Bool + openat2Set atomic.Bool +) + +func UseOpenat2() bool { + if openat2Set.Load() { + return openat2.Load() + } + defer openat2Set.Store(true) + + c := Get() + openatMode := c.System.OpenatMode + switch openatMode { + case "openat2": + openat2.Store(true) + return true + case "openat": + openat2.Store(false) + return false + default: + fd, err := unix.Openat2(unix.AT_FDCWD, "/", &unix.OpenHow{}) + if err != nil { + log.WithError(err).Warn("error occurred while checking for openat2 support, falling back to openat") + openat2.Store(false) + return false + } + _ = unix.Close(fd) + openat2.Store(true) + return true + } +} diff --git a/config/config_openat_linux_test.go b/config/config_openat_linux_test.go new file mode 100644 index 00000000..b35fc6b3 --- /dev/null +++ b/config/config_openat_linux_test.go @@ -0,0 +1,26 @@ +package config + +import "testing" + +func TestUseOpenat2ConfigOverride(t *testing.T) { + Set(&Configuration{ + AuthenticationToken: "test", + System: SystemConfiguration{ + OpenatMode: "openat", + }, + }) + openat2Set.Store(false) + + if UseOpenat2() { + t.Error("expected UseOpenat2() to return false when mode is 'openat'") + } + + openat2Set.Store(false) + Update(func(c *Configuration) { + c.System.OpenatMode = "openat2" + }) + + if !UseOpenat2() { + t.Error("expected UseOpenat2() to return true when mode is 'openat2'") + } +} diff --git a/config/config_openat_test.go b/config/config_openat_test.go new file mode 100644 index 00000000..ccf010e3 --- /dev/null +++ b/config/config_openat_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "runtime" + "testing" +) + +func TestUseOpenat2(t *testing.T) { + // Ensure UseOpenat2 doesn't panic. + Set(&Configuration{ + AuthenticationToken: "test", + System: SystemConfiguration{ + OpenatMode: "auto", + }, + }) + + result := UseOpenat2() + + switch runtime.GOOS { + case "darwin": + if result { + t.Error("expected UseOpenat2() to return false on Darwin") + } + case "linux": + // On Linux it may be true or false depending on kernel version. + // Just verify it returns without error. + t.Logf("UseOpenat2() returned %v on Linux", result) + } +} diff --git a/environment/settings.go b/environment/settings.go index 0b925a76..421665ef 100644 --- a/environment/settings.go +++ b/environment/settings.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "os" + "runtime" "strconv" "github.com/apex/log" @@ -145,6 +146,11 @@ func (l Limits) AsContainerResources() container.Resources { // container block IO weight. On cgroup v2 the io.weight knob must be present or // runc fails container creation; cgroup v1/hybrid always supports it. func blkioWeightSupported() bool { + // Only Linux has a cgroup hierarchy; on other platforms (e.g. macOS with + // Docker Desktop) setting the weight is rejected by the daemon. + if runtime.GOOS != "linux" { + return false + } // cgroup v1/hybrid honors the weight via blkio.weight; only v2 needs probing. if _, err := os.Stat("/sys/fs/cgroup/cgroup.controllers"); err != nil { return true diff --git a/internal/ufs/file.go b/internal/ufs/file.go index 9902c8b6..3b0eaea0 100644 --- a/internal/ufs/file.go +++ b/internal/ufs/file.go @@ -169,12 +169,10 @@ const ( O_DIRECTORY = unix.O_DIRECTORY // O_NOFOLLOW opens the exact path given without following symlinks. O_NOFOLLOW = unix.O_NOFOLLOW - O_CLOEXEC = unix.O_CLOEXEC - O_LARGEFILE = unix.O_LARGEFILE + O_CLOEXEC = unix.O_CLOEXEC ) const ( AT_SYMLINK_NOFOLLOW = unix.AT_SYMLINK_NOFOLLOW AT_REMOVEDIR = unix.AT_REMOVEDIR - AT_EMPTY_PATH = unix.AT_EMPTY_PATH ) diff --git a/internal/ufs/file_darwin.go b/internal/ufs/file_darwin.go new file mode 100644 index 00000000..405ec1ec --- /dev/null +++ b/internal/ufs/file_darwin.go @@ -0,0 +1,4 @@ +package ufs + +// O_LARGEFILE is a no-op on Darwin as all files support large offsets. +const O_LARGEFILE = 0 diff --git a/internal/ufs/file_linux.go b/internal/ufs/file_linux.go new file mode 100644 index 00000000..57d2a429 --- /dev/null +++ b/internal/ufs/file_linux.go @@ -0,0 +1,5 @@ +package ufs + +import "golang.org/x/sys/unix" + +const O_LARGEFILE = unix.O_LARGEFILE diff --git a/internal/ufs/fs_darwin.go b/internal/ufs/fs_darwin.go new file mode 100644 index 00000000..856807c0 --- /dev/null +++ b/internal/ufs/fs_darwin.go @@ -0,0 +1,61 @@ +package ufs + +import ( + "bytes" + "path/filepath" + "runtime" + "time" + "unsafe" + + "golang.org/x/sys/unix" +) + +// fdPath returns the filesystem path associated with a file descriptor using +// the F_GETPATH fcntl command on macOS. +func fdPath(fd int) (string, error) { + buf := make([]byte, unix.PathMax) + _, _, errno := unix.Syscall(unix.SYS_FCNTL, uintptr(fd), uintptr(unix.F_GETPATH), uintptr(unsafe.Pointer(&buf[0]))) + runtime.KeepAlive(buf) + if errno != 0 { + return "", errno + } + n := bytes.IndexByte(buf, 0) + if n < 0 { + n = len(buf) + } + return filepath.EvalSymlinks(string(buf[:n])) +} + +// _openat2 is a stub on Darwin. The openat2 syscall is Linux-specific (kernel +// 5.6+). On Darwin, this always returns ENOSYS to signal that the caller +// should fall back to the regular openat path. +func (fs *UnixFS) _openat2(dirfd int, name string, flag, mode uint64) (int, error) { + return 0, unix.ENOSYS +} + +// Chtimesat is like Chtimes but allows passing an existing directory file +// descriptor rather than needing to resolve one. On Darwin, UTIME_OMIT is not +// available, so zero times are handled by reading the current timestamps and +// preserving them. +func (fs *UnixFS) Chtimesat(dirfd int, name string, atime, mtime time.Time) error { + if atime.IsZero() || mtime.IsZero() { + var st unix.Stat_t + if err := unix.Fstatat(dirfd, name, &st, 0); err != nil { + return ensurePathError(err, "chtimes", name) + } + // Atim/Mtim require golang.org/x/sys >= v0.35, which renamed Darwin's + // Atimespec/Mtimespec fields to match the Linux names. + if atime.IsZero() { + atime = time.Unix(st.Atim.Unix()) + } + if mtime.IsZero() { + mtime = time.Unix(st.Mtim.Unix()) + } + } + + utimes := [2]unix.Timespec{ + unix.NsecToTimespec(atime.UnixNano()), + unix.NsecToTimespec(mtime.UnixNano()), + } + return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], 0), "chtimes", name) +} diff --git a/internal/ufs/fs_linux.go b/internal/ufs/fs_linux.go new file mode 100644 index 00000000..b62e85bd --- /dev/null +++ b/internal/ufs/fs_linux.go @@ -0,0 +1,72 @@ +package ufs + +import ( + "path/filepath" + "strconv" + "time" + + "golang.org/x/sys/unix" +) + +// fdPath returns the filesystem path associated with a file descriptor by +// reading the /proc/self/fd/ symlink. +func fdPath(fd int) (string, error) { + return filepath.EvalSymlinks(filepath.Join("/proc/self/fd/", strconv.Itoa(fd))) +} + +// _openat2 is a wonderful syscall that supersedes the `openat` syscall. It has +// improved validation and security characteristics that weren't available or +// considered when `openat` was originally implemented. As such, it is only +// present in Kernel 5.6 and above. +// +// This method should never be directly called, use `openat` instead. +func (fs *UnixFS) _openat2(dirfd int, name string, flag, mode uint64) (int, error) { + // Ensure the O_CLOEXEC flag is set. + // Go sets this when using the os package, but since we are directly using + // the unix package we need to set it ourselves. + if flag&O_CLOEXEC == 0 { + flag |= O_CLOEXEC + } + // Ensure the O_LARGEFILE flag is set. + // Go sets this for unix.Open, unix.Openat, but not unix.Openat2. + if flag&O_LARGEFILE == 0 { + flag |= O_LARGEFILE + } + fd, err := unix.Openat2(dirfd, name, &unix.OpenHow{ + Flags: flag, + Mode: mode, + // This is the bread and butter of preventing a symlink escape, without + // this option, we have to handle path validation fully on our own. + // + // This is why using Openat2 over Openat is preferred if available. + Resolve: unix.RESOLVE_BENEATH, + }) + switch { + case err == nil: + return fd, nil + case err == unix.EINTR: + return fd, err + case err == unix.EAGAIN: + return fd, err + default: + return fd, ensurePathError(err, "openat2", name) + } +} + +// Chtimesat is like Chtimes but allows passing an existing directory file +// descriptor rather than needing to resolve one. +func (fs *UnixFS) Chtimesat(dirfd int, name string, atime, mtime time.Time) error { + var utimes [2]unix.Timespec + set := func(i int, t time.Time) { + if t.IsZero() { + utimes[i] = unix.Timespec{Sec: unix.UTIME_OMIT, Nsec: unix.UTIME_OMIT} + } else { + utimes[i] = unix.NsecToTimespec(t.UnixNano()) + } + } + set(0, atime) + set(1, mtime) + + // This does support `AT_SYMLINK_NOFOLLOW` as well if needed. + return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], 0), "chtimes", name) +} diff --git a/internal/ufs/fs_platform_test.go b/internal/ufs/fs_platform_test.go new file mode 100644 index 00000000..6ef5a1b1 --- /dev/null +++ b/internal/ufs/fs_platform_test.go @@ -0,0 +1,142 @@ +//go:build unix + +package ufs_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/pelican-dev/wings/internal/ufs" +) + +func TestFillFileStatFromSys(t *testing.T) { + t.Parallel() + + fs, err := newTestUnixFS() + if err != nil { + t.Fatal(err) + } + defer fs.Cleanup() + + // Create a file with known content. + f, err := fs.Create("test_stat_file") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("hello world")); err != nil { + _ = f.Close() + t.Fatal(err) + } + _ = f.Close() + + // Stat through UnixFS. + info, err := fs.Stat("test_stat_file") + if err != nil { + t.Fatal(err) + } + + if info.Name() != "test_stat_file" { + t.Errorf("expected name 'test_stat_file', got %q", info.Name()) + } + if info.Size() != 11 { + t.Errorf("expected size 11, got %d", info.Size()) + } + if info.IsDir() { + t.Error("expected file, got directory") + } + if info.ModTime().IsZero() { + t.Error("expected non-zero mod time") + } +} + +func TestOpenatPathValidation(t *testing.T) { + t.Parallel() + + fs, err := newTestUnixFS() + if err != nil { + t.Fatal(err) + } + defer fs.Cleanup() + + // Create a file inside the root. + f, err := fs.Create("safe_file") + if err != nil { + t.Fatal(err) + } + _ = f.Close() + + // Open should succeed. + f, err = fs.Open("safe_file") + if err != nil { + t.Fatalf("expected to open safe_file, got error: %v", err) + } + _ = f.Close() + + // Create a symlink pointing outside the root. + if err := os.Symlink(fs.TmpDir, filepath.Join(fs.Root, "escape_link")); err != nil { + t.Fatal(err) + } + + // Opening through the escape symlink should fail with ErrBadPathResolution. + _, err = fs.Open("escape_link/anything") + if err == nil { + t.Error("expected error opening path through escape symlink") + } +} + +func TestChtimesatWithZeroTime(t *testing.T) { + t.Parallel() + + fs, err := newTestUnixFS() + if err != nil { + t.Fatal(err) + } + defer fs.Cleanup() + + // Create a file. + f, err := fs.Create("chtimes_file") + if err != nil { + t.Fatal(err) + } + _ = f.Close() + + // Get original timestamps. + origInfo, err := fs.Stat("chtimes_file") + if err != nil { + t.Fatal(err) + } + origMtime := origInfo.ModTime() + + // Wait a bit to ensure timestamps differ if changed. + time.Sleep(50 * time.Millisecond) + + // Set atime but leave mtime as zero (should preserve original). + newAtime := time.Now().Add(time.Hour) + if err := fs.Chtimes("chtimes_file", newAtime, time.Time{}); err != nil { + t.Fatalf("Chtimes with zero mtime failed: %v", err) + } + + // Verify mtime was preserved. + info, err := fs.Stat("chtimes_file") + if err != nil { + t.Fatal(err) + } + if info.ModTime().Sub(origMtime).Abs() > 100*time.Millisecond { + t.Errorf("expected mtime to be preserved (~%v), got %v", origMtime, info.ModTime()) + } +} + +func TestOLargefileConstant(t *testing.T) { + t.Parallel() + + // On Darwin, O_LARGEFILE must be 0 (large file support is always native). + // On Linux, O_LARGEFILE is architecture-dependent: 0 on 64-bit (native + // large file support) and non-zero on 32-bit. We only assert the Darwin + // case since CI runs on 64-bit Linux where the value is also 0. + if runtime.GOOS == "darwin" && ufs.O_LARGEFILE != 0 { + t.Error("expected O_LARGEFILE to be 0 on Darwin") + } +} diff --git a/internal/ufs/fs_unix.go b/internal/ufs/fs_unix.go index 2a16596b..a67b582c 100644 --- a/internal/ufs/fs_unix.go +++ b/internal/ufs/fs_unix.go @@ -10,8 +10,8 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" + "sync/atomic" "time" "golang.org/x/sys/unix" @@ -26,8 +26,9 @@ type UnixFS struct { basePath string // useOpenat2 controls whether the `openat2` syscall is used instead of the - // older `openat` syscall. - useOpenat2 bool + // older `openat` syscall. Accessed atomically because multiple goroutines + // may call openat concurrently and the ENOSYS fallback writes false. + useOpenat2 atomic.Bool } // NewUnixFS creates a new sandboxed unix filesystem. BasePath is used as the @@ -36,10 +37,16 @@ type UnixFS struct { // checked and prevented from enabling an escape in a non-raceable manor. func NewUnixFS(basePath string, useOpenat2 bool) (*UnixFS, error) { basePath = strings.TrimSuffix(basePath, "/") + // Resolve symlinks in the base path so that path comparisons against + // resolved file descriptor paths (e.g. /private/var vs /var on macOS) + // work correctly. + if resolved, err := filepath.EvalSymlinks(basePath); err == nil { + basePath = resolved + } fs := &UnixFS{ - basePath: basePath, - useOpenat2: useOpenat2, + basePath: basePath, } + fs.useOpenat2.Store(useOpenat2) return fs, nil } @@ -156,24 +163,6 @@ func (fs *UnixFS) Chtimes(name string, atime, mtime time.Time) error { return fs.Chtimesat(dirfd, name, atime, mtime) } -// Chtimesat is like Chtimes but allows passing an existing directory file -// descriptor rather than needing to resolve one. -func (fs *UnixFS) Chtimesat(dirfd int, name string, atime, mtime time.Time) error { - var utimes [2]unix.Timespec - set := func(i int, t time.Time) { - if t.IsZero() { - utimes[i] = unix.Timespec{Sec: unix.UTIME_OMIT, Nsec: unix.UTIME_OMIT} - } else { - utimes[i] = unix.NsecToTimespec(t.UnixNano()) - } - } - set(0, atime) - set(1, mtime) - - // This does support `AT_SYMLINK_NOFOLLOW` as well if needed. - return ensurePathError(unix.UtimesNanoAt(dirfd, name, utimes[0:], 0), "chtimes", name) -} - // Create creates or truncates the named file. If the file already exists, // it is truncated. // @@ -663,8 +652,14 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, var fd int for { var err error - if fs.useOpenat2 { + if fs.useOpenat2.Load() { fd, err = fs._openat2(dirfd, name, uint64(flag), uint64(syscallMode(mode))) + // If openat2 is not supported (e.g. on Darwin or older kernels), + // permanently fall back to openat for this instance. + if err == unix.ENOSYS { + fs.useOpenat2.Store(false) + fd, err = fs._openat(dirfd, name, flag, uint32(syscallMode(mode))) + } } else { fd, err = fs._openat(dirfd, name, flag, uint32(syscallMode(mode))) } @@ -679,7 +674,7 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, } // If we are using openat2, we don't need the additional security checks. - if fs.useOpenat2 { + if fs.useOpenat2.Load() { return fd, nil } @@ -687,7 +682,7 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, // that openat2 is using `RESOLVE_BENEATH` to avoid the same security // issue. var finalPath string - finalPath, err := filepath.EvalSymlinks(filepath.Join("/proc/self/fd/", strconv.Itoa(fd))) + finalPath, err := fdPath(fd) if err != nil { if !errors.Is(err, ErrNotExist) { return fd, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err)) @@ -711,7 +706,7 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, // Check if the path is within our root. if !fs.unsafeIsPathInsideOfBase(finalPath) { op := "openat" - if fs.useOpenat2 { + if fs.useOpenat2.Load() { op = "openat2" } return fd, &PathError{ @@ -748,45 +743,6 @@ func (fs *UnixFS) _openat(dirfd int, name string, flag int, mode uint32) (int, e } } -// _openat2 is a wonderful syscall that supersedes the `openat` syscall. It has -// improved validation and security characteristics that weren't available or -// considered when `openat` was originally implemented. As such, it is only -// present in Kernel 5.6 and above. -// -// This method should never be directly called, use `openat` instead. -func (fs *UnixFS) _openat2(dirfd int, name string, flag, mode uint64) (int, error) { - // Ensure the O_CLOEXEC flag is set. - // Go sets this when using the os package, but since we are directly using - // the unix package we need to set it ourselves. - if flag&O_CLOEXEC == 0 { - flag |= O_CLOEXEC - } - // Ensure the O_LARGEFILE flag is set. - // Go sets this for unix.Open, unix.Openat, but not unix.Openat2. - if flag&O_LARGEFILE == 0 { - flag |= O_LARGEFILE - } - fd, err := unix.Openat2(dirfd, name, &unix.OpenHow{ - Flags: flag, - Mode: mode, - // This is the bread and butter of preventing a symlink escape, without - // this option, we have to handle path validation fully on our own. - // - // This is why using Openat2 over Openat is preferred if available. - Resolve: unix.RESOLVE_BENEATH, - }) - switch { - case err == nil: - return fd, nil - case err == unix.EINTR: - return fd, err - case err == unix.EAGAIN: - return fd, err - default: - return fd, ensurePathError(err, "openat2", name) - } -} - func (fs *UnixFS) SafePath(path string) (int, string, func(), error) { return fs.safePath(path) } @@ -805,7 +761,7 @@ func (fs *UnixFS) safePath(path string) (dirfd int, file string, closeFd func(), // Open the base path. We use this as the sandbox root for any further // operations. var fsDirfd int - fsDirfd, err = fs._openat(AT_EMPTY_PATH, fs.basePath, O_DIRECTORY|O_RDONLY, 0) + fsDirfd, err = fs._openat(unix.AT_FDCWD, fs.basePath, O_DIRECTORY|O_RDONLY, 0) if err != nil { return } diff --git a/internal/ufs/fs_unix_test.go b/internal/ufs/fs_unix_test.go index 7b8c5c22..a8bab4b6 100644 --- a/internal/ufs/fs_unix_test.go +++ b/internal/ufs/fs_unix_test.go @@ -34,11 +34,14 @@ func newTestUnixFS() (*testUnixFS, error) { if err != nil { return nil, err } + // Resolve symlinks in tmpDir so tests work on macOS where /var -> /private/var. + if resolved, err := filepath.EvalSymlinks(tmpDir); err == nil { + tmpDir = resolved + } root := filepath.Join(tmpDir, "root") if err := os.Mkdir(root, 0o755); err != nil { return nil, err } - // fmt.Println(tmpDir) fs, err := ufs.NewUnixFS(root, true) if err != nil { return nil, err diff --git a/internal/ufs/readdirmap_test.go b/internal/ufs/readdirmap_test.go new file mode 100644 index 00000000..27df81bc --- /dev/null +++ b/internal/ufs/readdirmap_test.go @@ -0,0 +1,255 @@ +//go:build unix + +package ufs + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + "testing" +) + +func TestReadDirMapWithInfoAndOpen(t *testing.T) { + for _, useOpenat2 := range []bool{false, true} { + t.Run("useOpenat2="+strconv.FormatBool(useOpenat2), func(t *testing.T) { + tmp := t.TempDir() + tmp, _ = filepath.EvalSymlinks(tmp) + + for _, name := range []string{"file1.txt", "file2.txt", "server.jar", "eula.txt"} { + if err := os.WriteFile(filepath.Join(tmp, name), []byte("test data"), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Join(tmp, "logs"), 0o755); err != nil { + t.Fatal(err) + } + + fs, err := NewUnixFS(tmp, useOpenat2) + if err != nil { + t.Fatal(err) + } + + type result struct { + name string + size int64 + isDir bool + } + + out, err := ReadDirMap(fs, "/", func(e DirEntry) (result, error) { + info, err := e.Info() + if err != nil { + return result{}, err + } + + r := result{ + name: info.Name(), + size: info.Size(), + isDir: info.IsDir(), + } + + if e.Type().IsRegular() { + eO, ok := e.(interface { + Open() (File, error) + }) + if !ok { + return result{}, fmt.Errorf("entry %s does not implement Open()", e.Name()) + } + f, err := eO.Open() + if err != nil { + return result{}, err + } + _ = f.Close() + } + + return r, nil + }) + if err != nil { + t.Fatalf("ReadDirMap: %v", err) + } + + if len(out) != 5 { + t.Fatalf("expected 5 entries, got %d", len(out)) + } + }) + } +} + +func TestReadDirMapConcurrent(t *testing.T) { + tmp := t.TempDir() + tmp, _ = filepath.EvalSymlinks(tmp) + + for _, name := range []string{"file1.txt", "file2.txt", "server.jar", "eula.txt", "config.yml"} { + if err := os.WriteFile(filepath.Join(tmp, name), []byte("test data"), 0o644); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{"logs", "plugins"} { + if err := os.MkdirAll(filepath.Join(tmp, name), 0o755); err != nil { + t.Fatal(err) + } + } + + fs, err := NewUnixFS(tmp, false) + if err != nil { + t.Fatal(err) + } + + type result struct { + name string + size int64 + } + + var wg sync.WaitGroup + errs := make(chan error, 100) + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + out, err := ReadDirMap(fs, "/", func(e DirEntry) (result, error) { + info, err := e.Info() + if err != nil { + return result{}, fmt.Errorf("goroutine %d: Info() for %s: %w", i, e.Name(), err) + } + + if e.Type().IsRegular() { + eO, ok := e.(interface { + Open() (File, error) + }) + if !ok { + return result{}, fmt.Errorf("goroutine %d: entry %s does not implement Open()", i, e.Name()) + } + f, err := eO.Open() + if err != nil { + return result{}, fmt.Errorf("goroutine %d: Open() for %s: %w", i, e.Name(), err) + } + _ = f.Close() + } + + return result{name: info.Name(), size: info.Size()}, nil + }) + if err != nil { + errs <- fmt.Errorf("goroutine %d: %w", i, err) + return + } + if len(out) != 7 { + errs <- fmt.Errorf("goroutine %d: expected 7 entries, got %d", i, len(out)) + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } +} + +// TestReadDirMapSymlinkedBase tests with a symlinked base path, which is +// common on macOS (/var -> /private/var). NewUnixFS resolves symlinks, but +// let's verify the whole flow works. +func TestReadDirMapSymlinkedBase(t *testing.T) { + // Create the actual directory + actual := t.TempDir() + actual, _ = filepath.EvalSymlinks(actual) + + for _, name := range []string{"server.jar", "eula.txt"} { + if err := os.WriteFile(filepath.Join(actual, name), []byte("test data"), 0o644); err != nil { + t.Fatal(err) + } + } + + // Create a symlink to it + linkDir := t.TempDir() + linkDir, _ = filepath.EvalSymlinks(linkDir) + linkPath := filepath.Join(linkDir, "link") + if err := os.Symlink(actual, linkPath); err != nil { + t.Fatal(err) + } + + // Create UnixFS using the SYMLINK path (not the resolved path) + fs, err := NewUnixFS(linkPath, false) + if err != nil { + t.Fatal(err) + } + + t.Logf("Link path: %s", linkPath) + t.Logf("Resolved basePath: %s", fs.BasePath()) + + type result struct { + name string + } + + out, err := ReadDirMap(fs, "/", func(e DirEntry) (result, error) { + info, err := e.Info() + if err != nil { + return result{}, fmt.Errorf("Info() for %s: %w", e.Name(), err) + } + + if e.Type().IsRegular() { + eO, ok := e.(interface { + Open() (File, error) + }) + if !ok { + return result{}, fmt.Errorf("entry %s does not implement Open()", e.Name()) + } + f, err := eO.Open() + if err != nil { + return result{}, fmt.Errorf("Open() for %s: %w", e.Name(), err) + } + _ = f.Close() + } + + return result{name: info.Name()}, nil + }) + if err != nil { + t.Fatalf("ReadDirMap with symlinked base: %v", err) + } + + if len(out) != 2 { + t.Fatalf("expected 2 entries, got %d", len(out)) + } + for _, r := range out { + t.Logf(" %s", r.name) + } +} + +// TestReadDirRootEntryPaths is a regression test: entries read from the root +// of a directory walk (relative == ".") must carry their own name as their +// path, not the parent directory's name. Linux never reads dirent.path (it +// uses dirfd+name), so a wrong value only surfaced through Darwin's +// path-based Info()/Open() implementations. +func TestReadDirRootEntryPaths(t *testing.T) { + tmp := t.TempDir() + tmp, _ = filepath.EvalSymlinks(tmp) + + for _, name := range []string{"alpha.txt", "beta.txt"} { + if err := os.WriteFile(filepath.Join(tmp, name), []byte("test data"), 0o644); err != nil { + t.Fatal(err) + } + } + + fs, err := NewUnixFS(tmp, false) + if err != nil { + t.Fatal(err) + } + + entries, err := fs.ReadDir("/") + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + for _, e := range entries { + de, ok := e.(*dirent) + if !ok { + t.Fatalf("expected *dirent, got %T", e) + } + if de.path != de.name { + t.Errorf("root entry %q has path %q; expected it to equal the entry name", de.name, de.path) + } + } +} diff --git a/internal/ufs/walk_darwin.go b/internal/ufs/walk_darwin.go new file mode 100644 index 00000000..d0890980 --- /dev/null +++ b/internal/ufs/walk_darwin.go @@ -0,0 +1,18 @@ +package ufs + +import ( + "unsafe" + + "golang.org/x/sys/unix" +) + +// getdents wraps the Darwin Getdirentries syscall, which is the macOS +// equivalent of Linux's Getdents. +func getdents(fd int, buf []byte) (int, error) { + return unix.Getdirentries(fd, buf, nil) +} + +func nameFromDirent(de *unix.Dirent) []byte { + // Darwin's Dirent provides a Namlen field with the exact name length. + return unsafe.Slice((*byte)(unsafe.Pointer(&de.Name[0])), de.Namlen) +} diff --git a/internal/ufs/walk_dirent_darwin.go b/internal/ufs/walk_dirent_darwin.go new file mode 100644 index 00000000..76ca1de8 --- /dev/null +++ b/internal/ufs/walk_dirent_darwin.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BSD-2-Clause + +package ufs + +// info resolves the entry by path instead of using the stored directory fd. +// +// On Darwin, unix.Getdirentries is a user-space simulation that uses +// fdopendir/readdir_r/closedir and manipulates the directory fd's seek offset +// via lseek to track position (see x/sys/unix syscall_darwin.go). This +// manipulation can leave the directory fd in a state where subsequent fstatat +// calls return EBADF. Using the path-based approach avoids this by opening a +// fresh directory fd for each stat call via safePath. +func (de dirent) info() (FileInfo, error) { + return de.fs.Lstat(de.path) +} + +// open resolves the entry by path instead of using the stored directory fd. +// See info() for why this is necessary on Darwin. +func (de dirent) open() (File, error) { + return de.fs.OpenFile(de.path, O_RDONLY, 0) +} diff --git a/internal/ufs/walk_dirent_linux.go b/internal/ufs/walk_dirent_linux.go new file mode 100644 index 00000000..37a9141d --- /dev/null +++ b/internal/ufs/walk_dirent_linux.go @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: BSD-2-Clause + +package ufs + +// info uses the stored directory file descriptor and name to stat the entry. +// On Linux, Getdents is a real syscall and the directory fd remains fully +// usable for subsequent *at operations. +func (de dirent) info() (FileInfo, error) { + return de.fs.Lstatat(de.dirfd, de.name) +} + +// open uses the stored directory file descriptor and name to open the entry. +func (de dirent) open() (File, error) { + return de.fs.OpenFileat(de.dirfd, de.name, O_RDONLY, 0) +} diff --git a/internal/ufs/walk_linux.go b/internal/ufs/walk_linux.go new file mode 100644 index 00000000..55bafaff --- /dev/null +++ b/internal/ufs/walk_linux.go @@ -0,0 +1,33 @@ +package ufs + +import ( + "bytes" + "unsafe" + + "golang.org/x/sys/unix" +) + +// getdents wraps the Linux Getdents syscall. +func getdents(fd int, buf []byte) (int, error) { + return unix.Getdents(fd, buf) +} + +// nameOffset is a compile time constant. +const nameOffset = int(unsafe.Offsetof(unix.Dirent{}.Name)) + +func nameFromDirent(de *unix.Dirent) []byte { + // Because Linux's Dirent does not provide a field that specifies the name + // length, this function must first calculate the max possible name length, + // and then search for the NULL byte. + ml := int(de.Reclen) - nameOffset + + name := unsafe.Slice((*byte)(unsafe.Pointer(&de.Name[0])), ml) + if i := bytes.IndexByte(name, 0); i >= 0 { + return name[:i] + } + + // NOTE: This branch is not expected, but included for defensive + // programming. Return the calculated name slice as-is since it is already + // bounded by Reclen. + return name +} diff --git a/internal/ufs/walk_unix.go b/internal/ufs/walk_unix.go index 065afc22..c18ebccb 100644 --- a/internal/ufs/walk_unix.go +++ b/internal/ufs/walk_unix.go @@ -7,12 +7,10 @@ package ufs import ( - "bytes" "fmt" iofs "io/fs" "os" "path" - "reflect" "unsafe" "golang.org/x/sys/unix" @@ -110,55 +108,16 @@ func ReadDirMap[T any](fs *UnixFS, path string, fn func(DirEntry) (T, error)) ([ out := make([]T, len(entries)) for i, e := range entries { - idx := i - e := e v, err := fn(e) if err != nil { return nil, err } - out[idx] = v + out[i] = v } return out, nil } -// nameOffset is a compile time constant -const nameOffset = int(unsafe.Offsetof(unix.Dirent{}.Name)) - -func nameFromDirent(de *unix.Dirent) (name []byte) { - // Because this GOOS' syscall.Dirent does not provide a field that specifies - // the name length, this function must first calculate the max possible name - // length, and then search for the NULL byte. - ml := int(de.Reclen) - nameOffset - - // Convert syscall.Dirent.Name, which is array of int8, to []byte, by - // overwriting Cap, Len, and Data slice header fields to the max possible - // name length computed above, and finding the terminating NULL byte. - // - // TODO: is there an alternative to the deprecated SliceHeader? - // SliceHeader was mainly deprecated due to it being misused for avoiding - // allocations when converting a byte slice to a string, ref; - // https://go.dev/issue/53003 - sh := (*reflect.SliceHeader)(unsafe.Pointer(&name)) - sh.Cap = ml - sh.Len = ml - sh.Data = uintptr(unsafe.Pointer(&de.Name[0])) - - if index := bytes.IndexByte(name, 0); index >= 0 { - // Found NULL byte; set slice's cap and len accordingly. - sh.Cap = index - sh.Len = index - return - } - - // NOTE: This branch is not expected, but included for defensive - // programming, and provides a hard stop on the name based on the structure - // field array size. - sh.Cap = len(de.Name) - sh.Len = sh.Cap - return -} - // modeTypeFromDirent converts a syscall defined constant, which is in purview // of OS, to a constant defined by Go, assumed by this project to be stable. // @@ -222,7 +181,7 @@ func (fs *UnixFS) readDir(fd int, name, relative string, b []byte) ([]DirEntry, var sde unix.Dirent for { if len(workBuffer) == 0 { - n, err := unix.Getdents(fd, scratchBuffer) + n, err := getdents(fd, scratchBuffer) if err != nil { if err == unix.EINTR { continue @@ -258,7 +217,7 @@ func (fs *UnixFS) readDir(fd int, name, relative string, b []byte) ([]DirEntry, } var rel string if relative == "." { - rel = name + rel = childName } else { rel = path.Join(relative, childName) } @@ -293,16 +252,14 @@ func (de dirent) Info() (FileInfo, error) { if de.fs == nil { return nil, nil } - return de.fs.Lstatat(de.dirfd, de.name) - // return de.fs.Lstat(de.path) + return de.info() } func (de dirent) Open() (File, error) { if de.fs == nil { return nil, nil } - return de.fs.OpenFileat(de.dirfd, de.name, O_RDONLY, 0) - // return de.fs.OpenFile(de.path, O_RDONLY, 0) + return de.open() } // reset releases memory held by entry err and name, and resets mode type to 0. diff --git a/server/filesystem/filesystem_test.go b/server/filesystem/filesystem_test.go index 0366dfc8..644ea53a 100644 --- a/server/filesystem/filesystem_test.go +++ b/server/filesystem/filesystem_test.go @@ -31,6 +31,10 @@ func NewFs() (*Filesystem, *rootFs) { panic(err) return nil, nil } + // Resolve symlinks in tmpDir so tests work on macOS where /var -> /private/var. + if resolved, err := filepath.EvalSymlinks(tmpDir); err == nil { + tmpDir = resolved + } rfs := rootFs{root: tmpDir} diff --git a/server/filesystem/quotas/functions_other.go b/server/filesystem/quotas/functions_other.go new file mode 100644 index 00000000..1c1bc656 --- /dev/null +++ b/server/filesystem/quotas/functions_other.go @@ -0,0 +1,36 @@ +//go:build !linux + +package quotas + +import ( + "emperror.dev/errors" +) + +var errUnsupported = errors.New("quotas: server disk quotas are only supported on Linux") + +// IsSupportedFS checks if the filesystem for the data files is supported. +// Quotas rely on Linux-specific filesystem features, so this always returns +// false on other platforms. +func IsSupportedFS() bool { + return false +} + +// AddQuota is unsupported on non-Linux platforms. +func AddQuota(serverID int, serverUUID string) error { + return errUnsupported +} + +// SetQuota is unsupported on non-Linux platforms. +func SetQuota(limit int64, serverUUID string) error { + return errUnsupported +} + +// GetQuota is unsupported on non-Linux platforms. +func GetQuota(serverUUID string) (int64, error) { + return 0, errUnsupported +} + +// DelQuota is unsupported on non-Linux platforms. +func DelQuota(serverUUID string) error { + return errUnsupported +} diff --git a/server/filesystem/stat_darwin.go b/server/filesystem/stat_darwin.go new file mode 100644 index 00000000..56dd406c --- /dev/null +++ b/server/filesystem/stat_darwin.go @@ -0,0 +1,22 @@ +package filesystem + +import ( + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +// CTime returns the time that the file/folder was created. +// +// TODO: remove. Ctim is not actually ever been correct and doesn't actually +// return the creation time. +func (s *Stat) CTime() time.Time { + if st, ok := s.Sys().(*unix.Stat_t); ok { + return time.Unix(int64(st.Ctim.Sec), int64(st.Ctim.Nsec)) + } + if st, ok := s.Sys().(*syscall.Stat_t); ok { + return time.Unix(int64(st.Ctimespec.Sec), int64(st.Ctimespec.Nsec)) + } + return time.Time{} +} diff --git a/server/filesystem/stat_test.go b/server/filesystem/stat_test.go new file mode 100644 index 00000000..bbdc59ec --- /dev/null +++ b/server/filesystem/stat_test.go @@ -0,0 +1,72 @@ +package filesystem + +import ( + "encoding/json" + "testing" + "time" +) + +func TestStatCTime(t *testing.T) { + fs, rfs := NewFs() + defer func() { _ = fs.TruncateRootDirectory() }() + + if err := rfs.CreateServerFileFromString("ctime_test.txt", "hello"); err != nil { + t.Fatal(err) + } + + st, err := fs.Stat("ctime_test.txt") + if err != nil { + t.Fatal(err) + } + + ctime := st.CTime() + if ctime.IsZero() { + t.Error("expected non-zero CTime") + } + if time.Since(ctime) > 10*time.Second { + t.Errorf("CTime seems too old: %v", ctime) + } +} + +func TestStatMarshalJSON(t *testing.T) { + fs, rfs := NewFs() + defer func() { _ = fs.TruncateRootDirectory() }() + + if err := rfs.CreateServerFileFromString("json_test.txt", "hello world"); err != nil { + t.Fatal(err) + } + + st, err := fs.Stat("json_test.txt") + if err != nil { + t.Fatal(err) + } + + b, err := json.Marshal(&st) + if err != nil { + t.Fatal(err) + } + + var result map[string]interface{} + if err := json.Unmarshal(b, &result); err != nil { + t.Fatal(err) + } + + created, ok := result["created"].(string) + if !ok || created == "" { + t.Error("expected 'created' field in JSON output") + } + + if _, err := time.Parse(time.RFC3339, created); err != nil { + t.Errorf("expected RFC3339 timestamp for 'created', got %q: %v", created, err) + } + + modified, ok := result["modified"].(string) + if !ok || modified == "" { + t.Error("expected 'modified' field in JSON output") + } + + name, ok := result["name"].(string) + if !ok || name != "json_test.txt" { + t.Errorf("expected name 'json_test.txt', got %q", name) + } +} diff --git a/system/system.go b/system/system.go index f0ccecd9..6f360a21 100644 --- a/system/system.go +++ b/system/system.go @@ -112,18 +112,21 @@ func GetSystemInformation() (*Information, error) { return nil, err } - release, err := osrelease.Read() - if err != nil { - return nil, err - } - var os string - if release["PRETTY_NAME"] != "" { - os = release["PRETTY_NAME"] - } else if release["NAME"] != "" { - os = release["NAME"] + if runtime.GOOS == "darwin" { + os = "macOS" } else { - os = info.OperatingSystem + release, err := osrelease.Read() + if err != nil { + return nil, err + } + if release["PRETTY_NAME"] != "" { + os = release["PRETTY_NAME"] + } else if release["NAME"] != "" { + os = release["NAME"] + } else { + os = info.OperatingSystem + } } var filesystem string @@ -205,6 +208,9 @@ func getDiskForPath(path string, partitions []disk.PartitionStat) (string, strin // Gets the system release name. func getSystemName() (string, error) { + if runtime.GOOS == "darwin" { + return "darwin", nil + } // use osrelease to get release version and ID release, err := osrelease.Read() if err != nil { From c3a49055c1e10a8123911ed531ca03393987a593 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 16 Jul 2026 19:27:40 -0400 Subject: [PATCH 02/10] Reduce log level for expected websocket disconnects --- router/router_server_ws.go | 2 +- router/websocket/listeners.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/router/router_server_ws.go b/router/router_server_ws.go index 39d0d70d..952356aa 100644 --- a/router/router_server_ws.go +++ b/router/router_server_ws.go @@ -75,7 +75,7 @@ func getServerWebsocket(c *gin.Context) { case <-ctx.Done(): handler.Logger().Debug("closing connection to server websocket") if err := handler.Connection.Close(); err != nil { - handler.Logger().WithError(err).Error("failed to close websocket connection") + handler.Logger().WithError(err).Info("failed to close websocket connection") } break } diff --git a/router/websocket/listeners.go b/router/websocket/listeners.go index 5183b3a5..f6815c14 100644 --- a/router/websocket/listeners.go +++ b/router/websocket/listeners.go @@ -27,9 +27,9 @@ func (h *Handler) registerListenerEvents(ctx context.Context) { go func() { if err := h.listenForServerEvents(ctx); err != nil { - h.Logger().Warn("error while processing server event; closing websocket connection") + h.Logger().WithField("error", errors.WithStack(err)).Info("closing websocket connection after server event ended") if err := h.Connection.Close(); err != nil { - h.Logger().WithField("error", errors.WithStack(err)).Error("error closing websocket connection") + h.Logger().WithField("error", errors.WithStack(err)).Info("websocket connection already closed") } } }() @@ -97,7 +97,7 @@ func (h *Handler) listenForServerEvents(ctx context.Context) error { h.server.Sink(system.InstallSink).On(installOutput) onError := func(evt string, err2 error) { - h.Logger().WithField("event", evt).WithField("error", err2).Error("failed to send event over server websocket") + h.Logger().WithField("event", evt).WithField("error", err2).Info("failed to send event over server websocket") // Avoid race conditions by only setting the error once and then canceling // the context. This way if additional processing errors come through due // to a massive flood of things you still only report and stop at the first. From 93d47bad0c9eb101159fc2e72cb4903890e07593 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 16 Jul 2026 19:27:40 -0400 Subject: [PATCH 03/10] Add Darwin build targets and macOS CI job --- .github/workflows/push.yaml | 36 ++++++++++++++++++++++++++++++++++++ Makefile | 12 ++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 06b63633..1d7ed2a3 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -73,3 +73,39 @@ jobs: with: name: wings_linux_${{ matrix.goarch }}_debug path: dist/wings_debug + + test-macos: + name: Test macOS + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + go: ["1.25.11", "1.26.4"] + + steps: + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go }} + + - name: Code checkout + uses: actions/checkout@v6 + + - name: go mod download + env: + CGO_ENABLED: 0 + run: | + go mod download + + - name: Build + env: + CGO_ENABLED: 0 + SRC_PATH: github.com/pelican-dev/wings + run: | + go build -v -trimpath -ldflags="-s -w -X ${SRC_PATH}/system.Version=dev-${GITHUB_SHA:0:7}" -o dist/wings ${SRC_PATH} + + - name: go test -race + env: + CGO_ENABLED: 1 + run: | + go test -race $(go list ./...) diff --git a/Makefile b/Makefile index 3b307169..7632f75a 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ GIT_HEAD = $(shell git rev-parse HEAD | head -c8) build: - GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(pwd)" -o build/wings_linux_amd64 -v wings.go - GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(pwd)" -o build/wings_linux_arm64 -v wings.go + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(CURDIR)" -o build/wings_linux_amd64 -v wings.go + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(CURDIR)" -o build/wings_linux_arm64 -v wings.go test: go test -race ./... @@ -17,9 +17,13 @@ rmdebug: go build -gcflags "all=-N -l" -ldflags="-X github.com/pelican-dev/wings/system.Version=$(GIT_HEAD)" -race sudo dlv --listen=:2345 --headless=true --api-version=2 --accept-multiclient exec ./wings -- --debug --ignore-certificate-errors --config config.yml -cross-build: clean build compress +build-darwin: + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(CURDIR)" -o build/wings_darwin_arm64 -v wings.go + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(CURDIR)" -o build/wings_darwin_amd64 -v wings.go + +cross-build: clean build build-darwin clean: rm -rf build/wings_* -.PHONY: all build compress clean \ No newline at end of file +.PHONY: build build-darwin cross-build clean test debug rmdebug From a1c69f17d14f821116cff69f4d9d2fc46af4febd Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 16 Jul 2026 19:27:40 -0400 Subject: [PATCH 04/10] Document running Wings on macOS --- macos.md | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 macos.md diff --git a/macos.md b/macos.md new file mode 100644 index 00000000..00a4ac39 --- /dev/null +++ b/macos.md @@ -0,0 +1,137 @@ +# Running Wings on macOS + +Wings is designed for Linux, but it can compile and run natively on macOS. This +document covers how to set up Wings on macOS. + +> **Note:** Wings manages Docker containers that run Linux. On macOS, Docker +> Desktop provides a Linux VM transparently. Game servers and other containers +> run inside that VM — Wings itself runs on the macOS host. + +## Prerequisites + +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed + and running +- [mkcert](https://github.com/FiloSottile/mkcert) for SSL certificates + (`brew install mkcert`) +- A Pelican Panel instance with a node configured for this machine + +## Setup + +### 1. Create directories + +```bash +mkdir -p ~/.config/pelican +mkdir -p ~/.local/share/pelican/{logs,volumes,archives,backups} +mkdir -p ~/.pelican/tmp +``` + +### 2. Generate SSL certificates + +Wings requires HTTPS. Use mkcert to generate locally-trusted certificates: + +```bash +mkcert -install +mkcert -cert-file ~/.config/pelican/localhost.pem \ + -key-file ~/.config/pelican/localhost-key.pem \ + localhost 127.0.0.1 +``` + +### 3. Docker socket symlink + +Docker Desktop places its socket at `~/.docker/run/docker.sock`, but Wings +expects `/var/run/docker.sock`: + +```bash +sudo ln -sf ~/.docker/run/docker.sock /var/run/docker.sock +``` + +### 4. Configure Wings + +After creating the node in the Panel, copy the auto-generated config from +`/etc/pelican/config.yml` (or use `wings configure`) and save it to +`~/.config/pelican/config.yml`. Modify the following settings: + +```yaml +api: + ssl: + enabled: true + cert: /Users//.config/pelican/localhost.pem + key: /Users//.config/pelican/localhost-key.pem +system: + root_directory: /Users//.local/share/pelican + log_directory: /Users//.local/share/pelican/logs + data: /Users//.local/share/pelican/volumes + archive_directory: /Users//.local/share/pelican/archives + backup_directory: /Users//.local/share/pelican/backups + tmp_directory: /Users//.pelican/tmp + user: + uid: 501 # your UID (run `id -u`) + gid: 20 # your GID (run `id -g`) + passwd: + enable: true + directory: /Users//.config/pelican + machine_id: + enable: false + check_permissions_on_boot: false + enable_log_rotate: false +``` + +Replace `` with your macOS username. + +**Why these settings matter:** + +- **All paths under `/Users/`** — Docker Desktop's file sharing only grants the + VM access to paths under `/Users`, `/Volumes`, `/private`, and `/tmp` by + default. Paths like `/var/lib/pelican` will not be accessible from inside + containers. +- **`uid`/`gid` set to your user** — Wings won't try to create a system user + via `useradd`. +- **`machine_id.enable: false`** — Avoids a bind mount of `/etc/machine-id` + which doesn't exist on macOS. +- **`check_permissions_on_boot: false`** — Prevents Wings from trying to `chown` + server data directories to a pelican system user. +- **`enable_log_rotate: false`** — macOS doesn't have `/etc/logrotate.d/`. + +### 5. Panel node configuration + +In the Panel, configure the node with: + +- **FQDN:** `localhost` or `127.0.0.1` +- **Port:** `8080` +- **SSL:** enabled +- **Scheme:** HTTPS + +### 6. Start Wings + +```bash +./wings --config ~/.config/pelican/config.yml +``` + +No `sudo` required. Do not run Wings with `sudo` on macOS — Docker Desktop's VM +accesses host files as the host user. If Wings runs as root, it creates +directories owned by `root` that the VM cannot write to, causing containers to +fail on bind-mounted volumes. + +If the Panel shows "is not Pelican Wings!" after startup, clear the Panel cache: + +```bash +php artisan cache:clear +``` + +## Building from Source + +```bash +# Native build (current architecture) +go build -o wings wings.go + +# Or use the Makefile targets +make build-darwin +``` + +## Developer Notes + +Platform-specific behavior lives in `_linux.go` / `_darwin.go` file pairs +(Go's filename-based build constraints) in `internal/ufs/`, `config/`, and +`server/filesystem/`, with a few `runtime.GOOS` checks where a full file split +would be overkill. The rationale for each difference is documented alongside +the code. From 74ca284706a773db7b57be3e498892066613ce80 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 16 Jul 2026 19:35:18 -0400 Subject: [PATCH 05/10] Fix zero CTime for os.File-backed Stats on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CTime checked for *unix.Stat_t twice; the second branch was clearly meant to handle *syscall.Stat_t, which is what Sys() returns for FileInfos produced by (*os.File).Stat — the path Filesystem.Stat uses. As a result the file API's created timestamps were the zero time on Linux. Caught by the new TestStatCTime. --- server/filesystem/stat_linux.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/filesystem/stat_linux.go b/server/filesystem/stat_linux.go index 8d62e5e1..c3306571 100644 --- a/server/filesystem/stat_linux.go +++ b/server/filesystem/stat_linux.go @@ -1,6 +1,7 @@ package filesystem import ( + "syscall" "time" "golang.org/x/sys/unix" @@ -11,11 +12,14 @@ import ( // TODO: remove. Ctim is not actually ever been correct and doesn't actually // return the creation time. func (s *Stat) CTime() time.Time { + // FileInfos produced by ufs stat calls carry a *unix.Stat_t, while those + // produced by (*os.File).Stat (e.g. Filesystem.Stat) carry a + // *syscall.Stat_t; handle both. if st, ok := s.Sys().(*unix.Stat_t); ok { // Do not remove these "redundant" type-casts, they are required for 32-bit builds to work. return time.Unix(int64(st.Ctim.Sec), int64(st.Ctim.Nsec)) } - if st, ok := s.Sys().(*unix.Stat_t); ok { + if st, ok := s.Sys().(*syscall.Stat_t); ok { // Do not remove these "redundant" type-casts, they are required for 32-bit builds to work. return time.Unix(int64(st.Ctim.Sec), int64(st.Ctim.Nsec)) } From 3e6536ffc76e9373a2a475756c714c1500357bae Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 18 Jul 2026 08:54:56 -0400 Subject: [PATCH 06/10] Add explicit go:build constraints to platform-split files Adds //go:build darwin / //go:build linux tags to the darwin- and linux-suffixed source and test files. The filename suffixes already imply these constraints, but the explicit tags make the platform scoping clear at the top of each file, per review feedback. --- config/config_openat_darwin.go | 2 ++ config/config_openat_linux.go | 2 ++ config/config_openat_linux_test.go | 2 ++ internal/ufs/file_darwin.go | 2 ++ internal/ufs/file_linux.go | 2 ++ internal/ufs/fs_darwin.go | 2 ++ internal/ufs/fs_linux.go | 2 ++ internal/ufs/walk_darwin.go | 2 ++ internal/ufs/walk_dirent_darwin.go | 2 ++ internal/ufs/walk_dirent_linux.go | 2 ++ internal/ufs/walk_linux.go | 2 ++ server/filesystem/stat_darwin.go | 2 ++ server/filesystem/stat_linux.go | 2 ++ 13 files changed, 26 insertions(+) diff --git a/config/config_openat_darwin.go b/config/config_openat_darwin.go index 6d2b2d35..f4f81fb2 100644 --- a/config/config_openat_darwin.go +++ b/config/config_openat_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package config // UseOpenat2 always returns false on Darwin as the openat2 syscall is diff --git a/config/config_openat_linux.go b/config/config_openat_linux.go index e99642ce..df403317 100644 --- a/config/config_openat_linux.go +++ b/config/config_openat_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package config import ( diff --git a/config/config_openat_linux_test.go b/config/config_openat_linux_test.go index b35fc6b3..daa9a57c 100644 --- a/config/config_openat_linux_test.go +++ b/config/config_openat_linux_test.go @@ -1,3 +1,5 @@ +//go:build linux + package config import "testing" diff --git a/internal/ufs/file_darwin.go b/internal/ufs/file_darwin.go index 405ec1ec..7fe01a29 100644 --- a/internal/ufs/file_darwin.go +++ b/internal/ufs/file_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package ufs // O_LARGEFILE is a no-op on Darwin as all files support large offsets. diff --git a/internal/ufs/file_linux.go b/internal/ufs/file_linux.go index 57d2a429..9535b2b6 100644 --- a/internal/ufs/file_linux.go +++ b/internal/ufs/file_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package ufs import "golang.org/x/sys/unix" diff --git a/internal/ufs/fs_darwin.go b/internal/ufs/fs_darwin.go index 856807c0..2a680aee 100644 --- a/internal/ufs/fs_darwin.go +++ b/internal/ufs/fs_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package ufs import ( diff --git a/internal/ufs/fs_linux.go b/internal/ufs/fs_linux.go index b62e85bd..a77d6b9b 100644 --- a/internal/ufs/fs_linux.go +++ b/internal/ufs/fs_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package ufs import ( diff --git a/internal/ufs/walk_darwin.go b/internal/ufs/walk_darwin.go index d0890980..bd3dd35a 100644 --- a/internal/ufs/walk_darwin.go +++ b/internal/ufs/walk_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package ufs import ( diff --git a/internal/ufs/walk_dirent_darwin.go b/internal/ufs/walk_dirent_darwin.go index 76ca1de8..67684fb7 100644 --- a/internal/ufs/walk_dirent_darwin.go +++ b/internal/ufs/walk_dirent_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + // SPDX-License-Identifier: BSD-2-Clause package ufs diff --git a/internal/ufs/walk_dirent_linux.go b/internal/ufs/walk_dirent_linux.go index 37a9141d..41e0fbef 100644 --- a/internal/ufs/walk_dirent_linux.go +++ b/internal/ufs/walk_dirent_linux.go @@ -1,3 +1,5 @@ +//go:build linux + // SPDX-License-Identifier: BSD-2-Clause package ufs diff --git a/internal/ufs/walk_linux.go b/internal/ufs/walk_linux.go index 55bafaff..2937afb5 100644 --- a/internal/ufs/walk_linux.go +++ b/internal/ufs/walk_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package ufs import ( diff --git a/server/filesystem/stat_darwin.go b/server/filesystem/stat_darwin.go index 56dd406c..6f937877 100644 --- a/server/filesystem/stat_darwin.go +++ b/server/filesystem/stat_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package filesystem import ( diff --git a/server/filesystem/stat_linux.go b/server/filesystem/stat_linux.go index c3306571..d285fbbd 100644 --- a/server/filesystem/stat_linux.go +++ b/server/filesystem/stat_linux.go @@ -1,3 +1,5 @@ +//go:build linux + package filesystem import ( From fe068642e5fd8fce6e90caa21454eb0d9818c47a Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 18 Jul 2026 16:00:16 -0400 Subject: [PATCH 07/10] Close fd on openat path-validation failure When the post-open path validation in UnixFS.openat failed, the successfully opened descriptor was returned alongside the error. Callers treat a non-nil error as a total failure and never close the returned fd, leaking the descriptor. Close the descriptor and return 0 on every validation-error path, including the non-existent-symlink-target case that previously returned the fd with a non-nil error. Also drop the dead openat2 branch when building the PathError op: the openat2 path returns early before this check, so the op is always "openat". --- internal/ufs/fs_unix.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/internal/ufs/fs_unix.go b/internal/ufs/fs_unix.go index a67b582c..f311e1d5 100644 --- a/internal/ufs/fs_unix.go +++ b/internal/ufs/fs_unix.go @@ -681,11 +681,11 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, // If we are not using openat2, do additional path checking. This assumes // that openat2 is using `RESOLVE_BENEATH` to avoid the same security // issue. - var finalPath string finalPath, err := fdPath(fd) if err != nil { if !errors.Is(err, ErrNotExist) { - return fd, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err)) + _ = unix.Close(fd) + return 0, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err)) } // The target of one of the symlinks (EvalSymlinks is recursive) @@ -693,7 +693,8 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, // that for further validation instead. var pErr *PathError if !errors.As(err, &pErr) { - return fd, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err)) + _ = unix.Close(fd) + return 0, fmt.Errorf("failed to evaluate symlink: %w", convertErrorType(err)) } // Update the final path to whatever directory or path didn't exist while @@ -703,21 +704,27 @@ func (fs *UnixFS) openat(dirfd int, name string, flag int, mode FileMode) (int, err = convertErrorType(err) } - // Check if the path is within our root. + // Check if the path is within our root. We always reach this point using + // openat (the openat2 path returns early above), so the op is always + // "openat". if !fs.unsafeIsPathInsideOfBase(finalPath) { - op := "openat" - if fs.useOpenat2.Load() { - op = "openat2" - } - return fd, &PathError{ - Op: op, + _ = unix.Close(fd) + return 0, &PathError{ + Op: "openat", Path: name, Err: ErrBadPathResolution, } } - // Return the file descriptor and any potential error. - return fd, err + // If path validation surfaced an error (e.g. a non-existent symlink + // target), close the descriptor so callers don't leak it. + if err != nil { + _ = unix.Close(fd) + return 0, err + } + + // Return the validated file descriptor. + return fd, nil } // _openat is a wrapper around unix.Openat. This method should never be directly From bf1015b18cb5353acbfafa053d76ea2b8cf7f65c Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Sat, 18 Jul 2026 16:00:40 -0400 Subject: [PATCH 08/10] Disable credential persistence in CI checkouts actions/checkout persists the GitHub token in the local .git config by default. Since both jobs build and upload artifacts, set persist-credentials: false on each checkout to avoid leaking the token into the workspace. --- .github/workflows/push.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 1d7ed2a3..8020912a 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -28,6 +28,8 @@ jobs: - name: Code checkout uses: actions/checkout@v6 + with: + persist-credentials: false - name: go mod download env: @@ -90,6 +92,8 @@ jobs: - name: Code checkout uses: actions/checkout@v6 + with: + persist-credentials: false - name: go mod download env: From 430f01aa8285ebd9103fd830034a29a061e23721 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 30 Jul 2026 18:25:27 -0400 Subject: [PATCH 09/10] Split quota stubs into darwin-specific file Replace the generic //go:build !linux functions_other.go with a darwin-tagged functions_darwin.go so the unsupported-quota stubs are built per-platform rather than as a catch-all. --- .../{functions_other.go => functions_darwin.go} | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) rename server/filesystem/quotas/{functions_other.go => functions_darwin.go} (72%) diff --git a/server/filesystem/quotas/functions_other.go b/server/filesystem/quotas/functions_darwin.go similarity index 72% rename from server/filesystem/quotas/functions_other.go rename to server/filesystem/quotas/functions_darwin.go index 1c1bc656..a4df05aa 100644 --- a/server/filesystem/quotas/functions_other.go +++ b/server/filesystem/quotas/functions_darwin.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build darwin package quotas @@ -10,27 +10,27 @@ var errUnsupported = errors.New("quotas: server disk quotas are only supported o // IsSupportedFS checks if the filesystem for the data files is supported. // Quotas rely on Linux-specific filesystem features, so this always returns -// false on other platforms. +// false on macOS. func IsSupportedFS() bool { return false } -// AddQuota is unsupported on non-Linux platforms. +// AddQuota is unsupported on macOS. func AddQuota(serverID int, serverUUID string) error { return errUnsupported } -// SetQuota is unsupported on non-Linux platforms. +// SetQuota is unsupported on macOS. func SetQuota(limit int64, serverUUID string) error { return errUnsupported } -// GetQuota is unsupported on non-Linux platforms. +// GetQuota is unsupported on macOS. func GetQuota(serverUUID string) (int64, error) { return 0, errUnsupported } -// DelQuota is unsupported on non-Linux platforms. +// DelQuota is unsupported on macOS. func DelQuota(serverUUID string) error { return errUnsupported } From a8986d6e3aceb1ef89254768503d0f40e941ea68 Mon Sep 17 00:00:00 2001 From: Lance Pioch Date: Thu, 30 Jul 2026 19:59:47 -0400 Subject: [PATCH 10/10] Tag unix-only files and fix self-update OS in binary name Add //go:build unix to four files that use golang.org/x/sys/unix but were untagged (ufs file/error, disk_space, system), so they are explicitly gated as unix-only rather than compiling by accident on the current linux+darwin targets. No behavior change on either platform. Also fix determineBinaryName to use runtime.GOOS instead of a hardcoded "linux" in the release asset name, so 'wings update' no longer tries to overwrite a macOS binary with a Linux build. --- cmd/selfupdate.go | 6 ++---- internal/ufs/error.go | 2 ++ internal/ufs/file.go | 2 ++ server/filesystem/disk_space.go | 2 ++ system/system.go | 2 ++ 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cmd/selfupdate.go b/cmd/selfupdate.go index 7edda68b..1f8f1684 100644 --- a/cmd/selfupdate.go +++ b/cmd/selfupdate.go @@ -216,10 +216,8 @@ func fetchLatestGitHubRelease() (string, error) { func determineBinaryName() string { switch runtime.GOARCH { - case "amd64": - return "wings_linux_amd64" - case "arm64": - return "wings_linux_arm64" + case "amd64", "arm64": + return fmt.Sprintf("wings_%s_%s", runtime.GOOS, runtime.GOARCH) default: return "" } diff --git a/internal/ufs/error.go b/internal/ufs/error.go index 3e02546b..b33f0317 100644 --- a/internal/ufs/error.go +++ b/internal/ufs/error.go @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner +//go:build unix + package ufs import ( diff --git a/internal/ufs/file.go b/internal/ufs/file.go index 3b0eaea0..ca74cb4f 100644 --- a/internal/ufs/file.go +++ b/internal/ufs/file.go @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) 2024 Matthew Penner +//go:build unix + package ufs import ( diff --git a/server/filesystem/disk_space.go b/server/filesystem/disk_space.go index 0a343b65..5280b53d 100644 --- a/server/filesystem/disk_space.go +++ b/server/filesystem/disk_space.go @@ -1,3 +1,5 @@ +//go:build unix + package filesystem import ( diff --git a/system/system.go b/system/system.go index 6f360a21..50ec088a 100644 --- a/system/system.go +++ b/system/system.go @@ -1,3 +1,5 @@ +//go:build unix + package system import (