package apply import ( "context" "fmt" "os" osuser "os/user" "path/filepath" "strconv" "strings" "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 } 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 } // 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) }