nac-test-pyats-common 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ """SDWAN Manager authentication implementation for Cisco SD-WAN.
2
+
3
+ This module provides authentication functionality for Cisco SDWAN Manager (formerly
4
+ vManage), which manages the software-defined WAN fabric. The authentication mechanism
5
+ uses form-based login with JSESSIONID cookie and optional XSRF token for CSRF protection.
6
+
7
+ The module implements a two-tier API design:
8
+ 1. _authenticate() - Low-level method that performs direct SDWAN Manager authentication
9
+ 2. get_auth() - High-level method that leverages caching for efficient token reuse
10
+
11
+ This design ensures efficient session management by reusing valid sessions and only
12
+ re-authenticating when necessary, reducing unnecessary API calls to the SDWAN Manager.
13
+ """
14
+
15
+ import os
16
+ from typing import Any
17
+
18
+ import httpx
19
+ from nac_test.pyats_core.common.auth_cache import AuthCache # type: ignore[import-untyped]
20
+
21
+ # Default session lifetime for SDWAN Manager authentication in seconds
22
+ # SDWAN Manager sessions are typically valid for 30 minutes (1800 seconds) by default
23
+ SDWAN_MANAGER_SESSION_LIFETIME_SECONDS: int = 1800
24
+
25
+ # HTTP timeout for XSRF token fetch (shorter than auth timeout since it's optional)
26
+ XSRF_TOKEN_FETCH_TIMEOUT_SECONDS: float = 10.0
27
+
28
+ # HTTP timeout for authentication request
29
+ AUTH_REQUEST_TIMEOUT_SECONDS: float = 30.0
30
+
31
+
32
+ class SDWANManagerAuth:
33
+ """SDWAN Manager authentication implementation with session caching.
34
+
35
+ This class provides a two-tier API for SDWAN Manager authentication:
36
+
37
+ 1. Low-level _authenticate() method: Directly authenticates with SDWAN Manager using
38
+ form-based login and returns session data along with expiration time. This is
39
+ typically used by the caching layer and not called directly by consumers.
40
+
41
+ 2. High-level get_auth() method: Provides cached session management, automatically
42
+ handling session renewal when expired. This is the primary method that consumers
43
+ should use for obtaining SDWAN Manager authentication data.
44
+
45
+ The authentication flow supports both:
46
+ - Pre-19.2 versions: JSESSIONID cookie only
47
+ - 19.2+ versions: JSESSIONID cookie plus X-XSRF-TOKEN header for CSRF protection
48
+
49
+ Example:
50
+ >>> # Get authentication data for SDWAN Manager API calls
51
+ >>> auth_data = SDWANManagerAuth.get_auth()
52
+ >>> # Use in requests
53
+ >>> headers = {"Cookie": f"JSESSIONID={auth_data['jsessionid']}"}
54
+ >>> if auth_data.get("xsrf_token"):
55
+ ... headers["X-XSRF-TOKEN"] = auth_data["xsrf_token"]
56
+ """
57
+
58
+ @staticmethod
59
+ def _authenticate(url: str, username: str, password: str) -> tuple[dict[str, Any], int]:
60
+ """Perform direct SDWAN Manager authentication and obtain session data.
61
+
62
+ This method performs a direct authentication request to the SDWAN Manager
63
+ using form-based login. It returns both the session data and its lifetime
64
+ for proper cache management.
65
+
66
+ The authentication process:
67
+ 1. POST form credentials to /j_security_check endpoint
68
+ 2. Extract JSESSIONID cookie from response
69
+ 3. Attempt to fetch XSRF token (for 19.2+ only)
70
+ 4. Return session data with TTL
71
+
72
+ Args:
73
+ url: Base URL of the SDWAN Manager (e.g., "https://sdwan-manager.example.com").
74
+ Should not include trailing slashes or API paths.
75
+ username: SDWAN Manager username for authentication. This should be a valid
76
+ user configured with appropriate permissions.
77
+ password: Password for the specified user account.
78
+
79
+ Returns:
80
+ A tuple containing:
81
+ - auth_dict (dict): Dictionary with 'jsessionid' (str) and 'xsrf_token'
82
+ (str | None). The xsrf_token is None for pre-19.2 versions.
83
+ - expires_in (int): Session lifetime in seconds (typically 1800).
84
+
85
+ Raises:
86
+ httpx.HTTPStatusError: If SDWAN Manager returns a non-2xx status code,
87
+ typically indicating authentication failure (401) or server error.
88
+ httpx.RequestError: If the request fails due to network issues,
89
+ connection timeouts, or other transport-level problems.
90
+ ValueError: If the JSESSIONID cookie is not received in the response,
91
+ indicating a malformed or unexpected response.
92
+
93
+ Note:
94
+ SSL verification is disabled (verify=False) to handle self-signed
95
+ certificates commonly used in lab and development deployments.
96
+ In production environments, proper certificate validation should be enabled
97
+ by either installing the certificate in the trust store or providing
98
+ a custom CA bundle via the verify parameter.
99
+ """
100
+ # NOTE: SSL verification is disabled (verify=False) to handle self-signed
101
+ # certificates commonly used in lab and development deployments.
102
+ with httpx.Client(verify=False, timeout=AUTH_REQUEST_TIMEOUT_SECONDS) as client:
103
+ # Step 1: Form-based login to SDWAN Manager
104
+ auth_response = client.post(
105
+ f"{url}/j_security_check",
106
+ data={"j_username": username, "j_password": password},
107
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
108
+ follow_redirects=False,
109
+ )
110
+ auth_response.raise_for_status()
111
+
112
+ # Validate JSESSIONID cookie was received
113
+ if "JSESSIONID" not in auth_response.cookies:
114
+ raise ValueError(
115
+ "No JSESSIONID cookie received from SDWAN Manager. "
116
+ "This may indicate invalid credentials or a server error. "
117
+ f"Response status: {auth_response.status_code}"
118
+ )
119
+
120
+ jsessionid = auth_response.cookies["JSESSIONID"]
121
+
122
+ # Step 2: Attempt to get XSRF token (19.2+ only)
123
+ # Pre-19.2 versions do not require XSRF token, so failures are expected
124
+ xsrf_token: str | None = None
125
+ try:
126
+ token_response = client.get(
127
+ f"{url}/dataservice/client/token",
128
+ cookies={"JSESSIONID": jsessionid},
129
+ timeout=XSRF_TOKEN_FETCH_TIMEOUT_SECONDS,
130
+ )
131
+ if token_response.status_code == 200:
132
+ xsrf_token = token_response.text.strip()
133
+ except (httpx.HTTPError, httpx.TimeoutException):
134
+ # Pre-19.2 does not support XSRF tokens, continue without
135
+ pass
136
+
137
+ return {
138
+ "jsessionid": jsessionid,
139
+ "xsrf_token": xsrf_token,
140
+ }, SDWAN_MANAGER_SESSION_LIFETIME_SECONDS
141
+
142
+ @classmethod
143
+ def get_auth(cls) -> dict[str, Any]:
144
+ """Get SDWAN Manager authentication data with automatic caching and renewal.
145
+
146
+ This is the primary method that consumers should use to obtain SDWAN Manager
147
+ authentication data. It leverages the AuthCache to efficiently manage
148
+ session lifecycle, reusing valid sessions and automatically renewing
149
+ expired ones. This significantly reduces the number of authentication
150
+ requests to the SDWAN Manager.
151
+
152
+ The method uses a cache key based on the controller type ("SDWAN_MANAGER") and
153
+ URL to ensure proper session isolation between different SDWAN Manager instances.
154
+
155
+ Environment Variables Required:
156
+ SDWAN_URL: Base URL of the SDWAN Manager
157
+ SDWAN_USERNAME: SDWAN Manager username for authentication
158
+ SDWAN_PASSWORD: SDWAN Manager password for authentication
159
+
160
+ Returns:
161
+ A dictionary containing:
162
+ - jsessionid (str): The session cookie value for API requests
163
+ - xsrf_token (str | None): The XSRF token for CSRF protection
164
+ (None for pre-19.2 versions)
165
+
166
+ Raises:
167
+ ValueError: If any required environment variables (SDWAN_URL,
168
+ SDWAN_USERNAME, SDWAN_PASSWORD) are not set.
169
+ httpx.HTTPStatusError: If SDWAN Manager returns a non-2xx status code during
170
+ authentication, typically indicating invalid credentials (401) or
171
+ server issues (5xx).
172
+ httpx.RequestError: If the request fails due to network issues,
173
+ connection timeouts, or other transport-level problems.
174
+
175
+ Example:
176
+ >>> # Set environment variables first
177
+ >>> import os
178
+ >>> os.environ["SDWAN_URL"] = "https://sdwan-manager.example.com"
179
+ >>> os.environ["SDWAN_USERNAME"] = "admin"
180
+ >>> os.environ["SDWAN_PASSWORD"] = "password123"
181
+ >>> # Get authentication data
182
+ >>> auth_data = SDWANManagerAuth.get_auth()
183
+ >>> # Use in API requests
184
+ >>> headers = {"Cookie": f"JSESSIONID={auth_data['jsessionid']}"}
185
+ >>> if auth_data.get("xsrf_token"):
186
+ ... headers["X-XSRF-TOKEN"] = auth_data["xsrf_token"]
187
+ """
188
+ url = os.environ.get("SDWAN_URL")
189
+ username = os.environ.get("SDWAN_USERNAME")
190
+ password = os.environ.get("SDWAN_PASSWORD")
191
+
192
+ if not all([url, username, password]):
193
+ missing_vars: list[str] = []
194
+ if not url:
195
+ missing_vars.append("SDWAN_URL")
196
+ if not username:
197
+ missing_vars.append("SDWAN_USERNAME")
198
+ if not password:
199
+ missing_vars.append("SDWAN_PASSWORD")
200
+ raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
201
+
202
+ # Normalize URL by removing trailing slash
203
+ url = url.rstrip("/") # type: ignore[union-attr]
204
+
205
+ def auth_wrapper() -> tuple[dict[str, Any], int]:
206
+ """Wrapper for authentication that captures closure variables."""
207
+ return cls._authenticate(url, username, password) # type: ignore[arg-type]
208
+
209
+ # AuthCache.get_or_create returns dict[str, Any], but mypy can't verify this
210
+ # because nac_test lacks py.typed marker.
211
+ return AuthCache.get_or_create( # type: ignore[no-any-return]
212
+ controller_type="SDWAN_MANAGER",
213
+ url=url,
214
+ auth_func=auth_wrapper,
215
+ )
@@ -0,0 +1,179 @@
1
+ """SD-WAN-specific device resolver for parsing the NAC data model.
2
+
3
+ This module provides the SDWANDeviceResolver class, which extends
4
+ BaseDeviceResolver to implement SD-WAN schema navigation.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from nac_test_pyats_common.common import BaseDeviceResolver
11
+ from nac_test_pyats_common.iosxe.registry import register_iosxe_resolver
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @register_iosxe_resolver("SDWAN")
17
+ class SDWANDeviceResolver(BaseDeviceResolver):
18
+ """SD-WAN device resolver for D2D testing.
19
+
20
+ Navigates the SD-WAN NAC schema (sites[].routers[]) to extract
21
+ device information for SSH testing.
22
+
23
+ Schema structure:
24
+ sdwan:
25
+ sites:
26
+ - name: "site1"
27
+ routers:
28
+ - chassis_id: "abc123"
29
+ device_variables:
30
+ system_hostname: "router1"
31
+ vpn10_mgmt_ip: "10.1.1.100/32"
32
+
33
+ Credentials:
34
+ Uses IOSXE_USERNAME and IOSXE_PASSWORD environment variables
35
+ because SD-WAN edge devices are IOS-XE based.
36
+
37
+ Example:
38
+ >>> resolver = SDWANDeviceResolver(data_model)
39
+ >>> devices = resolver.get_resolved_inventory()
40
+ >>> for device in devices:
41
+ ... print(f"{device['hostname']}: {device['host']}")
42
+ """
43
+
44
+ def get_architecture_name(self) -> str:
45
+ """Return 'sdwan' as the architecture identifier.
46
+
47
+ Returns:
48
+ Architecture name used in logging and error messages.
49
+ """
50
+ return "sdwan"
51
+
52
+ def get_schema_root_key(self) -> str:
53
+ """Return 'sdwan' as the root key in the data model.
54
+
55
+ Returns:
56
+ Root key used when loading test inventory and navigating schema.
57
+ """
58
+ return "sdwan"
59
+
60
+ def navigate_to_devices(self) -> list[dict[str, Any]]:
61
+ """Navigate SD-WAN schema: sdwan.sites[].routers[].
62
+
63
+ Traverses the SD-WAN data model structure to find all router
64
+ devices across all sites.
65
+
66
+ Returns:
67
+ List of router dictionaries from all sites.
68
+ """
69
+ devices: list[dict[str, Any]] = []
70
+ sdwan_data = self.data_model.get("sdwan", {})
71
+
72
+ for site in sdwan_data.get("sites", []):
73
+ routers = site.get("routers", [])
74
+ devices.extend(routers)
75
+
76
+ return devices
77
+
78
+ def extract_device_id(self, device_data: dict[str, Any]) -> str:
79
+ """Extract chassis_id as the device identifier.
80
+
81
+ Args:
82
+ device_data: Router data dictionary from the data model.
83
+
84
+ Returns:
85
+ Unique chassis_id string.
86
+
87
+ Raises:
88
+ ValueError: If the router is missing the chassis_id field.
89
+ """
90
+ chassis_id = device_data.get("chassis_id")
91
+ if not chassis_id:
92
+ raise ValueError("Router missing 'chassis_id' field")
93
+ return str(chassis_id)
94
+
95
+ def extract_hostname(self, device_data: dict[str, Any]) -> str:
96
+ """Extract hostname from device_variables.system_hostname.
97
+
98
+ Looks for system_hostname in the device_variables section.
99
+ Falls back to chassis_id if system_hostname is not available.
100
+
101
+ Args:
102
+ device_data: Router data dictionary from the data model.
103
+
104
+ Returns:
105
+ Device hostname string.
106
+ """
107
+ device_vars = device_data.get("device_variables", {})
108
+
109
+ if "system_hostname" in device_vars:
110
+ return str(device_vars["system_hostname"])
111
+
112
+ # Fallback to chassis_id
113
+ chassis_id = device_data.get("chassis_id", "unknown")
114
+ logger.warning(f"No system_hostname found for {chassis_id}, using chassis_id as hostname")
115
+ return str(chassis_id)
116
+
117
+ def extract_host_ip(self, device_data: dict[str, Any]) -> str:
118
+ """Extract management IP from device_variables.
119
+
120
+ Handles CIDR notation (e.g., "10.1.1.100/32" -> "10.1.1.100").
121
+ Uses management_ip_variable field to determine which variable
122
+ contains the management IP.
123
+
124
+ Args:
125
+ device_data: Router data dictionary from the data model.
126
+
127
+ Returns:
128
+ IP address string without CIDR notation (e.g., "10.1.1.100").
129
+
130
+ Raises:
131
+ ValueError: If no management IP can be found.
132
+ """
133
+ device_vars = device_data.get("device_variables", {})
134
+
135
+ # Get the variable name that contains the management IP
136
+ ip_var = device_data.get("management_ip_variable")
137
+
138
+ if ip_var and ip_var in device_vars:
139
+ ip_value = str(device_vars[ip_var])
140
+ else:
141
+ # Fallback: try common variable names
142
+ for fallback_var in ["mgmt_ip", "management_ip", "vpn0_ip"]:
143
+ if fallback_var in device_vars:
144
+ ip_value = str(device_vars[fallback_var])
145
+ break
146
+ else:
147
+ raise ValueError(
148
+ "Could not find management IP for device. "
149
+ "Set 'management_ip_variable' in test_inventory or use "
150
+ "standard variable names (mgmt_ip, management_ip, vpn0_ip)."
151
+ )
152
+
153
+ # Strip CIDR notation if present
154
+ if "/" in ip_value:
155
+ ip_value = ip_value.split("/")[0]
156
+
157
+ return ip_value
158
+
159
+ def extract_os_type(self, device_data: dict[str, Any]) -> str:
160
+ """Extract OS type, defaulting to 'iosxe' for SD-WAN edges.
161
+
162
+ Args:
163
+ device_data: Router data dictionary from the data model.
164
+
165
+ Returns:
166
+ OS type string (e.g., "iosxe", "nxos", "iosxr").
167
+ """
168
+ return str(device_data.get("os", "iosxe"))
169
+
170
+ def get_credential_env_vars(self) -> tuple[str, str]:
171
+ """Return IOS-XE credential env vars for SD-WAN edge devices.
172
+
173
+ SD-WAN D2D tests connect to IOS-XE based edge devices,
174
+ NOT the vManage/SDWAN Manager controller.
175
+
176
+ Returns:
177
+ Tuple of (username_env_var, password_env_var).
178
+ """
179
+ return ("IOSXE_USERNAME", "IOSXE_PASSWORD")
@@ -0,0 +1,101 @@
1
+ """SD-WAN specific base test class for SSH/Direct-to-Device testing.
2
+
3
+ This module provides the SDWANTestBase class, which extends the generic SSHTestBase
4
+ to add SD-WAN-specific functionality for device-to-device (D2D) testing.
5
+
6
+ The class delegates device inventory resolution to SDWANDeviceResolver, which
7
+ handles all SD-WAN schema navigation and credential injection.
8
+
9
+ Credentials:
10
+ SD-WAN D2D tests connect to IOS-XE based edge devices, NOT the SDWAN Manager
11
+ controller. Set these environment variables:
12
+ - IOSXE_USERNAME: SSH username for edge devices
13
+ - IOSXE_PASSWORD: SSH password for edge devices
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ from typing import Any
19
+
20
+ from nac_test.pyats_core.common.ssh_base_test import SSHTestBase # type: ignore[import-untyped]
21
+
22
+ from .device_resolver import SDWANDeviceResolver
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class SDWANTestBase(SSHTestBase): # type: ignore[misc]
28
+ """SD-WAN-specific base test class for SSH/D2D testing.
29
+
30
+ This class extends SSHTestBase and implements the contract required by
31
+ nac-test's SSH execution engine. Device inventory resolution is fully
32
+ delegated to SDWANDeviceResolver.
33
+
34
+ Credentials:
35
+ SD-WAN D2D tests require IOSXE_USERNAME and IOSXE_PASSWORD environment
36
+ variables (NOT SDWAN_* which are for the controller).
37
+
38
+ Example:
39
+ class MySDWANSSHTest(SDWANTestBase):
40
+ @aetest.test
41
+ def verify_device_connectivity(self, steps, device):
42
+ # SSH-based verification logic here
43
+ pass
44
+ """
45
+
46
+ @classmethod
47
+ def get_ssh_device_inventory(cls, data_model: dict[str, Any]) -> list[dict[str, Any]]:
48
+ """Parse the SD-WAN data model to retrieve the device inventory.
49
+
50
+ This method is the entry point called by nac-test's orchestrator.
51
+ All device inventory resolution is delegated to SDWANDeviceResolver,
52
+ which handles:
53
+ - Test inventory loading (test_inventory.yaml)
54
+ - Schema navigation (sites[].routers[])
55
+ - Variable resolution (hostnames, IPs)
56
+ - Credential injection (IOSXE_USERNAME, IOSXE_PASSWORD)
57
+
58
+ Args:
59
+ data_model: The merged data model from nac-test containing all
60
+ sites.nac.yaml data with resolved variables.
61
+
62
+ Returns:
63
+ List of device dictionaries, each containing:
64
+ - hostname (str): Device hostname
65
+ - host (str): Management IP address for SSH connection
66
+ - os (str): Operating system type (e.g., "iosxe")
67
+ - username (str): SSH username from IOSXE_USERNAME
68
+ - password (str): SSH password from IOSXE_PASSWORD
69
+ - device_id (str): Device identifier (chassis_id)
70
+
71
+ Raises:
72
+ ValueError: If IOSXE_USERNAME or IOSXE_PASSWORD env vars are not set.
73
+ """
74
+ logger.info("SDWANTestBase: Resolving device inventory via SDWANDeviceResolver")
75
+
76
+ # Delegate entirely to the resolver
77
+ # SDWANDeviceResolver handles:
78
+ # 1. Test inventory loading via BaseDeviceResolver._load_inventory()
79
+ # 2. Schema navigation via navigate_to_devices()
80
+ # 3. Credential injection via _inject_credentials() using IOSXE_* env vars
81
+ resolver = SDWANDeviceResolver(data_model)
82
+ return resolver.get_resolved_inventory()
83
+
84
+ def get_device_credentials(self, device: dict[str, Any]) -> dict[str, str | None]:
85
+ """Get SD-WAN edge device SSH credentials from environment variables.
86
+
87
+ SD-WAN D2D tests connect to IOS-XE edge devices (vEdge, ISR, etc.),
88
+ NOT the SDWAN Manager controller. Use IOSXE_* environment variables.
89
+
90
+ Args:
91
+ device: Device dictionary (not used - all devices share credentials).
92
+
93
+ Returns:
94
+ Dictionary containing:
95
+ - username (str | None): SSH username from IOSXE_USERNAME
96
+ - password (str | None): SSH password from IOSXE_PASSWORD
97
+ """
98
+ return {
99
+ "username": os.environ.get("IOSXE_USERNAME"),
100
+ "password": os.environ.get("IOSXE_PASSWORD"),
101
+ }