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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user