fix(api): передавать наблюдаемые состояния сертификатов
This commit is contained in:
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")
|
||||
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user