//go:build linux package hostenv import ( "crypto/rand" "errors" "fmt" "os" "path/filepath" "golang.org/x/sys/unix" ) // writeHostEnv writes body as /etc/carlos/host.env, 0600, owned by // the process's user (root on a box). Every directory on the way is held // by descriptor, never looked up again by name: etc and etc/carlos are // opened with O_NOFOLLOW|O_DIRECTORY, so a symlink at either is refused, // and each must be owned by this user and writable by no one else // (checkedDir), so no other user can swap the file or the directory under // it. etc/carlos is created 0755 if it is missing, then opened and // checked like the rest. The new file is written beside the old one in // that directory, synced, renamed over it, and the directory synced, so a // reader sees the old file or the new one, never part of either. func writeHostEnv(root string, body []byte) error { rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) if err != nil { return err } defer unix.Close(rootFD) etc, err := checkedDir(rootFD, filepath.Join(root, "etc"), "etc", false) if err != nil { return err } defer unix.Close(etc) dir, err := checkedDir(etc, filepath.Join(root, "etc", "carlos"), "carlos", true) if err != nil { return err } defer unix.Close(dir) tmp := ".host.env." + rand.Text() fd, err := unix.Openat(dir, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) if err != nil { return err } defer unix.Unlinkat(dir, tmp, 0) // gone once renamed f := os.NewFile(uintptr(fd), tmp) if err := f.Chmod(0o600); err != nil { f.Close() return err } if _, err := f.Write(body); err != nil { f.Close() return err } if err := f.Sync(); err != nil { f.Close() return err } if err := f.Close(); err != nil { return err } if err := unix.Renameat(dir, tmp, dir, "host.env"); err != nil { return err } return unix.Fsync(dir) } // checkedDir opens name under the directory parent as a real directory, // creating it 0755 first when create is set and it is missing, and refuses // one that is a symlink, is not a directory, is not owned by this // process's user (hostEnvOwner), or is writable by group or others. path // is its name for the refusal. func checkedDir(parent int, path, name string, create bool) (int, error) { const how = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC fd, err := unix.Openat(parent, name, how, 0) if errors.Is(err, unix.ENOENT) && create { if err := unix.Mkdirat(parent, name, 0o755); err != nil && !errors.Is(err, unix.EEXIST) { return -1, err } fd, err = unix.Openat(parent, name, how, 0) } if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) { return -1, notRootsDir(path) } if err != nil { return -1, err } var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { unix.Close(fd) return -1, err } if st.Mode&unix.S_IFMT != unix.S_IFDIR || int(st.Uid) != hostEnvOwner() || st.Mode&0o022 != 0 { unix.Close(fd) return -1, notRootsDir(path) } return fd, nil } // hostEnvOwner is the user etc and etc/carlos must belong to: this // process's, which runHostEnv has already required to be root. A var so a // test can stand for a directory another user owns. var hostEnvOwner = os.Geteuid func notRootsDir(path string) error { return fmt.Errorf("%s is not a directory only root can write", path) }