#!/usr/bin/env python3 """ NSX Infrastructure Security Groups Creates or updates NSX security groups for shared infrastructure servers. Edit the CONFIGURATION section below before running, then: python infra/security_groups.py --nsx-manager --username admin --password Add --dry-run to preview the API payloads without making any changes. """ import argparse import json import sys import urllib3 import requests from typing import Any urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # ── CONFIGURATION ────────────────────────────────────────────────────────────── # Prefix applied to every group's display name and ID. # Change this to match your environment's naming standard. GROUP_PREFIX = "SG-Infra-" # NSX tag scope used for all infrastructure server tags. # Tag format: scope=TAG_SCOPE, value= e.g. scope="Infra", value="DNS Server" TAG_SCOPE = "Infra" # NSX Policy domain. "default" is correct for most environments. DOMAIN = "default" # Group definitions. # # Each entry key becomes part of the group ID (URL-safe, no spaces). # Fields: # display_name : suffix appended to GROUP_PREFIX → "SG-Infra-DNS-Servers" # tag_value : tag value (under TAG_SCOPE) that dynamically adds matching VMs # ip_addresses : static IPs or CIDR ranges to include (list of strings) # vm_ext_ids : vCenter VM External IDs / MoRefs to include (list of strings) # Find these in vCenter (e.g. "vm-1042") or via the NSX VM inventory API. # # Members from all three sources are OR'd together in the group expression. GROUPS: dict[str, dict[str, Any]] = { "DNS": { "display_name": "DNS-Servers", "tag_value": "DNS Server", "ip_addresses": [ # "192.168.10.53", # "192.168.10.54", ], "vm_ext_ids": [ # "vm-1001", ], }, "NTP": { "display_name": "NTP-Servers", "tag_value": "NTP Server", "ip_addresses": [ # "192.168.10.123", # "192.168.10.124", ], "vm_ext_ids": [ # "vm-1002", ], }, "DC": { "display_name": "Domain-Controllers", "tag_value": "Domain Controller", "ip_addresses": [ # "192.168.10.10", # "192.168.10.11", ], "vm_ext_ids": [ # "vm-1003", # "vm-1004", ], }, } # ── END CONFIGURATION ────────────────────────────────────────────────────────── def group_id(key: str) -> str: """Derive a stable, URL-safe NSX group ID from the config key and prefix.""" prefix = GROUP_PREFIX.rstrip("-").replace(" ", "-") return f"{prefix}-{key}" def build_expressions(cfg: dict[str, Any]) -> list[dict[str, Any]]: """ Build the NSX Policy expression list for a group. Each source type (IPs, tag-matched VMs, explicit VMs) is OR'd together. Tag matching uses a NestedExpression to enforce both scope AND value so that tags from other scopes with the same value are not inadvertently included. Expression order matters in the NSX API — the ConjunctionOperator must sit between two non-operator expressions; the first expression must never be a ConjunctionOperator. """ expressions: list[dict[str, Any]] = [] def _or(): """Append an OR separator between expression blocks.""" if expressions: expressions.append({ "resource_type": "ConjunctionOperator", "conjunction_operator": "OR", }) # ── Static IP addresses / CIDRs ────────────────────────────────────────── ip_list = [ip for ip in cfg.get("ip_addresses", []) if ip] if ip_list: _or() expressions.append({ "resource_type": "IPAddressExpression", "ip_addresses": ip_list, }) # ── Dynamic tag membership (scope=TAG_SCOPE AND value=tag_value) ───────── # NestedExpression combines two Condition objects with AND: # Condition 1 — matches the tag SCOPE field (scope_operator triggers this) # Condition 2 — matches the tag VALUE field tag_value = cfg.get("tag_value", "").strip() if tag_value: _or() expressions.append({ "resource_type": "NestedExpression", "expressions": [ { "resource_type": "Condition", "member_type": "VirtualMachine", "key": "Tag", "operator": "EQUALS", "value": TAG_SCOPE, # scope_operator tells NSX to match this Condition against # the tag's scope field instead of the tag's value field. "scope_operator": "EQUALS", }, { "resource_type": "ConjunctionOperator", "conjunction_operator": "AND", }, { "resource_type": "Condition", "member_type": "VirtualMachine", "key": "Tag", "operator": "EQUALS", "value": tag_value, }, ], }) # ── Explicit VMs by vCenter External ID / MoRef ────────────────────────── ext_ids = [v for v in cfg.get("vm_ext_ids", []) if v] if ext_ids: _or() expressions.append({ "resource_type": "ExternalIDExpression", "member_type": "VirtualMachine", "external_ids": ext_ids, }) return expressions def build_group_payload(key: str, cfg: dict[str, Any]) -> dict[str, Any]: """Assemble the full NSX Policy group PATCH body.""" expressions = build_expressions(cfg) if not expressions: raise ValueError( f"Group '{key}' has no members defined " "(add ip_addresses, tag_value, or vm_ext_ids in CONFIGURATION)." ) return { "id": group_id(key), "display_name": f"{GROUP_PREFIX}{cfg['display_name']}", "description": ( f"Infrastructure {cfg['display_name'].replace('-', ' ')} — " f"managed by nsx_blueprints/infra/security_groups.py" ), "expression": expressions, } def patch_group( session: requests.Session, base_url: str, key: str, payload: dict[str, Any], dry_run: bool, ) -> None: url = ( f"{base_url}/policy/api/v1/infra/domains/{DOMAIN}/groups/{group_id(key)}" ) print(f"\n{'[DRY RUN] ' if dry_run else ''}Group: {payload['display_name']}") print(f" URL : PATCH {url}") print(f" Body: {json.dumps(payload, indent=4)}") if dry_run: return resp = session.patch(url, json=payload) if resp.status_code in (200, 201): print(f" ✓ {resp.status_code} — group created/updated successfully.") else: print(f" ✗ {resp.status_code} — {resp.text}", file=sys.stderr) resp.raise_for_status() def build_session(username: str, password: str, verify_ssl: bool) -> requests.Session: s = requests.Session() s.auth = (username, password) s.verify = verify_ssl s.headers.update({"Content-Type": "application/json"}) return s def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Create/update NSX infrastructure security groups." ) p.add_argument("--nsx-manager", required=True, metavar="HOST", help="NSX Manager IP or FQDN (e.g. 192.168.1.10)") p.add_argument("--username", required=True, help="NSX Manager username") p.add_argument("--password", required=True, help="NSX Manager password") p.add_argument("--dry-run", action="store_true", help="Print payloads without calling the API") p.add_argument("--no-verify-ssl", action="store_true", help="Disable SSL certificate verification (useful in lab environments)") return p.parse_args() def main() -> None: args = parse_args() base_url = f"https://{args.nsx_manager.rstrip('/')}" session = build_session(args.username, args.password, not args.no_verify_ssl) errors: list[str] = [] for key, cfg in GROUPS.items(): try: payload = build_group_payload(key, cfg) patch_group(session, base_url, key, payload, args.dry_run) except ValueError as exc: print(f" ⚠ Skipping '{key}': {exc}") except requests.HTTPError as exc: errors.append(f"{key}: {exc}") if errors: print("\nErrors encountered:", file=sys.stderr) for err in errors: print(f" - {err}", file=sys.stderr) sys.exit(1) print("\nDone." if not args.dry_run else "\nDry run complete — no changes were made.") if __name__ == "__main__": main()