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) } } }