feat: initial implementation of NSX Security Group IP Sync tool

- Added main script (main.py) for syncing IPs between NSX managers.
- Implemented logging setup and YAML configuration loading.
- Created NSXClient for interacting with NSX Policy API.
- Developed SyncEngine for comparing and syncing IPs.
- Added interactive group selection feature.
- Implemented data export functionality for JSON and CSV formats.
- Created requirements.txt for dependencies.
- Added __init__.py for package versioning.
- Included error handling and validation for configuration.
This commit is contained in:
2026-05-01 09:46:04 -05:00
commit 5b76457ad8
9 changed files with 1579 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
"""
NSX Policy API client supporting NSX 3.x, 4.x, and 9.x managers.
Uses the Policy API (preferred over the deprecated Manager API).
"""
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
import urllib3
logger = logging.getLogger(__name__)
_POLICY_BASE = "/policy/api/v1"
_PAGE_SIZE = 1000
_MAX_RETRIES = 3
_RETRY_BACKOFF = 2 # seconds
class NSXAuthError(Exception):
pass
class NSXAPIError(Exception):
def __init__(self, message: str, status_code: int = 0, body: str = ""):
super().__init__(message)
self.status_code = status_code
self.body = body
class NSXClient:
"""
Thin wrapper around the NSX Policy REST API.
Supports basic auth (username + password) and handles:
- Cursor-based pagination
- Automatic retries on transient errors
- Optional SSL verification bypass (for self-signed certs)
"""
def __init__(
self,
host: str,
username: str,
password: str,
verify_ssl: bool = True,
timeout: int = 30,
label: str = "",
):
self.base_url = f"https://{host.rstrip('/')}{_POLICY_BASE}"
self.timeout = timeout
self.label = label or host
if not verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._session = requests.Session()
self._session.auth = (username, password)
self._session.verify = verify_ssl
self._session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"}
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _url(self, path: str) -> str:
return f"{self.base_url}/{path.lstrip('/')}"
def _request(
self,
method: str,
path: str,
**kwargs,
) -> requests.Response:
url = self._url(path)
last_exc: Optional[Exception] = None
for attempt in range(1, _MAX_RETRIES + 1):
try:
resp = self._session.request(
method, url, timeout=self.timeout, **kwargs
)
if resp.status_code == 401:
raise NSXAuthError(
f"[{self.label}] Authentication failed check credentials."
)
if resp.status_code == 403:
raise NSXAuthError(
f"[{self.label}] Access denied to {path} check permissions."
)
if resp.status_code >= 500 and attempt < _MAX_RETRIES:
logger.warning(
"[%s] HTTP %s on %s, retry %d/%d",
self.label, resp.status_code, path, attempt, _MAX_RETRIES,
)
time.sleep(_RETRY_BACKOFF * attempt)
continue
if not resp.ok:
raise NSXAPIError(
f"[{self.label}] HTTP {resp.status_code} on {method} {path}",
status_code=resp.status_code,
body=resp.text,
)
return resp
except (requests.ConnectionError, requests.Timeout) as exc:
last_exc = exc
if attempt < _MAX_RETRIES:
logger.warning(
"[%s] Connection error on %s, retry %d/%d: %s",
self.label, path, attempt, _MAX_RETRIES, exc,
)
time.sleep(_RETRY_BACKOFF * attempt)
raise NSXAPIError(
f"[{self.label}] Failed to reach {path} after {_MAX_RETRIES} attempts: {last_exc}"
)
def _get_paged(self, path: str, params: Optional[Dict] = None) -> List[Dict]:
"""Collect all pages for a list endpoint using cursor-based pagination."""
params = dict(params or {})
params["page_size"] = _PAGE_SIZE
results: List[Dict] = []
while True:
resp = self._request("GET", path, params=params)
data = resp.json()
results.extend(data.get("results", []))
cursor = data.get("cursor")
if not cursor:
break
params["cursor"] = cursor
return results
# ------------------------------------------------------------------
# Version / connectivity check
# ------------------------------------------------------------------
def get_version(self) -> str:
"""Return the NSX manager version string."""
resp = self._request("GET", "/infra/sites")
# Version is available via a different endpoint
try:
resp2 = self._session.get(
f"https://{self.base_url.split('//')[1].split('/')[0]}/api/v1/node/version",
timeout=self.timeout,
)
if resp2.ok:
return resp2.json().get("product_version", "unknown")
except Exception:
pass
return "unknown"
# ------------------------------------------------------------------
# Groups
# ------------------------------------------------------------------
def list_groups(self, domain: str = "default") -> List[Dict]:
"""Return all security groups in the given policy domain."""
logger.debug("[%s] Listing groups in domain '%s'", self.label, domain)
return self._get_paged(f"/infra/domains/{domain}/groups")
def get_group(self, group_id: str, domain: str = "default") -> Dict:
"""Return a single group definition by ID or display_name."""
groups = self.list_groups(domain)
# Try exact ID match first, then display_name
for g in groups:
if g.get("id") == group_id or g.get("display_name") == group_id:
return g
raise NSXAPIError(
f"[{self.label}] Group '{group_id}' not found in domain '{domain}'"
)
def get_group_effective_ips(
self, group_id: str, domain: str = "default"
) -> List[str]:
"""
Return the list of effective (computed) IP addresses that are members
of the group. This resolves VM-based, tag-based, etc. criteria.
"""
logger.debug(
"[%s] Fetching effective IPs for group '%s'", self.label, group_id
)
path = f"/infra/domains/{domain}/groups/{group_id}/members/ip-addresses"
raw = self._get_paged(path)
# Each item is a plain string (the IP / CIDR)
ips = [item if isinstance(item, str) else item.get("ip_address", "") for item in raw]
return sorted(ip for ip in ips if ip)
# ------------------------------------------------------------------
# IP Address Expression helpers
# ------------------------------------------------------------------
@staticmethod
def _extract_ip_expressions(
expressions: List[Dict],
) -> Tuple[List[Dict], List[str]]:
"""
Split expressions into (non-ip-entries, existing-static-ips).
Removes IPAddressExpression items and their preceding ConjunctionOperators.
Returns a clean expression list ready for re-injection and the existing IPs.
"""
existing_ips: List[str] = []
clean: List[Dict] = []
skip_next_conjunction = False
for expr in expressions:
rtype = expr.get("resource_type", "")
if rtype == "IPAddressExpression":
existing_ips.extend(expr.get("ip_addresses", []))
# Remove the conjunction that precedes this entry (if we added it)
if clean and clean[-1].get("resource_type") == "ConjunctionOperator":
clean.pop()
continue
clean.append(expr)
_ = skip_next_conjunction # unused, kept for clarity
return clean, existing_ips
def patch_group_ips(
self,
group_id: str,
ips_to_add: List[str],
ips_to_remove: List[str],
domain: str = "default",
whatif: bool = False,
) -> Dict:
"""
Modify the static IP address entries in a target group:
- Adds ips_to_add as static entries
- Removes ips_to_remove from static entries
- Leaves all non-IP expressions (Conditions, Tags, etc.) untouched
Returns a result dict describing what was (or would have been) done.
"""
group = self.get_group(group_id, domain)
gid = group["id"]
display = group.get("display_name", gid)
expressions = list(group.get("expression", []))
base_exprs, current_static_ips = self._extract_ip_expressions(expressions)
current_set = set(current_static_ips)
desired_set = (current_set | set(ips_to_add)) - set(ips_to_remove)
actually_added = sorted(set(ips_to_add) - current_set)
actually_removed = sorted(current_set & set(ips_to_remove))
result = {
"group_id": gid,
"display_name": display,
"ips_added": actually_added,
"ips_removed": actually_removed,
"whatif": whatif,
}
if not actually_added and not actually_removed:
logger.info("[%s] Group '%s': no changes needed.", self.label, display)
result["status"] = "no_change"
return result
if whatif:
logger.info(
"[%s] [WHATIF] Group '%s': would add %d, remove %d IPs.",
self.label, display, len(actually_added), len(actually_removed),
)
result["status"] = "whatif"
return result
# Build the new expression list
new_exprs = list(base_exprs)
if desired_set:
if new_exprs:
new_exprs.append(
{"resource_type": "ConjunctionOperator", "conjunction_operator": "OR"}
)
new_exprs.append(
{
"resource_type": "IPAddressExpression",
"ip_addresses": sorted(desired_set),
}
)
patch_body = dict(group)
patch_body["expression"] = new_exprs
self._request(
"PATCH",
f"/infra/domains/{domain}/groups/{gid}",
json=patch_body,
)
logger.info(
"[%s] Group '%s': added %d, removed %d IPs.",
self.label, display, len(actually_added), len(actually_removed),
)
result["status"] = "updated"
return result