"""Audit a node's network configuration, and optionally add an NTP server. Demonstrates: - ``client.nodes.network`` end to end: the Networking card snapshot (``get``), plus ``get_hostname``/``get_iptables``, which — like NTP servers below — have no dedicated ``GET`` endpoint on the wire and are instead read back from the node's own document. - Reading NTP servers, which have no resource method to list them at all (only create/update/delete): read directly from ``node.raw["deviceConfig"]["ntpServers"]`` instead, the same "raw payload" escape hatch used by ``node_info.py``/``check_barbara_core_updates.py`` for other fields this SDK doesn't wrap in a typed model. - The dry-run/``--apply`` convention: adding an NTP server is the only write this script performs, and only when explicitly requested. This intentionally never touches physical interfaces, proxy, standalone mode, VPN, or iptables — those can strand a node's connectivity if misconfigured, so this script only reports their current state. Usage: python network_audit.py # every node in your company python network_audit.py [ ...] python network_audit.py --add-ntp time.google.com --apply Without --apply, --add-ntp only reports what would be added (dry-run). Credentials are read from environment variables (BBR_API_CLIENT_ID / BBR_API_CLIENT_SECRET / BBR_API_USERNAME / BBR_API_PASSWORD), optionally loaded from a .env file first — see load_dotenv() below and README.md. """ from __future__ import annotations import argparse import json import os import re from typing import Any from barbara import BarbaraApiError, BarbaraAuthError, BarbaraClient from barbara.models import Node from barbara.utils import from_base64 INTERNAL_ID_RE = re.compile(r"^[0-9a-f]{24}$") def load_dotenv(path: str) -> None: """Minimal .env loader (no extra dependency). Variables already set in the environment always take precedence over the file. """ if not os.path.isfile(path): return with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) def resolve_node(client: BarbaraClient, identifier: str) -> Node: if INTERNAL_ID_RE.match(identifier): return client.nodes.get(identifier) return client.nodes.resolve(identifier) def all_nodes(client: BarbaraClient) -> list[Node]: """Every node in the company, paging through results — a plain ``client.nodes.list()`` call only returns one page. """ nodes: list[Node] = [] offset = 0 size = 100 while True: page = client.nodes.paginate(size=size, from_=offset) nodes.extend(page.items) if not page.items or page.total is None or len(nodes) >= page.total: break offset += size return nodes def decode_ntp_servers(node: Node) -> list[dict[str, Any]]: """No resource method lists NTP servers — read them from the node's own document instead. ``systemServer: true`` marks Barbara's own defaults; never create/update/delete those. """ servers = node.raw.get("deviceConfig", {}).get("ntpServers", []) result = [] for entry in servers: encoded = entry.get("current", {}).get("update", {}).get("config", {}).get("server", "") result.append( { "id": entry.get("_id"), "server": from_base64(encoded) if encoded else "", "systemServer": entry.get("systemServer", False), } ) return result def audit_node(client: BarbaraClient, node: Node) -> dict[str, Any]: network = client.nodes.network.get(node.id) return { "nodeId": node.id, "nodeName": node.node_name, "hostname": client.nodes.network.get_hostname(node.id), "interfaces": [ {"name": iface.name, "type": iface.type, "active": iface.active} for iface in network.interfaces ], "vpn": network.vpn, "proxy": [entry.get("status") for entry in network.proxy], "iptables": client.nodes.network.get_iptables(node.id), "ntpServers": decode_ntp_servers(node), } def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "nodes", nargs="*", help="Node names (deviceName) or internal _ids (default: every node in your company)", ) parser.add_argument("--add-ntp", metavar="SERVER", help="Add an NTP server to every targeted node") parser.add_argument("--apply", action="store_true", help="Actually add the NTP server given by --add-ntp") parser.add_argument( "--env-file", default=".env", help="Path to a .env file with Barbara API Credentials" ) args = parser.parse_args() load_dotenv(args.env_file) with BarbaraClient.from_env() as client: try: client.api_version() except BarbaraAuthError as exc: raise SystemExit(f"Login failed — check your Barbara API Credentials: {exc}") print("Login OK.") nodes = ( [resolve_node(client, d) for d in args.nodes] if args.nodes else all_nodes(client) ) results = [] for node in nodes: result = audit_node(client, node) if args.add_ntp: if not args.apply: result["addNtp"] = {"server": args.add_ntp, "status": "dry-run"} else: try: client.nodes.network.create_ntp_server(node.id, args.add_ntp) result["addNtp"] = {"server": args.add_ntp, "status": "added"} except BarbaraApiError as exc: result["addNtp"] = {"server": args.add_ntp, "status": "error", "error": str(exc)} results.append(result) print(json.dumps(results, indent=2, default=str)) if any(r.get("addNtp", {}).get("status") == "error" for r in results): raise SystemExit(1) if __name__ == "__main__": main()