peekinside 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/bin/peekinside.js +8 -0
- package/index.d.ts +389 -0
- package/index.js +3 -0
- package/package.json +77 -4
- package/src/PeekInside.js +731 -0
- package/src/capture/body-capture.js +86 -0
- package/src/capture/body-decoder.js +188 -0
- package/src/capture/exchange-recorder.js +263 -0
- package/src/capture/http-message.js +138 -0
- package/src/capture/ignored-paths.js +49 -0
- package/src/capture/request-body-tap.js +40 -0
- package/src/capture/response-tap.js +83 -0
- package/src/cli.js +254 -0
- package/src/config.js +183 -0
- package/src/crypto.js +189 -0
- package/src/deployment.js +183 -0
- package/src/exceptions/exception-builder.js +339 -0
- package/src/exceptions/exception-capture.js +407 -0
- package/src/exceptions/exception-record.js +118 -0
- package/src/exceptions/fingerprint.js +60 -0
- package/src/exceptions/frame-classifier.js +114 -0
- package/src/exceptions/limits.js +95 -0
- package/src/exceptions/process-monitor.js +192 -0
- package/src/exceptions/rate-limiter.js +50 -0
- package/src/exceptions/request-context.js +337 -0
- package/src/exceptions/safe-json.js +186 -0
- package/src/exceptions/source-context.js +144 -0
- package/src/exceptions/source-maps.js +313 -0
- package/src/exceptions/stack-parser.js +179 -0
- package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
- package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
- package/src/hub/client.js +184 -0
- package/src/hub/entry.js +4 -0
- package/src/hub/exception-state.js +97 -0
- package/src/hub/ingest-payload.js +362 -0
- package/src/hub/process.js +175 -0
- package/src/hub/project-log.js +559 -0
- package/src/hub/segment-log.js +282 -0
- package/src/hub/server.js +561 -0
- package/src/hub/spool-ingester.js +110 -0
- package/src/hub/store.js +404 -0
- package/src/instrumentation/console.js +83 -0
- package/src/instrumentation/http.js +146 -0
- package/src/instrumentation/index.js +234 -0
- package/src/instrumentation/jobs.js +186 -0
- package/src/instrumentation/loggers.js +135 -0
- package/src/instrumentation/mongodb.js +221 -0
- package/src/instrumentation/mysql2.js +161 -0
- package/src/instrumentation/operation.js +70 -0
- package/src/instrumentation/patch.js +72 -0
- package/src/instrumentation/pg.js +218 -0
- package/src/instrumentation/prisma.js +181 -0
- package/src/integrations/express-errors.js +135 -0
- package/src/integrations/express.js +57 -0
- package/src/integrations/fastify.js +72 -0
- package/src/integrations/framework-adapter.js +53 -0
- package/src/integrations/http-server.js +32 -0
- package/src/integrations/koa.js +72 -0
- package/src/integrations/server-binder.js +120 -0
- package/src/jobs/job-context.js +40 -0
- package/src/jobs/job-run-record.js +104 -0
- package/src/jobs/job-runner.js +173 -0
- package/src/logs/log-capture.js +143 -0
- package/src/logs/log-record.js +74 -0
- package/src/project.js +76 -0
- package/src/record.js +181 -0
- package/src/release.js +157 -0
- package/src/runtime/loop-monitor.js +162 -0
- package/src/runtime/sampler.js +93 -0
- package/src/spool.js +195 -0
- package/src/timeline/caller.js +129 -0
- package/src/timeline/execution-hash.js +110 -0
- package/src/timeline/lru.js +50 -0
- package/src/timeline/masking.js +266 -0
- package/src/timeline/shape.js +77 -0
- package/src/timeline/sql.js +396 -0
- package/src/timeline/timeline.js +642 -0
- package/src/transport/batch.js +324 -0
- package/src/transport/cloud.js +363 -0
- package/src/transport/local.js +354 -0
- package/src/user.js +83 -0
package/src/crypto.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end encryption for PeekInside Cloud, format v1.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the dashboard's implementation: ephemeral ECDH P-256, HKDF-SHA-256 and AES-256-GCM,
|
|
5
|
+
* all from Node's Web Crypto. Events are sealed to the project's public key, so only browsers
|
|
6
|
+
* holding the project's private key can read them; this process can encrypt but never decrypt.
|
|
7
|
+
*
|
|
8
|
+
* Output: "v1.<ephemeral public key>.<iv>.<ciphertext>" (base64url parts). Any change here must
|
|
9
|
+
* stay byte-compatible with the dashboard, or existing projects can no longer read their events.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createCipheriv, createECDH, hkdfSync, randomBytes, webcrypto } from 'crypto';
|
|
13
|
+
|
|
14
|
+
const subtle = webcrypto.subtle;
|
|
15
|
+
const ECDH = { name: 'ECDH', namedCurve: 'P-256' };
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
|
|
18
|
+
const PROJECT_ID = /^p[0-9a-z]{12}$/;
|
|
19
|
+
const SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
const PUBLIC_KEY = /^[A-Za-z0-9_-]{87}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {Uint8Array|ArrayBuffer} bytes
|
|
24
|
+
* @returns {string} Base64url without padding
|
|
25
|
+
*/
|
|
26
|
+
export function toBase64Url(bytes) {
|
|
27
|
+
return Buffer.from(bytes).toString('base64url');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} text - Base64url
|
|
32
|
+
* @returns {Uint8Array}
|
|
33
|
+
*/
|
|
34
|
+
export function fromBase64Url(text) {
|
|
35
|
+
return new Uint8Array(Buffer.from(text, 'base64url'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encrypts messages to one recipient public key, with one ephemeral key pair.
|
|
40
|
+
*
|
|
41
|
+
* The key agreement is the expensive part, so one sealer serves a whole batch: each message
|
|
42
|
+
* still gets its own random IV. Create a new sealer per batch, so ephemeral keys are not reused
|
|
43
|
+
* for long.
|
|
44
|
+
*/
|
|
45
|
+
export class Sealer {
|
|
46
|
+
/**
|
|
47
|
+
* Use Sealer.create(): the key agreement is asynchronous.
|
|
48
|
+
*
|
|
49
|
+
* @param {CryptoKey} key - AES-256-GCM key derived for this sealer
|
|
50
|
+
* @param {Uint8Array} ephemeralPublicKey - Raw ephemeral public key, sent with every message so
|
|
51
|
+
* the recipient can derive the same key
|
|
52
|
+
*/
|
|
53
|
+
constructor(key, ephemeralPublicKey) {
|
|
54
|
+
this._key = key;
|
|
55
|
+
this._prefix = `v1.${toBase64Url(ephemeralPublicKey)}.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Agrees on a key with the recipient: ECDH with a fresh ephemeral key, then HKDF-SHA-256 with
|
|
60
|
+
* both public keys as salt and "peekinside/v1/<purpose>" as info, so keys derived for one
|
|
61
|
+
* purpose never decrypt another.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} recipientPublicKey - Project public key (base64url, uncompressed P-256 point)
|
|
64
|
+
* @param {string} purpose - "event"
|
|
65
|
+
* @returns {Promise<Sealer>}
|
|
66
|
+
*/
|
|
67
|
+
static async create(recipientPublicKey, purpose) {
|
|
68
|
+
const recipientRaw = fromBase64Url(recipientPublicKey);
|
|
69
|
+
const recipient = await subtle.importKey('raw', recipientRaw, ECDH, false, []);
|
|
70
|
+
const ephemeral = await subtle.generateKey(ECDH, true, ['deriveBits']);
|
|
71
|
+
const ephemeralRaw = new Uint8Array(await subtle.exportKey('raw', ephemeral.publicKey));
|
|
72
|
+
|
|
73
|
+
const shared = await subtle.deriveBits({ name: 'ECDH', public: recipient }, ephemeral.privateKey, 256);
|
|
74
|
+
const material = await subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
|
|
75
|
+
const salt = new Uint8Array(ephemeralRaw.length + recipientRaw.length);
|
|
76
|
+
salt.set(ephemeralRaw, 0);
|
|
77
|
+
salt.set(recipientRaw, ephemeralRaw.length);
|
|
78
|
+
const key = await subtle.deriveKey(
|
|
79
|
+
{ name: 'HKDF', hash: 'SHA-256', salt, info: encoder.encode(`peekinside/v1/${purpose}`) },
|
|
80
|
+
material,
|
|
81
|
+
{ name: 'AES-GCM', length: 256 },
|
|
82
|
+
false,
|
|
83
|
+
['encrypt']
|
|
84
|
+
);
|
|
85
|
+
return new Sealer(key, ephemeralRaw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Encrypts one message with AES-256-GCM and a random 12-byte IV.
|
|
90
|
+
*
|
|
91
|
+
* @param {string|Uint8Array} plaintext
|
|
92
|
+
* @param {string} aad - Additional authenticated data, such as "detail:<request id>": decryption
|
|
93
|
+
* fails unless the reader expects the same value, which binds the message to its context
|
|
94
|
+
* @returns {Promise<string>} "v1.<ephemeral public key>.<iv>.<ciphertext>"
|
|
95
|
+
*/
|
|
96
|
+
async seal(plaintext, aad) {
|
|
97
|
+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
|
98
|
+
const data = typeof plaintext === 'string' ? encoder.encode(plaintext) : plaintext;
|
|
99
|
+
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: encoder.encode(aad) }, this._key, data);
|
|
100
|
+
return `${this._prefix}${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sealer for a process that is crashing: the same key agreement and format as Sealer, but
|
|
106
|
+
* synchronous (node:crypto instead of Web Crypto), because nothing asynchronous runs once the
|
|
107
|
+
* process has started to exit.
|
|
108
|
+
*
|
|
109
|
+
* ECDH on P-256 yields the x-coordinate of the shared point in both APIs, HKDF and AES-GCM are the
|
|
110
|
+
* same algorithms, and Web Crypto appends the 16-byte GCM tag to the ciphertext: output is
|
|
111
|
+
* interchangeable with Sealer's, and opens with the dashboard's Opener.
|
|
112
|
+
*/
|
|
113
|
+
export class SyncSealer {
|
|
114
|
+
/**
|
|
115
|
+
* Use SyncSealer.create().
|
|
116
|
+
*
|
|
117
|
+
* @param {Buffer} key - AES-256-GCM key derived for this sealer
|
|
118
|
+
* @param {Buffer} ephemeralPublicKey - Raw (uncompressed) ephemeral public key
|
|
119
|
+
*/
|
|
120
|
+
constructor(key, ephemeralPublicKey) {
|
|
121
|
+
this._key = key;
|
|
122
|
+
this._prefix = `v1.${toBase64Url(ephemeralPublicKey)}.`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Agrees on a key with the recipient, exactly like Sealer.create().
|
|
127
|
+
*
|
|
128
|
+
* @param {string} recipientPublicKey - Project public key (base64url, uncompressed P-256 point)
|
|
129
|
+
* @param {string} purpose - "event"
|
|
130
|
+
* @returns {SyncSealer}
|
|
131
|
+
* @throws {Error} When the public key is not a valid P-256 point
|
|
132
|
+
*/
|
|
133
|
+
static create(recipientPublicKey, purpose) {
|
|
134
|
+
const recipientRaw = Buffer.from(recipientPublicKey, 'base64url');
|
|
135
|
+
const ecdh = createECDH('prime256v1');
|
|
136
|
+
const ephemeralRaw = ecdh.generateKeys();
|
|
137
|
+
const shared = ecdh.computeSecret(recipientRaw);
|
|
138
|
+
const salt = Buffer.concat([ephemeralRaw, recipientRaw]);
|
|
139
|
+
const key = Buffer.from(hkdfSync('sha256', shared, salt, Buffer.from(`peekinside/v1/${purpose}`), 32));
|
|
140
|
+
return new SyncSealer(key, ephemeralRaw);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Encrypts one message with AES-256-GCM and a random 12-byte IV.
|
|
145
|
+
*
|
|
146
|
+
* @param {string|Uint8Array} plaintext
|
|
147
|
+
* @param {string} aad - Additional authenticated data (see Sealer#seal())
|
|
148
|
+
* @returns {string} "v1.<ephemeral public key>.<iv>.<ciphertext and tag>"
|
|
149
|
+
*/
|
|
150
|
+
seal(plaintext, aad) {
|
|
151
|
+
const iv = randomBytes(12);
|
|
152
|
+
const cipher = createCipheriv('aes-256-gcm', this._key, iv);
|
|
153
|
+
cipher.setAAD(Buffer.from(aad, 'utf8'));
|
|
154
|
+
const data = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
|
|
155
|
+
const ciphertext = Buffer.concat([cipher.update(data), cipher.final(), cipher.getAuthTag()]);
|
|
156
|
+
return `${this._prefix}${toBase64Url(iv)}.${toBase64Url(ciphertext)}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A PeekInside Cloud API key: "pik1.<projectId>.<secret>.<projectPublicKey>".
|
|
162
|
+
*
|
|
163
|
+
* The secret authorizes sending events; the public key lets the SDK encrypt them. The key never
|
|
164
|
+
* contains anything that can decrypt events.
|
|
165
|
+
*/
|
|
166
|
+
export class ApiKey {
|
|
167
|
+
/**
|
|
168
|
+
* @param {Object} parts
|
|
169
|
+
* @param {string} parts.projectId - Project id, such as "p0123456789ab"
|
|
170
|
+
* @param {string} parts.secret - Ingest secret (base64url)
|
|
171
|
+
* @param {string} parts.publicKey - Project public key (base64url, uncompressed P-256 point)
|
|
172
|
+
*/
|
|
173
|
+
constructor({ projectId, secret, publicKey }) {
|
|
174
|
+
this.projectId = projectId;
|
|
175
|
+
this.secret = secret;
|
|
176
|
+
this.publicKey = publicKey;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} apiKey
|
|
181
|
+
* @returns {ApiKey|null} The key's parts, or null when it isn't a well-formed v1 key
|
|
182
|
+
*/
|
|
183
|
+
static parse(apiKey) {
|
|
184
|
+
const [version, projectId, secret, publicKey, ...rest] = String(apiKey).trim().split('.');
|
|
185
|
+
if (version !== 'pik1' || rest.length) return null;
|
|
186
|
+
if (!PROJECT_ID.test(projectId) || !SECRET.test(secret) || !PUBLIC_KEY.test(publicKey)) return null;
|
|
187
|
+
return new ApiKey({ projectId, secret, publicKey });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tells local development apart from a deployed server, request by request.
|
|
3
|
+
*
|
|
4
|
+
* The free local dashboard records local development only. A request that reached the app
|
|
5
|
+
* through a public domain or IP address, or that a proxy forwarded from a public client, means
|
|
6
|
+
* the app runs on a server: those requests need PeekInside Cloud and are not recorded locally.
|
|
7
|
+
*
|
|
8
|
+
* Local means: localhost and *.localhost, reserved and LAN names (*.test, *.local, *.internal...),
|
|
9
|
+
* single-label names (Docker services, machine names), loopback and private addresses, the
|
|
10
|
+
* networks this machine is on, and development tunnels such as ngrok (webhooks to a laptop).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import net from 'net';
|
|
14
|
+
import os from 'os';
|
|
15
|
+
|
|
16
|
+
const LOCAL_SUFFIXES = ['.localhost', '.test', '.local', '.localdomain', '.internal', '.lan', '.home.arpa', '.example', '.invalid'];
|
|
17
|
+
const TUNNEL_SUFFIXES = [
|
|
18
|
+
'.ngrok.io', '.ngrok.app', '.ngrok.dev', '.ngrok-free.app', '.ngrok-free.dev',
|
|
19
|
+
'.trycloudflare.com', '.loca.lt', '.sharedwithexpose.com', '.lhr.life', '.app.github.dev', '.gitpod.io'
|
|
20
|
+
];
|
|
21
|
+
const PRIVATE_SUBNETS = [
|
|
22
|
+
['0.0.0.0', 8, 'ipv4'], ['10.0.0.0', 8, 'ipv4'], ['100.64.0.0', 10, 'ipv4'], ['127.0.0.0', 8, 'ipv4'],
|
|
23
|
+
['169.254.0.0', 16, 'ipv4'], ['172.16.0.0', 12, 'ipv4'], ['192.168.0.0', 16, 'ipv4'],
|
|
24
|
+
['::', 128, 'ipv6'], ['::1', 128, 'ipv6'], ['fc00::', 7, 'ipv6'], ['fe80::', 10, 'ipv6']
|
|
25
|
+
];
|
|
26
|
+
// Network interfaces change (Wi-Fi, VPN), but reading them on every request would be wasteful.
|
|
27
|
+
const NETWORKS_TTL_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} ServerSignal
|
|
31
|
+
* @property {'host'|'client'} kind - What gave the server away: the public host name the request
|
|
32
|
+
* was sent to, or the public client address behind a proxy
|
|
33
|
+
* @property {string} value - That host name or address
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Classifies requests as local development or server traffic.
|
|
38
|
+
*
|
|
39
|
+
* Keeps the networks this machine is on (refreshed every minute), since addresses on them are
|
|
40
|
+
* local too.
|
|
41
|
+
*/
|
|
42
|
+
export class TrafficClassifier {
|
|
43
|
+
/**
|
|
44
|
+
* The machine's networks are read on first use, not here.
|
|
45
|
+
*/
|
|
46
|
+
constructor() {
|
|
47
|
+
this._networks = null;
|
|
48
|
+
this._networksAt = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {import('http').IncomingMessage} req
|
|
53
|
+
* @returns {ServerSignal|null} What shows the request came to a server, or null when it is local
|
|
54
|
+
*/
|
|
55
|
+
serverSignal(req) {
|
|
56
|
+
const headers = req.headers || {};
|
|
57
|
+
const hosts = [headers.host, first(headers['x-forwarded-host']), ...forwardedValues(headers.forwarded, 'host')]
|
|
58
|
+
.map(hostnameOf)
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
|
|
61
|
+
// A tunnel brings outside requests (webhooks, a shared link) to a developer's machine.
|
|
62
|
+
if (hosts.some(isTunnel)) return null;
|
|
63
|
+
|
|
64
|
+
const publicHost = hosts.find((host) => !this.isLocalHostname(host));
|
|
65
|
+
if (publicHost) return { kind: 'host', value: publicHost };
|
|
66
|
+
|
|
67
|
+
// Proxies often rewrite Host (nginx sends the upstream address by default), but the client
|
|
68
|
+
// address still shows where the request came from.
|
|
69
|
+
const clients = [
|
|
70
|
+
req.socket && req.socket.remoteAddress,
|
|
71
|
+
...list(headers['x-forwarded-for']),
|
|
72
|
+
first(headers['x-real-ip']),
|
|
73
|
+
...forwardedValues(headers.forwarded, 'for')
|
|
74
|
+
];
|
|
75
|
+
for (const client of clients) {
|
|
76
|
+
const ip = ipOf(client);
|
|
77
|
+
if (ip && !this.isLocalAddress(ip)) return { kind: 'client', value: ip };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} hostname - Host name or address, without the port
|
|
84
|
+
* @returns {boolean} Whether the name only makes sense in local development
|
|
85
|
+
*/
|
|
86
|
+
isLocalHostname(hostname) {
|
|
87
|
+
const host = String(hostname).toLowerCase().replace(/\.$/, '');
|
|
88
|
+
if (!host || host === 'localhost') return true;
|
|
89
|
+
if (net.isIP(host)) return this.isLocalAddress(host);
|
|
90
|
+
if (!host.includes('.')) return true;
|
|
91
|
+
return LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix)) || isTunnel(host);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} address - IPv4 or IPv6 address
|
|
96
|
+
* @returns {boolean} Whether the address is private, loopback, or on one of this machine's networks
|
|
97
|
+
*/
|
|
98
|
+
isLocalAddress(address) {
|
|
99
|
+
const ip = unmapIPv4(address);
|
|
100
|
+
const family = net.isIP(ip);
|
|
101
|
+
if (!family) return false;
|
|
102
|
+
return this._localNetworks().check(ip, family === 6 ? 'ipv6' : 'ipv4');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Private ranges plus the networks this machine is on (e.g. public IPv6 addresses on home Wi-Fi).
|
|
106
|
+
_localNetworks() {
|
|
107
|
+
if (this._networks && Date.now() - this._networksAt < NETWORKS_TTL_MS) return this._networks;
|
|
108
|
+
const list = new net.BlockList();
|
|
109
|
+
for (const [address, prefix, type] of PRIVATE_SUBNETS) list.addSubnet(address, prefix, type);
|
|
110
|
+
try {
|
|
111
|
+
for (const iface of Object.values(os.networkInterfaces()).flat()) {
|
|
112
|
+
if (!iface || !iface.cidr) continue;
|
|
113
|
+
const [address, prefix] = iface.cidr.split('/');
|
|
114
|
+
const type = net.isIP(address) === 6 ? 'ipv6' : 'ipv4';
|
|
115
|
+
list.addSubnet(address.split('%')[0], Number(prefix), type);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// Interface details are optional: private ranges still apply.
|
|
119
|
+
}
|
|
120
|
+
this._networks = list;
|
|
121
|
+
this._networksAt = Date.now();
|
|
122
|
+
return list;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isTunnel(host) {
|
|
127
|
+
return TUNNEL_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// "host:port", "[::1]:port" or a bare host -> host name or address, without the port.
|
|
131
|
+
function hostnameOf(value) {
|
|
132
|
+
if (typeof value !== 'string') return '';
|
|
133
|
+
const host = value.trim().toLowerCase();
|
|
134
|
+
if (host.startsWith('[')) {
|
|
135
|
+
const end = host.indexOf(']');
|
|
136
|
+
return end > 0 ? host.slice(1, end) : '';
|
|
137
|
+
}
|
|
138
|
+
const colons = host.split(':').length - 1;
|
|
139
|
+
if (colons === 1) return host.slice(0, host.indexOf(':'));
|
|
140
|
+
return host; // a bare name, or an IPv6 address without brackets
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Client addresses as proxies write them: "203.0.113.7", "203.0.113.7:51234", "[2001:db8::1]:443".
|
|
144
|
+
function ipOf(value) {
|
|
145
|
+
if (typeof value !== 'string') return '';
|
|
146
|
+
let address = value.trim().replace(/^"|"$/g, '');
|
|
147
|
+
if (address.startsWith('[')) address = address.slice(1, address.indexOf(']'));
|
|
148
|
+
else if (address.split(':').length === 2) address = address.slice(0, address.indexOf(':'));
|
|
149
|
+
address = unmapIPv4(address.split('%')[0]);
|
|
150
|
+
return net.isIP(address) ? address : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// "::ffff:192.168.1.7" (IPv4 on a dual-stack socket) -> "192.168.1.7"
|
|
154
|
+
function unmapIPv4(address) {
|
|
155
|
+
const match = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
156
|
+
return match ? match[1] : address;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// First value of a header that may be repeated or comma-separated.
|
|
160
|
+
function first(value) {
|
|
161
|
+
const text = Array.isArray(value) ? value[0] : value;
|
|
162
|
+
return typeof text === 'string' ? text.split(',')[0].trim() : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// All values of a header that may be repeated or comma-separated.
|
|
166
|
+
function list(value) {
|
|
167
|
+
const text = Array.isArray(value) ? value.join(',') : value;
|
|
168
|
+
return typeof text === 'string' ? text.split(',').map((item) => item.trim()).filter(Boolean) : [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// RFC 7239: Forwarded: for=192.0.2.60;proto=http;host=example.com, for=198.51.100.17
|
|
172
|
+
function forwardedValues(value, key) {
|
|
173
|
+
const values = [];
|
|
174
|
+
for (const element of list(value)) {
|
|
175
|
+
for (const pair of element.split(';')) {
|
|
176
|
+
const index = pair.indexOf('=');
|
|
177
|
+
if (index > 0 && pair.slice(0, index).trim().toLowerCase() === key) {
|
|
178
|
+
values.push(pair.slice(index + 1).trim().replace(/^"|"$/g, ''));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return values;
|
|
183
|
+
}
|