refactor(errors): add scoped proxy failures

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-05 02:18:05 +00:00
parent 35db5f6840
commit 0a7c99264b
5 changed files with 133 additions and 52 deletions

View file

@ -6,7 +6,6 @@ import (
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"math" "math"
"net" "net"
@ -62,12 +61,12 @@ func splitterFactoryForMode(mode string) (func(*Config, *EventBus) Splitter, boo
func LoadConfig(path string) (*Config, Result) { func LoadConfig(path string) (*Config, Result) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, newErrorResult(err) return nil, newErrorResult(NewScopedError("proxy.config", "read config failed", err))
} }
config := &Config{} config := &Config{}
if err := json.Unmarshal(data, config); err != nil { if err := json.Unmarshal(data, config); err != nil {
return nil, newErrorResult(err) return nil, newErrorResult(NewScopedError("proxy.config", "parse config failed", err))
} }
config.configPath = path config.configPath = path
return config, config.Validate() return config, config.Validate()
@ -77,31 +76,31 @@ func LoadConfig(path string) (*Config, Result) {
// if result := cfg.Validate(); !result.OK { return result } // if result := cfg.Validate(); !result.OK { return result }
func (c *Config) Validate() Result { func (c *Config) Validate() Result {
if c == nil { if c == nil {
return newErrorResult(errors.New("config is nil")) return newErrorResult(NewScopedError("proxy.config", "config is nil", nil))
} }
if !isValidMode(c.Mode) { if !isValidMode(c.Mode) {
return newErrorResult(errors.New("mode must be \"nicehash\" or \"simple\"")) return newErrorResult(NewScopedError("proxy.config", "mode must be \"nicehash\" or \"simple\"", nil))
} }
if !isValidWorkersMode(c.Workers) { if !isValidWorkersMode(c.Workers) {
return newErrorResult(errors.New("workers must be one of \"rig-id\", \"user\", \"password\", \"agent\", \"ip\", or \"false\"")) return newErrorResult(NewScopedError("proxy.config", "workers must be one of \"rig-id\", \"user\", \"password\", \"agent\", \"ip\", or \"false\"", nil))
} }
if len(c.Bind) == 0 { if len(c.Bind) == 0 {
return newErrorResult(errors.New("bind list is empty")) return newErrorResult(NewScopedError("proxy.config", "bind list is empty", nil))
} }
if len(c.Pools) == 0 { if len(c.Pools) == 0 {
return newErrorResult(errors.New("pool list is empty")) return newErrorResult(NewScopedError("proxy.config", "pool list is empty", nil))
} }
enabledPools := 0 enabledPools := 0
for _, pool := range c.Pools { for _, pool := range c.Pools {
if pool.Enabled && strings.TrimSpace(pool.URL) == "" { if pool.Enabled && strings.TrimSpace(pool.URL) == "" {
return newErrorResult(errors.New("enabled pool url is empty")) return newErrorResult(NewScopedError("proxy.config", "enabled pool url is empty", nil))
} }
if pool.Enabled { if pool.Enabled {
enabledPools++ enabledPools++
} }
} }
if enabledPools == 0 { if enabledPools == 0 {
return newErrorResult(errors.New("pool list has no enabled entries")) return newErrorResult(NewScopedError("proxy.config", "pool list has no enabled entries", nil))
} }
return newSuccessResult() return newSuccessResult()
} }
@ -295,6 +294,7 @@ func (cd *CustomDiff) OnLogin(e Event) {
} }
// limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300}) // limiter := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300})
//
// if limiter.Allow("203.0.113.42:3333") { // if limiter.Allow("203.0.113.42:3333") {
// // first 30 connection attempts per minute are allowed // // first 30 connection attempts per minute are allowed
// } // }
@ -370,6 +370,7 @@ func (rl *RateLimiter) Tick() {
// watcher := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) { // watcher := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) {
// p.Reload(cfg) // p.Reload(cfg)
// }) // })
//
// watcher.Start() // polls once per second and reloads after the file mtime changes // watcher.Start() // polls once per second and reloads after the file mtime changes
func NewConfigWatcher(configPath string, onChange func(*Config)) *ConfigWatcher { func NewConfigWatcher(configPath string, onChange func(*Config)) *ConfigWatcher {
watcher := &ConfigWatcher{ watcher := &ConfigWatcher{

38
error.go Normal file
View file

@ -0,0 +1,38 @@
package proxy
// ScopedError carries a stable error scope alongside a human-readable message.
//
// err := proxy.NewScopedError("proxy.config", "load failed", io.EOF)
type ScopedError struct {
Scope string
Message string
Cause error
}
// NewScopedError creates an error that keeps a greppable scope token in the failure path.
//
// err := proxy.NewScopedError("proxy.server", "listen failed", cause)
func NewScopedError(scope, message string, cause error) error {
return &ScopedError{
Scope: scope,
Message: message,
Cause: cause,
}
}
func (e *ScopedError) Error() string {
if e == nil {
return ""
}
if e.Cause == nil {
return e.Scope + ": " + e.Message
}
return e.Scope + ": " + e.Message + ": " + e.Cause.Error()
}
func (e *ScopedError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}

43
error_test.go Normal file
View file

@ -0,0 +1,43 @@
package proxy
import (
"errors"
"testing"
)
func TestError_NewScopedError_Good(t *testing.T) {
err := NewScopedError("proxy.config", "bind list is empty", nil)
if err == nil {
t.Fatalf("expected scoped error")
}
if got := err.Error(); got != "proxy.config: bind list is empty" {
t.Fatalf("unexpected scoped error string: %q", got)
}
}
func TestError_NewScopedError_Bad(t *testing.T) {
cause := errors.New("permission denied")
err := NewScopedError("proxy.config", "read config failed", cause)
if err == nil {
t.Fatalf("expected scoped error")
}
if !errors.Is(err, cause) {
t.Fatalf("expected errors.Is to unwrap the original cause")
}
if got := err.Error(); got != "proxy.config: read config failed: permission denied" {
t.Fatalf("unexpected wrapped error string: %q", got)
}
}
func TestError_NewScopedError_Ugly(t *testing.T) {
var scoped *ScopedError
if got := scoped.Error(); got != "" {
t.Fatalf("expected nil scoped error string to be empty, got %q", got)
}
if scoped.Unwrap() != nil {
t.Fatalf("expected nil scoped error to unwrap to nil")
}
}

View file

@ -6,7 +6,6 @@ import (
"crypto/tls" "crypto/tls"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net" "net"
"strconv" "strconv"
@ -53,15 +52,15 @@ func (c *StratumClient) IsActive() bool {
// result := client.Connect() // result := client.Connect()
func (c *StratumClient) Connect() proxy.Result { func (c *StratumClient) Connect() proxy.Result {
if c == nil { if c == nil {
return proxy.Result{OK: false, Error: errors.New("client is nil")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "client is nil", nil)}
} }
addr := c.config.URL addr := c.config.URL
if addr == "" { if addr == "" {
return proxy.Result{OK: false, Error: errors.New("pool url is empty")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "pool url is empty", nil)}
} }
conn, err := net.Dial("tcp", addr) conn, err := net.Dial("tcp", addr)
if err != nil { if err != nil {
return proxy.Result{OK: false, Error: err} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.client", "dial pool failed", err)}
} }
if c.config.TLS { if c.config.TLS {
host := addr host := addr
@ -72,18 +71,18 @@ func (c *StratumClient) Connect() proxy.Result {
tlsConn := tls.Client(conn, tlsCfg) tlsConn := tls.Client(conn, tlsCfg)
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {
_ = conn.Close() _ = conn.Close()
return proxy.Result{OK: false, Error: err} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "handshake failed", err)}
} }
if fp := strings.TrimSpace(strings.ToLower(c.config.TLSFingerprint)); fp != "" { if fp := strings.TrimSpace(strings.ToLower(c.config.TLSFingerprint)); fp != "" {
cert := tlsConn.ConnectionState().PeerCertificates cert := tlsConn.ConnectionState().PeerCertificates
if len(cert) == 0 { if len(cert) == 0 {
_ = tlsConn.Close() _ = tlsConn.Close()
return proxy.Result{OK: false, Error: errors.New("missing certificate")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "missing certificate", nil)}
} }
sum := sha256.Sum256(cert[0].Raw) sum := sha256.Sum256(cert[0].Raw)
if hex.EncodeToString(sum[:]) != fp { if hex.EncodeToString(sum[:]) != fp {
_ = tlsConn.Close() _ = tlsConn.Close()
return proxy.Result{OK: false, Error: errors.New("tls fingerprint mismatch")} return proxy.Result{OK: false, Error: proxy.NewScopedError("proxy.pool.tls", "tls fingerprint mismatch", nil)}
} }
} }
c.conn = tlsConn c.conn = tlsConn
@ -211,16 +210,17 @@ func (c *StratumClient) writeJSON(payload any) error {
c.sendMu.Lock() c.sendMu.Lock()
defer c.sendMu.Unlock() defer c.sendMu.Unlock()
if c.conn == nil { if c.conn == nil {
return errors.New("connection is nil") return proxy.NewScopedError("proxy.pool.client", "connection is nil", nil)
} }
data, err := json.Marshal(payload) data, err := json.Marshal(payload)
if err != nil { if err != nil {
return err return proxy.NewScopedError("proxy.pool.client", "marshal request failed", err)
} }
data = append(data, '\n') data = append(data, '\n')
_, err = c.conn.Write(data) _, err = c.conn.Write(data)
if err != nil { if err != nil {
c.notifyDisconnect() c.notifyDisconnect()
return proxy.NewScopedError("proxy.pool.client", "write request failed", err)
} }
return err return err
} }

View file

@ -5,7 +5,6 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"errors"
"net" "net"
"net/http" "net/http"
"reflect" "reflect"
@ -40,7 +39,7 @@ type MinerSnapshot struct {
// } // }
func New(config *Config) (*Proxy, Result) { func New(config *Config) (*Proxy, Result) {
if config == nil { if config == nil {
return nil, newErrorResult(errors.New("config is nil")) return nil, newErrorResult(NewScopedError("proxy", "config is nil", nil))
} }
if result := config.Validate(); !result.OK { if result := config.Validate(); !result.OK {
return nil, result return nil, result
@ -490,11 +489,11 @@ func buildTLSConfig(cfg TLSConfig) (*tls.Config, Result) {
return nil, newSuccessResult() return nil, newSuccessResult()
} }
if cfg.CertFile == "" || cfg.KeyFile == "" { if cfg.CertFile == "" || cfg.KeyFile == "" {
return nil, newErrorResult(errors.New("tls certificate or key path is empty")) return nil, newErrorResult(NewScopedError("proxy.tls", "tls certificate or key path is empty", nil))
} }
cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile) cert, err := tls.LoadX509KeyPair(cfg.CertFile, cfg.KeyFile)
if err != nil { if err != nil {
return nil, newErrorResult(err) return nil, newErrorResult(NewScopedError("proxy.tls", "load certificate failed", err))
} }
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
applyTLSProtocols(tlsConfig, cfg.Protocols) applyTLSProtocols(tlsConfig, cfg.Protocols)
@ -646,7 +645,7 @@ func (p *Proxy) startMonitoringServer() bool {
p.httpServer = &http.Server{Addr: addr, Handler: mux} p.httpServer = &http.Server{Addr: addr, Handler: mux}
go func() { go func() {
err := p.httpServer.Serve(listener) err := p.httpServer.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) { if err != nil && err != http.ErrServerClosed {
p.Stop() p.Stop()
} }
}() }()
@ -1769,17 +1768,17 @@ func (s *Server) Stop() {
func (s *Server) listen() Result { func (s *Server) listen() Result {
if s == nil { if s == nil {
return newErrorResult(errors.New("server is nil")) return newErrorResult(NewScopedError("proxy.server", "server is nil", nil))
} }
if s.listener != nil { if s.listener != nil {
return newSuccessResult() return newSuccessResult()
} }
if s.addr.TLS && s.tlsCfg == nil { if s.addr.TLS && s.tlsCfg == nil {
return newErrorResult(errors.New("tls listener requires a tls config")) return newErrorResult(NewScopedError("proxy.server", "tls listener requires a tls config", nil))
} }
ln, err := net.Listen("tcp", net.JoinHostPort(s.addr.Host, strconv.Itoa(int(s.addr.Port)))) ln, err := net.Listen("tcp", net.JoinHostPort(s.addr.Host, strconv.Itoa(int(s.addr.Port))))
if err != nil { if err != nil {
return newErrorResult(err) return newErrorResult(NewScopedError("proxy.server", "listen failed", err))
} }
if s.tlsCfg != nil { if s.tlsCfg != nil {
ln = tls.NewListener(ln, s.tlsCfg) ln = tls.NewListener(ln, s.tlsCfg)