- Long lines are wrapped: the get_environment awk command is split into adjacent string literals, the rollback message and the system mixin's docstring are wrapped; the command and the text are unchanged. - The two except Exception: pass (the snmpd community in get_snmp_config, the network VLANs in get_ssids) now log at debug level (B110), so a swallowed failure leaves a trace. - tests: raise ... from exc, unused loop variable.
603 lines
23 KiB
Python
603 lines
23 KiB
Python
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Printed after a command so its exit status survives the shell session.
|
|
_RC_MARKER = "__OWRT_RC="
|
|
_RC_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
|
|
#: What apk prints when its index is not in RAM (after a reboot).
|
|
_APK_INDEX_MISSING = re.compile(r"opening from cache .*No such file or directory")
|
|
|
|
|
|
def _split_status(raw: str) -> tuple[str, int | None]:
|
|
"""``(output, exit_status)``; the status is None when the marker never arrived."""
|
|
statuses = _RC_RE.findall(raw or "")
|
|
output = _RC_RE.sub("", raw or "").strip()
|
|
return output, int(statuses[-1]) if statuses else None
|
|
|
|
|
|
class OpenWrtSystemMixin:
|
|
"""Mixin providing system-level NAPALM getters.
|
|
|
|
Environment, NTP, SNMP, users, services, updates and device actions.
|
|
"""
|
|
|
|
def get_environment(self) -> dict[str, Any]:
|
|
"""Return device environment data (CPU, memory).
|
|
|
|
CPU usage from ``/proc/stat`` (two samples 1 second apart via ``awk``).
|
|
Memory from ``/proc/meminfo``.
|
|
"""
|
|
cpu_out = self._send_command(
|
|
"awk '/^cpu /{idle1=$5; total1=$2+$3+$4+$5+$6+$7+$8} "
|
|
"END{print (1-(idle1/total1))*100}' /proc/stat"
|
|
)
|
|
mem_out = self._send_command("cat /proc/meminfo")
|
|
|
|
cpu_pct = 0.0
|
|
try:
|
|
cpu_pct = float(cpu_out.strip())
|
|
except (ValueError, AttributeError):
|
|
pass
|
|
|
|
mem_total = 0
|
|
mem_available = 0
|
|
for line in mem_out.splitlines():
|
|
if line.startswith("MemTotal:"):
|
|
try:
|
|
mem_total = int(line.split()[1])
|
|
except (IndexError, ValueError):
|
|
pass
|
|
elif line.startswith("MemAvailable:"):
|
|
try:
|
|
mem_available = int(line.split()[1])
|
|
except (IndexError, ValueError):
|
|
pass
|
|
|
|
return {
|
|
"fans": {},
|
|
"temperature": {},
|
|
"power": {},
|
|
"cpu": {0: {"%usage": round(cpu_pct, 1)}},
|
|
"memory": {
|
|
"available_ram": mem_available * 1024,
|
|
"used_ram": (mem_total - mem_available) * 1024,
|
|
},
|
|
}
|
|
|
|
def get_system_config(self) -> dict[str, Any]:
|
|
"""Return system-level configuration from UCI.
|
|
|
|
Reads ``uci show system`` and ``uci show dropbear`` to collect:
|
|
|
|
* hostname (str)
|
|
* timezone (str) — POSIX TZ string, e.g. ``"CET-1CEST,M3.5.0,M10.5.0/3"``
|
|
* zonename (str) — human-readable name, e.g. ``"Europe/Berlin"``
|
|
* ntp_servers (list[str])
|
|
* dropbear_port (int) — SSH port
|
|
* dropbear_password_auth (bool) — whether password login is allowed
|
|
* dropbear_root_password_auth (bool)
|
|
* syslog_remote (bool) — whether logs are shipped off the device
|
|
* syslog_ip (str), syslog_port (int), syslog_proto (str)
|
|
* luci_enabled (bool) — whether the uhttpd service serving LuCI is enabled
|
|
* bridge_stp (bool | None) — STP on br-ap; None when there is no br-ap
|
|
"""
|
|
sys_out = self._send_command("uci show system 2>/dev/null || true")
|
|
db_out = self._send_command("uci show dropbear 2>/dev/null || true")
|
|
luci_out = self._send_command("/etc/init.d/uhttpd enabled 2>/dev/null && echo 1 || echo 0")
|
|
net_out = self._send_command("uci show network 2>/dev/null || true")
|
|
|
|
sys_cfg: dict[str, str] = {}
|
|
for line in sys_out.splitlines():
|
|
m = re.match(r"system\.@system\[0\]\.(\w+)='([^']*)'", line.strip())
|
|
if m:
|
|
sys_cfg[m.group(1)] = m.group(2)
|
|
|
|
# NTP server list: all servers on a single line, space-separated quoted values
|
|
# e.g. system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org' ...
|
|
ntp_servers: list[str] = []
|
|
for line in sys_out.splitlines():
|
|
if re.match(r"system\.ntp\.server=", line.strip()):
|
|
ntp_servers = re.findall(r"'([^']+)'", line)
|
|
break
|
|
|
|
# Dropbear settings
|
|
db_cfg: dict[str, str] = {}
|
|
for line in db_out.splitlines():
|
|
# May be @dropbear[0] or named section
|
|
m = re.match(r"dropbear\.[@\w]+\.(\w+)='([^']*)'", line.strip())
|
|
if m:
|
|
db_cfg.setdefault(m.group(1), m.group(2))
|
|
|
|
try:
|
|
ssh_port = int(db_cfg.get("Port", "22"))
|
|
except (ValueError, TypeError):
|
|
ssh_port = 22
|
|
|
|
def _bool_uci(val: str, default: bool = True) -> bool:
|
|
return val.lower() not in ("0", "off", "false", "no") if val else default
|
|
|
|
# Remote syslog. OpenWrt only ships logs when log_remote is set, so a
|
|
# leftover log_ip without it means nothing is being sent.
|
|
syslog_remote = _bool_uci(sys_cfg.get("log_remote", ""), default=False)
|
|
try:
|
|
syslog_port = int(sys_cfg.get("log_port", "514") or "514")
|
|
except (ValueError, TypeError):
|
|
syslog_port = 514
|
|
|
|
# STP on the AP bridge. None when there is no br-ap device section at
|
|
# all — "no bridge" is a different statement from "bridge without STP".
|
|
bridge_stp: bool | None = None
|
|
br_section: str | None = None
|
|
for line in net_out.splitlines():
|
|
m = re.match(r"network\.(\w+)\.name='br-ap'", line.strip())
|
|
if m:
|
|
br_section = m.group(1)
|
|
break
|
|
if br_section:
|
|
bridge_stp = False
|
|
for line in net_out.splitlines():
|
|
m = re.match(rf"network\.{br_section}\.stp='([^']*)'", line.strip())
|
|
if m:
|
|
bridge_stp = _bool_uci(m.group(1), default=False)
|
|
break
|
|
|
|
return {
|
|
"hostname": sys_cfg.get("hostname", ""),
|
|
"timezone": sys_cfg.get("timezone", ""),
|
|
"zonename": sys_cfg.get("zonename", ""),
|
|
"ntp_servers": ntp_servers,
|
|
"dropbear_port": ssh_port,
|
|
"dropbear_password_auth": _bool_uci(db_cfg.get("PasswordAuth", "on")),
|
|
"dropbear_root_password_auth": _bool_uci(db_cfg.get("RootPasswordAuth", "on")),
|
|
"syslog_remote": syslog_remote,
|
|
"syslog_ip": sys_cfg.get("log_ip", "") if syslog_remote else "",
|
|
"syslog_port": syslog_port,
|
|
"syslog_proto": sys_cfg.get("log_proto", "udp"),
|
|
"luci_enabled": luci_out.strip().endswith("1"),
|
|
"bridge_stp": bridge_stp,
|
|
}
|
|
|
|
def get_snmp_information(self) -> dict[str, Any]:
|
|
"""Return SNMP configuration from ``uci show snmpd``.
|
|
|
|
UCI example::
|
|
|
|
snmpd.@com2sec[0].community='public'
|
|
snmpd.@com2sec[0].secname='public'
|
|
snmpd.@system[0].sysContact='root@localhost'
|
|
snmpd.@system[0].sysLocation='Unknown'
|
|
"""
|
|
uci_out = self._send_command("uci show snmpd")
|
|
|
|
contact = ""
|
|
location = ""
|
|
chassis_id = ""
|
|
community: dict[str, Any] = {}
|
|
|
|
# Track com2sec entries by index
|
|
com2sec: dict[str, dict[str, str]] = {}
|
|
|
|
for line in uci_out.splitlines():
|
|
line_s = line.strip()
|
|
m = re.match(r"snmpd\.@com2sec\[(\d+)\]\.(\w+)='([^']*)'", line_s)
|
|
if m:
|
|
idx, key, val = m.group(1), m.group(2), m.group(3)
|
|
com2sec.setdefault(idx, {})[key] = val
|
|
continue
|
|
m = re.match(r"snmpd\.@system\[0\]\.sys(\w+)='([^']*)'", line_s)
|
|
if m:
|
|
key, val = m.group(1).lower(), m.group(2)
|
|
if key == "contact":
|
|
contact = val
|
|
elif key == "location":
|
|
location = val
|
|
elif key == "name":
|
|
chassis_id = val
|
|
|
|
for entry in com2sec.values():
|
|
name = entry.get("community", entry.get("secname", ""))
|
|
if not name:
|
|
continue
|
|
# OpenWrt snmpd doesn't distinguish rw/ro per community via UCI by default
|
|
mode = "ro"
|
|
if entry.get("secname", "").lower() in ("private", "readwrite", "rw"):
|
|
mode = "rw"
|
|
community[name] = {
|
|
"acl": entry.get("source", "N/A"),
|
|
"mode": mode,
|
|
}
|
|
|
|
return {
|
|
"chassis_id": chassis_id,
|
|
"community": community,
|
|
"contact": contact,
|
|
"location": location,
|
|
}
|
|
|
|
def get_snmp_config(self):
|
|
"""Return SNMP agent config if snmpd is installed and running on OpenWrt."""
|
|
try:
|
|
from napalm_device_types.models import SNMPConfigDict
|
|
except ImportError:
|
|
return None
|
|
|
|
running = (
|
|
self._send_command(
|
|
"/etc/init.d/snmpd running 2>/dev/null && echo active || echo inactive"
|
|
).strip()
|
|
== "active"
|
|
)
|
|
if not running:
|
|
return None
|
|
|
|
community = "public"
|
|
try:
|
|
# UCI config (set by luci-app-snmpd)
|
|
uci_comm = self._send_command(
|
|
"uci -q get snmpd.public.community 2>/dev/null || "
|
|
"uci -q get snmpd.@com2sec[0].community 2>/dev/null || echo ''"
|
|
).strip()
|
|
if uci_comm:
|
|
community = uci_comm
|
|
except Exception:
|
|
logger.debug("snmpd community: ignored", exc_info=True)
|
|
|
|
return SNMPConfigDict(running=True, community=community, port=161, version="2c")
|
|
|
|
def get_users(self) -> dict[str, Any]:
|
|
"""Return users configured on the device.
|
|
|
|
Parses ``/etc/passwd`` for accounts with a valid login shell.
|
|
SSH public keys are read from ``~<user>/.ssh/authorized_keys``
|
|
(Dropbear also stores root keys at ``/etc/dropbear/authorized_keys``).
|
|
|
|
Level mapping:
|
|
- UID 0 (root) → 15 (full access)
|
|
- all other users → 1
|
|
"""
|
|
passwd_out = self._send_command("cat /etc/passwd")
|
|
# Root authorized_keys locations on OpenWrt
|
|
root_keys_out = self._send_command(
|
|
["cat /root/.ssh/authorized_keys", "cat /etc/dropbear/authorized_keys"]
|
|
)
|
|
|
|
users: dict[str, Any] = {}
|
|
valid_shells = {"/bin/sh", "/bin/ash", "/bin/bash", "/usr/bin/fish"}
|
|
|
|
for line in passwd_out.splitlines():
|
|
parts = line.strip().split(":")
|
|
if len(parts) < 7:
|
|
continue
|
|
username, password_hash, uid_str, _, _, home, shell = (
|
|
parts[0],
|
|
parts[1],
|
|
parts[2],
|
|
parts[3],
|
|
parts[4],
|
|
parts[5],
|
|
parts[6],
|
|
)
|
|
if shell not in valid_shells:
|
|
continue
|
|
try:
|
|
uid = int(uid_str)
|
|
except ValueError:
|
|
continue
|
|
|
|
level = 15 if uid == 0 else 1
|
|
|
|
# Collect SSH keys for this user
|
|
sshkeys: list[str] = []
|
|
if uid == 0:
|
|
for line_k in root_keys_out.splitlines():
|
|
line_k = line_k.strip()
|
|
if line_k and not line_k.startswith("#"):
|
|
sshkeys.append(line_k)
|
|
else:
|
|
# Try reading per-user authorized_keys
|
|
keys_out = self._send_command(f"cat {home}/.ssh/authorized_keys 2>/dev/null")
|
|
for line_k in keys_out.splitlines():
|
|
line_k = line_k.strip()
|
|
if line_k and not line_k.startswith("#"):
|
|
sshkeys.append(line_k)
|
|
|
|
users[username] = {
|
|
"level": level,
|
|
"password": password_hash,
|
|
"sshkeys": sshkeys,
|
|
}
|
|
|
|
return users
|
|
|
|
def get_services(self) -> list[dict[str, Any]]:
|
|
"""Return all system services with their running and enabled state.
|
|
|
|
Uses ``ubus call service list`` for running/PID info and
|
|
``/etc/rc.d/S*`` symlinks for enabled-at-boot state.
|
|
"""
|
|
import json as _json
|
|
|
|
# -- enabled set: names from /etc/rc.d/S<priority><name> symlinks ----
|
|
rc_out = self._send_command("ls /etc/rc.d/ 2>/dev/null | grep '^S' | sed 's/^S[0-9]*//'")
|
|
enabled: set[str] = {s.strip() for s in rc_out.splitlines() if s.strip()}
|
|
|
|
# -- running info from procd via ubus ---------------------------------
|
|
ubus_raw = self._send_command("ubus call service list 2>/dev/null")
|
|
ubus_data: dict = {}
|
|
try:
|
|
ubus_data = _json.loads(ubus_raw)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Build index from ubus data
|
|
service_map: dict[str, dict] = {}
|
|
for svc_name, svc_info in ubus_data.items():
|
|
if not isinstance(svc_info, dict):
|
|
continue
|
|
instances = svc_info.get("instances", {})
|
|
running = any(
|
|
inst.get("running", False) for inst in instances.values() if isinstance(inst, dict)
|
|
)
|
|
pid = next(
|
|
(
|
|
inst.get("pid", 0)
|
|
for inst in instances.values()
|
|
if isinstance(inst, dict) and inst.get("running")
|
|
),
|
|
0,
|
|
)
|
|
service_map[svc_name] = {"running": running, "pid": pid}
|
|
|
|
# -- all init scripts -------------------------------------------------
|
|
init_raw = self._send_command("ls -1 /etc/init.d/ 2>/dev/null")
|
|
init_scripts: set[str] = {s.strip() for s in init_raw.splitlines() if s.strip()}
|
|
|
|
# Merge: all known services (from init.d + ubus)
|
|
all_names = init_scripts | set(service_map.keys())
|
|
# Exclude procd internal pseudo-service
|
|
all_names.discard("")
|
|
|
|
result: list[dict[str, Any]] = []
|
|
for name in sorted(all_names):
|
|
info = service_map.get(name, {})
|
|
result.append(
|
|
{
|
|
"name": name,
|
|
"running": info.get("running", False),
|
|
"enabled": name in enabled,
|
|
"pid": info.get("pid", 0),
|
|
}
|
|
)
|
|
|
|
return result
|
|
|
|
def manage_service(self, name: str, action: str) -> dict[str, Any]:
|
|
"""Execute a lifecycle action (start/stop/restart/enable/disable) on a service."""
|
|
import re as _re
|
|
|
|
if not _re.match(r"^[a-zA-Z0-9_\-]+$", name):
|
|
raise ValueError(f"Invalid service name: {name!r}")
|
|
if action not in ("start", "stop", "restart", "enable", "disable"):
|
|
raise ValueError(f"Invalid action: {action!r}")
|
|
output = self._send_command(f"/etc/init.d/{name} {action} 2>&1")
|
|
return {"success": True, "output": output}
|
|
|
|
def get_available_updates(self) -> list[dict[str, Any]]:
|
|
"""Return list of upgradable packages from the local package manager cache.
|
|
|
|
OpenWrt keeps its package index in RAM. Without it -- after a reboot,
|
|
before ``refresh_available_updates`` -- ``apk version`` only warns and
|
|
``opkg list-upgradable`` prints nothing. Both raise here instead of
|
|
reporting "nothing pending".
|
|
"""
|
|
if self._pm_type() == "apk":
|
|
updates = self._updates_apk()
|
|
else:
|
|
updates = self._updates_opkg()
|
|
return sorted(updates, key=lambda u: u["name"])
|
|
|
|
def _updates_apk(self) -> list[dict[str, Any]]:
|
|
import re as _re
|
|
|
|
output, status = _split_status(self._send_command(f"apk version 2>&1; echo {_RC_MARKER}$?"))
|
|
if status != 0:
|
|
raise RuntimeError(f"apk version failed (exit {status}): {output[-200:]}")
|
|
if _APK_INDEX_MISSING.search(output):
|
|
raise RuntimeError("The package index is not loaded (run apk update first)")
|
|
updates: list[dict[str, Any]] = []
|
|
for line in output.splitlines():
|
|
# Output format: "pkgname-current_ver < new_ver"
|
|
m = _re.match(r"^(.+)-(\d\S*)\s+<\s+(\S+)", line.strip())
|
|
if m:
|
|
updates.append(
|
|
{
|
|
"name": m.group(1),
|
|
"current_version": m.group(2),
|
|
"new_version": m.group(3),
|
|
}
|
|
)
|
|
return updates
|
|
|
|
def _updates_opkg(self) -> list[dict[str, Any]]:
|
|
raw = self._send_command(
|
|
'[ -n "$(ls /var/opkg-lists 2>/dev/null)" ] || echo __NO_INDEX__; '
|
|
"opkg list-upgradable 2>/dev/null"
|
|
)
|
|
if "__NO_INDEX__" in raw:
|
|
raise RuntimeError("The package index is not loaded (run opkg update first)")
|
|
updates: list[dict[str, Any]] = []
|
|
for line in raw.splitlines():
|
|
# opkg output: "pkgname - current_ver - new_ver"
|
|
parts = [p.strip() for p in line.split(" - ")]
|
|
if len(parts) == 3:
|
|
updates.append(
|
|
{
|
|
"name": parts[0],
|
|
"current_version": parts[1],
|
|
"new_version": parts[2],
|
|
}
|
|
)
|
|
return updates
|
|
|
|
def refresh_available_updates(self) -> dict[str, Any]:
|
|
"""Load the package index into RAM (``apk update`` / ``opkg update``)."""
|
|
cmd = "apk update" if self._pm_type() == "apk" else "opkg update"
|
|
output, status = _split_status(self._send_command(f"{cmd} 2>&1; echo {_RC_MARKER}$?"))
|
|
return {"success": status == 0, "output": output}
|
|
|
|
def apply_updates(self, packages: list[str]) -> dict[str, Any]:
|
|
"""Upgrade the given packages using the device's package manager."""
|
|
import re as _re
|
|
|
|
for pkg in packages:
|
|
if not _re.match(r"^[a-zA-Z0-9_\-\+\.]+$", pkg):
|
|
raise ValueError(f"Invalid package name: {pkg!r}")
|
|
pm = self._pm_type()
|
|
pkg_args = " ".join(packages)
|
|
if pm == "apk":
|
|
cmd = f"apk upgrade {pkg_args} 2>&1"
|
|
else:
|
|
cmd = f"opkg upgrade {pkg_args} 2>&1"
|
|
output = self._send_command(cmd)
|
|
return {"success": True, "output": output}
|
|
|
|
# ------------------------------------------------------------------
|
|
# NTP
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_ntp_servers(self) -> dict[str, Any]:
|
|
"""Return configured NTP servers from ``uci show system``.
|
|
|
|
UCI example::
|
|
|
|
system.ntp.server='0.openwrt.pool.ntp.org 1.openwrt.pool.ntp.org'
|
|
"""
|
|
uci_out = self._send_command("uci show system")
|
|
servers: dict[str, Any] = {}
|
|
|
|
for line in uci_out.splitlines():
|
|
# Handles both list and single-value UCI representations
|
|
m = re.match(r"system\.ntp\.server(?:\[\d+\])?='([^']*)'", line.strip())
|
|
if m:
|
|
for srv in m.group(1).split():
|
|
srv = srv.strip()
|
|
if srv:
|
|
servers[srv] = {}
|
|
|
|
return servers
|
|
|
|
def get_ntp_peers(self) -> dict[str, Any]:
|
|
"""Return NTP peers from ``uci show system``.
|
|
|
|
OpenWrt's busybox ntpd does not differentiate peers from servers;
|
|
the same UCI ``ntp.server`` list is returned.
|
|
"""
|
|
return self.get_ntp_servers()
|
|
|
|
def get_ntp_stats(self) -> list[dict[str, Any]]:
|
|
"""Return NTP synchronisation statistics.
|
|
|
|
Tries ``ntpq -pn`` first (ntpd), then ``chronyc sources -v`` (chrony).
|
|
Returns an empty list when neither tool is available.
|
|
|
|
``ntpq -pn`` example line::
|
|
|
|
*188.114.101.4 188.114.100.1 4 u 107 256 377 164.228 -13.866 2.695
|
|
|
|
``chronyc sources -v`` example line::
|
|
|
|
^* 192.168.1.1 2 6 17 8 +2345us[ 0ns] +/- 15ms
|
|
"""
|
|
ntpq_out = self._send_command("ntpq -pn")
|
|
if ntpq_out and not ntpq_out.startswith(("ntpq: ", "sh: ", "ash: ", "command not found")):
|
|
return self._parse_ntpq(ntpq_out)
|
|
|
|
chrony_out = self._send_command("chronyc sources -v")
|
|
if chrony_out and not chrony_out.startswith(("sh: ", "ash: ", "command not found")):
|
|
return self._parse_chronyc(chrony_out)
|
|
|
|
return []
|
|
|
|
@staticmethod
|
|
def _parse_ntpq(output: str) -> list[dict[str, Any]]:
|
|
"""Parse ``ntpq -pn`` tabular output."""
|
|
stats = []
|
|
for line in output.splitlines():
|
|
line_s = line.strip()
|
|
if not line_s or line_s.startswith(("remote", "=")):
|
|
continue
|
|
# First char is the tally code (* = synchronized, + = candidate, etc.)
|
|
tally = line_s[0] if line_s[0] in "* +-x.o#" else " "
|
|
parts = line_s[1:].split()
|
|
if len(parts) < 10:
|
|
continue
|
|
try:
|
|
stats.append(
|
|
{
|
|
"remote": parts[0],
|
|
"referenceid": parts[1],
|
|
"synchronized": tally == "*",
|
|
"stratum": int(parts[2]),
|
|
"type": parts[3],
|
|
"when": parts[4],
|
|
"hostpoll": int(parts[5]),
|
|
"reachability": int(parts[6], 8), # octal
|
|
"delay": float(parts[7]),
|
|
"offset": float(parts[8]),
|
|
"jitter": float(parts[9]),
|
|
}
|
|
)
|
|
except (ValueError, IndexError):
|
|
continue
|
|
return stats
|
|
|
|
@staticmethod
|
|
def _parse_chronyc(output: str) -> list[dict[str, Any]]:
|
|
"""Parse ``chronyc sources -v`` tabular output."""
|
|
stats = []
|
|
for line in output.splitlines():
|
|
line_s = line.strip()
|
|
# Data lines start with ^* ^+ ^- ^?
|
|
m = re.match(r"^(\^[*+\-?])\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)", line_s)
|
|
if not m:
|
|
continue
|
|
tally = m.group(1)
|
|
try:
|
|
stats.append(
|
|
{
|
|
"remote": m.group(2),
|
|
"referenceid": "",
|
|
"synchronized": tally == "^*",
|
|
"stratum": int(m.group(3)),
|
|
"type": "u",
|
|
"when": m.group(6),
|
|
"hostpoll": int(m.group(4)),
|
|
"reachability": int(m.group(5), 8)
|
|
if re.match(r"^[0-7]+$", m.group(5))
|
|
else 0,
|
|
"delay": 0.0,
|
|
"offset": 0.0,
|
|
"jitter": 0.0,
|
|
}
|
|
)
|
|
except (ValueError, IndexError):
|
|
continue
|
|
return stats
|