Serve Let's Encrypt certificates for stratum TLS on public domains

This commit is contained in:
2026-08-01 06:31:15 +03:00
parent 703ca1b559
commit f118c153a0
11 changed files with 369 additions and 95 deletions
+119 -8
View File
@@ -29,13 +29,19 @@ 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).
* 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
@@ -51,20 +57,79 @@ export const defaultStratumTlsPort = 3334
* 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
tlsEnabled: boolean
}): string | null {
const { stratumPort, stratumTlsPort, tlsEnabled } = opts
const { stratumPort, stratumTlsPort } = opts
if (tlsEnabled && stratumPort === stratumTlsPort)
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'
@@ -86,6 +151,21 @@ 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',
@@ -184,6 +264,37 @@ export function bridgeAddress(
}
}
/**
* 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()`,