From bcee8aa273980a5ae84e8ef77e7867fa0fd69166 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 15:27:26 +0200 Subject: [PATCH 1/6] gitignore: graphify-out/ Local knowledge-graph output, built per machine; not part of the project. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4e837b2..170ebb8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ # and a `git add -A` sweeps them into a public repo. .DS_Store **/.DS_Store +# graphify knowledge-graph output (graph.json, graph.html, report, cache). +# Built locally per machine; not part of the project. +/graphify-out/ From 511c88d6e9e7f8f3ef8f8ae527e5f4c5e6c69f61 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 15:27:26 +0200 Subject: [PATCH 2/6] config: refuse an eighth listener line The server has eight port slots and listen_port takes the first, so only seven listener lines can be bound. An eighth loaded cleanly and was then silently dropped: a port the operator configured, advertised and firewalled, with nothing listening on it. It is now a config error naming the limit. --- proxy.conf.example | 1 + src/config.c | 8 +++++--- src/stratum.h | 6 ++++++ tests/test_config.c | 22 ++++++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/proxy.conf.example b/proxy.conf.example index 22d0069..44e5af3 100644 --- a/proxy.conf.example +++ b/proxy.conf.example @@ -66,6 +66,7 @@ vardiff_idle_step = 2 # unlike every other key here, a second `listener` line adds a port rather # than replacing the first. listen_port above is always served too, on the # settings above. +# At most 7 listener lines: listen_port takes the eighth port slot. # # port the port to bind (required) # min_diff vardiff floor for this port, and its starting difficulty diff --git a/src/config.c b/src/config.c index 7dcc571..195d930 100644 --- a/src/config.c +++ b/src/config.c @@ -254,9 +254,11 @@ int proxy_config_load(const char *path, proxy_config_t *cfg, else if (strcmp(k, "listener") == 0) { /* Repeatable, unlike every other key here: each one adds a port * rather than replacing the last. */ - if (cfg->listener_count >= STRATUM_MAX_LISTENERS) { - set_err(errbuf, errlen, "config: line %d: at most %d listeners", - lineno, STRATUM_MAX_LISTENERS); + if (cfg->listener_count >= STRATUM_MAX_EXTRA_LISTENERS) { + set_err(errbuf, errlen, + "config: line %d: at most %d listener lines " + "(listen_port takes the remaining slot)", + lineno, STRATUM_MAX_EXTRA_LISTENERS); fclose(f); return -1; } diff --git a/src/stratum.h b/src/stratum.h index 86f436a..8a83096 100644 --- a/src/stratum.h +++ b/src/stratum.h @@ -170,6 +170,12 @@ typedef struct { } stratum_listener_t; #define STRATUM_MAX_LISTENERS 8 +/* How many `listener` lines a config may add. The server binds listen_port in + * slot 0 and the extra ports after it, all out of STRATUM_MAX_LISTENERS slots, + * so one fewer than that fits. The config used to accept all 8 and the server + * then dropped the last one without a word: a port the operator configured, + * advertised and firewalled, that nothing was listening on. */ +#define STRATUM_MAX_EXTRA_LISTENERS (STRATUM_MAX_LISTENERS - 1) typedef struct { char bind_addr[64]; diff --git a/tests/test_config.c b/tests/test_config.c index a4469dc..9a4eb88 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -390,6 +390,27 @@ static void test_a_listener_without_a_port_is_refused(void) { CHECK(load_text(body, &cfg, err, sizeof err) != 0); } +/* listen_port holds one of the server's STRATUM_MAX_LISTENERS slots, so only + * STRATUM_MAX_EXTRA_LISTENERS `listener` lines can actually be bound. One more + * used to load cleanly and then be silently dropped by the server. */ +static void test_listeners_beyond_the_bindable_count_are_refused(void) { + char body[2048]; + size_t n = (size_t)snprintf(body, sizeof body, "operator_address = %s\n", VALID_ADDR); + for (int i = 0; i < STRATUM_MAX_EXTRA_LISTENERS; ++i) + n += (size_t)snprintf(body + n, sizeof body - n, + "listener = port=%d label=p%d\n", 3335 + i, i); + + proxy_config_t ok; char err[256] = {0}; + CHECK(load_text(body, &ok, err, sizeof err) == 0); + CHECK(ok.listener_count == STRATUM_MAX_EXTRA_LISTENERS); + + snprintf(body + n, sizeof body - n, "listener = port=%d label=over\n", + 3335 + STRATUM_MAX_EXTRA_LISTENERS); + proxy_config_t over; char err2[256] = {0}; + CHECK(load_text(body, &over, err2, sizeof err2) != 0); + CHECK(strstr(err2, "at most") != NULL); +} + /* ---- log level ----------------------------------------------------------- */ static void test_log_level_accepts_names_and_numbers(void) { @@ -488,6 +509,7 @@ int main(void) { test_a_nonsense_log_level_warns_and_keeps_the_default(); test_log_level_accepts_names_and_numbers(); test_a_listener_without_a_port_is_refused(); + test_listeners_beyond_the_bindable_count_are_refused(); test_a_listener_field_that_is_not_key_value_is_refused(); test_several_listeners_keep_their_own_policies(); test_a_listener_line_becomes_a_port_policy(); From 712940d240908102e2bb8766fe258c30a37b242f Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 15:27:26 +0200 Subject: [PATCH 3/6] pplns: don't deduct a fee the coinbase never paid When fee_bps of a block came to less than the 546-sat dust limit, the coinbase dropped the operator output and the pool wallet received the whole reward, but store_pplns_distribute still took fee_bps off before crediting miners. The difference sat in the pool wallet credited to nobody. The distributor now applies the coinbase's dust rule. Affects pplns-thunder and pplns-btc blocks under ~546*10000/fee_bps sats (54,600 at 1%). stratum.c's copy of the rule now uses COINBASE_DUST_SATS instead of a literal. --- src/store.c | 12 +++++-- src/stratum.c | 2 +- tests/test_store.c | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/store.c b/src/store.c index ccb7cd0..07a0c8d 100644 --- a/src/store.c +++ b/src/store.c @@ -10,6 +10,7 @@ #include "store.h" #include "log.h" +#include "coinbase.h" /* COINBASE_DUST_SATS */ #include /* INT64_MAX */ @@ -1432,10 +1433,17 @@ int store_pplns_distribute(store_t *s, int maturity_confs, int fee_bps, /* Net of the operator fee, the same basis points solo and PPS use. * On PPLNS the fee is normally set lower: there is no variance being - * absorbed, so there is no risk premium to charge for. */ + * absorbed, so there is no risk premium to charge for. + * + * The dust rule is the coinbase's, and has to be: a fee below + * COINBASE_DUST_SATS was never paid out -- the builder drops that + * output and the pool wallet receives the whole block. Deducting it + * here anyway credited miners less than the wallet actually holds for + * them, and the difference sat there owed to nobody. */ int64_t payable = gross; if (fee_bps > 0 && fee_bps <= 10000) { - payable = gross - (gross * (int64_t)fee_bps) / 10000; + int64_t fee = (gross * (int64_t)fee_bps) / 10000; + if (fee >= COINBASE_DUST_SATS) payable = gross - fee; } if (payable <= 0) { /* Nothing to share out, but the block is still settled: leaving diff --git a/src/stratum.c b/src/stratum.c index bd80319..09aac70 100644 --- a/src/stratum.c +++ b/src/stratum.c @@ -2520,7 +2520,7 @@ static int submit_with_job(stratum_server_t *s, stratum_conn_t *c, cJSON *id, int64_t fee_sats = 0; if (s->cfg.fee_bps > 0 && s->cfg.operator_address[0]) { fee_sats = (job->value_sats * (int64_t)s->cfg.fee_bps) / 10000; - if (fee_sats < 546) fee_sats = 0; /* matches coinbase dust rule */ + if (fee_sats < COINBASE_DUST_SATS) fee_sats = 0; /* the coinbase dust rule */ } int64_t reward_sats = job->value_sats - fee_sats; s->cfg.on_block_found(s->cfg.ctx, c->worker_name, diff --git a/tests/test_store.c b/tests/test_store.c index 170448a..7fcafaf 100644 --- a/tests/test_store.c +++ b/tests/test_store.c @@ -1226,6 +1226,86 @@ static void test_pplns_distributes_the_window(void) { printf(" ok test_pplns_distributes_the_window\n"); } +/* A fee below the dust limit is not deducted from what miners are credited. + * + * The coinbase drops an operator output worth less than COINBASE_DUST_SATS, so + * on a small block the pool wallet receives the whole reward. The distributor + * has to share out that whole reward: taking fee_bps off it anyway leaves sats + * in the pool wallet that no miner is credited for. 50000 sats at 1% is a + * 500-sat fee, under 546, so nothing comes off. */ +static void test_pplns_does_not_deduct_a_dust_fee(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 500; + + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + for (int i = 0; i < 10; ++i) { + assert(store_record_share_addr(s, "alice", "addr_a", + 2000ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + assert(store_record_share_addr(s, "bob", "addr_b", + 2100ULL + (uint64_t)i, 5.0, + 0, NULL, 0, 0.0) == 0); + } + assert(store_record_share_addr(s, "alice", "addr_a", 3000, 0.0, + 1, "blk_dustfee", 0, 0.0) == 0); + /* Recorded as the coinbase paid it: the whole 50000 to the pool, no fee. */ + assert(store_record_block(s, 3000, 800200, "blk_dustfee", "alice", "addr_a", + 50000, 0, STORE_BLOCK_PENDING, NULL, 100.0) == 0); + assert(store_flush(s) == 0); + assert(store_set_block_status(s, "blk_dustfee", STORE_BLOCK_CONFIRMED, + 100, "node") == 0); + + int blocks = 0, workers = 0; + assert(store_pplns_distribute(s, 100, 100, &blocks, &workers, NULL, 0) == 1); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(scalar_i64(db, "SELECT SUM(accrued_sats) FROM pps_credits") == 50000); + assert(scalar_i64(db, "SELECT accrued_sats FROM pps_credits WHERE worker_id =" + " (SELECT id FROM workers WHERE name='alice')") == 25000); + sqlite3_close(db); + store_close(s); + printf(" ok test_pplns_does_not_deduct_a_dust_fee\n"); +} + +/* A fee at or above the dust limit still comes off the top. 1,000,000 sats at + * 1% is 10000 sats of fee, leaving 990000 to share. */ +static void test_pplns_deducts_a_real_fee(void) { + const char *path = fresh_db_path(); + store_cfg_t cfg = {0}; + snprintf(cfg.path, sizeof(cfg.path), "%s", path); + cfg.commit_window_ms = 20; + cfg.commit_max_shares = 500; + + store_t *s = NULL; + assert(store_open(&cfg, &s) == 0); + for (int i = 0; i < 10; ++i) + assert(store_record_share_addr(s, "alice", "addr_a", + 2000ULL + (uint64_t)i, 10.0, + 0, NULL, 0, 0.0) == 0); + assert(store_record_share_addr(s, "alice", "addr_a", 3000, 0.0, + 1, "blk_realfee", 0, 0.0) == 0); + assert(store_record_block(s, 3000, 800201, "blk_realfee", "alice", "addr_a", + 990000, 10000, STORE_BLOCK_PENDING, NULL, 100.0) == 0); + assert(store_flush(s) == 0); + assert(store_set_block_status(s, "blk_realfee", STORE_BLOCK_CONFIRMED, + 100, "node") == 0); + + int blocks = 0, workers = 0; + assert(store_pplns_distribute(s, 100, 100, &blocks, &workers, NULL, 0) == 1); + + sqlite3 *db = NULL; + assert(sqlite3_open(path, &db) == SQLITE_OK); + assert(scalar_i64(db, "SELECT SUM(accrued_sats) FROM pps_credits") == 990000); + sqlite3_close(db); + store_close(s); + printf(" ok test_pplns_deducts_a_real_fee\n"); +} + /* Two matured blocks settled by a single pass. * * Every other pplns test distributes exactly one block per call, which never @@ -1980,6 +2060,8 @@ int main(void) { test_block_hash_index_after_dedupe(); test_open_upgrades_a_pre_status_database(); test_pplns_distributes_the_window(); + test_pplns_does_not_deduct_a_dust_fee(); + test_pplns_deducts_a_real_fee(); test_pplns_takes_the_operator_fee(); test_the_payout_floor_is_published_for_the_dashboard(); test_the_window_reads_past_the_first_batch(); From f03b03a978dc6bcffe306337ee63d17d0ce15e21 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 15:27:26 +0200 Subject: [PATCH 4/6] schema.sql: document the runtime blocks_found hash index blocks_found_hash_idx is created by the proxy at startup, after collapsing duplicate rows, and cannot live here: install.sh applies this file under set -e on every upgrade, where a UNIQUE index over an old database with duplicates would abort the upgrade half-done, and this file must not delete rows from a possibly live shares.db. Also fix the events_lost comment, which called a counter unix seconds. --- schema.sql | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/schema.sql b/schema.sql index 720830c..9f14aa7 100644 --- a/schema.sql +++ b/schema.sql @@ -96,6 +96,14 @@ CREATE TABLE IF NOT EXISTS blocks_found ( CREATE INDEX IF NOT EXISTS blocks_found_ts_idx ON blocks_found(ts); CREATE INDEX IF NOT EXISTS blocks_found_status_idx ON blocks_found(status); CREATE INDEX IF NOT EXISTS blocks_found_pplns_idx ON blocks_found(pplns_distributed, status); +/* blocks_found also carries a UNIQUE index on hash, blocks_found_hash_idx, + * which is deliberately NOT created here. Databases written before it existed + * can hold the same hash twice, and a CREATE UNIQUE INDEX over them fails -- + * which, since install.sh applies this file under `set -e` on every upgrade, + * would abort the upgrade half-done. The proxy creates it at startup instead + * (store_finalize_block_hash_index), after collapsing any duplicates onto the + * earliest row and carrying their verdict with it. Deleting rows is not + * something this file may do: it is also run against a live shares.db. */ /* Single-row mirror of the upstream bitcoind tip the proxy is currently * mining on. Written by the proxy's tip watcher on every successful @@ -161,7 +169,7 @@ CREATE TABLE IF NOT EXISTS pool_meta ( /* Mirror of the proxy's in-memory events_lost counter: accepted shares that * never reached the DB after every commit retry failed. Must be 0 — it is * work a miner was told was accepted and that no query can otherwise see. */ - events_lost INTEGER NOT NULL DEFAULT 0, /* unix seconds */ + events_lost INTEGER NOT NULL DEFAULT 0, /* count of events */ /* The stratum ports this pool listens on and the difficulty policy of * each, as a JSON array of {port, label, min_diff, initial_diff}. Written * at startup with the rest of the identity. A miner cannot tell from the From 961ee9823d9c4b3fbc0dac692bd608c899a08c2e Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 15:27:26 +0200 Subject: [PATCH 5/6] dashboard: listen on loopback by default (DASHBOARD_BIND) The dashboard bound every interface while the installer and docs said it was loopback behind nginx. /admin is HTTP Basic auth, which must not be reachable in the clear. DASHBOARD_BIND now defaults to 127.0.0.1, like the payout and slipstream services. install.sh sets loopback when nginx fronts it and 0.0.0.0 with --no-nginx; the Docker image sets 0.0.0.0 inside the container. A server reached directly on :8081 needs DASHBOARD_BIND=0.0.0.0 in its drop-in before upgrading. install.sh also stops claiming --pps-sats-per-diff defaults to 1000 (it defaults to unset, derived per template) and warns when it is passed. --- INSTALL.md | 7 +++++-- OPERATOR_GUIDE.md | 6 ++++++ dashboard/README.md | 3 ++- dashboard/server.js | 9 ++++++-- deploy/docker/Dockerfile.dashboard | 2 ++ deploy/docker/docker-compose.yml | 1 + deploy/systemd/simplepool-dashboard.service | 3 +++ scripts/install.sh | 23 ++++++++++++++++++--- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index cc6ef35..e1cb1c3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -656,8 +656,11 @@ scripted one-liner in [OPERATOR_GUIDE.md](OPERATOR_GUIDE.md#rotating-the-admin-p ### Reverse proxy (recommended) -The dashboard binds `0.0.0.0:8081` — reachable directly. In -production you probably want nginx / caddy in front of it. Solo +The dashboard binds `127.0.0.1:8081` by default (`DASHBOARD_BIND`), so it +is reachable only through a reverse proxy on the same host. To serve it +directly, set `Environment=DASHBOARD_BIND=0.0.0.0` in its drop-in — and then +put TLS in front of `/admin` some other way. In production you want nginx / +caddy in front of it. Solo `deploy/nginx/simplepool.conf` has a working template. Do NOT expose `/admin` on plain HTTP over the internet without at diff --git a/OPERATOR_GUIDE.md b/OPERATOR_GUIDE.md index f6501fe..bf151d7 100644 --- a/OPERATOR_GUIDE.md +++ b/OPERATOR_GUIDE.md @@ -45,6 +45,12 @@ tracked by git. | SSH | `root@` | `` | | Everything from the shell | `simplepoolctl status` / `doctor` / `logs -f` | root for `restart`, `upgrade`, `uninstall` | +The `:8081` URLs assume the dashboard listens publicly. Since it defaults to +loopback (`DASHBOARD_BIND=127.0.0.1`), that needs +`Environment=DASHBOARD_BIND=0.0.0.0` in +`/etc/systemd/system/simplepool-dashboard.service.d/local.conf`; behind nginx, +use `https:///` instead and leave it on loopback. + The admin password is stashed at `/root/simplepool-admin-cred.txt` on the box (root-only). To rotate, edit `/etc/systemd/system/simplepool-dashboard.service.d/pps-thunder.conf` diff --git a/dashboard/README.md b/dashboard/README.md index 8ba28fe..bdccd64 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -43,7 +43,8 @@ npm start # production npm run dev # auto-restart on file change ``` -Defaults: `PORT=8081`, `PROXY_DB_PATH=../data/shares.snapshot.db`. +Defaults: `PORT=8081`, `DASHBOARD_BIND=127.0.0.1` (set `0.0.0.0` to serve it +without a reverse proxy), `PROXY_DB_PATH=../data/shares.snapshot.db`. If the snapshot file doesn't exist yet, the dashboard starts anyway and displays "no data yet" until the first `.backup` produces it. You can also diff --git a/dashboard/server.js b/dashboard/server.js index f216cb1..dfc1d63 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -22,6 +22,11 @@ import { createAdminRouter } from './lib/admin-router.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || '8081', 10); +// Loopback by default, like the payout and slipstream services: the +// dashboard is meant to be published through nginx, and /admin speaks HTTP +// Basic auth, which must not be reachable in the clear on a public +// interface. Set DASHBOARD_BIND=0.0.0.0 (or ::) to serve it directly. +const BIND = process.env.DASHBOARD_BIND || '127.0.0.1'; const DB_PATH = process.env.PROXY_DB_PATH || '../data/shares.db'; const app = express(); @@ -296,6 +301,6 @@ app.use('/admin', app.use((_req, res) => res.status(404).render('404', { what: 'page' })); -app.listen(PORT, () => { - console.log(`simplepool dashboard on :${PORT} (db: ${db.path})`); +app.listen(PORT, BIND, () => { + console.log(`simplepool dashboard on ${BIND}:${PORT} (db: ${db.path})`); }); diff --git a/deploy/docker/Dockerfile.dashboard b/deploy/docker/Dockerfile.dashboard index f3ac658..d94e793 100644 --- a/deploy/docker/Dockerfile.dashboard +++ b/deploy/docker/Dockerfile.dashboard @@ -23,5 +23,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY dashboard/ ./ USER node +# Inside a container loopback is unreachable from the published port. +ENV DASHBOARD_BIND=0.0.0.0 EXPOSE 8081 ENTRYPOINT ["/usr/bin/tini", "--", "node", "server.js"] diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 7c2fb47..478f18c 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -50,6 +50,7 @@ services: - "${DASHBOARD_PORT:-8081}:8081" environment: PORT: "8081" + DASHBOARD_BIND: 0.0.0.0 PROXY_DB_PATH: /data/shares.db PUBLIC_STRATUM_URL: ${PUBLIC_STRATUM_URL:-stratum+tcp://:3334} THUNDER_RPC_URL: ${THUNDER_RPC_URL:-http://host.docker.internal:6009} diff --git a/deploy/systemd/simplepool-dashboard.service b/deploy/systemd/simplepool-dashboard.service index aa5da18..3a6876c 100644 --- a/deploy/systemd/simplepool-dashboard.service +++ b/deploy/systemd/simplepool-dashboard.service @@ -10,6 +10,9 @@ User=@USER@ Group=@USER@ WorkingDirectory=@ROOT@/dashboard Environment=PORT=8081 +# Loopback: nginx publishes the dashboard. Set 0.0.0.0 only to serve it +# directly, and then only behind a firewall or TLS -- /admin uses Basic auth. +Environment=DASHBOARD_BIND=127.0.0.1 Environment=PROXY_DB_PATH=@ROOT@/data/shares.db # Rendered on the public "Connect a miner" card. Set to the actual # host miners should point their ASIC at. Leave unset to show diff --git a/scripts/install.sh b/scripts/install.sh index 82a5e38..2819c96 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -55,10 +55,14 @@ # --thunder-address pps-classic reserve address (dashboard # deposits + payout worker source) # --thunder-rpc-url default http://127.0.0.1:6009 -# --pps-sats-per-diff default 1000 +# --pps-sats-per-diff default unset = derived per template from +# coinbasevalue, difficulty and fee_bps. +# Leave it unset: a fixed rate goes stale +# and bypasses fee_bps. # --payout-interval-hours how often payouts run (default 24) # --hostname dashboard domain (nginx vhost + TLS) -# --dashboard-port default 8081 (loopback; nginx fronts it) +# --dashboard-port default 8081 (loopback behind nginx; all +# interfaces with --no-nginx) # --admin-user default admin # --admin-password default: generated and printed once # --tls --email run certbot --nginx after the vhost lands @@ -897,6 +901,7 @@ case "$MODE" in pps-classic) conf_set "$TMP_CONF" pool_btc_address "$POOL_BTC_ADDRESS" if [[ -n "$PPS_SATS_PER_DIFF" ]]; then + warn "pps_sats_per_diff = $PPS_SATS_PER_DIFF pins the PPS rate: it is taken as already net of fee, so fee_bps no longer applies, and it will not follow difficulty. Leave it unset to derive the rate per template." conf_set "$TMP_CONF" pps_sats_per_diff "$PPS_SATS_PER_DIFF" else conf_unset "$TMP_CONF" pps_sats_per_diff @@ -962,6 +967,14 @@ if [[ "$DO_DASH" == "1" ]]; then echo "# Generated by scripts/install.sh — edit here, not in the unit." echo "[Service]" echo "Environment=PORT=${DASH_PORT}" + # Behind nginx it listens on loopback only. Without nginx it is the + # thing miners and the operator reach directly, so it must listen on + # every interface. + if [[ "$DO_NGINX" == "1" ]]; then + echo "Environment=DASHBOARD_BIND=127.0.0.1" + else + echo "Environment=DASHBOARD_BIND=0.0.0.0" + fi echo "Environment=PROXY_DB_PATH=${DB_PATH}" echo "Environment=PUBLIC_STRATUM_URL=${PUBLIC_STRATUM_URL}" if [[ "$ADMIN_CRED_FILE_SUPPORTED" == "1" ]]; then @@ -1083,7 +1096,11 @@ if [[ "$DO_UFW" == "1" ]]; then _extra="" (( ${#LISTENER_PORTS[@]} )) && _extra="$(printf ', %s' "${LISTENER_PORTS[@]}")" say "$(ufw status | head -1) (allowed: OpenSSH, ${STRATUM_PORT}${_extra}$([[ $DO_NGINX == 1 ]] && echo ', 80, 443'))" - say "${DASH_PORT}/tcp deliberately NOT opened — nginx fronts the dashboard" + if [[ "$DO_NGINX" == "1" ]]; then + say "${DASH_PORT}/tcp deliberately NOT opened — nginx fronts the dashboard" + else + say "${DASH_PORT}/tcp NOT opened — allow it yourself to reach the dashboard directly" + fi fi # ========================== 12. simplepoolctl =============================== From acedff2e7ea023dcb49f91a3ed76b4564a8d9622 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 29 Sep 2026 15:27:27 +0200 Subject: [PATCH 6/6] docs: make simplepool.html a full reference Adds every proxy.conf key and service environment variable with defaults and validation, the stratum protocol as implemented, the coinbase layout, ports, HTTP APIs and Redis channels, the full schema, hard limits, and build/release/tests, all taken from source. Fixes claims that had gone stale: extranonce1 is no longer XORed with the clock, the stratum password is read for d=, there are five modes not two, and the page no longer scrolls sideways on a phone. CHANGELOG gains an Unreleased section. --- CHANGELOG.md | 37 ++ docs/simplepool.html | 1169 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 1122 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db4d835..5cfad6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,43 @@ Anything that changes what a miner is paid, or what an operator has to tell their miners, is called out explicitly — those are the changes that cost somebody money if they go unread. +## Unreleased + +### Operators: the dashboard now listens on loopback by default + +`dashboard/server.js` binds `DASHBOARD_BIND`, default `127.0.0.1`, where it +used to bind every interface. Behind nginx nothing changes. **If you reach the +dashboard directly on `:8081`, add `Environment=DASHBOARD_BIND=0.0.0.0` to its +systemd drop-in before upgrading**, or it stops answering there. `install.sh` +sets it for you: loopback with nginx, all interfaces with `--no-nginx`. The +Docker image sets `0.0.0.0` inside the container. + +### Miners on pplns-thunder / pplns-btc: small blocks are no longer short-changed + +When a block's operator fee came to less than the 546-sat dust limit, the +coinbase (correctly) paid no fee output and the pool wallet received the whole +reward — but the distributor still took `fee_bps` off before crediting miners, +so the difference sat in the pool wallet credited to nobody. The distributor +now applies the coinbase's dust rule. Only blocks worth less than roughly +`546 × 10000 / fee_bps` sats are affected (54,600 sats at 1%). + +### Config: an eighth `listener` line is refused + +The server has room for `listen_port` plus seven extra ports. An eighth +`listener` used to load cleanly and then silently not be bound; it is now a +config error naming the limit. + +### Smaller fixes + +- `install.sh --help` no longer claims `--pps-sats-per-diff` defaults to 1000 + — it defaults to unset (derived per template) — and the installer warns if + you pass it. +- `schema.sql` documents the unique index on `blocks_found(hash)` that the + proxy creates at startup, and why it is not created there. +- `docs/simplepool.html` is now a full reference: every config key and + environment variable, the stratum protocol, the coinbase layout, every API, + the schema and the hard limits. + ## 0.4.0 — three PPLNS modes, and coinbase-direct payouts The headline is that a pool no longer has to hold miners' money to run PPLNS. diff --git a/docs/simplepool.html b/docs/simplepool.html index 0dd9cae..f17a332 100644 --- a/docs/simplepool.html +++ b/docs/simplepool.html @@ -4,7 +4,7 @@ simplepool — how it works - +