// kamado-api is the middleware that sits between ckpool-solo and the // Kamado dashboard. It polls ckpool's Unix socket API, calls bitcoind // over JSON-RPC, and serves the merged state over REST (WebSocket push, // SQLite persistence, ZMQ and log-tailer-based block detection land in // a follow-up commit). package main import ( "context" "errors" "fmt" "log/slog" "net/http" "net/url" "os" "os/signal" "strconv" "strings" "syscall" "time" "github.com/kamadopool/kamado-api/internal/bitcoind" "github.com/kamadopool/kamado-api/internal/blocksubmit" "github.com/kamadopool/kamado-api/internal/ckpool" "github.com/kamadopool/kamado-api/internal/config" "github.com/kamadopool/kamado-api/internal/httpapi" "github.com/kamadopool/kamado-api/internal/logmon" "github.com/kamadopool/kamado-api/internal/state" "github.com/kamadopool/kamado-api/internal/store" "github.com/kamadopool/kamado-api/internal/zmqmon" ) func main() { log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(log) cfg, err := config.FromEnv() if err != nil { log.Error("config", "err", err) os.Exit(1) } log.Info("kamado-api starting", "listen", cfg.ListenAddr, "sockdir", cfg.CKPoolSockDir, "bitcoind", cfg.BitcoinRPCURL, "poll_interval", cfg.PollInterval, ) ck := ckpool.New(cfg.CKPoolSockDir) rpc := bitcoind.NewRPC(cfg.BitcoinRPCURL, cfg.BitcoinRPCUser, cfg.BitcoinRPCPassword, cfg.BitcoinRPCTimeout) // Persistent block history. Non-fatal if it can't be opened — the // aggregator falls back to an in-memory ring so the pool keeps // running even with a broken data volume. var blockStore *store.BlockStore if cfg.DBPath != "" { if s, err := store.Open(cfg.DBPath); err != nil { log.Warn("block store open failed, running without persistence", "path", cfg.DBPath, "err", err) } else { blockStore = s defer blockStore.Close() log.Info("block store opened", "path", cfg.DBPath) } } agg := state.New(ck, rpc, cfg.PollInterval, log) agg.Store = blockStore agg.MempoolBaseURL = cfg.MempoolBaseURL ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() api := httpapi.New(agg, log) // Wire snapshot refreshes into the WebSocket hub so subscribers get // real-time updates without polling. agg.OnRefresh = api.Hub.Broadcast // Optional: bitcoind hashblock ZMQ subscription for sub-second // chain-tip refreshes. Empty endpoint disables it. zmq := zmqmon.New(cfg.BitcoinZMQBlock, log) go zmq.Run(ctx) go agg.Run(ctx, zmq.Events) // Background reconciliation: retry hash/reward enrichment for blocks // the initial RPC lookup couldn't fetch, and detect chain reorgs by // comparing recorded hashes against the canonical chain. go agg.ReconcileBlocks(ctx) // Tail the ckpool log for block-solve events (our own solves). tailer := logmon.New(cfg.CKPoolLogFile, log) if blockStore != nil { const cursorKey = "logmon_cursor" tailer.LoadCursor = func() (uint64, int64, bool) { v, err := blockStore.GetKV(cursorKey) if err != nil || v == "" { return 0, 0, false } parts := strings.SplitN(v, ":", 2) if len(parts) != 2 { return 0, 0, false } ino, err1 := strconv.ParseUint(parts[0], 10, 64) off, err2 := strconv.ParseInt(parts[1], 10, 64) if err1 != nil || err2 != nil { return 0, 0, false } return ino, off, true } tailer.SaveCursor = func(ino uint64, off int64) { if err := blockStore.SetKV(cursorKey, fmt.Sprintf("%d:%d", ino, off)); err != nil { log.Warn("logmon cursor persist failed", "err", err) } } } go tailer.Run(ctx) go agg.IngestBlockEvents(ctx, tailer.Events) go agg.IngestAttemptEvents(ctx, tailer.Attempts) // Fallback block submitter: if ckpool's primary bitcoind doesn't // accept a block, the patched local_block_submit leaves the raw // hex sitting in PENDING_BLOCKS_DIR. This watcher re-broadcasts it // via the operator-configured BACKUP_RPC_URLS list. if cfg.PendingBlocksDir != "" { fallbacks := parseBackupRPCs(cfg.BackupRPCURLs, log) sub := &blocksubmit.Submitter{ Dir: cfg.PendingBlocksDir, Grace: cfg.PendingBlocksGrace, Primary: rpc, Fallbacks: fallbacks, Log: log, OnSuccess: func(height int64, viaURL, viaLabel string) { agg.RecordFallbackSubmit(height, viaLabel) }, } // Sweep every second so we react within ~grace+1s of a // failed submit. Reading an empty directory is cheap. go sub.Run(ctx, 1*time.Second) } else { log.Info("blocksubmit fallback disabled (PENDING_BLOCKS_DIR not set)") } // Wait briefly for the aggregator's first refresh to complete so // the very first /api/snapshot or /api/health hit doesn't see an // all-zeros snapshot and report bitcoin_ok=false during its own // initialization. Cap the wait so a permanently-down bitcoind // can't block startup forever — /healthz is honest about the // degraded state. select { case <-agg.Ready(): log.Info("first snapshot ready") case <-time.After(8 * time.Second): log.Warn("first snapshot not ready within 8s, serving HTTP anyway (snapshot will be partial until backends respond)") case <-ctx.Done(): return } srv := &http.Server{ Addr: cfg.ListenAddr, Handler: api.Handler(), ReadHeaderTimeout: 5 * time.Second, } // Shutdown on ctx cancel go func() { <-ctx.Done() log.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) }() log.Info("http listening", "addr", cfg.ListenAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error("http server", "err", err) os.Exit(1) } } // parseBackupRPCs splits BACKUP_RPC_URLS (newline- or comma-separated) // into FallbackTargets. Credentials are accepted inline as // https://user:pass@host:port/. Lines beginning with '#' are comments. // Whitespace and empty lines are ignored. func parseBackupRPCs(raw string, log *slog.Logger) []blocksubmit.FallbackTarget { if raw == "" { return nil } separated := strings.NewReplacer(",", "\n").Replace(raw) var out []blocksubmit.FallbackTarget for _, line := range strings.Split(separated, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } u, err := url.Parse(line) if err != nil { log.Warn("blocksubmit: invalid fallback URL, skipping", "raw", line, "err", err) continue } var user, pass string if u.User != nil { user = u.User.Username() pass, _ = u.User.Password() u.User = nil } clean := u.String() out = append(out, blocksubmit.FallbackTarget{ URL: clean, User: user, Password: pass, Label: u.Host, }) log.Info("blocksubmit fallback registered", "url", clean, "host", u.Host, "auth", user != "") } return out }