Skip to content

Commit

Permalink
feat: allow scoped client certificates (#15)
Browse files Browse the repository at this point in the history
* feat: allow scoped client certificates

* style: remove new line and use template literal

* fix: use `node-fetch` latest cjs version

* test: fix self-signed certificate regex

* style: remove unused `@ts-expect-error`

* fix: use Powershell in windows CI (#16)

* fix: use Powershell in windows CI

* revert: change step names

* fix: set OS PATH in different steps

* fix: set Windows PATH

* fix: use Powershell v5.1

* fix: specify bvm path manually

* fix: dont modify windows PATH

* fix: set BVM path in PATH

* fix: set user profile automatically

* refactor: replace `uriToHost ` with `nerf-dart`

* test: add tests

* style: update

* style: update

* style: update

* style: update

* style: update

* fix: conditional if https

Co-authored-by: Zoltan Kochan <[email protected]>

* refactor: destructure client certificates

Co-authored-by: Zoltan Kochan <[email protected]>

* fix: type errors

* test: add more tests

* feat: add url component for URL specific functions

* fix: trailing slash

* fix: get each uri part correctly

* refactor: move `getXFromUri` to `url` component

* fix: disable v8 cache

* revert: disable v8 cache

* refactor: make `getMaxParts` an internal function

* fix: install bvm manually and use system node version

* revert: install bvm globally

* ci: use bit v1.6.44

* fix: install Bit v1.6.44

* chore: remove `settings.json`

* refactor: change component name to `pnpm.network/config`

* fix: replate `\r\n` with `\n` to match UNIX style

* refactor: remove unused functions

---------

Co-authored-by: Zoltan Kochan <[email protected]>
  • Loading branch information
nachoaldamav and zkochan committed Feb 6, 2024
1 parent c594fa9 commit 63b35e5
Show file tree
Hide file tree
Showing 12 changed files with 322 additions and 35 deletions.
14 changes: 14 additions & 0 deletions .bitmap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
137 changes: 137 additions & 0 deletions network/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
55 changes: 36 additions & 19 deletions network/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 */

Expand All @@ -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.
Expand All @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion network/ca-file/ca-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions network/config/config.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
labels: ['Config', 'module']
description: 'A Config module.'
---

A config module.

```ts
config();
```
42 changes: 42 additions & 0 deletions network/config/config.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
40 changes: 40 additions & 0 deletions network/config/config.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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}/`;
}
1 change: 1 addition & 0 deletions network/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { pickSettingByUrl } from './config';

0 comments on commit 63b35e5

Please sign in to comment.