feat: startign with netbox 07:10

This commit is contained in:
2025-10-30 07:10:27 +02:00
parent beb31c9429
commit 62ac98ba21

View File

@@ -1,32 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
NATS Registration Listener (with NetBox hostname lookup for fox100, no extra deps) NATS Registration Listener (with NetBox hostname lookup for fox100 + timing + problem counter)
---------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------
Adds:
Behavior: - nb_problems=<count> prefix on every stdout line
- Prints exactly one line per message: - Measures per-fox100 NetBox lookup duration (netbox_ms)
[TS] product=<productName> mac=<mac> fw=<active> [host=<device.name> for fox100] - Measures total latency from message arrival till final output (total_ms)
- Listener-only (no publish/reply). - Increments problem counter for each NetBox anomaly or lookup issue
- Payload-byte strict dedupe within a TTL window. - Adds iface_id=... to stderr diagnostics
- Graceful shutdown + reconnect logging.
- For product=fox100: best-effort NetBox lookup by MAC -> interface -> device.name.
(Inline, sequential "as-is" call; no concurrency / worker pool.)
NetBox (hardcoded as requested):
- Edit NB_TOKEN below.
- Uses stdlib urllib (no 'requests' / 'aiohttp').
CLI (same spirit as before; your entrypoint passes these):
--servers nats://host:4222 [one or more]
--subject v1.registration
--queue <group> (optional)
--name registration-listener
--tls-ca /path/to/ca.pem
--tls-cert/--tls-key/--insecure
--include-subject
Install deps in image:
pip install --no-cache-dir --upgrade nats-py
""" """
import argparse import argparse
@@ -37,6 +18,7 @@ import signal
import ssl import ssl
import sys import sys
import hashlib import hashlib
import time
from time import monotonic from time import monotonic
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -51,43 +33,32 @@ import nats
# NetBox hardcoded config # NetBox hardcoded config
# ========================= # =========================
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623" # <-- paste the real token here NB_TOKEN = "7648e4f5ee370cda7834682e61b47c2ee8e95623" # keep as provided
NB_TIMEOUT = 3.0 # seconds per HTTP GET NB_TIMEOUT = 3.0 # seconds per HTTP GET
# Global counter for any NetBox-related errors/anomalies
NB_PROBLEM_COUNTER = 0
NB_PROBLEM_LOCK = asyncio.Lock()
# ========================= # =========================
# Arg parsing (mirrors original) # Arg parsing
# ========================= # =========================
def parse_args(): def parse_args():
p = argparse.ArgumentParser(description="Listen to a NATS subject and print one line per device.") p = argparse.ArgumentParser(description="Listen to a NATS subject and print one line per device.")
p.add_argument( p.add_argument("--servers", nargs="+", default=["nats://127.0.0.1:4222"])
"--servers", p.add_argument("--subject", default="registration")
nargs="+", p.add_argument("--queue", default=None)
help="One or more NATS server URLs (e.g., nats://127.0.0.1:4222).", p.add_argument("--name", default="registration-listener")
default=["nats://127.0.0.1:4222"], p.add_argument("--creds")
) p.add_argument("--user")
p.add_argument( p.add_argument("--password")
"--subject", p.add_argument("--token")
help="NATS subject to subscribe to.", p.add_argument("--tls-ca")
default="registration", p.add_argument("--tls-cert")
) p.add_argument("--tls-key")
p.add_argument("--queue", help="Optional queue group name.", default=None) p.add_argument("--insecure", action="store_true")
p.add_argument("--name", help="Client connection name.", default="registration-listener") p.add_argument("--include-subject", action="store_true")
# Auth
p.add_argument("--creds", help="Path to .creds file (JWT + NKey).")
p.add_argument("--user", help="Username.")
p.add_argument("--password", help="Password.")
p.add_argument("--token", help="Auth token.")
# TLS
p.add_argument("--tls-ca", help="Path to CA certificate for TLS.")
p.add_argument("--tls-cert", help="Path to client certificate for TLS.")
p.add_argument("--tls-key", help="Path to client key for TLS.")
p.add_argument("--insecure", action="store_true", help="Disable TLS hostname verification.")
# Misc
p.add_argument("--include-subject", action="store_true", help="Append subject=... to each output line.")
return p.parse_args() return p.parse_args()
@@ -97,23 +68,18 @@ def parse_args():
def make_ssl_context(args) -> Optional[ssl.SSLContext]: def make_ssl_context(args) -> Optional[ssl.SSLContext]:
if not any([args.tls_ca, args.tls_cert, args.tls_key]) and not any(url.startswith("tls://") for url in args.servers): if not any([args.tls_ca, args.tls_cert, args.tls_key]) and not any(url.startswith("tls://") for url in args.servers):
return None return None
ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH) ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
if args.tls_ca: if args.tls_ca:
ctx.load_verify_locations(args.tls_ca) ctx.load_verify_locations(args.tls_ca)
if args.tls_cert and args.tls_key: if args.tls_cert and args.tls_key:
ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key) ctx.load_cert_chain(certfile=args.tls_cert, keyfile=args.tls_key)
if args.insecure: if args.insecure:
ctx.check_hostname = False ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE ctx.verify_mode = ssl.CERT_NONE
return ctx return ctx
def ts() -> str: def ts() -> str:
# local time with offset like your previous output
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S%z") return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S%z")
@@ -127,50 +93,23 @@ def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def extract_fields(obj: Dict[str, Any]): def extract_fields(obj: Dict[str, Any]):
"""
Expected structure (from examples):
{ "data": {
"productName": "...",
"ethernetInterfaces": { "eth0": { "macAddress": "..." } },
"firmwareVersion": { "active": "..." }
},
"metadata": { "type": "REGISTRATION_REQUEST" }
}
"""
root = obj root = obj
if isinstance(root.get("data"), dict): d = root["data"] if isinstance(root.get("data"), dict) else root
d = root["data"]
else:
d = root
product = d.get("productName") or "-" product = d.get("productName") or "-"
fw_active = "-"
try:
fw_active = d.get("firmwareVersion", {}).get("active") or "-" fw_active = d.get("firmwareVersion", {}).get("active") or "-"
except Exception:
pass
mac = "-" mac = "-"
try:
eths = d.get("ethernetInterfaces", {}) eths = d.get("ethernetInterfaces", {})
if isinstance(eths, dict): if isinstance(eths, dict):
chosen = pick_first_interface(eths) chosen = pick_first_interface(eths)
if chosen and isinstance(chosen.get("macAddress"), str): if chosen and isinstance(chosen.get("macAddress"), str):
mac = chosen["macAddress"] mac = chosen["macAddress"]
except Exception:
pass
return product, mac, fw_active return product, mac, fw_active
# ========================= # =========================
# NetBox lookup (stdlib urllib) # NetBox lookup (urllib)
# ========================= # =========================
def normalize_mac(mac: str) -> Optional[str]: def normalize_mac(mac: str) -> Optional[str]:
"""
Return lowercase colon-separated MAC or None if invalid-ish.
Accepts colon, dash, or bare hex and tries to normalize.
"""
if not mac or not isinstance(mac, str): if not mac or not isinstance(mac, str):
return None return None
s = mac.strip().lower().replace("-", ":") s = mac.strip().lower().replace("-", ":")
@@ -192,10 +131,7 @@ def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Op
if resp.status != 200: if resp.status != 200:
return None, resp.status return None, resp.status
data = resp.read() data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), 200 return json.loads(data.decode("utf-8", errors="replace")), 200
except Exception:
return None, 200
except HTTPError as e: except HTTPError as e:
return None, getattr(e, "code", 599) return None, getattr(e, "code", 599)
except URLError: except URLError:
@@ -204,18 +140,21 @@ def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Op
return None, 597 return None, 597
def nb_get_hostname_by_mac(mac: str, log_status) -> Optional[str]: async def nb_problem(log_status, msg: str):
"""Increment counter and log a problem line."""
global NB_PROBLEM_COUNTER
async with NB_PROBLEM_LOCK:
NB_PROBLEM_COUNTER += 1
await log_status(msg)
def nb_get_hostname_by_mac(mac: str, log_status) -> (Optional[str], Optional[int]):
""" """
Steps: Return (hostname, iface_id or None)
1) GET /api/dcim/mac-addresses/?mac_address=<mac>
2) If assigned to dcim.interface -> GET /api/dcim/interfaces/{id}/
3) Return device.name
Emits concise diagnostics to stderr via log_status on edge cases.
""" """
mac_norm = normalize_mac(mac) mac_norm = normalize_mac(mac)
if not mac_norm: if not mac_norm:
# invalid or missing mac; keep stdout clean return None, None
return None
base = NB_URL.rstrip("/") base = NB_URL.rstrip("/")
h = { h = {
@@ -224,42 +163,43 @@ def nb_get_hostname_by_mac(mac: str, log_status) -> Optional[str]:
"Authorization": f"Token {NB_TOKEN}", "Authorization": f"Token {NB_TOKEN}",
} }
# 1) find MAC record(s) # Step 1: MAC lookup
data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h) data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h)
if code != 200 or not data: if code != 200 or not data:
# network/HTTP issue or parse fail asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac query http={code} mac={mac_norm}"))
asyncio.create_task(log_status(f"[{ts()}] nb: mac query http={code} mac={mac_norm}")) return None, None
return None
results = (data or {}).get("results") or [] results = (data or {}).get("results") or []
if not results: if not results:
asyncio.create_task(log_status(f"[{ts()}] nb: mac not found mac={mac_norm}")) asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac not found mac={mac_norm}"))
return None return None, None
if len(results) > 1: if len(results) > 1:
asyncio.create_task(log_status(f"[{ts()}] nb: multiple mac records mac={mac_norm}")) # no iface_id yet; fetch it below
asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: multiple mac records mac={mac_norm}"))
rec = results[0] rec = results[0]
aot = (rec.get("assigned_object_type") or "").strip() aot = (rec.get("assigned_object_type") or "").strip()
aoid = rec.get("assigned_object_id") aoid = rec.get("assigned_object_id")
if not aot or aoid is None: if not aot or aoid is None:
asyncio.create_task(log_status(f"[{ts()}] nb: mac unassigned mac={mac_norm}")) asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac unassigned mac={mac_norm}"))
return None return None, None
if aot != "dcim.interface": if aot != "dcim.interface":
asyncio.create_task(log_status(f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm}")) asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm} iface_id={aoid}"))
return None return None, aoid
# 2) fetch interface -> device # Step 2: Interface → Device
iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h) iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h)
if code2 != 200 or not iface: if code2 != 200 or not iface:
asyncio.create_task(log_status(f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}")) asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}"))
return None return None, aoid
dev = iface.get("device") or {} dev = iface.get("device") or {}
host = dev.get("name") or dev.get("display") host = dev.get("name") or dev.get("display")
if not host: if not host:
asyncio.create_task(log_status(f"[{ts()}] nb: iface has no device iface_id={aoid}")) asyncio.create_task(nb_problem(log_status, f"[{ts()}] nb: iface has no device iface_id={aoid}"))
return None return None, aoid
return host
return host, aoid
# ========================= # =========================
@@ -275,13 +215,9 @@ async def main():
async with print_lock: async with print_lock:
print(s, file=sys.stderr, flush=True) print(s, file=sys.stderr, flush=True)
# --- Strict read-only dedupe (payload bytes) --- DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0")) # you can tune via env if you want; otherwise 2.0s
recent_payloads: Dict[bytes, float] = {} recent_payloads: Dict[bytes, float] = {}
reconnect_time_wait = 2
max_reconnect_attempts = -1
async def disconnected_cb(): async def disconnected_cb():
await log_status(f"[{ts()}] Disconnected from NATS.") await log_status(f"[{ts()}] Disconnected from NATS.")
@@ -298,8 +234,8 @@ async def main():
servers=args.servers, servers=args.servers,
name=args.name, name=args.name,
allow_reconnect=True, allow_reconnect=True,
reconnect_time_wait=reconnect_time_wait, reconnect_time_wait=2,
max_reconnect_attempts=max_reconnect_attempts, max_reconnect_attempts=-1,
disconnected_cb=disconnected_cb, disconnected_cb=disconnected_cb,
reconnected_cb=reconnected_cb, reconnected_cb=reconnected_cb,
error_cb=error_cb, error_cb=error_cb,
@@ -312,10 +248,9 @@ async def main():
) )
async def message_handler(msg: nats.aio.msg.Msg): async def message_handler(msg: nats.aio.msg.Msg):
now_s = ts() t_start = monotonic()
payload = msg.data payload = msg.data
# --- dedupe by exact payload bytes ---
digest = hashlib.blake2b(payload, digest_size=16).digest() digest = hashlib.blake2b(payload, digest_size=16).digest()
nowm = monotonic() nowm = monotonic()
exp = recent_payloads.get(digest) exp = recent_payloads.get(digest)
@@ -325,10 +260,9 @@ async def main():
if len(recent_payloads) > 4096: if len(recent_payloads) > 4096:
cutoff = nowm cutoff = nowm
for k in list(recent_payloads.keys()): for k in list(recent_payloads.keys()):
if recent_payloads.get(k, 0) <= cutoff: if recent_payloads[k] <= cutoff:
recent_payloads.pop(k, None) recent_payloads.pop(k, None)
# parse
product = mac = fw = "-" product = mac = fw = "-"
try: try:
text = payload.decode("utf-8", errors="replace") text = payload.decode("utf-8", errors="replace")
@@ -337,39 +271,43 @@ async def main():
except Exception: except Exception:
pass pass
# optional NetBox lookup for fox100 (inline, sequential)
host_suffix = "" host_suffix = ""
netbox_time_ms = 0.0
if product == "fox100": if product == "fox100":
nb_start = time.perf_counter()
try: try:
host = nb_get_hostname_by_mac(mac=mac, log_status=log_status) host, iface_id = nb_get_hostname_by_mac(mac=mac, log_status=log_status)
if host: if host:
host_suffix = f" host={host}" host_suffix = f" host={host}"
else: if iface_id and not host:
# minimal diag already sent to stderr by helper host_suffix += f" iface_id={iface_id}"
pass
except Exception as e: except Exception as e:
await log_status(f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}") await nb_problem(log_status, f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}")
netbox_time_ms = (time.perf_counter() - nb_start) * 1000
# print one compact line total_ms = (monotonic() - t_start) * 1000
line = f"[{now_s}] product={product} mac={mac} fw={fw}{host_suffix}"
async with NB_PROBLEM_LOCK:
nb_problems_snapshot = NB_PROBLEM_COUNTER
line = f"nb_problems={nb_problems_snapshot} [{ts()}] product={product} mac={mac} fw={fw}{host_suffix}"
if product == "fox100":
line += f" netbox_ms={netbox_time_ms:.1f} total_ms={total_ms:.1f}"
if args.include_subject: if args.include_subject:
line += f" subject={msg.subject}" line += f" subject={msg.subject}"
async with print_lock:
sys.stdout.write(line + "\n")
sys.stdout.flush()
# subscribe async with print_lock:
print(line, flush=True)
if args.queue: if args.queue:
await nc.subscribe(args.subject, queue=args.queue, cb=message_handler) await nc.subscribe(args.subject, queue=args.queue, cb=message_handler)
else: else:
await nc.subscribe(args.subject, cb=message_handler) await nc.subscribe(args.subject, cb=message_handler)
await log_status( await log_status(
f"[{ts()}] Listening on subject '{args.subject}' (queue={getattr(args, 'queue', None) or '-'}) via {args.servers} " f"[{ts()}] Listening on subject '{args.subject}' (queue={args.queue or '-'}) via {args.servers} | nb=on"
f"| mode=one-line dedupe=payload ttl={DEDUPE_TTL}s tx=disabled nb=on"
) )
# graceful shutdown
stop_event = asyncio.Event() stop_event = asyncio.Event()
def handle_signal(*_): def handle_signal(*_):
@@ -384,9 +322,7 @@ async def main():
signal.signal(s, lambda *_: handle_signal()) signal.signal(s, lambda *_: handle_signal())
await stop_event.wait() await stop_event.wait()
try:
await nc.drain() await nc.drain()
finally:
await nc.close() await nc.close()