blob: 0c21c7fa765477ae74478dcbe5050b0024ba666b [file]
/*!
*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
/* eslint-env jest */
import axios from 'axios';
import waitForExpect from 'wait-for-expect';
import * as fs from 'fs';
import * as path from 'path';
import * as tls from 'tls';
import { DockerComposeEnvironment, StartedDockerComposeEnvironment, Wait } from 'testcontainers';
const rootDir = path.resolve(__dirname);
const serverUrl = 'http://localhost:5011';
const oapTlsPort = 11811;
const oapGraphqlUrl = 'http://localhost:12801/graphql';
const serviceName = 'nodejs-mtls-e2e';
/** OAP Duration with MINUTE step: `yyyy-MM-dd HHmm` in UTC. */
function formatUtcMinute(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(
date.getUTCHours(),
)}${pad(date.getUTCMinutes())}`;
}
/**
* True only when the TLS failure clearly means the peer required a client
* certificate. Hostname / CA / cipher failures must NOT match.
*/
function isClientCertificateRequiredError(error: Error & { code?: string }): boolean {
const code = error.code ?? '';
const message = error.message;
if (
code === 'ERR_TLS_CERT_ALTNAME_INVALID' ||
code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
code === 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' ||
code === 'CERT_HAS_EXPIRED' ||
code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
code === 'ERR_SSL_WRONG_VERSION_NUMBER' ||
/altname|hostname|cipher|protocol version|unable to verify|self.signed/i.test(message)
) {
return false;
}
return (
code === 'ERR_SSL_PEER_CERTIFICATE_REQUIRED' ||
code === 'ERR_SSL_SSLV3_ALERT_CERTIFICATE_REQUIRED' ||
code === 'ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED' ||
/certificate.?required|peer did not return a certificate|client.?certificate.?required/i.test(message)
);
}
function assertOapRequiresClientCertificate(): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
let handshakeCompleted = false;
const socket = tls.connect({
host: '127.0.0.1',
port: oapTlsPort,
ca: fs.readFileSync(path.join(rootDir, 'server', 'ca.crt')),
servername: 'oap',
rejectUnauthorized: true,
// Intentionally omit key/cert — OAP must reject this connection.
});
const timer = setTimeout(() => {
socket.destroy();
finish(new Error('OAP accepted a TLS connection without a client certificate'));
}, 10000);
function finish(error?: Error): void {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (error) {
reject(error);
} else {
resolve();
}
}
socket.once('secureConnect', () => {
// TLS 1.3 may fire secureConnect before the server sends a
// certificate-required alert. Do not treat this as acceptance —
// only an explicit client-certificate-required error counts.
handshakeCompleted = true;
});
socket.once('error', (error: Error & { code?: string }) => {
const code = error.code ?? '';
const message = error.message;
const isNetworkError = /^(ECONNREFUSED|ETIMEDOUT|ECONNRESET|EPIPE|ENOTFOUND|EHOSTUNREACH)$/.test(code);
if (isNetworkError) {
finish(new Error(`Infra failure reaching OAP TLS port (${code}): ${message}`));
return;
}
if (isClientCertificateRequiredError(error)) {
finish();
return;
}
finish(new Error(`TLS error is not a client-certificate rejection (code=${code}): ${message}`));
});
socket.once('close', () => {
if (settled) {
return;
}
// A bare post-handshake close is not proof of mTLS enforcement — OAP
// could close for other reasons. Require an explicit certificate-
// required alert/error (handled above).
finish(
new Error(
handshakeCompleted
? 'OAP closed after TLS handshake without a client-certificate-required alert'
: 'OAP closed the connection without reporting a TLS handshake failure',
),
);
});
});
}
async function queryGraphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const response = await axios.post(oapGraphqlUrl, { query, variables });
expect(response.status).toBe(200);
expect(response.data.errors).toBeUndefined();
return response.data.data as T;
}
describe('remote-e2e mTLS with real OAP', () => {
let compose: StartedDockerComposeEnvironment | undefined;
beforeAll(async () => {
compose = await new DockerComposeEnvironment(rootDir, 'docker-compose.yml')
.withWaitStrategy('server-1', Wait.forHealthCheck())
.up();
});
afterAll(async () => {
await compose?.down();
});
it('rejects a TLS connection without a client certificate', async () => {
if (!compose) {
throw new Error('Docker Compose environment failed to start');
}
await assertOapRequiresClientCertificate();
}, 300000);
it('reports a trace to OAP through a mutually authenticated TLS channel', async () => {
if (!compose) {
throw new Error('Docker Compose environment failed to start');
}
await waitForExpect(async () => {
expect((await axios.get(`${serverUrl}/ping`)).status).toBe(200);
});
// Generate a real entry span via the instrumented HTTP server, then flush.
// flush() is best-effort and swallows report failures, so HTTP 200 alone
// is not proof — we assert persistence via OAP queryTraces below.
await axios.get(`${serverUrl}/ping`);
const flush = await axios.get(`${serverUrl}/flush`);
expect(flush.status).toBe(200);
expect(flush.data).toBe('flushed');
// Management gRPC over mTLS: service registration visible in metadata.
let serviceId = '';
await waitForExpect(
async () => {
const data = await queryGraphql<{ listServices: Array<{ id: string; name: string }> }>(
'{ listServices { id name } }',
);
const service = data.listServices.find((item) => item.name === serviceName);
expect(service).toBeDefined();
serviceId = service!.id;
},
120000,
3000,
);
// TraceSegmentReportService client-streaming over mTLS: segments must be
// queryable. queryTraces (v2) is supported on BanyanDB (same API Java
// e2e uses via `swctl tv2 ls`); queryBasicTracesByName is not.
const now = Date.now();
const start = formatUtcMinute(new Date(now - 60 * 60 * 1000));
const end = formatUtcMinute(new Date(now + 5 * 60 * 1000));
await waitForExpect(
async () => {
const data = await queryGraphql<{
queryTraces: { traces: Array<{ spans: Array<{ serviceCode: string; endpointName: string }> }> };
}>(
`query($serviceId: ID!, $start: String!, $end: String!) {
queryTraces(condition: {
serviceId: $serviceId
queryDuration: { start: $start, end: $end, step: MINUTE }
traceState: ALL
queryOrder: BY_START_TIME
paging: { pageNum: 1, pageSize: 20 }
}) {
traces {
spans {
serviceCode
endpointName
}
}
}
}`,
{ serviceId, start, end },
);
const spans: Array<{ serviceCode: string; endpointName: string }> = [];
for (const trace of data.queryTraces.traces) {
spans.push(...trace.spans);
}
expect(spans.length).toBeGreaterThan(0);
expect(spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
serviceCode: serviceName,
endpointName: expect.stringMatching(/\/ping/),
}),
]),
);
},
120000,
3000,
);
}, 300000);
});