Files
mesh-host/internal/apply/roots.go
T
jschoubben 2a2be687f3 Keep libraries under groups of their own, and give modules users in them (hq ADR 0328, issue 567)
ADR 0328 supersedes ADR 0325: shared data is a library with one home, kept usable by
its group rather than by ACL entries. The engine now makes each library's group and
each module's user in its libraries' groups; keeps a library's root and requested
sub-paths in its group with group write and setgid on its home, making missing
sub-paths as the root is; waits for the operator's word before touching a root that
holds data, and on the take act walks it by descriptor (no link, same filesystem,
bounded and resumed, files with other names left); holds the containers of a library
whose disk or mount is not there; and puts the tool runner's account in the group.
The ACL keeping, mask repair and watch are gone; an access is again as before ADR 0325.
2026-10-12 22:11:09 +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 0328 §15).
//
// 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, kept for the legacy reader), 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 0328 §15), 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 0328 §15)", 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 0328 §15)", 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] }