Mining/pkg/node/bundle.go
Claude 70fc8a6552
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
ax(node): remove banned fmt import from bundle.go
Replace all fmt.Errorf calls in bundle.go with ProtocolError structs,
eliminating the banned fmt import. Error messages are preserved via
string concatenation consistent with the rest of the node package.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-02 17:10:07 +01:00

369 lines
12 KiB
Go

package node
import (
"archive/tar"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"os"
"path/filepath"
"forge.lthn.ai/Snider/Borg/pkg/datanode"
"forge.lthn.ai/Snider/Borg/pkg/tim"
)
// bundle.Type = BundleProfile // config/profile JSON only
// bundle.Type = BundleMiner // miner binary + config
// bundle.Type = BundleFull // miner binary + profiles + config
type BundleType string
const (
BundleProfile BundleType = "profile" // Just config/profile JSON
BundleMiner BundleType = "miner" // Miner binary + config
BundleFull BundleType = "full" // Everything (miner + profiles + config)
)
// bundle, err := CreateProfileBundle(profileJSON, "main", password)
// bundle, err := CreateMinerBundle("/usr/bin/xmrig", nil, "xmrig-lthn", password)
// if VerifyBundle(bundle) { conn.Send(bundle) }
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
}
// manifest := BundleManifest{Type: BundleMiner, Name: "xmrig-lthn", MinerType: "xmrig", CreatedAt: "2026-01-01T00:00:00Z"}
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"`
}
// bundle, err := CreateProfileBundle(profileJSON, "main", password)
// if err != nil { return nil, err }
func CreateProfileBundle(profileJSON []byte, name string, password string) (*Bundle, error) {
// Create a TIM with just the profile config
timContainer, err := tim.New()
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to create TIM: " + err.Error()}
}
timContainer.Config = profileJSON
// Encrypt to STIM format
stimData, err := timContainer.ToSigil(password)
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to encrypt bundle: " + err.Error()}
}
// Calculate checksum
checksum := calculateChecksum(stimData)
return &Bundle{
Type: BundleProfile,
Name: name,
Data: stimData,
Checksum: checksum,
}, nil
}
// bundle, err := CreateProfileBundleUnencrypted(profileJSON, "main")
// if err != nil { return nil, err } // no password needed for trusted networks
func CreateProfileBundleUnencrypted(profileJSON []byte, name string) (*Bundle, error) {
checksum := calculateChecksum(profileJSON)
return &Bundle{
Type: BundleProfile,
Name: name,
Data: profileJSON,
Checksum: checksum,
}, nil
}
// bundle, err := CreateMinerBundle("/usr/bin/xmrig", profileJSON, "xmrig-lthn", password)
// if err != nil { return nil, err }
func CreateMinerBundle(minerPath string, profileJSON []byte, name string, password string) (*Bundle, error) {
// Read miner binary
minerData, err := os.ReadFile(minerPath)
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to read miner binary: " + err.Error()}
}
// Create a tarball with the miner binary
tarData, err := createTarball(map[string][]byte{
filepath.Base(minerPath): minerData,
})
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to create tarball: " + err.Error()}
}
// Create DataNode from tarball
dataNode, err := datanode.FromTar(tarData)
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to create datanode: " + err.Error()}
}
// Create TIM from DataNode
timContainer, err := tim.FromDataNode(dataNode)
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to create TIM: " + err.Error()}
}
// Set profile as config if provided
if profileJSON != nil {
timContainer.Config = profileJSON
}
// Encrypt to STIM format
stimData, err := timContainer.ToSigil(password)
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to encrypt bundle: " + err.Error()}
}
checksum := calculateChecksum(stimData)
return &Bundle{
Type: BundleMiner,
Name: name,
Data: stimData,
Checksum: checksum,
}, nil
}
// profileJSON, err := ExtractProfileBundle(bundle, password)
// if err != nil { return nil, err }
func ExtractProfileBundle(bundle *Bundle, password string) ([]byte, error) {
// Verify checksum first
if calculateChecksum(bundle.Data) != bundle.Checksum {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "checksum mismatch - bundle may be corrupted"}
}
// If it's unencrypted JSON, just return it
if isJSON(bundle.Data) {
return bundle.Data, nil
}
// Decrypt STIM format
timContainer, err := tim.FromSigil(bundle.Data, password)
if err != nil {
return nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to decrypt bundle: " + err.Error()}
}
return timContainer.Config, nil
}
// minerPath, profileJSON, err := ExtractMinerBundle(bundle, password, "/tmp/miners/xmrig")
// if err != nil { return nil, err }
func ExtractMinerBundle(bundle *Bundle, password string, destDir string) (string, []byte, error) {
// Verify checksum
if calculateChecksum(bundle.Data) != bundle.Checksum {
return "", nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "checksum mismatch - bundle may be corrupted"}
}
// Decrypt STIM format
timContainer, err := tim.FromSigil(bundle.Data, password)
if err != nil {
return "", nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to decrypt bundle: " + err.Error()}
}
// Convert rootfs to tarball and extract
tarData, err := timContainer.RootFS.ToTar()
if err != nil {
return "", nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to convert rootfs to tar: " + err.Error()}
}
// Extract tarball to destination
minerPath, err := extractTarball(tarData, destDir)
if err != nil {
return "", nil, &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to extract tarball: " + err.Error()}
}
return minerPath, timContainer.Config, nil
}
// if !node.VerifyBundle(bundle) { return fmt.Errorf("bundle corrupted or tampered") }
func VerifyBundle(bundle *Bundle) bool {
return calculateChecksum(bundle.Data) == bundle.Checksum
}
// checksum := calculateChecksum(stimData)
// bundle.Checksum = checksum
func calculateChecksum(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// if isJSON(bundle.Data) { return bundle.Data, nil } // skip decrypt for plain profiles
// if isJSON(content) { mode = 0644 } else { mode = 0755 } // executable unless JSON
func isJSON(data []byte) bool {
if len(data) == 0 {
return false
}
// JSON typically starts with { or [
return data[0] == '{' || data[0] == '['
}
// tarData, err := createTarball(map[string][]byte{"xmrig": binaryData, "config.json": cfgData})
// if err != nil { return nil, err }
func createTarball(files map[string][]byte) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
// Track directories we've created
directories := make(map[string]bool)
for name, content := range files {
// Create parent directories if needed
directory := filepath.Dir(name)
if directory != "." && !directories[directory] {
header := &tar.Header{
Name: directory + "/",
Mode: 0755,
Typeflag: tar.TypeDir,
}
if err := tarWriter.WriteHeader(header); err != nil {
return nil, err
}
directories[directory] = true
}
// Determine file mode (executable for binaries in miners/)
fileMode := int64(0644)
if filepath.Dir(name) == "miners" || !isJSON(content) {
fileMode = 0755
}
header := &tar.Header{
Name: name,
Mode: fileMode,
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 buffer.Bytes(), nil
}
// minerPath, err := extractTarball(tarData, "/tmp/miners/xmrig")
// if err != nil { return "", nil, err }
func extractTarball(tarData []byte, destDir string) (string, error) {
// Ensure destDir is an absolute, clean path for security checks
absDestDir, err := filepath.Abs(destDir)
if err != nil {
return "", &ProtocolError{Code: ErrCodeOperationFailed, Message: "failed to resolve destination directory: " + err.Error()}
}
absDestDir = filepath.Clean(absDestDir)
if err := os.MkdirAll(absDestDir, 0755); 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 := filepath.Clean(header.Name)
// Reject absolute paths
if filepath.IsAbs(cleanName) {
return "", &ProtocolError{Code: ErrCodeOperationFailed, Message: "invalid tar entry: absolute path not allowed: " + header.Name}
}
// Reject paths that escape the destination directory
parentPrefix := ".." + string(os.PathSeparator)
if bytes.HasPrefix([]byte(cleanName), []byte(parentPrefix)) || cleanName == ".." {
return "", &ProtocolError{Code: ErrCodeOperationFailed, Message: "invalid tar entry: path traversal attempt: " + header.Name}
}
// Build the full path and verify it's within destDir
fullPath := filepath.Join(absDestDir, cleanName)
fullPath = filepath.Clean(fullPath)
// Final security check: ensure the path is still within destDir
destDirPrefix := absDestDir + string(os.PathSeparator)
if !bytes.HasPrefix([]byte(fullPath), []byte(destDirPrefix)) && fullPath != absDestDir {
return "", &ProtocolError{Code: ErrCodeOperationFailed, Message: "invalid tar entry: path escape attempt: " + header.Name}
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(fullPath, os.FileMode(header.Mode)); err != nil {
return "", err
}
case tar.TypeReg:
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return "", err
}
outputFile, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if 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)
written, err := io.Copy(outputFile, limitedReader)
outputFile.Close()
if err != nil {
return "", err
}
if written > maxFileSize {
os.Remove(fullPath)
return "", &ProtocolError{Code: ErrCodeOperationFailed, Message: "file " + header.Name + " exceeds maximum allowed size"}
}
// 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(bundle, conn) // stream to peer over WebSocket or HTTP body
// StreamBundle(bundle, os.Stdout) // debug: print bundle JSON to stdout
func StreamBundle(bundle *Bundle, w io.Writer) error {
encoder := json.NewEncoder(w)
return encoder.Encode(bundle)
}
// bundle, err := ReadBundle(conn) // read from WebSocket or HTTP response body
// if err != nil { return nil, fmt.Errorf("read bundle: %w", err) }
func ReadBundle(r io.Reader) (*Bundle, error) {
var bundle Bundle
decoder := json.NewDecoder(r)
if err := decoder.Decode(&bundle); err != nil {
return nil, err
}
return &bundle, nil
}