├── .gitignore ├── docker-compose.yml ├── Dockerfile ├── package.json ├── LICENSE ├── README.md └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | swos-exporter: 5 | image: prometheus-mikrotik-swos-exporter 6 | build: ./ 7 | ports: 8 | - "9300:3000" -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | ENV NODE_ENV="production" 3 | WORKDIR /usr/src/app 4 | COPY package.json package-lock.json ./ 5 | RUN npm install 6 | COPY src ./src 7 | 8 | CMD ["node", "./src/index.js"] 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prometheus-mikrotik-swos-exporter", 3 | "version": "1.0.0", 4 | "description": "Mikrotik SwOS exporter for Prometheus (non-SNMP-data only)", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/patagonaa/prometheus-mikrotik-swos-exporter.git" 12 | }, 13 | "keywords": [ 14 | "mikrotik", 15 | "mikrotik-swos", 16 | "prometheus" 17 | ], 18 | "author": "patagona", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/patagonaa/prometheus-mikrotik-swos-exporter/issues" 22 | }, 23 | "homepage": "https://github.com/patagonaa/prometheus-mikrotik-swos-exporter#readme", 24 | "dependencies": { 25 | "express": "^5.1.0", 26 | "prom-client": "^15.1.3", 27 | "urllib": "2.34.2" 28 | }, 29 | "//": "!!! a dependency of urllib has a vulnerability, but since urllib 2.35.0 all header names are changed to lowercase and the mikrotik switches don't like that... !!!" 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 patagona 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prometheus-mikrotik-swos-exporter 2 | Mikrotik SwOS exporter for Prometheus. Up until version 2.12 the crossed out metrics below weren't available via SNMP yet. They are now, so this project is mostly unneccessary now. 3 | 4 | Tested with: 5 | - CSS326-24G-2S+ on SwOS version 2.9 to 2.13 6 | - CSS106-1G-4P-1S on SwOS version 2.11 to 2.13 7 | - CRS305-1G-4S+ on SwOS version 2.12 8 | 9 | Currently exported metrics: 10 | - ~~Device Input Voltage~~ (SNMP mtxrHlVoltage) 11 | - ~~PCB Temperature~~ (SNMP mtxrHlTemperature / mtxrHlProcessorTemperature) 12 | - ~~SFP Temperature~~ (SNMP mtxrOpticalTemperature) 13 | - ~~SFP Voltage~~ (SNMP mtxrOpticalSupplyVoltage) 14 | - ~~SFP TX Bias~~ (SNMP mtxrOpticalTxBiasCurrent) 15 | - SFP TX Power 16 | - SFP RX Power 17 | - ~~PoE Current~~ (SNMP mtxrPOECurrent) 18 | - ~~PoE Power~~ (SNMP mtxrPOEPower) 19 | 20 | These are fetched directly from the internal API of the web interface. The web interface might change in the future but this works for now. 21 | 22 | Other metrics (like link up/down, link speeds, rx/tx bytes etc.) should be acquired via SNMP. 23 | 24 | You can use the following generator.yml module for the [snmp-exporter](https://github.com/prometheus/snmp_exporter) generator to get PoE, temperature and SFP metrics (in addition to the usual IF-MIB statistics): 25 | ```yaml 26 | modules: 27 | mikrotik: 28 | walk: [mtxrPOE, mtxrOptical, mtxrHealth] 29 | lookups: 30 | - source_indexes: [mtxrOpticalIndex] 31 | lookup: mtxrOpticalName 32 | - source_indexes: [mtxrPOEInterfaceIndex] 33 | lookup: mtxrPOEName 34 | overrides: 35 | mtxrOpticalName: 36 | ignore: true 37 | mtxrPOEName: 38 | ignore: true 39 | mtxrPOEInterfaceIndex: 40 | ignore: true 41 | ``` 42 | 43 | ## Example using docker: 44 | (also using [mndp autodiscovery](https://github.com/patagonaa/prometheus-mndp-autodiscovery) to find all available MikroTik devices in the network) 45 | ### docker-compose.yml 46 | ```yaml 47 | version: "3" 48 | 49 | services: 50 | prometheus: 51 | image: prom/prometheus 52 | volumes: 53 | - "./prometheus.yml:/etc/prometheus/prometheus.yml" 54 | - "mikrotik-discovery:/etc/prometheus/mikrotik-discovery" 55 | - "prometheus-data:/prometheus" 56 | # [...] 57 | 58 | mndp-autodiscovery: 59 | restart: unless-stopped 60 | image: prometheus-mndp-autodiscovery 61 | build: ./prometheus-mndp-autodiscovery # path of git repo 62 | volumes: 63 | - "mikrotik-discovery:/file_sd/" 64 | network_mode: host 65 | 66 | swos-exporter: 67 | image: prometheus-mikrotik-swos-exporter 68 | build: ./prometheus-mikrotik-swos-exporter # path of git repo 69 | expose: 70 | - 3000 71 | 72 | volumes: 73 | prometheus-data: 74 | mikrotik-discovery: 75 | ``` 76 | 77 | ### prometheus.yml 78 | ```yaml 79 | global: 80 | # ... 81 | 82 | scrape_configs: 83 | - job_name: 'swos' 84 | metrics_path: '/metrics' 85 | params: 86 | user: ['admin'] 87 | password: ['secure'] # can be left empty 88 | scrape_interval: 30s 89 | file_sd_configs: 90 | - files: 91 | - 'mikrotik-discovery/targets.json' 92 | relabel_configs: 93 | - source_labels: [__address__] 94 | target_label: instance 95 | regex: '(^[^-]*-[^.]*).*' 96 | replacement: '$1' 97 | - source_labels: [__address__] 98 | target_label: __param_target 99 | - target_label: __address__ 100 | replacement: 'swos-exporter:3000' 101 | ``` 102 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const urllib = require('urllib'); 2 | var express = require('express') 3 | var app = express(); 4 | 5 | function fixBrokenJson(brokenJson) { 6 | return brokenJson 7 | .replace(/([{,])([a-zA-Z][a-zA-Z0-9]+)/g, '$1"$2"') // {abc: 123} -> {"abc": 123} 8 | .replace(/'/g, '"') // ' -> " 9 | .replace(/(0x[0-9a-zA-Z]+)/g, '"$1"'); // 0x1234 -> "0x1234" 10 | } 11 | 12 | function parseBrokenJson(brokenJson) { 13 | return JSON.parse(fixBrokenJson(brokenJson)); 14 | } 15 | 16 | function parseHexInt16(hex) { 17 | let result = parseInt(hex, 16); 18 | if ((result & 0x8000) !== 0) { 19 | result -= 0x10000; 20 | } 21 | return result; 22 | } 23 | 24 | function parseHexString(hex) { 25 | return Buffer.from(hex, 'hex').toString(); 26 | } 27 | 28 | async function doRequest(target, endPoint, user, password) { 29 | let error; 30 | for (let i = 0; i < 3; i++) { 31 | try { 32 | let requestOptions = { 33 | digestAuth: `${user}:${password}`, 34 | dataType: 'text', 35 | timeout: [1000, 2000] 36 | }; 37 | 38 | let url = `http://${target}/${endPoint}`; 39 | 40 | let response = await urllib.request(url, requestOptions); 41 | 42 | if (response.status != 200) 43 | throw response; 44 | 45 | return response.data; 46 | } catch (err) { 47 | error = err; 48 | } 49 | } 50 | console.error(error); 51 | return null; 52 | } 53 | 54 | async function getLink(target, user, password) { 55 | let link = await doRequest(target, 'link.b', user, password); 56 | return parseBrokenJson(link.toString()); 57 | } 58 | 59 | async function getSfp(target, user, password) { 60 | let sfp = await doRequest(target, 'sfp.b', user, password); 61 | return parseBrokenJson(sfp.toString()); 62 | } 63 | 64 | async function getDhost(target, user, password) { 65 | let dhost = await doRequest(target, '!dhost.b', user, password); 66 | if (dhost == null) 67 | return null; 68 | return parseBrokenJson(dhost.toString()); 69 | } 70 | 71 | const client = require('prom-client'); 72 | const sfpTxPowerGauge = new client.Gauge({ 73 | name: 'swos_sfp_tx_power_milliwatts', 74 | help: 'TX Power (mW) of SFP module', 75 | labelNames: ['target', 'sfp_name', 'sfp_desc'] 76 | }); 77 | const sfpRxPowerGauge = new client.Gauge({ 78 | name: 'swos_sfp_rx_power_milliwatts', 79 | help: 'RX Power (mW) of SFP module', 80 | labelNames: ['target', 'sfp_name', 'sfp_desc'] 81 | }); 82 | 83 | 84 | const macAddressTableGauge = new client.Gauge({ 85 | name: 'swos_mac_addr_table_count', 86 | help: 'Count of entries in mac address table', 87 | labelNames: ['target', 'vlan', 'port_name', 'port_desc'] 88 | }); 89 | 90 | // turn 91 | // {a: [0, 1], b: [2, 3]} 92 | // into 93 | // [{a: 0, b: 2}, {a: 1, b: 3}] 94 | function pivotObject(data, keys) { 95 | let toReturn = []; 96 | let count = (data[keys[0]] || {}).length || 0; 97 | 98 | for (let i = 0; i < count; i++) { 99 | let entry = { index: i }; 100 | for (let key of keys) { 101 | if (data[key] == null) 102 | continue; 103 | entry[key] = data[key][i]; 104 | } 105 | toReturn.push(entry); 106 | } 107 | return toReturn; 108 | } 109 | 110 | async function getMetrics(target, user, password) { 111 | client.register.resetMetrics(); 112 | 113 | let linkData = await getLink(target, user, password); 114 | let ports = pivotObject(linkData, ['nm']); 115 | 116 | try { 117 | let macTableData = await getDhost(target, user, password); 118 | 119 | if (macTableData != null) { 120 | let macTableGrouped = {}; 121 | for (const entry of macTableData) { 122 | var key = `${parseHexInt16(entry.vid)}|${parseHexInt16(entry.prt)}`; 123 | if (macTableGrouped[key] == null) { 124 | macTableGrouped[key] = new Set(); 125 | } 126 | macTableGrouped[key].add(entry.adr); 127 | } 128 | 129 | for (const key of Object.keys(macTableGrouped)) { 130 | let split = key.split('|'); 131 | let vlan = split[0]; 132 | let port = split[1]; 133 | macAddressTableGauge.set({ target: target, vlan: vlan, port_name: 'Port' + ((+port) + 1), port_desc: parseHexString(ports[port].nm) }, macTableGrouped[key].size); 134 | } 135 | } 136 | } catch (e) { 137 | console.error(e); 138 | } 139 | 140 | let sfpData = await getSfp(target, user, password); 141 | let sfps = Array.isArray(sfpData.vnd) ? pivotObject(sfpData, ['vnd', 'tpw', 'rpw']) : [{ index: 0, ...sfpData }]; 142 | 143 | for (let sfp of sfps) { 144 | let portIndex = ports.length - sfps.length + sfp.index; // assume sfps are always at the end of the port list 145 | 146 | let labels = { target: target, sfp_name: `SFP${(sfp.index + 1) || ''}`, sfp_desc: parseHexString(ports[portIndex].nm) }; 147 | 148 | if (sfp.vnd == '') { 149 | continue; 150 | } 151 | 152 | let txPowerMilliwatts = parseHexInt16(sfp.tpw); 153 | if (txPowerMilliwatts != 0) 154 | sfpTxPowerGauge.set(labels, txPowerMilliwatts / 10000); 155 | 156 | let rxPowerMilliwatts = parseHexInt16(sfp.rpw); 157 | if (rxPowerMilliwatts != 0) 158 | sfpRxPowerGauge.set(labels, rxPowerMilliwatts / 10000); 159 | } 160 | } 161 | 162 | app.get('/metrics', async function (req, res, next) { 163 | try { 164 | console.info('scraping', req.query.target); 165 | await getMetrics(req.query.target, req.query.user || 'admin', req.query.password || ''); 166 | res.set('Content-Type', client.register.contentType); 167 | res.end(await client.register.metrics()); 168 | } catch (e) { 169 | console.error(e); 170 | next(e); 171 | } 172 | }); 173 | 174 | app.listen(3000, function () { 175 | console.log('Exporter listening on port 3000!') 176 | }); 177 | --------------------------------------------------------------------------------