Files

651 lines
20 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package signatureaggregator
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/luxfi/cli/v2/pkg/models"
"github.com/luxfi/node/v2/v2/api/info"
// Temporary config types until warp imports are fixed
"go.uber.org/zap"
"github.com/luxfi/cli/v2/pkg/application"
"github.com/luxfi/cli/v2/pkg/binutils"
"github.com/luxfi/cli/v2/pkg/constants"
"github.com/luxfi/cli/v2/pkg/utils"
"github.com/luxfi/cli/v2/pkg/ux"
"github.com/luxfi/node/v2/v2/utils/logging"
)
// Temporary config types until warp imports are fixed
type signatureAggregatorConfig struct{}
type Config struct {
LogLevel string
PChainAPI *APIConfig
InfoAPI *APIConfig
SignatureCacheSize int
AllowPrivateIPs bool
TrackedSubnetIDs []string
ManuallyTrackedPeers []*PeerConfig
APIPort uint16
MetricsPort uint16
}
type APIConfig struct {
BaseURL string
}
type PeerConfig struct {
IP string
Signer string
NodeID string
PublicKey string
SubnetID string
BlsPubKey string
BlsSig string
}
type basecfg struct{}
var (
signatureAggregatorConfig = struct{}
basecfg = struct{}
)
func NewSignatureAggregatorLogger(
aggregatorLogLevel string,
aggregatorLogToStdout bool,
logDir string,
) (logging.Logger, error) {
return utils.NewLogger(
constants.SignatureAggregator,
aggregatorLogLevel,
constants.DefaultAggregatorLogLevel,
logDir,
aggregatorLogToStdout,
ux.Logger.PrintToUser,
)
}
func GetLatestSignatureAggregatorReleaseVersion() (string, error) {
downloader := application.NewDownloader()
releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest",
constants.LuxOrg,
constants.WarpServicesRepoName,
)
return downloader.GetLatestReleaseVersion(releaseURL)
}
func GetLatestSignatureAggregatorPreReleaseVersion() (string, error) {
downloader := application.NewDownloader()
return downloader.GetLatestPreReleaseVersion(
constants.LuxOrg,
constants.WarpServicesRepoName,
)
}
func InstallSignatureAggregator(app *application.Lux, version *string) (string, error) {
if *version == "" || *version == constants.LatestPreReleaseVersionTag {
var err error
*version, err = GetLatestSignatureAggregatorPreReleaseVersion()
if err != nil {
return "", err
}
}
if *version == constants.LatestReleaseVersionTag {
var err error
*version, err = GetLatestSignatureAggregatorReleaseVersion()
if err != nil {
return "", err
}
}
ux.Logger.PrintToUser("Signature Aggregator version %s", *version)
versionBinDir := filepath.Join(app.GetSignatureAggregatorBinDir(), *version)
binPath := filepath.Join(versionBinDir, constants.SignatureAggregator)
if utils.IsExecutable(binPath) {
return binPath, nil
}
ux.Logger.PrintToUser("Installing Signature Aggregator")
url, err := getSignatureAggregatorURL(*version)
if err != nil {
return "", err
}
bs, err := application.NewDownloader().Download(url)
if err != nil {
return "", err
}
if err := binutils.InstallArchive("tar.gz", bs, versionBinDir); err != nil {
return "", err
}
return binPath, nil
}
func getSignatureAggregatorURL(version string) (string, error) {
goarch, goos := runtime.GOARCH, runtime.GOOS
if goos != "linux" && goos != "darwin" {
return "", fmt.Errorf("OS not supported: %s", goos)
}
component := "signature-aggregator"
semanticVersion := strings.TrimPrefix(version, component+"/")
if semanticVersion != version {
return fmt.Sprintf(
"https://github.com/%s/%s/releases/download/signature-aggregator%%2F%s/signature-aggregator_%s_%s_%s.tar.gz",
constants.LuxOrg,
constants.WarpServicesRepoName,
semanticVersion,
strings.TrimPrefix(semanticVersion, "v"),
goos,
goarch,
), nil
}
semanticVersion = strings.TrimPrefix(version, component+"-")
if semanticVersion != version {
return fmt.Sprintf(
"https://github.com/%s/%s/releases/download/signature-aggregator-%s/signature-aggregator_%s_%s_%s.tar.gz",
constants.LuxOrg,
constants.WarpServicesRepoName,
semanticVersion,
strings.TrimPrefix(semanticVersion, "v"),
goos,
goarch,
), nil
}
return fmt.Sprintf(
"https://github.com/%s/%s/releases/download/%s/signature-aggregator_%s_%s_%s.tar.gz",
constants.LuxOrg,
constants.WarpServicesRepoName,
semanticVersion,
strings.TrimPrefix(semanticVersion, "v"),
goos,
goarch,
), nil
}
// StartSignatureAggregator starts the signature aggregator process.
func StartSignatureAggregator(app *application.Lux, network models.Network, configPath string, logFile string, logger logging.Logger, version string, signatureAggregatorEndpoint string) (int, error) {
binPath, err := InstallSignatureAggregator(app, &version)
if err != nil {
return 0, err
}
// Stop any existing signature aggregator process
if err := stopSignatureAggregator(app, network); err != nil {
logger.Warn("Failed to stop existing signature aggregator",
zap.Error(err),
)
}
if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil {
return 0, err
}
logWriter, err := os.Create(logFile)
if err != nil {
return 0, err
}
logger.Info("Starting Signature Aggregator")
cmd := exec.Command(binPath, "--config-file", configPath)
cmd.Stdout = logWriter
cmd.Stderr = logWriter
if err := cmd.Start(); err != nil {
if closeErr := logWriter.Close(); closeErr != nil {
return 0, closeErr
}
return 0, fmt.Errorf("failed to start signature-aggregator: %w", err)
}
ch := make(chan struct{})
go func() {
_ = cmd.Wait()
ch <- struct{}{}
}()
// Allow time for the aggregator to initialize
time.Sleep(2 * time.Second)
select {
case <-ch:
return 0, fmt.Errorf("signature-aggregator exited during startup")
default:
}
// Check if the aggregator is ready
if err := waitForAggregatorReady(signatureAggregatorEndpoint, 5*time.Second); err != nil {
_ = cmd.Process.Kill()
return 0, fmt.Errorf("signature-aggregator not ready: %w", err)
}
logger.Info("Signature Aggregator started successfully")
return cmd.Process.Pid, nil
}
func waitForAggregatorReady(url string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return errors.New("timeout waiting for signature-aggregator readiness")
case <-ticker.C:
resp, err := http.Get(url)
if err != nil {
continue
}
// Close response body immediately after checking status
statusCode := resp.StatusCode
if err = resp.Body.Close(); err != nil {
return fmt.Errorf("error waiting for signature-aggregator readiness %w", err)
}
// Check for various status codes
switch statusCode {
case http.StatusBadRequest:
// A 400 means the service is up but received a malformed request
return nil
case http.StatusOK:
// Service is up and responding correctly
return nil
case http.StatusServiceUnavailable:
// Service is not ready yet
continue
default:
// Log unexpected status codes but continue trying
continue
}
}
}
}
// readExistingConfig reads the existing signature aggregator configuration from a file.
func readExistingConfig(configPath string) (*Config, error) {
// Check if file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, nil
}
// Read the file
configBytes, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Parse the config
var config Config
if err := json.Unmarshal(configBytes, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
return &config, nil
}
func CreateSignatureAggregatorConfig(subnetID string, networkEndpoint string, peers []info.Peer, apiPort, metricsPort uint16) *Config {
config := &Config{
LogLevel: "debug",
PChainAPI: &APIConfig{BaseURL: networkEndpoint},
InfoAPI: &APIConfig{BaseURL: networkEndpoint},
SignatureCacheSize: 1048576,
AllowPrivateIPs: true,
TrackedSubnetIDs: []string{subnetID},
ManuallyTrackedPeers: make([]*PeerConfig, 0),
APIPort: apiPort,
MetricsPort: metricsPort,
}
for _, peer := range peers {
// Skip peers with invalid IP addresses
if !peer.Info.PublicIP.IsValid() {
continue
}
config.ManuallyTrackedPeers = append(config.ManuallyTrackedPeers, &basecfg.PeerConfig{
ID: peer.Info.ID.String(),
IP: peer.Info.PublicIP.String(),
})
}
return config
}
// WriteSignatureAggregatorConfig writes the signature aggregator configuration to a file.
func WriteSignatureAggregatorConfig(config *Config, configPath string) error {
// Read existing config if it exists
existingConfig, err := readExistingConfig(configPath)
if err != nil {
return fmt.Errorf("failed to read existing config: %w", err)
}
// If existing config exists, merge with it
if existingConfig != nil {
// Merge tracked subnet IDs
existingSubnetIDs := make(map[string]struct{})
for _, id := range existingConfig.TrackedSubnetIDs {
existingSubnetIDs[id] = struct{}{}
}
for _, id := range config.TrackedSubnetIDs {
existingSubnetIDs[id] = struct{}{}
}
mergedSubnetIDs := make([]string, 0, len(existingSubnetIDs))
for id := range existingSubnetIDs {
mergedSubnetIDs = append(mergedSubnetIDs, id)
}
config.TrackedSubnetIDs = mergedSubnetIDs
// Merge manually tracked peers
existingPeers := make(map[string]*basecfg.PeerConfig)
for _, peer := range existingConfig.ManuallyTrackedPeers {
existingPeers[peer.ID] = peer
}
for _, peer := range config.ManuallyTrackedPeers {
existingPeers[peer.ID] = peer
}
mergedPeers := make([]*basecfg.PeerConfig, 0, len(existingPeers))
for _, peer := range existingPeers {
mergedPeers = append(mergedPeers, peer)
}
config.ManuallyTrackedPeers = mergedPeers
// Keep other existing values
config.LogLevel = existingConfig.LogLevel
config.SignatureCacheSize = existingConfig.SignatureCacheSize
config.AllowPrivateIPs = existingConfig.AllowPrivateIPs
}
// Marshal and write the config
configBytes, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create parent directory if it doesn't exist
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, configBytes, 0o600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
func isPortAvailable(port int) bool {
addr := fmt.Sprintf("localhost:%d", port)
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err != nil {
// If we can't connect, the port is available
return true
}
// If we can connect, the port is in use
if err = conn.Close(); err != nil {
// Log the error but still return false since port is in use
ux.Logger.RedXToUser("failed to close connection while checking port %d: %s", port, err)
}
return false
}
func generateAPIMetricsPorts() (int, int, error) {
// Create a context with a 30 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Start with default ports and increment by 2 each time
apiPort := 8080
metricsPort := 8081
// Keep trying until we find available ports or timeout
for {
select {
case <-ctx.Done():
return 0, 0, fmt.Errorf("timeout while searching for available ports: %w", ctx.Err())
default:
if isPortAvailable(apiPort) && isPortAvailable(metricsPort) {
return apiPort, metricsPort, nil
}
apiPort += 2
metricsPort += 2
}
}
}
func CreateSignatureAggregatorInstance(app *application.Lux, subnetIDStr string, network models.Network, extraAggregatorPeers []info.Peer, aggregatorLogger logging.Logger, version string) error {
// Create config file for signature aggregator
var apiPort, metricsPort int
var err error
runFilePath := app.GetLocalSignatureAggregatorRunPath(network.Kind)
// Check if run file exists and read ports from it
if _, err := os.Stat(runFilePath); err == nil {
// File exists, get process details
runFile, err := getCurrentSignatureAggregatorProcessDetails(app, network)
if err != nil {
return fmt.Errorf("failed to get process details: %w", err)
}
// Use existing ports
apiPort = runFile.APIPort
metricsPort = runFile.MetricsPort
} else {
// Run file doesn't exist, generate new ports
apiPort, metricsPort, err = generateAPIMetricsPorts()
if err != nil {
return fmt.Errorf("failed to generate api and metrics ports: %w", err)
}
}
config := CreateSignatureAggregatorConfig(subnetIDStr, network.Endpoint, extraAggregatorPeers, uint16(apiPort), uint16(metricsPort))
configPath := filepath.Join(app.GetSignatureAggregatorRunDir(network.Kind), "config.json")
if err := WriteSignatureAggregatorConfig(config, configPath); err != nil {
return fmt.Errorf("failed to write signature aggregator config: %w", err)
}
logPath := filepath.Join(app.GetSignatureAggregatorRunDir(network.Kind), "signature-aggregator.log")
signatureAggregatorEndpoint := fmt.Sprintf("http://localhost:%d/aggregate-signatures", apiPort)
pid, err := StartSignatureAggregator(app, network, configPath, logPath, aggregatorLogger, version, signatureAggregatorEndpoint)
if err != nil {
return fmt.Errorf("failed to start signature aggregator: %w", err)
}
return saveSignatureAggregatorFile(runFilePath, pid, apiPort, metricsPort, version)
}
func GetSignatureAggregatorEndpoint(app *application.Lux, network models.Network) (string, error) {
runFile, err := getCurrentSignatureAggregatorProcessDetails(app, network)
if err != nil {
return "", fmt.Errorf("failed to get process details: %w", err)
}
return fmt.Sprintf("http://localhost:%d/aggregate-signatures", runFile.APIPort), nil
}
type signatureAggregatorRunFile struct {
Pid int `json:"pid"`
APIPort int `json:"api_port"`
MetricsPort int `json:"metrics_port"`
Version string `json:"version"`
}
func saveSignatureAggregatorFile(runFilePath string, pid, apiPort, metricsPort int, version string) error {
rf := signatureAggregatorRunFile{
Pid: pid,
APIPort: apiPort,
MetricsPort: metricsPort,
Version: version,
}
bs, err := json.Marshal(&rf)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(runFilePath), constants.DefaultPerms755); err != nil {
return err
}
if err := os.WriteFile(runFilePath, bs, constants.WriteReadReadPerms); err != nil {
return fmt.Errorf("could not write signature aggregator run file to %s: %w", runFilePath, err)
}
return nil
}
// getCurrentSignatureAggregatorProcessDetails reads the run file and returns the current process details.
// It returns the run file information including PID, ports, and version.
func getCurrentSignatureAggregatorProcessDetails(app *application.Lux, network models.Network) (*signatureAggregatorRunFile, error) {
runFilePath := app.GetLocalSignatureAggregatorRunPath(network.Kind)
runFileBytes, err := os.ReadFile(runFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read run file: %w", err)
}
var runFile signatureAggregatorRunFile
if err := json.Unmarshal(runFileBytes, &runFile); err != nil {
return nil, fmt.Errorf("failed to parse run file: %w", err)
}
return &runFile, nil
}
// stopSignatureAggregator stops the running signature aggregator process.
// It reads the run file to get the PID and kills the process if it's running.
func stopSignatureAggregator(app *application.Lux, network models.Network) error {
runFilePath := app.GetLocalSignatureAggregatorRunPath(network.Kind)
if _, err := os.Stat(runFilePath); os.IsNotExist(err) {
return nil
}
runFile, err := getCurrentSignatureAggregatorProcessDetails(app, network)
if err != nil {
return fmt.Errorf("failed to get process details: %w", err)
}
// Kill existing process if running
if runFile.Pid > 0 {
process, err := os.FindProcess(runFile.Pid)
if err == nil {
if err := process.Kill(); err != nil {
ux.Logger.RedXToUser("Failed to kill process %d: %v\n", runFile.Pid, err)
}
}
}
// Wait a bit for the process to be killed
time.Sleep(2 * time.Second)
return nil
}
// restartSignatureAggregator restarts the signature aggregator with the given config.
// It reads the run file to get the current ports and version, kills the existing process,
// and starts a new one with the updated config.
func restartSignatureAggregator(app *application.Lux, network models.Network, configPath string, logger logging.Logger) error {
// Get current process details
runFile, err := getCurrentSignatureAggregatorProcessDetails(app, network)
if err != nil {
return fmt.Errorf("failed to get process details: %w", err)
}
// Restart signature aggregator with updated config
runFilePath := app.GetLocalSignatureAggregatorRunPath(network.Kind)
logPath := filepath.Join(app.GetSignatureAggregatorRunDir(network.Kind), "signature-aggregator.log")
signatureAggregatorEndpoint := fmt.Sprintf("http://localhost:%d/aggregate-signatures", runFile.APIPort)
pid, err := StartSignatureAggregator(app, network, configPath, logPath, logger, runFile.Version, signatureAggregatorEndpoint)
if err != nil {
return fmt.Errorf("failed to restart signature aggregator: %w", err)
}
// Update run file with new PID
return saveSignatureAggregatorFile(runFilePath, pid, runFile.APIPort, runFile.MetricsPort, runFile.Version)
}
// UpdateSignatureAggregatorPeers updates the existing signature aggregator config with new peers.
// If new peers are found, it updates the config and restarts the signature aggregator.
func UpdateSignatureAggregatorPeers(app *application.Lux, network models.Network, extraAggregatorPeers []info.Peer, logger logging.Logger) error {
// Get the config path
configPath := filepath.Join(app.GetSignatureAggregatorRunDir(network.Kind), "config.json")
// Read existing config
existingConfig, err := readExistingConfig(configPath)
if err != nil {
return fmt.Errorf("failed to read existing config: %w", err)
}
if existingConfig == nil {
return fmt.Errorf("no existing config found at %s", configPath)
}
// Convert existing peers to a map for easy lookup
existingPeers := make(map[string]*basecfg.PeerConfig)
for _, peer := range existingConfig.ManuallyTrackedPeers {
existingPeers[peer.ID] = peer
}
// Check for new peers
hasNewPeers := false
for _, peer := range extraAggregatorPeers {
if !peer.Info.PublicIP.IsValid() {
continue
}
peerID := peer.Info.ID.String()
if _, exists := existingPeers[peerID]; !exists {
hasNewPeers = true
existingConfig.ManuallyTrackedPeers = append(existingConfig.ManuallyTrackedPeers, &basecfg.PeerConfig{
ID: peerID,
IP: peer.Info.PublicIP.String(),
})
}
}
// If no new peers, no need to update
if !hasNewPeers {
return nil
}
// Write updated config
if err := WriteSignatureAggregatorConfig(existingConfig, configPath); err != nil {
return fmt.Errorf("failed to write updated config: %w", err)
}
// Restart the signature aggregator with the updated config
return restartSignatureAggregator(app, network, configPath, logger)
}
// SignatureAggregatorCleanup cleans up the signature aggregator process and files.
// It removes the log file and run file, and stops the running process if any.
func SignatureAggregatorCleanup(
app *application.Lux,
network models.Network,
) error {
runFilePath := app.GetLocalSignatureAggregatorRunPath(network.Kind)
if _, err := os.Stat(runFilePath); os.IsNotExist(err) {
return nil
}
runFile, err := getCurrentSignatureAggregatorProcessDetails(app, network)
if err != nil {
// If we can't get process details, just continue with cleanup
ux.Logger.RedXToUser("unable to get signature aggregator process details: %s", err)
} else {
// Force kill the process
if err := syscall.Kill(runFile.Pid, syscall.SIGKILL); err != nil {
// Just log the error and continue with cleanup
ux.Logger.RedXToUser("unable to kill signature aggregator process with pid %d: %s", runFile.Pid, err)
}
}
// Remove the entire signature aggregator directory regardless of process state
signatureAggregatorDir := app.GetSignatureAggregatorRunDir(network.Kind)
if err := os.RemoveAll(signatureAggregatorDir); err != nil {
return fmt.Errorf("failed removing signature aggregator directory %s: %w", signatureAggregatorDir, err)
}
return nil
}