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.
186 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|