Report whether an unprivileged user namespace can be made (issue 1308)
mesh/repo-check head 64dfa90c3a6c merged into trunk 454be9b2693d — pass: its merge-check.sh passed
mesh/merge-gate head 64dfa90c3a6c merged into trunk 454be9b2693d — pass: group feat/r4a-sdk-first composed: every machine composes with the change as it di…
mesh/delivery delivered
mesh/delivery-group group feat/r4a-sdk-first delivered: every member is delivered
mesh/repo-check head 64dfa90c3a6c merged into trunk 454be9b2693d — pass: its merge-check.sh passed
mesh/merge-gate head 64dfa90c3a6c merged into trunk 454be9b2693d — pass: group feat/r4a-sdk-first composed: every machine composes with the change as it di…
mesh/delivery delivered
mesh/delivery-group group feat/r4a-sdk-first delivered: every member is delivered
A build module's sandbox needs one, and a module can require only a capability the node-engine detects (ADR 0448 section 5). The probe drops to an unprivileged uid first when run as root, since root always can.
This commit is contained in:
@@ -24,6 +24,11 @@ const (
|
||||
CapSeat = "seat"
|
||||
CapPrivileged = "privileged"
|
||||
|
||||
// CapUserNamespaces is whether an unprivileged user can make a user namespace: what a build
|
||||
// module's sandbox (bubblewrap) needs (novox/hq ADR 0448 section 5). Proven by making one, as
|
||||
// an unprivileged user, never by a version string or a sysctl alone.
|
||||
CapUserNamespaces = "user-namespaces"
|
||||
|
||||
// The network manager this machine runs, one capability per dialect (novox/hq ADR 0161): the
|
||||
// uplink seat's holder declares its own, so the holder for a manager the machine does not run
|
||||
// is refused the way any missing capability is, naming it. Active, not installed — a machine
|
||||
@@ -189,6 +194,7 @@ func Default(runner Runner) []Detector {
|
||||
seat{},
|
||||
battery{},
|
||||
powerMeter{},
|
||||
userNamespaces(runner),
|
||||
commandCapability{
|
||||
name: CapContainerRuntime, command: "docker", args: []string{"info", "--format", "{{.ServerVersion}}"},
|
||||
why: "asks the daemon for its version — a running daemon, not an installed client",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// effectiveUID and readProcFile are replaceable so the unit tests stay off the machine.
|
||||
var (
|
||||
effectiveUID = os.Geteuid
|
||||
readProcFile = func(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
return string(b), err
|
||||
}
|
||||
)
|
||||
|
||||
// unprivilegedID is the uid and gid the probe drops to (nobody on every mesh node).
|
||||
const unprivilegedID = "65534"
|
||||
|
||||
// userNamespaces builds the detector for CapUserNamespaces: it makes a user namespace as an
|
||||
// unprivileged user. The node-engine runs as root, and root can always make one, so as root the
|
||||
// probe drops first with setpriv (util-linux). Go's SysProcAttr Cloneflags with a Credential
|
||||
// would clone before the uid drops and prove nothing, so it is not used.
|
||||
func userNamespaces(runner Runner) Detector {
|
||||
command, args := "unshare", []string{"--user", "true"}
|
||||
if effectiveUID() == 0 {
|
||||
command = "setpriv"
|
||||
args = []string{"--reuid=" + unprivilegedID, "--regid=" + unprivilegedID, "--clear-groups", "unshare", "--user", "true"}
|
||||
}
|
||||
read := readProcFile // bound now, so a probe cut by the timeout never reads a later replacement
|
||||
return commandCapability{
|
||||
name: CapUserNamespaces, command: command, args: args,
|
||||
why: "makes a user namespace as an unprivileged user — what bubblewrap needs, not a kernel setting read",
|
||||
interpret: func(_ string, err error) (bool, string) {
|
||||
if err == nil {
|
||||
return true, "an unprivileged user can make a user namespace"
|
||||
}
|
||||
return false, err.Error() + userNamespaceSettings(read)
|
||||
},
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
// userNamespaceSettings names the two kernel settings that switch user namespaces off, when
|
||||
// they can be read. It is added to the reason of an absent verdict only.
|
||||
func userNamespaceSettings(read func(string) (string, error)) string {
|
||||
var parts []string
|
||||
for _, s := range []struct{ path, name string }{
|
||||
{"/proc/sys/kernel/unprivileged_userns_clone", "unprivileged_userns_clone"},
|
||||
{"/proc/sys/user/max_user_namespaces", "max_user_namespaces"},
|
||||
} {
|
||||
if v, err := read(s.path); err == nil {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", s.name, strings.TrimSpace(v)))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (" + strings.Join(parts, ", ") + ")"
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// recorded is a runner that remembers every command it was given and answers the user-namespace
|
||||
// probe with the given output and error. Other detectors get "not here".
|
||||
type recorded struct {
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
out string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recorded) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "setpriv" && name != "unshare" {
|
||||
return "", errors.New("not here")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
return r.out, r.err
|
||||
}
|
||||
|
||||
// asUID makes the detector believe it runs as uid, and reads no files from the machine.
|
||||
func asUID(t *testing.T, uid int, procs map[string]string) {
|
||||
t.Helper()
|
||||
oldUID, oldRead := effectiveUID, readProcFile
|
||||
effectiveUID = func() int { return uid }
|
||||
readProcFile = func(path string) (string, error) {
|
||||
if v, ok := procs[path]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", errors.New("no such file")
|
||||
}
|
||||
t.Cleanup(func() { effectiveUID, readProcFile = oldUID, oldRead })
|
||||
}
|
||||
|
||||
func verdictOf(t *testing.T, p Profile, name string) Verdict {
|
||||
t.Helper()
|
||||
for _, v := range p.Capabilities {
|
||||
if v.Name == name {
|
||||
return v
|
||||
}
|
||||
}
|
||||
t.Fatalf("no verdict named %s in the profile", name)
|
||||
return Verdict{}
|
||||
}
|
||||
|
||||
// unshare's own words when user namespaces are off, from util-linux's unshare.c:
|
||||
// err(EXIT_FAILURE, _("unshare failed")), which prints "unshare: unshare failed: " and strerror(EPERM).
|
||||
// ExecRunner wraps stderr as "<name> exited <code>: <stderr>".
|
||||
const unshareRefused = "unshare exited 1: unshare: unshare failed: Operation not permitted"
|
||||
|
||||
func TestUserNamespacesAsRootDropsToAnUnprivilegedUIDFirst(t *testing.T) {
|
||||
// 1308/AC1. Root can always make a user namespace, so a probe run as root proves nothing: the
|
||||
// command must drop to an unprivileged uid before unshare runs.
|
||||
asUID(t, 0, nil)
|
||||
r := &recorded{}
|
||||
got := Detect(context.Background(), Default(r.run), time.Second)
|
||||
|
||||
v := verdictOf(t, got, CapUserNamespaces)
|
||||
if !v.Present {
|
||||
t.Fatalf("a successful probe was reported absent: %+v", v)
|
||||
}
|
||||
want := "setpriv --reuid=65534 --regid=65534 --clear-groups unshare --user true"
|
||||
if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != want {
|
||||
t.Fatalf("the probe ran %v, want one call: %s", r.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserNamespacesAbsentCarriesTheCommandsWords(t *testing.T) {
|
||||
// 1308/AC2. The failing probe gives absent, and the detail is what unshare said.
|
||||
asUID(t, 0, nil)
|
||||
r := &recorded{err: errors.New(unshareRefused)}
|
||||
got := Detect(context.Background(), Default(r.run), time.Second)
|
||||
|
||||
v := verdictOf(t, got, CapUserNamespaces)
|
||||
if v.Present {
|
||||
t.Fatal("a probe that failed was reported present")
|
||||
}
|
||||
if !strings.Contains(v.Detail, "unshare failed: Operation not permitted") {
|
||||
t.Fatalf("the command's words were lost; detail was %q", v.Detail)
|
||||
}
|
||||
if strings.TrimSpace(v.How) == "" {
|
||||
t.Fatal("an absent verdict with no stated method")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserNamespacesAbsentSaysWhyFromTheKernelSettings(t *testing.T) {
|
||||
// 1308/AC2, the reasons: the two kernel settings that switch user namespaces off are read
|
||||
// through a replaceable function and named in the detail.
|
||||
asUID(t, 0, map[string]string{
|
||||
"/proc/sys/user/max_user_namespaces": "0\n",
|
||||
"/proc/sys/kernel/unprivileged_userns_clone": "0\n",
|
||||
})
|
||||
r := &recorded{err: errors.New(unshareRefused)}
|
||||
v := verdictOf(t, Detect(context.Background(), Default(r.run), time.Second), CapUserNamespaces)
|
||||
|
||||
if v.Present {
|
||||
t.Fatal("reported present")
|
||||
}
|
||||
// The detail is one line (firstLine), so the settings must be on that line.
|
||||
if !strings.Contains(v.Detail, "max_user_namespaces=0") || !strings.Contains(v.Detail, "unprivileged_userns_clone=0") {
|
||||
t.Fatalf("the kernel settings are not in the detail: %q", v.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserNamespacesNotAsRootUnsharesDirectly(t *testing.T) {
|
||||
// 1308/AC3. An unprivileged node-engine is already the unprivileged user: no setpriv.
|
||||
asUID(t, 1000, nil)
|
||||
r := &recorded{}
|
||||
v := verdictOf(t, Detect(context.Background(), Default(r.run), time.Second), CapUserNamespaces)
|
||||
|
||||
if !v.Present {
|
||||
t.Fatalf("a successful probe was reported absent: %+v", v)
|
||||
}
|
||||
if len(r.calls) != 1 || strings.Join(r.calls[0], " ") != "unshare --user true" {
|
||||
t.Fatalf("the probe ran %v, want: unshare --user true", r.calls)
|
||||
}
|
||||
if strings.TrimSpace(v.Detail) == "" || strings.TrimSpace(v.How) == "" {
|
||||
t.Fatalf("present verdict lacks detail or how: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserNamespacesProbeThatHangsIsCutAndTheRestStillReport(t *testing.T) {
|
||||
// 1308/AC4. A wedged unshare is cut by Detect's timeout; the other detectors still answer.
|
||||
asUID(t, 0, nil)
|
||||
blocking := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "setpriv" {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
done := make(chan Profile, 1)
|
||||
go func() { done <- Detect(context.Background(), Default(blocking), 50*time.Millisecond) }()
|
||||
select {
|
||||
case got := <-done:
|
||||
if verdictOf(t, got, CapUserNamespaces).Present {
|
||||
t.Error("a probe that never answered was reported present")
|
||||
}
|
||||
if !got.Has(CapPackageManager) {
|
||||
t.Error("the other capabilities were lost behind the wedged probe")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("detection did not return")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user