mirror of
https://github.com/checktheroads/hyperglass
synced 2024-05-11 05:55:08 +00:00
Refactor connection handling to support any transport drivers with a common API
This commit is contained in:
@@ -13,15 +13,16 @@ from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
|||||||
|
|
||||||
# Project
|
# Project
|
||||||
from hyperglass.log import log
|
from hyperglass.log import log
|
||||||
from hyperglass.util import clean_name, process_headers, import_public_key
|
from hyperglass.util import clean_name
|
||||||
from hyperglass.cache import AsyncCache
|
from hyperglass.cache import AsyncCache
|
||||||
from hyperglass.encode import jwt_decode
|
from hyperglass.encode import jwt_decode
|
||||||
from hyperglass.external import Webhook, bgptools
|
from hyperglass.external import Webhook, bgptools
|
||||||
|
from hyperglass.api.tasks import process_headers, import_public_key
|
||||||
from hyperglass.constants import __version__
|
from hyperglass.constants import __version__
|
||||||
from hyperglass.exceptions import HyperglassError
|
from hyperglass.exceptions import HyperglassError
|
||||||
from hyperglass.configuration import REDIS_CONFIG, params, devices
|
from hyperglass.configuration import REDIS_CONFIG, params, devices
|
||||||
|
from hyperglass.execution.main import execute
|
||||||
from hyperglass.api.models.query import Query
|
from hyperglass.api.models.query import Query
|
||||||
from hyperglass.execution.execute import Execute
|
|
||||||
from hyperglass.api.models.cert_import import EncodedRequest
|
from hyperglass.api.models.cert_import import EncodedRequest
|
||||||
|
|
||||||
APP_PATH = os.environ["hyperglass_directory"]
|
APP_PATH = os.environ["hyperglass_directory"]
|
||||||
@@ -118,7 +119,7 @@ async def query(query_data: Query, request: Request, background_tasks: Backgroun
|
|||||||
timestamp = query_data.timestamp
|
timestamp = query_data.timestamp
|
||||||
# Pass request to execution module
|
# Pass request to execution module
|
||||||
starttime = time.time()
|
starttime = time.time()
|
||||||
cache_output = await Execute(query_data).response()
|
cache_output = await execute(query_data)
|
||||||
endtime = time.time()
|
endtime = time.time()
|
||||||
elapsedtime = round(endtime - starttime, 4)
|
elapsedtime = round(endtime - starttime, 4)
|
||||||
log.debug(f"Query {cache_key} took {elapsedtime} seconds to run.")
|
log.debug(f"Query {cache_key} took {elapsedtime} seconds to run.")
|
||||||
|
|||||||
@@ -3,7 +3,3 @@
|
|||||||
Constructs SSH commands or API call parameters based on front end
|
Constructs SSH commands or API call parameters based on front end
|
||||||
input, executes the commands/calls, returns the output to front end.
|
input, executes the commands/calls, returns the output to front end.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Project
|
|
||||||
# flake8: noqa: F401
|
|
||||||
from hyperglass.execution import execute, construct
|
|
||||||
|
|||||||
1
hyperglass/execution/drivers/__init__.py
Normal file
1
hyperglass/execution/drivers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Individual transport driver classes & subclasses."""
|
||||||
65
hyperglass/execution/drivers/_common.py
Normal file
65
hyperglass/execution/drivers/_common.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Base Connection Class."""
|
||||||
|
|
||||||
|
# Standard Library
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
# Project
|
||||||
|
from hyperglass.log import log
|
||||||
|
from hyperglass.parsing.nos import nos_parsers
|
||||||
|
from hyperglass.parsing.common import parsers
|
||||||
|
from hyperglass.api.models.query import Query
|
||||||
|
from hyperglass.execution.construct import Construct
|
||||||
|
from hyperglass.configuration.models.routers import Router
|
||||||
|
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
"""Base transport driver class."""
|
||||||
|
|
||||||
|
def __init__(self, device: Router, query_data: Query) -> None:
|
||||||
|
"""Initialize connection to device."""
|
||||||
|
self.device = device
|
||||||
|
self.query_data = query_data
|
||||||
|
self.query_type = self.query_data.query_type
|
||||||
|
self.query_target = self.query_data.query_target
|
||||||
|
self._query = Construct(device=self.device, query_data=self.query_data)
|
||||||
|
self.query = self._query.queries()
|
||||||
|
|
||||||
|
async def parsed_response(self, output: Iterable) -> str:
|
||||||
|
"""Send output through common parsers."""
|
||||||
|
|
||||||
|
log.debug(f"Pre-parsed responses:\n{output}")
|
||||||
|
parsed = ()
|
||||||
|
response = None
|
||||||
|
|
||||||
|
nos_to_parse = nos_parsers.keys()
|
||||||
|
query_types_to_parse = nos_parsers.get(self.device.nos, {}).keys()
|
||||||
|
|
||||||
|
if not self.device.structured_output:
|
||||||
|
for coro in parsers:
|
||||||
|
for response in output:
|
||||||
|
_output = await coro(commands=self.query, output=response)
|
||||||
|
parsed += (_output,)
|
||||||
|
response = "\n\n".join(parsed)
|
||||||
|
elif (
|
||||||
|
self.device.structured_output
|
||||||
|
and self.device.nos in nos_to_parse
|
||||||
|
and self.query_type not in query_types_to_parse
|
||||||
|
):
|
||||||
|
for coro in parsers:
|
||||||
|
for response in output:
|
||||||
|
_output = await coro(commands=self.query, output=response)
|
||||||
|
parsed += (_output,)
|
||||||
|
response = "\n\n".join(parsed)
|
||||||
|
elif (
|
||||||
|
self.device.structured_output
|
||||||
|
and self.device.nos in nos_to_parse
|
||||||
|
and self.query_type in query_types_to_parse
|
||||||
|
):
|
||||||
|
func = nos_parsers[self.device.nos][self.query_type]
|
||||||
|
response = func(output)
|
||||||
|
|
||||||
|
if response is None:
|
||||||
|
response = "\n\n".join(output)
|
||||||
|
|
||||||
|
log.debug(f"Post-parsed responses:\n{response}")
|
||||||
|
return response
|
||||||
144
hyperglass/execution/drivers/agent.py
Normal file
144
hyperglass/execution/drivers/agent.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Execute validated & constructed query on device.
|
||||||
|
|
||||||
|
Accepts input from front end application, validates the input and
|
||||||
|
returns errors if input is invalid. Passes validated parameters to
|
||||||
|
construct.py, which is used to build & run the Netmiko connections or
|
||||||
|
hyperglass-frr API calls, returns the output back to the front end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Standard Library
|
||||||
|
from ssl import CertificateError
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
# Third Party
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# Project
|
||||||
|
from hyperglass.log import log
|
||||||
|
from hyperglass.util import parse_exception
|
||||||
|
from hyperglass.encode import jwt_decode, jwt_encode
|
||||||
|
from hyperglass.exceptions import RestError, ResponseEmpty
|
||||||
|
from hyperglass.configuration import params
|
||||||
|
from hyperglass.execution.drivers._common import Connection
|
||||||
|
|
||||||
|
|
||||||
|
class AgentConnection(Connection):
|
||||||
|
"""Connect to target device via specified transport.
|
||||||
|
|
||||||
|
scrape_direct() directly connects to devices via SSH
|
||||||
|
|
||||||
|
scrape_proxied() connects to devices via an SSH proxy
|
||||||
|
|
||||||
|
rest() connects to devices via HTTP for RESTful API communication
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def hyperglass_agent(self) -> Iterable: # noqa: C901
|
||||||
|
"""Connect to a device running hyperglass-agent via HTTP."""
|
||||||
|
log.debug(f"Query parameters: {self.query}")
|
||||||
|
|
||||||
|
client_params = {
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"timeout": params.request_timeout,
|
||||||
|
}
|
||||||
|
if self.device.ssl is not None and self.device.ssl.enable:
|
||||||
|
with self.device.ssl.cert.open("r") as file:
|
||||||
|
cert = file.read()
|
||||||
|
if not cert:
|
||||||
|
raise RestError(
|
||||||
|
"SSL Certificate for device {d} has not been imported",
|
||||||
|
level="danger",
|
||||||
|
d=self.device.display_name,
|
||||||
|
)
|
||||||
|
http_protocol = "https"
|
||||||
|
client_params.update({"verify": str(self.device.ssl.cert)})
|
||||||
|
log.debug(
|
||||||
|
(
|
||||||
|
f"Using {str(self.device.ssl.cert)} to validate connection "
|
||||||
|
f"to {self.device.name}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
http_protocol = "http"
|
||||||
|
endpoint = "{protocol}://{address}:{port}/query/".format(
|
||||||
|
protocol=http_protocol, address=self.device.address, port=self.device.port
|
||||||
|
)
|
||||||
|
|
||||||
|
log.debug(f"URL endpoint: {endpoint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(**client_params) as http_client:
|
||||||
|
responses = ()
|
||||||
|
|
||||||
|
for query in self.query:
|
||||||
|
encoded_query = await jwt_encode(
|
||||||
|
payload=query,
|
||||||
|
secret=self.device.credential.password.get_secret_value(),
|
||||||
|
duration=params.request_timeout,
|
||||||
|
)
|
||||||
|
log.debug(f"Encoded JWT: {encoded_query}")
|
||||||
|
|
||||||
|
raw_response = await http_client.post(
|
||||||
|
endpoint, json={"encoded": encoded_query}
|
||||||
|
)
|
||||||
|
log.debug(f"HTTP status code: {raw_response.status_code}")
|
||||||
|
|
||||||
|
raw = raw_response.text
|
||||||
|
log.debug(f"Raw Response: {raw}")
|
||||||
|
|
||||||
|
if raw_response.status_code == 200:
|
||||||
|
decoded = await jwt_decode(
|
||||||
|
payload=raw_response.json()["encoded"],
|
||||||
|
secret=self.device.credential.password.get_secret_value(),
|
||||||
|
)
|
||||||
|
log.debug(f"Decoded Response: {decoded}")
|
||||||
|
responses += (decoded,)
|
||||||
|
elif raw_response.status_code == 204:
|
||||||
|
raise ResponseEmpty(
|
||||||
|
params.messages.no_output,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.error(raw_response.text)
|
||||||
|
|
||||||
|
except httpx.exceptions.HTTPError as rest_error:
|
||||||
|
msg = parse_exception(rest_error)
|
||||||
|
log.error(f"Error connecting to device {self.device.name}: {msg}")
|
||||||
|
raise RestError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
error=msg,
|
||||||
|
)
|
||||||
|
except OSError as ose:
|
||||||
|
log.critical(str(ose))
|
||||||
|
raise RestError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
error="System error",
|
||||||
|
)
|
||||||
|
except CertificateError as cert_error:
|
||||||
|
log.critical(str(cert_error))
|
||||||
|
msg = parse_exception(cert_error)
|
||||||
|
raise RestError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
error=f"{msg}: {cert_error}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if raw_response.status_code != 200:
|
||||||
|
log.error(f"Response code is {raw_response.status_code}")
|
||||||
|
raise RestError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
error=params.messages.general,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not responses:
|
||||||
|
log.error(f"No response from device {self.device.name}")
|
||||||
|
raise RestError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
error=params.messages.no_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
return responses
|
||||||
141
hyperglass/execution/drivers/ssh.py
Normal file
141
hyperglass/execution/drivers/ssh.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Execute validated & constructed query on device.
|
||||||
|
|
||||||
|
Accepts input from front end application, validates the input and
|
||||||
|
returns errors if input is invalid. Passes validated parameters to
|
||||||
|
construct.py, which is used to build & run the Netmiko connections or
|
||||||
|
hyperglass-frr API calls, returns the output back to the front end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Standard Library
|
||||||
|
import math
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
# Third Party
|
||||||
|
from netmiko import (
|
||||||
|
ConnectHandler,
|
||||||
|
NetmikoAuthError,
|
||||||
|
NetmikoTimeoutError,
|
||||||
|
NetMikoTimeoutException,
|
||||||
|
NetMikoAuthenticationException,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Project
|
||||||
|
from hyperglass.log import log
|
||||||
|
from hyperglass.exceptions import AuthError, ScrapeError, DeviceTimeout
|
||||||
|
from hyperglass.configuration import params
|
||||||
|
from hyperglass.compat._sshtunnel import BaseSSHTunnelForwarderError, open_tunnel
|
||||||
|
from hyperglass.execution.drivers._common import Connection
|
||||||
|
|
||||||
|
|
||||||
|
class SSHConnection(Connection):
|
||||||
|
"""Connect to target device via specified transport.
|
||||||
|
|
||||||
|
scrape_direct() directly connects to devices via SSH
|
||||||
|
|
||||||
|
scrape_proxied() connects to devices via an SSH proxy
|
||||||
|
|
||||||
|
rest() connects to devices via HTTP for RESTful API communication
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setup_proxy(self) -> Callable:
|
||||||
|
"""Return a preconfigured sshtunnel.SSHTunnelForwarder instance."""
|
||||||
|
|
||||||
|
proxy = self.device.proxy
|
||||||
|
|
||||||
|
def opener():
|
||||||
|
"""Set up an SSH tunnel according to a device's configuration."""
|
||||||
|
try:
|
||||||
|
return open_tunnel(
|
||||||
|
proxy.address,
|
||||||
|
proxy.port,
|
||||||
|
ssh_username=proxy.credential.username,
|
||||||
|
ssh_password=proxy.credential.password.get_secret_value(),
|
||||||
|
remote_bind_address=(self.device.address, self.device.port),
|
||||||
|
local_bind_address=("localhost", 0),
|
||||||
|
skip_tunnel_checkup=False,
|
||||||
|
gateway_timeout=params.request_timeout - 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
except BaseSSHTunnelForwarderError as scrape_proxy_error:
|
||||||
|
log.error(
|
||||||
|
f"Error connecting to device {self.device.name} via "
|
||||||
|
f"proxy {proxy.name}"
|
||||||
|
)
|
||||||
|
raise ScrapeError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
proxy=proxy.name,
|
||||||
|
error=str(scrape_proxy_error),
|
||||||
|
)
|
||||||
|
|
||||||
|
return opener
|
||||||
|
|
||||||
|
async def netmiko(self, host: str = None, port: int = None) -> Iterable:
|
||||||
|
"""Connect directly to a device.
|
||||||
|
|
||||||
|
Directly connects to the router via Netmiko library, returns the
|
||||||
|
command output.
|
||||||
|
"""
|
||||||
|
if host is not None:
|
||||||
|
log.debug(
|
||||||
|
"Connecting to {} via proxy {} [{}]",
|
||||||
|
self.device.name,
|
||||||
|
self.device.proxy.name,
|
||||||
|
f"{host}:{port}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.debug("Connecting directly to {}", self.device.name)
|
||||||
|
|
||||||
|
netmiko_args = {
|
||||||
|
"host": host or self.device.address,
|
||||||
|
"port": port or self.device.port,
|
||||||
|
"device_type": self.device.nos,
|
||||||
|
"username": self.device.credential.username,
|
||||||
|
"password": self.device.credential.password.get_secret_value(),
|
||||||
|
"global_delay_factor": params.netmiko_delay_factor,
|
||||||
|
"timeout": math.floor(params.request_timeout * 1.25),
|
||||||
|
"session_timeout": math.ceil(params.request_timeout - 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
nm_connect_direct = ConnectHandler(**netmiko_args)
|
||||||
|
|
||||||
|
responses = ()
|
||||||
|
|
||||||
|
for query in self.query:
|
||||||
|
raw = nm_connect_direct.send_command(query)
|
||||||
|
responses += (raw,)
|
||||||
|
log.debug(f'Raw response for command "{query}":\n{raw}')
|
||||||
|
|
||||||
|
nm_connect_direct.disconnect()
|
||||||
|
|
||||||
|
except (NetMikoTimeoutException, NetmikoTimeoutError) as scrape_error:
|
||||||
|
log.error(str(scrape_error))
|
||||||
|
raise DeviceTimeout(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
proxy=None,
|
||||||
|
error=params.messages.request_timeout,
|
||||||
|
)
|
||||||
|
except (NetMikoAuthenticationException, NetmikoAuthError) as auth_error:
|
||||||
|
log.error(
|
||||||
|
"Error authenticating to device {loc}: {e}",
|
||||||
|
loc=self.device.name,
|
||||||
|
e=str(auth_error),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise AuthError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
proxy=None,
|
||||||
|
error=params.messages.authentication_error,
|
||||||
|
)
|
||||||
|
if not responses:
|
||||||
|
raise ScrapeError(
|
||||||
|
params.messages.connection_error,
|
||||||
|
device_name=self.device.display_name,
|
||||||
|
proxy=None,
|
||||||
|
error=params.messages.no_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
return responses
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
"""Execute validated & constructed query on device.
|
|
||||||
|
|
||||||
Accepts input from front end application, validates the input and
|
|
||||||
returns errors if input is invalid. Passes validated parameters to
|
|
||||||
construct.py, which is used to build & run the Netmiko connections or
|
|
||||||
hyperglass-frr API calls, returns the output back to the front end.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Standard Library
|
|
||||||
import math
|
|
||||||
import signal
|
|
||||||
from ssl import CertificateError
|
|
||||||
|
|
||||||
# Third Party
|
|
||||||
import httpx
|
|
||||||
from netmiko import (
|
|
||||||
ConnectHandler,
|
|
||||||
NetmikoAuthError,
|
|
||||||
NetmikoTimeoutError,
|
|
||||||
NetMikoTimeoutException,
|
|
||||||
NetMikoAuthenticationException,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Project
|
|
||||||
from hyperglass.log import log
|
|
||||||
from hyperglass.util import validate_nos, parse_exception
|
|
||||||
from hyperglass.compat import _sshtunnel as sshtunnel
|
|
||||||
from hyperglass.encode import jwt_decode, jwt_encode
|
|
||||||
from hyperglass.exceptions import (
|
|
||||||
AuthError,
|
|
||||||
RestError,
|
|
||||||
ConfigError,
|
|
||||||
ScrapeError,
|
|
||||||
DeviceTimeout,
|
|
||||||
ResponseEmpty,
|
|
||||||
)
|
|
||||||
from hyperglass.parsing.nos import nos_parsers
|
|
||||||
from hyperglass.configuration import params, devices
|
|
||||||
from hyperglass.parsing.common import parsers
|
|
||||||
from hyperglass.execution.construct import Construct
|
|
||||||
|
|
||||||
|
|
||||||
class Connect:
|
|
||||||
"""Connect to target device via specified transport.
|
|
||||||
|
|
||||||
scrape_direct() directly connects to devices via SSH
|
|
||||||
|
|
||||||
scrape_proxied() connects to devices via an SSH proxy
|
|
||||||
|
|
||||||
rest() connects to devices via HTTP for RESTful API communication
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, device, query_data, transport):
|
|
||||||
"""Initialize connection to device.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
device {object} -- Matched device object
|
|
||||||
query_data {object} -- Query object
|
|
||||||
transport {str} -- 'scrape' or 'rest'
|
|
||||||
"""
|
|
||||||
self.device = device
|
|
||||||
self.query_data = query_data
|
|
||||||
self.query_type = self.query_data.query_type
|
|
||||||
self.query_target = self.query_data.query_target
|
|
||||||
self.transport = transport
|
|
||||||
self._query = Construct(device=self.device, query_data=self.query_data)
|
|
||||||
self.query = self._query.queries()
|
|
||||||
self.netmiko_args = {
|
|
||||||
"global_delay_factor": params.netmiko_delay_factor,
|
|
||||||
"timeout": math.floor(params.request_timeout * 1.25),
|
|
||||||
"session_timeout": math.ceil(params.request_timeout - 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def parsed_response(self, output):
|
|
||||||
"""Send output through common parsers.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
output {str} -- Raw output
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{str} -- Parsed output
|
|
||||||
"""
|
|
||||||
log.debug(f"Pre-parsed responses:\n{output}")
|
|
||||||
parsed = ()
|
|
||||||
response = None
|
|
||||||
|
|
||||||
nos_to_parse = nos_parsers.keys()
|
|
||||||
query_type_to_parse = nos_parsers[self.device.nos].keys()
|
|
||||||
|
|
||||||
if not self.device.structured_output:
|
|
||||||
for coro in parsers:
|
|
||||||
for response in output:
|
|
||||||
_output = await coro(commands=self.query, output=response)
|
|
||||||
parsed += (_output,)
|
|
||||||
response = "\n\n".join(parsed)
|
|
||||||
elif (
|
|
||||||
self.device.structured_output
|
|
||||||
and self.device.nos in nos_to_parse
|
|
||||||
and self.query_type not in query_type_to_parse
|
|
||||||
):
|
|
||||||
for coro in parsers:
|
|
||||||
for response in output:
|
|
||||||
_output = await coro(commands=self.query, output=response)
|
|
||||||
parsed += (_output,)
|
|
||||||
response = "\n\n".join(parsed)
|
|
||||||
elif (
|
|
||||||
self.device.structured_output
|
|
||||||
and self.device.nos in nos_to_parse
|
|
||||||
and self.query_type in query_type_to_parse
|
|
||||||
):
|
|
||||||
func = nos_parsers[self.device.nos][self.query_type]
|
|
||||||
response = func(output)
|
|
||||||
|
|
||||||
if response is None:
|
|
||||||
response = "\n\n".join(output)
|
|
||||||
|
|
||||||
log.debug(f"Post-parsed responses:\n{response}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
async def scrape_proxied(self):
|
|
||||||
"""Connect to a device via an SSH proxy.
|
|
||||||
|
|
||||||
Connects to the router via Netmiko library via the sshtunnel
|
|
||||||
library, returns the command output.
|
|
||||||
"""
|
|
||||||
log.debug(f"Connecting to {self.device.proxy} via sshtunnel library...")
|
|
||||||
try:
|
|
||||||
tunnel = sshtunnel.open_tunnel(
|
|
||||||
self.device.proxy.address,
|
|
||||||
self.device.proxy.port,
|
|
||||||
ssh_username=self.device.proxy.credential.username,
|
|
||||||
ssh_password=self.device.proxy.credential.password.get_secret_value(),
|
|
||||||
remote_bind_address=(self.device.address, self.device.port),
|
|
||||||
local_bind_address=("localhost", 0),
|
|
||||||
skip_tunnel_checkup=False,
|
|
||||||
gateway_timeout=params.request_timeout - 2,
|
|
||||||
)
|
|
||||||
except sshtunnel.BaseSSHTunnelForwarderError as scrape_proxy_error:
|
|
||||||
log.error(
|
|
||||||
f"Error connecting to device {self.device.name} via "
|
|
||||||
f"proxy {self.device.proxy.name}"
|
|
||||||
)
|
|
||||||
raise ScrapeError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=self.device.proxy.name,
|
|
||||||
error=str(scrape_proxy_error),
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_timeout(*args, **kwargs):
|
|
||||||
tunnel.close()
|
|
||||||
raise DeviceTimeout(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=self.device.proxy.name,
|
|
||||||
error=params.messages.request_timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGALRM, handle_timeout)
|
|
||||||
signal.alarm(params.request_timeout - 1)
|
|
||||||
|
|
||||||
with tunnel:
|
|
||||||
log.debug(
|
|
||||||
"Established tunnel with {d}. Local bind port: {p}",
|
|
||||||
d=self.device.proxy,
|
|
||||||
p=tunnel.local_bind_port,
|
|
||||||
)
|
|
||||||
scrape_host = {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": tunnel.local_bind_port,
|
|
||||||
"device_type": self.device.nos,
|
|
||||||
"username": self.device.credential.username,
|
|
||||||
"password": self.device.credential.password.get_secret_value(),
|
|
||||||
**self.netmiko_args,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
log.debug("Connecting to {loc}...", loc=self.device.name)
|
|
||||||
|
|
||||||
nm_connect_direct = ConnectHandler(**scrape_host)
|
|
||||||
|
|
||||||
responses = ()
|
|
||||||
for query in self.query:
|
|
||||||
raw = nm_connect_direct.send_command(query)
|
|
||||||
responses += (raw,)
|
|
||||||
log.debug(f'Raw response for command "{query}":\n{raw}')
|
|
||||||
|
|
||||||
nm_connect_direct.disconnect()
|
|
||||||
|
|
||||||
except (NetMikoTimeoutException, NetmikoTimeoutError) as scrape_error:
|
|
||||||
log.error(
|
|
||||||
"Timeout connecting to device {loc}: {e}",
|
|
||||||
loc=self.device.name,
|
|
||||||
e=str(scrape_error),
|
|
||||||
)
|
|
||||||
raise DeviceTimeout(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=self.device.proxy.name,
|
|
||||||
error=params.messages.request_timeout,
|
|
||||||
)
|
|
||||||
except (NetMikoAuthenticationException, NetmikoAuthError) as auth_error:
|
|
||||||
log.error(
|
|
||||||
"Error authenticating to device {loc}: {e}",
|
|
||||||
loc=self.device.name,
|
|
||||||
e=str(auth_error),
|
|
||||||
)
|
|
||||||
raise AuthError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=self.device.proxy.name,
|
|
||||||
error=params.messages.authentication_error,
|
|
||||||
) from None
|
|
||||||
except sshtunnel.BaseSSHTunnelForwarderError:
|
|
||||||
raise ScrapeError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=self.device.proxy.name,
|
|
||||||
error=params.messages.general,
|
|
||||||
)
|
|
||||||
if not responses:
|
|
||||||
raise ScrapeError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=None,
|
|
||||||
error=params.messages.no_response,
|
|
||||||
)
|
|
||||||
signal.alarm(0)
|
|
||||||
return await self.parsed_response(responses)
|
|
||||||
|
|
||||||
async def scrape_direct(self):
|
|
||||||
"""Connect directly to a device.
|
|
||||||
|
|
||||||
Directly connects to the router via Netmiko library, returns the
|
|
||||||
command output.
|
|
||||||
"""
|
|
||||||
log.debug(f"Connecting directly to {self.device.name}...")
|
|
||||||
|
|
||||||
scrape_host = {
|
|
||||||
"host": self.device.address,
|
|
||||||
"port": self.device.port,
|
|
||||||
"device_type": self.device.nos,
|
|
||||||
"username": self.device.credential.username,
|
|
||||||
"password": self.device.credential.password.get_secret_value(),
|
|
||||||
**self.netmiko_args,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
nm_connect_direct = ConnectHandler(**scrape_host)
|
|
||||||
|
|
||||||
def handle_timeout(*args, **kwargs):
|
|
||||||
nm_connect_direct.disconnect()
|
|
||||||
raise DeviceTimeout(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
error=params.messages.request_timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGALRM, handle_timeout)
|
|
||||||
signal.alarm(params.request_timeout - 1)
|
|
||||||
|
|
||||||
responses = ()
|
|
||||||
|
|
||||||
for query in self.query:
|
|
||||||
raw = nm_connect_direct.send_command(query)
|
|
||||||
responses += (raw,)
|
|
||||||
log.debug(f'Raw response for command "{query}":\n{raw}')
|
|
||||||
|
|
||||||
nm_connect_direct.disconnect()
|
|
||||||
|
|
||||||
except (NetMikoTimeoutException, NetmikoTimeoutError) as scrape_error:
|
|
||||||
log.error(str(scrape_error))
|
|
||||||
raise DeviceTimeout(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=None,
|
|
||||||
error=params.messages.request_timeout,
|
|
||||||
)
|
|
||||||
except (NetMikoAuthenticationException, NetmikoAuthError) as auth_error:
|
|
||||||
log.error(
|
|
||||||
"Error authenticating to device {loc}: {e}",
|
|
||||||
loc=self.device.name,
|
|
||||||
e=str(auth_error),
|
|
||||||
)
|
|
||||||
|
|
||||||
raise AuthError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=None,
|
|
||||||
error=params.messages.authentication_error,
|
|
||||||
)
|
|
||||||
if not responses:
|
|
||||||
raise ScrapeError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
proxy=None,
|
|
||||||
error=params.messages.no_response,
|
|
||||||
)
|
|
||||||
signal.alarm(0)
|
|
||||||
return await self.parsed_response(responses)
|
|
||||||
|
|
||||||
async def rest(self): # noqa: C901
|
|
||||||
"""Connect to a device running hyperglass-agent via HTTP."""
|
|
||||||
log.debug(f"Query parameters: {self.query}")
|
|
||||||
|
|
||||||
client_params = {
|
|
||||||
"headers": {"Content-Type": "application/json"},
|
|
||||||
"timeout": params.request_timeout,
|
|
||||||
}
|
|
||||||
if self.device.ssl is not None and self.device.ssl.enable:
|
|
||||||
with self.device.ssl.cert.open("r") as file:
|
|
||||||
cert = file.read()
|
|
||||||
if not cert:
|
|
||||||
raise RestError(
|
|
||||||
"SSL Certificate for device {d} has not been imported",
|
|
||||||
level="danger",
|
|
||||||
d=self.device.display_name,
|
|
||||||
)
|
|
||||||
http_protocol = "https"
|
|
||||||
client_params.update({"verify": str(self.device.ssl.cert)})
|
|
||||||
log.debug(
|
|
||||||
(
|
|
||||||
f"Using {str(self.device.ssl.cert)} to validate connection "
|
|
||||||
f"to {self.device.name}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
http_protocol = "http"
|
|
||||||
endpoint = "{protocol}://{address}:{port}/query/".format(
|
|
||||||
protocol=http_protocol, address=self.device.address, port=self.device.port
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug(f"URL endpoint: {endpoint}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(**client_params) as http_client:
|
|
||||||
responses = ()
|
|
||||||
|
|
||||||
for query in self.query:
|
|
||||||
encoded_query = await jwt_encode(
|
|
||||||
payload=query,
|
|
||||||
secret=self.device.credential.password.get_secret_value(),
|
|
||||||
duration=params.request_timeout,
|
|
||||||
)
|
|
||||||
log.debug(f"Encoded JWT: {encoded_query}")
|
|
||||||
|
|
||||||
raw_response = await http_client.post(
|
|
||||||
endpoint, json={"encoded": encoded_query}
|
|
||||||
)
|
|
||||||
log.debug(f"HTTP status code: {raw_response.status_code}")
|
|
||||||
|
|
||||||
raw = raw_response.text
|
|
||||||
log.debug(f"Raw Response: {raw}")
|
|
||||||
|
|
||||||
if raw_response.status_code == 200:
|
|
||||||
decoded = await jwt_decode(
|
|
||||||
payload=raw_response.json()["encoded"],
|
|
||||||
secret=self.device.credential.password.get_secret_value(),
|
|
||||||
)
|
|
||||||
log.debug(f"Decoded Response: {decoded}")
|
|
||||||
responses += (decoded,)
|
|
||||||
elif raw_response.status_code == 204:
|
|
||||||
raise ResponseEmpty(
|
|
||||||
params.messages.no_output,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
log.error(raw_response.text)
|
|
||||||
|
|
||||||
except httpx.exceptions.HTTPError as rest_error:
|
|
||||||
msg = parse_exception(rest_error)
|
|
||||||
log.error(f"Error connecting to device {self.device.name}: {msg}")
|
|
||||||
raise RestError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
error=msg,
|
|
||||||
)
|
|
||||||
except OSError as ose:
|
|
||||||
log.critical(str(ose))
|
|
||||||
raise RestError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
error="System error",
|
|
||||||
)
|
|
||||||
except CertificateError as cert_error:
|
|
||||||
log.critical(str(cert_error))
|
|
||||||
msg = parse_exception(cert_error)
|
|
||||||
raise RestError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
error=f"{msg}: {cert_error}",
|
|
||||||
)
|
|
||||||
|
|
||||||
if raw_response.status_code != 200:
|
|
||||||
log.error(f"Response code is {raw_response.status_code}")
|
|
||||||
raise RestError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
error=params.messages.general,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not responses:
|
|
||||||
log.error(f"No response from device {self.device.name}")
|
|
||||||
raise RestError(
|
|
||||||
params.messages.connection_error,
|
|
||||||
device_name=self.device.display_name,
|
|
||||||
error=params.messages.no_response,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await self.parsed_response(responses)
|
|
||||||
|
|
||||||
|
|
||||||
class Execute:
|
|
||||||
"""Perform query execution on device.
|
|
||||||
|
|
||||||
Ingests raw user input, performs validation of target input, pulls
|
|
||||||
all configuration variables for the input router and connects to the
|
|
||||||
selected device to execute the query.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, lg_data):
|
|
||||||
"""Initialize execution object.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
lg_data {object} -- Validated query object
|
|
||||||
"""
|
|
||||||
self.query_data = lg_data
|
|
||||||
self.query_location = self.query_data.query_location
|
|
||||||
self.query_type = self.query_data.query_type
|
|
||||||
self.query_target = self.query_data.query_target
|
|
||||||
|
|
||||||
async def response(self):
|
|
||||||
"""Initiate query validation and execution."""
|
|
||||||
device = getattr(devices, self.query_location)
|
|
||||||
|
|
||||||
log.debug(f"Received query for {self.query_data}")
|
|
||||||
log.debug(f"Matched device config: {device}")
|
|
||||||
|
|
||||||
supported, transport = validate_nos(device.nos)
|
|
||||||
|
|
||||||
connect = None
|
|
||||||
output = params.messages.general
|
|
||||||
connect = Connect(device, self.query_data, transport)
|
|
||||||
|
|
||||||
if supported and transport == "rest":
|
|
||||||
output = await connect.rest()
|
|
||||||
|
|
||||||
elif supported and transport == "scrape":
|
|
||||||
if device.proxy:
|
|
||||||
output = await connect.scrape_proxied()
|
|
||||||
else:
|
|
||||||
output = await connect.scrape_direct()
|
|
||||||
else:
|
|
||||||
raise ConfigError('"{nos}" is not supported.', nos=device.nos)
|
|
||||||
|
|
||||||
if output == "" or output == "\n":
|
|
||||||
raise ResponseEmpty(
|
|
||||||
params.messages.no_output, device_name=device.display_name
|
|
||||||
)
|
|
||||||
|
|
||||||
log.debug(f"Output for query: {self.query_data.json()}:\n{repr(output)}")
|
|
||||||
|
|
||||||
return output
|
|
||||||
80
hyperglass/execution/main.py
Normal file
80
hyperglass/execution/main.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""Execute validated & constructed query on device.
|
||||||
|
|
||||||
|
Accepts input from front end application, validates the input and
|
||||||
|
returns errors if input is invalid. Passes validated parameters to
|
||||||
|
construct.py, which is used to build & run the Netmiko connections or
|
||||||
|
hyperglass-frr API calls, returns the output back to the front end.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Standard Library
|
||||||
|
import signal
|
||||||
|
from typing import Any, Dict, Union, Callable
|
||||||
|
|
||||||
|
# Project
|
||||||
|
from hyperglass.log import log
|
||||||
|
from hyperglass.util import validate_nos
|
||||||
|
from hyperglass.exceptions import DeviceTimeout, ResponseEmpty
|
||||||
|
from hyperglass.configuration import params, devices
|
||||||
|
from hyperglass.api.models.query import Query
|
||||||
|
from hyperglass.execution.drivers.ssh import SSHConnection
|
||||||
|
from hyperglass.execution.drivers.agent import AgentConnection
|
||||||
|
|
||||||
|
|
||||||
|
def handle_timeout(**exc_args: Any) -> Callable:
|
||||||
|
"""Return a function signal can use to raise a timeout exception."""
|
||||||
|
|
||||||
|
def handler(*args: Any, **kwargs: Any) -> None:
|
||||||
|
raise DeviceTimeout(**exc_args)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
async def execute(query: Query) -> Union[str, Dict]:
|
||||||
|
"""Initiate query validation and execution."""
|
||||||
|
|
||||||
|
output = params.messages.general
|
||||||
|
device = getattr(devices, query.query_location)
|
||||||
|
|
||||||
|
log.debug(f"Received query for {query}")
|
||||||
|
log.debug(f"Matched device config: {device}")
|
||||||
|
|
||||||
|
supported, driver_name = validate_nos(device.nos)
|
||||||
|
|
||||||
|
driver_map = {
|
||||||
|
"scrapli": SSHConnection,
|
||||||
|
"netmiko": SSHConnection,
|
||||||
|
"hyperglass_agent": AgentConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped_driver = driver_map.get(driver_name, SSHConnection)
|
||||||
|
driver = mapped_driver(device, query)
|
||||||
|
connector = getattr(driver, driver_name)
|
||||||
|
|
||||||
|
timeout_args = {
|
||||||
|
"unformatted_msg": params.messages.connection_error,
|
||||||
|
"device_name": device.display_name,
|
||||||
|
"error": params.messages.request_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.proxy:
|
||||||
|
timeout_args["proxy"] = device.proxy.name
|
||||||
|
|
||||||
|
signal.signal(signal.SIGALRM, handle_timeout(**timeout_args))
|
||||||
|
signal.alarm(params.request_timeout - 1)
|
||||||
|
|
||||||
|
if device.proxy:
|
||||||
|
proxy = driver.setup_proxy()
|
||||||
|
with proxy() as tunnel:
|
||||||
|
response = await connector(tunnel.local_bind_host, tunnel.local_bind_port)
|
||||||
|
else:
|
||||||
|
response = await connector()
|
||||||
|
|
||||||
|
output = await driver.parsed_response(response)
|
||||||
|
|
||||||
|
if output == "" or output == "\n":
|
||||||
|
raise ResponseEmpty(params.messages.no_output, device_name=device.display_name)
|
||||||
|
|
||||||
|
log.debug(f"Output for query: {query.json()}:\n{repr(output)}")
|
||||||
|
signal.alarm(0)
|
||||||
|
|
||||||
|
return output
|
||||||
Reference in New Issue
Block a user