yusufkhon-guard 1.0.0
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/LICENSE +21 -0
- package/README.md +734 -0
- package/bin/cli.js +39 -0
- package/bin/renew.js +149 -0
- package/bin/scan.js +124 -0
- package/bin/ssl.js +181 -0
- package/package.json +80 -0
- package/src/index.js +206 -0
- package/src/modules/acme/client.js +240 -0
- package/src/modules/acme/csr.js +172 -0
- package/src/modules/acme/detectDomains.js +132 -0
- package/src/modules/acme/index.js +211 -0
- package/src/modules/acme/jose.js +125 -0
- package/src/modules/acme/renew.js +89 -0
- package/src/modules/adaptiveRateLimiter.js +154 -0
- package/src/modules/alerts.js +168 -0
- package/src/modules/bodyLimit.js +106 -0
- package/src/modules/botDetector.js +144 -0
- package/src/modules/connectionGuard.js +128 -0
- package/src/modules/csrf.js +130 -0
- package/src/modules/dashboard.js +257 -0
- package/src/modules/dashboardHtml.js +235 -0
- package/src/modules/datacenterRanges.js +121 -0
- package/src/modules/ddosGuard.js +323 -0
- package/src/modules/firewall.js +239 -0
- package/src/modules/geoBlock.js +169 -0
- package/src/modules/headers.js +130 -0
- package/src/modules/honeypot.js +149 -0
- package/src/modules/httpsRedirect.js +67 -0
- package/src/modules/integrity.js +164 -0
- package/src/modules/ipBlocker.js +372 -0
- package/src/modules/ja3.js +155 -0
- package/src/modules/logExport.js +134 -0
- package/src/modules/nosqlGuard.js +143 -0
- package/src/modules/osFirewall.js +182 -0
- package/src/modules/rateLimiter.js +196 -0
- package/src/modules/sanitizer.js +253 -0
- package/src/modules/threatDetector.js +202 -0
- package/src/scanner/checks/dependencies.js +125 -0
- package/src/scanner/checks/environment.js +118 -0
- package/src/scanner/checks/exposure.js +99 -0
- package/src/scanner/checks/headers.js +119 -0
- package/src/scanner/checks/ssl.js +151 -0
- package/src/scanner/index.js +209 -0
- package/src/utils/cidr.js +140 -0
- package/src/utils/logger.js +194 -0
- package/src/utils/privilege.js +212 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file dashboardHtml.js
|
|
5
|
+
* @description Admin-panelning o'zi bilan to'liq (self-contained) HTML sahifasini
|
|
6
|
+
* generatsiya qiladi. Barcha CSS/JS ichkariga joylashtirilgan —
|
|
7
|
+
* tashqi manba yuklanmaydi. Real vaqt yangilanishi EventSource (SSE)
|
|
8
|
+
* orqali amalga oshiriladi.
|
|
9
|
+
* @module modules/dashboardHtml
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTML atributiga xavfsiz joylash uchun qatorni ekranlaydi.
|
|
14
|
+
* @private
|
|
15
|
+
* @param {string} value
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function esc(value) {
|
|
19
|
+
return String(value).replace(/[&<>"']/g, (c) => (
|
|
20
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
21
|
+
));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Admin-panel HTML sahifasini qaytaradi.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} params
|
|
28
|
+
* @param {string} params.title - Panel sarlavhasi.
|
|
29
|
+
* @param {string} params.token - Kirish tokeni (authed bo'lsa) — JS'ga joylashtiriladi.
|
|
30
|
+
* @param {boolean} params.authed - Foydalanuvchi tasdiqlanganmi.
|
|
31
|
+
* @returns {string} To'liq HTML hujjat.
|
|
32
|
+
*/
|
|
33
|
+
function renderDashboard({ title, token, authed }) {
|
|
34
|
+
const safeTitle = esc(title);
|
|
35
|
+
|
|
36
|
+
// Token noto'g'ri / yo'q — kirish formasi ko'rsatiladi.
|
|
37
|
+
if (!authed) {
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<html lang="uz"><head><meta charset="utf-8">
|
|
40
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
41
|
+
<title>${safeTitle} — Kirish</title>
|
|
42
|
+
<style>${baseCss()}
|
|
43
|
+
.login{max-width:360px;margin:12vh auto;padding:32px;background:var(--card);border:1px solid var(--border);border-radius:16px}
|
|
44
|
+
.login h1{font-size:18px;margin:0 0 4px}.login p{color:var(--muted);font-size:13px;margin:0 0 20px}
|
|
45
|
+
.login input{width:100%;padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:var(--bg);color:var(--fg);font-size:14px;box-sizing:border-box}
|
|
46
|
+
.login button{width:100%;margin-top:12px;padding:12px;border:0;border-radius:10px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;font-size:14px}
|
|
47
|
+
</style></head><body>
|
|
48
|
+
<div class="login">
|
|
49
|
+
<h1>🛡️ ${safeTitle}</h1>
|
|
50
|
+
<p>Admin-panelga kirish uchun tokenni kiriting.</p>
|
|
51
|
+
<form onsubmit="location.search='?token='+encodeURIComponent(document.getElementById('t').value);return false;">
|
|
52
|
+
<input id="t" type="password" placeholder="Kirish tokeni" autofocus>
|
|
53
|
+
<button type="submit">Kirish</button>
|
|
54
|
+
</form>
|
|
55
|
+
</div></body></html>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Tasdiqlangan — to'liq panel.
|
|
59
|
+
return `<!doctype html>
|
|
60
|
+
<html lang="uz"><head><meta charset="utf-8">
|
|
61
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
62
|
+
<title>${safeTitle}</title>
|
|
63
|
+
<style>${baseCss()}${panelCss()}</style></head>
|
|
64
|
+
<body>
|
|
65
|
+
<header class="top">
|
|
66
|
+
<div class="brand">🛡️ <span>${safeTitle}</span></div>
|
|
67
|
+
<div class="conn"><span id="dot" class="dot"></span><span id="connText">ulanmoqda…</span></div>
|
|
68
|
+
</header>
|
|
69
|
+
|
|
70
|
+
<main class="wrap">
|
|
71
|
+
<section class="cards">
|
|
72
|
+
<div class="card"><div class="k">So'rovlar</div><div class="v" id="mReq">0</div></div>
|
|
73
|
+
<div class="card warn"><div class="k">Tahdidlar</div><div class="v" id="mThreat">0</div></div>
|
|
74
|
+
<div class="card bad"><div class="k">Bloklashlar</div><div class="v" id="mBans">0</div></div>
|
|
75
|
+
<div class="card"><div class="k">Hozir bloklangan</div><div class="v" id="mActive">0</div></div>
|
|
76
|
+
<div class="card"><div class="k">Ishlash vaqti</div><div class="v" id="mUptime">—</div></div>
|
|
77
|
+
</section>
|
|
78
|
+
|
|
79
|
+
<section class="grid">
|
|
80
|
+
<div class="panel">
|
|
81
|
+
<div class="phead"><h2>⚡ Jonli hodisalar</h2><span class="hint" id="feedCount">0</span></div>
|
|
82
|
+
<div class="feed" id="feed"></div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<div class="panel">
|
|
86
|
+
<div class="phead"><h2>🚫 Bloklangan IP'lar</h2></div>
|
|
87
|
+
<div class="blockbar">
|
|
88
|
+
<input id="ipInput" placeholder="IP manzil (masalan 66.66.66.66)">
|
|
89
|
+
<button class="btn block" onclick="manualBlock()">Bloklash</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="tablewrap">
|
|
92
|
+
<table><thead><tr><th>IP</th><th>Sabab</th><th>Qolgan</th><th></th></tr></thead>
|
|
93
|
+
<tbody id="bans"><tr><td colspan="4" class="empty">yuklanmoqda…</td></tr></tbody></table>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
</main>
|
|
98
|
+
|
|
99
|
+
<script>
|
|
100
|
+
const TOKEN = ${JSON.stringify(token)};
|
|
101
|
+
const BASE = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/';
|
|
102
|
+
const q = (p) => BASE + p + (p.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
|
|
103
|
+
const $ = (id) => document.getElementById(id);
|
|
104
|
+
let feedN = 0;
|
|
105
|
+
|
|
106
|
+
const SEV = { critical:'#ef4444', high:'#f97316', medium:'#eab308', low:'#3b82f6', safe:'#22c55e' };
|
|
107
|
+
const LABEL = { ban:'BLOK', blocked:'TO\\'SILDI', threat:'TAHDID', tick:'', hello:'' };
|
|
108
|
+
|
|
109
|
+
function fmtUptime(s){ if(s==null)return '—'; const h=Math.floor(s/3600),m=Math.floor(s%3600/60),ss=s%60;
|
|
110
|
+
return (h?h+'s ':'')+(m?m+'d ':'')+ss+'soniya'; }
|
|
111
|
+
|
|
112
|
+
function applyStats(st){
|
|
113
|
+
if(!st) return;
|
|
114
|
+
$('mReq').textContent = st.requests ?? st.metrics?.requests ?? $('mReq').textContent;
|
|
115
|
+
$('mThreat').textContent = st.threats ?? st.metrics?.threats ?? $('mThreat').textContent;
|
|
116
|
+
$('mBans').textContent = st.bans ?? st.metrics?.bans ?? $('mBans').textContent;
|
|
117
|
+
const active = st.banned ?? st.store?.banned;
|
|
118
|
+
if(active!=null) $('mActive').textContent = active;
|
|
119
|
+
if(st.uptimeSec!=null) $('mUptime').textContent = fmtUptime(st.uptimeSec);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadStats(){
|
|
123
|
+
try{ const r=await fetch(q('api/stats')); const d=await r.json();
|
|
124
|
+
applyStats({ ...d.metrics, ...d.store, uptimeSec:d.uptimeSec }); }catch(e){}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function loadBans(){
|
|
128
|
+
try{
|
|
129
|
+
const r=await fetch(q('api/bans')); const d=await r.json();
|
|
130
|
+
const tb=$('bans'); tb.innerHTML='';
|
|
131
|
+
if(!d.banned||!d.banned.length){ tb.innerHTML='<tr><td colspan="4" class="empty">Bloklangan IP yo\\'q ✅</td></tr>'; return; }
|
|
132
|
+
for(const b of d.banned){
|
|
133
|
+
const left = b.secondsLeft===-1 ? 'doimiy' : b.secondsLeft+'s';
|
|
134
|
+
const tr=document.createElement('tr');
|
|
135
|
+
tr.innerHTML='<td class="mono">'+esc(b.ip)+'</td><td>'+esc(b.reason||'')+'</td><td>'+left+
|
|
136
|
+
'</td><td><button class="btn small" onclick="unban(\\''+esc(b.ip)+'\\')">Chiqarish</button></td>';
|
|
137
|
+
tb.appendChild(tr);
|
|
138
|
+
}
|
|
139
|
+
}catch(e){}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pushFeed(ev){
|
|
143
|
+
const feed=$('feed');
|
|
144
|
+
const lab=LABEL[ev.type]; if(lab==null) return;
|
|
145
|
+
const color = SEV[ev.severity] || (ev.type==='ban'?'#ef4444':ev.type==='blocked'?'#f97316':'#94a3b8');
|
|
146
|
+
const t=new Date(ev.at||Date.now()).toLocaleTimeString();
|
|
147
|
+
const meta = ev.type==='threat' ? (ev.severity+' · '+(ev.types||[]).join(',')) :
|
|
148
|
+
ev.type==='ban' ? (ev.reason||'') :
|
|
149
|
+
ev.type==='blocked' ? (ev.path||'') : '';
|
|
150
|
+
const row=document.createElement('div'); row.className='ev';
|
|
151
|
+
row.innerHTML='<span class="tag" style="background:'+color+'">'+lab+'</span>'+
|
|
152
|
+
'<span class="mono ip">'+esc(ev.ip||'')+'</span>'+
|
|
153
|
+
'<span class="evmeta">'+esc(meta)+'</span><span class="evt">'+t+'</span>';
|
|
154
|
+
feed.prepend(row);
|
|
155
|
+
while(feed.children.length>60) feed.removeChild(feed.lastChild);
|
|
156
|
+
$('feedCount').textContent = (++feedN);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function unban(ip){ await fetch(q('api/unban/'+encodeURIComponent(ip)),{method:'POST'}); loadBans(); loadStats(); }
|
|
160
|
+
async function manualBlock(){ const ip=$('ipInput').value.trim(); if(!ip)return;
|
|
161
|
+
await fetch(q('api/block/'+encodeURIComponent(ip)),{method:'POST'}); $('ipInput').value=''; loadBans(); loadStats(); }
|
|
162
|
+
function esc(s){ return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
163
|
+
|
|
164
|
+
function connect(){
|
|
165
|
+
const es=new EventSource(q('api/events'));
|
|
166
|
+
es.onopen=()=>{ $('dot').classList.add('on'); $('connText').textContent='jonli ulangan'; };
|
|
167
|
+
es.onerror=()=>{ $('dot').classList.remove('on'); $('connText').textContent='qayta ulanmoqda…'; };
|
|
168
|
+
es.onmessage=(m)=>{ try{ const ev=JSON.parse(m.data);
|
|
169
|
+
if(ev.type==='tick'){ applyStats(ev.stats); if(Math.random()<0.15) loadBans(); }
|
|
170
|
+
else { pushFeed(ev); if(ev.type==='ban'||ev.type==='blocked'){ loadBans(); loadStats(); } }
|
|
171
|
+
}catch(e){} };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
loadStats(); loadBans(); connect();
|
|
175
|
+
setInterval(loadStats, 5000);
|
|
176
|
+
</script>
|
|
177
|
+
</body></html>`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Umumiy CSS (mavzuga moslashuvchi — och/tund).
|
|
182
|
+
* @private
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
function baseCss() {
|
|
186
|
+
return `
|
|
187
|
+
:root{--bg:#f7f8fa;--card:#fff;--fg:#0f172a;--muted:#64748b;--border:#e2e8f0;--accent:#2563eb;--accent2:#1e293b}
|
|
188
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0b1120;--card:#111827;--fg:#e5e7eb;--muted:#94a3b8;--border:#1f2937;--accent:#3b82f6;--accent2:#0f172a}}
|
|
189
|
+
*{box-sizing:border-box}
|
|
190
|
+
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg)}
|
|
191
|
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Panelga xos CSS.
|
|
196
|
+
* @private
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function panelCss() {
|
|
200
|
+
return `
|
|
201
|
+
.top{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;background:var(--card);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:5}
|
|
202
|
+
.brand{font-weight:700;font-size:16px;display:flex;gap:8px;align-items:center}
|
|
203
|
+
.conn{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--muted)}
|
|
204
|
+
.dot{width:9px;height:9px;border-radius:50%;background:#94a3b8;transition:.3s}
|
|
205
|
+
.dot.on{background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.2)}
|
|
206
|
+
.wrap{max-width:1100px;margin:0 auto;padding:24px}
|
|
207
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:22px}
|
|
208
|
+
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
|
|
209
|
+
.card .k{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
210
|
+
.card .v{font-size:26px;font-weight:700;margin-top:6px}
|
|
211
|
+
.card.warn .v{color:#eab308}.card.bad .v{color:#ef4444}
|
|
212
|
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}
|
|
213
|
+
@media (max-width:820px){.grid{grid-template-columns:1fr}}
|
|
214
|
+
.panel{background:var(--card);border:1px solid var(--border);border-radius:14px;overflow:hidden}
|
|
215
|
+
.phead{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--border)}
|
|
216
|
+
.phead h2{font-size:14px;margin:0}.hint{font-size:12px;color:var(--muted)}
|
|
217
|
+
.feed{max-height:420px;overflow-y:auto;padding:8px}
|
|
218
|
+
.ev{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;font-size:13px}
|
|
219
|
+
.ev:hover{background:var(--bg)}
|
|
220
|
+
.tag{color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;letter-spacing:.03em}
|
|
221
|
+
.ev .ip{min-width:120px}.evmeta{color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
222
|
+
.evt{color:var(--muted);font-size:11px}
|
|
223
|
+
.blockbar{display:flex;gap:8px;padding:12px 18px;border-bottom:1px solid var(--border)}
|
|
224
|
+
.blockbar input{flex:1;padding:9px 12px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--fg);font-size:13px}
|
|
225
|
+
.tablewrap{overflow-x:auto}
|
|
226
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
227
|
+
th,td{text-align:left;padding:10px 18px;border-bottom:1px solid var(--border)}
|
|
228
|
+
th{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
|
|
229
|
+
td.empty{color:var(--muted);text-align:center;padding:24px}
|
|
230
|
+
.btn{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 12px;font-size:13px;font-weight:600;cursor:pointer}
|
|
231
|
+
.btn.small{padding:5px 10px;font-size:12px;background:#ef4444}
|
|
232
|
+
.btn.block{background:#ef4444}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { renderDashboard };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file datacenterRanges.js
|
|
5
|
+
* @description Mashhur datacenter (bulut) va hosting provayderlarining IP
|
|
6
|
+
* diapazonlari (CIDR). VPN/proxy va bot trafigi ko'pincha shu
|
|
7
|
+
* diapazonlardan keladi. Bu — boshlang'ich (starter) ro'yxat;
|
|
8
|
+
* foydalanuvchi o'z diapazonlarini qo'shishi mumkin.
|
|
9
|
+
*
|
|
10
|
+
* ESLATMA: Ro'yxat vakillik (representative) xarakterga ega —
|
|
11
|
+
* to'liq aniqlik uchun provayderlarning rasmiy IP-range fayllarini
|
|
12
|
+
* yuklab, `addRanges()` orqali qo'shing.
|
|
13
|
+
* @module modules/datacenterRanges
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { matchCidr, normalizeIp } = require('../utils/cidr');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Provayder -> CIDR diapazonlari.
|
|
20
|
+
* @type {Record<string, string[]>}
|
|
21
|
+
*/
|
|
22
|
+
const RANGES = {
|
|
23
|
+
AWS: [
|
|
24
|
+
'3.0.0.0/9', '13.32.0.0/15', '15.177.0.0/18', '18.32.0.0/11',
|
|
25
|
+
'35.71.64.0/18', '52.0.0.0/11', '52.192.0.0/11', '54.144.0.0/12',
|
|
26
|
+
'54.224.0.0/12', '99.77.128.0/18',
|
|
27
|
+
],
|
|
28
|
+
'Google Cloud': [
|
|
29
|
+
'34.64.0.0/10', '35.184.0.0/13', '35.192.0.0/14', '35.196.0.0/15',
|
|
30
|
+
'35.198.0.0/16', '35.199.0.0/17', '104.196.0.0/14', '130.211.0.0/16',
|
|
31
|
+
],
|
|
32
|
+
Azure: [
|
|
33
|
+
'13.64.0.0/11', '20.0.0.0/11', '40.64.0.0/10', '52.224.0.0/11',
|
|
34
|
+
'104.40.0.0/13', '137.116.0.0/15',
|
|
35
|
+
],
|
|
36
|
+
DigitalOcean: [
|
|
37
|
+
'104.131.0.0/16', '138.68.0.0/16', '159.203.0.0/16', '165.227.0.0/16',
|
|
38
|
+
'167.99.0.0/16', '178.62.0.0/16', '46.101.0.0/16', '146.190.0.0/16',
|
|
39
|
+
],
|
|
40
|
+
Hetzner: [
|
|
41
|
+
'5.9.0.0/16', '88.99.0.0/16', '116.202.0.0/16', '136.243.0.0/16',
|
|
42
|
+
'148.251.0.0/16', '176.9.0.0/16', '78.46.0.0/15', '95.216.0.0/15',
|
|
43
|
+
'95.217.0.0/16',
|
|
44
|
+
],
|
|
45
|
+
OVH: [
|
|
46
|
+
'51.38.0.0/16', '51.75.0.0/16', '51.83.0.0/16', '51.89.0.0/16',
|
|
47
|
+
'51.91.0.0/16', '54.36.0.0/16', '145.239.0.0/16', '137.74.0.0/16',
|
|
48
|
+
'167.114.0.0/16', '178.32.0.0/15', '91.121.0.0/16', '92.222.0.0/16',
|
|
49
|
+
'94.23.0.0/16',
|
|
50
|
+
],
|
|
51
|
+
Linode: ['45.33.0.0/16', '45.56.0.0/16', '66.175.208.0/20', '96.126.96.0/19', '172.104.0.0/15', '139.144.0.0/16'],
|
|
52
|
+
Vultr: ['45.32.0.0/16', '45.63.0.0/16', '104.207.128.0/17', '108.61.0.0/16', '149.28.0.0/16', '155.138.128.0/17'],
|
|
53
|
+
Oracle: ['129.146.0.0/16', '132.145.0.0/16', '138.1.0.0/16', '150.230.0.0/16'],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* VPN xizmatlarining (mashhur) chiqish diapazonlaridan namuna.
|
|
58
|
+
* @type {Record<string, string[]>}
|
|
59
|
+
*/
|
|
60
|
+
const VPN_RANGES = {
|
|
61
|
+
NordVPN: ['185.152.64.0/22', '194.110.13.0/24'],
|
|
62
|
+
ProtonVPN: ['185.159.156.0/22', '79.135.104.0/24'],
|
|
63
|
+
Mullvad: ['193.138.218.0/24', '185.213.152.0/22'],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Barcha diapazonlarni tekis (flat) massivga aylantiradi.
|
|
68
|
+
* @private
|
|
69
|
+
* @param {Record<string, string[]>} table
|
|
70
|
+
* @returns {Array<{ provider: string, cidr: string }>}
|
|
71
|
+
*/
|
|
72
|
+
function flatten(table) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const [provider, cidrs] of Object.entries(table)) {
|
|
75
|
+
for (const cidr of cidrs) out.push({ provider, cidr });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @type {Array<{ provider: string, cidr: string }>} */
|
|
81
|
+
let INDEX = [...flatten(RANGES), ...flatten(VPN_RANGES)];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* IP datacenter/VPN diapazoniga tegishlimi — tegishli bo'lsa provayderni qaytaradi.
|
|
85
|
+
* @param {string} ip
|
|
86
|
+
* @returns {{ provider: string, cidr: string } | null}
|
|
87
|
+
*/
|
|
88
|
+
function detect(ip) {
|
|
89
|
+
const nip = normalizeIp(ip);
|
|
90
|
+
for (const entry of INDEX) {
|
|
91
|
+
if (matchCidr(nip, [entry.cidr])) return entry;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* IP datacenter yoki VPN'ga tegishlimi (boolean).
|
|
98
|
+
* @param {string} ip
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function isDatacenter(ip) {
|
|
102
|
+
return detect(ip) !== null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Foydalanuvchi qo'shimcha diapazonlarni qo'shadi (masalan, rasmiy AWS ranges).
|
|
107
|
+
* @param {string} provider - Provayder nomi.
|
|
108
|
+
* @param {string[]} cidrs - CIDR ro'yxati.
|
|
109
|
+
* @returns {void}
|
|
110
|
+
*/
|
|
111
|
+
function addRanges(provider, cidrs) {
|
|
112
|
+
for (const cidr of cidrs) INDEX.push({ provider, cidr });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
detect,
|
|
117
|
+
isDatacenter,
|
|
118
|
+
addRanges,
|
|
119
|
+
RANGES,
|
|
120
|
+
VPN_RANGES,
|
|
121
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ddosGuard.js
|
|
5
|
+
* @description L7 (Application Layer) DDoS himoyasi. Oddiy per-IP rate-limitdan
|
|
6
|
+
* farqli o'laroq, TAQSIMLANGAN (distributed) hujumlarni ham hisobga
|
|
7
|
+
* oladi:
|
|
8
|
+
* • Global (umumiy) RPS anomaliyasini kuzatadi;
|
|
9
|
+
* • server yuklanib qolganda "Under Attack Mode" ni AVTOMAT yoqadi;
|
|
10
|
+
* • har IP uchun bir vaqtdagi ulanishlar (concurrency) sonini cheklaydi;
|
|
11
|
+
* • event-loop kechikishi (lag) oshsa — ortiqcha trafikni chetlatadi
|
|
12
|
+
* (load shedding, 503) va serverni tirik saqlaydi;
|
|
13
|
+
* • hujum paytida yengil cookie-challenge bilan oddiy botlarni ajratadi.
|
|
14
|
+
* @module modules/ddosGuard
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const crypto = require('crypto');
|
|
18
|
+
const { EventEmitter } = require('events');
|
|
19
|
+
const { defaultLogger } = require('../utils/logger');
|
|
20
|
+
const { defaultKeyGenerator } = require('./rateLimiter');
|
|
21
|
+
|
|
22
|
+
let monitorEventLoopDelay;
|
|
23
|
+
try {
|
|
24
|
+
// Node 12+; bo'lmasa lag o'lchovi 0 bo'ladi.
|
|
25
|
+
monitorEventLoopDelay = require('perf_hooks').monitorEventLoopDelay;
|
|
26
|
+
} catch {
|
|
27
|
+
monitorEventLoopDelay = null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sekundlar bo'yicha "bucket" larda so'rovlarni sanab, joriy RPS ni beruvchi o'lchagich.
|
|
32
|
+
* @class
|
|
33
|
+
*/
|
|
34
|
+
class RpsMeter {
|
|
35
|
+
/** @param {number} seconds - Nechta sekundlik oyna bo'yicha o'rtacha olinadi. */
|
|
36
|
+
constructor(seconds = 5) {
|
|
37
|
+
this.size = seconds;
|
|
38
|
+
this.buckets = new Array(seconds).fill(0);
|
|
39
|
+
this.lastSec = Math.floor(Date.now() / 1000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @private */
|
|
43
|
+
_advance() {
|
|
44
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
45
|
+
let gap = nowSec - this.lastSec;
|
|
46
|
+
if (gap <= 0) return;
|
|
47
|
+
gap = Math.min(gap, this.size);
|
|
48
|
+
for (let i = 0; i < gap; i++) {
|
|
49
|
+
this.lastSec += 1;
|
|
50
|
+
this.buckets[this.lastSec % this.size] = 0;
|
|
51
|
+
}
|
|
52
|
+
this.lastSec = nowSec;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bitta so'rovni qayd etadi. @returns {void} */
|
|
56
|
+
hit() {
|
|
57
|
+
this._advance();
|
|
58
|
+
this.buckets[this.lastSec % this.size] += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Joriy o'rtacha RPS. @returns {number} */
|
|
62
|
+
rps() {
|
|
63
|
+
this._advance();
|
|
64
|
+
const total = this.buckets.reduce((a, b) => a + b, 0);
|
|
65
|
+
return total / this.size;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {Object} DdosGuardOptions
|
|
71
|
+
* @property {number} [sampleSeconds=5] - RPS o'lchov oynasi.
|
|
72
|
+
* @property {number} [maxRps=300] - Umumiy RPS shu qiymatdan oshsa — Attack Mode.
|
|
73
|
+
* @property {number} [perIpConcurrency=25] - Bitta IP uchun bir vaqtdagi maksimal so'rovlar.
|
|
74
|
+
* @property {number} [attackPerIpMax=15] - Attack Mode'da har IP uchun oynadagi limit.
|
|
75
|
+
* @property {number} [attackWindowMs=10000] - Attack Mode per-IP oynasi.
|
|
76
|
+
* @property {number} [attackCooldownMs=15000] - RPS tushgach Attack Mode yana shuncha davom etadi.
|
|
77
|
+
* @property {number} [lagAttackMs=70] - Event-loop lag (ms) shu qiymatdan oshsa — Attack Mode.
|
|
78
|
+
* @property {number} [lagShedMs=200] - Lag shu qiymatdan oshsa — load shedding (503).
|
|
79
|
+
* @property {boolean} [challenge=true] - Attack Mode'da cookie-challenge yoqilsinmi.
|
|
80
|
+
* @property {string} [challengeSecret] - Challenge cookie'sini imzolash uchun maxfiy kalit.
|
|
81
|
+
* @property {string} [cookieName='__yg_l7'] - Challenge cookie nomi.
|
|
82
|
+
* @property {number} [challengeTtlMs=3600000] - Challenge cookie amal muddati.
|
|
83
|
+
* @property {number} [statusCode=503] - Chetlatish/challenge status kodi.
|
|
84
|
+
* @property {number} [banAfter=5] - Attack Mode'da shu sonli buzilishdan keyin firewall'ga ban.
|
|
85
|
+
* @property {string[]} [whitelist] - Tekshiruvsiz o'tadigan IP'lar.
|
|
86
|
+
* @property {import('./firewall').Firewall} [firewall] - Jiddiy buzuvchilarni bloklash uchun.
|
|
87
|
+
* @property {import('../utils/logger').Logger} [logger=defaultLogger] - Logger.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/** @type {DdosGuardOptions} */
|
|
91
|
+
const DEFAULTS = {
|
|
92
|
+
sampleSeconds: 5,
|
|
93
|
+
maxRps: 300,
|
|
94
|
+
perIpConcurrency: 25,
|
|
95
|
+
attackPerIpMax: 15,
|
|
96
|
+
attackWindowMs: 10000,
|
|
97
|
+
attackCooldownMs: 15000,
|
|
98
|
+
lagAttackMs: 70,
|
|
99
|
+
lagShedMs: 200,
|
|
100
|
+
challenge: true,
|
|
101
|
+
cookieName: '__yg_l7',
|
|
102
|
+
challengeTtlMs: 60 * 60 * 1000,
|
|
103
|
+
statusCode: 503,
|
|
104
|
+
banAfter: 5,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Cookie sarlavhasidan bitta cookie qiymatini ajratadi.
|
|
109
|
+
* @private
|
|
110
|
+
* @param {string} header
|
|
111
|
+
* @param {string} name
|
|
112
|
+
* @returns {string|null}
|
|
113
|
+
*/
|
|
114
|
+
function readCookie(header, name) {
|
|
115
|
+
if (!header) return null;
|
|
116
|
+
for (const part of header.split(';')) {
|
|
117
|
+
const idx = part.indexOf('=');
|
|
118
|
+
if (idx === -1) continue;
|
|
119
|
+
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim();
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* L7 DDoS himoya middleware'ini yaratadi.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* const { ddosGuard } = require('yusufkhon-guard');
|
|
129
|
+
* const dg = ddosGuard({ maxRps: 200, perIpConcurrency: 20, firewall: shield.firewall });
|
|
130
|
+
* app.use(dg);
|
|
131
|
+
* dg.ddos.on('attack-start', (m) => console.log('DDoS!', m));
|
|
132
|
+
*
|
|
133
|
+
* @param {DdosGuardOptions} [userOptions={}]
|
|
134
|
+
* @returns {import('express').RequestHandler & { ddos: EventEmitter, metrics: Object }}
|
|
135
|
+
*/
|
|
136
|
+
function ddosGuard(userOptions = {}) {
|
|
137
|
+
const opts = { ...DEFAULTS, ...userOptions };
|
|
138
|
+
const logger = opts.logger || defaultLogger;
|
|
139
|
+
const keyGen = opts.keyGenerator || defaultKeyGenerator;
|
|
140
|
+
const whitelist = new Set(opts.whitelist || ['127.0.0.1', '::1']);
|
|
141
|
+
const secret = opts.challengeSecret || crypto.randomBytes(16).toString('hex');
|
|
142
|
+
const emitter = new EventEmitter();
|
|
143
|
+
|
|
144
|
+
const meter = new RpsMeter(opts.sampleSeconds);
|
|
145
|
+
/** @type {Map<string, number>} */
|
|
146
|
+
const inflight = new Map();
|
|
147
|
+
/** @type {Map<string, { count: number, reset: number, violations: number }>} */
|
|
148
|
+
const perIp = new Map();
|
|
149
|
+
|
|
150
|
+
let eld = null;
|
|
151
|
+
if (monitorEventLoopDelay) {
|
|
152
|
+
eld = monitorEventLoopDelay({ resolution: 20 });
|
|
153
|
+
eld.enable();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let forced = false; // testlar/qo'lda yoqish uchun
|
|
157
|
+
let attackUntil = 0;
|
|
158
|
+
|
|
159
|
+
const metrics = { attackMode: false, rps: 0, lagMs: 0, challenged: 0, shed: 0, blocked: 0, concurrentPeak: 0 };
|
|
160
|
+
|
|
161
|
+
const lagMs = () => (eld ? eld.mean / 1e6 : 0);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Attack Mode holatini yangilaydi va qaytaradi.
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
function evaluateAttack() {
|
|
168
|
+
const rps = meter.rps();
|
|
169
|
+
const lag = lagMs();
|
|
170
|
+
metrics.rps = Math.round(rps);
|
|
171
|
+
metrics.lagMs = Math.round(lag);
|
|
172
|
+
|
|
173
|
+
if (forced || rps > opts.maxRps || lag > opts.lagAttackMs) {
|
|
174
|
+
attackUntil = Date.now() + opts.attackCooldownMs;
|
|
175
|
+
}
|
|
176
|
+
const active = Date.now() < attackUntil;
|
|
177
|
+
if (active !== metrics.attackMode) {
|
|
178
|
+
metrics.attackMode = active;
|
|
179
|
+
if (active) {
|
|
180
|
+
logger.warn('🚨 UNDER ATTACK MODE yoqildi (L7 DDoS)', { rps: metrics.rps, lagMs: metrics.lagMs });
|
|
181
|
+
emitter.emit('attack-start', { ...metrics });
|
|
182
|
+
} else {
|
|
183
|
+
logger.success('Under Attack Mode o\'chirildi (trafik normallashdi).');
|
|
184
|
+
emitter.emit('attack-end', { ...metrics });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return active;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Imzolangan challenge cookie qiymatini yaratadi.
|
|
192
|
+
* @private
|
|
193
|
+
* @param {string} ip
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
function makeChallenge(ip) {
|
|
197
|
+
const exp = Date.now() + opts.challengeTtlMs;
|
|
198
|
+
const sig = crypto.createHmac('sha256', secret).update(`${ip}.${exp}`).digest('hex').slice(0, 24);
|
|
199
|
+
return `${exp}.${sig}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Challenge cookie'ni tekshiradi.
|
|
204
|
+
* @private
|
|
205
|
+
* @param {string} ip
|
|
206
|
+
* @param {string|null} value
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
function verifyChallenge(ip, value) {
|
|
210
|
+
if (!value) return false;
|
|
211
|
+
const [expStr, sig] = value.split('.');
|
|
212
|
+
const exp = Number(expStr);
|
|
213
|
+
if (!exp || Date.now() > exp) return false;
|
|
214
|
+
const expect = crypto.createHmac('sha256', secret).update(`${ip}.${exp}`).digest('hex').slice(0, 24);
|
|
215
|
+
return sig === expect;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {import('express').Request} req
|
|
220
|
+
* @param {import('express').Response} res
|
|
221
|
+
* @param {import('express').NextFunction} next
|
|
222
|
+
*/
|
|
223
|
+
function ddosMiddleware(req, res, next) {
|
|
224
|
+
meter.hit();
|
|
225
|
+
const ip = keyGen(req);
|
|
226
|
+
|
|
227
|
+
if (whitelist.has(ip)) return next();
|
|
228
|
+
|
|
229
|
+
// --- Per-IP concurrency (bir vaqtdagi so'rovlar) ---
|
|
230
|
+
const cur = (inflight.get(ip) || 0) + 1;
|
|
231
|
+
inflight.set(ip, cur);
|
|
232
|
+
if (cur > metrics.concurrentPeak) metrics.concurrentPeak = cur;
|
|
233
|
+
|
|
234
|
+
let released = false;
|
|
235
|
+
const release = () => {
|
|
236
|
+
if (released) return;
|
|
237
|
+
released = true;
|
|
238
|
+
const n = (inflight.get(ip) || 1) - 1;
|
|
239
|
+
if (n <= 0) inflight.delete(ip); else inflight.set(ip, n);
|
|
240
|
+
};
|
|
241
|
+
res.on('finish', release);
|
|
242
|
+
res.on('close', release);
|
|
243
|
+
|
|
244
|
+
const reject = (code, body, headers) => {
|
|
245
|
+
if (headers) for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
|
|
246
|
+
if (res.status) res.status(code).json(body);
|
|
247
|
+
else { res.statusCode = code; res.end(typeof body === 'string' ? body : JSON.stringify(body)); }
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (cur > opts.perIpConcurrency) {
|
|
251
|
+
metrics.blocked += 1;
|
|
252
|
+
logger.blocked(ip, 'L7: per-IP concurrency oshdi', { concurrent: cur, limit: opts.perIpConcurrency });
|
|
253
|
+
if (opts.firewall) opts.firewall.blocker.addStrike(ip, 30, 'L7 concurrency flood');
|
|
254
|
+
return reject(429, { success: false, error: 'Too many concurrent requests.' }, { 'Retry-After': '5' });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- Load shedding: event-loop juda band ---
|
|
258
|
+
const lag = lagMs();
|
|
259
|
+
if (lag > opts.lagShedMs) {
|
|
260
|
+
metrics.shed += 1;
|
|
261
|
+
logger.warn('L7: load shedding (event-loop lag)', { lagMs: Math.round(lag) });
|
|
262
|
+
return reject(opts.statusCode, { success: false, error: 'Server band — birozdan so\'ng urinib ko\'ring.' }, { 'Retry-After': '3' });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const underAttack = evaluateAttack();
|
|
266
|
+
|
|
267
|
+
if (underAttack) {
|
|
268
|
+
// --- Cookie challenge: oddiy botlarni ajratamiz ---
|
|
269
|
+
if (opts.challenge) {
|
|
270
|
+
const cookie = readCookie(req.headers && req.headers.cookie, opts.cookieName);
|
|
271
|
+
if (!verifyChallenge(ip, cookie)) {
|
|
272
|
+
metrics.challenged += 1;
|
|
273
|
+
const token = makeChallenge(ip);
|
|
274
|
+
res.setHeader('Set-Cookie', `${opts.cookieName}=${token}; Path=/; Max-Age=${Math.floor(opts.challengeTtlMs / 1000)}; HttpOnly; SameSite=Lax`);
|
|
275
|
+
res.setHeader('Retry-After', '1');
|
|
276
|
+
res.statusCode = opts.statusCode;
|
|
277
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
278
|
+
// Meta-refresh: haqiqiy brauzer cookie bilan qayta so'raydi -> o'tadi.
|
|
279
|
+
res.end('<!doctype html><meta http-equiv="refresh" content="1"><title>Tekshiruv…</title><body style="font-family:sans-serif">Xavfsizlik tekshiruvi… bir soniya.</body>');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- Attack Mode'da qat'iy per-IP limit ---
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
let rec = perIp.get(ip);
|
|
287
|
+
if (!rec || now > rec.reset) {
|
|
288
|
+
rec = { count: 0, reset: now + opts.attackWindowMs, violations: rec ? rec.violations : 0 };
|
|
289
|
+
perIp.set(ip, rec);
|
|
290
|
+
}
|
|
291
|
+
rec.count += 1;
|
|
292
|
+
if (rec.count > opts.attackPerIpMax) {
|
|
293
|
+
rec.violations += 1;
|
|
294
|
+
metrics.blocked += 1;
|
|
295
|
+
logger.blocked(ip, 'L7: Attack Mode per-IP limit', { count: rec.count, limit: opts.attackPerIpMax });
|
|
296
|
+
if (opts.firewall && rec.violations >= opts.banAfter) {
|
|
297
|
+
// `ban()` haqiqiy BanRecord qaytaradi — uni 'ban' hodisasida yuboramiz
|
|
298
|
+
// (record.offenseCount kabi maydonlarga tayanadigan iste'molchilar uchun).
|
|
299
|
+
const banReason = 'L7 DDoS: doimiy flood';
|
|
300
|
+
const record = opts.firewall.blocker.ban(ip, banReason);
|
|
301
|
+
if (typeof opts.firewall.emit === 'function') opts.firewall.emit('ban', { ip, reason: banReason, record });
|
|
302
|
+
}
|
|
303
|
+
return reject(429, { success: false, error: 'Rate limit (attack mode).' }, { 'Retry-After': '10' });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
next();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Boshqarish/monitoring.
|
|
311
|
+
ddosMiddleware.ddos = emitter;
|
|
312
|
+
ddosMiddleware.metrics = metrics;
|
|
313
|
+
/** Attack Mode'ni qo'lda yoqish/o'chirish (test yoki qo'lda boshqarish uchun). */
|
|
314
|
+
ddosMiddleware.setUnderAttack = (v) => { forced = !!v; if (v) attackUntil = Date.now() + opts.attackCooldownMs; evaluateAttack(); };
|
|
315
|
+
ddosMiddleware.getStatus = () => ({ ...metrics });
|
|
316
|
+
ddosMiddleware.stop = () => { if (eld) eld.disable(); };
|
|
317
|
+
|
|
318
|
+
return ddosMiddleware;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = ddosGuard;
|
|
322
|
+
module.exports.ddosGuard = ddosGuard;
|
|
323
|
+
module.exports.RpsMeter = RpsMeter;
|