not a service A shell, a terminal, a chat client, a desktop are a package plus configuration in somebody's home. A mesh with no notion of a user can own /etc and nothing anybody looks at, which is most of the reason to manage a machine at all. Three shapes, and the vocabulary test asserts the count precisely because widening it widens what a compromised control plane can express: user a login, its shell and its groups archive a set of files, fetched by digest and unpacked (file) gains `bytes` for what is not text, and `owner` `user` also makes "zsh is my login shell" declared state. chsh is a command, the link may not carry one, and a shell settable only by hand is a shell the mesh cannot manage. Groups are additive and never pruned — usermod without --append REPLACES them, which would silently remove every group that makes a login able to use the machine. A machine's own groups are not the mesh's to know about. The archive is the one place this host reaches out on its own; everywhere else it holds one outbound connection and fetches nothing. So it carries the discipline the bootstrap already uses for images: pinned by digest, and the digest checked before a single file is written. Two decisions in the unpacker worth naming: - an entry naming a path outside the archive is REFUSED, not sanitised. Rewriting it to land inside would put a file somewhere nobody asked for and report success. Found by the test: the first version quietly relocated it. - symlinks and device nodes are refused rather than skipped, or an archive that needed one arrives silently incomplete. A partial host does archives and refuses users: an archive needs a filesystem and a way to fetch; a user needs a user database it is allowed to write.
156 lines
4.3 KiB
Go
156 lines
4.3 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
osuser "os/user"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"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.
|
|
func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner) (Outcome, error) {
|
|
out := begin(r)
|
|
out.Action = "unchanged"
|
|
|
|
login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name)
|
|
if err != nil {
|
|
return out, 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"
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
if out.Action == "unchanged" {
|
|
out.Action = "updated"
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
return out, 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
|
|
}
|
|
found, err := osuser.Lookup(owner)
|
|
if err != nil {
|
|
return fmt.Errorf("%s should belong to %q and this machine has no such user: %w",
|
|
path, owner, err)
|
|
}
|
|
uid, err := strconv.Atoi(found.Uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gid, err := strconv.Atoi(found.Gid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chown(path, uid, gid); err != nil {
|
|
return fmt.Errorf("cannot give %s to %q: %w", path, owner, err)
|
|
}
|
|
return 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
|
|
}
|
|
found, err := osuser.Lookup(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 strconv.Itoa(uid) == found.Uid && strconv.Itoa(gid) == found.Gid, 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)
|
|
}
|