fix(api): reject datum proxy shell responses
This commit is contained in:
@@ -83,6 +83,7 @@ export async function digestFetch(opts: DigestFetchOptions): Promise<Response> {
|
||||
const uri = url.pathname + url.search;
|
||||
const baseInit: RequestInit = {
|
||||
method,
|
||||
redirect: "manual",
|
||||
headers: { ...(opts.headers ?? {}) },
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,20 @@ function abortableSignal(timeoutMs: number): { signal: AbortSignal; cancel: () =
|
||||
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> {
|
||||
const fetchedAt = Date.now();
|
||||
|
||||
@@ -76,7 +90,7 @@ async function pollOnce(): Promise<DatumSnapshot> {
|
||||
creds: { username: config.datum.adminUser, password: config.datum.adminPassword },
|
||||
signal: clientsTimeout.signal,
|
||||
}),
|
||||
fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal }),
|
||||
fetch(`${config.datum.url}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }),
|
||||
]);
|
||||
clientsTimeout.cancel();
|
||||
threadsTimeout.cancel();
|
||||
@@ -104,18 +118,48 @@ async function pollOnce(): Promise<DatumSnapshot> {
|
||||
};
|
||||
}
|
||||
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 {
|
||||
...lastSnapshot,
|
||||
ok: false,
|
||||
fetchedAt,
|
||||
error: { code: "DATUM_HTTP", message: `Datum /clients returned ${clientsRes.status}` },
|
||||
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: `${config.datum.url}/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: `${config.datum.url}/threads`,
|
||||
location: threadsResSettled.value.headers.get("location"),
|
||||
},
|
||||
"datum_threads_redirected",
|
||||
);
|
||||
} else if (threadsResSettled.status === "rejected") {
|
||||
logger.warn(
|
||||
{ reason: formatErr(threadsResSettled.reason) },
|
||||
@@ -125,6 +169,12 @@ async function pollOnce(): Promise<DatumSnapshot> {
|
||||
|
||||
const miners = parseClientsHtml(clientsHtml);
|
||||
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 totalSubscriptions =
|
||||
|
||||
@@ -17,17 +17,17 @@ export const STATUS_LINES = {
|
||||
|
||||
export const LOTTERY_LINES = [
|
||||
(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) =>
|
||||
`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) =>
|
||||
`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 = [
|
||||
|
||||
Reference in New Issue
Block a user