341 lines
12 KiB
Go
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 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
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
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 228).
|
|
//
|
|
// **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)
|
|
}
|