Go 1.22 module that polls CKPool's Unix socket control API, queries
Bitcoin Core over JSON-RPC, merges both into a thread-safe snapshot, and
serves it over REST. Layout:
api/
├── cmd/kamado-api/main.go signal-aware entrypoint
└── internal/
├── config/ env var loader with validation
├── ckpool/ socket client (4-byte LE length-prefixed wire
│ protocol verified against libckpool.c), typed
│ response models for poolstats/users/workers/
│ clients/uptime, + unit tests using a fake
│ unix socket server
├── bitcoind/ minimal JSON-RPC client, getblockchaininfo
│ and getnetworkhashps
├── state/ Aggregator that refreshes a merged Snapshot
│ on a ticker; readers get a copy under RWMutex
└── httpapi/ REST handlers on Go 1.22 ServeMux:
/api/health /api/pool /api/users
/api/workers /api/clients /api/snapshot
CKPool stores hashrate as "dsps" (diff shares per second); we convert
to H/s via the 2^32 constant used by GoBrrr-Pool and other clients.
Every stat CKPool exposes to its socket API is surfaced — useragent,
IP, per-client diff, per-worker best diff — closing the gap against
Bassin which only reads the 60-second stats files.
Dockerfile does a CGO_ENABLED=0 static build on golang:1.22-bookworm
with -trimpath -ldflags=-s -w. docker-compose now runs both ckpool and
kamado-api, sharing a named volume for /run/ckpool so the API can
dial the stratifier socket directly.
Deferred to Phase 2b (called out in README):
- Bitcoin Core ZMQ hashblock subscriber
- WebSocket push for real-time UI updates
- SQLite persistence (block history, best-share history)
- CKPool log tailer for "Solved and confirmed block" detection
Tests and build NOT run in this commit — Go isn't installed in the
dev environment. Run `make api-test` or `make api` to verify.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
// Package state merges data from CKPool (via Unix socket), Bitcoin Core
|
|
// (via JSON-RPC), and future sources (ZMQ, log tailer) into a single
|
|
// thread-safe snapshot the HTTP layer can serve. The snapshot is refreshed
|
|
// on a ticker; readers get a copy without blocking the refresh.
|
|
package state
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kamadopool/kamado-api/internal/bitcoind"
|
|
"github.com/kamadopool/kamado-api/internal/ckpool"
|
|
)
|
|
|
|
// Snapshot is the merged view served to the UI. All fields are safe to
|
|
// JSON-serialize directly.
|
|
type Snapshot struct {
|
|
GeneratedAt time.Time `json:"generated_at"`
|
|
|
|
// CKPool-derived fields
|
|
Pool *ckpool.PoolStats `json:"pool"`
|
|
Uptime int64 `json:"uptime_seconds"`
|
|
Users []ckpool.User `json:"users"`
|
|
Workers []ckpool.Worker `json:"workers"`
|
|
Clients []ckpool.StratumClient `json:"clients"`
|
|
|
|
// Derived/enriched fields
|
|
HashrateHs float64 `json:"hashrate_hs_1m"` // from PoolStats.DSPS1
|
|
HashrateHs5m float64 `json:"hashrate_hs_5m"`
|
|
HashrateHs1h float64 `json:"hashrate_hs_1h"`
|
|
HashrateHs24h float64 `json:"hashrate_hs_24h"`
|
|
|
|
// Bitcoin Core fields
|
|
Chain *bitcoind.BlockchainInfo `json:"chain"`
|
|
NetworkHashrateHs float64 `json:"network_hashrate_hs"`
|
|
|
|
// Health
|
|
CKPoolOK bool `json:"ckpool_ok"`
|
|
BitcoinOK bool `json:"bitcoin_ok"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
}
|
|
|
|
// Aggregator refreshes a Snapshot on a ticker.
|
|
type Aggregator struct {
|
|
CK *ckpool.Client
|
|
RPC *bitcoind.RPC
|
|
Interval time.Duration
|
|
Log *slog.Logger
|
|
|
|
mu sync.RWMutex
|
|
snap Snapshot
|
|
}
|
|
|
|
func New(ck *ckpool.Client, rpc *bitcoind.RPC, interval time.Duration, log *slog.Logger) *Aggregator {
|
|
return &Aggregator{
|
|
CK: ck,
|
|
RPC: rpc,
|
|
Interval: interval,
|
|
Log: log,
|
|
}
|
|
}
|
|
|
|
// Run blocks until ctx is cancelled, refreshing the snapshot every Interval.
|
|
// It runs one immediate refresh at startup so readers don't see an empty
|
|
// snapshot after ctx launches the goroutine.
|
|
func (a *Aggregator) Run(ctx context.Context) {
|
|
a.refresh(ctx)
|
|
t := time.NewTicker(a.Interval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
a.refresh(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Snapshot returns a copy of the current snapshot.
|
|
func (a *Aggregator) Snapshot() Snapshot {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
return a.snap
|
|
}
|
|
|
|
func (a *Aggregator) refresh(ctx context.Context) {
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
next := Snapshot{GeneratedAt: time.Now()}
|
|
|
|
// --- ckpool: poolstats, users, workers, clients, uptime ---
|
|
if ps, err := a.CK.PoolStats(ctx); err == nil {
|
|
next.Pool = ps
|
|
next.HashrateHs = ckpool.DSPSToHashrate(ps.DSPS1)
|
|
next.HashrateHs5m = ckpool.DSPSToHashrate(ps.DSPS5)
|
|
next.HashrateHs1h = ckpool.DSPSToHashrate(ps.DSPS60)
|
|
next.HashrateHs24h = ckpool.DSPSToHashrate(ps.DSPS1440)
|
|
next.CKPoolOK = true
|
|
} else {
|
|
a.Log.Warn("ckpool poolstats failed", "err", err)
|
|
next.LastError = err.Error()
|
|
}
|
|
if u, err := a.CK.Uptime(ctx); err == nil {
|
|
next.Uptime = u
|
|
}
|
|
if us, err := a.CK.Users(ctx); err == nil {
|
|
next.Users = us
|
|
}
|
|
if ws, err := a.CK.Workers(ctx); err == nil {
|
|
next.Workers = ws
|
|
}
|
|
if cs, err := a.CK.Clients(ctx); err == nil {
|
|
next.Clients = cs
|
|
}
|
|
|
|
// --- bitcoind: chain + network hashrate ---
|
|
if bi, err := a.RPC.GetBlockchainInfo(ctx); err == nil {
|
|
next.Chain = bi
|
|
next.BitcoinOK = true
|
|
if nh, err := a.RPC.GetNetworkHashPS(ctx, -1, int(bi.Blocks)); err == nil {
|
|
next.NetworkHashrateHs = nh
|
|
}
|
|
} else {
|
|
a.Log.Warn("bitcoind getblockchaininfo failed", "err", err)
|
|
if next.LastError == "" {
|
|
next.LastError = err.Error()
|
|
}
|
|
}
|
|
|
|
a.mu.Lock()
|
|
a.snap = next
|
|
a.mu.Unlock()
|
|
}
|