feat: интеграция с CF Domain Manager
Docker / build (push) Failing after 22s

Синхронизация bindings в vps_domains с привязкой по IP, API приёма с Bearer-токеном, настройки и App Switcher из SQLite, UI доменов на VPS и уведомление CFDM при vps_down.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 16:45:16 +07:00
co-authored by Cursor
parent 2b01063279
commit 0e9a6a6a21
34 changed files with 1326 additions and 39 deletions
+4
View File
@@ -23,6 +23,8 @@ import { migrateRoutes } from './routes/migrate.js'
import { dashboardRoutes } from './routes/dashboard.js'
import { auditRoutes } from './routes/audit.js'
import { notificationsRoutes } from './routes/notifications.js'
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
import { appSwitcherRoutes } from './routes/app-switcher.js'
import { startScheduler } from './services/scheduler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -58,6 +60,8 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(dashboardRoutes)
await app.register(auditRoutes)
await app.register(notificationsRoutes)
await app.register(integrationsCfdmRoutes)
await app.register(appSwitcherRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
+43
View File
@@ -0,0 +1,43 @@
import { timingSafeEqual } from 'node:crypto'
import type { FastifyReply, FastifyRequest } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings'
function safeEqualToken(expected: string, provided: string): boolean {
if (!expected || !provided) return false
const a = Buffer.from(expected)
const b = Buffer.from(provided)
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
function extractBearer(request: FastifyRequest): string {
const auth = request.headers.authorization ?? ''
if (auth.startsWith('Bearer ')) return auth.slice(7).trim()
return ''
}
export async function requireIntegrationAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const row = settingsRepository.getRow('settings-main')
if (!row?.integrationEnabled) {
return reply.code(403).send({
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
})
}
const expected = settingsRepository.getIntegrationToken()
if (!expected) {
return reply.code(503).send({
error: { code: 'INTEGRATION_NOT_CONFIGURED', message: 'Integration token не настроен' },
})
}
const provided = extractBearer(request)
if (!safeEqualToken(expected, provided)) {
return reply.code(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
})
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { FastifyPluginAsync } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings'
export const appSwitcherRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings/app-switcher', async () => {
return settingsRepository.getAppSwitcher()
})
}
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
describe('integrations CFDM routes', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('отклоняет запрос без токена', async () => {
settingsRepository.upsert('settings-main', {
integrationToken: 'test-secret',
integrationEnabled: true,
})
const app = await buildApp()
const res = await app.inject({
method: 'POST',
url: '/api/integrations/cfdm/ping',
})
expect(res.statusCode).toBe(401)
})
it('принимает ping с верным Bearer', async () => {
settingsRepository.upsert('settings-main', {
integrationToken: 'test-secret',
integrationEnabled: true,
})
const app = await buildApp()
const res = await app.inject({
method: 'POST',
url: '/api/integrations/cfdm/ping',
headers: { authorization: 'Bearer test-secret' },
})
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ ok: true, service: 'vps-tracker' })
})
})
+34
View File
@@ -0,0 +1,34 @@
import type { FastifyPluginAsync } from 'fastify'
import { cfdmSyncBindingsBodySchema } from '@cfdm/shared/contracts/integration-cfdm'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import { requireIntegrationAuth } from '../plugins/integration-auth.js'
export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/api/integrations/cfdm/ping',
{ onRequest: requireIntegrationAuth },
async () => ({ ok: true, service: 'vps-tracker' }),
)
app.post(
'/api/integrations/cfdm/sync-bindings',
{ onRequest: requireIntegrationAuth },
async (req, reply) => {
const parsed = cfdmSyncBindingsBodySchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({
error: { code: 'VALIDATION', message: parsed.error.message },
})
}
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
settingsRepository.touchIntegrationSync()
return {
ok: true,
...result,
}
},
)
}
+6
View File
@@ -1,5 +1,6 @@
import type { FastifyPluginAsync } from 'fastify'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
import { vpsSchema } from '@cfdm/shared/contracts/vps'
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
@@ -27,6 +28,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
if (!updated) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
vpsDomainsRepository.rematchAll()
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
return updated
})
@@ -63,4 +65,8 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
}
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'action must be status, delete, or project' } })
})
app.get<{ Params: { id: string } }>('/api/vps/:id/domains', async (req) => {
return vpsDomainsRepository.listByVpsId(req.params.id)
})
}
+57
View File
@@ -0,0 +1,57 @@
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { vpsRepository } from '@cfdm/db/repositories/vps'
import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm'
const SETTINGS_ID = 'settings-main'
function resolveCfdmApiBase(): string | null {
const row = settingsRepository.getRow(SETTINGS_ID)
if (!row) return null
const explicit = row.cfdmApiUrl?.trim()
if (explicit) return explicit.replace(/\/$/, '')
const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm')
return cfdm?.url?.trim().replace(/\/$/, '') ?? null
}
export async function notifyCfdmVpsEvent(
event: VpsTrackerEvent['event'],
vpsIds: string[],
): Promise<void> {
if (vpsIds.length === 0) return
const row = settingsRepository.getRow(SETTINGS_ID)
if (!row?.integrationEnabled) return
const token = settingsRepository.getIntegrationToken()
const baseUrl = resolveCfdmApiBase()
if (!baseUrl || !token) return
const payload: VpsTrackerEvent = {
event,
vps: vpsIds.map((id) => {
const vps = vpsRepository.get(id)
return {
id,
ip: vps?.ip ?? undefined,
label: vps?.dns || vps?.ip || id,
}
}),
timestamp: new Date().toISOString(),
}
try {
const res = await fetch(`${baseUrl}/api/v1/integrations/vps-tracker/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
if (!res.ok) {
console.warn(`CFDM event notify failed (${res.status})`)
}
} catch (err) {
console.warn('CFDM event notify error:', err instanceof Error ? err.message : err)
}
}
+7
View File
@@ -5,6 +5,7 @@ import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
import { runAccountSync } from './providers/sync-job.js'
import { runVpsUptimeChecks } from './uptime-check.js'
import { notifyCfdmVpsEvent } from './cfdm-notify.js'
import { publishMany, publishNotification } from './notifications/engine.js'
import {
buildLowBalanceNotification,
@@ -154,6 +155,12 @@ export async function runScheduledUptimeChecks(): Promise<void> {
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
if (newlyDown.length > 0) {
void notifyCfdmVpsEvent(
'vps_down',
newlyDown.map((h) => h.id),
)
}
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}