feat(api): nostr nip-98 login, jwt sessions, datum digest poller
Backend in @gashboard/api (Express 5 + TS, ~1.2k LoC):
Auth (NIP-98 over HTTP, lifted from indeehub pattern):
- Client signs a kind-27235 event with method+URL, base64s as
Authorization: Nostr <event>. Server verifies sig, freshness
(±120s), method/URL tags via constant-time string compare.
- npub allowlist decoded to hex once at boot, fail-closed if any
entry is malformed or list is empty.
- HS256 JWT sessions returning {token, npub, expiresAt}.
- express-rate-limit on POST /api/auth/login (10/min/IP).
Datum integration (the trickier half):
- HTTP Digest *SHA-256* client (community-fork Datum uses sha-256,
not md5; node has no first-class support — hand-rolled in
digest.ts: parse challenge → ha1=sha256(user:realm:pw),
ha2=sha256(method:URI), response=sha256(...) → retry).
- HTML parsers for /clients (per-worker) and /threads (auth-less
fallback) using node-html-parser.
- Profile matcher: UserAgent contains "NerdQAxe" → NerdQAxe;
else worker-name suffix on auth username → workerNameMatchers.
Live UA strings observed: NerdQAxe self-IDs; Bitaxe / Avalon
Nano 3 / Avalon Mini 3 all report cgminer/4.11.1, must match
via workername.
- 5s poll interval, 10s AbortController timeout per upstream call,
in-memory snapshot, /api/datum/stats + SSE /api/datum/stream.
Hardened-by-default Express setup:
helmet CSP (frame-ancestors 'none', script-src 'self'),
pino with redaction (auth header, *.password, *.token, *.jwt,
*.sig), AppError class + central errorHandler, zod env validation,
graceful shutdown on SIGTERM/SIGINT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
39
apps/api/src/auth/jwt.ts
Normal file
39
apps/api/src/auth/jwt.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export type SessionPayload = {
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type IssuedSession = {
|
||||
token: string;
|
||||
npub: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export function issueSession(hexPubkey: string): IssuedSession {
|
||||
const issuedAt = Math.floor(Date.now() / 1000);
|
||||
const expiresAt = issuedAt + config.jwt.ttlSeconds;
|
||||
const npub = nip19.npubEncode(hexPubkey);
|
||||
const token = jwt.sign(
|
||||
{ pubkey: hexPubkey, npub, iat: issuedAt, exp: expiresAt },
|
||||
config.jwt.secret,
|
||||
{ algorithm: "HS256" },
|
||||
);
|
||||
return { token, npub, expiresAt };
|
||||
}
|
||||
|
||||
export function verifySession(token: string): SessionPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret, { algorithms: ["HS256"] });
|
||||
if (typeof decoded === "string") return null;
|
||||
if (typeof decoded.pubkey !== "string" || typeof decoded.npub !== "string") return null;
|
||||
return decoded as SessionPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
21
apps/api/src/auth/middleware.ts
Normal file
21
apps/api/src/auth/middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { unauthorized } from "../errors.js";
|
||||
import { verifySession } from "./jwt.js";
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
interface Request {
|
||||
session?: { pubkey: string; npub: string };
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, _res: Response, next: NextFunction): void {
|
||||
const header = req.header("authorization");
|
||||
const match = header?.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) return next(unauthorized("no_session"));
|
||||
|
||||
const session = verifySession(match[1]!.trim());
|
||||
if (!session) return next(unauthorized("bad_session"));
|
||||
|
||||
req.session = { pubkey: session.pubkey, npub: session.npub };
|
||||
next();
|
||||
}
|
||||
50
apps/api/src/auth/routes.ts
Normal file
50
apps/api/src/auth/routes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { verifyNip98 } from "../nostr/nip98.js";
|
||||
import { isPubkeyAllowed } from "../nostr/allowlist.js";
|
||||
import { issueSession } from "./jwt.js";
|
||||
import { requireAuth } from "./middleware.js";
|
||||
import { unauthorized, forbidden } from "../errors.js";
|
||||
import { logger } from "../logger.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
limit: 10,
|
||||
standardHeaders: "draft-7",
|
||||
legacyHeaders: false,
|
||||
message: { error: { code: "rate_limited", message: "Too many login attempts." } },
|
||||
});
|
||||
|
||||
function fullUrl(req: import("express").Request): string {
|
||||
const proto = (req.headers["x-forwarded-proto"] as string | undefined)?.split(",")[0] ?? req.protocol;
|
||||
const host = (req.headers["x-forwarded-host"] as string | undefined) ?? req.headers.host;
|
||||
return `${proto}://${host}${req.originalUrl}`;
|
||||
}
|
||||
|
||||
authRouter.post("/login", loginLimiter, (req, res, next) => {
|
||||
const result = verifyNip98({
|
||||
authHeader: req.headers.authorization,
|
||||
method: req.method,
|
||||
fullUrl: fullUrl(req),
|
||||
});
|
||||
if (!result.ok) {
|
||||
logger.warn({ reason: result.reason }, "nip98_rejected");
|
||||
return next(unauthorized("nip98_failed"));
|
||||
}
|
||||
if (!isPubkeyAllowed(result.pubkey)) {
|
||||
logger.warn({ pub: `${result.pubkey.slice(0, 8)}…` }, "pubkey_not_allowlisted");
|
||||
return next(forbidden("not_allowlisted"));
|
||||
}
|
||||
const session = issueSession(result.pubkey);
|
||||
res.json({
|
||||
token: session.token,
|
||||
npub: session.npub,
|
||||
expiresAt: session.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
authRouter.get("/me", requireAuth, (req, res) => {
|
||||
res.json({ npub: req.session!.npub });
|
||||
});
|
||||
67
apps/api/src/config.ts
Normal file
67
apps/api/src/config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { z } from "zod";
|
||||
|
||||
const RawEnv = z.object({
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||
LOG_LEVEL: z
|
||||
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
|
||||
.default("info"),
|
||||
|
||||
CORS_ORIGIN: z.string().optional(),
|
||||
STATIC_DIR: z.string().optional(),
|
||||
|
||||
DATUM_URL: z.string().url(),
|
||||
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),
|
||||
|
||||
NOSTR_ALLOWED_NPUBS: z.string().min(1),
|
||||
|
||||
JWT_SECRET: z
|
||||
.string()
|
||||
.min(32, "JWT_SECRET must be at least 32 chars (use openssl rand -hex 32)"),
|
||||
JWT_TTL_SECONDS: z.coerce.number().int().min(60).default(86400),
|
||||
});
|
||||
|
||||
function parseAllowedNpubs(input: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of input.split(",").map((s) => s.trim()).filter(Boolean)) {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type !== "npub") {
|
||||
throw new Error(`NOSTR_ALLOWED_NPUBS entry is not an npub: ${raw}`);
|
||||
}
|
||||
const hex = decoded.data;
|
||||
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
||||
throw new Error(`Decoded npub is not 64-char hex: ${raw}`);
|
||||
}
|
||||
out.push(hex);
|
||||
}
|
||||
if (out.length === 0) {
|
||||
throw new Error("NOSTR_ALLOWED_NPUBS produced an empty allowlist — refusing to start");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const parsed = RawEnv.parse(process.env);
|
||||
|
||||
export const config = {
|
||||
port: parsed.PORT,
|
||||
nodeEnv: parsed.NODE_ENV,
|
||||
logLevel: parsed.LOG_LEVEL,
|
||||
corsOrigin: parsed.CORS_ORIGIN,
|
||||
staticDir: parsed.STATIC_DIR,
|
||||
datum: {
|
||||
url: parsed.DATUM_URL.replace(/\/$/, ""),
|
||||
adminUser: parsed.DATUM_ADMIN_USER,
|
||||
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
|
||||
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
|
||||
},
|
||||
nostr: {
|
||||
allowedHexPubkeys: parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS),
|
||||
},
|
||||
jwt: {
|
||||
secret: parsed.JWT_SECRET,
|
||||
ttlSeconds: parsed.JWT_TTL_SECONDS,
|
||||
},
|
||||
} as const;
|
||||
113
apps/api/src/datum/digest.ts
Normal file
113
apps/api/src/datum/digest.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export type DigestCreds = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type DigestFetchOptions = {
|
||||
url: string;
|
||||
method?: string;
|
||||
creds: DigestCreds;
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
function sha256Hex(input: string): string {
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
type Challenge = {
|
||||
realm: string;
|
||||
nonce: string;
|
||||
qop: string;
|
||||
algorithm: string;
|
||||
opaque?: string;
|
||||
};
|
||||
|
||||
function parseChallenge(header: string): Challenge | null {
|
||||
if (!/^digest\s/i.test(header)) return null;
|
||||
const body = header.slice(7);
|
||||
const out: Record<string, string> = {};
|
||||
const re = /(\w+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(body)) !== null) {
|
||||
out[m[1]!.toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] ?? "");
|
||||
}
|
||||
if (!out["realm"] || !out["nonce"]) return null;
|
||||
return {
|
||||
realm: out["realm"],
|
||||
nonce: out["nonce"],
|
||||
qop: out["qop"] ?? "auth",
|
||||
algorithm: out["algorithm"] ?? "MD5",
|
||||
...(out["opaque"] !== undefined ? { opaque: out["opaque"] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthHeader(args: {
|
||||
user: string;
|
||||
password: string;
|
||||
challenge: Challenge;
|
||||
uri: string;
|
||||
method: string;
|
||||
}): string {
|
||||
const algoUpper = args.challenge.algorithm.toUpperCase();
|
||||
if (!algoUpper.includes("SHA-256") && !algoUpper.includes("SHA256")) {
|
||||
throw new Error(`Unsupported Datum digest algorithm: ${args.challenge.algorithm}`);
|
||||
}
|
||||
const nc = "00000001";
|
||||
const cnonce = randomBytes(8).toString("hex");
|
||||
const ha1 = sha256Hex(`${args.user}:${args.challenge.realm}:${args.password}`);
|
||||
const ha2 = sha256Hex(`${args.method}:${args.uri}`);
|
||||
const response = sha256Hex(
|
||||
`${ha1}:${args.challenge.nonce}:${nc}:${cnonce}:${args.challenge.qop}:${ha2}`,
|
||||
);
|
||||
const parts = [
|
||||
`username="${args.user}"`,
|
||||
`realm="${args.challenge.realm}"`,
|
||||
`nonce="${args.challenge.nonce}"`,
|
||||
`uri="${args.uri}"`,
|
||||
`algorithm=${args.challenge.algorithm}`,
|
||||
`response="${response}"`,
|
||||
`qop=${args.challenge.qop}`,
|
||||
`nc=${nc}`,
|
||||
`cnonce="${cnonce}"`,
|
||||
];
|
||||
if (args.challenge.opaque) parts.push(`opaque="${args.challenge.opaque}"`);
|
||||
return "Digest " + parts.join(", ");
|
||||
}
|
||||
|
||||
export async function digestFetch(opts: DigestFetchOptions): Promise<Response> {
|
||||
const method = opts.method ?? "GET";
|
||||
const url = new URL(opts.url);
|
||||
const uri = url.pathname + url.search;
|
||||
const baseInit: RequestInit = {
|
||||
method,
|
||||
headers: { ...(opts.headers ?? {}) },
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
};
|
||||
|
||||
const first = await fetch(opts.url, baseInit);
|
||||
if (first.status !== 401) return first;
|
||||
|
||||
const challengeHdr = first.headers.get("www-authenticate");
|
||||
if (!challengeHdr) return first;
|
||||
|
||||
const challenge = parseChallenge(challengeHdr);
|
||||
if (!challenge) return first;
|
||||
|
||||
await first.body?.cancel().catch(() => {});
|
||||
|
||||
const auth = buildAuthHeader({
|
||||
user: opts.creds.username,
|
||||
password: opts.creds.password,
|
||||
challenge,
|
||||
uri,
|
||||
method,
|
||||
});
|
||||
|
||||
return fetch(opts.url, {
|
||||
...baseInit,
|
||||
headers: { ...(opts.headers ?? {}), authorization: auth },
|
||||
});
|
||||
}
|
||||
155
apps/api/src/datum/parse.ts
Normal file
155
apps/api/src/datum/parse.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { parse, type HTMLElement } from "node-html-parser";
|
||||
import { MINER_PROFILES } from "./profiles.js";
|
||||
import type { MinerProfile, MinerStat } from "./types.js";
|
||||
|
||||
const UNKNOWN_PROFILE: MinerProfile = {
|
||||
slug: "unknown",
|
||||
nickname: "???",
|
||||
model: "Unknown",
|
||||
ownerLabel: "unknown",
|
||||
locationLabel: "unknown",
|
||||
expectedHashrateThs: 0,
|
||||
workerNameMatchers: [],
|
||||
};
|
||||
|
||||
export type ThreadRow = {
|
||||
tid: number;
|
||||
connections: number;
|
||||
subscriptions: number;
|
||||
hashrateThs: number;
|
||||
};
|
||||
|
||||
function num(s: string | undefined | null): number {
|
||||
if (!s) return 0;
|
||||
const m = s.replace(/,/g, "").match(/-?\d+(\.\d+)?/);
|
||||
return m ? Number(m[0]) : 0;
|
||||
}
|
||||
|
||||
function thsFromHashrateField(field: string): number {
|
||||
const numMatch = field.match(/-?\d+(?:\.\d+)?/);
|
||||
if (!numMatch) return 0;
|
||||
const v = Number(numMatch[0]);
|
||||
if (/Gh\/s/i.test(field)) return v / 1_000;
|
||||
if (/Mh\/s/i.test(field)) return v / 1_000_000;
|
||||
if (/Kh\/s/i.test(field)) return v / 1_000_000_000;
|
||||
return v;
|
||||
}
|
||||
|
||||
function parseAgeS(s: string): number | null {
|
||||
const m = s.match(/-?\d+(?:\.\d+)?/);
|
||||
return m ? Number(m[0]) : null;
|
||||
}
|
||||
|
||||
function findTableContaining(html: string, marker: string): HTMLElement | null {
|
||||
const root = parse(html);
|
||||
for (const t of root.querySelectorAll("table")) {
|
||||
if (t.text.includes(marker)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function matchProfile(userAgent: string, authUsername: string): MinerProfile {
|
||||
if (/nerdq?axe/i.test(userAgent)) {
|
||||
const p = MINER_PROFILES.find((x) => x.slug === "nerdqaxe");
|
||||
if (p) return p;
|
||||
}
|
||||
const dotIdx = authUsername.indexOf(".");
|
||||
if (dotIdx >= 0) {
|
||||
const worker = authUsername.slice(dotIdx + 1).toLowerCase();
|
||||
for (const p of MINER_PROFILES) {
|
||||
for (const m of p.workerNameMatchers) {
|
||||
if (worker.includes(m.toLowerCase())) return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return UNKNOWN_PROFILE;
|
||||
}
|
||||
|
||||
export function parseClientsHtml(html: string): MinerStat[] {
|
||||
const table = findTableContaining(html, "Auth Username");
|
||||
if (!table) return [];
|
||||
const out: MinerStat[] = [];
|
||||
const trs = table.querySelectorAll("tr");
|
||||
for (let i = 1; i < trs.length; i++) {
|
||||
const tds = trs[i]!.querySelectorAll("td");
|
||||
if (tds.length < 11) continue;
|
||||
|
||||
const remoteHost = tds[1]!.text.trim();
|
||||
const authUsername = tds[2]!.text.trim();
|
||||
const subbedField = tds[3]!.text.trim();
|
||||
const lastAcceptedField = tds[4]!.text.trim();
|
||||
const vdiffField = tds[5]!.text.trim();
|
||||
const diffAField = tds[6]!.text.trim();
|
||||
const diffRField = tds[7]!.text.trim();
|
||||
const hashrateField = tds[8]!.text.trim();
|
||||
const userAgent = tds[10]!.text.trim();
|
||||
|
||||
const subscribed = !subbedField.toLowerCase().startsWith("not subscribed");
|
||||
const lastShareAgeS = lastAcceptedField === "N/A" ? null : parseAgeS(lastAcceptedField);
|
||||
const vdiff = num(vdiffField);
|
||||
|
||||
const diffAMatch = diffAField.match(/(-?\d+(?:\.\d+)?)\s*\((\d+)\)/);
|
||||
const diffAcceptedSum = diffAMatch ? Number(diffAMatch[1]) : 0;
|
||||
const diffAcceptedCount = diffAMatch ? Number(diffAMatch[2]) : 0;
|
||||
|
||||
const diffRMatch = diffRField.match(/(-?\d+(?:\.\d+)?)\s*\((\d+)\)\s*([\d.]+)%/);
|
||||
const diffRejectedSum = diffRMatch ? Number(diffRMatch[1]) : 0;
|
||||
const diffRejectedCount = diffRMatch ? Number(diffRMatch[2]) : 0;
|
||||
const rejectPct = diffRMatch ? Number(diffRMatch[3]) : 0;
|
||||
|
||||
let hashrateThs = 0;
|
||||
let hashrateAgeS: number | null = null;
|
||||
if (hashrateField !== "N/A") {
|
||||
hashrateThs = thsFromHashrateField(hashrateField);
|
||||
const ageMatch = hashrateField.match(/\(\s*(-?\d+(?:\.\d+)?)\s*s\s*\)/);
|
||||
hashrateAgeS = ageMatch ? Number(ageMatch[1]) : null;
|
||||
}
|
||||
|
||||
const profile = matchProfile(userAgent, authUsername);
|
||||
|
||||
let status: MinerStat["status"];
|
||||
if (!subscribed) status = "idle";
|
||||
else if (hashrateThs <= 0) status = "idle";
|
||||
else if (hashrateAgeS !== null && hashrateAgeS > 180) status = "stale";
|
||||
else status = "hashing";
|
||||
|
||||
out.push({
|
||||
authUsername,
|
||||
remoteHost,
|
||||
nickname: profile.nickname,
|
||||
model: profile.model,
|
||||
location: profile.locationLabel,
|
||||
hashrateThs,
|
||||
hashrateAgeS,
|
||||
lastShareAgeS,
|
||||
diffAcceptedSum,
|
||||
diffAcceptedCount,
|
||||
diffRejectedSum,
|
||||
diffRejectedCount,
|
||||
rejectPct,
|
||||
vdiff,
|
||||
userAgent,
|
||||
subscribed,
|
||||
status,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseThreadsHtml(html: string): ThreadRow[] {
|
||||
const table = findTableContaining(html, "Approx. Hashrate");
|
||||
if (!table) return [];
|
||||
const out: ThreadRow[] = [];
|
||||
const trs = table.querySelectorAll("tr");
|
||||
for (let i = 1; i < trs.length; i++) {
|
||||
const tds = trs[i]!.querySelectorAll("td");
|
||||
if (tds.length < 4) continue;
|
||||
out.push({
|
||||
tid: num(tds[0]!.text),
|
||||
connections: num(tds[1]!.text),
|
||||
subscriptions: num(tds[2]!.text),
|
||||
hashrateThs: thsFromHashrateField(tds[3]!.text.trim()),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
181
apps/api/src/datum/poller.ts
Normal file
181
apps/api/src/datum/poller.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { config } from "../config.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { digestFetch } from "./digest.js";
|
||||
import { parseClientsHtml, parseThreadsHtml } from "./parse.js";
|
||||
import type { CurrentJob, DatumSnapshot, PoolStat } from "./types.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
const EMPTY_POOL: PoolStat = {
|
||||
combinedHashrateThs: 0,
|
||||
totalConnections: 0,
|
||||
totalSubscriptions: 0,
|
||||
activeThreads: 0,
|
||||
sharesAccepted: 0,
|
||||
sharesRejected: 0,
|
||||
uptimeSeconds: 0,
|
||||
connectionStatus: "unknown",
|
||||
poolHost: "",
|
||||
poolTag: "",
|
||||
minerTag: "",
|
||||
poolDifficulty: 0,
|
||||
};
|
||||
|
||||
const EMPTY_JOB: CurrentJob = {
|
||||
blockHeight: 0,
|
||||
blockValueSats: 0,
|
||||
difficulty: 0,
|
||||
bits: "",
|
||||
prevBlock: "",
|
||||
target: "",
|
||||
version: "",
|
||||
weight: 0,
|
||||
size: 0,
|
||||
txnCount: 0,
|
||||
timeInfo: "",
|
||||
};
|
||||
|
||||
let lastSnapshot: DatumSnapshot = {
|
||||
ok: false,
|
||||
fetchedAt: 0,
|
||||
pool: EMPTY_POOL,
|
||||
job: EMPTY_JOB,
|
||||
miners: [],
|
||||
error: { code: "NOT_YET_POLLED", message: "Poller has not run yet" },
|
||||
};
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
function abortableSignal(timeoutMs: number): { signal: AbortSignal; cancel: () => void } {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(new Error("upstream_timeout")), timeoutMs);
|
||||
return { signal: ctrl.signal, cancel: () => clearTimeout(t) };
|
||||
}
|
||||
|
||||
async function pollOnce(): Promise<DatumSnapshot> {
|
||||
const fetchedAt = Date.now();
|
||||
|
||||
// /clients (admin Digest-gated) and /threads (public) in parallel.
|
||||
const clientsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS);
|
||||
const threadsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [clientsResSettled, threadsResSettled] = await Promise.allSettled([
|
||||
digestFetch({
|
||||
url: `${config.datum.url}/clients`,
|
||||
creds: { username: config.datum.adminUser, password: config.datum.adminPassword },
|
||||
signal: clientsTimeout.signal,
|
||||
}),
|
||||
fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal }),
|
||||
]);
|
||||
clientsTimeout.cancel();
|
||||
threadsTimeout.cancel();
|
||||
|
||||
if (clientsResSettled.status === "rejected") {
|
||||
const reason =
|
||||
clientsResSettled.reason instanceof Error
|
||||
? clientsResSettled.reason.message
|
||||
: "unknown";
|
||||
logger.warn({ reason }, "datum_clients_fetch_failed");
|
||||
return {
|
||||
...lastSnapshot,
|
||||
ok: false,
|
||||
fetchedAt,
|
||||
error: { code: "DATUM_UNREACHABLE", message: reason },
|
||||
};
|
||||
}
|
||||
const clientsRes = clientsResSettled.value;
|
||||
if (clientsRes.status === 401) {
|
||||
return {
|
||||
...lastSnapshot,
|
||||
ok: false,
|
||||
fetchedAt,
|
||||
error: {
|
||||
code: "DATUM_AUTH_FAIL",
|
||||
message: "Datum rejected admin credentials (check DATUM_ADMIN_PASSWORD)",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (clientsRes.status !== 200) {
|
||||
return {
|
||||
...lastSnapshot,
|
||||
ok: false,
|
||||
fetchedAt,
|
||||
error: { code: "DATUM_HTTP", message: `Datum /clients returned ${clientsRes.status}` },
|
||||
};
|
||||
}
|
||||
|
||||
const clientsHtml = await clientsRes.text();
|
||||
let threadsHtml = "";
|
||||
if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) {
|
||||
threadsHtml = await threadsResSettled.value.text();
|
||||
} else if (threadsResSettled.status === "rejected") {
|
||||
logger.warn(
|
||||
{
|
||||
reason:
|
||||
threadsResSettled.reason instanceof Error
|
||||
? threadsResSettled.reason.message
|
||||
: "unknown",
|
||||
},
|
||||
"datum_threads_fetch_failed",
|
||||
);
|
||||
}
|
||||
|
||||
const miners = parseClientsHtml(clientsHtml);
|
||||
const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : [];
|
||||
|
||||
const combinedHashrateThs = miners.reduce((s, m) => s + m.hashrateThs, 0);
|
||||
const totalSubscriptions =
|
||||
threads.reduce((s, t) => s + t.subscriptions, 0) ||
|
||||
miners.filter((m) => m.subscribed).length;
|
||||
const totalConnections =
|
||||
threads.reduce((s, t) => s + t.connections, 0) || miners.length;
|
||||
const sharesAccepted = miners.reduce((s, m) => s + m.diffAcceptedCount, 0);
|
||||
const sharesRejected = miners.reduce((s, m) => s + m.diffRejectedCount, 0);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
fetchedAt,
|
||||
pool: {
|
||||
...EMPTY_POOL,
|
||||
combinedHashrateThs,
|
||||
totalConnections,
|
||||
totalSubscriptions,
|
||||
activeThreads: threads.length,
|
||||
sharesAccepted,
|
||||
sharesRejected,
|
||||
connectionStatus: "ok",
|
||||
},
|
||||
job: EMPTY_JOB,
|
||||
miners,
|
||||
};
|
||||
} catch (err) {
|
||||
clientsTimeout.cancel();
|
||||
threadsTimeout.cancel();
|
||||
const reason = err instanceof Error ? err.message : "unknown";
|
||||
logger.warn({ reason }, "datum_poll_unexpected_error");
|
||||
return {
|
||||
...lastSnapshot,
|
||||
ok: false,
|
||||
fetchedAt,
|
||||
error: { code: "POLL_ERROR", message: reason },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function startPoller(): void {
|
||||
if (timer) return;
|
||||
const tick = async (): Promise<void> => {
|
||||
lastSnapshot = await pollOnce();
|
||||
};
|
||||
void tick();
|
||||
timer = setInterval(() => void tick(), config.datum.pollIntervalMs);
|
||||
}
|
||||
|
||||
export function stopPoller(): void {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
export function getSnapshot(): DatumSnapshot {
|
||||
return lastSnapshot;
|
||||
}
|
||||
54
apps/api/src/datum/profiles.ts
Normal file
54
apps/api/src/datum/profiles.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { MinerProfile } from "./types.js";
|
||||
|
||||
// Live UserAgent strings observed on this Umbrel (2026-05-06):
|
||||
// NerdQAxe → "NerdQAxe/BM1370/v1.0.36" (self-identifies)
|
||||
// Bitaxe / Avalon Nano 3 / Avalon Mini 3 → all "cgminer/4.11.1" (indistinguishable
|
||||
// by UA — match these via auth-username worker-name suffix instead).
|
||||
export const MINER_PROFILES: readonly MinerProfile[] = [
|
||||
{
|
||||
slug: "nerdqaxe",
|
||||
nickname: "QU4CK",
|
||||
model: "NerdQAxe+",
|
||||
ownerLabel: "shared",
|
||||
locationLabel: "Site A",
|
||||
expectedHashrateThs: 4.5,
|
||||
workerNameMatchers: ["nerdqaxe", "qu4ck", "quack"],
|
||||
},
|
||||
{
|
||||
slug: "bitaxe",
|
||||
nickname: "P1XEL",
|
||||
model: "Bitaxe",
|
||||
ownerLabel: "shared",
|
||||
locationLabel: "Site A",
|
||||
expectedHashrateThs: 1.2,
|
||||
workerNameMatchers: ["bitaxe", "p1xel", "pixel"],
|
||||
},
|
||||
{
|
||||
slug: "avalon-nano-3",
|
||||
nickname: "N4N0",
|
||||
model: "Avalon Canaan Nano 3",
|
||||
ownerLabel: "shared",
|
||||
locationLabel: "Site B",
|
||||
expectedHashrateThs: 4.0,
|
||||
workerNameMatchers: ["nano", "nano3", "n4n0"],
|
||||
},
|
||||
{
|
||||
slug: "avalon-mini-3",
|
||||
nickname: "M1N1",
|
||||
model: "Avalon Mini 3",
|
||||
ownerLabel: "shared",
|
||||
locationLabel: "Site B",
|
||||
expectedHashrateThs: 37.0,
|
||||
workerNameMatchers: ["mini", "mini3", "m1n1"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function profileForWorker(workerName: string): MinerProfile | null {
|
||||
const lower = workerName.toLowerCase();
|
||||
for (const p of MINER_PROFILES) {
|
||||
for (const m of p.workerNameMatchers) {
|
||||
if (lower.includes(m)) return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
29
apps/api/src/datum/routes.ts
Normal file
29
apps/api/src/datum/routes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
import { getSnapshot } from "./poller.js";
|
||||
import { upstreamUnavailable } from "../errors.js";
|
||||
|
||||
export const datumRouter = Router();
|
||||
|
||||
datumRouter.use(requireAuth);
|
||||
|
||||
datumRouter.get("/stats", (_req, res, next) => {
|
||||
const snap = getSnapshot();
|
||||
if (!snap) return next(upstreamUnavailable());
|
||||
res.json(snap);
|
||||
});
|
||||
|
||||
datumRouter.get("/stream", (req, res) => {
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.flushHeaders?.();
|
||||
|
||||
const send = () => {
|
||||
const snap = getSnapshot();
|
||||
if (snap) res.write(`data: ${JSON.stringify(snap)}\n\n`);
|
||||
};
|
||||
send();
|
||||
const interval = setInterval(send, 5_000);
|
||||
req.on("close", () => clearInterval(interval));
|
||||
});
|
||||
67
apps/api/src/datum/types.ts
Normal file
67
apps/api/src/datum/types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type MinerProfile = {
|
||||
slug: string;
|
||||
nickname: string;
|
||||
model: string;
|
||||
ownerLabel: string;
|
||||
locationLabel: string;
|
||||
expectedHashrateThs: number;
|
||||
workerNameMatchers: readonly string[];
|
||||
};
|
||||
|
||||
export type MinerStat = {
|
||||
authUsername: string;
|
||||
remoteHost: string;
|
||||
nickname: string;
|
||||
model: string;
|
||||
location: string;
|
||||
hashrateThs: number;
|
||||
hashrateAgeS: number | null;
|
||||
lastShareAgeS: number | null;
|
||||
diffAcceptedSum: number;
|
||||
diffAcceptedCount: number;
|
||||
diffRejectedSum: number;
|
||||
diffRejectedCount: number;
|
||||
rejectPct: number;
|
||||
vdiff: number;
|
||||
userAgent: string;
|
||||
subscribed: boolean;
|
||||
status: 'hashing' | 'stale' | 'idle';
|
||||
};
|
||||
|
||||
export type PoolStat = {
|
||||
combinedHashrateThs: number;
|
||||
totalConnections: number;
|
||||
totalSubscriptions: number;
|
||||
activeThreads: number;
|
||||
sharesAccepted: number;
|
||||
sharesRejected: number;
|
||||
uptimeSeconds: number;
|
||||
connectionStatus: string;
|
||||
poolHost: string;
|
||||
poolTag: string;
|
||||
minerTag: string;
|
||||
poolDifficulty: number;
|
||||
};
|
||||
|
||||
export type CurrentJob = {
|
||||
blockHeight: number;
|
||||
blockValueSats: number;
|
||||
difficulty: number;
|
||||
bits: string;
|
||||
prevBlock: string;
|
||||
target: string;
|
||||
version: string;
|
||||
weight: number;
|
||||
size: number;
|
||||
txnCount: number;
|
||||
timeInfo: string;
|
||||
};
|
||||
|
||||
export type DatumSnapshot = {
|
||||
ok: boolean;
|
||||
fetchedAt: number;
|
||||
pool: PoolStat;
|
||||
job: CurrentJob;
|
||||
miners: MinerStat[];
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
31
apps/api/src/errors.ts
Normal file
31
apps/api/src/errors.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
export class AppError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export const unauthorized = (code = "unauthorized") =>
|
||||
new AppError(401, code, "Authentication required.");
|
||||
export const forbidden = (code = "forbidden") =>
|
||||
new AppError(403, code, "Access denied.");
|
||||
export const badRequest = (code = "bad_request", message = "Invalid request.") =>
|
||||
new AppError(400, code, message);
|
||||
export const upstreamUnavailable = () =>
|
||||
new AppError(503, "upstream_unavailable", "Upstream temporarily unavailable.");
|
||||
|
||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||
if (res.headersSent) return;
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.status).json({ error: { code: err.code, message: err.message } });
|
||||
return;
|
||||
}
|
||||
logger.error({ err }, "unhandled_error");
|
||||
res.status(500).json({ error: { code: "internal_error", message: "Something went wrong." } });
|
||||
}
|
||||
17
apps/api/src/index.ts
Normal file
17
apps/api/src/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { config } from "./config.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { startPoller } from "./datum/poller.js";
|
||||
import { buildApp } from "./server.js";
|
||||
|
||||
startPoller();
|
||||
|
||||
buildApp().listen(config.port, () => {
|
||||
logger.info(
|
||||
{
|
||||
port: config.port,
|
||||
datum: config.datum.url,
|
||||
allowedNpubs: config.nostr.allowedHexPubkeys.length,
|
||||
},
|
||||
"gashboard api listening",
|
||||
);
|
||||
});
|
||||
18
apps/api/src/logger.ts
Normal file
18
apps/api/src/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pino } from "pino";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const logger = pino({
|
||||
level: config.logLevel,
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'*.password',
|
||||
'*.token',
|
||||
'*.jwt',
|
||||
'*.sig',
|
||||
],
|
||||
censor: "[REDACTED]",
|
||||
},
|
||||
base: { app: "gashboard" },
|
||||
});
|
||||
8
apps/api/src/nostr/allowlist.ts
Normal file
8
apps/api/src/nostr/allowlist.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { config } from "../config.js";
|
||||
|
||||
const allowed = new Set<string>(config.nostr.allowedHexPubkeys.map((k) => k.toLowerCase()));
|
||||
|
||||
export function isPubkeyAllowed(hexPubkey: string): boolean {
|
||||
if (typeof hexPubkey !== "string" || hexPubkey.length !== 64) return false;
|
||||
return allowed.has(hexPubkey.toLowerCase());
|
||||
}
|
||||
69
apps/api/src/nostr/nip98.ts
Normal file
69
apps/api/src/nostr/nip98.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { verifyEvent, type Event as NostrEvent } from "nostr-tools";
|
||||
|
||||
export type Nip98Input = {
|
||||
authHeader: string | undefined;
|
||||
method: string;
|
||||
fullUrl: string;
|
||||
};
|
||||
|
||||
export type Nip98Result =
|
||||
| { ok: true; pubkey: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const KIND_HTTP_AUTH = 27235;
|
||||
const FRESHNESS_WINDOW_S = 120;
|
||||
|
||||
function findTag(event: NostrEvent, name: string): string | undefined {
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === name && typeof tag[1] === "string") return tag[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
export function verifyNip98(input: Nip98Input): Nip98Result {
|
||||
const { authHeader, method, fullUrl } = input;
|
||||
|
||||
if (!authHeader) return { ok: false, reason: "missing_authorization" };
|
||||
const match = authHeader.match(/^Nostr\s+(.+)$/i);
|
||||
if (!match) return { ok: false, reason: "wrong_scheme" };
|
||||
const b64 = match[1]!.trim();
|
||||
|
||||
let event: NostrEvent;
|
||||
try {
|
||||
const json = Buffer.from(b64, "base64").toString("utf8");
|
||||
event = JSON.parse(json) as NostrEvent;
|
||||
} catch {
|
||||
return { ok: false, reason: "decode_failed" };
|
||||
}
|
||||
|
||||
if (event.kind !== KIND_HTTP_AUTH) return { ok: false, reason: "wrong_kind" };
|
||||
if (!verifyEvent(event)) return { ok: false, reason: "bad_signature" };
|
||||
|
||||
const nowS = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(nowS - event.created_at) > FRESHNESS_WINDOW_S) {
|
||||
return { ok: false, reason: "stale" };
|
||||
}
|
||||
|
||||
const tagMethod = findTag(event, "method");
|
||||
const tagUrl = findTag(event, "u");
|
||||
if (!tagMethod || !tagUrl) return { ok: false, reason: "missing_tags" };
|
||||
|
||||
if (!timingSafeEqualStr(tagMethod.toUpperCase(), method.toUpperCase())) {
|
||||
return { ok: false, reason: "method_mismatch" };
|
||||
}
|
||||
if (!timingSafeEqualStr(tagUrl, fullUrl)) {
|
||||
return { ok: false, reason: "url_mismatch" };
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(event.pubkey)) {
|
||||
return { ok: false, reason: "malformed_pubkey" };
|
||||
}
|
||||
|
||||
return { ok: true, pubkey: event.pubkey };
|
||||
}
|
||||
71
apps/api/src/server.ts
Normal file
71
apps/api/src/server.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import cors from "cors";
|
||||
import { pinoHttp } from "pino-http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { authRouter } from "./auth/routes.js";
|
||||
import { datumRouter } from "./datum/routes.js";
|
||||
import { errorHandler } from "./errors.js";
|
||||
|
||||
export function buildApp() {
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": ["'self'"],
|
||||
"style-src": ["'self'", "'unsafe-inline'"],
|
||||
"img-src": ["'self'", "data:"],
|
||||
"connect-src": ["'self'"],
|
||||
"font-src": ["'self'", "data:"],
|
||||
"frame-ancestors": ["'none'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
if (config.corsOrigin) {
|
||||
app.use(cors({ origin: config.corsOrigin, credentials: false }));
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: "32kb" }));
|
||||
app.use(pinoHttp({ logger }));
|
||||
|
||||
app.get("/healthz", (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/datum", datumRouter);
|
||||
|
||||
const staticDir = config.staticDir
|
||||
? config.staticDir
|
||||
: resolveDefaultStaticDir();
|
||||
|
||||
if (staticDir && fs.existsSync(staticDir)) {
|
||||
app.use(express.static(staticDir, { index: false, maxAge: "1h" }));
|
||||
app.get(/.*/, (_req, res, next) => {
|
||||
const indexFile = path.join(staticDir, "index.html");
|
||||
if (!fs.existsSync(indexFile)) return next();
|
||||
res.sendFile(indexFile);
|
||||
});
|
||||
}
|
||||
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
function resolveDefaultStaticDir(): string {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(here, "../../web/dist");
|
||||
}
|
||||
Reference in New Issue
Block a user