Files
zeekay baebcfa594 fix: add GOWORK/CGO env to all CI workflows and fix go fmt
- Add GOWORK=off and CGO_ENABLED=0 to lint.yml, unit-test.yml, ci.yml, build.yml
- Remove -race flag from ci.yml (incompatible with CGO_ENABLED=0)
- Fix go fmt formatting in 22 Go files
2026-01-28 10:21:23 -08:00

600 lines
16 KiB
Go

// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpc
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
)
// NodeStatus represents the current state of an MPC node.
type NodeStatus string
const (
NodeStatusStopped NodeStatus = "stopped"
NodeStatusStarting NodeStatus = "starting"
NodeStatusRunning NodeStatus = "running"
NodeStatusError NodeStatus = "error"
)
// NodeConfig holds configuration for an MPC node.
type NodeConfig struct {
NodeID string `json:"nodeId"`
NodeName string `json:"nodeName"`
NodeIndex int `json:"nodeIndex"` // 0-based index in the MPC network
Threshold int `json:"threshold"` // t in t-of-n threshold signing
TotalNodes int `json:"totalNodes"` // n in t-of-n
Network string `json:"network"` // mainnet, testnet, devnet
ListenAddr string `json:"listenAddr"` // gRPC listen address
P2PPort int `json:"p2pPort"` // P2P communication port
APIPort int `json:"apiPort"` // API/gRPC port
Peers []string `json:"peers"` // Other MPC node addresses
DataDir string `json:"dataDir"` // Data directory
KeysDir string `json:"keysDir"` // Encrypted keys directory
LogLevel string `json:"logLevel"`
Created time.Time `json:"created"`
}
// NodeInfo contains runtime information about an MPC node.
type NodeInfo struct {
Config *NodeConfig `json:"config"`
Status NodeStatus `json:"status"`
PID int `json:"pid,omitempty"`
Uptime string `json:"uptime,omitempty"`
StartTime time.Time `json:"startTime,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Error string `json:"error,omitempty"`
}
// NetworkConfig holds configuration for an MPC network.
type NetworkConfig struct {
NetworkID string `json:"networkId"`
NetworkName string `json:"networkName"`
NetworkType string `json:"networkType"` // mainnet, testnet, devnet
Threshold int `json:"threshold"` // t in t-of-n
TotalNodes int `json:"totalNodes"` // n in t-of-n
Nodes []*NodeConfig `json:"nodes"`
Created time.Time `json:"created"`
BaseDir string `json:"baseDir"`
}
// NodeManager manages MPC node lifecycle.
type NodeManager struct {
baseDir string
}
// NewNodeManager creates a new node manager.
func NewNodeManager(baseDir string) *NodeManager {
return &NodeManager{baseDir: baseDir}
}
// BaseDir returns the base directory for MPC data.
func (m *NodeManager) BaseDir() string {
return m.baseDir
}
// InitNetwork initializes a new MPC network with the specified configuration.
func (m *NodeManager) InitNetwork(ctx context.Context, networkType string, threshold, totalNodes int) (*NetworkConfig, error) {
if threshold < 1 || threshold > totalNodes {
return nil, fmt.Errorf("invalid threshold: must be between 1 and %d", totalNodes)
}
if totalNodes < 2 {
return nil, fmt.Errorf("MPC network requires at least 2 nodes")
}
// Generate network ID
networkID := generateID(8)
networkName := fmt.Sprintf("mpc-%s-%s", networkType, networkID[:8])
// Create network directory
networkDir := filepath.Join(m.baseDir, networkName)
if err := os.MkdirAll(networkDir, 0750); err != nil {
return nil, fmt.Errorf("failed to create network directory: %w", err)
}
// Create keys directory (encrypted keys only)
keysDir := filepath.Join(m.baseDir, "..", "keys", "mpc", networkName)
if err := os.MkdirAll(keysDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create keys directory: %w", err)
}
// Base ports by network type
baseP2PPort := 9700
baseAPIPort := 9800
switch networkType {
case "testnet":
baseP2PPort = 9710
baseAPIPort = 9810
case "devnet":
baseP2PPort = 9720
baseAPIPort = 9820
}
// Create node configurations
nodes := make([]*NodeConfig, totalNodes)
peers := make([]string, totalNodes)
// First pass: create peer list
for i := 0; i < totalNodes; i++ {
peers[i] = fmt.Sprintf("127.0.0.1:%d", baseP2PPort+i)
}
// Second pass: create node configs
for i := 0; i < totalNodes; i++ {
nodeID := generateID(16)
nodeName := fmt.Sprintf("mpc-node-%d", i+1)
nodeDir := filepath.Join(networkDir, nodeName)
if err := os.MkdirAll(nodeDir, 0750); err != nil {
return nil, fmt.Errorf("failed to create node directory: %w", err)
}
// Create node subdirectories
for _, subdir := range []string{"db", "logs"} {
if err := os.MkdirAll(filepath.Join(nodeDir, subdir), 0750); err != nil {
return nil, fmt.Errorf("failed to create %s directory: %w", subdir, err)
}
}
// Remove self from peers list for this node
nodePeers := make([]string, 0, totalNodes-1)
for j, peer := range peers {
if j != i {
nodePeers = append(nodePeers, peer)
}
}
nodes[i] = &NodeConfig{
NodeID: nodeID,
NodeName: nodeName,
NodeIndex: i,
Threshold: threshold,
TotalNodes: totalNodes,
Network: networkType,
ListenAddr: fmt.Sprintf("127.0.0.1:%d", baseAPIPort+i),
P2PPort: baseP2PPort + i,
APIPort: baseAPIPort + i,
Peers: nodePeers,
DataDir: nodeDir,
KeysDir: filepath.Join(keysDir, nodeName),
LogLevel: "info",
Created: time.Now(),
}
// Create node keys directory
if err := os.MkdirAll(nodes[i].KeysDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create node keys directory: %w", err)
}
// Save node config
if err := m.saveNodeConfig(nodes[i]); err != nil {
return nil, fmt.Errorf("failed to save node config: %w", err)
}
}
networkCfg := &NetworkConfig{
NetworkID: networkID,
NetworkName: networkName,
NetworkType: networkType,
Threshold: threshold,
TotalNodes: totalNodes,
Nodes: nodes,
Created: time.Now(),
BaseDir: networkDir,
}
// Save network config
if err := m.saveNetworkConfig(networkCfg); err != nil {
return nil, fmt.Errorf("failed to save network config: %w", err)
}
return networkCfg, nil
}
// StartNetwork starts all nodes in an MPC network.
func (m *NodeManager) StartNetwork(ctx context.Context, networkName string) error {
networkCfg, err := m.LoadNetworkConfig(networkName)
if err != nil {
return fmt.Errorf("failed to load network config: %w", err)
}
for _, nodeCfg := range networkCfg.Nodes {
if err := m.StartNode(ctx, nodeCfg); err != nil {
return fmt.Errorf("failed to start node %s: %w", nodeCfg.NodeName, err)
}
}
return nil
}
// StopNetwork stops all nodes in an MPC network.
func (m *NodeManager) StopNetwork(ctx context.Context, networkName string) error {
networkCfg, err := m.LoadNetworkConfig(networkName)
if err != nil {
return fmt.Errorf("failed to load network config: %w", err)
}
for _, nodeCfg := range networkCfg.Nodes {
if err := m.StopNode(ctx, nodeCfg.NodeName); err != nil {
// Log but continue stopping other nodes
fmt.Printf("Warning: failed to stop node %s: %v\n", nodeCfg.NodeName, err)
}
}
return nil
}
// StartNode starts a single MPC node.
func (m *NodeManager) StartNode(ctx context.Context, cfg *NodeConfig) error {
// Check if already running
info, _ := m.GetNodeStatus(cfg.NodeName)
if info != nil && info.Status == NodeStatusRunning {
return fmt.Errorf("node %s is already running", cfg.NodeName)
}
logFile := filepath.Join(cfg.DataDir, "logs", "node.log")
pidFile := filepath.Join(cfg.DataDir, "node.pid")
// Build the peer address map for consensus transport
peerMap := make(map[string]string)
peerMap[cfg.NodeID] = fmt.Sprintf("127.0.0.1:%d", cfg.P2PPort)
for i, peer := range cfg.Peers {
peerID := fmt.Sprintf("peer-%d", i)
peerMap[peerID] = peer
}
// Find lux-mpc binary
mpcBinary := findMPCBinary()
if mpcBinary == "" {
// Fallback to placeholder mode if binary not found
return m.startPlaceholderNode(cfg, logFile, pidFile)
}
// Build command args for consensus-embedded transport mode
args := []string{
"start",
"--node-id", cfg.NodeID,
"--listen", fmt.Sprintf(":%d", cfg.P2PPort),
"--api", fmt.Sprintf(":%d", cfg.APIPort),
"--data", cfg.DataDir,
"--keys", cfg.KeysDir,
"--threshold", strconv.Itoa(cfg.Threshold),
"--log-level", cfg.LogLevel,
"--mode", "consensus", // Use consensus-embedded transport (not NATS/Consul)
}
// Add peers
for _, peer := range cfg.Peers {
args = append(args, "--peer", peer)
}
// Create log file
logF, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("failed to create log file: %w", err)
}
cmd := exec.Command(mpcBinary, args...)
cmd.Stdout = logF
cmd.Stderr = logF
setSysProcAttr(cmd)
if err := cmd.Start(); err != nil {
logF.Close()
return fmt.Errorf("failed to start node process: %w", err)
}
// Save PID
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
cmd.Process.Kill()
logF.Close()
return fmt.Errorf("failed to save PID: %w", err)
}
// Save start time
startTimeFile := filepath.Join(cfg.DataDir, "start_time")
if err := os.WriteFile(startTimeFile, []byte(time.Now().Format(time.RFC3339)), 0644); err != nil {
return fmt.Errorf("failed to save start time: %w", err)
}
// Don't close log file - keep it for the daemon
return nil
}
// startPlaceholderNode starts a placeholder process when the MPC binary isn't available
func (m *NodeManager) startPlaceholderNode(cfg *NodeConfig, logFile, pidFile string) error {
// Fallback placeholder for testing without actual MPC daemon
cmd := exec.Command("sh", "-c", fmt.Sprintf(
"echo 'MPC Node %s (placeholder) started at %s' >> %s && while true; do sleep 3600; done",
cfg.NodeName, time.Now().Format(time.RFC3339), logFile,
))
setSysProcAttr(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start placeholder process: %w", err)
}
// Save PID
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
cmd.Process.Kill()
return fmt.Errorf("failed to save PID: %w", err)
}
// Save start time
startTimeFile := filepath.Join(cfg.DataDir, "start_time")
if err := os.WriteFile(startTimeFile, []byte(time.Now().Format(time.RFC3339)), 0644); err != nil {
return fmt.Errorf("failed to save start time: %w", err)
}
return nil
}
// findMPCBinary searches for the mpcd binary in common locations
func findMPCBinary() string {
// Check common locations
locations := []string{
"/usr/local/bin/mpcd",
"/usr/bin/mpcd",
}
// Check GOPATH/bin
if gopath := os.Getenv("GOPATH"); gopath != "" {
locations = append(locations, filepath.Join(gopath, "bin", "mpcd"))
}
// Check HOME/go/bin
if home, err := os.UserHomeDir(); err == nil {
locations = append(locations, filepath.Join(home, "go", "bin", "mpcd"))
}
// Check PATH
if path, err := exec.LookPath("mpcd"); err == nil {
return path
}
for _, loc := range locations {
if _, err := os.Stat(loc); err == nil {
return loc
}
}
return ""
}
// StopNode stops a single MPC node.
func (m *NodeManager) StopNode(ctx context.Context, nodeName string) error {
// Find node config
cfg, err := m.findNodeConfig(nodeName)
if err != nil {
return err
}
pidFile := filepath.Join(cfg.DataDir, "node.pid")
pidData, err := os.ReadFile(pidFile)
if err != nil {
if os.IsNotExist(err) {
return nil // Node not running
}
return fmt.Errorf("failed to read PID file: %w", err)
}
pid, err := strconv.Atoi(string(pidData))
if err != nil {
return fmt.Errorf("invalid PID: %w", err)
}
process, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("failed to find process: %w", err)
}
// Send termination signal for graceful shutdown
if err := signalTerm(process); err != nil {
// Process might already be dead
if err.Error() != "os: process already finished" {
return fmt.Errorf("failed to stop process: %w", err)
}
}
// Remove PID file
os.Remove(pidFile)
os.Remove(filepath.Join(cfg.DataDir, "start_time"))
return nil
}
// GetNodeStatus returns the status of a single node.
func (m *NodeManager) GetNodeStatus(nodeName string) (*NodeInfo, error) {
cfg, err := m.findNodeConfig(nodeName)
if err != nil {
return nil, err
}
info := &NodeInfo{
Config: cfg,
Status: NodeStatusStopped,
Endpoint: fmt.Sprintf("http://%s", cfg.ListenAddr),
}
// Check if running
pidFile := filepath.Join(cfg.DataDir, "node.pid")
pidData, err := os.ReadFile(pidFile)
if err == nil {
pid, err := strconv.Atoi(string(pidData))
if err == nil {
process, err := os.FindProcess(pid)
if err == nil {
// Check if process is still alive
err = checkProcessAlive(process)
if err == nil {
info.Status = NodeStatusRunning
info.PID = pid
// Get uptime
startTimeFile := filepath.Join(cfg.DataDir, "start_time")
if startTimeData, err := os.ReadFile(startTimeFile); err == nil {
if startTime, err := time.Parse(time.RFC3339, string(startTimeData)); err == nil {
info.StartTime = startTime
info.Uptime = time.Since(startTime).Round(time.Second).String()
}
}
}
}
}
}
return info, nil
}
// GetNetworkStatus returns the status of all nodes in a network.
func (m *NodeManager) GetNetworkStatus(networkName string) ([]*NodeInfo, error) {
networkCfg, err := m.LoadNetworkConfig(networkName)
if err != nil {
return nil, err
}
infos := make([]*NodeInfo, len(networkCfg.Nodes))
for i, nodeCfg := range networkCfg.Nodes {
info, err := m.GetNodeStatus(nodeCfg.NodeName)
if err != nil {
infos[i] = &NodeInfo{
Config: nodeCfg,
Status: NodeStatusError,
Error: err.Error(),
}
} else {
infos[i] = info
}
}
return infos, nil
}
// ListNetworks returns all MPC networks.
func (m *NodeManager) ListNetworks() ([]*NetworkConfig, error) {
entries, err := os.ReadDir(m.baseDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var networks []*NetworkConfig
for _, entry := range entries {
if !entry.IsDir() || !isNetworkDir(entry.Name()) {
continue
}
cfg, err := m.LoadNetworkConfig(entry.Name())
if err != nil {
continue // Skip invalid networks
}
networks = append(networks, cfg)
}
return networks, nil
}
// LoadNetworkConfig loads a network configuration from disk.
func (m *NodeManager) LoadNetworkConfig(networkName string) (*NetworkConfig, error) {
configPath := filepath.Join(m.baseDir, networkName, "network.json")
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read network config: %w", err)
}
var cfg NetworkConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse network config: %w", err)
}
return &cfg, nil
}
// DeleteNetwork removes an MPC network and all its data.
func (m *NodeManager) DeleteNetwork(ctx context.Context, networkName string, force bool) error {
networkCfg, err := m.LoadNetworkConfig(networkName)
if err != nil {
return err
}
// Stop all nodes first
if err := m.StopNetwork(ctx, networkName); err != nil && !force {
return fmt.Errorf("failed to stop network: %w", err)
}
// Remove network directory
if err := os.RemoveAll(networkCfg.BaseDir); err != nil {
return fmt.Errorf("failed to remove network directory: %w", err)
}
// Remove keys directory
keysDir := filepath.Join(m.baseDir, "..", "keys", "mpc", networkName)
if err := os.RemoveAll(keysDir); err != nil {
// Log but don't fail
fmt.Printf("Warning: failed to remove keys directory: %v\n", err)
}
return nil
}
// Helper functions
func (m *NodeManager) saveNodeConfig(cfg *NodeConfig) error {
configPath := filepath.Join(cfg.DataDir, "config.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0640)
}
func (m *NodeManager) saveNetworkConfig(cfg *NetworkConfig) error {
configPath := filepath.Join(cfg.BaseDir, "network.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0640)
}
func (m *NodeManager) findNodeConfig(nodeName string) (*NodeConfig, error) {
// Search all networks for the node
networks, err := m.ListNetworks()
if err != nil {
return nil, err
}
for _, network := range networks {
for _, node := range network.Nodes {
if node.NodeName == nodeName {
return node, nil
}
}
}
return nil, fmt.Errorf("node not found: %s", nodeName)
}
func generateID(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
func isNetworkDir(name string) bool {
// MPC network directories start with "mpc-"
return len(name) > 4 && name[:4] == "mpc-"
}