A user, bytes, and an archive — because most of what people install is
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.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user