fix(routeros): подтверждать импорт сертификата и ограничить REST-сессии
This commit is contained in:
1 parent
9178ff2ccb
commit
c683e1b2c1
17 files changed
+662
-12
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user