Unlink scripts submodule and commit contents as normal directory
This commit is contained in:
1 parent
4f7dea111f
commit
6488b3ea85
48 files changed
+2876
-1
No files matched your search
@@ -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
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { isLimited: false, isBuffering: false, sentBulk: false };
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
}
|
||||
})();
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user