UNPKG

@edgedev/voidgates-watcher

Version:

Watches access logs and blocks abusive IPs via Cloudflare

266 lines (222 loc) 7.59 kB
import fetch from 'node-fetch' import { Tail } from 'tail' import { logger } from './logger.js' const { CF_API_TOKEN, CF_ACCOUNT_ID, VOIDGATES_ZONE, LOG_PATH, '404_BLOCK_THRESHOLD': BLOCK_THRESHOLD = 10, '404_BLOCK_INTERVAL_SECONDS': BLOCK_INTERVAL_SECONDS = 60, '404_BLOCK_DURATION_SECONDS': BLOCK_DURATION_SECONDS = '', PATH_BLOCK_THRESHOLD = 4, PATH_BLOCK_INTERVAL_SECONDS = 60, PATH_BLOCK_DURATION_SECONDS = 1800, CLEANUP_INTERVAL_SECONDS = 300, DRY_RUN = 'false', } = process.env console.log('apiToken:', CF_API_TOKEN) console.log('accountId:', CF_ACCOUNT_ID) console.log('zoneId:', VOIDGATES_ZONE) console.log('logPath:', LOG_PATH) const BLOCK_NOTE_PREFIX = 'VoidGates 404 Abuse -' const PATH_BLOCK_NOTE_PREFIX = 'VoidGates Path Abuse -' const ipTracker = new Map() const pathTracker = new Map() const intervalMs = Number(BLOCK_INTERVAL_SECONDS) * 1000 const pathIntervalMs = Number(PATH_BLOCK_INTERVAL_SECONDS) * 1000 const cleanupInterval = Number(CLEANUP_INTERVAL_SECONDS) * 1000 const blockDurationMs = BLOCK_DURATION_SECONDS ? Number(BLOCK_DURATION_SECONDS) * 1000 : null const pathBlockDurationMs = PATH_BLOCK_DURATION_SECONDS ? Number(PATH_BLOCK_DURATION_SECONDS) * 1000 : null if (!CF_API_TOKEN || !CF_ACCOUNT_ID || !LOG_PATH) { logger.error('Missing required environment variables.') process.exit(1) } function is404(line) { const match = line.match(/"\s(\d{3})\s/) return match && match[1] === '404' } function extractIP(line) { return line.split(' ')[0] } function extractPath(line) { const match = line.match(/"[A-Z]+\s([^\s]+)\sHTTP/) return match ? match[1] : null } function record404Hit(ip) { const now = Date.now() const data = ipTracker.get(ip) || { count: 0, start: now } if (now - data.start > intervalMs) { ipTracker.set(ip, { count: 1, start: now }) } else { data.count++ ipTracker.set(ip, data) } return data.count >= BLOCK_THRESHOLD } function recordPathHit(ip, path) { if (!path) return false // Skip common non-page paths if ( path.startsWith('/api') || path.startsWith('/wp-json/') || path.match(/\.(css|js|png|jpg|jpeg|gif|svg|webp|ico|woff2?|ttf|eot|mp4|webm|ogg|json)(\?|$)/i) ) { return false } const now = Date.now() const key = `${ip}:${path}` const data = pathTracker.get(key) || { count: 0, start: now } if (now - data.start > pathIntervalMs) { pathTracker.set(key, { count: 1, start: now }) } else { data.count++ pathTracker.set(key, data) } return data.count >= PATH_BLOCK_THRESHOLD } async function blockIp(ip, type = '404', path = null) { const timestamp = new Date().toISOString() const note = `${type === 'path' ? PATH_BLOCK_NOTE_PREFIX : BLOCK_NOTE_PREFIX} ${timestamp}` if (DRY_RUN === 'true') { logger.info(`[Dry Run] Block triggered for IP: ${ip} @ ${timestamp} [Type: ${type}${type === 'path' && path ? ` | Path: ${path}` : ''}]`) logger.debug(`[Dry Run] Would POST to Cloudflare with note: "${note}"`) ipTracker.delete(ip) return } const url = VOIDGATES_ZONE ? `https://api.cloudflare.com/client/v4/zones/${VOIDGATES_ZONE}/firewall/access_rules/rules` : `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/firewall/access_rules/rules` const body = { mode: 'block', configuration: { target: 'ip', value: ip, }, notes: note, } try { const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }) const json = await res.json() if (json.success) { logger.info(`Blocked IP: ${ip} @ ${timestamp} [Type: ${type}${type === 'path' && path ? ` | Path: ${path}` : ''}]`) } else { logger.error('Cloudflare block error:', JSON.stringify(json.errors)) } } catch (err) { logger.error('Block request failed:', err.message) } ipTracker.delete(ip) } async function cleanExpiredBlocks() { let page = 1 let hasMore = true const listUrl = VOIDGATES_ZONE ? `https://api.cloudflare.com/client/v4/zones/${VOIDGATES_ZONE}/firewall/access_rules/rules` : `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/firewall/access_rules/rules` while (hasMore) { try { const res = await fetch(`${listUrl}?page=${page}&per_page=100`, { headers: { Authorization: `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json', }, }) const json = await res.json() if (!json.success) { logger.error('Failed to list access rules:', JSON.stringify(json.errors)) return } const entries = json.result || [] for (const rule of entries) { const isPathBlock = rule.notes?.startsWith(PATH_BLOCK_NOTE_PREFIX) const is404Block = rule.notes?.startsWith(BLOCK_NOTE_PREFIX) if ((isPathBlock || is404Block) && ['ip', 'ip6'].includes(rule.configuration?.target)) { // console.log(rule) const timestamp = rule.notes.replace(`${isPathBlock ? PATH_BLOCK_NOTE_PREFIX : BLOCK_NOTE_PREFIX} `, '').trim() const expirationMs = isPathBlock ? pathBlockDurationMs : blockDurationMs if (!expirationMs || expirationMs <= 0) continue const expired = Date.now() - new Date(timestamp).getTime() > expirationMs if (expired) { await deleteRule(rule.id) } } } hasMore = json.result_info.page < json.result_info.total_pages page++ } catch (err) { logger.error('[Cleanup] Request failed:', err.message) return } } } async function deleteRule(id) { if (DRY_RUN === 'true') { logger.info(`[Dry Run] Would delete rule ID: ${id}`) return } const url = VOIDGATES_ZONE ? `https://api.cloudflare.com/client/v4/zones/${VOIDGATES_ZONE}/firewall/access_rules/rules/${id}` : `https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/firewall/access_rules/rules/${id}` try { const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${CF_API_TOKEN}`, 'Content-Type': 'application/json', }, }) const json = await res.json() if (json.success) { logger.info(`[Cleanup] Removed expired block ID: ${id}`) } else { logger.error(`[Cleanup] Failed to delete rule ID ${id}:`, JSON.stringify(json.errors)) } } catch (err) { logger.error(`[Cleanup] Delete request failed for ID ${id}:`, err.message) } } export function startWatcher() { const tail = new Tail(LOG_PATH) tail.on('line', async (line) => { const ip = extractIP(line) const path = extractPath(line) if (is404(line)) { if (record404Hit(ip)) { await blockIp(ip, '404') return } } if (recordPathHit(ip, path)) { await blockIp(ip, 'path', path) } }) function cleanupTrackers() { const now = Date.now() // Clean 404 tracker for (const [ip, data] of ipTracker.entries()) { if (now - data.start > intervalMs) { ipTracker.delete(ip) } } // Clean path tracker for (const [key, data] of pathTracker.entries()) { if (now - data.start > pathIntervalMs) { pathTracker.delete(key) } } } tail.on('error', (err) => { logger.error('Tail error:', err.message) }) logger.info('VoidGates Watcher is running...') setInterval(cleanupTrackers, 5 * 60 * 1000) setInterval(cleanExpiredBlocks, cleanupInterval) }