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.
321 lines
12 KiB
Go
321 lines
12 KiB
Go
// 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
|
|
}
|