go-p2p/node/bundle.go
Virgil 819862a1a4
All checks were successful
Security Scan / security (push) Successful in 11s
Test / test (push) Successful in 1m38s
refactor(node): tighten AX naming across core paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 22:31:11 +00:00

397 lines
11 KiB
Go

package node
import (
"archive/tar"
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
core "dappco.re/go/core"
"forge.lthn.ai/Snider/Borg/pkg/datanode"
"forge.lthn.ai/Snider/Borg/pkg/tim"
)
// BundleType defines the type of deployment bundle.
//
// bundleType := BundleProfile
type BundleType string
const (
// BundleProfile contains a profile JSON payload.
BundleProfile BundleType = "profile"
// BundleMiner contains a miner binary and optional profile data.
BundleMiner BundleType = "miner"
// BundleFull contains the full deployment payload.
BundleFull BundleType = "full"
)
// Bundle represents a deployment bundle for P2P transfer.
//
// bundle := &Bundle{Type: BundleProfile, Name: "xmrig", Data: []byte("{}")}
type Bundle struct {
Type BundleType `json:"type"`
Name string `json:"name"`
Data []byte `json:"data"` // Encrypted STIM data or raw JSON
Checksum string `json:"checksum"` // SHA-256 of Data
}
// BundleManifest describes the contents of a bundle.
//
// manifest := BundleManifest{Name: "xmrig", Type: BundleMiner}
type BundleManifest struct {
Type BundleType `json:"type"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
MinerType string `json:"minerType,omitempty"`
ProfileIDs []string `json:"profileIds,omitempty"`
CreatedAt string `json:"createdAt"`
}
// CreateProfileBundle creates an encrypted bundle containing a mining profile.
//
// bundle, err := CreateProfileBundle(profileJSON, "xmrig-default", "password")
func CreateProfileBundle(profileJSON []byte, name string, password string) (*Bundle, error) {
// Create a TIM with just the profile config
timBundle, err := tim.New()
if err != nil {
return nil, core.E("CreateProfileBundle", "failed to create TIM", err)
}
timBundle.Config = profileJSON
// Encrypt to STIM format
stimData, err := timBundle.ToSigil(password)
if err != nil {
return nil, core.E("CreateProfileBundle", "failed to encrypt bundle", err)
}
// Calculate checksum
checksum := calculateChecksum(stimData)
return &Bundle{
Type: BundleProfile,
Name: name,
Data: stimData,
Checksum: checksum,
}, nil
}
// CreateProfileBundleUnencrypted creates a plain JSON bundle (for testing or trusted networks).
//
// bundle, err := CreateProfileBundleUnencrypted(profileJSON, "xmrig-default")
func CreateProfileBundleUnencrypted(profileJSON []byte, name string) (*Bundle, error) {
checksum := calculateChecksum(profileJSON)
return &Bundle{
Type: BundleProfile,
Name: name,
Data: profileJSON,
Checksum: checksum,
}, nil
}
// CreateMinerBundle creates an encrypted bundle containing a miner binary and optional profile.
//
// bundle, err := CreateMinerBundle("/srv/miners/xmrig", profileJSON, "xmrig", "password")
func CreateMinerBundle(minerPath string, profileJSON []byte, name string, password string) (*Bundle, error) {
// Read miner binary
minerContent, err := filesystemRead(minerPath)
if err != nil {
return nil, core.E("CreateMinerBundle", "failed to read miner binary", err)
}
minerData := []byte(minerContent)
// Create a tarball with the miner binary
tarData, err := createTarball(map[string][]byte{
core.PathBase(minerPath): minerData,
})
if err != nil {
return nil, core.E("CreateMinerBundle", "failed to create tarball", err)
}
// Create DataNode from tarball
dataNode, err := datanode.FromTar(tarData)
if err != nil {
return nil, core.E("CreateMinerBundle", "failed to create datanode", err)
}
// Create TIM from DataNode
timBundle, err := tim.FromDataNode(dataNode)
if err != nil {
return nil, core.E("CreateMinerBundle", "failed to create TIM", err)
}
// Set profile as config if provided
if profileJSON != nil {
timBundle.Config = profileJSON
}
// Encrypt to STIM format
stimData, err := timBundle.ToSigil(password)
if err != nil {
return nil, core.E("CreateMinerBundle", "failed to encrypt bundle", err)
}
checksum := calculateChecksum(stimData)
return &Bundle{
Type: BundleMiner,
Name: name,
Data: stimData,
Checksum: checksum,
}, nil
}
// ExtractProfileBundle decrypts and extracts a profile bundle.
//
// profileJSON, err := ExtractProfileBundle(bundle, "password")
func ExtractProfileBundle(bundle *Bundle, password string) ([]byte, error) {
// Verify checksum first
if calculateChecksum(bundle.Data) != bundle.Checksum {
return nil, core.E("ExtractProfileBundle", "checksum mismatch - bundle may be corrupted", nil)
}
// If it's unencrypted JSON, just return it
if isJSON(bundle.Data) {
return bundle.Data, nil
}
// Decrypt STIM format
timBundle, err := tim.FromSigil(bundle.Data, password)
if err != nil {
return nil, core.E("ExtractProfileBundle", "failed to decrypt bundle", err)
}
return timBundle.Config, nil
}
// ExtractMinerBundle decrypts and extracts a miner bundle, returning the miner path and profile.
//
// minerPath, profileJSON, err := ExtractMinerBundle(bundle, "password", "/srv/miners")
func ExtractMinerBundle(bundle *Bundle, password string, destDir string) (string, []byte, error) {
// Verify checksum
if calculateChecksum(bundle.Data) != bundle.Checksum {
return "", nil, core.E("ExtractMinerBundle", "checksum mismatch - bundle may be corrupted", nil)
}
// Decrypt STIM format
timBundle, err := tim.FromSigil(bundle.Data, password)
if err != nil {
return "", nil, core.E("ExtractMinerBundle", "failed to decrypt bundle", err)
}
// Convert rootfs to tarball and extract
tarData, err := timBundle.RootFS.ToTar()
if err != nil {
return "", nil, core.E("ExtractMinerBundle", "failed to convert rootfs to tar", err)
}
// Extract tarball to destination
minerPath, err := extractTarball(tarData, destDir)
if err != nil {
return "", nil, core.E("ExtractMinerBundle", "failed to extract tarball", err)
}
return minerPath, timBundle.Config, nil
}
// VerifyBundle checks if a bundle's checksum is valid.
//
// ok := VerifyBundle(bundle)
func VerifyBundle(bundle *Bundle) bool {
return calculateChecksum(bundle.Data) == bundle.Checksum
}
// calculateChecksum computes SHA-256 checksum of data.
func calculateChecksum(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// isJSON checks if data starts with JSON characters.
func isJSON(data []byte) bool {
if len(data) == 0 {
return false
}
// JSON typically starts with { or [
return data[0] == '{' || data[0] == '['
}
// createTarball creates a tar archive from a map of filename -> content.
func createTarball(files map[string][]byte) ([]byte, error) {
var buf bytes.Buffer
tarWriter := tar.NewWriter(&buf)
// Track directories we've created
createdDirectories := make(map[string]bool)
for name, content := range files {
// Create parent directories if needed
dir := core.PathDir(name)
if dir != "." && !createdDirectories[dir] {
header := &tar.Header{
Name: dir + "/",
Mode: 0755,
Typeflag: tar.TypeDir,
}
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
createdDirectories[dir] = true
}
// Determine file mode (executable for binaries in miners/)
mode := int64(0644)
if core.PathDir(name) == "miners" || !isJSON(content) {
mode = 0755
}
header := &tar.Header{
Name: name,
Mode: mode,
Size: int64(len(content)),
}
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
if _, err := tarWriter.Write(content); err != nil {
return nil, err
}
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// extractTarball extracts a tar archive to a directory, returns first executable found.
func extractTarball(tarData []byte, destDir string) (string, error) {
// Ensure destDir is an absolute, clean path for security checks
absDestDir := destDir
pathSeparator := core.Env("DS")
if pathSeparator == "" {
pathSeparator = "/"
}
if !core.PathIsAbs(absDestDir) {
cwd := core.Env("DIR_CWD")
if cwd == "" {
return "", core.E("extractTarball", "failed to resolve destination directory", nil)
}
absDestDir = core.CleanPath(core.Concat(cwd, pathSeparator, absDestDir), pathSeparator)
} else {
absDestDir = core.CleanPath(absDestDir, pathSeparator)
}
if err := filesystemEnsureDir(absDestDir); err != nil {
return "", err
}
tarReader := tar.NewReader(bytes.NewReader(tarData))
var firstExecutable string
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
// Security: Sanitize the tar entry name to prevent path traversal (Zip Slip)
cleanName := core.CleanPath(header.Name, "/")
// Reject absolute paths
if core.PathIsAbs(cleanName) {
return "", core.E("extractTarball", "invalid tar entry: absolute path not allowed: "+header.Name, nil)
}
// Reject paths that escape the destination directory
if core.HasPrefix(cleanName, "../") || cleanName == ".." {
return "", core.E("extractTarball", "invalid tar entry: path traversal attempt: "+header.Name, nil)
}
// Build the full path and verify it's within destDir
fullPath := core.CleanPath(core.Concat(absDestDir, pathSeparator, cleanName), pathSeparator)
// Final security check: ensure the path is still within destDir
allowedPrefix := core.Concat(absDestDir, pathSeparator)
if absDestDir == pathSeparator {
allowedPrefix = absDestDir
}
if !core.HasPrefix(fullPath, allowedPrefix) && fullPath != absDestDir {
return "", core.E("extractTarball", "invalid tar entry: path escape attempt: "+header.Name, nil)
}
switch header.Typeflag {
case tar.TypeDir:
if err := filesystemEnsureDir(fullPath); err != nil {
return "", err
}
case tar.TypeReg:
// Ensure parent directory exists
if err := filesystemEnsureDir(core.PathDir(fullPath)); err != nil {
return "", err
}
// Limit file size to prevent decompression bombs (100MB max per file)
const maxFileSize int64 = 100 * 1024 * 1024
limitedReader := io.LimitReader(tarReader, maxFileSize+1)
content, err := io.ReadAll(limitedReader)
if err != nil {
return "", core.E("extractTarball", "failed to write file "+header.Name, err)
}
if int64(len(content)) > maxFileSize {
filesystemDelete(fullPath)
return "", core.E("extractTarball", "file "+header.Name+" exceeds maximum size", nil)
}
if err := filesystemResultError(localFileSystem.WriteMode(fullPath, string(content), fs.FileMode(header.Mode))); err != nil {
return "", core.E("extractTarball", "failed to create file "+header.Name, err)
}
// Track first executable
if header.Mode&0111 != 0 && firstExecutable == "" {
firstExecutable = fullPath
}
// Explicitly ignore symlinks and hard links to prevent symlink attacks
case tar.TypeSymlink, tar.TypeLink:
// Skip symlinks and hard links for security
continue
}
}
return firstExecutable, nil
}
// StreamBundle writes a bundle to a writer (for large transfers).
//
// err := StreamBundle(bundle, writer)
func StreamBundle(bundle *Bundle, w io.Writer) error {
result := core.JSONMarshal(bundle)
if !result.OK {
return result.Value.(error)
}
_, err := w.Write(result.Value.([]byte))
return err
}
// ReadBundle reads a bundle from a reader.
//
// bundle, err := ReadBundle(reader)
func ReadBundle(r io.Reader) (*Bundle, error) {
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
return nil, err
}
var bundle Bundle
result := core.JSONUnmarshal(buf.Bytes(), &bundle)
if !result.OK {
return nil, result.Value.(error)
}
return &bundle, nil
}