feat: startign with netbox 06:57
This commit is contained in:
@@ -1,17 +1,22 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
NATS Registration Listener (compact one-line output + strict read-only dedupe)
|
NATS Registration Listener (with optional NetBox hostname lookup for fox100)
|
||||||
------------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
Print exactly one line per message:
|
|
||||||
[TS] product=<productName> mac=<mac> fw=<active>
|
|
||||||
|
|
||||||
- Listener-only (does NOT publish/reply).
|
Adds: For product=fox100, lookup device hostname in NetBox by MAC and append
|
||||||
- Writes are serialized to avoid interleaved output.
|
'host=<device.name>' to the one-line output if found.
|
||||||
- Strict dedupe by exact payload bytes within a short TTL (env DEDUP_TTL, default 2.0s).
|
|
||||||
- Falls back gracefully if fields/structure are missing.
|
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:
|
||||||
|
- Non-fox100: unchanged.
|
||||||
|
- fox100: best-effort NetBox lookup inline (sequential), stderr on edge cases.
|
||||||
|
|
||||||
Install deps:
|
Install deps:
|
||||||
pip install --upgrade nats-py
|
pip install --upgrade nats-py aiohttp
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -27,7 +32,10 @@ from datetime import datetime, timezone
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
import nats
|
import nats
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import ClientTimeout
|
||||||
|
|
||||||
|
# ----------------- existing helpers (unchanged) -----------------
|
||||||
|
|
||||||
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.")
|
||||||
@@ -59,24 +67,25 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +94,6 @@ def ts() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def pick_first_interface(eths: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
# Prefer eth0; otherwise first key deterministically (sorted)
|
|
||||||
if "eth0" in eths and isinstance(eths["eth0"], dict):
|
if "eth0" in eths and isinstance(eths["eth0"], dict):
|
||||||
return eths["eth0"]
|
return eths["eth0"]
|
||||||
for name in sorted(eths.keys()):
|
for name in sorted(eths.keys()):
|
||||||
@@ -95,30 +103,17 @@ 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:
|
||||||
# Some producers might not wrap with "data"
|
|
||||||
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", {})
|
||||||
@@ -128,26 +123,108 @@ 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 -----------------
|
||||||
|
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
s = mac.strip().lower()
|
||||||
|
# replace dashes with colons
|
||||||
|
s = s.replace('-', ':')
|
||||||
|
hex_only = ''.join(ch for ch in s if ch in '0123456789abcdef')
|
||||||
|
if len(hex_only) == 12:
|
||||||
|
# reconstruct as colon-separated
|
||||||
|
s = ':'.join(hex_only[i:i+2] for i in range(0, 12, 2))
|
||||||
|
return s
|
||||||
|
# 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 None
|
||||||
|
|
||||||
|
|
||||||
|
async def nb_get_hostname_by_mac(session: aiohttp.ClientSession, base_url: str, token: str, mac: str, timeout_s: float, log_status):
|
||||||
|
"""
|
||||||
|
Best-effort:
|
||||||
|
1) GET /api/dcim/mac-addresses/?mac_address=<mac>
|
||||||
|
2) If one result and assigned to interface -> GET /api/dcim/interfaces/{id}/ -> device.name
|
||||||
|
Returns (hostname or None).
|
||||||
|
Emits concise diagnostics to stderr via log_status on edge cases.
|
||||||
|
"""
|
||||||
|
mac_norm = normalize_mac(mac)
|
||||||
|
if not mac_norm:
|
||||||
|
await log_status(f"[{ts()}] nb: invalid mac format mac={mac!r}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 1) find MAC record(s)
|
||||||
|
url_mac = f"{base_url.rstrip('/')}/api/dcim/mac-addresses/"
|
||||||
|
try:
|
||||||
|
async with session.get(url_mac, params={"mac_address": mac_norm, "limit": 2}, headers={"Authorization": f"Token {token}"}, timeout=timeout_s) as r:
|
||||||
|
if r.status != 200:
|
||||||
|
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
|
||||||
|
|
||||||
|
results = (data or {}).get("results") or []
|
||||||
|
if not results:
|
||||||
|
await log_status(f"[{ts()}] nb: mac not found mac={mac_norm}")
|
||||||
|
return None
|
||||||
|
if len(results) > 1:
|
||||||
|
await log_status(f"[{ts()}] nb: multiple mac records mac={mac_norm}")
|
||||||
|
|
||||||
|
rec = results[0]
|
||||||
|
aot = (rec.get("assigned_object_type") or "").strip()
|
||||||
|
aoid = rec.get("assigned_object_id")
|
||||||
|
if not aot or aoid is None:
|
||||||
|
await log_status(f"[{ts()}] nb: mac unassigned mac={mac_norm}")
|
||||||
|
return None
|
||||||
|
if aot != "dcim.interface":
|
||||||
|
await log_status(f"[{ts()}] nb: mac assigned to {aot} mac={mac_norm}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2) fetch interface -> device
|
||||||
|
url_iface = f"{base_url.rstrip('/')}/api/dcim/interfaces/{aoid}/"
|
||||||
|
try:
|
||||||
|
async with session.get(url_iface, headers={"Authorization": f"Token {token}"}, timeout=timeout_s) as r2:
|
||||||
|
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
|
||||||
|
|
||||||
|
dev = iface.get("device") or {}
|
||||||
|
host = dev.get("name") or dev.get("display")
|
||||||
|
if not host:
|
||||||
|
await log_status(f"[{ts()}] nb: iface has no device iface_id={aoid}")
|
||||||
|
return None
|
||||||
|
return host
|
||||||
|
|
||||||
|
# ----------------- 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)
|
||||||
|
|
||||||
# Serialize stdout writes to avoid interleaving
|
|
||||||
print_lock = asyncio.Lock()
|
print_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def log_status(s: str):
|
async def log_status(s: str):
|
||||||
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 config (env) ---
|
# dedupe
|
||||||
# Drop exact duplicate payloads seen within this TTL window.
|
|
||||||
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
|
DEDUPE_TTL = float(os.environ.get("DEDUP_TTL", "2.0"))
|
||||||
recent_payloads: Dict[bytes, float] = {} # digest -> expires_at (monotonic seconds)
|
recent_payloads: Dict[bytes, float] = {}
|
||||||
|
|
||||||
reconnect_time_wait = 2
|
reconnect_time_wait = 2
|
||||||
max_reconnect_attempts = -1
|
max_reconnect_attempts = -1
|
||||||
|
|
||||||
@@ -180,29 +257,33 @@ 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
|
||||||
|
|
||||||
# --- STRICT DEDUPE by exact payload bytes (read-only) ---
|
# dedupe by payload
|
||||||
# Use a stable, compact digest to key the recent map.
|
|
||||||
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)
|
||||||
if exp and exp > nowm:
|
if exp and exp > nowm:
|
||||||
return # drop exact duplicate seen very recently
|
return
|
||||||
recent_payloads[digest] = nowm + DEDUPE_TTL
|
recent_payloads[digest] = nowm + DEDUPE_TTL
|
||||||
|
|
||||||
# Optional light cleanup to keep the dict bounded
|
|
||||||
if len(recent_payloads) > 4096:
|
if len(recent_payloads) > 4096:
|
||||||
# remove expired entries
|
|
||||||
cutoff = nowm
|
cutoff = nowm
|
||||||
recent_payloads_keys = list(recent_payloads.keys())
|
for k in list(recent_payloads.keys()):
|
||||||
for k in recent_payloads_keys:
|
|
||||||
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 and print one line
|
# parse payload
|
||||||
product = mac = fw = "-"
|
product = mac = fw = "-"
|
||||||
try:
|
try:
|
||||||
text = payload.decode("utf-8", errors="replace")
|
text = payload.decode("utf-8", errors="replace")
|
||||||
@@ -211,25 +292,42 @@ async def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
line = f"[{now_s}] product={product} mac={mac} fw={fw}"
|
# optional NetBox lookup for fox100
|
||||||
|
host_suffix = ""
|
||||||
|
if nb_enabled and product == "fox100":
|
||||||
|
try:
|
||||||
|
host = await nb_get_hostname_by_mac(
|
||||||
|
session=session,
|
||||||
|
base_url=args.nb_url,
|
||||||
|
token=args.nb_token,
|
||||||
|
mac=mac,
|
||||||
|
timeout_s=args.nb_timeout,
|
||||||
|
log_status=log_status,
|
||||||
|
)
|
||||||
|
if host:
|
||||||
|
host_suffix = f" host={host}"
|
||||||
|
except Exception as e:
|
||||||
|
await log_status(f"[{ts()}] nb: unexpected error mac={mac!r} err={e!r}")
|
||||||
|
|
||||||
|
# print one line
|
||||||
|
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}"
|
||||||
|
|
||||||
async with print_lock:
|
async with print_lock:
|
||||||
sys.stdout.write(line + "\n")
|
sys.stdout.write(line + "\n")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# subscribe
|
||||||
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 '{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"| mode=one-line dedupe=payload ttl={DEDUPE_TTL}s tx=disabled"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Graceful shutdown
|
# graceful shutdown
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
def handle_signal(*_):
|
def handle_signal(*_):
|
||||||
@@ -248,7 +346,8 @@ 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user