From 4b28f760c58577b7c649820ea6eefbe524908c61 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 9 May 2026 16:41:33 +0100 Subject: [PATCH] Use Umbrel DNS for Datum polling --- .env.example | 8 +-- README.md | 10 +-- apps/api/src/config.ts | 6 +- apps/api/src/datum/poller.ts | 134 +---------------------------------- docker-compose.yml | 24 ++++--- 5 files changed, 23 insertions(+), 159 deletions(-) diff --git a/.env.example b/.env.example index e985511..1388898 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,9 @@ LOG_LEVEL=info # STATIC_DIR= # ---- Datum gateway (the Umbrel app we're polling) ---- -# 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's service alias on Umbrel's shared app network. Override if your Umbrel +# install exposes a different DNS name. +DATUM_URL=http://datum:21000 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 f6406c8..a8a09d7 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,11 @@ Allowlist of npubs is set via `NOSTR_ALLOWED_NPUBS`. Anything else is rejected b - Compose path: `docker-compose.yml` - Add env vars (see `.env.example`) - 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. + By default the stack uses `http://datum:21000` on Umbrel's + `umbrel_main_network`, so Datum can be recreated without changing a stale + IP address. + - If your Umbrel install exposes Datum under a different DNS name, set + `DATUM_URL=http://:21000`. 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 5c1ada2..d006823 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -15,8 +15,6 @@ const RawEnv = z.object({ 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), @@ -55,12 +53,10 @@ export const config = { corsOrigin: parsed.CORS_ORIGIN, staticDir: parsed.STATIC_DIR, datum: { - url: (parsed.DATUM_URL ?? "http://datum_datum_1:21000").replace(/\/$/, ""), + url: (parsed.DATUM_URL ?? "http://datum: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 9d0b11e..b7874ee 100644 --- a/apps/api/src/datum/poller.ts +++ b/apps/api/src/datum/poller.ts @@ -3,10 +3,8 @@ 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, @@ -100,124 +98,6 @@ type MempoolHashrate = { currentDifficulty?: number; }; -type DockerInspect = { - NetworkSettings?: { - Networks?: Record; - }; -}; - -type DockerContainerSummary = { - Id: string; - Names?: string[]; - Image?: string; - Labels?: Record; - State?: string; -}; - -let lastResolvedDatumUrl: string | null = null; - -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 inspectDatumContainer(container); - const address = selectNetworkAddress(inspected, 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", - ); - if (!lastResolvedDatumUrl) throw err; - } - - return lastResolvedDatumUrl ?? config.datum.url; -} - -async function inspectDatumContainer(container: string): Promise { - try { - return await dockerGetJson(`/containers/${encodeURIComponent(container)}/json`); - } catch (err) { - logger.warn({ reason: formatErr(err), container }, "datum_named_container_inspect_failed"); - } - - const containers = await dockerGetJson("/containers/json?all=1"); - const candidates = containers - .filter(isLikelyDatumContainer) - .sort((a, b) => Number(b.State === "running") - Number(a.State === "running")); - - for (const candidate of candidates) { - try { - return await dockerGetJson(`/containers/${candidate.Id}/json`); - } catch (err) { - logger.warn({ reason: formatErr(err), id: candidate.Id, names: candidate.Names }, "datum_candidate_inspect_failed"); - } - } - - throw new Error( - `Could not find a Datum container through Docker; checked ${containers.length} containers and expected ${container}`, - ); -} - -function isLikelyDatumContainer(container: DockerContainerSummary): boolean { - const names = (container.Names ?? []).join(" ").toLowerCase(); - const image = (container.Image ?? "").toLowerCase(); - const labels = Object.values(container.Labels ?? {}).join(" ").toLowerCase(); - const haystack = `${names} ${image} ${labels}`; - - if (haystack.includes("gashboard")) return false; - return ( - image.includes("retropex/datum") || - image.includes("datum_gateway") || - names.includes("datum") || - labels.includes("datum") - ); -} - -function selectNetworkAddress(inspected: DockerInspect, preferredNetwork: string): string { - const networks = inspected.NetworkSettings?.Networks ?? {}; - const preferred = networks[preferredNetwork]?.IPAddress; - if (preferred) return preferred; - - const entries = Object.entries(networks).filter(([, value]) => value.IPAddress); - const umbrel = entries.find(([name]) => name.toLowerCase().includes("umbrel")); - const first = umbrel ?? entries[0]; - if (first?.[1].IPAddress) return first[1].IPAddress; - - const networkNames = Object.keys(networks).join(", ") || "none"; - throw new Error(`Datum container has no Docker network IP; networks: ${networkNames}`); -} - async function fetchNetworkStats(): Promise { const timeout = abortableSignal(DEFAULT_TIMEOUT_MS); try { @@ -246,19 +126,7 @@ async function fetchNetworkStats(): Promise { async function pollOnce(): Promise { const fetchedAt = Date.now(); - let datumUrl: string; - try { - datumUrl = await resolveDatumUrl(); - } catch (err) { - const reason = formatErr(err); - logger.warn({ reason }, "datum_discovery_failed"); - return { - ...lastSnapshot, - ok: false, - fetchedAt, - error: { code: "DATUM_DISCOVERY_FAILED", message: reason }, - }; - } + const datumUrl = config.datum.url; // /clients (admin Digest-gated) and /threads (public) in parallel. const clientsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS); diff --git a/docker-compose.yml b/docker-compose.yml index c544f33..949edad 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. # -# Uses the Docker socket to discover Datum's current IP on Umbrel's shared -# Docker network, avoiding stale hard-coded container IPs. +# Joins Umbrel's shared Docker network so the API can reach Datum through +# Docker DNS instead of a changing container IP. services: gashboard: @@ -21,11 +21,9 @@ services: PORT: "1337" LOG_LEVEL: "${LOG_LEVEL:-info}" CORS_ORIGIN: "${CORS_ORIGIN:-}" - # 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's service alias on Umbrel's shared app network. Override this if + # your Umbrel install exposes a different DNS name. + DATUM_URL: "${DATUM_URL:-http://datum:21000}" 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}" @@ -33,13 +31,17 @@ services: NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}" JWT_SECRET: "${JWT_SECRET?must be set}" JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}" - network_mode: host - user: "0:0" - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - "${PORT:-1337}:1337" + networks: + - umbrel_main_network healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1337/healthz"] interval: 30s timeout: 5s retries: 3 start_period: 15s + +networks: + umbrel_main_network: + external: true