Files
mesh-host/internal/apply/user.go
T
jochen ab4ca44f98
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/module-groups delivering: 0 of 2 delivered
mesh/delivery delivered
Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)
A module puts the operator's account in a group by declaring the account with that group alone.
The node-engine now records each group it added, takes back only those when nothing declared still
asks for them, refuses a group the machine lacks before usermod runs, and states each such account
as its module's resource of kind account: relogin needed while the running session lacks the group.
2026-10-08 12:04:08 +02:00

459 lines
17 KiB
Go

package apply
import (
"context"
"errors"
"fmt"
"os"
osuser "os/user"
"path/filepath"
"strconv"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
"github.com/novox/mesh-host/internal/system"
)
// Logins, and the files that belong to them.
//
// Most of what a person installs is not a service. A shell, a terminal, a chat client, a desktop
// are a package plus configuration **in somebody's home** — so a mesh with no notion of a user
// can manage /etc and nothing anybody looks at.
// applyUser makes a login match what was declared.
//
// Reconciling, like everything else here: it is not told whether the user is new. Creating,
// setting a shell and adding groups are each done only when the machine does not already agree.
//
// previous is this resource's record, which carries the shell the account had before the mesh
// first changed it, so removal can give it back (novox/hq ADR 0176 §2, issue 228).
//
// wanted is every group the whole declaration asks an account to be in (novox/hq ADR 0252), so a group
// this resource stops asking for is kept while another resource asks for it.
func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner,
previous store.Applied, wanted Wanted) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
// What was found is carried from the record for as long as the resource is recorded — for this
// account only: a declaration that renamed its user says nothing about the new one's shell.
if previous.Shell != nil && previous.Target == r.Name {
kept := *previous.Shell
out.shell = &kept
}
if previous.Linger != nil && previous.Target == r.Name {
kept := *previous.Linger
out.linger = &kept
}
login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name)
if err != nil {
return out, err
}
// **A shell is refused before anything is touched** (novox/hq issue 228). Refused after the
// account was created or its groups changed, the account would be half the declaration's; a
// refusal fails this resource and leaves the account exactly as it was.
if r.Shell != "" && (!exists || login.Shell != r.Shell) {
if err := system.UsableShell(r.Shell); err != nil {
return out, fmt.Errorf("%q's shell was not set, and the account was left as it is: %w",
r.Name, err)
}
}
if !exists {
if err := sys.CreateUser(ctx, system.Runner(run), r.Name, r.Home, r.Shell); err != nil {
return out, err
}
// Read back from the machine, not from the call that made it. A useradd that returns
// success and leaves no entry is exactly the failure this host takes trouble over.
login, exists, err = system.LookUpUser(ctx, system.Runner(run), r.Name)
if err != nil {
return out, err
}
if !exists {
return out, fmt.Errorf("created the user %q and the user database does not have it",
r.Name)
}
out.Action = "created"
if r.Shell != "" {
// No shell from before to give back: the account had none until the mesh made it.
out.shell = &store.LoginShell{Set: login.Shell, Created: true}
}
}
// Groups before the shell, so that a failure here comes before the shell is changed: a record
// is written only for an apply that worked, and a shell changed by a failed one would be read
// next time as the account's own, and the one it replaced lost.
if err := applyGroups(ctx, sys, r, run, previous, wanted, &out); err != nil {
return out, err
}
// The shell, only when it differs. Absent means the host asserts nothing — a field that
// always asserts cannot express "leave it alone", which is the difference between managing a
// machine and taking it over.
if r.Shell != "" && login.Shell != r.Shell {
if err := sys.SetUserShell(ctx, system.Runner(run), r.Name, r.Shell); err != nil {
return out, err
}
if back, _, err := system.LookUpUser(ctx, system.Runner(run), r.Name); err != nil {
return out, err
} else if back.Shell != r.Shell {
return out, fmt.Errorf("set %q's shell to %q and the user database says %q",
r.Name, r.Shell, back.Shell)
}
// **What was found is recorded once** (novox/hq ADR 0176 §2). A later change keeps it: what
// is given back is the shell from before the mesh, never the mesh's own earlier choice.
if out.shell == nil {
out.shell = &store.LoginShell{Found: login.Shell}
}
out.shell.Set = r.Shell
if out.Action == "unchanged" {
out.Action = "updated"
}
}
// Lingering last, and only when declared and different: the one change here that starts or
// stops something — the account's manager and every unit in it (novox/hq ADR 0177).
if r.Linger != nil {
changed, err := applyLinger(ctx, sys, r.Name, *r.Linger, run, &out)
if err != nil {
return out, err
}
if changed && out.Action == "unchanged" {
out.Action = "updated"
}
}
return out, nil
}
// lingerer is a service manager that runs a manager per account, and can keep one running with
// nobody logged in (novox/hq ADR 0177).
type lingerer interface {
Lingering(ctx context.Context, run system.Runner, name string) (bool, error)
SetLingering(ctx context.Context, run system.Runner, name string, on bool) error
}
// applyLinger makes whether an account lingers what was declared, read back from the machine, and
// keeps what it found the first time it changed it so removal can give that back.
func applyLinger(ctx context.Context, sys system.System, name string, want bool, run Runner,
out *Outcome) (bool, error) {
l, ok := sys.(lingerer)
if !ok {
return false, fmt.Errorf("%q is declared to linger, and this machine's service manager has no "+
"manager per account to keep running (novox/hq ADR 0177)", name)
}
now, err := l.Lingering(ctx, system.Runner(run), name)
if err != nil {
return false, err
}
if now == want {
return false, nil
}
if err := l.SetLingering(ctx, system.Runner(run), name, want); err != nil {
return false, err
}
if back, err := l.Lingering(ctx, system.Runner(run), name); err != nil {
return false, err
} else if back != want {
return false, fmt.Errorf("asked logind to make %q linger %s, and it reads %s",
name, onOff(want), onOff(back))
}
// Found once, as the shell's is: what goes back is what was there before the mesh.
if out.linger == nil {
out.linger = &store.Lingering{Found: now}
}
out.linger.Set = want
return true, nil
}
func onOff(on bool) string {
if on {
return "on"
}
return "off"
}
// removeUser is what undeclaring a login does: never deleting the account, and giving back the
// shell the mesh replaced when that is still safe (novox/hq ADR 0176 §2, issue 228) — and whether
// it lingered, on the same rule (novox/hq ADR 0177).
//
// **The account is never deleted, whether or not the mesh created it.** An account owns a home,
// files, a crontab, a mailbox — what a person did with it is not the mesh's to know, and deleting
// it is the data loss ADR 0030 exists to prevent. It is the package's rule, on a login: the
// mesh no longer requires it, which is not the same as "remove it".
//
// The shell goes back only while the account still has the one the mesh set — one a person chose
// since is theirs — and only to a shell that is still usable: giving back a shell that has been
// uninstalled since would break the very logins the giving back is for. Otherwise it is left, and
// the outcome says why. Never errNoRemoval: an orphaned login that failed removal stopped the
// whole apply, on every apply after.
//
// And every group the mesh put the account in, while no other declared resource asks for it (novox/hq
// ADR 0252), on the same rule: never fatal, and never a group the account was in before.
func removeUser(ctx context.Context, sys system.System, a store.Applied, run Runner,
wanted Wanted) (string, string, error) {
const kept = "the account is kept; the host never deletes a login"
login, exists, err := system.LookUpUser(ctx, system.Runner(run), a.Target)
if err != nil {
return "", "", err
}
if !exists {
return "forgotten", "no longer there", nil
}
action, detail, err := giveShellBack(ctx, sys, a, login, run, kept)
if err != nil {
return "", "", err
}
if gave, said := giveLingerBack(ctx, sys, a, run); said != "" {
detail += "; " + said
if gave {
action = "restored"
}
}
if gave, said := giveGroupsBack(ctx, sys, a, run, wanted); said != "" {
detail += "; " + said
if gave {
action = "restored"
}
}
return action, detail, nil
}
// giveShellBack is removeUser's shell: given back only while the account still has the one the
// mesh set, and only to one still usable.
func giveShellBack(ctx context.Context, sys system.System, a store.Applied, login system.Login,
run Runner, kept string) (string, string, error) {
found := a.Shell
switch {
case found == nil:
return "forgotten", kept + ", and its shell was never changed by the mesh", nil
case found.Created:
return "forgotten", kept + "; the mesh created it, so there is no shell from before to give back", nil
case login.Shell != found.Set:
return "forgotten", fmt.Sprintf("%s, and its shell %s left as it is: changed since the mesh set %s",
kept, login.Shell, found.Set), nil
case found.Found == "":
return "forgotten", kept + ", and its shell left as it is: it had none before the mesh set one", nil
}
if err := system.UsableShell(found.Found); err != nil {
return "forgotten", fmt.Sprintf("%s, and its shell %s left as it is: the one it had before "+
"cannot be given back: %v", kept, login.Shell, err), nil
}
// A give-back that fails is said and not fatal: fatal, the record would stay and fail the same
// way on every apply after — the very wedge this removal exists to end.
if err := sys.SetUserShell(ctx, system.Runner(run), a.Target, found.Found); err != nil {
return "forgotten", fmt.Sprintf("%s, and the shell it had before the mesh, %s, could not be "+
"given back: %v", kept, found.Found, err), nil
}
if back, _, err := system.LookUpUser(ctx, system.Runner(run), a.Target); err != nil {
return "", "", err
} else if back.Shell != found.Found {
return "forgotten", fmt.Sprintf("%s; gave back the shell %s and the user database says %s",
kept, found.Found, back.Shell), nil
}
return "restored", fmt.Sprintf("%s; the shell it had before the mesh, %s, given back", kept, found.Found), nil
}
// giveLingerBack is removeUser's lingering (novox/hq ADR 0177), on the shell's rule: whether the
// account lingered before the mesh changed it goes back, and only while the account still has what
// the mesh set — one the operator changed since with loginctl is theirs. Never fatal, for the
// shell's reason. Empty when the mesh never changed it, so a removal says nothing about it.
//
// Giving back "not lingering" stops the account's manager if nobody is logged in, and every unit
// in it: that is what the account had before the mesh, and its user units go with its declaration.
func giveLingerBack(ctx context.Context, sys system.System, a store.Applied, run Runner) (bool, string) {
found := a.Linger
if found == nil {
return false, ""
}
if found.Found == found.Set {
return false, ""
}
l, ok := sys.(lingerer)
if !ok {
return false, ""
}
now, err := l.Lingering(ctx, system.Runner(run), a.Target)
if err != nil {
return false, fmt.Sprintf("whether it lingered before the mesh could not be given back: %v", err)
}
if now != found.Set {
return false, fmt.Sprintf("lingering left %s: changed since the mesh set it %s", onOff(now), onOff(found.Set))
}
if err := l.SetLingering(ctx, system.Runner(run), a.Target, found.Found); err != nil {
return false, fmt.Sprintf("lingering, %s before the mesh, could not be given back: %v", onOff(found.Found), err)
}
if back, err := l.Lingering(ctx, system.Runner(run), a.Target); err != nil || back != found.Found {
return false, fmt.Sprintf("gave back lingering %s and logind does not read it so", onOff(found.Found))
}
return true, fmt.Sprintf("lingering %s again, as before the mesh", onOff(found.Found))
}
// own sets a path's owner, when one was declared.
//
// Looked up by name every time rather than cached: a user's numeric id is not stable across
// machines, and the whole reason this exists is that the same declaration lands on several.
func own(path, owner string) error {
if owner == "" {
return nil
}
uid, gid, err := idsOf(owner)
if err != nil {
return fmt.Errorf("%s should belong to %q: %w", path, owner, err)
}
if err := os.Chown(path, uid, gid); err != nil {
return fmt.Errorf("cannot give %s to %q: %w", path, owner, err)
}
return nil
}
// idsOf resolves an owner to a uid and gid: a name this machine knows, or numbers it does not.
//
// **Numbers, because a container's user is a number the machine has never heard of.** A directory
// a module mounts into its container belongs to whoever runs inside — grafana's 472, redis's 999,
// www-data's 33 — and none of those has a row in this machine's passwd, so there is no name to
// look up and none to create. Refusing them looked principled and meant every module whose
// container drops privileges could not own its own data: the store's config was unreadable to
// the store, and the forge could not traverse into the directory that held its files.
//
// "uid:gid" and bare "uid" are numeric; anything else is a name, resolved as before.
func idsOf(owner string) (int, int, error) {
user, group, both := strings.Cut(owner, ":")
if uid, err := strconv.Atoi(user); err == nil {
gid := uid
if both {
g, err := strconv.Atoi(group)
if err != nil {
return 0, 0, fmt.Errorf(
"%q reads as a uid with a group that is not a gid", owner)
}
gid = g
}
return uid, gid, nil
}
if both {
return 0, 0, fmt.Errorf("%q mixes a name with a colon; a name stands alone", owner)
}
found, err := osuser.Lookup(owner)
if err != nil {
return 0, 0, fmt.Errorf("this machine has no such user: %w", err)
}
uid, err := strconv.Atoi(found.Uid)
if err != nil {
return 0, 0, err
}
gid, err := strconv.Atoi(found.Gid)
if err != nil {
return 0, 0, err
}
return uid, gid, nil
}
// ownedBy reports whether a path already belongs to a user, so applying twice changes nothing.
func ownedBy(path, owner string) (bool, error) {
if owner == "" {
return true, nil
}
wantUID, wantGID, err := idsOf(owner)
if err != nil {
return false, nil
}
info, err := os.Stat(path)
if err != nil {
return false, err
}
uid, gid, ok := ownerOf(info)
if !ok {
return false, nil
}
return uid == wantUID && gid == wantGID, nil
}
// makeDirs makes a directory and any parent of it that is missing, as MkdirAll does — and gives
// each one it made inside the owner's home to the owner (novox/hq ADR 0182, to-be 41).
//
// **A parent made as root inside a home is a home the person cannot use.** A module writing
// ~/.config/mesh/environment.sh, or unpacking into ~/.local/share/powerlevel10k, on a fresh account
// made ~/.config and ~/.local/share owned by root: the file was the person's, the directory every
// program of theirs writes into was not. So what the host creates between the home and the target
// is the owner's, as the target is.
//
// **Only what the host created.** A parent that was already there is never chowned or chmodded:
// what a person or another program made is held as found (ADR 0182). And only inside the owner's
// home, read from the user database, not guessed from a prefix on /home: a module's directory under
// /var/lib is made exactly as before, whoever its files belong to.
func makeDirs(dir string, mode os.FileMode, owner string) error {
_, err := makeDirsSaying(dir, mode, owner)
return err
}
// makeDirsSaying is makeDirs, and says which directories it made, deepest first — so an archive
// can take away on removal the parents it made to reach its directory (novox/hq issue 162).
func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error) {
var made []string
for d := filepath.Clean(dir); ; d = filepath.Dir(d) {
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
break
}
made = append(made, d)
if filepath.Dir(d) == d {
break
}
}
if err := os.MkdirAll(dir, mode); err != nil {
return nil, err
}
if owner == "" || len(made) == 0 {
return made, nil
}
home, err := homeOf(owner)
if err != nil || home == "" {
// A numeric owner — a container's user — has no home, and a name the machine does not
// know fails where the target is given to it. Either way nothing here is a home's.
return made, nil
}
home = filepath.Clean(home)
for _, d := range made {
if d != home && !strings.HasPrefix(d, home+string(os.PathSeparator)) {
continue
}
if err := ownMade(d, owner); err != nil {
return made, err
}
}
return made, nil
}
// homeOf is an owner's home from the user database, and ownMade gives a directory the host made to
// its owner. Variables so a test can give an owner a home it owns, and see what was given to whom
// without being root.
var (
homeOf = func(owner string) (string, error) {
found, err := osuser.Lookup(owner)
if err != nil {
return "", err
}
return found.HomeDir, nil
}
ownMade = own
)
// ownAll gives a whole tree to a user, for an archive that was unpacked into it.
func ownAll(root, owner string) error {
if owner == "" {
return nil
}
return filepath.Walk(root, func(path string, _ os.FileInfo, err error) error {
if err != nil {
return err
}
return own(path, owner)
})
}
// ownerOf is the numeric owner of a file, where the platform reports one.
func ownerOf(info os.FileInfo) (uid, gid int, ok bool) {
return statOwner(info)
}