Skip to content
Merged
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
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"socks-proxy-agent": "^8.0.4",
"stream-buffers": "^3.0.2",
"tar-fs": "^3.0.9",
"undici": "^7.24.0",
"ws": "^8.18.2"
},
"devDependencies": {
Expand Down
15 changes: 11 additions & 4 deletions src/azure_auth_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { User, Cluster } from './config_types.js';
import { AzureAuth } from './azure_auth.js';
import { KubeConfig } from './config.js';
import { HttpMethod, RequestContext } from './index.js';
import { Agent as UndiciAgent } from 'undici';

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -105,8 +106,14 @@ describe('AzureAuth', () => {
const requestContext = new RequestContext(testUrl1, HttpMethod.GET);

await config.applySecurityAuthentication(requestContext);
// @ts-expect-error
strictEqual(requestContext.getAgent().options.rejectUnauthorized, false);
const dispatcher = requestContext.getDispatcher() as UndiciAgent;
strictEqual(dispatcher instanceof UndiciAgent, true);
strictEqual(
dispatcher[
Object.getOwnPropertySymbols(dispatcher).find((s) => s.toString() === 'Symbol(options)')!
].connect.rejectUnauthorized,
false,
);
});

it('should not set rejectUnauthorized if skipTLSVerify is not set', async () => {
Expand All @@ -128,8 +135,8 @@ describe('AzureAuth', () => {
const requestContext = new RequestContext(testUrl1, HttpMethod.GET);

await config.applySecurityAuthentication(requestContext);
// @ts-expect-error
strictEqual(requestContext.getAgent().options.rejectUnauthorized, undefined);
// When skipTLSVerify is not set, no custom dispatcher is needed - undici validates certs by default
strictEqual(requestContext.getDispatcher(), undefined);
});

it('should throw with expired token and no cmd', async () => {
Expand Down
43 changes: 42 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import net from 'node:net';
import path from 'node:path';

import { Headers, RequestInit } from 'node-fetch';
import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent, type Dispatcher } from 'undici';
import { RequestContext } from './api.js';
import { Authenticator } from './auth.js';
import { AzureAuth } from './azure_auth.js';
Expand Down Expand Up @@ -275,7 +276,10 @@ export class KubeConfig implements SecurityAuthentication {
agentOptions.rejectUnauthorized = httpsOptions.rejectUnauthorized;
}

context.setAgent(this.createAgent(cluster, agentOptions));
const dispatcher = this.createDispatcher(cluster, agentOptions);
if (dispatcher !== undefined) {
context.setDispatcher(dispatcher);
}
}

/**
Expand Down Expand Up @@ -571,6 +575,43 @@ export class KubeConfig implements SecurityAuthentication {
return agent;
}

private createDispatcher(
cluster: Cluster | null,
agentOptions: https.AgentOptions,
): Dispatcher | undefined {
const connectOptions: Record<string, unknown> = {};
if (agentOptions.ca !== undefined) connectOptions.ca = agentOptions.ca;
if (agentOptions.cert !== undefined) connectOptions.cert = agentOptions.cert;
if (agentOptions.key !== undefined) connectOptions.key = agentOptions.key;
if (agentOptions.pfx !== undefined) connectOptions.pfx = agentOptions.pfx;
if (agentOptions.passphrase !== undefined) connectOptions.passphrase = agentOptions.passphrase;
if (agentOptions.rejectUnauthorized !== undefined)
connectOptions.rejectUnauthorized = agentOptions.rejectUnauthorized;
if ((agentOptions as any).servername !== undefined)
connectOptions.servername = (agentOptions as any).servername;

if (cluster && cluster.proxyUrl) {
if (cluster.proxyUrl.startsWith('socks')) {
throw new Error(
'SOCKS proxy is not supported with the undici HTTP client. ' +
'Use an HTTP/HTTPS proxy or configure a custom dispatcher.',
);
}
if (!cluster.server.startsWith('https') && !cluster.server.startsWith('http')) {
throw new Error('Unsupported proxy type');
}
return new UndiciProxyAgent({ uri: cluster.proxyUrl, requestTls: connectOptions });
} else if (cluster?.server?.startsWith('http:') && !cluster.skipTLSVerify) {
throw new Error('HTTP protocol is not allowed when skipTLSVerify is not set or false');
}
// Only create a custom agent when there are TLS options to configure.
// Otherwise, let undici use its default/global dispatcher (important for testing with MockAgent).
if (Object.keys(connectOptions).length === 0) {
return undefined;
}
return new UndiciAgent({ connect: connectOptions });
}

private applyHTTPSOptions(opts: https.RequestOptions | WebSocket.ClientOptions): void {
const cluster = this.getCurrentCluster();
const user = this.getCurrentUser();
Expand Down
57 changes: 34 additions & 23 deletions src/config_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ import { CoreV1Api, RequestContext } from './api.js';
import { bufferFromFileOrString, findHomeDir, findObject, KubeConfig, makeAbsolutePath } from './config.js';
import { ActionOnInvalid, Cluster, newClusters, newContexts, newUsers, User } from './config_types.js';
import { ExecAuth } from './exec_auth.js';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import { SocksProxyAgent } from 'socks-proxy-agent';
import { Agent as UndiciAgent, ProxyAgent as UndiciProxyAgent } from 'undici';
import { AddressInfo } from 'node:net';

const kcFileName = 'testdata/kubeconfig.yaml';
Expand Down Expand Up @@ -339,7 +338,14 @@ describe('KubeConfig', () => {
};

assertRequestOptionsEqual(opts, expectedOptions);
strictEqual((requestContext.getAgent()! as any).options.servername, 'kube.example2.com');
const dispatcher = requestContext.getDispatcher();
strictEqual(dispatcher instanceof UndiciAgent, true);
strictEqual(
(dispatcher as any)[
Object.getOwnPropertySymbols(dispatcher!).find((s) => s.toString() === 'Symbol(options)')!
].connect.servername,
'kube.example2.com',
);
});
it('should apply cert configs', async () => {
const kc = new KubeConfig();
Expand Down Expand Up @@ -404,16 +410,9 @@ describe('KubeConfig', () => {
const testServerName = 'https://example.com';
const rc = new RequestContext(testServerName, HttpMethod.GET);

await kc.applySecurityAuthentication(rc);
const expectedCA = Buffer.from('CADAT@', 'utf-8');
const expectedProxyHost = 'example';
const expectedProxyPort = 1187;

strictEqual(rc.getAgent() instanceof SocksProxyAgent, true);
const agent = rc.getAgent() as SocksProxyAgent;
strictEqual(agent.options.ca?.toString(), expectedCA.toString());
strictEqual(agent.proxy.host, expectedProxyHost);
strictEqual(agent.proxy.port, expectedProxyPort);
await rejects(kc.applySecurityAuthentication(rc), {
message: /SOCKS proxy is not supported/,
});
});
it('should apply https proxy', async () => {
const kc = new KubeConfig();
Expand All @@ -427,10 +426,16 @@ describe('KubeConfig', () => {
const expectedCA = Buffer.from('CADAT@', 'utf-8');
const expectedProxyHref = 'http://example:9443/';

strictEqual(rc.getAgent() instanceof HttpsProxyAgent, true);
const agent = rc.getAgent() as HttpsProxyAgent;
strictEqual(agent.options.ca?.toString(), expectedCA.toString());
strictEqual((agent as any).proxy.href, expectedProxyHref);
const dispatcher = rc.getDispatcher() as UndiciProxyAgent;
strictEqual(dispatcher instanceof UndiciProxyAgent, true);
const kProxyOpts = Object.getOwnPropertySymbols(dispatcher).find(
(s) => s.toString() === 'Symbol(proxy agent options)',
)!;
const kRequestTls = Object.getOwnPropertySymbols(dispatcher).find(
(s) => s.toString() === 'Symbol(request tls settings)',
)!;
strictEqual((dispatcher as any)[kProxyOpts].uri, expectedProxyHref);
strictEqual(Buffer.from((dispatcher as any)[kRequestTls].ca).toString(), expectedCA.toString());
});
it('should apply http proxy', async () => {
const kc = new KubeConfig();
Expand All @@ -444,10 +449,16 @@ describe('KubeConfig', () => {
const expectedCA = Buffer.from('CADAT@', 'utf-8');
const expectedProxyHref = 'http://example:8080/';

strictEqual(rc.getAgent() instanceof HttpProxyAgent, true);
const agent = rc.getAgent() as HttpProxyAgent;
strictEqual((agent as any).options.ca?.toString(), expectedCA.toString());
strictEqual((agent as any).proxy.href, expectedProxyHref);
const dispatcher = rc.getDispatcher() as UndiciProxyAgent;
strictEqual(dispatcher instanceof UndiciProxyAgent, true);
const kProxyOpts = Object.getOwnPropertySymbols(dispatcher).find(
(s) => s.toString() === 'Symbol(proxy agent options)',
)!;
const kRequestTls = Object.getOwnPropertySymbols(dispatcher).find(
(s) => s.toString() === 'Symbol(request tls settings)',
)!;
strictEqual((dispatcher as any)[kProxyOpts].uri, expectedProxyHref);
strictEqual(Buffer.from((dispatcher as any)[kRequestTls].ca).toString(), expectedCA.toString());
});
it('should throw an error if proxy-url is provided but the server protocol is not http or https', async () => {
const kc = new KubeConfig();
Expand All @@ -471,7 +482,7 @@ describe('KubeConfig', () => {

await kc.applySecurityAuthentication(rc);

strictEqual(rc.getAgent() instanceof http.Agent, true);
strictEqual(rc.getDispatcher() instanceof UndiciAgent, true);
});
it('should throw an error if cluster.server starts with http, no proxy-url is provided and insecure-skip-tls-verify is not set', async () => {
const kc = new KubeConfig();
Expand All @@ -493,7 +504,7 @@ describe('KubeConfig', () => {

await kc.applySecurityAuthentication(rc);

strictEqual(rc.getAgent() instanceof https.Agent, true);
strictEqual(rc.getDispatcher() instanceof UndiciAgent, true);
});

it('should apply NODE_TLS_REJECT_UNAUTHORIZED from environment to agent', async () => {
Expand Down
13 changes: 9 additions & 4 deletions src/gcp_auth_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { User, Cluster } from './config_types.js';
import { GoogleCloudPlatformAuth } from './gcp_auth.js';
import { KubeConfig } from './config.js';
import { HttpMethod, RequestContext } from './gen/index.js';
import { Agent } from 'node:https';
import { Agent as UndiciAgent } from 'undici';

const __dirname = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -107,9 +107,14 @@ describe('GoogleCloudPlatformAuth', () => {

await config.applySecurityAuthentication(requestContext);

// @ts-expect-error
const agent: Agent = requestContext.getAgent();
strictEqual(agent.options.rejectUnauthorized, false);
const dispatcher = requestContext.getDispatcher() as UndiciAgent;
strictEqual(dispatcher instanceof UndiciAgent, true);
strictEqual(
dispatcher[
Object.getOwnPropertySymbols(dispatcher).find((s) => s.toString() === 'Symbol(options)')!
].connect.rejectUnauthorized,
false,
);
});

it('should not set rejectUnauthorized if skipTLSVerify is not set', async () => {
Expand Down
31 changes: 21 additions & 10 deletions src/integration_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test';
import { deepEqual } from 'node:assert';
import nock from 'nock';
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';

import { CoreV1Api } from './api.js';
import { KubeConfig } from './config.js';
Expand Down Expand Up @@ -31,17 +31,28 @@ describe('FullRequest', () => {
items: [],
};
const auth = Buffer.from(`${username}:${password}`).toString('base64');
nock('https://nowhere.foo', {
reqheaders: {
authorization: `Basic ${auth}`,
},
})
.get('/api/v1/namespaces/default/pods')
.reply(200, result);

const list = await k8sApi.listNamespacedPod({ namespace: 'default' });
const originalDispatcher = getGlobalDispatcher();
const mockAgent = new MockAgent();
setGlobalDispatcher(mockAgent);
mockAgent.disableNetConnect();

return deepEqual(list, result);
const pool = mockAgent.get('https://nowhere.foo');
pool.intercept({
path: '/api/v1/namespaces/default/pods',
method: 'GET',
headers: { authorization: `Basic ${auth}` },
}).reply(200, JSON.stringify(result), {
headers: { 'content-type': 'application/json' },
});

try {
const list = await k8sApi.listNamespacedPod({ namespace: 'default' });
deepEqual(list, result);
} finally {
await mockAgent.close();
setGlobalDispatcher(originalDispatcher);
}
});
});
});
15 changes: 10 additions & 5 deletions src/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fetch from 'node-fetch';
import { fetch } from 'undici';
import { HttpMethod, RequestContext } from './gen/index.js';
import { KubeConfig } from './config.js';
import { ApiException, V1Status } from './gen/index.js';
import { normalizeResponseHeaders } from './util.js';
Expand Down Expand Up @@ -85,11 +86,15 @@ export class Metrics {

const requestURL = cluster.server + path;

const requestInit = await this.config.applyToFetchOptions({});
requestInit.method = 'GET';
const ctx = new RequestContext(requestURL, HttpMethod.GET);
await this.config.applySecurityAuthentication(ctx);

try {
const response = await fetch(requestURL, requestInit);
const response = await fetch(requestURL, {
method: 'GET',
headers: ctx.getHeaders(),
dispatcher: ctx.getDispatcher(),
});
const json = await response.json();
const { status } = response;

Expand Down Expand Up @@ -123,7 +128,7 @@ export class Metrics {
}
throw new ApiException<undefined | V1Status>(
500,
`Error occurred in metrics request: ${e.message}`,
`Error occurred in metrics request: ${e.message}${e.cause ? ': ' + e.cause.message : ''}`,
{},
{},
);
Expand Down
Loading