mirror of
https://github.com/checktheroads/hyperglass
synced 2024-05-11 05:55:08 +00:00
enable ssl certificate import for hyperglass-agent
This commit is contained in:
@@ -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
|
||||
|
14
hyperglass/api/models/cert_import.py
Normal file
14
hyperglass/api/models/cert_import.py
Normal file
@@ -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]
|
@@ -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:
|
||||
|
@@ -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.
|
||||
|
||||
|
@@ -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):
|
||||
|
@@ -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):
|
||||
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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.
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user