Files
bitcoin-ckpool-appliance/installer
2025-12-29 12:49:40 -05:00

3068 lines
93 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
#
# Bitcoin Node for Solo Miners Appliance Installer
# An Open Source Linux Appliance from Robbie Ferguson
# (c) 2025 Robbie Ferguson
# Version 1.0.2
set -e
BTC_VER="30.0"
APP_ROOT="/opt/btc-solo"
WWW_ROOT="/var/www/btc-solo"
BITCOIN_CONF="/etc/bitcoin/bitcoin.conf"
CERT_DIR="/etc/ssl/localcerts"
CKPOOL_USER="ckpool"
CKPOOL_PORT="3333"
BITCOIN_DATA="/var/lib/bitcoind"
BTC_RPC_USER="user"
BTC_RPC_PASS="pass"
PRUNE_SIZE="550"
# ---------------------------------------------------------------------------
# PURGE
# ---------------------------------------------------------------------------
if [[ "$1" == "--purge" ]]; then
systemctl stop ckpool 2>/dev/null || true
systemctl stop bitcoind 2>/dev/null || true
rm -f /var/lib/btc-solo/.setup-complete
rm -f /etc/systemd/system/ckpool.service
rm -f /etc/systemd/system/bitcoind.service
rm -f /etc/nginx/sites-enabled/btc-solo
rm -f /etc/nginx/sites-available/btc-solo
rm -f /etc/sudoers.d/btc-solo
rm -rf "$APP_ROOT" "$WWW_ROOT" "$CERT_DIR" "$BITCOIN_DATA"
rm -f /usr/local/sbin/btc-apply-admin.sh /usr/local/sbin/btc-set-prune.sh /usr/local/sbin/btc-make-cert.sh
# Remove bitcoin binaries we installed directly
rm -f /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli /usr/local/bin/bitcoin-tx /usr/local/bin/bitcoin-util 2>/dev/null || true
systemctl daemon-reload
systemctl restart nginx 2>/dev/null || true
echo "Bitcoin Solo Miner Appliance purged."
exit 0
fi
if [[ $EUID -ne 0 ]]; then
echo "Run as root."
exit 1
fi
echo "[***] Updating apt…"
apt-get update
echo "[***] Installing base packages…"
apt-get install -y \
curl ca-certificates gnupg \
nginx php-fpm php-cli \
git build-essential autoconf automake libtool pkg-config \
libjansson-dev libevent-dev libcurl4-openssl-dev libssl-dev zlib1g-dev \
openssl jq bc sqlite3 php-sqlite3
# ---------------------------------------------------------------------------
# Install Bitcoin Core from official binaries
# ---------------------------------------------------------------------------
echo "[***] Installing official Bitcoin Core binaries…"
# ===== Detect CPU arch and install correct Bitcoin Core binary =====
detect_arch() {
# Prefer dpkg (Debian/Ubuntu), fall back to uname
local deb_arch
deb_arch=$(dpkg --print-architecture 2>/dev/null || true)
case "$deb_arch" in
amd64) echo "x86_64-linux-gnu"; return ;;
arm64) echo "aarch64-linux-gnu"; return ;;
armhf) echo "arm-linux-gnueabihf"; return ;;
esac
# Fallback by kernel arch
local u
u=$(uname -m)
case "$u" in
x86_64) echo "x86_64-linux-gnu" ;;
aarch64) echo "aarch64-linux-gnu" ;;
armv7l|armv6l)
echo "arm-linux-gnueabihf" ;;
*)
echo "unsupported"
;;
esac
}
BTC_PLAT="$(detect_arch)"
if [ "$BTC_PLAT" = "unsupported" ]; then
echo "ERROR: Unsupported CPU architecture ($(uname -m))."
echo "Tip: For exotic arches, build from source using depends/ then install bitcoind/bitcoin-cli."
exit 1
fi
BASE_URL="https://bitcoincore.org/bin/bitcoin-core-$BTC_VER"
TARBALL="bitcoin-$BTC_VER-$BTC_PLAT.tar.gz"
TMPD="$(mktemp -d)"
echo "[Bitcoin Core] Detected platform: $BTC_PLAT"
echo "[Bitcoin Core] Downloading $TARBALL"
curl -fsSL "$BASE_URL/$TARBALL" -o "$TMPD/$TARBALL"
# Get SHA256SUMS (and try to verify signature if gpg is present)
curl -fsSL "$BASE_URL/SHA256SUMS" -o "$TMPD/SHA256SUMS"
if command -v gpg >/dev/null 2>&1; then
curl -fsSL "$BASE_URL/SHA256SUMS.asc" -o "$TMPD/SHA256SUMS.asc" || true
# If youve already imported the Bitcoin Core release keys, this will verify.
# If not, we still proceed after SHA256 check below.
gpg --verify "$TMPD/SHA256SUMS.asc" "$TMPD/SHA256SUMS" || echo "[WARN] Could not verify SHA256SUMS signature (no keys?). Continuing with SHA256 check."
fi
echo "[Bitcoin Core] Verifying tarball checksum …"
( cd "$TMPD" && grep " $TARBALL" SHA256SUMS | sha256sum -c - )
echo "[Bitcoin Core] Installing to /usr/local/bin …"
tar -xzf "$TMPD/$TARBALL" -C "$TMPD"
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoind" /usr/local/bin/
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoin-cli" /usr/local/bin/
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoin-tx" /usr/local/bin/
install -m 0755 "$TMPD/bitcoin-$BTC_VER/bin/bitcoin-wallet" /usr/local/bin/
rm -rf "$TMPD"
echo "[Bitcoin Core] Installed bitcoind/bitcoin-cli for $BTC_PLAT"
# systemd service for bitcoind
cat > /etc/systemd/system/bitcoind.service <<EOF
[Unit]
Description=Bitcoin daemon
After=network.target
[Service]
ExecStart=/usr/local/bin/bitcoind -conf=/etc/bitcoin/bitcoin.conf -datadir=${BITCOIN_DATA}
ExecStop=/usr/local/bin/bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf stop
User=root
Group=root
Type=simple
Restart=on-failure
RuntimeDirectory=bitcoind
[Install]
WantedBy=multi-user.target
EOF
mkdir -p /etc/bitcoin
mkdir -p "${BITCOIN_DATA}"
# Bitcoin Core Configuration
cat > "$BITCOIN_CONF" <<EOF
server=1
txindex=0
rpcallowip=127.0.0.1
rpcbind=127.0.0.1
listen=1
daemon=0
prune=${PRUNE_SIZE}
rpcuser=${BTC_RPC_USER}
rpcpassword=${BTC_RPC_PASS}
rpcbind=127.0.0.1
maxconnections=250 # Allow up to 250 connections to speed up sync
maxuploadtarget=0 # Unlimited upload speed
dbcache=2048 # 2 GB RAM for sync
maxmempool=300 # Use less RAM post-sync
EOF
systemctl daemon-reload
systemctl enable bitcoind
systemctl restart bitcoind
echo "[***] bitcoind started (pruned=550 MB)."
# ---------------------------------------------------------------------------
# Fetch/build ckpool (solo) - official Bitbucket source
# ---------------------------------------------------------------------------
echo "[***] Fetching ckpool from Bitbucket…"
mkdir -p "$APP_ROOT"
cd "$APP_ROOT"
if [[ -e ckpool ]]; then
rm -rf ckpool
fi
echo "[***] Building ckpool..."
# Obtain the current source code
git clone https://bitbucket.org/ckolivas/ckpool.git ckpool
chown -R $service_user:$service_user ckpool
cd ckpool
# This simply sets the donation address to allow you to donate to Robbie Ferguson if you win
# This optional donation (Default 2% of block reward) can be adjusted or turned off in the Settings screen
btc_address="1MoGAsK8bRFKjHrpnpFJyqxdqamSPH19dP"
find . -type f \( -name '*.c' -o -name '*.h' -o -name '*.cpp' \) -exec \
sed -i -E \
"s#(ckp\.donaddress\s*=\s*\")[^\"]*(\";)#\1$btc_address\2#g;
s#(ckp\.tndonaddress\s*=\s*\")[^\"]*(\";)#\1$btc_address\2#g;
s#(ckp\.rtdonaddress\s*=\s*\")[^\"]*(\";)#\1$btc_address\2#g" {} +
# Build and install CKPool
./autogen.sh
./configure
make
make install
# ensure the binary exists and is executable
if [[ ! -x "/opt/btc-solo/ckpool/src/ckpool" ]]; then
echo "ERROR: ckpool did not build correctly; /opt/btc-solo/ckpool/ckpool not found or not executable."
exit 1
fi
id -u "$CKPOOL_USER" >/dev/null 2>&1 || useradd -r -s /bin/false "$CKPOOL_USER"
usermod -aG $CKPOOL_USER www-data
# try to restart the running PHP-FPM service so www-data picks up new group
php_fpm_service=""
# 1) ask systemd what php-fpm services exist
php_fpm_service=$(systemctl list-units --type=service --all \
| awk '/php.*fpm\.service/ {print $1; exit}')
if [ -n "$php_fpm_service" ]; then
systemctl restart "$php_fpm_service"
fi
mkdir -p "$APP_ROOT/conf" "$APP_ROOT/logs"
cat > "$APP_ROOT/conf/ckpool.conf" <<EOF
{
"logdir" : "${APP_ROOT}/logs",
"stratum" : {
"port" : ${CKPOOL_PORT},
"bind" : "0.0.0.0"
},
"solomining" : true,
"donrate" : 2.0,
"btcsig" : "/Mined by a Baldnerd Solo Miner/",
"note": "Below settings allow low-hashrate miners. Disable if Public Pool.",
"mindiff": 1,
"startdiff": 1,
"idle_timeout": 300
}
EOF
chown -R "$CKPOOL_USER":"$CKPOOL_USER" "$APP_ROOT"
# make ckpmsg available to PHP
ln -sf ${APP_ROOT}/${CKPOOL_USER}/src/ckpmsg /usr/local/bin/ckpmsg
cat > /etc/systemd/system/ckpool.service <<EOF
[Unit]
Description=ckpool solo mining server
After=network.target bitcoind.service
Wants=bitcoind.service
[Service]
User=${CKPOOL_USER}
Group=${CKPOOL_USER}
RuntimeDirectory=${CKPOOL_USER}
RuntimeDirectoryMode=0770
WorkingDirectory=${APP_ROOT}/${CKPOOL_USER}
ExecStart=${APP_ROOT}/${CKPOOL_USER}/src/ckpool -c ${APP_ROOT}/conf/ckpool.conf -B -s /run/${CKPOOL_USER}
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable ckpool
systemctl restart ckpool || true
echo "[***] ckpool service installed."
# ---------------------------------------------------------------------------
echo "[***] Helper scripts (sudo targets)"
# ---------------------------------------------------------------------------
# apply admin creds
cat > /usr/local/sbin/btc-apply-admin.sh <<'EOF'
#!/bin/bash
CONF="/etc/bitcoin/bitcoin.conf"
CKPOOL_CONF="/opt/btc-solo/conf/ckpool.conf"
ADMINUSER="$1"
ADMINPASS="$2"
if [[ -z "$ADMINUSER" || -z "$ADMINPASS" ]]; then
echo "Usage: $0 <adminuser> <adminpass>"
exit 1
fi
# Update bitcoin.conf
sed -i '/^rpcuser=/d' "$CONF"
sed -i '/^rpcpassword=/d' "$CONF"
echo "rpcuser=${ADMINUSER}" >> "$CONF"
echo "rpcpassword=${ADMINPASS}" >> "$CONF"
# Update ckpool config so it can still talk to bitcoind
if [[ -f "$CKPOOL_CONF" ]]; then
sed -i "s/\"user\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"user\" : \"${ADMINUSER}\"/" "$CKPOOL_CONF"
sed -i "s/\"pass\"[[:space:]]*:[[:space:]]*\"[^\"]*\"/\"pass\" : \"${ADMINPASS}\"/" "$CKPOOL_CONF"
fi
systemctl restart bitcoind
systemctl restart ckpool
EOF
chmod 700 /usr/local/sbin/btc-apply-admin.sh
# set prune size
cat > /usr/local/sbin/btc-set-prune.sh <<'EOF'
#!/bin/bash
CONF="/etc/bitcoin/bitcoin.conf"
SIZE="$1"
if [[ -z "$SIZE" ]]; then
echo "Usage: $0 <size-in-mb|full>"
exit 1
fi
sed -i '/^prune=/d' "$CONF"
if [[ "$SIZE" != "full" ]]; then
echo "prune=${SIZE}" >> "$CONF"
fi
systemctl restart bitcoind
EOF
chmod 700 /usr/local/sbin/btc-set-prune.sh
# setup donation system
cat > /usr/local/sbin/btc-set-donation.sh <<'EOF'
#!/bin/bash
CONF="/opt/btc-solo/conf/ckpool.conf"
ADDR="$1"
RATE="$2"
if [[ -z "$ADDR" || -z "$RATE" ]]; then
echo "Usage: $0 <donation-address> <donation-rate-percent>"
exit 1
fi
# ensure conf exists
if [[ ! -f "$CONF" ]]; then
echo "ckpool.conf not found at $CONF"
exit 1
fi
# remove old lines
sed -i '/"donaddress"/d' "$CONF"
sed -i '/"donrate"/d' "$CONF"
# add new ones just before the final }
# (very simple appender; good enough for our generated file)
sed -i '$i \ ,"donaddress" : "'"$ADDR"'",\n "donrate" : '"$RATE"'' "$CONF"
systemctl restart ckpool
EOF
chmod 700 /usr/local/sbin/btc-set-donation.sh
# (re)create self-signed cert
cat > /usr/local/sbin/btc-make-cert.sh <<'EOF'
#!/bin/bash
CERTDIR="/etc/ssl/localcerts"
mkdir -p "$CERTDIR"
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout ${CERTDIR}/btc-solo.key \
-out ${CERTDIR}/btc-solo.crt \
-subj "/C=CA/ST=Ontario/L=Barrie/O=BTC-Solo/OU=IT/CN=$(hostname -f)"
chmod 600 ${CERTDIR}/btc-solo.key
systemctl restart nginx
EOF
chmod 700 /usr/local/sbin/btc-make-cert.sh
# ensure sudoers.d exists
mkdir -p /etc/sudoers.d
chmod 750 /etc/sudoers.d
# sudoers
cat > /etc/sudoers.d/btc-solo <<'EOF'
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-apply-admin.sh
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-set-prune.sh
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-make-cert.sh
www-data ALL=(root) NOPASSWD: /usr/local/sbin/btc-set-donation.sh
EOF
chmod 440 /etc/sudoers.d/btc-solo
# ---------------------------------------------------------------------------
echo "[***] Web UI (dashboard + activate)"
# ---------------------------------------------------------------------------
# web-owned state dir
mkdir -p /var/lib/btc-solo
chown www-data:www-data /var/lib/btc-solo
# web dashboard
mkdir -p "$WWW_ROOT"
mkdir -p "$WWW_ROOT/activate"
# Main dashboard
cat > "$WWW_ROOT/index.php" <<'EOF'
<?php
$flag = '/var/lib/btc-solo/.setup-complete';
if (!file_exists($flag)) {
header('Location: /activate/');
exit;
}
// Host / stratum endpoint
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_ADDR'] ?? gethostname();
$host = preg_replace('/:\d+$/', '', $host);
$stratum = 'stratum+tcp://' . $host . ':3333';
// Optional wallet address in query for worker view
$addr = '';
if (isset($_GET['addr'])) {
$candidate = trim($_GET['addr']);
// light sanity check; ckpool will do the real validation
if (preg_match('/^[13bc][a-km-zA-HJ-NP-Z1-9]{25,}$/', $candidate)) {
$addr = $candidate;
}
}
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Bitcoin Node for Solo Miners</title>
<link id="app-favicon" rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23222'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='32' fill='%23ffb347'%3E%E2%9A%A0%3C/text%3E%3C/svg%3E">
<style>
:root {
--bg: #0b0b0e;
--bg-card: #16161d;
--fg: #eee;
--accent: #4caf50;
--accent-warn: #ffb347;
--accent-err: #ff6b6b;
--muted: #999;
--border-soft: #262636;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
}
body {
min-height: 100vh;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top left, #181830, #050509 55%);
color: var(--fg);
margin: 0;
}
header {
background: rgba(10,10,18,0.96);
backdrop-filter: blur(12px);
padding: 1rem 2rem;
display:flex;
justify-content:space-between;
align-items:center;
border-bottom: 1px solid var(--border-soft);
position: sticky;
top: 0;
z-index: 10;
}
h1 { margin: 0; font-size: 1.4rem; }
header p { margin:0; color: var(--muted); font-size: .9rem; }
a { color: #7ad; text-decoration: none; }
a:hover { text-decoration: underline; }
main {
display:grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
padding: 1rem 2rem 2rem;
}
@media (max-width: 960px) {
main { grid-template-columns: minmax(0,1fr); }
}
.card {
background: linear-gradient(145deg, #14141f, #101018);
border-radius: 14px;
padding: 1rem 1.5rem 1.25rem;
box-shadow: 0 18px 35px rgba(0,0,0,0.65);
border: 1px solid var(--border-soft);
}
.card h2 {
margin-top: 0;
font-size: 1.1rem;
display:flex;
align-items:center;
gap:.4rem;
}
.card h2 span.label {
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--muted);
margin-left: .3rem;
}
code {
background: #22253a;
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: .9rem;
}
.status-ok { color: var(--accent); }
.status-warn { color: var(--accent-warn); }
.status-err { color: var(--accent-err); }
.progress-wrap {
background:#06070b;
border-radius:6px;
height:12px;
overflow:hidden;
border:1px solid #26283e;
margin-top:.35rem;
}
.progress-bar {
background: linear-gradient(90deg, #4caf50, #9be15d);
height:100%;
width:0%;
transition:width .6s ease;
}
ul.status-list { list-style:none; padding-left:0; margin:.5rem 0 0; font-size:.9rem; }
ul.status-list li { margin-bottom:.15rem; }
.meta-grid {
display:grid;
grid-template-columns: repeat(auto-fit,minmax(170px,1fr));
gap:.6rem;
margin-top:.75rem;
font-size: .85rem;
}
.meta-pill {
background:#10121f;
border-radius:8px;
padding:.5rem .65rem .6rem;
border:1px solid #24263a;
}
.meta-label { color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.06em; }
.meta-value { margin-top:.15rem; }
.mini-bar-wrap {
margin-top:.25rem;
background:#05060b;
border-radius:999px;
height:6px;
overflow:hidden;
}
.mini-bar {
height:100%;
width:0;
background:linear-gradient(90deg,#4cafef,#b388ff);
transition:width .4s ease;
}
.workers-header {
display:flex;
justify-content:space-between;
align-items:flex-end;
gap: .75rem;
flex-wrap:wrap;
}
.workers-form label { font-size:.84rem; color:var(--muted); display:block; margin-bottom:.15rem; }
.workers-form input[type=text] {
background:#11131e;
border-radius:8px;
border:1px solid #30324d;
padding:.4rem .6rem;
color:var(--fg);
min-width:260px;
}
.workers-form button {
margin-left:.4rem;
padding:.4rem .8rem;
border-radius:8px;
border:0;
background:#2563eb;
color:#fff;
font-size:.85rem;
cursor:pointer;
}
.workers-form button:hover { background:#1d4ed8; }
.workers-summary {
margin-top:.6rem;
font-size:.86rem;
}
.workers-summary strong { font-weight:600; }
.workers-table {
width:100%;
border-collapse:collapse;
margin-top:.75rem;
font-size:.82rem;
}
.workers-table th,
.workers-table td {
padding:.35rem .35rem;
border-bottom:1px solid #25273a;
text-align:left;
white-space:nowrap;
}
.workers-table th { color:var(--muted); font-weight:500; font-size:.78rem; }
.workers-table tr:hover { background:#15172a; cursor:pointer; }
.worker-detail {
margin-top:.7rem;
padding-top:.5rem;
border-top:1px dashed #30324d;
font-size:.84rem;
}
.worker-detail h3 { margin:.1rem 0 .25rem; font-size:.92rem; }
.small-note {
font-size:.8rem;
color:var(--muted);
margin-top:.5rem;
}
#stratum-card {
/* margin-top:.6rem; */
font-size:.88rem;
max-width: 640px;
justify-self: end;
}
#stratum-card .stratum-grid {
row-gap: .4rem;
}
.worker-idle td {
color: var(--accent-warn);
}
.worker-inactive td {
color: var(--accent-err);
opacity: 0.8;
}
.workers-controls {
font-size:.8rem;
color:var(--muted);
margin-top:.25rem;
display:none;
}
.workers-controls label {
cursor:pointer;
user-select:none;
}
.workers-controls input {
vertical-align:middle;
margin-right:.25rem;
}
.worker-detail-row td {
background:#11131e;
border-top:0;
padding-top:.4rem;
padding-bottom:.7rem;
}
.worker-detail {
font-size:.84rem;
color:var(--fg);
border-top:1px dashed #30324d;
margin-top:.2rem;
padding-top:.3rem;
}
@keyframes fadeDown {
from { opacity:0; transform: translateY(-4px); }
to { opacity:1; transform: translateY(0); }
}
tr[data-worker].is-open {
background:#181a30;
}
.pool-hashrate {
margin-top:.5rem;
}
.pool-hash-grid {
display:flex;
flex-wrap:wrap;
gap:.4rem .6rem;
margin-top:.25rem;
}
.pool-hash-item {
display:inline-flex;
flex-direction:column;
padding:.25rem .8rem .35rem;
border-radius:999px;
background:radial-gradient(circle at top left,#232745,#111321);
border:1px solid #2f3457;
min-width:6.3rem;
}
.pool-hash-item span {
color:var(--muted);
font-size:.7rem;
text-transform:uppercase;
letter-spacing:.08em;
}
.pool-hash-item strong {
font-family:var(--font-mono,"JetBrains Mono",Consolas,monospace);
font-size:.92rem;
font-weight:500;
}
footer {
padding:.6rem 2rem 1rem;
font-size:.75rem;
color:var(--muted);
text-align:right;
opacity:0.75;
}
footer a {
color:var(--muted);
text-decoration:none;
}
footer a:hover {
text-decoration:underline;
}
.graph-section {
margin-top:.8rem;
padding:.5rem .4rem .3rem;
background:#05060b;
border-radius:10px;
border:1px solid #24263a;
overflow: hidden;
}
.graph-section canvas {
width:100%;
height:140px;
display:block;
}
.graph-inner {
display: flex;
gap: 1.2rem;
align-items: stretch;
}
.graph-legend {
min-width: 160px;
font-size: .78rem;
color: var(--muted);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.graph-legend-heading {
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .12em;
color: #7579a8;
margin-bottom: .4rem;
}
.graph-legend-row {
display: flex;
align-items: center;
gap: .45rem;
margin-bottom: .35rem;
}
.graph-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg,#4cafef,#b388ff);
flex-shrink: 0;
}
.graph-legend-text {
display: flex;
flex-direction: column;
gap: .1rem;
}
.graph-legend-label {
font-size: .75rem;
color: #8a8fbc;
}
.graph-legend-value {
font-family: var(--font-mono,"JetBrains Mono",Consolas,monospace);
font-size: .88rem;
color: #e5e8ff;
}
.graph-legend-sub {
font-size: .72rem;
color: #a9aedc;
display: flex;
justify-content: space-between;
margin-top: .15rem;
}
.graph-canvas-wrap {
flex: 1;
min-width: 0;
}
.graph-caption {
font-size:.7rem;
color:var(--muted);
margin-top:.25rem;
}
/* Worker detail animation: only when explicitly requested */
.worker-detail {
margin-top:.7rem;
padding-top:.5rem;
border-top:1px dashed #30324d;
font-size:.84rem;
}
.worker-detail h3 { margin:.1rem 0 .25rem; font-size:.92rem; }
.worker-detail-animate {
animation: slideDown .18s ease-out;
}
@keyframes slideDown {
from { opacity:0; transform:translateY(-6px); }
to { opacity:1; transform:translateY(0); }
}
.pool-header {
display:flex;
justify-content:space-between;
align-items:flex-start;
margin-bottom:.55rem;
}
.pool-title-block h2 {
margin:0;
font-size:1.25rem;
}
.pool-subtitle {
font-size:.75rem;
text-transform:uppercase;
letter-spacing:.12em;
color:#6b6f9b;
}
.pool-stat-row {
display:flex;
gap:.35rem;
flex-wrap:wrap;
justify-content:flex-end;
font-size:.75rem;
}
.stat-chip {
display:inline-flex;
flex-direction:column;
align-items:flex-start;
padding:.2rem .5rem;
border-radius:999px;
background:radial-gradient(circle at top left,#232745,#111321);
border:1px solid #2e3255;
min-width:5.2rem;
}
.stat-chip-label {
font-size:.65rem;
text-transform:uppercase;
letter-spacing:.11em;
color:#7b7faa;
}
.stat-chip-value {
font-family:var(--font-mono, "JetBrains Mono", Consolas, monospace);
font-size:.8rem;
color:#e5e8ff;
}
.pool-metrics-row {
display:flex;
align-items:center;
justify-content:space-between;
margin:.2rem 0 .25rem;
}
.pool-metrics-label {
font-size:.7rem;
text-transform:uppercase;
letter-spacing:.15em;
color:#7579a8;
}
.pool-shares-line {
font-size:.78rem;
color:#c7caec;
margin-top:.1rem;
}
.pool-shares-line strong {
font-family:var(--font-mono, "JetBrains Mono", Consolas, monospace);
}
.pool-shares-line {
display:flex;
flex-wrap:wrap;
align-items:center;
gap:.4rem;
margin-top:.4rem;
font-size:.78rem;
}
.shares-label {
font-size:.7rem;
text-transform:uppercase;
letter-spacing:.14em;
color:#7579a8;
margin-right:.1rem;
}
.shares-pill {
display:inline-flex;
flex-direction:column;
padding:.2rem .55rem;
border-radius:999px;
background:radial-gradient(circle at top left,#232745,#111321);
border:1px solid #2f3457;
}
.shares-pill span {
font-size:.65rem;
text-transform:uppercase;
letter-spacing:.08em;
color:#8a8fbc;
}
.shares-pill strong {
font-family:var(--font-mono,"JetBrains Mono",Consolas,monospace);
font-size:.8rem;
color:#e5e8ff;
}
.shares-pill-pct {
font-size:.72rem;
color:#a9aedc;
}
.shares-pill-rejected { border-color:#ff6b81; }
.shares-pill-best { border-color:#4cafef; }
</style>
</head>
<body>
<header>
<div>
<h1>Bitcoin Node for Solo Miners</h1>
<p>A Linux Appliance by Robbie Ferguson</p>
</div>
<div>
<a href="/settings.php">Settings</a>
</div>
</header>
<main>
<!-- Node + system status (top-left) -->
<section class="card" id="node-status">
<h2>Node Status <span class="label">blockchain &amp; system</span></h2>
<p>Loading status…</p>
</section>
<!-- Stratum info card (top-right: connect your miners) -->
<section class="card" id="stratum-card">
<h2>Connect Your Miners <span class="label">stratum endpoint</span></h2>
<div class="stratum-grid">
<div>
<div class="meta-label">Endpoint</div>
<p><code><?php echo htmlspecialchars($stratum); ?></code></p>
</div>
<div>
<div class="meta-label">Username</div>
<p><code>Your BTC payout address</code></p>
</div>
<div>
<div class="meta-label">Password</div>
<p><code>anything</code></p>
</div>
</div>
<p class="small-note" id="mining-note">
Your miners will start submitting shares once Bitcoin Core is fully synced and connected.
</p>
</section>
<!-- Workers / pool dashboard (full-width bottom) -->
<section class="card" id="workers-card" style="grid-column:1 / -1;">
<div class="workers-header">
<h2>Workers &amp; Pool <span class="label">mining overview</span></h2>
<form class="workers-form" id="addr-form">
<div>
<label for="addr-input">Your payout address (for detailed worker view)</label>
<div>
<input type="text" id="addr-input" name="addr" placeholder="bc1… or 1/3 address"
value="<?php echo htmlspecialchars($addr); ?>">
<button type="submit">Load</button>
</div>
</div>
</form>
</div>
<div id="workers-summary">
<p class="small-note">Pulling pool stats from CKPool logs...</p>
</div>
<div class="graph-section" id="pool-hashrate-graph"></div>
<div class="workers-controls" id="workers-controls">
<label>
<input type="checkbox" id="toggle-inactive">
Show inactive workers
</label>
</div>
<div id="workers-table-wrap"></div>
<div id="worker-detail"></div>
<p class="small-note">
Best of luck to you!
</p>
</section>
</main>
<footer>
&copy; Copyright 2025-<?php echo date('Y'); ?> Robbie Ferguson //
<a href="https://category5.tv/">Category5 TV</a>
</footer>
<script>
// ---------------- Shared app state ----------------
const appState = {
bitcoin: {
ibd: true,
progress: 0,
serviceActive: false
},
pool: {
workers: 0
},
error: false,
lastStatusOk: Date.now()
};
let currentAddr = (document.getElementById('addr-input')?.value || '').trim() || null;
// Will want to use the JSON 1m average rather than the most recent database entry, so need to setup the variable to share this data
let currentPoolH1m = null;
// Sliding window tracker for sync ETA
const syncTracker = { samples: [] };
const WINDOW_SECONDS = 900; // 15 minutes
const STALE_MS = 30000;
// Inline SVG favicons
const FAV_SYNC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23222'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='32' fill='%23ffb347'%3E%E2%9A%A0%3C/text%3E%3C/svg%3E";
const FAV_READY = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23161717'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='34' fill='%238f8'%3E%E2%9C%94%3C/text%3E%3C/svg%3E";
const FAV_ERR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23222222'/%3E%3Ctext x='50%25' y='55%25' text-anchor='middle' font-size='34' fill='%23ff6b6b'%3E!%3C/text%3E%3C/svg%3E";
function formatEta(seconds) {
if (seconds == null || !isFinite(seconds)) return 'estimating…';
if (seconds < 60) return seconds.toFixed(0) + 's';
const mins = seconds / 60;
if (mins < 60) return mins.toFixed(1) + 'm';
const hours = mins / 60;
return hours.toFixed(1) + 'h';
}
function formatBytes(bytes) {
if (bytes == null || !isFinite(bytes)) return 'n/a';
const units = ['B','KiB','MiB','GiB','TiB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return v.toFixed(2) + ' ' + units[i];
}
function humanUptime(seconds) {
if (!seconds || seconds < 0) return 'n/a';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days) parts.push(days + ' day' + (days !== 1 ? 's' : ''));
if (hours) parts.push(hours + ' hour' + (hours !== 1 ? 's' : ''));
parts.push(mins + ' minute' + (mins !== 1 ? 's' : ''));
return parts.join(', ');
}
function updateSamples(progress) {
const now = Date.now() / 1000;
syncTracker.samples.push({ t: now, p: progress });
const cutoff = now - WINDOW_SECONDS;
syncTracker.samples = syncTracker.samples.filter(s => s.t >= cutoff);
}
function estimateEta(progress) {
const samples = syncTracker.samples;
if (samples.length < 2) return null;
const first = samples[0];
const last = samples[samples.length - 1];
const dP = last.p - first.p;
const dT = last.t - first.t;
if (dP <= 0 || dT <= 5) return null;
const rate = dP / dT;
if (rate <= 0) return null;
const remaining = Math.max(0, 100 - progress);
return remaining / rate;
}
const graphCache = {};
function drawLineGraph(containerId, points, opts = {}) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
if (!points || !points.length) {
container.innerHTML =
'<p class="small-note graph-caption">Not enough history yet.</p>';
return;
}
const seriesKey = opts.series || 'h1m';
const height = opts.height || 140;
const label = opts.label || 'Pool hashrate';
// Sanitize numeric values (ignore zeros, NaN, and insane outliers)
const xs = points.map(p => p.ts);
const rawVals = points
.map(p => p[seriesKey])
.filter(v => typeof v === 'number' && isFinite(v) && v > 0 && v <= 1e18);
if (!rawVals.length) {
container.innerHTML =
'<p class="small-note graph-caption">No hashrate yet.</p>';
return;
}
let maxRaw = Math.max(...rawVals);
// Choose unit
let unit = 'H/s', div = 1;
if (maxRaw >= 1e12) { unit = 'TH/s'; div = 1e12; }
else if (maxRaw >= 1e9) { unit = 'GH/s'; div = 1e9; }
else if (maxRaw >= 1e6) { unit = 'MH/s'; div = 1e6; }
else if (maxRaw >= 1e3) { unit = 'kH/s'; div = 1e3; }
// Values in chosen unit
const ys = points.map(p => {
const v = p[seriesKey];
if (typeof v !== 'number' || !isFinite(v) || v <= 0 || v > 1e18) return 0;
return v / div;
});
const positiveYs = ys.filter(v => v > 0);
if (!positiveYs.length) {
container.innerHTML =
'<p class="small-note graph-caption">No hashrate yet.</p>';
return;
}
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = 0;
const maxY = Math.max(...positiveYs);
const latestGood = positiveYs[positiveYs.length - 1];
// Live 1m value (raw H/s) from opts.currentValue
const explicitCurrent =
(opts.currentValue != null && isFinite(opts.currentValue) && opts.currentValue > 0)
? (opts.currentValue / div)
: null;
const displayLast = explicitCurrent != null ? explicitCurrent : latestGood;
const minVal = Math.min(...positiveYs);
const maxVal = maxY;
const dpr = window.devicePixelRatio || 1;
// ------- Build DOM first so flex can lay out -------
const wrapper = document.createElement('div');
wrapper.className = 'graph-inner';
const legend = document.createElement('div');
legend.className = 'graph-legend';
legend.innerHTML = `
<div>
<div class="graph-legend-heading">${label}</div>
<div class="graph-legend-row">
<span class="graph-swatch"></span>
<div class="graph-legend-text">
<div class="graph-legend-label">1m average</div>
<div class="graph-legend-value">${
isFinite(displayLast) ? displayLast.toFixed(2) + ' ' + unit : 'no data yet'
}</div>
</div>
</div>
<div class="graph-legend-row graph-legend-sub">
<span class="graph-legend-label">Range</span>
<span class="graph-legend-value">${
isFinite(minVal) && isFinite(maxVal)
? minVal.toFixed(2) + '' + maxVal.toFixed(2) + ' ' + unit
: 'n/a'
}</span>
</div>
</div>
`;
const canvasWrap = document.createElement('div');
canvasWrap.className = 'graph-canvas-wrap';
const canvas = document.createElement('canvas');
canvasWrap.appendChild(canvas);
wrapper.appendChild(legend);
wrapper.appendChild(canvasWrap);
container.appendChild(wrapper);
// ------- Now we know the real width available to the canvas -------
const widthCSS = canvasWrap.clientWidth || container.clientWidth || 300;
canvas.width = widthCSS * dpr;
canvas.height = height * dpr;
canvas.style.width = widthCSS + 'px';
canvas.style.height = height + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
const padL = 32, padR = 16, padT = 6, padB = 16;
const w = widthCSS - padL - padR;
const h = height - padT - padB;
function nx(x) {
return padL + (w * (x - minX) / (maxX - minX || 1));
}
function ny(y) {
return padT + h - (h * (y - minY) / (maxY - minY || 1));
}
// Grid
ctx.lineWidth = 1;
ctx.strokeStyle = '#1a1c2b';
ctx.beginPath();
for (let i = 0; i <= 4; i++) {
const yy = padT + (h * i / 4);
ctx.moveTo(padL, yy);
ctx.lineTo(widthCSS - padR, yy);
}
ctx.stroke();
// Y labels
ctx.fillStyle = '#666b8f';
ctx.font = '10px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const value = maxY * (1 - i / 4);
const yy = padT + (h * i / 4);
ctx.fillText(value.toFixed(2), padL - 4, yy);
}
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(unit, padL, padT);
// Line
ctx.lineWidth = 1.7;
const grad = ctx.createLinearGradient(padL, padT, widthCSS - padR, padT);
grad.addColorStop(0, '#4cafef');
grad.addColorStop(1, '#b388ff');
ctx.strokeStyle = grad;
ctx.beginPath();
points.forEach((p, idx) => {
const raw = p[seriesKey];
const v = (typeof raw === 'number' && isFinite(raw) && raw > 0 && raw <= 1e18)
? raw / div
: 0;
const x = nx(p.ts);
const y = ny(v);
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
if (opts.caption) {
const cap = document.createElement('div');
cap.className = 'graph-caption';
const capText = isFinite(displayLast)
? `${opts.caption} (current ${displayLast.toFixed(2)} ${unit})`
: opts.caption;
cap.textContent = capText;
canvasWrap.appendChild(cap);
}
}
function loadWorkerHistory(addr, workerName, containerId, currentH1m) {
if (!addr || !workerName || !containerId) return;
const container = document.getElementById(containerId);
if (!container) return;
const key = addr + '::' + workerName;
const now = Date.now();
const last = workerHistoryLastFetch[key] || 0;
// If the container is empty, we MUST draw something, even if we're inside
// the 60s rate limit window. This avoids the "graph disappears" issue when
// the table/detail HTML gets rebuilt.
const needsImmediateRedraw = !container.hasChildNodes();
// Only skip if we've drawn recently *and* the existing graph is still there
if (!needsImmediateRedraw && now - last < 60000) {
return;
}
workerHistoryLastFetch[key] = now;
// Optional: tiny "loading" hint while the history fetch happens
container.innerHTML =
'<p class="small-note graph-caption">Loading worker history…</p>';
fetch(
'/history.php?mode=worker&addr=' +
encodeURIComponent(addr) +
'&worker=' + encodeURIComponent(workerName) +
'&limit=120',
{ cache: 'no-store' }
)
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(data => {
if (!data || !Array.isArray(data.points)) return;
drawLineGraph(containerId, data.points, {
series: 'h1m',
caption: 'Worker hashrate (1m average over time)',
label: 'Worker hashrate',
// explicit current value so legend matches the JSON
currentValue: currentH1m
});
})
.catch(() => {
// leave whatever was last drawn, or the small "loading" text if first time
});
}
function renderServices(svcs) {
if (!svcs) return '';
const map = {
bitcoind: 'Bitcoin Core',
ckpool: 'CKPool'
};
let out = '<h3 style="margin-top:.75rem;font-size:.9rem;">Service Health</h3><ul class="status-list">';
for (const key of Object.keys(map)) {
if (!(key in svcs)) continue;
const state = svcs[key];
const ok = state === 'active';
out += '<li>' + (ok ? '✅' : '❌') + ' ' + map[key] + ' — ' + state + '</li>';
}
out += '</ul>';
return out;
}
function refreshTitleAndFavicon() {
const link = document.getElementById('app-favicon');
if (!link) return;
if (appState.error || !appState.bitcoin.serviceActive) {
link.href = FAV_ERR;
document.title = "Disconnected • Bitcoin Solo Miner";
return;
}
if (appState.bitcoin.ibd) {
link.href = FAV_SYNC;
document.title = "Syncing… " + appState.bitcoin.progress.toFixed(1) + "% | Bitcoin Solo Miner";
return;
}
// In sync
if (appState.pool.workers > 0) {
link.href = FAV_READY;
document.title = "Mining • Bitcoin Solo Miner";
} else {
link.href = FAV_READY;
document.title = "Waiting for Workers • Bitcoin Solo Miner";
}
}
async function loadPoolHistory() {
try {
const res = await fetch('/history.php?mode=pool&limit=720', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (!data || !Array.isArray(data.points)) return;
lastPoolHistory = data.points;
renderPoolGraph(); // <-- new
} catch (e) {
// silent failure is fine
}
}
function renderPoolGraph() {
const container = document.getElementById('pool-hashrate-graph');
if (!lastPoolHistory || !Array.isArray(lastPoolHistory) || !lastPoolHistory.length) {
if (container) {
container.innerHTML =
'<p class="small-note graph-caption">Not enough history yet.</p>';
}
bestPoolH1m = null;
return;
}
// Compute the best 1m pool hashrate seen in the loaded history window
let maxH1m = 0;
for (const p of lastPoolHistory) {
const v = (typeof p.h1m === 'number' && isFinite(p.h1m) && p.h1m > 0 && p.h1m <= 1e18)
? p.h1m
: 0;
if (v > maxH1m) maxH1m = v;
}
bestPoolH1m = maxH1m > 0 ? maxH1m : null;
drawLineGraph('pool-hashrate-graph', lastPoolHistory, {
series: 'h1m',
caption: 'Current hashrate',
label: 'Pool hashrate',
currentValue: currentPoolH1m // live 1m from JSON
});
}
// ---------------- Load node/system status ----------------
async function loadStatus() {
try {
const res = await fetch('/status.php', {cache: 'no-store'});
const data = await res.json();
const box = document.getElementById('node-status');
if (data.error) {
appState.error = true;
appState.bitcoin.serviceActive = false;
box.innerHTML = '<h2>Node Status <span class="label">blockchain &amp; system</span></h2>' +
'<p class="status-err">' + data.error + '</p>';
refreshTitleAndFavicon();
return;
}
appState.error = false;
appState.lastStatusOk = Date.now();
const rawIbd = data.initialblockdownload;
const hasRealIbd = (typeof rawIbd === 'boolean');
const ibd = hasRealIbd ? rawIbd : true;
const blocks = data.blocks ?? '';
const pruned = data.pruned ? '(pruned)' : '';
let progress = 0;
if (typeof data.verificationprogress === 'number') {
progress = data.verificationprogress * 100;
} else if (data.blocks && data.headers && data.headers > 0) {
progress = (data.blocks / data.headers) * 100;
}
appState.bitcoin.ibd = ibd;
appState.bitcoin.progress = progress;
const bitcoindActive = data.services && data.services.bitcoind === 'active';
appState.bitcoin.serviceActive = !!bitcoindActive;
updateSamples(progress);
const etaSeconds = ibd ? estimateEta(progress) : null;
let html = '<h2>Node Status <span class="label">blockchain &amp; system</span></h2>';
if (!bitcoindActive) {
html += '<p class="status-err">❌ Bitcoin service is not running.</p>';
} else if (!hasRealIbd) {
html += '<p class="status-err">❌ Cannot read Bitcoin Core status. If this persists, try refreshing your browser.</p>';
} else if (ibd && progress < 0.1) {
html += '<p class="status-warn">⚠ Bitcoin Core is starting up and loading blockchain data…</p>';
} else if (ibd) {
html += '<p class="status-warn">⚠ Bitcoin Core is syncing. Your miners will not be able to submit shares or receive work until the node is fully synced.</p>';
} else {
html += '<p class="status-ok">✔ Bitcoin Core is in sync.</p>';
}
html += '<p style="margin-bottom:.3rem;">Blocks: ' + blocks + ' ' + pruned + '</p>';
// Only show sync bar while still syncing
if (ibd) {
html += '<div class="progress-wrap"><div class="progress-bar" id="syncbar"></div></div>';
html += '<p style="margin-top:.25rem;"><small>Sync progress: ' + progress.toFixed(1) + '%';
if (etaSeconds && isFinite(etaSeconds) && etaSeconds > 0) {
const etaDate = new Date(Date.now() + etaSeconds * 1000);
const etaString = etaDate.toLocaleString(undefined, {
month: 'long', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit'
});
html += ' • Estimated time left: ' + formatEta(etaSeconds) + ' (' + etaString + ')';
}
html += '</small></p>';
}
// System metrics
const sys = data.system || {};
const load = sys.loadavg || [];
const memTotal = sys.mem_total_bytes;
const memAvail = sys.mem_available_bytes;
let memPct = null;
if (memTotal && memAvail != null) {
memPct = (1 - (memAvail / memTotal)) * 100;
}
const disk = sys.disk_bitcoind || {};
let diskPct = null;
if (disk.total_bytes && disk.used_bytes != null) {
diskPct = (disk.used_bytes / disk.total_bytes) * 100;
}
html += '<div class="meta-grid">';
// Uptime
if (sys.uptime_seconds != null) {
html += '<div class="meta-pill"><div class="meta-label">Node Uptime</div>' +
'<div class="meta-value">' + humanUptime(sys.uptime_seconds) + '</div></div>';
}
// CPU load (1m / 5m / 15m)
if (load && load.length >= 3) {
const l1 = (typeof load[0] === 'number') ? load[0].toFixed(2) : 'n/a';
const l5 = (typeof load[1] === 'number') ? load[1].toFixed(2) : 'n/a';
const l15 = (typeof load[2] === 'number') ? load[2].toFixed(2) : 'n/a';
html += '<div class="meta-pill">' +
'<div class="meta-label">CPU Load (1m / 5m / 15m)</div>' +
'<div class="meta-value">' + l1 + ' / ' + l5 + ' / ' + l15 + '</div>' +
'</div>';
}
// Memory
if (memTotal) {
const memText = formatBytes(memTotal - (memAvail || 0)) +
' / ' +
formatBytes(memTotal);
const pct = memPct != null ? Math.max(0, Math.min(100, memPct)) : 0;
html += '<div class="meta-pill">' +
'<div class="meta-label">Memory</div>' +
'<div class="meta-value">' + memText + '</div>' +
'<div class="mini-bar-wrap"><div class="mini-bar" id="mem-bar" style="width:' + pct.toFixed(1) + '%"></div></div>' +
'</div>';
}
// Disk
if (disk.total_bytes) {
const used = disk.used_bytes ?? (disk.total_bytes - (disk.free_bytes || 0));
const usedText = formatBytes(used) +
' / ' +
formatBytes(disk.total_bytes);
const dpct = diskPct != null ? Math.max(0, Math.min(100, diskPct)) : 0;
html += '<div class="meta-pill">' +
'<div class="meta-label">Bitcoin Data Disk</div>' +
'<div class="meta-value">' + usedText + '</div>' +
'<div class="mini-bar-wrap"><div class="mini-bar" id="disk-bar" style="width:' + dpct.toFixed(1) + '%"></div></div>' +
'</div>';
}
// CPU temp if present
if (sys.cpu_temp_c != null) {
html += '<div class="meta-pill">' +
'<div class="meta-label">CPU Temp</div>' +
'<div class="meta-value">' + sys.cpu_temp_c.toFixed(1) + ' °C</div>' +
'</div>';
}
html += '</div>'; // meta-grid
// Services
html += renderServices(data.services);
box.innerHTML = html;
const bar = document.getElementById('syncbar');
if (bar && ibd) {
bar.style.width = Math.min(progress, 100).toFixed(2) + '%';
}
// Hide "your miners will start..." note once fully in sync
const miningNote = document.getElementById('mining-note');
if (miningNote) {
miningNote.style.display = ibd ? 'block' : 'none';
}
refreshTitleAndFavicon();
} catch (e) {
const box = document.getElementById('node-status');
box.innerHTML =
'<h2>Node Status <span class="label">blockchain &amp; system</span></h2>' +
'<p class="status-err">Failed to fetch status.</p>';
appState.error = true;
refreshTitleAndFavicon();
}
}
// ---------------- CKPool / workers dashboard ----------------
function parseHashrate(str) {
if (!str || typeof str !== 'string') return null;
const m = str.trim().match(/^([\d.]+)\s*([kKmMgGtTpP]?)/);
if (!m) return null;
let v = parseFloat(m[1]);
const unit = m[2].toUpperCase();
const mult = {
'': 1,
'K': 1e3,
'M': 1e6,
'G': 1e9,
'T': 1e12,
'P': 1e15
}[unit] || 1;
return v * mult;
}
function formatHashrateHps(value) {
if (value == null || !isFinite(value) || value <= 0) return '';
let v = value;
let unit = 'H/s';
if (v >= 1e12) { unit = 'TH/s'; v /= 1e12; }
else if (v >= 1e9) { unit = 'GH/s'; v /= 1e9; }
else if (v >= 1e6) { unit = 'MH/s'; v /= 1e6; }
else if (v >= 1e3) { unit = 'kH/s'; v /= 1e3; }
return v.toFixed(2) + ' ' + unit;
}
function getDisplayName(rawName) {
let displayName = rawName || '';
if (currentAddr && displayName) {
if (displayName === currentAddr) {
displayName = 'Unnamed Worker';
} else if (displayName.startsWith(currentAddr + '.')) {
displayName = displayName.substring(currentAddr.length + 1) || 'Unnamed Worker';
}
}
if (!displayName) displayName = 'Unnamed Worker';
return displayName;
}
let selectedWorkerKey = null;
let workerDetailShouldAnimate = false;
const workerHistoryLastFetch = {};
let showInactiveWorkers = false;
const IDLE_SEC = 30 * 60; // 30 minutes -> mark as idle
const HIDE_SEC = 3 * 24 * 3600; // 3 days -> "inactive" list
const PURGE_SEC = 30 * 24 * 3600; // 30 days -> purge from UI
function formatShare(value) {
if (value == null) return '';
const num = typeof value === 'number' ? value : parseFloat(value);
if (!isFinite(num)) return '';
const units = ['', 'k', 'M', 'G', 'T', 'P'];
let v = num;
let i = 0;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
return v.toFixed(1) + ' ' + units[i];
}
function slugifyWorkerName(name) {
return (name || '')
.toString()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'worker';
}
function buildWorkerDetailHtml(worker, animate, graphId) {
const displayName = getDisplayName(worker.workername);
const now = Date.now() / 1000;
let lastRel = 'never';
let lastAbs = 'never';
if (worker.lastshare) {
const age = now - worker.lastshare;
if (age < 60) lastRel = Math.floor(age) + 's ago';
else if (age < 3600) lastRel = Math.floor(age / 60) + 'm ago';
else if (age < 86400) lastRel = Math.floor(age / 3600) + 'h ago';
else lastRel = Math.floor(age / 86400) + 'd ago';
const d = new Date(worker.lastshare * 1000);
lastAbs = d.toLocaleString();
}
const classes = ['worker-detail'];
if (animate) classes.push('worker-detail-animate');
const detail = [];
detail.push('<div class="' + classes.join(' ') + '">');
detail.push('<h3>Worker: ' + displayName + '</h3>');
detail.push('<div>Last share: ' + lastRel + ' (' + lastAbs + ')</div>');
detail.push('<div style="margin-top:.25rem;">Hashrate (averages):</div>');
detail.push('<ul style="margin:.2rem 0 .3rem .9rem;font-size:.84rem;">' +
'<li>1 minute: ' + (worker.hashrate1m || '') + '</li>' +
'<li>5 minutes: ' + (worker.hashrate5m || '') + '</li>' +
'<li>1 hour: ' + (worker.hashrate1hr || '') + '</li>' +
'<li>1 day: ' + (worker.hashrate1d || '') + '</li>' +
'<li>7 days: ' + (worker.hashrate7d || '') + '</li>' +
'</ul>');
detail.push('<div>Shares: ' + (worker.shares ?? '') +
' • Best share: ' + formatShare(worker.bestshare) +
' • Best ever: ' + formatShare(worker.bestever) + '</div>');
if (graphId) {
detail.push(
'<div class="graph-section" style="margin-top:.6rem;">' +
'<div id="' + graphId + '"></div>' +
'</div>'
);
}
detail.push('</div>');
return detail.join('');
}
async function loadPool() {
try {
const url = currentAddr
? '/ckpool_status.php?addr=' + encodeURIComponent(currentAddr)
: '/ckpool_status.php';
const res = await fetch(url, {cache:'no-store'});
const data = await res.json();
const summaryEl = document.getElementById('workers-summary');
const tableWrap = document.getElementById('workers-table-wrap');
let summaryHtml = '';
let tableHtml = '';
if (!data || data.error && !data.pool) {
summaryHtml = '<p class="status-err">Unable to read CKPool stats: ' +
(data && data.error ? data.error : 'Unknown error') +
'</p>';
summaryEl.innerHTML = summaryHtml;
appState.pool.workers = 0;
refreshTitleAndFavicon();
return;
}
// Pool-level stats
const counts = data.pool && data.pool.counts ? data.pool.counts : null;
const hashr = data.pool && data.pool.hashrate ? data.pool.hashrate : null;
const shares = data.pool && data.pool.shares ? data.pool.shares : null;
// Track the current pool 1m hashrate (from live JSON) for the graph legend
if (hashr && hashr.hashrate1m) {
currentPoolH1m = parseHashrate(hashr.hashrate1m) || null;
} else {
currentPoolH1m = null;
}
// Keep the graph legend in sync with the pills using the latest 1m value
renderPoolGraph();
const workers = counts && typeof counts.Workers === 'number'
? counts.Workers
: 0;
appState.pool.workers = workers;
summaryHtml += '<div class="workers-summary">';
// POOL STATUS: runtime + worker counts as pills
if (counts) {
let runtimeStr = 'n/a';
if (typeof counts.runtime === 'number') {
const rt = counts.runtime;
const h = Math.floor(rt / 3600);
const m = Math.floor((rt % 3600) / 60);
runtimeStr = (h ? h + 'h ' : '') + m + 'm';
}
const idleStr = typeof counts.Idle === 'number' ? String(counts.Idle) : '0';
const discStr = typeof counts.Disconnected === 'number' ? String(counts.Disconnected) : '0';
summaryHtml += '<div class="pool-metrics-label" style="margin-top:.4rem;">POOL STATUS</div>';
summaryHtml += '<div class="pool-meta-pills pool-hash-grid">';
summaryHtml += ' <div class="shares-pill">' +
' <span>Runtime</span>' +
' <strong>' + runtimeStr + '</strong>' +
' </div>';
summaryHtml += ' <div class="shares-pill">' +
' <span>Workers</span>' +
' <strong>' + workers + '</strong>' +
' </div>';
summaryHtml += ' <div class="shares-pill">' +
' <span>Idle</span>' +
' <strong>' + idleStr + '</strong>' +
' </div>';
summaryHtml += ' <div class="shares-pill">' +
' <span>Disconnected</span>' +
' <strong>' + discStr + '</strong>' +
' </div>';
summaryHtml += '</div>';
} else {
summaryHtml += '<div class="pool-metrics-label" style="margin-top:.4rem;">POOL STATUS</div>';
summaryHtml += '<div class="pool-shares-line">No pool summary yet — waiting for shares.</div>';
}
// HASHRATE AVERAGES: big pills for 1m, 5m, etc.
if (hashr) {
const buckets = [
['1 Minute', hashr.hashrate1m],
['5 Minutes', hashr.hashrate5m],
['15 Minutes', hashr.hashrate15m],
['1 Hour', hashr.hashrate1hr],
['6 Hours', hashr.hashrate6hr],
['1 Day', hashr.hashrate1d],
['7 Days', hashr.hashrate7d],
];
summaryHtml += '<div class="pool-metrics-label" style="margin-top:1rem;">HASHRATE AVERAGES</div>';
summaryHtml += '<div class="pool-hashrate"><div class="pool-hash-grid">';
for (const [label, value] of buckets) {
if (!value) continue;
summaryHtml += '<div class="shares-pill">' +
'<span>' + label + '</span>' +
'<strong>' + value + '</strong>' +
'</div>';
}
summaryHtml += '</div></div>';
}
// SHARES: pills for accepted / rejected / best share / best hashrate
if (shares) {
const acceptedNum = Number(shares.accepted ?? 0);
const rejectedNum = Number(shares.rejected ?? 0);
const totalShares = acceptedNum + rejectedNum;
const acceptedText = shares.accepted != null
? acceptedNum.toLocaleString()
: '';
const rejectedText = shares.rejected != null
? rejectedNum.toLocaleString()
: '';
let rejectPct = null;
if (totalShares > 0 && rejectedNum >= 0) {
rejectPct = ((rejectedNum / totalShares) * 100).toFixed(2);
}
const bestHashText = bestPoolH1m ? formatHashrateHps(bestPoolH1m) : '';
summaryHtml += '<div class="pool-metrics-label" style="margin-top:1rem;">SHARES</div>';
summaryHtml += '<div class="pool-shares-line">';
summaryHtml += ' <div class="shares-pill">' +
' <span>accepted</span>' +
' <strong>' + acceptedText + '</strong>' +
' </div>';
summaryHtml += ' <div class="shares-pill">' +
' <span>rejected</span>' +
' <strong>' + rejectedText +
(rejectPct !== null
? ' <span class="shares-pill-pct">(' + rejectPct + '%)</span>' +
''
: '') +
' </strong>' +
' </div>';
summaryHtml += ' <div class="shares-pill">' +
' <span>best share</span>' +
' <strong>' + formatShare(shares.bestshare) + '</strong>' +
' </div>';
summaryHtml += ' <div class="shares-pill">' +
' <span>best hashrate</span>' +
' <strong>' + bestHashText + '</strong>' +
' </div>';
summaryHtml += '</div>';
}
summaryHtml += '</div>'; // .workers-summary
summaryEl.innerHTML = summaryHtml;
// User + workers table
if (data.user && Array.isArray(data.workers) && data.workers.length > 0) {
const workersArr = data.workers;
// Sort workers alphabetically by display name, with "Unnamed Worker" last
const sortedWorkers = workersArr.slice().sort((a, b) => {
const nameA = getDisplayName(a.workername);
const nameB = getDisplayName(b.workername);
const unnamedA = (nameA === 'Unnamed Worker');
const unnamedB = (nameB === 'Unnamed Worker');
if (unnamedA && !unnamedB) return 1;
if (!unnamedA && unnamedB) return -1;
return nameA.localeCompare(nameB, undefined, { sensitivity: 'base' });
});
tableHtml += '<table class="workers-table"><thead><tr>' +
'<th>Worker</th>' +
'<th>1m</th>' +
'<th>5m</th>' +
'<th>1h</th>' +
'<th>1d</th>' +
'<th>7d</th>' +
'<th>Shares</th>' +
'<th>Best Share</th>' +
'<th>Last Share</th>' +
'</tr></thead><tbody>';
const now = Date.now() / 1000;
let hasInactive = false;
const pendingWorkerGraphs = [];
for (const w of sortedWorkers) {
const displayName = getDisplayName(w.workername);
// Time since last share
const lastAge = w.lastshare ? (now - w.lastshare) : null;
let lastStr = 'never';
if (lastAge != null) {
if (lastAge < 60) lastStr = Math.floor(lastAge) + 's ago';
else if (lastAge < 3600) lastStr = Math.floor(lastAge / 60) + 'm ago';
else if (lastAge < 86400) lastStr = Math.floor(lastAge / 3600) + 'h ago';
else lastStr = Math.floor(lastAge / 86400) + 'd ago';
}
let rowClass = '';
let stateTag = '';
if (lastAge == null) {
rowClass = 'worker-inactive';
stateTag = ' (inactive)';
hasInactive = true;
} else if (lastAge > PURGE_SEC) {
continue;
} else if (lastAge > HIDE_SEC) {
if (!showInactiveWorkers) continue;
rowClass = 'worker-inactive';
stateTag = ' (inactive)';
hasInactive = true;
} else if (lastAge > IDLE_SEC) {
rowClass = 'worker-idle';
stateTag = ' (idle)';
}
const lastCol = lastStr + stateTag;
const isOpen = (selectedWorkerKey === w.workername);
const openClass = isOpen ? ' is-open' : '';
tableHtml += '<tr class="' + rowClass + openClass + '" data-worker="' +
encodeURIComponent(w.workername) + '">' +
'<td>' + displayName + '</td>' +
'<td>' + (w.hashrate1m || '') + '</td>' +
'<td>' + (w.hashrate5m || '') + '</td>' +
'<td>' + (w.hashrate1hr || '') + '</td>' +
'<td>' + (w.hashrate1d || '') + '</td>' +
'<td>' + (w.hashrate7d || '') + '</td>' +
'<td>' + (w.shares ?? '') + '</td>' +
'<td>' + formatShare(w.bestshare) + '</td>' +
'<td>' + lastCol + '</td>' +
'</tr>';
if (isOpen) {
const graphId = 'worker-graph-' + slugifyWorkerName(w.workername || '');
tableHtml += '<tr class="worker-detail-row">' +
'<td colspan="9">' + buildWorkerDetailHtml(w, workerDetailShouldAnimate, graphId) + '</td>' +
'</tr>';
pendingWorkerGraphs.push({
workername: w.workername,
graphId,
currentH1m: parseHashrate(w.hashrate1m || null)
});
}
}
tableHtml += '</tbody></table>';
tableWrap.innerHTML = tableHtml;
// Once we've rendered the detail, don't re-animate on auto-refreshes
workerDetailShouldAnimate = false;
// Kick off worker history graphs (rate-limited per worker)
if (currentAddr && pendingWorkerGraphs.length) {
pendingWorkerGraphs.forEach(info => {
loadWorkerHistory(
currentAddr,
info.workername,
info.graphId,
info.currentH1m
);
});
}
const controlsEl = document.getElementById('workers-controls');
const toggleEl = document.getElementById('toggle-inactive');
if (controlsEl) {
if (currentAddr && hasInactive) {
controlsEl.style.display = 'block';
} else {
controlsEl.style.display = 'none';
showInactiveWorkers = false;
if (toggleEl) toggleEl.checked = false;
}
}
// Click handler for detail
tableWrap.querySelectorAll('tr[data-worker]').forEach(row => {
row.addEventListener('click', () => {
const name = decodeURIComponent(row.getAttribute('data-worker'));
if (selectedWorkerKey === name) {
selectedWorkerKey = null;
workerDetailShouldAnimate = false;
} else {
selectedWorkerKey = name;
workerDetailShouldAnimate = true;
}
loadPool();
});
});
} else if (currentAddr) {
tableWrap.innerHTML =
'<p class="small-note">No worker stats yet for this address. ' +
'If you just pointed your miners here, give it a moment.</p>';
} else {
tableWrap.innerHTML =
'<p class="small-note">Enter your BTC payout address above for a ' +
'per-worker breakdown. Without an address, you\'ll just see pool-wide stats.</p>';
}
refreshTitleAndFavicon();
} catch (e) {
const summaryEl = document.getElementById('workers-summary');
summaryEl.innerHTML =
'<p class="status-err">Failed to fetch CKPool stats.</p>';
appState.pool.workers = 0;
refreshTitleAndFavicon();
}
}
// Form wiring for address -> URL
const addrForm = document.getElementById('addr-form');
if (addrForm) {
addrForm.addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('addr-input');
const val = (input.value || '').trim();
currentAddr = val || null;
const url = new URL(window.location.href);
if (currentAddr) url.searchParams.set('addr', currentAddr);
else url.searchParams.delete('addr');
window.history.replaceState(null, '', url.toString());
loadPool();
});
}
// Toggle display of inactive workers
const toggleInactive = document.getElementById('toggle-inactive');
if (toggleInactive) {
toggleInactive.addEventListener('change', () => {
showInactiveWorkers = toggleInactive.checked;
loadPool();
});
}
let lastPoolHistory = null;
let bestPoolH1m = null; // highest 1m pool hashrate seen in the loaded history window
// Initial load & timers
loadStatus();
loadPool();
loadPoolHistory();
setInterval(loadStatus, 5000);
setInterval(loadPool, 7000);
setInterval(loadPoolHistory, 60000); // ~every 60s for history, which matches the cron task that updates the database
// Stale detection for status.php
setInterval(() => {
if (Date.now() - appState.lastStatusOk > STALE_MS) {
appState.error = true;
refreshTitleAndFavicon();
const box = document.getElementById('node-status');
if (box && !box.innerHTML.includes('Status information unavailable')) {
box.innerHTML =
'<h2>Node Status <span class="label">blockchain &amp; system</span></h2>' +
'<p class="status-err">Status information unavailable (stale data &gt; 30s). ' +
'If this persists, try refreshing your browser.</p>';
}
}
}, 3000);
</script>
</body>
</html>
EOF
# Settings page
cat > "$WWW_ROOT/settings.php" <<'EOF'
<?php
$flag = '/var/lib/btc-solo/.setup-complete';
if (!file_exists($flag)) {
header('Location: /activate/');
exit;
}
$opts = [
'550' => '550 MB (default, small)',
(2*1024) => '2 GB',
(4*1024) => '4 GB',
(8*1024) => '8 GB',
(16*1024) => '16 GB',
(32*1024) => '32 GB',
(64*1024) => '64 GB',
(100*1024) => '100 GB',
(200*1024) => '200 GB',
(300*1024) => '300 GB',
(400*1024) => '400 GB',
'full' => 'Full (no pruning)',
];
$current = '550';
$conf = @file_get_contents('/etc/bitcoin/bitcoin.conf');
if ($conf && preg_match('/^prune=(\d+)/m', $conf, $m)) {
$current = $m[1];
} elseif ($conf && !preg_match('/^prune=/m', $conf)) {
$current = 'full';
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['prune'])) {
$sel = $_POST['prune'];
if (isset($opts[$sel])) {
shell_exec('sudo /usr/local/sbin/btc-set-prune.sh ' . escapeshellarg($sel));
$current = $sel;
sleep(1);
}
}
$host = $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_ADDR'] ?? gethostname();
$host = preg_replace('/:\d+$/', '', $host); // strip port if present
$stratum = 'stratum+tcp://' . $host . ':3333';
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Bitcoin Node for Solo Miners [Settings]</title>
<style>
body { font-family: sans-serif; background: #111; color: #eee; margin: 0; }
header { background: #222; padding: 1rem 2rem; }
h1 { margin: 0; }
.card { background: #1b1b1b; margin: 1rem 2rem; padding: 1rem 1.5rem; border-radius: 12px; }
code { background: #333; padding: 0.25rem 0.5rem; border-radius: 6px; }
label { display:block; margin-bottom: .5rem; }
select, button { padding: .4rem .6rem; border-radius: 6px; border:0; }
button { background:#4caf50; color:#fff; cursor:pointer; }
</style>
</head>
<body>
<header>
<h1>Bitcoin Node for Solo Miners</h1>
<p style="margin:0;">A Linux Appliance by Robbie Ferguson</p>
</header>
<div class="card">
<h2>Stratum Endpoint</h2>
<p><code><?php echo htmlspecialchars($stratum); ?></code></p>
<p>Username: BTC address for payout</p>
<p>Password: anything</p>
</div>
<div class="card">
<h2>Blockchain Prune Size</h2>
<form method="post">
<label for="prune">Current setting:</label>
<select name="prune" id="prune">
<?php foreach ($opts as $val => $label): ?>
<option value="<?php echo $val; ?>" <?php echo ($val == $current ? 'selected' : ''); ?>>
<?php echo $label; ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit">Apply</button>
<p><small><b>Note:</b> Prune size only limits stored block data. You should also expect the node to use up to 500 MB for chainstate and metadata. Actual disk usage will be higher than the value selected here.</small></p>
<p><small>Changing this will restart bitcoind.</small></p>
</form>
</div>
<div class="card">
<h2>Donate to Bald Nerd</h2>
<p><small>If you happen to win a BTC block, you can donate a small percentage to Robbie Ferguson (developer of this appliance, host of Category5 Technology TV) with big thanks. You can set to 0 if you prefer to keep the full block to yourself.</small></p>
<?php
$conf = @file_get_contents('/opt/btc-solo/conf/ckpool.conf');
$currAddr = '1MoGAsK8bRFKjHrpnpFJyqxdqamSPH19dP';
$currRate = 2;
if ($conf) {
if (preg_match('/"donaddress"\s*:\s*"([^"]+)"/', $conf, $m)) $currAddr = $m[1];
if (preg_match('/"donrate"\s*:\s*([0-9.]+)/', $conf, $m)) $currRate = $m[1];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['donation_addr'])) {
$newAddr = trim($_POST['donation_addr']);
$newRate = floatval($_POST['donation_rate']);
if ($newAddr !== '') {
shell_exec('sudo /usr/local/sbin/btc-set-donation.sh '
. escapeshellarg($newAddr) . ' ' . escapeshellarg($newRate));
$currAddr = $newAddr;
$currRate = $newRate;
}
}
?>
<form method="post">
<label>Donation address (payout for your 2%)</label>
<input type="text" name="donation_addr" value="<?php echo htmlspecialchars($currAddr); ?>" style="width:100%;max-width:420px;">
<label>Donation %</label>
<select name="donation_rate">
<?php foreach ([0,0.5,1,2,3,4,5] as $r): ?>
<option value="<?php echo $r; ?>" <?php echo ($r == $currRate ? 'selected' : ''); ?>>
<?php echo $r; ?>%
</option>
<?php endforeach; ?>
</select>
<button type="submit">Save</button>
</form>
</div>
</body>
</html>
EOF
# AJAX status info
cat > "$WWW_ROOT/status.php" <<'EOF'
<?php
// lightweight JSON status for dashboard / monitoring
$flag = '/var/lib/btc-solo/.setup-complete';
if (!file_exists($flag)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'not activated']);
exit;
}
header('Content-Type: application/json');
// ---- 1) bitcoin core info ----
$btcInfo = null;
$out = shell_exec('/usr/local/bin/bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf getblockchaininfo 2>/dev/null');
if ($out) {
$btcInfo = json_decode($out, true);
}
// ---- 2) service health ----
function svc($name) {
$res = trim(shell_exec('systemctl is-active ' . escapeshellarg($name) . ' 2>/dev/null'));
return $res === 'active' ? 'active' : $res;
}
$services = [
'bitcoind' => svc('bitcoind'),
'ckpool' => svc('ckpool'),
];
// ---- 3) system info (non-sensitive) ----
// uptime
$uptime_seconds = null;
if (is_readable('/proc/uptime')) {
$up = trim(file_get_contents('/proc/uptime'));
$parts = explode(' ', $up);
$uptime_seconds = (int)floatval($parts[0]);
}
// load average
$load = function_exists('sys_getloadavg') ? sys_getloadavg() : null;
// memory
$mem_total = null;
$mem_avail = null;
if (is_readable('/proc/meminfo')) {
$meminfo = file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($meminfo as $line) {
if (strpos($line, 'MemTotal:') === 0) {
$mem_total = (int)filter_var($line, FILTER_SANITIZE_NUMBER_INT) * 1024;
} elseif (strpos($line, 'MemAvailable:') === 0) {
$mem_avail = (int)filter_var($line, FILTER_SANITIZE_NUMBER_INT) * 1024;
}
}
}
// disk (root)
$disk_root_total = @disk_total_space('/');
$disk_root_free = @disk_free_space('/');
// disk (bitcoind datadir)
$btcDir = '/var/lib/bitcoind';
$disk_btc_total = @disk_total_space($btcDir);
$disk_btc_free = @disk_free_space($btcDir);
// bitcoind dir size (used)
$btc_dir_size = null;
$du = shell_exec('du -sb ' . escapeshellarg($btcDir) . ' 2>/dev/null');
if ($du) {
$parts = preg_split('/\s+/', trim($du));
if (isset($parts[0]) && ctype_digit($parts[0])) {
$btc_dir_size = (int)$parts[0];
}
}
// CPU temperature (if available, eg. Raspberry Pi)
$cpu_temp_c = null;
foreach (glob('/sys/class/thermal/thermal_zone*/temp') as $zone) {
$raw = @file_get_contents($zone);
if ($raw === false) continue;
$raw = trim($raw);
if ($raw === '' || !is_numeric($raw)) continue;
$val = (float)$raw;
// Many systems report millidegC
if ($val > 200) $val = $val / 1000.0;
if ($val > 0 && $val < 120) {
$cpu_temp_c = $val;
break;
}
}
// ---- 4) output ----
echo json_encode([
'initialblockdownload' => $btcInfo['initialblockdownload'] ?? null,
'blocks' => $btcInfo['blocks'] ?? null,
'headers' => $btcInfo['headers'] ?? null,
'verificationprogress' => $btcInfo['verificationprogress'] ?? null,
'pruned' => $btcInfo['pruned'] ?? null,
'services' => $services,
'system' => [
'uptime_seconds' => $uptime_seconds,
'loadavg' => $load,
'mem_total_bytes' => $mem_total,
'mem_available_bytes' => $mem_avail,
'disk_root' => [
'total_bytes' => $disk_root_total,
'free_bytes' => $disk_root_free,
],
'disk_bitcoind' => [
'total_bytes' => $disk_btc_total,
'free_bytes' => $disk_btc_free,
// Prefer filesystem view (matches `df`); fall back to du if needed
'used_bytes' => ($disk_btc_total !== false && $disk_btc_free !== false)
? ($disk_btc_total - $disk_btc_free)
: $btc_dir_size,
],
'cpu_temp_c' => $cpu_temp_c,
],
], JSON_UNESCAPED_SLASHES);
EOF
# CKPool Status
# (reads CKPool JSON logs; address supplied per-request) ---
cat > /var/www/btc-solo/ckpool_status.php <<'PHP'
<?php
header('Content-Type: application/json');
$POOL_STATUS = '/opt/btc-solo/logs/pool/pool.status';
$USERS_DIR = '/opt/btc-solo/logs/users';
// BTC address is provided by the client *per request* (no server-side storage)
$addr = '';
if (isset($_GET['addr'])) {
$addr = trim($_GET['addr']);
}
// light sanity check (not strict CKPool does the real validation)
if ($addr !== '' && !preg_match('/^[13bc][a-km-zA-HJ-NP-Z1-9]{25,}$/', $addr)) {
$addr = '';
}
function read_json_lines($path) {
if (!is_readable($path)) return null;
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$objs = [];
foreach ($lines as $ln) {
$j = json_decode(trim($ln), true);
if (is_array($j)) $objs[] = $j;
}
return $objs ?: null;
}
function read_json_file($path) {
if (!is_readable($path)) return null;
$txt = file_get_contents($path);
$j = json_decode($txt, true);
return is_array($j) ? $j : null;
}
$resp = [
'source' => 'logs',
'pool' => null,
'user_addr' => $addr ?: null,
'user' => null,
'workers' => [],
'worker_count' => 0,
'error' => null,
];
// Pool summary (3 JSON lines)
$pool_objs = read_json_lines($POOL_STATUS);
if ($pool_objs) {
$resp['pool'] = [
'counts' => $pool_objs[0] ?? null, // runtime/users/workers/idle/disconnected
'hashrate' => $pool_objs[1] ?? null, // hashrate1m/5m/15m/1hr/...
'shares' => $pool_objs[2] ?? null, // accepted/rejected/bestshare/SPS...
];
} else {
$resp['error'] = 'Cannot read pool.status';
}
// User + workers (only if client supplied an address)
if ($addr !== '') {
$user_file = $USERS_DIR . '/' . $addr;
$user_obj = read_json_file($user_file);
if ($user_obj) {
$resp['user'] = $user_obj;
if (!empty($user_obj['worker']) && is_array($user_obj['worker'])) {
foreach ($user_obj['worker'] as $w) {
$resp['workers'][] = [
'workername' => $w['workername'] ?? 'unknown',
'hashrate1m' => $w['hashrate1m'] ?? null,
'hashrate5m' => $w['hashrate5m'] ?? null,
'hashrate1hr' => $w['hashrate1hr'] ?? null,
'hashrate1d' => $w['hashrate1d'] ?? null,
'hashrate7d' => $w['hashrate7d'] ?? null,
'lastshare' => $w['lastshare'] ?? null,
'shares' => $w['shares'] ?? null,
'bestshare' => $w['bestshare'] ?? null,
'bestever' => $w['bestever'] ?? null,
];
}
}
$resp['worker_count'] = count($resp['workers']);
} else {
// Not a fatal error just "no data yet" for that address
if ($resp['error'] === null) {
$resp['error'] = 'No worker stats yet for this address';
}
}
}
echo json_encode($resp);
PHP
chmod 0644 /var/www/btc-solo/ckpool_status.php
# activation page
cat > "$WWW_ROOT/activate/index.php" <<'EOF'
<?php
$flag = '/var/lib/btc-solo/.setup-complete';
if (file_exists($flag)) {
http_response_code(403);
echo "Setup already completed.";
exit;
}
$msg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$adminuser = trim($_POST['adminuser'] ?? '');
$adminpass = trim($_POST['adminpass'] ?? '');
$regen = isset($_POST['regen_cert']);
if ($adminuser !== '' && $adminpass !== '') {
// apply admin creds (writes rpcuser/rpcpassword, restarts services)
shell_exec('sudo /usr/local/sbin/btc-apply-admin.sh ' . escapeshellarg($adminuser) . ' ' . escapeshellarg($adminpass));
// optionally regenerate HTTPS cert
if ($regen) {
shell_exec('sudo /usr/local/sbin/btc-make-cert.sh');
}
// create setup-complete flag
file_put_contents($flag, date('c'));
// redirect to main dashboard
header('Location: /');
exit;
} else {
$msg = 'Both fields are required.';
}
}
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Create New Admin User</title>
<style>
body { font-family: sans-serif; background: #111; color: #eee; display:flex; justify-content:center; align-items:center; height:100vh; }
form { background: #1b1b1b; padding: 2rem; border-radius: 12px; width: 340px; }
label { display:block; margin-bottom: .5rem; }
input[type=text], input[type=password] { width:100%; padding:.5rem; margin-bottom:1rem; border:0; border-radius:6px; }
button { padding:.5rem 1rem; border:0; border-radius:6px; background:#4caf50; color:#fff; cursor:pointer; }
.msg { color: #f66; margin-bottom:1rem; }
h2 { margin-top:0; }
small { color:#aaa; }
</style>
</head>
<body>
<form method="post">
<h2>Create New Admin User</h2>
<p><small>
This sets the Bitcoin nodes administrative (RPC) credentials used to control the server itself.<br /><br />
Keep these private — they grant full access to your node.<br /><br />
These credentials are <b>not</b> used by miners to connect; miners only need your Bitcoin address and can use any password.
</small></p>
<?php if ($msg): ?>
<div class="msg"><?php echo htmlspecialchars($msg); ?></div>
<?php endif; ?>
<label>Admin Username</label>
<input type="text" name="adminuser" value="">
<label>Admin Password</label>
<input type="password" name="adminpass" value="">
<label style="display:flex;gap:.5rem;align-items:center;">
<input type="checkbox" name="regen_cert" checked>
Generate HTTPS certificate
</label>
<button type="submit">Continue</button>
</form>
</body>
</html>
EOF
# AJAX History Endpoint
cat > "$WWW_ROOT/history.php" <<'PHP'
<?php
$flag = '/var/lib/btc-solo/.setup-complete';
if (!file_exists($flag)) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'not activated']);
exit;
}
header('Content-Type: application/json');
$dbPath = '/opt/btc-solo/db/hashrate_history.sqlite';
if (!file_exists($dbPath)) {
echo json_encode(['error' => 'no history database']);
exit;
}
try {
$db = new SQLite3($dbPath, SQLITE3_OPEN_READONLY);
} catch (Exception $e) {
echo json_encode(['error' => 'cannot open history database']);
exit;
}
$mode = $_GET['mode'] ?? 'pool';
$limit = (int)($_GET['limit'] ?? 120);
if ($limit < 10) $limit = 10;
if ($limit > 720) $limit = 720; // up to 12 hours at 1/min
function parse_rate(?string $s): ?float {
if ($s === null || $s === '') return null;
if (!preg_match('/^([0-9.]+)\s*([kMGTPE]?)/i', $s, $m)) return null;
$v = (float)$m[1];
$unit = strtoupper($m[2] ?? '');
$mult = [
'' => 1,
'K' => 1e3,
'M' => 1e6,
'G' => 1e9,
'T' => 1e12,
'P' => 1e15,
'E' => 1e18,
][$unit] ?? 1;
return $v * $mult;
}
function downsample(array $points, int $limit): array {
$n = count($points);
if ($n <= $limit) return $points;
$step = (int)ceil($n / $limit);
$out = [];
for ($i = 0; $i < $n; $i += $step) {
$out[] = $points[$i];
}
return $out;
}
if ($mode === 'worker') {
$addr = $_GET['addr'] ?? '';
$worker = $_GET['worker'] ?? '';
if ($addr === '' || $worker === '') {
echo json_encode(['error' => 'missing addr or worker']);
exit;
}
$stmt = $db->prepare(
'SELECT ts,payload FROM history WHERE address = :addr ORDER BY ts DESC LIMIT :lim'
);
$rawLimit = $limit * 4;
$stmt->bindValue(':addr', $addr, SQLITE3_TEXT);
$stmt->bindValue(':lim', $rawLimit, SQLITE3_INTEGER);
$res = $stmt->execute();
$points = [];
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$ts = (int)$row['ts'];
$payload = base64_decode($row['payload'], true);
if ($payload === false) continue;
$obj = json_decode($payload, true);
if (!is_array($obj) || empty($obj['workers']) || !is_array($obj['workers'])) continue;
$match = null;
foreach ($obj['workers'] as $w) {
if (!isset($w['workername'])) continue;
if ($w['workername'] === $worker) {
$match = $w;
break;
}
}
if (!$match) continue;
$points[] = [
'ts' => $ts,
'h1m' => parse_rate($match['hashrate1m'] ?? null),
'h5m' => parse_rate($match['hashrate5m'] ?? null),
'h1h' => parse_rate($match['hashrate1hr'] ?? null),
'h1d' => parse_rate($match['hashrate1d'] ?? null),
'h7d' => parse_rate($match['hashrate7d'] ?? null),
];
}
usort($points, fn($a,$b) => $a['ts'] <=> $b['ts']);
$points = downsample($points, $limit);
echo json_encode(['mode' => 'worker', 'addr' => $addr, 'worker' => $worker, 'points' => $points]);
exit;
}
// Default: pool history
$stmt = $db->prepare(
'SELECT ts,payload FROM history WHERE address = :addr ORDER BY ts DESC LIMIT :lim'
);
$rawLimit = $limit * 4;
$stmt->bindValue(':addr', '__POOL__', SQLITE3_TEXT);
$stmt->bindValue(':lim', $rawLimit, SQLITE3_INTEGER);
$res = $stmt->execute();
$seen = [];
$points = [];
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$ts = (int)$row['ts'];
if (isset($seen[$ts])) continue;
$seen[$ts] = true;
$payload = base64_decode($row['payload'], true);
if ($payload === false) continue;
$obj = json_decode($payload, true);
if (!is_array($obj) || empty($obj['pool']['hashrate'])) continue;
$h = $obj['pool']['hashrate'];
$points[] = [
'ts' => $ts,
'h1m' => parse_rate($h['hashrate1m'] ?? null),
'h5m' => parse_rate($h['hashrate5m'] ?? null),
'h15m'=> parse_rate($h['hashrate15m'] ?? null),
'h1h' => parse_rate($h['hashrate1hr'] ?? null),
'h1d' => parse_rate($h['hashrate1d'] ?? null),
'h7d' => parse_rate($h['hashrate7d'] ?? null),
];
}
usort($points, fn($a,$b) => $a['ts'] <=> $b['ts']);
$points = downsample($points, $limit);
echo json_encode(['mode' => 'pool', 'points' => $points]);
PHP
# ---------------------------------------------------------------------------
echo "Console Dashboard"
# ---------------------------------------------------------------------------
cat > /usr/local/sbin/btc-console-dashboard <<'EOF'
#!/bin/bash
# Console dashboard for Bitcoin Solo Miner
BITCOIN_CLI="/usr/local/bin/bitcoin-cli"
BITCOIN_CONF="/etc/bitcoin/bitcoin.conf"
BTC_DIR="/var/lib/bitcoind"
# colors
RED="\033[31m"
GRN="\033[32m"
YEL="\033[33m"
BLU="\033[34m"
CYN="\033[36m"
BOLD="\033[1m"
RST="\033[0m"
CONSEC_FAILS=0
MAX_FAILS=3
LAST_INFO=""
get_ip() {
ip -4 addr show scope global 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1 | head -n1
}
make_bar() {
local pct="$1"
local width="$2"
[ -z "$pct" ] && pct=0
pct=$(printf "%.1f" "$pct")
# clamp
if (( $(echo "$pct < 0" | bc -l 2>/dev/null || echo 0) )); then pct=0; fi
if (( $(echo "$pct > 100" | bc -l 2>/dev/null || echo 0) )); then pct=100; fi
local filled=$(printf "%.0f" "$(echo "$pct / 100 * $width" | bc -l)")
local empty=$((width - filled))
printf "["
for ((i=0; i<filled; i++)); do printf "█"; done
for ((i=0; i<empty; i++)); do printf "-"; done
printf "] %s%%" "$pct"
}
draw_header() {
tput clear
tput civis 2>/dev/null || true
tput cup 0 0; echo -e "${BOLD}Bitcoin Node for Solo Miners${RST}"
tput cup 1 0; echo -e "A Linux Appliance by Robbie Ferguson"
tput cup 2 0; printf '%*s' "$(tput cols)" '' | tr ' ' '-'
}
draw_loading() {
tput cup 3 0; echo -e "${YEL}Loading node status…${RST}\033[K"
}
draw_dashboard() {
# args:
# 1 now 2 ip 3 web 4 status 5 bar 6 progress 7 services 8 cpu 9 mem 10 disk 11 workers
local now="$1"
local ip="$2"
local web="$3"
local status="$4"
local bar="$5"
local progress="$6"
local services="$7"
local cpu="$8"
local mem="$9"
local disk="${10}"
local workers="${11}"
tput cup 3 0; echo -e "Time: ${CYN}${now}${RST}\033[K"
tput cup 4 0; echo -e "IP: ${CYN}${ip}${RST}\033[K"
tput cup 5 0; echo -e "Web: ${CYN}${web}${RST}\033[K"
tput cup 6 0; printf '%*s' "$(tput cols)" '' | tr ' ' '-'
tput cup 7 0; echo -e "${status}\033[K"
tput cup 8 0; echo -e "${bar}\033[K"
tput cup 9 0; echo -e "${progress}\033[K"
tput cup 10 0; echo -e "${services}\033[K"
tput cup 11 0; printf '%*s' "$(tput cols)" '' | tr ' ' '-'
tput cup 12 0; echo -e "CPU load: ${cpu}\033[K"
tput cup 13 0; echo -e "Memory: ${mem}\033[K"
tput cup 14 0; echo -e "Disk (${BTC_DIR}): ${disk}\033[K"
tput cup 16 0; echo -e "Workers:\033[K"
tput cup 17 2; echo -e "${workers}\033[K"
tput cup 19 0; echo -e "(Appliance console view — use the web UI for full details.)\033[K"
}
draw_header
draw_loading
FIRST_DONE=0
while true; do
# -------- collect data (non-blocking) --------
NOW=$(date '+%Y-%m-%d %H:%M')
IPADDR=$(get_ip)
[ -z "$IPADDR" ] && IPADDR="No network"
WEB="https://${IPADDR}/"
BTC_STATE=$(systemctl is-active bitcoind 2>/dev/null || true)
CKP_STATE=$(systemctl is-active ckpool 2>/dev/null || true)
STATUS_LINE=""
PROGRESS_LINE=""
BAR_LINE="[--------------] 0.0%"
SERVICES_LINE="Bitcoin: ${BTC_STATE} CKPool: ${CKP_STATE}"
WORKERS_LINE="No workers detected (point your ASICs at this node)"
# cpu load
if [ -r /proc/loadavg ]; then
read -r L1 L5 L15 _ < /proc/loadavg
CPU_LINE="${L1} ${L5} ${L15}"
else
CPU_LINE="n/a"
fi
# memory
if [ -r /proc/meminfo ]; then
MEM_TOTAL=$(awk '/MemTotal:/{print $2}' /proc/meminfo)
MEM_AVAIL=$(awk '/MemAvailable:/{print $2}' /proc/meminfo)
if [ -n "$MEM_TOTAL" ] && [ -n "$MEM_AVAIL" ]; then
MEM_USED=$((MEM_TOTAL - MEM_AVAIL))
MEM_PCT=$((MEM_USED * 100 / MEM_TOTAL))
MEM_LINE="$(printf "%dMiB / %dMiB (%d%% used)" "$((MEM_USED/1024))" "$((MEM_TOTAL/1024))" "$MEM_PCT")"
else
MEM_LINE="n/a"
fi
else
MEM_LINE="n/a"
fi
# disk
if df -h "$BTC_DIR" >/dev/null 2>&1; then
read -r _ SIZE USED AVAIL USEP MNT <<< "$(df -h "$BTC_DIR" | awk 'NR==2{print $1,$2,$3,$4,$5,$6}')"
DISK_LINE="${AVAIL} free of ${SIZE} (${USEP} used)"
else
DISK_LINE="n/a"
fi
# bitcoin-cli, but don't let it hang
INFO=$(timeout 15 "$BITCOIN_CLI" -conf="$BITCOIN_CONF" getblockchaininfo 2>/dev/null)
if [ -z "$INFO" ]; then
# failed this round
CONSEC_FAILS=$((CONSEC_FAILS + 1))
# if we have an older good value, reuse it
if [ -n "$LAST_INFO" ]; then
INFO="$LAST_INFO"
fi
else
# success store and reset fail counter
LAST_INFO="$INFO"
CONSEC_FAILS=0
fi
if [ "$BTC_STATE" != "active" ]; then
STATUS_LINE="${RED}❌ Bitcoin service is not running.${RST}"
elif [ -z "$INFO" ]; then
if [ $CONSEC_FAILS -ge $MAX_FAILS ]; then
STATUS_LINE="${RED}❌ bitcoin-cli has not responded for several checks.${RST}"
BAR_LINE="[--------------] 0.0%"
PROGRESS_LINE=""
else
# transient hiccup — keep showing last good data, or at least don't scream
STATUS_LINE="${YEL}⚠ Waiting for Bitcoin Core…${RST}"
fi
else
if command -v jq >/dev/null 2>&1; then
IBD=$(echo "$INFO" | jq -r '.initialblockdownload')
BLOCKS=$(echo "$INFO" | jq -r '.blocks')
HEADERS=$(echo "$INFO" | jq -r '.headers')
PROG=$(echo "$INFO" | jq -r '.verificationprogress')
PRUNED=$(echo "$INFO" | jq -r '.pruned')
if [ "$PROG" != "null" ]; then
PROG_PCT=$(printf "%.1f" "$(echo "$PROG * 100" | bc -l 2>/dev/null || echo 0)")
else
PROG_PCT="0.0"
fi
if [ "$IBD" = "true" ]; then
if [ "$(echo "$PROG_PCT < 0.1" | bc -l 2>/dev/null || echo 0)" = "1" ]; then
STATUS_LINE="${YEL}⚠ Bitcoin Core is starting up and loading blockchain data…${RST}"
else
STATUS_LINE="${YEL}⚠ Syncing blockchain… ${PROG_PCT}%${RST}"
fi
else
STATUS_LINE="${GRN}✔ Bitcoin Core is ready.${RST}"
fi
PROGRESS_LINE="Blocks: ${BLOCKS} / ${HEADERS} $( [ "$PRUNED" = "true" ] && echo '(pruned)' )"
BAR_LINE=$(make_bar "$PROG_PCT" 30)
else
STATUS_LINE="${CYN} Bitcoin Core is active (install jq for more detail).${RST}"
fi
fi
# -------- draw --------
draw_dashboard "$NOW" "$IPADDR" "$WEB" "$STATUS_LINE" "$BAR_LINE" "$PROGRESS_LINE" "$SERVICES_LINE" "$CPU_LINE" "$MEM_LINE" "$DISK_LINE" "$WORKERS_LINE"
FIRST_DONE=1
sleep 5
done
EOF
chmod +x /usr/local/sbin/btc-console-dashboard
cat > /etc/systemd/system/btc-console-dashboard.service <<'EOF'
[Unit]
Description=Bitcoin Solo Miner Console Dashboard (dialog)
After=network-online.target
WantedBy=multi-user.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/btc-console-dashboard
StandardInput=tty
StandardOutput=tty
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
Restart=always
[Install]
WantedBy=getty.target
EOF
systemctl daemon-reload
systemctl disable getty@tty1.service --now 2>/dev/null || true
systemctl enable --now btc-console-dashboard.service
# ---------------------------------------------------------------------------
echo "NGINX with HTTPS + redirect"
# ---------------------------------------------------------------------------
mkdir -p "$CERT_DIR"
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout ${CERT_DIR}/btc-solo.key \
-out ${CERT_DIR}/btc-solo.crt \
-subj "/C=CA/ST=Ontario/L=Barrie/O=BTC-Solo/OU=IT/CN=$(hostname -f)" >/dev/null 2>&1
chmod 600 ${CERT_DIR}/btc-solo.key
cat > /etc/nginx/sites-available/btc-solo <<EOF
server {
listen 80 default_server;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
ssl_certificate ${CERT_DIR}/btc-solo.crt;
ssl_certificate_key ${CERT_DIR}/btc-solo.key;
root ${WWW_ROOT};
index index.php;
# main app: try file, dir, then index.php
location / {
try_files \$uri \$uri/ /index.php;
}
# activation: let PHP regex still handle .php
location /activate/ {
try_files \$uri /activate/index.php;
}
# PHP handler (use the generic symlink, which you have)
location ~ \.php\$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
}
EOF
ln -sf /etc/nginx/sites-available/btc-solo /etc/nginx/sites-enabled/btc-solo
rm -f /etc/nginx/sites-enabled/default || true
systemctl restart nginx
echo "[***] Setting up SQLite Databases"
# Create SQLite DB for hashrate history
if [ ! -e "$APP_ROOT/db" ]; then
mkdir -p "$APP_ROOT/db"
fi
HISTDB="$APP_ROOT/db/hashrate_history.sqlite"
if [ ! -f "$HISTDB" ]; then
sqlite3 "$HISTDB" <<'EOSQL'
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
address TEXT NOT NULL,
payload TEXT NOT NULL -- base64-encoded JSON from status.php
);
CREATE INDEX IF NOT EXISTS idx_history_addr_ts ON history(address, ts);
EOSQL
fi
chown www-data:www-data "$HISTDB" || true
# Save history script
cat > /usr/local/sbin/btc-save-history.sh <<'EOF'
#!/bin/bash
APP_ROOT="/opt/btc-solo"
HISTDB="$APP_ROOT/db/hashrate_history.sqlite"
USERS_DIR="$APP_ROOT/logs/users"
CKPOOL_PHP="/var/www/btc-solo/ckpool_status.php"
# bail quietly if prerequisites are missing
for bin in sqlite3 jq php; do
command -v "$bin" >/dev/null 2>&1 || exit 0
done
[ -f "$HISTDB" ] || exit 0
[ -d "$USERS_DIR" ] || exit 0
[ -f "$CKPOOL_PHP" ] || exit 0
TS=$(date +%s)
insert_row() {
local ts="$1" addr="$2" json="$3" b64
if base64 --help 2>&1 | grep -q '\-w'; then
b64=$(printf '%s' "$json" | base64 -w0)
else
b64=$(printf '%s' "$json" | base64)
fi
sqlite3 "$HISTDB" \
"INSERT INTO history (ts,address,payload) VALUES ($ts,'$addr','$b64');" \
2>/dev/null || true
}
# 1) Pool snapshot (no addr = global pool)
POOL_JSON=$(php "$CKPOOL_PHP" 2>/dev/null)
if [ -n "$POOL_JSON" ]; then
ERR=$(printf '%s' "$POOL_JSON" | jq -r '.error // empty' 2>/dev/null)
if [ -z "$ERR" ]; then
insert_row "$TS" "__POOL__" "$POOL_JSON"
fi
fi
# 2) Per-address snapshots for worker history
for f in "$USERS_DIR"/*; do
[ -f "$f" ] || continue
ADDR=$(basename "$f")
[ -n "$ADDR" ] || continue
JSON=$(ADDR="$ADDR" php -r '$_GET["addr"] = getenv("ADDR"); include "/var/www/btc-solo/ckpool_status.php";' 2>/dev/null)
[ -n "$JSON" ] || continue
ERR=$(printf '%s' "$JSON" | jq -r '.error // empty' 2>/dev/null)
[ -n "$ERR" ] && continue
insert_row "$TS" "$ADDR" "$JSON"
done
exit 0
EOF
chmod 0755 /usr/local/sbin/btc-save-history.sh
echo "[***] Setting up Cron Jobs..."
# Cron job for hashrate history logging
cat > /etc/cron.d/btc-save-history <<'EOF'
* * * * * www-data /usr/local/sbin/btc-save-history.sh >/dev/null 2>&1
EOF
chmod 0644 /etc/cron.d/btc-save-history
echo "Configuring Log Rotation"
cat > /etc/logrotate.d/btc-solo <<'EOF'
/opt/btc-solo/logs/*.log {
weekly
rotate 8
compress
missingok
notifempty
copytruncate
}
EOF
cat > /etc/logrotate.d/nginx <<'EOF'
/var/log/nginx/*.log {
weekly
rotate 8
compress
missingok
notifempty
copytruncate
}
EOF
# ---------------------------------------------------------------------------
echo "[***] Done"
# ---------------------------------------------------------------------------
echo
echo "================================================================"
echo " Bitcoin Solo Miner Appliance installed."
echo
echo " 1) Open: https://$(hostname -I | awk '{print $1}')/activate/"
echo " 2) Set Admin Username / Admin Password"
echo " 3) (Optional) Regenerate the cert"
echo " 4) You will be redirected to the dashboard."
echo
echo " Stratum endpoint for ASICs: stratum+tcp://$(hostname -I | awk '{print $1}'):${CKPOOL_PORT}"
echo "================================================================"