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
+102 -2
View File
@@ -1,10 +1,52 @@
import { asc, eq } from 'drizzle-orm'
import {
appSwitcherConfigSchema,
type AppSwitcherConfig,
} from '@cfdm/shared/contracts/app-switcher'
import { getDb, schema } from '../index.js'
type Row = typeof schema.settings.$inferSelect
export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEnabled' | 'notifyPaymentExpiryEnabled' | 'notifyNewTariffsEnabled' | 'notifyLowBalanceEnabled' | 'notifySyncDigestEnabled' | 'notifyVpsDownEnabled' | 'webhookEnabled' | 'customFields'> & {
const DEFAULT_APP_SWITCHER: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps-tracker',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'http://192.168.100.67:3001',
icon: 'server',
shortcut: '⌘1',
},
{
id: 'cfdm',
name: 'CF Domain Manager',
subtitle: 'Управление доменами',
url: 'http://192.168.100.67:6363',
icon: 'cloud',
shortcut: '⌘2',
},
],
}
export type SettingsDto = Omit<
Row,
| 'telegramBotToken'
| 'integrationToken'
| 'autoConvert'
| 'syncEnabled'
| 'notifyPaymentExpiryEnabled'
| 'notifyNewTariffsEnabled'
| 'notifyLowBalanceEnabled'
| 'notifySyncDigestEnabled'
| 'notifyVpsDownEnabled'
| 'webhookEnabled'
| 'integrationEnabled'
| 'customFields'
| 'appSwitcherJson'
> & {
telegramBotTokenSet: boolean
integrationTokenSet: boolean
autoConvert: boolean
syncEnabled: boolean
notifyPaymentExpiryEnabled: boolean
@@ -13,9 +55,20 @@ export type SettingsDto = Omit<Row, 'telegramBotToken' | 'autoConvert' | 'syncEn
notifySyncDigestEnabled: boolean
notifyVpsDownEnabled: boolean
webhookEnabled: boolean
integrationEnabled: boolean
notifyIntervalMinutes: number
uptimeCheckIntervalMinutes: number
customFields: unknown[]
appSwitcher: AppSwitcherConfig
}
function parseAppSwitcher(raw: string | null | undefined): AppSwitcherConfig {
if (!raw?.trim()) return DEFAULT_APP_SWITCHER
try {
return appSwitcherConfigSchema.parse(JSON.parse(raw))
} catch {
return DEFAULT_APP_SWITCHER
}
}
function toDto(row: Row | undefined): SettingsDto | undefined {
@@ -28,10 +81,11 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
customFields = []
}
}
const { telegramBotToken, ...rest } = row
const { telegramBotToken, integrationToken, appSwitcherJson, ...rest } = row
return {
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
integrationTokenSet: Boolean(integrationToken?.trim()),
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
@@ -40,9 +94,11 @@ function toDto(row: Row | undefined): SettingsDto | undefined {
notifySyncDigestEnabled: Boolean(row.notifySyncDigestEnabled),
notifyVpsDownEnabled: Boolean(row.notifyVpsDownEnabled),
webhookEnabled: Boolean(row.webhookEnabled),
integrationEnabled: Boolean(row.integrationEnabled),
notifyIntervalMinutes: Number(row.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(row.uptimeCheckIntervalMinutes) || 5,
customFields: Array.isArray(customFields) ? customFields : [],
appSwitcher: parseAppSwitcher(appSwitcherJson),
}
}
@@ -74,6 +130,11 @@ interface SettingsInput {
notifyIntervalMinutes?: number
uptimeCheckIntervalMinutes?: number
customFields?: unknown
appSwitcher?: AppSwitcherConfig
integrationToken?: string
integrationEnabled?: boolean
integrationLastSyncAt?: string
cfdmApiUrl?: string
}
function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
@@ -156,6 +217,27 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
? Math.max(1, Number(r.uptimeCheckIntervalMinutes) || 5)
: existing?.uptimeCheckIntervalMinutes ?? 5,
customFields: serializeCustomFields(r.customFields ?? existing?.customFields),
appSwitcherJson:
r.appSwitcher !== undefined
? JSON.stringify(r.appSwitcher)
: existing?.appSwitcherJson ?? JSON.stringify(DEFAULT_APP_SWITCHER),
integrationToken:
r.integrationToken !== undefined && String(r.integrationToken || '').trim() !== ''
? r.integrationToken
: existing?.integrationToken ?? '',
integrationEnabled:
r.integrationEnabled !== undefined
? r.integrationEnabled
? 1
: 0
: existing?.integrationEnabled
? 1
: 0,
integrationLastSyncAt:
r.integrationLastSyncAt !== undefined
? r.integrationLastSyncAt || ''
: existing?.integrationLastSyncAt ?? '',
cfdmApiUrl: r.cfdmApiUrl !== undefined ? r.cfdmApiUrl || '' : existing?.cfdmApiUrl ?? '',
}
}
@@ -170,6 +252,24 @@ export const settingsRepository = {
getRow(id: string): Row | undefined {
return getDb().select().from(schema.settings).where(eq(schema.settings.id, id)).get()
},
getIntegrationToken(id = 'settings-main'): string {
return this.getRow(id)?.integrationToken?.trim() ?? ''
},
getAppSwitcher(id = 'settings-main'): AppSwitcherConfig {
const row = this.getRow(id)
return parseAppSwitcher(row?.appSwitcherJson)
},
touchIntegrationSync(id = 'settings-main'): void {
const db = getDb()
const at = new Date().toISOString()
const existing = this.getRow(id)
if (existing) {
db.update(schema.settings)
.set({ integrationLastSyncAt: at })
.where(eq(schema.settings.id, id))
.run()
}
},
upsert(id: string, input: SettingsInput): SettingsDto {
const db = getDb()
const existing = this.getRow(id)
+4
View File
@@ -7,6 +7,7 @@ import { settingsRepository } from './settings.js'
import { activeTariffsRepository, tariffSyncOptionsRepository } from './tariffs.js'
import { projectsRepository } from './projects.js'
import { syncLogRepository } from './sync-log.js'
import { vpsDomainsRepository } from './vps-domains.js'
export interface Snapshot {
vps: ReturnType<typeof vpsRepository.list>
@@ -19,6 +20,7 @@ export interface Snapshot {
activeTariffs: ReturnType<typeof activeTariffsRepository.list>
tariffSyncOptions: ReturnType<typeof tariffSyncOptionsRepository.list>
syncLog: ReturnType<typeof syncLogRepository.listRecent>
vpsDomains: ReturnType<typeof vpsDomainsRepository.list>
}
export function getSnapshot(): Snapshot {
@@ -33,6 +35,7 @@ export function getSnapshot(): Snapshot {
activeTariffs: activeTariffsRepository.list(),
tariffSyncOptions: tariffSyncOptionsRepository.list(),
syncLog: syncLogRepository.listRecent(50),
vpsDomains: vpsDomainsRepository.list(),
}
}
@@ -47,4 +50,5 @@ export {
tariffSyncOptionsRepository,
projectsRepository,
syncLogRepository,
vpsDomainsRepository,
}
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '../index.js'
import { vpsRepository } from './vps.js'
import { vpsDomainsRepository } from './vps-domains.js'
import { settingsRepository } from './settings.js'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '../test-setup.js'
describe('vpsDomainsRepository', () => {
beforeEach(() => {
resetTestDb()
seedTestProvider('p1')
seedTestProviderAccount('a1', 'p1')
})
afterEach(() => {
closeDb()
})
it('привязывает домен к VPS по IP', () => {
const vps = vpsRepository.create({
ip: '203.0.113.10',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 1,
ramGb: 1,
diskGb: 10,
})
const created = Array.isArray(vps) ? vps[0]! : vps
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 1,
serviceId: 10,
serviceName: 'VPN Node',
serviceSlug: 'vpn-node',
fqdn: 'vpn.example.com',
zoneName: 'example.com',
hostname: 'vpn',
ips: ['203.0.113.10'],
},
])
expect(result.upserted).toBe(1)
expect(result.matched).toBe(1)
const domains = vpsDomainsRepository.listByVpsId(created.id)
expect(domains).toHaveLength(1)
expect(domains[0]?.fqdn).toBe('vpn.example.com')
expect(domains[0]?.matchStatus).toBe('matched')
})
it('помечает unmatched без совпадения IP', () => {
const result = vpsDomainsRepository.syncBindings([
{
bindingId: 2,
serviceId: 11,
serviceName: 'CDN',
serviceSlug: 'cdn',
fqdn: 'cdn.example.com',
zoneName: 'example.com',
hostname: 'cdn',
ips: ['198.51.100.1'],
},
])
expect(result.unmatched).toBe(1)
expect(vpsDomainsRepository.listUnmatched()).toHaveLength(1)
})
})
describe('settingsRepository integration fields', () => {
beforeEach(() => {
resetTestDb()
})
afterEach(() => {
closeDb()
})
it('маскирует integration token в DTO', () => {
settingsRepository.upsert('settings-main', {
integrationToken: 'secret-token-value',
integrationEnabled: true,
})
const dto = settingsRepository.get('settings-main')
expect(dto?.integrationTokenSet).toBe(true)
expect(dto).not.toHaveProperty('integrationToken')
})
})
+196
View File
@@ -0,0 +1,196 @@
import { asc, eq, isNull } from 'drizzle-orm'
import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfdm'
import { getDb, schema } from '../index.js'
import { generateId } from './utils.js'
import { vpsRepository } from './vps.js'
type Row = typeof schema.vpsDomains.$inferSelect
export type VpsDomainDto = Row
function normalizeIp(ip: string): string {
return ip.trim().toLowerCase()
}
function collectVpsIps(vps: { ip?: string | null; additionalIps?: string[] }): string[] {
const ips: string[] = []
if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip))
for (const raw of vps.additionalIps ?? []) {
if (raw?.trim()) ips.push(normalizeIp(raw))
}
return ips
}
function findVpsIdByIps(
allVps: ReturnType<typeof vpsRepository.list>,
ips: string[],
): string | null {
const normalized = [...new Set(ips.map(normalizeIp).filter(Boolean))]
if (normalized.length === 0) return null
const matches: string[] = []
for (const v of allVps) {
const vips = collectVpsIps(v)
if (normalized.some((ip) => vips.includes(ip))) {
matches.push(v.id)
}
}
if (matches.length === 1) return matches[0]!
return null
}
function resolveMatchStatus(vpsId: string | null): 'matched' | 'unmatched' {
return vpsId ? 'matched' : 'unmatched'
}
export const vpsDomainsRepository = {
list(): VpsDomainDto[] {
return getDb()
.select()
.from(schema.vpsDomains)
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
listByVpsId(vpsId: string): VpsDomainDto[] {
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.vpsId, vpsId))
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
getByCfdmBindingId(bindingId: number): VpsDomainDto | undefined {
return getDb()
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmBindingId, bindingId))
.get()
},
deleteByCfdmBindingId(bindingId: number): boolean {
const row = this.getByCfdmBindingId(bindingId)
if (!row) return false
getDb().delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
return true
},
rematchAll(): { updated: number } {
const db = getDb()
const allVps = vpsRepository.list()
const rows = db.select().from(schema.vpsDomains).all()
let updated = 0
const vpsIds = new Set(allVps.map((v) => v.id))
for (const row of rows) {
let storedIps: string[] = []
try {
storedIps = row.targetIps ? JSON.parse(row.targetIps) : []
} catch {
storedIps = []
}
let vpsId = row.vpsId
if (vpsId && !vpsIds.has(vpsId)) {
vpsId = null
}
if (!vpsId && storedIps.length > 0) {
vpsId = findVpsIdByIps(allVps, storedIps)
}
const matchStatus =
vpsId && vpsIds.has(vpsId)
? 'matched'
: row.vpsId && !vpsIds.has(row.vpsId)
? 'orphaned'
: resolveMatchStatus(vpsId)
if (vpsId !== row.vpsId || matchStatus !== row.matchStatus) {
db.update(schema.vpsDomains)
.set({ vpsId, matchStatus })
.where(eq(schema.vpsDomains.id, row.id))
.run()
updated++
}
}
return { updated }
},
syncBindings(items: CfdmBindingSyncItem[]): {
matched: number
unmatched: number
deleted: number
upserted: number
} {
const db = getDb()
const allVps = vpsRepository.list()
const now = new Date().toISOString()
let matched = 0
let unmatched = 0
let deleted = 0
let upserted = 0
for (const item of items) {
if (item.deleted) {
if (this.deleteByCfdmBindingId(item.bindingId)) deleted++
continue
}
const vpsId = findVpsIdByIps(allVps, item.ips)
const matchStatus = resolveMatchStatus(vpsId)
if (matchStatus === 'matched') matched++
else unmatched++
const existing = this.getByCfdmBindingId(item.bindingId)
const values = {
vpsId,
fqdn: item.fqdn,
zoneName: item.zoneName,
hostname: item.hostname,
serviceName: item.serviceName,
serviceSlug: item.serviceSlug,
cfdmServiceId: item.serviceId,
cfdmBindingId: item.bindingId,
source: 'cfdm' as const,
matchStatus,
targetIps: JSON.stringify(item.ips),
syncedAt: now,
}
if (existing) {
db.update(schema.vpsDomains).set(values).where(eq(schema.vpsDomains.id, existing.id)).run()
} else {
db.insert(schema.vpsDomains).values({ id: generateId('vd'), ...values }).run()
}
upserted++
}
return { matched, unmatched, deleted, upserted }
},
markOrphanedForMissingBindings(serviceId: number, keptBindingIds: number[]): number {
const db = getDb()
const rows = db
.select()
.from(schema.vpsDomains)
.where(eq(schema.vpsDomains.cfdmServiceId, serviceId))
.all()
let removed = 0
for (const row of rows) {
if (!keptBindingIds.includes(row.cfdmBindingId)) {
db.delete(schema.vpsDomains).where(eq(schema.vpsDomains.id, row.id)).run()
removed++
}
}
return removed
},
listUnmatched(): VpsDomainDto[] {
return getDb()
.select()
.from(schema.vpsDomains)
.where(isNull(schema.vpsDomains.vpsId))
.orderBy(asc(schema.vpsDomains.fqdn))
.all()
},
}
+21
View File
@@ -9,6 +9,12 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE settings ADD COLUMN webhookEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN notifyIntervalMinutes INTEGER`,
`ALTER TABLE settings ADD COLUMN uptimeCheckIntervalMinutes INTEGER`,
`ALTER TABLE settings ADD COLUMN appSwitcherJson TEXT`,
`ALTER TABLE settings ADD COLUMN integrationToken TEXT`,
`ALTER TABLE settings ADD COLUMN integrationEnabled INTEGER`,
`ALTER TABLE settings ADD COLUMN integrationLastSyncAt TEXT`,
`ALTER TABLE settings ADD COLUMN cfdmApiUrl TEXT`,
`ALTER TABLE vps_domains ADD COLUMN targetIps TEXT`,
]
const TABLE_MIGRATIONS: string[] = [
@@ -52,6 +58,21 @@ const TABLE_MIGRATIONS: string[] = [
notes TEXT,
createdAt TEXT
)`,
`CREATE TABLE IF NOT EXISTS vps_domains (
id TEXT PRIMARY KEY,
vpsId TEXT REFERENCES vps(id) ON DELETE SET NULL,
fqdn TEXT NOT NULL,
zoneName TEXT NOT NULL,
hostname TEXT NOT NULL,
serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL UNIQUE,
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
syncedAt TEXT NOT NULL
)`,
]
let migrated = false
+21
View File
@@ -128,6 +128,27 @@ export const settings = sqliteTable('settings', {
webhookEnabled: integer('webhookEnabled'),
notifyIntervalMinutes: integer('notifyIntervalMinutes'),
uptimeCheckIntervalMinutes: integer('uptimeCheckIntervalMinutes'),
appSwitcherJson: text('appSwitcherJson'),
integrationToken: text('integrationToken'),
integrationEnabled: integer('integrationEnabled'),
integrationLastSyncAt: text('integrationLastSyncAt'),
cfdmApiUrl: text('cfdmApiUrl'),
})
export const vpsDomains = sqliteTable('vps_domains', {
id: text('id').primaryKey(),
vpsId: text('vpsId').references(() => vps.id, { onDelete: 'set null' }),
fqdn: text('fqdn').notNull(),
zoneName: text('zoneName').notNull(),
hostname: text('hostname').notNull(),
serviceName: text('serviceName').notNull(),
serviceSlug: text('serviceSlug').notNull(),
cfdmServiceId: integer('cfdmServiceId').notNull(),
cfdmBindingId: integer('cfdmBindingId').notNull(),
source: text('source').notNull().default('cfdm'),
matchStatus: text('matchStatus').notNull().default('unmatched'),
targetIps: text('targetIps'),
syncedAt: text('syncedAt').notNull(),
})
export const notificationLog = sqliteTable('notification_log', {
+23 -1
View File
@@ -160,7 +160,29 @@ CREATE TABLE IF NOT EXISTS settings (
webhookUrl TEXT,
webhookEnabled INTEGER,
notifyIntervalMinutes INTEGER,
uptimeCheckIntervalMinutes INTEGER
uptimeCheckIntervalMinutes INTEGER,
appSwitcherJson TEXT,
integrationToken TEXT,
integrationEnabled INTEGER,
integrationLastSyncAt TEXT,
cfdmApiUrl TEXT
);
CREATE TABLE IF NOT EXISTS vps_domains (
id TEXT PRIMARY KEY,
vpsId TEXT,
fqdn TEXT NOT NULL,
zoneName TEXT NOT NULL,
hostname TEXT NOT NULL,
serviceName TEXT NOT NULL,
serviceSlug TEXT NOT NULL,
cfdmServiceId INTEGER NOT NULL,
cfdmBindingId INTEGER NOT NULL UNIQUE,
source TEXT NOT NULL DEFAULT 'cfdm',
matchStatus TEXT NOT NULL DEFAULT 'unmatched',
targetIps TEXT,
syncedAt TEXT NOT NULL,
FOREIGN KEY (vpsId) REFERENCES vps(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS notification_log (
@@ -0,0 +1,20 @@
import { z } from 'zod'
export const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
export const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string().url('Невалидный URL'),
icon: appSwitcherIconSchema.default('server'),
shortcut: z.string().optional(),
})
export const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
@@ -0,0 +1,34 @@
import { z } from 'zod'
export const cfdmBindingSyncItemSchema = z.object({
bindingId: z.number().int().positive(),
serviceId: z.number().int().positive(),
serviceName: z.string().min(1),
serviceSlug: z.string().min(1),
fqdn: z.string().min(1),
zoneName: z.string().min(1),
hostname: z.string(),
ips: z.array(z.string()),
deleted: z.boolean().optional(),
})
export const cfdmSyncBindingsBodySchema = z.object({
bindings: z.array(cfdmBindingSyncItemSchema).min(1),
})
export type CfdmBindingSyncItem = z.infer<typeof cfdmBindingSyncItemSchema>
export type CfdmSyncBindingsBody = z.infer<typeof cfdmSyncBindingsBodySchema>
export const vpsTrackerEventSchema = z.object({
event: z.enum(['vps_down', 'vps_up']),
vps: z.array(
z.object({
id: z.string().min(1),
ip: z.string().optional(),
label: z.string().optional(),
}),
),
timestamp: z.string().datetime().optional(),
})
export type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { customFieldsSchema } from './custom-fields.js'
import { appSwitcherConfigSchema } from './app-switcher.js'
export const settingsSchema = z.object({
id: z.string().optional(),
@@ -23,6 +24,9 @@ export const settingsSchema = z.object({
webhookUrl: z.string().url('Невалидный URL').or(z.literal('')).optional(),
webhookEnabled: z.boolean().optional(),
customFields: customFieldsSchema.optional(),
appSwitcher: appSwitcherConfigSchema.optional(),
integrationToken: z.string().optional(),
integrationEnabled: z.boolean().optional(),
})
export type Settings = z.infer<typeof settingsSchema>