@deepexi/datasense-connector 3.0.1 → 3.1.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 +158 -139
- package/package.json +19 -28
- package/scripts/build.ts +188 -0
- package/src/define.ts +267 -0
- package/src/descriptor.ts +116 -0
- package/src/errors.ts +105 -0
- package/src/index.ts +108 -0
- package/src/internal/location.ts +23 -0
- package/src/runtime/guest/bridge.ts +238 -0
- package/src/runtime/guest/cache.ts +43 -0
- package/src/runtime/guest/check.ts +17 -0
- package/src/runtime/guest/crypto.ts +166 -0
- package/src/runtime/guest/ctx.ts +157 -0
- package/src/runtime/guest/db.ts +59 -0
- package/src/runtime/guest/failure.ts +69 -0
- package/src/runtime/guest/http.ts +194 -0
- package/src/runtime/guest/index.ts +102 -0
- package/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
- package/src/runtime/guest/loader.ts +210 -0
- package/src/runtime/guest/log.ts +47 -0
- package/src/runtime/guest/map.ts +45 -0
- package/src/runtime/guest/operation.ts +193 -0
- package/src/runtime/guest/paginate.ts +169 -0
- package/src/runtime/guest/pull.ts +177 -0
- package/src/runtime/guest/push.ts +92 -0
- package/src/runtime/guest/signal.ts +14 -0
- package/src/runtime/guest/suggest.ts +51 -0
- package/src/runtime/guest/time.ts +137 -0
- package/src/runtime/guest-runtime.ts +75 -0
- package/src/runtime/index.ts +53 -0
- package/src/runtime/protocol.ts +557 -0
- package/src/runtime/session.ts +216 -0
- package/src/testing/child.ts +21 -0
- package/src/testing/compliance.ts +343 -0
- package/src/testing/engine.ts +445 -0
- package/src/testing/fixtures.ts +591 -0
- package/src/testing/gateway.ts +482 -0
- package/src/testing/import-guard.ts +221 -0
- package/src/testing/index.ts +43 -0
- package/src/testing/local-db.ts +226 -0
- package/src/testing/mock-external-system.ts +249 -0
- package/src/testing/pinned-http.ts +144 -0
- package/src/testing/recorder.ts +140 -0
- package/src/testing/redaction.ts +94 -0
- package/src/testing/replay.ts +437 -0
- package/src/testing/run-tests.ts +115 -0
- package/src/testing/schema.ts +70 -0
- package/src/testing/source-loader.ts +374 -0
- package/src/testing/types.ts +78 -0
- package/src/types.ts +329 -0
- package/src/validate.ts +267 -0
- package/test/build.test.ts +159 -0
- package/test/define.test-d.ts +182 -0
- package/test/fixtures/acceptance.ts +156 -0
- package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
- package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
- package/test/fixtures/connectors/http-pull/package.json +5 -0
- package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
- package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
- package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
- package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
- package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
- package/test/fixtures/connectors/pg-pull/package.json +5 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
- package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
- package/test/fixtures/descriptor.ts +54 -0
- package/test/fixtures/kingdee-example.ts +82 -0
- package/test/fixtures/record-acceptance.ts +32 -0
- package/test/runtime/ctx.test.ts +434 -0
- package/test/runtime/helpers.ts +157 -0
- package/test/runtime/jobs.test.ts +444 -0
- package/test/runtime/operation.test.ts +175 -0
- package/test/runtime/session.test.ts +229 -0
- package/test/testing/acceptance-fixtures.test.ts +69 -0
- package/test/testing/compliance.test.ts +247 -0
- package/test/testing/engine.test.ts +283 -0
- package/test/testing/fixtures.test.ts +221 -0
- package/test/testing/gateway.test.ts +337 -0
- package/test/testing/import-guard.test.ts +45 -0
- package/test/testing/mock-external-system.test.ts +144 -0
- package/test/testing/operation-fixtures.test.ts +160 -0
- package/test/testing/recorder.test.ts +163 -0
- package/test/testing/redaction.test.ts +54 -0
- package/test/testing/replay.test.ts +207 -0
- package/test/testing/run-tests.test.ts +137 -0
- package/test/testing/source-loader.test.ts +220 -0
- package/test/validate-config.test.ts +104 -0
- package/tsconfig.build.json +23 -0
- package/tsconfig.json +19 -0
- package/cjs/index.cjs +0 -1015
- package/cjs/runtime.cjs +0 -752
- package/cjs/testing.cjs +0 -3591
- package/errors.d.ts +0 -17
- package/errors.js +0 -53
- package/guest.cjs +0 -2659
- package/index.d.ts +0 -13
- package/index.js +0 -7
- package/internal/location.d.ts +0 -6
- package/internal/location.js +0 -8
- package/presentation.d.ts +0 -38
- package/presentation.js +0 -268
- package/runtime/guest/bridge.d.ts +0 -53
- package/runtime/guest/bridge.js +0 -187
- package/runtime/guest/cache.d.ts +0 -3
- package/runtime/guest/cache.js +0 -42
- package/runtime/guest/crypto.d.ts +0 -13
- package/runtime/guest/crypto.js +0 -177
- package/runtime/guest/ctx.d.ts +0 -30
- package/runtime/guest/ctx.js +0 -124
- package/runtime/guest/db.d.ts +0 -6
- package/runtime/guest/db.js +0 -59
- package/runtime/guest/discover.d.ts +0 -29
- package/runtime/guest/discover.js +0 -51
- package/runtime/guest/failure.d.ts +0 -24
- package/runtime/guest/failure.js +0 -63
- package/runtime/guest/http.d.ts +0 -4
- package/runtime/guest/http.js +0 -204
- package/runtime/guest/index.d.ts +0 -12
- package/runtime/guest/index.js +0 -89
- package/runtime/guest/limits.js +0 -12
- package/runtime/guest/loader.d.ts +0 -15
- package/runtime/guest/loader.js +0 -42
- package/runtime/guest/log.d.ts +0 -10
- package/runtime/guest/log.js +0 -36
- package/runtime/guest/map.d.ts +0 -4
- package/runtime/guest/map.js +0 -45
- package/runtime/guest/paginate.d.ts +0 -6
- package/runtime/guest/paginate.js +0 -180
- package/runtime/guest/pull.d.ts +0 -12
- package/runtime/guest/pull.js +0 -150
- package/runtime/guest/push.d.ts +0 -18
- package/runtime/guest/push.js +0 -80
- package/runtime/guest/signal.d.ts +0 -3
- package/runtime/guest/signal.js +0 -12
- package/runtime/guest/time.d.ts +0 -5
- package/runtime/guest/time.js +0 -127
- package/runtime/guest-runtime.d.ts +0 -7
- package/runtime/guest-runtime.js +0 -51
- package/runtime/index.d.ts +0 -18
- package/runtime/index.js +0 -3
- package/runtime/protocol.d.ts +0 -276
- package/runtime/protocol.js +0 -409
- package/runtime/session.d.ts +0 -12
- package/runtime/session.js +0 -191
- package/t.d.ts +0 -18
- package/t.js +0 -26
- package/testing/child.cjs +0 -350
- package/testing/child.d.ts +0 -1
- package/testing/child.js +0 -12
- package/testing/compliance.d.ts +0 -41
- package/testing/compliance.js +0 -291
- package/testing/engine.d.ts +0 -66
- package/testing/engine.js +0 -339
- package/testing/fixtures.d.ts +0 -145
- package/testing/fixtures.js +0 -407
- package/testing/gateway.d.ts +0 -65
- package/testing/gateway.js +0 -402
- package/testing/import-guard.d.ts +0 -57
- package/testing/import-guard.js +0 -187
- package/testing/index.d.ts +0 -33
- package/testing/index.js +0 -10
- package/testing/local-db.d.ts +0 -27
- package/testing/local-db.js +0 -181
- package/testing/pinned-http.d.ts +0 -14
- package/testing/pinned-http.js +0 -125
- package/testing/recorder.d.ts +0 -15
- package/testing/recorder.js +0 -116
- package/testing/redaction.d.ts +0 -28
- package/testing/redaction.js +0 -67
- package/testing/replay.d.ts +0 -50
- package/testing/replay.js +0 -313
- package/testing/schema.d.ts +0 -13
- package/testing/schema.js +0 -65
- package/testing/source-loader.d.ts +0 -19
- package/testing/source-loader.js +0 -238
- package/testing/types.d.ts +0 -79
- package/testing/types.js +0 -18
- package/types.d.ts +0 -302
- package/types.js +0 -13
- package/validate.d.ts +0 -78
- package/validate.js +0 -597
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地模拟网关:在作者机器上应答 guest 的宿主调用,语义照抄平台出站网关(connector `src/egress/`):
|
|
3
|
+
*
|
|
4
|
+
* - 主机求交:请求主机必须匹配 `hosts` 里的一项(精确主机名,或 `*.后缀` 匹配一级或多级子域);调用方传入的 `hosts`
|
|
5
|
+
* 已是「连接器声明 ∩ 本地配置」,空数组即全部拒绝。不匹配时 `LocalRunError('HOST_DENIED')`,回给连接器的码与线上相同
|
|
6
|
+
* (`EGRESS_HOST_DENIED`)。本地开发要连本机,所以不做私网拦截,但 `169.254.169.254`、`100.100.100.200`、`fd00:ec2::254`
|
|
7
|
+
* 这三个云元数据地址仍拦截(含 IPv4 映射写法)。用真实网络时每一跳(含重定向)都先解析主机名,解析到它们同样拦截,
|
|
8
|
+
* 连接只连这次检查过的地址(`pinned-http.ts`),不给 DNS 重绑定留第二次解析的窗口。
|
|
9
|
+
* - 凭证句柄 `ontos:secret:<字段>` 在 URL(含 URL 编码写法)、请求头、utf8 body 与 `crypto` 的 key / data 里替换成明文;
|
|
10
|
+
* 回给连接器的响应 url、头与 body 里出现的明文(含 URL 编码与表单编码写法,见 `secretRedactionTexts`)替换为 `***`。`secret.reveal` 直接回明文(只有 `exposeToScript` 字段会来取)。
|
|
11
|
+
* - 限流:`rateLimit` 给出时按 `1 / perSecond` 秒的间隔逐个放行(不攒额度),同时在途不超过 `concurrency`;
|
|
12
|
+
* 超出的请求排队等待,不回 429(本地没有并发的其他运行)。
|
|
13
|
+
* - 超时缺省 30 秒、上限 120 秒(`EGRESS_TIMEOUT`);响应超过 10 MiB `EGRESS_RESPONSE_TOO_LARGE`;同主机重定向最多跟 5 次。
|
|
14
|
+
* - `ctx.db` 连 `db` 配置的库(见 `local-db.ts`),`ctx.cache` 存在进程内存里。
|
|
15
|
+
*
|
|
16
|
+
* 每次 http / db / crypto 调用都经 `onExchange` 交出**未遮蔽**的往来(请求是句柄形态,响应是上游原文),录制器负责遮蔽。
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, createHmac, sign as rsaSign } from 'node:crypto';
|
|
19
|
+
import type { LookupAddress } from 'node:dns';
|
|
20
|
+
import { lookup } from 'node:dns/promises';
|
|
21
|
+
import type {
|
|
22
|
+
CacheEntry, CryptoRequest, DbQueryRequest, EgressHttpRequest, EgressHttpResponse,
|
|
23
|
+
} from '../runtime/protocol';
|
|
24
|
+
import { createLocalDb, LocalDbError, type LocalDb } from './local-db';
|
|
25
|
+
import { PinnedBodyTooLargeError, pinnedRequest } from './pinned-http';
|
|
26
|
+
import { redactSecretValues, secretRedactionTexts } from './redaction';
|
|
27
|
+
import { LocalRunError, type HostMethod, type LocalHost } from './types';
|
|
28
|
+
|
|
29
|
+
export interface LocalGatewayOptions {
|
|
30
|
+
/** 连接器声明 ∩ 本地配置;空数组 = 全部拒绝。 */
|
|
31
|
+
hosts: string[];
|
|
32
|
+
rateLimit?: { perSecond: number; concurrency: number };
|
|
33
|
+
/** 字段 → 明文,用于句柄替换与遮蔽。 */
|
|
34
|
+
secrets: Record<string, string>;
|
|
35
|
+
db?: { host: string; port: number; database: string; user: string; password: string; ssl?: boolean } | null;
|
|
36
|
+
/** 测试注入;给了就不走真实网络(不解析主机名、不固定连接地址)。 */
|
|
37
|
+
fetch?: typeof fetch;
|
|
38
|
+
/** 测试注入:真实网络下的主机名解析,缺省 `dns.lookup(host, { all: true })`。 */
|
|
39
|
+
resolve?: (host: string) => Promise<LookupAddress[]>;
|
|
40
|
+
/** 注入 postgres 驱动(CLI 用);缺省动态 `import('postgres')`。 */
|
|
41
|
+
postgres?: unknown;
|
|
42
|
+
/** 录制器挂这里(Phase 3)。 */
|
|
43
|
+
onExchange?: (exchange: LocalExchange) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 未遮蔽的原始往来,录制器负责遮蔽。 */
|
|
47
|
+
export interface LocalExchange {
|
|
48
|
+
kind: 'http' | 'db' | 'crypto';
|
|
49
|
+
request: unknown;
|
|
50
|
+
response: unknown;
|
|
51
|
+
error: { code: string; message: string } | null;
|
|
52
|
+
durationMs: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 本地网关自己的宿主调用失败(码与平台网关一致)。 */
|
|
56
|
+
export class LocalGatewayError extends Error {
|
|
57
|
+
readonly code: string;
|
|
58
|
+
readonly status: number | undefined;
|
|
59
|
+
|
|
60
|
+
constructor(code: string, message: string, status?: number) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'LocalGatewayError';
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.status = status;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 与 shared `connector-limits.ts` 同值。 */
|
|
69
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
70
|
+
const MAX_TIMEOUT_MS = 120_000;
|
|
71
|
+
const RESPONSE_MAX_BYTES = 10 * 1024 * 1024;
|
|
72
|
+
const MAX_REDIRECTS = 5;
|
|
73
|
+
const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
|
|
74
|
+
/** 发请求前去掉的头(由 fetch 自己管)。 */
|
|
75
|
+
const DROPPED_REQUEST_HEADERS: ReadonlySet<string> = new Set(['host', 'content-length', 'connection', 'transfer-encoding', 'keep-alive', 'upgrade']);
|
|
76
|
+
|
|
77
|
+
/** 云元数据地址:本地网关也拦截。 */
|
|
78
|
+
const METADATA_ADDRESSES: ReadonlySet<string> = new Set(['169.254.169.254', '100.100.100.200', 'fd00:ec2::254']);
|
|
79
|
+
|
|
80
|
+
const SECRET_FIELD = '[A-Za-z_][A-Za-z0-9_]*';
|
|
81
|
+
const PLAIN_HANDLE = new RegExp(`ontos:secret:(${SECRET_FIELD})`, 'g');
|
|
82
|
+
const URL_HANDLE = new RegExp(`ontos(?::|%3[Aa])secret(?::|%3[Aa])(${SECRET_FIELD})`, 'g');
|
|
83
|
+
|
|
84
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function errorMessage(error: unknown): string {
|
|
89
|
+
return error instanceof Error ? error.message : String(error);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sleep(ms: number): Promise<void> {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// 主机
|
|
98
|
+
|
|
99
|
+
/** 去掉 IPv6 方括号与末尾的点,转小写;IPv4 映射的 IPv6(`::ffff:a9fe:a9fe`)还原成 IPv4。 */
|
|
100
|
+
export function normalizeHost(host: string): string {
|
|
101
|
+
const value = host.trim().replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
102
|
+
const mapped = /^::ffff:(?:0:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(value);
|
|
103
|
+
if (mapped) {
|
|
104
|
+
const high = Number.parseInt(mapped[1] ?? '0', 16);
|
|
105
|
+
const low = Number.parseInt(mapped[2] ?? '0', 16);
|
|
106
|
+
return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
|
107
|
+
}
|
|
108
|
+
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value);
|
|
109
|
+
if (dotted?.[1]) return dotted[1];
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 单项匹配:精确主机名,或 `*.后缀` 匹配后缀下一级或多级子域(不含后缀本身)。与 connector `hosts.ts` 相同。 */
|
|
114
|
+
function matchHostPattern(host: string, pattern: string): boolean {
|
|
115
|
+
const normalized = normalizeHost(pattern);
|
|
116
|
+
if (normalized.startsWith('*.')) {
|
|
117
|
+
const suffix = normalized.slice(1);
|
|
118
|
+
return suffix.length > 1 && host.length > suffix.length && host.endsWith(suffix);
|
|
119
|
+
}
|
|
120
|
+
return normalized !== '' && host === normalized;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isMetadataAddress(host: string): boolean {
|
|
124
|
+
return METADATA_ADDRESSES.has(normalizeHost(host));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isIpLiteral(host: string): boolean {
|
|
128
|
+
return /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(':');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// 句柄
|
|
133
|
+
|
|
134
|
+
function secretUnavailable(field: string): LocalGatewayError {
|
|
135
|
+
return new LocalGatewayError('EGRESS_SECRET_UNAVAILABLE', `Secret '${field}' is unavailable: it isn't configured locally`, 422);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function lookupSecret(values: Record<string, string>, field: string): string {
|
|
139
|
+
const value = Object.hasOwn(values, field) ? values[field] : undefined;
|
|
140
|
+
if (typeof value !== 'string') throw secretUnavailable(field);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function substituteHandles(text: string, values: Record<string, string>): string {
|
|
145
|
+
for (const match of text.matchAll(PLAIN_HANDLE)) lookupSecret(values, match[1] ?? '');
|
|
146
|
+
return text.replace(PLAIN_HANDLE, (_match, field: string) => lookupSecret(values, field));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function substituteUrlHandles(text: string, values: Record<string, string>): string {
|
|
150
|
+
for (const match of text.matchAll(URL_HANDLE)) lookupSecret(values, match[1] ?? '');
|
|
151
|
+
return text.replace(URL_HANDLE, (_match, field: string) => encodeURIComponent(lookupSecret(values, field)));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// 限流
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 限流:同时在途不超过 `concurrency`,放行间隔 `1 / perSecond` 秒。
|
|
159
|
+
* 释放槽位时若有人排队,槽位直接交给队头(`active` 不减),避免刚进来的请求在交接间隙抢占同一个槽位。
|
|
160
|
+
*/
|
|
161
|
+
export class LocalLimiter {
|
|
162
|
+
private nextAt = 0;
|
|
163
|
+
private active = 0;
|
|
164
|
+
private readonly waiting: Array<() => void> = [];
|
|
165
|
+
|
|
166
|
+
constructor(private readonly perSecond: number, private readonly concurrency: number) {}
|
|
167
|
+
|
|
168
|
+
async acquire(): Promise<() => void> {
|
|
169
|
+
if (this.active < this.concurrency && this.waiting.length === 0) {
|
|
170
|
+
this.active += 1;
|
|
171
|
+
} else {
|
|
172
|
+
// 槽位由释放方交接过来,`active` 已经算上了这一个。
|
|
173
|
+
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
|
174
|
+
}
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
const at = Math.max(now, this.nextAt);
|
|
177
|
+
this.nextAt = at + 1000 / this.perSecond;
|
|
178
|
+
if (at > now) await sleep(at - now);
|
|
179
|
+
let released = false;
|
|
180
|
+
return () => {
|
|
181
|
+
if (released) return;
|
|
182
|
+
released = true;
|
|
183
|
+
const next = this.waiting.shift();
|
|
184
|
+
if (next) next();
|
|
185
|
+
else this.active -= 1;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// http
|
|
192
|
+
|
|
193
|
+
function parseHttpRequest(payload: unknown): EgressHttpRequest {
|
|
194
|
+
if (!isRecord(payload) || typeof payload.url !== 'string' || typeof payload.method !== 'string') {
|
|
195
|
+
throw new LocalGatewayError('BAD_REQUEST', 'http request must have a method and a url', 400);
|
|
196
|
+
}
|
|
197
|
+
return payload as unknown as EgressHttpRequest;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 脚本视角的 URL(句柄未替换)加上 query。 */
|
|
201
|
+
function scriptUrlOf(request: EgressHttpRequest): URL {
|
|
202
|
+
let url: URL;
|
|
203
|
+
try {
|
|
204
|
+
url = new URL(request.url);
|
|
205
|
+
} catch {
|
|
206
|
+
throw new LocalGatewayError('BAD_REQUEST', 'url must be an absolute URL', 400);
|
|
207
|
+
}
|
|
208
|
+
for (const [name, value] of Object.entries(request.query ?? {})) {
|
|
209
|
+
for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(name, item);
|
|
210
|
+
}
|
|
211
|
+
return url;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
215
|
+
|
|
216
|
+
function encodeBody(bytes: Uint8Array): { body: string; bodyEncoding: 'utf8' | 'base64' } {
|
|
217
|
+
try {
|
|
218
|
+
return { body: utf8.decode(bytes), bodyEncoding: 'utf8' };
|
|
219
|
+
} catch {
|
|
220
|
+
return { body: Buffer.from(bytes).toString('base64'), bodyEncoding: 'base64' };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function tooLarge(): LocalGatewayError {
|
|
225
|
+
return new LocalGatewayError('EGRESS_RESPONSE_TOO_LARGE', `The external system's response exceeds the ${RESPONSE_MAX_BYTES / 1024 / 1024} MiB limit`, 502);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hostDenied(host: string): LocalRunError {
|
|
229
|
+
return new LocalRunError('HOST_DENIED', `Access to host '${host}' is denied: add it to the connector's hosts and the local configuration`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// crypto
|
|
234
|
+
|
|
235
|
+
function computeCrypto(request: CryptoRequest, values: Record<string, string>): { value: string } {
|
|
236
|
+
const data = substituteHandles(request.data, values);
|
|
237
|
+
switch (request.op) {
|
|
238
|
+
case 'hash':
|
|
239
|
+
return { value: createHash(request.algorithm).update(data, 'utf8').digest(request.encoding) };
|
|
240
|
+
case 'hmac':
|
|
241
|
+
return { value: createHmac(request.algorithm, substituteHandles(request.key, values)).update(data, 'utf8').digest(request.encoding) };
|
|
242
|
+
case 'sign': {
|
|
243
|
+
try {
|
|
244
|
+
return { value: rsaSign('sha256', Buffer.from(data, 'utf8'), substituteHandles(request.key, values)).toString(request.encoding) };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (error instanceof LocalGatewayError) throw error;
|
|
247
|
+
throw new LocalGatewayError('BAD_REQUEST', 'key is not a valid RSA private key', 400);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
throw new LocalGatewayError('BAD_REQUEST', 'op must be one of hmac, sign, hash', 400);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// 网关
|
|
257
|
+
|
|
258
|
+
export function createLocalGateway(options: LocalGatewayOptions): LocalHost & { redact(text: string): string } {
|
|
259
|
+
const secrets = { ...options.secrets };
|
|
260
|
+
const secretTexts = secretRedactionTexts([...Object.values(secrets), ...(options.db?.password ? [options.db.password] : [])]);
|
|
261
|
+
const redact = (text: string): string => redactSecretValues(text, secretTexts);
|
|
262
|
+
const hostPatterns = [...options.hosts];
|
|
263
|
+
const doFetch = options.fetch ?? fetch;
|
|
264
|
+
const realNetwork = options.fetch === undefined;
|
|
265
|
+
const resolveHost = options.resolve ?? ((host: string) => lookup(host, { all: true, verbatim: true }));
|
|
266
|
+
const limiter = options.rateLimit && options.rateLimit.perSecond > 0
|
|
267
|
+
? new LocalLimiter(options.rateLimit.perSecond, Math.max(1, Math.floor(options.rateLimit.concurrency || 1)))
|
|
268
|
+
: null;
|
|
269
|
+
const cache = new Map<string, CacheEntry>();
|
|
270
|
+
let db: LocalDb | null = null;
|
|
271
|
+
|
|
272
|
+
const exchange = (entry: LocalExchange): void => {
|
|
273
|
+
try {
|
|
274
|
+
options.onExchange?.(entry);
|
|
275
|
+
} catch {
|
|
276
|
+
// 录制器出错不影响请求本身
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 主机检查(每一跳都做):主机求交、元数据地址拦截;真实网络下解析主机名,解析结果含元数据地址时拦截,
|
|
282
|
+
* 返回检查过的地址供连接固定使用(注入 fetch 时返回 null)。
|
|
283
|
+
*/
|
|
284
|
+
const checkHost = async (url: URL): Promise<LookupAddress[] | null> => {
|
|
285
|
+
const host = normalizeHost(url.hostname);
|
|
286
|
+
if (isMetadataAddress(host)) throw hostDenied(host);
|
|
287
|
+
const allowed = hostPatterns.some((pattern) => matchHostPattern(host, pattern) || normalizeHost(pattern) === url.host.toLowerCase());
|
|
288
|
+
if (!allowed) throw hostDenied(host);
|
|
289
|
+
if (!realNetwork) return null;
|
|
290
|
+
if (isIpLiteral(host)) return [{ address: host, family: host.includes(':') ? 6 : 4 }];
|
|
291
|
+
let addresses: LookupAddress[];
|
|
292
|
+
try {
|
|
293
|
+
addresses = await resolveHost(host);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
throw new LocalGatewayError('EGRESS_UPSTREAM_FAILED', redact(`The outbound request failed: cannot resolve ${host}: ${errorMessage(error)}`), 502);
|
|
296
|
+
}
|
|
297
|
+
if (addresses.length === 0) throw new LocalGatewayError('EGRESS_UPSTREAM_FAILED', `The outbound request failed: cannot resolve ${host}`, 502);
|
|
298
|
+
if (addresses.some((record) => isMetadataAddress(record.address))) throw hostDenied(host);
|
|
299
|
+
return addresses;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const sendOnce = async (
|
|
303
|
+
url: URL, method: string, headers: Record<string, string>, body: Uint8Array | null, addresses: LookupAddress[] | null, signal: AbortSignal,
|
|
304
|
+
): Promise<Response> => {
|
|
305
|
+
try {
|
|
306
|
+
if (addresses !== null) return await pinnedRequest({ url, method, headers, body, addresses, signal, maxBytes: RESPONSE_MAX_BYTES });
|
|
307
|
+
return await doFetch(url.toString(), {
|
|
308
|
+
method,
|
|
309
|
+
headers,
|
|
310
|
+
// DOM lib 下 `Uint8Array<ArrayBufferLike>` 不算 BodyInit(CLI 带 DOM lib 编译本文件),运行时 fetch 照收。
|
|
311
|
+
...(body !== null && method !== 'GET' && method !== 'HEAD' ? { body: body as unknown as NonNullable<RequestInit['body']> } : {}),
|
|
312
|
+
redirect: 'manual',
|
|
313
|
+
signal,
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error instanceof PinnedBodyTooLargeError) throw tooLarge();
|
|
317
|
+
if (signal.aborted) throw new LocalGatewayError('EGRESS_TIMEOUT', 'The outbound request timed out', 504);
|
|
318
|
+
throw new LocalGatewayError('EGRESS_UPSTREAM_FAILED', redact(`The outbound request failed: ${errorMessage(error)}`), 502);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const http = async (payload: unknown): Promise<EgressHttpResponse> => {
|
|
323
|
+
const request = parseHttpRequest(payload);
|
|
324
|
+
const scriptUrl = scriptUrlOf(request);
|
|
325
|
+
let url: URL;
|
|
326
|
+
try {
|
|
327
|
+
url = new URL(substituteUrlHandles(scriptUrl.toString(), secrets));
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error instanceof LocalGatewayError) throw error;
|
|
330
|
+
throw new LocalGatewayError('BAD_REQUEST', 'url is not valid after substituting secrets', 400);
|
|
331
|
+
}
|
|
332
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new LocalGatewayError('EGRESS_SCHEME_DENIED', 'Only http(s) outbound requests are allowed', 403);
|
|
333
|
+
let addresses = await checkHost(url);
|
|
334
|
+
let headers: Record<string, string> = {};
|
|
335
|
+
for (const [name, value] of Object.entries(request.headers ?? {})) {
|
|
336
|
+
if (DROPPED_REQUEST_HEADERS.has(name.toLowerCase())) continue;
|
|
337
|
+
headers[name] = substituteHandles(value, secrets);
|
|
338
|
+
}
|
|
339
|
+
let body: Uint8Array | null = null;
|
|
340
|
+
if (request.body !== null && request.body !== undefined) {
|
|
341
|
+
body = request.bodyEncoding === 'base64' ? Buffer.from(request.body, 'base64') : Buffer.from(substituteHandles(request.body, secrets), 'utf8');
|
|
342
|
+
}
|
|
343
|
+
const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
344
|
+
const release = limiter ? await limiter.acquire() : () => undefined;
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
347
|
+
try {
|
|
348
|
+
let method: string = request.method;
|
|
349
|
+
let redirects = 0;
|
|
350
|
+
for (;;) {
|
|
351
|
+
const response = await sendOnce(url, method, headers, body, addresses, controller.signal);
|
|
352
|
+
const location = response.headers.get('location');
|
|
353
|
+
if (REDIRECT_STATUSES.has(response.status) && location !== null && redirects < MAX_REDIRECTS) {
|
|
354
|
+
let next: URL | null = null;
|
|
355
|
+
try {
|
|
356
|
+
next = new URL(location, url);
|
|
357
|
+
} catch {
|
|
358
|
+
next = null;
|
|
359
|
+
}
|
|
360
|
+
if (next && next.host === url.host && (next.protocol === 'https:' || next.protocol === 'http:')) {
|
|
361
|
+
redirects += 1;
|
|
362
|
+
if (response.status === 303 ? method !== 'HEAD' : (response.status === 301 || response.status === 302) && method !== 'GET' && method !== 'HEAD') {
|
|
363
|
+
method = 'GET';
|
|
364
|
+
body = null;
|
|
365
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== 'content-type'));
|
|
366
|
+
}
|
|
367
|
+
url = next;
|
|
368
|
+
// 重定向的每一跳重新解析、重新检查,连接固定到这一跳检查过的地址。
|
|
369
|
+
addresses = await checkHost(url);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
let bytes: Uint8Array;
|
|
374
|
+
try {
|
|
375
|
+
bytes = method === 'HEAD' ? new Uint8Array() : new Uint8Array(await response.arrayBuffer());
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (controller.signal.aborted) throw new LocalGatewayError('EGRESS_TIMEOUT', 'The outbound request timed out', 504);
|
|
378
|
+
throw new LocalGatewayError('EGRESS_UPSTREAM_FAILED', redact(`Reading the response failed: ${errorMessage(error)}`), 502);
|
|
379
|
+
}
|
|
380
|
+
if (bytes.byteLength > RESPONSE_MAX_BYTES) throw tooLarge();
|
|
381
|
+
const responseHeaders: Record<string, string> = {};
|
|
382
|
+
response.headers.forEach((value, name) => {
|
|
383
|
+
const lower = name.toLowerCase();
|
|
384
|
+
// fetch 已经解压:编码与长度头不再对应正文。
|
|
385
|
+
if (lower === 'content-encoding' || lower === 'content-length') return;
|
|
386
|
+
responseHeaders[lower] = value;
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
status: response.status,
|
|
390
|
+
url: redirects === 0 ? scriptUrl.toString() : url.toString(),
|
|
391
|
+
headers: responseHeaders,
|
|
392
|
+
...encodeBody(bytes),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
release();
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/** 回给连接器的响应:明文替换为 `***`(与平台网关相同)。 */
|
|
402
|
+
const redactResponse = (response: EgressHttpResponse): EgressHttpResponse => ({
|
|
403
|
+
...response,
|
|
404
|
+
url: redact(response.url),
|
|
405
|
+
headers: Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name, redact(value)])),
|
|
406
|
+
body: response.bodyEncoding === 'utf8' ? redact(response.body) : response.body,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const dbOf = (): LocalDb => {
|
|
410
|
+
if (!options.db) throw new LocalGatewayError('DB_PROXY_NOT_DECLARED', 'ctx.db is not configured locally: set db in the local configuration', 403);
|
|
411
|
+
if (isMetadataAddress(options.db.host)) throw hostDenied(normalizeHost(options.db.host));
|
|
412
|
+
db ??= createLocalDb(options.db, { driver: options.postgres, secrets: Object.values(secrets) });
|
|
413
|
+
return db;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** 计时并把往来交给 `onExchange`;失败时同样交出(带 error)后再抛。 */
|
|
417
|
+
const traced = async <T>(kind: LocalExchange['kind'], request: unknown, run: () => Promise<T>): Promise<T> => {
|
|
418
|
+
const startedAt = Date.now();
|
|
419
|
+
try {
|
|
420
|
+
const response = await run();
|
|
421
|
+
exchange({ kind, request, response, error: null, durationMs: Date.now() - startedAt });
|
|
422
|
+
return response;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const code = isRecord(error) && typeof error.code === 'string' ? error.code : 'HOST_CALL_FAILED';
|
|
425
|
+
exchange({ kind, request, response: null, error: { code, message: errorMessage(error) }, durationMs: Date.now() - startedAt });
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const call = async (method: HostMethod, payload: unknown): Promise<unknown> => {
|
|
431
|
+
switch (method) {
|
|
432
|
+
case 'http':
|
|
433
|
+
return redactResponse(await traced('http', payload, () => http(payload)));
|
|
434
|
+
case 'db.query': {
|
|
435
|
+
const request = payload as DbQueryRequest;
|
|
436
|
+
return traced('db', request, () => dbOf().query({ sql: request.sql, params: request.params ?? [] }));
|
|
437
|
+
}
|
|
438
|
+
case 'db.next': {
|
|
439
|
+
const cursorId = isRecord(payload) && typeof payload.cursorId === 'string' ? payload.cursorId : '';
|
|
440
|
+
return traced('db', { cursorId }, () => dbOf().next(cursorId));
|
|
441
|
+
}
|
|
442
|
+
case 'db.close': {
|
|
443
|
+
const cursorId = isRecord(payload) && typeof payload.cursorId === 'string' ? payload.cursorId : '';
|
|
444
|
+
await dbOf().close(cursorId);
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
case 'crypto':
|
|
448
|
+
return traced('crypto', payload, async () => computeCrypto(payload as CryptoRequest, secrets));
|
|
449
|
+
case 'cache.get': {
|
|
450
|
+
const key = isRecord(payload) && typeof payload.key === 'string' ? payload.key : '';
|
|
451
|
+
const entry = cache.get(key);
|
|
452
|
+
if (!entry || Date.parse(entry.expiresAt) <= Date.now()) return null;
|
|
453
|
+
return entry;
|
|
454
|
+
}
|
|
455
|
+
case 'cache.set': {
|
|
456
|
+
if (!isRecord(payload) || typeof payload.key !== 'string' || typeof payload.value !== 'string' || typeof payload.ttlSeconds !== 'number') {
|
|
457
|
+
throw new LocalGatewayError('BAD_REQUEST', 'cache.set needs key, value and ttlSeconds', 400);
|
|
458
|
+
}
|
|
459
|
+
cache.set(payload.key, { value: payload.value, expiresAt: new Date(Date.now() + payload.ttlSeconds * 1000).toISOString() });
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
case 'secret.reveal': {
|
|
463
|
+
const field = isRecord(payload) && typeof payload.field === 'string' ? payload.field : '';
|
|
464
|
+
return { value: lookupSecret(secrets, field) };
|
|
465
|
+
}
|
|
466
|
+
default:
|
|
467
|
+
throw new LocalGatewayError('HOST_METHOD_UNSUPPORTED', `the local gateway does not answer ${method}`);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
call,
|
|
473
|
+
redact,
|
|
474
|
+
async close() {
|
|
475
|
+
const current = db;
|
|
476
|
+
db = null;
|
|
477
|
+
if (current) await current.end();
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export { LocalDbError };
|