fix(api): reject datum proxy shell responses

This commit is contained in:
Dorian
2026-05-06 17:19:19 +01:00
parent 2c80340039
commit 83b3a60497
3 changed files with 59 additions and 8 deletions

View File

@@ -83,6 +83,7 @@ export async function digestFetch(opts: DigestFetchOptions): Promise<Response> {
const uri = url.pathname + url.search; const uri = url.pathname + url.search;
const baseInit: RequestInit = { const baseInit: RequestInit = {
method, method,
redirect: "manual",
headers: { ...(opts.headers ?? {}) }, headers: { ...(opts.headers ?? {}) },
...(opts.signal ? { signal: opts.signal } : {}), ...(opts.signal ? { signal: opts.signal } : {}),
}; };

View File

@@ -62,6 +62,20 @@ function abortableSignal(timeoutMs: number): { signal: AbortSignal; cancel: () =
return { signal: ctrl.signal, cancel: () => clearTimeout(t) }; 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);
}
async function pollOnce(): Promise<DatumSnapshot> { async function pollOnce(): Promise<DatumSnapshot> {
const fetchedAt = Date.now(); const fetchedAt = Date.now();
@@ -76,7 +90,7 @@ async function pollOnce(): Promise<DatumSnapshot> {
creds: { username: config.datum.adminUser, password: config.datum.adminPassword }, creds: { username: config.datum.adminUser, password: config.datum.adminPassword },
signal: clientsTimeout.signal, signal: clientsTimeout.signal,
}), }),
fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal }), fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }),
]); ]);
clientsTimeout.cancel(); clientsTimeout.cancel();
threadsTimeout.cancel(); threadsTimeout.cancel();
@@ -104,18 +118,48 @@ async function pollOnce(): Promise<DatumSnapshot> {
}; };
} }
if (clientsRes.status !== 200) { if (clientsRes.status !== 200) {
const message = datumHttpMessage(clientsRes, "/clients");
logger.warn(
{ status: clientsRes.status, url: `${config.datum.url}/clients`, location: clientsRes.headers.get("location") },
"datum_clients_bad_status",
);
return { return {
...lastSnapshot, ...lastSnapshot,
ok: false, ok: false,
fetchedAt, fetchedAt,
error: { code: "DATUM_HTTP", message: `Datum /clients returned ${clientsRes.status}` }, error: { code: "DATUM_HTTP", message },
}; };
} }
const clientsHtml = await clientsRes.text(); 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: `${config.datum.url}/clients` }, "datum_clients_wrong_html");
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "DATUM_BAD_RESPONSE", message },
};
}
let threadsHtml = ""; let threadsHtml = "";
if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) { if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) {
threadsHtml = await threadsResSettled.value.text(); 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: `${config.datum.url}/threads`,
location: threadsResSettled.value.headers.get("location"),
},
"datum_threads_redirected",
);
} else if (threadsResSettled.status === "rejected") { } else if (threadsResSettled.status === "rejected") {
logger.warn( logger.warn(
{ reason: formatErr(threadsResSettled.reason) }, { reason: formatErr(threadsResSettled.reason) },
@@ -125,6 +169,12 @@ async function pollOnce(): Promise<DatumSnapshot> {
const miners = parseClientsHtml(clientsHtml); const miners = parseClientsHtml(clientsHtml);
const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : []; const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : [];
if (miners.length === 0) {
logger.warn(
{ url: `${config.datum.url}/clients`, bytes: clientsHtml.length },
"datum_clients_no_miners_parsed",
);
}
const combinedHashrateThs = miners.reduce((s, m) => s + m.hashrateThs, 0); const combinedHashrateThs = miners.reduce((s, m) => s + m.hashrateThs, 0);
const totalSubscriptions = const totalSubscriptions =

View File

@@ -17,17 +17,17 @@ export const STATUS_LINES = {
export const LOTTERY_LINES = [ export const LOTTERY_LINES = [
(oddsPerDay: number) => (oddsPerDay: number) =>
`P(block today): ${formatPct(oddsPerDay)}. P(struck by lightning today): 0.000028%. hash on, brave little board.`, `today's chance: ${formatPct(oddsPerDay)}. small number, enormous main-character energy.`,
(oddsPerDay: number) => (oddsPerDay: number) =>
`at this hashrate, a block is expected once every ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. compound those vibes.`, `statistically, the next block arrives in ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. emotionally, any minute now.`,
() => () =>
`lifetime tickets purchased: many. winners: 0. hope: priceless.`, `the math says no. the dashboard has chosen to hear "not yet."`,
(oddsPerDay: number) => (oddsPerDay: number) =>
`you're ${ratioVsLightning(oddsPerDay)}× as likely to find a block as get hit by lightning today. almost too easy.`, `versus lightning today: ${ratioVsLightning(oddsPerDay)}x. bad weather has better marketing.`,
() => () =>
`the network does not know you exist. and yet, you persist.`, `four tiny tickets in a planetary raffle. extremely unserious. deeply respected.`,
() => () =>
`every share is a ticket bought. nobody is buying with more conviction.`, `every share is a receipt for optimism with terrible accounting.`,
]; ];
export const BLOCK_CELEBRATION_LINES = [ export const BLOCK_CELEBRATION_LINES = [