Files
napalm-openwrt/napalm_openwrt/system_mixin.py
T
Christian Manivong d45c082355 feat(system): report remote syslog, LuCI state and bridge STP
get_system_config() already parsed the whole system section but returned
only a slice of it, so netOrk had no IST side for three AP-profile fields
and could not compare them at all — the fields were editable, stored and
silently ineffective.

Adds:
  * syslog_remote / syslog_ip / syslog_port / syslog_proto — read from the
    system section that was already being parsed. log_remote gates the
    others: OpenWrt ships nothing without it, so a leftover log_ip must
    not read as an active target.
  * luci_enabled — from the uhttpd init script rather than its listener
    config, so the answer stays "is the web UI served" and re-enabling
    restores whatever was configured before.
  * bridge_stp — None when there is no br-ap at all, which is a different
    statement from "bridge without STP" and has to stay distinguishable.

netOrk #164
2026-08-29 22:41:36 +07:00

758 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# 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 re
import time as _time
from typing import Any
class OpenWrtSystemMixin:
"""Mixin providing system-level NAPALM getters (environment, NTP, SNMP, users, services, updates, 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:
pass
return SNMPConfigDict(running=True, community=community, port=161, version="2c")
def _action_fix_snmp(self) -> dict[str, Any]:
"""Install and configure snmpd on OpenWrt.
Installs snmpd-nossl (the daemon) and luci-app-snmpd (UCI schema +
proper procd init script). Configures community 'public' via UCI.
"""
lines: list = []
# 1. Install packages — snmpd-nossl (daemon) + luci-app-snmpd (UCI init)
pm = self._pm_type()
if pm == "apk":
raw = self._send_command("apk add snmpd-nossl luci-app-snmpd 2>&1")
else:
self._send_command("opkg update 2>/dev/null || true")
raw = self._send_command("opkg install snmpd-nossl luci-app-snmpd 2>&1")
out = self._clean_pkg_output(raw)
low = out.lower()
installed = not any(kw in low for kw in ("error:", "failed"))
lines.append(f"[install] {out[-300:]}")
if not installed and "already installed" not in low:
return {"success": False, "output": "\n".join(lines)}
# 2. Configure via UCI — modify the existing default sections only.
# Do NOT create new named sections (causes duplicate directives in
# the generated /var/run/snmpd.conf which crashes snmpd).
# Also remove any stale named sections from previous fix attempts.
# The init script reads these UCI field names to generate /var/run/snmpd.conf:
# agent: agentaddress
# com2sec: secname, source, community
# group: group (name!), version, secname
# view: viewname (not name!), type, oid
# access: group, version, level, prefix, read, write, notify
# Default luci-app-snmpd schema uses different field names for group/view/access,
# so we patch all required fields explicitly.
uci_cmds = [
# Remove any stale named sections from previous runs
"uci -q delete snmpd.agent",
"uci -q delete snmpd.public",
# agent
"uci set snmpd.@agent[0].agentaddress='161'",
# com2sec
"uci set snmpd.@com2sec[0].secname='ro'",
"uci set snmpd.@com2sec[0].source='0.0.0.0/0'",
"uci set snmpd.@com2sec[0].community='public'",
# group — init script reads field 'group' (not 'name')
"uci set snmpd.@group[0].group='rogroup'",
"uci set snmpd.@group[0].version='v2c'",
"uci set snmpd.@group[0].secname='ro'",
# view — init script reads field 'viewname' (not 'name')
"uci set snmpd.@view[0].viewname='all'",
"uci set snmpd.@view[0].type='included'",
"uci set snmpd.@view[0].oid='.1'",
# access — init script needs write + notify or it returns early
"uci set snmpd.@access[0].group='rogroup'",
"uci set snmpd.@access[0].context='none'",
"uci set snmpd.@access[0].version='v2c'",
"uci set snmpd.@access[0].level='noAuthNoPriv'",
"uci set snmpd.@access[0].prefix='exact'",
"uci set snmpd.@access[0].read='all'",
"uci set snmpd.@access[0].write='none'",
"uci set snmpd.@access[0].notify='none'",
"uci commit snmpd",
]
for cmd in uci_cmds:
self._send_command(f"{cmd} 2>/dev/null || true")
lines.append("[config] Configured snmpd via UCI (all required fields set).")
# 3. Firewall: allow UDP 161 from the management zone
try:
# Use `ss` (always available on OpenWrt) to find the IP of the current SSH client
raw_conn = self._send_command(
"ss -tnp 2>/dev/null | awk '/ESTAB.*:22/{print $5}' | head -1 | sed 's/:[0-9]*$//'"
).strip()
# Strip IPv6-mapped prefix if present
if raw_conn.startswith("::ffff:"):
raw_conn = raw_conn[7:]
peer_ip = raw_conn.splitlines()[-1].strip() if raw_conn else ""
if peer_ip and peer_ip not in ("", "0.0.0.0", "::"):
# Determine which firewall zone owns the interface that routes to peer_ip.
# Walk uci firewall zones: find the zone whose associated network interface
# has an address in the same /24 as peer_ip.
peer_prefix = peer_ip.rsplit(".", 1)[0] if "." in peer_ip else ""
fw_out = self._send_command("uci show firewall 2>/dev/null || true")
src_zone = "*"
# Build map zone_name → zone_uci_key
zone_map: dict[str, str] = {}
for fw_line in fw_out.splitlines():
m = re.match(r"firewall\.(\w+)\.name='([^']+)'", fw_line.strip())
if m:
zone_map[m.group(2)] = m.group(1)
# For each zone find its network, then the interface IP
for zone_name, uci_key in zone_map.items():
net_line = self._send_command(
f"uci -q get firewall.{uci_key}.network 2>/dev/null || true"
).strip()
if not net_line:
continue
for net_name in net_line.split():
iface_ip = self._send_command(
f"uci -q get network.{net_name}.ipaddr 2>/dev/null || true"
).strip()
if iface_ip and "." in iface_ip:
iface_prefix = iface_ip.split("/")[0].rsplit(".", 1)[0]
if peer_prefix and iface_prefix == peer_prefix:
src_zone = zone_name
break
if src_zone != "*":
break
self._send_command(
f"uci -q delete firewall.snmp_netork 2>/dev/null; "
f"uci set firewall.snmp_netork=rule; "
f"uci set firewall.snmp_netork.name='Allow-SNMP-from-mgmt'; "
f"uci set firewall.snmp_netork.src='{src_zone}'; "
f"uci set firewall.snmp_netork.dest_port='161'; "
f"uci set firewall.snmp_netork.proto='udp'; "
f"uci set firewall.snmp_netork.target='ACCEPT'; "
f"uci commit firewall; "
f"/etc/init.d/firewall reload 2>/dev/null || true"
)
lines.append(f"[firewall] Added UDP:161 allow rule (src zone: {src_zone}).")
else:
lines.append("[firewall] Could not detect peer IP via ss — skipping firewall step.")
except Exception as exc:
lines.append(f"[firewall] skipped — {exc}")
# 4. Break any crash-loop, then start cleanly
self._send_command("/etc/init.d/snmpd stop 2>/dev/null; true")
_time.sleep(2)
self._send_command("pkill -9 snmpd 2>/dev/null; true") # kill crash-loop zombie
_time.sleep(3)
self._send_command("/etc/init.d/snmpd enable 2>/dev/null; true")
self._send_command("/etc/init.d/snmpd start 2>/dev/null; true")
_time.sleep(4)
lines.append("[service] snmpd started via procd.")
# 5. Check if snmpd is now active (no local snmpget on OpenWrt by default)
status = self._send_command(
"/etc/init.d/snmpd running 2>/dev/null && echo active || echo inactive"
).strip()
success = status == "active"
if success:
lines.append("[ok] snmpd is active.")
else:
lines.append(f"[warn] snmpd status: {status}")
return {"success": success, "output": "\n".join(lines)}
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_device_warnings(self) -> list[dict[str, Any]]:
"""Return a list of warning dicts for issues detected on this device.
Currently detects:
- lldpd not installed (LLDP neighbor discovery unavailable)
- package updates available (uses local package cache, no network call)
- update notifications not configured (auc not installed, opkg only)
"""
warnings: list[dict[str, Any]] = []
# 1. LLDP daemon
lldpd_path = self._send_command("which lldpd 2>/dev/null").strip()
if not lldpd_path:
warnings.append({"code": "lldpd_not_installed"})
pm = self._pm_type()
# 2. Package updates available (local cache only – no opkg update)
def _pkg_name(line: str, pm_type: str) -> str:
"""Extract just the package name from an upgradable line.
apk: 'luci-app-firewall-26.143~abc < 26.151~def' → 'luci-app-firewall'
opkg: 'luci-app-firewall - 1.0 - 1.1' → 'luci-app-firewall'
"""
import re as _re
if pm_type == "apk":
# Strip trailing ' < ...' then remove the version suffix (-\d...)
name_ver = line.split(" ")[0]
m = _re.match(r'^(.*?)-\d', name_ver)
return m.group(1) if m else name_ver
else:
return line.split(" - ")[0].strip()
try:
if pm == "apk":
raw_upg = self._send_command("apk version 2>/dev/null | grep '<'")
else:
raw_upg = self._send_command("opkg list-upgradable 2>/dev/null")
upgradable = [ln.strip() for ln in raw_upg.splitlines() if ln.strip()]
except Exception:
upgradable = []
if upgradable:
pkg_names = [_pkg_name(ln, pm) for ln in upgradable]
warnings.append({
"code": "updates_available",
"meta": {
"count": len(upgradable),
"packages": pkg_names[:10],
},
})
# 3. Attended sysupgrade client not installed (opkg systems only)
if pm == "opkg":
auc_path = self._send_command("which auc 2>/dev/null").strip()
if not auc_path:
warnings.append({"code": "update_notifications_disabled"})
# 4. base64 not available — needed for efficient config apply
b64_path = self._send_command("command -v base64 2>/dev/null").strip()
if not b64_path:
warnings.append({"code": "no_base64"})
return warnings
def get_available_updates(self) -> list[dict[str, Any]]:
"""Return list of upgradable packages from the local package manager cache."""
import re as _re
pm = self._pm_type()
updates: list[dict[str, Any]] = []
if pm == "apk":
# Output format: "pkgname-current_ver < new_ver"
raw = self._send_command("apk version 2>/dev/null | grep '<'")
for line in raw.splitlines():
line = line.strip()
m = _re.match(r'^(.+)-(\d\S*)\s+<\s+(\S+)', line)
if m:
updates.append({
"name": m.group(1),
"current_version": m.group(2),
"new_version": m.group(3),
})
else:
# opkg output: "pkgname - current_ver - new_ver"
raw = self._send_command("opkg list-upgradable 2>/dev/null")
for line in raw.splitlines():
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 sorted(updates, key=lambda u: u["name"])
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