Files
mesh-host/internal/apply/archive.go
T
jschoubben c57087d75d 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.
2026-08-30 03:22:38 +02:00

186 lines
6.0 KiB
Go

package apply
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A set of files, fetched by digest and unpacked.
//
// For what inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of
// files inlined would make every declaration enormous and rewrite all of them when one changed.
//
// **This is the one place the host reaches out on its own.** Everywhere else it holds a single
// outbound connection to the broker and fetches nothing; a container image is pulled by the
// runtime rather than by this process. So the discipline has to be explicit and it is the same
// one the bootstrap uses for images: **pinned by digest, and the digest is checked before
// anything is written.** What is fetched is bytes from a network the mesh does not control, and
// the only thing making them safe to unpack is that they hash to what was declared.
// maxArchive is how much will be read before giving up.
//
// Because a fetch with no limit is a machine somebody can fill up from the far end. Chosen large
// enough for a desktop theme and small enough to notice.
const maxArchive = 512 << 20
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
body, err := fetch(ctx, r.Source)
if err != nil {
return out, err
}
sum := sha256.Sum256(body)
got := "sha256:" + hex.EncodeToString(sum[:])
if got != r.Digest {
// Refused before a single file is written. A digest that does not match means the thing
// at that address is not the thing that was declared, and unpacking it would be applying
// something nobody reviewed.
return out, fmt.Errorf(
"%s was declared as %s and what arrived is %s; nothing was unpacked",
r.Source, r.Digest, got)
}
out.wrote = got
// Already what it should be. The digest is the whole identity of an archive, so a matching
// record means the unpacked tree came from these exact bytes.
if previous.Wrote == got {
if _, err := os.Stat(r.Path); err == nil {
owned, err := ownedBy(r.Path, r.Owner)
if err == nil && owned {
return out, nil
}
}
}
if err := os.MkdirAll(r.Path, 0o755); err != nil {
return out, err
}
written, err := unpack(body, r.Path)
if err != nil {
return out, err
}
if err := ownAll(r.Path, r.Owner); err != nil {
return out, err
}
out.Action = "updated"
if previous.Wrote == "" {
out.Action = "created"
}
out.Detail = fmt.Sprintf("%d file(s)", written)
return out, nil
}
func fetch(ctx context.Context, source string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
return nil, err
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("cannot fetch %s: %w", source, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", source, response.Status)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
if err != nil {
return nil, err
}
if len(body) > maxArchive {
return nil, fmt.Errorf("%s is larger than %d bytes, which is not an archive this host "+
"will unpack", source, maxArchive)
}
return body, nil
}
// unpack writes a gzipped tar into a directory, refusing anything that would land outside it.
func unpack(body []byte, into string) (int, error) {
zipped, err := gzip.NewReader(strings.NewReader(string(body)))
if err != nil {
return 0, fmt.Errorf("this is not a gzipped tar: %w", err)
}
defer zipped.Close()
root, err := filepath.Abs(into)
if err != nil {
return 0, err
}
reader := tar.NewReader(zipped)
written := 0
for {
header, err := reader.Next()
if err == io.EOF {
return written, nil
}
if err != nil {
return written, err
}
// The oldest bug in unpacking: an entry named ../../etc/passwd writes outside the
// directory it was unpacked into.
//
// **Refused, not sanitised.** Rewriting the name so it lands inside would put a file
// somewhere nobody asked for and report success — the "looks configured and is not"
// failure this host exists to prevent. An archive that names a path outside itself is
// either hostile or broken, and both want the same answer.
cleaned := filepath.Clean(header.Name)
if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
return written, fmt.Errorf(
"%s names a path outside the archive; nothing more was unpacked", header.Name)
}
// And the same question asked of the result, because a name can be made to resolve
// outside without saying so.
target := filepath.Join(root, cleaned)
if !strings.HasPrefix(target, root+string(os.PathSeparator)) && target != root {
return written, fmt.Errorf(
"%s would land outside %s; nothing more was unpacked", header.Name, into)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(header.Mode)&os.ModePerm); err != nil {
return written, err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return written, err
}
file, err := os.OpenFile(target,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&os.ModePerm)
if err != nil {
return written, err
}
if _, err := io.Copy(file, io.LimitReader(reader, maxArchive)); err != nil {
file.Close()
return written, err
}
if err := file.Close(); err != nil {
return written, err
}
written++
default:
// Symlinks, devices, fifos. Refused rather than skipped: a theme that needed one
// would silently arrive incomplete, and a device node in an archive is not something
// to unpack quietly onto a machine.
return written, fmt.Errorf(
"%s is a %c, and this host unpacks only files and directories",
header.Name, header.Typeflag)
}
}
}