feat(apps): add Gashboard with native signing and viewer access

This commit is contained in:
yaya
2026-10-06 06:42:25 +01:00
parent cedfbb2b07
commit 908e366006
100 changed files with 11072 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@gashboard/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsx --test tests/*.test.ts"
},
"dependencies": {
"cors": "2.8.5",
"express": "4.21.1",
"express-rate-limit": "7.4.1",
"helmet": "8.0.0",
"jsonwebtoken": "9.0.2",
"node-html-parser": "6.1.13",
"nostr-tools": "2.10.4",
"pino": "9.5.0",
"pino-http": "10.3.0",
"zod": "3.23.8"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/express": "5.0.0",
"@types/express-serve-static-core": "5.0.2",
"@types/jsonwebtoken": "9.0.7",
"@types/node": "22.9.0",
"tsx": "4.19.2",
"typescript": "5.6.3"
}
}
@@ -0,0 +1,44 @@
import { Router } from "express";
import { nip19 } from "nostr-tools";
import { requireAuth } from "../auth/middleware.js";
import { accessList } from "../nostr/allowlist.js";
import { badRequest, forbidden } from "../errors.js";
export const accessRouter = Router();
accessRouter.use(requireAuth);
accessRouter.get("/", (req, res) => {
const isOwner = accessList.isOwner(req.session!.pubkey);
res.setHeader("Cache-Control", "no-store");
res.json({ isOwner, members: isOwner ? accessList.members() : [] });
});
accessRouter.use((req, _res, next) => {
if (!accessList.isOwner(req.session!.pubkey)) return next(forbidden("owner_required"));
next();
});
function publicKey(value: unknown): string {
if (typeof value !== "string" || value.length > 100) throw badRequest("invalid_npub", "Enter a valid npub public key.");
try {
const decoded = nip19.decode(value.trim());
if (decoded.type === "npub") return decoded.data;
} catch { /* Map decoding failures to a client error. */ }
throw badRequest("invalid_npub", "Enter a valid npub public key.");
}
function update(npub: unknown, enabled: boolean): void {
const pubkey = publicKey(npub);
if (accessList.isOwner(pubkey)) throw badRequest("node_owner", "Manage node owners in Archipelago identities.");
if (enabled && !accessList.isAllowed(pubkey) && accessList.members().filter(m => m.role === "viewer").length >= 500) {
throw badRequest("list_full", "The access list is limited to 500 viewers.");
}
accessList.setViewer(pubkey, enabled);
}
accessRouter.post("/", (req, res) => {
update(req.body?.npub, true);
res.json({ isOwner: true, members: accessList.members() });
});
accessRouter.delete("/:npub", (req, res) => {
update(req.params.npub, false);
res.json({ isOwner: true, members: accessList.members() });
});
@@ -0,0 +1,41 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { nip19 } from "nostr-tools";
export class AccessList {
private readonly owners: Set<string>;
private viewers: Set<string>;
constructor(private readonly file: string, owners: string[], initialViewers: string[]) {
this.owners = new Set(owners);
try {
const saved: unknown = JSON.parse(readFileSync(file, "utf8"));
if (!Array.isArray(saved) || saved.length > 500 || saved.some(k => typeof k !== "string" || !/^[0-9a-f]{64}$/.test(k))) {
throw new Error("Invalid saved Gashboard access list");
}
this.viewers = new Set(saved as string[]);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.viewers = new Set(initialViewers);
}
}
isOwner(pubkey: string): boolean { return this.owners.has(pubkey); }
isAllowed(pubkey: string): boolean { return this.isOwner(pubkey) || this.viewers.has(pubkey); }
keys(): string[] { return [...new Set([...this.owners, ...this.viewers])].sort(); }
members(): Array<{ npub: string; role: "owner" | "viewer" }> {
return this.keys().map(pubkey => ({ npub: nip19.npubEncode(pubkey), role: this.isOwner(pubkey) ? "owner" : "viewer" }));
}
setViewer(pubkey: string, enabled: boolean): void {
if (this.isOwner(pubkey)) throw new Error("Node owners are managed in Archipelago identities");
const next = new Set(this.viewers);
if (enabled) next.add(pubkey); else next.delete(pubkey);
if (next.size > 500) throw new Error("The access list is limited to 500 viewers");
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
// Small synchronous writes serialize mutations; replace before updating memory.
writeFileSync(`${this.file}.tmp`, JSON.stringify([...next].sort()) + "\n", { mode: 0o600 });
renameSync(`${this.file}.tmp`, this.file);
this.viewers = next;
}
}
+39
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;
}
}
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from "express";
import { unauthorized } from "../errors.js";
import { isPubkeyAllowed } from "../nostr/allowlist.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 || !isPubkeyAllowed(session.pubkey)) return next(unauthorized("bad_session"));
req.session = { pubkey: session.pubkey, npub: session.npub };
next();
}
@@ -0,0 +1,54 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { verifyNip98 } from "../nostr/nip98.js";
import { isPubkeyAllowed, allowedPubkeys, isOwner } 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, isOwner: isOwner(req.session!.pubkey) });
});
authRouter.get("/chat-config", requireAuth, (_req, res) => {
res.json({ pubkeys: allowedPubkeys() });
});
@@ -0,0 +1,63 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth/middleware.js";
import { addMessage, addReaction, listChat, upsertKeyWrap } from "./store.js";
export const chatRouter = Router();
const MessageBody = z.object({
content: z.string().trim().min(1).max(12000),
replyToId: z.string().max(128).optional(),
replyToPubkey: z.string().max(128).optional(),
});
const ReactionBody = z.object({
messageId: z.string().min(1).max(128),
content: z.string().trim().min(1).max(12000),
});
const KeyWrapBody = z.object({
wraps: z.array(z.object({
recipientPubkey: z.string().regex(/^[0-9a-f]{64}$/),
ciphertext: z.string().trim().min(1).max(12000),
})).min(1).max(10),
});
chatRouter.use(requireAuth);
chatRouter.get("/", (_req, res) => {
res.json(listChat());
});
chatRouter.post("/messages", (req, res) => {
const body = MessageBody.parse(req.body);
const message = addMessage({
pubkey: req.session!.pubkey,
content: body.content,
...(body.replyToId ? { replyToId: body.replyToId } : {}),
...(body.replyToPubkey ? { replyToPubkey: body.replyToPubkey } : {}),
});
res.status(201).json(message);
});
chatRouter.post("/reactions", (req, res) => {
const body = ReactionBody.parse(req.body);
const reaction = addReaction({
pubkey: req.session!.pubkey,
messageId: body.messageId,
content: body.content,
});
res.status(201).json(reaction);
});
chatRouter.post("/key-wraps", (req, res) => {
const body = KeyWrapBody.parse(req.body);
const wraps = body.wraps.map((wrap) =>
upsertKeyWrap({
recipientPubkey: wrap.recipientPubkey,
senderPubkey: req.session!.pubkey,
ciphertext: wrap.ciphertext,
}),
);
res.status(201).json({ wraps });
});
@@ -0,0 +1,97 @@
export type StoredChatMessage = {
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
};
export type StoredChatReaction = {
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
};
export type StoredChatKeyWrap = {
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
updatedAt: number;
};
const MAX_MESSAGES = 500;
const MAX_REACTIONS = 2000;
const messages: StoredChatMessage[] = [];
const reactions: StoredChatReaction[] = [];
const keyWraps: StoredChatKeyWrap[] = [];
export function listChat(): {
messages: StoredChatMessage[];
reactions: StoredChatReaction[];
keyWraps: StoredChatKeyWrap[];
} {
return { messages: [...messages], reactions: [...reactions], keyWraps: [...keyWraps] };
}
export function addMessage(input: {
pubkey: string;
content: string;
replyToId?: string;
replyToPubkey?: string;
}): StoredChatMessage {
const message: StoredChatMessage = {
id: crypto.randomUUID(),
pubkey: input.pubkey,
content: input.content,
createdAt: Math.floor(Date.now() / 1000),
replyToId: input.replyToId ?? "",
replyToPubkey: input.replyToPubkey ?? "",
};
messages.push(message);
if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
return message;
}
export function addReaction(input: {
pubkey: string;
messageId: string;
content: string;
}): StoredChatReaction {
const reaction: StoredChatReaction = {
id: crypto.randomUUID(),
pubkey: input.pubkey,
messageId: input.messageId,
content: input.content,
createdAt: Math.floor(Date.now() / 1000),
};
reactions.push(reaction);
if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS);
return reaction;
}
export function upsertKeyWrap(input: {
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
}): StoredChatKeyWrap {
const updatedAt = Math.floor(Date.now() / 1000);
const existing = keyWraps.find((wrap) => wrap.recipientPubkey === input.recipientPubkey);
if (existing) {
existing.senderPubkey = input.senderPubkey;
existing.ciphertext = input.ciphertext;
existing.updatedAt = updatedAt;
return existing;
}
const wrap: StoredChatKeyWrap = {
recipientPubkey: input.recipientPubkey,
senderPubkey: input.senderPubkey,
ciphertext: input.ciphertext,
updatedAt,
};
keyWraps.push(wrap);
return wrap;
}
+84
View File
@@ -0,0 +1,84 @@
import { nip19 } from "nostr-tools";
import { z } from "zod";
const RawEnv = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(1337),
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().optional(),
DATUM_ADMIN_USER: z.string().default("admin"),
DATUM_ADMIN_PASSWORD: z.string().min(1),
DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
CONTRIBUTION_LEDGER_PATH: z.string().min(1).default("/data/contribution-ledger.json"),
MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"),
NOSTR_ALLOWED_NPUBS: z.string().default(""),
NOSTR_OWNER_PUBKEYS: z.string().default(""),
ACCESS_LIST_PATH: z.string().default("/data/access.json"),
ARCHIPELAGO_PROVIDER_PATH: z.string().optional(),
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);
}
return out;
}
const parsed = RawEnv.parse(process.env);
const ownerHexPubkeys = parsed.NOSTR_OWNER_PUBKEYS.split(",").map(k => k.trim()).filter(Boolean);
if (ownerHexPubkeys.some(k => !/^[0-9a-f]{64}$/.test(k))) {
throw new Error("NOSTR_OWNER_PUBKEYS must contain comma-separated 64-character hex public keys");
}
const allowedHexPubkeys = parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS);
if (!ownerHexPubkeys.length && !allowedHexPubkeys.length) {
throw new Error("Configure at least one owner or allowlisted npub before starting Gashboard");
}
export const config = {
port: parsed.PORT,
nodeEnv: parsed.NODE_ENV,
logLevel: parsed.LOG_LEVEL,
corsOrigin: parsed.CORS_ORIGIN,
staticDir: parsed.STATIC_DIR,
providerPath: parsed.ARCHIPELAGO_PROVIDER_PATH,
datum: {
url: (parsed.DATUM_URL ?? "http://127.0.0.1:21000").replace(/\/$/, ""),
adminUser: parsed.DATUM_ADMIN_USER,
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
contributionLedgerPath: parsed.CONTRIBUTION_LEDGER_PATH,
},
mempool: {
url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""),
},
nostr: {
allowedHexPubkeys,
ownerHexPubkeys,
accessListPath: parsed.ACCESS_LIST_PATH,
},
jwt: {
secret: parsed.JWT_SECRET,
ttlSeconds: parsed.JWT_TTL_SECONDS,
},
} as const;
@@ -0,0 +1,114 @@
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,
redirect: "manual",
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 },
});
}
@@ -0,0 +1,162 @@
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: "Unknown miner",
model: "Unknown",
ownerLabel: "unknown",
locationLabel: "unknown",
expectedHashrateThs: 0,
watts: 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;
}
if (/bitaxe/i.test(userAgent)) {
const p = MINER_PROFILES.find((x) => x.slug === "bitaxe");
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: authUsername.split(".").slice(1).join(".") || authUsername || remoteHost || "Unknown miner",
model: profile.model,
location: "unassigned",
expectedHashrateThs: profile.expectedHashrateThs,
watts: profile.watts,
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;
}
@@ -0,0 +1,471 @@
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, MinerStat, NetworkStat, PoolStat } from "./types.js";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
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: "",
};
const EMPTY_NETWORK: NetworkStat = {
blockHeight: 0,
hashrateEh: 0,
difficulty: 0,
source: "",
};
let lastSnapshot: DatumSnapshot = {
ok: false,
fetchedAt: 0,
pool: EMPTY_POOL,
job: EMPTY_JOB,
network: EMPTY_NETWORK,
miners: [],
error: { code: "NOT_YET_POLLED", message: "Poller has not run yet" },
};
let timer: NodeJS.Timeout | null = null;
let pollInFlight = false;
type ContributionEntry = {
miner: MinerStat;
lastRawAcceptedWork: number;
lastRawAcceptedShares: number;
totalAcceptedWork: number;
totalAcceptedShares: number;
firstSeenAt: number;
lastSeenAt: number;
currentlyConnected: boolean;
};
const contributionLedger = new Map<string, ContributionEntry>();
let ledgerLoaded = false;
let ledgerDirty = false;
let ledgerWriteInFlight: Promise<void> | null = null;
type PersistedContributionLedger = {
version: 1;
updatedAt: number;
entries: Array<[string, ContributionEntry]>;
};
function formatErr(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const cause = (err as Error & { cause?: unknown }).cause;
if (cause instanceof Error) {
const code = (cause as Error & { code?: string }).code;
const hostname = (cause as Error & { hostname?: string }).hostname;
if (code === "ENOTFOUND" && hostname) {
const datumHost = new URL(config.datum.url).hostname;
const hint =
hostname === datumHost
? "; make sure gashboard is attached to Umbrel's Docker network or set DATUM_URL to a hostname/IP reachable from the API container"
: "";
return `${err.message}: DNS could not resolve ${hostname} (${code})${hint}`;
}
return code ? `${err.message}: ${cause.message} (${code})` : `${err.message}: ${cause.message}`;
}
return err.message;
}
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) };
}
function datumHttpMessage(res: Response, path: string): string {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400) {
return location
? `Datum ${path} redirected to ${location}; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API`
: `Datum ${path} redirected; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API`;
}
return `Datum ${path} returned ${res.status}`;
}
function isUmbrelShellHtml(html: string): boolean {
return /<title>\s*Umbrel\s*<\/title>/i.test(html) || /<div\s+id=["']root["']/i.test(html);
}
type MempoolHashrate = {
currentHashrate?: number;
currentDifficulty?: number;
};
function minerIdentity(m: MinerStat): string {
return m.authUsername || m.remoteHost || m.nickname;
}
function validContributionEntry(entry: ContributionEntry): boolean {
return (
entry &&
typeof entry === "object" &&
typeof entry.lastRawAcceptedWork === "number" &&
typeof entry.lastRawAcceptedShares === "number" &&
typeof entry.totalAcceptedWork === "number" &&
typeof entry.totalAcceptedShares === "number" &&
typeof entry.firstSeenAt === "number" &&
typeof entry.lastSeenAt === "number" &&
entry.miner &&
typeof entry.miner === "object"
);
}
async function loadContributionLedger(): Promise<void> {
if (ledgerLoaded) return;
ledgerLoaded = true;
try {
const raw = await readFile(config.datum.contributionLedgerPath, "utf8");
const parsed = JSON.parse(raw) as PersistedContributionLedger;
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
throw new Error("unsupported contribution ledger format");
}
contributionLedger.clear();
for (const [key, entry] of parsed.entries) {
if (typeof key === "string" && validContributionEntry(entry)) {
contributionLedger.set(key, { ...entry, currentlyConnected: false });
}
}
logger.info(
{ entries: contributionLedger.size, path: config.datum.contributionLedgerPath },
"contribution_ledger_loaded",
);
} catch (err) {
const code = err instanceof Error ? (err as Error & { code?: string }).code : undefined;
if (code === "ENOENT") return;
logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_load_failed");
}
}
function scheduleContributionLedgerSave(): void {
ledgerDirty = true;
if (ledgerWriteInFlight) return;
ledgerWriteInFlight = saveContributionLedger()
.catch((err) => {
logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_save_failed");
})
.finally(() => {
ledgerWriteInFlight = null;
if (ledgerDirty) scheduleContributionLedgerSave();
});
}
async function saveContributionLedger(): Promise<void> {
if (!ledgerDirty) return;
ledgerDirty = false;
const path = config.datum.contributionLedgerPath;
await mkdir(dirname(path), { recursive: true });
const tmpPath = `${path}.${process.pid}.tmp`;
const payload: PersistedContributionLedger = {
version: 1,
updatedAt: Date.now(),
entries: [...contributionLedger.entries()],
};
await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
await rename(tmpPath, path);
}
async function applyContributionLedger(miners: MinerStat[], fetchedAt: number): Promise<MinerStat[]> {
await loadContributionLedger();
let changed = false;
for (const entry of contributionLedger.values()) {
entry.currentlyConnected = false;
}
const connectedKeys = new Set<string>();
for (const miner of miners) {
const key = minerIdentity(miner);
connectedKeys.add(key);
const existing = contributionLedger.get(key);
if (!existing) {
contributionLedger.set(key, {
miner,
lastRawAcceptedWork: miner.diffAcceptedSum,
lastRawAcceptedShares: miner.diffAcceptedCount,
totalAcceptedWork: miner.diffAcceptedSum,
totalAcceptedShares: miner.diffAcceptedCount,
firstSeenAt: fetchedAt,
lastSeenAt: fetchedAt,
currentlyConnected: true,
});
changed = true;
continue;
}
const workDelta = miner.diffAcceptedSum - existing.lastRawAcceptedWork;
const shareDelta = miner.diffAcceptedCount - existing.lastRawAcceptedShares;
existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum;
existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount;
if (workDelta > 0 || shareDelta > 0 || workDelta < 0 || shareDelta < 0) changed = true;
existing.lastRawAcceptedWork = miner.diffAcceptedSum;
existing.lastRawAcceptedShares = miner.diffAcceptedCount;
existing.lastSeenAt = fetchedAt;
existing.currentlyConnected = true;
existing.miner = miner;
}
if (changed) scheduleContributionLedgerSave();
const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0);
const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({
...miner,
contributionAcceptedWork: entry.totalAcceptedWork,
contributionAcceptedShares: entry.totalAcceptedShares,
contributionPct: totalWork > 0 ? (entry.totalAcceptedWork / totalWork) * 100 : 0,
contributionFirstSeenAt: entry.firstSeenAt,
contributionLastSeenAt: entry.lastSeenAt,
currentlyConnected: entry.currentlyConnected,
});
const live = miners.map((miner) => decorate(miner, contributionLedger.get(minerIdentity(miner))!));
const offline = [...contributionLedger.entries()]
.filter(([key]) => !connectedKeys.has(key))
.map(([, entry]) =>
decorate(
{
...entry.miner,
hashrateThs: 0,
hashrateAgeS: null,
subscribed: false,
status: "idle",
},
entry,
),
);
return [...live, ...offline].sort((a, b) => (b.contributionAcceptedWork ?? 0) - (a.contributionAcceptedWork ?? 0));
}
async function fetchNetworkStats(): Promise<NetworkStat> {
const timeout = abortableSignal(DEFAULT_TIMEOUT_MS);
try {
const [heightRes, hashrateRes] = await Promise.all([
fetch(`${config.mempool.url}/blocks/tip/height`, { signal: timeout.signal }),
fetch(`${config.mempool.url}/v1/mining/hashrate/3d`, { signal: timeout.signal }),
]);
if (!heightRes.ok || !hashrateRes.ok) {
throw new Error(`mempool returned ${heightRes.status}/${hashrateRes.status}`);
}
const [heightText, hashrateJson] = await Promise.all([
heightRes.text(),
hashrateRes.json() as Promise<MempoolHashrate>,
]);
const blockHeight = Number(heightText);
return {
blockHeight: Number.isFinite(blockHeight) ? blockHeight : 0,
hashrateEh: (hashrateJson.currentHashrate ?? 0) / 1e18,
difficulty: hashrateJson.currentDifficulty ?? 0,
source: config.mempool.url,
};
} finally {
timeout.cancel();
}
}
async function pollOnce(): Promise<DatumSnapshot> {
const fetchedAt = Date.now();
const datumUrl = config.datum.url;
// /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: `${datumUrl}/clients`,
creds: { username: config.datum.adminUser, password: config.datum.adminPassword },
signal: clientsTimeout.signal,
}),
fetch(`${datumUrl}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }),
]);
clientsTimeout.cancel();
threadsTimeout.cancel();
if (clientsResSettled.status === "rejected") {
const reason = formatErr(clientsResSettled.reason);
logger.warn({ reason, url: `${datumUrl}/clients` }, "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) {
const message = datumHttpMessage(clientsRes, "/clients");
logger.warn(
{ status: clientsRes.status, url: `${datumUrl}/clients`, location: clientsRes.headers.get("location") },
"datum_clients_bad_status",
);
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "DATUM_HTTP", message },
};
}
const clientsHtml = await clientsRes.text();
if (isUmbrelShellHtml(clientsHtml)) {
const message =
"DATUM_URL returned the Umbrel web shell, not Datum /clients; use the Datum container admin API URL";
logger.warn({ url: `${datumUrl}/clients` }, "datum_clients_wrong_html");
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "DATUM_BAD_RESPONSE", message },
};
}
let threadsHtml = "";
if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) {
threadsHtml = await threadsResSettled.value.text();
} else if (
threadsResSettled.status === "fulfilled" &&
threadsResSettled.value.status >= 300 &&
threadsResSettled.value.status < 400
) {
logger.warn(
{
status: threadsResSettled.value.status,
url: `${datumUrl}/threads`,
location: threadsResSettled.value.headers.get("location"),
},
"datum_threads_redirected",
);
} else if (threadsResSettled.status === "rejected") {
logger.warn(
{ reason: formatErr(threadsResSettled.reason) },
"datum_threads_fetch_failed",
);
}
const miners = await applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt);
const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : [];
if (miners.length === 0) {
logger.warn(
{ url: `${datumUrl}/clients`, bytes: clientsHtml.length },
"datum_clients_no_miners_parsed",
);
}
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);
let network = lastSnapshot.network.blockHeight > 0 ? lastSnapshot.network : EMPTY_NETWORK;
try {
network = await fetchNetworkStats();
} catch (err) {
logger.warn({ reason: formatErr(err), url: config.mempool.url }, "mempool_fetch_failed");
}
return {
ok: true,
fetchedAt,
pool: {
...EMPTY_POOL,
combinedHashrateThs,
totalConnections,
totalSubscriptions,
activeThreads: threads.length,
sharesAccepted,
sharesRejected,
connectionStatus: "ok",
},
job: {
...EMPTY_JOB,
blockHeight: network.blockHeight,
difficulty: network.difficulty,
},
network,
miners,
};
} catch (err) {
clientsTimeout.cancel();
threadsTimeout.cancel();
const reason = formatErr(err);
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> => {
if (pollInFlight) return;
pollInFlight = true;
try {
lastSnapshot = await pollOnce();
} finally {
pollInFlight = false;
}
};
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;
}
@@ -0,0 +1,68 @@
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 → "bitaxe/BM1368/v2.7.1" or "cgminer/4.11.1" depending on firmware
// Avalon Nano 3 / Avalon Mini 3 → "cgminer/4.11.1" (match via worker-name suffix).
export const MINER_PROFILES: readonly MinerProfile[] = [
{
slug: "nerdqaxe",
nickname: "QU4CK",
model: "NerdQAxe+",
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 4.5,
watts: 80,
workerNameMatchers: ["nerdqaxe", "qu4ck", "quack"],
},
{
slug: "bitaxe-bigpapa",
nickname: "BigPapa",
model: "Bitaxe",
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 1.2,
watts: 18,
workerNameMatchers: ["bigpapa", "big-papa", "big_papa"],
},
{
slug: "bitaxe",
nickname: "P1XEL",
model: "Bitaxe",
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 1.2,
watts: 18,
workerNameMatchers: ["bitaxe", "p1xel", "pixel"],
},
{
slug: "avalon-nano-3",
nickname: "N4N0",
model: "Avalon Canaan Nano 3",
ownerLabel: "shared",
locationLabel: "Site B",
expectedHashrateThs: 4.0,
watts: 140,
workerNameMatchers: ["nano", "nano3", "n4n0"],
},
{
slug: "avalon-mini-3",
nickname: "M1N1",
model: "Avalon Mini 3",
ownerLabel: "shared",
locationLabel: "Site B",
expectedHashrateThs: 37.0,
watts: 800,
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;
}
@@ -0,0 +1,31 @@
import { Router } from "express";
import { isPubkeyAllowed } from "../nostr/allowlist.js";
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 = () => {
if (!isPubkeyAllowed(req.session!.pubkey)) { res.end(); return; }
const snap = getSnapshot();
if (snap) res.write(`data: ${JSON.stringify(snap)}\n\n`);
};
send();
const interval = setInterval(send, 5_000);
res.on("close", () => clearInterval(interval));
});
@@ -0,0 +1,84 @@
export type MinerProfile = {
slug: string;
nickname: string;
model: string;
ownerLabel: string;
locationLabel: string;
expectedHashrateThs: number;
watts: number;
workerNameMatchers: readonly string[];
};
export type MinerStat = {
authUsername: string;
remoteHost: string;
nickname: string;
model: string;
location: string;
expectedHashrateThs: number;
watts: number;
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';
contributionAcceptedWork?: number;
contributionAcceptedShares?: number;
contributionPct?: number;
contributionFirstSeenAt?: number;
contributionLastSeenAt?: number;
currentlyConnected?: boolean;
};
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 NetworkStat = {
blockHeight: number;
hashrateEh: number;
difficulty: number;
source: string;
};
export type DatumSnapshot = {
ok: boolean;
fetchedAt: number;
pool: PoolStat;
job: CurrentJob;
network: NetworkStat;
miners: MinerStat[];
error?: { code: string; message: string };
};
+31
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
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",
);
});
+142
View File
@@ -0,0 +1,142 @@
import { pino } from "pino";
import { Writable } from "node:stream";
import { config } from "./config.js";
const levelNames: Record<number, string> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
let buffered = "";
const logStream = new Writable({
write(chunk, _encoding, callback) {
buffered += chunk.toString();
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
for (const line of lines) {
if (!line) continue;
process.stdout.write(`${formatLogLine(line)}\n`);
}
callback();
},
});
export const logger = pino({
level: config.logLevel,
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token',
'*.jwt',
'*.sig',
],
censor: "[REDACTED]",
},
base: { app: "gashboard" },
}, logStream);
function formatLogLine(line: string): string {
try {
const record = JSON.parse(line) as Record<string, unknown>;
const handled = new Set([
"level",
"time",
"pid",
"hostname",
"app",
"req",
"res",
"responseTime",
"port",
"datum",
"allowedNpubs",
"staticDir",
"url",
"reason",
"err",
"msg",
]);
const parts: string[] = [
`time=${formatTime(record.time)}`,
`level=${formatLevel(record.level)}`,
];
pushField(parts, "app", record.app);
pushRequestFields(parts, record.req);
pushResponseFields(parts, record.res);
pushField(parts, "responseTime", record.responseTime);
pushField(parts, "port", record.port);
pushField(parts, "datum", record.datum);
pushField(parts, "allowedNpubs", record.allowedNpubs);
pushField(parts, "staticDir", record.staticDir);
pushField(parts, "url", record.url);
pushField(parts, "reason", record.reason);
if (record.err && typeof record.err === "object") {
const err = record.err as Record<string, unknown>;
pushField(parts, "err", err.message ?? err.type ?? record.err);
}
for (const [key, value] of Object.entries(record)) {
if (handled.has(key)) continue;
if (!isScalar(value)) continue;
pushField(parts, key, value);
}
pushField(parts, "msg", record.msg);
return parts.join(" ");
} catch {
return line;
}
}
function formatTime(value: unknown): string {
if (typeof value === "number") return new Date(value).toISOString();
if (typeof value === "string") return value;
return new Date().toISOString();
}
function formatLevel(value: unknown): string {
if (typeof value === "number") return levelNames[value] ?? String(value);
if (typeof value === "string") return value;
return "info";
}
function pushRequestFields(parts: string[], value: unknown): void {
if (!value || typeof value !== "object") return;
const req = value as Record<string, unknown>;
pushField(parts, "method", req.method);
pushField(parts, "path", req.url);
pushField(parts, "remote", req.remoteAddress);
}
function pushResponseFields(parts: string[], value: unknown): void {
if (!value || typeof value !== "object") return;
const res = value as Record<string, unknown>;
pushField(parts, "status", res.statusCode);
}
function pushField(parts: string[], key: string, value: unknown): void {
if (value === undefined || value === null || value === "") return;
parts.push(`${key}=${formatValue(value)}`);
}
function isScalar(value: unknown): value is string | number | boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function formatValue(value: unknown): string {
const raw = typeof value === "string" ? value : JSON.stringify(value);
if (!raw) return '""';
if (/^[A-Za-z0-9_./:@+-]+$/.test(raw)) return raw;
return JSON.stringify(raw);
}
@@ -0,0 +1,7 @@
import { config } from "../config.js";
import { AccessList } from "../access/store.js";
export const accessList = new AccessList(config.nostr.accessListPath, config.nostr.ownerHexPubkeys, config.nostr.allowedHexPubkeys);
export const isPubkeyAllowed = (pubkey: string): boolean => accessList.isAllowed(pubkey);
export const isOwner = (pubkey: string): boolean => accessList.isOwner(pubkey);
export const allowedPubkeys = (): string[] => accessList.keys();
@@ -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 };
}
+124
View File
@@ -0,0 +1,124 @@
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 { accessRouter } from "./access/routes.js";
import { config } from "./config.js";
import { logger } from "./logger.js";
import { authRouter } from "./auth/routes.js";
import { chatRouter } from "./chat/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:", "https:"],
"connect-src": ["'self'", "wss://relay.primal.net"],
"font-src": ["'self'", "data:"],
"manifest-src": ["'self'"],
"worker-src": ["'self'"],
"frame-ancestors": ["'none'"],
// The canonical provider validates the same-host dashboard broker.
"frame-src": config.providerPath ? ["'self'", "http:", "https:"] : ["'self'"],
"upgrade-insecure-requests": null,
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
originAgentCluster: false,
}),
);
if (config.corsOrigin) {
app.use(cors({ origin: config.corsOrigin, credentials: false }));
}
app.use(express.json({ limit: "32kb" }));
app.use(
pinoHttp({
logger,
autoLogging: {
ignore: (req) =>
req.url === "/healthz" ||
req.url === "/favicon.ico" ||
req.url.startsWith("/assets/"),
},
}),
);
app.get("/healthz", (_req, res) => {
res.json({ ok: true });
});
app.use("/api/auth", authRouter);
app.use("/api/access", accessRouter);
app.use("/api/chat", chatRouter);
app.use("/api/datum", datumRouter);
app.get("/nostr-provider.js", (_req, res) => {
res.setHeader("Cache-Control", "no-cache, no-store");
const provider = config.providerPath && (fs.existsSync(config.providerPath) ? config.providerPath : "/app/nostr-provider.js");
if (!provider || !fs.existsSync(provider)) {
res.status(503).type("application/javascript").send("/* Archipelago signer is not installed. */");
return;
}
res.type("application/javascript").send("if (!window.nostr) {\n" + fs.readFileSync(provider, "utf8") + "\n}");
});
// Unknown API paths must never become a successful HTML SPA response.
app.use("/api", (_req, res) => { res.status(404).json({ error: { code: "not_found" } }); });
const staticDir = config.staticDir
? config.staticDir
: resolveDefaultStaticDir();
if (staticDir && fs.existsSync(staticDir)) {
logger.info({ staticDir }, "serving web assets");
app.use(
express.static(staticDir, {
index: false,
maxAge: "1h",
setHeaders: (res, filePath) => {
if (filePath.endsWith("sw.js") || filePath.endsWith("manifest.webmanifest")) {
res.setHeader("Cache-Control", "no-cache");
}
},
}),
);
app.get(/.*/, (_req, res, next) => {
const indexFile = path.join(staticDir, "index.html");
if (!fs.existsSync(indexFile)) return next();
res.setHeader("Cache-Control", "no-cache");
if (config.providerPath) {
const html = fs.readFileSync(indexFile, "utf8");
res.type("html").send(html.replace("</head>",
'<script src="/nostr-provider.js?v=archipelago-v1" data-app-id="gashboard" data-no-nip98></script></head>'));
} else {
res.sendFile(indexFile);
}
});
} else {
logger.warn({ staticDir }, "web assets directory not found");
}
app.use(errorHandler);
return app;
}
function resolveDefaultStaticDir(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, "../../web/dist");
}
@@ -0,0 +1,88 @@
import { after, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { once } from "node:events";
import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { AccessList } from "../src/access/store.js";
const dir = mkdtempSync(join(tmpdir(), "gashboard-access-"));
after(() => rmSync(dir, { recursive: true, force: true }));
const owner = generateSecretKey();
const viewer = generateSecretKey();
const outsider = generateSecretKey();
const ownerHex = getPublicKey(owner);
const viewerHex = getPublicKey(viewer);
const viewerNpub = nip19.npubEncode(viewerHex);
test("membership survives restart; owners cannot be removed and corruption fails closed", () => {
const file = join(dir, "store.json");
const store = new AccessList(file, [ownerHex], []);
store.setViewer(viewerHex, true);
assert.equal(new AccessList(file, [ownerHex], []).isAllowed(viewerHex), true);
assert.throws(() => store.setViewer(ownerHex, false));
store.setViewer(viewerHex, false);
assert.equal(new AccessList(file, [ownerHex], []).isAllowed(viewerHex), false);
// Removed platform owners are not retained in the persisted viewer list.
assert.equal(new AccessList(file, [], []).isAllowed(ownerHex), false);
writeFileSync(file, "{}");
assert.throws(() => new AccessList(file, [ownerHex], []));
});
test("signed logins, owner-only invitations, immediate revocation, and provider cache policy", async () => {
process.env.JWT_SECRET = "local-test-only-secret-32-characters-long";
process.env.DATUM_ADMIN_PASSWORD = "local-test-only";
process.env.NOSTR_OWNER_PUBKEYS = ownerHex;
process.env.ACCESS_LIST_PATH = join(dir, "api-access.json");
process.env.ARCHIPELAGO_PROVIDER_PATH = join(dir, "provider.js");
process.env.LOG_LEVEL = "silent";
writeFileSync(process.env.ARCHIPELAGO_PROVIDER_PATH, "window.nostr = { test: true };");
const { buildApp } = await import("../src/server.js");
const server = buildApp().listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const call = (path: string, token = "", method = "GET", body?: unknown) => fetch(base + path, {
method,
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
...(body ? { body: JSON.stringify(body) } : {}),
});
async function login(key: Uint8Array) {
const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [["u", base + "/api/auth/login"], ["method", "POST"]], content: "" }, key);
return fetch(base + "/api/auth/login", { method: "POST", headers: { authorization: "Nostr " + Buffer.from(JSON.stringify(event)).toString("base64") } });
}
try {
assert.equal((await call("/api/access")).status, 401);
assert.equal((await login(outsider)).status, 403);
const ownerLogin = await login(owner);
assert.equal(ownerLogin.status, 200);
const ownerToken = (await ownerLogin.json()).token as string;
assert.equal((await call("/api/access", ownerToken, "POST", { npub: "not-a-key" })).status, 400);
assert.equal((await call("/api/access", ownerToken, "POST", { npub: viewerNpub })).status, 200);
const viewerLogin = await login(viewer);
assert.equal(viewerLogin.status, 200);
const viewerToken = (await viewerLogin.json()).token as string;
assert.equal((await call("/api/datum/stats", viewerToken)).status, 200);
assert.equal((await call("/api/access", viewerToken, "POST", { npub: nip19.npubEncode(getPublicKey(outsider)) })).status, 403);
assert.deepEqual(await (await call("/api/access", viewerToken)).json(), { isOwner: false, members: [] });
assert.equal((await call(`/api/access/${nip19.npubEncode(ownerHex)}`, ownerToken, "DELETE")).status, 400);
const stream = await call("/api/datum/stream", viewerToken);
const reader = stream.body!.getReader();
assert.equal((await reader.read()).done, false);
assert.equal((await call(`/api/access/${viewerNpub}`, ownerToken, "DELETE")).status, 200);
assert.equal((await reader.read()).done, true);
assert.equal((await call("/api/datum/stats", viewerToken)).status, 401);
assert.equal((await call("/api/chat", viewerToken)).status, 401);
assert.equal((await login(viewer)).status, 403);
assert.deepEqual(JSON.parse(readFileSync(process.env.ACCESS_LIST_PATH, "utf8")), []);
const provider = await call("/nostr-provider.js");
assert.equal(provider.headers.get("cache-control"), "no-cache, no-store");
assert.match(await provider.text(), /if \(!window.nostr\)/);
assert.equal((await call("/api/not-real")).status, 404);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
@@ -0,0 +1,15 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseClientsHtml } from "../src/datum/parse.js";
test("multiple miners of one model keep their worker names and full identities", () => {
const row = (username: string, agent: string) => "<tr>" +
["0", "192.0.2.3", username, "Subscribed", "2 s", "16384", "32768 (2)", "0 (0) 0%", "1.2 Th/s (1 s)", "", agent]
.map(value => `<td>${value}</td>`).join("") + "</tr>";
const miners = parseClientsHtml('<table><tr><th>Auth Username</th></tr>' +
row("addressA.kitchen", "bitaxe/BM1368") + row("addressA.garage", "bitaxe/BM1368") + row("addressB.kitchen", "unknown") + '</table>');
assert.equal(miners.length, 3);
assert.deepEqual(miners.map(m => m.nickname), ["kitchen", "garage", "kitchen"]);
assert.equal(new Set(miners.map(m => m.authUsername)).size, 3);
assert.ok(miners.every(m => m.location === "unassigned"));
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}