An account's manager runs only while it is logged in or lingers. A user-scoped unit whose manager is not running is now "waiting" rather than failed, its record kept as it was; its removal is never fatal (kept recorded, retried) and an account that is gone is forgotten. Whether the manager runs is asked of user@<uid>.service in the machine's manager: asking the account's own, through --machine, logs it in. The user shape gains `linger`, set with loginctl, read back from logind's record, and given back on removal like the shell. Unit files the mesh writes under ~/.config/systemd/user or /etc/systemd/user make that unit the mesh's, and made, holds and found units are keyed by manager and name, so an account's unit and the machine's of one name are two units. A service moved between managers gives the old one back through the manager it was in. OpenRC refuses both.
464 lines
17 KiB
Go
464 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).
|
|
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
|
|
}
|
|
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 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"
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
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"
|
|
}
|
|
}
|
|
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)
|
|
}
|