mirror of
https://github.com/luxfi/node.git
synced 2026-07-27 03:39:39 +00:00
clean: squash history (binaries stripped via filter-repo)
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/rpc/v2"
|
||||
|
||||
apiadmin "github.com/luxfi/api/admin"
|
||||
"github.com/luxfi/node/utils/json"
|
||||
)
|
||||
|
||||
// CreateHandler returns an HTTP handler for the admin API.
|
||||
func (a *Service) CreateHandler() (http.Handler, error) {
|
||||
server := rpc.NewServer()
|
||||
codec := json.NewCodec()
|
||||
server.RegisterCodec(codec, "application/json")
|
||||
server.RegisterCodec(codec, "application/json;charset=UTF-8")
|
||||
|
||||
return server, server.RegisterService(&rpcService{svc: a}, "admin")
|
||||
}
|
||||
|
||||
type rpcService struct {
|
||||
svc apiadmin.Service
|
||||
}
|
||||
|
||||
func (r *rpcService) StartCPUProfiler(req *http.Request, _ *struct{}, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.StartCPUProfiler(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) StopCPUProfiler(req *http.Request, _ *struct{}, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.StopCPUProfiler(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) MemoryProfile(req *http.Request, _ *struct{}, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.MemoryProfile(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) LockProfile(req *http.Request, _ *struct{}, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.LockProfile(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) Alias(req *http.Request, args *apiadmin.AliasArgs, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.Alias(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) AliasChain(req *http.Request, args *apiadmin.AliasChainArgs, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.AliasChain(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) GetChainAliases(req *http.Request, args *apiadmin.GetChainAliasesArgs, reply *apiadmin.GetChainAliasesReply) error {
|
||||
resp, err := r.svc.GetChainAliases(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) Stacktrace(req *http.Request, _ *struct{}, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.Stacktrace(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) SetLoggerLevel(req *http.Request, args *apiadmin.SetLoggerLevelArgs, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.SetLoggerLevel(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) GetLoggerLevel(req *http.Request, args *apiadmin.GetLoggerLevelArgs, reply *apiadmin.LoggerLevelReply) error {
|
||||
resp, err := r.svc.GetLoggerLevel(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) GetConfig(req *http.Request, _ *struct{}, reply *any) error {
|
||||
resp, err := r.svc.GetConfig(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*reply = resp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) LoadVMs(req *http.Request, _ *struct{}, reply *apiadmin.LoadVMsReply) error {
|
||||
resp, err := r.svc.LoadVMs(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) DbGet(req *http.Request, args *apiadmin.DBGetArgs, reply *apiadmin.DBGetReply) error {
|
||||
resp, err := r.svc.DbGet(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) ListVMs(req *http.Request, _ *struct{}, reply *apiadmin.ListVMsReply) error {
|
||||
resp, err := r.svc.ListVMs(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) SetTrackedChains(req *http.Request, args *apiadmin.SetTrackedChainsArgs, reply *apiadmin.SetTrackedChainsReply) error {
|
||||
resp, err := r.svc.SetTrackedChains(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) GetTrackedChains(req *http.Request, _ *struct{}, reply *apiadmin.GetTrackedChainsReply) error {
|
||||
resp, err := r.svc.GetTrackedChains(req.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) Snapshot(req *http.Request, args *apiadmin.SnapshotArgs, reply *apiadmin.SnapshotReply) error {
|
||||
resp, err := r.svc.Snapshot(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rpcService) Load(req *http.Request, args *apiadmin.LoadArgs, reply *apiadmin.EmptyReply) error {
|
||||
resp, err := r.svc.Load(req.Context(), args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp != nil {
|
||||
*reply = *resp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
apiadmin "github.com/luxfi/api/admin"
|
||||
apitypes "github.com/luxfi/api/types"
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/filesystem/perms"
|
||||
"github.com/luxfi/formatting"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/node/chains"
|
||||
server "github.com/luxfi/node/server/http"
|
||||
"github.com/luxfi/node/service/backup"
|
||||
"github.com/luxfi/node/utils"
|
||||
"github.com/luxfi/node/utils/profiler"
|
||||
"github.com/luxfi/node/vms"
|
||||
"github.com/luxfi/node/vms/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAliasLength = 512
|
||||
|
||||
// Name of file that stacktraces are written to.
|
||||
stacktraceFile = "stacktrace.txt"
|
||||
)
|
||||
|
||||
var (
|
||||
errAliasTooLong = errors.New("alias length is too long")
|
||||
errNoLogLevel = errors.New("need to specify either displayLevel or logLevel")
|
||||
)
|
||||
|
||||
// ChainTracker is the interface for tracking chains at runtime.
|
||||
type ChainTracker interface {
|
||||
TrackChain(chainID ids.ID) error
|
||||
TrackedChains() set.Set[ids.ID]
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Log log.Logger
|
||||
ProfileDir string
|
||||
LogFactory log.Factory
|
||||
NodeConfig interface{}
|
||||
DB database.Database
|
||||
ChainManager chains.Manager
|
||||
HTTPServer server.PathAdderWithReadLock
|
||||
VMRegistry registry.VMRegistry
|
||||
VMManager vms.Manager
|
||||
PluginDir string
|
||||
Network ChainTracker
|
||||
// DataDir is the node's data directory, used for backup metadata.
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// Service implements api/admin.Service.
|
||||
type Service struct {
|
||||
Config
|
||||
lock sync.RWMutex
|
||||
profiler profiler.Profiler
|
||||
backupService *backup.Service
|
||||
}
|
||||
|
||||
func New(config Config) *Service {
|
||||
svc := &Service{
|
||||
Config: config,
|
||||
profiler: profiler.New(config.ProfileDir),
|
||||
}
|
||||
|
||||
// Initialize backup service if data directory is configured
|
||||
if config.DataDir != "" && config.DB != nil {
|
||||
backupSvc, err := backup.New(backup.Config{
|
||||
DB: config.DB,
|
||||
MetadataDir: filepath.Join(config.DataDir, "backup"),
|
||||
Log: config.Log,
|
||||
})
|
||||
if err != nil {
|
||||
config.Log.Warn("failed to initialize backup service", "error", err)
|
||||
} else {
|
||||
svc.backupService = backupSvc
|
||||
}
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func (a *Service) StartCPUProfiler(ctx context.Context) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "startCPUProfiler"))
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
return &apiadmin.EmptyReply{}, a.profiler.StartCPUProfiler()
|
||||
}
|
||||
|
||||
func (a *Service) StopCPUProfiler(ctx context.Context) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "stopCPUProfiler"))
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
return &apiadmin.EmptyReply{}, a.profiler.StopCPUProfiler()
|
||||
}
|
||||
|
||||
func (a *Service) MemoryProfile(ctx context.Context) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "memoryProfile"))
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
return &apiadmin.EmptyReply{}, a.profiler.MemoryProfile()
|
||||
}
|
||||
|
||||
func (a *Service) LockProfile(ctx context.Context) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "lockProfile"))
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
return &apiadmin.EmptyReply{}, a.profiler.LockProfile()
|
||||
}
|
||||
|
||||
func (a *Service) Alias(ctx context.Context, args *apiadmin.AliasArgs) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "alias"),
|
||||
log.String("endpoint", args.Endpoint),
|
||||
log.String("alias", args.Alias),
|
||||
)
|
||||
if len(args.Alias) > maxAliasLength {
|
||||
return nil, errAliasTooLong
|
||||
}
|
||||
return &apiadmin.EmptyReply{}, a.HTTPServer.AddAliasesWithReadLock(args.Endpoint, args.Alias)
|
||||
}
|
||||
|
||||
func (a *Service) AliasChain(ctx context.Context, args *apiadmin.AliasChainArgs) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "aliasChain"),
|
||||
log.String("chain", args.Chain),
|
||||
log.String("alias", args.Alias),
|
||||
)
|
||||
if len(args.Alias) > maxAliasLength {
|
||||
return nil, errAliasTooLong
|
||||
}
|
||||
chainID, err := a.ChainManager.Lookup(args.Chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
if err := a.ChainManager.Alias(chainID, args.Alias); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint := path.Join(constants.ChainAliasPrefix, chainID.String())
|
||||
alias := path.Join(constants.ChainAliasPrefix, args.Alias)
|
||||
return &apiadmin.EmptyReply{}, a.HTTPServer.AddAliasesWithReadLock(endpoint, alias)
|
||||
}
|
||||
|
||||
func (a *Service) GetChainAliases(ctx context.Context, args *apiadmin.GetChainAliasesArgs) (*apiadmin.GetChainAliasesReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "getChainAliases"),
|
||||
log.String("chain", args.Chain),
|
||||
)
|
||||
id, err := ids.FromString(args.Chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aliases, err := a.ChainManager.Aliases(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &apiadmin.GetChainAliasesReply{Aliases: aliases}, nil
|
||||
}
|
||||
|
||||
func (a *Service) Stacktrace(ctx context.Context) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "stacktrace"))
|
||||
stacktrace := []byte(utils.GetStacktrace(true))
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
return &apiadmin.EmptyReply{}, perms.WriteFile(stacktraceFile, stacktrace, perms.ReadWrite)
|
||||
}
|
||||
|
||||
func (a *Service) SetLoggerLevel(ctx context.Context, args *apiadmin.SetLoggerLevelArgs) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "setLoggerLevel"),
|
||||
log.String("loggerName", args.LoggerName),
|
||||
log.String("logLevel", args.LogLevel),
|
||||
log.String("displayLevel", args.DisplayLevel),
|
||||
)
|
||||
if args.LogLevel == "" && args.DisplayLevel == "" {
|
||||
return nil, errNoLogLevel
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
loggerNames := a.getLoggerNames(args.LoggerName)
|
||||
_ = loggerNames
|
||||
return &apiadmin.EmptyReply{}, nil
|
||||
}
|
||||
|
||||
func (a *Service) GetLoggerLevel(ctx context.Context, args *apiadmin.GetLoggerLevelArgs) (*apiadmin.LoggerLevelReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "getLoggerLevel"),
|
||||
log.String("loggerName", args.LoggerName),
|
||||
)
|
||||
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
loggerNames := a.getLoggerNames(args.LoggerName)
|
||||
loggerLevels, err := a.getLogLevels(loggerNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &apiadmin.LoggerLevelReply{LoggerLevels: loggerLevels}, nil
|
||||
}
|
||||
|
||||
func (a *Service) GetConfig(ctx context.Context) (any, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "getConfig"))
|
||||
return a.NodeConfig, nil
|
||||
}
|
||||
|
||||
func (a *Service) LoadVMs(ctx context.Context) (*apiadmin.LoadVMsReply, error) {
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "loadVMs"))
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
loadedVMs, failedVMs, err := a.VMRegistry.Reload(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
failedVMsParsed := make(map[ids.ID]string)
|
||||
for vmID, err := range failedVMs {
|
||||
failedVMsParsed[vmID] = err.Error()
|
||||
}
|
||||
|
||||
newVMs := make(map[ids.ID][]string, len(loadedVMs))
|
||||
for _, vmID := range loadedVMs {
|
||||
aliases, err := a.VMManager.Aliases(ctx, vmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newVMs[vmID] = aliases
|
||||
}
|
||||
|
||||
totalRetried := 0
|
||||
for _, vmID := range loadedVMs {
|
||||
retried := a.ChainManager.RetryPendingChains(vmID)
|
||||
totalRetried += retried
|
||||
}
|
||||
|
||||
return &apiadmin.LoadVMsReply{
|
||||
NewVMs: newVMs,
|
||||
FailedVMs: failedVMsParsed,
|
||||
ChainsRetried: totalRetried,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Service) DbGet(ctx context.Context, args *apiadmin.DBGetArgs) (*apiadmin.DBGetReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "dbGet"),
|
||||
log.String("key", args.Key),
|
||||
)
|
||||
|
||||
key, err := formatting.Decode(formatting.HexNC, args.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
value, err := a.DB.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
val, err := formatting.Encode(formatting.HexNC, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &apiadmin.DBGetReply{Value: val}, nil
|
||||
}
|
||||
|
||||
func (a *Service) ListVMs(ctx context.Context) (*apiadmin.ListVMsReply, error) {
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "listVMs"))
|
||||
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
vmIDs, err := a.VMManager.ListFactories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pluginPaths := make(map[ids.ID]string)
|
||||
if a.PluginDir != "" {
|
||||
entries, err := os.ReadDir(a.PluginDir)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
baseName := name[:len(name)-len(filepath.Ext(name))]
|
||||
if baseName == "" {
|
||||
continue
|
||||
}
|
||||
vmID, err := ids.FromString(baseName)
|
||||
if err == nil {
|
||||
pluginPaths[vmID] = filepath.Join(a.PluginDir, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reply := &apiadmin.ListVMsReply{
|
||||
VMs: make(map[string]apiadmin.VMInfo, len(vmIDs)),
|
||||
}
|
||||
for _, vmID := range vmIDs {
|
||||
aliases, err := a.VMManager.Aliases(ctx, vmID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vmIDStr := vmID.String()
|
||||
filteredAliases := make([]string, 0, len(aliases))
|
||||
for _, alias := range aliases {
|
||||
if alias != vmIDStr {
|
||||
filteredAliases = append(filteredAliases, alias)
|
||||
}
|
||||
}
|
||||
|
||||
info := apiadmin.VMInfo{
|
||||
ID: vmIDStr,
|
||||
Aliases: filteredAliases,
|
||||
}
|
||||
if path, ok := pluginPaths[vmID]; ok {
|
||||
info.Path = path
|
||||
}
|
||||
|
||||
reply.VMs[vmIDStr] = info
|
||||
}
|
||||
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (a *Service) SetTrackedChains(ctx context.Context, args *apiadmin.SetTrackedChainsArgs) (*apiadmin.SetTrackedChainsReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "setTrackedChains"))
|
||||
|
||||
chains := set.Set[ids.ID]{}
|
||||
for _, chainStr := range args.Chains {
|
||||
chainID, err := ids.FromString(chainStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chains.Add(chainID)
|
||||
}
|
||||
|
||||
for _, chainID := range chains.List() {
|
||||
if err := a.Network.TrackChain(chainID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &apiadmin.SetTrackedChainsReply{TrackedChains: args.Chains}, nil
|
||||
}
|
||||
|
||||
func (a *Service) GetTrackedChains(ctx context.Context) (*apiadmin.GetTrackedChainsReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called", log.String("service", "admin"), log.String("method", "getTrackedChains"))
|
||||
trackedIDs := a.Network.TrackedChains().List()
|
||||
trackedChains := make([]string, len(trackedIDs))
|
||||
for i, id := range trackedIDs {
|
||||
trackedChains[i] = id.String()
|
||||
}
|
||||
return &apiadmin.GetTrackedChainsReply{TrackedChains: trackedChains}, nil
|
||||
}
|
||||
|
||||
func (a *Service) getLoggerNames(loggerName string) []string {
|
||||
if len(loggerName) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
return []string{loggerName}
|
||||
}
|
||||
|
||||
func (a *Service) getLogLevels(loggerNames []string) (map[string]apiadmin.LogAndDisplayLevels, error) {
|
||||
loggerLevels := make(map[string]apiadmin.LogAndDisplayLevels)
|
||||
return loggerLevels, nil
|
||||
}
|
||||
|
||||
// Snapshot creates a backup of the database to the specified path.
|
||||
// Args:
|
||||
// - Path: file path to write the backup (supports .zst extension for compression)
|
||||
// - Since: version for incremental backup (0 for full backup)
|
||||
//
|
||||
// Returns the version number for use in future incremental backups.
|
||||
func (a *Service) Snapshot(ctx context.Context, args *apiadmin.SnapshotArgs) (*apiadmin.SnapshotReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "snapshot"),
|
||||
log.String("path", args.Path),
|
||||
log.Uint64("since", args.Since),
|
||||
)
|
||||
|
||||
if a.backupService == nil {
|
||||
return nil, errors.New("backup service not initialized")
|
||||
}
|
||||
|
||||
if args.Path == "" {
|
||||
return nil, errors.New("backup path is required")
|
||||
}
|
||||
|
||||
// Determine if compression is requested based on file extension
|
||||
compress := strings.HasSuffix(args.Path, ".zst") || strings.HasSuffix(args.Path, ".zstd")
|
||||
|
||||
version, err := a.backupService.BackupToFile(args.Path, args.Since, compress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &apiadmin.SnapshotReply{Version: version}, nil
|
||||
}
|
||||
|
||||
// Load restores the database from a backup file.
|
||||
// Args:
|
||||
// - Path: file path to read the backup from (detects .zst extension for decompression)
|
||||
//
|
||||
// Warning: This will overwrite the current database state.
|
||||
func (a *Service) Load(ctx context.Context, args *apiadmin.LoadArgs) (*apiadmin.EmptyReply, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "load"),
|
||||
log.String("path", args.Path),
|
||||
)
|
||||
|
||||
if a.backupService == nil {
|
||||
return nil, errors.New("backup service not initialized")
|
||||
}
|
||||
|
||||
if args.Path == "" {
|
||||
return nil, errors.New("backup path is required")
|
||||
}
|
||||
|
||||
// Determine if decompression is needed based on file extension
|
||||
compressed := strings.HasSuffix(args.Path, ".zst") || strings.HasSuffix(args.Path, ".zstd")
|
||||
|
||||
if err := a.backupService.RestoreFromFile(args.Path, compressed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &apiadmin.EmptyReply{}, nil
|
||||
}
|
||||
|
||||
// GetBackupMetadata returns information about the last backup.
|
||||
func (a *Service) GetBackupMetadata(ctx context.Context) (*backup.Metadata, error) {
|
||||
_ = ctx
|
||||
a.Log.Debug("API called",
|
||||
log.String("service", "admin"),
|
||||
log.String("method", "getBackupMetadata"),
|
||||
)
|
||||
|
||||
if a.backupService == nil {
|
||||
return nil, errors.New("backup service not initialized")
|
||||
}
|
||||
|
||||
return a.backupService.GetMetadata(), nil
|
||||
}
|
||||
|
||||
var _ apiadmin.Service = (*Service)(nil)
|
||||
var _ = apitypes.EmptyReply{}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gorilla/rpc/v2"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/timer/mockable"
|
||||
"github.com/luxfi/node/utils/json"
|
||||
"github.com/luxfi/node/utils/password"
|
||||
)
|
||||
|
||||
const (
|
||||
headerKey = "Authorization"
|
||||
headerValStart = "Bearer "
|
||||
|
||||
// number of bytes to use when generating a new random token ID
|
||||
tokenIDByteLen = 20
|
||||
|
||||
// defaultTokenLifespan is how long a token lives before it expires
|
||||
defaultTokenLifespan = time.Hour * 12
|
||||
|
||||
maxEndpoints = 128
|
||||
)
|
||||
|
||||
var (
|
||||
errNoToken = errors.New("auth token not provided")
|
||||
errAuthHeaderNotParsable = fmt.Errorf(
|
||||
"couldn't parse auth token. Header \"%s\" should be \"%sTOKEN.GOES.HERE\"",
|
||||
headerKey,
|
||||
headerValStart,
|
||||
)
|
||||
errInvalidSigningMethod = errors.New("auth token didn't specify the HS256 signing method correctly")
|
||||
errTokenRevoked = errors.New("the provided auth token was revoked")
|
||||
errTokenInsufficientPermission = errors.New("the provided auth token does not allow access to this endpoint")
|
||||
errWrongPassword = errors.New("incorrect password")
|
||||
errSamePassword = errors.New("new password can't be same as old password")
|
||||
errNoEndpoints = errors.New("must name at least one endpoint")
|
||||
errTooManyEndpoints = fmt.Errorf("can only name at most %d endpoints", maxEndpoints)
|
||||
|
||||
_ Auth = (*auth)(nil)
|
||||
)
|
||||
|
||||
type Auth interface {
|
||||
// Create and return a new token that allows access to each API endpoint for
|
||||
// [duration] such that the API's path ends with an element of [endpoints].
|
||||
// If one of the elements of [endpoints] is "*", all APIs are accessible.
|
||||
NewToken(pw string, duration time.Duration, endpoints []string) (string, error)
|
||||
|
||||
// Revokes [token]; it will not be accepted as authorization for future API
|
||||
// calls. If the token is invalid, this is a no-op. If a token is revoked
|
||||
// and then the password is changed, and then changed back to the current
|
||||
// password, the token will be un-revoked. Therefore, passwords shouldn't be
|
||||
// re-used before previously revoked tokens have expired.
|
||||
RevokeToken(pw, token string) error
|
||||
|
||||
// Authenticates [token] for access to [url].
|
||||
AuthenticateToken(token, url string) error
|
||||
|
||||
// Change the password required to create and revoke tokens.
|
||||
// [oldPW] is the current password.
|
||||
// [newPW] is the new password. It can't be the empty string and it can't be
|
||||
// unreasonably long.
|
||||
// Changing the password makes tokens issued under a previous password
|
||||
// invalid.
|
||||
ChangePassword(oldPW, newPW string) error
|
||||
|
||||
// Create the API endpoint for this auth handler.
|
||||
CreateHandler() (http.Handler, error)
|
||||
|
||||
// WrapHandler wraps an http.Handler. Before passing a request to the
|
||||
// provided handler, the auth token is authenticated.
|
||||
WrapHandler(h http.Handler) http.Handler
|
||||
}
|
||||
|
||||
type auth struct {
|
||||
// Used to mock time.
|
||||
clock mockable.Clock
|
||||
|
||||
log log.Logger
|
||||
endpoint string
|
||||
|
||||
lock sync.RWMutex
|
||||
// Can be changed via API call.
|
||||
password password.Hash
|
||||
// Set of token IDs that have been revoked
|
||||
revoked set.Set[string]
|
||||
}
|
||||
|
||||
func New(log log.Logger, endpoint, pw string) (Auth, error) {
|
||||
a := &auth{
|
||||
log: log,
|
||||
endpoint: endpoint,
|
||||
revoked: set.NewSet[string](),
|
||||
}
|
||||
return a, a.password.Set(pw)
|
||||
}
|
||||
|
||||
func NewFromHash(log log.Logger, endpoint string, pw password.Hash) Auth {
|
||||
return &auth{
|
||||
log: log,
|
||||
endpoint: endpoint,
|
||||
password: pw,
|
||||
revoked: set.NewSet[string](),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *auth) NewToken(pw string, duration time.Duration, endpoints []string) (string, error) {
|
||||
if pw == "" {
|
||||
return "", password.ErrEmptyPassword
|
||||
}
|
||||
if l := len(endpoints); l == 0 {
|
||||
return "", errNoEndpoints
|
||||
} else if l > maxEndpoints {
|
||||
return "", errTooManyEndpoints
|
||||
}
|
||||
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
if !a.password.Check(pw) {
|
||||
return "", errWrongPassword
|
||||
}
|
||||
|
||||
canAccessAll := false
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint == "*" {
|
||||
canAccessAll = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
idBytes := [tokenIDByteLen]byte{}
|
||||
if _, err := rand.Read(idBytes[:]); err != nil {
|
||||
return "", fmt.Errorf("failed to generate the unique token ID due to %w", err)
|
||||
}
|
||||
id := base64.RawURLEncoding.EncodeToString(idBytes[:])
|
||||
|
||||
claims := endpointClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(a.clock.Time().Add(duration)),
|
||||
ID: id,
|
||||
},
|
||||
}
|
||||
if canAccessAll {
|
||||
claims.Endpoints = []string{"*"}
|
||||
} else {
|
||||
claims.Endpoints = endpoints
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims)
|
||||
return token.SignedString(a.password.Password[:]) // Sign the token and return its string repr.
|
||||
}
|
||||
|
||||
func (a *auth) RevokeToken(tokenStr, pw string) error {
|
||||
if tokenStr == "" {
|
||||
return errNoToken
|
||||
}
|
||||
if pw == "" {
|
||||
return password.ErrEmptyPassword
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
if !a.password.Check(pw) {
|
||||
return errWrongPassword
|
||||
}
|
||||
|
||||
// See if token is well-formed and signature is right
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &endpointClaims{}, a.getTokenKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the token isn't valid, it has essentially already been revoked.
|
||||
if !token.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*endpointClaims)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected auth token's claims to be type endpointClaims but is %T", token.Claims)
|
||||
}
|
||||
a.revoked.Add(claims.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *auth) AuthenticateToken(tokenStr, url string) error {
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &endpointClaims{}, a.getTokenKey)
|
||||
if err != nil { // Probably because signature wrong
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure this token gives access to the requested endpoint
|
||||
claims, ok := token.Claims.(*endpointClaims)
|
||||
if !ok {
|
||||
// Error is intentionally dropped here as there is nothing left to do
|
||||
// with it.
|
||||
return fmt.Errorf("expected auth token's claims to be type endpointClaims but is %T", token.Claims)
|
||||
}
|
||||
|
||||
_, revoked := a.revoked[claims.ID]
|
||||
if revoked {
|
||||
return errTokenRevoked
|
||||
}
|
||||
|
||||
for _, endpoint := range claims.Endpoints {
|
||||
if endpoint == "*" || strings.HasSuffix(url, endpoint) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errTokenInsufficientPermission
|
||||
}
|
||||
|
||||
func (a *auth) ChangePassword(oldPW, newPW string) error {
|
||||
if oldPW == newPW {
|
||||
return errSamePassword
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
if !a.password.Check(oldPW) {
|
||||
return errWrongPassword
|
||||
}
|
||||
if err := password.IsValid(newPW, password.OK); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.password.Set(newPW); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// All the revoked tokens are now invalid; no need to mark specifically as
|
||||
// revoked.
|
||||
a.revoked.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *auth) CreateHandler() (http.Handler, error) {
|
||||
server := rpc.NewServer()
|
||||
codec := json.NewCodec()
|
||||
server.RegisterCodec(codec, "application/json")
|
||||
server.RegisterCodec(codec, "application/json;charset=UTF-8")
|
||||
return server, server.RegisterService(
|
||||
&Service{auth: a},
|
||||
"auth",
|
||||
)
|
||||
}
|
||||
|
||||
func (a *auth) WrapHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Don't require auth token to hit auth endpoint
|
||||
if path.Base(r.URL.Path) == a.endpoint {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Should be "Bearer AUTH.TOKEN.HERE"
|
||||
rawHeader := r.Header.Get(headerKey)
|
||||
if rawHeader == "" {
|
||||
writeUnauthorizedResponse(w, errNoToken)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(rawHeader, headerValStart) {
|
||||
// Error is intentionally dropped here as there is nothing left to
|
||||
// do with it.
|
||||
writeUnauthorizedResponse(w, errAuthHeaderNotParsable)
|
||||
return
|
||||
}
|
||||
// Returns actual auth token. Slice guaranteed to not go OOB
|
||||
tokenStr := rawHeader[len(headerValStart):]
|
||||
|
||||
if err := a.AuthenticateToken(tokenStr, r.URL.Path); err != nil {
|
||||
writeUnauthorizedResponse(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// getTokenKey returns the key to use when making and parsing tokens
|
||||
func (a *auth) getTokenKey(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errInvalidSigningMethod
|
||||
}
|
||||
return a.password.Password[:], nil
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/utils/password"
|
||||
)
|
||||
|
||||
var (
|
||||
testPassword = "password!@#$%$#@!"
|
||||
hashedPassword = password.Hash{}
|
||||
unAuthorizedResponseRegex = `^{"jsonrpc":"2.0","error":{"code":-32600,"message":"(.*)"},"id":1}`
|
||||
errTest = errors.New("non-nil error")
|
||||
hostName = "http://127.0.0.1:9630"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := hashedPassword.Set(testPassword); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Always returns 200 (http.StatusOK)
|
||||
var dummyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||
|
||||
func TestNewTokenWrongPassword(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
_, err := auth.NewToken("", defaultTokenLifespan, []string{"endpoint1, endpoint2"})
|
||||
require.ErrorIs(err, password.ErrEmptyPassword)
|
||||
|
||||
_, err = auth.NewToken("notThePassword", defaultTokenLifespan, []string{"endpoint1, endpoint2"})
|
||||
require.ErrorIs(err, errWrongPassword)
|
||||
}
|
||||
|
||||
func TestNewTokenHappyPath(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword).(*auth)
|
||||
|
||||
now := time.Now()
|
||||
auth.clock.Set(now)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"endpoint1", "endpoint2", "endpoint3"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
// Parse the token
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &endpointClaims{}, func(*jwt.Token) (interface{}, error) {
|
||||
auth.lock.RLock()
|
||||
defer auth.lock.RUnlock()
|
||||
return auth.password.Password[:], nil
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
require.IsType(&endpointClaims{}, token.Claims)
|
||||
claims := token.Claims.(*endpointClaims)
|
||||
require.Equal(endpoints, claims.Endpoints)
|
||||
|
||||
shouldExpireAt := jwt.NewNumericDate(now.Add(defaultTokenLifespan))
|
||||
require.Equal(shouldExpireAt, claims.ExpiresAt)
|
||||
}
|
||||
|
||||
func TestTokenHasWrongSig(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword).(*auth)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"endpoint1", "endpoint2", "endpoint3"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
// Try to parse the token using the wrong password
|
||||
_, err = jwt.ParseWithClaims(tokenStr, &endpointClaims{}, func(*jwt.Token) (interface{}, error) {
|
||||
auth.lock.RLock()
|
||||
defer auth.lock.RUnlock()
|
||||
return []byte(""), nil
|
||||
})
|
||||
require.ErrorIs(err, jwt.ErrSignatureInvalid)
|
||||
|
||||
// Try to parse the token using the wrong password
|
||||
_, err = jwt.ParseWithClaims(tokenStr, &endpointClaims{}, func(*jwt.Token) (interface{}, error) {
|
||||
auth.lock.RLock()
|
||||
defer auth.lock.RUnlock()
|
||||
return []byte("notThePassword"), nil
|
||||
})
|
||||
require.ErrorIs(err, jwt.ErrSignatureInvalid)
|
||||
}
|
||||
|
||||
func TestChangePassword(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword).(*auth)
|
||||
|
||||
// Password must be at least 20 chars for Fair strength
|
||||
password2 := "fejhkefjhefjhefhjeXX" // #nosec G101
|
||||
var err error
|
||||
|
||||
err = auth.ChangePassword("", password2)
|
||||
require.ErrorIs(err, errWrongPassword)
|
||||
|
||||
err = auth.ChangePassword("notThePassword", password2)
|
||||
require.ErrorIs(err, errWrongPassword)
|
||||
|
||||
err = auth.ChangePassword(testPassword, "")
|
||||
require.ErrorIs(err, password.ErrEmptyPassword)
|
||||
|
||||
require.NoError(auth.ChangePassword(testPassword, password2))
|
||||
require.True(auth.password.Check(password2))
|
||||
|
||||
// Password must be at least 20 chars for Fair strength
|
||||
password3 := "ufwhwohwfohawfhwdwdX" // #nosec G101
|
||||
|
||||
err = auth.ChangePassword(testPassword, password3)
|
||||
require.ErrorIs(err, errWrongPassword)
|
||||
|
||||
require.NoError(auth.ChangePassword(password2, password3))
|
||||
}
|
||||
|
||||
func TestRevokeToken(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword).(*auth)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
require.NoError(auth.RevokeToken(tokenStr, testPassword))
|
||||
require.Len(auth.revoked, 1)
|
||||
}
|
||||
|
||||
func TestWrapHandlerHappyPath(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusOK, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapHandlerRevokedToken(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
require.NoError(auth.RevokeToken(tokenStr, testPassword))
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
require.Contains(rr.Body.String(), errTokenRevoked.Error())
|
||||
require.Regexp(unAuthorizedResponseRegex, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapHandlerExpiredToken(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword).(*auth)
|
||||
|
||||
auth.clock.Set(time.Now().Add(-2 * defaultTokenLifespan))
|
||||
|
||||
// Make a token that expired well in the past
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
require.Contains(rr.Body.String(), "expired")
|
||||
require.Regexp(unAuthorizedResponseRegex, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapHandlerNoAuthToken(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:9630%s", endpoint), strings.NewReader(""))
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
require.Contains(rr.Body.String(), errNoToken.Error())
|
||||
require.Regexp(unAuthorizedResponseRegex, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapHandlerUnauthorizedEndpoint(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
unauthorizedEndpoints := []string{"/ext/bc/X", "/ext/metrics", "", "/foo", "/ext/info/foo"}
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
for _, endpoint := range unauthorizedEndpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
require.Contains(rr.Body.String(), errTokenInsufficientPermission.Error())
|
||||
require.Regexp(unAuthorizedResponseRegex, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapHandlerAuthEndpoint(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics", "", "/foo", "/ext/info/foo"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:9630/ext/auth", strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestWrapHandlerAccessAll(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
// Make a token that allows access to all endpoints
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics", "", "/foo", "/ext/foo/info"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, []string{"*"})
|
||||
require.NoError(err)
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusOK, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteUnauthorizedResponse(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
writeUnauthorizedResponse(rr, errTest)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
require.Equal(`{"jsonrpc":"2.0","error":{"code":-32600,"message":"non-nil error"},"id":1}`+"\n", rr.Body.String())
|
||||
}
|
||||
|
||||
func TestWrapHandlerMutatedRevokedToken(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
tokenStr, err := auth.NewToken(testPassword, defaultTokenLifespan, endpoints)
|
||||
require.NoError(err)
|
||||
|
||||
require.NoError(auth.RevokeToken(tokenStr, testPassword))
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s=", tokenStr)) // The appended = at the end looks like padding
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapHandlerInvalidSigningMethod(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
auth := NewFromHash(log.NewNoOpLogger(), "auth", hashedPassword).(*auth)
|
||||
|
||||
// Make a token
|
||||
endpoints := []string{"/ext/info", "/ext/bc/X", "/ext/metrics"}
|
||||
idBytes := [tokenIDByteLen]byte{}
|
||||
_, err := rand.Read(idBytes[:])
|
||||
require.NoError(err)
|
||||
id := base64.RawURLEncoding.EncodeToString(idBytes[:])
|
||||
|
||||
claims := endpointClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(auth.clock.Time().Add(defaultTokenLifespan)),
|
||||
ID: id,
|
||||
},
|
||||
Endpoints: endpoints,
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS512, &claims)
|
||||
tokenStr, err := token.SignedString(auth.password.Password[:])
|
||||
require.NoError(err)
|
||||
|
||||
wrappedHandler := auth.WrapHandler(dummyHandler)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
req := httptest.NewRequest(http.MethodPost, hostName+endpoint, strings.NewReader(""))
|
||||
req.Header.Add("Authorization", headerValStart+tokenStr)
|
||||
rr := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(rr, req)
|
||||
require.Equal(http.StatusUnauthorized, rr.Code)
|
||||
require.Contains(rr.Body.String(), errInvalidSigningMethod.Error())
|
||||
require.Regexp(unAuthorizedResponseRegex, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// Custom claim type used for API access token
|
||||
type endpointClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
|
||||
// Each element is an endpoint that the token allows access to
|
||||
// If endpoints has an element "*", allows access to all API endpoints
|
||||
// In this case, "*" should be the only element of [endpoints]
|
||||
Endpoints []string `json:"endpoints,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
rpc "github.com/gorilla/rpc/v2/json2"
|
||||
)
|
||||
|
||||
type responseErr struct {
|
||||
Code rpc.ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type responseBody struct {
|
||||
Version string `json:"jsonrpc"`
|
||||
Err responseErr `json:"error"`
|
||||
ID uint8 `json:"id"`
|
||||
}
|
||||
|
||||
// Write a JSON-RPC formatted response saying that the API call is unauthorized.
|
||||
// The response has header http.StatusUnauthorized.
|
||||
// Errors while writing are ignored.
|
||||
func writeUnauthorizedResponse(w http.ResponseWriter, err error) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
|
||||
// There isn't anything to do with the returned error, so it is dropped.
|
||||
_ = json.NewEncoder(w).Encode(responseBody{
|
||||
Version: rpc.Version,
|
||||
Err: responseErr{
|
||||
Code: rpc.E_INVALID_REQ,
|
||||
Message: err.Error(),
|
||||
},
|
||||
ID: 1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/luxfi/log"
|
||||
apitypes "github.com/luxfi/api/types"
|
||||
)
|
||||
|
||||
// Service that serves the Auth API functionality.
|
||||
type Service struct {
|
||||
auth *auth
|
||||
}
|
||||
|
||||
type Password struct {
|
||||
Password string `json:"password"` // The authorization password
|
||||
}
|
||||
|
||||
type NewTokenArgs struct {
|
||||
Password
|
||||
// Endpoints that may be accessed with this token e.g. if endpoints is
|
||||
// ["/ext/bc/X", "/ext/admin"] then the token holder can hit the X-Chain API
|
||||
// and the admin API. If [Endpoints] contains an element "*" then the token
|
||||
// allows access to all API endpoints. [Endpoints] must have between 1 and
|
||||
// [maxEndpoints] elements
|
||||
Endpoints []string `json:"endpoints"`
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
Token string `json:"token"` // The new token. Expires in [TokenLifespan].
|
||||
}
|
||||
|
||||
func (s *Service) NewToken(_ *http.Request, args *NewTokenArgs, reply *Token) error {
|
||||
s.auth.log.Debug("API called",
|
||||
log.String("service", "auth"),
|
||||
log.String("method", "newToken"),
|
||||
)
|
||||
|
||||
var err error
|
||||
reply.Token, err = s.auth.NewToken(args.Password.Password, defaultTokenLifespan, args.Endpoints)
|
||||
return err
|
||||
}
|
||||
|
||||
type RevokeTokenArgs struct {
|
||||
Password
|
||||
Token
|
||||
}
|
||||
|
||||
func (s *Service) RevokeToken(_ *http.Request, args *RevokeTokenArgs, _ *apitypes.EmptyReply) error {
|
||||
s.auth.log.Debug("API called",
|
||||
log.String("service", "auth"),
|
||||
log.String("method", "revokeToken"),
|
||||
)
|
||||
|
||||
return s.auth.RevokeToken(args.Token.Token, args.Password.Password)
|
||||
}
|
||||
|
||||
type ChangePasswordArgs struct {
|
||||
OldPassword string `json:"oldPassword"` // Current authorization password
|
||||
NewPassword string `json:"newPassword"` // New authorization password
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(_ *http.Request, args *ChangePasswordArgs, _ *apitypes.EmptyReply) error {
|
||||
s.auth.log.Debug("API called",
|
||||
log.String("service", "auth"),
|
||||
log.String("method", "changePassword"),
|
||||
)
|
||||
|
||||
return s.auth.ChangePassword(args.OldPassword, args.NewPassword)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// Package backup provides database backup and restore functionality.
|
||||
// It supports incremental backups using ZapDB's native backup mechanism
|
||||
// with optional zstd compression.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultChunkSize is the default size of backup chunks (1MB).
|
||||
DefaultChunkSize = 1 << 20
|
||||
|
||||
// MetadataFileName is the name of the backup metadata file.
|
||||
MetadataFileName = "backup_metadata.json"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDatabaseClosed is returned when the database is closed.
|
||||
ErrDatabaseClosed = errors.New("database is closed")
|
||||
|
||||
// ErrBackupInProgress is returned when a backup is already in progress.
|
||||
ErrBackupInProgress = errors.New("backup already in progress")
|
||||
|
||||
// ErrRestoreInProgress is returned when a restore is already in progress.
|
||||
ErrRestoreInProgress = errors.New("restore already in progress")
|
||||
)
|
||||
|
||||
// Metadata stores information about the last backup.
|
||||
type Metadata struct {
|
||||
LastVersion uint64 `json:"lastVersion"`
|
||||
LastBackupTime time.Time `json:"lastBackupTime"`
|
||||
LastBackupSize uint64 `json:"lastBackupSize"`
|
||||
Incremental bool `json:"incremental"`
|
||||
}
|
||||
|
||||
// Config configures the backup service.
|
||||
type Config struct {
|
||||
// DB is the database to backup/restore.
|
||||
DB database.Database
|
||||
// MetadataDir is the directory to store backup metadata.
|
||||
MetadataDir string
|
||||
// ChunkSize is the size of backup chunks in bytes.
|
||||
ChunkSize int
|
||||
// Log is the logger.
|
||||
Log log.Logger
|
||||
}
|
||||
|
||||
// Service provides backup and restore operations.
|
||||
type Service struct {
|
||||
db database.Database
|
||||
metadataDir string
|
||||
chunkSize int
|
||||
log log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
backupActive bool
|
||||
restoreActive bool
|
||||
currentMetadata *Metadata
|
||||
}
|
||||
|
||||
// New creates a new backup service.
|
||||
func New(cfg Config) (*Service, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("database is required")
|
||||
}
|
||||
if cfg.MetadataDir == "" {
|
||||
return nil, errors.New("metadata directory is required")
|
||||
}
|
||||
if cfg.ChunkSize <= 0 {
|
||||
cfg.ChunkSize = DefaultChunkSize
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
db: cfg.DB,
|
||||
metadataDir: cfg.MetadataDir,
|
||||
chunkSize: cfg.ChunkSize,
|
||||
log: cfg.Log,
|
||||
}
|
||||
|
||||
// Load existing metadata
|
||||
if err := s.loadMetadata(); err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load backup metadata: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Backup performs a database backup, writing to the provided writer.
|
||||
// since: 0 for full backup, or the version from the last backup for incremental.
|
||||
// compress: whether to compress the output with zstd.
|
||||
// Returns the new version number for future incremental backups.
|
||||
func (s *Service) Backup(w io.Writer, since uint64, compress bool) (uint64, error) {
|
||||
s.mu.Lock()
|
||||
if s.backupActive {
|
||||
s.mu.Unlock()
|
||||
return 0, ErrBackupInProgress
|
||||
}
|
||||
s.backupActive = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.backupActive = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
var writer io.Writer = w
|
||||
var encoder *zstd.Encoder
|
||||
var err error
|
||||
|
||||
if compress {
|
||||
encoder, err = zstd.NewWriter(w, zstd.WithEncoderLevel(zstd.SpeedDefault))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create zstd encoder: %w", err)
|
||||
}
|
||||
defer encoder.Close()
|
||||
writer = encoder
|
||||
}
|
||||
|
||||
// Perform the backup
|
||||
version, err := s.db.Backup(writer, since)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
|
||||
// Ensure compressed data is flushed
|
||||
if encoder != nil {
|
||||
if err := encoder.Close(); err != nil {
|
||||
return 0, fmt.Errorf("failed to close zstd encoder: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
s.mu.Lock()
|
||||
s.currentMetadata = &Metadata{
|
||||
LastVersion: version,
|
||||
LastBackupTime: time.Now(),
|
||||
Incremental: since > 0,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.saveMetadata(); err != nil {
|
||||
s.log.Warn("failed to save backup metadata", "error", err)
|
||||
}
|
||||
|
||||
s.log.Info("backup completed",
|
||||
"version", version,
|
||||
"since", since,
|
||||
"incremental", since > 0,
|
||||
)
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// BackupToFile performs a backup to a file path.
|
||||
func (s *Service) BackupToFile(path string, since uint64, compress bool) (uint64, error) {
|
||||
// Ensure parent directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return 0, fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create backup file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
version, err := s.Backup(f, since, compress)
|
||||
if err != nil {
|
||||
os.Remove(path) // Clean up on failure
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Get file size for metadata
|
||||
info, err := f.Stat()
|
||||
if err == nil {
|
||||
s.mu.Lock()
|
||||
if s.currentMetadata != nil {
|
||||
s.currentMetadata.LastBackupSize = uint64(info.Size())
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.saveMetadata()
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// Restore restores the database from the provided reader.
|
||||
// The reader should contain backup data (optionally zstd compressed).
|
||||
func (s *Service) Restore(r io.Reader, compressed bool) error {
|
||||
s.mu.Lock()
|
||||
if s.restoreActive {
|
||||
s.mu.Unlock()
|
||||
return ErrRestoreInProgress
|
||||
}
|
||||
s.restoreActive = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.restoreActive = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
var reader io.Reader = r
|
||||
|
||||
if compressed {
|
||||
decoder, err := zstd.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zstd decoder: %w", err)
|
||||
}
|
||||
defer decoder.Close()
|
||||
reader = decoder
|
||||
}
|
||||
|
||||
if err := s.db.Load(reader); err != nil {
|
||||
return fmt.Errorf("restore failed: %w", err)
|
||||
}
|
||||
|
||||
s.log.Info("restore completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreFromFile restores the database from a file path.
|
||||
func (s *Service) RestoreFromFile(path string, compressed bool) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open backup file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return s.Restore(f, compressed)
|
||||
}
|
||||
|
||||
// GetMetadata returns the current backup metadata.
|
||||
func (s *Service) GetMetadata() *Metadata {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.currentMetadata == nil {
|
||||
return &Metadata{}
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
return &Metadata{
|
||||
LastVersion: s.currentMetadata.LastVersion,
|
||||
LastBackupTime: s.currentMetadata.LastBackupTime,
|
||||
LastBackupSize: s.currentMetadata.LastBackupSize,
|
||||
Incremental: s.currentMetadata.Incremental,
|
||||
}
|
||||
}
|
||||
|
||||
// GetLastVersion returns the version of the last backup.
|
||||
// Returns 0 if no backup has been performed.
|
||||
func (s *Service) GetLastVersion() uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.currentMetadata == nil {
|
||||
return 0
|
||||
}
|
||||
return s.currentMetadata.LastVersion
|
||||
}
|
||||
|
||||
func (s *Service) metadataPath() string {
|
||||
return filepath.Join(s.metadataDir, MetadataFileName)
|
||||
}
|
||||
|
||||
func (s *Service) loadMetadata() error {
|
||||
data, err := os.ReadFile(s.metadataPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var meta Metadata
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.currentMetadata = &meta
|
||||
s.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) saveMetadata() error {
|
||||
s.mu.Lock()
|
||||
meta := s.currentMetadata
|
||||
s.mu.Unlock()
|
||||
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(s.metadataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create metadata directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.metadataPath(), data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupChunked performs a backup and returns data in chunks.
|
||||
// This is useful for streaming backups over gRPC.
|
||||
func (s *Service) BackupChunked(since uint64, compress bool) (<-chan []byte, <-chan error, <-chan uint64) {
|
||||
dataCh := make(chan []byte, 10)
|
||||
errCh := make(chan error, 1)
|
||||
versionCh := make(chan uint64, 1)
|
||||
|
||||
go func() {
|
||||
defer close(dataCh)
|
||||
defer close(errCh)
|
||||
defer close(versionCh)
|
||||
|
||||
var buf bytes.Buffer
|
||||
version, err := s.Backup(&buf, since, compress)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
// Split into chunks
|
||||
data := buf.Bytes()
|
||||
for i := 0; i < len(data); i += s.chunkSize {
|
||||
end := i + s.chunkSize
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
dataCh <- data[i:end]
|
||||
}
|
||||
|
||||
versionCh <- version
|
||||
}()
|
||||
|
||||
return dataCh, errCh, versionCh
|
||||
}
|
||||
|
||||
// RestoreChunked restores from chunks.
|
||||
// Send chunks to the returned channel, close when done.
|
||||
func (s *Service) RestoreChunked(compressed bool) (chan<- []byte, <-chan error) {
|
||||
dataCh := make(chan []byte, 10)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
|
||||
var buf bytes.Buffer
|
||||
for chunk := range dataCh {
|
||||
buf.Write(chunk)
|
||||
}
|
||||
|
||||
if err := s.Restore(&buf, compressed); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
return dataCh, errCh
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/zapdb"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestDB creates a zapdb for testing (supports backup/restore).
|
||||
func newTestDB(t *testing.T) database.Database {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
db, err := zapdb.New(tmpDir, nil, "", metric.NewRegistry())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestBackupService_New(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Test missing database
|
||||
_, err := New(Config{
|
||||
MetadataDir: t.TempDir(),
|
||||
})
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), "database is required")
|
||||
|
||||
// Test missing metadata directory
|
||||
db := newTestDB(t)
|
||||
|
||||
_, err = New(Config{
|
||||
DB: db,
|
||||
})
|
||||
require.Error(err)
|
||||
require.Contains(err.Error(), "metadata directory is required")
|
||||
|
||||
// Test successful creation
|
||||
svc, err := New(Config{
|
||||
DB: db,
|
||||
MetadataDir: t.TempDir(),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
require.NotNil(svc)
|
||||
}
|
||||
|
||||
func TestBackupService_BackupRestore(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Create source database with test data
|
||||
srcDB := newTestDB(t)
|
||||
|
||||
testData := map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
}
|
||||
for k, v := range testData {
|
||||
require.NoError(srcDB.Put([]byte(k), []byte(v)))
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create backup service
|
||||
svc, err := New(Config{
|
||||
DB: srcDB,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Perform backup (uncompressed)
|
||||
var buf bytes.Buffer
|
||||
version, err := svc.Backup(&buf, 0, false)
|
||||
require.NoError(err)
|
||||
require.Greater(version, uint64(0))
|
||||
|
||||
backupData := buf.Bytes()
|
||||
require.NotEmpty(backupData)
|
||||
|
||||
// Create destination database
|
||||
dstDB := newTestDB(t)
|
||||
|
||||
dstSvc, err := New(Config{
|
||||
DB: dstDB,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta2"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Restore
|
||||
err = dstSvc.Restore(bytes.NewReader(backupData), false)
|
||||
require.NoError(err)
|
||||
|
||||
// Verify data
|
||||
for k, expected := range testData {
|
||||
actual, err := dstDB.Get([]byte(k))
|
||||
require.NoError(err)
|
||||
require.Equal(expected, string(actual))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupService_BackupToFile(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Create database with test data
|
||||
db := newTestDB(t)
|
||||
|
||||
require.NoError(db.Put([]byte("test"), []byte("data")))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
svc, err := New(Config{
|
||||
DB: db,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Test uncompressed backup
|
||||
uncompressedPath := filepath.Join(tmpDir, "backup.db")
|
||||
version, err := svc.BackupToFile(uncompressedPath, 0, false)
|
||||
require.NoError(err)
|
||||
require.Greater(version, uint64(0))
|
||||
|
||||
info, err := os.Stat(uncompressedPath)
|
||||
require.NoError(err)
|
||||
require.Greater(info.Size(), int64(0))
|
||||
|
||||
// Test compressed backup
|
||||
compressedPath := filepath.Join(tmpDir, "backup.zst")
|
||||
version2, err := svc.BackupToFile(compressedPath, 0, true)
|
||||
require.NoError(err)
|
||||
require.Greater(version2, uint64(0))
|
||||
|
||||
info2, err := os.Stat(compressedPath)
|
||||
require.NoError(err)
|
||||
require.Greater(info2.Size(), int64(0))
|
||||
}
|
||||
|
||||
func TestBackupService_RestoreFromFile(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
// Create source database with test data
|
||||
srcDB := newTestDB(t)
|
||||
|
||||
require.NoError(srcDB.Put([]byte("restore-test"), []byte("restore-value")))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
srcSvc, err := New(Config{
|
||||
DB: srcDB,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta-src"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Create compressed backup
|
||||
backupPath := filepath.Join(tmpDir, "backup.zst")
|
||||
_, err = srcSvc.BackupToFile(backupPath, 0, true)
|
||||
require.NoError(err)
|
||||
|
||||
// Create destination database
|
||||
dstDB := newTestDB(t)
|
||||
|
||||
dstSvc, err := New(Config{
|
||||
DB: dstDB,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta-dst"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Restore from file
|
||||
err = dstSvc.RestoreFromFile(backupPath, true)
|
||||
require.NoError(err)
|
||||
|
||||
// Verify data
|
||||
val, err := dstDB.Get([]byte("restore-test"))
|
||||
require.NoError(err)
|
||||
require.Equal("restore-value", string(val))
|
||||
}
|
||||
|
||||
func TestBackupService_Metadata(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
db := newTestDB(t)
|
||||
|
||||
require.NoError(db.Put([]byte("meta-test"), []byte("meta-value")))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
metaDir := filepath.Join(tmpDir, "meta")
|
||||
|
||||
svc, err := New(Config{
|
||||
DB: db,
|
||||
MetadataDir: metaDir,
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Initially no metadata
|
||||
meta := svc.GetMetadata()
|
||||
require.Equal(uint64(0), meta.LastVersion)
|
||||
|
||||
// Perform backup
|
||||
var buf bytes.Buffer
|
||||
version, err := svc.Backup(&buf, 0, false)
|
||||
require.NoError(err)
|
||||
|
||||
// Check metadata updated
|
||||
meta = svc.GetMetadata()
|
||||
require.Equal(version, meta.LastVersion)
|
||||
require.False(meta.Incremental)
|
||||
|
||||
// Verify metadata file exists
|
||||
metaPath := filepath.Join(metaDir, MetadataFileName)
|
||||
_, err = os.Stat(metaPath)
|
||||
require.NoError(err)
|
||||
|
||||
// Create new service - should load metadata
|
||||
svc2, err := New(Config{
|
||||
DB: db,
|
||||
MetadataDir: metaDir,
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
meta2 := svc2.GetMetadata()
|
||||
require.Equal(version, meta2.LastVersion)
|
||||
}
|
||||
|
||||
func TestBackupService_IncrementalBackup(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
db := newTestDB(t)
|
||||
|
||||
// Initial data
|
||||
require.NoError(db.Put([]byte("key1"), []byte("value1")))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
svc, err := New(Config{
|
||||
DB: db,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Full backup
|
||||
var fullBuf bytes.Buffer
|
||||
fullVersion, err := svc.Backup(&fullBuf, 0, false)
|
||||
require.NoError(err)
|
||||
|
||||
meta := svc.GetMetadata()
|
||||
require.False(meta.Incremental)
|
||||
|
||||
// Add more data
|
||||
require.NoError(db.Put([]byte("key2"), []byte("value2")))
|
||||
|
||||
// Incremental backup
|
||||
var incrBuf bytes.Buffer
|
||||
incrVersion, err := svc.Backup(&incrBuf, fullVersion, false)
|
||||
require.NoError(err)
|
||||
require.GreaterOrEqual(incrVersion, fullVersion)
|
||||
|
||||
meta = svc.GetMetadata()
|
||||
require.True(meta.Incremental)
|
||||
}
|
||||
|
||||
func TestBackupService_GetLastVersion(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
db := newTestDB(t)
|
||||
|
||||
require.NoError(db.Put([]byte("version-test"), []byte("data")))
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
svc, err := New(Config{
|
||||
DB: db,
|
||||
MetadataDir: filepath.Join(tmpDir, "meta"),
|
||||
Log: log.New("test", "backup"),
|
||||
})
|
||||
require.NoError(err)
|
||||
|
||||
// Initially 0
|
||||
require.Equal(uint64(0), svc.GetLastVersion())
|
||||
|
||||
// After backup, should have version
|
||||
var buf bytes.Buffer
|
||||
version, err := svc.Backup(&buf, 0, false)
|
||||
require.NoError(err)
|
||||
require.Equal(version, svc.GetLastVersion())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Health Checking
|
||||
|
||||
## Health Check Types
|
||||
|
||||
### Readiness
|
||||
|
||||
Readiness is a special type of health check. Readiness checks will only run until they pass for the first time. After a readiness check passes, it will never be run again. These checks are typically used to indicate that the startup of a component has finished.
|
||||
|
||||
### Health
|
||||
|
||||
Health checks typically indicate that a component is operating as expected. The health of a component may flip due to any arbitrary heuristic the component exposes.
|
||||
|
||||
### Liveness
|
||||
|
||||
Liveness checks are intended to indicate that a component has become unhealthy and has no way to recover.
|
||||
|
||||
## Naming and Tags
|
||||
|
||||
All registered checks must have a unique name which will be included in the health check results.
|
||||
|
||||
Additionally, checks can optionally specify an arbitrary number of tags which can be used to group health checks together.
|
||||
|
||||
### Special Tags
|
||||
|
||||
- "All" is a tag that is automatically added for every check that is registered.
|
||||
- "Application" checks are checks that are globally applicable. This means that it is not possible to filter application-wide health checks from a response.
|
||||
|
||||
## Health Check Worker
|
||||
|
||||
Readiness, Health, and Liveness checks are all implemented by using their own health check worker.
|
||||
|
||||
A health check worker starts a goroutine that updates the health of all registered checks every `freq`. By default `freq` is set to `30s`.
|
||||
|
||||
When a health check is added it will always initially report as unhealthy.
|
||||
|
||||
Every health check runs in its own goroutine to maximize concurrency. It is guaranteed that no locks from the health checker are held during the execution of the health check.
|
||||
|
||||
When the health check worker is stopped, it will finish executing any currently running health checks and then terminate its primary goroutine. After the health check worker is stopped, the health checks will never run again.
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import "context"
|
||||
|
||||
var _ Checker = CheckerFunc(nil)
|
||||
|
||||
// Checker can have its health checked
|
||||
type Checker interface {
|
||||
// HealthCheck returns health check results and, if not healthy, a non-nil
|
||||
// error
|
||||
//
|
||||
// It is expected that the results are json marshallable.
|
||||
HealthCheck(context.Context) (interface{}, error)
|
||||
}
|
||||
|
||||
type CheckerFunc func(context.Context) (interface{}, error)
|
||||
|
||||
func (f CheckerFunc) HealthCheck(ctx context.Context) (interface{}, error) {
|
||||
return f(ctx)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/rpc/v2"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
"github.com/luxfi/log"
|
||||
|
||||
avajson "github.com/luxfi/node/utils/json"
|
||||
)
|
||||
|
||||
// NewGetAndPostHandler returns a health handler that supports GET and jsonrpc
|
||||
// POST requests.
|
||||
func NewGetAndPostHandler(log log.Logger, reporter Reporter) (http.Handler, error) {
|
||||
newServer := rpc.NewServer()
|
||||
codec := avajson.NewCodec()
|
||||
newServer.RegisterCodec(codec, "application/json")
|
||||
newServer.RegisterCodec(codec, "application/json;charset=UTF-8")
|
||||
|
||||
getHandler := NewGetHandler(reporter.Health)
|
||||
|
||||
// If a GET request is sent, we respond with a 200 if the node is healthy or
|
||||
// a 503 if the node isn't healthy.
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
newServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
getHandler.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
err := newServer.RegisterService(NewService(log, reporter), "health")
|
||||
return handler, err
|
||||
}
|
||||
|
||||
// NewGetHandler return a health handler that supports GET requests reporting
|
||||
// the result of the provided [reporter].
|
||||
func NewGetHandler(reporter func(tags ...string) (map[string]apihealth.Result, bool)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Make sure the content type is set before writing the header.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
tags := r.URL.Query()["tag"]
|
||||
checks, healthy := reporter(tags...)
|
||||
if !healthy {
|
||||
// If a health check has failed, we should return a 503.
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
// The encoder will call write on the writer, which will write the
|
||||
// header with a 200.
|
||||
_ = json.NewEncoder(w).Encode(apihealth.APIReply{
|
||||
Checks: checks,
|
||||
Healthy: healthy,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
const (
|
||||
// CheckLabel is the label used to differentiate between health checks.
|
||||
CheckLabel = "check"
|
||||
// TagLabel is the label used to differentiate between health check tags.
|
||||
TagLabel = "tag"
|
||||
// AllTag is automatically added to every registered check.
|
||||
AllTag = "all"
|
||||
// ApplicationTag checks will act as if they specified every tag that has
|
||||
// been registered.
|
||||
// Registering a health check with this tag will ensure that it is always
|
||||
// included in all health query results.
|
||||
ApplicationTag = "application"
|
||||
)
|
||||
|
||||
var _ Health = (*health)(nil)
|
||||
|
||||
// Health defines the full health service interface for registering, reporting
|
||||
// and refreshing health checks.
|
||||
type Health interface {
|
||||
Registerer
|
||||
Reporter
|
||||
|
||||
// Start running periodic health checks at the specified frequency.
|
||||
// Repeated calls to Start will be no-ops.
|
||||
Start(ctx context.Context, freq time.Duration)
|
||||
|
||||
// Stop running periodic health checks. Stop should only be called after
|
||||
// Start. Once Stop returns, no more health checks will be executed.
|
||||
Stop()
|
||||
}
|
||||
|
||||
// Registerer defines how to register new components to check the health of.
|
||||
type Registerer interface {
|
||||
RegisterReadinessCheck(name string, checker Checker, tags ...string) error
|
||||
RegisterHealthCheck(name string, checker Checker, tags ...string) error
|
||||
RegisterLivenessCheck(name string, checker Checker, tags ...string) error
|
||||
}
|
||||
|
||||
// Reporter returns the current health status.
|
||||
type Reporter interface {
|
||||
Readiness(tags ...string) (map[string]apihealth.Result, bool)
|
||||
Health(tags ...string) (map[string]apihealth.Result, bool)
|
||||
Liveness(tags ...string) (map[string]apihealth.Result, bool)
|
||||
}
|
||||
|
||||
type health struct {
|
||||
log log.Logger
|
||||
readiness *worker
|
||||
health *worker
|
||||
liveness *worker
|
||||
}
|
||||
|
||||
func New(log log.Logger, registerer metric.Registerer) (Health, error) {
|
||||
metricsInstance := metric.NewWithRegistry("health", registerer.(metric.Registry))
|
||||
failingChecks := metricsInstance.NewGaugeVec(
|
||||
"checks_failing",
|
||||
"number of currently failing health checks",
|
||||
[]string{CheckLabel, TagLabel},
|
||||
)
|
||||
return &health{
|
||||
log: log,
|
||||
readiness: newWorker(log, "readiness", failingChecks),
|
||||
health: newWorker(log, "health", failingChecks),
|
||||
liveness: newWorker(log, "liveness", failingChecks),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *health) RegisterReadinessCheck(name string, checker Checker, tags ...string) error {
|
||||
return h.readiness.RegisterMonotonicCheck(name, checker, tags...)
|
||||
}
|
||||
|
||||
func (h *health) RegisterHealthCheck(name string, checker Checker, tags ...string) error {
|
||||
return h.health.RegisterCheck(name, checker, tags...)
|
||||
}
|
||||
|
||||
func (h *health) RegisterLivenessCheck(name string, checker Checker, tags ...string) error {
|
||||
return h.liveness.RegisterCheck(name, checker, tags...)
|
||||
}
|
||||
|
||||
func (h *health) Readiness(tags ...string) (map[string]apihealth.Result, bool) {
|
||||
results, healthy := h.readiness.Results(tags...)
|
||||
if !healthy {
|
||||
h.log.Warn("failing check",
|
||||
log.UserString("namespace", "readiness"),
|
||||
log.Reflect("reason", results),
|
||||
)
|
||||
}
|
||||
return results, healthy
|
||||
}
|
||||
|
||||
func (h *health) Health(tags ...string) (map[string]apihealth.Result, bool) {
|
||||
results, healthy := h.health.Results(tags...)
|
||||
if !healthy {
|
||||
h.log.Warn("failing check",
|
||||
log.UserString("namespace", "health"),
|
||||
log.Reflect("reason", results),
|
||||
)
|
||||
}
|
||||
return results, healthy
|
||||
}
|
||||
|
||||
func (h *health) Liveness(tags ...string) (map[string]apihealth.Result, bool) {
|
||||
results, healthy := h.liveness.Results(tags...)
|
||||
if !healthy {
|
||||
h.log.Warn("failing check",
|
||||
log.UserString("namespace", "liveness"),
|
||||
log.Reflect("reason", results),
|
||||
)
|
||||
}
|
||||
return results, healthy
|
||||
}
|
||||
|
||||
func (h *health) Start(ctx context.Context, freq time.Duration) {
|
||||
h.readiness.Start(ctx, freq)
|
||||
h.health.Start(ctx, freq)
|
||||
h.liveness.Start(ctx, freq)
|
||||
}
|
||||
|
||||
func (h *health) Stop() {
|
||||
h.readiness.Stop()
|
||||
h.health.Stop()
|
||||
h.liveness.Stop()
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/atomic"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
const (
|
||||
checkFreq = time.Millisecond
|
||||
awaitFreq = 50 * time.Microsecond
|
||||
awaitTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var errUnhealthy = errors.New("unhealthy")
|
||||
|
||||
func awaitReadiness(t *testing.T, r Reporter, ready bool) {
|
||||
require.Eventually(t, func() bool {
|
||||
_, ok := r.Readiness()
|
||||
return ok == ready
|
||||
}, awaitTimeout, awaitFreq)
|
||||
}
|
||||
|
||||
func awaitHealthy(t *testing.T, r Reporter, healthy bool) {
|
||||
require.Eventually(t, func() bool {
|
||||
_, ok := r.Health()
|
||||
return ok == healthy
|
||||
}, awaitTimeout, awaitFreq)
|
||||
}
|
||||
|
||||
func awaitLiveness(t *testing.T, r Reporter, liveness bool) {
|
||||
require.Eventually(t, func() bool {
|
||||
_, ok := r.Liveness()
|
||||
return ok == liveness
|
||||
}, awaitTimeout, awaitFreq)
|
||||
}
|
||||
|
||||
func TestDuplicatedRegistrations(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
|
||||
require.NoError(h.RegisterReadinessCheck("check", check))
|
||||
err = h.RegisterReadinessCheck("check", check)
|
||||
require.ErrorIs(err, errDuplicateCheck)
|
||||
|
||||
require.NoError(h.RegisterHealthCheck("check", check))
|
||||
err = h.RegisterHealthCheck("check", check)
|
||||
require.ErrorIs(err, errDuplicateCheck)
|
||||
|
||||
require.NoError(h.RegisterLivenessCheck("check", check))
|
||||
err = h.RegisterLivenessCheck("check", check)
|
||||
require.ErrorIs(err, errDuplicateCheck)
|
||||
}
|
||||
|
||||
func TestDefaultFailing(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
|
||||
{
|
||||
require.NoError(h.RegisterReadinessCheck("check", check))
|
||||
|
||||
readinessResult, readiness := h.Readiness()
|
||||
require.Len(readinessResult, 1)
|
||||
require.Contains(readinessResult, "check")
|
||||
require.Equal(notYetRunResult, readinessResult["check"])
|
||||
require.False(readiness)
|
||||
}
|
||||
|
||||
{
|
||||
require.NoError(h.RegisterHealthCheck("check", check))
|
||||
|
||||
healthResult, health := h.Health()
|
||||
require.Len(healthResult, 1)
|
||||
require.Contains(healthResult, "check")
|
||||
require.Equal(notYetRunResult, healthResult["check"])
|
||||
require.False(health)
|
||||
}
|
||||
|
||||
{
|
||||
require.NoError(h.RegisterLivenessCheck("check", check))
|
||||
|
||||
livenessResult, liveness := h.Liveness()
|
||||
require.Len(livenessResult, 1)
|
||||
require.Contains(livenessResult, "check")
|
||||
require.Equal(notYetRunResult, livenessResult["check"])
|
||||
require.False(liveness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassingChecks(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
|
||||
require.NoError(h.RegisterReadinessCheck("check", check))
|
||||
require.NoError(h.RegisterHealthCheck("check", check))
|
||||
require.NoError(h.RegisterLivenessCheck("check", check))
|
||||
|
||||
h.Start(context.Background(), checkFreq)
|
||||
defer h.Stop()
|
||||
|
||||
{
|
||||
awaitReadiness(t, h, true)
|
||||
|
||||
readinessResult, readiness := h.Readiness()
|
||||
require.Len(readinessResult, 1)
|
||||
require.Contains(readinessResult, "check")
|
||||
|
||||
result := readinessResult["check"]
|
||||
require.Empty(result.Details)
|
||||
require.Nil(result.Error)
|
||||
require.Zero(result.ContiguousFailures)
|
||||
require.True(readiness)
|
||||
}
|
||||
|
||||
{
|
||||
awaitHealthy(t, h, true)
|
||||
|
||||
healthResult, health := h.Health()
|
||||
require.Len(healthResult, 1)
|
||||
require.Contains(healthResult, "check")
|
||||
|
||||
result := healthResult["check"]
|
||||
require.Empty(result.Details)
|
||||
require.Nil(result.Error)
|
||||
require.Zero(result.ContiguousFailures)
|
||||
require.True(health)
|
||||
}
|
||||
|
||||
{
|
||||
awaitLiveness(t, h, true)
|
||||
|
||||
livenessResult, liveness := h.Liveness()
|
||||
require.Len(livenessResult, 1)
|
||||
require.Contains(livenessResult, "check")
|
||||
|
||||
result := livenessResult["check"]
|
||||
require.Empty(result.Details)
|
||||
require.Nil(result.Error)
|
||||
require.Zero(result.ContiguousFailures)
|
||||
require.True(liveness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassingThenFailingChecks(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
var shouldCheckErr atomic.Atomic[bool]
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
if shouldCheckErr.Get() {
|
||||
return errUnhealthy.Error(), errUnhealthy
|
||||
}
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
|
||||
require.NoError(h.RegisterReadinessCheck("check", check))
|
||||
require.NoError(h.RegisterHealthCheck("check", check))
|
||||
require.NoError(h.RegisterLivenessCheck("check", check))
|
||||
|
||||
h.Start(context.Background(), checkFreq)
|
||||
defer h.Stop()
|
||||
|
||||
awaitReadiness(t, h, true)
|
||||
awaitHealthy(t, h, true)
|
||||
awaitLiveness(t, h, true)
|
||||
|
||||
{
|
||||
_, readiness := h.Readiness()
|
||||
require.True(readiness)
|
||||
|
||||
_, health := h.Health()
|
||||
require.True(health)
|
||||
|
||||
_, liveness := h.Liveness()
|
||||
require.True(liveness)
|
||||
}
|
||||
|
||||
shouldCheckErr.Set(true)
|
||||
|
||||
awaitHealthy(t, h, false)
|
||||
awaitLiveness(t, h, false)
|
||||
|
||||
{
|
||||
// Notice that Readiness is a monotonic check - so it still reports
|
||||
// ready.
|
||||
_, readiness := h.Readiness()
|
||||
require.True(readiness)
|
||||
|
||||
_, health := h.Health()
|
||||
require.False(health)
|
||||
|
||||
_, liveness := h.Liveness()
|
||||
require.False(liveness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlockRegression(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
|
||||
var lock sync.Mutex
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
lock.Lock()
|
||||
time.Sleep(time.Nanosecond)
|
||||
lock.Unlock()
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h.Start(context.Background(), time.Nanosecond)
|
||||
defer h.Stop()
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
lock.Lock()
|
||||
require.NoError(h.RegisterHealthCheck(fmt.Sprintf("check-%d", i), check))
|
||||
lock.Unlock()
|
||||
}
|
||||
|
||||
awaitHealthy(t, h, true)
|
||||
}
|
||||
|
||||
func TestTags(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
require.NoError(h.RegisterHealthCheck("check1", check))
|
||||
require.NoError(h.RegisterHealthCheck("check2", check, "tag1"))
|
||||
require.NoError(h.RegisterHealthCheck("check3", check, "tag2"))
|
||||
require.NoError(h.RegisterHealthCheck("check4", check, "tag1", "tag2"))
|
||||
require.NoError(h.RegisterHealthCheck("check5", check, ApplicationTag))
|
||||
|
||||
// default checks
|
||||
{
|
||||
healthResult, health := h.Health()
|
||||
require.Len(healthResult, 5)
|
||||
require.Contains(healthResult, "check1")
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.False(health)
|
||||
|
||||
healthResult, health = h.Health("tag1")
|
||||
require.Len(healthResult, 3)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.False(health)
|
||||
|
||||
healthResult, health = h.Health("tag1", "tag2")
|
||||
require.Len(healthResult, 4)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.False(health)
|
||||
|
||||
healthResult, health = h.Health("nonExistentTag")
|
||||
require.Len(healthResult, 1)
|
||||
require.Contains(healthResult, "check5")
|
||||
require.False(health)
|
||||
|
||||
healthResult, health = h.Health("tag1", "tag2", "nonExistentTag")
|
||||
require.Len(healthResult, 4)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.False(health)
|
||||
}
|
||||
|
||||
h.Start(context.Background(), checkFreq)
|
||||
|
||||
awaitHealthy(t, h, true)
|
||||
|
||||
{
|
||||
healthResult, health := h.Health()
|
||||
require.Len(healthResult, 5)
|
||||
require.Contains(healthResult, "check1")
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.True(health)
|
||||
|
||||
healthResult, health = h.Health("tag1")
|
||||
require.Len(healthResult, 3)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.True(health)
|
||||
|
||||
healthResult, health = h.Health("tag1", "tag2")
|
||||
require.Len(healthResult, 4)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.True(health)
|
||||
|
||||
healthResult, health = h.Health("nonExistentTag")
|
||||
require.Len(healthResult, 1)
|
||||
require.Contains(healthResult, "check5")
|
||||
require.True(health)
|
||||
|
||||
healthResult, health = h.Health("tag1", "tag2", "nonExistentTag")
|
||||
require.Len(healthResult, 4)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.True(health)
|
||||
}
|
||||
|
||||
// stop the health check
|
||||
h.Stop()
|
||||
|
||||
{
|
||||
// now we'll add a new check which is unhealthy by default (notYetRunResult)
|
||||
require.NoError(h.RegisterHealthCheck("check6", check, "tag1"))
|
||||
|
||||
awaitHealthy(t, h, false)
|
||||
|
||||
healthResult, health := h.Health("tag1")
|
||||
require.Len(healthResult, 4)
|
||||
require.Contains(healthResult, "check2")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.Contains(healthResult, "check6")
|
||||
require.Equal(notYetRunResult, healthResult["check6"])
|
||||
require.False(health)
|
||||
|
||||
healthResult, health = h.Health("tag2")
|
||||
require.Len(healthResult, 3)
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.True(health)
|
||||
|
||||
// add application tag
|
||||
require.NoError(h.RegisterHealthCheck("check7", check, ApplicationTag))
|
||||
|
||||
awaitHealthy(t, h, false)
|
||||
|
||||
healthResult, health = h.Health("tag2")
|
||||
require.Len(healthResult, 4)
|
||||
require.Contains(healthResult, "check3")
|
||||
require.Contains(healthResult, "check4")
|
||||
require.Contains(healthResult, "check5")
|
||||
require.Contains(healthResult, "check7")
|
||||
require.Equal(notYetRunResult, healthResult["check7"])
|
||||
require.False(health)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import "github.com/luxfi/metric"
|
||||
|
||||
type healthMetrics struct {
|
||||
// failingChecks keeps track of the number of check failing
|
||||
failingChecks metric.GaugeVec
|
||||
}
|
||||
|
||||
func newMetrics(namespace string, registry metric.Registry) (*healthMetrics, error) {
|
||||
metricsInstance := metric.NewWithRegistry(namespace, registry)
|
||||
|
||||
m := &healthMetrics{
|
||||
failingChecks: metricsInstance.NewGaugeVec(
|
||||
"checks_failing",
|
||||
"number of currently failing health checks",
|
||||
[]string{"tag"},
|
||||
),
|
||||
}
|
||||
m.failingChecks.WithLabelValues(AllTag).Set(0)
|
||||
m.failingChecks.WithLabelValues(ApplicationTag).Set(0)
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
)
|
||||
|
||||
// notYetRunResult is the result that is returned when a HealthCheck hasn't been
|
||||
// run yet.
|
||||
var notYetRunResult apihealth.Result
|
||||
|
||||
func init() {
|
||||
err := "not yet run"
|
||||
notYetRunResult = apihealth.Result{
|
||||
Error: &err,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// Service implements the health API with gorilla/rpc-compatible signatures.
|
||||
type Service struct {
|
||||
log log.Logger
|
||||
health Reporter
|
||||
}
|
||||
|
||||
func NewService(log log.Logger, reporter Reporter) *Service {
|
||||
return &Service{log: log, health: reporter}
|
||||
}
|
||||
|
||||
// Readiness returns if the node has finished initialization
|
||||
func (s *Service) Readiness(_ *http.Request, args *apihealth.APIArgs, reply *apihealth.APIReply) error {
|
||||
s.log.Debug("API called",
|
||||
log.UserString("service", "health"),
|
||||
log.UserString("method", "readiness"),
|
||||
log.Reflect("tags", args.Tags),
|
||||
)
|
||||
reply.Checks, reply.Healthy = s.health.Readiness(args.Tags...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health returns a summation of the health of the node
|
||||
func (s *Service) Health(_ *http.Request, args *apihealth.APIArgs, reply *apihealth.APIReply) error {
|
||||
s.log.Debug("API called",
|
||||
log.UserString("service", "health"),
|
||||
log.UserString("method", "health"),
|
||||
log.Reflect("tags", args.Tags),
|
||||
)
|
||||
reply.Checks, reply.Healthy = s.health.Health(args.Tags...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Liveness returns if the node is in need of a restart
|
||||
func (s *Service) Liveness(_ *http.Request, args *apihealth.APIArgs, reply *apihealth.APIReply) error {
|
||||
s.log.Debug("API called",
|
||||
log.UserString("service", "health"),
|
||||
log.UserString("method", "liveness"),
|
||||
log.Reflect("tags", args.Tags),
|
||||
)
|
||||
reply.Checks, reply.Healthy = s.health.Liveness(args.Tags...)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
The Health API can be used for measuring node health.
|
||||
|
||||
<Callout title="Note">
|
||||
This API set is for a specific node; it is unavailable on the [public server](https://docs.lux.network/docs/tooling/rpc-providers).
|
||||
</Callout>
|
||||
|
||||
## Health Checks
|
||||
|
||||
The node periodically runs all health checks, including health checks for each chain.
|
||||
|
||||
The frequency at which health checks are run can be specified with the [\--health-check-frequency](https://docs.lux.network/docs/nodes/configure/configs-flags) flag.
|
||||
|
||||
## Filterable Health Checks
|
||||
|
||||
The health checks that are run by the node are filterable. You can specify which health checks you want to see by using `tags` filters. Returned results will only include health checks that match the specified tags and global health checks like `network`, `database` etc. When filtered, the returned results will not show the full node health, but only a subset of filtered health checks. This means the node can still be unhealthy in unfiltered checks, even if the returned results show that the node is healthy. Lux Node supports using netIDs as tags.
|
||||
|
||||
## GET Request
|
||||
|
||||
To get an HTTP status code response that indicates the node's health, make a `GET` request. If the node is healthy, it will return a `200` status code. If the node is unhealthy, it will return a `503` status code. In-depth information about the node's health is included in the response body.
|
||||
|
||||
### Filtering
|
||||
|
||||
To filter GET health checks, add a `tag` query parameter to the request. The `tag` parameter is a string. For example, to filter health results by netID `29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL`, use the following query:
|
||||
|
||||
```sh
|
||||
curl 'http://localhost:9630/ext/health?tag=29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL'
|
||||
```
|
||||
|
||||
In this example returned results will contain global health checks and health checks that are related to netID `29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL`.
|
||||
|
||||
**Note**: This filtering can show healthy results even if the node is unhealthy in other Chains/Lux L1s.
|
||||
|
||||
In order to filter results by multiple tags, use multiple `tag` query parameters. For example, to filter health results by netID `29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL` and `28nrH5T2BMvNrWecFcV3mfccjs6axM1TVyqe79MCv2Mhs8kxiY` use the following query:
|
||||
|
||||
```sh
|
||||
curl 'http://localhost:9630/ext/health?tag=29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL&tag=28nrH5T2BMvNrWecFcV3mfccjs6axM1TVyqe79MCv2Mhs8kxiY'
|
||||
```
|
||||
|
||||
The returned results will include health checks for both netIDs as well as global health checks.
|
||||
|
||||
### Endpoints
|
||||
|
||||
The available endpoints for GET requests are:
|
||||
|
||||
- `/ext/health` returns a holistic report of the status of the node. **Most operators should monitor this status.**
|
||||
- `/ext/health/health` is the same as `/ext/health`.
|
||||
- `/ext/health/readiness` returns healthy once the node has finished initializing.
|
||||
- `/ext/health/liveness` returns healthy once the endpoint is available.
|
||||
|
||||
## JSON RPC Request
|
||||
|
||||
### Format
|
||||
|
||||
This API uses the `json 2.0` RPC format. For more information on making JSON RPC calls, see [here](https://docs.lux.network/docs/api-reference/guides/issuing-api-calls).
|
||||
|
||||
### Endpoint
|
||||
|
||||
### Methods
|
||||
|
||||
#### `health.health`
|
||||
|
||||
This method returns the last set of health check results.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -H 'Content-Type: application/json' --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"health.health",
|
||||
"params": {
|
||||
"tags": ["11111111111111111111111111111111LpoYY", "29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL"]
|
||||
}
|
||||
}' 'http://localhost:9630/ext/health'
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"checks": {
|
||||
"C": {
|
||||
"message": {
|
||||
"engine": {
|
||||
"consensus": {
|
||||
"lastAcceptedHeight": 31273749,
|
||||
"lastAcceptedID": "2Y4gZGzQnu8UjnHod8j1BLewHFVEbzhULPNzqrSWEHkHNqDrYL",
|
||||
"longestProcessingBlock": "0s",
|
||||
"processingBlocks": 0
|
||||
},
|
||||
"vm": null
|
||||
},
|
||||
"networking": {
|
||||
"percentConnected": 0.9999592612587486
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-03-26T19:44:45.2931-04:00",
|
||||
"duration": 20375
|
||||
},
|
||||
"P": {
|
||||
"message": {
|
||||
"engine": {
|
||||
"consensus": {
|
||||
"lastAcceptedHeight": 142517,
|
||||
"lastAcceptedID": "2e1FEPCBEkG2Q7WgyZh1v4nt3DXj1HDbDthyhxdq2Ltg3shSYq",
|
||||
"longestProcessingBlock": "0s",
|
||||
"processingBlocks": 0
|
||||
},
|
||||
"vm": null
|
||||
},
|
||||
"networking": {
|
||||
"percentConnected": 0.9999592612587486
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-03-26T19:44:45.293115-04:00",
|
||||
"duration": 8750
|
||||
},
|
||||
"X": {
|
||||
"message": {
|
||||
"engine": {
|
||||
"consensus": {
|
||||
"lastAcceptedHeight": 24464,
|
||||
"lastAcceptedID": "XuFCsGaSw9cn7Vuz5e2fip4KvP46Xu53S8uDRxaC2QJmyYc3w",
|
||||
"longestProcessingBlock": "0s",
|
||||
"processingBlocks": 0
|
||||
},
|
||||
"vm": null
|
||||
},
|
||||
"networking": {
|
||||
"percentConnected": 0.9999592612587486
|
||||
}
|
||||
},
|
||||
"timestamp": "2025-03-26T19:44:45.29312-04:00",
|
||||
"duration": 23291
|
||||
},
|
||||
"bootstrapped": {
|
||||
"message": [],
|
||||
"timestamp": "2025-03-26T19:44:45.293078-04:00",
|
||||
"duration": 3375
|
||||
},
|
||||
"database": {
|
||||
"timestamp": "2025-03-26T19:44:45.293102-04:00",
|
||||
"duration": 1959
|
||||
},
|
||||
"diskspace": {
|
||||
"message": {
|
||||
"availableDiskBytes": 227332591616
|
||||
},
|
||||
"timestamp": "2025-03-26T19:44:45.293106-04:00",
|
||||
"duration": 3042
|
||||
},
|
||||
"network": {
|
||||
"message": {
|
||||
"connectedPeers": 284,
|
||||
"sendFailRate": 0,
|
||||
"timeSinceLastMsgReceived": "293.098ms",
|
||||
"timeSinceLastMsgSent": "293.098ms"
|
||||
},
|
||||
"timestamp": "2025-03-26T19:44:45.2931-04:00",
|
||||
"duration": 2333
|
||||
},
|
||||
"router": {
|
||||
"message": {
|
||||
"longestRunningRequest": "66.90725ms",
|
||||
"outstandingRequests": 3
|
||||
},
|
||||
"timestamp": "2025-03-26T19:44:45.293097-04:00",
|
||||
"duration": 3542
|
||||
}
|
||||
},
|
||||
"healthy": true
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
In this example response, every check has passed. So, the node is healthy.
|
||||
|
||||
**Response Explanation**:
|
||||
|
||||
- `checks` is a list of health check responses.
|
||||
- A check response may include a `message` with additional context.
|
||||
- A check response may include an `error` describing why the check failed.
|
||||
- `timestamp` is the timestamp of the last health check.
|
||||
- `duration` is the execution duration of the last health check, in nanoseconds.
|
||||
- `contiguousFailures` is the number of times in a row this check failed.
|
||||
- `timeOfFirstFailure` is the time this check first failed.
|
||||
- `healthy` is true all the health checks are passing.
|
||||
|
||||
#### `health.readiness`
|
||||
|
||||
This method returns the last evaluation of the startup health check results.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -H 'Content-Type: application/json' --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"health.readiness",
|
||||
"params": {
|
||||
"tags": ["11111111111111111111111111111111LpoYY", "29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL"]
|
||||
}
|
||||
}' 'http://localhost:9630/ext/health'
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"checks": {
|
||||
"bootstrapped": {
|
||||
"message": [],
|
||||
"timestamp": "2025-03-26T20:02:45.299114-04:00",
|
||||
"duration": 2834
|
||||
}
|
||||
},
|
||||
"healthy": true
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
In this example response, every check has passed. So, the node has finished the startup process.
|
||||
|
||||
**Response Explanation**:
|
||||
|
||||
- `checks` is a list of health check responses.
|
||||
- A check response may include a `message` with additional context.
|
||||
- A check response may include an `error` describing why the check failed.
|
||||
- `timestamp` is the timestamp of the last health check.
|
||||
- `duration` is the execution duration of the last health check, in nanoseconds.
|
||||
- `contiguousFailures` is the number of times in a row this check failed.
|
||||
- `timeOfFirstFailure` is the time this check first failed.
|
||||
- `healthy` is true all the health checks are passing.
|
||||
|
||||
#### `health.liveness`
|
||||
|
||||
This method returns healthy.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -H 'Content-Type: application/json' --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"health.liveness"
|
||||
}' 'http://localhost:9630/ext/health'
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"checks": {},
|
||||
"healthy": true
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
In this example response, the node was able to handle the request and mark the service as healthy.
|
||||
|
||||
**Response Explanation**:
|
||||
|
||||
- `checks` is an empty list.
|
||||
- `healthy` is true.
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceResponses(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
|
||||
svc := NewService(log.NewNoOpLogger(), h)
|
||||
|
||||
require.NoError(h.RegisterReadinessCheck("check", check))
|
||||
require.NoError(h.RegisterHealthCheck("check", check))
|
||||
require.NoError(h.RegisterLivenessCheck("check", check))
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := svc.Readiness(nil, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
|
||||
require.Len(reply.Checks, 1)
|
||||
require.Contains(reply.Checks, "check")
|
||||
require.Equal(notYetRunResult, reply.Checks["check"])
|
||||
require.False(reply.Healthy)
|
||||
}
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := svc.Health(nil, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
|
||||
require.Len(reply.Checks, 1)
|
||||
require.Contains(reply.Checks, "check")
|
||||
require.Equal(notYetRunResult, reply.Checks["check"])
|
||||
require.False(reply.Healthy)
|
||||
}
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := svc.Liveness(nil, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
|
||||
require.Len(reply.Checks, 1)
|
||||
require.Contains(reply.Checks, "check")
|
||||
require.Equal(notYetRunResult, reply.Checks["check"])
|
||||
require.False(reply.Healthy)
|
||||
}
|
||||
|
||||
h.Start(context.Background(), checkFreq)
|
||||
defer h.Stop()
|
||||
|
||||
awaitReadiness(t, h, true)
|
||||
awaitHealthy(t, h, true)
|
||||
awaitLiveness(t, h, true)
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := svc.Readiness(nil, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
|
||||
result := reply.Checks["check"]
|
||||
require.Empty(result.Details)
|
||||
require.Nil(result.Error)
|
||||
require.Zero(result.ContiguousFailures)
|
||||
require.True(reply.Healthy)
|
||||
}
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := svc.Health(nil, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
|
||||
result := reply.Checks["check"]
|
||||
require.Empty(result.Details)
|
||||
require.Nil(result.Error)
|
||||
require.Zero(result.ContiguousFailures)
|
||||
require.True(reply.Healthy)
|
||||
}
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := svc.Liveness(nil, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
|
||||
result := reply.Checks["check"]
|
||||
require.Empty(result.Details)
|
||||
require.Nil(result.Error)
|
||||
require.Zero(result.ContiguousFailures)
|
||||
require.True(reply.Healthy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTagResponse(t *testing.T) {
|
||||
check := CheckerFunc(func(context.Context) (interface{}, error) {
|
||||
return "", nil
|
||||
})
|
||||
|
||||
netID1 := ids.GenerateTestID()
|
||||
netID2 := ids.GenerateTestID()
|
||||
|
||||
type testMethods struct {
|
||||
name string
|
||||
register func(Health, string, Checker, ...string) error
|
||||
check func(*Service, *apihealth.APIArgs, *apihealth.APIReply) error
|
||||
await func(*testing.T, Reporter, bool)
|
||||
}
|
||||
|
||||
tests := []testMethods{
|
||||
{
|
||||
name: "Readiness",
|
||||
register: func(h Health, s1 string, c Checker, s2 ...string) error {
|
||||
return h.RegisterReadinessCheck(s1, c, s2...)
|
||||
},
|
||||
check: func(s *Service, args *apihealth.APIArgs, reply *apihealth.APIReply) error {
|
||||
return s.Readiness(nil, args, reply)
|
||||
},
|
||||
await: awaitReadiness,
|
||||
},
|
||||
{
|
||||
name: "Health",
|
||||
register: func(h Health, s1 string, c Checker, s2 ...string) error {
|
||||
return h.RegisterHealthCheck(s1, c, s2...)
|
||||
},
|
||||
check: func(s *Service, args *apihealth.APIArgs, reply *apihealth.APIReply) error {
|
||||
return s.Health(nil, args, reply)
|
||||
},
|
||||
await: awaitHealthy,
|
||||
},
|
||||
{
|
||||
name: "Liveness",
|
||||
register: func(h Health, s1 string, c Checker, s2 ...string) error {
|
||||
return h.RegisterLivenessCheck(s1, c, s2...)
|
||||
},
|
||||
check: func(s *Service, args *apihealth.APIArgs, reply *apihealth.APIReply) error {
|
||||
return s.Liveness(nil, args, reply)
|
||||
},
|
||||
await: awaitLiveness,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
h, err := New(log.NewNoOpLogger(), metric.NewRegistry())
|
||||
require.NoError(err)
|
||||
require.NoError(test.register(h, "check1", check))
|
||||
require.NoError(test.register(h, "check2", check, netID1.String()))
|
||||
require.NoError(test.register(h, "check3", check, netID2.String()))
|
||||
require.NoError(test.register(h, "check4", check, netID1.String(), netID2.String()))
|
||||
|
||||
svc := NewService(log.NewNoOpLogger(), h)
|
||||
|
||||
// default checks
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := test.check(svc, &apihealth.APIArgs{}, reply)
|
||||
require.NoError(err)
|
||||
require.Len(reply.Checks, 4)
|
||||
require.Contains(reply.Checks, "check1")
|
||||
require.Contains(reply.Checks, "check2")
|
||||
require.Contains(reply.Checks, "check3")
|
||||
require.Contains(reply.Checks, "check4")
|
||||
require.Equal(notYetRunResult, reply.Checks["check1"])
|
||||
require.False(reply.Healthy)
|
||||
|
||||
reply = &apihealth.APIReply{}
|
||||
err = test.check(svc, &apihealth.APIArgs{Tags: []string{netID1.String()}}, reply)
|
||||
require.NoError(err)
|
||||
require.Len(reply.Checks, 2)
|
||||
require.Contains(reply.Checks, "check2")
|
||||
require.Contains(reply.Checks, "check4")
|
||||
require.Equal(notYetRunResult, reply.Checks["check2"])
|
||||
require.False(reply.Healthy)
|
||||
}
|
||||
|
||||
h.Start(context.Background(), checkFreq)
|
||||
|
||||
test.await(t, h, true)
|
||||
|
||||
{
|
||||
reply := &apihealth.APIReply{}
|
||||
err := test.check(svc, &apihealth.APIArgs{Tags: []string{netID1.String()}}, reply)
|
||||
require.NoError(err)
|
||||
require.Len(reply.Checks, 2)
|
||||
require.Contains(reply.Checks, "check2")
|
||||
require.Contains(reply.Checks, "check4")
|
||||
require.True(reply.Healthy)
|
||||
}
|
||||
|
||||
// stop the health check
|
||||
h.Stop()
|
||||
|
||||
{
|
||||
// now we'll add a new check which is unhealthy by default (notYetRunResult)
|
||||
require.NoError(test.register(h, "check5", check, netID1.String()))
|
||||
|
||||
reply := &apihealth.APIReply{}
|
||||
err := test.check(svc, &apihealth.APIArgs{Tags: []string{netID1.String()}}, reply)
|
||||
require.NoError(err)
|
||||
require.Len(reply.Checks, 3)
|
||||
require.Contains(reply.Checks, "check2")
|
||||
require.Contains(reply.Checks, "check4")
|
||||
require.Contains(reply.Checks, "check5")
|
||||
require.Equal(notYetRunResult, reply.Checks["check5"])
|
||||
require.False(reply.Healthy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apihealth "github.com/luxfi/api/health"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/math/set"
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/luxfi/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
allTags = []string{AllTag}
|
||||
|
||||
errRestrictedTag = errors.New("restricted tag")
|
||||
errDuplicateCheck = errors.New("duplicate check")
|
||||
)
|
||||
|
||||
type worker struct {
|
||||
log log.Logger
|
||||
name string
|
||||
failingChecks metric.GaugeVec
|
||||
checksLock sync.RWMutex
|
||||
checks map[string]*taggedChecker
|
||||
|
||||
resultsLock sync.RWMutex
|
||||
results map[string]apihealth.Result
|
||||
numFailingApplicationChecks int
|
||||
tags map[string]set.Set[string] // tag -> set of check names
|
||||
|
||||
startOnce sync.Once
|
||||
closeOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
closer chan struct{}
|
||||
}
|
||||
|
||||
type taggedChecker struct {
|
||||
checker Checker
|
||||
isApplicationCheck bool
|
||||
tags []string
|
||||
}
|
||||
|
||||
func newWorker(
|
||||
log log.Logger,
|
||||
name string,
|
||||
failingChecks metric.GaugeVec,
|
||||
) *worker {
|
||||
// Initialize the number of failing checks to 0 for all checks
|
||||
for _, tag := range []string{AllTag, ApplicationTag} {
|
||||
failingChecks.With(metric.Labels{
|
||||
CheckLabel: name,
|
||||
TagLabel: tag,
|
||||
}).Set(0)
|
||||
}
|
||||
return &worker{
|
||||
log: log,
|
||||
name: name,
|
||||
failingChecks: failingChecks,
|
||||
checks: make(map[string]*taggedChecker),
|
||||
results: make(map[string]apihealth.Result),
|
||||
closer: make(chan struct{}),
|
||||
tags: make(map[string]set.Set[string]),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *worker) RegisterCheck(name string, check Checker, tags ...string) error {
|
||||
// We ensure [AllTag] isn't contained in [tags] to prevent metrics from
|
||||
// double counting.
|
||||
if slices.Contains(tags, AllTag) {
|
||||
return fmt.Errorf("%w: %q", errRestrictedTag, AllTag)
|
||||
}
|
||||
|
||||
w.checksLock.Lock()
|
||||
defer w.checksLock.Unlock()
|
||||
|
||||
if _, ok := w.checks[name]; ok {
|
||||
return fmt.Errorf("%w: %q", errDuplicateCheck, name)
|
||||
}
|
||||
|
||||
// Acquire resultsLock before modifying tags and results maps
|
||||
w.resultsLock.Lock()
|
||||
|
||||
// Add the check to each tag
|
||||
for _, tag := range tags {
|
||||
names, ok := w.tags[tag]
|
||||
if !ok {
|
||||
names = make(set.Set[string])
|
||||
}
|
||||
names.Add(name)
|
||||
w.tags[tag] = names
|
||||
}
|
||||
// Add the special AllTag descriptor
|
||||
names, ok := w.tags[AllTag]
|
||||
if !ok {
|
||||
names = make(set.Set[string])
|
||||
}
|
||||
names.Add(name)
|
||||
w.tags[AllTag] = names
|
||||
|
||||
applicationChecks := w.tags[ApplicationTag]
|
||||
tc := &taggedChecker{
|
||||
checker: check,
|
||||
isApplicationCheck: applicationChecks.Contains(name),
|
||||
tags: tags,
|
||||
}
|
||||
w.checks[name] = tc
|
||||
w.results[name] = notYetRunResult
|
||||
|
||||
w.resultsLock.Unlock()
|
||||
|
||||
// Whenever a new check is added - it is failing
|
||||
w.log.Info("registered new check and initialized its state to failing",
|
||||
log.UserString("workerName", w.name),
|
||||
log.UserString("checkName", name),
|
||||
log.Reflect("tags", tags),
|
||||
)
|
||||
|
||||
// If this is a new application-wide check, then all of the registered tags
|
||||
// now have one additional failing check.
|
||||
w.updateMetrics(tc, false /*=healthy*/, true /*=register*/)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *worker) RegisterMonotonicCheck(name string, checker Checker, tags ...string) error {
|
||||
var result utils.Atomic[any]
|
||||
return w.RegisterCheck(name, CheckerFunc(func(ctx context.Context) (any, error) {
|
||||
details := result.Get()
|
||||
if details != nil {
|
||||
return details, nil
|
||||
}
|
||||
|
||||
details, err := checker.HealthCheck(ctx)
|
||||
if err == nil {
|
||||
result.Set(details)
|
||||
}
|
||||
return details, err
|
||||
}), tags...)
|
||||
}
|
||||
|
||||
func (w *worker) Results(tags ...string) (map[string]apihealth.Result, bool) {
|
||||
w.resultsLock.RLock()
|
||||
defer w.resultsLock.RUnlock()
|
||||
|
||||
// if no tags are specified, return all checks
|
||||
if len(tags) == 0 {
|
||||
tags = allTags
|
||||
}
|
||||
|
||||
names := make(set.Set[string])
|
||||
tagSet := set.Of(tags...)
|
||||
tagSet.Add(ApplicationTag) // we always want to include the application tag
|
||||
for tag := range tagSet {
|
||||
if tagSet, ok := w.tags[tag]; ok {
|
||||
names = names.Union(tagSet)
|
||||
}
|
||||
}
|
||||
|
||||
results := make(map[string]apihealth.Result, names.Len())
|
||||
healthy := true
|
||||
for name := range names {
|
||||
if result, ok := w.results[name]; ok {
|
||||
results[name] = result
|
||||
healthy = healthy && result.Error == nil
|
||||
}
|
||||
}
|
||||
return results, healthy
|
||||
}
|
||||
|
||||
func (w *worker) Start(ctx context.Context, freq time.Duration) {
|
||||
w.startOnce.Do(func() {
|
||||
detachedCtx := context.WithoutCancel(ctx)
|
||||
w.wg.Add(1)
|
||||
go func() {
|
||||
ticker := time.NewTicker(freq)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
w.wg.Done()
|
||||
}()
|
||||
|
||||
w.runChecks(detachedCtx)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.runChecks(detachedCtx)
|
||||
case <-w.closer:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func (w *worker) Stop() {
|
||||
w.closeOnce.Do(func() {
|
||||
close(w.closer)
|
||||
w.wg.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
func (w *worker) runChecks(ctx context.Context) {
|
||||
w.checksLock.RLock()
|
||||
// Copy the [w.checks] map to collect the checks that we will be running
|
||||
// during this iteration. If [w.checks] is modified during this iteration of
|
||||
// [runChecks], then the added check will not be run until the next
|
||||
// iteration.
|
||||
checks := maps.Clone(w.checks)
|
||||
w.checksLock.RUnlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(checks))
|
||||
for name, check := range checks {
|
||||
go w.runCheck(ctx, &wg, name, check)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (w *worker) runCheck(ctx context.Context, wg *sync.WaitGroup, name string, check *taggedChecker) {
|
||||
defer wg.Done()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// To avoid any deadlocks when [RegisterCheck] is called with a lock
|
||||
// that is grabbed by [check.HealthCheck], we ensure that no locks
|
||||
// are held when [check.HealthCheck] is called.
|
||||
details, err := check.checker.HealthCheck(ctx)
|
||||
end := time.Now()
|
||||
|
||||
result := apihealth.Result{
|
||||
Details: details,
|
||||
Timestamp: end,
|
||||
Duration: end.Sub(start),
|
||||
}
|
||||
|
||||
w.resultsLock.Lock()
|
||||
defer w.resultsLock.Unlock()
|
||||
prevResult := w.results[name]
|
||||
if err != nil {
|
||||
errString := err.Error()
|
||||
result.Error = &errString
|
||||
|
||||
result.ContiguousFailures = prevResult.ContiguousFailures + 1
|
||||
if prevResult.ContiguousFailures > 0 {
|
||||
result.TimeOfFirstFailure = prevResult.TimeOfFirstFailure
|
||||
} else {
|
||||
result.TimeOfFirstFailure = &end
|
||||
}
|
||||
|
||||
if prevResult.Error == nil {
|
||||
w.log.Warn("check started failing",
|
||||
log.UserString("workerName", w.name),
|
||||
log.UserString("checkName", name),
|
||||
log.Reflect("tags", check.tags),
|
||||
log.Reflect("error", err),
|
||||
)
|
||||
w.updateMetrics(check, false /*=healthy*/, false /*=register*/)
|
||||
}
|
||||
} else if prevResult.Error != nil {
|
||||
w.log.Info("check started passing",
|
||||
log.UserString("workerName", w.name),
|
||||
log.UserString("checkName", name),
|
||||
log.Reflect("tags", check.tags),
|
||||
)
|
||||
w.updateMetrics(check, true /*=healthy*/, false /*=register*/)
|
||||
}
|
||||
w.results[name] = result
|
||||
}
|
||||
|
||||
// updateMetrics updates the metrics for the given check. If [healthy] is true,
|
||||
// then the check is considered healthy and the metrics are decremented.
|
||||
// Otherwise, the check is considered unhealthy and the metrics are incremented.
|
||||
// [register] must be true only if this is the first time the check is being
|
||||
// registered.
|
||||
func (w *worker) updateMetrics(tc *taggedChecker, healthy bool, register bool) {
|
||||
if tc.isApplicationCheck {
|
||||
// Note: [w.tags] will include AllTag.
|
||||
for tag := range w.tags {
|
||||
gauge := w.failingChecks.With(metric.Labels{
|
||||
CheckLabel: w.name,
|
||||
TagLabel: tag,
|
||||
})
|
||||
if healthy {
|
||||
gauge.Dec()
|
||||
} else {
|
||||
gauge.Inc()
|
||||
}
|
||||
}
|
||||
if healthy {
|
||||
w.numFailingApplicationChecks--
|
||||
} else {
|
||||
w.numFailingApplicationChecks++
|
||||
}
|
||||
} else {
|
||||
for _, tag := range tc.tags {
|
||||
gauge := w.failingChecks.With(metric.Labels{
|
||||
CheckLabel: w.name,
|
||||
TagLabel: tag,
|
||||
})
|
||||
if healthy {
|
||||
gauge.Dec()
|
||||
} else {
|
||||
gauge.Inc()
|
||||
// If this is the first time this tag was registered, we also need to
|
||||
// account for the currently failing application-wide checks.
|
||||
if register && w.tags[tag].Len() == 1 {
|
||||
gauge.Add(float64(w.numFailingApplicationChecks))
|
||||
}
|
||||
}
|
||||
}
|
||||
gauge := w.failingChecks.With(metric.Labels{
|
||||
CheckLabel: w.name,
|
||||
TagLabel: AllTag,
|
||||
})
|
||||
if healthy {
|
||||
gauge.Dec()
|
||||
} else {
|
||||
gauge.Inc()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package info
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/gorilla/rpc/v2"
|
||||
|
||||
apiinfo "github.com/luxfi/api/info"
|
||||
apitypes "github.com/luxfi/api/types"
|
||||
jsonrpc "github.com/luxfi/codec/jsonrpc"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/node/chains"
|
||||
"github.com/luxfi/node/network"
|
||||
nodepeer "github.com/luxfi/node/network/peer"
|
||||
// "github.com/luxfi/consensus/networking/benchlist" // Unused
|
||||
"github.com/luxfi/constants"
|
||||
"github.com/luxfi/formatting"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/upgrade"
|
||||
avajson "github.com/luxfi/node/utils/json"
|
||||
"github.com/luxfi/node/version"
|
||||
"github.com/luxfi/node/vms"
|
||||
"github.com/luxfi/node/vms/platformvm/signer"
|
||||
p2ppeer "github.com/luxfi/p2p/peer"
|
||||
"github.com/luxfi/utils"
|
||||
"github.com/luxfi/utxo/nftfx"
|
||||
"github.com/luxfi/utxo/propertyfx"
|
||||
"github.com/luxfi/utxo/secp256k1fx"
|
||||
validators "github.com/luxfi/validators"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoChainProvided = errors.New("argument 'chain' not given")
|
||||
|
||||
mainnetGetTxFeeResponse = apiinfo.GetTxFeeResponse{
|
||||
CreateNetworkTxFee: apitypes.Uint64(1 * constants.Lux),
|
||||
TransformChainTxFee: apitypes.Uint64(10 * constants.Lux),
|
||||
CreateChainTxFee: apitypes.Uint64(1 * constants.Lux),
|
||||
AddNetworkValidatorFee: apitypes.Uint64(constants.MilliLux),
|
||||
AddNetworkDelegatorFee: apitypes.Uint64(constants.MilliLux),
|
||||
}
|
||||
fujiGetTxFeeResponse = apiinfo.GetTxFeeResponse{
|
||||
CreateNetworkTxFee: apitypes.Uint64(100 * constants.MilliLux),
|
||||
TransformChainTxFee: apitypes.Uint64(1 * constants.Lux),
|
||||
CreateChainTxFee: apitypes.Uint64(100 * constants.MilliLux),
|
||||
AddNetworkValidatorFee: apitypes.Uint64(constants.MilliLux),
|
||||
AddNetworkDelegatorFee: apitypes.Uint64(constants.MilliLux),
|
||||
}
|
||||
defaultGetTxFeeResponse = apiinfo.GetTxFeeResponse{
|
||||
CreateNetworkTxFee: apitypes.Uint64(100 * constants.MilliLux),
|
||||
TransformChainTxFee: apitypes.Uint64(100 * constants.MilliLux),
|
||||
CreateChainTxFee: apitypes.Uint64(100 * constants.MilliLux),
|
||||
AddNetworkValidatorFee: apitypes.Uint64(constants.MilliLux),
|
||||
AddNetworkDelegatorFee: apitypes.Uint64(constants.MilliLux),
|
||||
}
|
||||
)
|
||||
|
||||
// Info is the API service for unprivileged info on a node
|
||||
type Info struct {
|
||||
Parameters
|
||||
log log.Logger
|
||||
validators validators.Manager
|
||||
myIP *utils.Atomic[netip.AddrPort]
|
||||
networking network.Network
|
||||
chainManager chains.Manager
|
||||
vmManager vms.Manager
|
||||
// benchlist benchlist.Manager // benchlist package doesn't exist
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Version *version.Application
|
||||
NodeID ids.NodeID
|
||||
NodePOP *signer.ProofOfPossession
|
||||
NetworkID uint32
|
||||
VMManager vms.Manager
|
||||
Upgrades upgrade.Config
|
||||
|
||||
TxFee uint64
|
||||
CreateAssetTxFee uint64
|
||||
}
|
||||
|
||||
func NewService(
|
||||
parameters Parameters,
|
||||
log log.Logger,
|
||||
validators validators.Manager,
|
||||
chainManager chains.Manager,
|
||||
vmManager vms.Manager,
|
||||
myIP *utils.Atomic[netip.AddrPort],
|
||||
network network.Network,
|
||||
// benchlist benchlist.Manager, // benchlist package doesn't exist
|
||||
) (http.Handler, error) {
|
||||
server := rpc.NewServer()
|
||||
codec := avajson.NewCodec()
|
||||
server.RegisterCodec(codec, "application/json")
|
||||
server.RegisterCodec(codec, "application/json;charset=UTF-8")
|
||||
return server, server.RegisterService(
|
||||
&Info{
|
||||
Parameters: parameters,
|
||||
log: log,
|
||||
validators: validators,
|
||||
chainManager: chainManager,
|
||||
vmManager: vmManager,
|
||||
myIP: myIP,
|
||||
networking: network,
|
||||
// benchlist: benchlist, // benchlist removed
|
||||
},
|
||||
"info",
|
||||
)
|
||||
}
|
||||
|
||||
func toAPIProofOfPossession(pop *signer.ProofOfPossession) (*apiinfo.ProofOfPossession, error) {
|
||||
if pop == nil {
|
||||
return nil, nil
|
||||
}
|
||||
publicKey, err := formatting.Encode(formatting.HexNC, pop.PublicKey[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proof, err := formatting.Encode(formatting.HexNC, pop.ProofOfPossession[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &apiinfo.ProofOfPossession{
|
||||
PublicKey: publicKey,
|
||||
ProofOfPossession: proof,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toP2PPeerInfo(info nodepeer.Info) p2ppeer.Info {
|
||||
return p2ppeer.Info{
|
||||
IP: info.IP,
|
||||
PublicIP: info.PublicIP,
|
||||
ID: info.ID,
|
||||
Version: info.Version,
|
||||
LastSent: info.LastSent,
|
||||
LastReceived: info.LastReceived,
|
||||
ObservedUptime: jsonrpc.Uint32(info.ObservedUptime),
|
||||
TrackedChains: info.TrackedChains,
|
||||
SupportedLPs: info.SupportedLPs,
|
||||
ObjectedLPs: info.ObjectedLPs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNodeVersion returns the version this node is running
|
||||
func (i *Info) GetNodeVersion(_ *http.Request, _ *struct{}, reply *apiinfo.GetNodeVersionReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getNodeVersion"),
|
||||
)
|
||||
|
||||
reply.Version = i.Version.String()
|
||||
reply.DatabaseVersion = version.CurrentDatabase.String()
|
||||
reply.RPCProtocolVersion = apitypes.Uint32(version.RPCChainVMProtocol)
|
||||
reply.GitCommit = version.GitCommit
|
||||
reply.VMVersions = map[string]string{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNodeID returns the node ID of this node
|
||||
func (i *Info) GetNodeID(_ *http.Request, _ *struct{}, reply *apiinfo.GetNodeIDReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getNodeID"),
|
||||
)
|
||||
|
||||
reply.NodeID = i.NodeID
|
||||
pop, err := toAPIProofOfPossession(i.NodePOP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.NodePOP = pop
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNodeIP returns the IP of this node
|
||||
func (i *Info) GetNodeIP(_ *http.Request, _ *struct{}, reply *apiinfo.GetNodeIPReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getNodeIP"),
|
||||
)
|
||||
|
||||
reply.IP = i.myIP.Get()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkID returns the network ID this node is running on
|
||||
func (i *Info) GetNetworkID(_ *http.Request, _ *struct{}, reply *apiinfo.GetNetworkIDReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getNetworkID"),
|
||||
)
|
||||
|
||||
reply.NetworkID = apitypes.Uint32(i.NetworkID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNetworkName returns the network name this node is running on
|
||||
func (i *Info) GetNetworkName(_ *http.Request, _ *struct{}, reply *apiinfo.GetNetworkNameReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getNetworkName"),
|
||||
)
|
||||
|
||||
reply.NetworkName = constants.NetworkName(i.NetworkID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBlockchainID returns the blockchain ID that resolves the alias that was supplied
|
||||
func (i *Info) GetBlockchainID(_ *http.Request, args *apiinfo.GetBlockchainIDArgs, reply *apiinfo.GetBlockchainIDReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getBlockchainID"),
|
||||
)
|
||||
|
||||
bID, err := i.chainManager.Lookup(args.Alias)
|
||||
reply.BlockchainID = bID
|
||||
return err
|
||||
}
|
||||
|
||||
// Peers returns the list of current validators
|
||||
func (i *Info) Peers(_ *http.Request, args *apiinfo.PeersArgs, reply *apiinfo.PeersReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "peers"),
|
||||
)
|
||||
|
||||
peers := i.networking.PeerInfo(args.NodeIDs)
|
||||
peerInfo := make([]apiinfo.Peer, len(peers))
|
||||
for index, peer := range peers {
|
||||
// benchlist removed
|
||||
// benchedIDs := i.benchlist.GetBenched(peer.ID)
|
||||
benchedAliases := make([]string, 0) // Empty list since benchlist is removed
|
||||
// for idx, id := range benchedIDs {
|
||||
// alias, err := i.chainManager.PrimaryAlias(id)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("failed to get primary alias for chain ID %s: %w", id, err)
|
||||
// }
|
||||
// benchedAliases[idx] = alias
|
||||
// }
|
||||
peerInfo[index] = apiinfo.Peer{
|
||||
Info: toP2PPeerInfo(peer),
|
||||
Benched: benchedAliases,
|
||||
}
|
||||
}
|
||||
|
||||
reply.Peers = peerInfo
|
||||
reply.NumPeers = apitypes.Uint64(len(reply.Peers))
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBootstrapped returns nil and sets [reply.IsBootstrapped] == true iff [args.Chain] exists and is done bootstrapping
|
||||
// Returns an error if the chain doesn't exist
|
||||
func (i *Info) IsBootstrapped(_ *http.Request, args *apiinfo.IsBootstrappedArgs, reply *apiinfo.IsBootstrappedResponse) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "isBootstrapped"),
|
||||
log.String("chain", args.Chain),
|
||||
)
|
||||
|
||||
if args.Chain == "" {
|
||||
return errNoChainProvided
|
||||
}
|
||||
chainID, err := i.chainManager.Lookup(args.Chain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("there is no chain with alias/ID '%s'", args.Chain)
|
||||
}
|
||||
reply.IsBootstrapped = i.chainManager.IsBootstrapped(chainID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upgrades returns the upgrade schedule this node is running.
|
||||
func (i *Info) Upgrades(_ *http.Request, _ *struct{}, reply *upgrade.Config) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "upgrades"),
|
||||
)
|
||||
|
||||
*reply = i.Parameters.Upgrades
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Info) Uptime(_ *http.Request, _ *struct{}, reply *apiinfo.UptimeResponse) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "uptime"),
|
||||
)
|
||||
|
||||
result, err := i.networking.NodeUptime()
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't get node uptime: %w", err)
|
||||
}
|
||||
reply.WeightedAveragePercentage = apitypes.Float64(result.WeightedAveragePercentage)
|
||||
reply.RewardingStakePercentage = apitypes.Float64(result.RewardingStakePercentage)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getLP(reply *apiinfo.LPsReply, lpNum uint32) *apiinfo.LP {
|
||||
lp, ok := reply.LPs[lpNum]
|
||||
if !ok {
|
||||
lp = &apiinfo.LP{}
|
||||
reply.LPs[lpNum] = lp
|
||||
}
|
||||
return lp
|
||||
}
|
||||
|
||||
func (i *Info) Lps(_ *http.Request, _ *struct{}, reply *apiinfo.LPsReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "lps"),
|
||||
)
|
||||
|
||||
reply.LPs = make(map[uint32]*apiinfo.LP, constants.CurrentLPs.Len())
|
||||
peers := i.networking.PeerInfo(nil)
|
||||
for _, peer := range peers {
|
||||
w := i.validators.GetWeight(constants.PrimaryNetworkID, peer.ID)
|
||||
weight := apitypes.Uint64(w)
|
||||
if weight == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// SupportedLPs and ObjectedLPs not available on peer.Info type
|
||||
// for lpNum := range peer.SupportedLPs {
|
||||
// lp := reply.getLP(lpNum)
|
||||
// lp.Supporters.Add(peer.ID)
|
||||
// lp.SupportWeight += weight
|
||||
// }
|
||||
// for lpNum := range peer.ObjectedLPs {
|
||||
// lp := reply.getLP(lpNum)
|
||||
// lp.Objectors.Add(peer.ID)
|
||||
// lp.ObjectWeight += weight
|
||||
// }
|
||||
_ = peer // Silence unused variable warning
|
||||
}
|
||||
|
||||
totalWeight, err := i.validators.TotalWeight(constants.PrimaryNetworkID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for lpNum := range constants.CurrentLPs {
|
||||
lp := getLP(reply, lpNum)
|
||||
lp.AbstainWeight = apitypes.Uint64(totalWeight) - lp.SupportWeight - lp.ObjectWeight
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTxFee returns the transaction fee in nLUX.
|
||||
func (i *Info) GetTxFee(_ *http.Request, _ *struct{}, reply *apiinfo.GetTxFeeResponse) error {
|
||||
i.log.Warn("deprecated API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getTxFee"),
|
||||
)
|
||||
|
||||
switch i.NetworkID {
|
||||
case constants.MainnetID:
|
||||
*reply = mainnetGetTxFeeResponse
|
||||
// case constants.FujiID: // FujiID not available in constants package
|
||||
// *reply = fujiGetTxFeeResponse
|
||||
default:
|
||||
*reply = defaultGetTxFeeResponse
|
||||
}
|
||||
reply.TxFee = apitypes.Uint64(i.TxFee)
|
||||
reply.CreateAssetTxFee = apitypes.Uint64(i.CreateAssetTxFee)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVMs lists the virtual machines installed on the node
|
||||
func (i *Info) GetVMs(r *http.Request, _ *struct{}, reply *apiinfo.GetVMsReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getVMs"),
|
||||
)
|
||||
|
||||
// Fetch the VMs registered on this node.
|
||||
ctx := r.Context()
|
||||
vmIDs, err := i.VMManager.ListFactories(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply.VMs = make(map[ids.ID][]string, len(vmIDs))
|
||||
for _, vmID := range vmIDs {
|
||||
alias, err := i.VMManager.PrimaryAlias(ctx, vmID)
|
||||
if err != nil || alias == vmID.String() {
|
||||
reply.VMs[vmID] = nil
|
||||
continue
|
||||
}
|
||||
reply.VMs[vmID] = []string{alias}
|
||||
}
|
||||
reply.Fxs = map[ids.ID]string{
|
||||
secp256k1fx.ID: secp256k1fx.Name,
|
||||
nftfx.ID: nftfx.Name,
|
||||
propertyfx.ID: propertyfx.Name,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChainsReply is the response for info.getChains.
|
||||
type GetChainsReply struct {
|
||||
Chains []chains.ChainInfo `json:"chains"`
|
||||
}
|
||||
|
||||
// GetChains returns the locally running chains on this node.
|
||||
// Unlike platform.getBlockchains (which returns ALL registered chains),
|
||||
// this returns only chains this node is actively tracking and serving.
|
||||
func (i *Info) GetChains(_ *http.Request, _ *struct{}, reply *GetChainsReply) error {
|
||||
i.log.Debug("API called",
|
||||
log.String("service", "info"),
|
||||
log.String("method", "getChains"),
|
||||
)
|
||||
reply.Chains = i.chainManager.GetChains()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
The Info API can be used to access basic information about a Lux node.
|
||||
|
||||
## Format
|
||||
|
||||
This API uses the `json 2.0` RPC format. For more information on making JSON RPC calls, see [here](https://docs.lux.network/docs/api-reference/guides/issuing-api-calls).
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
/ext/info
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
### `info.lps`
|
||||
|
||||
Returns peer preferences for Lux Community Proposals (LPs)
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.lps() -> {
|
||||
lps: map[uint32]{
|
||||
supportWeight: uint64
|
||||
supporters: set[string]
|
||||
objectWeight: uint64
|
||||
objectors: set[string]
|
||||
abstainWeight: uint64
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -sX POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.lps",
|
||||
"params" :{}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"lps": {
|
||||
"23": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
},
|
||||
"24": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
},
|
||||
"25": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
},
|
||||
"30": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
},
|
||||
"31": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
},
|
||||
"41": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
},
|
||||
"62": {
|
||||
"supportWeight": "0",
|
||||
"supporters": [],
|
||||
"objectWeight": "0",
|
||||
"objectors": [],
|
||||
"abstainWeight": "161147778098286584"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `info.isBootstrapped`
|
||||
|
||||
Check whether a given chain is done bootstrapping
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.isBootstrapped({chain: string}) -> {isBootstrapped: bool}
|
||||
```
|
||||
|
||||
`chain` is the ID or alias of a chain.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.isBootstrapped",
|
||||
"params": {
|
||||
"chain":"X"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"isBootstrapped": true
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getBlockchainID`
|
||||
|
||||
Given a blockchain's alias, get its ID. (See [`admin.aliasChain`](https://docs.lux.network/docs/api-reference/admin-api#adminaliaschain).)
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getBlockchainID({alias:string}) -> {blockchainID:string}
|
||||
```
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getBlockchainID",
|
||||
"params": {
|
||||
"alias":"X"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"blockchainID": "sV6o671RtkGBcno1FiaDbVcFv2sG5aVXMZYzKdP4VQAWmJQnM"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getNetworkID`
|
||||
|
||||
Get the ID of the network this node is participating in.
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getNetworkID() -> { networkID: int }
|
||||
```
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getNetworkID"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"networkID": "2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Network ID of 1 = Mainnet Network ID of 5 = Fuji (testnet)
|
||||
|
||||
### `info.getNetworkName`
|
||||
|
||||
Get the name of the network this node is participating in.
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getNetworkName() -> { networkName:string }
|
||||
```
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getNetworkName"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"networkName": "local"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getNodeID`
|
||||
|
||||
Get the ID, the BLS key, and the proof of possession(BLS signature) of this node.
|
||||
|
||||
<Callout title="Note">
|
||||
This endpoint set is for a specific node, it is unavailable on the [public server](https://docs.lux.network/docs/tooling/rpc-providers).
|
||||
</Callout>
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getNodeID() -> {
|
||||
nodeID: string,
|
||||
nodePOP: {
|
||||
publicKey: string,
|
||||
proofOfPossession: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `nodeID` Node ID is the unique identifier of the node that you set to act as a validator on the Primary Network.
|
||||
- `nodePOP` is this node's BLS key and proof of possession. Nodes must register a BLS key to act as a validator on the Primary Network. Your node's POP is logged on startup and is accessible over this endpoint.
|
||||
- `publicKey` is the 48 byte hex representation of the BLS key.
|
||||
- `proofOfPossession` is the 96 byte hex representation of the BLS signature.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getNodeID"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"nodeID": "NodeID-5mb46qkSBj81k9g9e4VFjGGSbaaSLFRzD",
|
||||
"nodePOP": {
|
||||
"publicKey": "0x8f95423f7142d00a48e1014a3de8d28907d420dc33b3052a6dee03a3f2941a393c2351e354704ca66a3fc29870282e15",
|
||||
"proofOfPossession": "0x86a3ab4c45cfe31cae34c1d06f212434ac71b1be6cfe046c80c162e057614a94a5bc9f1ded1a7029deb0ba4ca7c9b71411e293438691be79c2dbf19d1ca7c3eadb9c756246fc5de5b7b89511c7d7302ae051d9e03d7991138299b5ed6a570a98"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getNodeIP`
|
||||
|
||||
Get the IP of this node.
|
||||
|
||||
<Callout title="Note">
|
||||
This endpoint set is for a specific node, it is unavailable on the [public server](https://docs.lux.network/docs/tooling/rpc-providers).
|
||||
</Callout>
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getNodeIP() -> {ip: string}
|
||||
```
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getNodeIP"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"ip": "192.168.1.1:9631"
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getNodeVersion`
|
||||
|
||||
Get the version of this node.
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getNodeVersion() -> {
|
||||
version: string,
|
||||
databaseVersion: string,
|
||||
gitCommit: string,
|
||||
vmVersions: map[string]string,
|
||||
rpcProtocolVersion: string,
|
||||
}
|
||||
```
|
||||
|
||||
where:
|
||||
|
||||
- `version` is this node's version
|
||||
- `databaseVersion` is the version of the database this node is using
|
||||
- `gitCommit` is the Git commit that this node was built from
|
||||
- `vmVersions` is map where each key/value pair is the name of a VM, and the version of that VM this node runs
|
||||
- `rpcProtocolVersion` is the RPCChainVM protocol version
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getNodeVersion"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"version": "lux/1.9.1",
|
||||
"databaseVersion": "v1.4.5",
|
||||
"rpcProtocolVersion": "18",
|
||||
"gitCommit": "79cd09ba728e1cecef40acd60702f0a2d41ea404",
|
||||
"vmVersions": {
|
||||
"xvm": "v1.9.1",
|
||||
"evm": "v0.11.1",
|
||||
"platform": "v1.9.1"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getTxFee`
|
||||
|
||||
<Callout type="warn">
|
||||
Deprecated as of [v1.12.2](https://github.com/luxfi/node/releases/tag/v1.12.2).
|
||||
</Callout>
|
||||
|
||||
Get the fees of the network.
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getTxFee() ->
|
||||
{
|
||||
txFee: uint64,
|
||||
createAssetTxFee: uint64,
|
||||
createNetTxFee: uint64,
|
||||
transformNetTxFee: uint64,
|
||||
createBlockchainTxFee: uint64,
|
||||
addPrimaryNetworkValidatorFee: uint64,
|
||||
addPrimaryNetworkDelegatorFee: uint64,
|
||||
addNetValidatorFee: uint64,
|
||||
addNetDelegatorFee: uint64
|
||||
}
|
||||
```
|
||||
|
||||
- `txFee` is the default fee for issuing X-Chain transactions.
|
||||
- `createAssetTxFee` is the fee for issuing a `CreateAssetTx` on the X-Chain.
|
||||
- `createNetTxFee` is no longer used.
|
||||
- `transformNetTxFee` is no longer used.
|
||||
- `createBlockchainTxFee` is no longer used.
|
||||
- `addPrimaryNetworkValidatorFee` is no longer used.
|
||||
- `addPrimaryNetworkDelegatorFee` is no longer used.
|
||||
- `addNetValidatorFee` is no longer used.
|
||||
- `addNetDelegatorFee` is no longer used.
|
||||
|
||||
All fees are denominated in nLUX.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getTxFee"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"txFee": "1000000",
|
||||
"createAssetTxFee": "10000000",
|
||||
"createNetTxFee": "1000000000",
|
||||
"transformNetTxFee": "10000000000",
|
||||
"createBlockchainTxFee": "1000000000",
|
||||
"addPrimaryNetworkValidatorFee": "0",
|
||||
"addPrimaryNetworkDelegatorFee": "0",
|
||||
"addNetValidatorFee": "1000000",
|
||||
"addNetDelegatorFee": "1000000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `info.getVMs`
|
||||
|
||||
Get the virtual machines installed on this node.
|
||||
|
||||
<Callout title="Note">
|
||||
This endpoint set is for a specific node, it is unavailable on the [public server](https://docs.lux.network/docs/tooling/rpc-providers).
|
||||
</Callout>
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.getVMs() -> {
|
||||
vms: map[string][]string
|
||||
}
|
||||
```
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.getVMs",
|
||||
"params" :{}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"vms": {
|
||||
"jvYyfQTxGMJLuGWa55kdP2p2zSUYsQ5Raupu4TW34ZAUBAbtq": ["xvm"],
|
||||
"mgj786NP7uDwBCcq6YwThhaN8FLyybkCa4zBWTQbNgmK6k9A6": ["evm"],
|
||||
"qd2U4HDWUvMrVUeTcCHp6xH3Qpnn1XbU5MDdnBoiifFqvgXwT": ["nftfx"],
|
||||
"rWhpuQPF1kb72esV2momhMuTYGkEb1oL29pt2EBXWmSy4kxnT": ["platform"],
|
||||
"rXJsCSEYXg2TehWxCEEGj6JU2PWKTkd6cBdNLjoe2SpsKD9cy": ["propertyfx"],
|
||||
"spdxUxVJQbX85MGxMHbKw1sHxMnSqJ3QBzDyDYEP3h6TLuxqQ": ["secp256k1fx"]
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### `info.peers`
|
||||
|
||||
Get a description of peer connections.
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.peers({
|
||||
nodeIDs: string[] // optional
|
||||
}) ->
|
||||
{
|
||||
numPeers: int,
|
||||
peers:[]{
|
||||
ip: string,
|
||||
publicIP: string,
|
||||
nodeID: string,
|
||||
version: string,
|
||||
lastSent: string,
|
||||
lastReceived: string,
|
||||
benched: string[],
|
||||
observedUptime: int,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `nodeIDs` is an optional parameter to specify what NodeID's descriptions should be returned. If this parameter is left empty, descriptions for all active connections will be returned. If the node is not connected to a specified NodeID, it will be omitted from the response.
|
||||
- `ip` is the remote IP of the peer.
|
||||
- `publicIP` is the public IP of the peer.
|
||||
- `nodeID` is the prefixed Node ID of the peer.
|
||||
- `version` shows which version the peer runs on.
|
||||
- `lastSent` is the timestamp of last message sent to the peer.
|
||||
- `lastReceived` is the timestamp of last message received from the peer.
|
||||
- `benched` shows chain IDs that the peer is currently benched on.
|
||||
- `observedUptime` is this node's primary network uptime, observed by the peer.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.peers",
|
||||
"params": {
|
||||
"nodeIDs": []
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"numPeers": 3,
|
||||
"peers": [
|
||||
{
|
||||
"ip": "206.189.137.87:9631",
|
||||
"publicIP": "206.189.137.87:9631",
|
||||
"nodeID": "NodeID-8PYXX47kqLDe2wD4oPbvRRchcnSzMA4J4",
|
||||
"version": "lux/1.9.4",
|
||||
"lastSent": "2020-06-01T15:23:02Z",
|
||||
"lastReceived": "2020-06-01T15:22:57Z",
|
||||
"benched": [],
|
||||
"observedUptime": "99",
|
||||
"trackedNets": [],
|
||||
"benched": []
|
||||
},
|
||||
{
|
||||
"ip": "158.255.67.151:9631",
|
||||
"publicIP": "158.255.67.151:9631",
|
||||
"nodeID": "NodeID-C14fr1n8EYNKyDfYixJ3rxSAVqTY3a8BP",
|
||||
"version": "lux/1.9.4",
|
||||
"lastSent": "2020-06-01T15:23:02Z",
|
||||
"lastReceived": "2020-06-01T15:22:34Z",
|
||||
"benched": [],
|
||||
"observedUptime": "75",
|
||||
"trackedNets": [
|
||||
"29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL"
|
||||
],
|
||||
"benched": []
|
||||
},
|
||||
{
|
||||
"ip": "83.42.13.44:9631",
|
||||
"publicIP": "83.42.13.44:9631",
|
||||
"nodeID": "NodeID-LPbcSMGJ4yocxYxvS2kBJ6umWeeFbctYZ",
|
||||
"version": "lux/1.9.3",
|
||||
"lastSent": "2020-06-01T15:23:02Z",
|
||||
"lastReceived": "2020-06-01T15:22:55Z",
|
||||
"benched": [],
|
||||
"observedUptime": "95",
|
||||
"trackedNets": [],
|
||||
"benched": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `info.uptime`
|
||||
|
||||
Returns the network's observed uptime of this node. This is the only reliable source of data for your node's uptime. Other sources may be using data gathered with incomplete (limited) information.
|
||||
|
||||
**Signature**:
|
||||
|
||||
```
|
||||
info.uptime() ->
|
||||
{
|
||||
rewardingStakePercentage: float64,
|
||||
weightedAveragePercentage: float64
|
||||
}
|
||||
```
|
||||
|
||||
- `rewardingStakePercentage` is the percent of stake which thinks this node is above the uptime requirement.
|
||||
- `weightedAveragePercentage` is the stake-weighted average of all observed uptimes for this node.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.uptime"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"rewardingStakePercentage": "100.0000",
|
||||
"weightedAveragePercentage": "99.0000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example Lux L1 Call
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.uptime",
|
||||
"params" :{
|
||||
"netID":"29uVeLPJB1eQJkzRemU8g8wZDw5uJRqpab5U2mX9euieVwiEbL"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/info
|
||||
```
|
||||
|
||||
#### Example Lux L1 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"rewardingStakePercentage": "74.0741",
|
||||
"weightedAveragePercentage": "72.4074"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `info.upgrades`
|
||||
|
||||
Returns the upgrade history and configuration of the network.
|
||||
|
||||
**Example Call**:
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"info.upgrades"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9650/ext/info
|
||||
```
|
||||
|
||||
**Example Response**:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"apricotPhase1Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhase2Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhase3Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhase4Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhase4MinPChainHeight": 0,
|
||||
"apricotPhase5Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhasePre6Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhase6Time": "2020-12-05T05:00:00Z",
|
||||
"apricotPhasePost6Time": "2020-12-05T05:00:00Z",
|
||||
"banffTime": "2020-12-05T05:00:00Z",
|
||||
"cortinaTime": "2020-12-05T05:00:00Z",
|
||||
"cortinaXChainStopVertexID": "11111111111111111111111111111111LpoYY",
|
||||
"durangoTime": "2020-12-05T05:00:00Z",
|
||||
"etnaTime": "2025-10-09T20:00:00Z",
|
||||
"fortunaTime": "9999-12-01T05:00:00Z",
|
||||
"graniteTime": "9999-12-01T05:00:00Z"
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package info
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/mock/gomock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
apiinfo "github.com/luxfi/api/info"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/vms/vmsmock"
|
||||
)
|
||||
|
||||
var errTest = errors.New("non-nil error")
|
||||
|
||||
type getVMsTest struct {
|
||||
info *Info
|
||||
mockVMManager *vmsmock.Manager
|
||||
}
|
||||
|
||||
func initGetVMsTest(t *testing.T) *getVMsTest {
|
||||
ctrl := gomock.NewController(t)
|
||||
mockVMManager := vmsmock.NewManager(ctrl)
|
||||
return &getVMsTest{
|
||||
info: &Info{
|
||||
Parameters: Parameters{
|
||||
VMManager: mockVMManager,
|
||||
},
|
||||
log: log.NewNoOpLogger(),
|
||||
},
|
||||
mockVMManager: mockVMManager,
|
||||
}
|
||||
}
|
||||
|
||||
// Tests GetVMs in the happy-case
|
||||
func TestGetVMsSuccess(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
resources := initGetVMsTest(t)
|
||||
|
||||
id1 := ids.GenerateTestID()
|
||||
id2 := ids.GenerateTestID()
|
||||
|
||||
vmIDs := []ids.ID{id1, id2}
|
||||
alias1 := "vm1-alias-1"
|
||||
alias2 := "vm2-alias-1"
|
||||
// Primary alias should be the only returned alias.
|
||||
expectedVMRegistry := map[ids.ID][]string{
|
||||
id1: []string{alias1},
|
||||
id2: []string{alias2},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/ext/info", nil)
|
||||
resources.mockVMManager.EXPECT().ListFactories(req.Context()).Times(1).Return(vmIDs, nil)
|
||||
resources.mockVMManager.EXPECT().PrimaryAlias(req.Context(), id1).Times(1).Return(alias1, nil)
|
||||
resources.mockVMManager.EXPECT().PrimaryAlias(req.Context(), id2).Times(1).Return(alias2, nil)
|
||||
|
||||
reply := apiinfo.GetVMsReply{}
|
||||
require.NoError(resources.info.GetVMs(req, nil, &reply))
|
||||
require.Equal(expectedVMRegistry, reply.VMs)
|
||||
}
|
||||
|
||||
// Tests GetVMs if we fail to list our vms.
|
||||
func TestGetVMsVMsListFactoriesFails(t *testing.T) {
|
||||
resources := initGetVMsTest(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/ext/info", nil)
|
||||
resources.mockVMManager.EXPECT().ListFactories(req.Context()).Times(1).Return(nil, errTest)
|
||||
|
||||
reply := apiinfo.GetVMsReply{}
|
||||
err := resources.info.GetVMs(req, nil, &reply)
|
||||
require.ErrorIs(t, err, errTest)
|
||||
}
|
||||
|
||||
// Tests GetVMs when a VM alias lookup fails.
|
||||
func TestGetVMsGetAliasesFails(t *testing.T) {
|
||||
require := require.New(t)
|
||||
resources := initGetVMsTest(t)
|
||||
|
||||
id1 := ids.GenerateTestID()
|
||||
id2 := ids.GenerateTestID()
|
||||
vmIDs := []ids.ID{id1, id2}
|
||||
alias1 := "vm1-alias-1"
|
||||
|
||||
req := httptest.NewRequest("POST", "/ext/info", nil)
|
||||
resources.mockVMManager.EXPECT().ListFactories(req.Context()).Times(1).Return(vmIDs, nil)
|
||||
resources.mockVMManager.EXPECT().PrimaryAlias(req.Context(), id1).Times(1).Return(alias1, nil)
|
||||
resources.mockVMManager.EXPECT().PrimaryAlias(req.Context(), id2).Times(1).Return("", errTest)
|
||||
|
||||
reply := apiinfo.GetVMsReply{}
|
||||
err := resources.info.GetVMs(req, nil, &reply)
|
||||
require.NoError(err)
|
||||
require.Equal(map[ids.ID][]string{
|
||||
id1: []string{alias1},
|
||||
id2: nil,
|
||||
}, reply.VMs)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"github.com/luxfi/log"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/encdb"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
var _ BlockchainKeystore = (*blockchainKeystore)(nil)
|
||||
|
||||
type BlockchainKeystore interface {
|
||||
// Get a database that is able to read and write unencrypted values from the
|
||||
// underlying database.
|
||||
GetDatabase(username, password string) (*encdb.Database, error)
|
||||
|
||||
// Get the underlying database that is able to read and write encrypted
|
||||
// values. This Database will not perform any encrypting or decrypting of
|
||||
// values and is not recommended to be used when implementing a VM.
|
||||
GetRawDatabase(username, password string) (database.Database, error)
|
||||
}
|
||||
|
||||
type blockchainKeystore struct {
|
||||
blockchainID ids.ID
|
||||
ks *keystore
|
||||
}
|
||||
|
||||
func (bks *blockchainKeystore) GetDatabase(username, password string) (*encdb.Database, error) {
|
||||
bks.ks.log.Warn("deprecated keystore called",
|
||||
log.String("method", "getDatabase"),
|
||||
log.String("username", username),
|
||||
log.Stringer("blockchainID", bks.blockchainID),
|
||||
)
|
||||
|
||||
return bks.ks.GetDatabase(bks.blockchainID, username, password)
|
||||
}
|
||||
|
||||
func (bks *blockchainKeystore) GetRawDatabase(username, password string) (database.Database, error) {
|
||||
bks.ks.log.Warn("deprecated keystore called",
|
||||
log.String("method", "getRawDatabase"),
|
||||
log.String("username", username),
|
||||
log.Stringer("blockchainID", bks.blockchainID),
|
||||
)
|
||||
|
||||
return bks.ks.GetRawDatabase(bks.blockchainID, username, password)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/luxfi/formatting"
|
||||
apitypes "github.com/luxfi/api/types"
|
||||
"github.com/luxfi/rpc"
|
||||
)
|
||||
|
||||
var _ Client = (*client)(nil)
|
||||
|
||||
// Client interface for Lux Keystore API Endpoint
|
||||
//
|
||||
// Deprecated: The Keystore API is deprecated. Dedicated wallets should be used
|
||||
// instead.
|
||||
type Client interface {
|
||||
CreateUser(context.Context, apitypes.UserPass, ...rpc.Option) error
|
||||
// Returns the usernames of all keystore users
|
||||
ListUsers(context.Context, ...rpc.Option) ([]string, error)
|
||||
// Returns the byte representation of the given user
|
||||
ExportUser(context.Context, apitypes.UserPass, ...rpc.Option) ([]byte, error)
|
||||
// Import [exportedUser] to [importTo]
|
||||
ImportUser(ctx context.Context, importTo apitypes.UserPass, exportedUser []byte, options ...rpc.Option) error
|
||||
// Delete the given user
|
||||
DeleteUser(context.Context, apitypes.UserPass, ...rpc.Option) error
|
||||
}
|
||||
|
||||
// Client implementation for Lux Keystore API Endpoint
|
||||
type client struct {
|
||||
requester rpc.EndpointRequester
|
||||
}
|
||||
|
||||
// Deprecated: The Keystore API is deprecated. Dedicated wallets should be used
|
||||
// instead.
|
||||
func NewClient(uri string) Client {
|
||||
return &client{requester: rpc.NewEndpointRequester(
|
||||
uri + "/ext/keystore",
|
||||
)}
|
||||
}
|
||||
|
||||
func (c *client) CreateUser(ctx context.Context, user apitypes.UserPass, options ...rpc.Option) error {
|
||||
return c.requester.SendRequest(ctx, "keystore.createUser", &user, &apitypes.EmptyReply{}, options...)
|
||||
}
|
||||
|
||||
func (c *client) ListUsers(ctx context.Context, options ...rpc.Option) ([]string, error) {
|
||||
res := &ListUsersReply{}
|
||||
err := c.requester.SendRequest(ctx, "keystore.listUsers", struct{}{}, res, options...)
|
||||
return res.Users, err
|
||||
}
|
||||
|
||||
func (c *client) ExportUser(ctx context.Context, user apitypes.UserPass, options ...rpc.Option) ([]byte, error) {
|
||||
res := &ExportUserReply{
|
||||
Encoding: formatting.Hex,
|
||||
}
|
||||
err := c.requester.SendRequest(ctx, "keystore.exportUser", &user, res, options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return formatting.Decode(res.Encoding, res.User)
|
||||
}
|
||||
|
||||
func (c *client) ImportUser(ctx context.Context, user apitypes.UserPass, account []byte, options ...rpc.Option) error {
|
||||
accountStr, err := formatting.Encode(formatting.Hex, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.requester.SendRequest(ctx, "keystore.importUser", &ImportUserArgs{
|
||||
UserPass: user,
|
||||
User: accountStr,
|
||||
Encoding: formatting.Hex,
|
||||
}, &apitypes.EmptyReply{}, options...)
|
||||
}
|
||||
|
||||
func (c *client) DeleteUser(ctx context.Context, user apitypes.UserPass, options ...rpc.Option) error {
|
||||
return c.requester.SendRequest(ctx, "keystore.deleteUser", &user, &apitypes.EmptyReply{}, options...)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"github.com/luxfi/codec"
|
||||
"github.com/luxfi/codec/linearcodec"
|
||||
)
|
||||
|
||||
const (
|
||||
CodecVersion = 0
|
||||
|
||||
// maxPackerSize caps the size of any single keystore codec payload.
|
||||
// Real user keystores are well under 1 MiB; the previous 1 GiB ceiling
|
||||
// was an OOM vector for authenticated RPC callers. 16 MiB is generous
|
||||
// for any plausible future wallet/seed container while bounding
|
||||
// server-side allocation to a non-pathological size.
|
||||
// See papers/oom-audit-2026-04-12.tex F-4.
|
||||
maxPackerSize = 16 * 1024 * 1024 // 16 MiB
|
||||
)
|
||||
|
||||
var Codec codec.Manager
|
||||
|
||||
func init() {
|
||||
lc := linearcodec.NewDefault()
|
||||
Codec = codec.NewManager(maxPackerSize)
|
||||
if err := Codec.RegisterCodec(CodecVersion, lc); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package gkeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/encdb"
|
||||
"github.com/luxfi/node/service/keystore"
|
||||
"github.com/luxfi/node/internal/database/rpcdb"
|
||||
"github.com/luxfi/vm/rpc/grpcutils"
|
||||
|
||||
keystorepb "github.com/luxfi/node/proto/pb/keystore"
|
||||
rpcdbpb "github.com/luxfi/node/proto/pb/rpcdb"
|
||||
)
|
||||
|
||||
var _ keystore.BlockchainKeystore = (*Client)(nil)
|
||||
|
||||
// Client is a consensus.Keystore that talks over RPC.
|
||||
type Client struct {
|
||||
client keystorepb.KeystoreClient
|
||||
}
|
||||
|
||||
// NewClient returns a keystore instance connected to a remote keystore instance
|
||||
func NewClient(client keystorepb.KeystoreClient) *Client {
|
||||
return &Client{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetDatabase(username, password string) (*encdb.Database, error) {
|
||||
bcDB, err := c.GetRawDatabase(username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encdb.New([]byte(password), bcDB)
|
||||
}
|
||||
|
||||
func (c *Client) GetRawDatabase(username, password string) (database.Database, error) {
|
||||
resp, err := c.client.GetDatabase(context.Background(), &keystorepb.GetDatabaseRequest{
|
||||
Username: username,
|
||||
Password: password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientConn, err := grpcutils.Dial(resp.ServerAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbClient := rpcdb.NewClient(rpcdbpb.NewDatabaseClient(clientConn))
|
||||
return dbClient, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build grpc
|
||||
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package gkeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/luxfi/database"
|
||||
|
||||
"github.com/luxfi/node/service/keystore"
|
||||
"github.com/luxfi/vm/rpc/grpcutils"
|
||||
|
||||
keystorepb "github.com/luxfi/node/proto/pb/keystore"
|
||||
)
|
||||
|
||||
var _ keystorepb.KeystoreServer = (*Server)(nil)
|
||||
|
||||
// Server is a consensus.Keystore that is managed over RPC.
|
||||
type Server struct {
|
||||
keystorepb.UnsafeKeystoreServer
|
||||
ks keystore.BlockchainKeystore
|
||||
}
|
||||
|
||||
// NewServer returns a keystore connected to a remote keystore
|
||||
func NewServer(ks keystore.BlockchainKeystore) *Server {
|
||||
return &Server{
|
||||
ks: ks,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) GetDatabase(
|
||||
_ context.Context,
|
||||
req *keystorepb.GetDatabaseRequest,
|
||||
) (*keystorepb.GetDatabaseResponse, error) {
|
||||
db, err := s.ks.GetRawDatabase(req.Username, req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
closer := dbCloser{Database: db}
|
||||
|
||||
serverListener, err := grpcutils.NewListener()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server := grpcutils.NewServer()
|
||||
closer.closer.Add(server)
|
||||
|
||||
// start the db server
|
||||
go grpcutils.Serve(serverListener, server)
|
||||
|
||||
return &keystorepb.GetDatabaseResponse{ServerAddr: serverListener.Addr().String()}, nil
|
||||
}
|
||||
|
||||
type dbCloser struct {
|
||||
database.Database
|
||||
closer grpcutils.ServerCloser
|
||||
}
|
||||
|
||||
func (db *dbCloser) Close() error {
|
||||
err := db.Database.Close()
|
||||
db.closer.Stop()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/rpc/v2"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/encdb"
|
||||
"github.com/luxfi/database/prefixdb"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
"github.com/luxfi/node/utils/json"
|
||||
"github.com/luxfi/node/utils/password"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxUserLen is the maximum allowed length of a username
|
||||
maxUserLen = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyUsername = errors.New("empty username")
|
||||
errUserMaxLength = fmt.Errorf("username exceeds maximum length of %d chars", maxUserLen)
|
||||
errUserAlreadyExists = errors.New("user already exists")
|
||||
errIncorrectPassword = errors.New("incorrect password")
|
||||
errNonexistentUser = errors.New("user doesn't exist")
|
||||
|
||||
usersPrefix = []byte("users")
|
||||
bcsPrefix = []byte("bcs")
|
||||
|
||||
_ Keystore = (*keystore)(nil)
|
||||
)
|
||||
|
||||
type Keystore interface {
|
||||
// Create the API endpoint for this keystore.
|
||||
CreateHandler() (http.Handler, error)
|
||||
|
||||
// NewBlockchainKeyStore returns this keystore limiting the functionality to
|
||||
// a single blockchain database.
|
||||
NewBlockchainKeyStore(blockchainID ids.ID) BlockchainKeystore
|
||||
|
||||
// Get a database that is able to read and write unencrypted values from the
|
||||
// underlying database.
|
||||
GetDatabase(bID ids.ID, username, password string) (*encdb.Database, error)
|
||||
|
||||
// Get the underlying database that is able to read and write encrypted
|
||||
// values. This Database will not perform any encrypting or decrypting of
|
||||
// values and is not recommended to be used when implementing a VM.
|
||||
GetRawDatabase(bID ids.ID, username, password string) (database.Database, error)
|
||||
|
||||
// CreateUser attempts to register this username and password as a new user
|
||||
// of the keystore.
|
||||
CreateUser(username, pw string) error
|
||||
|
||||
// DeleteUser attempts to remove the provided username and all of its data
|
||||
// from the keystore.
|
||||
DeleteUser(username, pw string) error
|
||||
|
||||
// ListUsers returns all the users that currently exist in this keystore.
|
||||
ListUsers() ([]string, error)
|
||||
|
||||
// ImportUser imports a serialized encoding of a user's information complete
|
||||
// with encrypted database values. The password is integrity checked.
|
||||
ImportUser(username, pw string, user []byte) error
|
||||
|
||||
// ExportUser exports a serialized encoding of a user's information complete
|
||||
// with encrypted database values.
|
||||
ExportUser(username, pw string) ([]byte, error)
|
||||
|
||||
// Get the password that is used by [username]. If [username] doesn't exist,
|
||||
// no error is returned and a nil password hash is returned.
|
||||
getPassword(username string) (*password.Hash, error)
|
||||
}
|
||||
|
||||
type kvPair struct {
|
||||
Key []byte `serialize:"true"`
|
||||
Value []byte `serialize:"true"`
|
||||
}
|
||||
|
||||
// user describes the full content of a user
|
||||
type user struct {
|
||||
password.Hash `serialize:"true"`
|
||||
Data []kvPair `serialize:"true"`
|
||||
}
|
||||
|
||||
type keystore struct {
|
||||
lock sync.Mutex
|
||||
log log.Logger
|
||||
|
||||
// Key: username
|
||||
// Value: The hash of that user's password
|
||||
usernameToPassword map[string]*password.Hash
|
||||
|
||||
// Used to persist users and their data
|
||||
userDB database.Database
|
||||
bcDB database.Database
|
||||
}
|
||||
|
||||
func New(log log.Logger, db database.Database) Keystore {
|
||||
return &keystore{
|
||||
log: log,
|
||||
usernameToPassword: make(map[string]*password.Hash),
|
||||
userDB: prefixdb.New(usersPrefix, db),
|
||||
bcDB: prefixdb.New(bcsPrefix, db),
|
||||
}
|
||||
}
|
||||
|
||||
func (ks *keystore) CreateHandler() (http.Handler, error) {
|
||||
newServer := rpc.NewServer()
|
||||
codec := json.NewCodec()
|
||||
newServer.RegisterCodec(codec, "application/json")
|
||||
newServer.RegisterCodec(codec, "application/json;charset=UTF-8")
|
||||
if err := newServer.RegisterService(&service{ks: ks}, "keystore"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newServer, nil
|
||||
}
|
||||
|
||||
func (ks *keystore) NewBlockchainKeyStore(blockchainID ids.ID) BlockchainKeystore {
|
||||
return &blockchainKeystore{
|
||||
blockchainID: blockchainID,
|
||||
ks: ks,
|
||||
}
|
||||
}
|
||||
|
||||
func (ks *keystore) GetDatabase(bID ids.ID, username, password string) (*encdb.Database, error) {
|
||||
bcDB, err := ks.GetRawDatabase(bID, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encdb.New([]byte(password), bcDB)
|
||||
}
|
||||
|
||||
func (ks *keystore) GetRawDatabase(bID ids.ID, username, pw string) (database.Database, error) {
|
||||
if username == "" {
|
||||
return nil, errEmptyUsername
|
||||
}
|
||||
|
||||
ks.lock.Lock()
|
||||
defer ks.lock.Unlock()
|
||||
|
||||
passwordHash, err := ks.getPassword(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if passwordHash == nil || !passwordHash.Check(pw) {
|
||||
return nil, fmt.Errorf("%w: user %q", errIncorrectPassword, username)
|
||||
}
|
||||
|
||||
userDB := prefixdb.New([]byte(username), ks.bcDB)
|
||||
bcDB := prefixdb.NewNested(bID[:], userDB)
|
||||
return bcDB, nil
|
||||
}
|
||||
|
||||
func (ks *keystore) CreateUser(username, pw string) error {
|
||||
if username == "" {
|
||||
return errEmptyUsername
|
||||
}
|
||||
if len(username) > maxUserLen {
|
||||
return errUserMaxLength
|
||||
}
|
||||
|
||||
ks.lock.Lock()
|
||||
defer ks.lock.Unlock()
|
||||
|
||||
passwordHash, err := ks.getPassword(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if passwordHash != nil {
|
||||
return fmt.Errorf("%w: %s", errUserAlreadyExists, username)
|
||||
}
|
||||
|
||||
if err := password.IsValid(pw, password.OK); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
passwordHash = &password.Hash{}
|
||||
if err := passwordHash.Set(pw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
passwordBytes, err := Codec.Marshal(CodecVersion, passwordHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ks.userDB.Put([]byte(username), passwordBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
ks.usernameToPassword[username] = passwordHash
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ks *keystore) DeleteUser(username, pw string) error {
|
||||
if username == "" {
|
||||
return errEmptyUsername
|
||||
}
|
||||
if len(username) > maxUserLen {
|
||||
return errUserMaxLength
|
||||
}
|
||||
|
||||
ks.lock.Lock()
|
||||
defer ks.lock.Unlock()
|
||||
|
||||
// check if user exists and valid user.
|
||||
passwordHash, err := ks.getPassword(username)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case passwordHash == nil:
|
||||
return fmt.Errorf("%w: %s", errNonexistentUser, username)
|
||||
case !passwordHash.Check(pw):
|
||||
return fmt.Errorf("%w: user %q", errIncorrectPassword, username)
|
||||
}
|
||||
|
||||
userNameBytes := []byte(username)
|
||||
userBatch := ks.userDB.NewBatch()
|
||||
if err := userBatch.Delete(userNameBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userDataDB := prefixdb.New(userNameBytes, ks.bcDB)
|
||||
dataBatch := userDataDB.NewBatch()
|
||||
|
||||
it := userDataDB.NewIterator()
|
||||
defer it.Release()
|
||||
|
||||
for it.Next() {
|
||||
if err := dataBatch.Delete(it.Key()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := it.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := atomic.WriteAll(dataBatch, userBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete from users map.
|
||||
delete(ks.usernameToPassword, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ks *keystore) ListUsers() ([]string, error) {
|
||||
users := []string{}
|
||||
|
||||
ks.lock.Lock()
|
||||
defer ks.lock.Unlock()
|
||||
|
||||
it := ks.userDB.NewIterator()
|
||||
defer it.Release()
|
||||
for it.Next() {
|
||||
users = append(users, string(it.Key()))
|
||||
}
|
||||
return users, it.Error()
|
||||
}
|
||||
|
||||
func (ks *keystore) ImportUser(username, pw string, userBytes []byte) error {
|
||||
if username == "" {
|
||||
return errEmptyUsername
|
||||
}
|
||||
if len(username) > maxUserLen {
|
||||
return errUserMaxLength
|
||||
}
|
||||
|
||||
ks.lock.Lock()
|
||||
defer ks.lock.Unlock()
|
||||
|
||||
passwordHash, err := ks.getPassword(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if passwordHash != nil {
|
||||
return fmt.Errorf("%w: %s", errUserAlreadyExists, username)
|
||||
}
|
||||
|
||||
userData := user{}
|
||||
if _, err := Codec.Unmarshal(userBytes, &userData); err != nil {
|
||||
return err
|
||||
}
|
||||
if !userData.Hash.Check(pw) {
|
||||
return fmt.Errorf("%w: user %q", errIncorrectPassword, username)
|
||||
}
|
||||
|
||||
usrBytes, err := Codec.Marshal(CodecVersion, &userData.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userBatch := ks.userDB.NewBatch()
|
||||
if err := userBatch.Put([]byte(username), usrBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userDataDB := prefixdb.New([]byte(username), ks.bcDB)
|
||||
dataBatch := userDataDB.NewBatch()
|
||||
for _, kvp := range userData.Data {
|
||||
if err := dataBatch.Put(kvp.Key, kvp.Value); err != nil {
|
||||
return fmt.Errorf("error on database put: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := atomic.WriteAll(dataBatch, userBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
ks.usernameToPassword[username] = &userData.Hash
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ks *keystore) ExportUser(username, pw string) ([]byte, error) {
|
||||
if username == "" {
|
||||
return nil, errEmptyUsername
|
||||
}
|
||||
if len(username) > maxUserLen {
|
||||
return nil, errUserMaxLength
|
||||
}
|
||||
|
||||
ks.lock.Lock()
|
||||
defer ks.lock.Unlock()
|
||||
|
||||
passwordHash, err := ks.getPassword(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if passwordHash == nil || !passwordHash.Check(pw) {
|
||||
return nil, fmt.Errorf("%w: user %q", errIncorrectPassword, username)
|
||||
}
|
||||
|
||||
userDB := prefixdb.New([]byte(username), ks.bcDB)
|
||||
|
||||
userData := user{Hash: *passwordHash}
|
||||
it := userDB.NewIterator()
|
||||
defer it.Release()
|
||||
for it.Next() {
|
||||
userData.Data = append(userData.Data, kvPair{
|
||||
Key: it.Key(),
|
||||
Value: it.Value(),
|
||||
})
|
||||
}
|
||||
if err := it.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return the byte representation of the user
|
||||
return Codec.Marshal(CodecVersion, &userData)
|
||||
}
|
||||
|
||||
func (ks *keystore) getPassword(username string) (*password.Hash, error) {
|
||||
// If the user is already in memory, return it
|
||||
passwordHash, exists := ks.usernameToPassword[username]
|
||||
if exists {
|
||||
return passwordHash, nil
|
||||
}
|
||||
|
||||
// The user is not in memory; try the database
|
||||
userBytes, err := ks.userDB.Get([]byte(username))
|
||||
if err == database.ErrNotFound {
|
||||
// The user doesn't exist
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
// An unexpected database error occurred
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwordHash = &password.Hash{}
|
||||
_, err = Codec.Unmarshal(userBytes, passwordHash)
|
||||
return passwordHash, err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/luxfi/formatting"
|
||||
"github.com/luxfi/log"
|
||||
apitypes "github.com/luxfi/api/types"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
ks *keystore
|
||||
}
|
||||
|
||||
func (s *service) CreateUser(_ *http.Request, args *apitypes.UserPass, _ *apitypes.EmptyReply) error {
|
||||
s.ks.log.Warn("deprecated API called",
|
||||
log.String("service", "keystore"),
|
||||
log.String("method", "createUser"),
|
||||
log.String("username", args.Username),
|
||||
)
|
||||
|
||||
return s.ks.CreateUser(args.Username, args.Password)
|
||||
}
|
||||
|
||||
func (s *service) DeleteUser(_ *http.Request, args *apitypes.UserPass, _ *apitypes.EmptyReply) error {
|
||||
s.ks.log.Warn("deprecated API called",
|
||||
log.String("service", "keystore"),
|
||||
log.String("method", "deleteUser"),
|
||||
"username", args.Username,
|
||||
)
|
||||
|
||||
return s.ks.DeleteUser(args.Username, args.Password)
|
||||
}
|
||||
|
||||
type ListUsersReply struct {
|
||||
Users []string `json:"users"`
|
||||
}
|
||||
|
||||
func (s *service) ListUsers(_ *http.Request, _ *struct{}, reply *ListUsersReply) error {
|
||||
s.ks.log.Warn("deprecated API called",
|
||||
log.String("service", "keystore"),
|
||||
log.String("method", "listUsers"),
|
||||
)
|
||||
|
||||
var err error
|
||||
reply.Users, err = s.ks.ListUsers()
|
||||
return err
|
||||
}
|
||||
|
||||
type ImportUserArgs struct {
|
||||
// The username and password of the user being imported
|
||||
apitypes.UserPass
|
||||
// The string representation of the user
|
||||
User string `json:"user"`
|
||||
// The encoding of [User] ("hex")
|
||||
Encoding formatting.Encoding `json:"encoding"`
|
||||
}
|
||||
|
||||
func (s *service) ImportUser(_ *http.Request, args *ImportUserArgs, _ *apitypes.EmptyReply) error {
|
||||
s.ks.log.Warn("deprecated API called",
|
||||
log.String("service", "keystore"),
|
||||
log.String("method", "importUser"),
|
||||
"username", args.Username,
|
||||
)
|
||||
|
||||
// Decode the user from string to bytes
|
||||
user, err := formatting.Decode(args.Encoding, args.User)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't decode 'user' to bytes: %w", err)
|
||||
}
|
||||
|
||||
return s.ks.ImportUser(args.Username, args.Password, user)
|
||||
}
|
||||
|
||||
type ExportUserArgs struct {
|
||||
// The username and password
|
||||
apitypes.UserPass
|
||||
// The encoding for the exported user ("hex")
|
||||
Encoding formatting.Encoding `json:"encoding"`
|
||||
}
|
||||
|
||||
type ExportUserReply struct {
|
||||
// String representation of the user
|
||||
User string `json:"user"`
|
||||
// The encoding for the exported user ("hex")
|
||||
Encoding formatting.Encoding `json:"encoding"`
|
||||
}
|
||||
|
||||
func (s *service) ExportUser(_ *http.Request, args *ExportUserArgs, reply *ExportUserReply) error {
|
||||
s.ks.log.Warn("deprecated API called",
|
||||
log.String("service", "keystore"),
|
||||
log.String("method", "exportUser"),
|
||||
"username", args.Username,
|
||||
)
|
||||
|
||||
userBytes, err := s.ks.ExportUser(args.Username, args.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encode the user from bytes to string
|
||||
reply.User, err = formatting.Encode(args.Encoding, userBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't encode user to string: %w", err)
|
||||
}
|
||||
reply.Encoding = args.Encoding
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
tags: [Lux Node APIs]
|
||||
description: This page is an overview of the Keystore API associated with Lux Node.
|
||||
sidebar_label: Keystore API
|
||||
pagination_label: Keystore API
|
||||
---
|
||||
|
||||
# Keystore API
|
||||
|
||||
:::warning
|
||||
Because the node operator has access to your plain-text password, you should only create a
|
||||
keystore user on a node that you operate. If that node is breached, you could lose all your tokens.
|
||||
Keystore APIs are not recommended for use on Mainnet.
|
||||
:::
|
||||
|
||||
Every node has a built-in keystore. Clients create users on the keystore, which act as identities to
|
||||
be used when interacting with blockchains. A keystore exists at the node level, so if you create a
|
||||
user on a node it exists _only_ on that node. However, users may be imported and exported using this
|
||||
API.
|
||||
|
||||
For validation and cross-chain transfer on the Mainnet, you should issue transactions through
|
||||
[LuxJS](/tooling/luxjs-overview). That way control keys for your funds won't be stored on
|
||||
the node, which significantly lowers the risk should a computer running a node be compromised. See
|
||||
following docs for details:
|
||||
|
||||
- Transfer LUX Tokens Between Chains:
|
||||
|
||||
- C-Chain: [export](https://github.com/luxfi/luxjs/blob/master/examples/c-chain/export.ts) and
|
||||
[import](https://github.com/luxfi/luxjs/blob/master/examples/c-chain/import.ts)
|
||||
- P-Chain: [export](https://github.com/luxfi/luxjs/blob/master/examples/p-chain/export.ts) and
|
||||
[import](https://github.com/luxfi/luxjs/blob/master/examples/p-chain/import.ts)
|
||||
- X-Chain: [export](https://github.com/luxfi/luxjs/blob/master/examples/x-chain/export.ts) and
|
||||
[import](https://github.com/luxfi/luxjs/blob/master/examples/x-chain/import.ts)
|
||||
|
||||
- [Add a Node to the Validator Set](/nodes/validate/add-a-validator)
|
||||
|
||||
:::info
|
||||
|
||||
This API set is for a specific node, it is unavailable on the [public server](/tooling/rpc-providers.md).
|
||||
|
||||
:::
|
||||
|
||||
## Format
|
||||
|
||||
This API uses the `json 2.0` API format. For more information on making JSON RPC calls, see
|
||||
[here](/reference/standards/guides/issuing-api-calls.md).
|
||||
|
||||
## Endpoint
|
||||
|
||||
```text
|
||||
/ext/keystore
|
||||
```
|
||||
|
||||
## Methods
|
||||
|
||||
### keystore.createUser
|
||||
|
||||
:::caution
|
||||
|
||||
Deprecated as of [**v1.9.12**](https://github.com/luxfi/node/releases/tag/v1.9.12).
|
||||
|
||||
:::
|
||||
|
||||
Create a new user with the specified username and password.
|
||||
|
||||
**Signature:**
|
||||
|
||||
```sh
|
||||
keystore.createUser(
|
||||
{
|
||||
username:string,
|
||||
password:string
|
||||
}
|
||||
) -> {}
|
||||
```
|
||||
|
||||
- `username` and `password` can be at most 1024 characters.
|
||||
- Your request will be rejected if `password` is too weak. `password` should be at least 8
|
||||
characters and contain upper and lower case letters as well as numbers and symbols.
|
||||
|
||||
**Example Call:**
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"keystore.createUser",
|
||||
"params" :{
|
||||
"username":"myUsername",
|
||||
"password":"myPassword"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/keystore
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {}
|
||||
}
|
||||
```
|
||||
|
||||
### keystore.deleteUser
|
||||
|
||||
:::caution
|
||||
|
||||
Deprecated as of [**v1.9.12**](https://github.com/luxfi/node/releases/tag/v1.9.12).
|
||||
|
||||
:::
|
||||
|
||||
Delete a user.
|
||||
|
||||
**Signature:**
|
||||
|
||||
```sh
|
||||
keystore.deleteUser({username: string, password:string}) -> {}
|
||||
```
|
||||
|
||||
**Example Call:**
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"keystore.deleteUser",
|
||||
"params" : {
|
||||
"username":"myUsername",
|
||||
"password":"myPassword"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/keystore
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {}
|
||||
}
|
||||
```
|
||||
|
||||
### keystore.exportUser
|
||||
|
||||
:::caution
|
||||
|
||||
Deprecated as of [**v1.9.12**](https://github.com/luxfi/node/releases/tag/v1.9.12).
|
||||
|
||||
:::
|
||||
|
||||
Export a user. The user can be imported to another node with
|
||||
[`keystore.importUser`](/reference/node/keystore-api.md#keystoreimportuser). The user’s password
|
||||
remains encrypted.
|
||||
|
||||
**Signature:**
|
||||
|
||||
```sh
|
||||
keystore.exportUser(
|
||||
{
|
||||
username:string,
|
||||
password:string,
|
||||
encoding:string //optional
|
||||
}
|
||||
) -> {
|
||||
user:string,
|
||||
encoding:string
|
||||
}
|
||||
```
|
||||
|
||||
`encoding` specifies the format of the string encoding user data. Can only be `hex` when a value is
|
||||
provided.
|
||||
|
||||
**Example Call:**
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"keystore.exportUser",
|
||||
"params" :{
|
||||
"username":"myUsername",
|
||||
"password":"myPassword"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/keystore
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"user": "7655a29df6fc2747b0874e1148b423b954a25fcdb1f170d0ec8eb196430f7001942ce55b02a83b1faf50a674b1e55bfc00000000",
|
||||
"encoding": "hex"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### keystore.importUser
|
||||
|
||||
:::caution
|
||||
|
||||
Deprecated as of [**v1.9.12**](https://github.com/luxfi/node/releases/tag/v1.9.12).
|
||||
|
||||
:::
|
||||
|
||||
Import a user. `password` must match the user’s password. `username` doesn’t have to match the
|
||||
username `user` had when it was exported.
|
||||
|
||||
**Signature:**
|
||||
|
||||
```sh
|
||||
keystore.importUser(
|
||||
{
|
||||
username:string,
|
||||
password:string,
|
||||
user:string,
|
||||
encoding:string //optional
|
||||
}
|
||||
) -> {}
|
||||
```
|
||||
|
||||
`encoding` specifies the format of the string encoding user data. Can only be `hex` when a value is
|
||||
provided.
|
||||
|
||||
**Example Call:**
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"keystore.importUser",
|
||||
"params" :{
|
||||
"username":"myUsername",
|
||||
"password":"myPassword",
|
||||
"user" :"0x7655a29df6fc2747b0874e1148b423b954a25fcdb1f170d0ec8eb196430f7001942ce55b02a83b1faf50a674b1e55bfc000000008cf2d869"
|
||||
}
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/keystore
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {}
|
||||
}
|
||||
```
|
||||
|
||||
### keystore.listUsers
|
||||
|
||||
:::caution
|
||||
|
||||
Deprecated as of [**v1.9.12**](https://github.com/luxfi/node/releases/tag/v1.9.12).
|
||||
|
||||
:::
|
||||
|
||||
List the users in this keystore.
|
||||
|
||||
**Signature:**
|
||||
|
||||
```sh
|
||||
keystore.ListUsers() -> {users:[]string}
|
||||
```
|
||||
|
||||
**Example Call:**
|
||||
|
||||
```sh
|
||||
curl -X POST --data '{
|
||||
"jsonrpc":"2.0",
|
||||
"id" :1,
|
||||
"method" :"keystore.listUsers"
|
||||
}' -H 'content-type:application/json;' 127.0.0.1:9630/ext/keystore
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"users": ["myUsername"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,337 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package keystore
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/formatting"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
apitypes "github.com/luxfi/api/types"
|
||||
"github.com/luxfi/node/utils/password"
|
||||
)
|
||||
|
||||
// strongPassword defines a password used for the following tests that
|
||||
// scores high enough to pass the password strength scoring system
|
||||
var strongPassword = "N_+=_jJ;^(<;{4,:*m6CET}'&N;83FYK.wtNpwp-Jt" // #nosec G101
|
||||
|
||||
func TestServiceListNoUsers(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
reply := ListUsersReply{}
|
||||
require.NoError(s.ListUsers(nil, nil, &reply))
|
||||
require.Empty(reply.Users)
|
||||
}
|
||||
|
||||
func TestServiceCreateUser(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
{
|
||||
require.NoError(s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
}, &apitypes.EmptyReply{}))
|
||||
}
|
||||
|
||||
{
|
||||
reply := ListUsersReply{}
|
||||
require.NoError(s.ListUsers(nil, nil, &reply))
|
||||
require.Len(reply.Users, 1)
|
||||
require.Equal("bob", reply.Users[0])
|
||||
}
|
||||
}
|
||||
|
||||
// genStr returns a string of given length
|
||||
func genStr(n int) string {
|
||||
b := make([]byte, n)
|
||||
rand.Read(b) // #nosec G404
|
||||
return hex.EncodeToString(b)[:n]
|
||||
}
|
||||
|
||||
// TestServiceCreateUserArgsCheck generates excessively long usernames or
|
||||
// passwords to assure the sanity checks on string length are not exceeded
|
||||
func TestServiceCreateUserArgsCheck(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
{
|
||||
reply := apitypes.EmptyReply{}
|
||||
err := s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: genStr(maxUserLen + 1),
|
||||
Password: strongPassword,
|
||||
}, &reply)
|
||||
require.ErrorIs(err, errUserMaxLength)
|
||||
}
|
||||
|
||||
{
|
||||
reply := apitypes.EmptyReply{}
|
||||
err := s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "shortuser",
|
||||
Password: genStr(maxUserLen + 1),
|
||||
}, &reply)
|
||||
require.ErrorIs(err, password.ErrPassMaxLength)
|
||||
}
|
||||
|
||||
{
|
||||
reply := ListUsersReply{}
|
||||
require.NoError(s.ListUsers(nil, nil, &reply))
|
||||
require.Empty(reply.Users)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceCreateUserWeakPassword tests creating a new user with a weak
|
||||
// password to ensure the password strength check is working
|
||||
func TestServiceCreateUserWeakPassword(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
{
|
||||
reply := apitypes.EmptyReply{}
|
||||
err := s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: "weak",
|
||||
}, &reply)
|
||||
require.ErrorIs(err, password.ErrWeakPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateDuplicate(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
{
|
||||
require.NoError(s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
}, &apitypes.EmptyReply{}))
|
||||
}
|
||||
|
||||
{
|
||||
err := s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
}, &apitypes.EmptyReply{})
|
||||
require.ErrorIs(err, errUserAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCreateUserNoName(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
reply := apitypes.EmptyReply{}
|
||||
err := s.CreateUser(nil, &apitypes.UserPass{
|
||||
Password: strongPassword,
|
||||
}, &reply)
|
||||
require.ErrorIs(err, errEmptyUsername)
|
||||
}
|
||||
|
||||
func TestServiceUseBlockchainDB(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
{
|
||||
require.NoError(s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
}, &apitypes.EmptyReply{}))
|
||||
}
|
||||
|
||||
{
|
||||
db, err := ks.GetDatabase(ids.Empty, "bob", strongPassword)
|
||||
require.NoError(err)
|
||||
require.NoError(db.Put([]byte("hello"), []byte("world")))
|
||||
}
|
||||
|
||||
{
|
||||
db, err := ks.GetDatabase(ids.Empty, "bob", strongPassword)
|
||||
require.NoError(err)
|
||||
val, err := db.Get([]byte("hello"))
|
||||
require.NoError(err)
|
||||
require.Equal([]byte("world"), val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExportImport(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
encodings := []formatting.Encoding{formatting.Hex}
|
||||
for _, encoding := range encodings {
|
||||
ks := New(log.NewNoOpLogger(), memdb.New())
|
||||
s := service{ks: ks.(*keystore)}
|
||||
|
||||
{
|
||||
require.NoError(s.CreateUser(nil, &apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
}, &apitypes.EmptyReply{}))
|
||||
}
|
||||
|
||||
{
|
||||
db, err := ks.GetDatabase(ids.Empty, "bob", strongPassword)
|
||||
require.NoError(err)
|
||||
require.NoError(db.Put([]byte("hello"), []byte("world")))
|
||||
}
|
||||
|
||||
exportArgs := ExportUserArgs{
|
||||
UserPass: apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
},
|
||||
Encoding: encoding,
|
||||
}
|
||||
exportReply := ExportUserReply{}
|
||||
require.NoError(s.ExportUser(nil, &exportArgs, &exportReply))
|
||||
|
||||
newKS := New(log.NewNoOpLogger(), memdb.New())
|
||||
newS := service{ks: newKS.(*keystore)}
|
||||
|
||||
{
|
||||
err := newS.ImportUser(nil, &ImportUserArgs{
|
||||
UserPass: apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: "",
|
||||
},
|
||||
User: exportReply.User,
|
||||
}, &apitypes.EmptyReply{})
|
||||
require.ErrorIs(err, errIncorrectPassword)
|
||||
}
|
||||
|
||||
{
|
||||
err := newS.ImportUser(nil, &ImportUserArgs{
|
||||
UserPass: apitypes.UserPass{
|
||||
Username: "",
|
||||
Password: "strongPassword",
|
||||
},
|
||||
User: exportReply.User,
|
||||
}, &apitypes.EmptyReply{})
|
||||
require.ErrorIs(err, errEmptyUsername)
|
||||
}
|
||||
|
||||
{
|
||||
require.NoError(newS.ImportUser(nil, &ImportUserArgs{
|
||||
UserPass: apitypes.UserPass{
|
||||
Username: "bob",
|
||||
Password: strongPassword,
|
||||
},
|
||||
User: exportReply.User,
|
||||
Encoding: encoding,
|
||||
}, &apitypes.EmptyReply{}))
|
||||
}
|
||||
|
||||
{
|
||||
db, err := newKS.GetDatabase(ids.Empty, "bob", strongPassword)
|
||||
require.NoError(err)
|
||||
val, err := db.Get([]byte("hello"))
|
||||
require.NoError(err)
|
||||
require.Equal([]byte("world"), val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDeleteUser(t *testing.T) {
|
||||
testUser := "testUser"
|
||||
password := "passwTest@fake01ordX" // 20+ chars for Fair strength
|
||||
tests := []struct {
|
||||
desc string
|
||||
setup func(ks *keystore) error
|
||||
request *apitypes.UserPass
|
||||
want *apitypes.EmptyReply
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
desc: "empty user name case",
|
||||
request: &apitypes.UserPass{},
|
||||
expectedErr: errEmptyUsername,
|
||||
},
|
||||
{
|
||||
desc: "user not exists case",
|
||||
request: &apitypes.UserPass{Username: "dummy"},
|
||||
expectedErr: errNonexistentUser,
|
||||
},
|
||||
{
|
||||
desc: "user exists and invalid password case",
|
||||
setup: func(ks *keystore) error {
|
||||
s := service{ks: ks}
|
||||
return s.CreateUser(nil, &apitypes.UserPass{Username: testUser, Password: password}, &apitypes.EmptyReply{})
|
||||
},
|
||||
request: &apitypes.UserPass{Username: testUser, Password: "password"},
|
||||
expectedErr: errIncorrectPassword,
|
||||
},
|
||||
{
|
||||
desc: "user exists and valid password case",
|
||||
setup: func(ks *keystore) error {
|
||||
s := service{ks: ks}
|
||||
return s.CreateUser(nil, &apitypes.UserPass{Username: testUser, Password: password}, &apitypes.EmptyReply{})
|
||||
},
|
||||
request: &apitypes.UserPass{Username: testUser, Password: password},
|
||||
want: &apitypes.EmptyReply{},
|
||||
},
|
||||
{
|
||||
desc: "delete a user, imported from import api case",
|
||||
setup: func(ks *keystore) error {
|
||||
s := service{ks: ks}
|
||||
|
||||
reply := apitypes.EmptyReply{}
|
||||
if err := s.CreateUser(nil, &apitypes.UserPass{Username: testUser, Password: password}, &reply); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// created data in bob db
|
||||
db, err := ks.GetDatabase(ids.Empty, testUser, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Put([]byte("hello"), []byte("world"))
|
||||
},
|
||||
request: &apitypes.UserPass{Username: testUser, Password: password},
|
||||
want: &apitypes.EmptyReply{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
ksIntf := New(log.NewNoOpLogger(), memdb.New())
|
||||
ks := ksIntf.(*keystore)
|
||||
s := service{ks: ks}
|
||||
|
||||
if tt.setup != nil {
|
||||
require.NoError(tt.setup(ks))
|
||||
}
|
||||
got := &apitypes.EmptyReply{}
|
||||
err := s.DeleteUser(nil, tt.request, got)
|
||||
require.ErrorIs(err, tt.expectedErr)
|
||||
if tt.expectedErr != nil {
|
||||
return
|
||||
}
|
||||
require.Equal(tt.want, got)
|
||||
require.NotContains(ks.usernameToPassword, testUser) // delete is successful
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
// cleanlyCloseBody drains and closes an HTTP response body to prevent
|
||||
// HTTP/2 GOAWAY errors caused by closing bodies with unread data.
|
||||
func cleanlyCloseBody(body io.ReadCloser) error {
|
||||
if body == nil {
|
||||
return nil
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, body)
|
||||
return body.Close()
|
||||
}
|
||||
|
||||
// Client for requesting metrics from a remote Lux Node instance
|
||||
type Client struct {
|
||||
uri string
|
||||
}
|
||||
|
||||
// NewClient returns a new Metrics API Client
|
||||
func NewClient(uri string) *Client {
|
||||
return &Client{
|
||||
uri: uri + "/ext/metrics",
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics returns the metrics from the connected node. The metrics are
|
||||
// returned as a map of metric family name to the metric family.
|
||||
func (c *Client) GetMetrics(ctx context.Context) (map[string]*metric.MetricFamily, error) {
|
||||
uri, err := url.Parse(c.uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
uri.String(),
|
||||
bytes.NewReader(nil),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to issue request: %w", err)
|
||||
}
|
||||
defer cleanlyCloseBody(resp.Body)
|
||||
|
||||
// Return an error for any non successful status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("received status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parser metric.TextParser
|
||||
metrics, err := parser.TextToMetricFamilies(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import "github.com/luxfi/metric"
|
||||
|
||||
var counterOpts = metric.CounterOpts{
|
||||
Name: "counter",
|
||||
Help: "help",
|
||||
}
|
||||
|
||||
type testGatherer struct {
|
||||
mfs []*metric.MetricFamily
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *testGatherer) Gather() ([]*metric.MetricFamily, error) {
|
||||
return g.mfs, g.err
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
_ MultiGatherer = (*prefixGatherer)(nil)
|
||||
|
||||
errDuplicateGatherer = errors.New("attempt to register duplicate gatherer")
|
||||
)
|
||||
|
||||
// NewLabelGatherer returns a new MultiGatherer that merges metrics by adding a
|
||||
// new label.
|
||||
func NewLabelGatherer(labelName string) MultiGatherer {
|
||||
return &labelGatherer{
|
||||
labelName: labelName,
|
||||
}
|
||||
}
|
||||
|
||||
type labelGatherer struct {
|
||||
multiGatherer
|
||||
|
||||
labelName string
|
||||
}
|
||||
|
||||
func (g *labelGatherer) Gather() ([]*metric.MetricFamily, error) {
|
||||
g.lock.RLock()
|
||||
defer g.lock.RUnlock()
|
||||
|
||||
// Map to merge metrics by family name
|
||||
familyMap := make(map[string]*metric.MetricFamily)
|
||||
var gathererError error
|
||||
|
||||
for _, gatherer := range g.gatherers {
|
||||
families, err := gatherer.Gather()
|
||||
// Store error but continue gathering
|
||||
if err != nil && gathererError == nil {
|
||||
gathererError = err
|
||||
}
|
||||
|
||||
for _, family := range families {
|
||||
name := family.Name
|
||||
if existingFamily, ok := familyMap[name]; ok {
|
||||
// Check for label conflicts - if any metric pair has all the same labels,
|
||||
// that's a conflict
|
||||
hasConflict := false
|
||||
for _, newMetric := range family.Metrics {
|
||||
for _, existingMetric := range existingFamily.Metrics {
|
||||
if labelsEqual(newMetric.Labels, existingMetric.Labels) {
|
||||
gathererError = fmt.Errorf("duplicate metrics in family %q", name)
|
||||
hasConflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasConflict {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Only merge if no conflict
|
||||
if !hasConflict {
|
||||
existingFamily.Metrics = append(existingFamily.Metrics, family.Metrics...)
|
||||
}
|
||||
} else {
|
||||
// Add new family - make a copy to avoid modifying the original
|
||||
familyCopy := &metric.MetricFamily{
|
||||
Name: family.Name,
|
||||
Help: family.Help,
|
||||
Type: family.Type,
|
||||
Metrics: make([]metric.Metric, len(family.Metrics)),
|
||||
}
|
||||
copy(familyCopy.Metrics, family.Metrics)
|
||||
familyMap[name] = familyCopy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
var result []*metric.MetricFamily
|
||||
for _, family := range familyMap {
|
||||
// Sort metrics within each family by label values
|
||||
sort.Slice(family.Metrics, func(i, j int) bool {
|
||||
return compareMetrics(family.Metrics[i], family.Metrics[j]) < 0
|
||||
})
|
||||
result = append(result, family)
|
||||
}
|
||||
|
||||
// Sort families by name
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
|
||||
return result, gathererError
|
||||
}
|
||||
|
||||
func labelsEqual(labels1, labels2 []metric.LabelPair) bool {
|
||||
if len(labels1) != len(labels2) {
|
||||
return false
|
||||
}
|
||||
// Create a map of labels1
|
||||
labelMap := make(map[string]string)
|
||||
for _, label := range labels1 {
|
||||
labelMap[label.Name] = label.Value
|
||||
}
|
||||
// Check if labels2 matches
|
||||
for _, label := range labels2 {
|
||||
if val, ok := labelMap[label.Name]; !ok || val != label.Value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func compareMetrics(m1, m2 metric.Metric) int {
|
||||
// Compare metrics by their label values
|
||||
for i := 0; i < len(m1.Labels) && i < len(m2.Labels); i++ {
|
||||
if m1.Labels[i].Name != m2.Labels[i].Name {
|
||||
if m1.Labels[i].Name < m2.Labels[i].Name {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if m1.Labels[i].Value != m2.Labels[i].Value {
|
||||
if m1.Labels[i].Value < m2.Labels[i].Value {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(m1.Labels) < len(m2.Labels) {
|
||||
return -1
|
||||
}
|
||||
if len(m1.Labels) > len(m2.Labels) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (g *labelGatherer) Register(labelValue string, gatherer metric.Gatherer) error {
|
||||
g.lock.Lock()
|
||||
defer g.lock.Unlock()
|
||||
|
||||
if slices.Contains(g.names, labelValue) {
|
||||
return fmt.Errorf("%w: for %q with label %q",
|
||||
errDuplicateGatherer,
|
||||
g.labelName,
|
||||
labelValue,
|
||||
)
|
||||
}
|
||||
|
||||
g.register(
|
||||
labelValue,
|
||||
&labeledGatherer{
|
||||
labelName: g.labelName,
|
||||
labelValue: labelValue,
|
||||
gatherer: gatherer,
|
||||
},
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type labeledGatherer struct {
|
||||
labelName string
|
||||
labelValue string
|
||||
gatherer metric.Gatherer
|
||||
}
|
||||
|
||||
func (g *labeledGatherer) Gather() ([]*metric.MetricFamily, error) {
|
||||
// Gather returns partially filled metrics in the case of an error. So, it
|
||||
// is expected to still return the metrics in the case an error is returned.
|
||||
metricFamilies, err := g.gatherer.Gather()
|
||||
var labelError error
|
||||
|
||||
for _, metricFamily := range metricFamilies {
|
||||
var validMetrics []metric.Metric
|
||||
for _, m := range metricFamily.Metrics {
|
||||
// Check if the label already exists
|
||||
hasConflict := false
|
||||
for _, existingLabel := range m.Labels {
|
||||
if existingLabel.Name == g.labelName {
|
||||
// Label already exists, this is an error
|
||||
if labelError == nil {
|
||||
labelError = fmt.Errorf("label %q is already present in metric %q", g.labelName, metricFamily.Name)
|
||||
}
|
||||
hasConflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasConflict {
|
||||
m.Labels = append(m.Labels, metric.LabelPair{
|
||||
Name: g.labelName,
|
||||
Value: g.labelValue,
|
||||
})
|
||||
// Sort labels by name to ensure consistent ordering
|
||||
sort.Slice(m.Labels, func(i, j int) bool {
|
||||
return m.Labels[i].Name < m.Labels[j].Name
|
||||
})
|
||||
validMetrics = append(validMetrics, m)
|
||||
}
|
||||
// If there's a conflict, skip this metric entirely
|
||||
}
|
||||
// Update the metric family with only valid metrics
|
||||
metricFamily.Metrics = validMetrics
|
||||
}
|
||||
|
||||
// Return the original error if present, otherwise the label error
|
||||
if err != nil {
|
||||
return metricFamilies, err
|
||||
}
|
||||
return metricFamilies, labelError
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLabelGatherer_Registration(t *testing.T) {
|
||||
const (
|
||||
firstName = "first"
|
||||
secondName = "second"
|
||||
)
|
||||
firstLabeledGatherer := &labeledGatherer{
|
||||
labelValue: firstName,
|
||||
gatherer: &testGatherer{},
|
||||
}
|
||||
firstLabelGatherer := func() *labelGatherer {
|
||||
return &labelGatherer{
|
||||
multiGatherer: multiGatherer{
|
||||
names: []string{firstLabeledGatherer.labelValue},
|
||||
gatherers: []metric.Gatherer{
|
||||
firstLabeledGatherer,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
secondLabeledGatherer := &labeledGatherer{
|
||||
labelValue: secondName,
|
||||
gatherer: &testGatherer{
|
||||
mfs: []*metric.MetricFamily{{}},
|
||||
},
|
||||
}
|
||||
secondLabelGatherer := func() *labelGatherer {
|
||||
return &labelGatherer{
|
||||
multiGatherer: multiGatherer{
|
||||
names: []string{
|
||||
firstLabeledGatherer.labelValue,
|
||||
secondLabeledGatherer.labelValue,
|
||||
},
|
||||
gatherers: metric.Gatherers{
|
||||
firstLabeledGatherer,
|
||||
secondLabeledGatherer,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
onlySecondLabeledGatherer := &labelGatherer{
|
||||
multiGatherer: multiGatherer{
|
||||
names: []string{
|
||||
secondLabeledGatherer.labelValue,
|
||||
},
|
||||
gatherers: metric.Gatherers{
|
||||
secondLabeledGatherer,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registerTests := []struct {
|
||||
name string
|
||||
labelGatherer *labelGatherer
|
||||
labelValue string
|
||||
gatherer metric.Gatherer
|
||||
expectedErr error
|
||||
expectedLabelGatherer *labelGatherer
|
||||
}{
|
||||
{
|
||||
name: "first registration",
|
||||
labelGatherer: &labelGatherer{},
|
||||
labelValue: firstName,
|
||||
gatherer: firstLabeledGatherer.gatherer,
|
||||
expectedErr: nil,
|
||||
expectedLabelGatherer: firstLabelGatherer(),
|
||||
},
|
||||
{
|
||||
name: "second registration",
|
||||
labelGatherer: firstLabelGatherer(),
|
||||
labelValue: secondName,
|
||||
gatherer: secondLabeledGatherer.gatherer,
|
||||
expectedErr: nil,
|
||||
expectedLabelGatherer: secondLabelGatherer(),
|
||||
},
|
||||
{
|
||||
name: "conflicts with previous registration",
|
||||
labelGatherer: firstLabelGatherer(),
|
||||
labelValue: firstName,
|
||||
gatherer: secondLabeledGatherer.gatherer,
|
||||
expectedErr: errDuplicateGatherer,
|
||||
expectedLabelGatherer: firstLabelGatherer(),
|
||||
},
|
||||
}
|
||||
for _, test := range registerTests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
err := test.labelGatherer.Register(test.labelValue, test.gatherer)
|
||||
require.ErrorIs(err, test.expectedErr)
|
||||
require.Equal(test.expectedLabelGatherer, test.labelGatherer)
|
||||
})
|
||||
}
|
||||
|
||||
deregisterTests := []struct {
|
||||
name string
|
||||
labelGatherer *labelGatherer
|
||||
labelValue string
|
||||
expectedRemoved bool
|
||||
expectedLabelGatherer *labelGatherer
|
||||
}{
|
||||
{
|
||||
name: "remove from nothing",
|
||||
labelGatherer: &labelGatherer{},
|
||||
labelValue: firstName,
|
||||
expectedRemoved: false,
|
||||
expectedLabelGatherer: &labelGatherer{},
|
||||
},
|
||||
{
|
||||
name: "remove unknown name",
|
||||
labelGatherer: firstLabelGatherer(),
|
||||
labelValue: secondName,
|
||||
expectedRemoved: false,
|
||||
expectedLabelGatherer: firstLabelGatherer(),
|
||||
},
|
||||
{
|
||||
name: "remove first name",
|
||||
labelGatherer: firstLabelGatherer(),
|
||||
labelValue: firstName,
|
||||
expectedRemoved: true,
|
||||
expectedLabelGatherer: &labelGatherer{
|
||||
multiGatherer: multiGatherer{
|
||||
// We must populate with empty slices rather than nil slices
|
||||
// to pass the equality check.
|
||||
names: []string{},
|
||||
gatherers: metric.Gatherers{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove second name",
|
||||
labelGatherer: secondLabelGatherer(),
|
||||
labelValue: secondName,
|
||||
expectedRemoved: true,
|
||||
expectedLabelGatherer: firstLabelGatherer(),
|
||||
},
|
||||
{
|
||||
name: "remove only first name",
|
||||
labelGatherer: secondLabelGatherer(),
|
||||
labelValue: firstName,
|
||||
expectedRemoved: true,
|
||||
expectedLabelGatherer: onlySecondLabeledGatherer,
|
||||
},
|
||||
}
|
||||
for _, test := range deregisterTests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
removed := test.labelGatherer.Deregister(test.labelValue)
|
||||
require.Equal(test.expectedRemoved, removed)
|
||||
require.Equal(test.expectedLabelGatherer, test.labelGatherer)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/luxfi/utils"
|
||||
)
|
||||
|
||||
// MultiGatherer extends the Gatherer interface by allowing additional gatherers
|
||||
// to be registered.
|
||||
type MultiGatherer interface {
|
||||
metric.Gatherer
|
||||
|
||||
// Register adds the outputs of [gatherer] to the results of future calls to
|
||||
// Gather with the provided [name] added to the metrics.
|
||||
Register(name string, gatherer metric.Gatherer) error
|
||||
|
||||
// Deregister removes the outputs of a gatherer with [name] from the results
|
||||
// of future calls to Gather. Returns true if a gatherer with [name] was
|
||||
// found.
|
||||
Deregister(name string) bool
|
||||
}
|
||||
|
||||
type multiGatherer struct {
|
||||
lock sync.RWMutex
|
||||
names []string
|
||||
gatherers []metric.Gatherer
|
||||
}
|
||||
|
||||
// NewMultiGatherer creates and returns a new MultiGatherer that applies
|
||||
// prefixes to metric names. For a MultiGatherer without prefix support, use
|
||||
// the multiGatherer struct directly.
|
||||
func NewMultiGatherer() MultiGatherer {
|
||||
return NewPrefixGatherer()
|
||||
}
|
||||
|
||||
func (g *multiGatherer) Gather() ([]*metric.MetricFamily, error) {
|
||||
g.lock.RLock()
|
||||
defer g.lock.RUnlock()
|
||||
|
||||
var allFamilies []*metric.MetricFamily
|
||||
for _, gatherer := range g.gatherers {
|
||||
families, err := gatherer.Gather()
|
||||
if err != nil {
|
||||
return allFamilies, err
|
||||
}
|
||||
allFamilies = append(allFamilies, families...)
|
||||
}
|
||||
|
||||
// Sort metrics by name for consistent ordering
|
||||
sort.Slice(allFamilies, func(i, j int) bool {
|
||||
return allFamilies[i].Name < allFamilies[j].Name
|
||||
})
|
||||
|
||||
return allFamilies, nil
|
||||
}
|
||||
|
||||
// Register adds the outputs of gatherer to the results of future calls to
|
||||
// Gather with the provided name added to the metrics.
|
||||
func (g *multiGatherer) Register(name string, gatherer metric.Gatherer) error {
|
||||
g.lock.Lock()
|
||||
defer g.lock.Unlock()
|
||||
|
||||
if slices.Contains(g.names, name) {
|
||||
return fmt.Errorf("gatherer with name %q already registered", name)
|
||||
}
|
||||
|
||||
g.register(name, gatherer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *multiGatherer) register(name string, gatherer metric.Gatherer) {
|
||||
g.names = append(g.names, name)
|
||||
g.gatherers = append(g.gatherers, gatherer)
|
||||
}
|
||||
|
||||
func (g *multiGatherer) Deregister(name string) bool {
|
||||
g.lock.Lock()
|
||||
defer g.lock.Unlock()
|
||||
|
||||
index := slices.Index(g.names, name)
|
||||
if index == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
g.names = utils.DeleteIndex(g.names, index)
|
||||
g.gatherers = utils.DeleteIndex(g.gatherers, index)
|
||||
return true
|
||||
}
|
||||
|
||||
func MakeAndRegister(gatherer MultiGatherer, name string) (metric.Registry, error) {
|
||||
reg := metric.NewRegistry()
|
||||
if err := gatherer.Register(name, reg); err != nil {
|
||||
return nil, fmt.Errorf("couldn't register %q metrics: %w", name, err)
|
||||
}
|
||||
return reg, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
hello = "hello"
|
||||
world = "world"
|
||||
helloWorld = "hello_world"
|
||||
)
|
||||
|
||||
func TestMultiGathererEmptyGather(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Empty(mfs)
|
||||
}
|
||||
|
||||
func TestMultiGathererDuplicatedPrefix(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
og := NewOptionalGatherer()
|
||||
|
||||
require.NoError(g.Register("foo", og))
|
||||
|
||||
// When using NewMultiGatherer (which returns a PrefixGatherer),
|
||||
// duplicate registrations with the same prefix should fail with errOverlappingNamespaces
|
||||
err := g.Register("foo", og)
|
||||
require.ErrorIs(err, errOverlappingNamespaces)
|
||||
|
||||
// Registering with a different prefix should work
|
||||
require.NoError(g.Register("bar", og))
|
||||
}
|
||||
|
||||
func TestMultiGathererAddedError(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
|
||||
tg := &testGatherer{
|
||||
err: errTest,
|
||||
}
|
||||
|
||||
require.NoError(g.Register("", tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.ErrorIs(err, errTest)
|
||||
require.Empty(mfs)
|
||||
}
|
||||
|
||||
func TestMultiGathererNoAddedPrefix(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
|
||||
tg := &testGatherer{
|
||||
mfs: []*metric.MetricFamily{{
|
||||
Name: hello,
|
||||
Type: metric.MetricTypeCounter,
|
||||
Metrics: []metric.Metric{
|
||||
{Value: metric.MetricValue{Value: 0}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
require.NoError(g.Register("", tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Len(mfs, 1)
|
||||
require.Equal(hello, mfs[0].Name)
|
||||
}
|
||||
|
||||
func TestMultiGathererAddedPrefix(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
|
||||
tg := &testGatherer{
|
||||
mfs: []*metric.MetricFamily{{
|
||||
Name: world,
|
||||
Type: metric.MetricTypeCounter,
|
||||
Metrics: []metric.Metric{
|
||||
{Value: metric.MetricValue{Value: 0}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
require.NoError(g.Register(hello, tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Len(mfs, 1)
|
||||
// The prefix gatherer combines "hello" + "_" + "world" = "hello_world"
|
||||
require.Equal(helloWorld, mfs[0].Name)
|
||||
}
|
||||
|
||||
func TestMultiGathererJustPrefix(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
|
||||
emptyName := ""
|
||||
tg := &testGatherer{
|
||||
mfs: []*metric.MetricFamily{{
|
||||
Name: emptyName,
|
||||
Type: metric.MetricTypeCounter,
|
||||
Metrics: []metric.Metric{
|
||||
{Value: metric.MetricValue{Value: 0}},
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
require.NoError(g.Register(hello, tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Len(mfs, 1)
|
||||
require.Equal(hello, mfs[0].Name)
|
||||
}
|
||||
|
||||
func TestMultiGathererSorted(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewMultiGatherer()
|
||||
|
||||
name0 := "a"
|
||||
name1 := "z"
|
||||
// Create metrics with proper structure
|
||||
tg := &testGatherer{
|
||||
mfs: []*metric.MetricFamily{
|
||||
{
|
||||
Name: name1,
|
||||
Type: metric.MetricTypeCounter,
|
||||
Metrics: []metric.Metric{
|
||||
{Value: metric.MetricValue{Value: 0}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: name0,
|
||||
Type: metric.MetricTypeCounter,
|
||||
Metrics: []metric.Metric{
|
||||
{Value: metric.MetricValue{Value: 0}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(g.Register("", tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Len(mfs, 2)
|
||||
// Check that metrics are sorted by name
|
||||
require.Equal(name0, mfs[0].Name)
|
||||
require.Equal(name1, mfs[1].Name)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
var errReregisterGatherer = errors.New("attempted to register a gatherer when one is already registered")
|
||||
|
||||
var _ OptionalGatherer = (*optionalGatherer)(nil)
|
||||
|
||||
// OptionalGatherer extends the Gatherer interface by allowing the optional
|
||||
// registration of a single gatherer. If no gatherer is registered, Gather will
|
||||
// return no metrics and no error. If a gatherer is registered, Gather will
|
||||
// return the results of calling Gather on the provided gatherer.
|
||||
type OptionalGatherer interface {
|
||||
metric.Gatherer
|
||||
|
||||
// Register the provided gatherer. If a gatherer was previously registered,
|
||||
// an error will be returned.
|
||||
Register(gatherer metric.Gatherer) error
|
||||
}
|
||||
|
||||
type optionalGatherer struct {
|
||||
lock sync.RWMutex
|
||||
gatherer metric.Gatherer
|
||||
}
|
||||
|
||||
func NewOptionalGatherer() OptionalGatherer {
|
||||
return &optionalGatherer{}
|
||||
}
|
||||
|
||||
func (g *optionalGatherer) Gather() ([]*metric.MetricFamily, error) {
|
||||
g.lock.RLock()
|
||||
defer g.lock.RUnlock()
|
||||
|
||||
if g.gatherer == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return g.gatherer.Gather()
|
||||
}
|
||||
|
||||
func (g *optionalGatherer) Register(gatherer metric.Gatherer) error {
|
||||
g.lock.Lock()
|
||||
defer g.lock.Unlock()
|
||||
|
||||
if g.gatherer != nil {
|
||||
return fmt.Errorf("%w; existing: %#v; new: %#v",
|
||||
errReregisterGatherer,
|
||||
g.gatherer,
|
||||
gatherer,
|
||||
)
|
||||
}
|
||||
g.gatherer = gatherer
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
errTest = errors.New("non-nil error")
|
||||
)
|
||||
|
||||
func TestOptionalGathererEmptyGather(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewOptionalGatherer()
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Empty(mfs)
|
||||
}
|
||||
|
||||
func TestOptionalGathererDuplicated(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewOptionalGatherer()
|
||||
og := NewOptionalGatherer()
|
||||
|
||||
require.NoError(g.Register(og))
|
||||
err := g.Register(og)
|
||||
require.ErrorIs(err, errReregisterGatherer)
|
||||
}
|
||||
|
||||
func TestOptionalGathererAddedError(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewOptionalGatherer()
|
||||
|
||||
tg := &testGatherer{
|
||||
err: errTest,
|
||||
}
|
||||
|
||||
require.NoError(g.Register(tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.ErrorIs(err, errTest)
|
||||
require.Empty(mfs)
|
||||
}
|
||||
|
||||
func TestMultiGathererAdded(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
g := NewOptionalGatherer()
|
||||
|
||||
tg := &testGatherer{
|
||||
mfs: []*metric.MetricFamily{{
|
||||
Name: hello,
|
||||
}},
|
||||
}
|
||||
|
||||
require.NoError(g.Register(tg))
|
||||
|
||||
mfs, err := g.Gather()
|
||||
require.NoError(err)
|
||||
require.Len(mfs, 1)
|
||||
require.Equal(hello, mfs[0].Name)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
_ MultiGatherer = (*prefixGatherer)(nil)
|
||||
|
||||
errOverlappingNamespaces = errors.New("prefix could create overlapping namespaces")
|
||||
)
|
||||
|
||||
// NewPrefixGatherer returns a new MultiGatherer that merges metrics by adding a
|
||||
// prefix to their names.
|
||||
func NewPrefixGatherer() MultiGatherer {
|
||||
return &prefixGatherer{}
|
||||
}
|
||||
|
||||
type prefixGatherer struct {
|
||||
multiGatherer
|
||||
}
|
||||
|
||||
func (g *prefixGatherer) Register(prefix string, gatherer metric.Gatherer) error {
|
||||
g.lock.Lock()
|
||||
defer g.lock.Unlock()
|
||||
|
||||
for _, existingPrefix := range g.names {
|
||||
if eitherIsPrefix(prefix, existingPrefix) {
|
||||
return fmt.Errorf("%w: %q conflicts with %q",
|
||||
errOverlappingNamespaces,
|
||||
prefix,
|
||||
existingPrefix,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
g.register(
|
||||
prefix,
|
||||
&prefixedGatherer{
|
||||
prefix: prefix,
|
||||
gatherer: gatherer,
|
||||
},
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *prefixGatherer) Deregister(prefix string) bool {
|
||||
g.lock.Lock()
|
||||
defer g.lock.Unlock()
|
||||
|
||||
for i, existingPrefix := range g.names {
|
||||
if existingPrefix == prefix {
|
||||
// Remove the gatherer and prefix
|
||||
g.names = append(g.names[:i], g.names[i+1:]...)
|
||||
g.gatherers = append(g.gatherers[:i], g.gatherers[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type prefixedGatherer struct {
|
||||
prefix string
|
||||
gatherer metric.Gatherer
|
||||
}
|
||||
|
||||
func (g *prefixedGatherer) Gather() ([]*metric.MetricFamily, error) {
|
||||
// Gather returns partially filled metrics in the case of an error. So, it
|
||||
// is expected to still return the metrics in the case an error is returned.
|
||||
metricFamilies, err := g.gatherer.Gather()
|
||||
for _, metricFamily := range metricFamilies {
|
||||
originalName := metricFamily.Name
|
||||
if originalName == "" {
|
||||
// When the original name is empty, just use the prefix
|
||||
metricFamily.Name = g.prefix
|
||||
} else {
|
||||
metricFamily.Name = metric.AppendNamespace(g.prefix, originalName)
|
||||
}
|
||||
}
|
||||
return metricFamilies, err
|
||||
}
|
||||
|
||||
// eitherIsPrefix returns true if either [a] is a prefix of [b] or [b] is a
|
||||
// prefix of [a].
|
||||
//
|
||||
// This function accounts for the usage of the namespace boundary, so "hello" is
|
||||
// not considered a prefix of "helloworld". However, "hello" is considered a
|
||||
// prefix of "hello_world".
|
||||
func eitherIsPrefix(a, b string) bool {
|
||||
if len(a) > len(b) {
|
||||
a, b = b, a
|
||||
}
|
||||
return a == b[:len(a)] && // a is a prefix of b
|
||||
(len(a) == 0 || // a is empty
|
||||
len(a) == len(b) || // a is equal to b
|
||||
b[len(a)] == metric.NamespaceSeparatorByte) // a ends at a namespace boundary of b
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrefixGatherer_Register(t *testing.T) {
|
||||
firstPrefix := "first"
|
||||
firstPrefixedGatherer := &prefixedGatherer{
|
||||
prefix: firstPrefix,
|
||||
gatherer: &testGatherer{},
|
||||
}
|
||||
firstPrefixGatherer := func() *prefixGatherer {
|
||||
return &prefixGatherer{
|
||||
multiGatherer: multiGatherer{
|
||||
names: []string{
|
||||
firstPrefixedGatherer.prefix,
|
||||
},
|
||||
gatherers: []metric.Gatherer{
|
||||
firstPrefixedGatherer,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
secondPrefix := "second"
|
||||
secondPrefixedGatherer := &prefixedGatherer{
|
||||
prefix: secondPrefix,
|
||||
gatherer: &testGatherer{
|
||||
mfs: []*metric.MetricFamily{{}},
|
||||
},
|
||||
}
|
||||
secondPrefixGatherer := &prefixGatherer{
|
||||
multiGatherer: multiGatherer{
|
||||
names: []string{
|
||||
firstPrefixedGatherer.prefix,
|
||||
secondPrefixedGatherer.prefix,
|
||||
},
|
||||
gatherers: []metric.Gatherer{
|
||||
firstPrefixedGatherer,
|
||||
secondPrefixedGatherer,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prefixGatherer *prefixGatherer
|
||||
prefix string
|
||||
gatherer metric.Gatherer
|
||||
expectedErr error
|
||||
expectedPrefixGatherer *prefixGatherer
|
||||
}{
|
||||
{
|
||||
name: "first registration",
|
||||
prefixGatherer: &prefixGatherer{},
|
||||
prefix: firstPrefixedGatherer.prefix,
|
||||
gatherer: firstPrefixedGatherer.gatherer,
|
||||
expectedErr: nil,
|
||||
expectedPrefixGatherer: firstPrefixGatherer(),
|
||||
},
|
||||
{
|
||||
name: "second registration",
|
||||
prefixGatherer: firstPrefixGatherer(),
|
||||
prefix: secondPrefixedGatherer.prefix,
|
||||
gatherer: secondPrefixedGatherer.gatherer,
|
||||
expectedErr: nil,
|
||||
expectedPrefixGatherer: secondPrefixGatherer,
|
||||
},
|
||||
{
|
||||
name: "conflicts with previous registration",
|
||||
prefixGatherer: firstPrefixGatherer(),
|
||||
prefix: firstPrefixedGatherer.prefix,
|
||||
gatherer: secondPrefixedGatherer.gatherer,
|
||||
expectedErr: errOverlappingNamespaces,
|
||||
expectedPrefixGatherer: firstPrefixGatherer(),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
err := test.prefixGatherer.Register(test.prefix, test.gatherer)
|
||||
require.ErrorIs(err, test.expectedErr)
|
||||
require.Equal(test.expectedPrefixGatherer, test.prefixGatherer)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEitherIsPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty strings",
|
||||
a: "",
|
||||
b: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "an empty string",
|
||||
a: "",
|
||||
b: "hello",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "same strings",
|
||||
a: "x",
|
||||
b: "x",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "different strings",
|
||||
a: "x",
|
||||
b: "y",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "splits namespace",
|
||||
a: "hello",
|
||||
b: "hello_world",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "is prefix before separator",
|
||||
a: "hello",
|
||||
b: "helloworld",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
require.Equal(test.expected, eitherIsPrefix(test.a, test.b))
|
||||
require.Equal(test.expected, eitherIsPrefix(test.b, test.a))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
The Metrics API allows clients to get statistics about a node's health and performance.
|
||||
|
||||
<Callout title="Note">
|
||||
This API set is for a specific node, it is unavailable on the [public server](https://docs.lux.network/docs/tooling/rpc-providers).
|
||||
</Callout>
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
/ext/metrics
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
To get the node metrics:
|
||||
|
||||
```sh
|
||||
curl -X POST 127.0.0.1:9630/ext/metrics
|
||||
```
|
||||
|
||||
## Format
|
||||
|
||||
This API produces Prometheus compatible metrics. See [here](https://metric.io/docs/instrumenting/exposition_formats) for information on Prometheus' formatting.
|
||||
|
||||
[Here](https://docs.lux.network/docs/nodes/maintain/monitoring) is a tutorial that shows how to set up Prometheus and Grafana to monitor Lux Node using the Metrics API.
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/luxfi/metric"
|
||||
)
|
||||
|
||||
type testGathererWithContext struct {
|
||||
mfs []*metric.MetricFamily
|
||||
}
|
||||
|
||||
func (g *testGathererWithContext) Gather(context.Context) ([]*metric.MetricFamily, error) {
|
||||
return g.mfs, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package warp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
apiwarp "github.com/luxfi/api/warp"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
"github.com/luxfi/node/chains"
|
||||
"github.com/luxfi/node/warp"
|
||||
)
|
||||
|
||||
// Service implements api/warp.Service.
|
||||
type Service struct {
|
||||
log log.Logger
|
||||
chainManager chains.Manager
|
||||
lock sync.RWMutex
|
||||
ipcs *warp.ChainIPCs
|
||||
}
|
||||
|
||||
func New(log log.Logger, chainManager chains.Manager, ipcs *warp.ChainIPCs) *Service {
|
||||
return &Service{
|
||||
log: log,
|
||||
chainManager: chainManager,
|
||||
ipcs: ipcs,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PublishBlockchain(ctx context.Context, args *apiwarp.PublishBlockchainArgs) (*apiwarp.PublishBlockchainReply, error) {
|
||||
_ = ctx
|
||||
s.log.Warn("deprecated API called",
|
||||
log.UserString("service", "ipcs"),
|
||||
log.UserString("method", "publishBlockchain"),
|
||||
log.UserString("blockchainID", args.BlockchainID),
|
||||
)
|
||||
|
||||
chainID, err := s.chainManager.Lookup(args.BlockchainID)
|
||||
if err != nil {
|
||||
s.log.Error("chain lookup failed",
|
||||
log.UserString("blockchainID", args.BlockchainID),
|
||||
log.Reflect("error", err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
ipcs, err := s.ipcs.Publish(chainID)
|
||||
if err != nil {
|
||||
s.log.Error("couldn't publish chain",
|
||||
log.UserString("blockchainID", args.BlockchainID),
|
||||
log.Reflect("error", err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &apiwarp.PublishBlockchainReply{
|
||||
ConsensusURL: ipcs.ConsensusURL(),
|
||||
DecisionsURL: ipcs.DecisionsURL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UnpublishBlockchain(ctx context.Context, args *apiwarp.UnpublishBlockchainArgs) (*apiwarp.EmptyReply, error) {
|
||||
_ = ctx
|
||||
s.log.Warn("deprecated API called",
|
||||
log.UserString("service", "ipcs"),
|
||||
log.UserString("method", "unpublishBlockchain"),
|
||||
log.UserString("blockchainID", args.BlockchainID),
|
||||
)
|
||||
|
||||
chainID, err := s.chainManager.Lookup(args.BlockchainID)
|
||||
if err != nil {
|
||||
s.log.Error("chain lookup failed",
|
||||
log.UserString("blockchainID", args.BlockchainID),
|
||||
log.Reflect("error", err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
ok, err := s.ipcs.Unpublish(chainID)
|
||||
if !ok {
|
||||
s.log.Error("couldn't publish chain",
|
||||
log.UserString("blockchainID", args.BlockchainID),
|
||||
log.Reflect("error", err),
|
||||
)
|
||||
}
|
||||
|
||||
return &apiwarp.EmptyReply{}, err
|
||||
}
|
||||
|
||||
func (s *Service) GetPublishedBlockchains(ctx context.Context) (*apiwarp.GetPublishedBlockchainsReply, error) {
|
||||
_ = ctx
|
||||
s.log.Warn("deprecated API called",
|
||||
log.UserString("service", "ipcs"),
|
||||
log.UserString("method", "getPublishedBlockchains"),
|
||||
)
|
||||
|
||||
s.lock.RLock()
|
||||
defer s.lock.RUnlock()
|
||||
|
||||
chains := s.ipcs.GetPublishedBlockchains()
|
||||
return &apiwarp.GetPublishedBlockchainsReply{Chains: chains}, nil
|
||||
}
|
||||
|
||||
var _ apiwarp.Service = (*Service)(nil)
|
||||
var _ ids.ID
|
||||
Reference in New Issue
Block a user