Files

864 lines
23 KiB
Go

// Copyright (C) 2024, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package nodecmd
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"math/big"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/luxfi/cli/v2/pkg/ux"
"github.com/spf13/cobra"
)
type validatorConfig struct {
Name string `json:"name"`
Seed string `json:"seed"`
Account int `json:"account"`
HTTPPort int `json:"http_port"`
StakingPort int `json:"staking_port"`
Bootstrap string `json:"bootstrap"`
Group string `json:"group"`
NetworkID uint32 `json:"network_id"`
Created time.Time `json:"created"`
}
type validatorRuntime struct {
PID int `json:"pid"`
Started time.Time `json:"started"`
HTTPUrl string `json:"http_url"`
RPCUrl string `json:"rpc_url"`
WSUrl string `json:"ws_url"`
}
func newValidatorCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "validator",
Short: "Manage validator nodes with flexible wallet configurations",
Long: `Manage multiple validator nodes, each with its own wallet configuration.
Each validator can have its own seed phrase and account index, allowing
complete flexibility in deployment scenarios.`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Add subcommands
cmd.AddCommand(newValidatorAddCmd())
cmd.AddCommand(newValidatorStartCmd())
cmd.AddCommand(newValidatorStopCmd())
cmd.AddCommand(newValidatorStatusCmd())
cmd.AddCommand(newValidatorListCmd())
cmd.AddCommand(newValidatorRemoveCmd())
cmd.AddCommand(newValidatorExportCmd())
cmd.AddCommand(newValidatorImportCmd())
return cmd
}
func newValidatorAddCmd() *cobra.Command {
var (
name string
seed string
account int
httpPort int
stakingPort int
bootstrap string
group string
networkID uint32
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a new validator configuration",
Long: `Add a new validator with its own wallet configuration.
Each validator can have its own seed phrase and account index.`,
Example: ` # Add validator with specific seed and account
lux node validator add --name mainnet-0 --seed "your seed phrase" --account 0 --http-port 9630
# Add multiple validators with same seed, different accounts
lux node validator add --name mainnet-1 --seed "your seed phrase" --account 1 --http-port 9640 --bootstrap "127.0.0.1:9631"`,
RunE: func(cmd *cobra.Command, args []string) error {
return addValidator(name, seed, account, httpPort, stakingPort, bootstrap, group, networkID)
},
}
cmd.Flags().StringVar(&name, "name", "", "Validator name (required)")
cmd.Flags().StringVar(&seed, "seed", "", "Wallet seed phrase (required)")
cmd.Flags().IntVar(&account, "account", 0, "Wallet account index")
cmd.Flags().IntVar(&httpPort, "http-port", 0, "HTTP port (auto-assigned if 0)")
cmd.Flags().IntVar(&stakingPort, "staking-port", 0, "Staking port (default: http-port + 1)")
cmd.Flags().StringVar(&bootstrap, "bootstrap", "", "Bootstrap nodes (comma-separated)")
cmd.Flags().StringVar(&group, "group", "default", "Validator group")
cmd.Flags().Uint32Var(&networkID, "network-id", 96369, "Network ID")
cmd.MarkFlagRequired("name")
cmd.MarkFlagRequired("seed")
return cmd
}
func newValidatorStartCmd() *cobra.Command {
var (
name string
group string
)
cmd := &cobra.Command{
Use: "start",
Short: "Start validator(s)",
Long: `Start one or more validators by name or group.`,
Example: ` # Start specific validator
lux node validator start --name mainnet-0
# Start all validators in a group
lux node validator start --group mainnet`,
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" && group == "" {
return fmt.Errorf("either --name or --group required")
}
return startValidator(name, group)
},
}
cmd.Flags().StringVar(&name, "name", "", "Validator name")
cmd.Flags().StringVar(&group, "group", "", "Validator group")
return cmd
}
func newValidatorStopCmd() *cobra.Command {
var (
name string
group string
)
cmd := &cobra.Command{
Use: "stop",
Short: "Stop validator(s)",
Long: `Stop one or more validators by name or group.`,
Example: ` # Stop specific validator
lux node validator stop --name mainnet-0
# Stop all validators in a group
lux node validator stop --group mainnet`,
RunE: func(cmd *cobra.Command, args []string) error {
if name == "" && group == "" {
return fmt.Errorf("either --name or --group required")
}
return stopValidator(name, group)
},
}
cmd.Flags().StringVar(&name, "name", "", "Validator name")
cmd.Flags().StringVar(&group, "group", "", "Validator group")
return cmd
}
func newValidatorStatusCmd() *cobra.Command {
var name string
cmd := &cobra.Command{
Use: "status",
Short: "Check validator status",
Long: `Check the status of all validators or a specific validator.`,
Example: ` # Check all validators
lux node validator status
# Check specific validator
lux node validator status --name mainnet-0`,
RunE: func(cmd *cobra.Command, args []string) error {
return checkValidatorStatus(name)
},
}
cmd.Flags().StringVar(&name, "name", "", "Validator name (optional)")
return cmd
}
func newValidatorListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List all configured validators",
Long: `List all configured validators organized by group.`,
RunE: func(cmd *cobra.Command, args []string) error {
return listValidators()
},
}
return cmd
}
func newValidatorRemoveCmd() *cobra.Command {
var name string
cmd := &cobra.Command{
Use: "remove",
Short: "Remove a validator configuration",
Long: `Remove a validator configuration and optionally its data.`,
Example: ` lux node validator remove --name mainnet-0`,
RunE: func(cmd *cobra.Command, args []string) error {
return removeValidator(name)
},
}
cmd.Flags().StringVar(&name, "name", "", "Validator name (required)")
cmd.MarkFlagRequired("name")
return cmd
}
func newValidatorExportCmd() *cobra.Command {
var file string
cmd := &cobra.Command{
Use: "export",
Short: "Export validator configurations",
Long: `Export validator configurations to a JSON file for backup or migration.`,
Example: ` lux node validator export --file validators-backup.json`,
RunE: func(cmd *cobra.Command, args []string) error {
return exportValidators(file)
},
}
cmd.Flags().StringVar(&file, "file", "validators-export.json", "Output file")
return cmd
}
func newValidatorImportCmd() *cobra.Command {
var file string
cmd := &cobra.Command{
Use: "import",
Short: "Import validator configurations",
Long: `Import validator configurations from a JSON file.`,
Example: ` lux node validator import --file validators-backup.json`,
RunE: func(cmd *cobra.Command, args []string) error {
return importValidators(file)
},
}
cmd.Flags().StringVar(&file, "file", "", "Input file (required)")
cmd.MarkFlagRequired("file")
return cmd
}
// Implementation functions
func getValidatorConfigDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".lux", "validators")
}
func addValidator(name, seed string, account, httpPort, stakingPort int, bootstrap, group string, networkID uint32) error {
configDir := getValidatorConfigDir()
valDir := filepath.Join(configDir, name)
// Check if already exists
if _, err := os.Stat(valDir); err == nil {
return fmt.Errorf("validator %s already exists", name)
}
// Auto-assign ports if not specified
if httpPort == 0 {
httpPort = 9630
// Find next available port
for {
inUse := false
// Check all validator configs
entries, _ := ioutil.ReadDir(configDir)
for _, entry := range entries {
if entry.IsDir() {
configFile := filepath.Join(configDir, entry.Name(), "config.json")
if data, err := ioutil.ReadFile(configFile); err == nil {
var config validatorConfig
if json.Unmarshal(data, &config) == nil && config.HTTPPort == httpPort {
inUse = true
break
}
}
}
}
if !inUse {
break
}
httpPort += 10
}
}
if stakingPort == 0 {
stakingPort = httpPort + 1
}
// Create validator directory
if err := os.MkdirAll(valDir, 0755); err != nil {
return err
}
// Save configuration
config := validatorConfig{
Name: name,
Seed: seed,
Account: account,
HTTPPort: httpPort,
StakingPort: stakingPort,
Bootstrap: bootstrap,
Group: group,
NetworkID: networkID,
Created: time.Now(),
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
if err := ioutil.WriteFile(filepath.Join(valDir, "config.json"), data, 0600); err != nil {
return err
}
ux.Logger.PrintToUser("Added validator %s", name)
ux.Logger.PrintToUser(" Account: %d", account)
ux.Logger.PrintToUser(" HTTP Port: %d", httpPort)
ux.Logger.PrintToUser(" Staking Port: %d", stakingPort)
ux.Logger.PrintToUser(" Group: %s", group)
return nil
}
func startValidator(name, group string) error {
configDir := getValidatorConfigDir()
if group != "" && name == "" {
// Start all validators in group
ux.Logger.PrintToUser("Starting validators in group: %s", group)
count := 0
entries, _ := ioutil.ReadDir(configDir)
for _, entry := range entries {
if entry.IsDir() {
configFile := filepath.Join(configDir, entry.Name(), "config.json")
if data, err := ioutil.ReadFile(configFile); err == nil {
var config validatorConfig
if json.Unmarshal(data, &config) == nil && config.Group == group {
if err := startSingleValidator(entry.Name()); err != nil {
ux.Logger.PrintToUser("Warning: Failed to start %s: %v", entry.Name(), err)
} else {
count++
}
time.Sleep(2 * time.Second)
}
}
}
}
ux.Logger.PrintToUser("Started %d validators in group %s", count, group)
return nil
}
// Start single validator
return startSingleValidator(name)
}
func startSingleValidator(name string) error {
configDir := getValidatorConfigDir()
valDir := filepath.Join(configDir, name)
configFile := filepath.Join(valDir, "config.json")
// Load configuration
data, err := ioutil.ReadFile(configFile)
if err != nil {
return fmt.Errorf("failed to read config: %v", err)
}
var config validatorConfig
if err := json.Unmarshal(data, &config); err != nil {
return fmt.Errorf("failed to parse config: %v", err)
}
// Check if already running
pidFile := filepath.Join(valDir, "validator.pid")
if data, err := ioutil.ReadFile(pidFile); err == nil {
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
// Check if process is still running
if err := syscall.Kill(pid, 0); err == nil {
ux.Logger.PrintToUser("Validator %s is already running (PID: %d)", name, pid)
return nil
}
}
// Create data directory
dataDir := filepath.Join(valDir, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
// Generate staking certificates if not exist
stakingDir := filepath.Join(dataDir, "staking")
if err := os.MkdirAll(stakingDir, 0755); err != nil {
return err
}
stakingKeyPath := filepath.Join(stakingDir, "staker.key")
stakingCertPath := filepath.Join(stakingDir, "staker.crt")
if _, err := os.Stat(stakingKeyPath); os.IsNotExist(err) {
// Generate new staking credentials
if err := generateStakingCreds(stakingKeyPath, stakingCertPath); err != nil {
return fmt.Errorf("failed to generate staking credentials: %v", err)
}
}
// Build luxd command
luxdPath := filepath.Join(app.GetBaseDir(), "..", "..", "node", "build", "luxd")
if _, err := os.Stat(luxdPath); os.IsNotExist(err) {
return fmt.Errorf("luxd binary not found at %s", luxdPath)
}
args := []string{
"--network-id", fmt.Sprintf("%d", config.NetworkID),
"--data-dir", dataDir,
"--db-dir", filepath.Join(dataDir, "db"),
"--staking-tls-key-file", stakingKeyPath,
"--staking-tls-cert-file", stakingCertPath,
"--http-port", fmt.Sprintf("%d", config.HTTPPort),
"--staking-port", fmt.Sprintf("%d", config.StakingPort),
"--http-host", "0.0.0.0",
"--staking-enabled", "true",
"--api-admin-enabled", "true",
"--api-keystore-enabled", "true",
"--api-metrics-enabled", "true",
"--index-enabled", "true",
"--log-level", "info",
}
// Add bootstrap nodes if provided
if config.Bootstrap != "" {
args = append(args, "--bootstrap-ips", config.Bootstrap)
}
// Set environment variables for wallet
env := os.Environ()
env = append(env, fmt.Sprintf("WALLET_SEED=%s", config.Seed))
env = append(env, fmt.Sprintf("WALLET_ACCOUNT=%d", config.Account))
// Create log file
logFile := filepath.Join(valDir, "validator.log")
log, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
defer log.Close()
// Start the process
cmd := exec.Command(luxdPath, args...)
cmd.Env = env
cmd.Stdout = log
cmd.Stderr = log
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start luxd: %v", err)
}
// Save PID
if err := ioutil.WriteFile(pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0644); err != nil {
cmd.Process.Kill()
return err
}
// Save runtime info
runtime := validatorRuntime{
PID: cmd.Process.Pid,
Started: time.Now(),
HTTPUrl: fmt.Sprintf("http://localhost:%d", config.HTTPPort),
RPCUrl: fmt.Sprintf("http://localhost:%d/ext/bc/C/rpc", config.HTTPPort),
WSUrl: fmt.Sprintf("ws://localhost:%d/ext/bc/C/ws", config.HTTPPort),
}
runtimeData, _ := json.MarshalIndent(runtime, "", " ")
ioutil.WriteFile(filepath.Join(valDir, "runtime.json"), runtimeData, 0644)
ux.Logger.PrintToUser("Started validator %s", name)
ux.Logger.PrintToUser(" PID: %d", cmd.Process.Pid)
ux.Logger.PrintToUser(" HTTP: %s", runtime.HTTPUrl)
ux.Logger.PrintToUser(" RPC: %s", runtime.RPCUrl)
ux.Logger.PrintToUser(" Logs: %s", logFile)
return nil
}
func stopValidator(name, group string) error {
configDir := getValidatorConfigDir()
if group != "" && name == "" {
// Stop all validators in group
ux.Logger.PrintToUser("Stopping validators in group: %s", group)
count := 0
entries, _ := ioutil.ReadDir(configDir)
for _, entry := range entries {
if entry.IsDir() {
configFile := filepath.Join(configDir, entry.Name(), "config.json")
if data, err := ioutil.ReadFile(configFile); err == nil {
var config validatorConfig
if json.Unmarshal(data, &config) == nil && config.Group == group {
if err := stopSingleValidator(entry.Name()); err != nil {
ux.Logger.PrintToUser("Warning: Failed to stop %s: %v", entry.Name(), err)
} else {
count++
}
}
}
}
}
ux.Logger.PrintToUser("Stopped %d validators in group %s", count, group)
return nil
}
// Stop single validator
return stopSingleValidator(name)
}
func stopSingleValidator(name string) error {
configDir := getValidatorConfigDir()
pidFile := filepath.Join(configDir, name, "validator.pid")
data, err := ioutil.ReadFile(pidFile)
if err != nil {
return fmt.Errorf("validator %s is not running", name)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return fmt.Errorf("invalid PID in %s", pidFile)
}
// Check if process exists
if err := syscall.Kill(pid, 0); err != nil {
// Process doesn't exist, clean up PID file
os.Remove(pidFile)
return fmt.Errorf("validator %s is not running (stale PID file)", name)
}
// Send SIGTERM
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to stop validator: %v", err)
}
// Wait for process to exit (up to 10 seconds)
for i := 0; i < 10; i++ {
if err := syscall.Kill(pid, 0); err != nil {
// Process has exited
break
}
time.Sleep(time.Second)
}
// Clean up files
os.Remove(pidFile)
os.Remove(filepath.Join(configDir, name, "runtime.json"))
ux.Logger.PrintToUser("Stopped validator %s (PID: %d)", name, pid)
return nil
}
func checkValidatorStatus(name string) error {
configDir := getValidatorConfigDir()
// If no name specified, show all validators
if name == "" {
entries, err := ioutil.ReadDir(configDir)
if err != nil {
return err
}
ux.Logger.PrintToUser("=== Validator Status ===")
for _, entry := range entries {
if entry.IsDir() {
showValidatorStatus(entry.Name())
}
}
return nil
}
// Show specific validator
return showValidatorStatus(name)
}
func showValidatorStatus(name string) error {
configDir := getValidatorConfigDir()
valDir := filepath.Join(configDir, name)
// Load config
configFile := filepath.Join(valDir, "config.json")
data, err := ioutil.ReadFile(configFile)
if err != nil {
ux.Logger.PrintToUser(" %s: Not configured", name)
return nil
}
var config validatorConfig
if err := json.Unmarshal(data, &config); err != nil {
return err
}
// Check if running
pidFile := filepath.Join(valDir, "validator.pid")
runtimeFile := filepath.Join(valDir, "runtime.json")
if pidData, err := ioutil.ReadFile(pidFile); err == nil {
pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData)))
// Check if process is still running
if err := syscall.Kill(pid, 0); err == nil {
// Running - load runtime info
var runtime validatorRuntime
if rtData, err := ioutil.ReadFile(runtimeFile); err == nil {
json.Unmarshal(rtData, &runtime)
}
// Try to check health
healthStatus := "Unknown"
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/ext/health", config.HTTPPort))
if err == nil {
defer resp.Body.Close()
var health map[string]interface{}
if json.NewDecoder(resp.Body).Decode(&health) == nil {
if healthy, ok := health["healthy"].(bool); ok && healthy {
healthStatus = "Healthy"
} else {
healthStatus = "Unhealthy"
}
}
}
ux.Logger.PrintToUser("\n%s:", name)
ux.Logger.PrintToUser(" Status: Running")
ux.Logger.PrintToUser(" PID: %d", pid)
ux.Logger.PrintToUser(" Health: %s", healthStatus)
ux.Logger.PrintToUser(" Account: %d", config.Account)
ux.Logger.PrintToUser(" Group: %s", config.Group)
ux.Logger.PrintToUser(" HTTP Port: %d", config.HTTPPort)
ux.Logger.PrintToUser(" RPC URL: %s", runtime.RPCUrl)
ux.Logger.PrintToUser(" Started: %s", runtime.Started.Format("2006-01-02 15:04:05"))
} else {
// Process died, clean up
os.Remove(pidFile)
os.Remove(runtimeFile)
ux.Logger.PrintToUser("\n%s: Stopped (stale PID file)", name)
}
} else {
ux.Logger.PrintToUser("\n%s: Stopped", name)
ux.Logger.PrintToUser(" Account: %d", config.Account)
ux.Logger.PrintToUser(" Group: %s", config.Group)
ux.Logger.PrintToUser(" HTTP Port: %d", config.HTTPPort)
}
return nil
}
func listValidators() error {
configDir := getValidatorConfigDir()
// Group validators by group
groups := make(map[string][]validatorConfig)
entries, err := ioutil.ReadDir(configDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
configFile := filepath.Join(configDir, entry.Name(), "config.json")
if data, err := ioutil.ReadFile(configFile); err == nil {
var config validatorConfig
if json.Unmarshal(data, &config) == nil {
groups[config.Group] = append(groups[config.Group], config)
}
}
}
}
ux.Logger.PrintToUser("=== Configured Validators ===")
for group, validators := range groups {
ux.Logger.PrintToUser("\nGroup: %s", group)
for _, val := range validators {
ux.Logger.PrintToUser(" - %s (account: %d, port: %d)", val.Name, val.Account, val.HTTPPort)
}
}
return nil
}
func removeValidator(name string) error {
configDir := getValidatorConfigDir()
valDir := filepath.Join(configDir, name)
// Check if validator exists
if _, err := os.Stat(valDir); os.IsNotExist(err) {
return fmt.Errorf("validator %s does not exist", name)
}
// Check if running
pidFile := filepath.Join(valDir, "validator.pid")
if data, err := ioutil.ReadFile(pidFile); err == nil {
pid, _ := strconv.Atoi(strings.TrimSpace(string(data)))
if err := syscall.Kill(pid, 0); err == nil {
return fmt.Errorf("validator %s is still running - stop it first", name)
}
}
// Remove directory
if err := os.RemoveAll(valDir); err != nil {
return fmt.Errorf("failed to remove validator: %v", err)
}
ux.Logger.PrintToUser("Removed validator %s", name)
return nil
}
func exportValidators(file string) error {
configDir := getValidatorConfigDir()
// Collect all validator configs
var validators []validatorConfig
entries, err := ioutil.ReadDir(configDir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() {
configFile := filepath.Join(configDir, entry.Name(), "config.json")
if data, err := ioutil.ReadFile(configFile); err == nil {
var config validatorConfig
if json.Unmarshal(data, &config) == nil {
validators = append(validators, config)
}
}
}
}
// Write to file
data, err := json.MarshalIndent(validators, "", " ")
if err != nil {
return err
}
if err := ioutil.WriteFile(file, data, 0644); err != nil {
return err
}
ux.Logger.PrintToUser("Exported %d validators to %s", len(validators), file)
return nil
}
func importValidators(file string) error {
// Read the file
data, err := ioutil.ReadFile(file)
if err != nil {
return fmt.Errorf("failed to read file: %v", err)
}
var validators []validatorConfig
if err := json.Unmarshal(data, &validators); err != nil {
return fmt.Errorf("failed to parse JSON: %v", err)
}
imported := 0
for _, val := range validators {
if err := addValidator(val.Name, val.Seed, val.Account, val.HTTPPort, val.StakingPort, val.Bootstrap, val.Group, val.NetworkID); err != nil {
ux.Logger.PrintToUser("Warning: Failed to import %s: %v", val.Name, err)
} else {
imported++
}
}
ux.Logger.PrintToUser("Imported %d validators from %s", imported, file)
return nil
}
// Helper function to generate staking credentials
func generateStakingCreds(keyPath, certPath string) error {
// Generate a new private key
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Lux"},
Country: []string{"US"},
Province: []string{""},
Locality: []string{"lux"},
StreetAddress: []string{""},
PostalCode: []string{""},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour * 10), // 10 years
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Create the certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return err
}
// Write key
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer keyOut.Close()
privKeyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return err
}
if err := pem.Encode(keyOut, &pem.Block{
Type: "PRIVATE KEY",
Bytes: privKeyBytes,
}); err != nil {
return err
}
// Write certificate
certOut, err := os.Create(certPath)
if err != nil {
return err
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
}); err != nil {
return err
}
return nil
}