clean: squash history (binaries stripped via filter-repo)

This commit is contained in:
Hanzo AI
2026-04-13 03:45:21 -07:00
commit f6e63d60b2
2029 changed files with 465257 additions and 0 deletions
+223
View File
@@ -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
}
+497
View File
@@ -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{}
+304
View File
@@ -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
}
+370
View File
@@ -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())
}
}
+18
View File
@@ -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"`
}
+40
View File
@@ -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,
})
}
+73
View File
@@ -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)
}
+385
View File
@@ -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
}
+304
View File
@@ -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())
}
+38
View File
@@ -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.
+23
View File
@@ -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)
}
+63
View File
@@ -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,
})
})
}
+137
View File
@@ -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()
}
+392
View File
@@ -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)
}
}
+26
View File
@@ -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
}
+19
View File
@@ -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,
}
}
+54
View File
@@ -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
}
+273
View File
@@ -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.
+227
View File
@@ -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)
}
})
}
}
+329
View File
@@ -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()
}
}
}
+418
View File
@@ -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
}
+703
View File
@@ -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
}
```
+103
View File
@@ -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)
}
+50
View File
@@ -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)
}
+81
View File
@@ -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...)
}
+31
View File
@@ -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
}
+381
View File
@@ -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
}
+112
View File
@@ -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
}
+290
View File
@@ -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 users 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 users password. `username` doesnt 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"]
}
}
```
+337
View File
@@ -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
})
}
}
+74
View File
@@ -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
}
+20
View File
@@ -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
}
+220
View File
@@ -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
}
+165
View File
@@ -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)
})
}
}
+104
View File
@@ -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
}
+168
View File
@@ -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)
}
+62
View File
@@ -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
}
+72
View File
@@ -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)
}
+103
View File
@@ -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
}
+147
View File
@@ -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))
})
}
}
+25
View File
@@ -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.
+18
View File
@@ -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
}
+114
View File
@@ -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