commit a2ae800a1965069e68041b5179dbde6c5927f36c Author: satoshi Date: Sat Aug 1 06:31:15 2026 +0300 Expose config options for cleartext stratum port diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b568e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.s9pk +startos/*.js +node_modules/ +.DS_Store +.vscode/ +javascript +ncc-cache +kamado-src/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5d11ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# syntax=docker/dockerfile:1.6 +# +# Kamado Pool StartOS 0.4.0 package image. +# +# KamadoPool source is expected at ./kamado-src (synced from a sibling +# checkout by `make kamado-src`; see the Makefile, override with +# KAMADO_SRC=/path/to/KamadoPool). `start-cli s9pk pack` builds this +# Dockerfile per architecture via buildx. +# +# The ckpool/ui/api build steps mirror the stages in KamadoPool's own +# ckpool/Dockerfile and api/Dockerfile. +# +# Cross-arch strategy: only the ckpool stage and the runtime stage are +# built for the target platform (emulated when building aarch64 on x86). +# The UI and API stages pin --platform=$BUILDPLATFORM and cross-compile, +# which is both correct and far faster than emulating them: +# * the Svelte UI emits static assets — no target-arch code at all; +# * kamado-api is CGO_ENABLED=0 with a pure-Go SQLite driver +# (modernc.org/sqlite), so GOARCH cross-compilation is exact. + +ARG CKPOOL_REPO=https://bitbucket.org/ckolivas/ckpool.git +ARG CKPOOL_COMMIT=cfb0f83b70d7b382b85d2bd0710cf4cb2dda4007 + +# ---------- stage 1: build patched ckpool ---------- +# Clones upstream ckpool at the pinned commit, applies Kamado patches, +# builds with portable CFLAGS. +FROM debian:bookworm-slim AS ckpool-build +ARG CKPOOL_REPO +ARG CKPOOL_COMMIT +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential autoconf automake libtool pkg-config \ + libzmq3-dev ca-certificates git \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +RUN git clone "${CKPOOL_REPO}" ckpool \ + && cd ckpool && git checkout "${CKPOOL_COMMIT}" +COPY kamado-src/ckpool/patches/ /tmp/kamado-patches/ +RUN set -eux; cd /build/ckpool; \ + for p in /tmp/kamado-patches/*.patch; do \ + [ -f "$p" ] || continue; \ + echo "Applying $(basename "$p")"; \ + git apply --verbose "$p"; \ + done +RUN cd /build/ckpool \ + && ./autogen.sh \ + && CFLAGS="-O2 -Wall -pipe" ./configure --prefix=/usr/local \ + && make -j"$(nproc)" +RUN install -Dm755 /build/ckpool/src/ckpool /out/usr/local/bin/ckpool \ + && install -Dm755 /build/ckpool/src/ckpmsg /out/usr/local/bin/ckpmsg + +# ---------- stage 2: build the Svelte UI ---------- +# Runs natively on the build host; `npm run build` output is static +# JS/CSS/HTML, identical for every target architecture. +FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS ui-build +WORKDIR /ui +COPY kamado-src/ui/package.json ./ +RUN npm install --no-audit --no-fund +COPY kamado-src/ui/ ./ +RUN npm run build + +# ---------- stage 3: build the Go api with embedded UI ---------- +# Runs natively on the build host and cross-compiles to $TARGETARCH. +FROM --platform=$BUILDPLATFORM golang:1.22-bookworm AS api-build +ARG TARGETARCH +WORKDIR /src +COPY kamado-src/api/go.mod ./ +RUN go mod download 2>/dev/null || true +COPY kamado-src/api/ ./ +RUN rm -rf internal/webui/dist && mkdir -p internal/webui/dist +COPY --from=ui-build /ui/dist/ internal/webui/dist/ +RUN CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" go build \ + -trimpath -ldflags="-s -w" \ + -o /out/kamado-api ./cmd/kamado-api \ + && file /out/kamado-api 2>/dev/null || true + +# ---------- stage 4: runtime ---------- +# No supervisor and no entrypoint script: StartOS 0.4.0 runs kamado-api, +# ckpool (via kamado-ckpool-run.sh), and stunnel as separate daemons in a +# shared subcontainer. curl + jq serve the bitcoind wait/chain-detection +# script and the in-container health checks. +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl jq libzmq5 stunnel4 openssl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ckpool-build /out/usr/local/bin/ckpool /usr/local/bin/ckpool +COPY --from=ckpool-build /out/usr/local/bin/ckpmsg /usr/local/bin/ckpmsg +COPY --from=api-build /out/kamado-api /usr/local/bin/kamado-api +COPY assets/scripts/kamado-ckpool-run.sh /usr/local/bin/kamado-ckpool-run.sh +COPY assets/scripts/kamado-tls-init.sh /usr/local/bin/kamado-tls-init.sh +# /etc/stunnel/stratum.conf is NOT baked in: its accept port is user config, +# so main.ts renders it into the subcontainer rootfs at startup. +RUN chmod +x /usr/local/bin/kamado-ckpool-run.sh /usr/local/bin/kamado-tls-init.sh \ + && mkdir -p /etc/ckpool /run/ckpool + +# Documents the defaults only — the stratum ports are user-configurable and +# StartOS publishes ports from the interface bindings, not from EXPOSE. +EXPOSE 3333 3334 8080 +WORKDIR /root +CMD ["kamado-api"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e13c2cf --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Kamado Pool +Copyright (C) 2026 Kamado Pool contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +--- + +This project is built on top of CKPool by Con Kolivas, which is itself +licensed under GPL-3.0. See https://bitbucket.org/ckolivas/ckpool for +the upstream source. The full GPL-3.0 text is available at: +https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..32fd4c2 --- /dev/null +++ b/Makefile @@ -0,0 +1,67 @@ +# Kamado Pool — StartOS 0.4.0 package. +# +# Prerequisites: node/npm, docker (with buildx), start-cli, rsync. +# First run `npm ci`, then: +# make -> kamado-pool.s9pk, the universal package (x86_64 + +# aarch64 in one file) — this is what to ship. +# make x86 / arm -> single-arch packages, for faster dev iteration. +# +# Building the aarch64 image on an x86 host needs qemu binfmt handlers and +# a builder that advertises linux/arm64 — `make setup` registers both. +# +# KamadoPool source is expected in a sibling checkout at ../KamadoPool +# (override with `make KAMADO_SRC=/path/to/KamadoPool`). It is synced into +# ./kamado-src before packing, because `start-cli s9pk pack` builds the +# Dockerfile with this directory as the only build context. + +ARCHES := x86 arm +# Ship one universal s9pk by default rather than a per-arch pair. +TARGETS := universal + +# Buildx builder used by `make setup`. Must advertise linux/amd64 and +# linux/arm64; network=host works around container-DNS breakage on hosts +# running systemd-resolved. +BUILDER ?= kamado-hostnet +KAMADO_SRC ?= ../KamadoPool + +# Source files whose changes should trigger a re-sync + repack (excludes +# node_modules/dist build artifacts). +KAMADO_DEPS := $(shell find "$(KAMADO_SRC)/api" "$(KAMADO_SRC)/ui" "$(KAMADO_SRC)/ckpool" \ + \( -name node_modules -o -name dist \) -prune -o -type f -print 2>/dev/null) + +ifeq (,$(wildcard node_modules/@start9labs/start-sdk/s9pk.mk)) +$(error node_modules missing — run 'npm ci' first) +endif + +# overrides to s9pk.mk must precede the include statement +include node_modules/@start9labs/start-sdk/s9pk.mk + +kamado-src: $(KAMADO_DEPS) + @test -d "$(KAMADO_SRC)/api" || { echo "Error: KamadoPool source not found at '$(KAMADO_SRC)'. Set KAMADO_SRC=/path/to/KamadoPool"; exit 1; } + @echo " Syncing KamadoPool source from '$(KAMADO_SRC)'..." + @rsync -a --delete --exclude node_modules --exclude dist \ + "$(KAMADO_SRC)/api" "$(KAMADO_SRC)/ui" "$(KAMADO_SRC)/ckpool" kamado-src/ + @touch kamado-src + +# Extra prerequisites for the pack targets defined in s9pk.mk: the Docker +# image build consumes ./kamado-src, so keep it fresh. +$(BASE_NAME).s9pk: kamado-src +$(BASE_NAME)_x86_64.s9pk: kamado-src +$(BASE_NAME)_aarch64.s9pk: kamado-src + +# One-time (per boot) cross-arch build setup: register qemu binfmt +# handlers so the emulated aarch64 ckpool stage can run, and create a +# buildx builder that advertises both platforms. +setup: + docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + -docker buildx rm $(BUILDER) 2>/dev/null + docker buildx create --name $(BUILDER) --driver docker-container \ + --driver-opt network=host \ + --platform linux/amd64,linux/arm64 --use + docker buildx inspect --bootstrap $(BUILDER) + @echo "Ready. 'make' will now build the universal package." + +clean-src: + rm -rf kamado-src + +.PHONY: clean-src setup diff --git a/README.md b/README.md new file mode 100644 index 0000000..77881ea --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Kamado Pool — StartOS 0.4.0 Packaging + +StartOS **0.4.0** wrapper for [Kamado Pool](../KamadoPool), a modern solo Bitcoin mining pool built on a patched fork of CKPool-solo with a Go middleware API and Svelte real-time dashboard. + +This is the 0.4.0 port of the [0.3.5.1 wrapper](../KamadoPool-StartOS-0351), rebuilt on the TypeScript `@start9labs/start-sdk` and the revised 0.4.0 service architecture. + +## What changed from the 0.3.5.1 package + +| 0.3.5.1 | 0.4.0 | +|---|---| +| `manifest.yaml` + Deno `embassy.js` procedures | `startos/` TypeScript package on `@start9labs/start-sdk` (npm), bundled with ncc | +| Single container, `docker_entrypoint.sh` supervises ckpool + kamado-api + stunnel | One shared subcontainer, three daemons (`api`, `ckpool`, `stunnel`) + oneshots, individually health-checked and ordered by the SDK daemon runtime | +| Config form (`getConfig`/`setConfig`) writing `start9/config.yaml` | `store.json` file model + **Configure** action with input spec; settings changes restart daemons reactively | +| RPC credentials via config **pointers** to `bitcoind`/`bitcoind-testnet` | bitcoind **cookie** auth read from a read-only dependency mount; RPC/ZMQ reached over the LXC bridge address resolved reactively (no `.embassy` DNS) | +| Two dependency variants (`bitcoind`, `bitcoind-testnet`) | Single `bitcoind` dependency; the active chain is detected at runtime from `getblockchaininfo` (the coinbase self-test address is chosen accordingly) | +| No LAN TCP forwarding — users needed router forwards / simpleproxy for stratum | Stratum (and TLS stratum) exposed as **raw TCP host bindings**, directly reachable on the LAN. The configured port drives both the in-container bind and the interface's `preferredExternalPort`, so the user's chosen number is what miners connect to when the OS can grant it | +| `properties` for TLS fingerprint/PEM | **Stratum TLS Certificate** action (fingerprint + PEM, copyable) | +| Health checks: web / ckpool / bitcoin / submit-gap | Same four, plus an optional **ZMQ Block Feed** check, as daemon `ready` checks + standalone health checks | +| duplicity backup of both volumes | `sdk.setupBackups` of both volumes (`main`, `ckpool`) | +| 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`. + +## Prerequisites + +- **Node.js + npm** +- **Docker** with buildx +- **start-cli** — from the [StartOS packaging guide](https://docs.start9.com/packaging/0.4.0.x/environment-setup.html) +- **rsync** +- **KamadoPool source** — local sibling checkout at `../KamadoPool` (override with `KAMADO_SRC=/path/to/KamadoPool`) + +## Building + +```sh +npm ci # once — installs the SDK and bitcoin-core-startos (for typed dependency wiring) +make setup # once per boot — qemu binfmt + a multi-platform buildx builder +make # kamado-pool.s9pk — the universal package (ship this) +``` + +`make` produces a **universal `kamado-pool.s9pk`** carrying both the x86_64 and aarch64 images, so a single file installs on any StartOS machine. For faster iteration during development, `make x86` and `make arm` emit single-arch packages (`kamado-pool_x86_64.s9pk` / `kamado-pool_aarch64.s9pk`) and skip the other architecture entirely. + +The Makefile syncs `../KamadoPool`'s `api/`, `ui/`, and `ckpool/` into `./kamado-src/` (gitignored) before packing; `start-cli s9pk pack` then builds the Docker image per architecture from the local `Dockerfile` (ckpool is cloned from the pinned upstream commit and patched with Kamado's patch series, the Svelte UI is embedded into the Go binary). + +Only the ckpool stage and the runtime stage are built for the target architecture — the aarch64 half of those runs under qemu emulation on an x86 host, which is why `make setup` is required. The UI and API stages pin `--platform=$BUILDPLATFORM` and cross-compile instead: the Svelte build emits architecture-independent static assets, and `kamado-api` is `CGO_ENABLED=0` with a pure-Go SQLite driver, so `GOARCH` cross-compilation is exact and avoids emulating the two slowest stages. + +**Note:** `start-cli s9pk pack` records a `gitHash` in the manifest and therefore requires this directory to be a git repository with at least one commit. Until you make one, you can point git at a throwaway repo just for the pack step: + +```sh +git init /tmp/stub && cd /tmp/stub && touch .stub && git add .stub && git commit -m stub +GIT_DIR=/tmp/stub/.git make +``` + +**Troubleshooting:** if the Docker build fails with DNS errors like `lookup registry-1.docker.io … connection refused` from inside buildkit, your buildx builder container cannot resolve DNS (common with systemd-resolved hosts). The `network=host` driver option in `make setup` is what avoids this. + +### Install to your server + +Configure `~/.startos/config.yaml` with your server, then: + +```sh +make install +``` + +Or sideload the produced `kamado-pool.s9pk` through the StartOS web UI. + +### Overrides + +```sh +make KAMADO_SRC=/path/to/KamadoPool +``` + +## Package layout + +```text +startos/ + manifest/ id, images (local Dockerfile build), volumes, bitcoind dependency + main.ts subcontainer, ckpool.conf + stunnel.conf rendering, daemons + health checks + interfaces.ts Web UI (http 8080), Stratum (raw TCP, configurable), Stratum TLS (raw TCP, configurable + conditional) + 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 + backups.ts volumes: main, ckpool + versions/ 0.2.0:0 with migration from the 0.3.5.1 wrapper + init/ store seeding, init ordering + i18n/ dictionaries (en, es, de, pl, fr) +assets/ + scripts/ kamado-ckpool-run.sh (bitcoind wait + chain detect + exec ckpool), kamado-tls-init.sh (cert v4) +Dockerfile ckpool (patched) + Svelte UI + Go API + runtime stage +``` + +## License + +GPL-3.0 — matches upstream Kamado and CKPool. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..207d9bd --- /dev/null +++ b/TODO.md @@ -0,0 +1,8 @@ +# TODO + +- [ ] Replace the placeholder `packageRepo` / `upstreamRepo` / `marketingUrl` URLs in `startos/manifest/index.ts` once the repos are published. +- [ ] Make a real first commit in this repo so packed s9pks record a meaningful `gitHash` (builds currently need the `GIT_DIR` stub described in the README). +- [ ] Widen the `bitcoind` dependency floor check: the range is `>=28.4:13`, verified against Bitcoin Core 31.1 on-device. +- [ ] Test the 0.3.5.1 → 0.4.0 update path on a device that has the old package installed (config migration, DB/TLS/ckpool state carry-over). +- [ ] Consider pinning the `bitcoin-core-startos` dev dependency to `next/31.x` instead of `next/28.x`. It is a build-time type/constant dependency only — the host ids (`rpc`, `zmq`), ports (8332, 28332), action id (`autoconfig`), health-check ids (`bitcoind`, `sync-progress`) and the `zmqEnabled` field are identical in both branches — but matching the deployed 31.x package would catch upstream drift at typecheck time. +- [ ] Consider converting `icon.png` to an SVG. diff --git a/assets/scripts/kamado-ckpool-run.sh b/assets/scripts/kamado-ckpool-run.sh new file mode 100755 index 0000000..342e664 --- /dev/null +++ b/assets/scripts/kamado-ckpool-run.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Kamado Pool — ckpool daemon wrapper for StartOS 0.4.0. +# +# main.ts renders /etc/ckpool/ckpool.conf.template into the subcontainer +# rootfs with everything resolved except the coinbase-builder self-test +# address, which depends on the active network. This script: +# +# 1. Blocks until bitcoind answers getblockchaininfo, so ckpool is never +# launched into a wall. When kamado-api kills ckpool on bitcoind +# failure (letting miners fail over to a backup pool), StartOS +# restarts this daemon and the wait re-arms until bitcoind recovers — +# the 0.3.x supervised-restart loop, expressed as a daemon. +# 2. Detects the chain and substitutes a network-valid self-test address. +# CKPool-solo uses the worker's stratum username as the payout address; +# the conf `btcaddress` is only consulted once at startup to build and +# validate a sample coinbase transaction against bitcoind. Since no +# worker has connected yet at that point, we hand it the genesis block +# coinbase address for the active network — always valid, and solo +# mode never credits it a satoshi. +# 3. Renders the final conf and execs ckpool. +# +# Env (set by main.ts): BITCOIN_RPC_URL, BITCOIN_RPC_USER, +# BITCOIN_RPC_PASSWORD, CKPOOL_SOCKDIR. +set -euo pipefail + +TEMPLATE=/etc/ckpool/ckpool.conf.template +CONF=/etc/ckpool/ckpool.conf +SOCKDIR="${CKPOOL_SOCKDIR:-/run/ckpool}" + +# CKPool loglevel: 6 = LOG_INFO, required for share-level logging +# (Accepted/Rejected client lines) used by the stats feature. +CKPOOL_LOGLEVEL="${CKPOOL_LOGLEVEL:-6}" + +rpc() { + curl -sf --max-time 5 \ + -u "${BITCOIN_RPC_USER}:${BITCOIN_RPC_PASSWORD}" \ + -d "{\"jsonrpc\":\"1.0\",\"method\":\"$1\",\"params\":[]}" \ + -H 'Content-Type: application/json' \ + "${BITCOIN_RPC_URL}" +} + +echo "kamado-ckpool: waiting for bitcoind at ${BITCOIN_RPC_URL}..." +backoff=2 +until CHAIN_INFO=$(rpc getblockchaininfo); do + echo "kamado-ckpool: bitcoind not reachable (retry in ${backoff}s)" + sleep "${backoff}" + backoff=$(( backoff < 30 ? backoff * 2 : 30 )) +done + +CHAIN=$(printf '%s' "${CHAIN_INFO}" | jq -r '.result.chain // "main"') +case "${CHAIN}" in + main) + # Satoshi's genesis block coinbase address. + SELFTEST_ADDRESS="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + ;; + test|testnet4|signet|regtest) + # Testnet genesis coinbase address — valid P2PKH on testnet-family + # networks (testnet3/testnet4/signet/regtest share address prefixes). + SELFTEST_ADDRESS="mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn" + ;; + *) + echo "kamado-ckpool: unknown chain '${CHAIN}', assuming mainnet" >&2 + SELFTEST_ADDRESS="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + ;; +esac + +sed "s|@SELFTEST_ADDRESS@|${SELFTEST_ADDRESS}|g" "${TEMPLATE}" > "${CONF}" + +mkdir -p "${SOCKDIR}" + +echo "kamado-ckpool: starting ckpool (solo, chain=${CHAIN}), loglevel ${CKPOOL_LOGLEVEL}" +exec /usr/local/bin/ckpool --btcsolo --config "${CONF}" \ + --sockdir "${SOCKDIR}" --log-shares -l "${CKPOOL_LOGLEVEL}" diff --git a/assets/scripts/kamado-tls-init.sh b/assets/scripts/kamado-tls-init.sh new file mode 100755 index 0000000..0d078a6 --- /dev/null +++ b/assets/scripts/kamado-tls-init.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# Kamado Pool — stratum TLS certificate init (oneshot, idempotent). +# +# Generates the persisted self-signed stratum certificate the stunnel +# daemon serves. Regenerates only when files are missing or the cert-format +# version marker is outdated, so miners' pinned fingerprints survive +# restarts and updates. Run the "Regenerate TLS Certificate" action to +# force a fresh one. +# +# Env (set by main.ts): TLS_DIR (defaults to /root/.kamado/tls). +set -euo pipefail + +TLS_DIR="${TLS_DIR:-/root/.kamado/tls}" +CRT="${TLS_DIR}/stratum.crt" +KEY="${TLS_DIR}/stratum.key" +CERT="${TLS_DIR}/stratum.pem" +MARKER="${TLS_DIR}/cert_version" + +mkdir -p "${TLS_DIR}" + +# Bump TLS_CERT_VERSION any time the cert format/extensions change. +# The startup check regenerates whenever the marker file is missing +# or doesn't match this version. This is more reliable than poking +# at the existing cert's extensions — we know *exactly* when a new +# shape is required and the upgrade self-heals on next boot. +# v4: broaden subjectAltName to cover mDNS / LAN / Tor hostnames +# so miner firmwares that verify the SAN against the hostname +# they were pointed at (e.g. AxeOS connecting to host.local) +# stop failing with MBEDTLS_ERR_X509_CERT_VERIFY_FAILED. +TLS_CERT_VERSION=4 + +NEEDS_REGEN=false +if [[ ! -f "${CERT}" || ! -f "${CRT}" || ! -f "${KEY}" ]]; then + NEEDS_REGEN=true +elif [[ ! -f "${MARKER}" ]] \ + || [[ "$(cat "${MARKER}" 2>/dev/null)" != "${TLS_CERT_VERSION}" ]]; then + echo "kamado-tls: TLS cert is older format (want v${TLS_CERT_VERSION}); regenerating" + NEEDS_REGEN=true +fi + +if [[ "${NEEDS_REGEN}" == "true" ]]; then + echo "kamado-tls: generating self-signed stratum TLS cert v${TLS_CERT_VERSION}" + # Write the extensions to a config file rather than rely on + # `-addext`: some openssl builds emit them into unpredictable + # locations (e.g. CSR instead of the cert), and this is the + # documented, cross-version way to pin the full extension set. + CONF=$(mktemp) + cat > "${CONF}" <<'OPENSSL_CONF' +[ req ] +default_bits = 2048 +default_md = sha256 +prompt = no +distinguished_name = req_dn +x509_extensions = v3_cert + +[ req_dn ] +CN = kamado-pool + +[ v3_cert ] +basicConstraints = critical, CA:FALSE +keyUsage = critical, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectKeyIdentifier = hash +subjectAltName = @alt_names + +[ alt_names ] +# Specific StartOS / local names the pool might be reached through. +DNS.1 = kamado-pool.embassy +DNS.2 = kamado-pool +DNS.3 = localhost +# Wildcard SANs covering the TLDs miners typically use: +# *.local -> mDNS / Bonjour (e.g. obese-admirer.local on AxeOS) +# *.embassy -> StartOS inter-service hostnames +# *.onion -> Tor hidden services +# *.home.arpa -> RFC 8375 home network namespace +# *.lan -> common consumer router default TLD +# *.internal -> some LAN setups +# Strictly leftmost-label wildcards per RFC 6125; libraries that +# enforce this (mbedtls, OpenSSL, BoringSSL, Go crypto/tls) all +# accept them. +DNS.4 = *.local +DNS.5 = *.embassy +DNS.6 = *.onion +DNS.7 = *.home.arpa +DNS.8 = *.lan +DNS.9 = *.internal +IP.1 = 127.0.0.1 +OPENSSL_CONF + + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "${KEY}" \ + -out "${CRT}" \ + -days 3650 \ + -config "${CONF}" \ + >/dev/null 2>&1 + rm -f "${CONF}" + + # stunnel reads cert+key in either order, but cert-first is the + # convention openssl and most tooling expect. + cat "${CRT}" "${KEY}" > "${CERT}" + chmod 600 "${KEY}" "${CERT}" + printf '%s\n' "${TLS_CERT_VERSION}" > "${MARKER}" + + # Log the extensions so operators can verify the cert is sane + # from the service logs without needing to exec into the + # container. + echo "kamado-tls: cert extensions:" + openssl x509 -in "${CRT}" -noout -ext subjectAltName,extendedKeyUsage,keyUsage 2>&1 \ + | sed 's/^/ /' +fi + +FINGERPRINT=$(openssl x509 -in "${CRT}" -noout -fingerprint -sha256 | cut -d= -f2) +printf '%s\n' "${FINGERPRINT}" > "${TLS_DIR}/fingerprint.txt" +echo "kamado-tls: stratum TLS SHA256 fingerprint: ${FINGERPRINT}" diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..80985b2 Binary files /dev/null and b/icon.png differ diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..bd3346f --- /dev/null +++ b/instructions.md @@ -0,0 +1,64 @@ +# Kamado Pool + +Kamado is a solo Bitcoin mining pool built on a patched fork of CKPool-solo, with a Go middleware API and a real-time Svelte dashboard. When a miner connected to your Kamado instance solves a block, **the full block reward goes to the payout address that miner connected with** — no pool fees, no splits, no share accounting. + +## What you get on StartOS + +- **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. + +## Setup + +1. Install and start **Bitcoin Core**. Kamado requires it running and synced; mining on an unsynced node produces invalid work. +2. Accept the suggested task to enable **ZMQ** on Bitcoin Core (recommended — it gives sub-second new-block detection; without it Kamado falls back to RPC polling). +3. Start Kamado Pool and open the **Web Dashboard** from the interface list. + +There is no payout address to configure. CKPool-solo pays the full block reward directly to whichever Bitcoin address the miner connects with as its stratum username — see **Connecting miners** below. Kamado validates worker usernames against Bitcoin Core and **refuses to authenticate any worker whose username is not a valid address on the active network**, so misconfigured miners fail loudly instead of silently mining to the wrong place. + +## Connecting miners + +The stratum port defaults to **3333** and can be changed in the *Configure* action. StartOS tries to publish the pool on that same port number on your network, so it is normally the port you give your miners — but check the **Stratum** interface after saving to see the actual external port, since the OS assigns a different one if your choice is already in use. Point each miner at: + +```text +stratum+tcp://: +``` + +- **Username**: the Bitcoin address that should receive the block reward, optionally followed by `.workername` for labelling in the dashboard (e.g. `bc1q....myBitaxe`). +- **Password**: ignored — anything works. + +### 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. + +Run the **Stratum TLS Certificate** action to get: + +- the **SHA-256 fingerprint** for firmwares that pin fingerprints, and +- the **full PEM** to paste into firmwares that accept a custom root (AxeOS exposes a *Stratum SSL Cert* field for exactly this). + +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. + +## 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. + +Changing a port restarts the pool and rebinds the interface, so miners will reconnect on the new port — update them accordingly. Port choices that cannot work (colliding with each other, with the dashboard, or with ckpool's internal TLS bind) are rejected when you save rather than failing at startup. + +## Actions + +- **Pool Status** — full text snapshot: Bitcoin Core sync, ckpool health, miners, hashrate, found blocks, submit-gap diagnostics. +- **Stratum TLS Certificate** — fingerprint + PEM for miner setup. +- **Regenerate TLS Certificate** — clears the cert; a fresh one is generated on next start. +- **Reset Block Latency** — zeroes the block-update latency counters after tuning. + +## Troubleshooting + +- **No miners appear after connecting**: check the Stratum interface for the right port, and confirm the miner reaches it (`telnet `). Check the Kamado logs. +- **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. + +## Upstream + +CKPool-solo by Con Kolivas: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8dc59bc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1716 @@ +{ + "name": "kamado-pool-startos", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kamado-pool-startos", + "dependencies": { + "@start9labs/start-sdk": "2.0.5", + "bitcoin-core-startos": "github:Start9Labs/bitcoin-core-startos#next/28.x" + }, + "devDependencies": { + "@types/node": "^22.17.1", + "@vercel/ncc": "^0.38.3", + "prettier": "^3.6.2", + "typescript": "^6.0.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-2.0.5.tgz", + "integrity": "sha512-g6F4n24MhSb1rLGYnxJTt0EdZ6mZzTyAGiggsPyEqZ59uMoo1NxWWlDV5ico71kR3qMUufr373anCUtpSW/lSQ==", + "bundleDependencies": [ + "@start9labs/start-core", + "eslint", + "typescript-eslint" + ], + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@start9labs/start-core": "file:../../shared-libs/ts-modules/start-core/dist", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "eslint": "^9.39.4", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "typescript-eslint": "^8.61.0", + "yaml": "^2.8.3", + "zod": "4.4.3", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array": { + "version": "0.21.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/core": { + "version": "0.17.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "inBundle": true, + "license": "Python-2.0" + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "inBundle": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/js": { + "version": "9.39.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/object-schema": { + "version": "2.1.7", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/core": { + "version": "0.19.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/node": { + "version": "0.16.8", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/types": { + "version": "0.15.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@types/estree": { + "version": "1.0.9", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@types/json-schema": { + "version": "7.0.15", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/acorn": { + "version": "8.16.0", + "inBundle": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/acorn-jsx": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ajv": { + "version": "6.15.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/callsites": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/chalk": { + "version": "4.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/debug": { + "version": "4.4.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/deep-is": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint": { + "version": "9.39.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint-scope": { + "version": "8.4.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/espree": { + "version": "10.4.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esquery": { + "version": "1.7.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esrecurse": { + "version": "4.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/estraverse": { + "version": "5.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esutils": { + "version": "2.0.3", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-levenshtein": { + "version": "2.0.6", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fdir": { + "version": "6.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/file-entry-cache": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/flat-cache": { + "version": "4.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/flatted": { + "version": "3.4.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/globals": { + "version": "14.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/has-flag": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/import-fresh": { + "version": "3.3.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/is-extglob": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/is-glob": { + "version": "4.0.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-buffer": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/keyv": { + "version": "4.5.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/levn": { + "version": "0.4.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/lodash.merge": { + "version": "4.6.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/natural-compare": { + "version": "1.4.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/optionator": { + "version": "0.9.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/p-limit": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/parent-module": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/path-exists": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/picomatch": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/prelude-ls": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/strip-json-comments": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/supports-color": { + "version": "7.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/tinyglobby": { + "version": "0.2.17", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ts-api-utils": { + "version": "2.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/type-check": { + "version": "0.4.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/typescript-eslint": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/uri-js": { + "version": "4.4.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/word-wrap": { + "version": "1.2.5", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/yocto-queue": { + "version": "0.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/bitcoin-core-startos": { + "resolved": "git+ssh://git@github.com/Start9Labs/bitcoin-core-startos.git#3f0a434d86163538a4921e289708860577e75bc3", + "dependencies": { + "@start9labs/start-sdk": "2.0.5", + "diskusage": "^1.2.0", + "tor-startos": "github:Start9Labs/tor-startos#next" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/diskusage": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/diskusage/-/diskusage-1.2.0.tgz", + "integrity": "sha512-2u3OG3xuf5MFyzc4MctNRUKjjwK+UkovRYdD2ed/NZNZPrt0lqHnLKxGhlFVvAb4/oufIgQG3nWgwmeTbHOvXA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^4.2.8", + "nan": "^2.18.0" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rfc4648": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz", + "integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==", + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tor-startos": { + "resolved": "git+ssh://git@github.com/Start9Labs/tor-startos.git#3c1bbfeec0c1e9220220303e14a34160703db0d1", + "dependencies": { + "@noble/curves": "*", + "@start9labs/start-sdk": "2.0.5", + "rfc4648": "^1.5.4" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0f135a --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "kamado-pool-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "2.0.5", + "bitcoin-core-startos": "github:Start9Labs/bitcoin-core-startos#next/28.x" + }, + "devDependencies": { + "@types/node": "^22.17.1", + "@vercel/ncc": "^0.38.3", + "prettier": "^3.6.2", + "typescript": "^6.0.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/startos/actions/config.ts b/startos/actions/config.ts new file mode 100644 index 0000000..0431a8e --- /dev/null +++ b/startos/actions/config.ts @@ -0,0 +1,156 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { + defaultStratumPort, + defaultStratumTlsPort, + logLevels, + validatePorts, +} from '../utils' + +const { InputSpec, Value } = sdk + +export const inputSpec = InputSpec.of({ + stratumPort: Value.number({ + name: i18n('Stratum Port'), + description: i18n( + 'TCP port the plaintext stratum server listens on. StartOS also tries to publish the pool on this same port number on your network, so this is normally the port you give your miners — check the Stratum interface after saving, since the OS will pick a different external port if this one is already taken.', + ), + required: true, + default: defaultStratumPort, + integer: true, + min: 1, + max: 65535, + }), + stratumTlsPort: Value.number({ + name: i18n('Stratum TLS Port'), + description: i18n( + 'TCP port stunnel accepts TLS stratum connections on. Only used when Stratum TLS is enabled below.', + ), + required: true, + default: defaultStratumTlsPort, + integer: true, + min: 1, + max: 65535, + }), + coinbaseTag: Value.text({ + name: i18n('Coinbase Tag'), + description: i18n( + 'Short string embedded in the coinbase transaction of solved blocks.', + ), + required: true, + default: '/Kamado/', + patterns: [], + }), + zmqEnabled: Value.toggle({ + name: i18n('ZMQ Block Notifications'), + description: i18n( + "Subscribe to Bitcoin Core's hashblock ZMQ topic for sub-second block detection. RPC polling remains active as a fallback either way.", + ), + default: true, + }), + tlsEnabled: Value.toggle({ + name: i18n('Stratum TLS'), + 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.', + ), + default: false, + }), + startDiff: Value.number({ + name: i18n('Starting Difficulty'), + description: i18n( + 'Initial vardiff target for new miner connections. Bitaxe-class miners typically land around 16384.', + ), + required: true, + default: 16384, + integer: true, + min: 1, + }), + minDiff: Value.number({ + name: i18n('Minimum Difficulty'), + description: i18n('Floor for the vardiff algorithm.'), + required: true, + default: 1000, + integer: true, + min: 1, + }), + maxDiff: Value.number({ + name: i18n('Maximum Difficulty'), + description: i18n('Ceiling for the vardiff algorithm. 0 means no cap.'), + required: true, + default: 0, + integer: true, + min: 0, + }), + dropIdle: Value.number({ + name: i18n('Drop Idle (seconds)'), + description: i18n( + 'Disconnect clients that have not submitted a share in this many seconds. 0 disables the idle disconnect.', + ), + required: true, + default: 0, + integer: true, + min: 0, + units: i18n('seconds'), + }), + logLevel: Value.select({ + name: i18n('Log Level'), + description: i18n('Verbosity of the kamado-api log output.'), + values: logLevels, + default: 'info', + }), + mempoolExplorerUrl: Value.text({ + name: i18n('Custom Block Explorer URL'), + description: i18n( + 'Base URL of a self-hosted mempool instance for dashboard links (e.g. https://mempool.example.com). Kamado appends /address/ and /block/, so the instance must follow the standard mempool.space URL layout. Leave empty to use the public mempool.space.', + ), + required: false, + default: null, + patterns: [ + { + regex: '^https?://[^\\s]+$', + description: i18n( + 'Must be an http:// or https:// URL with no whitespace', + ), + }, + ], + }), +}) + +export const config = sdk.Action.withInput( + // id + 'config', + + // metadata + async ({ effects }) => ({ + name: i18n('Configure'), + description: i18n( + 'Customize vardiff, TLS, block notifications, logging, and explorer links', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // form input specification + inputSpec, + + // optionally pre-fill the input form + async ({ effects }) => storeJson.read().once(), + + // the execution function + async ({ effects, input }) => { + // Refuse port choices that cannot bind — all of these processes share one + // network namespace, so a collision would surface as a restart loop after + // saving rather than as an error here. + const conflict = validatePorts({ + stratumPort: input.stratumPort, + stratumTlsPort: input.stratumTlsPort, + tlsEnabled: input.tlsEnabled, + }) + if (conflict) throw new Error(conflict) + + return storeJson.merge(effects, input) + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..4626631 --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,13 @@ +import { sdk } from '../sdk' +import { config } from './config' +import { poolStatus } from './poolStatus' +import { regenTlsCert } from './regenTlsCert' +import { resetLatency } from './resetLatency' +import { showTlsCert } from './showTlsCert' + +export const actions = sdk.Actions.of() + .addAction(config) + .addAction(poolStatus) + .addAction(showTlsCert) + .addAction(regenTlsCert) + .addAction(resetLatency) diff --git a/startos/actions/poolStatus.ts b/startos/actions/poolStatus.ts new file mode 100644 index 0000000..4101fa5 --- /dev/null +++ b/startos/actions/poolStatus.ts @@ -0,0 +1,390 @@ +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { curlJson, defaultStratumPort, uiPort } from '../utils' + +const API_BASE = `http://127.0.0.1:${uiPort}` + +// ── formatting helpers ──────────────────────────────────────────────────────── + +function ts(): string { + return new Date().toISOString().slice(11, 22) +} + +function fmtHR(hs: number): string { + if (hs <= 0) return '0 H/s' + if (hs >= 1e18) return `${(hs / 1e18).toFixed(2)} EH/s` + if (hs >= 1e15) return `${(hs / 1e15).toFixed(2)} PH/s` + if (hs >= 1e12) return `${(hs / 1e12).toFixed(2)} TH/s` + if (hs >= 1e9) return `${(hs / 1e9).toFixed(2)} GH/s` + if (hs >= 1e6) return `${(hs / 1e6).toFixed(2)} MH/s` + if (hs >= 1e3) return `${(hs / 1e3).toFixed(2)} kH/s` + return `${hs.toFixed(0)} H/s` +} + +function fmtDiff(d: number): string { + if (d <= 0) return '0' + if (d >= 1e12) return `${(d / 1e12).toFixed(2)}T` + if (d >= 1e9) return `${(d / 1e9).toFixed(2)}G` + if (d >= 1e6) return `${(d / 1e6).toFixed(2)}M` + if (d >= 1e3) return `${(d / 1e3).toFixed(2)}K` + return d.toFixed(2) +} + +function fmtUptime(s: number): string { + if (s <= 0) return '0m' + const d = Math.floor(s / 86400) + const h = Math.floor((s % 86400) / 3600) + const m = Math.floor((s % 3600) / 60) + if (d > 0) return `${d}d ${h}h ${m}m` + if (h > 0) return `${h}h ${m}m` + return `${m}m` +} + +function fmtBTC(btc: number): string { + return `${btc.toFixed(8)} BTC` +} +function pad(s: string, w: number): string { + return s.padEnd(w) +} + +const chainNames: Record = { main: 'mainnet' } +function displayChain(c: string): string { + return chainNames[c] ?? c +} + +// ── snapshot shape ──────────────────────────────────────────────────────────── + +interface PoolStats { + workers: number + users: number + accepted: number + rejected: number + shares: number + dsps1: number + dsps5: number + dsps60: number + dsps1440: number +} +interface Chain { + chain: string + blocks: number + headers: number + difficulty: number + initialblockdownload: boolean + verificationprogress: number + bestblockhash: string +} +interface Worker { + worker: string + dsps1: number + bestdiff: number + bestever: number + idle: boolean +} +interface Client { + id: number + workername: string + diff: number + dsps1: number + useragent: string +} +interface BlockRecord { + height: number + hash?: string + reward_btc?: number + found_at: string + orphaned_at?: string + chain?: string +} +interface Snapshot { + pool: PoolStats | null + uptime_seconds: number + hashrate_hs_1m: number + hashrate_hs_5m: number + hashrate_hs_1h: number + hashrate_hs_24h: number + best_diff: number + cumulative_shares: number + next_block_reward_btc: number + next_difficulty_percent: number + chain: Chain | null + network_hashrate_hs: number + recent_blocks: BlockRecord[] + ckpool_ok: boolean + bitcoin_ok: boolean + last_error?: string + block_submit_attempts: number + block_submits_confirmed: number + zmq_enabled: boolean + zmq_stale: boolean + has_last_zmq_event: boolean + last_zmq_event_age: number + workers: Worker[] + clients: Client[] +} +interface DebugBlocks { + memory: { + height: number + hash: string + chain: string + orphaned_at: string + found_at: string + }[] + db: { + height: number + hash: string + chain: string + orphaned_at: string + found_at: string + }[] +} + +export const poolStatus = sdk.Action.withoutInput( + // id + 'pool-status', + + // metadata + async ({ effects }) => ({ + name: i18n('Pool Status'), + description: i18n( + 'Displays a full status snapshot: Bitcoin Core sync state, ckpool health, connected miners, hashrate, found blocks, and submit-gap diagnostics.', + ), + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + // the execution function + async ({ effects }) => { + const stratumPort = + (await storeJson.read((s) => s.stratumPort).once()) ?? defaultStratumPort + + // Fetch from inside the service's network namespace: temp subcontainers + // share it, so curl reaches kamado-api on 127.0.0.1. + const { snap, dbg } = await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + null, + 'pool-status', + async (sub) => ({ + snap: await curlJson(sub, `${API_BASE}/api/snapshot`), + dbg: await curlJson( + sub, + `${API_BASE}/api/admin/debug-blocks`, + ), + }), + ) + + const lines: string[] = [] + const log = (s: string) => lines.push(`[${ts()}] ${s}`) + const sep = () => log('──────────────────────────────────────────') + let allPass = true + + log('Kamado Pool — Status') + sep() + + // ── 1. Full snapshot ──────────────────────────────────────────────────── + log('● Pool & Bitcoin Core status') + if (!snap) { + log(' API unreachable — service may still be starting.') + allPass = false + } + + if (snap) { + const chain = snap.chain + const pool = snap.pool + + log(` Bitcoin Core: ${snap.bitcoin_ok ? 'OK' : 'FAIL'}`) + if (chain) { + const ibd = chain.initialblockdownload + const syncPct = (chain.verificationprogress * 100).toFixed(2) + log( + ` Network: ${chain.chain} height=${chain.blocks} headers=${chain.headers}`, + ) + log( + ` Sync: ${ + ibd + ? `IBD — ${syncPct}% (pool will not mine until synced)` + : `${syncPct}% — fully synced` + }`, + ) + log(` Block hash: ${chain.bestblockhash}`) + log(` Difficulty: ${fmtDiff(chain.difficulty)}`) + log(` Network hashrate: ${fmtHR(snap.network_hashrate_hs)}`) + if (ibd) allPass = false + } + if (!snap.bitcoin_ok) allPass = false + + if (snap.next_block_reward_btc > 0) + log(` Next block reward: ${fmtBTC(snap.next_block_reward_btc)}`) + if (snap.next_difficulty_percent !== 0) { + const sign = snap.next_difficulty_percent > 0 ? '+' : '' + log( + ` Next diff adjust: ${sign}${snap.next_difficulty_percent.toFixed(1)}%`, + ) + } + + if (snap.zmq_enabled) { + const age = snap.has_last_zmq_event + ? `last event ${snap.last_zmq_event_age.toFixed(0)}s ago` + : 'no event yet since startup' + log( + ` ZMQ: ${snap.zmq_stale ? 'STALE — ' : 'OK — '}${age}`, + ) + if (snap.zmq_stale) allPass = false + } else { + log(` ZMQ: disabled`) + } + + log('') + log(` ckpool process: ${snap.ckpool_ok ? 'OK' : 'FAIL'}`) + if (!snap.ckpool_ok) allPass = false + if (snap.last_error) log(` last_error: ${snap.last_error}`) + if (pool) { + log(` Uptime: ${fmtUptime(snap.uptime_seconds)}`) + log( + ` Workers online: ${pool.workers} (${pool.users} user${ + pool.users !== 1 ? 's' : '' + })`, + ) + log(` Shares accepted: ${pool.accepted} rejected: ${pool.rejected}`) + log(` Hashrate 1m: ${fmtHR(snap.hashrate_hs_1m)}`) + log(` Hashrate 5m: ${fmtHR(snap.hashrate_hs_5m)}`) + log(` Hashrate 1h: ${fmtHR(snap.hashrate_hs_1h)}`) + log(` Hashrate 24h: ${fmtHR(snap.hashrate_hs_24h)}`) + log(` Best share ever: diff ${fmtDiff(snap.best_diff)}`) + log( + ` Cumul. work: ${fmtDiff(snap.cumulative_shares)} diff-1 shares`, + ) + } + + const gap = snap.block_submit_attempts - snap.block_submits_confirmed + log( + ` Submit attempts: ${snap.block_submit_attempts} confirmed: ${snap.block_submits_confirmed}`, + ) + log( + ` Submit gap: ${gap > 0 ? `WARN — ${gap} unconfirmed` : 'OK (all confirmed)'}`, + ) + if (gap > 0) allPass = false + + if (snap.clients && snap.clients.length > 0) { + log('') + log(`● Connected miners (${snap.clients.length})`) + for (const c of snap.clients) { + const tag = c.useragent ? ` [${c.useragent}]` : '' + log( + ` ${pad(c.workername || `client#${c.id}`, 30)} diff=${fmtDiff( + c.diff, + )} ${fmtHR(c.dsps1 * 4294967296)}${tag}`, + ) + } + } else { + log('') + log('● Connected miners: none') + } + + if (snap.workers && snap.workers.length > 0) { + log('') + log('● Workers — best shares') + for (const w of snap.workers) { + const ever = w.bestever > 0 ? w.bestever : w.bestdiff + log( + ` ${pad(w.worker, 30)} best=${fmtDiff(ever)}${w.idle ? ' (idle)' : ''}`, + ) + } + } + + log('') + if (snap.recent_blocks && snap.recent_blocks.length > 0) { + const currentNetwork = chain?.chain ?? '' + log(`● Blocks found (${snap.recent_blocks.length})`) + for (const b of snap.recent_blocks) { + let status = '' + if (b.orphaned_at) { + status = ' ORPHANED' + } else if (b.chain && currentNetwork && b.chain !== currentNetwork) { + status = ` [${displayChain(b.chain)}]` + } + const reward = b.reward_btc ? ` ${fmtBTC(b.reward_btc)}` : '' + const hash = b.hash ? ` ${b.hash.slice(0, 16)}…` : '' + log( + ` height=${b.height} ${b.found_at.slice(0, 19)}${reward}${hash}${status}`, + ) + log( + ` chain="${b.chain ?? ''}" orphaned_at="${b.orphaned_at ?? ''}" hash="${b.hash ?? ''}"`, + ) + } + } else { + log('● Blocks found: none yet') + } + } + + // ── 1b. Debug: memory vs DB comparison ───────────────────────────────── + if (dbg) { + log('') + log('● Debug: memory vs DB') + log( + ` Memory blocks: ${dbg.memory?.length ?? 0} DB blocks: ${dbg.db?.length ?? 0}`, + ) + if (dbg.memory && dbg.memory.length > 0) { + for (const m of dbg.memory) { + const dbRow = dbg.db?.find((d) => d.height === m.height) + const memOrph = m.orphaned_at || 'none' + const dbOrph = dbRow?.orphaned_at || 'none' + const match = memOrph === dbOrph ? '✓' : 'MISMATCH' + log( + ` h=${m.height} mem_orphan="${memOrph}" db_orphan="${dbOrph}" ${match}`, + ) + if (memOrph !== dbOrph) { + log( + ` mem: chain="${m.chain}" hash="${(m.hash || '').slice(0, 20)}…"`, + ) + log( + ` db: chain="${dbRow?.chain ?? '?'}" hash="${(dbRow?.hash || '').slice(0, 20)}…"`, + ) + } + } + } + } else { + log(' (debug endpoint unavailable)') + } + + // ── 2. Stratum ─────────────────────────────────────────────────────────── + log('') + sep() + log(`● Stratum (port ${stratumPort})`) + if (snap?.ckpool_ok) { + log( + ' ckpool process: healthy — stratum port is served by the same process', + ) + } else { + log( + ' FAIL — ckpool is not running, stratum port will not accept connections', + ) + } + + // ── 3. Overall ────────────────────────────────────────────────────────── + log('') + sep() + const pass = allPass && (snap?.ckpool_ok ?? false) + const summary = pass + ? i18n('PASS — pool is healthy') + : i18n('FAIL — see details above') + log(`Overall: ${summary}`) + + return { + version: '1', + title: i18n('Pool Status'), + message: summary, + result: { + type: 'single', + value: lines.join('\n'), + copyable: true, + qr: false, + masked: false, + }, + } + }, +) diff --git a/startos/actions/regenTlsCert.ts b/startos/actions/regenTlsCert.ts new file mode 100644 index 0000000..30b9ac1 --- /dev/null +++ b/startos/actions/regenTlsCert.ts @@ -0,0 +1,74 @@ +import { rm } from 'node:fs/promises' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { tlsVolumeFiles } from '../utils' + +export const regenTlsCert = sdk.Action.withoutInput( + // id + 'regen-tls-cert', + + // metadata + async ({ effects }) => ({ + name: i18n('Regenerate TLS Certificate'), + description: i18n( + 'Clears the current stratum TLS certificate so a fresh one is generated on the next service start. Use this to rotate an expired or untrusted certificate.', + ), + warning: i18n( + 'Miners connected via TLS will be disconnected on restart and will need to accept or re-pin the new certificate fingerprint.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // the execution function + async ({ effects }) => { + const removed: string[] = [] + const missing: string[] = [] + for (const f of tlsVolumeFiles) { + const path = sdk.volumes.main.subpath(f) + try { + await rm(path) + removed.push(f.replace(/^tls\//, '')) + } catch { + missing.push(f.replace(/^tls\//, '')) + } + } + + const hadCert = removed.length > 0 + const message = hadCert + ? i18n( + 'TLS certificate cleared — restart the service to generate a new one', + ) + : i18n('No TLS certificate files found (TLS may not have been enabled)') + + const detail = [ + hadCert + ? `${i18n('Removed')}: ${removed.join(', ')}` + : i18n('No certificate files were present.'), + missing.length > 0 + ? `${i18n('Already absent')}: ${missing.join(', ')}` + : '', + '', + i18n('Next steps:'), + ` 1. ${i18n('Restart Kamado Pool.')}`, + ` 2. ${i18n('Run the Stratum TLS Certificate action to see the new fingerprint and PEM.')}`, + ` 3. ${i18n('Provide the new fingerprint or PEM to miners that pin the certificate.')}`, + ] + .filter(Boolean) + .join('\n') + + return { + version: '1', + title: i18n('Regenerate TLS Certificate'), + message, + result: { + type: 'single', + value: detail, + copyable: false, + qr: false, + masked: false, + }, + } + }, +) diff --git a/startos/actions/resetLatency.ts b/startos/actions/resetLatency.ts new file mode 100644 index 0000000..ba8cc84 --- /dev/null +++ b/startos/actions/resetLatency.ts @@ -0,0 +1,68 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { uiPort } from '../utils' + +export const resetLatency = sdk.Action.withoutInput( + // id + 'reset-latency', + + // metadata + async ({ effects }) => ({ + name: i18n('Reset Block Latency'), + description: i18n( + 'Zeroes the block-update latency counters (avg, last, wasted work, block count). Use this after tuning ZMQ or ckpool to start fresh measurements.', + ), + warning: null, + allowedStatuses: 'only-running', + group: null, + visibility: 'enabled', + }), + + // the execution function + async ({ effects }) => { + const ok = await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + null, + 'reset-latency', + async (sub) => { + const res = await sub.exec([ + 'curl', + '-sf', + '--max-time', + '10', + '-X', + 'POST', + `http://127.0.0.1:${uiPort}/api/admin/reset-latency`, + ]) + return res.exitCode === 0 + }, + ) + + if (!ok) { + return { + version: '1', + title: i18n('Reset Block Latency'), + message: i18n( + 'Failed to reset latency stats — the Kamado API did not respond', + ), + result: null, + } + } + + return { + version: '1', + title: i18n('Reset Block Latency'), + message: i18n('Block latency stats reset to zero'), + result: { + type: 'single', + value: i18n( + 'All latency counters (count, avg, last, wasted work) have been cleared. New measurements will accumulate from the next block.', + ), + copyable: false, + qr: false, + masked: false, + }, + } + }, +) diff --git a/startos/actions/showTlsCert.ts b/startos/actions/showTlsCert.ts new file mode 100644 index 0000000..917bfbc --- /dev/null +++ b/startos/actions/showTlsCert.ts @@ -0,0 +1,96 @@ +import { readFile } from 'node:fs/promises' +import { storeJson } from '../fileModels/store.json' +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { defaultStratumTlsPort } from '../utils' + +/** + * 0.4.0 replacement for the 0.3.x "properties" that published the stratum + * TLS fingerprint and full PEM. Miners whose firmware verifies against a CA + * bundle (AxeOS / Bitaxe) need the PEM pasted in as a custom root, or the + * SHA-256 fingerprint pinned, depending on what the firmware exposes. + */ +export const showTlsCert = sdk.Action.withoutInput( + // id + 'show-tls-cert', + + // metadata + async ({ effects }) => ({ + name: i18n('Stratum TLS Certificate'), + description: i18n( + 'Shows the self-signed stratum TLS certificate: SHA-256 fingerprint for pinning and the full PEM to paste into miner firmware (e.g. the AxeOS "Stratum SSL Cert" field).', + ), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: (await storeJson.read((s) => s.tlsEnabled).const(effects)) + ? 'enabled' + : { disabled: i18n('Enable Stratum TLS in Configure first') }, + }), + + // the execution function + async ({ effects }) => { + const tlsPort = + (await storeJson.read((s) => s.stratumTlsPort).once()) ?? + defaultStratumTlsPort + const notYet = i18n('(not yet generated — start the service once)') + const fingerprint = await readFile( + sdk.volumes.main.subpath('tls/fingerprint.txt'), + 'utf-8', + ) + .then((s) => s.trim()) + .catch(() => notYet) + const certPem = await readFile( + sdk.volumes.main.subpath('tls/stratum.crt'), + 'utf-8', + ) + .then((s) => s.trim()) + .catch(() => notYet) + + return { + version: '1', + title: i18n('Stratum TLS Certificate'), + message: i18n( + 'Connect miners with stratum+ssl:// to the Stratum (TLS) interface. The certificate is self-signed: paste the PEM into firmware that accepts a custom root, pin the fingerprint, or disable verification.', + ), + result: { + type: 'group', + value: [ + { + name: i18n('TLS Port (internal)'), + description: i18n( + 'Container-side TLS stratum port. The externally reachable port is shown on the Stratum (TLS) interface.', + ), + type: 'single', + value: String(tlsPort), + copyable: true, + qr: false, + masked: false, + }, + { + name: i18n('Fingerprint (SHA-256)'), + description: i18n( + 'Use this for fingerprint pinning on miner firmwares that support it. Changes only when the certificate is regenerated.', + ), + type: 'single', + value: fingerprint, + copyable: true, + qr: false, + masked: false, + }, + { + name: i18n('Certificate (PEM)'), + description: i18n( + 'Full self-signed certificate. Copy the whole block including the BEGIN/END CERTIFICATE markers.', + ), + type: 'single', + value: certPem, + copyable: true, + qr: false, + masked: false, + }, + ], + }, + } + }, +) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..b289899 --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,15 @@ +import { sdk } from './sdk' + +/** + * Back up both volumes: + * - main: SQLite DB (found blocks, best shares, accelerated txs, log + * cursor), store.json settings, and the persisted stratum TLS certificate + * (so miners' pinned fingerprints survive a restore). + * - ckpool: ckpool's own state files (users/, workers/, pool status) and + * daily logs. The log file is included deliberately: the log-tailer's + * cursor in the DB references it, so restoring both keeps block-solve + * detection consistent. + */ +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main', 'ckpool'), +) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..9e38c12 --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,36 @@ +import { autoconfig } from 'bitcoin-core-startos/startos/actions/config/autoconfig' +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' + +export const setDependencies = sdk.setupDependencies(async ({ effects }) => { + // When the user wants sub-second block detection, ask bitcoind to enable + // its ZMQ publishers. Kamado degrades gracefully to RPC polling without it, + // so this is 'important', not 'critical'. Reactive: toggling ZMQ off in + // Kamado's config withdraws the request on the next re-run. + const zmqWanted = await storeJson.read((s) => s.zmqEnabled).const(effects) + + if (zmqWanted) { + await sdk.action.createTask(effects, 'bitcoind', autoconfig, 'important', { + input: { + kind: 'partial', + accept: [{ zmqEnabled: true }], + set: { zmqEnabled: true }, + }, + when: { condition: 'input-not-matches', once: false }, + reason: i18n( + 'Kamado Pool uses ZMQ block notifications for sub-second stale-work detection — every second of stale work in solo mode is hashrate burned on a dead block.', + ), + }) + } + + return { + bitcoind: { + kind: 'running', + versionRange: '>=28.4:13', + // sync-progress included deliberately: mining on an unsynced node + // produces invalid work, so surface IBD as an unsatisfied dependency. + healthChecks: ['bitcoind', 'sync-progress'], + }, + } +}) diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts new file mode 100644 index 0000000..faebe07 --- /dev/null +++ b/startos/fileModels/store.json.ts @@ -0,0 +1,56 @@ +import { FileHelper, z } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { defaultStratumPort, defaultStratumTlsPort } from '../utils' + +/** + * Persisted service settings (the 0.4.0 replacement for the 0.3.x + * config.yaml). Every field carries a `.catch()` default, so merging `{}` + * on install materializes a fully-populated file, and a corrupt or + * hand-edited file self-heals to defaults instead of crashing the service. + * + * Ports are intentionally absent: internal ports are fixed constants (see + * utils.ts) and external ports are remapped by the user through the StartOS + * interface UI, not service config. + */ +export const storeJson = FileHelper.json( + { + base: sdk.volumes.main, + subpath: '/store.json', + }, + z.object({ + /** + * Port ckpool binds for plaintext stratum. Also requested as the + * interface's preferred external port, so miners reach the pool on this + * same number whenever the OS can grant it. + */ + stratumPort: z.number().int().min(1).max(65535).catch(defaultStratumPort), + /** Port stunnel accepts TLS stratum on (only used when tlsEnabled). */ + stratumTlsPort: z + .number() + .int() + .min(1) + .max(65535) + .catch(defaultStratumTlsPort), + /** Short string embedded in the coinbase transaction of solved blocks (ckpool btcsig). */ + coinbaseTag: z.string().catch('/Kamado/'), + /** Initial vardiff target for new miner connections. */ + startDiff: z.number().int().min(1).catch(16384), + /** Floor for the vardiff algorithm. */ + minDiff: z.number().int().min(1).catch(1000), + /** Ceiling for the vardiff algorithm. 0 means no cap. */ + maxDiff: z.number().int().min(0).catch(0), + /** Disconnect clients idle for this many seconds. 0 disables. */ + dropIdle: z.number().int().min(0).catch(0), + /** kamado-api log verbosity. */ + logLevel: z.enum(['debug', 'info', 'warn', 'error']).catch('info'), + /** Subscribe kamado-api to bitcoind's hashblock ZMQ topic. */ + zmqEnabled: z.boolean().catch(true), + /** Terminate TLS for stratum via the stunnel sidecar on stratumTlsPort. */ + tlsEnabled: z.boolean().catch(false), + /** + * Base URL of a self-hosted mempool instance for dashboard explorer + * links. null -> use the public mempool.space. + */ + mempoolExplorerUrl: z.string().nullable().catch(null), + }), +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..83be4f6 --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,97 @@ +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, + '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, + 'Base URL of a self-hosted mempool instance for dashboard links (e.g. https://mempool.example.com). Kamado appends /address/ and /block/, so the instance must follow the standard mempool.space URL layout. Leave empty to use the public mempool.space.': 5, + 'Bitcoin Core RPC': 6, + 'Bitcoin Core RPC is unreachable': 7, + 'Block Submission': 8, + 'Block latency stats reset to zero': 9, + 'Ceiling for the vardiff algorithm. 0 means no cap.': 10, + 'Certificate (PEM)': 11, + 'Clears the current stratum TLS certificate so a fresh one is generated on the next service start. Use this to rotate an expired or untrusted certificate.': 12, + 'Coinbase Tag': 13, + Configure: 14, + 'Connect miners with stratum+ssl:// to the Stratum (TLS) interface. The certificate is self-signed: paste the PEM into firmware that accepts a custom root, pin the fingerprint, or disable verification.': 15, + 'Connected to Bitcoin Core': 16, + 'Container-side TLS stratum port. The externally reachable port is shown on the Stratum (TLS) interface.': 17, + 'Custom Block Explorer URL': 18, + 'Customize vardiff, TLS, block notifications, logging, and explorer links': 19, + Debug: 20, + 'Disconnect clients that have not submitted a share in this many seconds. 0 disables the idle disconnect.': 21, + 'Displays a full status snapshot: Bitcoin Core sync state, ckpool health, connected miners, hashrate, found blocks, and submit-gap diagnostics.': 22, + 'Drop Idle (seconds)': 23, + 'Enable Stratum TLS in Configure first': 24, + Error: 25, + 'FAIL — see details above': 26, + 'Failed to reset latency stats — the Kamado API did not respond': 27, + 'Fingerprint (SHA-256)': 28, + 'Floor for the vardiff algorithm.': 29, + 'Full self-signed certificate. Copy the whole block including the BEGIN/END CERTIFICATE markers.': 30, + Info: 31, + 'Initial vardiff target for new miner connections. Bitaxe-class miners typically land around 16384.': 32, + 'Kamado API is unreachable — service may be down': 33, + 'Kamado Pool uses ZMQ block notifications for sub-second stale-work detection — every second of stale work in solo mode is hashrate burned on a dead block.': 34, + 'Log Level': 35, + 'Maximum Difficulty': 36, + 'Miners connected via TLS will be disconnected on restart and will need to accept or re-pin the new certificate fingerprint.': 37, + 'Minimum Difficulty': 38, + 'Must be an http:// or https:// URL with no whitespace': 39, + 'Next steps:': 40, + 'No TLS certificate files found (TLS may not have been enabled)': 41, + 'No certificate files were present.': 42, + 'PASS — pool is healthy': 43, + 'Plaintext stratum endpoint. Point miners here with their Bitcoin payout address as the username': 44, + 'Pool Status': 45, + 'Provide the new fingerprint or PEM to miners that pin the certificate.': 46, + 'Real-time Kamado Pool dashboard (hashrate, miners, blocks, best shares)': 47, + 'Regenerate TLS Certificate': 48, + Removed: 49, + 'Reset Block Latency': 50, + 'Restart Kamado Pool.': 51, + 'Run the Stratum TLS Certificate action to see the new fingerprint and PEM.': 52, + 'Short string embedded in the coinbase transaction of solved blocks.': 53, + 'Shows the self-signed stratum TLS certificate: SHA-256 fingerprint for pinning and the full PEM to paste into miner firmware (e.g. the AxeOS "Stratum SSL Cert" field).': 54, + 'Starting Difficulty': 55, + Stratum: 56, + 'Stratum (TLS)': 57, + 'Stratum Server': 58, + 'Stratum TLS': 59, + 'Stratum TLS Certificate': 60, + "Subscribe to Bitcoin Core's hashblock ZMQ topic for sub-second block detection. RPC polling remains active as a fallback either way.": 61, + 'TLS Port (internal)': 62, + '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, + 'The Kamado dashboard is not reachable': 67, + 'The Kamado dashboard is reachable': 68, + 'The stratum server is accepting connections': 69, + 'The stratum server is not accepting connections': 70, + 'Use this for fingerprint pinning on miner firmwares that support it. Changes only when the certificate is regenerated.': 71, + 'Verbosity of the kamado-api log output.': 72, + Warn: 73, + 'Web Dashboard': 74, + 'ZMQ Block Feed': 75, + 'ZMQ Block Notifications': 76, + 'ZMQ block feed is stale — block notifications are falling back to RPC polling': 77, + 'ZMQ block notifications are flowing': 78, + 'Zeroes the block-update latency counters (avg, last, wasted work, block count). Use this after tuning ZMQ or ckpool to start fresh measurements.': 79, + 'block(s) submitted to bitcoind but not confirmed — check Bitcoin Core logs': 80, + seconds: 81, + 'Stratum Port': 82, + 'TCP port the plaintext stratum server listens on. StartOS also tries to publish the pool on this same port number on your network, so this is normally the port you give your miners — check the Stratum interface after saving, since the OS will pick a different external port if this one is already taken.': 83, + 'Stratum TLS Port': 84, + 'TCP port stunnel accepts TLS stratum connections on. Only used when Stratum TLS is enabled below.': 85, +} as const + +/** + * Plumbing. DO NOT EDIT. + */ +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..2b2e8ae --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,356 @@ +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.', + 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', + 5: 'URL base de una instancia mempool autoalojada para los enlaces del panel (p. ej. https://mempool.example.com). Kamado añade /address/ y /block/, por lo que la instancia debe seguir el esquema de URL estándar de mempool.space. Déjelo vacío para usar el mempool.space público.', + 6: 'RPC de Bitcoin Core', + 7: 'No se puede acceder al RPC de Bitcoin Core', + 8: 'Envío de bloques', + 9: 'Estadísticas de latencia de bloques puestas a cero', + 10: 'Techo del algoritmo vardiff. 0 significa sin límite.', + 11: 'Certificado (PEM)', + 12: 'Elimina el certificado TLS de stratum actual para que se genere uno nuevo en el próximo inicio del servicio. Úselo para rotar un certificado caducado o no confiable.', + 13: 'Etiqueta de coinbase', + 14: 'Configurar', + 15: 'Conecte los mineros con stratum+ssl:// a la interfaz Stratum (TLS). El certificado es autofirmado: pegue el PEM en firmware que acepte una raíz personalizada, fije la huella o desactive la verificación.', + 16: 'Conectado a Bitcoin Core', + 17: 'Puerto TLS de stratum del lado del contenedor. El puerto accesible desde el exterior se muestra en la interfaz Stratum (TLS).', + 18: 'URL de explorador de bloques personalizado', + 19: 'Personalice vardiff, TLS, notificaciones de bloques, registro y enlaces del explorador', + 20: 'Depuración', + 21: 'Desconecta a los clientes que no hayan enviado una participación en este número de segundos. 0 desactiva la desconexión por inactividad.', + 22: 'Muestra una instantánea de estado completa: sincronización de Bitcoin Core, salud de ckpool, mineros conectados, hashrate, bloques encontrados y diagnóstico de envíos.', + 23: 'Desconexión por inactividad (segundos)', + 24: 'Active primero Stratum TLS en Configurar', + 25: 'Error', + 26: 'FALLO — vea los detalles arriba', + 27: 'No se pudieron restablecer las estadísticas de latencia — la API de Kamado no respondió', + 28: 'Huella digital (SHA-256)', + 29: 'Suelo del algoritmo vardiff.', + 30: 'Certificado autofirmado completo. Copie todo el bloque incluidos los marcadores BEGIN/END CERTIFICATE.', + 31: 'Información', + 32: 'Objetivo vardiff inicial para nuevas conexiones de mineros. Los mineros tipo Bitaxe suelen quedar en torno a 16384.', + 33: 'No se puede acceder a la API de Kamado — el servicio puede estar caído', + 34: 'Kamado Pool usa notificaciones ZMQ de bloques para detectar trabajo obsoleto en menos de un segundo — cada segundo de trabajo obsoleto en modo solo es hashrate quemado en un bloque muerto.', + 35: 'Nivel de registro', + 36: 'Dificultad máxima', + 37: 'Los mineros conectados por TLS se desconectarán al reiniciar y deberán aceptar o volver a fijar la huella del nuevo certificado.', + 38: 'Dificultad mínima', + 39: 'Debe ser una URL http:// o https:// sin espacios', + 40: 'Próximos pasos:', + 41: 'No se encontraron archivos de certificado TLS (puede que TLS no esté activado)', + 42: 'No había archivos de certificado.', + 43: 'CORRECTO — el pool está en buen estado', + 44: 'Punto de acceso stratum sin cifrar. Apunte aquí a los mineros con su dirección de pago de Bitcoin como nombre de usuario', + 45: 'Estado del pool', + 46: 'Proporcione la nueva huella o el PEM a los mineros que fijan el certificado.', + 47: 'Panel de Kamado Pool en tiempo real (hashrate, mineros, bloques, mejores participaciones)', + 48: 'Regenerar certificado TLS', + 49: 'Eliminado', + 50: 'Restablecer latencia de bloques', + 51: 'Reinicie Kamado Pool.', + 52: 'Ejecute la acción Certificado TLS de Stratum para ver la nueva huella y el PEM.', + 53: 'Cadena corta incrustada en la transacción coinbase de los bloques resueltos.', + 54: 'Muestra el certificado TLS autofirmado de stratum: huella SHA-256 para fijar y el PEM completo para pegar en el firmware del minero (p. ej. el campo «Stratum SSL Cert» de AxeOS).', + 55: 'Dificultad inicial', + 56: 'Stratum', + 57: 'Stratum (TLS)', + 58: 'Servidor stratum', + 59: 'Stratum TLS', + 60: 'Certificado TLS de Stratum', + 61: 'Suscribirse al tema ZMQ hashblock de Bitcoin Core para detectar bloques en menos de un segundo. El sondeo RPC permanece activo como respaldo en cualquier caso.', + 62: 'Puerto TLS (interno)', + 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)', + 67: 'No se puede acceder al panel de Kamado', + 68: 'El panel de Kamado está accesible', + 69: 'El servidor stratum acepta conexiones', + 70: 'El servidor stratum no acepta conexiones', + 71: 'Úselo para fijar la huella en firmwares de mineros que lo admitan. Solo cambia cuando se regenera el certificado.', + 72: 'Verbosidad de la salida de registro de kamado-api.', + 73: 'Advertencia', + 74: 'Panel web', + 75: 'Flujo de bloques ZMQ', + 76: 'Notificaciones de bloques ZMQ', + 77: 'El flujo de bloques ZMQ está obsoleto — las notificaciones de bloques recurren al sondeo RPC', + 78: 'Las notificaciones de bloques ZMQ fluyen correctamente', + 79: 'Pone a cero los contadores de latencia de actualización de bloques (promedio, último, trabajo desperdiciado, conteo). Úselo tras ajustar ZMQ o ckpool para empezar mediciones nuevas.', + 80: 'bloque(s) enviados a bitcoind pero no confirmados — revise los registros de Bitcoin Core', + 81: 'segundos', + 82: 'Puerto stratum', + 83: 'Puerto TCP en el que escucha el servidor stratum sin cifrar. StartOS también intenta publicar el pool en ese mismo número de puerto en su red, por lo que normalmente es el puerto que dará a sus mineros: revise la interfaz Stratum después de guardar, ya que el sistema elegirá otro puerto externo si este ya está ocupado.', + 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.', + }, + 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.', + 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', + 5: 'Basis-URL einer selbst gehosteten Mempool-Instanz für Dashboard-Links (z. B. https://mempool.example.com). Kamado hängt /address/ und /block/ an, die Instanz muss also dem Standard-URL-Schema von mempool.space folgen. Leer lassen, um das öffentliche mempool.space zu verwenden.', + 6: 'Bitcoin Core RPC', + 7: 'Bitcoin Core RPC ist nicht erreichbar', + 8: 'Blockeinreichung', + 9: 'Blocklatenz-Statistiken auf null zurückgesetzt', + 10: 'Obergrenze für den Vardiff-Algorithmus. 0 bedeutet keine Begrenzung.', + 11: 'Zertifikat (PEM)', + 12: 'Löscht das aktuelle Stratum-TLS-Zertifikat, sodass beim nächsten Dienststart ein neues erzeugt wird. Verwenden Sie dies, um ein abgelaufenes oder nicht vertrauenswürdiges Zertifikat zu rotieren.', + 13: 'Coinbase-Tag', + 14: 'Konfigurieren', + 15: 'Verbinden Sie Miner mit stratum+ssl:// mit der Stratum-(TLS)-Schnittstelle. Das Zertifikat ist selbstsigniert: Fügen Sie das PEM in Firmware ein, die eine eigene Root akzeptiert, pinnen Sie den Fingerabdruck oder deaktivieren Sie die Verifizierung.', + 16: 'Mit Bitcoin Core verbunden', + 17: 'Containerseitiger TLS-Stratum-Port. Der extern erreichbare Port wird auf der Stratum-(TLS)-Schnittstelle angezeigt.', + 18: 'Benutzerdefinierte Block-Explorer-URL', + 19: 'Passen Sie Vardiff, TLS, Blockbenachrichtigungen, Protokollierung und Explorer-Links an', + 20: 'Debug', + 21: 'Trennt Clients, die innerhalb dieser Sekundenzahl keinen Share eingereicht haben. 0 deaktiviert die Leerlauf-Trennung.', + 22: 'Zeigt einen vollständigen Status-Schnappschuss: Bitcoin-Core-Synchronisierung, ckpool-Zustand, verbundene Miner, Hashrate, gefundene Blöcke und Submit-Gap-Diagnose.', + 23: 'Leerlauf-Trennung (Sekunden)', + 24: 'Aktivieren Sie zuerst Stratum-TLS unter Konfigurieren', + 25: 'Fehler', + 26: 'FEHLER — siehe Details oben', + 27: 'Latenzstatistiken konnten nicht zurückgesetzt werden — die Kamado-API hat nicht geantwortet', + 28: 'Fingerabdruck (SHA-256)', + 29: 'Untergrenze für den Vardiff-Algorithmus.', + 30: 'Vollständiges selbstsigniertes Zertifikat. Kopieren Sie den gesamten Block einschließlich der BEGIN/END-CERTIFICATE-Markierungen.', + 31: 'Info', + 32: 'Anfängliches Vardiff-Ziel für neue Miner-Verbindungen. Miner der Bitaxe-Klasse landen typischerweise bei etwa 16384.', + 33: 'Kamado-API ist nicht erreichbar — der Dienst ist möglicherweise ausgefallen', + 34: 'Kamado Pool nutzt ZMQ-Blockbenachrichtigungen zur Erkennung veralteter Arbeit im Subsekundenbereich — jede Sekunde veralteter Arbeit im Solo-Modus ist auf einem toten Block verbrannte Hashrate.', + 35: 'Protokollstufe', + 36: 'Maximale Schwierigkeit', + 37: 'Über TLS verbundene Miner werden beim Neustart getrennt und müssen den neuen Zertifikat-Fingerabdruck akzeptieren oder neu pinnen.', + 38: 'Minimale Schwierigkeit', + 39: 'Muss eine http://- oder https://-URL ohne Leerzeichen sein', + 40: 'Nächste Schritte:', + 41: 'Keine TLS-Zertifikatsdateien gefunden (TLS wurde möglicherweise nicht aktiviert)', + 42: 'Es waren keine Zertifikatsdateien vorhanden.', + 43: 'OK — der Pool ist gesund', + 44: 'Unverschlüsselter Stratum-Endpunkt. Verbinden Sie Miner hierhin mit ihrer Bitcoin-Auszahlungsadresse als Benutzername', + 45: 'Pool-Status', + 46: 'Geben Sie den neuen Fingerabdruck oder das PEM an Miner weiter, die das Zertifikat pinnen.', + 47: 'Echtzeit-Dashboard von Kamado Pool (Hashrate, Miner, Blöcke, beste Shares)', + 48: 'TLS-Zertifikat neu erzeugen', + 49: 'Entfernt', + 50: 'Blocklatenz zurücksetzen', + 51: 'Starten Sie Kamado Pool neu.', + 52: 'Führen Sie die Aktion „Stratum-TLS-Zertifikat“ aus, um den neuen Fingerabdruck und das PEM zu sehen.', + 53: 'Kurzer String, der in die Coinbase-Transaktion gelöster Blöcke eingebettet wird.', + 54: 'Zeigt das selbstsignierte Stratum-TLS-Zertifikat: SHA-256-Fingerabdruck zum Pinnen und das vollständige PEM zum Einfügen in die Miner-Firmware (z. B. das AxeOS-Feld „Stratum SSL Cert“).', + 55: 'Anfangsschwierigkeit', + 56: 'Stratum', + 57: 'Stratum (TLS)', + 58: 'Stratum-Server', + 59: 'Stratum-TLS', + 60: 'Stratum-TLS-Zertifikat', + 61: 'Abonniert das hashblock-ZMQ-Thema von Bitcoin Core zur Blockerkennung im Subsekundenbereich. RPC-Polling bleibt in jedem Fall als Fallback aktiv.', + 62: 'TLS-Port (intern)', + 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“)', + 67: 'Das Kamado-Dashboard ist nicht erreichbar', + 68: 'Das Kamado-Dashboard ist erreichbar', + 69: 'Der Stratum-Server akzeptiert Verbindungen', + 70: 'Der Stratum-Server akzeptiert keine Verbindungen', + 71: 'Verwenden Sie dies für Fingerabdruck-Pinning auf Miner-Firmwares, die es unterstützen. Ändert sich nur, wenn das Zertifikat neu erzeugt wird.', + 72: 'Ausführlichkeit der kamado-api-Protokollausgabe.', + 73: 'Warnung', + 74: 'Web-Dashboard', + 75: 'ZMQ-Block-Feed', + 76: 'ZMQ-Blockbenachrichtigungen', + 77: 'ZMQ-Block-Feed ist veraltet — Blockbenachrichtigungen fallen auf RPC-Polling zurück', + 78: 'ZMQ-Blockbenachrichtigungen fließen', + 79: 'Setzt die Latenzzähler für Blockaktualisierungen auf null (Durchschnitt, letzter, verschwendete Arbeit, Blockanzahl). Verwenden Sie dies nach dem Tuning von ZMQ oder ckpool für frische Messungen.', + 80: 'Block/Blöcke an bitcoind übermittelt, aber nicht bestätigt — prüfen Sie die Bitcoin-Core-Protokolle', + 81: 'Sekunden', + 82: 'Stratum-Port', + 83: 'TCP-Port, auf dem der unverschlüsselte Stratum-Server lauscht. StartOS versucht außerdem, den Pool unter derselben Portnummer im Netzwerk zu veröffentlichen — normalerweise ist dies also der Port für Ihre Miner. Prüfen Sie nach dem Speichern die Stratum-Schnittstelle, denn das System wählt einen anderen externen Port, falls dieser belegt ist.', + 84: 'Stratum-TLS-Port', + 85: 'TCP-Port, auf dem stunnel TLS-Stratum-Verbindungen annimmt. Wird nur verwendet, wenn Stratum-TLS unten aktiviert ist.', + }, + 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ą.', + 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', + 5: 'Bazowy URL własnej instancji mempool dla linków panelu (np. https://mempool.example.com). Kamado dołącza /address/ i /block/, więc instancja musi stosować standardowy układ URL mempool.space. Pozostaw puste, aby użyć publicznego mempool.space.', + 6: 'RPC Bitcoin Core', + 7: 'RPC Bitcoin Core jest nieosiągalne', + 8: 'Przesyłanie bloków', + 9: 'Statystyki opóźnień bloków wyzerowane', + 10: 'Górny limit algorytmu vardiff. 0 oznacza brak limitu.', + 11: 'Certyfikat (PEM)', + 12: 'Usuwa bieżący certyfikat TLS stratum, aby przy następnym uruchomieniu usługi wygenerować nowy. Użyj tego, aby wymienić wygasły lub niezaufany certyfikat.', + 13: 'Znacznik coinbase', + 14: 'Konfiguruj', + 15: 'Podłącz górników przez stratum+ssl:// do interfejsu Stratum (TLS). Certyfikat jest samopodpisany: wklej PEM do firmware akceptującego własny certyfikat główny, przypnij odcisk lub wyłącz weryfikację.', + 16: 'Połączono z Bitcoin Core', + 17: 'Port TLS stratum po stronie kontenera. Port osiągalny z zewnątrz jest widoczny w interfejsie Stratum (TLS).', + 18: 'Niestandardowy URL eksploratora bloków', + 19: 'Dostosuj vardiff, TLS, powiadomienia o blokach, logowanie i linki eksploratora', + 20: 'Debug', + 21: 'Rozłącza klientów, którzy nie przesłali udziału przez podaną liczbę sekund. 0 wyłącza rozłączanie bezczynnych.', + 22: 'Wyświetla pełny stan: synchronizację Bitcoin Core, zdrowie ckpool, podłączonych górników, hashrate, znalezione bloki i diagnostykę przesyłania.', + 23: 'Rozłączanie bezczynnych (sekundy)', + 24: 'Najpierw włącz Stratum TLS w Konfiguruj', + 25: 'Błąd', + 26: 'BŁĄD — szczegóły powyżej', + 27: 'Nie udało się wyzerować statystyk opóźnień — API Kamado nie odpowiedziało', + 28: 'Odcisk (SHA-256)', + 29: 'Dolny limit algorytmu vardiff.', + 30: 'Pełny certyfikat samopodpisany. Skopiuj cały blok wraz ze znacznikami BEGIN/END CERTIFICATE.', + 31: 'Informacja', + 32: 'Początkowy cel vardiff dla nowych połączeń górników. Górnicy klasy Bitaxe zwykle osiągają około 16384.', + 33: 'API Kamado jest nieosiągalne — usługa może nie działać', + 34: 'Kamado Pool używa powiadomień ZMQ o blokach do wykrywania przestarzałej pracy w ułamku sekundy — każda sekunda przestarzałej pracy w trybie solo to hashrate spalony na martwym bloku.', + 35: 'Poziom logowania', + 36: 'Maksymalna trudność', + 37: 'Górnicy połączeni przez TLS zostaną rozłączeni przy restarcie i będą musieli zaakceptować lub ponownie przypiąć odcisk nowego certyfikatu.', + 38: 'Minimalna trudność', + 39: 'Musi być adresem URL http:// lub https:// bez spacji', + 40: 'Następne kroki:', + 41: 'Nie znaleziono plików certyfikatu TLS (TLS mógł nie być włączony)', + 42: 'Nie było plików certyfikatu.', + 43: 'OK — pula działa prawidłowo', + 44: 'Nieszyfrowany punkt końcowy stratum. Skieruj tu górników z ich adresem wypłaty Bitcoin jako nazwą użytkownika', + 45: 'Stan puli', + 46: 'Przekaż nowy odcisk lub PEM górnikom przypinającym certyfikat.', + 47: 'Panel Kamado Pool w czasie rzeczywistym (hashrate, górnicy, bloki, najlepsze udziały)', + 48: 'Wygeneruj ponownie certyfikat TLS', + 49: 'Usunięto', + 50: 'Zresetuj opóźnienie bloków', + 51: 'Uruchom ponownie Kamado Pool.', + 52: 'Uruchom akcję Certyfikat TLS Stratum, aby zobaczyć nowy odcisk i PEM.', + 53: 'Krótki ciąg osadzany w transakcji coinbase rozwiązanych bloków.', + 54: 'Pokazuje samopodpisany certyfikat TLS stratum: odcisk SHA-256 do przypięcia i pełny PEM do wklejenia w firmware górnika (np. pole „Stratum SSL Cert” w AxeOS).', + 55: 'Trudność początkowa', + 56: 'Stratum', + 57: 'Stratum (TLS)', + 58: 'Serwer stratum', + 59: 'Stratum TLS', + 60: 'Certyfikat TLS Stratum', + 61: 'Subskrybuje temat ZMQ hashblock Bitcoin Core w celu wykrywania bloków w ułamku sekundy. Odpytywanie RPC pozostaje aktywne jako rezerwa.', + 62: 'Port TLS (wewnętrzny)', + 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)', + 67: 'Panel Kamado jest nieosiągalny', + 68: 'Panel Kamado jest osiągalny', + 69: 'Serwer stratum przyjmuje połączenia', + 70: 'Serwer stratum nie przyjmuje połączeń', + 71: 'Użyj tego do przypinania odcisku w firmware górników, które to obsługują. Zmienia się tylko przy ponownym wygenerowaniu certyfikatu.', + 72: 'Szczegółowość logów kamado-api.', + 73: 'Ostrzeżenie', + 74: 'Panel WWW', + 75: 'Kanał bloków ZMQ', + 76: 'Powiadomienia ZMQ o blokach', + 77: 'Kanał bloków ZMQ jest nieaktualny — powiadomienia o blokach wracają do odpytywania RPC', + 78: 'Powiadomienia ZMQ o blokach napływają', + 79: 'Zeruje liczniki opóźnień aktualizacji bloków (średnia, ostatni, zmarnowana praca, liczba bloków). Użyj po dostrojeniu ZMQ lub ckpool, aby rozpocząć nowe pomiary.', + 80: 'blok(i) przesłane do bitcoind, ale niepotwierdzone — sprawdź logi Bitcoin Core', + 81: 'sekundy', + 82: 'Port stratum', + 83: 'Port TCP, na którym nasłuchuje nieszyfrowany serwer stratum. StartOS próbuje również udostępnić pulę pod tym samym numerem portu w sieci, więc zwykle jest to port podawany górnikom — po zapisaniu sprawdź interfejs Stratum, ponieważ system wybierze inny port zewnętrzny, jeśli ten jest zajęty.', + 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.', + }, + 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 l’action Certificat TLS Stratum) ou se connecter sans vérification.', + 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 s’accumuleront à partir du prochain bloc.', + 4: 'Déjà absent', + 5: 'URL de base d’une instance mempool auto-hébergée pour les liens du tableau de bord (p. ex. https://mempool.example.com). Kamado ajoute /address/ et /block/, l’instance doit donc suivre le schéma d’URL standard de mempool.space. Laissez vide pour utiliser le mempool.space public.', + 6: 'RPC de Bitcoin Core', + 7: 'Le RPC de Bitcoin Core est injoignable', + 8: 'Soumission de blocs', + 9: 'Statistiques de latence des blocs remises à zéro', + 10: 'Plafond de l’algorithme vardiff. 0 signifie aucune limite.', + 11: 'Certificat (PEM)', + 12: 'Supprime le certificat TLS stratum actuel afin qu’un nouveau soit généré au prochain démarrage du service. Utilisez ceci pour renouveler un certificat expiré ou non fiable.', + 13: 'Tag coinbase', + 14: 'Configurer', + 15: 'Connectez les mineurs avec stratum+ssl:// à l’interface Stratum (TLS). Le certificat est auto-signé : collez le PEM dans un firmware acceptant une racine personnalisée, épinglez l’empreinte ou désactivez la vérification.', + 16: 'Connecté à Bitcoin Core', + 17: 'Port stratum TLS côté conteneur. Le port accessible de l’extérieur est indiqué sur l’interface Stratum (TLS).', + 18: 'URL d’explorateur de blocs personnalisé', + 19: 'Personnalisez vardiff, TLS, notifications de blocs, journalisation et liens d’explorateur', + 20: 'Débogage', + 21: 'Déconnecte les clients n’ayant soumis aucune part depuis ce nombre de secondes. 0 désactive la déconnexion pour inactivité.', + 22: 'Affiche un instantané complet : synchronisation de Bitcoin Core, santé de ckpool, mineurs connectés, hashrate, blocs trouvés et diagnostic des soumissions.', + 23: 'Déconnexion inactifs (secondes)', + 24: 'Activez d’abord Stratum TLS dans Configurer', + 25: 'Erreur', + 26: 'ÉCHEC — voir les détails ci-dessus', + 27: 'Impossible de réinitialiser les statistiques de latence — l’API Kamado n’a pas répondu', + 28: 'Empreinte (SHA-256)', + 29: 'Plancher de l’algorithme vardiff.', + 30: 'Certificat auto-signé complet. Copiez tout le bloc, y compris les marqueurs BEGIN/END CERTIFICATE.', + 31: 'Info', + 32: 'Cible vardiff initiale pour les nouvelles connexions. Les mineurs de classe Bitaxe se situent généralement autour de 16384.', + 33: 'L’API Kamado est injoignable — le service est peut-être arrêté', + 34: 'Kamado Pool utilise les notifications de blocs ZMQ pour détecter le travail obsolète en moins d’une seconde — chaque seconde de travail obsolète en mode solo est du hashrate brûlé sur un bloc mort.', + 35: 'Niveau de journalisation', + 36: 'Difficulté maximale', + 37: 'Les mineurs connectés en TLS seront déconnectés au redémarrage et devront accepter ou ré-épingler l’empreinte du nouveau certificat.', + 38: 'Difficulté minimale', + 39: 'Doit être une URL http:// ou https:// sans espaces', + 40: 'Étapes suivantes :', + 41: 'Aucun fichier de certificat TLS trouvé (TLS n’a peut-être pas été activé)', + 42: 'Aucun fichier de certificat n’était présent.', + 43: 'OK — le pool est en bonne santé', + 44: 'Point de terminaison stratum en clair. Pointez les mineurs ici avec leur adresse de paiement Bitcoin comme nom d’utilisateur', + 45: 'État du pool', + 46: 'Fournissez la nouvelle empreinte ou le PEM aux mineurs qui épinglent le certificat.', + 47: 'Tableau de bord Kamado Pool en temps réel (hashrate, mineurs, blocs, meilleures parts)', + 48: 'Régénérer le certificat TLS', + 49: 'Supprimé', + 50: 'Réinitialiser la latence des blocs', + 51: 'Redémarrez Kamado Pool.', + 52: 'Exécutez l’action Certificat TLS Stratum pour voir la nouvelle empreinte et le PEM.', + 53: 'Courte chaîne intégrée dans la transaction coinbase des blocs résolus.', + 54: 'Affiche le certificat TLS stratum auto-signé : empreinte SHA-256 à épingler et PEM complet à coller dans le firmware du mineur (p. ex. le champ « Stratum SSL Cert » d’AxeOS).', + 55: 'Difficulté initiale', + 56: 'Stratum', + 57: 'Stratum (TLS)', + 58: 'Serveur stratum', + 59: 'Stratum TLS', + 60: 'Certificat TLS Stratum', + 61: 'S’abonne au sujet ZMQ hashblock de Bitcoin Core pour détecter les blocs en moins d’une seconde. Le sondage RPC reste actif en secours dans tous les cas.', + 62: 'Port TLS (interne)', + 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 n’accepte pas les connexions', + 66: 'Point de terminaison stratum chiffré en TLS (certificat auto-signé — voir l’action 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', + 70: 'Le serveur stratum n’accepte pas les connexions', + 71: 'Utilisez ceci pour l’épinglage d’empreinte sur les firmwares de mineurs compatibles. Ne change que lorsque le certificat est régénéré.', + 72: 'Verbosité des journaux de kamado-api.', + 73: 'Avertissement', + 74: 'Tableau de bord web', + 75: 'Flux de blocs ZMQ', + 76: 'Notifications de blocs ZMQ', + 77: 'Le flux de blocs ZMQ est périmé — les notifications de blocs se rabattent sur le sondage RPC', + 78: 'Les notifications de blocs ZMQ arrivent', + 79: 'Remet à zéro les compteurs de latence de mise à jour des blocs (moyenne, dernier, travail gaspillé, nombre). Utilisez ceci après avoir réglé ZMQ ou ckpool pour repartir sur des mesures neuves.', + 80: 'bloc(s) soumis à bitcoind mais non confirmés — vérifiez les journaux de Bitcoin Core', + 81: 'secondes', + 82: 'Port stratum', + 83: 'Port TCP sur lequel le serveur stratum en clair écoute. StartOS tente également de publier le pool sur ce même numéro de port sur votre réseau : c’est donc normalement le port à donner à vos mineurs. Vérifiez l’interface Stratum après enregistrement, car le système choisira un autre port externe si celui-ci est déjà pris.', + 84: 'Port TLS stratum', + 85: 'Port TCP sur lequel stunnel accepte les connexions stratum TLS. Utilisé uniquement lorsque Stratum TLS est activé ci-dessous.', + }, +} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..06880a0 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,18 @@ +import { sdk } from '../sdk' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { seedFiles } from './seedFiles' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + seedFiles, + setInterfaces, + setDependencies, + actions, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/seedFiles.ts b/startos/init/seedFiles.ts new file mode 100644 index 0000000..c8ffef0 --- /dev/null +++ b/startos/init/seedFiles.ts @@ -0,0 +1,11 @@ +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' + +/** + * Merging {} materializes every `.catch()` default in the store schema, so a + * fresh install gets a fully-populated store.json and an existing one is + * healed if fields are missing (e.g. after a restore from an older backup). + */ +export const seedFiles = sdk.setupOnInit(async (effects) => { + await storeJson.merge(effects, {}) +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..c3b09b0 --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,103 @@ +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { + defaultStratumPort, + defaultStratumTlsPort, + stratumHostId, + stratumTlsHostId, + uiHostId, + uiPort, +} from './utils' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + // Stratum ports are user config. Read reactively so changing them in the + // Configure action re-runs this and rebinds the interfaces — the same + // mechanism that adds/removes the TLS interface when TLS is toggled. + const ports = await storeJson + .read((s) => ({ + stratum: s.stratumPort, + stratumTls: s.stratumTlsPort, + tlsEnabled: s.tlsEnabled, + })) + .const(effects) + + const stratumPort = ports?.stratum ?? defaultStratumPort + const stratumTlsPort = ports?.stratumTls ?? defaultStratumTlsPort + + // Web dashboard + const uiMulti = sdk.MultiHost.of(effects, uiHostId) + const uiMultiOrigin = await uiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const ui = sdk.createInterface(effects, { + name: i18n('Web Dashboard'), + id: 'ui', + description: i18n( + 'Real-time Kamado Pool dashboard (hashrate, miners, blocks, best shares)', + ), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + const uiReceipt = await uiMultiOrigin.export([ui]) + const receipts = [uiReceipt] + + // Plaintext stratum — a raw TCP interface. Unlike StartOS 0.3.x, 0.4.0 + // forwards raw TCP on the LAN, so miners connect directly to the host at + // the assigned external port; no router forward or simpleproxy needed. + const stratumMulti = sdk.MultiHost.of(effects, stratumHostId) + const stratumOrigin = await stratumMulti.bindPort(stratumPort, { + protocol: null, + preferredExternalPort: stratumPort, + addSsl: null, + secure: { ssl: false }, + }) + const stratum = sdk.createInterface(effects, { + name: i18n('Stratum'), + id: 'stratum', + description: i18n( + 'Plaintext stratum endpoint. Point miners here with their Bitcoin payout address as the username', + ), + type: 'api', + masked: false, + schemeOverride: { ssl: 'stratum+ssl', noSsl: 'stratum+tcp' }, + username: null, + path: '', + query: {}, + }) + 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(stratumTlsPort, { + protocol: null, + preferredExternalPort: stratumTlsPort, + 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])) + } + + return receipts +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..0b291c5 --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,390 @@ +import { FileHelper } from '@start9labs/start-sdk' +import { manifest as bitcoindManifest } from 'bitcoin-core-startos/startos/manifest' +import { mkdir, writeFile } from 'node:fs/promises' +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { + bitcoindBridge, + btcMountpoint, + ckpoolLogDir, + ckpoolLogFile, + ckpoolRoot, + ckpoolSocketDir, + curlJson, + HealthPayload, + kamadoDataDir, + kamadoDbPath, + kamadoRoot, + parseCookie, + tlsDir, + tlsInternalPort, + uiPort, +} from './utils' + +const healthUrl = `http://127.0.0.1:${uiPort}/api/health` + +export const main = sdk.setupMain(async ({ effects }) => { + /** + * ======================== Setup ======================== + */ + console.info('Starting Kamado Pool!') + + // Service settings; reactive, so any config-action change restarts the + // daemons with a freshly rendered ckpool.conf. + const store = await storeJson.read().const(effects) + if (!store) throw new Error('No store.json') + + // bitcoind's RPC + ZMQ endpoints over the LXC bridge (see bitcoindBridge in + // utils.ts). Each resolves null while bitcoind is absent; the .const() + // watches heal main with a restart when bitcoind appears, disappears, or + // changes ports — and never on a routine bitcoind update. + const bitcoind = await bitcoindBridge(effects) + + // 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 + // cross-container plumbing. + const kamadoSub = await sdk.SubContainer.eager( + effects, + { imageId: 'main' }, + sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: kamadoRoot, + readonly: false, + }) + .mountVolume({ + volumeId: 'ckpool', + subpath: null, + mountpoint: ckpoolRoot, + readonly: false, + }) + .mountDependency({ + dependencyId: 'bitcoind', + volumeId: 'main', + subpath: null, + mountpoint: btcMountpoint, + readonly: true, + }), + 'kamado', + ) + + // bitcoind uses cookie authentication in 0.4.0 (no more rpcuser/rpcpassword + // pointers). Read the cookie from the read-only dependency mount and watch + // it: a cookie rotation (bitcoind restart) restarts Kamado with fresh + // credentials. Null until bitcoind has started at least once. + const cookieRaw = await FileHelper.string( + `${kamadoSub.rootfs}/mnt/bitcoind/.cookie`, + ) + .read() + .const(effects) + const cookie = parseCookie(cookieRaw) + + // Placeholders keep kamado-api bootable while bitcoind is unresolved: the + // dashboard comes up, reports Bitcoin Core as unreachable, and the reactive + // reads above heal everything once the dependency is satisfied. + const rpcAddr = bitcoind.rpc ?? '127.0.0.1:8332' + const rpcUser = cookie?.user ?? '__cookie__' + const rpcPassword = cookie?.password ?? 'bitcoind-not-yet-available' + + // ckpool has TWO independent new-block detection paths. Wire up both so + // we're never blind to a tip change (every second of stale work in solo + // mode is hashrate burned on a dead block): + // 1. Blockpoll thread: polls getbestblockhash every `blockpoll` ms. Only + // runs when notify=false — so keep notify=false. + // 2. ZMQ hashblock subscriber: instant push from bitcoind. Point it at + // the real bridge endpoint; fall back to ckpool's (dead, harmless) + // loopback default while bitcoind's ZMQ interface is unavailable. + const ckpoolZmqBlock = bitcoind.zmqBlock + ? `tcp://${bitcoind.zmqBlock}` + : 'tcp://127.0.0.1:28332' + + // Rendered ckpool.conf, written to the subcontainer rootfs (ephemeral, so + // RPC credentials never touch a persisted volume). `btcaddress` is only + // consulted once at startup for ckpool's coinbase-builder self-test; solo + // mode pays the worker's stratum address, never this one. The right + // self-test address depends on the active network, which ckpool-run.sh + // detects from bitcoind at startup and substitutes for the placeholder. + const ckpoolConfTemplate = JSON.stringify( + { + btcd: [ + { + url: rpcAddr, + auth: rpcUser, + pass: rpcPassword, + notify: false, + }, + ], + btcaddress: '@SELFTEST_ADDRESS@', + btcsig: store.coinbaseTag, + blockpoll: 100, + update_interval: 30, + serverurl: [ + `0.0.0.0:${store.stratumPort}`, + `127.0.0.1:${tlsInternalPort}`, + ], + mindiff: store.minDiff, + startdiff: store.startDiff, + maxdiff: store.maxDiff, + dropidle: store.dropIdle, + zmqblock: ckpoolZmqBlock, + logdir: ckpoolLogDir, + }, + null, + 2, + ) + + await mkdir(`${kamadoSub.rootfs}/etc/ckpool`, { recursive: true }) + await writeFile( + `${kamadoSub.rootfs}/etc/ckpool/ckpool.conf.template`, + ckpoolConfTemplate, + ) + + // stunnel.conf is rendered here rather than shipped as a static asset, + // because the accept port is user config now. `connect` stays on ckpool's + // fixed loopback bind so TLS clients keep getting tagged server == 1 (the + // dashboard's lock icon). + if (store.tlsEnabled) { + const stunnelConf = [ + 'foreground = yes', + 'pid =', + 'output = /dev/stdout', + // debug = 5 (notice) so each successful TLS handshake produces a + // "Service [stratum] accepted connection" / "connected from" pair in the + // service logs. Failures (bad cert, alerts, cipher rejection) surface at + // level 3, so both happy- and sad-path events are visible without + // flipping levels per incident. + 'debug = 5', + // Pin a modern TLS floor. Any miner firmware younger than ~2018 speaks + // TLS 1.2, and TLS 1.0/1.1 are deprecated anyway. + 'sslVersion = all', + 'options = NO_SSLv2', + 'options = NO_SSLv3', + 'options = NO_TLSv1', + 'options = NO_TLSv1_1', + '', + '[stratum]', + `accept = 0.0.0.0:${store.stratumTlsPort}`, + `connect = 127.0.0.1:${tlsInternalPort}`, + `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', + '', + ].join('\n') + + await mkdir(`${kamadoSub.rootfs}/etc/stunnel`, { recursive: true }) + await writeFile(`${kamadoSub.rootfs}/etc/stunnel/stratum.conf`, stunnelConf) + } + + /** + * ======================== Daemons ======================== + */ + return sdk.Daemons.of(effects) + .addOneshot('dirs', { + subcontainer: kamadoSub, + exec: { + command: [ + 'mkdir', + '-p', + kamadoDataDir, + tlsDir, + ckpoolLogDir, + ckpoolSocketDir, + ], + }, + requires: [], + }) + .addDaemon('api', { + subcontainer: kamadoSub, + exec: { + command: ['kamado-api'], + env: { + LISTEN_ADDR: `:${uiPort}`, + CKPOOL_SOCKDIR: ckpoolSocketDir, + CKPOOL_LOGFILE: ckpoolLogFile, + DB_PATH: kamadoDbPath, + BITCOIN_RPC_URL: `http://${rpcAddr}`, + BITCOIN_RPC_USER: rpcUser, + BITCOIN_RPC_PASSWORD: rpcPassword, + POLL_INTERVAL: '5s', + KAMADO_LOG_LEVEL: store.logLevel, + // Empty disables kamado-api's ZMQ subscriber (RPC polling fallback + // remains active either way). + BITCOIN_ZMQ_BLOCK: + store.zmqEnabled && bitcoind.zmqBlock + ? `tcp://${bitcoind.zmqBlock}` + : '', + // Empty means "use mempool.space defaults" for dashboard links. + MEMPOOL_BASE_URL: store.mempoolExplorerUrl ?? '', + }, + }, + ready: { + display: i18n('Web Dashboard'), + gracePeriod: 15_000, + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('The Kamado dashboard is reachable'), + errorMessage: i18n('The Kamado dashboard is not reachable'), + }), + }, + requires: ['dirs'], + }) + .addDaemon('ckpool', { + subcontainer: kamadoSub, + exec: { + // Waits until bitcoind answers getblockchaininfo, resolves the + // network-correct self-test address, renders the final ckpool.conf, + // then execs ckpool. When kamado-api kills ckpool on bitcoind + // failure (so miners can fail over), StartOS restarts the daemon and + // the script blocks again until bitcoind recovers — the 0.3.x + // supervised-restart loop, expressed as a daemon. + command: ['kamado-ckpool-run.sh'], + env: { + BITCOIN_RPC_URL: `http://${rpcAddr}`, + BITCOIN_RPC_USER: rpcUser, + BITCOIN_RPC_PASSWORD: rpcPassword, + CKPOOL_SOCKDIR: ckpoolSocketDir, + }, + }, + ready: { + display: i18n('Stratum Server'), + gracePeriod: 30_000, + fn: () => + sdk.healthCheck.checkPortListening(effects, store.stratumPort, { + successMessage: i18n('The stratum server is accepting connections'), + errorMessage: i18n( + 'The stratum server is not accepting connections', + ), + }), + }, + requires: ['dirs'], + }) + .addHealthCheck('bitcoin', { + ready: { + display: i18n('Bitcoin Core RPC'), + fn: async () => { + const h = await curlJson(kamadoSub, healthUrl) + if (!h) + return { + result: 'failure', + message: i18n('Kamado API is unreachable — service may be down'), + } + if (h.bitcoin) + return { + result: 'success', + message: i18n('Connected to Bitcoin Core'), + } + return { + result: 'failure', + message: h.last_error + ? `${i18n('Bitcoin Core RPC is unreachable')} (${h.last_error})` + : i18n('Bitcoin Core RPC is unreachable'), + } + }, + }, + requires: ['api'], + }) + .addHealthCheck('submit-gap', { + ready: { + display: i18n('Block Submission'), + fn: async () => { + const h = await curlJson(kamadoSub, healthUrl) + if (!h) + return { + result: 'failure', + message: i18n('Kamado API is unreachable — service may be down'), + } + const gap = h.submit_gap ?? 0 + if (gap === 0) + return { + result: 'success', + message: i18n('All block submissions confirmed'), + } + return { + result: 'failure', + message: `${gap} ${i18n( + 'block(s) submitted to bitcoind but not confirmed — check Bitcoin Core logs', + )}`, + } + }, + }, + requires: ['api'], + }) + .addHealthCheck('zmq', () => + store.zmqEnabled + ? { + ready: { + display: i18n('ZMQ Block Feed'), + fn: async () => { + const h = await curlJson(kamadoSub, healthUrl) + if (!h) + return { + result: 'failure', + message: i18n( + 'Kamado API is unreachable — service may be down', + ), + } + if (h.zmq_stale) + return { + result: 'failure', + message: i18n( + 'ZMQ block feed is stale — block notifications are falling back to RPC polling', + ), + } + return { + result: 'success', + message: i18n('ZMQ block notifications are flowing'), + } + }, + }, + requires: ['api'], + } + : null, + ) + .addOneshot('tls-cert', () => + store.tlsEnabled + ? { + subcontainer: kamadoSub, + exec: { + // Generates (or migrates) the persisted self-signed stratum + // certificate under /root/.kamado/tls. Idempotent: regenerates + // only when files are missing or the cert-format version marker + // is outdated. + command: ['kamado-tls-init.sh'], + env: { TLS_DIR: tlsDir }, + }, + requires: ['dirs'], + } + : null, + ) + .addDaemon('stunnel', () => + store.tlsEnabled + ? { + subcontainer: kamadoSub, + exec: { + command: ['stunnel4', '/etc/stunnel/stratum.conf'], + }, + ready: { + display: i18n('Stratum TLS'), + fn: () => + sdk.healthCheck.checkPortListening( + effects, + store.stratumTlsPort, + { + successMessage: i18n( + 'TLS stratum is accepting connections', + ), + errorMessage: i18n( + 'TLS stratum is not accepting connections', + ), + }, + ), + }, + requires: ['tls-cert'], + } + : null, + ) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..2c58f28 --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,34 @@ +export const short = { + en_US: 'Modern solo Bitcoin mining pool with real-time dashboard', + es_ES: 'Pool moderno de minería solo de Bitcoin con panel en tiempo real', + de_DE: 'Moderner Solo-Bitcoin-Mining-Pool mit Echtzeit-Dashboard', + pl_PL: 'Nowoczesna solowa kopalnia Bitcoina z panelem czasu rzeczywistego', + fr_FR: + 'Pool de minage solo Bitcoin moderne avec tableau de bord en temps réel', +} + +export const long = { + en_US: + 'Kamado Pool is a solo Bitcoin mining pool built on a patched fork of CKPool-solo. Unlike wrappers that read periodic stats files, Kamado talks directly to CKPool’s Unix socket API to expose real-time per-client hashrate, difficulty, hardware detection, full block history, and best-share leaderboards (current round and all-time) via a Svelte dashboard with WebSocket push. Miners connect to the stratum port with their payout address as username — block rewards go straight to them.', + es_ES: + 'Kamado Pool es un pool de minería solo de Bitcoin basado en un fork parcheado de CKPool-solo. Kamado se comunica directamente con la API de socket Unix de CKPool para exponer en tiempo real el hashrate por cliente, la dificultad, la detección de hardware, el historial completo de bloques y las mejores participaciones (ronda actual e histórica) mediante un panel Svelte con WebSocket. Los mineros se conectan al puerto stratum usando su dirección de pago como usuario.', + de_DE: + 'Kamado Pool ist ein Solo-Bitcoin-Mining-Pool auf Basis eines gepatchten CKPool-solo-Forks. Kamado kommuniziert direkt mit der Unix-Socket-API von CKPool und zeigt in Echtzeit Hashrate pro Client, Schwierigkeit, Hardware-Erkennung, vollständige Blockhistorie und Bestwert-Ranglisten (aktuelle Runde und Allzeit) über ein Svelte-Dashboard mit WebSocket-Push. Miner verbinden sich mit dem Stratum-Port und ihrer Auszahlungsadresse als Benutzername.', + pl_PL: + 'Kamado Pool to solowa kopalnia Bitcoina oparta na załatanym forku CKPool-solo. Kamado komunikuje się bezpośrednio z API gniazda Unix CKPool, udostępniając w czasie rzeczywistym hashrate poszczególnych klientów, trudność, wykrywanie sprzętu, pełną historię bloków oraz rankingi najlepszych udziałów (bieżąca runda i wszech czasów) w panelu Svelte z WebSocket. Górnicy łączą się z portem stratum, podając adres wypłaty jako nazwę użytkownika.', + fr_FR: + 'Kamado Pool est un pool de minage solo Bitcoin basé sur un fork corrigé de CKPool-solo. Kamado communique directement avec l’API socket Unix de CKPool pour exposer en temps réel le hashrate par client, la difficulté, la détection du matériel, l’historique complet des blocs et les classements des meilleures parts (manche en cours et record absolu) via un tableau de bord Svelte avec WebSocket. Les mineurs se connectent au port stratum avec leur adresse de paiement comme nom d’utilisateur.', +} + +export const bitcoindDescription = { + en_US: + 'Used to build block templates, submit found blocks, and receive new block notifications via RPC + ZMQ.', + es_ES: + 'Se usa para construir plantillas de bloques, enviar bloques encontrados y recibir notificaciones de nuevos bloques mediante RPC + ZMQ.', + de_DE: + 'Wird verwendet, um Blockvorlagen zu erstellen, gefundene Blöcke einzureichen und neue Blockbenachrichtigungen über RPC + ZMQ zu erhalten.', + pl_PL: + 'Służy do budowania szablonów bloków, przesyłania znalezionych bloków i odbierania powiadomień o nowych blokach przez RPC + ZMQ.', + fr_FR: + 'Utilisé pour construire les modèles de blocs, soumettre les blocs trouvés et recevoir les notifications de nouveaux blocs via RPC + ZMQ.', +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..7ff740b --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,35 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { bitcoindDescription, long, short } from './i18n' + +export const manifest = setupManifest({ + id: 'kamado-pool', + title: 'Kamado Pool', + license: 'GPL-3.0', + packageRepo: 'https://something.com/satoshi/KamadoPool-StartOS-040', + upstreamRepo: 'https://something.com/satoshi/KamadoPool', + marketingUrl: 'https://something.com/satoshi/KamadoPool', + donationUrl: null, + description: { short, long }, + volumes: ['main', 'ckpool'], + images: { + main: { + source: { + dockerBuild: { + dockerfile: 'Dockerfile', + workdir: '.', + }, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + dependencies: { + bitcoind: { + description: bitcoindDescription, + optional: false, + metadata: { + title: 'Bitcoin Core', + icon: 'https://raw.githubusercontent.com/Start9Labs/bitcoin-core-startos/refs/heads/30.x/dep-icon.svg', + }, + }, + }, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const is used throughout this package codebase. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..9f4ba8c --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,232 @@ +import { T } from '@start9labs/start-sdk' +import { + rpcHostId as btcRpcHostId, + rpcPort as btcRpcPort, + zmqHostId as btcZmqHostId, + zmqPortBlock as btcZmqPortBlock, +} from 'bitcoin-core-startos/startos/utils' +import { i18n } from './i18n' +import { sdk } from './sdk' + +// ── Ports ──────────────────────────────────────────────────────────────────── + +/** + * kamado-api HTTP/WebSocket dashboard. Fixed: the OS reverse-proxies this + * interface, so the browser-facing port is never this number anyway. + */ +export const uiPort = 8080 + +/** + * Stratum port defaults. The live values are user config (see store.json) — + * each one sets both ckpool's/stunnel's in-container bind AND the interface's + * preferred external port, so the number the user picks is the number miners + * connect to whenever the OS can grant it. + */ +export const defaultStratumPort = 3333 +export const defaultStratumTlsPort = 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). Never user-visible, so it stays fixed — but it + * does occupy a port inside the container, hence validatePorts() below. + */ +export const tlsInternalPort = 3437 + +/** + * Ports already taken inside the service container, mapped to what occupies + * them. A user-chosen stratum port may not collide with these. + */ +const occupiedPorts: Record = { + [uiPort]: 'the web dashboard', + [tlsInternalPort]: "ckpool's internal TLS bind", +} + +/** + * Reject stratum port choices that cannot work: the two stratum ports would + * collide with each other, or with a port already bound inside the container. + * Returns a human-readable reason, or null when the pair is usable. + * + * All of these processes share one container (and therefore one network + * namespace), so a collision is a real bind failure at startup — better to + * refuse it in the config action than to restart-loop later. + */ +export function validatePorts(opts: { + stratumPort: number + stratumTlsPort: number + tlsEnabled: boolean +}): string | null { + const { stratumPort, stratumTlsPort, tlsEnabled } = opts + + const clash = occupiedPorts[stratumPort] + if (clash) return `Stratum port ${stratumPort} is already used by ${clash}.` + + if (!tlsEnabled) return null + + const tlsClash = occupiedPorts[stratumTlsPort] + if (tlsClash) + return `Stratum TLS port ${stratumTlsPort} is already used by ${tlsClash}.` + + if (stratumPort === stratumTlsPort) + return `The stratum port and the stratum TLS port must differ (both are ${stratumPort}).` + + return null +} + +// ── Host ids (the `sdk.MultiHost.of` groups) ───────────────────────────────── +export const uiHostId = 'ui' +export const stratumHostId = 'stratum' +export const stratumTlsHostId = 'stratum-tls' + +// ── In-container paths ─────────────────────────────────────────────────────── + +/** main volume mountpoint: SQLite DB (data/kamado.db) and TLS certs (tls/) */ +export const kamadoRoot = '/root/.kamado' +/** ckpool volume mountpoint: ckpool's own state + daily logs (logs/) */ +export const ckpoolRoot = '/root/.ckpool' +/** bitcoind's data dir (read-only dependency mount) — used for .cookie auth */ +export const btcMountpoint = '/mnt/bitcoind' + +export const ckpoolLogDir = `${ckpoolRoot}/logs` +export const ckpoolLogFile = `${ckpoolLogDir}/ckpool.log` +export const ckpoolSocketDir = '/run/ckpool' +export const kamadoDataDir = `${kamadoRoot}/data` +export const kamadoDbPath = `${kamadoDataDir}/kamado.db` +export const tlsDir = `${kamadoRoot}/tls` + +/** Files that make up the persisted stratum TLS certificate (relative to the main volume) */ +export const tlsVolumeFiles = [ + 'tls/stratum.crt', + 'tls/stratum.key', + 'tls/stratum.pem', + 'tls/cert_version', + 'tls/fingerprint.txt', +] + +// ── Misc constants ─────────────────────────────────────────────────────────── + +/** + * CKPool loglevel: 6 = LOG_INFO, required for share-level logging + * (Accepted/Rejected client lines) used by the stats feature. + */ +export const ckpoolLogLevel = '6' + +export const logLevels = { + debug: i18n('Debug'), + info: i18n('Info'), + warn: i18n('Warn'), + error: i18n('Error'), +} + +export type LogLevel = keyof typeof logLevels + +// ── Health payload served by kamado-api at /api/health ────────────────────── +export type HealthPayload = { + ok: boolean + ckpool: boolean + bitcoin: boolean + submit_gap: number + zmq_stale: boolean + last_error?: string +} + +/** Minimal structural type for anything exec-able (SubContainer, temp subcontainer). */ +export type Execable = { + exec(command: string[]): Promise<{ + exitCode: number | null + stdout: string | Buffer + stderr: string | Buffer + }> +} + +/** + * Fetch a URL from *inside* the service's network namespace by exec'ing curl + * in a subcontainer. Daemon and standalone health checks run in the host JS + * runtime, which cannot reach the container's 127.0.0.1 directly. + */ +export async function curlJson( + sub: Execable, + url: string, + opts: { method?: 'GET' | 'POST'; timeoutSeconds?: number } = {}, +): Promise { + const args = ['curl', '-sf', '--max-time', String(opts.timeoutSeconds ?? 10)] + if (opts.method === 'POST') args.push('-X', 'POST') + args.push(url) + const res = await sub.exec(args).catch(() => null) + if (!res || res.exitCode !== 0) return null + try { + return JSON.parse(res.stdout.toString()) as Res + } catch { + return null + } +} + +/** + * Bridge address (`10.0.3.1:`) of a dependency's + * binding, as a minimal reactive value. Chain `.const()` in main: the mapped + * string only changes when the address itself does, so main restarts exactly + * on dependency install/uninstall/port-change and never on dependency + * updates. Chain `.once()` in an action context. Resolves null while the + * dependency is absent. Drop-in for the planned SDK + * `sdk.host.getBridgeAddress` helper. + */ +export function bridgeAddress( + effects: T.Effects, + opts: { packageId: string; hostId: string; internalPort: number }, +): { const(): Promise; once(): Promise } { + const watchable = async () => { + const osIp = await sdk.getOsIp(effects) + return sdk.host.get( + effects, + { packageId: opts.packageId, hostId: opts.hostId }, + (host) => { + const port = host?.bindings[opts.internalPort]?.net.assignedPort + if (port == null) return null + return `${osIp}:${port}` + }, + ) + } + return { + const: async () => (await watchable()).const(), + once: async () => (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()`, + * so main restarts only when an address actually changes: a bitcoind update + * is 0 restarts, bitcoind installed after Kamado is one healing restart, and + * uninstall is one restart. Each resolves null while bitcoind is absent (or, + * for ZMQ, while bitcoind has ZMQ disabled). + */ +export const bitcoindBridge = async (effects: T.Effects) => { + const rpc = await bridgeAddress(effects, { + packageId: 'bitcoind', + hostId: btcRpcHostId, + internalPort: btcRpcPort, + }).const() + const zmqBlock = await bridgeAddress(effects, { + packageId: 'bitcoind', + hostId: btcZmqHostId, + internalPort: btcZmqPortBlock, + }).const() + return { rpc, zmqBlock } +} + +/** + * Parse bitcoind's RPC cookie (`__cookie__:`) into credentials. + * Returns null if the cookie is absent or malformed (e.g. bitcoind has not + * started yet, so the cookie file does not exist). + */ +export function parseCookie( + cookie: string | null | undefined, +): { user: string; password: string } | null { + if (!cookie) return null + const trimmed = cookie.trim() + const i = trimmed.indexOf(':') + if (i <= 0) return null + return { user: trimmed.slice(0, i), password: trimmed.slice(i + 1) } +} diff --git a/startos/versions/current.ts b/startos/versions/current.ts new file mode 100644 index 0000000..9709241 --- /dev/null +++ b/startos/versions/current.ts @@ -0,0 +1,77 @@ +import { IMPOSSIBLE, VersionInfo, YAML } from '@start9labs/start-sdk' +import { readFile, rm } from 'fs/promises' +import { storeJson } from '../fileModels/store.json' +import { defaultStratumPort, defaultStratumTlsPort, LogLevel } from '../utils' + +/** Shape of the 0.3.5.1 wrapper's config.yaml (main volume, start9/config.yaml). */ +type LegacyConfig = { + bitcoind?: { type?: string } + 'stratum-port'?: number + tls?: { enabled?: string; port?: number } + 'zmq-enabled'?: boolean + advanced?: { + 'pool-identifier'?: string + startdiff?: number + mindiff?: number + maxdiff?: number + dropidle?: number + 'log-level'?: LogLevel + 'mempool-explorer'?: { type?: string; url?: string } + } +} + +export const current = VersionInfo.of({ + version: '0.2.0:0', + releaseNotes: { + en_US: + 'StartOS 0.4.0 port. Stratum is now exposed directly on the LAN as a raw TCP interface (no more router forwards or simpleproxy), Bitcoin Core is reached over the internal network bridge with cookie authentication, and settings moved from Config to the Configure action. Existing settings, found-block history, and the stratum TLS certificate are migrated automatically.', + es_ES: + 'Adaptación a StartOS 0.4.0. Stratum ahora se expone directamente en la LAN como interfaz TCP, Bitcoin Core se alcanza a través del puente de red interno con autenticación por cookie, y la configuración se movió a la acción Configurar. Los ajustes existentes, el historial de bloques y el certificado TLS se migran automáticamente.', + de_DE: + 'Portierung auf StartOS 0.4.0. Stratum wird jetzt direkt im LAN als TCP-Schnittstelle bereitgestellt, Bitcoin Core wird über die interne Netzwerk-Bridge mit Cookie-Authentifizierung erreicht, und die Einstellungen sind in die Aktion „Konfigurieren“ umgezogen. Bestehende Einstellungen, Blockhistorie und das TLS-Zertifikat werden automatisch migriert.', + pl_PL: + 'Port na StartOS 0.4.0. Stratum jest teraz udostępniany bezpośrednio w sieci LAN jako interfejs TCP, Bitcoin Core jest osiągany przez wewnętrzny mostek sieciowy z uwierzytelnianiem cookie, a ustawienia przeniesiono do akcji Konfiguruj. Istniejące ustawienia, historia bloków i certyfikat TLS są migrowane automatycznie.', + fr_FR: + 'Portage vers StartOS 0.4.0. Stratum est désormais exposé directement sur le LAN comme interface TCP, Bitcoin Core est atteint via le pont réseau interne avec authentification par cookie, et les réglages ont migré vers l’action Configurer. Les réglages existants, l’historique des blocs et le certificat TLS sont migrés automatiquement.', + }, + migrations: { + up: async ({ effects }) => { + // Migrate from the 0.3.5.1 wrapper: its config.yaml lives on the main + // volume under start9/. The SQLite DB (data/kamado.db), TLS certs + // (tls/) and the ckpool volume carry over untouched — only the config + // format changed. + const configYaml: LegacyConfig | undefined = await readFile( + '/media/startos/volumes/main/start9/config.yaml', + 'utf-8', + ).then(YAML.parse, () => undefined) + + if (configYaml) { + const adv = configYaml.advanced ?? {} + const mempool = adv['mempool-explorer'] + await storeJson.merge(effects, { + // Carried over so miners pointed at the old forwarded port keep + // working: the same number is requested as the interface's + // preferred external port. + stratumPort: configYaml['stratum-port'] ?? defaultStratumPort, + stratumTlsPort: configYaml.tls?.port ?? defaultStratumTlsPort, + coinbaseTag: adv['pool-identifier'] ?? '/Kamado/', + startDiff: adv.startdiff ?? 16384, + minDiff: adv.mindiff ?? 1000, + maxDiff: adv.maxdiff ?? 0, + dropIdle: adv.dropidle ?? 0, + logLevel: adv['log-level'] ?? 'info', + zmqEnabled: configYaml['zmq-enabled'] ?? true, + tlsEnabled: configYaml.tls?.enabled === 'enabled', + mempoolExplorerUrl: + mempool?.type === 'custom' && mempool.url ? mempool.url : null, + }) + + // remove old start9 dir + await rm('/media/startos/volumes/main/start9', { + recursive: true, + }).catch(console.error) + } + }, + down: IMPOSSIBLE, + }, +}) diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..e596b0c --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { current } from './current' + +export const versionGraph = VersionGraph.of({ + current, + other: [], +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f032dc1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@start9labs/start-sdk/tsconfig.base.json", + "include": ["startos/**/*.ts", "node_modules/**/startos"] +}