quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 27s
1447 lines
46 KiB
JavaScript
1447 lines
46 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 boolEnv(v, fallback) {
|
|
if (v === void 0 || v === "") return fallback;
|
|
return v === "1" || v.toLowerCase() === "true";
|
|
}
|
|
function loadConfig() {
|
|
const isProd = process.env.NODE_ENV === "production";
|
|
const jwtSecret = process.env.AUTH_JWT_SECRET ?? process.env.JWT_SECRET ?? (isProd ? "" : "dev-secret-change-me");
|
|
return {
|
|
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
|
jwtSecret: jwtSecret || "dev-secret-change-me",
|
|
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
|
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
|
adminPasswordHash: process.env.ADMIN_PASSWORD_HASH?.trim() || "devplaceholder",
|
|
serverPort: Number(process.env.SERVER_PORT ?? "8081") || 8081,
|
|
staticDir: process.env.STATIC_DIR ? resolve(process.env.STATIC_DIR) : null,
|
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
|
authIssuer: process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
|
authPortalUrl: (process.env.AUTH_PORTAL_URL ?? process.env.VITE_AUTH_PORTAL_URL ?? "http://localhost:5175").replace(/\/$/, ""),
|
|
authAuditIngestSecret: process.env.AUTH_AUDIT_INGEST_SECRET?.trim() || (!isProd ? "dev-audit-ingest-secret" : null),
|
|
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim()
|
|
};
|
|
}
|
|
|
|
// src/plugins/auth.ts
|
|
import fp from "fastify-plugin";
|
|
|
|
// src/errors.ts
|
|
import { NotFoundError, ConflictError } from "@cdnmanager/db";
|
|
import { ValidationError } from "@cdnmanager/shared";
|
|
var AppError = class _AppError extends Error {
|
|
constructor(code, message, statusCode) {
|
|
super(message);
|
|
this.code = code;
|
|
this.statusCode = statusCode;
|
|
this.name = "AppError";
|
|
}
|
|
code;
|
|
statusCode;
|
|
static notFound(message) {
|
|
return new _AppError("NOT_FOUND", message, 404);
|
|
}
|
|
static validation(message) {
|
|
return new _AppError("VALIDATION_ERROR", message, 400);
|
|
}
|
|
static unauthorized() {
|
|
return new _AppError("UNAUTHORIZED", "unauthorized", 401);
|
|
}
|
|
static forbidden(message = "forbidden") {
|
|
return new _AppError("FORBIDDEN", message, 403);
|
|
}
|
|
static conflict(message) {
|
|
return new _AppError("CONFLICT", message, 409);
|
|
}
|
|
static cloudflare(message) {
|
|
return new _AppError("CLOUDFLARE_ERROR", message, 502);
|
|
}
|
|
static dnsUpdateFailed(message) {
|
|
return new _AppError(
|
|
"DNS_UPDATE_FAILED",
|
|
message,
|
|
502
|
|
);
|
|
}
|
|
static healthcheckCreateFailed(message) {
|
|
return new _AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
|
|
}
|
|
static zoneNotFound(message = "\u0437\u043E\u043D\u0430 Cloudflare \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430") {
|
|
return new _AppError("ZONE_NOT_FOUND", message, 404);
|
|
}
|
|
static invalidIp(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IP-\u0430\u0434\u0440\u0435\u0441") {
|
|
return new _AppError("INVALID_IP", message, 400);
|
|
}
|
|
static invalidHostname(message = "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u043E\u0435 \u0438\u043C\u044F \u0445\u043E\u0441\u0442\u0430") {
|
|
return new _AppError("INVALID_HOSTNAME", message, 400);
|
|
}
|
|
static rateLimited(message = "Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u043F\u043E\u043F\u044B\u0442\u043A\u0443.") {
|
|
return new _AppError("RATE_LIMITED", message, 429);
|
|
}
|
|
static cloudflareAuthFailed(message = "Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D \u0434\u043E\u0441\u0442\u0443\u043F\u0430") {
|
|
return new _AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
|
|
}
|
|
static syncFailed(message) {
|
|
return new _AppError("SYNC_FAILED", message, 502);
|
|
}
|
|
static internal(message) {
|
|
return new _AppError("INTERNAL_ERROR", message, 500);
|
|
}
|
|
};
|
|
function toAppError(err) {
|
|
if (err instanceof AppError) return err;
|
|
if (err instanceof NotFoundError) return AppError.notFound(err.message);
|
|
if (err instanceof ConflictError) return AppError.conflict(err.message);
|
|
if (err instanceof ValidationError) return AppError.validation(err.message);
|
|
if (err instanceof Error) return AppError.internal(err.message);
|
|
return AppError.internal(String(err));
|
|
}
|
|
function errorBody(err) {
|
|
return {
|
|
error: {
|
|
code: err.code,
|
|
message: err.message
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/permissions.ts
|
|
function hasPermission(granted, required) {
|
|
if (granted.includes(required)) return true;
|
|
const parts = required.split(":");
|
|
if (parts.length !== 3) return false;
|
|
const [app2, section, action] = parts;
|
|
if (action === "read") {
|
|
return granted.includes(`${app2}:${section}:write`) || granted.includes(`${app2}:${section}:admin`);
|
|
}
|
|
if (action === "write") {
|
|
return granted.includes(`${app2}:${section}:admin`);
|
|
}
|
|
return false;
|
|
}
|
|
var RULES = [
|
|
{
|
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
match: (p) => p.startsWith("/api/v1/settings"),
|
|
permission: "cdn:settings:admin"
|
|
}
|
|
];
|
|
function permissionForRequest(method, path) {
|
|
const m = method.toUpperCase();
|
|
const pathname = path.split("?")[0] ?? path;
|
|
for (const rule of RULES) {
|
|
if (!rule.methods.includes(m)) continue;
|
|
if (rule.match(pathname)) return rule.permission;
|
|
}
|
|
if (pathname.startsWith("/api/v1/")) return "cdn:dashboard:read";
|
|
return null;
|
|
}
|
|
|
|
// src/plugins/auth.ts
|
|
async function authPlugin(app2, opts) {
|
|
const { config: config2 } = opts;
|
|
if (config2.authRequired && (!config2.jwtSecret || config2.jwtSecret.length < 8)) {
|
|
throw new Error(
|
|
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true"
|
|
);
|
|
}
|
|
await app2.register(import("@fastify/jwt"), {
|
|
secret: config2.jwtSecret,
|
|
...config2.authRequired ? {
|
|
verify: {
|
|
allowedIss: [config2.authIssuer]
|
|
}
|
|
} : {}
|
|
});
|
|
app2.decorate("config", config2);
|
|
if (config2.authRequired) {
|
|
app2.log.info(
|
|
{ issuer: config2.authIssuer, portal: config2.authPortalUrl },
|
|
"AUTH_REQUIRED=true \u2014 portal JWT middleware enabled"
|
|
);
|
|
} else {
|
|
app2.log.info("AUTH_REQUIRED=false \u2014 local JWT / open protected routes with requireAuth");
|
|
}
|
|
}
|
|
async function requireAuth(request, reply) {
|
|
const config2 = request.server.config;
|
|
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();
|
|
}
|
|
if (!config2.authRequired) {
|
|
return;
|
|
}
|
|
const payload = request.user;
|
|
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
|
|
const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : [];
|
|
if (!apps.includes("cdn")) {
|
|
throw AppError.forbidden("\u041D\u0435\u0442 \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u043A \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044E CDN Manager");
|
|
}
|
|
request.authUser = {
|
|
id: String(payload.sub),
|
|
email: String(payload.email ?? ""),
|
|
name: String(payload.name ?? ""),
|
|
apps,
|
|
permissions,
|
|
isAdmin: Boolean(payload.is_admin)
|
|
};
|
|
const required = permissionForRequest(request.method, request.url);
|
|
if (required && !hasPermission(permissions, required)) {
|
|
throw AppError.forbidden(`\u041D\u0435\u0434\u043E\u0441\u0442\u0430\u0442\u043E\u0447\u043D\u043E \u043F\u0440\u0430\u0432: ${required}`);
|
|
}
|
|
}
|
|
var auth_default = fp(authPlugin, { name: "auth" });
|
|
|
|
// src/plugins/cors.ts
|
|
import fp2 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 = fp2(corsPlugin, { name: "cors" });
|
|
|
|
// src/plugins/cf-client.ts
|
|
import fp3 from "fastify-plugin";
|
|
|
|
// src/lib/cloudflare/http.ts
|
|
var CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
function parseRetryAfter(headers) {
|
|
const value = headers.get("retry-after");
|
|
if (!value) return null;
|
|
const seconds = Number(value);
|
|
return Number.isFinite(seconds) ? seconds * 1e3 : null;
|
|
}
|
|
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 mapCloudflareFailure(operation, status, message) {
|
|
const lower = message.toLowerCase();
|
|
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
|
return AppError.cloudflareAuthFailed(
|
|
"Cloudflare \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u0442\u043E\u043A\u0435\u043D. \u041F\u0440\u043E\u0432\u0435\u0440\u044C\u0442\u0435 CLOUDFLARE_API_TOKEN."
|
|
);
|
|
}
|
|
if (status === 429 || lower.includes("rate limit")) {
|
|
return AppError.rateLimited();
|
|
}
|
|
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
|
return AppError.zoneNotFound();
|
|
}
|
|
if (operation.includes("dns") || operation.includes("dns_record")) {
|
|
return AppError.dnsUpdateFailed(
|
|
`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C DNS \u0432 Cloudflare: ${message}`
|
|
);
|
|
}
|
|
return AppError.cloudflare(`${operation}: ${message}`);
|
|
}
|
|
async function handleCfResponse(response, operation) {
|
|
if (response.status === 429) {
|
|
const wait = parseRetryAfter(response.headers) ?? 5e3;
|
|
throw AppError.rateLimited(
|
|
`Cloudflare \u0432\u0440\u0435\u043C\u0435\u043D\u043D\u043E \u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0438\u043B \u0437\u0430\u043F\u0440\u043E\u0441\u044B. \u041F\u043E\u0432\u0442\u043E\u0440\u0438\u0442\u0435 \u0447\u0435\u0440\u0435\u0437 ${Math.ceil(wait / 1e3)} \u0441.`
|
|
);
|
|
}
|
|
const body = await response.json();
|
|
if (!body.success) {
|
|
const msg = body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
|
throw mapCloudflareFailure(operation, response.status, msg);
|
|
}
|
|
if (body.result === void 0) {
|
|
throw mapCloudflareFailure(operation, response.status, "empty result");
|
|
}
|
|
return body.result;
|
|
}
|
|
|
|
// src/lib/cloudflare/dns-service.ts
|
|
function createDnsAdapter(token) {
|
|
return {
|
|
async listDnsRecords(zoneId) {
|
|
return withRetry(async () => {
|
|
const all = [];
|
|
let page = 1;
|
|
while (page <= 50) {
|
|
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
|
url.searchParams.set("per_page", "100");
|
|
url.searchParams.set("page", String(page));
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure(
|
|
"list_dns_records",
|
|
response.status,
|
|
String(response.status)
|
|
);
|
|
}
|
|
const batch = await handleCfResponse(
|
|
response,
|
|
"list_dns_records"
|
|
);
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
page += 1;
|
|
}
|
|
return all;
|
|
});
|
|
},
|
|
async createDnsRecord(zoneId, payload) {
|
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
return handleCfResponse(response, "create_dns_record");
|
|
},
|
|
async updateDnsRecord(zoneId, recordId, payload) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "update_dns_record");
|
|
},
|
|
async patchDnsRecord(zoneId, recordId, payload) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
return handleCfResponse(response, "patch_dns_record");
|
|
},
|
|
async deleteDnsRecord(zoneId, recordId) {
|
|
const response = await fetch(
|
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
}
|
|
);
|
|
await handleCfResponse(response, "delete_dns_record");
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cloudflare/zone-service.ts
|
|
function createZoneAdapter(token) {
|
|
return {
|
|
async listZones() {
|
|
return withRetry(async () => {
|
|
const all = [];
|
|
let page = 1;
|
|
while (true) {
|
|
const url = new URL(`${CF_API_BASE}/zones`);
|
|
url.searchParams.set("per_page", "50");
|
|
url.searchParams.set("page", String(page));
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (response.status >= 500 || response.status === 429) {
|
|
throw mapCloudflareFailure(
|
|
"list_zones",
|
|
response.status,
|
|
String(response.status)
|
|
);
|
|
}
|
|
const batch = await handleCfResponse(response, "list_zones");
|
|
if (batch.length === 0) break;
|
|
all.push(...batch);
|
|
if (batch.length < 50) break;
|
|
page += 1;
|
|
}
|
|
return all;
|
|
});
|
|
},
|
|
async getZone(zoneId) {
|
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
return handleCfResponse(response, "get_zone");
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/lib/cf-client.ts
|
|
var CloudflareClient = class {
|
|
zones;
|
|
dns;
|
|
token;
|
|
constructor(token) {
|
|
this.token = token.trim();
|
|
this.zones = createZoneAdapter(this.token);
|
|
this.dns = createDnsAdapter(this.token);
|
|
}
|
|
get isConfigured() {
|
|
return this.token.length > 0;
|
|
}
|
|
listZones() {
|
|
return this.zones.listZones();
|
|
}
|
|
getZone(zoneId) {
|
|
return this.zones.getZone(zoneId);
|
|
}
|
|
listDnsRecords(zoneId) {
|
|
return this.dns.listDnsRecords(zoneId);
|
|
}
|
|
createDnsRecord(zoneId, payload) {
|
|
return this.dns.createDnsRecord(zoneId, payload);
|
|
}
|
|
updateDnsRecord(zoneId, recordId, payload) {
|
|
return this.dns.updateDnsRecord(zoneId, recordId, payload);
|
|
}
|
|
patchDnsRecord(zoneId, recordId, payload) {
|
|
return this.dns.patchDnsRecord(zoneId, recordId, payload);
|
|
}
|
|
deleteDnsRecord(zoneId, recordId) {
|
|
return this.dns.deleteDnsRecord(zoneId, recordId);
|
|
}
|
|
};
|
|
|
|
// src/plugins/cf-client.ts
|
|
var cfClientPlugin = async (app2, opts) => {
|
|
const cf = new CloudflareClient(opts.config.cloudflareApiToken);
|
|
app2.decorate("cf", cf);
|
|
};
|
|
var cf_client_default = fp3(cfClientPlugin, { name: "cf-client" });
|
|
|
|
// src/plugins/db.ts
|
|
import fp4 from "fastify-plugin";
|
|
import {
|
|
createDb,
|
|
createMemoryDb,
|
|
healthCheck,
|
|
runMigrations
|
|
} from "@cdnmanager/db";
|
|
async function dbPlugin(app2, opts) {
|
|
const { db, sqlite } = opts.memory ? createMemoryDb() : createDb(opts.config.databaseUrl);
|
|
runMigrations(sqlite);
|
|
app2.decorate("db", db);
|
|
app2.decorate("sqlite", sqlite);
|
|
app2.addHook("onClose", async () => {
|
|
sqlite.close();
|
|
});
|
|
}
|
|
var db_default = fp4(dbPlugin, { name: "db" });
|
|
|
|
// src/plugins/error-handler.ts
|
|
import fp5 from "fastify-plugin";
|
|
async function errorHandlerPlugin(app2) {
|
|
app2.setErrorHandler((err, _request, reply) => {
|
|
if (reply.sent) return;
|
|
const appErr = err.statusCode === 401 ? AppError.unauthorized() : toAppError(err);
|
|
reply.status(appErr.statusCode).send(errorBody(appErr));
|
|
});
|
|
}
|
|
var error_handler_default = fp5(errorHandlerPlugin, { name: "error-handler" });
|
|
|
|
// src/routes/health.ts
|
|
import { z } from "zod";
|
|
|
|
// src/services/auth.ts
|
|
import { verify } from "@node-rs/argon2";
|
|
async function verifyPassword(config2, password) {
|
|
if (config2.adminPasswordHash === "devplaceholder") {
|
|
if (password === "admin") return;
|
|
throw AppError.unauthorized();
|
|
}
|
|
const ok = await verify(config2.adminPasswordHash, password);
|
|
if (!ok) throw AppError.unauthorized();
|
|
}
|
|
async function login(config2, sign, req) {
|
|
if (req.username !== config2.adminUsername) {
|
|
throw AppError.unauthorized();
|
|
}
|
|
await verifyPassword(config2, req.password);
|
|
const expiresAt = new Date(
|
|
Date.now() + config2.jwtTtlHours * 60 * 60 * 1e3
|
|
);
|
|
const token = sign({
|
|
sub: req.username,
|
|
exp: Math.floor(expiresAt.getTime() / 1e3)
|
|
});
|
|
return {
|
|
token,
|
|
expires_at: expiresAt.toISOString()
|
|
};
|
|
}
|
|
|
|
// src/routes/health.ts
|
|
async function healthRoutes(app2) {
|
|
app2.get("/health", async (request) => {
|
|
healthCheck(request.server.sqlite);
|
|
return { status: "ok" };
|
|
});
|
|
app2.get("/ready", async (request) => {
|
|
healthCheck(request.server.sqlite);
|
|
return {
|
|
status: "ready",
|
|
database: true
|
|
};
|
|
});
|
|
}
|
|
async function authRoutes(app2) {
|
|
app2.get("/auth/config", async (request) => {
|
|
const { config: config2 } = request.server;
|
|
return {
|
|
required: config2.authRequired,
|
|
portal_url: config2.authPortalUrl
|
|
};
|
|
});
|
|
const loginSchema = z.object({
|
|
username: z.string(),
|
|
password: z.string()
|
|
});
|
|
app2.post("/auth/login", async (request, reply) => {
|
|
if (request.server.config.authRequired) {
|
|
return reply.code(403).send({
|
|
error: {
|
|
code: "FORBIDDEN",
|
|
message: "\u041B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0432\u0445\u043E\u0434 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439\u0442\u0435 auth-portal"
|
|
}
|
|
});
|
|
}
|
|
const body = loginSchema.parse(request.body);
|
|
const result = await login(
|
|
request.server.config,
|
|
(payload) => request.server.jwt.sign(payload),
|
|
body
|
|
);
|
|
return result;
|
|
});
|
|
}
|
|
|
|
// src/routes/settings.ts
|
|
import { appSettingsPatchSchema } from "@cdnmanager/shared";
|
|
import { getAppSettings, updateAppSettings } from "@cdnmanager/db";
|
|
async function settingsRoutes(app2) {
|
|
app2.get("/settings", async (request) => {
|
|
const settings = getAppSettings(request.server.db);
|
|
return {
|
|
...settings,
|
|
cloudflareConfigured: request.server.cf.isConfigured
|
|
};
|
|
});
|
|
app2.patch("/settings", async (request) => {
|
|
const parsed = appSettingsPatchSchema.safeParse(request.body);
|
|
if (!parsed.success) {
|
|
throw AppError.validation(
|
|
parsed.error.issues[0]?.message ?? "\u043D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0435 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438"
|
|
);
|
|
}
|
|
const settings = updateAppSettings(request.server.db, parsed.data);
|
|
return {
|
|
...settings,
|
|
cloudflareConfigured: request.server.cf.isConfigured
|
|
};
|
|
});
|
|
}
|
|
|
|
// src/routes/fleet.ts
|
|
import {
|
|
aliasCreateSchema,
|
|
aliasPatchSchema,
|
|
aliasRetargetSchema,
|
|
nodeCreateSchema,
|
|
nodePatchSchema,
|
|
orphanIgnoreSchema,
|
|
syncApplySchema,
|
|
zoneCreateSchema,
|
|
zonePatchSchema
|
|
} from "@cdnmanager/shared";
|
|
import {
|
|
createAlias,
|
|
createNode,
|
|
createZone,
|
|
dashboardCounts,
|
|
deleteAlias,
|
|
deleteNode,
|
|
deleteZone,
|
|
getAlias as getAlias2,
|
|
getNode as getNode2,
|
|
getZone as getZone2,
|
|
ignoreOrphan,
|
|
listAliases as listAliases2,
|
|
listIgnoredOrphans as listIgnoredOrphans2,
|
|
listLocations,
|
|
listNodes as listNodes2,
|
|
listSyncJobs,
|
|
listZones,
|
|
unignoreOrphan,
|
|
updateAlias as updateAlias2,
|
|
updateNode as updateNode2,
|
|
updateZone as updateZone2
|
|
} from "@cdnmanager/db";
|
|
|
|
// src/services/naming.ts
|
|
function buildHostname(opts) {
|
|
const nn = String(opts.indexNum).padStart(2, "0");
|
|
const template = opts.template ?? "{loc}-{role}{nn}.{zone}";
|
|
let host = template.replaceAll("{loc}", opts.locationCode.toLowerCase()).replaceAll("{role}", opts.role.toLowerCase()).replaceAll("{nn}", nn).replaceAll("{zone}", opts.zoneName.toLowerCase());
|
|
if (opts.providerTag) {
|
|
host = host.replaceAll("{provider}", opts.providerTag.toLowerCase());
|
|
} else {
|
|
host = host.replaceAll("-{provider}", "").replaceAll("{provider}", "");
|
|
}
|
|
return host.replace(/\.$/, "");
|
|
}
|
|
function normalizeFqdn(name) {
|
|
return name.trim().toLowerCase().replace(/\.$/, "");
|
|
}
|
|
function isValidIpv4(ip) {
|
|
const parts = ip.split(".");
|
|
if (parts.length !== 4) return false;
|
|
return parts.every((p) => {
|
|
const n = Number(p);
|
|
return Number.isInteger(n) && n >= 0 && n <= 255 && String(n) === p;
|
|
});
|
|
}
|
|
function isValidIpv6(ip) {
|
|
return /^[0-9a-f:]+$/i.test(ip) && ip.includes(":");
|
|
}
|
|
|
|
// src/services/sync.ts
|
|
import { randomUUID } from "crypto";
|
|
import {
|
|
getAlias,
|
|
getNode,
|
|
getZone,
|
|
listAliases,
|
|
listIgnoredOrphans,
|
|
listNodes,
|
|
updateAlias,
|
|
updateNode,
|
|
updateZone,
|
|
createSyncJob,
|
|
updateSyncJob,
|
|
getSyncJob,
|
|
addSyncEvent
|
|
} from "@cdnmanager/db";
|
|
function opId() {
|
|
return `op-${randomUUID().slice(0, 8)}`;
|
|
}
|
|
function findObserved(records, type, name) {
|
|
const n = normalizeFqdn(name);
|
|
return records.find(
|
|
(r) => r.type === type && normalizeFqdn(r.name) === n
|
|
);
|
|
}
|
|
async function buildZoneDiff(db, cf, zoneId) {
|
|
const zone = getZone(db, zoneId);
|
|
if (!zone.cfZoneId) {
|
|
throw AppError.validation("\u0423 \u0437\u043E\u043D\u044B \u043D\u0435 \u0437\u0430\u0434\u0430\u043D cfZoneId Cloudflare");
|
|
}
|
|
if (!cf.isConfigured) {
|
|
throw AppError.cloudflareAuthFailed(
|
|
"CLOUDFLARE_API_TOKEN \u043D\u0435 \u0437\u0430\u0434\u0430\u043D. \u0414\u043E\u0431\u0430\u0432\u044C\u0442\u0435 \u0442\u043E\u043A\u0435\u043D \u0432 \u043E\u043A\u0440\u0443\u0436\u0435\u043D\u0438\u0435 API."
|
|
);
|
|
}
|
|
const observed = await cf.listDnsRecords(zone.cfZoneId);
|
|
const nodeList = listNodes(db, { zoneId });
|
|
const aliasList = listAliases(db, { zoneId });
|
|
const ignored = new Set(
|
|
listIgnoredOrphans(db, zoneId).map(
|
|
(o) => `${o.recordType}:${normalizeFqdn(o.recordName)}`
|
|
)
|
|
);
|
|
const ops = [];
|
|
const managedKeys = /* @__PURE__ */ new Set();
|
|
for (const node of nodeList) {
|
|
const ttl = zone.defaultTtl;
|
|
const v4 = node.addresses.find((a) => a.family === "v4");
|
|
const v6 = node.addresses.find((a) => a.family === "v6");
|
|
if (v4) {
|
|
const key = `A:${normalizeFqdn(node.hostname)}`;
|
|
managedKeys.add(key);
|
|
const obs = findObserved(observed, "A", node.hostname);
|
|
const desired = {
|
|
type: "A",
|
|
name: node.hostname,
|
|
content: v4.ip,
|
|
ttl,
|
|
proxied: false
|
|
};
|
|
if (!obs) {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: "create",
|
|
entityType: "node_a",
|
|
entityId: node.id,
|
|
recordName: node.hostname,
|
|
recordType: "A",
|
|
desired,
|
|
observed: null,
|
|
detail: "A-\u0437\u0430\u043F\u0438\u0441\u044C \u043E\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0432 Cloudflare"
|
|
});
|
|
} else if (obs.content !== v4.ip || obs.proxied === true || obs.ttl !== 1 && obs.ttl !== ttl) {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: obs.proxied === true ? "proxy_violation" : "update",
|
|
entityType: "node_a",
|
|
entityId: node.id,
|
|
recordName: node.hostname,
|
|
recordType: "A",
|
|
desired,
|
|
observed: {
|
|
id: obs.id,
|
|
type: obs.type,
|
|
name: obs.name,
|
|
content: obs.content,
|
|
ttl: obs.ttl,
|
|
proxied: obs.proxied ?? false
|
|
},
|
|
detail: obs.proxied === true ? "Proxy \u0432\u043A\u043B\u044E\u0447\u0451\u043D \u2014 \u0434\u043B\u044F \u0442\u0443\u043D\u043D\u0435\u043B\u0435\u0439 \u043D\u0443\u0436\u0435\u043D DNS-only" : "\u0421\u043E\u0434\u0435\u0440\u0436\u0438\u043C\u043E\u0435/TTL \u043E\u0442\u043B\u0438\u0447\u0430\u0435\u0442\u0441\u044F \u043E\u0442 desired"
|
|
});
|
|
} else {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: "noop",
|
|
entityType: "node_a",
|
|
entityId: node.id,
|
|
recordName: node.hostname,
|
|
recordType: "A",
|
|
desired,
|
|
observed: { id: obs.id, content: obs.content }
|
|
});
|
|
}
|
|
}
|
|
if (v6) {
|
|
const key = `AAAA:${normalizeFqdn(node.hostname)}`;
|
|
managedKeys.add(key);
|
|
const obs = findObserved(observed, "AAAA", node.hostname);
|
|
const desired = {
|
|
type: "AAAA",
|
|
name: node.hostname,
|
|
content: v6.ip,
|
|
ttl,
|
|
proxied: false
|
|
};
|
|
if (!obs) {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: "create",
|
|
entityType: "node_aaaa",
|
|
entityId: node.id,
|
|
recordName: node.hostname,
|
|
recordType: "AAAA",
|
|
desired,
|
|
observed: null
|
|
});
|
|
} else if (obs.content !== v6.ip || obs.proxied === true) {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: obs.proxied === true ? "proxy_violation" : "update",
|
|
entityType: "node_aaaa",
|
|
entityId: node.id,
|
|
recordName: node.hostname,
|
|
recordType: "AAAA",
|
|
desired,
|
|
observed: {
|
|
id: obs.id,
|
|
content: obs.content,
|
|
proxied: obs.proxied ?? false
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const alias of aliasList) {
|
|
const target = getNode(db, alias.targetNodeId);
|
|
const key = `CNAME:${normalizeFqdn(alias.name)}`;
|
|
managedKeys.add(key);
|
|
const obs = findObserved(observed, "CNAME", alias.name);
|
|
const desired = {
|
|
type: "CNAME",
|
|
name: alias.name,
|
|
content: target.hostname,
|
|
ttl: zone.defaultTtl,
|
|
proxied: false
|
|
};
|
|
if (!obs) {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: "create",
|
|
entityType: "alias",
|
|
entityId: alias.id,
|
|
recordName: alias.name,
|
|
recordType: "CNAME",
|
|
desired,
|
|
observed: null
|
|
});
|
|
} else if (normalizeFqdn(obs.content) !== normalizeFqdn(target.hostname) || obs.proxied === true) {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: obs.proxied === true ? "proxy_violation" : "update",
|
|
entityType: "alias",
|
|
entityId: alias.id,
|
|
recordName: alias.name,
|
|
recordType: "CNAME",
|
|
desired,
|
|
observed: {
|
|
id: obs.id,
|
|
content: obs.content,
|
|
proxied: obs.proxied ?? false
|
|
}
|
|
});
|
|
} else {
|
|
ops.push({
|
|
id: opId(),
|
|
kind: "noop",
|
|
entityType: "alias",
|
|
entityId: alias.id,
|
|
recordName: alias.name,
|
|
recordType: "CNAME",
|
|
desired,
|
|
observed: { id: obs.id, content: obs.content }
|
|
});
|
|
}
|
|
}
|
|
for (const rec of observed) {
|
|
if (!["A", "AAAA", "CNAME"].includes(rec.type)) continue;
|
|
const key = `${rec.type}:${normalizeFqdn(rec.name)}`;
|
|
if (managedKeys.has(key)) continue;
|
|
if (ignored.has(key)) continue;
|
|
ops.push({
|
|
id: opId(),
|
|
kind: "orphan",
|
|
entityType: "orphan",
|
|
entityId: null,
|
|
recordName: rec.name,
|
|
recordType: rec.type,
|
|
desired: null,
|
|
observed: {
|
|
id: rec.id,
|
|
type: rec.type,
|
|
name: rec.name,
|
|
content: rec.content,
|
|
proxied: rec.proxied ?? false
|
|
},
|
|
detail: "\u0417\u0430\u043F\u0438\u0441\u044C \u0432 Cloudflare \u0432\u043D\u0435 inventory"
|
|
});
|
|
}
|
|
return ops;
|
|
}
|
|
async function syncZonePull(db, cf, zoneId) {
|
|
const jobId = createSyncJob(db, zoneId);
|
|
updateSyncJob(db, jobId, { status: "running" });
|
|
try {
|
|
const diff = await buildZoneDiff(db, cf, zoneId);
|
|
for (const op of diff) {
|
|
if (op.kind === "noop") continue;
|
|
addSyncEvent(db, jobId, {
|
|
kind: op.kind,
|
|
recordName: op.recordName,
|
|
recordType: op.recordType,
|
|
detail: op.detail
|
|
});
|
|
}
|
|
for (const op of diff) {
|
|
if (!op.entityId) continue;
|
|
const status = op.kind === "noop" ? "ok" : op.kind === "create" ? "missing" : op.kind === "proxy_violation" || op.kind === "update" ? "drift" : "error";
|
|
if (op.entityType === "alias") {
|
|
updateAlias(db, op.entityId, {
|
|
syncStatus: status,
|
|
cfRecordId: op.observed?.id ?? getAlias(db, op.entityId).cfRecordId
|
|
});
|
|
} else if (op.entityType === "node_a") {
|
|
updateNode(db, op.entityId, {
|
|
syncStatus: status === "ok" ? status : status,
|
|
cfARecordId: op.observed?.id ?? getNode(db, op.entityId).cfARecordId
|
|
});
|
|
} else if (op.entityType === "node_aaaa") {
|
|
updateNode(db, op.entityId, {
|
|
cfAaaaRecordId: op.observed?.id ?? getNode(db, op.entityId).cfAaaaRecordId,
|
|
syncStatus: status
|
|
});
|
|
}
|
|
}
|
|
const byEntity = /* @__PURE__ */ new Map();
|
|
for (const op of diff) {
|
|
if (!op.entityId) continue;
|
|
const list = byEntity.get(op.entityId) ?? [];
|
|
list.push(op);
|
|
byEntity.set(op.entityId, list);
|
|
}
|
|
for (const [entityId, list] of byEntity) {
|
|
const actionable = list.filter((o) => o.kind !== "noop");
|
|
if (actionable.length === 0) {
|
|
const sample = list[0];
|
|
if (sample?.entityType === "alias") {
|
|
updateAlias(db, entityId, {
|
|
syncStatus: "ok",
|
|
cfRecordId: sample.observed?.id ?? void 0,
|
|
lastError: null
|
|
});
|
|
} else if (sample?.entityType.startsWith("node")) {
|
|
updateNode(db, entityId, {
|
|
syncStatus: "ok",
|
|
lastError: null
|
|
});
|
|
}
|
|
}
|
|
}
|
|
updateSyncJob(db, jobId, {
|
|
status: "done",
|
|
diffJson: JSON.stringify(diff),
|
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
});
|
|
updateZone(db, zoneId, {
|
|
lastSyncAt: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
});
|
|
return getSyncJob(db, jobId);
|
|
} catch (err) {
|
|
updateSyncJob(db, jobId, {
|
|
status: "failed",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
async function applyZoneDiff(db, cf, zoneId, opIds) {
|
|
const zone = getZone(db, zoneId);
|
|
if (!zone.cfZoneId) {
|
|
throw AppError.validation("\u0423 \u0437\u043E\u043D\u044B \u043D\u0435 \u0437\u0430\u0434\u0430\u043D cfZoneId Cloudflare");
|
|
}
|
|
const diff = await buildZoneDiff(db, cf, zoneId);
|
|
const selected = opIds?.length ? diff.filter((o) => opIds.includes(o.id)) : diff.filter(
|
|
(o) => ["create", "update", "proxy_violation", "delete"].includes(o.kind)
|
|
);
|
|
const jobId = createSyncJob(db, zoneId);
|
|
updateSyncJob(db, jobId, { status: "running" });
|
|
try {
|
|
for (const op of selected) {
|
|
if (op.kind === "orphan" || op.kind === "noop") continue;
|
|
if (!op.desired) continue;
|
|
const payload = {
|
|
type: String(op.desired.type),
|
|
name: String(op.desired.name),
|
|
content: String(op.desired.content),
|
|
ttl: Number(op.desired.ttl ?? zone.defaultTtl),
|
|
proxied: false
|
|
};
|
|
if (op.kind === "create") {
|
|
const created = await cf.createDnsRecord(zone.cfZoneId, payload);
|
|
if (op.entityType === "alias" && op.entityId) {
|
|
updateAlias(db, op.entityId, {
|
|
syncStatus: "ok",
|
|
cfRecordId: created.id ?? null,
|
|
lastError: null
|
|
});
|
|
} else if (op.entityType === "node_a" && op.entityId) {
|
|
updateNode(db, op.entityId, {
|
|
syncStatus: "ok",
|
|
cfARecordId: created.id ?? null,
|
|
lastError: null
|
|
});
|
|
} else if (op.entityType === "node_aaaa" && op.entityId) {
|
|
updateNode(db, op.entityId, {
|
|
syncStatus: "ok",
|
|
cfAaaaRecordId: created.id ?? null,
|
|
lastError: null
|
|
});
|
|
}
|
|
} else if ((op.kind === "update" || op.kind === "proxy_violation") && op.observed?.id) {
|
|
await cf.updateDnsRecord(
|
|
zone.cfZoneId,
|
|
String(op.observed.id),
|
|
payload
|
|
);
|
|
if (op.entityType === "alias" && op.entityId) {
|
|
updateAlias(db, op.entityId, {
|
|
syncStatus: "ok",
|
|
cfRecordId: String(op.observed.id),
|
|
lastError: null
|
|
});
|
|
} else if (op.entityType === "node_a" && op.entityId) {
|
|
updateNode(db, op.entityId, {
|
|
syncStatus: "ok",
|
|
cfARecordId: String(op.observed.id),
|
|
lastError: null
|
|
});
|
|
} else if (op.entityType === "node_aaaa" && op.entityId) {
|
|
updateNode(db, op.entityId, {
|
|
syncStatus: "ok",
|
|
cfAaaaRecordId: String(op.observed.id),
|
|
lastError: null
|
|
});
|
|
}
|
|
} else if (op.kind === "delete" && op.observed?.id) {
|
|
await cf.deleteDnsRecord(zone.cfZoneId, String(op.observed.id));
|
|
}
|
|
addSyncEvent(db, jobId, {
|
|
kind: `applied_${op.kind}`,
|
|
recordName: op.recordName,
|
|
recordType: op.recordType
|
|
});
|
|
}
|
|
updateSyncJob(db, jobId, {
|
|
status: "done",
|
|
diffJson: JSON.stringify(selected),
|
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
});
|
|
updateZone(db, zoneId, {
|
|
lastSyncAt: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
});
|
|
return getSyncJob(db, jobId);
|
|
} catch (err) {
|
|
updateSyncJob(db, jobId, {
|
|
status: "failed",
|
|
error: err instanceof Error ? err.message : String(err),
|
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
async function retargetAlias(db, cf, aliasId, targetNodeId) {
|
|
const alias = updateAlias(db, aliasId, { targetNodeId });
|
|
const zone = getZone(db, alias.zoneId);
|
|
const target = getNode(db, targetNodeId);
|
|
if (cf.isConfigured && zone.cfZoneId) {
|
|
const payload = {
|
|
type: "CNAME",
|
|
name: alias.name,
|
|
content: target.hostname,
|
|
ttl: zone.defaultTtl,
|
|
proxied: false
|
|
};
|
|
if (alias.cfRecordId) {
|
|
await cf.patchDnsRecord(zone.cfZoneId, alias.cfRecordId, {
|
|
content: target.hostname,
|
|
proxied: false
|
|
});
|
|
updateAlias(db, aliasId, { syncStatus: "ok", lastError: null });
|
|
} else {
|
|
const created = await cf.createDnsRecord(zone.cfZoneId, payload);
|
|
updateAlias(db, aliasId, {
|
|
syncStatus: "ok",
|
|
cfRecordId: created.id ?? null,
|
|
lastError: null
|
|
});
|
|
}
|
|
} else {
|
|
updateAlias(db, aliasId, { syncStatus: "pending" });
|
|
}
|
|
return getAlias(db, aliasId);
|
|
}
|
|
function exportBindZone(db, zoneId) {
|
|
const zone = getZone(db, zoneId);
|
|
const nodeList = listNodes(db, { zoneId });
|
|
const aliasList = listAliases(db, { zoneId });
|
|
const lines = [
|
|
`;; CDNManager export \u2014 ${zone.name}`,
|
|
`;; TTL default ${zone.defaultTtl}; all records DNS-only (proxied:false)`,
|
|
"",
|
|
";; Canonical A/AAAA"
|
|
];
|
|
for (const node of nodeList) {
|
|
const v4 = node.addresses.find((a) => a.family === "v4");
|
|
const v6 = node.addresses.find((a) => a.family === "v6");
|
|
if (v4) {
|
|
lines.push(
|
|
`${node.hostname}. ${zone.defaultTtl} IN A ${v4.ip} ; cf_tags=cf-proxied:false`
|
|
);
|
|
}
|
|
if (v6) {
|
|
lines.push(
|
|
`${node.hostname}. ${zone.defaultTtl} IN AAAA ${v6.ip} ; cf_tags=cf-proxied:false`
|
|
);
|
|
}
|
|
}
|
|
lines.push("", ";; Service CNAME aliases");
|
|
for (const alias of aliasList) {
|
|
lines.push(
|
|
`${alias.name}. ${zone.defaultTtl} IN CNAME ${alias.targetHostname}. ; purpose=${alias.purpose}`
|
|
);
|
|
}
|
|
lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// src/routes/fleet.ts
|
|
var fleetRoutes = async (app2) => {
|
|
app2.get("/locations", async () => listLocations(app2.db));
|
|
app2.get("/zones", async () => listZones(app2.db));
|
|
app2.post("/zones", async (req) => {
|
|
const body = zoneCreateSchema.parse(req.body);
|
|
return createZone(app2.db, body);
|
|
});
|
|
app2.get(
|
|
"/zones/:zoneId",
|
|
async (req) => getZone2(app2.db, req.params.zoneId)
|
|
);
|
|
app2.patch("/zones/:zoneId", async (req) => {
|
|
const body = zonePatchSchema.parse(req.body);
|
|
return updateZone2(app2.db, req.params.zoneId, body);
|
|
});
|
|
app2.delete("/zones/:zoneId", async (req) => {
|
|
deleteZone(app2.db, req.params.zoneId);
|
|
return { ok: true };
|
|
});
|
|
app2.get("/cloudflare/zones", async () => {
|
|
if (!app2.cf.isConfigured) {
|
|
throw AppError.cloudflareAuthFailed("CLOUDFLARE_API_TOKEN \u043D\u0435 \u0437\u0430\u0434\u0430\u043D");
|
|
}
|
|
return app2.cf.listZones();
|
|
});
|
|
app2.post(
|
|
"/zones/:zoneId/sync",
|
|
async (req) => syncZonePull(app2.db, app2.cf, req.params.zoneId)
|
|
);
|
|
app2.post(
|
|
"/zones/:zoneId/apply",
|
|
async (req) => {
|
|
const body = syncApplySchema.parse(req.body ?? {});
|
|
return applyZoneDiff(app2.db, app2.cf, req.params.zoneId, body.opIds);
|
|
}
|
|
);
|
|
app2.get(
|
|
"/zones/:zoneId/sync-jobs",
|
|
async (req) => listSyncJobs(app2.db, req.params.zoneId)
|
|
);
|
|
app2.get(
|
|
"/zones/:zoneId/export/bind",
|
|
async (req) => ({
|
|
zoneName: getZone2(app2.db, req.params.zoneId).name,
|
|
content: exportBindZone(app2.db, req.params.zoneId)
|
|
})
|
|
);
|
|
app2.get(
|
|
"/zones/:zoneId/orphans/ignored",
|
|
async (req) => listIgnoredOrphans2(app2.db, req.params.zoneId)
|
|
);
|
|
app2.post(
|
|
"/zones/:zoneId/orphans/ignore",
|
|
async (req) => {
|
|
const body = orphanIgnoreSchema.parse(req.body);
|
|
ignoreOrphan(
|
|
app2.db,
|
|
req.params.zoneId,
|
|
normalizeFqdn(body.recordName),
|
|
body.recordType.toUpperCase()
|
|
);
|
|
return { ok: true };
|
|
}
|
|
);
|
|
app2.post(
|
|
"/zones/:zoneId/orphans/unignore",
|
|
async (req) => {
|
|
const body = orphanIgnoreSchema.parse(req.body);
|
|
unignoreOrphan(
|
|
app2.db,
|
|
req.params.zoneId,
|
|
normalizeFqdn(body.recordName),
|
|
body.recordType.toUpperCase()
|
|
);
|
|
return { ok: true };
|
|
}
|
|
);
|
|
app2.get("/nodes", async (req) => {
|
|
const q = req.query;
|
|
return listNodes2(app2.db, {
|
|
zoneId: q.zoneId,
|
|
locationId: q.locationId,
|
|
role: q.role,
|
|
syncStatus: q.syncStatus,
|
|
q: q.q
|
|
});
|
|
});
|
|
app2.post("/nodes", async (req) => {
|
|
const body = nodeCreateSchema.parse(req.body);
|
|
if (!isValidIpv4(body.ipv4)) throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4");
|
|
if (body.ipv6 && !isValidIpv6(body.ipv6)) {
|
|
throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv6");
|
|
}
|
|
const zone = getZone2(app2.db, body.zoneId);
|
|
const loc = listLocations(app2.db).find((l) => l.id === body.locationId);
|
|
if (!loc) throw AppError.notFound("location not found");
|
|
const hostname = body.hostname?.trim() || buildHostname({
|
|
template: zone.namingTemplate,
|
|
locationCode: loc.code,
|
|
role: body.role,
|
|
indexNum: body.indexNum,
|
|
zoneName: zone.name,
|
|
providerTag: body.providerTag
|
|
});
|
|
return createNode(app2.db, {
|
|
zoneId: body.zoneId,
|
|
locationId: body.locationId,
|
|
hostname: normalizeFqdn(hostname),
|
|
role: body.role,
|
|
indexNum: body.indexNum,
|
|
providerTag: body.providerTag,
|
|
notes: body.notes,
|
|
ipv4: body.ipv4,
|
|
ipv6: body.ipv6
|
|
});
|
|
});
|
|
app2.get(
|
|
"/nodes/:nodeId",
|
|
async (req) => getNode2(app2.db, req.params.nodeId)
|
|
);
|
|
app2.patch("/nodes/:nodeId", async (req) => {
|
|
const body = nodePatchSchema.parse(req.body);
|
|
if (body.ipv4 && !isValidIpv4(body.ipv4)) {
|
|
throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv4");
|
|
}
|
|
if (body.ipv6 && !isValidIpv6(body.ipv6)) {
|
|
throw AppError.invalidIp("\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 IPv6");
|
|
}
|
|
return updateNode2(app2.db, req.params.nodeId, {
|
|
...body,
|
|
hostname: body.hostname ? normalizeFqdn(body.hostname) : void 0
|
|
});
|
|
});
|
|
app2.delete("/nodes/:nodeId", async (req) => {
|
|
deleteNode(app2.db, req.params.nodeId);
|
|
return { ok: true };
|
|
});
|
|
app2.get("/aliases", async (req) => {
|
|
const q = req.query;
|
|
return listAliases2(app2.db, {
|
|
zoneId: q.zoneId,
|
|
purpose: q.purpose,
|
|
syncStatus: q.syncStatus,
|
|
q: q.q
|
|
});
|
|
});
|
|
app2.post("/aliases", async (req) => {
|
|
const body = aliasCreateSchema.parse(req.body);
|
|
return createAlias(app2.db, {
|
|
zoneId: body.zoneId,
|
|
name: normalizeFqdn(body.name),
|
|
purpose: body.purpose,
|
|
mode: body.mode,
|
|
targetNodeId: body.targetNodeId
|
|
});
|
|
});
|
|
app2.get(
|
|
"/aliases/:aliasId",
|
|
async (req) => getAlias2(app2.db, req.params.aliasId)
|
|
);
|
|
app2.patch(
|
|
"/aliases/:aliasId",
|
|
async (req) => {
|
|
const body = aliasPatchSchema.parse(req.body);
|
|
return updateAlias2(app2.db, req.params.aliasId, {
|
|
...body,
|
|
name: body.name ? normalizeFqdn(body.name) : void 0
|
|
});
|
|
}
|
|
);
|
|
app2.post(
|
|
"/aliases/:aliasId/retarget",
|
|
async (req) => {
|
|
const body = aliasRetargetSchema.parse(req.body);
|
|
return retargetAlias(
|
|
app2.db,
|
|
app2.cf,
|
|
req.params.aliasId,
|
|
body.targetNodeId
|
|
);
|
|
}
|
|
);
|
|
app2.delete(
|
|
"/aliases/:aliasId",
|
|
async (req) => {
|
|
deleteAlias(app2.db, req.params.aliasId);
|
|
return { ok: true };
|
|
}
|
|
);
|
|
app2.get("/dashboard/stats", async () => {
|
|
const base = dashboardCounts(app2.db);
|
|
const nodes = listNodes2(app2.db);
|
|
const aliases = listAliases2(app2.db);
|
|
const proxyViolations = [...nodes, ...aliases].filter(
|
|
(x) => x.syncStatus === "drift" && (x.lastError ?? "").includes("Proxy")
|
|
).length;
|
|
const orphans = listZones(app2.db).length ? listSyncJobs(app2.db, listZones(app2.db)[0].id).flatMap((j) => j.diff ?? []).filter((o) => o.kind === "orphan").length : 0;
|
|
return {
|
|
nodes: base.nodes,
|
|
aliases: base.aliases,
|
|
syncOk: base.syncOk,
|
|
drift: base.drift,
|
|
proxyViolations,
|
|
orphans,
|
|
lastSyncAt: base.lastSyncAt,
|
|
nodesWithoutIp: base.nodesWithoutIp,
|
|
brokenAliases: base.brokenAliases
|
|
};
|
|
});
|
|
app2.get("/topology", async (req) => {
|
|
const q = req.query;
|
|
const zoneId = q.zoneId;
|
|
const locs = listLocations(app2.db);
|
|
const nodeList = listNodes2(app2.db, zoneId ? { zoneId } : {});
|
|
const aliasList = listAliases2(app2.db, zoneId ? { zoneId } : {});
|
|
return {
|
|
locations: locs,
|
|
nodes: nodeList.map((n) => ({
|
|
id: n.id,
|
|
hostname: n.hostname,
|
|
role: n.role,
|
|
locationCode: n.locationCode ?? "",
|
|
ipv4: n.addresses.find((a) => a.family === "v4")?.ip ?? null,
|
|
syncStatus: n.syncStatus
|
|
})),
|
|
edges: aliasList.map((a) => ({
|
|
id: a.id,
|
|
aliasName: a.name,
|
|
purpose: a.purpose,
|
|
fromNodeId: a.targetNodeId,
|
|
toHostname: a.targetHostname ?? ""
|
|
}))
|
|
};
|
|
});
|
|
app2.get("/naming/preview", async (req) => {
|
|
const q = req.query;
|
|
if (!q.zoneId || !q.locationId || !q.role) {
|
|
throw AppError.validation("zoneId, locationId, role \u043E\u0431\u044F\u0437\u0430\u0442\u0435\u043B\u044C\u043D\u044B");
|
|
}
|
|
const zone = getZone2(app2.db, q.zoneId);
|
|
const loc = listLocations(app2.db).find((l) => l.id === q.locationId);
|
|
if (!loc) throw AppError.notFound("location not found");
|
|
const indexNum = Number(q.indexNum ?? "1") || 1;
|
|
return {
|
|
hostname: buildHostname({
|
|
template: q.template || zone.namingTemplate,
|
|
locationCode: loc.code,
|
|
role: q.role,
|
|
indexNum,
|
|
zoneName: zone.name,
|
|
providerTag: q.providerTag
|
|
})
|
|
};
|
|
});
|
|
};
|
|
|
|
// src/app.ts
|
|
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(
|
|
async (protectedApi) => {
|
|
protectedApi.addHook("onRequest", requireAuth);
|
|
await protectedApi.register(settingsRoutes);
|
|
await protectedApi.register(fleetRoutes);
|
|
},
|
|
{ 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");
|
|
});
|
|
}
|
|
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();
|
|
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);
|
|
}
|