fix(routeros): подтверждать импорт сертификата и ограничить REST-сессии

This commit is contained in:
Denozordec committed 2026-10-15 00:28:23 +07:00
1 parent 9178ff2ccb
commit c683e1b2c1
17 files changed
+662 -12

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@
"test:geoip": "tsx src/services/geoip-download.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.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/services/ospf-effective-disabled.test.ts && tsx src/routes/ospf-optimizer.test.ts",
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts",
"test:all": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && 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:scheduler && 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 && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
"test:server-privacy": "tsx src/routes/server-secrets-sql.test.ts"
+7 -1
View File
@@ -1,4 +1,6 @@
import { lookup } from "node:dns/promises"
import { X509Certificate } from "node:crypto"
import { applyVerifiedCertificateToServices } from "./certificate-installation.js"
import * as acme from "acme-client"
import type { Server } from "../db/schema.js"
import {
@@ -357,7 +359,11 @@ export async function issueCertificateWithCloudflareDns(params: {
})
}
await clientRos.applyCertificateToServices(params.certName)
await applyVerifiedCertificateToServices({
certificates: () => clientRos.get<unknown>("/certificate"),
apply: () => clientRos.applyCertificateToServices(params.certName),
services: () => clientRos.get<unknown>("/ip/service"),
}, params.certName, new X509Certificate(leafPem).fingerprint256)
} finally {
params.onStep?.("cleanup")
for (const item of txtCleanups) {
@@ -0,0 +1,22 @@
import assert from "node:assert/strict"
import { applyVerifiedCertificateToServices } from "./certificate-installation.js"
const fresh={name:"own",fingerprint:"b".repeat(64),"private-key":"true"}
const services=[{name:"www-ssl",certificate:"own",disabled:"false"},{name:"api-ssl",certificate:"own",disabled:"false"}]
for(const value of [{},[],[fresh,fresh],[{...fresh,fingerprint:"a".repeat(64)}],[{...fresh,"private-key":undefined}],[{...fresh,"private-key":"false",flags:"K"}]]) {
let applied=0
await assert.rejects(()=>applyVerifiedCertificateToServices({certificates:async()=>value,apply:async()=>{applied++},services:async()=>services},"own",fresh.fingerprint))
assert.equal(applied,0)
}
for(const value of [[],[services[0]],services.map(row=>({...row,disabled:"true"})),services.map(row=>({...row,certificate:"old"}))]) {
await assert.rejects(()=>applyVerifiedCertificateToServices({certificates:async()=>[fresh],apply:async()=>{},services:async()=>value},"own",fresh.fingerprint))
}
{
let reads=0
await assert.rejects(()=>applyVerifiedCertificateToServices({certificates:async()=>++reads===1?[fresh]:[fresh,fresh],apply:async()=>{},services:async()=>services},"own",fresh.fingerprint))
}
{
let applied=0
await applyVerifiedCertificateToServices({certificates:async()=>[fresh,{name:"ca",fingerprint:"a".repeat(64)}],apply:async()=>{applied++},services:async()=>[...services,{name:"www-ssl",dynamic:"true"}]},"own",fresh.fingerprint)
assert.equal(applied,1)
}
console.log("Certificate installation: 6 invalid imports never apply; 4 service mismatches and post-apply ambiguity never succeed; confirmed import/apply succeeds")
@@ -0,0 +1,33 @@
type Row = Record<string, unknown>
function rows(value: unknown): Row[] {
if (!Array.isArray(value) || value.some(row => !row || typeof row !== "object" || Array.isArray(row))) throw new Error("Некорректный ответ проверки сертификата")
return value as Row[]
}
function fingerprint(value: unknown): string | null {
if (typeof value !== "string") return null
const normalized = value.replaceAll(":", "").toLowerCase()
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null
}
export function assertImportedCertificate(value: unknown, name: string, expectedFingerprint: string): void {
const matches = rows(value).filter(row => row.name === name)
if (matches.length !== 1) throw new Error("Импорт не подтверждён: имя сертификата отсутствует или неоднозначно. Назначение сервисам не подтверждено.")
const row = matches[0]!
const expected = fingerprint(expectedFingerprint)
if (!expected || fingerprint(row.fingerprint) !== expected) throw new Error("Импорт не подтверждён: отпечаток сертификата отличается от выданного")
const privateKey = row["private-key"] === "true" || row["private-key"] === "yes" || row["private-key"] === undefined && typeof row.flags === "string" && row.flags.toUpperCase().includes("K")
if (!privateKey) throw new Error("Импорт не подтверждён: закрытый ключ сертификата не обнаружен")
}
export async function applyVerifiedCertificateToServices(deps: {
certificates: () => Promise<unknown>
apply: () => Promise<void>
services: () => Promise<unknown>
}, name: string, expectedFingerprint: string): Promise<void> {
assertImportedCertificate(await deps.certificates(), name, expectedFingerprint)
await deps.apply()
assertImportedCertificate(await deps.certificates(), name, expectedFingerprint)
const services = rows(await deps.services()).filter(row => row.dynamic !== "true" && row.dynamic !== "yes")
for (const service of ["www-ssl", "api-ssl"]) {
const matching = services.filter(row => row.name === service)
if (matching.length !== 1 || matching[0]!.certificate !== name || !["false", "no"].includes(String(matching[0]!.disabled))) throw new Error(`Назначение сертификата сервису ${service} не подтверждено; проверьте фактические настройки устройства`)
}
}
@@ -3,6 +3,33 @@ import assert from "node:assert/strict"
import { createServer } from "node:http"
import { MikrotikClient } from "./mikrotik.js"
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
let active=0,peak=0,requests=0
const connections=new Set<import("node:net").Socket>()
const server=createServer((req,res)=>{requests++;active++;peak=Math.max(peak,active);req.resume();setTimeout(()=>{res.end('{"ok":true}');active--},20)})
server.on("connection",socket=>{connections.add(socket);socket.on("close",()=>connections.delete(socket))})
await new Promise<void>(resolve=>server.listen(0,"127.0.0.1",resolve))
const address=server.address();assert(address&&typeof address!=="string")
const clients=Array.from({length:3},()=>new MikrotikClient({host:"127.0.0.1",port:address.port,useSsl:false,verifySsl:false,username:"fixture",password:"fixture"}))
try {
await Promise.all(Array.from({length:12},(_,i)=>i%2?clients[i%3]!.post("/own",{},2000):clients[i%3]!.get("/own",2000)))
assert.equal(requests,12);assert.equal(peak,2)
await new Promise<void>(resolve=>setTimeout(resolve,30));assert.equal(connections.size,0)
}finally{await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()))}
})
test("RouterOS queued mutation that times out never reaches the device later", async () => {
let writes=0
const server=createServer((req)=>{req.resume();if(req.method!=="GET")writes++})
await new Promise<void>(resolve=>server.listen(0,"127.0.0.1",resolve))
const address=server.address();assert(address&&typeof address!=="string")
const client=new MikrotikClient({host:"127.0.0.1",port:address.port,useSsl:false,verifySsl:false,username:"fixture",password:"fixture"})
try {
const first=client.get("/hold1",300),second=client.get("/hold2",300),write=client.put("/own",{},40)
const results=await Promise.allSettled([first,second,write]);assert(results.every(result=>result.status==="rejected"));assert.equal(writes,0)
}finally{server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()))}
})
const methods = ["get", "post", "put", "patch", "delete", "file"] as const
for (const method of methods) {
test(`RouterOS ${method}: interrupted response rejects promptly without a successful receipt`, async () => {
+10
View File
@@ -15,6 +15,10 @@ import type {
} from "../types/server.js"
const MAX_ROS_BODY_BYTES = 8 * 1024 * 1024
// Shared pools bound concurrent REST logins across client instances. Close idle
// connections so RouterOS does not keep scarce authenticated sessions occupied.
const rosHttpAgent = new http.Agent({ keepAlive: false, maxSockets: 2 })
const rosHttpsAgent = new https.Agent({ keepAlive: false, maxSockets: 2 })
function watchRosResponseFailure(res: http.IncomingMessage, reject: (error: Error) => void): void {
res.once("aborted", () => reject(new Error("RouterOS response interrupted (ECONNRESET)")))
@@ -63,6 +67,7 @@ function rosRequest(
}
const lib = params.useSsl ? https : http
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
@@ -122,6 +127,7 @@ function rosPost(
}
const lib = params.useSsl ? https : http
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
// An already-aborted signal can settle before the request is assigned.
// eslint-disable-next-line prefer-const
@@ -205,6 +211,7 @@ function rosPut(
}
const lib = params.useSsl ? https : http
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
@@ -256,6 +263,7 @@ function rosDelete(
}
const lib = params.useSsl ? https : http
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)
@@ -307,6 +315,7 @@ function rosRawRequest(
}
const lib = params.useSsl ? https : http
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
const req = lib.request(options, (res) => {
watchRosResponseFailure(res, error => { clearTimeout(timer); reject(error) })
@@ -360,6 +369,7 @@ function rosPatch(
}
const lib = params.useSsl ? https : http
options.agent = params.useSsl ? rosHttpsAgent : rosHttpAgent
const timer = setTimeout(() => {
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
}, timeoutMs)