Синхронизация 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:
@@ -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)) {
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user