feat: startign with netbox 07:02

This commit is contained in:
2025-10-30 07:02:31 +02:00
parent 8eed0e8cc5
commit acdebf568e

View File

@@ -1,22 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
NATS Registration Listener (with optional NetBox hostname lookup for fox100) NATS Registration Listener (with NetBox hostname lookup for fox100, no extra deps)
---------------------------------------------------------------------------- ----------------------------------------------------------------------------------
Adds: For product=fox100, lookup device hostname in NetBox by MAC and append
'host=<device.name>' to the one-line output if found.
Config (env or CLI):
NB_URL (e.g., http://netbox.gt-tiso.ikeja.co.za)
NB_TOKEN (API token)
NB_TIMEOUT (seconds, default 3.0)
Behavior: Behavior:
- Non-fox100: unchanged. - Prints exactly one line per message:
- fox100: best-effort NetBox lookup inline (sequential), stderr on edge cases. [TS] product=<productName> mac=<mac> fw=<active> [host=<device.name> for fox100]
- Listener-only (no publish/reply).
- Payload-byte strict dedupe within a TTL window.
- 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.)
Install deps: NetBox (hardcoded as requested):
pip install --upgrade nats-py aiohttp - 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
@@ -30,27 +40,38 @@ import hashlib
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
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import nats import nats
import aiohttp
from aiohttp import ClientTimeout
# ----------------- existing helpers (unchanged) -----------------
# =========================
# NetBox hardcoded config
# =========================
NB_URL = "http://netbox.gt-tiso.ikeja.co.za" # Base URL
NB_TOKEN = "CHANGE_ME_NETBOX_TOKEN" # <-- paste the real token here
NB_TIMEOUT = 3.0 # seconds per HTTP GET
# =========================
# Arg parsing (mirrors original)
# =========================
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", "--servers",
nargs="+", nargs="+",
help="One or more NATS server URLs (e.g., nats://127.0.0.1:4222).", help="One or more NATS server URLs (e.g., nats://127.0.0.1:4222).",
default=[os.environ.get("NATS_URL", "nats://127.0.0.1:4222")], default=["nats://127.0.0.1:4222"],
) )
p.add_argument( p.add_argument(
"--subject", "--subject",
help="NATS subject to subscribe to.", help="NATS subject to subscribe to.",
default=os.environ.get("NATS_SUBJECT", "registration"), default="registration",
) )
p.add_argument("--queue", help="Optional queue group name.", default=os.environ.get("NATS_QUEUE")) p.add_argument("--queue", help="Optional queue group name.", default=None)
p.add_argument("--name", help="Client connection name.", default="registration-listener") p.add_argument("--name", help="Client connection name.", default="registration-listener")
# Auth # Auth
@@ -67,29 +88,32 @@ def parse_args():
# Misc # Misc
p.add_argument("--include-subject", action="store_true", help="Append subject=... to each output line.") p.add_argument("--include-subject", action="store_true", help="Append subject=... to each output line.")
# --- NetBox cfg ---
p.add_argument("--nb-url", default=os.environ.get("NB_URL"), help="NetBox base URL (env NB_URL).")
p.add_argument("--nb-token", default=os.environ.get("NB_TOKEN"), help="NetBox API token (env NB_TOKEN).")
p.add_argument("--nb-timeout", type=float, default=float(os.environ.get("NB_TIMEOUT", "3.0")), help="NetBox HTTP timeout seconds (default 3.0).")
return p.parse_args() return p.parse_args()
# =========================
# Helpers
# =========================
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")
@@ -103,17 +127,29 @@ 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): if isinstance(root.get("data"), dict):
d = root["data"] d = root["data"]
else: else:
d = root d = root
product = d.get("productName") or "-" product = d.get("productName") or "-"
fw_active = "-" fw_active = "-"
try: try:
fw_active = d.get("firmwareVersion", {}).get("active") or "-" fw_active = d.get("firmwareVersion", {}).get("active") or "-"
except Exception: except Exception:
pass pass
mac = "-" mac = "-"
try: try:
eths = d.get("ethernetInterfaces", {}) eths = d.get("ethernetInterfaces", {})
@@ -123,10 +159,13 @@ def extract_fields(obj: Dict[str, Any]):
mac = chosen["macAddress"] mac = chosen["macAddress"]
except Exception: except Exception:
pass pass
return product, mac, fw_active return product, mac, fw_active
# ----------------- new: NetBox helpers -----------------
# =========================
# NetBox lookup (stdlib 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. Return lowercase colon-separated MAC or None if invalid-ish.
@@ -134,84 +173,98 @@ def normalize_mac(mac: str) -> Optional[str]:
""" """
if not mac or not isinstance(mac, str): if not mac or not isinstance(mac, str):
return None return None
s = mac.strip().lower() s = mac.strip().lower().replace("-", ":")
# replace dashes with colons hex_only = "".join(ch for ch in s if ch in "0123456789abcdef")
s = s.replace('-', ':')
hex_only = ''.join(ch for ch in s if ch in '0123456789abcdef')
if len(hex_only) == 12: if len(hex_only) == 12:
# reconstruct as colon-separated return ":".join(hex_only[i:i+2] for i in range(0, 12, 2))
s = ':'.join(hex_only[i:i+2] for i in range(0, 12, 2)) parts = s.split(":")
return s if len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts):
# already colonized?
parts = s.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789abcdef' for c in p) for p in parts):
return s return s
return None return None
async def nb_get_hostname_by_mac(session: aiohttp.ClientSession, base_url: str, token: str, mac: str, timeout_s: float, log_status): def http_get_json(url: str, params: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, timeout: float = NB_TIMEOUT):
if params:
url = f"{url}?{urlencode(params)}"
req = Request(url, headers=headers or {}, method="GET")
try:
with urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
return None, resp.status
data = resp.read()
try:
return json.loads(data.decode("utf-8", errors="replace")), 200
except Exception:
return None, 200
except HTTPError as e:
return None, getattr(e, "code", 599)
except URLError:
return None, 598
except Exception:
return None, 597
def nb_get_hostname_by_mac(mac: str, log_status) -> Optional[str]:
""" """
Best-effort: Steps:
1) GET /api/dcim/mac-addresses/?mac_address=<mac> 1) GET /api/dcim/mac-addresses/?mac_address=<mac>
2) If one result and assigned to interface -> GET /api/dcim/interfaces/{id}/ -> device.name 2) If assigned to dcim.interface -> GET /api/dcim/interfaces/{id}/
Returns (hostname or None). 3) Return device.name
Emits concise diagnostics to stderr via log_status on edge cases. 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:
await log_status(f"[{ts()}] nb: invalid mac format mac={mac!r}") # invalid or missing mac; keep stdout clean
return None return None
base = NB_URL.rstrip("/")
h = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Token {NB_TOKEN}",
}
# 1) find MAC record(s) # 1) find MAC record(s)
url_mac = f"{base_url.rstrip('/')}/api/dcim/mac-addresses/" data, code = http_get_json(f"{base}/api/dcim/mac-addresses/", params={"mac_address": mac_norm, "limit": "2"}, headers=h)
try: if code != 200 or not data:
async with session.get(url_mac, params={"mac_address": mac_norm, "limit": 2}, headers={"Authorization": f"Token {token}"}, timeout=timeout_s) as r: # network/HTTP issue or parse fail
if r.status != 200: asyncio.create_task(log_status(f"[{ts()}] nb: mac query http={code} mac={mac_norm}"))
await log_status(f"[{ts()}] nb: mac query http={r.status} mac={mac_norm}")
return None
data = await r.json()
except Exception as e:
await log_status(f"[{ts()}] nb: mac query error mac={mac_norm} err={e!r}")
return None return None
results = (data or {}).get("results") or [] results = (data or {}).get("results") or []
if not results: if not results:
await log_status(f"[{ts()}] nb: mac not found mac={mac_norm}") asyncio.create_task(log_status(f"[{ts()}] nb: mac not found mac={mac_norm}"))
return None return None
if len(results) > 1: if len(results) > 1:
await log_status(f"[{ts()}] nb: multiple mac records mac={mac_norm}") asyncio.create_task(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:
await log_status(f"[{ts()}] nb: mac unassigned mac={mac_norm}") asyncio.create_task(log_status(f"[{ts()}] nb: mac unassigned mac={mac_norm}"))
return None return None
if aot != "dcim.interface": if aot != "dcim.interface":
await log_status(f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm}") asyncio.create_task(log_status(f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm}"))
return None return None
# 2) fetch interface -> device # 2) fetch interface -> device
url_iface = f"{base_url.rstrip('/')}/api/dcim/interfaces/{aoid}/" iface, code2 = http_get_json(f"{base}/api/dcim/interfaces/{aoid}/", headers=h)
try: if code2 != 200 or not iface:
async with session.get(url_iface, headers={"Authorization": f"Token {token}"}, timeout=timeout_s) as r2: asyncio.create_task(log_status(f"[{ts()}] nb: iface fetch http={code2} iface_id={aoid}"))
if r2.status != 200:
await log_status(f"[{ts()}] nb: iface fetch http={r2.status} iface_id={aoid}")
return None
iface = await r2.json()
except Exception as e:
await log_status(f"[{ts()}] nb: iface fetch error iface_id={aoid} err={e!r}")
return None return None
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:
await log_status(f"[{ts()}] nb: iface has no device iface_id={aoid}") asyncio.create_task(log_status(f"[{ts()}] nb: iface has no device iface_id={aoid}"))
return None return None
return host return host
# ----------------- main -----------------
# =========================
# Main
# =========================
async def main(): async def main():
args = parse_args() args = parse_args()
ssl_ctx = make_ssl_context(args) ssl_ctx = make_ssl_context(args)
@@ -222,9 +275,10 @@ 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)
# dedupe # --- 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 reconnect_time_wait = 2
max_reconnect_attempts = -1 max_reconnect_attempts = -1
@@ -257,20 +311,11 @@ async def main():
tls=ssl_ctx, tls=ssl_ctx,
) )
# --- aiohttp session for NetBox ---
nb_enabled = bool(args.nb_url and args.nb_token)
if nb_enabled:
timeout = ClientTimeout(total=None) # per-call timeout set explicitly
http = aiohttp.TCPConnector(limit=32)
session = aiohttp.ClientSession(timeout=timeout, connector=http)
else:
session = None
async def message_handler(msg: nats.aio.msg.Msg): async def message_handler(msg: nats.aio.msg.Msg):
now_s = ts() now_s = ts()
payload = msg.data payload = msg.data
# dedupe by payload # --- 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)
@@ -283,7 +328,7 @@ async def main():
if recent_payloads.get(k, 0) <= cutoff: if recent_payloads.get(k, 0) <= cutoff:
recent_payloads.pop(k, None) recent_payloads.pop(k, None)
# parse payload # parse
product = mac = fw = "-" product = mac = fw = "-"
try: try:
text = payload.decode("utf-8", errors="replace") text = payload.decode("utf-8", errors="replace")
@@ -292,24 +337,20 @@ async def main():
except Exception: except Exception:
pass pass
# optional NetBox lookup for fox100 # optional NetBox lookup for fox100 (inline, sequential)
host_suffix = "" host_suffix = ""
if nb_enabled and product == "fox100": if product == "fox100":
try: try:
host = await nb_get_hostname_by_mac( host = nb_get_hostname_by_mac(mac=mac, log_status=log_status)
session=session,
base_url=args.nb_url,
token=args.nb_token,
mac=mac,
timeout_s=args.nb_timeout,
log_status=log_status,
)
if host: if host:
host_suffix = f" host={host}" host_suffix = f" host={host}"
else:
# minimal diag already sent to stderr by helper
pass
except Exception as e: except Exception as e:
await log_status(f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}") await log_status(f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}")
# print one line # print one compact line
line = f"[{now_s}] product={product} mac={mac} fw={fw}{host_suffix}" line = f"[{now_s}] product={product} mac={mac} fw={fw}{host_suffix}"
if args.include_subject: if args.include_subject:
line += f" subject={msg.subject}" line += f" subject={msg.subject}"
@@ -324,7 +365,8 @@ async def main():
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 '{args.subject}' (queue={getattr(args, 'queue', None) or '-'}) via {args.servers} | mode=one-line dedupe=payload ttl={DEDUPE_TTL}s tx=disabled nb={'on' if nb_enabled else 'off'}" f"[{ts()}] Listening on subject '{args.subject}' (queue={getattr(args, 'queue', None) or '-'}) via {args.servers} "
f"| mode=one-line dedupe=payload ttl={DEDUPE_TTL}s tx=disabled nb=on"
) )
# graceful shutdown # graceful shutdown
@@ -346,8 +388,7 @@ async def main():
await nc.drain() await nc.drain()
finally: finally:
await nc.close() await nc.close()
if session:
await session.close()
if __name__ == "__main__": if __name__ == "__main__":
try: try: