quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m9s
quality / api (push) Successful in 40s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 2m38s
- Added batch endpoint for health status queries to reduce the number of requests. - Improved DNS record management with caching and invalidation mechanisms. - Updated health check service to handle new configurations and improve performance. - Refactored certificate service to utilize concurrency for checks, enhancing efficiency. - Removed unused dependencies and optimized package configurations. Co-authored-by: Cursor <[email protected]>
6487 lines
217 KiB
JavaScript
6487 lines
217 KiB
JavaScript
// src/server.ts
|
|
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
import { resolve as resolve3 } from "path";
|
|
|
|
// src/app.ts
|
|
import { resolve as resolve2 } from "path";
|
|
import Fastify from "fastify";
|
|
import {
|
|
serializerCompiler,
|
|
validatorCompiler
|
|
} from "@fastify/type-provider-zod";
|
|
|
|
// src/config.ts
|
|
import { resolve } from "path";
|
|
function boolEnv(v, fallback) {
|
|
if (v === void 0 || v === "") return fallback;
|
|
return v === "1" || v.toLowerCase() === "true";
|
|
}
|
|
function loadConfig() {
|
|
const isProd = process.env.NODE_ENV === "production";
|
|
const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me");
|
|
return {
|
|
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
|
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
|
jwtSecret: jwtSecret || "dev-secret-change-me",
|
|
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
|
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
|
adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
|
|
serverPort: Number(process.env.SERVER_PORT ?? "8080") || 8080,
|
|
staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null,
|
|
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
|
|
// Default: every 2 minutes (was every 30s — hammered origins / anti-bot).
|
|
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "0 */2 * * * *",
|
|
healthDegradedFailures: Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
|
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
|
healthSuccessRecoveries: Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2,
|
|
healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1e3,
|
|
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2e3,
|
|
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
|
|
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
|
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
|
authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
|
authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""),
|
|
authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null)
|
|
};
|
|
}
|
|
|
|
// src/plugins/auth.ts
|
|
import fp from "fastify-plugin";
|
|
|
|
// src/errors.ts
|
|
import { NotFoundError, ConflictError } from "@cfdm/db";
|
|
import { ValidationError } from "@cfdm/shared";
|
|
var AppError = class _AppError extends Error {
|
|
constructor(code, message, statusCode) {
|
|
super(message);
|
|
this.code = code;
|
|
this.statusCode = statusCode;
|
|
this.name = "AppError";
|
|
}
|
|
code;
|
|
statusCode;
|
|
static notFound(message) {
|
|
return new _AppError("NOT_FOUND", message, 404);
|
|
}
|
|
static validation(message) {
|
|
return new _AppError("VALIDATION_ERROR", message, 400);
|
|
}
|
|
static unauthorized() {
|
|
return new _AppError("UNAUTHORIZED", "unauthorized", 401);
|
|
}
|
|
static forbidden(message = "forbidden") {
|
|
return new _AppError("FORBIDDEN", message, 403);
|
|
}
|
|
static conflict(message) {
|
|
return new _AppError("CONFLICT", message, 409);
|
|
}
|
|
static cloudflare(message) {
|
|
return new _AppError("CLOUDFLARE_ERROR", message, 502);
|
|
}
|
|
static dnsUpdateFailed(message) {
|
|
return new _AppError(
|
|
"DNS_UPDATE_FAILED",
|
|
message,
|
|
502
|
|
);
|
|
}
|
|
static healthcheckCreateFailed(message) {
|
|
return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
|
|
}
|
|
static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") {
|
|
return new _AppError("ZONE_NOT_FOUND", message, 404);
|
|
}
|
|
static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") {
|
|
return new _AppError("INVALID_IP", message, 400);
|
|
}
|
|
static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") {
|
|
return new _AppError("INVALID_HOSTNAME", message, 400);
|
|
}
|
|
static rateLimited(message = "Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") {
|
|
return new _AppError("RATE_LIMITED", message, 429);
|
|
}
|
|
static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") {
|
|
return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
|
|
}
|
|
static syncFailed(message) {
|
|
return new _AppError("SYNC_FAILED", message, 502);
|
|
}
|
|
static internal(message) {
|
|
return new _AppError("INTERNAL_ERROR", message, 500);
|
|
}
|
|
};
|
|
function toAppError(err) {
|
|
if (err instanceof AppError) return err;
|
|
if (err instanceof NotFoundError) return AppError.notFound(err.message);
|
|
if (err instanceof ConflictError) return AppError.conflict(err.message);
|
|
if (err instanceof ValidationError) return AppError.validation(err.message);
|
|
if (err instanceof Error) return AppError.internal(err.message);
|
|
return AppError.internal(String(err));
|
|
}
|
|
function errorBody(err) {
|
|
return {
|
|
error: {
|
|
code: err.code,
|
|
message: err.message
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/permissions.ts
|
|
function hasPermission(granted, required) {
|
|
if (granted.includes(required)) return true;
|
|
const parts = required.split(":");
|
|
if (parts.length !== 3) return false;
|
|
const [app2, section, action] = parts;
|
|
if (action === "read") {
|
|
return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`);
|
|
}
|
|
if (action === "write") {
|
|
return granted.includes(`${app2}:${section}:admin`);
|
|
}
|
|
return false;
|
|
}
|
|
var RULES = [
|
|
{
|
|
methods: ["GET"],
|
|
match: (p) => p.startsWith("/api/v1/domains") || p.startsWith("/api/v1/domain-monitors") || p === "/api/v1/domain-monitors",
|
|
permission: "cfdm:domains:read"
|
|
},
|
|
{
|
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/domains") || p.startsWith("/api/v1/domain-monitors"),
|
|
permission: "cfdm:domains:write"
|
|
},
|
|
{
|
|
methods: ["GET"],
|
|
match: (p) => p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
|
permission: "cfdm:dns:read"
|
|
},
|
|
{
|
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
|
permission: "cfdm:dns:write"
|
|
},
|
|
{
|
|
methods: ["GET"],
|
|
match: (p) => p.startsWith("/api/v1/certificates"),
|
|
permission: "cfdm:certificates:read"
|
|
},
|
|
{
|
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/certificates"),
|
|
permission: "cfdm:certificates:write"
|
|
},
|
|
{
|
|
methods: ["GET"],
|
|
match: (p) => p.startsWith("/api/v1/groups") || p.startsWith("/api/v1/service-groups"),
|
|
permission: "cfdm:groups:read"
|
|
},
|
|
{
|
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/groups") || p.startsWith("/api/v1/service-groups"),
|
|
permission: "cfdm:groups:write"
|
|
},
|
|
{
|
|
methods: ["GET"],
|
|
match: (p) => p.startsWith("/api/v1/services") || p.startsWith("/api/v1/service-bindings") || p.startsWith("/api/v1/health-checks") || p === "/api/v1/ops-summary",
|
|
permission: "cfdm:services:read"
|
|
},
|
|
{
|
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/services") || p.startsWith("/api/v1/service-bindings") || p.startsWith("/api/v1/health-checks"),
|
|
permission: "cfdm:services:write"
|
|
},
|
|
{
|
|
methods: ["GET", "POST"],
|
|
match: (p) => p.startsWith("/api/v1/sync"),
|
|
permission: "cfdm:domains:write"
|
|
},
|
|
{
|
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/settings") || p.startsWith("/api/v1/notifications") || p.startsWith("/api/v1/health-check"),
|
|
permission: "cfdm:settings:admin"
|
|
},
|
|
{
|
|
methods: ["GET"],
|
|
match: (p) => p.startsWith("/api/v1/audit"),
|
|
permission: "cfdm:settings:admin"
|
|
}
|
|
];
|
|
function permissionForRequest(method, path) {
|
|
const m = method.toUpperCase();
|
|
const pathname = path.split("?")[0] ?? path;
|
|
for (const rule of RULES) {
|
|
if (!rule.methods.includes(m)) continue;
|
|
if (rule.match(pathname)) return rule.permission;
|
|
}
|
|
if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read";
|
|
return null;
|
|
}
|
|
|
|
// src/plugins/auth.ts
|
|
async function authPlugin(app2, opts) {
|
|
const { config: config2 } = opts;
|
|
if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) {
|
|
throw new Error(
|
|
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true"
|
|
);
|
|
}
|
|
await app2.register(import("@fastify/jwt"), {
|
|
secret: config2.jwtSecret,
|
|
...config2.authRequired ? {
|
|
verify: {
|
|
allowedIss: [config2.authIssuer]
|
|
}
|
|
} : {}
|
|
});
|
|
if (config2.authRequired) {
|
|
app2.log.info(
|
|
{ issuer: config2.authIssuer, portal: config2.authPortalUrl },
|
|
"AUTH_REQUIRED=true \u2014 portal JWT middleware enabled"
|
|
);
|
|
} else {
|
|
app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth");
|
|
}
|
|
}
|
|
async function requireAuth(request2, reply) {
|
|
const config2 = request2.server.config;
|
|
const authHeader = request2.headers.authorization ?? "";
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
if (!token) throw AppError.unauthorized();
|
|
try {
|
|
await request2.jwtVerify();
|
|
} catch {
|
|
throw AppError.unauthorized();
|
|
}
|
|
if (!config2.authRequired) {
|
|
return;
|
|
}
|
|
const payload = request2.user;
|
|
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
|
|
const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : [];
|
|
if (!apps.includes("cfdm")) {
|
|
throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E Cloudflare Domain Manager");
|
|
}
|
|
request2.authUser = {
|
|
id: String(payload.sub),
|
|
email: String(payload.email ?? ""),
|
|
name: String(payload.name ?? ""),
|
|
apps,
|
|
permissions,
|
|
isAdmin: Boolean(payload.is_admin)
|
|
};
|
|
const required = permissionForRequest(request2.method, request2.url);
|
|
if (required && !hasPermission(permissions, required)) {
|
|
throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`);
|
|
}
|
|
}
|
|
var auth_default = fp(authPlugin, { name: "auth" });
|
|
|
|
// src/plugins/cf-client.ts
|
|
import fp2 from "fastify-plugin";
|
|
|
|
// src/lib/cf-retry.ts
|
|
async function withRetry(operation, maxAttempts = 3) {
|
|
let delay = 500;
|
|
let lastError;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
try {
|
|
return await operation();
|
|
} catch (err) {
|
|
lastError = err;
|
|
if (attempt < maxAttempts - 1) {
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
delay *= 2;
|
|
}
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
function parseRetryAfter(headers) {
|
|
const value = headers.get("retry-after");
|
|
if (!value) return null;
|
|
const seconds = Number(value);
|
|
return Number.isFinite(seconds) ? seconds * 1e3 : null;
|
|
}
|
|
|
|
// src/lib/cloudflare/zone-cache-events.ts
|
|
var subscribers = /* @__PURE__ */ new Set();
|
|
function subscribeZoneCacheInvalidation(cb) {
|
|
subscribers.add(cb);
|
|
return () => subscribers.delete(cb);
|
|
}
|
|
function notifyZoneCacheInvalidated() {
|
|
for (const cb of subscribers) {
|
|
try {
|
|
cb();
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/lib/cloudflare/http.ts
|
|
var CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
function mapCloudflareFailure(operation, status, message) {
|
|
const lower = message.toLowerCase();
|
|
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
|
notifyZoneCacheInvalidated();
|
|
if (operation.includes("workers") || operation.includes("kv_") || operation.includes("accounts")) {
|
|
return AppError.cloudflareAuthFailed(
|
|
"\u0422\u043E\u043A\u0435\u043D\u0443 \u043D\u0443\u0436\u043D\u044B \u043F\u0440\u0430\u0432\u0430 Account: Workers Scripts Write \u0438 Workers KV Storage Write. Zone DNS \u043D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E."
|
|
);
|
|
}
|
|
return AppError.cloudflareAuthFailed(
|
|
"Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D. \u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN."
|
|
);
|
|
}
|
|
if (status === 429 || lower.includes("rate limit")) {
|
|
notifyZoneCacheInvalidated();
|
|
return AppError.rateLimited();
|
|
}
|
|
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
|
return AppError.zoneNotFound();
|
|
}
|
|
if (operation.includes("healthcheck") && (lower.includes("plan") || lower.includes("not entitled") || lower.includes("not allowed") || lower.includes("permission"))) {
|
|
return AppError.healthcheckCreateFailed(
|
|
"Cloudflare Health Checks \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B \u0434\u043B\u044F \u044D\u0442\u043E\u0439 \u0437\u043E\u043D\u044B. \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0435 \u043F\u0440\u043E\u0432\u0435\u0440\u043A\u0438."
|
|
);
|
|
}
|
|
if (operation.includes("dns") || operation.includes("dns_record")) {
|
|
return AppError.dnsUpdateFailed(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C DNS \u0432 Cloudflare: ${message}`);
|
|
}
|
|
return AppError.cloudflare(`${operation}: ${message}`);
|
|
}
|
|
async function handleCfResponse(response, operation) {
|
|
if (response.status === 429) {
|
|
const wait = parseRetryAfter(response.headers) ?? 5e3;
|
|
throw AppError.rateLimited(
|
|
`Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 ${Math.ceil(wait / 1e3)} \u0441.`
|
|
);
|
|
}
|
|
const body = await response.json();
|
|
if (!body.success) {
|
|
const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
|
throw mapCloudflareFailure(operation, response.status, msg);
|
|
}
|
|
if (body.result === void 0) {
|
|
throw mapCloudflareFailure(operation, response.status, "empty result");
|
|
}
|
|
return body.result;
|
|
}
|
|
async function handleCfSuccess(response, operation) {
|
|
if (response.status === 429) {
|
|
const wait = parseRetryAfter(response.headers) ?? 5e3;
|
|
throw AppError.rateLimited(
|
|
`Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 ${Math.ceil(wait / 1e3)} \u0441.`
|
|
);
|
|
}
|
|
const text = await response.text();
|
|
if (!text) {
|
|
if (!response.ok) {
|
|
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
|
}
|
|
return;
|
|
}
|
|
let body;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
// src/lib/cloudflare/dns-service.ts
|
|
function createDnsAdapter(token) {
|
|
return {
|
|
async listDnsRecords(zoneId, cache) {
|
|
const cached = cache?.get(zoneId);
|
|
if (cached) return cached;
|
|
const all = await withRetry(async () => {
|
|
const records = [];
|
|
let page = 1;
|
|
while (page <= 50) {
|
|
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
|
url.searchParams.set("per_page", "100");
|
|
url.searchParams.set("page", String(page));
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure(
|
|
"list_dns_records",
|
|
response.status,
|
|
String(response.status)
|
|
);
|
|
}
|
|
const batch = await handleCfResponse(
|
|
response,
|
|
"list_dns_records"
|
|
);
|
|
if (batch.length === 0) break;
|
|
records.push(...batch);
|
|
page += 1;
|
|
}
|
|
return records;
|
|
});
|
|
cache?.set(zoneId, all);
|
|
return all;
|
|
},
|
|
async createDnsRecord(zoneId, payload) {
|
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
return handleCfResponse(response, "create_dns_record");
|
|
},
|
|
async updateDnsRecord(zoneId, recordId, payload) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "update_dns_record");
|
|
},
|
|
async patchDnsRecord(zoneId, recordId, payload) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "patch_dns_record");
|
|
},
|
|
async deleteDnsRecord(zoneId, recordId) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
await handleCfResponse(response, "delete_dns_record");
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cloudflare/healthcheck-service.ts
|
|
function createHealthCheckAdapter(token) {
|
|
return {
|
|
async listHealthChecks(zoneId) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "list_healthchecks");
|
|
},
|
|
async getHealthCheck(zoneId, id) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "get_healthcheck");
|
|
},
|
|
async createHealthCheck(zoneId, payload) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "create_healthcheck");
|
|
},
|
|
async updateHealthCheck(zoneId, id, payload) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "update_healthcheck");
|
|
},
|
|
async deleteHealthCheck(zoneId, id) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
await handleCfResponse(response, "delete_healthcheck");
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cloudflare/kv-service.ts
|
|
function createKvAdapter(token) {
|
|
return {
|
|
async listNamespaces(accountId) {
|
|
const all = [];
|
|
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(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
|
|
}
|
|
const batch = await handleCfResponse(response, "kv_list");
|
|
all.push(...batch);
|
|
if (batch.length < 100) break;
|
|
page += 1;
|
|
}
|
|
return all;
|
|
},
|
|
async createNamespace(accountId, title) {
|
|
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(3e4)
|
|
}
|
|
);
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
|
|
}
|
|
return handleCfResponse(response, "kv_create");
|
|
},
|
|
async getValue(accountId, namespaceId, key) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
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, namespaceId, key, value) {
|
|
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(3e4)
|
|
}
|
|
);
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
|
|
}
|
|
await handleCfSuccess(response, "kv_put");
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cloudflare/workers-service.ts
|
|
function createWorkersAdapter(token) {
|
|
return {
|
|
async listAccounts() {
|
|
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
|
|
}
|
|
return handleCfResponse(response, "list_accounts");
|
|
},
|
|
async putScript(opts) {
|
|
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(6e4)
|
|
}
|
|
);
|
|
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, scriptName, crons) {
|
|
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(3e4)
|
|
}
|
|
);
|
|
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, scriptName) {
|
|
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(3e4)
|
|
}
|
|
);
|
|
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) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
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(
|
|
response,
|
|
"workers_get_subdomain"
|
|
);
|
|
return result.subdomain?.trim() || null;
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cloudflare/zone-service.ts
|
|
var ZONE_CACHE_TTL_MS = 5 * 6e4;
|
|
function createZoneAdapter(token) {
|
|
let cachedAt = 0;
|
|
let cachedZones = null;
|
|
let inflight = null;
|
|
async function fetchZones() {
|
|
const all = [];
|
|
let page = 1;
|
|
while (true) {
|
|
const url = new URL(`${CF_API_BASE}/zones`);
|
|
url.searchParams.set("per_page", "50");
|
|
url.searchParams.set("page", String(page));
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
|
|
}
|
|
const batch = await handleCfResponse(response, "list_zones");
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
if (batch.length < 50) break;
|
|
page += 1;
|
|
}
|
|
return all;
|
|
}
|
|
function invalidate() {
|
|
cachedAt = 0;
|
|
cachedZones = null;
|
|
}
|
|
subscribeZoneCacheInvalidation(invalidate);
|
|
return {
|
|
async listZones() {
|
|
const now = Date.now();
|
|
if (cachedZones && now - cachedAt < ZONE_CACHE_TTL_MS) {
|
|
return cachedZones;
|
|
}
|
|
if (!inflight) {
|
|
inflight = withRetry(fetchZones).then((zones) => {
|
|
cachedAt = Date.now();
|
|
cachedZones = zones;
|
|
return zones;
|
|
}).finally(() => {
|
|
inflight = null;
|
|
});
|
|
}
|
|
return inflight;
|
|
},
|
|
invalidateZonesCache: invalidate,
|
|
async getZone(zoneId) {
|
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
return handleCfResponse(response, "get_zone");
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cf-client.ts
|
|
var CloudflareClient = class {
|
|
zones;
|
|
dns;
|
|
healthchecks;
|
|
kv;
|
|
workers;
|
|
token;
|
|
constructor(token) {
|
|
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() {
|
|
return this.token.length > 0;
|
|
}
|
|
listZones() {
|
|
return this.zones.listZones();
|
|
}
|
|
invalidateZonesCache() {
|
|
this.zones.invalidateZonesCache();
|
|
}
|
|
getZone(zoneId) {
|
|
return this.zones.getZone(zoneId);
|
|
}
|
|
listDnsRecords(zoneId, cache) {
|
|
return this.dns.listDnsRecords(zoneId, cache);
|
|
}
|
|
createDnsRecord(zoneId, payload) {
|
|
return this.dns.createDnsRecord(zoneId, payload);
|
|
}
|
|
updateDnsRecord(zoneId, recordId, payload) {
|
|
return this.dns.updateDnsRecord(zoneId, recordId, payload);
|
|
}
|
|
patchDnsRecord(zoneId, recordId, payload) {
|
|
return this.dns.patchDnsRecord(zoneId, recordId, payload);
|
|
}
|
|
deleteDnsRecord(zoneId, recordId) {
|
|
return this.dns.deleteDnsRecord(zoneId, recordId);
|
|
}
|
|
listHealthChecks(zoneId) {
|
|
return this.healthchecks.listHealthChecks(zoneId);
|
|
}
|
|
getHealthCheck(zoneId, id) {
|
|
return this.healthchecks.getHealthCheck(zoneId, id);
|
|
}
|
|
createHealthCheck(zoneId, payload) {
|
|
return this.healthchecks.createHealthCheck(zoneId, payload);
|
|
}
|
|
updateHealthCheck(zoneId, id, payload) {
|
|
return this.healthchecks.updateHealthCheck(zoneId, id, payload);
|
|
}
|
|
deleteHealthCheck(zoneId, id) {
|
|
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
|
}
|
|
listAccounts() {
|
|
return this.workers.listAccounts();
|
|
}
|
|
listKvNamespaces(accountId) {
|
|
return this.kv.listNamespaces(accountId);
|
|
}
|
|
createKvNamespace(accountId, title) {
|
|
return this.kv.createNamespace(accountId, title);
|
|
}
|
|
kvGet(accountId, namespaceId, key) {
|
|
return this.kv.getValue(accountId, namespaceId, key);
|
|
}
|
|
kvPut(accountId, namespaceId, key, value) {
|
|
return this.kv.putValue(accountId, namespaceId, key, value);
|
|
}
|
|
putWorkerScript(opts) {
|
|
return this.workers.putScript(opts);
|
|
}
|
|
putWorkerSchedules(accountId, scriptName, crons) {
|
|
return this.workers.putSchedules(accountId, scriptName, crons);
|
|
}
|
|
enableWorkersDev(accountId, scriptName) {
|
|
return this.workers.enableWorkersDev(accountId, scriptName);
|
|
}
|
|
getWorkersSubdomain(accountId) {
|
|
return this.workers.getWorkersSubdomain(accountId);
|
|
}
|
|
};
|
|
|
|
// src/plugins/cf-client.ts
|
|
async function cfClientPlugin(app2, opts) {
|
|
app2.decorate("config", opts.config);
|
|
app2.decorate("cf", new CloudflareClient(opts.config.cloudflareApiToken));
|
|
}
|
|
var cf_client_default = fp2(cfClientPlugin, { name: "cf-client" });
|
|
|
|
// src/plugins/cors.ts
|
|
import fp3 from "fastify-plugin";
|
|
async function corsPlugin(app2) {
|
|
await app2.register(import("@fastify/cors"), {
|
|
origin: true,
|
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
allowedHeaders: ["Content-Type", "Authorization"]
|
|
});
|
|
}
|
|
var cors_default = fp3(corsPlugin, { name: "cors" });
|
|
|
|
// src/plugins/db.ts
|
|
import fp4 from "fastify-plugin";
|
|
import {
|
|
createDb,
|
|
createMemoryDb,
|
|
healthCheck,
|
|
runMigrations
|
|
} from "@cfdm/db";
|
|
async function dbPlugin(app2, opts) {
|
|
const { db, sqlite } = opts.memory ? createMemoryDb() : createDb(opts.config.databaseUrl);
|
|
runMigrations(sqlite);
|
|
app2.decorate("db", db);
|
|
app2.decorate("sqlite", sqlite);
|
|
app2.addHook("onClose", async () => {
|
|
sqlite.close();
|
|
});
|
|
}
|
|
var db_default = fp4(dbPlugin, { name: "db" });
|
|
|
|
// src/plugins/error-handler.ts
|
|
import fp5 from "fastify-plugin";
|
|
async function errorHandlerPlugin(app2) {
|
|
app2.setErrorHandler((err, _request, reply) => {
|
|
if (reply.sent) return;
|
|
const appErr = err.statusCode === 401 ? AppError.unauthorized() : toAppError(err);
|
|
reply.status(appErr.statusCode).send(errorBody(appErr));
|
|
});
|
|
}
|
|
var error_handler_default = fp5(errorHandlerPlugin, { name: "error-handler" });
|
|
|
|
// src/routes/health.ts
|
|
import { z } from "zod";
|
|
|
|
// src/services/auth.ts
|
|
import { verify } from "@node-rs/argon2";
|
|
async function verifyPassword(config2, password) {
|
|
if (config2.adminPasswordHash === "devplaceholder") {
|
|
if (password === "admin") return;
|
|
throw AppError.unauthorized();
|
|
}
|
|
const ok = await verify(config2.adminPasswordHash, password);
|
|
if (!ok) throw AppError.unauthorized();
|
|
}
|
|
async function login(config2, sign, req) {
|
|
if (req.username !== config2.adminUsername) {
|
|
throw AppError.unauthorized();
|
|
}
|
|
await verifyPassword(config2, req.password);
|
|
const expiresAt = new Date(
|
|
Date.now() + config2.jwtTtlHours * 60 * 60 * 1e3
|
|
);
|
|
const token = sign({
|
|
sub: req.username,
|
|
exp: Math.floor(expiresAt.getTime() / 1e3)
|
|
});
|
|
return {
|
|
token,
|
|
expires_at: expiresAt.toISOString()
|
|
};
|
|
}
|
|
|
|
// src/routes/health.ts
|
|
async function healthRoutes(app2) {
|
|
app2.get("/health", async (request2, reply) => {
|
|
healthCheck(request2.server.sqlite);
|
|
return { status: "ok" };
|
|
});
|
|
app2.get("/ready", async (request2, reply) => {
|
|
healthCheck(request2.server.sqlite);
|
|
let cloudflare = false;
|
|
if (request2.server.config.cloudflareApiToken) {
|
|
try {
|
|
await request2.server.cf.listZones();
|
|
cloudflare = true;
|
|
} catch {
|
|
cloudflare = false;
|
|
}
|
|
}
|
|
return {
|
|
status: cloudflare || !request2.server.config.cloudflareApiToken ? "ready" : "degraded",
|
|
database: true,
|
|
cloudflare
|
|
};
|
|
});
|
|
}
|
|
async function authRoutes(app2) {
|
|
app2.get("/auth/config", async (request2) => {
|
|
const { config: config2 } = request2.server;
|
|
return {
|
|
required: config2.authRequired,
|
|
portal_url: config2.authPortalUrl
|
|
};
|
|
});
|
|
const loginSchema = z.object({
|
|
username: z.string(),
|
|
password: z.string()
|
|
});
|
|
app2.post("/auth/login", async (request2, reply) => {
|
|
if (request2.server.config.authRequired) {
|
|
return reply.code(403).send({
|
|
error: {
|
|
code: "FORBIDDEN",
|
|
message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal"
|
|
}
|
|
});
|
|
}
|
|
const body = loginSchema.parse(request2.body);
|
|
const result = await login(
|
|
request2.server.config,
|
|
(payload) => request2.server.jwt.sign(payload),
|
|
body
|
|
);
|
|
return result;
|
|
});
|
|
}
|
|
|
|
// src/routes/groups.ts
|
|
import { z as z2 } from "zod";
|
|
import { repos as repos2 } from "@cfdm/db";
|
|
|
|
// src/services/group-service.ts
|
|
import { repos } from "@cfdm/db";
|
|
function listGroups(db) {
|
|
return repos.listGroups(db);
|
|
}
|
|
function createGroup(db, name, slug) {
|
|
return repos.createGroup(db, name, slug);
|
|
}
|
|
function updateGroup(db, id, name, slug) {
|
|
return repos.updateGroup(db, id, name, slug);
|
|
}
|
|
function deleteGroup(db, id) {
|
|
repos.deleteGroup(db, id);
|
|
}
|
|
function getGroupWithStats(db, id) {
|
|
return repos.getGroupWithStats(db, id);
|
|
}
|
|
|
|
// src/lib/audit.ts
|
|
import { randomUUID } from "crypto";
|
|
import { appendAudit } from "@cfdm/db";
|
|
|
|
// src/services/audit-portal-push.ts
|
|
import { request } from "undici";
|
|
async function pushAuditEvents(portalUrl, secret, events) {
|
|
const base = portalUrl.replace(/\/$/, "");
|
|
const res = await request(`${base}/api/v1/ingest/audit`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
authorization: `Bearer ${secret}`
|
|
},
|
|
body: JSON.stringify({ events })
|
|
});
|
|
if (res.statusCode >= 400) {
|
|
const body = await res.body.text();
|
|
throw new Error(`portal audit ingest ${res.statusCode}: ${body}`);
|
|
}
|
|
}
|
|
|
|
// src/lib/audit.ts
|
|
function clientIp(request2) {
|
|
const forwarded = request2.headers["x-forwarded-for"];
|
|
if (typeof forwarded === "string" && forwarded.trim()) {
|
|
return forwarded.split(",")[0]?.trim() ?? null;
|
|
}
|
|
return request2.ip ?? null;
|
|
}
|
|
function actorFromRequest(request2) {
|
|
const u = request2.authUser;
|
|
if (u) {
|
|
return {
|
|
actorUserId: u.id,
|
|
actorEmail: u.email || null,
|
|
actorName: u.name || null
|
|
};
|
|
}
|
|
const payload = request2.user;
|
|
if (payload?.sub) {
|
|
return {
|
|
actorUserId: String(payload.sub),
|
|
actorEmail: payload.email ? String(payload.email) : null,
|
|
actorName: payload.name ? String(payload.name) : null
|
|
};
|
|
}
|
|
return {
|
|
actorUserId: null,
|
|
actorEmail: null,
|
|
actorName: null
|
|
};
|
|
}
|
|
function recordAudit(app2, request2, input) {
|
|
const eventId = randomUUID();
|
|
const actor = actorFromRequest(request2);
|
|
const ip = clientIp(request2);
|
|
const createdAt = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
const full = {
|
|
...input,
|
|
...actor,
|
|
eventId,
|
|
sourceApp: "cfdm",
|
|
ip,
|
|
createdAt
|
|
};
|
|
try {
|
|
appendAudit(app2.db, full);
|
|
} catch (err) {
|
|
app2.log.warn({ err }, "audit_log append failed");
|
|
}
|
|
const secret = app2.config.authAuditIngestSecret;
|
|
const portalUrl = app2.config.authPortalUrl;
|
|
if (!secret || !portalUrl) return;
|
|
void pushAuditEvents(portalUrl, secret, [
|
|
{
|
|
event_id: eventId,
|
|
source_app: "cfdm",
|
|
action: input.action,
|
|
severity: input.severity,
|
|
actor_user_id: actor.actorUserId,
|
|
actor_email: actor.actorEmail,
|
|
actor_name: actor.actorName,
|
|
target_type: input.targetType,
|
|
target_id: input.targetId,
|
|
summary: input.summary,
|
|
details: input.details,
|
|
ip,
|
|
created_at: createdAt
|
|
}
|
|
]).catch((err) => {
|
|
app2.log.warn({ err, eventId }, "audit portal push failed");
|
|
});
|
|
}
|
|
|
|
// src/routes/groups.ts
|
|
async function groupRoutes(app2) {
|
|
const bodySchema = z2.object({
|
|
name: z2.string(),
|
|
slug: z2.string()
|
|
});
|
|
app2.get("/groups", async (request2) => {
|
|
return listGroups(request2.server.db);
|
|
});
|
|
app2.post("/groups", async (request2) => {
|
|
const body = bodySchema.parse(request2.body);
|
|
const group = createGroup(request2.server.db, body.name, body.slug);
|
|
recordAudit(request2.server, request2, {
|
|
action: "group.create",
|
|
targetType: "app_resource",
|
|
targetId: String(group.id),
|
|
summary: `\u0421\u043E\u0437\u0434\u0430\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`,
|
|
details: { name: group.name, slug: group.slug }
|
|
});
|
|
return group;
|
|
});
|
|
app2.get("/groups/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getGroupWithStats(request2.server.db, Number(id));
|
|
});
|
|
app2.patch("/groups/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = bodySchema.parse(request2.body);
|
|
const group = updateGroup(
|
|
request2.server.db,
|
|
Number(id),
|
|
body.name,
|
|
body.slug
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "group.update",
|
|
targetType: "app_resource",
|
|
targetId: String(group.id),
|
|
summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`,
|
|
details: { name: group.name, slug: group.slug }
|
|
});
|
|
return group;
|
|
});
|
|
app2.delete("/groups/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const group = repos2.getGroup(request2.server.db, Number(id));
|
|
deleteGroup(request2.server.db, Number(id));
|
|
recordAudit(request2.server, request2, {
|
|
action: "group.delete",
|
|
severity: "warning",
|
|
targetType: "app_resource",
|
|
targetId: String(id),
|
|
summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 \u0433\u0440\u0443\u043F\u043F\u0430 \xAB${group.name}\xBB`,
|
|
details: { name: group.name, slug: group.slug }
|
|
});
|
|
return { deleted: true };
|
|
});
|
|
}
|
|
|
|
// src/routes/services.ts
|
|
import { z as z3 } from "zod";
|
|
import {
|
|
changeDomainSchema,
|
|
createServiceNodeSchema,
|
|
reorderServicesSchema,
|
|
toggleServiceIpSchema,
|
|
updateServiceConfigSchema,
|
|
updateServiceNodeSchema
|
|
} from "@cfdm/shared";
|
|
import { repos as repos13 } from "@cfdm/db";
|
|
|
|
// src/services/service-config-service.ts
|
|
import { repos as repos9 } from "@cfdm/db";
|
|
import {
|
|
SYNC_ERROR as SYNC_ERROR2,
|
|
SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3,
|
|
SYNC_SYNCED as SYNC_SYNCED3,
|
|
dnsRecordNamesMatch as dnsRecordNamesMatch2,
|
|
isIpLiteral as isIpLiteral2,
|
|
normalizeDnsRecordName as normalizeDnsRecordName2
|
|
} from "@cfdm/shared";
|
|
|
|
// src/lib/validators.ts
|
|
import {
|
|
validateDnsRecord,
|
|
certStatusFromExpiry,
|
|
isValidIpv4,
|
|
ValidationError as ValidationError2
|
|
} from "@cfdm/shared";
|
|
|
|
// src/services/dns-service.ts
|
|
import { repos as repos3 } from "@cfdm/db";
|
|
import {
|
|
SYNC_CONFLICT,
|
|
SYNC_ERROR,
|
|
SYNC_PENDING_PUSH,
|
|
SYNC_SYNCED,
|
|
normalizeDnsRecordName
|
|
} from "@cfdm/shared";
|
|
function toCfPayload(recordType, name, content, ttl, proxied, priority) {
|
|
return {
|
|
type: recordType.toUpperCase(),
|
|
name,
|
|
content,
|
|
ttl,
|
|
proxied,
|
|
priority: priority ?? void 0
|
|
};
|
|
}
|
|
async function pushRecord(db, cf, domainId, cfZoneId, record) {
|
|
const payload = toCfPayload(
|
|
record.record_type,
|
|
record.name,
|
|
record.content,
|
|
record.ttl,
|
|
record.proxied,
|
|
record.priority
|
|
);
|
|
try {
|
|
const cfRec = record.cf_record_id ? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload) : await cf.createDnsRecord(cfZoneId, payload);
|
|
repos3.updateDnsFields(
|
|
db,
|
|
record.id,
|
|
cfRec.type ?? record.record_type,
|
|
cfRec.name,
|
|
cfRec.content,
|
|
cfRec.ttl,
|
|
cfRec.proxied ?? false,
|
|
cfRec.priority ?? null,
|
|
SYNC_SYNCED,
|
|
cfRec.id ?? null,
|
|
null
|
|
);
|
|
return repos3.getDnsRecord(db, domainId, record.id);
|
|
} catch (e) {
|
|
repos3.setDnsSyncStatus(
|
|
db,
|
|
record.id,
|
|
SYNC_ERROR,
|
|
record.cf_record_id,
|
|
e instanceof Error ? e.message : String(e)
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
async function create(db, cf, domainId, req) {
|
|
const domain = repos3.getDomain(db, domainId);
|
|
const ttl = req.ttl ?? 1;
|
|
const proxied = req.proxied ?? false;
|
|
const name = normalizeDnsRecordName(req.name, domain.zone_name);
|
|
validateDnsRecord(req.record_type, name, req.content, ttl, proxied);
|
|
const record = repos3.insertDnsRecord(
|
|
db,
|
|
domainId,
|
|
req.record_type,
|
|
name,
|
|
req.content,
|
|
ttl,
|
|
proxied,
|
|
req.priority ?? null,
|
|
SYNC_PENDING_PUSH,
|
|
"local",
|
|
null
|
|
);
|
|
return pushRecord(db, cf, domainId, domain.cf_zone_id, record);
|
|
}
|
|
async function update(db, cf, domainId, recordId, req) {
|
|
const domain = repos3.getDomain(db, domainId);
|
|
const existing = repos3.getDnsRecord(db, domainId, recordId);
|
|
const recordType = req.record_type ?? existing.record_type;
|
|
const name = normalizeDnsRecordName(
|
|
req.name ?? existing.name,
|
|
domain.zone_name
|
|
);
|
|
const content = req.content ?? existing.content;
|
|
const ttl = req.ttl ?? existing.ttl;
|
|
const proxied = req.proxied ?? existing.proxied;
|
|
const priority = req.priority ?? existing.priority;
|
|
validateDnsRecord(recordType, name, content, ttl, proxied);
|
|
repos3.updateDnsFields(
|
|
db,
|
|
recordId,
|
|
recordType,
|
|
name,
|
|
content,
|
|
ttl,
|
|
proxied,
|
|
priority,
|
|
SYNC_PENDING_PUSH,
|
|
existing.cf_record_id,
|
|
null
|
|
);
|
|
const updated = repos3.getDnsRecord(db, domainId, recordId);
|
|
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
|
}
|
|
async function deleteRecord(db, cf, domainId, recordId) {
|
|
const domain = repos3.getDomain(db, domainId);
|
|
const record = repos3.getDnsRecord(db, domainId, recordId);
|
|
repos3.markDnsPendingDelete(db, recordId);
|
|
if (record.cf_record_id) {
|
|
try {
|
|
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
|
|
} catch (e) {
|
|
repos3.setDnsSyncStatus(
|
|
db,
|
|
recordId,
|
|
SYNC_ERROR,
|
|
record.cf_record_id,
|
|
e instanceof Error ? e.message : String(e)
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
repos3.deleteDnsRecord(db, recordId);
|
|
}
|
|
function list(db, domainId, filter) {
|
|
repos3.getDomain(db, domainId);
|
|
return repos3.listDnsRecords(db, domainId, filter);
|
|
}
|
|
function get(db, domainId, recordId) {
|
|
return repos3.getDnsRecord(db, domainId, recordId);
|
|
}
|
|
async function bulk(db, cf, domainId, ops) {
|
|
const results = [];
|
|
for (const op of ops) {
|
|
try {
|
|
if (op.action === "create") {
|
|
if (!op.record) throw AppError.validation("record required");
|
|
const r = await create(db, cf, domainId, op.record);
|
|
results.push({ id: r.id, success: true });
|
|
} else if (op.action === "update") {
|
|
if (op.id == null) throw AppError.validation("id required");
|
|
if (!op.record) throw AppError.validation("record required");
|
|
await update(db, cf, domainId, op.id, {
|
|
record_type: op.record.record_type,
|
|
name: op.record.name,
|
|
content: op.record.content,
|
|
ttl: op.record.ttl,
|
|
proxied: op.record.proxied,
|
|
priority: op.record.priority
|
|
});
|
|
results.push({ id: op.id, success: true });
|
|
} else if (op.action === "delete") {
|
|
if (op.id == null) throw AppError.validation("id required");
|
|
await deleteRecord(db, cf, domainId, op.id);
|
|
results.push({ id: op.id, success: true });
|
|
} else {
|
|
results.push({
|
|
id: op.id,
|
|
success: false,
|
|
error: `unknown action: ${op.action}`
|
|
});
|
|
}
|
|
} catch (e) {
|
|
results.push({
|
|
id: op.id,
|
|
success: false,
|
|
error: e instanceof Error ? e.message : String(e)
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
async function resolveConflict(db, cf, domainId, recordId, req) {
|
|
const domain = repos3.getDomain(db, domainId);
|
|
const record = repos3.getDnsRecord(db, domainId, recordId);
|
|
if (record.sync_status !== SYNC_CONFLICT) {
|
|
throw AppError.validation("record is not in conflict state");
|
|
}
|
|
if (req.source === "cloudflare") {
|
|
if (record.cf_record_id) {
|
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
|
const r = remote.find((x) => x.id === record.cf_record_id);
|
|
if (r) {
|
|
repos3.updateDnsFields(
|
|
db,
|
|
recordId,
|
|
r.type,
|
|
r.name,
|
|
r.content,
|
|
r.ttl,
|
|
r.proxied ?? false,
|
|
r.priority ?? null,
|
|
SYNC_SYNCED,
|
|
r.id ?? null,
|
|
null
|
|
);
|
|
}
|
|
}
|
|
return repos3.getDnsRecord(db, domainId, recordId);
|
|
}
|
|
if (req.source === "local") {
|
|
const updated = repos3.getDnsRecord(db, domainId, recordId);
|
|
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
|
}
|
|
throw AppError.validation("source must be cloudflare or local");
|
|
}
|
|
|
|
// src/services/domain-service.ts
|
|
import { repos as repos6 } from "@cfdm/db";
|
|
|
|
// src/services/binding-service.ts
|
|
import { repos as repos4 } from "@cfdm/db";
|
|
function normalizeHostname(hostname) {
|
|
const h = hostname?.trim();
|
|
return h ? h : "@";
|
|
}
|
|
async function syncTargetIp(db, cf, domainId, bindingId, hostname, dnsRecordId, targetIp) {
|
|
if (dnsRecordId) {
|
|
await update(db, cf, domainId, dnsRecordId, {
|
|
record_type: "A",
|
|
name: hostname,
|
|
content: targetIp,
|
|
proxied: false
|
|
});
|
|
return dnsRecordId;
|
|
}
|
|
const record = await create(db, cf, domainId, {
|
|
record_type: "A",
|
|
name: hostname,
|
|
content: targetIp,
|
|
ttl: 1,
|
|
proxied: false
|
|
});
|
|
repos4.setBindingDnsRecordId(db, bindingId, record.id);
|
|
return record.id;
|
|
}
|
|
function listAll(db) {
|
|
return repos4.listAllBindings(db);
|
|
}
|
|
function listByDomain(db, domainId) {
|
|
repos4.getDomain(db, domainId);
|
|
return repos4.listBindingsByDomain(db, domainId);
|
|
}
|
|
async function create2(db, cf, req) {
|
|
repos4.getDomain(db, req.domain_id);
|
|
repos4.getService(db, req.service_id);
|
|
const hostname = normalizeHostname(req.hostname);
|
|
const binding = repos4.insertBinding(
|
|
db,
|
|
req.domain_id,
|
|
req.service_id,
|
|
hostname,
|
|
null
|
|
);
|
|
const ip = req.target_ip?.trim();
|
|
if (ip) {
|
|
await syncTargetIp(db, cf, req.domain_id, binding.id, hostname, null, ip);
|
|
}
|
|
return repos4.getBindingView(db, binding.id);
|
|
}
|
|
async function update2(db, cf, id, req) {
|
|
const existing = repos4.getBinding(db, id);
|
|
if (req.cert_monitoring !== void 0) {
|
|
repos4.updateBindingLbConfig(db, id, {
|
|
cert_monitoring: req.cert_monitoring
|
|
});
|
|
}
|
|
const hasIdentityPatch = req.service_id !== void 0 || req.hostname !== void 0 || req.target_ip !== void 0;
|
|
if (!hasIdentityPatch) {
|
|
return repos4.getBindingView(db, id);
|
|
}
|
|
const serviceId = req.service_id ?? existing.service_id;
|
|
if (req.service_id) repos4.getService(db, req.service_id);
|
|
const hostname = req.hostname ? normalizeHostname(req.hostname) : existing.hostname;
|
|
repos4.updateBindingFields(
|
|
db,
|
|
id,
|
|
serviceId,
|
|
hostname,
|
|
existing.dns_record_id
|
|
);
|
|
const ip = req.target_ip?.trim();
|
|
if (ip) {
|
|
await syncTargetIp(
|
|
db,
|
|
cf,
|
|
existing.domain_id,
|
|
id,
|
|
hostname,
|
|
existing.dns_record_id,
|
|
ip
|
|
);
|
|
}
|
|
return repos4.getBindingView(db, id);
|
|
}
|
|
function remove(db, id) {
|
|
repos4.getBinding(db, id);
|
|
repos4.deleteBinding(db, id);
|
|
}
|
|
async function setDomainServices(db, domainId, serviceIds) {
|
|
repos4.getDomain(db, domainId);
|
|
for (const sid of serviceIds) {
|
|
repos4.getService(db, sid);
|
|
}
|
|
const existing = repos4.listBindingsByDomain(db, domainId);
|
|
for (const binding of existing) {
|
|
if (!serviceIds.includes(binding.service_id)) {
|
|
repos4.deleteBinding(db, binding.id);
|
|
}
|
|
}
|
|
for (const sid of serviceIds) {
|
|
const already = existing.some((b) => b.service_id === sid);
|
|
if (!already) {
|
|
repos4.insertBinding(db, domainId, sid, "@", null);
|
|
}
|
|
}
|
|
return repos4.listBindingsByDomain(db, domainId).map((b) => b.service_id);
|
|
}
|
|
|
|
// src/services/sync-service.ts
|
|
import { repos as repos5 } from "@cfdm/db";
|
|
import {
|
|
SYNC_CONFLICT as SYNC_CONFLICT2,
|
|
SYNC_PENDING_PUSH as SYNC_PENDING_PUSH2,
|
|
SYNC_SYNCED as SYNC_SYNCED2,
|
|
dnsNameToSubdomainLabel,
|
|
dnsRecordNamesMatch,
|
|
subdomainLabelToFqdn
|
|
} from "@cfdm/shared";
|
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
function findLocalByRemote(local, cfRec, zoneName) {
|
|
return local.find(
|
|
(record) => record.record_type.toUpperCase() === cfRec.type.toUpperCase() && dnsRecordNamesMatch(record.name, cfRec.name, zoneName)
|
|
) ?? null;
|
|
}
|
|
function dnsRecordsEquivalent(existing, cfRec, zoneName) {
|
|
const proxied = cfRec.proxied ?? false;
|
|
return existing.content === cfRec.content && existing.ttl === cfRec.ttl && existing.proxied === proxied && dnsRecordNamesMatch(existing.name, cfRec.name, zoneName) && existing.record_type.toUpperCase() === cfRec.type.toUpperCase();
|
|
}
|
|
function applyRemoteRecord(db, domainId, cfRec, existing, zoneName) {
|
|
const cfId = cfRec.id;
|
|
if (!cfId) return false;
|
|
const proxied = cfRec.proxied ?? false;
|
|
const equivalent = dnsRecordsEquivalent(existing, cfRec, zoneName);
|
|
if (!equivalent && existing.sync_status !== SYNC_PENDING_PUSH2) {
|
|
repos5.setDnsSyncStatus(db, existing.id, SYNC_CONFLICT2, cfId, null);
|
|
return true;
|
|
}
|
|
if (!equivalent) return false;
|
|
if (existing.name !== cfRec.name || existing.sync_status !== SYNC_SYNCED2 || existing.cf_record_id !== cfId || existing.content !== cfRec.content || existing.ttl !== cfRec.ttl || existing.proxied !== proxied) {
|
|
repos5.updateDnsFields(
|
|
db,
|
|
existing.id,
|
|
cfRec.type,
|
|
cfRec.name,
|
|
cfRec.content,
|
|
cfRec.ttl,
|
|
proxied,
|
|
cfRec.priority ?? null,
|
|
SYNC_SYNCED2,
|
|
cfId,
|
|
null
|
|
);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
async function pullSync(db, cf, domain) {
|
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
|
const local = repos5.listDnsByDomain(db, domain.id);
|
|
let changed = 0;
|
|
const remoteIds = new Set(
|
|
remote.map((r) => r.id).filter((id) => Boolean(id))
|
|
);
|
|
for (const cfRec of remote) {
|
|
const cfId = cfRec.id;
|
|
if (!cfId) continue;
|
|
let existing = repos5.findDnsByCfId(db, domain.id, cfId);
|
|
if (!existing) {
|
|
existing = findLocalByRemote(local, cfRec, domain.zone_name);
|
|
}
|
|
if (existing) {
|
|
if (applyRemoteRecord(db, domain.id, cfRec, existing, domain.zone_name)) {
|
|
changed += 1;
|
|
}
|
|
} else {
|
|
repos5.insertDnsRecord(
|
|
db,
|
|
domain.id,
|
|
cfRec.type,
|
|
cfRec.name,
|
|
cfRec.content,
|
|
cfRec.ttl,
|
|
cfRec.proxied ?? false,
|
|
cfRec.priority ?? null,
|
|
SYNC_SYNCED2,
|
|
"cloudflare",
|
|
cfId
|
|
);
|
|
changed += 1;
|
|
}
|
|
}
|
|
const refreshedLocal = repos5.listDnsByDomain(db, domain.id);
|
|
for (const rec of refreshedLocal) {
|
|
if (rec.cf_record_id && !remoteIds.has(rec.cf_record_id)) {
|
|
if (rec.sync_status !== "pending_delete") {
|
|
repos5.setDnsSyncStatus(
|
|
db,
|
|
rec.id,
|
|
SYNC_CONFLICT2,
|
|
rec.cf_record_id,
|
|
"missing in cloudflare"
|
|
);
|
|
changed += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (rec.sync_status === SYNC_PENDING_PUSH2) continue;
|
|
const remoteSameType = remote.find(
|
|
(r) => r.id && dnsRecordNamesMatch(r.name, rec.name, domain.zone_name) && r.type.toUpperCase() === rec.record_type.toUpperCase()
|
|
);
|
|
if (remoteSameType?.id) {
|
|
if (applyRemoteRecord(db, domain.id, remoteSameType, rec, domain.zone_name)) {
|
|
changed += 1;
|
|
}
|
|
continue;
|
|
}
|
|
const remoteSameHost = remote.find(
|
|
(r) => dnsRecordNamesMatch(r.name, rec.name, domain.zone_name)
|
|
);
|
|
if (remoteSameHost && remoteSameHost.type.toUpperCase() !== rec.record_type.toUpperCase()) {
|
|
repos5.setDnsSyncStatus(
|
|
db,
|
|
rec.id,
|
|
SYNC_CONFLICT2,
|
|
rec.cf_record_id,
|
|
"type mismatch with cloudflare"
|
|
);
|
|
changed += 1;
|
|
}
|
|
}
|
|
const labels = /* @__PURE__ */ new Set();
|
|
for (const rec of remote) {
|
|
const label = dnsNameToSubdomainLabel(rec.name, domain.zone_name);
|
|
if (label) labels.add(label);
|
|
}
|
|
for (const label of labels) {
|
|
const fqdn = subdomainLabelToFqdn(label, domain.zone_name);
|
|
repos5.upsertSubdomain(db, domain.id, label, fqdn);
|
|
changed += 1;
|
|
}
|
|
repos5.setDomainLastSynced(db, domain.id);
|
|
return changed;
|
|
}
|
|
async function syncDomain(db, cf, domainId) {
|
|
const jobId = randomUUID2();
|
|
repos5.createSyncJob(db, jobId, domainId);
|
|
const domain = repos5.getDomain(db, domainId);
|
|
try {
|
|
const changes = await pullSync(db, cf, domain);
|
|
repos5.finishSyncJob(db, jobId, "completed", `${changes} changes`);
|
|
return { jobId, changes };
|
|
} catch (e) {
|
|
repos5.finishSyncJob(
|
|
db,
|
|
jobId,
|
|
"failed",
|
|
e instanceof Error ? e.message : String(e)
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
async function syncAll(db, cf) {
|
|
const jobId = randomUUID2();
|
|
repos5.createSyncJob(db, jobId, null);
|
|
const all = repos5.listAllDomains(db);
|
|
let total = 0;
|
|
for (const domain of all) {
|
|
try {
|
|
total += await pullSync(db, cf, domain);
|
|
} catch {
|
|
}
|
|
}
|
|
repos5.finishSyncJob(db, jobId, "completed", `${total} total changes`);
|
|
return jobId;
|
|
}
|
|
function getJob(db, jobId) {
|
|
return repos5.getSyncJob(db, jobId);
|
|
}
|
|
|
|
// src/services/domain-service.ts
|
|
function listDomains(db, groupId) {
|
|
return repos6.listDomainsEnriched(db, groupId);
|
|
}
|
|
function getDomain(db, id) {
|
|
return repos6.getDomain(db, id);
|
|
}
|
|
async function createDomain(db, cf, groupId, zoneName) {
|
|
const trimmed = zoneName.trim();
|
|
const zones = await cf.listZones();
|
|
if (zones.length === 0) {
|
|
throw AppError.notFound(
|
|
"\u043D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0445 \u0437\u043E\u043D \u0432 Cloudflare \u2014 \u043F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN \u0438 \u043F\u0440\u0430\u0432\u0430 Zone:Read"
|
|
);
|
|
}
|
|
const zone = zones.find((z9) => z9.name.toLowerCase() === trimmed.toLowerCase());
|
|
if (!zone) {
|
|
const names = zones.map((z9) => z9.name).join(", ");
|
|
throw AppError.notFound(
|
|
`\u0437\u043E\u043D\u0430 \xAB${trimmed}\xBB \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430 \u0432 Cloudflare. \u0414\u043E\u0441\u0442\u0443\u043F\u043D\u044B\u0435: ${names}`
|
|
);
|
|
}
|
|
return repos6.createDomain(db, groupId, zone.name, zone.id);
|
|
}
|
|
function updateDomain(db, id, patch) {
|
|
const domain = repos6.updateDomain(db, id, {
|
|
group_id: patch.group_id,
|
|
status: patch.status,
|
|
cert_monitoring: patch.cert_monitoring,
|
|
environment: patch.environment
|
|
});
|
|
if (patch.tags !== void 0) {
|
|
repos6.setDomainTags(db, id, patch.tags);
|
|
}
|
|
return domain;
|
|
}
|
|
function bulkUpdateDomains(db, ids, patch) {
|
|
let updated = 0;
|
|
for (const id of ids) {
|
|
try {
|
|
repos6.updateDomain(db, id, {
|
|
group_id: patch.group_id,
|
|
environment: patch.environment
|
|
});
|
|
if (patch.tags_add?.length) {
|
|
repos6.addDomainTags(db, id, patch.tags_add);
|
|
}
|
|
updated += 1;
|
|
} catch {
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
function deleteDomain(db, id) {
|
|
repos6.deleteDomain(db, id);
|
|
}
|
|
async function setDomainServices2(db, domainId, serviceIds) {
|
|
return setDomainServices(db, domainId, serviceIds);
|
|
}
|
|
async function importZoneRecords(db, cf, domainId) {
|
|
const domain = repos6.getDomain(db, domainId);
|
|
return pullSync(db, cf, domain);
|
|
}
|
|
|
|
// src/services/vps-tracker-sync.ts
|
|
import { resolve4 } from "dns/promises";
|
|
import { isIpLiteral } from "@cfdm/shared";
|
|
import { repos as repos7, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
|
|
|
// src/services/routing/pool.ts
|
|
function uniqueIpCount(ips) {
|
|
return new Set(ips.filter(Boolean)).size;
|
|
}
|
|
function isSharedPool(ips) {
|
|
return uniqueIpCount(ips) >= 2;
|
|
}
|
|
function canApplyLb(serviceIps, bindingIps) {
|
|
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
|
|
}
|
|
function shouldRecordFailoverDnsDiff(input) {
|
|
if (!isSharedPool(input.configuredIps)) return false;
|
|
if (input.lbMode !== "weighted") return true;
|
|
return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));
|
|
}
|
|
|
|
// src/services/vps-tracker-sync.ts
|
|
function isLbMode(value) {
|
|
return value === "round_robin" || value === "failover" || value === "weighted";
|
|
}
|
|
function resolveLbModeForSync(bindingLbMode, groupLbMode) {
|
|
if (isLbMode(bindingLbMode)) return bindingLbMode;
|
|
if (isLbMode(groupLbMode)) return groupLbMode;
|
|
return void 0;
|
|
}
|
|
function effectiveLbModeForSync(bindingLbMode, groupLbMode, serviceIps) {
|
|
if (!isSharedPool(serviceIps)) return void 0;
|
|
return resolveLbModeForSync(bindingLbMode, groupLbMode);
|
|
}
|
|
function groupLbModeForService(db, serviceId, cache) {
|
|
if (cache.has(serviceId)) return cache.get(serviceId);
|
|
let mode;
|
|
try {
|
|
const service = repos7.getService(db, serviceId);
|
|
if (service.service_group_id != null) {
|
|
const group = repos7.getServiceGroup(db, service.service_group_id);
|
|
mode = resolveLbModeForSync(void 0, group.lb_mode);
|
|
}
|
|
} catch {
|
|
mode = void 0;
|
|
}
|
|
cache.set(serviceId, mode);
|
|
return mode;
|
|
}
|
|
function fqdnToDisplay(hostname, zoneName) {
|
|
if (hostname === "@" || !hostname.trim()) return zoneName;
|
|
return `${hostname}.${zoneName}`;
|
|
}
|
|
function normalizeCnameHost(target, zoneName) {
|
|
const trimmed = target.trim().toLowerCase().replace(/\.$/, "");
|
|
if (!trimmed) return "";
|
|
if (trimmed.includes(".")) return trimmed;
|
|
return `${trimmed}.${zoneName.toLowerCase()}`;
|
|
}
|
|
function buildBindingIndex(bindings) {
|
|
const byFqdn = /* @__PURE__ */ new Map();
|
|
for (const b of bindings) {
|
|
const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase();
|
|
byFqdn.set(fqdn, b);
|
|
}
|
|
return { byFqdn };
|
|
}
|
|
function resolveIpsLocally(index, startFqdn, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
const key = startFqdn.toLowerCase().replace(/\.$/, "");
|
|
if (!key || depth > 8 || seen.has(key)) return [];
|
|
seen.add(key);
|
|
const binding = index.byFqdn.get(key);
|
|
if (!binding) return [];
|
|
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
|
|
if (ips.length > 0) {
|
|
return ips;
|
|
}
|
|
const cname = binding.cname_target?.trim();
|
|
if (!cname) return [];
|
|
const next = normalizeCnameHost(cname, binding.zone_name);
|
|
return resolveIpsLocally(index, next, depth + 1, seen);
|
|
}
|
|
async function resolveIpsViaDns(hostname) {
|
|
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
if (!host) return [];
|
|
try {
|
|
return await resolve4(host);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
async function resolveBindingIpsForSync(binding, serviceIps, index, db) {
|
|
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
|
|
if (directIps.length > 0) {
|
|
return [...directIps];
|
|
}
|
|
const cname = binding.cname_target?.trim();
|
|
if (cname) {
|
|
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
|
|
const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral);
|
|
if (local.length > 0) return local;
|
|
if (db) {
|
|
const fromTable = repos7.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral);
|
|
if (fromTable.length > 0) return fromTable;
|
|
}
|
|
}
|
|
const fromService = serviceIps.filter(isIpLiteral);
|
|
if (fromService.length > 0) return [...fromService];
|
|
if (cname) {
|
|
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
|
|
const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral);
|
|
if (viaDns.length > 0) return viaDns;
|
|
}
|
|
return [];
|
|
}
|
|
function cnameTargetForSync(binding) {
|
|
const raw = binding.cname_target?.trim();
|
|
if (!raw) return void 0;
|
|
const normalized = normalizeCnameHost(raw, binding.zone_name);
|
|
return normalized || void 0;
|
|
}
|
|
async function buildServiceSyncBindingsAsync(db, serviceId, deletedBindingIds = []) {
|
|
const service = repos7.getService(db, serviceId);
|
|
const serviceIps = repos7.listServiceIps(db, serviceId);
|
|
const allBindings = repos7.listAllBindings(db);
|
|
const index = buildBindingIndex(allBindings);
|
|
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
|
const groupLbCache = /* @__PURE__ */ new Map();
|
|
const groupLb = groupLbModeForService(db, serviceId, groupLbCache);
|
|
const items = [];
|
|
for (const binding of bindings) {
|
|
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
|
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
|
items.push({
|
|
bindingId: binding.id,
|
|
serviceId: service.id,
|
|
serviceName: service.name,
|
|
serviceSlug: service.slug,
|
|
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
|
zoneName: binding.zone_name,
|
|
hostname: binding.hostname,
|
|
ips,
|
|
cnameTarget: cnameTargetForSync(binding),
|
|
...lbMode ? { lbMode } : {}
|
|
});
|
|
}
|
|
for (const bindingId of deletedBindingIds) {
|
|
items.push({
|
|
bindingId,
|
|
serviceId: service.id,
|
|
serviceName: service.name,
|
|
serviceSlug: service.slug,
|
|
fqdn: "",
|
|
zoneName: "",
|
|
hostname: "",
|
|
ips: [],
|
|
deleted: true
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
async function buildAllSyncBindings(db) {
|
|
const bindings = repos7.listAllBindings(db);
|
|
const index = buildBindingIndex(bindings);
|
|
const serviceIpCache = /* @__PURE__ */ new Map();
|
|
const groupLbCache = /* @__PURE__ */ new Map();
|
|
const items = [];
|
|
for (const binding of bindings) {
|
|
let serviceIps = serviceIpCache.get(binding.service_id);
|
|
if (!serviceIps) {
|
|
serviceIps = repos7.listServiceIps(db, binding.service_id);
|
|
serviceIpCache.set(binding.service_id, serviceIps);
|
|
}
|
|
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
|
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
|
|
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
|
items.push({
|
|
bindingId: binding.id,
|
|
serviceId: binding.service_id,
|
|
serviceName: binding.service_name,
|
|
serviceSlug: binding.service_slug,
|
|
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
|
zoneName: binding.zone_name,
|
|
hostname: binding.hostname,
|
|
ips,
|
|
cnameTarget: cnameTargetForSync(binding),
|
|
...lbMode ? { lbMode } : {}
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
async function syncServiceToVpsTracker(db, serviceId, deletedBindingIds = []) {
|
|
const config2 = getAppSettingsSecrets(db);
|
|
if (!config2.vpsTrackerSyncEnabled) return;
|
|
const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, "");
|
|
const token = config2.vpsTrackerIntegrationToken;
|
|
if (!baseUrl || !token) return;
|
|
const bindings = await buildServiceSyncBindingsAsync(
|
|
db,
|
|
serviceId,
|
|
deletedBindingIds
|
|
);
|
|
if (bindings.length === 0) return;
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ bindings })
|
|
});
|
|
if (res.ok) {
|
|
touchVpsTrackerSync(db);
|
|
} else {
|
|
console.warn(
|
|
`VPS Tracker sync failed (${res.status}): ${await res.text()}`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
"VPS Tracker sync error:",
|
|
err instanceof Error ? err.message : err
|
|
);
|
|
}
|
|
}
|
|
async function pingVpsTracker(db) {
|
|
const config2 = getAppSettingsSecrets(db);
|
|
const baseUrl = config2.vpsTrackerUrl.replace(/\/$/, "");
|
|
const token = config2.vpsTrackerIntegrationToken;
|
|
if (!baseUrl) return { ok: false, error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 URL VPS Tracker" };
|
|
if (!token) return { ok: false, error: "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 integration token" };
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` }
|
|
});
|
|
if (!res.ok) {
|
|
return {
|
|
ok: false,
|
|
error: `HTTP ${res.status}: ${await res.text()}`
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : "\u041E\u0448\u0438\u0431\u043A\u0430 \u0441\u0435\u0442\u0438"
|
|
};
|
|
}
|
|
}
|
|
|
|
// src/services/health/health-worker-deploy.ts
|
|
import { getAppSettings, repos as repos8, updateAppSettings } from "@cfdm/db";
|
|
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider as targetHasProvider2 } from "@cfdm/shared";
|
|
|
|
// src/services/health/health-probe-script.ts
|
|
import { existsSync, readFileSync } from "fs";
|
|
import { dirname, join } from "path";
|
|
import { fileURLToPath } from "url";
|
|
function loadHealthProbeWorkerSource() {
|
|
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("\u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u0438\u0441\u0445\u043E\u0434\u043D\u0438\u043A Worker health-probe");
|
|
}
|
|
|
|
// src/services/health/mailbox.ts
|
|
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared";
|
|
function createCloudflareKvMailbox(cf, accountId, namespaceId) {
|
|
return {
|
|
async getTargets() {
|
|
return readJson(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(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
|
|
}
|
|
};
|
|
}
|
|
async function readJson(cf, accountId, namespaceId, key) {
|
|
const raw = await cf.kvGet(accountId, namespaceId, key);
|
|
if (!raw) return null;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function originProbeKey(target) {
|
|
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}`;
|
|
}
|
|
function cloudflareMailboxTargets(targets) {
|
|
const unique = /* @__PURE__ */ new Map();
|
|
for (const target of targets) {
|
|
if (!targetHasProvider(target, "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 ?? 3e3,
|
|
verifyTls: Boolean(target.verify_tls)
|
|
});
|
|
}
|
|
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
}
|
|
function fingerprintTargets(items) {
|
|
return items.map(
|
|
(item) => `${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`
|
|
).join(";");
|
|
}
|
|
function buildTargetsDoc(targets) {
|
|
const items = cloudflareMailboxTargets(targets);
|
|
return {
|
|
fingerprint: fingerprintTargets(items),
|
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
items
|
|
};
|
|
}
|
|
function indexResults(doc) {
|
|
const map = /* @__PURE__ */ new Map();
|
|
if (!doc?.items) return map;
|
|
for (const item of doc.items) {
|
|
map.set(item.key, item);
|
|
}
|
|
return map;
|
|
}
|
|
function isResultsStale(doc, staleAfterMs) {
|
|
if (!doc?.probedAt) return true;
|
|
const ts = Date.parse(doc.probedAt);
|
|
if (!Number.isFinite(ts)) return true;
|
|
return Date.now() - ts > staleAfterMs;
|
|
}
|
|
function toCloudflareCron(expr) {
|
|
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("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435");
|
|
}
|
|
function cronStaleAfterMs(expr) {
|
|
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) * 6e4;
|
|
}
|
|
if (minute === "*") return 10 * 6e4;
|
|
return 10 * 6e4;
|
|
}
|
|
|
|
// src/services/health/health-worker-deploy.ts
|
|
var DEFAULT_HEALTH_FALLBACKS = {
|
|
healthCheckCron: "0 */2 * * * *",
|
|
healthDegradedFailures: 1,
|
|
healthDownFailures: 2,
|
|
healthLatencyWarnMs: 1e3,
|
|
healthSuccessRecoveries: 2,
|
|
healthWorkerUrl: "",
|
|
healthWorkerTokenSet: false
|
|
};
|
|
async function resolveAccountId(cf, db, cached) {
|
|
const trimmed = cached?.trim();
|
|
if (trimmed) return trimmed;
|
|
const domains = repos8.listDomains(db);
|
|
for (const domain of domains) {
|
|
if (!domain.cf_zone_id) continue;
|
|
try {
|
|
const zone = await cf.getZone(domain.cf_zone_id);
|
|
const id2 = zone.account?.id?.trim();
|
|
if (id2) return id2;
|
|
} catch {
|
|
}
|
|
}
|
|
const accounts = await cf.listAccounts();
|
|
const id = accounts[0]?.id?.trim();
|
|
if (!id) {
|
|
throw AppError.cloudflare(
|
|
"\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C Cloudflare account_id. \u0414\u043E\u0431\u0430\u0432\u044C\u0442\u0435 \u0437\u043E\u043D\u0443 \u0438\u043B\u0438 \u0440\u0430\u0441\u0448\u0438\u0440\u044C\u0442\u0435 \u043F\u0440\u0430\u0432\u0430 \u0442\u043E\u043A\u0435\u043D\u0430 (Account Settings Read)."
|
|
);
|
|
}
|
|
return id;
|
|
}
|
|
async function ensureKvNamespace(cf, accountId, existingId) {
|
|
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 \u043D\u0435 \u0432\u0435\u0440\u043D\u0443\u043B id KV namespace");
|
|
}
|
|
return created.id;
|
|
}
|
|
async function ensureHealthWorker(db, cf, fallbacks) {
|
|
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 {
|
|
}
|
|
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: (/* @__PURE__ */ 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;
|
|
}
|
|
}
|
|
async function maybeEnsureHealthWorker(db, cf, fallbacks) {
|
|
const hasCloudflare = repos8.listHealthCheckTargets(db).some((target) => targetHasProvider2(target, "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);
|
|
}
|
|
function mailboxFromSettings(db, cf, fallbacks) {
|
|
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);
|
|
}
|
|
async function syncCloudflareTargetsToKv(db, cf, fallbacks, mailbox) {
|
|
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
|
|
if (!box) return;
|
|
const next = buildTargetsDoc(repos8.listHealthCheckTargets(db));
|
|
const current = await box.getTargets();
|
|
if (current?.fingerprint === next.fingerprint) return;
|
|
await box.putTargets(next);
|
|
}
|
|
function fireEnsureHealthWorker(db, cf, fallbacks, log) {
|
|
if (process.env.VITEST) return;
|
|
if (!cf.isConfigured) return;
|
|
const hasCloudflare = repos8.listHealthCheckTargets(db).some((target) => targetHasProvider2(target, "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");
|
|
});
|
|
}
|
|
|
|
// src/services/routing/health.ts
|
|
function isDown(state) {
|
|
return state === "down" || state === "unhealthy";
|
|
}
|
|
function isPoolMember(state) {
|
|
return !isDown(state);
|
|
}
|
|
|
|
// src/services/routing/failover.ts
|
|
function failoverDesired(rows) {
|
|
if (rows.length === 0) return [];
|
|
const live = rows.filter((r) => isPoolMember(r.health));
|
|
const pool = live.length > 0 ? live : rows;
|
|
const sorted = [...pool].sort(
|
|
(a, b) => a.priority - b.priority || a.weight - b.weight
|
|
);
|
|
const minPriority = sorted[0].priority;
|
|
const primaries = sorted.filter((r) => r.priority === minPriority);
|
|
if (live.length > 0) {
|
|
return primaries.map((r) => r.ip);
|
|
}
|
|
return [sorted[0].ip];
|
|
}
|
|
|
|
// src/services/routing/round-robin.ts
|
|
function roundRobinDesired(rows) {
|
|
const live = rows.filter((r) => isPoolMember(r.health));
|
|
const pool = live.length > 0 ? live : rows;
|
|
return pool.map((r) => r.ip);
|
|
}
|
|
|
|
// src/services/routing/weighted.ts
|
|
var WEIGHTED_SLOT_MS = 6e4;
|
|
var WEIGHTED_DNS_TTL = 60;
|
|
function weightedDesired(rows, nowMs = Date.now()) {
|
|
if (rows.length === 0) return [];
|
|
const live = rows.filter((r) => isPoolMember(r.health));
|
|
const pool = live.length > 0 ? live : rows;
|
|
if (pool.length === 1) return [pool[0].ip];
|
|
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
|
const cycle = [];
|
|
for (const row of sorted) {
|
|
const weight = Math.max(1, Math.round(row.weight));
|
|
for (let i = 0; i < weight; i++) cycle.push(row.ip);
|
|
}
|
|
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
|
|
return [cycle[slot]];
|
|
}
|
|
|
|
// src/services/routing/binding-lock.ts
|
|
var locks = /* @__PURE__ */ new Map();
|
|
async function withBindingLock(bindingId, fn) {
|
|
const previous = locks.get(bindingId) ?? Promise.resolve();
|
|
let release;
|
|
const current = new Promise((resolve5) => {
|
|
release = resolve5;
|
|
});
|
|
locks.set(
|
|
bindingId,
|
|
previous.then(() => current).catch(() => current)
|
|
);
|
|
await previous.catch(() => void 0);
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release();
|
|
if (locks.get(bindingId) === current) {
|
|
locks.delete(bindingId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/services/routing/index.ts
|
|
function selectActiveIpsByMode(config2, rows, nowMs = Date.now()) {
|
|
if (rows.length === 0) return [];
|
|
if (config2.lb_mode === "failover") {
|
|
return failoverDesired(rows);
|
|
}
|
|
if (config2.lb_mode === "weighted") {
|
|
return weightedDesired(rows, nowMs);
|
|
}
|
|
return roundRobinDesired(rows);
|
|
}
|
|
function resolveDesiredAIps(config2, rows, fallbackIps, nowMs = Date.now(), serviceIps = fallbackIps) {
|
|
const fallback = [...fallbackIps];
|
|
if (!canApplyLb(serviceIps, fallback)) return fallback;
|
|
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
|
|
if (config2.lb_mode === "weighted" || config2.health_check_enabled) {
|
|
const activeIps = selectActiveIpsByMode(config2, rows, nowMs);
|
|
if (activeIps.length > 0) return activeIps;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// src/services/service-config-service.ts
|
|
var AUTO_DNS_TTL = 1;
|
|
function ttlForBinding(mode, ips) {
|
|
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
|
}
|
|
function enabledServiceIps(db, serviceId) {
|
|
return repos9.listServiceIpRows(db, serviceId).filter((row) => row.enabled).map((row) => row.ip);
|
|
}
|
|
function failoverARecordDiff(existingA, desiredIps) {
|
|
const before = new Set(existingA);
|
|
const after = new Set(desiredIps);
|
|
return {
|
|
added: desiredIps.filter((ip) => !before.has(ip)),
|
|
removed: existingA.filter((ip) => !after.has(ip))
|
|
};
|
|
}
|
|
function recordFailoverDnsDiff(db, bindingId, hostname, zoneName, existingRecords, desiredIps) {
|
|
const existingA = existingRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content);
|
|
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
|
if (added.length === 0 && removed.length === 0) return;
|
|
const binding = repos9.getBinding(db, bindingId);
|
|
const configuredIps = repos9.listBindingIps(db, bindingId);
|
|
const { config: config2, rows } = getBindingLbState(db, bindingId);
|
|
const downIps = new Set(
|
|
rows.filter((row) => row.health === "down").map((row) => row.ip)
|
|
);
|
|
if (!shouldRecordFailoverDnsDiff({
|
|
configuredIps,
|
|
lbMode: config2.lb_mode,
|
|
added,
|
|
removed,
|
|
downIps
|
|
})) {
|
|
return;
|
|
}
|
|
repos9.insertFailoverLog(db, {
|
|
serviceId: binding.service_id,
|
|
bindingId,
|
|
fqdn: fqdnToDisplay2(hostname, zoneName),
|
|
entries: [
|
|
...added.map((ip) => ({ ip, action: "added" })),
|
|
...removed.map((ip) => ({ ip, action: "removed" }))
|
|
]
|
|
});
|
|
}
|
|
function fqdnToDisplay2(hostname, zoneName) {
|
|
return hostname === "@" ? zoneName : `${hostname}.${zoneName}`;
|
|
}
|
|
function parseFqdn(fqdn, knownZones) {
|
|
const normalized = fqdn.trim().toLowerCase();
|
|
if (!normalized) throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 FQDN");
|
|
const zones = [...knownZones].sort((a, b) => b.length - a.length);
|
|
for (const zone of zones) {
|
|
const zoneLower = zone.toLowerCase();
|
|
if (normalized === zoneLower) {
|
|
return { zoneName: zone, hostname: "@" };
|
|
}
|
|
const suffix = `.${zoneLower}`;
|
|
if (normalized.endsWith(suffix)) {
|
|
const prefix = normalized.slice(0, -suffix.length);
|
|
if (prefix) return { zoneName: zone, hostname: prefix };
|
|
}
|
|
}
|
|
throw AppError.validation(
|
|
`\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0438\u0442\u044C \u0437\u043E\u043D\u0443 \u0434\u043B\u044F \xAB${fqdn}\xBB \u2014 \u0437\u043E\u043D\u0430 \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u043E\u0432\u0430\u0442\u044C \u0432 Cloudflare`
|
|
);
|
|
}
|
|
function normalizeIps(ips) {
|
|
const out = [];
|
|
for (const ip of ips) {
|
|
const trimmed = ip.trim();
|
|
if (!trimmed || !isValidIpv4(trimmed)) continue;
|
|
if (!out.includes(trimmed)) out.push(trimmed);
|
|
}
|
|
out.sort();
|
|
return out;
|
|
}
|
|
function aggregateSyncStatus(statuses) {
|
|
if (statuses.length === 0) return null;
|
|
if (statuses.some((s) => s === SYNC_ERROR2)) return SYNC_ERROR2;
|
|
if (statuses.some((s) => s === SYNC_PENDING_PUSH3)) return SYNC_PENDING_PUSH3;
|
|
if (statuses.every((s) => s === SYNC_SYNCED3)) return SYNC_SYNCED3;
|
|
return statuses[0] ?? null;
|
|
}
|
|
function getBindingLbState(db, bindingId) {
|
|
const binding = repos9.getBinding(db, bindingId);
|
|
const ipMetas = repos9.listBindingIpsWithMeta(db, bindingId);
|
|
const rows = ipMetas.map((entry) => {
|
|
const status = repos9.getIpHealthStatusRow(db, "binding", bindingId, entry.ip);
|
|
return {
|
|
ip: entry.ip,
|
|
weight: entry.weight,
|
|
priority: entry.priority,
|
|
health: status ? status.status : "unknown"
|
|
};
|
|
});
|
|
return {
|
|
config: {
|
|
lb_mode: binding.lb_mode,
|
|
health_check_enabled: binding.health_check_enabled
|
|
},
|
|
rows
|
|
};
|
|
}
|
|
function getGroupLbState(db, groupId) {
|
|
const group = repos9.getServiceGroup(db, groupId);
|
|
const services = repos9.listServicesByGroup(db, groupId);
|
|
const seen = /* @__PURE__ */ new Map();
|
|
for (const service of services) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos9.listBindingsByService(db, service.id);
|
|
for (const binding of bindings) {
|
|
const ipMetas = repos9.listBindingIpsWithMeta(db, binding.id);
|
|
for (const entry of ipMetas) {
|
|
const status = repos9.getIpHealthStatusRow(db, "group", groupId, entry.ip);
|
|
const existing = seen.get(entry.ip);
|
|
const weight = entry.weight * service.lb_weight;
|
|
const priority = Math.min(entry.priority, service.lb_priority);
|
|
if (!existing) {
|
|
seen.set(entry.ip, {
|
|
ip: entry.ip,
|
|
weight,
|
|
priority,
|
|
health: status ? status.status : "unknown"
|
|
});
|
|
} else {
|
|
existing.weight += weight;
|
|
existing.priority = Math.min(existing.priority, priority);
|
|
if (isPoolMember(existing.health) && status && !isPoolMember(status.status)) {
|
|
existing.health = status.status;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
config: {
|
|
lb_mode: group.lb_mode,
|
|
health_check_enabled: group.health_check_enabled
|
|
},
|
|
rows: [...seen.values()]
|
|
};
|
|
}
|
|
function desiredAIps(db, scope, refId, fallbackIps) {
|
|
const state = scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId);
|
|
const serviceIps = scope === "binding" ? enabledServiceIps(db, repos9.getBinding(db, refId).service_id) : fallbackIps;
|
|
return resolveDesiredAIps(
|
|
state.config,
|
|
state.rows,
|
|
fallbackIps,
|
|
Date.now(),
|
|
serviceIps
|
|
);
|
|
}
|
|
async function collectKnownZones(db, cf) {
|
|
const dbDomains = repos9.listDomains(db);
|
|
const zones = dbDomains.map((d) => d.zone_name);
|
|
const cfZones = await cf.listZones();
|
|
for (const zone of cfZones) {
|
|
if (!zones.some((n) => n.toLowerCase() === zone.name.toLowerCase())) {
|
|
zones.push(zone.name);
|
|
}
|
|
}
|
|
return zones;
|
|
}
|
|
function bindingLbStateFromBatch(binding, ctx) {
|
|
const ipMetas = ctx.ipMetaByBinding.get(binding.id) ?? [];
|
|
const healthByIp = ctx.healthByBinding.get(binding.id);
|
|
const rows = ipMetas.map((entry) => ({
|
|
ip: entry.ip,
|
|
weight: entry.weight,
|
|
priority: entry.priority,
|
|
health: healthByIp?.get(entry.ip)?.status ?? "unknown"
|
|
}));
|
|
return {
|
|
config: {
|
|
lb_mode: binding.lb_mode,
|
|
health_check_enabled: binding.health_check_enabled
|
|
},
|
|
rows
|
|
};
|
|
}
|
|
async function buildView(db, serviceId) {
|
|
const [view] = await buildViews(db, [serviceId]);
|
|
return view;
|
|
}
|
|
async function buildViews(db, serviceIds) {
|
|
const services = serviceIds.map((id) => repos9.getService(db, id));
|
|
const ipRowsByService = repos9.listServiceIpRowsByServiceIds(db, serviceIds);
|
|
const bindingsByService = repos9.listBindingsByServiceIds(db, serviceIds);
|
|
const allBindings = [...bindingsByService.values()].flat();
|
|
const bindingIds = allBindings.map((b) => b.id);
|
|
const recordsByBinding = repos9.listRecordsByBindingIds(db, bindingIds);
|
|
const ipMetaByBinding = repos9.listBindingIpsWithMetaByBindingIds(db, bindingIds);
|
|
const healthByBinding = repos9.listBindingIpHealthByBindingIds(db, bindingIds);
|
|
const lbCtx = { ipMetaByBinding, healthByBinding };
|
|
const now = Date.now();
|
|
return services.map((service) => {
|
|
const ipRows = ipRowsByService.get(service.id) ?? [];
|
|
const ips = ipRows.map((row) => row.ip);
|
|
const ip_enabled = Object.fromEntries(
|
|
ipRows.map((row) => [row.ip, row.enabled])
|
|
);
|
|
const bindings = bindingsByService.get(service.id) ?? [];
|
|
const domainViews = bindings.map((binding) => {
|
|
const records = recordsByBinding.get(binding.id) ?? [];
|
|
const statuses = records.map((r) => r.sync_status);
|
|
const targetIpsWithMeta = ipMetaByBinding.get(binding.id) ?? [];
|
|
const targetIps = targetIpsWithMeta.map((entry) => entry.ip);
|
|
const linkedCname = records.find(
|
|
(record) => record.record_type.toUpperCase() === "CNAME"
|
|
);
|
|
const targetCname = binding.cname_target?.trim() || linkedCname?.content?.trim() || null;
|
|
const target_ip_weights = {};
|
|
const target_ip_priorities = {};
|
|
for (const entry of targetIpsWithMeta) {
|
|
target_ip_weights[entry.ip] = entry.weight;
|
|
target_ip_priorities[entry.ip] = entry.priority;
|
|
}
|
|
for (const ip of targetIps) {
|
|
if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1;
|
|
if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1;
|
|
}
|
|
const { config: config2, rows } = bindingLbStateFromBatch(binding, lbCtx);
|
|
const bindingActiveIps = targetCname ? [] : resolveDesiredAIps(config2, rows, targetIps, now, ips);
|
|
return {
|
|
binding_id: binding.id,
|
|
domain_id: binding.domain_id,
|
|
zone_name: binding.zone_name,
|
|
hostname: binding.hostname,
|
|
fqdn: fqdnToDisplay2(binding.hostname, binding.zone_name),
|
|
record_type: targetCname ? "CNAME" : "A",
|
|
target_ips: targetCname ? [] : targetIps,
|
|
target_ip_weights,
|
|
target_ip_priorities,
|
|
target_cname: targetCname,
|
|
lb_mode: binding.lb_mode,
|
|
health_check_enabled: binding.health_check_enabled,
|
|
health_check_type: binding.health_check_type,
|
|
health_check_port: binding.health_check_port,
|
|
health_check_path: binding.health_check_path,
|
|
health_check_expected_status: binding.health_check_expected_status,
|
|
health_check_interval_sec: binding.health_check_interval_sec,
|
|
health_check_timeout_ms: binding.health_check_timeout_ms,
|
|
health_check_verify_tls: binding.health_check_verify_tls,
|
|
health_check_provider: binding.health_check_provider ?? "local",
|
|
health_check_providers: binding.health_check_providers ?? [
|
|
binding.health_check_provider ?? "local"
|
|
],
|
|
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
|
cert_monitoring: binding.cert_monitoring ?? "auto",
|
|
sync_status: aggregateSyncStatus(statuses),
|
|
active_ips: bindingActiveIps
|
|
};
|
|
});
|
|
const activeIps = /* @__PURE__ */ new Set();
|
|
for (const domain of domainViews) {
|
|
for (const ip of domain.active_ips) {
|
|
activeIps.add(ip);
|
|
}
|
|
}
|
|
return {
|
|
id: service.id,
|
|
name: service.name,
|
|
slug: service.slug,
|
|
service_group_id: service.service_group_id ?? null,
|
|
subdomain: service.subdomain ?? "",
|
|
enabled: Boolean(service.enabled),
|
|
computed_fqdn: null,
|
|
lb_weight: service.lb_weight,
|
|
lb_priority: service.lb_priority,
|
|
created_at: service.created_at,
|
|
updated_at: service.updated_at,
|
|
ips,
|
|
ip_enabled,
|
|
domains: domainViews,
|
|
health_status: "unknown",
|
|
health_latency_ms: null,
|
|
ip_health: [],
|
|
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
|
active_ips: [...activeIps]
|
|
};
|
|
});
|
|
}
|
|
var HEALTH_RANK = {
|
|
down: 3,
|
|
degraded: 2,
|
|
unknown: 1,
|
|
up: 0
|
|
};
|
|
function cnameLookupKeys(value, zoneName) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return [];
|
|
const noDot = trimmed.replace(/\.+$/, "");
|
|
const lower = noDot.toLowerCase();
|
|
const keys = /* @__PURE__ */ new Set([trimmed, noDot, lower]);
|
|
if (zoneName && !lower.includes(".")) {
|
|
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
|
|
}
|
|
return [...keys];
|
|
}
|
|
function fallbackCnameHealth(rows, view) {
|
|
const cnameKeys = /* @__PURE__ */ new Set();
|
|
for (const domain of view.domains ?? []) {
|
|
const cname = domain.target_cname?.trim();
|
|
if (!cname) continue;
|
|
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
|
|
cnameKeys.add(key);
|
|
}
|
|
}
|
|
const hostnameRows = rows.filter((row) => !isIpLiteral2(row.ip));
|
|
if (hostnameRows.length === 0) return void 0;
|
|
const matched = cnameKeys.size === 0 ? hostnameRows : hostnameRows.filter(
|
|
(row) => cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key))
|
|
);
|
|
const candidates = matched.length > 0 ? matched : hostnameRows;
|
|
return candidates.reduce(
|
|
(worst, row) => (HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0) ? row : worst
|
|
);
|
|
}
|
|
function overlayLiveHealth(stored, live) {
|
|
if (live && live !== "unknown") return live;
|
|
return stored ?? live ?? "unknown";
|
|
}
|
|
function bestAliveDisplayStatus(statuses) {
|
|
if (statuses.some((status) => status === "up")) return "up";
|
|
if (statuses.some((status) => status === "degraded")) return "degraded";
|
|
if (statuses.some((status) => status === "down")) return "down";
|
|
return "unknown";
|
|
}
|
|
function attachServiceHealth(db, views) {
|
|
const ids = views.map((v) => v.id);
|
|
const healthByService = repos9.aggregateIpHealthByServiceIds(db, ids);
|
|
const ipHealthByService = repos9.listIpHealthByServiceIds(db, ids);
|
|
const liveByService = repos9.listLatestLiveHealthByServiceIds(db, ids);
|
|
return views.map((view) => {
|
|
const health = healthByService.get(view.id);
|
|
const rows = ipHealthByService.get(view.id) ?? [];
|
|
const liveRows = liveByService.get(view.id) ?? [];
|
|
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
|
const liveByIp = new Map(liveRows.map((row) => [row.ip, row]));
|
|
const cnameFallback = fallbackCnameHealth(rows, view);
|
|
const aRecordIps = new Set(
|
|
(view.domains ?? []).flatMap(
|
|
(domain) => domain.target_cname?.trim() ? [] : domain.target_ips ?? []
|
|
)
|
|
);
|
|
const ip_health = (view.ips ?? []).map((ip) => {
|
|
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? void 0 : cnameFallback);
|
|
const live = liveByIp.get(ip);
|
|
const status = overlayLiveHealth(row?.status, live?.status);
|
|
const extras = live && live.status !== "unknown" ? live : row;
|
|
return {
|
|
ip,
|
|
status,
|
|
latency_ms: extras?.latency_ms ?? null,
|
|
last_checked_at: extras?.last_checked_at ?? null,
|
|
last_error: live && live.status !== "unknown" ? live.last_error : row?.last_error ?? null,
|
|
provider: extras?.provider ?? "local",
|
|
colo: extras?.colo ?? null
|
|
};
|
|
});
|
|
const displayStatus = bestAliveDisplayStatus(ip_health.map((row) => row.status));
|
|
const latencyRow = ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ?? ip_health.find((row) => row.latency_ms != null);
|
|
return {
|
|
...view,
|
|
health_status: overlayLiveHealth(health?.health_status, displayStatus),
|
|
health_latency_ms: displayStatus !== "unknown" ? latencyRow?.latency_ms ?? null : health?.health_latency_ms ?? null,
|
|
ip_health
|
|
};
|
|
});
|
|
}
|
|
async function listViews(db) {
|
|
const ids = repos9.listServices(db).map((s) => s.id);
|
|
const views = await buildViews(db, ids);
|
|
return attachServiceHealth(db, views);
|
|
}
|
|
async function getView(db, id) {
|
|
repos9.getService(db, id);
|
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
|
return view;
|
|
}
|
|
async function listGroupViews(db) {
|
|
const groups = repos9.listServiceGroups(db);
|
|
const servicesByGroup = repos9.listServicesByGroupIds(db, groups.map((g) => g.id));
|
|
const ungroupedServices = repos9.listUngroupedServices(db);
|
|
const allIds = [
|
|
...[...servicesByGroup.values()].flat().map((s) => s.id),
|
|
...ungroupedServices.map((s) => s.id)
|
|
];
|
|
const allServiceViews = await buildViews(db, allIds);
|
|
const viewsById = new Map(allServiceViews.map((v) => [v.id, v]));
|
|
const groupViewsRaw = groups.map((group) => ({
|
|
...group,
|
|
services: (servicesByGroup.get(group.id) ?? []).map((s) => viewsById.get(s.id)).filter((v) => v !== void 0)
|
|
}));
|
|
const withHealth = attachServiceHealth(db, allServiceViews);
|
|
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
|
const groupHealthById = repos9.aggregateGroupScopeHealthByIds(
|
|
db,
|
|
groups.map((g) => g.id)
|
|
);
|
|
const groupViews = groupViewsRaw.map((group) => {
|
|
const services = group.services.map(
|
|
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown", health_latency_ms: null, ip_health: [], ip_enabled: {} }
|
|
);
|
|
const groupScopeHealth = groupHealthById.get(group.id);
|
|
const enabledServiceHealth = services.filter((s) => s.enabled).map((s) => ({
|
|
health_status: s.health_status,
|
|
health_latency_ms: s.health_latency_ms
|
|
}));
|
|
const merged = repos9.mergeHealthAggregates([
|
|
groupScopeHealth,
|
|
...enabledServiceHealth
|
|
]);
|
|
return {
|
|
...group,
|
|
services,
|
|
health_status: merged.health_status,
|
|
health_latency_ms: merged.health_latency_ms
|
|
};
|
|
});
|
|
const ungrouped = ungroupedServices.map((s) => {
|
|
const view = viewsById.get(s.id);
|
|
return healthById.get(s.id) ?? (view ? {
|
|
...view,
|
|
health_status: "unknown",
|
|
health_latency_ms: null,
|
|
ip_health: [],
|
|
ip_enabled: {}
|
|
} : view);
|
|
});
|
|
return { groups: groupViews, ungrouped };
|
|
}
|
|
function shouldPushDns(db, service) {
|
|
if (!service.enabled) return false;
|
|
if (!service.service_group_id) return true;
|
|
const group = repos9.getServiceGroup(db, service.service_group_id);
|
|
return group.enabled;
|
|
}
|
|
async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget, listingCache) {
|
|
const domain = repos9.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
let effectiveCname = cnameTarget?.trim() || null;
|
|
if (!effectiveCname) {
|
|
const existingCname = await findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
"CNAME",
|
|
void 0,
|
|
listingCache
|
|
);
|
|
if (existingCname) {
|
|
effectiveCname = existingCname.content;
|
|
repos9.setBindingCnameTarget(db, bindingId, effectiveCname);
|
|
repos9.replaceBindingIps(db, bindingId, []);
|
|
}
|
|
}
|
|
if (effectiveCname) {
|
|
await syncBindingCnameDns(
|
|
db,
|
|
cf,
|
|
bindingId,
|
|
domainId,
|
|
hostname,
|
|
effectiveCname,
|
|
listingCache
|
|
);
|
|
return;
|
|
}
|
|
const binding = repos9.getBinding(db, bindingId);
|
|
const configuredIps = repos9.listBindingIps(db, bindingId);
|
|
await syncBindingADns(
|
|
db,
|
|
cf,
|
|
bindingId,
|
|
domainId,
|
|
hostname,
|
|
desiredIps,
|
|
ttlForBinding(binding.lb_mode, configuredIps),
|
|
listingCache
|
|
);
|
|
}
|
|
async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget, listingCache) {
|
|
const domain = repos9.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
|
const existingRecords = repos9.listRecordsForBinding(db, bindingId);
|
|
for (const record of existingRecords) {
|
|
if (record.record_type.toUpperCase() === "A") {
|
|
repos9.unlinkBindingRecord(db, bindingId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
}
|
|
}
|
|
const refreshed = repos9.listRecordsForBinding(db, bindingId);
|
|
const existingCname = refreshed.find(
|
|
(record) => record.record_type.toUpperCase() === "CNAME"
|
|
);
|
|
let recordId;
|
|
if (existingCname) {
|
|
if (!cnameContentMatches(existingCname.content, normalized, zoneName) || !dnsRecordNamesMatch2(existingCname.name, hostname, zoneName)) {
|
|
await update(db, cf, domainId, existingCname.id, {
|
|
record_type: "CNAME",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: normalized,
|
|
proxied: false
|
|
});
|
|
}
|
|
recordId = existingCname.id;
|
|
} else {
|
|
const adopted = await findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
"CNAME",
|
|
normalized,
|
|
listingCache
|
|
);
|
|
if (adopted) {
|
|
repos9.linkBindingRecord(db, bindingId, adopted.id);
|
|
if (!cnameContentMatches(adopted.content, normalized, zoneName)) {
|
|
await update(db, cf, domainId, adopted.id, {
|
|
record_type: "CNAME",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: normalized,
|
|
proxied: false
|
|
});
|
|
}
|
|
recordId = adopted.id;
|
|
} else {
|
|
const record = await create(db, cf, domainId, {
|
|
record_type: "CNAME",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: normalized,
|
|
ttl: 1,
|
|
proxied: false
|
|
});
|
|
repos9.linkBindingRecord(db, bindingId, record.id);
|
|
recordId = record.id;
|
|
}
|
|
}
|
|
repos9.setBindingDnsRecordId(db, bindingId, recordId);
|
|
repos9.setBindingCnameTarget(db, bindingId, normalized);
|
|
}
|
|
async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps, ttl, listingCache) {
|
|
const domain = repos9.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
const existingRecords = repos9.listRecordsForBinding(db, bindingId);
|
|
for (const record of existingRecords) {
|
|
if (record.record_type.toUpperCase() === "CNAME") {
|
|
repos9.unlinkBindingRecord(db, bindingId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
} else if (!desiredIps.includes(record.content)) {
|
|
repos9.unlinkBindingRecord(db, bindingId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
}
|
|
}
|
|
repos9.setBindingCnameTarget(db, bindingId, null);
|
|
if (desiredIps.length === 0) {
|
|
repos9.setBindingDnsRecordId(db, bindingId, null);
|
|
recordFailoverDnsDiff(
|
|
db,
|
|
bindingId,
|
|
hostname,
|
|
zoneName,
|
|
existingRecords,
|
|
desiredIps
|
|
);
|
|
return;
|
|
}
|
|
const refreshed = repos9.listRecordsForBinding(db, bindingId);
|
|
let primaryId = null;
|
|
for (const ip of desiredIps) {
|
|
const existing = refreshed.find((r) => r.content === ip);
|
|
const recordName = dnsNameForBinding(hostname, zoneName);
|
|
let recordId;
|
|
if (existing) {
|
|
if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
|
await update(db, cf, domainId, existing.id, {
|
|
record_type: "A",
|
|
name: recordName,
|
|
content: ip,
|
|
ttl,
|
|
proxied: false
|
|
});
|
|
}
|
|
recordId = existing.id;
|
|
} else {
|
|
const adopted = await findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
"A",
|
|
ip,
|
|
listingCache
|
|
);
|
|
if (adopted) {
|
|
repos9.linkBindingRecord(db, bindingId, adopted.id);
|
|
recordId = adopted.id;
|
|
if (!dnsRecordNamesMatch2(adopted.name, hostname, zoneName) || adopted.ttl !== ttl) {
|
|
await update(db, cf, domainId, adopted.id, {
|
|
record_type: "A",
|
|
name: recordName,
|
|
content: ip,
|
|
ttl,
|
|
proxied: false
|
|
});
|
|
}
|
|
} else {
|
|
const record = await create(db, cf, domainId, {
|
|
record_type: "A",
|
|
name: recordName,
|
|
content: ip,
|
|
ttl,
|
|
proxied: false
|
|
});
|
|
repos9.linkBindingRecord(db, bindingId, record.id);
|
|
recordId = record.id;
|
|
}
|
|
}
|
|
if (primaryId == null) primaryId = recordId;
|
|
}
|
|
repos9.setBindingDnsRecordId(db, bindingId, primaryId);
|
|
recordFailoverDnsDiff(
|
|
db,
|
|
bindingId,
|
|
hostname,
|
|
zoneName,
|
|
existingRecords,
|
|
desiredIps
|
|
);
|
|
}
|
|
async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) {
|
|
await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);
|
|
}
|
|
async function cleanupServiceDnsOnly(db, cf, serviceId) {
|
|
const bindings = repos9.listBindingsByService(db, serviceId);
|
|
for (const binding of bindings) {
|
|
await cleanupBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname
|
|
);
|
|
}
|
|
}
|
|
function validateTargetIpsInPool(targetIps, ips) {
|
|
for (const ip of targetIps) {
|
|
if (!isValidIpv4(ip)) {
|
|
throw AppError.validation(`\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4: ${ip}`);
|
|
}
|
|
if (!ips.includes(ip)) {
|
|
throw AppError.validation(`IP ${ip} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 \u043F\u0443\u043B \u0430\u0434\u0440\u0435\u0441\u043E\u0432 \u0441\u0435\u0440\u0432\u0438\u0441\u0430`);
|
|
}
|
|
}
|
|
}
|
|
function bindingTargetIps(input) {
|
|
if (input.target_cname?.trim()) return [];
|
|
const raw = input.target_ips ? input.target_ips : input.target_ip?.trim() ? [input.target_ip.trim()] : [];
|
|
const normalized = normalizeIps(raw);
|
|
if (raw.length > 0 && normalized.length === 0) {
|
|
throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 IP \u0432 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0435 \u0434\u043E\u043C\u0435\u043D\u0430");
|
|
}
|
|
return normalized;
|
|
}
|
|
function bindingTargetCname(input) {
|
|
const target = input.target_cname?.trim();
|
|
return target ? target : null;
|
|
}
|
|
function normalizeCnameTarget(target, zoneName) {
|
|
const trimmed = target.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 CNAME-\u0446\u0435\u043B\u044C");
|
|
}
|
|
if (trimmed.includes(".")) return trimmed;
|
|
return `${trimmed}.${zoneName.toLowerCase()}`;
|
|
}
|
|
function dnsNameForBinding(hostname, zoneName) {
|
|
return normalizeDnsRecordName2(hostname, zoneName);
|
|
}
|
|
function cnameContentMatches(left, right, zoneName) {
|
|
return normalizeCnameTarget(left, zoneName) === normalizeCnameTarget(right, zoneName);
|
|
}
|
|
function findLocalDnsRecord(db, domainId, zoneName, hostname, recordType, content) {
|
|
const records = repos9.listDnsByDomain(db, domainId);
|
|
return records.find(
|
|
(record) => record.record_type.toUpperCase() === recordType && (content == null || record.content === content) && dnsRecordNamesMatch2(record.name, hostname, zoneName)
|
|
) ?? null;
|
|
}
|
|
async function findOrImportDnsRecord(db, cf, domainId, zoneName, hostname, recordType, content, listingCache) {
|
|
const local = findLocalDnsRecord(
|
|
db,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
recordType,
|
|
content
|
|
);
|
|
if (local) return local;
|
|
const domain = repos9.getDomain(db, domainId);
|
|
const remote = await cf.listDnsRecords(domain.cf_zone_id, listingCache);
|
|
for (const cfRec of remote) {
|
|
if (cfRec.type.toUpperCase() !== recordType) continue;
|
|
if (content != null) {
|
|
if (recordType === "CNAME") {
|
|
if (!cnameContentMatches(cfRec.content, content, zoneName)) continue;
|
|
} else if (cfRec.content !== content) {
|
|
continue;
|
|
}
|
|
}
|
|
if (!dnsRecordNamesMatch2(cfRec.name, hostname, zoneName)) continue;
|
|
if (!cfRec.id) continue;
|
|
const existing = repos9.findDnsByCfId(db, domainId, cfRec.id);
|
|
if (existing) return existing;
|
|
return repos9.insertDnsRecord(
|
|
db,
|
|
domainId,
|
|
cfRec.type,
|
|
cfRec.name,
|
|
cfRec.content,
|
|
cfRec.ttl,
|
|
cfRec.proxied ?? false,
|
|
cfRec.priority ?? null,
|
|
SYNC_SYNCED3,
|
|
"cloudflare",
|
|
cfRec.id
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
async function findOrImportDnsARecord(db, cf, domainId, zoneName, hostname, content, listingCache) {
|
|
return findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
"A",
|
|
content,
|
|
listingCache
|
|
);
|
|
}
|
|
async function syncServiceBindingsToDns(db, cf, serviceId) {
|
|
const bindings = repos9.listBindingsByService(db, serviceId);
|
|
if (bindings.length === 0) {
|
|
throw AppError.validation("\u043D\u0430\u0441\u0442\u0440\u043E\u0439\u0442\u0435 FQDN \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
|
|
}
|
|
const needsIpPool = bindings.some((binding) => {
|
|
if (binding.cname_target?.trim()) return false;
|
|
const targetIps = repos9.listBindingIps(db, binding.id);
|
|
return targetIps.length > 0;
|
|
});
|
|
const ips = repos9.listServiceIps(db, serviceId);
|
|
if (needsIpPool && ips.length === 0) {
|
|
throw AppError.validation("\u0434\u043E\u0431\u0430\u0432\u044C\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441\u0430 \u0432 \u043F\u0443\u043B \u0441\u0435\u0440\u0432\u0438\u0441\u0430");
|
|
}
|
|
const listingCache = /* @__PURE__ */ new Map();
|
|
for (const binding of bindings) {
|
|
const cnameTarget = binding.cname_target?.trim() || null;
|
|
if (cnameTarget) {
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
[],
|
|
cnameTarget,
|
|
listingCache
|
|
);
|
|
continue;
|
|
}
|
|
let targetIps = repos9.listBindingIps(db, binding.id);
|
|
if (targetIps.length === 0) {
|
|
throw AppError.validation(
|
|
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 CNAME \u0434\u043B\u044F ${fqdnToDisplay2(binding.hostname, binding.zone_name)}`
|
|
);
|
|
}
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
desiredIps,
|
|
null,
|
|
listingCache
|
|
);
|
|
}
|
|
}
|
|
async function collectGroupDnsIps(db, groupId) {
|
|
const services = repos9.listServicesByGroup(db, groupId);
|
|
const ips = [];
|
|
for (const service of services) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos9.listBindingsByService(db, service.id);
|
|
for (const binding of bindings) {
|
|
for (const ip of repos9.listBindingIps(db, binding.id)) {
|
|
if (!ips.includes(ip)) ips.push(ip);
|
|
}
|
|
}
|
|
}
|
|
ips.sort();
|
|
return ips;
|
|
}
|
|
async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps, ttl = AUTO_DNS_TTL, listingCache) {
|
|
const domain = repos9.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
const existingRecords = repos9.listGroupDnsRecords(db, groupId);
|
|
for (const record of existingRecords) {
|
|
if (!desiredIps.includes(record.content)) {
|
|
repos9.unlinkGroupDnsRecord(db, groupId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
}
|
|
}
|
|
if (desiredIps.length === 0) return;
|
|
const refreshed = repos9.listGroupDnsRecords(db, groupId);
|
|
const recordName = dnsNameForBinding(hostname, zoneName);
|
|
for (const ip of desiredIps) {
|
|
const existing = refreshed.find((r) => r.content === ip);
|
|
if (existing) {
|
|
if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
|
await update(db, cf, domainId, existing.id, {
|
|
record_type: "A",
|
|
name: recordName,
|
|
content: ip,
|
|
ttl,
|
|
proxied: false
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const adopted = await findOrImportDnsARecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
ip,
|
|
listingCache
|
|
);
|
|
if (adopted) {
|
|
repos9.linkGroupDnsRecord(db, groupId, adopted.id);
|
|
if (!dnsRecordNamesMatch2(adopted.name, hostname, zoneName) || adopted.ttl !== ttl) {
|
|
await update(db, cf, domainId, adopted.id, {
|
|
record_type: "A",
|
|
name: recordName,
|
|
content: ip,
|
|
ttl,
|
|
proxied: false
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const record = await create(db, cf, domainId, {
|
|
record_type: "A",
|
|
name: recordName,
|
|
content: ip,
|
|
ttl,
|
|
proxied: false
|
|
});
|
|
repos9.linkGroupDnsRecord(db, groupId, record.id);
|
|
}
|
|
}
|
|
async function resolveDomainId(db, cf, zoneName) {
|
|
const trimmed = zoneName.trim();
|
|
if (!trimmed) throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0438\u043C\u044F \u0437\u043E\u043D\u044B");
|
|
const existing = repos9.findDomainByZoneName(db, trimmed);
|
|
if (existing) return existing.id;
|
|
const created = await createDomain(db, cf, null, trimmed);
|
|
return created.id;
|
|
}
|
|
async function cleanupGroupDomainDns(db, cf, groupId) {
|
|
const group = repos9.getServiceGroup(db, groupId);
|
|
const domainValue = group.domain?.trim();
|
|
if (!domainValue) return;
|
|
const knownZones = await collectKnownZones(db, cf);
|
|
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
|
const domainId = await resolveDomainId(db, cf, zoneName);
|
|
await syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, []);
|
|
}
|
|
async function syncGroupDomainDns(db, cf, groupId, listingCache) {
|
|
const group = repos9.getServiceGroup(db, groupId);
|
|
if (!group.enabled) {
|
|
await cleanupGroupDomainDns(db, cf, groupId);
|
|
return;
|
|
}
|
|
const domainValue = group.domain?.trim();
|
|
if (!domainValue) return;
|
|
const knownZones = await collectKnownZones(db, cf);
|
|
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
|
const domainId = await resolveDomainId(db, cf, zoneName);
|
|
const fallbackIps = await collectGroupDnsIps(db, groupId);
|
|
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
|
|
await syncGroupDomainDnsRecords(
|
|
db,
|
|
cf,
|
|
groupId,
|
|
domainId,
|
|
hostname,
|
|
desiredIps,
|
|
ttlForBinding(group.lb_mode, fallbackIps),
|
|
listingCache
|
|
);
|
|
}
|
|
async function syncGroupDomainForService(db, cf, serviceId) {
|
|
const service = repos9.getService(db, serviceId);
|
|
if (!service.service_group_id) return;
|
|
await syncGroupDomainDns(db, cf, service.service_group_id);
|
|
}
|
|
async function syncEnabledServicesInGroup(db, cf, groupId) {
|
|
const group = repos9.getServiceGroup(db, groupId);
|
|
if (!group.enabled || !group.domain?.trim()) return;
|
|
const services = repos9.listServicesByGroup(db, groupId);
|
|
for (const service of services) {
|
|
if (service.enabled) {
|
|
await syncServiceBindingsToDns(db, cf, service.id);
|
|
}
|
|
}
|
|
await syncGroupDomainDns(db, cf, groupId);
|
|
}
|
|
async function normalizeGroupDomain(db, cf, domain) {
|
|
const raw = domain?.trim();
|
|
if (!raw) return null;
|
|
const knownZones = await collectKnownZones(db, cf);
|
|
const { zoneName, hostname } = parseFqdn(raw, knownZones);
|
|
return fqdnToDisplay2(hostname, zoneName);
|
|
}
|
|
async function cleanupStaleGroupFqdnBindings(db, cf, groupId, fqdn) {
|
|
const knownZones = await collectKnownZones(db, cf);
|
|
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
|
if (hostname === "@") return;
|
|
const domain = repos9.findDomainByZoneName(db, zoneName);
|
|
if (!domain) return;
|
|
const services = repos9.listServicesByGroup(db, groupId);
|
|
for (const service of services) {
|
|
const binding = repos9.findBinding(
|
|
db,
|
|
service.id,
|
|
domain.id,
|
|
hostname
|
|
);
|
|
if (!binding) continue;
|
|
await cleanupBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname
|
|
);
|
|
repos9.deleteBinding(db, binding.id);
|
|
}
|
|
}
|
|
async function updateConfig(db, cf, id, req) {
|
|
if (req.name && req.slug) {
|
|
repos9.updateService(db, id, req.name, req.slug);
|
|
} else if (req.name) {
|
|
const existing = repos9.getService(db, id);
|
|
repos9.updateService(db, id, req.name, existing.slug);
|
|
} else if (req.slug) {
|
|
const existing = repos9.getService(db, id);
|
|
repos9.updateService(db, id, existing.name, req.slug);
|
|
}
|
|
if (req.service_group_id !== void 0) {
|
|
repos9.setServiceGroup(db, id, req.service_group_id);
|
|
}
|
|
if (req.lb_weight !== void 0 || req.lb_priority !== void 0) {
|
|
const existing = repos9.getService(db, id);
|
|
repos9.setServiceLb(
|
|
db,
|
|
id,
|
|
req.lb_weight ?? existing.lb_weight,
|
|
req.lb_priority ?? existing.lb_priority
|
|
);
|
|
}
|
|
const ipsUpdated = req.ips !== void 0;
|
|
const knownZones = await collectKnownZones(db, cf);
|
|
const ips = req.ips ? normalizeIps(req.ips) : repos9.listServiceIps(db, id);
|
|
if (ipsUpdated) repos9.replaceServiceIps(db, id, ips);
|
|
const keptBindingIds = [];
|
|
let service = repos9.getService(db, id);
|
|
const pushDns = shouldPushDns(db, service);
|
|
let removedBindingIds = [];
|
|
if (req.domains) {
|
|
for (const input of req.domains) {
|
|
const fqdn = input.fqdn.trim();
|
|
if (!fqdn) continue;
|
|
const targetCname = bindingTargetCname(input);
|
|
const targetIps = bindingTargetIps(input);
|
|
if (!targetCname) {
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
} else if (targetIps.length > 0) {
|
|
throw AppError.validation(
|
|
`\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043B\u0438\u0431\u043E IP, \u043B\u0438\u0431\u043E CNAME \u0434\u043B\u044F ${fqdn}`
|
|
);
|
|
}
|
|
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
|
const domainId = await resolveDomainId(db, cf, zoneName);
|
|
const binding = repos9.findBinding(db, id, domainId, hostname) ?? repos9.insertBinding(db, domainId, id, hostname, null);
|
|
keptBindingIds.push(binding.id);
|
|
const targetIpWeights = input.target_ip_weights ?? {};
|
|
const targetIpPriorities = input.target_ip_priorities ?? {};
|
|
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
|
|
ip,
|
|
weight: targetIpWeights[ip] ?? 1,
|
|
priority: targetIpPriorities[ip] ?? 1
|
|
}));
|
|
repos9.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
|
repos9.setBindingCnameTarget(db, binding.id, targetCname);
|
|
if (input.lb_mode !== void 0 || input.health_check_enabled !== void 0 || input.health_check_type !== void 0 || input.health_check_port !== void 0 || input.health_check_path !== void 0 || input.health_check_expected_status !== void 0 || input.health_check_interval_sec !== void 0 || input.health_check_timeout_ms !== void 0 || input.health_check_verify_tls !== void 0 || input.health_check_provider !== void 0 || input.health_check_providers !== void 0 || input.health_check_aggregate !== void 0) {
|
|
repos9.updateBindingLbConfig(db, binding.id, {
|
|
lb_mode: input.lb_mode,
|
|
health_check_enabled: input.health_check_enabled,
|
|
health_check_type: input.health_check_type,
|
|
health_check_port: input.health_check_port,
|
|
health_check_path: input.health_check_path,
|
|
health_check_expected_status: input.health_check_expected_status,
|
|
health_check_interval_sec: input.health_check_interval_sec,
|
|
health_check_timeout_ms: input.health_check_timeout_ms,
|
|
health_check_verify_tls: input.health_check_verify_tls,
|
|
health_check_provider: input.health_check_provider,
|
|
health_check_providers: input.health_check_providers,
|
|
health_check_aggregate: input.health_check_aggregate
|
|
});
|
|
}
|
|
if (pushDns) {
|
|
const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
domainId,
|
|
hostname,
|
|
effectiveIps,
|
|
targetCname
|
|
);
|
|
}
|
|
}
|
|
const removed = repos9.bindingsToRemove(db, id, keptBindingIds);
|
|
removedBindingIds = removed.map((binding) => binding.id);
|
|
for (const binding of removed) {
|
|
await cleanupBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname
|
|
);
|
|
}
|
|
repos9.deleteBindingsExcept(db, id, keptBindingIds);
|
|
} else if (ipsUpdated) {
|
|
const bindings = repos9.listBindingsByService(db, id);
|
|
for (const binding of bindings) {
|
|
const targetIps = repos9.listBindingIps(db, binding.id);
|
|
for (const ip of targetIps) {
|
|
if (!ips.includes(ip)) {
|
|
throw AppError.validation(
|
|
`IP ${ip} \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A ${fqdnToDisplay2(binding.hostname, binding.zone_name)}, \u043D\u043E \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 \u043D\u043E\u0432\u043E\u043C \u043F\u0443\u043B\u0435 \u0430\u0434\u0440\u0435\u0441\u043E\u0432`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
service = repos9.getService(db, id);
|
|
const remainingBindings = repos9.listBindingsByService(db, id);
|
|
if (req.domains && req.domains.length > 0 && !service.enabled) {
|
|
repos9.setServiceEnabled(db, id, true);
|
|
service = repos9.getService(db, id);
|
|
}
|
|
if (shouldPushDns(db, service)) {
|
|
if (remainingBindings.length > 0) {
|
|
await syncServiceBindingsToDns(db, cf, id);
|
|
}
|
|
await syncGroupDomainForService(db, cf, id);
|
|
}
|
|
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
|
return view;
|
|
}
|
|
async function createGroup2(db, cf, body) {
|
|
const groupType = body.type?.trim() || "custom";
|
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
|
const group = repos9.createServiceGroup(
|
|
db,
|
|
body.name,
|
|
groupType,
|
|
body.icon ?? null,
|
|
domain,
|
|
{
|
|
lb_mode: body.lb_mode,
|
|
health_check_enabled: body.health_check_enabled,
|
|
health_check_type: body.health_check_type,
|
|
health_check_port: body.health_check_port,
|
|
health_check_path: body.health_check_path,
|
|
health_check_expected_status: body.health_check_expected_status,
|
|
health_check_interval_sec: body.health_check_interval_sec,
|
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
|
health_check_verify_tls: body.health_check_verify_tls,
|
|
health_check_provider: body.health_check_provider,
|
|
health_check_providers: body.health_check_providers,
|
|
health_check_aggregate: body.health_check_aggregate
|
|
}
|
|
);
|
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
|
return group;
|
|
}
|
|
async function updateGroup2(db, cf, id, body) {
|
|
const groupType = body.type?.trim() || "custom";
|
|
const previous = repos9.getServiceGroup(db, id);
|
|
const name = body.name ?? previous.name;
|
|
const oldDomain = previous.domain?.trim();
|
|
if (oldDomain) {
|
|
await cleanupStaleGroupFqdnBindings(db, cf, id, oldDomain);
|
|
await cleanupGroupDomainDns(db, cf, id);
|
|
}
|
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
|
let group = repos9.updateServiceGroup(
|
|
db,
|
|
id,
|
|
name,
|
|
groupType,
|
|
body.icon ?? null,
|
|
domain,
|
|
{
|
|
lb_mode: body.lb_mode,
|
|
health_check_enabled: body.health_check_enabled,
|
|
health_check_type: body.health_check_type,
|
|
health_check_port: body.health_check_port,
|
|
health_check_path: body.health_check_path,
|
|
health_check_expected_status: body.health_check_expected_status,
|
|
health_check_interval_sec: body.health_check_interval_sec,
|
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
|
health_check_verify_tls: body.health_check_verify_tls,
|
|
health_check_provider: body.health_check_provider,
|
|
health_check_providers: body.health_check_providers,
|
|
health_check_aggregate: body.health_check_aggregate
|
|
}
|
|
);
|
|
if (!domain && group.enabled) {
|
|
repos9.setServiceGroupEnabled(db, id, false);
|
|
group = repos9.getServiceGroup(db, id);
|
|
}
|
|
await syncEnabledServicesInGroup(db, cf, id);
|
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
|
return group;
|
|
}
|
|
function deleteGroup2(db, id) {
|
|
repos9.deleteServiceGroup(db, id);
|
|
}
|
|
async function toggleService(db, cf, serviceId, enabled) {
|
|
const service = repos9.getService(db, serviceId);
|
|
if (enabled && service.service_group_id) {
|
|
const group = repos9.getServiceGroup(db, service.service_group_id);
|
|
if (group.domain?.trim() && !group.enabled) {
|
|
throw AppError.validation("\u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0433\u0440\u0443\u043F\u043F\u0443 \u0441\u0435\u0440\u0432\u0438\u0441\u043E\u0432");
|
|
}
|
|
}
|
|
repos9.setServiceEnabled(db, serviceId, enabled);
|
|
if (!enabled) {
|
|
await cleanupServiceDnsOnly(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
const [disabledView] = attachServiceHealth(db, [
|
|
await buildView(db, serviceId)
|
|
]);
|
|
return disabledView;
|
|
}
|
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
const [enabledView] = attachServiceHealth(db, [
|
|
await buildView(db, serviceId)
|
|
]);
|
|
return enabledView;
|
|
}
|
|
async function toggleServiceIp(db, cf, serviceId, ip, enabled) {
|
|
repos9.getService(db, serviceId);
|
|
const pool = repos9.listServiceIps(db, serviceId);
|
|
if (!pool.includes(ip)) {
|
|
throw AppError.validation(`IP ${ip} \u043D\u0435 \u0432\u0445\u043E\u0434\u0438\u0442 \u0432 \u043F\u0443\u043B \u0430\u0434\u0440\u0435\u0441\u043E\u0432 \u0441\u0435\u0440\u0432\u0438\u0441\u0430`);
|
|
}
|
|
repos9.setServiceIpEnabled(db, serviceId, ip, enabled);
|
|
const node = repos9.listNodes(db, serviceId).find((entry) => entry.address === ip);
|
|
if (node) {
|
|
repos9.updateNode(db, node.id, { enabled });
|
|
}
|
|
const bindings = repos9.listBindingsByService(db, serviceId);
|
|
for (const binding of bindings) {
|
|
if (binding.cname_target?.trim()) continue;
|
|
const current = repos9.listBindingIpsWithMeta(db, binding.id);
|
|
const hasIp = current.some((entry) => entry.ip === ip);
|
|
if (enabled && !hasIp) {
|
|
repos9.replaceBindingIpsWithMeta(db, binding.id, [
|
|
...current,
|
|
{ ip, weight: 1, priority: 1 }
|
|
]);
|
|
continue;
|
|
}
|
|
if (!enabled && hasIp) {
|
|
repos9.replaceBindingIpsWithMeta(
|
|
db,
|
|
binding.id,
|
|
current.filter((entry) => entry.ip !== ip)
|
|
);
|
|
}
|
|
}
|
|
const service = repos9.getService(db, serviceId);
|
|
if (shouldPushDns(db, service)) {
|
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
}
|
|
void syncServiceToVpsTracker(db, serviceId);
|
|
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
|
|
return view;
|
|
}
|
|
async function toggleGroup(db, cf, groupId, enabled) {
|
|
const group = repos9.getServiceGroup(db, groupId);
|
|
if (enabled && !group.domain?.trim()) {
|
|
throw AppError.validation("\u043D\u0435\u043B\u044C\u0437\u044F \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0433\u0440\u0443\u043F\u043F\u0443 \u0431\u0435\u0437 \u0434\u043E\u043C\u0435\u043D\u0430");
|
|
}
|
|
repos9.setServiceGroupEnabled(db, groupId, enabled);
|
|
if (!enabled) {
|
|
const services = repos9.listServicesByGroup(db, groupId);
|
|
for (const service of services) {
|
|
if (service.enabled) {
|
|
repos9.setServiceEnabled(db, service.id, false);
|
|
await cleanupServiceDnsOnly(db, cf, service.id);
|
|
}
|
|
}
|
|
await cleanupGroupDomainDns(db, cf, groupId);
|
|
} else {
|
|
await syncEnabledServicesInGroup(db, cf, groupId);
|
|
}
|
|
return listGroupViews(db);
|
|
}
|
|
function reorderServices(db, groupId, serviceIds) {
|
|
if (groupId !== null) {
|
|
repos9.getServiceGroup(db, groupId);
|
|
}
|
|
repos9.reorderServices(db, groupId, serviceIds);
|
|
}
|
|
async function applyBindingDesiredDns(db, cf, bindingId, desiredIps) {
|
|
const binding = repos9.getBinding(db, bindingId);
|
|
const cnameTarget = binding.cname_target?.trim() || null;
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
desiredIps,
|
|
cnameTarget
|
|
);
|
|
}
|
|
async function reconcileDnsForTarget(db, cf, scope, refId) {
|
|
if (scope === "binding") {
|
|
await withBindingLock(refId, async () => {
|
|
const binding = repos9.getBinding(db, refId);
|
|
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
|
|
const service = repos9.getService(db, binding.service_id);
|
|
if (!shouldPushDns(db, service)) return;
|
|
const cnameTarget = binding.cname_target?.trim() || null;
|
|
if (cnameTarget) return;
|
|
const ips = repos9.listServiceIps(db, service.id);
|
|
const poolIps = enabledServiceIps(db, service.id);
|
|
const targetIps = repos9.listBindingIps(db, binding.id);
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
const desiredIps = canApplyLb(poolIps, targetIps) ? desiredAIps(db, "binding", refId, targetIps) : targetIps;
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
desiredIps,
|
|
null
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
const group = repos9.getServiceGroup(db, refId);
|
|
if (!group.enabled || !group.domain?.trim()) {
|
|
return;
|
|
}
|
|
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
|
|
return;
|
|
}
|
|
await syncGroupDomainDns(db, cf, refId);
|
|
}
|
|
async function reconcileWeightedDns(db, cf) {
|
|
if (!repos9.hasWeightedBindings(db)) return 0;
|
|
let n = 0;
|
|
const listingCache = /* @__PURE__ */ new Map();
|
|
for (const binding of repos9.listAllBindings(db)) {
|
|
if (binding.lb_mode !== "weighted") continue;
|
|
if (binding.cname_target?.trim()) continue;
|
|
if (!isSharedPool(binding.target_ips ?? [])) continue;
|
|
try {
|
|
await withBindingLock(binding.id, async () => {
|
|
const latest = repos9.getBinding(db, binding.id);
|
|
if (latest.lb_mode !== "weighted") return;
|
|
if (latest.cname_target?.trim()) return;
|
|
const service = repos9.getService(db, latest.service_id);
|
|
if (!shouldPushDns(db, service)) return;
|
|
const targetIps = repos9.listBindingIps(db, latest.id);
|
|
const poolIps = enabledServiceIps(db, service.id);
|
|
if (!canApplyLb(poolIps, targetIps)) return;
|
|
const ips = repos9.listServiceIps(db, service.id);
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
latest.id,
|
|
latest.domain_id,
|
|
latest.hostname,
|
|
desiredIps,
|
|
null,
|
|
listingCache
|
|
);
|
|
n += 1;
|
|
});
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
for (const group of repos9.listServiceGroups(db)) {
|
|
if (group.lb_mode !== "weighted") continue;
|
|
if (!group.enabled || !group.domain?.trim()) continue;
|
|
try {
|
|
await syncGroupDomainDns(db, cf, group.id, listingCache);
|
|
n += 1;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// src/services/node-service.ts
|
|
import { repos as repos10 } from "@cfdm/db";
|
|
function assertAddress(address) {
|
|
if (!isValidIpv4(address)) {
|
|
throw AppError.invalidIp(`\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441: ${address}`);
|
|
}
|
|
}
|
|
function listNodes(db, serviceId) {
|
|
repos10.getService(db, serviceId);
|
|
return repos10.listNodes(db, serviceId);
|
|
}
|
|
function createNode(db, serviceId, input) {
|
|
repos10.getService(db, serviceId);
|
|
assertAddress(input.address);
|
|
try {
|
|
return repos10.createNode(db, serviceId, {
|
|
address: input.address,
|
|
protocol: input.protocol,
|
|
port: input.port,
|
|
enabled: input.enabled,
|
|
priority: input.priority,
|
|
weight: input.weight,
|
|
health_check_id: input.health_check_id
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === "ConflictError") {
|
|
throw AppError.conflict(err.message);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
function updateNode(db, serviceId, nodeId, patch) {
|
|
const node = repos10.getNode(db, nodeId);
|
|
if (node.service_id !== serviceId) {
|
|
throw AppError.notFound(`node ${nodeId}`);
|
|
}
|
|
if (patch.address) assertAddress(patch.address);
|
|
return repos10.updateNode(db, nodeId, patch);
|
|
}
|
|
function deleteNode(db, serviceId, nodeId) {
|
|
const node = repos10.getNode(db, nodeId);
|
|
if (node.service_id !== serviceId) {
|
|
throw AppError.notFound(`node ${nodeId}`);
|
|
}
|
|
repos10.deleteNode(db, nodeId);
|
|
}
|
|
async function getOverview(db, serviceId) {
|
|
const service = await getView(db, serviceId);
|
|
const nodes = repos10.listNodes(db, serviceId);
|
|
const bindings = repos10.listBindingsByService(db, serviceId);
|
|
const first = bindings[0];
|
|
const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin";
|
|
const healthCheck2 = nodes.map((n) => n.health_check_id).find((id) => id != null) != null ? repos10.getHealthCheck(
|
|
db,
|
|
nodes.find((n) => n.health_check_id != null).health_check_id
|
|
) : null;
|
|
const active = /* @__PURE__ */ new Set();
|
|
const serviceIps = service.ips.filter(
|
|
(ip) => service.ip_enabled[ip] !== false
|
|
);
|
|
for (const binding of bindings) {
|
|
const metas = repos10.listBindingIpsWithMeta(db, binding.id);
|
|
const targetIps = metas.map((entry) => entry.ip);
|
|
const rows = metas.map((entry) => {
|
|
const status = repos10.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
|
return {
|
|
ip: entry.ip,
|
|
weight: entry.weight,
|
|
priority: entry.priority,
|
|
health: status ? status.status : "unknown"
|
|
};
|
|
});
|
|
for (const ip of resolveDesiredAIps(
|
|
{
|
|
lb_mode: binding.lb_mode,
|
|
health_check_enabled: binding.health_check_enabled
|
|
},
|
|
rows,
|
|
targetIps,
|
|
Date.now(),
|
|
serviceIps
|
|
)) {
|
|
active.add(ip);
|
|
}
|
|
}
|
|
return {
|
|
service,
|
|
nodes,
|
|
health_check: healthCheck2,
|
|
routing_strategy: routing,
|
|
active_addresses: [...active]
|
|
};
|
|
}
|
|
function opsSummary(db) {
|
|
const allNodes = repos10.listAllNodes(db);
|
|
const services = repos10.listServices(db);
|
|
const domains = repos10.listDomains(db);
|
|
const healthy = allNodes.filter(
|
|
(n) => n.health_status === "healthy" || n.health_status === "up"
|
|
).length;
|
|
const unhealthy = allNodes.filter(
|
|
(n) => n.health_status === "unhealthy" || n.health_status === "down"
|
|
).length;
|
|
const failoverActive = repos10.listAllBindings(db).filter((binding) => {
|
|
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
|
return false;
|
|
}
|
|
return isSharedPool(binding.target_ips) && binding.target_ips.some((ip) => {
|
|
const row = repos10.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
|
return row?.status === "down";
|
|
});
|
|
}).length;
|
|
return {
|
|
domains: domains.length,
|
|
services: services.length,
|
|
nodes: allNodes.length,
|
|
healthy,
|
|
unhealthy,
|
|
active_failovers: failoverActive
|
|
};
|
|
}
|
|
|
|
// src/services/change-domain-service.ts
|
|
import { repos as repos11 } from "@cfdm/db";
|
|
async function changeServiceDomain(db, cf, serviceId, input) {
|
|
repos11.getService(db, serviceId);
|
|
if (input.from_domain_id === input.to_domain_id) {
|
|
throw AppError.validation("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0434\u0440\u0443\u0433\u043E\u0439 \u0446\u0435\u043B\u0435\u0432\u043E\u0439 \u0434\u043E\u043C\u0435\u043D");
|
|
}
|
|
const fromDomain = repos11.getDomain(db, input.from_domain_id);
|
|
const toDomain = repos11.getDomain(db, input.to_domain_id);
|
|
const bindings = repos11.listBindingsByService(db, serviceId).filter((b) => b.domain_id === input.from_domain_id);
|
|
const selected = input.hostnames?.length ? bindings.filter((b) => input.hostnames.includes(b.hostname)) : bindings;
|
|
if (selected.length === 0) {
|
|
throw AppError.validation("\u043D\u0435\u0442 \u043F\u0440\u0438\u0432\u044F\u0437\u043E\u043A \u0434\u043B\u044F \u043F\u0435\u0440\u0435\u043D\u043E\u0441\u0430");
|
|
}
|
|
const items = selected.map((binding) => ({
|
|
binding_id: binding.id,
|
|
hostname: binding.hostname,
|
|
from_fqdn: fqdnToDisplay2(binding.hostname, fromDomain.zone_name),
|
|
to_fqdn: fqdnToDisplay2(binding.hostname, toDomain.zone_name)
|
|
}));
|
|
const preview = {
|
|
from_domain_id: fromDomain.id,
|
|
to_domain_id: toDomain.id,
|
|
from_zone: fromDomain.zone_name,
|
|
to_zone: toDomain.zone_name,
|
|
items,
|
|
dry_run: Boolean(input.dry_run),
|
|
applied: false,
|
|
message: `\u041F\u0435\u0440\u0435\u043D\u043E\u0441 ${items.length} \u043F\u0440\u0438\u0432\u044F\u0437\u043E\u043A ${fromDomain.zone_name} \u2192 ${toDomain.zone_name}`
|
|
};
|
|
if (input.dry_run) return preview;
|
|
const createdRecordIds = [];
|
|
try {
|
|
for (const binding of selected) {
|
|
const existing = repos11.findBinding(
|
|
db,
|
|
serviceId,
|
|
toDomain.id,
|
|
binding.hostname
|
|
);
|
|
if (existing) {
|
|
throw AppError.conflict(
|
|
`\u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0430 ${fqdnToDisplay2(binding.hostname, toDomain.zone_name)} \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442`
|
|
);
|
|
}
|
|
await withBindingLock(binding.id, async () => {
|
|
repos11.bumpBindingVersion(db, binding.id);
|
|
const ips = repos11.listBindingIps(db, binding.id);
|
|
repos11.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname);
|
|
await applyBindingDesiredDns(db, cf, binding.id, ips);
|
|
const newRecords = repos11.listRecordsForBinding(db, binding.id);
|
|
createdRecordIds.push(...newRecords.map((r) => r.id));
|
|
const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id);
|
|
for (const record of oldRecords) {
|
|
repos11.unlinkBindingRecord(db, binding.id, record.id);
|
|
try {
|
|
await deleteRecord(db, cf, fromDomain.id, record.id);
|
|
} catch {
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
throw err instanceof AppError ? err : AppError.syncFailed(
|
|
err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0442\u0438 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438"
|
|
);
|
|
}
|
|
void createdRecordIds;
|
|
return {
|
|
...preview,
|
|
dry_run: false,
|
|
applied: true,
|
|
message: `\u041F\u0440\u0438\u0432\u044F\u0437\u043A\u0438 \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0435\u043D\u044B \u0432 ${toDomain.zone_name}. \u0421\u0442\u0430\u0440\u044B\u0435 \u0437\u0430\u043F\u0438\u0441\u0438 \u0437\u043E\u043D\u044B \u0443\u0434\u0430\u043B\u0435\u043D\u044B.`
|
|
};
|
|
}
|
|
|
|
// src/services/certificate-service.ts
|
|
import { connect } from "net";
|
|
import { connect as tlsConnect } from "tls";
|
|
import pLimit from "p-limit";
|
|
import { repos as repos12 } from "@cfdm/db";
|
|
import {
|
|
CERT_ERROR,
|
|
CERT_MONITOR_AUTO,
|
|
CERT_MONITOR_REQUIRED,
|
|
CERT_MONITOR_SKIPPED,
|
|
CERT_UNKNOWN,
|
|
certStatusFromExpiry as certStatusFromExpiry2,
|
|
fqdnToDisplay as fqdnToDisplay3,
|
|
shouldMonitorService
|
|
} from "@cfdm/shared";
|
|
function listCertificates(db, status) {
|
|
return repos12.listCertificates(db, status);
|
|
}
|
|
function getCertificate(db, id) {
|
|
return repos12.getCertificate(db, id);
|
|
}
|
|
function listServiceCertificates(db, serviceId) {
|
|
repos12.getService(db, serviceId);
|
|
const certsByHost = new Map(
|
|
repos12.listCertificates(db).map((cert) => [cert.hostname, cert])
|
|
);
|
|
return repos12.listBindingsByService(db, serviceId).map((binding) => {
|
|
const hostname = fqdnToDisplay3(binding.hostname, binding.zone_name);
|
|
const cert = certsByHost.get(hostname);
|
|
return {
|
|
binding_id: binding.id,
|
|
domain_id: binding.domain_id,
|
|
service_id: binding.service_id,
|
|
hostname,
|
|
cert_monitoring: binding.cert_monitoring ?? "auto",
|
|
id: cert?.id ?? null,
|
|
status: cert?.status ?? "unknown",
|
|
expires_at: cert?.expires_at ?? null,
|
|
last_checked_at: cert?.last_checked_at ?? null,
|
|
last_error: cert?.last_error ?? null
|
|
};
|
|
});
|
|
}
|
|
async function checkHostname(hostname) {
|
|
return new Promise((resolve5) => {
|
|
const socket = connect({ host: hostname, port: 443, timeout: 1e4 });
|
|
socket.on(
|
|
"error",
|
|
(e) => resolve5({ expiresAt: null, error: e.message })
|
|
);
|
|
socket.on("timeout", () => {
|
|
socket.destroy();
|
|
resolve5({ expiresAt: null, error: "connection timeout" });
|
|
});
|
|
socket.on("connect", () => {
|
|
const tlsSocket = tlsConnect(
|
|
{ socket, servername: hostname, rejectUnauthorized: true },
|
|
() => {
|
|
const cert = tlsSocket.getPeerCertificate();
|
|
tlsSocket.end();
|
|
if (!cert?.valid_to) {
|
|
resolve5({ expiresAt: null, error: "no peer certificates" });
|
|
return;
|
|
}
|
|
resolve5({ expiresAt: new Date(cert.valid_to), error: null });
|
|
}
|
|
);
|
|
tlsSocket.on(
|
|
"error",
|
|
(e) => resolve5({ expiresAt: null, error: e.message })
|
|
);
|
|
});
|
|
});
|
|
}
|
|
async function checkAndStore(db, domainId, subdomainId, hostname, serviceId = null) {
|
|
const { expiresAt, error } = await checkHostname(hostname);
|
|
if (error) {
|
|
return repos12.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
null,
|
|
CERT_ERROR,
|
|
error,
|
|
serviceId
|
|
);
|
|
}
|
|
if (expiresAt) {
|
|
const days = Math.floor(
|
|
(expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24)
|
|
);
|
|
return repos12.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
expiresAt.toISOString(),
|
|
certStatusFromExpiry2(days),
|
|
null,
|
|
serviceId
|
|
);
|
|
}
|
|
return repos12.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
null,
|
|
CERT_UNKNOWN,
|
|
"unknown expiry",
|
|
serviceId
|
|
);
|
|
}
|
|
function bindingSubdomain(db, domainId, hostname) {
|
|
if (hostname === "@") return null;
|
|
return repos12.findSubdomainByDomainAndName(db, domainId, hostname);
|
|
}
|
|
function hasSslHealthGate(own, group) {
|
|
if (own.health_check_enabled) {
|
|
return own.health_check_verify_tls;
|
|
}
|
|
if (group?.enabled && group.health_check_enabled) {
|
|
return group.health_check_verify_tls;
|
|
}
|
|
return false;
|
|
}
|
|
function resolveCertificateTargets(db) {
|
|
const targets = [];
|
|
const seen = /* @__PURE__ */ new Set();
|
|
for (const binding of repos12.listAllBindings(db)) {
|
|
const service = repos12.getService(db, binding.service_id);
|
|
const group = service.service_group_id ? repos12.getServiceGroup(db, service.service_group_id) : null;
|
|
if (!shouldMonitorService(service, group)) continue;
|
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
|
if (subdomain && !subdomain.enabled) continue;
|
|
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
|
if (mode === CERT_MONITOR_SKIPPED) continue;
|
|
if (mode === CERT_MONITOR_AUTO) {
|
|
if (!hasSslHealthGate(
|
|
{
|
|
health_check_enabled: binding.health_check_enabled,
|
|
health_check_verify_tls: binding.health_check_verify_tls
|
|
},
|
|
group
|
|
)) {
|
|
continue;
|
|
}
|
|
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
|
continue;
|
|
}
|
|
const fqdn = fqdnToDisplay3(binding.hostname, binding.zone_name);
|
|
if (seen.has(fqdn)) continue;
|
|
seen.add(fqdn);
|
|
targets.push({
|
|
domainId: binding.domain_id,
|
|
subdomainId: subdomain?.id ?? null,
|
|
serviceId: binding.service_id,
|
|
hostname: fqdn
|
|
});
|
|
}
|
|
return targets;
|
|
}
|
|
async function runAllChecks(db) {
|
|
const targets = resolveCertificateTargets(db);
|
|
const limit = pLimit(5);
|
|
await Promise.all(
|
|
targets.map(
|
|
(target) => limit(
|
|
() => checkAndStore(
|
|
db,
|
|
target.domainId,
|
|
target.subdomainId,
|
|
target.hostname,
|
|
target.serviceId
|
|
)
|
|
)
|
|
)
|
|
);
|
|
repos12.deleteCertificatesNotIn(
|
|
db,
|
|
targets.map((t) => t.hostname)
|
|
);
|
|
return targets.length;
|
|
}
|
|
async function runServiceChecks(db, serviceId) {
|
|
repos12.getService(db, serviceId);
|
|
const targets = resolveCertificateTargets(db).filter(
|
|
(target) => target.serviceId === serviceId
|
|
);
|
|
for (const target of targets) {
|
|
await checkAndStore(
|
|
db,
|
|
target.domainId,
|
|
target.subdomainId,
|
|
target.hostname,
|
|
target.serviceId
|
|
);
|
|
}
|
|
return targets.length;
|
|
}
|
|
function statusSummary(db) {
|
|
return repos12.countCertificatesByStatus(db);
|
|
}
|
|
|
|
// src/routes/services.ts
|
|
async function serviceRoutes(app2) {
|
|
const createSchema = z3.object({
|
|
name: z3.string(),
|
|
slug: z3.string(),
|
|
service_group_id: z3.number().nullable().optional()
|
|
});
|
|
app2.get("/services", async (request2) => {
|
|
return listViews(request2.server.db);
|
|
});
|
|
app2.patch("/services/reorder", async (request2) => {
|
|
const body = reorderServicesSchema.parse(request2.body);
|
|
reorderServices(
|
|
request2.server.db,
|
|
body.group_id,
|
|
body.service_ids
|
|
);
|
|
return { ok: true };
|
|
});
|
|
app2.post("/services", async (request2) => {
|
|
const body = createSchema.parse(request2.body);
|
|
const service = repos13.createService(
|
|
request2.server.db,
|
|
body.name,
|
|
body.slug
|
|
);
|
|
if (body.service_group_id != null) {
|
|
repos13.setServiceGroup(
|
|
request2.server.db,
|
|
service.id,
|
|
body.service_group_id
|
|
);
|
|
}
|
|
const view = await getView(request2.server.db, service.id);
|
|
recordAudit(request2.server, request2, {
|
|
action: "service.create",
|
|
targetType: "app_resource",
|
|
targetId: String(service.id),
|
|
summary: `\u0421\u043E\u0437\u0434\u0430\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${service.name}\xBB`,
|
|
details: { name: service.name, slug: service.slug }
|
|
});
|
|
return view;
|
|
});
|
|
app2.get("/services/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getView(request2.server.db, Number(id));
|
|
});
|
|
app2.get("/services/:id/health-log", async (request2) => {
|
|
const { id } = request2.params;
|
|
repos13.getService(request2.server.db, Number(id));
|
|
return {
|
|
items: repos13.listHealthProbeLogForService(
|
|
request2.server.db,
|
|
Number(id),
|
|
200
|
|
)
|
|
};
|
|
});
|
|
app2.get("/services/:id/failover-log", async (request2) => {
|
|
const { id } = request2.params;
|
|
repos13.getService(request2.server.db, Number(id));
|
|
return {
|
|
items: repos13.listFailoverLogForService(
|
|
request2.server.db,
|
|
Number(id),
|
|
200
|
|
)
|
|
};
|
|
});
|
|
app2.get("/services/:id/certificates", async (request2) => {
|
|
const { id } = request2.params;
|
|
return listServiceCertificates(
|
|
request2.server.db,
|
|
Number(id)
|
|
);
|
|
});
|
|
app2.post("/services/:id/certificates/check", async (request2) => {
|
|
const { id } = request2.params;
|
|
const checked = await runServiceChecks(
|
|
request2.server.db,
|
|
Number(id)
|
|
);
|
|
return { checked };
|
|
});
|
|
app2.get("/services/:id/overview", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getOverview(request2.server.db, Number(id));
|
|
});
|
|
app2.get("/services/:id/nodes", async (request2) => {
|
|
const { id } = request2.params;
|
|
return listNodes(request2.server.db, Number(id));
|
|
});
|
|
app2.post("/services/:id/nodes", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = createServiceNodeSchema.parse(request2.body);
|
|
const node = createNode(request2.server.db, Number(id), body);
|
|
recordAudit(request2.server, request2, {
|
|
action: "node.create",
|
|
targetType: "app_resource",
|
|
targetId: String(node.id),
|
|
summary: `\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`,
|
|
details: { service_id: Number(id), address: node.address }
|
|
});
|
|
return node;
|
|
});
|
|
app2.patch("/services/:id/nodes/:nodeId", async (request2) => {
|
|
const { id, nodeId } = request2.params;
|
|
const body = updateServiceNodeSchema.parse(request2.body);
|
|
const node = updateNode(
|
|
request2.server.db,
|
|
Number(id),
|
|
Number(nodeId),
|
|
body
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "node.update",
|
|
targetType: "app_resource",
|
|
targetId: String(node.id),
|
|
summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`,
|
|
details: body
|
|
});
|
|
return node;
|
|
});
|
|
app2.delete("/services/:id/nodes/:nodeId", async (request2) => {
|
|
const { id, nodeId } = request2.params;
|
|
const node = repos13.getNode(request2.server.db, Number(nodeId));
|
|
deleteNode(request2.server.db, Number(id), Number(nodeId));
|
|
recordAudit(request2.server, request2, {
|
|
action: "node.delete",
|
|
severity: "warning",
|
|
targetType: "app_resource",
|
|
targetId: nodeId,
|
|
summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 \u043D\u043E\u0434\u0430 ${node.address}`
|
|
});
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/services/:id/change-domain", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = changeDomainSchema.parse(request2.body);
|
|
const result = await changeServiceDomain(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
if (result.applied) {
|
|
recordAudit(request2.server, request2, {
|
|
action: "service.change_domain",
|
|
targetType: "app_resource",
|
|
targetId: id,
|
|
summary: result.message,
|
|
details: result
|
|
});
|
|
}
|
|
return result;
|
|
});
|
|
app2.get("/ops-summary", async (request2) => {
|
|
return opsSummary(request2.server.db);
|
|
});
|
|
app2.patch("/services/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = updateServiceConfigSchema.parse(request2.body);
|
|
const view = await updateConfig(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "service.update",
|
|
targetType: "app_resource",
|
|
targetId: String(id),
|
|
summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${view.name}\xBB`,
|
|
details: body
|
|
});
|
|
return view;
|
|
});
|
|
app2.delete("/services/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const view = await getView(request2.server.db, Number(id));
|
|
repos13.deleteService(request2.server.db, Number(id));
|
|
recordAudit(request2.server, request2, {
|
|
action: "service.delete",
|
|
severity: "warning",
|
|
targetType: "app_resource",
|
|
targetId: String(id),
|
|
summary: `\u0423\u0434\u0430\u043B\u0451\u043D \u0441\u0435\u0440\u0432\u0438\u0441 \xAB${view.name}\xBB`,
|
|
details: { name: view.name, slug: view.slug }
|
|
});
|
|
return { deleted: true };
|
|
});
|
|
app2.patch("/services/:id/toggle", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = z3.object({ enabled: z3.boolean() }).parse(request2.body);
|
|
return toggleService(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body.enabled
|
|
);
|
|
});
|
|
app2.patch("/services/:id/ips/toggle", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = toggleServiceIpSchema.parse(request2.body);
|
|
const view = await toggleServiceIp(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body.ip,
|
|
body.enabled
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "service.ip.toggle",
|
|
targetType: "app_resource",
|
|
targetId: String(id),
|
|
summary: body.enabled ? `\u0412\u043A\u043B\u044E\u0447\u0451\u043D IP ${body.ip} \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \xAB${view.name}\xBB` : `\u0412\u044B\u043A\u043B\u044E\u0447\u0435\u043D IP ${body.ip} \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \xAB${view.name}\xBB`,
|
|
details: { ip: body.ip, enabled: body.enabled }
|
|
});
|
|
return view;
|
|
});
|
|
}
|
|
|
|
// src/routes/service-groups.ts
|
|
import {
|
|
createServiceGroupSchema,
|
|
toggleEnabledSchema,
|
|
updateServiceGroupSchema
|
|
} from "@cfdm/shared";
|
|
async function serviceGroupRoutes(app2) {
|
|
app2.get("/service-groups", async (request2) => {
|
|
return listGroupViews(request2.server.db);
|
|
});
|
|
app2.post("/service-groups", async (request2) => {
|
|
const body = createServiceGroupSchema.parse(request2.body);
|
|
return createGroup2(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
body
|
|
);
|
|
});
|
|
app2.patch("/service-groups/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = updateServiceGroupSchema.parse(request2.body);
|
|
return updateGroup2(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
});
|
|
app2.delete("/service-groups/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
deleteGroup2(request2.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
app2.patch("/service-groups/:id/toggle", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = toggleEnabledSchema.parse(request2.body);
|
|
return toggleGroup(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body.enabled
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/service-bindings.ts
|
|
import { z as z4 } from "zod";
|
|
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
|
|
|
|
// src/services/change-ip-service.ts
|
|
import { repos as repos14 } from "@cfdm/db";
|
|
async function patchRecordContent(db, cf, domainId, recordId, content) {
|
|
const domain = repos14.getDomain(db, domainId);
|
|
const record = repos14.getDnsRecord(db, domainId, recordId);
|
|
if (!record.cf_record_id) {
|
|
throw AppError.dnsUpdateFailed("\u0443 DNS-\u0437\u0430\u043F\u0438\u0441\u0438 \u043D\u0435\u0442 \u0438\u0434\u0435\u043D\u0442\u0438\u0444\u0438\u043A\u0430\u0442\u043E\u0440\u0430 Cloudflare");
|
|
}
|
|
try {
|
|
const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, {
|
|
content
|
|
});
|
|
repos14.updateDnsFields(
|
|
db,
|
|
record.id,
|
|
patched.type ?? record.record_type,
|
|
patched.name ?? record.name,
|
|
patched.content ?? content,
|
|
patched.ttl ?? record.ttl,
|
|
patched.proxied ?? record.proxied,
|
|
patched.priority ?? record.priority,
|
|
"synced",
|
|
patched.id ?? record.cf_record_id,
|
|
null
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof AppError) throw err;
|
|
throw AppError.dnsUpdateFailed(
|
|
err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0437\u0430\u043F\u0438\u0441\u044C \u0432 Cloudflare"
|
|
);
|
|
}
|
|
}
|
|
async function changeBindingIp(db, cf, bindingId, input) {
|
|
const binding = repos14.getBinding(db, bindingId);
|
|
const domain = repos14.getDomain(db, binding.domain_id);
|
|
const current = repos14.listBindingIpsWithMeta(db, bindingId);
|
|
if (current.length === 0) {
|
|
throw AppError.validation("\u0443 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438 \u043D\u0435\u0442 IP \u0434\u043B\u044F \u0437\u0430\u043C\u0435\u043D\u044B");
|
|
}
|
|
let fromIp = input.from_ip?.trim();
|
|
let toIp = input.to_ip?.trim();
|
|
if (input.node_id) {
|
|
const node = repos14.getNode(db, input.node_id);
|
|
if (node.service_id !== binding.service_id) {
|
|
throw AppError.validation("\u043D\u043E\u0434\u0430 \u043D\u0435 \u043F\u0440\u0438\u043D\u0430\u0434\u043B\u0435\u0436\u0438\u0442 \u0441\u0435\u0440\u0432\u0438\u0441\u0443 \u044D\u0442\u043E\u0439 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0438");
|
|
}
|
|
toIp = node.address;
|
|
}
|
|
if (!fromIp) {
|
|
fromIp = current[0].ip;
|
|
}
|
|
if (!toIp) {
|
|
throw AppError.invalidIp("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u043E\u0432\u044B\u0439 IP \u0438\u043B\u0438 \u043D\u043E\u0434\u0443");
|
|
}
|
|
if (!isValidIpv4(toIp)) {
|
|
throw AppError.invalidIp(`\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441: ${toIp}`);
|
|
}
|
|
if (!current.some((row) => row.ip === fromIp)) {
|
|
throw AppError.validation(`IP ${fromIp} \u043D\u0435\u0442 \u0432 \u043F\u0440\u0438\u0432\u044F\u0437\u043A\u0435`);
|
|
}
|
|
const preview = {
|
|
binding_id: bindingId,
|
|
hostname: binding.hostname,
|
|
zone_name: domain.zone_name,
|
|
from_ip: fromIp,
|
|
to_ip: toIp,
|
|
dry_run: Boolean(input.dry_run),
|
|
applied: false,
|
|
message: `${fromIp} \u2192 ${toIp}`
|
|
};
|
|
if (input.dry_run || fromIp === toIp) {
|
|
return preview;
|
|
}
|
|
return withBindingLock(bindingId, async () => {
|
|
repos14.bumpBindingVersion(db, bindingId);
|
|
const next = current.map(
|
|
(row) => row.ip === fromIp ? { ...row, ip: toIp } : row
|
|
);
|
|
repos14.replaceBindingIpsWithMeta(db, bindingId, next);
|
|
const records = repos14.listRecordsForBinding(db, bindingId);
|
|
const match = records.find(
|
|
(record) => record.content === fromIp && (record.record_type.toUpperCase() === "A" || record.record_type.toUpperCase() === "AAAA")
|
|
);
|
|
if (match) {
|
|
await patchRecordContent(db, cf, binding.domain_id, match.id, toIp);
|
|
} else {
|
|
await applyBindingDesiredDns(
|
|
db,
|
|
cf,
|
|
bindingId,
|
|
next.map((row) => row.ip)
|
|
);
|
|
}
|
|
return {
|
|
...preview,
|
|
dry_run: false,
|
|
applied: true,
|
|
message: `\u0417\u0430\u043F\u0438\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0432 Cloudflare (${fromIp} \u2192 ${toIp}). \u0420\u0430\u0441\u043F\u0440\u043E\u0441\u0442\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043E\u0442 TTL.`
|
|
};
|
|
});
|
|
}
|
|
|
|
// src/routes/service-bindings.ts
|
|
async function serviceBindingRoutes(app2) {
|
|
const createSchema = z4.object({
|
|
domain_id: z4.number(),
|
|
service_id: z4.number(),
|
|
hostname: z4.string().optional(),
|
|
target_ip: z4.string().optional()
|
|
});
|
|
const updateSchema = z4.object({
|
|
service_id: z4.number().optional(),
|
|
hostname: z4.string().optional(),
|
|
target_ip: z4.string().optional(),
|
|
cert_monitoring: certMonitoringSchema.optional()
|
|
});
|
|
app2.get("/service-bindings", async (request2) => {
|
|
return listAll(request2.server.db);
|
|
});
|
|
app2.post("/service-bindings", async (request2) => {
|
|
const body = createSchema.parse(request2.body);
|
|
return create2(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
body
|
|
);
|
|
});
|
|
app2.get("/service-bindings/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const { repos: repos24 } = await import("@cfdm/db");
|
|
return repos24.getBindingView(request2.server.db, Number(id));
|
|
});
|
|
app2.patch("/service-bindings/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = updateSchema.parse(request2.body);
|
|
return update2(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
});
|
|
app2.delete("/service-bindings/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
remove(request2.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/service-bindings/:id/change-ip", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = changeIpSchema.parse(request2.body);
|
|
const result = await changeBindingIp(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
if (result.applied) {
|
|
recordAudit(request2.server, request2, {
|
|
action: "binding.change_ip",
|
|
targetType: "app_resource",
|
|
targetId: id,
|
|
summary: `\u0421\u043C\u0435\u043D\u0451\u043D IP: ${result.message}`,
|
|
details: result
|
|
});
|
|
}
|
|
return result;
|
|
});
|
|
app2.get("/domains/:id/service-bindings", async (request2) => {
|
|
const { id } = request2.params;
|
|
return listByDomain(request2.server.db, Number(id));
|
|
});
|
|
}
|
|
|
|
// src/routes/domains.ts
|
|
import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared";
|
|
import { z as z5 } from "zod";
|
|
async function domainRoutes(app2) {
|
|
const createSchema = z5.object({
|
|
zone_name: z5.string(),
|
|
group_id: z5.number().nullable().optional()
|
|
});
|
|
app2.get("/domains", async (request2) => {
|
|
const query = request2.query;
|
|
const groupId = query.group_id ? Number(query.group_id) : void 0;
|
|
return listDomains(request2.server.db, groupId);
|
|
});
|
|
app2.post("/domains", async (request2) => {
|
|
const body = createSchema.parse(request2.body);
|
|
const domain = await createDomain(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
body.group_id ?? null,
|
|
body.zone_name
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "domain.create",
|
|
targetType: "app_resource",
|
|
targetId: String(domain.id),
|
|
summary: `\u0414\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`,
|
|
details: { zone_name: domain.zone_name, group_id: domain.group_id }
|
|
});
|
|
return domain;
|
|
});
|
|
app2.post("/domains/bulk", async (request2) => {
|
|
const body = bulkUpdateDomainsSchema.parse(request2.body);
|
|
const updated = bulkUpdateDomains(
|
|
request2.server.db,
|
|
body.ids,
|
|
{
|
|
group_id: body.group_id,
|
|
environment: body.environment,
|
|
tags_add: body.tags_add
|
|
}
|
|
);
|
|
return { updated };
|
|
});
|
|
app2.get("/domains/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getDomain(request2.server.db, Number(id));
|
|
});
|
|
app2.patch("/domains/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = updateDomainSchema.parse(request2.body);
|
|
const domain = updateDomain(request2.server.db, Number(id), body);
|
|
recordAudit(request2.server, request2, {
|
|
action: "domain.update",
|
|
targetType: "app_resource",
|
|
targetId: String(domain.id),
|
|
summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`,
|
|
details: body
|
|
});
|
|
return domain;
|
|
});
|
|
app2.delete("/domains/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const domain = getDomain(request2.server.db, Number(id));
|
|
deleteDomain(request2.server.db, Number(id));
|
|
recordAudit(request2.server, request2, {
|
|
action: "domain.delete",
|
|
severity: "warning",
|
|
targetType: "app_resource",
|
|
targetId: String(id),
|
|
summary: `\u0423\u0434\u0430\u043B\u0451\u043D \u0434\u043E\u043C\u0435\u043D ${domain.zone_name}`,
|
|
details: { zone_name: domain.zone_name }
|
|
});
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/domains/:id/import", async (request2) => {
|
|
const { id } = request2.params;
|
|
const imported = await importZoneRecords(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id)
|
|
);
|
|
return { imported };
|
|
});
|
|
app2.put("/domains/:id/services", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request2.body);
|
|
const serviceIds = await setDomainServices2(
|
|
request2.server.db,
|
|
Number(id),
|
|
body.service_ids
|
|
);
|
|
return { service_ids: serviceIds };
|
|
});
|
|
}
|
|
|
|
// src/routes/dns.ts
|
|
import { z as z6 } from "zod";
|
|
async function dnsRoutes(app2) {
|
|
const createSchema = z6.object({
|
|
record_type: z6.string(),
|
|
name: z6.string(),
|
|
content: z6.string(),
|
|
ttl: z6.number().optional(),
|
|
proxied: z6.boolean().optional(),
|
|
priority: z6.number().optional()
|
|
});
|
|
app2.get("/domains/:id/dns", async (request2) => {
|
|
const { id } = request2.params;
|
|
const q = request2.query;
|
|
return list(request2.server.db, Number(id), {
|
|
record_type: q.record_type,
|
|
name: q.name,
|
|
content: q.content,
|
|
proxied: q.proxied != null ? q.proxied === "true" : void 0,
|
|
sync_status: q.sync_status,
|
|
q: q.q,
|
|
sort: q.sort ?? "name",
|
|
page: q.page ? Number(q.page) : 1,
|
|
limit: q.limit ? Number(q.limit) : 50
|
|
});
|
|
});
|
|
app2.post("/domains/:id/dns", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = createSchema.parse(request2.body);
|
|
const record = await create(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "dns.create",
|
|
targetType: "app_resource",
|
|
targetId: String(record.id),
|
|
summary: `\u0421\u043E\u0437\u0434\u0430\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name} (${record.record_type})`,
|
|
details: {
|
|
domain_id: Number(id),
|
|
record_type: record.record_type,
|
|
name: record.name,
|
|
content: record.content
|
|
}
|
|
});
|
|
return record;
|
|
});
|
|
app2.post("/domains/:id/dns/bulk", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request2.body);
|
|
return bulk(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body.operations
|
|
);
|
|
});
|
|
app2.get("/domains/:id/dns/:recordId", async (request2) => {
|
|
const { id, recordId } = request2.params;
|
|
return get(
|
|
request2.server.db,
|
|
Number(id),
|
|
Number(recordId)
|
|
);
|
|
});
|
|
app2.patch("/domains/:id/dns/:recordId", async (request2) => {
|
|
const { id, recordId } = request2.params;
|
|
const record = await update(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
Number(recordId),
|
|
request2.body
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "dns.update",
|
|
targetType: "app_resource",
|
|
targetId: String(recordId),
|
|
summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name}`,
|
|
details: { domain_id: Number(id), record_id: Number(recordId) }
|
|
});
|
|
return record;
|
|
});
|
|
app2.delete("/domains/:id/dns/:recordId", async (request2) => {
|
|
const { id, recordId } = request2.params;
|
|
const record = get(
|
|
request2.server.db,
|
|
Number(id),
|
|
Number(recordId)
|
|
);
|
|
await deleteRecord(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
Number(recordId)
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "dns.delete",
|
|
severity: "warning",
|
|
targetType: "app_resource",
|
|
targetId: String(recordId),
|
|
summary: `\u0423\u0434\u0430\u043B\u0435\u043D\u0430 DNS-\u0437\u0430\u043F\u0438\u0441\u044C ${record.name}`,
|
|
details: { domain_id: Number(id), name: record.name }
|
|
});
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/domains/:id/dns/:recordId/resolve", async (request2) => {
|
|
const { id, recordId } = request2.params;
|
|
const body = z6.object({ source: z6.string() }).parse(request2.body);
|
|
return resolveConflict(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
Number(recordId),
|
|
body
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/subdomains.ts
|
|
import { updateSubdomainSchema } from "@cfdm/shared";
|
|
import { repos as repos15 } from "@cfdm/db";
|
|
import { z as z7 } from "zod";
|
|
async function subdomainRoutes(app2) {
|
|
app2.get("/domains/:id/subdomains", async (request2) => {
|
|
const { id } = request2.params;
|
|
repos15.getDomain(request2.server.db, Number(id));
|
|
return repos15.listSubdomainsByDomain(request2.server.db, Number(id));
|
|
});
|
|
app2.post("/domains/:id/subdomains", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = z7.object({ name: z7.string() }).parse(request2.body);
|
|
const domain = repos15.getDomain(request2.server.db, Number(id));
|
|
const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
|
|
return repos15.createSubdomain(
|
|
request2.server.db,
|
|
Number(id),
|
|
body.name,
|
|
fqdn
|
|
);
|
|
});
|
|
app2.get("/subdomains/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return repos15.getSubdomain(request2.server.db, Number(id));
|
|
});
|
|
app2.patch("/subdomains/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = updateSubdomainSchema.parse(request2.body);
|
|
const sub = repos15.getSubdomain(request2.server.db, Number(id));
|
|
const domain = repos15.getDomain(request2.server.db, sub.domain_id);
|
|
const patch = {};
|
|
if (body.name !== void 0) {
|
|
patch.name = body.name;
|
|
patch.fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
|
|
}
|
|
if (body.enabled !== void 0) {
|
|
patch.enabled = body.enabled;
|
|
}
|
|
if (body.cert_monitoring !== void 0) {
|
|
patch.cert_monitoring = body.cert_monitoring;
|
|
}
|
|
return repos15.updateSubdomain(request2.server.db, Number(id), patch);
|
|
});
|
|
app2.delete("/subdomains/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
repos15.deleteSubdomain(request2.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
}
|
|
|
|
// src/routes/certificates.ts
|
|
async function certificateRoutes(app2) {
|
|
app2.get("/certificates", async (request2) => {
|
|
const query = request2.query;
|
|
return listCertificates(
|
|
request2.server.db,
|
|
query.status
|
|
);
|
|
});
|
|
app2.get("/certificates/summary", async (request2) => {
|
|
return statusSummary(request2.server.db);
|
|
});
|
|
app2.post("/certificates/check", async (request2) => {
|
|
const checked = await runAllChecks(request2.server.db);
|
|
return { checked };
|
|
});
|
|
app2.get("/certificates/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getCertificate(
|
|
request2.server.db,
|
|
Number(id)
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/sync.ts
|
|
async function syncRoutes(app2) {
|
|
app2.post("/sync", async (request2) => {
|
|
const jobId = await syncAll(
|
|
request2.server.db,
|
|
request2.server.cf
|
|
);
|
|
return { job_id: jobId };
|
|
});
|
|
app2.post("/domains/:id/sync", async (request2) => {
|
|
const { id } = request2.params;
|
|
const result = await syncDomain(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id)
|
|
);
|
|
return { job_id: result.jobId, changes: result.changes };
|
|
});
|
|
app2.get("/sync/jobs/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getJob(request2.server.db, id);
|
|
});
|
|
}
|
|
|
|
// src/routes/origin-health-checks.ts
|
|
import {
|
|
createOriginHealthCheckSchema
|
|
} from "@cfdm/shared";
|
|
|
|
// src/services/origin-health-check-service.ts
|
|
import { repos as repos17 } from "@cfdm/db";
|
|
|
|
// src/services/health/cloudflare.ts
|
|
import { repos as repos16 } from "@cfdm/db";
|
|
function toPayload(check, address) {
|
|
const type = (check.protocol || "TCP").toUpperCase();
|
|
const payload = {
|
|
address,
|
|
name: check.name,
|
|
type,
|
|
interval: check.interval_sec,
|
|
timeout: check.timeout,
|
|
retries: check.retries,
|
|
consecutive_fails: check.consecutive_fails,
|
|
consecutive_successes: check.consecutive_successes,
|
|
suspended: check.suspended
|
|
};
|
|
if (type === "HTTP" || type === "HTTPS") {
|
|
payload.http_config = {
|
|
method: check.method ?? "GET",
|
|
path: check.path ?? "/",
|
|
expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"]
|
|
};
|
|
} else {
|
|
payload.tcp_config = { method: "connection_established" };
|
|
}
|
|
return payload;
|
|
}
|
|
var CloudflareHealthCheckProvider = class {
|
|
constructor(db, cf) {
|
|
this.db = db;
|
|
this.cf = cf;
|
|
}
|
|
db;
|
|
cf;
|
|
kind = "cloudflare";
|
|
async probe(target) {
|
|
const node = repos16.findNodeByIp(this.db, target.ip);
|
|
if (!node?.health_check_id) {
|
|
return { ok: false, latencyMs: 0, error: "\u043D\u0435\u0442 Cloudflare Health Check" };
|
|
}
|
|
const check = repos16.getHealthCheck(this.db, node.health_check_id);
|
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
|
|
return { ok: false, latencyMs: 0, error: "Cloudflare Health Check \u043D\u0435 \u0441\u0438\u043D\u0445\u0440\u043E\u043D\u0438\u0437\u0438\u0440\u043E\u0432\u0430\u043D" };
|
|
}
|
|
try {
|
|
const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
|
|
const status = (remote.status ?? "").toLowerCase();
|
|
const ok = status === "healthy" || status === "ok";
|
|
return {
|
|
ok,
|
|
latencyMs: 0,
|
|
error: ok ? null : remote.status ?? "unhealthy"
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: err instanceof Error ? err.message : String(err)
|
|
};
|
|
}
|
|
}
|
|
async syncCreate(check, zoneId, address) {
|
|
try {
|
|
const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address));
|
|
repos16.updateHealthCheck(this.db, check.id, {
|
|
cf_healthcheck_id: remote.id,
|
|
cf_zone_id: zoneId,
|
|
provider: "cloudflare"
|
|
});
|
|
return remote;
|
|
} catch (err) {
|
|
if (err instanceof AppError) throw err;
|
|
throw AppError.healthcheckCreateFailed(
|
|
err instanceof Error ? err.message : "\u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043E\u0437\u0434\u0430\u0442\u044C Cloudflare Health Check"
|
|
);
|
|
}
|
|
}
|
|
async syncUpdate(check, address) {
|
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
|
|
throw AppError.healthcheckCreateFailed("Cloudflare Health Check \u043D\u0435 \u043F\u0440\u0438\u0432\u044F\u0437\u0430\u043D \u043A \u0437\u043E\u043D\u0435");
|
|
}
|
|
return this.cf.updateHealthCheck(
|
|
check.cf_zone_id,
|
|
check.cf_healthcheck_id,
|
|
toPayload(check, address)
|
|
);
|
|
}
|
|
async syncDelete(check) {
|
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) return;
|
|
await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
|
|
}
|
|
};
|
|
|
|
// src/services/origin-health-check-service.ts
|
|
function listOriginHealthChecks(db) {
|
|
return repos17.listHealthChecks(db);
|
|
}
|
|
function getOriginHealthCheck(db, id) {
|
|
return repos17.getHealthCheck(db, id);
|
|
}
|
|
async function createOriginHealthCheck(db, cf, input) {
|
|
const protocol = (input.protocol ?? "tcp").toLowerCase();
|
|
const check = repos17.createHealthCheck(db, {
|
|
provider: input.provider,
|
|
name: input.name,
|
|
cf_zone_id: input.cf_zone_id ?? null,
|
|
protocol,
|
|
path: input.path,
|
|
method: input.method,
|
|
timeout: input.timeout,
|
|
interval_sec: input.interval_sec,
|
|
retries: input.retries,
|
|
expected_status: input.expected_status,
|
|
consecutive_fails: input.consecutive_fails,
|
|
consecutive_successes: input.consecutive_successes,
|
|
suspended: input.suspended
|
|
});
|
|
if (input.node_id) {
|
|
const node = repos17.getNode(db, input.node_id);
|
|
repos17.updateNode(db, node.id, { health_check_id: check.id });
|
|
}
|
|
if (input.provider === "cloudflare") {
|
|
const zoneId = input.cf_zone_id;
|
|
if (!zoneId) {
|
|
throw AppError.zoneNotFound("\u0443\u043A\u0430\u0436\u0438\u0442\u0435 \u0437\u043E\u043D\u0443 Cloudflare \u0434\u043B\u044F Health Check");
|
|
}
|
|
const address = input.node_id ? repos17.getNode(db, input.node_id).address : check.name;
|
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
|
await provider.syncCreate(check, zoneId, address);
|
|
return repos17.getHealthCheck(db, check.id);
|
|
}
|
|
return check;
|
|
}
|
|
async function updateOriginHealthCheck(db, cf, id, patch) {
|
|
const current = repos17.getHealthCheck(db, id);
|
|
const updated = repos17.updateHealthCheck(db, id, {
|
|
provider: patch.provider,
|
|
name: patch.name,
|
|
cf_zone_id: patch.cf_zone_id,
|
|
protocol: patch.protocol?.toLowerCase(),
|
|
path: patch.path,
|
|
method: patch.method,
|
|
timeout: patch.timeout,
|
|
interval_sec: patch.interval_sec,
|
|
retries: patch.retries,
|
|
expected_status: patch.expected_status,
|
|
consecutive_fails: patch.consecutive_fails,
|
|
consecutive_successes: patch.consecutive_successes,
|
|
suspended: patch.suspended
|
|
});
|
|
if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) {
|
|
const address = repos17.findNodeByIp(db, updated.name)?.address ?? updated.name;
|
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
|
await provider.syncUpdate(updated, address);
|
|
}
|
|
void current;
|
|
return repos17.getHealthCheck(db, id);
|
|
}
|
|
async function deleteOriginHealthCheck(db, cf, id) {
|
|
const check = repos17.getHealthCheck(db, id);
|
|
if (check.provider === "cloudflare") {
|
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
|
await provider.syncDelete(check);
|
|
}
|
|
repos17.deleteHealthCheck(db, id);
|
|
}
|
|
async function syncOriginHealthCheck(db, cf, id) {
|
|
const check = repos17.getHealthCheck(db, id);
|
|
if (check.provider !== "cloudflare") {
|
|
throw AppError.validation("\u0441\u0438\u043D\u0445\u0440\u043E\u043D\u0438\u0437\u0430\u0446\u0438\u044F \u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F Cloudflare Health Checks");
|
|
}
|
|
if (!check.cf_zone_id) {
|
|
throw AppError.zoneNotFound();
|
|
}
|
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
|
const address = repos17.findNodeByIp(db, check.name)?.address ?? check.name;
|
|
if (check.cf_healthcheck_id) {
|
|
await provider.syncUpdate(check, address);
|
|
} else {
|
|
await provider.syncCreate(check, check.cf_zone_id, address);
|
|
}
|
|
return repos17.getHealthCheck(db, id);
|
|
}
|
|
|
|
// src/routes/origin-health-checks.ts
|
|
async function originHealthCheckRoutes(app2) {
|
|
app2.get("/health-checks", async (request2) => {
|
|
return listOriginHealthChecks(request2.server.db);
|
|
});
|
|
app2.post("/health-checks", async (request2) => {
|
|
const body = createOriginHealthCheckSchema.parse(request2.body);
|
|
const check = await createOriginHealthCheck(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
body
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "healthcheck.create",
|
|
targetType: "app_resource",
|
|
targetId: String(check.id),
|
|
summary: `\u0421\u043E\u0437\u0434\u0430\u043D health check \xAB${check.name}\xBB (${check.provider})`
|
|
});
|
|
return check;
|
|
});
|
|
app2.get("/health-checks/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
return getOriginHealthCheck(request2.server.db, Number(id));
|
|
});
|
|
app2.patch("/health-checks/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = createOriginHealthCheckSchema.partial().parse(request2.body);
|
|
const check = await updateOriginHealthCheck(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "healthcheck.update",
|
|
targetType: "app_resource",
|
|
targetId: id,
|
|
summary: `\u041E\u0431\u043D\u043E\u0432\u043B\u0451\u043D health check \xAB${check.name}\xBB`
|
|
});
|
|
return check;
|
|
});
|
|
app2.delete("/health-checks/:id", async (request2) => {
|
|
const { id } = request2.params;
|
|
await deleteOriginHealthCheck(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id)
|
|
);
|
|
recordAudit(request2.server, request2, {
|
|
action: "healthcheck.delete",
|
|
severity: "warning",
|
|
targetType: "app_resource",
|
|
targetId: id,
|
|
summary: `\u0423\u0434\u0430\u043B\u0451\u043D health check ${id}`
|
|
});
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/health-checks/:id/sync", async (request2) => {
|
|
const { id } = request2.params;
|
|
return syncOriginHealthCheck(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
Number(id)
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/health-check.ts
|
|
import { z as z8 } from "zod";
|
|
import { healthStatusQuerySchema } from "@cfdm/shared";
|
|
import { getAppSettings as getAppSettings3, repos as repos20 } from "@cfdm/db";
|
|
|
|
// src/services/health-check-service.ts
|
|
import { connect as connect2, isIP } from "net";
|
|
import { resolve4 as resolve42, resolve6 } from "dns/promises";
|
|
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
|
import { repos as repos18 } from "@cfdm/db";
|
|
import {
|
|
aggregateHealthOk,
|
|
parseHealthAggregate,
|
|
targetProviders
|
|
} from "@cfdm/shared";
|
|
|
|
// src/services/health/state-machine.ts
|
|
function wasHealthy(status) {
|
|
return status === "up" || status === "healthy";
|
|
}
|
|
function wasUnhealthy(status) {
|
|
return status === "down" || status === "unhealthy" || status === "checking" || status === "degraded";
|
|
}
|
|
function nextHealthState(ok, latencyMs, prev, thresholds) {
|
|
if (!ok) {
|
|
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
|
if (failures >= thresholds.downFailures) {
|
|
return { legacy: "down", node: "unhealthy", failures, successes: 0 };
|
|
}
|
|
return { legacy: "degraded", node: "degraded", failures, successes: 0 };
|
|
}
|
|
if (latencyMs > thresholds.latencyWarnMs) {
|
|
return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 };
|
|
}
|
|
if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) {
|
|
return {
|
|
legacy: "up",
|
|
node: "healthy",
|
|
failures: 0,
|
|
successes: (prev?.consecutive_successes ?? 0) + 1
|
|
};
|
|
}
|
|
const successes = (prev.consecutive_successes ?? 0) + 1;
|
|
if (successes >= thresholds.successRecoveries) {
|
|
return { legacy: "up", node: "healthy", failures: 0, successes };
|
|
}
|
|
return { legacy: "unknown", node: "checking", failures: 0, successes };
|
|
}
|
|
|
|
// src/services/health/local.ts
|
|
var LocalHealthCheckProvider = class {
|
|
kind = "local";
|
|
probe(target) {
|
|
return probeTarget(target);
|
|
}
|
|
};
|
|
|
|
// src/services/health/worker.ts
|
|
function workerNotConfiguredResult() {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Cloudflare Worker \u043D\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0435\u043D (\u043D\u0435\u0442 KV mailbox)",
|
|
colo: null
|
|
};
|
|
}
|
|
|
|
// src/lib/globalping-client.ts
|
|
import {
|
|
clampGlobalpingLimit,
|
|
parseGlobalpingLocations
|
|
} from "@cfdm/shared";
|
|
var GLOBALPING_API_ROOT = "https://api.globalping.io";
|
|
var GLOBALPING_MIN_POLL_MS = 500;
|
|
var GLOBALPING_UA = "CFDM-health/1.0";
|
|
function sleep(ms) {
|
|
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
}
|
|
function locationLabel(probe) {
|
|
if (!probe) return null;
|
|
const city = probe.city?.trim();
|
|
const country = probe.country?.trim();
|
|
if (city && country) return `${city}, ${country}`;
|
|
return city || country || null;
|
|
}
|
|
function buildMeasurementBody(target, options) {
|
|
const limit = clampGlobalpingLimit(options.limit, 3);
|
|
const locations = parseGlobalpingLocations(options.locations).map((magic) => ({
|
|
magic
|
|
}));
|
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
|
const ip = String(target.ip || "").trim();
|
|
const hostname = (target.hostname || ip).trim();
|
|
if (target.type === "http") {
|
|
const path = target.path?.trim() || "/";
|
|
const protocol = port === 443 ? "HTTPS" : "HTTP";
|
|
return {
|
|
type: "http",
|
|
target: ip,
|
|
inProgressUpdates: false,
|
|
limit,
|
|
locations,
|
|
measurementOptions: {
|
|
protocol,
|
|
port,
|
|
request: {
|
|
method: "GET",
|
|
host: hostname,
|
|
path: path.startsWith("/") ? path : `/${path}`
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return {
|
|
type: "ping",
|
|
target: ip,
|
|
inProgressUpdates: false,
|
|
limit,
|
|
locations,
|
|
measurementOptions: {
|
|
protocol: "TCP",
|
|
port
|
|
}
|
|
};
|
|
}
|
|
function rowOk(target, row) {
|
|
const result = row.result;
|
|
if (!result) return false;
|
|
const status = String(result.status ?? "").toLowerCase();
|
|
if (status && status !== "finished") return false;
|
|
if (target.type === "http") {
|
|
const code = result.statusCode;
|
|
if (code == null) return false;
|
|
if (target.expected_status != null) return code === target.expected_status;
|
|
return code >= 200 && code < 400;
|
|
}
|
|
const loss = result.stats?.loss;
|
|
if (loss != null && loss >= 100) return false;
|
|
return status === "finished" || status === "";
|
|
}
|
|
function rowLatency(row) {
|
|
const total = row.result?.timings?.total;
|
|
if (typeof total === "number" && Number.isFinite(total)) return Math.round(total);
|
|
const avg = row.result?.stats?.avg;
|
|
if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg);
|
|
return 0;
|
|
}
|
|
function summarizeMeasurement(target, doc) {
|
|
const rows = doc.results ?? [];
|
|
if (rows.length === 0) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Globalping: \u043F\u0443\u0441\u0442\u043E\u0439 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442",
|
|
colo: null
|
|
};
|
|
}
|
|
const oks = rows.map((row) => rowOk(target, row));
|
|
const okCount = oks.filter(Boolean).length;
|
|
const ok = okCount > rows.length / 2;
|
|
const latencies = rows.map(rowLatency);
|
|
const latencyMs = Math.round(
|
|
latencies.reduce((sum, n) => sum + n, 0) / latencies.length
|
|
);
|
|
const colo = locationLabel(rows.find((_, i) => oks[i])?.probe) ?? locationLabel(rows[0]?.probe);
|
|
if (ok) {
|
|
return { ok: true, latencyMs, error: null, colo };
|
|
}
|
|
const expected = target.type === "http" && target.expected_status != null ? `\u043E\u0436\u0438\u0434\u0430\u043B\u0438 HTTP ${target.expected_status}` : target.type === "http" ? "\u043E\u0436\u0438\u0434\u0430\u043B\u0438 HTTP 2xx/3xx" : "TCP ping \u0441 packet loss < 100%";
|
|
return {
|
|
ok: false,
|
|
latencyMs,
|
|
error: `Globalping: ${okCount}/${rows.length} \u043F\u0440\u043E\u0431 \u0443\u0441\u043F\u0435\u0448\u043D\u044B (${expected})`,
|
|
colo
|
|
};
|
|
}
|
|
async function parseJson(response) {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
async function runGlobalpingMeasurement(target, options = {}) {
|
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
const pollMs = options.pollIntervalMs === void 0 ? GLOBALPING_MIN_POLL_MS : Math.max(0, options.pollIntervalMs);
|
|
const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3e3, 3e3) + 15e3;
|
|
const headers = {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": GLOBALPING_UA
|
|
};
|
|
const token = options.token?.trim();
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(buildMeasurementBody(target, options))
|
|
});
|
|
if (created.status === 429) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Globalping: 429 rate limit",
|
|
colo: null
|
|
};
|
|
}
|
|
if (created.status !== 202 && created.status !== 200) {
|
|
const body = await parseJson(created);
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`,
|
|
colo: null
|
|
};
|
|
}
|
|
const createdBody = await parseJson(created);
|
|
const id = createdBody.id?.trim();
|
|
if (!id) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Globalping: \u043D\u0435\u0442 id \u0438\u0437\u043C\u0435\u0440\u0435\u043D\u0438\u044F",
|
|
colo: null
|
|
};
|
|
}
|
|
const started = Date.now();
|
|
while (Date.now() - started < maxWaitMs) {
|
|
await sleep(pollMs);
|
|
const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"User-Agent": GLOBALPING_UA,
|
|
...token ? { Authorization: `Bearer ${token}` } : {}
|
|
}
|
|
});
|
|
if (polled.status === 429) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Globalping: 429 rate limit",
|
|
colo: null
|
|
};
|
|
}
|
|
if (!polled.ok) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: `Globalping: HTTP ${polled.status} \u043F\u0440\u0438 \u043E\u043F\u0440\u043E\u0441\u0435`,
|
|
colo: null
|
|
};
|
|
}
|
|
const doc = await parseJson(polled);
|
|
if (String(doc.status ?? "").toLowerCase() === "in-progress") continue;
|
|
return summarizeMeasurement(target, doc);
|
|
}
|
|
return {
|
|
ok: false,
|
|
latencyMs: Date.now() - started,
|
|
error: "Globalping: timeout \u043E\u0436\u0438\u0434\u0430\u043D\u0438\u044F measurement",
|
|
colo: null
|
|
};
|
|
}
|
|
|
|
// src/services/health/globalping.ts
|
|
function globalpingNotConfiguredResult() {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Globalping: \u0442\u043E\u043A\u0435\u043D \u043D\u0435 \u0437\u0430\u0434\u0430\u043D",
|
|
colo: null
|
|
};
|
|
}
|
|
async function probeWithGlobalping(target, options) {
|
|
if (!options.token?.trim()) {
|
|
return globalpingNotConfiguredResult();
|
|
}
|
|
try {
|
|
const result = await runGlobalpingMeasurement(target, options);
|
|
return {
|
|
ok: result.ok,
|
|
latencyMs: result.latencyMs,
|
|
error: result.error,
|
|
colo: result.colo
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: err instanceof Error ? err.message : "Globalping: \u043E\u0448\u0438\u0431\u043A\u0430 \u0437\u0430\u043F\u0440\u043E\u0441\u0430",
|
|
colo: null
|
|
};
|
|
}
|
|
}
|
|
|
|
// src/services/health-check-service.ts
|
|
function hostForUrl(ipOrHost) {
|
|
return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost;
|
|
}
|
|
function buildHttpProbeUrl(urlHost, port, pathWithSlash, useTls) {
|
|
const defaultPort = useTls ? 443 : 80;
|
|
const portPart = port === defaultPort ? "" : `:${port}`;
|
|
return `${useTls ? "https" : "http"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`;
|
|
}
|
|
function tcpProbe(ip, port, timeoutMs) {
|
|
return new Promise((resolve5) => {
|
|
const started = Date.now();
|
|
const socket = connect2({ host: ip, port, timeout: timeoutMs });
|
|
let settled = false;
|
|
const finish = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
socket.destroy();
|
|
resolve5(result);
|
|
};
|
|
socket.on(
|
|
"connect",
|
|
() => finish({
|
|
ok: true,
|
|
latencyMs: Date.now() - started,
|
|
error: null
|
|
})
|
|
);
|
|
socket.on(
|
|
"timeout",
|
|
() => finish({
|
|
ok: false,
|
|
latencyMs: Date.now() - started,
|
|
error: "connection timeout"
|
|
})
|
|
);
|
|
socket.on(
|
|
"error",
|
|
(err) => finish({
|
|
ok: false,
|
|
latencyMs: Date.now() - started,
|
|
error: err.message
|
|
})
|
|
);
|
|
});
|
|
}
|
|
function createIpPinnedAgent(connectAddr, sniHost, useTls, timeoutMs, verifyTls) {
|
|
const connector = buildConnector({
|
|
rejectUnauthorized: verifyTls,
|
|
timeout: timeoutMs
|
|
});
|
|
return new Agent({
|
|
connect(opts, callback) {
|
|
connector(
|
|
{
|
|
...opts,
|
|
// Force socket to configured IP (or CNAME target), not public DNS of FQDN.
|
|
hostname: connectAddr,
|
|
host: connectAddr,
|
|
servername: useTls && isIP(sniHost) === 0 ? sniHost : opts.servername
|
|
},
|
|
callback
|
|
);
|
|
}
|
|
});
|
|
}
|
|
async function httpProbe(ip, target, timeoutMs) {
|
|
const started = Date.now();
|
|
const path = target.path?.trim() || "/";
|
|
const pathWithSlash = path.startsWith("/") ? path : `/${path}`;
|
|
const port = target.port ?? 80;
|
|
const useTls = port === 443;
|
|
const connectAddr = String(ip || "").trim();
|
|
const headerHost = (target.hostname || "").trim() || connectAddr;
|
|
const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls);
|
|
const verifyTls = target.verify_tls === true;
|
|
const family = isIP(connectAddr);
|
|
const pinToIp = family === 4 || family === 6;
|
|
const dispatcher = pinToIp ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls) : useTls ? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls) : void 0;
|
|
try {
|
|
const response = await undiciFetch(url, {
|
|
method: "GET",
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
redirect: "manual",
|
|
dispatcher
|
|
});
|
|
const latency = Date.now() - started;
|
|
await response.body?.cancel().catch(() => {
|
|
});
|
|
if (target.expected_status != null) {
|
|
if (response.status !== target.expected_status) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: latency,
|
|
error: `expected ${target.expected_status}, got ${response.status}`
|
|
};
|
|
}
|
|
return { ok: true, latencyMs: latency, error: null };
|
|
}
|
|
if (response.status >= 200 && response.status < 400) {
|
|
return { ok: true, latencyMs: latency, error: null };
|
|
}
|
|
return {
|
|
ok: false,
|
|
latencyMs: latency,
|
|
error: `unexpected status ${response.status}`
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: Date.now() - started,
|
|
error: err instanceof Error ? err.message : String(err)
|
|
};
|
|
} finally {
|
|
await dispatcher?.destroy().catch(() => {
|
|
});
|
|
}
|
|
}
|
|
async function pingProbe(hostname, timeoutMs) {
|
|
const ports = [443, 80];
|
|
let last = {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "unreachable"
|
|
};
|
|
for (const port of ports) {
|
|
last = await tcpProbe(hostname, port, timeoutMs);
|
|
if (last.ok) return last;
|
|
}
|
|
return last;
|
|
}
|
|
async function dnsProbe(hostname) {
|
|
const started = Date.now();
|
|
try {
|
|
const [v4, v6] = await Promise.allSettled([
|
|
resolve42(hostname),
|
|
resolve6(hostname)
|
|
]);
|
|
const hasV4 = v4.status === "fulfilled" && v4.value.length > 0;
|
|
const hasV6 = v6.status === "fulfilled" && v6.value.length > 0;
|
|
if (!hasV4 && !hasV6) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: Date.now() - started,
|
|
error: "no A/AAAA records"
|
|
};
|
|
}
|
|
return { ok: true, latencyMs: Date.now() - started, error: null };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: Date.now() - started,
|
|
error: err instanceof Error ? err.message : String(err)
|
|
};
|
|
}
|
|
}
|
|
async function probeTarget(target) {
|
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
|
const timeoutMs = target.timeout_ms || 3e3;
|
|
if (target.type === "http") {
|
|
return httpProbe(target.ip, target, timeoutMs);
|
|
}
|
|
if (target.type === "ping") {
|
|
return pingProbe(target.hostname || target.ip, timeoutMs);
|
|
}
|
|
if (target.type === "dns") {
|
|
return dnsProbe(target.hostname || target.ip);
|
|
}
|
|
return tcpProbe(target.ip, port, timeoutMs);
|
|
}
|
|
function deriveState(ok, latencyMs, prev, thresholds) {
|
|
const next = nextHealthState(ok, latencyMs, prev, {
|
|
degradedFailures: thresholds.degradedFailures,
|
|
downFailures: thresholds.downFailures,
|
|
latencyWarnMs: thresholds.latencyWarnMs,
|
|
successRecoveries: thresholds.successRecoveries ?? 2
|
|
});
|
|
return {
|
|
state: next.legacy,
|
|
failures: next.failures,
|
|
successes: next.successes,
|
|
node: next.node
|
|
};
|
|
}
|
|
function sleep2(ms) {
|
|
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
}
|
|
function logSourceResult(db, target, provider, result) {
|
|
repos18.insertHealthProbeLog(db, {
|
|
scope: target.scope,
|
|
refId: target.ref_id,
|
|
ip: target.ip,
|
|
provider,
|
|
status: result.ok ? "up" : "down",
|
|
ok: result.ok,
|
|
latencyMs: result.latencyMs,
|
|
colo: result.colo ?? null,
|
|
error: result.error
|
|
});
|
|
}
|
|
async function applyAggregatedStatus(db, target, sources, options) {
|
|
const policy = parseHealthAggregate(target.aggregate);
|
|
const oks = sources.map((s) => s.result.ok);
|
|
const aggregatedOk = aggregateHealthOk(oks, policy);
|
|
const latencies = sources.map((s) => s.result.latencyMs);
|
|
const latencyMs = latencies.length ? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length) : 0;
|
|
const colo = sources.find((s) => s.result.colo)?.result.colo ?? sources[0]?.result.colo ?? null;
|
|
const error = aggregatedOk ? null : sources.map((s) => s.result.error).filter((msg) => Boolean(msg)).join("; ") || "health aggregate down";
|
|
const statusProvider = sources.length > 1 ? "aggregate" : sources[0]?.provider ?? target.provider;
|
|
const prev = repos18.getIpHealthStatusRow(
|
|
db,
|
|
target.scope,
|
|
target.ref_id,
|
|
target.ip
|
|
);
|
|
const { state, failures, successes, node } = deriveState(
|
|
aggregatedOk,
|
|
latencyMs,
|
|
prev ? {
|
|
consecutive_failures: prev.consecutive_failures,
|
|
consecutive_successes: prev.consecutive_successes,
|
|
status: prev.status
|
|
} : null,
|
|
options.thresholds
|
|
);
|
|
const prevState = prev ? prev.status : null;
|
|
repos18.upsertIpHealthStatus(
|
|
db,
|
|
target.scope,
|
|
target.ref_id,
|
|
target.ip,
|
|
state,
|
|
latencyMs,
|
|
failures,
|
|
error,
|
|
successes,
|
|
{ colo, provider: statusProvider }
|
|
);
|
|
const matchedNode = repos18.findNodeByIp(db, target.ip);
|
|
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
|
|
repos18.updateNode(db, matchedNode.id, {
|
|
health_status: node,
|
|
consecutive_failures: failures,
|
|
consecutive_successes: successes,
|
|
last_check_at: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19),
|
|
last_failure_reason: error
|
|
});
|
|
}
|
|
if (prevState !== state) {
|
|
await options.onStatusChange?.(target, prevState, state);
|
|
}
|
|
}
|
|
function staleWorkerResult(colo) {
|
|
return {
|
|
ok: false,
|
|
latencyMs: 0,
|
|
error: "Cloudflare Worker: \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442\u044B \u0443\u0441\u0442\u0430\u0440\u0435\u043B\u0438 \u0438\u043B\u0438 KV \u043F\u0443\u0441\u0442",
|
|
colo
|
|
};
|
|
}
|
|
async function runAllChecks2(db, options) {
|
|
const targets = repos18.listHealthCheckTargets(db);
|
|
const gapMs = Math.max(0, options.probeGapMs ?? 2e3);
|
|
const local = new LocalHealthCheckProvider();
|
|
const staleAfterMs = options.staleAfterMs ?? 10 * 6e4;
|
|
const byOrigin = /* @__PURE__ */ new Map();
|
|
for (const target of targets) {
|
|
const key = originProbeKey(target);
|
|
const list2 = byOrigin.get(key);
|
|
if (list2) list2.push(target);
|
|
else byOrigin.set(key, [target]);
|
|
}
|
|
const needsCloudflare = targets.some(
|
|
(t) => targetProviders(t).includes("cloudflare")
|
|
);
|
|
let mailboxResults = /* @__PURE__ */ new Map();
|
|
let mailboxColo = null;
|
|
let mailboxStale = true;
|
|
const mailbox = options.mailbox ?? null;
|
|
if (needsCloudflare) {
|
|
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
|
mailboxResults = indexResults(resultsDoc);
|
|
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
|
mailboxColo = 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 {
|
|
}
|
|
}
|
|
}
|
|
const probeCache = /* @__PURE__ */ new Map();
|
|
let probeIndex = 0;
|
|
async function resolveProvider(provider, representative, originKey) {
|
|
const cacheKey = `${provider}|${originKey}`;
|
|
const cached = probeCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
let result;
|
|
if (provider === "local") {
|
|
if (probeIndex > 0 && gapMs > 0) await sleep2(gapMs);
|
|
probeIndex += 1;
|
|
result = await local.probe(representative);
|
|
} else if (provider === "cloudflare") {
|
|
const item = mailboxResults.get(originKey);
|
|
if (!mailbox) result = workerNotConfiguredResult();
|
|
else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo);
|
|
else {
|
|
result = {
|
|
ok: item.ok,
|
|
latencyMs: item.latencyMs,
|
|
error: item.error,
|
|
colo: mailboxColo
|
|
};
|
|
}
|
|
} else {
|
|
if (!options.globalping?.token?.trim()) {
|
|
result = globalpingNotConfiguredResult();
|
|
} else {
|
|
if (probeIndex > 0 && gapMs > 0) await sleep2(gapMs);
|
|
probeIndex += 1;
|
|
result = await probeWithGlobalping(representative, options.globalping);
|
|
}
|
|
}
|
|
probeCache.set(cacheKey, result);
|
|
return result;
|
|
}
|
|
for (const [originKey, group] of byOrigin) {
|
|
const representative = group.find((t) => t.scope === "binding") ?? group[0];
|
|
const needed = /* @__PURE__ */ new Set();
|
|
for (const target of group) {
|
|
for (const provider of targetProviders(target)) needed.add(provider);
|
|
}
|
|
for (const provider of needed) {
|
|
await resolveProvider(provider, representative, originKey);
|
|
}
|
|
for (const target of group) {
|
|
const providers = targetProviders(target);
|
|
const sources = providers.map((provider) => ({
|
|
provider,
|
|
result: probeCache.get(`${provider}|${originKey}`)
|
|
}));
|
|
for (const source of sources) {
|
|
logSourceResult(db, target, source.provider, source.result);
|
|
}
|
|
await applyAggregatedStatus(db, target, sources, options);
|
|
}
|
|
}
|
|
repos18.pruneStaleIpHealthStatus(db, targets);
|
|
return targets.length;
|
|
}
|
|
async function runDomainMonitors(db, thresholds) {
|
|
const monitors = repos18.listEnabledDomainMonitors(db);
|
|
let checked = 0;
|
|
for (const monitor of monitors) {
|
|
const target = {
|
|
scope: "binding",
|
|
ref_id: monitor.id,
|
|
ip: monitor.hostname,
|
|
hostname: monitor.hostname,
|
|
type: monitor.type,
|
|
port: monitor.type === "http" ? monitor.path?.includes("443") ? 443 : 80 : null,
|
|
path: monitor.path,
|
|
expected_status: monitor.expected_status,
|
|
timeout_ms: monitor.timeout_ms,
|
|
verify_tls: false,
|
|
provider: "local",
|
|
providers: ["local"],
|
|
aggregate: "majority"
|
|
};
|
|
let result;
|
|
if (monitor.type === "http") {
|
|
result = await httpProbe(monitor.hostname, {
|
|
...target,
|
|
port: 443,
|
|
path: monitor.path ?? "/"
|
|
}, monitor.timeout_ms);
|
|
if (!result.ok) {
|
|
result = await httpProbe(monitor.hostname, {
|
|
...target,
|
|
port: 80,
|
|
path: monitor.path ?? "/"
|
|
}, monitor.timeout_ms);
|
|
}
|
|
} else if (monitor.type === "ping") {
|
|
result = await pingProbe(monitor.hostname, monitor.timeout_ms);
|
|
} else {
|
|
result = await dnsProbe(monitor.hostname);
|
|
}
|
|
const prevStatus = monitor.last_status;
|
|
const { state } = deriveState(
|
|
result.ok,
|
|
result.latencyMs,
|
|
{
|
|
consecutive_failures: result.ok ? 0 : 1,
|
|
consecutive_successes: result.ok ? 1 : 0,
|
|
status: prevStatus
|
|
},
|
|
thresholds
|
|
);
|
|
repos18.updateDomainMonitorResult(
|
|
db,
|
|
monitor.id,
|
|
state,
|
|
result.latencyMs,
|
|
result.error
|
|
);
|
|
if (prevStatus !== state && prevStatus !== "unknown") {
|
|
const label = state === "up" ? "OK" : state === "degraded" ? "Slow" : state === "down" ? "Down" : "\u2014";
|
|
repos18.insertNotificationLog(
|
|
db,
|
|
"domain_monitor",
|
|
"domain_monitor",
|
|
monitor.id,
|
|
`${monitor.hostname}: ${label}`,
|
|
result.error ? `${monitor.type.toUpperCase()} \u2192 ${label}. ${result.error}` : `${monitor.type.toUpperCase()} \u2192 ${label}${result.latencyMs != null ? ` (${result.latencyMs} \u043C\u0441)` : ""}`
|
|
);
|
|
}
|
|
checked += 1;
|
|
}
|
|
return checked;
|
|
}
|
|
function listStatus(db, scope, refId) {
|
|
return repos18.listIpHealthStatus(db, scope, refId);
|
|
}
|
|
|
|
// src/services/health-check-scheduler.ts
|
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
|
import {
|
|
getAppSettings as getAppSettings2,
|
|
getAppSettingsSecrets as getAppSettingsSecrets2,
|
|
updateAppSettings as updateAppSettings2
|
|
} from "@cfdm/db";
|
|
import { repos as repos19 } from "@cfdm/db";
|
|
var HEALTH_CHECK_JOB_ID = "health-check";
|
|
function healthEngineFallbacksFromConfig(config2) {
|
|
return {
|
|
healthCheckCron: config2.healthCheckCron,
|
|
healthDegradedFailures: config2.healthDegradedFailures,
|
|
healthDownFailures: config2.healthDownFailures,
|
|
healthLatencyWarnMs: config2.healthLatencyWarnMs,
|
|
healthSuccessRecoveries: config2.healthSuccessRecoveries,
|
|
healthWorkerUrl: config2.healthWorkerUrl,
|
|
healthWorkerTokenSet: Boolean(config2.healthWorkerToken)
|
|
};
|
|
}
|
|
function assertValidHealthCron(expr) {
|
|
const cronExpression = expr.trim();
|
|
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
|
if (parts.length < 5 || parts.length > 6) {
|
|
throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435");
|
|
}
|
|
try {
|
|
const job = new CronJob(
|
|
{ cronExpression },
|
|
new AsyncTask("validate-cron", async () => void 0),
|
|
{ id: "validate-cron" }
|
|
);
|
|
job.stop();
|
|
} catch {
|
|
throw AppError.validation("\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 cron-\u0432\u044B\u0440\u0430\u0436\u0435\u043D\u0438\u0435");
|
|
}
|
|
}
|
|
function createHealthCheckTask(app2, config2) {
|
|
const fallbacks = healthEngineFallbacksFromConfig(config2);
|
|
return new AsyncTask(
|
|
HEALTH_CHECK_JOB_ID,
|
|
async () => {
|
|
const settings = getAppSettings2(app2.db, fallbacks);
|
|
const thresholds = {
|
|
degradedFailures: settings.healthDegradedFailures,
|
|
downFailures: settings.healthDownFailures,
|
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
|
successRecoveries: settings.healthSuccessRecoveries
|
|
};
|
|
const mailbox = mailboxFromSettings(app2.db, app2.cf, fallbacks);
|
|
const secrets = getAppSettingsSecrets2(app2.db);
|
|
const n = await runAllChecks2(app2.db, {
|
|
thresholds,
|
|
probeGapMs: config2.healthProbeGapMs,
|
|
mailbox,
|
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
|
globalping: {
|
|
token: secrets.globalpingToken,
|
|
locations: secrets.globalpingLocations,
|
|
limit: secrets.globalpingLimit
|
|
},
|
|
onStatusChange: async (target, prev, next) => {
|
|
try {
|
|
const label = next === "up" ? "OK" : next === "degraded" ? "Slow" : next === "down" ? "Down" : "\u2014";
|
|
repos19.insertNotificationLog(
|
|
app2.db,
|
|
"ip_health",
|
|
target.scope,
|
|
target.ref_id,
|
|
`${target.hostname || target.ip}: ${label}`,
|
|
`IP ${target.ip}: ${prev ?? "\u2014"} \u2192 ${label}`
|
|
);
|
|
await reconcileDnsForTarget(
|
|
app2.db,
|
|
app2.cf,
|
|
target.scope,
|
|
target.ref_id
|
|
);
|
|
} catch (err) {
|
|
app2.log.warn(
|
|
{ err, scope: target.scope, refId: target.ref_id },
|
|
"health-check reconcile failed"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
if (mailbox) {
|
|
updateAppSettings2(
|
|
app2.db,
|
|
{ healthWorkerLastIngestAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
fallbacks
|
|
);
|
|
}
|
|
const monitors = await runDomainMonitors(
|
|
app2.db,
|
|
thresholds
|
|
);
|
|
app2.log.info({ checked: n, monitors }, "health check completed");
|
|
},
|
|
(err) => {
|
|
app2.log.warn({ err }, "health check failed");
|
|
}
|
|
);
|
|
}
|
|
function scheduleHealthCheckJob(app2, config2, task) {
|
|
const scheduler = app2.scheduler;
|
|
if (!scheduler) return;
|
|
if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {
|
|
scheduler.removeById(HEALTH_CHECK_JOB_ID);
|
|
}
|
|
const settings = getAppSettings2(
|
|
app2.db,
|
|
healthEngineFallbacksFromConfig(config2)
|
|
);
|
|
scheduler.addCronJob(
|
|
new CronJob(
|
|
{ cronExpression: settings.healthCheckCron },
|
|
task,
|
|
{ preventOverrun: true, id: HEALTH_CHECK_JOB_ID }
|
|
)
|
|
);
|
|
}
|
|
|
|
// src/routes/health-check.ts
|
|
async function healthCheckRoutes(app2) {
|
|
app2.get("/health-status", async (request2) => {
|
|
const query = healthStatusQuerySchema.parse(request2.query);
|
|
return listStatus(
|
|
request2.server.db,
|
|
query.scope,
|
|
query.ref_id
|
|
);
|
|
});
|
|
app2.get("/health-status/batch", async (request2) => {
|
|
const raw = request2.query ?? {};
|
|
const idsParam = typeof raw.ref_ids === "string" ? raw.ref_ids : "";
|
|
const refIds = z8.array(z8.coerce.number().int().positive()).max(200).parse(
|
|
idsParam.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
|
);
|
|
const grouped = repos20.listIpHealthStatusByBindingIds(
|
|
request2.server.db,
|
|
refIds
|
|
);
|
|
const items = [];
|
|
for (const id of refIds) {
|
|
items.push({ ref_id: id, rows: grouped.get(id) ?? [] });
|
|
}
|
|
return { items };
|
|
});
|
|
app2.post("/health-check/run", async (request2) => {
|
|
const config2 = request2.server.config;
|
|
const fallbacks = healthEngineFallbacksFromConfig(config2);
|
|
const settings = getAppSettings3(
|
|
request2.server.db,
|
|
fallbacks
|
|
);
|
|
const thresholds = {
|
|
degradedFailures: settings.healthDegradedFailures,
|
|
downFailures: settings.healthDownFailures,
|
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
|
successRecoveries: settings.healthSuccessRecoveries
|
|
};
|
|
const checked = await runAllChecks2(request2.server.db, {
|
|
thresholds,
|
|
probeGapMs: config2.healthProbeGapMs,
|
|
mailbox: mailboxFromSettings(request2.server.db, request2.server.cf, fallbacks),
|
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
|
onStatusChange: async (target, prev, next) => {
|
|
try {
|
|
const label = next === "up" ? "OK" : next === "degraded" ? "Slow" : next === "down" ? "Down" : "\u2014";
|
|
repos20.insertNotificationLog(
|
|
request2.server.db,
|
|
"ip_health",
|
|
target.scope,
|
|
target.ref_id,
|
|
`${target.hostname || target.ip}: ${label}`,
|
|
`IP ${target.ip}: ${prev ?? "\u2014"} \u2192 ${label}`
|
|
);
|
|
await reconcileDnsForTarget(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
target.scope,
|
|
target.ref_id
|
|
);
|
|
} catch {
|
|
}
|
|
}
|
|
});
|
|
const monitors = await runDomainMonitors(
|
|
request2.server.db,
|
|
thresholds
|
|
);
|
|
return { checked, monitors };
|
|
});
|
|
}
|
|
|
|
// src/routes/domain-monitors.ts
|
|
import { createDomainMonitorSchema } from "@cfdm/shared";
|
|
import { repos as repos21 } from "@cfdm/db";
|
|
async function domainMonitorRoutes(app2) {
|
|
app2.get("/domains/:id/monitors", async (request2) => {
|
|
const { id } = request2.params;
|
|
return repos21.listDomainMonitors(request2.server.db, Number(id));
|
|
});
|
|
app2.post("/domains/:id/monitors", async (request2) => {
|
|
const { id } = request2.params;
|
|
const body = createDomainMonitorSchema.parse(request2.body);
|
|
return repos21.createDomainMonitor(request2.server.db, Number(id), body);
|
|
});
|
|
app2.delete("/domains/:domainId/monitors/:monitorId", async (request2) => {
|
|
const { monitorId } = request2.params;
|
|
repos21.deleteDomainMonitor(request2.server.db, Number(monitorId));
|
|
return { deleted: true };
|
|
});
|
|
app2.get("/domains/:id/monitor-results", async (request2) => {
|
|
const { id } = request2.params;
|
|
const query = request2.query;
|
|
const limit = query.limit ? Number(query.limit) : 50;
|
|
return repos21.listDomainMonitorResultsForDomain(
|
|
request2.server.db,
|
|
Number(id),
|
|
limit
|
|
);
|
|
});
|
|
app2.post("/domains/:id/monitors/run", async (request2) => {
|
|
const config2 = request2.server.config;
|
|
const checked = await runDomainMonitors(request2.server.db, {
|
|
degradedFailures: config2.healthDegradedFailures,
|
|
downFailures: config2.healthDownFailures,
|
|
latencyWarnMs: config2.healthLatencyWarnMs
|
|
});
|
|
return { checked };
|
|
});
|
|
}
|
|
async function notificationRoutes(app2) {
|
|
app2.get("/notifications/log", async (request2) => {
|
|
const query = request2.query;
|
|
const limit = query.limit ? Number(query.limit) : 50;
|
|
return repos21.listNotificationLog(request2.server.db, limit);
|
|
});
|
|
}
|
|
|
|
// src/routes/settings.ts
|
|
import { appSettingsPatchSchema } from "@cfdm/shared";
|
|
import {
|
|
getAppSettings as getAppSettings4,
|
|
updateAppSettings as updateAppSettings3
|
|
} from "@cfdm/db";
|
|
async function settingsRoutes(app2) {
|
|
app2.get("/settings", async (request2) => {
|
|
return getAppSettings4(
|
|
request2.server.db,
|
|
healthEngineFallbacksFromConfig(request2.server.config)
|
|
);
|
|
});
|
|
app2.patch("/settings", async (request2) => {
|
|
const parsed = appSettingsPatchSchema.safeParse(request2.body);
|
|
if (!parsed.success) {
|
|
throw AppError.validation(
|
|
parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438"
|
|
);
|
|
}
|
|
const body = parsed.data;
|
|
if (body.healthCheckCron) {
|
|
assertValidHealthCron(body.healthCheckCron);
|
|
}
|
|
const fallbacks = healthEngineFallbacksFromConfig(request2.server.config);
|
|
const current = getAppSettings4(request2.server.db, fallbacks);
|
|
const nextDegraded = body.healthDegradedFailures ?? current.healthDegradedFailures;
|
|
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
|
|
if (nextDown < nextDegraded) {
|
|
throw AppError.validation(
|
|
"\u043E\u0448\u0438\u0431\u043E\u043A \u0434\u043E down \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435, \u0447\u0435\u043C \u0434\u043E degraded"
|
|
);
|
|
}
|
|
updateAppSettings3(request2.server.db, body, fallbacks);
|
|
if (body.healthCheckCron !== void 0) {
|
|
request2.server.reloadHealthCheckJob?.();
|
|
const after = getAppSettings4(request2.server.db, fallbacks);
|
|
if (after.healthWorkerKvNamespaceId) {
|
|
try {
|
|
await ensureHealthWorker(
|
|
request2.server.db,
|
|
request2.server.cf,
|
|
fallbacks
|
|
);
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
return getAppSettings4(request2.server.db, fallbacks);
|
|
});
|
|
app2.post("/settings/health/worker/ensure", async (request2) => {
|
|
const fallbacks = healthEngineFallbacksFromConfig(request2.server.config);
|
|
await ensureHealthWorker(request2.server.db, request2.server.cf, fallbacks);
|
|
return getAppSettings4(request2.server.db, fallbacks);
|
|
});
|
|
app2.post("/settings/vps-tracker/test", async (request2) => {
|
|
return pingVpsTracker(request2.server.db);
|
|
});
|
|
}
|
|
|
|
// src/routes/integrations-vps-tracker.ts
|
|
import { timingSafeEqual } from "crypto";
|
|
import { vpsTrackerEventSchema } from "@cfdm/shared";
|
|
import { getAppSettingsSecrets as getAppSettingsSecrets3 } from "@cfdm/db";
|
|
|
|
// src/services/vps-tracker-events.ts
|
|
import { repos as repos22 } from "@cfdm/db";
|
|
function collectIps(event) {
|
|
const ips = /* @__PURE__ */ new Set();
|
|
for (const v of event.vps) {
|
|
if (v.ip?.trim()) ips.add(v.ip.trim());
|
|
}
|
|
return ips;
|
|
}
|
|
function bindingUsesIps(db, serviceId, bindingId, ips) {
|
|
const targetIps = repos22.listBindingIps(db, bindingId);
|
|
const poolIps = repos22.listServiceIps(db, serviceId);
|
|
const effective = targetIps.length > 0 ? targetIps : poolIps;
|
|
return effective.some((ip) => ips.has(ip));
|
|
}
|
|
async function reconcileForVpsDown(db, cf, event) {
|
|
const ips = collectIps(event);
|
|
if (ips.size === 0) return 0;
|
|
const seen = /* @__PURE__ */ new Set();
|
|
let reconciled = 0;
|
|
for (const service of repos22.listServices(db)) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos22.listBindingsByService(db, service.id);
|
|
for (const binding of bindings) {
|
|
if (!bindingUsesIps(db, service.id, binding.id, ips)) continue;
|
|
const key = `binding:${binding.id}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
await reconcileDnsForTarget(db, cf, "binding", binding.id);
|
|
reconciled += 1;
|
|
}
|
|
}
|
|
return reconciled;
|
|
}
|
|
|
|
// src/routes/integrations-vps-tracker.ts
|
|
function verifyBearer(authHeader, expected) {
|
|
if (!authHeader?.startsWith("Bearer ")) return false;
|
|
const token = authHeader.slice(7);
|
|
if (!token || !expected) return false;
|
|
const a = Buffer.from(token);
|
|
const b = Buffer.from(expected);
|
|
if (a.length !== b.length) return false;
|
|
return timingSafeEqual(a, b);
|
|
}
|
|
async function integrationsVpsTrackerRoutes(app2) {
|
|
app2.post("/integrations/vps-tracker/events", async (req, reply) => {
|
|
const secrets = getAppSettingsSecrets3(app2.db);
|
|
const token = secrets.vpsTrackerIntegrationToken;
|
|
if (!token) {
|
|
return reply.status(503).send({ error: "Integration not configured" });
|
|
}
|
|
if (!verifyBearer(req.headers.authorization, token)) {
|
|
return reply.status(401).send({ error: "Unauthorized" });
|
|
}
|
|
const parsed = vpsTrackerEventSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: parsed.error.flatten() });
|
|
}
|
|
if (parsed.data.event !== "vps_down") {
|
|
return { ok: true, reconciled: 0 };
|
|
}
|
|
const reconciled = await reconcileForVpsDown(
|
|
app2.db,
|
|
app2.cf,
|
|
parsed.data
|
|
);
|
|
return { ok: true, reconciled };
|
|
});
|
|
app2.post("/integrations/vps-tracker/sync", async (req, reply) => {
|
|
const secrets = getAppSettingsSecrets3(app2.db);
|
|
const token = secrets.vpsTrackerIntegrationToken;
|
|
if (!token) {
|
|
return reply.status(503).send({
|
|
ok: false,
|
|
error: "Integration not configured"
|
|
});
|
|
}
|
|
if (!verifyBearer(req.headers.authorization, token)) {
|
|
return reply.status(401).send({ ok: false, error: "Unauthorized" });
|
|
}
|
|
const bindings = await buildAllSyncBindings(app2.db);
|
|
return {
|
|
ok: true,
|
|
count: bindings.length,
|
|
bindings,
|
|
fullSync: true
|
|
};
|
|
});
|
|
}
|
|
|
|
// src/routes/audit.ts
|
|
import { listAudit } from "@cfdm/db";
|
|
import { auditListQuerySchema } from "@cfdm/shared";
|
|
async function auditRoutes(app2) {
|
|
app2.get("/audit", async (request2) => {
|
|
const parsed = auditListQuerySchema.safeParse(request2.query);
|
|
if (!parsed.success) {
|
|
throw AppError.validation("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0437\u0430\u043F\u0440\u043E\u0441\u0430");
|
|
}
|
|
const q = parsed.data;
|
|
return listAudit(app2.db, {
|
|
action: q.action,
|
|
severity: q.severity,
|
|
userId: q.user_id,
|
|
sourceApp: q.source_app,
|
|
limit: q.limit
|
|
});
|
|
});
|
|
}
|
|
|
|
// src/app.ts
|
|
import { repos as repos23, walCheckpointTruncate } from "@cfdm/db";
|
|
|
|
// src/services/weighted-dns-scheduler.ts
|
|
import { AsyncTask as AsyncTask2, SimpleIntervalJob } from "toad-scheduler";
|
|
var WEIGHTED_DNS_JOB_ID = "weighted-dns";
|
|
function createWeightedDnsTask(app2) {
|
|
return new AsyncTask2(
|
|
WEIGHTED_DNS_JOB_ID,
|
|
async () => {
|
|
const n = await reconcileWeightedDns(app2.db, app2.cf);
|
|
if (n > 0) {
|
|
app2.log.info({ reconciled: n }, "weighted dns rotated");
|
|
}
|
|
},
|
|
(err) => {
|
|
app2.log.warn({ err }, "weighted dns rotate failed");
|
|
}
|
|
);
|
|
}
|
|
function scheduleWeightedDnsJob(app2, task) {
|
|
const scheduler = app2.scheduler;
|
|
if (!scheduler) return;
|
|
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
|
|
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
|
|
}
|
|
scheduler.addSimpleIntervalJob(
|
|
new SimpleIntervalJob(
|
|
{ seconds: WEIGHTED_SLOT_MS / 1e3, runImmediately: true },
|
|
task,
|
|
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true }
|
|
)
|
|
);
|
|
}
|
|
|
|
// src/app.ts
|
|
import { AsyncTask as AsyncTask3, CronJob as CronJob2, ToadScheduler } from "toad-scheduler";
|
|
async function buildApp(opts = {}) {
|
|
const config2 = opts.config ?? loadConfig();
|
|
const app2 = Fastify({
|
|
logger: { level: config2.logLevel }
|
|
}).withTypeProvider();
|
|
app2.setValidatorCompiler(validatorCompiler);
|
|
app2.setSerializerCompiler(serializerCompiler);
|
|
await app2.register(import("@fastify/sensible"));
|
|
await app2.register(import("@fastify/helmet"), { contentSecurityPolicy: false });
|
|
await app2.register(import("@fastify/rate-limit"), {
|
|
max: 300,
|
|
timeWindow: "1 minute"
|
|
});
|
|
await app2.register(cors_default);
|
|
await app2.register(error_handler_default);
|
|
await app2.register(db_default, { config: config2, memory: opts.memory });
|
|
await app2.register(cf_client_default, { config: config2 });
|
|
await app2.register(auth_default, { config: config2 });
|
|
await app2.register(healthRoutes);
|
|
await app2.register(authRoutes, { prefix: "/api/v1" });
|
|
await app2.register(integrationsVpsTrackerRoutes, { prefix: "/api/v1" });
|
|
await app2.register(
|
|
async (protectedApi) => {
|
|
protectedApi.addHook("onRequest", requireAuth);
|
|
await protectedApi.register(groupRoutes);
|
|
await protectedApi.register(serviceRoutes);
|
|
await protectedApi.register(serviceGroupRoutes);
|
|
await protectedApi.register(serviceBindingRoutes);
|
|
await protectedApi.register(domainRoutes);
|
|
await protectedApi.register(dnsRoutes);
|
|
await protectedApi.register(subdomainRoutes);
|
|
await protectedApi.register(certificateRoutes);
|
|
await protectedApi.register(syncRoutes);
|
|
await protectedApi.register(healthCheckRoutes);
|
|
await protectedApi.register(originHealthCheckRoutes);
|
|
await protectedApi.register(domainMonitorRoutes);
|
|
await protectedApi.register(notificationRoutes);
|
|
await protectedApi.register(settingsRoutes);
|
|
await protectedApi.register(auditRoutes);
|
|
},
|
|
{ prefix: "/api/v1" }
|
|
);
|
|
const staticDir = config2.staticDir ?? resolve2(process.cwd(), "static");
|
|
if (config2.staticDir !== null) {
|
|
await app2.register(import("@fastify/static"), {
|
|
root: staticDir,
|
|
wildcard: false
|
|
});
|
|
app2.setNotFoundHandler(async (_request, reply) => {
|
|
return reply.sendFile("index.html");
|
|
});
|
|
}
|
|
if (!opts.memory) {
|
|
const scheduler = new ToadScheduler();
|
|
app2.decorate("scheduler", scheduler);
|
|
app2.addHook("onClose", async () => {
|
|
scheduler.stop();
|
|
});
|
|
const certTask = new AsyncTask3(
|
|
"certificate-check",
|
|
async () => {
|
|
const pruned = repos23.pruneLogs(app2.db);
|
|
if (pruned > 0) {
|
|
app2.log.info({ pruned }, "log retention pruned");
|
|
}
|
|
walCheckpointTruncate(app2.sqlite);
|
|
const n = await runAllChecks(app2.db);
|
|
app2.log.info({ checked: n }, "certificate check completed");
|
|
},
|
|
(err) => {
|
|
app2.log.warn({ err }, "certificate check failed");
|
|
}
|
|
);
|
|
scheduler.addCronJob(
|
|
new CronJob2(
|
|
{ cronExpression: config2.certCheckCron },
|
|
certTask,
|
|
{ preventOverrun: true }
|
|
)
|
|
);
|
|
const healthTask = createHealthCheckTask(app2, config2);
|
|
scheduleHealthCheckJob(app2, config2, healthTask);
|
|
app2.decorate("reloadHealthCheckJob", () => {
|
|
scheduleHealthCheckJob(app2, config2, healthTask);
|
|
});
|
|
scheduleWeightedDnsJob(app2, createWeightedDnsTask(app2));
|
|
if (config2.cloudflareApiToken) {
|
|
fireEnsureHealthWorker(
|
|
app2.db,
|
|
app2.cf,
|
|
healthEngineFallbacksFromConfig(config2),
|
|
app2.log
|
|
);
|
|
}
|
|
}
|
|
return app2;
|
|
}
|
|
|
|
// src/server.ts
|
|
for (const path of [
|
|
resolve3(import.meta.dirname, "../../../.env"),
|
|
".env",
|
|
"../.env"
|
|
]) {
|
|
if (!existsSync2(path)) continue;
|
|
const content = readFileSync2(path, "utf-8");
|
|
for (const line of content.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const eq = trimmed.indexOf("=");
|
|
if (eq === -1) continue;
|
|
const key = trimmed.slice(0, eq).trim();
|
|
let value = trimmed.slice(eq + 1).trim();
|
|
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
if (!(key in process.env)) process.env[key] = value;
|
|
}
|
|
break;
|
|
}
|
|
var config = loadConfig();
|
|
if (!config.cloudflareApiToken) {
|
|
console.warn(
|
|
"CLOUDFLARE_API_TOKEN \u043D\u0435 \u0437\u0430\u0434\u0430\u043D \u2014 \u0438\u043C\u043F\u043E\u0440\u0442 \u0434\u043E\u043C\u0435\u043D\u043E\u0432 \u0438\u0437 Cloudflare \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D"
|
|
);
|
|
}
|
|
var app = await buildApp({ config });
|
|
try {
|
|
await app.listen({ port: config.serverPort, host: "0.0.0.0" });
|
|
app.log.info(`listening on ${config.serverPort}`);
|
|
} catch (err) {
|
|
app.log.error(err);
|
|
process.exit(1);
|
|
}
|
|
//# sourceMappingURL=server.js.map
|