Files
mesh-host/internal/apply/user.go
T
jochen 2a5f4c8270 Parents the host makes inside an owner's home are the owner's (hq ADR 0182, to-be 41)
A file or archive placed under a fresh account's home with an owner left
the parents it created, such as ~/.config or ~/.local/share, owned by
root, so the person's own programs could not write there. Parents that
already existed, and any outside the owner's home, are left as before.
2026-10-04 04:07:40 +02:00

341 lines
12 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 225).
func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner,
previous store.Applied) (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
}
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 225). 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 len(r.Groups) > 0 {
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
if err != nil {
return out, err
}
already := map[string]bool{}
for _, g := range in {
already[g] = true
}
for _, want := range r.Groups {
if already[want] {
continue
}
if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil {
return out, err
}
if out.Action == "unchanged" {
out.Action = "updated"
}
}
}
// 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"
}
}
return out, nil
}
// 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 225).
//
// **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.
func removeUser(ctx context.Context, sys system.System, a store.Applied, run Runner) (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
}
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
}
// 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 {
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 err
}
if owner == "" || len(made) == 0 {
return 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 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 err
}
}
return 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)
}