Mining/pkg/node/bundle.go
Claude b12db10680
ax(batch): expand abbreviated parameter and local variable names across all packages
Applies AX principle 1 (Predictable Names Over Short Names) to function
signatures and local variables: s->input/raw, v->target/value, d->duration,
a,b->left,right, w->writer, r->reader, l->logger, p->part/databasePoint,
fn parameter names left as-is where they are callback conventions.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-02 18:27:21 +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, writer io.Writer) error {
encoder := json.NewEncoder(writer)
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(reader io.Reader) (*Bundle, error) {
var bundle Bundle
decoder := json.NewDecoder(reader)
if err := decoder.Decode(&bundle); err != nil {
return nil, err
}
return &bundle, nil
}