diff options
| -rw-r--r-- | overlays/zte-prometheus-exporter/default.nix | 28 | ||||
| -rw-r--r-- | overlays/zte-prometheus-exporter/python-packages.nix | 21 | ||||
| -rw-r--r-- | overlays/zte-prometheus-exporter/zte-prometheus-exporter.py | 216 |
3 files changed, 265 insertions, 0 deletions
diff --git a/overlays/zte-prometheus-exporter/default.nix b/overlays/zte-prometheus-exporter/default.nix new file mode 100644 index 00000000..1609724b --- /dev/null +++ b/overlays/zte-prometheus-exporter/default.nix | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | final: prev: | ||
| 2 | let | ||
| 3 | packageOverrides = final.callPackage ./python-packages.nix {}; | ||
| 4 | inpPython = final.python310.override { inherit packageOverrides; }; | ||
| 5 | in { | ||
| 6 | zte-prometheus-exporter = prev.stdenv.mkDerivation rec { | ||
| 7 | name = "zte-prometheus-exporter"; | ||
| 8 | src = ./zte-prometheus-exporter.py; | ||
| 9 | |||
| 10 | phases = [ "buildPhase" "checkPhase" "installPhase" ]; | ||
| 11 | |||
| 12 | python = inpPython.withPackages (ps: with ps; [pytimeparse requests]); | ||
| 13 | |||
| 14 | buildPhase = '' | ||
| 15 | substituteAll $src zte-prometheus-exporter | ||
| 16 | ''; | ||
| 17 | |||
| 18 | doCheck = true; | ||
| 19 | checkPhase = '' | ||
| 20 | ${python}/bin/python -m py_compile zte-prometheus-exporter | ||
| 21 | ''; | ||
| 22 | |||
| 23 | installPhase = '' | ||
| 24 | install -m 0755 -D -t $out/bin \ | ||
| 25 | zte-prometheus-exporter | ||
| 26 | ''; | ||
| 27 | }; | ||
| 28 | } | ||
diff --git a/overlays/zte-prometheus-exporter/python-packages.nix b/overlays/zte-prometheus-exporter/python-packages.nix new file mode 100644 index 00000000..89fa2f99 --- /dev/null +++ b/overlays/zte-prometheus-exporter/python-packages.nix | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | # Generated by pip2nix 0.8.0.dev1 | ||
| 2 | # See https://github.com/nix-community/pip2nix | ||
| 3 | |||
| 4 | { pkgs, fetchurl, fetchgit, fetchhg }: | ||
| 5 | |||
| 6 | self: super: { | ||
| 7 | "pytimeparse" = super.buildPythonPackage rec { | ||
| 8 | pname = "pytimeparse"; | ||
| 9 | version = "1.1.8"; | ||
| 10 | src = fetchurl { | ||
| 11 | url = "https://files.pythonhosted.org/packages/1b/b4/afd75551a3b910abd1d922dbd45e49e5deeb4d47dc50209ce489ba9844dd/pytimeparse-1.1.8-py2.py3-none-any.whl"; | ||
| 12 | sha256 = "1g9nc03jya5scx1xlsbypkk4xhrsdj948m1jlr3md7xxr1nbxdq4"; | ||
| 13 | }; | ||
| 14 | format = "wheel"; | ||
| 15 | doCheck = false; | ||
| 16 | buildInputs = []; | ||
| 17 | checkInputs = []; | ||
| 18 | nativeBuildInputs = []; | ||
| 19 | propagatedBuildInputs = []; | ||
| 20 | }; | ||
| 21 | } | ||
diff --git a/overlays/zte-prometheus-exporter/zte-prometheus-exporter.py b/overlays/zte-prometheus-exporter/zte-prometheus-exporter.py new file mode 100644 index 00000000..13ff5a85 --- /dev/null +++ b/overlays/zte-prometheus-exporter/zte-prometheus-exporter.py | |||
| @@ -0,0 +1,216 @@ | |||
| 1 | #!@python@/bin/python | ||
| 2 | |||
| 3 | import requests | ||
| 4 | import xml.etree.ElementTree as ET | ||
| 5 | from getpass import getpass | ||
| 6 | from hashlib import sha256 | ||
| 7 | |||
| 8 | from time import sleep | ||
| 9 | |||
| 10 | import re | ||
| 11 | |||
| 12 | import json | ||
| 13 | |||
| 14 | from os import environ | ||
| 15 | import sys | ||
| 16 | |||
| 17 | from http.server import BaseHTTPRequestHandler, HTTPServer | ||
| 18 | |||
| 19 | from urllib.parse import urlparse | ||
| 20 | |||
| 21 | from pytimeparse.timeparse import timeparse | ||
| 22 | |||
| 23 | from textwrap import dedent | ||
| 24 | |||
| 25 | |||
| 26 | def _format_prom_attrs(**attrs): | ||
| 27 | if not attrs: | ||
| 28 | return '' | ||
| 29 | |||
| 30 | return '{' + ','.join(map(lambda k: f'{k}="{attrs[k]}"', attrs)) + '}' | ||
| 31 | |||
| 32 | def _format_prom_metrics(metricName, metricType, metrics, metricHelp=''): | ||
| 33 | metricStr = dedent(f''' | ||
| 34 | # HELP {metricName} {metricHelp} | ||
| 35 | # TYPE {metricName} {metricType} | ||
| 36 | ''').lstrip() | ||
| 37 | for (attrs, val) in metrics: | ||
| 38 | attrs_str = _format_prom_attrs(**attrs) | ||
| 39 | metricStr += dedent(f''' | ||
| 40 | {metricName}{attrs_str} {val} | ||
| 41 | ''').lstrip() | ||
| 42 | return metricStr | ||
| 43 | |||
| 44 | |||
| 45 | class ZTEMetrics: | ||
| 46 | _instance = None | ||
| 47 | |||
| 48 | @classmethod | ||
| 49 | def instance(cls): | ||
| 50 | if cls._instance is None: | ||
| 51 | cls._instance = cls.__new__(cls) | ||
| 52 | cls._instance.base_url = environ.get('ZTE_BASEURL') | ||
| 53 | cls._instance.username = environ.get('ZTE_USERNAME') | ||
| 54 | cls._instance.password = environ.get('ZTE_PASSWORD') | ||
| 55 | cls._instance.attrs = None | ||
| 56 | return cls._instance | ||
| 57 | |||
| 58 | |||
| 59 | def __init__(self): | ||
| 60 | raise RuntimeError('Call instance() instead') | ||
| 61 | |||
| 62 | _error_pattern = re.compile('^IF_ERROR(PARAM|TYPE|STR|ID)$') | ||
| 63 | _obj_pattern = re.compile('^(?:OBJ_(.+)_ID)|(?:ID_(WAN_COMFIG))$') | ||
| 64 | def update(self): | ||
| 65 | attrs = dict() | ||
| 66 | |||
| 67 | with requests.Session() as session: | ||
| 68 | session.get(self.base_url) | ||
| 69 | |||
| 70 | tok_req = session.get(f'{self.base_url}/function_module/login_module/login_page/logintoken_lua.lua') | ||
| 71 | tok_tree = ET.fromstring(tok_req.text) | ||
| 72 | login_token = tok_tree.text | ||
| 73 | |||
| 74 | password_hash = sha256((self.password + login_token).encode('utf-8')).hexdigest() | ||
| 75 | |||
| 76 | session.post(self.base_url, data = { 'Username': self.username, 'Password': password_hash, 'action': 'login' }) | ||
| 77 | |||
| 78 | dev_req = session.get(f'{self.base_url}/common_page/ManagReg_lua.lua') | ||
| 79 | sntp_req = session.get(f'{self.base_url}/getpage.lua?pid=1005&nextpage=Internet_sntp_lua.lua') | ||
| 80 | session.get(f'{self.base_url}/getpage.lua?pid=123&nextpage=Internet_AdminInternetStatus_DSL_t.lp') | ||
| 81 | dsl_req = session.get(f'{self.base_url}/common_page/internet_dsl_interface_lua.lua') | ||
| 82 | ppp_req = session.get(f'{self.base_url}/common_page/Internet_Internet_lua.lua?TypeUplink=1&pageType=1') | ||
| 83 | session.get(f'{self.base_url}/getpage.lua?pid=123&nextpage=Localnet_LocalnetStatusAd_t.lp') | ||
| 84 | lan_req = session.get(f'{self.base_url}/common_page/lanStatus_lua.lua') | ||
| 85 | dhcp_req = session.get(f'{self.base_url}/common_page/Localnet_LanMgrIpv4_DHCPHostInfo_lua.lua') | ||
| 86 | |||
| 87 | for req in [dev_req, sntp_req, dsl_req, ppp_req, lan_req, dhcp_req]: | ||
| 88 | xml = ET.fromstring(req.text) | ||
| 89 | for child in xml: | ||
| 90 | if self._error_pattern.match(child.tag): | ||
| 91 | continue | ||
| 92 | obj_tag = self._obj_pattern.match(child.tag) | ||
| 93 | if not obj_tag: | ||
| 94 | continue | ||
| 95 | obj_type = obj_tag.group(1) or obj_tag.group(2) | ||
| 96 | |||
| 97 | for instance in child.findall('Instance'): | ||
| 98 | instance_dict = dict() | ||
| 99 | name = None | ||
| 100 | value = None | ||
| 101 | for child in instance: | ||
| 102 | match child.tag: | ||
| 103 | case 'ParaName': | ||
| 104 | name = child.text | ||
| 105 | case 'ParaValue': | ||
| 106 | value = child.text | ||
| 107 | case _: | ||
| 108 | pass | ||
| 109 | if not name is None and not value is None: | ||
| 110 | instance_dict[name] = value | ||
| 111 | name = None | ||
| 112 | value = None | ||
| 113 | |||
| 114 | if obj_type not in attrs: | ||
| 115 | attrs[obj_type] = dict() | ||
| 116 | attrs[obj_type][instance_dict['_InstID']] = instance_dict | ||
| 117 | |||
| 118 | self.attrs = attrs | ||
| 119 | |||
| 120 | def json_text(self): | ||
| 121 | return json.dumps(self.attrs) | ||
| 122 | |||
| 123 | _link_pattern = re.compile('^IGD\.WD1\.LINE([0-9]+)$') | ||
| 124 | _eth_pattern = re.compile('^IGD\.LD1\.ETH([0-9]+)$') | ||
| 125 | def prometheus(self): | ||
| 126 | metrics = '' | ||
| 127 | |||
| 128 | uptime_seconds = timeparse(self.attrs['SYSTEMYIME']['IGD']['systemTime']) | ||
| 129 | metrics += _format_prom_metrics('uptime_seconds', 'gauge', [({}, uptime_seconds)], 'Seconds device has been running') | ||
| 130 | |||
| 131 | link_metrics = dict() | ||
| 132 | for link in self.attrs['DSLINTERFACE']: | ||
| 133 | link_match = self._link_pattern.match(link) | ||
| 134 | link_number = link_match.group(1) | ||
| 135 | |||
| 136 | if 'crc_errors_count' not in link_metrics: | ||
| 137 | link_metrics['crc_errors_count'] = {'type': 'counter', 'metrics': []} | ||
| 138 | link_metrics['crc_errors_count']['metrics'] += [({"direction": "up", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['UpCrc_errors']))] | ||
| 139 | link_metrics['crc_errors_count']['metrics'] += [({"direction": "down", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['DownCrc_errors']))] | ||
| 140 | |||
| 141 | if 'noise_margin_db' not in link_metrics: | ||
| 142 | link_metrics['noise_margin_db'] = {'type': 'gauge', 'metrics': []} | ||
| 143 | link_metrics['noise_margin_db']['metrics'] += [({"direction": "up", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Upstream_noise_margin']))] | ||
| 144 | link_metrics['noise_margin_db']['metrics'] += [({"direction": "down", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Downstream_noise_margin']))] | ||
| 145 | |||
| 146 | if 'attenuation_db' not in link_metrics: | ||
| 147 | link_metrics['attenuation_db'] = {'type': 'gauge', 'metrics': []} | ||
| 148 | link_metrics['attenuation_db']['metrics'] += [({"direction": "up", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Upstream_attenuation']))] | ||
| 149 | link_metrics['attenuation_db']['metrics'] += [({"direction": "down", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Downstream_attenuation']))] | ||
| 150 | |||
| 151 | if 'max_rate_kbps' not in link_metrics: | ||
| 152 | link_metrics['max_rate_kbps'] = {'type': 'gauge', 'metrics': []} | ||
| 153 | link_metrics['max_rate_kbps']['metrics'] += [({"direction": "up", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Upstream_max_rate']))] | ||
| 154 | link_metrics['max_rate_kbps']['metrics'] += [({"direction": "down", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Downstream_max_rate']))] | ||
| 155 | |||
| 156 | if 'current_rate_kbps' not in link_metrics: | ||
| 157 | link_metrics['current_rate_kbps'] = {'type': 'gauge', 'metrics': []} | ||
| 158 | link_metrics['current_rate_kbps']['metrics'] += [({"direction": "up", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Upstream_current_rate']))] | ||
| 159 | link_metrics['current_rate_kbps']['metrics'] += [({"direction": "down", "link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Downstream_current_rate']))] | ||
| 160 | |||
| 161 | if 'dsl_uptime_seconds' not in link_metrics: | ||
| 162 | link_metrics['dsl_uptime_seconds'] = {'type': 'gauge', 'metrics': []} | ||
| 163 | link_metrics['dsl_uptime_seconds']['metrics'] += [({"link": link_number}, int(self.attrs['DSLINTERFACE'][link]['Showtime_start']))] | ||
| 164 | if link_metrics: | ||
| 165 | for metric_name in link_metrics: | ||
| 166 | metrics += _format_prom_metrics('dsl_{metric_name}', link_metrics[metric_name]['type'], link_metrics[metric_name]['metrics']) | ||
| 167 | |||
| 168 | eth_metrics = dict() | ||
| 169 | for link in self.attrs['ETH']: | ||
| 170 | link_match = self._eth_pattern.match(link) | ||
| 171 | link_number = link_match.group(1) | ||
| 172 | |||
| 173 | if 'received_bytes' not in eth_metrics: | ||
| 174 | eth_metrics['received_bytes'] = {'type': 'counter', 'metrics': []} | ||
| 175 | eth_metrics['received_bytes']['metrics'] += [({"link": link_number}, int(self.attrs['ETH'][link]['BytesReceived']))] | ||
| 176 | if 'sent_bytes' not in eth_metrics: | ||
| 177 | eth_metrics['sent_bytes'] = {'type': 'counter', 'metrics': []} | ||
| 178 | eth_metrics['sent_bytes']['metrics'] += [({"link": link_number}, int(self.attrs['ETH'][link]['BytesSent']))] | ||
| 179 | if eth_metrics: | ||
| 180 | for metric_name in eth_metrics: | ||
| 181 | metrics += _format_prom_metrics('eth_{metric_name}', eth_metrics[metric_name]['type'], eth_metrics[metric_name]['metrics']) | ||
| 182 | |||
| 183 | return metrics.encode('utf-8') | ||
| 184 | |||
| 185 | class ZTEMetricsServer(BaseHTTPRequestHandler): | ||
| 186 | def do_GET(self): | ||
| 187 | zte_metrics = ZTEMetrics.instance() | ||
| 188 | zte_metrics.update() | ||
| 189 | |||
| 190 | url = urlparse(self.path) | ||
| 191 | |||
| 192 | match url.path: | ||
| 193 | case '/metrics.json': | ||
| 194 | self.send_response(200) | ||
| 195 | self.send_header("Content-type", "application/json") | ||
| 196 | self.end_headers() | ||
| 197 | |||
| 198 | self.wfile.write(zte_metrics.json_text().encode('utf-8')) | ||
| 199 | case '/metrics': | ||
| 200 | self.send_response(200) | ||
| 201 | self.send_header("Content-type", "text/plain") | ||
| 202 | self.end_headers() | ||
| 203 | |||
| 204 | self.wfile.write(zte_metrics.prometheus()) | ||
| 205 | case _: | ||
| 206 | self.send_response(404) | ||
| 207 | self.end_headers() | ||
| 208 | |||
| 209 | |||
| 210 | def main(): | ||
| 211 | webServer = HTTPServer((str(environ.get('ZTE_HOSTNAME')), int(environ.get('ZTE_PORT'))), ZTEMetricsServer) | ||
| 212 | |||
| 213 | webServer.serve_forever() | ||
| 214 | |||
| 215 | if __name__ == "__main__": | ||
| 216 | sys.exit(main()) | ||
