diff --git a/apps/api/src/auth/jwt.ts b/apps/api/src/auth/jwt.ts new file mode 100644 index 0000000..62db9c1 --- /dev/null +++ b/apps/api/src/auth/jwt.ts @@ -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; + } +} diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts new file mode 100644 index 0000000..696ba02 --- /dev/null +++ b/apps/api/src/auth/middleware.ts @@ -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(); +} diff --git a/apps/api/src/auth/routes.ts b/apps/api/src/auth/routes.ts new file mode 100644 index 0000000..7cc2c0f --- /dev/null +++ b/apps/api/src/auth/routes.ts @@ -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 }); +}); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..9741830 --- /dev/null +++ b/apps/api/src/config.ts @@ -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; diff --git a/apps/api/src/datum/digest.ts b/apps/api/src/datum/digest.ts new file mode 100644 index 0000000..153f612 --- /dev/null +++ b/apps/api/src/datum/digest.ts @@ -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; +}; + +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 = {}; + 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 { + 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 }, + }); +} diff --git a/apps/api/src/datum/parse.ts b/apps/api/src/datum/parse.ts new file mode 100644 index 0000000..f1547ed --- /dev/null +++ b/apps/api/src/datum/parse.ts @@ -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; +} diff --git a/apps/api/src/datum/poller.ts b/apps/api/src/datum/poller.ts new file mode 100644 index 0000000..91686e8 --- /dev/null +++ b/apps/api/src/datum/poller.ts @@ -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 { + 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 => { + 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; +} diff --git a/apps/api/src/datum/profiles.ts b/apps/api/src/datum/profiles.ts new file mode 100644 index 0000000..a270c32 --- /dev/null +++ b/apps/api/src/datum/profiles.ts @@ -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; +} diff --git a/apps/api/src/datum/routes.ts b/apps/api/src/datum/routes.ts new file mode 100644 index 0000000..23902ef --- /dev/null +++ b/apps/api/src/datum/routes.ts @@ -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)); +}); diff --git a/apps/api/src/datum/types.ts b/apps/api/src/datum/types.ts new file mode 100644 index 0000000..e97f482 --- /dev/null +++ b/apps/api/src/datum/types.ts @@ -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 }; +}; diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts new file mode 100644 index 0000000..41b91de --- /dev/null +++ b/apps/api/src/errors.ts @@ -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." } }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..ae13587 --- /dev/null +++ b/apps/api/src/index.ts @@ -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", + ); +}); diff --git a/apps/api/src/logger.ts b/apps/api/src/logger.ts new file mode 100644 index 0000000..a0b3121 --- /dev/null +++ b/apps/api/src/logger.ts @@ -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" }, +}); diff --git a/apps/api/src/nostr/allowlist.ts b/apps/api/src/nostr/allowlist.ts new file mode 100644 index 0000000..b0d03f6 --- /dev/null +++ b/apps/api/src/nostr/allowlist.ts @@ -0,0 +1,8 @@ +import { config } from "../config.js"; + +const allowed = new Set(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()); +} diff --git a/apps/api/src/nostr/nip98.ts b/apps/api/src/nostr/nip98.ts new file mode 100644 index 0000000..489f382 --- /dev/null +++ b/apps/api/src/nostr/nip98.ts @@ -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 }; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..9564df9 --- /dev/null +++ b/apps/api/src/server.ts @@ -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"); +}