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:
Dorian
2026-05-06 15:58:35 +01:00
parent 2dc9be4678
commit de353878f6
16 changed files with 990 additions and 0 deletions

39
apps/api/src/auth/jwt.ts Normal file
View 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;
}
}

View 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();
}

View 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
View 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;

View 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
View 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;
}

View 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;
}

View 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;
}

View 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));
});

View 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
View 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
View 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
View 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" },
});

View 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());
}

View 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
View 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");
}