0.4.0-beta.4

This commit is contained in:
Sefinek committed 2025-05-13 20:14:08 +02:00
1 parent c8f8da8a6c
commit ab207030b5
6 files changed
+77 -63

No files matched your search

+6 -6
View File
@@ -11,14 +11,14 @@ Also, check this out: [sefinek/Cloudflare-WAF-To-AbuseIPDB](https://github.com/s
## 📋 Requirements ## 📋 Requirements
1. [Node.js + npm](https://github.com/sefinek/UFW-AbuseIPDB-Reporter?tab=readme-ov-file#nodejs-installation) - [Node.js + npm](https://gist.github.com/sefinek/fb50041a5f456321d58104bbf3f6e649)
2. [PM2](https://www.npmjs.com/package/pm2) (`npm i pm2 -g`) - [PM2](https://www.npmjs.com/package/pm2) (`npm i -g pm2`)
3. [Git](https://github.com/sefinek/UFW-AbuseIPDB-Reporter?tab=readme-ov-file#git-installation) - [Git](https://gist.github.com/sefinek/1de50073ffbbae82fc901506304f0ada)
4. Linux (Ubuntu or Debian) - Linux (Ubuntu or Debian)
## ✅ Features ## ✅ Features
1. **Easy Configuration** – The [`config.js`](config.default.js) file allows for quick and simple customization. 1. **Easy Configuration** – The [`config.js`](config.default.js) file allows for quick and simple configuration.
2. **Simple Installer** – Enables fast and seamless integration deployment. 2. **Simple Installer** – Enables fast and seamless integration deployment.
3. **Bulk Reporting Support** – If the script encounters a rate limit, it will start buffering collected IPs and send a bulk report. 3. **Bulk Reporting Support** – If the script encounters a rate limit, it will start buffering collected IPs and send a bulk report.
4. **Self-IP Protection (IPv4 & IPv6)** – The script will never report IP addresses belonging to you or your server, even if you're using a dynamic IP address. 4. **Self-IP Protection (IPv4 & IPv6)** – The script will never report IP addresses belonging to you or your server, even if you're using a dynamic IP address.
@@ -27,7 +27,7 @@ Also, check this out: [sefinek/Cloudflare-WAF-To-AbuseIPDB](https://github.com/s
- Critical notifications - Critical notifications
- Script error alerts - Script error alerts
- Daily summaries of reported IPs - Daily summaries of reported IPs
7. **Automatic Updates** – The script regularly fetches and applies the latest updates. You can [disable this](https://github.com/sefinek/UFW-AbuseIPDB-Reporter/blob/main/config.default.js#L14) if you prefer. 7. **Automatic Updates** – The script regularly fetches and applies the latest updates. You can disable this feature if you'd prefer.
## 📥 Installation (Ubuntu & Debian) ## 📥 Installation (Ubuntu & Debian)
+17 -16
View File
@@ -1,26 +1,27 @@
exports.MAIN = { exports.MAIN = {
// Server /* --------------------------- Server --------------------------- */
SERVER_ID: null, // Server identifier (e.g., 'hp-terminal', 'pl-cluster', 'de1'). Use 'development' for testing only. 'production' has no effect. Use null to leave it unset.
EXTENDED_LOGS: false, // Specifies whether the script should display additional information in the logs.
UFW_LOG_FILE: '/var/log/ufw.log', UFW_LOG_FILE: '/var/log/ufw.log',
CACHE_FILE: '/var/cache/sefinek/ufw-abuseipdb-reporter.cache', CACHE_FILE: '/var/cache/sefinek/ufw-abuseipdb-reporter.cache',
SERVER_ID: null, // The server name that will be visible in reports (e.g., homeserver1, de1). Leave as null if you don't want to define it.
EXTENDED_LOGS: false, // Specifies whether the script should display additional information in the logs.
// Network /* --------------------------- Network --------------------------- */
IP_REFRESH_SCHEDULE: '0 */6 * * *', // CRON: How often the script should check the IP address assigned by the ISP to prevent accidental self-reporting. If you have a static IP, you can set it to '0 0 1 * *' (once a month). Default: every 6 hours IP_ASSIGNMENT: 'dynamic', // IP assignment type: 'static' for a fixed IP, 'dynamic' if it may change over time.
IPv6_SUPPORT: true, // Specifies whether the device has an assigned IPv6 address. IP_REFRESH_SCHEDULE: '0 */6 * * *', // CRON schedule for checking the public IP assigned by your ISP. Used only with dynamic IPs to prevent accidental self-reporting. If IP_ASSIGNMENT is set to 'static', the script will check your IP only once.
IPv6_SUPPORT: true, // IPv6 support: true if the device has a globally routable address assigned by the ISP.
// Reporting /* --------------------------- Reports --------------------------- */
ABUSEIPDB_API_KEY: '', // Secret API key for AbuseIPDB. ABUSEIPDB_API_KEY: '', // https://www.abuseipdb.com/account/api
IP_REPORT_COOLDOWN: 12 * 60 * 60 * 1000, // Minimum time (12 hours in this example) that must pass before the same IP address can be reported again. Do not set values like 1 hour, as it wouldn't make sense due to rate limits. IP_REPORT_COOLDOWN: 12 * 60 * 60 * 1000, // Minimum time between reports of the same IP. Must be >= 15 minutes. Do not set values like 1 hour, as it wouldn't make sense due to rate limits.
// Automatic Updates /* --------------------------- Automatic Updates --------------------------- */
AUTO_UPDATE_ENABLED: false, // Should the script automatically update to the latest version using 'git pull'? If enabled, monitor the script periodically — incompatibilities may occasionally occur with the config file. AUTO_UPDATE_ENABLED: true, // Automatic updates: true to enable auto-update via 'git pull', false to disable.
AUTO_UPDATE_SCHEDULE: '0 18 * * *', // CRON: Schedule for automatic script updates. Default: every day at 18:00 AUTO_UPDATE_SCHEDULE: '15,17,18,20 * * *', // CRON schedule for automatic script updates. Default: every day at 15:00, 17:00, 18:00, 20:00
// Discord Webhooks /* --------------------------- Discord Webhooks --------------------------- */
DISCORD_WEBHOOKS_ENABLED: false, // Should the script send webhooks? These will include error reports, daily summaries, and other related information. DISCORD_WEBHOOK_ENABLED: false, // Enables sending Discord webhooks with error reports, execution status, and other events.
DISCORD_WEBHOOKS_URL: '', DISCORD_WEBHOOK_URL: '',
DISCORD_WEBHOOK_USERNAME: 'SERVER_ID', // The name displayed as the message author on Discord. If you don't want to set it, leave the value as null. Providing SERVER_ID as a string will display this.MAIN.SERVER_ID. DISCORD_WEBHOOK_USERNAME: 'SERVER_ID', // Username shown as the message author. Use null for default. 'SERVER_ID' will resolve to this.MAIN.SERVER_ID.
}; };
+36 -37
View File
@@ -1,19 +1,20 @@
// Copyright 2024-2025 (c) by Sefinek All rights reserved. // Copyright 2024-2025 © by Sefinek. All Rights Reserved.
// https://sefinek.net // https://sefinek.net
const fs = require('node:fs'); const fs = require('node:fs');
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { parseUfwLog } = require('ufw-log-parser'); const { parseUfwLog } = require('ufw-log-parser');
const banner = require('./scripts/banners/ufw.js');
const axios = require('./scripts/services/axios.js'); const axios = require('./scripts/services/axios.js');
const { saveBufferToFile, loadBufferFromFile, sendBulkReport, BULK_REPORT_BUFFER } = require('./scripts/services/bulk.js'); const { saveBufferToFile, loadBufferFromFile, sendBulkReport, BULK_REPORT_BUFFER } = require('./scripts/services/bulk.js');
const { reportedIPs, loadReportedIPs, saveReportedIPs, isIPReportedRecently, markIPAsReported } = require('./scripts/services/cache.js'); const { reportedIPs, loadReportedIPs, saveReportedIPs, isIPReportedRecently, markIPAsReported } = require('./scripts/services/cache.js');
const { refreshServerIPs, getServerIPs } = require('./scripts/services/ipFetcher.js'); const { refreshServerIPs, getServerIPs } = require('./scripts/services/ipFetcher.js');
const { name, version, authorEmailWebsite, repoFullUrl } = require('./scripts/repo.js'); const { name, version, repoFullUrl } = require('./scripts/repo.js');
const sendWebhook = require('./scripts/services/discordWebhooks.js'); const sendWebhook = require('./scripts/services/discordWebhooks.js');
const isLocalIP = require('./scripts/isLocalIP.js'); const isSpecialPurposeIP = require('./scripts/isSpecialPurposeIP.js');
const log = require('./scripts/log.js'); const logger = require('./scripts/logger.js');
const config = require('./config.js'); const config = require('./config.js');
const { UFW_LOG_FILE, ABUSEIPDB_API_KEY, SERVER_ID, EXTENDED_LOGS, AUTO_UPDATE_ENABLED, AUTO_UPDATE_SCHEDULE, DISCORD_WEBHOOKS_ENABLED, DISCORD_WEBHOOKS_URL } = config.MAIN; const { UFW_LOG_FILE, ABUSEIPDB_API_KEY, SERVER_ID, EXTENDED_LOGS, AUTO_UPDATE_ENABLED, AUTO_UPDATE_SCHEDULE, DISCORD_WEBHOOK_ENABLED, DISCORD_WEBHOOK_URL } = config.MAIN;
const ABUSE_STATE = { isLimited: false, isBuffering: false, sentBulk: false }; const ABUSE_STATE = { isLimited: false, isBuffering: false, sentBulk: false };
const RATE_LIMIT_LOG_INTERVAL = 10 * 60 * 1000; const RATE_LIMIT_LOG_INTERVAL = 10 * 60 * 1000;
@@ -37,17 +38,17 @@ const checkRateLimit = async () => {
if (!ABUSE_STATE.sentBulk && BULK_REPORT_BUFFER.size > 0) await sendBulkReport(); if (!ABUSE_STATE.sentBulk && BULK_REPORT_BUFFER.size > 0) await sendBulkReport();
RATELIMIT_RESET = nextRateLimitReset(); RATELIMIT_RESET = nextRateLimitReset();
ABUSE_STATE.sentBulk = false; ABUSE_STATE.sentBulk = false;
log(`Rate limit reset. Next reset scheduled at ${RATELIMIT_RESET.toISOString()}`, 1); logger.log(`Rate limit reset. Next reset scheduled at ${RATELIMIT_RESET.toISOString()}`, 1);
} else if (now - LAST_RATELIMIT_LOG >= RATE_LIMIT_LOG_INTERVAL) { } else if (now - LAST_RATELIMIT_LOG >= RATE_LIMIT_LOG_INTERVAL) {
const minutesLeft = Math.ceil((RATELIMIT_RESET.getTime() - now) / 60000); const minutesLeft = Math.ceil((RATELIMIT_RESET.getTime() - now) / 60000);
log(`Rate limit is still active. Collected ${BULK_REPORT_BUFFER.size} IPs. Waiting for reset in ${minutesLeft} minute(s) (${RATELIMIT_RESET.toISOString()})`, 1); logger.log(`Rate limit is still active. Collected ${BULK_REPORT_BUFFER.size} IPs. Waiting for reset in ${minutesLeft} minute(s) (${RATELIMIT_RESET.toISOString()})`, 1);
LAST_RATELIMIT_LOG = now; LAST_RATELIMIT_LOG = now;
} }
} }
}; };
const reportIp = async ({ srcIp, dpt = 'N/A', proto = 'N/A', id, timestamp }, categories, comment) => { const reportIp = async ({ srcIp, dpt = 'N/A', proto = 'N/A', id, timestamp }, categories, comment) => {
if (!srcIp) return log('Missing source IP (srcIp)', 3); if (!srcIp) return logger.log('Missing source IP (srcIp)', 3);
await checkRateLimit(); await checkRateLimit();
@@ -55,7 +56,7 @@ const reportIp = async ({ srcIp, dpt = 'N/A', proto = 'N/A', id, timestamp }, ca
if (BULK_REPORT_BUFFER.has(srcIp)) return; if (BULK_REPORT_BUFFER.has(srcIp)) return;
BULK_REPORT_BUFFER.set(srcIp, { categories, timestamp, comment }); BULK_REPORT_BUFFER.set(srcIp, { categories, timestamp, comment });
await saveBufferToFile(); await saveBufferToFile();
log(`Queued ${srcIp} for bulk report (collected ${BULK_REPORT_BUFFER.size} IPs)`, 1); logger.log(`Queued ${srcIp} for bulk report (collected ${BULK_REPORT_BUFFER.size} IPs)`, 1);
return; return;
} }
@@ -66,7 +67,7 @@ const reportIp = async ({ srcIp, dpt = 'N/A', proto = 'N/A', id, timestamp }, ca
comment, comment,
}), { headers: { 'Key': ABUSEIPDB_API_KEY } }); }), { headers: { 'Key': ABUSEIPDB_API_KEY } });
log(`Reported ${srcIp} [${dpt}/${proto}]; ID: ${id}; Categories: ${categories}; Abuse: ${res.data.abuseConfidenceScore}%`, 1); logger.log(`Reported ${srcIp} [${dpt}/${proto}]; ID: ${id}; Categories: ${categories}; Abuse: ${res.data.abuseConfidenceScore}%`, 1);
return true; return true;
} catch (err) { } catch (err) {
const status = err.response?.status ?? 'unknown'; const status = err.response?.status ?? 'unknown';
@@ -77,37 +78,34 @@ const reportIp = async ({ srcIp, dpt = 'N/A', proto = 'N/A', id, timestamp }, ca
ABUSE_STATE.sentBulk = false; ABUSE_STATE.sentBulk = false;
LAST_RATELIMIT_LOG = Date.now(); LAST_RATELIMIT_LOG = Date.now();
RATELIMIT_RESET = nextRateLimitReset(); RATELIMIT_RESET = nextRateLimitReset();
log(`Daily AbuseIPDB limit reached. Buffering reports until ${RATELIMIT_RESET.toLocaleString()}`, 0, true); logger.log(`Daily AbuseIPDB limit reached. Buffering reports until ${RATELIMIT_RESET.toLocaleString()}`, 0, true);
} }
if (BULK_REPORT_BUFFER.has(srcIp)) { if (!BULK_REPORT_BUFFER.has(srcIp)) {
log(`${srcIp} is already in buffer, skipping`); BULK_REPORT_BUFFER.set(srcIp, { timestamp, categories, comment });
return; await saveBufferToFile();
logger.log(`Queued ${srcIp} for bulk report due to rate limit`, 1);
} }
BULK_REPORT_BUFFER.set(srcIp, { timestamp, categories, comment });
await saveBufferToFile();
log(`Queued ${srcIp} for bulk report due to rate limit`);
} else { } else {
log(`Failed to report ${srcIp} [${dpt}/${proto}]; ${err.response?.data?.errors ? JSON.stringify(err.response.data.errors) : err.message}`, status === 429 ? 0 : 3); logger.log(`Failed to report ${srcIp} [${dpt}/${proto}]; ${err.response?.data?.errors ? JSON.stringify(err.response.data.errors) : err.message}`, status === 429 ? 0 : 3);
} }
} }
}; };
const processLogLine = async (line, test = false) => { const processLogLine = async (line, test = false) => {
if (!line.includes('[UFW BLOCK]')) return log(`Ignoring invalid line: ${line}`, 2); if (!line.includes('[UFW BLOCK]')) return logger.log(`Ignoring invalid line: ${line}`, 2);
const data = parseUfwLog(line); const data = parseUfwLog(line);
const { srcIp, proto, dpt } = data; const { srcIp, proto, dpt } = data;
if (!srcIp) return log(`Missing SRC in the log line: ${line}`, 3); if (!srcIp) return logger.log(`Missing SRC in the log line: ${line}`, 3);
const ips = getServerIPs(); const ips = getServerIPs();
if (!Array.isArray(ips)) return log(`For some reason, 'ips' from 'getServerIPs()' is not an array. Received: ${ips}`, 3, true); if (!Array.isArray(ips)) return logger.log(`For some reason, 'ips' from 'getServerIPs()' is not an array. Received: ${ips}`, 3, true);
if (ips.includes(srcIp)) return log(`Ignoring own IP address: PROTO=${proto?.toLowerCase()} SRC=${srcIp} DPT=${dpt} ID=${data.id}`, 0, true); if (ips.includes(srcIp)) return logger.log(`Ignoring own IP address: PROTO=${proto?.toLowerCase()} SRC=${srcIp} DPT=${dpt} ID=${data.id}`, 0, true);
if (isLocalIP(srcIp)) return log(`Ignoring local IP address: PROTO=${proto?.toLowerCase()} SRC=${srcIp} DPT=${dpt} ID=${data.id}`, 0, true); if (isSpecialPurposeIP(srcIp)) return logger.log(`Ignoring local IP address: PROTO=${proto?.toLowerCase()} SRC=${srcIp} DPT=${dpt} ID=${data.id}`, 0, true);
if (proto === 'UDP') { if (proto === 'UDP') {
if (EXTENDED_LOGS) log(`Skipping UDP traffic: SRC=${srcIp} DPT=${dpt} ID=${data.id}`); if (EXTENDED_LOGS) logger.log(`Skipping UDP traffic: SRC=${srcIp} DPT=${dpt} ID=${data.id}`);
return; return;
} }
@@ -127,7 +125,7 @@ const processLogLine = async (line, test = false) => {
(seconds || (!days && !hours && !minutes)) && `${seconds}s`, (seconds || (!days && !hours && !minutes)) && `${seconds}s`,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
if (EXTENDED_LOGS) log(`${srcIp} was last reported on ${new Date(lastReportedTime * 1000).toLocaleString()} (${timeAgo} ago)`); if (EXTENDED_LOGS) logger.log(`${srcIp} was last reported on ${new Date(lastReportedTime * 1000).toLocaleString()} (${timeAgo} ago)`);
return; return;
} }
@@ -141,7 +139,7 @@ const processLogLine = async (line, test = false) => {
}; };
(async () => { (async () => {
log(`${repoFullUrl} - v${version} | Author: ${authorEmailWebsite}`); banner(`UFW To AbuseIPDB (v${version})`);
// Auto updates // Auto updates
if (AUTO_UPDATE_ENABLED && AUTO_UPDATE_SCHEDULE && SERVER_ID !== 'development') { if (AUTO_UPDATE_ENABLED && AUTO_UPDATE_SCHEDULE && SERVER_ID !== 'development') {
@@ -150,23 +148,24 @@ const processLogLine = async (line, test = false) => {
await require('./scripts/services/version.js'); await require('./scripts/services/version.js');
} }
// Bulk // Load cache
await loadReportedIPs(); await loadReportedIPs();
await loadBufferFromFile();
// Bulk
await loadBufferFromFile();
if (BULK_REPORT_BUFFER.size > 0 && !ABUSE_STATE.isLimited) { if (BULK_REPORT_BUFFER.size > 0 && !ABUSE_STATE.isLimited) {
log(`Found ${BULK_REPORT_BUFFER.size} IPs in buffer after restart. Sending bulk report...`); logger.log(`Found ${BULK_REPORT_BUFFER.size} IPs in buffer after restart. Sending bulk report...`);
await sendBulkReport(); await sendBulkReport();
} }
// Fetch IPs // Fetch IPs
log('Trying to fetch your IPv4 and IPv6 address from api.sefinek.net...'); logger.log('Trying to fetch your IPv4 and IPv6 address from api.sefinek.net...');
await refreshServerIPs(); await refreshServerIPs();
log(`Fetched ${getServerIPs()?.length} of your IP addresses. If any of them accidentally appear in the UFW logs, they will be ignored.`, 1); logger.log(`Fetched ${getServerIPs()?.length} of your IP addresses. If any of them accidentally appear in the UFW logs, they will be ignored.`, 1);
// Check UFW_LOG_FILE
if (!fs.existsSync(UFW_LOG_FILE)) { if (!fs.existsSync(UFW_LOG_FILE)) {
log(`Log file ${UFW_LOG_FILE} does not exist`, 3); logger.log(`Log file ${UFW_LOG_FILE} does not exist`, 3);
return; return;
} }
@@ -177,7 +176,7 @@ const processLogLine = async (line, test = false) => {
const stats = fs.statSync(path); const stats = fs.statSync(path);
if (stats.size < fileOffset) { if (stats.size < fileOffset) {
fileOffset = 0; fileOffset = 0;
log('The file has been truncated, and the offset has been reset'); logger.log('The file has been truncated, and the offset has been reset');
} }
fs.createReadStream(path, { start: fileOffset, encoding: 'utf8' }).on('data', chunk => { fs.createReadStream(path, { start: fileOffset, encoding: 'utf8' }).on('data', chunk => {
@@ -188,11 +187,11 @@ const processLogLine = async (line, test = false) => {
}); });
// Summaries // Summaries
if (DISCORD_WEBHOOKS_ENABLED && DISCORD_WEBHOOKS_URL) await require('./scripts/services/summaries.js')(); if (DISCORD_WEBHOOK_ENABLED && DISCORD_WEBHOOK_URL) await require('./scripts/services/summaries.js')();
// Ready // Ready
await sendWebhook(`[${name}](${repoFullUrl}) was successfully started!`, 0x59D267); await sendWebhook(`[${name}](${repoFullUrl}) was successfully started!`, 0x59D267);
log(`Ready! Now monitoring: ${UFW_LOG_FILE}`, 1); logger.log(`Ready! Now monitoring: ${UFW_LOG_FILE}`, 1);
process.send?.('ready'); process.send?.('ready');
})(); })();
+15 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ufw-abuseipdb-reporter", "name": "ufw-abuseipdb-reporter",
"version": "0.4.0-beta.3", "version": "0.4.0-beta.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ufw-abuseipdb-reporter", "name": "ufw-abuseipdb-reporter",
"version": "0.4.0-beta.3", "version": "0.4.0-beta.4",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^1.9.0", "axios": "^1.9.0",
@@ -17,6 +17,7 @@
"csv-stringify": "^6.5.2", "csv-stringify": "^6.5.2",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"ipaddr.js": "^2.2.0", "ipaddr.js": "^2.2.0",
"semver": "^7.7.2",
"simple-git": "^3.27.0", "simple-git": "^3.27.0",
"ufw-log-parser": "^0.1.0" "ufw-log-parser": "^0.1.0"
}, },
@@ -465,6 +466,18 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-git": { "node_modules/simple-git": {
"version": "3.27.0", "version": "3.27.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ufw-abuseipdb-reporter", "name": "ufw-abuseipdb-reporter",
"version": "0.4.0-beta.3", "version": "0.4.0-beta.4",
"homepage": "https://github.com/sefinek/UFW-AbuseIPDB-Reporter#readme", "homepage": "https://github.com/sefinek/UFW-AbuseIPDB-Reporter#readme",
"bugs": { "bugs": {
"url": "https://github.com/sefinek/UFW-AbuseIPDB-Reporter/issues" "url": "https://github.com/sefinek/UFW-AbuseIPDB-Reporter/issues"
@@ -27,6 +27,7 @@
"csv-stringify": "^6.5.2", "csv-stringify": "^6.5.2",
"form-data": "^4.0.2", "form-data": "^4.0.2",
"ipaddr.js": "^2.2.0", "ipaddr.js": "^2.2.0",
"semver": "^7.7.2",
"simple-git": "^3.27.0", "simple-git": "^3.27.0",
"ufw-log-parser": "^0.1.0" "ufw-log-parser": "^0.1.0"
}, },
+1 -1
Submodule scripts updated: 2163caf74e...245f3a43b2.