@roflsec/fail2scan 0.0.3 → 0.0.5

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/bin/daemon.js CHANGED
@@ -1,346 +1,200 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
-
4
- /**
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
11
- *
12
- * Usage:
13
- * fail2scan-daemon --log /var/log/fail2ban.log --out /var/log/fail2scan --concurrency 2 --nmap-args "-sS -Pn -p- -T4 -sV" --quiet
14
- */
15
-
16
- const fs = require('fs');
17
- const path = require('path');
18
- const os = require('os');
3
+ const fs = require('fs'), path = require('path'), os = require('os');
19
4
  const { execFile, spawn } = require('child_process');
20
5
  const { promisify } = require('util');
21
6
  const execFileP = promisify(execFile);
22
7
 
23
8
  // -------------------- CLI / CONFIG --------------------
24
9
  const argv = process.argv.slice(2);
25
- function getArg(key, def) {
26
- for (let i = 0; i < argv.length; i++) {
27
- const a = argv[i];
28
- if (a === key && argv[i + 1]) return argv[++i];
29
- if (a.startsWith(key + '=')) return a.split('=')[1];
30
- }
31
- return def;
32
- }
33
- if (argv.includes('--help') || argv.includes('-h')) {
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');
35
- process.exit(0);
36
- }
37
-
38
- const LOG_PATH = getArg('--log', '/var/log/fail2ban.log');
39
- const OUT_ROOT = getArg('--out', '/var/log/fail2scan');
40
- const CONCURRENCY = Math.max(1, parseInt(getArg('--concurrency', '1'), 10) || 1);
41
- const NMAP_ARGS_STR = getArg('--nmap-args', '-sS -Pn -p- -T4 -sV');
42
- const SINGLE_IP = getArg('--scan-ip', null);
10
+ const getArg = (k,d) => { for(let i=0;i<argv.length;i++){const a=argv[i]; if(a===k&&argv[i+1]) return argv[++i]; if(a.startsWith(k+'=')) return a.split('=')[1]; } return d; };
11
+ if(argv.includes('--help')||argv.includes('-h')){console.log(`Fail2Scan optimized daemon
12
+ --log PATH (default /var/log/fail2ban.log)
13
+ --out PATH (default /var/log/fail2scan)
14
+ --concurrency N (default 1)
15
+ --cores N (override concurrency with CPU cores)
16
+ --nmap-args "args" (default "-sS -Pn -p- -T4 -sV")
17
+ --scan-ip IP (do one scan and exit)
18
+ --quiet
19
+ `); process.exit(0); }
20
+
21
+ const LOG_PATH = getArg('--log','/var/log/fail2ban.log');
22
+ const OUT_ROOT = getArg('--out','/var/log/fail2scan');
23
+ const USER_CONCURRENCY = parseInt(getArg('--concurrency','0'),10)||0;
24
+ const CORE_OVERRIDE = parseInt(getArg('--cores','0'),10)||0;
25
+ const NMAP_ARGS_STR = getArg('--nmap-args','-sS -Pn -p- -T4 -sV');
26
+ const SINGLE_IP = getArg('--scan-ip',null);
43
27
  const QUIET = argv.includes('--quiet');
28
+ const RESCAN_TTL_SEC = 60*60;
29
+ const STATE_FILE = path.join(os.homedir(),'.fail2scan_state.json');
30
+ const LOG_FILE = path.join(os.homedir(),'.fail2scan.log');
44
31
 
45
- function log(...args) { if (!QUIET) console.log(new Date().toISOString(), ...args); appendLog(args.join(' ')); }
46
-
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) {}
52
- }
32
+ const log=(...a)=>{if(!QUIET)console.log(new Date().toISOString(),...a); try{fs.appendFileSync(LOG_FILE,new Date().toISOString()+' '+a.join(' ')+'\n');}catch{}};
53
33
 
54
34
  // -------------------- 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 = {}) {
58
- try {
59
- const { stdout, stderr } = await execFileP(cmd, args, { maxBuffer: 1024 * 1024 * 32, ...opts });
60
- return { ok: true, stdout: stdout || '', stderr: stderr || '' };
61
- } catch (e) {
62
- return { ok: false, stdout: (e.stdout || '') + '', stderr: (e.stderr || e.message) + '' };
63
- }
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
- }
35
+ const sanitizeFilename=s=>String(s).replace(/[:\/\\<>?"|* ]+/g,'_');
36
+ async function which(bin){try{await execFileP('which',[bin]);return true;}catch{return false;}}
37
+ async function runCmdCapture(cmd,args,opts={}){try{const {stdout,stderr}=await execFileP(cmd,args,{maxBuffer:1024*1024*32,...opts}); return {ok:true,stdout:stdout||'',stderr:stderr||''};}catch(e){return {ok:false,stdout:(e.stdout||'')+'',stderr:(e.stderr||e.message)+''};}}
38
+ function safeMkdirSyncWithFallback(p){try{return fs.mkdirSync(p,{recursive:true,mode:0o750})||p}catch(e){const f=path.join('/tmp','fail2scan');try{return fs.mkdirSync(f,{recursive:true,mode:0o750})||f}catch(ee){throw e;}}}
73
39
 
74
40
  // -------------------- prerequisites --------------------
75
- (async function checkTools() {
76
- const tools = ['nmap', 'dig', 'whois', 'which'];
77
- for (const t of tools) {
78
- if (t === 'which') continue;
79
- if (!(await which(t))) {
80
- console.error(`Missing required binary: ${t}. Please install it (eg: apt install ${t}).`);
81
- process.exit(2);
82
- }
83
- }
84
- })().catch(e => { console.error('Prereq check failed', e); process.exit(2); });
41
+ (async()=>{for(const t of ['nmap','dig','whois','which']){if(t==='which')continue;if(!(await which(t))){console.error(`Missing required binary: ${t}`);process.exit(2);}}})().catch(e=>{console.error('Prereq check failed',e);process.exit(2);});
85
42
 
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: {} };
99
- }
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) {}
107
- }
108
- const STATE = loadState();
109
- const RESCAN_TTL_SEC = 60 * 60; // default 1 hour
43
+ // -------------------- state --------------------
44
+ function loadState(){try{if(fs.existsSync(STATE_FILE)){const j=JSON.parse(fs.readFileSync(STATE_FILE,'utf8'));return{seen:new Set(Array.isArray(j.seen)?j.seen:[]),retryAfter:typeof j.retryAfter==='object'?j.retryAfter:{}};}}catch{}return{seen:new Set(),retryAfter:{}};}
45
+ function saveState(s){try{fs.mkdirSync(path.dirname(STATE_FILE),{recursive:true,mode:0o700});fs.writeFileSync(STATE_FILE,JSON.stringify({seen:Array.from(s.seen||[]),retryAfter:s.retryAfter||{}},null,2));}catch{}}
46
+ const STATE=loadState();
110
47
 
111
48
  // -------------------- IP extraction --------------------
112
- function extractIpFromLine(line) {
113
- // prefer strict IPv4
114
- const ipv4 = line.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
115
- if (ipv4 && ipv4[0]) return ipv4[0];
116
- // simple IPv6 match
117
- const ipv6 = line.match(/\b([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\b/);
118
- if (ipv6 && ipv6[0]) return ipv6[0];
119
- return null;
120
- }
49
+ function extractIpFromLine(line){const v4=line.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);if(v4&&v4[0])return v4[0];const v6=line.match(/\b([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\b/);if(v6&&v6[0])return v6[0];return null;}
121
50
 
122
- // -------------------- Scan implementation --------------------
123
- function spawnNmap(ip, outDir, nmapArgs) {
51
+ // -------------------- nmap helpers (parallel support) --------------------
52
+ function spawnOneNmap(args, outFile) {
124
53
  return new Promise((resolve, reject) => {
125
- const outNmap = path.join(outDir, 'nmap.txt');
126
- const args = nmapArgs.concat([ip]);
127
54
  const proc = spawn('nmap', args, { stdio: ['ignore', 'pipe', 'pipe'] });
128
- const outStream = fs.createWriteStream(outNmap, { flags: 'w' });
55
+ const outStream = fs.createWriteStream(outFile, { flags: 'w' });
129
56
  proc.stdout.pipe(outStream);
130
57
  let stderr = '';
131
58
  proc.stderr.on('data', c => { stderr += c.toString(); });
132
59
  proc.on('close', code => {
133
- if (stderr) fs.appendFileSync(outNmap, '\n\nSTDERR:\n' + stderr);
60
+ if (stderr) {
61
+ try { fs.appendFileSync(outFile, '\n\nSTDERR:\n' + stderr); } catch (e) {}
62
+ }
134
63
  resolve({ code, ok: code === 0 });
135
64
  });
136
65
  proc.on('error', err => reject(err));
137
66
  });
138
67
  }
139
68
 
140
- async function performScan(ip) {
141
- const now = new Date();
142
- const dateDir = now.toISOString().slice(0, 10);
143
- const safeIp = sanitizeFilename(ip);
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);
69
+ async function spawnNmapParallel(ip, outDir, requestedArgs, parts) {
70
+ // requestedArgs: array of args
71
+ // if -p- not present, run single nmap
72
+ if (!requestedArgs.includes('-p-')) {
73
+ const outNmap = path.join(outDir, 'nmap.txt');
74
+ await spawnOneNmap([...requestedArgs, ip], outNmap);
153
75
  return;
154
76
  }
155
77
 
156
- const summary = { ip, ts: now.toISOString(), cmds: {} };
78
+ // decide number of parts
79
+ const cpuCount = os.cpus() ? os.cpus().length : 1;
80
+ const numParts = Math.max(1, parts || CORE_OVERRIDE || cpuCount);
81
+
82
+ const portsPer = Math.floor(65535 / numParts);
83
+ const jobs = [];
84
+ for (let i = 0; i < numParts; i++) {
85
+ const start = 1 + i * portsPer;
86
+ const end = (i === numParts - 1) ? 65535 : ((i + 1) * portsPer);
87
+ const subdir = path.join(outDir, `part-${i}`);
88
+ try { fs.mkdirSync(subdir, { recursive: true, mode: 0o750 }); } catch (e) {}
89
+ const outNmap = path.join(subdir, 'nmap.txt');
90
+ const portArg = `-p${start}-${end}`;
91
+ // replace '-p-' with '-pstart-end' in requestedArgs
92
+ const args = requestedArgs.map(a => a === '-p-' ? portArg : a).concat([ip]);
93
+ jobs.push(spawnOneNmap(args, outNmap));
94
+ }
157
95
 
158
- // choose nmap args and adapt if not root
159
- const requested = NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
160
- const isRoot = (typeof process.getuid === 'function' && process.getuid() === 0);
161
- const nmapArgs = requested.map(a => (!isRoot && a === '-sS') ? '-sT' : a);
96
+ // await all parts
97
+ await Promise.all(jobs);
162
98
 
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 };
99
+ // merge outputs into single nmap.txt (preserve order)
100
+ const partsContent = [];
101
+ for (let i = 0; i < numParts; i++) {
102
+ const fn = path.join(outDir, `part-${i}`, 'nmap.txt');
103
+ try {
104
+ if (fs.existsSync(fn)) partsContent.push(fs.readFileSync(fn, 'utf8'));
105
+ } catch (e) {}
170
106
  }
107
+ try { fs.writeFileSync(path.join(outDir, 'nmap.txt'), partsContent.join('\n\n--- PART ---\n\n')); } catch (e) {}
108
+ }
171
109
 
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
180
- try {
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 }; }
110
+ // wrapper used by performScan
111
+ async function runNmap(ip, outDir, requestedArgs) {
112
+ // adapt -sS -> -sT if not root
113
+ const isRoot = (typeof process.getuid === 'function' && process.getuid() === 0);
114
+ const args = requestedArgs.map(a => (!isRoot && a === '-sS') ? '-sT' : a);
185
115
 
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;
191
- } catch (e) { summary.open_ports = []; }
116
+ // if user explicitly set a small host-timeout, keep it; otherwise let parallel jobs share same requested args
117
+ // decide parts from CORE_OVERRIDE or cpu
118
+ const cpuCount = os.cpus() ? os.cpus().length : 1;
119
+ const parts = CORE_OVERRIDE || cpuCount;
192
120
 
193
- fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
194
- try { fs.chmodSync(outDir, 0o750); } catch (e) {}
195
- log('Scan written for', ip, '->', outDir);
121
+ // if args include -p- -> use parallel split, else single
122
+ await spawnNmapParallel(ip, outDir, args, parts);
196
123
  }
197
124
 
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
125
+ // -------------------- scan --------------------
126
+ async function performScan(ip){
127
+ const now=new Date(),dateDir=now.toISOString().slice(0,10),safeIp=sanitizeFilename(ip);
128
+ let outDir=path.join(OUT_ROOT,dateDir,`${safeIp}_${now.toISOString().replace(/[:.]/g,'-')}`);
129
+ outDir=path.join(safeMkdirSyncWithFallback(path.dirname(outDir)),path.basename(outDir));
130
+ try { fs.mkdirSync(outDir,{recursive:true,mode:0o750}); } catch (e) {}
131
+ const summary={ip,ts:now.toISOString(),cmds:{}};
132
+
133
+ const requested=NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
134
+
135
+ try{
136
+ log('Running nmap on',ip,'args:',requested.join(' '));
137
+ await runNmap(ip, outDir, requested);
138
+ summary.cmds.nmap = { ok: true, args: requested.join(' '), path: 'nmap.txt' };
139
+ } catch(e){
140
+ log('nmap failed for',ip, e && e.message ? e.message : e);
141
+ summary.cmds.nmap = { ok: false, err: e && e.message ? e.message : String(e) };
205
142
  }
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();
143
+
144
+ try{ const dig = await runCmdCapture('dig',['-x',ip,'+short']); fs.writeFileSync(path.join(outDir,'dig.txt'),(dig.stdout||'') + (dig.stderr?'\n\nSTDERR:\n'+dig.stderr:'')); summary.cmds.dig = { ok: dig.ok, path: 'dig.txt' }; } catch(e){ summary.cmds.dig = { ok:false, err: e && e.message ? e.message : String(e) }; }
145
+ try{ const who = await runCmdCapture('whois',[ip]); fs.writeFileSync(path.join(outDir,'whois.txt'),(who.stdout||'') + (who.stderr?'\n\nSTDERR:\n'+who.stderr:'')); summary.cmds.whois = { ok: who.ok, path: 'whois.txt' }; } catch(e){ summary.cmds.whois = { ok:false, err: e && e.message ? e.message : String(e) }; }
146
+
147
+ try{ const nmapTxt = fs.readFileSync(path.join(outDir,'nmap.txt'),'utf8'); summary.open_ports = nmapTxt.split(/\r?\n/).filter(l=>/^\d+\/tcp\s+open/.test(l)).map(l=>l.trim()); } catch(e){ summary.open_ports = []; }
148
+
149
+ try{ fs.writeFileSync(path.join(outDir,'summary.json'),JSON.stringify(summary,null,2)); } catch (e) {}
150
+ try{ fs.chmodSync(outDir,0o750); } catch (e) {}
151
+ log('Scan written for',ip,'->',outDir);
152
+ }
153
+
154
+ // -------------------- queue optimized --------------------
155
+ class ScanQueue{
156
+ constructor(concurrency=1){this.concurrency=concurrency;this.running=0;this.q=[];this.set=STATE.seen;this.tmpCache=new Set();}
157
+ push(ip){
158
+ const now=Math.floor(Date.now()/1000),next=STATE.retryAfter[ip]||0;
159
+ if((this.set.has(ip)&&next>now)||this.tmpCache.has(ip)){log('IP already queued or running (TTL/cache):',ip);return;}
160
+ if(this.set.has(ip)&&next<=now)log('Re-queueing after TTL:',ip),this.set.delete(ip);
161
+ this.set.add(ip);STATE.seen=this.set;saveState(STATE);
162
+ this.q.push(ip);this.tmpCache.add(ip);this._next();
222
163
  }
223
- _next() {
224
- if (this.running >= this.concurrency) return;
225
- const ip = this.q.shift();
226
- if (!ip) return;
164
+ _next(){
165
+ if(this.running>=this.concurrency)return;
166
+ const ip=this.q.shift();if(!ip)return;
227
167
  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;
168
+ (async()=>{
169
+ try{log('Scanning',ip);await performScan(ip);log('Done',ip);}
170
+ catch(e){log('Error scanning',ip,e.message||e);}
171
+ finally{
172
+ STATE.retryAfter[ip]=Math.floor(Date.now()/1000)+RESCAN_TTL_SEC;
237
173
  saveState(STATE);
238
- this.set.delete(ip); // allow future re-queue after TTL (state still records retryAfter)
239
- this.running--;
240
- setImmediate(() => this._next());
174
+ this.set.delete(ip);this.tmpCache.delete(ip);
175
+ this.running--;setImmediate(()=>this._next());
241
176
  }
242
177
  })();
178
+ setImmediate(()=>this._next());
243
179
  }
244
180
  }
245
181
 
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);
182
+ // -------------------- main --------------------
183
+ if(SINGLE_IP){(async()=>{const q=new ScanQueue(CORE_OVERRIDE||USER_CONCURRENCY||1);q.push(SINGLE_IP);})();return;}
184
+ const concurrency = CORE_OVERRIDE||USER_CONCURRENCY||os.cpus().length||1;
185
+ const q = new ScanQueue(concurrency);
186
+ log(`Fail2Scan started. Watching ${LOG_PATH} -> output ${OUT_ROOT}, concurrency ${concurrency}`);
321
187
 
322
- // On each new line, extract IP and push to queue if Ban detected
323
188
  const BAN_RE = /\bBan\b/i;
324
- const tail = new FileTail(LOG_PATH, (line) => {
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); }
331
- });
332
-
333
- // graceful shutdown
334
- function shutdown() {
335
- log('Shutting down Fail2Scan...');
336
- tail.close();
337
- // wait briefly for running tasks
338
- const start = Date.now();
339
- const wait = () => {
340
- if (q.running === 0 || Date.now() - start > 10000) process.exit(0);
341
- setTimeout(wait, 500);
342
- };
343
- wait();
189
+ class FileTail{
190
+ constructor(filePath,onLine){this.filePath=filePath;this.onLine=onLine;this.pos=0;this.inode=null;this.buf='';this.watch=null;this.start();}
191
+ start(){try{const st=fs.statSync(this.filePath);this.inode=st.ino;this.pos=st.size;}catch{this.inode=null;this.pos=0;}this._watch();this._readNew().catch(()=>{});}
192
+ _watch(){try{this.watch=fs.watch(this.filePath,{persistent:true},async()=>{try{const st=fs.statSync(this.filePath);if(!st){this.inode=null;this.pos=0;return;}if(this.inode!==null&&st.ino!==this.inode)this.inode=st.ino,this.pos=0;else if(this.inode===null)this.inode=st.ino,this.pos=0;await this._readNew();}catch{}});}catch(e){log('fs.watch failed:',e.message);}}
193
+ async _readNew(){try{const st=fs.statSync(this.filePath);if(st.size<this.pos)this.pos=0;if(st.size===this.pos)return;const stream=fs.createReadStream(this.filePath,{start:this.pos,end:st.size-1,encoding:'utf8'});for await(const chunk of stream){this.buf+=chunk;let idx;while((idx=this.buf.indexOf('\n'))>=0){const line=this.buf.slice(0,idx);this.buf=this.buf.slice(idx+1);if(line.trim())this.onLine(line);}}this.pos=st.size;}catch{}}
194
+ close(){try{this.watch?.close();}catch{}}
344
195
  }
345
- process.on('SIGINT', shutdown);
346
- process.on('SIGTERM', shutdown);
196
+ const tail=new FileTail(LOG_PATH,line=>{try{if(!BAN_RE.test(line))return;const ip=extractIpFromLine(line);if(!ip)return;q.push(ip);}catch(e){log('onLine handler error',e.message||e);}});
197
+
198
+ function shutdown(){log('Shutting down Fail2Scan...');tail.close();const start=Date.now();const wait=()=>{if(q.running===0||Date.now()-start>10000)process.exit(0);setTimeout(wait,500);};wait();}
199
+ process.on('SIGINT',shutdown);
200
+ process.on('SIGTERM',shutdown);
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
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
11
+ *
12
+ * Usage:
13
+ * fail2scan-daemon --log /var/log/fail2ban.log --out /var/log/fail2scan --concurrency 2 --nmap-args "-sS -Pn -p- -T4 -sV" --quiet
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const { execFile, spawn } = require('child_process');
20
+ const { promisify } = require('util');
21
+ const execFileP = promisify(execFile);
22
+
23
+ // -------------------- CLI / CONFIG --------------------
24
+ const argv = process.argv.slice(2);
25
+ function getArg(key, def) {
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === key && argv[i + 1]) return argv[++i];
29
+ if (a.startsWith(key + '=')) return a.split('=')[1];
30
+ }
31
+ return def;
32
+ }
33
+ if (argv.includes('--help') || argv.includes('-h')) {
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');
35
+ process.exit(0);
36
+ }
37
+
38
+ const LOG_PATH = getArg('--log', '/var/log/fail2ban.log');
39
+ const OUT_ROOT = getArg('--out', '/var/log/fail2scan');
40
+ const CONCURRENCY = Math.max(1, parseInt(getArg('--concurrency', '1'), 10) || 1);
41
+ const NMAP_ARGS_STR = getArg('--nmap-args', '-sS -Pn -p- -T4 -sV');
42
+ const SINGLE_IP = getArg('--scan-ip', null);
43
+ const QUIET = argv.includes('--quiet');
44
+
45
+ function log(...args) { if (!QUIET) console.log(new Date().toISOString(), ...args); appendLog(args.join(' ')); }
46
+
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) {}
52
+ }
53
+
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 = {}) {
58
+ try {
59
+ const { stdout, stderr } = await execFileP(cmd, args, { maxBuffer: 1024 * 1024 * 32, ...opts });
60
+ return { ok: true, stdout: stdout || '', stderr: stderr || '' };
61
+ } catch (e) {
62
+ return { ok: false, stdout: (e.stdout || '') + '', stderr: (e.stderr || e.message) + '' };
63
+ }
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
+ }
73
+
74
+ // -------------------- prerequisites --------------------
75
+ (async function checkTools() {
76
+ const tools = ['nmap', 'dig', 'whois', 'which'];
77
+ for (const t of tools) {
78
+ if (t === 'which') continue;
79
+ if (!(await which(t))) {
80
+ console.error(`Missing required binary: ${t}. Please install it (eg: apt install ${t}).`);
81
+ process.exit(2);
82
+ }
83
+ }
84
+ })().catch(e => { console.error('Prereq check failed', e); process.exit(2); });
85
+
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: {} };
99
+ }
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) {}
107
+ }
108
+ const STATE = loadState();
109
+ const RESCAN_TTL_SEC = 60 * 60; // default 1 hour
110
+
111
+ // -------------------- IP extraction --------------------
112
+ function extractIpFromLine(line) {
113
+ // prefer strict IPv4
114
+ const ipv4 = line.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/);
115
+ if (ipv4 && ipv4[0]) return ipv4[0];
116
+ // simple IPv6 match
117
+ const ipv6 = line.match(/\b([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}\b/);
118
+ if (ipv6 && ipv6[0]) return ipv6[0];
119
+ return null;
120
+ }
121
+
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
+
140
+ async function performScan(ip) {
141
+ const now = new Date();
142
+ const dateDir = now.toISOString().slice(0, 10);
143
+ const safeIp = sanitizeFilename(ip);
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
+ }
155
+
156
+ const summary = { ip, ts: now.toISOString(), cmds: {} };
157
+
158
+ // choose nmap args and adapt if not root
159
+ const requested = NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
160
+ const isRoot = (typeof process.getuid === 'function' && process.getuid() === 0);
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
180
+ try {
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;
191
+ } catch (e) { summary.open_ports = []; }
192
+
193
+ fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
194
+ try { fs.chmodSync(outDir, 0o750); } catch (e) {}
195
+ log('Scan written for', ip, '->', outDir);
196
+ }
197
+
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
+ }
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;
324
+ const tail = new FileTail(LOG_PATH, (line) => {
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); }
331
+ });
332
+
333
+ // graceful shutdown
334
+ function shutdown() {
335
+ log('Shutting down Fail2Scan...');
336
+ tail.close();
337
+ // wait briefly for running tasks
338
+ const start = Date.now();
339
+ const wait = () => {
340
+ if (q.running === 0 || Date.now() - start > 10000) process.exit(0);
341
+ setTimeout(wait, 500);
342
+ };
343
+ wait();
344
+ }
345
+ process.on('SIGINT', shutdown);
346
+ process.on('SIGTERM', shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roflsec/fail2scan",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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"