diff --git a/.env.example b/.env.example index a2f32db..e985511 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,11 @@ LOG_LEVEL=info # STATIC_DIR= # ---- Datum gateway (the Umbrel app we're polling) ---- -# Use Docker DNS on Datum's app network so Datum can be recreated without -# changing a stale container IP. -DATUM_URL=http://datum:21000 +# Fallback URL. In Docker deployment, DATUM_DOCKER_* below lets the API discover +# Datum's current container IP before polling. +DATUM_URL=http://datum_datum_1:21000 +DATUM_DOCKER_CONTAINER=datum_datum_1 +DATUM_DOCKER_NETWORK=umbrel_main_network DATUM_ADMIN_USER=admin DATUM_ADMIN_PASSWORD= # How often to scrape /clients (ms). Datum updates per-worker hashrate every diff --git a/README.md b/README.md index c5d6380..f6406c8 100644 --- a/README.md +++ b/README.md @@ -46,15 +46,12 @@ Allowlist of npubs is set via `NOSTR_ALLOWED_NPUBS`. Anything else is rejected b - Repository URL: `https://git.tx1138.com/lfg2025/gashboard` - Compose path: `docker-compose.yml` - Add env vars (see `.env.example`) - - Remove any old `DATUM_URL=http://10.21...` or - `DATUM_URL=http://datum_datum_1:21000` value from the stack env vars. - Leave `DATUM_URL` unset unless your Datum container name is different. By - default the stack uses `http://datum:21000` on Datum's `datum_default` - app network, so Datum can be recreated without changing a stale - IP address. - - If your Umbrel install uses a different Datum app network or service name, - update `datum_default` in `docker-compose.yml` or set - `DATUM_URL=http://:21000`. + - Remove any old `DATUM_URL=http://10.21...` value from the stack env vars. + By default the stack reads Docker metadata for `datum_datum_1` on + `umbrel_main_network` and polls Datum at its current container IP, so Datum + can be recreated without changing a stale IP address. + - If your Umbrel install uses a different Datum container or network name, + set `DATUM_DOCKER_CONTAINER` or `DATUM_DOCKER_NETWORK` accordingly. 4. Open the dashboard, log in with one of the allowed npubs, watch your boards lose at hashing in style. diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 3dd9b8e..5c1ada2 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -11,10 +11,12 @@ const RawEnv = z.object({ CORS_ORIGIN: z.string().optional(), STATIC_DIR: z.string().optional(), - DATUM_URL: z.string().url(), + DATUM_URL: z.string().url().optional(), DATUM_ADMIN_USER: z.string().default("admin"), DATUM_ADMIN_PASSWORD: z.string().min(1), DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000), + DATUM_DOCKER_CONTAINER: z.string().min(1).optional(), + DATUM_DOCKER_NETWORK: z.string().min(1).optional(), MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"), NOSTR_ALLOWED_NPUBS: z.string().min(1), @@ -53,10 +55,12 @@ export const config = { corsOrigin: parsed.CORS_ORIGIN, staticDir: parsed.STATIC_DIR, datum: { - url: parsed.DATUM_URL.replace(/\/$/, ""), + url: (parsed.DATUM_URL ?? "http://datum_datum_1:21000").replace(/\/$/, ""), adminUser: parsed.DATUM_ADMIN_USER, adminPassword: parsed.DATUM_ADMIN_PASSWORD, pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS, + dockerContainer: parsed.DATUM_DOCKER_CONTAINER, + dockerNetwork: parsed.DATUM_DOCKER_NETWORK, }, mempool: { url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""), diff --git a/apps/api/src/datum/poller.ts b/apps/api/src/datum/poller.ts index a6265d6..d16dc6a 100644 --- a/apps/api/src/datum/poller.ts +++ b/apps/api/src/datum/poller.ts @@ -3,8 +3,10 @@ import { logger } from "../logger.js"; import { digestFetch } from "./digest.js"; import { parseClientsHtml, parseThreadsHtml } from "./parse.js"; import type { CurrentJob, DatumSnapshot, NetworkStat, PoolStat } from "./types.js"; +import http from "node:http"; const DEFAULT_TIMEOUT_MS = 10_000; +const DOCKER_SOCKET = "/var/run/docker.sock"; const EMPTY_POOL: PoolStat = { combinedHashrateThs: 0, @@ -98,6 +100,64 @@ type MempoolHashrate = { currentDifficulty?: number; }; +type DockerInspect = { + NetworkSettings?: { + Networks?: Record; + }; +}; + +let lastResolvedDatumUrl = config.datum.url; + +function dockerGetJson(path: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { socketPath: DOCKER_SOCKET, path, method: "GET", timeout: DEFAULT_TIMEOUT_MS }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`Docker API returned ${res.statusCode ?? "unknown"} for ${path}: ${body}`)); + return; + } + try { + resolve(JSON.parse(body) as T); + } catch (err) { + reject(err); + } + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("docker_socket_timeout"))); + req.end(); + }); +} + +async function resolveDatumUrl(): Promise { + const container = config.datum.dockerContainer; + const network = config.datum.dockerNetwork; + if (!container || !network) return config.datum.url; + + try { + const inspected = await dockerGetJson(`/containers/${encodeURIComponent(container)}/json`); + const address = inspected.NetworkSettings?.Networks?.[network]?.IPAddress; + if (!address) { + throw new Error(`container ${container} has no IP on network ${network}`); + } + const port = new URL(config.datum.url).port || "21000"; + lastResolvedDatumUrl = `http://${address}:${port}`; + } catch (err) { + logger.warn( + { reason: formatErr(err), container, network, fallbackUrl: lastResolvedDatumUrl }, + "datum_docker_discovery_failed", + ); + } + + return lastResolvedDatumUrl; +} + async function fetchNetworkStats(): Promise { const timeout = abortableSignal(DEFAULT_TIMEOUT_MS); try { @@ -126,6 +186,7 @@ async function fetchNetworkStats(): Promise { async function pollOnce(): Promise { const fetchedAt = Date.now(); + const datumUrl = await resolveDatumUrl(); // /clients (admin Digest-gated) and /threads (public) in parallel. const clientsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS); @@ -134,18 +195,18 @@ async function pollOnce(): Promise { try { const [clientsResSettled, threadsResSettled] = await Promise.allSettled([ digestFetch({ - url: `${config.datum.url}/clients`, + url: `${datumUrl}/clients`, creds: { username: config.datum.adminUser, password: config.datum.adminPassword }, signal: clientsTimeout.signal, }), - fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }), + fetch(`${datumUrl}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }), ]); clientsTimeout.cancel(); threadsTimeout.cancel(); if (clientsResSettled.status === "rejected") { const reason = formatErr(clientsResSettled.reason); - logger.warn({ reason, url: `${config.datum.url}/clients` }, "datum_clients_fetch_failed"); + logger.warn({ reason, url: `${datumUrl}/clients` }, "datum_clients_fetch_failed"); return { ...lastSnapshot, ok: false, @@ -168,7 +229,7 @@ async function pollOnce(): Promise { if (clientsRes.status !== 200) { const message = datumHttpMessage(clientsRes, "/clients"); logger.warn( - { status: clientsRes.status, url: `${config.datum.url}/clients`, location: clientsRes.headers.get("location") }, + { status: clientsRes.status, url: `${datumUrl}/clients`, location: clientsRes.headers.get("location") }, "datum_clients_bad_status", ); return { @@ -183,7 +244,7 @@ async function pollOnce(): Promise { if (isUmbrelShellHtml(clientsHtml)) { const message = "DATUM_URL returned the Umbrel web shell, not Datum /clients; use the Datum container admin API URL"; - logger.warn({ url: `${config.datum.url}/clients` }, "datum_clients_wrong_html"); + logger.warn({ url: `${datumUrl}/clients` }, "datum_clients_wrong_html"); return { ...lastSnapshot, ok: false, @@ -203,7 +264,7 @@ async function pollOnce(): Promise { logger.warn( { status: threadsResSettled.value.status, - url: `${config.datum.url}/threads`, + url: `${datumUrl}/threads`, location: threadsResSettled.value.headers.get("location"), }, "datum_threads_redirected", @@ -219,7 +280,7 @@ async function pollOnce(): Promise { const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : []; if (miners.length === 0) { logger.warn( - { url: `${config.datum.url}/clients`, bytes: clientsHtml.length }, + { url: `${datumUrl}/clients`, bytes: clientsHtml.length }, "datum_clients_no_miners_parsed", ); } diff --git a/docker-compose.yml b/docker-compose.yml index 2c67cf6..c544f33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ # gashboard — deploy as a Portainer Stack on the same Umbrel host that runs Datum. # -# Join Datum's app network so the API can reach Datum by its stable Compose -# service DNS name instead of a changing container IP. +# Uses the Docker socket to discover Datum's current IP on Umbrel's shared +# Docker network, avoiding stale hard-coded container IPs. services: gashboard: @@ -21,9 +21,11 @@ services: PORT: "1337" LOG_LEVEL: "${LOG_LEVEL:-info}" CORS_ORIGIN: "${CORS_ORIGIN:-}" - # Reach the Datum gateway container through Docker DNS on Datum's app - # network. This avoids hard-coding Datum's changing container IP. - DATUM_URL: "${DATUM_URL:-http://datum:21000}" + # Fallback URL. With DATUM_DOCKER_* set below, the API resolves Datum's + # current container IP before polling. + DATUM_URL: "${DATUM_URL:-http://datum_datum_1:21000}" + DATUM_DOCKER_CONTAINER: "${DATUM_DOCKER_CONTAINER:-datum_datum_1}" + DATUM_DOCKER_NETWORK: "${DATUM_DOCKER_NETWORK:-umbrel_main_network}" DATUM_ADMIN_USER: "${DATUM_ADMIN_USER:-admin}" DATUM_ADMIN_PASSWORD: "${DATUM_ADMIN_PASSWORD?must be set}" DATUM_POLL_INTERVAL_MS: "${DATUM_POLL_INTERVAL_MS:-5000}" @@ -31,17 +33,13 @@ services: NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}" JWT_SECRET: "${JWT_SECRET?must be set}" JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}" - ports: - - "${PORT:-1337}:1337" - networks: - - datum_default + network_mode: host + user: "0:0" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1337/healthz"] interval: 30s timeout: 5s retries: 3 start_period: 15s - -networks: - datum_default: - external: true