feat(apps): add Gashboard with native signing and viewer access
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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." } });
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user