Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade - клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers - скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase - история изменений (config_revisions, секция ipsec) и restore только managed-объектов - привязка IPsec-клиентов к пользователям приложения по Common Name - страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
238 lines
7.0 KiB
TypeScript
238 lines
7.0 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto"
|
|
import { and, desc, eq } from "drizzle-orm"
|
|
import { db } from "../db/index.js"
|
|
import { configRevisions, servers, type ConfigRevisionRow } from "../db/schema.js"
|
|
|
|
export const CONFIG_REVISION_KEEP = 50
|
|
|
|
export const CONFIG_SECTIONS = [
|
|
"filters",
|
|
"recursive-routes",
|
|
"firewall",
|
|
"wireguard",
|
|
"ipsec",
|
|
"gre",
|
|
] as const
|
|
|
|
export type ConfigSection = (typeof CONFIG_SECTIONS)[number]
|
|
export type ConfigRevisionSource = "apply" | "rollback" | "observed" | "copy"
|
|
|
|
export interface ConfigRevisionDto {
|
|
id: string
|
|
serverId: string
|
|
section: ConfigSection
|
|
source: ConfigRevisionSource
|
|
fingerprint: string
|
|
createdAt: string
|
|
note: string | null
|
|
itemCount: number
|
|
}
|
|
|
|
export function stableStringify(value: unknown): string {
|
|
if (value === null || typeof value !== "object") return JSON.stringify(value)
|
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
|
|
const obj = value as Record<string, unknown>
|
|
const keys = Object.keys(obj).sort()
|
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`
|
|
}
|
|
|
|
export function fingerprintPayload(payload: unknown): string {
|
|
return createHash("sha256").update(stableStringify(payload)).digest("hex")
|
|
}
|
|
|
|
export function revisionItemCount(payload: unknown): number {
|
|
if (Array.isArray(payload)) return payload.length
|
|
if (payload && typeof payload === "object") {
|
|
let n = 0
|
|
for (const value of Object.values(payload as Record<string, unknown>)) {
|
|
if (Array.isArray(value)) n += value.length
|
|
}
|
|
return n
|
|
}
|
|
return 0
|
|
}
|
|
|
|
export function persistablePayload(payload: unknown): unknown {
|
|
if (payload === undefined) return []
|
|
return payload
|
|
}
|
|
|
|
export function canonicalFilterRules(
|
|
rules: Array<{
|
|
community?: string
|
|
action?: string
|
|
gateway?: string
|
|
gatewayTunnelId?: string
|
|
description?: string
|
|
}>,
|
|
): unknown[] {
|
|
return rules.map((r) => ({
|
|
community: (r.community ?? "").trim(),
|
|
action: r.action === "blackhole" ? "blackhole" : "route",
|
|
gateway: r.gateway ?? "",
|
|
gatewayTunnelId: r.gatewayTunnelId ?? "",
|
|
description: r.description ?? "",
|
|
}))
|
|
}
|
|
|
|
export function canonicalRecursiveRoutes(
|
|
routes: Array<{
|
|
dstAddress?: string
|
|
gateway?: string
|
|
distance?: number
|
|
scope?: number | null
|
|
targetScope?: number | null
|
|
routingTable?: string
|
|
checkGateway?: string
|
|
comment?: string
|
|
disabled?: boolean
|
|
country?: string
|
|
}>,
|
|
): unknown[] {
|
|
return routes.map((r) => ({
|
|
dstAddress: (r.dstAddress ?? "").trim(),
|
|
gateway: r.gateway ?? "",
|
|
distance: r.distance ?? 1,
|
|
scope: r.scope ?? null,
|
|
targetScope: r.targetScope ?? null,
|
|
routingTable: r.routingTable || "main",
|
|
checkGateway: r.checkGateway ?? "",
|
|
comment: r.comment ?? "",
|
|
disabled: Boolean(r.disabled),
|
|
country: r.country ?? "",
|
|
}))
|
|
}
|
|
|
|
export function toRevisionDto(row: ConfigRevisionRow): ConfigRevisionDto {
|
|
return {
|
|
id: row.id,
|
|
serverId: String(row.serverId),
|
|
section: row.section as ConfigSection,
|
|
source: row.source,
|
|
fingerprint: row.fingerprint,
|
|
createdAt: row.createdAt,
|
|
note: row.note ?? null,
|
|
itemCount: revisionItemCount(row.payload),
|
|
}
|
|
}
|
|
|
|
export async function listRevisions(
|
|
serverId: number,
|
|
section: ConfigSection,
|
|
limit = CONFIG_REVISION_KEEP,
|
|
): Promise<ConfigRevisionDto[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(configRevisions)
|
|
.where(and(eq(configRevisions.serverId, serverId), eq(configRevisions.section, section)))
|
|
.orderBy(desc(configRevisions.createdAt))
|
|
.limit(limit)
|
|
return rows.map(toRevisionDto)
|
|
}
|
|
|
|
export async function getRevisionById(id: string): Promise<ConfigRevisionRow | undefined> {
|
|
return (await db.select().from(configRevisions).where(eq(configRevisions.id, id)).limit(1))[0]
|
|
}
|
|
|
|
export async function pruneRevisions(
|
|
serverId: number,
|
|
section: ConfigSection,
|
|
keep = CONFIG_REVISION_KEEP,
|
|
): Promise<number> {
|
|
const rows = await db
|
|
.select({ id: configRevisions.id })
|
|
.from(configRevisions)
|
|
.where(and(eq(configRevisions.serverId, serverId), eq(configRevisions.section, section)))
|
|
.orderBy(desc(configRevisions.createdAt))
|
|
const extra = rows.slice(keep)
|
|
if (extra.length === 0) return 0
|
|
for (const row of extra) {
|
|
await db.delete(configRevisions).where(eq(configRevisions.id, row.id))
|
|
}
|
|
return extra.length
|
|
}
|
|
|
|
export async function appendRevisionIfChanged(input: {
|
|
serverId: number
|
|
section: ConfigSection
|
|
source: ConfigRevisionSource
|
|
payload: unknown
|
|
note?: string | null
|
|
}): Promise<{ created: boolean; revision: ConfigRevisionDto }> {
|
|
const payload = persistablePayload(input.payload)
|
|
const fingerprint = fingerprintPayload(payload)
|
|
const latest = (await db
|
|
.select()
|
|
.from(configRevisions)
|
|
.where(and(
|
|
eq(configRevisions.serverId, input.serverId),
|
|
eq(configRevisions.section, input.section),
|
|
))
|
|
.orderBy(desc(configRevisions.createdAt))
|
|
.limit(1))[0]
|
|
|
|
if (latest?.fingerprint === fingerprint) {
|
|
return { created: false, revision: toRevisionDto(latest) }
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
const id = randomUUID()
|
|
await db.insert(configRevisions).values({
|
|
id,
|
|
serverId: input.serverId,
|
|
section: input.section,
|
|
source: input.source,
|
|
fingerprint,
|
|
payload,
|
|
note: input.note ?? null,
|
|
createdAt: now,
|
|
})
|
|
await pruneRevisions(input.serverId, input.section)
|
|
const row = await getRevisionById(id)
|
|
if (!row) throw new Error("config-revisions: insert vanished")
|
|
return { created: true, revision: toRevisionDto(row) }
|
|
}
|
|
|
|
/** После успешного mutate: capture live → append, ошибки snapshot не валят мутацию. */
|
|
export async function captureAndAppendRevision(input: {
|
|
serverId: number
|
|
section: ConfigSection
|
|
source: ConfigRevisionSource
|
|
capture: () => Promise<unknown>
|
|
note?: string | null
|
|
}): Promise<{ created: boolean; revision: ConfigRevisionDto } | null> {
|
|
try {
|
|
const payload = await input.capture()
|
|
return await appendRevisionIfChanged({
|
|
serverId: input.serverId,
|
|
section: input.section,
|
|
source: input.source,
|
|
payload,
|
|
note: input.note,
|
|
})
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function loadRevisionForRestore(opts: {
|
|
id: string
|
|
section: ConfigSection
|
|
requestedServerId: number | null
|
|
}): Promise<
|
|
| { ok: true; row: ConfigRevisionRow; server: typeof servers.$inferSelect }
|
|
| { ok: false; status: number; error: string }
|
|
> {
|
|
const row = await getRevisionById(opts.id)
|
|
if (!row) return { ok: false, status: 404, error: "Revision not found" }
|
|
if (row.section !== opts.section) {
|
|
return { ok: false, status: 400, error: "Revision section mismatch" }
|
|
}
|
|
if (opts.requestedServerId !== null && opts.requestedServerId !== row.serverId) {
|
|
return { ok: false, status: 400, error: "Revision belongs to another server" }
|
|
}
|
|
const server = (await db.select().from(servers).where(eq(servers.id, row.serverId)).limit(1))[0]
|
|
if (!server) return { ok: false, status: 404, error: "Server not found" }
|
|
return { ok: true, row, server }
|
|
}
|