// Package accounts is the node-engine reading whether an account has, where it runs, the groups a module // put it in (novox/hq ADR 0252, issue 247). // // **A group takes effect at the next login.** The apply puts the account in a group in the machine's // database at once. Every process already running keeps the groups it started with: the account's own // service manager, and every unit it starts. The lighting daemon's module puts the operator's account in // `openrazer`, and the daemon, started by that manager, still refuses to start, for as long as the // session that began before lasts. Nothing said why. // // On every look the engine reads, for each account a module declares groups for: // // 1. whether the user database lists the account in each group. One it does not is the apply's to put // right, and is said as `not in the group`; // 2. whether the account's own service manager runs, and if it does, the groups that process holds, // read from the process itself. A group the database lists and the running manager lacks is said as // **relogin needed**, with what to do. // // No running manager is healthy: nobody is logged in, and the next login takes the groups. // // Each verdict is the declaring module's, as a resource of kind `account`, beside what liveness says: the // controller raises it as the module's condition on two statements in a row, and clears it on the first // healthy one. // // **And an account declared never to become root without a person** (`root: never`, novox/hq ADR 0266) — // the account agent sessions run as on a machine where they must not reach root by themselves — is judged // on every look for whether it can anyway: by its uid, by a group that grants root to its members, by any // sudo rule at all, or by reading a secret the mesh placed for another account (the tool runner's bus // credential among them, which carries every co-hosted module's grants). Judged first, whether or not // anybody is logged in: a way to root does not wait for a session. Any way found is unhealthy, its reason // starting ReasonRoot and naming every way; a question the machine did not answer is unknown, never // healthy. This is the fact the controller reads to tell a machine where no agent can become root from one // where an agent can. // // **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service // manager's `show` of the account's manager unit, and that process's status file. It never starts the // account's manager, which asking that manager itself would. package accounts import ( "context" "errors" "fmt" "sort" "strings" "sync" "time" "github.com/novox/mesh-host/internal/declaration" ) // The states, in the words liveness says them (the controller reads them alike). const ( Healthy = "healthy" Unhealthy = "unhealthy" Unknown = "unknown" ) // ReasonRelogin starts the reason of an account whose running session lacks a group it is in. const ReasonRelogin = "relogin needed" // ReasonRoot starts the reason of an account declared never to become root without a person that can // (novox/hq ADR 0266); every way found follows it. const ReasonRoot = "can become root without a person" // RootGroups are the groups whose members become root by membership alone, without a sudo rule naming // them: the administrators' groups a distribution's own rules or polkit treat as root, the container // runtime's socket (a container with the host's root mounted), the raw disks, and the virtualisation // daemons that start a guest with the host's devices — and the kernel's memory (kmem) and the password hashes // (shadow), either of which is root a step away (novox/hq ADR 0312 §4 review). A closed list, so what the judge // calls "grants root" is written down and reviewable rather than guessed per machine. var RootGroups = []string{"root", "wheel", "sudo", "admin", "docker", "disk", "lxd", "incus-admin", "libvirt", "kmem", "shadow"} // Account is one user resource of a module that declares groups. type Account struct { Module string // ID is the user resource's id; Name the account. ID string Name string Groups []string // Root is true for an account declared never to become root without a person (novox/hq ADR 0266). Root bool // Secrets are the paths of every secret the declaration places for another account, judged for // whether this one can read them; only for a Root account. Secrets []string } // Of is every account a declaration has a module put in a group. held is every resource an adopted // machine holds as found, by id: its groups are not the mesh's yet. A resource the mesh declares in its // own right (no module) is not judged here. func Of(d *declaration.Declaration, held map[string]bool) []Account { if d == nil { return nil } var out []Account for _, r := range d.Resources { u, ok := r.(*declaration.User) root := ok && u.Root == declaration.RootNever if !ok || (len(u.Groups) == 0 && !root) || held[u.ID] || u.Name == "" { continue } at := strings.LastIndex(u.ID, ".") if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) { continue } a := Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, Groups: append([]string(nil), u.Groups...), Root: root} if root { a.Secrets = secretsOf(d, u.Name) } out = append(out, a) } sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) return out } // secretsOf is the path of every secret file the declaration places for anybody but account: sealed whole, // or with sealed values in its content. One the account owns is its own to read. func secretsOf(d *declaration.Declaration, account string) []string { var out []string for _, r := range d.Resources { f, ok := r.(*declaration.File) if !ok || f.Path == "" || (f.Sealed == "" && len(f.Secrets) == 0) || f.Owner == account { continue } out = append(out, f.Path) } sort.Strings(out) return out } // Session is what an account's own service manager holds. type Session struct { // Running is whether the manager runs. Running bool // Has is, of the groups asked about, those the running manager holds. Has map[string]bool } // Reader is what a look asks the machine. An error is "could not be read": said unknown, never healthy. type Reader interface { // InDatabase is every group the user database lists an account in. InDatabase(ctx context.Context, account string) ([]string, error) // Session is the account's own service manager: whether it runs, and which of groups it holds. Session(ctx context.Context, account string, groups []string) (Session, error) // Escalation is every way the account can become root without a person, in words — its uid, a group // of RootGroups, a sudo rule, a secret of secrets it can read — and none when there is none. Reads // only (novox/hq ADR 0266). Escalation(ctx context.Context, account string, secrets []string) ([]string, error) } // Verdict is one account's state as a statement says it. type Verdict struct { Account State string Reason string Since time.Time Streak int // Search is how far the setuid search got, while the verdict waits for it (novox/hq issue 361); nil // otherwise. Never part of Reason: it moves every minute, and a verdict that changed every minute would // be said in the journal every minute. Search *SearchProgress } // Statement is one look at every account. type Statement struct { At time.Time Accounts []Verdict } // Healthy says no account in it is anything but healthy. func (s Statement) Healthy() bool { for _, v := range s.Accounts { if v.State != Healthy { return false } } return true } // Judge reads every account's groups on every look. Safe for the apply and the looking loop at once. type Judge struct { reader Reader Now func() time.Time mu sync.Mutex accounts []Account kept map[string]*Verdict last Statement } // New is a judge reading through r. func New(r Reader) *Judge { return &Judge{reader: r, Now: time.Now, kept: map[string]*Verdict{}} } // Set is what the declaration just applied asks. An account no longer asked for is forgotten. func (j *Judge) Set(as []Account) { j.mu.Lock() defer j.mu.Unlock() j.accounts = append([]Account(nil), as...) declared := map[string]bool{} for _, a := range as { declared[a.ID] = true } for id := range j.kept { if !declared[id] { delete(j.kept, id) } } } // Last is the statement of the last look. func (j *Judge) Last() Statement { j.mu.Lock() defer j.mu.Unlock() return j.last } // Look reads every account once, and answers the statement and whether any verdict changed since the // last look. func (j *Judge) Look(ctx context.Context) (Statement, bool) { j.mu.Lock() defer j.mu.Unlock() now := j.Now() st := Statement{At: now} changed := len(j.last.Accounts) != len(j.accounts) for _, a := range j.accounts { state, reason, search := j.judge(ctx, a) k := j.kept[a.ID] if k == nil || k.State != state || k.Reason != reason { changed = true k = &Verdict{Account: a, State: state, Reason: reason, Since: now} j.kept[a.ID] = k } k.Account, k.Search = a, search if state == Unhealthy { k.Streak++ } else { k.Streak = 0 } st.Accounts = append(st.Accounts, *k) } j.last = st return st, changed } // judge is one account's verdict on one look, and how far the setuid search got while it waits for one. func (j *Judge) judge(ctx context.Context, a Account) (string, string, *SearchProgress) { if a.Root { ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets) var notJudged *SetuidNotJudged switch { case len(ways) > 0: // A way found is a way found, whatever else could not be judged; said beside it. reason := ReasonRoot + ": " + strings.Join(ways, "; ") if errors.As(err, ¬Judged) { reason += "; " + firstLine(err.Error()) } var progress *SearchProgress if notJudged != nil { progress = notJudged.Progress } return Unhealthy, reason, progress case errors.As(err, ¬Judged): // The setuid search has no result yet: not judged, said as such — the same words on every look // until it has one, never a search that stalls the look (novox/hq ADR 0266). return Unknown, firstLine(err.Error()), notJudged.Progress case err != nil: return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error()), nil } if len(a.Groups) == 0 { // Declared for root alone: nothing of a session to read. return Healthy, "", nil } } in, err := j.reader.InDatabase(ctx, a.Name) if err != nil { return Unknown, "the user database could not be read: " + firstLine(err.Error()), nil } listed := map[string]bool{} for _, g := range in { listed[g] = true } var missing, inDB []string for _, g := range a.Groups { if listed[g] { inDB = append(inDB, g) } else { missing = append(missing, g) } } if len(missing) > 0 { return Unhealthy, fmt.Sprintf("not in the group %s: the apply has not put %s there; its outcome says why", strings.Join(missing, ", "), a.Name), nil } s, err := j.reader.Session(ctx, a.Name, inDB) if err != nil { return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error()), nil } if !s.Running { // Nobody is logged in, and the account does not linger: the next login takes every group. return Healthy, "", nil } var lacking []string for _, g := range inDB { if !s.Has[g] { lacking = append(lacking, g) } } if len(lacking) > 0 { return Unhealthy, fmt.Sprintf("%s: %s is in the group %s, and its running session began before it was; "+ "log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", ")), nil } return Healthy, "", nil } func firstLine(s string) string { line, _, _ := strings.Cut(strings.TrimSpace(s), "\n") return line }