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
+390
View File
@@ -0,0 +1,390 @@
import { FileHelper } from '@start9labs/start-sdk'
import { manifest as bitcoindManifest } from 'bitcoin-core-startos/startos/manifest'
import { mkdir, writeFile } from 'node:fs/promises'
import { storeJson } from './fileModels/store.json'
import { i18n } from './i18n'
import { sdk } from './sdk'
import {
bitcoindBridge,
btcMountpoint,
ckpoolLogDir,
ckpoolLogFile,
ckpoolRoot,
ckpoolSocketDir,
curlJson,
HealthPayload,
kamadoDataDir,
kamadoDbPath,
kamadoRoot,
parseCookie,
tlsDir,
tlsInternalPort,
uiPort,
} from './utils'
const healthUrl = `http://127.0.0.1:${uiPort}/api/health`
export const main = sdk.setupMain(async ({ effects }) => {
/**
* ======================== Setup ========================
*/
console.info('Starting Kamado Pool!')
// Service settings; reactive, so any config-action change restarts the
// daemons with a freshly rendered ckpool.conf.
const store = await storeJson.read().const(effects)
if (!store) throw new Error('No store.json')
// bitcoind's RPC + ZMQ endpoints over the LXC bridge (see bitcoindBridge in
// utils.ts). Each resolves null while bitcoind is absent; the .const()
// watches heal main with a restart when bitcoind appears, disappears, or
// changes ports — and never on a routine bitcoind update.
const bitcoind = await bitcoindBridge(effects)
// All Kamado processes (kamado-api, ckpool, stunnel) share ONE
// subcontainer, mirroring the single 0.3.x container: kamado-api reaches
// ckpool's Unix socket in /run/ckpool and tails its log file without any
// cross-container plumbing.
const kamadoSub = await sdk.SubContainer.eager(
effects,
{ imageId: 'main' },
sdk.Mounts.of()
.mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: kamadoRoot,
readonly: false,
})
.mountVolume({
volumeId: 'ckpool',
subpath: null,
mountpoint: ckpoolRoot,
readonly: false,
})
.mountDependency<typeof bitcoindManifest>({
dependencyId: 'bitcoind',
volumeId: 'main',
subpath: null,
mountpoint: btcMountpoint,
readonly: true,
}),
'kamado',
)
// bitcoind uses cookie authentication in 0.4.0 (no more rpcuser/rpcpassword
// pointers). Read the cookie from the read-only dependency mount and watch
// it: a cookie rotation (bitcoind restart) restarts Kamado with fresh
// credentials. Null until bitcoind has started at least once.
const cookieRaw = await FileHelper.string(
`${kamadoSub.rootfs}/mnt/bitcoind/.cookie`,
)
.read()
.const(effects)
const cookie = parseCookie(cookieRaw)
// Placeholders keep kamado-api bootable while bitcoind is unresolved: the
// dashboard comes up, reports Bitcoin Core as unreachable, and the reactive
// reads above heal everything once the dependency is satisfied.
const rpcAddr = bitcoind.rpc ?? '127.0.0.1:8332'
const rpcUser = cookie?.user ?? '__cookie__'
const rpcPassword = cookie?.password ?? 'bitcoind-not-yet-available'
// ckpool has TWO independent new-block detection paths. Wire up both so
// we're never blind to a tip change (every second of stale work in solo
// mode is hashrate burned on a dead block):
// 1. Blockpoll thread: polls getbestblockhash every `blockpoll` ms. Only
// runs when notify=false — so keep notify=false.
// 2. ZMQ hashblock subscriber: instant push from bitcoind. Point it at
// the real bridge endpoint; fall back to ckpool's (dead, harmless)
// loopback default while bitcoind's ZMQ interface is unavailable.
const ckpoolZmqBlock = bitcoind.zmqBlock
? `tcp://${bitcoind.zmqBlock}`
: 'tcp://127.0.0.1:28332'
// Rendered ckpool.conf, written to the subcontainer rootfs (ephemeral, so
// RPC credentials never touch a persisted volume). `btcaddress` is only
// consulted once at startup for ckpool's coinbase-builder self-test; solo
// mode pays the worker's stratum address, never this one. The right
// self-test address depends on the active network, which ckpool-run.sh
// detects from bitcoind at startup and substitutes for the placeholder.
const ckpoolConfTemplate = JSON.stringify(
{
btcd: [
{
url: rpcAddr,
auth: rpcUser,
pass: rpcPassword,
notify: false,
},
],
btcaddress: '@SELFTEST_ADDRESS@',
btcsig: store.coinbaseTag,
blockpoll: 100,
update_interval: 30,
serverurl: [
`0.0.0.0:${store.stratumPort}`,
`127.0.0.1:${tlsInternalPort}`,
],
mindiff: store.minDiff,
startdiff: store.startDiff,
maxdiff: store.maxDiff,
dropidle: store.dropIdle,
zmqblock: ckpoolZmqBlock,
logdir: ckpoolLogDir,
},
null,
2,
)
await mkdir(`${kamadoSub.rootfs}/etc/ckpool`, { recursive: true })
await writeFile(
`${kamadoSub.rootfs}/etc/ckpool/ckpool.conf.template`,
ckpoolConfTemplate,
)
// stunnel.conf is rendered here rather than shipped as a static asset,
// because the accept port is user config now. `connect` stays on ckpool's
// fixed loopback bind so TLS clients keep getting tagged server == 1 (the
// dashboard's lock icon).
if (store.tlsEnabled) {
const stunnelConf = [
'foreground = yes',
'pid =',
'output = /dev/stdout',
// debug = 5 (notice) so each successful TLS handshake produces a
// "Service [stratum] accepted connection" / "connected from" pair in the
// service logs. Failures (bad cert, alerts, cipher rejection) surface at
// level 3, so both happy- and sad-path events are visible without
// flipping levels per incident.
'debug = 5',
// Pin a modern TLS floor. Any miner firmware younger than ~2018 speaks
// TLS 1.2, and TLS 1.0/1.1 are deprecated anyway.
'sslVersion = all',
'options = NO_SSLv2',
'options = NO_SSLv3',
'options = NO_TLSv1',
'options = NO_TLSv1_1',
'',
'[stratum]',
`accept = 0.0.0.0:${store.stratumTlsPort}`,
`connect = 127.0.0.1:${tlsInternalPort}`,
`cert = ${tlsDir}/stratum.pem`,
// No client-cert auth — stratum over TLS is opportunistic encryption;
// the stratum protocol layer handles miner auth via username.
'verify = 0',
'',
].join('\n')
await mkdir(`${kamadoSub.rootfs}/etc/stunnel`, { recursive: true })
await writeFile(`${kamadoSub.rootfs}/etc/stunnel/stratum.conf`, stunnelConf)
}
/**
* ======================== Daemons ========================
*/
return sdk.Daemons.of(effects)
.addOneshot('dirs', {
subcontainer: kamadoSub,
exec: {
command: [
'mkdir',
'-p',
kamadoDataDir,
tlsDir,
ckpoolLogDir,
ckpoolSocketDir,
],
},
requires: [],
})
.addDaemon('api', {
subcontainer: kamadoSub,
exec: {
command: ['kamado-api'],
env: {
LISTEN_ADDR: `:${uiPort}`,
CKPOOL_SOCKDIR: ckpoolSocketDir,
CKPOOL_LOGFILE: ckpoolLogFile,
DB_PATH: kamadoDbPath,
BITCOIN_RPC_URL: `http://${rpcAddr}`,
BITCOIN_RPC_USER: rpcUser,
BITCOIN_RPC_PASSWORD: rpcPassword,
POLL_INTERVAL: '5s',
KAMADO_LOG_LEVEL: store.logLevel,
// Empty disables kamado-api's ZMQ subscriber (RPC polling fallback
// remains active either way).
BITCOIN_ZMQ_BLOCK:
store.zmqEnabled && bitcoind.zmqBlock
? `tcp://${bitcoind.zmqBlock}`
: '',
// Empty means "use mempool.space defaults" for dashboard links.
MEMPOOL_BASE_URL: store.mempoolExplorerUrl ?? '',
},
},
ready: {
display: i18n('Web Dashboard'),
gracePeriod: 15_000,
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n('The Kamado dashboard is reachable'),
errorMessage: i18n('The Kamado dashboard is not reachable'),
}),
},
requires: ['dirs'],
})
.addDaemon('ckpool', {
subcontainer: kamadoSub,
exec: {
// Waits until bitcoind answers getblockchaininfo, resolves the
// network-correct self-test address, renders the final ckpool.conf,
// then execs ckpool. When kamado-api kills ckpool on bitcoind
// failure (so miners can fail over), StartOS restarts the daemon and
// the script blocks again until bitcoind recovers — the 0.3.x
// supervised-restart loop, expressed as a daemon.
command: ['kamado-ckpool-run.sh'],
env: {
BITCOIN_RPC_URL: `http://${rpcAddr}`,
BITCOIN_RPC_USER: rpcUser,
BITCOIN_RPC_PASSWORD: rpcPassword,
CKPOOL_SOCKDIR: ckpoolSocketDir,
},
},
ready: {
display: i18n('Stratum Server'),
gracePeriod: 30_000,
fn: () =>
sdk.healthCheck.checkPortListening(effects, store.stratumPort, {
successMessage: i18n('The stratum server is accepting connections'),
errorMessage: i18n(
'The stratum server is not accepting connections',
),
}),
},
requires: ['dirs'],
})
.addHealthCheck('bitcoin', {
ready: {
display: i18n('Bitcoin Core RPC'),
fn: async () => {
const h = await curlJson<HealthPayload>(kamadoSub, healthUrl)
if (!h)
return {
result: 'failure',
message: i18n('Kamado API is unreachable — service may be down'),
}
if (h.bitcoin)
return {
result: 'success',
message: i18n('Connected to Bitcoin Core'),
}
return {
result: 'failure',
message: h.last_error
? `${i18n('Bitcoin Core RPC is unreachable')} (${h.last_error})`
: i18n('Bitcoin Core RPC is unreachable'),
}
},
},
requires: ['api'],
})
.addHealthCheck('submit-gap', {
ready: {
display: i18n('Block Submission'),
fn: async () => {
const h = await curlJson<HealthPayload>(kamadoSub, healthUrl)
if (!h)
return {
result: 'failure',
message: i18n('Kamado API is unreachable — service may be down'),
}
const gap = h.submit_gap ?? 0
if (gap === 0)
return {
result: 'success',
message: i18n('All block submissions confirmed'),
}
return {
result: 'failure',
message: `${gap} ${i18n(
'block(s) submitted to bitcoind but not confirmed — check Bitcoin Core logs',
)}`,
}
},
},
requires: ['api'],
})
.addHealthCheck('zmq', () =>
store.zmqEnabled
? {
ready: {
display: i18n('ZMQ Block Feed'),
fn: async () => {
const h = await curlJson<HealthPayload>(kamadoSub, healthUrl)
if (!h)
return {
result: 'failure',
message: i18n(
'Kamado API is unreachable — service may be down',
),
}
if (h.zmq_stale)
return {
result: 'failure',
message: i18n(
'ZMQ block feed is stale — block notifications are falling back to RPC polling',
),
}
return {
result: 'success',
message: i18n('ZMQ block notifications are flowing'),
}
},
},
requires: ['api'],
}
: null,
)
.addOneshot('tls-cert', () =>
store.tlsEnabled
? {
subcontainer: kamadoSub,
exec: {
// Generates (or migrates) the persisted self-signed stratum
// certificate under /root/.kamado/tls. Idempotent: regenerates
// only when files are missing or the cert-format version marker
// is outdated.
command: ['kamado-tls-init.sh'],
env: { TLS_DIR: tlsDir },
},
requires: ['dirs'],
}
: null,
)
.addDaemon('stunnel', () =>
store.tlsEnabled
? {
subcontainer: kamadoSub,
exec: {
command: ['stunnel4', '/etc/stunnel/stratum.conf'],
},
ready: {
display: i18n('Stratum TLS'),
fn: () =>
sdk.healthCheck.checkPortListening(
effects,
store.stratumTlsPort,
{
successMessage: i18n(
'TLS stratum is accepting connections',
),
errorMessage: i18n(
'TLS stratum is not accepting connections',
),
},
),
},
requires: ['tls-cert'],
}
: null,
)
})