Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/cli/e2e/server/tls.e2e-spec.ts
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()));
});
Comment on lines +57 to +59

Copy link
Copy Markdown

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:

#!/bin/bash
set -euo pipefail

rg -n -C3 'afterAll|backend\.close' apps/cli/e2e/server/tls.e2e-spec.ts
fd -H -t f '^package\.json$' . -x rg -n -C2 '"node"|`@types/node`|engines' {} || true

Repository: api7/adc

Length of output: 1694


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== tls.e2e-spec.ts ==\n'
cat -n apps/cli/e2e/server/tls.e2e-spec.ts | sed -n '1,140p'

printf '\n== backend.close references ==\n'
rg -n -C3 'close\(' apps/cli/e2e/server libs apps | sed -n '1,220p'

printf '\n== backend type / creation ==\n'
rg -n -C4 'backend\s*=|create.*backend|createServer|listen\(' apps/cli/e2e/server libs apps | sed -n '1,260p'

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 both http.Server and net.Server accepts 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 an Error object as its only argument if the server was not open when server.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 standard null error argument; instead, the argument is undefined [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 with undefined), rather than a node-style (err, result) signature where err is null [5]. If you need to handle the outcome of server.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 provide null on success [5].

Citations:


Propagate backend.close failures.
backend.close can report an error through its callback; reject on err so teardown failures don’t get swallowed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/cli/e2e/server/tls.e2e-spec.ts` around lines 57 - 59, Update the
afterAll teardown’s backend.close promise to accept the callback error and
reject when err is present, while resolving only on successful closure so close
failures propagate instead of being swallowed.

Source: Coding guidelines


// 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/);
});
});
});
63 changes: 63 additions & 0 deletions apps/cli/src/server/agents.spec.ts
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();
});
});
69 changes: 69 additions & 0 deletions apps/cli/src/server/agents.ts
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;
};
33 changes: 33 additions & 0 deletions apps/cli/src/server/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import * as ADCSDK from '@api7/adc-sdk';
import { X509Certificate } from 'node:crypto';
import { z } from 'zod';

const tlsSkipVerify = z.boolean().optional();

const PEM_CERTIFICATE =
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;

// every certificate in the bundle is parsed: X509Certificate on its own reads
// only the first one, and tls.createSecureContext accepts anything at all.
const isCertificateBundle = (pem: string) => {
const certificates = pem.match(PEM_CERTIFICATE);
if (!certificates) return false;
try {
certificates.forEach((certificate) => new X509Certificate(certificate));
return true;
} catch {
return false;
}
};

// PEM-encoded CA certificate (or bundle) used to verify the backend,
// instead of the system trust store.
const caCert = z
.string()
.min(1)
.refine(isCertificateBundle, {
error: 'caCert must be a PEM-encoded certificate',
})
.optional();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const SyncTask = z.strictObject({
opts: z.looseObject({
backend: z.string().min(1),
Expand All @@ -12,6 +41,8 @@ const SyncTask = z.strictObject({
labelSelector: z.record(z.string(), z.string()).optional(),
cacheKey: z.string(),
bypassCache: z.boolean().optional().default(false),
tlsSkipVerify,
caCert,
}),
config: z.looseObject({}),
});
Expand All @@ -31,6 +62,8 @@ const ValidateTask = z.strictObject({
excludeResourceType: z.array(z.enum(ADCSDK.ResourceType)).optional(),
labelSelector: z.record(z.string(), z.string()).optional(),
cacheKey: z.string(),
tlsSkipVerify,
caCert,
}),
config: z.looseObject({}),
});
Expand Down
24 changes: 2 additions & 22 deletions apps/cli/src/server/sync.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Differ } from '@api7/adc-differ';
import * as ADCSDK from '@api7/adc-sdk';
import { HttpAgent, HttpOptions, HttpsAgent } from 'agentkeepalive';
import { AxiosResponse } from 'axios';
import type { RequestHandler } from 'express';
import { omit, toString } from 'lodash-es';
Expand All @@ -13,29 +12,10 @@ import {
loadBackend,
} from '../command/utils';
import { check } from '../linter';
import { httpAgent, resolveHttpsAgent } from './agents';
import { logger } from './logger';
import { SyncInput, type SyncInputType } from './schema';

// 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.
};
const httpAgent = new HttpAgent(keepAlive);

//TODO: dynamic rejectUnauthorized and support mTLS
const httpsAgent = new HttpsAgent({
rejectUnauthorized: true,
...keepAlive,
});
const httpsInsecureAgent = new HttpsAgent({
rejectUnauthorized: false,
...keepAlive,
});

export const syncHandler: RequestHandler<
unknown,
unknown,
Expand Down Expand Up @@ -74,7 +54,7 @@ export const syncHandler: RequestHandler<
? task.opts.server.join(',')
: task.opts.server) as string,
httpAgent,
httpsAgent: (task.opts as any).tlsSkipVerify ? httpsInsecureAgent : httpsAgent,
httpsAgent: resolveHttpsAgent(task.opts),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

backend.on('TASK_START', ({ name }) =>
Expand Down
24 changes: 2 additions & 22 deletions apps/cli/src/server/validate.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,15 @@
import { Differ } from '@api7/adc-differ';
import * as ADCSDK from '@api7/adc-sdk';
import { HttpAgent, HttpOptions, HttpsAgent } from 'agentkeepalive';
import type { RequestHandler } from 'express';
import { toString } from 'lodash-es';
import { lastValueFrom } from 'rxjs';

import { fillLabels, filterResourceType, loadBackend } from '../command/utils';
import { check } from '../linter';
import { httpAgent, resolveHttpsAgent } from './agents';
import { logger } from './logger';
import { ValidateInput, type ValidateInputType } from './schema';

// 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,
};
const httpAgent = new HttpAgent(keepAlive);

//TODO: dynamic rejectUnauthorized and support mTLS
const httpsAgent = new HttpsAgent({
rejectUnauthorized: true,
...keepAlive,
});
const httpsInsecureAgent = new HttpsAgent({
rejectUnauthorized: false,
...keepAlive,
});

export const validateHandler: RequestHandler<
unknown,
unknown,
Expand Down Expand Up @@ -73,7 +53,7 @@ export const validateHandler: RequestHandler<
? task.opts.server.join(',')
: task.opts.server) as string,
httpAgent,
httpsAgent: (task.opts as any).tlsSkipVerify ? httpsInsecureAgent : httpsAgent,
httpsAgent: resolveHttpsAgent(task.opts),
});

backend.on('AXIOS_DEBUG', ({ description, response }) =>
Expand Down
Loading
Loading