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
|
||||
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.encode import jwt_decode
|
||||
from hyperglass.external import Webhook, bgptools
|
||||
from hyperglass.api.tasks import process_headers, import_public_key
|
||||
from hyperglass.constants import __version__
|
||||
from hyperglass.exceptions import HyperglassError
|
||||
from hyperglass.configuration import REDIS_CONFIG, params, devices
|
||||
from hyperglass.execution.main import execute
|
||||
from hyperglass.api.models.query import Query
|
||||
from hyperglass.execution.execute import Execute
|
||||
from hyperglass.api.models.cert_import import EncodedRequest
|
||||
|
||||
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
|
||||
# Pass request to execution module
|
||||
starttime = time.time()
|
||||
cache_output = await Execute(query_data).response()
|
||||
cache_output = await execute(query_data)
|
||||
endtime = time.time()
|
||||
elapsedtime = round(endtime - starttime, 4)
|
||||
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
|
||||
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