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] }