agent-dual-proxy 0.0.0-stage → 0.7.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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +140 -2
- package/SECURITY.md +23 -0
- package/bin/agent-gateway.js +261 -0
- package/bin/dual-proxy.js +195 -0
- package/contrib/dual-proxy.service +17 -0
- package/docs/agents.md +94 -0
- package/package.json +45 -4
- package/src/agents.js +61 -0
- package/src/engines.js +25 -0
- package/src/gateways.js +163 -0
- package/src/paths.js +194 -0
- package/src/pref.js +40 -0
- package/src/retry-policy.js +43 -0
- package/src/server.js +532 -0
package/src/paths.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import tls from 'node:tls';
|
|
3
|
+
|
|
4
|
+
/** 挂在隧道 socket 上:CONNECT 200 之后同包到达的早期数据。 */
|
|
5
|
+
export const LEFTOVER = Symbol('agent-dual-proxy.leftover');
|
|
6
|
+
|
|
7
|
+
/** 隧道 socket 的通用参数:让死连接更快被发现,小包不被攒着发。 */
|
|
8
|
+
function tune(sock) {
|
|
9
|
+
try {
|
|
10
|
+
sock.setKeepAlive(true, 30000);
|
|
11
|
+
sock.setNoDelay(true);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
/* ignore */
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 直连目标,成功回 socket,失败/超时回 null。
|
|
19
|
+
* 只做 TCP 连接,不做 TLS——TLS 由 probeTls 或客户端自己完成。
|
|
20
|
+
*/
|
|
21
|
+
export function connectDirect(host, port, timeoutMs) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const sock = net.connect({ host, port });
|
|
24
|
+
// 兜底监听:超时/销毁后底层若再冒 error,不能变成未捕获异常
|
|
25
|
+
sock.on('error', () => {});
|
|
26
|
+
let settled = false;
|
|
27
|
+
const done = (value) => {
|
|
28
|
+
if (settled) return;
|
|
29
|
+
settled = true;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
sock.removeListener('error', onError);
|
|
32
|
+
if (!value) sock.destroy();
|
|
33
|
+
resolve(value);
|
|
34
|
+
};
|
|
35
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
36
|
+
const onError = () => done(null);
|
|
37
|
+
sock.once('connect', () => {
|
|
38
|
+
tune(sock);
|
|
39
|
+
done(sock);
|
|
40
|
+
});
|
|
41
|
+
sock.once('error', onError);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 经上游 HTTP 代理建 CONNECT 隧道,成功回 socket,失败/超时回 null。
|
|
47
|
+
* 返回的 socket 处于隧道中,可直接写目标协议(TLS 由调用方/客户端完成)。
|
|
48
|
+
*/
|
|
49
|
+
export function connectViaProxy(upstream, host, port, timeoutMs) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const sock = net.connect({ host: upstream.host, port: upstream.port });
|
|
52
|
+
sock.on('error', () => {});
|
|
53
|
+
let settled = false;
|
|
54
|
+
const done = (value) => {
|
|
55
|
+
if (settled) return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
sock.removeListener('data', onData);
|
|
59
|
+
sock.removeListener('error', onError);
|
|
60
|
+
if (!value) sock.destroy();
|
|
61
|
+
resolve(value);
|
|
62
|
+
};
|
|
63
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
64
|
+
let head = '';
|
|
65
|
+
const onData = (chunk) => {
|
|
66
|
+
head += chunk.toString('latin1');
|
|
67
|
+
const end = head.indexOf('\r\n\r\n');
|
|
68
|
+
if (end === -1) {
|
|
69
|
+
if (head.length > 16384) done(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const statusLine = head.slice(0, head.indexOf('\r\n'));
|
|
73
|
+
if (!/^HTTP\/1\.[01] 200\b/.test(statusLine)) {
|
|
74
|
+
done(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// 200 之后同包到达的隧道数据(服务端先发言的 banner)留给调用方转交。
|
|
78
|
+
// 不能 unshift:socket 处于 flowing 模式,此刻没有 data 监听,数据会被丢掉。
|
|
79
|
+
const leftover = head.slice(end + 4);
|
|
80
|
+
if (leftover) sock[LEFTOVER] = Buffer.from(leftover, 'latin1');
|
|
81
|
+
tune(sock);
|
|
82
|
+
done(sock);
|
|
83
|
+
};
|
|
84
|
+
const onError = () => done(null);
|
|
85
|
+
sock.on('data', onData);
|
|
86
|
+
sock.on('error', onError);
|
|
87
|
+
sock.write(
|
|
88
|
+
'CONNECT ' + host + ':' + port + ' HTTP/1.1\r\n' +
|
|
89
|
+
'Host: ' + host + ':' + port + '\r\n' +
|
|
90
|
+
(upstream.auth ? 'Proxy-Authorization: ' + upstream.auth + '\r\n' : '') +
|
|
91
|
+
'Proxy-Connection: keep-alive\r\n\r\n'
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 对一条路做 TLS 握手探测:能完成握手才算“到得了”。
|
|
98
|
+
*
|
|
99
|
+
* 只测连通性,不校验证书(rejectUnauthorized: false)——探测的目的不是信任,
|
|
100
|
+
* 而是判断这条路能不能承载 TLS。探测连接用完即毁,不会交给客户端。
|
|
101
|
+
*/
|
|
102
|
+
export async function probeTls(open, host, port, timeoutMs) {
|
|
103
|
+
const sock = await open(host, port, timeoutMs);
|
|
104
|
+
if (!sock) return false;
|
|
105
|
+
|
|
106
|
+
const client = await tlsOver(sock, host, timeoutMs);
|
|
107
|
+
if (client) {
|
|
108
|
+
client.destroy();
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
sock.destroy();
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 在一条路上完成 TLS 握手,返回已加密的 socket;失败回 null。 */
|
|
116
|
+
function tlsOver(sock, servername, timeoutMs) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
let client;
|
|
119
|
+
let done = false;
|
|
120
|
+
const finish = (value) => {
|
|
121
|
+
if (done) return;
|
|
122
|
+
done = true;
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
if (!value && client) { try { client.destroy(); } catch (e) { /* ignore */ } }
|
|
125
|
+
resolve(value);
|
|
126
|
+
};
|
|
127
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
128
|
+
try {
|
|
129
|
+
const options = { socket: sock, rejectUnauthorized: false };
|
|
130
|
+
// 目标是 IP 字面量时不要设 servername(Node 会报 DEP0123 并忽略)
|
|
131
|
+
if (net.isIP(servername) === 0) options.servername = servername;
|
|
132
|
+
client = tls.connect(options);
|
|
133
|
+
client.once('secureConnect', () => finish(client));
|
|
134
|
+
client.once('error', () => finish(null));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
finish(null);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** HTTP 请求行里的 Host:IPv6 加方括号,非默认端口带端口。 */
|
|
142
|
+
export function hostHeader(host, port, secure) {
|
|
143
|
+
const name = net.isIP(host) === 6 ? '[' + host + ']' : host;
|
|
144
|
+
const defaultPort = secure ? 443 : 80;
|
|
145
|
+
return Number(port) === defaultPort ? name : name + ':' + port;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 在一条路上发一个 HTTP 请求,只看响应状态码——用来验证“通道真的通到应用层”。
|
|
150
|
+
*
|
|
151
|
+
* 与 probeTls 一样不校验证书:这里判断的是可达性,不是信任。任何 < 500 的响应
|
|
152
|
+
* 都算通道顺畅(401/403 说明端点活着,只是没带凭证)。失败/超时回 null。
|
|
153
|
+
*/
|
|
154
|
+
export async function probeHttp(open, host, port, path, secure, timeoutMs) {
|
|
155
|
+
const sock = await open(host, port, timeoutMs);
|
|
156
|
+
if (!sock) return null;
|
|
157
|
+
|
|
158
|
+
let target = sock;
|
|
159
|
+
if (secure) {
|
|
160
|
+
target = await tlsOver(sock, host, timeoutMs);
|
|
161
|
+
if (!target) { try { sock.destroy(); } catch (e) { /* ignore */ } return null; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
let done = false;
|
|
166
|
+
let buf = '';
|
|
167
|
+
const finish = (value) => {
|
|
168
|
+
if (done) return;
|
|
169
|
+
done = true;
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
target.removeListener('data', onData);
|
|
172
|
+
target.removeListener('error', onError);
|
|
173
|
+
try { target.destroy(); } catch (e) { /* ignore */ }
|
|
174
|
+
if (target !== sock) { try { sock.destroy(); } catch (e) { /* ignore */ } }
|
|
175
|
+
resolve(value);
|
|
176
|
+
};
|
|
177
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
178
|
+
const onData = (chunk) => {
|
|
179
|
+
buf += chunk.toString('latin1');
|
|
180
|
+
const match = /^HTTP\/1\.[01] (\d{3})/.exec(buf);
|
|
181
|
+
if (match) { finish(Number(match[1])); return; }
|
|
182
|
+
if (buf.length > 16384) finish(null);
|
|
183
|
+
};
|
|
184
|
+
const onError = () => finish(null);
|
|
185
|
+
target.on('data', onData);
|
|
186
|
+
target.on('error', onError);
|
|
187
|
+
target.write(
|
|
188
|
+
'GET ' + path + ' HTTP/1.1\r\n' +
|
|
189
|
+
'Host: ' + hostHeader(host, port, secure) + '\r\n' +
|
|
190
|
+
'User-Agent: agent-dual-proxy\r\n' +
|
|
191
|
+
'Connection: close\r\n\r\n'
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
package/src/pref.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按 host 记住上一次走通的路,避免每个请求都重新诊断。
|
|
3
|
+
*
|
|
4
|
+
* 只存 host(不带端口)——同一 host 的不同端口通常同属一个服务,
|
|
5
|
+
* 共享偏好能少一次探测;TTL 到期自动失效。
|
|
6
|
+
*/
|
|
7
|
+
export function createPref(ttlMs, now, maxSize = 1000) {
|
|
8
|
+
const clock = now || Date.now;
|
|
9
|
+
const map = new Map();
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
get(host) {
|
|
13
|
+
const entry = map.get(host);
|
|
14
|
+
if (!entry) return null;
|
|
15
|
+
if (clock() - entry.at > ttlMs) {
|
|
16
|
+
map.delete(host);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// 访问即刷新顺序,淘汰时先丢最久没用过的
|
|
20
|
+
map.delete(host);
|
|
21
|
+
map.set(host, entry);
|
|
22
|
+
return entry.via;
|
|
23
|
+
},
|
|
24
|
+
set(host, via) {
|
|
25
|
+
if (map.has(host)) {
|
|
26
|
+
map.delete(host);
|
|
27
|
+
} else if (maxSize > 0 && map.size >= maxSize) {
|
|
28
|
+
const oldest = map.keys().next().value;
|
|
29
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
map.set(host, { via, at: clock() });
|
|
32
|
+
},
|
|
33
|
+
delete(host) {
|
|
34
|
+
map.delete(host);
|
|
35
|
+
},
|
|
36
|
+
size() {
|
|
37
|
+
return map.size;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** 幂等集合按 RFC 9110 §9.2.2;方法名大小写不敏感,未知方法按非幂等。 */
|
|
2
|
+
const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'PUT', 'DELETE']);
|
|
3
|
+
const IDEMPOTENCY_HEADERS = new Set(['idempotency-key', 'x-idempotency-key']);
|
|
4
|
+
|
|
5
|
+
export function isIdempotent(method) {
|
|
6
|
+
return IDEMPOTENT.has(String(method || '').toUpperCase());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 请求是否带了一个非空的幂等键。只看值是否存在,不校验格式,也不记录值。
|
|
11
|
+
* 头名大小写不敏感;数组值里任意一个非空即算有键。
|
|
12
|
+
*/
|
|
13
|
+
export function hasIdempotencyKey(headers) {
|
|
14
|
+
if (!headers || typeof headers !== 'object') return false;
|
|
15
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
16
|
+
if (!IDEMPOTENCY_HEADERS.has(String(name).toLowerCase())) continue;
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
if (value.some((item) => String(item).trim())) return true;
|
|
19
|
+
} else if (String(value === undefined || value === null ? '' : value).trim()) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 连接已建立(请求体可能已交给网关)之后失败时,是否允许换路重放。
|
|
28
|
+
* sent=false 时请求体一定没交出去,任何方法都可以重放。
|
|
29
|
+
*
|
|
30
|
+
* 优先级:未发送 → 幂等方法 → 全局开关 → 显式幂等键。
|
|
31
|
+
*/
|
|
32
|
+
export function shouldRetryAfterSend({
|
|
33
|
+
method,
|
|
34
|
+
sent,
|
|
35
|
+
retryNonIdempotentAfterSend = false,
|
|
36
|
+
retryNonIdempotentWithIdempotencyKey = false,
|
|
37
|
+
idempotencyKey = false
|
|
38
|
+
}) {
|
|
39
|
+
if (!sent) return true;
|
|
40
|
+
if (isIdempotent(method)) return true;
|
|
41
|
+
if (retryNonIdempotentAfterSend) return true;
|
|
42
|
+
return Boolean(retryNonIdempotentWithIdempotencyKey && idempotencyKey);
|
|
43
|
+
}
|