diff --git a/.bitmap b/.bitmap index bc81831..86ec46c 100644 --- a/.bitmap +++ b/.bitmap @@ -35,6 +35,20 @@ } } }, + "config": { + "name": "config", + "scope": "", + "version": "", + "defaultScope": "pnpm.network", + "mainFile": "index.ts", + "rootDir": "network/config", + "config": { + "pnpm.env/envs/pnpm-env": {}, + "teambit.envs/envs": { + "env": "pnpm.env/envs/pnpm-env" + } + } + }, "env-replace": { "name": "env-replace", "scope": "pnpm.config", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e72d95..3286325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,12 @@ jobs: with: node-version: ${{ matrix.node }} cache: 'pnpm' + - name: Install BVM + run: pnpm add -g @teambit/bvm + - name: Set Bit to nightly + run: bvm config set RELEASE_TYPE nightly - name: Install Bit - run: pnpm dlx @teambit/bvm install + run: bvm install 1.6.44 - name: Set PATH for Unix if: runner.os != 'Windows' diff --git a/network/agent/agent.spec.ts b/network/agent/agent.spec.ts index 6d0321e..5f3429c 100644 --- a/network/agent/agent.spec.ts +++ b/network/agent/agent.spec.ts @@ -89,3 +89,140 @@ test("don't use a proxy when the URL is in noProxy", () => { }) }) +test('should return the correct client certificates', () => { + const agent = getAgent('https://foo.com/bar', { + clientCertificates: { + '//foo.com/': { + ca: 'ca', + cert: 'cert', + key: 'key', + }, + }, + }) + + expect(agent).toEqual({ + ca: 'ca', + cert: 'cert', + key: 'key', + localAddress: undefined, + maxSockets: 50, + rejectUnauthorized: undefined, + timeout: 0, + __type: 'https', + }) +}) + +test('should not return client certificates for a different host', () => { + const agent = getAgent('https://foo.com/bar', { + clientCertificates: { + '//bar.com/': { + ca: 'ca', + cert: 'cert', + key: 'key', + }, + }, + }) + + expect(agent).toEqual({ + localAddress: undefined, + maxSockets: 50, + rejectUnauthorized: undefined, + timeout: 0, + __type: 'https', + }) +}) + +test('scoped certificates override global certificates', () => { + const agent = getAgent('https://foo.com/bar', { + ca: 'global-ca', + key: 'global-key', + cert: 'global-cert', + clientCertificates: { + '//foo.com/': { + ca: 'scoped-ca', + cert: 'scoped-cert', + key: 'scoped-key', + }, + }, + }) + + expect(agent).toEqual({ + ca: 'scoped-ca', + cert: 'scoped-cert', + key: 'scoped-key', + localAddress: undefined, + maxSockets: 50, + rejectUnauthorized: undefined, + timeout: 0, + __type: 'https', + }) +}) + +test('select correct client certificates when host has a port', () => { + const agent = getAgent('https://foo.com:1234/bar', { + clientCertificates: { + '//foo.com:1234/': { + ca: 'ca', + cert: 'cert', + key: 'key', + }, + }, + }) + + expect(agent).toEqual({ + ca: 'ca', + cert: 'cert', + key: 'key', + localAddress: undefined, + maxSockets: 50, + rejectUnauthorized: undefined, + timeout: 0, + __type: 'https', + }) +}) + +test('select correct client certificates when host has a path', () => { + const agent = getAgent('https://foo.com/bar/baz', { + clientCertificates: { + '//foo.com/': { + ca: 'ca', + cert: 'cert', + key: 'key', + }, + }, + }) + + expect(agent).toEqual({ + ca: 'ca', + cert: 'cert', + key: 'key', + localAddress: undefined, + maxSockets: 50, + rejectUnauthorized: undefined, + timeout: 0, + __type: 'https', + }) +}) + +test('select correct client certificates when host has a path and the cert contains a path', () => { + const agent = getAgent('https://foo.com/bar/baz', { + clientCertificates: { + '//foo.com/bar/': { + ca: 'ca', + cert: 'cert', + key: 'key', + }, + }, + }) + + expect(agent).toEqual({ + ca: 'ca', + cert: 'cert', + key: 'key', + localAddress: undefined, + maxSockets: 50, + rejectUnauthorized: undefined, + timeout: 0, + __type: 'https', + }) +}) \ No newline at end of file diff --git a/network/agent/agent.ts b/network/agent/agent.ts index e30fc7d..091d0ce 100644 --- a/network/agent/agent.ts +++ b/network/agent/agent.ts @@ -2,6 +2,7 @@ import { URL } from 'url' import HttpAgent from 'agentkeepalive' import LRU from 'lru-cache' import { getProxyAgent, ProxyAgentOptions } from '@pnpm/network.proxy-agent' +import { pickSettingByUrl } from '@pnpm/network.config'; const HttpsAgent = HttpAgent.HttpsAgent @@ -25,14 +26,21 @@ function getNonProxyAgent (uri: string, opts: AgentOptions) { const parsedUri = new URL(uri) const isHttps = parsedUri.protocol === 'https:' + const { ca, cert, key: certKey } = { + ...opts, + ...pickSettingByUrl(opts.clientCertificates, uri) + } + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ const key = [ `https:${isHttps.toString()}`, `local-address:${opts.localAddress ?? '>no-local-address<'}`, - `strict-ssl:${isHttps ? Boolean(opts.strictSsl).toString() : '>no-strict-ssl<'}`, - `ca:${(isHttps && opts.ca?.toString()) || '>no-ca<'}`, - `cert:${(isHttps && opts.cert?.toString()) || '>no-cert<'}`, - `key:${(isHttps && opts.key) || '>no-key<'}`, + `strict-ssl:${ + isHttps ? Boolean(opts.strictSsl).toString() : '>no-strict-ssl<' + }`, + `ca:${isHttps && (ca?.toString()) || '>no-ca<'}`, + `cert:${isHttps && (cert?.toString()) || '>no-cert<'}`, + `key:${isHttps && (certKey?.toString()) || '>no-key<'}`, ].join(':') /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ @@ -45,7 +53,10 @@ function getNonProxyAgent (uri: string, opts: AgentOptions) { // opts.timeout is a non-zero value, set it to timeout + 1, to ensure that // the node-fetch-npm timeout will always fire first, giving us more // consistent errors. - const agentTimeout = typeof opts.timeout !== 'number' || opts.timeout === 0 ? 0 : opts.timeout + 1 + const agentTimeout = + typeof opts.timeout !== 'number' || opts.timeout === 0 + ? 0 + : opts.timeout + 1 // NOTE: localAddress is passed to the agent here even though it is an // undocumented option of the agent's constructor. @@ -55,29 +66,35 @@ function getNonProxyAgent (uri: string, opts: AgentOptions) { // https://github.com/nodejs/node/blob/350a95b89faab526de852d417bbb8a3ac823c325/lib/_http_agent.js#L254 const agent = isHttps ? new HttpsAgent({ - ca: opts.ca, - cert: opts.cert, - key: opts.key, - localAddress: opts.localAddress, - maxSockets: opts.maxSockets ?? DEFAULT_MAX_SOCKETS, - rejectUnauthorized: opts.strictSsl, - timeout: agentTimeout, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + ca, + cert, + key: certKey, + localAddress: opts.localAddress, + maxSockets: opts.maxSockets ?? DEFAULT_MAX_SOCKETS, + rejectUnauthorized: opts.strictSsl, + timeout: agentTimeout, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any : new HttpAgent({ - localAddress: opts.localAddress, - maxSockets: opts.maxSockets ?? DEFAULT_MAX_SOCKETS, - timeout: agentTimeout, - } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + localAddress: opts.localAddress, + maxSockets: opts.maxSockets ?? DEFAULT_MAX_SOCKETS, + timeout: agentTimeout, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any AGENT_CACHE.set(key, agent) return agent } function checkNoProxy (uri: string, opts: { noProxy?: boolean | string }) { - const host = new URL(uri).hostname.split('.').filter(x => x).reverse() + const host = new URL(uri).hostname + .split('.') + .filter(x => x) + .reverse() if (typeof opts.noProxy === 'string') { const noproxyArr = opts.noProxy.split(/\s*,\s*/g) return noproxyArr.some(no => { - const noParts = no.split('.').filter(x => x).reverse() + const noParts = no + .split('.') + .filter(x => x) + .reverse() if (noParts.length === 0) { return false } diff --git a/network/ca-file/ca-file.ts b/network/ca-file/ca-file.ts index b9771ac..bb8fae7 100644 --- a/network/ca-file/ca-file.ts +++ b/network/ca-file/ca-file.ts @@ -2,7 +2,9 @@ import fs from 'graceful-fs' export function readCAFileSync (filePath: string): string[] | undefined { try { - const contents = fs.readFileSync(filePath, 'utf8') + let contents = fs.readFileSync(filePath, 'utf8') + // Normalize line endings to Unix-style + contents = contents.replace(/\r\n/g, '\n'); const delim = '-----END CERTIFICATE-----' const output = contents .split(delim) diff --git a/network/config/config.docs.mdx b/network/config/config.docs.mdx new file mode 100644 index 0000000..63e7241 --- /dev/null +++ b/network/config/config.docs.mdx @@ -0,0 +1,10 @@ +--- +labels: ['Config', 'module'] +description: 'A Config module.' +--- + +A config module. + +```ts +config(); +``` diff --git a/network/config/config.spec.ts b/network/config/config.spec.ts new file mode 100644 index 0000000..b002917 --- /dev/null +++ b/network/config/config.spec.ts @@ -0,0 +1,42 @@ +import { pickSettingByUrl } from './config'; + +describe('pickSettingByUrl', () => { + test('should return undefined if generic object is undefined', () => { + expect(pickSettingByUrl(undefined, 'https://example.com')).toBeUndefined(); + }); + + test('should return the exact match from the generic object', () => { + const settings = { 'https://example.com/': 'ExampleSetting' }; + expect(pickSettingByUrl(settings, 'https://example.com')).toBe( + 'ExampleSetting' + ); + }); + + test('should return a match using nerf dart', () => { + const settings = { '//example.com/': 'NerfDartSetting' }; + expect( + pickSettingByUrl(settings, 'https://example.com/path/to/resource') + ).toBe('NerfDartSetting'); + }); + + test('should return a match using withoutPort', () => { + const settings = { + 'https://example.com/path/to/resource/': 'WithoutPortSetting', + }; + expect( + pickSettingByUrl(settings, 'https://example.com:8080/path/to/resource') + ).toBe('WithoutPortSetting'); + }); + + test('should return undefined if no match is found', () => { + const settings = { 'https://example.com/': 'ExampleSetting' }; + expect(pickSettingByUrl(settings, 'https://nomatch.com')).toBeUndefined(); + }); + + test('should recursively match using withoutPort', () => { + const settings = { 'https://example.com/': 'RecursiveSetting' }; + expect(pickSettingByUrl(settings, 'https://example.com:8080')).toBe( + 'RecursiveSetting' + ); + }); +}); diff --git a/network/config/config.ts b/network/config/config.ts new file mode 100644 index 0000000..0d6ea8e --- /dev/null +++ b/network/config/config.ts @@ -0,0 +1,40 @@ +import nerfDart from 'nerf-dart'; + +function getMaxParts(uris: string[]) { + return uris.reduce((max, uri) => { + const parts = uri.split('/').length; + return parts > max ? parts : max; + }, 0); +} + +export function pickSettingByUrl( + generic: { [key: string]: T } | undefined, + uri: string +): T | undefined { + if (!generic) return undefined; + if (generic[uri]) return generic[uri]; + /* const { nerf, withoutPort } = parseUri(uri); */ + const nerf = nerfDart(uri); + const withoutPort = removePort(new URL(uri)); + if (generic[nerf]) return generic[nerf]; + if (generic[withoutPort]) return generic[withoutPort]; + const maxParts = getMaxParts(Object.keys(generic)); + const parts = nerf.split('/'); + for (let i = Math.min(parts.length, maxParts) - 1; i >= 3; i--) { + const key = `${parts.slice(0, i).join('/')}/`; + if (generic[key]) { + return generic[key]; + } + } + if (withoutPort !== uri) { + return pickSettingByUrl(generic, withoutPort); + } + return undefined; +} + +function removePort(config: URL): string { + if (config.port === '') return config.href; + config.port = ''; + const res = config.toString(); + return res.endsWith('/') ? res : `${res}/`; +} diff --git a/network/config/index.ts b/network/config/index.ts new file mode 100644 index 0000000..fda18dd --- /dev/null +++ b/network/config/index.ts @@ -0,0 +1 @@ +export { pickSettingByUrl } from './config'; diff --git a/network/proxy-agent/proxy-agent.ts b/network/proxy-agent/proxy-agent.ts index b54d849..40a044f 100644 --- a/network/proxy-agent/proxy-agent.ts +++ b/network/proxy-agent/proxy-agent.ts @@ -19,6 +19,13 @@ export interface ProxyAgentOptions { noProxy?: boolean | string strictSsl?: boolean timeout?: number + clientCertificates?: { + [registryUrl: string]: { + cert: string + key: string + ca?: string + } + } } export function getProxyAgent (uri: string, opts: ProxyAgentOptions) { @@ -32,7 +39,9 @@ export function getProxyAgent (uri: string, opts: ProxyAgentOptions) { `https:${isHttps.toString()}`, `proxy:${pxuri.protocol}//${pxuri.username}:${pxuri.password}@${pxuri.host}:${pxuri.port}`, `local-address:${opts.localAddress ?? '>no-local-address<'}`, - `strict-ssl:${isHttps ? Boolean(opts.strictSsl).toString() : '>no-strict-ssl<'}`, + `strict-ssl:${ + isHttps ? Boolean(opts.strictSsl).toString() : '>no-strict-ssl<' + }`, `ca:${(isHttps && opts.ca?.toString()) || '>no-ca<'}`, `cert:${(isHttps && opts.cert?.toString()) || '>no-cert<'}`, `key:${(isHttps && opts.key) || '>no-key<'}`, @@ -58,14 +67,14 @@ function getProxyUri ( let proxy: string | undefined switch (protocol) { - case 'http:': { - proxy = opts.httpProxy - break - } - case 'https:': { - proxy = opts.httpsProxy - break - } + case 'http:': { + proxy = opts.httpProxy + break + } + case 'https:': { + proxy = opts.httpsProxy + break + } } if (!proxy) { @@ -114,7 +123,10 @@ function getProxy ( port: proxyUrl.port, protocol: proxyUrl.protocol, rejectUnauthorized: opts.strictSsl, - timeout: typeof opts.timeout !== 'number' || opts.timeout === 0 ? 0 : opts.timeout + 1, + timeout: + typeof opts.timeout !== 'number' || opts.timeout === 0 + ? 0 + : opts.timeout + 1, } if (proxyUrl.protocol === 'http:' || proxyUrl.protocol === 'https:') { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e926878..99a231d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: lru-cache: specifier: 7.10.1 version: registry.npmjs.org/lru-cache@7.10.1 + nerf-dart: + specifier: ^1.0.0 + version: 1.0.0 react: specifier: ^18.0.0 version: 18.2.0 @@ -140,6 +143,8 @@ importers: network/ca-file: {} + network/config: {} + network/proxy-agent: {} os/env/path-extender: {} @@ -22125,6 +22130,10 @@ packages: /neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + /nerf-dart@1.0.0: + resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} + dev: false + /new-url-loader@0.1.1(webpack@5.84.1): resolution: {integrity: sha512-e7v5Q3uFk2jXgnL1JSVCszPTg9MYkbNIpKI6azeNlAa1bAwboA63aBsC63jlTEWlTacNL45tqWPx0Nm0SkCxCQ==} engines: {node: '>=10.13.0'} diff --git a/workspace.jsonc b/workspace.jsonc index 3ff217b..7b2de1e 100644 --- a/workspace.jsonc +++ b/workspace.jsonc @@ -1,7 +1,7 @@ /** * this is the main configuration file of your bit workspace. * for full documentation, please see: https://bit.dev/docs/workspace/workspace-configuration - **/{ + **/ { "$schema": "https://static.bit.dev/teambit/schemas/schema.json", /** * main configuration of the Bit workspace. @@ -58,6 +58,7 @@ "http-proxy-agent": "5.0.0", "https-proxy-agent": "5.0.1", "lru-cache": "7.10.1", + "nerf-dart": "^1.0.0", "node-fetch": "^2.6.7", "proxy": "1.0.2", "safe-execa": "0.1.1", @@ -79,9 +80,7 @@ "teambit.workspace/variants": { "*": { "teambit.pkg/pkg": { - "packageManagerPublishArgs": [ - "--access public" - ], + "packageManagerPublishArgs": ["--access public"], "packageJson": { "name": "@pnpm/{scope}.{name}", "private": false,