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

@@ -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 {
certificateIssueRequestSchema,
certificatesStateSchema,
putAcmeCloudflareSettingsSchema,
putCertificateRenewSettingsSchema,
testAcmeCloudflareSettingsSchema,
@@ -17,12 +18,19 @@ import {
getIssueJobRecord,
getServerRowByIdString,
listCertificatesFromServers,
listCertificatesState,
toIssueJobDto,
updateAcmeSettings,
updateCertificateRenewSettings,
} 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) => {
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)
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})
}