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 /** * In-container bind ports. These are FIXED and never user-configurable, which * is load-bearing: a binding is keyed by (hostId, internalPort), so moving an * internal port registers a *new* binding and orphans the old one — StartOS * disables the orphan but keeps listing it, and the user sees a duplicate * interface. Keeping these constant means each host has exactly one binding * for the lifetime of the install, and a port change is a pure rebind that * doesn't even restart the daemons. */ export const stratumInternalPort = 3333 export const stratumTlsInternalPort = 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). */ export const ckpoolTlsLoopbackPort = 3437 /** * Defaults for the user-facing *external* ports (see store.json). These are * what miners connect to; they are requested as each interface's * `preferredExternalPort` and the OS grants them when free. Same numbers as * the internal binds, so the out-of-the-box experience is unchanged. */ export const defaultStratumPort = 3333 export const defaultStratumTlsPort = 3334 /** * Reject external-port choices that cannot work. Since the user no longer * picks any container-side port, the only real conflict left is asking for the * same external port twice. Returns a human-readable reason, or null when the * pair is usable. */ export function validatePorts(opts: { stratumPort: number stratumTlsPort: number tlsEnabled: boolean }): string | null { const { stratumPort, stratumTlsPort, tlsEnabled } = opts if (tlsEnabled && 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) } }