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:
@@ -131,3 +131,40 @@ func errText(err error) string {
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// CreateUser makes a login with busybox adduser, whose flags are not useradd's.
|
||||
//
|
||||
// `-D` is "do not ask for a password", which is what makes it usable without a terminal. A login
|
||||
// created this way has no password and cannot be logged into over the network with one, which is
|
||||
// correct: what the mesh manages is what a login owns, never a way to become it.
|
||||
func (alpine) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
|
||||
args := []string{"-D"}
|
||||
if home != "" {
|
||||
args = append(args, "-h", home)
|
||||
}
|
||||
if shell != "" {
|
||||
args = append(args, "-s", shell)
|
||||
}
|
||||
if _, err := run(ctx, "adduser", append(args, name)...); err != nil {
|
||||
return fmt.Errorf("cannot create the user %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (alpine) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
|
||||
// busybox has no usermod. `sed`-ing /etc/passwd is what the distribution's own tooling does,
|
||||
// and chsh is the one command that exists for it everywhere.
|
||||
if _, err := run(ctx, "chsh", "-s", shell, name); err != nil {
|
||||
return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddUserToGroup uses addgroup, which on busybox takes the user and the group and is additive by
|
||||
// construction — there is no form of it that replaces the set.
|
||||
func (alpine) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
|
||||
if _, err := run(ctx, "addgroup", name, group); err != nil {
|
||||
return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -80,3 +80,21 @@ func (a android) ServiceBoot(context.Context, Runner, string) (string, error) {
|
||||
func (a android) SetServiceBoot(context.Context, Runner, string, string) error {
|
||||
return fmt.Errorf("%w: service", ErrUnsupported)
|
||||
}
|
||||
|
||||
// Users are one of the shapes this host refuses.
|
||||
//
|
||||
// Android's user database belongs to the framework and is not something an ordinary app may
|
||||
// write. Refused with a reason rather than attempted, the same as package, service and container
|
||||
// above — and the profile says so, so the control plane never sends one.
|
||||
func (android) CreateUser(context.Context, Runner, string, string, string) error {
|
||||
return fmt.Errorf("this host implements no users: Android's user database belongs to the " +
|
||||
"framework and is not writable by an ordinary process")
|
||||
}
|
||||
|
||||
func (android) SetUserShell(context.Context, Runner, string, string) error {
|
||||
return fmt.Errorf("this host implements no users")
|
||||
}
|
||||
|
||||
func (android) AddUserToGroup(context.Context, Runner, string, string) error {
|
||||
return fmt.Errorf("this host implements no users")
|
||||
}
|
||||
|
||||
@@ -142,3 +142,38 @@ func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) e
|
||||
_, err := run(ctx, "systemctl", verb, unit)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUser makes a login with useradd.
|
||||
//
|
||||
// `--create-home` because a user whose home does not exist is a user nothing can be delivered
|
||||
// to, and delivering a shell's configuration is most of why the mesh knows about users at all.
|
||||
func (arch) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
|
||||
args := []string{"--create-home"}
|
||||
if home != "" {
|
||||
args = append(args, "--home-dir", home)
|
||||
}
|
||||
if shell != "" {
|
||||
args = append(args, "--shell", shell)
|
||||
}
|
||||
if _, err := run(ctx, "useradd", append(args, name)...); err != nil {
|
||||
return fmt.Errorf("cannot create the user %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (arch) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
|
||||
if _, err := run(ctx, "usermod", "--shell", shell, name); err != nil {
|
||||
return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddUserToGroup appends, and `--append` is the whole point: without it usermod REPLACES the
|
||||
// user's supplementary groups, so a declaration naming one group would silently remove every
|
||||
// other — including the ones that make a login able to use a machine at all.
|
||||
func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
|
||||
if _, err := run(ctx, "usermod", "--append", "--groups", group, name); err != nil {
|
||||
return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -60,6 +60,61 @@ type System interface {
|
||||
// ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot.
|
||||
ServiceBoot(ctx context.Context, run Runner, unit string) (string, error)
|
||||
SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error
|
||||
|
||||
// CreateUser makes a login. Home and shell may be empty, meaning the system's own defaults —
|
||||
// a declaration that says nothing about them must not impose an opinion.
|
||||
CreateUser(ctx context.Context, run Runner, name, home, shell string) error
|
||||
// SetUserShell changes an existing login's shell, which is what makes "zsh is my shell"
|
||||
// declared state rather than a command the link may not carry.
|
||||
SetUserShell(ctx context.Context, run Runner, name, shell string) error
|
||||
// AddUserToGroup is additive and never removes. A machine's own groups are not the mesh's to
|
||||
// know about, and a declaration that pruned them would take away what somebody set by hand.
|
||||
AddUserToGroup(ctx context.Context, run Runner, name, group string) error
|
||||
}
|
||||
|
||||
// Login is what the machine's user database says about a login.
|
||||
type Login struct {
|
||||
Home string
|
||||
Shell string
|
||||
}
|
||||
|
||||
// LookUpUser reads a login from the user database.
|
||||
//
|
||||
// Shared rather than per-system: `getent passwd` gives the same seven colon-separated fields
|
||||
// everywhere this host runs, and a second implementation would be a second thing to get wrong in
|
||||
// the same way.
|
||||
//
|
||||
// **Absent is an answer, an error is not.** A user database that cannot be read must not be
|
||||
// reported as "no such user" — that is absence read as fact, the exact confusion this package
|
||||
// takes trouble over elsewhere. `getent` exits 2 for "not found" and other codes for failures, so
|
||||
// the two are distinguished rather than collapsed.
|
||||
func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, error) {
|
||||
out, err := run(ctx, "getent", "passwd", name)
|
||||
if err != nil {
|
||||
// getent's own convention: 2 means the key was not found, which is the only failure that
|
||||
// means "no such user".
|
||||
if strings.Contains(err.Error(), "exit status 2") {
|
||||
return Login{}, false, nil
|
||||
}
|
||||
return Login{}, false, fmt.Errorf(
|
||||
"the user database did not answer about %q, so nothing can be said about it: %w",
|
||||
name, err)
|
||||
}
|
||||
fields := strings.Split(strings.TrimSpace(out), ":")
|
||||
if len(fields) < 7 {
|
||||
return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
|
||||
strings.TrimSpace(out), name)
|
||||
}
|
||||
return Login{Home: fields[5], Shell: fields[6]}, true, nil
|
||||
}
|
||||
|
||||
// GroupsOf is every group a login is in.
|
||||
func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) {
|
||||
out, err := run(ctx, "id", "-nG", name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return strings.Fields(out), nil
|
||||
}
|
||||
|
||||
// Supports reports whether this host can apply a shape.
|
||||
@@ -110,6 +165,7 @@ func everyShape() []declaration.Type {
|
||||
return []declaration.Type{
|
||||
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService,
|
||||
declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction,
|
||||
declaration.TypeUser, declaration.TypeArchive,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +176,10 @@ func everyShape() []declaration.Type {
|
||||
func portableShapes() []declaration.Type {
|
||||
return []declaration.Type{
|
||||
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction,
|
||||
// An archive is a file that arrives in a bundle rather than in the declaration. It needs
|
||||
// only a filesystem and a way to fetch, so a partial host can do it; a user needs a user
|
||||
// database it is allowed to write, which it does not have.
|
||||
declaration.TypeArchive,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,3 +280,68 @@ func TestAndroidsUnreachableAppliersFailLoudly(t *testing.T) {
|
||||
t.Errorf("android's service applier did not report it as unsupported: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginThatIsNotThereIsAnAnswerAndABrokenDatabaseIsNot(t *testing.T) {
|
||||
// The distinction this package takes trouble over everywhere else, applied to users. A user
|
||||
// database that cannot be read must not be reported as "no such user" — absence read as
|
||||
// fact is the fault the whole host exists to prevent.
|
||||
notFound := func(context.Context, string, ...string) (string, error) {
|
||||
return "", errors.New("exit status 2")
|
||||
}
|
||||
if _, exists, err := LookUpUser(context.Background(), notFound, "nobody"); err != nil {
|
||||
t.Fatalf("a missing user was reported as a failure: %v", err)
|
||||
} else if exists {
|
||||
t.Fatal("a missing user was reported as present")
|
||||
}
|
||||
|
||||
broken := func(context.Context, string, ...string) (string, error) {
|
||||
return "", errors.New("exit status 71: cannot read /etc/passwd")
|
||||
}
|
||||
if _, exists, err := LookUpUser(context.Background(), broken, "somebody"); err == nil {
|
||||
t.Fatal("a broken user database was reported as an answer")
|
||||
} else if exists {
|
||||
t.Fatal("a broken user database reported a user as present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALoginIsReadFromThePasswdEntry(t *testing.T) {
|
||||
answering := func(context.Context, string, ...string) (string, error) {
|
||||
return "worker:x:1001:1001:,,,:/home/worker:/usr/bin/zsh\n", nil
|
||||
}
|
||||
login, exists, err := LookUpUser(context.Background(), answering, "worker")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("exists=%v err=%v", exists, err)
|
||||
}
|
||||
if login.Home != "/home/worker" || login.Shell != "/usr/bin/zsh" {
|
||||
t.Fatalf("got %+v", login)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSomethingThatIsNotAPasswdEntryIsRefused(t *testing.T) {
|
||||
// Rather than read as a login with empty fields, which would have the host decide the shell
|
||||
// differs and set it on every apply for ever.
|
||||
nonsense := func(context.Context, string, ...string) (string, error) {
|
||||
return "who knows\n", nil
|
||||
}
|
||||
if _, _, err := LookUpUser(context.Background(), nonsense, "worker"); err == nil {
|
||||
t.Fatal("nonsense was read as a login")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPartialHostRefusesUsersAndAllowsArchives(t *testing.T) {
|
||||
// An archive needs a filesystem and a way to fetch; a user needs a user database this host is
|
||||
// allowed to write, which Android does not have.
|
||||
speaks := map[declaration.Type]bool{}
|
||||
for _, shape := range (android{}).Shapes() {
|
||||
speaks[shape] = true
|
||||
}
|
||||
if !speaks[declaration.TypeArchive] {
|
||||
t.Error("a partial host refuses archives, which need only a filesystem")
|
||||
}
|
||||
if speaks[declaration.TypeUser] {
|
||||
t.Error("a partial host claims to manage users")
|
||||
}
|
||||
if err := (android{}).CreateUser(context.Background(), nil, "a", "", ""); err == nil {
|
||||
t.Error("a partial host created a user")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user