Web UI for operating cgminer rigs. Displays data fetched from cgminer_monitor and issues pool-management commands to miners via cgminer_api_client.
Screenshots are generated from a scripted harness in dev/screenshots/ — see that directory's README for how to regenerate.
- Ruby 3.2+ (4.0.2 recommended; see
.ruby-version) - A running
cgminer_monitorinstance exposing/v2/* - MongoDB (used by
cgminer_monitor, not directly by this service)
Multi-arch images (linux/amd64 + linux/arm64) are published from CI
to GHCR on every v* tag push:
docker pull ghcr.io/jramos/cgminer_manager:latest
# or pin to a specific release:
docker pull ghcr.io/jramos/cgminer_manager:1.3Run with the provided compose stack:
export SESSION_SECRET=$(ruby -rsecurerandom -e 'puts SecureRandom.hex(32)')
cp config/miners.yml.example config/miners.yml
docker compose upOpen http://localhost:3000. The Admin tab at the top of the dashboard exposes fleet operations (version / stats / devs / zero / save / restart / quit) and a raw cgminer RPC form.
Admin routes require HTTP Basic Auth by default as of 1.3.0. Set both:
export CGMINER_MANAGER_ADMIN_USER=admin
export CGMINER_MANAGER_ADMIN_PASSWORD=$(ruby -rsecurerandom -e 'puts SecureRandom.hex(24)')
docker compose upWithout credentials, cgminer_manager run fails to start with a ConfigError.
To deliberately run the open/CSRF-only posture (e.g., developer loopback or an
isolated lab network), set CGMINER_MANAGER_ADMIN_AUTH=off. docker-compose.yml
defaults to this escape hatch for dev; the e2e stack requires a password.
git clone https://github.com/jramos/cgminer_manager.git
cd cgminer_manager
bundle install
cp config/miners.yml.example config/miners.yml
# point at a running cgminer_monitor:
export CGMINER_MONITOR_URL=http://localhost:9292
export SESSION_SECRET=$(ruby -rsecurerandom -e 'puts SecureRandom.hex(32)')
bin/cgminer_manager doctor
bin/cgminer_manager runAll settings come from environment variables.
| Variable | Required | Default | Notes |
|---|---|---|---|
CGMINER_MONITOR_URL |
yes | — | Base URL for cgminer_monitor (e.g., http://localhost:9292) |
MINERS_FILE |
config/miners.yml |
YAML list of {host, port} entries (optional label for display) |
|
PORT |
3000 |
Listening port | |
BIND |
127.0.0.1 |
Listening interface | |
SESSION_SECRET |
yes in production | generated in dev | Signs session cookies (CSRF) |
CGMINER_MANAGER_ADMIN_USER |
yes by default | — | HTTP Basic Auth username for /admin/* routes. Boot fails unless this and CGMINER_MANAGER_ADMIN_PASSWORD are both set, or CGMINER_MANAGER_ADMIN_AUTH=off. |
CGMINER_MANAGER_ADMIN_PASSWORD |
yes by default | — | HTTP Basic Auth password. Valid credentials also bypass CSRF (intended for scripts / curl). |
CGMINER_MANAGER_ADMIN_AUTH |
unset | Set to off to deliberately disable admin auth (escape hatch for dev loopback / isolated lab networks). |
|
LOG_FORMAT |
text (dev), json (prod) |
||
LOG_LEVEL |
info |
debug, info, warn, error |
|
STALE_THRESHOLD_SECONDS |
300 |
Tile "updated Xm ago" warning threshold | |
SHUTDOWN_TIMEOUT |
10 |
Seconds to wait for Puma to stop | |
CGMINER_MANAGER_PID_FILE |
unset | Path where run writes the server PID on boot and unlinks on shutdown. Required for bin/cgminer_manager reload; operators who prefer can still kill -HUP <pid> directly. |
|
CGMINER_MANAGER_RESTART_SCHEDULES_FILE |
data/restart_schedules.json |
JSON file backing per-miner daily restart schedules. Mutated by the UI; the directory is created on first write. | |
CGMINER_MANAGER_RESTART_SCHEDULER |
unset | Set to off to disable the scheduler thread (the routes still mutate the file — useful when running multiple managers behind a load balancer where only one should drive restarts). |
bin/cgminer_manager run— start the server.bin/cgminer_manager doctor— verifyminers.yml, cgminer reachability, and monitor/v2/miners.bin/cgminer_manager reload— dry-run-parseminers.yml, then SIGHUP the running server (requiresCGMINER_MANAGER_PID_FILE).bin/cgminer_manager version— print version.
miners.yml is hot-reloadable — add, remove, or re-label a miner, then
either kill -HUP $(cat $CGMINER_MANAGER_PID_FILE) or
bin/cgminer_manager reload. The server logs event=reload.ok on
success or event=reload.failed (and keeps the old list) if the new
file fails to parse. Only settings.configured_miners reloads; other
Config fields (CGMINER_MONITOR_URL, SESSION_SECRET, log level,
etc.) still require a full restart to change.
| Code | Meaning |
|---|---|
0 |
Clean shutdown (run), all checks passed (doctor), or normal completion (version). |
1 |
doctor: at least one check failed. |
2 |
Configuration error (missing CGMINER_MONITOR_URL, unreadable miners.yml, invalid LOG_FORMAT/LOG_LEVEL, etc.). |
64 |
Unknown or missing CLI verb (EX_USAGE-ish). |
The gem's error taxonomy (all under CgminerManager::Error < StandardError):
CgminerManager::ConfigError— configuration validation failed at boot. The CLI translates this to exit 2.CgminerManager::MonitorError::ConnectionError— couldn't reachcgminer_monitor(DNS, refused, timeout). Renders a "data source unavailable" banner on the dashboard; failsdoctor.CgminerManager::MonitorError::ApiError— monitor answered with a non-2xx response. Carriesstatus:andbody:. Same UI behavior asConnectionError.CgminerManager::PoolManagerError::DidNotConverge— a pool operation's post-write verification query saw an unexpected state. Caught internally and surfaced as:indeterminatein the per-miner result row (not raised to the caller).
GET /— dashboard (Summary / Miner Pool / Admin tabs).GET /miner/:miner_id— per-miner page (Miner / Devs / Pools / Stats / Admin tabs).:miner_idis URL-encodedhost:port.GET /graph_data/:metric— aggregate graph data across all miners. Returns a JSON array of rows.GET /miner/:miner_id/graph_data/:metric— per-miner graph data, same shape.POST /manager/manage_pools,POST /miner/:miner_id/manage_pools— pool management commands (CSRF-protected).POST /manager/admin/:command— typed fleet admin (version,stats,devs,zero,save,restart,quit). CSRF-protected; Basic Auth required by default (or=off).POST /miner/:miner_id/admin/:command— per-miner variant of the above.POST /manager/admin/run— raw cgminer RPC withcommand+args+scopeparams;scopeisallor a configuredhost:port. Server-side rejects hardware-tuning verbs (pgaset,ascset,pgarestart,ascrestart,pga{enable,disable},asc{enable,disable}) withscope=all.POST /miner/:miner_id/admin/run— raw RPC against a single miner (no scope=all restriction).GET /miner/:miner_id/maintenance— render the per-miner scheduled-restart form. Basic Auth required.POST /miner/:miner_id/maintenance— persist the schedule (CSRF + Basic Auth + rate-limited).GET /api/v1/ping.json— legacy probe, returns{timestamp, available_miners, unavailable_miners}computed directly from cgminers.GET /api/v1/restart_schedules.json— public read of every miner's restart schedule. Consumed bycgminer_monitorto suppressofflinealerts during a scheduled restart window.GET /healthz— service health (manager + monitor reachability).
Supported graph metrics: hashrate (7 columns), temperature (4 columns), availability (2-3 columns).
POST /manager/admin/run passes args to cgminer_api_client's Miner#query after split(',') on the raw string. Commas inside argument values are not escapable through this form — the split happens before the gem's own escape pass. This is not a practical limitation for any cgminer verb in common use (pgaset/ascset take numeric or option-name args without commas), and the typed manage_pools endpoints handle pool-related commands with credentials that may contain commas.
bundle install
bundle exec rake # rubocop + rspecDefault bind is 127.0.0.1. The service is designed for secure local networks; to expose it beyond localhost, put it behind a reverse proxy that provides authentication.
The Admin surface (/manager/admin/*, /miner/:id/admin/*) is CSRF-protected for the browser path and required to be gated by HTTP Basic Auth by default as of 1.3.0. Boot fails unless CGMINER_MANAGER_ADMIN_USER and CGMINER_MANAGER_ADMIN_PASSWORD are both set, or CGMINER_MANAGER_ADMIN_AUTH=off is set to deliberately disable. Valid Basic Auth bypasses CSRF — a static credential is strictly stronger proof than a session cookie + CSRF token, and this lets operators curl admin routes during incidents. bin/cgminer_manager doctor reports the active posture so audits can confirm which deployments are gated.
The typed admin button list (version/stats/devs/zero/save/restart/quit) is ergonomic, not defensive: anyone who can reach /manager/admin/run can execute any cgminer verb. The defensive layers are:
- Basic Auth via the env vars above.
- Scope restrictions on hardware-tuning verbs (
pgaset/ascset/pgarestart/ascrestart/pga{enable,disable}/asc{enable,disable}) — the server refusesscope=allfor these and the UI disables thealloption when the command input matches. - Per-command audit logging (
admin.command,admin.raw_command,admin.result,admin.auth_failed,admin.auth_misconfigured,admin.scope_rejected) with arequest_idUUID threading entry and exit events for any given POST.
Basic Auth transmits credentials base64-encoded (reversible), so terminate TLS at a reverse proxy in any deployment where the UI is reachable beyond localhost.
Fleet-wide destructive admin POSTs require a two-step confirmation by default. A single misclick on Restart can no longer restart the whole fleet in one request: the first POST returns 202 + a 2-minute single-use token, and a separate POST /manager/admin/confirm/:token consumes the token and dispatches the originally-pinned action.
Routes that gate (when CGMINER_MANAGER_REQUIRE_CONFIRM is on, the default):
| Route | Verbs |
|---|---|
POST /manager/admin/:command |
restart, quit, zero, save (the four typed-allowlist writes — read-only version/stats/devs always skip) |
POST /manager/admin/run |
every command at scope=all (raw RPC; per-miner scopes skip) |
POST /manager/manage_pools |
every action (disable, enable, remove, add) |
Per-miner destructive routes (/miner/:id/admin/*, /miner/:id/manage_pools) always skip the gate — the blast radius is one rig, and the existing browser confirm dialog is the guardrail there.
Per-curl bypass. Append ?auto_confirm=1 to the destructive POST URL to execute in one step. The bypass emits an admin.action_auto_confirmed audit-log line so the trail captures who skipped the dance and why:
# Two-step (default):
curl -u admin:pw -X POST http://localhost:3000/manager/admin/restart
# → 202 Accepted + {"confirmation_token":"…", "expires_at":"…", …}
curl -u admin:pw -X POST http://localhost:3000/manager/admin/confirm/<token>
# → 200 + the fleet-write result
# Single-step bypass:
curl -u admin:pw -X POST 'http://localhost:3000/manager/admin/restart?auto_confirm=1'
# → 200 + the fleet-write result; admin.action_auto_confirmed in the audit logPer-deployment opt-out. Set CGMINER_MANAGER_REQUIRE_CONFIRM=off to disable the flow globally. Useful for CI fleets where every script would otherwise need updating.
Audit events. Five new events under the admin.action_* namespace:
| Event | Level | Emitted on |
|---|---|---|
admin.action_started |
info | Step 1 — token issued |
admin.action_confirmed |
info | Step 2 — token consumed, dispatch about to fire |
admin.action_auto_confirmed |
info | ?auto_confirm=1 skipped step 1 |
admin.action_cancelled |
info | DELETE /confirm/:token (Cancel button) |
admin.action_rejected |
warn | Step 2 failed; carries reason: Symbol (expired / session_mismatch / evicted / not_found) |
admin.command and admin.result still emit on dispatch; the new events wrap rather than replace the existing audit trail.
Posture gotchas:
AUTH=off+REQUIRE_CONFIRM=onis fail-closed. Destructive POSTs return 503 with a body explaining the misalignment — admin auth is required for the session-binding defense to function. SetCGMINER_MANAGER_REQUIRE_CONFIRM=offto align the two knobs in dev mode. A boot-time warn surfaces this pre-request.- Cluster-mode Puma is unsafe. Tokens live in a process-local store (same as RateLimiter); a worker hop between step 1 and step 2 silently drops legitimate confirmations. A boot-time warn fires when
WEB_CONCURRENCY > 1. Single-worker deployment is the supported posture until shared-store support lands. - Pool credentials are redacted in the audit log.
manage_pools/addactions persist URL+user+password in the in-memory entry so step 2 dispatches verbatim, but the audit-logargsfield is"[REDACTED: pool credentials]". Raw/runargs pass through unredacted (operator on the hook for what they typed).
Stop a single rig from hashing without restarting it. Pool 0 gets disabled (disablepool 0); the rig stays responsive on the cgminer API for diagnosis. Resume calls enablepool 0. Useful for swapping a fan, investigating thermal issues, or pulling a rig for maintenance without losing accumulated runtime state.
Per-miner only — fleet-wide drain is intentionally not exposed (the operator workflow that needs it is rare, and a process restart between drain and resume could leave half the fleet idle indefinitely).
| Endpoint | Effect |
|---|---|
POST /miner/:id/maintenance/drain |
Calls disablepool 0, persists drained: true, browser confirm() prompts before submission |
POST /miner/:id/maintenance/resume |
Calls enablepool 0, clears drain state |
The maintenance partial on the miner detail page surfaces both buttons + a "Currently draining since X by Y" status block when drained.
Auto-resume. The RestartScheduler thread runs a pre-pass each tick: any drained miner whose now - drained_at >= CGMINER_MANAGER_DRAIN_AUTO_RESUME_SECONDS (default 3600) gets enablepool 0 issued and the drain cleared. Wire-call failures apply exponential-with-cap backoff (60-minute cap); after 5 consecutive failures the scheduler emits drain.auto_resume_giving_up once at error level and keeps retrying at the cap with drain.failed warns. The pre-pass runs BEFORE the schedule-firing pass, so a drain that ages out into a restart window correctly fires the restart on the same tick.
Audit events (collapsed per cause: discriminator):
| Event | Level | Notes |
|---|---|---|
drain.applied |
info | Drain succeeded; carries auto_resume_seconds (operator intent at drain time) |
drain.resumed |
info | Drain cleared; cause: is :operator, :auto_resume, or :auto_resume_orphan_cleared (rig removed from miners.yml mid-drain) |
drain.failed |
warn | Wire call :failed; cause: distinguishes :drain / :resume / :auto_resume |
drain.indeterminate |
warn | Wire call :indeterminate (verification timed out — operator should verify rig state) |
drain.auto_resume_giving_up |
error | One-shot after 5 consecutive auto-resume failures |
Drain suppresses cgminer_monitor's offline alert. Requires cgminer_monitor ≥ 1.5.0; older monitors will treat drained rigs as offline and page the operator. The cross-repo wire is the existing /api/v1/restart_schedules.json endpoint, which auto-extends with the new drain fields.
Cluster-mode caveat. Drain state lives in the same atomic-rename JSON file as RestartStore — single-Puma-process safe. Multi-worker Puma deployments may see a ~30-second propagation lag between a drain POST landing on worker B and the scheduler-running worker A's next file-read tick.
As of 1.5.0, POSTs to admin + write paths (/manager/admin/*, /miner/:id/admin/*, /manager/manage_pools, /miner/:id/manage_pools) are throttled to 60 requests / 60 seconds per client IP. Anything over the limit receives 429 Too Many Requests with a Retry-After header. The limiter sits above Basic Auth, so 401-probing attackers are throttled before AdminAuth ever runs.
Tuning env vars:
CGMINER_MANAGER_RATE_LIMIT=off— disable entirely (escape hatch; mirrors the admin-auth pattern).CGMINER_MANAGER_RATE_LIMIT_REQUESTS— default60.CGMINER_MANAGER_RATE_LIMIT_WINDOW_SECONDS— default60.
bin/cgminer_manager doctor reports the active limits.
Behind nginx / a reverse proxy: without proxy-trust config, every request appears to come from the proxy's IP and the limiter throttles the whole site globally. Set CGMINER_MANAGER_TRUSTED_PROXIES to the proxy's IP or CIDR (comma-separated list, e.g. 127.0.0.1/32,10.0.0.0/8); the limiter then consults X-Forwarded-For and keys the bucket on the leftmost untrusted hop (the actual client). Pair with nginx config:
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:3000;
}
Implementation is a single-Puma-process in-memory bucket (Hash + Mutex). Cluster-mode Puma deployments would need a shared store (Redis or similar) that the bundled middleware intentionally does not include.
cgminer_manager emits structured JSON to stdout; durable storage, rotation, and retention are the deployer's responsibility (systemd journald, Docker logging driver, or a log shipper like Vector / Fluent-Bit). Filter audit events with event=admin.* OR event=rate_limit.exceeded — the latter catches unauthenticated 401-probing because the rate limiter sits above the auth gate. See docs/logging.md for systemd / Docker / Vector recipes and cgminer_monitor/docs/log_schema.md for the cross-repo log contract.
CHANGELOG.md— release history: 1.0 Sinatra rewrite, 1.1 rich UI restoration, 1.2 admin surface restoration.MIGRATION.md— step-by-step upgrade from the 0.x Rails engine era.AGENTS.md— context for AI coding assistants; also a useful conventions-and-extension guide for human contributors.docs/— topic-split deep dives on architecture, components, interfaces, data models, workflows, and dependencies. Start withdocs/index.md.cgminer_monitorandcgminer_api_client— the upstream gems this service consumes. Operators frequently need to cross-reference them.
If you find this application useful, please consider donating.
BTC: bc1q00genlpcpcglgd4rezqcurf4t4taz0acmm9vea
MIT. See LICENSE.txt.



