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({ 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(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(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(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, ) })