mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
The client-IP resolver trusted one forwarding hop by default and read the rightmost X-Forwarded-For entry. Next fills that header with the socket's address only when the request carries none, so a caller reaching the published port directly could send its own and choose a fresh address per request: every per-IP limit (sign-in, second factor, passkey, share unlock, webhooks) became per-request, and sessions, the audit log and sign-in notices recorded the chosen address. The server now writes each connection's own peer address into an internal header from Node's http.server.request.start diagnostics channel, installed at boot and overwriting anything a caller sent under that name. The resolver reads forwarding headers only when that peer is loopback, private, unique-local or CGNAT, where a reverse proxy sits; a public peer is its own client address, and a log line says once that its forwarding headers were ignored. With TRUST_PROXY_HOPS=0 the peer is used instead of no address, so a directly exposed host no longer shares one bucket across all anonymous callers. Proxies on the same host, Docker network or tailnet need no change.
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import type { Instrumentation } from "next";
|
|
|
|
/**
|
|
* The admin notice for a host whose login-IP lookups leave it. The geo
|
|
* resolver evaluates it on its first public-IP lookup; it holds a slot
|
|
* rather than importing the notification layer (see the notice module).
|
|
*/
|
|
async function registerOfflineGeoNoticeAtBoot(): Promise<void> {
|
|
try {
|
|
const { registerOfflineGeoNotice } =
|
|
await import("@/lib/notifications/offline-geo-notice");
|
|
registerOfflineGeoNotice();
|
|
} catch {
|
|
// Without it the lookups still resolve; only the notice is missing.
|
|
}
|
|
}
|
|
|
|
export async function register() {
|
|
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
// First: every request then carries its socket's address (peer-address).
|
|
(await import("@/lib/http/peer-address")).installPeerAddressStamp();
|
|
|
|
// The event-loop monitor comes first, so the loading of the database
|
|
// client and of the worker graph is inside its view, and every stall it
|
|
// reports names the boot step that was running (`boot-steps.ts`).
|
|
const [{ startEventLoopLagMonitor }, { beginBootStep }] = await Promise.all(
|
|
[
|
|
import("@/lib/observability/event-loop-lag"),
|
|
import("@/lib/boot/boot-steps"),
|
|
],
|
|
);
|
|
startEventLoopLagMonitor();
|
|
|
|
// Every locale falls back to the English message catalog, so a process
|
|
// without a readable `messages/en.json` cannot serve a page or a job.
|
|
// Refuse to start with the file named, rather than fail every request.
|
|
const { assertDefaultCatalogReadable } =
|
|
await import("@/lib/i18n/server-catalogs");
|
|
assertDefaultCatalogReadable();
|
|
|
|
beginBootStep("readiness_summary");
|
|
|
|
// First-boot readiness summary: a human-readable block on the core secrets
|
|
// (with the exact fix on a bad one) before any fail-closed loader throws,
|
|
// plus a one-glance status of the optional channels. Runs for every process
|
|
// type (web + worker). Never throws — the real gate stays the loaders.
|
|
try {
|
|
const { logReadinessSummary } =
|
|
await import("@/lib/boot/readiness-summary");
|
|
logReadinessSummary();
|
|
} catch {
|
|
// A readiness summary must never break boot.
|
|
}
|
|
|
|
await registerOfflineGeoNoticeAtBoot();
|
|
|
|
// Does the configured encryption key open this database? A process whose
|
|
// key does not refuses every API request with one clear 503 and starts
|
|
// neither the producer nor the worker (a wrong key must never write).
|
|
beginBootStep("key_check");
|
|
try {
|
|
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
|
|
import("@/lib/boot/key-check"),
|
|
import("@/lib/db"),
|
|
]);
|
|
if (await runBootKeyCheck(prisma)) return;
|
|
} catch (err) {
|
|
console.error("[instrumentation] Encryption key check crashed:", err);
|
|
}
|
|
|
|
// Every connection is pinned to UTC; say clearly if one is not.
|
|
beginBootStep("time_zone_check");
|
|
try {
|
|
const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([
|
|
import("@/lib/boot/time-zone-check"),
|
|
import("@/lib/db"),
|
|
]);
|
|
await runBootTimeZoneCheck(prisma);
|
|
} catch {
|
|
// A diagnostic must never break boot.
|
|
}
|
|
|
|
// Every pulse reader follows the operator's pulse day-mean method; mirror
|
|
// it in this process and keep the mirror current.
|
|
beginBootStep("pulse_method_sync");
|
|
try {
|
|
const { startPulseDayMeanMethodSync } =
|
|
await import("@/lib/measurements/pulse-day-mean-method");
|
|
await startPulseDayMeanMethodSync();
|
|
} catch {
|
|
// Without the mirror the process keeps the default method.
|
|
}
|
|
|
|
// Every Node process watches its heap as it watches its event loop: a
|
|
// trend line, and one report naming the work in flight before the heap
|
|
// runs out (a heap-limit crash logs nothing else).
|
|
beginBootStep("monitors");
|
|
try {
|
|
const [{ startHttpInFlightTracking }, { startHeapMonitor }] =
|
|
await Promise.all([
|
|
import("@/lib/observability/in-flight"),
|
|
import("@/lib/observability/heap-monitor"),
|
|
]);
|
|
startHttpInFlightTracking();
|
|
startHeapMonitor();
|
|
} catch {
|
|
// A diagnostic must never break boot.
|
|
}
|
|
|
|
// One line when the container's memory limit is below the supported
|
|
// floor. The app keeps running.
|
|
try {
|
|
const { logMemoryFloorAtBoot } = await import("@/lib/support/heap-limit");
|
|
logMemoryFloorAtBoot();
|
|
} catch {
|
|
// A diagnostic must never break boot.
|
|
}
|
|
|
|
// The background role starts after the server is up. Awaiting it here
|
|
// held every request, `/api/health` included, for the seconds pg-boss
|
|
// needs to start, provision its queues and run the boot discovery; the
|
|
// probe now answers `starting` meanwhile. A request that enqueues while
|
|
// the boss is not bound yet takes the same path as during a producer
|
|
// reconnect: the enqueue waits for the boss in the background
|
|
// (`enqueueWhenBossReady`, bounded and logged when it gives up), and a
|
|
// route that needs the worker answers 503 until it is up.
|
|
const [
|
|
{ shouldRunWorker },
|
|
{ markStartup, startupPhase, startupElapsedMs, STARTING_MAX_MS },
|
|
] = await Promise.all([
|
|
import("@/lib/process-type"),
|
|
import("@/lib/boot/startup-state"),
|
|
]);
|
|
markStartup("starting");
|
|
// One line when the start takes longer than any real boot; from then on
|
|
// the probe reports plain `degraded` instead of `starting`.
|
|
setTimeout(() => {
|
|
if (startupPhase() === "stalled") {
|
|
console.warn(
|
|
`[boot] The background role is still not up after ${Math.round(STARTING_MAX_MS / 1000)} s; /api/health now reports degraded without "starting".`,
|
|
);
|
|
}
|
|
}, STARTING_MAX_MS + 1_000).unref();
|
|
const ready = (up: boolean) => {
|
|
const elapsed = startupElapsedMs();
|
|
markStartup(up ? "ready" : "failed");
|
|
console.info(
|
|
`[boot] background role ${up ? "ready" : "failed"} after ${elapsed ?? 0} ms`,
|
|
);
|
|
};
|
|
if (!shouldRunWorker()) {
|
|
// Split deployment: web requests still produce queue jobs, but this
|
|
// process owns no schedules, supervision, or consumers.
|
|
void (async () => {
|
|
try {
|
|
beginBootStep("producer_start");
|
|
const { startGlobalBossProducer } =
|
|
await import("@/lib/jobs/boss-instance");
|
|
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
|
|
if (boss) {
|
|
const { registerShutdownHandlers } =
|
|
await import("@/lib/jobs/reminder/worker-lifecycle");
|
|
registerShutdownHandlers(boss);
|
|
}
|
|
ready(boss !== null);
|
|
const { reportBootSteps } = await import("@/lib/boot/boot-steps");
|
|
await reportBootSteps();
|
|
} catch (err) {
|
|
ready(false);
|
|
// Queue-backed garnishes degrade independently; never make the web
|
|
// server unavailable because its producer connection failed.
|
|
console.error(
|
|
"[instrumentation] Failed to start pg-boss producer:",
|
|
err,
|
|
);
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
const { WideEventBuilder } = await import("@/lib/logging/event-builder");
|
|
const { emitIfSampled } = await import("@/lib/logging/transports");
|
|
|
|
// Emit startup event
|
|
const startupEvt = new WideEventBuilder("background");
|
|
startupEvt.setBackground({ task_name: "startup" });
|
|
|
|
try {
|
|
beginBootStep("worker_import");
|
|
const { startReminderWorker } =
|
|
await import("@/lib/jobs/reminder-worker");
|
|
await startReminderWorker();
|
|
ready(true);
|
|
|
|
startupEvt.addMeta("reminder_worker", "started");
|
|
startupEvt.finish();
|
|
emitIfSampled(startupEvt.toJSON());
|
|
} catch (err) {
|
|
ready(false);
|
|
startupEvt.setError(err);
|
|
startupEvt.addMeta("reminder_worker", "failed");
|
|
startupEvt.finish();
|
|
emitIfSampled(startupEvt.toJSON());
|
|
|
|
// Still log to stderr for container health checks
|
|
console.error(
|
|
"[instrumentation] CRITICAL: Failed to start reminder worker:",
|
|
err,
|
|
);
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
|
|
export const onRequestError: Instrumentation.onRequestError = async (
|
|
error,
|
|
request,
|
|
context,
|
|
) => {
|
|
// Prisma-based settings lookup is not available in edge instrumentation.
|
|
if (process.env.NEXT_RUNTIME === "edge") return;
|
|
|
|
try {
|
|
const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] =
|
|
await Promise.all([
|
|
import("@/lib/monitoring-settings"),
|
|
import("@/lib/monitoring/glitchtip"),
|
|
]);
|
|
|
|
const settings = await getGlitchtipSettings();
|
|
if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return;
|
|
|
|
const err =
|
|
error instanceof Error
|
|
? error
|
|
: new Error(
|
|
typeof error === "string" ? error : "Unhandled request error",
|
|
);
|
|
|
|
// Skip expected errors from bot scanners (malformed JSON bodies)
|
|
if (err instanceof SyntaxError) return;
|
|
|
|
const userAgentHeader = request.headers["user-agent"];
|
|
const userAgent = Array.isArray(userAgentHeader)
|
|
? userAgentHeader[0]
|
|
: userAgentHeader;
|
|
|
|
const details = [
|
|
`Route: ${context.routePath}`,
|
|
`Type: ${context.routeType}`,
|
|
`Router: ${context.routerKind}`,
|
|
`Method: ${request.method}`,
|
|
`Path: ${request.path}`,
|
|
context.renderSource ? `Render: ${context.renderSource}` : null,
|
|
context.revalidateReason
|
|
? `Revalidate: ${context.revalidateReason}`
|
|
: null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | ");
|
|
|
|
const message = `${err.message} [${details}]`.slice(0, 1900);
|
|
|
|
// Try to get request_id from Wide Event context
|
|
let requestId: string | undefined;
|
|
try {
|
|
const { getEvent } = await import("@/lib/logging/context");
|
|
requestId = getEvent()?.getRequestId();
|
|
} catch {
|
|
// Logging not available in this context
|
|
}
|
|
|
|
const delivery = await sendGlitchtipEvent({
|
|
dsn: settings.glitchtipDsn,
|
|
input: {
|
|
environment: settings.glitchtipEnvironment || "production",
|
|
message,
|
|
stack: err.stack,
|
|
level: "error",
|
|
type: err.name || "RequestError",
|
|
url: request.path,
|
|
userAgent,
|
|
sourceTag: "healthlog-server",
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
if (!delivery.ok) {
|
|
console.error(
|
|
"Global Glitchtip request error reporting failed:",
|
|
delivery.status,
|
|
delivery.details,
|
|
);
|
|
}
|
|
} catch (reportError) {
|
|
console.error(
|
|
"Global Glitchtip request error reporting crashed:",
|
|
reportError,
|
|
);
|
|
}
|
|
};
|