# -*- coding: utf-8 -*- "Common utility functions" # SPDX-License-Identifier: BSD-2-Clause from __future__ import print_function, division import collections import os import re import shutil import socket import string import sys import time import ntp.ntpc import ntp.magic import ntp.control # Old CTL_PST defines for version 2. OLD_CTL_PST_CONFIG = 0x80 OLD_CTL_PST_AUTHENABLE = 0x40 OLD_CTL_PST_AUTHENTIC = 0x20 OLD_CTL_PST_REACH = 0x10 OLD_CTL_PST_SANE = 0x08 OLD_CTL_PST_DISP = 0x04 OLD_CTL_PST_SEL_REJECT = 0 OLD_CTL_PST_SEL_SELCAND = 1 OLD_CTL_PST_SEL_SYNCCAND = 2 OLD_CTL_PST_SEL_SYSPEER = 3 # Units for formatting UNIT_NS = "ns" # nano second UNIT_US = u"µs" # micro second UNIT_MS = "ms" # milli second UNIT_S = "s" # second UNIT_KS = "ks" # kilo seconds UNITS_SEC = [UNIT_NS, UNIT_US, UNIT_MS, UNIT_S, UNIT_KS] UNIT_PPT = "ppt" # parts per trillion UNIT_PPB = "ppb" # parts per billion UNIT_PPM = "ppm" # parts per million UNIT_PPK = u"‰" # parts per thousand UNITS_PPX = [UNIT_PPT, UNIT_PPB, UNIT_PPM, UNIT_PPK] unitgroups = (UNITS_SEC, UNITS_PPX) # These two functions are not tested because they will muck up the module # for everything else, and they are simple. def check_unicode(): # pragma: no cover if "UTF-8" != sys.stdout.encoding: deunicode_units() return True # needed by ntpmon return False def deunicode_units(): # pragma: no cover """Under certain conditions it is not possible to force unicode output, this overwrites units that contain unicode with safe versions""" global UNIT_US global UNIT_PPK # Replacement units new_us = "us" new_ppk = "ppk" # Replace units in unit groups UNITS_SEC[UNITS_SEC.index(UNIT_US)] = new_us UNITS_PPX[UNITS_PPX.index(UNIT_PPK)] = new_ppk # Replace the units themselves UNIT_US = new_us UNIT_PPK = new_ppk # Variables that have units S_VARS = ("tai", "poll") MS_VARS = ("rootdelay", "rootdisp", "rootdist", "offset", "sys_jitter", "clk_jitter", "leapsmearoffset", "authdelay", "koffset", "kmaxerr", "kesterr", "kprecis", "kppsjitter", "fuzz", "clk_wander_threshold", "tick", "in", "out", "bias", "delay", "jitter", "dispersion", "fudgetime1", "fudgetime2") PPM_VARS = ("frequency", "clk_wander") def dolog(logfp, text, debug, threshold): """debug is the current debug value threshold is the trigger for the current log""" if logfp is None: return # can turn off logging by supplying a None file descriptor text = rfc3339(time.time()) + " " + text + "\n" if debug >= threshold: logfp.write(text) logfp.flush() # we don't want to lose an important log to a crash def safeargcast(arg, castfunc, errtext, usage): """Attempts to typecast an argument, prints and dies on failure. errtext must contain a %s for splicing in the argument, and be newline terminated.""" try: casted = castfunc(arg) except ValueError: sys.stderr.write(errtext % arg) sys.stderr.write(usage) raise SystemExit(1) return casted def stdversion(): "Returns the NTPsec version string in a standard format" return "ntpsec-%s" % "1.2.2" def rfc3339(t): "RFC 3339 string from Unix time, including fractional second." rep = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(t)) t = str(t) if "." in t: subsec = t.split(".", 1)[1] if int(subsec) > 0: rep += "." + subsec rep += "Z" return rep def deformatNTPTime(txt): txt = txt[2:] # Strip '0x' txt = "".join(txt.split(".")) # Strip '.' value = ntp.util.hexstr2octets(txt) return value def hexstr2octets(hexstr): if (len(hexstr) % 2) != 0: hexstr = hexstr[:-1] # slice off the last char values = [] for index in range(0, len(hexstr), 2): values.append(chr(int(hexstr[index:index+2], 16))) return "".join(values) def slicedata(data, slicepoint): "Breaks a sequence into two pieces at the slice point" return data[:slicepoint], data[slicepoint:] def portsplit(hostname): portsuffix = "" if hostname.count(":") == 1: # IPv4 with appended port (hostname, portsuffix) = hostname.split(":") portsuffix = ":" + portsuffix elif ']' in hostname: # IPv6 rbrak = hostname.rindex("]") if ":" in hostname[rbrak:]: portsep = hostname.rindex(":") portsuffix = hostname[portsep:] hostname = hostname[:portsep] hostname = hostname[1:-1] # Strip brackets return (hostname, portsuffix) def parseConf(text): inQuote = False quoteStarter = "" lines = [] tokens = [] current = [] def pushToken(): token = "".join(current) if not inQuote: # Attempt type conversion try: token = int(token) except ValueError: try: token = float(token) except ValueError: pass wrapper = (inQuote, token) tokens.append(wrapper) current[:] = [] def pushLine(): if current: pushToken() if tokens: lines.append(tokens[:]) tokens[:] = [] i = 0 tlen = len(text) while i < tlen: if inQuote: if text[i] == quoteStarter: # Ending a text string pushToken() quoteStarter = "" inQuote = False elif text[i] == "\\": # Starting an escape sequence i += 1 if text[i] in "'\"n\\": current.append(eval("\'\\" + text[i] + "\'")) else: current.append(text[i]) else: if text[i] == "#": # Comment while (i < tlen) and (text[i] != "\n"): i += 1 # Advance to end of line... i -= 1 # ...and back up so we don't skip the newline elif text[i] in "'\"": # Starting a text string inQuote = True quoteStarter = text[i] if current: pushToken() elif text[i] == "\\": # Linebreak escape i += 1 if text[i] != "\n": raise SyntaxError elif text[i] == "\n": # EOL: break the lines pushLine() elif text[i] in string.whitespace: if current: pushToken() else: current.append(text[i]) i += 1 pushLine() return lines def stringfilt(data): "Pretty print string of space separated numbers" parts = data.split() cooked = [] for part in parts: # These are expected to fit on a single 80-char line. # Accounting for other factors this leaves each number with # 7 chars + a space. fitted = fitinfield(part, 7) cooked.append(fitted) rendered = " ".join(cooked) return rendered def stringfiltcooker(data): "Cooks a filt* string of space separated numbers, expects milliseconds" parts = data.split() oomcount = {} minscale = -100000 # Keep track of the maxdownscale for each value # Find out what the 'natural' unit of each value is for part in parts: # Only care about OOMs, the real scaling happens later value, oom = scalestring(part) # Track the highest maxdownscale so we do not invent precision ds = maxdownscale(part) minscale = max(ds, minscale) oomcount[oom] = oomcount.get(oom, 0) + 1 # Find the most common unit mostcommon = 0 highestcount = 0 for key in oomcount.keys(): if key < minscale: continue # skip any scale that would result in making up data count = oomcount[key] if count > highestcount: mostcommon = key highestcount = count # Shift all values to the new unit cooked = [] for part in parts: part = rescalestring(part, mostcommon) fitted = fitinfield(part, 7) cooked.append(fitted) rendered = " ".join(cooked) + " " + UNITS_SEC[mostcommon + UNITS_SEC.index(UNIT_MS)] return rendered def getunitgroup(unit): "Returns the unit group which contains a given unit" for group in unitgroups: if unit in group: return group def oomsbetweenunits(a, b): "Calculates how many orders of magnitude separate two units" group = getunitgroup(a) if b is None: # Caller is asking for the distance from the base unit return group.index(a) * 3 elif b in group: ia = group.index(a) ib = group.index(b) return abs((ia - ib) * 3) return None def breaknumberstring(value): "Breaks a number string into (aboveDecimal, belowDecimal, isNegative?)" if value[0] == "-": value = value[1:] negative = True else: negative = False if "." in value: above, below = value.split(".") else: above = value below = "" return (above, below, negative) def gluenumberstring(above, below, isnegative): "Glues together parts of a number string" if above == "": above = "0" if below: newvalue = ".".join((above, below)) else: newvalue = above if isnegative: newvalue = "-" + newvalue return newvalue def maxdownscale(value): "Maximum units a value can be scaled down without inventing data" if "." in value: digitcount = len(value.split(".")[1]) # Return a negative so it can be fed directly to a scaling function return -(digitcount // 3) else: # No decimals, the value is already at the maximum down-scale return 0 def rescalestring(value, unitsscaled): "Rescale a number string by a given number of units" whole, dec, negative = breaknumberstring(value) if unitsscaled == 0: # This may seem redundant, but glue forces certain formatting details value = gluenumberstring(whole, dec, negative) return value hilen = len(whole) lolen = len(dec) digitsmoved = abs(unitsscaled * 3) if unitsscaled > 0: # Scale to a larger unit, move decimal left if hilen < digitsmoved: # Scaling beyond the digits, pad it out. We can pad here # without making up digits that don't exist padcount = digitsmoved - hilen newwhole = "" newdec = ("0" * padcount) + whole + dec else: # Scaling in the digits, no need to pad choppoint = -digitsmoved newdec = whole[choppoint:] + dec newwhole = whole[:choppoint] elif unitsscaled < 0: # scale to a smaller unit, move decimal right if lolen < digitsmoved: # Scaling beyond the digits would force us to make up data # that doesn't exist. So fail. # The caller should have already caught this with maxdownscale() return None else: newwhole = whole + dec[:digitsmoved] newdec = dec[digitsmoved:] newwhole = newwhole.lstrip("0") newvalue = gluenumberstring(newwhole, newdec, negative) return newvalue def formatzero(value): "Scale a zero value for the unit with the highest available precision" scale = maxdownscale(value) newvalue = rescalestring(value, scale).lstrip("-") return (newvalue, scale) def scalestring(value): "Scales a number string to fit in the range 1.0-999.9" if isstringzero(value): return formatzero(value) whole, dec, negative = breaknumberstring(value) hilen = len(whole) if (hilen == 0) or isstringzero(whole): # Need to shift to smaller units i = 0 lolen = len(dec) while i < lolen: # need to find the actual digits if dec[i] != "0": break i += 1 lounits = (i // 3) + 1 # always need to shift one more unit movechars = lounits * 3 if lolen < movechars: # Not enough digits to scale all the way down. Inventing # digits is unacceptable, so scale down as much as we can. lounits = (i // 3) # "always", unless out of digits movechars = lounits * 3 newwhole = dec[:movechars].lstrip("0") newdec = dec[movechars:] unitsmoved = -lounits else: # Shift to larger units hiunits = hilen // 3 # How many we have, not how many to move hidigits = hilen % 3 if hidigits == 0: # full unit above the decimal hiunits -= 1 # the unit above the decimal doesn't count hidigits = 3 newwhole = whole[:hidigits] newdec = whole[hidigits:] + dec unitsmoved = hiunits newvalue = gluenumberstring(newwhole, newdec, negative) return (newvalue, unitsmoved) def fitinfield(value, fieldsize): "Attempt to fit value into a field, preserving as much data as possible" vallen = len(value) if fieldsize is None: newvalue = value elif vallen == fieldsize: # Goldilocks! newvalue = value elif vallen < fieldsize: # Extra room, pad it out pad = " " * (fieldsize - vallen) newvalue = pad + value else: # Insufficient room, round as few digits as possible if "." in value: # Ok, we *do* have decimals to crop diff = vallen - fieldsize declen = len(value.split(".")[1]) # length of decimals croplen = min(declen, diff) # Never round above the decimal point roundlen = declen - croplen # How many digits we round to newvalue = str(round(float(value), roundlen)) splitted = newvalue.split(".") # This should never fail declen = len(splitted[1]) if roundlen == 0: # if rounding all the decimals don't display .0 # but do display the point, to show that there is more beyond newvalue = splitted[0] + "." elif roundlen > declen: # some zeros have been cropped, fix that padcount = roundlen - declen newvalue = newvalue + ("0" * padcount) else: # No decimals, nothing we can crop newvalue = value return newvalue def cropprecision(value, ooms): "Crops digits below the maximum precision" if "." not in value: # No decimals, nothing to crop return value if ooms == 0: # We are at the baseunit, crop it all return value.split(".")[0] dstart = value.find(".") + 1 dsize = len(value) - dstart precision = min(ooms, dsize) cropcount = dsize - precision if cropcount > 0: value = value[:-cropcount] return value def isstringzero(value): "Detects whether a string is equal to zero" for i in value: if i not in ("-", ".", "0"): return False return True def unitrelativeto(unit, move): "Returns a unit at a different scale from the input unit" for group in unitgroups: if unit in group: if move is None: # asking for the base unit return group[0] else: index = group.index(unit) index += move # index of the new unit if 0 <= index < len(group): # found the new unit return group[index] else: # not in range return None return None # couldn't find anything def unitifyvar(value, varname, baseunit=None, width=8, unitSpace=False): "Call unitify() with the correct units for varname" if varname in S_VARS: start = UNIT_S elif varname in MS_VARS: start = UNIT_MS elif varname in PPM_VARS: start = UNIT_PPM else: return value return unitify(value, start, baseunit, width, unitSpace) def unitify(value, startingunit, baseunit=None, width=8, unitSpace=False): "Formats a numberstring with relevant units. Attempts to fit in width." if baseunit is None: baseunit = getunitgroup(startingunit)[0] ooms = oomsbetweenunits(startingunit, baseunit) if isstringzero(value): newvalue, unitsmoved = formatzero(value) else: newvalue = cropprecision(value, ooms) newvalue, unitsmoved = scalestring(newvalue) unitget = unitrelativeto(startingunit, unitsmoved) if unitSpace: spaceWidthAdjustment = 1 spacer = " " else: spaceWidthAdjustment = 0 spacer = "" if unitget is not None: # We have a unit if width is None: realwidth = None else: realwidth = width - (len(unitget) + spaceWidthAdjustment) newvalue = fitinfield(newvalue, realwidth) + spacer + unitget else: # don't have a replacement unit, use original newvalue = value + spacer + startingunit if width is None: newvalue = newvalue.strip() return newvalue def f8dot4(f): "Scaled floating point formatting to fit in 8 characters" if isinstance(f, str): # a string? pass it on as a signal return "%8s" % f if not isinstance(f, (int, float)): # huh? return " X" if str(float(f)).lower() == 'nan': # yes, this is a better test than math.isnan() # it also catches None, strings, etc. return " nan" fmt = "%8d" # xxxxxxxx or -xxxxxxx if f >= 0: if f < 1000.0: fmt = "%8.4f" # xxx.xxxx normal case elif f < 10000.0: fmt = "%8.3f" # xxxx.xxx elif f < 100000.0: fmt = "%8.2f" # xxxxx.xx elif f < 1000000.0: fmt = "%8.1f" # xxxxxx.x else: # negative number, account for minus sign if f > -100.0: fmt = "%8.4f" # -xx.xxxx normal case elif f > -1000.0: fmt = "%8.3f" # -xxx.xxx elif f > -10000.0: fmt = "%8.2f" # -xxxx.xx elif f > -100000.0: fmt = "%8.1f" # -xxxxx.x return fmt % f def f8dot3(f): "Scaled floating point formatting to fit in 8 characters" if isinstance(f, str): # a string? pass it on as a signal return "%8s" % f if not isinstance(f, (int, float)): # huh? return " X" if str(float(f)).lower() == 'nan': # yes, this is a better test than math.isnan() # it also catches None, strings, etc. return " nan" fmt = "%8d" # xxxxxxxx or -xxxxxxx if f >= 0: if f < 10000.0: fmt = "%8.3f" # xxxx.xxx normal case elif f < 100000.0: fmt = "%8.2f" # xxxxx.xx elif f < 1000000.0: fmt = "%8.1f" # xxxxxx.x else: # negative number, account for minus sign if f > -1000.0: fmt = "%8.3f" # -xxx.xxx normal case elif f > -10000.0: fmt = "%8.2f" # -xxxx.xx elif f > -100000.0: fmt = "%8.1f" # -xxxxx.x return fmt % f def monoclock(): "Try to get a monotonic clock value unaffected by NTP stepping." try: # Available in Python 3.3 and up. return time.monotonic() except AttributeError: return time.time() class Cache: "Simple time-based cache" def __init__(self, defaultTimeout=300): # 5 min default TTL self.defaultTimeout = defaultTimeout self._cache = {} def get(self, key): if key in self._cache: value, settime, ttl = self._cache[key] if settime >= monoclock() - ttl: return value else: # key expired, delete it del self._cache[key] return None else: return None def set(self, key, value, customTTL=None): ttl = customTTL if customTTL is not None else self.defaultTimeout self._cache[key] = (value, monoclock(), ttl) # A hack to avoid repeatedly hammering on DNS when ntpmon runs. canonicalization_cache = Cache() def canonicalize_dns(inhost, family=socket.AF_UNSPEC): "Canonicalize a hostname or numeric IP address." resname = canonicalization_cache.get(inhost) if resname is not None: return resname # Catch garbaged hostnames in corrupted Mode 6 responses m = re.match("([:.[\]]|\w)*", inhost) if not m: raise TypeError (hostname, portsuffix) = portsplit(inhost) try: ai = socket.getaddrinfo(hostname, None, family, 0, 0, socket.AI_CANONNAME) except socket.gaierror: return "DNSFAIL:%s" % hostname (family, socktype, proto, canonname, sockaddr) = ai[0] try: name = socket.getnameinfo(sockaddr, socket.NI_NAMEREQD) result = name[0].lower() + portsuffix except socket.gaierror: # On OS X, canonname is empty for hosts without rDNS. # Fall back to the hostname. canonicalized = canonname or hostname result = canonicalized.lower() + portsuffix canonicalization_cache.set(inhost, result) return result TermSize = collections.namedtuple("TermSize", ["width", "height"]) # Python 2.x does not have the shutil.get_terminal_size function. # This conditional import is only needed by termsize() and should be kept # near it. It is not inside the function because the unit tests need to be # able to splice in a jig. if str is bytes: # We are on python 2.x import fcntl import termios import struct def termsize(): # pragma: no cover "Return the current terminal size." # Alternatives at http://stackoverflow.com/questions/566746 # The way this is used makes it not a big deal if the default is wrong. size = (80, 24) if os.isatty(1): if str is not bytes: # str is bytes means we are >py3.0, but this will still fail # on versions <3.3. We do not support those anyway. (w, h) = shutil.get_terminal_size((80, 24)) size = (w, h) else: try: # OK, Python version < 3.3, cope h, w, hp, wp = struct.unpack( 'HHHH', fcntl.ioctl(2, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))) size = (w, h) except IOError: pass return TermSize(*size) class PeerStatusWord: "A peer status word from readstats(), dissected for display" def __init__(self, status, pktversion=ntp.magic.NTP_VERSION): # Event self.event = ntp.control.CTL_PEER_EVENT(status) # Event count self.event_count = ntp.control.CTL_PEER_NEVNT(status) statval = ntp.control.CTL_PEER_STATVAL(status) # Config if statval & ntp.control.CTL_PST_CONFIG: self.conf = "yes" else: self.conf = "no" # Reach if statval & ntp.control.CTL_PST_BCAST: self.reach = "none" elif statval & ntp.control.CTL_PST_REACH: self.reach = "yes" else: self.reach = "no" # Auth if (statval & ntp.control.CTL_PST_AUTHENABLE) == 0: self.auth = "none" elif statval & ntp.control.CTL_PST_AUTHENTIC: self.auth = "ok " else: self.auth = "bad" # Condition if pktversion > ntp.magic.NTP_OLDVERSION: seldict = { ntp.control.CTL_PST_SEL_REJECT: "reject", ntp.control.CTL_PST_SEL_SANE: "falsetick", ntp.control.CTL_PST_SEL_CORRECT: "excess", ntp.control.CTL_PST_SEL_SELCAND: "outlier", ntp.control.CTL_PST_SEL_SYNCCAND: "candidate", ntp.control.CTL_PST_SEL_EXCESS: "backup", ntp.control.CTL_PST_SEL_SYSPEER: "sys.peer", ntp.control.CTL_PST_SEL_PPS: "pps.peer", } self.condition = seldict[statval & 0x7] else: if (statval & 0x3) == OLD_CTL_PST_SEL_REJECT: if (statval & OLD_CTL_PST_SANE) == 0: self.condition = "insane" elif (statval & OLD_CTL_PST_DISP) == 0: self.condition = "hi_disp" else: self.condition = "" elif (statval & 0x3) == OLD_CTL_PST_SEL_SELCAND: self.condition = "sel_cand" elif (statval & 0x3) == OLD_CTL_PST_SEL_SYNCCAND: self.condition = "sync_cand" elif (statval & 0x3) == OLD_CTL_PST_SEL_SYSPEER: self.condition = "sys_peer" # Last Event event_dict = { ntp.magic.PEVNT_MOBIL: "mobilize", ntp.magic.PEVNT_DEMOBIL: "demobilize", ntp.magic.PEVNT_UNREACH: "unreachable", ntp.magic.PEVNT_REACH: "reachable", ntp.magic.PEVNT_RESTART: "restart", ntp.magic.PEVNT_REPLY: "no_reply", ntp.magic.PEVNT_RATE: "rate_exceeded", ntp.magic.PEVNT_DENY: "access_denied", ntp.magic.PEVNT_ARMED: "leap_armed", ntp.magic.PEVNT_NEWPEER: "sys_peer", ntp.magic.PEVNT_CLOCK: "clock_alarm", } self.last_event = event_dict.get(ntp.magic.PEER_EVENT | self.event, "") def __str__(self): return ("conf=%(conf)s, reach=%(reach)s, auth=%(auth)s, " "cond=%(condition)s, event=%(last_event)s ec=%(event_count)s" % self.__dict__) def cook(variables, showunits=False, sep=", "): "Cooked-mode variable display." width = ntp.util.termsize().width - 2 text = "" specials = ("filtdelay", "filtoffset", "filtdisp", "filterror") longestspecial = len(max(specials, key=len)) for (name, (value, rawvalue)) in variables.items(): if name in specials: # need special formatting for column alignment formatter = "%" + str(longestspecial) + "s =" item = formatter % name else: item = "%s=" % name if name in ("reftime", "clock", "org", "rec", "xmt"): item += ntp.ntpc.prettydate(value) elif name in ("srcadr", "peeradr", "dstadr", "refid"): # C ntpq cooked these in obscure ways. Since they # came up from the daemon as human-readable # strings this was probably a bad idea, but we'll # leave this case separated in case somebody thinks # re-cooking them is a good idea. item += value elif name == "leap": item += ("00", "01", "10", "11")[value] elif name == "reach": item += "%03lo" % value elif name in specials: if showunits: item += stringfiltcooker(value) else: item += "\t".join(value.split()) elif name == "flash": item += "%02x " % value if value == 0: item += "ok " else: # flasher bits tstflagnames = ( "pkt_dup", # BOGON1 "pkt_bogus", # BOGON2 "pkt_unsync", # BOGON3 "pkt_denied", # BOGON4 "pkt_auth", # BOGON5 "pkt_stratum", # BOGON6 "pkt_header", # BOGON7 "pkt_autokey", # BOGON8 "pkt_crypto", # BOGON9 "peer_stratum", # BOGON10 "peer_dist", # BOGON11 "peer_loop", # BOGON12 "peer_unreach" # BOGON13 ) for (i, n) in enumerate(tstflagnames): if (1 << i) & value: item += tstflagnames[i] + " " item = item[:-1] elif name in MS_VARS: # Note that this is *not* complete, there are definitely # missing variables here. # Completion cannot occur until all units are tracked down. if showunits: item += unitify(rawvalue, UNIT_MS, UNIT_NS, width=None) else: item += repr(value) elif name in S_VARS: if showunits: item += unitify(rawvalue, UNIT_S, UNIT_NS, width=None) else: item += repr(value) elif name in PPM_VARS: if showunits: item += unitify(rawvalue, UNIT_PPM, width=None) else: item += repr(value) else: item += repr(value) # add field separator item += sep # add newline so we don't overflow screen lastcount = 0 for c in text: if c == '\n': lastcount = 0 else: lastcount += 1 if lastcount + len(item) > width: text = text[:-1] + "\n" text += item text = text[:-2] + "\n" return text class PeerSummary: "Reusable report generator for peer statistics" def __init__(self, displaymode, pktversion, showhostnames, wideremote, showunits=False, termwidth=None, debug=0, logfp=sys.stderr): self.displaymode = displaymode # peers/apeers/opeers self.pktversion = pktversion # interpretation of flash bits self.showhostnames = showhostnames # If false, display numeric IPs self.showunits = showunits # If False show old style float self.wideremote = wideremote # show wide remote names? self.debug = debug self.logfp = logfp self.termwidth = termwidth # By default, the peer spreadsheet layout is designed so lines just # fit in 80 characters. This tells us how much extra horizontal space # we have available on a wider terminal emulator. self.horizontal_slack = min((termwidth or 80) - 80, 24) # Peer spreadsheet column widths. The reason we cap extra # width used at 24 is that on very wide displays, slamming the # non-hostname fields all the way to the right produces a huge # river that makes the entries difficult to read as wholes. # This choice caps the peername field width at that of the longest # possible IPV6 numeric address. self.namewidth = 15 + self.horizontal_slack self.refidwidth = 15 # Compute peer spreadsheet headers self.__remote = " remote ".ljust(self.namewidth) self.__common = "st t when poll reach delay offset " self.__header = None self.polls = [] @staticmethod def prettyinterval(diff): "Print an interval in natural time units." if not isinstance(diff, int) or diff <= 0: return '-' if diff <= 2048: return str(diff) diff = (diff + 29) / 60 if diff <= 300: return "%dm" % diff diff = (diff + 29) / 60 if diff <= 96: return "%dh" % diff diff = (diff + 11) / 24 return "%dd" % diff @staticmethod def high_truncate(hostname, maxlen): "Truncate on the left using leading _ to indicate 'more'." # Used for local IPv6 addresses, best distinguished by low bits if len(hostname) <= maxlen: return hostname else: return '-' + hostname[-maxlen+1:] @staticmethod def is_clock(variables): "Does a set of variables look like it returned from a clock?" return "srchost" in variables and '(' in variables["srchost"][0] def header(self): "Column headers for peer display" if self.displaymode == "apeers": self.__header = self.__remote + \ " refid assid ".ljust(self.refidwidth) + \ self.__common + "jitter" elif self.displaymode == "opeers": self.__header = self.__remote + \ " local ".ljust(self.refidwidth) + \ self.__common + " disp" elif self.displaymode == 'rpeers': self.__header = ' st t when poll reach delay ' + \ 'offset jitter refid T remote' else: self.__header = self.__remote + \ " refid ".ljust(self.refidwidth) + \ self.__common + "jitter" return self.__header def width(self): "Width of display" return 79 + self.horizontal_slack def summary(self, rstatus, variables, associd): "Peer status summary line." clock_name = '' dstadr_refid = "" dstport = 0 estdelay = '.' estdisp = '.' estjitter = '.' estoffset = '.' filtdelay = 0.0 filtdisp = 0.0 filtoffset = 0.0 flash = 0 have_jitter = False headway = 0 hmode = 0 hpoll = 0 keyid = 0 last_sync = None leap = 0 pmode = 0 ppoll = 0 precision = 0 ptype = '?' reach = 0 rec = None reftime = None rootdelay = 0.0 saw6 = False # x.6 floats for delay and friends srcadr = None srchost = None srcport = 0 stratum = 20 mode = 0 unreach = 0 xmt = 0 ntscookies = -1 now = time.time() for item in variables.items(): if 2 != len(item) or 2 != len(item[1]): # bad item continue (name, (value, rawvalue)) = item if name == "delay": estdelay = rawvalue if self.showunits else value if len(rawvalue) > 6 and rawvalue[-7] == ".": saw6 = True elif name == "dstadr": # The C code tried to get a fallback ptype from this in case # the hmode field was not included if "local" in self.__header: dstadr_refid = rawvalue elif name == "dstport": # FIXME, dstport never used. dstport = value elif name == "filtdelay": # FIXME, filtdelay never used. filtdelay = value elif name == "filtdisp": # FIXME, filtdisp never used. filtdisp = value elif name == "filtoffset": # FIXME, filtoffset never used. filtoffset = value elif name == "flash": # FIXME, flash never used. flash = value elif name == "headway": # FIXME, headway never used. headway = value elif name == "hmode": hmode = value elif name == "hpoll": hpoll = value if hpoll < 0: hpoll = ntp.magic.NTP_MINPOLL elif name == "jitter": if "jitter" in self.__header: estjitter = rawvalue if self.showunits else value have_jitter = True elif name == "keyid": # FIXME, keyid never used. keyid = value elif name == "leap": # FIXME, leap never used. leap = value elif name == "offset": estoffset = rawvalue if self.showunits else value elif name == "pmode": # FIXME, pmode never used. pmode = value elif name == "ppoll": ppoll = value if ppoll < 0: ppoll = ntp.magic.NTP_MINPOLL elif name == "precision": # FIXME, precision never used. precision = value elif name == "reach": # Shipped as hex, displayed in octal reach = value elif name == "refid": # The C code for this looked crazily overelaborate. Best # guess is that it was designed to deal with formats that # no longer occur in this field. if "refid" in self.__header: dstadr_refid = rawvalue elif name == "rec": rec = value # l_fp timestamp last_sync = int(now - ntp.ntpc.lfptofloat(rec)) elif name == "reftime": reftime = value # l_fp timestamp last_sync = int(now - ntp.ntpc.lfptofloat(reftime)) elif name == "rootdelay": # FIXME, rootdelay never used. rootdelay = value # l_fp timestamp elif name == "rootdisp" or name == "dispersion": estdisp = rawvalue if self.showunits else value elif name in ("srcadr", "peeradr"): srcadr = value elif name == "srchost": srchost = value elif name == "srcport" or name == "peerport": # FIXME, srcport never used. srcport = value elif name == "stratum": stratum = value elif name == "mode": # FIXME, mode never used. mode = value elif name == "unreach": # FIXME, unreach never used. unreach = value elif name == "xmt": # FIXME, xmt never used. xmt = value elif name == "ntscookies": ntscookies = value else: # unknown name? # line = " name=%s " % (name) # debug # return line # debug continue if hmode == ntp.magic.MODE_BCLIENTX: # broadcastclient or multicastclient ptype = 'b' elif hmode == ntp.magic.MODE_BROADCASTx: # broadcast or multicast server if srcadr.startswith("224."): # IANA multicast address prefix ptype = 'M' else: ptype = 'B' elif hmode == ntp.magic.MODE_CLIENT: if PeerSummary.is_clock(variables): ptype = 'l' # local refclock elif dstadr_refid == "POOL": ptype = 'p' # pool elif srcadr.startswith("224."): ptype = 'a' # manycastclient (compatibility with Classic) elif ntscookies > -1: # FIXME: Will foo up if there are ever more than 9 cookies ptype = chr(ntscookies + ord('0')) else: ptype = 'u' # unicast elif hmode == ntp.magic.MODE_ACTIVEx: ptype = 's' # symmetric active elif hmode == ntp.magic.MODE_PASSIVEx: ptype = 'S' # symmetric passive # # Got everything, format the line # line = "" poll_sec = 1 << min(ppoll, hpoll) self.polls.append(poll_sec) if self.pktversion > ntp.magic.NTP_OLDVERSION: c = " x.-+#*o"[ntp.control.CTL_PEER_STATVAL(rstatus) & 0x7] else: c = " .+*"[ntp.control.CTL_PEER_STATVAL(rstatus) & 0x3] # Source host or clockname or poolname or servername # After new DNS, 2017-Apr-17 # servers setup via numerical IP Address have only srcadr # servers setup via DNS have both srcadr and srchost # refclocks have both srcadr and srchost # pool has "0.0.0.0" (or "::") and srchost # slots setup via pool have only srcadr if srcadr is not None \ and srcadr != "0.0.0.0" \ and not srcadr.startswith("127.127") \ and srcadr != "::": if self.showhostnames & 2 and 'srchost' in locals() and srchost: clock_name = srchost elif self.showhostnames & 1: try: if self.debug: self.logfp.write("DNS lookup begins...\n") clock_name = canonicalize_dns(srcadr) if self.debug: self.logfp.write("DNS lookup ends.\n") except TypeError: # pragma: no cover return '' else: clock_name = srcadr else: clock_name = srchost if clock_name is None: if srcadr: clock_name = srcadr else: clock_name = "" if self.displaymode != "rpeers": if self.wideremote and len(clock_name) > self.namewidth: line += ("%c%s\n" % (c, clock_name)) line += (" " * (self.namewidth + 2)) else: line += ("%c%-*.*s " % (c, self.namewidth, self.namewidth, clock_name[:self.namewidth])) # Destination address, assoc ID or refid. assocwidth = 7 if self.displaymode == "apeers" else 0 if "." not in dstadr_refid and ":" not in dstadr_refid: dstadr_refid = "." + dstadr_refid + "." if assocwidth and len(dstadr_refid) >= self.refidwidth - assocwidth: visible = "..." else: visible = dstadr_refid if self.displaymode != "rpeers": line += self.high_truncate(visible, self.refidwidth) if self.displaymode == "apeers": line += (" " * (self.refidwidth - len(visible) - assocwidth + 1)) line += ("%-6d" % (associd)) else: line += (" " * (self.refidwidth - len(visible))) # The rest of the story if last_sync is None: last_sync = now jd = estjitter if have_jitter else estdisp if self.showunits: fini = lambda x : unitify(x, UNIT_MS) elif saw6: fini = lambda x : f8dot4(x) else: fini = lambda x : f8dot3(x) line += ( " %2ld %c %4.4s %4.4s %3lo %s %s %s" % (stratum, ptype, PeerSummary.prettyinterval(last_sync), PeerSummary.prettyinterval(poll_sec), reach, fini(estdelay), fini(estoffset), fini(jd))) line += "\n" # for debugging both case # if srcadr != None and srchost != None: # line += "srcadr: %s, srchost: %s\n" % (srcadr, srchost) return line def intervals(self): "Return and flush the list of actual poll intervals." res = self.polls[:] self.polls = [] return res class MRUSummary: "Reusable class for MRU entry summary generation." def __init__(self, showhostnames, wideremote=False, debug=0, logfp=sys.stderr): self.debug = debug self.logfp = logfp self.now = None self.showhostnames = showhostnames # if & 1, display names self.wideremote = wideremote header = " lstint avgint rstr r m v count score drop rport remote address" def summary(self, entry): first = ntp.ntpc.lfptofloat(entry.first) last = ntp.ntpc.lfptofloat(entry.last) active = float(last - first) count = int(entry.ct) if self.now: lstint = int(self.now - last + 0.5) stats = "%7d" % lstint if count == 1: favgint = 0 else: favgint = active / (count-1) avgint = int(favgint + 0.5) if 5.0 < favgint or 1 == count: stats += " %6d" % avgint elif 1.0 <= favgint: stats += " %6.2f" % favgint else: stats += " %6.3f" % favgint else: MJD_1970 = 40587 # MJD for 1 Jan 1970, Unix epoch days, lstint = divmod(int(last), 86400) stats = "%5d %5d %6d" % (days + MJD_1970, lstint, active) if entry.rs & ntp.magic.RES_KOD: rscode = 'K' elif entry.rs & ntp.magic.RES_LIMITED: rscode = 'L' else: rscode = '.' (ip, port) = portsplit(entry.addr) try: if not self.showhostnames & 1: # if not & 1 display numeric IPs dns = ip else: dns = canonicalize_dns(ip) # Forward-confirm the returned DNS confirmed = canonicalization_cache.get(dns) if confirmed is None: confirmed = False try: ai = socket.getaddrinfo(dns, None) for (_, _, _, _, sockaddr) in ai: if sockaddr and sockaddr[0] == ip: confirmed = True break except socket.gaierror: pass canonicalization_cache.set(dns, confirmed) if not confirmed: dns = "%s (%s)" % (ip, dns) if not self.wideremote: # truncate for narrow display dns = dns[:40] if entry.sc: score = float(entry.sc) if score > 100000.0: score = "%8.1f" % score elif score > 10000.0: score = "%8.2f" % score else: score = "%8.3f" % score else: score = "-" if entry.dr!= None: # 0 is valid drop = "%4d" % entry.dr else: drop = "-" stats += " %4hx %c %d %d %6d %8s %6s %5s %s" % \ (entry.rs, rscode, ntp.magic.PKT_MODE(entry.mv), ntp.magic.PKT_VERSION(entry.mv), entry.ct, score, drop, port[1:], dns) return stats except ValueError: # This can happen when ntpd ships a corrupt varlist return '' class ReslistSummary: "Reusable class for reslist entry summary generation." header = """\ hits addr/prefix or addr mask restrictions """ width = 72 @staticmethod def __getPrefix(mask): if not mask: prefix = '' if ':' in mask: sep = ':' base = 16 else: sep = '.' base = 10 prefix = sum([bin(int(x, base)).count('1') for x in mask.split(sep) if x]) return '/' + str(prefix) def summary(self, variables): hits = variables.get("hits", "?") address = variables.get("addr", "?") mask = variables.get("mask", "?") if address == '?' or mask == '?': return '' address += ReslistSummary.__getPrefix(mask) flags = variables.get("flags", "?") # reslist responses are often corrupted s = "%10s %s\n %s\n" % (hits, address, flags) # Throw away corrupted entries. This is a shim - we really # want to make ntpd stop generating garbage for c in s: if not c.isalnum() and c not in "/.: \n": return '' return s class IfstatsSummary: "Reusable class for ifstats entry summary generation." header = """\ interface name send # address/broadcast drop flag received sent failed peers uptime """ width = 74 # Numbers are the fieldsize fields = {'name': '%-24.24s', 'flags': '%4x', 'rx': '%6d', 'tx': '%6d', 'txerr': '%6d', 'pc': '%5d', 'up': '%8d'} def summary(self, i, variables): formatted = {} try: # Format the fields for name in self.fields.keys(): value = variables.get(name, "?") if value == "?": fmt = value else: fmt = self.fields[name] % value formatted[name] = fmt enFlag = '.' if variables.get('en', False) else 'D' address = variables.get("addr", "?") bcast = variables.get("bcast") # Assemble the fields into a line s = ("%3u %s %s %s %s %s %s %s %s\n %s\n" % (i, formatted['name'], enFlag, formatted['flags'], formatted['rx'], formatted['tx'], formatted['txerr'], formatted['pc'], formatted['up'], address)) if bcast: s += " %s\n" % bcast except TypeError: # pragma: no cover # Can happen when ntpd ships a corrupted response return '' # FIXME, a brutal and slow way to check for invalid chars.. # maybe just strip non-printing chars? for c in s: if not c.isalnum() and c not in "/.:[] \%\n": return '' return s try: from collections import OrderedDict except ImportError: # pragma: no cover class OrderedDict(dict): "A stupid simple implementation in order to be back-portable to 2.6" # This can be simple because it doesn't need to be fast. # The programs that use it only have to run at human speed, # and the collections are small. def __init__(self, items=None): dict.__init__(self) self.__keys = [] if items: for (k, v) in items: self[k] = v def __setitem__(self, key, val): dict.__setitem__(self, key, val) self.__keys.append(key) def __delitem__(self, key): dict.__delitem__(self, key) self.__keys.remove(key) def keys(self): return self.__keys def items(self): return tuple([(k, self[k]) for k in self.__keys]) def __iter__(self): for key in self.__keys: yield key def packetize(packets, period, clipdigits=0, periodized=False): """Given a number of packets and a duration (s) return a tuple. return the packet quantity, and a two part rate in packets/seconds or seconds/packet. On error the latter fields should be blank, the first the number of packets if zero otherwise unhelpful text.""" if not isinstance(packets, int): return ("???", "", "") if packets == 0 or not isinstance(period, (int, float)): return (packets, "", "") if packets > period: return (packets, round(packets / period, clipdigits), "p/s") if periodized: return (packets, periodize(period / packets, clipdigits)[1], "/p") return (packets, round(period / packets, clipdigits), "s/p") def periodize(period, clipdigits=0): """Given a number of seconds, return number and pretty string. On error return None for the number and an unhelpful string.""" clip = clipdigits if isinstance(clipdigits, (int, float)) else 0 if not isinstance(period, (int, float)): return (None, "???") result = "" _ = round(period, clip) nperiod = int(_) if clip < 1 else _ if nperiod >= 86400: result += "%dD " % (nperiod // 86400) result += "%02d:%02d:%02d" % ( (nperiod % 86400) // 3600, (nperiod % 3600) // 60, nperiod % 60, ) return (nperiod, result) uptime = lambda p: periodize(p)[1] # end