wafio-client 1.0.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/LICENSE +21 -0
- package/README.md +835 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +766 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wafio client: TCP mTLS client for Wafio WAF server.
|
|
3
|
+
* Send requests for analysis (analyze) and check block status (checkBlock).
|
|
4
|
+
* Works with Node.js and Bun.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as tls from "tls";
|
|
8
|
+
// --- Frame type constants (must match server) ---
|
|
9
|
+
const TYPE_CHECK_BLOCK_REQ = 0x01;
|
|
10
|
+
const TYPE_ANALYZE_REQ = 0x02;
|
|
11
|
+
const TYPE_TIER_LIMITS_REQ = 0x03;
|
|
12
|
+
const TYPE_CHECK_BLOCK_RESP = 0x81;
|
|
13
|
+
const TYPE_ANALYZE_RESP = 0x82;
|
|
14
|
+
const TYPE_TIER_LIMITS_RESP = 0x83;
|
|
15
|
+
/** Normalize connection errors so caller gets a single Error with .code (e.g. ECONNREFUSED) for fail-open. */
|
|
16
|
+
function normalizeConnectError(err, host, port) {
|
|
17
|
+
const code = getConnectErrorCode(err);
|
|
18
|
+
const msg = code === "ECONNREFUSED"
|
|
19
|
+
? `Wafio server unreachable at ${host}:${port} (ECONNREFUSED). Is the server running?`
|
|
20
|
+
: err instanceof Error
|
|
21
|
+
? err.message
|
|
22
|
+
: String(err);
|
|
23
|
+
const out = err instanceof Error ? err : new Error(String(err));
|
|
24
|
+
if (code) {
|
|
25
|
+
out.code = code;
|
|
26
|
+
}
|
|
27
|
+
out.message = msg;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
const CONNECTION_ERROR_CODES = ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "ECONNRESET", "ENETUNREACH", "EPIPE"];
|
|
31
|
+
function getConnectErrorCode(err) {
|
|
32
|
+
if (err && typeof err === "object") {
|
|
33
|
+
const c = err.code;
|
|
34
|
+
if (typeof c === "string" && CONNECTION_ERROR_CODES.includes(c))
|
|
35
|
+
return c;
|
|
36
|
+
const agg = err;
|
|
37
|
+
if (Array.isArray(agg.errors)) {
|
|
38
|
+
for (const e of agg.errors) {
|
|
39
|
+
const inner = getConnectErrorCode(e);
|
|
40
|
+
if (inner)
|
|
41
|
+
return inner;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
function normalizePem(pem) {
|
|
48
|
+
if (typeof pem !== "string")
|
|
49
|
+
return "";
|
|
50
|
+
let s = pem.replace(/\\n/g, "\n").replace(/\r\n/g, "\n");
|
|
51
|
+
if (s.length > 0 && s[s.length - 1] !== "\n")
|
|
52
|
+
s += "\n";
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
function parseAndNormalizeMtlsFile(filePath, data) {
|
|
56
|
+
if (!data.client_cert_pem || !data.client_key_pem) {
|
|
57
|
+
throw new Error("JSON file must contain client_cert_pem and client_key_pem");
|
|
58
|
+
}
|
|
59
|
+
if (!data.ca_pem) {
|
|
60
|
+
throw new Error("JSON file must contain ca_pem (credentials from Wafio API include ca_pem)");
|
|
61
|
+
}
|
|
62
|
+
const cert = normalizePem(data.client_cert_pem);
|
|
63
|
+
const key = normalizePem(data.client_key_pem);
|
|
64
|
+
const ca = normalizePem(data.ca_pem);
|
|
65
|
+
if (!cert.includes("BEGIN CERTIFICATE") || !cert.includes("END CERTIFICATE")) {
|
|
66
|
+
throw new Error("client_cert_pem must be valid PEM with BEGIN/END CERTIFICATE");
|
|
67
|
+
}
|
|
68
|
+
if (!key.includes("BEGIN") || !key.includes("PRIVATE KEY")) {
|
|
69
|
+
throw new Error("client_key_pem must be valid PEM with BEGIN PRIVATE KEY");
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
client_cert_pem: cert,
|
|
73
|
+
client_key_pem: key,
|
|
74
|
+
ca_pem: ca,
|
|
75
|
+
tcp_url: typeof data.tcp_url === "string" ? data.tcp_url.trim() : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Load the mtls-credentials.json file (saved from Wafio API, e.g. POST /api/projects/:id/mtls-keys).
|
|
80
|
+
* The file must contain client_cert_pem, client_key_pem, and ca_pem.
|
|
81
|
+
*
|
|
82
|
+
* @param filePath - Path ke file JSON (e.g. "./mtls-credentials.json")
|
|
83
|
+
* @returns WafioCredentials untuk new WafioClient({ credentials: ... })
|
|
84
|
+
*/
|
|
85
|
+
export function loadMtlsCredentialsFile(filePath) {
|
|
86
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
87
|
+
const data = JSON.parse(raw);
|
|
88
|
+
return parseAndNormalizeMtlsFile(filePath, data);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Load credentials from a JSON file (same format as mtls-credentials.json).
|
|
92
|
+
*/
|
|
93
|
+
export function loadCredentialsFromFile(filePath) {
|
|
94
|
+
return loadMtlsCredentialsFile(filePath);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Load the full mtls-credentials.json and return the parsed object (includes project_id, secret, etc.).
|
|
98
|
+
*/
|
|
99
|
+
export function loadMtlsCredentialsFileFull(filePath) {
|
|
100
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
101
|
+
const data = JSON.parse(raw);
|
|
102
|
+
const creds = parseAndNormalizeMtlsFile(filePath, data);
|
|
103
|
+
return { ...data, ...creds };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Generic helper to add an application‑level timeout to a Wafio operation.
|
|
107
|
+
* Does not change the internal client timeout; only wraps the promise with an extra time limit.
|
|
108
|
+
*
|
|
109
|
+
* @param p - Wafio operation promise (e.g. pool.analyze()).
|
|
110
|
+
* @param ms - Timeout in milliseconds. 0/negative = no extra timeout.
|
|
111
|
+
* @param onTimeout - Optional callback invoked before rejecting when the timeout elapses.
|
|
112
|
+
* Useful for logging/metrics or building a custom error.
|
|
113
|
+
*/
|
|
114
|
+
export function withWafioTimeout(p, ms, onTimeout) {
|
|
115
|
+
if (!ms || ms <= 0)
|
|
116
|
+
return p;
|
|
117
|
+
return Promise.race([
|
|
118
|
+
p,
|
|
119
|
+
new Promise((_, reject) => setTimeout(() => {
|
|
120
|
+
if (onTimeout)
|
|
121
|
+
onTimeout();
|
|
122
|
+
reject(new Error("Wafio timeout"));
|
|
123
|
+
}, ms)),
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Resolve credentials: if string, load from file; otherwise use object and normalize PEMs.
|
|
128
|
+
*/
|
|
129
|
+
function parseTcpEndpoint(raw) {
|
|
130
|
+
if (!raw || typeof raw !== "string")
|
|
131
|
+
return null;
|
|
132
|
+
let endpoint = raw.trim();
|
|
133
|
+
if (!endpoint)
|
|
134
|
+
return null;
|
|
135
|
+
endpoint = endpoint
|
|
136
|
+
.replace(/^tls:\/\//i, "")
|
|
137
|
+
.replace(/^tcp:\/\//i, "")
|
|
138
|
+
.replace(/^https?:\/\//i, "");
|
|
139
|
+
const slashIndex = endpoint.indexOf("/");
|
|
140
|
+
if (slashIndex >= 0)
|
|
141
|
+
endpoint = endpoint.slice(0, slashIndex);
|
|
142
|
+
if (endpoint.startsWith(":"))
|
|
143
|
+
endpoint = `localhost${endpoint}`;
|
|
144
|
+
try {
|
|
145
|
+
const url = new URL(`tcp://${endpoint}`);
|
|
146
|
+
const host = url.hostname || "localhost";
|
|
147
|
+
const port = Number(url.port || "9089");
|
|
148
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
149
|
+
return null;
|
|
150
|
+
return { host, port };
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function resolveCredentials(credentials) {
|
|
157
|
+
let cred;
|
|
158
|
+
if (typeof credentials === "string") {
|
|
159
|
+
cred = loadMtlsCredentialsFileFull(credentials);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
cred = {
|
|
163
|
+
client_cert_pem: normalizePem(credentials.client_cert_pem),
|
|
164
|
+
client_key_pem: normalizePem(credentials.client_key_pem),
|
|
165
|
+
ca_pem: credentials.ca_pem ? normalizePem(credentials.ca_pem) : undefined,
|
|
166
|
+
tcp_url: credentials.tcp_url,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (!cred.ca_pem) {
|
|
170
|
+
throw new Error("CA PEM is required (credentials object must include ca_pem)");
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
cert: cred.client_cert_pem,
|
|
174
|
+
key: cred.client_key_pem,
|
|
175
|
+
ca: cred.ca_pem,
|
|
176
|
+
tcpUrl: cred.tcp_url,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Normalize headers to Record<string, string[]>. Server expects array per header. */
|
|
180
|
+
export function normalizeHeaders(headers) {
|
|
181
|
+
if (!headers || typeof headers !== "object")
|
|
182
|
+
return {};
|
|
183
|
+
const out = {};
|
|
184
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
185
|
+
if (v === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
out[k] = Array.isArray(v) ? v : [v];
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** Ambil nilai pertama dari header (case-insensitive). */
|
|
192
|
+
function getFirstHeader(headers, name) {
|
|
193
|
+
if (!headers)
|
|
194
|
+
return "";
|
|
195
|
+
const lower = name.toLowerCase();
|
|
196
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
197
|
+
if (k.toLowerCase() === lower && v !== undefined) {
|
|
198
|
+
const s = Array.isArray(v) ? v[0] : v;
|
|
199
|
+
return typeof s === "string" ? s.trim() : "";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return "";
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Resolve client IP from a snapshot: prefer proxy headers (X-Forwarded-For, X-Real-IP, Forwarded) then fall back to remoteAddress.
|
|
206
|
+
* When running behind a proxy, ensure it sets X-Forwarded-For or X-Real-IP; the leftmost value is treated as the original client.
|
|
207
|
+
*/
|
|
208
|
+
export function resolveClientIp(snapshot) {
|
|
209
|
+
const headers = snapshot.headers;
|
|
210
|
+
const xff = getFirstHeader(headers, "x-forwarded-for");
|
|
211
|
+
if (xff) {
|
|
212
|
+
const first = xff.split(",")[0]?.trim() ?? "";
|
|
213
|
+
if (first)
|
|
214
|
+
return first;
|
|
215
|
+
}
|
|
216
|
+
const xri = getFirstHeader(headers, "x-real-ip");
|
|
217
|
+
if (xri)
|
|
218
|
+
return xri;
|
|
219
|
+
const forwarded = getFirstHeader(headers, "forwarded");
|
|
220
|
+
if (forwarded) {
|
|
221
|
+
const forMatch = /for\s*=\s*["']?([^"',;\s]+)/i.exec(forwarded);
|
|
222
|
+
if (forMatch?.[1])
|
|
223
|
+
return forMatch[1].trim();
|
|
224
|
+
}
|
|
225
|
+
return snapshot.remoteAddress ?? "127.0.0.1";
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Build an AnalyzeRequest from a RequestSnapshot (framework‑agnostic).
|
|
229
|
+
* Client IP is resolved from proxy headers (X-Forwarded-For, X-Real-IP) when present.
|
|
230
|
+
*/
|
|
231
|
+
export function buildAnalyzeRequest(snapshot) {
|
|
232
|
+
const headers = normalizeHeaders(snapshot.headers);
|
|
233
|
+
const remote_addr = resolveClientIp(snapshot);
|
|
234
|
+
const user_agent = snapshot.userAgent ?? getFirstHeader(snapshot.headers, "user-agent");
|
|
235
|
+
return {
|
|
236
|
+
method: snapshot.method || "GET",
|
|
237
|
+
uri: snapshot.url || "/",
|
|
238
|
+
remote_addr,
|
|
239
|
+
host: snapshot.host ?? "",
|
|
240
|
+
headers,
|
|
241
|
+
body: snapshot.body ?? "",
|
|
242
|
+
user_agent,
|
|
243
|
+
request_id: snapshot.requestId ?? "",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Convert an Express/Fastify/Hono‑style request (object with method, url, headers, body, ip, get, ...) into a RequestSnapshot.
|
|
248
|
+
* There is no hard dependency on Express; any framework with a similar shape can be used.
|
|
249
|
+
*/
|
|
250
|
+
export function fromRequest(req) {
|
|
251
|
+
const url = req.originalUrl ?? req.url ?? "";
|
|
252
|
+
const headers = req.headers;
|
|
253
|
+
const body = req.body !== undefined
|
|
254
|
+
? (typeof req.body === "string" ? req.body : JSON.stringify(req.body))
|
|
255
|
+
: undefined;
|
|
256
|
+
const remoteAddress = req.ip ?? req.socket?.remoteAddress;
|
|
257
|
+
const host = req.hostname ?? (typeof req.get === "function" ? req.get?.("host") : undefined);
|
|
258
|
+
return {
|
|
259
|
+
method: req.method ?? "GET",
|
|
260
|
+
url,
|
|
261
|
+
headers,
|
|
262
|
+
body,
|
|
263
|
+
remoteAddress,
|
|
264
|
+
host,
|
|
265
|
+
requestId: typeof req.get === "function" ? req.get?.("x-request-id") : undefined,
|
|
266
|
+
userAgent: typeof req.get === "function" ? req.get?.("user-agent") : undefined,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Wafio TCP mTLS client.
|
|
271
|
+
* Connect once, then call analyze() / checkBlock() as needed. Call close() when done.
|
|
272
|
+
*/
|
|
273
|
+
export class WafioClient {
|
|
274
|
+
socket = null;
|
|
275
|
+
host;
|
|
276
|
+
port;
|
|
277
|
+
cert;
|
|
278
|
+
key;
|
|
279
|
+
ca;
|
|
280
|
+
maxPayloadSize;
|
|
281
|
+
failOpenOnUnreachable;
|
|
282
|
+
requestTimeoutMs;
|
|
283
|
+
reconnectCooldownMs;
|
|
284
|
+
connectTimeoutMs;
|
|
285
|
+
onRequestTimeout;
|
|
286
|
+
logRequestTimeout;
|
|
287
|
+
keepaliveIntervalMs;
|
|
288
|
+
failOpenCooldownMs;
|
|
289
|
+
_unreachable = false;
|
|
290
|
+
lastUnreachableTime = 0;
|
|
291
|
+
failOpenUntil = 0;
|
|
292
|
+
socketBuffer = new Map();
|
|
293
|
+
keepaliveTimerId = null;
|
|
294
|
+
inFlightRequests = 0;
|
|
295
|
+
pingInProgress = false;
|
|
296
|
+
constructor(options) {
|
|
297
|
+
const resolved = resolveCredentials(options.credentials);
|
|
298
|
+
const endpoint = parseTcpEndpoint(resolved.tcpUrl);
|
|
299
|
+
this.host = options.host ?? endpoint?.host ?? "localhost";
|
|
300
|
+
this.port = options.port ?? endpoint?.port ?? 9089;
|
|
301
|
+
this.maxPayloadSize = options.maxPayloadSize ?? 0;
|
|
302
|
+
this.failOpenOnUnreachable = options.failOpenOnUnreachable ?? true;
|
|
303
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 300;
|
|
304
|
+
this.reconnectCooldownMs = options.reconnectCooldownMs ?? 2000;
|
|
305
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? 2000;
|
|
306
|
+
this.onRequestTimeout = options.onRequestTimeout;
|
|
307
|
+
this.logRequestTimeout = options.logRequestTimeout ?? false;
|
|
308
|
+
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? 25000;
|
|
309
|
+
this.failOpenCooldownMs = options.failOpenCooldownMs ?? 5000;
|
|
310
|
+
this.cert = resolved.cert;
|
|
311
|
+
this.key = resolved.key;
|
|
312
|
+
this.ca = resolved.ca;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Connect to the Wafio server (mTLS). Must be called before analyze/checkBlock.
|
|
316
|
+
* On failure (e.g. ECONNREFUSED), rejects with a single Error with .code set so caller can do fail-open.
|
|
317
|
+
*/
|
|
318
|
+
connect() {
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
if (this.socket) {
|
|
321
|
+
resolve();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this._unreachable = false;
|
|
325
|
+
const socket = tls.connect({
|
|
326
|
+
host: this.host,
|
|
327
|
+
port: this.port,
|
|
328
|
+
cert: this.cert,
|
|
329
|
+
key: this.key,
|
|
330
|
+
ca: this.ca,
|
|
331
|
+
rejectUnauthorized: true,
|
|
332
|
+
}, () => {
|
|
333
|
+
this.socket = socket;
|
|
334
|
+
socket.setMaxListeners(2048);
|
|
335
|
+
this.startKeepalive();
|
|
336
|
+
resolve();
|
|
337
|
+
});
|
|
338
|
+
socket.on("error", (err) => {
|
|
339
|
+
const code = getConnectErrorCode(err);
|
|
340
|
+
if (code && this.failOpenOnUnreachable) {
|
|
341
|
+
this._unreachable = true;
|
|
342
|
+
resolve();
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
reject(normalizeConnectError(err, this.host, this.port));
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** Whether the client is connected. */
|
|
351
|
+
get connected() {
|
|
352
|
+
return this.socket != null && !this.socket.destroyed;
|
|
353
|
+
}
|
|
354
|
+
startKeepalive() {
|
|
355
|
+
if (this.keepaliveIntervalMs <= 0 || this.keepaliveTimerId != null)
|
|
356
|
+
return;
|
|
357
|
+
this.keepaliveTimerId = setInterval(() => this.tickKeepalive(), this.keepaliveIntervalMs);
|
|
358
|
+
}
|
|
359
|
+
stopKeepalive() {
|
|
360
|
+
if (this.keepaliveTimerId != null) {
|
|
361
|
+
clearInterval(this.keepaliveTimerId);
|
|
362
|
+
this.keepaliveTimerId = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
destroySocket() {
|
|
366
|
+
if (this.socket) {
|
|
367
|
+
this.socket.destroy();
|
|
368
|
+
this.socketBuffer.delete(this.socket);
|
|
369
|
+
this.socket = null;
|
|
370
|
+
}
|
|
371
|
+
this.lastUnreachableTime = Date.now();
|
|
372
|
+
}
|
|
373
|
+
async doKeepalivePing() {
|
|
374
|
+
const sock = this.socket;
|
|
375
|
+
if (!sock)
|
|
376
|
+
return;
|
|
377
|
+
const p = this.sendFrame(TYPE_CHECK_BLOCK_REQ, { key: "__keepalive__" });
|
|
378
|
+
const timeoutMs = 2000;
|
|
379
|
+
await Promise.race([
|
|
380
|
+
p,
|
|
381
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("keepalive timeout")), timeoutMs)),
|
|
382
|
+
]);
|
|
383
|
+
}
|
|
384
|
+
tickKeepalive() {
|
|
385
|
+
if (!this.socket || this.socket.destroyed || this.inFlightRequests > 0 || this.pingInProgress)
|
|
386
|
+
return;
|
|
387
|
+
this.pingInProgress = true;
|
|
388
|
+
this.doKeepalivePing()
|
|
389
|
+
.then(() => {
|
|
390
|
+
this.pingInProgress = false;
|
|
391
|
+
})
|
|
392
|
+
.catch(() => {
|
|
393
|
+
this.destroySocket();
|
|
394
|
+
this.stopKeepalive();
|
|
395
|
+
this.pingInProgress = false;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
/** Wrap promise in timeout; on timeout reject dulu (return allow cepat), cleanup (destroy, log) jalan async. */
|
|
399
|
+
withRequestTimeout(p) {
|
|
400
|
+
if (this.requestTimeoutMs <= 0)
|
|
401
|
+
return p;
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const t = setTimeout(() => {
|
|
404
|
+
const err = new Error("Wafio request timeout");
|
|
405
|
+
err.code = "ETIMEDOUT";
|
|
406
|
+
reject(err);
|
|
407
|
+
setImmediate(() => {
|
|
408
|
+
this.stopKeepalive();
|
|
409
|
+
this.destroySocket();
|
|
410
|
+
const timeoutMs = this.requestTimeoutMs;
|
|
411
|
+
if (this.onRequestTimeout) {
|
|
412
|
+
this.onRequestTimeout({ timeoutMs });
|
|
413
|
+
}
|
|
414
|
+
else if (this.logRequestTimeout) {
|
|
415
|
+
console.info(`Wafio: request timeout (${timeoutMs}ms), request allowed (fail-open)`);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}, this.requestTimeoutMs);
|
|
419
|
+
p.then((v) => {
|
|
420
|
+
clearTimeout(t);
|
|
421
|
+
resolve(v);
|
|
422
|
+
}, (e) => {
|
|
423
|
+
clearTimeout(t);
|
|
424
|
+
reject(e);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Lazy reconnect: try to connect once when there is no active socket. Uses a cooldown to avoid hammering a down server.
|
|
430
|
+
* Returns true when connected, false when skipped (cooldown) or failed (fail‑open).
|
|
431
|
+
*/
|
|
432
|
+
async tryConnectIfNeeded() {
|
|
433
|
+
if (this.socket?.destroyed === false)
|
|
434
|
+
return true;
|
|
435
|
+
this.socket = null;
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
if (this.reconnectCooldownMs > 0 && now - this.lastUnreachableTime < this.reconnectCooldownMs) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
this._unreachable = false;
|
|
441
|
+
try {
|
|
442
|
+
await Promise.race([
|
|
443
|
+
this.connect(),
|
|
444
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("connect timeout")), this.connectTimeoutMs)),
|
|
445
|
+
]);
|
|
446
|
+
if (this.socket !== null && !this.socket.destroyed)
|
|
447
|
+
return true;
|
|
448
|
+
this.lastUnreachableTime = Date.now();
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
this.lastUnreachableTime = Date.now();
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
isTimeoutOrConnectionError(err) {
|
|
457
|
+
if (err instanceof Error) {
|
|
458
|
+
if (err.message === "Wafio request timeout" || err.message === "Wafio timeout")
|
|
459
|
+
return true;
|
|
460
|
+
const code = err.code;
|
|
461
|
+
if (typeof code === "string" && CONNECTION_ERROR_CODES.includes(code))
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Send a frame and wait for the matching response (same type | 0x80).
|
|
468
|
+
*/
|
|
469
|
+
sendFrame(type, payload) {
|
|
470
|
+
const sock = this.socket;
|
|
471
|
+
if (!sock) {
|
|
472
|
+
return Promise.reject(new Error("WafioClient: not connected. Call connect() first."));
|
|
473
|
+
}
|
|
474
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8");
|
|
475
|
+
if (this.maxPayloadSize > 0 && body.length > this.maxPayloadSize) {
|
|
476
|
+
return Promise.reject(new Error(`WafioClient: payload too large (max ${this.maxPayloadSize})`));
|
|
477
|
+
}
|
|
478
|
+
const len = Buffer.allocUnsafe(4);
|
|
479
|
+
len.writeUInt32BE(body.length, 0);
|
|
480
|
+
sock.write(Buffer.concat([Buffer.from([type]), len, body]));
|
|
481
|
+
return this.readFrame(sock);
|
|
482
|
+
}
|
|
483
|
+
readFrame(socket) {
|
|
484
|
+
return new Promise((resolve, reject) => {
|
|
485
|
+
if (!this.socketBuffer.has(socket)) {
|
|
486
|
+
this.socketBuffer.set(socket, Buffer.alloc(0));
|
|
487
|
+
}
|
|
488
|
+
const tryConsume = () => {
|
|
489
|
+
const buf = this.socketBuffer.get(socket);
|
|
490
|
+
if (buf.length < 5)
|
|
491
|
+
return false;
|
|
492
|
+
const bodyLen = buf.readUInt32BE(1);
|
|
493
|
+
if (buf.length < 5 + bodyLen)
|
|
494
|
+
return false;
|
|
495
|
+
const type = buf[0];
|
|
496
|
+
const bodyStr = buf.subarray(5, 5 + bodyLen).toString("utf8");
|
|
497
|
+
this.socketBuffer.set(socket, buf.subarray(5 + bodyLen));
|
|
498
|
+
try {
|
|
499
|
+
resolve({ type, body: JSON.parse(bodyStr) });
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
resolve({ type, body: { error: "invalid json" } });
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
};
|
|
506
|
+
if (tryConsume())
|
|
507
|
+
return;
|
|
508
|
+
const onData = (chunk) => {
|
|
509
|
+
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
|
|
510
|
+
this.socketBuffer.set(socket, Buffer.concat([this.socketBuffer.get(socket), part]));
|
|
511
|
+
if (tryConsume()) {
|
|
512
|
+
socket.off("data", onData);
|
|
513
|
+
socket.off("error", onError);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
const onError = (err) => {
|
|
517
|
+
socket.off("data", onData);
|
|
518
|
+
reject(err);
|
|
519
|
+
};
|
|
520
|
+
socket.on("data", onData);
|
|
521
|
+
socket.on("error", onError);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Request tier limits from the server (max_tcp_connections). Use once after connect() to cap pool size.
|
|
526
|
+
* Returns undefined on error or if server does not support tier limits.
|
|
527
|
+
*/
|
|
528
|
+
async getTierLimits() {
|
|
529
|
+
const sock = this.socket;
|
|
530
|
+
if (!sock)
|
|
531
|
+
return undefined;
|
|
532
|
+
try {
|
|
533
|
+
const { type, body } = await this.sendFrame(TYPE_TIER_LIMITS_REQ, {});
|
|
534
|
+
if (type !== TYPE_TIER_LIMITS_RESP || typeof body !== "object" || body === null)
|
|
535
|
+
return undefined;
|
|
536
|
+
const max = body.max_tcp_connections;
|
|
537
|
+
return typeof max === "number" && max >= 0 ? max : undefined;
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Analyze a request. Returns allow/block and metadata.
|
|
545
|
+
* Uses lazy reconnect: when there is no socket, it tries to connect once (with cooldown). On timeout/unreachable
|
|
546
|
+
* it returns allow when failOpenOnUnreachable is enabled.
|
|
547
|
+
*/
|
|
548
|
+
async analyze(req) {
|
|
549
|
+
if (this.failOpenOnUnreachable && this.failOpenUntil > 0 && Date.now() < this.failOpenUntil) {
|
|
550
|
+
return { action: "allow" };
|
|
551
|
+
}
|
|
552
|
+
if (!this.socket) {
|
|
553
|
+
const ok = await this.tryConnectIfNeeded();
|
|
554
|
+
if (!ok && this.failOpenOnUnreachable)
|
|
555
|
+
return { action: "allow" };
|
|
556
|
+
if (!this.socket)
|
|
557
|
+
return { action: "allow" };
|
|
558
|
+
}
|
|
559
|
+
this.inFlightRequests++;
|
|
560
|
+
try {
|
|
561
|
+
const payload = {
|
|
562
|
+
method: req.method,
|
|
563
|
+
uri: req.uri,
|
|
564
|
+
remote_addr: req.remote_addr,
|
|
565
|
+
host: req.host ?? "",
|
|
566
|
+
headers: req.headers ?? {},
|
|
567
|
+
body: req.body ?? "",
|
|
568
|
+
body_b64: req.body_b64 ?? "",
|
|
569
|
+
user_agent: req.user_agent ?? "",
|
|
570
|
+
request_id: req.request_id ?? "",
|
|
571
|
+
};
|
|
572
|
+
if (req.body_size != null && req.body_size > 0) {
|
|
573
|
+
payload.body_size = req.body_size;
|
|
574
|
+
}
|
|
575
|
+
const sendPromise = this.sendFrame(TYPE_ANALYZE_REQ, payload);
|
|
576
|
+
const { type, body } = await (this.requestTimeoutMs > 0 ? this.withRequestTimeout(sendPromise) : sendPromise);
|
|
577
|
+
if (type !== TYPE_ANALYZE_RESP) {
|
|
578
|
+
return { action: "allow", error: "unexpected response type" };
|
|
579
|
+
}
|
|
580
|
+
const resp = body;
|
|
581
|
+
// Treat server-side concurrency/rate shaping as soft fail-open for the app.
|
|
582
|
+
if (resp.code === "RATE_LIMIT" || resp.code === "CONCURRENCY_LIMIT") {
|
|
583
|
+
return { action: "allow", error: resp.error, message: resp.message, categories: resp.categories };
|
|
584
|
+
}
|
|
585
|
+
return resp;
|
|
586
|
+
}
|
|
587
|
+
catch (err) {
|
|
588
|
+
if (this.failOpenOnUnreachable && this.isTimeoutOrConnectionError(err)) {
|
|
589
|
+
if (this.failOpenCooldownMs > 0) {
|
|
590
|
+
this.failOpenUntil = Date.now() + this.failOpenCooldownMs;
|
|
591
|
+
}
|
|
592
|
+
return { action: "allow" };
|
|
593
|
+
}
|
|
594
|
+
throw err;
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
this.inFlightRequests--;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Check if a key is currently in the block window.
|
|
602
|
+
* Uses lazy reconnect when there is no socket; on timeout/unreachable it returns not blocked when
|
|
603
|
+
* failOpenOnUnreachable is enabled.
|
|
604
|
+
*/
|
|
605
|
+
async checkBlock(key) {
|
|
606
|
+
if (this.failOpenOnUnreachable && this.failOpenUntil > 0 && Date.now() < this.failOpenUntil) {
|
|
607
|
+
return { blocked: false };
|
|
608
|
+
}
|
|
609
|
+
if (!this.socket) {
|
|
610
|
+
const ok = await this.tryConnectIfNeeded();
|
|
611
|
+
if (!ok && this.failOpenOnUnreachable)
|
|
612
|
+
return { blocked: false };
|
|
613
|
+
if (!this.socket)
|
|
614
|
+
return { blocked: false };
|
|
615
|
+
}
|
|
616
|
+
this.inFlightRequests++;
|
|
617
|
+
try {
|
|
618
|
+
const sendPromise = this.sendFrame(TYPE_CHECK_BLOCK_REQ, { key: key || "unknown" });
|
|
619
|
+
const { type, body } = await (this.requestTimeoutMs > 0 ? this.withRequestTimeout(sendPromise) : sendPromise);
|
|
620
|
+
if (type !== TYPE_CHECK_BLOCK_RESP) {
|
|
621
|
+
return { blocked: false, error: "unexpected response type" };
|
|
622
|
+
}
|
|
623
|
+
const resp = body;
|
|
624
|
+
if (resp.code === "RATE_LIMIT" || resp.code === "CONCURRENCY_LIMIT") {
|
|
625
|
+
return { blocked: false, error: resp.error };
|
|
626
|
+
}
|
|
627
|
+
return resp;
|
|
628
|
+
}
|
|
629
|
+
catch (err) {
|
|
630
|
+
if (this.failOpenOnUnreachable && this.isTimeoutOrConnectionError(err)) {
|
|
631
|
+
if (this.failOpenCooldownMs > 0) {
|
|
632
|
+
this.failOpenUntil = Date.now() + this.failOpenCooldownMs;
|
|
633
|
+
}
|
|
634
|
+
return { blocked: false };
|
|
635
|
+
}
|
|
636
|
+
throw err;
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
this.inFlightRequests--;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Close the connection.
|
|
644
|
+
*/
|
|
645
|
+
close() {
|
|
646
|
+
this.stopKeepalive();
|
|
647
|
+
if (this.socket) {
|
|
648
|
+
this.socket.destroy();
|
|
649
|
+
this.socketBuffer.delete(this.socket);
|
|
650
|
+
this.socket = null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Simple connection pool for WafioClient.
|
|
656
|
+
* On first init(), opens one connection to fetch tier limits (max_tcp_connections) and caps pool size to that;
|
|
657
|
+
* then creates up to that many connections. If the fetch fails, uses the configured pool size.
|
|
658
|
+
*/
|
|
659
|
+
export class WafioClientPool {
|
|
660
|
+
options;
|
|
661
|
+
/** Configured pool size (from options). */
|
|
662
|
+
configuredPoolSize;
|
|
663
|
+
/** Effective pool size (capped by tier limit after first init). */
|
|
664
|
+
effectivePoolSize;
|
|
665
|
+
clients = [];
|
|
666
|
+
available = [];
|
|
667
|
+
initPromise = null;
|
|
668
|
+
constructor(options) {
|
|
669
|
+
const { poolSize, ...clientOpts } = options;
|
|
670
|
+
this.options = clientOpts;
|
|
671
|
+
this.configuredPoolSize = Math.max(1, poolSize ?? 5);
|
|
672
|
+
this.effectivePoolSize = this.configuredPoolSize;
|
|
673
|
+
}
|
|
674
|
+
/** Initialize the pool: fetch tier limits once, cap size, then create WafioClient instances (lazy connection). Idempotent. */
|
|
675
|
+
async init() {
|
|
676
|
+
if (this.clients.length >= this.effectivePoolSize)
|
|
677
|
+
return;
|
|
678
|
+
if (this.initPromise)
|
|
679
|
+
return this.initPromise;
|
|
680
|
+
this.initPromise = (async () => {
|
|
681
|
+
// One connection just to get tier limits; does not count toward project connection limit.
|
|
682
|
+
const tempClient = new WafioClient(this.options);
|
|
683
|
+
let maxConns = null;
|
|
684
|
+
let probeErr = null;
|
|
685
|
+
try {
|
|
686
|
+
await tempClient.connect();
|
|
687
|
+
maxConns = (await tempClient.getTierLimits()) ?? null;
|
|
688
|
+
if (typeof maxConns === "number" && maxConns > 0) {
|
|
689
|
+
this.effectivePoolSize = Math.min(this.configuredPoolSize, maxConns);
|
|
690
|
+
if (this.effectivePoolSize < this.configuredPoolSize) {
|
|
691
|
+
console.error(`[wafio-client] Pool size capped by server tier limit: configured=${this.configuredPoolSize}, effective=${this.effectivePoolSize} (server max_tcp_connections=${maxConns})`);
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
console.error(`[wafio-client] Pool size initialized: configured=${this.configuredPoolSize}, effective=${this.effectivePoolSize} (server max_tcp_connections=${maxConns} allows full size)`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
console.error(`[wafio-client] Pool size probe failed or server returned 0, using configured size: ${this.configuredPoolSize} (maxConns=${maxConns})`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
probeErr = err instanceof Error ? err : new Error(String(err));
|
|
703
|
+
console.error(`[wafio-client] Pool size probe failed, using configured size: ${this.configuredPoolSize} (err=${probeErr.message})`);
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
tempClient.close();
|
|
707
|
+
}
|
|
708
|
+
// Create effectivePoolSize clients (lazy connection: connection established per request by analyze/checkBlock).
|
|
709
|
+
for (let i = 0; i < this.effectivePoolSize; i++) {
|
|
710
|
+
const client = new WafioClient(this.options);
|
|
711
|
+
this.clients.push(client);
|
|
712
|
+
this.available.push(client);
|
|
713
|
+
}
|
|
714
|
+
})();
|
|
715
|
+
return this.initPromise;
|
|
716
|
+
}
|
|
717
|
+
/** Get a client from the pool (waits if all are in use). */
|
|
718
|
+
async getClient() {
|
|
719
|
+
if (this.available.length > 0) {
|
|
720
|
+
return this.available.pop();
|
|
721
|
+
}
|
|
722
|
+
return new Promise((resolve) => {
|
|
723
|
+
const tryGet = () => {
|
|
724
|
+
if (this.available.length > 0) {
|
|
725
|
+
resolve(this.available.pop());
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
setImmediate(tryGet);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
setImmediate(tryGet);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
/** Return a client to the pool (including disconnected ones; the client will lazy‑reconnect on next use). */
|
|
735
|
+
releaseClient(client) {
|
|
736
|
+
this.available.push(client);
|
|
737
|
+
}
|
|
738
|
+
/** Generic helper: run a function with a single client from the pool. */
|
|
739
|
+
async withClient(fn) {
|
|
740
|
+
await this.init();
|
|
741
|
+
const client = await this.getClient();
|
|
742
|
+
try {
|
|
743
|
+
return await fn(client);
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
this.releaseClient(client);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
/** Call analyze() using a client from the pool. */
|
|
750
|
+
analyze(req) {
|
|
751
|
+
return this.withClient((c) => c.analyze(req));
|
|
752
|
+
}
|
|
753
|
+
/** Call checkBlock() using a client from the pool. */
|
|
754
|
+
checkBlock(key) {
|
|
755
|
+
return this.withClient((c) => c.checkBlock(key));
|
|
756
|
+
}
|
|
757
|
+
/** Close all connections in the pool. */
|
|
758
|
+
close() {
|
|
759
|
+
for (const c of this.clients) {
|
|
760
|
+
c.close();
|
|
761
|
+
}
|
|
762
|
+
this.clients.splice(0, this.clients.length);
|
|
763
|
+
this.available.splice(0, this.available.length);
|
|
764
|
+
this.initPromise = null;
|
|
765
|
+
}
|
|
766
|
+
}
|