An account an agent runs as (novox/hq ADR 0266) is read on every look for a uid of 0, a group that grants root, any sudo rule and a mesh secret it can read; any way found is unhealthy and said, a read that fails is unknown.
300 lines
10 KiB
Go
300 lines
10 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"
|
|
"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. 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"}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 := 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 = a
|
|
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.
|
|
func (j *Judge) judge(ctx context.Context, a Account) (string, string) {
|
|
if a.Root {
|
|
ways, err := j.reader.Escalation(ctx, a.Name, a.Secrets)
|
|
if err != nil {
|
|
return Unknown, "whether the account can become root could not be read: " + firstLine(err.Error())
|
|
}
|
|
if len(ways) > 0 {
|
|
return Unhealthy, ReasonRoot + ": " + strings.Join(ways, "; ")
|
|
}
|
|
if len(a.Groups) == 0 {
|
|
// Declared for root alone: nothing of a session to read.
|
|
return Healthy, ""
|
|
}
|
|
}
|
|
in, err := j.reader.InDatabase(ctx, a.Name)
|
|
if err != nil {
|
|
return Unknown, "the user database could not be read: " + firstLine(err.Error())
|
|
}
|
|
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)
|
|
}
|
|
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())
|
|
}
|
|
if !s.Running {
|
|
// Nobody is logged in, and the account does not linger: the next login takes every group.
|
|
return Healthy, ""
|
|
}
|
|
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, ", "))
|
|
}
|
|
return Healthy, ""
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
line, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
|
|
return line
|
|
}
|