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 loopback-only stratum binds. stunnel forwards decrypted TLS traffic * to one of these depending on which certificate terminated the connection, * which is how the dashboard tells the two TLS paths apart: ckpool tags every * client with the index of the bind it arrived on, and index -> meaning is * declared to kamado-api via STRATUM_SERVERS (see stratumServers below). * * Both are bound unconditionally, even when nothing is listening in front of * them. That is deliberate — see stratumServerUrls. */ /** Self-signed certificate, for miners on the local network. */ export const ckpoolTlsLoopbackPort = 3437 /** CA-issued certificate for a StartOS public domain (ACME / Let's Encrypt). */ export const ckpoolPublicTlsLoopbackPort = 3438 /** * 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. * * Deliberately not conditional on the local-TLS toggle: the TLS interface is * bound unconditionally now (so a public domain can be attached to it), which * means the two external ports always collide if they match — even with the * toggle off. */ export function validatePorts(opts: { stratumPort: number stratumTlsPort: number }): string | null { const { stratumPort, stratumTlsPort } = opts if (stratumPort === stratumTlsPort) return `The stratum port and the stratum TLS port must differ (both are ${stratumPort}).` return null } // ── ckpool serverurl[] contract ────────────────────────────────────────────── /** * How a miner reached the pool. Mirrors state.StratumServer in kamado-api; * the dashboard switches on `kind` to label its lock badge. */ export type StratumServerKind = 'plain' | 'tls-local' | 'tls-public' export type StratumServer = { kind: StratumServerKind; label: string } /** * ckpool's serverurl[] array, in a FIXED order, with every entry bound * unconditionally. * * ckpool tags each client with the index of the bind it arrived on, and the * dashboard turns that index into a connection badge. Emitting the array * conditionally (only the binds currently in use) would renumber the indices * whenever the user toggles local TLS or attaches a domain, silently * relabelling every connected miner. Two idle loopback listeners are a much * cheaper price than an index that means different things over time. * * Keep in lockstep with stratumServers() below. */ export function stratumServerUrls(): string[] { return [ `0.0.0.0:${stratumInternalPort}`, `127.0.0.1:${ckpoolTlsLoopbackPort}`, `127.0.0.1:${ckpoolPublicTlsLoopbackPort}`, ] } /** * The meaning of each stratumServerUrls() entry, handed to kamado-api as * STRATUM_SERVERS so the dashboard can name the transport on hover instead of * assuming a bind order. `publicDomains` only affects the label text — the * array shape is fixed. * * These strings surface in the (English-only) dashboard, not the StartOS UI, * so they deliberately skip i18n. */ export function stratumServers(publicDomains: string[]): StratumServer[] { return [ { kind: 'plain', label: 'Plaintext — not encrypted' }, { kind: 'tls-local', label: 'TLS — self-signed certificate (local network)', }, { kind: 'tls-public', label: publicDomains.length ? `TLS — CA-issued certificate for ${publicDomains.join(', ')}` : 'TLS — CA-issued certificate for a public domain', }, ] } // ── 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` /** * stunnel's config directory, on the subcontainer rootfs rather than a volume. * The OS-managed certificates for public domains are re-fetched from StartOS * on every main run, so — like ckpool.conf and its RPC credentials — they are * written somewhere ephemeral and never persisted. Only the self-signed * certificate lives on the volume, because its fingerprint has to survive * restarts for miners that pin it. */ export const stunnelConfDir = '/etc/stunnel' /** Path of the PEM bundle (chain + key) stunnel serves for `fqdn`. */ export function publicCertPath(fqdn: string): string { return `${stunnelConfDir}/public-${fqdn.replace(/[^a-zA-Z0-9._-]/g, '_')}.pem` } /** 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(), } } /** * Public (clearnet) domains the user has attached to a host, as a minimal * reactive value — the same pattern as bridgeAddress. * * The user adds these in the StartOS interface UI, so this is the package's * only source of truth for "is there a domain to get a CA-issued certificate * for": no config field to drift out of sync with what the OS actually has, * and adding or removing one heals the service with a restart. * * The watched projection is a sorted, comma-joined *string* rather than an * array on purpose: a fresh array is a new reference on every poll, which * would restart main continuously. */ export function publicDomains( effects: T.Effects, hostId: string, ): { const(): Promise; once(): Promise } { const split = (joined: string | null) => joined ? joined.split(',').filter(Boolean) : [] const watchable = async () => sdk.host.get(effects, { hostId }, (host) => Object.keys(host?.publicDomains ?? {}) .sort() .join(','), ) return { const: async () => split(await (await watchable()).const()), once: async () => split(await (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) } }