From 8a35b02c7e2ffd8944fb1529b08b214f17454a86 Mon Sep 17 00:00:00 2001 From: satoshi Date: Sat, 1 Aug 2026 06:14:15 +0300 Subject: [PATCH] ix ckpool.conf render dropping the TLS bind port; Let deployments declare what each stratum bind is --- README.md | 7 ++- api/cmd/kamado-api/main.go | 13 ++++++ api/internal/config/config.go | 28 +++++++----- api/internal/state/aggregator.go | 69 +++++++++++++++++++++--------- ckpool/entrypoint.sh | 6 +++ ui/src/format.ts | 22 ++++++++++ ui/src/lib/MinersTable.svelte | 16 ++++--- ui/src/lib/UserDetailPage.svelte | 12 ++++-- ui/src/lib/WorkerDetailPage.svelte | 10 ++++- ui/src/types.ts | 21 +++++++-- 10 files changed, 159 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 1eafb60..9162cd7 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ The Go API (`kamado-api`) bridges CKPool's Unix socket protocol, bitcoind's JSON ### Stratum TLS -Optional encrypted stratum via stunnel. CKPool binds two stratum sockets — a public plaintext port and a loopback-only internal port. Stunnel terminates TLS on the external port and forwards decrypted traffic to the internal bind. CKPool tags connections by `serverurl` index (`server === 1` means TLS), which the dashboard reads to display a lock icon next to encrypted miners — no source-IP heuristics needed. +Optional encrypted stratum via stunnel. CKPool binds a public plaintext socket plus one loopback-only socket per TLS certificate; stunnel terminates TLS on the external port and forwards decrypted traffic to the matching internal bind. CKPool tags each connection with its `serverurl` index, and the deployment declares what those indices mean via the `STRATUM_SERVERS` env — so the dashboard shows a lock icon next to encrypted miners and names the certificate in use on hover, with no source-IP heuristics. -The TLS certificate is auto-generated on first start with broad SAN coverage (`.local`, `.embassy`, `.onion`, `.lan`, `.home.arpa`, `.internal`) so miner firmware that validates the SAN against the connection hostname (e.g. AxeOS with mbedtls) works without manual cert pinning. A version marker triggers automatic regeneration when the cert format changes. +Under StartOS this drives two certificates on one port, chosen per connection by SNI: a CA-issued (Let's Encrypt) certificate for miners connecting over a clearnet domain, and the self-signed one for miners on the LAN, which send no SNI and fall through to it. + +The self-signed certificate is auto-generated on first start with broad SAN coverage (`.local`, `.embassy`, `.onion`, `.lan`, `.home.arpa`, `.internal`) so miner firmware that validates the SAN against the connection hostname (e.g. AxeOS with mbedtls) works without manual cert pinning. A version marker triggers automatic regeneration when the cert format changes. ### Dashboard @@ -161,6 +163,7 @@ The dashboard is at `http://localhost:8080`. Point a miner at `stratum+tcp://loc | `BITCOIN_ZMQ_BLOCK` | (disabled) | no | ZMQ hashblock endpoint (e.g. `tcp://127.0.0.1:28332`) | | `BITCOIN_RPC_TIMEOUT` | `10s` | no | RPC call timeout | | `MEMPOOL_BASE_URL` | (mempool.space) | no | Custom mempool explorer URL | +| `STRATUM_SERVERS` | (undeclared) | no | JSON array describing ckpool's `serverurl[]` binds, e.g. `[{"kind":"plain","label":"Plaintext"},{"kind":"tls-local","label":"TLS — self-signed"}]`. Entry N labels clients with `server == N` in the dashboard. Unset falls back to treating index 1 as TLS | ### UI Development diff --git a/api/cmd/kamado-api/main.go b/api/cmd/kamado-api/main.go index 91f19a5..aa08736 100644 --- a/api/cmd/kamado-api/main.go +++ b/api/cmd/kamado-api/main.go @@ -7,6 +7,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -65,6 +66,18 @@ func main() { agg := state.New(ck, rpc, cfg.PollInterval, log) agg.Store = blockStore agg.MempoolBaseURL = cfg.MempoolBaseURL + // Purely descriptive (it drives the dashboard's connection badge), so a + // malformed value degrades to the UI's fallback rather than refusing to + // start a pool that is otherwise fine. + if cfg.StratumServersJSON != "" { + var servers []state.StratumServer + if err := json.Unmarshal([]byte(cfg.StratumServersJSON), &servers); err != nil { + log.Warn("ignoring malformed STRATUM_SERVERS", "err", err) + } else { + agg.StratumServers = servers + log.Info("stratum binds declared", "count", len(servers)) + } + } agg.LogFilePath = cfg.CKPoolLogFile agg.KillCKPool = killCKPool(log) diff --git a/api/internal/config/config.go b/api/internal/config/config.go index 769049a..0f09549 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -39,21 +39,29 @@ type Config struct { // the UI uses the public mempool.space; non-empty means the user // has pointed Kamado at their own instance via the StartOS config. MempoolBaseURL string + + // Optional JSON array describing ckpool's serverurl[] binds, so the + // dashboard can name the transport a miner connected over instead of + // guessing from the bind index. Rendered by whoever writes + // ckpool.conf; empty means "undeclared" and the UI falls back. Parsed + // in main (the concrete type lives with the snapshot it belongs to). + StratumServersJSON string } func FromEnv() (*Config, error) { cfg := &Config{ - ListenAddr: getenv("LISTEN_ADDR", ":8080"), - CKPoolSockDir: getenv("CKPOOL_SOCKDIR", "/run/ckpool"), - BitcoinRPCURL: os.Getenv("BITCOIN_RPC_URL"), - BitcoinRPCUser: os.Getenv("BITCOIN_RPC_USER"), + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + CKPoolSockDir: getenv("CKPOOL_SOCKDIR", "/run/ckpool"), + BitcoinRPCURL: os.Getenv("BITCOIN_RPC_URL"), + BitcoinRPCUser: os.Getenv("BITCOIN_RPC_USER"), BitcoinRPCPassword: os.Getenv("BITCOIN_RPC_PASSWORD"), - BitcoinRPCTimeout: getenvDuration("BITCOIN_RPC_TIMEOUT", 10*time.Second), - BitcoinZMQBlock: os.Getenv("BITCOIN_ZMQ_BLOCK"), - CKPoolLogFile: getenv("CKPOOL_LOGFILE", "/var/log/ckpool/ckpool.log"), - DBPath: getenv("DB_PATH", "/var/lib/kamado/kamado.db"), - PollInterval: getenvDuration("POLL_INTERVAL", 5*time.Second), - MempoolBaseURL: os.Getenv("MEMPOOL_BASE_URL"), + BitcoinRPCTimeout: getenvDuration("BITCOIN_RPC_TIMEOUT", 10*time.Second), + BitcoinZMQBlock: os.Getenv("BITCOIN_ZMQ_BLOCK"), + CKPoolLogFile: getenv("CKPOOL_LOGFILE", "/var/log/ckpool/ckpool.log"), + DBPath: getenv("DB_PATH", "/var/lib/kamado/kamado.db"), + PollInterval: getenvDuration("POLL_INTERVAL", 5*time.Second), + MempoolBaseURL: os.Getenv("MEMPOOL_BASE_URL"), + StratumServersJSON: os.Getenv("STRATUM_SERVERS"), } if cfg.BitcoinRPCURL == "" { diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go index 5e105f1..f09bd14 100644 --- a/api/internal/state/aggregator.go +++ b/api/internal/state/aggregator.go @@ -25,6 +25,21 @@ type HashratePoint struct { V float64 `json:"v"` // H/s } +// StratumServer describes one entry in ckpool's serverurl[] array. ckpool +// tags every client with the index of the bind it arrived on, but the index +// alone doesn't say what that bind *is* — that depends on how the deployment +// rendered ckpool.conf. Declaring the array lets the dashboard report the +// actual transport (plaintext, TLS with which certificate) instead of +// hardcoding a bind order. +type StratumServer struct { + // Kind is the stable machine-readable tag the UI switches on: + // "plain", "tls-local" (package-managed self-signed certificate) or + // "tls-public" (CA-issued certificate for a public domain). + Kind string `json:"kind"` + // Label is the human-readable description shown on hover. + Label string `json:"label"` +} + // Snapshot is the merged view served to the UI. All fields are safe to // JSON-serialize directly. type Snapshot struct { @@ -38,7 +53,7 @@ type Snapshot struct { Clients []ckpool.StratumClient `json:"clients"` // Derived/enriched fields - HashrateHs float64 `json:"hashrate_hs_1m"` // from PoolStats.DSPS1 + 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"` @@ -68,8 +83,8 @@ type Snapshot struct { NextDifficultyPercent float64 `json:"next_difficulty_percent"` // Bitcoin Core fields - Chain *bitcoind.BlockchainInfo `json:"chain"` - NetworkHashrateHs float64 `json:"network_hashrate_hs"` + Chain *bitcoind.BlockchainInfo `json:"chain"` + NetworkHashrateHs float64 `json:"network_hashrate_hs"` // Recent found blocks (in-memory history; persisted in Phase 2b.5). RecentBlocks []BlockRecord `json:"recent_blocks,omitempty"` @@ -82,6 +97,13 @@ type Snapshot struct { // the StartOS config "Block Explorer" -> "Custom URL". MempoolBaseURL string `json:"mempool_base_url,omitempty"` + // Describes ckpool's serverurl[] array: entry N tells the UI what a + // client with `server == N` is actually connected over. Set via the + // STRATUM_SERVERS env by whoever renders ckpool.conf (the StartOS + // wrapper). Empty when undeclared, in which case the UI falls back to + // its historical "index 1 means TLS" assumption. + StratumServers []StratumServer `json:"stratum_servers,omitempty"` + // Counts of share-submit attempts ("Possible/Submitting block solve" // log lines) and confirmed solves ("Solved and confirmed block"). // A growing gap means bitcoind is rejecting our submissions or @@ -93,15 +115,15 @@ type Snapshot struct { // LastZMQEventAge is the seconds-since the last bitcoind hashblock // frame arrived; -1 means no event seen since startup. ZMQEnabled // is whether the user configured an endpoint at all. - ZMQEnabled bool `json:"zmq_enabled"` - LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0 - HasLastZMQEvent bool `json:"has_last_zmq_event"` - TipChangedAge float64 `json:"tip_changed_age"` // seconds since tip height last changed + ZMQEnabled bool `json:"zmq_enabled"` + LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0 + HasLastZMQEvent bool `json:"has_last_zmq_event"` + TipChangedAge float64 `json:"tip_changed_age"` // seconds since tip height last changed // Bitcoin Core peer connections. - PeerCount int `json:"peer_count"` - PeersIn int `json:"peers_in"` - PeersOut int `json:"peers_out"` + PeerCount int `json:"peer_count"` + PeersIn int `json:"peers_in"` + PeersOut int `json:"peers_out"` // Share counters: raw counts (1 submission = 1 share regardless of diff). // Session = since ckpool started; AllTime = persisted across restarts. @@ -151,8 +173,8 @@ const ( kvLatencyLastMs = "latency_last_ms" kvStaleWorkHashes = "stale_work_hashes" - kvAckedBestDiff = "acked_best_diff" - kvBestDiff = "best_diff" + kvAckedBestDiff = "acked_best_diff" + kvBestDiff = "best_diff" kvBestShareHash = "best_share_hash" kvBestShareNetDiff = "best_share_net_diff" @@ -186,6 +208,10 @@ type Aggregator struct { // MempoolBaseURL leaves the UI on its mempool.space defaults. MempoolBaseURL string + // Declared meaning of each ckpool serverurl[] index; see StratumServer. + // Nil leaves the UI on its index-1-means-TLS fallback. + StratumServers []StratumServer + // LogFilePath is the ckpool log path, used for one-time backfill // of the best share hash when upgrading from a version that didn't // capture it. Set by main before calling Run. @@ -220,10 +246,10 @@ type Aggregator struct { // post-load observation of pool.Shares only establishes the // baseline — the current ckpool counter was already accounted for // before we crashed. - cumulativeShares float64 - lastPoolShares float64 - hasPoolSharesBaseline bool - lastCumulativeSave time.Time + cumulativeShares float64 + lastPoolShares float64 + hasPoolSharesBaseline bool + lastCumulativeSave time.Time // Next-block reward cache; refreshed on its own cadence so we don't // hammer bitcoind with getblocktemplate on every poll tick. @@ -245,8 +271,8 @@ type Aggregator struct { // btcFailStreak counts consecutive refreshes where bitcoind was // unreachable. After a threshold, if no block submission is pending, // we kill the ckpool process so miners can failover to other pools. - btcFailStreak int - ckpoolKilled bool // true after we sent SIGTERM, reset on bitcoind recovery + btcFailStreak int + ckpoolKilled bool // true after we sent SIGTERM, reset on bitcoind recovery // readyOnce + ready closes the Ready() channel exactly once after // the first refresh completes. main blocks briefly on this so the @@ -401,7 +427,11 @@ func (a *Aggregator) refresh(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - next := Snapshot{GeneratedAt: time.Now(), MempoolBaseURL: a.MempoolBaseURL} + next := Snapshot{ + GeneratedAt: time.Now(), + MempoolBaseURL: a.MempoolBaseURL, + StratumServers: a.StratumServers, + } // --- ckpool: poolstats, users, workers, clients, uptime --- if ps, err := a.CK.PoolStats(ctx); err == nil { @@ -776,7 +806,6 @@ func diffBucket(d float64) int { // DiffBucketLabels are the human-readable labels for each difficulty bucket. var DiffBucketLabels = [6]string{"< 1M", "1M – 100M", "100M – 1G", "1G – 100G", "100G – 1T", "≥ 1T"} - // IngestShareEvents reads individual share events from the log tailer // and maintains rejection-reason counts and difficulty-distribution // histograms. Session counters reset when ckpool restarts (detected by diff --git a/ckpool/entrypoint.sh b/ckpool/entrypoint.sh index d798dd9..5c7b3c3 100644 --- a/ckpool/entrypoint.sh +++ b/ckpool/entrypoint.sh @@ -49,6 +49,11 @@ LOGDIR="${LOGDIR:-/var/log/ckpool}" SOCKET_DIR="${SOCKET_DIR:-/run/ckpool}" SHARE_LOG="${SHARE_LOG:-1}" CKPOOL_LOGLEVEL="${CKPOOL_LOGLEVEL:-6}" +# ckpool's second, loopback-only stratum bind. Nothing terminates TLS in the +# dev compose stack, but the bind must still resolve: ckpool passes the port +# straight to getaddrinfo() and a non-numeric service string is fatal +# (connector.c logs "Failed to extract resolved url" and exit(1)s). +TLS_INTERNAL_PORT="${TLS_INTERNAL_PORT:-3437}" mkdir -p "$LOGDIR" "$SOCKET_DIR" @@ -68,6 +73,7 @@ sed \ -e "s|\${BLOCKPOLL_MS}|${BLOCKPOLL_MS}|g" \ -e "s|\${UPDATE_INTERVAL_S}|${UPDATE_INTERVAL_S}|g" \ -e "s|\${STRATUM_PORT}|${STRATUM_PORT}|g" \ + -e "s|\${TLS_INTERNAL_PORT}|${TLS_INTERNAL_PORT}|g" \ -e "s|\${MINDIFF}|${MINDIFF}|g" \ -e "s|\${STARTDIFF}|${STARTDIFF}|g" \ -e "s|\${MAXDIFF}|${MAXDIFF}|g" \ diff --git a/ui/src/format.ts b/ui/src/format.ts index 765526f..caa82d6 100644 --- a/ui/src/format.ts +++ b/ui/src/format.ts @@ -1,6 +1,8 @@ // Formatters for hashrate, share difficulty, time, and miner hardware // detection from stratum user-agent strings. Pure functions, no DOM. +import type { StratumServer } from "./types"; + const HASHRATE_UNITS = ["H/s", "kH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"]; export function formatHashrate(hs: number): string { @@ -100,6 +102,26 @@ export function explorerBaseFor( return "https://mempool.space"; } +// Resolve how a miner is connected from its ckpool serverurl[] index. +// +// The deployment declares the bind array via STRATUM_SERVERS (the StartOS +// wrapper renders one entry per bind it configures). When it hasn't — the +// dev compose stack, or an older wrapper — fall back to the historical +// layout, where index 0 is the plaintext bind and anything else is the +// loopback bind that stunnel forwards TLS traffic to. +export function connectionOf( + serverIndex: number | undefined, + servers: StratumServer[] | undefined, +): { tls: boolean; label: string } { + const declared = servers?.[serverIndex ?? 0]; + if (declared) { + return { tls: declared.kind !== "plain", label: declared.label }; + } + return serverIndex + ? { tls: true, label: "Encrypted connection via TLS" } + : { tls: false, label: "Unencrypted connection" }; +} + // Detect common Bitcoin mining hardware from the stratum useragent // string. This is a best-effort heuristic; unknown agents fall back // to a stripped version of the raw string. Order matters — check diff --git a/ui/src/lib/MinersTable.svelte b/ui/src/lib/MinersTable.svelte index 5773767..ec8f7d5 100644 --- a/ui/src/lib/MinersTable.svelte +++ b/ui/src/lib/MinersTable.svelte @@ -8,6 +8,7 @@ detectHardware, isOpenSource, btcAddressOf, + connectionOf, } from "../format"; import type { StratumClient, Worker } from "../types"; @@ -18,6 +19,7 @@ hardware: string; openSource: boolean; tls: boolean; + tlsLabel: string; hashrate1m: number; hashrate1h: number; bestSession: number; @@ -35,9 +37,10 @@ // from the joined worker_instance, which ckpool keys by full // workername with user = the BTC. // - // ckpool's serverurl array gives us a second piece of info: TLS - // traffic comes in via stunnel on the loopback-only bind (server - // index 1), so client.server === 1 means this miner is using TLS. + // ckpool's serverurl array gives us a second piece of info: which + // bind the connection arrived on. connectionOf() maps that index to + // the transport the deployment declared, so the lock badge can name + // the certificate in play rather than just "encrypted". const rows = $derived.by(() => { const clients = snap.data?.clients ?? []; const workers = snap.data?.workers ?? []; @@ -52,13 +55,15 @@ seen.add(wname); const w = byWorker.get(wname); const btcAddress = w?.user ?? btcAddressOf(wname); + const conn = connectionOf(c.server, snap.data?.stratum_servers); out.push({ workerName: wname, btcAddress, sourceIp: c.address, hardware: detectHardware(c.useragent), openSource: isOpenSource(c.useragent), - tls: c.server === 1, + tls: conn.tls, + tlsLabel: conn.label, hashrate1m: c.dsps1 * 2 ** 32, hashrate1h: c.dsps60 * 2 ** 32, bestSession: c.bestdiff, @@ -79,6 +84,7 @@ hardware: "offline", openSource: false, tls: false, + tlsLabel: "", hashrate1m: 0, hashrate1h: 0, bestSession: 0, @@ -133,7 +139,7 @@ title="View per-worker stats" >{r.workerName} {#if r.tls} - + diff --git a/ui/src/lib/UserDetailPage.svelte b/ui/src/lib/UserDetailPage.svelte index a35c76a..91c5643 100644 --- a/ui/src/lib/UserDetailPage.svelte +++ b/ui/src/lib/UserDetailPage.svelte @@ -8,6 +8,7 @@ detectHardware, isOpenSource, explorerBaseFor, + connectionOf, } from "../format"; import type { Worker, StratumClient } from "../types"; @@ -46,6 +47,7 @@ hardware: string; openSource: boolean; tls: boolean; + tlsLabel: string; sourceIp: string; hashrate1m: number; hashrate1h: number; @@ -64,7 +66,10 @@ worker: w.worker, hardware: c ? detectHardware(c.useragent) : "offline", openSource: c ? isOpenSource(c.useragent) : false, - tls: c?.server === 1, + tls: !!c && connectionOf(c.server, snap.data?.stratum_servers).tls, + tlsLabel: c + ? connectionOf(c.server, snap.data?.stratum_servers).label + : "", sourceIp: c?.address ?? "", hashrate1m: c ? c.dsps1 * 2 ** 32 : 0, hashrate1h: c ? c.dsps60 * 2 ** 32 : 0, @@ -85,7 +90,8 @@ worker: wname, hardware: detectHardware(c.useragent), openSource: isOpenSource(c.useragent), - tls: c.server === 1, + tls: connectionOf(c.server, snap.data?.stratum_servers).tls, + tlsLabel: connectionOf(c.server, snap.data?.stratum_servers).label, sourceIp: c.address, hashrate1m: c.dsps1 * 2 ** 32, hashrate1h: c.dsps60 * 2 ** 32, @@ -232,7 +238,7 @@ title="View per-worker stats" >{r.worker} {#if r.tls} - + diff --git a/ui/src/lib/WorkerDetailPage.svelte b/ui/src/lib/WorkerDetailPage.svelte index 3d78507..517a82b 100644 --- a/ui/src/lib/WorkerDetailPage.svelte +++ b/ui/src/lib/WorkerDetailPage.svelte @@ -9,6 +9,7 @@ detectHardware, isOpenSource, btcAddressOf, + connectionOf, } from "../format"; import type { Worker, StratumClient } from "../types"; @@ -29,7 +30,12 @@ const idle = $derived(client?.idle ?? worker?.idle ?? false); const hardware = $derived(client ? detectHardware(client.useragent) : "offline"); const openSource = $derived(client ? isOpenSource(client.useragent) : false); - const tls = $derived(client?.server === 1); + const conn = $derived( + client + ? connectionOf(client.server, snap.data?.stratum_servers) + : { tls: false, label: "" }, + ); + const tls = $derived(conn.tls); const hs1m = $derived(client ? client.dsps1 * 2 ** 32 : 0); const hs5m = $derived(client ? client.dsps5 * 2 ** 32 : 0); @@ -88,7 +94,7 @@ title="View per-user stats" >{btcAddress} {#if tls} - + diff --git a/ui/src/types.ts b/ui/src/types.ts index 489980f..0f9f4f9 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -71,9 +71,10 @@ export type StratumClient = { workername: string; userid: number; // Index into ckpool's serverurl[] array; identifies which stratum - // bind the client connected on. We use this to tag TLS clients: - // index 0 is the public plaintext bind, index 1 is the loopback-only - // bind that stunnel forwards TLS traffic to. + // bind the client connected on. What each index *means* depends on + // how the deployment rendered ckpool.conf, so resolve it through + // Snapshot.stratum_servers rather than hardcoding — see + // connectionOf() in format.ts. server: number; bestdiff: number; }; @@ -112,6 +113,16 @@ export type HashratePoint = { v: number; // H/s }; +// One entry per ckpool serverurl[] bind, declared by the deployment via +// the STRATUM_SERVERS env. `kind` is the stable tag the UI switches on; +// `label` is free text shown on hover. +export type StratumServerKind = "plain" | "tls-local" | "tls-public"; + +export type StratumServer = { + kind: StratumServerKind; + label: string; +}; + export type Snapshot = { generated_at: string; pool: PoolStats | null; @@ -137,6 +148,10 @@ export type Snapshot = { // Optional override for explorer links. Empty/undefined means the // UI falls back to its mempool.space defaults. mempool_base_url?: string; + // Describes ckpool's serverurl[] binds: entry N says what a client + // with `server === N` is connected over. Undefined when the + // deployment didn't declare them — see connectionOf() in format.ts. + stratum_servers?: StratumServer[]; // Submission attempt tracking — count of "Possible block solve" // log lines vs "Solved and confirmed" lines. A growing gap means // submissions are being rejected by bitcoind.