Files
KamadoPool-StartOS-040/startos/utils.ts
T

334 lines
13 KiB
TypeScript

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<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(),
}
}
/**
* 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<string[]>; once(): Promise<string[]> } {
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__:<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) }
}