@roflsec/fail2scan 0.0.2 → 0.0.4
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 +102 -202
- package/bin/old.daemon.js +346 -0
- package/package.json +1 -1
package/bin/daemon.js
CHANGED
|
@@ -1,217 +1,117 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
|
|
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.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* fail2scan-daemon --log /var/log/fail2ban.log --out /var/log/fail2scan --concurrency 1 --nmap-args "-sS -Pn -p- -T4 -sV" --quiet
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const fs = require('fs');
|
|
14
|
-
const path = require('path');
|
|
15
|
-
const { execFile } = require('child_process');
|
|
3
|
+
const fs = require('fs'), path = require('path'), os = require('os');
|
|
4
|
+
const { execFile, spawn } = require('child_process');
|
|
16
5
|
const { promisify } = require('util');
|
|
17
|
-
|
|
18
6
|
const execFileP = promisify(execFile);
|
|
19
|
-
const argv = process.argv.slice(2);
|
|
20
7
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
8
|
+
// -------------------- CLI / CONFIG --------------------
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
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);
|
|
40
27
|
const QUIET = argv.includes('--quiet');
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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');
|
|
31
|
+
|
|
32
|
+
const log=(...a)=>{if(!QUIET)console.log(new Date().toISOString(),...a); try{fs.appendFileSync(LOG_FILE,new Date().toISOString()+' '+a.join(' ')+'\n');}catch{}};
|
|
33
|
+
|
|
34
|
+
// -------------------- utilities --------------------
|
|
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;}}}
|
|
39
|
+
|
|
40
|
+
// -------------------- prerequisites --------------------
|
|
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);});
|
|
42
|
+
|
|
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();
|
|
47
|
+
|
|
48
|
+
// -------------------- IP extraction --------------------
|
|
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;}
|
|
50
|
+
|
|
51
|
+
// -------------------- scan --------------------
|
|
52
|
+
function spawnNmap(ip,outDir,args){return new Promise((res,rej)=>{const outNmap=path.join(outDir,'nmap.txt');const proc=spawn('nmap',[...args,ip],{stdio:['ignore','pipe','pipe']});const outStream=fs.createWriteStream(outNmap,{flags:'w'});proc.stdout.pipe(outStream);let err='';proc.stderr.on('data',c=>{err+=c.toString();});proc.on('close',code=>{if(err)fs.appendFileSync(outNmap,'\n\nSTDERR:\n'+err);res({code,ok:code===0});});proc.on('error',e=>rej(e));});}
|
|
53
|
+
async function performScan(ip){
|
|
54
|
+
const now=new Date(),dateDir=now.toISOString().slice(0,10),safeIp=sanitizeFilename(ip);
|
|
55
|
+
let outDir=path.join(OUT_ROOT,dateDir,`${safeIp}_${now.toISOString().replace(/[:.]/g,'-')}`);
|
|
56
|
+
outDir=path.join(safeMkdirSyncWithFallback(path.dirname(outDir)),path.basename(outDir));
|
|
57
|
+
fs.mkdirSync(outDir,{recursive:true,mode:0o750});
|
|
58
|
+
const summary={ip,ts:now.toISOString(),cmds:{}};
|
|
59
|
+
const requested=NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
|
|
60
|
+
const isRoot=(typeof process.getuid==='function'&&process.getuid()===0);
|
|
61
|
+
const nmapArgs=requested.map(a=>(!isRoot&&a==='-sS')?'-sT':a);
|
|
62
|
+
try{log('Running nmap on',ip,'args:',nmapArgs.join(' '));await spawnNmap(ip,outDir,nmapArgs);summary.cmds.nmap={ok:true,args:nmapArgs.join(' '),path:'nmap.txt'}}catch(e){log('nmap failed for',ip,e.message);summary.cmds.nmap={ok:false,err:e.message};}
|
|
63
|
+
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.message};}
|
|
64
|
+
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.message};}
|
|
65
|
+
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=[];}
|
|
66
|
+
fs.writeFileSync(path.join(outDir,'summary.json'),JSON.stringify(summary,null,2));
|
|
67
|
+
try{fs.chmodSync(outDir,0o750);}catch{}
|
|
68
|
+
log('Scan written for',ip,'->',outDir);
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
71
|
+
// -------------------- queue optimized --------------------
|
|
72
|
+
class ScanQueue{
|
|
73
|
+
constructor(concurrency=1){this.concurrency=concurrency;this.running=0;this.q=[];this.set=STATE.seen;this.tmpCache=new Set();}
|
|
74
|
+
push(ip){
|
|
75
|
+
const now=Math.floor(Date.now()/1000),next=STATE.retryAfter[ip]||0;
|
|
76
|
+
if((this.set.has(ip)&&next>now)||this.tmpCache.has(ip)){log('IP already queued or running (TTL/cache):',ip);return;}
|
|
77
|
+
if(this.set.has(ip)&&next<=now)log('Re-queueing after TTL:',ip),this.set.delete(ip);
|
|
78
|
+
this.set.add(ip);STATE.seen=this.set;saveState(STATE);
|
|
79
|
+
this.q.push(ip);this.tmpCache.add(ip);this._next();
|
|
70
80
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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) {} }
|
|
125
|
-
}
|
|
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;
|
|
81
|
+
_next(){
|
|
82
|
+
if(this.running>=this.concurrency)return;
|
|
83
|
+
const ip=this.q.shift();if(!ip)return;
|
|
138
84
|
this.running++;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
85
|
+
(async()=>{
|
|
86
|
+
try{log('Scanning',ip);await performScan(ip);log('Done',ip);}
|
|
87
|
+
catch(e){log('Error scanning',ip,e.message||e);}
|
|
88
|
+
finally{
|
|
89
|
+
STATE.retryAfter[ip]=Math.floor(Date.now()/1000)+RESCAN_TTL_SEC;
|
|
90
|
+
saveState(STATE);
|
|
91
|
+
this.set.delete(ip);this.tmpCache.delete(ip);
|
|
92
|
+
this.running--;setImmediate(()=>this._next());
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
setImmediate(()=>this._next());
|
|
143
96
|
}
|
|
144
97
|
}
|
|
145
98
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
99
|
+
// -------------------- main --------------------
|
|
100
|
+
if(SINGLE_IP){(async()=>{const q=new ScanQueue(CORE_OVERRIDE||USER_CONCURRENCY||1);q.push(SINGLE_IP);})();return;}
|
|
101
|
+
const concurrency = CORE_OVERRIDE||USER_CONCURRENCY||os.cpus().length||1;
|
|
102
|
+
const q = new ScanQueue(concurrency);
|
|
103
|
+
log(`Fail2Scan started. Watching ${LOG_PATH} -> output ${OUT_ROOT}, concurrency ${concurrency}`);
|
|
104
|
+
|
|
105
|
+
const BAN_RE = /\bBan\b/i;
|
|
106
|
+
class FileTail{
|
|
107
|
+
constructor(filePath,onLine){this.filePath=filePath;this.onLine=onLine;this.pos=0;this.inode=null;this.buf='';this.watch=null;this.start();}
|
|
108
|
+
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(()=>{});}
|
|
109
|
+
_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);}}
|
|
110
|
+
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{}}
|
|
111
|
+
close(){try{this.watch?.close();}catch{}}
|
|
152
112
|
}
|
|
113
|
+
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);}});
|
|
153
114
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const iso = now.toISOString().replace(/\..+$/, '').replace(/:/g, '-');
|
|
158
|
-
const dateDir = now.toISOString().slice(0, 10);
|
|
159
|
-
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); }
|
|
162
|
-
|
|
163
|
-
const summary = { ip, timestamp: now.toISOString(), commands: {} };
|
|
164
|
-
|
|
165
|
-
// Nmap
|
|
166
|
-
const requestedArgs = NMAP_ARGS_STR.trim().split(/\s+/).filter(Boolean);
|
|
167
|
-
let nmapArgsBase = requestedArgs.slice();
|
|
168
|
-
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
|
|
187
|
-
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;
|
|
190
|
-
} catch (e) { summary.open_ports = []; }
|
|
191
|
-
|
|
192
|
-
fs.writeFileSync(path.join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
|
|
193
|
-
try { fs.chmodSync(outDir, 0o750); } catch (e) {}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// --------- Start daemon ----------
|
|
197
|
-
const queue = new ScanQueue(CONCURRENCY);
|
|
198
|
-
log('Fail2Scan daemon started on', LOG_PATH, 'output ->', OUT_ROOT, 'concurrency', CONCURRENCY);
|
|
199
|
-
|
|
200
|
-
const tail = new FileTail(LOG_PATH, (line) => {
|
|
201
|
-
const ip = extractIpFromLine(line);
|
|
202
|
-
if (ip) queue.push(ip);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// --------- Graceful shutdown ----------
|
|
206
|
-
function shutdown() {
|
|
207
|
-
log('Shutting down Fail2Scan daemon...');
|
|
208
|
-
tail.close();
|
|
209
|
-
const start = Date.now();
|
|
210
|
-
const wait = () => {
|
|
211
|
-
if (queue.running === 0 || Date.now() - start > 10000) process.exit(0);
|
|
212
|
-
setTimeout(wait, 500);
|
|
213
|
-
};
|
|
214
|
-
wait();
|
|
215
|
-
}
|
|
216
|
-
process.on('SIGINT', shutdown);
|
|
217
|
-
process.on('SIGTERM', shutdown);
|
|
115
|
+
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();}
|
|
116
|
+
process.on('SIGINT',shutdown);
|
|
117
|
+
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);
|