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 = { [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( sub: Execable, url: string, opts: { method?: 'GET' | 'POST'; timeoutSeconds?: number } = {}, ): Promise { 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:`) 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; once(): Promise } { 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__:`) 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) } }