A user had no removal, so an undeclared one failed as an orphan and aborted every apply after. Removal now keeps the account, gives back the shell recorded when the mesh first changed it if it is still the mesh's and still usable, and says why otherwise (hq ADR 0176 §2). A shell is refused before it is set unless it is executable and listed in /etc/shells, since usermod succeeds on a missing one.
278 lines
10 KiB
Go
278 lines
10 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
// 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)
|
|
}
|