Expose config options for cleartext stratum port
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
Reference in New Issue
Block a user