Serve Let's Encrypt certificates for stratum TLS on public domains
This commit is contained in:
+109
-16
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user