securenow 5.16.3 → 5.17.1
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/SKILL-API.md +3 -0
- package/cli/config.js +9 -3
- package/cli/monitor.js +54 -9
- package/cli/ui.js +31 -3
- package/cli.js +2 -2
- package/docs/FIREWALL-GUIDE.md +15 -0
- package/firewall.js +318 -285
- package/package.json +1 -1
package/SKILL-API.md
CHANGED
|
@@ -460,6 +460,7 @@ Add custom fields via `SECURENOW_SENSITIVE_FIELDS=field1,field2`.
|
|
|
460
460
|
| `SECURENOW_API_KEY` | API key (`snk_live_...`); activates firewall when set | — |
|
|
461
461
|
| `SECURENOW_API_URL` | SecureNow API base URL | `https://api.securenow.ai` |
|
|
462
462
|
| `SECURENOW_FIREWALL_ENABLED` | Master kill-switch (`0` to disable) | `1` |
|
|
463
|
+
| `SECURENOW_FIREWALL_VERSION_INTERVAL` | Seconds between lightweight version checks | `10` |
|
|
463
464
|
| `SECURENOW_FIREWALL_SYNC_INTERVAL` | Full blocklist refresh interval in seconds | `300` |
|
|
464
465
|
| `SECURENOW_FIREWALL_FAIL_MODE` | `open` (allow all when unavailable) or `closed` | `open` |
|
|
465
466
|
| `SECURENOW_FIREWALL_STATUS_CODE` | HTTP status for blocked requests | `403` |
|
|
@@ -470,6 +471,8 @@ Add custom fields via `SECURENOW_SENSITIVE_FIELDS=field1,field2`.
|
|
|
470
471
|
| `SECURENOW_FIREWALL_CLOUD_DRY_RUN` | `1` to log cloud pushes without applying | `0` |
|
|
471
472
|
| `SECURENOW_TRUSTED_PROXIES` | Comma-separated trusted proxy IPs | — |
|
|
472
473
|
|
|
474
|
+
**Resilience:** The firewall SDK includes a circuit breaker (opens after 5 consecutive errors, 2-min cooldown), in-flight request guards (prevents overlapping requests), 429 Retry-After support, and exponential backoff on both version checks and initial sync retries.
|
|
475
|
+
|
|
473
476
|
### Cloud WAF Provider Variables
|
|
474
477
|
|
|
475
478
|
| Provider | Variables |
|
package/cli/config.js
CHANGED
|
@@ -62,12 +62,18 @@ function getAuthSource() {
|
|
|
62
62
|
function loadConfig() {
|
|
63
63
|
const global = loadJSON(CONFIG_FILE);
|
|
64
64
|
const local = fs.existsSync(LOCAL_CONFIG_FILE) ? loadJSON(LOCAL_CONFIG_FILE) : {};
|
|
65
|
+
if (hasLocalCredentials()) {
|
|
66
|
+
const { defaultApp: _ignored, ...globalWithoutAccountScoped } = global;
|
|
67
|
+
return { ...DEFAULTS, ...globalWithoutAccountScoped, ...local };
|
|
68
|
+
}
|
|
65
69
|
return { ...DEFAULTS, ...global, ...local };
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
function saveConfig(config) {
|
|
69
|
-
const
|
|
70
|
-
|
|
72
|
+
function saveConfig(config, { local } = {}) {
|
|
73
|
+
const useLocal = local === true || (local == null && hasLocalCredentials());
|
|
74
|
+
const targetFile = useLocal ? LOCAL_CONFIG_FILE : CONFIG_FILE;
|
|
75
|
+
const existing = loadJSON(targetFile);
|
|
76
|
+
saveJSON(targetFile, { ...existing, ...config });
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
function getConfigValue(key) {
|
package/cli/monitor.js
CHANGED
|
@@ -163,6 +163,24 @@ async function tracesAnalyze(args, flags) {
|
|
|
163
163
|
|
|
164
164
|
// ── Logs ──
|
|
165
165
|
|
|
166
|
+
// Parse a duration string like "6h", "30m", "2d", "90s" into minutes.
|
|
167
|
+
// Returns null on unparseable input so the caller can error out clearly
|
|
168
|
+
// instead of silently falling through to a default.
|
|
169
|
+
function parseDurationToMinutes(value) {
|
|
170
|
+
if (value == null || value === '') return null;
|
|
171
|
+
const m = String(value).trim().match(/^(\d+)\s*(s|m|h|d)?$/i);
|
|
172
|
+
if (!m) return null;
|
|
173
|
+
const n = parseInt(m[1], 10);
|
|
174
|
+
const unit = (m[2] || 'm').toLowerCase();
|
|
175
|
+
if (unit === 's') return Math.max(1, Math.round(n / 60));
|
|
176
|
+
if (unit === 'm') return n;
|
|
177
|
+
if (unit === 'h') return n * 60;
|
|
178
|
+
if (unit === 'd') return n * 60 * 24;
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'WARNING', 'ERROR', 'FATAL'];
|
|
183
|
+
|
|
166
184
|
async function logsList(args, flags) {
|
|
167
185
|
requireAuth();
|
|
168
186
|
const appKey = resolveApp(flags);
|
|
@@ -171,17 +189,42 @@ async function logsList(args, flags) {
|
|
|
171
189
|
process.exit(1);
|
|
172
190
|
}
|
|
173
191
|
|
|
192
|
+
let minutes = 60;
|
|
193
|
+
if (flags.since != null) {
|
|
194
|
+
const parsed = parseDurationToMinutes(flags.since);
|
|
195
|
+
if (parsed == null) {
|
|
196
|
+
ui.error(`Invalid --since value "${flags.since}". Expected e.g. 30m, 6h, 2d.`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
minutes = parsed;
|
|
200
|
+
} else if (flags.minutes != null) {
|
|
201
|
+
const n = parseInt(flags.minutes, 10);
|
|
202
|
+
if (isNaN(n) || n <= 0) {
|
|
203
|
+
ui.error(`Invalid --minutes value "${flags.minutes}".`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
minutes = n;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let severity = null;
|
|
210
|
+
if (flags.level) {
|
|
211
|
+
severity = String(flags.level).toUpperCase();
|
|
212
|
+
if (!LOG_LEVELS.includes(severity)) {
|
|
213
|
+
ui.error(`Invalid --level "${flags.level}". Expected one of: ${LOG_LEVELS.join(', ').toLowerCase()}.`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
174
218
|
const s = ui.spinner('Fetching logs');
|
|
175
219
|
try {
|
|
176
|
-
const minutes = parseInt(flags.minutes || '60', 10);
|
|
177
220
|
const now = Date.now();
|
|
178
221
|
const query = {
|
|
179
222
|
appKeys: appKey,
|
|
180
|
-
limit: flags.limit ||
|
|
223
|
+
limit: flags.limit || 200,
|
|
181
224
|
from: flags.start || new Date(now - minutes * 60 * 1000).toISOString(),
|
|
182
225
|
to: flags.end || new Date(now).toISOString(),
|
|
183
226
|
};
|
|
184
|
-
if (
|
|
227
|
+
if (severity) query.severity = severity;
|
|
185
228
|
|
|
186
229
|
const data = await api.get('/logs', { query });
|
|
187
230
|
const logs = data.logs || [];
|
|
@@ -193,11 +236,12 @@ async function logsList(args, flags) {
|
|
|
193
236
|
for (const log of logs) {
|
|
194
237
|
const level = (log.severityText || log.level || 'INFO').toUpperCase();
|
|
195
238
|
const levelColor = {
|
|
196
|
-
ERROR: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
|
|
197
|
-
INFO: ui.c.cyan, DEBUG: ui.c.dim,
|
|
239
|
+
ERROR: ui.c.red, FATAL: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
|
|
240
|
+
INFO: ui.c.cyan, DEBUG: ui.c.dim, TRACE: ui.c.dim,
|
|
198
241
|
}[level] || ui.c.white;
|
|
199
242
|
|
|
200
|
-
const
|
|
243
|
+
const parsedTime = ui.parseTimestamp(log.timestamp);
|
|
244
|
+
const time = ui.c.dim(parsedTime ? parsedTime.toLocaleTimeString() : '');
|
|
201
245
|
const body = log.body || log.message || log.severityText || '';
|
|
202
246
|
|
|
203
247
|
console.log(` ${time} ${levelColor(level.padEnd(7))} ${body}`);
|
|
@@ -233,11 +277,12 @@ async function logsTrace(args, flags) {
|
|
|
233
277
|
for (const log of logs) {
|
|
234
278
|
const level = (log.severityText || log.level || 'INFO').toUpperCase();
|
|
235
279
|
const levelColor = {
|
|
236
|
-
ERROR: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
|
|
237
|
-
INFO: ui.c.cyan, DEBUG: ui.c.dim,
|
|
280
|
+
ERROR: ui.c.red, FATAL: ui.c.red, WARN: ui.c.yellow, WARNING: ui.c.yellow,
|
|
281
|
+
INFO: ui.c.cyan, DEBUG: ui.c.dim, TRACE: ui.c.dim,
|
|
238
282
|
}[level] || ui.c.white;
|
|
239
283
|
|
|
240
|
-
const
|
|
284
|
+
const parsedTime = ui.parseTimestamp(log.timestamp);
|
|
285
|
+
const time = ui.c.dim(parsedTime ? parsedTime.toLocaleTimeString() : '');
|
|
241
286
|
console.log(` ${time} ${levelColor(level.padEnd(7))} ${log.body || log.message || ''}`);
|
|
242
287
|
}
|
|
243
288
|
console.log('');
|
package/cli/ui.js
CHANGED
|
@@ -266,9 +266,36 @@ function hr() {
|
|
|
266
266
|
console.log(c.dim('─'.repeat(width)));
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
// Parses timestamps the backend returns in multiple formats:
|
|
270
|
+
// - UInt64 nanoseconds as a string (signoz logs) → "1775856777891000000"
|
|
271
|
+
// - ClickHouse DateTime64 string (signoz traces) → "2026-04-10 21:34:51.133000000" (UTC, no Z)
|
|
272
|
+
// - ISO 8601 → "2026-04-10T21:34:51.133Z"
|
|
273
|
+
// - number (ms) / Date → passthrough
|
|
274
|
+
function parseTimestamp(ts) {
|
|
275
|
+
if (ts == null || ts === '') return null;
|
|
276
|
+
if (ts instanceof Date) return isNaN(ts.getTime()) ? null : ts;
|
|
277
|
+
if (typeof ts === 'number') {
|
|
278
|
+
const d = new Date(ts);
|
|
279
|
+
return isNaN(d.getTime()) ? null : d;
|
|
280
|
+
}
|
|
281
|
+
const s = String(ts).trim();
|
|
282
|
+
if (/^\d{16,}$/.test(s)) {
|
|
283
|
+
const d = new Date(Number(s) / 1e6);
|
|
284
|
+
return isNaN(d.getTime()) ? null : d;
|
|
285
|
+
}
|
|
286
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) {
|
|
287
|
+
const isoish = s.replace(' ', 'T').replace(/\.(\d{3})\d*$/, '.$1') + 'Z';
|
|
288
|
+
const d = new Date(isoish);
|
|
289
|
+
if (!isNaN(d.getTime())) return d;
|
|
290
|
+
}
|
|
291
|
+
const d = new Date(s);
|
|
292
|
+
return isNaN(d.getTime()) ? null : d;
|
|
293
|
+
}
|
|
294
|
+
|
|
269
295
|
function timeAgo(dateStr) {
|
|
270
|
-
|
|
271
|
-
|
|
296
|
+
const date = parseTimestamp(dateStr);
|
|
297
|
+
if (!date) return '—';
|
|
298
|
+
const diff = Date.now() - date.getTime();
|
|
272
299
|
const seconds = Math.floor(diff / 1000);
|
|
273
300
|
if (seconds < 60) return 'just now';
|
|
274
301
|
const minutes = Math.floor(seconds / 60);
|
|
@@ -277,7 +304,7 @@ function timeAgo(dateStr) {
|
|
|
277
304
|
if (hours < 24) return `${hours}h ago`;
|
|
278
305
|
const days = Math.floor(hours / 24);
|
|
279
306
|
if (days < 30) return `${days}d ago`;
|
|
280
|
-
return
|
|
307
|
+
return date.toLocaleDateString();
|
|
281
308
|
}
|
|
282
309
|
|
|
283
310
|
function truncate(str, len = 50) {
|
|
@@ -351,6 +378,7 @@ module.exports = {
|
|
|
351
378
|
json,
|
|
352
379
|
hr,
|
|
353
380
|
timeAgo,
|
|
381
|
+
parseTimestamp,
|
|
354
382
|
truncate,
|
|
355
383
|
statusBadge,
|
|
356
384
|
httpStatusColor,
|
package/cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const ui = require('./cli/ui');
|
|
@@ -90,7 +90,7 @@ const COMMANDS = {
|
|
|
90
90
|
desc: 'View application logs',
|
|
91
91
|
usage: 'securenow logs [options]',
|
|
92
92
|
sub: {
|
|
93
|
-
list: { desc: 'List recent logs', flags: { app: 'App key', limit: 'Max results', minutes: 'Time window in minutes', level: 'Filter by level' }, run: (a, f) => require('./cli/monitor').logsList(a, f) },
|
|
93
|
+
list: { desc: 'List recent logs', flags: { app: 'App key', limit: 'Max results (default 200)', since: 'Time window (e.g. 30m, 6h, 2d)', minutes: 'Time window in minutes (alias for --since Nm)', level: 'Filter by level (error, warn, info, debug)', start: 'Start time (ISO 8601)', end: 'End time (ISO 8601)' }, run: (a, f) => require('./cli/monitor').logsList(a, f) },
|
|
94
94
|
trace: { desc: 'Show logs for a trace', usage: 'securenow logs trace <traceId>', run: (a, f) => require('./cli/monitor').logsTrace(a, f) },
|
|
95
95
|
},
|
|
96
96
|
defaultSub: 'list',
|
package/docs/FIREWALL-GUIDE.md
CHANGED
|
@@ -345,6 +345,21 @@ The firewall uses the same trusted-proxy-aware IP resolution as SecureNow tracin
|
|
|
345
345
|
- Store it in environment variables or `.env` — never commit it to source control
|
|
346
346
|
- The key is hashed (SHA-256) on the server — SecureNow never stores the plaintext
|
|
347
347
|
|
|
348
|
+
### Sync Architecture
|
|
349
|
+
|
|
350
|
+
The SDK uses a unified sync endpoint (`/firewall/sync`) that combines version checking and data fetching into a single request:
|
|
351
|
+
|
|
352
|
+
1. **One request per poll** — The SDK sends its current blocklist and allowlist versions. The API responds with version info and only includes full IP lists for lists that have changed.
|
|
353
|
+
2. **HTTP Keep-Alive** — TCP connections are reused across polls. TLS handshake happens once; subsequent requests reuse the socket (~5-10ms vs ~100-200ms per request).
|
|
354
|
+
3. **Automatic fallback** — If the unified endpoint is not available (older API), the SDK falls back to legacy separate endpoints.
|
|
355
|
+
|
|
356
|
+
### Circuit Breaker & Back-Pressure
|
|
357
|
+
|
|
358
|
+
- **Circuit breaker** — After 5 consecutive errors, the SDK pauses all polling for 2 minutes. After cooldown, a single probe is sent. If it succeeds, normal polling resumes.
|
|
359
|
+
- **In-flight guard** — Only one poll can be in-flight at a time. If the API is slow, the next scheduled poll is skipped.
|
|
360
|
+
- **429 Retry-After** — When the API returns HTTP 429, all polling pauses for the `Retry-After` duration.
|
|
361
|
+
- **Exponential backoff** — Poll interval doubles on each consecutive error (10s → 20s → 40s → 80s, capped at 120s).
|
|
362
|
+
|
|
348
363
|
### Cleanup on Shutdown
|
|
349
364
|
|
|
350
365
|
All layers clean up on process exit (SIGINT/SIGTERM):
|
package/firewall.js
CHANGED
|
@@ -8,7 +8,7 @@ const { resolveClientIp } = require('./resolve-ip');
|
|
|
8
8
|
let _options = null;
|
|
9
9
|
let _matcher = null;
|
|
10
10
|
let _syncTimer = null;
|
|
11
|
-
let
|
|
11
|
+
let _pollTimer = null;
|
|
12
12
|
let _lastModified = null;
|
|
13
13
|
let _lastVersion = null;
|
|
14
14
|
let _lastSyncEtag = null;
|
|
@@ -25,36 +25,29 @@ let _allowlistRawIps = [];
|
|
|
25
25
|
let _lastAllowlistModified = null;
|
|
26
26
|
let _lastAllowlistVersion = null;
|
|
27
27
|
let _lastAllowlistSyncEtag = null;
|
|
28
|
-
let _allowlistVersionTimer = null;
|
|
29
|
-
let _allowlistConsecutiveErrors = 0;
|
|
30
28
|
|
|
31
|
-
// Circuit breaker
|
|
32
|
-
// Opens after CIRCUIT_OPEN_THRESHOLD consecutive errors across both lists,
|
|
33
|
-
// stays open for CIRCUIT_OPEN_COOLDOWN_MS, then half-opens (single probe).
|
|
29
|
+
// Circuit breaker
|
|
34
30
|
const CIRCUIT_OPEN_THRESHOLD = 5;
|
|
35
31
|
const CIRCUIT_OPEN_COOLDOWN_MS = 120_000;
|
|
36
|
-
let _circuitState = 'closed';
|
|
32
|
+
let _circuitState = 'closed';
|
|
37
33
|
let _circuitOpenedAt = 0;
|
|
38
34
|
|
|
39
|
-
// In-flight
|
|
40
|
-
let
|
|
41
|
-
let _allowlistVersionCheckInflight = false;
|
|
42
|
-
let _blocklistSyncInflight = false;
|
|
43
|
-
let _allowlistSyncInflight = false;
|
|
44
|
-
|
|
45
|
-
// 429 global back-off: if the API returns 429 with Retry-After, all polling
|
|
46
|
-
// pauses until this timestamp.
|
|
35
|
+
// In-flight guard and 429 back-off
|
|
36
|
+
let _pollInflight = false;
|
|
47
37
|
let _retryAfterUntil = 0;
|
|
48
38
|
|
|
49
|
-
//
|
|
39
|
+
// Keep-alive agents — reuse TCP connections across polls (TLS handshake once)
|
|
40
|
+
const _httpAgent = new http.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
|
|
41
|
+
const _httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, keepAliveMsecs: 30_000 });
|
|
50
42
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
// Unified sync uses /firewall/sync (v2). Falls back to legacy on 404.
|
|
44
|
+
let _useUnifiedSync = true;
|
|
45
|
+
|
|
46
|
+
// ────── Circuit Breaker ──────
|
|
54
47
|
|
|
55
48
|
function maybeOpenCircuit() {
|
|
56
49
|
if (_circuitState === 'open') return;
|
|
57
|
-
if (
|
|
50
|
+
if (_consecutiveErrors >= CIRCUIT_OPEN_THRESHOLD) {
|
|
58
51
|
_circuitState = 'open';
|
|
59
52
|
_circuitOpenedAt = Date.now();
|
|
60
53
|
if (_options && _options.log) {
|
|
@@ -69,7 +62,6 @@ function resetCircuit() {
|
|
|
69
62
|
if (_options && _options.log) console.log('[securenow] Firewall: circuit breaker CLOSED — API healthy');
|
|
70
63
|
}
|
|
71
64
|
_consecutiveErrors = 0;
|
|
72
|
-
_allowlistConsecutiveErrors = 0;
|
|
73
65
|
}
|
|
74
66
|
|
|
75
67
|
function shouldSkipRequest() {
|
|
@@ -97,23 +89,25 @@ function handleRetryAfter(res) {
|
|
|
97
89
|
}
|
|
98
90
|
}
|
|
99
91
|
|
|
100
|
-
// ──────
|
|
92
|
+
// ────── HTTP helpers ──────
|
|
101
93
|
|
|
102
94
|
function buildUrl(apiUrl, path) {
|
|
103
95
|
return apiUrl.replace(/\/+$/, '') + '/api/v1' + path;
|
|
104
96
|
}
|
|
105
97
|
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
98
|
+
function jitter(baseMs) {
|
|
99
|
+
return baseMs * (0.8 + Math.random() * 0.4);
|
|
100
|
+
}
|
|
109
101
|
|
|
110
|
-
|
|
102
|
+
function agentFor(url) {
|
|
103
|
+
return url.startsWith('https') ? _httpsAgent : _httpAgent;
|
|
104
|
+
}
|
|
111
105
|
|
|
112
|
-
|
|
106
|
+
function httpGet(url, extraHeaders, timeout, callback) {
|
|
113
107
|
const mod = url.startsWith('https') ? https : http;
|
|
114
108
|
const parsed = new URL(url);
|
|
115
109
|
|
|
116
|
-
const
|
|
110
|
+
const req = mod.request({
|
|
117
111
|
hostname: parsed.hostname,
|
|
118
112
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
119
113
|
path: parsed.pathname + parsed.search,
|
|
@@ -121,297 +115,315 @@ function syncBlocklist(callback) {
|
|
|
121
115
|
headers: {
|
|
122
116
|
'Authorization': `Bearer ${_options.apiKey}`,
|
|
123
117
|
'User-Agent': 'securenow-firewall-sdk',
|
|
118
|
+
...extraHeaders,
|
|
124
119
|
},
|
|
125
|
-
timeout
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (_lastSyncEtag) {
|
|
129
|
-
reqOptions.headers['If-None-Match'] = _lastSyncEtag;
|
|
130
|
-
} else if (_lastModified) {
|
|
131
|
-
reqOptions.headers['If-Modified-Since'] = _lastModified;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const req = mod.request(reqOptions, (res) => {
|
|
135
|
-
if (res.statusCode === 304) {
|
|
136
|
-
done(null, false);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
140
|
-
|
|
120
|
+
timeout,
|
|
121
|
+
agent: agentFor(url),
|
|
122
|
+
}, (res) => {
|
|
141
123
|
let data = '';
|
|
142
124
|
res.on('data', (chunk) => { data += chunk; });
|
|
143
|
-
res.on('end', () => {
|
|
144
|
-
if (res.statusCode !== 200) {
|
|
145
|
-
done(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
try {
|
|
149
|
-
const body = JSON.parse(data);
|
|
150
|
-
const ips = body.ips || [];
|
|
151
|
-
_rawIps = ips;
|
|
152
|
-
_matcher = createMatcher(ips);
|
|
153
|
-
_lastModified = res.headers['last-modified'] || null;
|
|
154
|
-
if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
|
|
155
|
-
_stats.syncs++;
|
|
156
|
-
notifyLayers(ips);
|
|
157
|
-
done(null, true, _matcher.stats());
|
|
158
|
-
} catch (e) {
|
|
159
|
-
done(new Error(`Failed to parse blocklist response: ${e.message}`));
|
|
160
|
-
}
|
|
161
|
-
});
|
|
125
|
+
res.on('end', () => { callback(null, res, data); });
|
|
162
126
|
});
|
|
163
127
|
|
|
164
|
-
req.on('error', (err) =>
|
|
165
|
-
req.on('timeout', () => { req.destroy();
|
|
128
|
+
req.on('error', (err) => callback(err));
|
|
129
|
+
req.on('timeout', () => { req.destroy(); callback(new Error('Request timed out')); });
|
|
166
130
|
req.end();
|
|
167
131
|
}
|
|
168
132
|
|
|
169
|
-
|
|
170
|
-
for (const layer of _layers) {
|
|
171
|
-
try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
133
|
+
// ────── Unified Sync (v2 — single request for everything) ──────
|
|
174
134
|
|
|
175
|
-
|
|
135
|
+
function doUnifiedSync(callback) {
|
|
136
|
+
const url = buildUrl(_options.apiUrl, '/firewall/sync');
|
|
137
|
+
const headers = {};
|
|
138
|
+
if (_lastVersion) headers['X-Blocklist-Version'] = _lastVersion;
|
|
139
|
+
if (_lastAllowlistVersion) headers['X-Allowlist-Version'] = _lastAllowlistVersion;
|
|
176
140
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
_allowlistSyncInflight = true;
|
|
141
|
+
httpGet(url, headers, 8000, (err, res, data) => {
|
|
142
|
+
if (err) return callback(err);
|
|
180
143
|
|
|
181
|
-
|
|
144
|
+
_stats.versionChecks++;
|
|
182
145
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
146
|
+
if (res.statusCode === 304) {
|
|
147
|
+
return callback(null, { blChanged: false, alChanged: false });
|
|
148
|
+
}
|
|
186
149
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
headers: {
|
|
193
|
-
'Authorization': `Bearer ${_options.apiKey}`,
|
|
194
|
-
'User-Agent': 'securenow-firewall-sdk',
|
|
195
|
-
},
|
|
196
|
-
timeout: 10000,
|
|
197
|
-
};
|
|
150
|
+
if (res.statusCode === 404) {
|
|
151
|
+
_useUnifiedSync = false;
|
|
152
|
+
if (_options.log) console.log('[securenow] Firewall: /sync not available, using legacy endpoints');
|
|
153
|
+
return callback(null, { blChanged: false, alChanged: false, useLegacy: true });
|
|
154
|
+
}
|
|
198
155
|
|
|
199
|
-
|
|
200
|
-
reqOptions.headers['If-None-Match'] = _lastAllowlistSyncEtag;
|
|
201
|
-
} else if (_lastAllowlistModified) {
|
|
202
|
-
reqOptions.headers['If-Modified-Since'] = _lastAllowlistModified;
|
|
203
|
-
}
|
|
156
|
+
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
204
157
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
done(null, false);
|
|
208
|
-
return;
|
|
158
|
+
if (res.statusCode !== 200) {
|
|
159
|
+
return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
209
160
|
}
|
|
210
|
-
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
211
161
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
162
|
+
try {
|
|
163
|
+
const body = JSON.parse(data);
|
|
164
|
+
|
|
165
|
+
let blChanged = false;
|
|
166
|
+
let alChanged = false;
|
|
167
|
+
|
|
168
|
+
// Update blocklist version + data
|
|
169
|
+
if (body.blocklist) {
|
|
170
|
+
const newVer = body.blocklist.version;
|
|
171
|
+
if (newVer !== _lastVersion) {
|
|
172
|
+
_lastVersion = newVer;
|
|
173
|
+
blChanged = true;
|
|
174
|
+
}
|
|
218
175
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
done(null, true, _allowlistMatcher.stats());
|
|
227
|
-
} catch (e) {
|
|
228
|
-
done(new Error(`Failed to parse allowlist response: ${e.message}`));
|
|
176
|
+
|
|
177
|
+
if (body.blocklistIps) {
|
|
178
|
+
_rawIps = body.blocklistIps;
|
|
179
|
+
_matcher = createMatcher(body.blocklistIps);
|
|
180
|
+
_stats.syncs++;
|
|
181
|
+
notifyLayers(body.blocklistIps);
|
|
182
|
+
blChanged = true;
|
|
229
183
|
}
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
184
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
185
|
+
// Update allowlist version + data
|
|
186
|
+
if (body.allowlist) {
|
|
187
|
+
const newVer = body.allowlist.version;
|
|
188
|
+
if (newVer !== _lastAllowlistVersion) {
|
|
189
|
+
_lastAllowlistVersion = newVer;
|
|
190
|
+
alChanged = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
237
193
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
194
|
+
if (body.allowlistIps) {
|
|
195
|
+
_allowlistRawIps = body.allowlistIps;
|
|
196
|
+
_allowlistMatcher = createMatcher(body.allowlistIps);
|
|
197
|
+
alChanged = true;
|
|
198
|
+
}
|
|
241
199
|
|
|
242
|
-
|
|
200
|
+
callback(null, { blChanged, alChanged });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
callback(new Error(`Failed to parse sync response: ${e.message}`));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
243
206
|
|
|
244
|
-
|
|
245
|
-
const mod = url.startsWith('https') ? https : http;
|
|
246
|
-
const parsed = new URL(url);
|
|
207
|
+
// ────── Legacy Sync (v1 — separate endpoints, kept for backward compat) ──────
|
|
247
208
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
209
|
+
function legacyBlocklistSync(callback) {
|
|
210
|
+
const url = buildUrl(_options.apiUrl, '/firewall/blocklist');
|
|
211
|
+
const headers = {};
|
|
212
|
+
if (_lastSyncEtag) headers['If-None-Match'] = _lastSyncEtag;
|
|
213
|
+
else if (_lastModified) headers['If-Modified-Since'] = _lastModified;
|
|
253
214
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
path: parsed.pathname + parsed.search,
|
|
258
|
-
method: 'GET',
|
|
259
|
-
headers,
|
|
260
|
-
timeout: 5000,
|
|
261
|
-
}, (res) => {
|
|
262
|
-
if (res.statusCode === 304) {
|
|
263
|
-
_allowlistConsecutiveErrors = 0;
|
|
264
|
-
resetCircuit();
|
|
265
|
-
res.resume();
|
|
266
|
-
done(null, false);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
215
|
+
httpGet(url, headers, 10000, (err, res, data) => {
|
|
216
|
+
if (err) return callback(err);
|
|
217
|
+
if (res.statusCode === 304) return callback(null, false);
|
|
269
218
|
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
219
|
+
if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
270
220
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const body = JSON.parse(data);
|
|
285
|
-
const version = body.version || null;
|
|
286
|
-
const changed = version !== _lastAllowlistVersion;
|
|
287
|
-
if (changed) _lastAllowlistVersion = version;
|
|
288
|
-
done(null, changed);
|
|
289
|
-
} catch (_e) { done(null, false); }
|
|
290
|
-
});
|
|
221
|
+
try {
|
|
222
|
+
const body = JSON.parse(data);
|
|
223
|
+
const ips = body.ips || [];
|
|
224
|
+
_rawIps = ips;
|
|
225
|
+
_matcher = createMatcher(ips);
|
|
226
|
+
_lastModified = res.headers['last-modified'] || null;
|
|
227
|
+
if (res.headers['etag']) _lastSyncEtag = res.headers['etag'];
|
|
228
|
+
_stats.syncs++;
|
|
229
|
+
notifyLayers(ips);
|
|
230
|
+
callback(null, true, _matcher.stats());
|
|
231
|
+
} catch (e) {
|
|
232
|
+
callback(new Error(`Failed to parse blocklist: ${e.message}`));
|
|
233
|
+
}
|
|
291
234
|
});
|
|
292
|
-
|
|
293
|
-
req.on('error', () => { _allowlistConsecutiveErrors++; _stats.errors++; maybeOpenCircuit(); done(null, false); });
|
|
294
|
-
req.on('timeout', () => { req.destroy(); _allowlistConsecutiveErrors++; _stats.errors++; maybeOpenCircuit(); done(null, false); });
|
|
295
|
-
req.end();
|
|
296
235
|
}
|
|
297
236
|
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
237
|
+
function legacyAllowlistSync(callback) {
|
|
238
|
+
const url = buildUrl(_options.apiUrl, '/firewall/allowlist');
|
|
239
|
+
const headers = {};
|
|
240
|
+
if (_lastAllowlistSyncEtag) headers['If-None-Match'] = _lastAllowlistSyncEtag;
|
|
241
|
+
else if (_lastAllowlistModified) headers['If-Modified-Since'] = _lastAllowlistModified;
|
|
242
|
+
|
|
243
|
+
httpGet(url, headers, 10000, (err, res, data) => {
|
|
244
|
+
if (err) return callback(err);
|
|
245
|
+
if (res.statusCode === 304) return callback(null, false);
|
|
246
|
+
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
247
|
+
if (res.statusCode !== 200) return callback(new Error(`API returned ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const body = JSON.parse(data);
|
|
251
|
+
const ips = body.ips || [];
|
|
252
|
+
_allowlistRawIps = ips;
|
|
253
|
+
_allowlistMatcher = createMatcher(ips);
|
|
254
|
+
_lastAllowlistModified = res.headers['last-modified'] || null;
|
|
255
|
+
if (res.headers['etag']) _lastAllowlistSyncEtag = res.headers['etag'];
|
|
256
|
+
callback(null, true, _allowlistMatcher.stats());
|
|
257
|
+
} catch (e) {
|
|
258
|
+
callback(new Error(`Failed to parse allowlist: ${e.message}`));
|
|
304
259
|
}
|
|
305
260
|
});
|
|
306
261
|
}
|
|
307
262
|
|
|
308
|
-
function
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
263
|
+
function legacyVersionCheck(callback) {
|
|
264
|
+
const url = buildUrl(_options.apiUrl, '/firewall/blocklist/version');
|
|
265
|
+
const headers = {};
|
|
266
|
+
if (_lastVersion) headers['If-None-Match'] = _lastVersion;
|
|
312
267
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
268
|
+
httpGet(url, headers, 5000, (err, res, data) => {
|
|
269
|
+
if (err) return callback(err);
|
|
270
|
+
_stats.versionChecks++;
|
|
271
|
+
if (res.statusCode === 304) return callback(null, false, false);
|
|
272
|
+
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
273
|
+
if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const body = JSON.parse(data);
|
|
277
|
+
const version = body.version || null;
|
|
278
|
+
const changed = version !== _lastVersion;
|
|
279
|
+
if (changed) _lastVersion = version;
|
|
280
|
+
callback(null, changed, false);
|
|
281
|
+
} catch (_e) { callback(null, false, false); }
|
|
282
|
+
});
|
|
323
283
|
}
|
|
324
284
|
|
|
325
|
-
function
|
|
326
|
-
const url = buildUrl(_options.apiUrl, '/firewall/
|
|
327
|
-
const
|
|
328
|
-
|
|
285
|
+
function legacyAllowlistVersionCheck(callback) {
|
|
286
|
+
const url = buildUrl(_options.apiUrl, '/firewall/allowlist/version');
|
|
287
|
+
const headers = {};
|
|
288
|
+
if (_lastAllowlistVersion) headers['If-None-Match'] = _lastAllowlistVersion;
|
|
329
289
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
290
|
+
httpGet(url, headers, 5000, (err, res, data) => {
|
|
291
|
+
if (err) return callback(err);
|
|
292
|
+
if (res.statusCode === 304) return callback(null, false);
|
|
293
|
+
if (res.statusCode === 429) { handleRetryAfter(res); }
|
|
294
|
+
if (res.statusCode !== 200) return callback(new Error(`API ${res.statusCode}`));
|
|
335
295
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
296
|
+
try {
|
|
297
|
+
const body = JSON.parse(data);
|
|
298
|
+
const version = body.version || null;
|
|
299
|
+
const changed = version !== _lastAllowlistVersion;
|
|
300
|
+
if (changed) _lastAllowlistVersion = version;
|
|
301
|
+
callback(null, changed);
|
|
302
|
+
} catch (_e) { callback(null, false); }
|
|
303
|
+
});
|
|
304
|
+
}
|
|
345
305
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
306
|
+
function doLegacyPoll(callback) {
|
|
307
|
+
let pending = 2;
|
|
308
|
+
let blChanged = false;
|
|
309
|
+
let alChanged = false;
|
|
310
|
+
let firstError = null;
|
|
311
|
+
|
|
312
|
+
function checkDone() {
|
|
313
|
+
if (--pending > 0) return;
|
|
314
|
+
if (firstError) return callback(firstError);
|
|
315
|
+
|
|
316
|
+
let syncsPending = 0;
|
|
317
|
+
if (blChanged) syncsPending++;
|
|
318
|
+
if (alChanged) syncsPending++;
|
|
319
|
+
if (syncsPending === 0) return callback(null, { blChanged: false, alChanged: false });
|
|
320
|
+
|
|
321
|
+
function syncDone() {
|
|
322
|
+
if (--syncsPending > 0) return;
|
|
323
|
+
callback(null, { blChanged, alChanged });
|
|
351
324
|
}
|
|
352
325
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
326
|
+
if (blChanged) {
|
|
327
|
+
legacyBlocklistSync((err, changed, stats) => {
|
|
328
|
+
if (err && _options.log) console.warn('[securenow] Firewall: sync failed (using stale list):', err.message);
|
|
329
|
+
else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d blocked IPs', stats.total);
|
|
330
|
+
syncDone();
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (alChanged) {
|
|
334
|
+
legacyAllowlistSync((err, changed, stats) => {
|
|
335
|
+
if (err && _options.log) console.warn('[securenow] Firewall: allowlist sync failed:', err.message);
|
|
336
|
+
else if (changed && stats && _options.log) console.log('[securenow] Firewall: re-synced %d allowed IPs', stats.total);
|
|
337
|
+
syncDone();
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
legacyVersionCheck((err, changed) => {
|
|
343
|
+
if (err) firstError = firstError || err;
|
|
344
|
+
blChanged = changed;
|
|
345
|
+
checkDone();
|
|
371
346
|
});
|
|
372
347
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
348
|
+
legacyAllowlistVersionCheck((err, changed) => {
|
|
349
|
+
if (err) firstError = firstError || err;
|
|
350
|
+
alChanged = changed;
|
|
351
|
+
checkDone();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ────── Unified poll loop ──────
|
|
356
|
+
|
|
357
|
+
function notifyLayers(ips) {
|
|
358
|
+
for (const layer of _layers) {
|
|
359
|
+
try { if (typeof layer.sync === 'function') layer.sync(ips); } catch (_) {}
|
|
360
|
+
}
|
|
376
361
|
}
|
|
377
362
|
|
|
378
|
-
function
|
|
379
|
-
|
|
363
|
+
function pollOnce(callback) {
|
|
364
|
+
if (_pollInflight || shouldSkipRequest()) return callback(null);
|
|
365
|
+
_pollInflight = true;
|
|
366
|
+
|
|
367
|
+
const done = (err, result) => {
|
|
368
|
+
_pollInflight = false;
|
|
380
369
|
if (err) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
370
|
+
_consecutiveErrors++;
|
|
371
|
+
_stats.errors++;
|
|
372
|
+
maybeOpenCircuit();
|
|
373
|
+
if (_options.log) console.warn('[securenow] Firewall: poll failed:', err.message);
|
|
374
|
+
return callback(err);
|
|
384
375
|
}
|
|
385
|
-
|
|
386
|
-
|
|
376
|
+
_consecutiveErrors = 0;
|
|
377
|
+
resetCircuit();
|
|
378
|
+
if (result) {
|
|
379
|
+
if (result.blChanged && _options.log && _matcher) {
|
|
380
|
+
const s = _matcher.stats();
|
|
381
|
+
console.log('[securenow] Firewall: re-synced %d blocked IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
382
|
+
}
|
|
383
|
+
if (result.alChanged && _options.log && _allowlistMatcher) {
|
|
384
|
+
const s = _allowlistMatcher.stats();
|
|
385
|
+
console.log('[securenow] Firewall: re-synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
callback(null);
|
|
389
|
+
};
|
|
387
390
|
|
|
388
|
-
|
|
389
|
-
|
|
391
|
+
if (_useUnifiedSync) {
|
|
392
|
+
doUnifiedSync((err, result) => {
|
|
393
|
+
if (err) return done(err);
|
|
394
|
+
if (result && result.useLegacy) {
|
|
395
|
+
doLegacyPoll(done);
|
|
396
|
+
} else {
|
|
397
|
+
done(null, result);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
} else {
|
|
401
|
+
doLegacyPoll(done);
|
|
402
|
+
}
|
|
390
403
|
}
|
|
391
404
|
|
|
392
|
-
function
|
|
405
|
+
function scheduleNextPoll() {
|
|
393
406
|
const baseMs = (_options.versionCheckInterval || 10) * 1000;
|
|
394
407
|
const backoffMs = Math.min(baseMs * Math.pow(2, _consecutiveErrors), 120_000);
|
|
395
408
|
const delayMs = jitter(backoffMs);
|
|
396
409
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (changed) {
|
|
400
|
-
if (_options.log) console.log('[securenow] Firewall: blocklist version changed, syncing…');
|
|
401
|
-
doFullSync();
|
|
402
|
-
}
|
|
403
|
-
scheduleNextVersionCheck();
|
|
404
|
-
});
|
|
410
|
+
_pollTimer = setTimeout(() => {
|
|
411
|
+
pollOnce(() => { scheduleNextPoll(); });
|
|
405
412
|
}, delayMs);
|
|
406
|
-
if (
|
|
413
|
+
if (_pollTimer.unref) _pollTimer.unref();
|
|
407
414
|
}
|
|
408
415
|
|
|
409
416
|
function startSyncLoop() {
|
|
410
417
|
const fullSyncIntervalMs = (_options.syncInterval || 300) * 1000;
|
|
411
|
-
const
|
|
418
|
+
const BASE_RETRY = 5000;
|
|
419
|
+
const MAX_RETRY = 120_000;
|
|
420
|
+
let _initAttempt = 0;
|
|
412
421
|
|
|
413
422
|
function initialSync() {
|
|
414
|
-
|
|
423
|
+
// Use unified endpoint for initial sync too
|
|
424
|
+
const syncFn = _useUnifiedSync ? doUnifiedSync : (cb) => doLegacyPoll(cb);
|
|
425
|
+
|
|
426
|
+
syncFn((err, result) => {
|
|
415
427
|
if (err) {
|
|
416
428
|
const isConnErr = /ECONNREFUSED|ENOTFOUND|timed out/i.test(err.message);
|
|
417
429
|
if (isConnErr && !_localhostFallbackTried && _options.apiUrl !== 'http://localhost:4000') {
|
|
@@ -423,42 +435,58 @@ function startSyncLoop() {
|
|
|
423
435
|
if (retryTimer.unref) retryTimer.unref();
|
|
424
436
|
return;
|
|
425
437
|
}
|
|
438
|
+
if (result && result.useLegacy) {
|
|
439
|
+
const retryTimer = setTimeout(initialSync, 1000);
|
|
440
|
+
if (retryTimer.unref) retryTimer.unref();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
426
443
|
if (_options.log) console.warn('[securenow] Firewall: initial sync failed:', err.message);
|
|
427
444
|
if (_options.failMode === 'closed') {
|
|
428
445
|
_matcher = { isBlocked: () => true, stats: () => ({ exact: 0, cidr: 0, total: 0 }) };
|
|
429
446
|
}
|
|
430
|
-
|
|
447
|
+
_initAttempt++;
|
|
448
|
+
const delay = Math.min(BASE_RETRY * Math.pow(2, _initAttempt), MAX_RETRY);
|
|
449
|
+
const retryTimer = setTimeout(initialSync, jitter(delay));
|
|
431
450
|
if (retryTimer.unref) retryTimer.unref();
|
|
432
451
|
return;
|
|
433
452
|
}
|
|
434
|
-
if (changed && stats) {
|
|
435
|
-
if (_options.log) console.log('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)', stats.total, stats.exact, stats.cidr);
|
|
436
|
-
}
|
|
437
|
-
_initialized = true;
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
453
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (err) {
|
|
444
|
-
if (_options.log) console.warn('[securenow] Firewall: initial allowlist sync failed:', err.message);
|
|
445
|
-
const retryTimer = setTimeout(initialAllowlistSync, RETRY_DELAY);
|
|
454
|
+
if (result && result.useLegacy) {
|
|
455
|
+
const retryTimer = setTimeout(initialSync, 1000);
|
|
446
456
|
if (retryTimer.unref) retryTimer.unref();
|
|
447
457
|
return;
|
|
448
458
|
}
|
|
449
|
-
|
|
450
|
-
|
|
459
|
+
|
|
460
|
+
_initialized = true;
|
|
461
|
+
if (_options.log && _matcher) {
|
|
462
|
+
const s = _matcher.stats();
|
|
463
|
+
console.log('[securenow] Firewall: synced %d blocked IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
464
|
+
}
|
|
465
|
+
if (_options.log && _allowlistMatcher) {
|
|
466
|
+
const s = _allowlistMatcher.stats();
|
|
467
|
+
if (s.total > 0) console.log('[securenow] Firewall: synced %d allowed IPs (%d exact + %d CIDR ranges)', s.total, s.exact, s.cidr);
|
|
451
468
|
}
|
|
452
469
|
});
|
|
453
470
|
}
|
|
454
471
|
|
|
455
472
|
initialSync();
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
473
|
+
scheduleNextPoll();
|
|
474
|
+
|
|
475
|
+
// Safety-net full sync timer (less frequent, uses same path)
|
|
476
|
+
_syncTimer = setInterval(() => {
|
|
477
|
+
if (shouldSkipRequest()) return;
|
|
478
|
+
// Force a full re-fetch by clearing versions so unified endpoint returns full data
|
|
479
|
+
const savedBlVer = _lastVersion;
|
|
480
|
+
const savedAlVer = _lastAllowlistVersion;
|
|
481
|
+
_lastVersion = null;
|
|
482
|
+
_lastAllowlistVersion = null;
|
|
483
|
+
pollOnce((err) => {
|
|
484
|
+
if (err) {
|
|
485
|
+
_lastVersion = savedBlVer;
|
|
486
|
+
_lastAllowlistVersion = savedAlVer;
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}, fullSyncIntervalMs);
|
|
462
490
|
if (_syncTimer.unref) _syncTimer.unref();
|
|
463
491
|
}
|
|
464
492
|
|
|
@@ -541,7 +569,6 @@ function firewallRequestHandler(req, res) {
|
|
|
541
569
|
sendBlockResponse(req, res, ip);
|
|
542
570
|
return true;
|
|
543
571
|
}
|
|
544
|
-
// IP is on the allowlist — skip blocklist check, allow through
|
|
545
572
|
return false;
|
|
546
573
|
}
|
|
547
574
|
|
|
@@ -575,7 +602,6 @@ function patchHttpLayer() {
|
|
|
575
602
|
if (_httpPatched) return;
|
|
576
603
|
_httpPatched = true;
|
|
577
604
|
|
|
578
|
-
// Patch Server.prototype.emit to intercept requests on already-created servers
|
|
579
605
|
patchEmitLayer();
|
|
580
606
|
|
|
581
607
|
http.createServer = function(...args) {
|
|
@@ -602,11 +628,9 @@ function init(options) {
|
|
|
602
628
|
|
|
603
629
|
if (_options.log) console.log('[securenow] Firewall: ENABLED');
|
|
604
630
|
|
|
605
|
-
// Layer 1: HTTP (always on)
|
|
606
631
|
patchHttpLayer();
|
|
607
632
|
if (_options.log) console.log('[securenow] Firewall: Layer 1 (HTTP 403) active');
|
|
608
633
|
|
|
609
|
-
// Layer 2: TCP
|
|
610
634
|
if (_options.tcp) {
|
|
611
635
|
try {
|
|
612
636
|
const tcpLayer = require('./firewall-tcp');
|
|
@@ -620,7 +644,6 @@ function init(options) {
|
|
|
620
644
|
if (_options.log) console.log('[securenow] Firewall: Layer 2 (TCP drop) disabled (set SECURENOW_FIREWALL_TCP=1)');
|
|
621
645
|
}
|
|
622
646
|
|
|
623
|
-
// Layer 3: iptables
|
|
624
647
|
if (_options.iptables) {
|
|
625
648
|
try {
|
|
626
649
|
const iptablesLayer = require('./firewall-iptables');
|
|
@@ -634,7 +657,6 @@ function init(options) {
|
|
|
634
657
|
if (_options.log) console.log('[securenow] Firewall: Layer 3 (iptables) disabled (set SECURENOW_FIREWALL_IPTABLES=1)');
|
|
635
658
|
}
|
|
636
659
|
|
|
637
|
-
// Layer 4: Cloud WAF
|
|
638
660
|
if (_options.cloud) {
|
|
639
661
|
try {
|
|
640
662
|
const cloudLayer = require('./firewall-cloud');
|
|
@@ -652,10 +674,18 @@ function init(options) {
|
|
|
652
674
|
}
|
|
653
675
|
|
|
654
676
|
function shutdown() {
|
|
655
|
-
if (
|
|
656
|
-
if (_allowlistVersionTimer) { clearTimeout(_allowlistVersionTimer); _allowlistVersionTimer = null; }
|
|
677
|
+
if (_pollTimer) { clearTimeout(_pollTimer); _pollTimer = null; }
|
|
657
678
|
if (_syncTimer) { clearInterval(_syncTimer); _syncTimer = null; }
|
|
658
679
|
|
|
680
|
+
_circuitState = 'closed';
|
|
681
|
+
_circuitOpenedAt = 0;
|
|
682
|
+
_consecutiveErrors = 0;
|
|
683
|
+
_pollInflight = false;
|
|
684
|
+
_retryAfterUntil = 0;
|
|
685
|
+
|
|
686
|
+
_httpAgent.destroy();
|
|
687
|
+
_httpsAgent.destroy();
|
|
688
|
+
|
|
659
689
|
for (const layer of _layers) {
|
|
660
690
|
try { if (typeof layer.shutdown === 'function') layer.shutdown(); } catch (_) {}
|
|
661
691
|
}
|
|
@@ -678,6 +708,9 @@ function getStats() {
|
|
|
678
708
|
matcher: _matcher ? _matcher.stats() : null,
|
|
679
709
|
allowlistMatcher: _allowlistMatcher ? _allowlistMatcher.stats() : null,
|
|
680
710
|
initialized: _initialized,
|
|
711
|
+
circuitState: _circuitState,
|
|
712
|
+
consecutiveErrors: _consecutiveErrors,
|
|
713
|
+
unifiedSync: _useUnifiedSync,
|
|
681
714
|
};
|
|
682
715
|
}
|
|
683
716
|
|
package/package.json
CHANGED