Compare commits
45 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46231ef0a3 | ||
|
|
066bc42a89 | ||
|
|
b65bb76ac5 | ||
|
|
d2d737764f | ||
|
|
8225649394 | ||
|
|
69eb908fe8 | ||
|
|
d66ccd3ab6 | ||
|
|
9e997554fa | ||
|
|
3535e4b006 | ||
|
|
1d6176153c | ||
|
|
23623a97d3 | ||
|
|
e523fd0740 | ||
|
|
140e66ac64 | ||
|
|
b1af2e0081 | ||
|
|
38a93605a1 | ||
|
|
5190caf9d6 | ||
|
|
8798eea2a0 | ||
|
|
f2fd83caad | ||
|
|
f16c9033e3 | ||
|
|
22d3cd9c09 | ||
|
|
c26136b208 | ||
|
|
259f7e80c8 | ||
|
|
22e98635e7 | ||
|
|
75de9000f0 | ||
|
|
0ab02e9e4b | ||
|
|
22f1420d1c | ||
|
|
fe2872149e | ||
|
|
78e740add7 | ||
|
|
f0477b9980 | ||
|
|
c164bf2e26 | ||
|
|
0a195f7962 | ||
|
|
0d7c60726c | ||
|
|
417b967d48 | ||
|
|
96a0652235 | ||
|
|
8579b0cc11 | ||
|
|
b63e7562de | ||
|
|
9e906d11f9 | ||
|
|
e41ad7ef2e | ||
|
|
465ea38308 | ||
|
|
3376cea600 | ||
|
|
07ff21aa67 | ||
|
|
36fb1232d5 | ||
|
|
7d2d309529 | ||
|
|
20f0626a19 | ||
|
|
48c6e0fc6d |
41 changed files with 5964 additions and 181 deletions
22
CODEX.md
Normal file
22
CODEX.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# CODEX.md
|
||||
|
||||
This repository uses the same conventions as `CLAUDE.md`.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
- RFC: `docs/RFC.md`
|
||||
- AX principles: `.core/reference/RFC-025-AGENT-EXPERIENCE.md`
|
||||
- Package conventions: `CLAUDE.md`
|
||||
|
||||
## Quick Commands
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
## Commit Style
|
||||
|
||||
- Use conventional commits: `type(scope): description`
|
||||
- Include `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||
|
|
@ -1,63 +1,26 @@
|
|||
// Package api implements the HTTP monitoring endpoints for the proxy.
|
||||
// Package api wires the monitoring endpoints onto an HTTP router.
|
||||
//
|
||||
// Registered routes:
|
||||
//
|
||||
// GET /1/summary — aggregated proxy stats
|
||||
// GET /1/workers — per-worker hashrate table
|
||||
// GET /1/miners — per-connection state table
|
||||
//
|
||||
// proxyapi.RegisterRoutes(apiRouter, p)
|
||||
// mux := http.NewServeMux()
|
||||
// api.RegisterRoutes(mux, p)
|
||||
package api
|
||||
|
||||
// SummaryResponse is the /1/summary JSON body.
|
||||
//
|
||||
// {"version":"1.0.0","mode":"nicehash","hashrate":{"total":[...]}, ...}
|
||||
type SummaryResponse struct {
|
||||
Version string `json:"version"`
|
||||
Mode string `json:"mode"`
|
||||
Hashrate HashrateResponse `json:"hashrate"`
|
||||
Miners MinersCountResponse `json:"miners"`
|
||||
Workers uint64 `json:"workers"`
|
||||
Upstreams UpstreamResponse `json:"upstreams"`
|
||||
Results ResultsResponse `json:"results"`
|
||||
}
|
||||
import (
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
||||
// HashrateResponse carries the per-window hashrate array.
|
||||
//
|
||||
// HashrateResponse{Total: [6]float64{12345.67, 11900.00, 12100.00, 11800.00, 12000.00, 12200.00}}
|
||||
type HashrateResponse struct {
|
||||
Total [6]float64 `json:"total"`
|
||||
}
|
||||
// Router is the minimal route-registration surface used by RegisterRoutes.
|
||||
type Router = proxy.RouteRegistrar
|
||||
|
||||
// MinersCountResponse carries current and peak miner counts.
|
||||
//
|
||||
// MinersCountResponse{Now: 142, Max: 200}
|
||||
type MinersCountResponse struct {
|
||||
Now uint64 `json:"now"`
|
||||
Max uint64 `json:"max"`
|
||||
}
|
||||
type SummaryResponse = proxy.SummaryResponse
|
||||
type HashrateResponse = proxy.HashrateResponse
|
||||
type MinersCountResponse = proxy.MinersCountResponse
|
||||
type UpstreamResponse = proxy.UpstreamResponse
|
||||
type ResultsResponse = proxy.ResultsResponse
|
||||
|
||||
// UpstreamResponse carries pool connection state counts.
|
||||
// RegisterRoutes mounts the monitoring endpoints on any router with HandleFunc.
|
||||
//
|
||||
// UpstreamResponse{Active: 1, Sleep: 0, Error: 0, Total: 1, Ratio: 142.0}
|
||||
type UpstreamResponse struct {
|
||||
Active uint64 `json:"active"`
|
||||
Sleep uint64 `json:"sleep"`
|
||||
Error uint64 `json:"error"`
|
||||
Total uint64 `json:"total"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
// ResultsResponse carries share acceptance statistics.
|
||||
//
|
||||
// ResultsResponse{Accepted: 4821, Rejected: 3, Invalid: 0, Expired: 12}
|
||||
type ResultsResponse struct {
|
||||
Accepted uint64 `json:"accepted"`
|
||||
Rejected uint64 `json:"rejected"`
|
||||
Invalid uint64 `json:"invalid"`
|
||||
Expired uint64 `json:"expired"`
|
||||
AvgTime uint32 `json:"avg_time"`
|
||||
Latency uint32 `json:"latency"`
|
||||
HashesTotal uint64 `json:"hashes_total"`
|
||||
Best [10]uint64 `json:"best"`
|
||||
// mux := http.NewServeMux()
|
||||
// api.RegisterRoutes(mux, p)
|
||||
func RegisterRoutes(router Router, proxyValue *proxy.Proxy) {
|
||||
proxy.RegisterMonitoringRoutes(router, proxyValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ type Config struct {
|
|||
AlgoExtension bool `json:"algo-ext"` // forward algo field in jobs
|
||||
Workers WorkersMode `json:"workers"` // "rig-id", "user", "password", "agent", "ip", "false"
|
||||
AccessLogFile string `json:"access-log-file"` // "" = disabled
|
||||
ShareLogFile string `json:"share-log-file"` // "" = disabled
|
||||
ReuseTimeout int `json:"reuse-timeout"` // seconds; simple mode upstream reuse
|
||||
Retries int `json:"retries"` // pool reconnect attempts
|
||||
RetryPause int `json:"retry-pause"` // seconds between retries
|
||||
Watch bool `json:"watch"` // hot-reload on file change
|
||||
RateLimit RateLimit `json:"rate-limit"` // per-IP connection rate limit
|
||||
sourcePath string `json:"-"`
|
||||
}
|
||||
|
||||
// BindAddr is one TCP listen endpoint.
|
||||
|
|
@ -39,6 +41,7 @@ type PoolConfig struct {
|
|||
URL string `json:"url"`
|
||||
User string `json:"user"`
|
||||
Pass string `json:"pass"`
|
||||
Password string `json:"password"`
|
||||
RigID string `json:"rig-id"`
|
||||
Algo string `json:"algo"`
|
||||
TLS bool `json:"tls"`
|
||||
|
|
@ -83,7 +86,9 @@ type WorkersMode string
|
|||
const (
|
||||
WorkersByRigID WorkersMode = "rig-id" // rigid field, fallback to user
|
||||
WorkersByUser WorkersMode = "user"
|
||||
WorkersByPass WorkersMode = "password"
|
||||
WorkersByPassword WorkersMode = "password"
|
||||
// WorkersByPass is kept as a compatibility alias for older configs.
|
||||
WorkersByPass WorkersMode = WorkersByPassword
|
||||
WorkersByAgent WorkersMode = "agent"
|
||||
WorkersByIP WorkersMode = "ip"
|
||||
WorkersDisabled WorkersMode = "false"
|
||||
|
|
|
|||
154
config_runtime.go
Normal file
154
config_runtime.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadConfig reads a JSON config file and validates the result.
|
||||
//
|
||||
// cfg, errorValue := proxy.LoadConfig("config.json")
|
||||
// if errorValue != nil {
|
||||
// return
|
||||
// }
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, errorValue := os.ReadFile(path)
|
||||
if errorValue != nil {
|
||||
return nil, errorValue
|
||||
}
|
||||
|
||||
config := &Config{}
|
||||
if errorValue = json.Unmarshal(data, config); errorValue != nil {
|
||||
return nil, errorValue
|
||||
}
|
||||
config.sourcePath = path
|
||||
|
||||
if errorValue = config.Validate(); errorValue != nil {
|
||||
return nil, errorValue
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Validate checks that `bind` and `pools` are present and every enabled pool has a URL.
|
||||
//
|
||||
// cfg := &proxy.Config{
|
||||
// Bind: []proxy.BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
// Pools: []proxy.PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
// }
|
||||
// if errorValue := cfg.Validate(); errorValue != nil {
|
||||
// return
|
||||
// }
|
||||
func (c *Config) Validate() error {
|
||||
if c == nil {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
if strings.TrimSpace(c.Mode) == "" {
|
||||
return errors.New("mode is empty")
|
||||
}
|
||||
if c.Mode != "nicehash" && c.Mode != "simple" {
|
||||
return errors.New("mode is invalid")
|
||||
}
|
||||
if strings.TrimSpace(string(c.Workers)) == "" {
|
||||
return errors.New("workers mode is empty")
|
||||
}
|
||||
if c.Workers != WorkersByRigID &&
|
||||
c.Workers != WorkersByUser &&
|
||||
c.Workers != WorkersByPass &&
|
||||
c.Workers != WorkersByPassword &&
|
||||
c.Workers != WorkersByAgent &&
|
||||
c.Workers != WorkersByIP &&
|
||||
c.Workers != WorkersDisabled {
|
||||
return errors.New("workers mode is invalid")
|
||||
}
|
||||
if len(c.Bind) == 0 {
|
||||
return errors.New("bind list is empty")
|
||||
}
|
||||
if len(c.Pools) == 0 {
|
||||
return errors.New("pool list is empty")
|
||||
}
|
||||
|
||||
for _, poolConfig := range c.Pools {
|
||||
if poolConfig.Enabled && strings.TrimSpace(poolConfig.URL) == "" {
|
||||
return errors.New("enabled pool URL is empty")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewConfigWatcher watches a config file and reloads the proxy on modification.
|
||||
//
|
||||
// w := proxy.NewConfigWatcher("config.json", func(cfg *proxy.Config) {
|
||||
// p.Reload(cfg)
|
||||
// })
|
||||
func NewConfigWatcher(path string, onChange func(*Config)) *ConfigWatcher {
|
||||
return newConfigWatcher(path, onChange, true)
|
||||
}
|
||||
|
||||
func newConfigWatcher(path string, onChange func(*Config), enabled bool) *ConfigWatcher {
|
||||
return &ConfigWatcher{
|
||||
path: path,
|
||||
onChange: onChange,
|
||||
enabled: enabled,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins 1-second polling for `config.json`.
|
||||
//
|
||||
// w.Start()
|
||||
func (w *ConfigWatcher) Start() {
|
||||
if w == nil || !w.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if info, errorValue := os.Stat(w.path); errorValue == nil {
|
||||
w.lastModifiedAt = info.ModTime()
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
info, errorValue := os.Stat(w.path)
|
||||
if errorValue != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !info.ModTime().After(w.lastModifiedAt) {
|
||||
continue
|
||||
}
|
||||
w.lastModifiedAt = info.ModTime()
|
||||
|
||||
config, errorValue := LoadConfig(w.path)
|
||||
if errorValue == nil && w.onChange != nil {
|
||||
w.onChange(config)
|
||||
}
|
||||
case <-w.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends polling so the watcher can be shut down with `p.Stop()`.
|
||||
//
|
||||
// w.Stop()
|
||||
func (w *ConfigWatcher) Stop() {
|
||||
if w == nil || w.done == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-w.done:
|
||||
default:
|
||||
close(w.done)
|
||||
}
|
||||
}
|
||||
106
config_runtime_test.go
Normal file
106
config_runtime_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig_Validate_Good(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "nicehash",
|
||||
Workers: WorkersByRigID,
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
}
|
||||
|
||||
if errorValue := cfg.Validate(); errorValue != nil {
|
||||
t.Fatalf("expected valid config, got %v", errorValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_Bad(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "bogus",
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
}
|
||||
|
||||
if errorValue := cfg.Validate(); errorValue == nil {
|
||||
t.Fatal("expected invalid mode to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_EmptyMode(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
}
|
||||
|
||||
if errorValue := cfg.Validate(); errorValue == nil {
|
||||
t.Fatal("expected empty mode to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_Ugly(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "simple",
|
||||
Workers: WorkersMode("bogus"),
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{Enabled: true}},
|
||||
}
|
||||
|
||||
if errorValue := cfg.Validate(); errorValue == nil {
|
||||
t.Fatal("expected invalid workers mode to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_EmptyWorkers(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "simple",
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
}
|
||||
|
||||
if errorValue := cfg.Validate(); errorValue == nil {
|
||||
t.Fatal("expected empty workers mode to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate_EnabledPoolURL(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "simple",
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{Enabled: true}},
|
||||
}
|
||||
|
||||
if errorValue := cfg.Validate(); errorValue == nil {
|
||||
t.Fatal("expected empty enabled pool URL to fail validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWatcher_Start_Bad(t *testing.T) {
|
||||
path := t.TempDir() + "/config.json"
|
||||
errorValue := os.WriteFile(path, []byte(`{"mode":"nicehash","workers":"rig-id","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool-a:3333","enabled":true}]}`), 0o644)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
triggered := make(chan struct{}, 1)
|
||||
watcher := newConfigWatcher(path, func(cfg *Config) {
|
||||
triggered <- struct{}{}
|
||||
}, false)
|
||||
watcher.Start()
|
||||
defer watcher.Stop()
|
||||
|
||||
errorValue = os.WriteFile(path, []byte(`{"mode":"nicehash","workers":"rig-id","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool-b:3333","enabled":true}]}`), 0o644)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-triggered:
|
||||
t.Fatal("expected disabled watcher to stay quiet")
|
||||
case <-time.After(1200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
49
enum_string.go
Normal file
49
enum_string.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package proxy
|
||||
|
||||
// String returns the stable name for one worker routing mode.
|
||||
//
|
||||
// mode := WorkersByRigID
|
||||
// _ = mode.String()
|
||||
func (mode WorkersMode) String() string {
|
||||
return string(mode)
|
||||
}
|
||||
|
||||
// String returns the stable name for one miner state.
|
||||
//
|
||||
// state := MinerStateReady
|
||||
// _ = state.String()
|
||||
func (state MinerState) String() string {
|
||||
switch state {
|
||||
case MinerStateWaitLogin:
|
||||
return "wait_login"
|
||||
case MinerStateWaitReady:
|
||||
return "wait_ready"
|
||||
case MinerStateReady:
|
||||
return "ready"
|
||||
case MinerStateClosing:
|
||||
return "closing"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the stable name for one event type.
|
||||
//
|
||||
// eventType := EventAccept
|
||||
// _ = eventType.String()
|
||||
func (eventType EventType) String() string {
|
||||
switch eventType {
|
||||
case EventLogin:
|
||||
return "login"
|
||||
case EventSubmit:
|
||||
return "submit"
|
||||
case EventAccept:
|
||||
return "accept"
|
||||
case EventReject:
|
||||
return "reject"
|
||||
case EventClose:
|
||||
return "close"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
41
events.go
41
events.go
|
|
@ -18,6 +18,7 @@ type EventType int
|
|||
|
||||
const (
|
||||
EventLogin EventType = iota // miner completed login
|
||||
EventSubmit // miner submitted a share
|
||||
EventAccept // pool accepted a submitted share
|
||||
EventReject // pool rejected a share (or share expired)
|
||||
EventClose // miner TCP connection closed
|
||||
|
|
@ -34,8 +35,48 @@ type Event struct {
|
|||
Type EventType
|
||||
Miner *Miner // always set
|
||||
Job *Job // set for Accept and Reject events
|
||||
JobID string // set for Submit events
|
||||
Nonce string // set for Submit events
|
||||
Result string // set for Submit events
|
||||
Algo string // set for Submit events
|
||||
RequestID int64 // set for Submit events
|
||||
Diff uint64 // effective difficulty of the share (Accept and Reject)
|
||||
Error string // rejection reason (Reject only)
|
||||
Latency uint16 // pool response time in ms (Accept and Reject)
|
||||
Expired bool // true if the share was accepted but against the previous job
|
||||
}
|
||||
|
||||
// NewEventBus builds an empty synchronous event dispatcher.
|
||||
//
|
||||
// bus := proxy.NewEventBus()
|
||||
func NewEventBus() *EventBus {
|
||||
return &EventBus{
|
||||
listeners: make(map[EventType][]EventHandler),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers a handler for the given event type. Safe to call before Start.
|
||||
//
|
||||
// bus.Subscribe(proxy.EventAccept, func(e proxy.Event) { stats.OnAccept(e) })
|
||||
func (b *EventBus) Subscribe(eventType EventType, handler EventHandler) {
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.listeners[eventType] = append(b.listeners[eventType], handler)
|
||||
}
|
||||
|
||||
// Dispatch calls all registered handlers for the event's type in subscription order.
|
||||
//
|
||||
// bus.Dispatch(proxy.Event{Type: proxy.EventLogin, Miner: m})
|
||||
func (b *EventBus) Dispatch(event Event) {
|
||||
b.mu.RLock()
|
||||
handlers := append([]EventHandler(nil), b.listeners[event.Type]...)
|
||||
b.mu.RUnlock()
|
||||
|
||||
for _, handler := range handlers {
|
||||
handler(event)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
job.go
88
job.go
|
|
@ -1,5 +1,11 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Job holds the current work unit received from a pool. Immutable once assigned.
|
||||
//
|
||||
// j := proxy.Job{
|
||||
|
|
@ -9,11 +15,79 @@ package proxy
|
|||
// Algo: "cn/r",
|
||||
// }
|
||||
type Job struct {
|
||||
Blob string // hex-encoded block template (160 hex chars = 80 bytes)
|
||||
JobID string // pool-assigned identifier
|
||||
Target string // 8-char hex little-endian uint32 difficulty target
|
||||
Algo string // algorithm e.g. "cn/r", "rx/0"; "" if not negotiated
|
||||
Height uint64 // block height (0 if pool did not provide)
|
||||
SeedHash string // RandomX seed hash hex (empty if not RandomX)
|
||||
ClientID string // pool session ID that issued this job (for stale detection)
|
||||
Blob string `json:"blob"` // hex-encoded block template (160 hex chars = 80 bytes)
|
||||
JobID string `json:"job_id"` // pool-assigned identifier
|
||||
Target string `json:"target"` // 8-char hex little-endian uint32 difficulty target
|
||||
Algo string `json:"algo"` // algorithm e.g. "cn/r", "rx/0"; "" if not negotiated
|
||||
Height uint64 `json:"height"` // block height (0 if pool did not provide)
|
||||
SeedHash string `json:"seed_hash"` // RandomX seed hash hex (empty if not RandomX)
|
||||
ClientID string `json:"id"` // pool session ID that issued this job (for stale detection)
|
||||
}
|
||||
|
||||
// IsValid returns true if Blob and JobID are non-empty.
|
||||
//
|
||||
// if !job.IsValid() { return }
|
||||
func (j Job) IsValid() bool {
|
||||
return j.Blob != "" && j.JobID != ""
|
||||
}
|
||||
|
||||
// BlobWithFixedByte returns a copy of Blob with hex characters at positions 78-79
|
||||
// (blob byte index 39) replaced by the two-digit lowercase hex of fixedByte.
|
||||
//
|
||||
// partitioned := job.BlobWithFixedByte(0x2A) // chars 78-79 become "2a"
|
||||
func (j Job) BlobWithFixedByte(fixedByte uint8) string {
|
||||
if len(j.Blob) < 80 {
|
||||
return j.Blob
|
||||
}
|
||||
|
||||
blob := []byte(j.Blob)
|
||||
blob[78] = lowerHexDigit(fixedByte >> 4)
|
||||
blob[79] = lowerHexDigit(fixedByte & 0x0F)
|
||||
return string(blob)
|
||||
}
|
||||
|
||||
// DifficultyFromTarget converts the 8-char little-endian hex Target field to a uint64 difficulty.
|
||||
//
|
||||
// diff := job.DifficultyFromTarget() // "b88d0600" → ~100000
|
||||
func (j Job) DifficultyFromTarget() uint64 {
|
||||
if len(j.Target) != 8 {
|
||||
return 0
|
||||
}
|
||||
|
||||
targetBytes, errorValue := hex.DecodeString(j.Target)
|
||||
if errorValue != nil || len(targetBytes) != 4 {
|
||||
return 0
|
||||
}
|
||||
|
||||
targetValue := binary.LittleEndian.Uint32(targetBytes)
|
||||
if targetValue == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return uint64(^uint32(0) / targetValue)
|
||||
}
|
||||
|
||||
// TargetForDifficulty converts a difficulty back to the 8-char little-endian target field.
|
||||
//
|
||||
// target := proxy.TargetForDifficulty(100000)
|
||||
func TargetForDifficulty(difficulty uint64) string {
|
||||
if difficulty <= 1 {
|
||||
return "ffffffff"
|
||||
}
|
||||
|
||||
targetValue := uint64(^uint32(0)) / difficulty
|
||||
if targetValue == 0 {
|
||||
targetValue = 1
|
||||
}
|
||||
if targetValue > uint64(^uint32(0)) {
|
||||
targetValue = uint64(^uint32(0))
|
||||
}
|
||||
|
||||
targetBytes := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(targetBytes, uint32(targetValue))
|
||||
return hex.EncodeToString(targetBytes)
|
||||
}
|
||||
|
||||
func lowerHexDigit(value uint8) byte {
|
||||
return strconv.FormatUint(uint64(value), 16)[0]
|
||||
}
|
||||
|
|
|
|||
85
job_test.go
Normal file
85
job_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJob_IsValid_Good(t *testing.T) {
|
||||
job := Job{Blob: "abcd", JobID: "job-1"}
|
||||
if !job.IsValid() {
|
||||
t.Fatal("expected valid job")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_IsValid_Bad(t *testing.T) {
|
||||
job := Job{Blob: "abcd"}
|
||||
if job.IsValid() {
|
||||
t.Fatal("expected invalid job without job ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_IsValid_Ugly(t *testing.T) {
|
||||
var job Job
|
||||
if job.IsValid() {
|
||||
t.Fatal("zero job should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_BlobWithFixedByte_Good(t *testing.T) {
|
||||
job := Job{Blob: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}
|
||||
got := job.BlobWithFixedByte(0x2a)
|
||||
if got[78:80] != "2a" {
|
||||
t.Fatalf("expected byte patch 2a, got %s", got[78:80])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_BlobWithFixedByte_Bad(t *testing.T) {
|
||||
job := Job{Blob: "short"}
|
||||
if got := job.BlobWithFixedByte(0x2a); got != "short" {
|
||||
t.Fatalf("expected short blob unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_BlobWithFixedByte_Ugly(t *testing.T) {
|
||||
job := Job{Blob: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"}
|
||||
got := job.BlobWithFixedByte(0x00)
|
||||
if got[78:80] != "00" {
|
||||
t.Fatalf("expected byte patch 00, got %s", got[78:80])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_DifficultyFromTarget_Good(t *testing.T) {
|
||||
job := Job{Target: "b88d0600"}
|
||||
if got := job.DifficultyFromTarget(); got == 0 {
|
||||
t.Fatal("expected non-zero difficulty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_DifficultyFromTarget_Bad(t *testing.T) {
|
||||
job := Job{Target: "zzzzzzzz"}
|
||||
if got := job.DifficultyFromTarget(); got != 0 {
|
||||
t.Fatalf("expected invalid target difficulty to be zero, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_DifficultyFromTarget_Ugly(t *testing.T) {
|
||||
job := Job{Target: "00000000"}
|
||||
if got := job.DifficultyFromTarget(); got != 0 {
|
||||
t.Fatalf("expected zero target difficulty to be zero, got %d", got)
|
||||
}
|
||||
job = Job{Target: "ffffffff"}
|
||||
if got := job.DifficultyFromTarget(); got != 1 {
|
||||
t.Fatalf("expected maximum target to resolve to difficulty 1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_JSON_Unmarshal_Good(t *testing.T) {
|
||||
var job Job
|
||||
if err := json.Unmarshal([]byte(`{"blob":"abcd","job_id":"job-1","target":"b88d0600","algo":"cn/r","height":42,"seed_hash":"seed","id":"session-1"}`), &job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.JobID != "job-1" || job.SeedHash != "seed" || job.ClientID != "session-1" {
|
||||
t.Fatalf("unexpected decoded job: %+v", job)
|
||||
}
|
||||
}
|
||||
105
log/access.go
105
log/access.go
|
|
@ -5,19 +5,108 @@
|
|||
// bus.Subscribe(proxy.EventClose, al.OnClose)
|
||||
package log
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
// AccessLog writes connection lifecycle lines to an append-only text file.
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
||||
// AccessLog writes append-only connection lines.
|
||||
//
|
||||
// Line format (connect): 2026-04-04T12:00:00Z CONNECT <ip> <user> <agent>
|
||||
// Line format (close): 2026-04-04T12:00:00Z CLOSE <ip> <user> rx=<bytes> tx=<bytes>
|
||||
//
|
||||
// al, result := log.NewAccessLog("/var/log/proxy-access.log")
|
||||
// al := log.NewAccessLog("/var/log/proxy-access.log")
|
||||
// bus.Subscribe(proxy.EventLogin, al.OnLogin)
|
||||
// bus.Subscribe(proxy.EventClose, al.OnClose)
|
||||
type AccessLog struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
// f is opened append-only on first write; nil until first event.
|
||||
// Uses core.File for I/O abstraction.
|
||||
file *os.File
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewAccessLog opens the file lazily on first write.
|
||||
//
|
||||
// al := log.NewAccessLog("/var/log/proxy-access.log")
|
||||
func NewAccessLog(path string) *AccessLog {
|
||||
return &AccessLog{path: path}
|
||||
}
|
||||
|
||||
// OnLogin writes `2026-04-04T12:00:00Z CONNECT 10.0.0.1 WALLET XMRig/6.21.0`.
|
||||
//
|
||||
// al.OnLogin(proxy.Event{Miner: miner})
|
||||
func (l *AccessLog) OnLogin(event proxy.Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s CONNECT %s %s %s\n",
|
||||
utcTimestamp(),
|
||||
event.Miner.IP(),
|
||||
event.Miner.User(),
|
||||
event.Miner.Agent(),
|
||||
)
|
||||
l.writeLine(line)
|
||||
}
|
||||
|
||||
// OnClose writes `2026-04-04T12:00:00Z CLOSE 10.0.0.1 WALLET rx=512 tx=4096`.
|
||||
//
|
||||
// al.OnClose(proxy.Event{Miner: miner})
|
||||
func (l *AccessLog) OnClose(event proxy.Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s CLOSE %s %s rx=%d tx=%d\n",
|
||||
utcTimestamp(),
|
||||
event.Miner.IP(),
|
||||
event.Miner.User(),
|
||||
event.Miner.RX(),
|
||||
event.Miner.TX(),
|
||||
)
|
||||
l.writeLine(line)
|
||||
}
|
||||
|
||||
// Close releases the append-only file handle if it has been opened.
|
||||
//
|
||||
// al.Close()
|
||||
func (l *AccessLog) Close() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return
|
||||
}
|
||||
l.closed = true
|
||||
if l.file != nil {
|
||||
_ = l.file.Close()
|
||||
l.file = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AccessLog) writeLine(line string) {
|
||||
if l == nil || l.path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return
|
||||
}
|
||||
|
||||
if l.file == nil {
|
||||
file, errorValue := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if errorValue != nil {
|
||||
return
|
||||
}
|
||||
l.file = file
|
||||
}
|
||||
|
||||
_, _ = l.file.WriteString(line)
|
||||
}
|
||||
|
|
|
|||
106
log/share.go
106
log/share.go
|
|
@ -1,11 +1,15 @@
|
|||
package log
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// ShareLog writes share result lines to an append-only text file.
|
||||
//
|
||||
// Line format (accept): 2026-04-04T12:00:00Z ACCEPT <user> diff=<diff> latency=<ms>ms
|
||||
// Line format (reject): 2026-04-04T12:00:00Z REJECT <user> reason="<message>"
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
||||
// ShareLog writes append-only share result lines.
|
||||
//
|
||||
// sl := log.NewShareLog("/var/log/proxy-shares.log")
|
||||
// bus.Subscribe(proxy.EventAccept, sl.OnAccept)
|
||||
|
|
@ -13,6 +17,94 @@ import "sync"
|
|||
type ShareLog struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
// f is opened append-only on first write; nil until first event.
|
||||
// Uses core.File for I/O abstraction.
|
||||
file *os.File
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewShareLog opens the file lazily on first write.
|
||||
//
|
||||
// sl := log.NewShareLog("/var/log/proxy-shares.log")
|
||||
func NewShareLog(path string) *ShareLog {
|
||||
return &ShareLog{path: path}
|
||||
}
|
||||
|
||||
// OnAccept writes `2026-04-04T12:00:00Z ACCEPT WALLET diff=100000 latency=82ms`.
|
||||
//
|
||||
// sl.OnAccept(proxy.Event{Miner: miner, Diff: 100000})
|
||||
func (l *ShareLog) OnAccept(event proxy.Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s ACCEPT %s diff=%d latency=%dms\n",
|
||||
utcTimestamp(),
|
||||
event.Miner.User(),
|
||||
event.Diff,
|
||||
event.Latency,
|
||||
)
|
||||
l.writeLine(line)
|
||||
}
|
||||
|
||||
// OnReject writes `2026-04-04T12:00:00Z REJECT WALLET reason="Low difficulty share"`.
|
||||
//
|
||||
// sl.OnReject(proxy.Event{Miner: miner, Error: "Low difficulty share"})
|
||||
func (l *ShareLog) OnReject(event proxy.Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s REJECT %s reason=%q\n",
|
||||
utcTimestamp(),
|
||||
event.Miner.User(),
|
||||
event.Error,
|
||||
)
|
||||
l.writeLine(line)
|
||||
}
|
||||
|
||||
// Close releases the append-only file handle if it has been opened.
|
||||
//
|
||||
// sl.Close()
|
||||
func (l *ShareLog) Close() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return
|
||||
}
|
||||
l.closed = true
|
||||
if l.file != nil {
|
||||
_ = l.file.Close()
|
||||
l.file = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *ShareLog) writeLine(line string) {
|
||||
if l == nil || l.path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return
|
||||
}
|
||||
|
||||
if l.file == nil {
|
||||
file, errorValue := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if errorValue != nil {
|
||||
return
|
||||
}
|
||||
l.file = file
|
||||
}
|
||||
|
||||
_, _ = l.file.WriteString(line)
|
||||
}
|
||||
|
||||
func utcTimestamp() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
|
|
|||
8
miner.go
8
miner.go
|
|
@ -28,7 +28,9 @@ type Miner struct {
|
|||
id int64 // monotonically increasing per-process; atomic assignment
|
||||
rpcID string // UUID v4 sent to miner as session id
|
||||
state MinerState
|
||||
stateMu sync.RWMutex
|
||||
extAlgo bool // miner sent algo list in login params
|
||||
algoExtension bool // config allows forwarding algo negotiation
|
||||
extNH bool // NiceHash mode active (fixed byte splitting)
|
||||
ip string // remote IP (without port, for logging)
|
||||
localPort uint16
|
||||
|
|
@ -36,6 +38,7 @@ type Miner struct {
|
|||
password string // login params.pass
|
||||
agent string // login params.agent
|
||||
rigID string // login params.rigid (optional extension)
|
||||
algo []string
|
||||
fixedByte uint8 // NiceHash slot index (0-255)
|
||||
mapperID int64 // which NonceMapper owns this miner; -1 = unassigned
|
||||
routeID int64 // SimpleMapper ID in simple mode; -1 = unassigned
|
||||
|
|
@ -45,6 +48,11 @@ type Miner struct {
|
|||
tx uint64 // bytes sent to miner
|
||||
connectedAt time.Time
|
||||
lastActivityAt time.Time
|
||||
events *EventBus
|
||||
splitter Splitter
|
||||
currentJob *Job
|
||||
closeOnce sync.Once
|
||||
accessPassword string
|
||||
conn net.Conn
|
||||
tlsConn *tls.Conn // nil if plain TCP
|
||||
sendMu sync.Mutex // serialises writes to conn
|
||||
|
|
|
|||
124
miner_methods.go
Normal file
124
miner_methods.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
var minerIDSequence atomic.Int64
|
||||
|
||||
// NewMiner creates a Miner for an accepted net.Conn. Does not start reading yet.
|
||||
//
|
||||
// m := proxy.NewMiner(conn, 3333, nil)
|
||||
func NewMiner(conn net.Conn, localPort uint16, tlsCfg *tls.Config) *Miner {
|
||||
miner := &Miner{
|
||||
id: minerIDSequence.Add(1),
|
||||
state: MinerStateWaitLogin,
|
||||
localPort: localPort,
|
||||
mapperID: -1,
|
||||
routeID: -1,
|
||||
conn: conn,
|
||||
connectedAt: time.Now().UTC(),
|
||||
lastActivityAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if tlsCfg != nil {
|
||||
if tlsConnection, ok := conn.(*tls.Conn); ok {
|
||||
miner.tlsConn = tlsConnection
|
||||
}
|
||||
}
|
||||
|
||||
if conn != nil && conn.RemoteAddr() != nil {
|
||||
miner.ip = remoteHost(conn.RemoteAddr().String())
|
||||
}
|
||||
|
||||
return miner
|
||||
}
|
||||
|
||||
func (m *Miner) ID() int64 { return m.id }
|
||||
func (m *Miner) RPCID() string { return m.rpcID }
|
||||
func (m *Miner) User() string { return m.user }
|
||||
func (m *Miner) Password() string { return m.password }
|
||||
func (m *Miner) Agent() string { return m.agent }
|
||||
func (m *Miner) RigID() string { return m.rigID }
|
||||
func (m *Miner) IP() string { return m.ip }
|
||||
func (m *Miner) State() MinerState {
|
||||
if m == nil {
|
||||
return MinerStateClosing
|
||||
}
|
||||
|
||||
m.stateMu.RLock()
|
||||
state := m.state
|
||||
m.stateMu.RUnlock()
|
||||
return state
|
||||
}
|
||||
func (m *Miner) Diff() uint64 { return m.diff }
|
||||
func (m *Miner) FixedByte() uint8 { return m.fixedByte }
|
||||
func (m *Miner) MapperID() int64 { return m.mapperID }
|
||||
func (m *Miner) RouteID() int64 { return m.routeID }
|
||||
func (m *Miner) CustomDiff() uint64 { return m.customDiff }
|
||||
func (m *Miner) TX() uint64 { return m.tx }
|
||||
func (m *Miner) RX() uint64 { return m.rx }
|
||||
func (m *Miner) LastActivityAt() time.Time { return m.lastActivityAt }
|
||||
|
||||
func (m *Miner) SetRPCID(value string) { m.rpcID = value }
|
||||
func (m *Miner) SetUser(value string) { m.user = value }
|
||||
func (m *Miner) SetPassword(value string) { m.password = value }
|
||||
func (m *Miner) SetAgent(value string) { m.agent = value }
|
||||
func (m *Miner) SetRigID(value string) { m.rigID = value }
|
||||
func (m *Miner) SetState(value MinerState) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.stateMu.Lock()
|
||||
m.state = value
|
||||
m.stateMu.Unlock()
|
||||
}
|
||||
func (m *Miner) SetDiff(value uint64) { m.diff = value }
|
||||
func (m *Miner) SetFixedByte(value uint8) { m.fixedByte = value }
|
||||
func (m *Miner) SetMapperID(value int64) { m.mapperID = value }
|
||||
func (m *Miner) SetRouteID(value int64) { m.routeID = value }
|
||||
func (m *Miner) SetCustomDiff(value uint64) { m.customDiff = value }
|
||||
func (m *Miner) SetNiceHashEnabled(value bool) { m.extNH = value }
|
||||
|
||||
func (m *Miner) PrimeJob(job Job) {
|
||||
if m == nil || !job.IsValid() {
|
||||
return
|
||||
}
|
||||
m.currentJob = &job
|
||||
m.diff = m.effectiveDifficulty(job)
|
||||
}
|
||||
|
||||
func (m *Miner) Touch() {
|
||||
m.lastActivityAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
func remoteHost(address string) string {
|
||||
host, _, errorValue := net.SplitHostPort(address)
|
||||
if errorValue != nil {
|
||||
return address
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (m *Miner) effectiveDifficulty(job Job) uint64 {
|
||||
difficulty := job.DifficultyFromTarget()
|
||||
if m == nil || m.customDiff == 0 {
|
||||
return difficulty
|
||||
}
|
||||
if difficulty == 0 || difficulty <= m.customDiff {
|
||||
return difficulty
|
||||
}
|
||||
return m.customDiff
|
||||
}
|
||||
|
||||
func (m *Miner) effectiveTarget(job Job) string {
|
||||
difficulty := m.effectiveDifficulty(job)
|
||||
if difficulty == 0 {
|
||||
return job.Target
|
||||
}
|
||||
return TargetForDifficulty(difficulty)
|
||||
}
|
||||
443
miner_runtime.go
Normal file
443
miner_runtime.go
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type minerRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
// Start begins the read loop in a goroutine and arms the login timeout timer.
|
||||
//
|
||||
// m.Start()
|
||||
func (m *Miner) Start() {
|
||||
if m == nil || m.conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
reader := bufio.NewReaderSize(m.conn, len(m.buf))
|
||||
for {
|
||||
if errorValue := m.applyReadDeadline(); errorValue != nil {
|
||||
m.Close()
|
||||
return
|
||||
}
|
||||
line, isPrefix, errorValue := reader.ReadLine()
|
||||
if errorValue != nil {
|
||||
m.Close()
|
||||
return
|
||||
}
|
||||
if isPrefix {
|
||||
m.Close()
|
||||
return
|
||||
}
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
m.rx += uint64(len(line) + 1)
|
||||
m.Touch()
|
||||
m.handleLine(line)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ForwardJob encodes the job as a stratum job notification and writes it to the miner.
|
||||
//
|
||||
// m.ForwardJob(job, "cn/r")
|
||||
func (m *Miner) ForwardJob(job Job, algo string) {
|
||||
if m == nil || m.conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
blob := job.Blob
|
||||
if m.extNH {
|
||||
blob = job.BlobWithFixedByte(m.fixedByte)
|
||||
}
|
||||
|
||||
target := m.effectiveTarget(job)
|
||||
m.diff = m.effectiveDifficulty(job)
|
||||
m.SetState(MinerStateReady)
|
||||
jobCopy := job
|
||||
m.currentJob = &jobCopy
|
||||
m.Touch()
|
||||
|
||||
params := map[string]interface{}{
|
||||
"blob": blob,
|
||||
"job_id": job.JobID,
|
||||
"target": target,
|
||||
"id": m.rpcID,
|
||||
}
|
||||
if job.Height > 0 {
|
||||
params["height"] = job.Height
|
||||
}
|
||||
if job.SeedHash != "" {
|
||||
params["seed_hash"] = job.SeedHash
|
||||
}
|
||||
if m.algoExtension && m.extAlgo && algo != "" {
|
||||
params["algo"] = algo
|
||||
}
|
||||
|
||||
m.writeJSON(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "job",
|
||||
"params": params,
|
||||
})
|
||||
}
|
||||
|
||||
// ReplyWithError sends a JSON-RPC error response for the given request id.
|
||||
//
|
||||
// m.ReplyWithError(2, "Low difficulty share")
|
||||
func (m *Miner) ReplyWithError(id int64, message string) {
|
||||
m.writeJSON(map[string]interface{}{
|
||||
"id": id,
|
||||
"jsonrpc": "2.0",
|
||||
"error": map[string]interface{}{
|
||||
"code": -1,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Success sends a JSON-RPC success response with the given status string.
|
||||
//
|
||||
// m.Success(2, "OK")
|
||||
func (m *Miner) Success(id int64, status string) {
|
||||
m.writeJSON(map[string]interface{}{
|
||||
"id": id,
|
||||
"jsonrpc": "2.0",
|
||||
"error": nil,
|
||||
"result": map[string]string{
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Close initiates graceful TCP shutdown. Safe to call multiple times.
|
||||
//
|
||||
// m.Close()
|
||||
func (m *Miner) Close() {
|
||||
if m == nil || m.conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.closeOnce.Do(func() {
|
||||
m.SetState(MinerStateClosing)
|
||||
if m.events != nil {
|
||||
m.events.Dispatch(Event{Type: EventClose, Miner: m})
|
||||
}
|
||||
_ = m.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Miner) writeJSON(value interface{}) {
|
||||
if m == nil || m.conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data, errorValue := json.Marshal(value)
|
||||
if errorValue != nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.sendMu.Lock()
|
||||
defer m.sendMu.Unlock()
|
||||
|
||||
data = append(data, '\n')
|
||||
written, errorValue := m.conn.Write(data)
|
||||
if errorValue == nil {
|
||||
m.tx += uint64(written)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Miner) handleLine(line []byte) {
|
||||
if len(line) > len(m.buf) {
|
||||
m.Close()
|
||||
return
|
||||
}
|
||||
|
||||
request := minerRequest{}
|
||||
if errorValue := json.Unmarshal(line, &request); errorValue != nil {
|
||||
m.Close()
|
||||
return
|
||||
}
|
||||
|
||||
switch request.Method {
|
||||
case "login":
|
||||
m.handleLogin(request)
|
||||
case "submit":
|
||||
m.handleSubmit(request)
|
||||
case "keepalived":
|
||||
m.handleKeepalived(request)
|
||||
default:
|
||||
if request.ID != 0 {
|
||||
m.ReplyWithError(request.ID, "Invalid request")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Miner) handleLogin(request minerRequest) {
|
||||
type loginParams struct {
|
||||
Login string `json:"login"`
|
||||
Pass string `json:"pass"`
|
||||
Agent string `json:"agent"`
|
||||
Algo []string `json:"algo"`
|
||||
RigID string `json:"rigid"`
|
||||
}
|
||||
|
||||
params := loginParams{}
|
||||
if errorValue := json.Unmarshal(request.Params, ¶ms); errorValue != nil {
|
||||
m.ReplyWithError(request.ID, "Invalid payment address provided")
|
||||
return
|
||||
}
|
||||
|
||||
if params.Login == "" {
|
||||
m.ReplyWithError(request.ID, "Invalid payment address provided")
|
||||
return
|
||||
}
|
||||
if m.accessPassword != "" && params.Pass != m.accessPassword {
|
||||
m.ReplyWithError(request.ID, "Invalid password")
|
||||
return
|
||||
}
|
||||
|
||||
m.SetCustomDiff(0)
|
||||
m.currentJob = nil
|
||||
m.diff = 0
|
||||
m.SetPassword(params.Pass)
|
||||
m.SetAgent(params.Agent)
|
||||
m.SetRigID(params.RigID)
|
||||
m.algo = append(m.algo[:0], params.Algo...)
|
||||
m.extAlgo = len(params.Algo) > 0
|
||||
m.SetUser(params.Login)
|
||||
m.SetRPCID(newRPCID())
|
||||
|
||||
if m.events != nil {
|
||||
m.events.Dispatch(Event{Type: EventLogin, Miner: m})
|
||||
}
|
||||
|
||||
m.Touch()
|
||||
if m.State() == MinerStateClosing {
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": m.rpcID,
|
||||
"status": "OK",
|
||||
}
|
||||
if m.currentJob != nil && m.currentJob.IsValid() {
|
||||
jobCopy := *m.currentJob
|
||||
blob := jobCopy.Blob
|
||||
if m.extNH {
|
||||
blob = jobCopy.BlobWithFixedByte(m.fixedByte)
|
||||
}
|
||||
jobResult := map[string]interface{}{
|
||||
"blob": blob,
|
||||
"job_id": jobCopy.JobID,
|
||||
"target": m.effectiveTarget(jobCopy),
|
||||
"id": m.rpcID,
|
||||
}
|
||||
if jobCopy.Height > 0 {
|
||||
jobResult["height"] = jobCopy.Height
|
||||
}
|
||||
if jobCopy.SeedHash != "" {
|
||||
jobResult["seed_hash"] = jobCopy.SeedHash
|
||||
}
|
||||
if m.algoExtension && m.extAlgo && jobCopy.Algo != "" {
|
||||
jobResult["algo"] = jobCopy.Algo
|
||||
}
|
||||
result["job"] = jobResult
|
||||
if m.algoExtension && m.extAlgo {
|
||||
result["extensions"] = []string{"algo"}
|
||||
}
|
||||
m.SetState(MinerStateReady)
|
||||
} else {
|
||||
m.SetState(MinerStateWaitReady)
|
||||
if m.algoExtension && m.extAlgo {
|
||||
result["extensions"] = []string{"algo"}
|
||||
}
|
||||
}
|
||||
|
||||
m.writeJSON(map[string]interface{}{
|
||||
"id": request.ID,
|
||||
"jsonrpc": "2.0",
|
||||
"error": nil,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Miner) handleSubmit(request minerRequest) {
|
||||
if m.State() != MinerStateReady {
|
||||
m.ReplyWithError(request.ID, "Unauthenticated")
|
||||
return
|
||||
}
|
||||
|
||||
type submitParams struct {
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"job_id"`
|
||||
Nonce string `json:"nonce"`
|
||||
Result string `json:"result"`
|
||||
Algo string `json:"algo"`
|
||||
}
|
||||
|
||||
params := submitParams{}
|
||||
if errorValue := json.Unmarshal(request.Params, ¶ms); errorValue != nil {
|
||||
m.ReplyWithError(request.ID, "Malformed share")
|
||||
return
|
||||
}
|
||||
|
||||
if params.ID != m.rpcID {
|
||||
m.ReplyWithError(request.ID, "Unauthenticated")
|
||||
return
|
||||
}
|
||||
if params.JobID == "" {
|
||||
m.ReplyWithError(request.ID, "Missing job id")
|
||||
return
|
||||
}
|
||||
if len(params.Nonce) != 8 || params.Nonce != strings.ToLower(params.Nonce) {
|
||||
m.ReplyWithError(request.ID, "Invalid nonce")
|
||||
return
|
||||
}
|
||||
if _, errorValue := hex.DecodeString(params.Nonce); errorValue != nil {
|
||||
m.ReplyWithError(request.ID, "Invalid nonce")
|
||||
return
|
||||
}
|
||||
|
||||
submitAlgo := ""
|
||||
if m.algoExtension && m.extAlgo {
|
||||
submitAlgo = params.Algo
|
||||
}
|
||||
|
||||
m.Touch()
|
||||
if m.events != nil {
|
||||
m.events.Dispatch(Event{
|
||||
Type: EventSubmit,
|
||||
Miner: m,
|
||||
JobID: params.JobID,
|
||||
Nonce: params.Nonce,
|
||||
Result: params.Result,
|
||||
Algo: submitAlgo,
|
||||
RequestID: request.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if m.splitter != nil {
|
||||
m.splitter.OnSubmit(&SubmitEvent{
|
||||
Miner: m,
|
||||
JobID: params.JobID,
|
||||
Nonce: params.Nonce,
|
||||
Result: params.Result,
|
||||
Algo: submitAlgo,
|
||||
RequestID: request.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Miner) handleKeepalived(request minerRequest) {
|
||||
m.Touch()
|
||||
m.Success(request.ID, "KEEPALIVED")
|
||||
}
|
||||
|
||||
func (m *Miner) currentJobCopy() *Job {
|
||||
if m == nil || m.currentJob == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobCopy := *m.currentJob
|
||||
return &jobCopy
|
||||
}
|
||||
|
||||
func (m *Miner) applyReadDeadline() error {
|
||||
if m == nil || m.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deadline := m.readDeadline()
|
||||
if deadline.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.conn.SetReadDeadline(deadline)
|
||||
}
|
||||
|
||||
func (m *Miner) readDeadline() time.Time {
|
||||
if m == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
switch m.State() {
|
||||
case MinerStateWaitLogin:
|
||||
return m.lastActivityAt.Add(10 * time.Second)
|
||||
case MinerStateWaitReady, MinerStateReady:
|
||||
return m.lastActivityAt.Add(600 * time.Second)
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Miner) dispatchSubmitResult(eventType EventType, diff uint64, errorMessage string, requestID int64) {
|
||||
if m == nil || m.events == nil {
|
||||
return
|
||||
}
|
||||
|
||||
jobCopy := m.currentJobCopy()
|
||||
m.events.Dispatch(Event{
|
||||
Type: eventType,
|
||||
Miner: m,
|
||||
Job: jobCopy,
|
||||
Diff: diff,
|
||||
Error: errorMessage,
|
||||
Latency: 0,
|
||||
})
|
||||
if eventType == EventAccept {
|
||||
m.Success(requestID, "OK")
|
||||
return
|
||||
}
|
||||
m.ReplyWithError(requestID, errorMessage)
|
||||
}
|
||||
|
||||
func (m *Miner) setStateFromJob(job Job) {
|
||||
m.currentJob = &job
|
||||
m.SetState(MinerStateReady)
|
||||
}
|
||||
|
||||
func (m *Miner) Expire() {
|
||||
if m == nil || m.State() == MinerStateClosing {
|
||||
return
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func newRPCID() string {
|
||||
value := make([]byte, 16)
|
||||
_, _ = rand.Read(value)
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
|
||||
encoded := make([]byte, 36)
|
||||
hex.Encode(encoded[0:8], value[0:4])
|
||||
encoded[8] = '-'
|
||||
hex.Encode(encoded[9:13], value[4:6])
|
||||
encoded[13] = '-'
|
||||
hex.Encode(encoded[14:18], value[6:8])
|
||||
encoded[18] = '-'
|
||||
hex.Encode(encoded[19:23], value[8:10])
|
||||
encoded[23] = '-'
|
||||
hex.Encode(encoded[24:36], value[10:16])
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (m *Miner) RemoteAddr() net.Addr {
|
||||
if m == nil || m.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return m.conn.RemoteAddr()
|
||||
}
|
||||
394
miner_runtime_test.go
Normal file
394
miner_runtime_test.go
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMiner_Login_Good(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
before := miner.LastActivityAt()
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "login",
|
||||
"params": map[string]interface{}{
|
||||
"login": "wallet",
|
||||
"pass": "x",
|
||||
"agent": "xmrig",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if response["jsonrpc"] != "2.0" {
|
||||
t.Fatalf("unexpected response: %#v", response)
|
||||
}
|
||||
if !miner.LastActivityAt().After(before) {
|
||||
t.Fatalf("expected login to refresh last activity timestamp, got before=%s after=%s", before, miner.LastActivityAt())
|
||||
}
|
||||
|
||||
result := response["result"].(map[string]interface{})
|
||||
id, _ := result["id"].(string)
|
||||
if result["status"] != "OK" || len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' || id[14] != '4' || !strings.ContainsAny(string(id[19]), "89ab") {
|
||||
t.Fatalf("unexpected login response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_Keepalived_Bad(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 2,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "keepalived",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := response["result"].(map[string]interface{})
|
||||
if result["status"] != "KEEPALIVED" {
|
||||
t.Fatalf("unexpected keepalived response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_Submit_Ugly(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
miner.SetRPCID("session")
|
||||
miner.SetState(MinerStateReady)
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 3,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "submit",
|
||||
"params": map[string]interface{}{
|
||||
"id": "session",
|
||||
"job_id": "job-1",
|
||||
"nonce": "ABC123",
|
||||
"result": "abc",
|
||||
"algo": "cn/r",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if response["error"] == nil {
|
||||
t.Fatalf("expected invalid nonce error, got %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_Login_Ugly(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.algoExtension = true
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 4,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "login",
|
||||
"params": map[string]interface{}{
|
||||
"login": "wallet",
|
||||
"pass": "x",
|
||||
"agent": "xmrig",
|
||||
"algo": []string{"cn/r"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := response["result"].(map[string]interface{})
|
||||
extensions, ok := result["extensions"].([]interface{})
|
||||
if !ok || len(extensions) != 1 || extensions[0] != "algo" {
|
||||
t.Fatalf("expected algo extension to be advertised, got %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_Login_NiceHashPatchedJob_Good(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.algoExtension = true
|
||||
miner.SetCustomDiff(10000)
|
||||
miner.events = NewEventBus()
|
||||
miner.events.Subscribe(EventLogin, func(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
event.Miner.SetNiceHashEnabled(true)
|
||||
event.Miner.SetFixedByte(0x2a)
|
||||
event.Miner.PrimeJob(Job{
|
||||
Blob: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
JobID: "job-1",
|
||||
Target: "b88d0600",
|
||||
Algo: "cn/r",
|
||||
Height: 42,
|
||||
SeedHash: "seed-hash",
|
||||
})
|
||||
})
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 5,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "login",
|
||||
"params": map[string]interface{}{
|
||||
"login": "wallet",
|
||||
"pass": "x",
|
||||
"agent": "xmrig",
|
||||
"algo": []string{"cn/r"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result := response["result"].(map[string]interface{})
|
||||
job := result["job"].(map[string]interface{})
|
||||
if blob, _ := job["blob"].(string); blob[78:80] != "2a" {
|
||||
t.Fatalf("expected patched NiceHash blob, got %q", blob)
|
||||
}
|
||||
if target, _ := job["target"].(string); target != TargetForDifficulty(10000) {
|
||||
t.Fatalf("expected custom diff target, got %q", target)
|
||||
}
|
||||
if height, _ := job["height"].(float64); height != 42 {
|
||||
t.Fatalf("expected job height to be forwarded, got %#v", job)
|
||||
}
|
||||
if seedHash, _ := job["seed_hash"].(string); seedHash != "seed-hash" {
|
||||
t.Fatalf("expected job seed_hash to be forwarded, got %#v", job)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_ForwardJob_Ugly(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.algoExtension = true
|
||||
miner.extAlgo = true
|
||||
miner.SetRPCID("session")
|
||||
miner.SetCustomDiff(10000)
|
||||
|
||||
go miner.ForwardJob(Job{
|
||||
Blob: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
JobID: "job-2",
|
||||
Target: "b88d0600",
|
||||
Algo: "rx/0",
|
||||
Height: 99,
|
||||
SeedHash: "seed",
|
||||
}, "rx/0")
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params := response["params"].(map[string]interface{})
|
||||
if params["target"] != TargetForDifficulty(10000) {
|
||||
t.Fatalf("expected custom diff target, got %#v", params)
|
||||
}
|
||||
if params["algo"] != "rx/0" || params["seed_hash"] != "seed" || params["height"] != float64(99) {
|
||||
t.Fatalf("expected extended job fields to be forwarded, got %#v", params)
|
||||
}
|
||||
if miner.Diff() != 10000 {
|
||||
t.Fatalf("expected miner diff to track the effective target, got %d", miner.Diff())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_Submit_Good(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.events = NewEventBus()
|
||||
miner.algoExtension = true
|
||||
miner.extAlgo = true
|
||||
miner.SetRPCID("session")
|
||||
miner.SetState(MinerStateReady)
|
||||
|
||||
submitSeen := make(chan Event, 1)
|
||||
miner.events.Subscribe(EventSubmit, func(event Event) {
|
||||
submitSeen <- event
|
||||
miner.Success(event.RequestID, "OK")
|
||||
})
|
||||
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 6,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "submit",
|
||||
"params": map[string]interface{}{
|
||||
"id": "session",
|
||||
"job_id": "job-1",
|
||||
"nonce": "deadbeef",
|
||||
"result": "abc",
|
||||
"algo": "cn/r",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-submitSeen:
|
||||
if event.JobID != "job-1" || event.Nonce != "deadbeef" || event.Algo != "cn/r" {
|
||||
t.Fatalf("unexpected submit event: %+v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected submit event to be dispatched")
|
||||
}
|
||||
|
||||
clientConn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
line, err := bufio.NewReader(clientConn).ReadBytes('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(line, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := response["result"].(map[string]interface{})
|
||||
if result["status"] != "OK" {
|
||||
t.Fatalf("unexpected submit response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiner_Submit_AlgoExtension_Bad(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
miner := NewMiner(serverConn, 3333, nil)
|
||||
miner.events = NewEventBus()
|
||||
miner.SetRPCID("session")
|
||||
miner.SetState(MinerStateReady)
|
||||
|
||||
submitSeen := make(chan Event, 1)
|
||||
miner.events.Subscribe(EventSubmit, func(event Event) {
|
||||
submitSeen <- event
|
||||
miner.Success(event.RequestID, "OK")
|
||||
})
|
||||
|
||||
miner.Start()
|
||||
defer miner.Close()
|
||||
|
||||
encoder := json.NewEncoder(clientConn)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"id": 7,
|
||||
"jsonrpc": "2.0",
|
||||
"method": "submit",
|
||||
"params": map[string]interface{}{
|
||||
"id": "session",
|
||||
"job_id": "job-1",
|
||||
"nonce": "deadbeef",
|
||||
"result": "abc",
|
||||
"algo": "cn/r",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case event := <-submitSeen:
|
||||
if event.Algo != "" {
|
||||
t.Fatalf("expected algo to be suppressed when extension is disabled, got %+v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected submit event to be dispatched")
|
||||
}
|
||||
}
|
||||
251
pool/client.go
251
pool/client.go
|
|
@ -5,9 +5,17 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
|
@ -19,13 +27,14 @@ import (
|
|||
// client := pool.NewStratumClient(poolCfg, listener)
|
||||
// client.Connect()
|
||||
type StratumClient struct {
|
||||
cfg proxy.PoolConfig
|
||||
config proxy.PoolConfig
|
||||
listener StratumListener
|
||||
conn net.Conn
|
||||
tlsConn *tls.Conn // nil if plain TCP
|
||||
sessionID string // pool-assigned session id from login reply
|
||||
seq int64 // atomic JSON-RPC request id counter
|
||||
requestSequence int64 // atomic JSON-RPC request id counter
|
||||
active bool // true once first job received
|
||||
disconnectOnce sync.Once
|
||||
sendMu sync.Mutex
|
||||
}
|
||||
|
||||
|
|
@ -39,3 +48,241 @@ type StratumListener interface {
|
|||
// OnDisconnect is called when the pool TCP connection closes for any reason.
|
||||
OnDisconnect()
|
||||
}
|
||||
|
||||
type jsonRPCRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *jsonRPCErrorBody `json:"error"`
|
||||
}
|
||||
|
||||
type jsonRPCErrorBody struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewStratumClient builds one outbound pool client.
|
||||
//
|
||||
// client := pool.NewStratumClient(proxy.PoolConfig{URL: "pool.lthn.io:3333", User: "WALLET", Pass: "x"}, listener)
|
||||
func NewStratumClient(cfg proxy.PoolConfig, listener StratumListener) *StratumClient {
|
||||
return &StratumClient{
|
||||
config: cfg,
|
||||
listener: listener,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect dials the pool and starts the read loop.
|
||||
//
|
||||
// client := pool.NewStratumClient(proxy.PoolConfig{URL: "pool.lthn.io:3333", TLS: true}, listener)
|
||||
// errorValue := client.Connect()
|
||||
func (c *StratumClient) Connect() error {
|
||||
var connection net.Conn
|
||||
var errorValue error
|
||||
dialer := net.Dialer{}
|
||||
if c.config.Keepalive {
|
||||
dialer.KeepAlive = 30 * time.Second
|
||||
}
|
||||
|
||||
if c.config.TLS {
|
||||
connection, errorValue = dialer.Dial("tcp", c.config.URL)
|
||||
if errorValue != nil {
|
||||
return errorValue
|
||||
}
|
||||
|
||||
serverName := c.config.URL
|
||||
if host, _, splitError := net.SplitHostPort(c.config.URL); splitError == nil && host != "" {
|
||||
serverName = host
|
||||
}
|
||||
|
||||
tlsConnection := tls.Client(connection, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: serverName})
|
||||
errorValue = tlsConnection.Handshake()
|
||||
if errorValue != nil {
|
||||
_ = connection.Close()
|
||||
return errorValue
|
||||
}
|
||||
if c.config.TLSFingerprint != "" {
|
||||
state := tlsConnection.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
_ = connection.Close()
|
||||
return errors.New("missing peer certificate")
|
||||
}
|
||||
|
||||
fingerprint := sha256.Sum256(state.PeerCertificates[0].Raw)
|
||||
if hex.EncodeToString(fingerprint[:]) != strings.ToLower(c.config.TLSFingerprint) {
|
||||
_ = connection.Close()
|
||||
return errors.New("pool fingerprint mismatch")
|
||||
}
|
||||
}
|
||||
connection = tlsConnection
|
||||
c.tlsConn = tlsConnection
|
||||
} else {
|
||||
connection, errorValue = dialer.Dial("tcp", c.config.URL)
|
||||
if errorValue != nil {
|
||||
return errorValue
|
||||
}
|
||||
}
|
||||
|
||||
c.conn = connection
|
||||
c.disconnectOnce = sync.Once{}
|
||||
go c.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login sends the stratum login request using the configured wallet and password.
|
||||
//
|
||||
// client.Login()
|
||||
func (c *StratumClient) Login() {
|
||||
password := c.config.Password
|
||||
if password == "" {
|
||||
password = c.config.Pass
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"login": c.config.User,
|
||||
"pass": password,
|
||||
"rigid": c.config.RigID,
|
||||
}
|
||||
if c.config.Algo != "" {
|
||||
params["algo"] = []string{c.config.Algo}
|
||||
}
|
||||
|
||||
_ = c.writeJSON(jsonRPCRequest{
|
||||
ID: 1,
|
||||
Method: "login",
|
||||
Params: params,
|
||||
})
|
||||
}
|
||||
|
||||
// Submit sends a share submission. Returns the sequence number for result correlation.
|
||||
//
|
||||
// seq := client.Submit("job-1", "deadbeef", "HASH64HEX", "cn/r")
|
||||
func (c *StratumClient) Submit(jobID string, nonce string, result string, algo string) int64 {
|
||||
requestID := atomic.AddInt64(&c.requestSequence, 1)
|
||||
params := map[string]string{
|
||||
"id": c.sessionID,
|
||||
"job_id": jobID,
|
||||
"nonce": nonce,
|
||||
"result": result,
|
||||
}
|
||||
if algo != "" {
|
||||
params["algo"] = algo
|
||||
}
|
||||
|
||||
_ = c.writeJSON(jsonRPCRequest{
|
||||
ID: requestID,
|
||||
Method: "submit",
|
||||
Params: params,
|
||||
})
|
||||
|
||||
return requestID
|
||||
}
|
||||
|
||||
// Disconnect closes the connection and emits one disconnect callback.
|
||||
//
|
||||
// client.Disconnect()
|
||||
func (c *StratumClient) Disconnect() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
c.notifyDisconnect()
|
||||
}
|
||||
|
||||
func (c *StratumClient) writeJSON(value interface{}) error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, errorValue := json.Marshal(value)
|
||||
if errorValue != nil {
|
||||
return errorValue
|
||||
}
|
||||
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
_, errorValue = c.conn.Write(append(data, '\n'))
|
||||
return errorValue
|
||||
}
|
||||
|
||||
func (c *StratumClient) readLoop() {
|
||||
reader := bufio.NewReaderSize(c.conn, 16384)
|
||||
for {
|
||||
line, isPrefix, errorValue := reader.ReadLine()
|
||||
if errorValue != nil {
|
||||
c.notifyDisconnect()
|
||||
return
|
||||
}
|
||||
if isPrefix {
|
||||
c.notifyDisconnect()
|
||||
return
|
||||
}
|
||||
|
||||
response := jsonRPCResponse{}
|
||||
if errorValue = json.Unmarshal(line, &response); errorValue != nil {
|
||||
continue
|
||||
}
|
||||
c.handleMessage(response)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *StratumClient) notifyDisconnect() {
|
||||
c.disconnectOnce.Do(func() {
|
||||
if c.listener != nil {
|
||||
c.listener.OnDisconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *StratumClient) handleMessage(response jsonRPCResponse) {
|
||||
if response.Method == "job" {
|
||||
var payload proxy.Job
|
||||
if json.Unmarshal(response.Params, &payload) == nil && payload.IsValid() {
|
||||
payload.ClientID = c.sessionID
|
||||
c.active = true
|
||||
if c.listener != nil {
|
||||
c.listener.OnJob(payload)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if response.ID == 1 && c.sessionID == "" {
|
||||
if len(response.Result) > 0 {
|
||||
var loginResult struct {
|
||||
ID string `json:"id"`
|
||||
Job proxy.Job `json:"job"`
|
||||
}
|
||||
if json.Unmarshal(response.Result, &loginResult) == nil && loginResult.ID != "" {
|
||||
c.sessionID = loginResult.ID
|
||||
if loginResult.Job.IsValid() {
|
||||
loginResult.Job.ClientID = c.sessionID
|
||||
c.active = true
|
||||
if c.listener != nil {
|
||||
c.listener.OnJob(loginResult.Job)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if response.Error != nil {
|
||||
c.Disconnect()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if response.ID == 0 || c.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
accepted := response.Error == nil
|
||||
errorMessage := ""
|
||||
if response.Error != nil {
|
||||
errorMessage = response.Error.Message
|
||||
}
|
||||
c.listener.OnResultAccepted(response.ID, accepted, errorMessage)
|
||||
}
|
||||
|
|
|
|||
248
pool/client_test.go
Normal file
248
pool/client_test.go
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
||||
type disconnectCountingListener struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
}
|
||||
|
||||
func (l *disconnectCountingListener) OnJob(job proxy.Job) {}
|
||||
|
||||
func (l *disconnectCountingListener) OnResultAccepted(sequence int64, accepted bool, errorMessage string) {
|
||||
}
|
||||
|
||||
func (l *disconnectCountingListener) OnDisconnect() {
|
||||
l.mu.Lock()
|
||||
l.count++
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *disconnectCountingListener) Count() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.count
|
||||
}
|
||||
|
||||
func TestStratumClient_ReadLoop_Ugly(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
listener := &disconnectCountingListener{}
|
||||
client := &StratumClient{
|
||||
listener: listener,
|
||||
conn: serverConn,
|
||||
}
|
||||
|
||||
go client.readLoop()
|
||||
|
||||
payload := make([]byte, 16385)
|
||||
for index := range payload {
|
||||
payload[index] = 'a'
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := clientConn.Write(payload)
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := listener.Count(); got != 1 {
|
||||
t.Fatalf("expected oversized line to close the connection, got %d disconnect callbacks", got)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-writeErr:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumClient_Disconnect_Good(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
listener := &disconnectCountingListener{}
|
||||
client := &StratumClient{
|
||||
listener: listener,
|
||||
conn: serverConn,
|
||||
}
|
||||
|
||||
go client.readLoop()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
client.Disconnect()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := listener.Count(); got != 1 {
|
||||
t.Fatalf("expected one disconnect callback, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type resultCapturingListener struct {
|
||||
mu sync.Mutex
|
||||
sequence int64
|
||||
accepted bool
|
||||
errorMessage string
|
||||
results int
|
||||
disconnects int
|
||||
}
|
||||
|
||||
func (l *resultCapturingListener) OnJob(job proxy.Job) {}
|
||||
|
||||
func (l *resultCapturingListener) OnResultAccepted(sequence int64, accepted bool, errorMessage string) {
|
||||
l.mu.Lock()
|
||||
l.sequence = sequence
|
||||
l.accepted = accepted
|
||||
l.errorMessage = errorMessage
|
||||
l.results++
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *resultCapturingListener) OnDisconnect() {
|
||||
l.mu.Lock()
|
||||
l.disconnects++
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *resultCapturingListener) Snapshot() (int64, bool, string, int, int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.sequence, l.accepted, l.errorMessage, l.results, l.disconnects
|
||||
}
|
||||
|
||||
func TestStratumClient_HandleMessage_Bad(t *testing.T) {
|
||||
listener := &resultCapturingListener{}
|
||||
client := &StratumClient{
|
||||
listener: listener,
|
||||
sessionID: "session-1",
|
||||
}
|
||||
|
||||
client.handleMessage(jsonRPCResponse{
|
||||
ID: 7,
|
||||
Error: &jsonRPCErrorBody{
|
||||
Code: -1,
|
||||
Message: "Low difficulty share",
|
||||
},
|
||||
})
|
||||
|
||||
sequence, accepted, errorMessage, results, disconnects := listener.Snapshot()
|
||||
if sequence != 7 || accepted || errorMessage != "Low difficulty share" || results != 1 {
|
||||
t.Fatalf("expected rejected submit callback, got sequence=%d accepted=%v error=%q results=%d", sequence, accepted, errorMessage, results)
|
||||
}
|
||||
if disconnects != 0 {
|
||||
t.Fatalf("expected no disconnect on submit rejection, got %d", disconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumClient_HandleMessage_Good(t *testing.T) {
|
||||
listener := &resultCapturingListener{}
|
||||
client := &StratumClient{
|
||||
listener: listener,
|
||||
sessionID: "session-1",
|
||||
}
|
||||
|
||||
client.handleMessage(jsonRPCResponse{
|
||||
ID: 7,
|
||||
})
|
||||
|
||||
sequence, accepted, errorMessage, results, disconnects := listener.Snapshot()
|
||||
if sequence != 7 || !accepted || errorMessage != "" || results != 1 {
|
||||
t.Fatalf("expected accepted submit callback, got sequence=%d accepted=%v error=%q results=%d", sequence, accepted, errorMessage, results)
|
||||
}
|
||||
if disconnects != 0 {
|
||||
t.Fatalf("expected no disconnect on submit accept, got %d", disconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumClient_HandleMessage_Ugly(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
listener := &resultCapturingListener{}
|
||||
client := &StratumClient{
|
||||
listener: listener,
|
||||
conn: serverConn,
|
||||
}
|
||||
defer client.Disconnect()
|
||||
|
||||
client.handleMessage(jsonRPCResponse{
|
||||
ID: 1,
|
||||
Error: &jsonRPCErrorBody{
|
||||
Code: -1,
|
||||
Message: "Unauthenticated",
|
||||
},
|
||||
})
|
||||
|
||||
_, _, _, results, disconnects := listener.Snapshot()
|
||||
if results != 0 {
|
||||
t.Fatalf("expected login rejection not to be reported as a share result, got %d results", results)
|
||||
}
|
||||
if disconnects != 1 {
|
||||
t.Fatalf("expected login rejection to disconnect once, got %d", disconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStratumClient_Login_Good(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
defer clientConn.Close()
|
||||
|
||||
client := &StratumClient{
|
||||
config: proxy.PoolConfig{
|
||||
User: "WALLET",
|
||||
Pass: "legacy",
|
||||
Password: "preferred",
|
||||
RigID: "rig-alpha",
|
||||
Algo: "cn/r",
|
||||
},
|
||||
conn: serverConn,
|
||||
}
|
||||
|
||||
writeDone := make(chan struct{})
|
||||
go func() {
|
||||
client.Login()
|
||||
close(writeDone)
|
||||
}()
|
||||
|
||||
buffer := make([]byte, 2048)
|
||||
n, err := clientConn.Read(buffer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var request jsonRPCRequest
|
||||
if err := json.Unmarshal(buffer[:n], &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
params, ok := request.Params.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected login params map, got %T", request.Params)
|
||||
}
|
||||
if got := params["pass"]; got != "preferred" {
|
||||
t.Fatalf("expected preferred password, got %v", got)
|
||||
}
|
||||
if got := params["rigid"]; got != "rig-alpha" {
|
||||
t.Fatalf("expected rigid field to be forwarded, got %v", got)
|
||||
}
|
||||
if got := params["algo"]; got == nil {
|
||||
t.Fatal("expected algo extension to be forwarded")
|
||||
}
|
||||
|
||||
client.Disconnect()
|
||||
select {
|
||||
case <-writeDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected login write to complete")
|
||||
}
|
||||
}
|
||||
162
pool/strategy.go
162
pool/strategy.go
|
|
@ -2,6 +2,7 @@ package pool
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
|
@ -17,7 +18,9 @@ type FailoverStrategy struct {
|
|||
current int
|
||||
client *StratumClient
|
||||
listener StratumListener
|
||||
cfg *proxy.Config
|
||||
config *proxy.Config
|
||||
closed bool
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
|
|
@ -35,3 +38,160 @@ type Strategy interface {
|
|||
Disconnect()
|
||||
IsActive() bool
|
||||
}
|
||||
|
||||
// NewStrategyFactory captures the live pool list and retry settings.
|
||||
//
|
||||
// factory := pool.NewStrategyFactory(proxy.Config{Pools: []proxy.PoolConfig{{URL: "pool.lthn.io:3333", Enabled: true}}})
|
||||
func NewStrategyFactory(cfg *proxy.Config) StrategyFactory {
|
||||
return func(listener StratumListener) Strategy {
|
||||
if cfg == nil {
|
||||
return NewFailoverStrategy(nil, listener, nil)
|
||||
}
|
||||
return NewFailoverStrategy(cfg.Pools, listener, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// NewFailoverStrategy builds one failover client stack.
|
||||
//
|
||||
// strategy := pool.NewFailoverStrategy([]proxy.PoolConfig{{URL: "pool.lthn.io:3333", Enabled: true}}, listener, cfg)
|
||||
func NewFailoverStrategy(pools []proxy.PoolConfig, listener StratumListener, cfg *proxy.Config) *FailoverStrategy {
|
||||
return &FailoverStrategy{
|
||||
pools: append([]proxy.PoolConfig(nil), pools...),
|
||||
listener: listener,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect dials the first enabled pool and rotates through fallbacks on failure.
|
||||
//
|
||||
// strategy.Connect()
|
||||
func (s *FailoverStrategy) Connect() {
|
||||
s.mu.Lock()
|
||||
s.closed = false
|
||||
s.mu.Unlock()
|
||||
s.connectFrom(0)
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) connectFrom(start int) {
|
||||
s.mu.Lock()
|
||||
if s.running || s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
pools := s.pools
|
||||
if s.config != nil && len(s.config.Pools) > 0 {
|
||||
pools = s.config.Pools
|
||||
}
|
||||
if len(pools) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
retries := 1
|
||||
pause := time.Duration(0)
|
||||
if s.config != nil {
|
||||
if s.config.Retries > 0 {
|
||||
retries = s.config.Retries
|
||||
}
|
||||
if s.config.RetryPause > 0 {
|
||||
pause = time.Duration(s.config.RetryPause) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < retries; attempt++ {
|
||||
for offset := 0; offset < len(pools); offset++ {
|
||||
index := (start + offset) % len(pools)
|
||||
poolConfig := pools[index]
|
||||
if !poolConfig.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
client := NewStratumClient(poolConfig, s)
|
||||
if errorValue := client.Connect(); errorValue == nil {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
client.Disconnect()
|
||||
return
|
||||
}
|
||||
s.client = client
|
||||
s.current = index
|
||||
s.mu.Unlock()
|
||||
client.Login()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if pause > 0 && attempt < retries-1 {
|
||||
time.Sleep(pause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) Submit(jobID string, nonce string, result string, algo string) int64 {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return 0
|
||||
}
|
||||
return client.Submit(jobID, nonce, result, algo)
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) Disconnect() {
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
client := s.client
|
||||
s.client = nil
|
||||
s.mu.Unlock()
|
||||
if client != nil {
|
||||
client.Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) IsActive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.client != nil && s.client.active
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) OnJob(job proxy.Job) {
|
||||
if s.listener != nil {
|
||||
s.listener.OnJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) OnResultAccepted(sequence int64, accepted bool, errorMessage string) {
|
||||
if s.listener != nil {
|
||||
s.listener.OnResultAccepted(sequence, accepted, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FailoverStrategy) OnDisconnect() {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.client = nil
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.listener != nil {
|
||||
s.listener.OnDisconnect()
|
||||
}
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
if client != nil {
|
||||
client.active = false
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
s.connectFrom(0)
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
306
pool/strategy_test.go
Normal file
306
pool/strategy_test.go
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
package pool
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
||||
type strategyTestListener struct {
|
||||
jobCh chan proxy.Job
|
||||
disconnectMu sync.Mutex
|
||||
disconnects int
|
||||
}
|
||||
|
||||
func (l *strategyTestListener) OnJob(job proxy.Job) {
|
||||
l.jobCh <- job
|
||||
}
|
||||
|
||||
func (l *strategyTestListener) OnResultAccepted(sequence int64, accepted bool, errorMessage string) {}
|
||||
|
||||
func (l *strategyTestListener) OnDisconnect() {
|
||||
l.disconnectMu.Lock()
|
||||
l.disconnects++
|
||||
l.disconnectMu.Unlock()
|
||||
}
|
||||
|
||||
func (l *strategyTestListener) Disconnects() int {
|
||||
l.disconnectMu.Lock()
|
||||
defer l.disconnectMu.Unlock()
|
||||
return l.disconnects
|
||||
}
|
||||
|
||||
func TestFailoverStrategy_Connect_Ugly(t *testing.T) {
|
||||
primaryListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer primaryListener.Close()
|
||||
|
||||
backupListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer backupListener.Close()
|
||||
|
||||
go func() {
|
||||
conn, acceptErr := primaryListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
_ = primaryListener.Close()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
conn, acceptErr := backupListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
if _, readErr := reader.ReadBytes('\n'); readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(conn).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"error": nil,
|
||||
"result": map[string]interface{}{
|
||||
"id": "session-1",
|
||||
"job": map[string]interface{}{
|
||||
"blob": "abcd",
|
||||
"job_id": "job-1",
|
||||
"target": "b88d0600",
|
||||
},
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
listener := &strategyTestListener{
|
||||
jobCh: make(chan proxy.Job, 1),
|
||||
}
|
||||
strategy := NewFailoverStrategy([]proxy.PoolConfig{
|
||||
{URL: primaryListener.Addr().String(), Enabled: true},
|
||||
{URL: backupListener.Addr().String(), Enabled: true},
|
||||
}, listener, &proxy.Config{Retries: 2})
|
||||
|
||||
strategy.Connect()
|
||||
defer strategy.Disconnect()
|
||||
|
||||
select {
|
||||
case job := <-listener.jobCh:
|
||||
if job.JobID != "job-1" {
|
||||
t.Fatalf("expected backup job, got %+v", job)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("expected failover job after primary disconnect")
|
||||
}
|
||||
|
||||
if listener.Disconnects() == 0 {
|
||||
t.Fatal("expected disconnect callback before failover reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailoverStrategy_OnDisconnect_Good(t *testing.T) {
|
||||
primaryListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer primaryListener.Close()
|
||||
|
||||
backupListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer backupListener.Close()
|
||||
|
||||
var primaryConnections atomic.Int32
|
||||
|
||||
go func() {
|
||||
conn, acceptErr := primaryListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
primaryConnections.Add(1)
|
||||
defer primaryListener.Close()
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
if _, readErr := reader.ReadBytes('\n'); readErr != nil {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(conn).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"error": map[string]interface{}{
|
||||
"code": -1,
|
||||
"message": "Unauthenticated",
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
go func() {
|
||||
conn, acceptErr := backupListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
if _, readErr := reader.ReadBytes('\n'); readErr != nil {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(conn).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"error": nil,
|
||||
"result": map[string]interface{}{
|
||||
"id": "session-1",
|
||||
"job": map[string]interface{}{
|
||||
"blob": "abcd",
|
||||
"job_id": "job-1",
|
||||
"target": "b88d0600",
|
||||
},
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
listener := &strategyTestListener{
|
||||
jobCh: make(chan proxy.Job, 1),
|
||||
}
|
||||
strategy := NewFailoverStrategy([]proxy.PoolConfig{
|
||||
{URL: primaryListener.Addr().String(), Enabled: true},
|
||||
{URL: backupListener.Addr().String(), Enabled: true},
|
||||
}, listener, &proxy.Config{Retries: 1})
|
||||
|
||||
strategy.Connect()
|
||||
defer strategy.Disconnect()
|
||||
|
||||
select {
|
||||
case job := <-listener.jobCh:
|
||||
if job.JobID != "job-1" {
|
||||
t.Fatalf("expected backup job, got %+v", job)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("expected backup job after primary disconnect, primary connections=%d", primaryConnections.Load())
|
||||
}
|
||||
|
||||
if listener.Disconnects() == 0 {
|
||||
t.Fatal("expected disconnect callback before failover reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailoverStrategy_OnDisconnect_PrimaryFirst(t *testing.T) {
|
||||
primaryListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primaryAddr := primaryListener.Addr().String()
|
||||
_ = primaryListener.Close()
|
||||
|
||||
backupListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer backupListener.Close()
|
||||
|
||||
go func() {
|
||||
conn, acceptErr := backupListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
if _, readErr := reader.ReadBytes('\n'); readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(conn).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"error": nil,
|
||||
"result": map[string]interface{}{
|
||||
"id": "session-backup",
|
||||
"job": map[string]interface{}{
|
||||
"blob": "abcd",
|
||||
"job_id": "backup-job",
|
||||
"target": "b88d0600",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
}()
|
||||
|
||||
listener := &strategyTestListener{
|
||||
jobCh: make(chan proxy.Job, 2),
|
||||
}
|
||||
strategy := NewFailoverStrategy([]proxy.PoolConfig{
|
||||
{URL: primaryAddr, Enabled: true},
|
||||
{URL: backupListener.Addr().String(), Enabled: true},
|
||||
}, listener, &proxy.Config{Retries: 2})
|
||||
|
||||
strategy.Connect()
|
||||
defer strategy.Disconnect()
|
||||
|
||||
select {
|
||||
case job := <-listener.jobCh:
|
||||
if job.JobID != "backup-job" {
|
||||
t.Fatalf("expected initial failover job, got %+v", job)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("expected initial failover job")
|
||||
}
|
||||
|
||||
primaryListener, err = net.Listen("tcp", primaryAddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer primaryListener.Close()
|
||||
|
||||
go func() {
|
||||
conn, acceptErr := primaryListener.Accept()
|
||||
if acceptErr != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
if _, readErr := reader.ReadBytes('\n'); readErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(conn).Encode(map[string]interface{}{
|
||||
"id": 1,
|
||||
"jsonrpc": "2.0",
|
||||
"error": nil,
|
||||
"result": map[string]interface{}{
|
||||
"id": "session-primary",
|
||||
"job": map[string]interface{}{
|
||||
"blob": "abcd",
|
||||
"job_id": "primary-job",
|
||||
"target": "b88d0600",
|
||||
},
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case job := <-listener.jobCh:
|
||||
if job.JobID != "primary-job" {
|
||||
t.Fatalf("expected reconnect to prefer primary pool, got %+v", job)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("expected reconnect job")
|
||||
}
|
||||
}
|
||||
42
proxy.go
42
proxy.go
|
|
@ -11,7 +11,9 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -22,11 +24,19 @@ import (
|
|||
// if result.OK { p.Start() }
|
||||
type Proxy struct {
|
||||
config *Config
|
||||
customDifficulty *CustomDiff
|
||||
rateLimiter *RateLimiter
|
||||
splitter Splitter
|
||||
stats *Stats
|
||||
workers *Workers
|
||||
events *EventBus
|
||||
currentMiners atomic.Uint64
|
||||
miners map[int64]*Miner
|
||||
minerMu sync.RWMutex
|
||||
servers []*Server
|
||||
httpServer *http.Server
|
||||
accessLogger *appendLineLogger
|
||||
shareLogger *appendLineLogger
|
||||
ticker *time.Ticker
|
||||
watcher *ConfigWatcher
|
||||
done chan struct{}
|
||||
|
|
@ -88,7 +98,8 @@ type CloseEvent struct {
|
|||
type ConfigWatcher struct {
|
||||
path string
|
||||
onChange func(*Config)
|
||||
lastMod time.Time
|
||||
enabled bool
|
||||
lastModifiedAt time.Time
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +111,7 @@ type ConfigWatcher struct {
|
|||
// rl := proxy.NewRateLimiter(cfg.RateLimit)
|
||||
// if !rl.Allow("1.2.3.4") { conn.Close(); return }
|
||||
type RateLimiter struct {
|
||||
cfg RateLimit
|
||||
config RateLimit
|
||||
buckets map[string]*tokenBucket
|
||||
banned map[string]time.Time
|
||||
mu sync.Mutex
|
||||
|
|
@ -119,4 +130,31 @@ type tokenBucket struct {
|
|||
// bus.Subscribe(proxy.EventLogin, cd.OnLogin)
|
||||
type CustomDiff struct {
|
||||
globalDiff uint64
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var splitterFactories = map[string]func(*Config, *EventBus) Splitter{
|
||||
"": noopSplitterFactory,
|
||||
}
|
||||
|
||||
// RegisterSplitterFactory registers a splitter constructor for a mode name.
|
||||
//
|
||||
// proxy.RegisterSplitterFactory("nicehash", func(cfg *proxy.Config, bus *proxy.EventBus) proxy.Splitter {
|
||||
// return nicehash.NewNonceSplitter(cfg, bus, pool.NewStrategyFactory(cfg))
|
||||
// })
|
||||
func RegisterSplitterFactory(mode string, factory func(*Config, *EventBus) Splitter) {
|
||||
if mode == "" || factory == nil {
|
||||
return
|
||||
}
|
||||
splitterFactories[mode] = factory
|
||||
}
|
||||
|
||||
func newSplitter(cfg *Config, events *EventBus) Splitter {
|
||||
if cfg == nil {
|
||||
return noopSplitter{}
|
||||
}
|
||||
if factory, exists := splitterFactories[cfg.Mode]; exists && factory != nil {
|
||||
return factory(cfg, events)
|
||||
}
|
||||
return noopSplitter{}
|
||||
}
|
||||
|
|
|
|||
244
proxy_http_runtime.go
Normal file
244
proxy_http_runtime.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const proxyAPIVersion = "1.0.0"
|
||||
|
||||
// RouteRegistrar is the minimal route-registration surface used by RegisterMonitoringRoutes.
|
||||
//
|
||||
// mux := http.NewServeMux()
|
||||
// RegisterMonitoringRoutes(mux, p)
|
||||
type RouteRegistrar interface {
|
||||
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
|
||||
}
|
||||
|
||||
// SummaryResponse is the /1/summary JSON body.
|
||||
type SummaryResponse struct {
|
||||
Version string `json:"version"`
|
||||
Mode string `json:"mode"`
|
||||
Hashrate HashrateResponse `json:"hashrate"`
|
||||
Miners MinersCountResponse `json:"miners"`
|
||||
Workers uint64 `json:"workers"`
|
||||
Upstreams UpstreamResponse `json:"upstreams"`
|
||||
Results ResultsResponse `json:"results"`
|
||||
}
|
||||
|
||||
// HashrateResponse carries the per-window hashrate array.
|
||||
type HashrateResponse struct {
|
||||
Total [6]float64 `json:"total"`
|
||||
}
|
||||
|
||||
// MinersCountResponse carries current and peak miner counts.
|
||||
type MinersCountResponse struct {
|
||||
Now uint64 `json:"now"`
|
||||
Max uint64 `json:"max"`
|
||||
}
|
||||
|
||||
// UpstreamResponse carries pool connection state counts.
|
||||
type UpstreamResponse struct {
|
||||
Active uint64 `json:"active"`
|
||||
Sleep uint64 `json:"sleep"`
|
||||
Error uint64 `json:"error"`
|
||||
Total uint64 `json:"total"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
}
|
||||
|
||||
// ResultsResponse carries share acceptance statistics.
|
||||
type ResultsResponse struct {
|
||||
Accepted uint64 `json:"accepted"`
|
||||
Rejected uint64 `json:"rejected"`
|
||||
Invalid uint64 `json:"invalid"`
|
||||
Expired uint64 `json:"expired"`
|
||||
AvgTime uint32 `json:"avg_time"`
|
||||
Latency uint32 `json:"latency"`
|
||||
HashesTotal uint64 `json:"hashes_total"`
|
||||
Best [10]uint64 `json:"best"`
|
||||
}
|
||||
|
||||
func startHTTPServer(p *Proxy) {
|
||||
if p == nil || p.config == nil || !p.config.HTTP.Enabled || p.httpServer != nil {
|
||||
return
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterMonitoringRoutes(mux, p)
|
||||
|
||||
address := net.JoinHostPort(p.config.HTTP.Host, strconv.Itoa(int(p.config.HTTP.Port)))
|
||||
listener, errorValue := net.Listen("tcp", address)
|
||||
if errorValue != nil {
|
||||
return
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: mux,
|
||||
}
|
||||
p.httpServer = server
|
||||
|
||||
go func() {
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
}
|
||||
|
||||
func stopHTTPServer(p *Proxy) {
|
||||
if p == nil || p.httpServer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
server := p.httpServer
|
||||
p.httpServer = nil
|
||||
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownContext)
|
||||
}
|
||||
|
||||
// RegisterMonitoringRoutes mounts the monitoring endpoints on any router with HandleFunc.
|
||||
//
|
||||
// mux := http.NewServeMux()
|
||||
// RegisterMonitoringRoutes(mux, p)
|
||||
func RegisterMonitoringRoutes(router RouteRegistrar, proxyValue *Proxy) {
|
||||
if router == nil || proxyValue == nil {
|
||||
return
|
||||
}
|
||||
|
||||
router.HandleFunc("/1/summary", func(writer http.ResponseWriter, request *http.Request) {
|
||||
if !allowHTTPRequest(writer, request, proxyValue.HTTPConfig()) {
|
||||
return
|
||||
}
|
||||
summary := proxyValue.Summary()
|
||||
upstreams := proxyValue.Upstreams()
|
||||
ratio := 0.0
|
||||
if upstreams.Total > 0 {
|
||||
ratio = float64(proxyValue.CurrentMiners()) / float64(upstreams.Total)
|
||||
}
|
||||
response := SummaryResponse{
|
||||
Version: proxyAPIVersion,
|
||||
Mode: proxyValue.Mode(),
|
||||
Hashrate: HashrateResponse{
|
||||
Total: summary.Hashrate,
|
||||
},
|
||||
Miners: MinersCountResponse{
|
||||
Now: proxyValue.CurrentMiners(),
|
||||
Max: proxyValue.MaxMiners(),
|
||||
},
|
||||
Workers: uint64(len(proxyValue.Workers())),
|
||||
Upstreams: UpstreamResponse{
|
||||
Active: upstreams.Active,
|
||||
Sleep: upstreams.Sleep,
|
||||
Error: upstreams.Error,
|
||||
Total: upstreams.Total,
|
||||
Ratio: ratio,
|
||||
},
|
||||
Results: ResultsResponse{
|
||||
Accepted: summary.Accepted,
|
||||
Rejected: summary.Rejected,
|
||||
Invalid: summary.Invalid,
|
||||
Expired: summary.Expired,
|
||||
AvgTime: summary.AvgTime,
|
||||
Latency: summary.AvgLatency,
|
||||
HashesTotal: summary.Hashes,
|
||||
Best: summary.TopDiff,
|
||||
},
|
||||
}
|
||||
writeHTTPJSON(writer, response)
|
||||
})
|
||||
|
||||
router.HandleFunc("/1/workers", func(writer http.ResponseWriter, request *http.Request) {
|
||||
if !allowHTTPRequest(writer, request, proxyValue.HTTPConfig()) {
|
||||
return
|
||||
}
|
||||
|
||||
records := proxyValue.Workers()
|
||||
rows := make([][]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
rows = append(rows, []interface{}{
|
||||
record.Name,
|
||||
record.LastIP,
|
||||
record.Connections,
|
||||
record.Accepted,
|
||||
record.Rejected,
|
||||
record.Invalid,
|
||||
record.Hashes,
|
||||
record.LastHashAt.Unix(),
|
||||
record.Hashrate(60),
|
||||
record.Hashrate(600),
|
||||
record.Hashrate(3600),
|
||||
record.Hashrate(43200),
|
||||
record.Hashrate(86400),
|
||||
})
|
||||
}
|
||||
|
||||
writeHTTPJSON(writer, map[string]interface{}{
|
||||
"mode": proxyValue.WorkersMode(),
|
||||
"workers": rows,
|
||||
})
|
||||
})
|
||||
|
||||
router.HandleFunc("/1/miners", func(writer http.ResponseWriter, request *http.Request) {
|
||||
if !allowHTTPRequest(writer, request, proxyValue.HTTPConfig()) {
|
||||
return
|
||||
}
|
||||
|
||||
miners := proxyValue.Miners()
|
||||
rows := make([][]interface{}, 0, len(miners))
|
||||
for _, miner := range miners {
|
||||
ip := ""
|
||||
if remote := miner.RemoteAddr(); remote != nil {
|
||||
ip = remote.String()
|
||||
}
|
||||
rows = append(rows, []interface{}{
|
||||
miner.ID(),
|
||||
ip,
|
||||
miner.TX(),
|
||||
miner.RX(),
|
||||
miner.State(),
|
||||
miner.Diff(),
|
||||
miner.User(),
|
||||
"********",
|
||||
miner.RigID(),
|
||||
miner.Agent(),
|
||||
})
|
||||
}
|
||||
|
||||
writeHTTPJSON(writer, map[string]interface{}{
|
||||
"format": []string{"id", "ip", "tx", "rx", "state", "diff", "user", "password", "rig_id", "agent"},
|
||||
"miners": rows,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func allowHTTPRequest(writer http.ResponseWriter, request *http.Request, config HTTPConfig) bool {
|
||||
if request == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if config.AccessToken != "" {
|
||||
header := request.Header.Get("Authorization")
|
||||
prefix := "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) || strings.TrimSpace(strings.TrimPrefix(header, prefix)) != config.AccessToken {
|
||||
writer.Header().Set("WWW-Authenticate", "Bearer")
|
||||
http.Error(writer, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if config.Restricted && request.Method != http.MethodGet {
|
||||
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func writeHTTPJSON(writer http.ResponseWriter, value interface{}) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
142
proxy_logging_runtime.go
Normal file
142
proxy_logging_runtime.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type appendLineLogger struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newAppendLineLogger(path string) *appendLineLogger {
|
||||
return &appendLineLogger{path: path}
|
||||
}
|
||||
|
||||
func (l *appendLineLogger) writeLine(line string) {
|
||||
if l == nil || l.path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return
|
||||
}
|
||||
|
||||
if l.file == nil {
|
||||
file, errorValue := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if errorValue != nil {
|
||||
return
|
||||
}
|
||||
l.file = file
|
||||
}
|
||||
|
||||
_, _ = l.file.WriteString(line)
|
||||
}
|
||||
|
||||
func (l *appendLineLogger) setPath(path string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.path == path {
|
||||
return
|
||||
}
|
||||
|
||||
if l.file != nil {
|
||||
_ = l.file.Close()
|
||||
l.file = nil
|
||||
}
|
||||
l.closed = false
|
||||
l.path = path
|
||||
}
|
||||
|
||||
func (l *appendLineLogger) close() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.closed {
|
||||
return
|
||||
}
|
||||
l.closed = true
|
||||
if l.file != nil {
|
||||
_ = l.file.Close()
|
||||
l.file = nil
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeAccessLog(events *EventBus, path string) *appendLineLogger {
|
||||
if events == nil || path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := newAppendLineLogger(path)
|
||||
events.Subscribe(EventLogin, func(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
logger.writeLine(fmt.Sprintf("%s CONNECT %s %s %s\n",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
event.Miner.IP(),
|
||||
event.Miner.User(),
|
||||
event.Miner.Agent(),
|
||||
))
|
||||
})
|
||||
events.Subscribe(EventClose, func(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
logger.writeLine(fmt.Sprintf("%s CLOSE %s %s rx=%d tx=%d\n",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
event.Miner.IP(),
|
||||
event.Miner.User(),
|
||||
event.Miner.RX(),
|
||||
event.Miner.TX(),
|
||||
))
|
||||
})
|
||||
return logger
|
||||
}
|
||||
|
||||
func subscribeShareLog(events *EventBus, path string) *appendLineLogger {
|
||||
if events == nil || path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := newAppendLineLogger(path)
|
||||
events.Subscribe(EventAccept, func(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
logger.writeLine(fmt.Sprintf("%s ACCEPT %s diff=%d latency=%dms\n",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
event.Miner.User(),
|
||||
event.Diff,
|
||||
event.Latency,
|
||||
))
|
||||
})
|
||||
events.Subscribe(EventReject, func(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
logger.writeLine(fmt.Sprintf("%s REJECT %s reason=%q\n",
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
event.Miner.User(),
|
||||
event.Error,
|
||||
))
|
||||
})
|
||||
return logger
|
||||
}
|
||||
356
proxy_runtime.go
Normal file
356
proxy_runtime.go
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type splitterShutdown interface {
|
||||
PendingCount() int
|
||||
Disconnect()
|
||||
}
|
||||
|
||||
// New wires the proxy and returns a ready-to-start instance.
|
||||
//
|
||||
// p, errorValue := proxy.New(config)
|
||||
func New(config *Config) (*Proxy, error) {
|
||||
if config == nil {
|
||||
return nil, errors.New("config is nil")
|
||||
}
|
||||
if errorValue := config.Validate(); errorValue != nil {
|
||||
return nil, errorValue
|
||||
}
|
||||
|
||||
eventBus := NewEventBus()
|
||||
statsValue := NewStats()
|
||||
customDifficultyFilter := NewCustomDiff(config.CustomDiff)
|
||||
eventBus.Subscribe(EventLogin, customDifficultyFilter.OnLogin)
|
||||
workersValue := NewWorkers(config.Workers, eventBus)
|
||||
workersValue.SetCustomDiffStats(config.CustomDiffStats)
|
||||
splitterValue := newSplitter(config, eventBus)
|
||||
|
||||
proxyInstance := &Proxy{
|
||||
config: config,
|
||||
customDifficulty: customDifficultyFilter,
|
||||
splitter: splitterValue,
|
||||
stats: statsValue,
|
||||
workers: workersValue,
|
||||
events: eventBus,
|
||||
miners: make(map[int64]*Miner),
|
||||
rateLimiter: NewRateLimiter(config.RateLimit),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
proxyInstance.accessLogger = subscribeAccessLog(eventBus, config.AccessLogFile)
|
||||
proxyInstance.shareLogger = subscribeShareLog(eventBus, config.ShareLogFile)
|
||||
|
||||
eventBus.Subscribe(EventLogin, func(event Event) {
|
||||
if event.Miner != nil {
|
||||
proxyInstance.minerMu.Lock()
|
||||
proxyInstance.miners[event.Miner.ID()] = event.Miner
|
||||
proxyInstance.minerMu.Unlock()
|
||||
}
|
||||
current := proxyInstance.currentMiners.Add(1)
|
||||
for {
|
||||
maximum := statsValue.maxMiners.Load()
|
||||
if current <= maximum || statsValue.maxMiners.CompareAndSwap(maximum, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
eventBus.Subscribe(EventClose, func(event Event) {
|
||||
if event.Miner != nil {
|
||||
proxyInstance.minerMu.Lock()
|
||||
delete(proxyInstance.miners, event.Miner.ID())
|
||||
proxyInstance.minerMu.Unlock()
|
||||
}
|
||||
if proxyInstance.currentMiners.Load() > 0 {
|
||||
proxyInstance.currentMiners.Add(^uint64(0))
|
||||
}
|
||||
})
|
||||
eventBus.Subscribe(EventAccept, statsValue.OnAccept)
|
||||
eventBus.Subscribe(EventReject, statsValue.OnReject)
|
||||
if splitterValue != nil {
|
||||
eventBus.Subscribe(EventSubmit, func(event Event) {
|
||||
splitterValue.OnSubmit(&SubmitEvent{
|
||||
Miner: event.Miner,
|
||||
JobID: event.JobID,
|
||||
Nonce: event.Nonce,
|
||||
Result: event.Result,
|
||||
Algo: event.Algo,
|
||||
RequestID: event.RequestID,
|
||||
})
|
||||
})
|
||||
eventBus.Subscribe(EventLogin, func(event Event) {
|
||||
splitterValue.OnLogin(&LoginEvent{Miner: event.Miner})
|
||||
})
|
||||
eventBus.Subscribe(EventClose, func(event Event) {
|
||||
splitterValue.OnClose(&CloseEvent{Miner: event.Miner})
|
||||
})
|
||||
}
|
||||
if config.Watch && config.sourcePath != "" {
|
||||
proxyInstance.watcher = newConfigWatcher(config.sourcePath, proxyInstance.Reload, config.Watch)
|
||||
proxyInstance.watcher.Start()
|
||||
}
|
||||
|
||||
return proxyInstance, nil
|
||||
}
|
||||
|
||||
// Start connects the pool, opens listeners, and blocks until `Stop()`.
|
||||
//
|
||||
// p.Start()
|
||||
func (p *Proxy) Start() {
|
||||
if p.splitter != nil {
|
||||
p.splitter.Connect()
|
||||
}
|
||||
p.ticker = time.NewTicker(time.Second)
|
||||
|
||||
for _, bind := range p.config.Bind {
|
||||
var tlsConfig *tls.Config
|
||||
if bind.TLS && p.config.TLS.Enabled {
|
||||
certificate, errorValue := tls.LoadX509KeyPair(p.config.TLS.CertFile, p.config.TLS.KeyFile)
|
||||
if errorValue == nil {
|
||||
tlsConfig = buildTLSConfig(p.config.TLS)
|
||||
tlsConfig.Certificates = []tls.Certificate{certificate}
|
||||
} else {
|
||||
p.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
server, errorValue := NewServer(bind, tlsConfig, p.rateLimiter, p.acceptConn)
|
||||
if errorValue != nil {
|
||||
p.Stop()
|
||||
return
|
||||
}
|
||||
p.servers = append(p.servers, server)
|
||||
server.Start()
|
||||
}
|
||||
|
||||
if p.config != nil && p.config.HTTP.Enabled {
|
||||
startHTTPServer(p)
|
||||
}
|
||||
|
||||
go func() {
|
||||
var ticks uint64
|
||||
for {
|
||||
select {
|
||||
case <-p.ticker.C:
|
||||
ticks++
|
||||
p.stats.Tick()
|
||||
p.workers.Tick()
|
||||
if p.rateLimiter != nil {
|
||||
p.rateLimiter.Tick()
|
||||
}
|
||||
if p.splitter != nil {
|
||||
p.splitter.Tick(ticks)
|
||||
}
|
||||
case <-p.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
<-p.done
|
||||
}
|
||||
|
||||
type noopSplitter struct{}
|
||||
|
||||
func (noopSplitter) Connect() {}
|
||||
func (noopSplitter) OnLogin(event *LoginEvent) {}
|
||||
func (noopSplitter) OnSubmit(event *SubmitEvent) {}
|
||||
func (noopSplitter) OnClose(event *CloseEvent) {}
|
||||
func (noopSplitter) Tick(ticks uint64) {}
|
||||
func (noopSplitter) GC() {}
|
||||
func (noopSplitter) Upstreams() UpstreamStats { return UpstreamStats{} }
|
||||
func (noopSplitter) PendingCount() int { return 0 }
|
||||
func (noopSplitter) Disconnect() {}
|
||||
|
||||
func noopSplitterFactory(cfg *Config, events *EventBus) Splitter {
|
||||
return noopSplitter{}
|
||||
}
|
||||
|
||||
// Stop closes listeners, log files, watcher, miners, and pool connections.
|
||||
//
|
||||
// p.Stop()
|
||||
func (p *Proxy) Stop() {
|
||||
if p.ticker != nil {
|
||||
p.ticker.Stop()
|
||||
}
|
||||
for _, server := range p.servers {
|
||||
server.Stop()
|
||||
}
|
||||
stopHTTPServer(p)
|
||||
if p.watcher != nil {
|
||||
p.watcher.Stop()
|
||||
}
|
||||
|
||||
if shutdown, ok := p.splitter.(splitterShutdown); ok {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for shutdown.PendingCount() > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
p.minerMu.RLock()
|
||||
miners := make([]*Miner, 0, len(p.miners))
|
||||
for _, miner := range p.miners {
|
||||
miners = append(miners, miner)
|
||||
}
|
||||
p.minerMu.RUnlock()
|
||||
for _, miner := range miners {
|
||||
if miner != nil {
|
||||
miner.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if shutdown, ok := p.splitter.(splitterShutdown); ok {
|
||||
shutdown.Disconnect()
|
||||
}
|
||||
|
||||
if p.accessLogger != nil {
|
||||
p.accessLogger.close()
|
||||
}
|
||||
if p.shareLogger != nil {
|
||||
p.shareLogger.close()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-p.done:
|
||||
default:
|
||||
close(p.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload replaces the live config.
|
||||
//
|
||||
// p.Reload(newCfg)
|
||||
func (p *Proxy) Reload(config *Config) {
|
||||
if config != nil {
|
||||
if p.config == nil {
|
||||
p.config = config
|
||||
} else {
|
||||
sourcePath := p.config.sourcePath
|
||||
bind := append([]BindAddr(nil), p.config.Bind...)
|
||||
*p.config = *config
|
||||
p.config.sourcePath = sourcePath
|
||||
p.config.Bind = bind
|
||||
}
|
||||
if p.customDifficulty != nil {
|
||||
p.customDifficulty.SetGlobalDiff(p.config.CustomDiff)
|
||||
}
|
||||
if p.workers != nil {
|
||||
p.workers.SetCustomDiffStats(p.config.CustomDiffStats)
|
||||
}
|
||||
if p.rateLimiter != nil {
|
||||
p.rateLimiter.SetConfig(p.config.RateLimit)
|
||||
}
|
||||
if p.accessLogger != nil {
|
||||
p.accessLogger.setPath(p.config.AccessLogFile)
|
||||
}
|
||||
if p.shareLogger != nil {
|
||||
p.shareLogger.setPath(p.config.ShareLogFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) Summary() StatsSummary {
|
||||
if p == nil || p.stats == nil {
|
||||
return StatsSummary{}
|
||||
}
|
||||
return p.stats.Summary()
|
||||
}
|
||||
|
||||
func (p *Proxy) Workers() []WorkerRecord {
|
||||
if p == nil || p.workers == nil {
|
||||
return nil
|
||||
}
|
||||
return p.workers.List()
|
||||
}
|
||||
|
||||
func (p *Proxy) Miners() []*Miner {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.minerMu.RLock()
|
||||
defer p.minerMu.RUnlock()
|
||||
|
||||
miners := make([]*Miner, 0, len(p.miners))
|
||||
for _, miner := range p.miners {
|
||||
miners = append(miners, miner)
|
||||
}
|
||||
sort.Slice(miners, func(left int, right int) bool {
|
||||
if miners[left] == nil {
|
||||
return false
|
||||
}
|
||||
if miners[right] == nil {
|
||||
return true
|
||||
}
|
||||
return miners[left].ID() < miners[right].ID()
|
||||
})
|
||||
return miners
|
||||
}
|
||||
|
||||
func (p *Proxy) CurrentMiners() uint64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return p.currentMiners.Load()
|
||||
}
|
||||
|
||||
func (p *Proxy) MaxMiners() uint64 {
|
||||
if p == nil || p.stats == nil {
|
||||
return 0
|
||||
}
|
||||
return p.stats.maxMiners.Load()
|
||||
}
|
||||
|
||||
func (p *Proxy) Mode() string {
|
||||
if p == nil || p.config == nil {
|
||||
return ""
|
||||
}
|
||||
return p.config.Mode
|
||||
}
|
||||
|
||||
func (p *Proxy) HTTPConfig() HTTPConfig {
|
||||
if p == nil || p.config == nil {
|
||||
return HTTPConfig{}
|
||||
}
|
||||
return p.config.HTTP
|
||||
}
|
||||
|
||||
func (p *Proxy) WorkersMode() string {
|
||||
if p == nil || p.config == nil {
|
||||
return ""
|
||||
}
|
||||
return string(p.config.Workers)
|
||||
}
|
||||
|
||||
func (p *Proxy) Upstreams() UpstreamStats {
|
||||
if p == nil || p.splitter == nil {
|
||||
return UpstreamStats{}
|
||||
}
|
||||
return p.splitter.Upstreams()
|
||||
}
|
||||
|
||||
func (p *Proxy) acceptConn(conn net.Conn, localPort uint16) {
|
||||
if p != nil && p.stats != nil {
|
||||
p.stats.connections.Add(1)
|
||||
}
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
if _, ok := conn.(*tls.Conn); ok {
|
||||
tlsConfig = &tls.Config{}
|
||||
}
|
||||
miner := NewMiner(conn, localPort, tlsConfig)
|
||||
miner.events = p.events
|
||||
miner.splitter = p.splitter
|
||||
if p.config != nil {
|
||||
miner.accessPassword = p.config.AccessPassword
|
||||
miner.algoExtension = p.config.AlgoExtension
|
||||
}
|
||||
miner.Start()
|
||||
}
|
||||
134
proxy_runtime_test.go
Normal file
134
proxy_runtime_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProxy_Reload_Good(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := &Config{
|
||||
Mode: "nicehash",
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
CustomDiff: 100,
|
||||
Workers: WorkersDisabled,
|
||||
AccessLogFile: tempDir + "/access-a.log",
|
||||
ShareLogFile: tempDir + "/share-a.log",
|
||||
}
|
||||
proxyValue, errorValue := New(cfg)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
miner := &Miner{user: "wallet", agent: "agent", ip: "10.0.0.1"}
|
||||
proxyValue.events.Dispatch(Event{Type: EventLogin, Miner: miner})
|
||||
proxyValue.events.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 100, Latency: 10})
|
||||
|
||||
reloadCfg := &Config{
|
||||
Mode: "simple",
|
||||
Bind: []BindAddr{{Host: "0.0.0.0", Port: 4444}},
|
||||
Pools: []PoolConfig{{URL: "pool-b:4444", Enabled: true}},
|
||||
CustomDiff: 250,
|
||||
Workers: WorkersByUser,
|
||||
AccessLogFile: tempDir + "/access-b.log",
|
||||
ShareLogFile: tempDir + "/share-b.log",
|
||||
}
|
||||
|
||||
proxyValue.Reload(reloadCfg)
|
||||
|
||||
if len(proxyValue.config.Bind) != 1 || proxyValue.config.Bind[0].Port != 3333 {
|
||||
t.Fatalf("expected bind addresses to remain unchanged, got %+v", proxyValue.config.Bind)
|
||||
}
|
||||
if len(proxyValue.config.Pools) != 1 || proxyValue.config.Pools[0].URL != "pool-b:4444" {
|
||||
t.Fatalf("expected pools to reload, got %+v", proxyValue.config.Pools)
|
||||
}
|
||||
if proxyValue.config.CustomDiff != 250 {
|
||||
t.Fatalf("expected custom diff to reload, got %d", proxyValue.config.CustomDiff)
|
||||
}
|
||||
if proxyValue.customDifficulty == nil || proxyValue.customDifficulty.globalDiff != 250 {
|
||||
t.Fatalf("expected live custom diff to update, got %+v", proxyValue.customDifficulty)
|
||||
}
|
||||
|
||||
proxyValue.events.Dispatch(Event{Type: EventLogin, Miner: miner})
|
||||
proxyValue.events.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 250, Latency: 12})
|
||||
|
||||
oldAccessLog, errorValue := os.ReadFile(tempDir + "/access-a.log")
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
newAccessLog, errorValue := os.ReadFile(tempDir + "/access-b.log")
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
if strings.Count(string(oldAccessLog), "CONNECT") != 1 || strings.Count(string(newAccessLog), "CONNECT") != 1 {
|
||||
t.Fatalf("expected access log writes to move across reload, got old=%q new=%q", oldAccessLog, newAccessLog)
|
||||
}
|
||||
|
||||
oldShareLog, errorValue := os.ReadFile(tempDir + "/share-a.log")
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
newShareLog, errorValue := os.ReadFile(tempDir + "/share-b.log")
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
if strings.Count(string(oldShareLog), "ACCEPT") != 1 || strings.Count(string(newShareLog), "ACCEPT") != 1 {
|
||||
t.Fatalf("expected share log writes to move across reload, got old=%q new=%q", oldShareLog, newShareLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_CurrentMiners_Good(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "nicehash",
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
Workers: WorkersDisabled,
|
||||
}
|
||||
firstProxy, errorValue := New(cfg)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
secondProxy, errorValue := New(cfg)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
miner := &Miner{}
|
||||
firstProxy.events.Dispatch(Event{Type: EventLogin, Miner: miner})
|
||||
if got := firstProxy.CurrentMiners(); got != 1 {
|
||||
t.Fatalf("expected first proxy miner count 1, got %d", got)
|
||||
}
|
||||
if got := secondProxy.CurrentMiners(); got != 0 {
|
||||
t.Fatalf("expected second proxy miner count 0, got %d", got)
|
||||
}
|
||||
|
||||
firstProxy.events.Dispatch(Event{Type: EventClose, Miner: miner})
|
||||
if got := firstProxy.CurrentMiners(); got != 0 {
|
||||
t.Fatalf("expected first proxy miner count to return to 0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_AcceptConn_Good(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Mode: "nicehash",
|
||||
Bind: []BindAddr{{Host: "127.0.0.1", Port: 3333}},
|
||||
Pools: []PoolConfig{{URL: "pool-a:3333", Enabled: true}},
|
||||
Workers: WorkersDisabled,
|
||||
}
|
||||
proxyValue, errorValue := New(cfg)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
serverConn, clientConn := net.Pipe()
|
||||
proxyValue.acceptConn(serverConn, 3333)
|
||||
if got := proxyValue.stats.connections.Load(); got != 1 {
|
||||
t.Fatalf("expected connection counter to increment on accept, got %d", got)
|
||||
}
|
||||
|
||||
_ = clientConn.Close()
|
||||
_ = serverConn.Close()
|
||||
}
|
||||
181
runtime_support.go
Normal file
181
runtime_support.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewRateLimiter creates a per-IP limiter, for example:
|
||||
//
|
||||
// rl := proxy.NewRateLimiter(proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300})
|
||||
func NewRateLimiter(config RateLimit) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
config: config,
|
||||
buckets: make(map[string]*tokenBucket),
|
||||
banned: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfig swaps in a live reload value such as:
|
||||
//
|
||||
// rl.SetConfig(proxy.RateLimit{MaxConnectionsPerMinute: 30, BanDurationSeconds: 300})
|
||||
func (rateLimiter *RateLimiter) SetConfig(config RateLimit) {
|
||||
if rateLimiter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rateLimiter.mu.Lock()
|
||||
rateLimiter.config = config
|
||||
rateLimiter.mu.Unlock()
|
||||
}
|
||||
|
||||
// Allow returns true if the IP address is permitted to open a new connection. Thread-safe.
|
||||
//
|
||||
// if rl.Allow(conn.RemoteAddr().String()) { proceed() }
|
||||
func (rateLimiter *RateLimiter) Allow(ip string) bool {
|
||||
if rateLimiter == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
host := remoteHost(ip)
|
||||
now := time.Now().UTC()
|
||||
|
||||
rateLimiter.mu.Lock()
|
||||
defer rateLimiter.mu.Unlock()
|
||||
|
||||
if rateLimiter.config.MaxConnectionsPerMinute <= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if bannedUntil, exists := rateLimiter.banned[host]; exists {
|
||||
if bannedUntil.After(now) {
|
||||
return false
|
||||
}
|
||||
delete(rateLimiter.banned, host)
|
||||
}
|
||||
|
||||
bucket, exists := rateLimiter.buckets[host]
|
||||
if !exists {
|
||||
bucket = &tokenBucket{
|
||||
tokens: rateLimiter.config.MaxConnectionsPerMinute,
|
||||
lastRefill: now,
|
||||
}
|
||||
rateLimiter.buckets[host] = bucket
|
||||
}
|
||||
|
||||
rateLimiter.refillBucket(bucket, now)
|
||||
if bucket.tokens <= 0 {
|
||||
if rateLimiter.config.BanDurationSeconds > 0 {
|
||||
rateLimiter.banned[host] = now.Add(time.Duration(rateLimiter.config.BanDurationSeconds) * time.Second)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
bucket.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// Tick removes expired ban entries and refills all token buckets. Called every second.
|
||||
//
|
||||
// rl.Tick()
|
||||
func (rateLimiter *RateLimiter) Tick() {
|
||||
if rateLimiter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rateLimiter.mu.Lock()
|
||||
defer rateLimiter.mu.Unlock()
|
||||
|
||||
if rateLimiter.config.MaxConnectionsPerMinute <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for host, bannedUntil := range rateLimiter.banned {
|
||||
if !bannedUntil.After(now) {
|
||||
delete(rateLimiter.banned, host)
|
||||
}
|
||||
}
|
||||
|
||||
for _, bucket := range rateLimiter.buckets {
|
||||
rateLimiter.refillBucket(bucket, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (rateLimiter *RateLimiter) refillBucket(bucket *tokenBucket, now time.Time) {
|
||||
if bucket == nil || rateLimiter.config.MaxConnectionsPerMinute <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
refillEvery := time.Minute / time.Duration(rateLimiter.config.MaxConnectionsPerMinute)
|
||||
if refillEvery <= 0 {
|
||||
refillEvery = time.Second
|
||||
}
|
||||
|
||||
elapsed := now.Sub(bucket.lastRefill)
|
||||
if elapsed < refillEvery {
|
||||
return
|
||||
}
|
||||
|
||||
tokensToAdd := int(elapsed / refillEvery)
|
||||
bucket.tokens += tokensToAdd
|
||||
if bucket.tokens > rateLimiter.config.MaxConnectionsPerMinute {
|
||||
bucket.tokens = rateLimiter.config.MaxConnectionsPerMinute
|
||||
}
|
||||
bucket.lastRefill = bucket.lastRefill.Add(time.Duration(tokensToAdd) * refillEvery)
|
||||
}
|
||||
|
||||
// NewCustomDiff stores the default custom difficulty override.
|
||||
//
|
||||
// cd := proxy.NewCustomDiff(50000)
|
||||
func NewCustomDiff(globalDiff uint64) *CustomDiff {
|
||||
return &CustomDiff{globalDiff: globalDiff}
|
||||
}
|
||||
|
||||
// SetGlobalDiff updates the default custom difficulty override.
|
||||
//
|
||||
// cd.SetGlobalDiff(100000)
|
||||
func (customDiff *CustomDiff) SetGlobalDiff(globalDiff uint64) {
|
||||
if customDiff == nil {
|
||||
return
|
||||
}
|
||||
|
||||
customDiff.mu.Lock()
|
||||
customDiff.globalDiff = globalDiff
|
||||
customDiff.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnLogin parses `WALLET+50000` into `WALLET` and `50000`.
|
||||
//
|
||||
// cd.OnLogin(proxy.Event{Miner: miner})
|
||||
func (customDiff *CustomDiff) OnLogin(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
user := event.Miner.User()
|
||||
index := strings.LastIndex(user, "+")
|
||||
if index > 0 && index < len(user)-1 {
|
||||
if value, errorValue := strconv.ParseUint(user[index+1:], 10, 64); errorValue == nil {
|
||||
event.Miner.SetUser(user[:index])
|
||||
event.Miner.SetCustomDiff(value)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if customDiff == nil {
|
||||
event.Miner.SetCustomDiff(0)
|
||||
return
|
||||
}
|
||||
|
||||
customDiff.mu.RLock()
|
||||
globalDiff := customDiff.globalDiff
|
||||
customDiff.mu.RUnlock()
|
||||
if globalDiff > 0 {
|
||||
event.Miner.SetCustomDiff(globalDiff)
|
||||
return
|
||||
}
|
||||
|
||||
event.Miner.SetCustomDiff(0)
|
||||
}
|
||||
57
runtime_support_test.go
Normal file
57
runtime_support_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter_Allow_Good(t *testing.T) {
|
||||
limiter := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 2})
|
||||
if !limiter.Allow("127.0.0.1:1234") {
|
||||
t.Fatal("expected first connection to pass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Allow_Bad(t *testing.T) {
|
||||
limiter := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1, BanDurationSeconds: 60})
|
||||
if !limiter.Allow("127.0.0.1:1234") {
|
||||
t.Fatal("expected first connection to pass")
|
||||
}
|
||||
if limiter.Allow("127.0.0.1:1234") {
|
||||
t.Fatal("expected second connection to be blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_Allow_Ugly(t *testing.T) {
|
||||
limiter := NewRateLimiter(RateLimit{MaxConnectionsPerMinute: 1})
|
||||
limiter.Allow("127.0.0.1:1234")
|
||||
time.Sleep(time.Second)
|
||||
limiter.Tick()
|
||||
if limiter.Allow("127.0.0.1:1234") {
|
||||
t.Fatal("expected bucket not to refill fully after one second at 1/minute")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDiff_OnLogin_Good(t *testing.T) {
|
||||
miner := &Miner{user: "wallet+5000"}
|
||||
NewCustomDiff(100).OnLogin(Event{Miner: miner})
|
||||
if miner.User() != "wallet" || miner.CustomDiff() != 5000 {
|
||||
t.Fatalf("expected parsed custom diff, got user=%q diff=%d", miner.User(), miner.CustomDiff())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDiff_OnLogin_Bad(t *testing.T) {
|
||||
miner := &Miner{user: "wallet"}
|
||||
NewCustomDiff(100).OnLogin(Event{Miner: miner})
|
||||
if miner.CustomDiff() != 100 {
|
||||
t.Fatalf("expected fallback diff 100, got %d", miner.CustomDiff())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDiff_OnLogin_Ugly(t *testing.T) {
|
||||
miner := &Miner{user: "wallet+bad"}
|
||||
NewCustomDiff(100).OnLogin(Event{Miner: miner})
|
||||
if miner.User() != "wallet+bad" || miner.CustomDiff() != 100 {
|
||||
t.Fatalf("expected invalid suffix to preserve user and fall back to global diff, got user=%q diff=%d", miner.User(), miner.CustomDiff())
|
||||
}
|
||||
}
|
||||
84
server_runtime.go
Normal file
84
server_runtime.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// NewServer opens a listener and prepares the accept loop.
|
||||
//
|
||||
// srv, errorValue := proxy.NewServer(proxy.BindAddr{Host: "0.0.0.0", Port: 3333}, nil, rateLimiter, onAccept)
|
||||
func NewServer(bindAddress BindAddr, tlsConfig *tls.Config, rateLimiter *RateLimiter, onAccept func(net.Conn, uint16)) (*Server, error) {
|
||||
address := net.JoinHostPort(bindAddress.Host, strconv.Itoa(int(bindAddress.Port)))
|
||||
listener, errorValue := net.Listen("tcp", address)
|
||||
if errorValue != nil {
|
||||
return nil, errorValue
|
||||
}
|
||||
|
||||
return &Server{
|
||||
addr: bindAddress,
|
||||
tlsCfg: tlsConfig,
|
||||
limiter: rateLimiter,
|
||||
onAccept: onAccept,
|
||||
listener: listener,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start accepts miners in a background goroutine.
|
||||
//
|
||||
// srv.Start()
|
||||
func (server *Server) Start() {
|
||||
if server == nil || server.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, errorValue := server.listener.Accept()
|
||||
if errorValue != nil {
|
||||
select {
|
||||
case <-server.done:
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if server.limiter != nil && !server.limiter.Allow(conn.RemoteAddr().String()) {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
if server.tlsCfg != nil {
|
||||
conn = tls.Server(conn, server.tlsCfg)
|
||||
}
|
||||
|
||||
if server.onAccept == nil {
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
server.onAccept(conn, server.addr.Port)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop closes the listener without forcing existing sockets shut.
|
||||
//
|
||||
// srv.Stop()
|
||||
func (server *Server) Stop() {
|
||||
if server == nil || server.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-server.done:
|
||||
default:
|
||||
close(server.done)
|
||||
}
|
||||
_ = server.listener.Close()
|
||||
}
|
||||
|
||||
var errServerClosed = errors.New("server closed")
|
||||
|
|
@ -2,6 +2,7 @@ package nicehash
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
"dappco.re/go/core/proxy/pool"
|
||||
|
|
@ -16,10 +17,12 @@ type NonceMapper struct {
|
|||
id int64
|
||||
storage *NonceStorage
|
||||
strategy pool.Strategy // manages pool client lifecycle and failover
|
||||
pending map[int64]SubmitContext // sequence → {requestID, minerID}
|
||||
cfg *proxy.Config
|
||||
pending map[int64]SubmitContext // sequence → {requestID, minerID, jobID}
|
||||
config *proxy.Config
|
||||
events *proxy.EventBus
|
||||
active bool // true once pool has sent at least one job
|
||||
suspended int // > 0 when pool connection is in error/reconnecting
|
||||
idleAt time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
|
|
@ -29,4 +32,185 @@ type NonceMapper struct {
|
|||
type SubmitContext struct {
|
||||
RequestID int64 // JSON-RPC id from the miner's submit request
|
||||
MinerID int64 // miner that submitted
|
||||
Job proxy.Job
|
||||
JobID string
|
||||
Expired bool
|
||||
SubmittedAt time.Time
|
||||
}
|
||||
|
||||
// NewNonceMapper creates one upstream pool mapper and its local slot table.
|
||||
//
|
||||
// mapper := nicehash.NewNonceMapper(1, cfg, strategy)
|
||||
func NewNonceMapper(id int64, cfg *proxy.Config, strategy pool.Strategy) *NonceMapper {
|
||||
return &NonceMapper{
|
||||
id: id,
|
||||
storage: NewNonceStorage(),
|
||||
strategy: strategy,
|
||||
config: cfg,
|
||||
pending: make(map[int64]SubmitContext),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NonceMapper) Add(miner *proxy.Miner) bool {
|
||||
if !m.storage.Add(miner) {
|
||||
return false
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.idleAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *NonceMapper) Remove(miner *proxy.Miner) {
|
||||
m.storage.Remove(miner)
|
||||
|
||||
_, _, active := m.storage.SlotCount()
|
||||
if active == 0 {
|
||||
m.mu.Lock()
|
||||
if m.idleAt.IsZero() {
|
||||
m.idleAt = time.Now().UTC()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NonceMapper) Submit(event *proxy.SubmitEvent) {
|
||||
if event == nil || event.Miner == nil || m.strategy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
job, valid, expired := m.storage.JobForID(event.JobID)
|
||||
if !valid {
|
||||
event.Miner.ReplyWithError(event.RequestID, "Invalid job id")
|
||||
return
|
||||
}
|
||||
|
||||
sequence := m.strategy.Submit(event.JobID, event.Nonce, event.Result, event.Algo)
|
||||
if sequence == 0 {
|
||||
if event.Miner != nil {
|
||||
event.Miner.ReplyWithError(event.RequestID, "Pool unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.pending[sequence] = SubmitContext{
|
||||
RequestID: event.RequestID,
|
||||
MinerID: event.Miner.ID(),
|
||||
Job: job,
|
||||
JobID: event.JobID,
|
||||
Expired: expired,
|
||||
SubmittedAt: time.Now().UTC(),
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *NonceMapper) IsActive() bool {
|
||||
if m.strategy == nil {
|
||||
return false
|
||||
}
|
||||
return m.strategy.IsActive()
|
||||
}
|
||||
|
||||
func (m *NonceMapper) OnJob(job proxy.Job) {
|
||||
if !job.IsValid() {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.active = true
|
||||
m.suspended = 0
|
||||
m.idleAt = time.Time{}
|
||||
m.mu.Unlock()
|
||||
m.storage.SetJob(job)
|
||||
}
|
||||
|
||||
func (m *NonceMapper) OnResultAccepted(sequence int64, accepted bool, errorMessage string) {
|
||||
m.mu.Lock()
|
||||
context, exists := m.pending[sequence]
|
||||
if exists {
|
||||
delete(m.pending, sequence)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
miner := m.storage.Miners()[context.MinerID]
|
||||
if miner == nil {
|
||||
return
|
||||
}
|
||||
shareDifficulty := context.Job.DifficultyFromTarget()
|
||||
if shareDifficulty == 0 {
|
||||
shareDifficulty = miner.Diff()
|
||||
}
|
||||
|
||||
eventType := proxy.EventReject
|
||||
if accepted {
|
||||
eventType = proxy.EventAccept
|
||||
}
|
||||
|
||||
if m.events != nil {
|
||||
latency := uint16(0)
|
||||
if !context.SubmittedAt.IsZero() {
|
||||
elapsed := time.Since(context.SubmittedAt).Milliseconds()
|
||||
if elapsed > 0 {
|
||||
if elapsed > int64(^uint16(0)) {
|
||||
latency = ^uint16(0)
|
||||
} else {
|
||||
latency = uint16(elapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.events.Dispatch(proxy.Event{
|
||||
Type: eventType,
|
||||
Miner: miner,
|
||||
Job: jobPointer(context.Job),
|
||||
Diff: shareDifficulty,
|
||||
Error: errorMessage,
|
||||
Latency: latency,
|
||||
Expired: context.Expired,
|
||||
})
|
||||
}
|
||||
|
||||
if accepted {
|
||||
miner.Success(context.RequestID, "OK")
|
||||
return
|
||||
}
|
||||
miner.ReplyWithError(context.RequestID, errorMessage)
|
||||
}
|
||||
|
||||
func (m *NonceMapper) OnDisconnect() {
|
||||
m.clearPending()
|
||||
m.mu.Lock()
|
||||
m.active = false
|
||||
m.suspended++
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *NonceMapper) IdleDuration(now time.Time) time.Duration {
|
||||
m.mu.Lock()
|
||||
idleAt := m.idleAt
|
||||
m.mu.Unlock()
|
||||
|
||||
if idleAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
|
||||
return now.Sub(idleAt)
|
||||
}
|
||||
|
||||
func (m *NonceMapper) clearPending() {
|
||||
m.mu.Lock()
|
||||
m.pending = make(map[int64]SubmitContext)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func jobPointer(job proxy.Job) *proxy.Job {
|
||||
if !job.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobCopy := job
|
||||
return &jobCopy
|
||||
}
|
||||
|
|
|
|||
12
splitter/nicehash/register.go
Normal file
12
splitter/nicehash/register.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package nicehash
|
||||
|
||||
import (
|
||||
"dappco.re/go/core/proxy"
|
||||
"dappco.re/go/core/proxy/pool"
|
||||
)
|
||||
|
||||
func init() {
|
||||
proxy.RegisterSplitterFactory("nicehash", func(cfg *proxy.Config, events *proxy.EventBus) proxy.Splitter {
|
||||
return NewNonceSplitter(cfg, events, pool.NewStrategyFactory(cfg))
|
||||
})
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ package nicehash
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
"dappco.re/go/core/proxy/pool"
|
||||
|
|
@ -28,3 +29,188 @@ type NonceSplitter struct {
|
|||
strategyFactory pool.StrategyFactory
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewNonceSplitter creates the NiceHash splitter.
|
||||
//
|
||||
// s := nicehash.NewNonceSplitter(cfg, bus, factory)
|
||||
func NewNonceSplitter(cfg *proxy.Config, events *proxy.EventBus, factory pool.StrategyFactory) *NonceSplitter {
|
||||
return &NonceSplitter{
|
||||
cfg: cfg,
|
||||
events: events,
|
||||
strategyFactory: factory,
|
||||
mappers: make([]*NonceMapper, 0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) Connect() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(s.mappers) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
mapper := s.newMapperLocked()
|
||||
s.mappers = append(s.mappers, mapper)
|
||||
mapper.strategy.Connect()
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) OnLogin(event *proxy.LoginEvent) {
|
||||
if event == nil || event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, mapper := range s.mappers {
|
||||
if mapper.Add(event.Miner) {
|
||||
mapper.events = s.events
|
||||
event.Miner.SetMapperID(mapper.id)
|
||||
event.Miner.SetNiceHashEnabled(true)
|
||||
if currentJob := mapper.storage.CurrentJob(); currentJob != nil && currentJob.IsValid() {
|
||||
event.Miner.PrimeJob(*currentJob)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mapper := s.newMapperLocked()
|
||||
s.mappers = append(s.mappers, mapper)
|
||||
mapper.strategy.Connect()
|
||||
if mapper.Add(event.Miner) {
|
||||
mapper.events = s.events
|
||||
event.Miner.SetMapperID(mapper.id)
|
||||
event.Miner.SetNiceHashEnabled(true)
|
||||
if currentJob := mapper.storage.CurrentJob(); currentJob != nil && currentJob.IsValid() {
|
||||
event.Miner.PrimeJob(*currentJob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) OnSubmit(event *proxy.SubmitEvent) {
|
||||
if event == nil || event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, mapper := range s.mappers {
|
||||
if mapper.id == event.Miner.MapperID() {
|
||||
mapper.Submit(event)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) OnClose(event *proxy.CloseEvent) {
|
||||
if event == nil || event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, mapper := range s.mappers {
|
||||
if mapper.id == event.Miner.MapperID() {
|
||||
mapper.Remove(event.Miner)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) Tick(ticks uint64) {
|
||||
if ticks%60 == 0 {
|
||||
s.GC()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) GC() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
filtered := s.mappers[:0]
|
||||
for _, mapper := range s.mappers {
|
||||
_, _, active := mapper.storage.SlotCount()
|
||||
if active == 0 && mapper.IdleDuration(now) >= 60*time.Second {
|
||||
if mapper.strategy != nil {
|
||||
mapper.strategy.Disconnect()
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mapper)
|
||||
}
|
||||
s.mappers = filtered
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) Upstreams() proxy.UpstreamStats {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var stats proxy.UpstreamStats
|
||||
for _, mapper := range s.mappers {
|
||||
stats.Total++
|
||||
switch {
|
||||
case mapper.suspended > 0:
|
||||
stats.Error++
|
||||
case mapper.IsActive():
|
||||
stats.Active++
|
||||
default:
|
||||
stats.Sleep++
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) PendingCount() int {
|
||||
s.mu.RLock()
|
||||
mappers := append([]*NonceMapper(nil), s.mappers...)
|
||||
s.mu.RUnlock()
|
||||
|
||||
pending := 0
|
||||
for _, mapper := range mappers {
|
||||
if mapper == nil {
|
||||
continue
|
||||
}
|
||||
mapper.mu.Lock()
|
||||
pending += len(mapper.pending)
|
||||
mapper.mu.Unlock()
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) Disconnect() {
|
||||
s.mu.Lock()
|
||||
mappers := s.mappers
|
||||
s.mappers = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, mapper := range mappers {
|
||||
if mapper == nil {
|
||||
continue
|
||||
}
|
||||
mapper.mu.Lock()
|
||||
strategy := mapper.strategy
|
||||
mapper.strategy = nil
|
||||
mapper.active = false
|
||||
mapper.suspended = 0
|
||||
mapper.mu.Unlock()
|
||||
if strategy != nil {
|
||||
strategy.Disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NonceSplitter) newMapperLocked() *NonceMapper {
|
||||
mapperID := int64(len(s.mappers) + 1)
|
||||
mapper := NewNonceMapper(mapperID, s.cfg, nil)
|
||||
mapper.events = s.events
|
||||
var strategy pool.Strategy
|
||||
if s.strategyFactory != nil {
|
||||
strategy = s.strategyFactory(mapper)
|
||||
}
|
||||
mapper.strategy = strategy
|
||||
return mapper
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,5 +21,184 @@ type NonceStorage struct {
|
|||
job proxy.Job // current job from pool
|
||||
prevJob proxy.Job // previous job (for stale submit validation)
|
||||
cursor int // search starts here (round-robin allocation)
|
||||
expired uint64 // stale job ID hits for the previous job
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewNonceStorage allocates the fixed-size miner slot table.
|
||||
//
|
||||
// storage := nicehash.NewNonceStorage()
|
||||
func NewNonceStorage() *NonceStorage {
|
||||
return &NonceStorage{
|
||||
miners: make(map[int64]*proxy.Miner),
|
||||
}
|
||||
}
|
||||
|
||||
// Add finds the next free slot starting from cursor (wrapping), sets slot[index] = minerID,
|
||||
// and sets the miner fixed byte.
|
||||
//
|
||||
// ok := storage.Add(miner)
|
||||
func (s *NonceStorage) Add(miner *proxy.Miner) bool {
|
||||
if miner == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for offset := 0; offset < len(s.slots); offset++ {
|
||||
index := (s.cursor + offset) % len(s.slots)
|
||||
if s.slots[index] != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
s.slots[index] = miner.ID()
|
||||
s.miners[miner.ID()] = miner
|
||||
miner.SetFixedByte(uint8(index))
|
||||
s.cursor = (index + 1) % len(s.slots)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove marks slot[miner.FixedByte] as a dead slot until the next SetJob call.
|
||||
//
|
||||
// storage.Remove(miner)
|
||||
func (s *NonceStorage) Remove(miner *proxy.Miner) {
|
||||
if miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
index := int(miner.FixedByte())
|
||||
if index >= 0 && index < len(s.slots) && s.slots[index] == miner.ID() {
|
||||
s.slots[index] = -miner.ID()
|
||||
}
|
||||
delete(s.miners, miner.ID())
|
||||
}
|
||||
|
||||
// SetJob replaces the current job, clears dead slots, and fans the job out to active miners.
|
||||
//
|
||||
// storage.SetJob(job)
|
||||
func (s *NonceStorage) SetJob(job proxy.Job) {
|
||||
s.mu.Lock()
|
||||
if s.job.IsValid() && s.job.ClientID != "" && s.job.ClientID == job.ClientID {
|
||||
s.prevJob = s.job
|
||||
} else {
|
||||
s.prevJob = proxy.Job{}
|
||||
}
|
||||
s.job = job
|
||||
|
||||
miners := make([]*proxy.Miner, 0, len(s.miners))
|
||||
for index, minerID := range s.slots {
|
||||
if minerID < 0 {
|
||||
s.slots[index] = 0
|
||||
continue
|
||||
}
|
||||
if minerID > 0 {
|
||||
if miner := s.miners[minerID]; miner != nil {
|
||||
miners = append(miners, miner)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, miner := range miners {
|
||||
miner.ForwardJob(job, job.Algo)
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidJobID returns true if id matches the current or previous job ID.
|
||||
//
|
||||
// if !storage.IsValidJobID(submitJobID) { reject }
|
||||
func (s *NonceStorage) IsValidJobID(id string) bool {
|
||||
valid, _ := s.JobStatus(id)
|
||||
return valid
|
||||
}
|
||||
|
||||
// JobForID returns a copy of the current or previous job for the given ID.
|
||||
//
|
||||
// job, valid, expired := storage.JobForID(submitJobID)
|
||||
func (s *NonceStorage) JobForID(id string) (job proxy.Job, valid bool, expired bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return proxy.Job{}, false, false
|
||||
}
|
||||
if id == s.job.JobID {
|
||||
return s.job, true, false
|
||||
}
|
||||
if s.prevJob.IsValid() && s.prevJob.ClientID != "" && id == s.prevJob.JobID {
|
||||
s.expired++
|
||||
return s.prevJob, true, true
|
||||
}
|
||||
return proxy.Job{}, false, false
|
||||
}
|
||||
|
||||
// JobStatus returns whether the job ID is current or stale-but-still-acceptable.
|
||||
//
|
||||
// valid, expired := storage.JobStatus(submitJobID)
|
||||
func (s *NonceStorage) JobStatus(id string) (valid bool, expired bool) {
|
||||
_, valid, expired = s.JobForID(id)
|
||||
return valid, expired
|
||||
}
|
||||
|
||||
// SlotCount returns free, dead, and active slot counts for monitoring output.
|
||||
//
|
||||
// free, dead, active := storage.SlotCount()
|
||||
func (s *NonceStorage) SlotCount() (free int, dead int, active int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, slot := range s.slots {
|
||||
switch {
|
||||
case slot == 0:
|
||||
free++
|
||||
case slot < 0:
|
||||
dead++
|
||||
default:
|
||||
active++
|
||||
}
|
||||
}
|
||||
|
||||
return free, dead, active
|
||||
}
|
||||
|
||||
// ExpiredCount returns the number of times the previous job ID has been accepted as stale.
|
||||
//
|
||||
// count := storage.ExpiredCount()
|
||||
func (s *NonceStorage) ExpiredCount() uint64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.expired
|
||||
}
|
||||
|
||||
// Miners returns a snapshot of the active miner map.
|
||||
func (s *NonceStorage) Miners() map[int64]*proxy.Miner {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
miners := make(map[int64]*proxy.Miner, len(s.miners))
|
||||
for minerID, miner := range s.miners {
|
||||
miners[minerID] = miner
|
||||
}
|
||||
return miners
|
||||
}
|
||||
|
||||
// CurrentJob returns a copy of the latest assigned job, if any.
|
||||
func (s *NonceStorage) CurrentJob() *proxy.Job {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.job.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobCopy := s.job
|
||||
return &jobCopy
|
||||
}
|
||||
|
|
|
|||
120
splitter/nicehash/storage_test.go
Normal file
120
splitter/nicehash/storage_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package nicehash
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
)
|
||||
|
||||
func TestNonceStorage_Add_Good(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
miner := proxy.NewMiner(nil, 0, nil)
|
||||
miner.SetUser("wallet")
|
||||
if !storage.Add(miner) {
|
||||
t.Fatal("expected slot allocation to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceStorage_Add_Bad(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
if storage.Add(nil) {
|
||||
t.Fatal("expected nil miner allocation to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceStorage_Add_Ugly(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
for index := 0; index < 256; index++ {
|
||||
miner := proxy.NewMiner(nil, 0, nil)
|
||||
if !storage.Add(miner) {
|
||||
t.Fatalf("expected miner %d to fit", index)
|
||||
}
|
||||
}
|
||||
if storage.Add(proxy.NewMiner(nil, 0, nil)) {
|
||||
t.Fatal("expected 257th miner to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceStorage_IsValidJobID_Good(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
storage.SetJob(proxy.Job{Blob: "abcd", JobID: "job-1"})
|
||||
if !storage.IsValidJobID("job-1") {
|
||||
t.Fatal("expected current job ID to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceStorage_IsValidJobID_Bad(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
storage.SetJob(proxy.Job{Blob: "abcd", JobID: "job-1"})
|
||||
if storage.IsValidJobID("job-2") {
|
||||
t.Fatal("expected unknown job ID to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceStorage_IsValidJobID_Ugly(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
storage.SetJob(proxy.Job{Blob: "abcd", JobID: "job-1", ClientID: "pool-a"})
|
||||
storage.SetJob(proxy.Job{Blob: "efgh", JobID: "job-2", ClientID: "pool-a"})
|
||||
if !storage.IsValidJobID("job-1") {
|
||||
t.Fatal("expected previous job ID from same client to remain valid")
|
||||
}
|
||||
if got := storage.ExpiredCount(); got != 1 {
|
||||
t.Fatalf("expected stale job lookups to increment the expired counter, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceStorage_IsValidJobID_BadClientID(t *testing.T) {
|
||||
storage := NewNonceStorage()
|
||||
storage.SetJob(proxy.Job{Blob: "abcd", JobID: "job-1", ClientID: "pool-a"})
|
||||
storage.SetJob(proxy.Job{Blob: "efgh", JobID: "job-2", ClientID: "pool-b"})
|
||||
if storage.IsValidJobID("job-1") {
|
||||
t.Fatal("expected previous job ID from a different client to be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceMapper_OnDisconnect_Ugly(t *testing.T) {
|
||||
mapper := NewNonceMapper(1, &proxy.Config{}, nil)
|
||||
mapper.pending[1] = SubmitContext{RequestID: 7}
|
||||
mapper.OnDisconnect()
|
||||
if len(mapper.pending) != 0 {
|
||||
t.Fatalf("expected pending submits to be cleared, got %d", len(mapper.pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceMapper_OnResultAccepted_Good(t *testing.T) {
|
||||
bus := proxy.NewEventBus()
|
||||
resultCh := make(chan proxy.Event, 1)
|
||||
bus.Subscribe(proxy.EventAccept, func(event proxy.Event) {
|
||||
resultCh <- event
|
||||
})
|
||||
|
||||
miner := proxy.NewMiner(nil, 0, nil)
|
||||
mapper := NewNonceMapper(1, &proxy.Config{}, nil)
|
||||
mapper.events = bus
|
||||
if !mapper.storage.Add(miner) {
|
||||
t.Fatal("expected miner slot allocation")
|
||||
}
|
||||
mapper.storage.SetJob(proxy.Job{Blob: strings.Repeat("0", 160), JobID: "job-a", Target: "b88d0600"})
|
||||
mapper.mu.Lock()
|
||||
mapper.pending[1] = SubmitContext{
|
||||
RequestID: 7,
|
||||
MinerID: miner.ID(),
|
||||
Job: proxy.Job{Blob: strings.Repeat("0", 160), JobID: "job-a", Target: "b88d0600"},
|
||||
SubmittedAt: time.Now().UTC(),
|
||||
}
|
||||
mapper.mu.Unlock()
|
||||
mapper.storage.SetJob(proxy.Job{Blob: strings.Repeat("1", 160), JobID: "job-b", Target: "b88d0600"})
|
||||
|
||||
mapper.OnResultAccepted(1, true, "")
|
||||
|
||||
select {
|
||||
case event := <-resultCh:
|
||||
if event.Job == nil || event.Job.JobID != "job-a" {
|
||||
t.Fatalf("expected submitted job to be reported, got %#v", event.Job)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected accept event")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package simple
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
|
|
@ -16,6 +17,135 @@ type SimpleMapper struct {
|
|||
id int64
|
||||
miner *proxy.Miner // nil when idle
|
||||
strategy pool.Strategy
|
||||
events *proxy.EventBus
|
||||
pending map[int64]simpleSubmitContext
|
||||
job proxy.Job
|
||||
prevJob proxy.Job
|
||||
idleAt time.Time // zero when active
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type simpleSubmitContext struct {
|
||||
RequestID int64
|
||||
Job proxy.Job
|
||||
Expired bool
|
||||
SubmittedAt time.Time
|
||||
}
|
||||
|
||||
// NewSimpleMapper stores the mapper ID and strategy.
|
||||
//
|
||||
// mapper := simple.NewSimpleMapper(1, strategy)
|
||||
func NewSimpleMapper(id int64, strategy pool.Strategy) *SimpleMapper {
|
||||
return &SimpleMapper{id: id, strategy: strategy, pending: make(map[int64]simpleSubmitContext)}
|
||||
}
|
||||
|
||||
func (m *SimpleMapper) OnJob(job proxy.Job) {
|
||||
if !job.IsValid() {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.job.IsValid() && m.job.ClientID != "" && m.job.ClientID == job.ClientID {
|
||||
m.prevJob = m.job
|
||||
} else {
|
||||
m.prevJob = proxy.Job{}
|
||||
}
|
||||
m.job = job
|
||||
miner := m.miner
|
||||
m.mu.Unlock()
|
||||
|
||||
if miner != nil {
|
||||
miner.ForwardJob(job, job.Algo)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SimpleMapper) JobStatus(id string) (valid bool, expired bool) {
|
||||
_, valid, expired = m.JobForID(id)
|
||||
return valid, expired
|
||||
}
|
||||
|
||||
func (m *SimpleMapper) JobForID(id string) (proxy.Job, bool, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return proxy.Job{}, false, false
|
||||
}
|
||||
if id == m.job.JobID {
|
||||
return m.job, true, false
|
||||
}
|
||||
if m.prevJob.IsValid() && m.prevJob.ClientID != "" && id == m.prevJob.JobID {
|
||||
return m.prevJob, true, true
|
||||
}
|
||||
return proxy.Job{}, false, false
|
||||
}
|
||||
|
||||
func (m *SimpleMapper) OnResultAccepted(sequence int64, accepted bool, errorMessage string) {
|
||||
m.mu.Lock()
|
||||
context, exists := m.pending[sequence]
|
||||
miner := m.miner
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(m.pending, sequence)
|
||||
m.mu.Unlock()
|
||||
|
||||
if miner == nil {
|
||||
return
|
||||
}
|
||||
shareDifficulty := context.Job.DifficultyFromTarget()
|
||||
if shareDifficulty == 0 {
|
||||
shareDifficulty = miner.Diff()
|
||||
}
|
||||
|
||||
if accepted {
|
||||
latency := shareLatency(context.SubmittedAt)
|
||||
if m.events != nil {
|
||||
m.events.Dispatch(proxy.Event{Type: proxy.EventAccept, Miner: miner, Job: jobPointer(context.Job), Diff: shareDifficulty, Latency: latency, Expired: context.Expired})
|
||||
}
|
||||
miner.Success(context.RequestID, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
latency := shareLatency(context.SubmittedAt)
|
||||
if m.events != nil {
|
||||
m.events.Dispatch(proxy.Event{Type: proxy.EventReject, Miner: miner, Job: jobPointer(context.Job), Diff: shareDifficulty, Error: errorMessage, Latency: latency, Expired: context.Expired})
|
||||
}
|
||||
miner.ReplyWithError(context.RequestID, errorMessage)
|
||||
}
|
||||
|
||||
func (m *SimpleMapper) OnDisconnect() {
|
||||
m.clearPending()
|
||||
m.stopped = true
|
||||
}
|
||||
|
||||
func (m *SimpleMapper) clearPending() {
|
||||
m.mu.Lock()
|
||||
m.pending = make(map[int64]simpleSubmitContext)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func jobPointer(job proxy.Job) *proxy.Job {
|
||||
if !job.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobCopy := job
|
||||
return &jobCopy
|
||||
}
|
||||
|
||||
func shareLatency(submittedAt time.Time) uint16 {
|
||||
if submittedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(submittedAt).Milliseconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
if elapsed > int64(^uint16(0)) {
|
||||
return ^uint16(0)
|
||||
}
|
||||
return uint16(elapsed)
|
||||
}
|
||||
|
|
|
|||
12
splitter/simple/register.go
Normal file
12
splitter/simple/register.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package simple
|
||||
|
||||
import (
|
||||
"dappco.re/go/core/proxy"
|
||||
"dappco.re/go/core/proxy/pool"
|
||||
)
|
||||
|
||||
func init() {
|
||||
proxy.RegisterSplitterFactory("simple", func(cfg *proxy.Config, events *proxy.EventBus) proxy.Splitter {
|
||||
return NewSimpleSplitter(cfg, events, pool.NewStrategyFactory(cfg))
|
||||
})
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ package simple
|
|||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
"dappco.re/go/core/proxy/pool"
|
||||
|
|
@ -20,9 +21,255 @@ import (
|
|||
type SimpleSplitter struct {
|
||||
active map[int64]*SimpleMapper // minerID → mapper
|
||||
idle map[int64]*SimpleMapper // mapperID → mapper (reuse pool, keyed by mapper seq)
|
||||
cfg *proxy.Config
|
||||
config *proxy.Config
|
||||
events *proxy.EventBus
|
||||
factory pool.StrategyFactory
|
||||
strategyFactory pool.StrategyFactory
|
||||
mu sync.Mutex
|
||||
seq int64 // monotonic mapper sequence counter
|
||||
mapperSequence int64 // monotonic mapper sequence counter
|
||||
}
|
||||
|
||||
// NewSimpleSplitter creates the passthrough splitter.
|
||||
//
|
||||
// s := simple.NewSimpleSplitter(cfg, bus, factory)
|
||||
func NewSimpleSplitter(cfg *proxy.Config, events *proxy.EventBus, factory pool.StrategyFactory) *SimpleSplitter {
|
||||
return &SimpleSplitter{
|
||||
active: make(map[int64]*SimpleMapper),
|
||||
idle: make(map[int64]*SimpleMapper),
|
||||
config: cfg,
|
||||
events: events,
|
||||
strategyFactory: factory,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) Connect() {}
|
||||
|
||||
func (s *SimpleSplitter) OnLogin(event *proxy.LoginEvent) {
|
||||
if event == nil || event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
timeout := time.Duration(0)
|
||||
if s.config != nil && s.config.ReuseTimeout > 0 {
|
||||
timeout = time.Duration(s.config.ReuseTimeout) * time.Second
|
||||
}
|
||||
|
||||
var mapper *SimpleMapper
|
||||
now := time.Now().UTC()
|
||||
for mapperID, idleMapper := range s.idle {
|
||||
if idleMapper == nil || idleMapper.stopped || idleMapper.strategy == nil || !idleMapper.strategy.IsActive() || (timeout > 0 && !idleMapper.idleAt.IsZero() && now.Sub(idleMapper.idleAt) > timeout) {
|
||||
if idleMapper != nil && idleMapper.strategy != nil {
|
||||
idleMapper.strategy.Disconnect()
|
||||
}
|
||||
delete(s.idle, mapperID)
|
||||
continue
|
||||
}
|
||||
mapper = idleMapper
|
||||
delete(s.idle, mapperID)
|
||||
break
|
||||
}
|
||||
|
||||
if mapper == nil {
|
||||
s.mapperSequence++
|
||||
var strategy pool.Strategy
|
||||
mapper = NewSimpleMapper(s.mapperSequence, nil)
|
||||
mapper.events = s.events
|
||||
if s.strategyFactory != nil {
|
||||
strategy = s.strategyFactory(mapper)
|
||||
}
|
||||
mapper.strategy = strategy
|
||||
if mapper.strategy != nil {
|
||||
mapper.strategy.Connect()
|
||||
}
|
||||
} else {
|
||||
mapper.events = s.events
|
||||
mapper.clearPending()
|
||||
}
|
||||
|
||||
mapper.miner = event.Miner
|
||||
mapper.idleAt = time.Time{}
|
||||
event.Miner.SetRouteID(mapper.id)
|
||||
s.active[event.Miner.ID()] = mapper
|
||||
|
||||
mapper.mu.Lock()
|
||||
currentJob := mapper.job
|
||||
mapper.mu.Unlock()
|
||||
if currentJob.IsValid() {
|
||||
event.Miner.PrimeJob(currentJob)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) OnSubmit(event *proxy.SubmitEvent) {
|
||||
if event == nil || event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
mapper := s.active[event.Miner.ID()]
|
||||
s.mu.Unlock()
|
||||
if mapper == nil || mapper.strategy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
job, valid, expired := mapper.JobForID(event.JobID)
|
||||
if !valid {
|
||||
event.Miner.ReplyWithError(event.RequestID, "Invalid job id")
|
||||
return
|
||||
}
|
||||
|
||||
sequence := mapper.strategy.Submit(event.JobID, event.Nonce, event.Result, event.Algo)
|
||||
if sequence == 0 {
|
||||
event.Miner.ReplyWithError(event.RequestID, "Pool unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
mapper.mu.Lock()
|
||||
mapper.pending[sequence] = simpleSubmitContext{
|
||||
RequestID: event.RequestID,
|
||||
Job: job,
|
||||
Expired: expired,
|
||||
SubmittedAt: time.Now().UTC(),
|
||||
}
|
||||
mapper.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) OnClose(event *proxy.CloseEvent) {
|
||||
if event == nil || event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
mapper := s.active[event.Miner.ID()]
|
||||
if mapper == nil {
|
||||
return
|
||||
}
|
||||
delete(s.active, event.Miner.ID())
|
||||
|
||||
mapper.clearPending()
|
||||
mapper.miner = nil
|
||||
mapper.idleAt = time.Now().UTC()
|
||||
if s.config != nil && s.config.ReuseTimeout > 0 {
|
||||
s.idle[mapper.id] = mapper
|
||||
return
|
||||
}
|
||||
|
||||
mapper.stopped = true
|
||||
if mapper.strategy != nil {
|
||||
mapper.strategy.Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) Tick(ticks uint64) {
|
||||
if ticks%60 == 0 {
|
||||
s.GC()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) GC() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
timeout := time.Duration(0)
|
||||
if s.config != nil && s.config.ReuseTimeout > 0 {
|
||||
timeout = time.Duration(s.config.ReuseTimeout) * time.Second
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for mapperID, mapper := range s.idle {
|
||||
if mapper == nil {
|
||||
delete(s.idle, mapperID)
|
||||
continue
|
||||
}
|
||||
if mapper.stopped || mapper.strategy == nil || !mapper.strategy.IsActive() || timeout == 0 || (!mapper.idleAt.IsZero() && now.Sub(mapper.idleAt) > timeout) {
|
||||
if mapper.strategy != nil {
|
||||
mapper.strategy.Disconnect()
|
||||
}
|
||||
delete(s.idle, mapperID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) Upstreams() proxy.UpstreamStats {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
stats := proxy.UpstreamStats{
|
||||
Sleep: uint64(len(s.idle)),
|
||||
}
|
||||
for _, mapper := range s.active {
|
||||
stats.Total++
|
||||
if mapper.strategy != nil && mapper.strategy.IsActive() {
|
||||
stats.Active++
|
||||
} else {
|
||||
stats.Error++
|
||||
}
|
||||
}
|
||||
stats.Total += uint64(len(s.idle))
|
||||
return stats
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) PendingCount() int {
|
||||
s.mu.Lock()
|
||||
mapperList := make([]*SimpleMapper, 0, len(s.active)+len(s.idle))
|
||||
for _, mapper := range s.active {
|
||||
mapperList = append(mapperList, mapper)
|
||||
}
|
||||
for _, mapper := range s.idle {
|
||||
mapperList = append(mapperList, mapper)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
pending := 0
|
||||
for _, mapper := range mapperList {
|
||||
if mapper == nil {
|
||||
continue
|
||||
}
|
||||
mapper.mu.Lock()
|
||||
pending += len(mapper.pending)
|
||||
mapper.mu.Unlock()
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
func (s *SimpleSplitter) Disconnect() {
|
||||
s.mu.Lock()
|
||||
active := s.active
|
||||
idle := s.idle
|
||||
s.active = make(map[int64]*SimpleMapper)
|
||||
s.idle = make(map[int64]*SimpleMapper)
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, mapper := range active {
|
||||
if mapper == nil {
|
||||
continue
|
||||
}
|
||||
mapper.mu.Lock()
|
||||
mapper.stopped = true
|
||||
strategy := mapper.strategy
|
||||
mapper.strategy = nil
|
||||
mapper.miner = nil
|
||||
mapper.mu.Unlock()
|
||||
if strategy != nil {
|
||||
strategy.Disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
for _, mapper := range idle {
|
||||
if mapper == nil {
|
||||
continue
|
||||
}
|
||||
mapper.mu.Lock()
|
||||
mapper.stopped = true
|
||||
strategy := mapper.strategy
|
||||
mapper.strategy = nil
|
||||
mapper.miner = nil
|
||||
mapper.mu.Unlock()
|
||||
if strategy != nil {
|
||||
strategy.Disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
splitter/simple/splitter_test.go
Normal file
196
splitter/simple/splitter_test.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package simple
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/proxy"
|
||||
"dappco.re/go/core/proxy/pool"
|
||||
)
|
||||
|
||||
type fakeStrategy struct {
|
||||
active bool
|
||||
connects int
|
||||
disconnects int
|
||||
}
|
||||
|
||||
func (s *fakeStrategy) Connect() {}
|
||||
|
||||
func (s *fakeStrategy) Submit(jobID, nonce, result, algo string) int64 { return 1 }
|
||||
|
||||
func (s *fakeStrategy) Disconnect() {
|
||||
s.disconnects++
|
||||
s.active = false
|
||||
}
|
||||
|
||||
func (s *fakeStrategy) IsActive() bool { return s.active }
|
||||
|
||||
func TestSimpleSplitter_OnLogin_Ugly(t *testing.T) {
|
||||
deadStrategy := &fakeStrategy{active: false}
|
||||
liveStrategy := &fakeStrategy{active: true}
|
||||
splitter := &SimpleSplitter{
|
||||
active: make(map[int64]*SimpleMapper),
|
||||
idle: map[int64]*SimpleMapper{
|
||||
1: {
|
||||
id: 1,
|
||||
strategy: deadStrategy,
|
||||
idleAt: time.Now().UTC(),
|
||||
},
|
||||
},
|
||||
config: &proxy.Config{ReuseTimeout: 60},
|
||||
strategyFactory: func(listener pool.StratumListener) pool.Strategy {
|
||||
return liveStrategy
|
||||
},
|
||||
}
|
||||
|
||||
miner := &proxy.Miner{}
|
||||
splitter.OnLogin(&proxy.LoginEvent{Miner: miner})
|
||||
|
||||
if len(splitter.idle) != 0 {
|
||||
t.Fatalf("expected dead idle mapper to be discarded, got %d idle mappers", len(splitter.idle))
|
||||
}
|
||||
if len(splitter.active) != 1 {
|
||||
t.Fatalf("expected one active mapper, got %d", len(splitter.active))
|
||||
}
|
||||
if deadStrategy.disconnects != 1 {
|
||||
t.Fatalf("expected dead mapper to be disconnected once, got %d", deadStrategy.disconnects)
|
||||
}
|
||||
if miner.RouteID() == 0 {
|
||||
t.Fatal("expected miner to receive a route ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleSplitter_OnLogin_Bad(t *testing.T) {
|
||||
activeStrategy := &fakeStrategy{active: true}
|
||||
splitter := &SimpleSplitter{
|
||||
active: make(map[int64]*SimpleMapper),
|
||||
idle: map[int64]*SimpleMapper{
|
||||
1: {
|
||||
id: 1,
|
||||
strategy: activeStrategy,
|
||||
idleAt: time.Now().UTC().Add(-2 * time.Minute),
|
||||
},
|
||||
},
|
||||
config: &proxy.Config{ReuseTimeout: 60},
|
||||
strategyFactory: func(listener pool.StratumListener) pool.Strategy {
|
||||
return activeStrategy
|
||||
},
|
||||
}
|
||||
|
||||
miner := &proxy.Miner{}
|
||||
splitter.OnLogin(&proxy.LoginEvent{Miner: miner})
|
||||
|
||||
if len(splitter.idle) != 0 {
|
||||
t.Fatalf("expected stale idle mapper to be discarded, got %d idle mappers", len(splitter.idle))
|
||||
}
|
||||
if len(splitter.active) != 1 {
|
||||
t.Fatalf("expected one active mapper, got %d active mappers", len(splitter.active))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleSplitter_OnClose_Ugly(t *testing.T) {
|
||||
activeStrategy := &fakeStrategy{active: true}
|
||||
splitter := &SimpleSplitter{
|
||||
active: make(map[int64]*SimpleMapper),
|
||||
idle: make(map[int64]*SimpleMapper),
|
||||
config: &proxy.Config{ReuseTimeout: 60},
|
||||
strategyFactory: func(listener pool.StratumListener) pool.Strategy {
|
||||
return activeStrategy
|
||||
},
|
||||
}
|
||||
|
||||
miner := &proxy.Miner{}
|
||||
splitter.OnLogin(&proxy.LoginEvent{Miner: miner})
|
||||
|
||||
mapper := splitter.active[miner.ID()]
|
||||
if mapper == nil {
|
||||
t.Fatal("expected active mapper")
|
||||
}
|
||||
mapper.pending[1] = simpleSubmitContext{RequestID: 42}
|
||||
|
||||
splitter.OnClose(&proxy.CloseEvent{Miner: miner})
|
||||
|
||||
if len(mapper.pending) != 0 {
|
||||
t.Fatalf("expected pending submits to be cleared, got %d", len(mapper.pending))
|
||||
}
|
||||
if _, exists := splitter.idle[mapper.id]; !exists {
|
||||
t.Fatal("expected mapper to move to idle pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleMapper_OnResultAccepted_Good(t *testing.T) {
|
||||
bus := proxy.NewEventBus()
|
||||
resultCh := make(chan proxy.Event, 1)
|
||||
bus.Subscribe(proxy.EventAccept, func(event proxy.Event) {
|
||||
resultCh <- event
|
||||
})
|
||||
|
||||
mapper := &SimpleMapper{
|
||||
miner: &proxy.Miner{},
|
||||
events: bus,
|
||||
pending: make(map[int64]simpleSubmitContext),
|
||||
job: proxy.Job{Blob: strings.Repeat("0", 160), JobID: "job-b", Target: "b88d0600"},
|
||||
prevJob: proxy.Job{Blob: strings.Repeat("1", 160), JobID: "job-a", Target: "b88d0600"},
|
||||
}
|
||||
mapper.pending[1] = simpleSubmitContext{
|
||||
RequestID: 7,
|
||||
Job: proxy.Job{Blob: strings.Repeat("1", 160), JobID: "job-a", Target: "b88d0600"},
|
||||
SubmittedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
mapper.OnResultAccepted(1, true, "")
|
||||
|
||||
select {
|
||||
case event := <-resultCh:
|
||||
if event.Job == nil || event.Job.JobID != "job-a" {
|
||||
t.Fatalf("expected submitted job to be reported, got %#v", event.Job)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected accept event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleMapper_JobForID_BadClientID(t *testing.T) {
|
||||
mapper := &SimpleMapper{
|
||||
pending: make(map[int64]simpleSubmitContext),
|
||||
}
|
||||
mapper.OnJob(proxy.Job{Blob: strings.Repeat("1", 160), JobID: "job-a", ClientID: "pool-a"})
|
||||
mapper.OnJob(proxy.Job{Blob: strings.Repeat("0", 160), JobID: "job-b", ClientID: "pool-b"})
|
||||
|
||||
if valid, expired := mapper.JobStatus("job-a"); valid || expired {
|
||||
t.Fatalf("expected stale job from a different client to be invalid, got valid=%t expired=%t", valid, expired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWatcher_Start_Ugly(t *testing.T) {
|
||||
path := t.TempDir() + "/config.json"
|
||||
errorValue := os.WriteFile(path, []byte(`{"mode":"simple","workers":"rig-id","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool-a:3333","enabled":true}]}`), 0o644)
|
||||
if errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
watcherTriggered := make(chan struct{}, 1)
|
||||
watcher := proxy.NewConfigWatcher(path, func(cfg *proxy.Config) {
|
||||
watcherTriggered <- struct{}{}
|
||||
})
|
||||
watcher.Start()
|
||||
defer watcher.Stop()
|
||||
|
||||
select {
|
||||
case <-watcherTriggered:
|
||||
t.Fatal("expected watcher to stay quiet until the file changes")
|
||||
case <-time.After(1200 * time.Millisecond):
|
||||
}
|
||||
|
||||
if errorValue = os.WriteFile(path, []byte(`{"mode":"simple","workers":"rig-id","bind":[{"host":"127.0.0.1","port":3333}],"pools":[{"url":"pool-b:3333","enabled":true}]}`), 0o644); errorValue != nil {
|
||||
t.Fatal(errorValue)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-watcherTriggered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected watcher to observe the modification")
|
||||
}
|
||||
}
|
||||
162
stats.go
162
stats.go
|
|
@ -1,6 +1,8 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
|
@ -20,8 +22,8 @@ type Stats struct {
|
|||
hashes atomic.Uint64 // cumulative sum of accepted share difficulties
|
||||
connections atomic.Uint64 // total TCP connections accepted (ever)
|
||||
maxMiners atomic.Uint64 // peak concurrent miner count
|
||||
topDiff [10]uint64 // top-10 accepted difficulties, sorted descending; guarded by mu
|
||||
latency []uint16 // pool response latencies in ms; capped at 10000 samples; guarded by mu
|
||||
topDifficulties [10]uint64 // top-10 accepted difficulties, sorted descending; guarded by mu
|
||||
latencySamples []uint16 // pool response latencies in ms; capped at 10000 samples; guarded by mu
|
||||
windows [6]tickWindow // one per hashrate reporting period
|
||||
startTime time.Time
|
||||
mu sync.Mutex
|
||||
|
|
@ -58,3 +60,159 @@ type StatsSummary struct {
|
|||
Hashrate [6]float64 `json:"hashrate"` // H/s per window (index = HashrateWindow* constants)
|
||||
TopDiff [10]uint64 `json:"best"`
|
||||
}
|
||||
|
||||
var hashrateWindowSizes = [5]int{60, 600, 3600, 43200, 86400}
|
||||
|
||||
// NewStats allocates the rolling windows and initialises the clock anchor.
|
||||
//
|
||||
// s := proxy.NewStats()
|
||||
func NewStats() *Stats {
|
||||
stats := &Stats{
|
||||
startTime: time.Now().UTC(),
|
||||
latencySamples: make([]uint16, 0, 128),
|
||||
}
|
||||
|
||||
for index, size := range hashrateWindowSizes {
|
||||
stats.windows[index] = tickWindow{
|
||||
buckets: make([]uint64, size),
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// OnAccept records an accepted share. Adds diff to the current second's bucket in all windows.
|
||||
//
|
||||
// stats.OnAccept(proxy.Event{Diff: 100000, Latency: 82})
|
||||
func (s *Stats) OnAccept(event Event) {
|
||||
s.accepted.Add(1)
|
||||
s.hashes.Add(event.Diff)
|
||||
if event.Expired {
|
||||
s.expired.Add(1)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for index := 0; index < HashrateWindowAll; index++ {
|
||||
s.windows[index].buckets[s.windows[index].pos] += event.Diff
|
||||
}
|
||||
insertTopDiff(&s.topDifficulties, event.Diff)
|
||||
if event.Latency > 0 {
|
||||
s.latencySamples = appendCappedLatency(s.latencySamples, event.Latency)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// OnReject records a rejected share. If e.Error indicates low diff or malformed, increments invalid.
|
||||
//
|
||||
// stats.OnReject(proxy.Event{Error: "Low difficulty share"})
|
||||
func (s *Stats) OnReject(event Event) {
|
||||
s.rejected.Add(1)
|
||||
if isInvalidShareError(event.Error) {
|
||||
s.invalid.Add(1)
|
||||
}
|
||||
if event.Expired {
|
||||
s.expired.Add(1)
|
||||
}
|
||||
if event.Latency > 0 {
|
||||
s.mu.Lock()
|
||||
s.latencySamples = appendCappedLatency(s.latencySamples, event.Latency)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Tick advances all rolling windows by one second bucket. Called by the proxy tick loop.
|
||||
//
|
||||
// stats.Tick()
|
||||
func (s *Stats) Tick() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for index := 0; index < HashrateWindowAll; index++ {
|
||||
window := &s.windows[index]
|
||||
window.pos = (window.pos + 1) % window.size
|
||||
window.buckets[window.pos] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Summary returns a point-in-time snapshot of all stats fields for API serialisation.
|
||||
//
|
||||
// summary := stats.Summary()
|
||||
func (s *Stats) Summary() StatsSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var summary StatsSummary
|
||||
summary.Accepted = s.accepted.Load()
|
||||
summary.Rejected = s.rejected.Load()
|
||||
summary.Invalid = s.invalid.Load()
|
||||
summary.Expired = s.expired.Load()
|
||||
summary.Hashes = s.hashes.Load()
|
||||
summary.TopDiff = s.topDifficulties
|
||||
|
||||
for index := 0; index < HashrateWindowAll; index++ {
|
||||
windowSize := hashrateWindowSizes[index]
|
||||
summary.Hashrate[index] = float64(sumBuckets(s.windows[index].buckets)) / float64(windowSize)
|
||||
}
|
||||
|
||||
uptimeSeconds := uint64(time.Since(s.startTime).Seconds())
|
||||
if uptimeSeconds > 0 {
|
||||
summary.Hashrate[HashrateWindowAll] = float64(summary.Hashes) / float64(uptimeSeconds)
|
||||
}
|
||||
if summary.Accepted > 0 && uptimeSeconds > 0 {
|
||||
summary.AvgTime = uint32(uptimeSeconds / summary.Accepted)
|
||||
}
|
||||
|
||||
if len(s.latencySamples) > 0 {
|
||||
values := slices.Clone(s.latencySamples)
|
||||
sort.Slice(values, func(left int, right int) bool {
|
||||
return values[left] < values[right]
|
||||
})
|
||||
summary.AvgLatency = uint32(values[len(values)/2])
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func appendCappedLatency(latencies []uint16, latency uint16) []uint16 {
|
||||
if len(latencies) == 10000 {
|
||||
copy(latencies, latencies[1:])
|
||||
latencies[len(latencies)-1] = latency
|
||||
return latencies
|
||||
}
|
||||
|
||||
return append(latencies, latency)
|
||||
}
|
||||
|
||||
func insertTopDiff(topDiff *[10]uint64, difficulty uint64) {
|
||||
if difficulty == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for index, value := range topDiff {
|
||||
if difficulty <= value {
|
||||
continue
|
||||
}
|
||||
|
||||
copy(topDiff[index+1:], topDiff[index:len(topDiff)-1])
|
||||
topDiff[index] = difficulty
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func isInvalidShareError(message string) bool {
|
||||
switch message {
|
||||
case "Low difficulty share", "Invalid nonce", "Malformed share", "Invalid result":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sumBuckets(values []uint64) uint64 {
|
||||
var total uint64
|
||||
for _, value := range values {
|
||||
total += value
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
|
|
|||
141
stats_workers_test.go
Normal file
141
stats_workers_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package proxy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEventBus_Dispatch_Good(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
called := false
|
||||
bus.Subscribe(EventLogin, func(event Event) {
|
||||
called = event.Miner != nil
|
||||
})
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: &Miner{}})
|
||||
if !called {
|
||||
t.Fatal("expected handler to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventBus_Dispatch_Bad(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
bus.Subscribe(EventLogin, nil)
|
||||
bus.Dispatch(Event{Type: EventLogin})
|
||||
}
|
||||
|
||||
func TestEventBus_Dispatch_Ugly(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
count := 0
|
||||
bus.Subscribe(EventLogin, func(event Event) { count++ })
|
||||
bus.Subscribe(EventLogin, func(event Event) { count++ })
|
||||
bus.Dispatch(Event{Type: EventLogin})
|
||||
if count != 2 {
|
||||
t.Fatalf("expected both handlers to run, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_Summary_Good(t *testing.T) {
|
||||
stats := NewStats()
|
||||
stats.OnAccept(Event{Diff: 120, Latency: 80})
|
||||
summary := stats.Summary()
|
||||
if summary.Accepted != 1 || summary.Hashes != 120 {
|
||||
t.Fatalf("unexpected summary: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_Summary_Bad(t *testing.T) {
|
||||
stats := NewStats()
|
||||
stats.OnReject(Event{Error: "Low difficulty share"})
|
||||
summary := stats.Summary()
|
||||
if summary.Rejected != 1 || summary.Invalid != 1 {
|
||||
t.Fatalf("unexpected summary: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_Summary_Ugly(t *testing.T) {
|
||||
stats := NewStats()
|
||||
stats.OnAccept(Event{Diff: 100, Latency: 10})
|
||||
stats.Tick()
|
||||
stats.OnAccept(Event{Diff: 200, Latency: 20})
|
||||
summary := stats.Summary()
|
||||
if summary.TopDiff[0] != 200 || summary.TopDiff[1] != 100 {
|
||||
t.Fatalf("unexpected best shares: %+v", summary.TopDiff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkers_List_Good(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
workers := NewWorkers(WorkersByRigID, bus)
|
||||
miner := &Miner{id: 1, user: "wallet", rigID: "rig-a", ip: "10.0.0.1"}
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
|
||||
bus.Dispatch(Event{Type: EventAccept, Miner: miner, Diff: 600})
|
||||
records := workers.List()
|
||||
if len(records) != 1 || records[0].Name != "rig-a" || records[0].Accepted != 1 {
|
||||
t.Fatalf("unexpected worker records: %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkers_List_Bad(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
workers := NewWorkers(WorkersDisabled, bus)
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: &Miner{id: 1, user: "wallet"}})
|
||||
if len(workers.List()) != 0 {
|
||||
t.Fatal("expected no worker records when disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkers_List_Ugly(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
workers := NewWorkers(WorkersByRigID, bus)
|
||||
miner := &Miner{id: 1, user: "wallet", ip: "10.0.0.1"}
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: miner})
|
||||
bus.Dispatch(Event{Type: EventReject, Miner: miner, Error: "Low difficulty share"})
|
||||
records := workers.List()
|
||||
if len(records) != 1 || records[0].Name != "wallet" || records[0].Invalid != 1 {
|
||||
t.Fatalf("unexpected worker records: %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkers_CustomDiffStats_Good(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
workers := NewWorkers(WorkersByUser, bus)
|
||||
workers.SetCustomDiffStats(true)
|
||||
|
||||
firstMiner := &Miner{id: 1, user: "wallet", customDiff: 1000}
|
||||
secondMiner := &Miner{id: 2, user: "wallet", customDiff: 2000}
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: firstMiner})
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: secondMiner})
|
||||
|
||||
records := workers.List()
|
||||
if len(records) != 2 || records[0].Name == records[1].Name {
|
||||
t.Fatalf("expected separate custom-diff buckets, got %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkers_CustomDiffStats_Bad(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
workers := NewWorkers(WorkersByUser, bus)
|
||||
workers.SetCustomDiffStats(true)
|
||||
|
||||
firstMiner := &Miner{id: 1, user: "wallet", customDiff: 1000}
|
||||
secondMiner := &Miner{id: 2, user: "wallet", customDiff: 1000}
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: firstMiner})
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: secondMiner})
|
||||
|
||||
records := workers.List()
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected identical custom-diff bucket to merge, got %+v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkers_CustomDiffStats_Ugly(t *testing.T) {
|
||||
bus := NewEventBus()
|
||||
workers := NewWorkers(WorkersByUser, bus)
|
||||
|
||||
firstMiner := &Miner{id: 1, user: "wallet", customDiff: 1000}
|
||||
secondMiner := &Miner{id: 2, user: "wallet", customDiff: 2000}
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: firstMiner})
|
||||
bus.Dispatch(Event{Type: EventLogin, Miner: secondMiner})
|
||||
|
||||
records := workers.List()
|
||||
if len(records) != 1 || records[0].Name != "wallet" {
|
||||
t.Fatalf("expected default worker bucketing to ignore custom diff, got %+v", records)
|
||||
}
|
||||
}
|
||||
120
tls_runtime.go
Normal file
120
tls_runtime.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func buildTLSConfig(config TLSConfig) *tls.Config {
|
||||
tlsConfig := &tls.Config{}
|
||||
if versions := parseTLSVersions(config.Protocols); versions != nil {
|
||||
tlsConfig.MinVersion = versions.min
|
||||
tlsConfig.MaxVersion = versions.max
|
||||
}
|
||||
if suites := parseCipherSuites(config.Ciphers); len(suites) > 0 {
|
||||
tlsConfig.CipherSuites = suites
|
||||
}
|
||||
return tlsConfig
|
||||
}
|
||||
|
||||
type tlsVersionBounds struct {
|
||||
min uint16
|
||||
max uint16
|
||||
}
|
||||
|
||||
func parseTLSVersions(value string) *tlsVersionBounds {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
bounds := tlsVersionBounds{}
|
||||
for _, token := range splitTLSList(value) {
|
||||
version, ok := parseTLSVersionToken(token)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bounds.min == 0 || version < bounds.min {
|
||||
bounds.min = version
|
||||
}
|
||||
if version > bounds.max {
|
||||
bounds.max = version
|
||||
}
|
||||
}
|
||||
|
||||
if bounds.min == 0 || bounds.max == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &bounds
|
||||
}
|
||||
|
||||
func parseTLSVersionToken(token string) (uint16, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(token)) {
|
||||
case "tls1.0", "tlsv1.0", "tls1", "tlsv1", "1.0", "tls10":
|
||||
return tls.VersionTLS10, true
|
||||
case "tls1.1", "tlsv1.1", "1.1", "tls11":
|
||||
return tls.VersionTLS11, true
|
||||
case "tls1.2", "tlsv1.2", "1.2", "tls12":
|
||||
return tls.VersionTLS12, true
|
||||
case "tls1.3", "tlsv1.3", "1.3", "tls13":
|
||||
return tls.VersionTLS13, true
|
||||
}
|
||||
|
||||
if raw, errorValue := strconv.ParseUint(strings.TrimSpace(token), 10, 16); errorValue == nil {
|
||||
switch uint16(raw) {
|
||||
case tls.VersionTLS10, tls.VersionTLS11, tls.VersionTLS12, tls.VersionTLS13:
|
||||
return uint16(raw), true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseCipherSuites(value string) []uint16 {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var suites []uint16
|
||||
for _, token := range splitTLSList(value) {
|
||||
if suite, ok := tlsCipherSuiteNames[strings.ToUpper(strings.TrimSpace(token))]; ok {
|
||||
suites = append(suites, suite)
|
||||
}
|
||||
}
|
||||
return suites
|
||||
}
|
||||
|
||||
func splitTLSList(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
switch r {
|
||||
case ':', ',', ' ', ';':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var tlsCipherSuiteNames = map[string]uint16{
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256": tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384": tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
"TLS_AES_128_GCM_SHA256": tls.TLS_AES_128_GCM_SHA256,
|
||||
"TLS_AES_256_GCM_SHA384": tls.TLS_AES_256_GCM_SHA384,
|
||||
"TLS_CHACHA20_POLY1305_SHA256": tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||
"ECDHE-RSA-AES128-GCM-SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
"ECDHE-RSA-AES256-GCM-SHA384": tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384": tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
"AES128-GCM-SHA256": tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
"AES256-GCM-SHA384": tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
"ECDHE-RSA-CHACHA20-POLY1305": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
"CHACHA20-POLY1305": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
}
|
||||
48
tls_runtime_test.go
Normal file
48
tls_runtime_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTLSRuntime_buildTLSConfig_Good(t *testing.T) {
|
||||
config := buildTLSConfig(TLSConfig{
|
||||
Ciphers: "ECDHE-RSA-AES128-GCM-SHA256:TLS_AES_128_GCM_SHA256",
|
||||
Protocols: "TLSv1.2,TLSv1.3",
|
||||
})
|
||||
|
||||
if config.MinVersion != tls.VersionTLS12 {
|
||||
t.Fatalf("expected min version TLS1.2, got %d", config.MinVersion)
|
||||
}
|
||||
if config.MaxVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("expected max version TLS1.3, got %d", config.MaxVersion)
|
||||
}
|
||||
if len(config.CipherSuites) != 2 || config.CipherSuites[0] != tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 || config.CipherSuites[1] != tls.TLS_AES_128_GCM_SHA256 {
|
||||
t.Fatalf("unexpected cipher suites: %#v", config.CipherSuites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSRuntime_buildTLSConfig_Bad(t *testing.T) {
|
||||
config := buildTLSConfig(TLSConfig{Protocols: "bogus", Ciphers: "bogus"})
|
||||
|
||||
if config.MinVersion != 0 || config.MaxVersion != 0 {
|
||||
t.Fatalf("expected default versions for invalid input, got min=%d max=%d", config.MinVersion, config.MaxVersion)
|
||||
}
|
||||
if len(config.CipherSuites) != 0 {
|
||||
t.Fatalf("expected no cipher suites for invalid input, got %#v", config.CipherSuites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSRuntime_buildTLSConfig_Ugly(t *testing.T) {
|
||||
config := buildTLSConfig(TLSConfig{Protocols: "1.1:1.2:1.3", Ciphers: "AES128-GCM-SHA256,unknown"})
|
||||
|
||||
if config.MinVersion != tls.VersionTLS11 {
|
||||
t.Fatalf("expected min version TLS1.1, got %d", config.MinVersion)
|
||||
}
|
||||
if config.MaxVersion != tls.VersionTLS13 {
|
||||
t.Fatalf("expected max version TLS1.3, got %d", config.MaxVersion)
|
||||
}
|
||||
if len(config.CipherSuites) != 1 || config.CipherSuites[0] != tls.TLS_RSA_WITH_AES_128_GCM_SHA256 {
|
||||
t.Fatalf("unexpected cipher suites: %#v", config.CipherSuites)
|
||||
}
|
||||
}
|
||||
204
worker.go
204
worker.go
|
|
@ -1,6 +1,7 @@
|
|||
package proxy
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -11,6 +12,7 @@ import (
|
|||
// w := proxy.NewWorkers(proxy.WorkersByRigID, bus)
|
||||
type Workers struct {
|
||||
mode WorkersMode
|
||||
customDiffStats bool
|
||||
entries []WorkerRecord // ordered by first-seen (stable)
|
||||
nameIndex map[string]int // workerName → entries index
|
||||
idIndex map[int64]int // minerID → entries index
|
||||
|
|
@ -31,3 +33,205 @@ type WorkerRecord struct {
|
|||
LastHashAt time.Time
|
||||
windows [5]tickWindow // 60s, 600s, 3600s, 12h, 24h
|
||||
}
|
||||
|
||||
// Hashrate returns the H/s for a given window (seconds: 60, 600, 3600, 43200, 86400).
|
||||
//
|
||||
// hr60 := record.Hashrate(60)
|
||||
func (r *WorkerRecord) Hashrate(seconds int) float64 {
|
||||
for index, windowSize := range hashrateWindowSizes {
|
||||
if windowSize == seconds {
|
||||
return float64(sumBuckets(r.windows[index].buckets)) / float64(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// NewWorkers creates the worker aggregate and subscribes it to the event bus.
|
||||
//
|
||||
// w := proxy.NewWorkers(proxy.WorkersByRigID, bus)
|
||||
func NewWorkers(mode WorkersMode, bus *EventBus) *Workers {
|
||||
workers := &Workers{
|
||||
mode: mode,
|
||||
entries: make([]WorkerRecord, 0),
|
||||
nameIndex: make(map[string]int),
|
||||
idIndex: make(map[int64]int),
|
||||
}
|
||||
|
||||
if bus != nil {
|
||||
bus.Subscribe(EventLogin, workers.onLogin)
|
||||
bus.Subscribe(EventAccept, workers.onAccept)
|
||||
bus.Subscribe(EventReject, workers.onReject)
|
||||
bus.Subscribe(EventClose, workers.onClose)
|
||||
}
|
||||
|
||||
return workers
|
||||
}
|
||||
|
||||
// SetCustomDiffStats toggles per-custom-difficulty worker bucketing.
|
||||
//
|
||||
// workers.SetCustomDiffStats(true)
|
||||
func (w *Workers) SetCustomDiffStats(enabled bool) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.customDiffStats = enabled
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
// List returns a snapshot of all worker records in first-seen order.
|
||||
//
|
||||
// records := workers.List()
|
||||
func (w *Workers) List() []WorkerRecord {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
records := make([]WorkerRecord, len(w.entries))
|
||||
copy(records, w.entries)
|
||||
return records
|
||||
}
|
||||
|
||||
// Tick advances all worker hashrate windows. Called by the proxy tick loop every second.
|
||||
//
|
||||
// workers.Tick()
|
||||
func (w *Workers) Tick() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
for entryIndex := range w.entries {
|
||||
for windowIndex, size := range hashrateWindowSizes {
|
||||
if windowIndex >= len(w.entries[entryIndex].windows) {
|
||||
break
|
||||
}
|
||||
window := &w.entries[entryIndex].windows[windowIndex]
|
||||
if window.size == 0 {
|
||||
window.size = size
|
||||
window.buckets = make([]uint64, size)
|
||||
}
|
||||
window.pos = (window.pos + 1) % window.size
|
||||
window.buckets[window.pos] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workers) onLogin(event Event) {
|
||||
if event.Miner == nil || w.mode == WorkersDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
name := w.workerName(event.Miner)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
index, exists := w.nameIndex[name]
|
||||
if !exists {
|
||||
record := WorkerRecord{Name: name}
|
||||
for windowIndex, size := range hashrateWindowSizes {
|
||||
if windowIndex >= len(record.windows) {
|
||||
break
|
||||
}
|
||||
record.windows[windowIndex] = tickWindow{
|
||||
buckets: make([]uint64, size),
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
w.entries = append(w.entries, record)
|
||||
index = len(w.entries) - 1
|
||||
w.nameIndex[name] = index
|
||||
}
|
||||
|
||||
record := &w.entries[index]
|
||||
record.LastIP = event.Miner.IP()
|
||||
record.Connections++
|
||||
w.idIndex[event.Miner.ID()] = index
|
||||
}
|
||||
|
||||
func (w *Workers) onAccept(event Event) {
|
||||
w.updateShare(event, true)
|
||||
}
|
||||
|
||||
func (w *Workers) onReject(event Event) {
|
||||
w.updateShare(event, false)
|
||||
}
|
||||
|
||||
func (w *Workers) onClose(event Event) {
|
||||
if event.Miner == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
delete(w.idIndex, event.Miner.ID())
|
||||
}
|
||||
|
||||
func (w *Workers) updateShare(event Event, accepted bool) {
|
||||
if event.Miner == nil || w.mode == WorkersDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
index, exists := w.idIndex[event.Miner.ID()]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
record := &w.entries[index]
|
||||
if accepted {
|
||||
record.Accepted++
|
||||
record.Hashes += event.Diff
|
||||
record.LastHashAt = time.Now().UTC()
|
||||
for windowIndex := range record.windows {
|
||||
record.windows[windowIndex].buckets[record.windows[windowIndex].pos] += event.Diff
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
record.Rejected++
|
||||
if isInvalidShareError(event.Error) {
|
||||
record.Invalid++
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Workers) workerName(miner *Miner) string {
|
||||
if miner == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
w.mu.RLock()
|
||||
customDiffStats := w.customDiffStats
|
||||
w.mu.RUnlock()
|
||||
|
||||
name := ""
|
||||
switch w.mode {
|
||||
case WorkersByRigID:
|
||||
if miner.RigID() != "" {
|
||||
name = miner.RigID()
|
||||
} else {
|
||||
name = miner.User()
|
||||
}
|
||||
case WorkersByUser:
|
||||
name = miner.User()
|
||||
case WorkersByPass:
|
||||
name = miner.Password()
|
||||
case WorkersByAgent:
|
||||
name = miner.Agent()
|
||||
case WorkersByIP:
|
||||
name = miner.IP()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
if !customDiffStats || miner.CustomDiff() == 0 || name == "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return name + "+cd" + strconv.FormatUint(miner.CustomDiff(), 10)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue