Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m14s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m50s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 7s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Subpath @cfdm/db/settings-repo и @cfdm/db/repos не объявлены в package exports — в Docker/vitest падали 5 test suites. Co-authored-by: Cursor <cursoragent@cursor.com>
3097 lines
98 KiB
JavaScript
3097 lines
98 KiB
JavaScript
// src/server.ts
|
|
import { readFileSync, existsSync } 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 loadConfig() {
|
|
return {
|
|
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
|
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
|
jwtSecret: process.env.JWT_SECRET ?? "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 * * *",
|
|
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *",
|
|
healthDegradedFailures: Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
|
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
|
healthLatencyWarnMs: Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1e3,
|
|
logLevel: process.env.LOG_LEVEL ?? "info"
|
|
};
|
|
}
|
|
|
|
// 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() {
|
|
return new _AppError("FORBIDDEN", "forbidden", 403);
|
|
}
|
|
static conflict(message) {
|
|
return new _AppError("CONFLICT", message, 409);
|
|
}
|
|
static cloudflare(message) {
|
|
return new _AppError("CLOUDFLARE_ERROR", 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/plugins/auth.ts
|
|
async function authPlugin(app2, opts) {
|
|
await app2.register(import("@fastify/jwt"), {
|
|
secret: opts.config.jwtSecret
|
|
});
|
|
}
|
|
async function requireAuth(request) {
|
|
const authHeader = request.headers.authorization ?? "";
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
if (!token) throw AppError.unauthorized();
|
|
try {
|
|
await request.jwtVerify();
|
|
} catch {
|
|
throw AppError.unauthorized();
|
|
}
|
|
}
|
|
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/cf-client.ts
|
|
var BASE_URL = "https://api.cloudflare.com/client/v4";
|
|
var CloudflareClient = class {
|
|
constructor(token) {
|
|
this.token = token;
|
|
}
|
|
token;
|
|
async handleResponse(response, operation) {
|
|
if (response.status === 429) {
|
|
const wait = parseRetryAfter(response.headers) ?? 5e3;
|
|
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
|
|
}
|
|
const body = await response.json();
|
|
if (!body.success) {
|
|
const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
|
throw AppError.cloudflare(`${operation}: ${msg}`);
|
|
}
|
|
if (body.result === void 0) {
|
|
throw AppError.cloudflare(`${operation}: empty result`);
|
|
}
|
|
return body.result;
|
|
}
|
|
async listZones() {
|
|
return withRetry(async () => {
|
|
const all = [];
|
|
let page = 1;
|
|
while (true) {
|
|
const url = new URL(`${BASE_URL}/zones`);
|
|
url.searchParams.set("per_page", "50");
|
|
url.searchParams.set("page", String(page));
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw AppError.cloudflare(String(response.status));
|
|
}
|
|
const batch = await this.handleResponse(
|
|
response,
|
|
"list_zones"
|
|
);
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
if (batch.length < 50) break;
|
|
page += 1;
|
|
}
|
|
return all;
|
|
});
|
|
}
|
|
async getZone(zoneId) {
|
|
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
return this.handleResponse(response, "get_zone");
|
|
}
|
|
async listDnsRecords(zoneId) {
|
|
return withRetry(async () => {
|
|
const all = [];
|
|
let page = 1;
|
|
while (page <= 50) {
|
|
const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`);
|
|
url.searchParams.set("per_page", "100");
|
|
url.searchParams.set("page", String(page));
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw AppError.cloudflare(String(response.status));
|
|
}
|
|
const batch = await this.handleResponse(
|
|
response,
|
|
"list_dns_records"
|
|
);
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
page += 1;
|
|
}
|
|
return all;
|
|
});
|
|
}
|
|
async createDnsRecord(zoneId, payload) {
|
|
const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
return this.handleResponse(response, "create_dns_record");
|
|
}
|
|
async updateDnsRecord(zoneId, recordId, payload) {
|
|
const response = await fetch(
|
|
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${this.token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return this.handleResponse(response, "update_dns_record");
|
|
}
|
|
async deleteDnsRecord(zoneId, recordId) {
|
|
const response = await fetch(
|
|
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
await this.handleResponse(response, "delete_dns_record");
|
|
}
|
|
};
|
|
|
|
// 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";
|
|
import { getAppSwitcher } from "@cfdm/db";
|
|
|
|
// 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 (request, reply) => {
|
|
healthCheck(request.server.sqlite);
|
|
return { status: "ok" };
|
|
});
|
|
app2.get("/ready", async (request, reply) => {
|
|
healthCheck(request.server.sqlite);
|
|
let cloudflare = false;
|
|
if (request.server.config.cloudflareApiToken) {
|
|
try {
|
|
await request.server.cf.listZones();
|
|
cloudflare = true;
|
|
} catch {
|
|
cloudflare = false;
|
|
}
|
|
}
|
|
return {
|
|
status: cloudflare || !request.server.config.cloudflareApiToken ? "ready" : "degraded",
|
|
database: true,
|
|
cloudflare
|
|
};
|
|
});
|
|
}
|
|
async function authRoutes(app2) {
|
|
app2.get("/settings/app-switcher", async (request) => {
|
|
return getAppSwitcher(request.server.db);
|
|
});
|
|
const loginSchema = z.object({
|
|
username: z.string(),
|
|
password: z.string()
|
|
});
|
|
app2.post("/auth/login", async (request, reply) => {
|
|
const body = loginSchema.parse(request.body);
|
|
const result = await login(
|
|
request.server.config,
|
|
(payload) => request.server.jwt.sign(payload),
|
|
body
|
|
);
|
|
return result;
|
|
});
|
|
}
|
|
|
|
// src/routes/groups.ts
|
|
import { z as z2 } from "zod";
|
|
|
|
// 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/routes/groups.ts
|
|
async function groupRoutes(app2) {
|
|
const bodySchema = z2.object({
|
|
name: z2.string(),
|
|
slug: z2.string()
|
|
});
|
|
app2.get("/groups", async (request) => {
|
|
return listGroups(request.server.db);
|
|
});
|
|
app2.post("/groups", async (request) => {
|
|
const body = bodySchema.parse(request.body);
|
|
return createGroup(request.server.db, body.name, body.slug);
|
|
});
|
|
app2.get("/groups/:id", async (request) => {
|
|
const { id } = request.params;
|
|
return getGroupWithStats(request.server.db, Number(id));
|
|
});
|
|
app2.patch("/groups/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const body = bodySchema.parse(request.body);
|
|
return updateGroup(
|
|
request.server.db,
|
|
Number(id),
|
|
body.name,
|
|
body.slug
|
|
);
|
|
});
|
|
app2.delete("/groups/:id", async (request) => {
|
|
const { id } = request.params;
|
|
deleteGroup(request.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
}
|
|
|
|
// src/routes/services.ts
|
|
import { z as z3 } from "zod";
|
|
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
|
import { repos as repos8 } from "@cfdm/db";
|
|
|
|
// src/services/service-config-service.ts
|
|
import { repos as repos7 } from "@cfdm/db";
|
|
import {
|
|
SYNC_ERROR as SYNC_ERROR2,
|
|
SYNC_PENDING_PUSH as SYNC_PENDING_PUSH3,
|
|
SYNC_SYNCED as SYNC_SYNCED3,
|
|
dnsRecordNamesMatch as dnsRecordNamesMatch2,
|
|
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 repos2 } 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);
|
|
repos2.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 repos2.getDnsRecord(db, domainId, record.id);
|
|
} catch (e) {
|
|
repos2.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 = repos2.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 = repos2.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 = repos2.getDomain(db, domainId);
|
|
const existing = repos2.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);
|
|
repos2.updateDnsFields(
|
|
db,
|
|
recordId,
|
|
recordType,
|
|
name,
|
|
content,
|
|
ttl,
|
|
proxied,
|
|
priority,
|
|
SYNC_PENDING_PUSH,
|
|
existing.cf_record_id,
|
|
null
|
|
);
|
|
const updated = repos2.getDnsRecord(db, domainId, recordId);
|
|
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
|
}
|
|
async function deleteRecord(db, cf, domainId, recordId) {
|
|
const domain = repos2.getDomain(db, domainId);
|
|
const record = repos2.getDnsRecord(db, domainId, recordId);
|
|
repos2.markDnsPendingDelete(db, recordId);
|
|
if (record.cf_record_id) {
|
|
try {
|
|
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
|
|
} catch (e) {
|
|
repos2.setDnsSyncStatus(
|
|
db,
|
|
recordId,
|
|
SYNC_ERROR,
|
|
record.cf_record_id,
|
|
e instanceof Error ? e.message : String(e)
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
repos2.deleteDnsRecord(db, recordId);
|
|
}
|
|
function list(db, domainId, filter) {
|
|
repos2.getDomain(db, domainId);
|
|
return repos2.listDnsRecords(db, domainId, filter);
|
|
}
|
|
function get(db, domainId, recordId) {
|
|
return repos2.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 = repos2.getDomain(db, domainId);
|
|
const record = repos2.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) {
|
|
repos2.updateDnsFields(
|
|
db,
|
|
recordId,
|
|
r.type,
|
|
r.name,
|
|
r.content,
|
|
r.ttl,
|
|
r.proxied ?? false,
|
|
r.priority ?? null,
|
|
SYNC_SYNCED,
|
|
r.id ?? null,
|
|
null
|
|
);
|
|
}
|
|
}
|
|
return repos2.getDnsRecord(db, domainId, recordId);
|
|
}
|
|
if (req.source === "local") {
|
|
const updated = repos2.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 repos5 } from "@cfdm/db";
|
|
|
|
// src/services/binding-service.ts
|
|
import { repos as repos3 } 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
|
|
});
|
|
repos3.setBindingDnsRecordId(db, bindingId, record.id);
|
|
return record.id;
|
|
}
|
|
function listAll(db) {
|
|
return repos3.listAllBindings(db);
|
|
}
|
|
function listByDomain(db, domainId) {
|
|
repos3.getDomain(db, domainId);
|
|
return repos3.listBindingsByDomain(db, domainId);
|
|
}
|
|
async function create2(db, cf, req) {
|
|
repos3.getDomain(db, req.domain_id);
|
|
repos3.getService(db, req.service_id);
|
|
const hostname = normalizeHostname(req.hostname);
|
|
const binding = repos3.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 repos3.getBindingView(db, binding.id);
|
|
}
|
|
async function update2(db, cf, id, req) {
|
|
const existing = repos3.getBinding(db, id);
|
|
const serviceId = req.service_id ?? existing.service_id;
|
|
if (req.service_id) repos3.getService(db, req.service_id);
|
|
const hostname = req.hostname ? normalizeHostname(req.hostname) : existing.hostname;
|
|
repos3.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 repos3.getBindingView(db, id);
|
|
}
|
|
function remove(db, id) {
|
|
repos3.getBinding(db, id);
|
|
repos3.deleteBinding(db, id);
|
|
}
|
|
async function setDomainServices(db, domainId, serviceIds) {
|
|
repos3.getDomain(db, domainId);
|
|
for (const sid of serviceIds) {
|
|
repos3.getService(db, sid);
|
|
}
|
|
const existing = repos3.listBindingsByDomain(db, domainId);
|
|
for (const binding of existing) {
|
|
if (!serviceIds.includes(binding.service_id)) {
|
|
repos3.deleteBinding(db, binding.id);
|
|
}
|
|
}
|
|
for (const sid of serviceIds) {
|
|
const already = existing.some((b) => b.service_id === sid);
|
|
if (!already) {
|
|
repos3.insertBinding(db, domainId, sid, "@", null);
|
|
}
|
|
}
|
|
return repos3.listBindingsByDomain(db, domainId).map((b) => b.service_id);
|
|
}
|
|
|
|
// src/services/sync-service.ts
|
|
import { repos as repos4 } 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 } 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) {
|
|
repos4.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) {
|
|
repos4.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 = repos4.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 = repos4.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 {
|
|
repos4.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 = repos4.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") {
|
|
repos4.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()) {
|
|
repos4.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);
|
|
repos4.upsertSubdomain(db, domain.id, label, fqdn);
|
|
changed += 1;
|
|
}
|
|
repos4.setDomainLastSynced(db, domain.id);
|
|
return changed;
|
|
}
|
|
async function syncDomain(db, cf, domainId) {
|
|
const jobId = randomUUID();
|
|
repos4.createSyncJob(db, jobId, domainId);
|
|
const domain = repos4.getDomain(db, domainId);
|
|
try {
|
|
const changes = await pullSync(db, cf, domain);
|
|
repos4.finishSyncJob(db, jobId, "completed", `${changes} changes`);
|
|
return { jobId, changes };
|
|
} catch (e) {
|
|
repos4.finishSyncJob(
|
|
db,
|
|
jobId,
|
|
"failed",
|
|
e instanceof Error ? e.message : String(e)
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
async function syncAll(db, cf) {
|
|
const jobId = randomUUID();
|
|
repos4.createSyncJob(db, jobId, null);
|
|
const all = repos4.listAllDomains(db);
|
|
let total = 0;
|
|
for (const domain of all) {
|
|
try {
|
|
total += await pullSync(db, cf, domain);
|
|
} catch {
|
|
}
|
|
}
|
|
repos4.finishSyncJob(db, jobId, "completed", `${total} total changes`);
|
|
return jobId;
|
|
}
|
|
function getJob(db, jobId) {
|
|
return repos4.getSyncJob(db, jobId);
|
|
}
|
|
|
|
// src/services/domain-service.ts
|
|
function listDomains(db, groupId) {
|
|
return repos5.listDomainsEnriched(db, groupId);
|
|
}
|
|
function getDomain(db, id) {
|
|
return repos5.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((z8) => z8.name.toLowerCase() === trimmed.toLowerCase());
|
|
if (!zone) {
|
|
const names = zones.map((z8) => z8.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 repos5.createDomain(db, groupId, zone.name, zone.id);
|
|
}
|
|
function updateDomain(db, id, groupId, status, certMonitoring) {
|
|
return repos5.updateDomain(db, id, groupId, status, certMonitoring);
|
|
}
|
|
function deleteDomain(db, id) {
|
|
repos5.deleteDomain(db, id);
|
|
}
|
|
async function setDomainServices2(db, domainId, serviceIds) {
|
|
return setDomainServices(db, domainId, serviceIds);
|
|
}
|
|
async function importZoneRecords(db, cf, domainId) {
|
|
const domain = repos5.getDomain(db, domainId);
|
|
return pullSync(db, cf, domain);
|
|
}
|
|
|
|
// src/services/vps-tracker-sync.ts
|
|
import { repos as repos6, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
|
function fqdnToDisplay(hostname, zoneName) {
|
|
if (hostname === "@" || !hostname.trim()) return zoneName;
|
|
return `${hostname}.${zoneName}`;
|
|
}
|
|
function buildServiceSyncBindings(db, serviceId, deletedBindingIds = []) {
|
|
const service = repos6.getService(db, serviceId);
|
|
const serviceIps = repos6.listServiceIps(db, serviceId);
|
|
const bindings = repos6.listBindingsByService(db, serviceId);
|
|
const items = bindings.map((binding) => {
|
|
const targetIps = repos6.listBindingIps(db, binding.id);
|
|
const ips = targetIps.length > 0 ? targetIps : serviceIps.length > 0 ? serviceIps : [];
|
|
return {
|
|
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
|
|
};
|
|
});
|
|
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 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 = buildServiceSyncBindings(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/service-config-service.ts
|
|
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 isHealthy(state) {
|
|
return state === "up" || state === "unknown";
|
|
}
|
|
function selectActiveIpsByMode(config2, rows) {
|
|
if (rows.length === 0) return [];
|
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
|
const pool = healthy.length > 0 ? healthy : rows;
|
|
if (config2.lb_mode === "failover") {
|
|
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 (healthy.length > 0) {
|
|
return primaries.map((r) => r.ip);
|
|
}
|
|
return [sorted[0].ip];
|
|
}
|
|
if (config2.lb_mode === "weighted") {
|
|
return pool.map((r) => r.ip);
|
|
}
|
|
return pool.map((r) => r.ip);
|
|
}
|
|
function getBindingLbState(db, bindingId) {
|
|
const binding = repos7.getBinding(db, bindingId);
|
|
const ipMetas = repos7.listBindingIpsWithMeta(db, bindingId);
|
|
const rows = ipMetas.map((entry) => {
|
|
const status = repos7.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 = repos7.getServiceGroup(db, groupId);
|
|
const services = repos7.listServicesByGroup(db, groupId);
|
|
const seen = /* @__PURE__ */ new Map();
|
|
for (const service of services) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos7.listBindingsByService(db, service.id);
|
|
for (const binding of bindings) {
|
|
const ipMetas = repos7.listBindingIpsWithMeta(db, binding.id);
|
|
for (const entry of ipMetas) {
|
|
const status = repos7.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 (isHealthy(existing.health) && status && !isHealthy(status.status)) {
|
|
existing.health = status.status;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
config: {
|
|
lb_mode: group.lb_mode,
|
|
health_check_enabled: group.health_check_enabled
|
|
},
|
|
rows: [...seen.values()]
|
|
};
|
|
}
|
|
function computeActiveIps(db, scope, refId) {
|
|
const state = scope === "binding" ? getBindingLbState(db, refId) : getGroupLbState(db, refId);
|
|
return selectActiveIpsByMode(state.config, state.rows);
|
|
}
|
|
async function collectKnownZones(db, cf) {
|
|
const dbDomains = repos7.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;
|
|
}
|
|
async function buildView(db, serviceId) {
|
|
const service = repos7.getService(db, serviceId);
|
|
const ips = repos7.listServiceIps(db, serviceId);
|
|
const bindings = repos7.listBindingsByService(db, serviceId);
|
|
const domainViews = bindings.map((binding) => {
|
|
const records = repos7.listRecordsForBinding(db, binding.id);
|
|
const statuses = records.map((r) => r.sync_status);
|
|
const targetIpsWithMeta = repos7.listBindingIpsWithMeta(db, 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;
|
|
}
|
|
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,
|
|
sync_status: aggregateSyncStatus(statuses)
|
|
};
|
|
});
|
|
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,
|
|
domains: domainViews
|
|
};
|
|
}
|
|
async function listViews(db) {
|
|
return Promise.all(
|
|
repos7.listServices(db).map((s) => buildView(db, s.id))
|
|
);
|
|
}
|
|
async function getView(db, id) {
|
|
repos7.getService(db, id);
|
|
return buildView(db, id);
|
|
}
|
|
async function listGroupViews(db) {
|
|
const groups = repos7.listServiceGroups(db);
|
|
const groupViews = await Promise.all(
|
|
groups.map(async (group) => {
|
|
const services = repos7.listServicesByGroup(db, group.id);
|
|
const serviceViews = await Promise.all(
|
|
services.map((s) => buildView(db, s.id))
|
|
);
|
|
return { ...group, services: serviceViews };
|
|
})
|
|
);
|
|
const ungroupedServices = repos7.listUngroupedServices(db);
|
|
const ungrouped = await Promise.all(
|
|
ungroupedServices.map((s) => buildView(db, s.id))
|
|
);
|
|
return { groups: groupViews, ungrouped };
|
|
}
|
|
function shouldPushDns(db, service) {
|
|
if (!service.enabled) return false;
|
|
if (!service.service_group_id) return true;
|
|
const group = repos7.getServiceGroup(db, service.service_group_id);
|
|
return group.enabled;
|
|
}
|
|
async function syncBindingDns(db, cf, bindingId, domainId, hostname, desiredIps, cnameTarget) {
|
|
const domain = repos7.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"
|
|
);
|
|
if (existingCname) {
|
|
effectiveCname = existingCname.content;
|
|
repos7.setBindingCnameTarget(db, bindingId, effectiveCname);
|
|
repos7.replaceBindingIps(db, bindingId, []);
|
|
}
|
|
}
|
|
if (effectiveCname) {
|
|
await syncBindingCnameDns(
|
|
db,
|
|
cf,
|
|
bindingId,
|
|
domainId,
|
|
hostname,
|
|
effectiveCname
|
|
);
|
|
return;
|
|
}
|
|
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
|
|
}
|
|
async function syncBindingCnameDns(db, cf, bindingId, domainId, hostname, cnameTarget) {
|
|
const domain = repos7.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
|
const existingRecords = repos7.listRecordsForBinding(db, bindingId);
|
|
for (const record of existingRecords) {
|
|
if (record.record_type.toUpperCase() === "A") {
|
|
repos7.unlinkBindingRecord(db, bindingId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
}
|
|
}
|
|
const refreshed = repos7.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
|
|
);
|
|
if (adopted) {
|
|
repos7.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
|
|
});
|
|
repos7.linkBindingRecord(db, bindingId, record.id);
|
|
recordId = record.id;
|
|
}
|
|
}
|
|
repos7.setBindingDnsRecordId(db, bindingId, recordId);
|
|
repos7.setBindingCnameTarget(db, bindingId, normalized);
|
|
}
|
|
async function syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps) {
|
|
const domain = repos7.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
const existingRecords = repos7.listRecordsForBinding(db, bindingId);
|
|
for (const record of existingRecords) {
|
|
if (record.record_type.toUpperCase() === "CNAME") {
|
|
repos7.unlinkBindingRecord(db, bindingId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
} else if (!desiredIps.includes(record.content)) {
|
|
repos7.unlinkBindingRecord(db, bindingId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
}
|
|
}
|
|
repos7.setBindingCnameTarget(db, bindingId, null);
|
|
if (desiredIps.length === 0) {
|
|
repos7.setBindingDnsRecordId(db, bindingId, null);
|
|
return;
|
|
}
|
|
const refreshed = repos7.listRecordsForBinding(db, bindingId);
|
|
let primaryId = null;
|
|
for (const ip of desiredIps) {
|
|
const existing = refreshed.find((r) => r.content === ip);
|
|
let recordId;
|
|
if (existing) {
|
|
if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) {
|
|
await update(db, cf, domainId, existing.id, {
|
|
record_type: "A",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: ip,
|
|
proxied: false
|
|
});
|
|
}
|
|
recordId = existing.id;
|
|
} else {
|
|
const adopted = await findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
"A",
|
|
ip
|
|
);
|
|
if (adopted) {
|
|
repos7.linkBindingRecord(db, bindingId, adopted.id);
|
|
recordId = adopted.id;
|
|
} else {
|
|
const record = await create(db, cf, domainId, {
|
|
record_type: "A",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: ip,
|
|
ttl: 1,
|
|
proxied: false
|
|
});
|
|
repos7.linkBindingRecord(db, bindingId, record.id);
|
|
recordId = record.id;
|
|
}
|
|
}
|
|
if (primaryId == null) primaryId = recordId;
|
|
}
|
|
repos7.setBindingDnsRecordId(db, bindingId, primaryId);
|
|
}
|
|
async function cleanupBindingDns(db, cf, bindingId, domainId, hostname) {
|
|
await syncBindingDns(db, cf, bindingId, domainId, hostname, [], null);
|
|
}
|
|
async function cleanupServiceDnsOnly(db, cf, serviceId) {
|
|
const bindings = repos7.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 = repos7.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) {
|
|
const local = findLocalDnsRecord(
|
|
db,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
recordType,
|
|
content
|
|
);
|
|
if (local) return local;
|
|
const domain = repos7.getDomain(db, domainId);
|
|
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
|
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 = repos7.findDnsByCfId(db, domainId, cfRec.id);
|
|
if (existing) return existing;
|
|
return repos7.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) {
|
|
return findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
"A",
|
|
content
|
|
);
|
|
}
|
|
async function serviceBindingsExistInDns(db, cf, serviceId) {
|
|
const bindings = repos7.listBindingsByService(db, serviceId);
|
|
if (bindings.length === 0) return false;
|
|
for (const binding of bindings) {
|
|
const cnameTarget = binding.cname_target?.trim() || null;
|
|
if (cnameTarget) {
|
|
const record = await findOrImportDnsRecord(
|
|
db,
|
|
cf,
|
|
binding.domain_id,
|
|
binding.zone_name,
|
|
binding.hostname,
|
|
"CNAME",
|
|
cnameTarget
|
|
);
|
|
if (!record) return false;
|
|
continue;
|
|
}
|
|
const targetIps = repos7.listBindingIps(db, binding.id);
|
|
if (targetIps.length === 0) return false;
|
|
for (const ip of targetIps) {
|
|
const record = await findOrImportDnsARecord(
|
|
db,
|
|
cf,
|
|
binding.domain_id,
|
|
binding.zone_name,
|
|
binding.hostname,
|
|
ip
|
|
);
|
|
if (!record) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
async function syncServiceBindingsToDns(db, cf, serviceId) {
|
|
const bindings = repos7.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 = repos7.listBindingIps(db, binding.id);
|
|
return targetIps.length > 0;
|
|
});
|
|
const ips = repos7.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");
|
|
}
|
|
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
|
|
);
|
|
continue;
|
|
}
|
|
let targetIps = repos7.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);
|
|
if (binding.health_check_enabled) {
|
|
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
if (activeIps.length > 0) {
|
|
targetIps = activeIps;
|
|
}
|
|
}
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
targetIps,
|
|
null
|
|
);
|
|
}
|
|
}
|
|
async function collectGroupDnsIps(db, groupId) {
|
|
const services = repos7.listServicesByGroup(db, groupId);
|
|
const ips = [];
|
|
for (const service of services) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos7.listBindingsByService(db, service.id);
|
|
for (const binding of bindings) {
|
|
for (const ip of repos7.listBindingIps(db, binding.id)) {
|
|
if (!ips.includes(ip)) ips.push(ip);
|
|
}
|
|
}
|
|
}
|
|
ips.sort();
|
|
return ips;
|
|
}
|
|
async function syncGroupDomainDnsRecords(db, cf, groupId, domainId, hostname, desiredIps) {
|
|
const domain = repos7.getDomain(db, domainId);
|
|
const zoneName = domain.zone_name;
|
|
const existingRecords = repos7.listGroupDnsRecords(db, groupId);
|
|
for (const record of existingRecords) {
|
|
if (!desiredIps.includes(record.content)) {
|
|
repos7.unlinkGroupDnsRecord(db, groupId, record.id);
|
|
await deleteRecord(db, cf, domainId, record.id);
|
|
}
|
|
}
|
|
if (desiredIps.length === 0) return;
|
|
const refreshed = repos7.listGroupDnsRecords(db, groupId);
|
|
for (const ip of desiredIps) {
|
|
const existing = refreshed.find((r) => r.content === ip);
|
|
if (existing) {
|
|
if (!dnsRecordNamesMatch2(existing.name, hostname, zoneName)) {
|
|
await update(db, cf, domainId, existing.id, {
|
|
record_type: "A",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: ip,
|
|
proxied: false
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const adopted = await findOrImportDnsARecord(
|
|
db,
|
|
cf,
|
|
domainId,
|
|
zoneName,
|
|
hostname,
|
|
ip
|
|
);
|
|
if (adopted) {
|
|
repos7.linkGroupDnsRecord(db, groupId, adopted.id);
|
|
continue;
|
|
}
|
|
const record = await create(db, cf, domainId, {
|
|
record_type: "A",
|
|
name: dnsNameForBinding(hostname, zoneName),
|
|
content: ip,
|
|
ttl: 1,
|
|
proxied: false
|
|
});
|
|
repos7.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 = repos7.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 = repos7.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) {
|
|
const group = repos7.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 desiredIps = group.health_check_enabled ? computeActiveIps(db, "group", groupId) : await collectGroupDnsIps(db, groupId);
|
|
await syncGroupDomainDnsRecords(
|
|
db,
|
|
cf,
|
|
groupId,
|
|
domainId,
|
|
hostname,
|
|
desiredIps
|
|
);
|
|
}
|
|
async function syncGroupDomainForService(db, cf, serviceId) {
|
|
const service = repos7.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 = repos7.getServiceGroup(db, groupId);
|
|
if (!group.enabled || !group.domain?.trim()) return;
|
|
const services = repos7.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 = repos7.findDomainByZoneName(db, zoneName);
|
|
if (!domain) return;
|
|
const services = repos7.listServicesByGroup(db, groupId);
|
|
for (const service of services) {
|
|
const binding = repos7.findBinding(
|
|
db,
|
|
service.id,
|
|
domain.id,
|
|
hostname
|
|
);
|
|
if (!binding) continue;
|
|
await cleanupBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname
|
|
);
|
|
repos7.deleteBinding(db, binding.id);
|
|
}
|
|
}
|
|
async function updateConfig(db, cf, id, req) {
|
|
if (req.name && req.slug) {
|
|
repos7.updateService(db, id, req.name, req.slug);
|
|
} else if (req.name) {
|
|
const existing = repos7.getService(db, id);
|
|
repos7.updateService(db, id, req.name, existing.slug);
|
|
} else if (req.slug) {
|
|
const existing = repos7.getService(db, id);
|
|
repos7.updateService(db, id, existing.name, req.slug);
|
|
}
|
|
if (req.service_group_id !== void 0) {
|
|
repos7.setServiceGroup(db, id, req.service_group_id);
|
|
}
|
|
if (req.lb_weight !== void 0 || req.lb_priority !== void 0) {
|
|
const existing = repos7.getService(db, id);
|
|
repos7.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) : repos7.listServiceIps(db, id);
|
|
if (ipsUpdated) repos7.replaceServiceIps(db, id, ips);
|
|
const keptBindingIds = [];
|
|
let service = repos7.getService(db, id);
|
|
const pushDns = shouldPushDns(db, service);
|
|
let removedBindingIds = [];
|
|
if (req.domains) {
|
|
if (req.domains.length > 0) {
|
|
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 = repos7.findBinding(db, id, domainId, hostname) ?? repos7.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
|
|
}));
|
|
repos7.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
|
repos7.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) {
|
|
repos7.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
|
|
});
|
|
}
|
|
if (pushDns) {
|
|
let effectiveIps = targetIps;
|
|
const refreshedBinding = repos7.getBinding(db, binding.id);
|
|
if (refreshedBinding.health_check_enabled) {
|
|
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
if (activeIps.length > 0) {
|
|
effectiveIps = activeIps;
|
|
}
|
|
}
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
domainId,
|
|
hostname,
|
|
effectiveIps,
|
|
targetCname
|
|
);
|
|
}
|
|
}
|
|
const removed = repos7.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
|
|
);
|
|
}
|
|
repos7.deleteBindingsExcept(db, id, keptBindingIds);
|
|
}
|
|
} else if (ipsUpdated) {
|
|
const bindings = repos7.listBindingsByService(db, id);
|
|
for (const binding of bindings) {
|
|
const targetIps = repos7.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 = repos7.getService(db, id);
|
|
if (shouldPushDns(db, service)) {
|
|
await syncServiceBindingsToDns(db, cf, id);
|
|
await syncGroupDomainForService(db, cf, id);
|
|
} else if (req.domains && req.domains.length > 0 && !service.enabled && await serviceBindingsExistInDns(db, cf, id)) {
|
|
repos7.setServiceEnabled(db, id, true);
|
|
await syncServiceBindingsToDns(db, cf, id);
|
|
await syncGroupDomainForService(db, cf, id);
|
|
}
|
|
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
|
return buildView(db, id);
|
|
}
|
|
async function createGroup2(db, cf, body) {
|
|
const groupType = body.type?.trim() || "custom";
|
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
|
return repos7.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
|
|
}
|
|
);
|
|
}
|
|
async function updateGroup2(db, cf, id, body) {
|
|
const groupType = body.type?.trim() || "custom";
|
|
const previous = repos7.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 = repos7.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
|
|
}
|
|
);
|
|
if (!domain && group.enabled) {
|
|
repos7.setServiceGroupEnabled(db, id, false);
|
|
group = repos7.getServiceGroup(db, id);
|
|
}
|
|
await syncEnabledServicesInGroup(db, cf, id);
|
|
return group;
|
|
}
|
|
function deleteGroup2(db, id) {
|
|
repos7.deleteServiceGroup(db, id);
|
|
}
|
|
async function toggleService(db, cf, serviceId, enabled) {
|
|
const service = repos7.getService(db, serviceId);
|
|
if (enabled && service.service_group_id) {
|
|
const group = repos7.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");
|
|
}
|
|
}
|
|
repos7.setServiceEnabled(db, serviceId, enabled);
|
|
if (!enabled) {
|
|
await cleanupServiceDnsOnly(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
return buildView(db, serviceId);
|
|
}
|
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
|
await syncGroupDomainForService(db, cf, serviceId);
|
|
return buildView(db, serviceId);
|
|
}
|
|
async function toggleGroup(db, cf, groupId, enabled) {
|
|
const group = repos7.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");
|
|
}
|
|
repos7.setServiceGroupEnabled(db, groupId, enabled);
|
|
if (!enabled) {
|
|
const services = repos7.listServicesByGroup(db, groupId);
|
|
for (const service of services) {
|
|
if (service.enabled) {
|
|
repos7.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) {
|
|
repos7.getServiceGroup(db, groupId);
|
|
}
|
|
repos7.reorderServices(db, groupId, serviceIds);
|
|
}
|
|
async function reconcileDnsForTarget(db, cf, scope, refId) {
|
|
if (scope === "binding") {
|
|
const binding = repos7.getBinding(db, refId);
|
|
if (!binding.health_check_enabled) return;
|
|
const service = repos7.getService(db, binding.service_id);
|
|
if (!shouldPushDns(db, service)) return;
|
|
const cnameTarget = binding.cname_target?.trim() || null;
|
|
if (cnameTarget) return;
|
|
const ips = repos7.listServiceIps(db, service.id);
|
|
const targetIps = repos7.listBindingIps(db, binding.id);
|
|
validateTargetIpsInPool(targetIps, ips);
|
|
const activeIps = computeActiveIps(db, "binding", refId);
|
|
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
|
await syncBindingDns(
|
|
db,
|
|
cf,
|
|
binding.id,
|
|
binding.domain_id,
|
|
binding.hostname,
|
|
desiredIps,
|
|
null
|
|
);
|
|
return;
|
|
}
|
|
const group = repos7.getServiceGroup(db, refId);
|
|
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
|
|
return;
|
|
}
|
|
await syncGroupDomainDns(db, cf, refId);
|
|
}
|
|
|
|
// 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 (request) => {
|
|
return listViews(request.server.db);
|
|
});
|
|
app2.patch("/services/reorder", async (request) => {
|
|
const body = reorderServicesSchema.parse(request.body);
|
|
reorderServices(
|
|
request.server.db,
|
|
body.group_id,
|
|
body.service_ids
|
|
);
|
|
return { ok: true };
|
|
});
|
|
app2.post("/services", async (request) => {
|
|
const body = createSchema.parse(request.body);
|
|
const service = repos8.createService(
|
|
request.server.db,
|
|
body.name,
|
|
body.slug
|
|
);
|
|
if (body.service_group_id != null) {
|
|
repos8.setServiceGroup(
|
|
request.server.db,
|
|
service.id,
|
|
body.service_group_id
|
|
);
|
|
}
|
|
return getView(request.server.db, service.id);
|
|
});
|
|
app2.get("/services/:id", async (request) => {
|
|
const { id } = request.params;
|
|
return getView(request.server.db, Number(id));
|
|
});
|
|
app2.patch("/services/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const body = updateServiceConfigSchema.parse(request.body);
|
|
return updateConfig(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
});
|
|
app2.delete("/services/:id", async (request) => {
|
|
const { id } = request.params;
|
|
repos8.deleteService(request.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
app2.patch("/services/:id/toggle", async (request) => {
|
|
const { id } = request.params;
|
|
const body = z3.object({ enabled: z3.boolean() }).parse(request.body);
|
|
return toggleService(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body.enabled
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/service-groups.ts
|
|
import {
|
|
createServiceGroupSchema,
|
|
toggleEnabledSchema,
|
|
updateServiceGroupSchema
|
|
} from "@cfdm/shared";
|
|
async function serviceGroupRoutes(app2) {
|
|
app2.get("/service-groups", async (request) => {
|
|
return listGroupViews(request.server.db);
|
|
});
|
|
app2.post("/service-groups", async (request) => {
|
|
const body = createServiceGroupSchema.parse(request.body);
|
|
return createGroup2(
|
|
request.server.db,
|
|
request.server.cf,
|
|
body
|
|
);
|
|
});
|
|
app2.patch("/service-groups/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const body = updateServiceGroupSchema.parse(request.body);
|
|
return updateGroup2(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
});
|
|
app2.delete("/service-groups/:id", async (request) => {
|
|
const { id } = request.params;
|
|
deleteGroup2(request.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
app2.patch("/service-groups/:id/toggle", async (request) => {
|
|
const { id } = request.params;
|
|
const body = toggleEnabledSchema.parse(request.body);
|
|
return toggleGroup(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body.enabled
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/service-bindings.ts
|
|
import { z as z4 } from "zod";
|
|
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()
|
|
});
|
|
app2.get("/service-bindings", async (request) => {
|
|
return listAll(request.server.db);
|
|
});
|
|
app2.post("/service-bindings", async (request) => {
|
|
const body = createSchema.parse(request.body);
|
|
return create2(
|
|
request.server.db,
|
|
request.server.cf,
|
|
body
|
|
);
|
|
});
|
|
app2.get("/service-bindings/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const { repos: repos13 } = await import("@cfdm/db");
|
|
return repos13.getBindingView(request.server.db, Number(id));
|
|
});
|
|
app2.patch("/service-bindings/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const body = updateSchema.parse(request.body);
|
|
return update2(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
});
|
|
app2.delete("/service-bindings/:id", async (request) => {
|
|
const { id } = request.params;
|
|
remove(request.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
app2.get("/domains/:id/service-bindings", async (request) => {
|
|
const { id } = request.params;
|
|
return listByDomain(request.server.db, Number(id));
|
|
});
|
|
}
|
|
|
|
// src/routes/domains.ts
|
|
import { 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 (request) => {
|
|
const query = request.query;
|
|
const groupId = query.group_id ? Number(query.group_id) : void 0;
|
|
return listDomains(request.server.db, groupId);
|
|
});
|
|
app2.post("/domains", async (request) => {
|
|
const body = createSchema.parse(request.body);
|
|
return createDomain(
|
|
request.server.db,
|
|
request.server.cf,
|
|
body.group_id ?? null,
|
|
body.zone_name
|
|
);
|
|
});
|
|
app2.get("/domains/:id", async (request) => {
|
|
const { id } = request.params;
|
|
return getDomain(request.server.db, Number(id));
|
|
});
|
|
app2.patch("/domains/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const body = updateDomainSchema.parse(request.body);
|
|
const existing = getDomain(request.server.db, Number(id));
|
|
return updateDomain(
|
|
request.server.db,
|
|
Number(id),
|
|
body.group_id !== void 0 ? body.group_id : existing.group_id,
|
|
body.status ?? existing.status,
|
|
body.cert_monitoring
|
|
);
|
|
});
|
|
app2.delete("/domains/:id", async (request) => {
|
|
const { id } = request.params;
|
|
deleteDomain(request.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/domains/:id/import", async (request) => {
|
|
const { id } = request.params;
|
|
const imported = await importZoneRecords(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id)
|
|
);
|
|
return { imported };
|
|
});
|
|
app2.put("/domains/:id/services", async (request) => {
|
|
const { id } = request.params;
|
|
const body = z5.object({ service_ids: z5.array(z5.number()) }).parse(request.body);
|
|
const serviceIds = await setDomainServices2(
|
|
request.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 (request) => {
|
|
const { id } = request.params;
|
|
const q = request.query;
|
|
return list(request.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 (request) => {
|
|
const { id } = request.params;
|
|
const body = createSchema.parse(request.body);
|
|
return create(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body
|
|
);
|
|
});
|
|
app2.post("/domains/:id/dns/bulk", async (request) => {
|
|
const { id } = request.params;
|
|
const body = z6.object({ operations: z6.array(z6.record(z6.unknown())) }).parse(request.body);
|
|
return bulk(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
body.operations
|
|
);
|
|
});
|
|
app2.get("/domains/:id/dns/:recordId", async (request) => {
|
|
const { id, recordId } = request.params;
|
|
return get(
|
|
request.server.db,
|
|
Number(id),
|
|
Number(recordId)
|
|
);
|
|
});
|
|
app2.patch("/domains/:id/dns/:recordId", async (request) => {
|
|
const { id, recordId } = request.params;
|
|
return update(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
Number(recordId),
|
|
request.body
|
|
);
|
|
});
|
|
app2.delete("/domains/:id/dns/:recordId", async (request) => {
|
|
const { id, recordId } = request.params;
|
|
await deleteRecord(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
Number(recordId)
|
|
);
|
|
return { deleted: true };
|
|
});
|
|
app2.post("/domains/:id/dns/:recordId/resolve", async (request) => {
|
|
const { id, recordId } = request.params;
|
|
const body = z6.object({ source: z6.string() }).parse(request.body);
|
|
return resolveConflict(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id),
|
|
Number(recordId),
|
|
body
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/subdomains.ts
|
|
import { updateSubdomainSchema } from "@cfdm/shared";
|
|
import { repos as repos9 } from "@cfdm/db";
|
|
import { z as z7 } from "zod";
|
|
async function subdomainRoutes(app2) {
|
|
app2.get("/domains/:id/subdomains", async (request) => {
|
|
const { id } = request.params;
|
|
repos9.getDomain(request.server.db, Number(id));
|
|
return repos9.listSubdomainsByDomain(request.server.db, Number(id));
|
|
});
|
|
app2.post("/domains/:id/subdomains", async (request) => {
|
|
const { id } = request.params;
|
|
const body = z7.object({ name: z7.string() }).parse(request.body);
|
|
const domain = repos9.getDomain(request.server.db, Number(id));
|
|
const fqdn = body.name === "@" ? domain.zone_name : `${body.name}.${domain.zone_name}`;
|
|
return repos9.createSubdomain(
|
|
request.server.db,
|
|
Number(id),
|
|
body.name,
|
|
fqdn
|
|
);
|
|
});
|
|
app2.get("/subdomains/:id", async (request) => {
|
|
const { id } = request.params;
|
|
return repos9.getSubdomain(request.server.db, Number(id));
|
|
});
|
|
app2.patch("/subdomains/:id", async (request) => {
|
|
const { id } = request.params;
|
|
const body = updateSubdomainSchema.parse(request.body);
|
|
const sub = repos9.getSubdomain(request.server.db, Number(id));
|
|
const domain = repos9.getDomain(request.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 repos9.updateSubdomain(request.server.db, Number(id), patch);
|
|
});
|
|
app2.delete("/subdomains/:id", async (request) => {
|
|
const { id } = request.params;
|
|
repos9.deleteSubdomain(request.server.db, Number(id));
|
|
return { deleted: true };
|
|
});
|
|
}
|
|
|
|
// src/services/certificate-service.ts
|
|
import { connect } from "net";
|
|
import { connect as tlsConnect } from "tls";
|
|
import { repos as repos10 } from "@cfdm/db";
|
|
import {
|
|
CERT_ERROR,
|
|
CERT_MONITOR_AUTO,
|
|
CERT_MONITOR_REQUIRED,
|
|
CERT_MONITOR_SKIPPED,
|
|
CERT_UNKNOWN,
|
|
certStatusFromExpiry as certStatusFromExpiry2,
|
|
fqdnToDisplay as fqdnToDisplay3,
|
|
parseFqdn as parseFqdn2,
|
|
shouldMonitorService
|
|
} from "@cfdm/shared";
|
|
function listCertificates(db, status) {
|
|
return repos10.listCertificates(db, status);
|
|
}
|
|
function getCertificate(db, id) {
|
|
return repos10.getCertificate(db, id);
|
|
}
|
|
async function checkHostname(hostname) {
|
|
return new Promise((resolve4) => {
|
|
const socket = connect({ host: hostname, port: 443, timeout: 1e4 });
|
|
socket.on(
|
|
"error",
|
|
(e) => resolve4({ expiresAt: null, error: e.message })
|
|
);
|
|
socket.on("timeout", () => {
|
|
socket.destroy();
|
|
resolve4({ 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) {
|
|
resolve4({ expiresAt: null, error: "no peer certificates" });
|
|
return;
|
|
}
|
|
resolve4({ expiresAt: new Date(cert.valid_to), error: null });
|
|
}
|
|
);
|
|
tlsSocket.on(
|
|
"error",
|
|
(e) => resolve4({ expiresAt: null, error: e.message })
|
|
);
|
|
});
|
|
});
|
|
}
|
|
async function checkAndStore(db, domainId, subdomainId, hostname) {
|
|
const { expiresAt, error } = await checkHostname(hostname);
|
|
if (error) {
|
|
return repos10.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
null,
|
|
CERT_ERROR,
|
|
error
|
|
);
|
|
}
|
|
if (expiresAt) {
|
|
const days = Math.floor(
|
|
(expiresAt.getTime() - Date.now()) / (1e3 * 60 * 60 * 24)
|
|
);
|
|
return repos10.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
expiresAt.toISOString(),
|
|
certStatusFromExpiry2(days),
|
|
null
|
|
);
|
|
}
|
|
return repos10.upsertCertificateCheck(
|
|
db,
|
|
domainId,
|
|
subdomainId,
|
|
hostname,
|
|
null,
|
|
CERT_UNKNOWN,
|
|
"unknown expiry"
|
|
);
|
|
}
|
|
function resolveMonitoringMode(domain, subdomain, fqdn) {
|
|
if (subdomain) {
|
|
return subdomain.cert_monitoring;
|
|
}
|
|
if (fqdn === domain.zone_name) {
|
|
return domain.cert_monitoring;
|
|
}
|
|
return CERT_MONITOR_AUTO;
|
|
}
|
|
function bindingSubdomain(db, domainId, hostname) {
|
|
if (hostname === "@") return null;
|
|
return repos10.findSubdomainByDomainAndName(db, domainId, hostname);
|
|
}
|
|
function buildServiceCertificateFqdns(db) {
|
|
const result = /* @__PURE__ */ new Map();
|
|
for (const binding of repos10.listAllBindings(db)) {
|
|
const service = repos10.getService(db, binding.service_id);
|
|
const group = service.service_group_id ? repos10.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 fqdn = fqdnToDisplay3(binding.hostname, binding.zone_name);
|
|
result.set(fqdn, {
|
|
domainId: binding.domain_id,
|
|
subdomainId: subdomain?.id ?? null,
|
|
hostname: fqdn
|
|
});
|
|
}
|
|
const knownZones = repos10.listAllDomains(db).map((d) => d.zone_name);
|
|
for (const group of repos10.listServiceGroups(db)) {
|
|
if (!group.enabled || !group.domain?.trim()) continue;
|
|
const parsed = parseFqdn2(group.domain, knownZones);
|
|
if (!parsed) continue;
|
|
const domain = repos10.findDomainByZoneName(db, parsed.zoneName);
|
|
if (!domain) continue;
|
|
const subdomain = parsed.hostname === "@" ? null : bindingSubdomain(db, domain.id, parsed.hostname);
|
|
if (subdomain && !subdomain.enabled) continue;
|
|
result.set(parsed.fqdn, {
|
|
domainId: domain.id,
|
|
subdomainId: subdomain?.id ?? null,
|
|
hostname: parsed.fqdn
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
function resolveCertificateTargets(db) {
|
|
const serviceFqdns = buildServiceCertificateFqdns(db);
|
|
const targets = /* @__PURE__ */ new Map();
|
|
for (const domain of repos10.listAllDomains(db)) {
|
|
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
|
targets.set(domain.zone_name, {
|
|
domainId: domain.id,
|
|
subdomainId: null,
|
|
hostname: domain.zone_name
|
|
});
|
|
}
|
|
}
|
|
for (const sub of repos10.listAllSubdomains(db)) {
|
|
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
|
targets.set(sub.fqdn, {
|
|
domainId: sub.domain_id,
|
|
subdomainId: sub.id,
|
|
hostname: sub.fqdn
|
|
});
|
|
}
|
|
}
|
|
for (const [fqdn, meta] of serviceFqdns) {
|
|
const domain = repos10.getDomain(db, meta.domainId);
|
|
const subdomain = meta.subdomainId ? repos10.getSubdomain(db, meta.subdomainId) : null;
|
|
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
|
|
if (monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
if (monitoring === CERT_MONITOR_AUTO || monitoring === CERT_MONITOR_REQUIRED) {
|
|
targets.set(fqdn, meta);
|
|
}
|
|
}
|
|
return [...targets.values()];
|
|
}
|
|
async function runAllChecks(db) {
|
|
const targets = resolveCertificateTargets(db);
|
|
for (const target of targets) {
|
|
await checkAndStore(
|
|
db,
|
|
target.domainId,
|
|
target.subdomainId,
|
|
target.hostname
|
|
);
|
|
}
|
|
repos10.deleteCertificatesNotIn(
|
|
db,
|
|
targets.map((t) => t.hostname)
|
|
);
|
|
return targets.length;
|
|
}
|
|
function statusSummary(db) {
|
|
return repos10.countCertificatesByStatus(db);
|
|
}
|
|
|
|
// src/routes/certificates.ts
|
|
async function certificateRoutes(app2) {
|
|
app2.get("/certificates", async (request) => {
|
|
const query = request.query;
|
|
return listCertificates(
|
|
request.server.db,
|
|
query.status
|
|
);
|
|
});
|
|
app2.get("/certificates/summary", async (request) => {
|
|
return statusSummary(request.server.db);
|
|
});
|
|
app2.post("/certificates/check", async (request) => {
|
|
const checked = await runAllChecks(request.server.db);
|
|
return { checked };
|
|
});
|
|
app2.get("/certificates/:id", async (request) => {
|
|
const { id } = request.params;
|
|
return getCertificate(
|
|
request.server.db,
|
|
Number(id)
|
|
);
|
|
});
|
|
}
|
|
|
|
// src/routes/sync.ts
|
|
async function syncRoutes(app2) {
|
|
app2.post("/sync", async (request) => {
|
|
const jobId = await syncAll(
|
|
request.server.db,
|
|
request.server.cf
|
|
);
|
|
return { job_id: jobId };
|
|
});
|
|
app2.post("/domains/:id/sync", async (request) => {
|
|
const { id } = request.params;
|
|
const result = await syncDomain(
|
|
request.server.db,
|
|
request.server.cf,
|
|
Number(id)
|
|
);
|
|
return { job_id: result.jobId, changes: result.changes };
|
|
});
|
|
app2.get("/sync/jobs/:id", async (request) => {
|
|
const { id } = request.params;
|
|
return getJob(request.server.db, id);
|
|
});
|
|
}
|
|
|
|
// src/routes/health-check.ts
|
|
import { healthStatusQuerySchema } from "@cfdm/shared";
|
|
|
|
// src/services/health-check-service.ts
|
|
import { connect as connect2 } from "net";
|
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
import { repos as repos11 } from "@cfdm/db";
|
|
function tcpProbe(ip, port, timeoutMs) {
|
|
return new Promise((resolve4) => {
|
|
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();
|
|
resolve4(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
|
|
})
|
|
);
|
|
});
|
|
}
|
|
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 urlHost = useTls ? target.hostname || ip : ip;
|
|
const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`;
|
|
const dispatcher = useTls && target.hostname ? new Agent({
|
|
connect: {
|
|
servername: target.hostname,
|
|
rejectUnauthorized: false
|
|
}
|
|
}) : void 0;
|
|
try {
|
|
const response = await undiciFetch(url, {
|
|
method: "GET",
|
|
headers: { Host: target.hostname || ip },
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
redirect: "manual",
|
|
dispatcher
|
|
});
|
|
const latency = Date.now() - started;
|
|
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)
|
|
};
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
return tcpProbe(target.ip, port, timeoutMs);
|
|
}
|
|
function deriveState(ok, latencyMs, prev, thresholds) {
|
|
if (!ok) {
|
|
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
|
if (failures >= thresholds.downFailures) {
|
|
return { state: "down", failures };
|
|
}
|
|
if (failures >= thresholds.degradedFailures) {
|
|
return { state: "degraded", failures };
|
|
}
|
|
return { state: "degraded", failures };
|
|
}
|
|
if (latencyMs > thresholds.latencyWarnMs) {
|
|
return { state: "degraded", failures: 0 };
|
|
}
|
|
return { state: "up", failures: 0 };
|
|
}
|
|
async function runAllChecks2(db, options) {
|
|
const targets = repos11.listHealthCheckTargets(db);
|
|
for (const target of targets) {
|
|
const prev = repos11.getIpHealthStatusRow(
|
|
db,
|
|
target.scope,
|
|
target.ref_id,
|
|
target.ip
|
|
);
|
|
const result = await probeTarget(target);
|
|
const { state, failures } = deriveState(
|
|
result.ok,
|
|
result.latencyMs,
|
|
prev ? {
|
|
consecutive_failures: prev.consecutive_failures,
|
|
status: prev.status
|
|
} : null,
|
|
options.thresholds
|
|
);
|
|
const prevState = prev ? prev.status : null;
|
|
repos11.upsertIpHealthStatus(
|
|
db,
|
|
target.scope,
|
|
target.ref_id,
|
|
target.ip,
|
|
state,
|
|
result.latencyMs,
|
|
failures,
|
|
result.error
|
|
);
|
|
if (prevState !== state) {
|
|
options.onStatusChange?.(target, prevState, state);
|
|
}
|
|
}
|
|
return targets.length;
|
|
}
|
|
function listStatus(db, scope, refId) {
|
|
return repos11.listIpHealthStatus(db, scope, refId);
|
|
}
|
|
|
|
// src/routes/health-check.ts
|
|
async function healthCheckRoutes(app2) {
|
|
app2.get("/health-status", async (request) => {
|
|
const query = healthStatusQuerySchema.parse(request.query);
|
|
return listStatus(
|
|
request.server.db,
|
|
query.scope,
|
|
query.ref_id
|
|
);
|
|
});
|
|
app2.post("/health-check/run", async (request) => {
|
|
const config2 = request.server.config;
|
|
const checked = await runAllChecks2(request.server.db, {
|
|
thresholds: {
|
|
degradedFailures: config2.healthDegradedFailures,
|
|
downFailures: config2.healthDownFailures,
|
|
latencyWarnMs: config2.healthLatencyWarnMs
|
|
},
|
|
onStatusChange: async (target, _prev, _next) => {
|
|
try {
|
|
await reconcileDnsForTarget(
|
|
request.server.db,
|
|
request.server.cf,
|
|
target.scope,
|
|
target.ref_id
|
|
);
|
|
} catch {
|
|
}
|
|
}
|
|
});
|
|
return { checked };
|
|
});
|
|
}
|
|
|
|
// src/routes/settings.ts
|
|
import { appSettingsPatchSchema } from "@cfdm/shared";
|
|
import {
|
|
getAppSettings,
|
|
updateAppSettings
|
|
} from "@cfdm/db";
|
|
async function settingsRoutes(app2) {
|
|
app2.get("/settings", async (request) => {
|
|
return getAppSettings(request.server.db);
|
|
});
|
|
app2.patch("/settings", async (request) => {
|
|
const body = appSettingsPatchSchema.parse(request.body);
|
|
return updateAppSettings(request.server.db, body);
|
|
});
|
|
app2.post("/settings/vps-tracker/test", async (request) => {
|
|
return pingVpsTracker(request.server.db);
|
|
});
|
|
}
|
|
|
|
// src/routes/integrations-vps-tracker.ts
|
|
import { timingSafeEqual } from "crypto";
|
|
import { vpsTrackerEventSchema } from "@cfdm/shared";
|
|
import { getAppSettingsSecrets as getAppSettingsSecrets2 } from "@cfdm/db";
|
|
|
|
// src/services/vps-tracker-events.ts
|
|
import { repos as repos12 } 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 = repos12.listBindingIps(db, bindingId);
|
|
const poolIps = repos12.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 repos12.listServices(db)) {
|
|
if (!service.enabled) continue;
|
|
const bindings = repos12.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 = getAppSettingsSecrets2(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 };
|
|
});
|
|
}
|
|
|
|
// src/app.ts
|
|
import { AsyncTask, CronJob } 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(settingsRoutes);
|
|
},
|
|
{ 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) {
|
|
await app2.register(import("@fastify/schedule"));
|
|
const certTask = new AsyncTask(
|
|
"certificate-check",
|
|
async () => {
|
|
const n = await runAllChecks(app2.db);
|
|
app2.log.info({ checked: n }, "certificate check completed");
|
|
},
|
|
(err) => {
|
|
app2.log.warn({ err }, "certificate check failed");
|
|
}
|
|
);
|
|
app2.scheduler.addCronJob(
|
|
new CronJob(
|
|
{ cronExpression: config2.certCheckCron },
|
|
certTask,
|
|
{ preventOverrun: true }
|
|
)
|
|
);
|
|
const healthTask = new AsyncTask(
|
|
"health-check",
|
|
async () => {
|
|
const n = await runAllChecks2(app2.db, {
|
|
thresholds: {
|
|
degradedFailures: config2.healthDegradedFailures,
|
|
downFailures: config2.healthDownFailures,
|
|
latencyWarnMs: config2.healthLatencyWarnMs
|
|
},
|
|
onStatusChange: async (target, _prev, _next) => {
|
|
try {
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
app2.log.info({ checked: n }, "health check completed");
|
|
},
|
|
(err) => {
|
|
app2.log.warn({ err }, "health check failed");
|
|
}
|
|
);
|
|
app2.scheduler.addCronJob(
|
|
new CronJob(
|
|
{ cronExpression: config2.healthCheckCron },
|
|
healthTask,
|
|
{ preventOverrun: true }
|
|
)
|
|
);
|
|
}
|
|
return app2;
|
|
}
|
|
|
|
// src/server.ts
|
|
for (const path of [
|
|
resolve3(import.meta.dirname, "../../../.env"),
|
|
".env",
|
|
"../.env"
|
|
]) {
|
|
if (!existsSync(path)) continue;
|
|
const content = readFileSync(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);
|
|
}
|