Header banner: Dolomites near Nova Levante, 2023

Multi-OS Privilege Dropping in Go

After I started writing about techniques to let software drop privileges, I realized that there is way more to say than I first anticipated. To be able to finish the previous posts, I made many compromises and skipped some topics.

This became especially clear to me after finishing my last post on Privilege Separation in Go, which became very Linux-leaning. While I introduced common POSIX APIs and both Linux and OpenBSD APIs in the earlier Dropping Privileges in Go, only the Linux parts made it into the subsequent post.

The result was a post about architectural changes in software design to achieve privilege separation, but with examples only targeting Linux. Those examples were not portable, something I usually try to avoid. This post tries to make up for that and demonstrates how to write Go code with multiple OS-specific parts for different target platforms.

Non-Portable Code

But what exactly is non-portable code? Let’s start with a very trivial example, calling OpenBSD’s unveil(2) via golang.org/x/sys/unix.

package main

import "golang.org/x/sys/unix"

func main() {
	if err := unix.Unveil(".", "r"); err != nil {
		panic(err)
	}
	if err := unix.UnveilBlock(); err != nil {
		panic(err)
	}
}

The problem is that the two functions unix.Unveil and unix.UnveilBlock are only defined for OpenBSD. When building this program for other operating systems by setting the GOOS environment variable, it will fail.

$ GOOS=openbsd go build
$ GOOS=linux go build
# non-portable-example
./main.go:6:17: undefined: unix.Unveil
./main.go:9:17: undefined: unix.UnveilBlock

If there is code in the codebase that is restricted to certain operating systems, calling it will either fail, or the code will fail to compile, as just demonstrated.

Go Build Constraints

Go comes with an easy way to do conditional builds, including or excluding some files for certain targets. These are the so called build constraints.

They can be used by adding a build tag at the top of the Go file. A simple build tag restricting the file to OpenBSD looks like this.

//go:build openbsd

If only a few OS-restricted functions are involved, a new function that mimicks the original’s signature can be introduced. For the supporting OS, this function wraps the original function. Otherwise, it is either a no-op or raises an error, depending on the use case.

This pattern can be applied to the unveil(2) example above. In an unveil_openbsd.go file, the original functions are being wrapped. The same functions are then implemented in unveil_fallback.go, with an empty function body. In the end, the main.go file now calls the mimicking functions instead of the originals.

// unveil_openbsd.go
//go:build openbsd

package main

import "golang.org/x/sys/unix"

func Unveil(path, permissions string) error {
	return unix.Unveil(path, permissions)
}

func UnveilBlock() error {
	return unix.UnveilBlock()
}
// unveil_fallback.go
//go:build !openbsd

package main

func Unveil(_, _ string) error {
	return nil
}

func UnveilBlock() error {
	return nil
}
// main.go

package main

func main() {
	if err := Unveil(".", "r"); err != nil {
		panic(err)
	}
	if err := UnveilBlock(); err != nil {
		panic(err)
	}
}

Building this modified version will work with the restriction enabled on OpenBSD, while still working unrestricted on any other operating system.

Abstraction Layer

This approach only scales so far. If there are multiple OS-dependent functions and the goal is to support multiple operating systems, this pattern will result in a lot of unnecessary stub functions that will eventually be forgotten.

There is an infamous theorem that any problem in software “engineering” can be solved by introducing another level of indirection. It is said that some even try to apply this to the problem of too much indirection itself.

Nevertheless, abstraction may save us here.

Instead of creating multiple shadow functions, a single function is introduced, having a lookup map to be populated by OS-specific implementations for all possible restrictions.

// Restriction defines an OS-specific restriction type.
type Restriction int

const (
	_ Restriction = iota

	// RestrictLinuxLandlock sets a Landlock LSM filter on Linux.
	//
	// The Restrict() function expects (multiple) landlock.Rule arguments.
	RestrictLinuxLandlock

	// RestrictOpenBSDUnveil sets and blocks unveil(2) on OpenBSD.
	//
	// The Restrict() function expects (multiple) string pairs like
	// ".", "r", "tmp", "rwc". After calling unveil(2) for each pair of path and
	// permission, unveil will be locked.
	RestrictOpenBSDUnveil
)

// restrictionFns is the internal map to be populated for each operating system.
var restrictionFns = make(map[Restriction]func(...any) error)

// Restrict operating system specific permissions.
//
// Based on the Restriction kind, the variadic function arguments differ. They
// are described at their definition.
//
// Note, unknown or unsupported Restrictions will be ignored and do NOT result
// in an error. One may call RestrictOpenBSDUnveil on a Linux without any error.
func Restrict(kind Restriction, args ...any) error {
	fn, ok := restrictionFns[kind]
	if !ok {
		return nil
	}
	return fn(args...)
}

The package-private restrictionFns map can be filled with multiple init functions, like the following.

//go:build linux

package main

import (
	"fmt"

	"github.com/landlock-lsm/go-landlock/landlock"
)

func init() {
	restrictionFns[RestrictLinuxLandlock] = func(args ...any) error {
		rules := make([]landlock.Rule, len(args))
		for i, arg := range args {
			rule, ok := arg.(landlock.Rule)
			if !ok {
				return fmt.Errorf("landlock parameter %d is not a landlock.Rule but %T", i, arg)
			}
			rules[i] = rule
		}

		return landlock.V5.BestEffort().Restrict(rules...)
	}
}
//go:build openbsd

package main

import (
	"fmt"

	"golang.org/x/sys/unix"
)

func init() {
	restrictionFns[RestrictOpenBSDUnveil] = func(args ...any) error {
		if len(args) == 0 || len(args)%2 != 0 {
			return fmt.Errorf("unveil expects two parameters or a multiple of two")
		}

		for i := 0; i < len(args); i += 2 {
			path, ok := args[i].(string)
			if !ok {
				return fmt.Errorf("unveil expects first parameter to be a string, not %T", args[i])
			}

			flags, ok := args[i+1].(string)
			if !ok {
				return fmt.Errorf("unveil expects second parameter to be a string, not %T", args[i+1])
			}

			if err := unix.Unveil(path, flags); err != nil {
				return fmt.Errorf("cannot unveil(%q, %q): %w", path, flags, err)
			}
		}

		return unix.UnveilBlock()
	}
}

Eventually, an external caller can use this as follows.

func main() {
	if err := Restrict(
		RestrictLinuxLandlock,
		landlock.RODirs("/proc", "."),
		landlock.RWDirs("/tmp"),
	); err != nil {
		log.Fatalf("cannot filter Landlock LSM: %v", err)
	}

	if err := Restrict(
		RestrictOpenBSDUnveil,
		".", "r",
		"/tmp", "rwc",
	); err != nil {
		log.Fatalf("cannot unveil(2): %v", err)
	}

	home, err := os.UserHomeDir()
	if err != nil {
		log.Fatalf("cannot obtain home dir: %v", err)
	}

	for _, dir := range []string{".", filepath.Join(home, ".ssh")} {
		_, err := os.ReadDir(dir)
		if err != nil {
			log.Printf("cannot read dir %s: %v", dir, err)
		} else {
			log.Printf("can read dir %s", dir)
		}
	}

	tmpF, err := os.Create("/tmp/08-multi-os-demo")
	if err != nil {
		log.Fatalf("cannot create temp file: %v", err)
	}
	if _, err := fmt.Fprint(tmpF, "hello world"); err != nil {
		log.Fatalf("cannot write temp file: %v", err)
	}
	if err := tmpF.Close(); err != nil {
		log.Fatalf("cannot close temp file: %v", err)
	}
	log.Print("created temp file")
}

Running the code produces the expected outcome.

2025/03/17 21:18:20 can read dir .
2025/03/17 21:18:20 cannot read dir /home/alvar/.ssh: open /home/alvar/.ssh: no such file or directory
2025/03/17 21:18:20 created temp file

Did Someone Say Abstraction Layer?!

While the calls to other operating systems are now harmless no-ops, there is still a distinction within the code. This looks like a textbook example for abstraction. For moar abstraction.

The existing structure already allows adding Restriction types independent of any operating system. It was just the implementation so far that made them OS-dependent. So another entry in the const-and-not-enum list may follow:

	// RestrictFileSystemAccess is an OS-independent abstraction to limit
	// directories to be accessed.
	//
	// The Restrict() function expects two string arrays, one listing directories
	// for reading, writing and executing and a second one for reading and
	// executing only.
	RestrictFileSystemAccess

Its implementation can be built on top of RestrictLinuxLandlock and RestrictOpenBSDUnveil. Both share the same type checking boilerplate and then call the underlying layer.

// Addition to the _linux.go implementation.

func init() {
	restrictionFns[RestrictLinuxLandlock] = func(args ...any) error { /* ... */ }

	restrictionFns[RestrictFileSystemAccess] = func(args ...any) error {
		if len(args) != 2 {
			return fmt.Errorf("RestrictFileSystemAccess expects two string arrays, not %T", args)
		}
		rwDirs, okRwDirs := args[0].([]string)
		roDirs, okRoDirs := args[1].([]string)
		if !okRwDirs || !okRoDirs {
			return fmt.Errorf("RestrictFileSystemAccess expects two string arrays, not %T and %T", args[0], args[1])
		}

		return restrictionFns[RestrictLinuxLandlock](
			landlock.RWDirs(rwDirs...),
			landlock.RODirs(append(roDirs, "/proc")...))
	}
}
// Addition to the _openbsd.go implementation.

func init() {
	restrictionFns[RestrictOpenBSDUnveil] = func(args ...any) error { /* ... */ }

	restrictionFns[RestrictFileSystemAccess] = func(args ...any) error {
		if len(args) != 2 {
			return fmt.Errorf("RestrictFileSystemAccess expects two string arrays, not %T", args)
		}
		rwDirs, okRwDirs := args[0].([]string)
		roDirs, okRoDirs := args[1].([]string)
		if !okRwDirs || !okRoDirs {
			return fmt.Errorf("RestrictFileSystemAccess expects two string arrays, not %T and %T", args[0], args[1])
		}

		rules := make([]any, 0, 2*len(rwDirs)+2*len(roDirs))
		for _, rwDir := range rwDirs {
			rules = append(rules, rwDir, "rwxc")
		}
		for _, roDir := range roDirs {
			rules = append(rules, roDir, "rx")
		}

		return restrictionFns[RestrictOpenBSDUnveil](rules...)
	}
}

Finally, the two OS-specific Restriction(...) calls in the main function can be unified.

	if err := Restrict(
		RestrictFileSystemAccess,
		[]string{"/tmp"},
		[]string{"."},
	); err != nil {
		log.Fatalf("cannot restrict file system access: %v", err)
	}

While this was not even my initial intent, this example of abstraction and unification shows both the advantages and disadvantages of the general idea.

On the plus side, a caller does not need to know any details about either Landlock LSM or unveil(2). Under the hood, implementations for any other operating system can be added or replaced, and hopefully it will just work.

The trade-off is that the nuances of each implementation will be lost. For example, unveil(2) explicitly allows or denies execution for each path, Landlock LSM does not. Thus, the generalized API is reduced to the least common denominator of always allowing file execution, even if not necessary.

What To Use?

It is hard to say how many layers of abstraction to stack on another.

If the software only targets one operating system, nothing here matters - just let compilation fail on other platforms. Depending on the size of the software and the importance of portability, some level of abstraction will prove useful. Personally, I would mostly not attempt the last example of a unified API, since its generalized nature prevents access to specifics.

But again, it depends. And there are even other ways, using other technologies that were totally out of scope for this post.

For example, in the recent Go version 1.24, os.Root was added. This is a Go API allowing to restrict file system calls going through it to be bound to a specific directory, described in more detail in Damien Neil’s “Traversal-resistant file APIs”. While it does not restrict the whole process, it is easy to use without having to deal with the effects of the restriction in other places. These features are not mutually exclusive, of course.

What to use now? Still depends.

However, if you just want the code shown in this post, look no further: codeberg.org/oxzi/go-privsep-showcase.