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
+109 -16
View File
@@ -19,8 +19,15 @@ import {
parseCookie,
tlsDir,
ckpoolTlsLoopbackPort,
ckpoolPublicTlsLoopbackPort,
publicCertPath,
publicDomains,
stratumInternalPort,
stratumTlsHostId,
stratumTlsInternalPort,
stratumServers,
stratumServerUrls,
stunnelConfDir,
uiPort,
} from './utils'
@@ -59,6 +66,12 @@ export const main = sdk.setupMain(async ({ effects }) => {
// changes ports — and never on a routine bitcoind update.
const bitcoind = await bitcoindBridge(effects)
// Clearnet domains the user attached to the Stratum (TLS) interface. There
// is no config field for this: the domain is added in the StartOS interface
// UI, and attaching or removing one restarts main through the same reactive
// mechanism as everything else above.
const tlsDomains = await publicDomains(effects, stratumTlsHostId).const()
// All Kamado processes (kamado-api, ckpool, stunnel) share ONE
// subcontainer, mirroring the single 0.3.x container: kamado-api reaches
// ckpool's Unix socket in /run/ckpool and tails its log file without any
@@ -139,10 +152,9 @@ export const main = sdk.setupMain(async ({ effects }) => {
btcsig: store.coinbaseTag,
blockpoll: 100,
update_interval: 30,
serverurl: [
`0.0.0.0:${stratumInternalPort}`,
`127.0.0.1:${ckpoolTlsLoopbackPort}`,
],
// Fixed three-entry array; see stratumServerUrls for why it never
// varies with the TLS settings.
serverurl: stratumServerUrls(),
mindiff: store.minDiff,
startdiff: store.startDiff,
maxdiff: store.maxDiff,
@@ -160,12 +172,73 @@ export const main = sdk.setupMain(async ({ effects }) => {
ckpoolConfTemplate,
)
// Pull the OS-managed certificate for each attached public domain. StartOS
// provisions these over ACME, so they chain to a public CA and any miner
// with a normal root store validates them with nothing pasted in.
//
// Failures are per-domain and non-fatal: right after a domain is added the
// certificate may not be issued yet (DNS still propagating, ACME challenge
// pending). Skipping it leaves the rest of the pool running and the Stratum
// TLS health check reports the shortfall, rather than a domain typo taking
// the whole stratum server down.
await mkdir(`${kamadoSub.rootfs}${stunnelConfDir}`, { recursive: true })
const publicCerts: { fqdn: string; path: string }[] = []
for (const fqdn of tlsDomains) {
try {
const chain = await sdk.getSslCertificate(effects, [fqdn]).const()
const key = await sdk.getSslKey(effects, { hostnames: [fqdn] })
const path = publicCertPath(fqdn)
// stunnel takes the chain and the key from a single file. Concatenating
// the whole fullchain means the server presents its intermediates,
// which is what lets a miner validate without a pinned copy.
await writeFile(
`${kamadoSub.rootfs}${path}`,
[...chain, key].join('\n'),
{ mode: 0o600 },
)
publicCerts.push({ fqdn, path })
console.info(`kamado-tls: serving CA-issued certificate for ${fqdn}`)
} catch (e) {
console.error(
`kamado-tls: no certificate available for ${fqdn} yet — skipping`,
e,
)
}
}
// stunnel runs when there is at least one certificate to serve. The two
// paths are independent: local TLS is the user's toggle, public TLS follows
// whatever domains are attached to the interface.
const stunnelEnabled = store.tlsEnabled || publicCerts.length > 0
// stunnel.conf is rendered here rather than shipped as a static asset so it
// stays next to the ports it references. `connect` targets ckpool's loopback
// bind so TLS clients keep getting tagged server == 1 (the dashboard's lock
// icon); `accept` is the fixed in-container TLS port, which the OS forwards
// the user's chosen external port to.
if (store.tlsEnabled) {
// stays next to the ports it references. `accept` is the fixed in-container
// TLS port, which the OS forwards the user's chosen external port to.
//
// Certificate selection is by SNI, and it degrades in exactly the direction
// we need. The primary service's certificate is what a client gets when it
// sends no SNI or an unrecognised one — which is precisely the miner that
// connected to a bare LAN IP and therefore cannot use a public certificate
// anyway. A miner that connected by domain name sends SNI, matches a
// secondary service, and gets the CA-issued certificate for that name.
//
// Each service `connect`s to a different ckpool loopback bind so ckpool
// tags the two paths with different serverurl indices, which is how the
// dashboard's lock badge can name the certificate in use.
if (stunnelEnabled) {
// Local TLS owns the primary service when it's on. With it off, the first
// public certificate takes over as the default so the port still answers
// a no-SNI client (encrypted, just not verifiable against a bare IP).
const primary = store.tlsEnabled
? { cert: `${tlsDir}/stratum.pem`, connect: ckpoolTlsLoopbackPort }
: {
cert: publicCerts[0].path,
connect: ckpoolPublicTlsLoopbackPort,
}
// Whichever domain was promoted to primary must not also appear as a
// secondary — stunnel would be routing an SNI name to itself.
const sniCerts = store.tlsEnabled ? publicCerts : publicCerts.slice(1)
const stunnelConf = [
'foreground = yes',
'pid =',
@@ -186,16 +259,26 @@ export const main = sdk.setupMain(async ({ effects }) => {
'',
'[stratum]',
`accept = 0.0.0.0:${stratumTlsInternalPort}`,
`connect = 127.0.0.1:${ckpoolTlsLoopbackPort}`,
`cert = ${tlsDir}/stratum.pem`,
`connect = 127.0.0.1:${primary.connect}`,
`cert = ${primary.cert}`,
// No client-cert auth — stratum over TLS is opportunistic encryption;
// the stratum protocol layer handles miner auth via username.
'verify = 0',
'',
...sniCerts.flatMap(({ fqdn, path }) => [
`[stratum-${fqdn}]`,
`sni = stratum:${fqdn}`,
`connect = 127.0.0.1:${ckpoolPublicTlsLoopbackPort}`,
`cert = ${path}`,
'verify = 0',
'',
]),
].join('\n')
await mkdir(`${kamadoSub.rootfs}/etc/stunnel`, { recursive: true })
await writeFile(`${kamadoSub.rootfs}/etc/stunnel/stratum.conf`, stunnelConf)
await writeFile(
`${kamadoSub.rootfs}${stunnelConfDir}/stratum.conf`,
stunnelConf,
)
}
/**
@@ -238,6 +321,13 @@ export const main = sdk.setupMain(async ({ effects }) => {
: '',
// Empty means "use mempool.space defaults" for dashboard links.
MEMPOOL_BASE_URL: store.mempoolExplorerUrl ?? '',
// Tells the dashboard what each ckpool serverurl index means, so
// the lock badge can name the certificate a miner is using instead
// of assuming a bind order. Labels only mention domains we actually
// managed to load a certificate for.
STRATUM_SERVERS: JSON.stringify(
stratumServers(publicCerts.map((c) => c.fqdn)),
),
},
},
ready: {
@@ -380,11 +470,11 @@ export const main = sdk.setupMain(async ({ effects }) => {
: null,
)
.addDaemon('stunnel', () =>
store.tlsEnabled
stunnelEnabled
? {
subcontainer: kamadoSub,
exec: {
command: ['stunnel4', '/etc/stunnel/stratum.conf'],
command: ['stunnel4', `${stunnelConfDir}/stratum.conf`],
},
ready: {
display: i18n('Stratum TLS'),
@@ -402,7 +492,10 @@ export const main = sdk.setupMain(async ({ effects }) => {
},
),
},
requires: ['tls-cert'],
// The self-signed generator only runs when local TLS is on; with
// only public certificates configured there is no 'tls-cert'
// oneshot to wait for (they were written during setup above).
requires: store.tlsEnabled ? ['tls-cert'] : ['dirs'],
}
: null,
)