diff --git a/Makefile b/Makefile index 3b307169..9d2650c8 100644 --- a/Makefile +++ b/Makefile @@ -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=$(pwd)" -o build/wings_darwin_arm64 -v wings.go + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -gcflags "all=-trimpath=$(pwd)" -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: all build build-darwin cross-build clean test debug rmdebug diff --git a/config/config.go b/config/config.go index cf0cd38d..fb6c5cc8 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" @@ -503,6 +502,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") @@ -791,6 +802,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 { @@ -799,39 +813,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 596da6fc..bfa7e290 100644 --- a/environment/settings.go +++ b/environment/settings.go @@ -3,6 +3,7 @@ package environment import ( "fmt" "math" + "runtime" "strconv" "github.com/apex/log" @@ -112,11 +113,14 @@ func (l Limits) AsContainerResources() container.Resources { Memory: l.BoundedMemoryLimit(), MemoryReservation: l.MemoryLimit * 1024 * 1024, MemorySwap: l.ConvertedSwap(), - BlkioWeight: l.IoWeight, OomKillDisable: boolPtr(!l.OOMKiller), PidsLimit: &pids, } + if runtime.GOOS == "linux" { + resources.BlkioWeight = l.IoWeight + } + // If the CPU Limit is not set, don't send any of these fields through. Providing // them seems to break some Java services that try to read the available processors. // 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..98ea9ffd --- /dev/null +++ b/internal/ufs/fs_darwin.go @@ -0,0 +1,59 @@ +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) + } + 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 dff36c9b..8d14a46f 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 9187b6ff..046f5ece 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/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_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..9409f3eb 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" @@ -122,42 +120,6 @@ func ReadDirMap[T any](fs *UnixFS, path string, fn func(DirEntry) (T, error)) ([ 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 +184,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 diff --git a/macos.md b/macos.md new file mode 100644 index 00000000..dcdabd9c --- /dev/null +++ b/macos.md @@ -0,0 +1,203 @@ +# 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 and the code changes that make it +possible. + +> **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 +``` + +--- + +## Code Changes + +The sections below document the platform-specific code changes for developers. + +### Platform-Specific File Splits + +The core compilation work splits Linux-only syscalls into `_linux.go` and +`_darwin.go` file pairs using Go's filename-based build tag convention. + +#### `internal/ufs/` — Unix Filesystem Layer + +| File | Purpose | +|------|---------| +| `file_linux.go` | `O_LARGEFILE = unix.O_LARGEFILE` | +| `file_darwin.go` | `O_LARGEFILE = 0` (no-op on macOS) | +| `fs_linux.go` | `_openat2()` using `unix.Openat2`, `fdPath()` via `/proc/self/fd/`, `Chtimesat()` using `unix.UTIME_OMIT` | +| `fs_darwin.go` | `_openat2()` stub returning `ENOSYS`, `fdPath()` via `F_GETPATH` fcntl, `Chtimesat()` that reads current timestamps when zero | +| `walk_linux.go` | `getdents()` using `unix.Getdents`, `nameFromDirent()` with NUL scan | +| `walk_darwin.go` | `getdents()` using `unix.Getdirentries`, `nameFromDirent()` using `Dirent.Namlen` | + +**Why these splits are needed:** + +- `unix.O_LARGEFILE` does not exist on Darwin (all files support large offsets). +- `unix.Openat2` / `unix.OpenHow` / `unix.RESOLVE_BENEATH` are Linux 5.6+ only. +- `/proc/self/fd/` does not exist on macOS; `F_GETPATH` fcntl is the equivalent. +- `unix.UTIME_OMIT` does not exist on Darwin. +- `unix.Getdents` does not exist on Darwin; `unix.Getdirentries` is used instead. +- Darwin `Dirent` has a `Namlen` field; Linux requires scanning for NUL. + +#### `config/` — OpenAt2 Detection + +| File | Purpose | +|------|---------| +| `config_openat_linux.go` | Full `UseOpenat2()` with runtime probe via `unix.Openat2` | +| `config_openat_darwin.go` | `UseOpenat2()` always returns `false` | + +#### `server/filesystem/` — File Stat CTime + +| File | Purpose | +|------|---------| +| `stat_linux.go` | Original `CTime()` using `unix.Stat_t.Ctim` (was `//go:build linux`) | +| `stat_darwin.go` | `CTime()` handling both `unix.Stat_t.Ctim` and `syscall.Stat_t.Ctimespec` | + +`golang.org/x/sys/unix` v0.35.0 normalizes `Stat_t` field names (`Mtim`, +`Ctim`) across Linux and Darwin, but `syscall.Stat_t` still uses +`Ctimespec`/`Mtimespec` on Darwin. + +### Runtime Guards + +These changes use `runtime.GOOS` checks to handle macOS differences at runtime. + +#### `config/config.go` + +- **`getSystemName()`** — Returns `"darwin"` immediately on macOS instead of + calling `osrelease.Read()` (which reads `/usr/lib/os-release`, a file that + does not exist on macOS). +- **`EnsurePelicanUser()`** — On macOS, uses `user.Current()` to get the + running user instead of calling `useradd` (which does not exist on macOS). + +#### `system/system.go` + +- **`GetSystemInformation()`** — Returns `"macOS"` as the OS name on Darwin + instead of reading `/usr/lib/os-release`. +- **`getSystemName()`** — Same early return as `config/config.go`. + +#### `environment/settings.go` + +- **`AsContainerResources()`** — Only sets `BlkioWeight` (IO weight) on Linux. + Docker Desktop's Linux VM does not support `io.weight` in its cgroup + configuration, so setting it on macOS causes container creation to fail. 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..f670abd4 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().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. 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/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 87ffc56b..88396082 100644 --- a/system/system.go +++ b/system/system.go @@ -111,18 +111,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 @@ -204,6 +207,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 {