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) }