From 0eaf4f0fc3d1e3237eaba33dd5726ee3062d4277 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:48:33 +1000 Subject: [PATCH 1/3] feat: add ethstats server implementation - Add WebSocket server for ethstats protocol - Implement authentication and authorization system - Add connection management with rate limiting - Add protocol handlers for ethstats messages - Add metrics collection for monitoring - Add example configuration file --- .gitignore | 1 + ai_plans/ethstats-server-implementation.md | 666 +++++++++++++++++++++ cmd/ethstats.go | 82 +++ ethstats-implementation-guide.md | 308 ++++++++++ example_ethstats.yaml | 38 ++ pkg/ethstats/auth/authorization.go | 99 +++ pkg/ethstats/auth/config.go | 56 ++ pkg/ethstats/auth/groups.go | 49 ++ pkg/ethstats/auth/user.go | 33 + pkg/ethstats/config.go | 110 ++++ pkg/ethstats/connection/client.go | 135 +++++ pkg/ethstats/connection/manager.go | 155 +++++ pkg/ethstats/connection/metrics.go | 9 + pkg/ethstats/connection/ratelimit.go | 95 +++ pkg/ethstats/metrics.go | 121 ++++ pkg/ethstats/protocol/handler.go | 292 +++++++++ pkg/ethstats/protocol/interfaces.go | 28 + pkg/ethstats/protocol/ping.go | 64 ++ pkg/ethstats/protocol/types.go | 398 ++++++++++++ pkg/ethstats/server.go | 291 +++++++++ 20 files changed, 3030 insertions(+) create mode 100644 ai_plans/ethstats-server-implementation.md create mode 100644 cmd/ethstats.go create mode 100644 ethstats-implementation-guide.md create mode 100644 example_ethstats.yaml create mode 100644 pkg/ethstats/auth/authorization.go create mode 100644 pkg/ethstats/auth/config.go create mode 100644 pkg/ethstats/auth/groups.go create mode 100644 pkg/ethstats/auth/user.go create mode 100644 pkg/ethstats/config.go create mode 100644 pkg/ethstats/connection/client.go create mode 100644 pkg/ethstats/connection/manager.go create mode 100644 pkg/ethstats/connection/metrics.go create mode 100644 pkg/ethstats/connection/ratelimit.go create mode 100644 pkg/ethstats/metrics.go create mode 100644 pkg/ethstats/protocol/handler.go create mode 100644 pkg/ethstats/protocol/interfaces.go create mode 100644 pkg/ethstats/protocol/ping.go create mode 100644 pkg/ethstats/protocol/types.go create mode 100644 pkg/ethstats/server.go diff --git a/.gitignore b/.gitignore index e86cfcd8c..3f3c7e331 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ cl-mimicry.yaml el-mimicry.yaml cannon.yaml sage.yaml +ethstats.yaml dist GeoLite2-ASN.mmdb GeoLite2-City.mmdb diff --git a/ai_plans/ethstats-server-implementation.md b/ai_plans/ethstats-server-implementation.md new file mode 100644 index 000000000..11ab5b312 --- /dev/null +++ b/ai_plans/ethstats-server-implementation.md @@ -0,0 +1,666 @@ +# Ethstats Server Implementation Plan + +## Executive Summary +> The ethstats server will be a new Xatu subcommand that accepts websocket connections from Ethereum execution clients following the ethstats protocol. Unlike the existing gRPC-based server, this will implement a websocket server to maintain compatibility with the established ethstats protocol used by go-ethereum, Nethermind, Erigon, and Besu. The server will authenticate clients using base64-encoded credentials, store connection metadata, handle protocol messages, and provide metrics about connected nodes. + +## Goals & Objectives +### Primary Goals +- Implement a websocket server that fully supports the ethstats protocol for Ethereum execution clients +- Provide authentication and authorization using the existing auth patterns from the event-ingester service +- Collect and store IP-based connection information with basic rate limiting detection + +### Secondary Objectives +- Maintain consistency with Xatu's existing architecture and patterns +- Enable future message handling and data pipeline integration +- Provide comprehensive metrics for monitoring connected nodes +- Support both ws:// and wss:// connections + +## Solution Overview +### Approach +The ethstats server will be implemented as a new package (`pkg/ethstats`) and subcommand, following the existing Xatu patterns. It will use the Gorilla WebSocket library for server implementation, adapt the event-ingester's authentication system for ethstats protocol compatibility, and maintain per-connection state for handling the protocol's ping/pong and message requirements. + +### Key Components +1. **Command Integration**: New `ethstats` subcommand in cmd/ following Cobra patterns +2. **WebSocket Server**: Gorilla WebSocket-based server handling protocol requirements +3. **Authentication**: Adapted from event-ingester with base64 username:password parsing +4. **Connection Manager**: Tracks client connections, IPs, and protocol state +5. **Message Handler**: Processes ethstats protocol messages with proper JSON formatting +6. **Metrics**: Prometheus metrics for connections, messages, and rate limiting + +### Architecture Diagram +``` +[Execution Clients] --ws/wss--> [Ethstats Server] + | + ├── Auth Manager (Groups/Users) + ├── Connection Manager + ├── Message Handler + └── Metrics Exporter +``` + +### Architectural Notes +- Consider implementing under `pkg/server/service/ethstats/` to follow existing Xatu service patterns +- The protocol types will include custom unmarshalers for fields like `Block.Number` that can be either hex strings or numbers + +### Data Flow +``` +Client Connect → WebSocket Upgrade → Hello Message → Auth Check → Ready Response → Message Loop + ↓ + ← Ping/Pong → Message Processing +``` + +### Expected Outcomes +- Execution clients can connect using standard ethstats protocol URLs (nodename:secret@host:port) +- Server authenticates clients and maintains connection state with IP tracking +- All protocol messages are logged with proper handling of format variations +- Metrics expose connection counts, message rates, and authentication failures +- Foundation for future message processing and data pipeline integration + +## Implementation Tasks + +### CRITICAL IMPLEMENTATION RULES +1. **NO PLACEHOLDER CODE**: Every implementation must be production-ready. NEVER write "TODO", "in a real implementation", or similar placeholders unless explicitly requested by the user. +2. **CROSS-DIRECTORY TASKS**: Group related changes across directories into single tasks to ensure consistency. Never create isolated changes that require follow-up work in sibling directories. +3. **COMPLETE IMPLEMENTATIONS**: Each task must fully implement its feature including all consumers, type updates, and integration points. +4. **DETAILED SPECIFICATIONS**: Each task must include EXACTLY what to implement, including specific functions, types, and integration points to avoid "breaking change" confusion. +5. **CONTEXT AWARENESS**: Each task is part of a larger system - specify how it connects to other parts. +6. **MAKE BREAKING CHANGES**: Unless explicitly requested by the user, you MUST make breaking changes. + +### Visual Dependency Tree +``` +cmd/ +├── ethstats.go (Task #6: Create ethstats subcommand with Cobra integration) +│ +pkg/ +├── ethstats/ +│ ├── config.go (Task #1: Configuration structs and validation) +│ ├── server.go (Task #4: Main server implementation with WebSocket handling) +│ ├── metrics.go (Task #0: Prometheus metrics for ethstats) +│ │ +│ ├── auth/ +│ │ ├── config.go (Task #2: Auth configuration structures) +│ │ ├── authorization.go (Task #2: Authorization logic for ethstats) +│ │ ├── groups.go (Task #2: Groups management) +│ │ └── user.go (Task #2: User authentication) +│ │ +│ ├── connection/ +│ │ ├── manager.go (Task #3: Connection state management) +│ │ ├── client.go (Task #3: Individual client connection state) +│ │ └── ratelimit.go (Task #3: IP-based rate limit detection) +│ │ +│ └── protocol/ +│ ├── types.go (Task #0: Ethstats protocol message types) +│ ├── handler.go (Task #5: Message handling and routing) +│ └── ping.go (Task #5: Ping/pong protocol handling) +│ +example_ethstats.yaml (Task #7: Example configuration file) +``` + +### Execution Plan + +#### Group A: Foundation (Execute all in parallel) +- [x] **Task #0**: Create Prometheus metrics for ethstats + - Folder: `pkg/ethstats/` + - File: `metrics.go` + - Implements: + ```go + type Metrics struct { + connectedClients *prometheus.GaugeVec // Labels: node_type, network + authenticationTotal *prometheus.CounterVec // Labels: status, group + messagesReceivedTotal *prometheus.CounterVec // Labels: type, node_id + messagesSentTotal *prometheus.CounterVec // Labels: type + protocolErrors *prometheus.CounterVec // Labels: error_type + connectionDuration *prometheus.HistogramVec // Labels: node_type + ipRateLimitWarnings *prometheus.CounterVec // Labels: ip + } + func NewMetrics(namespace string) *Metrics + func (m *Metrics) IncConnectedClients(nodeType, network string) + func (m *Metrics) DecConnectedClients(nodeType, network string) + func (m *Metrics) IncAuthentication(status, group string) + func (m *Metrics) IncMessagesReceived(msgType, nodeID string) + func (m *Metrics) IncMessagesSent(msgType string) + func (m *Metrics) IncProtocolError(errorType string) + func (m *Metrics) ObserveConnectionDuration(duration float64, nodeType string) + func (m *Metrics) IncIPRateLimitWarning(ip string) + ``` + - Exports: Metrics type and constructor + - Context: Central metrics collection for the ethstats server + +- [x] **Task #0**: Create ethstats protocol types + - Folder: `pkg/ethstats/protocol/` + - File: `types.go` + - Implements: + ```go + // Wrapper for all messages + type Message struct { + Emit []interface{} `json:"emit"` + } + + // Hello message structures + type HelloMessage struct { + ID string `json:"id"` + Info NodeInfo `json:"info"` + Secret string `json:"secret"` + } + + type NodeInfo struct { + Name string `json:"name"` + Node string `json:"node"` + Port int `json:"port"` + Net string `json:"net"` + Protocol string `json:"protocol"` + API string `json:"api"` + OS string `json:"os"` + OSVersion string `json:"os_v"` + Client string `json:"client"` + CanUpdateHistory bool `json:"canUpdateHistory"` + Contact string `json:"contact,omitempty"` + } + + // Block report structures + type BlockReport struct { + ID string `json:"id"` + Block Block `json:"block"` + } + + // BlockNumber handles both hex string and number formats + type BlockNumber struct { + value uint64 + } + + // UnmarshalJSON implements json.Unmarshaler + func (bn *BlockNumber) UnmarshalJSON(data []byte) error + func (bn BlockNumber) Value() uint64 + func (bn BlockNumber) MarshalJSON() ([]byte, error) + + type Block struct { + Number BlockNumber `json:"number"` // Custom type for hex/number handling + Hash string `json:"hash"` + ParentHash string `json:"parentHash"` + Timestamp int64 `json:"timestamp"` + Miner string `json:"miner"` + GasUsed int64 `json:"gasUsed"` + GasLimit int64 `json:"gasLimit"` + Difficulty string `json:"difficulty"` + TotalDifficulty string `json:"totalDifficulty"` + Transactions []TxHash `json:"transactions"` + TransactionsRoot string `json:"transactionsRoot"` + StateRoot string `json:"stateRoot"` + Uncles []string `json:"uncles"` + } + + type TxHash struct { + Hash string `json:"hash"` + } + + // Stats structures + type StatsReport struct { + ID string `json:"id"` + Stats NodeStats `json:"stats"` + } + + type NodeStats struct { + Active bool `json:"active"` + Syncing bool `json:"syncing"` + Mining bool `json:"mining"` + Hashrate int64 `json:"hashrate"` + Peers int `json:"peers"` + GasPrice int64 `json:"gasPrice"` + Uptime int `json:"uptime"` + } + + // Pending transactions + type PendingReport struct { + ID string `json:"id"` + Stats PendingStats `json:"stats"` + } + + type PendingStats struct { + Pending int `json:"pending"` + } + + // Latency structures + type NodePing struct { + ID string `json:"id"` + ClientTime string `json:"clientTime"` + } + + type NodePong struct { + ID string `json:"id"` + ClientTime string `json:"clientTime"` + ServerTime string `json:"serverTime"` + } + + type LatencyReport struct { + ID string `json:"id"` + Latency int `json:"latency"` + } + + // History structures + type HistoryRequest struct { + Max int `json:"max"` + Min int `json:"min"` + } + + type HistoryReport struct { + ID string `json:"id"` + History []Block `json:"history"` + } + + // Helper functions + func ParseMessage(data []byte) (*Message, error) + func ParseHelloMessage(emit []interface{}) (*HelloMessage, error) + func ParseBlockReport(emit []interface{}) (*BlockReport, error) + func ParseStatsReport(emit []interface{}) (*StatsReport, error) + func ParsePendingReport(emit []interface{}) (*PendingReport, error) + func ParseNodePing(emit []interface{}) (*NodePing, error) + func ParseLatencyReport(emit []interface{}) (*LatencyReport, error) + func ParseHistoryReport(emit []interface{}) (*HistoryReport, error) + func FormatReadyMessage() []byte + func FormatNodePong(ping *NodePing, serverTime string) []byte + func FormatHistoryRequest(max, min int) []byte + func FormatPrimusPing(timestamp int64) []byte + ``` + - Exports: All protocol types and parsing functions + - Context: Core protocol definitions for ethstats communication + +#### Group B: Authentication System (Execute after Group A) +- [x] **Task #1**: Create ethstats configuration + - Folder: `pkg/ethstats/` + - File: `config.go` + - Imports: + - `fmt` + - `time` + - `github.com/ethpandaops/xatu/pkg/ethstats/auth` (after Task #2) + - Implements: + ```go + type Config struct { + Enabled bool `yaml:"enabled" default:"true"` + Addr string `yaml:"addr" default:":8081"` + MetricsAddr string `yaml:"metricsAddr" default:":9090"` + LoggingLevel string `yaml:"logging" default:"info"` + + // WebSocket settings + MaxMessageSize int64 `yaml:"maxMessageSize" default:"15728640"` // 15MB + ReadTimeout time.Duration `yaml:"readTimeout" default:"60s"` + WriteTimeout time.Duration `yaml:"writeTimeout" default:"10s"` + PingInterval time.Duration `yaml:"pingInterval" default:"30s"` + + // Authentication + Auth auth.Config `yaml:"auth"` + + // Rate limiting + RateLimit RateLimitConfig `yaml:"rateLimit"` + + // Labels for metrics + Labels map[string]string `yaml:"labels"` + } + + type RateLimitConfig struct { + Enabled bool `yaml:"enabled" default:"true"` + ConnectionsPerIP int `yaml:"connectionsPerIP" default:"10"` + WindowDuration time.Duration `yaml:"windowDuration" default:"1m"` + FailuresBeforeWarn int `yaml:"failuresBeforeWarn" default:"5"` + } + + func (c *Config) Validate() error + func NewDefaultConfig() *Config + ``` + - Exports: Config, RateLimitConfig types and validation + - Context: Main configuration for the ethstats server + +- [x] **Task #2**: Create ethstats authentication system + - Folder: `pkg/ethstats/auth/` + - Files: `config.go`, `authorization.go`, `groups.go`, `user.go` + - File: `config.go` + - Implements: + ```go + type Config struct { + Enabled bool `yaml:"enabled" default:"true"` + Groups map[string]GroupConfig `yaml:"groups"` + } + + type GroupConfig struct { + Users map[string]UserConfig `yaml:"users"` + } + + type UserConfig struct { + Password string `yaml:"password"` + } + + func (c *Config) Validate() error + func (c *GroupConfig) Validate() error + func (c *UserConfig) Validate() error + ``` + - File: `user.go` + - Implements: + ```go + type User struct { + username string + password string + } + + func NewUser(username string, cfg UserConfig) (*User, error) + func (u *User) Username() string + func (u *User) ValidatePassword(password string) bool + ``` + - File: `groups.go` + - Implements: + ```go + type Group struct { + name string + users map[string]*User + } + + func NewGroup(name string, cfg GroupConfig) (*Group, error) + func (g *Group) Name() string + func (g *Group) ValidateUser(username, password string) bool + func (g *Group) HasUser(username string) bool + ``` + - File: `authorization.go` + - Implements: + ```go + type Authorization struct { + enabled bool + groups map[string]*Group + log logrus.FieldLogger + } + + func NewAuthorization(log logrus.FieldLogger, cfg Config) (*Authorization, error) + func (a *Authorization) Start(ctx context.Context) error + func (a *Authorization) AuthorizeSecret(secret string) (username, group string, err error) + func (a *Authorization) parseSecret(secret string) (username, password string, err error) + ``` + - Exports: Authorization system adapted for ethstats protocol + - Context: Handles base64 encoded username:password authentication + +#### Group C: Connection Management (Execute after Group B) +- [x] **Task #3**: Create connection management system + - Folder: `pkg/ethstats/connection/` + - Files: `manager.go`, `client.go`, `ratelimit.go` + - File: `client.go` + - Imports: + - `time` + - `sync` + - `github.com/gorilla/websocket` + - `github.com/ethpandaops/xatu/pkg/ethstats/protocol` + - Implements: + ```go + type Client struct { + mu sync.RWMutex + conn *websocket.Conn + id string + nodeInfo *protocol.NodeInfo + username string + group string + ip string + connectedAt time.Time + lastSeen time.Time + lastPing time.Time + authenticated bool + closeOnce sync.Once + done chan struct{} + } + + func NewClient(conn *websocket.Conn, ip string) *Client + func (c *Client) ID() string + func (c *Client) SetAuthenticated(id, username, group string, nodeInfo *protocol.NodeInfo) + func (c *Client) IsAuthenticated() bool + func (c *Client) UpdateLastSeen() + func (c *Client) SendMessage(msg []byte) error + func (c *Client) Close() error + func (c *Client) Done() <-chan struct{} + ``` + - File: `ratelimit.go` + - Implements: + ```go + type RateLimiter struct { + mu sync.RWMutex + connections map[string]int + failures map[string]int + windowStart time.Time + windowDuration time.Duration + maxConnections int + maxFailures int + log logrus.FieldLogger + } + + func NewRateLimiter(windowDuration time.Duration, maxConn, maxFail int, log logrus.FieldLogger) *RateLimiter + func (r *RateLimiter) AddConnection(ip string) bool + func (r *RateLimiter) RemoveConnection(ip string) + func (r *RateLimiter) AddFailure(ip string) bool + func (r *RateLimiter) cleanup() + func (r *RateLimiter) getConnectionCount(ip string) int + func (r *RateLimiter) getFailureCount(ip string) int + ``` + - File: `manager.go` + - Implements: + ```go + type Manager struct { + mu sync.RWMutex + clients map[string]*Client + clientsByIP map[string]map[string]*Client + rateLimiter *RateLimiter + metrics *ethstats.Metrics + log logrus.FieldLogger + } + + func NewManager(rateLimiter *RateLimiter, metrics *ethstats.Metrics, log logrus.FieldLogger) *Manager + func (m *Manager) AddClient(client *Client) error + func (m *Manager) RemoveClient(id string) + func (m *Manager) GetClient(id string) (*Client, bool) + func (m *Manager) GetClientsByIP(ip string) []*Client + func (m *Manager) BroadcastMessage(msg []byte) + func (m *Manager) GetStats() (total, authenticated int) + ``` + - Exports: Connection management system with rate limiting + - Context: Manages all client connections and tracks IPs + +#### Group D: Server Implementation (Execute after Group C) +- [x] **Task #4**: Create main ethstats server + - Folder: `pkg/ethstats/` + - File: `server.go` + - Imports: + - `context` + - `fmt` + - `net/http` + - `strings` + - `time` + - `github.com/gorilla/websocket` + - `github.com/sirupsen/logrus` + - `github.com/ethpandaops/xatu/pkg/ethstats/auth` + - `github.com/ethpandaops/xatu/pkg/ethstats/connection` + - `github.com/ethpandaops/xatu/pkg/ethstats/protocol` + - `github.com/ethpandaops/xatu/pkg/observability` + - `github.com/prometheus/client_golang/prometheus/promhttp` + - Implements: + ```go + type Server struct { + config *Config + log logrus.FieldLogger + auth *auth.Authorization + manager *connection.Manager + handler *protocol.Handler + metrics *Metrics + upgrader websocket.Upgrader + httpServer *http.Server + metricsServer *http.Server + } + + func NewServer(log logrus.FieldLogger, config *Config) (*Server, error) + func (s *Server) Start(ctx context.Context) error + func (s *Server) Stop(ctx context.Context) error + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) + func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) + func (s *Server) handleClient(ctx context.Context, client *connection.Client) + func (s *Server) readPump(ctx context.Context, client *connection.Client) + func (s *Server) writePump(ctx context.Context, client *connection.Client) + func (s *Server) extractIPAddress(r *http.Request) string + func (s *Server) startMetricsServer(ctx context.Context) error + ``` + - Exports: Server type and constructor + - Context: Main server implementation with WebSocket upgrade and client handling + +- [x] **Task #5**: Create protocol message handler + - Folder: `pkg/ethstats/protocol/` + - Files: `handler.go`, `ping.go` + - File: `handler.go` + - Imports: + - `context` + - `encoding/json` + - `fmt` + - `time` + - `github.com/sirupsen/logrus` + - `github.com/ethpandaops/xatu/pkg/ethstats/connection` + - Implements: + ```go + type Handler struct { + log logrus.FieldLogger + metrics *ethstats.Metrics + auth *auth.Authorization + manager *connection.Manager + } + + func NewHandler(log logrus.FieldLogger, metrics *ethstats.Metrics, auth *auth.Authorization, manager *connection.Manager) *Handler + func (h *Handler) HandleMessage(ctx context.Context, client *connection.Client, data []byte) error + func (h *Handler) handleHello(ctx context.Context, client *connection.Client, msg *HelloMessage) error + func (h *Handler) handleBlock(ctx context.Context, client *connection.Client, msg *BlockReport) error + func (h *Handler) handleStats(ctx context.Context, client *connection.Client, msg *StatsReport) error + func (h *Handler) handlePending(ctx context.Context, client *connection.Client, msg *PendingReport) error + func (h *Handler) handleNodePing(ctx context.Context, client *connection.Client, msg *NodePing) error + func (h *Handler) handleLatency(ctx context.Context, client *connection.Client, msg *LatencyReport) error + func (h *Handler) handleHistory(ctx context.Context, client *connection.Client, msg *HistoryReport) error + func (h *Handler) handlePrimusPong(ctx context.Context, client *connection.Client, timestamp string) error + ``` + - File: `ping.go` + - Implements: + ```go + type PingManager struct { + ticker *time.Ticker + done chan struct{} + manager *connection.Manager + interval time.Duration + log logrus.FieldLogger + } + + func NewPingManager(manager *connection.Manager, interval time.Duration, log logrus.FieldLogger) *PingManager + func (p *PingManager) Start(ctx context.Context) + func (p *PingManager) Stop() + func (p *PingManager) sendPings() + ``` + - Exports: Message handling and ping/pong management + - Context: Processes all ethstats protocol messages + +#### Group E: Integration (Execute after Group D) +- [x] **Task #6**: Create ethstats command + - Folder: `cmd/` + - File: `ethstats.go` + - Imports: + - `context` + - `os` + - `os/signal` + - `syscall` + - `github.com/sirupsen/logrus` + - `github.com/spf13/cobra` + - `github.com/ethpandaops/xatu/pkg/ethstats` + - `github.com/ethpandaops/xatu/pkg/observability` + - Implements: + ```go + 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) { + // Load configuration + // Initialize logger + // Setup observability + // Create and start server + // Handle shutdown + }, + } + ) + + func init() { + rootCmd.AddCommand(ethstatsCmd) + + ethstatsCmd.Flags().StringVar(ðstatsCfgFile, "config", "ethstats.yaml", "config file") + } + + func loadEthstatsConfigFromFile(file string) (*ethstats.Config, error) + ``` + - Exports: Cobra command for ethstats server + - Context: CLI integration for the ethstats server + +- [x] **Task #7**: Create example configuration + - Folder: Repository root + - File: `example_ethstats.yaml` + - Implements: + ```yaml + # 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 + ``` + - Context: Example configuration demonstrating all features + +--- + +## Implementation Workflow + +This plan file serves as the authoritative checklist for implementation. When implementing: + +### Required Process +1. **Load Plan**: Read this entire plan file before starting +2. **Sync Tasks**: Create TodoWrite tasks matching the checkboxes below +3. **Execute & Update**: For each task: + - Mark TodoWrite as `in_progress` when starting + - Update checkbox `[ ]` to `[x]` when completing + - Mark TodoWrite as `completed` when done +4. **Maintain Sync**: Keep this file and TodoWrite synchronized throughout + +### Critical Rules +- This plan file is the source of truth for progress +- Update checkboxes in real-time as work progresses +- Never lose synchronization between plan file and TodoWrite +- Mark tasks complete only when fully implemented (no placeholders) +- Tasks should be run in parallel, unless there are dependencies, using subtasks, to avoid context bloat. + +### Progress Tracking +The checkboxes above represent the authoritative status of each task. Keep them updated as you work. \ No newline at end of file diff --git a/cmd/ethstats.go b/cmd/ethstats.go new file mode 100644 index 000000000..a306c9927 --- /dev/null +++ b/cmd/ethstats.go @@ -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(ðstatsCfgFile, "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 +} diff --git a/ethstats-implementation-guide.md b/ethstats-implementation-guide.md new file mode 100644 index 000000000..e036c4673 --- /dev/null +++ b/ethstats-implementation-guide.md @@ -0,0 +1,308 @@ +# Ethstats Implementation Guide for Go Server + +## Overview + +Based on analysis of 5 major Ethereum execution layer clients (go-ethereum, Nethermind, Erigon, Besu, and reth), this guide provides comprehensive requirements for building a Go ethstats server that clients can connect to. + +**Note**: Reth does not currently implement ethstats support. + +## Core Protocol Requirements + +### 1. WebSocket Connection + +- **Protocol**: WebSocket (must support both `ws://` and `wss://`) +- **Endpoint**: Most clients expect `/api` endpoint (some will try both root and `/api`) +- **Message Size Limit**: 15 MB (go-ethereum requirement) +- **Timeout**: 5-second handshake timeout is standard +- **Ping/Pong**: Must handle WebSocket-level ping/pong for connection health + +### 2. Authentication + +**URL Format**: `nodename:secret@host:port` + +**Authentication Flow**: +1. Client connects via WebSocket +2. Client sends "hello" message with node info and secret +3. Server validates secret and responds with "ready" message +4. If authentication fails, close connection + +### 3. Message Protocol + +All messages use JSON format with an "emit" wrapper: + +```json +{ + "emit": ["message_type", data_object] +} +``` + +## Message Types + +### Incoming Messages (Client → Server) + +#### 1. **hello** - Initial Authentication +```json +{ + "emit": ["hello", { + "id": "nodename", + "info": { + "name": "nodename", + "node": "Geth/v1.10.0", + "port": 30303, + "net": "1", + "protocol": "eth/66,eth/67", + "api": "no", + "os": "linux", + "os_v": "x64", + "client": "0.1.1", + "canUpdateHistory": true, + "contact": "admin@example.com" // Optional, Besu/Nethermind specific + }, + "secret": "your-secret" + }] +} +``` + +#### 2. **block** - New Block Report +```json +{ + "emit": ["block", { + "id": "nodename", + "block": { + "number": 12345678, + "hash": "0x...", + "parentHash": "0x...", + "timestamp": 1234567890, + "miner": "0x...", + "gasUsed": 15000000, + "gasLimit": 30000000, + "difficulty": "1234567890", + "totalDifficulty": "12345678901234567890", + "transactions": [ + {"hash": "0x..."}, + {"hash": "0x..."} + ], + "transactionsRoot": "0x...", + "stateRoot": "0x...", + "uncles": [] + } + }] +} +``` + +#### 3. **pending** - Pending Transactions Count +```json +{ + "emit": ["pending", { + "id": "nodename", + "stats": { + "pending": 150 + } + }] +} +``` + +#### 4. **stats** - Node Statistics +```json +{ + "emit": ["stats", { + "id": "nodename", + "stats": { + "active": true, + "syncing": false, + "mining": false, + "hashrate": 0, + "peers": 25, + "gasPrice": 30000000000, + "uptime": 100 + } + }] +} +``` + +#### 5. **history** - Historical Blocks +```json +{ + "emit": ["history", { + "id": "nodename", + "history": [ + // Array of block objects (same structure as block report) + ] + }] +} +``` + +#### 6. **node-ping** - Latency Measurement Request +```json +{ + "emit": ["node-ping", { + "id": "nodename", + "clientTime": "timestamp_string" + }] +} +``` + +#### 7. **latency** - Latency Report +```json +{ + "emit": ["latency", { + "id": "nodename", + "latency": 25 + }] +} +``` + +#### 8. **primus::pong::timestamp** - Heartbeat Response +```json +"primus::pong::1234567890123" +``` + +### Outgoing Messages (Server → Client) + +#### 1. **ready** - Authentication Success +```json +{ + "emit": ["ready"] +} +``` + +#### 2. **node-pong** - Latency Measurement Response +```json +{ + "emit": ["node-pong", { + "id": "nodename", + "clientTime": "original_timestamp", + "serverTime": "server_timestamp" + }] +} +``` + +#### 3. **history** - Request Historical Data +```json +{ + "emit": ["history", { + "max": 50, + "min": 1 + }] +} +``` + +#### 4. **primus::ping::timestamp** - Heartbeat Request +```json +"primus::ping::1234567890123" +``` + +## Implementation Requirements + +### 1. Connection Management + +- Support multiple concurrent client connections +- Implement connection pooling with unique node identification +- Handle reconnections gracefully (clients will automatically reconnect) +- Track connection state per client + +### 2. Data Handling + +- **Block Numbers**: Can be hex strings or numbers (handle both) +- **Timestamps**: Unix timestamps (seconds since epoch) +- **Difficulty**: String representation of big integers +- **Gas Price**: Integer (wei), may need to handle very large values +- **Hashes**: Always 0x-prefixed hex strings + +### 3. Update Frequencies + +- **Full Stats Report**: Every 15 seconds (go-ethereum, Erigon, Nethermind) +- **Block Updates**: Real-time on new blocks +- **Pending Updates**: Throttled to max once per second (go-ethereum) +- **Heartbeat**: Primus ping/pong every few seconds + +### 4. Special Considerations + +#### Primus Protocol Support +Some clients expect Primus WebSocket protocol compatibility: +- Messages prefixed with `primus::ping::` are heartbeats +- Must respond with `primus::pong::` + same timestamp +- These are raw strings, not JSON + +#### Client Variations +- **Nethermind**: May send "contact" field in node info +- **Besu**: Sends "contact" field, reports Clique signer status in mining field +- **Go-ethereum**: Throttles transaction updates, reports light client status +- **Erigon**: Always reports mining=false, gasPrice=0 + +#### Error Handling +- Invalid authentication should close connection immediately +- Malformed messages should be logged but not crash server +- Support graceful degradation for missing optional fields + +### 5. Security Considerations + +- Validate all input data to prevent injection attacks +- Implement rate limiting per client +- Use TLS (wss://) in production +- Store secrets securely (hashed, not plaintext) +- Implement connection limits to prevent DoS + +### 6. Monitoring and Logging + +- Log all client connections/disconnections +- Track message rates per client +- Monitor WebSocket connection health +- Implement metrics for server performance + +## Sample Go Server Structure + +```go +type EthstatsServer struct { + clients map[string]*Client + mu sync.RWMutex +} + +type Client struct { + ID string + NodeInfo NodeInfo + Conn *websocket.Conn + LastSeen time.Time +} + +type NodeInfo struct { + Name string `json:"name"` + Node string `json:"node"` + Port int `json:"port"` + Network string `json:"net"` + Protocol string `json:"protocol"` + // ... other fields +} + +type Message struct { + Emit []interface{} `json:"emit"` +} +``` + +## Testing Recommendations + +1. Test with multiple client implementations +2. Verify handling of large block numbers (post-merge) +3. Test reconnection scenarios +4. Validate Primus protocol compatibility +5. Stress test with many concurrent connections +6. Test malformed message handling + +## Common Gotchas + +1. **Message Format**: The "emit" field is always an array with exactly 2 elements +2. **Empty Arrays**: Uncle arrays should be `[]` not `null` +3. **Network ID**: Sometimes sent as string, sometimes as number +4. **Gas Price**: May overflow int64, use big.Int or uint64 +5. **History Requests**: Clients may expect server to request history on login +6. **ID Field**: Some clients generate complex IDs (e.g., "name-keccakhash") +7. **Protocol Versions**: Format varies (e.g., "eth/66,eth/67" vs "eth/66, eth/67") + +## Additional Features to Consider + +1. **Web Dashboard**: Display connected nodes and statistics +2. **Persistence**: Store historical data for analytics +3. **Alerting**: Notify on node disconnections or issues +4. **API**: RESTful API for querying node status +5. **Clustering**: Support multiple server instances +6. **Export**: Prometheus metrics or other monitoring integrations \ No newline at end of file diff --git a/example_ethstats.yaml b/example_ethstats.yaml new file mode 100644 index 000000000..717db60a2 --- /dev/null +++ b/example_ethstats.yaml @@ -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 \ No newline at end of file diff --git a/pkg/ethstats/auth/authorization.go b/pkg/ethstats/auth/authorization.go new file mode 100644 index 000000000..d78a8506c --- /dev/null +++ b/pkg/ethstats/auth/authorization.go @@ -0,0 +1,99 @@ +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") + + return nil +} + +func (a *Authorization) AuthorizeSecret(secret string) (username, group string, err error) { + if !a.enabled { + return "", "", nil + } + + username, password, err := a.parseSecret(secret) + if err != nil { + return "", "", fmt.Errorf("failed to parse secret: %w", err) + } + + for groupName, grp := range a.groups { + if grp.ValidateUser(username, password) { + return username, groupName, nil + } + } + + return "", "", fmt.Errorf("invalid credentials") +} + +func (a *Authorization) parseSecret(secret string) (username, password string, err error){ + // The secret can be in format: + // 1. base64(username:password) + // 2. plain username:password (for compatibility) + + // Try base64 decode first + decoded, err := base64.StdEncoding.DecodeString(secret) + if err == nil { + secret = string(decoded) + } + + // 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") + } + + return username, password, nil +} diff --git a/pkg/ethstats/auth/config.go b/pkg/ethstats/auth/config.go new file mode 100644 index 000000000..8ed668d86 --- /dev/null +++ b/pkg/ethstats/auth/config.go @@ -0,0 +1,56 @@ +package auth + +import "fmt" + +type Config struct { + Enabled bool `yaml:"enabled" default:"true"` + Groups map[string]GroupConfig `yaml:"groups"` +} + +type GroupConfig struct { + Users map[string]UserConfig `yaml:"users"` +} + +type UserConfig struct { + Password string `yaml:"password"` +} + +func (c *Config) Validate() error { + if !c.Enabled { + return nil + } + + if len(c.Groups) == 0 { + return fmt.Errorf("at least one group must be configured when auth is enabled") + } + + for groupName, group := range c.Groups { + if err := group.Validate(); err != nil { + return fmt.Errorf("group %s validation failed: %w", groupName, err) + } + } + + return nil +} + +func (c *GroupConfig) Validate() error { + if len(c.Users) == 0 { + return fmt.Errorf("at least one user must be configured in the group") + } + + for username, user := range c.Users { + if err := user.Validate(); err != nil { + return fmt.Errorf("user %s validation failed: %w", username, err) + } + } + + return nil +} + +func (c *UserConfig) Validate() error { + if c.Password == "" { + return fmt.Errorf("password is required") + } + + return nil +} diff --git a/pkg/ethstats/auth/groups.go b/pkg/ethstats/auth/groups.go new file mode 100644 index 000000000..ea1dc5fe6 --- /dev/null +++ b/pkg/ethstats/auth/groups.go @@ -0,0 +1,49 @@ +package auth + +import "fmt" + +type Group struct { + name string + users map[string]*User +} + +func NewGroup(name string, cfg GroupConfig) (*Group, error) { + if name == "" { + return nil, fmt.Errorf("group name cannot be empty") + } + + group := &Group{ + name: name, + users: make(map[string]*User), + } + + for username, userCfg := range cfg.Users { + user, err := NewUser(username, userCfg) + if err != nil { + return nil, fmt.Errorf("failed to create user %s: %w", username, err) + } + + group.users[username] = user + } + + return group, nil +} + +func (g *Group) Name() string { + return g.name +} + +func (g *Group) ValidateUser(username, password string) bool { + user, exists := g.users[username] + if !exists { + return false + } + + return user.ValidatePassword(password) +} + +func (g *Group) HasUser(username string) bool { + _, exists := g.users[username] + + return exists +} diff --git a/pkg/ethstats/auth/user.go b/pkg/ethstats/auth/user.go new file mode 100644 index 000000000..6c0ce1c38 --- /dev/null +++ b/pkg/ethstats/auth/user.go @@ -0,0 +1,33 @@ +package auth + +import ( + "fmt" +) + +type User struct { + username string + password string +} + +func NewUser(username string, cfg UserConfig) (*User, error) { + if username == "" { + return nil, fmt.Errorf("username cannot be empty") + } + + if cfg.Password == "" { + return nil, fmt.Errorf("password cannot be empty") + } + + return &User{ + username: username, + password: cfg.Password, + }, nil +} + +func (u *User) Username() string { + return u.username +} + +func (u *User) ValidatePassword(password string) bool { + return u.password == password +} diff --git a/pkg/ethstats/config.go b/pkg/ethstats/config.go new file mode 100644 index 000000000..2ce98f94e --- /dev/null +++ b/pkg/ethstats/config.go @@ -0,0 +1,110 @@ +package ethstats + +import ( + "fmt" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/auth" +) + +type Config struct { + Enabled bool `yaml:"enabled" default:"true"` + Addr string `yaml:"addr" default:":8081"` + MetricsAddr string `yaml:"metricsAddr" default:":9090"` + LoggingLevel string `yaml:"logging" default:"info"` + + // WebSocket settings + MaxMessageSize int64 `yaml:"maxMessageSize" default:"15728640"` // 15MB + ReadTimeout time.Duration `yaml:"readTimeout" default:"60s"` + WriteTimeout time.Duration `yaml:"writeTimeout" default:"10s"` + PingInterval time.Duration `yaml:"pingInterval" default:"30s"` + + // Authentication + Auth auth.Config `yaml:"auth"` + + // Rate limiting + RateLimit RateLimitConfig `yaml:"rateLimit"` + + // Labels for metrics + Labels map[string]string `yaml:"labels"` +} + +type RateLimitConfig struct { + Enabled bool `yaml:"enabled" default:"true"` + ConnectionsPerIP int `yaml:"connectionsPerIp" default:"10"` + WindowDuration time.Duration `yaml:"windowDuration" default:"1m"` + FailuresBeforeWarn int `yaml:"failuresBeforeWarn" default:"5"` +} + +func (c *Config) Validate() error { + if c.Addr == "" { + return fmt.Errorf("addr is required") + } + + if c.MaxMessageSize <= 0 { + return fmt.Errorf("maxMessageSize must be positive") + } + + if c.ReadTimeout <= 0 { + return fmt.Errorf("readTimeout must be positive") + } + + if c.WriteTimeout <= 0 { + return fmt.Errorf("writeTimeout must be positive") + } + + if c.PingInterval <= 0 { + return fmt.Errorf("pingInterval must be positive") + } + + if err := c.Auth.Validate(); err != nil { + return fmt.Errorf("auth config validation failed: %w", err) + } + + if err := c.RateLimit.Validate(); err != nil { + return fmt.Errorf("rate limit config validation failed: %w", err) + } + + return nil +} + +func (c *RateLimitConfig) Validate() error { + if c.Enabled { + if c.ConnectionsPerIP <= 0 { + return fmt.Errorf("connectionsPerIP must be positive when rate limiting is enabled") + } + + if c.WindowDuration <= 0 { + return fmt.Errorf("windowDuration must be positive when rate limiting is enabled") + } + + if c.FailuresBeforeWarn < 0 { + return fmt.Errorf("failuresBeforeWarn must be non-negative") + } + } + + return nil +} + +func NewDefaultConfig() *Config { + return &Config{ + Enabled: true, + Addr: ":8081", + MetricsAddr: ":9090", + LoggingLevel: "info", + MaxMessageSize: 15728640, // 15MB + ReadTimeout: 60 * time.Second, + WriteTimeout: 10 * time.Second, + PingInterval: 30 * time.Second, + Auth: auth.Config{ + Enabled: true, + }, + RateLimit: RateLimitConfig{ + Enabled: true, + ConnectionsPerIP: 10, + WindowDuration: time.Minute, + FailuresBeforeWarn: 5, + }, + Labels: make(map[string]string), + } +} diff --git a/pkg/ethstats/connection/client.go b/pkg/ethstats/connection/client.go new file mode 100644 index 000000000..675ed7751 --- /dev/null +++ b/pkg/ethstats/connection/client.go @@ -0,0 +1,135 @@ +package connection + +import ( + "sync" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/protocol" + "github.com/gorilla/websocket" +) + +type Client struct { + mu sync.RWMutex + conn *websocket.Conn + id string + nodeInfo *protocol.NodeInfo + username string + group string + ip string + connectedAt time.Time + lastSeen time.Time + authenticated bool + closeOnce sync.Once + done chan struct{} +} + +func NewClient(conn *websocket.Conn, ip string) *Client { + return &Client{ + conn: conn, + ip: ip, + connectedAt: time.Now(), + lastSeen: time.Now(), + done: make(chan struct{}), + } +} + +func (c *Client) ID() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.id +} + +func (c *Client) SetAuthenticated(id, username, group string, nodeInfo *protocol.NodeInfo) { + c.mu.Lock() + defer c.mu.Unlock() + + c.id = id + c.username = username + c.group = group + c.nodeInfo = nodeInfo + c.authenticated = true +} + +func (c *Client) IsAuthenticated() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.authenticated +} + +func (c *Client) UpdateLastSeen() { + c.mu.Lock() + defer c.mu.Unlock() + + c.lastSeen = time.Now() +} + +func (c *Client) SendMessage(msg []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + + return c.conn.WriteMessage(websocket.TextMessage, msg) +} + +func (c *Client) Close() error { + var err error + + c.closeOnce.Do(func() { + close(c.done) + err = c.conn.Close() + }) + + return err +} + +func (c *Client) Done() <-chan struct{} { + return c.done +} + +// GetConn returns the underlying websocket connection +func (c *Client) GetConn() *websocket.Conn { + return c.conn +} + +// GetNodeInfo returns the node info if authenticated +func (c *Client) GetNodeInfo() *protocol.NodeInfo { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.nodeInfo +} + +// GetGroup returns the authentication group +func (c *Client) GetGroup() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.group +} + +// GetUsername returns the authenticated username +func (c *Client) GetUsername() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.username +} + +// GetIP returns the client IP address +func (c *Client) GetIP() string { + return c.ip +} + +// GetConnectedAt returns the connection time +func (c *Client) GetConnectedAt() time.Time { + return c.connectedAt +} + +// GetLastSeen returns the last seen time +func (c *Client) GetLastSeen() time.Time { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.lastSeen +} diff --git a/pkg/ethstats/connection/manager.go b/pkg/ethstats/connection/manager.go new file mode 100644 index 000000000..ee3f1c779 --- /dev/null +++ b/pkg/ethstats/connection/manager.go @@ -0,0 +1,155 @@ +package connection + +import ( + "fmt" + "sync" + + "github.com/sirupsen/logrus" +) + +type Manager struct { + mu sync.RWMutex + clients map[string]*Client + clientsByIP map[string]map[string]*Client + rateLimiter *RateLimiter + metrics MetricsInterface + log logrus.FieldLogger +} + +func NewManager(rateLimiter *RateLimiter, metrics MetricsInterface, log logrus.FieldLogger) *Manager { + return &Manager{ + clients: make(map[string]*Client), + clientsByIP: make(map[string]map[string]*Client), + rateLimiter: rateLimiter, + metrics: metrics, + log: log, + } +} + +func (m *Manager) AddClient(client *Client) error { + if client == nil { + return fmt.Errorf("client cannot be nil") + } + + // Check rate limit before adding + if m.rateLimiter != nil && !m.rateLimiter.AddConnection(client.ip) { + m.metrics.IncIPRateLimitWarning(client.ip) + + return fmt.Errorf("rate limit exceeded for IP %s", client.ip) + } + + m.mu.Lock() + defer m.mu.Unlock() + + // Initialize IP map if needed + if _, exists := m.clientsByIP[client.ip]; !exists { + m.clientsByIP[client.ip] = make(map[string]*Client) + } + + // Add to temporary ID map initially (will be moved after auth) + tempID := fmt.Sprintf("temp_%p", client) + m.clients[tempID] = client + m.clientsByIP[client.ip][tempID] = client + + m.log.WithFields(logrus.Fields{ + "ip": client.ip, + "temp_id": tempID, + }).Debug("Client connected") + + return nil +} + +func (m *Manager) RemoveClient(id string) { + m.mu.Lock() + defer m.mu.Unlock() + + client, exists := m.clients[id] + if !exists { + return + } + + // Remove from clients map + delete(m.clients, id) + + // Remove from IP map + if ipClients, exists := m.clientsByIP[client.ip]; exists { + delete(ipClients, id) + + if len(ipClients) == 0 { + delete(m.clientsByIP, client.ip) + } + } + + // Update rate limiter + if m.rateLimiter != nil { + m.rateLimiter.RemoveConnection(client.ip) + } + + // Update metrics if authenticated + if client.IsAuthenticated() && client.nodeInfo != nil { + m.metrics.DecConnectedClients(client.nodeInfo.Client, client.nodeInfo.Net) + + // Calculate connection duration + duration := float64(client.lastSeen.Sub(client.connectedAt).Seconds()) + m.metrics.ObserveConnectionDuration(duration, client.nodeInfo.Client) + } + + m.log.WithFields(logrus.Fields{ + "id": id, + "ip": client.ip, + "username": client.username, + "group": client.group, + }).Info("Client disconnected") +} + +func (m *Manager) GetClient(id string) (*Client, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + client, exists := m.clients[id] + + return client, exists +} + +func (m *Manager) GetClientsByIP(ip string) []*Client { + m.mu.RLock() + defer m.mu.RUnlock() + + clients := make([]*Client, 0) + + if ipClients, exists := m.clientsByIP[ip]; exists { + for _, client := range ipClients { + clients = append(clients, client) + } + } + + return clients +} + +func (m *Manager) BroadcastMessage(msg []byte) { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, client := range m.clients { + if client.IsAuthenticated() { + if err := client.SendMessage(msg); err != nil { + m.log.WithError(err).WithField("id", client.ID()).Error("Failed to send message to client") + } + } + } +} + +func (m *Manager) GetStats() (total, authenticated int) { + m.mu.RLock() + defer m.mu.RUnlock() + + total = len(m.clients) + + for _, client := range m.clients { + if client.IsAuthenticated() { + authenticated++ + } + } + + return total, authenticated +} diff --git a/pkg/ethstats/connection/metrics.go b/pkg/ethstats/connection/metrics.go new file mode 100644 index 000000000..85d2bbc6c --- /dev/null +++ b/pkg/ethstats/connection/metrics.go @@ -0,0 +1,9 @@ +package connection + +// MetricsInterface defines the methods needed by the connection manager +type MetricsInterface interface { + IncConnectedClients(nodeType, network string) + DecConnectedClients(nodeType, network string) + IncIPRateLimitWarning(ip string) + ObserveConnectionDuration(duration float64, nodeType string) +} diff --git a/pkg/ethstats/connection/ratelimit.go b/pkg/ethstats/connection/ratelimit.go new file mode 100644 index 000000000..d3f63db92 --- /dev/null +++ b/pkg/ethstats/connection/ratelimit.go @@ -0,0 +1,95 @@ +package connection + +import ( + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +type RateLimiter struct { + mu sync.RWMutex + connections map[string]int + failures map[string]int + windowStart time.Time + windowDuration time.Duration + maxConnections int + maxFailures int + log logrus.FieldLogger +} + +func NewRateLimiter(windowDuration time.Duration, maxConn, maxFail int, log logrus.FieldLogger) *RateLimiter { + return &RateLimiter{ + connections: make(map[string]int), + failures: make(map[string]int), + windowStart: time.Now(), + windowDuration: windowDuration, + maxConnections: maxConn, + maxFailures: maxFail, + log: log, + } +} + +func (r *RateLimiter) AddConnection(ip string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + r.cleanup() + + count := r.connections[ip] + if count >= r.maxConnections { + r.log.WithFields(logrus.Fields{ + "ip": ip, + "count": count, + "max": r.maxConnections, + }).Warn("Connection limit exceeded for IP") + + return false + } + + r.connections[ip]++ + + return true +} + +func (r *RateLimiter) RemoveConnection(ip string) { + r.mu.Lock() + defer r.mu.Unlock() + + if count, exists := r.connections[ip]; exists && count > 0 { + r.connections[ip]-- + if r.connections[ip] == 0 { + delete(r.connections, ip) + } + } +} + +func (r *RateLimiter) AddFailure(ip string) bool { + r.mu.Lock() + defer r.mu.Unlock() + + r.cleanup() + + r.failures[ip]++ + failureCount := r.failures[ip] + + if failureCount >= r.maxFailures { + r.log.WithFields(logrus.Fields{ + "ip": ip, + "failures": failureCount, + "threshold": r.maxFailures, + }).Warn("High failure rate detected for IP") + + return false + } + + return true +} + +func (r *RateLimiter) cleanup() { + now := time.Now() + if now.Sub(r.windowStart) > r.windowDuration { + r.failures = make(map[string]int) + r.windowStart = now + } +} diff --git a/pkg/ethstats/metrics.go b/pkg/ethstats/metrics.go new file mode 100644 index 000000000..05babd12b --- /dev/null +++ b/pkg/ethstats/metrics.go @@ -0,0 +1,121 @@ +package ethstats + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +type Metrics struct { + connectedClients *prometheus.GaugeVec + authenticationTotal *prometheus.CounterVec + messagesReceivedTotal *prometheus.CounterVec + messagesSentTotal *prometheus.CounterVec + protocolErrors *prometheus.CounterVec + connectionDuration *prometheus.HistogramVec + ipRateLimitWarnings *prometheus.CounterVec +} + +func NewMetrics(namespace string) *Metrics { + m := &Metrics{ + connectedClients: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: namespace, + Name: "connected_clients", + Help: "Number of currently connected clients", + }, + []string{"node_type", "network"}, + ), + authenticationTotal: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "authentication_total", + Help: "Total number of authentication attempts", + }, + []string{"status", "group"}, + ), + messagesReceivedTotal: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "messages_received_total", + Help: "Total number of messages received from clients", + }, + []string{"type", "node_id"}, + ), + messagesSentTotal: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "messages_sent_total", + Help: "Total number of messages sent to clients", + }, + []string{"type"}, + ), + protocolErrors: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "protocol_errors_total", + Help: "Total number of protocol errors", + }, + []string{"error_type"}, + ), + connectionDuration: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: namespace, + Name: "connection_duration_seconds", + Help: "Duration of client connections in seconds", + Buckets: prometheus.ExponentialBuckets(1, 2, 15), + }, + []string{"node_type"}, + ), + ipRateLimitWarnings: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: namespace, + Name: "ip_rate_limit_warnings_total", + Help: "Total number of IP rate limit warnings", + }, + []string{"ip"}, + ), + } + + prometheus.MustRegister( + m.connectedClients, + m.authenticationTotal, + m.messagesReceivedTotal, + m.messagesSentTotal, + m.protocolErrors, + m.connectionDuration, + m.ipRateLimitWarnings, + ) + + return m +} + +func (m *Metrics) IncConnectedClients(nodeType, network string) { + m.connectedClients.WithLabelValues(nodeType, network).Inc() +} + +func (m *Metrics) DecConnectedClients(nodeType, network string) { + m.connectedClients.WithLabelValues(nodeType, network).Dec() +} + +func (m *Metrics) IncAuthentication(status, group string) { + m.authenticationTotal.WithLabelValues(status, group).Inc() +} + +func (m *Metrics) IncMessagesReceived(msgType, nodeID string) { + m.messagesReceivedTotal.WithLabelValues(msgType, nodeID).Inc() +} + +func (m *Metrics) IncMessagesSent(msgType string) { + m.messagesSentTotal.WithLabelValues(msgType).Inc() +} + +func (m *Metrics) IncProtocolError(errorType string) { + m.protocolErrors.WithLabelValues(errorType).Inc() +} + +func (m *Metrics) ObserveConnectionDuration(duration float64, nodeType string) { + m.connectionDuration.WithLabelValues(nodeType).Observe(duration) +} + +func (m *Metrics) IncIPRateLimitWarning(ip string) { + m.ipRateLimitWarnings.WithLabelValues(ip).Inc() +} diff --git a/pkg/ethstats/protocol/handler.go b/pkg/ethstats/protocol/handler.go new file mode 100644 index 000000000..4a0e423cc --- /dev/null +++ b/pkg/ethstats/protocol/handler.go @@ -0,0 +1,292 @@ +package protocol + +import ( + "context" + "fmt" + "time" + + "github.com/sirupsen/logrus" +) + +type Handler struct { + log logrus.FieldLogger + metrics MetricsInterface + auth AuthInterface + manager ConnectionManagerInterface +} + +func NewHandler(log logrus.FieldLogger, metrics MetricsInterface, auth AuthInterface, manager ConnectionManagerInterface) *Handler { + return &Handler{ + log: log, + metrics: metrics, + auth: auth, + manager: manager, + } +} + +func (h *Handler) HandleMessage(ctx context.Context, client ClientInterface, data []byte) error { + // Parse base message + msg, err := ParseMessage(data) + if err != nil { + h.log.WithError(err).Debug("Failed to parse message") + h.metrics.IncProtocolError("parse_error") + + return fmt.Errorf("failed to parse message: %w", err) + } + + if len(msg.Emit) == 0 { + h.metrics.IncProtocolError("empty_emit") + + return fmt.Errorf("empty emit array") + } + + // Get message type + msgType, ok := msg.Emit[0].(string) + if !ok { + h.metrics.IncProtocolError("invalid_message_type") + + return fmt.Errorf("invalid message type") + } + + // Log message + h.log.WithFields(logrus.Fields{ + "type": msgType, + "client_id": client.ID(), + }).Debug("Received message") + + // Handle message based on type + switch msgType { + case "hello": + return h.handleHello(ctx, client, msg.Emit) + case "block": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + return h.handleBlock(ctx, client, msg.Emit) + case "stats": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + return h.handleStats(ctx, client, msg.Emit) + case "pending": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + return h.handlePending(ctx, client, msg.Emit) + case "node-ping": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + return h.handleNodePing(ctx, client, msg.Emit) + case "latency": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + return h.handleLatency(ctx, client, msg.Emit) + case "history": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + return h.handleHistory(ctx, client, msg.Emit) + case "primus::pong::": + if !client.IsAuthenticated() { + return fmt.Errorf("client not authenticated") + } + + if len(msg.Emit) > 1 { + if timestamp, ok := msg.Emit[1].(string); ok { + return h.handlePrimusPong(ctx, client, timestamp) + } + } + + return nil + default: + h.log.WithField("type", msgType).Warn("Unknown message type") + h.metrics.IncProtocolError("unknown_message_type") + + return nil + } +} + +func (h *Handler) handleHello(ctx context.Context, client ClientInterface, emit []interface{}) error { + hello, err := ParseHelloMessage(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_hello") + + return fmt.Errorf("failed to parse hello message: %w", err) + } + + // Authorize client + username, group, err := h.auth.AuthorizeSecret(hello.Secret) + if err != nil { + h.metrics.IncAuthentication("failed", "") + h.log.WithError(err).WithField("node", hello.ID).Warn("Authentication failed") + + return fmt.Errorf("authentication failed: %w", err) + } + + // Set client as authenticated + client.SetAuthenticated(hello.ID, username, group, &hello.Info) + h.metrics.IncAuthentication("success", group) + h.metrics.IncConnectedClients(hello.Info.Client, hello.Info.Net) + + h.log.WithFields(logrus.Fields{ + "id": hello.ID, + "username": username, + "group": group, + "client": hello.Info.Client, + "network": hello.Info.Net, + }).Info("Client authenticated") + + // Send ready message + readyMsg := FormatReadyMessage() + if err := client.SendMessage(readyMsg); err != nil { + return fmt.Errorf("failed to send ready message: %w", err) + } + + h.metrics.IncMessagesSent("ready") + + return nil +} + +func (h *Handler) handleBlock(ctx context.Context, client ClientInterface, emit []interface{}) error { + report, err := ParseBlockReport(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_block") + + return fmt.Errorf("failed to parse block report: %w", err) + } + + h.metrics.IncMessagesReceived("block", client.ID()) + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "block_num": report.Block.Number.Value(), + "block_hash": report.Block.Hash, + "parent_hash": report.Block.ParentHash, + "miner": report.Block.Miner, + "gas_used": report.Block.GasUsed, + "gas_limit": report.Block.GasLimit, + "tx_count": len(report.Block.Transactions), + }).Debug("Received block report") + + return nil +} + +func (h *Handler) handleStats(ctx context.Context, client ClientInterface, emit []interface{}) error { + report, err := ParseStatsReport(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_stats") + + return fmt.Errorf("failed to parse stats report: %w", err) + } + + h.metrics.IncMessagesReceived("stats", client.ID()) + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "active": report.Stats.Active, + "syncing": report.Stats.Syncing, + "mining": report.Stats.Mining, + "hashrate": report.Stats.Hashrate, + "peers": report.Stats.Peers, + "gas_price": report.Stats.GasPrice, + "uptime": report.Stats.Uptime, + }).Debug("Received stats report") + + return nil +} + +func (h *Handler) handlePending(ctx context.Context, client ClientInterface, emit []interface{}) error { + report, err := ParsePendingReport(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_pending") + + return fmt.Errorf("failed to parse pending report: %w", err) + } + + h.metrics.IncMessagesReceived("pending", client.ID()) + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "pending": report.Stats.Pending, + }).Debug("Received pending report") + + return nil +} + +func (h *Handler) handleNodePing(ctx context.Context, client ClientInterface, emit []interface{}) error { + ping, err := ParseNodePing(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_node_ping") + + return fmt.Errorf("failed to parse node ping: %w", err) + } + + h.metrics.IncMessagesReceived("node-ping", client.ID()) + + // Send pong response + serverTime := fmt.Sprintf("%d", time.Now().UnixMilli()) + pongMsg := FormatNodePong(ping, serverTime) + + if err := client.SendMessage(pongMsg); err != nil { + return fmt.Errorf("failed to send node pong: %w", err) + } + + h.metrics.IncMessagesSent("node-pong") + + return nil +} + +func (h *Handler) handleLatency(ctx context.Context, client ClientInterface, emit []interface{}) error { + report, err := ParseLatencyReport(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_latency") + + return fmt.Errorf("failed to parse latency report: %w", err) + } + + h.metrics.IncMessagesReceived("latency", client.ID()) + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "latency": report.Latency, + }).Debug("Received latency report") + + return nil +} + +func (h *Handler) handleHistory(ctx context.Context, client ClientInterface, emit []interface{}) error { + report, err := ParseHistoryReport(emit) + if err != nil { + h.metrics.IncProtocolError("invalid_history") + + return fmt.Errorf("failed to parse history report: %w", err) + } + + h.metrics.IncMessagesReceived("history", client.ID()) + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "block_count": len(report.History), + }).Debug("Received history report") + + return nil +} + +func (h *Handler) handlePrimusPong(ctx context.Context, client ClientInterface, timestamp string) error { + h.metrics.IncMessagesReceived("primus::pong::", client.ID()) + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "timestamp": timestamp, + }).Debug("Received primus pong") + + return nil +} diff --git a/pkg/ethstats/protocol/interfaces.go b/pkg/ethstats/protocol/interfaces.go new file mode 100644 index 000000000..383cedca5 --- /dev/null +++ b/pkg/ethstats/protocol/interfaces.go @@ -0,0 +1,28 @@ +package protocol + +// MetricsInterface defines the methods needed by the protocol handler +type MetricsInterface interface { + IncAuthentication(status, group string) + IncMessagesReceived(msgType, nodeID string) + IncMessagesSent(msgType string) + IncProtocolError(errorType string) + IncConnectedClients(nodeType, network string) +} + +// AuthInterface defines the methods needed for authorization +type AuthInterface interface { + AuthorizeSecret(secret string) (username, group string, err error) +} + +// ConnectionManagerInterface defines the methods needed for connection management +type ConnectionManagerInterface interface { + BroadcastMessage(msg []byte) +} + +// ClientInterface defines the methods needed for client interaction +type ClientInterface interface { + ID() string + SetAuthenticated(id, username, group string, nodeInfo *NodeInfo) + IsAuthenticated() bool + SendMessage(msg []byte) error +} diff --git a/pkg/ethstats/protocol/ping.go b/pkg/ethstats/protocol/ping.go new file mode 100644 index 000000000..66bec3443 --- /dev/null +++ b/pkg/ethstats/protocol/ping.go @@ -0,0 +1,64 @@ +package protocol + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" +) + +// PingManagerInterface defines the methods needed by ping manager +type PingManagerInterface interface { + BroadcastMessage(msg []byte) +} + +type PingManager struct { + ticker *time.Ticker + done chan struct{} + manager PingManagerInterface + interval time.Duration + log logrus.FieldLogger +} + +func NewPingManager(manager PingManagerInterface, interval time.Duration, log logrus.FieldLogger) *PingManager { + return &PingManager{ + manager: manager, + interval: interval, + log: log, + done: make(chan struct{}), + } +} + +func (p *PingManager) Start(ctx context.Context) { + p.ticker = time.NewTicker(p.interval) + defer p.ticker.Stop() + + p.log.Info("Starting ping manager") + + for { + select { + case <-ctx.Done(): + p.log.Info("Stopping ping manager") + + return + case <-p.done: + return + case <-p.ticker.C: + p.sendPings() + } + } +} + +func (p *PingManager) Stop() { + close(p.done) +} + +func (p *PingManager) sendPings() { + // Send primus ping to all connected clients + timestamp := time.Now().UnixMilli() + pingMsg := FormatPrimusPing(timestamp) + + p.manager.BroadcastMessage(pingMsg) + + p.log.WithField("timestamp", timestamp).Debug("Sent primus ping") +} diff --git a/pkg/ethstats/protocol/types.go b/pkg/ethstats/protocol/types.go new file mode 100644 index 000000000..06dd163d4 --- /dev/null +++ b/pkg/ethstats/protocol/types.go @@ -0,0 +1,398 @@ +package protocol + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// Message is the wrapper for all ethstats protocol messages +type Message struct { + Emit []interface{} `json:"emit"` +} + +// HelloMessage is sent by the client on connection +type HelloMessage struct { + ID string `json:"id"` + Info NodeInfo `json:"info"` + Secret string `json:"secret"` +} + +// NodeInfo contains information about the connected node +type NodeInfo struct { + Name string `json:"name"` + Node string `json:"node"` + Port int `json:"port"` + Net string `json:"net"` + Protocol string `json:"protocol"` + API string `json:"api"` + OS string `json:"os"` + OSVersion string `json:"osV"` + Client string `json:"client"` + CanUpdateHistory bool `json:"canUpdateHistory"` + Contact string `json:"contact,omitempty"` +} + +// BlockReport is sent by the client to report new blocks +type BlockReport struct { + ID string `json:"id"` + Block Block `json:"block"` +} + +// BlockNumber handles both hex string and number formats +type BlockNumber struct { + value uint64 +} + +// UnmarshalJSON implements json.Unmarshaler +func (bn *BlockNumber) UnmarshalJSON(data []byte) error { + // Try to unmarshal as number first + var num uint64 + if err := json.Unmarshal(data, &num); err == nil { + bn.value = num + + return nil + } + + // Try to unmarshal as string + var str string + if err := json.Unmarshal(data, &str); err != nil { + return fmt.Errorf("block number must be either number or string: %w", err) + } + + // Handle hex string + if strings.HasPrefix(str, "0x") { + val, err := strconv.ParseUint(str[2:], 16, 64) + if err != nil { + return fmt.Errorf("invalid hex block number: %w", err) + } + + bn.value = val + + return nil + } + + // Handle decimal string + val, err := strconv.ParseUint(str, 10, 64) + if err != nil { + return fmt.Errorf("invalid decimal block number: %w", err) + } + + bn.value = val + + return nil +} + +// Value returns the block number as uint64 +func (bn BlockNumber) Value() uint64 { + return bn.value +} + +// MarshalJSON implements json.Marshaler +func (bn BlockNumber) MarshalJSON() ([]byte, error) { + return json.Marshal(bn.value) +} + +// Block represents a block in the blockchain +type Block struct { + Number BlockNumber `json:"number"` + Hash string `json:"hash"` + ParentHash string `json:"parentHash"` + Timestamp int64 `json:"timestamp"` + Miner string `json:"miner"` + GasUsed int64 `json:"gasUsed"` + GasLimit int64 `json:"gasLimit"` + Difficulty string `json:"difficulty"` + TotalDifficulty string `json:"totalDifficulty"` + Transactions []TxHash `json:"transactions"` + TransactionsRoot string `json:"transactionsRoot"` + StateRoot string `json:"stateRoot"` + Uncles []string `json:"uncles"` +} + +// TxHash represents a transaction hash +type TxHash struct { + Hash string `json:"hash"` +} + +// StatsReport contains node statistics +type StatsReport struct { + ID string `json:"id"` + Stats NodeStats `json:"stats"` +} + +// NodeStats represents the current statistics of a node +type NodeStats struct { + Active bool `json:"active"` + Syncing bool `json:"syncing"` + Mining bool `json:"mining"` + Hashrate int64 `json:"hashrate"` + Peers int `json:"peers"` + GasPrice int64 `json:"gasPrice"` + Uptime int `json:"uptime"` +} + +// PendingReport contains pending transaction count +type PendingReport struct { + ID string `json:"id"` + Stats PendingStats `json:"stats"` +} + +// PendingStats contains the pending transaction count +type PendingStats struct { + Pending int `json:"pending"` +} + +// NodePing is sent by the client to measure latency +type NodePing struct { + ID string `json:"id"` + ClientTime string `json:"clientTime"` +} + +// NodePong is the server's response to a ping +type NodePong struct { + ID string `json:"id"` + ClientTime string `json:"clientTime"` + ServerTime string `json:"serverTime"` +} + +// LatencyReport is sent by the client to report measured latency +type LatencyReport struct { + ID string `json:"id"` + Latency int `json:"latency"` +} + +// HistoryRequest requests historical blocks +type HistoryRequest struct { + Max int `json:"max"` + Min int `json:"min"` +} + +// HistoryReport contains historical blocks +type HistoryReport struct { + ID string `json:"id"` + History []Block `json:"history"` +} + +// ParseMessage parses the initial message wrapper +func ParseMessage(data []byte) (*Message, error) { + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + return nil, fmt.Errorf("failed to parse message: %w", err) + } + + return &msg, nil +} + +// ParseHelloMessage parses a hello message from the emit array +func ParseHelloMessage(emit []interface{}) (*HelloMessage, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("hello message requires at least 2 elements") + } + + if emit[0] != "hello" { + return nil, fmt.Errorf("not a hello message") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal hello data: %w", err) + } + + var hello HelloMessage + if err := json.Unmarshal(data, &hello); err != nil { + return nil, fmt.Errorf("failed to unmarshal hello message: %w", err) + } + + return &hello, nil +} + +// ParseBlockReport parses a block report from the emit array +func ParseBlockReport(emit []interface{}) (*BlockReport, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("block report requires at least 2 elements") + } + + if emit[0] != "block" { + return nil, fmt.Errorf("not a block report") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal block data: %w", err) + } + + var report BlockReport + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("failed to unmarshal block report: %w", err) + } + + return &report, nil +} + +// ParseStatsReport parses a stats report from the emit array +func ParseStatsReport(emit []interface{}) (*StatsReport, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("stats report requires at least 2 elements") + } + + if emit[0] != "stats" { + return nil, fmt.Errorf("not a stats report") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal stats data: %w", err) + } + + var report StatsReport + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("failed to unmarshal stats report: %w", err) + } + + return &report, nil +} + +// ParsePendingReport parses a pending report from the emit array +func ParsePendingReport(emit []interface{}) (*PendingReport, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("pending report requires at least 2 elements") + } + + if emit[0] != "pending" { + return nil, fmt.Errorf("not a pending report") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal pending data: %w", err) + } + + var report PendingReport + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("failed to unmarshal pending report: %w", err) + } + + return &report, nil +} + +// ParseNodePing parses a node ping from the emit array +func ParseNodePing(emit []interface{}) (*NodePing, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("node ping requires at least 2 elements") + } + + if emit[0] != "node-ping" { + return nil, fmt.Errorf("not a node ping") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal ping data: %w", err) + } + + var ping NodePing + if err := json.Unmarshal(data, &ping); err != nil { + return nil, fmt.Errorf("failed to unmarshal node ping: %w", err) + } + + return &ping, nil +} + +// ParseLatencyReport parses a latency report from the emit array +func ParseLatencyReport(emit []interface{}) (*LatencyReport, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("latency report requires at least 2 elements") + } + + if emit[0] != "latency" { + return nil, fmt.Errorf("not a latency report") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal latency data: %w", err) + } + + var report LatencyReport + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("failed to unmarshal latency report: %w", err) + } + + return &report, nil +} + +// ParseHistoryReport parses a history report from the emit array +func ParseHistoryReport(emit []interface{}) (*HistoryReport, error) { + if len(emit) < 2 { + return nil, fmt.Errorf("history report requires at least 2 elements") + } + + if emit[0] != "history" { + return nil, fmt.Errorf("not a history report") + } + + data, err := json.Marshal(emit[1]) + if err != nil { + return nil, fmt.Errorf("failed to marshal history data: %w", err) + } + + var report HistoryReport + if err := json.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("failed to unmarshal history report: %w", err) + } + + return &report, nil +} + +// FormatReadyMessage formats a ready message for the client +func FormatReadyMessage() []byte { + msg := map[string]interface{}{ + "emit": []interface{}{"ready"}, + } + data, _ := json.Marshal(msg) + + return data +} + +// FormatNodePong formats a node pong response +func FormatNodePong(ping *NodePing, serverTime string) []byte { + pong := NodePong{ + ID: ping.ID, + ClientTime: ping.ClientTime, + ServerTime: serverTime, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"node-pong", pong}, + } + data, _ := json.Marshal(msg) + + return data +} + +// FormatHistoryRequest formats a history request +func FormatHistoryRequest(maxVal, minVal int) []byte { + req := HistoryRequest{ + Max: maxVal, + Min: minVal, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"history", req}, + } + data, _ := json.Marshal(msg) + + return data +} + +// FormatPrimusPing formats a primus protocol ping +func FormatPrimusPing(timestamp int64) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{"primus::ping::", timestamp}, + } + data, _ := json.Marshal(msg) + + return data +} diff --git a/pkg/ethstats/server.go b/pkg/ethstats/server.go new file mode 100644 index 000000000..ea75672a8 --- /dev/null +++ b/pkg/ethstats/server.go @@ -0,0 +1,291 @@ +package ethstats + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/auth" + "github.com/ethpandaops/xatu/pkg/ethstats/connection" + "github.com/ethpandaops/xatu/pkg/ethstats/protocol" + "github.com/gorilla/websocket" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/sirupsen/logrus" +) + +type Server struct { + config *Config + log logrus.FieldLogger + auth *auth.Authorization + manager *connection.Manager + handler *protocol.Handler + metrics *Metrics + upgrader websocket.Upgrader + httpServer *http.Server + metricsServer *http.Server +} + +func NewServer(log logrus.FieldLogger, config *Config) (*Server, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + // Create metrics + metrics := NewMetrics("xatu_ethstats") + + // Create authorization + authz, err := auth.NewAuthorization(log.WithField("component", "auth"), config.Auth) + if err != nil { + return nil, fmt.Errorf("failed to create authorization: %w", err) + } + + // Create rate limiter + var rateLimiter *connection.RateLimiter + if config.RateLimit.Enabled { + rateLimiter = connection.NewRateLimiter( + config.RateLimit.WindowDuration, + config.RateLimit.ConnectionsPerIP, + config.RateLimit.FailuresBeforeWarn, + log.WithField("component", "ratelimiter"), + ) + } + + // Create connection manager + manager := connection.NewManager(rateLimiter, metrics, log.WithField("component", "manager")) + + // Create protocol handler + handler := protocol.NewHandler(log.WithField("component", "handler"), metrics, authz, manager) + + // Configure WebSocket upgrader + upgrader := websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + // Allow all origins for ethstats compatibility + return true + }, + } + + server := &Server{ + config: config, + log: log, + auth: authz, + manager: manager, + handler: handler, + metrics: metrics, + upgrader: upgrader, + } + + // Create HTTP server + mux := http.NewServeMux() + mux.HandleFunc("/", server.handleWebSocket) + server.httpServer = &http.Server{ + Addr: config.Addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + // Create metrics server + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.Handler()) + server.metricsServer = &http.Server{ + Addr: config.MetricsAddr, + Handler: metricsMux, + ReadHeaderTimeout: 10 * time.Second, + } + + return server, nil +} + +func (s *Server) Start(ctx context.Context) error { + s.log.WithField("addr", s.config.Addr).Info("Starting ethstats server") + + // Start authorization + if err := s.auth.Start(ctx); err != nil { + return fmt.Errorf("failed to start authorization: %w", err) + } + + // Start metrics server + go func() { + s.log.WithField("addr", s.config.MetricsAddr).Info("Starting metrics server") + + if err := s.metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.log.WithError(err).Error("Metrics server error") + } + }() + + // Start ping manager + pingManager := protocol.NewPingManager(s.manager, s.config.PingInterval, s.log.WithField("component", "ping")) + go pingManager.Start(ctx) + + // Start WebSocket server + go func() { + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.log.WithError(err).Error("HTTP server error") + } + }() + + <-ctx.Done() + + return s.Stop(context.Background()) +} + +func (s *Server) Stop(ctx context.Context) error { + s.log.Info("Stopping ethstats server") + + // Shutdown HTTP server + if err := s.httpServer.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown HTTP server: %w", err) + } + + // Shutdown metrics server + if err := s.metricsServer.Shutdown(ctx); err != nil { + return fmt.Errorf("failed to shutdown metrics server: %w", err) + } + + return nil +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + // Extract client IP + ip := s.extractIPAddress(r) + + // Log connection attempt + s.log.WithFields(logrus.Fields{ + "ip": ip, + "uri": r.RequestURI, + "origin": r.Header.Get("Origin"), + }).Debug("WebSocket connection attempt") + + // Upgrade to WebSocket + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + s.log.WithError(err).Error("Failed to upgrade connection") + + return + } + + // Create client + client := connection.NewClient(conn, ip) + + // Add to manager + if err := s.manager.AddClient(client); err != nil { + s.log.WithError(err).WithField("ip", ip).Error("Failed to add client") + conn.Close() + + return + } + + // Handle client in goroutine + go s.handleClient(r.Context(), client) +} + +func (s *Server) handleClient(ctx context.Context, client *connection.Client) { + defer func() { + client.Close() + s.manager.RemoveClient(client.ID()) + }() + + // Create client context + clientCtx, cancel := context.WithCancel(ctx) + defer cancel() + + // Start write pump + go s.writePump(clientCtx, client) + + // Start read pump + s.readPump(clientCtx, client) +} + +func (s *Server) readPump(ctx context.Context, client *connection.Client) { + // Configure connection + conn := client.GetConn() + conn.SetReadLimit(s.config.MaxMessageSize) + _ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) + conn.SetPongHandler(func(string) error { + _ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) + + return nil + }) + + for { + select { + case <-ctx.Done(): + return + case <-client.Done(): + return + default: + // Read message + _, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + s.log.WithError(err).Debug("WebSocket read error") + } + + return + } + + // Update last seen + client.UpdateLastSeen() + + _ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) + + // Handle message + if err := s.handler.HandleMessage(ctx, client, message); err != nil { + s.log.WithError(err).Error("Failed to handle message") + } + } + } +} + +func (s *Server) writePump(ctx context.Context, client *connection.Client) { + ticker := time.NewTicker(s.config.PingInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-client.Done(): + return + case <-ticker.C: + conn := client.GetConn() + _ = conn.SetWriteDeadline(time.Now().Add(s.config.WriteTimeout)) + + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func (s *Server) extractIPAddress(r *http.Request) string { + // Try X-Forwarded-For first + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + // Take the first IP in the chain + parts := strings.Split(xff, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + if ip != "" { + return ip + } + } + } + + // Try X-Real-IP + xri := r.Header.Get("X-Real-IP") + if xri != "" { + return xri + } + + // Fall back to RemoteAddr + host := r.RemoteAddr + if colonIdx := strings.LastIndex(host, ":"); colonIdx != -1 { + host = host[:colonIdx] + } + + return host +} From 719ce04db93eeb7f87e392039eab8d88482d2f16 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Wed, 9 Jul 2025 13:35:24 +1000 Subject: [PATCH 2/3] feat(ethstats): add comprehensive test coverage for ethstats server - Add authorization package tests (config, groups, user management) - Add connection client and manager tests - Add protocol handler and types tests - Add integration tests for server functionality - Add ethstats-client testing tool - Update gitignore for test artifacts --- .gitignore | 4 + pkg/ethstats/auth/authorization.go | 55 ++- pkg/ethstats/auth/authorization_test.go | 265 ++++++++++++ pkg/ethstats/auth/config_test.go | 170 ++++++++ pkg/ethstats/auth/groups_test.go | 133 ++++++ pkg/ethstats/auth/user_test.go | 100 +++++ pkg/ethstats/connection/client.go | 6 + pkg/ethstats/connection/client_test.go | 235 +++++++++++ pkg/ethstats/connection/manager.go | 34 +- pkg/ethstats/integration_test.go | 383 ++++++++++++++++++ pkg/ethstats/protocol/handler.go | 46 ++- pkg/ethstats/protocol/handler_test.go | 515 ++++++++++++++++++++++++ pkg/ethstats/protocol/types_test.go | 364 +++++++++++++++++ pkg/ethstats/server.go | 73 +++- pkg/ethstats/testutil/client.go | 366 +++++++++++++++++ tools/README.md | 42 ++ tools/build.sh | 19 + tools/ethstats-client/README.md | 60 +++ tools/ethstats-client/main.go | 253 ++++++++++++ 19 files changed, 3105 insertions(+), 18 deletions(-) create mode 100644 pkg/ethstats/auth/authorization_test.go create mode 100644 pkg/ethstats/auth/config_test.go create mode 100644 pkg/ethstats/auth/groups_test.go create mode 100644 pkg/ethstats/auth/user_test.go create mode 100644 pkg/ethstats/connection/client_test.go create mode 100644 pkg/ethstats/integration_test.go create mode 100644 pkg/ethstats/protocol/handler_test.go create mode 100644 pkg/ethstats/protocol/types_test.go create mode 100644 pkg/ethstats/testutil/client.go create mode 100644 tools/README.md create mode 100755 tools/build.sh create mode 100644 tools/ethstats-client/README.md create mode 100644 tools/ethstats-client/main.go diff --git a/.gitignore b/.gitignore index 3f3c7e331..8536b2f63 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ sage.yaml .goreleaser.yaml.new .aider* relay-monitor.yaml + +# Development tools binaries +bin/ +tools/ethstats-client/ethstats-client diff --git a/pkg/ethstats/auth/authorization.go b/pkg/ethstats/auth/authorization.go index d78a8506c..f20866a14 100644 --- a/pkg/ethstats/auth/authorization.go +++ b/pkg/ethstats/auth/authorization.go @@ -49,39 +49,90 @@ func (a *Authorization) Start(ctx context.Context) error { 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 } + 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) } + 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") } -func (a *Authorization) parseSecret(secret string) (username, password string, err error){ +func (a *Authorization) parseSecret(secret string) (username, password string, err error) { // 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 { @@ -95,5 +146,7 @@ func (a *Authorization) parseSecret(secret string) (username, password string, e return "", "", fmt.Errorf("username and password cannot be empty") } + a.log.WithField("username", username).Debug("Parsed credentials") + return username, password, nil } diff --git a/pkg/ethstats/auth/authorization_test.go b/pkg/ethstats/auth/authorization_test.go new file mode 100644 index 000000000..159a734d3 --- /dev/null +++ b/pkg/ethstats/auth/authorization_test.go @@ -0,0 +1,265 @@ +package auth + +import ( + "context" + "encoding/base64" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAuthorization(t *testing.T) { + log := logrus.New() + + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid config with groups", + config: Config{ + Enabled: true, + Groups: map[string]GroupConfig{ + "admin": { + Users: map[string]UserConfig{ + "user1": {Password: "pass1"}, + "user2": {Password: "pass2"}, + }, + }, + "readonly": { + Users: map[string]UserConfig{ + "viewer": {Password: "viewpass"}, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "disabled auth", + config: Config{ + Enabled: false, + }, + wantErr: false, + }, + { + name: "invalid group config", + config: Config{ + Enabled: true, + Groups: map[string]GroupConfig{ + "": { // empty group name + Users: map[string]UserConfig{ + "user1": {Password: "pass1"}, + }, + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := NewAuthorization(log, tt.config) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.NotNil(t, auth) + assert.Equal(t, tt.config.Enabled, auth.enabled) + } + }) + } +} + +func TestAuthorization_Start(t *testing.T) { + log := logrus.New() + auth, err := NewAuthorization(log, Config{Enabled: true}) + require.NoError(t, err) + + ctx := context.Background() + err = auth.Start(ctx) + assert.NoError(t, err) +} + +func TestAuthorization_AuthorizeSecret(t *testing.T) { + log := logrus.New() + + config := Config{ + Enabled: true, + Groups: map[string]GroupConfig{ + "admin": { + Users: map[string]UserConfig{ + "admin": {Password: "adminpass"}, + }, + }, + "operators": { + Users: map[string]UserConfig{ + "operator1": {Password: "operpass1"}, + "operator2": {Password: "operpass2"}, + }, + }, + }, + } + + auth, err := NewAuthorization(log, config) + require.NoError(t, err) + + tests := []struct { + name string + secret string + wantUsername string + wantGroup string + wantErr bool + }{ + { + name: "valid admin plain", + secret: "admin:adminpass", + wantUsername: "admin", + wantGroup: "admin", + }, + { + name: "valid admin base64", + secret: base64.StdEncoding.EncodeToString([]byte("admin:adminpass")), + wantUsername: "admin", + wantGroup: "admin", + }, + { + name: "valid operator1", + secret: "operator1:operpass1", + wantUsername: "operator1", + wantGroup: "operators", + }, + { + name: "valid operator2", + secret: "operator2:operpass2", + wantUsername: "operator2", + wantGroup: "operators", + }, + { + name: "invalid password", + secret: "admin:wrongpass", + wantErr: true, + }, + { + name: "non-existent user", + secret: "nobody:pass", + wantErr: true, + }, + { + name: "malformed secret", + secret: "invalidformat", + wantErr: true, + }, + { + name: "empty username", + secret: ":password", + wantErr: true, + }, + { + name: "empty password", + secret: "username:", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + username, group, err := auth.AuthorizeSecret(tt.secret) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantUsername, username) + assert.Equal(t, tt.wantGroup, group) + } + }) + } +} + +func TestAuthorization_AuthorizeSecret_Disabled(t *testing.T) { + log := logrus.New() + auth, err := NewAuthorization(log, Config{Enabled: false}) + require.NoError(t, err) + + username, group, err := auth.AuthorizeSecret("anything") + assert.NoError(t, err) + assert.Empty(t, username) + assert.Empty(t, group) +} + +func TestAuthorization_parseSecret(t *testing.T) { + auth := &Authorization{} + + tests := []struct { + name string + secret string + wantUsername string + wantPassword string + wantErr bool + }{ + { + name: "plain format", + secret: "user:pass", + wantUsername: "user", + wantPassword: "pass", + }, + { + name: "base64 encoded", + secret: base64.StdEncoding.EncodeToString([]byte("user:pass")), + wantUsername: "user", + wantPassword: "pass", + }, + { + name: "with spaces", + secret: " user : pass ", + wantUsername: "user", + wantPassword: "pass", + }, + { + name: "password with colon", + secret: "user:pass:with:colon", + wantUsername: "user", + wantPassword: "pass:with:colon", + }, + { + name: "no colon", + secret: "invalid", + wantErr: true, + }, + { + name: "empty username", + secret: ":pass", + wantErr: true, + }, + { + name: "empty password", + secret: "user:", + wantErr: true, + }, + { + name: "empty secret", + secret: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + username, password, err := auth.parseSecret(tt.secret) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantUsername, username) + assert.Equal(t, tt.wantPassword, password) + } + }) + } +} diff --git a/pkg/ethstats/auth/config_test.go b/pkg/ethstats/auth/config_test.go new file mode 100644 index 000000000..444bb05a3 --- /dev/null +++ b/pkg/ethstats/auth/config_test.go @@ -0,0 +1,170 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid config", + config: Config{ + Enabled: true, + Groups: map[string]GroupConfig{ + "admin": { + Users: map[string]UserConfig{ + "admin": {Password: "pass"}, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "disabled config", + config: Config{ + Enabled: false, + }, + wantErr: false, + }, + { + name: "enabled without groups", + config: Config{ + Enabled: true, + Groups: map[string]GroupConfig{}, + }, + wantErr: true, + }, + { + name: "group without users", + config: Config{ + Enabled: true, + Groups: map[string]GroupConfig{ + "empty": { + Users: map[string]UserConfig{}, + }, + }, + }, + wantErr: true, + }, + { + name: "user without password", + config: Config{ + Enabled: true, + Groups: map[string]GroupConfig{ + "test": { + Users: map[string]UserConfig{ + "user": {Password: ""}, + }, + }, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGroupConfig_Validate(t *testing.T) { + tests := []struct { + name string + config GroupConfig + wantErr bool + }{ + { + name: "valid group", + config: GroupConfig{ + Users: map[string]UserConfig{ + "user1": {Password: "pass1"}, + "user2": {Password: "pass2"}, + }, + }, + wantErr: false, + }, + { + name: "empty users", + config: GroupConfig{ + Users: map[string]UserConfig{}, + }, + wantErr: true, + }, + { + name: "nil users", + config: GroupConfig{ + Users: nil, + }, + wantErr: true, + }, + { + name: "user with empty password", + config: GroupConfig{ + Users: map[string]UserConfig{ + "user": {Password: ""}, + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUserConfig_Validate(t *testing.T) { + tests := []struct { + name string + config UserConfig + wantErr bool + }{ + { + name: "valid user", + config: UserConfig{Password: "validpass"}, + wantErr: false, + }, + { + name: "empty password", + config: UserConfig{Password: ""}, + wantErr: true, + }, + { + name: "whitespace password", + config: UserConfig{Password: " "}, + wantErr: false, // whitespace is technically allowed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/ethstats/auth/groups_test.go b/pkg/ethstats/auth/groups_test.go new file mode 100644 index 000000000..b806f7d9a --- /dev/null +++ b/pkg/ethstats/auth/groups_test.go @@ -0,0 +1,133 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewGroup(t *testing.T) { + tests := []struct { + name string + group string + config GroupConfig + wantErr bool + }{ + { + name: "valid group", + group: "admins", + config: GroupConfig{ + Users: map[string]UserConfig{ + "admin1": {Password: "pass1"}, + "admin2": {Password: "pass2"}, + }, + }, + wantErr: false, + }, + { + name: "empty group name", + group: "", + config: GroupConfig{}, + wantErr: true, + }, + { + name: "invalid user in group", + group: "test", + config: GroupConfig{ + Users: map[string]UserConfig{ + "": {Password: "pass"}, // empty username + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + group, err := NewGroup(tt.group, tt.config) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.NotNil(t, group) + assert.Equal(t, tt.group, group.Name()) + } + }) + } +} + +func TestGroup_ValidateUser(t *testing.T) { + config := GroupConfig{ + Users: map[string]UserConfig{ + "user1": {Password: "correctpass"}, + "user2": {Password: "anotherpass"}, + }, + } + + group, err := NewGroup("testgroup", config) + require.NoError(t, err) + + tests := []struct { + name string + username string + password string + want bool + }{ + { + name: "valid user1", + username: "user1", + password: "correctpass", + want: true, + }, + { + name: "valid user2", + username: "user2", + password: "anotherpass", + want: true, + }, + { + name: "wrong password", + username: "user1", + password: "wrongpass", + want: false, + }, + { + name: "non-existent user", + username: "user3", + password: "anypass", + want: false, + }, + { + name: "empty username", + username: "", + password: "correctpass", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := group.ValidateUser(tt.username, tt.password) + assert.Equal(t, tt.want, result) + }) + } +} + +func TestGroup_HasUser(t *testing.T) { + config := GroupConfig{ + Users: map[string]UserConfig{ + "user1": {Password: "pass1"}, + "user2": {Password: "pass2"}, + }, + } + + group, err := NewGroup("testgroup", config) + require.NoError(t, err) + + assert.True(t, group.HasUser("user1")) + assert.True(t, group.HasUser("user2")) + assert.False(t, group.HasUser("user3")) + assert.False(t, group.HasUser("")) +} diff --git a/pkg/ethstats/auth/user_test.go b/pkg/ethstats/auth/user_test.go new file mode 100644 index 000000000..3817f6048 --- /dev/null +++ b/pkg/ethstats/auth/user_test.go @@ -0,0 +1,100 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewUser(t *testing.T) { + tests := []struct { + name string + username string + config UserConfig + wantErr bool + }{ + { + name: "valid user", + username: "testuser", + config: UserConfig{Password: "testpass"}, + wantErr: false, + }, + { + name: "empty username", + username: "", + config: UserConfig{Password: "testpass"}, + wantErr: true, + }, + { + name: "empty password", + username: "testuser", + config: UserConfig{Password: ""}, + wantErr: true, + }, + { + name: "both empty", + username: "", + config: UserConfig{Password: ""}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, err := NewUser(tt.username, tt.config) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, tt.username, user.Username()) + } + }) + } +} + +func TestUser_ValidatePassword(t *testing.T) { + user, err := NewUser("testuser", UserConfig{Password: "correctpass"}) + require.NoError(t, err) + + tests := []struct { + name string + password string + want bool + }{ + { + name: "correct password", + password: "correctpass", + want: true, + }, + { + name: "wrong password", + password: "wrongpass", + want: false, + }, + { + name: "empty password", + password: "", + want: false, + }, + { + name: "case sensitive", + password: "CORRECTPASS", + want: false, + }, + { + name: "extra spaces", + password: " correctpass ", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := user.ValidatePassword(tt.password) + assert.Equal(t, tt.want, result) + }) + } +} diff --git a/pkg/ethstats/connection/client.go b/pkg/ethstats/connection/client.go index 675ed7751..1dbd1d0b2 100644 --- a/pkg/ethstats/connection/client.go +++ b/pkg/ethstats/connection/client.go @@ -1,6 +1,7 @@ package connection import ( + "fmt" "sync" "time" @@ -69,6 +70,11 @@ func (c *Client) SendMessage(msg []byte) error { c.mu.Lock() defer c.mu.Unlock() + // Set write deadline + if err := c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { + return fmt.Errorf("failed to set write deadline: %w", err) + } + return c.conn.WriteMessage(websocket.TextMessage, msg) } diff --git a/pkg/ethstats/connection/client_test.go b/pkg/ethstats/connection/client_test.go new file mode 100644 index 000000000..27beb0190 --- /dev/null +++ b/pkg/ethstats/connection/client_test.go @@ -0,0 +1,235 @@ +package connection + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/protocol" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewClient(t *testing.T) { + // Create a test WebSocket server + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + require.NoError(t, err) + defer conn.Close() + })) + defer server.Close() + + // Connect to the test server + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer resp.Body.Close() + defer conn.Close() + + client := NewClient(conn, "127.0.0.1") + + assert.NotNil(t, client) + assert.Equal(t, conn, client.conn) + assert.Equal(t, "127.0.0.1", client.ip) + assert.False(t, client.authenticated) + assert.Empty(t, client.id) + assert.NotNil(t, client.done) + assert.WithinDuration(t, time.Now(), client.connectedAt, time.Second) + assert.WithinDuration(t, time.Now(), client.lastSeen, time.Second) +} + +func TestClient_SetAuthenticated(t *testing.T) { + client := &Client{ + mu: sync.RWMutex{}, + } + + nodeInfo := &protocol.NodeInfo{ + Name: "test-node", + Node: "Geth/v1.0.0", + Port: 30303, + Net: "1", + Protocol: "eth/66", + } + + client.SetAuthenticated("test-id", "testuser", "admin", nodeInfo) + + assert.Equal(t, "test-id", client.ID()) + assert.True(t, client.IsAuthenticated()) + assert.Equal(t, "testuser", client.GetUsername()) + assert.Equal(t, "admin", client.GetGroup()) + assert.Equal(t, nodeInfo, client.GetNodeInfo()) +} + +func TestClient_UpdateLastSeen(t *testing.T) { + client := &Client{ + mu: sync.RWMutex{}, + lastSeen: time.Now().Add(-time.Hour), + } + + oldLastSeen := client.GetLastSeen() + time.Sleep(10 * time.Millisecond) + + client.UpdateLastSeen() + newLastSeen := client.GetLastSeen() + + assert.True(t, newLastSeen.After(oldLastSeen)) + assert.WithinDuration(t, time.Now(), newLastSeen, time.Second) +} + +func TestClient_SendMessage(t *testing.T) { + // Create a test WebSocket server that echoes messages + upgrader := websocket.Upgrader{} + receivedMsg := make(chan []byte, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + require.NoError(t, err) + defer conn.Close() + + _, msg, err := conn.ReadMessage() + if err == nil { + receivedMsg <- msg + } + })) + defer server.Close() + + // Connect to the test server + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer resp.Body.Close() + defer conn.Close() + + client := NewClient(conn, "127.0.0.1") + + testMsg := []byte(`{"test": "message"}`) + err = client.SendMessage(testMsg) + assert.NoError(t, err) + + // Wait for message to be received + select { + case msg := <-receivedMsg: + assert.Equal(t, testMsg, msg) + case <-time.After(time.Second): + t.Fatal("timeout waiting for message") + } +} + +func TestClient_Close(t *testing.T) { + // Create a test WebSocket server + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + require.NoError(t, err) + defer conn.Close() + + // Keep connection open + <-make(chan struct{}) + })) + defer server.Close() + + // Connect to the test server + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + defer resp.Body.Close() + + client := NewClient(conn, "127.0.0.1") + + // First close should succeed + err = client.Close() + assert.NoError(t, err) + + // Verify done channel is closed + select { + case <-client.Done(): + // Expected + default: + t.Fatal("done channel should be closed") + } + + // Second close should not panic (due to closeOnce) + err = client.Close() + assert.NoError(t, err) +} + +func TestClient_Getters(t *testing.T) { + connectedAt := time.Now().Add(-time.Hour) + lastSeen := time.Now().Add(-time.Minute) + + nodeInfo := &protocol.NodeInfo{ + Name: "test-node", + } + + client := &Client{ + mu: sync.RWMutex{}, + id: "test-id", + username: "testuser", + group: "admin", + ip: "192.168.1.1", + connectedAt: connectedAt, + lastSeen: lastSeen, + authenticated: true, + nodeInfo: nodeInfo, + } + + assert.Equal(t, "test-id", client.ID()) + assert.Equal(t, "testuser", client.GetUsername()) + assert.Equal(t, "admin", client.GetGroup()) + assert.Equal(t, "192.168.1.1", client.GetIP()) + assert.Equal(t, connectedAt, client.GetConnectedAt()) + assert.Equal(t, lastSeen, client.GetLastSeen()) + assert.True(t, client.IsAuthenticated()) + assert.Equal(t, nodeInfo, client.GetNodeInfo()) +} + +func TestClient_ConcurrentAccess(t *testing.T) { + client := &Client{ + mu: sync.RWMutex{}, + lastSeen: time.Now(), + } + + // Test concurrent reads and writes + var wg sync.WaitGroup + iterations := 100 + + // Reader goroutines + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + _ = client.ID() + _ = client.IsAuthenticated() + _ = client.GetLastSeen() + } + }() + } + + // Writer goroutines + for i := 0; i < 2; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + client.UpdateLastSeen() + if j%10 == 0 { + client.SetAuthenticated( + "id", + "user", + "group", + &protocol.NodeInfo{Name: "node"}, + ) + } + } + }(i) + } + + wg.Wait() + // If we get here without deadlock or panic, the test passes +} diff --git a/pkg/ethstats/connection/manager.go b/pkg/ethstats/connection/manager.go index ee3f1c779..43c563ce4 100644 --- a/pkg/ethstats/connection/manager.go +++ b/pkg/ethstats/connection/manager.go @@ -63,18 +63,39 @@ func (m *Manager) RemoveClient(id string) { m.mu.Lock() defer m.mu.Unlock() + // First check using the provided ID client, exists := m.clients[id] if !exists { - return + // If not found by ID, it might be a temporary ID + // Search through all clients to find one that might have been closed + for tempID, c := range m.clients { + if c.ID() == id || tempID == id { + client = c + id = tempID + exists = true + break + } + } + + if !exists { + return + } } // Remove from clients map delete(m.clients, id) - // Remove from IP map + // Remove from IP map - check all possible IDs if ipClients, exists := m.clientsByIP[client.ip]; exists { + // Remove by the temp ID delete(ipClients, id) + // Also remove by the actual client ID if different + actualID := client.ID() + if actualID != "" && actualID != id { + delete(ipClients, actualID) + } + if len(ipClients) == 0 { delete(m.clientsByIP, client.ip) } @@ -95,10 +116,11 @@ func (m *Manager) RemoveClient(id string) { } m.log.WithFields(logrus.Fields{ - "id": id, - "ip": client.ip, - "username": client.username, - "group": client.group, + "id": id, + "client_id": client.ID(), + "ip": client.ip, + "username": client.username, + "group": client.group, }).Info("Client disconnected") } diff --git a/pkg/ethstats/integration_test.go b/pkg/ethstats/integration_test.go new file mode 100644 index 000000000..ab3ff7abb --- /dev/null +++ b/pkg/ethstats/integration_test.go @@ -0,0 +1,383 @@ +package ethstats_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/auth" + "github.com/ethpandaops/xatu/pkg/ethstats/connection" + "github.com/ethpandaops/xatu/pkg/ethstats/protocol" + "github.com/ethpandaops/xatu/pkg/ethstats/testutil" + "github.com/gorilla/websocket" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEthstatsProtocol_Integration(t *testing.T) { + // Create test logger + log := logrus.New() + log.SetLevel(logrus.DebugLevel) + + // Create auth with test users + authCfg := auth.Config{ + Enabled: true, + Groups: map[string]auth.GroupConfig{ + "admin": { + Users: map[string]auth.UserConfig{ + "testuser": {Password: "testpass"}, + }, + }, + }, + } + authz, err := auth.NewAuthorization(log, authCfg) + require.NoError(t, err) + + // Create metrics + metrics := NewMockMetrics() + + // Create connection manager + manager := connection.NewManager(nil, metrics, log) + + // Create protocol handler + handler := protocol.NewHandler(log, metrics, authz, manager) + + // Create WebSocket test server + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Logf("Upgrade error: %v", err) + + return + } + defer conn.Close() + + // Create client + client := connection.NewClient(conn, r.RemoteAddr) + if err := manager.AddClient(client); err != nil { + t.Logf("Add client error: %v", err) + + return + } + defer manager.RemoveClient(client.ID()) + + // Handle messages + for { + _, message, err := conn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + t.Logf("Read error: %v", err) + } + + break + } + + if err := handler.HandleMessage(context.Background(), client, message); err != nil { + t.Logf("Handle error: %v", err) + // In real server, would close connection on auth failure + if strings.Contains(err.Error(), "authentication failed") { + break + } + } + } + })) + defer server.Close() + + t.Run("successful authentication and communication", func(t *testing.T) { + // Create test client + client := testutil.NewTestClient(server.URL, "test-node", "testuser:testpass") + + // Connect to server + err := client.Connect(context.Background()) + require.NoError(t, err) + defer client.Close() + + // Send hello message + err = client.SendHello() + require.NoError(t, err) + + // Wait for ready message + msg, err := client.WaitForMessage("ready", 2*time.Second) + require.NoError(t, err) + assert.NotNil(t, msg) + assert.True(t, client.IsAuthenticated()) + + // Send block report + block := testutil.CreateTestBlock(12345678, "0xparenthash") + err = client.SendBlock(&block) + assert.NoError(t, err) + + // Send stats + stats := testutil.CreateTestStats(true, false, false, 25) + err = client.SendStats(stats) + assert.NoError(t, err) + + // Send pending count + err = client.SendPending(150) + assert.NoError(t, err) + + // Test ping-pong + err = client.SendNodePing() + assert.NoError(t, err) + + pongMsg, err := client.WaitForMessage("node-pong", time.Second) + require.NoError(t, err) + assert.NotNil(t, pongMsg) + + // Send latency report + err = client.SendLatency(25) + assert.NoError(t, err) + + // Verify metrics were called + assert.True(t, metrics.WasCalled("IncAuthentication")) + assert.True(t, metrics.WasCalled("IncConnectedClients")) + assert.True(t, metrics.WasCalled("IncMessagesReceived")) + assert.True(t, metrics.WasCalled("IncMessagesSent")) + }) + + t.Run("failed authentication", func(t *testing.T) { + // Create client with wrong credentials + client := testutil.NewTestClient(server.URL, "bad-node", "wronguser:wrongpass") + + // Connect to server + err := client.Connect(context.Background()) + require.NoError(t, err) + defer client.Close() + + // Send hello message + err = client.SendHello() + require.NoError(t, err) + + // Should not receive ready message + _, err = client.WaitForMessage("ready", 500*time.Millisecond) + assert.Error(t, err) + assert.False(t, client.IsAuthenticated()) + + // Verify auth failure was recorded + assert.True(t, metrics.WasCalled("IncAuthentication")) + }) + + t.Run("messages before authentication", func(t *testing.T) { + // Create test client + client := testutil.NewTestClient(server.URL, "test-node2", "testuser:testpass") + + // Connect to server + err := client.Connect(context.Background()) + require.NoError(t, err) + defer client.Close() + + // Try to send block before authentication + block := testutil.CreateTestBlock(12345679, "0xparenthash2") + err = client.SendBlock(&block) + assert.NoError(t, err) // Send succeeds at client level + + // Should not process the message + time.Sleep(100 * time.Millisecond) + + // Now authenticate + err = client.SendHello() + require.NoError(t, err) + + // Should receive ready message + _, err = client.WaitForMessage("ready", time.Second) + require.NoError(t, err) + }) + + t.Run("primus protocol support", func(t *testing.T) { + // Create test client + client := testutil.NewTestClient(server.URL, "primus-node", "testuser:testpass") + + // Connect and authenticate + err := client.Connect(context.Background()) + require.NoError(t, err) + defer client.Close() + + err = client.SendHello() + require.NoError(t, err) + + _, err = client.WaitForMessage("ready", 2*time.Second) + require.NoError(t, err) + + // Send primus pong + err = client.SendPrimusPong("1234567890") + assert.NoError(t, err) + + // Verify it was received + assert.True(t, metrics.WasCalled("IncMessagesReceived")) + }) + + t.Run("history report", func(t *testing.T) { + // Create test client + client := testutil.NewTestClient(server.URL, "history-node", "testuser:testpass") + + // Connect and authenticate + err := client.Connect(context.Background()) + require.NoError(t, err) + defer client.Close() + + err = client.SendHello() + require.NoError(t, err) + + _, err = client.WaitForMessage("ready", 2*time.Second) + require.NoError(t, err) + + // Send history report + blocks := []protocol.Block{ + testutil.CreateTestBlock(12345676, "0xparenthash0"), + testutil.CreateTestBlock(12345677, "0xparenthash1"), + testutil.CreateTestBlock(12345678, "0xparenthash2"), + } + err = client.SendHistory(blocks) + assert.NoError(t, err) + + // Verify it was received + assert.True(t, metrics.WasCalled("IncMessagesReceived")) + }) +} + +func TestEthstatsProtocol_DisabledAuth(t *testing.T) { + // Create test logger + log := logrus.New() + log.SetLevel(logrus.WarnLevel) + + // Create auth with disabled mode + authCfg := auth.Config{ + Enabled: false, + } + authz, err := auth.NewAuthorization(log, authCfg) + require.NoError(t, err) + + // Create other components + metrics := NewMockMetrics() + manager := connection.NewManager(nil, metrics, log) + handler := protocol.NewHandler(log, metrics, authz, manager) + + // Create WebSocket test server + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer conn.Close() + + client := connection.NewClient(conn, r.RemoteAddr) + if errAdd := manager.AddClient(client); errAdd != nil { + return + } + defer manager.RemoveClient(client.ID()) + + for { + _, message, readErr := conn.ReadMessage() + if readErr != nil { + break + } + _ = handler.HandleMessage(context.Background(), client, message) + } + })) + defer server.Close() + + // Create client with any credentials + client := testutil.NewTestClient(server.URL, "any-node", "any:secret") + + // Connect to server + err = client.Connect(context.Background()) + require.NoError(t, err) + defer client.Close() + + // Send hello message + err = client.SendHello() + require.NoError(t, err) + + // Should receive ready message even with any credentials + msg, err := client.WaitForMessage("ready", 2*time.Second) + require.NoError(t, err) + assert.NotNil(t, msg) + assert.True(t, client.IsAuthenticated()) +} + +// Mock metrics implementation for testing +type MockMetrics struct { + mu sync.Mutex + calls map[string]int +} + +func NewMockMetrics() *MockMetrics { + return &MockMetrics{ + calls: make(map[string]int), + } +} + +func (m *MockMetrics) IncProtocolError(errorType string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncProtocolError"]++ +} + +func (m *MockMetrics) IncAuthentication(status, group string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncAuthentication"]++ +} + +func (m *MockMetrics) IncConnectedClients(client, network string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncConnectedClients"]++ +} + +func (m *MockMetrics) IncMessagesSent(msgType string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncMessagesSent"]++ +} + +func (m *MockMetrics) IncMessagesReceived(msgType, clientID string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncMessagesReceived"]++ +} + +func (m *MockMetrics) DecConnectedClients(nodeType, network string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["DecConnectedClients"]++ +} + +func (m *MockMetrics) IncIPRateLimitWarning(ip string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncIPRateLimitWarning"]++ +} + +func (m *MockMetrics) ObserveConnectionDuration(duration float64, nodeType string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["ObserveConnectionDuration"]++ +} + +func (m *MockMetrics) WasCalled(method string) bool { + m.mu.Lock() + defer m.mu.Unlock() + + return m.calls[method] > 0 +} + +func (m *MockMetrics) CallCount(method string) int { + m.mu.Lock() + defer m.mu.Unlock() + + return m.calls[method] +} diff --git a/pkg/ethstats/protocol/handler.go b/pkg/ethstats/protocol/handler.go index 4a0e423cc..b5b26c971 100644 --- a/pkg/ethstats/protocol/handler.go +++ b/pkg/ethstats/protocol/handler.go @@ -25,6 +25,12 @@ func NewHandler(log logrus.FieldLogger, metrics MetricsInterface, auth AuthInter } func (h *Handler) HandleMessage(ctx context.Context, client ClientInterface, data []byte) error { + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "data_size": len(data), + "data": string(data), + }).Debug("Handling message") + // Parse base message msg, err := ParseMessage(data) if err != nil { @@ -52,7 +58,8 @@ func (h *Handler) HandleMessage(ctx context.Context, client ClientInterface, dat h.log.WithFields(logrus.Fields{ "type": msgType, "client_id": client.ID(), - }).Debug("Received message") + "emit_len": len(msg.Emit), + }).Info("Processing message") // Handle message based on type switch msgType { @@ -115,6 +122,8 @@ func (h *Handler) HandleMessage(ctx context.Context, client ClientInterface, dat } func (h *Handler) handleHello(ctx context.Context, client ClientInterface, emit []interface{}) error { + h.log.Debug("Processing hello message") + hello, err := ParseHelloMessage(emit) if err != nil { h.metrics.IncProtocolError("invalid_hello") @@ -122,13 +131,33 @@ func (h *Handler) handleHello(ctx context.Context, client ClientInterface, emit return fmt.Errorf("failed to parse hello message: %w", err) } + h.log.WithFields(logrus.Fields{ + "node_id": hello.ID, + "node_name": hello.Info.Name, + "client": hello.Info.Client, + "network": hello.Info.Net, + "secret_len": len(hello.Secret), + }).Info("Received hello message") + // Authorize client username, group, err := h.auth.AuthorizeSecret(hello.Secret) if err != nil { h.metrics.IncAuthentication("failed", "") h.log.WithError(err).WithField("node", hello.ID).Warn("Authentication failed") - return fmt.Errorf("authentication failed: %w", err) + // Don't immediately close or respond on auth failure + // Instead, schedule a delayed close to prevent timing attacks + go func() { + time.Sleep(5 * time.Second) + h.log.WithField("node", hello.ID).Info("Closing connection after authentication failure") + + if closer, ok := client.(interface{ Close() error }); ok { + _ = closer.Close() + } + }() + + // Return nil to prevent immediate error response + return nil } // Set client as authenticated @@ -146,11 +175,24 @@ func (h *Handler) handleHello(ctx context.Context, client ClientInterface, emit // Send ready message readyMsg := FormatReadyMessage() + + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "message": string(readyMsg), + }).Debug("Sending ready message") + if err := client.SendMessage(readyMsg); err != nil { + h.log.WithError(err).Error("Failed to send ready message") + return fmt.Errorf("failed to send ready message: %w", err) } h.metrics.IncMessagesSent("ready") + h.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "message_size": len(readyMsg), + "authenticated": client.IsAuthenticated(), + }).Info("Ready message sent successfully") return nil } diff --git a/pkg/ethstats/protocol/handler_test.go b/pkg/ethstats/protocol/handler_test.go new file mode 100644 index 000000000..85d0e8490 --- /dev/null +++ b/pkg/ethstats/protocol/handler_test.go @@ -0,0 +1,515 @@ +package protocol + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +// Mock implementations +type MockMetrics struct { + mu sync.Mutex + calls map[string][]interface{} +} + +func NewMockMetrics() *MockMetrics { + return &MockMetrics{ + calls: make(map[string][]interface{}), + } +} + +func (m *MockMetrics) IncProtocolError(errorType string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncProtocolError"] = append(m.calls["IncProtocolError"], errorType) +} + +func (m *MockMetrics) IncAuthentication(status, group string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncAuthentication"] = append(m.calls["IncAuthentication"], []any{status, group}) +} + +func (m *MockMetrics) IncConnectedClients(client, network string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncConnectedClients"] = append(m.calls["IncConnectedClients"], []any{client, network}) +} + +func (m *MockMetrics) IncMessagesSent(msgType string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncMessagesSent"] = append(m.calls["IncMessagesSent"], msgType) +} + +func (m *MockMetrics) IncMessagesReceived(msgType, clientID string) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls["IncMessagesReceived"] = append(m.calls["IncMessagesReceived"], []any{msgType, clientID}) +} + +func (m *MockMetrics) AssertCalled(t *testing.T, method string, times int) { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + assert.Equal(t, times, len(m.calls[method]), "Expected %s to be called %d times, but was called %d times", method, times, len(m.calls[method])) +} + +type MockAuth struct { + authorizeFunc func(secret string) (username, group string, err error) +} + +func (m *MockAuth) AuthorizeSecret(secret string) (username, group string, err error) { + if m.authorizeFunc != nil { + return m.authorizeFunc(secret) + } + + return "", "", errors.New("not implemented") +} + +type MockConnectionManager struct{} + +func (m *MockConnectionManager) BroadcastMessage(msg []byte) { + // Mock implementation +} + +func (m *MockConnectionManager) AddClient(client ClientInterface) {} +func (m *MockConnectionManager) RemoveClient(id string) {} +func (m *MockConnectionManager) GetClient(id string) (ClientInterface, bool) { + return nil, false +} + +type MockClient struct { + authenticated bool + id string + nodeInfo *NodeInfo + username string + group string + sentMessages [][]byte + sendError error +} + +func (m *MockClient) ID() string { + return m.id +} + +func (m *MockClient) IsAuthenticated() bool { + return m.authenticated +} + +func (m *MockClient) SetAuthenticated(id, username, group string, nodeInfo *NodeInfo) { + m.authenticated = true + m.id = id + m.username = username + m.group = group + m.nodeInfo = nodeInfo +} + +func (m *MockClient) SendMessage(msg []byte) error { + if m.sendError != nil { + return m.sendError + } + m.sentMessages = append(m.sentMessages, msg) + + return nil +} + +func TestNewHandler(t *testing.T) { + log := logrus.New() + metrics := NewMockMetrics() + auth := &MockAuth{} + manager := &MockConnectionManager{} + + handler := NewHandler(log, metrics, auth, manager) + + assert.NotNil(t, handler) + assert.Equal(t, log, handler.log) +} + +func TestHandler_HandleMessage_InvalidJSON(t *testing.T) { + handler := createTestHandler() + client := &MockClient{} + + err := handler.HandleMessage(context.Background(), client, []byte("invalid json")) + + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "failed to parse message") + } + handler.metrics.(*MockMetrics).AssertCalled(t, "IncProtocolError", 1) +} + +func TestHandler_HandleMessage_EmptyEmit(t *testing.T) { + handler := createTestHandler() + client := &MockClient{} + + msg := `{"emit":[]}` + err := handler.HandleMessage(context.Background(), client, []byte(msg)) + + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "empty emit array") + } + handler.metrics.(*MockMetrics).AssertCalled(t, "IncProtocolError", 1) +} + +func TestHandler_HandleMessage_InvalidMessageType(t *testing.T) { + handler := createTestHandler() + client := &MockClient{id: "test-client"} + + msg := `{"emit":[123, {}]}` + err := handler.HandleMessage(context.Background(), client, []byte(msg)) + + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "invalid message type") + } + handler.metrics.(*MockMetrics).AssertCalled(t, "IncProtocolError", 1) +} + +func TestHandler_HandleMessage_Hello_Success(t *testing.T) { + handler := createTestHandler() + client := &MockClient{} + + // Setup auth mock + handler.auth.(*MockAuth).authorizeFunc = func(secret string) (string, string, error) { + if secret == "test-secret" { + return "testuser", "admin", nil + } + + return "", "", errors.New("invalid secret") + } + + helloMsg := createHelloMessage("test-node", "test-secret") + + err := handler.HandleMessage(context.Background(), client, helloMsg) + + assert.NoError(t, err) + assert.True(t, client.authenticated) + assert.Equal(t, "test-node", client.id) + assert.Equal(t, "testuser", client.username) + assert.Equal(t, "admin", client.group) + assert.Len(t, client.sentMessages, 1) // Ready message + + handler.metrics.(*MockMetrics).AssertCalled(t, "IncAuthentication", 1) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncConnectedClients", 1) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesSent", 1) +} + +func TestHandler_HandleMessage_Hello_AuthFailure(t *testing.T) { + handler := createTestHandler() + client := &MockClient{} + + // Setup auth mock to fail + handler.auth.(*MockAuth).authorizeFunc = func(secret string) (string, string, error) { + return "", "", errors.New("invalid credentials") + } + + helloMsg := createHelloMessage("test-node", "wrong-secret") + + err := handler.HandleMessage(context.Background(), client, helloMsg) + + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "authentication failed") + } + assert.False(t, client.authenticated) + + handler.metrics.(*MockMetrics).AssertCalled(t, "IncAuthentication", 1) +} + +func TestHandler_HandleMessage_Block_Authenticated(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + blockMsg := createBlockMessage("test-node", 12345678) + + err := handler.HandleMessage(context.Background(), client, blockMsg) + + assert.NoError(t, err) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) +} + +func TestHandler_HandleMessage_Block_NotAuthenticated(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: false, id: "test-node"} + + blockMsg := createBlockMessage("test-node", 12345678) + + err := handler.HandleMessage(context.Background(), client, blockMsg) + + if assert.Error(t, err) { + assert.Contains(t, err.Error(), "client not authenticated") + } +} + +func TestHandler_HandleMessage_Stats(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + statsMsg := createStatsMessage("test-node", true, false, 25) + + err := handler.HandleMessage(context.Background(), client, statsMsg) + + assert.NoError(t, err) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) +} + +func TestHandler_HandleMessage_Pending(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + pendingMsg := createPendingMessage("test-node", 150) + + err := handler.HandleMessage(context.Background(), client, pendingMsg) + + assert.NoError(t, err) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) +} + +func TestHandler_HandleMessage_NodePing(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + pingMsg := createNodePingMessage("test-node", "1234567890") + + err := handler.HandleMessage(context.Background(), client, pingMsg) + + assert.NoError(t, err) + assert.Len(t, client.sentMessages, 1) // Pong response + + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesSent", 1) +} + +func TestHandler_HandleMessage_Latency(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + latencyMsg := createLatencyMessage("test-node", 25) + + err := handler.HandleMessage(context.Background(), client, latencyMsg) + + assert.NoError(t, err) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) +} + +func TestHandler_HandleMessage_History(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + historyMsg := createHistoryMessage("test-node", 1) + + err := handler.HandleMessage(context.Background(), client, historyMsg) + + assert.NoError(t, err) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) +} + +func TestHandler_HandleMessage_PrimusPong(t *testing.T) { + handler := createTestHandler() + client := &MockClient{authenticated: true, id: "test-node"} + + primusPongMsg := createPrimusPongMessage("1234567890") + + err := handler.HandleMessage(context.Background(), client, primusPongMsg) + + assert.NoError(t, err) + handler.metrics.(*MockMetrics).AssertCalled(t, "IncMessagesReceived", 1) +} + +func TestHandler_HandleMessage_UnknownType(t *testing.T) { + handler := createTestHandler() + client := &MockClient{id: "test-node"} + + msg := `{"emit":["unknown-type", {}]}` + + err := handler.HandleMessage(context.Background(), client, []byte(msg)) + + assert.NoError(t, err) // Unknown messages don't return error + handler.metrics.(*MockMetrics).AssertCalled(t, "IncProtocolError", 1) +} + +// Helper functions +func createTestHandler() *Handler { + log := logrus.New() + log.SetLevel(logrus.DebugLevel) + + return &Handler{ + log: log, + metrics: NewMockMetrics(), + auth: &MockAuth{}, + manager: &MockConnectionManager{}, + } +} + +func createHelloMessage(id, secret string) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{ + "hello", + map[string]interface{}{ + "id": id, + "info": map[string]interface{}{ + "name": id, + "node": "Geth/v1.10.0", + "port": 30303, + "net": "1", + "protocol": "eth/66", + "api": "no", + "os": "linux", + "os_v": "x64", + "client": "test-client", + "canUpdateHistory": true, + }, + "secret": secret, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createBlockMessage(id string, blockNumber uint64) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{ + "block", + map[string]interface{}{ + "id": id, + "block": map[string]interface{}{ + "number": blockNumber, + "hash": "0x1234", + "parentHash": "0x5678", + "timestamp": time.Now().Unix(), + "miner": "0xabc", + "gasUsed": 15000000, + "gasLimit": 30000000, + "difficulty": "1234567890", + "totalDifficulty": "12345678901234567890", + "transactions": []interface{}{}, + "transactionsRoot": "0xtxroot", + "stateRoot": "0xstateroot", + "uncles": []interface{}{}, + }, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createStatsMessage(id string, active, syncing bool, peers int) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{ + "stats", + map[string]interface{}{ + "id": id, + "stats": map[string]interface{}{ + "active": active, + "syncing": syncing, + "mining": false, + "hashrate": 0, + "peers": peers, + "gasPrice": 30000000000, + "uptime": 100, + }, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createPendingMessage(id string, pending int) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{ + "pending", + map[string]interface{}{ + "id": id, + "stats": map[string]interface{}{ + "pending": pending, + }, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createNodePingMessage(id, clientTime string) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{ + "node-ping", + map[string]interface{}{ + "id": id, + "clientTime": clientTime, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createLatencyMessage(id string, latency int) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{ + "latency", + map[string]interface{}{ + "id": id, + "latency": latency, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createHistoryMessage(id string, blockCount int) []byte { + blocks := make([]interface{}, blockCount) + for i := 0; i < blockCount; i++ { + blocks[i] = map[string]interface{}{ + "number": 12345677 + i, + "hash": "0x1234", + "parentHash": "0x5678", + "timestamp": time.Now().Unix(), + "miner": "0xabc", + "gasUsed": 15000000, + "gasLimit": 30000000, + "difficulty": "1234567890", + "totalDifficulty": "12345678901234567890", + "transactions": []interface{}{}, + "transactionsRoot": "0xtxroot", + "stateRoot": "0xstateroot", + "uncles": []interface{}{}, + } + } + + msg := map[string]interface{}{ + "emit": []interface{}{ + "history", + map[string]interface{}{ + "id": id, + "history": blocks, + }, + }, + } + data, _ := json.Marshal(msg) + + return data +} + +func createPrimusPongMessage(timestamp string) []byte { + msg := map[string]interface{}{ + "emit": []interface{}{"primus::pong::", timestamp}, + } + data, _ := json.Marshal(msg) + + return data +} diff --git a/pkg/ethstats/protocol/types_test.go b/pkg/ethstats/protocol/types_test.go new file mode 100644 index 000000000..638f02494 --- /dev/null +++ b/pkg/ethstats/protocol/types_test.go @@ -0,0 +1,364 @@ +package protocol + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBlockNumber_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + expected uint64 + wantErr bool + }{ + { + name: "number format", + input: "12345678", + expected: 12345678, + }, + { + name: "hex string format", + input: `"0xbc614e"`, + expected: 12345678, + }, + { + name: "decimal string format", + input: `"12345678"`, + expected: 12345678, + }, + { + name: "invalid hex", + input: `"0xzzzz"`, + wantErr: true, + }, + { + name: "invalid decimal string", + input: `"abc123"`, + wantErr: true, + }, + { + name: "invalid format", + input: `true`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var bn BlockNumber + err := json.Unmarshal([]byte(tt.input), &bn) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, bn.Value()) + } + }) + } +} + +func TestBlockNumber_MarshalJSON(t *testing.T) { + bn := &BlockNumber{} + data, _ := json.Marshal(uint64(12345678)) + _ = bn.UnmarshalJSON(data) + + marshaled, err := json.Marshal(bn) + require.NoError(t, err) + assert.Equal(t, "12345678", string(marshaled)) +} + +func TestParseMessage(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid message", + input: `{"emit":["hello",{"id":"test"}]}`, + }, + { + name: "empty emit array", + input: `{"emit":[]}`, + }, + { + name: "invalid json", + input: `{invalid}`, + wantErr: true, + }, + { + name: "missing emit field", + input: `{"data":"test"}`, + wantErr: false, // Will parse but emit will be nil + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, err := ParseMessage([]byte(tt.input)) + + if tt.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.NotNil(t, msg) + } + }) + } +} + +func TestParseHelloMessage(t *testing.T) { + validHello := []interface{}{ + "hello", + map[string]interface{}{ + "id": "test-node", + "info": map[string]interface{}{ + "name": "test-node", + "node": "Geth/v1.10.0", + "port": 30303, + "net": "1", + "protocol": "eth/66", + "api": "no", + "os": "linux", + "os_v": "x64", + "client": "0.1.1", + "canUpdateHistory": true, + }, + "secret": "test-secret", + }, + } + + hello, err := ParseHelloMessage(validHello) + require.NoError(t, err) + assert.Equal(t, "test-node", hello.ID) + assert.Equal(t, "test-secret", hello.Secret) + assert.Equal(t, "test-node", hello.Info.Name) + assert.Equal(t, "Geth/v1.10.0", hello.Info.Node) + assert.Equal(t, 30303, hello.Info.Port) + assert.Equal(t, "1", hello.Info.Net) + assert.Equal(t, "eth/66", hello.Info.Protocol) + assert.True(t, hello.Info.CanUpdateHistory) + + // Test invalid cases + _, err = ParseHelloMessage([]interface{}{"hello"}) + assert.Error(t, err, "should fail with too few elements") + + _, err = ParseHelloMessage([]interface{}{"not-hello", map[string]interface{}{}}) + assert.Error(t, err, "should fail with wrong message type") +} + +func TestParseBlockReport(t *testing.T) { + validBlock := []interface{}{ + "block", + map[string]interface{}{ + "id": "test-node", + "block": map[string]interface{}{ + "number": 12345678, + "hash": "0x1234", + "parentHash": "0x5678", + "timestamp": 1234567890, + "miner": "0xabc", + "gasUsed": 15000000, + "gasLimit": 30000000, + "difficulty": "1234567890", + "totalDifficulty": "12345678901234567890", + "transactions": []interface{}{map[string]interface{}{"hash": "0xtx1"}}, + "transactionsRoot": "0xtxroot", + "stateRoot": "0xstateroot", + "uncles": []interface{}{}, + }, + }, + } + + report, err := ParseBlockReport(validBlock) + require.NoError(t, err) + assert.Equal(t, "test-node", report.ID) + assert.Equal(t, uint64(12345678), report.Block.Number.Value()) + assert.Equal(t, "0x1234", report.Block.Hash) + assert.Equal(t, "0x5678", report.Block.ParentHash) + assert.Equal(t, int64(1234567890), report.Block.Timestamp) + assert.Equal(t, "0xabc", report.Block.Miner) + assert.Equal(t, 1, len(report.Block.Transactions)) + assert.Equal(t, 0, len(report.Block.Uncles)) +} + +func TestParseStatsReport(t *testing.T) { + validStats := []interface{}{ + "stats", + map[string]interface{}{ + "id": "test-node", + "stats": map[string]interface{}{ + "active": true, + "syncing": false, + "mining": false, + "hashrate": 0, + "peers": 25, + "gasPrice": 30000000000, + "uptime": 100, + }, + }, + } + + report, err := ParseStatsReport(validStats) + require.NoError(t, err) + assert.Equal(t, "test-node", report.ID) + assert.True(t, report.Stats.Active) + assert.False(t, report.Stats.Syncing) + assert.False(t, report.Stats.Mining) + assert.Equal(t, 25, report.Stats.Peers) + assert.Equal(t, int64(30000000000), report.Stats.GasPrice) + assert.Equal(t, 100, report.Stats.Uptime) +} + +func TestParsePendingReport(t *testing.T) { + validPending := []interface{}{ + "pending", + map[string]interface{}{ + "id": "test-node", + "stats": map[string]interface{}{ + "pending": 150, + }, + }, + } + + report, err := ParsePendingReport(validPending) + require.NoError(t, err) + assert.Equal(t, "test-node", report.ID) + assert.Equal(t, 150, report.Stats.Pending) +} + +func TestParseNodePing(t *testing.T) { + validPing := []interface{}{ + "node-ping", + map[string]interface{}{ + "id": "test-node", + "clientTime": "1234567890", + }, + } + + ping, err := ParseNodePing(validPing) + require.NoError(t, err) + assert.Equal(t, "test-node", ping.ID) + assert.Equal(t, "1234567890", ping.ClientTime) +} + +func TestParseLatencyReport(t *testing.T) { + validLatency := []interface{}{ + "latency", + map[string]interface{}{ + "id": "test-node", + "latency": 25, + }, + } + + report, err := ParseLatencyReport(validLatency) + require.NoError(t, err) + assert.Equal(t, "test-node", report.ID) + assert.Equal(t, 25, report.Latency) +} + +func TestParseHistoryReport(t *testing.T) { + validHistory := []interface{}{ + "history", + map[string]interface{}{ + "id": "test-node", + "history": []interface{}{ + map[string]interface{}{ + "number": 12345677, + "hash": "0x1233", + "parentHash": "0x5677", + "timestamp": 1234567889, + "miner": "0xabc", + "gasUsed": 14000000, + "gasLimit": 30000000, + "difficulty": "1234567889", + "totalDifficulty": "12345678901234567889", + "transactions": []interface{}{}, + "transactionsRoot": "0xtxroot", + "stateRoot": "0xstateroot", + "uncles": []interface{}{}, + }, + }, + }, + } + + report, err := ParseHistoryReport(validHistory) + require.NoError(t, err) + assert.Equal(t, "test-node", report.ID) + assert.Equal(t, 1, len(report.History)) + assert.Equal(t, uint64(12345677), report.History[0].Number.Value()) +} + +func TestFormatReadyMessage(t *testing.T) { + msg := FormatReadyMessage() + + var parsed map[string]interface{} + err := json.Unmarshal(msg, &parsed) + require.NoError(t, err) + + emit, ok := parsed["emit"].([]interface{}) + require.True(t, ok) + require.Equal(t, 1, len(emit)) + assert.Equal(t, "ready", emit[0]) +} + +func TestFormatNodePong(t *testing.T) { + ping := &NodePing{ + ID: "test-node", + ClientTime: "1234567890", + } + + msg := FormatNodePong(ping, "1234567891") + + var parsed map[string]interface{} + err := json.Unmarshal(msg, &parsed) + require.NoError(t, err) + + emit, ok := parsed["emit"].([]interface{}) + require.True(t, ok) + require.Equal(t, 2, len(emit)) + assert.Equal(t, "node-pong", emit[0]) + + pongData, ok := emit[1].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "test-node", pongData["id"]) + assert.Equal(t, "1234567890", pongData["clientTime"]) + assert.Equal(t, "1234567891", pongData["serverTime"]) +} + +func TestFormatHistoryRequest(t *testing.T) { + msg := FormatHistoryRequest(50, 1) + + var parsed map[string]interface{} + err := json.Unmarshal(msg, &parsed) + require.NoError(t, err) + + emit, ok := parsed["emit"].([]interface{}) + require.True(t, ok) + require.Equal(t, 2, len(emit)) + assert.Equal(t, "history", emit[0]) + + reqData, ok := emit[1].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, float64(50), reqData["max"]) + assert.Equal(t, float64(1), reqData["min"]) +} + +func TestFormatPrimusPing(t *testing.T) { + msg := FormatPrimusPing(1234567890) + + var parsed map[string]interface{} + err := json.Unmarshal(msg, &parsed) + require.NoError(t, err) + + emit, ok := parsed["emit"].([]interface{}) + require.True(t, ok) + require.Equal(t, 2, len(emit)) + assert.Equal(t, "primus::ping::", emit[0]) + assert.Equal(t, float64(1234567890), emit[1]) +} diff --git a/pkg/ethstats/server.go b/pkg/ethstats/server.go index ea75672a8..fc8ecbaaa 100644 --- a/pkg/ethstats/server.go +++ b/pkg/ethstats/server.go @@ -154,19 +154,29 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { // Log connection attempt s.log.WithFields(logrus.Fields{ - "ip": ip, - "uri": r.RequestURI, - "origin": r.Header.Get("Origin"), - }).Debug("WebSocket connection attempt") + "ip": ip, + "uri": r.RequestURI, + "path": r.URL.Path, + "origin": r.Header.Get("Origin"), + "headers": r.Header, + }).Info("WebSocket connection attempt") // Upgrade to WebSocket conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { - s.log.WithError(err).Error("Failed to upgrade connection") + s.log.WithError(err).WithFields(logrus.Fields{ + "headers": r.Header, + "method": r.Method, + }).Error("Failed to upgrade connection") return } + s.log.WithFields(logrus.Fields{ + "remote_addr": conn.RemoteAddr().String(), + "local_addr": conn.LocalAddr().String(), + }).Info("WebSocket upgrade successful") + // Create client client := connection.NewClient(conn, ip) @@ -178,14 +188,36 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { return } - // Handle client in goroutine - go s.handleClient(r.Context(), client) + s.log.WithFields(logrus.Fields{ + "client_id": client.ID(), + "ip": ip, + }).Info("Client connected") + + // Handle client in goroutine with background context + // Don't use r.Context() as it gets cancelled when the HTTP handler returns + go s.handleClient(context.Background(), client) } func (s *Server) handleClient(ctx context.Context, client *connection.Client) { + // Store the initial temporary ID assigned by the manager + tempID := fmt.Sprintf("temp_%p", client) + s.log.WithField("temp_id", tempID).Info("Starting client handler") + defer func() { + clientID := client.ID() + // Use the actual client ID if set, otherwise use the temp ID + removeID := clientID + if removeID == "" { + removeID = tempID + } + + s.log.WithFields(logrus.Fields{ + "client_id": clientID, + "temp_id": tempID, + "remove_id": removeID, + }).Info("Cleaning up client") client.Close() - s.manager.RemoveClient(client.ID()) + s.manager.RemoveClient(removeID) }() // Create client context @@ -220,8 +252,12 @@ func (s *Server) readPump(ctx context.Context, client *connection.Client) { // Read message _, message, err := conn.ReadMessage() if err != nil { + // Check if this is a normal close or an error we should log if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - s.log.WithError(err).Debug("WebSocket read error") + s.log.WithError(err).Warn("WebSocket unexpected close error") + } else if !strings.Contains(err.Error(), "use of closed network connection") { + // Only log if it's not the expected "use of closed network connection" error + s.log.WithError(err).Debug("WebSocket connection closed") } return @@ -232,6 +268,17 @@ func (s *Server) readPump(ctx context.Context, client *connection.Client) { _ = conn.SetReadDeadline(time.Now().Add(s.config.ReadTimeout)) + clientID := client.ID() + if clientID == "" { + clientID = "unauthenticated" + } + + s.log.WithFields(logrus.Fields{ + "client_id": clientID, + "size": len(message), + "preview": string(message[:minInt(100, len(message))]), + }).Info("Received message") + // Handle message if err := s.handler.HandleMessage(ctx, client, message); err != nil { s.log.WithError(err).Error("Failed to handle message") @@ -289,3 +336,11 @@ func (s *Server) extractIPAddress(r *http.Request) string { return host } + +func minInt(a, b int) int { + if a < b { + return a + } + + return b +} diff --git a/pkg/ethstats/testutil/client.go b/pkg/ethstats/testutil/client.go new file mode 100644 index 000000000..0e934d320 --- /dev/null +++ b/pkg/ethstats/testutil/client.go @@ -0,0 +1,366 @@ +package testutil + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "sync" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/protocol" + "github.com/gorilla/websocket" +) + +// TestClient is a mock ethstats client for testing +type TestClient struct { + mu sync.RWMutex + conn *websocket.Conn + url string + nodeInfo protocol.NodeInfo + secret string + receivedMessages []json.RawMessage + connected bool + authenticated bool + done chan struct{} + doneOnce sync.Once + errors []error +} + +// NewTestClient creates a new test client +func NewTestClient(serverURL, nodeID, secret string) *TestClient { + return &TestClient{ + url: serverURL, + secret: secret, + nodeInfo: protocol.NodeInfo{ + Name: nodeID, + Node: "TestClient/v1.0.0", + Port: 30303, + Net: "1", + Protocol: "eth/66,eth/67", + API: "no", + OS: "linux", + OSVersion: "x64", + Client: "test-client", + CanUpdateHistory: true, + }, + done: make(chan struct{}), + } +} + +// Connect establishes a WebSocket connection to the server +func (c *TestClient) Connect(ctx context.Context) error { + u, err := url.Parse(c.url) + if err != nil { + return fmt.Errorf("failed to parse URL: %w", err) + } + + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + } + + // Ethstats server handles WebSocket connections at root path + u.Path = "/" + + dialer := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + + conn, _, err := dialer.DialContext(ctx, u.String(), nil) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.connected = true + c.mu.Unlock() + + // Start reading messages + go c.readLoop() + + return nil +} + +// SendHello sends the initial hello message +func (c *TestClient) SendHello() error { + hello := protocol.HelloMessage{ + ID: c.nodeInfo.Name, + Info: c.nodeInfo, + Secret: c.secret, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"hello", hello}, + } + + return c.sendMessage(msg) +} + +// SendBlock sends a block report +func (c *TestClient) SendBlock(block *protocol.Block) error { + report := protocol.BlockReport{ + ID: c.nodeInfo.Name, + Block: *block, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"block", report}, + } + + return c.sendMessage(msg) +} + +// SendStats sends a stats report +func (c *TestClient) SendStats(stats protocol.NodeStats) error { + report := protocol.StatsReport{ + ID: c.nodeInfo.Name, + Stats: stats, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"stats", report}, + } + + return c.sendMessage(msg) +} + +// SendPending sends a pending transactions report +func (c *TestClient) SendPending(pending int) error { + report := protocol.PendingReport{ + ID: c.nodeInfo.Name, + Stats: protocol.PendingStats{ + Pending: pending, + }, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"pending", report}, + } + + return c.sendMessage(msg) +} + +// SendNodePing sends a node ping message +func (c *TestClient) SendNodePing() error { + ping := protocol.NodePing{ + ID: c.nodeInfo.Name, + ClientTime: fmt.Sprintf("%d", time.Now().UnixMilli()), + } + + msg := map[string]interface{}{ + "emit": []interface{}{"node-ping", ping}, + } + + return c.sendMessage(msg) +} + +// SendLatency sends a latency report +func (c *TestClient) SendLatency(latency int) error { + report := protocol.LatencyReport{ + ID: c.nodeInfo.Name, + Latency: latency, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"latency", report}, + } + + return c.sendMessage(msg) +} + +// SendHistory sends a history report +func (c *TestClient) SendHistory(blocks []protocol.Block) error { + report := protocol.HistoryReport{ + ID: c.nodeInfo.Name, + History: blocks, + } + + msg := map[string]interface{}{ + "emit": []interface{}{"history", report}, + } + + return c.sendMessage(msg) +} + +// SendPrimusPong sends a primus pong message +func (c *TestClient) SendPrimusPong(timestamp string) error { + msg := map[string]interface{}{ + "emit": []interface{}{"primus::pong::", timestamp}, + } + + return c.sendMessage(msg) +} + +// WaitForMessage waits for a specific message type with timeout +func (c *TestClient) WaitForMessage(msgType string, timeout time.Duration) (json.RawMessage, error) { + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + c.mu.RLock() + messages := c.receivedMessages + c.mu.RUnlock() + + for _, msg := range messages { + var parsed map[string]interface{} + if err := json.Unmarshal(msg, &parsed); err != nil { + continue + } + + if emit, ok := parsed["emit"].([]interface{}); ok && len(emit) > 0 { + if emit[0] == msgType { + return msg, nil + } + } + } + + time.Sleep(10 * time.Millisecond) + } + + return nil, fmt.Errorf("timeout waiting for message type: %s", msgType) +} + +// IsAuthenticated returns whether the client received a ready message +func (c *TestClient) IsAuthenticated() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.authenticated +} + +// GetReceivedMessages returns all received messages +func (c *TestClient) GetReceivedMessages() []json.RawMessage { + c.mu.RLock() + defer c.mu.RUnlock() + + messages := make([]json.RawMessage, len(c.receivedMessages)) + copy(messages, c.receivedMessages) + + return messages +} + +// GetErrors returns any errors encountered +func (c *TestClient) GetErrors() []error { + c.mu.RLock() + defer c.mu.RUnlock() + + errors := make([]error, len(c.errors)) + copy(errors, c.errors) + + return errors +} + +// Close closes the client connection +func (c *TestClient) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + var err error + + if c.conn != nil { + c.doneOnce.Do(func() { + close(c.done) + }) + + err = c.conn.Close() + } + + return err +} + +func (c *TestClient) sendMessage(msg interface{}) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return fmt.Errorf("not connected") + } + + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + return c.conn.WriteMessage(websocket.TextMessage, data) +} + +func (c *TestClient) readLoop() { + defer func() { + c.mu.Lock() + c.connected = false + c.mu.Unlock() + }() + + for { + select { + case <-c.done: + return + default: + _, message, err := c.conn.ReadMessage() + if err != nil { + c.mu.Lock() + c.errors = append(c.errors, err) + c.mu.Unlock() + + return + } + + c.mu.Lock() + c.receivedMessages = append(c.receivedMessages, message) + + // Check if it's a ready message + var parsed map[string]interface{} + if err := json.Unmarshal(message, &parsed); err == nil { + if emit, ok := parsed["emit"].([]interface{}); ok && len(emit) > 0 { + if emit[0] == "ready" { + c.authenticated = true + } + } + } + c.mu.Unlock() + } + } +} + +// CreateTestBlock creates a test block with sample data +func CreateTestBlock(number uint64, parentHash string) protocol.Block { + // Create BlockNumber by marshaling and unmarshaling + blockNum := &protocol.BlockNumber{} + data, _ := json.Marshal(number) + _ = blockNum.UnmarshalJSON(data) + + return protocol.Block{ + Number: *blockNum, + Hash: fmt.Sprintf("0x%064x", number), + ParentHash: parentHash, + Timestamp: time.Now().Unix(), + Miner: "0x0000000000000000000000000000000000000000", + GasUsed: 15000000, + GasLimit: 30000000, + Difficulty: "1", + TotalDifficulty: "1000000", + Transactions: []protocol.TxHash{ + {Hash: "0x1234567890abcdef"}, + {Hash: "0xfedcba0987654321"}, + }, + TransactionsRoot: "0xabcdef", + StateRoot: "0x123456", + Uncles: []string{}, + } +} + +// CreateTestStats creates test node statistics +func CreateTestStats(active, syncing, mining bool, peers int) protocol.NodeStats { + return protocol.NodeStats{ + Active: active, + Syncing: syncing, + Mining: mining, + Hashrate: 0, + Peers: peers, + GasPrice: 30000000000, + Uptime: 100, + } +} diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 000000000..e04b70fb3 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,42 @@ +# Xatu Development Tools + +This directory contains development and testing utilities for the Xatu project. + +## Tools + +### ethstats-client + +A test client that simulates an Ethereum node reporting to the ethstats server. Used for testing and development of the ethstats server implementation. + +**Usage:** +```bash +cd tools/ethstats-client +go run -tags tools main.go --ethstats username:password@localhost:8081 +``` + +See [ethstats-client/README.md](ethstats-client/README.md) for detailed documentation. + +## Building Tools + +All tools can be built individually: + +```bash +# Build ethstats-client +go build -o bin/ethstats-client ./tools/ethstats-client + +# Or build all tools +make tools +``` + +## Contributing + +When adding new development tools: + +1. Create a new subdirectory under `tools/` +2. Include a README.md with usage instructions +3. Update this main README with a description +4. Consider adding a Makefile target if the tool should be built regularly + +## Note + +These tools are for development and testing purposes only. They are not included in production builds or releases. \ No newline at end of file diff --git a/tools/build.sh b/tools/build.sh new file mode 100755 index 000000000..274372dd9 --- /dev/null +++ b/tools/build.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Build all tools in the tools directory + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Building Xatu development tools..." + +# Build ethstats-client with tools tag +echo "→ Building ethstats-client..." +cd "$PROJECT_ROOT" +go build -tags tools -o bin/ethstats-client ./tools/ethstats-client + +echo "✓ Tools built successfully in bin/" +echo "" +echo "Available tools:" +echo " - bin/ethstats-client" \ No newline at end of file diff --git a/tools/ethstats-client/README.md b/tools/ethstats-client/README.md new file mode 100644 index 000000000..9743bfdc3 --- /dev/null +++ b/tools/ethstats-client/README.md @@ -0,0 +1,60 @@ +# Ethstats Test Client + +A standalone test client for the ethstats server that simulates an Ethereum node reporting statistics. + +**Note:** This tool requires the `tools` build tag to compile. + +## Building + +```bash +# Build with the tools tag +go build -tags tools -o ethstats-client . + +# Or use the build script +../../tools/build.sh +``` + +## Usage + +```bash +./ethstats-client --ethstats username:password@host:port +``` + +### Example + +```bash +# Connect to local server +./ethstats-client --ethstats testuser:testpass@localhost:8081 + +# Connect to remote server +./ethstats-client --ethstats mynode:secretpass@ethstats.example.com:8081 +``` + +## Features + +- Performs initial authentication handshake +- Sends random events every 5 seconds: + - Block reports (incrementing block numbers) + - Node statistics (peers, syncing status) + - Pending transaction counts + - Latency reports + - Historical block data (10% chance) +- Handles ping/pong messages every 30 seconds +- Graceful shutdown on Ctrl+C + +## Event Types + +The client simulates the following event types: + +1. **Block Reports**: New blocks with incrementing numbers +2. **Stats Updates**: Node statistics including peer count and sync status +3. **Pending Transactions**: Random pending transaction counts +4. **Latency Reports**: Simulated network latency (10-110ms) +5. **History Reports**: Occasional historical block data + +## Notes + +- The client uses the node ID from the username part of the credentials +- Initial block number starts around 18,000,000 + random offset +- All random values use crypto/rand for secure randomness +- The client maintains the WebSocket connection and handles reconnection if needed \ No newline at end of file diff --git a/tools/ethstats-client/main.go b/tools/ethstats-client/main.go new file mode 100644 index 000000000..3ac1b8135 --- /dev/null +++ b/tools/ethstats-client/main.go @@ -0,0 +1,253 @@ +//go:build tools +// +build tools + +// ethstats-client is a manual testing tool for the ethstats server. +// It reuses the test client from pkg/ethstats/testutil to ensure +// consistency between manual and automated testing. +package main + +import ( + "context" + "crypto/rand" + "flag" + "fmt" + "log" + "math/big" + "os" + "os/signal" + "strings" + "time" + + "github.com/ethpandaops/xatu/pkg/ethstats/protocol" + "github.com/ethpandaops/xatu/pkg/ethstats/testutil" +) + +func main() { + var ethstatsURL string + + flag.StringVar(ðstatsURL, "ethstats", "", "Ethstats URL in format: username:password@host:port") + flag.Parse() + + if ethstatsURL == "" { + log.Fatal("--ethstats flag is required") + } + + // Parse the ethstats URL + parts := strings.SplitN(ethstatsURL, "@", 2) + if len(parts) != 2 { + log.Fatal("Invalid ethstats URL format. Expected: username:password@host:port") + } + + credentials := parts[0] + address := parts[1] + + // Extract username from credentials (for node ID) + credParts := strings.SplitN(credentials, ":", 2) + if len(credParts) != 2 { + log.Fatal("Invalid credentials format. Expected: username:password") + } + + nodeID := credParts[0] + + // Build server URL + serverURL := "http://" + address + + log.Printf("Connecting to ethstats server at %s as node '%s'", serverURL, nodeID) + + // Create test client + client := testutil.NewTestClient(serverURL, nodeID, credentials) + + // Setup signal handling + ctx, cancel := context.WithCancel(context.Background()) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt) + + // Connect to server + if err := client.Connect(ctx); err != nil { + cancel() + log.Fatalf("Failed to connect: %v", err) + } + + defer func() { + client.Close() + cancel() + }() + + log.Println("Connected to server") + + // Send hello message + if err := client.SendHello(); err != nil { + log.Printf("Failed to send hello: %v", err) + + return + } + + log.Println("Hello message sent, waiting for authentication...") + + // Wait for ready message + if _, err := client.WaitForMessage("ready", 5*time.Second); err != nil { + log.Printf("Failed to authenticate: %v", err) + + return + } + + log.Println("Successfully authenticated") + + // Start sending events + go sendRandomEvents(ctx, client) + + // Handle ping messages in the background + go handlePings(ctx, client) + + // Wait for interrupt signal + <-sigChan + log.Println("Shutting down...") +} + +func randInt(maxValue int) int { + n, err := rand.Int(rand.Reader, big.NewInt(int64(maxValue))) + if err != nil { + return 0 + } + + return int(n.Int64()) +} + +func randUint64(maxValue uint64) uint64 { + maxBig := new(big.Int).SetUint64(maxValue) + + n, err := rand.Int(rand.Reader, maxBig) + if err != nil { + return 0 + } + + return n.Uint64() +} + +func sendRandomEvents(ctx context.Context, client *testutil.TestClient) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + const baseBlock = uint64(18000000) + + blockNumber := baseBlock + randUint64(100000) + pendingCount := 100 + + // Send initial stats + stats := testutil.CreateTestStats(true, false, false, 25+randInt(10)) + if err := client.SendStats(stats); err != nil { + log.Printf("Failed to send stats: %v", err) + } + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // Randomly choose which event to send + eventType := randInt(4) + + switch eventType { + case 0: + // Send block report + blockNumber++ + block := testutil.CreateTestBlock(blockNumber, fmt.Sprintf("0x%064x", blockNumber-1)) + + if err := client.SendBlock(&block); err != nil { + log.Printf("Failed to send block: %v", err) + } else { + log.Printf("Sent block #%d", blockNumber) + } + + case 1: + // Send stats update + peers := 20 + randInt(15) + syncing := randInt(10) < 1 // 10% chance of syncing + stats := testutil.CreateTestStats(true, syncing, false, peers) + + if err := client.SendStats(stats); err != nil { + log.Printf("Failed to send stats: %v", err) + } else { + log.Printf("Sent stats update (peers: %d, syncing: %v)", peers, syncing) + } + + case 2: + // Send pending transactions update + delta := randInt(100) - 50 + pendingCount += delta + + if pendingCount < 0 { + pendingCount = 0 + } + + if err := client.SendPending(pendingCount); err != nil { + log.Printf("Failed to send pending: %v", err) + } else { + log.Printf("Sent pending count: %d", pendingCount) + } + + case 3: + // Send latency report + latency := 10 + randInt(100) + + if err := client.SendLatency(latency); err != nil { + log.Printf("Failed to send latency: %v", err) + } else { + log.Printf("Sent latency: %dms", latency) + } + } + + // Occasionally send history + shouldSendHistory := randInt(10) < 1 // 10% chance + + if shouldSendHistory { + blocks := []protocol.Block{} + + for i := uint64(0); i < 5; i++ { + offset := i + 1 + + if blockNumber <= offset { + break + } + + blockNum := blockNumber - offset + parentHash := fmt.Sprintf("0x%064x", blockNum-1) + b := testutil.CreateTestBlock(blockNum, parentHash) + blocks = append(blocks, b) + } + + if err := client.SendHistory(blocks); err != nil { + log.Printf("Failed to send history: %v", err) + } else { + log.Printf("Sent history with %d blocks", len(blocks)) + } + } + } + } +} + +func handlePings(ctx context.Context, client *testutil.TestClient) { + // Send periodic pings + pingTicker := time.NewTicker(30 * time.Second) + defer pingTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-pingTicker.C: + if err := client.SendNodePing(); err != nil { + log.Printf("Failed to send ping: %v", err) + + continue + } + + // Wait for pong + if _, err := client.WaitForMessage("node-pong", 5*time.Second); err != nil { + log.Printf("Failed to receive pong: %v", err) + } else { + log.Println("Ping-pong successful") + } + } + } +} From c920091897898d2fd586c5040f320392e459b6a8 Mon Sep 17 00:00:00 2001 From: Andrew Davis <1709934+Savid@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:58:47 +1000 Subject: [PATCH 3/3] cleanup --- ethstats-implementation-guide.md | 308 ------------------------------- 1 file changed, 308 deletions(-) delete mode 100644 ethstats-implementation-guide.md diff --git a/ethstats-implementation-guide.md b/ethstats-implementation-guide.md deleted file mode 100644 index e036c4673..000000000 --- a/ethstats-implementation-guide.md +++ /dev/null @@ -1,308 +0,0 @@ -# Ethstats Implementation Guide for Go Server - -## Overview - -Based on analysis of 5 major Ethereum execution layer clients (go-ethereum, Nethermind, Erigon, Besu, and reth), this guide provides comprehensive requirements for building a Go ethstats server that clients can connect to. - -**Note**: Reth does not currently implement ethstats support. - -## Core Protocol Requirements - -### 1. WebSocket Connection - -- **Protocol**: WebSocket (must support both `ws://` and `wss://`) -- **Endpoint**: Most clients expect `/api` endpoint (some will try both root and `/api`) -- **Message Size Limit**: 15 MB (go-ethereum requirement) -- **Timeout**: 5-second handshake timeout is standard -- **Ping/Pong**: Must handle WebSocket-level ping/pong for connection health - -### 2. Authentication - -**URL Format**: `nodename:secret@host:port` - -**Authentication Flow**: -1. Client connects via WebSocket -2. Client sends "hello" message with node info and secret -3. Server validates secret and responds with "ready" message -4. If authentication fails, close connection - -### 3. Message Protocol - -All messages use JSON format with an "emit" wrapper: - -```json -{ - "emit": ["message_type", data_object] -} -``` - -## Message Types - -### Incoming Messages (Client → Server) - -#### 1. **hello** - Initial Authentication -```json -{ - "emit": ["hello", { - "id": "nodename", - "info": { - "name": "nodename", - "node": "Geth/v1.10.0", - "port": 30303, - "net": "1", - "protocol": "eth/66,eth/67", - "api": "no", - "os": "linux", - "os_v": "x64", - "client": "0.1.1", - "canUpdateHistory": true, - "contact": "admin@example.com" // Optional, Besu/Nethermind specific - }, - "secret": "your-secret" - }] -} -``` - -#### 2. **block** - New Block Report -```json -{ - "emit": ["block", { - "id": "nodename", - "block": { - "number": 12345678, - "hash": "0x...", - "parentHash": "0x...", - "timestamp": 1234567890, - "miner": "0x...", - "gasUsed": 15000000, - "gasLimit": 30000000, - "difficulty": "1234567890", - "totalDifficulty": "12345678901234567890", - "transactions": [ - {"hash": "0x..."}, - {"hash": "0x..."} - ], - "transactionsRoot": "0x...", - "stateRoot": "0x...", - "uncles": [] - } - }] -} -``` - -#### 3. **pending** - Pending Transactions Count -```json -{ - "emit": ["pending", { - "id": "nodename", - "stats": { - "pending": 150 - } - }] -} -``` - -#### 4. **stats** - Node Statistics -```json -{ - "emit": ["stats", { - "id": "nodename", - "stats": { - "active": true, - "syncing": false, - "mining": false, - "hashrate": 0, - "peers": 25, - "gasPrice": 30000000000, - "uptime": 100 - } - }] -} -``` - -#### 5. **history** - Historical Blocks -```json -{ - "emit": ["history", { - "id": "nodename", - "history": [ - // Array of block objects (same structure as block report) - ] - }] -} -``` - -#### 6. **node-ping** - Latency Measurement Request -```json -{ - "emit": ["node-ping", { - "id": "nodename", - "clientTime": "timestamp_string" - }] -} -``` - -#### 7. **latency** - Latency Report -```json -{ - "emit": ["latency", { - "id": "nodename", - "latency": 25 - }] -} -``` - -#### 8. **primus::pong::timestamp** - Heartbeat Response -```json -"primus::pong::1234567890123" -``` - -### Outgoing Messages (Server → Client) - -#### 1. **ready** - Authentication Success -```json -{ - "emit": ["ready"] -} -``` - -#### 2. **node-pong** - Latency Measurement Response -```json -{ - "emit": ["node-pong", { - "id": "nodename", - "clientTime": "original_timestamp", - "serverTime": "server_timestamp" - }] -} -``` - -#### 3. **history** - Request Historical Data -```json -{ - "emit": ["history", { - "max": 50, - "min": 1 - }] -} -``` - -#### 4. **primus::ping::timestamp** - Heartbeat Request -```json -"primus::ping::1234567890123" -``` - -## Implementation Requirements - -### 1. Connection Management - -- Support multiple concurrent client connections -- Implement connection pooling with unique node identification -- Handle reconnections gracefully (clients will automatically reconnect) -- Track connection state per client - -### 2. Data Handling - -- **Block Numbers**: Can be hex strings or numbers (handle both) -- **Timestamps**: Unix timestamps (seconds since epoch) -- **Difficulty**: String representation of big integers -- **Gas Price**: Integer (wei), may need to handle very large values -- **Hashes**: Always 0x-prefixed hex strings - -### 3. Update Frequencies - -- **Full Stats Report**: Every 15 seconds (go-ethereum, Erigon, Nethermind) -- **Block Updates**: Real-time on new blocks -- **Pending Updates**: Throttled to max once per second (go-ethereum) -- **Heartbeat**: Primus ping/pong every few seconds - -### 4. Special Considerations - -#### Primus Protocol Support -Some clients expect Primus WebSocket protocol compatibility: -- Messages prefixed with `primus::ping::` are heartbeats -- Must respond with `primus::pong::` + same timestamp -- These are raw strings, not JSON - -#### Client Variations -- **Nethermind**: May send "contact" field in node info -- **Besu**: Sends "contact" field, reports Clique signer status in mining field -- **Go-ethereum**: Throttles transaction updates, reports light client status -- **Erigon**: Always reports mining=false, gasPrice=0 - -#### Error Handling -- Invalid authentication should close connection immediately -- Malformed messages should be logged but not crash server -- Support graceful degradation for missing optional fields - -### 5. Security Considerations - -- Validate all input data to prevent injection attacks -- Implement rate limiting per client -- Use TLS (wss://) in production -- Store secrets securely (hashed, not plaintext) -- Implement connection limits to prevent DoS - -### 6. Monitoring and Logging - -- Log all client connections/disconnections -- Track message rates per client -- Monitor WebSocket connection health -- Implement metrics for server performance - -## Sample Go Server Structure - -```go -type EthstatsServer struct { - clients map[string]*Client - mu sync.RWMutex -} - -type Client struct { - ID string - NodeInfo NodeInfo - Conn *websocket.Conn - LastSeen time.Time -} - -type NodeInfo struct { - Name string `json:"name"` - Node string `json:"node"` - Port int `json:"port"` - Network string `json:"net"` - Protocol string `json:"protocol"` - // ... other fields -} - -type Message struct { - Emit []interface{} `json:"emit"` -} -``` - -## Testing Recommendations - -1. Test with multiple client implementations -2. Verify handling of large block numbers (post-merge) -3. Test reconnection scenarios -4. Validate Primus protocol compatibility -5. Stress test with many concurrent connections -6. Test malformed message handling - -## Common Gotchas - -1. **Message Format**: The "emit" field is always an array with exactly 2 elements -2. **Empty Arrays**: Uncle arrays should be `[]` not `null` -3. **Network ID**: Sometimes sent as string, sometimes as number -4. **Gas Price**: May overflow int64, use big.Int or uint64 -5. **History Requests**: Clients may expect server to request history on login -6. **ID Field**: Some clients generate complex IDs (e.g., "name-keccakhash") -7. **Protocol Versions**: Format varies (e.g., "eth/66,eth/67" vs "eth/66, eth/67") - -## Additional Features to Consider - -1. **Web Dashboard**: Display connected nodes and statistics -2. **Persistence**: Store historical data for analytics -3. **Alerting**: Notify on node disconnections or issues -4. **API**: RESTful API for querying node status -5. **Clustering**: Support multiple server instances -6. **Export**: Prometheus metrics or other monitoring integrations \ No newline at end of file