ix ckpool.conf render dropping the TLS bind port; Let deployments declare what each stratum bind is

This commit is contained in:
2026-08-01 06:22:11 +03:00
parent 2c4c6609ba
commit 8a35b02c7e
10 changed files with 159 additions and 45 deletions
+5 -2
View File
@@ -59,9 +59,11 @@ The Go API (`kamado-api`) bridges CKPool's Unix socket protocol, bitcoind's JSON
### Stratum TLS ### 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 ### 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_ZMQ_BLOCK` | (disabled) | no | ZMQ hashblock endpoint (e.g. `tcp://127.0.0.1:28332`) |
| `BITCOIN_RPC_TIMEOUT` | `10s` | no | RPC call timeout | | `BITCOIN_RPC_TIMEOUT` | `10s` | no | RPC call timeout |
| `MEMPOOL_BASE_URL` | (mempool.space) | no | Custom mempool explorer URL | | `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 ### UI Development
+13
View File
@@ -7,6 +7,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -65,6 +66,18 @@ func main() {
agg := state.New(ck, rpc, cfg.PollInterval, log) agg := state.New(ck, rpc, cfg.PollInterval, log)
agg.Store = blockStore agg.Store = blockStore
agg.MempoolBaseURL = cfg.MempoolBaseURL 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.LogFilePath = cfg.CKPoolLogFile
agg.KillCKPool = killCKPool(log) agg.KillCKPool = killCKPool(log)
+18 -10
View File
@@ -39,21 +39,29 @@ type Config struct {
// the UI uses the public mempool.space; non-empty means the user // the UI uses the public mempool.space; non-empty means the user
// has pointed Kamado at their own instance via the StartOS config. // has pointed Kamado at their own instance via the StartOS config.
MempoolBaseURL string 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) { func FromEnv() (*Config, error) {
cfg := &Config{ cfg := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
CKPoolSockDir: getenv("CKPOOL_SOCKDIR", "/run/ckpool"), CKPoolSockDir: getenv("CKPOOL_SOCKDIR", "/run/ckpool"),
BitcoinRPCURL: os.Getenv("BITCOIN_RPC_URL"), BitcoinRPCURL: os.Getenv("BITCOIN_RPC_URL"),
BitcoinRPCUser: os.Getenv("BITCOIN_RPC_USER"), BitcoinRPCUser: os.Getenv("BITCOIN_RPC_USER"),
BitcoinRPCPassword: os.Getenv("BITCOIN_RPC_PASSWORD"), BitcoinRPCPassword: os.Getenv("BITCOIN_RPC_PASSWORD"),
BitcoinRPCTimeout: getenvDuration("BITCOIN_RPC_TIMEOUT", 10*time.Second), BitcoinRPCTimeout: getenvDuration("BITCOIN_RPC_TIMEOUT", 10*time.Second),
BitcoinZMQBlock: os.Getenv("BITCOIN_ZMQ_BLOCK"), BitcoinZMQBlock: os.Getenv("BITCOIN_ZMQ_BLOCK"),
CKPoolLogFile: getenv("CKPOOL_LOGFILE", "/var/log/ckpool/ckpool.log"), CKPoolLogFile: getenv("CKPOOL_LOGFILE", "/var/log/ckpool/ckpool.log"),
DBPath: getenv("DB_PATH", "/var/lib/kamado/kamado.db"), DBPath: getenv("DB_PATH", "/var/lib/kamado/kamado.db"),
PollInterval: getenvDuration("POLL_INTERVAL", 5*time.Second), PollInterval: getenvDuration("POLL_INTERVAL", 5*time.Second),
MempoolBaseURL: os.Getenv("MEMPOOL_BASE_URL"), MempoolBaseURL: os.Getenv("MEMPOOL_BASE_URL"),
StratumServersJSON: os.Getenv("STRATUM_SERVERS"),
} }
if cfg.BitcoinRPCURL == "" { if cfg.BitcoinRPCURL == "" {
+49 -20
View File
@@ -25,6 +25,21 @@ type HashratePoint struct {
V float64 `json:"v"` // H/s 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 // Snapshot is the merged view served to the UI. All fields are safe to
// JSON-serialize directly. // JSON-serialize directly.
type Snapshot struct { type Snapshot struct {
@@ -38,7 +53,7 @@ type Snapshot struct {
Clients []ckpool.StratumClient `json:"clients"` Clients []ckpool.StratumClient `json:"clients"`
// Derived/enriched fields // 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"` HashrateHs5m float64 `json:"hashrate_hs_5m"`
HashrateHs1h float64 `json:"hashrate_hs_1h"` HashrateHs1h float64 `json:"hashrate_hs_1h"`
HashrateHs24h float64 `json:"hashrate_hs_24h"` HashrateHs24h float64 `json:"hashrate_hs_24h"`
@@ -68,8 +83,8 @@ type Snapshot struct {
NextDifficultyPercent float64 `json:"next_difficulty_percent"` NextDifficultyPercent float64 `json:"next_difficulty_percent"`
// Bitcoin Core fields // Bitcoin Core fields
Chain *bitcoind.BlockchainInfo `json:"chain"` Chain *bitcoind.BlockchainInfo `json:"chain"`
NetworkHashrateHs float64 `json:"network_hashrate_hs"` NetworkHashrateHs float64 `json:"network_hashrate_hs"`
// Recent found blocks (in-memory history; persisted in Phase 2b.5). // Recent found blocks (in-memory history; persisted in Phase 2b.5).
RecentBlocks []BlockRecord `json:"recent_blocks,omitempty"` RecentBlocks []BlockRecord `json:"recent_blocks,omitempty"`
@@ -82,6 +97,13 @@ type Snapshot struct {
// the StartOS config "Block Explorer" -> "Custom URL". // the StartOS config "Block Explorer" -> "Custom URL".
MempoolBaseURL string `json:"mempool_base_url,omitempty"` 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" // Counts of share-submit attempts ("Possible/Submitting block solve"
// log lines) and confirmed solves ("Solved and confirmed block"). // log lines) and confirmed solves ("Solved and confirmed block").
// A growing gap means bitcoind is rejecting our submissions or // 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 // LastZMQEventAge is the seconds-since the last bitcoind hashblock
// frame arrived; -1 means no event seen since startup. ZMQEnabled // frame arrived; -1 means no event seen since startup. ZMQEnabled
// is whether the user configured an endpoint at all. // is whether the user configured an endpoint at all.
ZMQEnabled bool `json:"zmq_enabled"` ZMQEnabled bool `json:"zmq_enabled"`
LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0 LastZMQEventAge float64 `json:"last_zmq_event_age,omitempty"` // seconds; >=0
HasLastZMQEvent bool `json:"has_last_zmq_event"` HasLastZMQEvent bool `json:"has_last_zmq_event"`
TipChangedAge float64 `json:"tip_changed_age"` // seconds since tip height last changed TipChangedAge float64 `json:"tip_changed_age"` // seconds since tip height last changed
// Bitcoin Core peer connections. // Bitcoin Core peer connections.
PeerCount int `json:"peer_count"` PeerCount int `json:"peer_count"`
PeersIn int `json:"peers_in"` PeersIn int `json:"peers_in"`
PeersOut int `json:"peers_out"` PeersOut int `json:"peers_out"`
// Share counters: raw counts (1 submission = 1 share regardless of diff). // Share counters: raw counts (1 submission = 1 share regardless of diff).
// Session = since ckpool started; AllTime = persisted across restarts. // Session = since ckpool started; AllTime = persisted across restarts.
@@ -151,8 +173,8 @@ const (
kvLatencyLastMs = "latency_last_ms" kvLatencyLastMs = "latency_last_ms"
kvStaleWorkHashes = "stale_work_hashes" kvStaleWorkHashes = "stale_work_hashes"
kvAckedBestDiff = "acked_best_diff" kvAckedBestDiff = "acked_best_diff"
kvBestDiff = "best_diff" kvBestDiff = "best_diff"
kvBestShareHash = "best_share_hash" kvBestShareHash = "best_share_hash"
kvBestShareNetDiff = "best_share_net_diff" kvBestShareNetDiff = "best_share_net_diff"
@@ -186,6 +208,10 @@ type Aggregator struct {
// MempoolBaseURL leaves the UI on its mempool.space defaults. // MempoolBaseURL leaves the UI on its mempool.space defaults.
MempoolBaseURL string 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 // LogFilePath is the ckpool log path, used for one-time backfill
// of the best share hash when upgrading from a version that didn't // of the best share hash when upgrading from a version that didn't
// capture it. Set by main before calling Run. // capture it. Set by main before calling Run.
@@ -220,10 +246,10 @@ type Aggregator struct {
// post-load observation of pool.Shares only establishes the // post-load observation of pool.Shares only establishes the
// baseline — the current ckpool counter was already accounted for // baseline — the current ckpool counter was already accounted for
// before we crashed. // before we crashed.
cumulativeShares float64 cumulativeShares float64
lastPoolShares float64 lastPoolShares float64
hasPoolSharesBaseline bool hasPoolSharesBaseline bool
lastCumulativeSave time.Time lastCumulativeSave time.Time
// Next-block reward cache; refreshed on its own cadence so we don't // Next-block reward cache; refreshed on its own cadence so we don't
// hammer bitcoind with getblocktemplate on every poll tick. // hammer bitcoind with getblocktemplate on every poll tick.
@@ -245,8 +271,8 @@ type Aggregator struct {
// btcFailStreak counts consecutive refreshes where bitcoind was // btcFailStreak counts consecutive refreshes where bitcoind was
// unreachable. After a threshold, if no block submission is pending, // unreachable. After a threshold, if no block submission is pending,
// we kill the ckpool process so miners can failover to other pools. // we kill the ckpool process so miners can failover to other pools.
btcFailStreak int btcFailStreak int
ckpoolKilled bool // true after we sent SIGTERM, reset on bitcoind recovery ckpoolKilled bool // true after we sent SIGTERM, reset on bitcoind recovery
// readyOnce + ready closes the Ready() channel exactly once after // readyOnce + ready closes the Ready() channel exactly once after
// the first refresh completes. main blocks briefly on this so the // 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) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() 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 --- // --- ckpool: poolstats, users, workers, clients, uptime ---
if ps, err := a.CK.PoolStats(ctx); err == nil { 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. // DiffBucketLabels are the human-readable labels for each difficulty bucket.
var DiffBucketLabels = [6]string{"< 1M", "1M 100M", "100M 1G", "1G 100G", "100G 1T", "≥ 1T"} var DiffBucketLabels = [6]string{"< 1M", "1M 100M", "100M 1G", "1G 100G", "100G 1T", "≥ 1T"}
// IngestShareEvents reads individual share events from the log tailer // IngestShareEvents reads individual share events from the log tailer
// and maintains rejection-reason counts and difficulty-distribution // and maintains rejection-reason counts and difficulty-distribution
// histograms. Session counters reset when ckpool restarts (detected by // histograms. Session counters reset when ckpool restarts (detected by
+6
View File
@@ -49,6 +49,11 @@ LOGDIR="${LOGDIR:-/var/log/ckpool}"
SOCKET_DIR="${SOCKET_DIR:-/run/ckpool}" SOCKET_DIR="${SOCKET_DIR:-/run/ckpool}"
SHARE_LOG="${SHARE_LOG:-1}" SHARE_LOG="${SHARE_LOG:-1}"
CKPOOL_LOGLEVEL="${CKPOOL_LOGLEVEL:-6}" 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" mkdir -p "$LOGDIR" "$SOCKET_DIR"
@@ -68,6 +73,7 @@ sed \
-e "s|\${BLOCKPOLL_MS}|${BLOCKPOLL_MS}|g" \ -e "s|\${BLOCKPOLL_MS}|${BLOCKPOLL_MS}|g" \
-e "s|\${UPDATE_INTERVAL_S}|${UPDATE_INTERVAL_S}|g" \ -e "s|\${UPDATE_INTERVAL_S}|${UPDATE_INTERVAL_S}|g" \
-e "s|\${STRATUM_PORT}|${STRATUM_PORT}|g" \ -e "s|\${STRATUM_PORT}|${STRATUM_PORT}|g" \
-e "s|\${TLS_INTERNAL_PORT}|${TLS_INTERNAL_PORT}|g" \
-e "s|\${MINDIFF}|${MINDIFF}|g" \ -e "s|\${MINDIFF}|${MINDIFF}|g" \
-e "s|\${STARTDIFF}|${STARTDIFF}|g" \ -e "s|\${STARTDIFF}|${STARTDIFF}|g" \
-e "s|\${MAXDIFF}|${MAXDIFF}|g" \ -e "s|\${MAXDIFF}|${MAXDIFF}|g" \
+22
View File
@@ -1,6 +1,8 @@
// Formatters for hashrate, share difficulty, time, and miner hardware // Formatters for hashrate, share difficulty, time, and miner hardware
// detection from stratum user-agent strings. Pure functions, no DOM. // 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"]; const HASHRATE_UNITS = ["H/s", "kH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"];
export function formatHashrate(hs: number): string { export function formatHashrate(hs: number): string {
@@ -100,6 +102,26 @@ export function explorerBaseFor(
return "https://mempool.space"; 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 // Detect common Bitcoin mining hardware from the stratum useragent
// string. This is a best-effort heuristic; unknown agents fall back // string. This is a best-effort heuristic; unknown agents fall back
// to a stripped version of the raw string. Order matters — check // to a stripped version of the raw string. Order matters — check
+11 -5
View File
@@ -8,6 +8,7 @@
detectHardware, detectHardware,
isOpenSource, isOpenSource,
btcAddressOf, btcAddressOf,
connectionOf,
} from "../format"; } from "../format";
import type { StratumClient, Worker } from "../types"; import type { StratumClient, Worker } from "../types";
@@ -18,6 +19,7 @@
hardware: string; hardware: string;
openSource: boolean; openSource: boolean;
tls: boolean; tls: boolean;
tlsLabel: string;
hashrate1m: number; hashrate1m: number;
hashrate1h: number; hashrate1h: number;
bestSession: number; bestSession: number;
@@ -35,9 +37,10 @@
// from the joined worker_instance, which ckpool keys by full // from the joined worker_instance, which ckpool keys by full
// workername with user = the BTC. // workername with user = the BTC.
// //
// ckpool's serverurl array gives us a second piece of info: TLS // ckpool's serverurl array gives us a second piece of info: which
// traffic comes in via stunnel on the loopback-only bind (server // bind the connection arrived on. connectionOf() maps that index to
// index 1), so client.server === 1 means this miner is using TLS. // the transport the deployment declared, so the lock badge can name
// the certificate in play rather than just "encrypted".
const rows = $derived.by<Row[]>(() => { const rows = $derived.by<Row[]>(() => {
const clients = snap.data?.clients ?? []; const clients = snap.data?.clients ?? [];
const workers = snap.data?.workers ?? []; const workers = snap.data?.workers ?? [];
@@ -52,13 +55,15 @@
seen.add(wname); seen.add(wname);
const w = byWorker.get(wname); const w = byWorker.get(wname);
const btcAddress = w?.user ?? btcAddressOf(wname); const btcAddress = w?.user ?? btcAddressOf(wname);
const conn = connectionOf(c.server, snap.data?.stratum_servers);
out.push({ out.push({
workerName: wname, workerName: wname,
btcAddress, btcAddress,
sourceIp: c.address, sourceIp: c.address,
hardware: detectHardware(c.useragent), hardware: detectHardware(c.useragent),
openSource: isOpenSource(c.useragent), openSource: isOpenSource(c.useragent),
tls: c.server === 1, tls: conn.tls,
tlsLabel: conn.label,
hashrate1m: c.dsps1 * 2 ** 32, hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32, hashrate1h: c.dsps60 * 2 ** 32,
bestSession: c.bestdiff, bestSession: c.bestdiff,
@@ -79,6 +84,7 @@
hardware: "offline", hardware: "offline",
openSource: false, openSource: false,
tls: false, tls: false,
tlsLabel: "",
hashrate1m: 0, hashrate1m: 0,
hashrate1h: 0, hashrate1h: 0,
bestSession: 0, bestSession: 0,
@@ -133,7 +139,7 @@
title="View per-worker stats" title="View per-worker stats"
>{r.workerName}</button> >{r.workerName}</button>
{#if r.tls} {#if r.tls}
<span class="badge tls" title="Encrypted connection via TLS"> <span class="badge tls" title={r.tlsLabel}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/> <path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/>
</svg> </svg>
+9 -3
View File
@@ -8,6 +8,7 @@
detectHardware, detectHardware,
isOpenSource, isOpenSource,
explorerBaseFor, explorerBaseFor,
connectionOf,
} from "../format"; } from "../format";
import type { Worker, StratumClient } from "../types"; import type { Worker, StratumClient } from "../types";
@@ -46,6 +47,7 @@
hardware: string; hardware: string;
openSource: boolean; openSource: boolean;
tls: boolean; tls: boolean;
tlsLabel: string;
sourceIp: string; sourceIp: string;
hashrate1m: number; hashrate1m: number;
hashrate1h: number; hashrate1h: number;
@@ -64,7 +66,10 @@
worker: w.worker, worker: w.worker,
hardware: c ? detectHardware(c.useragent) : "offline", hardware: c ? detectHardware(c.useragent) : "offline",
openSource: c ? isOpenSource(c.useragent) : false, 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 ?? "", sourceIp: c?.address ?? "",
hashrate1m: c ? c.dsps1 * 2 ** 32 : 0, hashrate1m: c ? c.dsps1 * 2 ** 32 : 0,
hashrate1h: c ? c.dsps60 * 2 ** 32 : 0, hashrate1h: c ? c.dsps60 * 2 ** 32 : 0,
@@ -85,7 +90,8 @@
worker: wname, worker: wname,
hardware: detectHardware(c.useragent), hardware: detectHardware(c.useragent),
openSource: isOpenSource(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, sourceIp: c.address,
hashrate1m: c.dsps1 * 2 ** 32, hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32, hashrate1h: c.dsps60 * 2 ** 32,
@@ -232,7 +238,7 @@
title="View per-worker stats" title="View per-worker stats"
>{r.worker}</button> >{r.worker}</button>
{#if r.tls} {#if r.tls}
<span class="badge tls" title="Encrypted connection via TLS"> <span class="badge tls" title={r.tlsLabel}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/> <path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/>
</svg> </svg>
+8 -2
View File
@@ -9,6 +9,7 @@
detectHardware, detectHardware,
isOpenSource, isOpenSource,
btcAddressOf, btcAddressOf,
connectionOf,
} from "../format"; } from "../format";
import type { Worker, StratumClient } from "../types"; import type { Worker, StratumClient } from "../types";
@@ -29,7 +30,12 @@
const idle = $derived(client?.idle ?? worker?.idle ?? false); const idle = $derived(client?.idle ?? worker?.idle ?? false);
const hardware = $derived(client ? detectHardware(client.useragent) : "offline"); const hardware = $derived(client ? detectHardware(client.useragent) : "offline");
const openSource = $derived(client ? isOpenSource(client.useragent) : false); 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 hs1m = $derived(client ? client.dsps1 * 2 ** 32 : 0);
const hs5m = $derived(client ? client.dsps5 * 2 ** 32 : 0); const hs5m = $derived(client ? client.dsps5 * 2 ** 32 : 0);
@@ -88,7 +94,7 @@
title="View per-user stats" title="View per-user stats"
>{btcAddress}</button> >{btcAddress}</button>
{#if tls} {#if tls}
<span class="badge tls" title="Encrypted connection via TLS"> <span class="badge tls" title={conn.label}>
<svg viewBox="0 0 16 16" aria-hidden="true"> <svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/> <path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/>
</svg> </svg>
+18 -3
View File
@@ -71,9 +71,10 @@ export type StratumClient = {
workername: string; workername: string;
userid: number; userid: number;
// Index into ckpool's serverurl[] array; identifies which stratum // Index into ckpool's serverurl[] array; identifies which stratum
// bind the client connected on. We use this to tag TLS clients: // bind the client connected on. What each index *means* depends on
// index 0 is the public plaintext bind, index 1 is the loopback-only // how the deployment rendered ckpool.conf, so resolve it through
// bind that stunnel forwards TLS traffic to. // Snapshot.stratum_servers rather than hardcoding — see
// connectionOf() in format.ts.
server: number; server: number;
bestdiff: number; bestdiff: number;
}; };
@@ -112,6 +113,16 @@ export type HashratePoint = {
v: number; // H/s 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 = { export type Snapshot = {
generated_at: string; generated_at: string;
pool: PoolStats | null; pool: PoolStats | null;
@@ -137,6 +148,10 @@ export type Snapshot = {
// Optional override for explorer links. Empty/undefined means the // Optional override for explorer links. Empty/undefined means the
// UI falls back to its mempool.space defaults. // UI falls back to its mempool.space defaults.
mempool_base_url?: string; 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" // Submission attempt tracking — count of "Possible block solve"
// log lines vs "Solved and confirmed" lines. A growing gap means // log lines vs "Solved and confirmed" lines. A growing gap means
// submissions are being rejected by bitcoind. // submissions are being rejected by bitcoind.