#!/usr/bin/env node
'use strict';
/*
* Fluint Shield agent - a zero-dependency reverse proxy that sits in front of your website.
*
* visitors --> [ Fluint agent :80/:443 ] --> your real site (e.g. http://127.0.0.1:8081)
*
* It enforces the protection policy you control from the Fluint dashboard (WAF rules, rate
* limiting, brute-force and scanner bans, IP/country blocks, security headers, lockdown) and
* reports traffic and attacks back to Fluint. Everything starts in "monitor" mode: attacks are
* reported to you, and only blocked once you choose to block them.
*
* Usage:
* node fluint-agent.js --server https://fluint.example.com --key fla_xxx --upstream http://127.0.0.1:8081 --port 80
* Options (or env vars FLUINT_SERVER, FLUINT_KEY, FLUINT_UPSTREAM, FLUINT_PORT, FLUINT_HOST,
* FLUINT_TRUST_PROXY, FLUINT_TLS_CERT, FLUINT_TLS_KEY):
* --trust-proxy cloudflare|xff read the real visitor IP from CF-Connecting-IP / X-Forwarded-For
* --tls-cert / --tls-key PEM files to serve HTTPS directly
* Optional: `npm i geoip-lite` next to this file to enable country blocking without Cloudflare.
*/
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const net = require('net');
const crypto = require('crypto');
const dns = require('dns').promises;
const VERSION = '0.1.0';
// --- Config ---------------------------------------------------------------------------------------
const argv = {};
for (let i = 2; i < process.argv.length; i++) {
const m = process.argv[i].match(/^--([\w-]+)(?:=(.*))?$/);
if (m) argv[m[1]] = m[2] !== undefined ? m[2] : (process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[++i] : 'true');
}
const opt = (name, envName, def) => argv[name] ?? process.env[envName] ?? def;
const CFG = {
server: String(opt('server', 'FLUINT_SERVER', '')).replace(/\/$/, ''),
key: opt('key', 'FLUINT_KEY', ''),
upstream: new URL(opt('upstream', 'FLUINT_UPSTREAM', 'http://127.0.0.1:8081')),
port: Number(opt('port', 'FLUINT_PORT', 8000)),
host: opt('host', 'FLUINT_HOST', '0.0.0.0'),
trustProxy: opt('trust-proxy', 'FLUINT_TRUST_PROXY', ''),
tlsCert: opt('tls-cert', 'FLUINT_TLS_CERT', ''),
tlsKey: opt('tls-key', 'FLUINT_TLS_KEY', ''),
cacheFile: opt('cache', 'FLUINT_CACHE', path.join(__dirname, '.fluint-policy.json')),
};
if (!CFG.server || !/^fla_[0-9a-f]{16}_/.test(CFG.key)) {
console.error('Fluint agent: --server and --key are required (copy the install command from your Fluint dashboard).');
process.exit(1);
}
// Fluint's own scanner/uptime checks carry an HMAC signature so they aren't reported as attacks.
const KEY_HASH = crypto.createHash('sha256').update(CFG.key.slice(21)).digest('hex');
function isFluintScan(req) {
const [ts, mac] = String(req.headers['x-fluint-scan'] || '').split('.');
if (!ts || !mac || Math.abs(Date.now() - Number(ts)) > 300000) return false;
const expected = crypto.createHmac('sha256', KEY_HASH).update(ts).digest('hex');
return mac.length === expected.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
}
let geoip = null;
try { geoip = require('geoip-lite'); } catch { /* optional */ }
// --- Policy (cached to disk so protection survives restarts / Fluint outages) ---------------------
let policy = {
waf: { sqli: 'monitor', xss: 'monitor', traversal: 'monitor', cmdi: 'monitor', ssrf: 'monitor', sensitiveFiles: 'monitor', scanners: 'monitor' },
rateLimit: { mode: 'monitor', perMinute: 300, banMinutes: 15 },
bruteForce: { mode: 'monitor', paths: ['/wp-login.php', '/xmlrpc.php', '/login', '/admin'], maxAttempts: 10, windowMinutes: 5 },
probing: { mode: 'monitor', max404PerMinute: 40 },
headers: {}, blockedPaths: [], ipBlock: [], ipAllow: [], countryBlock: [], lockdown: false,
};
let verifyToken = '';
try {
const cached = JSON.parse(fs.readFileSync(CFG.cacheFile, 'utf8'));
policy = cached.policy || policy;
verifyToken = cached.verifyToken || '';
} catch { /* first run */ }
let compiled = compile(policy);
function ipToInt(ip) { return ip.split('.').reduce((a, o) => (a << 8) + Number(o), 0) >>> 0; }
function compileIpList(list) {
const exact = new Set();
const ranges = [];
for (const entry of list || []) {
const [ip, bits] = entry.split('/');
if (bits === undefined) exact.add(ip);
else if (net.isIPv4(ip)) { const b = Number(bits); const mask = b === 0 ? 0 : (~0 << (32 - b)) >>> 0; ranges.push([ipToInt(ip) & mask, mask]); }
}
return (ip) => exact.has(ip) || (net.isIPv4(ip) && ranges.some(([n, m]) => ((ipToInt(ip) & m) >>> 0) === n));
}
function compile(p) {
return {
blocked: compileIpList(p.ipBlock),
allowed: compileIpList(p.ipAllow),
countries: new Set(p.countryBlock || []),
bruteRe: (p.bruteForce.paths || []).map((x) => x.toLowerCase()),
};
}
async function api(method, route, body) {
const res = await fetch(CFG.server + route, {
method,
headers: { authorization: `Bearer ${CFG.key}`, 'content-type': 'application/json', 'x-fluint-agent': VERSION },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(15000),
});
if (!res.ok) throw new Error(`${route} -> HTTP ${res.status}`);
return res.json();
}
let lastPolicyJson = '';
async function pullPolicy() {
try {
const r = await api('GET', '/api/agent/policy');
const json = JSON.stringify(r.policy);
if (json !== lastPolicyJson) {
policy = r.policy;
compiled = compile(policy);
verifyToken = r.verifyToken;
if (lastPolicyJson) console.log(`[fluint] policy updated (lockdown=${policy.lockdown})`);
lastPolicyJson = json;
fs.writeFile(CFG.cacheFile, JSON.stringify({ policy, verifyToken }), () => {});
}
} catch (e) { console.warn('[fluint] policy fetch failed:', e.message); }
}
// --- Detection rules -------------------------------------------------------------------------------
const RULES = {
sqli: [
['union-select', /\bunion\b[\s\S]{0,40}\bselect\b/i],
['schema-probe', /\b(information_schema|mysql\.user|pg_catalog|sysobjects|sqlite_master)\b/i],
['tautology', /['"`]\s*(or|and|\|\||&&)\s+['"`]?[\w-]+['"`]?\s*(=|like)\s*['"`]?[\w-]+/i],
['time-based', /\b(sleep|benchmark|pg_sleep)\s*\(\s*\d|\bwaitfor\s+delay\b/i],
['stacked-query', /;\s*(drop|truncate|alter|insert|update|delete)\s+(table|into|from|\w+\s+set)\b/i],
['error-based', /\b(extractvalue|updatexml)\s*\(/i],
['comment-terminator', /'\s*(--|#|\/\*)/],
],
xss: [
['script-tag', /<\s*script\b/i],
['js-uri', /\bjavascript\s*:/i],
['event-handler', /<[^>]*\bon(error|load|mouseover|focus|click|toggle|animationstart|pointerover)\s*=/i],
['dangerous-tag', /<\s*(iframe|object|embed|svg|math)\b/i],
['cookie-theft', /\bdocument\.(cookie|domain)\b|\bwindow\.location\s*=/i],
],
traversal: [
['dot-dot', /(?:\.\.[/\\]){2,}|(?:^|[/\\=])\.\.[/\\]/],
['system-file', /\/etc\/(passwd|shadow|hosts|group)\b|\b(boot|win)\.ini\b|\/proc\/self\//i],
['php-wrapper', /\b(php|zip|phar|expect|data):\/\//i],
['file-uri', /\bfile:\/\//i],
],
cmdi: [
// (?!\s*=) so ordinary query strings like "&id=5" or "&cat=shoes" are not flagged.
['shell-chain', /(?:[;&|`]|\$\()\s*(?:cat|ls|id|whoami|uname|wget|curl|nc|ncat|bash|sh|powershell|cmd(?:\.exe)?|python|perl|chmod)\b(?!\s*=)/i],
['log4shell', /\$\{\s*(jndi|lower|upper|env|sys|::-)/i],
['php-exec', /\b(shell_exec|passthru|proc_open|popen|system)\s*\(/i],
],
ssrf: [
['cloud-metadata', /169\.254\.169\.254|metadata\.google\.internal|100\.100\.100\.200|fd00:ec2::254/i],
['odd-scheme', /\b(gopher|dict|ldap|tftp):\/\//i],
],
sensitiveFiles: [
['dotfile', /\/\.(git|svn|hg|env|aws|ssh|htpasswd|DS_Store|vscode|idea)(\/|$)/i],
['config-backup', /(wp-config|config|configuration|settings|database|db)\.(php|inc|ya?ml|json|ini)(\.|~)(bak|old|orig|save|swp|txt|1)?$/i],
['dump', /\/(backup|dump|db|database|site)\.(sql|tar|tar\.gz|tgz|zip|rar|7z)$/i],
['info-page', /\/(phpinfo|info|test)\.php$/i],
],
};
const SCANNER_UA = /(sqlmap|nikto|nmap|masscan|zgrab|wpscan|acunetix|nessus|openvas|nuclei|gobuster|dirbuster|feroxbuster|ffuf|wfuzz|hydra|jaeles|whatweb|netsparker|commix|xsstrike|arachni)/i;
function safeDecode(s) {
let out = s;
for (let i = 0; i < 2; i++) { try { const d = decodeURIComponent(out.replace(/\+/g, ' ')); if (d === out) break; out = d; } catch { break; } }
return out;
}
function inspect(target, rules) {
for (const [ruleName, re] of rules) {
const m = target.match(re);
if (m) return { rule: ruleName, sample: target.slice(Math.max(0, m.index - 30), m.index + 90) };
}
return null;
}
// --- Telemetry buffers ----------------------------------------------------------------------------
let hits = [], threats = [];
const counters = { requests: 0, blocked: 0, bytes: 0, peakRps: 0, challenged: 0, passed: 0 };
function reportThreat(t) { if (threats.length < 3000) threats.push({ ts: Date.now(), ...t }); }
async function flush() {
if (!hits.length && !threats.length && !counters.requests) return;
const batch = { hits, threats, counters: { ...counters } };
hits = []; threats = [];
Object.assign(counters, { requests: 0, blocked: 0, bytes: 0, peakRps: 0, challenged: 0, passed: 0 });
try { await api('POST', '/api/agent/ingest', batch); } catch (e) {
console.warn('[fluint] report failed:', e.message);
// Keep the most recent data for the next attempt.
hits = batch.hits.concat(hits).slice(-5000);
threats = batch.threats.concat(threats).slice(-2000);
}
}
// --- Per-IP state ---------------------------------------------------------------------------------
const bans = new Map(); // ip -> until
const perIp = new Map(); // ip -> { minute, count, notFound, logins: [ts], flagged: {} }
let secondCount = 0, currentSecond = 0, rpsAvg = 20, surgeSeconds = 0, surgeReportedAt = 0;
function ipState(ip) {
const minute = Math.floor(Date.now() / 60000);
let s = perIp.get(ip);
if (!s) { s = { minute, count: 0, notFound: 0, logins: [], flagged: {} }; perIp.set(ip, s); }
if (s.minute !== minute) { s.minute = minute; s.count = 0; s.notFound = 0; s.flagged = {}; }
return s;
}
setInterval(() => {
const t = Date.now(), minute = Math.floor(t / 60000);
for (const [ip, until] of bans) if (until < t) bans.delete(ip);
for (const [ip, s] of perIp) if (s.minute < minute - 1 && !s.logins.length) perIp.delete(ip);
}, 60000).unref();
// A surge = 3+ consecutive seconds far above the learned normal rate (one heavy page load is not a DDoS).
const surgeLimit = () => Math.max(200, rpsAvg * 8);
function underSurge() { return surgeSeconds >= 3; }
function trackRps() {
const sec = Math.floor(Date.now() / 1000);
if (sec !== currentSecond) {
if (currentSecond) {
counters.peakRps = Math.max(counters.peakRps, secondCount);
surgeSeconds = sec - currentSecond === 1 && secondCount > surgeLimit() ? surgeSeconds + 1 : 0;
// Learn the normal rate only from non-surge traffic.
if (!surgeSeconds) rpsAvg = Math.max(20, rpsAvg * 0.98 + secondCount * 0.02);
}
currentSecond = sec; secondCount = 0;
}
secondCount++;
if (underSurge() && Date.now() - surgeReportedAt > 60000) {
surgeReportedAt = Date.now();
const top = [...perIp.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, 5).map(([ip, s]) => `${ip} (${s.count}/min)`);
reportThreat({ type: 'ddos', rule: 'surge', ip: '-', sample: `${counters.peakRps} req/s peak vs normal ~${Math.round(rpsAvg)} req/s. Top sources: ${top.join(', ')}`, action: policy.rateLimit.mode === 'block' ? 'blocked' : 'logged' });
}
}
// --- Request handling -----------------------------------------------------------------------------
function clientIp(req) {
let ip = req.socket.remoteAddress || '';
if (CFG.trustProxy === 'cloudflare' && req.headers['cf-connecting-ip']) ip = req.headers['cf-connecting-ip'];
else if (CFG.trustProxy === 'xff' && req.headers['x-forwarded-for']) ip = req.headers['x-forwarded-for'].split(',').pop().trim();
return ip.replace(/^::ffff:/, '');
}
function countryOf(req, ip) {
if (CFG.trustProxy === 'cloudflare' && req.headers['cf-ipcountry']) return req.headers['cf-ipcountry'];
if (geoip) { try { return (geoip.lookup(ip) || {}).country || null; } catch { /* ignore */ } }
return null;
}
const PAGE = (title, msg) => `
${title}`;
function deny(res, status, title, msg, extra = {}) {
counters.blocked++;
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-fluint': 'blocked', ...extra });
res.end(PAGE(title, msg));
}
const isAsset = (p) => /\.(css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|eot|mp4|webm|mp3|json|txt|xml)$/i.test(p);
/** Returns true if the request was handled (blocked). Runs before the body is read. */
function preChecks(req, res, ctx) {
const { ip, pathname, decoded, ua } = ctx;
const p = policy;
if (compiled.allowed(ip)) { ctx.trusted = true; return false; }
if (p.lockdown) { deny(res, 503, 'Down for maintenance', 'We\'ll be back shortly.', { 'retry-after': '600' }); return true; }
if (compiled.blocked(ip)) { deny(res, 403, 'Access denied', 'Your network has been blocked by this site\'s security policy.'); return true; }
const banned = bans.get(ip);
if (banned && banned > Date.now()) { deny(res, 429, 'Slow down', 'Too many requests. Please try again later.', { 'retry-after': String(Math.ceil((banned - Date.now()) / 1000)) }); return true; }
if (compiled.countries.size) {
const c = countryOf(req, ip);
if (c && compiled.countries.has(c)) { deny(res, 403, 'Not available', 'This site is not available in your region.'); return true; }
}
const lowerPath = pathname.toLowerCase();
if ((p.blockedPaths || []).some((bp) => lowerPath === bp.toLowerCase() || lowerPath.startsWith(bp.toLowerCase().replace(/\/?$/, '/')))) {
counters.blocked++;
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('Not found');
return true;
}
// Signed Fluint scans see the site exactly as visitors do (blocks above apply) but are never reported.
if (ctx.fluint) return false;
// Rate limiting.
const st = ipState(ip);
st.count++;
const limit = underSurge() && p.rateLimit.mode === 'block' ? Math.ceil(p.rateLimit.perMinute / 2) : p.rateLimit.perMinute;
if (p.rateLimit.mode !== 'off' && st.count > limit) {
const block = p.rateLimit.mode === 'block';
if (!st.flagged.rate) { st.flagged.rate = true; reportThreat({ type: 'ratelimit', rule: 'per-ip', ip, method: req.method, path: pathname, sample: `${st.count} requests in the last minute (limit ${limit})`, ua, action: block ? 'blocked' : 'logged' }); }
if (block) { bans.set(ip, Date.now() + p.rateLimit.banMinutes * 60000); deny(res, 429, 'Slow down', 'Too many requests. Please try again later.', { 'retry-after': String(p.rateLimit.banMinutes * 60) }); return true; }
}
// Brute force on login endpoints.
if (req.method === 'POST' && p.bruteForce.mode !== 'off' && compiled.bruteRe.some((bp) => lowerPath === bp || lowerPath.startsWith(`${bp}/`))) {
const windowMs = p.bruteForce.windowMinutes * 60000;
st.logins = st.logins.filter((t) => t > Date.now() - windowMs);
st.logins.push(Date.now());
if (st.logins.length > p.bruteForce.maxAttempts) {
const block = p.bruteForce.mode === 'block';
if (!st.flagged.brute) { st.flagged.brute = true; reportThreat({ type: 'bruteforce', rule: 'login-attempts', ip, method: req.method, path: pathname, sample: `${st.logins.length} login attempts in ${p.bruteForce.windowMinutes} min`, ua, action: block ? 'blocked' : 'logged' }); }
if (block) { bans.set(ip, Date.now() + 30 * 60000); deny(res, 429, 'Too many login attempts', 'Please wait a while before trying again.'); return true; }
}
}
// Already flagged as a scanner this minute.
if (p.probing.mode === 'block' && st.notFound > p.probing.max404PerMinute) {
bans.set(ip, Date.now() + 60 * 60000);
deny(res, 403, 'Access denied', 'Automated scanning is not allowed.');
return true;
}
// WAF on URL + headers.
const targets = [
['scanners', ua, [['scanner-ua', SCANNER_UA]]],
['sensitiveFiles', pathname, RULES.sensitiveFiles],
];
const urlTarget = decoded;
const headerTarget = `${req.headers.referer || ''} ${ua} ${req.headers['x-forwarded-host'] || ''} ${req.headers['x-api-version'] || ''}`;
for (const type of ['sqli', 'xss', 'traversal', 'cmdi', 'ssrf']) targets.push([type, urlTarget, RULES[type]]);
targets.push(['cmdi', headerTarget, [RULES.cmdi[1]]]); // Log4Shell hides in headers
for (const [type, target, rules] of targets) {
if (wafHit(req, res, ctx, type, target, rules)) return true;
}
return false;
}
function wafHit(req, res, ctx, type, target, rules) {
const mode = policy.waf[type];
if (!mode || mode === 'off' || !target) return false;
const hit = inspect(target, rules);
if (!hit) return false;
const block = mode === 'block';
reportThreat({ type, rule: hit.rule, ip: ctx.ip, method: req.method, path: ctx.pathname, sample: hit.sample, ua: ctx.ua, action: block ? 'blocked' : 'logged' });
if (block) { deny(res, 403, 'Request blocked', 'This request looked like an attack and was blocked by the site\'s firewall.'); return true; }
return false;
}
function hardenHeaders(headers) {
const h = policy.headers || {};
const out = { ...headers };
const setIfMissing = (k, v) => { if (out[k] === undefined) out[k] = v; };
if (h.hsts) setIfMissing('strict-transport-security', 'max-age=31536000; includeSubDomains');
if (h.nosniff) setIfMissing('x-content-type-options', 'nosniff');
if (h.frameDeny) setIfMissing('x-frame-options', 'SAMEORIGIN');
if (h.referrerPolicy) setIfMissing('referrer-policy', 'strict-origin-when-cross-origin');
if (h.permissionsPolicy) setIfMissing('permissions-policy', 'camera=(), microphone=(), geolocation=()');
if (h.csp) setIfMissing('content-security-policy', h.csp);
if (h.hideServer) for (const k of ['server', 'x-powered-by', 'x-aspnet-version', 'x-aspnetmvc-version', 'x-generator']) delete out[k];
if (h.secureCookies && out['set-cookie']) {
out['set-cookie'] = [].concat(out['set-cookie']).map((c) => {
let v = c;
const name = c.split('=')[0];
if (!/;\s*secure/i.test(v)) v += '; Secure';
if (!/;\s*samesite=/i.test(v)) v += '; SameSite=Lax';
if (/sess|sid|token|auth|login|jwt/i.test(name) && !/;\s*httponly/i.test(v)) v += '; HttpOnly';
return v;
});
}
return out;
}
const upstreamMod = CFG.upstream.protocol === 'https:' ? https : http;
const HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer'];
function forwardHeaders(req, ip) {
const headers = { ...req.headers };
for (const k of HOP) delete headers[k];
delete headers['x-fluint-scan'];
const prior = CFG.trustProxy ? req.headers['x-forwarded-for'] : null;
headers['x-forwarded-for'] = prior ? `${prior}, ${ip}` : ip;
headers['x-real-ip'] = ip;
headers['x-forwarded-proto'] = req.socket.encrypted ? 'https' : (CFG.trustProxy && req.headers['x-forwarded-proto']) || 'http';
headers['x-forwarded-host'] = req.headers.host || '';
return headers;
}
function proxy(req, res, ctx, bodyBuf) {
const started = Date.now();
const preq = upstreamMod.request({
protocol: CFG.upstream.protocol,
hostname: CFG.upstream.hostname,
port: CFG.upstream.port || (CFG.upstream.protocol === 'https:' ? 443 : 80),
method: req.method,
path: req.url,
headers: forwardHeaders(req, ctx.ip),
timeout: 60000,
}, (pres) => {
const headers = hardenHeaders(pres.headers);
for (const k of HOP) delete headers[k];
res.writeHead(pres.statusCode, pres.statusMessage, headers);
pres.on('data', (d) => { counters.bytes += d.length; });
pres.pipe(res);
pres.on('end', () => record(req, ctx, pres.statusCode, Date.now() - started));
});
preq.on('timeout', () => preq.destroy(new Error('Upstream timeout')));
preq.on('error', (e) => {
if (!res.headersSent) { res.writeHead(502, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE('Site unavailable', 'The website is not responding right now. Please try again in a moment.')); }
else res.destroy();
record(req, ctx, 502, Date.now() - started);
console.warn('[fluint] upstream error:', e.message);
});
if (bodyBuf) preq.end(bodyBuf); else req.pipe(preq);
}
function record(req, ctx, status, ms) {
if (ctx.fluint) return;
if (status === 404) {
const st = ipState(ctx.ip);
st.notFound++;
if (policy.probing.mode !== 'off' && st.notFound > policy.probing.max404PerMinute && !st.flagged.probe && !ctx.trusted) {
st.flagged.probe = true;
reportThreat({ type: 'probing', rule: '404-flood', ip: ctx.ip, method: req.method, path: ctx.pathname, sample: `${st.notFound} not-found pages in one minute`, ua: ctx.ua, action: policy.probing.mode === 'block' ? 'blocked' : 'logged' });
if (policy.probing.mode === 'block') bans.set(ctx.ip, Date.now() + 60 * 60000);
}
}
if (!isAsset(ctx.pathname) || status >= 400) {
if (hits.length < 8000) hits.push({ ts: Date.now(), ip: ctx.ip, method: req.method, path: ctx.pathname.slice(0, 300), status, ms, ua: ctx.ua, ref: req.headers.referer || '' });
}
}
// --- Visitor challenge (captcha for new IPs) --------------------------------------------------------
const CHALLENGE_PATH = '/.fluint/challenge';
const CHALLENGE_KEY = crypto.randomBytes(32);
const PASS_FILE = path.join(path.dirname(CFG.cacheFile), '.fluint-passes.json');
const passes = new Map(); // ip -> { at, exp }
const usedTokens = new Map(); // token -> expiry (one use only)
const botCache = new Map(); // ip -> { ok, exp }
let passesDirty = false;
try { for (const [ip, v] of Object.entries(JSON.parse(fs.readFileSync(PASS_FILE, 'utf8')))) passes.set(ip, v); } catch { /* first run */ }
function savePasses() {
if (!passesDirty) return;
passesDirty = false;
const now = Date.now();
const obj = {};
for (const [ip, v] of passes) if (v.exp > now) obj[ip] = v;
fs.writeFile(PASS_FILE, JSON.stringify(obj), () => {});
}
setInterval(() => {
savePasses();
const now = Date.now();
for (const [t, exp] of usedTokens) if (exp < now) usedTokens.delete(t);
for (const [ip, v] of botCache) if (v.exp < now) botCache.delete(ip);
}, 30000).unref();
const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const sha256hex = (s) => crypto.createHash('sha256').update(s).digest('hex');
const pathMatch = (lowerPath, p) => { const lp = p.toLowerCase(); return lowerPath === lp || lowerPath.startsWith(lp.replace(/\/?$/, '/')) || (lp.endsWith('/') && lowerPath.startsWith(lp)); };
function hasPassed(ip) {
const v = passes.get(ip);
if (!v) return false;
if (v.exp < Date.now() || v.at < (policy.challenge.resetAt || 0)) { passes.delete(ip); passesDirty = true; return false; }
return true;
}
function needsChallenge(ctx) {
const c = policy.challenge;
if (!c || !c.enabled || ctx.trusted || ctx.fluint) return false;
if (c.when === 'surge' && !underSurge()) return false;
const lp = ctx.pathname.toLowerCase();
if ((c.exemptPaths || []).some((p) => pathMatch(lp, p))) return false;
if ((c.onlyPaths || []).length && !c.onlyPaths.some((p) => pathMatch(lp, p))) return false;
return !hasPassed(ctx.ip);
}
// Real search-engine crawlers are verified by reverse + forward DNS (a user agent alone can be faked).
const SEARCH_UA = /Googlebot|Google-InspectionTool|AdsBot-Google|bingbot|Applebot|YandexBot|Baiduspider/i;
const SEARCH_HOSTS = /\.(googlebot\.com|google\.com|googleusercontent\.com|search\.msn\.com|applebot\.apple\.com|yandex\.(ru|net|com)|baidu\.(com|jp))$/i;
async function isVerifiedSearchBot(ip, ua) {
if (!SEARCH_UA.test(ua)) return false;
const cached = botCache.get(ip);
if (cached && cached.exp > Date.now()) return cached.ok;
let ok = false;
try {
for (const name of await dns.reverse(ip)) {
if (!SEARCH_HOSTS.test(name)) continue;
if ((await dns.lookup(name, { all: true })).some((a) => a.address === ip)) { ok = true; break; }
}
} catch { /* no PTR record */ }
botCache.set(ip, { ok, exp: Date.now() + 6 * 3600000 });
return ok;
}
function makeToken(ip) {
const ts = Date.now().toString(36);
const rand = crypto.randomBytes(8).toString('hex');
const mac = crypto.createHmac('sha256', CHALLENGE_KEY).update(`${ip}|${ts}|${rand}`).digest('hex').slice(0, 32);
return `${ts}.${rand}.${mac}`;
}
function checkToken(token, ip) {
const [ts, rand, mac] = String(token || '').split('.');
if (!ts || !rand || !mac || usedTokens.has(token)) return false;
if (Date.now() - parseInt(ts, 36) > 15 * 60000) return false;
const expected = crypto.createHmac('sha256', CHALLENGE_KEY).update(`${ip}|${ts}|${rand}`).digest('hex').slice(0, 32);
return mac.length === expected.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
}
const safeReturn = (r) => (typeof r === 'string' && r.startsWith('/') && !r.startsWith('//') && !/[\\\r\n]/.test(r) && r.length < 2000 ? r : '/');
// Public-domain compact SHA-256 so the proof-of-work also runs on plain-HTTP pages (no crypto.subtle there).
const SHA256_JS = "function sha256(a){function r(v,n){return(v>>>n)|(v<<(32-n))}var P=Math.pow,M=P(2,32),i,j,out='',w=[],L=a.length*8,h=sha256.h=sha256.h||[],k=sha256.k=sha256.k||[],pc=k.length,c={};for(var q=2;pc<64;q++){if(!c[q]){for(i=0;i<313;i+=q)c[i]=q;h[pc]=(P(q,.5)*M)|0;k[pc++]=(P(q,1/3)*M)|0}}a+='\\x80';while(a.length%64-56)a+='\\x00';for(i=0;i>8)return;w[i>>2]|=j<<((3-i)%4)*8}w[w.length]=((L/M)|0);w[w.length]=L;for(j=0;j>>3))+W[i-7]+(r(y,17)^r(y,19)^(y>>>10)))|0),t2=(r(A,2)^r(A,13)^r(A,22))+((A&h[1])^(A&h[2])^(h[1]&h[2]));h=[(t1+t2)|0].concat(h);h[4]=(h[4]+t1)|0}for(i=0;i<8;i++)h[i]=(h[i]+o[i])|0}for(i=0;i<8;i++){for(j=3;j+1;j--){var b=(h[i]>>(j*8))&255;out+=((b<16)?0:'')+b.toString(16)}}return out}";
function challengePage(ctx, returnTo, error) {
const c = policy.challenge;
const token = makeToken(ctx.ip);
const hidden = ``;
const body = c.provider === 'turnstile'
? `
`
: `
`;
return `Quick check
Quick check
To keep this site safe from bots, please confirm you're human. You'll only need to do this once.
${error ? `
${escHtml(error)}
` : ''}${body}
`;
}
function serveChallenge(req, res, ctx, error) {
counters.challenged++;
res.writeHead(403, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-fluint': 'challenge' });
res.end(challengePage(ctx, safeReturn(req.method === 'GET' ? req.url : '/'), error));
}
/** Resolves true if the visitor was shown/answered a challenge instead of the site. */
async function challengeGate(req, res, ctx) {
const c = policy.challenge;
if (c.allowSearchBots && await isVerifiedSearchBot(ctx.ip, ctx.ua)) return false;
if (req.method === 'GET' && /text\/html|\*\/\*/.test(String(req.headers.accept || '')) && !/^(application\/json)/.test(String(req.headers.accept || ''))) {
serveChallenge(req, res, ctx);
} else {
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'x-fluint': 'challenge' });
res.end('Verification required - open this site in a web browser first.');
}
return true;
}
function challengeSubmit(req, res, ctx) {
let body = '';
req.setEncoding('utf8');
req.on('data', (d) => { body += d; if (body.length > 16384) req.destroy(); });
req.on('end', async () => {
const f = new URLSearchParams(body);
const token = f.get('t');
const c = policy.challenge;
if (!c || !c.enabled) { res.writeHead(303, { location: safeReturn(f.get('r')) }); return res.end(); }
if (!checkToken(token, ctx.ip)) return serveChallenge(req, res, ctx, 'That check expired - please try again.');
let ok = false;
if (c.provider === 'turnstile') {
try {
const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: new URLSearchParams({ secret: c.turnstileSecret, response: f.get('cf-turnstile-response') || '', remoteip: ctx.ip }),
signal: AbortSignal.timeout(10000),
});
ok = !!(await r.json()).success;
} catch (e) { console.warn('[fluint] turnstile verify failed:', e.message); }
} else {
const n = f.get('n') || '';
ok = /^\d{1,12}$/.test(n) && sha256hex(token + n).startsWith('0'.repeat(c.difficulty));
}
if (!ok) return serveChallenge(req, res, ctx, 'Verification failed - please try again.');
usedTokens.set(token, Date.now() + 20 * 60000);
if (passes.size < 500000) passes.set(ctx.ip, { at: Date.now(), exp: Date.now() + c.passDays * 86400000 });
passesDirty = true;
counters.passed++;
res.writeHead(303, { location: safeReturn(f.get('r')), 'cache-control': 'no-store' });
res.end();
});
}
const TEXTUAL = /^(application\/(x-www-form-urlencoded|json|xml|graphql|.*\+json)|text\/)/i;
function handler(req, res) {
counters.requests++;
trackRps();
let pathname = '/';
try { pathname = new URL(req.url, 'http://x').pathname; } catch { /* keep / */ }
const ctx = { fluint: isFluintScan(req), ip: clientIp(req), pathname, decoded: safeDecode(req.url || ''), ua: String(req.headers['user-agent'] || '').slice(0, 300) };
if (pathname === '/.well-known/fluint-verify.txt' && verifyToken) {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end(verifyToken);
}
if (preChecks(req, res, ctx)) return;
if (pathname === CHALLENGE_PATH && req.method === 'POST') return challengeSubmit(req, res, ctx);
if (needsChallenge(ctx)) {
challengeGate(req, res, ctx)
.then((handled) => { if (!handled) proceed(req, res, ctx); })
.catch(() => proceed(req, res, ctx));
return;
}
proceed(req, res, ctx);
}
function proceed(req, res, ctx) {
// Inspect small textual bodies (forms, JSON APIs). File uploads stream straight through.
const len = Number(req.headers['content-length'] || 0);
const type = String(req.headers['content-type'] || '');
const bodyRules = ['sqli', 'xss', 'cmdi', 'ssrf', 'traversal'].filter((t) => policy.waf[t] && policy.waf[t] !== 'off');
if (!ctx.trusted && !ctx.fluint && req.method !== 'GET' && req.method !== 'HEAD' && len > 0 && len <= 1_000_000 && TEXTUAL.test(type) && bodyRules.length) {
const chunks = [];
let size = 0;
req.on('data', (d) => { size += d.length; if (size <= 1_000_000) chunks.push(d); });
req.on('end', () => {
const buf = Buffer.concat(chunks);
const text = safeDecode(buf.toString('utf8'));
for (const t of bodyRules) if (wafHit(req, res, ctx, t, text, RULES[t])) return;
proxy(req, res, ctx, buf);
});
return;
}
proxy(req, res, ctx, null);
}
// WebSockets pass through after the same IP checks.
function onUpgrade(req, socket, head) {
const ip = clientIp(req);
if (!compiled.allowed(ip) && (policy.lockdown || compiled.blocked(ip) || (bans.get(ip) || 0) > Date.now())) return socket.destroy();
let wsPath = '/';
try { wsPath = new URL(req.url, 'http://x').pathname; } catch { /* keep / */ }
if (needsChallenge({ ip, pathname: wsPath, trusted: compiled.allowed(ip), fluint: false })) return socket.destroy();
const preq = upstreamMod.request({
hostname: CFG.upstream.hostname,
port: CFG.upstream.port || (CFG.upstream.protocol === 'https:' ? 443 : 80),
method: req.method, path: req.url,
headers: { ...forwardHeaders(req, ip), connection: 'Upgrade', upgrade: req.headers.upgrade },
});
preq.on('upgrade', (pres, psock, phead) => {
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(pres.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n')}\r\n\r\n`);
if (phead && phead.length) socket.write(phead);
if (head && head.length) psock.write(head);
psock.pipe(socket).pipe(psock);
psock.on('error', () => socket.destroy());
socket.on('error', () => psock.destroy());
});
preq.on('response', (pres) => { socket.end(`HTTP/1.1 ${pres.statusCode} ${pres.statusMessage}\r\n\r\n`); });
preq.on('error', () => socket.destroy());
preq.end();
}
// --- Start -----------------------------------------------------------------------------------------
const server = CFG.tlsCert && CFG.tlsKey
? https.createServer({ cert: fs.readFileSync(CFG.tlsCert), key: fs.readFileSync(CFG.tlsKey) }, handler)
: http.createServer(handler);
server.on('upgrade', onUpgrade);
server.headersTimeout = 20000; // slowloris protection
server.requestTimeout = 120000;
server.keepAliveTimeout = 5000;
(async () => {
await pullPolicy();
setInterval(pullPolicy, 10000).unref();
setInterval(flush, 5000).unref();
server.listen(CFG.port, CFG.host, () => {
console.log(`Fluint agent ${VERSION} protecting ${CFG.upstream.origin} on ${CFG.tlsCert ? 'https' : 'http'}://${CFG.host}:${CFG.port}`);
console.log(`Reporting to ${CFG.server}${geoip ? ' (country blocking enabled)' : ''}`);
});
})();
function saveNow() {
passesDirty = true;
const now = Date.now();
const obj = {};
for (const [ip, v] of passes) if (v.exp > now) obj[ip] = v;
try { fs.writeFileSync(PASS_FILE, JSON.stringify(obj)); } catch { /* ignore */ }
}
process.on('SIGINT', async () => { saveNow(); await flush(); process.exit(0); });
process.on('SIGTERM', async () => { saveNow(); await flush(); process.exit(0); });