quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 1m1s
CD / quality (push) Successful in 2m30s
CD / publish (push) Successful in 2m4s
Сервис up, если жив хотя бы один IP. Failover показывает Down по FQDN и журнал add/remove A-записей. Co-authored-by: Cursor <cursoragent@cursor.com>
2628 lines
100 KiB
JavaScript
2628 lines
100 KiB
JavaScript
var __defProp = Object.defineProperty;
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
|
|
// src/schema.ts
|
|
import { sql } from "drizzle-orm";
|
|
import {
|
|
integer,
|
|
primaryKey,
|
|
sqliteTable,
|
|
text,
|
|
unique
|
|
} from "drizzle-orm/sqlite-core";
|
|
var groups = sqliteTable("groups", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull(),
|
|
slug: text("slug").notNull().unique(),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var services = sqliteTable("services", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull(),
|
|
slug: text("slug").notNull().unique(),
|
|
service_group_id: integer("service_group_id").references(
|
|
() => serviceGroups.id,
|
|
{ onDelete: "set null" }
|
|
),
|
|
subdomain: text("subdomain"),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
sort_order: integer("sort_order").notNull().default(0),
|
|
lb_weight: integer("lb_weight").notNull().default(1),
|
|
lb_priority: integer("lb_priority").notNull().default(1),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var serviceGroups = sqliteTable("service_groups", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull(),
|
|
type: text("type").notNull().default("custom"),
|
|
icon: text("icon"),
|
|
domain: text("domain"),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
lb_mode: text("lb_mode").notNull().default("round_robin"),
|
|
health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false),
|
|
health_check_type: text("health_check_type").notNull().default("tcp"),
|
|
health_check_port: integer("health_check_port"),
|
|
health_check_path: text("health_check_path"),
|
|
health_check_expected_status: integer("health_check_expected_status"),
|
|
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
|
|
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
|
|
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
|
|
health_check_provider: text("health_check_provider").notNull().default("local"),
|
|
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
|
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var domains = sqliteTable("domains", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
group_id: integer("group_id").references(() => groups.id, {
|
|
onDelete: "set null"
|
|
}),
|
|
zone_name: text("zone_name").notNull().unique(),
|
|
cf_zone_id: text("cf_zone_id").notNull(),
|
|
status: text("status").notNull().default("active"),
|
|
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
|
environment: text("environment"),
|
|
last_synced_at: text("last_synced_at"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var subdomains = sqliteTable("subdomains", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
|
|
name: text("name").notNull(),
|
|
fqdn: text("fqdn").notNull(),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var dnsRecords = sqliteTable("dns_records", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
|
|
cf_record_id: text("cf_record_id"),
|
|
record_type: text("record_type").notNull(),
|
|
name: text("name").notNull(),
|
|
content: text("content").notNull(),
|
|
ttl: integer("ttl").notNull().default(1),
|
|
proxied: integer("proxied", { mode: "boolean" }).notNull().default(false),
|
|
priority: integer("priority"),
|
|
sync_status: text("sync_status").notNull().default("pending_push"),
|
|
origin: text("origin").notNull().default("local"),
|
|
last_error: text("last_error"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var serviceBindings = sqliteTable(
|
|
"service_bindings",
|
|
{
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
|
|
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
|
hostname: text("hostname").notNull().default("@"),
|
|
cname_target: text("cname_target"),
|
|
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
|
|
onDelete: "set null"
|
|
}),
|
|
lb_mode: text("lb_mode").notNull().default("round_robin"),
|
|
health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false),
|
|
health_check_type: text("health_check_type").notNull().default("tcp"),
|
|
health_check_port: integer("health_check_port"),
|
|
health_check_path: text("health_check_path"),
|
|
health_check_expected_status: integer("health_check_expected_status"),
|
|
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
|
|
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
|
|
health_check_verify_tls: integer("health_check_verify_tls", {
|
|
mode: "boolean"
|
|
}).notNull().default(false),
|
|
health_check_provider: text("health_check_provider").notNull().default("local"),
|
|
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
|
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
|
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
|
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
|
operation_version: integer("operation_version").notNull().default(0),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
},
|
|
(table) => [
|
|
unique("service_bindings_domain_service_hostname").on(
|
|
table.domain_id,
|
|
table.service_id,
|
|
table.hostname
|
|
)
|
|
]
|
|
);
|
|
var healthChecks = sqliteTable("health_checks", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
provider: text("provider").notNull().default("local"),
|
|
cf_healthcheck_id: text("cf_healthcheck_id"),
|
|
cf_zone_id: text("cf_zone_id"),
|
|
name: text("name").notNull(),
|
|
protocol: text("protocol").notNull().default("tcp"),
|
|
path: text("path"),
|
|
method: text("method"),
|
|
timeout: integer("timeout").notNull().default(5),
|
|
interval_sec: integer("interval_sec").notNull().default(30),
|
|
retries: integer("retries").notNull().default(2),
|
|
expected_status: integer("expected_status"),
|
|
consecutive_fails: integer("consecutive_fails").notNull().default(2),
|
|
consecutive_successes: integer("consecutive_successes").notNull().default(2),
|
|
suspended: integer("suspended", { mode: "boolean" }).notNull().default(false),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var nodes = sqliteTable(
|
|
"nodes",
|
|
{
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
|
address: text("address").notNull(),
|
|
protocol: text("protocol").notNull().default("tcp"),
|
|
port: integer("port"),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
priority: integer("priority").notNull().default(1),
|
|
weight: integer("weight").notNull().default(1),
|
|
health_status: text("health_status").notNull().default("unknown"),
|
|
health_check_id: integer("health_check_id").references(() => healthChecks.id, {
|
|
onDelete: "set null"
|
|
}),
|
|
consecutive_failures: integer("consecutive_failures").notNull().default(0),
|
|
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
|
last_check_at: text("last_check_at"),
|
|
last_failure_reason: text("last_failure_reason"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
},
|
|
(t) => [unique("nodes_service_address").on(t.service_id, t.address)]
|
|
);
|
|
var bindingNodes = sqliteTable(
|
|
"binding_nodes",
|
|
{
|
|
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
|
|
node_id: integer("node_id").notNull().references(() => nodes.id, { onDelete: "cascade" }),
|
|
weight: integer("weight").notNull().default(1),
|
|
priority: integer("priority").notNull().default(1)
|
|
},
|
|
(t) => [primaryKey({ columns: [t.binding_id, t.node_id] })]
|
|
);
|
|
var serviceIps = sqliteTable("service_ips", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
|
ip: text("ip").notNull(),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var serviceBindingRecords = sqliteTable(
|
|
"service_binding_records",
|
|
{
|
|
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
|
|
dns_record_id: integer("dns_record_id").notNull().references(() => dnsRecords.id, { onDelete: "cascade" })
|
|
},
|
|
(t) => [primaryKey({ columns: [t.binding_id, t.dns_record_id] })]
|
|
);
|
|
var serviceBindingIps = sqliteTable(
|
|
"service_binding_ips",
|
|
{
|
|
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
|
|
ip: text("ip").notNull(),
|
|
weight: integer("weight").notNull().default(1),
|
|
priority: integer("priority").notNull().default(1)
|
|
},
|
|
(t) => [primaryKey({ columns: [t.binding_id, t.ip] })]
|
|
);
|
|
var serviceGroupDnsRecords = sqliteTable(
|
|
"service_group_dns_records",
|
|
{
|
|
group_id: integer("group_id").notNull().references(() => serviceGroups.id, { onDelete: "cascade" }),
|
|
dns_record_id: integer("dns_record_id").notNull().references(() => dnsRecords.id, { onDelete: "cascade" })
|
|
},
|
|
(t) => [primaryKey({ columns: [t.group_id, t.dns_record_id] })]
|
|
);
|
|
var certificates = sqliteTable("certificates", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
|
|
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
|
onDelete: "set null"
|
|
}),
|
|
service_id: integer("service_id").references(() => services.id, {
|
|
onDelete: "set null"
|
|
}),
|
|
hostname: text("hostname").notNull().unique(),
|
|
expires_at: text("expires_at"),
|
|
last_checked_at: text("last_checked_at"),
|
|
last_error: text("last_error"),
|
|
status: text("status").notNull().default("unknown"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var syncJobs = sqliteTable("sync_jobs", {
|
|
id: text("id").primaryKey(),
|
|
status: text("status").notNull().default("pending"),
|
|
domain_id: integer("domain_id").references(() => domains.id, {
|
|
onDelete: "set null"
|
|
}),
|
|
message: text("message"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
finished_at: text("finished_at")
|
|
});
|
|
var ipHealthStatus = sqliteTable(
|
|
"ip_health_status",
|
|
{
|
|
scope: text("scope").notNull(),
|
|
ref_id: integer("ref_id").notNull(),
|
|
ip: text("ip").notNull(),
|
|
status: text("status").notNull().default("unknown"),
|
|
latency_ms: integer("latency_ms"),
|
|
consecutive_failures: integer("consecutive_failures").notNull().default(0),
|
|
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
|
last_checked_at: text("last_checked_at"),
|
|
last_error: text("last_error"),
|
|
colo: text("colo"),
|
|
provider: text("provider").notNull().default("local"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
},
|
|
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })]
|
|
);
|
|
var appSettings = sqliteTable("app_settings", {
|
|
id: text("id").primaryKey(),
|
|
app_switcher_json: text("app_switcher_json"),
|
|
// deprecated: portal is SoT; column kept for migrate compat
|
|
vps_tracker_url: text("vps_tracker_url"),
|
|
vps_tracker_integration_token: text("vps_tracker_integration_token"),
|
|
vps_tracker_sync_enabled: integer("vps_tracker_sync_enabled", {
|
|
mode: "boolean"
|
|
}).notNull().default(false),
|
|
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
|
|
show_quick_actions: integer("show_quick_actions", {
|
|
mode: "boolean"
|
|
}).notNull().default(true),
|
|
health_check_cron: text("health_check_cron"),
|
|
health_degraded_failures: integer("health_degraded_failures"),
|
|
health_down_failures: integer("health_down_failures"),
|
|
health_latency_warn_ms: integer("health_latency_warn_ms"),
|
|
health_success_recoveries: integer("health_success_recoveries"),
|
|
health_worker_url: text("health_worker_url"),
|
|
health_worker_token: text("health_worker_token"),
|
|
health_worker_account_id: text("health_worker_account_id"),
|
|
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
|
|
health_worker_error: text("health_worker_error"),
|
|
health_worker_deployed_at: text("health_worker_deployed_at"),
|
|
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
|
globalping_token: text("globalping_token"),
|
|
globalping_locations: text("globalping_locations"),
|
|
globalping_limit: integer("globalping_limit"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var domainTags = sqliteTable(
|
|
"domain_tags",
|
|
{
|
|
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
|
|
tag: text("tag").notNull()
|
|
},
|
|
(t) => [primaryKey({ columns: [t.domain_id, t.tag] })]
|
|
);
|
|
var domainMonitors = sqliteTable("domain_monitors", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
domain_id: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }),
|
|
hostname: text("hostname").notNull(),
|
|
type: text("type").notNull().default("http"),
|
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
interval_sec: integer("interval_sec").notNull().default(60),
|
|
timeout_ms: integer("timeout_ms").notNull().default(5e3),
|
|
path: text("path"),
|
|
expected_status: integer("expected_status"),
|
|
last_status: text("last_status").notNull().default("unknown"),
|
|
last_latency_ms: integer("last_latency_ms"),
|
|
last_checked_at: text("last_checked_at"),
|
|
last_error: text("last_error"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var domainMonitorResults = sqliteTable("domain_monitor_results", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
monitor_id: integer("monitor_id").notNull().references(() => domainMonitors.id, { onDelete: "cascade" }),
|
|
status: text("status").notNull(),
|
|
latency_ms: integer("latency_ms"),
|
|
error: text("error"),
|
|
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var healthProbeLog = sqliteTable("health_probe_log", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
scope: text("scope").notNull(),
|
|
ref_id: integer("ref_id").notNull(),
|
|
ip: text("ip").notNull(),
|
|
provider: text("provider").notNull(),
|
|
status: text("status").notNull(),
|
|
ok: integer("ok", { mode: "boolean" }).notNull(),
|
|
latency_ms: integer("latency_ms"),
|
|
colo: text("colo"),
|
|
error: text("error"),
|
|
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var notificationLog = sqliteTable("notification_log", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
kind: text("kind").notNull(),
|
|
ref_type: text("ref_type").notNull(),
|
|
ref_id: integer("ref_id"),
|
|
title: text("title").notNull(),
|
|
message: text("message").notNull(),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var failoverLog = sqliteTable("failover_log", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
|
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
|
|
fqdn: text("fqdn").notNull(),
|
|
ip: text("ip").notNull(),
|
|
action: text("action").notNull(),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var auditLog = sqliteTable("audit_log", {
|
|
id: text("id").primaryKey(),
|
|
event_id: text("event_id"),
|
|
source_app: text("source_app").notNull().default("cfdm"),
|
|
action: text("action").notNull(),
|
|
severity: text("severity").notNull().default("info"),
|
|
actor_user_id: text("actor_user_id"),
|
|
actor_email: text("actor_email"),
|
|
actor_name: text("actor_name"),
|
|
target_type: text("target_type"),
|
|
target_id: text("target_id"),
|
|
summary: text("summary").notNull(),
|
|
details_json: text("details_json"),
|
|
ip: text("ip"),
|
|
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
|
});
|
|
var schema = {
|
|
groups,
|
|
services,
|
|
serviceGroups,
|
|
domains,
|
|
subdomains,
|
|
dnsRecords,
|
|
serviceBindings,
|
|
healthChecks,
|
|
nodes,
|
|
bindingNodes,
|
|
serviceIps,
|
|
serviceBindingRecords,
|
|
serviceBindingIps,
|
|
serviceGroupDnsRecords,
|
|
certificates,
|
|
syncJobs,
|
|
ipHealthStatus,
|
|
appSettings,
|
|
domainTags,
|
|
domainMonitors,
|
|
domainMonitorResults,
|
|
healthProbeLog,
|
|
notificationLog,
|
|
failoverLog,
|
|
auditLog
|
|
};
|
|
|
|
// src/client.ts
|
|
import { dirname, join } from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { readFileSync, readdirSync } from "fs";
|
|
import Database from "better-sqlite3";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
function resolveDatabasePath(databaseUrl) {
|
|
const url = databaseUrl.startsWith("sqlite:") ? databaseUrl.slice("sqlite:".length) : databaseUrl;
|
|
return url;
|
|
}
|
|
function createDb(databaseUrl) {
|
|
const path = resolveDatabasePath(databaseUrl);
|
|
const sqlite = new Database(path);
|
|
sqlite.pragma("journal_mode = WAL");
|
|
sqlite.pragma("synchronous = NORMAL");
|
|
sqlite.pragma("foreign_keys = ON");
|
|
const db = drizzle(sqlite, { schema });
|
|
return { db, sqlite };
|
|
}
|
|
function createMemoryDb() {
|
|
const sqlite = new Database(":memory:");
|
|
sqlite.pragma("foreign_keys = ON");
|
|
const db = drizzle(sqlite, { schema });
|
|
return { db, sqlite };
|
|
}
|
|
function runMigrations(sqlite) {
|
|
const migrationsDir = join(__dirname, "..", "migrations");
|
|
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
|
sqlite.exec(
|
|
`CREATE TABLE IF NOT EXISTS _migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`
|
|
);
|
|
for (const file of files) {
|
|
const applied = sqlite.prepare("SELECT 1 FROM _migrations WHERE name = ?").get(file);
|
|
if (applied) continue;
|
|
const sql3 = readFileSync(join(migrationsDir, file), "utf-8");
|
|
sqlite.exec(sql3);
|
|
sqlite.prepare("INSERT INTO _migrations (name) VALUES (?)").run(file);
|
|
}
|
|
}
|
|
function healthCheck(sqlite) {
|
|
sqlite.prepare("SELECT 1").get();
|
|
}
|
|
|
|
// src/errors.ts
|
|
var NotFoundError = class extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "NotFoundError";
|
|
}
|
|
};
|
|
var ConflictError = class extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "ConflictError";
|
|
}
|
|
};
|
|
|
|
// src/audit-log.ts
|
|
import { and, desc, eq, or } from "drizzle-orm";
|
|
import { randomUUID } from "crypto";
|
|
function mapRow(row) {
|
|
let details = null;
|
|
if (row.details_json) {
|
|
try {
|
|
details = JSON.parse(row.details_json);
|
|
} catch {
|
|
details = { raw: row.details_json };
|
|
}
|
|
}
|
|
return {
|
|
id: row.id,
|
|
event_id: row.event_id,
|
|
source_app: row.source_app || "cfdm",
|
|
action: row.action,
|
|
severity: row.severity,
|
|
actor_user_id: row.actor_user_id,
|
|
actor_email: row.actor_email,
|
|
actor_name: row.actor_name,
|
|
target_type: row.target_type ?? null,
|
|
target_id: row.target_id,
|
|
summary: row.summary,
|
|
details,
|
|
ip: row.ip,
|
|
created_at: row.created_at
|
|
};
|
|
}
|
|
function appendAudit(db, input) {
|
|
const now = input.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
const eventId = input.eventId ?? null;
|
|
if (eventId) {
|
|
const existing = db.select({ id: auditLog.id }).from(auditLog).where(eq(auditLog.event_id, eventId)).get();
|
|
if (existing) return false;
|
|
}
|
|
db.insert(auditLog).values({
|
|
id: randomUUID(),
|
|
event_id: eventId,
|
|
source_app: input.sourceApp ?? "cfdm",
|
|
action: input.action,
|
|
severity: input.severity ?? "info",
|
|
actor_user_id: input.actorUserId ?? null,
|
|
actor_email: input.actorEmail ?? null,
|
|
actor_name: input.actorName ?? null,
|
|
target_type: input.targetType ?? null,
|
|
target_id: input.targetId ?? null,
|
|
summary: input.summary,
|
|
details_json: input.details ? JSON.stringify(input.details) : null,
|
|
ip: input.ip ?? null,
|
|
created_at: now
|
|
}).run();
|
|
return true;
|
|
}
|
|
function listAudit(db, opts = {}) {
|
|
const limit = opts.limit ?? 200;
|
|
const conditions = [];
|
|
if (opts.action) conditions.push(eq(auditLog.action, opts.action));
|
|
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity));
|
|
if (opts.sourceApp) conditions.push(eq(auditLog.source_app, opts.sourceApp));
|
|
if (opts.userId) {
|
|
conditions.push(
|
|
or(
|
|
eq(auditLog.actor_user_id, opts.userId),
|
|
eq(auditLog.target_id, opts.userId)
|
|
)
|
|
);
|
|
}
|
|
const rows = conditions.length > 0 ? db.select().from(auditLog).where(and(...conditions)).orderBy(desc(auditLog.created_at)).limit(limit).all() : db.select().from(auditLog).orderBy(desc(auditLog.created_at)).limit(limit).all();
|
|
return rows.map(mapRow);
|
|
}
|
|
|
|
// src/settings-repo.ts
|
|
import { eq as eq2 } from "drizzle-orm";
|
|
var SETTINGS_ID = "settings-main";
|
|
function coalesceInt(value, fallback) {
|
|
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
|
}
|
|
function workerStatus(row, envUrl) {
|
|
if (row.health_worker_error?.trim()) return "error";
|
|
const url = row.health_worker_url?.trim() || envUrl;
|
|
const kv = row.health_worker_kv_namespace_id?.trim();
|
|
if (kv && url) return "ready";
|
|
return "missing";
|
|
}
|
|
function toDto(row, fallbacks) {
|
|
const env = fallbacks ?? {
|
|
healthCheckCron: "0 */2 * * * *",
|
|
healthDegradedFailures: 1,
|
|
healthDownFailures: 2,
|
|
healthLatencyWarnMs: 1e3,
|
|
healthSuccessRecoveries: 2,
|
|
healthWorkerUrl: "",
|
|
healthWorkerTokenSet: false
|
|
};
|
|
return {
|
|
id: row.id,
|
|
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
|
vpsTrackerIntegrationTokenSet: Boolean(
|
|
row.vps_tracker_integration_token?.trim()
|
|
),
|
|
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
|
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
|
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
|
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
|
|
healthDegradedFailures: coalesceInt(
|
|
row.health_degraded_failures,
|
|
env.healthDegradedFailures
|
|
),
|
|
healthDownFailures: coalesceInt(
|
|
row.health_down_failures,
|
|
env.healthDownFailures
|
|
),
|
|
healthLatencyWarnMs: coalesceInt(
|
|
row.health_latency_warn_ms,
|
|
env.healthLatencyWarnMs
|
|
),
|
|
healthSuccessRecoveries: coalesceInt(
|
|
row.health_success_recoveries,
|
|
env.healthSuccessRecoveries
|
|
),
|
|
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
|
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
|
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
|
|
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
|
|
healthWorkerError: row.health_worker_error?.trim() || null,
|
|
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
|
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
|
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
|
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
|
|
globalpingLocations: row.globalping_locations?.trim() || "World",
|
|
globalpingLimit: row.globalping_limit == null || Number.isNaN(row.globalping_limit) || row.globalping_limit < 1 ? 3 : Math.min(10, row.globalping_limit)
|
|
};
|
|
}
|
|
function getAppSettings(db, fallbacks) {
|
|
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
|
if (!row) {
|
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
|
return toDto(
|
|
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(),
|
|
fallbacks
|
|
);
|
|
}
|
|
return toDto(row, fallbacks);
|
|
}
|
|
function getAppSettingsSecrets(db) {
|
|
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
|
const limit = row?.globalping_limit;
|
|
return {
|
|
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
|
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
|
|
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
|
|
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
|
|
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
|
|
globalpingToken: row?.globalping_token?.trim() ?? "",
|
|
globalpingLocations: row?.globalping_locations?.trim() || "World",
|
|
globalpingLimit: limit == null || Number.isNaN(limit) || limit < 1 ? 3 : Math.min(10, limit)
|
|
};
|
|
}
|
|
function updateAppSettings(db, patch, fallbacks) {
|
|
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
|
if (!existing) {
|
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
|
}
|
|
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
|
db.update(appSettings).set({
|
|
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
|
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
|
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
|
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
|
health_check_cron: patch.healthCheckCron !== void 0 ? patch.healthCheckCron.trim() : current.health_check_cron,
|
|
health_degraded_failures: patch.healthDegradedFailures !== void 0 ? patch.healthDegradedFailures : current.health_degraded_failures,
|
|
health_down_failures: patch.healthDownFailures !== void 0 ? patch.healthDownFailures : current.health_down_failures,
|
|
health_latency_warn_ms: patch.healthLatencyWarnMs !== void 0 ? patch.healthLatencyWarnMs : current.health_latency_warn_ms,
|
|
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
|
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
|
|
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
|
|
health_worker_account_id: patch.healthWorkerAccountId !== void 0 ? patch.healthWorkerAccountId?.trim() || null : current.health_worker_account_id,
|
|
health_worker_kv_namespace_id: patch.healthWorkerKvNamespaceId !== void 0 ? patch.healthWorkerKvNamespaceId?.trim() || null : current.health_worker_kv_namespace_id,
|
|
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
|
|
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
|
|
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
|
|
globalping_token: patch.globalpingToken !== void 0 && patch.globalpingToken.trim() !== "" ? patch.globalpingToken : current.globalping_token,
|
|
globalping_locations: patch.globalpingLocations !== void 0 ? patch.globalpingLocations.trim() || "World" : current.globalping_locations,
|
|
globalping_limit: patch.globalpingLimit !== void 0 ? Math.min(10, Math.max(1, patch.globalpingLimit)) : current.globalping_limit,
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
|
return getAppSettings(db, fallbacks);
|
|
}
|
|
function touchVpsTrackerSync(db) {
|
|
db.update(appSettings).set({
|
|
vps_tracker_last_sync_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
|
}
|
|
|
|
// src/repos.ts
|
|
var repos_exports = {};
|
|
__export(repos_exports, {
|
|
addDomainTags: () => addDomainTags,
|
|
aggregateGroupScopeHealthByIds: () => aggregateGroupScopeHealthByIds,
|
|
aggregateIpHealthByRefs: () => aggregateIpHealthByRefs,
|
|
aggregateIpHealthByServiceIds: () => aggregateIpHealthByServiceIds,
|
|
bindingsToRemove: () => bindingsToRemove,
|
|
bumpBindingVersion: () => bumpBindingVersion,
|
|
countCertificatesByStatus: () => countCertificatesByStatus,
|
|
createDomain: () => createDomain,
|
|
createDomainMonitor: () => createDomainMonitor,
|
|
createGroup: () => createGroup,
|
|
createHealthCheck: () => createHealthCheck,
|
|
createNode: () => createNode,
|
|
createService: () => createService,
|
|
createServiceGroup: () => createServiceGroup,
|
|
createSubdomain: () => createSubdomain,
|
|
createSyncJob: () => createSyncJob,
|
|
deleteBinding: () => deleteBinding,
|
|
deleteBindingsExcept: () => deleteBindingsExcept,
|
|
deleteCertificatesNotIn: () => deleteCertificatesNotIn,
|
|
deleteDnsRecord: () => deleteDnsRecord,
|
|
deleteDomain: () => deleteDomain,
|
|
deleteDomainMonitor: () => deleteDomainMonitor,
|
|
deleteGroup: () => deleteGroup,
|
|
deleteHealthCheck: () => deleteHealthCheck,
|
|
deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp,
|
|
deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef,
|
|
deleteNode: () => deleteNode,
|
|
deleteService: () => deleteService,
|
|
deleteServiceGroup: () => deleteServiceGroup,
|
|
deleteSubdomain: () => deleteSubdomain,
|
|
ensureNode: () => ensureNode,
|
|
findBinding: () => findBinding,
|
|
findDnsByCfId: () => findDnsByCfId,
|
|
findDomainByZoneName: () => findDomainByZoneName,
|
|
findHealthCheckByCfId: () => findHealthCheckByCfId,
|
|
findNodeByAddress: () => findNodeByAddress,
|
|
findNodeByIp: () => findNodeByIp,
|
|
findSubdomainByDomainAndName: () => findSubdomainByDomainAndName,
|
|
finishSyncJob: () => finishSyncJob,
|
|
getBinding: () => getBinding,
|
|
getBindingView: () => getBindingView,
|
|
getCertificate: () => getCertificate,
|
|
getDnsRecord: () => getDnsRecord,
|
|
getDomain: () => getDomain,
|
|
getDomainMonitor: () => getDomainMonitor,
|
|
getGroup: () => getGroup,
|
|
getGroupWithStats: () => getGroupWithStats,
|
|
getHealthCheck: () => getHealthCheck,
|
|
getIpHealthStatusRow: () => getIpHealthStatusRow,
|
|
getNode: () => getNode,
|
|
getService: () => getService,
|
|
getServiceGroup: () => getServiceGroup,
|
|
getSubdomain: () => getSubdomain,
|
|
getSyncJob: () => getSyncJob,
|
|
insertBinding: () => insertBinding,
|
|
insertDnsRecord: () => insertDnsRecord,
|
|
insertFailoverLog: () => insertFailoverLog,
|
|
insertHealthProbeLog: () => insertHealthProbeLog,
|
|
insertNotificationLog: () => insertNotificationLog,
|
|
linkBindingRecord: () => linkBindingRecord,
|
|
linkGroupDnsRecord: () => linkGroupDnsRecord,
|
|
listAllBindings: () => listAllBindings,
|
|
listAllDomains: () => listAllDomains,
|
|
listAllNodes: () => listAllNodes,
|
|
listAllSubdomains: () => listAllSubdomains,
|
|
listBindingIps: () => listBindingIps,
|
|
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
|
|
listBindingNodes: () => listBindingNodes,
|
|
listBindingsByDomain: () => listBindingsByDomain,
|
|
listBindingsByService: () => listBindingsByService,
|
|
listCertificates: () => listCertificates,
|
|
listDnsByDomain: () => listDnsByDomain,
|
|
listDnsRecords: () => listDnsRecords,
|
|
listDomainMonitorResults: () => listDomainMonitorResults,
|
|
listDomainMonitorResultsForDomain: () => listDomainMonitorResultsForDomain,
|
|
listDomainMonitors: () => listDomainMonitors,
|
|
listDomainTags: () => listDomainTags,
|
|
listDomains: () => listDomains,
|
|
listDomainsEnriched: () => listDomainsEnriched,
|
|
listEnabledDomainMonitors: () => listEnabledDomainMonitors,
|
|
listFailoverLogForService: () => listFailoverLogForService,
|
|
listGroupDnsRecords: () => listGroupDnsRecords,
|
|
listGroups: () => listGroups,
|
|
listHealthCheckTargets: () => listHealthCheckTargets,
|
|
listHealthChecks: () => listHealthChecks,
|
|
listHealthProbeLogForService: () => listHealthProbeLogForService,
|
|
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
|
|
listIpHealthStatus: () => listIpHealthStatus,
|
|
listNodes: () => listNodes,
|
|
listNotificationLog: () => listNotificationLog,
|
|
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
|
listRecordsForBinding: () => listRecordsForBinding,
|
|
listServiceGroups: () => listServiceGroups,
|
|
listServiceIpRows: () => listServiceIpRows,
|
|
listServiceIps: () => listServiceIps,
|
|
listServices: () => listServices,
|
|
listServicesByGroup: () => listServicesByGroup,
|
|
listSubdomainsByDomain: () => listSubdomainsByDomain,
|
|
listUngroupedServices: () => listUngroupedServices,
|
|
markDnsPendingDelete: () => markDnsPendingDelete,
|
|
mergeHealthAggregates: () => mergeHealthAggregates,
|
|
pruneStaleIpHealthStatus: () => pruneStaleIpHealthStatus,
|
|
reorderServices: () => reorderServices,
|
|
replaceBindingIps: () => replaceBindingIps,
|
|
replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta,
|
|
replaceServiceIps: () => replaceServiceIps,
|
|
setBindingCnameTarget: () => setBindingCnameTarget,
|
|
setBindingDnsRecordId: () => setBindingDnsRecordId,
|
|
setBindingRoutingStrategy: () => setBindingRoutingStrategy,
|
|
setDnsSyncStatus: () => setDnsSyncStatus,
|
|
setDomainLastSynced: () => setDomainLastSynced,
|
|
setDomainTags: () => setDomainTags,
|
|
setServiceEnabled: () => setServiceEnabled,
|
|
setServiceGroup: () => setServiceGroup,
|
|
setServiceGroupEnabled: () => setServiceGroupEnabled,
|
|
setServiceIpEnabled: () => setServiceIpEnabled,
|
|
setServiceLb: () => setServiceLb,
|
|
unlinkBindingRecord: () => unlinkBindingRecord,
|
|
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
|
updateBindingDomain: () => updateBindingDomain,
|
|
updateBindingFields: () => updateBindingFields,
|
|
updateBindingLbConfig: () => updateBindingLbConfig,
|
|
updateDnsFields: () => updateDnsFields,
|
|
updateDomain: () => updateDomain,
|
|
updateDomainMonitorResult: () => updateDomainMonitorResult,
|
|
updateGroup: () => updateGroup,
|
|
updateHealthCheck: () => updateHealthCheck,
|
|
updateNode: () => updateNode,
|
|
updateService: () => updateService,
|
|
updateServiceGroup: () => updateServiceGroup,
|
|
updateSubdomain: () => updateSubdomain,
|
|
upsertCertificateCheck: () => upsertCertificateCheck,
|
|
upsertIpHealthStatus: () => upsertIpHealthStatus,
|
|
upsertSubdomain: () => upsertSubdomain
|
|
});
|
|
import {
|
|
derivePrimaryProvider,
|
|
dnsRecordNamesMatch,
|
|
isIpLiteral,
|
|
parseHealthAggregate,
|
|
parseHealthProviders,
|
|
serializeHealthProviders,
|
|
normalizeStatusProvider
|
|
} from "@cfdm/shared";
|
|
import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
|
|
function listGroups(db) {
|
|
return db.select().from(groups).orderBy(asc(groups.name)).all();
|
|
}
|
|
function getGroup(db, id) {
|
|
const row = db.select().from(groups).where(eq3(groups.id, id)).get();
|
|
if (!row) throw new NotFoundError(`group ${id}`);
|
|
return row;
|
|
}
|
|
function getGroupWithStats(db, id) {
|
|
const result = db.all(sql2`
|
|
SELECT g.id, g.name, g.slug, g.created_at, g.updated_at,
|
|
(SELECT COUNT(*) FROM domains d WHERE d.group_id = g.id) AS domain_count
|
|
FROM groups g WHERE g.id = ${id}
|
|
`);
|
|
if (!result[0]) throw new NotFoundError(`group ${id}`);
|
|
return result[0];
|
|
}
|
|
function createGroup(db, name, slug) {
|
|
const id = db.insert(groups).values({ name, slug }).returning({ id: groups.id }).get().id;
|
|
return getGroup(db, id);
|
|
}
|
|
function updateGroup(db, id, name, slug) {
|
|
const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq3(groups.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`group ${id}`);
|
|
return getGroup(db, id);
|
|
}
|
|
function deleteGroup(db, id) {
|
|
const result = db.delete(groups).where(eq3(groups.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`group ${id}`);
|
|
}
|
|
function listDomains(db, groupId) {
|
|
if (groupId != null) {
|
|
return db.select().from(domains).where(eq3(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all();
|
|
}
|
|
return db.select().from(domains).orderBy(asc(domains.zone_name)).all();
|
|
}
|
|
function listDomainsEnriched(db, groupId) {
|
|
const base = groupId != null ? sql2`WHERE d.group_id = ${groupId}` : sql2``;
|
|
const rows = db.all(sql2`
|
|
SELECT d.*, g.name AS group_name,
|
|
(SELECT COUNT(*) FROM service_bindings sb WHERE sb.domain_id = d.id) AS service_count,
|
|
COALESCE((
|
|
SELECT CASE
|
|
WHEN MAX(CASE
|
|
WHEN ihs.status = 'down' THEN 3
|
|
WHEN ihs.status = 'degraded' THEN 2
|
|
WHEN ihs.status = 'up' THEN 1
|
|
ELSE 0
|
|
END) = 3 THEN 'down'
|
|
WHEN MAX(CASE
|
|
WHEN ihs.status = 'down' THEN 3
|
|
WHEN ihs.status = 'degraded' THEN 2
|
|
WHEN ihs.status = 'up' THEN 1
|
|
ELSE 0
|
|
END) = 2 THEN 'degraded'
|
|
WHEN MAX(CASE
|
|
WHEN ihs.status = 'down' THEN 3
|
|
WHEN ihs.status = 'degraded' THEN 2
|
|
WHEN ihs.status = 'up' THEN 1
|
|
ELSE 0
|
|
END) = 1 THEN 'up'
|
|
ELSE 'unknown'
|
|
END
|
|
FROM ip_health_status ihs
|
|
INNER JOIN service_bindings sb ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
|
WHERE sb.domain_id = d.id
|
|
), 'unknown') AS health_status,
|
|
(
|
|
SELECT MAX(ihs.latency_ms)
|
|
FROM ip_health_status ihs
|
|
INNER JOIN service_bindings sb ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
|
WHERE sb.domain_id = d.id
|
|
) AS health_latency_ms,
|
|
(
|
|
SELECT GROUP_CONCAT(dt.tag, ',')
|
|
FROM domain_tags dt
|
|
WHERE dt.domain_id = d.id
|
|
) AS tags_json
|
|
FROM domains d
|
|
LEFT JOIN groups g ON g.id = d.group_id
|
|
${base}
|
|
ORDER BY d.zone_name ASC
|
|
`);
|
|
return rows.map((row) => {
|
|
const { tags_json, ...rest } = row;
|
|
return {
|
|
...rest,
|
|
environment: rest.environment ?? null,
|
|
health_status: rest.health_status ?? "unknown",
|
|
health_latency_ms: rest.health_latency_ms ?? null,
|
|
tags: tags_json ? tags_json.split(",").map((t) => t.trim()).filter(Boolean) : []
|
|
};
|
|
});
|
|
}
|
|
function findDomainByZoneName(db, zoneName) {
|
|
const rows = db.all(sql2`
|
|
SELECT * FROM domains WHERE LOWER(zone_name) = LOWER(${zoneName}) LIMIT 1
|
|
`);
|
|
return rows[0] ?? null;
|
|
}
|
|
function getDomain(db, id) {
|
|
const row = db.select().from(domains).where(eq3(domains.id, id)).get();
|
|
if (!row) throw new NotFoundError(`domain ${id}`);
|
|
return row;
|
|
}
|
|
function createDomain(db, groupId, zoneName, cfZoneId) {
|
|
const id = db.insert(domains).values({
|
|
group_id: groupId,
|
|
zone_name: zoneName,
|
|
cf_zone_id: cfZoneId
|
|
}).returning({ id: domains.id }).get().id;
|
|
return getDomain(db, id);
|
|
}
|
|
function updateDomain(db, id, patch) {
|
|
const existing = getDomain(db, id);
|
|
const updates = {
|
|
group_id: patch.group_id !== void 0 ? patch.group_id : existing.group_id,
|
|
status: patch.status !== void 0 ? patch.status : existing.status,
|
|
updated_at: sql2`datetime('now')`
|
|
};
|
|
if (patch.cert_monitoring !== void 0) {
|
|
updates.cert_monitoring = patch.cert_monitoring;
|
|
}
|
|
if (patch.environment !== void 0) {
|
|
updates.environment = patch.environment;
|
|
}
|
|
const result = db.update(domains).set(updates).where(eq3(domains.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
|
|
return getDomain(db, id);
|
|
}
|
|
function deleteDomain(db, id) {
|
|
const result = db.delete(domains).where(eq3(domains.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
|
|
}
|
|
function setDomainLastSynced(db, id) {
|
|
db.update(domains).set({
|
|
last_synced_at: sql2`datetime('now')`,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(domains.id, id)).run();
|
|
}
|
|
function listAllDomains(db) {
|
|
return listDomains(db);
|
|
}
|
|
function listSubdomainsByDomain(db, domainId) {
|
|
return db.select().from(subdomains).where(eq3(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all();
|
|
}
|
|
function getSubdomain(db, id) {
|
|
const row = db.select().from(subdomains).where(eq3(subdomains.id, id)).get();
|
|
if (!row) throw new NotFoundError(`subdomain ${id}`);
|
|
return row;
|
|
}
|
|
function findSubdomainByDomainAndName(db, domainId, name) {
|
|
const row = db.select().from(subdomains).where(and2(eq3(subdomains.domain_id, domainId), eq3(subdomains.name, name))).get();
|
|
return row ?? null;
|
|
}
|
|
function upsertSubdomain(db, domainId, name, fqdn) {
|
|
db.run(sql2`
|
|
INSERT INTO subdomains (domain_id, name, fqdn)
|
|
VALUES (${domainId}, ${name}, ${fqdn})
|
|
ON CONFLICT(domain_id, name) DO UPDATE SET
|
|
fqdn = excluded.fqdn,
|
|
updated_at = datetime('now')
|
|
`);
|
|
}
|
|
function createSubdomain(db, domainId, name, fqdn) {
|
|
const id = db.insert(subdomains).values({ domain_id: domainId, name, fqdn }).returning({ id: subdomains.id }).get().id;
|
|
return getSubdomain(db, id);
|
|
}
|
|
function updateSubdomain(db, id, patch) {
|
|
const updates = { updated_at: sql2`datetime('now')` };
|
|
if (patch.name !== void 0) updates.name = patch.name;
|
|
if (patch.fqdn !== void 0) updates.fqdn = patch.fqdn;
|
|
if (patch.enabled !== void 0) updates.enabled = patch.enabled;
|
|
if (patch.cert_monitoring !== void 0) {
|
|
updates.cert_monitoring = patch.cert_monitoring;
|
|
}
|
|
const result = db.update(subdomains).set(updates).where(eq3(subdomains.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
|
|
return getSubdomain(db, id);
|
|
}
|
|
function deleteSubdomain(db, id) {
|
|
const result = db.delete(subdomains).where(eq3(subdomains.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
|
|
}
|
|
function listAllSubdomains(db) {
|
|
return db.select().from(subdomains).orderBy(asc(subdomains.fqdn)).all();
|
|
}
|
|
function mapDnsRecord(row) {
|
|
return row;
|
|
}
|
|
function listDnsRecords(db, domainId, filter = {}) {
|
|
const conditions = [eq3(dnsRecords.domain_id, domainId)];
|
|
if (filter.record_type) {
|
|
conditions.push(eq3(dnsRecords.record_type, filter.record_type.toUpperCase()));
|
|
}
|
|
if (filter.name) {
|
|
conditions.push(like(dnsRecords.name, `%${filter.name}%`));
|
|
}
|
|
if (filter.content) {
|
|
conditions.push(like(dnsRecords.content, `%${filter.content}%`));
|
|
}
|
|
if (filter.proxied != null) {
|
|
conditions.push(eq3(dnsRecords.proxied, filter.proxied));
|
|
}
|
|
if (filter.sync_status) {
|
|
conditions.push(eq3(dnsRecords.sync_status, filter.sync_status));
|
|
}
|
|
if (filter.q) {
|
|
const pat = `%${filter.q}%`;
|
|
conditions.push(
|
|
or2(
|
|
like(dnsRecords.name, pat),
|
|
like(dnsRecords.content, pat),
|
|
like(dnsRecords.record_type, pat)
|
|
)
|
|
);
|
|
}
|
|
const sortCol = filter.sort === "type" ? dnsRecords.record_type : filter.sort === "updated_at" ? dnsRecords.updated_at : dnsRecords.name;
|
|
const page = Math.max(1, filter.page ?? 1);
|
|
const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
|
|
const offset = (page - 1) * limit;
|
|
return db.select().from(dnsRecords).where(and2(...conditions)).orderBy(asc(sortCol)).limit(limit).offset(offset).all().map(mapDnsRecord);
|
|
}
|
|
function getDnsRecord(db, domainId, id) {
|
|
const row = db.select().from(dnsRecords).where(and2(eq3(dnsRecords.id, id), eq3(dnsRecords.domain_id, domainId))).get();
|
|
if (!row) throw new NotFoundError(`dns record ${id}`);
|
|
return mapDnsRecord(row);
|
|
}
|
|
function insertDnsRecord(db, domainId, recordType, name, content, ttl, proxied, priority, syncStatus, origin, cfRecordId) {
|
|
const id = db.insert(dnsRecords).values({
|
|
domain_id: domainId,
|
|
cf_record_id: cfRecordId,
|
|
record_type: recordType.toUpperCase(),
|
|
name,
|
|
content,
|
|
ttl,
|
|
proxied,
|
|
priority,
|
|
sync_status: syncStatus,
|
|
origin
|
|
}).returning({ id: dnsRecords.id }).get().id;
|
|
return getDnsRecord(db, domainId, id);
|
|
}
|
|
function updateDnsFields(db, id, recordType, name, content, ttl, proxied, priority, syncStatus, cfRecordId, lastError) {
|
|
db.update(dnsRecords).set({
|
|
cf_record_id: cfRecordId,
|
|
record_type: recordType.toUpperCase(),
|
|
name,
|
|
content,
|
|
ttl,
|
|
proxied,
|
|
priority,
|
|
sync_status: syncStatus,
|
|
last_error: lastError,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(dnsRecords.id, id)).run();
|
|
}
|
|
function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) {
|
|
db.update(dnsRecords).set({
|
|
sync_status: syncStatus,
|
|
cf_record_id: cfRecordId,
|
|
last_error: lastError,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(dnsRecords.id, id)).run();
|
|
}
|
|
function deleteDnsRecord(db, id) {
|
|
db.delete(dnsRecords).where(eq3(dnsRecords.id, id)).run();
|
|
}
|
|
function listDnsByDomain(db, domainId) {
|
|
return db.select().from(dnsRecords).where(eq3(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord);
|
|
}
|
|
function listOriginIpsForFqdn(db, fqdn, depth = 0) {
|
|
if (depth > 8) return [];
|
|
const normalized = fqdn.trim().toLowerCase().replace(/\.+$/, "");
|
|
if (!normalized) return [];
|
|
const aIps = [];
|
|
let cnameNext = null;
|
|
for (const domain of listAllDomains(db)) {
|
|
const zone = domain.zone_name.trim().toLowerCase().replace(/\.+$/, "");
|
|
if (!zone) continue;
|
|
if (normalized !== zone && !normalized.endsWith(`.${zone}`)) continue;
|
|
const hostLabel = normalized === zone ? "@" : normalized.slice(0, -(zone.length + 1));
|
|
for (const record of listDnsByDomain(db, domain.id)) {
|
|
const type = record.record_type.toUpperCase();
|
|
if (!dnsRecordNamesMatch(record.name, hostLabel, zone)) continue;
|
|
if (type === "A" || type === "AAAA") {
|
|
const ip = record.content.trim();
|
|
if (ip) aIps.push(ip);
|
|
} else if (type === "CNAME" && !cnameNext) {
|
|
const target = record.content.trim().replace(/\.+$/, "");
|
|
if (target) cnameNext = target.includes(".") ? target : `${target}.${zone}`;
|
|
}
|
|
}
|
|
}
|
|
if (aIps.length > 0) return [...new Set(aIps)];
|
|
if (cnameNext) return listOriginIpsForFqdn(db, cnameNext, depth + 1);
|
|
return [];
|
|
}
|
|
function findDnsByCfId(db, domainId, cfRecordId) {
|
|
const row = db.select().from(dnsRecords).where(
|
|
and2(
|
|
eq3(dnsRecords.domain_id, domainId),
|
|
eq3(dnsRecords.cf_record_id, cfRecordId)
|
|
)
|
|
).get();
|
|
return row ? mapDnsRecord(row) : null;
|
|
}
|
|
function markDnsPendingDelete(db, id) {
|
|
setDnsSyncStatus(db, id, "pending_delete", null, null);
|
|
}
|
|
function maxSortOrderInGroup(db, groupId) {
|
|
const condition = groupId === null ? isNull(services.service_group_id) : eq3(services.service_group_id, groupId);
|
|
const row = db.select({ maxOrder: sql2`coalesce(max(${services.sort_order}), -1)` }).from(services).where(condition).get();
|
|
return row?.maxOrder ?? -1;
|
|
}
|
|
function listServices(db) {
|
|
return db.select().from(services).orderBy(asc(services.sort_order), asc(services.name)).all();
|
|
}
|
|
function listServicesByGroup(db, groupId) {
|
|
return db.select().from(services).where(eq3(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all();
|
|
}
|
|
function listUngroupedServices(db) {
|
|
return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all();
|
|
}
|
|
function getService(db, id) {
|
|
const row = db.select().from(services).where(eq3(services.id, id)).get();
|
|
if (!row) throw new NotFoundError(`service ${id}`);
|
|
return row;
|
|
}
|
|
function createService(db, name, slug) {
|
|
const sortOrder = maxSortOrderInGroup(db, null) + 1;
|
|
const id = db.insert(services).values({ name, slug, subdomain: slug, sort_order: sortOrder, enabled: true }).returning({ id: services.id }).get().id;
|
|
return getService(db, id);
|
|
}
|
|
function updateService(db, id, name, slug) {
|
|
db.update(services).set({
|
|
name,
|
|
slug,
|
|
subdomain: slug,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(services.id, id)).run();
|
|
return getService(db, id);
|
|
}
|
|
function setServiceEnabled(db, id, enabled) {
|
|
db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq3(services.id, id)).run();
|
|
return getService(db, id);
|
|
}
|
|
function setServiceLb(db, id, weight, priority) {
|
|
db.update(services).set({
|
|
lb_weight: weight,
|
|
lb_priority: priority,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(services.id, id)).run();
|
|
}
|
|
function setServiceGroup(db, id, groupId) {
|
|
const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
|
|
db.update(services).set({
|
|
service_group_id: groupId,
|
|
sort_order: sortOrder,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(services.id, id)).run();
|
|
}
|
|
function reorderServices(db, groupId, orderedIds) {
|
|
const uniqueIds = new Set(orderedIds);
|
|
if (uniqueIds.size !== orderedIds.length) {
|
|
throw new Error("duplicate service ids in reorder request");
|
|
}
|
|
const condition = groupId === null ? isNull(services.service_group_id) : eq3(services.service_group_id, groupId);
|
|
const existing = db.select({ id: services.id }).from(services).where(condition).all().map((row) => row.id);
|
|
const existingSet = new Set(existing);
|
|
for (const serviceId of orderedIds) {
|
|
if (!existingSet.has(serviceId)) {
|
|
throw new NotFoundError(`service ${serviceId} not in group`);
|
|
}
|
|
}
|
|
db.transaction((tx) => {
|
|
for (let index = 0; index < orderedIds.length; index++) {
|
|
tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq3(services.id, orderedIds[index])).run();
|
|
}
|
|
});
|
|
}
|
|
function deleteService(db, id) {
|
|
const result = db.delete(services).where(eq3(services.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
|
|
}
|
|
function healthProviderColumns(patch) {
|
|
const out = {};
|
|
if (patch.health_check_providers !== void 0) {
|
|
const list = parseHealthProviders(patch.health_check_providers);
|
|
out.health_check_providers = serializeHealthProviders(list);
|
|
out.health_check_provider = derivePrimaryProvider(list);
|
|
} else if (patch.health_check_provider !== void 0) {
|
|
const list = parseHealthProviders(null, patch.health_check_provider);
|
|
out.health_check_providers = serializeHealthProviders(list);
|
|
out.health_check_provider = derivePrimaryProvider(list);
|
|
}
|
|
if (patch.health_check_aggregate !== void 0) {
|
|
out.health_check_aggregate = parseHealthAggregate(
|
|
patch.health_check_aggregate
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
function mapHealthFields(row) {
|
|
const providers = parseHealthProviders(
|
|
row.health_check_providers,
|
|
row.health_check_provider
|
|
);
|
|
return {
|
|
health_check_providers: providers,
|
|
health_check_provider: derivePrimaryProvider(providers),
|
|
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate)
|
|
};
|
|
}
|
|
function mapServiceBinding(row) {
|
|
return {
|
|
id: row.id,
|
|
domain_id: row.domain_id,
|
|
service_id: row.service_id,
|
|
hostname: row.hostname,
|
|
cname_target: row.cname_target,
|
|
dns_record_id: row.dns_record_id,
|
|
lb_mode: row.lb_mode,
|
|
health_check_enabled: Boolean(row.health_check_enabled),
|
|
health_check_type: row.health_check_type,
|
|
health_check_port: row.health_check_port,
|
|
health_check_path: row.health_check_path,
|
|
health_check_expected_status: row.health_check_expected_status,
|
|
health_check_interval_sec: row.health_check_interval_sec,
|
|
health_check_timeout_ms: row.health_check_timeout_ms,
|
|
health_check_verify_tls: Boolean(row.health_check_verify_tls),
|
|
...mapHealthFields(row),
|
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
|
routing_strategy: row.routing_strategy,
|
|
operation_version: row.operation_version,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at
|
|
};
|
|
}
|
|
function mapServiceGroup(row) {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
type: row.type,
|
|
icon: row.icon,
|
|
domain: row.domain,
|
|
enabled: row.enabled,
|
|
lb_mode: row.lb_mode,
|
|
health_check_enabled: row.health_check_enabled,
|
|
health_check_type: row.health_check_type,
|
|
health_check_port: row.health_check_port,
|
|
health_check_path: row.health_check_path,
|
|
health_check_expected_status: row.health_check_expected_status,
|
|
health_check_interval_sec: row.health_check_interval_sec,
|
|
health_check_timeout_ms: row.health_check_timeout_ms,
|
|
health_check_verify_tls: row.health_check_verify_tls,
|
|
...mapHealthFields(row),
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at
|
|
};
|
|
}
|
|
function listServiceGroups(db) {
|
|
return db.select().from(serviceGroups).orderBy(asc(serviceGroups.name)).all().map(mapServiceGroup);
|
|
}
|
|
function getServiceGroup(db, id) {
|
|
const row = db.select().from(serviceGroups).where(eq3(serviceGroups.id, id)).get();
|
|
if (!row) throw new NotFoundError(`service group ${id}`);
|
|
return mapServiceGroup(row);
|
|
}
|
|
function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
|
|
const id = db.insert(serviceGroups).values({
|
|
name,
|
|
type: groupType,
|
|
icon,
|
|
domain,
|
|
lb_mode: lbPatch?.lb_mode ?? "round_robin",
|
|
health_check_enabled: lbPatch?.health_check_enabled ?? false,
|
|
health_check_type: lbPatch?.health_check_type ?? "tcp",
|
|
health_check_port: lbPatch?.health_check_port ?? null,
|
|
health_check_path: lbPatch?.health_check_path ?? null,
|
|
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
|
|
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
|
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
|
|
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
|
|
...healthProviderColumns({
|
|
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
|
health_check_providers: lbPatch?.health_check_providers,
|
|
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority"
|
|
})
|
|
}).returning({ id: serviceGroups.id }).get().id;
|
|
return getServiceGroup(db, id);
|
|
}
|
|
function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
|
|
const update = {
|
|
name,
|
|
type: groupType,
|
|
icon,
|
|
domain,
|
|
updated_at: sql2`datetime('now')`
|
|
};
|
|
if (lbPatch) {
|
|
if (lbPatch.lb_mode !== void 0) update.lb_mode = lbPatch.lb_mode;
|
|
if (lbPatch.health_check_enabled !== void 0)
|
|
update.health_check_enabled = lbPatch.health_check_enabled;
|
|
if (lbPatch.health_check_type !== void 0)
|
|
update.health_check_type = lbPatch.health_check_type;
|
|
if (lbPatch.health_check_port !== void 0)
|
|
update.health_check_port = lbPatch.health_check_port;
|
|
if (lbPatch.health_check_path !== void 0)
|
|
update.health_check_path = lbPatch.health_check_path;
|
|
if (lbPatch.health_check_expected_status !== void 0)
|
|
update.health_check_expected_status = lbPatch.health_check_expected_status;
|
|
if (lbPatch.health_check_interval_sec !== void 0)
|
|
update.health_check_interval_sec = lbPatch.health_check_interval_sec;
|
|
if (lbPatch.health_check_timeout_ms !== void 0)
|
|
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
|
if (lbPatch.health_check_verify_tls !== void 0)
|
|
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
|
Object.assign(update, healthProviderColumns(lbPatch));
|
|
}
|
|
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
|
return getServiceGroup(db, id);
|
|
}
|
|
function setServiceGroupEnabled(db, id, enabled) {
|
|
const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq3(serviceGroups.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
|
return getServiceGroup(db, id);
|
|
}
|
|
function deleteServiceGroup(db, id) {
|
|
const result = db.delete(serviceGroups).where(eq3(serviceGroups.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
|
}
|
|
function insertServiceIpIfMissing(db, serviceId, ip) {
|
|
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
|
if (!existing) {
|
|
db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
|
|
}
|
|
}
|
|
function listServiceIpRows(db, serviceId) {
|
|
return db.select({ ip: serviceIps.ip, enabled: serviceIps.enabled }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
|
|
}
|
|
function listServiceIps(db, serviceId) {
|
|
return listServiceIpRows(db, serviceId).map((row) => row.ip);
|
|
}
|
|
function setServiceIpEnabled(db, serviceId, ip, enabled) {
|
|
const result = db.update(serviceIps).set({ enabled }).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).run();
|
|
if (result.changes === 0) {
|
|
throw new NotFoundError(`service ip ${ip}`);
|
|
}
|
|
}
|
|
function replaceServiceIps(db, serviceId, ips) {
|
|
const previous = new Map(
|
|
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled])
|
|
);
|
|
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
|
for (const ip of ips) {
|
|
db.insert(serviceIps).values({
|
|
service_id: serviceId,
|
|
ip,
|
|
enabled: previous.get(ip) ?? true
|
|
}).run();
|
|
ensureNode(db, serviceId, ip);
|
|
}
|
|
const keep = new Set(ips);
|
|
for (const node of listNodes(db, serviceId)) {
|
|
if (keep.has(node.address)) continue;
|
|
const bound = db.select({ node_id: bindingNodes.node_id }).from(bindingNodes).where(eq3(bindingNodes.node_id, node.id)).get();
|
|
if (!bound) deleteNode(db, node.id);
|
|
}
|
|
}
|
|
function mapNode(row) {
|
|
return {
|
|
id: row.id,
|
|
service_id: row.service_id,
|
|
address: row.address,
|
|
protocol: row.protocol,
|
|
port: row.port,
|
|
enabled: Boolean(row.enabled),
|
|
priority: row.priority,
|
|
weight: row.weight,
|
|
health_status: row.health_status,
|
|
health_check_id: row.health_check_id,
|
|
consecutive_failures: row.consecutive_failures,
|
|
consecutive_successes: row.consecutive_successes,
|
|
last_check_at: row.last_check_at,
|
|
last_failure_reason: row.last_failure_reason,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at
|
|
};
|
|
}
|
|
function listNodes(db, serviceId) {
|
|
return db.select().from(nodes).where(eq3(nodes.service_id, serviceId)).all().map(mapNode);
|
|
}
|
|
function getNode(db, id) {
|
|
const row = db.select().from(nodes).where(eq3(nodes.id, id)).get();
|
|
if (!row) throw new NotFoundError(`node ${id}`);
|
|
return mapNode(row);
|
|
}
|
|
function findNodeByAddress(db, serviceId, address) {
|
|
const row = db.select().from(nodes).where(and2(eq3(nodes.service_id, serviceId), eq3(nodes.address, address))).get();
|
|
return row ? mapNode(row) : null;
|
|
}
|
|
function findNodeByIp(db, address) {
|
|
const row = db.select().from(nodes).where(eq3(nodes.address, address)).get();
|
|
return row ? mapNode(row) : null;
|
|
}
|
|
function ensureNode(db, serviceId, address, meta) {
|
|
const existing = findNodeByAddress(db, serviceId, address);
|
|
if (existing) return existing;
|
|
const id = db.insert(nodes).values({
|
|
service_id: serviceId,
|
|
address,
|
|
protocol: meta?.protocol ?? "tcp",
|
|
port: meta?.port ?? null,
|
|
weight: meta?.weight ?? 1,
|
|
priority: meta?.priority ?? 1
|
|
}).returning({ id: nodes.id }).get().id;
|
|
return getNode(db, id);
|
|
}
|
|
function createNode(db, serviceId, input) {
|
|
getService(db, serviceId);
|
|
const existing = findNodeByAddress(db, serviceId, input.address);
|
|
if (existing) {
|
|
throw new ConflictError(`node ${input.address} already exists`);
|
|
}
|
|
const id = db.insert(nodes).values({
|
|
service_id: serviceId,
|
|
address: input.address,
|
|
protocol: input.protocol ?? "tcp",
|
|
port: input.port ?? null,
|
|
enabled: input.enabled ?? true,
|
|
priority: input.priority ?? 1,
|
|
weight: input.weight ?? 1,
|
|
health_check_id: input.health_check_id ?? null
|
|
}).returning({ id: nodes.id }).get().id;
|
|
insertServiceIpIfMissing(db, serviceId, input.address);
|
|
return getNode(db, id);
|
|
}
|
|
function updateNode(db, id, patch) {
|
|
const current = getNode(db, id);
|
|
const update = { updated_at: sql2`datetime('now')` };
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value !== void 0) update[key] = value;
|
|
}
|
|
db.update(nodes).set(update).where(eq3(nodes.id, id)).run();
|
|
if (patch.address && patch.address !== current.address) {
|
|
db.delete(serviceIps).where(
|
|
and2(
|
|
eq3(serviceIps.service_id, current.service_id),
|
|
eq3(serviceIps.ip, current.address)
|
|
)
|
|
).run();
|
|
insertServiceIpIfMissing(db, current.service_id, patch.address);
|
|
}
|
|
return getNode(db, id);
|
|
}
|
|
function deleteNode(db, id) {
|
|
const current = getNode(db, id);
|
|
db.delete(nodes).where(eq3(nodes.id, id)).run();
|
|
db.delete(serviceIps).where(
|
|
and2(
|
|
eq3(serviceIps.service_id, current.service_id),
|
|
eq3(serviceIps.ip, current.address)
|
|
)
|
|
).run();
|
|
}
|
|
function mapHealthCheck(row) {
|
|
return {
|
|
id: row.id,
|
|
provider: row.provider,
|
|
cf_healthcheck_id: row.cf_healthcheck_id,
|
|
cf_zone_id: row.cf_zone_id,
|
|
name: row.name,
|
|
protocol: row.protocol,
|
|
path: row.path,
|
|
method: row.method,
|
|
timeout: row.timeout,
|
|
interval_sec: row.interval_sec,
|
|
retries: row.retries,
|
|
expected_status: row.expected_status,
|
|
consecutive_fails: row.consecutive_fails,
|
|
consecutive_successes: row.consecutive_successes,
|
|
suspended: Boolean(row.suspended),
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at
|
|
};
|
|
}
|
|
function listHealthChecks(db) {
|
|
return db.select().from(healthChecks).all().map(mapHealthCheck);
|
|
}
|
|
function getHealthCheck(db, id) {
|
|
const row = db.select().from(healthChecks).where(eq3(healthChecks.id, id)).get();
|
|
if (!row) throw new NotFoundError(`health check ${id}`);
|
|
return mapHealthCheck(row);
|
|
}
|
|
function findHealthCheckByCfId(db, cfId) {
|
|
const row = db.select().from(healthChecks).where(eq3(healthChecks.cf_healthcheck_id, cfId)).get();
|
|
return row ? mapHealthCheck(row) : null;
|
|
}
|
|
function createHealthCheck(db, input) {
|
|
const id = db.insert(healthChecks).values({
|
|
provider: input.provider,
|
|
name: input.name,
|
|
cf_healthcheck_id: input.cf_healthcheck_id ?? null,
|
|
cf_zone_id: input.cf_zone_id ?? null,
|
|
protocol: input.protocol ?? "tcp",
|
|
path: input.path ?? null,
|
|
method: input.method ?? null,
|
|
timeout: input.timeout ?? 5,
|
|
interval_sec: input.interval_sec ?? 30,
|
|
retries: input.retries ?? 2,
|
|
expected_status: input.expected_status ?? null,
|
|
consecutive_fails: input.consecutive_fails ?? 2,
|
|
consecutive_successes: input.consecutive_successes ?? 2,
|
|
suspended: input.suspended ?? false
|
|
}).returning({ id: healthChecks.id }).get().id;
|
|
return getHealthCheck(db, id);
|
|
}
|
|
function updateHealthCheck(db, id, patch) {
|
|
getHealthCheck(db, id);
|
|
const update = { updated_at: sql2`datetime('now')` };
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value !== void 0) update[key] = value;
|
|
}
|
|
db.update(healthChecks).set(update).where(eq3(healthChecks.id, id)).run();
|
|
return getHealthCheck(db, id);
|
|
}
|
|
function deleteHealthCheck(db, id) {
|
|
const result = db.delete(healthChecks).where(eq3(healthChecks.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`health check ${id}`);
|
|
}
|
|
function bumpBindingVersion(db, bindingId, expected) {
|
|
const binding = getBinding(db, bindingId);
|
|
if (expected != null && binding.operation_version !== expected) {
|
|
throw new ConflictError(`binding ${bindingId} version conflict`);
|
|
}
|
|
const next = (binding.operation_version ?? 0) + 1;
|
|
db.update(serviceBindings).set({
|
|
operation_version: next,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(serviceBindings.id, bindingId)).run();
|
|
return next;
|
|
}
|
|
function setBindingRoutingStrategy(db, bindingId, strategy) {
|
|
db.update(serviceBindings).set({
|
|
routing_strategy: strategy,
|
|
lb_mode: strategy,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(serviceBindings.id, bindingId)).run();
|
|
}
|
|
function listAllNodes(db) {
|
|
return db.select().from(nodes).all().map(mapNode);
|
|
}
|
|
function updateBindingDomain(db, bindingId, domainId, hostname) {
|
|
db.update(serviceBindings).set({
|
|
domain_id: domainId,
|
|
hostname,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(serviceBindings.id, bindingId)).run();
|
|
}
|
|
function listBindingNodes(db, bindingId) {
|
|
return db.select({ node: nodes }).from(bindingNodes).innerJoin(nodes, eq3(bindingNodes.node_id, nodes.id)).where(eq3(bindingNodes.binding_id, bindingId)).all().map((row) => mapNode(row.node));
|
|
}
|
|
function listBindingIps(db, bindingId) {
|
|
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
|
|
}
|
|
function listBindingIpsWithMeta(db, bindingId) {
|
|
return db.select({
|
|
ip: serviceBindingIps.ip,
|
|
weight: serviceBindingIps.weight,
|
|
priority: serviceBindingIps.priority
|
|
}).from(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).all();
|
|
}
|
|
function replaceBindingIps(db, bindingId, ips) {
|
|
replaceBindingIpsWithMeta(
|
|
db,
|
|
bindingId,
|
|
ips.map((ip) => ({ ip, weight: 1, priority: 1 }))
|
|
);
|
|
}
|
|
function replaceBindingIpsWithMeta(db, bindingId, entries) {
|
|
const binding = getBinding(db, bindingId);
|
|
db.delete(serviceBindingIps).where(eq3(serviceBindingIps.binding_id, bindingId)).run();
|
|
db.delete(bindingNodes).where(eq3(bindingNodes.binding_id, bindingId)).run();
|
|
for (const entry of entries) {
|
|
db.insert(serviceBindingIps).values({
|
|
binding_id: bindingId,
|
|
ip: entry.ip,
|
|
weight: entry.weight,
|
|
priority: entry.priority
|
|
}).run();
|
|
const node = ensureNode(db, binding.service_id, entry.ip, {
|
|
weight: entry.weight,
|
|
priority: entry.priority
|
|
});
|
|
db.insert(bindingNodes).values({
|
|
binding_id: bindingId,
|
|
node_id: node.id,
|
|
weight: entry.weight,
|
|
priority: entry.priority
|
|
}).run();
|
|
}
|
|
}
|
|
function updateBindingLbConfig(db, bindingId, patch) {
|
|
const update = {
|
|
updated_at: sql2`datetime('now')`
|
|
};
|
|
if (patch.lb_mode !== void 0) {
|
|
update.lb_mode = patch.lb_mode;
|
|
update.routing_strategy = patch.lb_mode;
|
|
}
|
|
if (patch.health_check_enabled !== void 0)
|
|
update.health_check_enabled = patch.health_check_enabled;
|
|
if (patch.health_check_type !== void 0)
|
|
update.health_check_type = patch.health_check_type;
|
|
if (patch.health_check_port !== void 0)
|
|
update.health_check_port = patch.health_check_port;
|
|
if (patch.health_check_path !== void 0)
|
|
update.health_check_path = patch.health_check_path;
|
|
if (patch.health_check_expected_status !== void 0)
|
|
update.health_check_expected_status = patch.health_check_expected_status;
|
|
if (patch.health_check_interval_sec !== void 0)
|
|
update.health_check_interval_sec = patch.health_check_interval_sec;
|
|
if (patch.health_check_timeout_ms !== void 0)
|
|
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
|
if (patch.health_check_verify_tls !== void 0)
|
|
update.health_check_verify_tls = patch.health_check_verify_tls;
|
|
if (patch.cert_monitoring !== void 0)
|
|
update.cert_monitoring = patch.cert_monitoring;
|
|
Object.assign(update, healthProviderColumns(patch));
|
|
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
|
|
}
|
|
function setBindingCnameTarget(db, bindingId, target) {
|
|
db.update(serviceBindings).set({
|
|
cname_target: target,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(serviceBindings.id, bindingId)).run();
|
|
}
|
|
function listRecordsForBinding(db, bindingId) {
|
|
return db.all(sql2`
|
|
SELECT dr.* FROM dns_records dr
|
|
INNER JOIN service_binding_records sbr ON sbr.dns_record_id = dr.id
|
|
WHERE sbr.binding_id = ${bindingId}
|
|
`);
|
|
}
|
|
function linkBindingRecord(db, bindingId, dnsRecordId) {
|
|
db.run(sql2`
|
|
INSERT INTO service_binding_records (binding_id, dns_record_id)
|
|
VALUES (${bindingId}, ${dnsRecordId})
|
|
ON CONFLICT(binding_id, dns_record_id) DO NOTHING
|
|
`);
|
|
}
|
|
function unlinkBindingRecord(db, bindingId, dnsRecordId) {
|
|
db.delete(serviceBindingRecords).where(
|
|
and2(
|
|
eq3(serviceBindingRecords.binding_id, bindingId),
|
|
eq3(serviceBindingRecords.dns_record_id, dnsRecordId)
|
|
)
|
|
).run();
|
|
}
|
|
function listGroupDnsRecords(db, groupId) {
|
|
return db.all(sql2`
|
|
SELECT dr.* FROM dns_records dr
|
|
INNER JOIN service_group_dns_records sgdr ON sgdr.dns_record_id = dr.id
|
|
WHERE sgdr.group_id = ${groupId}
|
|
`);
|
|
}
|
|
function linkGroupDnsRecord(db, groupId, dnsRecordId) {
|
|
db.run(sql2`
|
|
INSERT INTO service_group_dns_records (group_id, dns_record_id)
|
|
VALUES (${groupId}, ${dnsRecordId})
|
|
ON CONFLICT(group_id, dns_record_id) DO NOTHING
|
|
`);
|
|
}
|
|
function unlinkGroupDnsRecord(db, groupId, dnsRecordId) {
|
|
db.delete(serviceGroupDnsRecords).where(
|
|
and2(
|
|
eq3(serviceGroupDnsRecords.group_id, groupId),
|
|
eq3(serviceGroupDnsRecords.dns_record_id, dnsRecordId)
|
|
)
|
|
).run();
|
|
}
|
|
function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
|
|
return dnsRecordNamesMatch(recordName, hostname, zoneName);
|
|
}
|
|
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
|
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
|
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
|
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
|
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
|
|
d.zone_name, d.group_id, g.name AS group_name,
|
|
s.name AS service_name, s.slug AS service_slug,
|
|
dr.content AS target_ip, dr.sync_status,
|
|
sb.created_at, sb.updated_at`;
|
|
function enrichServiceBindingView(db, row) {
|
|
const configured = listBindingIpsWithMeta(db, row.id);
|
|
const configuredIps = configured.map((c) => c.ip);
|
|
const linkedRecords = listRecordsForBinding(db, row.id);
|
|
const linkedIps = linkedRecords.filter((record) => {
|
|
const type = record.record_type.toUpperCase();
|
|
return (type === "A" || type === "AAAA") && isIpLiteral(record.content);
|
|
}).map((record) => record.content);
|
|
const target_ips = [
|
|
.../* @__PURE__ */ new Set([
|
|
...configuredIps.filter(isIpLiteral),
|
|
...linkedIps,
|
|
...row.target_ip && isIpLiteral(row.target_ip) ? [row.target_ip] : []
|
|
])
|
|
].sort();
|
|
if (target_ips.length === 0) {
|
|
for (const record of listDnsByDomain(db, row.domain_id)) {
|
|
const type = record.record_type.toUpperCase();
|
|
if (type !== "A" && type !== "AAAA") continue;
|
|
if (!isIpLiteral(record.content)) continue;
|
|
if (!dnsRecordMatchesHostname(record.name, row.hostname, row.zone_name)) {
|
|
continue;
|
|
}
|
|
if (!target_ips.includes(record.content)) {
|
|
target_ips.push(record.content);
|
|
}
|
|
}
|
|
target_ips.sort();
|
|
}
|
|
const target_ip_weights = {};
|
|
const target_ip_priorities = {};
|
|
for (const entry of configured) {
|
|
target_ip_weights[entry.ip] = entry.weight;
|
|
target_ip_priorities[entry.ip] = entry.priority;
|
|
}
|
|
for (const ip of target_ips) {
|
|
if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1;
|
|
if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1;
|
|
}
|
|
const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null;
|
|
return {
|
|
...row,
|
|
cname_target: row.cname_target ?? null,
|
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
|
...mapHealthFields(row),
|
|
target_ips,
|
|
target_ip: target_ips[0] ?? null,
|
|
target_ip_weights,
|
|
target_ip_priorities,
|
|
sync_status
|
|
};
|
|
}
|
|
function listAllBindings(db) {
|
|
return db.all(sql2`
|
|
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
|
FROM service_bindings sb
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
LEFT JOIN groups g ON g.id = d.group_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
|
|
ORDER BY d.zone_name, s.name
|
|
`).map((row) => enrichServiceBindingView(db, row));
|
|
}
|
|
function listBindingsByDomain(db, domainId) {
|
|
return db.all(sql2`
|
|
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
|
FROM service_bindings sb
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
LEFT JOIN groups g ON g.id = d.group_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
|
|
WHERE sb.domain_id = ${domainId}
|
|
ORDER BY s.name
|
|
`).map((row) => enrichServiceBindingView(db, row));
|
|
}
|
|
function listBindingsByService(db, serviceId) {
|
|
const rows = db.all(sql2`
|
|
SELECT sb.*, d.zone_name FROM service_bindings sb
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
WHERE sb.service_id = ${serviceId}
|
|
`);
|
|
return rows.map((row) => ({
|
|
...mapServiceBinding(row),
|
|
zone_name: row.zone_name
|
|
}));
|
|
}
|
|
function getBinding(db, id) {
|
|
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
|
|
if (!row) throw new NotFoundError(`service binding ${id}`);
|
|
return mapServiceBinding(row);
|
|
}
|
|
function getBindingView(db, id) {
|
|
const rows = db.all(sql2`
|
|
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
|
|
FROM service_bindings sb
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
LEFT JOIN groups g ON g.id = d.group_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
LEFT JOIN dns_records dr ON dr.id = sb.dns_record_id
|
|
WHERE sb.id = ${id}
|
|
`);
|
|
if (!rows[0]) throw new NotFoundError(`service binding ${id}`);
|
|
return enrichServiceBindingView(db, rows[0]);
|
|
}
|
|
function findBinding(db, serviceId, domainId, hostname) {
|
|
const row = db.select().from(serviceBindings).where(
|
|
and2(
|
|
eq3(serviceBindings.service_id, serviceId),
|
|
eq3(serviceBindings.domain_id, domainId),
|
|
eq3(serviceBindings.hostname, hostname)
|
|
)
|
|
).get();
|
|
if (!row) return null;
|
|
return mapServiceBinding(row);
|
|
}
|
|
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
|
|
const id = db.insert(serviceBindings).values({
|
|
domain_id: domainId,
|
|
service_id: serviceId,
|
|
hostname,
|
|
dns_record_id: dnsRecordId
|
|
}).returning({ id: serviceBindings.id }).get().id;
|
|
return getBinding(db, id);
|
|
}
|
|
function updateBindingFields(db, id, serviceId, hostname, dnsRecordId) {
|
|
db.update(serviceBindings).set({
|
|
service_id: serviceId,
|
|
hostname,
|
|
dns_record_id: dnsRecordId,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(serviceBindings.id, id)).run();
|
|
}
|
|
function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
|
|
db.update(serviceBindings).set({
|
|
dns_record_id: dnsRecordId,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(serviceBindings.id, bindingId)).run();
|
|
}
|
|
function bindingsToRemove(db, serviceId, keepIds) {
|
|
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all().map(mapServiceBinding);
|
|
return all.filter((b) => !keepIds.includes(b.id));
|
|
}
|
|
function deleteBindingsExcept(db, serviceId, keepIds) {
|
|
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all();
|
|
for (const binding of all) {
|
|
if (!keepIds.includes(binding.id)) {
|
|
db.delete(serviceBindings).where(eq3(serviceBindings.id, binding.id)).run();
|
|
}
|
|
}
|
|
}
|
|
function deleteBinding(db, id) {
|
|
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
|
|
}
|
|
var CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
|
|
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
|
|
s.name AS service_name`;
|
|
function mapCertificate(row) {
|
|
return {
|
|
id: row.id,
|
|
domain_id: row.domain_id,
|
|
subdomain_id: row.subdomain_id,
|
|
service_id: row.service_id ?? null,
|
|
service_name: row.service_name ?? null,
|
|
hostname: row.hostname,
|
|
expires_at: row.expires_at,
|
|
last_checked_at: row.last_checked_at,
|
|
last_error: row.last_error,
|
|
status: row.status,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at
|
|
};
|
|
}
|
|
function listCertificates(db, status) {
|
|
const rows = status ? db.all(sql2`
|
|
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
|
FROM certificates c
|
|
LEFT JOIN services s ON s.id = c.service_id
|
|
WHERE c.status = ${status}
|
|
ORDER BY c.expires_at ASC
|
|
`) : db.all(sql2`
|
|
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
|
FROM certificates c
|
|
LEFT JOIN services s ON s.id = c.service_id
|
|
ORDER BY c.expires_at ASC
|
|
`);
|
|
return rows.map(mapCertificate);
|
|
}
|
|
function getCertificate(db, id) {
|
|
const row = db.all(sql2`
|
|
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
|
FROM certificates c
|
|
LEFT JOIN services s ON s.id = c.service_id
|
|
WHERE c.id = ${id}
|
|
`)[0];
|
|
if (!row) throw new NotFoundError(`certificate ${id}`);
|
|
return mapCertificate(row);
|
|
}
|
|
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError, serviceId) {
|
|
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
|
|
if (existing) {
|
|
db.update(certificates).set({
|
|
domain_id: domainId,
|
|
subdomain_id: subdomainId,
|
|
service_id: serviceId === void 0 ? existing.service_id : serviceId,
|
|
expires_at: expiresAt,
|
|
last_checked_at: sql2`datetime('now')`,
|
|
last_error: lastError,
|
|
status,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(certificates.id, existing.id)).run();
|
|
return getCertificate(db, existing.id);
|
|
}
|
|
const id = db.insert(certificates).values({
|
|
domain_id: domainId,
|
|
subdomain_id: subdomainId,
|
|
service_id: serviceId ?? null,
|
|
hostname,
|
|
expires_at: expiresAt,
|
|
last_checked_at: sql2`datetime('now')`,
|
|
last_error: lastError,
|
|
status
|
|
}).returning({ id: certificates.id }).get().id;
|
|
return getCertificate(db, id);
|
|
}
|
|
function countCertificatesByStatus(db) {
|
|
const rows = db.select({
|
|
status: certificates.status,
|
|
cnt: count()
|
|
}).from(certificates).groupBy(certificates.status).all();
|
|
return rows.map((r) => [r.status, r.cnt]);
|
|
}
|
|
function deleteCertificatesNotIn(db, hostnames) {
|
|
if (hostnames.length === 0) {
|
|
const result2 = db.delete(certificates).run();
|
|
return result2.changes;
|
|
}
|
|
const result = db.delete(certificates).where(notInArray(certificates.hostname, hostnames)).run();
|
|
return result.changes;
|
|
}
|
|
function createSyncJob(db, id, domainId) {
|
|
db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run();
|
|
}
|
|
function getSyncJob(db, id) {
|
|
const row = db.select().from(syncJobs).where(eq3(syncJobs.id, id)).get();
|
|
if (!row) throw new NotFoundError(`sync job ${id}`);
|
|
return row;
|
|
}
|
|
function finishSyncJob(db, id, status, message) {
|
|
db.update(syncJobs).set({
|
|
status,
|
|
message,
|
|
finished_at: sql2`datetime('now')`
|
|
}).where(eq3(syncJobs.id, id)).run();
|
|
}
|
|
function listIpHealthStatus(db, scope, refId) {
|
|
return db.all(sql2`
|
|
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
|
last_checked_at, last_error, colo, provider
|
|
FROM ip_health_status
|
|
WHERE scope = ${scope} AND ref_id = ${refId}
|
|
`);
|
|
}
|
|
var UNKNOWN_HEALTH = {
|
|
health_status: "unknown",
|
|
health_latency_ms: null
|
|
};
|
|
function parseHealthAggregateRow(row) {
|
|
const status = row.health_status;
|
|
if (status === "up" || status === "down" || status === "degraded" || status === "unknown") {
|
|
return {
|
|
health_status: status,
|
|
health_latency_ms: row.health_latency_ms ?? null
|
|
};
|
|
}
|
|
return UNKNOWN_HEALTH;
|
|
}
|
|
var WORST_HEALTH_SQL = sql2.raw(`CASE
|
|
WHEN MAX(CASE
|
|
WHEN status = 'down' THEN 3
|
|
WHEN status = 'degraded' THEN 2
|
|
WHEN status = 'up' THEN 1
|
|
ELSE 0
|
|
END) = 3 THEN 'down'
|
|
WHEN MAX(CASE
|
|
WHEN status = 'down' THEN 3
|
|
WHEN status = 'degraded' THEN 2
|
|
WHEN status = 'up' THEN 1
|
|
ELSE 0
|
|
END) = 2 THEN 'degraded'
|
|
WHEN MAX(CASE
|
|
WHEN status = 'down' THEN 3
|
|
WHEN status = 'degraded' THEN 2
|
|
WHEN status = 'up' THEN 1
|
|
ELSE 0
|
|
END) = 1 THEN 'up'
|
|
ELSE 'unknown'
|
|
END`);
|
|
var BEST_ALIVE_HEALTH_SQL = sql2.raw(`CASE
|
|
WHEN MAX(CASE WHEN status = 'up' THEN 1 ELSE 0 END) = 1 THEN 'up'
|
|
WHEN MAX(CASE WHEN status = 'degraded' THEN 1 ELSE 0 END) = 1 THEN 'degraded'
|
|
WHEN MAX(CASE WHEN status = 'down' THEN 1 ELSE 0 END) = 1 THEN 'down'
|
|
ELSE 'unknown'
|
|
END`);
|
|
function aggregateIpHealthByRefs(db, scope, refIds) {
|
|
const result = /* @__PURE__ */ new Map();
|
|
if (refIds.length === 0) return result;
|
|
const idList = sql2.join(
|
|
refIds.map((id) => sql2`${id}`),
|
|
sql2`, `
|
|
);
|
|
const rows = db.all(sql2`
|
|
SELECT ref_id,
|
|
${WORST_HEALTH_SQL} AS health_status,
|
|
MAX(latency_ms) AS health_latency_ms
|
|
FROM ip_health_status
|
|
WHERE scope = ${scope} AND ref_id IN (${idList})
|
|
GROUP BY ref_id
|
|
`);
|
|
for (const row of rows) {
|
|
result.set(row.ref_id, parseHealthAggregateRow(row));
|
|
}
|
|
return result;
|
|
}
|
|
function aggregateGroupScopeHealthByIds(db, groupIds) {
|
|
const result = /* @__PURE__ */ new Map();
|
|
if (groupIds.length === 0) return result;
|
|
const idList = sql2.join(
|
|
groupIds.map((id) => sql2`${id}`),
|
|
sql2`, `
|
|
);
|
|
const rows = db.all(sql2`
|
|
SELECT ihs.ref_id AS ref_id,
|
|
${WORST_HEALTH_SQL} AS health_status,
|
|
MAX(ihs.latency_ms) AS health_latency_ms
|
|
FROM ip_health_status ihs
|
|
WHERE ihs.scope = 'group'
|
|
AND ihs.ref_id IN (${idList})
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM service_binding_ips sbi
|
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
WHERE s.service_group_id = ihs.ref_id
|
|
AND s.enabled = 1
|
|
AND sbi.ip = ihs.ip
|
|
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
|
)
|
|
GROUP BY ihs.ref_id
|
|
`);
|
|
for (const row of rows) {
|
|
result.set(row.ref_id, parseHealthAggregateRow(row));
|
|
}
|
|
return result;
|
|
}
|
|
function aggregateIpHealthByServiceIds(db, serviceIds) {
|
|
const result = /* @__PURE__ */ new Map();
|
|
if (serviceIds.length === 0) return result;
|
|
const idList = sql2.join(
|
|
serviceIds.map((id) => sql2`${id}`),
|
|
sql2`, `
|
|
);
|
|
const rows = db.all(sql2`
|
|
SELECT sb.service_id AS service_id,
|
|
${BEST_ALIVE_HEALTH_SQL} AS health_status,
|
|
MAX(ihs.latency_ms) AS health_latency_ms
|
|
FROM ip_health_status ihs
|
|
INNER JOIN service_bindings sb
|
|
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
|
WHERE sb.service_id IN (${idList})
|
|
GROUP BY sb.service_id
|
|
`);
|
|
for (const row of rows) {
|
|
result.set(row.service_id, parseHealthAggregateRow(row));
|
|
}
|
|
return result;
|
|
}
|
|
function listIpHealthByServiceIds(db, serviceIds) {
|
|
const result = /* @__PURE__ */ new Map();
|
|
if (serviceIds.length === 0) return result;
|
|
const idList = sql2.join(
|
|
serviceIds.map((id) => sql2`${id}`),
|
|
sql2`, `
|
|
);
|
|
const rows = db.all(sql2`
|
|
SELECT sb.service_id AS service_id,
|
|
ihs.ip AS ip,
|
|
${WORST_HEALTH_SQL} AS health_status,
|
|
MAX(ihs.latency_ms) AS health_latency_ms,
|
|
MAX(ihs.last_checked_at) AS last_checked_at,
|
|
MAX(ihs.last_error) AS last_error,
|
|
MAX(ihs.provider) AS provider,
|
|
MAX(ihs.colo) AS colo
|
|
FROM ip_health_status ihs
|
|
INNER JOIN service_bindings sb
|
|
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
|
WHERE sb.service_id IN (${idList})
|
|
GROUP BY sb.service_id, ihs.ip
|
|
`);
|
|
for (const row of rows) {
|
|
const parsed = parseHealthAggregateRow({
|
|
health_status: row.health_status,
|
|
health_latency_ms: row.health_latency_ms
|
|
});
|
|
const list = result.get(row.service_id) ?? [];
|
|
list.push({
|
|
ip: row.ip,
|
|
status: parsed.health_status,
|
|
latency_ms: parsed.health_latency_ms,
|
|
last_checked_at: row.last_checked_at,
|
|
last_error: row.last_error,
|
|
provider: normalizeStatusProvider(row.provider),
|
|
colo: row.colo
|
|
});
|
|
result.set(row.service_id, list);
|
|
}
|
|
return result;
|
|
}
|
|
function mergeHealthAggregates(parts) {
|
|
const rank = {
|
|
unknown: 0,
|
|
up: 1,
|
|
degraded: 2,
|
|
down: 3
|
|
};
|
|
let worst = UNKNOWN_HEALTH;
|
|
let hasAny = false;
|
|
for (const part of parts) {
|
|
if (!part) continue;
|
|
hasAny = true;
|
|
if (rank[part.health_status] > rank[worst.health_status]) {
|
|
worst = {
|
|
health_status: part.health_status,
|
|
health_latency_ms: part.health_latency_ms
|
|
};
|
|
} else if (part.health_status === worst.health_status && part.health_latency_ms != null && (worst.health_latency_ms == null || part.health_latency_ms > worst.health_latency_ms)) {
|
|
worst = {
|
|
health_status: worst.health_status,
|
|
health_latency_ms: part.health_latency_ms
|
|
};
|
|
}
|
|
}
|
|
return hasAny ? worst : UNKNOWN_HEALTH;
|
|
}
|
|
function getIpHealthStatusRow(db, scope, refId, ip) {
|
|
const rows = db.all(sql2`
|
|
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
|
consecutive_successes, last_checked_at, last_error, colo, provider
|
|
FROM ip_health_status
|
|
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
|
|
LIMIT 1
|
|
`);
|
|
return rows[0] ?? null;
|
|
}
|
|
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0, extras) {
|
|
const colo = extras?.colo ?? null;
|
|
const provider = extras?.provider ?? "local";
|
|
db.run(sql2`
|
|
INSERT INTO ip_health_status
|
|
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
|
consecutive_successes, last_checked_at, last_error, colo, provider,
|
|
created_at, updated_at)
|
|
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
|
|
${consecutiveSuccesses}, datetime('now'), ${lastError}, ${colo}, ${provider},
|
|
datetime('now'), datetime('now'))
|
|
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
|
|
status = excluded.status,
|
|
latency_ms = excluded.latency_ms,
|
|
consecutive_failures = excluded.consecutive_failures,
|
|
consecutive_successes = excluded.consecutive_successes,
|
|
last_checked_at = excluded.last_checked_at,
|
|
last_error = excluded.last_error,
|
|
colo = excluded.colo,
|
|
provider = excluded.provider,
|
|
updated_at = datetime('now')
|
|
`);
|
|
}
|
|
function deleteIpHealthStatusForRef(db, scope, refId) {
|
|
db.delete(ipHealthStatus).where(
|
|
and2(
|
|
eq3(ipHealthStatus.scope, scope),
|
|
eq3(ipHealthStatus.ref_id, refId)
|
|
)
|
|
).run();
|
|
}
|
|
function deleteIpHealthStatusForIp(db, scope, refId, ip) {
|
|
db.delete(ipHealthStatus).where(
|
|
and2(
|
|
eq3(ipHealthStatus.scope, scope),
|
|
eq3(ipHealthStatus.ref_id, refId),
|
|
eq3(ipHealthStatus.ip, ip)
|
|
)
|
|
).run();
|
|
}
|
|
function pruneStaleIpHealthStatus(db, activeTargets) {
|
|
const byRef = /* @__PURE__ */ new Map();
|
|
for (const t of activeTargets) {
|
|
const key = `${t.scope}:${t.ref_id}`;
|
|
let ips = byRef.get(key);
|
|
if (!ips) {
|
|
ips = /* @__PURE__ */ new Set();
|
|
byRef.set(key, ips);
|
|
}
|
|
ips.add(t.ip);
|
|
}
|
|
const storedRefs = db.all(sql2`
|
|
SELECT DISTINCT scope, ref_id FROM ip_health_status
|
|
`);
|
|
let deleted = 0;
|
|
for (const { scope, ref_id: refId } of storedRefs) {
|
|
const key = `${scope}:${refId}`;
|
|
const ips = byRef.get(key);
|
|
if (!ips || ips.size === 0) {
|
|
deleteIpHealthStatusForRef(db, scope, refId);
|
|
deleted += 1;
|
|
continue;
|
|
}
|
|
for (const row of listIpHealthStatus(db, scope, refId)) {
|
|
if (!ips.has(row.ip)) {
|
|
deleteIpHealthStatusForIp(db, scope, refId, row.ip);
|
|
deleted += 1;
|
|
}
|
|
}
|
|
}
|
|
return deleted;
|
|
}
|
|
function normalizeCnameHost(target, zoneName) {
|
|
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
|
|
if (!trimmed) return "";
|
|
if (trimmed.includes(".")) return trimmed;
|
|
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
|
|
return zone ? `${trimmed}.${zone}` : trimmed;
|
|
}
|
|
function resolveCnameProbeIps(db, cnameTarget, zoneName, serviceId) {
|
|
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
|
|
if (fqdn) {
|
|
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
|
|
if (fromDns.length > 0) return [...new Set(fromDns)];
|
|
}
|
|
if (serviceId > 0) {
|
|
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
|
|
}
|
|
return [];
|
|
}
|
|
function expandCnameHealthTargets(db, rows) {
|
|
const expanded = [];
|
|
for (const row of rows) {
|
|
const ips = resolveCnameProbeIps(
|
|
db,
|
|
row.ip,
|
|
row.zone_name ?? "",
|
|
row.service_id ?? 0
|
|
);
|
|
const resolved = ips.length > 0 ? ips : [row.ip];
|
|
for (const ip of resolved) {
|
|
expanded.push({ ...row, ip });
|
|
}
|
|
}
|
|
return expanded;
|
|
}
|
|
function listHealthCheckTargets(db) {
|
|
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
|
const bindingTargets = db.all(sql2`
|
|
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
|
${fqdnExpr} AS hostname,
|
|
sb.health_check_type AS type,
|
|
sb.health_check_port AS port,
|
|
sb.health_check_path AS path,
|
|
sb.health_check_expected_status AS expected_status,
|
|
sb.health_check_timeout_ms AS timeout_ms,
|
|
sb.health_check_verify_tls AS verify_tls,
|
|
sb.health_check_providers AS providers_json,
|
|
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
|
COALESCE(sb.health_check_provider, 'local') AS provider
|
|
FROM service_binding_ips sbi
|
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
WHERE sb.health_check_enabled = 1
|
|
`).filter((t) => isIpLiteral(t.ip));
|
|
const groupTargets = db.all(sql2`
|
|
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
|
sg.domain AS hostname,
|
|
sg.health_check_type AS type,
|
|
sg.health_check_port AS port,
|
|
sg.health_check_path AS path,
|
|
sg.health_check_expected_status AS expected_status,
|
|
sg.health_check_timeout_ms AS timeout_ms,
|
|
sg.health_check_verify_tls AS verify_tls,
|
|
sg.health_check_providers AS providers_json,
|
|
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
|
COALESCE(sg.health_check_provider, 'local') AS provider
|
|
FROM service_binding_ips sbi
|
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
JOIN service_groups sg ON sg.id = s.service_group_id
|
|
WHERE sg.health_check_enabled = 1
|
|
AND sg.domain IS NOT NULL
|
|
AND sg.domain <> ''
|
|
AND s.enabled = 1
|
|
AND sg.enabled = 1
|
|
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
|
`).filter((t) => isIpLiteral(t.ip));
|
|
const groupInheritedBindingTargets = db.all(sql2`
|
|
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
|
${fqdnExpr} AS hostname,
|
|
sg.health_check_type AS type,
|
|
sg.health_check_port AS port,
|
|
sg.health_check_path AS path,
|
|
sg.health_check_expected_status AS expected_status,
|
|
sg.health_check_timeout_ms AS timeout_ms,
|
|
sg.health_check_verify_tls AS verify_tls,
|
|
sg.health_check_providers AS providers_json,
|
|
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
|
COALESCE(sg.health_check_provider, 'local') AS provider
|
|
FROM service_binding_ips sbi
|
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
JOIN service_groups sg ON sg.id = s.service_group_id
|
|
WHERE sg.health_check_enabled = 1
|
|
AND sg.domain IS NOT NULL
|
|
AND s.enabled = 1
|
|
AND sg.enabled = 1
|
|
AND sb.health_check_enabled = 0
|
|
`).filter((t) => isIpLiteral(t.ip));
|
|
const cnameBindingTargets = expandCnameHealthTargets(
|
|
db,
|
|
db.all(sql2`
|
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
|
${fqdnExpr} AS hostname,
|
|
sb.health_check_type AS type,
|
|
sb.health_check_port AS port,
|
|
sb.health_check_path AS path,
|
|
sb.health_check_expected_status AS expected_status,
|
|
sb.health_check_timeout_ms AS timeout_ms,
|
|
sb.health_check_verify_tls AS verify_tls,
|
|
sb.health_check_providers AS providers_json,
|
|
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
|
COALESCE(sb.health_check_provider, 'local') AS provider,
|
|
d.zone_name AS zone_name,
|
|
sb.service_id AS service_id
|
|
FROM service_bindings sb
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
WHERE sb.health_check_enabled = 1
|
|
AND sb.cname_target IS NOT NULL
|
|
AND sb.cname_target <> ''
|
|
AND s.enabled = 1
|
|
`)
|
|
);
|
|
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
|
|
db,
|
|
db.all(sql2`
|
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
|
${fqdnExpr} AS hostname,
|
|
sg.health_check_type AS type,
|
|
sg.health_check_port AS port,
|
|
sg.health_check_path AS path,
|
|
sg.health_check_expected_status AS expected_status,
|
|
sg.health_check_timeout_ms AS timeout_ms,
|
|
sg.health_check_verify_tls AS verify_tls,
|
|
sg.health_check_providers AS providers_json,
|
|
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
|
COALESCE(sg.health_check_provider, 'local') AS provider,
|
|
d.zone_name AS zone_name,
|
|
sb.service_id AS service_id
|
|
FROM service_bindings sb
|
|
JOIN domains d ON d.id = sb.domain_id
|
|
JOIN services s ON s.id = sb.service_id
|
|
JOIN service_groups sg ON sg.id = s.service_group_id
|
|
WHERE sg.health_check_enabled = 1
|
|
AND sg.domain IS NOT NULL
|
|
AND s.enabled = 1
|
|
AND sg.enabled = 1
|
|
AND sb.health_check_enabled = 0
|
|
AND sb.cname_target IS NOT NULL
|
|
AND sb.cname_target <> ''
|
|
`)
|
|
);
|
|
return [
|
|
...bindingTargets,
|
|
...groupTargets,
|
|
...groupInheritedBindingTargets,
|
|
...cnameBindingTargets,
|
|
...groupInheritedCnameBindingTargets
|
|
].map((t) => {
|
|
const row = t;
|
|
const providers = parseHealthProviders(row.providers_json, row.provider);
|
|
return {
|
|
scope: row.scope,
|
|
ref_id: row.ref_id,
|
|
ip: row.ip,
|
|
hostname: row.hostname,
|
|
type: row.type,
|
|
port: row.port,
|
|
path: row.path,
|
|
expected_status: row.expected_status,
|
|
timeout_ms: row.timeout_ms,
|
|
verify_tls: Boolean(row.verify_tls),
|
|
providers,
|
|
aggregate: parseHealthAggregate(row.aggregate),
|
|
provider: derivePrimaryProvider(providers)
|
|
};
|
|
});
|
|
}
|
|
function listDomainTags(db, domainId) {
|
|
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag);
|
|
}
|
|
function setDomainTags(db, domainId, tags) {
|
|
db.delete(domainTags).where(eq3(domainTags.domain_id, domainId)).run();
|
|
const unique2 = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
|
|
for (const tag of unique2) {
|
|
db.insert(domainTags).values({ domain_id: domainId, tag }).run();
|
|
}
|
|
}
|
|
function addDomainTags(db, domainId, tags) {
|
|
const unique2 = [...new Set(tags.map((t) => t.trim()).filter(Boolean))];
|
|
for (const tag of unique2) {
|
|
db.run(sql2`
|
|
INSERT INTO domain_tags (domain_id, tag)
|
|
VALUES (${domainId}, ${tag})
|
|
ON CONFLICT(domain_id, tag) DO NOTHING
|
|
`);
|
|
}
|
|
}
|
|
function listDomainMonitors(db, domainId) {
|
|
return db.select().from(domainMonitors).where(eq3(domainMonitors.domain_id, domainId)).orderBy(asc(domainMonitors.id)).all();
|
|
}
|
|
function listEnabledDomainMonitors(db) {
|
|
return db.select().from(domainMonitors).where(eq3(domainMonitors.enabled, true)).all();
|
|
}
|
|
function getDomainMonitor(db, id) {
|
|
const row = db.select().from(domainMonitors).where(eq3(domainMonitors.id, id)).get();
|
|
if (!row) throw new NotFoundError(`domain_monitor ${id}`);
|
|
return row;
|
|
}
|
|
function createDomainMonitor(db, domainId, input) {
|
|
const id = db.insert(domainMonitors).values({
|
|
domain_id: domainId,
|
|
hostname: input.hostname.trim(),
|
|
type: input.type,
|
|
enabled: input.enabled ?? true,
|
|
interval_sec: input.interval_sec ?? 60,
|
|
timeout_ms: input.timeout_ms ?? 5e3,
|
|
path: input.path ?? null,
|
|
expected_status: input.expected_status ?? null
|
|
}).returning({ id: domainMonitors.id }).get().id;
|
|
return getDomainMonitor(db, id);
|
|
}
|
|
function deleteDomainMonitor(db, id) {
|
|
const result = db.delete(domainMonitors).where(eq3(domainMonitors.id, id)).run();
|
|
if (result.changes === 0) throw new NotFoundError(`domain_monitor ${id}`);
|
|
}
|
|
function updateDomainMonitorResult(db, monitorId, status, latencyMs, error) {
|
|
db.update(domainMonitors).set({
|
|
last_status: status,
|
|
last_latency_ms: latencyMs,
|
|
last_checked_at: sql2`datetime('now')`,
|
|
last_error: error,
|
|
updated_at: sql2`datetime('now')`
|
|
}).where(eq3(domainMonitors.id, monitorId)).run();
|
|
db.insert(domainMonitorResults).values({
|
|
monitor_id: monitorId,
|
|
status,
|
|
latency_ms: latencyMs,
|
|
error
|
|
}).run();
|
|
db.run(sql2`
|
|
DELETE FROM domain_monitor_results
|
|
WHERE monitor_id = ${monitorId}
|
|
AND id NOT IN (
|
|
SELECT id FROM domain_monitor_results
|
|
WHERE monitor_id = ${monitorId}
|
|
ORDER BY checked_at DESC, id DESC
|
|
LIMIT 100
|
|
)
|
|
`);
|
|
}
|
|
function listDomainMonitorResults(db, monitorId, limit = 50) {
|
|
return db.all(sql2`
|
|
SELECT id, monitor_id, status, latency_ms, error, checked_at
|
|
FROM domain_monitor_results
|
|
WHERE monitor_id = ${monitorId}
|
|
ORDER BY checked_at DESC, id DESC
|
|
LIMIT ${limit}
|
|
`);
|
|
}
|
|
function listDomainMonitorResultsForDomain(db, domainId, limit = 50) {
|
|
return db.all(sql2`
|
|
SELECT r.id, r.monitor_id, m.hostname, m.type, r.status, r.latency_ms, r.error, r.checked_at
|
|
FROM domain_monitor_results r
|
|
JOIN domain_monitors m ON m.id = r.monitor_id
|
|
WHERE m.domain_id = ${domainId}
|
|
ORDER BY r.checked_at DESC, r.id DESC
|
|
LIMIT ${limit}
|
|
`);
|
|
}
|
|
var HEALTH_PROBE_LOG_KEEP = 50;
|
|
function insertHealthProbeLog(db, entry) {
|
|
db.insert(healthProbeLog).values({
|
|
scope: entry.scope,
|
|
ref_id: entry.refId,
|
|
ip: entry.ip,
|
|
provider: entry.provider,
|
|
status: entry.status,
|
|
ok: entry.ok,
|
|
latency_ms: entry.latencyMs,
|
|
colo: entry.colo,
|
|
error: entry.error
|
|
}).run();
|
|
db.run(sql2`
|
|
DELETE FROM health_probe_log
|
|
WHERE id IN (
|
|
SELECT id FROM health_probe_log
|
|
WHERE scope = ${entry.scope} AND ref_id = ${entry.refId} AND ip = ${entry.ip}
|
|
ORDER BY checked_at DESC, id DESC
|
|
LIMIT -1 OFFSET ${HEALTH_PROBE_LOG_KEEP}
|
|
)
|
|
`);
|
|
}
|
|
function listHealthProbeLogForService(db, serviceId, limit = 50) {
|
|
const rows = db.all(sql2`
|
|
SELECT l.id, l.scope, l.ref_id, l.ip, l.provider, l.status, l.ok,
|
|
l.latency_ms, l.colo, l.error, l.checked_at
|
|
FROM health_probe_log l
|
|
INNER JOIN service_bindings sb
|
|
ON l.scope = 'binding' AND l.ref_id = sb.id
|
|
WHERE sb.service_id = ${serviceId}
|
|
ORDER BY l.checked_at DESC, l.id DESC
|
|
LIMIT ${limit}
|
|
`);
|
|
return rows.map((row) => ({
|
|
...row,
|
|
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
|
|
ok: Boolean(row.ok)
|
|
}));
|
|
}
|
|
function insertNotificationLog(db, kind, refType, refId, title, message) {
|
|
db.insert(notificationLog).values({
|
|
kind,
|
|
ref_type: refType,
|
|
ref_id: refId,
|
|
title,
|
|
message
|
|
}).run();
|
|
}
|
|
function listNotificationLog(db, limit = 50) {
|
|
return db.all(sql2`
|
|
SELECT id, kind, ref_type, ref_id, title, message, created_at
|
|
FROM notification_log
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT ${limit}
|
|
`);
|
|
}
|
|
function insertFailoverLog(db, input) {
|
|
for (const entry of input.entries) {
|
|
db.insert(failoverLog).values({
|
|
service_id: input.serviceId,
|
|
binding_id: input.bindingId,
|
|
fqdn: input.fqdn,
|
|
ip: entry.ip,
|
|
action: entry.action
|
|
}).run();
|
|
}
|
|
}
|
|
function listFailoverLogForService(db, serviceId, limit = 100) {
|
|
return db.all(sql2`
|
|
SELECT id, service_id, binding_id, fqdn, ip, action, created_at
|
|
FROM failover_log
|
|
WHERE service_id = ${serviceId}
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT ${limit}
|
|
`);
|
|
}
|
|
export {
|
|
ConflictError,
|
|
NotFoundError,
|
|
appSettings,
|
|
appendAudit,
|
|
auditLog,
|
|
bindingNodes,
|
|
certificates,
|
|
createDb,
|
|
createMemoryDb,
|
|
dnsRecords,
|
|
domainMonitorResults,
|
|
domainMonitors,
|
|
domainTags,
|
|
domains,
|
|
failoverLog,
|
|
getAppSettings,
|
|
getAppSettingsSecrets,
|
|
groups,
|
|
healthCheck,
|
|
healthChecks,
|
|
healthProbeLog,
|
|
ipHealthStatus,
|
|
listAudit,
|
|
nodes,
|
|
notificationLog,
|
|
repos_exports as repos,
|
|
resolveDatabasePath,
|
|
runMigrations,
|
|
schema,
|
|
serviceBindingIps,
|
|
serviceBindingRecords,
|
|
serviceBindings,
|
|
serviceGroupDnsRecords,
|
|
serviceGroups,
|
|
serviceIps,
|
|
services,
|
|
subdomains,
|
|
syncJobs,
|
|
touchVpsTrackerSync,
|
|
updateAppSettings
|
|
};
|