Expose config options for cleartext stratum port

This commit is contained in:
2026-08-01 06:31:15 +03:00
commit a2ae800a19
36 changed files with 4585 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import { storeJson } from '../fileModels/store.json'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import {
defaultStratumPort,
defaultStratumTlsPort,
logLevels,
validatePorts,
} from '../utils'
const { InputSpec, Value } = sdk
export const inputSpec = InputSpec.of({
stratumPort: Value.number({
name: i18n('Stratum Port'),
description: i18n(
'TCP port the plaintext stratum server listens on. StartOS also tries to publish the pool on this same port number on your network, so this is normally the port you give your miners — check the Stratum interface after saving, since the OS will pick a different external port if this one is already taken.',
),
required: true,
default: defaultStratumPort,
integer: true,
min: 1,
max: 65535,
}),
stratumTlsPort: Value.number({
name: i18n('Stratum TLS Port'),
description: i18n(
'TCP port stunnel accepts TLS stratum connections on. Only used when Stratum TLS is enabled below.',
),
required: true,
default: defaultStratumTlsPort,
integer: true,
min: 1,
max: 65535,
}),
coinbaseTag: Value.text({
name: i18n('Coinbase Tag'),
description: i18n(
'Short string embedded in the coinbase transaction of solved blocks.',
),
required: true,
default: '/Kamado/',
patterns: [],
}),
zmqEnabled: Value.toggle({
name: i18n('ZMQ Block Notifications'),
description: i18n(
"Subscribe to Bitcoin Core's hashblock ZMQ topic for sub-second block detection. RPC polling remains active as a fallback either way.",
),
default: true,
}),
tlsEnabled: Value.toggle({
name: i18n('Stratum TLS'),
description: i18n(
'Accept stratum connections over TLS via an stunnel sidecar on a second port. A self-signed certificate is generated once and persisted — miners must trust the certificate (see the Stratum TLS Certificate action) or connect with verification disabled.',
),
default: false,
}),
startDiff: Value.number({
name: i18n('Starting Difficulty'),
description: i18n(
'Initial vardiff target for new miner connections. Bitaxe-class miners typically land around 16384.',
),
required: true,
default: 16384,
integer: true,
min: 1,
}),
minDiff: Value.number({
name: i18n('Minimum Difficulty'),
description: i18n('Floor for the vardiff algorithm.'),
required: true,
default: 1000,
integer: true,
min: 1,
}),
maxDiff: Value.number({
name: i18n('Maximum Difficulty'),
description: i18n('Ceiling for the vardiff algorithm. 0 means no cap.'),
required: true,
default: 0,
integer: true,
min: 0,
}),
dropIdle: Value.number({
name: i18n('Drop Idle (seconds)'),
description: i18n(
'Disconnect clients that have not submitted a share in this many seconds. 0 disables the idle disconnect.',
),
required: true,
default: 0,
integer: true,
min: 0,
units: i18n('seconds'),
}),
logLevel: Value.select({
name: i18n('Log Level'),
description: i18n('Verbosity of the kamado-api log output.'),
values: logLevels,
default: 'info',
}),
mempoolExplorerUrl: Value.text({
name: i18n('Custom Block Explorer URL'),
description: i18n(
'Base URL of a self-hosted mempool instance for dashboard links (e.g. https://mempool.example.com). Kamado appends /address/<addr> and /block/<hash>, so the instance must follow the standard mempool.space URL layout. Leave empty to use the public mempool.space.',
),
required: false,
default: null,
patterns: [
{
regex: '^https?://[^\\s]+$',
description: i18n(
'Must be an http:// or https:// URL with no whitespace',
),
},
],
}),
})
export const config = sdk.Action.withInput(
// id
'config',
// metadata
async ({ effects }) => ({
name: i18n('Configure'),
description: i18n(
'Customize vardiff, TLS, block notifications, logging, and explorer links',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// form input specification
inputSpec,
// optionally pre-fill the input form
async ({ effects }) => storeJson.read().once(),
// the execution function
async ({ effects, input }) => {
// Refuse port choices that cannot bind — all of these processes share one
// network namespace, so a collision would surface as a restart loop after
// saving rather than as an error here.
const conflict = validatePorts({
stratumPort: input.stratumPort,
stratumTlsPort: input.stratumTlsPort,
tlsEnabled: input.tlsEnabled,
})
if (conflict) throw new Error(conflict)
return storeJson.merge(effects, input)
},
)
+13
View File
@@ -0,0 +1,13 @@
import { sdk } from '../sdk'
import { config } from './config'
import { poolStatus } from './poolStatus'
import { regenTlsCert } from './regenTlsCert'
import { resetLatency } from './resetLatency'
import { showTlsCert } from './showTlsCert'
export const actions = sdk.Actions.of()
.addAction(config)
.addAction(poolStatus)
.addAction(showTlsCert)
.addAction(regenTlsCert)
.addAction(resetLatency)
+390
View File
@@ -0,0 +1,390 @@
import { storeJson } from '../fileModels/store.json'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { curlJson, defaultStratumPort, uiPort } from '../utils'
const API_BASE = `http://127.0.0.1:${uiPort}`
// ── formatting helpers ────────────────────────────────────────────────────────
function ts(): string {
return new Date().toISOString().slice(11, 22)
}
function fmtHR(hs: number): string {
if (hs <= 0) return '0 H/s'
if (hs >= 1e18) return `${(hs / 1e18).toFixed(2)} EH/s`
if (hs >= 1e15) return `${(hs / 1e15).toFixed(2)} PH/s`
if (hs >= 1e12) return `${(hs / 1e12).toFixed(2)} TH/s`
if (hs >= 1e9) return `${(hs / 1e9).toFixed(2)} GH/s`
if (hs >= 1e6) return `${(hs / 1e6).toFixed(2)} MH/s`
if (hs >= 1e3) return `${(hs / 1e3).toFixed(2)} kH/s`
return `${hs.toFixed(0)} H/s`
}
function fmtDiff(d: number): string {
if (d <= 0) return '0'
if (d >= 1e12) return `${(d / 1e12).toFixed(2)}T`
if (d >= 1e9) return `${(d / 1e9).toFixed(2)}G`
if (d >= 1e6) return `${(d / 1e6).toFixed(2)}M`
if (d >= 1e3) return `${(d / 1e3).toFixed(2)}K`
return d.toFixed(2)
}
function fmtUptime(s: number): string {
if (s <= 0) return '0m'
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
if (d > 0) return `${d}d ${h}h ${m}m`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
function fmtBTC(btc: number): string {
return `${btc.toFixed(8)} BTC`
}
function pad(s: string, w: number): string {
return s.padEnd(w)
}
const chainNames: Record<string, string> = { main: 'mainnet' }
function displayChain(c: string): string {
return chainNames[c] ?? c
}
// ── snapshot shape ────────────────────────────────────────────────────────────
interface PoolStats {
workers: number
users: number
accepted: number
rejected: number
shares: number
dsps1: number
dsps5: number
dsps60: number
dsps1440: number
}
interface Chain {
chain: string
blocks: number
headers: number
difficulty: number
initialblockdownload: boolean
verificationprogress: number
bestblockhash: string
}
interface Worker {
worker: string
dsps1: number
bestdiff: number
bestever: number
idle: boolean
}
interface Client {
id: number
workername: string
diff: number
dsps1: number
useragent: string
}
interface BlockRecord {
height: number
hash?: string
reward_btc?: number
found_at: string
orphaned_at?: string
chain?: string
}
interface Snapshot {
pool: PoolStats | null
uptime_seconds: number
hashrate_hs_1m: number
hashrate_hs_5m: number
hashrate_hs_1h: number
hashrate_hs_24h: number
best_diff: number
cumulative_shares: number
next_block_reward_btc: number
next_difficulty_percent: number
chain: Chain | null
network_hashrate_hs: number
recent_blocks: BlockRecord[]
ckpool_ok: boolean
bitcoin_ok: boolean
last_error?: string
block_submit_attempts: number
block_submits_confirmed: number
zmq_enabled: boolean
zmq_stale: boolean
has_last_zmq_event: boolean
last_zmq_event_age: number
workers: Worker[]
clients: Client[]
}
interface DebugBlocks {
memory: {
height: number
hash: string
chain: string
orphaned_at: string
found_at: string
}[]
db: {
height: number
hash: string
chain: string
orphaned_at: string
found_at: string
}[]
}
export const poolStatus = sdk.Action.withoutInput(
// id
'pool-status',
// metadata
async ({ effects }) => ({
name: i18n('Pool Status'),
description: i18n(
'Displays a full status snapshot: Bitcoin Core sync state, ckpool health, connected miners, hashrate, found blocks, and submit-gap diagnostics.',
),
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
}),
// the execution function
async ({ effects }) => {
const stratumPort =
(await storeJson.read((s) => s.stratumPort).once()) ?? defaultStratumPort
// Fetch from inside the service's network namespace: temp subcontainers
// share it, so curl reaches kamado-api on 127.0.0.1.
const { snap, dbg } = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'main' },
null,
'pool-status',
async (sub) => ({
snap: await curlJson<Snapshot>(sub, `${API_BASE}/api/snapshot`),
dbg: await curlJson<DebugBlocks>(
sub,
`${API_BASE}/api/admin/debug-blocks`,
),
}),
)
const lines: string[] = []
const log = (s: string) => lines.push(`[${ts()}] ${s}`)
const sep = () => log('──────────────────────────────────────────')
let allPass = true
log('Kamado Pool — Status')
sep()
// ── 1. Full snapshot ────────────────────────────────────────────────────
log('● Pool & Bitcoin Core status')
if (!snap) {
log(' API unreachable — service may still be starting.')
allPass = false
}
if (snap) {
const chain = snap.chain
const pool = snap.pool
log(` Bitcoin Core: ${snap.bitcoin_ok ? 'OK' : 'FAIL'}`)
if (chain) {
const ibd = chain.initialblockdownload
const syncPct = (chain.verificationprogress * 100).toFixed(2)
log(
` Network: ${chain.chain} height=${chain.blocks} headers=${chain.headers}`,
)
log(
` Sync: ${
ibd
? `IBD — ${syncPct}% (pool will not mine until synced)`
: `${syncPct}% — fully synced`
}`,
)
log(` Block hash: ${chain.bestblockhash}`)
log(` Difficulty: ${fmtDiff(chain.difficulty)}`)
log(` Network hashrate: ${fmtHR(snap.network_hashrate_hs)}`)
if (ibd) allPass = false
}
if (!snap.bitcoin_ok) allPass = false
if (snap.next_block_reward_btc > 0)
log(` Next block reward: ${fmtBTC(snap.next_block_reward_btc)}`)
if (snap.next_difficulty_percent !== 0) {
const sign = snap.next_difficulty_percent > 0 ? '+' : ''
log(
` Next diff adjust: ${sign}${snap.next_difficulty_percent.toFixed(1)}%`,
)
}
if (snap.zmq_enabled) {
const age = snap.has_last_zmq_event
? `last event ${snap.last_zmq_event_age.toFixed(0)}s ago`
: 'no event yet since startup'
log(
` ZMQ: ${snap.zmq_stale ? 'STALE — ' : 'OK — '}${age}`,
)
if (snap.zmq_stale) allPass = false
} else {
log(` ZMQ: disabled`)
}
log('')
log(` ckpool process: ${snap.ckpool_ok ? 'OK' : 'FAIL'}`)
if (!snap.ckpool_ok) allPass = false
if (snap.last_error) log(` last_error: ${snap.last_error}`)
if (pool) {
log(` Uptime: ${fmtUptime(snap.uptime_seconds)}`)
log(
` Workers online: ${pool.workers} (${pool.users} user${
pool.users !== 1 ? 's' : ''
})`,
)
log(` Shares accepted: ${pool.accepted} rejected: ${pool.rejected}`)
log(` Hashrate 1m: ${fmtHR(snap.hashrate_hs_1m)}`)
log(` Hashrate 5m: ${fmtHR(snap.hashrate_hs_5m)}`)
log(` Hashrate 1h: ${fmtHR(snap.hashrate_hs_1h)}`)
log(` Hashrate 24h: ${fmtHR(snap.hashrate_hs_24h)}`)
log(` Best share ever: diff ${fmtDiff(snap.best_diff)}`)
log(
` Cumul. work: ${fmtDiff(snap.cumulative_shares)} diff-1 shares`,
)
}
const gap = snap.block_submit_attempts - snap.block_submits_confirmed
log(
` Submit attempts: ${snap.block_submit_attempts} confirmed: ${snap.block_submits_confirmed}`,
)
log(
` Submit gap: ${gap > 0 ? `WARN — ${gap} unconfirmed` : 'OK (all confirmed)'}`,
)
if (gap > 0) allPass = false
if (snap.clients && snap.clients.length > 0) {
log('')
log(`● Connected miners (${snap.clients.length})`)
for (const c of snap.clients) {
const tag = c.useragent ? ` [${c.useragent}]` : ''
log(
` ${pad(c.workername || `client#${c.id}`, 30)} diff=${fmtDiff(
c.diff,
)} ${fmtHR(c.dsps1 * 4294967296)}${tag}`,
)
}
} else {
log('')
log('● Connected miners: none')
}
if (snap.workers && snap.workers.length > 0) {
log('')
log('● Workers — best shares')
for (const w of snap.workers) {
const ever = w.bestever > 0 ? w.bestever : w.bestdiff
log(
` ${pad(w.worker, 30)} best=${fmtDiff(ever)}${w.idle ? ' (idle)' : ''}`,
)
}
}
log('')
if (snap.recent_blocks && snap.recent_blocks.length > 0) {
const currentNetwork = chain?.chain ?? ''
log(`● Blocks found (${snap.recent_blocks.length})`)
for (const b of snap.recent_blocks) {
let status = ''
if (b.orphaned_at) {
status = ' ORPHANED'
} else if (b.chain && currentNetwork && b.chain !== currentNetwork) {
status = ` [${displayChain(b.chain)}]`
}
const reward = b.reward_btc ? ` ${fmtBTC(b.reward_btc)}` : ''
const hash = b.hash ? ` ${b.hash.slice(0, 16)}` : ''
log(
` height=${b.height} ${b.found_at.slice(0, 19)}${reward}${hash}${status}`,
)
log(
` chain="${b.chain ?? ''}" orphaned_at="${b.orphaned_at ?? ''}" hash="${b.hash ?? ''}"`,
)
}
} else {
log('● Blocks found: none yet')
}
}
// ── 1b. Debug: memory vs DB comparison ─────────────────────────────────
if (dbg) {
log('')
log('● Debug: memory vs DB')
log(
` Memory blocks: ${dbg.memory?.length ?? 0} DB blocks: ${dbg.db?.length ?? 0}`,
)
if (dbg.memory && dbg.memory.length > 0) {
for (const m of dbg.memory) {
const dbRow = dbg.db?.find((d) => d.height === m.height)
const memOrph = m.orphaned_at || 'none'
const dbOrph = dbRow?.orphaned_at || 'none'
const match = memOrph === dbOrph ? '✓' : 'MISMATCH'
log(
` h=${m.height} mem_orphan="${memOrph}" db_orphan="${dbOrph}" ${match}`,
)
if (memOrph !== dbOrph) {
log(
` mem: chain="${m.chain}" hash="${(m.hash || '').slice(0, 20)}…"`,
)
log(
` db: chain="${dbRow?.chain ?? '?'}" hash="${(dbRow?.hash || '').slice(0, 20)}…"`,
)
}
}
}
} else {
log(' (debug endpoint unavailable)')
}
// ── 2. Stratum ───────────────────────────────────────────────────────────
log('')
sep()
log(`● Stratum (port ${stratumPort})`)
if (snap?.ckpool_ok) {
log(
' ckpool process: healthy — stratum port is served by the same process',
)
} else {
log(
' FAIL — ckpool is not running, stratum port will not accept connections',
)
}
// ── 3. Overall ──────────────────────────────────────────────────────────
log('')
sep()
const pass = allPass && (snap?.ckpool_ok ?? false)
const summary = pass
? i18n('PASS — pool is healthy')
: i18n('FAIL — see details above')
log(`Overall: ${summary}`)
return {
version: '1',
title: i18n('Pool Status'),
message: summary,
result: {
type: 'single',
value: lines.join('\n'),
copyable: true,
qr: false,
masked: false,
},
}
},
)
+74
View File
@@ -0,0 +1,74 @@
import { rm } from 'node:fs/promises'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { tlsVolumeFiles } from '../utils'
export const regenTlsCert = sdk.Action.withoutInput(
// id
'regen-tls-cert',
// metadata
async ({ effects }) => ({
name: i18n('Regenerate TLS Certificate'),
description: i18n(
'Clears the current stratum TLS certificate so a fresh one is generated on the next service start. Use this to rotate an expired or untrusted certificate.',
),
warning: i18n(
'Miners connected via TLS will be disconnected on restart and will need to accept or re-pin the new certificate fingerprint.',
),
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
// the execution function
async ({ effects }) => {
const removed: string[] = []
const missing: string[] = []
for (const f of tlsVolumeFiles) {
const path = sdk.volumes.main.subpath(f)
try {
await rm(path)
removed.push(f.replace(/^tls\//, ''))
} catch {
missing.push(f.replace(/^tls\//, ''))
}
}
const hadCert = removed.length > 0
const message = hadCert
? i18n(
'TLS certificate cleared — restart the service to generate a new one',
)
: i18n('No TLS certificate files found (TLS may not have been enabled)')
const detail = [
hadCert
? `${i18n('Removed')}: ${removed.join(', ')}`
: i18n('No certificate files were present.'),
missing.length > 0
? `${i18n('Already absent')}: ${missing.join(', ')}`
: '',
'',
i18n('Next steps:'),
` 1. ${i18n('Restart Kamado Pool.')}`,
` 2. ${i18n('Run the Stratum TLS Certificate action to see the new fingerprint and PEM.')}`,
` 3. ${i18n('Provide the new fingerprint or PEM to miners that pin the certificate.')}`,
]
.filter(Boolean)
.join('\n')
return {
version: '1',
title: i18n('Regenerate TLS Certificate'),
message,
result: {
type: 'single',
value: detail,
copyable: false,
qr: false,
masked: false,
},
}
},
)
+68
View File
@@ -0,0 +1,68 @@
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { uiPort } from '../utils'
export const resetLatency = sdk.Action.withoutInput(
// id
'reset-latency',
// metadata
async ({ effects }) => ({
name: i18n('Reset Block Latency'),
description: i18n(
'Zeroes the block-update latency counters (avg, last, wasted work, block count). Use this after tuning ZMQ or ckpool to start fresh measurements.',
),
warning: null,
allowedStatuses: 'only-running',
group: null,
visibility: 'enabled',
}),
// the execution function
async ({ effects }) => {
const ok = await sdk.SubContainer.withTemp(
effects,
{ imageId: 'main' },
null,
'reset-latency',
async (sub) => {
const res = await sub.exec([
'curl',
'-sf',
'--max-time',
'10',
'-X',
'POST',
`http://127.0.0.1:${uiPort}/api/admin/reset-latency`,
])
return res.exitCode === 0
},
)
if (!ok) {
return {
version: '1',
title: i18n('Reset Block Latency'),
message: i18n(
'Failed to reset latency stats — the Kamado API did not respond',
),
result: null,
}
}
return {
version: '1',
title: i18n('Reset Block Latency'),
message: i18n('Block latency stats reset to zero'),
result: {
type: 'single',
value: i18n(
'All latency counters (count, avg, last, wasted work) have been cleared. New measurements will accumulate from the next block.',
),
copyable: false,
qr: false,
masked: false,
},
}
},
)
+96
View File
@@ -0,0 +1,96 @@
import { readFile } from 'node:fs/promises'
import { storeJson } from '../fileModels/store.json'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { defaultStratumTlsPort } from '../utils'
/**
* 0.4.0 replacement for the 0.3.x "properties" that published the stratum
* TLS fingerprint and full PEM. Miners whose firmware verifies against a CA
* bundle (AxeOS / Bitaxe) need the PEM pasted in as a custom root, or the
* SHA-256 fingerprint pinned, depending on what the firmware exposes.
*/
export const showTlsCert = sdk.Action.withoutInput(
// id
'show-tls-cert',
// metadata
async ({ effects }) => ({
name: i18n('Stratum TLS Certificate'),
description: i18n(
'Shows the self-signed stratum TLS certificate: SHA-256 fingerprint for pinning and the full PEM to paste into miner firmware (e.g. the AxeOS "Stratum SSL Cert" field).',
),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: (await storeJson.read((s) => s.tlsEnabled).const(effects))
? 'enabled'
: { disabled: i18n('Enable Stratum TLS in Configure first') },
}),
// the execution function
async ({ effects }) => {
const tlsPort =
(await storeJson.read((s) => s.stratumTlsPort).once()) ??
defaultStratumTlsPort
const notYet = i18n('(not yet generated — start the service once)')
const fingerprint = await readFile(
sdk.volumes.main.subpath('tls/fingerprint.txt'),
'utf-8',
)
.then((s) => s.trim())
.catch(() => notYet)
const certPem = await readFile(
sdk.volumes.main.subpath('tls/stratum.crt'),
'utf-8',
)
.then((s) => s.trim())
.catch(() => notYet)
return {
version: '1',
title: i18n('Stratum TLS Certificate'),
message: i18n(
'Connect miners with stratum+ssl:// to the Stratum (TLS) interface. The certificate is self-signed: paste the PEM into firmware that accepts a custom root, pin the fingerprint, or disable verification.',
),
result: {
type: 'group',
value: [
{
name: i18n('TLS Port (internal)'),
description: i18n(
'Container-side TLS stratum port. The externally reachable port is shown on the Stratum (TLS) interface.',
),
type: 'single',
value: String(tlsPort),
copyable: true,
qr: false,
masked: false,
},
{
name: i18n('Fingerprint (SHA-256)'),
description: i18n(
'Use this for fingerprint pinning on miner firmwares that support it. Changes only when the certificate is regenerated.',
),
type: 'single',
value: fingerprint,
copyable: true,
qr: false,
masked: false,
},
{
name: i18n('Certificate (PEM)'),
description: i18n(
'Full self-signed certificate. Copy the whole block including the BEGIN/END CERTIFICATE markers.',
),
type: 'single',
value: certPem,
copyable: true,
qr: false,
masked: false,
},
],
},
}
},
)