P1 audits / fixes:
* Bitcoin Core RPC now retries up to 3 times with linear backoff on
transport errors, 5xx responses, and warm-up/loading RPC errors
(code -28). Hard "no" answers (block-not-found etc.) bubble up
immediately so we don't mask real errors.
* WebSocket hub disconnects clients that miss 6 consecutive broadcasts
(~30s with the default poll cadence). Stuck readers no longer hold
stale snapshots indefinitely or freeze hub state.
* ZMQ subscriber freshness: aggregator records the last-event
timestamp, surfaces zmq_enabled / has_last_zmq_event /
last_zmq_event_age in the snapshot. /healthz flags zmq_stale when
the gap exceeds 30 minutes.
* /healthz expanded with submit_attempts / submits_confirmed /
submit_gap, fallback_submits_total + last_fallback_*, and the zmq
staleness check. Now usable as a real-world ops dashboard signal.
Block-broadcast fallback (new feature):
* ckpool patch 0004: hooks local_block_submit to write the raw block
hex to <logdir>/pending-blocks/<height>-<hash16>.hex right before
invoking generator_submitblock. Unlinks on success. ckpool's normal
flow is otherwise untouched.
* api/internal/blocksubmit: watcher polls the dir every 5s. Files
sitting longer than the grace window (default 30s, configurable)
are re-broadcast through operator-supplied backup RPC URLs in
sequence. Treats both null and any "duplicate*" reject reason as
success (the block landed). Pre-checks the primary chain first so
a stale file from a successful-but-unlinked submit gets cleaned
up without bothering fallbacks.
* Aggregator records each successful fallback submission as a
persistent counter and surfaces it in the snapshot so the UI can
show a "primary bitcoind isn't accepting submits" alert.
* Config: BACKUP_RPC_URLS (comma- or newline-separated, with
optional inline credentials) plus PENDING_BLOCKS_DIR and
PENDING_BLOCKS_GRACE. URLs are parsed via net/url so
https://user:pass@host:port/ works cleanly.
The fallback is opt-in and disabled by default. Once enabled with at
least one URL, a primary bitcoind outage at the moment of solving no
longer means a lost block — kamado-api re-broadcasts via whichever
backup the operator trusts (a second self-hosted node, an
authenticated public RPC service, etc.).
The production image is now a single Go binary that serves both the
JSON/WebSocket API under /api and the Svelte dashboard at /.
- New internal/webui package embeds a dist/ subdir via //go:embed.
A placeholder index.html is committed so `go build` works on a
fresh checkout; anything else in dist/ is regenerated per build
and gitignored.
- httpapi.Server.Handler mounts the embed.FS at / with SPA-style
fallback: unknown non-/api paths serve index.html so client-side
routes survive a reload. /api/* is carved out explicitly so POSTs
or typos never accidentally shadow API semantics with HTML.
- api/Dockerfile grows a node:22 builder stage that runs
`npm ci && npm run build`, and the Go stage copies ui/dist/ into
internal/webui/dist/ before `go build`. Build context moves to
the repo root (docker-compose + `make api` both updated) so the
Dockerfile can see both api/ and ui/.
With this in place, `make up` brings the whole stack online at
http://localhost:8080 — API under /api, dashboard at /. The Vite
dev server on :5173 with the /api proxy is still available via
`make ui-dev` for hot-reload development.
Adds real-time block detection via ckpool log tailing and a push
channel for the upcoming Svelte UI, all stdlib-only:
- logmon.Tailer follows ckpool.log with rotation/truncation survival
(inode + size tracking) and parses "Solved and confirmed block N"
into BlockEvent values.
- state.Aggregator grows a 256-entry block ring, an OnRefresh hook,
and IngestBlockEvents which best-effort enriches events with the
block hash via bitcoind getblockhash.
- httpapi.Hub implements RFC 6455 from scratch (SHA1 handshake,
unmasked text frames out, masked frames in, ping keepalive,
per-client write mutex, slow-client drop) so we don't pull in a
ws dependency before we can go mod tidy.
- New routes: GET /api/blocks and GET /api/ws. Snapshot pushes fire
on every poll tick and immediately on block-solve.
ZMQ hashblock subscription and SQLite persistence are deferred to
Phase 2b.5 once the s9pk packaging repo exists and we have a real
build environment for adding Go deps.
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>