fix(api): передавать наблюдаемые состояния сертификатов

This commit is contained in:
Denozordec committed 2026-10-01 11:18:32 +07:00
1 parent 71cc6fa3a7
commit 26869632a0
9 files changed
+151 -5

No files matched your search

+3 -2
View File
@@ -26,10 +26,11 @@
"test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts",
"test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts", "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts" "test:containers": "tsx src/services/containers-state.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.888.0", "@aws-sdk/client-s3": "^3.888.0",
@@ -0,0 +1,15 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod"
import {createCertificatesRoutes} from "./certificates.js"
import {permissionForRequest} from "../lib/permissions.js"
import type {CertificatesState} from "@mmapp/contracts/certificates"
let state:unknown={collectedAt:new Date().toISOString(),servers:[]},failed=false
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
await app.register(createCertificatesRoutes({getState:async()=>{if(failed)throw new Error("DB unavailable");return state as CertificatesState}}),{prefix:"/api"})
const read=()=>app.inject({method:"GET",url:"/api/certificates/state"})
assert.equal((await read()).statusCode,200);assert.deepEqual((await read()).json().servers,[])
state={collectedAt:new Date().toISOString(),servers:[{serverId:"1",serverName:"Router",collectedAt:new Date().toISOString(),certificates:[],error:"offline"}]};assert.equal((await read()).statusCode,200);assert.equal((await read()).json().servers[0].error,"offline")
for(const value of [{certificates:[]},{collectedAt:"bad",servers:[]},{collectedAt:new Date().toISOString(),servers:[{}]}]){state=value;assert.equal((await read()).statusCode,502)}
failed=true;assert.equal((await read()).statusCode,500);assert.equal(permissionForRequest("GET","/api/certificates/state"),"mm:certificates:read")
await app.close();console.log("Certificates HTTP: known empty/partial, invalid 502, DB failure 500, existing RBAC")
+12 -2
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { import {
certificateIssueRequestSchema, certificateIssueRequestSchema,
certificatesStateSchema,
putAcmeCloudflareSettingsSchema, putAcmeCloudflareSettingsSchema,
putCertificateRenewSettingsSchema, putCertificateRenewSettingsSchema,
testAcmeCloudflareSettingsSchema, testAcmeCloudflareSettingsSchema,
@@ -17,12 +18,19 @@ import {
getIssueJobRecord, getIssueJobRecord,
getServerRowByIdString, getServerRowByIdString,
listCertificatesFromServers, listCertificatesFromServers,
listCertificatesState,
toIssueJobDto, toIssueJobDto,
updateAcmeSettings, updateAcmeSettings,
updateCertificateRenewSettings, updateCertificateRenewSettings,
} from "../services/certificates-service.js" } from "../services/certificates-service.js"
const certificatesRoutes: FastifyPluginAsyncZod = async (app) => { export function createCertificatesRoutes(deps: { getState?: typeof listCertificatesState } = {}): FastifyPluginAsyncZod {
return async (app) => {
app.get("/certificates/state", async (_req, reply) => {
const parsed = certificatesStateSchema.safeParse(await (deps.getState ?? listCertificatesState)())
if (!parsed.success) return reply.status(502).send({ error: "Некорректный каталог сертификатов" })
return reply.send(parsed.data)
})
app.get("/certificates", async (_req, reply) => { app.get("/certificates", async (_req, reply) => {
return reply.send(await listCertificatesFromServers()) return reply.send(await listCertificatesFromServers())
}) })
@@ -109,4 +117,6 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
}) })
} }
export default certificatesRoutes }
export default createCertificatesRoutes()
@@ -270,3 +270,9 @@ export async function hasActiveIssueJob(serverId: string, certName: string): Pro
const sid = Number(serverId) const sid = Number(serverId)
return rows.some((r) => r.serverId === sid && r.certName === certName) return rows.some((r) => r.serverId === sid && r.certName === certName)
} }
export async function listCertificatesState() {
const { collectCertificatesState } = await import("./certificates-state.js")
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
return collectCertificatesState(enabledServers, server => { const client=MikrotikClient.fromServer(server); return () => client.get<unknown>("/certificate") })
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import {mapObservedCertificate,readCertificateServerState,collectCertificatesState} from "./certificates-state.js"
import {certificatesStateSchema,certificateObservedSchema} from "@mmapp/contracts/certificates"
const server={id:1,name:"Router"},now=new Date("2026-10-01T00:00:00Z")
const raw={".id":"*A",name:"cert-live",flags:"KT","invalid-before":"2026-09-01T00:00:00Z","invalid-after":"2026-11-01T00:00:00Z","key-size":"2048",trusted:"true"}
const good=mapObservedCertificate(server,raw,now)
assert.equal(good.status,"valid");assert.equal(good.daysLeft,31);assert.equal(good.keySize,2048);assert.equal(good.trusted,true);assert.equal(good.rosId,"*A")
const unknown=mapObservedCertificate(server,{name:"unsigned"},now)
assert.equal(unknown.daysLeft,null);assert.equal(unknown.status,"unknown");assert.equal(unknown.trusted,null);assert.equal(unknown.keySize,null);assert.equal(unknown.rosId,null)
for(const date of ["yesterday","2026-02-30T00:00:00Z","oct/01/2026 12:00:00","2026-11-01"]){const row=mapObservedCertificate(server,{...raw,"invalid-after":date},now);assert.equal(row.daysLeft,null,date);assert.equal(row.status,"unknown");assert.equal(row.rawValidUntil,date)}
assert.equal(mapObservedCertificate(server,{...raw,"invalid-before":"2026-10-01T03:00:00+03:00"},now).validFrom,now.toISOString())
assert.equal(mapObservedCertificate(server,{...raw,"invalid-before":"2026-12-01T00:00:00Z"},now).status,"unknown")
assert.equal(mapObservedCertificate(server,{...raw,flags:undefined,revoked:undefined},now).status,"unknown")
assert.equal(mapObservedCertificate(server,{...raw,flags:"R","invalid-after":undefined},now).status,"revoked")
assert.equal(mapObservedCertificate(server,{...raw,flags:"E","invalid-after":undefined},now).status,"expired")
assert.equal(mapObservedCertificate(server,{...raw,"invalid-after":"2026-09-01T00:00:00Z"},now).status,"expired")
for(const size of ["0","bad","256x","-1","256.5"])assert.equal(mapObservedCertificate(server,{...raw,"key-size":size},now).keySize,null)
assert.equal((await readCertificateServerState(server,async()=>[])).error,null)
for(const value of [{certificates:[]},[{}],[raw,raw],[{...raw,flags:true}]])assert.ok((await readCertificateServerState(server,async()=>value)).error)
const partial=await collectCertificatesState([server,{id:2,name:"offline"}],srv=>async()=>{if(srv.id===2)throw new Error("offline");return [raw]})
assert.equal(partial.servers[0].certificates.length,1);assert.equal(partial.servers[1].error,"offline");assert.equal((await collectCertificatesState([],()=>async()=>[])).servers.length,0)
for(const value of [{...partial,servers:[partial.servers[0],partial.servers[0]]},{...partial,servers:[{...partial.servers[0],serverId:"2"}]},{...partial,servers:[{...partial.servers[0],error:"failure"}]}])assert.equal(certificatesStateSchema.safeParse(value).success,false)
assert.equal(certificateObservedSchema.safeParse({...good,validUntil:null}).success,false)
console.log("Certificate observed: unknown/date timezone/invalid calendar, flags, key size, known empty, malformed/duplicate/context, partial devices; no DB/RouterOS")
@@ -0,0 +1,41 @@
import {z} from "zod"
import {certificateObservedSchema,certificateServerStateSchema,certificatesStateSchema,type CertificateServerState,type CertificatesState} from "@mmapp/contracts/certificates"
import {mapRosCertificateRow,type RosCertificateRow} from "./certificate-parse.js"
type ServerContext={id:number;name:string}
const rawSchema=z.array(z.record(z.string(),z.string().optional()).refine(row=>!!row.name?.trim(),"Отсутствует имя сертификата"))
const dateSchema=z.string().datetime({offset:true})
function observedDate(raw:string|undefined) {
const value=raw?.trim()
return value&&dateSchema.safeParse(value).success?new Date(value):null
}
export function mapObservedCertificate(server:ServerContext,row:RosCertificateRow,now=new Date()) {
const base=mapRosCertificateRow(server.id,server.name,row)
if(!base)throw new Error("Отсутствует сертификат")
const from=observedDate(row["invalid-before"]),until=observedDate(row["invalid-after"])
const flags=row.flags?.toUpperCase()
const revoked=flags?.includes("R")||row.revoked==="true"||row.revoked==="yes"
const expired=flags?.includes("E")||row.expired==="true"||row.expired==="yes"||!!until&&until.getTime()<now.getTime()
const revocationKnown=flags!==undefined||row.revoked==="false"||row.revoked==="no"
const valid=revocationKnown&&!!from&&!!until&&from.getTime()<=now.getTime()&&until.getTime()>=now.getTime()
const size=row["key-size"]?.trim()
const keySize=size&&/^\d+$/.test(size)&&Number.isSafeInteger(Number(size))&&Number(size)>0?Number(size):null
const trusted=row.trusted==="true"||row.trusted==="yes"?true:row.trusted==="false"||row.trusted==="no"?false:flags!==undefined?flags.includes("T"):null
return certificateObservedSchema.parse({...base,rosId:row[".id"]?.trim()||null,
validFrom:from?.toISOString()??null,validUntil:until?.toISOString()??null,
rawValidFrom:row["invalid-before"]??null,rawValidUntil:row["invalid-after"]??null,
daysLeft:until?Math.ceil((until.getTime()-now.getTime())/86_400_000):null,keySize,trusted,
status:revoked?"revoked":expired?"expired":valid?"valid":"unknown"})
}
export async function readCertificateServerState(server:ServerContext,read:()=>Promise<unknown>):Promise<CertificateServerState> {
try{
const rows=rawSchema.parse(await read()),now=new Date()
const result={serverId:String(server.id),serverName:server.name,collectedAt:now.toISOString(),certificates:rows.map(row=>mapObservedCertificate(server,row,now)),error:null}
return certificateServerStateSchema.parse(result)
}catch(error){
return certificateServerStateSchema.parse({serverId:String(server.id),serverName:server.name,collectedAt:new Date().toISOString(),certificates:[],error:error instanceof z.ZodError?"Некорректный ответ каталога сертификатов":error instanceof Error?error.message.slice(0,500)||"Ошибка RouterOS":"Ошибка RouterOS"})
}
}
export async function collectCertificatesState<T extends ServerContext>(servers:T[],makeReader:(server:T)=>()=>Promise<unknown>):Promise<CertificatesState>{
const states=await Promise.all(servers.map(server=>readCertificateServerState(server,makeReader(server))))
return certificatesStateSchema.parse({collectedAt:new Date().toISOString(),servers:states})
}
+10 -1
View File
@@ -27,7 +27,7 @@
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.13; пользователи и контейнеры приняты локально, далее сертификаты | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.14; пользователи/контейнеры приняты локально, observed API сертификатов подготовлен |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,15 @@
## Блок 11А.14 — observed state API сертификатов (2026-10-01)
- Добавлен совместимый GET /certificates/state: collectedAt, отдельные enabled устройства, подтверждённые строки или error. Новое чтение /certificate не использует tolerant getCertificates, скрывавший неверный ответ как [].
- Новый CertificateObserved не подменяет отсутствующие срок/keySize/trusted/rosId. daysLeft nullable, status unknown; даты без часового пояса/некорректного календаря остаются raw полями и не используются для вычисления срока. Известные UTC/offset даты нормализованы; valid требует известных сроков и сведений об отзыве. Неизвестный будущий/неполный сертификат не становится valid.
- Контракт отклоняет дубли/чужой контекст, несогласованные срок/статус и error со строками. Частичный отказ одного устройства сохраняет остальные; общий DB отказ — HTTP 500, неверный envelope — 502.
- Legacy DTO/API, parser ролей, ACME issue/renew и сетевые операции сохранены для совместимости. Frontend ещё читает legacy; эти сценарии не приняты и требуют последующей миграции.
- Backend build, профильный lint, observed service/HTTP tests и прежний certificate-parse test прошли. test:certificates включён в общий test. PostgreSQL/RouterOS/ACME не использованы в тестах; локальные bare-time даты требуют отдельной проверки часового пояса устройства.
- Далее 11А.15: typed frontend reader и immutable merge последних снимков; затем 11А.16 — таблица/детали/unknown KPI, partial сбои, freshness, контекст/права. ACME settings/jobs/экспорт — последующие блоки. Резервирование, 11Б и итоговый 12 ещё не приняты.
## Блок 11А.13 — контекст и локальная приёмка контейнеров (2026-10-01) ## Блок 11А.13 — контекст и локальная приёмка контейнеров (2026-10-01)
- URL восстанавливает server/status/q, поддерживает Back/reload, сохраняет чужие параметры/hash. Дубли, неверные ID/status и слишком длинный поиск показывают ошибку со сбросом. Использован существующий preserveSelection ServerRail; исчезнувшее устройство не заменяет контекст. - URL восстанавливает server/status/q, поддерживает Back/reload, сохраняет чужие параметры/hash. Дубли, неверные ID/status и слишком длинный поиск показывают ошибку со сбросом. Использован существующий preserveSelection ServerRail; исчезнувшее устройство не заменяет контекст.
+10
View File
@@ -153,3 +153,13 @@
- 40 контейнерных E2E, 8 source-context, 10 профильных проверок экспорта, URL unit, TypeScript, профильный lint (включая общую оболочку) и production frontend прошли. Backend state тесты/сборка приняты в 11А.12. Контейнеры приняты локально; создание/логи/редактирование явно disabled, реальный RouterOS/БД требует внешней проверки. - 40 контейнерных E2E, 8 source-context, 10 профильных проверок экспорта, URL unit, TypeScript, профильный lint (включая общую оболочку) и production frontend прошли. Backend state тесты/сборка приняты в 11А.12. Контейнеры приняты локально; создание/логи/редактирование явно disabled, реальный RouterOS/БД требует внешней проверки.
- Далее 11А.14: observed state API сертификатов. Исходный mapper делает отсутствующий срок daysLeft=0, status=valid, keySize=0/trusted=false; это нельзя выдавать за наблюдаемые значения. Legacy API/renew/issue должны сохранять совместимость до отдельной миграции; frontend, ACME jobs, настройки и экспорт сертификатов ещё не приняты. - Далее 11А.14: observed state API сертификатов. Исходный mapper делает отсутствующий срок daysLeft=0, status=valid, keySize=0/trusted=false; это нельзя выдавать за наблюдаемые значения. Legacy API/renew/issue должны сохранять совместимость до отдельной миграции; frontend, ACME jobs, настройки и экспорт сертификатов ещё не приняты.
## Блок 11А.14 — observed state API сертификатов (2026-10-01)
- Добавлен совместимый GET /certificates/state: collectedAt, отдельные enabled устройства, подтверждённые строки или error. Новое чтение /certificate не использует tolerant getCertificates, скрывавший неверный ответ как [].
- Новый CertificateObserved не подменяет отсутствующие срок/keySize/trusted/rosId. daysLeft nullable, status unknown; даты без часового пояса/некорректного календаря остаются raw полями и не используются для вычисления срока. Известные UTC/offset даты нормализованы; valid требует известных сроков и сведений об отзыве. Неизвестный будущий/неполный сертификат не становится valid.
- Контракт отклоняет дубли/чужой контекст, несогласованные срок/статус и error со строками. Частичный отказ одного устройства сохраняет остальные; общий DB отказ — HTTP 500, неверный envelope — 502.
- Legacy DTO/API, parser ролей, ACME issue/renew и сетевые операции сохранены для совместимости. Frontend ещё читает legacy; эти сценарии не приняты и требуют последующей миграции.
- Backend build, профильный lint, observed service/HTTP tests и прежний certificate-parse test прошли. test:certificates включён в общий test. PostgreSQL/RouterOS/ACME не использованы в тестах; локальные bare-time даты требуют отдельной проверки часового пояса устройства.
- Далее 11А.15: typed frontend reader и immutable merge последних снимков; затем 11А.16 — таблица/детали/unknown KPI, partial сбои, freshness, контекст/права. ACME settings/jobs/экспорт — последующие блоки. Резервирование, 11Б и итоговый 12 ещё не приняты.
+30
View File
@@ -127,3 +127,33 @@ export type CertificateIssueRequest = z.infer<typeof certificateIssueRequestSche
export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema> export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema>
export type AcmeCloudflareSettingsDto = z.infer<typeof acmeCloudflareSettingsDtoSchema> export type AcmeCloudflareSettingsDto = z.infer<typeof acmeCloudflareSettingsDtoSchema>
export type CertificateRenewSettingsDto = z.infer<typeof certificateRenewSettingsDtoSchema> export type CertificateRenewSettingsDto = z.infer<typeof certificateRenewSettingsDtoSchema>
export const certificateObservedSchema = certificateDtoSchema.extend({
rosId: z.string().min(1).nullable(),
validFrom: z.string().datetime({ offset: true }).nullable(),
validUntil: z.string().datetime({ offset: true }).nullable(),
rawValidFrom: z.string().nullable(), rawValidUntil: z.string().nullable(),
daysLeft: z.number().int().nullable(), keySize: z.number().int().positive().nullable(),
trusted: z.boolean().nullable(), status: z.enum(["valid", "expired", "revoked", "unknown"]),
}).superRefine((value,ctx) => {
if ((value.validUntil === null) !== (value.daysLeft === null)) ctx.addIssue({code:"custom",message:"Срок не соответствует дате"})
if (value.status === "valid" && (!value.validFrom || !value.validUntil || value.daysLeft === null || value.daysLeft < 0 || Date.parse(value.validFrom)>Date.parse(value.validUntil))) ctx.addIssue({code:"custom",message:"Действительность не подтверждена датами"})
})
export const certificateServerStateSchema = z.object({
serverId: z.string().regex(/^[1-9]\d*$/).refine(value => Number.isSafeInteger(Number(value))),
serverName: z.string(), collectedAt: z.string().datetime({ offset: true }),
certificates: z.array(certificateObservedSchema),
error: z.string().min(1).nullable(),
}).superRefine((value,ctx) => {
if (value.error && value.certificates.length) ctx.addIssue({code:"custom",message:"Ошибка загрузки содержит сертификаты"})
if (new Set(value.certificates.map(row=>row.id)).size !== value.certificates.length) ctx.addIssue({code:"custom",message:"Дубли сертификатов"})
if (value.certificates.some(row=>row.serverId !== value.serverId || !row.id.startsWith(value.serverId + ":"))) ctx.addIssue({code:"custom",message:"Чужой контекст сертификата"})
})
export const certificatesStateSchema = z.object({
collectedAt: z.string().datetime({ offset: true }), servers: z.array(certificateServerStateSchema),
}).superRefine((value,ctx) => {
if(new Set(value.servers.map(server=>server.serverId)).size!==value.servers.length)ctx.addIssue({code:"custom",message:"Дубли устройств"})
})
export type CertificateObserved = z.infer<typeof certificateObservedSchema>
export type CertificateServerState = z.infer<typeof certificateServerStateSchema>
export type CertificatesState = z.infer<typeof certificatesStateSchema>