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