Unlink scripts submodule and commit contents as normal directory

This commit is contained in:
Kyle Speight committed 2026-06-14 03:42:18 -07:00
1 parent 4f7dea111f
commit 6488b3ea85
48 files changed
+2876 -1

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
const axios = require('axios');
const { version, repoName, repoUrl } = require('../repo.js');
const { SERVER_ID, EXTENDED_LOGS, SNIFFCAT_API_KEY, ABUSEIPDB_API_KEY, SEFIN_API_SECRET_TOKEN, CLOUDFLARE_API_KEY } = require('../../config.js').MAIN;
// Lazy-load logger
let logger;
const getLogger = () => logger || (logger = require('../logger.js'));
const DEFAULT_HEADERS = {
'User-Agent': `Mozilla/5.0 (compatible; ${repoName}/${version}; +${repoUrl})`,
'Accept': 'application/json',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
};
const HEADERS = {
json: { ...DEFAULT_HEADERS, 'Content-Type': 'application/json' },
multipart: { ...DEFAULT_HEADERS, 'Content-Type': 'multipart/form-data' },
};
const SERVICE_HEADERS = {
sniffcat: { ...HEADERS.json, 'X-Secret-Token': SNIFFCAT_API_KEY },
abuseipdb: { ...HEADERS.json, Key: ABUSEIPDB_API_KEY },
sefinek: { ...DEFAULT_HEADERS, 'X-API-Key': SEFIN_API_SECRET_TOKEN },
cloudflare: { ...HEADERS.json, Authorization: `Bearer ${CLOUDFLARE_API_KEY}` },
};
const BASE_URLS = {
sniffcat: 'https://api.sniffcat.com/api/v1',
abuseipdb: 'https://api.abuseipdb.com/api/v2',
};
const resolveServiceConfig = str => {
const lower = str.toLowerCase();
for (const [key, baseURL] of Object.entries(BASE_URLS)) {
if (lower.includes(key)) return { baseURL, headers: SERVICE_HEADERS[key], serviceKey: key };
}
return null;
};
const serviceConfig = resolveServiceConfig(repoName);
if (!serviceConfig) {
getLogger().log(`No matching baseURL found for repoName '${repoName}', expected one of: ${Object.keys(BASE_URLS).join(', ')}`, 3, true);
process.exit(1);
}
if (SERVER_ID === 'development' && EXTENDED_LOGS && process.argv.length <= 2) {
getLogger().log(`Base URL: ${serviceConfig.baseURL}`);
getLogger().log(JSON.stringify(serviceConfig.headers));
}
// Resolve headers for bulk (multipart + token)
const resolveBulkHeaders = serviceKey => {
if (!serviceKey || !SERVICE_HEADERS[serviceKey]) return HEADERS.multipart;
return { ...SERVICE_HEADERS[serviceKey], 'Content-Type': 'multipart/form-data' };
};
// Axios instances
const axiosGeneric = axios.create({ timeout: 20000, headers: DEFAULT_HEADERS });
const axiosService = axios.create({ baseURL: serviceConfig.baseURL, timeout: 30000, headers: serviceConfig.headers });
const axiosBulk = axios.create({ baseURL: serviceConfig.baseURL, timeout: 55000, headers: resolveBulkHeaders(serviceConfig.serviceKey) });
const axiosSefinek = axios.create({ timeout: 35000, headers: SERVICE_HEADERS.sefinek });
const axiosWebhook = axios.create({ timeout: 15000, headers: DEFAULT_HEADERS });
const axiosCloudflare = axios.create({ baseURL: 'https://api.cloudflare.com/client/v4', timeout: 30000, headers: SERVICE_HEADERS.cloudflare });
// Retry interceptor
const MAX_RETRIES = 3;
const RETRY_DELAY = count => count * 7000;
const RETRYABLE_CODES = new Set(['ECONNABORTED', 'ENOTFOUND', 'ETIMEDOUT']);
const isRetryable = err =>
RETRYABLE_CODES.has(err.code) ||
err.response?.status >= 500;
const hasStream = config => config.data && typeof config.data.pipe === 'function';
const applyRetry = instance => {
instance.interceptors.response.use(null, async err => {
const config = err.config;
if (!config) throw err;
config.__retryCount ??= 0;
if (config.__retryCount >= MAX_RETRIES || !isRetryable(err) || hasStream(config)) throw err;
config.__retryCount++;
const status = err.response?.status
? `Status ${err.response.status}`
: err.code || err.message || 'Unknown error';
getLogger().log(`${status} - retry #${config.__retryCount} for ${config.url}`, 2);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY(config.__retryCount)));
return instance(config);
});
};
[axiosGeneric, axiosService, axiosBulk, axiosSefinek, axiosWebhook, axiosCloudflare].forEach(applyRetry);
module.exports = Object.freeze({
axiosGeneric, // Generic client without baseURL
axiosService, // Main client for SniffCat, AbuseIPDB
axiosBulk, // Bulk client with multipart/form-data (CSV upload) for SniffCat and AbuseIPDB
axiosSefinek, // Client for Sefinek API
axiosWebhook, // Client for Discord webhooks
axiosCloudflare, // Client for Cloudflare API
HEADERS, // Predefined headers for all services
});
+187
View File
@@ -0,0 +1,187 @@
const FormData = require('form-data');
const { parse } = require('csv-parse/sync');
const { stringify } = require('csv-stringify/sync');
const fs = require('node:fs/promises');
const path = require('node:path');
const { axiosBulk } = require('../services/axios.js');
const { saveReportedIPs, markIPAsReported } = require('../services/cache.js');
const ABUSE_STATE = require('./state.js');
const { queueWrite, atomicWriteFile } = require('./writeQueue.js');
const logger = require('../logger.js');
const BULK_REPORT_BUFFER = new Map();
const BUFFER_FILE = path.join(__dirname, '..', '..', 'tmp', 'bulk-report-buffer.csv');
const OFFLINE_MODE = false;
const ensureDirectoryExists = async filePath => {
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
};
const saveBufferToFile = () => queueWrite(BUFFER_FILE, async () => {
if (!BULK_REPORT_BUFFER.size) return;
const records = [];
for (const [ip, entry] of BULK_REPORT_BUFFER.entries()) {
records.push([ip, entry.categories, new Date(entry.timestamp).toISOString(), entry.comment]);
}
try {
const output = stringify(records, { header: true, columns: ['IP', 'Categories', 'ReportDate', 'Comment'], quoted: true });
await ensureDirectoryExists(BUFFER_FILE);
await atomicWriteFile(BUFFER_FILE, output);
} catch (err) {
logger.error(`Failed to write buffer file: ${err.message}`, { ping: true });
}
});
const loadBufferFromFile = async () => {
try {
await fs.access(BUFFER_FILE);
const fileContent = await fs.readFile(BUFFER_FILE, 'utf-8');
const records = parse(fileContent, { columns: false, from_line: 2, skip_empty_lines: true, trim: true });
for (const record of records) {
const [ip, categories, timestamp, comment] = record;
if (!ip || !timestamp) continue;
BULK_REPORT_BUFFER.set(ip, { categories, timestamp: new Date(timestamp).getTime(), comment });
}
} catch (err) {
if (err.code !== 'ENOENT') logger.error(`Failed to parse/load buffer file: ${err.message}`);
}
};
const sendBulkReport = async () => {
if (!BULK_REPORT_BUFFER.size) return;
logger.info(`Starting bulk report upload (${BULK_REPORT_BUFFER.size} IPs)...`);
const records = Array.from(BULK_REPORT_BUFFER.entries(), ([ip, entry]) => [
ip,
entry.categories,
new Date(entry.timestamp ?? Date.now()).toISOString(),
entry.comment ? (entry.comment.length > 1024 ? entry.comment.substring(0, 1021) + '...' : entry.comment) : '',
]);
const sendChunk = async (chunk, index = 0, total = 1) => {
const payload = stringify(chunk, {
header: true,
columns: ['IP', 'Categories', 'ReportDate', 'Comment'],
quoted: true,
});
const lines = payload.split('\n').length;
logger.success(`${total > 1 ? `Chunk ${index + 1}/${total}: ` : ''}Generated CSV with ${chunk.length} records, ${lines} lines`);
if (OFFLINE_MODE) {
if (lines > 10000) {
const error = new Error('Line limit exceeded');
error.response = {
status: 422,
data: { errors: [{ detail: `File uploaded has ${lines} lines which exceeds the line limit of 10,000 lines.`, status: 422 }] },
};
throw error;
}
const saved = chunk.length;
logger.success(`${total > 1 ? `Chunk ${index + 1}/${total}: ` : 'Sent bulk report: '}${saved} accepted, 0 rejected [OFFLINE]`, { discord: true });
return;
}
if (lines > 10000) {
const error = new Error('Line limit exceeded');
error.response = {
status: 422,
data: { errors: [{ detail: `File uploaded has ${lines} lines which exceeds the line limit of 10,000 lines.`, status: 422 }] },
};
throw error;
}
const form = new FormData();
form.append('csv', Buffer.from(payload), {
filename: total > 1 ? `report-chunk-${index + 1}.csv` : 'report.csv',
contentType: 'text/csv',
});
const { data } = await axiosBulk.post('/bulk-report', form, { headers: { ...form.getHeaders() } });
const saved = data?.data?.savedReports ?? 0;
const failed = data?.data?.invalidReports?.length ?? 0;
logger.success(`${total > 1 ? `Chunk ${index + 1}/${total}: ` : 'Sent bulk report: '}${saved} accepted, ${failed} rejected`, { discord: true });
if (failed > 0) {
const prefix = total > 1 ? `Chunk ${index + 1}: ` : '';
data.data.invalidReports.forEach(fail => {
logger.warn(`${prefix}Rejected [Row ${fail.rowNumber}] ${fail.input} -> ${fail.error}`);
});
}
};
const isLineLimitError = err =>
err.response?.status === 422 &&
err.response?.data?.errors?.some(e => e.detail.includes('exceeds the line limit'));
let success = false;
try {
await sendChunk(records);
success = true;
} catch (err) {
if (isLineLimitError(err)) {
const chunks = [];
const estimatedLinesPerRecord = 10;
const chunkSize = Math.max(1, Math.floor(9000 / estimatedLinesPerRecord));
for (let i = 0; i < records.length; i += chunkSize) {
chunks.push(records.slice(i, i + chunkSize));
}
logger.info(`File too large. Splitting ${records.length} records into ${chunks.length} chunks (~${chunkSize} records each)`, { discord: true });
let allChunksSuccessful = true;
for (let i = 0; i < chunks.length; i++) {
try {
await sendChunk(chunks[i], i, chunks.length);
} catch (chunkErr) {
logger.error(`Chunk ${i + 1} failed: ${chunkErr.response?.data?.errors?.[0]?.detail || chunkErr.message}`);
allChunksSuccessful = false;
}
}
success = allChunksSuccessful;
} else {
const errorMsg = err.response?.data?.errors
? err.response.data.errors.map(e => e.detail).join(', ')
: err.response?.data ? JSON.stringify(err.response.data) : err.stack;
logger.error(errorMsg);
}
}
if (success) {
for (const ip of BULK_REPORT_BUFFER.keys()) markIPAsReported(ip);
await saveReportedIPs();
BULK_REPORT_BUFFER.clear();
try {
await fs.unlink(BUFFER_FILE);
} catch (err) {
// Ignore file deletion errors - file may not exist or already deleted
if (err.code !== 'ENOENT') logger.warn(`Failed to delete buffer file: ${err.message}`);
}
ABUSE_STATE.sentBulk = true;
} else {
logger.error('Bulk upload failed, keeping buffer file for retry', { ping: true });
}
};
module.exports = Object.freeze({
saveBufferToFile,
loadBufferFromFile,
sendBulkReport,
BULK_REPORT_BUFFER,
});
+80
View File
@@ -0,0 +1,80 @@
const { dirname } = require('node:path');
const fs = require('node:fs/promises');
const resolvePath = require('../pathResolver.js');
const { CACHE_FILE, IP_REPORT_COOLDOWN } = require('../../config.js').MAIN;
const { queueWrite, atomicWriteFile } = require('./writeQueue.js');
const logger = require('../logger.js');
const reportedIPs = new Map();
const RESOLVED_CACHE_FILE = resolvePath(CACHE_FILE);
const ensureCacheDir = async () => {
const dir = dirname(RESOLVED_CACHE_FILE);
try {
await fs.access(dir);
} catch (err) {
if (err.code === 'ENOENT') {
await fs.mkdir(dir, { recursive: true });
logger.success(`Created cache directory: ${dir}`);
} else {
logger.error(`Failed to access cache directory: ${err.stack}`);
}
}
};
const loadReportedIPs = async () => {
if (!RESOLVED_CACHE_FILE) return;
await ensureCacheDir();
try {
const fileContent = await fs.readFile(RESOLVED_CACHE_FILE, 'utf8');
fileContent
.split('\n')
.filter(Boolean)
.forEach(line => {
const [ip, time] = line.trim().split(/\s+/);
if (ip && !isNaN(time)) reportedIPs.set(ip, Number(time));
});
} catch (err) {
if (err.code === 'ENOENT') {
logger.info(`${RESOLVED_CACHE_FILE} does not exist. No data to load.`);
} else {
logger.error(`Failed to load cache file: ${err.stack}`);
}
}
};
const saveReportedIPs = () => {
if (!RESOLVED_CACHE_FILE) return Promise.resolve();
return queueWrite(RESOLVED_CACHE_FILE, async () => {
await ensureCacheDir();
try {
const data = Array.from(reportedIPs)
.map(([ip, time]) => `${ip} ${time}`)
.join('\n');
await atomicWriteFile(RESOLVED_CACHE_FILE, data);
} catch (err) {
logger.error(`Failed to save cache file: ${err.stack}`);
}
});
};
const IP_REPORT_COOLDOWN_SECONDS = IP_REPORT_COOLDOWN / 1000;
const isIPReportedRecently = ip => {
const reportedTime = reportedIPs.get(ip);
if (!reportedTime) return false;
return (Math.floor(Date.now() / 1000) - reportedTime < IP_REPORT_COOLDOWN_SECONDS);
};
const markIPAsReported = ip => reportedIPs.set(ip, Math.floor(Date.now() / 1000));
module.exports = Object.freeze({
reportedIPs,
loadReportedIPs,
saveReportedIPs,
isIPReportedRecently,
markIPAsReported,
});
+144
View File
@@ -0,0 +1,144 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { existsSync } = require('node:fs');
const { parse } = require('csv-parse/sync');
const { stringify } = require('csv-stringify/sync');
const logger = require('../../logger.js');
const CSV_FILE = path.join(__dirname, '..', '..', '..', 'tmp', 'reported_ips.csv');
const CSV_COLUMNS = ['Timestamp', 'CF RayID', 'IP', 'Country', 'Hostname', 'Endpoint', 'User-Agent', 'Action taken', 'Status', 'Sefinek API'];
const MAX_CSV_SIZE = 1024 * 1024;
const CSV_STRINGIFY_OPTS = {
header: true,
columns: CSV_COLUMNS,
record_delimiter: '\n',
quoted: true,
quoted_empty: true,
quoted_string: true,
};
const CSV_PARSE_OPTS = {
columns: true,
skip_empty_lines: true,
trim: false,
skip_records_with_empty_values: false,
};
const ensureCSVExists = async () => {
try {
if (existsSync(CSV_FILE)) return;
await fs.mkdir(path.dirname(CSV_FILE), { recursive: true });
await fs.writeFile(CSV_FILE, stringify([], CSV_STRINGIFY_OPTS));
logger.success(`Created missing CSV file: ${CSV_FILE}`);
} catch (err) {
logger.error(`Failed to ensure CSV exists: ${err.stack}`);
throw err;
}
};
const checkCSVSize = async () => {
try {
if (!existsSync(CSV_FILE)) return;
const stats = await fs.stat(CSV_FILE);
if (stats.size > MAX_CSV_SIZE) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${CSV_FILE}.${timestamp}.bkp`;
await fs.copyFile(CSV_FILE, backupPath);
await fs.writeFile(CSV_FILE, stringify([], CSV_STRINGIFY_OPTS));
logger.success(`CSV size limit exceeded (${(MAX_CSV_SIZE / (1024 * 1024)).toFixed(2)} MB). Backup saved as "${path.basename(backupPath)}", original file cleared.`);
}
} catch (err) {
logger.error(`Failed to check or clear CSV size: ${err.stack}`);
}
};
const logToCSV = async (event, status = 'N/A', sefinekAPI = false) => {
try {
await ensureCSVExists();
await checkCSVSize();
const {
rayName, clientIP, clientCountryName,
clientRequestHTTPHost, clientRequestPath,
userAgent, action,
} = event;
const row = {
'Timestamp': new Date().toISOString(),
'CF RayID': rayName,
'IP': clientIP,
'Country': clientCountryName,
'Hostname': clientRequestHTTPHost,
'Endpoint': clientRequestPath,
'User-Agent': userAgent,
'Action taken': action.toUpperCase(),
'Status': status,
'Sefinek API': String(sefinekAPI),
};
const line = stringify([row], { ...CSV_STRINGIFY_OPTS, header: false });
await fs.appendFile(CSV_FILE, line);
} catch (err) {
logger.error(`Failed to log event to CSV: ${err.stack}`);
}
};
const readReportedIPs = async () => {
if (!existsSync(CSV_FILE)) return [];
try {
const content = await fs.readFile(CSV_FILE, 'utf-8');
const records = parse(content, CSV_PARSE_OPTS);
return records.map(row => ({
timestamp: Date.parse(row['Timestamp']),
rayId: row['CF RayID'],
ip: row['IP'],
country: row['Country'],
hostname: row['Hostname'],
endpoint: row['Endpoint'],
userAgent: row['User-Agent'],
action: row['Action taken'],
status: row['Status'],
sefinekAPI: row['Sefinek API'] === 'true',
}));
} catch (err) {
logger.error(`Failed to parse or map CSV content: ${err.stack}`);
return [];
}
};
const batchUpdateSefinekAPIInCSV = async (rayIds = []) => {
if (!existsSync(CSV_FILE) || !rayIds.length) return;
try {
const content = await fs.readFile(CSV_FILE, 'utf-8');
const records = parse(content, CSV_PARSE_OPTS);
const raySet = new Set(rayIds);
let updated = false;
for (const row of records) {
if (raySet.has(row['CF RayID']) && row['Sefinek API'] !== 'true') {
row['Sefinek API'] = 'true';
updated = true;
}
}
if (updated) {
const output = stringify(records, CSV_STRINGIFY_OPTS);
await fs.writeFile(CSV_FILE, output);
}
} catch (err) {
logger.error(`Batch CSV update failed: ${err.stack}`);
}
};
module.exports = Object.freeze({
logToCSV,
readReportedIPs,
batchUpdateSefinekAPIInCSV,
});
@@ -0,0 +1,55 @@
const { MAIN } = require('../../../config.js');
module.exports = zoneTag => {
if (!zoneTag) throw new Error('zoneTag is null or undefined');
const variables = {
zoneTag,
filter: {
datetime_geq: new Date(Date.now() - MAIN.CLOUDFLARE_TIME_RANGE || 12 * 60 * 60 * 1000).toISOString(),
AND: [
{ action_neq: 'allow' },
{ action_neq: 'skip' },
{ action_neq: 'challenge_solved' },
{ action_neq: 'challenge_failed' },
{ action_neq: 'challenge_bypassed' },
{ action_neq: 'jschallenge_solved' },
{ action_neq: 'jschallenge_failed' },
{ action_neq: 'jschallenge_bypassed' },
{ action_neq: 'managed_challenge_skipped' },
{ action_neq: 'managed_challenge_non_interactive_solved' },
{ action_neq: 'managed_challenge_interactive_solved' },
{ action_neq: 'managed_challenge_bypassed' },
],
},
};
return { query: `
query ListFirewallEvents($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
firewallEventsAdaptive(
filter: $filter,
limit: ${MAIN.CLOUDFLARE_EVENTS_LIMIT || 3000},
orderBy: [datetime_DESC]
) {
action
clientASNDescription
clientAsn
clientCountryName
clientIP
clientRequestHTTPHost
clientRequestHTTPMethodName
clientRequestHTTPProtocol
clientRequestPath
clientRequestQuery
datetime
rayName
ruleId
source
userAgent
}
}
}
}`, variables };
};
@@ -0,0 +1,64 @@
const fs = require('node:fs');
const path = require('node:path');
const PAYLOAD = require('./generateFirewallQuery.js');
const { axiosCloudflare } = require('../axios.js');
const logger = require('../../logger.js');
const { MAIN } = require('../../../config.js');
const ensureFile = filePath => {
if (fs.existsSync(filePath)) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '');
};
const fetchCloudflareEvents = async () => {
const rawZoneIds = MAIN.CLOUDFLARE_ZONE_IDS || MAIN.CLOUDFLARE_ZONE_ID;
const zoneIds = Array.isArray(rawZoneIds) ? rawZoneIds : [rawZoneIds];
const allEvents = [];
for (const zoneId of zoneIds) {
try {
const { data, status } = await axiosCloudflare.post('/graphql', PAYLOAD(zoneId));
const events = data?.data?.viewer?.zones?.[0]?.firewallEventsAdaptive;
if (!events) throw new Error(`Failed to retrieve data from Cloudflare (status ${status}): ${JSON.stringify(data?.errors)}`);
const l7ddosEvents = events.filter(e => e.source === 'l7ddos');
logger.success(`Zone ${zoneId}: ${events.length} events fetched (${l7ddosEvents.length} L7 DDoS)`);
allEvents.push(...l7ddosEvents);
} catch (err) {
logger.error(err.response?.data ? `${err.response.status} HTTP ERROR for zone ${zoneId}: ${JSON.stringify(err.response.data, null, 2)}` : `Unknown error for zone ${zoneId}: ${err.message}`);
}
}
return allEvents;
};
const saveToCSV = (events, filePath = 'report.csv') => {
const map = new Map();
for (const { clientIP, datetime } of events) {
if (!map.has(clientIP)) map.set(clientIP, `${clientIP},4,${new Date(datetime).toISOString()},Detected L7 DDoS via Cloudflare`);
}
ensureFile(filePath);
if (!fs.statSync(filePath).size) fs.appendFileSync(filePath, 'IP,Categories,ReportDate,Comment\n');
fs.appendFileSync(filePath, [...map.values()].join('\n') + '\n');
};
const saveToTXT = (events, filePath = 'report.txt') => {
const uniqueIPs = new Set(events.map(e => e.clientIP));
ensureFile(filePath);
fs.appendFileSync(filePath, [...uniqueIPs].join('\n') + '\n');
};
(async () => {
const events = await fetchCloudflareEvents();
if (events.length) {
saveToCSV(events);
saveToTXT(events);
logger.success(`Saved ${events.length} L7 DDoS events to CSV and TXT`);
} else {
logger.info('No L7 DDoS events found');
}
})();
@@ -0,0 +1,22 @@
const { axiosGeneric } = require('../axios.js');
const logger = require('../../logger.js');
module.exports = async () => {
try {
const res = await axiosGeneric.get('https://api.sefinek.net/api/v2/filter-rules');
const data = res.data;
if (!data.success || typeof data !== 'object') {
throw new Error(`Sefinek API error: Invalid response (success=${data.success})`);
}
const { userAgents, domains, endpoints, imgExtensions, lastUpdate } = data;
if (!Array.isArray(userAgents) || !Array.isArray(domains) || !Array.isArray(endpoints) || !Array.isArray(imgExtensions)) {
throw new Error('Sefinek API error: Response is missing expected arrays');
}
logger.success(`Filter rules loaded from api.sefinek.net, last update: ${lastUpdate}`);
return { userAgents, domains, endpoints, imgExtensions, lastUpdate };
} catch (err) {
throw new Error(`Failed to fetch filter rules: ${err.message}`, { cause: err });
}
};
@@ -0,0 +1,56 @@
const FormData = require('form-data');
const logger = require('../../logger.js');
const { readReportedIPs, batchUpdateSefinekAPIInCSV } = require('./csv.js');
const { axiosSefinek } = require('../axios.js');
const { getServerIPs } = require('../ipFetcher.js');
module.exports = async () => {
const serverIPs = getServerIPs();
const reportedIPs = (await readReportedIPs() || []).filter(x =>
['REPORTED', 'READY_FOR_BULK_REPORT', 'RL_BULK_REPORT'].includes(x.status) &&
!serverIPs.includes(x.ip) &&
!x.sefinekAPI
);
if (!reportedIPs.length) return logger.info('Sefinek API: No data to report');
const seenIPs = new Set();
const uniqueLogs = reportedIPs.filter(ip => {
if (seenIPs.has(ip.ip)) return false;
seenIPs.add(ip.ip);
return true;
});
if (!uniqueLogs.length) return logger.info(`Sefinek API: No unique IPs to send (reportedIPs = ${reportedIPs.length}; uniqueLogs = ${uniqueLogs.length})`);
try {
const payload = uniqueLogs.map(ip => ({
rayId: ip.rayId,
ip: ip.ip,
endpoint: ip.endpoint,
userAgent: ip.userAgent,
action: ip.action,
country: ip.country,
timestamp: ip.timestamp,
}));
const base64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
const form = new FormData();
form.append('file', base64, {
filename: 'reports.dat',
contentType: 'application/octet-stream',
});
const res = await axiosSefinek.post('https://api.sefinek.net/api/v2/cloudflare-waf-abuseipdb', form, { headers: { ...form.getHeaders() } });
// const res = await axiosSefinek.post('http://127.0.0.1:4010/api/v2/cloudflare-waf-abuseipdb', form, { headers: { ...form.getHeaders() } });
if (res.data.success) {
logger.success(`Sefinek API (status: ${res.status}): Successfully sent ${uniqueLogs.length} logs`);
await batchUpdateSefinekAPIInCSV(uniqueLogs.map(x => x.rayId));
} else {
logger.warn(`Sefinek API (status: ${res.status}): ${res.data.message || 'Something went wrong'}`);
}
} catch (err) {
if (err.response?.data?.message?.includes('No valid or unique')) return;
const msg = err.response?.data?.message ?? err.message;
logger.error(`Sefinek API (status: ${err.response?.status ?? 'unknown'}): Failed to send logs! Message: ${typeof msg === 'object' ? JSON.stringify(msg) : msg}`, { ping: true });
}
};
+84
View File
@@ -0,0 +1,84 @@
const { networkInterfaces } = require('node:os');
const https = require('node:https');
const { CronJob } = require('cron');
const { axiosSefinek } = require('./axios.js');
const isSpecialPurposeIP = require('../isSpecialPurposeIP.js');
const logger = require('../logger.js');
const { IP_ASSIGNMENT, IP_REFRESH_SCHEDULE, IPv6_SUPPORT } = require('../../config.js').MAIN;
const ipAddresses = new Set();
let ipv6ErrorCount = 0, ipv6ErrorLogged = false;
let isRefreshing = false;
const fetchLocalIPs = () => {
for (const ifaceList of Object.values(networkInterfaces())) {
for (const iface of ifaceList) {
const addr = iface?.address;
if (addr && !iface.internal && !isSpecialPurposeIP(addr).is) ipAddresses.add(addr);
}
}
};
const fetchIPAddress = async (family, attempt = 1) => {
if (family === 0) return fetchLocalIPs();
if (family === 6 && (!IPv6_SUPPORT || ipv6ErrorLogged)) return;
try {
const { data } = await axiosSefinek.get('https://api.sefinek.net/api/v2/ip', {
httpsAgent: new https.Agent({ family }),
});
if (data?.success && data?.message) {
ipAddresses.add(data.message);
if (family === 6 && ipv6ErrorCount > 0) {
logger.success(`IPv6 is now working. It succeeded after ${ipv6ErrorCount} failed attempts.`);
ipv6ErrorCount = 0;
ipv6ErrorLogged = false;
}
} else {
logger.warn(`Unexpected API response: success=${data?.success}, message=${data?.message}`);
}
} catch (err) {
logger.error(`Failed to fetch IPv${family} address (attempt ${attempt}): ${err.message}`);
if (family === 6) {
ipv6ErrorCount++;
if (ipv6ErrorCount >= 6 && !ipv6ErrorLogged) {
ipv6ErrorLogged = true;
logger.warn('IPv6 address could not be retrieved after multiple attempts. Disabling further checks.');
} else if (attempt < 6) {
await new Promise(resolve => setTimeout(resolve, 4000));
await fetchIPAddress(6, attempt + 1);
}
}
}
};
const refreshServerIPs = async () => {
if (isRefreshing) {
logger.info('IP refresh already in progress, skipping...');
return;
}
isRefreshing = true;
logger.info('Trying to fetch your IPv4 and IPv6 address from api.sefinek.net...');
try {
await Promise.all([fetchIPAddress(0), fetchIPAddress(4), fetchIPAddress(6)]);
} catch (err) {
logger.error(`Failed to refresh IP addresses: ${err.message}`);
} finally {
isRefreshing = false;
}
};
(async () => {
if (IP_ASSIGNMENT === 'dynamic') new CronJob(IP_REFRESH_SCHEDULE || '0 */6 * * *', refreshServerIPs, null, true);
})();
module.exports = Object.freeze({
refreshServerIPs,
getServerIPs: () => [...ipAddresses],
});
+41
View File
@@ -0,0 +1,41 @@
const { exec } = require('node:child_process');
const ecosystem = require('../../ecosystem.config.js');
const logger = require('../logger.js');
const EXEC_TIMEOUT = 60000;
const executeCmd = cmd => new Promise((resolve, reject) => {
exec(cmd, { timeout: EXEC_TIMEOUT }, (err, stdout, stderr) => {
if (err) return reject(err);
if (stderr) logger.warn(`Warning while executing: ${cmd}\n${stderr}`);
resolve(stdout.trim());
});
});
const CMD_1 = 'npm install --omit=dev';
const APP_NAME = (() => {
if (!ecosystem.apps?.[0]?.name) throw new Error('Missing app name in ecosystem config');
return ecosystem.apps[0].name;
})();
const CMD_2 = `pm2 restart ${APP_NAME}`;
module.exports = async () => {
try {
// 1 - npm dependencies
logger.info(`Running npm '${CMD_1}'...`);
const result1 = await executeCmd(CMD_1);
logger.info(result1, { discord: true });
// 2 - restart
if (process.env.pm_id !== undefined) {
logger.info(`Running '${CMD_2}'...`);
await executeCmd(CMD_2);
} else {
logger.info('Process is not managed by PM2! Exiting to apply updates. To start again, run: node . (one-time), pm2 start (24/7 with auto-restart).', { discord: true });
process.exit(0);
}
} catch (err) {
logger.error(err.stack);
}
};
+1
View File
@@ -0,0 +1 @@
module.exports = { isLimited: false, isBuffering: false, sentBulk: false };
+99
View File
@@ -0,0 +1,99 @@
const { CronJob } = require('cron');
const fs = require('node:fs/promises');
const logger = require('../logger.js');
const resolvePath = require('../pathResolver.js');
const { SERVER_ID, EXTENDED_LOGS, CACHE_FILE } = require('../../config.js').MAIN;
const RESOLVED_CACHE_FILE = resolvePath(CACHE_FILE);
const pad = n => n.toString().padStart(2, '0');
const formatHourRange = h => `${pad(h)}:00-${pad(h)}:59`;
const pluralizeReport = n => (n === 1 ? 'report' : 'reports');
const summaryEmbed = async () => {
const now = new Date();
const midnightLocal = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(midnightLocal.getTime() - 86400000);
const yesterdayString = `${yesterday.getFullYear()}-${pad(yesterday.getMonth() + 1)}-${pad(yesterday.getDate())}`;
const baseTs = Math.floor(Date.UTC(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate()) / 1000);
if (EXTENDED_LOGS) {
logger.info(`Daily summary » Using base timestamp: ${baseTs} (${new Date(baseTs * 1000).toISOString()}) for ${yesterdayString}`);
}
let data;
if (SERVER_ID === 'development') {
logger.info('Daily summary » Using test data instead of reading cache file [DEV]');
const generateRandomIp = () => Array(4).fill(0).map(() => Math.floor(Math.random() * 256)).join('.');
const generateTimestamps = () => baseTs + Math.floor(Math.random() * 86400);
const entries = Array.from({ length: Math.floor(Math.random() * 1001) + 4000 }, () => `${generateRandomIp()} ${generateTimestamps()}`);
data = entries.join('\n');
} else {
try {
await fs.access(RESOLVED_CACHE_FILE);
data = (await fs.readFile(RESOLVED_CACHE_FILE, 'utf8')).trim();
if (!data) return logger.warn(`Daily summary » Cache file exists but is empty: ${RESOLVED_CACHE_FILE}`);
} catch (err) {
return logger.warn(`Daily summary » Failed to access/read cache file: ${err.message}`);
}
}
try {
const hourlySummary = {};
const seen = new Set();
for (const line of data.split('\n')) {
const [ip, tsStr] = line.split(' ');
const ts = parseInt(tsStr, 10);
if (!ip || isNaN(ts)) continue;
const key = `${ip}_${ts}`;
if (seen.has(key)) continue;
seen.add(key);
const date = new Date(ts * 1000);
const dateStr = date.toISOString().split('T')[0];
if (dateStr !== yesterdayString) continue;
const hour = date.getUTCHours();
hourlySummary[hour] = (hourlySummary[hour] || 0) + 1;
}
if (!Object.keys(hourlySummary).length) {
return logger.warn(`Daily summary » No reports found for ${yesterdayString}`);
}
const sortedChrono = Object.entries(hourlySummary)
.map(([h, c]) => [parseInt(h), c])
.sort((a, b) => a[0] - b[0]);
const top3Sorted = [...Object.entries(hourlySummary)]
.map(([h, c]) => [parseInt(h), c])
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const top3 = top3Sorted.map(([h]) => h);
const total = sortedChrono.reduce((sum, [, c]) => sum + c, 0);
const summaryStr = sortedChrono
.map(([h, c]) => `${formatHourRange(h)} → ${c} ${pluralizeReport(c)}${top3.includes(h) ? ' 🔥' : ''}`)
.join('\n');
logger.info(`Midnight. Summary of IP address reports (${total}) from ${yesterdayString}:\n${summaryStr}`);
const peakStr = top3Sorted
.map(([h, c]) => `${formatHourRange(h)} → ${c} ${pluralizeReport(c)}`)
.join('\n');
logger.info(`Top 3 peaks:\n${peakStr}`);
await logger.webhook(
`Midnight. Summary of IP address reports from yesterday.\nGood night to you, sleep well! 😴\n### ${total} ${pluralizeReport(total)} from ${yesterdayString}\n\`\`\`${summaryStr}\`\`\`\n### Top 3 peaks\n\`\`\`${peakStr}\`\`\``,
0x00FF39
);
} catch (err) {
logger.error(`Daily summary » Unexpected error:\n${err.stack}`);
}
};
module.exports = async () => {
// await summaryEmbed();
new CronJob('0 0 * * *', summaryEmbed, null, true);
};
+60
View File
@@ -0,0 +1,60 @@
const fs = require('node:fs');
const path = require('node:path');
const TailFile = require('@logdna/tail-file');
const split2 = require('split2');
const logger = require('../logger.js');
/**
* Starts tailing a log file, calling onLine for each new line.
* If the file does not exist, waits for it to be created (directory must exist).
*
* @param {string} filePath - Path to the log file.
* @param {Function} onLine - Callback invoked for each new line (receives the line string).
* @param {Object} [options]
* @param {string} [options.label] - Label for log messages (e.g., 'DIONAEA', 'SURICATA').
* @returns {Promise<TailFile|null>} Resolves with the TailFile instance once tailing starts, or null on error.
*/
module.exports = (filePath, onLine, options = {}) => {
const { label } = options;
const prefix = label ? `${label} -> ` : '';
const start = async () => {
const tail = new TailFile(filePath);
const onError = err => logger.error(`${prefix}${err.message || err}`);
tail.on('tail_error', onError);
tail.on('error', onError);
await tail.start();
tail.pipe(split2()).on('data', onLine).on('error', onError);
return tail;
};
if (fs.existsSync(filePath)) return start();
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
const filename = path.basename(resolved);
if (!fs.existsSync(dir)) {
logger.error(`${prefix}Log file directory does not exist: ${dir}`);
return Promise.resolve(null);
}
logger.warn(`${prefix}Log file does not exist yet: ${filePath}. Waiting for it to be created...`);
return new Promise(resolve => {
const watcher = fs.watch(dir, (event, file) => {
if (file === filename && fs.existsSync(filePath)) {
watcher.close();
logger.info(`${prefix}Log file detected: ${filePath}. Starting tail...`);
resolve(start());
}
});
watcher.on('error', err => {
logger.error(`${prefix}Failed to watch directory ${dir}: ${err.message}`);
resolve(null);
});
});
};
+91
View File
@@ -0,0 +1,91 @@
const simpleGit = require('simple-git');
const semver = require('semver');
const { CronJob } = require('cron');
const fs = require('node:fs');
const path = require('node:path');
const restartApp = require('./restartApp.js');
const logger = require('../logger.js');
const { SERVER_ID, AUTO_UPDATE_SCHEDULE, EXTENDED_LOGS } = require('../../config.js').MAIN;
const git = simpleGit();
const pkgPath = path.resolve(__dirname, '../../package.json');
const LOCK_FILE = path.resolve(__dirname, '../../.git/index.lock');
const STALE_LOCK_THRESHOLD = 60_000;
const getLocalVersion = () => {
delete require.cache[require.resolve(pkgPath)];
return require(pkgPath).version;
};
const cleanStaleLock = () => {
try {
const stat = fs.statSync(LOCK_FILE);
if (Date.now() - stat.mtimeMs > STALE_LOCK_THRESHOLD) {
fs.unlinkSync(LOCK_FILE);
logger.warn('Removed stale .git/index.lock file', { discord: true });
}
} catch (err) {
if (err.code !== 'ENOENT') logger.error(`Failed to check/remove index.lock: ${err.message}`);
}
};
const pull = async () => {
try {
if (SERVER_ID !== 'development') {
if (EXTENDED_LOGS) logger.info('Resetting local repository to HEAD (--hard)...');
await git.reset(['--hard']);
}
logger.info('Pulling the repository and the required submodule...');
// Get submodule status before update
const submoduleBefore = await git.raw(['submodule', 'status']);
// Pull main repo and update submodules
const pullResult = await git.pull();
await git.submoduleUpdate(['--init', '--recursive', '--remote', '--merge']);
// Get submodule status after update
const submoduleAfter = await git.raw(['submodule', 'status']);
const submoduleChanged = submoduleBefore !== submoduleAfter;
const { changes, insertions, deletions } = pullResult.summary;
const mainRepoChanged = changes > 0 || insertions > 0 || deletions > 0;
const hasChanges = mainRepoChanged || submoduleChanged;
if (hasChanges) {
const parts = [];
if (mainRepoChanged) parts.push(`Main repo - Changes: ${changes}; Insertions: ${insertions}; Deletions: ${deletions};`);
if (submoduleChanged) parts.push('Submodule updated.');
logger.success(`Updates pulled successfully! ${parts.join(' ')}`, { discord: true });
} else {
logger.info('No new updates detected');
}
return hasChanges;
} catch (err) {
logger.error(err.stack);
return null;
}
};
const pullAndRestart = async () => {
try {
cleanStaleLock();
const oldVersion = getLocalVersion();
const hasChanges = await pull();
if (!hasChanges) return;
const newVersion = getLocalVersion();
if (semver.neq(newVersion, oldVersion)) {
logger.success(`Version changed: ${oldVersion} → ${newVersion}`, { discord: true });
}
await restartApp();
} catch (err) {
logger.error(err.stack);
}
};
new CronJob(AUTO_UPDATE_SCHEDULE, pullAndRestart, null, true);
module.exports = pullAndRestart;
+22
View File
@@ -0,0 +1,22 @@
const semver = require('semver');
const { axiosGeneric } = require('./axios.js');
const { version, repoSlug } = require('../repo.js');
const logger = require('../logger.js');
const PACKAGE_JSON_URL = `https://raw.githubusercontent.com/${repoSlug}/main/package.json`;
(async () => {
logger.info('Checking for new versions...');
try {
const { data: { version: latest } } = await axiosGeneric.get(PACKAGE_JSON_URL);
if (semver.gt(latest, version)) {
logger.info(`A new version is available! v${version} → v${latest}. Please update.`, { discord: true });
} else if (semver.gt(version, latest)) {
logger.info(`Local version v${version} is ahead of remote v${latest}`, { discord: true });
}
} catch (err) {
logger.error(`Version check failed: ${err.stack}`);
}
})();
+154
View File
@@ -0,0 +1,154 @@
const fs = require('node:fs');
const path = require('node:path');
const logger = require('../logger.js');
const isSpecialPurposeIP = require('../isSpecialPurposeIP.js');
const filePath = path.resolve(__dirname, '..', '..', 'whitelist.txt');
let whitelistedIPs = new Set();
let whitelistedPorts = new Set(); // @chillcog Port Whitelist
let reloadTimer = null;
const parse = content => {
// @chillcog Port Whitelist
const ipSet = new Set();
const portSet = new Set();
const invalidIPs = [], skippedIPs = [];
const invalidPorts = [], skippedPorts = [];
let section = 'ip';
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const header = raw.trim().toLowerCase();
if (header.includes('[port whitelist]') || (header.includes('port whitelist') && header.includes('['))) {
section = 'port';
continue;
}
if (header.includes('[ip whitelist]') || (header.includes('ip whitelist') && header.includes('['))) {
section = 'ip';
continue;
}
const line = raw.split('#')[0].trim();
if (!line) continue;
if (section === 'port') {
const portStr = String(line).split(':').pop().split('/')[0].trim();
const port = Number(portStr);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
invalidPorts.push(`${line} (line ${i + 1})`);
continue;
}
if (portSet.has(port)) {
skippedPorts.push(`${line} (line ${i + 1})`);
continue;
}
portSet.add(port);
continue;
}
const { is, range } = isSpecialPurposeIP(line);
if (range === null) {
invalidIPs.push(`${line} (line ${i + 1})`);
continue;
}
if (is) {
skippedIPs.push(`${line} (${range})`);
continue;
}
ipSet.add(line);
}
if (invalidIPs.length > 0) {
logger.warn(`Invalid IP address${invalidIPs.length > 1 ? 'es' : ''} in whitelist, skipping: ${invalidIPs.join(', ')}`);
}
if (skippedIPs.length > 0) {
logger.warn(`Special-purpose IP${skippedIPs.length > 1 ? 's' : ''} detected in whitelist.txt, was this intentional? ${skippedIPs.join(', ')}`);
}
if (invalidPorts.length > 0) {
logger.warn(`Invalid port${invalidPorts.length > 1 ? 's' : ''} in whitelist, skipping: ${invalidPorts.join(', ')}`);
}
if (skippedPorts.length > 0) {
logger.warn(`Skipped duplicate port${skippedPorts.length > 1 ? 's' : ''} in whitelist.txt: ${skippedPorts.join(', ')}`);
}
return { ipSet, portSet };
};
const load = () => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const { ipSet, portSet } = parse(content);
whitelistedIPs = ipSet;
whitelistedPorts = portSet;
if (whitelistedIPs.size > 0) logger.info(`Loaded ${whitelistedIPs.size} whitelisted IP${whitelistedIPs.size > 1 ? 's' : ''} from ${filePath}`);
if (whitelistedPorts.size > 0) logger.info(`Loaded ${whitelistedPorts.size} whitelisted port${whitelistedPorts.size > 1 ? 's' : ''} from ${filePath}`);
} catch (err) {
logger.error(`Failed to load whitelist file: ${err.message}`);
}
};
const initWhitelist = () => {
if (!fs.existsSync(filePath)) {
fs.writeFileSync(
filePath,
[
'# UFW AbuseIPDB Reporter - whitelist.txt',
'# Changes to this file are detected and applied automatically (no restart required)',
'',
'# ====== [IP WHITELIST] ======',
'# One IP address per line (IPv4 or IPv6)',
'# Lines starting with # are comments',
'#',
'# Examples:',
'# 79.186.0.0',
'# 2a01:11bf:4504:b10c:8a32:ffe7:510a:6d4e',
'',
'# ====== [PORT WHITELIST] ======',
'# One destination port per line. Applies to ALL IPs.',
'# Optional: you may write entries like "22/tcp"; the numeric port will be extracted.',
'# Added if you have certain services that trigger false positives and you want to exclude',
'# them from triggering reports regardless of source IP. Use with caution, as this will ignore',
'# all traffic to the specified port(s) and may cause you to miss legitimate reports if that port is attacked.',
'# Examples:',
'# 22',
'# 25565',
'# 1194/tcp',
].join('\n') + '\n'
);
logger.info(`Created default whitelist file: ${filePath}`);
}
// @chillcog end;
load();
const dir = path.dirname(filePath);
const filename = path.basename(filePath);
try {
const watcher = fs.watch(dir, (event, file) => {
if (file !== filename) return;
clearTimeout(reloadTimer);
reloadTimer = setTimeout(load, 200);
});
watcher.on('error', err => logger.error(`Whitelist watcher error: ${err.message}`));
} catch (err) {
logger.error(`Failed to watch whitelist file: ${err.message}`);
}
};
module.exports = {
initWhitelist,
isWhitelisted: ip => whitelistedIPs.has(ip),
isPortWhitelisted: port => whitelistedPorts.has(port),
};
+20
View File
@@ -0,0 +1,20 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const queues = new Map();
const queueWrite = (filePath, fn) => {
const key = path.resolve(filePath);
const prev = queues.get(key) || Promise.resolve();
const next = prev.then(fn, fn);
queues.set(key, next);
return next;
};
const atomicWriteFile = async (filePath, data) => {
const tmp = `${filePath}.tmp`;
await fs.writeFile(tmp, data);
await fs.rename(tmp, filePath);
};
module.exports = { queueWrite, atomicWriteFile };