midline-agent 0.1.9 → 0.2.0
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/README.md +288 -252
- package/dist/agent.d.ts +105 -6
- package/dist/agent.js +663 -102
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +63 -0
- package/dist/config.d.ts +63 -0
- package/dist/config.js +230 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +38 -0
- package/dist/errorHandler.d.ts +11 -2
- package/dist/errorHandler.js +32 -12
- package/dist/index.d.ts +9 -2
- package/dist/index.js +9 -1
- package/dist/middleware.d.ts +19 -2
- package/dist/middleware.js +92 -41
- package/dist/proxy.d.ts +70 -0
- package/dist/proxy.js +383 -0
- package/dist/redact.d.ts +35 -0
- package/dist/redact.js +223 -0
- package/dist/tap.d.ts +18 -0
- package/dist/tap.js +62 -0
- package/dist/transport.d.ts +35 -0
- package/dist/transport.js +133 -0
- package/dist/types.d.ts +107 -5
- package/package.json +13 -8
- package/src/agent.ts +734 -102
- package/src/cli.ts +69 -0
- package/src/config.ts +232 -0
- package/src/context.ts +48 -0
- package/src/errorHandler.ts +36 -13
- package/src/index.ts +19 -2
- package/src/middleware.ts +118 -43
- package/src/proxy.ts +435 -0
- package/src/redact.ts +231 -0
- package/src/tap.ts +55 -0
- package/src/transport.ts +125 -0
- package/src/types.ts +144 -31
- package/test/agent.test.js +353 -0
- package/test/helpers.js +151 -0
- package/test/middleware.test.js +178 -0
- package/test/proxy.test.js +274 -0
- package/test/redact.test.js +105 -0
package/dist/middleware.js
CHANGED
|
@@ -3,52 +3,103 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.midlineMiddleware = midlineMiddleware;
|
|
4
4
|
const agent_1 = require("./agent");
|
|
5
5
|
const breadcrumbs_1 = require("./breadcrumbs");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
const context_1 = require("./context");
|
|
8
|
+
const tap_1 = require("./tap");
|
|
9
|
+
/**
|
|
10
|
+
* Records every request that passes through. Mount it before your routes.
|
|
11
|
+
*
|
|
12
|
+
* It only listens: nothing here reads the request stream, delays `next()`, or
|
|
13
|
+
* changes the response. Recording happens on `finish`/`close`, after the response
|
|
14
|
+
* has been handed to the socket.
|
|
15
|
+
*/
|
|
16
|
+
function midlineMiddleware(options = {}) {
|
|
17
|
+
const captureOverride = options.capture ? (0, config_1.resolveCapture)(options.capture) : undefined;
|
|
18
|
+
return function midline(req, res, next) {
|
|
19
|
+
try {
|
|
20
|
+
observe(req, res, options, captureOverride);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Monitoring must never be the reason a request fails.
|
|
24
|
+
}
|
|
25
|
+
next();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function observe(req, res, options, captureOverride) {
|
|
29
|
+
const agent = options.agent ?? agent_1.MidlineAgent.current;
|
|
30
|
+
if (!agent || !agent.active || options.ignore?.(req)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const capture = captureOverride ?? agent.capture;
|
|
34
|
+
const context = (0, context_1.createRequestContext)(req.headers);
|
|
35
|
+
(0, context_1.setRequestContext)(req, context);
|
|
36
|
+
const started = process.hrtime.bigint();
|
|
37
|
+
// originalUrl survives router rewrites of req.url; read it now, not at finish.
|
|
38
|
+
const url = req.originalUrl ?? req.url ?? "/";
|
|
39
|
+
const responseTap = capture.responseBody ? tapResponse(res, capture.maxBodyBytes) : undefined;
|
|
40
|
+
let recorded = false;
|
|
41
|
+
const record = () => {
|
|
42
|
+
if (recorded)
|
|
43
|
+
return;
|
|
44
|
+
recorded = true;
|
|
45
|
+
try {
|
|
46
|
+
const aborted = !res.writableFinished;
|
|
47
|
+
const statusCode = aborted && !res.headersSent ? 499 : res.statusCode;
|
|
48
|
+
const routePath = req.route?.path;
|
|
49
|
+
const encoding = (0, tap_1.headerValue)(res.getHeader("content-encoding"));
|
|
16
50
|
(0, breadcrumbs_1.addBreadcrumb)({
|
|
17
51
|
type: "http",
|
|
18
|
-
message: `${req.method} ${
|
|
52
|
+
message: `${req.method} ${url.split("?")[0]} -> ${statusCode}`,
|
|
19
53
|
timestamp: new Date().toISOString(),
|
|
20
54
|
});
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
path: req.path,
|
|
55
|
+
agent.recordHttp({
|
|
56
|
+
integration: req.originalUrl !== undefined ? "express" : "node-http",
|
|
57
|
+
context,
|
|
25
58
|
method: req.method,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
59
|
+
url,
|
|
60
|
+
statusCode,
|
|
61
|
+
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
62
|
+
ip: req.ip ?? req.socket?.remoteAddress,
|
|
63
|
+
userAgent: (0, tap_1.headerValue)(req.headers["user-agent"]),
|
|
64
|
+
routeTemplate: typeof routePath === "string" ? `${req.baseUrl ?? ""}${routePath}` : undefined,
|
|
65
|
+
aborted,
|
|
66
|
+
capture,
|
|
67
|
+
request: {
|
|
68
|
+
headers: capture.headers ? req.headers : undefined,
|
|
69
|
+
contentType: (0, tap_1.headerValue)(req.headers["content-type"]),
|
|
70
|
+
// Whatever a body parser already produced. The raw stream is never read here.
|
|
71
|
+
body: capture.requestBody ? req.body : undefined,
|
|
72
|
+
},
|
|
73
|
+
response: {
|
|
74
|
+
headers: capture.headers ? res.getHeaders() : undefined,
|
|
75
|
+
contentType: (0, tap_1.headerValue)(res.getHeader("content-type")),
|
|
76
|
+
body: responseTap && !(0, tap_1.isCompressed)(encoding) ? responseTap.body : undefined,
|
|
77
|
+
bodyBytes: responseTap?.bytes,
|
|
78
|
+
truncated: responseTap?.truncated,
|
|
79
|
+
},
|
|
30
80
|
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// See above.
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
res.once("finish", record);
|
|
87
|
+
res.once("close", record);
|
|
88
|
+
}
|
|
89
|
+
/** Wraps write/end with every argument passed through untouched, callbacks included. */
|
|
90
|
+
function tapResponse(res, limit) {
|
|
91
|
+
const tap = new tap_1.BodyTap(limit);
|
|
92
|
+
const write = res.write;
|
|
93
|
+
const end = res.end;
|
|
94
|
+
res.write = function (...args) {
|
|
95
|
+
tap.push(args[0], typeof args[1] === "string" ? args[1] : undefined);
|
|
96
|
+
return write.apply(this, args);
|
|
97
|
+
};
|
|
98
|
+
res.end = function (...args) {
|
|
99
|
+
if (typeof args[0] !== "function") {
|
|
100
|
+
tap.push(args[0], typeof args[1] === "string" ? args[1] : undefined);
|
|
101
|
+
}
|
|
102
|
+
return end.apply(this, args);
|
|
53
103
|
};
|
|
104
|
+
return tap;
|
|
54
105
|
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as http from "http";
|
|
2
|
+
import { MidlineAgent } from "./agent";
|
|
3
|
+
import { CaInput, CaptureOptions, MidlineConfig } from "./types";
|
|
4
|
+
export interface MidlineProxyOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The destination API — the thing being monitored. `http://localhost:4000`,
|
|
7
|
+
* `https://staging.example.com`, `https://api.example.com`. Only its origin and
|
|
8
|
+
* path prefix are used; clients can never steer a request to another host.
|
|
9
|
+
* Env fallback: `TARGET_API_URL`.
|
|
10
|
+
*/
|
|
11
|
+
target?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Extra CAs to trust for the destination only (a private/internal CA). Added to
|
|
14
|
+
* the default trust store; verification is never switched off. Independent of
|
|
15
|
+
* the Midline server's `ca`. Env fallback: `TARGET_API_CA` (PEM or file path).
|
|
16
|
+
*/
|
|
17
|
+
targetCa?: CaInput;
|
|
18
|
+
/** Time allowed for the destination to start responding, in ms. Default 30000. Env: `TARGET_API_TIMEOUT_MS`. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Time allowed to connect to the destination, in ms. Default 5000. */
|
|
21
|
+
connectTimeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Retries for requests that never reached the destination (refused, reset, DNS,
|
|
24
|
+
* connect timeout). Only GET, HEAD and OPTIONS without a body are retried — a
|
|
25
|
+
* write might have been applied. Default 0. Env: `TARGET_API_RETRIES`.
|
|
26
|
+
*/
|
|
27
|
+
retries?: number;
|
|
28
|
+
/** Requests with a larger body get 413. Default 10 MiB. */
|
|
29
|
+
maxRequestBodyBytes?: number;
|
|
30
|
+
/** Send the client's Host header upstream instead of the destination's. Default false. */
|
|
31
|
+
preserveHost?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Trust `X-Forwarded-*` from the client, e.g. when this proxy itself sits behind a
|
|
34
|
+
* load balancer. Default false, so clients cannot spoof their address.
|
|
35
|
+
*/
|
|
36
|
+
trustForwardedHeaders?: boolean;
|
|
37
|
+
/** Overrides the agent's capture settings for proxied traffic. */
|
|
38
|
+
capture?: CaptureOptions;
|
|
39
|
+
/** Where events go. Defaults to `MidlineAgent.current`. Traffic is forwarded either way. */
|
|
40
|
+
agent?: MidlineAgent;
|
|
41
|
+
/** Receives proxy diagnostics. Defaults to console.warn. */
|
|
42
|
+
onError?: (message: string) => void;
|
|
43
|
+
}
|
|
44
|
+
export interface MidlineProxy {
|
|
45
|
+
(req: http.IncomingMessage, res: http.ServerResponse): void;
|
|
46
|
+
readonly target: URL;
|
|
47
|
+
/** Releases pooled upstream connections. */
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
export declare function resolveTarget(target: string | undefined): URL;
|
|
51
|
+
/**
|
|
52
|
+
* A reverse proxy for the destination API: clients call it, it forwards to
|
|
53
|
+
* `target`, and every exchange is reported to Midline out of band.
|
|
54
|
+
*
|
|
55
|
+
* The Midline server is never in the request path. If it is down, slow or
|
|
56
|
+
* misconfigured, traffic keeps flowing and only telemetry is buffered.
|
|
57
|
+
*/
|
|
58
|
+
export declare function createMidlineProxy(options?: MidlineProxyOptions): MidlineProxy;
|
|
59
|
+
export interface StartProxyOptions extends MidlineProxyOptions {
|
|
60
|
+
/** Default 8080. Env: `MIDLINE_PROXY_PORT`. */
|
|
61
|
+
port?: number;
|
|
62
|
+
/** Default 127.0.0.1 — set 0.0.0.0 explicitly to accept traffic from other machines. Env: `MIDLINE_PROXY_HOST`. */
|
|
63
|
+
host?: string;
|
|
64
|
+
/** Used to create an agent when `agent` isn't passed and none is initialised. */
|
|
65
|
+
midline?: MidlineConfig;
|
|
66
|
+
}
|
|
67
|
+
export declare function startMidlineProxy(options?: StartProxyOptions): Promise<http.Server & {
|
|
68
|
+
proxy: MidlineProxy;
|
|
69
|
+
}>;
|
|
70
|
+
export declare function proxyAddress(server: http.Server): string;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveTarget = resolveTarget;
|
|
37
|
+
exports.createMidlineProxy = createMidlineProxy;
|
|
38
|
+
exports.startMidlineProxy = startMidlineProxy;
|
|
39
|
+
exports.proxyAddress = proxyAddress;
|
|
40
|
+
const http = __importStar(require("http"));
|
|
41
|
+
const https = __importStar(require("https"));
|
|
42
|
+
const agent_1 = require("./agent");
|
|
43
|
+
const config_1 = require("./config");
|
|
44
|
+
const context_1 = require("./context");
|
|
45
|
+
const tap_1 = require("./tap");
|
|
46
|
+
const HOP_BY_HOP = new Set([
|
|
47
|
+
"connection",
|
|
48
|
+
"keep-alive",
|
|
49
|
+
"proxy-authenticate",
|
|
50
|
+
"proxy-authorization",
|
|
51
|
+
"proxy-connection",
|
|
52
|
+
"te",
|
|
53
|
+
"trailer",
|
|
54
|
+
"transfer-encoding",
|
|
55
|
+
"upgrade",
|
|
56
|
+
"http2-settings",
|
|
57
|
+
]);
|
|
58
|
+
const RETRYABLE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
59
|
+
const RETRYABLE_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNECT_TIMEOUT"]);
|
|
60
|
+
const TIMEOUT_CODES = new Set(["ETIMEDOUT", "ECONNECT_TIMEOUT"]);
|
|
61
|
+
class ProxyError extends Error {
|
|
62
|
+
constructor(message, code) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.code = code;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function resolveTarget(target) {
|
|
68
|
+
if (!target) {
|
|
69
|
+
throw new config_1.ConfigError("no destination: pass `target` or set TARGET_API_URL (e.g. http://localhost:4000)");
|
|
70
|
+
}
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(target);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new config_1.ConfigError(`target "${target}" is not a valid URL`);
|
|
77
|
+
}
|
|
78
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
79
|
+
throw new config_1.ConfigError(`target must be http:// or https:// (got ${url.protocol}//)`);
|
|
80
|
+
}
|
|
81
|
+
if (url.username || url.password) {
|
|
82
|
+
throw new config_1.ConfigError("target must not contain credentials");
|
|
83
|
+
}
|
|
84
|
+
url.search = "";
|
|
85
|
+
url.hash = "";
|
|
86
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A reverse proxy for the destination API: clients call it, it forwards to
|
|
91
|
+
* `target`, and every exchange is reported to Midline out of band.
|
|
92
|
+
*
|
|
93
|
+
* The Midline server is never in the request path. If it is down, slow or
|
|
94
|
+
* misconfigured, traffic keeps flowing and only telemetry is buffered.
|
|
95
|
+
*/
|
|
96
|
+
function createMidlineProxy(options = {}) {
|
|
97
|
+
const target = resolveTarget(options.target ?? (0, config_1.env)("TARGET_API_URL"));
|
|
98
|
+
const extraCa = (0, config_1.loadCa)(options.targetCa ?? (0, config_1.env)("TARGET_API_CA"), "targetCa / TARGET_API_CA");
|
|
99
|
+
if (extraCa && target.protocol !== "https:") {
|
|
100
|
+
throw new config_1.ConfigError("targetCa / TARGET_API_CA is set but the target is not https://");
|
|
101
|
+
}
|
|
102
|
+
const timeoutMs = options.timeoutMs ?? (0, config_1.envInt)("TARGET_API_TIMEOUT_MS") ?? 30000;
|
|
103
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? 5000;
|
|
104
|
+
const retries = Math.max(0, Math.min(options.retries ?? (0, config_1.envInt)("TARGET_API_RETRIES") ?? 0, 5));
|
|
105
|
+
const maxRequestBodyBytes = options.maxRequestBodyBytes ?? 10 * 1024 * 1024;
|
|
106
|
+
const captureOverride = options.capture ? (0, config_1.resolveCapture)(options.capture) : undefined;
|
|
107
|
+
const isHttps = target.protocol === "https:";
|
|
108
|
+
// `pathname` of an http(s) URL is never empty, so a root target reads as "/". Joining
|
|
109
|
+
// that with "/users" would forward "//users".
|
|
110
|
+
const prefix = target.pathname === "/" ? "" : target.pathname;
|
|
111
|
+
const upstreamAgent = isHttps
|
|
112
|
+
? new https.Agent({ keepAlive: true, ca: (0, config_1.trustStore)(extraCa) })
|
|
113
|
+
: new http.Agent({ keepAlive: true });
|
|
114
|
+
let lastNotice = "";
|
|
115
|
+
const warn = (signature, message) => {
|
|
116
|
+
if (signature === lastNotice)
|
|
117
|
+
return;
|
|
118
|
+
lastNotice = signature;
|
|
119
|
+
try {
|
|
120
|
+
(options.onError ?? console.warn)(message);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const handler = function midlineProxy(req, res) {
|
|
127
|
+
const started = process.hrtime.bigint();
|
|
128
|
+
const context = (0, context_1.createRequestContext)(req.headers);
|
|
129
|
+
const agent = options.agent ?? agent_1.MidlineAgent.current;
|
|
130
|
+
const capture = captureOverride ?? agent?.capture ?? (0, config_1.resolveCapture)(undefined);
|
|
131
|
+
const incomingUrl = req.url ?? "/";
|
|
132
|
+
// Origin-form only ("/path?query"). An absolute-form or authority-form request
|
|
133
|
+
// line would otherwise turn this into an open forward proxy.
|
|
134
|
+
const upstreamUrl = incomingUrl.startsWith("/") ? new URL(`${target.origin}${prefix}${incomingUrl}`) : null;
|
|
135
|
+
if (!upstreamUrl || upstreamUrl.origin !== target.origin) {
|
|
136
|
+
respondError(res, 400, "Bad Request", "Only origin-form request targets are accepted.", context);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const requestTap = capture.requestBody ? new tap_1.BodyTap(capture.maxBodyBytes) : undefined;
|
|
140
|
+
const responseTap = capture.responseBody ? new tap_1.BodyTap(capture.maxBodyBytes) : undefined;
|
|
141
|
+
let upstreamResponse;
|
|
142
|
+
let failure;
|
|
143
|
+
let recorded = false;
|
|
144
|
+
const record = () => {
|
|
145
|
+
if (recorded)
|
|
146
|
+
return;
|
|
147
|
+
recorded = true;
|
|
148
|
+
if (!agent?.active)
|
|
149
|
+
return;
|
|
150
|
+
const responseEncoding = (0, tap_1.headerValue)(upstreamResponse?.headers["content-encoding"]);
|
|
151
|
+
agent.recordHttp({
|
|
152
|
+
integration: "proxy",
|
|
153
|
+
context,
|
|
154
|
+
method: req.method,
|
|
155
|
+
url: incomingUrl,
|
|
156
|
+
statusCode: failure?.status ?? (res.headersSent ? res.statusCode : 499),
|
|
157
|
+
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
158
|
+
ip: clientAddress(req, options.trustForwardedHeaders === true),
|
|
159
|
+
userAgent: (0, tap_1.headerValue)(req.headers["user-agent"]),
|
|
160
|
+
aborted: !failure && !res.writableFinished,
|
|
161
|
+
// Origin and path only — a query string may carry credentials.
|
|
162
|
+
destination: { url: `${upstreamUrl.origin}${upstreamUrl.pathname}` },
|
|
163
|
+
errorCode: failure?.code,
|
|
164
|
+
errorMessage: failure?.message,
|
|
165
|
+
capture,
|
|
166
|
+
request: {
|
|
167
|
+
headers: capture.headers ? req.headers : undefined,
|
|
168
|
+
contentType: (0, tap_1.headerValue)(req.headers["content-type"]),
|
|
169
|
+
body: requestTap && requestTap.bytes > 0 && !(0, tap_1.isCompressed)((0, tap_1.headerValue)(req.headers["content-encoding"])) ? requestTap.body : undefined,
|
|
170
|
+
bodyBytes: requestTap?.bytes,
|
|
171
|
+
truncated: requestTap?.truncated,
|
|
172
|
+
},
|
|
173
|
+
response: {
|
|
174
|
+
headers: capture.headers && upstreamResponse ? upstreamResponse.headers : undefined,
|
|
175
|
+
contentType: (0, tap_1.headerValue)(upstreamResponse?.headers["content-type"]),
|
|
176
|
+
body: responseTap && responseTap.bytes > 0 && !(0, tap_1.isCompressed)(responseEncoding) ? responseTap.body : undefined,
|
|
177
|
+
bodyBytes: responseTap?.bytes,
|
|
178
|
+
truncated: responseTap?.truncated,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
res.once("finish", record);
|
|
183
|
+
res.once("close", record);
|
|
184
|
+
const declaredLength = Number(req.headers["content-length"]);
|
|
185
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxRequestBodyBytes) {
|
|
186
|
+
failure = { status: 413, code: "EBODY_TOO_LARGE", message: `request body of ${declaredLength} bytes exceeds ${maxRequestBodyBytes}` };
|
|
187
|
+
res.setHeader("connection", "close");
|
|
188
|
+
respondError(res, 413, "Payload Too Large", "Request body is too large.", context);
|
|
189
|
+
req.resume();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const hasBody = (Number.isFinite(declaredLength) && declaredLength > 0) || req.headers["transfer-encoding"] !== undefined;
|
|
193
|
+
const canRetry = retries > 0 && !hasBody && RETRYABLE_METHODS.has((req.method ?? "GET").toUpperCase());
|
|
194
|
+
const headers = forwardHeaders(req, target, context, options);
|
|
195
|
+
let bodyBytes = 0;
|
|
196
|
+
let bodyTooLarge = false;
|
|
197
|
+
const attempt = (attemptNumber) => {
|
|
198
|
+
let connectTimer;
|
|
199
|
+
const upstream = (isHttps ? https : http).request(upstreamUrl, {
|
|
200
|
+
method: req.method,
|
|
201
|
+
headers,
|
|
202
|
+
agent: upstreamAgent,
|
|
203
|
+
});
|
|
204
|
+
const responseTimer = setTimeout(() => {
|
|
205
|
+
upstream.destroy(new ProxyError(`destination did not respond within ${timeoutMs}ms`, "ETIMEDOUT"));
|
|
206
|
+
}, timeoutMs);
|
|
207
|
+
upstream.on("socket", (socket) => {
|
|
208
|
+
if (socket.connecting) {
|
|
209
|
+
connectTimer = setTimeout(() => {
|
|
210
|
+
upstream.destroy(new ProxyError(`could not connect to the destination within ${connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
|
|
211
|
+
}, connectTimeoutMs);
|
|
212
|
+
socket.once(isHttps ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
upstream.on("response", (response) => {
|
|
216
|
+
clearTimeout(responseTimer);
|
|
217
|
+
if (connectTimer)
|
|
218
|
+
clearTimeout(connectTimer);
|
|
219
|
+
upstreamResponse = response;
|
|
220
|
+
lastNotice = "";
|
|
221
|
+
res.writeHead(response.statusCode ?? 502, response.statusMessage, stripHopByHop(response.headers));
|
|
222
|
+
response.on("data", (chunk) => responseTap?.push(chunk));
|
|
223
|
+
response.on("error", () => res.destroy());
|
|
224
|
+
response.on("aborted", () => res.destroy());
|
|
225
|
+
response.pipe(res);
|
|
226
|
+
});
|
|
227
|
+
upstream.on("error", (err) => {
|
|
228
|
+
clearTimeout(responseTimer);
|
|
229
|
+
if (connectTimer)
|
|
230
|
+
clearTimeout(connectTimer);
|
|
231
|
+
const code = String(err?.code ?? "EUPSTREAM");
|
|
232
|
+
// A destroyed request can report more than one error; answer the first.
|
|
233
|
+
if (failure)
|
|
234
|
+
return;
|
|
235
|
+
if (upstreamResponse) {
|
|
236
|
+
// Failed mid-body: the status line is already out, so all that's left is to cut the connection.
|
|
237
|
+
res.destroy();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (bodyTooLarge) {
|
|
241
|
+
failure = { status: 413, code: "EBODY_TOO_LARGE", message: `request body exceeded ${maxRequestBodyBytes} bytes` };
|
|
242
|
+
res.setHeader("connection", "close");
|
|
243
|
+
respondError(res, 413, "Payload Too Large", "Request body is too large.", context);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (res.destroyed || res.writableEnded) {
|
|
247
|
+
// The client is gone; the close handler records it as aborted.
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (canRetry && attemptNumber < retries && RETRYABLE_CODES.has(code)) {
|
|
251
|
+
setTimeout(() => attempt(attemptNumber + 1), Math.min(100 * 2 ** attemptNumber, 2000));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const timedOut = TIMEOUT_CODES.has(code);
|
|
255
|
+
const status = timedOut ? 504 : 502;
|
|
256
|
+
failure = { status, code, message: describeUpstreamError(code, err, target) };
|
|
257
|
+
warn(code, `midline proxy: ${failure.message}`);
|
|
258
|
+
respondError(res, status, timedOut ? "Gateway Timeout" : "Bad Gateway", "The destination API is unavailable.", context);
|
|
259
|
+
});
|
|
260
|
+
// The client went away: stop the upstream work too.
|
|
261
|
+
res.once("close", () => {
|
|
262
|
+
if (!res.writableFinished)
|
|
263
|
+
upstream.destroy();
|
|
264
|
+
});
|
|
265
|
+
if (!hasBody) {
|
|
266
|
+
upstream.end();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
req.on("data", (chunk) => {
|
|
270
|
+
bodyBytes += chunk.length;
|
|
271
|
+
if (bodyBytes > maxRequestBodyBytes && !bodyTooLarge) {
|
|
272
|
+
bodyTooLarge = true;
|
|
273
|
+
req.unpipe(upstream);
|
|
274
|
+
upstream.destroy(new ProxyError("request body too large", "EBODY_TOO_LARGE"));
|
|
275
|
+
req.resume();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
requestTap?.push(chunk);
|
|
279
|
+
});
|
|
280
|
+
req.pipe(upstream);
|
|
281
|
+
};
|
|
282
|
+
attempt(0);
|
|
283
|
+
};
|
|
284
|
+
Object.defineProperty(handler, "target", { value: target, enumerable: true });
|
|
285
|
+
handler.close = () => upstreamAgent.destroy();
|
|
286
|
+
return handler;
|
|
287
|
+
}
|
|
288
|
+
async function startMidlineProxy(options = {}) {
|
|
289
|
+
const agent = options.agent ?? agent_1.MidlineAgent.current ?? agent_1.MidlineAgent.init(options.midline ?? {});
|
|
290
|
+
const proxy = createMidlineProxy({ ...options, agent });
|
|
291
|
+
const server = http.createServer(proxy);
|
|
292
|
+
server.proxy = proxy;
|
|
293
|
+
server.on("close", () => proxy.close());
|
|
294
|
+
const port = options.port ?? (0, config_1.envInt)("MIDLINE_PROXY_PORT") ?? 8080;
|
|
295
|
+
const host = options.host ?? (0, config_1.env)("MIDLINE_PROXY_HOST") ?? "127.0.0.1";
|
|
296
|
+
await new Promise((resolve, reject) => {
|
|
297
|
+
server.once("error", reject);
|
|
298
|
+
server.listen(port, host, () => {
|
|
299
|
+
server.off("error", reject);
|
|
300
|
+
resolve();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
return server;
|
|
304
|
+
}
|
|
305
|
+
function proxyAddress(server) {
|
|
306
|
+
const address = server.address();
|
|
307
|
+
const host = address.family === "IPv6" ? `[${address.address}]` : address.address;
|
|
308
|
+
return `http://${host}:${address.port}`;
|
|
309
|
+
}
|
|
310
|
+
function forwardHeaders(req, target, context, options) {
|
|
311
|
+
const out = stripHopByHop(req.headers);
|
|
312
|
+
const trustForwarded = options.trustForwardedHeaders === true;
|
|
313
|
+
const clientIp = req.socket.remoteAddress ?? "";
|
|
314
|
+
const incomingFor = (0, tap_1.headerValue)(req.headers["x-forwarded-for"]);
|
|
315
|
+
if (!options.preserveHost) {
|
|
316
|
+
out.host = target.host;
|
|
317
|
+
}
|
|
318
|
+
// Already answered by this server; forwarding it would make the destination wait for a body it already has.
|
|
319
|
+
delete out.expect;
|
|
320
|
+
const proto = req.socket.encrypted ? "https" : "http";
|
|
321
|
+
out["x-forwarded-for"] = trustForwarded && incomingFor ? `${incomingFor}, ${clientIp}` : clientIp;
|
|
322
|
+
out["x-forwarded-proto"] = (trustForwarded && (0, tap_1.headerValue)(req.headers["x-forwarded-proto"])) || proto;
|
|
323
|
+
out["x-forwarded-host"] = (trustForwarded && (0, tap_1.headerValue)(req.headers["x-forwarded-host"])) || (0, tap_1.headerValue)(req.headers.host) || "";
|
|
324
|
+
out["x-request-id"] = context.requestId;
|
|
325
|
+
out["x-correlation-id"] = context.correlationId;
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
function stripHopByHop(headers) {
|
|
329
|
+
const named = new Set(String(headers.connection ?? "")
|
|
330
|
+
.split(",")
|
|
331
|
+
.map((token) => token.trim().toLowerCase())
|
|
332
|
+
.filter(Boolean));
|
|
333
|
+
const out = {};
|
|
334
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
335
|
+
if (value === undefined || HOP_BY_HOP.has(name) || named.has(name))
|
|
336
|
+
continue;
|
|
337
|
+
out[name] = value;
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
function clientAddress(req, trustForwarded) {
|
|
342
|
+
if (trustForwarded) {
|
|
343
|
+
const forwarded = (0, tap_1.headerValue)(req.headers["x-forwarded-for"])?.split(",")[0]?.trim();
|
|
344
|
+
if (forwarded)
|
|
345
|
+
return forwarded;
|
|
346
|
+
}
|
|
347
|
+
return req.socket.remoteAddress;
|
|
348
|
+
}
|
|
349
|
+
function respondError(res, status, error, message, context) {
|
|
350
|
+
if (res.headersSent) {
|
|
351
|
+
res.destroy();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const body = JSON.stringify({ error, message, requestId: context.requestId });
|
|
355
|
+
res.writeHead(status, {
|
|
356
|
+
"content-type": "application/json; charset=utf-8",
|
|
357
|
+
"content-length": Buffer.byteLength(body),
|
|
358
|
+
"x-request-id": context.requestId,
|
|
359
|
+
});
|
|
360
|
+
res.end(body);
|
|
361
|
+
}
|
|
362
|
+
function describeUpstreamError(code, err, target) {
|
|
363
|
+
const where = target.origin;
|
|
364
|
+
switch (code) {
|
|
365
|
+
case "ECONNREFUSED":
|
|
366
|
+
return `${where} refused the connection (is the destination API running?)`;
|
|
367
|
+
case "ENOTFOUND":
|
|
368
|
+
case "EAI_AGAIN":
|
|
369
|
+
return `cannot resolve ${target.hostname} (${code}) — check TARGET_API_URL`;
|
|
370
|
+
case "ETIMEDOUT":
|
|
371
|
+
case "ECONNECT_TIMEOUT":
|
|
372
|
+
return `${where}: ${err?.message ?? code}`;
|
|
373
|
+
case "DEPTH_ZERO_SELF_SIGNED_CERT":
|
|
374
|
+
case "SELF_SIGNED_CERT_IN_CHAIN":
|
|
375
|
+
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
|
|
376
|
+
case "UNABLE_TO_GET_ISSUER_CERT_LOCALLY":
|
|
377
|
+
case "CERT_HAS_EXPIRED":
|
|
378
|
+
case "ERR_TLS_CERT_ALTNAME_INVALID":
|
|
379
|
+
return `${where} presented a certificate that could not be verified (${code}); verification stays on — if the destination uses a private CA, set targetCa / TARGET_API_CA`;
|
|
380
|
+
default:
|
|
381
|
+
return `${where}: ${err?.message ?? code} (${code})`;
|
|
382
|
+
}
|
|
383
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction happens in the host process, before an event is queued. Whatever is
|
|
3
|
+
* removed here never reaches a socket, a log line or the Midline server.
|
|
4
|
+
*
|
|
5
|
+
* Matching is on a normalised key — lower-cased with punctuation stripped — so
|
|
6
|
+
* `X-API-Key`, `api_key` and `apiKey` are all the same key.
|
|
7
|
+
*/
|
|
8
|
+
export declare const REDACTED = "[REDACTED]";
|
|
9
|
+
/** Always redacted by name, even if a user-supplied list somehow unmatched them. */
|
|
10
|
+
export declare const DEFAULT_SENSITIVE_HEADERS: string[];
|
|
11
|
+
export declare function normalizeKey(key: string): string;
|
|
12
|
+
export declare class Redactor {
|
|
13
|
+
private readonly extraKeys;
|
|
14
|
+
private readonly headerNames;
|
|
15
|
+
constructor(extraFields?: string[], extraHeaders?: string[]);
|
|
16
|
+
isSensitiveKey(key: string): boolean;
|
|
17
|
+
/** Masks credentials embedded in free text: bearer tokens, JWTs, key formats, URL userinfo and query params. */
|
|
18
|
+
string(value: string, maxLength?: number): string;
|
|
19
|
+
/** Deep copy with sensitive keys and values masked. Bounded in depth, breadth and string length. */
|
|
20
|
+
value(input: unknown, depth?: number, seen?: WeakSet<object>): unknown;
|
|
21
|
+
headers(headers: Record<string, unknown> | undefined): Record<string, string> | undefined;
|
|
22
|
+
query(search: string): Record<string, string | string[]> | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Redacts a captured body. Structured content is parsed and redacted by key;
|
|
25
|
+
* text that cannot be parsed (usually because it was truncated) gets key/value
|
|
26
|
+
* pattern masking instead, so a cut-off JSON body still loses its passwords.
|
|
27
|
+
*/
|
|
28
|
+
body(raw: unknown, contentType: string | undefined, maxBytes: number): {
|
|
29
|
+
body?: unknown;
|
|
30
|
+
truncated?: boolean;
|
|
31
|
+
omitted?: string;
|
|
32
|
+
};
|
|
33
|
+
private fit;
|
|
34
|
+
private cut;
|
|
35
|
+
}
|