summaryrefslogtreecommitdiff
path: root/pyvyos
diff options
context:
space:
mode:
authorRoberto Bertó <463349+robertoberto@users.noreply.github.com>2025-09-18 13:08:55 -0300
committerGitHub <noreply@github.com>2025-09-18 13:08:55 -0300
commit9a69a954a086df321640dfa65a2ac0ac35a15095 (patch)
tree3c8e41a08dab6e47e4639e09eebcc37c4549b914 /pyvyos
parent85e4714c53b662c45a1f6ee4b6cb0089ad29cc7b (diff)
parentf25d28ab90a266c03fd966ddabc752088da080d6 (diff)
downloadpyvyos-9a69a954a086df321640dfa65a2ac0ac35a15095.tar.gz
pyvyos-9a69a954a086df321640dfa65a2ac0ac35a15095.zip
Merge pull request #18 from eduardormorais/release/0.3.0
Updating version release 0.3.0
Diffstat (limited to 'pyvyos')
-rw-r--r--pyvyos/__init__.py2
-rw-r--r--pyvyos/device.py278
-rw-r--r--pyvyos/rest.py316
3 files changed, 415 insertions, 181 deletions
diff --git a/pyvyos/__init__.py b/pyvyos/__init__.py
index 7c327e5..44bd563 100644
--- a/pyvyos/__init__.py
+++ b/pyvyos/__init__.py
@@ -1,2 +1,2 @@
from .device import VyDevice
-from .device import ApiResponse \ No newline at end of file
+from .device import ApiResponse
diff --git a/pyvyos/device.py b/pyvyos/device.py
index d168b65..246a913 100644
--- a/pyvyos/device.py
+++ b/pyvyos/device.py
@@ -1,26 +1,10 @@
-import urllib3
-import requests
-import json
-import pprint
-from dataclasses import dataclass
-
-@dataclass
-class ApiResponse:
- """
- Represents an API response.
+import warnings
+from typing import List, Literal
- Attributes:
- status (int): The HTTP status code of the response.
- request (dict): The request payload sent to the API.
- result (dict): The data result of the API response.
- error (str): Any error message in case of a failed response.
- """
- status: int
- request: dict
- result: dict
- error: str
+from .rest import ApiResponse, RestClient
-class VyDevice:
+
+class VyDevice(RestClient):
"""
Represents a device for interacting with the VyOS API.
@@ -51,8 +35,8 @@ class VyDevice:
image_delete(name, url=None, file=None, path=[]): Delete a specific image.
show(path=[]): Show configuration information.
generate(path=[]): Generate configuration based on specified path.
- configure_set(path=[]): Sets configuration based on the specified path. This method is versatile, accepting
- either a single configuration path or a list of configuration paths. This flexibility
+ configure_set(path=[]): Sets configuration based on the specified path. This method is versatile, accepting
+ either a single configuration path or a list of configuration paths. This flexibility
allows for setting both individual and multiple configurations in a single operation.
configure_delete(path=[]): Delete configuration based on specified path.
config_file_save(file=None): Save the configuration to a file.
@@ -61,140 +45,37 @@ class VyDevice:
poweroff(path=["now"]): Power off the device.
"""
- def __init__(self, hostname, apikey, protocol='https', port=443, verify=True, timeout=10):
- """
- Initializes a VyDevice instance.
-
- Args:
- hostname (str): The hostname or IP address of the VyOS device.
- apikey (str): The API key for authentication.
- protocol (str, optional): The protocol to use (default is 'https').
- port (int, optional): The port to use (default is 443).
- verify (bool, optional): Whether to verify SSL certificates (default is True).
- timeout (int, optional): The request timeout in seconds (default is 10).
- """
- self.hostname = hostname
- self.apikey = apikey
- self.protocol = protocol
- self.port = port
- self.verify = verify
- self.timeout = timeout
-
- def _get_url(self, command):
- """
- Get the full URL for a specific API command.
-
- Args:
- command (str): The API command to construct the URL for.
-
- Returns:
- str: The full URL for the API command.
- """
- return f"{self.protocol}://{self.hostname}:{self.port}/{command}"
-
- def _get_payload(self, op, path=[], file=None, url=None, name=None):
- """
- Generate the payload for an API request.
-
- Args:
- op (str): The operation to perform in the API request.
- path (list, optional): The path elements for the API request. This can be a single list for a single
- configuration path or a list of lists for multiple configuration paths.
- file (str, optional): The file to include in the request (default is None).
- url (str, optional): The URL to include in the request (default is None).
- name (str, optional): The name to include in the request (default is None).
-
- Returns:
- dict: The payload for the API request.
- """
- # Adjusting the data structure based on whether path is single or multiple
- if isinstance(path[0], list): # Handling multiple paths
- data = [{'op': op, 'path': p} for p in path]
- else: # Handling a single path
- data = {'op': op, 'path': path}
-
- # Including the optional parameters if provided
- if file:
- if isinstance(data, list): # If data is a list of dicts (multiple paths)
- for d in data:
- d['file'] = file
- else: # If data is a single dict (single path)
- data['file'] = file
-
- if url:
- if isinstance(data, list):
- for d in data:
- d['url'] = url
- else:
- data['url'] = url
-
- if name:
- if isinstance(data, list):
- for d in data:
- d['name'] = name
- else:
- data['name'] = name
-
- payload = {
- 'data': json.dumps(data),
- 'key': self.apikey
- }
-
- return payload
-
-
- def _api_request(self, command, op, path=[], method='POST', file=None, url=None, name=None):
- """
- Make an API request.
-
- Args:
- command (str): The API command to execute.
- op (str): The operation to perform in the API request.
- path (list, optional): The path elements for the API request (default is an empty list).
- method (str, optional): The HTTP method to use for the request (default is 'POST').
- file (str, optional): The file to include in the request (default is None).
- url (str, optional): The URL to include in the request (default is None).
- name (str, optional): The name to include in the request (default is None).
-
- Returns:
- ApiResponse: An ApiResponse object representing the API response.
- """
- url = self._get_url(command)
- payload = self._get_payload(op, path=path, file=file, url=url, name=name)
-
- headers = {}
- error = False
- result = {}
-
- try:
- resp = requests.post(url, verify=self.verify, data=payload, timeout=self.timeout, headers=headers)
-
- if resp.status_code == 200:
- try:
- resp_decoded = resp.json()
-
- if resp_decoded['success'] == True:
- result = resp_decoded['data']
- error = False
- else:
- error = resp_decoded['error']
-
- except json.JSONDecodeError:
- error = 'json decode error'
- else:
- error = 'http error'
-
- status = resp.status_code
-
- except requests.exceptions.ConnectionError as e:
- error = 'connection error: ' + str(e)
- status = 0
-
- # Removing apikey from payload for security reasons
- del(payload['key'])
- return ApiResponse(status=status, request=payload, result=result, error=error)
-
- def retrieve_show_config(self, path=[]):
+ def __init__(
+ self,
+ hostname: str,
+ apikey: str,
+ protocol: Literal["http", "https"] = "https",
+ port: int = 443,
+ verify: bool = True,
+ timeout: int = 10,
+ ):
+ super().__init__(
+ hostname, apikey, protocol, int(port), bool(verify), int(timeout)
+ )
+ self._validate_params()
+
+ def _validate_params(
+ self,
+ ) -> None:
+ """Validação centralizada de parâmetros"""
+ if not isinstance(self.hostname, str) or len(self.hostname) < 3:
+ raise ValueError("Invalid hostname")
+
+ if self.protocol not in ("http", "https"):
+ raise ValueError("The protocol must be http or https")
+
+ if not 1 <= self.port <= 65535:
+ raise ValueError("Port out of valid range (1-65535)")
+
+ if self.timeout and self.timeout < 1:
+ warnings.warn("Timeout below 1s may cause instability", UserWarning)
+
+ def retrieve_show_config(self, path: List = None):
"""
Retrieve and show the device configuration.
@@ -204,9 +85,12 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="retrieve", op='showConfig', path=path, method="POST")
- def retrieve_return_values(self, path=[]):
+ return self._api_request(
+ command="retrieve", op="showConfig", path=path, method="POST"
+ )
+
+ def retrieve_return_values(self, path: List = None):
"""
Retrieve and return specific configuration values.
@@ -216,9 +100,11 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="retrieve", op='returnValues', path=path, method="POST")
+ return self._api_request(
+ command="retrieve", op="returnValues", path=path, method="POST"
+ )
- def reset(self, path=[]):
+ def reset(self, path: List = None):
"""
Reset a specific configuration element.
@@ -228,7 +114,7 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="reset", op='reset', path=path, method="POST")
+ return self._api_request(command="reset", op="reset", path=path, method="POST")
def image_add(self, url=None, file=None, path=[]):
"""
@@ -242,7 +128,7 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="image", op='add', url=url, method="POST")
+ return self._api_request(command="image", op="add", url=url, method="POST")
def image_delete(self, name, url=None, file=None, path=[]):
"""
@@ -257,9 +143,9 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="image", op='delete', name=name, method="POST")
+ return self._api_request(command="image", op="delete", name=name, method="POST")
- def show(self, path=[]):
+ def show(self, path: List = None):
"""
Show configuration information.
@@ -269,9 +155,9 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="show", op='show', path=path, method="POST")
+ return self._api_request(command="show", op="show", path=path, method="POST")
- def generate(self, path=[]):
+ def generate(self, path: List = None):
"""
Generate configuration based on the given path.
@@ -281,9 +167,11 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="generate", op='generate', path=path, method="POST")
+ return self._api_request(
+ command="generate", op="generate", path=path, method="POST"
+ )
- def configure_set(self, path=[]):
+ def configure_set(self, path: List = None):
"""
Set configuration based on the given path.
@@ -293,10 +181,11 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="configure", op='set', path=path, method="POST")
+ return self._api_request(
+ command="configure", op="set", path=path, method="POST"
+ )
-
- def configure_delete(self, path=[]):
+ def configure_delete(self, path: List = None):
"""
Delete configuration based on the given path.
@@ -306,7 +195,22 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="configure", op='delete', path=path, method="POST")
+ return self._api_request(
+ command="configure", op="delete", path=path, method="POST"
+ )
+
+ def configure_multiple_op(self, op_path: List = None):
+ """
+ Set configuration based on the given {operation : path} for multiple operation.
+
+ Args:
+ op_path (list): The path elements for configuration deletion or/and setting.
+ eg: [{'op': 'delete', 'path': [...]}, {'op': 'set', 'path': [...]}]
+
+ Returns:
+ ApiResponse: An ApiResponse object representing the API response.
+ """
+ return self._api_request(command="configure", op="", path=op_path)
def config_file_save(self, file=None):
"""
@@ -318,7 +222,9 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="config-file", op='save', file=file, method="POST")
+ return self._api_request(
+ command="config-file", op="save", file=file, method="POST"
+ )
def config_file_load(self, file=None):
"""
@@ -330,9 +236,11 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="config-file", op='load', file=file, method="POST")
+ return self._api_request(
+ command="config-file", op="load", file=file, method="POST"
+ )
- def reboot(self, path=["now"]):
+ def reboot(self, path: List = None):
"""
Reboot the device.
@@ -342,9 +250,14 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="reboot", op='reboot', path=path, method="POST")
-
- def poweroff(self, path=["now"]):
+ if path is None:
+ path = ["now"]
+
+ return self._api_request(
+ command="reboot", op="reboot", path=path, method="POST"
+ )
+
+ def poweroff(self, path: List = None):
"""
Power off the device.
@@ -354,4 +267,9 @@ class VyDevice:
Returns:
ApiResponse: An ApiResponse object representing the API response.
"""
- return self._api_request(command="poweroff", op='poweroff', path=path, method="POST")
+ if path is None:
+ path = ["now"]
+
+ return self._api_request(
+ command="poweroff", op="poweroff", path=path, method="POST"
+ )
diff --git a/pyvyos/rest.py b/pyvyos/rest.py
new file mode 100644
index 0000000..04c786e
--- /dev/null
+++ b/pyvyos/rest.py
@@ -0,0 +1,316 @@
+import json
+from abc import ABC
+from dataclasses import dataclass
+from typing import Tuple, List, Union, Dict, Any, Optional
+
+import requests
+from requests import Response
+from requests.exceptions import (
+ HTTPError,
+ ConnectionError,
+ Timeout,
+ RequestException,
+ JSONDecodeError,
+)
+
+
+@dataclass
+class ApiResponse:
+ """
+ Represents an API response.
+
+ Attributes:
+ status (int): The HTTP status code of the response.
+ request (dict): The request payload sent to the API.
+ result (dict): The data result of the API response.
+ error (str): Any error message in case of a failed response.
+ """
+
+ status: int
+ request: dict
+ result: dict
+ error: str
+
+
+class RestClient(ABC):
+ """Secure REST client for integration with VyOS device APIs"""
+
+ hostname: str
+ apikey: str
+ protocol: str
+ port: int
+ verify: bool
+ timeout: int
+
+ def __init__(
+ self,
+ hostname: str,
+ apikey: str,
+ protocol: str = "https",
+ port: int = 443,
+ verify: bool = False,
+ timeout: int = 10,
+ ):
+ """
+ Args:
+ hostname: VyOS device address
+ apikey: API key for authentication
+ protocol: Protocol (http/https)
+ port: Access port
+ verify: Verify SSL certificates
+ timeout: Request timeout in seconds
+ """
+ super().__init__()
+ self.hostname = hostname
+ self.apikey = apikey
+ self.protocol = protocol
+ self.port = port
+ self.verify = verify
+ self.timeout = timeout
+
+ def _get_url(self, command):
+ """
+ Get the full URL for a specific API command.
+
+ Args:
+ command (str): The API command to construct the URL for.
+
+ Returns:
+ str: The full URL for the API command.
+ """
+ return f"{self.protocol}://{self.hostname}:{self.port}/{command}"
+
+ def _get_payload(
+ self,
+ op: Optional[str] = None,
+ path: Union[List[str], List[List[str]]] = None,
+ file: Optional[str] = None,
+ url: Optional[str] = None,
+ name: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """
+ Generates API request payload based on specified operations and parameters.
+
+ Parameters:
+ op (str, optional): Operation to perform (e.g., 'set', 'delete')
+ path (Union[List[str], List[List[str]]], optional):
+ Configuration path(s) for the API. Can be:
+ - Single path as string list
+ - Multiple paths as list of string lists
+ file (str, optional): File path for upload
+ url (str, optional): External resource URL
+ name (str, optional): Resource name
+
+ Returns:
+ Dict: Formatted API payload containing:
+ - data: JSON-serialized operations
+ - key: API key
+
+ Raises:
+ ValueError: If required parameters are missing or invalid
+ """
+
+ def _create_operations() -> Union[List[Dict], Dict]:
+ """Creates operation structure based on parameters."""
+ if not op:
+ if not all(isinstance(p, dict) for p in path):
+ raise ValueError(
+ "Path must contain dictionaries when no operation is specified"
+ )
+ return path
+
+ normalized_paths = path or []
+ is_multiple = (
+ isinstance(normalized_paths[0], list) if normalized_paths else False
+ )
+
+ if is_multiple:
+ return [{"op": op, "path": p} for p in normalized_paths]
+ return {"op": op, "path": normalized_paths}
+
+ def _add_optional_params(
+ data: Union[List[Dict], Dict], params: Dict[str, str]
+ ) -> Union[List[Dict], Dict]:
+ """Adds optional parameters to operation structure."""
+ if isinstance(data, list):
+ return [{**item, **params} for item in data]
+ return {**data, **params}
+
+ # Initial validation
+ if not op and not path:
+ raise ValueError(
+ "Must provide either 'op' or pre-formatted operations in 'path'"
+ )
+
+ operations = _create_operations()
+ optional_params = {
+ k: v for k, v in zip(["file", "url", "name"], [file, url, name]) if v
+ }
+
+ if optional_params:
+ operations = _add_optional_params(operations, optional_params)
+
+ return {"data": json.dumps(operations), "key": self.apikey}
+
+ def _api_request(
+ self,
+ command: str,
+ op: Optional[str] = None,
+ path: Optional[List[str]] = None,
+ method: str = "POST",
+ file: Optional[str] = None,
+ resource_url: Optional[str] = None,
+ name: Optional[str] = None,
+ ):
+ """
+ Executes an API request with proper error handling and security measures.
+
+ Parameters:
+ command (str): API endpoint command to execute
+ op (str, optional): Operation type (e.g., 'create', 'update', 'delete')
+ path (List[str], optional): Hierarchical path for resource location
+ method (str): HTTP method (GET/POST/PUT/DELETE). Default: POST
+ file (str, optional): Local file path for file uploads
+ resource_url (str, optional): External resource URL reference
+ name (str, optional): Resource identifier name
+
+ Returns:
+ ApiResponse: Structured response containing:
+ - status: HTTP status code
+ - request: Sanitized request payload
+ - result: Parsed response data
+ - error: Error message if applicable
+
+ Raises:
+ ConnectionError: Network communication failures
+ Timeout: Server response timeout
+ ValueError: Invalid parameter combinations
+ """
+
+ def _prepare_request() -> Dict[str, Any]:
+ """Constructs request components with validation."""
+ if not command:
+ raise ValueError("API command is required")
+ return {
+ "url": self._get_url(command),
+ "method": method,
+ "verify": self.verify,
+ "timeout": self.timeout,
+ "payload": self._get_payload(
+ op, path=path, file=file, url=resource_url, name=name
+ ),
+ "headers": {},
+ }
+
+ # Initialize mutable defaults safely
+ path = path or []
+
+ # Request execution flow
+ request_components = _prepare_request()
+ response = self._execute_request(**request_components)
+ status, result, error = self._validate_response(response)
+
+ # Sanitize sensitive data before returning
+ sanitized_payload = request_components["payload"].copy()
+ sanitized_payload.pop("key", None)
+
+ return ApiResponse(
+ status=status, request=sanitized_payload, result=result, error=error
+ )
+
+ @classmethod
+ def _execute_request(
+ cls,
+ url: str,
+ method: str,
+ verify: bool,
+ timeout: int,
+ payload: Dict,
+ headers: Dict,
+ ) -> requests.Response:
+ """Sends HTTP request with error handling."""
+ try:
+ return requests.request(
+ method=method.upper(),
+ url=url,
+ verify=verify,
+ data=payload,
+ timeout=timeout,
+ headers=headers,
+ )
+ except Timeout:
+ raise Timeout(f"Request timed out after {timeout} seconds")
+ except RequestException as e:
+ raise ConnectionError(f"Network error: {str(e)}")
+
+ @classmethod
+ def _validate_response(
+ cls, resp: Response
+ ) -> Tuple[Optional[int], Dict[str, Any], Union[str, bool]]:
+ """
+ Validates and processes API responses with comprehensive error handling.
+
+ Parameters:
+ resp (Response): HTTP response object from requests library
+
+ Returns:
+ Tuple containing:
+ - status (int | None): HTTP status code
+ - result (dict): Parsed successful response data
+ - error (str | bool): Error message (False indicates success)
+
+ Raises:
+ ValueError: For invalid response structures
+ RuntimeError: For unexpected parsing failures
+
+ Processing Flow:
+ 1. HTTP Status Code Validation
+ 2. Response Body Parsing
+ 3. API Success/Failure Flag Check
+ 4. Error Message Extraction
+ 5. Fallback Error Handling
+ """
+ status: Optional[int] = None
+ result: Dict[str, Any] = {}
+ error: Union[str, bool] = False
+
+ def _validate_schema(response_json: Dict[str, Any]) -> None:
+ """Validates response structure against API contract."""
+ required_keys = {"success", "data", "error"}
+ if isinstance(response_json, dict) and not required_keys.issubset(response_json.keys()):
+ missing = required_keys - response_json.keys()
+ raise ValueError(f"Invalid response structure. Missing keys: {missing}")
+
+ try:
+ # Validate HTTP status code
+ resp.raise_for_status()
+ status = resp.status_code
+
+ # Parse and validate JSON structure
+ resp_decoded = resp.json()
+ _validate_schema(resp_decoded)
+
+ # Process API business logic
+ if resp_decoded["success"]:
+ result = resp_decoded["data"]
+ else:
+ error = f"API Error {status}: {resp_decoded['error']}"
+
+ except JSONDecodeError as exc:
+ error = f"Invalid response format: {str(exc)}"
+ status = resp.status_code if resp is not None and isinstance(resp, Response) else 500
+
+
+ except HTTPError as exc:
+ response = exc.response
+ status = response.status_code if response is not None and isinstance(response, Response) else 500
+ error = f"HTTP Error {status}: {response.text[:200] if response else 'Unknown error'}"
+
+ except ValueError as exc:
+ error = f"Validation Error: {str(exc)}"
+
+ except Exception as exc:
+ error = f"Unexpected error: {str(exc)}"
+ status = 500
+
+ return status, result, error