diff --git a/config.yml.example b/config.yml.example index 1fbb216..6c6bbad 100644 --- a/config.yml.example +++ b/config.yml.example @@ -53,6 +53,11 @@ monitors: - file: monitorROAS channel: rpki name: rpki-diff + params: + enableDiffAlerts: true, + enableExpirationAlerts: true, + roaExpirationAlertHours: 2, + checkOnlyAsns: false - file: monitorPathNeighbors channel: hijack diff --git a/docs/configuration.md b/docs/configuration.md index 8a51689..57c35b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -343,19 +343,48 @@ Note, while BGPalerter will perform the check near real time, many RIRs have del > asn: 1234 > description: an example > ignoreMorespecifics: false +> group: noc1 > > options: > monitorASns: > 2914: -> group: default +> group: noc2 > ``` > If in config.yml monitorROAS is enabled, you will receive alerts every time: -> * A ROA that is, or was, involving 1.2.3.4/24 is added/edited/removed. -> * A ROA that is, or was, involving AS2914 is added/edited/removed. +> * A ROA that is, or was, involving 1.2.3.4/24 is added/edited/removed (based on the prefix `1.2.3.4/24` matching rule). +> * A ROA that is, or was, involving AS2914 is added/edited/removed (based on the `monitorASns` section). +**Important 1:** for a complete monitoring, configure also the `monitorASns` section. Setting only prefix matching rules is not sufficient: prefix matching rules are based on the longest prefix match, less specific ROAs impacting the prefix will NOT be matched. On the other side, setting only the `monitorASns` section is instead perfectly fine for ROA monitoring purposes. + +**Important 2:** prefix matching rules have always priorities on `monitorASns` rules. If an alert matches both a prefix rule and an AS rule, it will be sent only to the prefix rule, except if the `checkOnlyAsns` params is set to true (see parameters below). In the example above, a ROA change impacting `1.2.3.4/24` is only sent to the user group `noc1` and not to `noc2`; whatever other ROA change impacting a prefix not in the list (no prefix matching rule) will be sent to `noc2` instead. Example of alerts: -> ROAs change detected: removed <1.2.3.4/24, 1234, 25, apnic> +> ROAs change detected: removed <1.2.3.4/24, 1234, 25, apnic>; added <5.5.3.4/24, 1234, 25, apnic> + +**This monitor also alerts about ROAs expiration.** + +This feature requires a vrps file having a `expires` field for each vrp, currently supported only by [rpki-client](https://www.rpki-client.org/). To enable this feature, provide a file having such field or use as vrp provider one of: `ntt`, `rpkiclient` ([more info](rpki.md)). + +ROAs are affected by a series of expiration times: +* Certificate Authority's "notAfter" date; +* each CRL's "nextUpdate" date; +* each manifest's EE certificate notAfter, and each manifests eContent "nextUpdate"; +* the ROA's own EE certificate "notAfter". + +The field `expire` must be the closest expiration time of all of the above. + +Example of alerts: +> The following ROAs will expire in less than 2 hours: <1.2.3.4/24, 1234, 25, apnic>; <5.5.3.4/24, 1234, 25, apnic> + +Parameters for this monitor module: + +|Parameter| Description| +|---|---| +|enableDiffAlerts| Enables alerts showing edits impacting ROAs for the monitored resources. Default true| +|enableExpirationAlerts| Enables alerts about expiring ROAs. Default true.| +|roaExpirationAlertHours| If a ROA is expiring in less than this amount of hours, an alert will be triggered. The default is 2 hours. I strongly suggest to keep this value, ROAs are almost expiring every day, read above what this expiration time means. | +|checkOnlyAsns| If set to true (default false), ROAs diff alerts will be generated based only on the ASns contained in the `monitorASns` of `prefixes.yml`. This means that no ROA diffs will be matched against prefix matching rules (see example above). | + #### monitorPathNeighbors diff --git a/package-lock.json b/package-lock.json index 588e211..458b1f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "nodemailer": "^6.6.0", "path": "^0.12.7", "restify": "^8.5.1", - "rpki-validator": "^2.6.0", + "rpki-validator": "^2.6.1", "semver": "^7.3.5", "syslog-client": "^1.1.1", "ws": "^7.4.5", @@ -6361,9 +6361,9 @@ } }, "node_modules/rpki-validator": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/rpki-validator/-/rpki-validator-2.6.0.tgz", - "integrity": "sha512-XTybuQPein6VYerScna2ifTknHJv0pV5vRS7tGLvOLaVLC/qmO6TG/wRYVEVxMkSagMdVOu96s6SYYZ0//DHsA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/rpki-validator/-/rpki-validator-2.6.1.tgz", + "integrity": "sha512-vnUrq8WVSRufuEsBbhFYtGw89ceRghQQQ3NmFnOQp/SsH61twoHlpkbWI2ZmFHW5t7sPUC693+OStDo73uzHbQ==", "dependencies": { "axios": "^0.21.1", "brembo": "^2.0.5", @@ -12760,9 +12760,9 @@ } }, "rpki-validator": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/rpki-validator/-/rpki-validator-2.6.0.tgz", - "integrity": "sha512-XTybuQPein6VYerScna2ifTknHJv0pV5vRS7tGLvOLaVLC/qmO6TG/wRYVEVxMkSagMdVOu96s6SYYZ0//DHsA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/rpki-validator/-/rpki-validator-2.6.1.tgz", + "integrity": "sha512-vnUrq8WVSRufuEsBbhFYtGw89ceRghQQQ3NmFnOQp/SsH61twoHlpkbWI2ZmFHW5t7sPUC693+OStDo73uzHbQ==", "requires": { "axios": "^0.21.1", "brembo": "^2.0.5", diff --git a/package.json b/package.json index 4a9c153..c2bf66f 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "nodemailer": "^6.6.0", "path": "^0.12.7", "restify": "^8.5.1", - "rpki-validator": "^2.6.0", + "rpki-validator": "^2.6.1", "semver": "^7.3.5", "syslog-client": "^1.1.1", "ws": "^7.4.5", diff --git a/src/config/config.js b/src/config/config.js index ad24452..bf5e6fb 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -77,7 +77,12 @@ export default class Config { file: "monitorROAS", channel: "rpki", name: "rpki-diff", - params: {} + params: { + enableDiffAlerts: true, + enableExpirationAlerts: true, + roaExpirationAlertHours: 2, + checkOnlyAsns: false + } }, { file: "monitorPathNeighbors", diff --git a/src/monitors/monitorROAS.js b/src/monitors/monitorROAS.js index 32f211f..ce12c68 100644 --- a/src/monitors/monitorROAS.js +++ b/src/monitors/monitorROAS.js @@ -1,7 +1,8 @@ import Monitor from "./monitor"; import md5 from "md5"; -import diff from "../utils/rpkiDiffingTool"; +import { getPrefixes, getRelevant, diff } from "../utils/rpkiDiffingTool"; import {AS} from "../model"; +import moment from "moment"; export default class MonitorROAS extends Monitor { @@ -10,29 +11,74 @@ export default class MonitorROAS extends Monitor { this.logger = env.logger; this.rpki = env.rpki; - setInterval(this._diffVrps, 20000); + this.roaExpirationAlertHours = params.roaExpirationAlertHours || 2; + this.checkOnlyAsns = params.checkOnlyAsns || false; + this.enableDiffAlerts = params.enableDiffAlerts != null ? params.enableDiffAlerts : true; + this.enableExpirationAlerts = params.enableExpirationAlerts != null ? params.enableExpirationAlerts : true; + + if (this.enableDiffAlerts) { + setInterval(this._diffVrps, 20000); + } + if (this.enableExpirationAlerts) { + setInterval(this._verifyExpiration, global.EXTERNAL_ROA_EXPIRATION_TEST || 440000); + } }; - _diffVrps = () => { + _verifyExpiration = () => { + const vrps = this.rpki.getVrps() + .filter(i => !!i.expires && (i.expires - moment.utc().unix() < this.roaExpirationAlertHours * 3600)); + + // We can check here if too many vrps are expiring, maybe TA malfunction + + const prefixesIn = this.monitored.prefixes.map(i => i.prefix); + const asnsIn = this.monitored.asns.map(i => i.asn.getValue()); + const relevantVrps = getRelevant(vrps, prefixesIn, asnsIn); + + let alerts = []; + if (relevantVrps.length) { + if (!this.checkOnlyAsns) { + alerts = this._checkExpirationPrefixes(relevantVrps); + } + for (let asn of asnsIn) { + this._checkExpirationAs(relevantVrps, asn, alerts); + } + } + }; + + _checkExpirationPrefixes = (vrps) => { + let alerts = []; + + for (let prefix of [...new Set(vrps.map(i => i.prefix))]) { + + const roas = vrps.filter(i => i.prefix === prefix); // Get only the ROAs for this prefix + const matchedRule = this.getMoreSpecificMatch(prefix, false); // Get the matching rule + if (matchedRule) { + const alertsStrings = [...new Set(roas.map(this._roaToString))]; + const message = `The following ROAs will expire in less than ${this.roaExpirationAlertHours} hours: ${alertsStrings.join("; ")}`; + alerts = alerts.concat(alertsStrings); + + this.publishAlert(md5(message), // The hash will prevent alert duplications in case multiple ASes/prefixes are involved + matchedRule.prefix, + matchedRule, + message, + {}); + } + } + + return alerts; + }; + + _checkExpirationAs = (vrps, asn, sent) => { try { - let roaDiff; - const newVrps = this.rpki.getVrps(); // Get all the vrps as retrieved from the rpki validator + let alerts = []; + const impactedASes = [...new Set(vrps.map(i => i.asn))]; + const matchedRules = impactedASes.map(asn => this.getMonitoredAsMatch(new AS(asn))); - if (this._oldVrps) { // No diff if there were no vrps before - roaDiff = [].concat.apply([], this.monitored - .map(i => diff(this._oldVrps, newVrps, i.asn.getValue()))); // Get the diff for each monitored AS - } - - if (newVrps.length) { - this._oldVrps = newVrps; - } - - if (roaDiff && roaDiff.length) { // Differences found - const impactedASes = [...new Set(roaDiff.map(i => i.asn))]; - const matchedRules = impactedASes.map(asn => this.getMonitoredAsMatch(new AS(asn))); - - for (let matchedRule of matchedRules.filter(i => !!i)) { // An alert for each AS involved (they may have different user group) - const message = "ROAs change detected: " + [...new Set(roaDiff.map(this._roaToString))].join("; "); + for (let matchedRule of matchedRules.filter(i => !!i)) { // An alert for each AS involved (they may have different user group) + const alertsStrings = [...new Set(vrps.map(this._roaToString))].filter(i => !sent.includes(i)); + if (alertsStrings.length) { + const message = `The following ROAs will expire in less than ${this.roaExpirationAlertHours} hours: ${alertsStrings.join("; ")}`; + alerts = alerts.concat(alertsStrings); this.publishAlert(md5(message), // The hash will prevent alert duplications in case multiple ASes/prefixes are involved matchedRule.asn.getId(), @@ -41,6 +87,95 @@ export default class MonitorROAS extends Monitor { {}); } } + + return alerts; + } catch (error) { + this.logger.log({ + level: 'error', + message: error + }); + } + }; + + _diffVrps = () => { + const newVrps = this.rpki.getVrps(); // Get all the vrps as retrieved from the rpki validator + + if (this._oldVrps) { // No diff if there were no vrps before + const prefixesIn = this.monitored.prefixes.map(i => i.prefix); + const asns = this.monitored.asns.map(i => i.asn.getValue()); + let alerts = []; + if (!this.checkOnlyAsns){ + alerts = this._diffVrpsPrefixes(this._oldVrps, newVrps, prefixesIn); + } + for (let asn of asns) { + this._diffVrpsAs(this._oldVrps, newVrps, asn, alerts); + } + } + + if (newVrps.length) { + this._oldVrps = newVrps; + } + }; + + _diffVrpsPrefixes = (oldVrps, newVrps, prefixesIn) => { + try { + const roaDiff = diff(oldVrps, newVrps, [], prefixesIn); + let alerts = []; + + if (roaDiff && roaDiff.length) { // Differences found + for (let prefix of [...new Set(roaDiff.map(i => i.prefix))]) { + + const roas = roaDiff.filter(i => i.prefix === prefix); // Get only the ROAs for this prefix + const matchedRule = this.getMoreSpecificMatch(prefix, false); // Get the matching rule + if (matchedRule) { + const alertsStrings = [...new Set(roas.map(this._roaToString))]; + const message = `ROAs change detected: ${alertsStrings.join("; ")}`; + alerts = alerts.concat(alertsStrings); + + this.publishAlert(md5(message), // The hash will prevent alert duplications in case multiple ASes/prefixes are involved + matchedRule.prefix, + matchedRule, + message, + {}); + } + } + } + + return alerts; + } catch (error) { + this.logger.log({ + level: 'error', + message: error + }); + } + }; + + _diffVrpsAs = (oldVrps, newVrps, asn, sent) => { + try { + const roaDiff = diff(oldVrps, newVrps, asn, []); + let alerts = []; + + if (roaDiff && roaDiff.length) { // Differences found + + const impactedASes = [...new Set(roaDiff.map(i => i.asn))]; + const matchedRules = impactedASes.map(asn => this.getMonitoredAsMatch(new AS(asn))); + + for (let matchedRule of matchedRules.filter(i => !!i)) { // An alert for each AS involved (they may have different user group) + const alertsStrings = [...new Set(roaDiff.map(this._roaToString))].filter(i => !sent.includes(i)); + if (alertsStrings.length) { + const message = `ROAs change detected: ${alertsStrings.join("; ")}`; + alerts = alerts.concat(alertsStrings); + + this.publishAlert(md5(message), // The hash will prevent alert duplications in case multiple ASes/prefixes are involved + matchedRule.asn.getId(), + matchedRule, + message, + {}); + } + } + } + + return alerts; } catch (error) { this.logger.log({ level: 'error', @@ -50,11 +185,18 @@ export default class MonitorROAS extends Monitor { }; _roaToString = (roa) => { - return `${roa.status} <${roa.prefix}, ${roa.asn}, ${roa.maxLength}, ${roa.ta || ""}>`; + if (roa.status) { + return `${roa.status} <${roa.prefix}, ${roa.asn}, ${roa.maxLength}, ${roa.ta || ""}>`; + } else { + return `<${roa.prefix}, ${roa.asn}, ${roa.maxLength}, ${roa.ta || ""}>`; + } }; updateMonitoredResources = () => { - this.monitored = this.input.getMonitoredASns(); + this.monitored = { + asns: this.input.getMonitoredASns(), + prefixes: this.input.getMonitoredPrefixes() + } }; filter = (message) => false; diff --git a/src/utils/rpkiDiffingTool.js b/src/utils/rpkiDiffingTool.js index 1c863c4..0b50097 100644 --- a/src/utils/rpkiDiffingTool.js +++ b/src/utils/rpkiDiffingTool.js @@ -1,35 +1,45 @@ -export default function diff (vrpsOld, vrpsNew, asn) { - - const getKey = (vrp) => { - return `${vrp.ta}-${vrp.prefix}-${vrp.asn}-${vrp.maxLength}`; - }; - - const getDiff = (vrpsOld, vrpsNew, asn) => { - asn = parseInt(asn); - const prefixes = [...new Set(vrpsOld.concat(vrpsNew).filter(i => i.asn === asn).map(i => i.prefix))]; - - const filteredVrpsOld = vrpsOld.filter(i => i.asn === asn || prefixes.includes(i.prefix)) - .map(i => { - i.status = "removed"; - return i; - }); - const filteredVrpsNew = vrpsNew.filter(i => i.asn === asn || prefixes.includes(i.prefix)) - .map(i => { - i.status = "added"; - return i; - }); - - const index = {}; - - for (let vrp of filteredVrpsOld.concat(filteredVrpsNew)) { - const key = getKey(vrp); - index[key] = index[key] || []; - index[key].push(vrp); - } - - return Object.values(index).filter(i => i.length === 1).map(i => i[0]); - }; - - return getDiff(vrpsOld, vrpsNew, asn); +function getPrefixes(vrp, asn) { + return [...new Set(vrp.filter(i => i.asn === asn).map(i => i.prefix))]; } + +function getRelevant(vrp, prefixes, asns=[]){ + return vrp.filter(i => asns.includes(i.asn) || prefixes.includes(i.prefix)); +} + +function diff(vrpsOld, vrpsNew, asn, prefixesIn=[]) { + asn = parseInt(asn); + + let prefixes; + if (asn) { + prefixes = [...new Set(prefixesIn)]; + } else { + prefixes = [...new Set([...prefixesIn, ...getPrefixes(vrpsOld, asn), ...getPrefixes(vrpsNew, asn)])]; + } + const filteredVrpsOld = JSON.parse(JSON.stringify(getRelevant(vrpsOld, prefixes, [asn]))) + .map(i => { + i.status = "removed"; + return i; + }); + const filteredVrpsNew = JSON.parse(JSON.stringify(getRelevant(vrpsNew, prefixes, [asn]))) + .map(i => { + i.status = "added"; + return i; + }); + + const index = {}; + + for (let vrp of filteredVrpsOld.concat(filteredVrpsNew)) { + const key = `${vrp.ta}-${vrp.prefix}-${vrp.asn}-${vrp.maxLength}`; + index[key] = index[key] || []; + index[key].push(vrp); + } + + return Object.values(index).filter(i => i.length === 1).map(i => i[0]); +} + +export { + getPrefixes, + getRelevant, + diff +}; \ No newline at end of file diff --git a/tests/rpki_tests/roas.after.json b/tests/rpki_tests/roas.after.json index f45fe45..0ad2c7c 100644 --- a/tests/rpki_tests/roas.after.json +++ b/tests/rpki_tests/roas.after.json @@ -3,7 +3,8 @@ "asn": "2914", "prefix": "1.2.3.0/24", "maxLength": 24, - "ta": "ripe" + "ta": "ripe", + "expires": 1621691602 }, { "asn": 2914, diff --git a/tests/rpki_tests/roas.before.json b/tests/rpki_tests/roas.before.json index 9d7fb14..c17d938 100644 --- a/tests/rpki_tests/roas.before.json +++ b/tests/rpki_tests/roas.before.json @@ -16,5 +16,17 @@ "prefix": "2001:db8:123::/48", "maxLength": 48, "ta": "ripe" + }, + { + "asn": 65000, + "prefix": "2001:db8:123::/48", + "maxLength": 48, + "ta": "ripe" + }, + { + "asn": 2914, + "prefix": "94.5.4.3/22", + "maxLength": 22, + "ta": "ripe" } ] \ No newline at end of file diff --git a/tests/rpki_tests/roas.json b/tests/rpki_tests/roas.json index f45fe45..c17d938 100644 --- a/tests/rpki_tests/roas.json +++ b/tests/rpki_tests/roas.json @@ -5,10 +5,28 @@ "maxLength": 24, "ta": "ripe" }, + { + "asn": "AS2914", + "prefix": "2.3.4.0/24", + "maxLength": 24, + "ta": "ripe" + }, { "asn": 2914, "prefix": "2001:db8:123::/48", "maxLength": 48, "ta": "ripe" + }, + { + "asn": 65000, + "prefix": "2001:db8:123::/48", + "maxLength": 48, + "ta": "ripe" + }, + { + "asn": 2914, + "prefix": "94.5.4.3/22", + "maxLength": 22, + "ta": "ripe" } ] \ No newline at end of file diff --git a/tests/rpki_tests/tests.external-roas.js b/tests/rpki_tests/tests.external-roas.js index 7fa0667..e750bf0 100644 --- a/tests/rpki_tests/tests.external-roas.js +++ b/tests/rpki_tests/tests.external-roas.js @@ -38,6 +38,7 @@ const asyncTimeout = 200000; chai.use(chaiSubset); global.EXTERNAL_CONFIG_FILE = "tests/rpki_tests/config.rpki.test.external-roas.yml"; +global.EXTERNAL_ROA_EXPIRATION_TEST = 5000; // ROAs before fs.copyFileSync("tests/rpki_tests/roas.before.json", "tests/rpki_tests/roas.json"); @@ -52,10 +53,31 @@ const pubSub = worker.pubSub; describe("RPKI monitoring external", function() { - it("ROA diff - external connector", function (done) { + it("ROA diff and expiration - external connector", function (done) { const expectedData = { + "28c7aa78b6286e0e3c6583797f7df47c": { + id: '28c7aa78b6286e0e3c6583797f7df47c', + truncated: false, + origin: 'rpki-monitor', + affected: 2914, + message: 'The following ROAs will expire in less than 2 hours: <1.2.3.0/24, 2914, 24, ripe>', + }, + "47807c7558dbe001b4aad9f3a87eb427": { + id: '47807c7558dbe001b4aad9f3a87eb427', + truncated: false, + origin: 'rpki-monitor', + affected: '94.5.4.3/22', + message: 'ROAs change detected: removed <94.5.4.3/22, 2914, 22, ripe>' + }, + "de3bd9a6cdeeb05e1c2c7c04f7220485" : { + id: 'de3bd9a6cdeeb05e1c2c7c04f7220485', + truncated: false, + origin: 'rpki-monitor', + affected: '2001:db8:123::/48', + message: 'ROAs change detected: removed <2001:db8:123::/48, 65000, 48, ripe>' + }, "129aafe3c8402fb045b71e810a73d425": { id: '129aafe3c8402fb045b71e810a73d425', truncated: false, @@ -63,12 +85,10 @@ describe("RPKI monitoring external", function() { affected: 2914, message: 'ROAs change detected: removed <2.3.4.0/24, 2914, 24, ripe>' } - }; let rpkiTestCompletedExternal = false; pubSub.subscribe("rpki", function (message, type) { - try { if (!rpkiTestCompletedExternal) { message = JSON.parse(JSON.stringify(message));