Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ cl-mimicry.yaml
el-mimicry.yaml
cannon.yaml
sage.yaml
ethstats.yaml
dist
GeoLite2-ASN.mmdb
GeoLite2-City.mmdb
Expand All @@ -16,3 +17,7 @@ sage.yaml
.goreleaser.yaml.new
.aider*
relay-monitor.yaml

# Development tools binaries
bin/
tools/ethstats-client/ethstats-client
666 changes: 666 additions & 0 deletions ai_plans/ethstats-server-implementation.md

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions cmd/ethstats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package cmd

import (
"context"
"os"
"os/signal"
"syscall"

"github.com/ethpandaops/xatu/pkg/ethstats"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

var (
ethstatsCfgFile string
ethstatsCmd = &cobra.Command{
Use: "ethstats",
Short: "Runs Xatu in Ethstats server mode",
Long: `Ethstats server mode accepts WebSocket connections from Ethereum execution clients
following the ethstats protocol. It authenticates clients, collects node information,
and handles protocol messages.`,
Run: func(cmd *cobra.Command, args []string) {
initCommon()

// Load configuration
config, err := loadEthstatsConfigFromFile(ethstatsCfgFile)
if err != nil {
log.Fatal(err)
}

// Initialize logger
log = getLogger(config.LoggingLevel, "")

log.WithField("location", ethstatsCfgFile).Info("Loaded config")

// Create and start server
server, err := ethstats.NewServer(log, config)
if err != nil {
log.WithError(err).Fatal("Failed to create server")
}

// Setup signal handling
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

go func() {
<-sigCh
log.Info("Received shutdown signal")
cancel()
}()

// Start server
if err := server.Start(ctx); err != nil {
log.WithError(err).Fatal("Failed to start server")
}

log.Info("Server shutdown complete")
},
}
)

func init() {
rootCmd.AddCommand(ethstatsCmd)

ethstatsCmd.Flags().StringVar(&ethstatsCfgFile, "config", "ethstats.yaml", "config file")
}

func loadEthstatsConfigFromFile(file string) (*ethstats.Config, error) {
config := ethstats.NewDefaultConfig()

data, err := os.ReadFile(file)
if err != nil {
return nil, err
}

if err := yaml.Unmarshal(data, config); err != nil {
return nil, err
}

return config, nil
}
38 changes: 38 additions & 0 deletions example_ethstats.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Ethstats server configuration example
enabled: true
addr: ":8081"
metricsAddr: ":9090"
logging: "info"

# WebSocket configuration
maxMessageSize: 15728640 # 15MB (go-ethereum requirement)
readTimeout: 60s
writeTimeout: 10s
pingInterval: 30s

# Authentication configuration
auth:
enabled: true
groups:
mainnet:
users:
node1:
password: "securepassword1"
node2:
password: "securepassword2"
testnet:
users:
testnode1:
password: "testpass1"

# Rate limiting configuration
rateLimit:
enabled: true
connectionsPerIp: 10
windowDuration: 1m
failuresBeforeWarn: 5

# Labels for metrics
labels:
environment: production
service: ethstats
152 changes: 152 additions & 0 deletions pkg/ethstats/auth/authorization.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package auth

import (
"context"
"encoding/base64"
"fmt"
"strings"

"github.com/sirupsen/logrus"
)

type Authorization struct {
enabled bool
groups map[string]*Group
log logrus.FieldLogger
}

func NewAuthorization(log logrus.FieldLogger, cfg Config) (*Authorization, error) {
auth := &Authorization{
enabled: cfg.Enabled,
groups: make(map[string]*Group),
log: log,
}

if !cfg.Enabled {
log.Info("Authorization is disabled")

return auth, nil
}

for groupName, groupCfg := range cfg.Groups {
group, err := NewGroup(groupName, groupCfg)
if err != nil {
return nil, fmt.Errorf("failed to create group %s: %w", groupName, err)
}

auth.groups[groupName] = group
}

log.WithField("groups", len(auth.groups)).Info("Authorization configured")

return auth, nil
}

func (a *Authorization) Start(ctx context.Context) error {
if !a.enabled {
return nil
}

a.log.Info("Starting authorization service")

// Log configured groups and users
for groupName, grp := range a.groups {
userCount := 0
usernames := []string{}

for username := range grp.users {
userCount++

usernames = append(usernames, username)
}

a.log.WithFields(logrus.Fields{
"group": groupName,
"user_count": userCount,
"users": usernames,
}).Info("Configured group")
}

return nil
}

func (a *Authorization) AuthorizeSecret(secret string) (username, group string, err error) {
if !a.enabled {
a.log.Debug("Authorization disabled, allowing access")
return "", "", nil

Check failure on line 76 in pkg/ethstats/auth/authorization.go

View workflow job for this annotation

GitHub Actions / lint

return with no blank line before (nlreturn)
}

a.log.WithField("secret_length", len(secret)).Debug("Authorizing secret")

username, password, err := a.parseSecret(secret)
if err != nil {
a.log.WithError(err).Warn("Failed to parse secret")
return "", "", fmt.Errorf("failed to parse secret: %w", err)

Check failure on line 84 in pkg/ethstats/auth/authorization.go

View workflow job for this annotation

GitHub Actions / lint

return with no blank line before (nlreturn)
}

a.log.WithFields(logrus.Fields{
"username": username,
"groups": len(a.groups),
}).Debug("Checking user against groups")

for groupName, grp := range a.groups {
a.log.WithFields(logrus.Fields{
"username": username,
"group": groupName,
"has_user": grp.HasUser(username),
}).Debug("Checking user in group")

if grp.ValidateUser(username, password) {
a.log.WithFields(logrus.Fields{
"username": username,
"group": groupName,
}).Info("User authorized")
return username, groupName, nil
}
}

a.log.WithFields(logrus.Fields{
"username": username,
"groups_checked": len(a.groups),
}).Warn("Invalid credentials - user not found or password incorrect")
return "", "", fmt.Errorf("invalid credentials")

Check failure on line 112 in pkg/ethstats/auth/authorization.go

View workflow job for this annotation

GitHub Actions / lint

return with no blank line before (nlreturn)
}

func (a *Authorization) parseSecret(secret string) (username, password string, err error) {

Check failure on line 115 in pkg/ethstats/auth/authorization.go

View workflow job for this annotation

GitHub Actions / lint

block should not start with a whitespace (wsl)
// The secret can be in format:
// 1. base64(username:password)
// 2. plain username:password (for compatibility)

originalSecret := secret
isBase64 := false

// Try base64 decode first
decoded, err := base64.StdEncoding.DecodeString(secret)
if err == nil {
secret = string(decoded)
isBase64 = true
}

a.log.WithFields(logrus.Fields{
"original_length": len(originalSecret),
"decoded_length": len(secret),
"is_base64": isBase64,
}).Debug("Parsing secret")

// Split username:password
parts := strings.SplitN(secret, ":", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid secret format, expected username:password")
}

username = strings.TrimSpace(parts[0])
password = strings.TrimSpace(parts[1])

if username == "" || password == "" {
return "", "", fmt.Errorf("username and password cannot be empty")
}

a.log.WithField("username", username).Debug("Parsed credentials")

return username, password, nil
}
Loading
Loading