go-p2p/node/bundle.go
Claude 8f94639ec9
feat: extract P2P networking and UEPS protocol from Mining repo
P2P node layer (peer discovery, WebSocket transport, message protocol,
worker pool, identity management) and Unified Ethical Protocol Stack
(TLV packet builder with HMAC-signed frames).

Ported from github.com/Snider/Mining/pkg/{node,ueps,logging}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:47:10 +00:00

355 lines
9.5 KiB
Go

package node
import (
"archive/tar"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
)
// BundleType defines the type of deployment bundle.
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 represents a deployment bundle for P2P transfer.
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.
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.
func CreateProfileBundle(profileJSON []byte, name string, password string) (*Bundle, error) {
// Create a TIM with just the profile config
t, err := tim.New()
if err != nil {
return nil, fmt.Errorf("failed to create TIM: %w", err)
}
t.Config = profileJSON
// Encrypt to STIM format
stimData, err := t.ToSigil(password)
if err != nil {
return nil, fmt.Errorf("failed to encrypt bundle: %w", 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).
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.
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, fmt.Errorf("failed to read miner binary: %w", err)
}
// Create a tarball with the miner binary
tarData, err := createTarball(map[string][]byte{
filepath.Base(minerPath): minerData,
})
if err != nil {
return nil, fmt.Errorf("failed to create tarball: %w", err)
}
// Create DataNode from tarball
dn, err := datanode.FromTar(tarData)
if err != nil {
return nil, fmt.Errorf("failed to create datanode: %w", err)
}
// Create TIM from DataNode
t, err := tim.FromDataNode(dn)
if err != nil {
return nil, fmt.Errorf("failed to create TIM: %w", err)
}
// Set profile as config if provided
if profileJSON != nil {
t.Config = profileJSON
}
// Encrypt to STIM format
stimData, err := t.ToSigil(password)
if err != nil {
return nil, fmt.Errorf("failed to encrypt bundle: %w", err)
}
checksum := calculateChecksum(stimData)
return &Bundle{
Type: BundleMiner,
Name: name,
Data: stimData,
Checksum: checksum,
}, nil
}
// ExtractProfileBundle decrypts and extracts a profile bundle.
func ExtractProfileBundle(bundle *Bundle, password string) ([]byte, error) {
// Verify checksum first
if calculateChecksum(bundle.Data) != bundle.Checksum {
return nil, fmt.Errorf("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
t, err := tim.FromSigil(bundle.Data, password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt bundle: %w", err)
}
return t.Config, nil
}
// ExtractMinerBundle decrypts and extracts a miner bundle, returning the miner path and profile.
func ExtractMinerBundle(bundle *Bundle, password string, destDir string) (string, []byte, error) {
// Verify checksum
if calculateChecksum(bundle.Data) != bundle.Checksum {
return "", nil, fmt.Errorf("checksum mismatch - bundle may be corrupted")
}
// Decrypt STIM format
t, err := tim.FromSigil(bundle.Data, password)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt bundle: %w", err)
}
// Convert rootfs to tarball and extract
tarData, err := t.RootFS.ToTar()
if err != nil {
return "", nil, fmt.Errorf("failed to convert rootfs to tar: %w", err)
}
// Extract tarball to destination
minerPath, err := extractTarball(tarData, destDir)
if err != nil {
return "", nil, fmt.Errorf("failed to extract tarball: %w", err)
}
return minerPath, t.Config, nil
}
// VerifyBundle checks if a bundle's checksum is valid.
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
tw := tar.NewWriter(&buf)
// Track directories we've created
dirs := make(map[string]bool)
for name, content := range files {
// Create parent directories if needed
dir := filepath.Dir(name)
if dir != "." && !dirs[dir] {
hdr := &tar.Header{
Name: dir + "/",
Mode: 0755,
Typeflag: tar.TypeDir,
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
dirs[dir] = true
}
// Determine file mode (executable for binaries in miners/)
mode := int64(0644)
if filepath.Dir(name) == "miners" || !isJSON(content) {
mode = 0755
}
hdr := &tar.Header{
Name: name,
Mode: mode,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
return nil, err
}
if _, err := tw.Write(content); err != nil {
return nil, err
}
}
if err := tw.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, err := filepath.Abs(destDir)
if err != nil {
return "", fmt.Errorf("failed to resolve destination directory: %w", err)
}
absDestDir = filepath.Clean(absDestDir)
if err := os.MkdirAll(absDestDir, 0755); err != nil {
return "", err
}
tr := tar.NewReader(bytes.NewReader(tarData))
var firstExecutable string
for {
hdr, err := tr.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(hdr.Name)
// Reject absolute paths
if filepath.IsAbs(cleanName) {
return "", fmt.Errorf("invalid tar entry: absolute path not allowed: %s", hdr.Name)
}
// Reject paths that escape the destination directory
if strings.HasPrefix(cleanName, ".."+string(os.PathSeparator)) || cleanName == ".." {
return "", fmt.Errorf("invalid tar entry: path traversal attempt: %s", hdr.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
if !strings.HasPrefix(fullPath, absDestDir+string(os.PathSeparator)) && fullPath != absDestDir {
return "", fmt.Errorf("invalid tar entry: path escape attempt: %s", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(fullPath, os.FileMode(hdr.Mode)); err != nil {
return "", err
}
case tar.TypeReg:
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return "", err
}
f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.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(tr, maxFileSize+1)
written, err := io.Copy(f, limitedReader)
f.Close()
if err != nil {
return "", err
}
if written > maxFileSize {
os.Remove(fullPath)
return "", fmt.Errorf("file %s exceeds maximum size of %d bytes", hdr.Name, maxFileSize)
}
// Track first executable
if hdr.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).
func StreamBundle(bundle *Bundle, w io.Writer) error {
encoder := json.NewEncoder(w)
return encoder.Encode(bundle)
}
// ReadBundle reads a bundle from a reader.
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
}