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
+4 -2
View File
@@ -20,7 +20,9 @@ This is the 0.4.0 port of the [0.3.5.1 wrapper](../KamadoPool-StartOS-0351), reb
| compat migrations | `VersionGraph` with an `up` migration that converts a 0.3.5.1 `config.yaml` into `store.json` and preserves the SQLite DB, TLS cert, and ckpool state |
| — | i18n (en, es, de, pl, fr) for all user-facing strings |
Retained behavior: the ckpool restart loop gated on bitcoind reachability (now a daemon wrapper script), dual block-detection (ZMQ + 100 ms blockpoll), the loopback second stratum bind for TLS tagging (`server == 1` → lock icon), the v4 self-signed certificate with broad SANs, and ckpool loglevel 6 with `--log-shares`.
Retained behavior: the ckpool restart loop gated on bitcoind reachability (now a daemon wrapper script), dual block-detection (ZMQ + 100 ms blockpoll), loopback stratum binds for TLS tagging (now one per certificate, declared to kamado-api via `STRATUM_SERVERS` so the dashboard names the certificate on hover), the v4 self-signed certificate with broad SANs, and ckpool loglevel 6 with `--log-shares`.
Stratum TLS serves **two** certificates on one port, selected per connection by SNI: a Let's Encrypt certificate for any clearnet domain attached to the Stratum (TLS) interface (fetched with `getSslCertificate`, no config field — the attached domains *are* the setting), and the self-signed certificate as the default for LAN miners, which send no SNI and fall through to it. The TLS interface binds unconditionally so a domain can be attached without first enabling local TLS.
## Prerequisites
@@ -75,7 +77,7 @@ make KAMADO_SRC=/path/to/KamadoPool
startos/
manifest/ id, images (local Dockerfile build), volumes, bitcoind dependency
main.ts subcontainer, ckpool.conf + stunnel.conf rendering, daemons + health checks (fixed internal ports)
interfaces.ts Web UI (http 8080), Stratum (raw TCP, configurable), Stratum TLS (raw TCP, configurable + conditional)
interfaces.ts Web UI (http 8080), Stratum (raw TCP, configurable), Stratum TLS (raw TCP, configurable; always bound so domains can attach)
fileModels/ store.json (service settings, incl. stratum ports)
actions/ Configure, Pool Status, Stratum TLS Certificate, Regenerate TLS Certificate, Reset Block Latency
dependencies.ts bitcoind (running, synced) + ZMQ autoconfig task
+32 -3
View File
@@ -7,7 +7,7 @@ Kamado is a solo Bitcoin mining pool built on a patched fork of CKPool-solo, wit
- **A running solo pool**: stratum server (ckpool), middleware API, and web dashboard, supervised as separate daemons with individual health checks.
- **A real-time dashboard** with live hashrate, per-miner stats, hardware detection, block history, best-share leaderboards, and a transaction accelerator.
- **Direct LAN stratum access**: StartOS 0.4.0 exposes the stratum TCP port on your network — no router port-forward or proxy needed (this was a 0.3.x limitation).
- **Optional stratum TLS** with a persisted self-signed certificate miners can pin.
- **Stratum TLS on both sides of the network**: a publicly trusted certificate for miners connecting over a clearnet domain, and a persisted self-signed certificate for miners on the LAN — on the same port, at the same time.
## Setup
@@ -30,7 +30,26 @@ stratum+tcp://<your-server-lan-address>:<stratum-port>
### Stratum over TLS
Enable **Stratum TLS** in the *Configure* action to add an encrypted stratum endpoint, terminated by an stunnel sidecar on its own port (default **3334**, also configurable). The certificate is self-signed, generated once, and persisted, so pinned fingerprints survive restarts and updates.
Kamado exposes two stratum interfaces — **Stratum** (plaintext) and **Stratum (TLS)** — and each is reachable both on your local network and, if you attach a domain, over the internet. That gives four working combinations:
| Path | Endpoint | Certificate | Setup needed |
| ---- | -------- | ----------- | ------------ |
| Plaintext, local network | `stratum+tcp://<lan-address>:3333` | — | none |
| Plaintext, public domain | `stratum+tcp://<your-domain>:3333` | — | attach a domain to **Stratum** |
| TLS, local network | `stratum+ssl://<lan-address>:3334` | self-signed | enable **Stratum TLS (Local Network)** |
| TLS, public domain | `stratum+ssl://<your-domain>:3334` | Let's Encrypt | attach a domain to **Stratum (TLS)** |
The dashboard shows a padlock next to every encrypted miner; hover it to see which of the two certificates that miner is actually using.
#### TLS over a public domain (no certificate setup)
Add a clearnet domain to the **Stratum (TLS)** interface in the StartOS interface list. StartOS obtains a Let's Encrypt certificate for it and Kamado starts serving it automatically — there is nothing to enable in *Configure* and nothing to paste into your miners. Any miner whose firmware ships a normal CA root store validates it the same way a browser validates a website.
Certificates renew automatically. Kamado picks up each renewal on its own, which briefly restarts the stratum listener — connected miners reconnect within seconds.
#### TLS on the local network (self-signed)
A Let's Encrypt certificate is only ever valid for the domain name it was issued for, so a miner pointed at a bare LAN IP cannot validate it. For those miners, enable **Stratum TLS (Local Network)** in *Configure*. Kamado generates a self-signed certificate once and persists it, so pinned fingerprints survive restarts and updates.
Run the **Stratum TLS Certificate** action to get:
@@ -39,9 +58,17 @@ Run the **Stratum TLS Certificate** action to get:
Otherwise connect with `stratum+ssl://` and certificate verification disabled. Use the **Regenerate TLS Certificate** action to rotate the certificate; miners that pin it will need the new fingerprint.
#### How the two share one port
Both certificates are served on the same TLS port, selected per connection by the hostname the miner asks for (SNI). A miner that connects by domain name gets the Let's Encrypt certificate for that name; a miner that connects to a bare IP sends no hostname and gets the self-signed one. You do not have to choose, and you do not need a second port.
If you would rather have your LAN miners use the publicly trusted certificate too, point your local DNS (router, Pi-hole, AdGuard) at the server's LAN address for your domain and connect them by domain name. They then validate against Let's Encrypt while their traffic stays on the LAN — and you can leave **Stratum TLS (Local Network)** off entirely.
## Configuration
Everything lives in the **Configure** action: the stratum and stratum-TLS ports, vardiff (starting/min/max difficulty), idle-client disconnect, the coinbase tag embedded in solved blocks, ZMQ, TLS, log level, and an optional self-hosted mempool explorer URL for dashboard links.
Everything lives in the **Configure** action: the stratum and stratum-TLS ports, vardiff (starting/min/max difficulty), idle-client disconnect, the coinbase tag embedded in solved blocks, ZMQ, local-network TLS, log level, and an optional self-hosted mempool explorer URL for dashboard links.
TLS over a public domain is deliberately *not* a config option — it follows whatever domains you attach to the **Stratum (TLS)** interface, so there is no second copy of that setting to drift out of sync with what the OS actually has.
Changing a port rebinds the interface without restarting the pool, so miners already connected on other ports keep hashing — but anything pointed at the old port must be updated. Setting both stratum ports to the same number is rejected when you save.
@@ -58,6 +85,8 @@ Changing a port rebinds the interface without restarting the pool, so miners alr
- **Bitcoin Core RPC errors**: make sure Bitcoin Core is running and fully synced; Kamado's *Bitcoin Core RPC* health check shows the current state.
- **Best share resets to 0 after a block is found**: upstream CKPool zeroes the "current round" best diff on solve. Kamado ships a patch that also exposes the all-time best, so the dashboard has both columns.
- **Miner rejects the TLS certificate**: re-check that the PEM was pasted completely (including the BEGIN/END lines), or pin the SHA-256 fingerprint, or disable verification in the miner.
- **Miner rejects the certificate on a public domain**: make sure it is connecting by the domain name, not by IP — the certificate is only valid for the name. If it is using the name and still fails, the firmware's CA root store may not include Let's Encrypt's ISRG Root X1; pin the certificate or use the plaintext endpoint for that miner.
- **The public domain's certificate is not being served**: it takes a few minutes after adding a domain for StartOS to complete the ACME challenge. Until it does, Kamado logs `no certificate available for <domain> yet — skipping` and keeps serving the other paths. Check that the domain's DNS points at your server and that the port is reachable from the internet.
## Upstream
+7 -4
View File
@@ -25,7 +25,7 @@ export const inputSpec = InputSpec.of({
stratumTlsPort: Value.number({
name: i18n('Stratum TLS Port'),
description: i18n(
'Network port your miners connect to for TLS stratum. Only used when Stratum TLS is enabled below.',
'Network port your miners connect to for TLS stratum — both for the local self-signed certificate and for any public domain attached to the Stratum (TLS) interface.',
),
required: true,
default: defaultStratumTlsPort,
@@ -49,10 +49,14 @@ export const inputSpec = InputSpec.of({
),
default: true,
}),
// Storage key stays `tlsEnabled` so existing installs keep their setting;
// only the scope it describes narrowed. TLS over a public domain is not
// covered by this toggle — it follows the domains attached to the Stratum
// (TLS) interface and needs no configuration here.
tlsEnabled: Value.toggle({
name: i18n('Stratum TLS'),
name: i18n('Stratum TLS (Local Network)'),
description: i18n(
'Accept stratum connections over TLS via an stunnel sidecar on a second port. A self-signed certificate is generated once and persisted — miners must trust the certificate (see the Stratum TLS Certificate action) or connect with verification disabled.',
'Serve the self-signed certificate on the TLS stratum port, for miners on your local network. Miners have to trust it (see the Stratum TLS Certificate action) or connect with verification disabled. You do NOT need this for miners connecting over a public domain: attach the domain to the Stratum (TLS) interface and StartOS issues a publicly trusted certificate automatically.',
),
default: false,
}),
@@ -147,7 +151,6 @@ export const config = sdk.Action.withInput(
const conflict = validatePorts({
stratumPort: input.stratumPort,
stratumTlsPort: input.stratumTlsPort,
tlsEnabled: input.tlsEnabled,
})
if (conflict) throw new Error(conflict)
+4 -3
View File
@@ -2,7 +2,7 @@ export const DEFAULT_LANG = 'en_US'
const dict = {
'(not yet generated — start the service once)': 0,
'Accept stratum connections over TLS via an stunnel sidecar on a second port. A self-signed certificate is generated once and persisted — miners must trust the certificate (see the Stratum TLS Certificate action) or connect with verification disabled.': 1,
'Serve the self-signed certificate on the TLS stratum port, for miners on your local network. Miners have to trust it (see the Stratum TLS Certificate action) or connect with verification disabled. You do NOT need this for miners connecting over a public domain: attach the domain to the Stratum (TLS) interface and StartOS issues a publicly trusted certificate automatically.': 1,
'All block submissions confirmed': 2,
'All latency counters (count, avg, last, wasted work) have been cleared. New measurements will accumulate from the next block.': 3,
'Already absent': 4,
@@ -67,7 +67,7 @@ const dict = {
'TLS certificate cleared — restart the service to generate a new one': 63,
'TLS stratum is accepting connections': 64,
'TLS stratum is not accepting connections': 65,
'TLS-encrypted stratum endpoint (self-signed certificate see the Stratum TLS Certificate action)': 66,
'TLS-encrypted stratum endpoint. Miners on a public domain attached here get a CA-issued certificate automatically; on the local network the self-signed certificate is used (see the Stratum TLS Certificate action)': 66,
'The Kamado dashboard is not reachable': 67,
'The Kamado dashboard is reachable': 68,
'The stratum server is accepting connections': 69,
@@ -88,7 +88,8 @@ const dict = {
'Stratum TLS Port': 84,
'TCP port stunnel accepts TLS stratum connections on. Only used when Stratum TLS is enabled below.': 85,
'Network port your miners connect to for plaintext stratum. StartOS publishes the pool on this port; if it is already claimed by another service the OS assigns a different one, so check the Stratum interface after saving. Changing this does not interrupt connected miners.': 86,
'Network port your miners connect to for TLS stratum. Only used when Stratum TLS is enabled below.': 87,
'Network port your miners connect to for TLS stratum — both for the local self-signed certificate and for any public domain attached to the Stratum (TLS) interface.': 87,
'Stratum TLS (Local Network)': 88,
} as const
/**
+16 -12
View File
@@ -3,7 +3,7 @@ import { LangDict } from './default'
export default {
es_ES: {
0: '(aún no generado — inicie el servicio una vez)',
1: 'Acepta conexiones stratum por TLS mediante un sidecar stunnel en un segundo puerto. Se genera y persiste un certificado autofirmado una sola vez: los mineros deben confiar en el certificado (vea la acción Certificado TLS de Stratum) o conectarse con la verificación desactivada.',
1: 'Sirve el certificado autofirmado en el puerto stratum TLS, para mineros de su red local. Los mineros deben confiar en él (vea la acción Certificado TLS de Stratum) o conectarse con la verificación desactivada. NO lo necesita para mineros que se conecten mediante un dominio público: adjunte el dominio a la interfaz Stratum (TLS) y StartOS emitirá automáticamente un certificado de confianza pública.',
2: 'Todos los envíos de bloques confirmados',
3: 'Todos los contadores de latencia (conteo, promedio, último, trabajo desperdiciado) se han restablecido. Las nuevas mediciones se acumularán a partir del próximo bloque.',
4: 'Ya ausente',
@@ -68,7 +68,7 @@ export default {
63: 'Certificado TLS eliminado — reinicie el servicio para generar uno nuevo',
64: 'El stratum TLS acepta conexiones',
65: 'El stratum TLS no acepta conexiones',
66: 'Punto de acceso stratum cifrado con TLS (certificado autofirmado vea la acción Certificado TLS de Stratum)',
66: 'Punto de acceso stratum cifrado con TLS. Los mineros que usen un dominio público adjunto aquí reciben automáticamente un certificado emitido por una CA; en la red local se usa el certificado autofirmado (vea la acción Certificado TLS de Stratum)',
67: 'No se puede acceder al panel de Kamado',
68: 'El panel de Kamado está accesible',
69: 'El servidor stratum acepta conexiones',
@@ -89,11 +89,12 @@ export default {
84: 'Puerto TLS de stratum',
85: 'Puerto TCP en el que stunnel acepta conexiones stratum TLS. Solo se usa cuando Stratum TLS está activado más abajo.',
86: 'Puerto de red al que se conectan sus mineros para stratum sin cifrar. StartOS publica el pool en este puerto; si ya está ocupado por otro servicio, el sistema asigna otro, así que revise la interfaz Stratum después de guardar. Cambiarlo no interrumpe a los mineros conectados.',
87: 'Puerto de red al que se conectan sus mineros para stratum TLS. Solo se usa cuando Stratum TLS está activado más abajo.',
87: 'Puerto de red al que se conectan sus mineros para stratum TLS: tanto para el certificado autofirmado local como para cualquier dominio público adjunto a la interfaz Stratum (TLS).',
88: 'Stratum TLS (red local)',
},
de_DE: {
0: '(noch nicht erzeugt — starten Sie den Dienst einmal)',
1: 'Akzeptiert Stratum-Verbindungen über TLS mittels eines stunnel-Sidecars auf einem zweiten Port. Ein selbstsigniertes Zertifikat wird einmal erzeugt und gespeichert — Miner müssen dem Zertifikat vertrauen (siehe Aktion „Stratum-TLS-Zertifikat“) oder ohne Verifizierung verbinden.',
1: 'Stellt das selbstsignierte Zertifikat auf dem TLS-Stratum-Port bereit, für Miner in Ihrem lokalen Netzwerk. Miner müssen ihm vertrauen (siehe Aktion „Stratum-TLS-Zertifikat“) oder ohne Verifizierung verbinden. Für Miner, die über eine öffentliche Domain verbinden, wird dies NICHT benötigt: Hängen Sie die Domain an die Schnittstelle Stratum (TLS) an, und StartOS stellt automatisch ein öffentlich vertrauenswürdiges Zertifikat aus.',
2: 'Alle Blockeinreichungen bestätigt',
3: 'Alle Latenzzähler (Anzahl, Durchschnitt, letzter, verschwendete Arbeit) wurden zurückgesetzt. Neue Messungen sammeln sich ab dem nächsten Block.',
4: 'Bereits nicht vorhanden',
@@ -158,7 +159,7 @@ export default {
63: 'TLS-Zertifikat gelöscht — starten Sie den Dienst neu, um ein neues zu erzeugen',
64: 'TLS-Stratum akzeptiert Verbindungen',
65: 'TLS-Stratum akzeptiert keine Verbindungen',
66: 'TLS-verschlüsselter Stratum-Endpunkt (selbstsigniertes Zertifikat siehe Aktion „Stratum-TLS-Zertifikat“)',
66: 'TLS-verschlüsselter Stratum-Endpunkt. Miner über eine hier angehängte öffentliche Domain erhalten automatisch ein CA-signiertes Zertifikat; im lokalen Netzwerk wird das selbstsignierte Zertifikat verwendet (siehe Aktion „Stratum-TLS-Zertifikat“)',
67: 'Das Kamado-Dashboard ist nicht erreichbar',
68: 'Das Kamado-Dashboard ist erreichbar',
69: 'Der Stratum-Server akzeptiert Verbindungen',
@@ -179,11 +180,12 @@ export default {
84: 'Stratum-TLS-Port',
85: 'TCP-Port, auf dem stunnel TLS-Stratum-Verbindungen annimmt. Wird nur verwendet, wenn Stratum-TLS unten aktiviert ist.',
86: 'Netzwerk-Port, über den sich Ihre Miner für unverschlüsseltes Stratum verbinden. StartOS veröffentlicht den Pool auf diesem Port; ist er bereits von einem anderen Dienst belegt, weist das System einen anderen zu — prüfen Sie daher nach dem Speichern die Stratum-Schnittstelle. Eine Änderung unterbricht verbundene Miner nicht.',
87: 'Netzwerk-Port, über den sich Ihre Miner für TLS-Stratum verbinden. Wird nur verwendet, wenn Stratum-TLS unten aktiviert ist.',
87: 'Netzwerk-Port, über den sich Ihre Miner für TLS-Stratum verbinden — sowohl für das lokale selbstsignierte Zertifikat als auch für jede an die Schnittstelle Stratum (TLS) angehängte öffentliche Domain.',
88: 'Stratum-TLS (lokales Netzwerk)',
},
pl_PL: {
0: '(jeszcze nie wygenerowano — uruchom usługę raz)',
1: 'Akceptuje połączenia stratum przez TLS za pomocą pomocniczego stunnela na drugim porcie. Certyfikat samopodpisany jest generowany raz i zapisywany — górnicy muszą zaufać certyfikatowi (zobacz akcję Certyfikat TLS Stratum) lub łączyć się z wyłączoną weryfikacją.',
1: 'Udostępnia certyfikat samopodpisany na porcie stratum TLS, dla górników w sieci lokalnej. Górnicy muszą mu zaufać (zobacz akcję Certyfikat TLS Stratum) lub łączyć się z wyłączoną weryfikacją. NIE jest to potrzebne dla górników łączących się przez domenę publiczną: podłącz domenę do interfejsu Stratum (TLS), a StartOS automatycznie wystawi publicznie zaufany certyfikat.',
2: 'Wszystkie przesłane bloki potwierdzone',
3: 'Wszystkie liczniki opóźnień (liczba, średnia, ostatni, zmarnowana praca) zostały wyzerowane. Nowe pomiary będą gromadzone od następnego bloku.',
4: 'Już nieobecne',
@@ -248,7 +250,7 @@ export default {
63: 'Certyfikat TLS usunięty — uruchom ponownie usługę, aby wygenerować nowy',
64: 'Stratum TLS przyjmuje połączenia',
65: 'Stratum TLS nie przyjmuje połączeń',
66: 'Szyfrowany TLS punkt końcowy stratum (certyfikat samopodpisany zobacz akcję Certyfikat TLS Stratum)',
66: 'Szyfrowany TLS punkt końcowy stratum. Górnicy korzystający z podłączonej tu domeny publicznej automatycznie otrzymują certyfikat wystawiony przez CA; w sieci lokalnej używany jest certyfikat samopodpisany (zobacz akcję Certyfikat TLS Stratum)',
67: 'Panel Kamado jest nieosiągalny',
68: 'Panel Kamado jest osiągalny',
69: 'Serwer stratum przyjmuje połączenia',
@@ -269,11 +271,12 @@ export default {
84: 'Port TLS stratum',
85: 'Port TCP, na którym stunnel przyjmuje połączenia stratum TLS. Używany tylko, gdy Stratum TLS jest włączone poniżej.',
86: 'Port sieciowy, na który łączą się górnicy dla nieszyfrowanego stratum. StartOS udostępnia pulę na tym porcie; jeśli jest już zajęty przez inną usługę, system przydzieli inny — sprawdź interfejs Stratum po zapisaniu. Zmiana nie przerywa połączeń górników.',
87: 'Port sieciowy, na który łączą się górnicy dla stratum TLS. Używany tylko, gdy Stratum TLS jest włączone poniżej.',
87: 'Port sieciowy, na który łączą się górnicy dla stratum TLS — zarówno dla lokalnego certyfikatu samopodpisanego, jak i dla dowolnej domeny publicznej podłączonej do interfejsu Stratum (TLS).',
88: 'Stratum TLS (sieć lokalna)',
},
fr_FR: {
0: '(pas encore généré — démarrez le service une fois)',
1: 'Accepte les connexions stratum en TLS via un sidecar stunnel sur un second port. Un certificat auto-signé est généré une fois et conservé — les mineurs doivent faire confiance au certificat (voir laction Certificat TLS Stratum) ou se connecter sans vérification.',
1: 'Présente le certificat auto-signé sur le port stratum TLS, pour les mineurs de votre réseau local. Les mineurs doivent lui faire confiance (voir laction Certificat TLS Stratum) ou se connecter sans vérification. Ceci nest PAS nécessaire pour les mineurs qui se connectent via un domaine public : rattachez le domaine à linterface Stratum (TLS) et StartOS émet automatiquement un certificat publiquement approuvé.',
2: 'Toutes les soumissions de blocs confirmées',
3: 'Tous les compteurs de latence (nombre, moyenne, dernier, travail gaspillé) ont été remis à zéro. Les nouvelles mesures saccumuleront à partir du prochain bloc.',
4: 'Déjà absent',
@@ -338,7 +341,7 @@ export default {
63: 'Certificat TLS supprimé — redémarrez le service pour en générer un nouveau',
64: 'Le stratum TLS accepte les connexions',
65: 'Le stratum TLS naccepte pas les connexions',
66: 'Point de terminaison stratum chiffré en TLS (certificat auto-signé voir laction Certificat TLS Stratum)',
66: 'Point de terminaison stratum chiffré en TLS. Les mineurs passant par un domaine public rattaché ici reçoivent automatiquement un certificat émis par une AC ; sur le réseau local, le certificat auto-signé est utilisé (voir laction Certificat TLS Stratum)',
67: 'Le tableau de bord Kamado est injoignable',
68: 'Le tableau de bord Kamado est joignable',
69: 'Le serveur stratum accepte les connexions',
@@ -359,6 +362,7 @@ export default {
84: 'Port TLS stratum',
85: 'Port TCP sur lequel stunnel accepte les connexions stratum TLS. Utilisé uniquement lorsque Stratum TLS est activé ci-dessous.',
86: 'Port réseau auquel vos mineurs se connectent pour le stratum en clair. StartOS publie le pool sur ce port ; sil est déjà pris par un autre service, le système en attribue un autre — vérifiez donc linterface Stratum après enregistrement. Le modifier ninterrompt pas les mineurs connectés.',
87: 'Port réseau auquel vos mineurs se connectent pour le stratum TLS. Utilisé uniquement lorsque Stratum TLS est activé ci-dessous.',
87: 'Port réseau auquel vos mineurs se connectent pour le stratum TLS — aussi bien pour le certificat auto-signé local que pour tout domaine public rattaché à linterface Stratum (TLS).',
88: 'Stratum TLS (réseau local)',
},
} satisfies Record<string, LangDict>
+46 -43
View File
@@ -15,14 +15,16 @@ import {
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
// The user's stratum ports are EXTERNAL ports only. Read reactively so
// changing them in the Configure action re-runs this and updates the
// binding — the same mechanism that adds/removes the TLS interface when TLS
// is toggled. The in-container ports stay fixed (see utils.ts), so each
// change updates the existing binding rather than orphaning it.
// binding. The in-container ports stay fixed (see utils.ts), so each change
// updates the existing binding rather than orphaning it.
//
// The TLS toggle is deliberately NOT read here: both stratum interfaces now
// exist unconditionally, so toggling it no longer rebinds anything — it
// only changes which certificates stunnel serves, which is main.ts's job.
const ports = await storeJson
.read((s) => ({
stratum: s.stratumPort,
stratumTls: s.stratumTlsPort,
tlsEnabled: s.tlsEnabled,
}))
.const(effects)
@@ -75,57 +77,58 @@ export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
})
receipts.push(await stratumOrigin.export([stratum]))
// TLS stratum (conditional) — stunnel terminates TLS with the persisted
// package-managed certificate and forwards to ckpool's loopback-only
// second bind. The OS sees a raw TCP port; TLS lives at the app layer, so
// the noSsl scheme is deliberately stratum+ssl.
if (ports?.tlsEnabled) {
const tlsMulti = sdk.MultiHost.of(effects, stratumTlsHostId)
const tlsOrigin = await tlsMulti.bindPort(stratumTlsInternalPort, {
protocol: null,
preferredExternalPort: externalStratumTlsPort,
addSsl: null,
secure: { ssl: false },
})
const stratumTls = sdk.createInterface(effects, {
name: i18n('Stratum (TLS)'),
id: 'stratum-tls',
description: i18n(
'TLS-encrypted stratum endpoint (self-signed certificate — see the Stratum TLS Certificate action)',
),
type: 'api',
masked: false,
schemeOverride: { ssl: 'stratum+ssl', noSsl: 'stratum+ssl' },
username: null,
path: '',
query: {},
})
receipts.push(await tlsOrigin.export([stratumTls]))
}
// TLS stratum — stunnel terminates TLS and forwards to one of ckpool's
// loopback-only binds. The OS sees a raw TCP port; TLS lives at the app
// layer, so the noSsl scheme is deliberately stratum+ssl.
//
// This is bound and exported UNCONDITIONALLY, unlike the local-TLS toggle
// that used to gate it. A public domain is attached to an interface in the
// StartOS UI, so gating the interface on the toggle made the Let's Encrypt
// path unreachable for anyone who had not first enabled the self-signed
// one — there was nothing to attach the domain to. main.ts decides whether
// stunnel actually runs; when neither a certificate nor a domain is
// configured the port simply doesn't answer, and the Stratum TLS health
// check says so.
const tlsMulti = sdk.MultiHost.of(effects, stratumTlsHostId)
const tlsOrigin = await tlsMulti.bindPort(stratumTlsInternalPort, {
protocol: null,
preferredExternalPort: externalStratumTlsPort,
addSsl: null,
secure: { ssl: false },
})
const stratumTls = sdk.createInterface(effects, {
name: i18n('Stratum (TLS)'),
id: 'stratum-tls',
description: i18n(
'TLS-encrypted stratum endpoint. Miners on a public domain attached here get a CA-issued certificate automatically; on the local network the self-signed certificate is used (see the Stratum TLS Certificate action)',
),
type: 'api',
masked: false,
schemeOverride: { ssl: 'stratum+ssl', noSsl: 'stratum+ssl' },
username: null,
path: '',
query: {},
})
receipts.push(await tlsOrigin.export([stratumTls]))
// Drop bindings we no longer use — primarily the stratum-tls binding after
// the user disables TLS. With fixed internal ports this no longer has to
// clean up after port changes (the whole point of keeping them fixed), but
// it still matters for the TLS toggle, and it clears orphans left by older
// versions of this package that did move the internal port.
// Drop bindings we no longer use. With fixed internal ports and an
// unconditional TLS binding, all three are now permanent — this only
// clears orphans left by older versions of this package, which did move the
// internal port and did drop the TLS binding when the toggle was off.
await sdk.clearBindings(effects, {
except: [
{ id: uiHostId, internalPort: uiPort },
{ id: stratumHostId, internalPort: stratumInternalPort },
...(ports?.tlsEnabled
? [{ id: stratumTlsHostId, internalPort: stratumTlsInternalPort }]
: []),
{ id: stratumTlsHostId, internalPort: stratumTlsInternalPort },
],
})
// Exported service interfaces are a SECOND registry, independent of the
// bindings above and with its own cleanup effect. Clearing bindings alone
// leaves an orphaned interface record behind, which the UI still lists — so
// a port change can produce two identical "Stratum" rows. Excluding
// 'stratum-tls' when TLS is off also removes that row when the user
// disables TLS, rather than leaving a dead endpoint on display.
// a port change can produce two identical "Stratum" rows.
await effects.clearServiceInterfaces({
except: ['ui', 'stratum', ...(ports?.tlsEnabled ? ['stratum-tls'] : [])],
except: ['ui', 'stratum', 'stratum-tls'],
})
return receipts
+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,
)
+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()`,
+7 -3
View File
@@ -1,7 +1,11 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { current } from './current'
import { v0_2_0 } from './v0_2_0'
import { v0_2_1 } from './v0_2_1'
// The current version must be listed first; `other` carries the rest of the
// graph so StartOS can find a migration path from whatever version an existing
// install is currently on.
export const versionGraph = VersionGraph.of({
current,
other: [],
current: v0_2_1,
other: [v0_2_0],
})
@@ -20,7 +20,7 @@ type LegacyConfig = {
}
}
export const current = VersionInfo.of({
export const v0_2_0 = VersionInfo.of({
version: '0.2.0:3',
releaseNotes: {
en_US:
+24
View File
@@ -0,0 +1,24 @@
import { VersionInfo } from '@start9labs/start-sdk'
export const v0_2_1 = VersionInfo.of({
version: '0.2.1:0',
releaseNotes: {
en_US:
'Stratum TLS now works over the internet with no certificate setup: attach a clearnet domain to the Stratum (TLS) interface and StartOS issues a Lets Encrypt certificate that Kamado serves automatically. The self-signed certificate is still served on the same port for miners on your local network, chosen per connection, so both work at once without a second port. The TLS switch in Configure is now "Stratum TLS (Local Network)" and covers only the self-signed certificate, and the TLS interface is always listed so you can attach a domain to it without enabling local TLS first. The dashboard padlock now shows which of the two certificates each miner is using when you hover it.',
es_ES:
'Stratum TLS ahora funciona por internet sin configurar ningún certificado: adjunte un dominio de clearnet a la interfaz Stratum (TLS) y StartOS emitirá un certificado de Lets Encrypt que Kamado sirve automáticamente. El certificado autofirmado se sigue sirviendo en el mismo puerto para los mineros de su red local, elegido por conexión, así que ambos casos funcionan a la vez sin un segundo puerto. El interruptor TLS en Configurar ahora se llama «Stratum TLS (red local)» y cubre solo el certificado autofirmado, y la interfaz TLS aparece siempre, de modo que puede adjuntarle un dominio sin activar antes el TLS local. El candado del panel ahora indica, al pasar el ratón, cuál de los dos certificados usa cada minero.',
de_DE:
'Stratum-TLS funktioniert jetzt ohne Zertifikatseinrichtung über das Internet: Hängen Sie eine Clearnet-Domain an die Schnittstelle Stratum (TLS) an, und StartOS stellt ein Lets-Encrypt-Zertifikat aus, das Kamado automatisch bereitstellt. Das selbstsignierte Zertifikat wird weiterhin auf demselben Port für Miner im lokalen Netzwerk bereitgestellt, pro Verbindung ausgewählt — beides funktioniert gleichzeitig, ohne zweiten Port. Der TLS-Schalter unter „Konfigurieren“ heißt jetzt „Stratum-TLS (lokales Netzwerk)“ und betrifft nur das selbstsignierte Zertifikat, und die TLS-Schnittstelle wird immer angezeigt, sodass Sie ihr eine Domain zuweisen können, ohne zuvor lokales TLS zu aktivieren. Das Schloss-Symbol im Dashboard zeigt beim Daraufzeigen, welches der beiden Zertifikate ein Miner verwendet.',
pl_PL:
'Stratum TLS działa teraz przez internet bez konfigurowania certyfikatu: podłącz domenę clearnet do interfejsu Stratum (TLS), a StartOS wystawi certyfikat Lets Encrypt, który Kamado udostępnia automatycznie. Certyfikat samopodpisany jest nadal udostępniany na tym samym porcie dla górników w sieci lokalnej, wybierany dla każdego połączenia, więc oba przypadki działają jednocześnie bez drugiego portu. Przełącznik TLS w akcji Konfiguruj nazywa się teraz „Stratum TLS (sieć lokalna)” i dotyczy wyłącznie certyfikatu samopodpisanego, a interfejs TLS jest zawsze widoczny, więc można podłączyć do niego domenę bez wcześniejszego włączania lokalnego TLS. Kłódka w panelu pokazuje po najechaniu kursorem, którego z dwóch certyfikatów używa dany górnik.',
fr_FR:
'Le stratum TLS fonctionne désormais sur internet sans aucune configuration de certificat : rattachez un domaine clearnet à linterface Stratum (TLS) et StartOS émet un certificat Lets Encrypt que Kamado présente automatiquement. Le certificat auto-signé reste présenté sur le même port pour les mineurs de votre réseau local, choisi connexion par connexion, si bien que les deux fonctionnent simultanément sans second port. Le commutateur TLS dans Configurer sappelle maintenant « Stratum TLS (réseau local) » et ne concerne que le certificat auto-signé, et linterface TLS est toujours affichée, ce qui permet dy rattacher un domaine sans activer dabord le TLS local. Le cadenas du tableau de bord indique désormais au survol lequel des deux certificats chaque mineur utilise.',
},
// No data migration in either direction. store.json is unchanged — the TLS
// toggle deliberately kept its `tlsEnabled` key when its scope narrowed to
// the local network, so existing settings carry over untouched. The new
// unconditional stratum-tls binding is registered by setInterfaces on init,
// and the certificates for public domains are fetched fresh on every start
// rather than persisted, so there is nothing to convert or undo.
migrations: {},
})