~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
220 lines · 6.3 KB Raw History
package devcheck

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

type Status string

const (
	StatusOK   Status = "ok"
	StatusWarn Status = "warn"
	StatusFail Status = "fail"
)

const (
	CheckNotRoot        = "not-root"
	CheckRuntimeDir     = "xdg-runtime-dir"
	CheckKVM            = "dev-kvm"
	CheckTun            = "dev-net-tun"
	CheckUserns         = "unprivileged-userns"
	CheckAppArmorUserns = "apparmor-userns-restriction"
	CheckNewuidmap      = "newuidmap-newgidmap"
	CheckSubuid         = "subuid-subgid"
	CheckLogindKill     = "logind-kill-user-processes"
)

var Names = []string{CheckNotRoot, CheckRuntimeDir, CheckKVM, CheckTun, CheckUserns, CheckAppArmorUserns,
	CheckNewuidmap, CheckSubuid, CheckLogindKill}

type Env interface {
	LookupEnv(key string) (string, bool)
}

type Host struct {
	Root     string
	Euid     int
	User     string
	Env      Env
	LookPath func(name string) (string, error)
}

type Result struct {
	Name   string `json:"name"`
	Status Status `json:"status"`
	Needed string `json:"neededBy"`
	Detail string `json:"detail"`
	Fix    string `json:"fix,omitempty"`
}

func (h Host) path(p string) string { return filepath.Join(h.Root, p) }

func (h Host) read(p string) (string, bool) {
	b, err := os.ReadFile(h.path(p))
	if err != nil {
		return "", false
	}
	return strings.TrimSpace(string(b)), true
}

func (h Host) readWrite(p string) error {
	f, err := os.OpenFile(h.path(p), os.O_RDWR, 0)
	if err != nil {
		return err
	}
	return f.Close()
}

func Failed(rs []Result) bool {
	for _, r := range rs {
		if r.Status == StatusFail {
			return true
		}
	}
	return false
}

func Run(h Host) []Result { return hostChecks(h) }

func notRoot(h Host) Result {
	r := Result{Name: CheckNotRoot, Needed: "every dev verb", Status: StatusOK, Detail: "euid " + strconv.Itoa(h.Euid)}
	if h.Euid == 0 {
		r.Status, r.Fix = StatusFail, "run dev as your own user; dev mode needs no root and production uses install"
	}
	return r
}

func device(h Host, name, dev, needed, fix string) Result {
	r := Result{Name: name, Needed: needed, Status: StatusOK, Detail: dev + " is readable and writable"}
	if err := h.readWrite(dev); err != nil {
		r.Status, r.Detail, r.Fix = StatusFail, err.Error(), fix
	}
	return r
}

func userns(h Host) Result {
	r := Result{Name: CheckUserns, Needed: "pasta and per-VM user namespaces", Status: StatusOK}
	maxNS, ok := h.read("/proc/sys/user/max_user_namespaces")
	n, err := strconv.Atoi(maxNS)
	if !ok || err != nil || n <= 0 {
		r.Status, r.Detail = StatusFail, "user.max_user_namespaces is "+maxNS
		r.Fix = "sysctl user.max_user_namespaces to a positive value (root)"
		return r
	}
	r.Detail = "user.max_user_namespaces=" + maxNS
	if v, ok := h.read("/proc/sys/kernel/unprivileged_userns_clone"); ok && v != "1" {
		r.Status, r.Detail = StatusFail, "kernel.unprivileged_userns_clone="+v
		r.Fix = "sysctl kernel.unprivileged_userns_clone=1 (root)"
	}
	return r
}

func apparmor(h Host) Result {
	r := Result{Name: CheckAppArmorUserns, Needed: "pasta (Ubuntu 24.04+)", Status: StatusOK}
	v, ok := h.read("/proc/sys/kernel/apparmor_restrict_unprivileged_userns")
	switch {
	case !ok:
		r.Detail = "no AppArmor userns restriction on this kernel"
	case v == "1":
		r.Status, r.Detail = StatusWarn, "kernel.apparmor_restrict_unprivileged_userns=1 (dev reports it; AppArmor in dev is report-only)"
		r.Fix = "install the AppArmor profile granting userns to the pinned pasta path (install does it in production), or sysctl the restriction off (root)"
	default:
		r.Detail = "kernel.apparmor_restrict_unprivileged_userns=" + v
	}
	return r
}

func newuidmap(h Host) Result {
	r := Result{Name: CheckNewuidmap, Needed: "per-VM subuid user namespaces", Status: StatusOK}
	var found, missing []string
	for _, n := range []string{"newuidmap", "newgidmap"} {
		p, err := h.LookPath(n)
		if err != nil {
			missing = append(missing, n)
			continue
		}
		found = append(found, p)
	}
	r.Detail = strings.Join(found, " ")
	if len(missing) > 0 {
		r.Status, r.Detail = StatusFail, "not found: "+strings.Join(missing, " ")
		r.Fix = "install the uidmap (Debian/Ubuntu) or shadow-utils (Fedora) package (root)"
	}
	return r
}

func hasRange(content, user string, uid int) bool {
	for line := range strings.SplitSeq(content, "\n") {
		f := strings.Split(strings.TrimSpace(line), ":")
		if len(f) == 3 && (f[0] == user || f[0] == strconv.Itoa(uid)) {
			return true
		}
	}
	return false
}

func subuid(h Host) Result {
	r := Result{Name: CheckSubuid, Needed: "per-VM subuid user namespaces", Status: StatusOK}
	var missing []string
	for _, f := range []string{"/etc/subuid", "/etc/subgid"} {
		c, _ := h.read(f)
		if !hasRange(c, h.User, h.Euid) {
			missing = append(missing, f)
		}
	}
	r.Detail = "a range for " + h.User + " in /etc/subuid and /etc/subgid"
	if len(missing) > 0 {
		r.Status, r.Detail = StatusFail, "no range for "+h.User+" in "+strings.Join(missing, " ")
		r.Fix = fmt.Sprintf("usermod --add-subuids 100000-165535 --add-subgids 100000-165535 %s (root)", h.User)
	}
	return r
}

func logindSetting(h Host) (string, string) {
	files := []string{"/usr/lib/systemd/logind.conf", "/etc/systemd/logind.conf"}
	for _, dir := range []string{"/usr/lib/systemd/logind.conf.d", "/etc/systemd/logind.conf.d"} {
		matches, _ := filepath.Glob(filepath.Join(h.path(dir), "*.conf"))
		sort.Strings(matches)
		for _, m := range matches {
			rel, _ := filepath.Rel(h.Root, m)
			files = append(files, "/"+rel)
		}
	}
	value, from := "", ""
	for _, f := range files {
		fh, err := os.Open(h.path(f))
		if err != nil {
			continue
		}
		sc := bufio.NewScanner(fh)
		for sc.Scan() {
			k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), "=")
			if ok && strings.TrimSpace(k) == "KillUserProcesses" {
				value, from = strings.TrimSpace(v), f
			}
		}
		fh.Close()
	}
	return value, from
}

func logind(h Host) Result {
	r := Result{Name: CheckLogindKill, Needed: "a detached devsup surviving logout", Status: StatusOK}
	v, from := logindSetting(h)
	switch strings.ToLower(v) {
	case "yes", "true", "1", "on":
		r.Status, r.Detail = StatusWarn, "KillUserProcesses="+v+" in "+from+": a detached devsup dies at logout"
		r.Fix = "use dev up --fg, or set KillUserProcesses=no in logind.conf (root)"
	case "":
		r.Detail = "KillUserProcesses unset (compiled default no on this distribution family)"
	default:
		r.Detail = "KillUserProcesses=" + v + " in " + from
	}
	return r
}