Files
mesh-host/internal/apply/user.go
T
jschoubben c57087d75d 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.
2026-08-30 03:22:38 +02:00

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