Show domains in pool status

This commit is contained in:
2026-08-18 02:53:40 +03:00
parent a1fa90c66a
commit eb5f4cbf5a
12 changed files with 517 additions and 141 deletions
+85 -74
View File
@@ -19,11 +19,11 @@ import {
parseCookie,
tlsDir,
ckpoolTlsLoopbackPort,
ckpoolPublicTlsLoopbackPort,
publicCertPath,
endpointPorts,
portAssignmentSignature,
publicDomains,
stratumInternalPort,
stratumTlsHostId,
stratumPublicTlsHostId,
stratumTlsInternalPort,
stratumServers,
stratumServerUrls,
@@ -66,11 +66,68 @@ 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()
// Clearnet domains attached to the Stratum (TLS, Public Domain) 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.
//
// Used only to label the connection in the dashboard — the certificates
// themselves are the OS's concern now, so this never gates anything starting.
const tlsDomains = await publicDomains(
effects,
stratumPublicTlsHostId,
).const()
// Warn when the OS could not grant a port we asked for.
//
// `preferredExternalPort` is a request: if the number is already claimed the
// OS silently assigns another, and the first symptom is a miner that cannot
// connect on the port the config form shows. Surfacing it here turns a
// silent substitution into something the user is told about once, naming
// both numbers. Pool Status prints the effective ports on demand.
//
// Read `.const()` so a later reassignment re-fires this; the store field
// that de-dupes the warning is read `.once()` and is deliberately absent
// from the projection above, so writing it cannot restart the service.
const portRequests = await storeJson
.read((s) => ({
stratum: s.stratumPort,
tls: s.stratumTlsPort,
publicTls: s.stratumPublicTlsPort,
}))
.const(effects)
if (portRequests) {
const ports = await endpointPorts(effects, portRequests, 'const')
const mismatched = ports.filter(
(p) => p.assigned !== null && p.assigned !== p.requested,
)
const signature = portAssignmentSignature(ports)
const lastNotified = await storeJson
.read((s) => s.notifiedPortAssignment)
.once()
if (mismatched.length > 0 && signature !== lastNotified) {
await sdk.notification.create(effects, {
level: 'warning',
title: i18n('Stratum port changed by StartOS'),
message: mismatched
.map((p) =>
i18n('{label}: requested {requested}, assigned {assigned}')
.replace('{label}', p.label)
.replace('{requested}', String(p.requested))
.replace('{assigned}', String(p.assigned)),
)
.concat(
i18n(
'The port you asked for was already in use, so StartOS assigned another one. Point your miners at the assigned port, or pick a free one in Configure.',
),
)
.join('\n'),
})
}
if (signature !== lastNotified)
await storeJson.merge(effects, { notifiedPortAssignment: signature })
}
// All Kamado processes (kamado-api, ckpool, stunnel) share ONE
// subcontainer, mirroring the single 0.3.x container: kamado-api reaches
@@ -172,44 +229,21 @@ 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.
// Public-domain TLS is StartOS's job, not ours — see the stratum-tls-public
// interface in interfaces.ts. The OS terminates ACME-backed TLS and forwards
// plaintext into ckpool's third bind, so nothing here fetches, writes or
// serves a certificate for a public domain.
//
// 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.
// This package used to do that itself with sdk.getSslCertificate() plus
// stunnel SNI sections, which cannot work: StartOS only provisions ACME
// certificates for bindings it terminates TLS for, so a raw TCP binding was
// handed no CA-issued certificate to serve and miners got the self-signed
// one (mbedtls -0x2700, X509_CERT_VERIFY_FAILED).
//
// stunnel is therefore left with exactly one job: the self-signed
// certificate for miners on the local network.
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
const stunnelEnabled = store.tlsEnabled
// stunnel.conf is rendered here rather than shipped as a static asset so it
// stays next to the ports it references. `accept` is the fixed in-container
@@ -226,19 +260,6 @@ export const main = sdk.setupMain(async ({ effects }) => {
// 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 =',
@@ -259,20 +280,12 @@ export const main = sdk.setupMain(async ({ effects }) => {
'',
'[stratum]',
`accept = 0.0.0.0:${stratumTlsInternalPort}`,
`connect = 127.0.0.1:${primary.connect}`,
`cert = ${primary.cert}`,
`connect = 127.0.0.1:${ckpoolTlsLoopbackPort}`,
`cert = ${tlsDir}/stratum.pem`,
// 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 writeFile(
@@ -325,9 +338,7 @@ export const main = sdk.setupMain(async ({ effects }) => {
// 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)),
),
STRATUM_SERVERS: JSON.stringify(stratumServers(tlsDomains)),
},
},
ready: {
@@ -492,10 +503,10 @@ export const main = sdk.setupMain(async ({ effects }) => {
},
),
},
// 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'],
// stunnel now runs only when local TLS is on, and that is exactly
// when the self-signed certificate it serves is generated — so the
// oneshot is always the dependency.
requires: ['tls-cert'],
}
: null,
)