feat(agent): auto-renew the host cert in the native run-loop (A5)
Wire createCertRotator/renewCert into superviseNative so the frp-client cert renews silently at ~2/3 TTL over mTLS /renew and frpc restarts onto the fresh leaf; failures retry with backoff, never crash the supervisor. mTLS transport has a 15s timeout + 64KB response cap. 281 tests pass.
This commit is contained in:
168
agent/test/nativeRenewTransport.test.ts
Normal file
168
agent/test/nativeRenewTransport.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
|
||||
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
|
||||
* the actual transport to prove:
|
||||
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
|
||||
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
|
||||
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
|
||||
*/
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
|
||||
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
|
||||
|
||||
import { generateP256Identity } from '../src/keys/identity.js'
|
||||
import { openKeystore } from '../src/keys/keystore.js'
|
||||
import {
|
||||
createMtlsFetch,
|
||||
RENEW_REQUEST_TIMEOUT_MS,
|
||||
MAX_RENEW_RESPONSE_BYTES,
|
||||
} from '../src/certs/nativeRenew.js'
|
||||
|
||||
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
|
||||
|
||||
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
|
||||
class FakeClientRequest extends EventEmitter {
|
||||
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
|
||||
readonly written: string[] = []
|
||||
ended = false
|
||||
destroyedWith: Error | undefined
|
||||
setTimeout(ms: number, cb: () => void): this {
|
||||
this.setTimeoutCalls.push({ ms, cb })
|
||||
return this
|
||||
}
|
||||
write(chunk: string): boolean {
|
||||
this.written.push(chunk)
|
||||
return true
|
||||
}
|
||||
end(): this {
|
||||
this.ended = true
|
||||
return this
|
||||
}
|
||||
destroy(err?: Error): this {
|
||||
this.destroyedWith = err
|
||||
if (err) this.emit('error', err)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
|
||||
class FakeIncomingMessage extends EventEmitter {
|
||||
statusCode = 200
|
||||
destroyed = false
|
||||
destroy(): this {
|
||||
this.destroyed = true
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
type ReqCb = (res: FakeIncomingMessage) => void
|
||||
let dirs: string[] = []
|
||||
|
||||
function enrolledKs(): ReturnType<typeof openKeystore> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
|
||||
dirs.push(dir)
|
||||
const ks = openKeystore(dir)
|
||||
ks.saveIdentity(generateP256Identity())
|
||||
ks.saveCert('LEAFCERT', 'CACHAIN')
|
||||
return ks
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true })
|
||||
dirs = []
|
||||
})
|
||||
|
||||
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
|
||||
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenUrl = ''
|
||||
let seenOpts: Record<string, unknown> = {}
|
||||
let seenReq: FakeClientRequest | undefined
|
||||
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
|
||||
seenUrl = url
|
||||
seenOpts = opts
|
||||
const req = new FakeClientRequest()
|
||||
seenReq = req
|
||||
queueMicrotask(() => {
|
||||
const res = new FakeIncomingMessage()
|
||||
res.statusCode = 200
|
||||
cb(res)
|
||||
res.emit('data', Buffer.from('{"cert":"NEW",'))
|
||||
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
|
||||
res.emit('end')
|
||||
})
|
||||
return req
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
|
||||
const res = await f('https://cp.example.com/renew', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"csr":"x"}',
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
|
||||
expect(seenUrl).toBe('https://cp.example.com/renew')
|
||||
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
|
||||
expect(seenOpts.method).toBe('POST')
|
||||
expect(seenOpts.cert).toBe('LEAFCERT')
|
||||
expect(seenOpts.ca).toBe('CACHAIN')
|
||||
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
|
||||
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
|
||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
|
||||
expect(seenReq!.ended).toBe(true)
|
||||
})
|
||||
|
||||
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenReq: FakeClientRequest | undefined
|
||||
requestMock.mockImplementation(() => {
|
||||
const req = new FakeClientRequest()
|
||||
seenReq = req
|
||||
return req // never invokes the response callback — a stalled control-plane
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
|
||||
|
||||
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
|
||||
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
|
||||
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
|
||||
seenReq!.setTimeoutCalls[0]!.cb()
|
||||
|
||||
await expect(p).rejects.toThrow(/timed out/i)
|
||||
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
|
||||
})
|
||||
|
||||
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
|
||||
const ks = enrolledKs()
|
||||
let seenRes: FakeIncomingMessage | undefined
|
||||
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
|
||||
const req = new FakeClientRequest()
|
||||
queueMicrotask(() => {
|
||||
const res = new FakeIncomingMessage()
|
||||
res.statusCode = 200
|
||||
seenRes = res
|
||||
cb(res)
|
||||
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
|
||||
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
|
||||
})
|
||||
return req
|
||||
})
|
||||
|
||||
const f = createMtlsFetch(ks, { certParser: farFuture })
|
||||
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
|
||||
expect(seenRes!.destroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user