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,
},
],
},
}
},
)
+15
View File
@@ -0,0 +1,15 @@
import { sdk } from './sdk'
/**
* Back up both volumes:
* - main: SQLite DB (found blocks, best shares, accelerated txs, log
* cursor), store.json settings, and the persisted stratum TLS certificate
* (so miners' pinned fingerprints survive a restore).
* - ckpool: ckpool's own state files (users/, workers/, pool status) and
* daily logs. The log file is included deliberately: the log-tailer's
* cursor in the DB references it, so restoring both keeps block-solve
* detection consistent.
*/
export const { createBackup, restoreInit } = sdk.setupBackups(
async ({ effects }) => sdk.Backups.ofVolumes('main', 'ckpool'),
)
+36
View File
@@ -0,0 +1,36 @@
import { autoconfig } from 'bitcoin-core-startos/startos/actions/config/autoconfig'
import { storeJson } from './fileModels/store.json'
import { i18n } from './i18n'
import { sdk } from './sdk'
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
// When the user wants sub-second block detection, ask bitcoind to enable
// its ZMQ publishers. Kamado degrades gracefully to RPC polling without it,
// so this is 'important', not 'critical'. Reactive: toggling ZMQ off in
// Kamado's config withdraws the request on the next re-run.
const zmqWanted = await storeJson.read((s) => s.zmqEnabled).const(effects)
if (zmqWanted) {
await sdk.action.createTask(effects, 'bitcoind', autoconfig, 'important', {
input: {
kind: 'partial',
accept: [{ zmqEnabled: true }],
set: { zmqEnabled: true },
},
when: { condition: 'input-not-matches', once: false },
reason: i18n(
'Kamado Pool uses ZMQ block notifications for sub-second stale-work detection — every second of stale work in solo mode is hashrate burned on a dead block.',
),
})
}
return {
bitcoind: {
kind: 'running',
versionRange: '>=28.4:13',
// sync-progress included deliberately: mining on an unsynced node
// produces invalid work, so surface IBD as an unsatisfied dependency.
healthChecks: ['bitcoind', 'sync-progress'],
},
}
})
+56
View File
@@ -0,0 +1,56 @@
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
import { defaultStratumPort, defaultStratumTlsPort } from '../utils'
/**
* Persisted service settings (the 0.4.0 replacement for the 0.3.x
* config.yaml). Every field carries a `.catch()` default, so merging `{}`
* on install materializes a fully-populated file, and a corrupt or
* hand-edited file self-heals to defaults instead of crashing the service.
*
* Ports are intentionally absent: internal ports are fixed constants (see
* utils.ts) and external ports are remapped by the user through the StartOS
* interface UI, not service config.
*/
export const storeJson = FileHelper.json(
{
base: sdk.volumes.main,
subpath: '/store.json',
},
z.object({
/**
* Port ckpool binds for plaintext stratum. Also requested as the
* interface's preferred external port, so miners reach the pool on this
* same number whenever the OS can grant it.
*/
stratumPort: z.number().int().min(1).max(65535).catch(defaultStratumPort),
/** Port stunnel accepts TLS stratum on (only used when tlsEnabled). */
stratumTlsPort: z
.number()
.int()
.min(1)
.max(65535)
.catch(defaultStratumTlsPort),
/** Short string embedded in the coinbase transaction of solved blocks (ckpool btcsig). */
coinbaseTag: z.string().catch('/Kamado/'),
/** Initial vardiff target for new miner connections. */
startDiff: z.number().int().min(1).catch(16384),
/** Floor for the vardiff algorithm. */
minDiff: z.number().int().min(1).catch(1000),
/** Ceiling for the vardiff algorithm. 0 means no cap. */
maxDiff: z.number().int().min(0).catch(0),
/** Disconnect clients idle for this many seconds. 0 disables. */
dropIdle: z.number().int().min(0).catch(0),
/** kamado-api log verbosity. */
logLevel: z.enum(['debug', 'info', 'warn', 'error']).catch('info'),
/** Subscribe kamado-api to bitcoind's hashblock ZMQ topic. */
zmqEnabled: z.boolean().catch(true),
/** Terminate TLS for stratum via the stunnel sidecar on stratumTlsPort. */
tlsEnabled: z.boolean().catch(false),
/**
* Base URL of a self-hosted mempool instance for dashboard explorer
* links. null -> use the public mempool.space.
*/
mempoolExplorerUrl: z.string().nullable().catch(null),
}),
)
+97
View File
@@ -0,0 +1,97 @@
export const DEFAULT_LANG = 'en_US'
const dict = {
'(not yet generated — start the service once)': 0,
'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.': 1,
'All block submissions confirmed': 2,
'All latency counters (count, avg, last, wasted work) have been cleared. New measurements will accumulate from the next block.': 3,
'Already absent': 4,
'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.': 5,
'Bitcoin Core RPC': 6,
'Bitcoin Core RPC is unreachable': 7,
'Block Submission': 8,
'Block latency stats reset to zero': 9,
'Ceiling for the vardiff algorithm. 0 means no cap.': 10,
'Certificate (PEM)': 11,
'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.': 12,
'Coinbase Tag': 13,
Configure: 14,
'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.': 15,
'Connected to Bitcoin Core': 16,
'Container-side TLS stratum port. The externally reachable port is shown on the Stratum (TLS) interface.': 17,
'Custom Block Explorer URL': 18,
'Customize vardiff, TLS, block notifications, logging, and explorer links': 19,
Debug: 20,
'Disconnect clients that have not submitted a share in this many seconds. 0 disables the idle disconnect.': 21,
'Displays a full status snapshot: Bitcoin Core sync state, ckpool health, connected miners, hashrate, found blocks, and submit-gap diagnostics.': 22,
'Drop Idle (seconds)': 23,
'Enable Stratum TLS in Configure first': 24,
Error: 25,
'FAIL — see details above': 26,
'Failed to reset latency stats — the Kamado API did not respond': 27,
'Fingerprint (SHA-256)': 28,
'Floor for the vardiff algorithm.': 29,
'Full self-signed certificate. Copy the whole block including the BEGIN/END CERTIFICATE markers.': 30,
Info: 31,
'Initial vardiff target for new miner connections. Bitaxe-class miners typically land around 16384.': 32,
'Kamado API is unreachable — service may be down': 33,
'Kamado Pool uses ZMQ block notifications for sub-second stale-work detection — every second of stale work in solo mode is hashrate burned on a dead block.': 34,
'Log Level': 35,
'Maximum Difficulty': 36,
'Miners connected via TLS will be disconnected on restart and will need to accept or re-pin the new certificate fingerprint.': 37,
'Minimum Difficulty': 38,
'Must be an http:// or https:// URL with no whitespace': 39,
'Next steps:': 40,
'No TLS certificate files found (TLS may not have been enabled)': 41,
'No certificate files were present.': 42,
'PASS — pool is healthy': 43,
'Plaintext stratum endpoint. Point miners here with their Bitcoin payout address as the username': 44,
'Pool Status': 45,
'Provide the new fingerprint or PEM to miners that pin the certificate.': 46,
'Real-time Kamado Pool dashboard (hashrate, miners, blocks, best shares)': 47,
'Regenerate TLS Certificate': 48,
Removed: 49,
'Reset Block Latency': 50,
'Restart Kamado Pool.': 51,
'Run the Stratum TLS Certificate action to see the new fingerprint and PEM.': 52,
'Short string embedded in the coinbase transaction of solved blocks.': 53,
'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).': 54,
'Starting Difficulty': 55,
Stratum: 56,
'Stratum (TLS)': 57,
'Stratum Server': 58,
'Stratum TLS': 59,
'Stratum TLS Certificate': 60,
"Subscribe to Bitcoin Core's hashblock ZMQ topic for sub-second block detection. RPC polling remains active as a fallback either way.": 61,
'TLS Port (internal)': 62,
'TLS certificate cleared — restart the service to generate a new one': 63,
'TLS stratum is accepting connections': 64,
'TLS stratum is not accepting connections': 65,
'TLS-encrypted stratum endpoint (self-signed certificate — see the Stratum TLS Certificate action)': 66,
'The Kamado dashboard is not reachable': 67,
'The Kamado dashboard is reachable': 68,
'The stratum server is accepting connections': 69,
'The stratum server is not accepting connections': 70,
'Use this for fingerprint pinning on miner firmwares that support it. Changes only when the certificate is regenerated.': 71,
'Verbosity of the kamado-api log output.': 72,
Warn: 73,
'Web Dashboard': 74,
'ZMQ Block Feed': 75,
'ZMQ Block Notifications': 76,
'ZMQ block feed is stale — block notifications are falling back to RPC polling': 77,
'ZMQ block notifications are flowing': 78,
'Zeroes the block-update latency counters (avg, last, wasted work, block count). Use this after tuning ZMQ or ckpool to start fresh measurements.': 79,
'block(s) submitted to bitcoind but not confirmed — check Bitcoin Core logs': 80,
seconds: 81,
'Stratum Port': 82,
'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.': 83,
'Stratum TLS Port': 84,
'TCP port stunnel accepts TLS stratum connections on. Only used when Stratum TLS is enabled below.': 85,
} as const
/**
* Plumbing. DO NOT EDIT.
*/
export type I18nKey = keyof typeof dict
export type LangDict = Record<(typeof dict)[I18nKey], string>
export default dict
+356
View File
@@ -0,0 +1,356 @@
import { LangDict } from './default'
export default {
es_ES: {
0: '(aún no generado — inicie el servicio una vez)',
1: 'Acepta conexiones stratum por TLS mediante un sidecar stunnel en un segundo puerto. Se genera y persiste un certificado autofirmado una sola vez: los mineros deben confiar en el certificado (vea la acción Certificado TLS de Stratum) o conectarse con la verificación desactivada.',
2: 'Todos los envíos de bloques confirmados',
3: 'Todos los contadores de latencia (conteo, promedio, último, trabajo desperdiciado) se han restablecido. Las nuevas mediciones se acumularán a partir del próximo bloque.',
4: 'Ya ausente',
5: 'URL base de una instancia mempool autoalojada para los enlaces del panel (p. ej. https://mempool.example.com). Kamado añade /address/<addr> y /block/<hash>, por lo que la instancia debe seguir el esquema de URL estándar de mempool.space. Déjelo vacío para usar el mempool.space público.',
6: 'RPC de Bitcoin Core',
7: 'No se puede acceder al RPC de Bitcoin Core',
8: 'Envío de bloques',
9: 'Estadísticas de latencia de bloques puestas a cero',
10: 'Techo del algoritmo vardiff. 0 significa sin límite.',
11: 'Certificado (PEM)',
12: 'Elimina el certificado TLS de stratum actual para que se genere uno nuevo en el próximo inicio del servicio. Úselo para rotar un certificado caducado o no confiable.',
13: 'Etiqueta de coinbase',
14: 'Configurar',
15: 'Conecte los mineros con stratum+ssl:// a la interfaz Stratum (TLS). El certificado es autofirmado: pegue el PEM en firmware que acepte una raíz personalizada, fije la huella o desactive la verificación.',
16: 'Conectado a Bitcoin Core',
17: 'Puerto TLS de stratum del lado del contenedor. El puerto accesible desde el exterior se muestra en la interfaz Stratum (TLS).',
18: 'URL de explorador de bloques personalizado',
19: 'Personalice vardiff, TLS, notificaciones de bloques, registro y enlaces del explorador',
20: 'Depuración',
21: 'Desconecta a los clientes que no hayan enviado una participación en este número de segundos. 0 desactiva la desconexión por inactividad.',
22: 'Muestra una instantánea de estado completa: sincronización de Bitcoin Core, salud de ckpool, mineros conectados, hashrate, bloques encontrados y diagnóstico de envíos.',
23: 'Desconexión por inactividad (segundos)',
24: 'Active primero Stratum TLS en Configurar',
25: 'Error',
26: 'FALLO — vea los detalles arriba',
27: 'No se pudieron restablecer las estadísticas de latencia — la API de Kamado no respondió',
28: 'Huella digital (SHA-256)',
29: 'Suelo del algoritmo vardiff.',
30: 'Certificado autofirmado completo. Copie todo el bloque incluidos los marcadores BEGIN/END CERTIFICATE.',
31: 'Información',
32: 'Objetivo vardiff inicial para nuevas conexiones de mineros. Los mineros tipo Bitaxe suelen quedar en torno a 16384.',
33: 'No se puede acceder a la API de Kamado — el servicio puede estar caído',
34: 'Kamado Pool usa notificaciones ZMQ de bloques para detectar trabajo obsoleto en menos de un segundo — cada segundo de trabajo obsoleto en modo solo es hashrate quemado en un bloque muerto.',
35: 'Nivel de registro',
36: 'Dificultad máxima',
37: 'Los mineros conectados por TLS se desconectarán al reiniciar y deberán aceptar o volver a fijar la huella del nuevo certificado.',
38: 'Dificultad mínima',
39: 'Debe ser una URL http:// o https:// sin espacios',
40: 'Próximos pasos:',
41: 'No se encontraron archivos de certificado TLS (puede que TLS no esté activado)',
42: 'No había archivos de certificado.',
43: 'CORRECTO — el pool está en buen estado',
44: 'Punto de acceso stratum sin cifrar. Apunte aquí a los mineros con su dirección de pago de Bitcoin como nombre de usuario',
45: 'Estado del pool',
46: 'Proporcione la nueva huella o el PEM a los mineros que fijan el certificado.',
47: 'Panel de Kamado Pool en tiempo real (hashrate, mineros, bloques, mejores participaciones)',
48: 'Regenerar certificado TLS',
49: 'Eliminado',
50: 'Restablecer latencia de bloques',
51: 'Reinicie Kamado Pool.',
52: 'Ejecute la acción Certificado TLS de Stratum para ver la nueva huella y el PEM.',
53: 'Cadena corta incrustada en la transacción coinbase de los bloques resueltos.',
54: 'Muestra el certificado TLS autofirmado de stratum: huella SHA-256 para fijar y el PEM completo para pegar en el firmware del minero (p. ej. el campo «Stratum SSL Cert» de AxeOS).',
55: 'Dificultad inicial',
56: 'Stratum',
57: 'Stratum (TLS)',
58: 'Servidor stratum',
59: 'Stratum TLS',
60: 'Certificado TLS de Stratum',
61: 'Suscribirse al tema ZMQ hashblock de Bitcoin Core para detectar bloques en menos de un segundo. El sondeo RPC permanece activo como respaldo en cualquier caso.',
62: 'Puerto TLS (interno)',
63: 'Certificado TLS eliminado — reinicie el servicio para generar uno nuevo',
64: 'El stratum TLS acepta conexiones',
65: 'El stratum TLS no acepta conexiones',
66: 'Punto de acceso stratum cifrado con TLS (certificado autofirmado — vea la acción Certificado TLS de Stratum)',
67: 'No se puede acceder al panel de Kamado',
68: 'El panel de Kamado está accesible',
69: 'El servidor stratum acepta conexiones',
70: 'El servidor stratum no acepta conexiones',
71: 'Úselo para fijar la huella en firmwares de mineros que lo admitan. Solo cambia cuando se regenera el certificado.',
72: 'Verbosidad de la salida de registro de kamado-api.',
73: 'Advertencia',
74: 'Panel web',
75: 'Flujo de bloques ZMQ',
76: 'Notificaciones de bloques ZMQ',
77: 'El flujo de bloques ZMQ está obsoleto — las notificaciones de bloques recurren al sondeo RPC',
78: 'Las notificaciones de bloques ZMQ fluyen correctamente',
79: 'Pone a cero los contadores de latencia de actualización de bloques (promedio, último, trabajo desperdiciado, conteo). Úselo tras ajustar ZMQ o ckpool para empezar mediciones nuevas.',
80: 'bloque(s) enviados a bitcoind pero no confirmados — revise los registros de Bitcoin Core',
81: 'segundos',
82: 'Puerto stratum',
83: 'Puerto TCP en el que escucha el servidor stratum sin cifrar. StartOS también intenta publicar el pool en ese mismo número de puerto en su red, por lo que normalmente es el puerto que dará a sus mineros: revise la interfaz Stratum después de guardar, ya que el sistema elegirá otro puerto externo si este ya está ocupado.',
84: 'Puerto TLS de stratum',
85: 'Puerto TCP en el que stunnel acepta conexiones stratum TLS. Solo se usa cuando Stratum TLS está activado más abajo.',
},
de_DE: {
0: '(noch nicht erzeugt — starten Sie den Dienst einmal)',
1: 'Akzeptiert Stratum-Verbindungen über TLS mittels eines stunnel-Sidecars auf einem zweiten Port. Ein selbstsigniertes Zertifikat wird einmal erzeugt und gespeichert — Miner müssen dem Zertifikat vertrauen (siehe Aktion „Stratum-TLS-Zertifikat“) oder ohne Verifizierung verbinden.',
2: 'Alle Blockeinreichungen bestätigt',
3: 'Alle Latenzzähler (Anzahl, Durchschnitt, letzter, verschwendete Arbeit) wurden zurückgesetzt. Neue Messungen sammeln sich ab dem nächsten Block.',
4: 'Bereits nicht vorhanden',
5: 'Basis-URL einer selbst gehosteten Mempool-Instanz für Dashboard-Links (z. B. https://mempool.example.com). Kamado hängt /address/<addr> und /block/<hash> an, die Instanz muss also dem Standard-URL-Schema von mempool.space folgen. Leer lassen, um das öffentliche mempool.space zu verwenden.',
6: 'Bitcoin Core RPC',
7: 'Bitcoin Core RPC ist nicht erreichbar',
8: 'Blockeinreichung',
9: 'Blocklatenz-Statistiken auf null zurückgesetzt',
10: 'Obergrenze für den Vardiff-Algorithmus. 0 bedeutet keine Begrenzung.',
11: 'Zertifikat (PEM)',
12: 'Löscht das aktuelle Stratum-TLS-Zertifikat, sodass beim nächsten Dienststart ein neues erzeugt wird. Verwenden Sie dies, um ein abgelaufenes oder nicht vertrauenswürdiges Zertifikat zu rotieren.',
13: 'Coinbase-Tag',
14: 'Konfigurieren',
15: 'Verbinden Sie Miner mit stratum+ssl:// mit der Stratum-(TLS)-Schnittstelle. Das Zertifikat ist selbstsigniert: Fügen Sie das PEM in Firmware ein, die eine eigene Root akzeptiert, pinnen Sie den Fingerabdruck oder deaktivieren Sie die Verifizierung.',
16: 'Mit Bitcoin Core verbunden',
17: 'Containerseitiger TLS-Stratum-Port. Der extern erreichbare Port wird auf der Stratum-(TLS)-Schnittstelle angezeigt.',
18: 'Benutzerdefinierte Block-Explorer-URL',
19: 'Passen Sie Vardiff, TLS, Blockbenachrichtigungen, Protokollierung und Explorer-Links an',
20: 'Debug',
21: 'Trennt Clients, die innerhalb dieser Sekundenzahl keinen Share eingereicht haben. 0 deaktiviert die Leerlauf-Trennung.',
22: 'Zeigt einen vollständigen Status-Schnappschuss: Bitcoin-Core-Synchronisierung, ckpool-Zustand, verbundene Miner, Hashrate, gefundene Blöcke und Submit-Gap-Diagnose.',
23: 'Leerlauf-Trennung (Sekunden)',
24: 'Aktivieren Sie zuerst Stratum-TLS unter Konfigurieren',
25: 'Fehler',
26: 'FEHLER — siehe Details oben',
27: 'Latenzstatistiken konnten nicht zurückgesetzt werden — die Kamado-API hat nicht geantwortet',
28: 'Fingerabdruck (SHA-256)',
29: 'Untergrenze für den Vardiff-Algorithmus.',
30: 'Vollständiges selbstsigniertes Zertifikat. Kopieren Sie den gesamten Block einschließlich der BEGIN/END-CERTIFICATE-Markierungen.',
31: 'Info',
32: 'Anfängliches Vardiff-Ziel für neue Miner-Verbindungen. Miner der Bitaxe-Klasse landen typischerweise bei etwa 16384.',
33: 'Kamado-API ist nicht erreichbar — der Dienst ist möglicherweise ausgefallen',
34: 'Kamado Pool nutzt ZMQ-Blockbenachrichtigungen zur Erkennung veralteter Arbeit im Subsekundenbereich — jede Sekunde veralteter Arbeit im Solo-Modus ist auf einem toten Block verbrannte Hashrate.',
35: 'Protokollstufe',
36: 'Maximale Schwierigkeit',
37: 'Über TLS verbundene Miner werden beim Neustart getrennt und müssen den neuen Zertifikat-Fingerabdruck akzeptieren oder neu pinnen.',
38: 'Minimale Schwierigkeit',
39: 'Muss eine http://- oder https://-URL ohne Leerzeichen sein',
40: 'Nächste Schritte:',
41: 'Keine TLS-Zertifikatsdateien gefunden (TLS wurde möglicherweise nicht aktiviert)',
42: 'Es waren keine Zertifikatsdateien vorhanden.',
43: 'OK — der Pool ist gesund',
44: 'Unverschlüsselter Stratum-Endpunkt. Verbinden Sie Miner hierhin mit ihrer Bitcoin-Auszahlungsadresse als Benutzername',
45: 'Pool-Status',
46: 'Geben Sie den neuen Fingerabdruck oder das PEM an Miner weiter, die das Zertifikat pinnen.',
47: 'Echtzeit-Dashboard von Kamado Pool (Hashrate, Miner, Blöcke, beste Shares)',
48: 'TLS-Zertifikat neu erzeugen',
49: 'Entfernt',
50: 'Blocklatenz zurücksetzen',
51: 'Starten Sie Kamado Pool neu.',
52: 'Führen Sie die Aktion „Stratum-TLS-Zertifikat“ aus, um den neuen Fingerabdruck und das PEM zu sehen.',
53: 'Kurzer String, der in die Coinbase-Transaktion gelöster Blöcke eingebettet wird.',
54: 'Zeigt das selbstsignierte Stratum-TLS-Zertifikat: SHA-256-Fingerabdruck zum Pinnen und das vollständige PEM zum Einfügen in die Miner-Firmware (z. B. das AxeOS-Feld „Stratum SSL Cert“).',
55: 'Anfangsschwierigkeit',
56: 'Stratum',
57: 'Stratum (TLS)',
58: 'Stratum-Server',
59: 'Stratum-TLS',
60: 'Stratum-TLS-Zertifikat',
61: 'Abonniert das hashblock-ZMQ-Thema von Bitcoin Core zur Blockerkennung im Subsekundenbereich. RPC-Polling bleibt in jedem Fall als Fallback aktiv.',
62: 'TLS-Port (intern)',
63: 'TLS-Zertifikat gelöscht — starten Sie den Dienst neu, um ein neues zu erzeugen',
64: 'TLS-Stratum akzeptiert Verbindungen',
65: 'TLS-Stratum akzeptiert keine Verbindungen',
66: 'TLS-verschlüsselter Stratum-Endpunkt (selbstsigniertes Zertifikat — siehe Aktion „Stratum-TLS-Zertifikat“)',
67: 'Das Kamado-Dashboard ist nicht erreichbar',
68: 'Das Kamado-Dashboard ist erreichbar',
69: 'Der Stratum-Server akzeptiert Verbindungen',
70: 'Der Stratum-Server akzeptiert keine Verbindungen',
71: 'Verwenden Sie dies für Fingerabdruck-Pinning auf Miner-Firmwares, die es unterstützen. Ändert sich nur, wenn das Zertifikat neu erzeugt wird.',
72: 'Ausführlichkeit der kamado-api-Protokollausgabe.',
73: 'Warnung',
74: 'Web-Dashboard',
75: 'ZMQ-Block-Feed',
76: 'ZMQ-Blockbenachrichtigungen',
77: 'ZMQ-Block-Feed ist veraltet — Blockbenachrichtigungen fallen auf RPC-Polling zurück',
78: 'ZMQ-Blockbenachrichtigungen fließen',
79: 'Setzt die Latenzzähler für Blockaktualisierungen auf null (Durchschnitt, letzter, verschwendete Arbeit, Blockanzahl). Verwenden Sie dies nach dem Tuning von ZMQ oder ckpool für frische Messungen.',
80: 'Block/Blöcke an bitcoind übermittelt, aber nicht bestätigt — prüfen Sie die Bitcoin-Core-Protokolle',
81: 'Sekunden',
82: 'Stratum-Port',
83: 'TCP-Port, auf dem der unverschlüsselte Stratum-Server lauscht. StartOS versucht außerdem, den Pool unter derselben Portnummer im Netzwerk zu veröffentlichen — normalerweise ist dies also der Port für Ihre Miner. Prüfen Sie nach dem Speichern die Stratum-Schnittstelle, denn das System wählt einen anderen externen Port, falls dieser belegt ist.',
84: 'Stratum-TLS-Port',
85: 'TCP-Port, auf dem stunnel TLS-Stratum-Verbindungen annimmt. Wird nur verwendet, wenn Stratum-TLS unten aktiviert ist.',
},
pl_PL: {
0: '(jeszcze nie wygenerowano — uruchom usługę raz)',
1: 'Akceptuje połączenia stratum przez TLS za pomocą pomocniczego stunnela na drugim porcie. Certyfikat samopodpisany jest generowany raz i zapisywany — górnicy muszą zaufać certyfikatowi (zobacz akcję Certyfikat TLS Stratum) lub łączyć się z wyłączoną weryfikacją.',
2: 'Wszystkie przesłane bloki potwierdzone',
3: 'Wszystkie liczniki opóźnień (liczba, średnia, ostatni, zmarnowana praca) zostały wyzerowane. Nowe pomiary będą gromadzone od następnego bloku.',
4: 'Już nieobecne',
5: 'Bazowy URL własnej instancji mempool dla linków panelu (np. https://mempool.example.com). Kamado dołącza /address/<addr> i /block/<hash>, więc instancja musi stosować standardowy układ URL mempool.space. Pozostaw puste, aby użyć publicznego mempool.space.',
6: 'RPC Bitcoin Core',
7: 'RPC Bitcoin Core jest nieosiągalne',
8: 'Przesyłanie bloków',
9: 'Statystyki opóźnień bloków wyzerowane',
10: 'Górny limit algorytmu vardiff. 0 oznacza brak limitu.',
11: 'Certyfikat (PEM)',
12: 'Usuwa bieżący certyfikat TLS stratum, aby przy następnym uruchomieniu usługi wygenerować nowy. Użyj tego, aby wymienić wygasły lub niezaufany certyfikat.',
13: 'Znacznik coinbase',
14: 'Konfiguruj',
15: 'Podłącz górników przez stratum+ssl:// do interfejsu Stratum (TLS). Certyfikat jest samopodpisany: wklej PEM do firmware akceptującego własny certyfikat główny, przypnij odcisk lub wyłącz weryfikację.',
16: 'Połączono z Bitcoin Core',
17: 'Port TLS stratum po stronie kontenera. Port osiągalny z zewnątrz jest widoczny w interfejsie Stratum (TLS).',
18: 'Niestandardowy URL eksploratora bloków',
19: 'Dostosuj vardiff, TLS, powiadomienia o blokach, logowanie i linki eksploratora',
20: 'Debug',
21: 'Rozłącza klientów, którzy nie przesłali udziału przez podaną liczbę sekund. 0 wyłącza rozłączanie bezczynnych.',
22: 'Wyświetla pełny stan: synchronizację Bitcoin Core, zdrowie ckpool, podłączonych górników, hashrate, znalezione bloki i diagnostykę przesyłania.',
23: 'Rozłączanie bezczynnych (sekundy)',
24: 'Najpierw włącz Stratum TLS w Konfiguruj',
25: 'Błąd',
26: 'BŁĄD — szczegóły powyżej',
27: 'Nie udało się wyzerować statystyk opóźnień — API Kamado nie odpowiedziało',
28: 'Odcisk (SHA-256)',
29: 'Dolny limit algorytmu vardiff.',
30: 'Pełny certyfikat samopodpisany. Skopiuj cały blok wraz ze znacznikami BEGIN/END CERTIFICATE.',
31: 'Informacja',
32: 'Początkowy cel vardiff dla nowych połączeń górników. Górnicy klasy Bitaxe zwykle osiągają około 16384.',
33: 'API Kamado jest nieosiągalne — usługa może nie działać',
34: 'Kamado Pool używa powiadomień ZMQ o blokach do wykrywania przestarzałej pracy w ułamku sekundy — każda sekunda przestarzałej pracy w trybie solo to hashrate spalony na martwym bloku.',
35: 'Poziom logowania',
36: 'Maksymalna trudność',
37: 'Górnicy połączeni przez TLS zostaną rozłączeni przy restarcie i będą musieli zaakceptować lub ponownie przypiąć odcisk nowego certyfikatu.',
38: 'Minimalna trudność',
39: 'Musi być adresem URL http:// lub https:// bez spacji',
40: 'Następne kroki:',
41: 'Nie znaleziono plików certyfikatu TLS (TLS mógł nie być włączony)',
42: 'Nie było plików certyfikatu.',
43: 'OK — pula działa prawidłowo',
44: 'Nieszyfrowany punkt końcowy stratum. Skieruj tu górników z ich adresem wypłaty Bitcoin jako nazwą użytkownika',
45: 'Stan puli',
46: 'Przekaż nowy odcisk lub PEM górnikom przypinającym certyfikat.',
47: 'Panel Kamado Pool w czasie rzeczywistym (hashrate, górnicy, bloki, najlepsze udziały)',
48: 'Wygeneruj ponownie certyfikat TLS',
49: 'Usunięto',
50: 'Zresetuj opóźnienie bloków',
51: 'Uruchom ponownie Kamado Pool.',
52: 'Uruchom akcję Certyfikat TLS Stratum, aby zobaczyć nowy odcisk i PEM.',
53: 'Krótki ciąg osadzany w transakcji coinbase rozwiązanych bloków.',
54: 'Pokazuje samopodpisany certyfikat TLS stratum: odcisk SHA-256 do przypięcia i pełny PEM do wklejenia w firmware górnika (np. pole „Stratum SSL Cert” w AxeOS).',
55: 'Trudność początkowa',
56: 'Stratum',
57: 'Stratum (TLS)',
58: 'Serwer stratum',
59: 'Stratum TLS',
60: 'Certyfikat TLS Stratum',
61: 'Subskrybuje temat ZMQ hashblock Bitcoin Core w celu wykrywania bloków w ułamku sekundy. Odpytywanie RPC pozostaje aktywne jako rezerwa.',
62: 'Port TLS (wewnętrzny)',
63: 'Certyfikat TLS usunięty — uruchom ponownie usługę, aby wygenerować nowy',
64: 'Stratum TLS przyjmuje połączenia',
65: 'Stratum TLS nie przyjmuje połączeń',
66: 'Szyfrowany TLS punkt końcowy stratum (certyfikat samopodpisany — zobacz akcję Certyfikat TLS Stratum)',
67: 'Panel Kamado jest nieosiągalny',
68: 'Panel Kamado jest osiągalny',
69: 'Serwer stratum przyjmuje połączenia',
70: 'Serwer stratum nie przyjmuje połączeń',
71: 'Użyj tego do przypinania odcisku w firmware górników, które to obsługują. Zmienia się tylko przy ponownym wygenerowaniu certyfikatu.',
72: 'Szczegółowość logów kamado-api.',
73: 'Ostrzeżenie',
74: 'Panel WWW',
75: 'Kanał bloków ZMQ',
76: 'Powiadomienia ZMQ o blokach',
77: 'Kanał bloków ZMQ jest nieaktualny — powiadomienia o blokach wracają do odpytywania RPC',
78: 'Powiadomienia ZMQ o blokach napływają',
79: 'Zeruje liczniki opóźnień aktualizacji bloków (średnia, ostatni, zmarnowana praca, liczba bloków). Użyj po dostrojeniu ZMQ lub ckpool, aby rozpocząć nowe pomiary.',
80: 'blok(i) przesłane do bitcoind, ale niepotwierdzone — sprawdź logi Bitcoin Core',
81: 'sekundy',
82: 'Port stratum',
83: 'Port TCP, na którym nasłuchuje nieszyfrowany serwer stratum. StartOS próbuje również udostępnić pulę pod tym samym numerem portu w sieci, więc zwykle jest to port podawany górnikom — po zapisaniu sprawdź interfejs Stratum, ponieważ system wybierze inny port zewnętrzny, jeśli ten jest zajęty.',
84: 'Port TLS stratum',
85: 'Port TCP, na którym stunnel przyjmuje połączenia stratum TLS. Używany tylko, gdy Stratum TLS jest włączone poniżej.',
},
fr_FR: {
0: '(pas encore généré — démarrez le service une fois)',
1: 'Accepte les connexions stratum en TLS via un sidecar stunnel sur un second port. Un certificat auto-signé est généré une fois et conservé — les mineurs doivent faire confiance au certificat (voir laction Certificat TLS Stratum) ou se connecter sans vérification.',
2: 'Toutes les soumissions de blocs confirmées',
3: 'Tous les compteurs de latence (nombre, moyenne, dernier, travail gaspillé) ont été remis à zéro. Les nouvelles mesures saccumuleront à partir du prochain bloc.',
4: 'Déjà absent',
5: 'URL de base dune instance mempool auto-hébergée pour les liens du tableau de bord (p. ex. https://mempool.example.com). Kamado ajoute /address/<addr> et /block/<hash>, linstance doit donc suivre le schéma dURL standard de mempool.space. Laissez vide pour utiliser le mempool.space public.',
6: 'RPC de Bitcoin Core',
7: 'Le RPC de Bitcoin Core est injoignable',
8: 'Soumission de blocs',
9: 'Statistiques de latence des blocs remises à zéro',
10: 'Plafond de lalgorithme vardiff. 0 signifie aucune limite.',
11: 'Certificat (PEM)',
12: 'Supprime le certificat TLS stratum actuel afin quun nouveau soit généré au prochain démarrage du service. Utilisez ceci pour renouveler un certificat expiré ou non fiable.',
13: 'Tag coinbase',
14: 'Configurer',
15: 'Connectez les mineurs avec stratum+ssl:// à linterface Stratum (TLS). Le certificat est auto-signé : collez le PEM dans un firmware acceptant une racine personnalisée, épinglez lempreinte ou désactivez la vérification.',
16: 'Connecté à Bitcoin Core',
17: 'Port stratum TLS côté conteneur. Le port accessible de lextérieur est indiqué sur linterface Stratum (TLS).',
18: 'URL dexplorateur de blocs personnalisé',
19: 'Personnalisez vardiff, TLS, notifications de blocs, journalisation et liens dexplorateur',
20: 'Débogage',
21: 'Déconnecte les clients nayant soumis aucune part depuis ce nombre de secondes. 0 désactive la déconnexion pour inactivité.',
22: 'Affiche un instantané complet : synchronisation de Bitcoin Core, santé de ckpool, mineurs connectés, hashrate, blocs trouvés et diagnostic des soumissions.',
23: 'Déconnexion inactifs (secondes)',
24: 'Activez dabord Stratum TLS dans Configurer',
25: 'Erreur',
26: 'ÉCHEC — voir les détails ci-dessus',
27: 'Impossible de réinitialiser les statistiques de latence — lAPI Kamado na pas répondu',
28: 'Empreinte (SHA-256)',
29: 'Plancher de lalgorithme vardiff.',
30: 'Certificat auto-signé complet. Copiez tout le bloc, y compris les marqueurs BEGIN/END CERTIFICATE.',
31: 'Info',
32: 'Cible vardiff initiale pour les nouvelles connexions. Les mineurs de classe Bitaxe se situent généralement autour de 16384.',
33: 'LAPI Kamado est injoignable — le service est peut-être arrêté',
34: 'Kamado Pool utilise les notifications de blocs ZMQ pour détecter le travail obsolète en moins dune seconde — chaque seconde de travail obsolète en mode solo est du hashrate brûlé sur un bloc mort.',
35: 'Niveau de journalisation',
36: 'Difficulté maximale',
37: 'Les mineurs connectés en TLS seront déconnectés au redémarrage et devront accepter ou ré-épingler lempreinte du nouveau certificat.',
38: 'Difficulté minimale',
39: 'Doit être une URL http:// ou https:// sans espaces',
40: 'Étapes suivantes :',
41: 'Aucun fichier de certificat TLS trouvé (TLS na peut-être pas été activé)',
42: 'Aucun fichier de certificat n’était présent.',
43: 'OK — le pool est en bonne santé',
44: 'Point de terminaison stratum en clair. Pointez les mineurs ici avec leur adresse de paiement Bitcoin comme nom dutilisateur',
45: 'État du pool',
46: 'Fournissez la nouvelle empreinte ou le PEM aux mineurs qui épinglent le certificat.',
47: 'Tableau de bord Kamado Pool en temps réel (hashrate, mineurs, blocs, meilleures parts)',
48: 'Régénérer le certificat TLS',
49: 'Supprimé',
50: 'Réinitialiser la latence des blocs',
51: 'Redémarrez Kamado Pool.',
52: 'Exécutez laction Certificat TLS Stratum pour voir la nouvelle empreinte et le PEM.',
53: 'Courte chaîne intégrée dans la transaction coinbase des blocs résolus.',
54: 'Affiche le certificat TLS stratum auto-signé : empreinte SHA-256 à épingler et PEM complet à coller dans le firmware du mineur (p. ex. le champ « Stratum SSL Cert » dAxeOS).',
55: 'Difficulté initiale',
56: 'Stratum',
57: 'Stratum (TLS)',
58: 'Serveur stratum',
59: 'Stratum TLS',
60: 'Certificat TLS Stratum',
61: 'Sabonne au sujet ZMQ hashblock de Bitcoin Core pour détecter les blocs en moins dune seconde. Le sondage RPC reste actif en secours dans tous les cas.',
62: 'Port TLS (interne)',
63: 'Certificat TLS supprimé — redémarrez le service pour en générer un nouveau',
64: 'Le stratum TLS accepte les connexions',
65: 'Le stratum TLS naccepte pas les connexions',
66: 'Point de terminaison stratum chiffré en TLS (certificat auto-signé — voir laction Certificat TLS Stratum)',
67: 'Le tableau de bord Kamado est injoignable',
68: 'Le tableau de bord Kamado est joignable',
69: 'Le serveur stratum accepte les connexions',
70: 'Le serveur stratum naccepte pas les connexions',
71: 'Utilisez ceci pour l’épinglage dempreinte sur les firmwares de mineurs compatibles. Ne change que lorsque le certificat est régénéré.',
72: 'Verbosité des journaux de kamado-api.',
73: 'Avertissement',
74: 'Tableau de bord web',
75: 'Flux de blocs ZMQ',
76: 'Notifications de blocs ZMQ',
77: 'Le flux de blocs ZMQ est périmé — les notifications de blocs se rabattent sur le sondage RPC',
78: 'Les notifications de blocs ZMQ arrivent',
79: 'Remet à zéro les compteurs de latence de mise à jour des blocs (moyenne, dernier, travail gaspillé, nombre). Utilisez ceci après avoir réglé ZMQ ou ckpool pour repartir sur des mesures neuves.',
80: 'bloc(s) soumis à bitcoind mais non confirmés — vérifiez les journaux de Bitcoin Core',
81: 'secondes',
82: 'Port stratum',
83: 'Port TCP sur lequel le serveur stratum en clair écoute. StartOS tente également de publier le pool sur ce même numéro de port sur votre réseau : cest donc normalement le port à donner à vos mineurs. Vérifiez linterface Stratum après enregistrement, car le système choisira un autre port externe si celui-ci est déjà pris.',
84: 'Port TLS stratum',
85: 'Port TCP sur lequel stunnel accepte les connexions stratum TLS. Utilisé uniquement lorsque Stratum TLS est activé ci-dessous.',
},
} satisfies Record<string, LangDict>
+8
View File
@@ -0,0 +1,8 @@
/**
* Plumbing. DO NOT EDIT this file.
*/
import { setupI18n } from '@start9labs/start-sdk'
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
import translations from './dictionaries/translations'
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
+11
View File
@@ -0,0 +1,11 @@
/**
* Plumbing. DO NOT EDIT.
*/
export { createBackup } from './backups'
export { main } from './main'
export { init, uninit } from './init'
export { actions } from './actions'
import { buildManifest } from '@start9labs/start-sdk'
import { manifest as sdkManifest } from './manifest'
import { versionGraph } from './versions'
export const manifest = buildManifest(versionGraph, sdkManifest)
+18
View File
@@ -0,0 +1,18 @@
import { sdk } from '../sdk'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { versionGraph } from '../versions'
import { actions } from '../actions'
import { restoreInit } from '../backups'
import { seedFiles } from './seedFiles'
export const init = sdk.setupInit(
restoreInit,
versionGraph,
seedFiles,
setInterfaces,
setDependencies,
actions,
)
export const uninit = sdk.setupUninit(versionGraph)
+11
View File
@@ -0,0 +1,11 @@
import { storeJson } from '../fileModels/store.json'
import { sdk } from '../sdk'
/**
* Merging {} materializes every `.catch()` default in the store schema, so a
* fresh install gets a fully-populated store.json and an existing one is
* healed if fields are missing (e.g. after a restore from an older backup).
*/
export const seedFiles = sdk.setupOnInit(async (effects) => {
await storeJson.merge(effects, {})
})
+103
View File
@@ -0,0 +1,103 @@
import { storeJson } from './fileModels/store.json'
import { i18n } from './i18n'
import { sdk } from './sdk'
import {
defaultStratumPort,
defaultStratumTlsPort,
stratumHostId,
stratumTlsHostId,
uiHostId,
uiPort,
} from './utils'
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
// Stratum ports are user config. Read reactively so changing them in the
// Configure action re-runs this and rebinds the interfaces — the same
// mechanism that adds/removes the TLS interface when TLS is toggled.
const ports = await storeJson
.read((s) => ({
stratum: s.stratumPort,
stratumTls: s.stratumTlsPort,
tlsEnabled: s.tlsEnabled,
}))
.const(effects)
const stratumPort = ports?.stratum ?? defaultStratumPort
const stratumTlsPort = ports?.stratumTls ?? defaultStratumTlsPort
// Web dashboard
const uiMulti = sdk.MultiHost.of(effects, uiHostId)
const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
})
const ui = sdk.createInterface(effects, {
name: i18n('Web Dashboard'),
id: 'ui',
description: i18n(
'Real-time Kamado Pool dashboard (hashrate, miners, blocks, best shares)',
),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const uiReceipt = await uiMultiOrigin.export([ui])
const receipts = [uiReceipt]
// Plaintext stratum — a raw TCP interface. Unlike StartOS 0.3.x, 0.4.0
// forwards raw TCP on the LAN, so miners connect directly to the host at
// the assigned external port; no router forward or simpleproxy needed.
const stratumMulti = sdk.MultiHost.of(effects, stratumHostId)
const stratumOrigin = await stratumMulti.bindPort(stratumPort, {
protocol: null,
preferredExternalPort: stratumPort,
addSsl: null,
secure: { ssl: false },
})
const stratum = sdk.createInterface(effects, {
name: i18n('Stratum'),
id: 'stratum',
description: i18n(
'Plaintext stratum endpoint. Point miners here with their Bitcoin payout address as the username',
),
type: 'api',
masked: false,
schemeOverride: { ssl: 'stratum+ssl', noSsl: 'stratum+tcp' },
username: null,
path: '',
query: {},
})
receipts.push(await stratumOrigin.export([stratum]))
// TLS stratum (conditional) — stunnel terminates TLS with the persisted
// package-managed certificate and forwards to ckpool's loopback-only
// second bind. The OS sees a raw TCP port; TLS lives at the app layer, so
// the noSsl scheme is deliberately stratum+ssl.
if (ports?.tlsEnabled) {
const tlsMulti = sdk.MultiHost.of(effects, stratumTlsHostId)
const tlsOrigin = await tlsMulti.bindPort(stratumTlsPort, {
protocol: null,
preferredExternalPort: stratumTlsPort,
addSsl: null,
secure: { ssl: false },
})
const stratumTls = sdk.createInterface(effects, {
name: i18n('Stratum (TLS)'),
id: 'stratum-tls',
description: i18n(
'TLS-encrypted stratum endpoint (self-signed certificate — see the Stratum TLS Certificate action)',
),
type: 'api',
masked: false,
schemeOverride: { ssl: 'stratum+ssl', noSsl: 'stratum+ssl' },
username: null,
path: '',
query: {},
})
receipts.push(await tlsOrigin.export([stratumTls]))
}
return receipts
})
+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,
)
})
+34
View File
@@ -0,0 +1,34 @@
export const short = {
en_US: 'Modern solo Bitcoin mining pool with real-time dashboard',
es_ES: 'Pool moderno de minería solo de Bitcoin con panel en tiempo real',
de_DE: 'Moderner Solo-Bitcoin-Mining-Pool mit Echtzeit-Dashboard',
pl_PL: 'Nowoczesna solowa kopalnia Bitcoina z panelem czasu rzeczywistego',
fr_FR:
'Pool de minage solo Bitcoin moderne avec tableau de bord en temps réel',
}
export const long = {
en_US:
'Kamado Pool is a solo Bitcoin mining pool built on a patched fork of CKPool-solo. Unlike wrappers that read periodic stats files, Kamado talks directly to CKPools Unix socket API to expose real-time per-client hashrate, difficulty, hardware detection, full block history, and best-share leaderboards (current round and all-time) via a Svelte dashboard with WebSocket push. Miners connect to the stratum port with their payout address as username — block rewards go straight to them.',
es_ES:
'Kamado Pool es un pool de minería solo de Bitcoin basado en un fork parcheado de CKPool-solo. Kamado se comunica directamente con la API de socket Unix de CKPool para exponer en tiempo real el hashrate por cliente, la dificultad, la detección de hardware, el historial completo de bloques y las mejores participaciones (ronda actual e histórica) mediante un panel Svelte con WebSocket. Los mineros se conectan al puerto stratum usando su dirección de pago como usuario.',
de_DE:
'Kamado Pool ist ein Solo-Bitcoin-Mining-Pool auf Basis eines gepatchten CKPool-solo-Forks. Kamado kommuniziert direkt mit der Unix-Socket-API von CKPool und zeigt in Echtzeit Hashrate pro Client, Schwierigkeit, Hardware-Erkennung, vollständige Blockhistorie und Bestwert-Ranglisten (aktuelle Runde und Allzeit) über ein Svelte-Dashboard mit WebSocket-Push. Miner verbinden sich mit dem Stratum-Port und ihrer Auszahlungsadresse als Benutzername.',
pl_PL:
'Kamado Pool to solowa kopalnia Bitcoina oparta na załatanym forku CKPool-solo. Kamado komunikuje się bezpośrednio z API gniazda Unix CKPool, udostępniając w czasie rzeczywistym hashrate poszczególnych klientów, trudność, wykrywanie sprzętu, pełną historię bloków oraz rankingi najlepszych udziałów (bieżąca runda i wszech czasów) w panelu Svelte z WebSocket. Górnicy łączą się z portem stratum, podając adres wypłaty jako nazwę użytkownika.',
fr_FR:
'Kamado Pool est un pool de minage solo Bitcoin basé sur un fork corrigé de CKPool-solo. Kamado communique directement avec lAPI socket Unix de CKPool pour exposer en temps réel le hashrate par client, la difficulté, la détection du matériel, lhistorique complet des blocs et les classements des meilleures parts (manche en cours et record absolu) via un tableau de bord Svelte avec WebSocket. Les mineurs se connectent au port stratum avec leur adresse de paiement comme nom dutilisateur.',
}
export const bitcoindDescription = {
en_US:
'Used to build block templates, submit found blocks, and receive new block notifications via RPC + ZMQ.',
es_ES:
'Se usa para construir plantillas de bloques, enviar bloques encontrados y recibir notificaciones de nuevos bloques mediante RPC + ZMQ.',
de_DE:
'Wird verwendet, um Blockvorlagen zu erstellen, gefundene Blöcke einzureichen und neue Blockbenachrichtigungen über RPC + ZMQ zu erhalten.',
pl_PL:
'Służy do budowania szablonów bloków, przesyłania znalezionych bloków i odbierania powiadomień o nowych blokach przez RPC + ZMQ.',
fr_FR:
'Utilisé pour construire les modèles de blocs, soumettre les blocs trouvés et recevoir les notifications de nouveaux blocs via RPC + ZMQ.',
}
+35
View File
@@ -0,0 +1,35 @@
import { setupManifest } from '@start9labs/start-sdk'
import { bitcoindDescription, long, short } from './i18n'
export const manifest = setupManifest({
id: 'kamado-pool',
title: 'Kamado Pool',
license: 'GPL-3.0',
packageRepo: 'https://something.com/satoshi/KamadoPool-StartOS-040',
upstreamRepo: 'https://something.com/satoshi/KamadoPool',
marketingUrl: 'https://something.com/satoshi/KamadoPool',
donationUrl: null,
description: { short, long },
volumes: ['main', 'ckpool'],
images: {
main: {
source: {
dockerBuild: {
dockerfile: 'Dockerfile',
workdir: '.',
},
},
arch: ['x86_64', 'aarch64'],
},
},
dependencies: {
bitcoind: {
description: bitcoindDescription,
optional: false,
metadata: {
title: 'Bitcoin Core',
icon: 'https://raw.githubusercontent.com/Start9Labs/bitcoin-core-startos/refs/heads/30.x/dep-icon.svg',
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
import { StartSdk } from '@start9labs/start-sdk'
import { manifest } from './manifest'
/**
* Plumbing. DO NOT EDIT.
*
* The exported "sdk" const is used throughout this package codebase.
*/
export const sdk = StartSdk.of().withManifest(manifest).build(true)
+232
View File
@@ -0,0 +1,232 @@
import { T } from '@start9labs/start-sdk'
import {
rpcHostId as btcRpcHostId,
rpcPort as btcRpcPort,
zmqHostId as btcZmqHostId,
zmqPortBlock as btcZmqPortBlock,
} from 'bitcoin-core-startos/startos/utils'
import { i18n } from './i18n'
import { sdk } from './sdk'
// ── Ports ────────────────────────────────────────────────────────────────────
/**
* kamado-api HTTP/WebSocket dashboard. Fixed: the OS reverse-proxies this
* interface, so the browser-facing port is never this number anyway.
*/
export const uiPort = 8080
/**
* Stratum port defaults. The live values are user config (see store.json) —
* each one sets both ckpool's/stunnel's in-container bind AND the interface's
* preferred external port, so the number the user picks is the number miners
* connect to whenever the OS can grant it.
*/
export const defaultStratumPort = 3333
export const defaultStratumTlsPort = 3334
/**
* ckpool's second, loopback-only stratum bind. stunnel forwards decrypted TLS
* traffic here. ckpool tags clients by serverurl index (server == 1 -> TLS),
* which the dashboard reads to render a lock icon next to encrypted miners —
* no source-IP heuristics needed. The bind is harmless when TLS is disabled
* (nothing connects to it). Never user-visible, so it stays fixed — but it
* does occupy a port inside the container, hence validatePorts() below.
*/
export const tlsInternalPort = 3437
/**
* Ports already taken inside the service container, mapped to what occupies
* them. A user-chosen stratum port may not collide with these.
*/
const occupiedPorts: Record<number, string> = {
[uiPort]: 'the web dashboard',
[tlsInternalPort]: "ckpool's internal TLS bind",
}
/**
* Reject stratum port choices that cannot work: the two stratum ports would
* collide with each other, or with a port already bound inside the container.
* Returns a human-readable reason, or null when the pair is usable.
*
* All of these processes share one container (and therefore one network
* namespace), so a collision is a real bind failure at startup — better to
* refuse it in the config action than to restart-loop later.
*/
export function validatePorts(opts: {
stratumPort: number
stratumTlsPort: number
tlsEnabled: boolean
}): string | null {
const { stratumPort, stratumTlsPort, tlsEnabled } = opts
const clash = occupiedPorts[stratumPort]
if (clash) return `Stratum port ${stratumPort} is already used by ${clash}.`
if (!tlsEnabled) return null
const tlsClash = occupiedPorts[stratumTlsPort]
if (tlsClash)
return `Stratum TLS port ${stratumTlsPort} is already used by ${tlsClash}.`
if (stratumPort === stratumTlsPort)
return `The stratum port and the stratum TLS port must differ (both are ${stratumPort}).`
return null
}
// ── Host ids (the `sdk.MultiHost.of` groups) ─────────────────────────────────
export const uiHostId = 'ui'
export const stratumHostId = 'stratum'
export const stratumTlsHostId = 'stratum-tls'
// ── In-container paths ───────────────────────────────────────────────────────
/** main volume mountpoint: SQLite DB (data/kamado.db) and TLS certs (tls/) */
export const kamadoRoot = '/root/.kamado'
/** ckpool volume mountpoint: ckpool's own state + daily logs (logs/) */
export const ckpoolRoot = '/root/.ckpool'
/** bitcoind's data dir (read-only dependency mount) — used for .cookie auth */
export const btcMountpoint = '/mnt/bitcoind'
export const ckpoolLogDir = `${ckpoolRoot}/logs`
export const ckpoolLogFile = `${ckpoolLogDir}/ckpool.log`
export const ckpoolSocketDir = '/run/ckpool'
export const kamadoDataDir = `${kamadoRoot}/data`
export const kamadoDbPath = `${kamadoDataDir}/kamado.db`
export const tlsDir = `${kamadoRoot}/tls`
/** Files that make up the persisted stratum TLS certificate (relative to the main volume) */
export const tlsVolumeFiles = [
'tls/stratum.crt',
'tls/stratum.key',
'tls/stratum.pem',
'tls/cert_version',
'tls/fingerprint.txt',
]
// ── Misc constants ───────────────────────────────────────────────────────────
/**
* CKPool loglevel: 6 = LOG_INFO, required for share-level logging
* (Accepted/Rejected client lines) used by the stats feature.
*/
export const ckpoolLogLevel = '6'
export const logLevels = {
debug: i18n('Debug'),
info: i18n('Info'),
warn: i18n('Warn'),
error: i18n('Error'),
}
export type LogLevel = keyof typeof logLevels
// ── Health payload served by kamado-api at /api/health ──────────────────────
export type HealthPayload = {
ok: boolean
ckpool: boolean
bitcoin: boolean
submit_gap: number
zmq_stale: boolean
last_error?: string
}
/** Minimal structural type for anything exec-able (SubContainer, temp subcontainer). */
export type Execable = {
exec(command: string[]): Promise<{
exitCode: number | null
stdout: string | Buffer
stderr: string | Buffer
}>
}
/**
* Fetch a URL from *inside* the service's network namespace by exec'ing curl
* in a subcontainer. Daemon and standalone health checks run in the host JS
* runtime, which cannot reach the container's 127.0.0.1 directly.
*/
export async function curlJson<Res>(
sub: Execable,
url: string,
opts: { method?: 'GET' | 'POST'; timeoutSeconds?: number } = {},
): Promise<Res | null> {
const args = ['curl', '-sf', '--max-time', String(opts.timeoutSeconds ?? 10)]
if (opts.method === 'POST') args.push('-X', 'POST')
args.push(url)
const res = await sub.exec(args).catch(() => null)
if (!res || res.exitCode !== 0) return null
try {
return JSON.parse(res.stdout.toString()) as Res
} catch {
return null
}
}
/**
* Bridge address (`10.0.3.1:<assigned external port>`) of a dependency's
* binding, as a minimal reactive value. Chain `.const()` in main: the mapped
* string only changes when the address itself does, so main restarts exactly
* on dependency install/uninstall/port-change and never on dependency
* updates. Chain `.once()` in an action context. Resolves null while the
* dependency is absent. Drop-in for the planned SDK
* `sdk.host.getBridgeAddress` helper.
*/
export function bridgeAddress(
effects: T.Effects,
opts: { packageId: string; hostId: string; internalPort: number },
): { const(): Promise<string | null>; once(): Promise<string | null> } {
const watchable = async () => {
const osIp = await sdk.getOsIp(effects)
return sdk.host.get(
effects,
{ packageId: opts.packageId, hostId: opts.hostId },
(host) => {
const port = host?.bindings[opts.internalPort]?.net.assignedPort
if (port == null) return null
return `${osIp}:${port}`
},
)
}
return {
const: async () => (await watchable()).const(),
once: async () => (await watchable()).once(),
}
}
/**
* bitcoind's RPC and ZMQ-block endpoints over the LXC bridge. Two reactive
* bridge-address watches — one per bitcoind host — each chained `.const()`,
* so main restarts only when an address actually changes: a bitcoind update
* is 0 restarts, bitcoind installed after Kamado is one healing restart, and
* uninstall is one restart. Each resolves null while bitcoind is absent (or,
* for ZMQ, while bitcoind has ZMQ disabled).
*/
export const bitcoindBridge = async (effects: T.Effects) => {
const rpc = await bridgeAddress(effects, {
packageId: 'bitcoind',
hostId: btcRpcHostId,
internalPort: btcRpcPort,
}).const()
const zmqBlock = await bridgeAddress(effects, {
packageId: 'bitcoind',
hostId: btcZmqHostId,
internalPort: btcZmqPortBlock,
}).const()
return { rpc, zmqBlock }
}
/**
* Parse bitcoind's RPC cookie (`__cookie__:<random>`) into credentials.
* Returns null if the cookie is absent or malformed (e.g. bitcoind has not
* started yet, so the cookie file does not exist).
*/
export function parseCookie(
cookie: string | null | undefined,
): { user: string; password: string } | null {
if (!cookie) return null
const trimmed = cookie.trim()
const i = trimmed.indexOf(':')
if (i <= 0) return null
return { user: trimmed.slice(0, i), password: trimmed.slice(i + 1) }
}
+77
View File
@@ -0,0 +1,77 @@
import { IMPOSSIBLE, VersionInfo, YAML } from '@start9labs/start-sdk'
import { readFile, rm } from 'fs/promises'
import { storeJson } from '../fileModels/store.json'
import { defaultStratumPort, defaultStratumTlsPort, LogLevel } from '../utils'
/** Shape of the 0.3.5.1 wrapper's config.yaml (main volume, start9/config.yaml). */
type LegacyConfig = {
bitcoind?: { type?: string }
'stratum-port'?: number
tls?: { enabled?: string; port?: number }
'zmq-enabled'?: boolean
advanced?: {
'pool-identifier'?: string
startdiff?: number
mindiff?: number
maxdiff?: number
dropidle?: number
'log-level'?: LogLevel
'mempool-explorer'?: { type?: string; url?: string }
}
}
export const current = VersionInfo.of({
version: '0.2.0:0',
releaseNotes: {
en_US:
'StartOS 0.4.0 port. Stratum is now exposed directly on the LAN as a raw TCP interface (no more router forwards or simpleproxy), Bitcoin Core is reached over the internal network bridge with cookie authentication, and settings moved from Config to the Configure action. Existing settings, found-block history, and the stratum TLS certificate are migrated automatically.',
es_ES:
'Adaptación a StartOS 0.4.0. Stratum ahora se expone directamente en la LAN como interfaz TCP, Bitcoin Core se alcanza a través del puente de red interno con autenticación por cookie, y la configuración se movió a la acción Configurar. Los ajustes existentes, el historial de bloques y el certificado TLS se migran automáticamente.',
de_DE:
'Portierung auf StartOS 0.4.0. Stratum wird jetzt direkt im LAN als TCP-Schnittstelle bereitgestellt, Bitcoin Core wird über die interne Netzwerk-Bridge mit Cookie-Authentifizierung erreicht, und die Einstellungen sind in die Aktion „Konfigurieren“ umgezogen. Bestehende Einstellungen, Blockhistorie und das TLS-Zertifikat werden automatisch migriert.',
pl_PL:
'Port na StartOS 0.4.0. Stratum jest teraz udostępniany bezpośrednio w sieci LAN jako interfejs TCP, Bitcoin Core jest osiągany przez wewnętrzny mostek sieciowy z uwierzytelnianiem cookie, a ustawienia przeniesiono do akcji Konfiguruj. Istniejące ustawienia, historia bloków i certyfikat TLS są migrowane automatycznie.',
fr_FR:
'Portage vers StartOS 0.4.0. Stratum est désormais exposé directement sur le LAN comme interface TCP, Bitcoin Core est atteint via le pont réseau interne avec authentification par cookie, et les réglages ont migré vers laction Configurer. Les réglages existants, lhistorique des blocs et le certificat TLS sont migrés automatiquement.',
},
migrations: {
up: async ({ effects }) => {
// Migrate from the 0.3.5.1 wrapper: its config.yaml lives on the main
// volume under start9/. The SQLite DB (data/kamado.db), TLS certs
// (tls/) and the ckpool volume carry over untouched — only the config
// format changed.
const configYaml: LegacyConfig | undefined = await readFile(
'/media/startos/volumes/main/start9/config.yaml',
'utf-8',
).then(YAML.parse, () => undefined)
if (configYaml) {
const adv = configYaml.advanced ?? {}
const mempool = adv['mempool-explorer']
await storeJson.merge(effects, {
// Carried over so miners pointed at the old forwarded port keep
// working: the same number is requested as the interface's
// preferred external port.
stratumPort: configYaml['stratum-port'] ?? defaultStratumPort,
stratumTlsPort: configYaml.tls?.port ?? defaultStratumTlsPort,
coinbaseTag: adv['pool-identifier'] ?? '/Kamado/',
startDiff: adv.startdiff ?? 16384,
minDiff: adv.mindiff ?? 1000,
maxDiff: adv.maxdiff ?? 0,
dropIdle: adv.dropidle ?? 0,
logLevel: adv['log-level'] ?? 'info',
zmqEnabled: configYaml['zmq-enabled'] ?? true,
tlsEnabled: configYaml.tls?.enabled === 'enabled',
mempoolExplorerUrl:
mempool?.type === 'custom' && mempool.url ? mempool.url : null,
})
// remove old start9 dir
await rm('/media/startos/volumes/main/start9', {
recursive: true,
}).catch(console.error)
}
},
down: IMPOSSIBLE,
},
})
+7
View File
@@ -0,0 +1,7 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { current } from './current'
export const versionGraph = VersionGraph.of({
current,
other: [],
})