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.
180 lines
6.4 KiB
Go
180 lines
6.4 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// arch is pacman and systemd.
|
|
type arch struct{}
|
|
|
|
func (arch) Name() string { return "arch" }
|
|
func (arch) Shapes() []declaration.Type { return everyShape() }
|
|
|
|
func (a arch) Confirm(ctx context.Context, run Runner) error {
|
|
if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil {
|
|
return fmt.Errorf(
|
|
"this is the arch host and pacman does not answer here. Either this machine is not "+
|
|
"Arch, or its package database is broken: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PackageInstalled asks the package database, having first established that it answers.
|
|
//
|
|
// The two-step is the trap this file exists to remember. `pacman -Q name` exits non-zero for a
|
|
// package that is not installed AND for a database that cannot be read, so believing the first
|
|
// answer reports a broken package manager as "nothing is installed" — absence read as fact.
|
|
// Proving the tool answers about something that certainly exists separates them.
|
|
func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) {
|
|
if err := a.Confirm(ctx, run); err != nil {
|
|
return false, fmt.Errorf("nothing can be said about %q: %w", name, err)
|
|
}
|
|
if _, err := run(ctx, "pacman", "-Q", name); err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
|
_, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
|
return err
|
|
}
|
|
|
|
// ServiceState reads what systemd says about a unit.
|
|
//
|
|
// Two traps, and both were hit before this read what it now reads.
|
|
//
|
|
// The exit code is not the answer: `is-active` exits non-zero for every state except active.
|
|
//
|
|
// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that
|
|
// DOES NOT EXIST exactly as it does for one installed and stopped, so declaring a unit stopped
|
|
// reported success for a unit the host cannot manage at all. LoadState is what separates them,
|
|
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
|
|
// would have had to drop.
|
|
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "show", unit,
|
|
"--property=LoadState", "--property=ActiveState")
|
|
|
|
var load, active string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "LoadState":
|
|
load = value
|
|
case "ActiveState":
|
|
active = value
|
|
}
|
|
}
|
|
|
|
switch load {
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
|
case "not-found":
|
|
return "", fmt.Errorf(
|
|
"%s does not exist on this machine. A declaration naming a unit that is not "+
|
|
"installed cannot be satisfied, and reporting it stopped would be reporting "+
|
|
"absence as success", unit)
|
|
case "masked":
|
|
return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit)
|
|
case "error", "bad-setting":
|
|
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load)
|
|
}
|
|
|
|
switch active {
|
|
case "active", "activating", "reloading":
|
|
return "running", nil
|
|
case "inactive", "failed", "deactivating":
|
|
return "stopped", nil
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q, which is neither running nor stopped", unit, active)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
|
|
verb := "start"
|
|
if state == "stopped" {
|
|
verb = "stop"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|
|
|
|
// ServiceBoot reads whether a unit starts at boot.
|
|
//
|
|
// `is-enabled` has more than two answers, and `static` is the one that matters: the unit has no
|
|
// install section and CANNOT be enabled. Reading it as "disabled" would have the host try, fail,
|
|
// and blame the wrong thing — the same shape as reading a missing unit as "stopped".
|
|
func (arch) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "is-enabled", unit)
|
|
switch state := strings.TrimSpace(out); state {
|
|
case "enabled", "enabled-runtime", "alias":
|
|
return "enabled", nil
|
|
case "disabled":
|
|
return "disabled", nil
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about whether %s starts at boot", unit)
|
|
case "static":
|
|
return "", fmt.Errorf(
|
|
"%s is static — it has no install section, so it cannot be enabled or disabled. "+
|
|
"Something else pulls it in, and that is what a declaration should name", unit)
|
|
case "masked", "masked-runtime":
|
|
return "", fmt.Errorf("%s is masked, so its boot state cannot be declared", unit)
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q at boot, which is neither enabled nor disabled",
|
|
unit, state)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error {
|
|
verb := "enable"
|
|
if boot == "disabled" {
|
|
verb = "disable"
|
|
}
|
|
_, 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
|
|
}
|