securenow 7.6.6 → 7.6.7

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.
Files changed (2) hide show
  1. package/firewall.js +88 -57
  2. package/package.json +1 -1
package/firewall.js CHANGED
@@ -45,12 +45,15 @@ let _circuitOpenedAt = 0;
45
45
  let _pollInflight = false;
46
46
  let _retryAfterUntil = 0;
47
47
 
48
- // Keep-alive agents reuse TCP connections across polls (TLS handshake once)
49
- const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
50
- const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
48
+ // Firewall control-plane traffic is low-frequency and correctness-critical.
49
+ // Use non-keep-alive agents so clustered apps do not reuse sockets that an
50
+ // upstream load balancer has silently closed between sync cycles.
51
+ const _httpAgent = new http.Agent({ keepAlive: false });
52
+ const _httpsAgent = new https.Agent({ keepAlive: false });
51
53
  const EVENT_FLUSH_INTERVAL_MS = 2_000;
52
54
  const EVENT_BATCH_SIZE = 25;
53
55
  const EVENT_QUEUE_MAX = 1_000;
56
+ const TRANSIENT_NETWORK_CODES = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'EAI_AGAIN']);
54
57
 
55
58
  // Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
56
59
  let _useUnifiedSync = true;
@@ -119,67 +122,95 @@ function jitter(baseMs) {
119
122
  return baseMs * (0.8 + Math.random() * 0.4);
120
123
  }
121
124
 
122
- function agentFor(url) {
123
- return url.startsWith('https') ? _httpsAgent : _httpAgent;
124
- }
125
-
126
- function httpGet(url, extraHeaders, timeout, callback) {
127
- const mod = url.startsWith('https') ? https : http;
128
- const parsed = new URL(url);
129
-
130
- const req = mod.request({
131
- hostname: parsed.hostname,
132
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
133
- path: parsed.pathname + parsed.search,
134
- method: 'GET',
135
- headers: {
136
- 'Authorization': `Bearer ${_options.apiKey}`,
137
- 'User-Agent': 'securenow-firewall-sdk',
138
- ...extraHeaders,
139
- },
140
- timeout,
141
- agent: agentFor(url),
142
- }, (res) => {
143
- let data = '';
144
- res.on('data', (chunk) => { data += chunk; });
145
- res.on('end', () => { callback(null, res, data); });
146
- });
147
-
148
- req.on('error', (err) => callback(err));
149
- req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
150
- req.end();
125
+ function agentFor(url) {
126
+ return url.startsWith('https') ? _httpsAgent : _httpAgent;
151
127
  }
152
128
 
153
- function httpPostJson(url, body, timeout, callback) {
129
+ function isTransientNetworkError(err) {
130
+ if (!err) return false;
131
+ if (err.code && TRANSIENT_NETWORK_CODES.has(err.code)) return true;
132
+ return /socket hang up|connection reset|ECONNRESET/i.test(String(err.message || ''));
133
+ }
134
+
135
+ function formatRequestError(err) {
136
+ if (!err) return 'unknown error';
137
+ const parts = [err.message || String(err)];
138
+ if (err.code && !parts[0].includes(err.code)) parts.push(`code=${err.code}`);
139
+ if (err.syscall) parts.push(`syscall=${err.syscall}`);
140
+ return parts.join(' ');
141
+ }
142
+
143
+ function requestOnce(method, url, body, extraHeaders, timeout, callback) {
154
144
  const mod = url.startsWith('https') ? https : http;
155
145
  const parsed = new URL(url);
156
- const payload = JSON.stringify(body || {});
146
+ const payload = body == null ? null : JSON.stringify(body || {});
157
147
 
158
148
  const req = mod.request({
159
149
  hostname: parsed.hostname,
160
150
  port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
161
151
  path: parsed.pathname + parsed.search,
162
- method: 'POST',
152
+ method,
163
153
  headers: {
164
154
  'Authorization': `Bearer ${_options.apiKey}`,
165
155
  'User-Agent': 'securenow-firewall-sdk',
166
- 'Content-Type': 'application/json',
167
- 'Content-Length': Buffer.byteLength(payload),
156
+ 'Connection': 'close',
157
+ ...(payload
158
+ ? {
159
+ 'Content-Type': 'application/json',
160
+ 'Content-Length': Buffer.byteLength(payload),
161
+ }
162
+ : {}),
163
+ ...extraHeaders,
168
164
  },
169
165
  timeout,
170
166
  agent: agentFor(url),
171
167
  }, (res) => {
172
168
  let data = '';
173
169
  res.on('data', (chunk) => { data += chunk; });
174
- res.on('end', () => { callback(null, res, data); });
170
+ res.on('end', () => { done(null, res, data); });
175
171
  });
176
172
 
177
- req.on('error', (err) => callback(err));
178
- req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
179
- req.write(payload);
173
+ let finished = false;
174
+ function done(...args) {
175
+ if (finished) return;
176
+ finished = true;
177
+ callback(...args);
178
+ }
179
+
180
+ req.on('error', (err) => done(err));
181
+ req.on('timeout', () => {
182
+ const err = new Error('Request timed out');
183
+ err.code = 'ETIMEDOUT';
184
+ done(err);
185
+ req.destroy(err);
186
+ });
187
+ if (payload) req.write(payload);
180
188
  req.end();
181
189
  }
182
190
 
191
+ function requestWithRetry(method, url, body, extraHeaders, timeout, callback) {
192
+ requestOnce(method, url, body, extraHeaders, timeout, (err, res, data) => {
193
+ if (!err || !isTransientNetworkError(err)) return callback(err, res, data);
194
+
195
+ if (_options && _options.log) {
196
+ console.warn('[securenow] Firewall: transient API socket error, retrying once:', formatRequestError(err));
197
+ }
198
+
199
+ const retryTimer = setTimeout(() => {
200
+ requestOnce(method, url, body, extraHeaders, timeout, callback);
201
+ }, 100);
202
+ if (retryTimer.unref) retryTimer.unref();
203
+ });
204
+ }
205
+
206
+ function httpGet(url, extraHeaders, timeout, callback) {
207
+ requestWithRetry('GET', url, null, extraHeaders, timeout, callback);
208
+ }
209
+
210
+ function httpPostJson(url, body, timeout, callback) {
211
+ requestWithRetry('POST', url, body, {}, timeout, callback);
212
+ }
213
+
183
214
  function scheduleEventFlush() {
184
215
  if (_eventTimer || _eventQueue.length === 0) return;
185
216
  _eventTimer = setTimeout(() => {
@@ -202,7 +233,7 @@ function flushFirewallEvents() {
202
233
  httpPostJson(url, { events: batch }, 5000, (err, res) => {
203
234
  if (err || !res || res.statusCode >= 400) {
204
235
  if (_options.log) {
205
- const msg = err ? err.message : `API returned ${res.statusCode}`;
236
+ const msg = err ? formatRequestError(err) : `API returned ${res.statusCode}`;
206
237
  console.warn('[securenow] Firewall: failed to report blocked-request ledger:', msg);
207
238
  }
208
239
  }
@@ -448,19 +479,19 @@ function doLegacyPoll(callback) {
448
479
  callback(null, { blChanged, alChanged });
449
480
  }
450
481
 
451
- if (blChanged) {
452
- legacyBlocklistSync((err, changed, stats) => {
453
- if (err && _options.log) console.warn('[securenow] Firewall: sync failed (using stale list):', err.message);
454
- else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
455
- syncDone();
456
- });
457
- }
458
- if (alChanged) {
459
- legacyAllowlistSync((err, changed, stats) => {
460
- if (err && _options.log) console.warn('[securenow] Firewall: allowlist sync failed:', err.message);
461
- else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
462
- syncDone();
463
- });
482
+ if (blChanged) {
483
+ legacyBlocklistSync((err, changed, stats) => {
484
+ if (err && _options.log) console.warn('[securenow] Firewall: sync failed (using stale list):', formatRequestError(err));
485
+ else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
486
+ syncDone();
487
+ });
488
+ }
489
+ if (alChanged) {
490
+ legacyAllowlistSync((err, changed, stats) => {
491
+ if (err && _options.log) console.warn('[securenow] Firewall: allowlist sync failed:', formatRequestError(err));
492
+ else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
493
+ syncDone();
494
+ });
464
495
  }
465
496
  }
466
497
 
@@ -495,7 +526,7 @@ function pollOnce(callback) {
495
526
  _consecutiveErrors++;
496
527
  _stats.errors++;
497
528
  maybeOpenCircuit();
498
- if (_options.log) console.warn('[securenow] Firewall: poll failed:', err.message);
529
+ if (_options.log) console.warn('[securenow] Firewall: poll failed:', formatRequestError(err));
499
530
  return callback(err);
500
531
  }
501
532
  _consecutiveErrors = 0;
@@ -565,7 +596,7 @@ function startSyncLoop() {
565
596
  if (retryTimer.unref) retryTimer.unref();
566
597
  return;
567
598
  }
568
- if (_options.log) console.warn('[securenow] Firewall: initial sync failed:', err.message);
599
+ if (_options.log) console.warn('[securenow] Firewall: initial sync failed:', formatRequestError(err));
569
600
  if (_options.failMode === 'closed') {
570
601
  _matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
571
602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "securenow",
3
- "version": "7.6.6",
3
+ "version": "7.6.7",
4
4
  "description": "OpenTelemetry instrumentation for Node.js, Next.js, and Nuxt - Send traces and logs to any OTLP-compatible backend",
5
5
  "type": "commonjs",
6
6
  "main": "register.js",