The engine is built without cgo, so a check by name missed a root group a name service holds or one number carrying another name: the root groups are resolved to numbers (now with kmem and shadow) and a number named nothing is refused for containers too. A group without write is granted nothing (§2). The look reads each path off the lock within five seconds, so a dead network mount cannot stop every health statement. A grant's account may not start with a dash. A group a record leaves for another asker is handed to it, so it is still taken back when the last asker goes (§5). The scheduler skips paths issue 339's guard refuses.
515 lines
20 KiB
Go
515 lines
20 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/accounts"
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// A read-write access grants the path's own group (novox/hq ADR 0312, issue 537).
|
|
//
|
|
// **An access is not owning.** ADR 0051 left the operator's shared paths exactly as they are: the engine
|
|
// confirms one is there and mounts it, and sets no owner, group or mode on it or below it. Nothing decided how
|
|
// a `read-write` access then becomes writable, and a module whose identity was not the path's owner could not
|
|
// write it at all — a download client could not remove a folder it had filed (issue 535).
|
|
//
|
|
// **The path's group is the operator's statement of who may share it.** So the engine reads, at apply, which
|
|
// group owns the accessed path — only the machine knows — and makes the identities the controller names for
|
|
// the access members of it: each container that mounts it gains the group as a supplementary group of its
|
|
// process (`--group-add`, by number, because a container's own group database is not the machine's), and the
|
|
// account that runs the module's tools is put in it as an account group (ADR 0252 §1). The module then writes
|
|
// the path as its group may, and as nothing else.
|
|
//
|
|
// **The path is never changed** (§2). A group that cannot write the path is the operator's choice, as the data
|
|
// is: the access grants nothing, is stated unhealthy on every look, saying so and that the operator decides, and
|
|
// nothing is done to make it writable. **A read access grants nothing** (§3). **The root group is never granted** (§4): a
|
|
// path owned by group 0, or by a group whose members act as root (accounts.RootGroups, the account judge's own
|
|
// closed list, judged by the numbers those groups have here), or by a number the machine names nothing for, is
|
|
// refused by name.
|
|
//
|
|
// **What was granted is taken back as ADR 0252 takes back** (§5): the access records each group it put the
|
|
// account in that the account was not in before; when no declared resource asks for it any more, the account
|
|
// leaves it, and a group the account held before is never recorded and never taken back. A container needs no
|
|
// record: the group is part of what it is created as, so one no longer granted is recreated without it.
|
|
//
|
|
// **And what runs the code is restarted to hold it** (§6). A container is recreated with the group, because
|
|
// its spec carries it. The tool runner — a process of the machine's own service manager with `User=` — is
|
|
// restarted because the controller names the access under its `restart-on`, and the access is reported
|
|
// updated exactly when the account's groups moved: that manager reads the account's groups from the database
|
|
// each time it starts the unit, so the restarted process holds the group with no new login. A unit of the
|
|
// account's own manager, or a login session, keeps the groups its login began with: the account judge
|
|
// (internal/accounts) says *relogin needed* there, for the access's account as for a user resource's.
|
|
|
|
// PathGroup is the group that owns an accessed path, as the engine reads it.
|
|
type PathGroup struct {
|
|
GID int
|
|
// Name is the group's name in the machine's group database; empty when the database names nothing for
|
|
// the number.
|
|
Name string
|
|
// Writable is whether the group's write bit is set on the path itself.
|
|
Writable bool
|
|
}
|
|
|
|
// Words names the group for a person: its name and number, or its number alone.
|
|
func (g PathGroup) Words() string {
|
|
if g.Name == "" {
|
|
return fmt.Sprintf("gid %d", g.GID)
|
|
}
|
|
return fmt.Sprintf("%s (gid %d)", g.Name, g.GID)
|
|
}
|
|
|
|
// GroupOfPath reads which group owns a path, by its number, its name and whether it may write. Reads only. A
|
|
// variable so a test can stand in for a machine whose groups it cannot make.
|
|
var GroupOfPath = func(path string) (PathGroup, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return PathGroup{}, err
|
|
}
|
|
_, gid, ok := statOwner(info)
|
|
if !ok {
|
|
return PathGroup{}, fmt.Errorf("the group that owns %s cannot be read on this machine", path)
|
|
}
|
|
g := PathGroup{GID: gid, Writable: info.Mode().Perm()&0o020 != 0}
|
|
if found, err := user.LookupGroupId(strconv.Itoa(gid)); err == nil {
|
|
g.Name = found.Name
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GrantRefusal is why a path's group is never granted (novox/hq ADR 0312 §4), or nil when it may be: group 0, a
|
|
// group whose members act as root by membership alone — judged by its NUMBER against every number those groups
|
|
// have here, and by its name — or a group this machine names nothing for, which cannot be judged at all.
|
|
func GrantRefusal(g PathGroup) error {
|
|
const never = "a shared path is never a way to root (novox/hq ADR 0312 §4); nothing was granted, and nothing " +
|
|
"was changed on it"
|
|
if g.GID == 0 {
|
|
return fmt.Errorf("it is owned by group 0 (%s), and a read-write access never grants the root group: %s",
|
|
orRoot(g.Name), never)
|
|
}
|
|
if root, is := RootGroupIDs()[g.GID]; is {
|
|
return fmt.Errorf("it is owned by gid %d, which is %s here, whose members act as root, and a read-write "+
|
|
"access never grants such a group: %s", g.GID, root, never)
|
|
}
|
|
if slices.Contains(accounts.RootGroups, g.Name) {
|
|
return fmt.Errorf("it is owned by the group %s, whose members act as root, and a read-write access never "+
|
|
"grants such a group: %s", g.Words(), never)
|
|
}
|
|
if g.Name == "" {
|
|
return fmt.Errorf("it is owned by gid %d, which this machine's group database names nothing, so whether "+
|
|
"its members act as root cannot be judged, and an unjudged group is never granted: %s", g.GID, never)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CannotWriteWords is the reason an access whose group may not write its path is stated unhealthy with (novox/hq
|
|
// ADR 0312 §2). Never the path: the controller says a reason on the operator's channel, which withholds a path
|
|
// (ADR 0234 §6), and keeps the target as evidence.
|
|
func CannotWriteWords(g PathGroup) string {
|
|
return fmt.Sprintf("its group cannot write it: %s owns it without group write, and the mesh changes nothing on "+
|
|
"an accessed path (novox/hq ADR 0312 §2) — whether its group may write is the operator's to decide",
|
|
g.Words())
|
|
}
|
|
|
|
func orRoot(name string) string {
|
|
if name == "" {
|
|
return "root"
|
|
}
|
|
return name
|
|
}
|
|
|
|
// grant is what one read-write access grants, read once per apply.
|
|
type grant struct {
|
|
path string
|
|
group PathGroup
|
|
// refused is why nothing is granted: the path could not be read, or its group is never granted.
|
|
refused error
|
|
}
|
|
|
|
// grants says the group is given: it may be granted, and it may write the path. A group that cannot write is
|
|
// granted nothing (novox/hq ADR 0312 §2) — the access is stated unhealthy for the operator to decide.
|
|
func (g grant) grants() bool { return g.refused == nil && g.group.Writable }
|
|
|
|
// grantsOf reads the group of every read-write access that names a grant, by access id. One that is refused
|
|
// grants nothing — the access itself fails, saying why — and one whose path the engine refuses to place
|
|
// anything at (issue 339) is not read at all.
|
|
func grantsOf(resources []declaration.Resource, refused map[string]error) map[string]grant {
|
|
out := map[string]grant{}
|
|
for _, r := range resources {
|
|
a, ok := r.(*declaration.Access)
|
|
if !ok || a.Grant == nil || a.Mode != declaration.AccessReadWrite {
|
|
continue
|
|
}
|
|
if why := refused[filepath.Clean(a.Path)]; why != nil {
|
|
out[a.ID] = grant{path: a.Path, refused: why}
|
|
continue
|
|
}
|
|
g, err := GroupOfPath(a.Path)
|
|
if err == nil {
|
|
err = GrantRefusal(g)
|
|
}
|
|
out[a.ID] = grant{path: a.Path, group: g, refused: err}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// containerGroups is, for each container, the number of every group a read-write access grants it, sorted and
|
|
// once each: what it is created with as supplementary groups.
|
|
func containerGroups(resources []declaration.Resource, grants map[string]grant) map[string][]int {
|
|
out := map[string][]int{}
|
|
for _, r := range resources {
|
|
a, ok := r.(*declaration.Access)
|
|
if !ok || a.Grant == nil {
|
|
continue
|
|
}
|
|
g, granted := grants[a.ID]
|
|
if !granted || !g.grants() {
|
|
continue
|
|
}
|
|
for _, id := range a.Grant.Containers {
|
|
if !slices.Contains(out[id], g.group.GID) {
|
|
out[id] = append(out[id], g.group.GID)
|
|
}
|
|
}
|
|
}
|
|
for id := range out {
|
|
sort.Ints(out[id])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ContainerGroupsOf is containerGroups for a whole declaration, read now: what the scheduler arms a scheduled
|
|
// step with, so a fire runs with the groups an apply would have created it with. A path issue 339's guard refuses
|
|
// is not read, as the apply reads none.
|
|
func ContainerGroupsOf(d *declaration.Declaration) map[string][]int {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
return containerGroups(d.Resources, grantsOf(d.Resources, refusedPlaces(d.Resources)))
|
|
}
|
|
|
|
// withGrants adds to what the declaration asks of each account the groups its read-write accesses grant (novox/hq
|
|
// ADR 0312 §5): one user resource or access going leaves an account in a group another still asks for.
|
|
func (w Wanted) withGrants(resources []declaration.Resource, grants map[string]grant) Wanted {
|
|
for _, r := range resources {
|
|
a, ok := r.(*declaration.Access)
|
|
if !ok || a.Grant == nil || a.Grant.Account == "" {
|
|
continue
|
|
}
|
|
g, granted := grants[a.ID]
|
|
if !granted || !g.grants() {
|
|
continue
|
|
}
|
|
if w[a.Grant.Account] == nil {
|
|
w[a.Grant.Account] = map[string][]string{}
|
|
}
|
|
w[a.Grant.Account][g.group.Name] = append(w[a.Grant.Account][g.group.Name], a.ID)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// GrantedAccounts is every account a read-write access puts in its path's group, as the account judge reads
|
|
// them (novox/hq ADR 0312 §6): judged like a user resource's account groups, so a session of the account's own
|
|
// manager that began before is said *relogin needed*, and only there. Read now; a path whose group is refused
|
|
// or cannot be read asks nothing of the judge — the access says that itself.
|
|
func GrantedAccounts(d *declaration.Declaration, held map[string]bool) []accounts.Account {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
grants := grantsOf(d.Resources, refusedPlaces(d.Resources))
|
|
var out []accounts.Account
|
|
for _, r := range d.Resources {
|
|
a, ok := r.(*declaration.Access)
|
|
if !ok || a.Grant == nil || a.Grant.Account == "" || held[a.ID] {
|
|
continue
|
|
}
|
|
g, granted := grants[a.ID]
|
|
if !granted || !g.grants() {
|
|
continue
|
|
}
|
|
at := strings.LastIndex(a.ID, ".")
|
|
if at <= 0 || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) {
|
|
continue
|
|
}
|
|
out = append(out, accounts.Account{Module: a.ID[:at], ID: a.ID, Name: a.Grant.Account,
|
|
Groups: []string{g.group.Name}})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// grantAccess is applyAccess's grant (novox/hq ADR 0312): the account put in the path's group, what an earlier
|
|
// apply of this access put an account in and nobody asks for any more taken back, and the outcome said. The
|
|
// path itself is only read.
|
|
func grantAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied,
|
|
in inputs, out *Outcome) error {
|
|
var g grant
|
|
granting := r.Grant != nil && r.Mode == declaration.AccessReadWrite
|
|
if granting {
|
|
var read bool
|
|
g, read = in.grants[r.ID]
|
|
if !read {
|
|
// An apply that read nothing ahead — a caller with no inputs — reads it here.
|
|
g = grantsOf([]declaration.Resource{r}, in.refused)[r.ID]
|
|
}
|
|
if g.refused != nil {
|
|
return fmt.Errorf("%s grants nothing: %w", r.Path, g.refused)
|
|
}
|
|
}
|
|
// A group that cannot write is granted nothing (§2): what an earlier apply granted is taken back below.
|
|
account := ""
|
|
if granting && g.group.Writable {
|
|
account = r.Grant.Account
|
|
}
|
|
if account != "" && g.group.Name == "" {
|
|
return fmt.Errorf("%s is owned by %s, which this machine's group database names nothing, and an account "+
|
|
"is put in a group by name: %s was not (novox/hq ADR 0312 §1)", r.Path, g.group.Words(), account)
|
|
}
|
|
|
|
var said []string
|
|
moved := false
|
|
// What this access put an account in before. An access whose account changed gives the old one back
|
|
// everything it put it in; the new one starts with nothing recorded.
|
|
recorded := previous.Groups
|
|
if previous.User != "" && previous.User != account {
|
|
gave, words := takeBack(ctx, sys, previous.User, r.ID, previous.Groups, nil, run, in.groups)
|
|
moved = moved || gave
|
|
said = append(said, words...)
|
|
recorded = nil
|
|
}
|
|
var kept []string
|
|
if account != "" {
|
|
groups, err := system.GroupsOf(ctx, system.Runner(run), account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inGroup := setOf(groups)[g.group.Name]
|
|
if !inGroup {
|
|
if err := sys.AddUserToGroup(ctx, system.Runner(run), account, g.group.Name); err != nil {
|
|
return err
|
|
}
|
|
moved = true
|
|
said = append(said, fmt.Sprintf("put %s in %s, the group that owns it; the tool runner holds it once "+
|
|
"restarted, a session that began before only after a new login", account, g.group.Name))
|
|
}
|
|
// Recorded only when the mesh put it there, now or before, or a record that went handed it over: a group
|
|
// held before is never taken back.
|
|
if !inGroup || slices.Contains(recorded, g.group.Name) || in.handed.has(account, g.group.Name) {
|
|
kept = append(kept, g.group.Name)
|
|
}
|
|
var others []string
|
|
for _, x := range recorded {
|
|
if x != g.group.Name {
|
|
others = append(others, x)
|
|
}
|
|
}
|
|
gave, words := takeBack(ctx, sys, account, r.ID, others, &kept, run, in.groups)
|
|
moved = moved || gave
|
|
said = append(said, words...)
|
|
}
|
|
if len(kept) > 0 {
|
|
out.groups, out.user = kept, account
|
|
}
|
|
|
|
if granting {
|
|
var to []string
|
|
if len(r.Grant.Containers) > 0 {
|
|
to = append(to, "its containers "+strings.Join(r.Grant.Containers, ", "))
|
|
}
|
|
if account != "" {
|
|
to = append(to, account)
|
|
}
|
|
switch {
|
|
case !g.group.Writable:
|
|
out.Detail = joinDetail(out.Detail, fmt.Sprintf("its group %s cannot write it, so nothing is granted: "+
|
|
"stated unhealthy, and nothing was changed on it — the operator decides", g.group.Words()))
|
|
case len(to) > 0:
|
|
out.Detail = joinDetail(out.Detail, fmt.Sprintf("grants %s to %s", g.group.Words(), strings.Join(to, " and ")))
|
|
}
|
|
}
|
|
if len(said) > 0 {
|
|
out.Detail = joinDetail(out.Detail, strings.Join(said, "; "))
|
|
}
|
|
if moved {
|
|
// Updated exactly when the account's groups moved: what names this access under restart-on — the
|
|
// tool runner — is restarted to hold them (novox/hq ADR 0312 §6).
|
|
out.Action = "updated"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// takeBack takes account out of every group of groups the mesh put it in for the resource id, unless another
|
|
// declared resource still asks for it — then it stays, and stays recorded on keep when keep is given, so it is
|
|
// taken back once nobody asks. A group it is no longer in is nothing to give back. Never fatal: a failure is
|
|
// said, and the group stays recorded where it can be. Whether any group was taken back, and what was said.
|
|
func takeBack(ctx context.Context, sys system.System, account, id string, groups []string, keep *[]string,
|
|
run Runner, wanted Wanted) (bool, []string) {
|
|
if len(groups) == 0 {
|
|
return false, nil
|
|
}
|
|
in, err := system.GroupsOf(ctx, system.Runner(run), account)
|
|
if err != nil {
|
|
if keep != nil {
|
|
*keep = append(*keep, groups...)
|
|
}
|
|
return false, []string{fmt.Sprintf("the groups the mesh put %s in (%s) were not given back: %v", account,
|
|
strings.Join(groups, ", "), err)}
|
|
}
|
|
already := setOf(in)
|
|
gave := false
|
|
var said []string
|
|
for _, g := range groups {
|
|
if !already[g] {
|
|
continue
|
|
}
|
|
if others := wanted.othersAsk(account, g, id); len(others) > 0 {
|
|
said = append(said, fmt.Sprintf("left %s in %s, which %s still asks for", account, g, strings.Join(others, ", ")))
|
|
if keep != nil {
|
|
*keep = append(*keep, g)
|
|
}
|
|
continue
|
|
}
|
|
gone, why := leaveGroup(ctx, sys, account, g, run)
|
|
if !gone && keep != nil {
|
|
*keep = append(*keep, g)
|
|
}
|
|
gave = gave || gone
|
|
said = append(said, account+" "+why)
|
|
}
|
|
return gave, said
|
|
}
|
|
|
|
// removeAccess is an access no longer declared: the path left exactly as it is (novox/hq ADR 0051), and every
|
|
// group it put an account in taken back unless another declared resource still asks for it (ADR 0312 §5) — then
|
|
// handed to that resource, which records it as the mesh's and takes it back when it stops asking.
|
|
func removeAccess(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted,
|
|
handed Handed) (string, string) {
|
|
const left = "an operator-owned path is never the host's to remove"
|
|
if a.User == "" || len(a.Groups) == 0 {
|
|
return "forgotten", left
|
|
}
|
|
var stays []string
|
|
gave, said := takeBack(ctx, sys, a.User, a.ID, a.Groups, &stays, run, wanted)
|
|
for _, g := range stays {
|
|
if len(wanted.othersAsk(a.User, g, a.ID)) > 0 {
|
|
handed.hand(a.User, g)
|
|
}
|
|
}
|
|
action := "forgotten"
|
|
if gave {
|
|
action = "restored"
|
|
}
|
|
return action, joinDetail(left, strings.Join(said, "; "))
|
|
}
|
|
|
|
// groupAddArgs is the runtime's arguments that give a container's process each granted group as a supplementary
|
|
// group, by number: the container's own group database is not the machine's, and a name could mean another
|
|
// group there, or none.
|
|
func groupAddArgs(groups []int) []string {
|
|
var args []string
|
|
for _, gid := range groups {
|
|
args = append(args, "--group-add", strconv.Itoa(gid))
|
|
}
|
|
return args
|
|
}
|
|
|
|
func gidWords(groups []int) string {
|
|
words := make([]string, 0, len(groups))
|
|
for _, gid := range groups {
|
|
words = append(words, strconv.Itoa(gid))
|
|
}
|
|
return strings.Join(words, ", ")
|
|
}
|
|
|
|
// 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, ADR 0312 §5).
|
|
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] }
|