@roflsec/fail2scan 0.0.2 → 0.0.3

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/bin/daemon.js +258 -129
  2. package/package.json +1 -1
package/bin/daemon.js CHANGED
@@ -2,23 +2,26 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * Fail2Scan v0.0.1 - daemon.js
6
- * Watches Fail2Ban log for ban events, queues IPs and scans them with nmap/dig/whois.
7
- * Node 18+, CommonJS, no external dependencies.
5
+ * Fail2Scan daemon - integrated version
6
+ * - watches fail2ban log for "Ban" events (handles rotation)
7
+ * - extracts IPv4/IPv6
8
+ * - queue with persistence and rescan TTL
9
+ * - fallback output dir if /var/log/fail2scan not writable
10
+ * - single-ip CLI --scan-ip
8
11
  *
9
12
  * Usage:
10
- * fail2scan-daemon --log /var/log/fail2ban.log --out /var/log/fail2scan --concurrency 1 --nmap-args "-sS -Pn -p- -T4 -sV" --quiet
13
+ * fail2scan-daemon --log /var/log/fail2ban.log --out /var/log/fail2scan --concurrency 2 --nmap-args "-sS -Pn -p- -T4 -sV" --quiet
11
14
  */
12
15
 
13
16
  const fs = require('fs');
14
17
  const path = require('path');
15
- const { execFile } = require('child_process');
18
+ const os = require('os');
19
+ const { execFile, spawn } = require('child_process');
16
20
  const { promisify } = require('util');
17
-
18
21
  const execFileP = promisify(execFile);
19
- const argv = process.argv.slice(2);
20
22
 
21
- // --------- CLI helpers ----------
23
+ // -------------------- CLI / CONFIG --------------------
24
+ const argv = process.argv.slice(2);
22
25
  function getArg(key, def) {
23
26
  for (let i = 0; i < argv.length; i++) {
24
27
  const a = argv[i];
@@ -27,9 +30,8 @@ function getArg(key, def) {
27
30
  }
28
31
  return def;
29
32
  }
30
-
31
33
  if (argv.includes('--help') || argv.includes('-h')) {
32
- console.log('Fail2Scan daemon\n--log PATH (default /var/log/fail2ban.log)\n--out PATH (default /var/log/fail2scan)\n--concurrency N (default 1)\n--nmap-args "args" (default "-sS -Pn -p- -T4 -sV")\n--quiet');
34
+ console.log('Fail2Scan daemon\n--log PATH (default /var/log/fail2ban.log)\n--out PATH (default /var/log/fail2scan)\n--concurrency N (default 1)\n--nmap-args "args" (default "-sS -Pn -p- -T4 -sV")\n--scan-ip IP (do one scan and exit)\n--quiet');
33
35
  process.exit(0);
34
36
  }
35
37
 
@@ -37,18 +39,22 @@ const LOG_PATH = getArg('--log', '/var/log/fail2ban.log');
37
39
  const OUT_ROOT = getArg('--out', '/var/log/fail2scan');
38
40
  const CONCURRENCY = Math.max(1, parseInt(getArg('--concurrency', '1'), 10) || 1);
39
41
  const NMAP_ARGS_STR = getArg('--nmap-args', '-sS -Pn -p- -T4 -sV');
42
+ const SINGLE_IP = getArg('--scan-ip', null);
40
43
  const QUIET = argv.includes('--quiet');
41
44
 
42
- function log(...args) { if (!QUIET) console.log(new Date().toISOString(), ...args); }
45
+ function log(...args) { if (!QUIET) console.log(new Date().toISOString(), ...args); appendLog(args.join(' ')); }
43
46
 
44
- // --------- Utilities ----------
45
- function safeMkdirSync(p) { fs.mkdirSync(p, { recursive: true, mode: 0o750 }); }
46
-
47
- async function which(bin) {
48
- try { await execFileP('which', [bin]); return true; } catch { return false; }
47
+ // -------------------- small logger to file --------------------
48
+ const STATE_FILE = path.join(os.homedir(), '.fail2scan_state.json');
49
+ const LOG_FILE = path.join(os.homedir(), '.fail2scan.log');
50
+ function appendLog(msg) {
51
+ try { fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + msg + '\n'); } catch (e) {}
49
52
  }
50
53
 
51
- async function runCmd(cmd, args, opts = {}) {
54
+ // -------------------- utilities --------------------
55
+ function sanitizeFilename(s) { return String(s).replace(/[:\/\\<>?"|* ]+/g, '_'); }
56
+ async function which(bin) { try { await execFileP('which', [bin]); return true; } catch { return false; } }
57
+ async function runCmdCapture(cmd, args, opts = {}) {
52
58
  try {
53
59
  const { stdout, stderr } = await execFileP(cmd, args, { maxBuffer: 1024 * 1024 * 32, ...opts });
54
60
  return { ok: true, stdout: stdout || '', stderr: stderr || '' };
@@ -56,159 +62,282 @@ async function runCmd(cmd, args, opts = {}) {
56
62
  return { ok: false, stdout: (e.stdout || '') + '', stderr: (e.stderr || e.message) + '' };
57
63
  }
58
64
  }
65
+ function safeMkdirSyncWithFallback(p) {
66
+ try { fs.mkdirSync(p, { recursive: true, mode: 0o750 }); return p; }
67
+ catch (e) {
68
+ const fallback = path.join('/tmp', 'fail2scan');
69
+ try { fs.mkdirSync(fallback, { recursive: true, mode: 0o750 }); return fallback; }
70
+ catch (ee) { throw e; }
71
+ }
72
+ }
59
73
 
60
- function sanitizeFilename(s) { return String(s).replace(/[:\/\\<>?"|* ]+/g, '_'); }
61
-
62
- // --------- Prerequisites check ----------
63
- (async function checkPrereqs() {
64
- const tools = ['nmap', 'dig', 'whois'];
74
+ // -------------------- prerequisites --------------------
75
+ (async function checkTools() {
76
+ const tools = ['nmap', 'dig', 'whois', 'which'];
65
77
  for (const t of tools) {
78
+ if (t === 'which') continue;
66
79
  if (!(await which(t))) {
67
- console.error(`Missing required binary: ${t}. Install it (eg: apt install ${t}).`);
80
+ console.error(`Missing required binary: ${t}. Please install it (eg: apt install ${t}).`);
68
81
  process.exit(2);
69
82
  }
70
83
  }
71
84
  })().catch(e => { console.error('Prereq check failed', e); process.exit(2); });
72
85
 
73
- // --------- File tail (handles rotation) ----------
74
- class FileTail {
75
- constructor(filePath, onLine) {
76
- this.filePath = filePath;
77
- this.onLine = onLine;
78
- this.position = 0;
79
- this.inode = null;
80
- this.buffer = '';
81
- this.watcher = null;
82
- this.start().catch(err => { console.error('Tail start error', err); process.exit(1); });
83
- }
84
-
85
- async start() {
86
- try { const st = fs.statSync(this.filePath); this.inode = st.ino; this.position = st.size; } catch (e) { this.inode = null; this.position = 0; }
87
- this._watchFile();
88
- await this._readNew();
89
- }
90
-
91
- _watchFile() {
92
- try {
93
- this.watcher = fs.watch(this.filePath, { persistent: true }, async () => {
94
- try {
95
- let st; try { st = fs.statSync(this.filePath); } catch { st = null; }
96
- if (!st) { this.inode = null; this.position = 0; return; }
97
- if (this.inode !== null && st.ino !== this.inode) { this.inode = st.ino; this.position = 0; }
98
- else if (this.inode === null) { this.inode = st.ino; this.position = 0; }
99
- await this._readNew();
100
- } catch (err) {}
101
- });
102
- } catch (e) { console.error('fs.watch failed:', e.message); process.exit(1); }
103
- }
104
-
105
- async _readNew() {
106
- try {
107
- const st = fs.statSync(this.filePath);
108
- if (st.size < this.position) this.position = 0;
109
- if (st.size === this.position) return;
110
- const stream = fs.createReadStream(this.filePath, { start: this.position, end: st.size - 1, encoding: 'utf8' });
111
- for await (const chunk of stream) {
112
- this.buffer += chunk;
113
- let idx;
114
- while ((idx = this.buffer.indexOf('\n')) >= 0) {
115
- const line = this.buffer.slice(0, idx);
116
- this.buffer = this.buffer.slice(idx + 1);
117
- if (line.trim()) this.onLine(line);
118
- }
119
- }
120
- this.position = st.size;
121
- } catch (e) {}
122
- }
123
-
124
- close() { try { if (this.watcher) this.watcher.close(); } catch (e) {} }
86
+ // -------------------- persistence state --------------------
87
+ function loadState() {
88
+ try {
89
+ if (fs.existsSync(STATE_FILE)) {
90
+ const raw = fs.readFileSync(STATE_FILE, 'utf8');
91
+ const j = JSON.parse(raw);
92
+ return {
93
+ seen: new Set(Array.isArray(j.seen) ? j.seen : []),
94
+ retryAfter: typeof j.retryAfter === 'object' ? j.retryAfter : {}
95
+ };
96
+ }
97
+ } catch (e) {}
98
+ return { seen: new Set(), retryAfter: {} };
125
99
  }
126
-
127
- // --------- Scan queue ----------
128
- class ScanQueue {
129
- constructor(concurrency = 1) { this.concurrency = concurrency; this.running = 0; this.queue = []; this.set = new Set(); }
130
- push(ip) {
131
- if (this.set.has(ip)) { log('IP already queued or running:', ip); return; }
132
- this.set.add(ip); this.queue.push(ip); this._next();
133
- }
134
- _next() {
135
- if (this.running >= this.concurrency) return;
136
- const ip = this.queue.shift();
137
- if (!ip) return;
138
- this.running++;
139
- this._run(ip).finally(() => { this.running--; this.set.delete(ip); setImmediate(() => this._next()); });
140
- }
141
- async _run(ip) {
142
- try { log('Scanning', ip); await performScan(ip); log('Done', ip); } catch (e) { console.error('Error scanning', ip, e); }
143
- }
100
+ function saveState(state) {
101
+ try {
102
+ const obj = { seen: Array.from(state.seen || []), retryAfter: state.retryAfter || {} };
103
+ const dir = path.dirname(STATE_FILE);
104
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (e) {}
105
+ fs.writeFileSync(STATE_FILE, JSON.stringify(obj, null, 2));
106
+ } catch (e) {}
144
107
  }
108
+ const STATE = loadState();
109
+ const RESCAN_TTL_SEC = 60 * 60; // default 1 hour
145
110
 
111
+ // -------------------- IP extraction --------------------
146
112
  function extractIpFromLine(line) {
113
+ // prefer strict IPv4
147
114
  const ipv4 = line.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
148
115
  if (ipv4 && ipv4[0]) return ipv4[0];
116
+ // simple IPv6 match
149
117
  const ipv6 = line.match(/\b([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\b/);
150
118
  if (ipv6 && ipv6[0]) return ipv6[0];
151
119
  return null;
152
120
  }
153
121
 
154
- // --------- Scan logic ----------
122
+ // -------------------- Scan implementation --------------------
123
+ function spawnNmap(ip, outDir, nmapArgs) {
124
+ return new Promise((resolve, reject) => {
125
+ const outNmap = path.join(outDir, 'nmap.txt');
126
+ const args = nmapArgs.concat([ip]);
127
+ const proc = spawn('nmap', args, { stdio: ['ignore', 'pipe', 'pipe'] });
128
+ const outStream = fs.createWriteStream(outNmap, { flags: 'w' });
129
+ proc.stdout.pipe(outStream);
130
+ let stderr = '';
131
+ proc.stderr.on('data', c => { stderr += c.toString(); });
132
+ proc.on('close', code => {
133
+ if (stderr) fs.appendFileSync(outNmap, '\n\nSTDERR:\n' + stderr);
134
+ resolve({ code, ok: code === 0 });
135
+ });
136
+ proc.on('error', err => reject(err));
137
+ });
138
+ }
139
+
155
140
  async function performScan(ip) {
156
141
  const now = new Date();
157
- const iso = now.toISOString().replace(/\..+$/, '').replace(/:/g, '-');
158
142
  const dateDir = now.toISOString().slice(0, 10);
159
143
  const safeIp = sanitizeFilename(ip);
160
- const outDir = path.join(OUT_ROOT, dateDir, `${safeIp}_${iso}`);
161
- try { safeMkdirSync(outDir); } catch (e) { throw new Error('Cannot create outDir: ' + e.message); }
144
+ let outDir = path.join(OUT_ROOT, dateDir, `${safeIp}_${now.toISOString().replace(/[:.]/g, '-')}`);
145
+ try {
146
+ outDir = path.join(safeMkdirSyncWithFallback(path.dirname(outDir)), path.basename(outDir));
147
+ fs.mkdirSync(outDir, { recursive: true, mode: 0o750 });
148
+ } catch (e) {
149
+ log('Cannot create out dir for', ip, '-', e.message);
150
+ // schedule quick retry and exit
151
+ STATE.retryAfter[ip] = Math.floor(Date.now() / 1000) + 60;
152
+ saveState(STATE);
153
+ return;
154
+ }
162
155
 
163
- const summary = { ip, timestamp: now.toISOString(), commands: {} };
156
+ const summary = { ip, ts: now.toISOString(), cmds: {} };
164
157
 
165
- // Nmap
166
- const requestedArgs = NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
167
- let nmapArgsBase = requestedArgs.slice();
158
+ // choose nmap args and adapt if not root
159
+ const requested = NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
168
160
  const isRoot = (typeof process.getuid === 'function' && process.getuid() === 0);
169
- if (!isRoot) { nmapArgsBase = nmapArgsBase.map(a => a === '-sS' ? '-sT' : a); }
170
- const nmapArgs = nmapArgsBase.concat([ip]);
171
- summary.commands.nmap = { args: nmapArgs.join(' ') };
172
- const nmapRes = await runCmd('nmap', nmapArgs);
173
- fs.writeFileSync(path.join(outDir, 'nmap.txt'), (nmapRes.stdout || '') + (nmapRes.stderr ? '\n\nSTDERR:\n' + nmapRes.stderr : ''));
174
- summary.commands.nmap.result = { ok: nmapRes.ok, path: 'nmap.txt' };
175
-
176
- // Dig
177
- const digRes = await runCmd('dig', ['-x', ip, '+short']);
178
- fs.writeFileSync(path.join(outDir, 'dig.txt'), (digRes.stdout || '') + (digRes.stderr ? '\n\nSTDERR:\n' + digRes.stderr : ''));
179
- summary.commands.dig = { ok: digRes.ok, path: 'dig.txt', reverse: (digRes.stdout || '').trim() };
180
-
181
- // Whois
182
- const whoisRes = await runCmd('whois', [ip]);
183
- fs.writeFileSync(path.join(outDir, 'whois.txt'), (whoisRes.stdout || '') + (whoisRes.stderr ? '\n\nSTDERR:\n' + whoisRes.stderr : ''));
184
- summary.commands.whois = { ok: whoisRes.ok, path: 'whois.txt' };
185
-
186
- // Open ports from Nmap
161
+ const nmapArgs = requested.map(a => (!isRoot && a === '-sS') ? '-sT' : a);
162
+
163
+ try {
164
+ log('Running nmap on', ip, 'args:', nmapArgs.join(' '));
165
+ await spawnNmap(ip, outDir, nmapArgs);
166
+ summary.cmds.nmap = { ok: true, args: nmapArgs.join(' '), path: 'nmap.txt' };
167
+ } catch (e) {
168
+ log('nmap failed for', ip, e.message);
169
+ summary.cmds.nmap = { ok: false, err: e.message };
170
+ }
171
+
172
+ // dig
173
+ try {
174
+ const dig = await runCmdCapture('dig', ['-x', ip, '+short']);
175
+ fs.writeFileSync(path.join(outDir, 'dig.txt'), (dig.stdout || '') + (dig.stderr ? '\n\nSTDERR:\n' + dig.stderr : ''));
176
+ summary.cmds.dig = { ok: dig.ok, path: 'dig.txt' };
177
+ } catch (e) { summary.cmds.dig = { ok: false, err: e.message }; }
178
+
179
+ // whois
187
180
  try {
188
- const openLines = (nmapRes.stdout || '').split(/\r?\n/).filter(l => /^\d+\/tcp\s+open/.test(l)).map(l => l.trim());
189
- summary.open_ports = openLines;
181
+ const who = await runCmdCapture('whois', [ip]);
182
+ fs.writeFileSync(path.join(outDir, 'whois.txt'), (who.stdout || '') + (who.stderr ? '\n\nSTDERR:\n' + who.stderr : ''));
183
+ summary.cmds.whois = { ok: who.ok, path: 'whois.txt' };
184
+ } catch (e) { summary.cmds.whois = { ok: false, err: e.message }; }
185
+
186
+ // minimal parse for open ports
187
+ try {
188
+ const nmapTxt = fs.readFileSync(path.join(outDir, 'nmap.txt'), 'utf8');
189
+ const open = nmapTxt.split(/\r?\n/).filter(l => /^\d+\/tcp\s+open/.test(l)).map(l => l.trim());
190
+ summary.open_ports = open;
190
191
  } catch (e) { summary.open_ports = []; }
191
192
 
192
193
  fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
193
194
  try { fs.chmodSync(outDir, 0o750); } catch (e) {}
195
+ log('Scan written for', ip, '->', outDir);
194
196
  }
195
197
 
196
- // --------- Start daemon ----------
197
- const queue = new ScanQueue(CONCURRENCY);
198
- log('Fail2Scan daemon started on', LOG_PATH, 'output ->', OUT_ROOT, 'concurrency', CONCURRENCY);
198
+ // -------------------- Queue with concurrency and TTL --------------------
199
+ class ScanQueue {
200
+ constructor(concurrency = 1) {
201
+ this.concurrency = concurrency;
202
+ this.running = 0;
203
+ this.q = [];
204
+ this.set = STATE.seen; // persistent set
205
+ }
206
+ push(ip) {
207
+ const now = Math.floor(Date.now() / 1000);
208
+ const next = STATE.retryAfter[ip] || 0;
209
+ if (this.set.has(ip) && next > now) {
210
+ log('IP already queued or running (TTL not expired):', ip);
211
+ return;
212
+ }
213
+ if (this.set.has(ip) && next <= now) {
214
+ log('Re-queueing after TTL:', ip);
215
+ this.set.delete(ip);
216
+ }
217
+ this.set.add(ip);
218
+ STATE.seen = this.set;
219
+ saveState(STATE);
220
+ this.q.push(ip);
221
+ this._next();
222
+ }
223
+ _next() {
224
+ if (this.running >= this.concurrency) return;
225
+ const ip = this.q.shift();
226
+ if (!ip) return;
227
+ this.running++;
228
+ (async () => {
229
+ try {
230
+ log('Scanning', ip);
231
+ await performScan(ip);
232
+ log('Done', ip);
233
+ } catch (e) {
234
+ log('Error scanning', ip, e && e.message ? e.message : e);
235
+ } finally {
236
+ STATE.retryAfter[ip] = Math.floor(Date.now() / 1000) + RESCAN_TTL_SEC;
237
+ saveState(STATE);
238
+ this.set.delete(ip); // allow future re-queue after TTL (state still records retryAfter)
239
+ this.running--;
240
+ setImmediate(() => this._next());
241
+ }
242
+ })();
243
+ }
244
+ }
199
245
 
246
+ // -------------------- File tail (watch + read new lines, handle rotation) --------------------
247
+ class FileTail {
248
+ constructor(filePath, onLine) {
249
+ this.filePath = filePath;
250
+ this.onLine = onLine;
251
+ this.pos = 0;
252
+ this.inode = null;
253
+ this.buf = '';
254
+ this.watch = null;
255
+ this.start();
256
+ }
257
+ start() {
258
+ try {
259
+ const st = fs.statSync(this.filePath);
260
+ this.inode = st.ino;
261
+ this.pos = st.size;
262
+ } catch (e) {
263
+ this.inode = null;
264
+ this.pos = 0;
265
+ }
266
+ this._watch();
267
+ // try initial read (if file exists and appended)
268
+ this._readNew().catch(()=>{});
269
+ }
270
+ _watch() {
271
+ try {
272
+ this.watch = fs.watch(this.filePath, { persistent: true }, async () => {
273
+ try {
274
+ let st;
275
+ try { st = fs.statSync(this.filePath); } catch { st = null; }
276
+ if (!st) { this.inode = null; this.pos = 0; return; }
277
+ if (this.inode !== null && st.ino !== this.inode) { // rotated
278
+ this.inode = st.ino;
279
+ this.pos = 0;
280
+ } else if (this.inode === null) {
281
+ this.inode = st.ino;
282
+ this.pos = 0;
283
+ }
284
+ await this._readNew();
285
+ } catch (e) { /* ignore transient */ }
286
+ });
287
+ } catch (e) { log('fs.watch failed:', e.message); }
288
+ }
289
+ async _readNew() {
290
+ try {
291
+ const st = fs.statSync(this.filePath);
292
+ if (st.size < this.pos) this.pos = 0;
293
+ if (st.size === this.pos) return;
294
+ const stream = fs.createReadStream(this.filePath, { start: this.pos, end: st.size - 1, encoding: 'utf8' });
295
+ for await (const chunk of stream) {
296
+ this.buf += chunk;
297
+ let idx;
298
+ while ((idx = this.buf.indexOf('\n')) >= 0) {
299
+ const line = this.buf.slice(0, idx);
300
+ this.buf = this.buf.slice(idx + 1);
301
+ if (line.trim()) this.onLine(line);
302
+ }
303
+ }
304
+ this.pos = st.size;
305
+ } catch (e) { /* ignore */ }
306
+ }
307
+ close() { try { if (this.watch) this.watch.close(); } catch (e) {} }
308
+ }
309
+
310
+ // -------------------- Main startup --------------------
311
+ if (SINGLE_IP) {
312
+ (async () => {
313
+ const q = new ScanQueue(CONCURRENCY);
314
+ q.push(SINGLE_IP);
315
+ })();
316
+ return;
317
+ }
318
+
319
+ const q = new ScanQueue(CONCURRENCY);
320
+ log('Fail2Scan started. Watching', LOG_PATH, ' -> output', OUT_ROOT, 'concurrency', CONCURRENCY);
321
+
322
+ // On each new line, extract IP and push to queue if Ban detected
323
+ const BAN_RE = /\bBan\b/i;
200
324
  const tail = new FileTail(LOG_PATH, (line) => {
201
- const ip = extractIpFromLine(line);
202
- if (ip) queue.push(ip);
325
+ try {
326
+ if (!BAN_RE.test(line)) return;
327
+ const ip = extractIpFromLine(line);
328
+ if (!ip) return;
329
+ q.push(ip);
330
+ } catch (e) { log('onLine handler error', e && e.message ? e.message : e); }
203
331
  });
204
332
 
205
- // --------- Graceful shutdown ----------
333
+ // graceful shutdown
206
334
  function shutdown() {
207
- log('Shutting down Fail2Scan daemon...');
335
+ log('Shutting down Fail2Scan...');
208
336
  tail.close();
337
+ // wait briefly for running tasks
209
338
  const start = Date.now();
210
339
  const wait = () => {
211
- if (queue.running === 0 || Date.now() - start > 10000) process.exit(0);
340
+ if (q.running === 0 || Date.now() - start > 10000) process.exit(0);
212
341
  setTimeout(wait, 500);
213
342
  };
214
343
  wait();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roflsec/fail2scan",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Fail2Scan daemon - watches fail2ban logs and scans banned IPs using nmap, dig and whois.",
5
5
  "bin": {
6
6
  "fail2scan-daemon": "./bin/daemon.js"