feat(health): деплоить probe-Worker из CFDM и опрашивать цели с edge
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 2m21s
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 2m21s
Worker сам ходит на origin по Cron Trigger; CFDM кладёт цели в KV и забирает результаты без POST /probe. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup src/server.ts --format esm --dts",
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -35,8 +35,10 @@ import { auditRoutes } from "./routes/audit.js";
|
||||
import * as certificateService from "./services/certificate-service.js";
|
||||
import {
|
||||
createHealthCheckTask,
|
||||
healthEngineFallbacksFromConfig,
|
||||
scheduleHealthCheckJob,
|
||||
} from "./services/health-check-scheduler.js";
|
||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
|
||||
export interface BuildAppOptions {
|
||||
@@ -131,6 +133,14 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
app.decorate("reloadHealthCheckJob", () => {
|
||||
scheduleHealthCheckJob(app, config, healthTask);
|
||||
});
|
||||
if (config.cloudflareApiToken) {
|
||||
fireEnsureHealthWorker(
|
||||
app.db,
|
||||
app.cf,
|
||||
healthEngineFallbacksFromConfig(config),
|
||||
app.log,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
} from "@cfdm/shared";
|
||||
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
||||
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
||||
import { createKvAdapter } from "./cloudflare/kv-service.js";
|
||||
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
|
||||
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
||||
|
||||
export type { CfHealthCheckPayload };
|
||||
@@ -15,11 +17,21 @@ export class CloudflareClient {
|
||||
private readonly zones;
|
||||
private readonly dns;
|
||||
private readonly healthchecks;
|
||||
private readonly kv;
|
||||
private readonly workers;
|
||||
private readonly token;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token.trim();
|
||||
this.zones = createZoneAdapter(token);
|
||||
this.dns = createDnsAdapter(token);
|
||||
this.healthchecks = createHealthCheckAdapter(token);
|
||||
this.kv = createKvAdapter(token);
|
||||
this.workers = createWorkersAdapter(token);
|
||||
}
|
||||
|
||||
get isConfigured(): boolean {
|
||||
return this.token.length > 0;
|
||||
}
|
||||
|
||||
listZones(): Promise<CfZone[]> {
|
||||
@@ -81,4 +93,45 @@ export class CloudflareClient {
|
||||
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
||||
}
|
||||
|
||||
listAccounts() {
|
||||
return this.workers.listAccounts();
|
||||
}
|
||||
|
||||
listKvNamespaces(accountId: string) {
|
||||
return this.kv.listNamespaces(accountId);
|
||||
}
|
||||
|
||||
createKvNamespace(accountId: string, title: string) {
|
||||
return this.kv.createNamespace(accountId, title);
|
||||
}
|
||||
|
||||
kvGet(accountId: string, namespaceId: string, key: string) {
|
||||
return this.kv.getValue(accountId, namespaceId, key);
|
||||
}
|
||||
|
||||
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
|
||||
return this.kv.putValue(accountId, namespaceId, key, value);
|
||||
}
|
||||
|
||||
putWorkerScript(opts: {
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
source: string;
|
||||
kvNamespaceId: string;
|
||||
}) {
|
||||
return this.workers.putScript(opts);
|
||||
}
|
||||
|
||||
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
|
||||
return this.workers.putSchedules(accountId, scriptName, crons);
|
||||
}
|
||||
|
||||
enableWorkersDev(accountId: string, scriptName: string) {
|
||||
return this.workers.enableWorkersDev(accountId, scriptName);
|
||||
}
|
||||
|
||||
getWorkersSubdomain(accountId: string) {
|
||||
return this.workers.getWorkersSubdomain(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ export function mapCloudflareFailure(
|
||||
): AppError {
|
||||
const lower = message.toLowerCase();
|
||||
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||||
if (
|
||||
operation.includes("workers") ||
|
||||
operation.includes("kv_") ||
|
||||
operation.includes("accounts")
|
||||
) {
|
||||
return AppError.cloudflareAuthFailed(
|
||||
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
|
||||
);
|
||||
}
|
||||
return AppError.cloudflareAuthFailed(
|
||||
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
||||
);
|
||||
@@ -67,6 +76,40 @@ export async function handleCfResponse<T>(
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/** KV PUT / schedules often return `{ success: true }` without `result`. */
|
||||
export async function handleCfSuccess(
|
||||
response: Response,
|
||||
operation: string,
|
||||
): Promise<void> {
|
||||
if (response.status === 429) {
|
||||
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||
throw AppError.rateLimited(
|
||||
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
if (!response.ok) {
|
||||
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let body: CfResponse<unknown>;
|
||||
try {
|
||||
body = JSON.parse(text) as CfResponse<unknown>;
|
||||
} catch {
|
||||
if (!response.ok) {
|
||||
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!body.success) {
|
||||
const msg =
|
||||
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||||
throw mapCloudflareFailure(operation, response.status, msg);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cfRequest<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
|
||||
|
||||
export interface CfKvNamespace {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function createKvAdapter(token: string) {
|
||||
return {
|
||||
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
|
||||
const all: CfKvNamespace[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const url = new URL(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||
);
|
||||
url.searchParams.set("per_page", "100");
|
||||
url.searchParams.set("page", String(page));
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
|
||||
}
|
||||
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
|
||||
all.push(...batch);
|
||||
if (batch.length < 100) break;
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
},
|
||||
|
||||
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
|
||||
}
|
||||
return handleCfResponse<CfKvNamespace>(response, "kv_create");
|
||||
},
|
||||
|
||||
async getValue(
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status === 404) return null;
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
|
||||
}
|
||||
return response.text();
|
||||
},
|
||||
|
||||
async putValue(
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body: value,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
|
||||
}
|
||||
await handleCfSuccess(response, "kv_put");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
CF_API_BASE,
|
||||
handleCfResponse,
|
||||
handleCfSuccess,
|
||||
mapCloudflareFailure,
|
||||
} from "./http.js";
|
||||
|
||||
export interface CfAccount {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CfWorkersSubdomain {
|
||||
subdomain?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function createWorkersAdapter(token: string) {
|
||||
return {
|
||||
async listAccounts(): Promise<CfAccount[]> {
|
||||
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
|
||||
}
|
||||
return handleCfResponse<CfAccount[]>(response, "list_accounts");
|
||||
},
|
||||
|
||||
async putScript(opts: {
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
source: string;
|
||||
kvNamespaceId: string;
|
||||
filename?: string;
|
||||
}): Promise<void> {
|
||||
const filename = opts.filename ?? "index.mjs";
|
||||
const metadata = {
|
||||
main_module: filename,
|
||||
compatibility_date: "2025-04-01",
|
||||
bindings: [
|
||||
{
|
||||
type: "kv_namespace",
|
||||
name: "HEALTH_KV",
|
||||
namespace_id: opts.kvNamespaceId,
|
||||
},
|
||||
],
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"metadata",
|
||||
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
|
||||
);
|
||||
form.append(
|
||||
filename,
|
||||
new Blob([opts.source], { type: "application/javascript+module" }),
|
||||
filename,
|
||||
);
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
|
||||
}
|
||||
await handleCfSuccess(response, "workers_put_script");
|
||||
},
|
||||
|
||||
async putSchedules(
|
||||
accountId: string,
|
||||
scriptName: string,
|
||||
crons: string[],
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(crons.map((cron) => ({ cron }))),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
|
||||
}
|
||||
await handleCfSuccess(response, "workers_put_schedules");
|
||||
},
|
||||
|
||||
async enableWorkersDev(
|
||||
accountId: string,
|
||||
scriptName: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status === 409) return;
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
|
||||
}
|
||||
if (!response.ok && response.status !== 200 && response.status !== 201) {
|
||||
await handleCfSuccess(response, "workers_subdomain");
|
||||
}
|
||||
},
|
||||
|
||||
async getWorkersSubdomain(accountId: string): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status === 404) return null;
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
|
||||
}
|
||||
const result = await handleCfResponse<CfWorkersSubdomain>(
|
||||
response,
|
||||
"workers_get_subdomain",
|
||||
);
|
||||
return result.subdomain?.trim() || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import * as healthCheckService from "../services/health-check-service.js";
|
||||
import * as serviceConfigService from "../services/service-config-service.js";
|
||||
import {
|
||||
healthEngineFallbacksFromConfig,
|
||||
resolveWorkerProbeConfig,
|
||||
} from "../services/health-check-scheduler.js";
|
||||
import { mailboxFromSettings } from "../services/health/health-worker-deploy.js";
|
||||
import { cronStaleAfterMs } from "../services/health/mailbox.js";
|
||||
|
||||
export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
app.get("/health-status", async (request) => {
|
||||
@@ -20,9 +21,10 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post("/health-check/run", async (request) => {
|
||||
const config = request.server.config;
|
||||
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||
const settings = getAppSettings(
|
||||
request.server.db,
|
||||
healthEngineFallbacksFromConfig(config),
|
||||
fallbacks,
|
||||
);
|
||||
const thresholds = {
|
||||
degradedFailures: settings.healthDegradedFailures,
|
||||
@@ -33,7 +35,8 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
worker: resolveWorkerProbeConfig(request.server.db, config),
|
||||
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
|
||||
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
const label =
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
assertValidHealthCron,
|
||||
healthEngineFallbacksFromConfig,
|
||||
} from "../services/health-check-scheduler.js";
|
||||
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get("/settings", async (request) => {
|
||||
@@ -40,11 +41,29 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
"ошибок до down не меньше, чем до degraded",
|
||||
);
|
||||
}
|
||||
const next = updateAppSettings(request.server.db, body, fallbacks);
|
||||
updateAppSettings(request.server.db, body, fallbacks);
|
||||
if (body.healthCheckCron !== undefined) {
|
||||
request.server.reloadHealthCheckJob?.();
|
||||
const after = getAppSettings(request.server.db, fallbacks);
|
||||
if (after.healthWorkerKvNamespaceId) {
|
||||
try {
|
||||
await ensureHealthWorker(
|
||||
request.server.db,
|
||||
request.server.cf,
|
||||
fallbacks,
|
||||
);
|
||||
} catch {
|
||||
// error stored in settings
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
return getAppSettings(request.server.db, fallbacks);
|
||||
});
|
||||
|
||||
app.post("/settings/health/worker/ensure", async (request) => {
|
||||
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||
await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);
|
||||
return getAppSettings(request.server.db, fallbacks);
|
||||
});
|
||||
|
||||
app.post("/settings/vps-tracker/test", async (request) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
import {
|
||||
getAppSettings,
|
||||
getAppSettingsSecrets,
|
||||
updateAppSettings,
|
||||
type HealthEngineFallbacks,
|
||||
} from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
@@ -10,6 +10,10 @@ import type { AppConfig } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import * as healthCheckService from "./health-check-service.js";
|
||||
import * as serviceConfigService from "./service-config-service.js";
|
||||
import {
|
||||
mailboxFromSettings,
|
||||
} from "./health/health-worker-deploy.js";
|
||||
import { cronStaleAfterMs } from "./health/mailbox.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
@@ -33,18 +37,6 @@ export function healthEngineFallbacksFromConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkerProbeConfig(
|
||||
db: import("@cfdm/db").Db,
|
||||
config: AppConfig,
|
||||
): { url: string; token: string } | null {
|
||||
const settings = getAppSettings(db, healthEngineFallbacksFromConfig(config));
|
||||
const secrets = getAppSettingsSecrets(db);
|
||||
const url = settings.healthWorkerUrl.trim();
|
||||
const token = (secrets.healthWorkerToken || config.healthWorkerToken).trim();
|
||||
if (!url || !token) return null;
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
export function assertValidHealthCron(expr: string): void {
|
||||
const cronExpression = expr.trim();
|
||||
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
||||
@@ -78,10 +70,12 @@ export function createHealthCheckTask(
|
||||
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||
successRecoveries: settings.healthSuccessRecoveries,
|
||||
};
|
||||
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
|
||||
const n = await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
worker: resolveWorkerProbeConfig(app.db, config),
|
||||
mailbox,
|
||||
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
const label =
|
||||
@@ -114,6 +108,13 @@ export function createHealthCheckTask(
|
||||
}
|
||||
},
|
||||
});
|
||||
if (mailbox) {
|
||||
updateAppSettings(
|
||||
app.db,
|
||||
{ healthWorkerLastIngestAt: new Date().toISOString() },
|
||||
fallbacks,
|
||||
);
|
||||
}
|
||||
const monitors = await healthCheckService.runDomainMonitors(
|
||||
app.db,
|
||||
thresholds,
|
||||
|
||||
@@ -7,10 +7,14 @@ import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||
import { AppError } from "../errors.js";
|
||||
import { nextHealthState } from "./health/state-machine.js";
|
||||
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||
import { workerNotConfiguredResult } from "./health/worker.js";
|
||||
import {
|
||||
CloudflareWorkerHealthCheckProvider,
|
||||
workerNotConfiguredResult,
|
||||
} from "./health/worker.js";
|
||||
buildTargetsDoc,
|
||||
indexResults,
|
||||
isResultsStale,
|
||||
originProbeKey,
|
||||
type HealthMailbox,
|
||||
} from "./health/mailbox.js";
|
||||
|
||||
export interface HealthCheckThresholds {
|
||||
degradedFailures: number;
|
||||
@@ -267,8 +271,10 @@ export interface RunAllChecksOptions {
|
||||
thresholds: HealthCheckThresholds;
|
||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||
probeGapMs?: number;
|
||||
/** Cloudflare Worker URL+token. Missing → cloudflare targets fail, never Local fallback. */
|
||||
worker?: { url: string; token: string } | null;
|
||||
/** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */
|
||||
mailbox?: HealthMailbox | null;
|
||||
/** Results older than this are stale (default 10 min). */
|
||||
staleAfterMs?: number;
|
||||
onStatusChange?: (
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
@@ -286,33 +292,82 @@ function sleep(ms: number): Promise<void> {
|
||||
*/
|
||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||
const kind = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||
const ip = String(target.ip || "").trim().toLowerCase();
|
||||
if (target.type === "http") {
|
||||
const path = (target.path?.trim() || "/") || "/";
|
||||
const expected = target.expected_status ?? "";
|
||||
return `${kind}|http|${ip}|${port}|${path}|${expected}`;
|
||||
}
|
||||
if (target.type === "tcp") return `${kind}|tcp|${ip}|${port}`;
|
||||
if (target.type === "ping") {
|
||||
return `${kind}|ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
if (target.type === "dns") {
|
||||
return `${kind}|dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
return `${kind}|${target.type}|${ip}|${port}`;
|
||||
return `${kind}|${originProbeKey(target)}`;
|
||||
}
|
||||
|
||||
async function executeProbe(
|
||||
function applyProbeResult(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
local: LocalHealthCheckProvider,
|
||||
worker: CloudflareWorkerHealthCheckProvider | null,
|
||||
): Promise<ProbeResult> {
|
||||
if (target.provider === "cloudflare") {
|
||||
if (!worker) return workerNotConfiguredResult();
|
||||
return worker.probe(target);
|
||||
result: ProbeResult,
|
||||
options: RunAllChecksOptions,
|
||||
): void {
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures, successes, node } = deriveState(
|
||||
result.ok,
|
||||
result.latencyMs,
|
||||
prev
|
||||
? {
|
||||
consecutive_failures: prev.consecutive_failures,
|
||||
consecutive_successes: prev.consecutive_successes,
|
||||
status: prev.status,
|
||||
}
|
||||
: null,
|
||||
options.thresholds,
|
||||
);
|
||||
const prevState: IpHealthState | null = prev
|
||||
? (prev.status as IpHealthState)
|
||||
: null;
|
||||
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
state,
|
||||
result.latencyMs,
|
||||
failures,
|
||||
result.error,
|
||||
successes,
|
||||
{ colo: result.colo ?? null, provider },
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: target.scope,
|
||||
refId: target.ref_id,
|
||||
ip: target.ip,
|
||||
provider,
|
||||
status: state,
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
colo: result.colo ?? null,
|
||||
error: result.error,
|
||||
});
|
||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||
if (matchedNode && matchedNode.enabled) {
|
||||
repos.updateNode(db, matchedNode.id, {
|
||||
health_status: node,
|
||||
consecutive_failures: failures,
|
||||
consecutive_successes: successes,
|
||||
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||
last_failure_reason: result.error,
|
||||
});
|
||||
}
|
||||
return local.probe(target);
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
}
|
||||
|
||||
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||
colo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAllChecks(
|
||||
@@ -322,10 +377,7 @@ export async function runAllChecks(
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||
const local = new LocalHealthCheckProvider();
|
||||
const worker =
|
||||
options.worker?.url && options.worker.token
|
||||
? new CloudflareWorkerHealthCheckProvider(options.worker)
|
||||
: null;
|
||||
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
|
||||
|
||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||
for (const target of targets) {
|
||||
@@ -335,80 +387,69 @@ export async function runAllChecks(
|
||||
else byPhysical.set(key, [target]);
|
||||
}
|
||||
|
||||
let probeIndex = 0;
|
||||
const localGroups: HealthCheckTarget[][] = [];
|
||||
const cloudflareGroups: HealthCheckTarget[][] = [];
|
||||
for (const group of byPhysical.values()) {
|
||||
if (group[0]?.provider === "cloudflare") cloudflareGroups.push(group);
|
||||
else localGroups.push(group);
|
||||
}
|
||||
|
||||
let probeIndex = 0;
|
||||
for (const group of localGroups) {
|
||||
if (probeIndex > 0 && gapMs > 0) {
|
||||
await sleep(gapMs);
|
||||
}
|
||||
probeIndex += 1;
|
||||
|
||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const result = await executeProbe(representative, local, worker);
|
||||
|
||||
const result = await local.probe(representative);
|
||||
for (const target of group) {
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures, successes, node } = deriveState(
|
||||
result.ok,
|
||||
result.latencyMs,
|
||||
prev
|
||||
? {
|
||||
consecutive_failures: prev.consecutive_failures,
|
||||
consecutive_successes: prev.consecutive_successes,
|
||||
status: prev.status,
|
||||
}
|
||||
: null,
|
||||
options.thresholds,
|
||||
);
|
||||
const prevState: IpHealthState | null = prev
|
||||
? (prev.status as IpHealthState)
|
||||
: null;
|
||||
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
state,
|
||||
result.latencyMs,
|
||||
failures,
|
||||
result.error,
|
||||
successes,
|
||||
{ colo: result.colo ?? null, provider },
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: target.scope,
|
||||
refId: target.ref_id,
|
||||
ip: target.ip,
|
||||
provider,
|
||||
status: state,
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
colo: result.colo ?? null,
|
||||
error: result.error,
|
||||
});
|
||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||
if (matchedNode && matchedNode.enabled) {
|
||||
repos.updateNode(db, matchedNode.id, {
|
||||
health_status: node,
|
||||
consecutive_failures: failures,
|
||||
consecutive_successes: successes,
|
||||
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||
last_failure_reason: result.error,
|
||||
});
|
||||
applyProbeResult(db, target, result, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudflareGroups.length > 0) {
|
||||
const mailbox = options.mailbox ?? null;
|
||||
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||
const byKey = indexResults(resultsDoc);
|
||||
const stale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
const colo = resultsDoc?.colo ?? null;
|
||||
|
||||
if (mailbox) {
|
||||
try {
|
||||
const next = buildTargetsDoc(targets);
|
||||
const current = await mailbox.getTargets();
|
||||
if (current?.fingerprint !== next.fingerprint) {
|
||||
await mailbox.putTargets(next);
|
||||
}
|
||||
} catch {
|
||||
// ingest still proceeds
|
||||
}
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
|
||||
for (const group of cloudflareGroups) {
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const item = byKey.get(originProbeKey(representative));
|
||||
let result: ProbeResult;
|
||||
if (!mailbox) {
|
||||
result = workerNotConfiguredResult();
|
||||
} else if (stale || !item) {
|
||||
result = staleWorkerResult(colo);
|
||||
} else {
|
||||
result = {
|
||||
ok: item.ok,
|
||||
latencyMs: item.latencyMs,
|
||||
error: item.error,
|
||||
colo,
|
||||
};
|
||||
}
|
||||
for (const target of group) {
|
||||
applyProbeResult(db, target, result, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
||||
|
||||
repos.pruneStaleIpHealthStatus(db, targets);
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function loadHealthProbeWorkerSource(): string {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
join(dir, "health-probe-worker.mjs"),
|
||||
join(process.cwd(), "dist/health-probe-worker.mjs"),
|
||||
join(process.cwd(), "health-probe-worker.mjs"),
|
||||
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
|
||||
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
|
||||
join(process.cwd(), "workers/health-probe/src/index.mjs"),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
}
|
||||
throw new Error("не найден исходник Worker health-probe");
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||
import { AppError } from "../../errors.js";
|
||||
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
|
||||
import {
|
||||
buildTargetsDoc,
|
||||
createCloudflareKvMailbox,
|
||||
toCloudflareCron,
|
||||
type HealthMailbox,
|
||||
} from "./mailbox.js";
|
||||
|
||||
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
|
||||
healthCheckCron: "0 */2 * * * *",
|
||||
healthDegradedFailures: 1,
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1000,
|
||||
healthSuccessRecoveries: 2,
|
||||
healthWorkerUrl: "",
|
||||
healthWorkerTokenSet: false,
|
||||
};
|
||||
|
||||
export async function resolveAccountId(
|
||||
cf: CloudflareClient,
|
||||
db: Db,
|
||||
cached?: string | null,
|
||||
): Promise<string> {
|
||||
const trimmed = cached?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const domains = repos.listDomains(db);
|
||||
for (const domain of domains) {
|
||||
if (!domain.cf_zone_id) continue;
|
||||
try {
|
||||
const zone = await cf.getZone(domain.cf_zone_id);
|
||||
const id = zone.account?.id?.trim();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
// try next zone / accounts list
|
||||
}
|
||||
}
|
||||
const accounts = await cf.listAccounts();
|
||||
const id = accounts[0]?.id?.trim();
|
||||
if (!id) {
|
||||
throw AppError.cloudflare(
|
||||
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function ensureKvNamespace(
|
||||
cf: CloudflareClient,
|
||||
accountId: string,
|
||||
existingId?: string | null,
|
||||
): Promise<string> {
|
||||
if (existingId?.trim()) return existingId.trim();
|
||||
const listed = await cf.listKvNamespaces(accountId);
|
||||
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
|
||||
if (found?.id) return found.id;
|
||||
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
|
||||
if (!created.id) {
|
||||
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function ensureHealthWorker(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
try {
|
||||
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
|
||||
const kvNamespaceId = await ensureKvNamespace(
|
||||
cf,
|
||||
accountId,
|
||||
settings.healthWorkerKvNamespaceId,
|
||||
);
|
||||
const source = loadHealthProbeWorkerSource();
|
||||
await cf.putWorkerScript({
|
||||
accountId,
|
||||
scriptName: HEALTH_PROBE_SCRIPT_NAME,
|
||||
source,
|
||||
kvNamespaceId,
|
||||
});
|
||||
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
|
||||
toCloudflareCron(settings.healthCheckCron),
|
||||
]);
|
||||
try {
|
||||
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
|
||||
} catch {
|
||||
// workers.dev may already be on
|
||||
}
|
||||
const subdomain = await cf.getWorkersSubdomain(accountId);
|
||||
const url = subdomain
|
||||
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
|
||||
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
|
||||
|
||||
updateAppSettings(
|
||||
db,
|
||||
{
|
||||
healthWorkerAccountId: accountId,
|
||||
healthWorkerKvNamespaceId: kvNamespaceId,
|
||||
healthWorkerUrl: url,
|
||||
healthWorkerError: null,
|
||||
healthWorkerDeployedAt: new Date().toISOString(),
|
||||
},
|
||||
fallbacks,
|
||||
);
|
||||
|
||||
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||
return { url, kvNamespaceId, accountId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeEnsureHealthWorker(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
): Promise<void> {
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => target.provider === "cloudflare");
|
||||
if (!hasCloudflare) return;
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||
return;
|
||||
}
|
||||
await ensureHealthWorker(db, cf, fallbacks);
|
||||
}
|
||||
|
||||
export function mailboxFromSettings(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
): HealthMailbox | null {
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
const accountId = settings.healthWorkerAccountId.trim();
|
||||
const ns = settings.healthWorkerKvNamespaceId.trim();
|
||||
if (!accountId || !ns) return null;
|
||||
return createCloudflareKvMailbox(cf, accountId, ns);
|
||||
}
|
||||
|
||||
export async function syncCloudflareTargetsToKv(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
mailbox?: HealthMailbox | null,
|
||||
): Promise<void> {
|
||||
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
|
||||
if (!box) return;
|
||||
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
|
||||
const current = await box.getTargets();
|
||||
if (current?.fingerprint === next.fingerprint) return;
|
||||
await box.putTargets(next);
|
||||
}
|
||||
|
||||
export function fireEnsureHealthWorker(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
log?: { warn: (obj: unknown, msg: string) => void },
|
||||
): void {
|
||||
if (process.env.VITEST) return;
|
||||
if (!cf.isConfigured) return;
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => target.provider === "cloudflare");
|
||||
if (!hasCloudflare) {
|
||||
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||
log?.warn({ err }, "health worker KV sync failed");
|
||||
});
|
||||
return;
|
||||
}
|
||||
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
|
||||
log?.warn({ err }, "health worker ensure failed");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
HealthCheckTarget,
|
||||
HealthProbeResultItem,
|
||||
HealthProbeResultsDoc,
|
||||
HealthProbeTargetItem,
|
||||
HealthProbeTargetsDoc,
|
||||
} from "@cfdm/shared";
|
||||
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||
|
||||
export interface HealthMailbox {
|
||||
getTargets(): Promise<HealthProbeTargetsDoc | null>;
|
||||
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
|
||||
getResults(): Promise<HealthProbeResultsDoc | null>;
|
||||
}
|
||||
|
||||
export function createCloudflareKvMailbox(
|
||||
cf: CloudflareClient,
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
): HealthMailbox {
|
||||
return {
|
||||
async getTargets() {
|
||||
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
|
||||
},
|
||||
async putTargets(doc) {
|
||||
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
|
||||
},
|
||||
async getResults() {
|
||||
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(
|
||||
cf: CloudflareClient,
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
key: string,
|
||||
): Promise<T | null> {
|
||||
const raw = await cf.kvGet(accountId, namespaceId, key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function originProbeKey(target: HealthCheckTarget): string {
|
||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||
const ip = String(target.ip || "").trim().toLowerCase();
|
||||
if (target.type === "http") {
|
||||
const path = (target.path?.trim() || "/") || "/";
|
||||
const expected = target.expected_status ?? "";
|
||||
return `http|${ip}|${port}|${path}|${expected}`;
|
||||
}
|
||||
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||
if (target.type === "ping") {
|
||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
if (target.type === "dns") {
|
||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
return `${target.type}|${ip}|${port}`;
|
||||
}
|
||||
|
||||
export function cloudflareMailboxTargets(
|
||||
targets: HealthCheckTarget[],
|
||||
): HealthProbeTargetItem[] {
|
||||
const unique = new Map<string, HealthProbeTargetItem>();
|
||||
for (const target of targets) {
|
||||
if (target.provider !== "cloudflare") continue;
|
||||
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||
const key = originProbeKey(target);
|
||||
if (unique.has(key)) continue;
|
||||
unique.set(key, {
|
||||
key,
|
||||
ip: target.ip,
|
||||
hostname: target.hostname || target.ip,
|
||||
type: target.type,
|
||||
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||
path: target.path ?? "/",
|
||||
expectedStatus: target.expected_status,
|
||||
timeoutMs: target.timeout_ms ?? 3000,
|
||||
verifyTls: Boolean(target.verify_tls),
|
||||
});
|
||||
}
|
||||
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
|
||||
return items
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
|
||||
const items = cloudflareMailboxTargets(targets);
|
||||
return {
|
||||
fingerprint: fingerprintTargets(items),
|
||||
updatedAt: new Date().toISOString(),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function indexResults(
|
||||
doc: HealthProbeResultsDoc | null,
|
||||
): Map<string, HealthProbeResultItem> {
|
||||
const map = new Map<string, HealthProbeResultItem>();
|
||||
if (!doc?.items) return map;
|
||||
for (const item of doc.items) {
|
||||
map.set(item.key, item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
|
||||
if (!doc?.probedAt) return true;
|
||||
const ts = Date.parse(doc.probedAt);
|
||||
if (!Number.isFinite(ts)) return true;
|
||||
return Date.now() - ts > staleAfterMs;
|
||||
}
|
||||
|
||||
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
|
||||
export function toCloudflareCron(expr: string): string {
|
||||
const parts = expr.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 6) return parts.slice(1).join(" ");
|
||||
if (parts.length === 5) return parts.join(" ");
|
||||
throw new Error("некорректное cron-выражение");
|
||||
}
|
||||
|
||||
export function cronStaleAfterMs(expr: string): number {
|
||||
const cf = toCloudflareCron(expr);
|
||||
const minute = cf.split(/\s+/)[0] ?? "*";
|
||||
if (minute.startsWith("*/")) {
|
||||
const n = Number(minute.slice(2));
|
||||
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
|
||||
}
|
||||
if (minute === "*") return 10 * 60_000;
|
||||
return 10 * 60_000;
|
||||
}
|
||||
@@ -1,93 +1,13 @@
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
import type { ProbeResult } from "../health-check-service.js";
|
||||
import type { HealthCheckProvider } from "./provider.js";
|
||||
|
||||
export interface WorkerProbeConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const WORKER_NOT_CONFIGURED = "Cloudflare Worker не настроен (URL и токен)";
|
||||
|
||||
export class CloudflareWorkerHealthCheckProvider implements HealthCheckProvider {
|
||||
readonly kind = "cloudflare" as const;
|
||||
|
||||
constructor(private readonly config: WorkerProbeConfig) {}
|
||||
|
||||
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
||||
const base = this.config.url.replace(/\/$/, "");
|
||||
if (!base || !this.config.token) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: WORKER_NOT_CONFIGURED,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const timeoutMs = Math.max(100, target.timeout_ms ?? 3000);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs + 2500);
|
||||
try {
|
||||
const res = await fetch(`${base}/probe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: target.type === "http" ? "http" : "tcp",
|
||||
ip: target.ip,
|
||||
hostname: target.hostname,
|
||||
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||
path: target.path ?? "/",
|
||||
expected_status: target.expected_status,
|
||||
timeout_ms: timeoutMs,
|
||||
verify_tls: Boolean(target.verify_tls),
|
||||
method: "GET",
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: `Worker HTTP ${res.status}${text ? `: ${text.slice(0, 180)}` : ""}`,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string | null;
|
||||
colo?: string | null;
|
||||
};
|
||||
const ok = Boolean(body.ok);
|
||||
return {
|
||||
ok,
|
||||
latencyMs: typeof body.latencyMs === "number" ? body.latencyMs : 0,
|
||||
error: ok ? null : (body.error ?? "probe failed"),
|
||||
colo: body.colo ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.name === "AbortError"
|
||||
? "Worker timeout"
|
||||
: err.message
|
||||
: "Worker probe failed";
|
||||
return { ok: false, latencyMs: 0, error: message, colo: null };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function workerNotConfiguredResult(): ProbeResult {
|
||||
export function workerNotConfiguredResult(): {
|
||||
ok: false;
|
||||
latencyMs: number;
|
||||
error: string;
|
||||
colo: null;
|
||||
} {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: WORKER_NOT_CONFIGURED,
|
||||
error: "Cloudflare Worker не настроен (нет KV mailbox)",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { isValidIpv4 } from "../lib/validators.js";
|
||||
import * as dnsService from "./dns-service.js";
|
||||
import * as domainService from "./domain-service.js";
|
||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||
import {
|
||||
selectActiveIpsByMode,
|
||||
withBindingLock,
|
||||
@@ -1250,6 +1251,8 @@ export async function updateConfig(
|
||||
|
||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
|
||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||
return view!;
|
||||
}
|
||||
@@ -1261,7 +1264,7 @@ export async function createGroup(
|
||||
): Promise<ServiceGroup> {
|
||||
const groupType = body.type?.trim() || "custom";
|
||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||
return repos.createServiceGroup(
|
||||
const group = repos.createServiceGroup(
|
||||
db,
|
||||
body.name,
|
||||
groupType,
|
||||
@@ -1280,6 +1283,8 @@ export async function createGroup(
|
||||
health_check_provider: body.health_check_provider,
|
||||
},
|
||||
);
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
return group;
|
||||
}
|
||||
|
||||
export async function updateGroup(
|
||||
@@ -1322,6 +1327,7 @@ export async function updateGroup(
|
||||
group = repos.getServiceGroup(db, id);
|
||||
}
|
||||
await syncEnabledServicesInGroup(db, cf, id);
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
return group;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { toCloudflareCron } from "../src/services/health/mailbox.js";
|
||||
import { HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: "admin", password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function jsonOk(result: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify({ success: true, result }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("health worker deploy", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("maps 6-field toad cron to 5-field Cloudflare cron", () => {
|
||||
expect(toCloudflareCron("0 */2 * * * *")).toBe("*/2 * * * *");
|
||||
expect(toCloudflareCron("*/5 * * * *")).toBe("*/5 * * * *");
|
||||
});
|
||||
|
||||
it("POST ensure creates KV+script; 403 is not local fallback", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === "string" || input instanceof URL
|
||||
? String(input)
|
||||
: input.url;
|
||||
const method = (
|
||||
init?.method ??
|
||||
(typeof Request !== "undefined" && input instanceof Request
|
||||
? input.method
|
||||
: "GET")
|
||||
).toUpperCase();
|
||||
calls.push(`${method} ${url}`);
|
||||
if (
|
||||
(url.includes("/accounts?") || /\/accounts$/.test(url.split("?")[0] ?? "")) &&
|
||||
!url.includes("/storage/") &&
|
||||
!url.includes("/workers/")
|
||||
) {
|
||||
return jsonOk([{ id: "acc-1", name: "Test" }]);
|
||||
}
|
||||
if (url.includes("/storage/kv/namespaces") && method === "GET" && !url.includes("/values/")) {
|
||||
return jsonOk([]);
|
||||
}
|
||||
if (url.includes("/storage/kv/namespaces") && method === "POST") {
|
||||
return jsonOk({ id: "kv-1", title: "cfdm-health-probe" });
|
||||
}
|
||||
if (
|
||||
url.includes(`/workers/scripts/${HEALTH_PROBE_SCRIPT_NAME}`) &&
|
||||
method === "PUT" &&
|
||||
!url.includes("/schedules")
|
||||
) {
|
||||
return jsonOk({ id: "script-1" });
|
||||
}
|
||||
if (url.includes("/schedules") && method === "PUT") {
|
||||
return jsonOk([{ cron: "*/2 * * * *" }]);
|
||||
}
|
||||
if (url.includes("/subdomain") && method === "POST") {
|
||||
return jsonOk({ enabled: true });
|
||||
}
|
||||
if (url.includes("/workers/subdomain") && method === "GET") {
|
||||
return jsonOk({ subdomain: "example" });
|
||||
}
|
||||
if (url.includes("/values/") && method === "GET") {
|
||||
return new Response("null", { status: 404 });
|
||||
}
|
||||
if (url.includes("/values/") && method === "PUT") {
|
||||
return jsonOk(null);
|
||||
}
|
||||
return jsonOk({});
|
||||
},
|
||||
);
|
||||
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "cf-token" },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/health/worker/ensure",
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
healthWorkerStatus: string;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerKvNamespaceId: string;
|
||||
healthWorkerError: string | null;
|
||||
};
|
||||
expect(body.healthWorkerStatus).toBe("ready");
|
||||
expect(body.healthWorkerKvNamespaceId).toBe("kv-1");
|
||||
expect(body.healthWorkerUrl).toContain("cfdm-health-probe.example.workers.dev");
|
||||
expect(body.healthWorkerError).toBeNull();
|
||||
expect(calls.some((c) => c.includes("/workers/scripts/"))).toBe(true);
|
||||
await app.close();
|
||||
}, 20_000);
|
||||
|
||||
it("POST ensure 403 stores error, does not probe as local", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/storage/kv/namespaces")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
errors: [{ code: 10000, message: "Authentication error" }],
|
||||
}),
|
||||
{ status: 403, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.includes("/accounts")) {
|
||||
return jsonOk([{ id: "acc-1" }]);
|
||||
}
|
||||
return jsonOk({});
|
||||
},
|
||||
);
|
||||
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "zone-only" },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/health/worker/ensure",
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
const again = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers,
|
||||
});
|
||||
const body = again.json() as {
|
||||
healthWorkerStatus: string;
|
||||
healthWorkerError: string | null;
|
||||
};
|
||||
expect(body.healthWorkerStatus).toBe("error");
|
||||
expect(body.healthWorkerError).toMatch(/Workers Scripts Write|токен/i);
|
||||
await app.close();
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createServer, type Server as HttpServer } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { repos, type Db } from "@cfdm/db";
|
||||
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||
import type { HealthMailbox } from "../src/services/health/mailbox.js";
|
||||
import { originProbeKey } from "../src/services/health/mailbox.js";
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const res = await app.inject({
|
||||
@@ -16,37 +18,6 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function startWorkerMock(handler: (req: {
|
||||
url?: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
}) => { status: number; json: unknown } | "hang"): Promise<{
|
||||
server: HttpServer;
|
||||
url: string;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
req.on("end", () => {
|
||||
const result = handler({
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
if (result === "hang") return;
|
||||
res.writeHead(result.status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(result.json));
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
resolve({ server, url: `http://127.0.0.1:${port}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function seedBinding(
|
||||
db: Db,
|
||||
opts: { provider: "local" | "cloudflare"; ip: string },
|
||||
@@ -76,7 +47,51 @@ const thresholds = {
|
||||
successRecoveries: 2,
|
||||
};
|
||||
|
||||
describe("health-check XOR worker", () => {
|
||||
function memoryMailbox(opts?: {
|
||||
resultsOk?: boolean;
|
||||
colo?: string;
|
||||
probedAt?: string;
|
||||
}): HealthMailbox {
|
||||
let targets: unknown = null;
|
||||
return {
|
||||
async getTargets() {
|
||||
return targets as never;
|
||||
},
|
||||
async putTargets(doc) {
|
||||
targets = doc;
|
||||
},
|
||||
async getResults() {
|
||||
if (!opts) return null;
|
||||
const dummy: HealthCheckTarget = {
|
||||
scope: "binding",
|
||||
ref_id: 1,
|
||||
ip: "203.0.113.10",
|
||||
hostname: "panel.example.com",
|
||||
type: "tcp",
|
||||
port: 1,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
timeout_ms: 400,
|
||||
verify_tls: false,
|
||||
provider: "cloudflare",
|
||||
};
|
||||
return {
|
||||
probedAt: opts.probedAt ?? new Date().toISOString(),
|
||||
colo: opts.colo ?? "AMS",
|
||||
items: [
|
||||
{
|
||||
key: originProbeKey(dummy),
|
||||
ok: opts.resultsOk !== false,
|
||||
latencyMs: 42,
|
||||
error: opts.resultsOk === false ? "down" : null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("health-check XOR worker mailbox", () => {
|
||||
it("lists only local providers when no cloudflare bindings", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
@@ -93,7 +108,7 @@ describe("health-check XOR worker", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("cloudflare without worker URL does not fall back to local", async () => {
|
||||
it("cloudflare without mailbox does not fall back to local", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -105,7 +120,7 @@ describe("health-check XOR worker", () => {
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
worker: null,
|
||||
mailbox: null,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
@@ -118,17 +133,7 @@ describe("health-check XOR worker", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("worker mock 200 writes colo and last_checked_at", async () => {
|
||||
const mock = await startWorkerMock((req) => {
|
||||
const auth = String(req.headers.authorization ?? "");
|
||||
if (auth !== "Bearer secret") {
|
||||
return { status: 401, json: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
json: { ok: true, latencyMs: 42, error: null, colo: "AMS" },
|
||||
};
|
||||
});
|
||||
it("KV results write colo and last_checked_at without HTTP /probe", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -138,10 +143,34 @@ describe("health-check XOR worker", () => {
|
||||
provider: "cloudflare",
|
||||
ip: "203.0.113.10",
|
||||
});
|
||||
const targets = repos.listHealthCheckTargets(app.db);
|
||||
const cfTarget = targets.find((t) => t.ip === "203.0.113.10")!;
|
||||
const mailbox: HealthMailbox = {
|
||||
async getTargets() {
|
||||
return null;
|
||||
},
|
||||
async putTargets() {
|
||||
/* fingerprint sync */
|
||||
},
|
||||
async getResults() {
|
||||
return {
|
||||
probedAt: new Date().toISOString(),
|
||||
colo: "AMS",
|
||||
items: [
|
||||
{
|
||||
key: originProbeKey(cfTarget),
|
||||
ok: true,
|
||||
latencyMs: 42,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
worker: { url: mock.url, token: "secret" },
|
||||
mailbox,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
@@ -170,7 +199,6 @@ describe("health-check XOR worker", () => {
|
||||
};
|
||||
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
||||
expect(ipRow?.colo).toBe("AMS");
|
||||
expect(ipRow?.last_checked_at).toBeTruthy();
|
||||
expect(ipRow?.provider).toBe("cloudflare");
|
||||
|
||||
const logRes = await app.inject({
|
||||
@@ -182,12 +210,10 @@ describe("health-check XOR worker", () => {
|
||||
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
||||
expect(logBody.items[0]?.colo).toBe("AMS");
|
||||
|
||||
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("worker timeout is recorded, not local probe", async () => {
|
||||
const mock = await startWorkerMock(() => "hang");
|
||||
it("stale KV results are recorded, not local probe", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -199,7 +225,12 @@ describe("health-check XOR worker", () => {
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
worker: { url: mock.url, token: "secret" },
|
||||
mailbox: memoryMailbox({
|
||||
resultsOk: true,
|
||||
colo: "SIN",
|
||||
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||
}),
|
||||
staleAfterMs: 60_000,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
@@ -207,9 +238,8 @@ describe("health-check XOR worker", () => {
|
||||
binding.id,
|
||||
"203.0.113.20",
|
||||
);
|
||||
expect(row?.last_error).toMatch(/timeout|Worker/i);
|
||||
expect(row?.last_error).toMatch(/устарели|KV/i);
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
await new Promise<void>((resolve) => mock.server.close(() => resolve()));
|
||||
await app.close();
|
||||
}, 15_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,12 +40,14 @@ describe("settings health engine", () => {
|
||||
healthDownFailures: number;
|
||||
healthLatencyWarnMs: number;
|
||||
healthSuccessRecoveries: number;
|
||||
healthWorkerStatus?: string;
|
||||
};
|
||||
expect(body.healthCheckCron).toBe("*/30 * * * * *");
|
||||
expect(body.healthDegradedFailures).toBe(3);
|
||||
expect(body.healthDownFailures).toBe(4);
|
||||
expect(body.healthLatencyWarnMs).toBe(1500);
|
||||
expect(body.healthSuccessRecoveries).toBe(5);
|
||||
expect(body.healthWorkerStatus).toBe("missing");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { copyFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/server.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
async onSuccess() {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
copyFileSync(
|
||||
join(root, "workers/health-probe/src/index.mjs"),
|
||||
join(dirname(fileURLToPath(import.meta.url)), "dist/health-probe-worker.mjs"),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
testTimeout: 20_000,
|
||||
typecheck: {
|
||||
tsconfig: "./tsconfig.test.json",
|
||||
},
|
||||
|
||||
@@ -211,12 +211,12 @@ export function HealthCheckConfigFields({
|
||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
||||
Регионы WNAM/WEU недоступны — в результате будет colo ближайшего POP
|
||||
(например AMS). URL и токен Worker — в{' '}
|
||||
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
|
||||
Статус деплоя — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
. Если Worker не создан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
const formSchema = z.object({
|
||||
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||
@@ -35,8 +37,6 @@ const formSchema = z.object({
|
||||
healthDownFailures: z.number().int().min(1).max(50),
|
||||
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||
healthWorkerUrl: z.string().trim().url('Некорректный URL').or(z.literal('')),
|
||||
healthWorkerToken: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
@@ -48,10 +48,16 @@ const formSchema = z.object({
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
id: string
|
||||
healthWorkerTokenSet?: boolean
|
||||
healthWorkerUrl?: string
|
||||
healthWorkerStatus?: HealthWorkerStatus
|
||||
healthWorkerError?: string | null
|
||||
healthWorkerDeployedAt?: string | null
|
||||
healthWorkerLastIngestAt?: string | null
|
||||
healthWorkerKvNamespaceId?: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/health')({
|
||||
@@ -94,6 +100,28 @@ function CompactNumberInput({
|
||||
)
|
||||
}
|
||||
|
||||
function statusBadge(status: HealthWorkerStatus | undefined) {
|
||||
if (status === 'ready') {
|
||||
return (
|
||||
<Badge variant="success-light" size="sm">
|
||||
Готов
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
Ошибка
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
Не создан
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function HealthSettingsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -109,8 +137,6 @@ function HealthSettingsPage() {
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1000,
|
||||
healthSuccessRecoveries: 2,
|
||||
healthWorkerUrl: '',
|
||||
healthWorkerToken: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -122,26 +148,18 @@ function HealthSettingsPage() {
|
||||
healthDownFailures: data.healthDownFailures,
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
healthWorkerUrl: data.healthWorkerUrl ?? '',
|
||||
healthWorkerToken: '',
|
||||
})
|
||||
}, [data, form])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: FormValues) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
mutationFn: (values: FormValues) =>
|
||||
api.patch<SettingsResponse>('/api/v1/settings', {
|
||||
healthCheckCron: values.healthCheckCron,
|
||||
healthDegradedFailures: values.healthDegradedFailures,
|
||||
healthDownFailures: values.healthDownFailures,
|
||||
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||
healthWorkerUrl: values.healthWorkerUrl,
|
||||
}
|
||||
if (values.healthWorkerToken?.trim()) {
|
||||
payload.healthWorkerToken = values.healthWorkerToken.trim()
|
||||
}
|
||||
return api.patch<SettingsResponse>('/api/v1/settings', payload)
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки health-check сохранены')
|
||||
@@ -150,6 +168,17 @@ function HealthSettingsPage() {
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
const ensureMut = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Worker создан или обновлён')
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex w-full flex-col gap-4"
|
||||
@@ -172,7 +201,7 @@ function HealthSettingsPage() {
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Cron"
|
||||
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
|
||||
description="Расписание проб CFDM (6 полей). Worker на edge получает 5-польное cron без секунд. Env: HEALTH_CHECK_CRON."
|
||||
labelFor="health-cron"
|
||||
stacked
|
||||
>
|
||||
@@ -267,6 +296,7 @@ function HealthSettingsPage() {
|
||||
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||
labelFor="health-recoveries"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
@@ -283,55 +313,7 @@ function HealthSettingsPage() {
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="URL Worker"
|
||||
description="https://cfdm-health-probe.<account>.workers.dev. Env: HEALTH_WORKER_URL."
|
||||
labelFor="health-worker-url"
|
||||
compact
|
||||
>
|
||||
<Input
|
||||
id="health-worker-url"
|
||||
type="url"
|
||||
placeholder="https://cfdm-health-probe.workers.dev"
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthWorkerUrl')}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.formState.errors.healthWorkerUrl ? (
|
||||
<p className="text-destructive px-5 pb-2 text-sm">
|
||||
{form.formState.errors.healthWorkerUrl.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Токен Worker"
|
||||
description={
|
||||
data?.healthWorkerTokenSet
|
||||
? 'Токен задан. Оставьте пустым, чтобы не менять.'
|
||||
: 'Authorization Bearer. Env: HEALTH_WORKER_TOKEN.'
|
||||
}
|
||||
labelFor="health-worker-token"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="health-worker-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={data?.healthWorkerTokenSet ? '••••••••' : 'секрет'}
|
||||
disabled={isLoading || saveMut.isPending}
|
||||
{...form.register('healthWorkerToken')}
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Worker, не Health Checks API</AlertTitle>
|
||||
<AlertDescription>
|
||||
На Free-плане продукта Health Checks нет. CFDM вызывает Worker с edge;
|
||||
cron остаётся здесь. Лимит Free Workers ≈ 100k запросов/сутки (cron × число IP).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
@@ -343,6 +325,96 @@ function HealthSettingsPage() {
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="flex items-center gap-2">
|
||||
Cloudflare Worker
|
||||
{statusBadge(data?.healthWorkerStatus)}
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Worker сам опрашивает IP/порты с edge. CFDM создаёт скрипт через API
|
||||
и забирает результаты из KV. Preview:{' '}
|
||||
<a
|
||||
href="https://reui.io/preview/base/settings-16"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
settings-16
|
||||
</a>
|
||||
.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<Alert variant={data?.healthWorkerStatus === 'error' ? 'destructive' : 'info'}>
|
||||
<AlertTitle>Не Health Checks API</AlertTitle>
|
||||
<AlertDescription>
|
||||
На Free-плане продукта Health Checks нет. Нужен Account-токен с
|
||||
Workers Scripts Write и Workers KV Storage Write — Zone DNS
|
||||
недостаточно. Лимиты Free: 5 cron на аккаунт, KV 1000 writes/сутки
|
||||
(интервал ≥ 2 мин), до 48 целей за тик.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{data?.healthWorkerError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка деплоя</AlertTitle>
|
||||
<AlertDescription>{data.healthWorkerError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow title="Скрипт" compact>
|
||||
<span className="font-mono text-sm">cfdm-health-probe</span>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="KV namespace"
|
||||
description="id mailbox targets/results"
|
||||
compact
|
||||
>
|
||||
<span className="font-mono text-sm break-all">
|
||||
{data?.healthWorkerKvNamespaceId || '—'}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="URL"
|
||||
description="workers.dev после автодеплоя"
|
||||
compact
|
||||
>
|
||||
<span className="font-mono text-sm break-all">
|
||||
{data?.healthWorkerUrl || '—'}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Последний деплой"
|
||||
compact
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data?.healthWorkerDeployedAt || '—'}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Последний ingest"
|
||||
description="colo пишется в журнал проб"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data?.healthWorkerLastIngestAt || '—'}
|
||||
</span>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={ensureMut.isPending}
|
||||
onClick={() => ensureMut.mutate()}
|
||||
>
|
||||
{ensureMut.isPending ? 'Создаём…' : 'Создать / обновить Worker'}
|
||||
</Button>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ COPY apps/api apps/api
|
||||
COPY packages/ui packages/ui
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/db packages/db
|
||||
COPY workers/health-probe workers/health-probe
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm turbo build --filter=web --filter=@cfdm/api \
|
||||
&& pnpm --filter @cfdm/api deploy --prod /out \
|
||||
|
||||
+15
-13
@@ -14,7 +14,7 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CLOUDFLARE_API_TOKEN` | API token with Zone.DNS permissions |
|
||||
| `CLOUDFLARE_API_TOKEN` | API token: Zone.DNS **и** для Worker — Account Workers Scripts Write + Workers KV Storage Write |
|
||||
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
||||
| `JWT_SECRET` | JWT signing secret |
|
||||
| `ADMIN_USERNAME` | Admin username |
|
||||
@@ -25,8 +25,6 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||
| `HEALTH_WORKER_URL` | URL Worker health-probe (fallback). Переопределяется в **Настройки → Health-check**. |
|
||||
| `HEALTH_WORKER_TOKEN` | Bearer-токен Worker (fallback). В GET `/settings` не отдаётся целиком. |
|
||||
|
||||
## Load balancing & health checks
|
||||
|
||||
@@ -58,22 +56,26 @@ health-check работают на двух уровнях:
|
||||
|
||||
| | Local | Cloudflare Worker |
|
||||
|---|---|---|
|
||||
| Кто пробирует | процесс API CFDM | Worker на edge Cloudflare |
|
||||
| Планировщик | глобальный cron CFDM | тот же cron вызывает Worker |
|
||||
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) |
|
||||
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV в CFDM |
|
||||
| Пороги Slow/Down | Настройки → Health-check | те же |
|
||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` |
|
||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV |
|
||||
| Регионы Health Checks | нет | нет (на Free продукта нет) |
|
||||
|
||||
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
|
||||
Продукт Health Checks на Free-плане недоступен и **не используется**. Worker
|
||||
stateless: конфиг и журнал (`health_probe_log`) живут в SQLite CFDM.
|
||||
Продукт Health Checks на Free-плане недоступен и **не используется**.
|
||||
|
||||
Деплой Worker: [`workers/health-probe/README.md`](../workers/health-probe/README.md)
|
||||
(`wrangler deploy`). URL и токен — **Настройки → Health-check**. Если Worker не
|
||||
задан, cloudflare-цели **не** пробируются как Local.
|
||||
Worker **сам** опрашивает IP/порты/протоколы (TCP/HTTP, паттерн [UptimeFlare](https://github.com/lyc8503/UptimeFlare): `sockets.opened`, p-limit 5).
|
||||
CFDM создаёт скрипт через Workers Scripts API, кладёт список целей в KV и читает результаты.
|
||||
Публичный URL API не нужен. Если Worker/KV не готовы, cloudflare-цели **не** пробируются как Local.
|
||||
|
||||
Reconcile DNS запускается cron-задачей `health-check`. Free Workers ≈ 100k
|
||||
запросов/сутки; cron раз в 2 мин × число IP должен влезать.
|
||||
Кнопка **Создать / обновить Worker** — **Настройки → Health-check**. Токен:
|
||||
Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недостаточно.
|
||||
|
||||
Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
|
||||
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/).
|
||||
|
||||
Reconcile DNS запускается cron-задачей `health-check` после ingest KV.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
Vendored
+202
-1
@@ -1,7 +1,7 @@
|
||||
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||
|
||||
declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
name: "groups";
|
||||
@@ -3365,6 +3365,101 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_account_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_account_id";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_kv_namespace_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_kv_namespace_id";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_error: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_error";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_deployed_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_deployed_at";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_last_ingest_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_last_ingest_at";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "app_settings";
|
||||
@@ -7822,6 +7917,101 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_account_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_account_id";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_kv_namespace_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_kv_namespace_id";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_error: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_error";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_deployed_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_deployed_at";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_worker_last_ingest_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_worker_last_ingest_at";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "app_settings";
|
||||
@@ -8980,6 +9170,12 @@ type AppSettingsDto = {
|
||||
showQuickActions: boolean;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerTokenSet: boolean;
|
||||
healthWorkerStatus: HealthWorkerStatus;
|
||||
healthWorkerAccountId: string;
|
||||
healthWorkerKvNamespaceId: string;
|
||||
healthWorkerError: string | null;
|
||||
healthWorkerDeployedAt: string | null;
|
||||
healthWorkerLastIngestAt: string | null;
|
||||
} & HealthEngineSettings;
|
||||
type AppSettingsPatch = {
|
||||
vpsTrackerUrl?: string;
|
||||
@@ -8993,6 +9189,11 @@ type AppSettingsPatch = {
|
||||
healthSuccessRecoveries?: number;
|
||||
healthWorkerUrl?: string;
|
||||
healthWorkerToken?: string;
|
||||
healthWorkerAccountId?: string | null;
|
||||
healthWorkerKvNamespaceId?: string | null;
|
||||
healthWorkerError?: string | null;
|
||||
healthWorkerDeployedAt?: string | null;
|
||||
healthWorkerLastIngestAt?: string | null;
|
||||
};
|
||||
type HealthEngineFallbacks = HealthEngineSettings & {
|
||||
healthWorkerUrl: string;
|
||||
|
||||
Vendored
+24
-2
@@ -281,6 +281,11 @@ var appSettings = sqliteTable("app_settings", {
|
||||
health_success_recoveries: integer("health_success_recoveries"),
|
||||
health_worker_url: text("health_worker_url"),
|
||||
health_worker_token: text("health_worker_token"),
|
||||
health_worker_account_id: text("health_worker_account_id"),
|
||||
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
|
||||
health_worker_error: text("health_worker_error"),
|
||||
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
@@ -522,6 +527,13 @@ var SETTINGS_ID = "settings-main";
|
||||
function coalesceInt(value, fallback) {
|
||||
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||
}
|
||||
function workerStatus(row, envUrl) {
|
||||
if (row.health_worker_error?.trim()) return "error";
|
||||
const url = row.health_worker_url?.trim() || envUrl;
|
||||
const kv = row.health_worker_kv_namespace_id?.trim();
|
||||
if (kv && url) return "ready";
|
||||
return "missing";
|
||||
}
|
||||
function toDto(row, fallbacks) {
|
||||
const env = fallbacks ?? {
|
||||
healthCheckCron: "0 */2 * * * *",
|
||||
@@ -559,7 +571,13 @@ function toDto(row, fallbacks) {
|
||||
env.healthSuccessRecoveries
|
||||
),
|
||||
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
||||
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet
|
||||
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
||||
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
|
||||
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
|
||||
healthWorkerError: row.health_worker_error?.trim() || null,
|
||||
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl)
|
||||
};
|
||||
}
|
||||
function getAppSettings(db, fallbacks) {
|
||||
@@ -590,7 +608,6 @@ function updateAppSettings(db, patch, fallbacks) {
|
||||
}
|
||||
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||
db.update(appSettings).set({
|
||||
// app_switcher_json: deprecated — source of truth is auth-portal
|
||||
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
||||
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
||||
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
||||
@@ -602,6 +619,11 @@ function updateAppSettings(db, patch, fallbacks) {
|
||||
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
||||
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
|
||||
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
|
||||
health_worker_account_id: patch.healthWorkerAccountId !== void 0 ? patch.healthWorkerAccountId?.trim() || null : current.health_worker_account_id,
|
||||
health_worker_kv_namespace_id: patch.healthWorkerKvNamespaceId !== void 0 ? patch.healthWorkerKvNamespaceId?.trim() || null : current.health_worker_kv_namespace_id,
|
||||
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
|
||||
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
|
||||
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
|
||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||
return getAppSettings(db, fallbacks);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE app_settings ADD COLUMN health_worker_account_id TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN health_worker_kv_namespace_id TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN health_worker_error TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN health_worker_deployed_at TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN health_worker_last_ingest_at TEXT;
|
||||
@@ -393,6 +393,11 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
health_success_recoveries: integer("health_success_recoveries"),
|
||||
health_worker_url: text("health_worker_url"),
|
||||
health_worker_token: text("health_worker_token"),
|
||||
health_worker_account_id: text("health_worker_account_id"),
|
||||
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
|
||||
health_worker_error: text("health_worker_error"),
|
||||
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { HealthWorkerStatus } from "@cfdm/shared";
|
||||
import type { Db } from "./client.js";
|
||||
import { appSettings } from "./schema.js";
|
||||
|
||||
@@ -21,6 +22,12 @@ export type AppSettingsDto = {
|
||||
showQuickActions: boolean;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerTokenSet: boolean;
|
||||
healthWorkerStatus: HealthWorkerStatus;
|
||||
healthWorkerAccountId: string;
|
||||
healthWorkerKvNamespaceId: string;
|
||||
healthWorkerError: string | null;
|
||||
healthWorkerDeployedAt: string | null;
|
||||
healthWorkerLastIngestAt: string | null;
|
||||
} & HealthEngineSettings;
|
||||
|
||||
export type AppSettingsPatch = {
|
||||
@@ -35,6 +42,11 @@ export type AppSettingsPatch = {
|
||||
healthSuccessRecoveries?: number;
|
||||
healthWorkerUrl?: string;
|
||||
healthWorkerToken?: string;
|
||||
healthWorkerAccountId?: string | null;
|
||||
healthWorkerKvNamespaceId?: string | null;
|
||||
healthWorkerError?: string | null;
|
||||
healthWorkerDeployedAt?: string | null;
|
||||
healthWorkerLastIngestAt?: string | null;
|
||||
};
|
||||
|
||||
export type HealthEngineFallbacks = HealthEngineSettings & {
|
||||
@@ -46,6 +58,17 @@ function coalesceInt(value: number | null | undefined, fallback: number): number
|
||||
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||
}
|
||||
|
||||
function workerStatus(
|
||||
row: typeof appSettings.$inferSelect,
|
||||
envUrl: string,
|
||||
): HealthWorkerStatus {
|
||||
if (row.health_worker_error?.trim()) return "error";
|
||||
const url = row.health_worker_url?.trim() || envUrl;
|
||||
const kv = row.health_worker_kv_namespace_id?.trim();
|
||||
if (kv && url) return "ready";
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function toDto(
|
||||
row: typeof appSettings.$inferSelect,
|
||||
fallbacks?: HealthEngineFallbacks,
|
||||
@@ -89,6 +112,12 @@ function toDto(
|
||||
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
||||
healthWorkerTokenSet:
|
||||
Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
||||
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
|
||||
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
|
||||
healthWorkerError: row.health_worker_error?.trim() || null,
|
||||
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,7 +183,6 @@ export function updateAppSettings(
|
||||
|
||||
db.update(appSettings)
|
||||
.set({
|
||||
// app_switcher_json: deprecated — source of truth is auth-portal
|
||||
vps_tracker_url:
|
||||
patch.vpsTrackerUrl !== undefined
|
||||
? patch.vpsTrackerUrl
|
||||
@@ -201,6 +229,26 @@ export function updateAppSettings(
|
||||
patch.healthWorkerToken.trim() !== ""
|
||||
? patch.healthWorkerToken
|
||||
: current.health_worker_token,
|
||||
health_worker_account_id:
|
||||
patch.healthWorkerAccountId !== undefined
|
||||
? patch.healthWorkerAccountId?.trim() || null
|
||||
: current.health_worker_account_id,
|
||||
health_worker_kv_namespace_id:
|
||||
patch.healthWorkerKvNamespaceId !== undefined
|
||||
? patch.healthWorkerKvNamespaceId?.trim() || null
|
||||
: current.health_worker_kv_namespace_id,
|
||||
health_worker_error:
|
||||
patch.healthWorkerError !== undefined
|
||||
? patch.healthWorkerError?.trim() || null
|
||||
: current.health_worker_error,
|
||||
health_worker_deployed_at:
|
||||
patch.healthWorkerDeployedAt !== undefined
|
||||
? patch.healthWorkerDeployedAt
|
||||
: current.health_worker_deployed_at,
|
||||
health_worker_last_ingest_at:
|
||||
patch.healthWorkerLastIngestAt !== undefined
|
||||
? patch.healthWorkerLastIngestAt
|
||||
: current.health_worker_last_ingest_at,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
|
||||
Vendored
+42
-1
@@ -151,6 +151,10 @@ interface CfZone {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
account?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
interface CfDnsRecord {
|
||||
id?: string;
|
||||
@@ -1936,6 +1940,43 @@ declare const vpsTrackerEventSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
|
||||
|
||||
declare const HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
|
||||
declare const HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
|
||||
declare const HEALTH_KV_TARGETS_KEY = "targets";
|
||||
declare const HEALTH_KV_RESULTS_KEY = "results";
|
||||
declare const HEALTH_KV_CURSOR_KEY = "cursor";
|
||||
declare const HEALTH_PROBE_BATCH = 48;
|
||||
declare const HEALTH_PROBE_CONCURRENCY = 5;
|
||||
type HealthWorkerStatus = "missing" | "ready" | "error";
|
||||
interface HealthProbeTargetItem {
|
||||
key: string;
|
||||
ip: string;
|
||||
hostname: string;
|
||||
type: "tcp" | "http";
|
||||
port: number;
|
||||
path?: string;
|
||||
expectedStatus?: number | null;
|
||||
timeoutMs?: number;
|
||||
verifyTls?: boolean;
|
||||
}
|
||||
interface HealthProbeTargetsDoc {
|
||||
fingerprint: string;
|
||||
updatedAt: string;
|
||||
items: HealthProbeTargetItem[];
|
||||
}
|
||||
interface HealthProbeResultItem {
|
||||
key: string;
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
interface HealthProbeResultsDoc {
|
||||
probedAt: string;
|
||||
colo: string | null;
|
||||
fingerprint?: string;
|
||||
items: HealthProbeResultItem[];
|
||||
}
|
||||
|
||||
declare const AUDIT_SEVERITIES: readonly ["info", "warning", "critical"];
|
||||
type AuditSeverity = (typeof AUDIT_SEVERITIES)[number];
|
||||
declare const auditSeveritySchema: z.ZodEnum<{
|
||||
@@ -2044,4 +2085,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
||||
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
|
||||
Vendored
+16
@@ -749,6 +749,15 @@ var vpsTrackerEventSchema = z3.object({
|
||||
timestamp: z3.string().datetime().optional()
|
||||
});
|
||||
|
||||
// src/health-probe-mailbox.ts
|
||||
var HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
|
||||
var HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
|
||||
var HEALTH_KV_TARGETS_KEY = "targets";
|
||||
var HEALTH_KV_RESULTS_KEY = "results";
|
||||
var HEALTH_KV_CURSOR_KEY = "cursor";
|
||||
var HEALTH_PROBE_BATCH = 48;
|
||||
var HEALTH_PROBE_CONCURRENCY = 5;
|
||||
|
||||
// src/audit.ts
|
||||
import { z as z4 } from "zod";
|
||||
var AUDIT_SEVERITIES = ["info", "warning", "critical"];
|
||||
@@ -820,6 +829,13 @@ export {
|
||||
CERT_OK,
|
||||
CERT_UNKNOWN,
|
||||
CERT_WARNING,
|
||||
HEALTH_KV_CURSOR_KEY,
|
||||
HEALTH_KV_RESULTS_KEY,
|
||||
HEALTH_KV_TARGETS_KEY,
|
||||
HEALTH_PROBE_BATCH,
|
||||
HEALTH_PROBE_CONCURRENCY,
|
||||
HEALTH_PROBE_KV_TITLE,
|
||||
HEALTH_PROBE_SCRIPT_NAME,
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_DELETE,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export const HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
|
||||
export const HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
|
||||
export const HEALTH_KV_TARGETS_KEY = "targets";
|
||||
export const HEALTH_KV_RESULTS_KEY = "results";
|
||||
export const HEALTH_KV_CURSOR_KEY = "cursor";
|
||||
export const HEALTH_PROBE_BATCH = 48;
|
||||
export const HEALTH_PROBE_CONCURRENCY = 5;
|
||||
|
||||
export type HealthWorkerStatus = "missing" | "ready" | "error";
|
||||
|
||||
export interface HealthProbeTargetItem {
|
||||
key: string;
|
||||
ip: string;
|
||||
hostname: string;
|
||||
type: "tcp" | "http";
|
||||
port: number;
|
||||
path?: string;
|
||||
expectedStatus?: number | null;
|
||||
timeoutMs?: number;
|
||||
verifyTls?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthProbeTargetsDoc {
|
||||
fingerprint: string;
|
||||
updatedAt: string;
|
||||
items: HealthProbeTargetItem[];
|
||||
}
|
||||
|
||||
export interface HealthProbeResultItem {
|
||||
key: string;
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface HealthProbeResultsDoc {
|
||||
probedAt: string;
|
||||
colo: string | null;
|
||||
fingerprint?: string;
|
||||
items: HealthProbeResultItem[];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from "./parse-fqdn.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./app-switcher.js";
|
||||
export * from "./integration-vps-tracker.js";
|
||||
export * from "./health-probe-mailbox.js";
|
||||
export * from "./audit.js";
|
||||
export type {
|
||||
CfZone,
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface CfZone {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
account?: { id: string; name?: string };
|
||||
}
|
||||
|
||||
export interface CfDnsRecord {
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
# CFDM health-probe Worker
|
||||
|
||||
Stateless edge probe for CFDM. **Not** Cloudflare Health Checks API (unavailable on Free).
|
||||
Cron stays in CFDM — this Worker has no Cron Trigger.
|
||||
Edge probe for CFDM. **Not** Cloudflare Health Checks API (unavailable on Free).
|
||||
|
||||
## Deploy
|
||||
Production: CFDM creates this Worker via the Cloudflare API (KV mailbox + Cron Trigger).
|
||||
You do not need `wrangler deploy`. Token needs **Account**: Workers Scripts Write and Workers KV Storage Write.
|
||||
|
||||
Local debug:
|
||||
|
||||
```powershell
|
||||
cd workers/health-probe
|
||||
npx wrangler login
|
||||
npx wrangler secret put PROBE_TOKEN
|
||||
npx wrangler deploy
|
||||
npx wrangler dev
|
||||
```
|
||||
|
||||
Paste the Worker URL (`https://cfdm-health-probe.<account>.workers.dev`) and the same token into **Настройки → Health-check**.
|
||||
Worker reads KV `targets`, probes TCP (`cloudflare:sockets` + `opened`) or HTTP (`fetch` to IP + `Host`), writes KV `results`. Batch ≤ 48, concurrency 5.
|
||||
|
||||
Free Workers ≈ 100k requests/day. CFDM cron every 2 minutes × number of IPs must fit.
|
||||
|
||||
## API
|
||||
|
||||
`POST /probe` + `Authorization: Bearer <PROBE_TOKEN>`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tcp",
|
||||
"ip": "1.2.3.4",
|
||||
"hostname": "app.example.com",
|
||||
"port": 443,
|
||||
"path": "/",
|
||||
"expected_status": 200,
|
||||
"timeout_ms": 3000,
|
||||
"verify_tls": true,
|
||||
"method": "GET"
|
||||
}
|
||||
```
|
||||
|
||||
Response: `{ "ok": true, "latencyMs": 42, "error": null, "colo": "AMS" }`.
|
||||
`GET /` — liveness.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* CFDM health-probe Worker. Cron Trigger reads KV `targets`, probes TCP/HTTP
|
||||
* from the edge (UptimeFlare-style), writes KV `results`. CFDM is SoT in SQLite.
|
||||
*/
|
||||
|
||||
const TARGETS_KEY = "targets";
|
||||
const RESULTS_KEY = "results";
|
||||
const CURSOR_KEY = "cursor";
|
||||
const BATCH = 48;
|
||||
const CONCURRENCY = 5;
|
||||
const COOLDOWN_MS = 3 * 60 * 1000;
|
||||
const UA = "CFDM-health-probe/1.0";
|
||||
|
||||
export default {
|
||||
async fetch() {
|
||||
return new Response(JSON.stringify({ ok: true, service: "cfdm-health-probe" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
|
||||
async scheduled(_event, env) {
|
||||
await probeBatch(env);
|
||||
},
|
||||
};
|
||||
|
||||
async function probeBatch(env) {
|
||||
const raw = await env.HEALTH_KV.get(TARGETS_KEY);
|
||||
if (!raw) return;
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const items = Array.isArray(doc.items) ? doc.items : [];
|
||||
if (items.length === 0) return;
|
||||
|
||||
let offset = 0;
|
||||
const cursorRaw = await env.HEALTH_KV.get(CURSOR_KEY);
|
||||
if (cursorRaw) {
|
||||
try {
|
||||
const cursor = JSON.parse(cursorRaw);
|
||||
if (Number.isFinite(cursor.offset) && cursor.offset >= 0) {
|
||||
offset = cursor.offset % items.length;
|
||||
}
|
||||
} catch {
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const slice = items.slice(offset, offset + BATCH);
|
||||
const nextOffset = offset + slice.length >= items.length ? 0 : offset + slice.length;
|
||||
|
||||
const colo = await readColo();
|
||||
const probed = await mapPool(slice, CONCURRENCY, async (target) => {
|
||||
const type = target.type === "http" ? "http" : "tcp";
|
||||
const port = Number(target.port) || (type === "http" ? 80 : 80);
|
||||
const timeoutMs = Math.min(Math.max(Number(target.timeoutMs) || 3000, 100), 25_000);
|
||||
const hostname = String(target.hostname ?? "").trim() || target.ip;
|
||||
try {
|
||||
const result =
|
||||
type === "http"
|
||||
? await httpProbe({
|
||||
ip: target.ip,
|
||||
hostname,
|
||||
port,
|
||||
path: target.path || "/",
|
||||
expectedStatus: target.expectedStatus ?? 200,
|
||||
timeoutMs,
|
||||
verifyTls: Boolean(target.verifyTls),
|
||||
})
|
||||
: await tcpProbe(target.ip, port, timeoutMs);
|
||||
return { key: target.key, ...result };
|
||||
} catch (err) {
|
||||
return {
|
||||
key: target.key,
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: err instanceof Error ? err.message : "probe failed",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const fingerprint = resultFingerprint(probed);
|
||||
const previousRaw = await env.HEALTH_KV.get(RESULTS_KEY);
|
||||
let skipWrite = false;
|
||||
if (previousRaw) {
|
||||
try {
|
||||
const prev = JSON.parse(previousRaw);
|
||||
const age = Date.now() - Date.parse(prev.probedAt);
|
||||
if (prev.fingerprint === fingerprint && Number.isFinite(age) && age < COOLDOWN_MS) {
|
||||
skipWrite = true;
|
||||
}
|
||||
} catch {
|
||||
skipWrite = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipWrite) {
|
||||
const results = {
|
||||
probedAt: new Date().toISOString(),
|
||||
colo,
|
||||
fingerprint,
|
||||
items: probed,
|
||||
};
|
||||
await env.HEALTH_KV.put(RESULTS_KEY, JSON.stringify(results));
|
||||
}
|
||||
if (items.length > BATCH || offset !== 0) {
|
||||
await env.HEALTH_KV.put(CURSOR_KEY, JSON.stringify({ offset: nextOffset }));
|
||||
}
|
||||
}
|
||||
|
||||
function resultFingerprint(items) {
|
||||
return items
|
||||
.map((item) => `${item.key}:${item.ok ? "1" : "0"}:${item.error ?? ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
}
|
||||
|
||||
async function mapPool(items, concurrency, fn) {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
const idx = next;
|
||||
next += 1;
|
||||
results[idx] = await fn(items[idx]);
|
||||
}
|
||||
}
|
||||
const n = Math.min(concurrency, items.length);
|
||||
await Promise.all(Array.from({ length: n }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
async function readColo() {
|
||||
try {
|
||||
const res = await fetch("https://www.cloudflare.com/cdn-cgi/trace", {
|
||||
cf: { cacheTtlByStatus: { "100-599": -1 } },
|
||||
});
|
||||
const text = await res.text();
|
||||
const line = text.split("\n").find((row) => row.startsWith("colo="));
|
||||
return line ? line.slice(5).trim() || null : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function tcpProbe(ip, port, timeoutMs) {
|
||||
const started = Date.now();
|
||||
const { connect } = await import("cloudflare:sockets");
|
||||
const socket = connect({ hostname: ip, port });
|
||||
try {
|
||||
await withTimeout(socket.opened, timeoutMs, "tcp");
|
||||
return { ok: true, latencyMs: Date.now() - started, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "tcp failed";
|
||||
return { ok: false, latencyMs: Date.now() - started, error: message };
|
||||
} finally {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function httpProbe(opts) {
|
||||
const started = Date.now();
|
||||
const useTls = opts.verifyTls || opts.port === 443;
|
||||
const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip;
|
||||
const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`;
|
||||
const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Host: opts.hostname,
|
||||
"User-Agent": UA,
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
cf: { cacheTtlByStatus: { "100-599": -1 } },
|
||||
});
|
||||
try {
|
||||
await res.body?.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const latencyMs = Date.now() - started;
|
||||
if (res.status !== opts.expectedStatus) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`,
|
||||
};
|
||||
}
|
||||
return { ok: true, latencyMs, error: null };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.name === "AbortError"
|
||||
? "http timeout"
|
||||
: err.message
|
||||
: "http failed";
|
||||
return { ok: false, latencyMs: Date.now() - started, error: message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
/**
|
||||
* Stateless CFDM health probe. Cron lives in CFDM API — this Worker only
|
||||
* answers POST /probe. Deploy: wrangler deploy; paste URL + token into
|
||||
* Настройки → Health-check.
|
||||
*/
|
||||
|
||||
export interface Env {
|
||||
PROBE_TOKEN: string;
|
||||
}
|
||||
|
||||
type ProbeType = "tcp" | "http";
|
||||
|
||||
interface ProbeRequest {
|
||||
type?: ProbeType;
|
||||
ip?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
expected_status?: number | null;
|
||||
timeout_ms?: number;
|
||||
verify_tls?: boolean;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
interface ProbeResponse {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
colo: string | null;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const colo =
|
||||
(request as Request & { cf?: { colo?: string } }).cf?.colo ?? null;
|
||||
if (request.method !== "POST") {
|
||||
return json({ ok: false, latencyMs: 0, error: "method not allowed", colo }, 405);
|
||||
}
|
||||
const pathname = new URL(request.url).pathname.replace(/\/$/, "") || "/";
|
||||
if (pathname !== "/probe") {
|
||||
return json({ ok: false, latencyMs: 0, error: "not found", colo }, 404);
|
||||
}
|
||||
const token = bearer(request);
|
||||
if (!env.PROBE_TOKEN || token !== env.PROBE_TOKEN) {
|
||||
return json({ ok: false, latencyMs: 0, error: "unauthorized", colo }, 401);
|
||||
}
|
||||
|
||||
let body: ProbeRequest;
|
||||
try {
|
||||
body = (await request.json()) as ProbeRequest;
|
||||
} catch {
|
||||
return json({ ok: false, latencyMs: 0, error: "invalid json", colo }, 400);
|
||||
}
|
||||
|
||||
const ip = String(body.ip ?? "").trim();
|
||||
if (!ip) {
|
||||
return json({ ok: false, latencyMs: 0, error: "ip required", colo }, 400);
|
||||
}
|
||||
const type: ProbeType = body.type === "http" ? "http" : "tcp";
|
||||
const port = Number(body.port) || (type === "http" ? 80 : 80);
|
||||
const timeoutMs = Math.min(Math.max(Number(body.timeout_ms) || 3000, 100), 25_000);
|
||||
const hostname = String(body.hostname ?? "").trim() || ip;
|
||||
|
||||
try {
|
||||
const result =
|
||||
type === "http"
|
||||
? await httpProbe({
|
||||
ip,
|
||||
hostname,
|
||||
port,
|
||||
path: body.path || "/",
|
||||
expectedStatus: body.expected_status ?? 200,
|
||||
timeoutMs,
|
||||
verifyTls: Boolean(body.verify_tls),
|
||||
method: (body.method || "GET").toUpperCase(),
|
||||
})
|
||||
: await tcpProbe(ip, port, timeoutMs);
|
||||
return json({ ...result, colo });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "probe failed";
|
||||
return json({ ok: false, latencyMs: 0, error: message, colo });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function bearer(request: Request): string {
|
||||
const header = request.headers.get("Authorization") ?? "";
|
||||
return header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
}
|
||||
|
||||
function json(body: ProbeResponse, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timeout`)), timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function tcpProbe(
|
||||
ip: string,
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
): Promise<Omit<ProbeResponse, "colo">> {
|
||||
const started = Date.now();
|
||||
const { connect } = await import("cloudflare:sockets");
|
||||
const socket = connect({ hostname: ip, port });
|
||||
try {
|
||||
await withTimeout(socket.opened, timeoutMs, "tcp");
|
||||
return { ok: true, latencyMs: Date.now() - started, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "tcp failed";
|
||||
return { ok: false, latencyMs: Date.now() - started, error: message };
|
||||
} finally {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function httpProbe(opts: {
|
||||
ip: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
path: string;
|
||||
expectedStatus: number;
|
||||
timeoutMs: number;
|
||||
verifyTls: boolean;
|
||||
method: string;
|
||||
}): Promise<Omit<ProbeResponse, "colo">> {
|
||||
const started = Date.now();
|
||||
const useTls = opts.verifyTls || opts.port === 443;
|
||||
const host = opts.ip.includes(":") ? `[${opts.ip}]` : opts.ip;
|
||||
const path = opts.path.startsWith("/") ? opts.path : `/${opts.path}`;
|
||||
const url = `${useTls ? "https" : "http"}://${host}:${opts.port}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: opts.method === "HEAD" ? "HEAD" : "GET",
|
||||
headers: { Host: opts.hostname },
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
const latencyMs = Date.now() - started;
|
||||
if (res.status !== opts.expectedStatus) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
error: `HTTP ${res.status} (ожидали ${opts.expectedStatus})`,
|
||||
};
|
||||
}
|
||||
return { ok: true, latencyMs, error: null };
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.name === "AbortError"
|
||||
? "http timeout"
|
||||
: err.message
|
||||
: "http failed";
|
||||
return { ok: false, latencyMs: Date.now() - started, error: message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
name = "cfdm-health-probe"
|
||||
main = "src/index.ts"
|
||||
main = "src/index.mjs"
|
||||
compatibility_date = "2025-04-01"
|
||||
|
||||
# Set the shared secret: wrangler secret put PROBE_TOKEN
|
||||
# Then paste the Worker URL + token into CFDM → Настройки → Health-check.
|
||||
# Production Worker is created by CFDM (Workers Scripts API + KV + Cron Trigger).
|
||||
# This file is for local `wrangler dev` only.
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "HEALTH_KV"
|
||||
id = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
Reference in New Issue
Block a user