-
Notifications
You must be signed in to change notification settings - Fork 15
feat(server): support a custom CA bundle for the backend connection #537
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
shreemaan-abhishek
wants to merge
2
commits into
main
Choose a base branch
from
feat/cp-ca-bundle
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { readFileSync } from 'node:fs'; | ||
| import * as https from 'node:https'; | ||
| import { join } from 'node:path'; | ||
| import request from 'supertest'; | ||
|
|
||
| import { ADCServer } from '../../src/server'; | ||
|
|
||
| const readCert = (fileName: string) => | ||
| readFileSync(join(__dirname, '../assets/tls/', fileName), 'utf-8'); | ||
|
|
||
| describe('Server - Backend TLS', () => { | ||
| let server: ADCServer; | ||
| // a backend whose certificate is signed by a CA the system does not trust | ||
| let backend: https.Server; | ||
| let backendURL: string; | ||
|
|
||
| const send = (path: string, opts: Record<string, unknown>) => | ||
| request(server.TEST_ONLY_getExpress()) | ||
| .put(path) | ||
| .send({ | ||
| task: { | ||
| opts: { | ||
| backend: 'apisix', | ||
| server: backendURL, | ||
| token: 'mock', | ||
| cacheKey: 'default', | ||
| ...opts, | ||
| }, | ||
| config: {}, | ||
| }, | ||
| }); | ||
|
|
||
| beforeAll(async () => { | ||
| server = new ADCServer({ | ||
| listen: new URL('http://127.0.0.1:3000'), | ||
| listenStatus: 3001, | ||
| }); | ||
| backend = https.createServer( | ||
| { cert: readCert('server.cer'), key: readCert('server.key') }, | ||
| (_, res) => (res.writeHead(404), res.end('{}')), | ||
| ); | ||
| await new Promise<void>((resolve, reject) => { | ||
| const onError = (err: Error) => reject(err); | ||
| backend.once('error', onError); | ||
| backend.listen(0, '127.0.0.1', () => { | ||
| backend.off('error', onError); | ||
| const address = backend.address(); | ||
| if (!address || typeof address === 'string') | ||
| return reject(new Error('backend did not bind a TCP port')); | ||
| // the test certificate is issued for localhost | ||
| backendURL = `https://localhost:${address.port}`; | ||
| resolve(); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await new Promise<void>((resolve) => backend.close(() => resolve())); | ||
| }); | ||
|
|
||
| // once the handshake succeeds the request reaches the backend, and each | ||
| // endpoint fails on what it finds there instead of on the certificate | ||
| describe.each([ | ||
| { path: '/sync', reachedBackend: /status code 404/ }, | ||
| { path: '/validate', reachedBackend: /Validate is not supported/ }, | ||
| ])('$path', ({ path, reachedBackend }) => { | ||
| it('rejects an untrusted certificate', async () => { | ||
| const { body } = await send(path, {}); | ||
|
|
||
| expect(body.message).toMatch(/unable to verify the first certificate/); | ||
| }); | ||
|
|
||
| it('accepts the certificate when its CA is provided', async () => { | ||
| const { body } = await send(path, { caCert: readCert('ca.cer') }); | ||
|
|
||
| expect(body.message).toMatch(reachedBackend); | ||
| }); | ||
|
|
||
| it('ignores the CA bundle when verification is off', async () => { | ||
| const { body } = await send(path, { | ||
| tlsSkipVerify: true, | ||
| caCert: readCert('ca.cer'), | ||
| }); | ||
|
|
||
| expect(body.message).toMatch(reachedBackend); | ||
| }); | ||
|
|
||
| it.each([ | ||
| ['not PEM at all', 'not-a-certificate'], | ||
| ['a header with no certificate', '-----BEGIN CERTIFICATE-----'], | ||
| [ | ||
| 'an unparseable body', | ||
| '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----', | ||
| ], | ||
| [ | ||
| 'one good and one broken certificate', | ||
| `${readCert('ca.cer')}\n-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----`, | ||
| ], | ||
| ])('rejects a CA bundle that is %s', async (_, caCert) => { | ||
| const { status, body } = await send(path, { caCert }); | ||
|
|
||
| expect(status).toEqual(400); | ||
| expect(body.message).toMatch(/caCert must be a PEM-encoded certificate/); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { MAX_CA_CERT_AGENTS, resolveHttpsAgent } from './agents'; | ||
|
|
||
| const CA_A = `-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----`; | ||
| const CA_B = `-----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----`; | ||
|
|
||
| describe('Server - HTTPS agents', () => { | ||
| it('verifies against the system trust store by default', () => { | ||
| const agent = resolveHttpsAgent({}); | ||
| expect(agent.options.rejectUnauthorized).toEqual(true); | ||
| expect(agent.options.ca).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('skips verification when tlsSkipVerify is set', () => { | ||
| const agent = resolveHttpsAgent({ tlsSkipVerify: true }); | ||
| expect(agent.options.rejectUnauthorized).toEqual(false); | ||
| }); | ||
|
|
||
| it('verifies against the given CA bundle', () => { | ||
| const agent = resolveHttpsAgent({ caCert: CA_A }); | ||
| expect(agent.options.rejectUnauthorized).toEqual(true); | ||
| expect(agent.options.ca).toEqual(CA_A); | ||
| }); | ||
|
|
||
| it('reuses one agent per CA bundle, so sockets stay pooled', () => { | ||
| expect(resolveHttpsAgent({ caCert: CA_A })).toBe( | ||
| resolveHttpsAgent({ caCert: CA_A }), | ||
| ); | ||
| expect(resolveHttpsAgent({ caCert: CA_A })).not.toBe( | ||
| resolveHttpsAgent({ caCert: CA_B }), | ||
| ); | ||
| expect(resolveHttpsAgent({ caCert: CA_A })).not.toBe(resolveHttpsAgent({})); | ||
| }); | ||
|
|
||
| it('reuses one agent across equivalent bundles', () => { | ||
| expect(resolveHttpsAgent({ caCert: ` ${CA_A}\n` })).toBe( | ||
| resolveHttpsAgent({ caCert: CA_A }), | ||
| ); | ||
| expect(resolveHttpsAgent({ caCert: ' ' })).toBe(resolveHttpsAgent({})); | ||
| }); | ||
|
|
||
| it('caps the cache, evicting the least recently used bundle', () => { | ||
| const bundle = (i: number) => | ||
| `-----BEGIN CERTIFICATE-----\ncap-${i}\n-----END CERTIFICATE-----`; | ||
|
|
||
| const first = resolveHttpsAgent({ caCert: bundle(0) }); | ||
| const second = resolveHttpsAgent({ caCert: bundle(1) }); | ||
|
|
||
| // fill the cache, keeping the first bundle in use so the second ages out | ||
| for (let i = 2; i < MAX_CA_CERT_AGENTS + 2; i++) { | ||
| resolveHttpsAgent({ caCert: bundle(i) }); | ||
| resolveHttpsAgent({ caCert: bundle(0) }); | ||
| } | ||
|
|
||
| expect(resolveHttpsAgent({ caCert: bundle(0) })).toBe(first); | ||
| expect(resolveHttpsAgent({ caCert: bundle(1) })).not.toBe(second); | ||
| }); | ||
|
|
||
| it('tlsSkipVerify takes precedence over the CA bundle', () => { | ||
| const agent = resolveHttpsAgent({ tlsSkipVerify: true, caCert: CA_A }); | ||
| expect(agent.options.rejectUnauthorized).toEqual(false); | ||
| expect(agent.options.ca).toBeUndefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { HttpAgent, HttpOptions, HttpsAgent } from 'agentkeepalive'; | ||
|
|
||
| // create connection pool | ||
| const keepAlive: HttpOptions = { | ||
| keepAlive: true, | ||
| maxSockets: 256, // per host | ||
| maxFreeSockets: 16, // per host free | ||
| freeSocketTimeout: | ||
| parseInt(process.env.ADC_INGRESS_FREE_SOCKET_TIMEOUT ?? '') || 50000, // free socket keepalive for 50 seconds, and if the ADC_INGRESS_FREE_SOCKET_TIMEOUT environment variable is provided, it takes precedence. | ||
| }; | ||
|
|
||
| export const httpAgent = new HttpAgent(keepAlive); | ||
|
|
||
| const httpsAgent = new HttpsAgent({ | ||
| rejectUnauthorized: true, | ||
| ...keepAlive, | ||
| }); | ||
| const httpsInsecureAgent = new HttpsAgent({ | ||
| rejectUnauthorized: false, | ||
| ...keepAlive, | ||
| }); | ||
|
|
||
| // one agent per CA bundle, so sockets stay pooled across requests. the key is | ||
| // request input, so the cache is capped and evicts least recently used first. | ||
| // evicted agents are dropped rather than destroyed: in-flight requests finish, | ||
| // and freeSocketTimeout reaps the sockets they leave behind. | ||
| export const MAX_CA_CERT_AGENTS = 32; | ||
| const httpsCACertAgents = new Map<string, HttpsAgent>(); | ||
|
|
||
| export interface TLSOptions { | ||
| tlsSkipVerify?: boolean; | ||
|
|
||
| // PEM-encoded CA certificate (or bundle) to verify the backend against, | ||
| // instead of the system trust store. Ignored when tlsSkipVerify is set. | ||
| caCert?: string; | ||
| } | ||
|
|
||
| //TODO: support mTLS | ||
| export const resolveHttpsAgent = ({ | ||
| tlsSkipVerify, | ||
| caCert, | ||
| }: TLSOptions): HttpsAgent => { | ||
| if (tlsSkipVerify) return httpsInsecureAgent; | ||
|
|
||
| const ca = caCert?.trim(); | ||
| if (!ca) return httpsAgent; | ||
|
|
||
| const cached = httpsCACertAgents.get(ca); | ||
| if (cached) { | ||
| // re-insert to mark it most recently used | ||
| httpsCACertAgents.delete(ca); | ||
| httpsCACertAgents.set(ca, cached); | ||
| return cached; | ||
| } | ||
|
|
||
| const agent = new HttpsAgent({ | ||
| rejectUnauthorized: true, | ||
| ca, | ||
| ...keepAlive, | ||
| }); | ||
| httpsCACertAgents.set(ca, agent); | ||
|
|
||
| if (httpsCACertAgents.size > MAX_CA_CERT_AGENTS) { | ||
| const lruKey = httpsCACertAgents.keys().next().value; | ||
| if (lruKey !== undefined) httpsCACertAgents.delete(lruKey); | ||
| } | ||
|
|
||
| return agent; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: api7/adc
Length of output: 1694
🏁 Script executed:
Repository: api7/adc
Length of output: 26277
🌐 Web query:
Node.js HTTP server.close callback error argument documentation💡 Result:
In Node.js, the
server.close([callback])method for bothhttp.Serverandnet.Serveraccepts an optional callback function [1][2]. According to the official Node.js documentation, this callback is invoked once the server has closed [2]. Regarding the error argument: The official documentation explicitly states that the callback is called with anErrorobject as its only argument if the server was not open whenserver.close()was called [2][3][4]. However, users have noted that in practical implementation, if the server closes successfully (or is already closed), the callback does not receive a standardnullerror argument; instead, the argument isundefined[5]. While the documentation mentions the error argument for the failure case, the behavior for successful closure typically results in the callback being called without arguments (or withundefined), rather than a node-style(err, result)signature whereerrisnull[5]. If you need to handle the outcome ofserver.close(), it is best to check if the argument passed to the callback exists to determine if an error occurred, as the library does not consistently providenullon success [5].Citations:
server.close()callback error argument is undefined nodejs/node#47261Propagate
backend.closefailures.backend.closecan report an error through its callback; reject onerrso teardown failures don’t get swallowed.🤖 Prompt for AI Agents
Source: Coding guidelines