fix(certificates): учитывать подтверждённый часовой пояс устройства

This commit is contained in:
Denozordec committed 2026-10-14 21:50:11 +07:00
1 parent e78bc2d7cb
commit 0e86628d58
12 files changed
+176 -22

No files matched your search

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"
import { appendEvent } from "../modules/events/service/events-service.js"
import type { CertificatesRenewRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { readCertificateServerState } from "./certificates-state.js"
import { readCertificateServerState, readCertificateRowsWithClock } from "./certificates-state.js"
import { runCertificateIssueJob } from "./certificate-issue-runner.js"
import {
createIssueJobRecord,
@@ -21,7 +21,10 @@ const defaults = {
getTargets: listManagedCertificateTargets,
hasActiveJob: hasActiveIssueJob,
getServer: getServerRowByIdString,
read: (server: NonNullable<Awaited<ReturnType<typeof getServerRowByIdString>>>) => MikrotikClient.fromServer(server).getCertificates(),
read: (server: NonNullable<Awaited<ReturnType<typeof getServerRowByIdString>>>) => {
const client = MikrotikClient.fromServer(server)
return readCertificateRowsWithClock(() => client.get<unknown>("/certificate"), () => client.get<unknown>("/system/clock"))
},
createJob: createIssueJobRecord,
runJob: runCertificateIssueJob,
getJob: getIssueJobRecord,
+2 -2
View File
@@ -269,7 +269,7 @@ export async function hasActiveIssueJob(serverId: string, certName: string): Pro
}
export async function listCertificatesState() {
const { collectCertificatesState } = await import("./certificates-state.js")
const { collectCertificatesState,readCertificateRowsWithClock } = 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") })
return collectCertificatesState(enabledServers, server => { const client=MikrotikClient.fromServer(server); return () => readCertificateRowsWithClock(()=>client.get<unknown>("/certificate"),()=>client.get<unknown>("/system/clock")) })
}
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import {mapObservedCertificate,readCertificateServerState,collectCertificatesState} from "./certificates-state.js"
import {mapObservedCertificate,readCertificateServerState,collectCertificatesState,readCertificateRowsWithClock} 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"}
@@ -24,3 +24,19 @@ assert.equal(certificateObservedSchema.safeParse({...good,validUntil:null}).succ
console.log("Certificate observed: unknown/date timezone/invalid calendar, flags, key size, known empty, malformed/duplicate/context, partial devices; no DB/RouterOS")
const setupFailure=await collectCertificatesState([server,{id:2,name:"bad client"}],srv=>{if(srv.id===2)throw new Error("client setup refused");return async()=>[raw]});assert.equal(setupFailure.servers[0].certificates.length,1);assert.equal(setupFailure.servers[1].error,"client setup refused")
const native={...raw,"invalid-before":"2026-10-14 15:37:47","invalid-after":"2027-01-12 15:37:46"}
const calibrated=mapObservedCertificate(server,native,new Date("2026-10-14T14:37:23Z"),"+02:00")
assert.equal(calibrated.validFrom,"2026-10-14T13:37:47.000Z");assert.equal(calibrated.validUntil,"2027-01-12T13:37:46.000Z");assert.equal(calibrated.status,"valid");assert.equal(calibrated.rawValidUntil,native["invalid-after"])
for(const offset of [undefined,null,"bad","+14:01","+24:00","+02:60"])assert.equal(mapObservedCertificate(server,native,now,offset).validUntil,null)
assert.equal(mapObservedCertificate(server,{...native,"invalid-after":"2026-02-30 12:00:00"},now,"+02:00").validUntil,null)
assert.equal(mapObservedCertificate(server,raw,now,"+02:00").validUntil,"2026-11-01T00:00:00.000Z")
for(const offsets of [["+02:00","+02:00"],["+02:00","+01:00"],[null,"+02:00"],["+02:00",null]]) {
let index=0
const rows=await readCertificateRowsWithClock(async()=>[native],async()=>({"gmt-offset":offsets[index++]}))
const observed=await readCertificateServerState(server,async()=>rows)
assert.equal(observed.error,null);assert.equal(observed.certificates[0].validUntil,offsets[0]!==null&&offsets[0]===offsets[1]?"2027-01-12T13:37:46.000Z":null)
}
const clockFailure=await readCertificateRowsWithClock(async()=>[native],async()=>{throw Error("clock denied")})
assert.equal((await readCertificateServerState(server,async()=>clockFailure)).certificates[0].daysLeft,null)
console.log("Native dates: observed stable clock offset, future winter expiry, missing/changed/failed clock, invalid calendar, explicit ISO preservation passed")
+20 -7
View File
@@ -4,14 +4,26 @@ import {mapRosCertificateRow,type RosCertificateRow} from "./certificate-parse.j
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
const nativeDate=/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
function validOffset(value:unknown):value is string {
return typeof value==="string"&&/^[+-](?:0\d|1[0-4]):[0-5]\d$/.test(value)&&(!value.slice(1).startsWith("14:")||value.endsWith(":00"))
}
export function mapObservedCertificate(server:ServerContext,row:RosCertificateRow,now=new Date()) {
export async function readCertificateRowsWithClock(read:()=>Promise<unknown>,clock:()=>Promise<unknown>):Promise<unknown> {
const offset=async()=>{try {const row=await clock();const value=row&&typeof row==="object"?(row as Record<string,unknown>)["gmt-offset"]:null;return validOffset(value)?value:null}catch{return null}}
const before=await offset(),rows=await read()
if(!Array.isArray(rows)||!rows.some(row=>row&&[row["invalid-before"],row["invalid-after"]].some(value=>typeof value==="string"&&nativeDate.test(value.trim()))))return rows
const after=await offset()
return {rows,gmtOffset:before!==null&&before===after?before:null}
}
function observedDate(raw:string|undefined,gmtOffset?:string|null) {
const value=raw?.trim()
const qualified=value&&nativeDate.test(value)&&validOffset(gmtOffset)?value.replace(" ","T")+gmtOffset:value
return qualified&&dateSchema.safeParse(qualified).success?new Date(qualified):null
}
export function mapObservedCertificate(server:ServerContext,row:RosCertificateRow,now=new Date(),gmtOffset?:string|null) {
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 from=observedDate(row["invalid-before"],gmtOffset),until=observedDate(row["invalid-after"],gmtOffset)
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()
@@ -28,8 +40,9 @@ export function mapObservedCertificate(server:ServerContext,row:RosCertificateRo
}
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}
const raw=await read(),envelope=Array.isArray(raw)?null:z.object({rows:rawSchema,gmtOffset:z.string().nullable()}).strict().parse(raw)
const rows=rawSchema.parse(envelope?envelope.rows:raw),now=new Date()
const result={serverId:String(server.id),serverName:server.name,collectedAt:now.toISOString(),certificates:rows.map(row=>mapObservedCertificate(server,row,now,envelope?.gmtOffset)),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"})