From fe61a7e90f4c1debf0485c745ceb878b4b3a6291 Mon Sep 17 00:00:00 2001 From: checktheroads Date: Sat, 21 Mar 2020 01:44:38 -0700 Subject: [PATCH] enable ssl certificate import for hyperglass-agent --- hyperglass/api/__init__.py | 18 +++++- hyperglass/api/models/cert_import.py | 14 +++++ hyperglass/api/routes.py | 47 +++++++++++++- hyperglass/configuration/models/_utils.py | 62 +++++++++++++----- .../configuration/models/credentials.py | 3 +- hyperglass/configuration/models/networks.py | 5 +- hyperglass/configuration/models/proxies.py | 3 +- hyperglass/configuration/models/routers.py | 33 +++++++--- hyperglass/{execution => }/encode.py | 0 hyperglass/execution/execute.py | 10 ++- hyperglass/util.py | 63 +++++++++++++++++++ 11 files changed, 224 insertions(+), 34 deletions(-) create mode 100644 hyperglass/api/models/cert_import.py rename hyperglass/{execution => }/encode.py (100%) diff --git a/hyperglass/api/__init__.py b/hyperglass/api/__init__.py index 28d2d56..166e292 100644 --- a/hyperglass/api/__init__.py +++ b/hyperglass/api/__init__.py @@ -15,11 +15,11 @@ from starlette.middleware.cors import CORSMiddleware # Project from hyperglass.util import log -from hyperglass.constants import __version__ +from hyperglass.constants import TRANSPORT_REST, __version__ from hyperglass.api.events import on_startup, on_shutdown -from hyperglass.api.routes import docs, query, queries, routers +from hyperglass.api.routes import docs, query, queries, routers, import_certificate from hyperglass.exceptions import HyperglassError -from hyperglass.configuration import URL_DEV, STATIC_PATH, params +from hyperglass.configuration import URL_DEV, STATIC_PATH, params, devices from hyperglass.api.error_handlers import ( app_handler, http_handler, @@ -200,6 +200,18 @@ app.add_api_route( response_class=UJSONResponse, ) +# Enable certificate import route only if a device using +# hyperglass-agent is defined. +for device in devices.routers: + if device.nos in TRANSPORT_REST: + app.add_api_route( + path="/api/import-agent-certificate/", + endpoint=import_certificate, + methods=["POST"], + include_in_schema=False, + ) + break + if params.docs.enable: app.add_api_route(path=params.docs.uri, endpoint=docs, include_in_schema=False) app.openapi = _custom_openapi diff --git a/hyperglass/api/models/cert_import.py b/hyperglass/api/models/cert_import.py new file mode 100644 index 0000000..be4dc99 --- /dev/null +++ b/hyperglass/api/models/cert_import.py @@ -0,0 +1,14 @@ +"""hyperglass-agent certificate import models.""" +# Standard Library +from typing import Union + +# Third Party +from pydantic import BaseModel, StrictStr + +# Project +from hyperglass.configuration.models._utils import StrictBytes + + +class EncodedRequest(BaseModel): + device: StrictStr + encoded: Union[StrictStr, StrictBytes] diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 1279ddf..2bb4add 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -1,6 +1,7 @@ """API Routes.""" # Standard Library +import os import time # Third Party @@ -10,14 +11,18 @@ from starlette.requests import Request from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html # Project -from hyperglass.util import log +from hyperglass.util import log, clean_name, import_public_key +from hyperglass.encode import jwt_decode from hyperglass.exceptions import HyperglassError from hyperglass.configuration import REDIS_CONFIG, params, devices from hyperglass.api.models.query import Query from hyperglass.execution.execute import Execute +from hyperglass.api.models.cert_import import EncodedRequest Cache = aredis.StrictRedis(db=params.cache.database, **REDIS_CONFIG) +APP_PATH = os.environ["hyperglass_directory"] + async def query(query_data: Query, request: Request): """Ingest request data pass it to the backend application to perform the query.""" @@ -60,6 +65,46 @@ async def query(query_data: Query, request: Request): return {"output": cache_response, "level": "success", "keywords": []} +async def import_certificate(encoded_request: EncodedRequest): + """Import a certificate from hyperglass-agent.""" + + # Try to match the requested device name with configured devices + matched_device = None + requested_device_name = clean_name(encoded_request.device) + for device in devices.routers: + if device.name == requested_device_name: + matched_device = device + break + + if matched_device is None: + raise HTTPException( + detail=f"Device {str(encoded_request.device)} not found", status_code=404 + ) + + try: + # Decode JSON Web Token + decoded_request = await jwt_decode( + payload=encoded_request.encoded, + secret=matched_device.credential.password.get_secret_value(), + ) + except HyperglassError as decode_error: + raise HTTPException(detail=str(decode_error), status_code=401) + + try: + # Write certificate to file + import_public_key( + app_path=APP_PATH, device_name=device.name, keystring=decoded_request + ) + except RuntimeError as import_error: + raise HyperglassError(str(import_error), level="danger") + + return { + "output": f"Added public key for {encoded_request.device}", + "level": "success", + "keywords": [encoded_request.device], + } + + async def docs(): """Serve custom docs.""" if params.docs.enable: diff --git a/hyperglass/configuration/models/_utils.py b/hyperglass/configuration/models/_utils.py index 47cc4b8..4d01394 100644 --- a/hyperglass/configuration/models/_utils.py +++ b/hyperglass/configuration/models/_utils.py @@ -8,23 +8,8 @@ from pathlib import Path # Third Party from pydantic import HttpUrl, BaseModel - -def clean_name(_name): - """Remove unsupported characters from field names. - - Converts any "desirable" seperators to underscore, then removes all - characters that are unsupported in Python class variable names. - Also removes leading numbers underscores. - - Arguments: - _name {str} -- Initial field name - - Returns: - {str} -- Cleaned field name - """ - _replaced = re.sub(r"[\-|\.|\@|\~|\:\/|\s]", "_", _name) - _scrubbed = "".join(re.findall(r"([a-zA-Z]\w+|\_+)", _replaced)) - return _scrubbed.lower() +# Project +from hyperglass.util import clean_name class HyperglassModel(BaseModel): @@ -107,6 +92,49 @@ class AnyUri(str): return f"AnyUri({super().__repr__()})" +class StrictBytes(bytes): + """Custom data type for a strict byte string. + + Used for validating the encoded JWT request payload. + """ + + @classmethod + def __get_validators__(cls): + """Yield Pydantic validator function. + + See: https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types + + Yields: + {function} -- Validator + """ + yield cls.validate + + @classmethod + def validate(cls, value): + """Validate type. + + Arguments: + value {Any} -- Pre-validated input + + Raises: + TypeError: Raised if value is not bytes + + Returns: + {object} -- Instantiated class + """ + if not isinstance(value, bytes): + raise TypeError("bytes required") + return cls() + + def __repr__(self): + """Return representation of object. + + Returns: + {str} -- Representation + """ + return f"StrictBytes({super().__repr__()})" + + def validate_image(value): """Convert file path to URL path. diff --git a/hyperglass/configuration/models/credentials.py b/hyperglass/configuration/models/credentials.py index 3fa3593..8b78a53 100644 --- a/hyperglass/configuration/models/credentials.py +++ b/hyperglass/configuration/models/credentials.py @@ -4,7 +4,8 @@ from pydantic import SecretStr # Project -from hyperglass.configuration.models._utils import HyperglassModel, clean_name +from hyperglass.util import clean_name +from hyperglass.configuration.models._utils import HyperglassModel class Credential(HyperglassModel): diff --git a/hyperglass/configuration/models/networks.py b/hyperglass/configuration/models/networks.py index 7f64f6f..b1368f5 100644 --- a/hyperglass/configuration/models/networks.py +++ b/hyperglass/configuration/models/networks.py @@ -1,12 +1,11 @@ """Validate network configuration variables.""" # Third Party -# Third Party Imports from pydantic import Field, StrictStr # Project -# Project Imports -from hyperglass.configuration.models._utils import HyperglassModel, clean_name +from hyperglass.util import clean_name +from hyperglass.configuration.models._utils import HyperglassModel class Network(HyperglassModel): diff --git a/hyperglass/configuration/models/proxies.py b/hyperglass/configuration/models/proxies.py index 7b19f8a..9f93dc3 100644 --- a/hyperglass/configuration/models/proxies.py +++ b/hyperglass/configuration/models/proxies.py @@ -4,8 +4,9 @@ from pydantic import StrictInt, StrictStr, validator # Project +from hyperglass.util import clean_name from hyperglass.exceptions import UnsupportedDevice -from hyperglass.configuration.models._utils import HyperglassModel, clean_name +from hyperglass.configuration.models._utils import HyperglassModel from hyperglass.configuration.models.credentials import Credential diff --git a/hyperglass/configuration/models/routers.py b/hyperglass/configuration/models/routers.py index 141733c..88e3150 100644 --- a/hyperglass/configuration/models/routers.py +++ b/hyperglass/configuration/models/routers.py @@ -1,23 +1,21 @@ """Validate router configuration variables.""" # Standard Library +import os import re from typing import List, Optional +from pathlib import Path # Third Party from pydantic import StrictInt, StrictStr, validator # Project -from hyperglass.util import log +from hyperglass.util import log, clean_name from hyperglass.constants import Supported from hyperglass.exceptions import ConfigError, UnsupportedDevice from hyperglass.configuration.models.ssl import Ssl from hyperglass.configuration.models.vrfs import Vrf, Info -from hyperglass.configuration.models._utils import ( - HyperglassModel, - HyperglassModelExtra, - clean_name, -) +from hyperglass.configuration.models._utils import HyperglassModel, HyperglassModelExtra from hyperglass.configuration.models.proxies import Proxy from hyperglass.configuration.models.commands import Command from hyperglass.configuration.models.networks import Network @@ -72,7 +70,7 @@ class Router(HyperglassModel): return value @validator("name") - def clean_name(cls, value): + def validate_name(cls, value): """Remove or replace unsupported characters from field values. Arguments: @@ -83,6 +81,27 @@ class Router(HyperglassModel): """ return clean_name(value) + @validator("ssl") + def validate_ssl(cls, value, values): + """Set default cert file location if undefined. + + Arguments: + value {object} -- SSL object + values {dict} -- Other already-valiated fields + + Returns: + {object} -- SSL configuration + """ + if value is not None: + if value.enable and value.cert is None: + app_path = Path(os.environ["hyperglass_directory"]) + cert_file = app_path / "certs" / f'{values["name"]}.pem' + if not cert_file.exists(): + log.warning("No certificate found for device {d}", d=values["name"]) + cert_file.touch() + value.cert = cert_file + return value + @validator("commands", always=True) def validate_commands(cls, value, values): """If a named command profile is not defined, use the NOS name. diff --git a/hyperglass/execution/encode.py b/hyperglass/encode.py similarity index 100% rename from hyperglass/execution/encode.py rename to hyperglass/encode.py diff --git a/hyperglass/execution/execute.py b/hyperglass/execution/execute.py index b0cc6bb..d18ef85 100644 --- a/hyperglass/execution/execute.py +++ b/hyperglass/execution/execute.py @@ -23,6 +23,7 @@ from netmiko import ( # Project from hyperglass.util import log +from hyperglass.encode import jwt_decode, jwt_encode from hyperglass.constants import Supported from hyperglass.exceptions import ( AuthError, @@ -32,7 +33,6 @@ from hyperglass.exceptions import ( ResponseEmpty, ) from hyperglass.configuration import params, devices -from hyperglass.execution.encode import jwt_decode, jwt_encode from hyperglass.execution.construct import Construct @@ -258,6 +258,14 @@ class Connect: "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( diff --git a/hyperglass/util.py b/hyperglass/util.py index aad36cc..a60457c 100644 --- a/hyperglass/util.py +++ b/hyperglass/util.py @@ -27,6 +27,26 @@ def cpu_count(): return multiprocessing.cpu_count() +def clean_name(_name): + """Remove unsupported characters from field names. + + Converts any "desirable" seperators to underscore, then removes all + characters that are unsupported in Python class variable names. + Also removes leading numbers underscores. + + Arguments: + _name {str} -- Initial field name + + Returns: + {str} -- Cleaned field name + """ + import re + + _replaced = re.sub(r"[\-|\.|\@|\~|\:\/|\s]", "_", _name) + _scrubbed = "".join(re.findall(r"([a-zA-Z]\w+|\_+)", _replaced)) + return _scrubbed.lower() + + async def check_path(path, mode="r"): """Verify if a path exists and is accessible. @@ -512,3 +532,46 @@ def set_app_path(required=False): os.environ["hyperglass_directory"] = str(matched_path) return True + + +def import_public_key(app_path, device_name, keystring): + """Import a public key for hyperglass-agent. + + Arguments: + app_path {Path|str} -- hyperglass app path + device_name {str} -- Device name + keystring {str} -- Public key + + Raises: + RuntimeError: Raised if unable to create certs directory + RuntimeError: Raised if written key does not match input + + Returns: + {bool} -- True if file was written + """ + import re + from pathlib import Path + + if not isinstance(app_path, Path): + app_path = Path(app_path) + + cert_dir = app_path / "certs" + + if not cert_dir.exists(): + cert_dir.mkdir() + + if not cert_dir.exists(): + raise RuntimeError(f"Failed to create certs directory at {str(cert_dir)}") + + filename = re.sub(r"[^A-Za-z0-9]", "_", device_name) + ".pem" + cert_file = cert_dir / filename + + with cert_file.open("w+") as file: + file.write(str(keystring)) + + with cert_file.open("r") as file: + read_file = file.read().strip() + if not keystring == read_file: + raise RuntimeError("Wrote key, but written file did not match input key") + + return True