Files
mesh-host/internal/apply/roots.go
T
jschoubben 51e97a8c14 Give every module an identity and keep shared folders through ACL entries (hq ADR 0325, issue 567)
A module's containers ran as whatever their image chose and shared folders were
made writable by putting accounts in the folder's group (ADR 0312), which ADR 0325
supersedes. The engine now makes each declared module identity as a system account
with the controller's numbers, runs a container declaring `identity` as it, and keeps
each placed shared folder usable through one POSIX ACL entry per user, set through
the kernel's xattrs on descriptors it opened itself. A folder holding data is kept
only after the operator's warrant (the take act); a missing folder is made where its
data lives and waits where its disk is not; root's data is never kept. A watch and a
bounded sweep put back what an app narrows. The group grant is removed, and what it
put an account in is taken back.

Reuses the descriptor walks, the create act, the missing-path wait and the warrant-
backed act message from the paused issue 567 branch.
2026-10-12 18:43:04 +02:00

148 lines
4.6 KiB
Go

package apply
import (
"context"
"fmt"
"os"
"os/exec"
"os/user"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/accounts"
)
// Root's numbers, which no module identity is and whose data is never kept (novox/hq ADR 0325 §11, §12).
//
// A module identity whose uid or gid is 0, or whose gid is a group whose members act as root (accounts.RootGroups,
// the account judge's own closed list), would be a way to root through a container or a shared folder: refused,
// naming why. A shared folder owned by root or such a group is the machine's own data, never kept.
// RootGroupIDs is the number of every group whose members act as root (accounts.RootGroups), as this machine
// numbers them: read from /etc/group itself and from the machine's name service (`getent group`), since the engine
// is built without cgo and Go's own lookup reads /etc/group alone — a group a name service holds, or one number
// carrying several names, would otherwise pass a check made by name. Read at most once a minute, every look and
// apply judging against the same answer, and the name service given five seconds in all. A variable so a test can
// stand in.
var RootGroupIDs = func() map[int]string {
rootIDs.Lock()
defer rootIDs.Unlock()
if rootIDs.ids == nil || time.Since(rootIDs.at) > time.Minute {
rootIDs.ids, rootIDs.at = readRootGroupIDs(), time.Now()
}
return rootIDs.ids
}
var rootIDs struct {
sync.Mutex
ids map[int]string
at time.Time
}
func readRootGroupIDs() map[int]string {
out := map[int]string{}
if raw, err := os.ReadFile("/etc/group"); err == nil {
for name, gid := range groupEntries(string(raw)) {
if slices.Contains(accounts.RootGroups, name) {
out[gid] = name
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, name := range accounts.RootGroups {
raw, err := exec.CommandContext(ctx, "getent", "group", name).Output()
if err != nil {
continue
}
for found, gid := range groupEntries(string(raw)) {
if found == name {
out[gid] = name
}
}
}
return out
}
// groupEntries reads group database lines, name:password:gid:members, as name to number.
func groupEntries(raw string) map[string]int {
out := map[string]int{}
for _, line := range strings.Split(raw, "\n") {
fields := strings.Split(strings.TrimSpace(line), ":")
if len(fields) < 3 || strings.HasPrefix(fields[0], "#") {
continue
}
if gid, err := strconv.Atoi(fields[2]); err == nil {
out[fields[0]] = gid
}
}
return out
}
// GroupName is the name this machine's group database gives a number, empty when none. A variable so a test can
// stand in for groups it cannot make.
var GroupName = func(gid int) string {
if g, err := user.LookupGroupId(strconv.Itoa(gid)); err == nil {
return g.Name
}
return ""
}
// rootGroup is why gid is a root group here — 0, a number a root group has, or a number named as one — or "".
func rootGroup(gid int) string {
if gid == 0 {
return "group 0"
}
if name, is := RootGroupIDs()[gid]; is {
return fmt.Sprintf("gid %d, which is %s here, whose members act as root", gid, name)
}
if name := GroupName(gid); slices.Contains(accounts.RootGroups, name) {
return fmt.Sprintf("gid %d, which is %s here, whose members act as root", gid, name)
}
return ""
}
// rootOwned is why a folder owned by uid and gid is root's data (ADR 0325 §12), or "": owned by root, or by a
// root group.
func rootOwned(uid, gid uint32) string {
if uid == 0 {
return "owned by root"
}
if why := rootGroup(int(gid)); why != "" {
return "owned by " + why
}
return ""
}
// rootIdentity is why a module identity's numbers are refused (ADR 0325 §11), or nil.
func rootIdentity(uid, gid int) error {
if uid <= 0 {
return fmt.Errorf("its uid is %d, and a module's identity is never root (novox/hq ADR 0325 §11)", uid)
}
if why := rootGroup(gid); why != "" {
return fmt.Errorf("its gid is %s, and a module's identity is never in a root group (novox/hq ADR 0325 §11)", why)
}
return nil
}
// Handed is every group a record that went in this apply put an account in, and another declared resource still
// asks for: account → group. The resource that still asks records it as the mesh's when it is applied, so the group
// is taken back once nobody asks for it any more, rather than staying for ever because the record that put it
// there went first (novox/hq ADR 0252 §3).
type Handed map[string]map[string]bool
func (h Handed) hand(account, group string) {
if h == nil {
return
}
if h[account] == nil {
h[account] = map[string]bool{}
}
h[account][group] = true
}
func (h Handed) has(account, group string) bool { return h[account][group] }